Learning Platform
Глоссарий Troubleshooting
Урок 16.04 · 26 мин
Начальный
systemd unitservice fileExecStartRestartEnvironmentFileDE pattern
Airflow в Docker Compose: альтернатива systemd для dev Deployment в Kubernetes: production-замена systemd-сервиса

Зачем DE свой systemd-сервис

В data engineering часто есть code, который должен постоянно работать: кастомный ETL-daemon, Kafka consumer, S3-watcher, custom alerter. До systemd обычный подход был:

$ nohup python my_etl.py > /var/log/my_etl.log 2>&1 &

Проблемы такого подхода:

  • При reboot ничего не запустится автоматически.
  • Если процесс упадёт — никто не перезапустит.
  • Логи в одном файле без ротации.
  • Нет лимитов по памяти/CPU.
  • Нет proper shutdown при systemctl poweroff — SIGTERM не доходит правильно.

Правильное решение — описать процесс как systemd service unit. Это даёт: autostart, restart-on-failure, structured logging в journal, resource limits, security sandbox.

В этом уроке мы напишем production-ready unit для Python ETL daemon-а.

Анатомия unit-файла

Unit-файл — это INI с тремя обычными секциями: [Unit], [Service], [Install].

[Unit]
Description=My ETL daemon
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=etl
Group=etl
WorkingDirectory=/opt/my-etl
EnvironmentFile=/etc/my-etl/env
ExecStart=/opt/my-etl/venv/bin/python /opt/my-etl/main.py
Restart=on-failure
RestartSec=10s

[Install]
WantedBy=multi-user.target

Разберём построчно.

[Unit]: метаданные и зависимости

[Unit] секция: что это и от чего зависит

Описание для систем и порядка запуска.

Description=что это (для людей)
After=postgresql.serviceпорядок запуска
Wants=network-online.targetмягкая зависимость
Requires=postgresql.serviceжёсткая зависимость (с осторожностью)
ConditionPathExists=/dataусловный запуск
Documentation=ссылки на man/wiki

After vs Wants vs Requires — нюанс

  • After=foo.serviceпорядок: мы стартуем после foo. Но не зависим от foo (foo может быть disabled).
  • Wants=foo.servicesoft dep: при старте также запустить foo. Если foo упадёт — нам всё равно.
  • Requires=foo.servicehard dep: foo должен быть запущен. Если foo упадёт — мы тоже остановимся.

Для DE обычно: After=network-online.target postgresql.service + Wants=network-online.target. Это значит: ждём сеть и Postgres, пытаемся их поднять (если ещё не подняты), но не падаем если postgres недоступен (наш ETL умеет retry).

[Service]: что и как запустить

Самая важная секция. Содержит десятки опций — рассмотрим главные.

Type=

Какого типа процесс?

Service Type=

systemd ждёт разных сигналов в зависимости от типа.

Type=simpleпроцесс на foreground (default)
Type=forkingтрадиционный fork-демон
Type=oneshotbatch job, выполнился и вышел
Type=notifyпроцесс шлёт READY через sd_notify
Type=dbusждём D-Bus name

Для Python ETL обычно Type=simple. Если процесс делает heavy initialization (загружает кубический ГБ модели в RAM перед началом работы) — можно использовать Type=notify и слать sd_notify(READY=1) после загрузки.

ExecStart= — команда запуска

ExecStart=/opt/my-etl/venv/bin/python /opt/my-etl/main.py

ВАЖНО: пути должны быть абсолютные. systemd не запускает через shell — PATH не настроен, ~/bin не работает.

# НЕПРАВИЛЬНО — упадёт:
ExecStart=python main.py

# НЕПРАВИЛЬНО — упадёт:
ExecStart=/opt/my-etl/run.sh && tail -f log

# ПРАВИЛЬНО — абсолютный путь:
ExecStart=/opt/my-etl/venv/bin/python /opt/my-etl/main.py

Если нужны shell-features (pipes, &&):

ExecStart=/bin/bash -c 'cd /opt/etl && /opt/etl/venv/bin/python main.py'

