Ночью на промышленном сервере кончается не память и не диск, а таблица процессов. Мониторинг рисует тревожный график: количество задач в системе растёт час за часом, хотя загрузка процессора нулевая и свободной памяти море. Открываешь ps и видишь сотни строк с буквой Z в колонке состояния. Это зомби-процессы, завершившиеся дети, чьи родители забыли или не смогли забрать их код возврата. Сама по себе такая запись почти ничего не стоит, но толпа зомби упирается в лимит pid_max, ломает скрипты, парсящие вывод ps, и пугает коллег. Разберёмся, откуда они берутся, как найти виновного родителя и что с этим делать в продакшене, не перезагружая сервер.

Что такое зомби и почему ядро оставляет их в таблице процессов

Процесс в Linux почти никогда не исчезает мгновенно после завершения. Когда программа вызывает exit() или получает фатальный сигнал, ядро освобождает память, закрывает файловые дескрипторы, но оставляет одну строчку в таблице процессов: PID, код возврата, накопленное процессорное время и кое-какую статистику. Всё это ждёт, пока родитель вызовет wait() или waitpid() и заберёт результат. Состояние такого процесса называется zombie, в выводе ps оно помечается буквой Z, а в поле команды часто стоит пометка defunct.

Смысл этого механизма прост: код возврата должен куда-то деться. Ребёнок не может сам сообщить родителю, с каким результатом он закончил работу, поэтому ядро хранит послание до востребования. Проблемы начинаются, когда родитель про востребование забыл. Тогда послание висит в таблице бесконечно, а PID остаётся занятым. Один зомби потребляет примерно пару сотен байт структуры task_struct, но при типичном pid_max в 4194304 и активном баге в коде таблица забивается за дни, после чего fork() начинает возвращать EAGAIN и система отказывается запускать даже ls.

Как найти зомби и определить родителя через ps

Поиск начинается с классической команды, которую администраторы помнят наизусть:

ps aux | grep ' Z '

Фильтр по пробелам вокруг буквы Z отсекает команды, где Z просто встречается в имени. Реальный вывод выглядит так:

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
appuser  12041  0.0  0.0      0     0 ?        Z    03:12   0:00 [worker] <defunct>
appuser  12044  0.0  0.0      0     0 ?        Z    03:12   0:00 [worker] <defunct>
appuser  12089  0.0  0.0      0     0 ?        Z    03:14   0:00 [worker] <defunct>

Разбор по полям. В колонке STAT стоит Z, то есть zombie. Колонки VSZ и RSS равны нулю, потому что память освобождена, осталась только запись в таблице. Колонка TIME показывает 0:00, зомби не тратит процессор. Пометка defunct вместо настоящей команды говорит, что таблица аргументов уже освобождена.

Дальше главный вопрос: кто родитель. Для каждого найденного PID спрашиваем у ядра ppid:

ps -o ppid= -p 12041

Знак равенства после имени поля убирает заголовок, на выходе остаётся чистое число, удобное для скриптов. Узнав, что ppid равен, скажем, 11980, смотрим, что это за процесс: ps -fp 11980. Часто оказывается что-то вроде старого плагина мониторинга, самописного воркера или скрипта, запускающего дочерние команды в цикле. Массовый подсчёт зомби с группировкой по родителю делается одной строкой:

ps -eo ppid,stat,comm | awk '$2 ~ /Z/ {print $1, $3}' | sort | uniq -c | sort -rn

Если в начале списка один родитель с тремя сотнями зомби, расследование закончено, виновник найден. Полезно сразу посмотреть и общее число зомби: ps -eo stat | grep -c Z.

Состояния процессов и роль сигнала SIGCHLD

Чтобы понять, где именно ломается логика, нужно держать в голове карту состояний. R означает running или runnable, процесс исполняется или ждёт очереди на процессор. S это прерываемый сон, процесс ждёт события и принимает сигналы. D это непрерываемый сон, обычно ожидание диска, сигналы не доходят. T означает остановленный по SIGSTOP или отладчиком. И наконец Z, финальное состояние перед полным исчезновением.

Механика перехода в Z и обратно построена на SIGCHLD. Когда дочерний процесс завершается или останавливается, ядро посылает родителю сигнал SIGCHLD. В оригинальной задумке родитель в обработчике вызывает waitpid() в цикле с флагом WNOHANG, забирает коды возврата всех завершившихся детей, и записи стираются. Типичные причины, почему этого не происходит. Первое, обработчик SIGCHLD установлен в SIG_IGN без дополнительных условий на старых ядрах или с конфликтом флага SA_NOCLDWAIT, и поведение оказывается не тем, что ждал программист. Второе, обработчик написан, но вызывает waitpid() один раз, а за время его работы завершились трое детей, два сигнала слиплись в один, и пара зомби осталась. Третье, родитель ушёл в блокирующий вызов или зациклился, и сигналы просто копятся в очереди. Четвёртое, детей порождает библиотека, о существовании которой автор программы вообще не подозревал.

Пример кода на C где fork порождает зомби

Минимальный воспроизводимый пример занимает пару десятков строк:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main(void) {
    /* Порождаем пятерых детей, каждый из которых завершается сразу */
    for (int i = 0; i < 5; i++) {
        pid_t pid = fork();
        if (pid == 0) {
            /* Дочерний процесс: ничего не делает и выходит */
            _exit(0);
        }
        /* Родитель: намеренно НЕ вызывает wait() */
    }
    /* Родитель спит, зомби в это время видны в ps */
    sleep(60);
    return 0;
}

Компилируем gcc zombie.c -o zombie, запускаем ./zombie и из соседнего терминала видим пять строк с состоянием Z. Правильная версия отличается обработчиком:

#include <signal.h>
#include <sys/wait.h>

/* Обработчик SIGCHLD: забираем ВСЕХ завершившихся детей */
static void reap(int sig) {
    (void)sig;
    /* Цикл с WNOHANG, потому что сигналы SIGCHLD не ставятся в очередь,
       за один вызов обработчика могло завершиться несколько детей */
    while (waitpid(-1, NULL, WNOHANG) > 0) {
        /* каждый проход цикла убирает одного зомби */
    }
}

/* В main перед fork: */
signal(SIGCHLD, reap);

Ключевой момент именно цикл while. Новички пишут if вместо while, и этот if становится источником редких, плавающих зомби, которые проявляются только под нагрузкой, когда дети завершаются пачками.

PID 1, systemd reaping и что делает kill -9 с зомби

Особая роль в этой истории у процесса с PID 1. Если родитель умирает раньше детей, сироты усыновляются единицей, которая обязана собирать их коды возврата. В современных дистрибутивах это systemd, и он честно вызывает waitpid() по каждому SIGCHLD. Аналогично работает субрепер: любой процесс, объявивший себя таковым вызовом prctl(PR_SET_CHILD_SUBREAPER, 1), принимает сирот из своего поддерева. Именно поэтому контейнерные рантаймы ставят в качестве init лёгкие реперы вроде tini, ведь произвольное приложение, запущенное как PID 1 внутри контейнера, редко умеет собирать зомби, и они копятся до пересоздания контейнера.

Теперь о любимом вопросе на собеседованиях. Можно ли убить зомби через kill -9? Нет, и команда это докажет:

kill -9 12041
ps -o pid,stat,comm -p 12041

Вывод покажет тот же самый процесс в состоянии Z, kill вернёт успех или ошибку, но ничего не изменится. Сигнал нужен живому процессу, чтобы тот его обработал или был прерван ядром по месту исполнения. У зомби нет исполняемого кода, нечем обрабатывать SIGKILL. Единственный способ убрать зомби легально это заставить родителя вызвать wait. Если родитель жив, но глючный, иногда помогает kill -CHLD <ppid>, подталкивающий ядро повторно отправить сигнал и разбудить встроенный обработчик. Если родитель безнадёжен, его убивают, зомби усыновляются PID 1, и systemd подметает их за секунды. Перезагрузка сервера из-за зомби это признание поражения.

Про лимиты стоит сказать чуть подробнее, потому что именно они превращают стайку зомби в инцидент. Максимум идентификаторов задаётся в /proc/sys/kernel/pid_max и по умолчанию на большинстве дистрибутивов равен 4194304, а на старых ядрах всего 32768. Посмотреть текущее значение просто:

cat /proc/sys/kernel/pid_max
cat /proc/sys/kernel/threads-max

Второй файл, threads-max, часто становится реальной границей раньше: на машине с четырьмя гигабайтами памяти он обычно около 30000. Поднять pid_max до восьми миллионов можно через sysctl, добавив в /etc/sysctl.d/99-pids.conf строку kernel.pid_max = 8388608 и применив sysctl --system, но честно говоря, это лечение симптома. Если сервис плодит по сотне зомби в час, любой лимит будет достигнут, вопрос лишь в том, случится это через месяц или через три.

Пошаговый план администратора и профилактика на будущее

