Чи завжди проби Kubernetes допомагають підвищити стабільність сервісів
Привіт! Це Сергій Калинець із Parimatch Tech.
Ось у нас є мікросервіси. Вони працюють у кубернетесі. Одні мікросервіси викликають інші, а також користуються різними кафками, базами даних тощо. Звісно, це все може завалитись будь-якої миті. Як можна підвищити стабільність окремо взятого сервісу?
Існує практика додавання індикації здоровʼя сервісів (health check). Зазвичай вона реалізується шляхом виставляння окремого ендпоінту, що публікує поточний стан сервісу. Всередині здійснюються перевірки ключових компонентів сервісу, включаючи його залежності. Якщо якийсь з компонентів не повертає потрібного статусу, сервіс вважається не здоровим.
Така конструкція дозволяє виявити проблеми, але як на них реагувати? У кубернетесі є так звані проби, які теж сигналізують про проблеми, і кластер певним чином реагує на них. І нерідко виникає бажання обʼєднати ці концепції задля підвищення загальної стабільності наших сервісів і тотального аптайму нашої системи.
У цій статті ми на реальному прикладі подивимось, що буде, якщо поєднати проби кубернетеса зі статусом зовнішніх залежностей сервісу (спойлер: нічого доброго). Ну і принагідно розповімо про інструменти і підходи, з допомогою яких ми все це з’ясовували, а також покажемо код.

Якась розумна людина свого часу сказала, що комп’ютери не дуже хороші в роздумах та побудові гіпотез, але їм немає рівних у їхньому підтвердженні. Можна довго сперечатися, наскільки ефективний фрагмент коду, і так ні до чого і не домовитися, особливо, якщо ви маєте рацію, а ваш опонент — прокачані софт-скіли :)
А ось комп’ютеру досить просто запустити код і він швидко і однозначно дасть відповідь, працює цей код чи ні. Тут, звичайно, варто пам’ятати, що машини роблять те, що ми їм говоримо, а не те, що ми хочемо, щоб вони робили. Тому потрібно відповідально підходити до формулювання завдання, тобто написання програми або скрипта. Але якщо все зроблено правильно, можна сміливо покластись на отриманий результат.
TL;DR;
Стаття вийшла довга, крім сухих цифр і результатів хотілося поділитися супутніми деталями (такий собі backstage), які теж цікаві, але все ж другорядні. Тому спочатку — результат для нетерплячих, а нижче — деталі для всіх, кому вони цікаві.
Ми зібрали невеликий полігон з кількох сервісів і поганяли на них тести навантаження в різних конфігураціях. Обробили результати і вийшла така картинка для презентації:

Там всього чотири стовпці, в яких:
all OK — це кількість успішних викликів за хвилину на нормально працюючому сервісі;
50% failures, no probes — кількість при сценарії, коли 50% викликів до залежності намертво зависають;
50% failures, liveness probe — те саме, але з активною liveness пробою;
50% failures, readiness probe — те саме, але з активною readiness пробою відповідно.
З діаграми видно, що liveness проба погіршує ситуацію, а readiness — не змінює. На цьому, власне, можемо розходитися, хіба що вам цікаво, що і як ми перевіряли, або хочете самостійно перевірити наші результати. Адже ще Цицерон писав, що не можна вірити всьому, що пишуть в інтернетах.
Що перевіряємо
Увесь код, який буде згадуватися нижче, ми виклали на гітхаб. Можете його забрати, подивитися, позапускати тощо.
Для тестів ми зібрали невеликий зоопарк. У ньому буде піддослідний сервіс, його залежність та трохи метушні. В якості піддослідного візьмемо мінімальний вебсервіс на .NET 6 (до речі, hey look ma, там всього один файл і майже немає фігурних дужок). Назвемо його Patient — тут ми згадуємо здоров’я сервісів, тому най буде пацієнтом.
Сервіс вийшов максимально простим. У ньому два ендпоінти, один (на діаграмі це check-nodep) просто повертає поточну дату, а інший (check-dep) робить виклик до залежності та повертає константу. Так ми емулюємо типову для багатьох сервісів ситуацію, коли певна залежність потрібна не всім викликам. Наприклад, апдейти ми постимо у шину, а запити на читання отримуємо з бази чи кешу. Або ми кешуємо запити до бази для читання, і реальний виклик до бази буде далеко не за кожної операції.

З основним сервісом зрозуміло, а що там з нашою залежністю? Її було б добре зробити у вигляді бази даних. Тож спочатку ми взяли MS SQL Server (камон, це ж .NET). Проте, коли ми дійшли до пункту «а тепер трохи все зламаємо», виявилося, що наш інструментарій вміє наводити метушню тільки для HTTP-трафіку. Тому SQL Server ми відкинули і замислилися, чого б додати такого HTTP-ічного. Звісно, це міг би бути ще один самописний сервіс, але наш мотиватор — це лінощі DRY / YAGNI / KISS принципи, тому ми взяли готову штуку.
Цією готовою штукою став httpbin.org — такий собі echo на стероїдах для HTTP. Він багато чого вміє, але ми будемо використовувати базову функціональність — простий GET запит.
У нас тут ще є startup probe — це тест, який кубернетес запускає після старту поди та робить її доступною лише після того, коли проба успішно проходить. У наших тестах наявність такої проби некритична, але загалом вона корисна для фреймворків на кшталт дотнета, де дуже актуальна проблема холодного старту (задумливість сервісу під час першого запиту). Під час розробки це просто трохи бісяча затримка після перезапуску, коли потрібно почекати пару секунд перед тим, щоб переконатися, що баг досі там і ваші правки його ніяк не виправили. Але для високонавантаженої системи це може стати доволі серйозною проблемою, тому що за ті самі кілька секунд, поки сервіс вийде на робочі обороти, йому можуть навалити десятки тисяч запитів, які мають реальний шанс завдати клопоту.
Гаразд, сервіс є, залежність є, що ще потрібне? Це дві речі — генератор проблем та генератор тестів.
Як ламаємо
Для наших тестів нам потрібен контрольований хаос. Ми вибрали сценарій, коли виклики залежності залипають, тобто відповідь не повертається протягом тривалого часу. Є багато способів це зробити, але наш вибір впав на один з найледачіших, тому що знову ж таки KISS.
У кубернетесах досить просто змінювати поведінку мережі за допомогою так званих service mesh (звісно, якщо її чи його встановити, хехе, але цей пост не про це — хай девопси це роблять). Зараз тільки дуже ліниві айтішники не знають, що означає цей термін, тому не будемо вдаватися в деталі, а скажемо лише, що вони вміють як створювати хаос, так і боротися з ним. Адже боротьба з хаосом та невизначеністю без зміни коду сервісів є одним із завдань service mesh.

У нашому випадку ми використовуємо Istio, це, мабуть, одне з найвідоміших рішень. Google повертає його у верху списку на запит «Service Mesh» (може тому що розробили її теж у Google?). Там є цікава штука під назвою fault injection, що дозволяє імітувати проблеми. Наприклад, можемо сказати, що всі запити, або якась їх частина, за певною адресою повинні падати з помилкою або затримуватися на якийсь час. У нашому сценарії ми використовували останнє — нескінченний таймаут. Насправді він цілком кінцевий, а саме 100 секунд, але в порівнянні зі звичайним часом відповіді це дуже багато.
Чому саме нескінченні таймаути, а не помилки 500, наприклад? Справа в тому, що збільшений у порівнянні з нормальною роботою час відповіді (не кажучи вже про нескінченні таймаути) має більш катастрофічні наслідки для надійності та продуктивності сервісу. Якщо коротко — більше шансів на виснаження пулу потоків, і може дійти до того, що всі ресурси будуть зайняті очікуванням відповіді, якої ніколи не отримають. І може банально не вистачити ресурсів для нових запитів, що призведе до недоступності вже самого сервісу.
Чим тестуємо
Ок, а що із генерацією навантаження? Тут все ще простіше, є купи різних інструментів, ось вони — зліва направо (пам’ятаєте цей мем?): Apache ab, Apache (знову ж таки) JMeter, wrk і так далі. Вони всі по-своєму хороші, і вибір інструменту часто доволі субʼєктивний. Тому ми будемо використовувати k6. Він простий, продуктивний та вміє багато чого. Нас у ньому підкуповує можливість написання тестових сценаріїв на JS, який багато хто не любить, але при цьому розуміють майже всі.
Для наших тестів ми хочемо дати трохи кіптяви, щоб дійсно привантажити наші сервіси, адже під навантаженням незначні на перший погляд перебої стрімко перетворюються на монстрів, що пожирають весь error budget. Є навіть такий термін — crack propagation. Це коли маленька тріщина швидко перетворюється на катастрофу. Він добре описаний у книзі «Release It», яку ми можемо сміливо рекомендувати до прочитання всім, хто не хоче вставати ночами і піднімати сервіси, що впали.

