Docker: правильный запуск процессов в контейнере с PID 1

Введение

Данная статья посвящена корректному написанию Dockerfile по части запуска процессов внутри контейнера. Казалось бы, что всё описано в документации. Но порой бывают неоднозначные случаи и в целом хорошие практики, о которых будет рассказано на примерах с пояснением.

Основы ENTRYPOINT && CMD

Перед началом нужно кратко вспомнить, что это такое и чем отличается. Эти две директивы в Dockerfile наверняка всем известны – обе запускают процессы в контейнере. Как правило, в entrypoint используется какой-либо скрипт или команда для запуска процесса внутри контейнера, а в cmd – параметры, которые передаются в entrypoint.

Если указывать параметры в квадратных скобках, т.е. в формате exec, то внутри контейнера будет выполняться лишь процесс, который указан для запуска, и никаких оболочек запущено не будет. Это значит, что замена (substitution) переменных и их обработка невозможны – это также предпочтительный вариант для простого запуска какого-либо процесса. О более сложных моментах будет рассказано далее.

Если же указать команду для запуска без фигурных скобок, то внутри контейнера можно будет увидеть, что процесс запущен через форму shell и процесс внутри будет вида sh -c “ping ya.ru”.

Грубый пример Dockerfile для демонстрации работы exec формы:

FROM centos:7

ENTRYPOINT ["/bin/ping"]
CMD ["it-lux.ru"]

После сборки образа с именем “ping” и дальнейшего запуска контейнера из него будет выполняться команда “/bin/ping it-lux.ru”. Домен можно переопределить, указав его при запуске контейнера, например:

docker run -d ping google.com

Тем самым выполняется переопределение значения из параметра CMD в Dockerfile, что очень удобно – из одного образа можно запускать команды с различными аргументами. Такой способ я использовал для выполнения крон-задач в отдельном докер-контейнере, указывая на хосте в crontab через аргументы путь к скриптам при запуске docker run, которые принимал для выполнения php.

PID 1 и остановка контейнера

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

Dockerfile:
FROM centos:7

COPY docker-entrypoint.sh /usr/bin
ENTRYPOINT ["/usr/bin/docker-entrypoint.sh"]

docker-entrypoint.sh:
#!/bin/bash

ping ya.ru

Допустим, что для запуска приложения в контейнере понадобилось выполнять какие-то подготовительные действия перед запуском финальной команды “ping ya.ru”. Для этого обычно пишется docker-entrypoint.sh и уже этот скрипт запускается в контейнере. После запуска можно увидеть, что теперь процесс внутри контейнера уже не “ping ya.ru”, а /bin/bash /usr/bin/docker-entrypoint.sh и процесс с PID 1 уже принадлежит bash, внутри которого выполняется ping:

При выполнении команды docker stop <имя контейнера>, докер посылает сигнал SIGTERM процессу с 1 PID, а в данном случае это обычный bash, который данный сигнал может не обработать. Аналогичная ситуация, если для запуска процессов используется shell форма вместо exec. Таким образом, процесс внутри контейнера не сможет корректно завершится и возникнет ситуация, когда bash будет остановлен, а запущенный им процесс останется висеть в виде зомби. Да, он будет “прибит” в конечном счёте спустя какое-то время, но это не будет graceful termination.

docker-entrypoint.sh и запуск процессов

Возникает логичный вопрос: если принято использовать docker-entrypoint.sh для запуска всех процессов, то как сделать корректный PID в контейнере, если в самом начале всё равно запускается баш? Ответ прост – в том же bash есть уже команда exec, которая принимает в качестве аргумента ваш процесс, убивает текущую оболочку и запускает то, что передали через аргументы. Простой пример:

docker-entrypoint.sh:
#!/bin/bash

exec ping ya.ru

При запуске такого контейнера, если посмотреть в список процессов, можно увидеть, что теперь PID 1 принадлежит основной команде ping:

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

Но нельзя забывать про директиву CMD. Она по-прежнему полезна, хотя в последних примерах и не используется. Её польза состоит также в передаче аргументов, но уже в docker-entrypoint.sh, где они обрабатываются. Например:

#!/bin/bash

# do something...

set -- ping "$@"

exec "$@"

Здесь появилось пару новых элементов. Это по-прежнему всё тот же скрипт, в котором в начале выполняются подготовительные действия. Но используется переменная “$@” – она содержит в себе все аргументы, которые будут переданы при запуске контейнера, т.е. какой-либо домен в данном случае. И используется команда set с аргументом из двух тире, которая подставляет команду ping перед последними аргументами, т.е. перед доменом, который надо пинговать.

В итоге получается, что при запуске контейнера с неким аргументом, он записывается в переменную “$@” и сразу же передается команде ping также аргументом. А для выполнения используется уже знакомая форма exec. Напоминаю, что “-d ping ” – это название образа, а “google.com” – аргумент к команде ping из entrypoint-скрипта.

docker run -d ping google.com

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0  29064  1636 ?        Ss   19:48   0:00 ping google.com

На первый взгляд это кажется чем-то сложным и совсем непонятным. Зачем это всё, когда можно просто явно указать какую команду выполнять? Это же всё усложняет!

Да, порой так и бывает, а потому это вряд ли пригодится в написании простых Dockerfile. Но с ростом логики, которую необходимо обработать в docker-entrypoint.sh, и вышеописанными командами, появляется прекрасная возможность очень гибко оперировать ходом событий в скрипте. Похожие методы обычно применяются во многих других Dockerfile и docker-entrypoint.sh для популярного софта – zabbix, например. В них можно более детально изучить применение подобных функций для полноценного понимания.

tini и запуск процессов

Теперь, когда вроде бы понятно, что основной процесс в контейнере должен иметь PID 1 и это не должен быть bash, т.е. используется команда exec, может возникнуть ещё один неочевидный момент.

Приложение запускается и начинает рождать дочерние процессы. Например, это тот же Zabbix server, Apache или Jenkins. Основной процесс запущен с PID 1 и всё, казалось бы, хорошо. Но возникает такой момент, когда процесс с PID 1 перестаёт корректно реагировать на сигналы, посылаемые демоном докера при остановке контейнера. Это случается, например, по причине “осиротевших” дочерних процессов, когда родительский процесс не подчистил за собой хвосты. Т.е. остались зомби-процессы.

Появление таких процессов, опять же, казалось бы, должно быть на совести разработчиков ПО, но не всё так однозначно. Например, Jenkins позволяет запускать код, который написан не разработчиками Jenkins, т.е. выполняются сценарии сборки. В zabbix тоже есть возможность запуска некоторых скриптов. И вот из всего этого и могут остаться “висящие” процессы.

Здесь в игру и вступает tini (init наоборот) – это легковесная современная утилита, разработанная специально для контейнеров, которая умеет проводить “чистку” за зомби-процессами.

Но постойте… То есть получается, что нужно запускать tini с PID 1 в контейнере? Да! Но ведь изначально же был уже bash с PID 1 и это было не кошерно неправильно, не так ли? Абсолютно верно!

Первая проблема с баш заключается в следующем – если запущен bash с PID 1, внутри которого крутится всё тот же Jenkins, то при остановке контейнера посылаемые сигналы могут не дойти до Jenkins, они будут получены башем и в нём пропадут. А далее передавать он их не умеет, в отличие от tini.

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

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

Заключение

В статье были рассмотрены некоторые основные и неочевидные моменты по работе с Docker-контейнерами и процессами внутри. Большая часть из примеров может показаться непонятным на первых этапах – я соглашусь с тем, что без всего вышеперечисленного можно обойтись и всё и так будет работать. Заморочки со скриптами в entrypoint – можно сломать глаза, посмотрев, что туда пишут. Докер наоборот должен всё упрощать, а не усложнять. Использование какого-то tini, когда проблема должна решаться на уровне кода? Абсолютно верно!

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

По ссылкам ниже можно ознакомиться с англоязычными первоисточниками.

Используемые источники

Понравилась статья? Поделиться с друзьями:
Комментарии: 2
  1. Олег
  2. Олег

    Спасибо за статью, про PID 1 и tini было познавательно

Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: