Администратор замечает странность в выводе ps aux: часть строк несёт букву Z в колонке STAT, памяти такие записи не занимают, а команда kill на них никак не влияет. Это зомби-процессы, то есть уже завершённые программы, чьи записи в таблице процессов никто не забрал. Единичная запись безвредна, зато утечка в большой установке способна исчерпать таблицу целиком, и тогда в системе не запустится ни один новый процесс. Разберёмся, откуда берётся буква Z, при чём тут родительский процесс и PID 1, чем грозит переполнение таблицы и какие команды возвращают систему в норму.

Буква Z в колонке STAT означает программу, которую некому забрать

Утилита ps помечает состояние каждой записи одной буквой в колонке STAT, а уточняющие флаги добавляет следом. Заглавная D означает непрерываемое ожидание дисковой операции, R готовность к выполнению, S обычный сон до наступления события, T остановку сигналом управления заданиями. Дальше идут редкие гости: t ставится, когда процесс остановил отладчик, буква W вышла из употребления ещё в ядре 2.6, а X помечает запись, которую в живой системе встречать вообще не положено. Состояние Z расшифровывается как defunct, то есть завершённая программа, которую родитель так и не подхватил. Модификаторы добавляют деталей: s помечает лидера сеанса, l многопоточный процесс на pthreads, плюс в конце означает работу на переднем плане терминала.

На тестовой машине запись зомби выглядит так:

  PID  PPID STAT COMMAND
443488  443487 S    python3
443490  443488 Z    python3

Родитель 443488 спит, потомок 443490 уже отработал, но висит в состоянии Z. У зомби нет ни памяти, ни открытых файлов, ни потоков: ядро освобождает всё это сразу после завершения. В таблице остаётся пустой слот с PID, номером родителя, кодом выхода и статистикой израсходованного процессорного времени. На это и рассчитан механизм: результаты работы должны дожить до родителя. Практика показывает, что чаще всего букву Z приносят скрипты, порождающие утилиты через subprocess или system, и долго живущие службы, написанные без обработки SIGCHLD.

Путь программы от fork до wait и почему родитель обязан её забрать

Путь каждой записи начинается с системного вызова fork: ядро создаёт копию родителя, копия либо заменяет себя новой программой через exec, либо сразу завершается через exit. Допустим, потомок вернул код выхода 7. Ядро не выбрасывает запись немедленно, потому что родитель имеет право прочитать результат, и убирает слот только после того, как тот вызовет функцию семейства wait, чаще всего waitpid. Код выхода живёт в диапазоне от 0 до 255, его же оболочка показывает по команде echo $?. Кстати, поэтому после завершения фоновой задачи её код доступен в shell даже спустя время: оболочка сохранила его, разобрав запись. Между завершением потомка и вызовом wait запись находится в состоянии зомби, и это нормальная, короткая фаза, а не поломка.

Проблема начинается, когда родитель wait не вызывает никогда. Постоянно работающая служба порождает потомков сотнями в час, каждый оставляет слот, и таблица медленно зарастает. Отдельная тонкость связана с сигналом SIGCHLD, которым ядро сообщает родителю о завершении потомка. Интуиция подсказывает, что раз действие по умолчанию для этого сигнала равно игнорированию, записи чистятся автоматически. На самом деле стандарт POSIX.1-2001 требует явности: автосборка включается только при установке SIG_IGN на SIGCHLD либо флага SA_NOCLDWAIT, после чего завершившиеся потомки зомби не становятся вовсе, а waitpid в итоге вернёт ошибку ECHILD. Обработчик по умолчанию, который молча проглатывает сигнал, зомби не собирает. Хорошего родителя узнать просто: bash и systemd разбирают свои записи мгновенно, поэтому у их потомков буква Z задерживается на доли секунды.

Роль PID 1 и subreaper при подхвате осиротевших зомби

Если родитель закончился раньше потомка, ядро не бросает запись на произвол судьбы. Осиротевший зомби переходит к init с номером PID 1 либо к ближайшему предку, помеченному как subreaper вызовом prctl с операцией PR_SET_CHILD_SUBREAPER, механизм появился в ядре 3.4. Системный менеджер systemd непрерывно забирает таких сирот и тут же считывает их коды выхода, поэтому зомби с PPID 1 живёт секунды и в нормальной системе это скорее переходное состояние, чем болезнь. Пользовательский экземпляр systemd тоже объявляет себя subreaper, поэтому зомби внутри сеанса пользователя остаются в его владении и не утекают к системному менеджеру. Точка сборки смещается и в контейнерных средах: роль подбирающего на себя берут среда выполнения контейнеров и другие процессы-посредники, и сироты стекаются уже к ним.