Щоб змусити сервіс задуматися, потрібно багато запитів. Всі (або, як мінімум, переважна більшість) інструменти лоад-тестування працюють наступним чином. Визначаються і масово запускаються сценарії, які складаються з одного або більше запитів на сервіс. Вводиться поняття користувача (або віртуального користувача, або сесії — у різних інструментах все по-різному називається, звичайно ж), що, по суті, є окремим потоком виконання. І визначається кількісна характеристика активності цих користувачів. Найпростіша — це кількість запитів, наприклад, якщо задати 100 користувачів та 200 запитів, то ми матимемо 100 сесій, кожна з яких навалить по 200 запитів, і в результаті ми отримуємо 100*200=20K запитів на сервісі.
У k6 можна задавати різні профілі навантаження, ми вибрали генерацію 900 викликів на секунду по кожному з двох сценаріїв. При цьому сам k6 визначає, скільки віртуальних користувачів йому створювати, щоб підтримувати такий темп (але ми про всяк випадок обмежили цю кількість зверху). Сам скрипт виглядає трохи складніше, ніж мав би — це тому, що ми хотіли у результатах бачити розбивку по сценаріях.
Тут невеликий відступ. За замовчуванням k6 може не дозволити створити навіть тисячу віртуальних користувачів, а натомість буде скаржитись на обмеження операційної системи щодо кількості доступних файлів. Як це обмеження обійти, написано на офіційному сайті.
Кожен з наших двох сценаріїв складається з одного виклику на кожен з ендпоінтів, ми їх будемо запускати разом і спостерігати за продуктивністю (а також кількістю помилок тощо). Як і у більшості програмістів, у нас теж криза вигадування імен, тому сценарії будуть називатися check_dep (виклик ендпоінту із залежністю) та check_nodep (виклик порожнього ендпоінту). Назви запам’ятовувати не обов’язково, але ми будемо їх використовувати далі за текстом та на картинках.
Результатом запуску буде ось приблизно таке простирадло з купою цифр, в яких можна поколупатися:

Сферичний ідеал
Так, з сетапом розібралися, час це все підпалювати. Спочатку оцінимо роботу сервісу в «ванільному» режимі, коли все добре, нічого не падає, і взагалі працює на межі своїх можливостей.
Отримуємо ось таку картину (ми прибрали частину виводу k6, залишивши тільки важливе):
Тривалість
✓ { scenario:check_dep }.......: max=540.52ms p(95)=101.53ms
✓ { scenario:check_nodep }.....: max=493.98ms p(95)=55.26ms
Помилки
http_req_failed................: 0.00% ✓ 0 ✗ 107796
✓ { scenario:check_dep }.......: 0.00% ✓ 0 ✗ 53866
✓ { scenario:check_nodep }.....: 0.00% ✓ 0 ✗ 53930
Продуктивність
http_reqs......................: 107796 1795.108355/s
✓ { scenario:check_dep }.......: 53866 897.021287/s
✓ { scenario:check_nodep }.....: 53930 898.087068/s
Приблизно однакові характеристики для обох ендпоінтів — запам’ятаймо їх, і давайте сіяти хаос.
Додаємо затримки
Як згадувалося вище, хаос полягає у імітуватуванні проблеми з роботою залежності.
Для цього ми створимо віртуальний сервіс, а в ньому пропишемо правило, щоб 50% запитів залипали на 100 секунд. Ось тут розписані деталі того, що відбувається, і як усе треба налаштовувати, а ось наша реалізація.
Запустимо знову наші тести і отримаємо ось таку картину:
Тривалість
✓ { scenario:check_dep }.......: max=1m0s p(95)=1m0s
✓ { scenario:check_nodep }.....: max=679.63ms p(95)=57.78ms
Помилки
http_req_failed................: 4.23% ✓ 3030 ✗ 68545
✓ { scenario:check_dep }.......: 17.01% ✓ 3030 ✗ 14775
✓ { scenario:check_nodep }.....: 0.00% ✓ 0 ✗ 53770
Продуктивність
http_reqs......................: 71575 944.000879/s
✓ { scenario:check_dep }.......: 17805 234.829698/s
✓ { scenario:check_nodep }.....: 53770 709.171181/s
Бачимо, що 17 відсотків check_dep запитів у нас впали з помилками, і також їх щільність зменшилась. Виріс лише час очікування — більшість запитів тривали хвилину.
З check_nodep ситуація приблизно, як і була. Що в принципі було очікувано.
В цілому, можна констатувати, що є погіршення ситуації. Давайте подивимося, як її поліпшить додавання проб.
Фіксимо проблеми liveness пробою
Тут ми підходимо до однієї з найрозповсюдженіших помилок, через яку і зʼявилася ця публікація. Спочатку розробники сервісів додають так званий /health ендпоінт, який каже, чи сервіс живий. У нього навалюють опитування всіх залежностей, і, якщо якась лежить, то і сервіс визнається хворим.
Ну а якщо це все запускається в кубернетесі, то на той самий ендпоінт навішується liveness-проба. Чому? Можливо, тому що проби зазвичай асоціюються з «health check» і до чого їх чіпляти, якщо не до /health ендпоінту?
Додамо сюди ще вимогу девопсів про обов’язкову наявність проб у всіх сервісів у кластері, бо інакше «ми не будемо таке підтримувати і взагалі не дозволимо задеплоїти», і отримуємо бомбу відкладеної дії, яка бахне в найвідповідальніший момент.
Відтворимо цей момент. Окремий /health ендпоінт реалізовувати не будемо, оскільки ми в ньому і так перевіряли б доступність залежності, а в нас уже є готовий ендпоінт, який робить виклик до цієї самої залежності, ну і відповідно впаде, якщо там щось не в порядку. Тому просто додамо liveness-пробу на наш /check_dep. За замовчуванням у них таймаут одна секунда, тому проба стрільне, якщо буде затримка. Ну і для більшої наочності зробимо так, щоб для спрацьовування проби було достатньо одного негативного результату. Тобто падати будемо впевнено та швидко.
Як тільки всі зміни застосовані, і ми запустимо наш тест, картинка виходить ось такою:
Тривалість
✓ { scenario:check_dep }.......: max=15.4s p(95)=8.05s
✓ { scenario:check_nodep }.....: max=615.27ms p(95)=65.51ms
Помилки
http_req_failed................: 82.47% ✓ 87443 ✗ 18581
✓ { scenario:check_dep }.......: 93.57% ✓ 48778 ✗ 3349
✓ { scenario:check_nodep }.....: 71.73% ✓ 38665 ✗ 15232
Продуктивність
http_reqs......................: 106024 1765.194854/s
✓ { scenario:check_dep }.......: 52127 867.863051/s
✓ { scenario:check_nodep }.....: 53897 897.33180/s
Дуже багато помилок навіть у сценарії check_nodep, якому взагалі не потрібна наша залежність, і проблеми з нею не повинні впливати. Затримки дикі, і лише продуктивність на висоті. Це пояснюється тим, що більшість часу обслуговування просто лежить, і запити відразу ж падають з помилками. Наочний приклад, чому не завжди висока швидкість — це добре. Тому треба дивитися на картину загалом і підбирати правильні метрики.
Чому так? Коли кубернетес зупиняє поду через погану liveness-пробу, вона перезапускається. А на старт потрібен певний час, протягом якого запити не будуть оброблятися (пам’ятаємо про холодний старт). Так само через те, що проблеми зі зв’язком будуть у всіх под, вони можуть стати недоступними одночасно.
Проте це лише частина біди. З часом пода, яка часто перезапускається, переходить у статус CrashLoopBackOff. У цьому статусі вона перебуває якийсь час, протягом якого кубернетес не робить спроб її підняти (дає їй охолонути, сподіваючись, що згодом поду попустить). Якщо ж після закінчення цього часу після чергового рестарту поди вона знову падатиме, вона знову стане CrashLoopBackOff, але вже на більш тривалий період. І так цей період буде збільшуватися, що в кінці призведе до того, що пода дуже довго не запускатиметься (навіть якщо проблема давно розсмокталася). У нашому випадку через кілька хвилин поди майже завжди недоступні.
Перевіряємо readiness-пробу
Liveness-проба не допомогла, а навіть зробила гірше. Спробуємо readiness-пробу? Знову ж таки освіжимо пам’ять — це проба, яка при негативному результаті прибирає на якийсь час поду з пулу лоад-балансера, і на неї перестають надходити запити. Пода при цьому не вбивається. Це передбачено для випадків, коли пода відходить у себе і не може обробляти нові запити, але як тільки розгрібається, то знову зможе:) Але чи допоможе така проба при проблемах із залежностями? Ось наша конфігурація, а ось результати її тестування:
Тривалість
✓ { scenario:check_dep }.......: max=1m0s p(95)=1m0s
✓ { scenario:check_nodep }.....: max=1.39s p(95)=293.34ms
Помилки
http_req_failed................: 4.30% ✓ 3083 ✗ 68568
✓ { scenario:check_dep }.......: 17.06% ✓ 3083 ✗ 14986
✓ { scenario:check_nodep }.....: 0.00% ✓ 0 ✗ 53582
Продуктивність
http_reqs......................: 71651 939.294339/s
✓ { scenario:check_dep }.......: 18069 236.871913/s
✓ { scenario:check_nodep }.....: 53582 702.422427/s
Тут ситуація краща, ніж з liveness, і майже ідентична конфігурації, де жодних проб не було. Ми чекали гіршого результату, наприклад, помилок по check_nodep, проте маємо, що маємо, і потрібно це все якось обґрунтувати :)
По-перше, навантаження в нашому сценарії не максимальне, і є ще запас міцності, і навіть якщо одна пода буде недоступна короткий час, друга може цілком з таким навантаженням упоратися. По-друге, немає експоненційних затримок, та й поди випадають нечасто (перевірка робиться раз на три секунди і стріляє з 50% ймовірністю) і ненадовго (за замовчуванням на одну секунду). А по-третє, якщо немає вільних под, кубернетес затримує запит, поки вони не з’являться, тому в нас і максимальне значення тривалості запитів check_nodep близько однієї секунди.
Разом виходить, що додавання readiness-проби і не заважає, і не допомагає нашим проблемам, тобто її додавання не дуже доцільне.
Що ж робити?
Якщо не проби, то що? Натомість краще використовувати патерни стабільності («resilience patterns» англійською). У нашому випадку допоможе комбінація таймауту та повторення запиту. За вимірами бачимо, що зазвичай запити завершуються в межах 100 ms. Так що можемо припустити, що якщо, наприклад, пройшло більше 200 ms з початку запиту, то його можна закінчувати і натомість спробувати запустити інший. І робити так кілька разів. У результаті це має збільшити час відгуку деяких запитів, але не було б помилок.
Але це все поки що теорія (хоч і переконлива), перевірка якої заслуговує на окрему статтю.
Висновок
Комусь може здатися, що ця стаття дискредитує саму ідею проб у кубернетесі, але це геть не так. Нашою метою було показати, що потрібно відповідально підходити до їхнього використання, усвідомлювати принципи роботи проб і розуміти наслідки, які вони спричиняють.
Readiness probes чудово допомагають сервісу охолонути, щоб потім з новими силами повернутись до роботи. Liveness probes дозволяють вивести сервіс з екзистенціальної кризи і почати життя з нової сторінки.
Проте не треба їх плутати з показниками здоровʼя сервісу, у яких доволі часто перевіряються саме залежності. А використання залежностей в пробах — погана практика, у чому ми мали можливість переконатись. Тому, якщо до вас прийдуть з вимогами додати проби на залежності — покажіть їм цю статтю.
13 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівliveness пробы лучше вообще отключать, единственное что они могут помочь сделать — это рестартнуть при дедлоке, при коде устойчивом к concurrency они только будут вызывать рестарты при перегрузке (что еще более усугубит)
Рубрика «вредные советы»?
Чтобы охладить траханье сервиса есть readiness probes. А ввести сервис в какой-то corrupted state есть много разных удивительных способов.
чтобы снизить градус любви на сервисе, есть более удобные способы. ливнесс только даёт мнимую уверенность что «когда нибудь у меня случиться unrecoverable state и он мне поможет» , за это время принесёт мегатонну проблем. Особенно когда в него вопреки рекомендациям пихают хелсчеки в бд, сторонние сервисы и делают его == readiness probe
Про health check-и бд у меня есть хороший пример. Представим кластер K8s, в который задеплоена MongoDB и сервис, котоый хранит в ней свое состояние. Есть Mongo Driver, который, как рекомендуется, является singleton-ом. Этот Mongo Driver на страрте приложения получает connection string, резолвит DNS и коннектися к бд. В процессе работы сервиса по произвольной причине (например maintenance) поды монги рестартуют и им ассайнятся новые IP. Mongo Driver продолжает безуспешные попытки подконнектиться. Пара-пара-пам: Всё.
А чтобы он не приносил проблем его нужно уметь готовить.
Для подобных ситуаций есть внешние чеки, которые приносят алярму в уютненький корпоративный слак/телефон и тп, то есть что-то внезапное, что никто не мог подумать. И это надо после алярмы починить, чтобы больше так не случалось.
Если проблема изначально известная, и затыкается ливнесс пробой — это наверняка сигнал что что-то идёт не так на уровне системы (Устройство для перемещения при повреждении нижних конечностей).
Солюшн — например не деплоить базу в кубернетес. Или модифицировать класс бд, чтобы в фоне проверял живость соединения и переподключал в таких ситуациях (тоже устройство, но не приводит к рестарту).
единственное место где я считаю оправданы ливнесс пробы — это старые легаси сервисы, там где дешевле рестартить что-то с дедлоком, чем инвестировать время в ремонт. Но это опять же устройство
А как же fail fast подход? Для чего городить кучу инфраструктурного кода внутри сервиса, который еще и поддерживать нужно, будить инженера ночью и т.п., если можно сделать resilient сервис, который, как паравозик, смог?
Добавлю оговорку, на всякий случай: я не топлю за то, чтобы все дыры сервиса затыкать рестартами. Это конечно, зло. Алерты — это не вместо, а вместе. И каждый случай нужно разбирать и улучшать, как предлагается выше.
У нативного монго драйвера (как и в остальных БД драйверах) в большинстве реализаций есть конфигурации реконнекта. Для пользователей сервиса это не потеря траффика (просто небольшой лаг). Оч помогает на временных сетевых проблемах, которые периодически случаются (этого не избежать). То что он не хендлит смену днс — это можно модифицировать (потому что никто и представить такое не мог), так что обернуть это в доп слой который это делает — вполне себе решение соответствующее реалиям.
Рестарт сервиса — это потеря траффика. Что не Ъ.
Но опять же, нормальный солюшн — чинить вот эти вот смены ДНС, потому что зачем создавать себе лишние проблемы. Разве что чтобы потом их героически преодолевать
У сервиса есть реплики для HA, поэтому рестарт одного инстанса != downtime.
DNS монги в K8s — это просто частный наглядный пример. Который дешевле и недежнее починить health check-ом, чем усложнять инфраструктуру сервиса.
Есть много других примеров почему сервис может перейти в corrupted state. Те же deadlock-и. И невозможно отловить все подобные кейсы или гарантировать, что их никогда не произойдет. И, если выбирать будить челокека on-call или дать сервису самому прийти в себя по доброй воле, для меня выбор очевиден. Для человека на on-call тоже 😄 А утром можно (и нужно) заниматься разбором инцидента.
Прелесть будить онколл инженеров по очереди в том, что по мере разбирательств в причинах инцидентов — количество алярмов и неучтённых кейсов будет стремиться у нулю. Прям как в книжке по SRE. Хотя опять же, если это не нужно никому и бизнесу пофиг, это не нужно.
Тоже норм вариант. И текучку кадров заодно так можно увеличить.
Если это добровольно и за это платят доп деньги? не думаю)
Но в таком случае и мотивация делать надежные сервисы может страдать. Или наоборот могут появляться скрытые мотивы 🙂
Есть такой риск, но хорошо поставленные целевые метрики — и всё хорошо )