Избегайте этого при запуске контейнерных приложений в производстве

Избегайте этого при запуске контейнерных приложений в производстве

Содержание
  1. Сигналы завершения
  2. Как выполнить плавное выключение?
  3. Производственная среда
  4. Жизненный цикл контейнера
  5. Поведение сигнала о прекращении обслуживания
  6. Что может пойти не так?
  7. Не позволяйте вашему процессу подачи заявки стать PID1
  8. Что представляет собой процесс PID1 под псевдонимом “init”?
  9. Кто же тогда должен быть PID1?

Привет всем!

Давайте поговорим о том, чем нужно управлять при запуске контейнерных приложений, и как это связано с правильным управлением сигналами завершения.

Если вы предпочитаете формат GitHub с примерами, я создал репозиторий, который следует тому же пути с практическими примерами.

Прежде чем говорить конкретно о контейнерах, давайте отложим их в сторону и посмотрим, как мы запускаем приложения на ежедневной основе. Все мы используем различные операционные системы, способные выполнять огромное количество задач. Эти задачи выполняются в рамках процессов - одной из фундаментальных единиц операционной системы.

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

С помощью Node.js мы можем проверить, какой идентификатор процесса в данный момент выполняет наше приложение:

$ node -e "console.log(process.pid)" > 39829

В общем, одной из основных обязанностей операционной системы является управление жизненным циклом этих процессов: их создание, уничтожение, приостановка, перезапуск и т. д.

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

Сигналы завершения

Сигналы завершения - это примитивы, используемые ОС, чтобы сообщить определенному процессу о его завершении.

В Unix эти сигналы завершения работы можно послать с помощью команды kill.

$ kill 39829 // SIGTERM by default $ kill -9 39829 // SIGKILL

Существует множество существующих сигналов завершения, но мы поговорим о трех наиболее распространенных:

SIGTERM: это самый распространенный сигнал завершения. Такое завершение называется ”мягким”, потому что сигнал просто приказывает процессу остановиться, но процесс может решить проигнорировать его. В Unix OS этот сигнал используется по умолчанию при использовании команды kill. Это эквивалент кнопки OFF на вашем пульте дистанционного управления.

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

SIGINT: это сигнал прерывания, который, например, посылается, когда пользователь отправляет команду CTRL+C в терминале, на котором в данный момент выполняется процесс.

Благодатное завершение работы: одна из основных обязанностей приложения производственного класса

Теперь, когда мы знаем, что такое сигналы завершения, мы можем задаться вопросом, какова ответственность приложения в отношении этих сигналов?

Само приложение должно правильно обрабатывать эти сигналы, иначе не будет возможности для плавного завершения работы.

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

Как выполнить плавное выключение?

Чаще всего супервизоры процессов сначала подают ”мягкие” сигналы, такие как SIGTERM или SIGINT, которые не завершают процессы сразу.

В течение определенного периода времени (в зависимости от супервизора) процесс приложения успеет очистить все, что ему нужно было сделать, и затем выйти, т. е. сам выполнит грациозное завершение работы. Другими словами, льготное завершение работы начинается с реакции на сигнал о завершении работы и последующего запуска кода на уровне приложения для управления завершением работы.

// Node.js // Graceful shutdown on SIGTERM process.on("SIGTERM", () => { // close server, close database connection, write logs... releaseAllResources(); // then manually exit process.exit(143); });

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

// Node.js process.on("SIGTERM", () => { // If we don't manually exit, the process will hang forever // until a SIGKILL is fired. Once attaching these types of handler, // it's then your responsibility to release the last ressource: the process itself. });

Добавление обработчиков процесса SIGTERM позволяет нам взять под контроль логику, которая будет выполняться, но мы также несем ответственность за правильный выход после этого.

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

Но не может ли прикрепление этих обработчиков быть вредным, если мы не выйдем после этого?

Конечно, может! Именно поэтому большинство супервизоров обычно не полагаются только на SIGTERM, а после отправки SIGTERM ждут определенное время и посылают SIGKILL, если процесс все еще не завершен (это, по сути, разница между “Quit” и “Force Quit” при использовании диспетчера задач). Обратите внимание, что в зависимости от ОС, некоторые могут просто позволить процессу висеть неопределенное время.

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

Производственная среда

Давайте вернемся к основной теме - запуску контейнерных приложений в производственных средах.

В этой статье я исхожу из того, что вы знаете, что такое контейнер. Если нет, ознакомьтесь с документацией Docker, прежде чем углубляться в эту тему.

Жизненный цикл контейнера

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

Контейнеры обычно управляются супервизорами или оркестраторами, которые определяют, следует ли запускать, перезапускать, останавливать контейнер, увеличивать/уменьшать его масштаб и т. д. Одним из самых известных контейнерных оркестров является Kubernetes (K8S), изначально разработанный компанией Google.

Целью K8S является оркестровка контейнеров в масштабе, управление кластерами контейнеров, включающими множество различных сервисов и приложений компании. В двух словах K8S управляет развертыванием контейнеров, жизненным циклом контейнеров, определяя необходимость их увеличения или уменьшения в зависимости от нагрузки, а также имеет дело с развертыванием и принятием произвольных решений, таких как остановка/перезапуск контейнеров при появлении новых версий.

Чтобы Kubernetes работала эффективно, необходимо соблюдать один фундаментальный критерий для контейнеров: приложение, запущенное в контейнере, должно правильно реагировать на сигналы, поступающие от орхистратора.

В чем опасность неправильной реакции на эти сигналы в контексте контейнеров?

Как было описано в предыдущем разделе для процессов ОС, супервизор, которым в контексте контейнеров является Kubernetes, сначала попытается отправить SIGTERM, а по истечении стандартного значения 30 секунд (может быть настроено) будет отправлен сигнал SIGKILL, чтобы убедиться, что контейнер остановлен.

Здесь приведен список контейнерных сервисов с соответствующим поведением при SIGTERM и SIGKILL

Поведение сигнала о прекращении обслуживания

AWS Elastic Container Service SIGTERM, подождите 30 секунд, затем SIGKILL

Kubernetes SIGTERM, подождите 30 секунд, затем SIGKILL

Azure App Service SIGTERM, wait 30s, then SIGKILL

Docker SIGTERM, подождите 10 секунд, затем SIGKILL

Мы можем сказать, что все в порядке и нас не очень заботит обработка сигналов завершения, поскольку SIGKILL все равно будет отправлен в какой-то момент и завершит как наш процесс приложения, так и контейнер, в котором изначально находился процесс приложения.

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

Представьте, что контейнер необходимо откатить из-за ошибки, это значит, что в течение 30 секунд мы не сможем делать ничего, кроме как ждать. Кроме того, все операции, такие как уменьшение масштаба или остановка, будут продолжать использовать ресурсы до тех пор, пока не будет получен сигнал SIGKILL. При масштабировании на сотни/тысячи контейнеров 30s может быстро стать очень накладным и привести к критическим штрафам за производительность.

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

Убедитесь, что сигналы об окончании могут распространяться

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

Что может пойти не так?

Рассмотрим очень простой пример с контейнерами Docker, где Dockerfile задает ENTRYPOINT так, чтобы породить shell-процесс в качестве PID1, что известно как shell-форма команды ENTRYPOINT.

Пожалуйста, не используйте следующий Dockerfile для доставки приложений Node.js в производство. Это упрощенный пример, который будет использовать устаревшую версию Node.js (v12), которая подходит только для образовательных целей.

Вы можете самостоятельно протестировать все последующие примеры, используя созданный мной репозиторий

Dockerfile

FROM ubuntu:22.04 RUN apt-get update && \ apt-get install -y nodejs ENTRYPOINT node index.js

Собирать свой собственный образ Node.js не рекомендуется, здесь мы собираем его из Ubuntu в образовательных целях. Пожалуйста, полагайтесь на официальные образы для производства.

В результате в контейнере будет два процесса: shell - родительский, а приложение Node.js - дочерний процесс shell (subshell). Следствием этого будет то, что shell не будет корректно передавать сигналы завершения, а это значит, что процесс Node.js никогда не получит этих сигналов.

# Command run within the container $ ps aux USER PID COMMAND root 1 /bin/sh -c node index.js root 7 node index.js

Теперь, когда мы выполняем команду docker stop <container-id>, мы видим, что процесс Node.js не получает сигнал SIGTERM и продолжает жить, пока Docker не отправит сигнал SIGKILL через 10 с.

Один из способов обойти это - не делать оболочку PID1 и превратить сам процесс приложения в PID1 с помощью exec-формы ENTRYPOINT.

Dockerfile

FROM ubuntu:22.04 RUN apt-get update && \ apt-get install -y nodejs ENTRYPOINT ["node", "index.js"]

Если мы снова запустим ps aux внутри контейнера, то увидим, что процесс Node.js теперь имеет PID1:

$ ps aux USER PID COMMAND root 1 node index.js

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

Не позволяйте вашему процессу подачи заявки стать PID1

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

Тем не менее, у нас появилась новая проблема, которая заключается в том, что нашим приложением теперь является PID1, также известный как процесс init.

Что представляет собой процесс PID1 под псевдонимом “init”?

PID1 или процесс “init” имеет очень четкие обязанности в отношении операционной системы (или, точнее, контейнера в данном контексте).

Прежде чем объяснять проблему вокруг PID1, давайте быстро вернемся к основе ОС с организацией процессов.

Реестр процессов представлен в виде графа, где PID1 представляет корневой узел, обычно называемый “init”.

В отличие от других процессов, процесс “init” (PID1) наделяется ядром весьма специфическими обязанностями, которые, в частности, заключаются в следующем:

инициализировать все службы (процессы), необходимые операционной системе.

управлять так называемыми ”зомби” или “сиротскими” процессами и получать от них прибыль. Зомби-процесс - это процесс, который завершил выполнение, но ресурсы которого продолжают монополизироваться.

обеспечить правильную передачу кода завершения дочернего процесса за пределы контейнера.

Все эти обязанности не могут и не должны возлагаться ни на ваше приложение, ни на среду выполнения, на которой оно выполняется (JVM, Node.js и т. д.).

Кто же тогда должен быть PID1?

Существует множество решений, но Tini - самое известное и проверенное в боях.

У Tini одна цель - обеспечить процесс “init”, который работает так, как ожидается. Это независимый исполняемый файл, но важно упомянуть, что он по умолчанию встроен в Docker начиная с версии 1.13, его можно использовать с помощью docker run --init или с помощью docker-compose (версия 2.2), используя init: true из конфигурационного файла для сервиса.

В контексте приложения Node.js вот пример минималистичного, но более близкого к производственной готовности Dockerfile с использованием Tini:

FROM node:18-alpine RUN apk add --no-cache tini # Copy app files ENTRYPOINT ["/sbin/tini", "--", "node", "index.js"]

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

Я часто публикую в своем блоге статьи о программной инженерии, не забудьте подписаться, если вам интересно узнать больше!

Увидимся позже 👋🏻