30. systemd. Управление сервисами в Linux
systemd: Инициатор, менеджер и надзиратель
В современных дистрибутивах Linux (RHEL, Ubuntu, Debian, Alpine-based images) systemd занимает PID 1. Это не просто «менеджер процессов», а полнофункциональная инициализационная система, управляющая жизненным циклом демонов, изоляцией ресурсов, журналированием и активацией сокетов. Для Go-разработчика, целящегося на Senior/Lead, понимание systemd критично: именно он определяет, как ваше приложение стартует, получает ресурсы, реагирует на падения и интегрируется в инфраструктуру.
"Когда ваш сервис выходит в продакшен, его непосредственным начальником становится процесс с PID 1. Вы можете написать безупречный код на Go, но если unit-файл systemd настроен неверно, ядро молча пристрелит ваш сервис через OOM Killer при первом же скачке нагрузки или оборвет активные соединения пользователей при перезапуске."
В отличие от классических SysVinit-скриптов, systemd работает параллельно, использует cgroups для ограничения ресурсов и namespaces для изоляции. Это напрямую влияет на производительность, безопасность и наблюдаемость Go-приложения.
Unit-файл: Анатомия конфигурации Go-приложения
Конфигурация сервиса в systemd хранится в .service файлах. Для production-ready Go-приложения минимально достаточный unit выглядит так:
// go.mod
require github.com/coreos/go-systemd/v22 v22.5.0
// main.go
package main
import (
"fmt"
"log"
"net"
"os"
"strconv"
"github.com/coreos/go-systemd/v22/daemon"
)
func main() {
// 1. Поддержка systemd socket activation
if listenFDs, ok := os.LookupEnv("LISTEN_FDS"); ok {
fd, err := strconv.Atoi(listenFDs)
if err != nil || fd < 1 {
log.Fatalf("invalid LISTEN_FDS: %v", listenFDs)
}
// systemd передает FD начиная с 3. Берем первый.
// FD 3 — это слушающий сокет, поэтому нужен net.FileListener, а не net.FileConn
ln, err := net.FileListener(os.NewFile(3, ""))
if err != nil {
log.Fatalf("failed to get listener from FD 3: %v", err)
}
defer ln.Close()
// ... ln.Accept() и обработка соединений ...
} else {
// Fallback для локальной разработки
conn, err := net.Listen("tcp", ":8080")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
}
// 2. Отправка READY сигнала в systemd
_, err := daemon.SdNotify(false, daemon.SdNotifyReady)
if err != nil {
log.Printf("failed to notify systemd: %v", err)
}
defer daemon.SdNotify(false, daemon.SdNotifyStopping)
// Запуск сервера
log.Println("server started")
}
# /etc/systemd/system/myapp.service
[Unit]
Description=Go Backend Application
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=notify
User=appuser
Group=appgroup
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/myapp
ExecReload=/bin/kill -SIGUSR1 $MAINPID
Restart=on-failure
RestartSec=5s
WatchdogSec=30s
LimitNOFILE=65536
MemoryMax=2G
CPUQuota=80%
EnvironmentFile=/etc/myapp/env
[Install]
WantedBy=multi-user.target
Socket Activation: Устранение TCP-накладных расходов
systemd умеет открывать TCP/UDP/Unix-сокеты до запуска процесса. При первом входящем соединении он fork() и exec() процесс, передавая готовый FD через переменную окружения LISTEN_FDS.
sequenceDiagram
autonumber
participant Client as Клиент
participant Systemd as systemd (PID 1)
participant App as Go Приложение (myapp)
Note over Systemd: systemd открывает сокет :8080 (FD 3) в режиме LISTEN
Client->>Systemd: TCP SYN (входящее соединение)
Systemd->>Systemd: Прием TCP рукопожатия, буферизация в ядре
Systemd->>App: Запуск myapp (передача LISTEN_FDS=1, FD=3)
App->>App: net.FileListener(os.NewFile(3, ""))
App->>Systemd: daemon.SdNotify(READY=1)
Systemd->>App: Передача готовых клиентских сокетов
App->>Client: Обработка HTTP-запроса
Зачем это Go-разработчику:
- Zero-downtime деплой: systemd держит сокет открытым. Новый процесс получает его, старый завершается после
SIGUSR1и обработки активных соединений. - Устранение холодного старта: TCP-рукопожатие (SYN/SYN-ACK/ACK) и инициализация TLS занимают 1-3 RTT. С активацией сокета приложение стартует мгновенно при первом запросе, а сокет уже в состоянии
LISTEN. - Балансировка нагрузки: systemd может передавать сокет сразу нескольким инстансам сервиса (socket-based activation).
// Пример получения сокета от systemd (Socket Activation)
func getListenFD() (net.Listener, error) {
// systemd передает PID процесса в LISTEN_PID и количество дескрипторов в LISTEN_FDS
if pidStr, ok := os.LookupEnv("LISTEN_PID"); ok && pidStr == strconv.Itoa(os.Getpid()) {
if fdStr, ok := os.LookupEnv("LISTEN_FDS"); ok {
nfds, err := strconv.Atoi(fdStr)
if err != nil {
return nil, fmt.Errorf("invalid LISTEN_FDS: %w", err)
}
if nfds > 0 {
// Дескрипторы от systemd всегда начинаются с FD 3 (SD_LISTEN_FDS_START)
const sdListenFdsStart = 3
f := os.NewFile(uintptr(sdListenFdsStart), "systemd-listen-socket")
defer f.Close() // net.FileListener выполняет dup(fd); исходный os.File нужно закрыть
return net.FileListener(f)
}
}
}
return net.Listen("tcp", ":8080")
}
Под капотом: cgroups, namespaces и запуск процесса
Когда вы запускаете systemctl start myapp, происходит следующий цепочный процесс:
- Парсинг unit-файла: systemd читает директивы, создает cgroup v2 группу
system.slice/myapp.service. - Изоляция ресурсов:
MemoryMaxиCPUQuotaприменяются к cgroup через/sys/fs/cgroup/, аLimitNOFILE(как и остальныеLimit*) — это не cgroup, а обычныйrlimitпроцесса (setrlimit). Всё это жесткие ограничения ядра. Если Go-приложение превыситMemoryMax, ядро убьет процесс (OOMKilled), даже если GC работает корректно. - Формирование процесса:
fork()->clone()с флагамиCLONE_NEWPID,CLONE_NEWNS,CLONE_NEWNET(если настроено вProtectSystem,ProtectHome,PrivateTmp).setuid/setgidдляUser/Group.chdirвWorkingDirectory.- Установка
EnvironmentFile.
- Exec:
execve()бинарника. ПередачаLISTEN_FDSчерез env. - Наблюдение: systemd отслеживает PID главного процесса (
$MAINPID). Если он падает, systemd проверяетRestart=иRestartSec=.
Сравнение с другими подходами:
- PHP-FPM: Тоже поддерживает socket activation, но управляет только воркерами PHP. systemd универсален и работает с любым бинарником.
- Docker/K8s: В контейнеризованном мире systemd часто заменяется на
tiniилиdumb-init(PID 1 в контейнере). Однако на bare-metal или в гибридных кластерах systemd остается стандартом. - C++/C#: В этих экосистемах часто используют
systemdдля управления демоном, но Go-приложения выигрывают отType=notifyблагодаря отсутствию тяжелых фреймворков.
Watchdog, Health Checks и graceful shutdown
В production Go-приложения должны сообщать о своем здоровье. systemd поддерживает Watchdog (наблюдатель), который убьет процесс, если тот не отчитается в течение WatchdogSec.
sequenceDiagram
autonumber
participant App as Go Приложение (Watchdog горутина)
participant Systemd as systemd (/run/systemd/notify)
loop Каждые 15 секунд (WatchdogSec=30s)
App->>Systemd: SdNotify(WATCHDOG=1)
Note over Systemd: Сброс таймера сторожевого пса
end
Note over App: Горутины зависли в дедлоке!
Note over Systemd: Таймер 30s истек без пинга!
Systemd->>App: SIGABRT / SIGKILL (OOM / Hang Protection)
Note over Systemd: Перезапуск по директиве Restart=on-failure
// Пинг каждые 15 секунд (WatchdogSec=30s)
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
go func() {
for {
select {
case <-ticker.C:
_, err := daemon.SdNotify(false, daemon.SdNotifyWatchdog)
if err != nil {
log.Printf("watchdog notify failed: %v", err)
}
}
}
}()
Graceful Shutdown в связке с systemd:
systemd посылает SIGTERM при stop или reload. Задержка определяется TimeoutStopSec (по умолчанию 90s). Внутри этого окна приложение должно:
- Остановить прием новых соединений (
listener.Close()). - Завершить обработку активных запросов.
- Отключиться от БД/брокеров.
- Отправить
SdNotifyStopping(опционально, для ускорения таймаута). - Завершиться с кодом 0.
Если приложение не завершится за TimeoutStopSec, systemd пришлет SIGKILL. Это вызовет core dump, который можно исследовать через coredumpctl.
Ловушки и вопросы на собеседовании
-
Type=simplevsType=notifysimple: systemd считает процесс запущенным сразу послеexec. Если приложение падает в инициализации, systemd не узнает об этом, пока не упадет PID.notify: ждет сигналаREADY=1. Надежнее для production.
-
KillModecontrol-group(default): убивает все процессы в cgroup.process: убивает только$MAINPID. Оставшиеся процессы (например, воркеры) могут создать zombies или повиснуть в памяти.mixed:SIGTERMдля$MAINPID,SIGKILLдля группы. Часто используется для Go-приложений с отдельными воркерами.
-
Restart=alwaysvsRestart=on-failurealwaysперезапускает сервис даже при успешном завершении (exit 0). Для демонов это нормально, но для скриптов приведет к бесконечному циклу.on-failureперезапускает только при ненулевом коде или сигнале.
-
Вопрос на интервью: Как systemd управляет памятью Go-приложения? Что произойдет, если приложение превысит
MemoryMax?
Ответ: systemd применяетMemoryMaxчерез cgroup v2. Ядро Linux отслеживает RSS и cache процесса. При превышении лимита ядро инициирует OOM. Если процесс не может освободить память, он получаетSIGKILL. Для Go это особенно опасно из-заmadvise(MADV_DONTNEED)и работы GC: память может быть возвращена ядру не мгновенно, что вызовет ложные срабатывания или OOMKilled. Рекомендуется использоватьMemoryHigh(soft limit) вместо жесткогоMemoryMaxдля Go-приложений.
Итог
systemd — это не просто «менеджер сервисов», а фундамент операционной системы для управления процессами в production. Понимание его работы позволяет:
- Корректно настраивать жизненный цикл Go-приложения (
Type=notify,Watchdog,Restart). - Использовать
LISTEN_FDSдля ускорения холодного старта и zero-downtime деплоя. - Правильно ограничивать ресурсы через cgroups, не ломая работу GC и netpoller.
- Отлаживать падения через
journalctlиcoredumpctl.
В следующей статье мы разберем, как Go-приложения взаимодействуют друг с другом и с другими процессами в системе, перейдя к 31. Межпроцессное взаимодействие. IPC.