Назад к блогу

Как Podman обходится без демона: механика запуска контейнеров

Как Podman обходится без демона: механика запуска контейнеров

Podman радикально отличается от Docker тем, что не использует постоянно работающий демон: каждый запуск контейнера — это самостоятельный процесс, который создаёт контейнер, вызывает OCI-рантайм и отслеживает его завершение. В статье разбирается, как эта архитектура работает на практике — от выбора между привилегированной и непривилегированной ветвями создания до взаимодействия с systemd и ожидания процессов. Это полезно всем, кто хочет понять, почему Podman ведёт себя иначе в rootless-режиме и как отсутствие демона влияет на управление жизненным циклом контейнеров.

Docker запускает контейнеры через постоянно работающий демон. Podman устроен иначе: каждый запуск podman run — это отдельный процесс, который сам создаёт контейнер, сам запускает OCI-рантайм и сам следит за результатом. Из этого различия вырастает всё остальное: как выбирается ветвь создания, кто считается главным процессом юнита systemd, как передаётся состояние между процессами, как работает политика перезапуска при отсутствии демона-координатора и почему rootless-режим опирается на user namespaces и отдельный сетевой стек. Разберём механику по шагам.

Что запускается вместо демона

Когда Podman создаёт контейнер, он выбирает одну из двух ветвей — привилегированную или непривилегированную. Решение принимает функция hasCurrentUserMapped, которая проверяет, отображён ли текущий пользователь внутри user namespace контейнера.

Логика такая. Если оба списка отображений — UID и GID — пусты, считается, что пользователь отображён. Иначе проверяется, что эффективный UID процесса попадает в один из диапазонов UIDMap, а эффективный GID — в один из диапазонов GIDMap. Попадание проверяется по границам диапазона: идентификатор должен быть не меньше начального HostID и строго меньше HostID + Size.

В CreateContainer результат этой проверки используется в условии выбора ветви. Если текущий пользователь не отображён или задан RootfsMapping — параметр, который явно указывает отдельное отображение для корневой файловой системы контейнера, — создание идёт по непривилегированной ветви: контейнер запускается без полного набора привилегий хоста, а его идентификаторы отображаются через подчинённые диапазоны. Во всех остальных случаях создание идёт по привилегированной ветви: контейнер получает обычный набор привилегий хоста. То есть RootfsMapping переводит создание в непривилегированную ветвь даже тогда, когда пользователь внутри namespace отображён.

В непривилегированной ветви дополнительно вычисляется hideFiles: он равен true только для непривилегированного контейнера при запуске не в rootless-режиме. Это скрытие файлов, которое не нужно, когда сам Podman уже работает без root.

Как Podman сообщает systemd о главном процессе

Когда контейнер запускается под управлением systemd, Podman уведомляет systemd о том, какой процесс считать главным. Для этого используется поле MAINPID — часть уведомления systemd: в нём указывается PID процесса, который systemd должен считать главным процессом юнита. Podman подставляет туда PID не контейнерного процесса, а conmon. PID conmon читается из pid-файла и сохраняется в состоянии контейнера.

Смысл в том, что именно conmon переживает контейнерный процесс и именно он сообщает о завершении. Если systemd будет следить за PID самого контейнера, он потеряет связь с процессом, который на самом деле управляет жизненным циклом.

Режим SdNotifyMode определяет, как именно отправляется уведомление. В режиме SdNotifyModeIgnore уведомление не отправляется вовсе — блок отправки MAINPID пропускается. В режиме SdNotifyModeConmon в то же сообщение добавляется READY, то есть готовность сообщается сразу. В режиме SdNotifyModeHealthy READY отправляется позже — после того как контейнер станет healthy.

Как Podman ждёт завершения conmon

Ожидание завершения процесса построено на опросе. Функция waitPidStop в цикле проверяет процесс сигналом 0 через unix.Kill(pid, 0) и спит 10 миллисекунд между проверками. Если Kill возвращает unix.ESRCH — «такого процесса нет», — ожидание прекращается: процесс считается завершённым. Эта же функция используется для ожидания остановки контейнера и остановки exec-сессии.

Проверка запущенности conmon вынесена отдельно в CheckConmonRunning. Если ConmonPID равен 0, но состояние — Running или Paused, предполагается, что conmon работает (с предупреждением в лог). Если PID есть, он пингуется сигналом 0, и при unix.ESRCH conmon считается неработающим.

Как Podman общается с OCI-рантаймом

Связь между Podman и conmon идёт через пару unix-сокетов, которую создаёт newPipe: первый файл — родительский, второй — дочерний. Через этот канал conmon передаёт структуру syncInfo — сообщение о результате операции, по которому Podman понимает, создался ли контейнер. У неё два поля: Data — целое число, несущее результат операции (при успехе — неотрицательное значение), и Message — строка с текстом ошибки, если он есть. Эта структура и есть единица синхронизации: conmon отправляет её по каналу, а Podman по ней узнаёт, завершилась ли операция успешно или с ошибкой.

Читающая сторона читает сообщение построчно, потому что conmon завершает передачу символом новой строки, а затем разбирает полученные байты как JSON. Если Data отрицательно, читающая сторона пытается прочитать лог OCI-рантайма, разобрать его как ошибку и вернуть её. Если разобрать лог не удалось, но Message не пуст, ошибка формируется из Message. Если и Message пуст, возвращается общая ошибка container create failed.

Ожидание данных ограничено таймаутом ContainerCreateTimeout. По его истечении возвращается -1 и ошибка container creation timeout. В обратную сторону в канал пишется один нулевой байт — это nonce для синхронизации: conmon получает уведомление, что можно продолжать.

Отдельная тонкость — закрытие записи в сокет. Функция socketCloseWrite вызывает CloseWrite() и фильтрует ошибку ENOTCONN: если другая сторона соединения уже закрыта, на FreeBSD это может вернуть ENOTCONN, и такая ситуация считается успехом, а не ошибкой.

Как Podman узнаёт о завершении контейнера

Завершение контейнера определяется по exit-файлу. Функция waitForExitFileAndSync ждёт появления файла с таймаутом 5 секунд. Если файл так и не появился, состояние сбрасывается: код возврата -1, время завершения — текущее, состояние — Stopped, после чего состояние сохраняется и возвращается ошибка.

Если файл появился, из него извлекаются данные. Время завершения берётся из времени создания exit-файла, а код возврата читается из содержимого файла и преобразуется в целое. Дополнительно проверяется наличие oom-файла: если он существует, в состоянии контейнера выставляется признак OOMKilled. Затем выставляется Exited, пишется событие о выходе контейнера с кодом возврата, и код сохраняется в базе рантайма. Событие формируется на основе кода возврата независимо от признака OOMKilled.

Отдельный случай — conmon умер, не создав exit-файл, а контейнер всё ещё в состоянии Stopping. Тогда код возврата выставляется в -1, состояние — Stopped, PID и ConmonPID сбрасываются в 0, выставляются время завершения и Exited. В поле ошибки записывается строка о том, что conmon умер без exit-файла и код возврата получить не удалось, а наружу возвращается ошибка, обёрнутая вокруг ErrConmonDead. Перед этим, если PID1 контейнера ещё жив, он немедленно убивается через SIGKILL с нулевым таймаутом.

Политика перезапуска без демона

Поскольку демона нет, решение о перезапуске принимается на основе состояния, сохранённого в базе. Ключевой элемент — флаг совпадения политики RestartPolicyMatch, который хранится в состоянии контейнера. Он выставляется только при наблюдённом рантаймом переходе из Running или Paused в Stopped или Exited, когда политика не RestartPolicyNone и не RestartPolicyNo и контейнер не был остановлен пользователем.

Функция shouldRestart блокирует перезапуск по нескольким условиям по порядку. Сначала — если контейнер был явно остановлен пользователем (StoppedByUser). Затем — если флаг совпадения политики не выставлен либо политика равна RestartPolicyNo или RestartPolicyNone. Для политики RestartPolicyOnFailure перезапуск дополнительно блокируется при нулевом коде возврата и при исчерпании лимита попыток: когда счётчик перезапусков достиг заданного лимита.

Явная остановка пользователем блокирует перезапуск дважды. stopInternal выставляет StoppedByUser и переводит состояние в Stopping, из-за чего переход Running/Paused → Stopped/Exited не выполняется, и флаг RestartPolicyMatch не становится истинным. То есть даже без проверки StoppedByUser политика не сработала бы.

Когда перезапуск всё же происходит, Podman проверяет зависимости, удаляет временные файлы healthcheck, генерирует событие Restart, увеличивает счётчик перезапусков и сохраняет состояние (при ошибке — очистка). Затем всегда разбирается сеть, вызывается подготовка: при состоянии Stopped — реинициализация, при Configured или Exited — инициализация, после чего запуск и ожидание healthy.

Почему после перезагрузки хоста контейнеры не восстанавливаются

Функция refresh вызывается при старте рантайма, когда файл-маркер живости. Она заново создаёт каталоги, пространства имён и cgroups для контейнеров, подов и томов. Сначала из базы выбираются все контейнеры, поды и тома, затем для каждого вызывается своя refresh-функция. Блокировки при этом не берутся, и сами refresh-функции не должны их брать.

