Прямой ответ
systemd — стандартный способ «приложение живёт как служба» на современном Linux: стартует при загрузке, перезапускается при падении, логирует в журнал, ограничивается по ресурсам. Оформление приложения — один unit-файл /etc/systemd/system/myapp.service с тремя секциями: [Unit] (описание, зависимости, порядок старта), [Service] (как запускать: ExecStart, пользователь, окружение, политика рестарта) и [Install] (WantedBy=multi-user.target — включить в загрузку). Активация — systemctl daemon-reload && systemctl enable --now myapp. Для разработчика важны четыре практических свойства: Restart=on-failure (служба переживает падения), журнал (journalctl вместо самописных лог-файлов — хотя структурированные логи приложения остаются его задачей), таймеры (нативная замена cron с логированием и зависимостями), изоляция (запуск под отдельным пользователем с ограничениями — дешёвое усиление безопасности).
Анатомия service-юнита: поля, которые решают
Минимальный рабочий юнит: ExecStart=/opt/myapp/venv/bin/uvicorn app:app --port 8000 (абсолютный путь — PATH у systemd свой, «просто python» не найдётся; активация venv не нужна — путь к интерпретатору venv делает всё). Ключевые поля [Service]: User=myapp/Group=myapp (никогда не root без необходимости; пользователь создаётся ansible-ролью — гайд); WorkingDirectory=; Environment=/EnvironmentFile=/etc/myapp/env (переменные и секреты — из файла с правами 600, не инлайн в юнит — правила секретов); Restart=on-failure + RestartSec=3 (перезапуск при аварийном выходе, но не при штатном stop); Type= — simple для «процесс и есть служба» (дефолт, подходит uvicorn/gunicorn), notify/oneshot — особые случаи. WantedBy=multi-user.target в [Install] — и служба в загрузке; Wants=/After=network-online.target/postgresql.service — порядок старта (приложение стартует после БД, а не гоняет ретраи подключения вслепую).
Жизненный цикл: запуск, логи, рестарт
Повседневные команды: systemctl status myapp (состояние + последние строки лога), journalctl -u myapp -f (лог службы в реальном времени), journalctl -u myapp --since "10 min ago" (срез), systemctl restart myapp. Полезные детали: журнал бинарный и ротируется сам (journald — настройка размера в journald.conf; дублирование в файлы не нужно); journalctl -u myapp -o json отдаёт структурированный вывод для разбора; приложения, пишущие JSON-логи в stdout, попадают в журнал как есть — связка со структурированным логированием прямая. Деплой новой версии — git pull/копирование артефакта + systemctl restart; ноль-даунтайм — уже уровень gunicorn (graceful reload по SIGHUP) или нескольких инстансов за nginx (прокси-конфиг). daemon-reload — обязателен после каждой правки unit-файла, иначе systemd работает со старой версией (частая причина «правки не применились»).
Таймеры: замена cron с преимуществами
myapp-backup.timer + myapp-backup.service (Type=oneshot) — пара, заменяющая cron-строку: OnCalendar=*-*-* 03:00:00 (синтаксис богаче крона: OnCalendar=Mon..Fri 09:00, *:0/15 — каждые 15 минут), Persistent=true (пропущенный запуск — например, сервер спал — догоняется при пробуждении; cron просто теряет запуск). Преимущества перед cron по построению: каждый прогон — в журнале с юнита (видно длительность, код выхода, stdout — у cron «задача не отработала» молчалива), RandomizedDelaySec (размазывание пиков), зависимости (After=network-online — бэкап в S3 не стартует без сети). Системный cron остаётся для легаси и редких случаев; новые периодические задачи на сервере разумнее оформлять таймерами (общий контекст cron против systemd — в статье про cron). systemctl list-timers — обзор расписания с временами следующего запуска.
Изоляция и лимиты: дешёвая безопасность
systemd умеет ограничивать службу без контейнеров, и для внутреннего сервиса этого часто достаточно: NoNewPrivileges=true (запрет повышения привилегий), ProtectSystem=strict + ReadWritePaths=/var/lib/myapp (файловая система только на чтение, кроме нужного), PrivateTmp=true, ProtectHome=true, MemoryMax=1G/CPUQuota=200% (лимиты ресурсов — cgroup под капотом; упирающаяся в лимит служба убивается OOM-киллером systemd с ясной записью в журнале, а не съедает сервер), LimitNOFILE=65536 (файловые дескрипторы для высоконагруженных сервисов). Проверка достигнутого — systemd-analyze security myapp (оценка exposure и список рекомендаций). Это не замена контейнеризации, но для «один сервис на VM» — уровень изоляции, которого нет у «просто процесса из-под root».
Практический сценарий: деплой Python-сервиса целиком
Полный комплект для FastAPI-приложения. Ansible создаёт пользователя myapp, каталоги /opt/myapp (код) и /var/lib/myapp (данные), кладёт /etc/myapp/env (0600, владелец myapp) с DATABASE_URL и SECRET_KEY. Unit-файл: User=myapp, ExecStart=/opt/myapp/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 (только localhost — наружу смотрит nginx), EnvironmentFile=/etc/myapp/env, Restart=on-failure, RestartSec=3, After=network-online.target postgresql.service, Wants=network-online.target, плюс NoNewPrivileges/ProtectSystem=strict/ReadWritePaths=/var/lib/myapp. enable --now — и сервис переживает перезагрузки сервера и падения процесса. Диагностика после деплоя: status (active/running), journalctl -u myapp -n 50 (старт без трейсбеков), curl health-эндпоинта через localhost. Обновление — плейбук деплоя: pull, миграции, restart — и проверка health; откат — предыдущий тег и restart. Через месяц эксплуатации видно главное отличие от «nohup python app.py &»: после ночного рестарта сервера всё поднялось само, а упавший по OOM процесс был перезапущен за три секунды с записью в журнале.
FAQ
Чем user-юниты (~/.config/systemd/user) отличаются от системных?
Работают от пользователя без root, но по умолчанию умирают при его выходе из сессии (лечится loginctl enable-linger user) и не могут слушать привилегированные порты. Для личных демо-сервисов на своей машине удобны; для прода — системные юниты.
Почему ExecStart с относительным путём или переменными не работает?
systemd не запускает shell: нет PATH пользователя, подстановок $VAR и glob'ов в ExecStart. Нужны абсолютные пути, а переменные — через Environment/EnvironmentFile (или ExecStart=/bin/sh -c '...', если действительно нужен shell — с потерей части преимуществ).
Как передать приложению несколько окружений (dev/prod) на одной машине?
Разные юниты с разными EnvironmentFile (myapp@dev.service через шаблон-юнит myapp@.service с %i в пути к env) — systemd-шаблоны созданы для этого; один юнит с «переключателем» — путь к путанице.
Что делать, если служба «activating (auto-restart)» по кругу?
Смотреть journalctl -u myapp — почти всегда это ошибка старта (неверный путь, недоступная БД, битый env-файл): приложение падает, Restart=on-failure поднимает, снова падает. После починки цикл прерывается сам; systemctl reset-failed myapp сбрасывает счётчик, если systemd ушёл в rate-limit рестартов.
Источники: man systemd.service, systemd.exec, journalctl (freedesktop.org) — ссылки в шапке.