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
// go.mod
require github.com/coreos/go-systemd/v22 v22.5.0
Go
// 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")
}
INI
# /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-разработчику:

  1. Zero-downtime деплой: systemd держит сокет открытым. Новый процесс получает его, старый завершается после SIGUSR1 и обработки активных соединений.
  2. Устранение холодного старта: TCP-рукопожатие (SYN/SYN-ACK/ACK) и инициализация TLS занимают 1-3 RTT. С активацией сокета приложение стартует мгновенно при первом запросе, а сокет уже в состоянии LISTEN.
  3. Балансировка нагрузки: systemd может передавать сокет сразу нескольким инстансам сервиса (socket-based activation).
Go
// Пример получения сокета от 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, происходит следующий цепочный процесс:

  1. Парсинг unit-файла: systemd читает директивы, создает cgroup v2 группу system.slice/myapp.service.
  2. Изоляция ресурсов: MemoryMax и CPUQuota применяются к cgroup через /sys/fs/cgroup/, а LimitNOFILE (как и остальные Limit*) — это не cgroup, а обычный rlimit процесса (setrlimit). Всё это жесткие ограничения ядра. Если Go-приложение превысит MemoryMax, ядро убьет процесс (OOMKilled), даже если GC работает корректно.
  3. Формирование процесса:
    • fork() -> clone() с флагами CLONE_NEWPID, CLONE_NEWNS, CLONE_NEWNET (если настроено в ProtectSystem, ProtectHome, PrivateTmp).
    • setuid/setgid для User/Group.
    • chdir в WorkingDirectory.
    • Установка EnvironmentFile.
  4. Exec: execve() бинарника. Передача LISTEN_FDS через env.
  5. Наблюдение: 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
  
Go
// Пинг каждые 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). Внутри этого окна приложение должно:

  1. Остановить прием новых соединений (listener.Close()).
  2. Завершить обработку активных запросов.
  3. Отключиться от БД/брокеров.
  4. Отправить SdNotifyStopping (опционально, для ускорения таймаута).
  5. Завершиться с кодом 0.

Если приложение не завершится за TimeoutStopSec, systemd пришлет SIGKILL. Это вызовет core dump, который можно исследовать через coredumpctl.


Ловушки и вопросы на собеседовании

  1. Type=simple vs Type=notify

    • simple: systemd считает процесс запущенным сразу после exec. Если приложение падает в инициализации, systemd не узнает об этом, пока не упадет PID.
    • notify: ждет сигнала READY=1. Надежнее для production.
  2. KillMode

    • control-group (default): убивает все процессы в cgroup.
    • process: убивает только $MAINPID. Оставшиеся процессы (например, воркеры) могут создать zombies или повиснуть в памяти.
    • mixed: SIGTERM для $MAINPID, SIGKILL для группы. Часто используется для Go-приложений с отдельными воркерами.
  3. Restart=always vs Restart=on-failure

    • always перезапускает сервис даже при успешном завершении (exit 0). Для демонов это нормально, но для скриптов приведет к бесконечному циклу.
    • on-failure перезапускает только при ненулевом коде или сигнале.
  4. Вопрос на интервью: Как 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. Понимание его работы позволяет:

  1. Корректно настраивать жизненный цикл Go-приложения (Type=notify, Watchdog, Restart).
  2. Использовать LISTEN_FDS для ускорения холодного старта и zero-downtime деплоя.
  3. Правильно ограничивать ресурсы через cgroups, не ломая работу GC и netpoller.
  4. Отлаживать падения через journalctl и coredumpctl.

В следующей статье мы разберем, как Go-приложения взаимодействуют друг с другом и с другими процессами в системе, перейдя к 31. Межпроцессное взаимодействие. IPC.