Модуль 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.
Деякі language 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.