Главная/Containers и lifecycle/Урок

4.4. Сигналы и graceful shutdown

Цели

После этого материала вы сможете:

  • описать точную последовательность действий Docker при docker stop;
  • объяснить, почему приложение, запущенное через shell form, не получает SIGTERM;
  • реализовать корректное завершение и доказать его работу измерением;
  • настроить STOPSIGNAL и grace period под конкретное приложение;
  • объяснить, почему SIGKILL нельзя перехватить, и что теряется при его получении;
  • диагностировать ситуацию «docker stop занимает ровно 10 секунд»;
  • учитывать сигналы при работе с несколькими процессами в container.

Предварительные знания

Ключевые термины

ТерминОбъяснение
сигналАсинхронное уведомление процессу от ядра или другого процесса
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

Точная последовательность:

text
   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.

dockerfile
FROM python:3.13-slim
COPY app.py /app.py
CMD python /app.py

Последняя строка превращается в:

text
/bin/sh -c "python /app.py"

Дерево процессов внутри container:

text
   PID 1   /bin/sh -c "python /app.py"   ← сигнал приходит сюда
   PID 7   python /app.py                ← а работает здесь

docker stop посылает SIGTERM процессу PID 1 — оболочке sh. Оболочка в неинтерактивном режиме, ожидающая завершения дочернего процесса, обычно не пересылает сигнал потомку. Результат: python не узнаёт о необходимости завершиться, проходит 10 секунд, SIGKILL убивает оба процесса.

Exec form решает проблему:

dockerfile
FROM python:3.13-slim
COPY app.py /app.py
CMD ["python", "/app.py"]
text
   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, завершился бы.

Проверить легко:

bash
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=30Container
stop_grace_period: 30s в ComposeСервис
shutdown-timeout в daemon.jsonВсе containers на host

Что теряется при SIGKILL

Конкретика, объясняющая, почему это важно:

  • HTTP-запросы обрываются — клиент получает ошибку соединения вместо ответа;
  • Транзакции БД откатываются — иногда после таймаута на стороне сервера, оставляя блокировки;
  • Сообщения очереди теряются или дублируются — зависит от того, было ли подтверждение;
  • Буферы не сброшены — последние записи логов и данных не попадают на диск;
  • Временные файлы остаются — код очистки не выполнился;
  • Блокировки не сняты — следующий запуск может не стартовать.

Внутренний механизм

Как сигнал доходит до процесса

  1. CLI отправляет POST /containers/<id>/stop?t=10.
  2. Daemon читает StopSignal из конфигурации container (по умолчанию SIGTERM).
  3. Через containerd вызывается отправка сигнала задаче.
  4. Shim выполняет kill(pid, signal) для процесса PID 1 в PID namespace container.
  5. Daemon ждёт завершения задачи, но не дольше t секунд.
  6. По истечении отправляется SIGKILL.

Шаг 4 объясняет, почему сигнал получает только один процесс: kill() адресуется конкретному PID, а не группе.

Почему exit code равен 143 или 137

При завершении процесса сигналом оболочка и Docker сообщают код по формуле 128 + номер сигнала:

СигналНомерExit code
SIGHUP1129
SIGINT2130
SIGKILL9137
SIGTERM15143

Практическая интерпретация:

  • 143 — PID 1 умер от SIGTERM. Вопреки распространённому объяснению, это не случай «нет обработчика»: для PID 1 ядро не применяет действие по умолчанию, поэтому без обработчика приходит SIGKILL и код будет 137. Код 143 даёт --init: tini пересылает сигнал потомку и сам завершается с 128+15.
  • 137 — процесс убит SIGKILL. Причины: истёк grace period, docker kill, или OOM killer. Различить помогает поле .State.OOMKilled.
  • 0 — приложение обработало сигнал и завершилось само. Целевое состояние.

Команды и примеры

Подготовка

bash
mkdir -p /tmp/signals && cd /tmp/signals

Три варианта одного приложения — для сравнения поведения.

no_handler.py — без обработчика:

bash
cat > no_handler.py <<'PY'
import time

