Продвинутое управление сервисами systemd: 30 юнитов SaaS-платформы под контролем

Задача клиента: 30 микросервисов без единого оркестратора

SaaS-платформа «КлаудАпп» — российский аналог Notion с модулями для документов, задач, чатов и видеозвонков. Каждый модуль представляет собой отдельный сервис: Go-бэкенд, Python-воркеры, Node.js-рендерер, Redis, PostgreSQL, MinIO и ещё два десятка компонентов. Всё это работало на трёх bare-metal серверах без Kubernetes — команда из пяти разработчиков не хотела тратить ресурсы на оркестрацию кластера.

Проблема, с которой «КлаудАпп» обратилась к специалистам itfresh.ru, была типичной для монолитного деплоя: сервисы падали, перезапускались вручную, утечки памяти в Python-воркерах убивали соседние процессы, а логи смешивались в общем syslog без возможности фильтрации. Однажды OOM-killer остановил PostgreSQL из-за того, что воркер обработки изображений сожрал 28 ГБ из 32 доступных.

Мы предложили системный подход: превратить systemd из простого «запускальщика» в полноценную платформу управления сервисами с ресурсными лимитами, изоляцией, мониторингом и автоматическим восстановлением.

Структура unit-файлов и типы сервисов

Первым шагом стала ревизия всех unit-файлов. Из 30 сервисов 22 использовали Type=simple — даже те, которые форкались или отправляли уведомление о готовности. Неправильный тип сервиса приводил к тому, что systemd считал сервис запущенным до того, как тот реально начинал принимать соединения.

Мы выделили три основных типа:

# Type=simple — процесс остаётся на переднем плане
# Подходит для Go-бинарников и Node.js
[Service]
Type=simple
ExecStart=/opt/cloudapp/bin/api-server --config /etc/cloudapp/api.yaml

# Type=forking — классические демоны, которые форкаются в фон
# Подходит для legacy-компонентов на C/C++
[Service]
Type=forking
PIDFile=/run/cloudapp/renderer.pid
ExecStart=/opt/cloudapp/bin/renderer -d -p /run/cloudapp/renderer.pid

# Type=notify — сервис сам сообщает systemd о готовности
# Подходит для приложений с длительной инициализацией
[Service]
Type=notify
ExecStart=/opt/cloudapp/bin/search-indexer --config /etc/cloudapp/search.yaml
TimeoutStartSec=120
WatchdogSec=30

Для Go-сервисов мы внедрили библиотеку go-systemd/daemon, которая отправляет уведомление sd_notify(READY=1) после завершения инициализации. Это позволило systemd точно знать, когда сервис готов принимать запросы, и правильно выстраивать зависимости через After= и Requires=.

# Полный unit-файл для API-сервера
[Unit]
Description=CloudApp API Server
After=network-online.target postgresql.service redis.service
Requires=postgresql.service redis.service
Wants=network-online.target

[Service]
Type=notify
User=cloudapp
Group=cloudapp
WorkingDirectory=/opt/cloudapp
ExecStart=/opt/cloudapp/bin/api-server --config /etc/cloudapp/api.yaml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
TimeoutStartSec=60
TimeoutStopSec=30
WatchdogSec=30

# Graceful shutdown: сначала SIGTERM, через 30 секунд SIGKILL
KillMode=mixed
KillSignal=SIGTERM

[Install]
WantedBy=multi-user.target

Ресурсные лимиты: MemoryMax, CPUQuota, IOWeight

Главная причина инцидента с OOM-killer — отсутствие ресурсных ограничений. Каждый сервис мог потребить сколько угодно памяти и CPU. Мы внедрили лимиты через cgroups v2, которыми systemd управляет нативно.

# Лимиты для Python-воркера обработки изображений
[Service]
# Жёсткий лимит памяти: при превышении сервис перезапускается,
# а не убивает соседей через OOM
MemoryMax=4G
MemoryHigh=3G

# Лимит CPU: 200% = максимум 2 ядра из 16
CPUQuota=200%

# Приоритет I/O: 50 из 100 (ниже, чем у PostgreSQL)
IOWeight=50

# Лимит на количество процессов (защита от fork-бомб)
TasksMax=64

# Лимит на размер core dump
LimitCORE=0

# Лимит на количество открытых файлов
LimitNOFILE=65536

Разница между MemoryMax и MemoryHigh критически важна. MemoryHigh — мягкий лимит: при достижении ядро начинает агрессивнее освобождать память (reclaim), но не убивает процесс. MemoryMax — жёсткий лимит: при превышении процесс получает OOM и перезапускается systemd.

Для PostgreSQL мы выставили повышенные приоритеты:

# /etc/systemd/system/postgresql.service.d/limits.conf
[Service]
MemoryMax=16G
MemoryHigh=14G
CPUQuota=400%
IOWeight=90
OOMScoreAdjust=-900
TasksMax=512

Параметр OOMScoreAdjust=-900 гарантирует, что ядро убьёт PostgreSQL последним — только если вообще не останется других кандидатов. После внедрения лимитов OOM-инциденты прекратились: воркер перезапускался при достижении своих 4 ГБ, не затрагивая остальные сервисы.

Sandboxing: изоляция сервисов без контейнеров

Systemd предоставляет десятки директив для изоляции сервисов — фактически контейнеризация без Docker. Мы настроили sandboxing для каждого сервиса в зависимости от его потребностей.

# Sandboxing для API-сервера
[Service]
# Файловая система корня — только чтение
ProtectSystem=strict

# /home, /root, /run/user — недоступны
ProtectHome=yes

# Приватный /tmp для каждого сервиса
PrivateTmp=yes

# Запрет получения новых привилегий (suid/sgid)
NoNewPrivileges=yes

# Приватный /dev: только /dev/null, /dev/zero, /dev/urandom
PrivateDevices=yes

# Запрет записи в /usr, /boot, /efi
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes

# Разрешаем запись только в конкретные директории
ReadWritePaths=/var/lib/cloudapp/api /var/log/cloudapp/api /run/cloudapp

# Фильтр системных вызовов
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

# Ограничение сетевых семейств
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

# Запрет изменения пространства имён
RestrictNamespaces=yes

# Запрет real-time планирования
RestrictRealtime=yes

Директива ProtectSystem=strict монтирует всю файловую систему в режиме только для чтения. Сервис может писать только в директории, явно указанные в ReadWritePaths. Это предотвращает случайное повреждение системных файлов и существенно снижает поверхность атаки.

Для проверки уровня безопасности использовали встроенный аудит:

# Оценка безопасности unit-файла (0 — идеал, 10 — опасно)
$ systemd-analyze security cloudapp-api.service

  NAME                          DESCRIPTION                    EXPOSURE
✓ PrivateDevices=               Service has no access to devs  0.2
✓ PrivateTmp=                   Service uses private /tmp      0.1
✓ ProtectSystem=strict          Full file system protection    0.0
✗ CapabilityBoundingSet=        Service capability set not     0.4
✓ NoNewPrivileges=              No privilege escalation        0.0
→ Overall exposure level for cloudapp-api.service: 2.4 SAFE

Socket activation и timer units

Три сервиса «КлаудАпп» использовались редко: экспорт в PDF, генерация отчётов и миграция данных. Держать их постоянно запущенными — пустая трата памяти. Мы внедрили socket activation: systemd слушает сокет и запускает сервис только при входящем соединении.

# /etc/systemd/system/cloudapp-pdf.socket
[Unit]
Description=CloudApp PDF Export Socket

[Socket]
ListenStream=/run/cloudapp/pdf.sock
SocketUser=cloudapp
SocketGroup=cloudapp
SocketMode=0660

# Очередь ожидающих соединений
Backlog=128

# Если сервис не ответил за 60 секунд — таймаут
TriggerLimitIntervalSec=60
TriggerLimitBurst=10

[Install]
WantedBy=sockets.target

# /etc/systemd/system/cloudapp-pdf.service
[Unit]
Description=CloudApp PDF Export Service
Requires=cloudapp-pdf.socket

[Service]
Type=simple
User=cloudapp
ExecStart=/opt/cloudapp/bin/pdf-exporter
StandardInput=socket

# Автоматическая остановка через 5 минут бездействия
TimeoutIdleSec=300

Для периодических задач заменили cron на systemd timer units. Преимущества: интеграция с journalctl, зависимости от других сервисов, точное управление пропущенными запусками.