Но обычно лучше написать обёртку-скрипт run.sh с set -euo pipefail и указать его:

ExecStart=/opt/my-etl/run.sh

Подробнее про set -euo pipefail — в модуле 17-bash-scripting-advanced.

User= и Group= — под кем запускать

User=etl
Group=etl

systemd сделает setuid(etl) + setgid(etl) перед execve(). Это критично для безопасности: не запускай свой ETL от root.

Создать system user заранее:

$ sudo useradd -r -s /usr/sbin/nologin -d /opt/my-etl etl
  • -r — system user (UID < 1000).
  • -s /usr/sbin/nologin — не разрешать интерактивный login.
  • -d /opt/my-etl — указать home (для ~/-ссылок в коде).

Подробнее про users — модуль 05-permissions-users.

WorkingDirectory= — где cwd

WorkingDirectory=/opt/my-etl

systemd сделает chdir() перед запуском. Без этого cwd = / — и относительные пути в Python (open("config.yaml")) не найдутся.

EnvironmentFile= — секреты и конфиги

EnvironmentFile=/etc/my-etl/env

Содержимое /etc/my-etl/env:

# Это shell-style env file, без 'export'.
DATABASE_URL=postgresql://etl:secret@localhost:5432/etl
S3_BUCKET=my-bucket
LOG_LEVEL=INFO
SENTRY_DSN=https://[email protected]/12345

Преимущества над Environment= (inline):

  • Секреты не в systemd unit (который часто в git).
  • Можно ограничить права: chmod 0640 /etc/my-etl/env, chown root:etl.
  • Удобно менять без daemon-reload.

Inline-вариант тоже бывает:

Environment="LOG_LEVEL=INFO"
Environment="WORKERS=4"

Для несекретных значений нормально. Для секретов — только EnvironmentFile=.

Restart= — что делать при падении

Restart=on-failure
RestartSec=10s

Опции Restart=:

  • no — не рестартить никогда.
  • on-success — рестарт только если exit code 0 (странно, редко надо).
  • on-failure — рестарт при ненулевом exit, kill signal, OOM.
  • on-abnormal — kill signal, OOM, но не на exit code.
  • on-abort — только если процесс получил uncaught signal (SIGABRT).
  • always — рестарт ВСЕГДА (даже после успешного exit).

Для DE-задач обычно: Restart=on-failure. Это значит «упал — подними». Не always — иначе нормальный shutdown (exit 0) приведёт к infinite restart loop.

RestartSec= — задержка перед рестартом (default 100ms — слишком быстро для большинства случаев). 10s обычно достаточно.

# Защита от crash loop — если падает чаще 5 раз за 60 секунд, останавливаемся:
StartLimitBurst=5
StartLimitIntervalSec=60s

Без этого crash loop может загрузить процессорное время до 100% при быстрых рестартах.

StandardOutput / StandardError

StandardOutput=journal
StandardError=journal

Куда направить stdout/stderr процесса. Опции:

  • journal (default) — в systemd journal. journalctl -u SVC покажет.
  • null>/dev/null.
  • file:/path — в файл.
  • inherit — наследовать от systemd (обычно тоже в journal).

journal — почти всегда правильный выбор. Это даёт structured logging бесплатно.

TimeoutStartSec, TimeoutStopSec

TimeoutStartSec=120s
TimeoutStopSec=30s

Сколько ждать ExecStart, прежде чем считать запуск failed. И сколько ждать после SIGTERM, прежде чем послать SIGKILL.

Default обычно 90s. Если ETL делает heavy startup — увеличить. Если хочешь быстрый kill при systemctl stop — уменьшить.

Resource limits — защита от прожорливости

MemoryMax=2G
CPUQuota=200%
TasksMax=512
LimitNOFILE=4096
  • MemoryMax= — лимит RAM. При превышении — OOM (через cgroup).
  • CPUQuota=200% — два полных ядра. 100% = одно ядро.
  • TasksMax= — максимум process/thread.
  • LimitNOFILE= — limit на открытые file descriptors (default 1024 — мало для Kafka consumer).

