Системы инициализации: 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=— жёсткая зависимость (БД, без которой сервис бессмыслен). Падение зависимости остановит вас.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 запускает сервис и передаёт ему готовый
файловый дескриптор. Соединение при этом не теряется — оно просто ждёт в бэклоге.
сервис ещё НЕ запущен, память не занята S->>K: socket(); bind(:8080); listen(backlog=128) C->>K: connect(:8080) K-->>K: SYN-ACK, соединение в accept-очереди K->>S: сокет готов для чтения (epoll) S->>A: fork/exec, fd=3 = слушающий сокет
LISTEN_FDS=1, LISTEN_PID=«наш pid» A->>K: accept(3) → соединение из очереди A-->>C: HTTP 200 (клиент не заметил задержки старта) Note over S,A: При рестарте сервиса сокет остаётся у systemd —
клиенты копятся в бэклоге, а не получают ECONNREFUSED
Практические следствия, за которые это стоит любить:
- Zero-downtime рестарт без reverse-proxy.
systemctl restart myappне роняет соединения: пока сервис перезапускается, ядро копит SYN в очереди. - Параллельная загрузка без графа зависимостей. Не нужно ждать, пока БД «полностью поднимется»: клиент коннектится к сокету, который открыт с самого начала.
- Ленивый запуск. Сервис не занимает память, пока к нему никто не пришёл.
- Привилегированный порт без 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).
(симлинк, обычно → graphical.target)"] subgraph early["Ранняя загрузка"] SYSINIT["sysinit.target
монтирование, udev, swap, journald"] LOCALFS["local-fs.target
генерируется из /etc/fstab"] SWAP["swap.target"] end subgraph basic["База"] SOCKETS["sockets.target
все .socket открыты"] BASIC["basic.target"] TIMERS["timers.target"] end subgraph late["Пользовательские сервисы"] MULTI["multi-user.target"] NET["network-online.target
(опасная иллюзия: см. ниже)"] APP["myapp.service"] GRAPH["graphical.target
display-manager.service"] end DEF --> SYSINIT SYSINIT --> LOCALFS SYSINIT --> SWAP LOCALFS --> SOCKETS SOCKETS --> BASIC BASIC --> TIMERS BASIC --> MULTI NET --> APP MULTI --> APP MULTI --> GRAPH style DEF fill:#7aa7d6,stroke:#4a7fb5,color:#1a1a1a style NET fill:#c9a227,stroke:#b8901f,color:#1a1a1a style APP fill:#8fbf8f,stroke:#4f8f4f,color:#1a1a1a
Про 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/2 — runsvdir, /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 failure ≈ Restart=+RestartSec=,
obj= ≈ User=, группы ServiceGroupOrder ≈ таргеты, Job Objects ≈ cgroups (лимиты памяти и
CPU на группу процессов). Принципиальное отличие: порядок задаётся группами загрузки и
зависимостями, но параллелизм ограничен — SCM менее агрессивен в распараллеливании, чем
systemd.
Init в контейнерах: где это ломается чаще всего
Контейнер — это PID-namespace, а значит внутри него есть свой 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 отработал
Правила, которые стоит запомнить:
- exec form (
CMD ["a","b"]) всегда, кроме случаев, где вам явно нужна подстановка переменных shell. Если нужна — используйтеexecвнутри entrypoint-скрипта:exec node server.js "$@". --initилиtini, если приложение форкает дочерние процессы (вызываетffmpeg/imagemagick/git). Kubernetes-эквивалент —shareProcessNamespace: trueили просто корректный PID 1 в образе.- Обрабатывайте
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, а не только при старте.
Источники
- Michael Kerrisk, «The Linux Programming Interface», гл. 37 «Daemons» и гл. 26 «Monitoring Child Processes» — man7.org/tlpi
- W. Richard Stevens, Stephen Rago, «Advanced Programming in the UNIX Environment», 3rd ed., гл. 13 «Daemon Processes»
- Официальная документация systemd: systemd.unit(5), systemd.service(5), systemd.exec(5), sd_notify(3)
- Lennart Poettering, «Rethinking PID 1» (2010) — 0pointer.de/blog/projects/systemd.html и серия «systemd for Administrators» — 0pointer.de/blog/projects/systemd-for-admins-1.html
- FreeBSD Handbook, гл. «The FreeBSD Booting Process» и
rc.subr(8),rcorder(8),service(8)— docs.freebsd.org - OpenBSD
rc(8),rcctl(8)— man.openbsd.org/rcctl - runit documentation — smarden.org/runit
- s6 и s6-rc, Laurent Bercot — skarnet.org/software/s6
- D. J. Bernstein, daemontools — cr.yp.to/daemontools.html
- Apple, «Daemons and Services Programming Guide»,
launchd.plist(5),launchctl(1)— developer.apple.com - Microsoft, «Service Programs» и
StartServiceCtrlDispatcher— learn.microsoft.com/en-us/windows/win32/services/ - OpenRC — github.com/OpenRC/openrc,
openrc-run(8) - Docker, «Specify the ENTRYPOINT» и
--init— docs.docker.com
Что дальше
Мы прошли путь от execve(/sbin/init) до продакшн-юнита с sandboxing и graceful shutdown.
Теперь у вас есть цельная картина запуска системы: железо → загрузчик → ядро → PID 1 → сервисы.
Дальше — самое прикладное: как устроена рабочая среда программиста в Linux, из чего она состоит и
какие инструменты нужны каждый день.