Почему процесс-зомби не занимает ни байта памяти, но может положить сервер
Вы смотрите в top, видите строку со статусом Z и колонкой памяти в ноль, и первая реакция — «ну и ладно, раз он ничего не жрёт». А через месяц сервер отказывается принимать новые соединения, fork() в логах падает с Resource temporarily unavailable, и оказывается, что зомби были не безобидным мусором, а симптомом медленно текущей утечки другого рода — утечки записей в таблице процессов. Разберём, что происходит с процессом после его смерти, почему он действительно не занимает память, и почему это не отменяет реальной опасности.
Содержание
Что такое процесс-зомби на самом деле
Когда процесс завершается — вызывает exit(), получает необработанный сигнал или просто доходит до конца main(), — ядро Linux не удаляет его немедленно. Происходит следующее:
- Ядро закрывает все файловые дескрипторы процесса, освобождает виртуальную память, отключает от него разделяемые библиотеки, снимает с очередей планировщика.
- Ядро сохраняет код возврата (
exit code), статистику использования CPU и другую итоговую информацию в компактной записи процесса. - Процесс переходит в состояние
Z(zombie, в выводеpsчасто помечается какdefunct) и ждёт, пока родительский процесс заберёт эту информацию через системный вызовwait()илиwaitpid(). - Только после того, как родитель вызвал
wait(), ядро удаляет последнюю запись из таблицы процессов (process table), и PID полностью освобождается.
То есть зомби — это не «мёртвый, но работающий» процесс. Это уже полностью мёртвый процесс, от которого осталась ровно одна структура данных в ядре: task_struct в сильно урезанном виде. Именно поэтому в top и htop у зомби всегда 0.0% CPU и 0 памяти в столбцах RSS/VSZ — измерять там нечего, вся адресная память давно возвращена системе.
Проверить состояние конкретного процесса можно напрямую:
cat /proc/<pid>/status | grep State
Для зомби вы увидите State: Z (zombie). А список всех зомби в системе — одной командой:
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z/'
Почему зомби не расходует ни память, ни CPU — разбираем парадокс
Кажущийся парадокс в том, что интуитивно «процесс, который висит в списке процессов, что-то же должен занимать». На самом деле разница принципиальная между двумя вещами:
- Ресурсы самого процесса (адресное пространство, кучи, стек, открытые файлы, сокеты) — освобождаются в момент завершения, до перехода в состояние зомби. Это происходит всегда, вне зависимости от того, вызовет родитель
wait()или нет. - Запись в таблице процессов — крошечная структура (в современных ядрах это часть
task_struct, урезанная до идентификатора, кода возврата и статистики) — остаётся, потому что она нужна родителю. Ядро не может знать заранее, важен ли код возврата вызывающей стороне, поэтому обязано сохранить его до явного запроса.
Размер этой оставшейся записи — единицы, не сотни, килобайт, и в масштабах сервера с гигабайтами оперативной памяти один зомби действительно неотличим от статистической погрешности. Проблема не в весе одной записи, а в том, что таблица процессов — это не безразмерный список, а конечный ресурс с жёстким лимитом (об этом — в следующем разделе). Поэтому корректная формулировка звучит так: один зомби не опасен, но система, которая позволяет зомби накапливаться, стремится к отказу — не по памяти, а по количеству доступных PID и слотов таблицы процессов.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКто должен «похоронить» зомби: роль родителя и wait()
За уборку зомби отвечает родительский процесс, и происходит это так:
- Дочерний процесс завершается, ядро посылает родителю сигнал
SIGCHLD. - Родитель либо явно вызывает
wait()/waitpid()в обработчикеSIGCHLD, либо периодически опрашивает статус детей. - После вызова
wait()ядро окончательно уничтожает запись зомби и освобождает PID для повторного использования.
Если родитель это делает — зомби живёт доли секунды и в ps вы его просто не увидите. Проблема начинается, если родитель:
- не подписан на
SIGCHLDи никогда не вызываетwait()(типичная ошибка в самописных демонах и скриптах-обёртках, которые запускают дочерние процессы черезfork()/exec()и забывают про них); - завис, упал в состояние
D(непрерываемый сон на I/O) или заблокирован сам — тогда он физически не может обработатьSIGCHLD, даже если хотел бы; - сам стал зомби или завершился раньше своих детей без корректной передачи их системному
init.
Отдельный случай — если сам родитель уже умер до того, как забрал детей. Тогда осиротевшие процессы (и будущие зомби) переусыновляются процессом с PID 1 (init, systemd или, в контейнере, тем, что запущено как PID 1). У PID 1 есть особая обязанность в ядре — периодически вызывать wait() за всех осиротевших детей именно для того, чтобы зомби не копились вечно. Если PID 1 в контейнере — это ваше приложение (например, Node.js или Python-скрипт), которое само не занимается reaping, эта защита не работает, и мы подробно разбирали похожий сценарий в статье про то, как PID 1 не пересылал сигналы и ронял деплой — тот же класс проблемы: init-процесс не выполняет свои обязанности перед детьми.
Как накопление зомби реально кладёт сервер
Вот та часть, где кажущаяся безобидность оборачивается инцидентом. У ядра Linux есть жёсткий предел на общее число процессов и на диапазон PID:
cat /proc/sys/kernel/pid_max
sysctl kernel.pid_max
Каждый зомби — это ровно один занятый PID и один занятый слот в таблице процессов, до тех пор, пока родитель его не заберёт. Если родитель систематически не делает wait() — например, плодит новые дочерние процессы в цикле (обработка входящих соединений, запуск воркеров, вызов внешних утилит через system()/popen()), — зомби накапливаются один за другим и никогда не освобождаются сами по себе. Занятые PID не переиспользуются, пока запись зомби жива.
Дальше срабатывает цепочка:
- Число процессов приближается к
kernel.pid_max(или к лимитуulimit -uдля конкретного пользователя, если он ниже системного). - Новые вызовы
fork()начинают завершаться ошибкойEAGAIN— «Resource temporarily unavailable», причём ошибка эта не про память и не про CPU, а именно про невозможность выделить новый PID. - Любой сервис, которому нужно породить дочерний процесс — веб-сервер, принимающий новое соединение через
fork-модель, cron, ssh, shell-обёртки, — начинает падать с этой же ошибкой. - В худшем случае система вообще перестаёт запускать новые процессы, включая диагностические (
ps,top,bash), потому что и они требуютfork().
Здесь стоит явно развести две смежные, но разные аварии, чтобы не путать их в логах:
- Исчерпание PID/таблицы процессов из-за зомби — симптом:
fork failed: Resource temporarily unavailable, при этом свободной памяти поfree -hможет быть много. - Исчерпание лимита пользователя на число процессов (
ulimit -u,nproc) — похожий симптом, но причина не в зомби, а в общем количестве живых процессов пользователя; мы разбирали этот сценарий отдельно в статье про то, как лимит процессов на пользователя оборвал деплой.
Оба случая внешне выглядят как «сервер завис», но требуют разной диагностики: в первом случае вы увидите десятки или сотни строк со статусом Z в ps, во втором — живые процессы, упирающиеся в ulimit.
Практические примеры: где зомби реально накапливаются
Несколько сценариев, которые на практике встречаются чаще всего:
Веб-приложение или CGI-скрипт, вызывающий внешние утилиты. Код вида subprocess.Popen(...) в Python или exec()/system() в PHP без последующего ожидания результата — классический источник зомби при высокой частоте запросов. Если на каждый HTTP-запрос происходит один такой вызов без wait(), а сервис не перезапускается, зомби копятся пропорционально трафику.
Docker-контейнер без нормального init-процесса. Если приложение в контейнере запущено напрямую как PID 1 (без tini, dumb-init или --init), а оно порождает дочерние процессы (например, воркеры, cron-подобные задачи, обёртки над CLI-утилитами) и не реализует reaping, зомби накапливаются именно внутри пространства имён контейнера. Проверить это можно так:
docker top <container_id> | grep Z
Лечится флагом --init при запуске (docker run --init ...) или явным указанием tini как entrypoint — тогда PID 1 берёт на себя обязанность вызывать wait() за все процессы в контейнере.
Самописные демоны и супервизоры. Скрипт, который в цикле форкает воркеров для обработки очереди задач, но обрабатывает только успешное завершение и забывает про ветку с ошибкой, — оставляет зомби именно в ветке исключений. Это медленная утечка: система может работать неделями, прежде чем накопленных зомби станет достаточно, чтобы упереться в лимит.
Fork-бомба или баг с бесконечным циклом порождения процессов. Отдельный, гораздо более быстрый сценарий: если код (по ошибке или в результате атаки) вызывает fork() в цикле без ограничения, таблица процессов заполняется не зомби, а живыми процессами — тоже в считаные секунды упирается в pid_max. Симптомы на уровне ошибок идентичны исчерпанию PID из-за зомби, но причина другая, и ps в этом случае покажет живые процессы, а не Z.
Диагностика и лечение на живом сервере
Порядок действий, когда вы подозреваете накопление зомби:
# посчитать зомби
ps -eo stat | grep -c Z
# найти зомби с их родителями
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z/'
# посмотреть, сколько всего процессов и какой лимит
ps -e | wc -l
cat /proc/sys/kernel/pid_max
ulimit -u
Если зомби нашлись — искать нужно не сам зомби (убить Z-процесс сигналом нельзя, он уже мёртв и ни на какие сигналы не реагирует), а его родителя из колонки PPID:
ps -p <PPID> -o pid,cmd,stat
Дальше два пути:
- Если родитель жив, но завис или не обрабатывает
SIGCHLD— перезапустить именно его (не весь сервер). После завершения родителя все его зомби-дети переусыновляютсяinit/systemd, который корректно вызоветwait()и уберёт их за секунды. - Если родитель сам в состоянии
D(непрерываемый сон) — это отдельная и более тяжёлая проблема с I/O, которая требует своей диагностики; зомби здесь — следствие, а не причина.
Профилактика на уровне архитектуры:
- В коде, который явно вызывает
fork(), всегда регистрировать обработчикSIGCHLDс вызовомwaitpid(-1, NULL, WNOHANG)в цикле, либо использовать библиотечные обёртки, которые это делают за вас (например, модульsubprocessв Python с корректнымcommunicate()/wait(), а не «выстрелил и забыл»). - В контейнерах — всегда запускать реальный init-процесс как PID 1 (
tini,dumb-init,docker run --init), особенно если внутри контейнера порождаются дочерние процессы. - Мониторить количество зомби как отдельную метрику, а не полагаться на общий мониторинг памяти — метрика по CPU/RAM зомби не покажет никак, а вот число процессов в состоянии
Zи близость кpid_max— вполне измеримые и алертуемые величины. Общие принципы сбора метрик по загрузке и процессам разобраны в статье про поиск причины высокой нагрузки на процессор. - Если у вас на сервере systemd — он сам является PID 1 для системных сервисов и корректно реапит зомби от юнитов, которые запускает напрямую; но если сервис сам порождает собственных потомков через
fork, ответственность за них всё равно на самом сервисе, а не на systemd. Подробнее о том, как systemd ведёт себя как init-процесс при загрузке, — в статье как работает systemd при загрузке.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли убить зомби вручную командой kill?
Нет. Зомби — это уже завершённый процесс без исполняемого кода и без обработчиков сигналов, ему физически некому доставить сигнал. kill на PID зомби либо ничего не сделает, либо вернёт ошибку. Убирать нужно причину — родителя, который не вызывает wait().
Зомби занимает файловые дескрипторы или сетевые сокеты?
Нет. Все дескрипторы закрываются в момент завершения процесса, до перехода в состояние зомби. У зомби нет открытых файлов, сокетов или памяти — только запись в таблице процессов с кодом возврата.
Сколько зомби — это уже опасно?
Единого порога нет, и он зависит от значения kernel.pid_max и ulimit -u на конкретном сервере — эти значения стоит проверить командами из раздела про диагностику. Важна не абсолютная цифра, а тренд: если число зомби растёт со временем и не возвращается к нулю, это утечка, которую нужно чинить в коде родительского процесса, а не разовая случайность.
Перезагрузка сервера решает проблему?
Да, но только как временная мера — после перезагрузки таблица процессов пустая. Если не исправить родительский процесс, который не вызывает wait(), зомби начнут копиться заново с той же скоростью.
Отличается ли поведение зомби в контейнере от поведения на обычном сервере?
Механизм тот же самый — это одна и та же подсистема ядра. Разница в том, что PID-пространство контейнера изолировано, и если PID 1 внутри контейнера не реапит зомби, они копятся независимо от того, что происходит на хосте, пока не упрутся в лимит именно этого пространства имён (или общий pid_max хоста, если он общий с другими контейнерами).
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →