4.4. Сигналы и graceful shutdown
Цели
После этого материала вы сможете:
- описать точную последовательность действий Docker при
docker stop; - объяснить, почему приложение, запущенное через shell form, не получает
SIGTERM; - реализовать корректное завершение и доказать его работу измерением;
- настроить
STOPSIGNALи grace period под конкретное приложение; - объяснить, почему
SIGKILLнельзя перехватить, и что теряется при его получении; - диагностировать ситуацию «
docker stopзанимает ровно 10 секунд»; - учитывать сигналы при работе с несколькими процессами в container.
Предварительные знания
- 4.1. Состояния и переходы;
- 4.2. Режимы запуска;
- понимание того, что такое сигнал в Linux;
- базовое знание shell: разница между
sh -c "cmd"и прямым запускомcmd.
Ключевые термины
| Термин | Объяснение |
|---|---|
сигнал | Асинхронное уведомление процессу от ядра или другого процесса |
SIGTERM (15) | Просьба завершиться. Перехватывается, игнорируется, откладывается |
SIGKILL (9) | Принудительное завершение. Не перехватывается и не игнорируется |
SIGINT (2) | Прерывание с терминала, Ctrl+C |
SIGHUP (1) | Разрыв терминала; часто используется для перечтения конфигурации |
graceful shutdown | Корректное завершение: дозавершить работу, освободить ресурсы, выйти |
grace period | Время между SIGTERM и SIGKILL |
exec form | Запись команды массивом: CMD ["python", "app.py"] |
shell form | Запись строкой: CMD python app.py |
Теория
Что делает docker stop
Точная последовательность:
docker stop <container>
│
├─ 1. Послать SIGTERM процессу PID 1 в container
│ (или сигнал, заданный в STOPSIGNAL)
│
├─ 2. Ждать до grace period (по умолчанию 10 секунд)
│ │
│ ├─ процесс завершился ──► готово, exit code от процесса
│ │
│ └─ время вышло ──► шаг 3
│
└─ 3. Послать SIGKILL ──► процесс убит, exit code 137
Ключевые детали:
Сигнал получает только PID 1. Дочерние процессы сигнала не получают. Если PID 1 их не уведомит — они будут убиты SIGKILL вместе со всем container.
Grace period — это максимум, а не фиксированная пауза. Если приложение завершится за 200 миллисекунд, docker stop вернётся через 200 миллисекунд.
SIGKILL не оставляет шансов. Ядро завершает процесс немедленно. Обработчики не вызываются, буферы не сбрасываются, соединения не закрываются штатно.
Почему приложение не получает сигнал
Самая частая причина в Python-проектах — shell form в Dockerfile.
FROM python:3.13-slim
COPY app.py /app.py
CMD python /app.py
Последняя строка превращается в:
/bin/sh -c "python /app.py"
Дерево процессов внутри container:
PID 1 /bin/sh -c "python /app.py" ← сигнал приходит сюда
PID 7 python /app.py ← а работает здесь
docker stop посылает SIGTERM процессу PID 1 — оболочке sh. Оболочка в неинтерактивном режиме, ожидающая завершения дочернего процесса, обычно не пересылает сигнал потомку. Результат: python не узнаёт о необходимости завершиться, проходит 10 секунд, SIGKILL убивает оба процесса.
Exec form решает проблему:
FROM python:3.13-slim
COPY app.py /app.py
CMD ["python", "/app.py"]
PID 1 python /app.py ← сигнал приходит прямо приложению
Оболочки нет, приложение является PID 1 и получает сигнал напрямую.
Правило: в
CMDиENTRYPOINTиспользуйте exec form. Shell form допустим только когда действительно нужны возможности оболочки — подстановка переменных, конвейеры, — и тогда сигналы нужно продумывать отдельно.
Подробно формы записи разбираются в разделе 05.
Вторая причина: PID 1 без обработчика
Даже при exec form есть особенность, специфичная для PID 1.
Для обычного процесса сигнал с действием по умолчанию (например, SIGTERM) приводит к завершению — обработчик не нужен. Но для PID 1 ядро не применяет действие по умолчанию: сигналы без явно установленного обработчика просто игнорируются.
Это защита: PID 1 в обычной системе — это init, и его случайное завершение остановило бы всю систему.
Практическое следствие: Python-скрипт, запущенный как PID 1 без signal.signal(signal.SIGTERM, ...), проигнорирует SIGTERM. Тот же скрипт, запущенный не как PID 1, завершился бы.
Проверить легко:
docker run --rm -d --name t1 python:3.13-slim python -c "import time; time.sleep(300)"
time docker stop t1 # 10 секунд — сигнал проигнорирован
Механизм подробно разбирается в уроке 4.5, реализация для Python — в разделе 06.
Что должно происходить при graceful shutdown
Зависит от типа приложения.
| Тип | Что нужно сделать при SIGTERM |
|---|---|
| HTTP-сервис | Перестать принимать новые соединения, дозавершить текущие запросы, закрыть пул БД |
| Worker очереди | Дообработать текущее сообщение, не забирать новые, подтвердить обработку |
| CLI-задача | Обычно ничего: работа атомарна либо перезапускаема |
| Приложение с состоянием | Сбросить буферы на диск, снять блокировки, закрыть файлы |
Общее правило: завершиться быстрее grace period. Если штатное завершение занимает 30 секунд, а grace period 10 — приложение всё равно получит SIGKILL. Нужно либо ускорить завершение, либо увеличить таймаут.
STOPSIGNAL и настройка таймаута
Не все приложения ожидают SIGTERM. Классический пример — nginx, где SIGTERM означает быстрое завершение, а SIGQUIT — graceful.
Сигнал настраивается тремя способами:
| Способ | Область действия |
|---|---|
STOPSIGNAL SIGQUIT в Dockerfile | Все containers из этого образа |
docker run --stop-signal=SIGQUIT | Конкретный container |
stop_signal: SIGQUIT в Compose | Сервис |
Grace period — аналогично:
| Способ | Область |
|---|---|
docker stop --timeout 30 | Один вызов |
docker run --stop-timeout=30 | Container |
stop_grace_period: 30s в Compose | Сервис |
shutdown-timeout в daemon.json | Все containers на host |
Что теряется при SIGKILL
Конкретика, объясняющая, почему это важно:
- HTTP-запросы обрываются — клиент получает ошибку соединения вместо ответа;
- Транзакции БД откатываются — иногда после таймаута на стороне сервера, оставляя блокировки;
- Сообщения очереди теряются или дублируются — зависит от того, было ли подтверждение;
- Буферы не сброшены — последние записи логов и данных не попадают на диск;
- Временные файлы остаются — код очистки не выполнился;
- Блокировки не сняты — следующий запуск может не стартовать.
Внутренний механизм
Как сигнал доходит до процесса
- CLI отправляет
POST /containers/<id>/stop?t=10. - Daemon читает
StopSignalиз конфигурации container (по умолчаниюSIGTERM). - Через containerd вызывается отправка сигнала задаче.
- Shim выполняет
kill(pid, signal)для процесса PID 1 в PID namespace container. - Daemon ждёт завершения задачи, но не дольше
tсекунд. - По истечении отправляется
SIGKILL.
Шаг 4 объясняет, почему сигнал получает только один процесс: kill() адресуется конкретному PID, а не группе.
Почему exit code равен 143 или 137
При завершении процесса сигналом оболочка и Docker сообщают код по формуле 128 + номер сигнала:
| Сигнал | Номер | Exit code |
|---|---|---|
SIGHUP | 1 | 129 |
SIGINT | 2 | 130 |
SIGKILL | 9 | 137 |
SIGTERM | 15 | 143 |
Практическая интерпретация:
- 143 — PID 1 умер от
SIGTERM. Вопреки распространённому объяснению, это не случай «нет обработчика»: для PID 1 ядро не применяет действие по умолчанию, поэтому без обработчика приходитSIGKILLи код будет 137. Код 143 даёт--init:tiniпересылает сигнал потомку и сам завершается с128+15. - 137 — процесс убит
SIGKILL. Причины: истёк grace period,docker kill, или OOM killer. Различить помогает поле.State.OOMKilled. - 0 — приложение обработало сигнал и завершилось само. Целевое состояние.
Команды и примеры
Подготовка
mkdir -p /tmp/signals && cd /tmp/signals
Три варианта одного приложения — для сравнения поведения.
no_handler.py — без обработчика:
cat > no_handler.py <<'PY'
import time
print("запущен без обработчика сигналов", flush=True)
while True:
time.sleep(0.5)
PY
graceful.py — с обработчиком:
cat > graceful.py <<'PY'
import signal
import sys
import time
shutdown = False
def handle(signum, frame):
global shutdown
name = signal.Signals(signum).name
print(f"получен {name} ({signum}), начинаю завершение", flush=True)
shutdown = True
signal.signal(signal.SIGTERM, handle)
signal.signal(signal.SIGINT, handle)
print("запущен с обработчиком сигналов", flush=True)
while not shutdown:
time.sleep(0.1)
print("закрываю соединения...", flush=True)
time.sleep(0.3)
print("завершён корректно", flush=True)
sys.exit(0)
PY
slow_shutdown.py — завершается дольше grace period:
cat > slow_shutdown.py <<'PY'
import signal
import sys
import time
shutdown = False
def handle(signum, frame):
global shutdown
print(f"получен сигнал {signum}", flush=True)
shutdown = True
signal.signal(signal.SIGTERM, handle)
print("запущен; завершение займёт 20 секунд", flush=True)
while not shutdown:
time.sleep(0.1)
for i in range(20):
print(f"завершаюсь... {i + 1}/20", flush=True)
time.sleep(1)
print("завершён", flush=True)
sys.exit(0)
PY
Сравнение: с обработчиком и без
run_and_stop() {
local script="$1" name="$2"
docker run -d --name "$name" \
-v "$PWD/$script:/app/$script:ro" \
python:3.13-slim python -u "/app/$script" > /dev/null
sleep 1
local start end
start="$(date +%s.%N)"
docker stop "$name" > /dev/null
end="$(date +%s.%N)"
printf ' время stop: %.2f c\n' "$(echo "$end - $start" | bc)"
printf ' exit code: %s\n' "$(docker inspect "$name" --format '{{.State.ExitCode}}')"
echo " логи:"
docker logs "$name" 2>&1 | sed 's/^/ /'
docker rm "$name" > /dev/null
}
echo "=== Без обработчика ==="
run_and_stop no_handler.py t-nohandler
echo
echo "=== С обработчиком ==="
run_and_stop graceful.py t-graceful
=== Без обработчика ===
время stop: 10.32 c
exit code: 137
логи:
запущен без обработчика сигналов
=== С обработчиком ===
время stop: 0.44 c
exit code: 0
логи:
запущен с обработчиком сигналов
получен SIGTERM (15), начинаю завершение
закрываю соединения...
завершён корректно
Разница исчерпывающая: 10.32 секунды против 0.44, код 137 против 0, и главное — во втором случае приложение успело выполнить код завершения.
Первый случай — та самая ситуация «docker stop почему-то занимает 10 секунд». Причина: скрипт является PID 1, а PID 1 без обработчика игнорирует SIGTERM.
Shell form ломает доставку сигнала
cat > Dockerfile.shell <<'EOF'
FROM python:3.13-slim
COPY graceful.py /app/graceful.py
CMD python -u /app/graceful.py
EOF
cat > Dockerfile.exec <<'EOF'
FROM python:3.13-slim
COPY graceful.py /app/graceful.py
CMD ["python", "-u", "/app/graceful.py"]
EOF
docker build -q -f Dockerfile.shell -t sig:shell . > /dev/null
docker build -q -f Dockerfile.exec -t sig:exec . > /dev/null
Посмотрим на дерево процессов:
docker run -d --name p-shell sig:shell > /dev/null
docker run -d --name p-exec sig:exec > /dev/null
sleep 1
echo "=== shell form ==="
docker exec p-shell ps -o pid,args
echo "=== exec form ==="
docker exec p-exec ps -o pid,args
=== shell form ===
PID COMMAND
1 /bin/sh -c python -u /app/graceful.py
7 python -u /app/graceful.py
13 ps -o pid,args
=== exec form ===
PID COMMAND
1 python -u /app/graceful.py
8 ps -o pid,args
При shell form между Docker и приложением стоит /bin/sh. Проверим последствия:
for c in p-shell p-exec; do
echo "--- $c ---"
start="$(date +%s.%N)"
docker stop "$c" > /dev/null
end="$(date +%s.%N)"
printf ' время: %.2f c, exit code: %s\n' \
"$(echo "$end - $start" | bc)" \
"$(docker inspect "$c" --format '{{.State.ExitCode}}')"
echo " логи:"
docker logs "$c" 2>&1 | sed 's/^/ /'
docker rm "$c" > /dev/null
done
--- p-shell ---
время: 10.28 c, exit code: 137
логи:
запущен с обработчиком сигналов
--- p-exec ---
время: 0.41 c, exit code: 0
логи:
запущен с обработчиком сигналов
получен SIGTERM (15), начинаю завершение
закрываю соединения...
завершён корректно
Один и тот же Python-код. Разница только в форме записи CMD. При shell form обработчик установлен, но сигнал до него не дошёл.
Это одна из самых дорогих ошибок в контейнеризации Python-приложений, и находится она в одной строке Dockerfile.
Как починить shell form, если он нужен
Иногда возможности оболочки действительно нужны — например, для подстановки переменной. Тогда используйте exec:
cat > Dockerfile.exec-in-shell <<'EOF'
FROM python:3.13-slim
COPY graceful.py /app/graceful.py
ENV APP_MODULE=/app/graceful.py
CMD exec python -u "$APP_MODULE"
EOF
docker build -q -f Dockerfile.exec-in-shell -t sig:fixed . > /dev/null
docker run -d --name p-fixed sig:fixed > /dev/null
sleep 1
docker exec p-fixed ps -o pid,args | head -3
PID COMMAND
1 python -u /app/graceful.py
7 ps -o pid,args
Встроенная команда exec заменяет процесс оболочки процессом приложения — sh не остаётся в памяти, приложение становится PID 1.
start="$(date +%s.%N)"; docker stop p-fixed > /dev/null; end="$(date +%s.%N)"
printf 'время: %.2f c, exit code: %s\n' \
"$(echo "$end - $start" | bc)" \
"$(docker inspect p-fixed --format '{{.State.ExitCode}}')"
docker rm p-fixed > /dev/null
время: 0.43 c, exit code: 0
Тот же приём применяется в entrypoint-скриптах: последняя строка должна быть exec "$@", а не просто "$@".
Grace period и медленное завершение
docker run -d --name slow \
-v "$PWD/slow_shutdown.py:/app/s.py:ro" \
python:3.13-slim python -u /app/s.py > /dev/null
sleep 1
echo "--- таймаут по умолчанию (10 с), а завершение требует 20 ---"
start="$(date +%s.%N)"; docker stop slow > /dev/null; end="$(date +%s.%N)"
printf ' время: %.2f c, exit code: %s\n' \
"$(echo "$end - $start" | bc)" \
"$(docker inspect slow --format '{{.State.ExitCode}}')"
echo " успело выполниться:"
docker logs slow 2>&1 | tail -3 | sed 's/^/ /'
docker rm slow > /dev/null
--- таймаут по умолчанию (10 с), а завершение требует 20 ---
время: 10.31 c, exit code: 137
успело выполниться:
завершаюсь... 8/20
завершаюсь... 9/20
завершаюсь... 10/20
Приложение обрабатывало сигнал корректно, но не уложилось: на десятой секунде получило SIGKILL. Половина работы по завершению не выполнена.
Увеличим таймаут:
docker run -d --name slow2 \
-v "$PWD/slow_shutdown.py:/app/s.py:ro" \
python:3.13-slim python -u /app/s.py > /dev/null
sleep 1
start="$(date +%s.%N)"; docker stop --timeout 25 slow2 > /dev/null; end="$(date +%s.%N)"
printf ' время: %.2f c, exit code: %s\n' \
"$(echo "$end - $start" | bc)" \
"$(docker inspect slow2 --format '{{.State.ExitCode}}')"
docker logs slow2 2>&1 | tail -2 | sed 's/^/ /'
docker rm slow2 > /dev/null
время: 20.44 c, exit code: 0
завершаюсь... 20/20
завершён
Теперь завершение прошло полностью. Обратите внимание: заняло 20.44 секунды, а не 25 — grace period это максимум ожидания.
Зафиксировать таймаут на уровне образа:
FROM python:3.13-slim
COPY app.py /app/app.py
STOPSIGNAL SIGTERM
CMD ["python", "-u", "/app/app.py"]
Или при создании container: docker run --stop-timeout=25 ....
Увеличение таймаута — не универсальное решение. Оркестраторы имеют собственные пределы ожидания, и слишком долгое завершение задерживает развёртывание. Практический ориентир: укладываться в 10–30 секунд.
STOPSIGNAL для nginx
docker run -d --name nginx-default nginx:alpine > /dev/null
docker run -d --name nginx-quit --stop-signal=SIGQUIT nginx:alpine > /dev/null
sleep 2
for c in nginx-default nginx-quit; do
sig="$(docker inspect "$c" --format '{{.Config.StopSignal}}')"
start="$(date +%s.%N)"; docker stop "$c" > /dev/null; end="$(date +%s.%N)"
printf '%-16s сигнал=%-10s время=%.2f c код=%s\n' \
"$c" "${sig:-SIGTERM}" \
"$(echo "$end - $start" | bc)" \
"$(docker inspect "$c" --format '{{.State.ExitCode}}')"
docker rm "$c" > /dev/null
done
nginx-default сигнал=SIGQUIT время=0.28 c код=0
nginx-quit сигнал=SIGQUIT время=0.31 c код=0
Официальный образ nginx уже содержит STOPSIGNAL SIGQUIT — мейнтейнеры позаботились об этом. Проверить можно так:
docker image inspect nginx:alpine --format 'STOPSIGNAL: {{.Config.StopSignal}}'
STOPSIGNAL: SIGQUIT
Это хороший пример того, что стоит проверять в чужих образах: если STOPSIGNAL не задан, а приложение ожидает нестандартный сигнал, graceful shutdown работать не будет.
stop против kill
docker run -d --name k1 \
-v "$PWD/graceful.py:/app/g.py:ro" \
python:3.13-slim python -u /app/g.py > /dev/null
sleep 1
docker kill k1 > /dev/null
echo "docker kill: код $(docker inspect k1 --format '{{.State.ExitCode}}')"
echo "логи:"; docker logs k1 2>&1 | sed 's/^/ /'
docker rm k1 > /dev/null
docker kill: код 137
логи:
запущен с обработчиком сигналов
Обработчик установлен, но SIGKILL его не вызывает — ядро завершает процесс немедленно. Строк о завершении в логах нет.
docker kill умеет посылать произвольный сигнал:
docker run -d --name k2 \
-v "$PWD/graceful.py:/app/g.py:ro" \
python:3.13-slim python -u /app/g.py > /dev/null
sleep 1
docker kill --signal=SIGINT k2 > /dev/null
sleep 1
docker logs k2 2>&1 | sed 's/^/ /'
echo "код: $(docker inspect k2 --format '{{.State.ExitCode}}')"
docker rm k2 > /dev/null
запущен с обработчиком сигналов
получен SIGINT (2), начинаю завершение
закрываю соединения...
завершён корректно
код: 0
Наш обработчик перехватывает и SIGINT, поэтому завершение прошло корректно. Это способ послать приложению произвольный сигнал — например, SIGHUP для перечтения конфигурации без перезапуска.
Дочерние процессы не получают сигнал
cat > parent.py <<'PY'
import signal
import subprocess
import sys
import time
child = subprocess.Popen(
[sys.executable, "-u", "-c",
"import time\n"
"while True:\n"
" print('дочерний работает', flush=True)\n"
" time.sleep(1)\n"]
)
shutdown = False
def handle(signum, frame):
global shutdown
print(f"родитель получил сигнал {signum}", flush=True)
shutdown = True
signal.signal(signal.SIGTERM, handle)
print(f"родитель PID 1, дочерний PID {child.pid}", flush=True)
while not shutdown:
time.sleep(0.1)
print("родитель завершается, НЕ уведомив дочерний", flush=True)
sys.exit(0)
PY
docker run -d --name parent \
-v "$PWD/parent.py:/app/p.py:ro" \
python:3.13-slim python -u /app/p.py > /dev/null
sleep 3
docker exec parent ps -o pid,args
docker stop parent > /dev/null
echo "логи:"; docker logs parent 2>&1 | tail -4 | sed 's/^/ /'
docker rm parent > /dev/null
PID COMMAND
1 python -u /app/p.py
7 /usr/local/bin/python -u -c import time...
14 ps -o pid,args
логи:
дочерний работает
родитель получил сигнал 15
родитель завершается, НЕ уведомив дочерний
Дочерний процесс сигнала не получил — он был убит вместе с container, когда завершился PID 1. Если бы он что-то записывал, данные потерялись бы.
Правильный вариант — родитель уведомляет потомков:
def handle(signum, frame):
child.terminate() # послать SIGTERM дочернему
child.wait(timeout=5) # дождаться завершения
shutdown = True
Для более сложных случаев применяется рассылка сигнала группе процессов или init-процесс — урок 4.5.
Уборка
docker rmi sig:shell sig:exec sig:fixed 2>/dev/null || true
cd /tmp && rm -rf /tmp/signals
Практическое упражнение
Задание. Напишите скрипт signal-matrix.sh, который экспериментально сравнивает четыре конфигурации по времени остановки, exit code и полноте выполнения кода завершения.
Конфигурации:
- Приложение без обработчика, exec form.
- Приложение с обработчиком, shell form.
- Приложение с обработчиком, exec form.
- Приложение с обработчиком, shell form с
exec.
Для каждой выведите: время docker stop, exit code, дошёл ли сигнал до приложения.
Дополнительно объясните, почему конфигурации 3 и 4 дают одинаковый результат при разной записи CMD.
Подсказки
Подсказка 1
Признак того, что сигнал дошёл, — наличие в логах строки, которую печатает обработчик.
Подсказка 2
Измерять время удобно через date +%s.%N до и после, а разницу считать через bc или awk.
Подсказка 3
Проверить, кто является PID 1, можно так:
docker exec <c> ps -o pid,args | awk '$1==1'
Решение
Сначала выполните задание самостоятельно.
Показать решение
#!/usr/bin/env bash
# signal-matrix.sh — влияние формы CMD и обработчика на graceful shutdown.
set -euo pipefail
WORK="$(mktemp -d)"
trap 'docker rm -f $(docker ps -aq --filter "name=sigm-") >/dev/null 2>&1 || true;
docker rmi -f sigm:1 sigm:2 sigm:3 sigm:4 >/dev/null 2>&1 || true;
rm -rf "$WORK"' EXIT
cd "$WORK"
cat > app.py <<'PY'
import os
import signal
import sys
import time
HANDLED = os.environ.get("WITH_HANDLER", "1") == "1"
shutdown = False
def handle(signum, frame):
global shutdown
print(f"SIGNAL-RECEIVED {signum}", flush=True)
shutdown = True
if HANDLED:
signal.signal(signal.SIGTERM, handle)
print(f"STARTED pid={os.getpid()} handler={HANDLED}", flush=True)
while not shutdown:
time.sleep(0.1)
print("CLEANUP-DONE", flush=True)
sys.exit(0)
PY
# 1: без обработчика, exec form
cat > Dockerfile.1 <<'EOF'
FROM python:3.13-slim
COPY app.py /app.py
ENV WITH_HANDLER=0
CMD ["python", "-u", "/app.py"]
EOF
# 2: с обработчиком, shell form
cat > Dockerfile.2 <<'EOF'
FROM python:3.13-slim
COPY app.py /app.py
ENV WITH_HANDLER=1
CMD python -u /app.py
EOF
# 3: с обработчиком, exec form
cat > Dockerfile.3 <<'EOF'
FROM python:3.13-slim
COPY app.py /app.py
ENV WITH_HANDLER=1
CMD ["python", "-u", "/app.py"]
EOF
# 4: с обработчиком, shell form + exec
cat > Dockerfile.4 <<'EOF'
FROM python:3.13-slim
COPY app.py /app.py
ENV WITH_HANDLER=1
CMD exec python -u /app.py
EOF
for i in 1 2 3 4; do
docker build -q -f "Dockerfile.$i" -t "sigm:$i" . > /dev/null
done
LABELS=(
"1|без обработчика, exec form"
"2|с обработчиком, SHELL form"
"3|с обработчиком, exec form"
"4|с обработчиком, shell + exec"
)
printf '%-32s %-10s %-6s %-10s %s\n' КОНФИГУРАЦИЯ ВРЕМЯ КОД 'СИГНАЛ' 'PID 1'
printf '%s\n' "-------------------------------------------------------------------------------"
for entry in "${LABELS[@]}"; do
i="${entry%%|*}"; label="${entry#*|}"
name="sigm-$i"
docker run -d --name "$name" "sigm:$i" > /dev/null
sleep 1.2
pid1="$(docker exec "$name" ps -o pid,args 2>/dev/null \
| awk '$1==1 {$1=""; print substr($0,2)}' | cut -c1-22)"
start="$(date +%s.%N)"
docker stop "$name" > /dev/null
end="$(date +%s.%N)"
elapsed="$(awk -v a="$start" -v b="$end" 'BEGIN{printf "%.2f", b-a}')"
code="$(docker inspect "$name" --format '{{.State.ExitCode}}')"
logs="$(docker logs "$name" 2>&1)"
if grep -q 'SIGNAL-RECEIVED' <<< "$logs"; then
sig="дошёл"
else
sig="НЕ дошёл"
fi
grep -q 'CLEANUP-DONE' <<< "$logs" || sig="$sig*"
printf '%-32s %-10s %-6s %-10s %s\n' "$label" "${elapsed}c" "$code" "$sig" "$pid1"
docker rm "$name" > /dev/null
done
echo
echo "* — код завершения не выполнился полностью"
Ожидаемый вывод:
КОНФИГУРАЦИЯ ВРЕМЯ КОД СИГНАЛ PID 1
-------------------------------------------------------------------------------
без обработчика, exec form 10.31c 137 НЕ дошёл* python -u /app.py
с обработчиком, SHELL form 10.28c 137 НЕ дошёл* /bin/sh -c python -u
с обработчиком, exec form 0.42c 0 дошёл python -u /app.py
с обработчиком, shell + exec 0.41c 0 дошёл python -u /app.py
* — код завершения не выполнился полностью
Разбор по строкам.
Конфигурация 1. Форма записи правильная — приложение является PID 1. Но обработчика нет, а PID 1 без обработчика игнорирует SIGTERM (особенность ядра). Десять секунд, затем SIGKILL.
Конфигурация 2. Обработчик есть, но PID 1 — это /bin/sh. Сигнал получила оболочка, приложению не переслала. Тот же результат при полностью корректном коде приложения.
Конфигурация 3. Обработчик есть, приложение является PID 1. Сигнал доставлен, завершение штатное, код 0.
Конфигурация 4. Запись CMD строкой, но встроенная команда exec заменяет процесс оболочки процессом Python. В памяти остаётся один процесс, и он является PID 1.
Почему 3 и 4 совпадают. Обе дают одинаковое дерево процессов — колонка «PID 1» это подтверждает. Разница только в том, как это дерево получилось: в третьем случае оболочка не запускалась вовсе, в четвёртом запустилась и немедленно заменила себя через execve().
Отсюда практический вывод: важна не форма записи сама по себе, а то, какой процесс окажется PID 1. Exec form гарантирует это автоматически; shell form — только при явном exec.
Два условия graceful shutdown. Эксперимент показывает, что нужны оба одновременно:
- Приложение должно быть PID 1 (или сигнал должен до него доходить).
- Приложение должно иметь обработчик сигнала.
Невыполнение любого даёт одинаковый наблюдаемый результат — десять секунд и код 137, — поэтому при диагностике проверять нужно оба.
Проверка результата
docker run -d --name check python:3.13-slim python -c "import time; time.sleep(300)" > /dev/null
sleep 1
time docker stop check
docker inspect check --format 'код: {{.State.ExitCode}}'
docker rm check > /dev/null
Ожидается около 10 секунд и код 137. Объясните, почему, — и вы поняли урок.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Shell form в CMD | Короче писать | PID 1 становится sh, сигнал не доходит. Использовать exec form |
Нет обработчика SIGTERM | В обычном процессе он не нужен | PID 1 игнорирует сигналы без обработчика. Установить явно |
docker stop --timeout 1 для ускорения | Маскирует проблему | Найти причину: форма CMD или отсутствие обработчика |
docker kill вместо docker stop | Быстрее | SIGKILL не даёт завершиться корректно; данные теряются |
Ожидание кода 143 после docker stop | Так пишут в статьях | Обработавшее сигнал приложение даёт 0; 143 — признак --init |
Толкование кода 137 только как OOM | Частая ассоциация | Это SIGKILL от кого угодно. Проверять .State.OOMKilled |
| Дочерние процессы не уведомляются | Сигнал получает только PID 1 | Пересылать сигнал потомкам или использовать --init |
"$@" вместо exec "$@" в entrypoint | Не очевидно | Без exec оболочка остаётся PID 1 |
| Завершение дольше grace period | Не измерялось | Уложиться в таймаут или увеличить его через --stop-timeout |
Игнорирование STOPSIGNAL чужого образа | Предполагается SIGTERM | Проверять docker image inspect --format '{{.Config.StopSignal}}' |
Контрольные вопросы
На понимание:
- Опишите точную последовательность действий Docker при
docker stop. - Почему приложение, запущенное через shell form, не получает
SIGTERM? - Почему PID 1 без обработчика игнорирует
SIGTERM, хотя обычный процесс завершился бы? - В чём разница между exit code
0,143и137при остановке? - Почему
SIGKILLневозможно перехватить?
На применение:
- Как убедиться, что приложение действительно получает
SIGTERM? - Как сохранить возможности оболочки в
CMDи при этом обеспечить доставку сигнала? - Как узнать, какой сигнал остановки настроен в чужом образе?
На диагностику:
docker stopзанимает ровно 10 секунд, код137. Назовите две независимые причины и способ различить их.- Приложение обрабатывает
SIGTERM, но в логах видно, что завершение оборвалось на середине. Что проверить?
Краткое резюме
docker stopпосылаетSIGTERMпроцессу PID 1, ждёт grace period (10 с), затем посылаетSIGKILL.- Сигнал получает только PID 1; дочерние процессы нужно уведомлять самостоятельно.
- Shell form в
CMDделает PID 1 оболочкой — сигнал до приложения не доходит. - Exec form или явный
execв shell form решают проблему. - PID 1 без установленного обработчика игнорирует
SIGTERM— это особенность ядра. - Для graceful shutdown нужны оба условия: приложение является PID 1 и имеет обработчик.
- Exit code
0— обработано корректно,143—SIGTERMбез обработчика,137—SIGKILL. SIGKILLне перехватывается: обработчики не вызываются, буферы не сбрасываются.STOPSIGNALи grace period настраиваются вDockerfile, приdocker runи в Compose.- Завершение должно укладываться в grace period, иначе оно будет оборвано на середине.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| docker stop reference | https://docs.docker.com/reference/cli/docker/container/stop/ | Последовательность SIGTERM → grace period → SIGKILL, флаг --timeout |
| docker kill reference | https://docs.docker.com/reference/cli/docker/container/kill/ | Немедленный SIGKILL, флаг --signal |
| Dockerfile reference: STOPSIGNAL | https://docs.docker.com/reference/dockerfile/#stopsignal | Настройка сигнала остановки в образе |
| Dockerfile reference: CMD | https://docs.docker.com/reference/dockerfile/#cmd | Exec form и shell form, оборачивание в /bin/sh -c |
| Dockerfile reference: ENTRYPOINT | https://docs.docker.com/reference/dockerfile/#entrypoint | Влияние формы записи на доставку сигналов |
| docker run reference | https://docs.docker.com/reference/cli/docker/container/run/ | Флаги --stop-signal, --stop-timeout |
| Compose services | https://docs.docker.com/reference/compose-file/services/ | stop_signal, stop_grace_period |
signal(7) man page | https://man7.org/linux/man-pages/man7/signal.7.html | Номера сигналов, неперехватываемость SIGKILL |
pid_namespaces(7) man page | https://man7.org/linux/man-pages/man7/pid_namespaces.7.html | Особая обработка сигналов для PID 1 |
Python: signal | https://docs.python.org/3/library/signal.html | Установка обработчиков, ограничения |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → PID 1 и init
Главное оглавление