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

Имеется 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 hook – kubelet  его выполняет. Если хук продолжает выполнение после истечения 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




  • Re-play



  • Copy to Clipboard



  • Pause



  • Full View




Пишем 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.




Источник: https://rtfm.co.ua/ru/kubernetes-nginx-php-fpm-graceful-shutdown-izbavlyaemsya-ot-502-oshibok/



2023-01-03T01:50:56
DevOps