Внутри контейнера PID 1 достаётся самому приложению, и если оно не умеет ждать потомков, зомби копятся в изолированном пространстве имён, хотя свободной памяти на хосте полно. Docker начиная с уровня API 1.25 решает это флагом --init, который подкладывает в контейнер процесс init, пересылающий сигналы и собирающий зомби; в самодельных образах ту же работу выполняет утилита tini. Кластерные платформы добавляют свой контроль и перезапускают контейнер, когда проверки живости перестают отвечать. Отсюда практический вывод: у каждой среды есть свой сборщик записей, и его поведение стоит знать так же хорошо, как список служб на сервере.

Эксперимент в терминале от появления буквы Z до подхвата systemd

Теория проверяется за минуту в обычном терминале от непривилегированного пользователя. Оболочка bash на роль нерадивого родителя не годится: она сама аккуратно разбирает фоновые задания, поэтому нерадивую сторону в опыте исполняет Python. Запускаем скрипт: потомок немедленно завершается с кодом 7, а родитель уходит спать на пять минут и никого не ждёт.

python3 -c "import os,time,sys
p = os.fork()
if p == 0: sys.exit(7)
time.sleep(300)" &

sleep 1
ps -eo pid,ppid,stat,comm | grep -w python3

Вывод показывает ровно ту картину, о которой шла речь:

 443488  443487 S    python3
 443490  443488 Z    python3

Родитель 443488 спит внутри time.sleep, потомок 443490 уже завершился, но остался в таблице со статусом Z, потому что читать его код выхода некому. Теперь отправляем родителю сигнал завершения и смотрим ещё раз.

kill -TERM 443488
sleep 1
ps -eo pid,ppid,stat,comm | grep -w python3

Вывод пустой: обе записи исчезли. Родитель перестал существовать, зомби осиротел, systemd с PID 1 мгновенно забрал запись, и таблица очистилась. Отдельно любопытно поведение самого зомби: сигнал до него не доходит вовсе, команда kill -9 443490 вернёт успех, но слот останется на месте, потому что доставлять сигнал уже некому. Единственный способ убрать запись, заставить родителя вызвать wait либо завершить родителя, как только что и сделали.

Переполнение таблицы процессов и ложная ошибка Cannot allocate memory

Таблица процессов конечна, и её размер задаёт параметр ядра kernel.pid_max, доступный с версии 2.5.34. По умолчанию ставится 32768, а на 64-битных системах потолок равен 2^22, то есть 4194304; само значение файла на единицу больше максимального номера, который получит процесс. Параметр ограничивает суммарно и процессы, и потоки, поэтому зомби конкурируют за номер с рабочими задачами. Потоки тоже участвуют в расходе: каждая нить получает собственный номер из того же пула, поэтому многопоточные приложения опустошают таблицу заметно быстрее. Посмотреть текущие значения можно двумя командами:

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

На тестовой машине вывелось 4194304 и 52797: первый предел когда-то подняли до максимума, а второй ядро посчитало из объёма памяти. Поднять планку можно на ходу под root:

sysctl -w kernel.pid_max=4194304
echo "kernel.pid_max = 4194304" > /etc/sysctl.d/99-pid-max.conf

Первая команда действует до перезагрузки, вторая закрепляет настройку в файле каталога /etc/sysctl.d.

Симптомы исчерпания выглядят обманчиво. Вызов fork возвращает EAGAIN, оболочка печатает "Cannot allocate memory", а free -m рапортует о свободных гигабайтах. Администратор ищет утечку памяти, которой нет: память целая, кончились номера. Общий счётчик виден в верхней строке top, где после перечня бегущих и спящих задач стоит число zombie. В этот момент перестают открываться новые сеансы ssh, cron молча пропускает задания, приложения падают при попытке породить вспомогательный процесс.

