Модуль 6 / 18
Процессы, PID 1, сигналы и корректное завершение
Содержание
1. Цели и эксплуатационный контракт
После модуля вы сможете определить главный процесс контейнера, предсказать доставку сигнала, реализовать ограниченное по времени graceful shutdown, объяснить обязанности PID 1 и доказать причину принудительного завершения по runtime evidence.
- жизненный цикл контейнера связан с PID 1
- exec form сохраняет прямую доставку сигналов executable
- shell form может поставить /bin/sh между runtime и приложением
- docker stop завершает двухфазный протокол сигналом и SIGKILL
- PID 1 должен обрабатывать сигналы и собирать завершившихся детей
- --init добавляет минимальный init для forwarding и reaping
2. Теория: PID 1 внутри контейнера
Первый процесс нового PID namespace получает PID 1 и особую роль. Пока он жив, контейнер считается работающим; после его выхода runtime завершает остальные процессы namespace. Ядро ожидает, что PID 1 будет вызывать wait для осиротевших завершившихся потомков и тем самым удалять zombie entries.
Некоторые runtimes языков меняют поведение сигналов или запускают worker processes. Контейнерная модель не исправляет это автоматически. Нужно знать фактическое дерево процессов через docker top и /proc, а не предполагать, что имя image равно PID 1.
3. Теория: сигналы и двухфазная остановка
docker stop сначала посылает настроенный stop signal главному процессу. Если контейнер не завершился за timeout, daemon посылает SIGKILL. По умолчанию используется STOPSIGNAL image либо SIGTERM, а стандартный Linux timeout равен 10 секундам. --stop-signal и --stop-timeout сохраняются в container configuration; параметры docker stop могут изменить конкретную операцию.
SIGTERM можно обработать: прекратить принимать новые запросы, дождаться in-flight работы, сбросить buffers и закрыть соединения. SIGKILL обработать или отложить нельзя. Exit code часто отображает завершение сигналом как 128 + номер сигнала, поэтому 137 обычно указывает на SIGKILL, но причину нужно подтвердить OOMKilled, events и timeline.
4. Теория: exec form, shell form и entrypoint scripts
Exec-form ENTRYPOINT запускает указанный executable напрямую. Shell form запускает /bin/sh -c, shell становится PID 1 и может не переслать сигнал дочернему приложению; кроме того, shell-form ENTRYPOINT игнорирует CMD и arguments docker run.
Если wrapper script нужен для templates, permissions или migration, он должен завершиться exec "$@", когда не требуется дальнейшая координация. Если wrapper управляет несколькими детьми и cleanup, он остаётся PID 1, ставит traps, пересылает сигналы каждому ребёнку, выполняет wait и возвращает осмысленный exit code.
5. Теория: проектирование graceful shutdown
Shutdown budget должен быть согласован по всей цепочке: load balancer перестаёт направлять новые запросы, приложение получает сигнал, закрывает listener, завершает текущую работу, telemetry успевает отправиться, а orchestrator применяет внешний deadline. Внутренний timeout приложения должен быть короче внешнего, оставляя запас runtime.
Готовность и завершение различны: после начала shutdown процесс может быть жив, но уже не должен принимать новую работу. Долгие requests, consumers и background jobs требуют явной стратегии drain, idempotency и visibility, иначе принудительный kill превращается в потерю либо повтор операции.
6. Теория: init, zombies и несколько процессов
--init помещает минимальный docker-init, основанный на tini, перед приложением. Он пересылает сигналы и собирает осиротевших детей. Это полезно, когда приложение не выполняет reaping, но не компенсирует неверный shutdown protocol самого приложения.
Один основной concern на контейнер остаётся хорошей границей, но не означает буквально один процесс: web server может иметь workers. Полноценный systemd или supervisor внутри обычно усложняет health, logs и restart ownership. Restart policy Docker отвечает за container, а не за отдельного ребёнка внутри него.
7. Методические указания
- Перед stop зафиксируйте tree процессов и фактический PID 1
- Проверяйте image STOPSIGNAL и container overrides
- Измеряйте реальное время остановки, а не только exit code
- Не используйте timeout -1 в production без внешней границы
- Не считайте exit 137 доказательством OOM без OOMKilled и events
- Не используйте kill -9 как штатный shutdown
- Не запускайте daemonized process в контейнере
- Тестируйте shutdown под реальной in-flight нагрузкой
8. Лабораторная работа: signal-aware процесс
Создайте entrypoint с traps, дочерним sleep и явным wait. Соберите exec-form и shell-form варианты из одного script.
mkdir -p /tmp/docker-module-06
printf '%s
' '#!/bin/sh' 'set -eu' 'child=' 'shutdown() {' ' signal="$1"' ' printf "received=%s pid=%s\n" "$signal" "$$"' ' if [ -n "$child" ]; then' ' kill -TERM "$child" 2>/dev/null || true' ' wait "$child" 2>/dev/null || true' ' fi' ' printf "shutdown=complete\n"' ' exit 0' '}' 'trap '''shutdown TERM''' TERM' 'trap '''shutdown INT''' INT' 'printf "started pid=%s\n" "$$"' 'while :; do' ' sleep 30 &' ' child=$!' ' wait "$child" || true' 'done' > /tmp/docker-module-06/entrypoint.sh
chmod 755 /tmp/docker-module-06/entrypoint.sh
printf '%s
' '# syntax=docker/dockerfile:1' 'FROM busybox:1.37' 'WORKDIR /app' 'COPY --chmod=755 entrypoint.sh ./' 'USER 65532:65532' 'STOPSIGNAL SIGTERM' 'ENTRYPOINT ["/app/entrypoint.sh"]' > /tmp/docker-module-06/Dockerfile.exec
printf '%s
' '# syntax=docker/dockerfile:1' 'FROM busybox:1.37' 'WORKDIR /app' 'COPY --chmod=755 entrypoint.sh ./' 'USER 65532:65532' 'STOPSIGNAL SIGTERM' 'ENTRYPOINT /app/entrypoint.sh & wait' > /tmp/docker-module-06/Dockerfile.shell
find /tmp/docker-module-06 -maxdepth 1 -type f -printf '%f %m %s bytes
' | sortdocker buildx build --file /tmp/docker-module-06/Dockerfile.exec --tag docker-m6:exec --load /tmp/docker-module-06
docker buildx build --file /tmp/docker-module-06/Dockerfile.shell --tag docker-m6:shell --load /tmp/docker-module-06
docker image inspect docker-m6:exec docker-m6:shell --format 'image={{.RepoTags}} entrypoint={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}} stop={{json .Config.StopSignal}}'Сначала проверьте корректную остановку exec-form: в logs должны появиться received=TERM и shutdown=complete, а exit code должен быть 0.
docker run --detach --name docker-m6-exec docker-m6:exec
sleep 1
docker top docker-m6-exec -eo pid,ppid,user,stat,args
docker inspect docker-m6-exec --format 'host_pid={{.State.Pid}} path={{.Path}} args={{json .Args}}'
time docker stop --timeout 5 docker-m6-exec
docker logs docker-m6-exec
docker inspect docker-m6-exec --format 'status={{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} finished={{.State.FinishedAt}}'Переопределите stop signal на SIGINT и подтвердите, что handler получил именно его.
docker run --detach --name docker-m6-int --stop-signal SIGINT --stop-timeout 4 docker-m6:exec
docker inspect docker-m6-int --format 'configured_stop_signal={{json .Config.StopSignal}} timeout={{.Config.StopTimeout}}'
docker kill --signal SIGINT docker-m6-int
docker wait docker-m6-int
docker logs docker-m6-int
docker inspect docker-m6-int --format 'exit={{.State.ExitCode}} error={{json .State.Error}}'Затем измерьте shell-form. Shell-wrapper оставляет приложение в background и не пересылает ему SIGTERM, поэтому после короткого timeout ожидается принудительное завершение и отсутствие graceful marker.
docker run --detach --name docker-m6-shell docker-m6:shell
sleep 1
docker top docker-m6-shell -eo pid,ppid,user,stat,args
time docker stop --timeout 1 docker-m6-shell
docker logs docker-m6-shell
docker inspect docker-m6-shell --format 'path={{.Path}} args={{json .Args}} exit={{.State.ExitCode}} finished={{.State.FinishedAt}}'Наконец добавьте минимальный init и сравните дерево процессов.
docker run --detach --name docker-m6-init --init docker-m6:exec
sleep 1
docker top docker-m6-init -eo pid,ppid,user,stat,args
docker inspect docker-m6-init --format 'init={{.HostConfig.Init}} host_pid={{.State.Pid}}'
docker stop --timeout 5 docker-m6-init
docker logs docker-m6-init
docker inspect docker-m6-init --format 'exit={{.State.ExitCode}}'Критерий: вы доказали различие exec/shell по tree, logs, elapsed time и exit code; SIGINT override обработан; --init виден отдельным PID 1.
9. Диагностика: контейнер долго останавливается
Диагностика начинается до повторного stop: сохраните process tree, configuration, timestamps и events. После SIGKILL часть evidence приложения уже недоступна.
docker container ls --all --filter name=docker-m6
docker inspect docker-m6-shell --format '{{json .State}}' | jq
docker logs --timestamps docker-m6-shell
docker events --since 15m --until 0s --filter container=docker-m6-shell
docker top docker-m6-shell -eo pid,ppid,user,stat,args 2>&1 || true
docker info --format 'init_binary={{.InitBinary}} live_restore={{.LiveRestoreEnabled}}'- определить .Path и .Args главного процесса
- проверить shell wrapper и наличие exec
- сверить StopSignal и StopTimeout
- проверить, установлен ли handler и не блокируется ли он
- найти незавершённые children и zombie state Z
- отличить daemon stop, manual kill, OOM и application exit
- сопоставить shutdown duration с внешним deadline
10. Контроль, cleanup и шпаргалка
- Почему завершение PID 1 останавливает контейнер?
- Какие обязанности init связаны с wait?
- Почему shell-form ENTRYPOINT ломает forwarding?
- Что происходит после истечения stop timeout?
- Почему 137 не всегда OOM?
- Когда нужен --init?
- Зачем приложению прекращать readiness до exit?
- Почему внутренний shutdown budget должен быть короче внешнего?
docker container rm --force docker-m6-exec docker-m6-int docker-m6-shell docker-m6-init 2>/dev/null || true
docker image rm docker-m6:exec docker-m6:shell 2>/dev/null || true
rm -f /tmp/docker-module-06/Dockerfile.exec /tmp/docker-module-06/Dockerfile.shell /tmp/docker-module-06/entrypoint.sh
rmdir /tmp/docker-module-06Модуль освоен, если вы проектируете и измеряете graceful shutdown, читаете process tree и подтверждаете причину завершения минимум по State, logs и events.