Это супер-важно для DE: ETL без лимита может съесть всю RAM и положить production-VM (включая postgres, который висит на том же хосте).

Security sandbox — изоляция

systemd может изолировать ваш сервис почти как Docker:

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadWritePaths=/opt/my-etl/data /var/log/my-etl
Главные security options

Накладывают namespaces и restrictions без Docker.

NoNewPrivileges=trueзапрет SUID escalation
ProtectSystem=strict/ read-only
ProtectHome=trueнет доступа к /home
PrivateTmp=trueсвой /tmp
RestrictAddressFamilies=ограничение сокетов
ReadWritePaths=белый список для записи

systemd-analyze security my-etl.service оценит уровень изоляции в баллах от 0 (опасно) до 10 (UNSAFE).

[Install]: куда подключить

[Install]
WantedBy=multi-user.target

WantedBy=multi-user.target — при enable создать symlink в /etc/systemd/system/multi-user.target.wants/. multi-user.target — стандартный server target.

Альтернативы:

  • WantedBy=graphical.target — только если есть GUI (странно для server).
  • WantedBy=default.target — что было дефолтом.

Если [Install] пуст или отсутствует — unit нельзя enable (только start руками). Это полезно для template-units и oneshot-jobs.

Полный пример: Python ETL daemon

Файл /etc/systemd/system/orders-etl.service:

[Unit]
Description=Daily orders ETL daemon
Documentation=https://wiki.company.com/de/orders-etl
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=etl
Group=etl
WorkingDirectory=/opt/orders-etl

# Environment from /etc/orders-etl/env (chmod 0640, owned root:etl)
EnvironmentFile=/etc/orders-etl/env
Environment=PYTHONUNBUFFERED=1

# Run via venv
ExecStart=/opt/orders-etl/venv/bin/python -m orders_etl.daemon

# Graceful shutdown: send SIGTERM, wait 30s, then SIGKILL
KillSignal=SIGTERM
TimeoutStopSec=30s

# Auto-restart on crash but не infinite loop
Restart=on-failure
RestartSec=10s
StartLimitBurst=5
StartLimitIntervalSec=60s

# Resource limits — защита от прожорливости
MemoryMax=2G
CPUQuota=150%
TasksMax=256
LimitNOFILE=4096

# Logs в journal — увидим через journalctl -u orders-etl
StandardOutput=journal
StandardError=journal

# Security sandbox
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadWritePaths=/opt/orders-etl/data /var/log/orders-etl

[Install]
WantedBy=multi-user.target

Workflow развёртывания:

# 1) Создать system user
$ sudo useradd -r -s /usr/sbin/nologin -d /opt/orders-etl etl

# 2) Создать директории
$ sudo mkdir -p /opt/orders-etl /etc/orders-etl /var/log/orders-etl
$ sudo chown -R etl:etl /opt/orders-etl /var/log/orders-etl
$ sudo chmod 0750 /etc/orders-etl

# 3) Положить код, venv
$ sudo -u etl python3 -m venv /opt/orders-etl/venv
$ sudo -u etl /opt/orders-etl/venv/bin/pip install -r requirements.txt

# 4) Положить env-file с секретами
$ sudo tee /etc/orders-etl/env > /dev/null <<'EOF'
DATABASE_URL=postgresql://etl:secret@localhost:5432/orders
S3_BUCKET=orders-data
LOG_LEVEL=INFO
EOF
$ sudo chown root:etl /etc/orders-etl/env
$ sudo chmod 0640 /etc/orders-etl/env

# 5) Положить unit
$ sudo cp orders-etl.service /etc/systemd/system/

# 6) Перечитать unit-файлы
$ sudo systemctl daemon-reload

# 7) Запустить и поставить на autostart
$ sudo systemctl enable --now orders-etl

# 8) Проверить
$ systemctl status orders-etl
$ journalctl -u orders-etl -f

User units: без root

Если ты не админ сервера или хочешь debug в своём $HOME:

# Папка user units
$ mkdir -p ~/.config/systemd/user/

# Unit (БЕЗ User=, потому что и так от тебя):
$ cat > ~/.config/systemd/user/my-test.service <<'EOF'
[Unit]
Description=My test service

[Service]
Type=simple
ExecStart=/usr/bin/python3 /home/levo/test.py
Restart=on-failure

[Install]
WantedBy=default.target
EOF

# Управление через --user
$ systemctl --user daemon-reload
$ systemctl --user enable --now my-test
$ systemctl --user status my-test
$ journalctl --user -u my-test -f

User units работают пока user залогинен (или enabled lingering: loginctl enable-linger USERNAME). Для production обычно system units (root-installed). User units — для personal development.

systemctl edit: override без правки оригинала

Иногда нужно изменить unit пакета (postgresql, nginx), но не редактировать /usr/lib/systemd/system/:

$ sudo systemctl edit postgresql

Откроет editor с пустым файлом. Добавляешь только то, что переопределяешь:

[Service]
Environment=POSTGRES_LOG_STATEMENT=ddl
MemoryMax=4G

После save — это окажется в /etc/systemd/system/postgresql.service.d/override.conf. Будет применено поверх основного unit-файла.

systemctl daemon-reload (или сам systemctl edit предложит) -> systemctl restart postgresql.

systemctl cat postgresql покажет результат merge — основной unit + drop-ins.

Best practices summary

Best practices systemd unit для DE

Если делать всё по этому списку — будет production-ready.

User=etl (не root!)принцип least privilege
Restart=on-failure + лимитresilience без infinite loop
MemoryMax=, CPUQuota=resource limits
EnvironmentFile=секреты вне unit
StandardOutput=journalлоги в journalctl
ProtectSystem=strictsandbox
systemd-analyze securityaudit изоляции

Попробуй сам

  1. Посмотри unit пакета (например, nginx):
    systemctl cat nginx.service
  2. Посмотри drop-ins (override):
    sudo systemctl edit nginx --full   # все опции, может пугать
  3. Оценка security одного из сервисов:
    systemd-analyze security ssh.service
  4. Создай user unit для python-скрипта:
    mkdir -p ~/.config/systemd/user
    # ... напиши .service
    systemctl --user daemon-reload
    systemctl --user start my-svc
  5. Сколько времени запускались сервисы:
    systemd-analyze blame | head -10

Что дальше

В модуле 15-cron-scheduling мы увидим как делать timers — systemd-альтернатива cron. Связка .timer + .service для periodic jobs.

В модуле 16-bash-scripting-basics — bash-скрипты, которые часто становятся ExecStart= в .service units.

macOS-различия

На macOS нет systemd. Аналог — launchd с XML .plist файлами:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ...>
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.company.my-etl</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/python3</string>
        <string>/Users/me/etl/main.py</string>
    </array>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/my-etl.out</string>
</dict>
</plist>

Установка: launchctl load -w ~/Library/LaunchAgents/com.company.my-etl.plist. Концептуально похоже, но синтаксис совсем другой.

Главное

  • Unit-файл = 3 секции: [Unit] (deps, description), [Service] (как запускать), [Install] (target).
  • Type=simple — стандарт для Python/Go/Rust сервисов на foreground.
  • User=обязательно не root. Создай system user через useradd -r.
  • ExecStart=абсолютные пути. Нет PATH в systemd-окружении.
  • EnvironmentFile=/etc/SVC/env — секреты вне unit-файла. Права 0640 root:USER.
  • Restart=on-failure + RestartSec=10s + StartLimitBurst=5 — защита от crash loop.
  • MemoryMax=, CPUQuota=, TasksMax= — resource limits на cgroup.
  • StandardOutput=journal — structured logging, читается через journalctl -u SVC.
  • Security: NoNewPrivileges, ProtectSystem=strict, PrivateTmp, ReadWritePaths=.
  • Workflow: write unit -> systemctl daemon-reload -> systemctl enable --now SVC -> проверить через status и journalctl.
  • User units: ~/.config/systemd/user/, systemctl --user ... — без root.
  • systemctl edit SVC — override без правки основного unit (полезно для пакетных).

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Пишем .service для Python ETL daemon. Какое значение `Type=` правильное?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4