Зачем 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]: метаданные и зависимости
Описание для систем и порядка запуска.
After vs Wants vs Requires — нюанс
After=foo.service— порядок: мы стартуем после foo. Но не зависим от foo (foo может быть disabled).Wants=foo.service— soft dep: при старте также запустить foo. Если foo упадёт — нам всё равно.Requires=foo.service— hard dep: foo должен быть запущен. Если foo упадёт — мы тоже остановимся.
Для DE обычно: After=network-online.target postgresql.service + Wants=network-online.target. Это значит: ждём сеть и Postgres, пытаемся их поднять (если ещё не подняты), но не падаем если postgres недоступен (наш ETL умеет retry).
[Service]: что и как запустить
Самая важная секция. Содержит десятки опций — рассмотрим главные.
Type=
Какого типа процесс?
systemd ждёт разных сигналов в зависимости от типа.
Для 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
Накладывают namespaces и restrictions без Docker.
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
Если делать всё по этому списку — будет production-ready.
Попробуй сам
- Посмотри unit пакета (например, nginx):
systemctl cat nginx.service - Посмотри drop-ins (override):
sudo systemctl edit nginx --full # все опции, может пугать - Оценка security одного из сервисов:
systemd-analyze security ssh.service - Создай user unit для python-скрипта:
mkdir -p ~/.config/systemd/user # ... напиши .service systemctl --user daemon-reload systemctl --user start my-svc - Сколько времени запускались сервисы:
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 (полезно для пакетных).