Kubernetes tips & tricks: особливості виконання graceful shutdown у NGINX та PHP-FPM

Типова умова при реалізації CI/CD в Kubernetes: додаток має вміти перед зупинкою не приймати нові запити клієнтів, а найголовніше — успішно завершувати вже існуючі.

Kubernetes tips & tricks: особливості виконання graceful shutdown у NGINX та PHP-FPM

Дотримання такої умови дозволяє досягти нульового простою під час деплою. Однак, навіть при використанні дуже популярних зв'язок (на зразок NGINX і PHP-FPM) можна зіткнутися зі складнощами, які спричинять сплеск помилок при кожному деплої.

Теорія. Як живе pod

Детально про життєвий цикл pod'а ми вже публікували цю статтю. У контексті аналізованої теми нас цікавить таке: у той момент, коли pod переходить у стан Припинення, на нього перестають надсилатися нові запити (pod видаляється зі списку endpoints для сервісу). Таким чином, щоб уникнути простою під час деплою, з нашого боку достатньо вирішити проблему коректної зупинки програми.

Також слід пам'ятати, що grace period за замовчуванням дорівнює 30 секунд: після цього під буде термінований і програма повинна встигнути обробити всі запити до цього періоду. Примітка: хоча будь-який запит, який виконується більше 5-10 секунд, вже є проблемним, і graceful shutdown йому вже не допоможе.

Щоб краще зрозуміти, що відбувається, коли pod завершує свою роботу, достатньо вивчити таку схему:

Kubernetes tips & tricks: особливості виконання graceful shutdown у NGINX та PHP-FPM

А1, B1 - Отримання змін про стан пода
A2 - Відправлення SIGTERM
B2 - Видалення pod'а з endpoints
B3 - Отримання змін (змінився список endpoints)
B4 — Оновлення правил iptables

Зверніть увагу: видалення endpoint pod'а та посилка SIGTERM відбувається не послідовно, а паралельно. А через те, що Ingress отримує оновлений список Endpoints не відразу, у pod будуть надсилатися нові запити від клієнтів, що викличе 500 помилок під час термінації pod'а (Детальніший матеріал з цього питання ми перекладали). Вирішувати цю проблему слід такими способами:

  • Надсилати у заголовках відповіді Connection: close (якщо це стосується HTTP-додатку).
  • Якщо немає можливості вносити зміни до коду, далі в статті описано рішення, яке дозволить обробити запити до кінця graceful period.

Теорія. Як NGINX та PHP-FPM завершують свої процеси

NGINX

Почнемо з NGINX, тому що з ним все більш-менш очевидно. Занурившись у теорію, ми дізнаємося, що NGINX має один майстер-процес і кілька «воркерів» — це дочірні процеси, які і обробляють клієнтські запити. Передбачено зручну нагоду: за допомогою команди nginx -s <SIGNAL> завершувати процеси або в режимі fast shutdown або в graceful shutdown. Очевидно, що нас цікавить саме останній варіант.

Далі все просто: потрібно додати до preStop-хук команду, яка надсилатиме сигнал про graceful shutdown. Це можна зробити в Deployment, у блоці контейнера:

       lifecycle:
          preStop:
            exec:
              command:
              - /usr/sbin/nginx
              - -s
              - quit

Тепер у момент завершення роботи pod'а у логах контейнера NGINX ми побачимо наступне:

2018/01/25 13:58:31 [notice] 1#1: signal 3 (SIGQUIT) received, shutting down
2018/01/25 13:58:31 [notice] 11#11: gracefully shutting down

І це означатиме те, що нам потрібно: NGINX очікує на завершення виконання запитів, після чого вбиває процес. Втім, нижче ще буде розглянуто поширену проблему, через яку навіть за наявності команди nginx -s quit процес завершується некоректно.

А на цьому етапі з NGINX закінчили: як мінімум по логах можна зрозуміти, що все працює так, як треба.

Як справи з PHP-FPM? Як він обробляє graceful shutdown? Давайте розумітися.

PHP-FPM

У випадку з PHP-FPM інформації трохи менше. Якщо орієнтуватися на офіційний мануал щодо PHP-FPM, то в ньому буде розказано, що приймаються такі POSIX-сигнали:

  1. SIGINT, SIGTERM - Fast shutdown;
  2. SIGQUIT - Graceful shutdown (те, що нам потрібно).

Інші сигнали в цьому завданні не потрібні, тому їх аналіз опустимо. Для коректного завершення процесу знадобиться написати наступний preStop-хук:

        lifecycle:
          preStop:
            exec:
              command:
              - /bin/kill
              - -SIGQUIT
              - "1"

