Борьба с зомби-процессами

В Linux каждый процесс знает о своих потомках и должен самостоятельно о них заботиться. Например, если код приложения создает дочерний процесс вызовом системной функции fork(), то он должен дождаться его завершения через вызов функции waitpid(). Вполне логично, ведь "родитель" должен заботиться о своих детях. :)

Борьба с зомби-процессами

Функция fork() создает процесс на основе текущего исполняемого файла. Типичный код, который создает дочерний процесс, обычно выглядит следующим образом.

int pid = fork(); if (pid > 0) { // Parent logic waitpid(pid); } else { // Children logic }

Когда процесс завершается, он переходит в статус "зомби" (zombie, defunct), а ОС формирует сигнал SIGCHLD для всех процессов, которые ожидали его завершения с помощью функции waitpid(). Вместе с этим waitpid() заканчивает работу и возвращает управление, а зомби уничтожается - удаляется из системных таблиц ядра ОС. Если никто не ждет дочерний процесс, он продолжает бесконечно висеть в статусе "зомби", находясь на учете у ОС.

В общем случае зомби-процессы сигнализируют об ошибках ПО, для этого их и придумали. В справках пишут, что зомби-процессы не потребляют ресурсы (RAM/CPU), но, находясь на учете у ОС, расходуют PIDs. Таким образом, рано или поздно может произойти нехватка PIDs. Это значит, что вы не сможете запустить новый процесс или поток!

Самый простой способ создать зомби - убить родительский процесс раньше дочернего. В этом случае ОС назначает сироте нового родителя - опекуна. По дизайну Linux им становится процесс с PID=1, который называют init-процессом. В обязанности init-процесса в том числе входит грязная работа - делать то, что не сделали безответственные родители - дожидаться завершения "зомби" и таким образом удалять их из системных таблиц ядра ОС.

Как правило, в большинстве Docker-образов init-процессом является sh. Так повелось, что sh специально "игнорирует" наличие зомби и не освобождает PIDs. Это сделано для того, чтобы системные администраторы могли увидеть проблему, например, выполнив команду ps, в выводе которой зомби-процессы отображаются как <defunct>. Таким образом, зомби будут висеть в системе бесконечно, до рестарта системы. Игнорирование этой проблемы ведет к утечке PIDs, которая при наличии в ОС лимита на их количество приводит к невозможности запуска новых процессов и потоков. Следовательно, к серьезным проблемам функционирования сервера.

Кстати, на базе этой особенности построена очень простая, но крайне агрессивная атака, называемая Fork bomb (по имени системной функции). Вот вариант ее реализации на C++ - процесс клонирует сам себя в бесконечном цикле:

#include <unistd.h> int main() { while (1) fork(); }

Если запустить такой процесс без подготовки, поможет только рестарт. Но даже если получится принудительно завершить все вредоносные процессы, в системных таблицах ядра ОС останется куча зомби-процессов и лимит PIDs будет на гране истощения.

Но что, если мы знаем, что запускаем ненадежный код? Например, реализация какого-нибудь GitLab делает это постоянно, выполняя CI на базе наших скриптов. Для GitLab пользовательские скрипты являются ненадежным кодом, и такие процессы могут быть завершены некорректно или принудительно. В таком приложении наличие зомби-процессов в системе - это норма. Тогда как бороться с зомби и утечкой PIDs?

Решение заключается в подмене init-процесс на такой, который будет удалять зомби сразу, как только они появляются. Делать ничего не нужно, т.к. всё уже сделано за нас. Проект называется tini. Использовать легко: в Dockerfile нужно указать tini в качестве ENTRYPOINT.

В дополнение могу порекомендовать книгу "Linux System Programming" (Robert Love). В ней очень кратко и понятно описывается внутреннее устройство Linux и то, как правильно работать с функциями ОС.

P.s. Если вам интересна данная тематика, присоединяйтесь к моей новостной ленте в Telegram или здесь. Буду рад поделиться опытом. ;-)

Начать дискуссию