Операционные системы Системы инициализации: SysV, systemd, runit, OpenRC, launchd, rc.d в BSD
0%

Системы инициализации: SysV, systemd, runit, OpenRC, launchd, rc.d в BSD

Системы инициализации: SysV, systemd, runit, OpenRC, launchd, rc.d в BSD

Ядро загрузилось, смонтировало корневую файловую систему, инициализировало драйверы — и оказалось в тупике. У него есть планировщик, но нечего планировать; есть менеджер памяти, но некому выделять память. Ядро — это библиотека механизмов, а не программа. Ему нужен кто-то, кто начнёт этими механизмами пользоваться.

Последнее, что делает ядро на пути загрузки — запускает ровно один процесс в user space и присваивает ему PID 1. С этого момента вся политика — что запускать, в каком порядке, что делать при падении, как выключаться — принадлежит user space. Граница между «загрузкой ядра» и «инициализацией системы» проходит ровно здесь: подробности того, что было до PID 1, — в статье Загрузка системы.

Прикладному программисту эта тема кажется епархией админов ровно до того момента, когда его сервис начинает падать в проде. И тогда выясняется, что половина инцидентов — это не баги в бизнес-логике, а неверные ответы на вопросы: когда мой процесс считается «готовым»? что с ним происходит при deploy? кто убьёт его дочерние процессы? почему он рестартует в цикле? почему в контейнере Ctrl+C не работает? Все эти ответы даёт система инициализации.

Что вообще обязана делать PID 1

PID 1 — не обычный процесс. Ядро относится к нему особым образом, и из этого растут три жёсткие обязанности.

1. Он не может умереть. Если PID 1 завершается, ядро Linux паникует:

Kernel panic - not syncing: Attempted to kill init! exitcode=0x00000100

В PID-namespace контейнера последствия мягче, но столь же фатальны для приложения: смерть PID 1 приводит к рассылке SIGKILL всем остальным процессам namespace и его уничтожению.

2. Он усыновляет сирот. Когда процесс завершается, а его дети живы, ядро переназначает им родителя. Исторически им становился PID 1; с Linux 3.4 есть prctl(PR_SET_CHILD_SUBREAPER, 1), позволяющий любому процессу объявить себя «локальным init» и собирать сирот в своём поддереве — именно этим пользуется systemd --user и менеджеры контейнеров.

3. Он обязан жать зомби. Завершившийся процесс остаётся в таблице процессов как запись EXIT_ZOMBIE, пока родитель не заберёт его код возврата через wait(). Если PID 1 не вызывает wait(), усыновлённые сироты накапливаются вечно и выедают kernel.pid_max.

Плюс к этому у PID 1 нет действий по умолчанию для сигналов: ядро не убьёт его по SIGTERM, если он сам не установил обработчик. Это защита от случайного kill -9 1, но и источник популярнейшего бага в контейнерах — об этом ниже.

Минимальный корректный init на C — это буквально тридцать строк:

#define _GNU_SOURCE
#include <sys/wait.h>
#include <signal.h>
#include <unistd.h>

static volatile sig_atomic_t want_stop = 0;
static void on_term(int sig) { (void)sig; want_stop = 1; }

int main(void) {
    /* Без явного обработчика SIGTERM PID 1 его просто проигнорирует. */
    struct sigaction sa = { .sa_handler = on_term };
    sigaction(SIGTERM, &sa, NULL);
    sigaction(SIGINT,  &sa, NULL);

    pid_t child = fork();
    if (child == 0) {
        execl("/usr/local/bin/app", "app", (char *)NULL);
        _exit(127);
    }

    for (;;) {
        int status;
        /* waitpid(-1) забирает ЛЮБОГО ребёнка, включая усыновлённых сирот. */
        pid_t p = waitpid(-1, &status, 0);
        if (p == child && WIFEXITED(status))
            return WEXITSTATUS(status);      /* основной процесс ушёл — уходим и мы */
        if (p < 0 && want_stop)
            break;
        if (want_stop) kill(child, SIGTERM); /* транслируем сигнал вниз */
    }
    return 0;
}

Всё остальное — SysV, systemd, launchd — это надстройки над этими тремя обязанностями: что запускать, в каком порядке, что делать при падении и как об этом рассказать.

Эволюция: почему у нас столько разных init

Главный сюжет здесь один: init медленно эволюционировал от «запускателя скриптов» к менеджеру состояния сервисов. Разница фундаментальна. Запускатель скриптов выполняет последовательность команд один раз и забывает о них. Менеджер состояния помнит, что должно быть запущено, следит за живостью, владеет ресурсами процессов и умеет привести систему к декларативно описанному целевому состоянию — ровно так же, как Kubernetes работает с подами.

SysV init: как это было и почему сломалось

Классический System V init читал /etc/inittab:

# id:runlevels:action:process
id:3:initdefault:
si::sysinit:/etc/rc.d/rc.sysinit
l3:3:wait:/etc/rc.d/rc 3
1:2345:respawn:/sbin/mingetty tty1
ca::ctrlaltdel:/sbin/shutdown -t3 -r now

Идея runlevels — набор именованных состояний системы: 0 — выключение, 1 — single user, 3 — многопользовательский с сетью, 5 — то же плюс графика, 6 — перезагрузка. Смена уровня (telinit 5) означала выполнение скриптов из соответствующего каталога:

$ ls /etc/rc.d/rc3.d/
K10postfix  K30sendmail  S10network  S12syslog  S55sshd  S80httpd  S99local

Символические ссылки на скрипты в /etc/init.d/. Префикс S/K — start/kill, число — порядок. rc вызывал их подряд: S10network start, S12syslog start и так далее. Всё.

Скрипт был обычным shell:

#!/bin/bash
### BEGIN INIT INFO
# Provides:          myapp
# Required-Start:    $network $remote_fs postgresql
# Required-Stop:     $network
# Default-Start:     2 3 4 5
# Short-Description: My application
### END INIT INFO

. /lib/lsb/init-functions
PIDFILE=/var/run/myapp.pid

case "$1" in
  start)
    start-stop-daemon --start --background --make-pidfile --pidfile $PIDFILE \
        --chuid myapp --exec /usr/local/bin/myapp -- --config /etc/myapp.conf
    ;;
  stop)
    start-stop-daemon --stop --pidfile $PIDFILE --retry TERM/10/KILL/5
    rm -f $PIDFILE
    ;;
  status)
    status_of_proc -p $PIDFILE /usr/local/bin/myapp myapp
    ;;
  restart) $0 stop; sleep 1; $0 start ;;
  *) echo "Usage: $0 {start|stop|status|restart}"; exit 1 ;;
esac

Блок INIT INFO — это LSB-заголовки (Linux Standard Base), поздняя попытка задекларировать зависимости, чтобы insserv мог сам вычислить номера SNN. Компромисс между декларативностью и шеллом, который так и остался полумерой.

Что здесь принципиально плохо

Определение «сервис работает» опирается на PID-файл, и это ложь. Демон форкается, пишет PID в файл, файл переживает падение процесса. PID переиспользуется ядром. Классическая гонка: демон упал, ядро выдало его PID другому процессу, stop убивает невиновного. Никакой атомарности — между fork() и записью PID-файла зияет окно.

Отслеживание дочерних процессов невозможно. Сервис нафоркал воркеров, главный процесс упал — воркеры остались сиротами, усыновлёнными PID 1. stop их не найдёт: PID-файл знает один PID. Именно эту дыру закрывают cgroups.

Порядок последовательный и жёстко зашитый в числа. S55sshd стартует после S12syslog, даже если между ними нет реальной связи. Загрузка на многоядерной машине шла со скоростью самого медленного скрипта — минуту-полторы на типичном сервере 2008 года.

Демонизация — обряд из десяти шагов, который каждый демон реализует по-своему и с ошибками. Каноническая последовательность из «Advanced Programming in the UNIX Environment» Стивенса:

/* Классический двойной форк — то, что systemd делает НЕНУЖНЫМ */
void daemonize(void) {
    if (fork() != 0) _exit(0);        /* 1. родитель уходит, мы не лидер группы */
    setsid();                          /* 2. новая сессия, отвязка от терминала */
    signal(SIGHUP, SIG_IGN);
    if (fork() != 0) _exit(0);        /* 3. второй форк: не лидер сессии,
                                            больше никогда не получим терминал */
    chdir("/");                        /* 4. не держим смонтированную ФС */
    umask(0);                          /* 5. предсказуемые права */
    close(0); close(1); close(2);      /* 6. отвязка от stdio */
    open("/dev/null", O_RDWR);         /* 7. fd 0,1,2 → /dev/null */
    dup(0); dup(0);
    write_pidfile();                   /* 8. и вот тут гонка */
}

Под systemd, runit и launchd ничего из этого писать не надо. Сервис остаётся в foreground, пишет логи в stdout, а надзиратель отвечает за отвязку, сессию и права. Если вы в 2026 году пишете daemonize() в новом сервисе — почти наверняка вы делаете лишнюю и вредную работу.

rc.d в BSD: shell, но с топологической сортировкой

BSD пошли другим путём. Вместо runlevels — единственный /etc/rc, читающий конфигурацию из /etc/rc.conf, и каталог скриптов, между которыми зависимости объявлены прямо в комментариях. Механизм придумали в NetBSD (утилита rcorder(8), 1998), FreeBSD взял его в 5.0.

#!/bin/sh
# FreeBSD: /usr/local/etc/rc.d/myapp

# PROVIDE: myapp
# REQUIRE: LOGIN postgresql
# BEFORE:  securelevel
# KEYWORD: shutdown

. /etc/rc.subr

name="myapp"
rcvar="myapp_enable"
command="/usr/local/bin/myapp"
command_args="--config /usr/local/etc/myapp.conf"
pidfile="/var/run/${name}.pid"
start_precmd="myapp_prestart"

myapp_prestart() {
    install -d -o myapp -g myapp /var/db/myapp
}

load_rc_config $name
: ${myapp_enable:="NO"}
run_rc_command "$1"

rcorder читает PROVIDE/REQUIRE/BEFORE во всех скриптах, строит DAG и выдаёт корректный порядок — то есть делает ровно то, чего SysV достиг только через LSB-костыли:

$ rcorder /etc/rc.d/* /usr/local/etc/rc.d/* | head
/etc/rc.d/growfs
/etc/rc.d/dumpon
/etc/rc.d/ddb
/etc/rc.d/zfs
/etc/rc.d/hostid
...

Управление — через service(8), конфигурация — через sysrc(8), который правит /etc/rc.conf без ручного редактирования:

$ sudo sysrc myapp_enable=YES
myapp_enable:  -> YES
$ sudo service myapp start
Starting myapp.
$ service -e | tail -3        # список включённых сервисов
/usr/local/etc/rc.d/nginx
/usr/local/etc/rc.d/postgresql
/usr/local/etc/rc.d/myapp

Различия внутри семейства BSD

FreeBSD NetBSD OpenBSD DragonFly
Механизм порядка rcorder(8) rcorder(8) (родина) без rcorder, порядок в rc.d вручную rcorder(8)
Управление service(8), sysrc(8) service(8) rcctl(8) service(8)
Конфиг /etc/rc.conf, rc.conf.d /etc/rc.conf /etc/rc.conf.local /etc/rc.conf
Надзор за процессом нет (есть daemon(8) с -r) нет rc_bg, без рестарта нет
Jail/контейнеры jail.conf, service jail

OpenBSD принципиально проще: rcctl — единственный интерфейс, зависимости решаются порядком переменной pkg_scripts в /etc/rc.conf.local. Это осознанный выбор в пользу аудируемости: меньше механизма — меньше поверхности для ошибок. Подробнее о философских различиях — в статье Семейство BSD.

Что важно понять: rc.d не следит за сервисами. Упал процесс — никто не заметит. На FreeBSD это лечат daemon(8) -r (перезапуск), -P (supervisor pidfile) или внешним надзирателем вроде supervisord/runit. Это осознанный trade-off: rc.d — исключительно про порядок старта.

systemd: init как менеджер состояния

systemd (2010, Леннарт Поттеринг и Кай Сиверс) переопределил задачу. Не «выполнить скрипты», а «привести систему в описанное состояние и поддерживать его». Три технических решения сделали это возможным.

Первое: cgroups как границы сервиса. Каждый юнит запускается в собственной control group. Всё, что процесс нафоркает — сколько угодно вглубь, с какими угодно setsid() — остаётся в этой cgroup, потому что cgroup наследуется и её нельзя покинуть без привилегий. Значит, systemd достоверно знает состав сервиса и может убить его целиком:

$ systemctl status nginx
● nginx.service - A high performance web server
     Loaded: loaded (/lib/systemd/system/nginx.service; enabled)
     Active: active (running) since Thu 2026-07-16 09:12:04 MSK; 2h 3min ago
   Main PID: 1284 (nginx)
      Tasks: 9 (limit: 4915)
     Memory: 12.4M (peak: 18.1M)
        CPU: 3.412s
     CGroup: /system.slice/nginx.service
             ├─1284 "nginx: master process /usr/sbin/nginx"
             ├─1285 "nginx: worker process"
             └─1286 "nginx: worker process"

$ cat /sys/fs/cgroup/system.slice/nginx.service/cgroup.procs
1284
1285
1286

Это качественный скачок относительно PID-файла. Больше нет вопроса «а точно ли эти процессы — мой сервис». Подробнее про механику cgroup v2 — в Безопасность и изоляция.

Второе: декларативные зависимости вместо номеров. Отношения задаются явно, а порядок вычисляется. Юнит-файл — простой ini:

# /etc/systemd/system/myapp.service
[Unit]
Description=My application
Documentation=https://example.com/docs
Requires=postgresql.service
After=postgresql.service network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Type=notify
NotifyAccess=main
ExecStart=/usr/local/bin/myapp --config /etc/myapp.conf
ExecReload=/bin/kill -HUP $MAINPID
User=myapp
Group=myapp
Restart=on-failure
RestartSec=2s
TimeoutStopSec=30s
WatchdogSec=30s

# Логи — просто в stdout/stderr, journald их подхватит
StandardOutput=journal
StandardError=journal

# Sandbox: половина эксплойтов умирает вот на этих строчках
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/myapp
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
MemoryMax=2G
CPUQuota=200%
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

Третье: сокет-активация вместо ожидания. Об этом отдельно ниже — это самая недооценённая часть systemd.

Requires vs After: главный источник багов

Эти две директивы независимы и означают совершенно разные вещи. Requires= — «если я запускаюсь, то и он должен». After= — «я не начинаю раньше, чем он закончил старт». Ни одна не подразумевает другую.

Requires и After — две ортогональные оси в systemd

На практике набор правил такой:

  • Requires= + After= — жёсткая зависимость (БД, без которой сервис бессмыслен). Падение зависимости остановит вас.
  • Wants= + After= — мягкая зависимость. Самый частый правильный выбор: если она недоступна, сервис всё равно поднимется. Ставится и через симлинк в myapp.service.wants/.
  • BindsTo= — как Requires=, но ещё жёстче: остановка (даже штатная) зависимости немедленно остановит вас. Для устройств (.device-юнитов) и монтирований.
  • PartOf= — стоп/рестарт родителя каскадится вниз, но не наоборот. Для групп воркеров.
  • Conflicts= — не могут работать одновременно (getty@tty1 и графическая сессия).

Проверять всегда на живой системе, а не по памяти:

$ systemctl show -p Requires -p Wants -p After -p Before myapp.service
Requires=postgresql.service sysinit.target system.slice
Wants=network-online.target
After=postgresql.service network-online.target systemd-journald.socket basic.target
Before=multi-user.target shutdown.target

$ systemctl list-dependencies myapp.service --after   # кто стартует до меня
$ systemd-analyze verify /etc/systemd/system/myapp.service   # линтер юнита

Типы юнитов

Расширение Что описывает Пример
.service долгоживущий или одноразовый процесс sshd.service
.socket слушающий сокет, активирующий сервис docker.socket
.target группа юнитов, точка синхронизации (аналог runlevel) multi-user.target
.timer запуск по расписанию (замена cron) logrotate.timer
.mount / .automount точка монтирования (генерируется из /etc/fstab) var-lib.mount
.path реакция на появление/изменение файла (inotify) cups.path
.device устройство из udev dev-sda1.device
.slice узел иерархии cgroup для распределения ресурсов user-1000.slice
.scope группа процессов, запущенных не systemd (сессии, контейнеры) session-3.scope

Порядок поиска юнитов (по возрастанию приоритета): /usr/lib/systemd/system (пакеты) → /run/systemd/system (рантайм) → /etc/systemd/system (администратор). Никогда не правьте файлы пакета — используйте drop-in:

$ sudo systemctl edit myapp.service     # создаст /etc/systemd/system/myapp.service.d/override.conf
$ systemctl cat myapp.service           # покажет итоговый файл со всеми drop-in

Тип запуска и что такое «сервис готов»

Type= определяет, в какой момент systemd считает старт завершённым — а значит, когда запускать всех, кто стоит After= вас. Это ровно тот вопрос, который SysV не умел задать.

Type= Готов, когда Когда использовать
simple сразу после execve() по умолчанию; не означает готовности принимать запросы
exec после успешного execve() (бинарь найден, права ок) чуть честнее simple
forking родитель завершился (совместимость с демонами) легаси; требует PIDFile=
oneshot процесс завершился миграции, подготовка, RemainAfterExit=yes
notify процесс прислал READY=1 через sd_notify() правильный выбор для сервисов
dbus сервис занял имя на шине демоны D-Bus
idle как simple, но с задержкой до 5 с косметика для консольного вывода

Type=notify — единственный способ сказать «я действительно готов»:

#include <systemd/sd-daemon.h>   /* -lsystemd */

int main(void) {
    listen_and_bind();
    run_migrations();
    warm_up_caches();

    sd_notify(0, "READY=1\nSTATUS=Принимаю запросы");

    for (;;) {
        serve_one();
        /* Если в юните задан WatchdogSec — обязаны стучать чаще, чем половина интервала,
           иначе systemd посчитает нас зависшими и применит WatchdogSignal (по умолчанию
           SIGABRT с корным дампом). */
        sd_notify(0, "WATCHDOG=1");
    }
}

Протокол sd_notify — это одна строка в AF_UNIX-датаграмме на сокет из $NOTIFY_SOCKET. Реализуется на любом языке в десять строк, зависимость от libsystemd не нужна:

// Go: ровно тот же протокол без cgo и без библиотек
func notifyReady() error {
    addr := os.Getenv("NOTIFY_SOCKET")
    if addr == "" {
        return nil // запущены не под systemd — просто ничего не делаем
    }
    if addr[0] == '@' {
        addr = "\x00" + addr[1:] // абстрактный сокет Linux
    }
    conn, err := net.Dial("unixgram", addr)
    if err != nil {
        return err
    }
    defer conn.Close()
    _, err = conn.Write([]byte("READY=1"))
    return err
}

Про сокеты и AF_UNIX подробнее — в Системные вызовы и IPC.

Жизненный цикл юнита

Крайне частая практическая ловушка — Restart=always в паре с RestartSec=100ms и дефолтным лимитом «5 стартов за 10 секунд». Сервис, падающий на старте (например, из-за неверного конфига), исчерпывает лимит за секунду и остаётся в failed навсегда — даже после того, как конфиг починили. Лечение — разумный RestartSec=2s и явные StartLimitIntervalSec/Burst.

Сокет-активация: то, ради чего многое затевалось

Идея украдена у inetd и launchd: сокет открывает init, а не сервис. Клиент подключается, ядро складывает соединение в очередь listen(), systemd запускает сервис и передаёт ему готовый файловый дескриптор. Соединение при этом не теряется — оно просто ждёт в бэклоге.

Практические следствия, за которые это стоит любить:

  1. Zero-downtime рестарт без reverse-proxy. systemctl restart myapp не роняет соединения: пока сервис перезапускается, ядро копит SYN в очереди.
  2. Параллельная загрузка без графа зависимостей. Не нужно ждать, пока БД «полностью поднимется»: клиент коннектится к сокету, который открыт с самого начала.
  3. Ленивый запуск. Сервис не занимает память, пока к нему никто не пришёл.
  4. Привилегированный порт без root. systemd биндит :443, сервис работает от nobody.
# myapp.socket
[Unit]
Description=Socket for myapp

[Socket]
ListenStream=0.0.0.0:8080
Accept=no          # один процесс на все соединения (accept=yes = процесс на соединение, inetd-стиль)
Backlog=1024

[Install]
WantedBy=sockets.target

Со стороны приложения переданный дескриптор — это всегда fd = 3 (SD_LISTEN_FDS_START):

// Go: поднять net.Listener из переданного systemd дескриптора
func listenerFromSystemd() (net.Listener, error) {
    if os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) {
        return net.Listen("tcp", ":8080") // fallback: запущены вручную
    }
    n, _ := strconv.Atoi(os.Getenv("LISTEN_FDS"))
    if n < 1 {
        return net.Listen("tcp", ":8080")
    }
    f := os.NewFile(3, "systemd-socket") // SD_LISTEN_FDS_START == 3
    return net.FileListener(f)
}

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

Таргеты вместо runlevels

.target — юнит без собственного действия, чистая точка синхронизации. multi-user.target примерно соответствует runlevel 3, graphical.target — runlevel 5, и symlink-совместимость сохранена (runlevel3.target -> multi-user.target).

Про network-online.target стоит сказать отдельно, потому что это заблуждение номер один: он не означает, что сеть работает. Он означает, что менеджер сети (NetworkManager, systemd-networkd) отрапортовал о завершении конфигурации. DHCP мог не выдать адрес, DNS может не резолвить, маршрут до нужного хоста может отсутствовать. Правильно писать код, который переживает недоступность сети и ретраится, а не полагаться на таргет.

Критика systemd: где она по делу

systemd — самый спорный проект в истории Linux, и часть претензий содержательна.

По делу:

  • Объём и связность. Больше 70 бинарей: systemd-resolved, systemd-timesyncd, systemd-homed, systemd-boot, journald. Формально они опциональны, практически — дистрибутивы включают их пакетом, и границы размываются.
  • Бинарный журнал. journald пишет индексированный бинарный формат. При повреждении (обрыв питания) восстановление сложнее, чем grep по текстовому syslog. Митигируется ForwardToSyslog=yes и journalctl --verify.
  • Непортируемость. systemd намертво привязан к Linux-специфике: cgroups, epoll, signalfd, timerfd, inotify, namespaces. Это осознанное решение, но оно означает, что экосистема ПО, завязанного на systemd, не работает на BSD и illumos.
  • История с багами и тоном общения. CVE в systemd-resolved, парсинг /etc/passwd и прочее — на своё время реальная проблема доверия.

Не по делу:

  • «Нарушает философию Unix — делает всё в PID 1». Не делает: PID 1 — это ~2 МБ RSS, остальное — отдельные процессы с отдельными привилегиями, а «одна программа делает одно дело» — про композицию, а не про размер репозитория.
  • «Бинарные конфиги». Юнит-файлы — обычный ini, читаются в блокноте.
  • «Медленнее SysV». Измеримо быстрее: параллельный старт + сокет-активация.

Итог трезвый: systemd решил реальные проблемы (надзор, надёжный останов, границы ресурсов, sandboxing, единый декларативный формат) ценой сложности и монокультуры. На сервере под нагрузкой альтернативы обычно проигрывают по функциональности; на встроенных системах, в контейнерах и там, где ценится аудируемость, — выигрывают.

runit и s6: supervision tree в 300 строках

Ветка от daemontools Дэна Бернстайна. Одна идея: процесс работает в foreground, надзиратель его перезапускает. Никакого PID-файла, никакой демонизации, никакого IPC — только fork, exec, wait и файловая система как API.

Сервис в runit — это каталог с исполняемым файлом run:

# /etc/sv/myapp/run
#!/bin/sh
exec 2>&1                             # stderr в stdout, дальше заберёт логгер
exec chpst -u myapp:myapp \
     -o 65536 \                        # rlimit на открытые файлы
     /usr/local/bin/myapp --config /etc/myapp.conf --foreground
# /etc/sv/myapp/log/run — логгер это ОТДЕЛЬНЫЙ сервис, соединённый пайпом
#!/bin/sh
exec chpst -u log svlogd -tt /var/log/myapp

exec в конце обязателен: он замещает shell процессом сервиса, чтобы надзиратель следил за приложением, а не за оболочкой. Ровно та же ошибка, что CMD npm start в Docker.

$ ln -s /etc/sv/myapp /var/service/     # включение = симлинк, runsvdir подхватит за 5 с
$ sv status myapp
run: myapp: (pid 4412) 128s; run: log: (pid 4413) 128s
$ sv hup myapp        # послать SIGHUP
$ sv down myapp       # остановить (создаёт ./down — не стартует при загрузке)
$ sv -w 30 restart myapp

Структура runit: PID 1 — крошечный runit, который запускает три этапа (/etc/runit/1 — однократная инициализация, /etc/runit/2runsvdir, /etc/runit/3 — выключение). runsvdir держит по одному runsv на каждый сервис, runsv форкает run и перезапускает при выходе. Дерево надзора буквально видно в ps:

$ pstree -p 1
runit(1)─┬─runsvdir(215)─┬─runsv(240)─┬─myapp(4412)
         │               │            └─svlogd(4413)
         │               └─runsv(241)───nginx(302)
         └─getty(210)

s6 (Laurent Bercot) — идейный наследник с более строгой моделью: s6-rc добавляет зависимости и «oneshot»-сервисы, s6-log — ротацию, execline — язык без форков вместо shell. Используется в Alpine-образах и Void Linux.

Trade-offs честные: нет декларативных зависимостей (порядок обеспечивается тем, что run сам блокируется, пока зависимость не готова), нет cgroups «из коробки», нет сокет-активации, нет таймеров. Взамен — весь init читается за вечер, RAM-след в сотни килобайт и полная предсказуемость. Для контейнеров, embedded и людей, ценящих понимание всей системы целиком, это часто лучший выбор.

OpenRC: зависимости поверх shell

Gentoo и Alpine используют OpenRC — попытку взять от SysV совместимость с shell, а от systemd декларативные зависимости, не уходя в Linux-специфику (OpenRC работает и на FreeBSD, и на NetBSD). Важная деталь: OpenRC не является PID 1 — он работает поверх sysvinit, busybox init или иного init.

#!/sbin/openrc-run
# /etc/init.d/myapp

name="My application"
command="/usr/local/bin/myapp"
command_args="--config /etc/myapp.conf"
command_user="myapp:myapp"
pidfile="/run/${RC_SVCNAME}.pid"
command_background="yes"          # openrc сам демонизирует foreground-процесс
supervisor="supervise-daemon"     # надзор с рестартом (OpenRC >= 0.21)
respawn_delay=5
respawn_max=0                      # 0 = без лимита

depend() {
    need net postgresql            # жёсткая зависимость
    use logger dns                 # мягкая
    after firewall                 # только порядок
    provide myapp-api
}

start_pre() {
    checkpath -d -o myapp:myapp -m 0750 /var/lib/myapp
}
$ rc-update add myapp default      # добавить в runlevel "default"
$ rc-service myapp start
$ rc-status                        # состояние всех сервисов текущего уровня
Runlevel: default
 sshd    [  started  ]
 nginx   [  started  ]
 myapp   [  started  ]
$ rc-update show

Ключевые слова need/use/after/before/provide — точный аналог Requires/Wants/After из systemd, просто в виде shell-функции. Это делает OpenRC хорошим срединным решением: гибче SysV, легче systemd, портируем на BSD. Но надзор скромнее (supervise-daemon не знает cgroups по умолчанию), нет сокет-активации, sandboxing ограничен тем, что даст start-stop-daemon.

launchd: macOS сделал это раньше

launchd появился в Mac OS X 10.4 (2005) — за пять лет до systemd — и уже тогда содержал ключевые идеи: единый демон, XML-описания вместо скриптов, on-demand активация по сокетам, надзор с рестартом. Systemd многое взял именно отсюда, и Поттеринг это признавал в оригинальном анонсе.

<!-- /Library/LaunchDaemons/com.example.myapp.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>            <string>com.example.myapp</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/myapp</string>
        <string>--config</string>
        <string>/usr/local/etc/myapp.conf</string>
    </array>
    <key>RunAtLoad</key>        <true/>
    <key>KeepAlive</key>
    <dict>
        <key>SuccessfulExit</key>  <false/>   <!-- рестарт только при ненулевом коде -->
        <key>NetworkState</key>    <true/>
    </dict>
    <key>UserName</key>         <string>_myapp</string>
    <key>StandardOutPath</key>  <string>/var/log/myapp.log</string>
    <key>StandardErrorPath</key><string>/var/log/myapp.err</string>
    <key>ThrottleInterval</key> <integer>10</integer>
    <key>Sockets</key>
    <dict>
        <key>Listener</key>
        <dict>
            <key>SockServiceName</key> <string>8080</string>
            <key>SockType</key>        <string>stream</string>
        </dict>
    </dict>
</dict>
</plist>

Каталоги задают область действия и владельца:

Путь Кто запускает Когда
~/Library/LaunchAgents пользователь при его логине
/Library/LaunchAgents все пользователи при логине любого
/Library/LaunchDaemons root, до логина при загрузке
/System/Library/Launch* система (SIP-защищено)

Современный CLI (старые launchctl load/unload объявлены устаревшими):

$ launchctl bootstrap system /Library/LaunchDaemons/com.example.myapp.plist
$ launchctl kickstart -k system/com.example.myapp     # рестарт
$ launchctl print system/com.example.myapp | head -20
$ launchctl blame system/com.example.myapp            # почему сервис был запущен
$ launchctl bootout system/com.example.myapp

Отличия от systemd, важные на практике: нет cgroups (границы сервиса определяются деревом процессов и launchd-джобами), нет сложного графа зависимостей — вместо него ленивая активация через сокеты и XPC (сервис поднимается тогда, когда к нему обратились, что устраняет большинство вопросов порядка). Есть штатные условия запуска (RunAtLoad, StartCalendarInterval, WatchPaths, QueueDirectories) — прямые аналоги .timer и .path. Подробнее про XNU — в статье Другие ОС.

Windows NT: Service Control Manager

В NT нет ни fork(), ни PID 1 в юниксовом смысле. Роль init распределена между smss.exe (session manager), wininit.exe и services.exe — Service Control Manager (SCM). Сервис — не просто процесс, а процесс, реализующий определённый контракт: он обязан при старте вызвать StartServiceCtrlDispatcher() и зарегистрировать обработчик управляющих кодов (SERVICE_CONTROL_STOP, _PAUSE, _SHUTDOWN), иначе SCM убьёт его по таймауту (30 секунд по умолчанию).

C:\> sc create MyApp binPath= "C:\apps\myapp.exe" start= auto depend= "MSSQLSERVER/Tcpip"
C:\> sc config MyApp obj= "NT SERVICE\MyApp" type= own
C:\> sc failure MyApp reset= 86400 actions= restart/5000/restart/10000/run/60000
C:\> sc qc MyApp
C:\> sc query MyApp

Конфигурация живёт в реестре: HKLM\SYSTEM\CurrentControlSet\Services\MyApp. Аналоги знакомых понятий: depend=After=+Requires=, sc failureRestart=+RestartSec=, obj=User=, группы ServiceGroupOrder ≈ таргеты, Job Objects ≈ cgroups (лимиты памяти и CPU на группу процессов). Принципиальное отличие: порядок задаётся группами загрузки и зависимостями, но параллелизм ограничен — SCM менее агрессивен в распараллеливании, чем systemd.

Init в контейнерах: где это ломается чаще всего

Контейнер — это PID-namespace, а значит внутри него есть свой PID 1 со всеми обязанностями из начала статьи. И почти всегда им становится ваше приложение, которое к этой роли не готово.

PID 1 внутри контейнера: сигналы и зомби

Самая частая ошибка — shell form в Dockerfile:

# ПЛОХО: разворачивается в /bin/sh -c "node server.js"
CMD node server.js

# ХОРОШО: exec form, node становится PID 1 напрямую
CMD ["node", "server.js"]

Проверить последствия можно за минуту:

$ docker run -d --name bad  myimage sh -c "node server.js"
$ time docker stop bad
bad
real    0m10.412s     # 10 секунд — это таймаут перед SIGKILL

$ docker run -d --name good --init myimage node server.js
$ time docker stop good
good
real    0m0.31s       # SIGTERM дошёл, graceful shutdown отработал

Правила, которые стоит запомнить:

  1. exec form (CMD ["a","b"]) всегда, кроме случаев, где вам явно нужна подстановка переменных shell. Если нужна — используйте exec внутри entrypoint-скрипта: exec node server.js "$@".
  2. --init или tini, если приложение форкает дочерние процессы (вызывает ffmpeg/imagemagick/git). Kubernetes-эквивалент — shareProcessNamespace: true или просто корректный PID 1 в образе.
  3. Обрабатывайте SIGTERM. Kubernetes шлёт SIGTERM, ждёт terminationGracePeriodSeconds (30 с по умолчанию), затем SIGKILL. Всё, что вы не успели сдренировать, потеряно.
// Минимальный корректный graceful shutdown под контейнер/systemd
func main() {
    srv := &http.Server{Addr: ":8080", Handler: mux}
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    stop := make(chan os.Signal, 1)
    signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) // SIGINT — для docker run -it
    <-stop
    log.Println("получен сигнал, дренируем соединения")

    // Запас должен быть МЕНЬШЕ terminationGracePeriodSeconds / TimeoutStopSec
    ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        log.Printf("принудительное завершение: %v", err)
    }
}

Про пространства имён и рантаймы — в Виртуализация и контейнеры.

Диагностика: чем реально пользуются

# Почему загрузка идёт 40 секунд
$ systemd-analyze
Startup finished in 3.918s (firmware) + 2.104s (loader) + 1.882s (kernel)
                     + 12.371s (userspace) = 20.276s
graphical.target reached after 12.352s in userspace

$ systemd-analyze blame | head -5
      8.412s NetworkManager-wait-online.service     # почти всегда главный виновник
      2.201s dev-sda2.device
      1.104s postgresql.service
       894ms docker.service
       310ms systemd-journal-flush.service

$ systemd-analyze critical-chain myapp.service      # ЦЕПОЧКА, а не просто долгие юниты
myapp.service +512ms
└─postgresql.service @6.104s +1.104s
  └─network-online.target @6.101s
    └─NetworkManager-wait-online.service @2.010s +4.091s

$ systemd-analyze plot > boot.svg                   # визуальная диаграмма загрузки
$ systemd-analyze security myapp.service            # оценка sandboxing 0..10

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

Работа с логами:

$ journalctl -u myapp.service -f                     # хвост в реальном времени
$ journalctl -u myapp -p err --since "1 hour ago"    # только ошибки за час
$ journalctl -u myapp -b -1                          # предыдущая загрузка
$ journalctl -u myapp -o json-pretty | head -30      # все поля, включая _PID, _CMDLINE
$ journalctl --disk-usage && journalctl --vacuum-size=200M
$ journalctl _SYSTEMD_UNIT=myapp.service _UID=1000   # фильтр по индексированным полям

Инспекция состояния:

$ systemctl list-units --state=failed
$ systemctl list-timers --all
$ systemctl show myapp -p ExecMainStartTimestamp -p NRestarts -p MemoryCurrent
$ systemd-cgls /system.slice/myapp.service          # дерево cgroup сервиса
$ systemd-cgtop                                      # top по cgroup, а не по процессам
$ systemd-run --scope -p MemoryMax=500M ./stress-test   # разовый запуск в cgroup

