Как я написал PID 1 для контейнеров на чистом ассемблере: x86-64 и ARM64

в 16:43, , рубрики: arm64, assembly, containers, Debian, docker, epoll, linux, pid 1, x86-64

У меня была довольно странная идея: написать минимальный PID 1 для контейнеров целиком на ассемблере.

Не потому, что контейнерному миру срочно нужен ещё один init. Есть Tini, есть docker run --init, и для большинства production-сценариев я бы по-прежнему начал именно с них.

Мне скорее хотелось разобрать задачу до самого нижнего уровня: без libc, без runtime, напрямую через Linux syscalls. Заодно проверить, насколько сильно будет отличаться одна и та же реализация на x86-64 и ARM64.

В итоге из небольшого эксперимента получился mini-init-asm: PID 1, который запускает приложение в отдельной process group, передаёт сигналы всей группе, собирает zombie-процессы и умеет корректно завершать контейнер с exit code приложения.

Позже туда добавились subreaper mode и простой restart-on-crash. А сам проект в итоге доехал сначала до Debian unstable, а затем и до testing.

Но началось всё с вопроса: что вообще должен уметь нормальный PID 1 внутри контейнера?

Когда приложение внезапно становится init

Типичный контейнер выглядит примерно так:

container
└── application    PID 1
    ├── worker
    └── helper

На первый взгляд в этом нет ничего необычного. Приложение запустилось, значит всё хорошо.

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

Например, завершившийся child остаётся zombie до тех пор, пока родитель не заберёт его status через wait*(). В обычной системе orphaned процессы в итоге подхватывает init. В PID namespace контейнера эту роль играет его PID 1.

С сигналами тоже есть свои нюансы. У namespace init особая семантика, и обычная программа не обязательно написана с расчётом на то, что однажды окажется первым процессом в namespace.

Поэтому Docker умеет вставить перед приложением небольшой init:

docker run --init image

Один из самых известных вариантов - Tini. Он запускает child, занимается reaping и пересылает сигналы. На этом месте вполне можно было закончить исследование. Но мне хотелось посмотреть, насколько маленькой и при этом понятной получится такая реализация, если оставить между ней и ядром практически ничего.

Почему assembly

Сам по себе выбор ассемблера здесь не даёт какой-то магической производительности.

PID 1 почти всё время ждёт события. Экономить несколько инструкций внутри такого процесса практически бессмысленно. Assembly был интересен по другой причине: он заставляет явно увидеть границу между программой и Linux kernel.

Если нужно создать signalfd, ты не вызываешь функцию из libc. Ты кладёшь номер syscall в регистр, раскладываешь аргументы по ABI и переходишь в kernel. То же самое с clone, execve, wait4, kill, setsid, epoll, timerfd.

Бинарники линкуются через:

ld -nostdlib

Для x86-64 Linux syscall ABI примерно такой:

syscall number: rax

arg1: rdi
arg2: rsi
arg3: rdx
arg4: r10
arg5: r8
arg6: r9

instruction: syscall

Например:

mov rax, SYS_kill
mov rdi, rbx
mov rsi, SIGTERM
syscall

На AArch64 уже другие регистры:

syscall number: x8

arg1: x0
arg2: x1
arg3: x2
arg4: x3
arg5: x4
arg6: x5

instruction: svc #0

То же действие выглядит примерно так:

mov x8, #SYS_kill
mov x0, x19
mov x1, #SIGTERM
svc #0

Когда я начинал ARM64-версию, ожидал, что две реализации довольно быстро разойдутся. На практике алгоритм остался практически тем же. Основные различия оказались как раз там, где и ожидаешь: syscall numbers, register ABI и несколько architecture-specific деталей. В репозитории это разделено достаточно явно:

src/
├── amd64/
└── arm64/

include/
├── macros.inc
├── macros_arm64.inc
├── syscalls_amd64.inc
└── syscalls_aarch64.inc

Самая сложная часть проекта оказалась вообще не связана с тем, как написать syscall или svc #0. Гораздо больше времени ушло на поведение процессов.

Почему сигналы идут в process group

Представим чуть более реальный entrypoint:

mini-init      PID 1
└── shell
    ├── worker A
    └── worker B

Получаем SIGTERM. Можно сделать:

kill(child_pid, SIGTERM);

Но тогда мы передали сигнал конкретному child. Что произойдёт с worker A и worker B, зависит уже от приложения. Мне хотелось, чтобы поведение init было более явным. Поэтому target в mini-init-asm запускается в новой session и получает собственную process group:

mini-init              PID 1

application            PGID 42
├── worker A
└── worker B

А forwarding делается через:

kill(-pgid, signal);

Отрицательный PID здесь означает process group. То есть при SIGTERM схема получается такой:

SIGTERM
   │
   ▼
mini-init
   │
   └── kill(-pgid, SIGTERM)
                │
                ├── application
                ├── worker A
                └── worker B

Это и есть одна из основных особенностей проекта: group forwarding не включается дополнительной опцией, а является стандартным режимом. Конечно, process group не даёт абсолютного контроля над любым возможным деревом процессов. Приложение может создать новую session или намеренно изменить свою process topology. Но для обычного container entrypoint PGID-first модель оказалась достаточно простой и предсказуемой.

Вместо signal handlers я использовал signalfd

Следующий вопрос: как построить main loop.

Можно было поставить обычные signal handlers, менять из них состояние и отдельно заниматься ожиданием child и таймерами. Но Linux даёт signalfd(2), и для такого небольшого init он оказался очень удобным. Нужные сигналы блокируются через signal mask, после чего они читаются из file descriptor. А раз это fd, его можно положить в epoll.

В итоге вместо нескольких разных механизмов получается один event loop:

                ┌──────────┐
signals ───────►│ signalfd │
                └────┬─────┘
                     │
                     ▼
                ┌─────────┐
                │  epoll  │
                └────┬────┘
                     │
              ┌──────┴──────┐
              │             │
           SIGCHLD       TERM/INT/...
              │             │
              ▼             ▼
            wait4       kill(-pgid)

Туда же добавляется timerfd, который отвечает за graceful shutdown timeout. Получается довольно компактная state machine. В упрощённом pseudo-C она выглядит примерно так:

for (;;) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);

    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == signal_fd) {
            struct signalfd_siginfo si;

            read(signal_fd, &si, sizeof(si));

            if (is_shutdown_signal(si.ssi_signo)) {
                kill(-child_pgid, si.ssi_signo);
                arm_grace_timer();
            } else if (si.ssi_signo == SIGCHLD) {
                reap_children();
            } else {
                kill(-child_pgid, si.ssi_signo);
            }
        }

        if (events[i].data.fd == timer_fd) {
            kill(-child_pgid, SIGKILL);
        }
    }
}

Реальный код, конечно, более неприятный: нужно следить за состоянием main child, повторными сигналами, результатами read, ошибками syscall и race conditions вокруг завершения процесса. Но основная логика действительно близка к этому примеру. И мне понравилось, что благодаря signalfd signal handling даже в assembly не превращается в набор прыжков из асинхронных handlers.

Что происходит при docker stop

В стандартном режиме первый soft shutdown signal - например SIGTERM - пересылается всей process group. После этого запускается grace timer. По умолчанию сейчас это 10 секунд:

EP_GRACE_SECONDS=10

Если приложение завершилось за это время, init забирает его status и выходит. Если нет:

SIGTERM
   │
   ▼
kill(-pgid, SIGTERM)
   │
   ▼
grace period
   │
   ├── application exited
   │         │
   │         └──► return child status
   │
   └── still alive
             │
             └──► kill(-pgid, SIGKILL)

Здесь довольно быстро всплывают случаи, о которых в happy path не думаешь. Например: child уже завершился, но событие timerfd тоже находится в очереди. Или SIGCHLD и shutdown signal пришли почти одновременно. Или закончился main process, а в группе ещё остались процессы. Именно на таких местах проект перестал быть для меня упражнением по assembly и стал скорее упражнением по Linux process semantics.

Zombie reaping

Получив SIGCHLD, нельзя просто один раз вызвать wait4 и считать работу законченной. Между двумя проходами event loop могли завершиться несколько процессов. Поэтому логика выглядит примерно так:

while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
    ...
}

В реализации без libc вместо waitpid() используется wait4(2) напрямую. Main child отслеживается отдельно. Если завершился другой процесс, его нужно просто очистить. Если завершился target, его status становится status самого init. Для обычного завершения возвращается exit code приложения. Если child был убит сигналом, по умолчанию используется привычная shell-схема:

128 + signal_number

Например:

SIGTERM → 143
SIGKILL → 137

Base можно изменить через EP_EXIT_CODE_BASE, хотя это скорее специальная возможность, чем настройка, которую стоит трогать каждый день. Есть и subreaper mode:

EP_SUBREAPER=1

Он включает PR_SET_CHILD_SUBREAPER. Это полезно, если под target появляется более сложное дерево и orphaned descendants нужно переподчинять init для последующего reaping.

Немного supervisor: restart-on-crash

В какой-то момент я добавил ещё одну возможность, которая уже выходит за минимальный набор обязанностей PID 1. Простой restart:

EP_RESTART_ENABLED=1

Например:

EP_RESTART_ENABLED=1 
EP_MAX_RESTARTS=3 
EP_RESTART_BACKOFF_SECONDS=5 
mini-init-asm -- ./my-app

Здесь мне было важно не начать незаметно писать systemd в assembly. Поэтому механизм специально остаётся примитивным. Есть restart, число попыток и backoff. Нет dependencies, health checks, нескольких сервисов, сложных policies и прочих функций настоящего supervisor.

Отдельно shutdown имеет приоритет над restart. Если контейнер уже получил termination signal и начал останавливаться, child не должен внезапно появиться снова. И ещё довольно очевидная, но неприятная конфигурация:

EP_MAX_RESTARTS=0
EP_RESTART_BACKOFF_SECONDS=0

Если приложение падает сразу после запуска, получаем tight loop. Поэтому backoff в нормальной конфигурации лучше не отключать.

ARM64: сборка оказалась проще тестирования

ARM64 хотелось поддерживать с самого начала. Cross-build выглядит обычно:

make build-arm64

А на x86-хосте бинарник можно запустить через:

qemu-aarch64-static -- ./build/mini-init-arm64 -- /bin/sh

И вот здесь была одна из наиболее интересных проблем проекта.

QEMU user-mode на некоторых хостах оказался не очень хорошей средой для полноценного тестирования комбинации:

epoll + signalfd + timerfd

Простой smoke test проходит, бинарник запускается, child завершается.

Но когда начинаешь проверять timing, signals и event loop, результаты становятся менее предсказуемыми. Поэтому сейчас ARM64 testing разделён. QEMU используется для smoke-проверок. Для него есть EP_ARM64_FALLBACK=1 - намеренно урезанный CI mode, который проверяет в основном spawn и exit propagation и не должен использоваться как production mode. А полноценные тесты запускаются также нативно на ARM64 GitHub runners. Для меня из этого получился довольно полезный вывод: "бинарник работает под QEMU" и "мы проверили process semantics на этой архитектуре" - далеко не одно и то же.

Сравнение с Tini

Наверное, самое простое здесь - не изображать benchmark, которого на самом деле не нужно.

И Tini, и mini-init-asm решают базовые задачи container init: запускают child, занимаются reaping и signal forwarding. Но дальше цели расходятся.

У mini-init-asm process-group forwarding является стандартной моделью. Он использует direct Linux syscalls и не зависит от libc. Есть небольшой restart mode.

Tini намного старше, поддерживает больше архитектур, широко используется и давно прошёл проверку реальными workloads. Поэтому мой ответ на вопрос "что ставить в production?" от появления mini-init-asm не изменился:

если специальных требований нет - Tini или Docker --init остаются более консервативным выбором.

А mini-init-asm интереснее в ситуациях, где нужен именно PGID-first подход, небольшой libc-free static binary или просто хочется увидеть реализацию container init практически syscall за syscall.

Как попробовать

Из исходников:

git clone https://github.com/roots666/mini-init-asm.git
cd mini-init-asm
make

Запуск:

./build/mini-init-amd64 -- /bin/sh -c 'echo hello; sleep 5'

Можно посмотреть поведение при остановке:

./build/mini-init-amd64 -- 
  bash -c 'trap "echo got TERM; exit 0" TERM; sleep 1000'

И отправить сигнал из другого terminal.

Для Docker в репозитории есть готовые Dockerfiles, включая multi-arch вариант для amd64 и arm64.

И неожиданно - Debian

Когда я опубликовал первую англоязычную статью о проекте это всё ещё был в основном эксперимент. Я не ожидал, что следующей отдельной задачей станет Debian packaging. В итоге пакет прошёл через Debian unstable и позже мигрировал в testing.

На момент написания статьи mini-init-asm доступен в:

Debian testing (forky)
Debian unstable (sid)

Пакет собирается для amd64 и arm64. На testing или sid теперь достаточно:

sudo apt update
sudo apt install mini-init-asm

Проверить, что именно видит APT:

apt-cache policy mini-init-asm

или посмотреть пакет в Debian archive через rmadison:

rmadison mini-init-asm

Здесь важно уточнить: в Debian stable пакета пока нет.

Я бы не подключал testing к stable-системе только ради этой утилиты. Для эксперимента на stable проще взять upstream binary, собрать исходники или использовать отдельный контейнер/VM.

Сам процесс попадания в Debian оказался полезен ещё по одной причине: packaging и review заставляют посмотреть на проект немного иначе, чем когда единственным потребителем остаётся собственный GitHub CI.

Что оказалось сложнее всего

Если бы меня до начала проекта спросили, где будет основная сложность, я бы ответил: ARM64 assembly. Оказалось - нет. Написать syscall для второй архитектуры относительно просто.

Гораздо больше вопросов появляется вокруг состояний процессов:

  • что делать, если SIGCHLD и SIGTERM пришли почти одновременно;

  • когда именно считать grace period начавшимся;

  • что делать с descendants после завершения main child;

  • как не запустить restart во время shutdown;

  • когда можно считать process group завершённой;

  • как отличить проблему своей реализации от особенности QEMU;

  • как корректно вернуть status, который пришёл из wait4.

И чем больше таких случаев появлялось в тестах, тем меньше проект был про "посмотрите, PID 1 на assembly" и тем больше - про то, как на самом деле работает Linux process model. Наверное, это и осталось для меня главным результатом.

syscall на x86-64 и svc #0 на ARM64 - сравнительно простая часть. А вокруг них находятся process groups, sessions, signals, zombies, reparenting, epoll, signalfd, timers и десятки небольших race conditions. Получился удобный размер проекта: исходники ещё реально прочитать целиком, но под ними скрывается достаточно Linux internals, чтобы постоянно находить что-то новое.

Ссылки

Автор: mrpickles666

Источник

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


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