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

Привіт, Хабр!
Ми в Хекслете активно використовуємо Докер як для запуску самого додатка і супутніх серверів, так і для запуску користувальницького коду у практичних вправах з програмування. Без цих легковагих контейнерів нам було б в рази складніше впоратися з цими завданнями. Докер-чудова технологія, але іноді виникають несподівані проблеми. Одна з таких проблем (і її рішення) описана в блозі Phusion (це творці Phusion Passenger), сьогодні ми публікуємо її переклад.


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

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

Однак, ми з'ясували, що багато людей не розуміють проблем, з якими ми зіткнулися. Так, це досить низькорівневі системні механізми Unix, які зрозумілі далеко не всім. Тому в цьому пості ми опишемо найголовнішу проблему, яку ми вирішуємо — PID 1 zombie reaping problem.



Виявилося:
  1. Проблеми, які ми вирішуємо, актуальні для багатьох людей.
  2. Багато людей не знають про їх існування, тому в якийсь момент обов'язково починаються несподівані проблеми (закон Мерфі).
  3. Буде дуже неефективно якщо кожен буде вирішувати проблеми самостійно.
Тому ми винесли рішення в універсальний базовий образ, який може використовувати кожен: Baseimage-docker. Цей образ додає купу корисних інструментів, необхідних (як ми вважаємо) розробнику Докер-образів. Ми використовуємо Baseimage-docker як основу для своїх образів.

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



The PID 1 problem: збір зомбі

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

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



Нічого незвичайного. Але що відбувається, коли процес завершується? Припустимо, процес bash (PID 5) був завершений. Він перетворюється на так званий «defunct process», також відомий як «процес зомбі».



Чому це відбувається? 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») дочірній процес.



Але є особливий випадок. Уявіть собі, що батьківський процес завершився, навмисно або через дії користувача. Що відбувається з його дочірніми процесами? У них більше немає батька, тому вони стають «сиротами» (це технічний термін).

Тут у гру вступає процес init. У процесу init — PID 1 — є спеціальна завдання: «усиновляти» осиротілі процеси (це знову справжній технічний термін). Це означає, що init стає батьком таких процесів, не дивлячись на те, що вони в реальності не були породжені init'ом.

Розглянемо приклад з Nginx, який демонізується за замовчуванням. Він працює таким чином: спочатку Nginx створює дочірній процес. Потім основний процес Nginx завершується. Тепер дочірній процес Nginx усиновлена init'ом.



Ядро ОС очікує від 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 slot a 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 не обов'язково важка. Можливо, ви думаєте про Вискочка, 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()'ать його напряму).

У підсумку вийде така ієрархія процесів:



Але, на жаль, у цього підходу є проблема. Він не обробляє сигнали! Припустимо, ви використовуєте kill щоб послати сигнал SIGTERM процесу bash. Bash завершується, але не посилає SIGTERM своїм дочірнім процесом!



Коли bash завершується, ядро завершує весь контейнер з усіма процесами всередині. Ці процеси завершуються за допомогою SIGKILL. Тому немає способу завершити ці процеси чисто. Припустимо, ваш додаток пише щось в файл. Файл може бути пошкоджений якщо додаток завершилося таким чином під час запису. Нечисте завершення процесів це погано. Це майже як висмикнути шнур живлення сервера.

Але чому нас має хвилювати, що процес init завершується сигналом SIGTERM? Тому що docker stop посилає SIGTERM процесу init. «docker stop» повинен зупинити контейнер правильно, щоб його можна було потім запустити за допомогою «docker start».

Експерти з bash напевно захочуть написати нормальний обробник EXIT, який посилає сигнали своїм дітям зразок такого:

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

trap cleanup EXIT
/path to your-app

На жаль, це не вирішує проблеми. Посилати сигнали дочірнім процесів недостатньо. init також повинен чекати завершення дочірніх процесів перед тим, як завершаться самому. Якщо init завершиться раніше, то всі дочірні процеси будуть вбиті (не чисто) ядром.

Очевидно, потрібно трохи більш складне рішення, але повна система init з Вискочка, Systemd і SysV це занадто жирно для легковагового докер-контейнера. На щастя, Baseimage-docker містить рішення. Ми написали свою, легку систему init спеціально для використання всередині докер-контейнера. Не придумавши нічого кращого, ми назвали її my_init. Це програма на Пітоні в 350 рядків.

Ключові функції my_init:
  • Обробить (reap) дочірні процесів
  • Запускає підпроцеси
  • Очікує завершення всіх підпроцесів перед власним завершенням, з максимальним таймаутом
  • Записує активність в «docker logs»


Вирішить Докер цю проблему сам?

В ідеалі, проблема з 1 PID повинна вирішуватися нативно самим Докером. Було б здорово, але поки, в січні 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 і вставили його туди.

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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.