Последняя команда особенно полезна разработчику: systemd-run позволяет проверить, как ваш процесс ведёт себя при жёстком лимите памяти или CPU, не трогая юнит-файлы. Про метрики и профилирование — в Наблюдаемость и производительность.

Типичные заблуждения

«Requires= гарантирует порядок». Нет. Нужен After=. См. SVG выше — это причина номер один загадочных Connection refused на холодном старте, которые «не воспроизводятся» при ручном рестарте.

«Type=simple означает, что сервис готов». Нет, только что execve() состоялся. Если следом стартует зависимый сервис, он придёт в ещё не открытый порт. Правильно — Type=notify или сокет-активация.

«network-online.target = сеть работает». Нет, см. выше. Сеть — не бинарное состояние.

«Демон должен демонизироваться». Под любым современным init — наоборот: --foreground, логи в stdout, никакого двойного форка и PID-файлов. Type=forking — режим совместимости с легаси, а не рекомендация.

«Логи надо писать в файл и ротировать». Под systemd/runit/launchd/Docker пишите в stdout/stderr. Надзиратель заберёт и отротирует (journald, svlogd, StandardOutPath, драйвер логов Docker). Своя ротация из приложения — источник гонок и потерянных строк.

«systemd — это PID 1, и он огромный». PID 1 — небольшой процесс; всё остальное — отдельные демоны. Спор о размере проекта не равен спору о размере PID 1.

«systemctl restart перечитает изменённый юнит-файл». Нет. Нужен systemctl daemon-reload — systemd держит разобранные юниты в памяти. Забытый daemon-reload — второй по частоте источник «я же поправил, а не работает».

«kill -9 на сервисе — нормальный способ остановки». Он не даёт закрыть транзакции, сбросить буферы, снять регистрацию в service discovery. SIGKILL — это то, что делает init, когда вы не уложились в TimeoutStopSec.

Сводная таблица

SysV init BSD rc.d OpenRC runit / s6 systemd launchd Windows SCM
Формат shell shell shell + depend() каталог + run ini-юниты XML plist реестр
Зависимости номера SNN / LSB rcorder DAG need/use/after нет (блокировка в run) полный граф почти нет (ленивость) depend=, группы
Параллельный старт нет нет да да да да ограниченно
Надзор и рестарт нет нет (кроме daemon -r) supervise-daemon ядро идеи Restart= KeepAlive sc failure
Границы сервиса PID-файл PID-файл PID-файл / cgroup дерево процессов cgroup v2 job Job Object
Сокет-активация inetd отдельно inetd отдельно нет нет да да (первым) нет
Таймеры cron cron cron нет .timer StartCalendarInterval Task Scheduler
Sandboxing нет jail, отдельно ограниченно chpst богатый sandbox profiles ACL, AppContainer
Логи syslog syslog syslog svlogd journald ASL / os_log Event Log
Строк кода (порядок) ~1k ~5k ~30k ~5k ~500k+ закрытый закрытый

Читать таблицу стоит не как рейтинг, а как карту trade-offs: столбцы «надзор», «границы» и «sandboxing» — это функциональность, за которую платят строками в последней строке.

Мини-практикум: сервис от нуля до прода

# 1. Отдельный системный пользователь без shell и без домашнего каталога
sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp

# 2. Каталог состояния с правильными правами
sudo install -d -o myapp -g myapp -m 0750 /var/lib/myapp

# 3. Юнит (файл выше) + drop-in для локальных отличий
sudo systemctl edit --force --full myapp.service

# 4. Перечитать конфигурацию — без этого шага правки не видны
sudo systemctl daemon-reload

# 5. Проверить синтаксис и безопасность ДО запуска
systemd-analyze verify /etc/systemd/system/myapp.service
systemd-analyze security myapp.service

# 6. Включить (создаст симлинк из WantedBy=) и запустить
sudo systemctl enable --now myapp.service

# 7. Убедиться, что сервис действительно живёт, а не рестартует по кругу
systemctl show myapp -p NRestarts -p ActiveEnterTimestamp -p MainPID
journalctl -u myapp -n 50 --no-pager

# 8. Проверить поведение при рестарте под нагрузкой
ab -n 20000 -c 50 http://localhost:8080/ & sleep 2; sudo systemctl restart myapp

Чек-лист «сервис готов к проду»: Type=notify с честным sd_notify; обработчик SIGTERM с дренированием быстрее TimeoutStopSec; Restart=on-failure с RestartSec не меньше секунды; логи в stdout без своей ротации; User= не root; включённые NoNewPrivileges, PrivateTmp, ProtectSystem=strict; MemoryMax/CPUQuota заданы (иначе OOM killer выберет жертву сам); конфигурация читается при ExecReload, а не только при старте.

Источники

Что дальше

Мы прошли путь от execve(/sbin/init) до продакшн-юнита с sandboxing и graceful shutdown. Теперь у вас есть цельная картина запуска системы: железо → загрузчик → ядро → PID 1 → сервисы. Дальше — самое прикладное: как устроена рабочая среда программиста в Linux, из чего она состоит и какие инструменты нужны каждый день.

Linux для программиста: базовый курс всего необходимого

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов