Kubernetes: NGINX/PHP-FPM graceful shutdown – избавляемся от 502 ошибок

Автор: | 02/24/2021
 

Имеется PHP-приложение, работает в Kubernetes в подах с двумя контейнерами – NGINX и PHP-FPM.

Проблема: во время скейлинга приложения начинают проскакивать 502 ошибки. Т.е. при остановке подов – некорректно отрабатывает завершение подключений.

Рассмотрим процесс остановки подов вообще, и особенности NGINX и PHP-FPM в частности.

Тестировать будем приложение в AWS Elastic Kubernetes Service с помощью Yandex.Tank.

Ingress создаёт AWS Application Load Balancer с помощью AWS ALB Ingress Controller.

Для управления контейнерами на Kubernetes WorkerNodes испольузется Docker.

Pod Lifecycle – Termination of Pods

Посмотрим, как вообще происходит процесс остановки и удаления подов.

Итак, под – это процесс(ы), запущенные на WorkerNode, для остановки которых используются стандартные сигналы IPC (Inter Process Communication).

Что бы дать возможность поду (процессу/ам) закончить все его текущие операции – система управления контейнерами (container runtime) пытается мягко завершить его работу (graceful shutdown), отправляя сигнал SIGTERM процессу с PID 1 в каждом контейнере этого пода (см. docker stop). При этом, кластер запускает отсчёт grace period перед тем, как жёстко убить под отправкой сигнала SIGKILL.

При этом, можно переопределить сигнал для мягкой остановки используя STOPSIGNAL в образе, из которого запускался контейнер.

Итак, процесс удаления пода выглядит следующим образом:

  1. мы выполняем kubectl delete pod или kubectl scale deployment – запускается процесс удаления подов и стартует таймер отсчёта grace period с дефолтным значением в 30 секунд
  2. API-сервер обновляет статус пода – из Running он становится Terminating (см. Container states). На WorkerNode, на которой этот под запущен, kubelet получает обновление статус этого пода и начинает процесс его остановки:
    1. если для контейнера(ов) в поде есть preStop hookkubelet  его выполняет. Если хук продолжает выполнение после истечения grace period – добавляется ещё 2 секунды на его завершение. При необходимости можно изменить дефолтные 30 секунд используя terminationGracePeriodSeconds
    2. после завершения preStop хука – kubelet отправляет указание Docker на остановку контейнеров, и Docker отправляет сигнал SIGTERM процессу с PID 1 в каждом контейнере. При этом контейнеры в поде получают сигнал в случайном порядке.
  3. одновременно с началом процесса graceful shutdown – Kubernetes Control Plane (его kube-controller-manager) удаляет останавливаемый под из списка ендпоинтов (см. Kubernetes – Endpoints), и соответствующий Service перестаёт отправлять новые подключения на останавливаемый под
  4. по завершению grace period, kubelet триггерит force shutdown – Docker отправляет сигнал SIGKILL всем оставшимся процесам во всех контейнерах пода, который они проигнорировать не могут, и мгновенно умирают, аварийно завершая все свои операции
  5. kubelet триггерит удаление объекта пода из API-сервера
  6. API-сервер удаляет запись о поде из базы, и под становится недоступен

Наглядная табличка:

Собственно, тут возникает две проблемы:

  1. сам NGINX и PHP-FPM воспринимают SIGTERM как “жестокое убийство”, и завершают работу немедленно, не заботясь о корректном завершении текущих подключений (см. Controlling nginx и php-fpm(8) – Linux man page)
  2. шаги 2 и 3 – отправка SIGTERM и удаление ендпоинта – выполняются одновременно. Однако, обновление данных в Ingress Service происходит не моментально, и под может начать умирать раньше, чем Ingress перестанет на него слать трафик, соотвественно – получим 502, т.к. процесс в поде уже не может принимать подключения

Т.е. в первом случае, если у нас есть подключение к NGINX с keep-alive – то NGINX при выполнении fast shutdown просто обрубит его, а клиент получит 502 ошибку, см. Avoiding dropped connections in nginx containers with “STOPSIGNAL SIGQUIT”.

NGINX STOPSIGNAL и 502

Попробуем воспроизвести проблему с NGINX.

Возмём пример из поста по ссылке выше, и задеплоим его в Кубер.

Пишем Dockerfile:

FROM nginx

RUN echo 'server {\n\
    listen 80 default_server;\n\
    location / {\n\
      proxy_pass      http://httpbin.org/delay/10;\n\
    }\n\
}' > /etc/nginx/conf.d/default.conf

CMD ["nginx", "-g", "daemon off;"]

Тут NGINX выполняет proxy_pass на http://httpbin.org, который отвечает с задержкой в 10 секунд – эмулируем работу PHP-бекенда.

Собираем, пушим в репозиторий:

docker build -t setevoy/nginx-sigterm .
docker push setevoy/nginx-sigterm

Пишем Deployment с 10 подами из собранного образа.

Тут приведу полный файл, с Namespace, Service и Ingress, далее только те части, которые будут меняться:

---
apiVersion: v1
kind: Namespace
metadata:
  name: test-namespace
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: test-deployment
  namespace: test-namespace
  labels:
    app: test
spec:
  replicas: 10
  selector:
    matchLabels:
      app: test
  template:
    metadata:
      labels:
        app: test
    spec:
      containers:
      - name: web
        image: setevoy/nginx-sigterm
        ports:
        - containerPort: 80
        resources:
          requests:
            cpu: 100m
            memory: 100Mi
        readinessProbe:
          tcpSocket:
            port: 80
---
apiVersion: v1
kind: Service
metadata:
  name: test-svc
  namespace: test-namespace
spec:
  type: NodePort
  selector:
    app: test
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: test-ingress
  namespace: test-namespace
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}]'
spec:
  rules:
  - http:
      paths:
      - backend:
          serviceName: test-svc
          servicePort: 80

Деплоим:

kubectl apply -f test-deployment.yaml
namespace/test-namespace created
deployment.apps/test-deployment created
service/test-svc created
ingress.extensions/test-ingress created

Проверяем Ingress:

curl -I aadca942-testnamespace-tes-5874-698012771.us-east-2.elb.amazonaws.com
HTTP/1.1 200 OK

Запущено 10 подов:

kubectl -n test-namespace get pod
NAME                              READY   STATUS    RESTARTS   AGE
test-deployment-ccb7ff8b6-2d6gn   1/1     Running   0          26s
test-deployment-ccb7ff8b6-4scxc   1/1     Running   0          35s
test-deployment-ccb7ff8b6-8b2cj   1/1     Running   0          35s
test-deployment-ccb7ff8b6-bvzgz   1/1     Running   0          35s
test-deployment-ccb7ff8b6-db6jj   1/1     Running   0          35s
test-deployment-ccb7ff8b6-h9zsm   1/1     Running   0          20s
test-deployment-ccb7ff8b6-n5rhz   1/1     Running   0          23s
test-deployment-ccb7ff8b6-smpjd   1/1     Running   0          23s
test-deployment-ccb7ff8b6-x5dc2   1/1     Running   0          35s
test-deployment-ccb7ff8b6-zlqxs   1/1     Running   0          25s

Готовим load.yaml для Yandex.Tank:

phantom:
  address: aadca942-testnamespace-tes-5874-698012771.us-east-2.elb.amazonaws.com
  header_http: "1.1"
  headers:
     - "[Host: aadca942-testnamespace-tes-5874-698012771.us-east-2.elb.amazonaws.com]"
  uris:
    - /    
  load_profile:
    load_type: rps
    schedule: const(100,30m)
  ssl: false
console:
  enabled: true
telegraf:
  enabled: false
  package: yandextank.plugins.Telegraf
  config: monitoring.xml

Тут выполняем 1 запрос в секунду к подам за нашим Ingress (и NGINX в каждом будет ждать 10 секунд ответа от своего “бекенда” перед тем, как ответить 200 нашему Танку).

Запускаем тесты:

Пока всё хорошо.

Теперь скейлим поды с 10 до 1:

kubectl -n test-namespace scale deploy test-deployment --replicas=1
deployment.apps/test-deployment scaled

Поды перешли в Terminating:

kubectl -n test-namespace get pod
NAME                              READY   STATUS        RESTARTS   AGE
test-deployment-647ddf455-67gv8   1/1     Terminating   0          4m15s
test-deployment-647ddf455-6wmcq   1/1     Terminating   0          4m15s
test-deployment-647ddf455-cjvj6   1/1     Terminating   0          4m15s
test-deployment-647ddf455-dh7pc   1/1     Terminating   0          4m15s
test-deployment-647ddf455-dvh7g   1/1     Terminating   0          4m15s
test-deployment-647ddf455-gpwc6   1/1     Terminating   0          4m15s
test-deployment-647ddf455-nbgkn   1/1     Terminating   0          4m15s
test-deployment-647ddf455-tm27p   1/1     Running       0          26m
...

И ловим наши 502 ошибки:

Теперь в Dockerfile добавляем STOPSIGNAL SIGQUIT:

FROM nginx

RUN echo 'server {\n\
    listen 80 default_server;\n\
    location / {\n\
      proxy_pass      http://httpbin.org/delay/10;\n\
    }\n\
}' > /etc/nginx/conf.d/default.conf

STOPSIGNAL SIGQUIT

CMD ["nginx", "-g", "daemon off;"]

Билдим, пушим:

docker build -t setevoy/nginx-sigquit .
docker push setevoy/nginx-sigquit

Обновляем деплоймент:

...
    spec:
      containers:
      - name: web
        image: setevoy/nginx-sigquit
        ports:
        - containerPort: 80
...

Передеплоиваем, и проверяем ещё раз.

Запускаем тесты:

Скейлим деплоймент:

kubectl -n test-namespace scale deploy test-deployment --replicas=1
deployment.apps/test-deployment scaled

А ошибок по-прежнему нет:

Прекрасно.

Трафик, preStop и sleep

И всё-таки, если повторить тесты несколько раз, то иногда 502 ошибки всё-таки проскакивают:

Тут мы уже сталкиваемся со второй проблемой – обновление ендпоинтов выполняется параллельно с отправкой SIGTERM.

Добавим preStop хук со sleep, что бы дать время на обновление ендпоинтов – тогда после получения команды на удаление пода, kubelet выждет 5 секунд перед отправкой SIGTERM, за которые Ingress успеет обновить список ендпоинтов:

...
    spec:
      containers:
      - name: web
        image: setevoy/nginx-sigquit
        ports:
        - containerPort: 80
        lifecycle:
          preStop:
            exec:
              command: ["/bin/sleep","5"]
...

Повторяем тесты – всё хорошо.

Выкатили фикс в Production – всё работает замечательно, ошибки пропали, автотесты теперь работают без проблем.

С PHP-FPM проблемы, как оказалось, не было вообще,  т.к. исходный образ изначально был со STOPSIGNAL SIGQUIT.

Другие варианты решения

Конечно, по ходу поиска решения были переброваны самые разные варианты. См. ссылки на интересные материалы в конце, а тут опишу их кратко.

preStop и nginx -s quit

Одним из вариантов было добавить в preStop хук отправку сигнала QUIT в NGINX:

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

Но не помогло. Не очень понятно почему, потому как идея вроде бы правильная – вместо того, что бы ожидать TERM от Kubernetes/Docker – мы сами мягко стопаем NGINX, отправляя ему QUIT.

Но можно попробовать.

NGINX + PHP-FPM, supervisord и stopsignal

Наше приложение работает в двух отдельных контейнерах NGINX и PHP-FPM.

Но в процессе поиска решения пробовал использовать образ, в котором оба сервиса собраны в одном образе, например trafex/alpine-nginx-php7.

В нём пробовал добавлять параметр stopsignal и для NGINX, и для PHP-FPM со значением QUIT – но тоже не помогло, хотя идея тоже вроде правильная.

Тем не менее – как вариант на попытку.

PHP-FPM и process_control_timeout

В посте Graceful shutdown in Kubernetes is not always trivial и на Stackoveflow в вопросе Nginx / PHP FPM graceful stop (SIGQUIT): not so graceful есть упоминание о том, что мастер-процесс FPM убивается не дожидаясь своих дочерних процессов, что также может приводить к 502.

Не наш случай, но стоит обратить внимание на параметр process_control_timeout.

NGINX, HTTP и keep-alive session

В принципе, можно было бы указать приложению отправлять заголовок [Connection: close] – тогда клиент по завершении передачи данных закрывал бы соединение, и это уменьшило бы вероятность 502-х. Но они всё равно будут, если NGINX получит SIGTERM в процессе обработки запроса от клиента, а т.к. у нас “сзади” ещё PHP, который ходит в базу – то некоторые запросы могут обрабатываться по 5-10 и более секунд.

См. HTTP persistent connection.

Ссылки по теме