Привет, Хабр!

Сегодня расскажу о довольно узком, но довольно интересном моменты работы с Linux, о процессе с PID 1 и зомби‑процессах. Когда запускаешь приложение в минимальном окружении и оно оказывается первым процессом, могут возникнуть небольшие сюрпрзики. Та же команда ps может показывать несколько процессов со статусом <defunct>. Эти дефекты и есть зомби‑процессы. Столкнувшись с ними впервые, можно растеряться, процесс ведь уже завершился, а запись о нём всё торчит в таблице процессов. Как так, и главное, что с этим делать?

Давайте смотреть, почему появляются зомби, какую роль здесь играет процесс № 1 (он же init), и как написать свой минималистичный init, который этих зомби убивает (то есть убирает) автоматически.

PID 1: главный процесс системы

Для начала освежим понимание, что такое PID 1. В любой Unix‑подобной системе есть специальный процесс init с идентификатором 1. Его запускает ядро при загрузке ОС. Этот процесс прародитель всех остальных. Иерархия процессов в Linux древовидная: каждый процесс имеет родителя, кроме самого первого. Init (PID 1) обычно ответственен за старт всех сервисов: он запускает демоны, фоновые службы, логгеры и так далее (в дистрибутиве роль init чаще всего выполняет systemd или аналог). Но помимо запуска, у init есть ещё одна важнейшая обязанность, приём и воспитание осиротевших процессов.

Процесс может породить дочерний процесс, и так выстраивается древо. Когда дочерний завершается, он не исчезает мгновенно, он переходит в состояние зомби. Потому что родитель должен вызвать системный вызов wait() (или его варианты) чтобы забрать код возврата дочернего процесса и тем самым окончательно удалить запись о процессе из системы.

До тех пор дочерний висит как зомби, памяти почти не занимает, но числится в таблице процессов. Если родитель своевременно делает wait, зомби сразу удаляется, этот процесс называют «жать зомби» или по‑формальному reap zombie processes. В адекватных сценариях большинство программ сами корректно забирают статусы своих потомков.

Например, демон sshd дождётся завершения своей дочерней bash и вызовет waitpid(), убрав зомби.

А что если родитель не вызвал wait? Тогда зомби останется висеть. Более того, бывают ситуации, когда родитель сам умер, так и не дочистив потомков. Такие дети называются осиротевшие процессы. И тут init автоматически усыновляет всех осиротевших детей.

Ядро перепривязывает их родителем к PID 1. Получается, init делается родителем процессов, которых он сам напрямую не создавал. Предполагается, что init возьмёт на себя уборку за этими сиротами, то есть будет ждать их завершения и при необходимости удалять зомби.

Практически все демоны в Unix полагаются на это поведение. Когда они отпочковываются в фон, исходный процесс завершается, оставляя рабочий процесс сиротой, и уже init подхватывает его как нового родителя. Вообще, даже разрабы софта обычно пишут код, держащий в уме: ну, если родитель умер, init потом приберёт. В общей картине классической ОС это работает.

Может возникнуть вопрос: ну висит себе зомби, память вроде не течёт, подожду, сам пропадёт. Однако не всё так безобидно. Зомби процесс хоть и мёртв, всё равно занимает запись в таблице процессов ядра. Эта запись содержит PID, код возврата, немного статистики. Ресурсов мало, но ячейка в таблице занята. Если таких записей накопится очень много, таблица процессов может исчерпаться, и тогда новая fork/exec попытка может провалиться, система не сможет создать новый процесс.

Кроме того, обилие зомби замусоривает вывод утилит вроде ps или top, сбивая с толку администратора. Они отображаются со статусом Z или <defunct>, что явно указывает на проблему в родительском процессе.

PID 1 внутри контейнера

Теперь перейдём к интересному случаю однопроцессовых окружений, например Docker‑контейнеров. Часто Docker‑контейнер строят под запуск одного приложения (процесса). По дефолту Docker запускает команду в контейнере напрямую, без полноценной системы init. То есть ваш процесс внутри контейнера станет PID 1, даже если на хосте это далеко не первый процесс. Контейнер как бы своя мини‑система с собственной нумерацией PIDs.