Ограничения существуют не только на уровне системы. Команда ulimit -u показывает потолок задач на пользователя через лимит RLIMIT_NPROC, на тестовой машине он равен unlimited, но на общих серверах его сужают строкой в /etc/security/limits.conf:

devops  soft  nproc  4096

В cgroup v2 за количество задач отвечает файл pids.max в каталоге группы, по умолчанию там стоит max, то есть без ограничений. Внутри контейнеров при этом действует собственный экземпляр pid_max в пространстве имён PID, так что пределы хоста и контейнера не обязаны совпадать. В юнитах systemd ту же работу выполняет директива TasksMax, а глобальный потолок задаёт параметр DefaultTasksMax в /etc/systemd/system.conf. Все эти лимиты лишь отодвигают момент встречи с утечкой: если родитель не забирает записи, любой потолок рано или поздно будет достигнут.

Диагностика зомби командами ps, strace и корректная очистка таблицы

Начинается всё с поиска записей со статусом Z и их родителей:

ps -eo pid,ppid,stat,comm | awk '$3 ~ /^Z/ {print $1, $2, $4}'
ps -o ppid= -p 443490
ps -o pid,ppid,stat,etime,cmd -p 443488

Первая команда перечисляет все зомби в системе вместе с номерами родителей, вторая извлекает номер родителя конкретной записи, третья показывает, что это за программа и как долго живёт. Колонка cmd обычно выдаёт виновника: старая служба мониторинга, скрипт на perl без waitpid или самописный планировщик задач. Для беглого наблюдения годится watch -n 5 "ps -eo stat | grep -c ^Z": счётчик обновляется каждые пять секунд. Дальше стоит заглянуть в системные вызовы родителя:

strace -p 443488 -e trace=wait4

Если процесс крутится в цикле select или read и ни разу не дёргает wait4, диагноз готов, утечка сидит в коде. Иногда встряхнуть обработчик помогает посылка SIGCHLD командой kill -CHLD 443488: аккуратный обработчик проснётся и разберёт накопившиеся записи. Честное и мгновенное решение, рестарт виновника командой systemctl restart имя-службы, очищает таблицу сразу, потому что свежий экземпляр обычно ждёт потомков правильно. Завершить сам зомби напрямую нельзя, как ни пробуй: сигнал некому доставлять, запись уйдёт только через родителя.

Одиночная буква Z в колонке STAT не повод для тревоги. Беспокоиться нужно, когда их десятки и они привязаны к одной службе, особенно если счёт идёт на прирост в минуту. Такой шаблон означает, что служба порвёт лимиты в самый неподходящий момент, скажем при пиковой нагрузке, когда процессов и так много.

Профилактика в коде и настройках от двойного форка до pids.max

Лучшая очистка та, после которой чистить нечего. Классический приём называется двойным форком: родитель порождает потомка, потомок немедленно порождает внука и сам завершается, родитель дожидается потомка и читает его код, а внук осиротеет и перейдёт под опеку systemd. Записей-зомби не остаётся, и новая фоновая служба отвязывается от терминала одним махом. В минималистичном коде на C весь вопрос закрывается двумя строками:

signal(SIGCHLD, SIG_IGN);
while (waitpid(-1, NULL, WNOHANG) > 0) { }

Первая строка переводит ядро в режим автосборки без зомби, вторая подойдёт тем, кто предпочитает явный обработчик и разбор кодов выхода. Для существующих развёртываний проверенный набор мер выглядит так:

  1. в конце порождённого процесса всегда читайте код выхода через waitpid, даже если результат не нужен;
  2. если слежение за потомками не нужно вовсе, ставьте SIG_IGN на SIGCHLD или флаг SA_NOCLDWAIT, тогда ядро не создаёт записей-зомби;
  3. для служб, уходящих в фон, применяйте двойной форк, чтобы ответственность за сирот переходила к системному менеджеру;
  4. в контейнерах включайте флаг --init в Docker либо добавляйте утилиту tini первой строкой образа;
  5. страховку со стороны ресурсов дают директива TasksMax в юнит-файле и файл pids.max в группе cgroup v2.

Буква Z в выводе ps выглядит безобидной мелочью, но по сути это честный сигнал от ядра: кто-то не выполнил свою половину контракта между процессами. Найдёте родителя, почините waitpid, и таблица перестанет превращаться в архив законченных программ, а записи о давно закрытых утилитах исчезнут из неё навсегда.