print("запущен без обработчика сигналов", flush=True)
while True:
    time.sleep(0.5)
PY

graceful.py — с обработчиком:

bash
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:

bash
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

Сравнение: с обработчиком и без

bash
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
text
=== Без обработчика ===
  время 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 ломает доставку сигнала

bash
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

Посмотрим на дерево процессов:

bash
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
text
=== 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. Проверим последствия:

bash
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
text
--- 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:

bash
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
text
PID   COMMAND
    1 python -u /app/graceful.py
    7 ps -o pid,args

Встроенная команда exec заменяет процесс оболочки процессом приложения — sh не остаётся в памяти, приложение становится PID 1.

bash
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
text
время: 0.43 c, exit code: 0

Тот же приём применяется в entrypoint-скриптах: последняя строка должна быть exec "$@", а не просто "$@".

Grace period и медленное завершение

bash
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
text
--- таймаут по умолчанию (10 с), а завершение требует 20 ---
  время: 10.31 c, exit code: 137
  успело выполниться:
    завершаюсь... 8/20
    завершаюсь... 9/20
    завершаюсь... 10/20

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

Увеличим таймаут:

bash
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
text
  время: 20.44 c, exit code: 0
    завершаюсь... 20/20
    завершён

Теперь завершение прошло полностью. Обратите внимание: заняло 20.44 секунды, а не 25 — grace period это максимум ожидания.

Зафиксировать таймаут на уровне образа:

dockerfile
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

bash
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
text
nginx-default    сигнал=SIGQUIT    время=0.28 c  код=0
nginx-quit       сигнал=SIGQUIT    время=0.31 c  код=0

Официальный образ nginx уже содержит STOPSIGNAL SIGQUIT — мейнтейнеры позаботились об этом. Проверить можно так:

bash
docker image inspect nginx:alpine --format 'STOPSIGNAL: {{.Config.StopSignal}}'
text
STOPSIGNAL: SIGQUIT

Это хороший пример того, что стоит проверять в чужих образах: если STOPSIGNAL не задан, а приложение ожидает нестандартный сигнал, graceful shutdown работать не будет.

stop против kill

bash
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
text
docker kill: код 137
логи:
  запущен с обработчиком сигналов

Обработчик установлен, но SIGKILL его не вызывает — ядро завершает процесс немедленно. Строк о завершении в логах нет.

docker kill умеет посылать произвольный сигнал:

bash
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
text
  запущен с обработчиком сигналов
  получен SIGINT (2), начинаю завершение
  закрываю соединения...
  завершён корректно
  код: 0

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

Дочерние процессы не получают сигнал

bash
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
text
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. Если бы он что-то записывал, данные потерялись бы.

Правильный вариант — родитель уведомляет потомков:

python
def handle(signum, frame):
    child.terminate()          # послать SIGTERM дочернему
    child.wait(timeout=5)      # дождаться завершения
    shutdown = True

Для более сложных случаев применяется рассылка сигнала группе процессов или init-процесс — урок 4.5.

Уборка

bash
docker rmi sig:shell sig:exec sig:fixed 2>/dev/null || true
cd /tmp && rm -rf /tmp/signals

Практическое упражнение

Задание. Напишите скрипт signal-matrix.sh, который экспериментально сравнивает четыре конфигурации по времени остановки, exit code и полноте выполнения кода завершения.

Конфигурации:

  1. Приложение без обработчика, exec form.
  2. Приложение с обработчиком, shell form.
  3. Приложение с обработчиком, exec form.
  4. Приложение с обработчиком, shell form с exec.

Для каждой выведите: время docker stop, exit code, дошёл ли сигнал до приложения.

Дополнительно объясните, почему конфигурации 3 и 4 дают одинаковый результат при разной записи CMD.

Подсказки

Подсказка 1

Признак того, что сигнал дошёл, — наличие в логах строки, которую печатает обработчик.

Подсказка 2

Измерять время удобно через date +%s.%N до и после, а разницу считать через bc или awk.

Подсказка 3

Проверить, кто является PID 1, можно так:

bash
docker exec <c> ps -o pid,args | awk '$1==1'

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
#!/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 "* — код завершения не выполнился полностью"

Ожидаемый вывод:

text
КОНФИГУРАЦИЯ                     ВРЕМЯ      КОД    СИГНАЛ     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. Эксперимент показывает, что нужны оба одновременно:

  1. Приложение должно быть PID 1 (или сигнал должен до него доходить).
  2. Приложение должно иметь обработчик сигнала.

Невыполнение любого даёт одинаковый наблюдаемый результат — десять секунд и код 137, — поэтому при диагностике проверять нужно оба.

Проверка результата

bash
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}}'

Контрольные вопросы

На понимание:

  1. Опишите точную последовательность действий Docker при docker stop.
  2. Почему приложение, запущенное через shell form, не получает SIGTERM?
  3. Почему PID 1 без обработчика игнорирует SIGTERM, хотя обычный процесс завершился бы?
  4. В чём разница между exit code 0, 143 и 137 при остановке?
  5. Почему SIGKILL невозможно перехватить?

На применение:

  1. Как убедиться, что приложение действительно получает SIGTERM?
  2. Как сохранить возможности оболочки в CMD и при этом обеспечить доставку сигнала?
  3. Как узнать, какой сигнал остановки настроен в чужом образе?

На диагностику:

  1. docker stop занимает ровно 10 секунд, код 137. Назовите две независимые причины и способ различить их.
  2. Приложение обрабатывает SIGTERM, но в логах видно, что завершение оборвалось на середине. Что проверить?

Краткое резюме

  1. docker stop посылает SIGTERM процессу PID 1, ждёт grace period (10 с), затем посылает SIGKILL.
  2. Сигнал получает только PID 1; дочерние процессы нужно уведомлять самостоятельно.
  3. Shell form в CMD делает PID 1 оболочкой — сигнал до приложения не доходит.
  4. Exec form или явный exec в shell form решают проблему.
  5. PID 1 без установленного обработчика игнорирует SIGTERM — это особенность ядра.
  6. Для graceful shutdown нужны оба условия: приложение является PID 1 и имеет обработчик.
  7. Exit code 0 — обработано корректно, 143SIGTERM без обработчика, 137SIGKILL.
  8. SIGKILL не перехватывается: обработчики не вызываются, буферы не сбрасываются.
  9. STOPSIGNAL и grace period настраиваются в Dockerfile, при docker run и в Compose.
  10. Завершение должно укладываться в grace period, иначе оно будет оборвано на середине.

Официальные источники

ИсточникСсылкаЧто подтверждает
docker stop referencehttps://docs.docker.com/reference/cli/docker/container/stop/Последовательность SIGTERM → grace period → SIGKILL, флаг --timeout
docker kill referencehttps://docs.docker.com/reference/cli/docker/container/kill/Немедленный SIGKILL, флаг --signal
Dockerfile reference: STOPSIGNALhttps://docs.docker.com/reference/dockerfile/#stopsignalНастройка сигнала остановки в образе
Dockerfile reference: CMDhttps://docs.docker.com/reference/dockerfile/#cmdExec form и shell form, оборачивание в /bin/sh -c
Dockerfile reference: ENTRYPOINThttps://docs.docker.com/reference/dockerfile/#entrypointВлияние формы записи на доставку сигналов
docker run referencehttps://docs.docker.com/reference/cli/docker/container/run/Флаги --stop-signal, --stop-timeout
Compose serviceshttps://docs.docker.com/reference/compose-file/services/stop_signal, stop_grace_period
signal(7) man pagehttps://man7.org/linux/man-pages/man7/signal.7.htmlНомера сигналов, неперехватываемость SIGKILL
pid_namespaces(7) man pagehttps://man7.org/linux/man-pages/man7/pid_namespaces.7.htmlОсобая обработка сигналов для PID 1
Python: signalhttps://docs.python.org/3/library/signal.htmlУстановка обработчиков, ограничения

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → PID 1 и init
Главное оглавление

Markdown на GitHub ↗