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

4.5. PID 1 и init

Цели

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

  • объяснить два особых свойства PID 1 в Linux и их последствия для containers;
  • воспроизвести zombie process в container и объяснить механизм его появления;
  • объяснить, чем zombie вреден и когда он становится проблемой;
  • применять флаг --init осознанно и знать, что именно он добавляет;
  • выбирать между --init, tini в образе и обработкой в приложении;
  • определять, нужен ли init-процесс конкретному приложению.

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

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

ТерминОбъяснение
PID 1Первый процесс в PID namespace. В обычной системе — init или systemd
zombieЗавершившийся процесс, чей статус ещё не собран родителем. Состояние Z в ps
reapingСбор статуса завершившихся потомков вызовом wait()
orphanПроцесс, чей родитель завершился раньше него
reparentingПередача осиротевшего процесса под опеку PID 1
initПроцесс, отвечающий за сбор осиротевших потомков и раздачу сигналов
tiniМинимальный init-процесс, встроенный в Docker и доступный через --init

Теория

Два особых свойства PID 1

Ядро Linux относится к процессу с PID 1 иначе, чем к остальным. Отличий ровно два, и оба влияют на containers.

Свойство 1: сигналы без обработчика игнорируются.

Для обычного процесса SIGTERM без установленного обработчика приводит к завершению — это действие по умолчанию. Для PID 1 ядро не применяет действие по умолчанию: сигнал, для которого нет явного обработчика, просто отбрасывается.

Причина историческая и разумная: PID 1 в обычной системе — это init. Его случайное завершение остановило бы всю систему, поэтому ядро защищает его от сигналов, которые он не готов обработать.

Последствие для containers: приложение, запущенное как PID 1 без signal.signal(...), проигнорирует docker stop. Это разбиралось в уроке 4.4.

Исключение: SIGKILL и SIGSTOP не могут быть перехвачены или проигнорированы никем, включая PID 1.

Свойство 2: осиротевшие процессы передаются PID 1.

Когда процесс завершается раньше своих потомков, потомки становятся сиротами. Ядро назначает им нового родителя — PID 1. Это называется reparenting.

Отсюда обязанность PID 1: собирать статусы завершившихся потомков вызовом wait(). Если он этого не делает, накапливаются zombie.

Что такое zombie

Когда процесс завершается, ядро не удаляет его запись сразу. Оно сохраняет минимальную информацию — PID и код возврата — до тех пор, пока родитель не заберёт её вызовом wait().

В этом промежутке процесс находится в состоянии Z (zombie): он не выполняется, не занимает память под код и данные, но занимает запись в таблице процессов.

text
   процесс завершился
          │
          ▼
   ┌─────────────────┐   родитель вызвал wait()   ┌──────────────┐
   │  zombie (Z)     │ ─────────────────────────► │  запись      │
   │  занимает PID   │                            │  удалена     │
   └─────────────────┘                            └──────────────┘
          │
          │ родитель не вызывает wait()
          ▼
   zombie остаётся навсегда
   (пока жив родитель)

Zombie исчезает в двух случаях: родитель вызвал wait() либо родитель сам завершился — тогда zombie переходит к PID 1, и его собирает init.

Вторая ветка — причина того, что в обычной системе zombie редки: systemd регулярно вызывает wait().

Почему это проблема в containers

В container PID 1 — это ваше приложение, а не systemd. Типичное приложение не написано как init: оно не ожидает чужих потомков и не вызывает wait() в цикле.

Сценарий накопления:

text
   PID 1  приложение
     │
     ├─ порождает вспомогательный процесс (PID 20)
     │     │
     │     └─ порождает свой потомок (PID 21)
     │
     └─ PID 20 завершается
             │
             ▼
        PID 21 осиротел → передан PID 1
             │
             ▼
        PID 21 завершается → zombie
             │
             ▼
        PID 1 не вызывает wait() → zombie навсегда

Каждая итерация оставляет одну запись в таблице процессов.

Насколько это опасно

Честная оценка: обычно не опасно, иногда критично.

Zombie не потребляет CPU и почти не потребляет память — только запись в таблице процессов. Проблема возникает при накоплении.

СитуацияРиск
Приложение не порождает подпроцессовНет. Init не нужен
Порождает и корректно ждёт (subprocess.run)Нет
Порождает изредка, container живёт часыПренебрежимо мал
Порождает часто, container живёт неделямиРеален: исчерпание PID
Задан --pids-limitПовышен: лимит достигается быстрее

При исчерпании лимита PID — системного kernel.pid_max или заданного --pids-limit — вызовы fork() начинают возвращать EAGAIN. Приложение получает Resource temporarily unavailable и перестаёт работать, при том что память и CPU свободны. Диагностировать это без понимания механизма трудно.

Вторая проблема, не связанная с исчерпанием: PID 1, не пересылающий сигналы потомкам, оставляет их на SIGKILL при остановке container. Данные, которые они не успели записать, теряются.

Что делает --init

Флаг вставляет между Docker и приложением минимальный init-процесс — tini:

text
   без --init                    с --init
   ──────────                    ────────
   PID 1  приложение             PID 1  /sbin/docker-init (tini)
     └─ потомки                    └─ PID 7  приложение
                                        └─ потомки

Tini выполняет две функции:

  1. Собирает zombie. В цикле вызывает wait() для всех потомков, включая переданных ему сирот.
  2. Пересылает сигналы. Полученный SIGTERM передаёт дочернему процессу.

Размер tini — около 20 KB, потребление ресурсов пренебрежимо.

Важное следствие второй функции: с --init приложение перестаёт быть PID 1, а значит, к нему применяются обычные правила обработки сигналов. Приложение без обработчика SIGTERM теперь корректно завершится по действию по умолчанию.

Это делает --init частичным решением и проблемы сигналов тоже — но только частичным: завершение будет немедленным, без graceful shutdown. Закрыть соединения и сбросить буферы всё равно может только само приложение.

Три подхода

ПодходКогда применятьПлюсыМинусы
Ничего не делатьПриложение не порождает подпроцессовПроще всегоНе работает при подпроцессах
--init / init: trueПриложение порождает подпроцессы, но менять его нельзяОдна строка конфигурацииЛишний процесс; не даёт graceful shutdown
Обработка в приложенииОсновной путь для своего кодаПолный контроль, настоящий graceful shutdownНужно писать код
tini в образеНужен init независимо от способа запускаРаботает без флагаУсложняет Dockerfile

Рекомендация курса: писать обработчик сигналов в приложении и добавлять --init там, где приложение действительно порождает подпроцессы, за которыми не следит. Эти подходы дополняют друг друга, а не заменяют.


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

Почему ядро игнорирует сигналы для PID 1

В функции доставки сигнала ядро проверяет, является ли процесс init своего PID namespace. Если да и обработчик не установлен явно — сигнал отбрасывается.

Формулировка из документации ядра: процесс init получает только те сигналы, для которых установлены обработчики. Это защищает от случайного завершения init.

Правило распространяется на PID 1 любого PID namespace, не только начального. Поэтому оно действует и в containers.

Как происходит reparenting

При завершении процесса ядро перебирает его потомков и назначает им нового родителя. По умолчанию это PID 1 текущего PID namespace.

Начиная с Linux 3.4 процесс может объявить себя «subreaper» через prctl(PR_SET_CHILD_SUBREAPER). Тогда осиротевшие потомки в его поддереве достаются ему, а не PID 1. Этот механизм использует systemd для служб — и его же может использовать приложение, которому нужен контроль над потомками.

Как tini собирает zombie

Основной цикл tini:

  1. Установить обработчики для всех сигналов, которые можно перехватить.
  2. Породить дочерний процесс — приложение.
  3. В цикле вызывать waitpid(-1, ...) с флагом WNOHANG, собирая всех завершившихся потомков.
  4. Полученные сигналы пересылать дочернему процессу.
  5. Когда завершится основной дочерний процесс — завершиться с его кодом возврата.

Шаг 3 с аргументом -1 — ключевой: собираются любые потомки, включая переданных сирот.


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

Свойство 1: PID 1 игнорирует сигналы

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

# скрипт без обработчика
cat > plain.py <<'PY'
import os
import time

print(f"мой PID: {os.getpid()}", flush=True)
while True:
    time.sleep(0.5)
PY

Как PID 1:

bash
docker run -d --name as-pid1 \
    -v "$PWD/plain.py:/app.py:ro" \
    python:3.13-slim python -u /app.py > /dev/null
sleep 1
docker logs as-pid1
time docker stop as-pid1
docker inspect as-pid1 --format 'код: {{.State.ExitCode}}'
docker rm as-pid1 > /dev/null
text
мой PID: 1

real	0m10.294s
код: 137

Тот же скрипт, но не как PID 1 — запустим его под оболочкой, которая останется PID 1:

bash
docker run -d --name not-pid1 \
    -v "$PWD/plain.py:/app.py:ro" \
    python:3.13-slim sh -c 'python -u /app.py & wait' > /dev/null
sleep 1
docker exec not-pid1 ps -o pid,args | head -4
docker rm -f not-pid1 > /dev/null
text
PID   COMMAND
    1 sh -c python -u /app.py & wait
    7 python -u /app.py
   13 ps -o pid,args

Здесь Python имеет PID 7. Если послать SIGTERM непосредственно ему, он завершится по действию по умолчанию:

bash
docker run -d --name direct \
    -v "$PWD/plain.py:/app.py:ro" \
    python:3.13-slim sh -c 'python -u /app.py & wait' > /dev/null
sleep 1
docker exec direct sh -c 'kill -TERM 7'
sleep 1
docker exec direct ps -o pid,args 2>/dev/null | grep -c 'app.py' || echo "процесс завершился"
docker rm -f direct > /dev/null
text
процесс завершился

Один и тот же код: как PID 1 сигнал игнорируется, как обычный процесс — завершает.

Свойство 2: воспроизведение zombie

bash
cat > spawner.py <<'PY'
import os
import subprocess
import sys
import time

print(f"PID 1 = {os.getpid()}", flush=True)

# Каждую секунду порождаем процесс, который сам порождает потомка
# и немедленно завершается, оставляя сироту.
for i in range(6):
    subprocess.Popen([
        sys.executable, "-c",
        "import subprocess, sys, os\n"
        # внук: живёт полсекунды и завершается
        "subprocess.Popen([sys.executable, '-c', "
        "'import time; time.sleep(0.5)'])\n"
        # родитель завершается сразу — внук становится сиротой
        "os._exit(0)\n"
    ])
    time.sleep(1)

print("порождение закончено, ждём", flush=True)
while True:
    time.sleep(1)
PY

Без --init:

bash
docker run -d --name zombies \
    -v "$PWD/spawner.py:/app.py:ro" \
    python:3.13-slim python -u /app.py > /dev/null
sleep 10

# ps в python:3.13-slim нет — читаем /proc напрямую.
# Поля /proc/PID/stat: 1 — pid, 2 — (comm), 3 — состояние, 4 — ppid.
proctable() {
    docker exec "$1" sh -c '
        printf "%5s %5s %-4s %s\n" PID PPID STAT COMMAND
        for f in /proc/[0-9]*/stat; do
            set -- $(cat "$f" 2>/dev/null) || continue
            comm=${2#(}; comm=${comm%)}
            printf "%5s %5s %-4s %s\n" "$1" "$4" "$3" "$comm"
        done | sort -n'
}
zombies_count() {
    docker exec "$1" sh -c '
        n=0
        for f in /proc/[0-9]*/stat; do
            set -- $(cat "$f" 2>/dev/null)
            [ "$3" = Z ] && n=$((n+1))
        done
        echo $n'
}

echo "=== процессы в container ==="
proctable zombies
echo
echo "zombie-процессов: $(zombies_count zombies)"
text
=== процессы в container ===
  PID  PPID STAT COMMAND
    1     0 S    python
    8     1 Z    python
   10     1 Z    python
   12     1 Z    python
   14     1 Z    python
   16     1 Z    python
   17     1 Z    python
   18     1 Z    python
   19     0 S    sh
   25    19 S    sh
   26    19 S    sort

zombie-процессов: 7

Семь записей в состоянии Z с PPID 1 — это осиротевшие внуки, переданные PID 1 и не собранные им. Их число зависит от того, сколько секунд отработал spawner.py до замера; строки с sh и sort — сама команда, читающая /proc.

Обратите внимание: приложение работает нормально, никаких ошибок нет. Проблема накапливается молча.

С --init:

bash
docker rm -f zombies > /dev/null
docker run -d --name no-zombies --init \
    -v "$PWD/spawner.py:/app.py:ro" \
    python:3.13-slim python -u /app.py > /dev/null
sleep 10

echo "=== процессы с --init ==="
proctable no-zombies
echo
echo "zombie-процессов: $(zombies_count no-zombies)"
text
=== процессы с --init ===
  PID  PPID STAT COMMAND
    1     0 S    docker-init
    7     1 S    python
   18     7 Z    python
   20     0 S    sh
   26    20 S    sh
   27    20 S    sort

zombie-процессов: 1

PID 1 теперь docker-init (tini), приложение получило PID 7. Семи zombie с PPID 1 больше нет — tini их собрал.

Но один остался, и он важнее остальных семи. Посмотрите на его PPID: 7, а не 1. Это не осиротевший внук, а прямой потомок самого приложения — процесс, который subprocess.Popen породил, а Python ещё не дождался.

Отсюда точная формулировка того, что делает --init:

Кто родитель zombieСоберёт ли tini
PID 1 (процесс осиротел)да
Само приложениенет — это его работа

--init закрывает дыру, которую приложение закрыть не может: сирот, переданных ему ядром. Ответственности вызывать wait() за своими детьми он не снимает. Три прогона подряд дают один и тот же результат — zombie=1, ppid=7, — так что это свойство, а не случайность замера.

Бонус --init: сигналы работают без обработчика

bash
echo "--- без --init, без обработчика ---"
docker run -d --name t-noinit \
    -v "$PWD/plain.py:/app.py:ro" \
    python:3.13-slim python -u /app.py > /dev/null
sleep 1
start="$(date +%s.%N)"; docker stop t-noinit > /dev/null; end="$(date +%s.%N)"
printf '  время: %.2f c  код: %s\n' \
    "$(awk -v a="$start" -v b="$end" 'BEGIN{printf "%.2f", b-a}')" \
    "$(docker inspect t-noinit --format '{{.State.ExitCode}}')"

echo "--- с --init, без обработчика ---"
docker run -d --name t-init --init \
    -v "$PWD/plain.py:/app.py:ro" \
    python:3.13-slim python -u /app.py > /dev/null
sleep 1
start="$(date +%s.%N)"; docker stop t-init > /dev/null; end="$(date +%s.%N)"
printf '  время: %.2f c  код: %s\n' \
    "$(awk -v a="$start" -v b="$end" 'BEGIN{printf "%.2f", b-a}')" \
    "$(docker inspect t-init --format '{{.State.ExitCode}}')"

docker rm t-noinit t-init > /dev/null
text
--- без --init, без обработчика ---
  время: 10.31 c  код: 137
--- с --init, без обработчика ---
  время: 0.38 c  код: 143

С --init остановка мгновенная. Причина: приложение больше не PID 1, поэтому SIGTERM применяется по действию по умолчанию.

Но обратите внимание на код: 143, а не 0. Приложение было завершено сигналом, а не вышло само. Никакого graceful shutdown не произошло — соединения не закрыты, буферы не сброшены.

Вывод: --init чинит доставку сигнала, но не заменяет обработчик в приложении.

Полное решение: --init плюс обработчик

bash
cat > full.py <<'PY'
import os
import signal
import subprocess
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(f"PID {os.getpid()}", flush=True)

# порождаем сирот, как раньше
for _ in range(3):
    subprocess.Popen([
        sys.executable, "-c",
        "import subprocess, sys, os\n"
        "subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(0.5)'])\n"
        "os._exit(0)\n"
    ])
    time.sleep(0.5)

while not shutdown:
    time.sleep(0.1)

print("закрываю ресурсы...", flush=True)
time.sleep(0.2)
print("завершён корректно", flush=True)
sys.exit(0)
PY

docker run -d --name full --init \
    -v "$PWD/full.py:/app.py:ro" \
    python:3.13-slim python -u /app.py > /dev/null
sleep 4

echo "zombie: $(docker exec full sh -c "ps -o stat= | grep -c '^Z'" || echo 0)"
start="$(date +%s.%N)"; docker stop full > /dev/null; end="$(date +%s.%N)"
printf 'время: %.2f c  код: %s\n' \
    "$(awk -v a="$start" -v b="$end" 'BEGIN{printf "%.2f", b-a}')" \
    "$(docker inspect full --format '{{.State.ExitCode}}')"
docker logs full 2>&1 | tail -3 | sed 's/^/  /'
docker rm full > /dev/null
text
zombie: 0
время: 0.44 c  код: 0
  получен сигнал 15
  закрываю ресурсы...
  завершён корректно

Целевое состояние: нет zombie, быстрая остановка, код 0, код завершения выполнен.

Исчерпание PID

Покажем, почему zombie всё же опасны, — с малым --pids-limit:

bash
cat > flood.py <<'PY'
import subprocess
import sys
import time

count = 0
try:
    while True:
        subprocess.Popen([
            sys.executable, "-c",
            "import subprocess, sys, os\n"
            "subprocess.Popen([sys.executable, '-c', 'pass'])\n"
            "os._exit(0)\n"
        ])
        count += 1
        time.sleep(0.05)
except BlockingIOError as e:
    print(f"FORK FAILED после {count} итераций: {e}", flush=True)
except OSError as e:
    print(f"OSError после {count} итераций: {e}", flush=True)
PY

docker run --rm --name flood --pids-limit=25 \
    -v "$PWD/flood.py:/app.py:ro" \
    python:3.13-slim timeout 25 python -u /app.py 2>&1 | tail -3
text
FORK FAILED после 23 итераций: [Errno 11] Resource temporarily unavailable

Сработала первая ветвь except, и это не случайность: BlockingIOError — подкласс OSError, а EAGAIN при исчерпании PID поднимает именно его. Вторая ветвь в этом сценарии недостижима и оставлена как страховка на случай другой ошибки. По тексту сообщения видно, какая из них выполнилась, — поэтому они и различаются.

Число итераций у вас будет своим: оно зависит от того, сколько процессов успело накопиться до упора в лимит.

Errno 11 — это EAGAIN. Лимит в 25 процессов исчерпан накопленными zombie, и fork() перестал работать. Приложение при этом не потребляло ни памяти, ни CPU.

Именно так проявляется проблема в реальности: сервис перестаёт обрабатывать запросы с непонятной ошибкой, метрики показывают нормальное потребление ресурсов.

С --init того же не произойдёт:

bash
docker run --rm --name flood-ok --init --pids-limit=25 \
    -v "$PWD/flood.py:/app.py:ro" \
    python:3.13-slim timeout 12 python -u /app.py 2>&1 | tail -2
echo "завершилось по таймауту без ошибок fork"
text
завершилось по таймауту без ошибок fork

--init в Compose и в образе

Compose:

yaml
services:
  worker:
    image: myapp:1.0
    init: true

Собственный tini в образе — нужен, когда init должен работать независимо от того, как container запущен:

dockerfile
FROM python:3.13-slim

RUN apt-get update \
 && apt-get install -y --no-install-recommends tini \
 && rm -rf /var/lib/apt/lists/*

COPY app.py /app/app.py

ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["python", "-u", "/app/app.py"]

Двойное тире после tini обязательно: оно отделяет опции tini от команды приложения.

Этот вариант добавляет зависимость в образ и увеличивает его. Флаг --init даёт то же самое без изменения образа и предпочтителен, если вы управляете запуском.

Проверка: нужен ли init вашему приложению

bash
docker run -d --name probe python:3.13-slim sleep 300 > /dev/null
sleep 1
echo "процессов в container: $(docker exec probe sh -c 'ps -o pid= | wc -l')"
docker rm -f probe > /dev/null

Практический критерий: если за время работы docker exec <c> ps -e показывает только ваш процесс и его известные потомки — init не нужен. Если появляются записи Z — нужен.

Автоматическая проверка:

bash
check_zombies() {
    local c="$1"
    local z
    z="$(docker exec "$c" sh -c "ps -o stat= 2>/dev/null | grep -c '^Z'" 2>/dev/null || echo 0)"
    if [ "${z:-0}" -gt 0 ]; then
        echo "  [!] $c: zombie-процессов $z — рассмотрите --init"
    else
        echo "  [+] $c: zombie нет"
    fi
}

Уборка

bash
docker rm -f zombies no-zombies 2>/dev/null || true
cd /tmp && rm -rf /tmp/pid1

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

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

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

  1. Без --init, без обработчика сигнала.
  2. Без --init, с обработчиком.
  3. С --init, без обработчика.
  4. С --init, с обработчиком.

Для каждой измерьте: количество zombie после работы, время docker stop, exit code, выполнился ли код завершения.

По результатам заполните таблицу «какая конфигурация какую проблему решает» и сформулируйте рекомендацию.

Подсказки

Подсказка 1

Один скрипт с переключателем через переменную окружения проще, чем четыре разных файла:

python
if os.environ.get("HANDLER") == "1":
    signal.signal(signal.SIGTERM, handle)
Подсказка 2

Подсчёт zombie:

bash
# ps в python:*-slim нет — читаем /proc
docker exec <c> sh -c '
    n=0
    for f in /proc/[0-9]*/stat; do
        set -- $(cat "$f" 2>/dev/null)
        [ "$3" = Z ] && n=$((n+1))
    done
    echo $n'
Подсказка 3

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

Решение

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

Показать решение
bash
#!/usr/bin/env bash
# init-comparison.sh — что решает --init, а что обработчик сигнала.
set -euo pipefail

WORK="$(mktemp -d)"
trap 'docker rm -f $(docker ps -aq --filter "name=initcmp-") >/dev/null 2>&1 || true;
      rm -rf "$WORK"' EXIT
cd "$WORK"

cat > app.py <<'PY'
import os
import signal
import subprocess
import sys
import time

shutdown = False


def handle(signum, frame):
    global shutdown
    print("HANDLER-CALLED", flush=True)
    shutdown = True


if os.environ.get("HANDLER") == "1":
    signal.signal(signal.SIGTERM, handle)

print(f"STARTED pid={os.getpid()}", flush=True)

# порождаем сирот: внуки достанутся PID 1
for _ in range(5):
    subprocess.Popen([
        sys.executable, "-c",
        "import subprocess, sys, os\n"
        "subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(0.3)'])\n"
        "os._exit(0)\n"
    ])
    time.sleep(0.4)

while not shutdown:
    time.sleep(0.1)

print("CLEANUP-DONE", flush=True)
sys.exit(0)
PY

run_case() {
    local n="$1" init_flag="$2" handler="$3" label="$4"
    local name="initcmp-$n"

    docker run -d --name "$name" $init_flag \
        -e "HANDLER=$handler" \
        -v "$PWD/app.py:/app.py:ro" \
        python:3.13-slim python -u /app.py > /dev/null
    sleep 4

    local z
    z="$(docker exec "$name" sh -c "ps -o stat= 2>/dev/null | grep -c '^Z'" 2>/dev/null || echo 0)"
    z="$(tr -dc '0-9' <<< "$z")"

    local start end elapsed code logs cleanup
    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 'CLEANUP-DONE' <<< "$logs"; then
        cleanup="да"
    else
        cleanup="НЕТ"
    fi

    printf '%-30s %-8s %-8s %-6s %s\n' "$label" "${z:-0}" "${elapsed}c" "$code" "$cleanup"
    docker rm "$name" > /dev/null
}

printf '%-30s %-8s %-8s %-6s %s\n' КОНФИГУРАЦИЯ ZOMBIE ВРЕМЯ КОД 'CLEANUP'
printf '%s\n' "----------------------------------------------------------------------"

run_case 1 ""       0 "без init, без обработчика"
run_case 2 ""       1 "без init, с обработчиком"
run_case 3 "--init" 0 "с --init, без обработчика"
run_case 4 "--init" 1 "с --init, с обработчиком"

cat <<'EOF'

═══ Что какая проблема решает ═══

  Проблема                    Решает --init   Решает обработчик
  ─────────────────────────────────────────────────────────────
  Накопление zombie                да               нет
  Долгая остановка (10 с)          да               да
  Graceful shutdown                нет              да

  Рекомендация: обработчик сигнала обязателен для своего кода.
  Флаг --init добавляется тогда, когда приложение порождает
  подпроцессы, за которыми не следит.
EOF

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

text
КОНФИГУРАЦИЯ                   ZOMBIE   ВРЕМЯ    КОД    CLEANUP
----------------------------------------------------------------------
без init, без обработчика      5        10.29c   137    НЕТ
без init, с обработчиком       5        0.43c    0      да
с --init, без обработчика      0        0.39c    143    НЕТ
с --init, с обработчиком       0        0.42c    0      да

═══ Что какая проблема решает ═══

  Проблема                    Решает --init   Решает обработчик
  ─────────────────────────────────────────────────────────────
  Накопление zombie                да               нет
  Долгая остановка (10 с)          да               да
  Graceful shutdown                нет              да

  Рекомендация: обработчик сигнала обязателен для своего кода.
  Флаг --init добавляется тогда, когда приложение порождает
  подпроцессы, за которыми не следит.

Разбор строк.

Конфигурация 1 — худшая: zombie накапливаются, остановка занимает 10 секунд, код завершения не выполняется.

Конфигурация 2 — обработчик решил проблему сигналов полностью: быстрая остановка, код 0, cleanup выполнен. Но zombie остались: обработчик сигнала не имеет отношения к сбору потомков.

Конфигурация 3--init решил проблему zombie и ускорил остановку. Но код 143 и CLEANUP: НЕТ: приложение было завершено сигналом, а не вышло само. Соединения не закрыты.

Конфигурация 4 — целевая: нет zombie, быстрая остановка, код 0, cleanup выполнен.

Главный вывод. Строки 2 и 3 показывают, что --init и обработчик решают разные задачи и не заменяют друг друга. Совпадение по колонке «время» создаёт иллюзию взаимозаменяемости, но колонки «zombie» и «cleanup» её разрушают.

Строка 3 особенно поучительна: остановка выглядит корректной — быстрая, без таймаута, — но приложение при этом не выполнило ни строки кода завершения. При поверхностной проверке такая конфигурация кажется рабочей.

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

bash
docker run -d --name z --init alpine sh -c 'sleep 300' > /dev/null
sleep 1
docker exec z ps -o pid,comm | head -3
docker rm -f z > /dev/null

Первой строкой вывода должен быть процесс с PID 1 и именем, отличным от sleep. Объясните, какой это процесс и зачем он нужен.

Типичные ошибки

ОшибкаПричинаИсправление
--init вместо обработчика сигналаОстановка становится быстрой, кажется исправленным--init не даёт graceful shutdown; код 143 вместо 0
Обработчик вместо --init при подпроцессахПроблема сигналов решена, о zombie не думалиОбработчик не собирает потомков
--init «на всякий случай» вездеКажется безвреднымЛишний процесс; если приложение не плодит сирот, пользы нет
tini в образе при управляемом запускеКопирование чужих DockerfileФлаг --init проще и не меняет образ
Забыт -- после tini в ENTRYPOINTНе очевидноБез него tini пытается разобрать команду приложения как свои опции
Игнорирование zombie как безобидныхОни действительно почти не расходуют ресурсыПри --pids-limit или долгой работе приводят к EAGAIN
Диагностика EAGAIN как нехватки памятиОшибка звучит похожеResource temporarily unavailable при fork() — это исчерпание PID
--init в Compose через commandИщут не тамЕсть отдельный ключ init: true

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

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

  1. Назовите два особых свойства PID 1 и их последствия для containers.
  2. Почему zombie почти не потребляет ресурсов, но всё же вреден?
  3. Что происходит с процессом, чей родитель завершился раньше него?
  4. Почему с --init приложение без обработчика останавливается быстро, но код возврата 143?
  5. Почему --init не заменяет обработчик сигнала?

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

  1. Как проверить, накапливаются ли zombie в работающем container?
  2. Как включить init-процесс в Compose?
  3. Как определить, нужен ли --init конкретному приложению?

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

  1. Сервис перестал обрабатывать запросы с ошибкой Resource temporarily unavailable, при этом память и CPU свободны. Что проверить?
  2. docker stop быстрый, код 143, но приложение не успевает закрыть соединения с базой. В чём причина?

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

  1. PID 1 обладает двумя особыми свойствами: игнорирует сигналы без обработчика и принимает осиротевших потомков.
  2. Zombie — завершившийся процесс, чей статус не собран родителем; занимает запись в таблице процессов.
  3. В обычной системе zombie редки, потому что systemd регулярно вызывает wait().
  4. В container PID 1 — это приложение, которое обычно не собирает чужих потомков.
  5. Опасность zombie проявляется при долгой работе или заданном --pids-limit: fork() возвращает EAGAIN.
  6. Флаг --init вставляет tini, который собирает zombie и пересылает сигналы.
  7. С --init приложение перестаёт быть PID 1, поэтому сигналы работают по умолчанию.
  8. --init даёт код 143, а не 0: завершение штатное, но без graceful shutdown.
  9. --init и обработчик сигнала решают разные задачи и применяются вместе.
  10. Init не нужен приложению, которое не порождает подпроцессов или корректно их ожидает.

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

ИсточникСсылкаЧто подтверждает
docker run referencehttps://docs.docker.com/reference/cli/docker/container/run/Флаг --init и его назначение
Compose serviceshttps://docs.docker.com/reference/compose-file/services/Ключ init: true
tinihttps://github.com/krallin/tiniРеализация init: сбор zombie, пересылка сигналов, синтаксис с --
pid_namespaces(7) man pagehttps://man7.org/linux/man-pages/man7/pid_namespaces.7.htmlОсобая обработка сигналов для PID 1, reparenting сирот
wait(2) man pagehttps://man7.org/linux/man-pages/man2/wait.2.htmlМеханизм сбора статуса потомков, появление zombie
fork(2) man pagehttps://man7.org/linux/man-pages/man2/fork.2.htmlОшибка EAGAIN при исчерпании лимита процессов
prctl(2) man pagehttps://man7.org/linux/man-pages/man2/prctl.2.htmlPR_SET_CHILD_SUBREAPER как альтернатива init
Runtime options: pids-limithttps://docs.docker.com/engine/containers/resource_constraints/Ограничение числа процессов в container
Python: subprocesshttps://docs.python.org/3/library/subprocess.htmlКорректное ожидание потомков через wait() и run()

Навигация

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

Markdown на GitHub ↗