Проблема PID 1 zombie reaping в Докере

в 7:00, , рубрики: docker, linux, python, UNIX, zombie, Блог компании «Hexlet», виртуализация, Настройка Linux, системное администрирование

Привет!
Мы в Хекслете активно используем Докер как для запуска самого приложения и сопутствующих серверов, так и для запуска пользовательского кода в практических упражнениях по программированию. Без этих легковесных контейнеров нам было бы в разы сложнее справиться с этими задачами. Докер –замечательная технология, но иногда возникают неожиданные проблемы. Одна из таких проблем (и ее решение) описана в блоге Phusion (это создатели Phusion Passenger), сегодня мы публикуем ее перевод.

Примерно год назад, когда Докер был в версии 0.6, мы первыми представили Baseimage-docker. Это минимальный образ Ubuntu, модифицированный специально для Докера. Люди могут пуллить этот базовый образ из Docker Registry и использовать его как основу для своих образов.

Мы были ранними пользователям Докера, используя его для CI и для создания рабочего окружения задолго до выхода версии 1.0. Базовый образ мы сделали чтобы решить проблемы, специфичные для принципов работы Докера. Например, Докер не запускает процессы под специальным процессом init, который бы правильно обрабатывал дочерние процессы, поэтому возможна такая ситуация, когда зомби-процессы вызывают кучу проблем. Докер также не делает ничего с syslog, поэтому важные сообщения могут быть утеряны. И так далее.

Однако, мы выяснили, что многие люди не понимают проблем, с которыми мы столкнулись. Да, это довольно низкоуровневые системные механизмы Unix, которые понятны далеко не всем. Поэтому в этом посте мы опишем самую главную проблему, которую мы решаем – PID 1 zombie reaping problem.

Проблема PID 1 zombie reaping в Докере - 1

Оказалось:

  1. Проблемы, которые мы решаем, актуальны для многих людей.
  2. Многие люди не знают об их существовании, поэтому в какой-то момент обязательно начинаются неожиданные неполадки (закон Мерфи).
  3. Будет очень неэффективно если каждый будет решать проблемы самостоятельно.

Поэтому мы вынесли решение в универсальный базовый образ, который может использовать каждый: Baseimage-docker. Этот образ добавляет кучу полезных инструментов, необходимых (как мы считаем) разработчику Докер-образов. Мы используем Baseimage-docker как основу для все своих образов.

Сообществу нравится что мы делаем: наш образ третий по популярности в Docker Registry после официальных образов Ubuntu и CentOS.

Проблема PID 1 zombie reaping в Докере - 2

The PID 1 problem: сбор зомби

Все процессы в Unix представлены в виде дерева. Каждый процесс порождает дочерние процессы, и каждый процесс имеет родителя кроме самого верхнего (или корневого).

Корневой процесс это init. Он запускается ядром при загрузке системы. init отвечает за старт остальных частей системы, например, демона SSH, демона Докера, запуск Apache/Nginx, запуск графического интерфейса и так далее. Каждый из них в свою очередь запускает свои дочерние процессы.

Проблема PID 1 zombie reaping в Докере - 3

Ничего необычного. Но что происходит когда процесс завершается? Допустим, процесс bash (PID 5) был завершен. Он превращается в так называемый “defunct process”, также известный как “процесс зомби”.

Проблема PID 1 zombie reaping в Докере - 4

Почему это происходит? Unix сделан таким образом, что родительский процесс ждет завершения дочернего чтобы получить код завершения (exit status). Зомби процесс существует до тех пор, пока родительский процесс не закончит это действие, используя семейство системных вызовов waitpid(). Вот цитата из man:

A child that terminates, but has not been waited for becomes a “zombie”. The kernel maintains a minimal set of information about the zombie process (PID, termination status, resource usage information) in order to allow the parent to later perform a wait to obtain information about the child.

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

В большинстве случаев это не проблема. Системный вызов waitpid() для обработки зомби называют “reaping” (сбор, обработка). Многие приложения обрабатывают свои дочерние процессы корректно. В примере с sssh выше если bash завершается, то ОС пошлет сигнал SIGCHLD процессу sshd чтобы разбудить его. Sshd заметит это и обработает (“reaps”) дочерний процесс.

Проблема PID 1 zombie reaping в Докере - 5