Сразу после refresh контейнера проверяется его состояние без блокировки — удерживается alive-блокировка. Если контейнер помечен автоудаляемым и уже находится в состоянии Exited, либо находится в состоянии Removing, он удаляется без force, но с удалением тома. Это происходит именно здесь потому, что такой контейнер должен был автоудалиться ещё до перезагрузки, но не успел, а рантайм только что стартовал — значит, force-удаление не требуется.

В конце refresh создаётся файл-маркер живости рантайма и генерируется событие Refresh. Именно поэтому перезапуск контейнеров после перезагрузки хоста не выполняется: рантайм не «поднимает» контейнеры, а лишь приводит в порядок их состояние и удаляет то, что должно было исчезнуть.

Rootless через user namespaces

Rootless-режим опирается на подчинённые диапазоны UID и GID из /etc/subuid и /etc/subgid. Формат строки — USERNAME:UID:RANGE: имя пользователя, начальный выделенный UID и размер диапазона. Например, johndoe:100000:65536 означает, что пользователю johndoe выделены UID с 100000 по 165535 в дополнение к его обычному UID в /etc/passwd.

Podman получает эти диапазоны через ParseIDMapping, которая принимает пути к subuid- и subgid-файлам и заполняет отображения UID и GID. Если задан только один из двух путей, второй приравнивается к первому. Если же ни явные отображения, ни subuid/subgid не заданы, а процесс запущен не от root, используются одиночные маппинги 0:<uid>:1 и 0:<gid>:1. При наличии непустых отображений флаги HostUIDMapping и HostGIDMapping сбрасываются в false.

Отдельный сценарий — «имперсонация» группы. Команда usermod --add-subgids 2000-2000 johndoe выделяет пользователю подчинённый диапазон групп из одного идентификатора 2000, что позволяет ему представить эту группу внутри контейнера. Синтаксис --gidmap="+g102000:@2000" задаёт отображение: группа 2000 на хосте превращается в группу 102000 внутри контейнера. Знак + означает добавление к существующим отображениям, @ указывает на подчинённую группу пользователя. Опция --group-add keep-groups сохраняет дополнительные группы пользователя при запуске контейнера. Вместе эти три элемента позволяют контейнеру видеть нужную группу хоста под другим номером.

При обновлении /etc/subuid или /etc/subgid нужно остановить все запущенные контейнеры пользователя и убить pause-процесс, запущенный в системе для этого пользователя. Автоматически это делает podman system migrate от имени пользователя. Причина в том, что изменение диапазонов затрагивает уже запущенные контейнеры и pause-процесс, поэтому их нужно остановить, чтобы новые отображения ID применились корректно.

Сеть и привилегированные порты

В rootless-режиме Podman использует pasta вместо стандартного сетевого стека. Pasta полностью поддерживает IPv6 и архитектурно безопасна: она работает в отдельном процессе и использует современные механизмы изоляции Linux.

Pasta требуется установить для создания сетевого устройства. Без неё rootless-контейнеры вынуждены работать в сетевом пространстве имён хоста. Ситуация по умолчанию, когда pasta не позволяла контейнеру и хосту обмениваться данными, исправлена в Podman 5.3.

Что из этого следует на практике

Отсутствие демона — не просто деталь реализации, а причина целого набора поведений. Состояние контейнера живёт в базе рантайма и в файлах (exit-файл, oom-файл, pid-файл conmon), а не в памяти долгоживущего процесса. Поэтому завершение контейнера определяется по появлению exit-файла с таймаутом 5 секунд, а смерть conmon без exit-файла — отдельный, явно обрабатываемый случай.

Политика перезапуска работает не «сама по себе», а через флаг RestartPolicyMatch, который выставляется только при наблюдённом переходе из Running/Paused в Stopped/Exited. Явная остановка пользователем идёт через Stopping и потому этот переход не выполняет — перезапуска не будет, даже если политика задана.

После перезагрузки хоста контейнеры не восстанавливаются: refresh лишь приводит в порядок каталоги, пространства имён и cgroups, а также удаляет автоудаляемые контейнеры, которые не успели исчезнуть до перезагрузки.

Rootless-режим целиком опирается на подчинённые диапазоны из /etc/subuid и /etc/subgid и на user namespaces. Изменение этих файлов требует остановки контейнеров и pause-процесса — иначе новые отображения ID не применятся. Сеть в этом режиме обеспечивает pasta, и без неё rootless-контейнер оказывается в сетевом пространстве имён хоста.

Где смотреть в коде

Источники

Похожее