Многие не сразу осознают последствие процесс, который не рассчитан быть init‑системой, внезапно обрёл PID 1 и всю связанную ответственность. А большинство приложений на такую роль не рассчитаны. Они не пишут в коде «если я PID 1, то ловить чужих зомби». Они, естественно, ждут, что над ними есть systemd или другой init, кто этим займётся.

Рассмотрим конкретный пример. Допустим, у нас контейнер с веб‑сервером, который обрабатывает запросы через CGI‑скрипты.

Схема такая: веб‑сервер (PID 1 в контейнере) → порождает дочерний Bash (CGI) → тот запускает внешнюю утилиту grep.

И вот, сервер решил убить заевший CGI‑скрипт, отправив ему SIGKILL, а grep при этом не тронул. Bash завершается (его больше нет), grep остаётся работать, но теперь у него нет родителя, он сирота. Ядро переподчиняет grep процессу PID 1, то есть нашему веб‑серверу. Server даже не в курсе про тот grep, он его не запускал напрямую. Когда grep закончит работу, он станет зомби, отправив сигнал SIGCHLD родителю. Но родитель, веб‑сервер, не знает о нём и, скорее всего, SIGCHLD проигнорирует или не обработает. Зомби повиснет. Получается, внутри контейнера на PID 1 будет висеть зомби‑процесс grep, пока контейнер не перезапустят.

Ситуация усугубляется, если мы запускаем в контейнере какой‑то сторонний сервис как единственный процесс. Например, берем образ Postgres, запускаем postgres как PID 1. Уверены ли мы, что Postgres нигде не форкается таким образом, что могут остаться сиротами какие‑нибудь subprocess? Если нет, могут накопиться зомби, от которых внутри контейнера некому избавиться.

Особенности сигналов для PID 1

Есть ещё один подвох, про который часто забывают: процесс с PID 1 особым образом обрабатывает сигналы в Linux.

Обычно, если процессу послать сигнал SIGTERM или SIGINT (Ctrl+C), и у процесса нет специально установленного обработчика, ядро применит поведение по умолчанию, просто завершит процесс. Но для PID 1 действует исключение: если он не захватил (не обработал) определённый сигнал, никакого действия не происходит!

То есть SIGTERM прилетает init‑процессу, у которого нет своего обработчика SIGTERM, и игнорируется ядром. Процесс продолжит жить, будто ничего не было.

Конечно, в жизни init почти никогда не убивают, так что это защитная мера, но внутри контейнера это неожиданное поведение. Вы можете попробовать, запустите контейнер с простым скриптом как PID 1, и изнутри контейнера вызовите kill -TERM 1. Ничего не произойдёт, процесс не завершится. А вот если тот же процесс запущен не с PID 1, SIGTERM убьёт его как положено..

В контейнере без спецнастроек docker stop работает некрасиво, SIGTERM внутри никто не ловит, процесс не завершился, и Docker через таймаут прибивает его SIGKILL, то есть моментально и без шанса на очистку ресурсов.

Минимальный init-процесс внутри контейнера

Получается, нам нужен кто‑то, кто возьмёт на себя обязанности systemd внутри контейнера: перехват сигналов и подчистку зомби. Но полноценный systemd или даже /sbin/init тянуть в каждый контейнер это слишком жирно и сложно. init‑процесс для наших нужд можно написать очень компактным. Достаточно, чтобы он делал две вещи:

  1. Запускал наше приложение.

  2. Обрабатывал сигнал.

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

Первая попытка: bash как init (не совсем удачно)

Напрашивается простой путь: использовать /bin/bash для запуска приложения. Bash умеет ожидать дочерние процессы и собирать их статусы. Если в Dockerfile написать что‑то вроде:

CMD ["/bin/bash", "-c", "set -e && /path/to/your_app"]

то в контейнере PID 1 будет Bash, который выполнит ваш your_app как дочерний. Bash по дефолту действительно вызывает wait() для завершившихся процессов, которые он запустил, так что зомби от усыновлённых процессов при таком подходе не висят.