На перший погляд, це все, що потрібно для виконання graceful shutdown в обох контейнерах. Проте завдання складніше, ніж здається. Далі розібрано два випадки, у яких graceful shutdown не працював і викликав короткочасну недоступність проекту під час деплою.

практика. Можливі проблеми з graceful shutdown

NGINX

Насамперед корисно пам'ятати: крім виконання команди nginx -s quit є ще один етап, який варто звернути увагу. Ми стикалися з проблемою, коли NGINX замість сигналу SIGQUIT все одно надсилав SIGTERM, через що запити не завершувалися коректно. Подібні випадки можна знайти, наприклад, тут. На жаль, конкретну причину такої поведінки нам встановити не вдалося: була підозра на версії NGINX, але вона не підтвердилася. Симптоматика полягала в тому, що в логах контейнера NGINX спостерігалися повідомлення "open socket #10 left in connection 5", після чого pod зупинявся.

Ми можемо спостерігати таку проблему, наприклад, щодо відповідей на потрібному нам Ingress'e:

Kubernetes tips & tricks: особливості виконання graceful shutdown у NGINX та PHP-FPM
Показники статус-кодів у момент деплою

В даному випадку ми отримуємо якраз 503 код помилки від самого Ingress: він не може звернутися до контейнера NGINX, оскільки вже недоступний. Якщо подивитися в логи контейнера з NGINX, у них наступне:

[alert] 13939#0: *154 open socket #3 left in connection 16
[alert] 13939#0: *168 open socket #6 left in connection 13

Після зміни стоп-сигналу контейнер починає зупинятися коректно: це підтверджується тим, що більше немає 503 помилка.

Якщо ви зустрілися зі схожою проблемою, є сенс розібратися, який стоп-сигнал використовується в контейнері і як виглядає preStop-хук. Цілком можливо, що причина полягає саме в цьому.

PHP-FPM ... і не тільки

Проблема з PHP-FPM описується тривіально: він не чекає завершення дочірніх процесів, термінує їх, через що виникають 502 помилки під час деплою та інших операцій. На bugs.php.net з 2005 року є кілька повідомлень про помилки (наприклад, тут и тут), у яких описується дана проблема. А ось у логах ви, найімовірніше, нічого не побачите: PHP-FPM оголосить про завершення свого процесу без будь-яких помилок чи сторонніх повідомлень.

Варто уточнити, що сама проблема може меншою чи більшою мірою залежати від самої програми та не виявлятися, наприклад, у моніторингу. Якщо ви все ж таки зіткнетеся з нею, то на думку спочатку приходить простий workaround: додати preStop-хук зі sleep(30). Він дозволить завершити всі запити, які були до цього (а нові ми не приймаємо, тому що під. вже в стані Припинення), а через 30 секунд сам pod завершиться сигналом SIGTERM.

Виходить, що lifecycle для контейнера буде виглядати так:

    lifecycle:
      preStop:
        exec:
          command:
          - /bin/sleep
          - "30"

Однак, через вказівку 30-секундного sleep ми сильно збільшимо час деплою, тому що кожен pod буде термінуватись мінімум 30 секунд, що погано. Що із цим можна зробити?

Звернемося до сторони, яка відповідає за безпосереднє виконання програми. У нашому випадку це PHP-FPM, Який за умовчанням не слідкує за виконанням своїх child-процесів: майстер-процес термінується відразу. Змінити цю поведінку можна за допомогою директиви process_control_timeoutяка вказує тимчасові ліміти для очікування сигналів від майстра дочірніми процесами. Якщо встановити значення в 20 секунд, цим покриється більшість запитів, що виконуються в контейнері, і після їх завершення майстер-процес буде зупинено.

З цим знанням повернемося до нашої останньої проблеми. Як уже згадувалося, Kubernetes не є монолітною платформою: на взаємодію між різними її компонентами потрібно деякий час. Це особливо актуально, коли ми розглядаємо роботу Ingress'ів та інших суміжних компонентів, оскільки через таку затримку в момент деплою легко отримати сплеск 500 помилок. Наприклад, помилка може виникати на етапі відправки запиту до upstream'у, але сама «тимчасова лаг» взаємодії між компонентами досить коротка — менше секунди.

Тому, в сукупності з уже згаданою директивою process_control_timeout можна використовувати наступну конструкцію для lifecycle:

lifecycle:
  preStop:
    exec:
      command: ["/bin/bash","-c","/bin/sleep 1; kill -QUIT 1"]

У такому разі ми компенсуємо затримку командою sleep і сильно не збільшуємо час деплою: адже помітна різниця між 30 секундами та однією? По суті «основну роботу» на себе бере саме process_control_timeout, а lifecycle використовується лише як «підстрахування» на випадок лага.

Взагалі кажучи, Описана поведінка та відповідний workaround стосуються не тільки PHP-FPM. Така ситуація може однак виникати під час використання інших ЯП/фреймворков. Якщо не виходить іншими способами виправити graceful shutdown — наприклад, переписати код так, щоб програма коректно обробляла сигнали завершення, можна застосувати описаний спосіб. Нехай він не найкрасивіший, але працює.

практика. Навантажувальне тестування для перевірки роботи

Навантажувальне тестування - один із способів перевірки, як працює контейнер, оскільки ця процедура наближає до реальних бойових умов, коли на сайт заходять користувачі. Для тестування наведених вище рекомендацій можна скористатися Яндекс.Танком: він відмінно покриває всі наші потреби Далі наведено поради та рекомендації щодо проведення тестування з наочним – завдяки графікам Grafana та самого Яндекс.Танку – прикладом з нашого досвіду.

Найголовніше тут - перевіряти зміни поетапно. Після додавання нового виправлення запускайте тестування та дивіться, чи змінилися результати порівняно з минулим запуском. В іншому випадку буде складно виявити неефективні рішення, а в перспективі можна тільки нашкодити (наприклад, збільшити час деплою).

Інший нюанс – дивіться логи контейнера під час його термінації. Чи фіксується інформація про graceful shutdown? Чи є помилки в логах при зверненні до інших ресурсів (наприклад, до сусіднього контейнера PHP-FPM)? Помилки самої програми (як описано вище з NGINX)? Сподіваюся, що вступна інформація з цієї статті допоможе краще розібратися, що відбувається з контейнером під час його термінування.

Отже, перший запуск тестування відбувався без lifecycle і без додаткових директив для сервера додатків (process_control_timeout в PHP-FPM). Метою цього тесту було виявлення приблизної кількості помилок (і вони взагалі). Також із додаткової інформації слід знати, що середній час деплою кожного пода становив близько 5-10 секунд до повної готовності. Результати такі:

Kubernetes tips & tricks: особливості виконання graceful shutdown у NGINX та PHP-FPM

На інформаційній панелі Яндекс.Танку видно сплеск 502 помилок, що стався в момент деплою і тривав у середньому до 5 секунд. Імовірно, це обривалися існуючі запити до старого pod'у, коли він термінувався. Після цього з'явилися 503 помилки, що стало результатом зупиненого контейнера NGINX, який також обірвав з'єднання через бекенд (через це до нього не зміг підключитися Ingress).

Подивимося, як process_control_timeout PHP-FPM допоможе нам чекати завершення child-процесів, тобто. виправити такі помилки. Повторний деплой вже з використанням цієї директиви:

Kubernetes tips & tricks: особливості виконання graceful shutdown у NGINX та PHP-FPM

Під час деплою 500 помилок більше немає! Деплой проходить успішно, graceful shutdown працює.

Однак варто згадати момент із Ingress-контейнерами, невеликий відсоток помилок у яких ми можемо отримувати через тимчасовий лаг. Щоб їх уникнути, залишається додати конструкцію зі sleep і повторити деплою. Втім, у нашому конкретному випадку змін не було видно (помилок знову нема).

Висновок

Для коректного завершення процесу ми очікуємо від застосування такої поведінки:

  1. Чекати кілька секунд, після чого припинити приймати нові з'єднання.
  2. Дочекатися завершення всіх запитів і закрити всі keepalive-підключення, які не виконують запити.
  3. Завершити свій процес.

Однак не всі програми вміють так працювати. Одним із вирішень проблеми в реаліях Kubernetes є:

  • додавання хука pre-stop, який чекатиме кілька секунд;
  • вивчення конфігураційного файлу нашого бекенда щодо відповідних параметрів.

Приклад з NGINX дозволяє зрозуміти, що навіть та програма, яка спочатку повинна коректно відпрацьовує сигнали до завершення, може цього не робити, тому критично перевіряти наявність 500 помилок під час деплою програми. Також це дозволяє дивитися на проблему ширше та не концентруватися на окремому pod'ї чи контейнері, а дивитися на всю інфраструктуру загалом.

Як інструмент для тестування можна використовувати Яндекс.Танк спільно з будь-якою системою моніторингу (у нашому випадку для тесту бралися дані з Grafana з бекендом у вигляді Prometheus). Проблеми з graceful shutdown добре видно при великих навантаженнях, які може генерувати benchmark, а моніторинг допомагає детальніше розібрати ситуацію під час або після тесту.

Відповідаючи на зворотний зв'язок за статтею: варто зазначити, що проблеми та шляхи їх вирішення тут описуються стосовно NGINX Ingress. Для інших випадків є інші рішення, які, можливо, ми розглянемо наступні матеріали циклу.

PS

Інше з циклу K8s tips & tricks:

Джерело: habr.com

Додати коментар або відгук