Когда мониторинг сообщает о росте числа процессов, действуем по чек-листу:

  1. Считаем зомби командой ps -eo stat | grep -c Z и фиксируем число для истории;
  2. Группируем зомби по родителям через ps -eo ppid,stat,comm с awk, находим главный источник;
  3. Определяем ppid конкретного экземпляра через ps -o ppid= -p PID и смотрим родителя через ps -fp;
  4. Проверяем у родителя открытый поток сигналов, смотрим /proc/<ppid>/status на предмет SigQ и функционирования обработчика, при желании подключаемся strace -p и наблюдаем вызовы wait4;
  5. Пробуем мягкое лечение kill -CHLD <ppid>, при отсутствии эффекта аккуратно завершаем родителя и проверяем, что PID 1 забрал зомби;
  6. Пишем баг-репорт разработчикам сервиса с приложением минимального примера и выводов strace.

Профилактика начинается с наблюдаемости. В node_exporter и zabbix-агенте есть готовые метрики по состояниям процессов, алерт стоит ставить на число зомби выше, скажем, 50 штук в течение пяти минут. В unit-файлах systemd полезно фиксировать, что сборку сирот ведёт сам менеджер: для контейнерных нагрузок использовать tini или флаг --init в docker run, чтобы не зависеть от того, умеет ли приложение в роли PID 1 вызывать waitpid. Разработчикам стоит помнить три вещи. Обработчик SIGCHLD всегда крутит waitpid в цикле с WNOHANG. Внутри обработчика допустимы только безопасные в сигналах функции. Тесты на регресс легко пишутся: запускаем сервис, нагружаем, проверяем счётчик зомби до и после.

Углублённая диагностика через proc и strace

Когда ps уже показал виновника, полезно заглянуть глубже. Файл /proc/<pid>/status рассказывает про родителя почти всё:

grep -E 'State|PPid|SigQ|Threads' /proc/11980/status
State:  S (sleeping)
PPid:   1
SigQ:   17/128276
Threads: 8

Разбор по строкам. State S значит, что родитель жив и спит прерываемым сном, то есть теоретически способен принимать сигналы. PPid 1 говорит, что сам он прямой ребёнок systemd. Поле SigQ самое интересное: первое число, 17, это количество сигналов, ожидающих обработки в очереди, второе, 128276, общий лимит очереди для пользователя. Если SigQ растёт синхронно с числом зомби, ядро добросовестно шлёт SIGCHLD, а процесс их игнорирует, виноват код, а не система. Восемь потоков намекают, что обработчик может теряться на фоне гонок в многопоточной программе, там сигналы доставляются в произвольный поток, и без блокировки через sigprocmask логика легко рассыпается.

Следующий шаг это strace, живая запись системных вызовов:

strace -p 11980 -e trace=wait4,waitpid,rt_sigaction -f
[pid 11980] rt_sigaction(SIGCHLD, {sa_handler=0x55a1c2, sa_mask=[], sa_flags=SA_RESTORER|SA_RESTART}, NULL, 8) = 0
[pid 11983] wait4(-1, NULL, WNOHANG, NULL) = 12044
[pid 11983] wait4(-1, NULL, WNOHANG, NULL) = -1 ECHILD (No child processes)

Здесь видно картину здорового поведения: обработчик установлен, wait4 крутится в цикле с WNOHANG и заканчивается ECHILD, когда детей не осталось. У больного родителя strace вместо этого минутами молчит или показывает бесконечные poll() и read() без единого wait4. Такой вывод сам по себе является готовым доказательством для баг-репорта.

Отдельная головная боль это зомби внутри контейнеров. Там процесс с PID 1 локальный для пространства имён, и увидеть его зомби с хоста можно, но ps -o ppid= покажет ppid, которого на хосте вообще нет, потому что номера другие. Команда ps -ef --forest с ASCII-деревом или pstree -p помогает визуально найти куст, где копятся потерянные дети. Если контейнер запущен без tini и зомби растут, лечение одно, перезапуск контейнера с флагом --init, никакие локальные kill внутри уже не спасут.

Зомби-процессы это редкий случай, когда страшное слово описывает почти безобидное явление, по крайней мере поначалу. Умелый взгляд на вывод ps, пара команд для поиска родителя и понимание того, что kill -9 тут бессилен, превращают ночной инцидент в десять минут спокойной работы. А заодно в отличный повод напомнить разработчикам, что fork без wait это как письмо без ответа: рано или поздно почтовый ящик переполнится.