Однако у такого решения серьёзный недостаток: сигналы. Если послать SIGTERM процессу Bash, который запущен как PID 1, Bash завершится сам и не передаст сигнал вашему приложению. В результате контейнер выключится, но грязно, дочернему процессу прилетит SIGKILL автоматически, без возможности нормально завершиться.

Мы опять получаем ситуацию внезапной смерти приложения при docker stop. Можно пытаться извратиться с trap и обработчиком EXIT в Bash, чтобы пересылать сигналы дочкам, но и этого недостаточно: Bash всё равно не будет ждать, пока дети умрут естественно. Он выйдет, а ядро тут же выпилит оставшиеся процессы SIGKILL.

Поэтому лучше написать действительно свою мини‑программу на языке ближе к системе (C, Go, Python — кому что удобнее) и сделать в ней по минимуму всё как надо.

Реализация своего PID 1 на C

Пойдём по пути наименьшего сопротивления: язык C, потому что он даёт прямой доступ к системным вызовам и не тянет лишних зависимостей. Программа mini-init будет делать следующее:

  • Парсить аргументы командной строки, там будет команда и её параметры, которую нужно запустить.

  • Форкаться: в дочернем процессе выполнить execvp() указанной программы (чтобы именно она работала в контейнере как основной сервис).

  • В родительском процессе (который останется PID 1) установить обработчики сигналов:

    • Ловить SIGTERM, SIGINT, SIGHUP и, при их получении, перенаправлять эти сигналы дочернему процессу (нашему сервису). Это позволит при остановке контейнера передать приложению запрос на корректное завершение.

    • Ловить SIGCHLD, сигнал о завершении дочернего процесса (или любого потомка). В обработчике SIGCHLD вызывать waitpid() для любых процессов‑детей, чтобы убрать зомби.

  • Ожидать завершения основного дочернего (нашего приложения), после чего выйти с тем же кодом возврата, что и у приложения.

Обрабатывая SIGCHLD, мы должны вычищать всех завершившихся детей, не только главного. Вдруг наш сервис порождал кого‑то ещё (или кто‑то успел осиротеть и переподчиниться нам). Поэтому waitpid будем вызывать в цикле для любого дочернего, пока они есть. А вот что касается основных сигналов, их можно просто передавать дальше, ведь теперь наш сервис уже не PID 1, и если он не обрабатывает сигналы, то ядро применит стандартное действие. То есть наша прослойка устранит иммунитет PID 1, и всё заработает как на обычной машине.

Посмотрим на код. Ниже приведён упрощённый пример mini-init.c:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <errno.h>

static pid_t child_pid = -1;
static int child_exit_status = 0;

// Обработчик SIGCHLD: жмём всех зомби
static void sigchld_handler(int signo) {
    // Пока есть завершившиеся процессы, вызывать waitpid
    // WNOHANG: не блокировать, просто проверить и вернуть, если ещё живы
    while (1) {
        int status;
        pid_t pid = waitpid(-1, &status, WNOHANG);
        if (pid <= 0) {
            // выходим из цикла, если дочерних нет (0) или ошибка (-1)
            if (pid < 0 && errno == ECHILD) {
                // Нет детей — можно спокойно выйти
            }
            break;
        }
        // Если наш основной дочерний завершился — сохраним его статус
        if (pid == child_pid) {
            child_exit_status = status;
        }
        // цикл продолжится, чтобы собрать всех детей, если их несколько
    }
}

// Обработчик "основных" сигналов: пересылаем их дочке
static void forward_handler(int signo) {
    if (child_pid > 0) {
        kill(child_pid, signo);
    }
}
    
int main(int argc, char *argv[]) {
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <program> [args...]\n", argv[0]);
        return 1;
    }
    // Установим обработчики сигналов
    struct sigaction sa;
    // 1. SIGCHLD -> sigchld_handler
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sa.sa_handler = sigchld_handler;
    sigaction(SIGCHLD, &sa, NULL);
    // 2. SIGTERM, SIGINT, SIGHUP -> forward_handler
    sa.sa_handler = forward_handler;
    sigaction(SIGTERM, &sa, NULL);
    sigaction(SIGINT,  &sa, NULL);
    sigaction(SIGHUP,  &sa, NULL);

    // Создаём дочерний процесс
    child_pid = fork();
    if (child_pid == -1) {
        perror("fork failed");
        exit(1);
    }
    if (child_pid == 0) {
        // Дочерний: запускаем программу
        execvp(argv[1], &argv[1]);
        // Если execvp вернулся, значит ошибка
        perror("execvp failed");
        exit(1);
    }

    // Родитель: ждём завершения дочернего процесса
    int status;
    // Ждём именно нашего основного ребёнка
    pid_t pid = waitpid(child_pid, &status, 0);
    if (pid == -1) {
        perror("waitpid failed");
        exit(1);
    }
    // Здесь основной процесс завершился. Соберём остальные зомби, если появились.
    // (На случай, если дочерний породил кого-то и не дождался, а они успели завершиться)
    sigchld_handler(SIGCHLD);

    // Выходим с тем же кодом, что и у дочернего
    if (WIFEXITED(status)) {
        // Нормальный exit(...)
        exit(WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
        // Завершён сигналом, передаём информацию как код выхода
        // Обычно shell возвращает 128 + номер сигнала
        exit(128 + WTERMSIG(status));
    } else {
        exit(1);
    }
}

Регистрируем два вида обработчиков сигналов. sigchld_handler будет вызываться, когда любой дочерний процесс завершится. В нём с помощью waitpid(-1, WNOHANG) мы в цикле вынимаем сразу все трупики, которые успели накопиться. Вызов с pid=-1 означает забирать любой завершившийся дочерний. Флаг WNOHANG говорит: если нет завершившихся, не зависай, просто верни нолик. Мы крутимся, пока waitpid не вернёт 0 (нет зомби) или -1 (ошибка, скорее всего ECHILD, нет процессов). Так мы гарантируем, что любой наш дочерний, умерший на данный момент, будет убран. Если среди них был наш основной (запущенное приложение), мы сохраняем его статус, чтобы потом корректно выйти с тем же кодом.

Второй обработчик forward_handler ставим на сигналы SIGTERM, SIGINT, SIGHUP. Его задача очень проста: получить сигнал и отправить такой же нашему дочернему процессу (тем самым передать пожелание завершиться). SIGTERM сам по себе это стандартный «остановись», Docker отправляет его при docker stop. SIGINT, это если мы вдруг запустим контейнер в интерактиве и нажмём Ctrl+C. SIGHUP, часто означает перечитай конфиг для демонов, а ещё Docker может слать его при рестарте контейнера. При желании можно добавить и SIGQUIT, и SIGUSR1/2, если нужно, или вообще зарегистрировать все возможные сигналы в Linux. Некоторые init‑программы так и делают, чтобы ничего не пропустить.

Дальше делаем fork(). Получаем PID дочернего. В дочернем вызовом execvp запускаем нашу целевую программу (заменяя текущий процесс), а родитель идёт ниже и выполняет роль надзирателя. Обратите внимание: мы ставили обработчики до форка. Так сигналы будут обрабатываться во всём процессе‑родителе уже с самого начала запуска дочернего.

После форка родитель зовёт waitpid(child_pid, ...) без WNOHANG, то есть просто ждёт завершения основного дочернего процесса (того, что мы породили). Здесь главное приложение будет исполняться, а наш init‑процесс будет спать, пока оно не завершится (или не прервётся сигналом).

Но помните, мы также ловим SIGCHLD. Как только любой дочерний процесс умрёт, наш waitpid может прерваться (возвратится с EINTR), или мы можем параллельно в обработчике sigchld_handler почистить процессы. В нашем случае, раз мы ожидаем конкретный child_pid, при смерти главного waitpid разблокируется и вернёт его PID. Мы получаем статус, сохраняем его. На всякий случай, после выхода из waitpid, я вызвал sigchld_handler(SIGCHLD) вручную: это на случай, если во время исполнения приложения успели накопиться другие зомби (например, приложение породило кого‑то и не дождалось, а они умерли чуть раньше или одновременно). Тогда мы подчистим остатки.

Завершаем наш init с тем же кодом возврата, что и наше приложение. Если оно вышло через exit(code), вернём тот же code. Если погибло от сигнала, вернём код, смиксованный как делают shell: 128 + номер сигнала (например, SIGKILL 9 → exit code 137).

Тестируем программу

Создадим простой Dockerfile:

FROM ubuntu:20.04
COPY mini-init /sbin/mini-init
COPY myapp /usr/bin/myapp    # Это наше тестовое приложение
ENTRYPOINT ["/sbin/mini-init", "/usr/bin/myapp", "param1", "param2"]

Предположим, myapp делает что‑то вроде: запускает пару дочерних процессов, сам ничего про них не знает и не ждёт их, и затем спокойно работает. Также он игнорирует сигналы. Без нашего init картина была бы печальной: зомби копятся, docker stop не останавливает корректно... Проверим, как теперь.

  1. Реакция на docker stop: Docker отправит SIGTERM PID 1. Обработчик поймает SIGTERM и перешлёт его myapp. Если myapp никак не обрабатывает SIGTERM, то по умолчанию ядро его убьёт, и myapp завершится. Наш waitpid это зафиксирует. Mini‑init после этого тоже завершится. Снаружи Docker увидит мгновенное завершение.

  2. Зомби‑процессы: пусть myapp породил некий процесс‑работник, а потом сам по себе завершился (или мы его прибили). Обычно такой работник станет сиротой и должен быть усыновлён init. Наш mini‑init как раз его усыновит. Когда работник завершится, mini‑init получит SIGCHLD и выполнит waitpid, вычистив его запись. Зомби не зависнет вообще. А даже если myapp запустил кого‑то и сразу умер, оставив того работать, картина такая: mini‑init заметил смерть myapp (и начал готовиться завершиться), но пока mini‑init жив, он тоже будет родителем для оставшегося процесса. Если тот процесс живёт дольше mini‑init, вот тут интересный момент: при завершении PID 1 оставшиеся процессы в PID namespace обычно прибиваются ядром автоматически. Но мы можем улучшить код, чтобы ждать и этих внуков.

Стоит упомянуть, что Linux имеет ещё механизм под названием subreaper, процесс может объявить себя промежуточным приёмным родителем для сирот, даже если он не PID 1 (через prctl(PR_SET_CHILD_SUBREAPER)). Этим пользуются некоторые утилиты, но по сути наш случай классический init, мы и есть PID 1, нам субрейпер не нужен.

Также сигналы SIGKILL и SIGSTOP не перехватываются вообще. То есть мы не можем их отловить и переслать. К счастью, Docker не посылает SIGKILL сразу (только если stop с таймаутом), а SIGSTOP вручную никто не шлёт обычно.


Как ни парадоксально, возвращаемся к старому доброму принципу: «один процесс хорошо, а два — лучше».

В контексте контейнеров запускать вторым процессом маленький init‑прокси, вполне разумно, хоть и кажется, будто нарушаем идеологию «1 контейнер = 1 процесс». Зато этот дополнительный 0.001% к использованию CPU избавит нас от трудноуловимых багов и обеспечит адекватное завершение сервисов.

Главное — не оставлять контейнеры совсем без init‑процесса.

Если вам интересны внутренние механизмы Linux и вы хотите понять, как работает ядро изнутри — приглашаем на курс «Разработка ядра Linux». Чтобы узнать, подойдет ли вам программа курса, пройдите вступительный тест.

Преподаватели в рамках набора на курс проведут два бесплатных демо-урока:

  • 6 ноября в 20:00 — «Как ядро Linux взаимодействует с устройствами: драйверы, шины и модель устройств» Записаться

  • 24 ноября в 20:00 — «Вход в ядро: системные вызовы и граница между user space и kernel space» Записаться

Комментарии (1)


  1. apevzner
    02.11.2025 21:56

    Хорошо бы еще от управляющего терминала отцепиться и сделать каждый запущенный mini-init-ом процесс лидером сессии (см setsid())...