# /etc/systemd/system/cloudapp-cleanup.timer
[Unit]
Description=CloudApp Database Cleanup Timer

[Timer]
# Ежедневно в 03:00
OnCalendar=*-*-* 03:00:00

# Если сервер был выключен в 03:00 — запустить при включении
Persistent=true

# Случайная задержка до 15 минут (чтобы не все серверы разом)
RandomizedDelaySec=900

# Точность таймера
AccuracySec=60

[Install]
WantedBy=timers.target

# /etc/systemd/system/cloudapp-cleanup.service
[Unit]
Description=CloudApp Database Cleanup
After=postgresql.service
Requires=postgresql.service

[Service]
Type=oneshot
User=cloudapp
ExecStart=/opt/cloudapp/bin/db-cleanup --older-than 90d
MemoryMax=2G
TimeoutStartSec=3600
# Управление таймерами
$ systemctl list-timers --all
NEXT                        LEFT          LAST                        PASSED   UNIT
Sun 2026-04-06 03:07:23 MSK 12h left      Sat 2026-04-05 03:02:41 MSK 11h ago  cloudapp-cleanup.timer

$ systemctl status cloudapp-cleanup.timer
$ journalctl -u cloudapp-cleanup.service --since today

Journalctl: продвинутая фильтрация и экспорт логов

С переходом на systemd все сервисы автоматически пишут в journal — структурированное хранилище логов. Это позволило отказаться от rsyslog и logrotate для сервисов «КлаудАпп» и настроить мощную фильтрацию.

# Логи конкретного сервиса за последний час
$ journalctl -u cloudapp-api.service --since '1 hour ago'

# Логи по приоритету (только ошибки и критические)
$ journalctl -u cloudapp-api.service -p err..crit

# Фильтрация по нескольким юнитам одновременно
$ journalctl -u cloudapp-api.service -u cloudapp-worker.service

# Полнотекстовый поиск по сообщению
$ journalctl -u cloudapp-api.service -g 'connection refused|timeout'

# Вывод в JSON для парсинга скриптами
$ journalctl -u cloudapp-api.service -o json-pretty --since today | \
  jq 'select(.PRIORITY == "3")'

# Логи конкретного PID
$ journalctl _PID=12345

# Логи по cgroup (все процессы сервиса, включая дочерние)
$ journalctl _SYSTEMD_CGROUP=/system.slice/cloudapp-api.service

# Логи ядра, связанные с OOM
$ journalctl -k -g 'oom|Out of memory' --since '7 days ago'

# Использование дискового пространства
$ journalctl --disk-usage

# Ротация: оставить логи за последние 7 дней
$ journalctl --vacuum-time=7d

Для централизованного сбора логов мы настроили systemd-journal-remote, который отправляет журнал со всех трёх серверов на центральный лог-коллектор:

# /etc/systemd/journal-upload.conf
[Upload]
URL=https://logs.cloudapp.internal:19532
ServerKeyFile=/etc/ssl/private/journal-upload.key
ServerCertificateFile=/etc/ssl/certs/journal-upload.pem
TrustedCertificateFile=/etc/ssl/certs/ca.pem

Slice-иерархия и cgroups v2

Systemd организует cgroups в иерархию slice-юнитов. По умолчанию все сервисы попадают в system.slice. Мы создали выделенные слайсы для группировки сервисов и управления ресурсами на уровне групп.

# /etc/systemd/system/cloudapp.slice
[Unit]
Description=CloudApp Services Slice
Before=slices.target

[Slice]
# Все сервисы CloudApp суммарно: максимум 24 ГБ из 32
MemoryMax=24G
MemoryHigh=22G

# Суммарно: максимум 12 ядер из 16
CPUQuota=1200%

# Вес для распределения CPU между слайсами
CPUWeight=80

# /etc/systemd/system/cloudapp-db.slice
[Unit]
Description=CloudApp Database Slice
Before=slices.target

[Slice]
MemoryMax=16G
CPUQuota=400%
IOWeight=90
# Привязка сервиса к слайсу
[Service]
Slice=cloudapp.slice
# или для баз данных:
Slice=cloudapp-db.slice

Мониторинг cgroups v2 в реальном времени:

# Дерево cgroups с потреблением ресурсов
$ systemd-cgtop

Control Group                  Tasks   %CPU   Memory  Input/s Output/s
/                                234   45.2    18.3G      -       -
/cloudapp.slice                  156   38.1    12.7G      -       -
/cloudapp.slice/cloudapp-api      12    8.3     1.2G      -       -
/cloudapp.slice/cloudapp-worker   48   22.4     3.8G      -       -
/cloudapp-db.slice                28    6.8     8.1G      -       -

# Текущие лимиты конкретного cgroup
$ cat /sys/fs/cgroup/cloudapp.slice/memory.max
25769803776
$ cat /sys/fs/cgroup/cloudapp.slice/memory.current
13635510272

# Счётчик OOM-событий
$ cat /sys/fs/cgroup/cloudapp.slice/cloudapp-worker.service/memory.events
low 0
high 847
max 12
oom 3
oom_kill 3

Иерархия слайсов позволила гарантировать, что база данных всегда имеет достаточно ресурсов, даже если воркеры потребляют максимум. Сервисы внутри cloudapp.slice конкурируют друг с другом, но не могут забрать ресурсы у cloudapp-db.slice.

Результаты и рекомендации по systemd в продакшене

После трёх недель рефакторинга unit-файлов и настройки ресурсных лимитов платформа «КлаудАпп» получила стабильную систему управления 30 сервисами без Kubernetes.

МетрикаДоПосле
OOM-инциденты3-4 в неделю0 за 2 месяца
Среднее время восстановления сервиса5-15 минут (вручную)5 секунд (Restart=on-failure)
Время поиска в логах10-20 минут (grep по файлам)5 секунд (journalctl с фильтрами)
Потребление памяти idle-сервисами2.8 ГБ0.4 ГБ (socket activation)
Безопасность (systemd-analyze security)8.2 UNSAFE2.4 SAFE

Ключевые рекомендации:

  • Всегда указывайте правильный Type= — от этого зависит корректность определения состояния сервиса.
  • Выставляйте MemoryMax для каждого сервиса — один утечка памяти не должна убивать сервер.
  • Используйте sandboxing: ProtectSystem=strict + NoNewPrivileges=yes — минимальный набор для любого сервиса.
  • Замените cron на timer units — получите логирование, зависимости и обработку пропущенных запусков.
  • Организуйте сервисы в slice-иерархию для управления ресурсами на уровне групп.

Если ваши серверы работают на bare-metal и вы хотите навести порядок в управлении сервисами — обращайтесь к специалистам itfresh.ru. Мы настроим systemd так, чтобы он работал как полноценная платформа оркестрации.

Часто задаваемые вопросы

Type=simple считает сервис запущенным сразу после вызова ExecStart, даже если приложение ещё инициализируется. Type=notify ждёт явного сигнала sd_notify(READY=1) от приложения, что гарантирует корректную работу зависимостей (After=/Requires=) и health-check через WatchdogSec.
Добавьте в секцию [Service] директивы MemoryHigh (мягкий лимит — ядро начнёт агрессивный reclaim) и MemoryMax (жёсткий лимит — при превышении процесс будет убит OOM-killer и перезапущен systemd). Дополнительно выставьте OOMScoreAdjust=-900 для критичных сервисов вроде СУБД.
Да, для серверных задач timer units предпочтительнее: логи автоматически попадают в journal, поддерживаются зависимости от других сервисов, Persistent=true обеспечивает запуск пропущенных задач, а RandomizedDelaySec распределяет нагрузку. Cron остаётся удобнее только для простых пользовательских скриптов.
ProtectSystem=strict монтирует всю файловую систему read-only. Сервис может писать только в директории из ReadWritePaths, StateDirectory, LogsDirectory и RuntimeDirectory. Перед включением проверьте, куда сервис пишет файлы (strace -e openat), и добавьте эти пути в ReadWritePaths.
Используйте systemd-cgtop для обзора дерева cgroups с CPU, памятью и I/O. Для деталей читайте файлы в /sys/fs/cgroup/: memory.current, memory.events (счётчик OOM), cpu.stat. Для интеграции с Prometheus используйте node_exporter с коллектором systemd.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#systemd#systemd service#unit file#socket activation#systemd timer#cgroups v2#sandboxing systemd#journalctl
Комментарии 0

Оставить комментарий

загрузка...