Но есть особый случай. Представьте себе, что родительский процесс завершился, намеренно или из-за действия пользователя. Что происходит с его дочерними процессами? У них больше нет родителя, поэтому они становятся “сиротами” (это технический термин).

Тут в игру вступает процесс init. У процесса init – PID 1 – есть специальная задача: “усыновлять” осиротевшие процессы (это снова настоящий технический термин). Это означает, что init становится родителем таких процессов, не смотря на то, что они в реальности не были порождены init’ом.

Рассмотрим пример с Nginx, который демонизируется по-умолчанию. Он работает следующим образом: сначала Nginx создает дочерний процесс. Потом основной процесс Nginx завершается. Теперь дочерний процесс Nginx усыновлен init’ом.

Проблема PID 1 zombie reaping в Докере - 6

Ядро ОС ожидает от init специального поведения: ядро считает, что init должен обрабатывать (собирать, “reap”) усыновленные процессы тоже.

Это очень важная функция в Unix. Она настолько фундаментальна, что многие программы рассчитаны на ее корректную работу. Большинство демонов рассчитано на то, что демонизированные процессы будут усыновлены и обработаны (то есть корректно завершены после превращения в зомби) init’ом.

Я использую демоны в качестве примера, но этот механизм распространяется не только на них. Каждый раз когда процесс, имеющий детей, завершается, он ожидает, что init подчистит все за ним. Это описано детально в двух очень хороших книгах: Operating System Concepts и Advanced Programming in the UNIX Environment.

Почему процессы зомби вредны

Почему зомби-процессы вредны, не смотря на то, что они всего лишь завершенные процессы? Ведь наверняка память, выделенная процессу уже освобождена, и зомби это всего лишь строка в ps?

Да, память этого процесса уже освобождена. Но тот факт, что процесс еще виден в ps означает, что он использует ресурсы ядра. Вот цитата из man по waitpid:

As long as a zombie is not removed from the system via a wait, it will consume a slot in the kernel process table, and if this table fills, it will not be possible to create further processes.

До тех пор пока zombie не удален из системы с помощью wait, он будет использовать слот в таблице процессов ядра, и если эта таблица заполнится, создание новых процессов будет невозможно

Причем тут Докер

Причем же тут Докер? Многие люди запускают только один процесс в своем контейнере. Но скорее всего этот процесс не ведет себя как правильный init. То есть вместо корректной обработки усыновленных процессов, он считает, что другой init процесс должен делать это. И считает так совершенно справедливо.

Давайте рассмотрим конкретный пример. Допустим, ваш контейнер содержит веб-сервер, в котором крутится CGI-скрипт, написанный на bash. Скрипт вызывает grep. Потом веб-сервер решает, что скрипт обрабатывается слишком долго и убивает его. Но grep остается запущенным. Когда он заканчивает свою работу, он превращается в зомби и усыновляется процессом PID 1 (веб-сервером). Веб-сервер не знает ничего про grep, поэтому не обрабатывает его завершение и зомби-grep остается в системе.

Проблема применима и к другим ситуациям. Многие создают контейнеры для сторонних приложений, например, PostgreSQL, и запускают эти приложения как единственный процесс внутри контейнера. Когда вы запускаете чужой код, уверены ли вы что он не порождает дочерние процессы, которые потом превратятся в зомби? Если вы запускаете свой код и точно знаете, что он и используемые им библиотеки делают, то все хорошо. Но в общем случае необходимо запускать правильный init для решения проблем.

Но разве запуск полного системного init не превращает контейнер в тяжелую штуку вроде виртуальной машины?

Система init не обязательно тяжелая. Возможно, вы думаете про Upstart, Systemd, SysV и так далее. Возможно, вам кажется, что внутри контейнера нужно запустить целую систему. Это не так. “Полная система init” не обязательна и не нужна.

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

Простая init система

Возможно, есть готовые решения? Почти. Старый добрый bash. Bash обрабатывает усыновленные процессы. Bash может запустить что угодно. Так что вместо такой строчки в Dockerfile…

CMD ["/path-to-your-app"]()

можно написать

CMD ["/bin/bash", "-c", "set -e && /path-to-your-app"]()

(директива -e запрещает bash’у распознавать скрипт как простую команду и exec()’ать его напрямую).

В итоге получится такая иерархия процессов:

Проблема PID 1 zombie reaping в Докере - 7

Но, к сожалению, у этого подхода есть проблема. Он не обрабатывает сигналы! Допустим, вы используете kill чтобы послать сигнал SIGTERM процессу bash. Bash завершается, но не посылает SIGTERM своим дочерним процессам!

Проблема PID 1 zombie reaping в Докере - 8

Когда bash завершается, ядро завершает весь контейнер со всеми процессами внутри. Эти процессы завершаются с помощью SIGKILL. Поэтому нет способа завершить эти процессы чисто. Допустим, ваше приложение пишет что-то в файл. Файл может быть поврежден если приложение завершилось таким образом во время записи. Нечистое завершение процессов это плохо. Это почти как выдернуть шнур питания у сервера.

Но почему нас должно волновать, что процесс init завершается сигналом SIGTERM? Потому что docker stop посылает SIGTERM процессу init. “docker stop” должен остановить контейнер правильно, чтобы его можно было потом запустить с помощью “docker start”.

Эксперты по bash наверняка захотят написать нормальный обработчик EXIT, который посылает сигналы своим детям, вроде такого:

# !/bin/bash
function cleanup()
{
local pids=`jobs -p`
if [\[ "$pids" != "" ]()]; then
kill $pids \>/dev/null 2\>/dev/null
fi
}

trap cleanup EXIT
/path-to-your-app

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

Очевидно, требуется чуть более сложное решение, но полная система init с Upstart, Systemd и SysV это слишком жирно для легковесного докер-контейнера. К счастью, Baseimage-docker содержит решение. Мы написали свою, легкую систему init специально для использования внутри докер-контейнера. Не придумав ничего лучше, мы назвали ее my_init. Это программа на Питоне в 350 строк.

Ключевые функции my_init:

  • Обработает (reap) дочерние процессов
  • Запускает подпроцессы
  • Ожидает завершения всех подпроцессов перед собственным завершением, с максимальным таймаутом
  • Записывает активность в “docker logs”

Решит ли Докер эту проблему сам?

В идеале, проблема с PID 1 должна решаться нативно самим Докером. Было бы здорово, но пока, в январе 2015 года, мы не слышали ничего подобного от команды Докера. Это не критика – Докер очень амбициозен, и я уверен, что у их команды есть проблемы поважнее. Проблема PID 1 легко решается на пользовательском уровне. Так что пока Докер не решит эту проблему официально, мы рекомендуем людям решать ее самим, используя систему вроде той, что описана выше.

Проблема ли это вообще?

Проблема может казаться гипотетической. Если вы никогда не видели зомби в своем контейнере, вам может показаться что все нормально. Но единственный способ удостовериться, что проблемы нет это проверить весь свой код, все свои библиотеки и все библиотеки, которые используются библиотеками. Если вы не сделали этого, то возможно где-то сидит строка, которая запускает дочерний процесс, который потом превратится в зомби.

Не забывайте про закон Мерфи.

Кроме того, что зомби забивают таблицу ресурсов ядра, они также могут мешать корректной работе программ, которые проверяют наличие процессов. Например, Phusion Passenger управляет процессами. Он перезапускает процессы при их падении. Он парсит вывод ps и отправляет сигнал 0 процессу. Зомби виден в ps и реагирует на сигнал 0, так что Phusion Passenger думает, что процесс все еще жив.

Все, что нужно чтобы обезопасить себя от проблемы с зомби, это потратить 5 минут на подключение Baseimage-docker или на импорт 350 строк my_init. Дополнительные затраты на диск и память минимальны: в память добавляется лишь пара мегабайт.

Заключение

Проблема PID 1 – реальна. Один из способов ее решения – использовать Baseimage-docker. Единственный ли это путь? Конечно, нет. Цели Baseimage-docker это:

  1. Рассказать людям о нескольких важных моментах при работы с Докер-контейнерами.
  2. Предоставить готовое решение чтобы люди не изобретали велосипед.

При этом возможны несколько решений, главное чтобы они справлялись с описанной задачей. Можете написать свой вариант на C, Go, Ruby или чем-то еще.

Возможно, вы не хотите использовать базовый образ Ubuntu. Может, вы используете CentOS. Но Baseimage-docker все равно может быть вам полезен. Например, проект ourpassenger_rpm_automation использует контейнеры CentOS. Мы просто извлекли my_init и вставили его туда.

Счастливого Докерства!

Автор: freetonik

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js