Мы в Debricked уже достаточно давно используем Symfony на нашем веб-сервере. За все это время он очень хорошо послужил нам, и когда разработчики Symfony анонсировали компонент Messenger в Symfony 4.1, мы уже с нетерпением ждали, когда сможем опробовать его. С тех пор мы использовали этот компонент для отправки электронных писем в асинхронную очередь.

Однако недавно у нас возникла необходимость вынести обработку событий GitHub, которые мы получаем из нашей интеграции с GitHub, из нашего веб-сервера в отдельный микросервис (чтобы повысить производительность). Мы решили прибегнуть к паттерну производитель/потребитель (producer/consumer), который предоставляет компонент Messenger, поскольку он позволит нам асинхронно отправлять различные события в очередь, а затем немедленно подтверждать их прием в GitHub.

Однако в сравнении с отправкой электронных писем обработка некоторых событий GitHub может занимать достаточно много времени. Мы также не можем контролировать, когда эти события произойдут, поэтому нагрузка может быть совершенно непредсказуемой и нерегулярной. Нам нужно было решение, позволяющее автоматическое масштабирование наших потребителей.

Kubernetes Autoscaling в помощь

Поскольку большая часть нашей инфраструктуры уже была развернута в Kubernetes в Google Cloud, попытка задействовать ее для наших потребителей была более чем оправдана. Kubernetes предлагает нечто под названием Horizontal Pod Autoscaler, который позволяет автоматически масштабировать ваши поды в зависимости от какой-нибудь метрики.

Это средство для автомасштабирования уже имеет встроенную метрику CPU. Мы можем установить целевую загрузку CPU для наших подов, и Kubernetes автоматически отрегулирует количество подов, чтобы соответствовать установленному нами показателю. Мы будем использовать эту метрику, чтобы гарантировать, что количество подов, на которых работают наши потребители, всегда соответствует текущей нагрузке.

Подготовка образа Docker к запуску потребителей

Убедившись, что Kubernetes сможет помочь нам в решении нашей задачи, мы теперь должны создать подходящий образ Docker для запуска наших подов. За основу образа с потребителями мы берем наш базовый образ, который, в свою очередь, берет за основу Debian и содержит логику нашего бэкенда, включая логику для потребителя/обработчика событий GitHub.

Для контроля работы потребителя Symfony рекомендует инструмент под названием «Supervisor», поэтому мы добавляем его в наш образ и запускаем его в директиве CMD Docker, как показано в примере кода ниже:

FROM your-registry.com:5555/your_base_image:latest

USER root

WORKDIR /app

RUN apt update && apt install -y supervisor

