4.5. PID 1 и init
Цели
После этого материала вы сможете:
- объяснить два особых свойства PID 1 в Linux и их последствия для containers;
- воспроизвести zombie process в container и объяснить механизм его появления;
- объяснить, чем zombie вреден и когда он становится проблемой;
- применять флаг
--initосознанно и знать, что именно он добавляет; - выбирать между
--init,tiniв образе и обработкой в приложении; - определять, нужен ли init-процесс конкретному приложению.
Предварительные знания
- 4.4. Сигналы и graceful shutdown;
- 2.1. Процессы и containers;
- понимание родительских и дочерних процессов,
fork()иwait().
Ключевые термины
| Термин | Объяснение |
|---|---|
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): он не выполняется, не занимает память под код и данные, но занимает запись в таблице процессов.
процесс завершился
│
▼
┌─────────────────┐ родитель вызвал wait() ┌──────────────┐
│ zombie (Z) │ ─────────────────────────► │ запись │
│ занимает PID │ │ удалена │
└─────────────────┘ └──────────────┘
│
│ родитель не вызывает wait()
▼
zombie остаётся навсегда
(пока жив родитель)
Zombie исчезает в двух случаях: родитель вызвал wait() либо родитель сам завершился — тогда zombie переходит к PID 1, и его собирает init.
Вторая ветка — причина того, что в обычной системе zombie редки: systemd регулярно вызывает wait().
Почему это проблема в containers
В container PID 1 — это ваше приложение, а не systemd. Типичное приложение не написано как init: оно не ожидает чужих потомков и не вызывает wait() в цикле.
Сценарий накопления:
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:
без --init с --init
────────── ────────
PID 1 приложение PID 1 /sbin/docker-init (tini)
└─ потомки └─ PID 7 приложение
└─ потомки
Tini выполняет две функции:
- Собирает zombie. В цикле вызывает
wait()для всех потомков, включая переданных ему сирот. - Пересылает сигналы. Полученный
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:
- Установить обработчики для всех сигналов, которые можно перехватить.
- Породить дочерний процесс — приложение.
- В цикле вызывать
waitpid(-1, ...)с флагомWNOHANG, собирая всех завершившихся потомков. - Полученные сигналы пересылать дочернему процессу.
- Когда завершится основной дочерний процесс — завершиться с его кодом возврата.
Шаг 3 с аргументом -1 — ключевой: собираются любые потомки, включая переданных сирот.
Команды и примеры
Свойство 1: PID 1 игнорирует сигналы
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:
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
мой PID: 1
real 0m10.294s
код: 137
Тот же скрипт, но не как PID 1 — запустим его под оболочкой, которая останется PID 1:
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
PID COMMAND
1 sh -c python -u /app.py & wait
7 python -u /app.py
13 ps -o pid,args
Здесь Python имеет PID 7. Если послать SIGTERM непосредственно ему, он завершится по действию по умолчанию:
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
процесс завершился
Один и тот же код: как PID 1 сигнал игнорируется, как обычный процесс — завершает.
Свойство 2: воспроизведение zombie
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:
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)"
=== процессы в 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:
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)"
=== процессы с --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: сигналы работают без обработчика
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
--- без --init, без обработчика ---
время: 10.31 c код: 137
--- с --init, без обработчика ---
время: 0.38 c код: 143
С --init остановка мгновенная. Причина: приложение больше не PID 1, поэтому SIGTERM применяется по действию по умолчанию.
Но обратите внимание на код: 143, а не 0. Приложение было завершено сигналом, а не вышло само. Никакого graceful shutdown не произошло — соединения не закрыты, буферы не сброшены.
Вывод: --init чинит доставку сигнала, но не заменяет обработчик в приложении.
Полное решение: --init плюс обработчик
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
zombie: 0
время: 0.44 c код: 0
получен сигнал 15
закрываю ресурсы...
завершён корректно
Целевое состояние: нет zombie, быстрая остановка, код 0, код завершения выполнен.
Исчерпание PID
Покажем, почему zombie всё же опасны, — с малым --pids-limit:
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
FORK FAILED после 23 итераций: [Errno 11] Resource temporarily unavailable
Сработала первая ветвь except, и это не случайность: BlockingIOError — подкласс OSError, а EAGAIN при исчерпании PID поднимает именно его. Вторая ветвь в этом сценарии недостижима и оставлена как страховка на случай другой ошибки. По тексту сообщения видно, какая из них выполнилась, — поэтому они и различаются.
Число итераций у вас будет своим: оно зависит от того, сколько процессов успело накопиться до упора в лимит.
Errno 11 — это EAGAIN. Лимит в 25 процессов исчерпан накопленными zombie, и fork() перестал работать. Приложение при этом не потребляло ни памяти, ни CPU.
Именно так проявляется проблема в реальности: сервис перестаёт обрабатывать запросы с непонятной ошибкой, метрики показывают нормальное потребление ресурсов.
С --init того же не произойдёт:
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"
завершилось по таймауту без ошибок fork
--init в Compose и в образе
Compose:
services:
worker:
image: myapp:1.0
init: true
Собственный tini в образе — нужен, когда init должен работать независимо от того, как container запущен:
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 вашему приложению
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 — нужен.
Автоматическая проверка:
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
}
Уборка
docker rm -f zombies no-zombies 2>/dev/null || true
cd /tmp && rm -rf /tmp/pid1
Практическое упражнение
Задание. Напишите скрипт init-comparison.sh, который экспериментально сравнивает четыре конфигурации и показывает, какая проблема какой из них решается.
Конфигурации:
- Без
--init, без обработчика сигнала. - Без
--init, с обработчиком. - С
--init, без обработчика. - С
--init, с обработчиком.
Для каждой измерьте: количество zombie после работы, время docker stop, exit code, выполнился ли код завершения.
По результатам заполните таблицу «какая конфигурация какую проблему решает» и сформулируйте рекомендацию.
Подсказки
Подсказка 1
Один скрипт с переключателем через переменную окружения проще, чем четыре разных файла:
if os.environ.get("HANDLER") == "1":
signal.signal(signal.SIGTERM, handle)
Подсказка 2
Подсчёт zombie:
# 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
Признак выполнения кода завершения — специальная строка в логах, которую печатает только обработчик.
Решение
Сначала выполните задание самостоятельно.
Показать решение
#!/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
Ожидаемый вывод:
КОНФИГУРАЦИЯ 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 особенно поучительна: остановка выглядит корректной — быстрая, без таймаута, — но приложение при этом не выполнило ни строки кода завершения. При поверхностной проверке такая конфигурация кажется рабочей.
Проверка результата
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 |
Контрольные вопросы
На понимание:
- Назовите два особых свойства PID 1 и их последствия для containers.
- Почему zombie почти не потребляет ресурсов, но всё же вреден?
- Что происходит с процессом, чей родитель завершился раньше него?
- Почему с
--initприложение без обработчика останавливается быстро, но код возврата143? - Почему
--initне заменяет обработчик сигнала?
На применение:
- Как проверить, накапливаются ли zombie в работающем container?
- Как включить init-процесс в Compose?
- Как определить, нужен ли
--initконкретному приложению?
На диагностику:
- Сервис перестал обрабатывать запросы с ошибкой
Resource temporarily unavailable, при этом память и CPU свободны. Что проверить? docker stopбыстрый, код143, но приложение не успевает закрыть соединения с базой. В чём причина?
Краткое резюме
- PID 1 обладает двумя особыми свойствами: игнорирует сигналы без обработчика и принимает осиротевших потомков.
- Zombie — завершившийся процесс, чей статус не собран родителем; занимает запись в таблице процессов.
- В обычной системе zombie редки, потому что
systemdрегулярно вызываетwait(). - В container PID 1 — это приложение, которое обычно не собирает чужих потомков.
- Опасность zombie проявляется при долгой работе или заданном
--pids-limit:fork()возвращаетEAGAIN. - Флаг
--initвставляетtini, который собирает zombie и пересылает сигналы. - С
--initприложение перестаёт быть PID 1, поэтому сигналы работают по умолчанию. --initдаёт код143, а не0: завершение штатное, но без graceful shutdown.--initи обработчик сигнала решают разные задачи и применяются вместе.- Init не нужен приложению, которое не порождает подпроцессов или корректно их ожидает.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| docker run reference | https://docs.docker.com/reference/cli/docker/container/run/ | Флаг --init и его назначение |
| Compose services | https://docs.docker.com/reference/compose-file/services/ | Ключ init: true |
| tini | https://github.com/krallin/tini | Реализация init: сбор zombie, пересылка сигналов, синтаксис с -- |
pid_namespaces(7) man page | https://man7.org/linux/man-pages/man7/pid_namespaces.7.html | Особая обработка сигналов для PID 1, reparenting сирот |
wait(2) man page | https://man7.org/linux/man-pages/man2/wait.2.html | Механизм сбора статуса потомков, появление zombie |
fork(2) man page | https://man7.org/linux/man-pages/man2/fork.2.html | Ошибка EAGAIN при исчерпании лимита процессов |
prctl(2) man page | https://man7.org/linux/man-pages/man2/prctl.2.html | PR_SET_CHILD_SUBREAPER как альтернатива init |
| Runtime options: pids-limit | https://docs.docker.com/engine/containers/resource_constraints/ | Ограничение числа процессов в container |
Python: subprocess | https://docs.python.org/3/library/subprocess.html | Корректное ожидание потомков через wait() и run() |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Restart policies и exit codes
Главное оглавление