# Cleanup
RUN rm -rf /var/lib/apt/lists/* && apt clean

COPY ./pre_stop.sh /pre_stop.sh
COPY ./supervisord_githubeventconsumer.conf /etc/supervisord.conf
COPY ./supervisord_githubeventconsumer.sh /supervisord_githubeventconsumer.sh

CMD supervisord -c /etc/supervisord.conf

Файл конфигурации

Если вы внимательно посмотрите на этот код, то вы заметите, что мы также добавляем два файла, которые связаны с запуском Supervisor(d). Эти файлы выглядят следующим образом:

[supervisord]
nodaemon=true
user=root

[program:consume-github-events]
command=bash /supervisord_githubeventconsumer.sh
directory=/app
autostart=true
# Перезапуск при получении неожиданных кодов завершения
autorestart=unexpected
# Ожидаем код завершения 37, возвращаемый при наличии стоп-файла
exitcodes=37
startretries=99999
startsecs=0
# Ваш пользователь
user=www-data
killasgroup=true
stopasgroup=true
# Число потребителей на первоначальный запуск. Мы вынуждены использовать большое значение, потому что мы привязаны к операциям ввода/вывода
numprocs=70
process_name=%(program_name)s_%(process_num)02d
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

Bash-скрипт

if [ -f "/tmp/debricked-stop-work.txt" ]; then
  rm -rf /tmp/debricked-stop-work.txt
  exit 37
else
  php bin/console messenger:consume -m 100 --time-limit=3600 --memory-limit=150M githubevents --env=prod
fi

Это довольно стандартная конфигурация Supervisor, но пара элементов все-таки заслуживают внимания. Мы выполняем bash-скрипт, который, в свою очередь, либо завершается с кодом 37 (подробнее об этом в следующем разделе), либо выполняет команду consume компонента Messenger, используя нашего потребителя событий GitHub. Мы также настраиваем Supervisor на автоматический перезапуск при непредвиденных сбоях, то есть при любом коде состояния, отличном от 37.

В нашем случае мы будем одновременно запускать большое количество потребителей (70), из-за того, что нагрузка очень сильно зависит от операций ввода/вывода (IO-bound). Запуская 70 потребителей одновременно, мы можем полностью загрузить наш CPU. Это необходимо для правильной работы метрики CPU Horizontal Pod Autoscaler, так как в противном случае нагрузка была бы слишком низкой, из-за чего масштабирование зависало бы на минимальном количестве реплик, независимо от длины очереди.

Изящное уменьшение количества подов/потребителей

Когда Autoscaler решает, что нагрузка слишком высокая, он запускает новые поды. Благодаря асинхронной природе компонента messenger нам не нужно беспокоиться о таких проблемах параллелизма, как состояние гонки. Все просто будет работать из коробки, поэтому увеличение количества подов/потребителей не вызовет никаких проблем, но что произойдет, когда нагрузка станет слишком низкой, и Autoscaler решит уменьшить масштаб инстанса?

По умолчанию Autoscaler просто резко завершает работающий под, если решит, что он больше не нужен. Это, конечно же, представляет проблему для потребителя, поскольку он может находиться в процессе обработки сообщения. Нам нужен способ изящно завершить работу пода, обработать сообщение, с которым мы сейчас имеем дело, и затем выйти.

В предыдущем разделе Dockerfile вы могли заметить, что мы скопировали файл с именем pre_stop.sh в наш образ. Этот файл выглядит следующим образом:

# Этот скрипт выполняется при завершении пода

touch /tmp/debricked-stop-work.txt
chown www-data:www-data /tmp/debricked-stop-work.txt
# Приказываем воркерам остановиться
php bin/console messenger:stop-workers --env=prod
# Ждем удаления файла
until [ ! -f /tmp/debricked-stop-work.txt ]
do
	echo "Stop file still exists"
	sleep 5
done

echo "Stop file found, exiting"

При выполнении этот bash-скрипт создаст файл /tmp/debricked-stop-work.txt. Поскольку скрипт также вызывает php /app/bin/console messenger:stop-workers, он аккуратно остановит текущих воркеров/потребителей, в результате чего Supervisord перезапустит supervisord_githubeventconsumer.sh. Когда скрипт перезапустится, он сразу же завершится с кодом состояния 37, потому что уже существует файл /tmp/debricked-stop-work.txt. Это в свою очередь спровоцирует завершение Supervisor, потому что 37 — это ожидаемый нами код завершения работы.

Как только Supervisor завершит работу, то же сделает и образ Docker, поскольку Supervisor является нашим CMD, и pre_script.sh также завершится, потому что supervisord_githubeventconsumer.sh удалит файл /tmp/debricked-stop-work.txt перед выходом с кодом 37. Вот так мы и добились изящного завершения работы!

Но вы можете задаться вопросом, когда же выполняется pre_script.sh? Мы выполним его в рамках PreStop события жизненного цикла контейнера Kubernetes.

Это событие происходит каждый раз, когда контейнер должен быть остановлен, например, при завершении работы в рамках автоматического масштабирования. Это блокирующее событие, что означает, что контейнер не будет удален до завершения этого скрипта — это как раз то, что мы хотим.

Чтобы настроить событие жизненного цикла, нам просто нужно добавить несколько строк кода в нашу конфигурацию развертывания, как показано во фрагменте ниже:

---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
	name: gheventconsumer
	namespace: default
	labels:
    	app: gheventconsumer
    	tier: backend
spec:
	replicas: 1
	selector:
    		matchLabels:
        			app: gheventconsumer
	template:
    	metadata:
        	labels:
            	app: gheventconsumer
    	spec:
        	terminationGracePeriodSeconds: 240 # Consuming might be slow, allow for 4 minutes graceful shutdown
        	containers:
            	- name: gheventconsumer
              	  image: your-registry.com:5555/your_base_image:latest
              	  imagePullPolicy: Always
              	  Lifecycle: # ← this let’s shut down gracefully
                  	  preStop:
                      		  exec:
                          		  command: ["bash", "/pre_stop.sh"]
              	  resources:
                  	  requests:
                      	  cpu: 0.490m
                      	  memory: 6500Mi
---
apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
	name: gheventconsumer-hpa
	namespace: default
	labels:
    	app: gheventconsumer
    	tier: backend
spec:
	scaleTargetRef:
    	kind: Deployment
    	name: gheventconsumer
    	apiVersion: apps/v1
	minReplicas: 1
	maxReplicas: 5
	metrics:
    	- type: Resource
      	  resource:
          	  name: cpu
          	  targetAverageUtilization: 60

Вы потрясены? Не волнуйтесь, вот диаграмма потока завершения работы:

Заключение

В этой статье мы с вам разобрались, как динамически масштабировать потребителей Symfony Messenger в зависимости от нагрузки, в том числе корректно отключать их. Результатом является высокая пропускная способность сообщений с наименьшими затратами.


Сегодня вечером пройдет открытый урок «Фильтры в API Platform», на который приглашаем всех желающих. На нем рассмотрим фильтрацию по полям сущности и фильтрацию по полям связанных сущностей; а также напишем свой фильтр (фильтрация по полями из JSON-колонки).

Комментарии (1)


  1. square
    17.05.2023 11:44

    Зачем вы тащите supervisord в контейнер, умножая на ноль смысл существования docker и k8s? Создали сами-себе из-за этого кучу проблем, которых бы не возникло, если бы вы не забивали гвозди микроскопом. Извините, если резковато написал, но как есть