Когда нужно запустить свой сервис на Linux‑хосте, выбор небольшой: либо systemd unit‑файл, либо что‑то из эры до systemd — init‑скрипт, supervisord, screen, nohup. На современных дистрибутивах (Ubuntu 22.04+, Debian 12, RHEL 9+, Amazon Linux 2023) systemd — стандарт по умолчанию, и причины для других вариантов остаются только в legacy‑проектах.

Сам unit‑файл выглядит просто: пара десятков строк в формате INI с тремя секциями. Но за этой простотой прячется приличное количество нюансов — какой Type= выбрать, как настроить рестарт, чтобы он не превратился в спам, какой набор security‑параметров включить, чтобы сервис не имел доступа в /etc и /home без необходимости, и как корректно интегрировать с journald.

К весне 2026 актуальная версия systemd — 258 (релиз март 2026), но базовые конструкции для unit‑файлов стабильны последние лет десять и работают одинаково на всех современных дистрибутивах. В статье разбор того, как написать unit‑файл для своего сервиса с нуля, и какие параметры стоит выставлять не как «скопировал с примера», а с пониманием.

Минимальный unit‑файл

Допустим, у вас есть Spring Boot приложение, упакованное в jar, которое нужно запускать как systemd‑сервис. Создаём файл /etc/systemd/system/api.service:

[Unit]
Description=API service
After=network.target

[Service]
Type=simple
User=api
WorkingDirectory=/opt/api
ExecStart=/usr/lib/jvm/java-25/bin/java -jar /opt/api/app.jar
Restart=on-failure

[Install]
WantedBy=multi-user.target

Команды для активации и проверки:

sudo systemctl daemon-reload    # после изменения unit-файла
sudo systemctl enable --now api
sudo systemctl status api
sudo journalctl -u api -f

Это минимально работающий вариант — сервис запускается при старте системы, перезапускается при падении, логи идут в journald. Но в рабочей конфигурации к этому скелету добавляется ещё пять‑семь блоков, без которых сервис либо работает не так, как ожидалось, либо открывает дыры в безопасности.

Type=: выбор модели запуска

systemd различает несколько типов сервисов, и от выбора зависит, как systemd понимает, что сервис стартанул и продолжает работать. Основные варианты:

  • Type=simple (дефолт) — самый простой и в большинстве случаев правильный. systemd считает сервис стартовавшим сразу после fork() и запуска ExecStart, не дожидаясь никакого сигнала «я готов». Подходит для приложений, которые становятся полезными сразу после запуска бинаря: большинство Spring Boot и Node.js приложений сюда попадают.

  • Type=exec — похож на simple, но systemd дожидается, пока бинарь действительно запустится через execve(). Разница тонкая и редко важная, но если ExecStart использует обёртки или скрипт, который потом запускает реальный бинарь, Type=exec даёт более точный момент «стартовало».

  • Type=notify — systemd ждёт от сервиса явного сигнала готовности через sd_notify(READY=1). Это самый правильный вариант для приложений, которые после запуска бинаря ещё минуту прогревают кеш, подключаются к базе или строят индексы.

  • Type=forking — для legacy‑демонов, которые сами форкаются в background после старта. Нужен ещё PIDFile=, чтобы systemd понимал, какой процесс отслеживать.

  • Type=oneshot — для одноразовых задач, которые отрабатывают и завершаются.

В сухом остатке: для нового Java/Python/Go‑сервиса берите simple. Если приложение умеет sd_notify — notify. Forking — только если поддерживаете старый daemon, который иначе не работает.

ExecStart, ExecStartPre, ExecStop

ExecStart — главная команда сервиса. Должна быть абсолютным путём, никаких cd или && в shell‑стиле — это exec‑syntax, а не shell. Если нужны переменные окружения, они задаются отдельно через Environment= или EnvironmentFile=, а не через export X=Y в строке.

ExecStart=/usr/lib/jvm/java-25/bin/java -Xmx2g -jar /opt/api/app.jar

Если нужно что‑то сделать перед стартом — создать директорию, проверить конфиг, дождаться готовности базы — есть ExecStartPre:

ExecStartPre=/bin/mkdir -p /var/cache/api
ExecStartPre=/usr/bin/test -f /opt/api/app.jar
ExecStart=/usr/lib/jvm/java-25/bin/java -jar /opt/api/app.jar

Несколько ExecStartPre выполняются последовательно. Если любой завершится с ненулевым кодом, сервис не стартует. Можно поставить - перед командой, чтобы игнорировать её ошибку — ExecStartPre=-/bin/mkdir -p ..., но обычно лучше явная ошибка, чем тихое игнорирование.

ExecStop — команда для остановки. По умолчанию systemd отправляет SIGTERM, ждёт TimeoutStopSec (по умолчанию 90 секунд), потом отправляет SIGKILL. Своя ExecStop нужна редко — обычно SIGTERM достаточно. Если Java‑сервис требует graceful shutdown подольше, увеличьте TimeoutStopSec, но не отключайте SIGKILL fallback, иначе зависший процесс будет висеть вечно.

ExecReload — команда для перезагрузки конфига без рестарта. Для nginx это nginx -s reload, для большинства Java‑сервисов перезагрузка не поддерживается, и ExecReload просто опускается.

Restart=on‑failure и связанные параметры

Restart=on-failure
RestartSec=5
StartLimitBurst=5
StartLimitIntervalSec=120

Параметр Restart определяет, когда сервис нужно перезапускать. Варианты:

  • no — никогда (дефолт).

  • on-success — если завершился штатно (exit code 0).

  • on-failure — если упал с ошибкой, это самый частый выбор.

  • on-abnormal — на ошибки, сигналы, тайм‑ауты, но не на штатный exit.

  • always — после любого завершения, полезно для daemons с плановым рестартом.

RestartSec=5 — задержка между падением и попыткой запуска. Слишком маленькое значение под нагрузкой создаёт каскадный отказ: сервис падает, мгновенно стартует, не успевает прогреться, опять падает..

StartLimitBurst=5 и StartLimitIntervalSec=120 — защита от бесконечного цикла рестартов. Если сервис упал 5 раз за 120 секунд, systemd прекращает попытки и отмечает его как failed. Дальше уже нужен мониторинг, который заметит это состояние и алертит.

User, Group, WorkingDirectory, Environment

Сервис никогда не должен запускаться от root, если только это не реально нужно (биндинг на порт <1024 без CAP_NET_BIND_SERVICE — пример исключения). Стандартный подход — создать системного пользователя с минимальными правами:

sudo useradd --system --no-create-home --shell /usr/sbin/nologin api
sudo chown -R api:api /opt/api

И в unit‑файле:

User=api
Group=api
WorkingDirectory=/opt/api

WorkingDirectory задаёт текущую директорию для процесса. Если приложение использует относительные пути для конфигов или данных, это важно. Если все пути абсолютные, можно опустить.

Environment и EnvironmentFile задают переменные окружения:

Environment=SPRING_PROFILES_ACTIVE=prod
Environment=JAVA_OPTS=-Xmx2g
EnvironmentFile=-/etc/api/env

EnvironmentFile=-/etc/api/env (с минусом перед путём) означает, что файл опциональный — его отсутствие не ломает старт. Файл содержит пары KEY=value по одной на строку, без export:

DB_PASSWORD=secret
REDIS_HOST=redis.internal

Главное правило — секреты (пароли, ключи, токены) кладутся в EnvironmentFile с правами 0600 на пользователя сервиса, а не в сам unit‑файл. Unit‑файл обычно версионируется в git, а секреты там быть не должны. Альтернатива — LoadCredential= (systemd 250+), который интегрируется с системами вроде systemd‑creds или HashiCorp Vault и подгружает секреты в момент старта без записи на диск.

Security hardening: главные параметры

systemd предлагает обширный набор security‑параметров, изолирующих сервис от системы. Включать их имеет смысл всем сервисам, не только тем, что смотрят наружу, даже внутренние демоны могут стать вектором атаки при компрометации соседнего сервиса, и hardening снижает blast radius при инциденте.

Минимальный набор для типового сервиса:

[Service]
# ...
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
LockPersonality=true
RestrictRealtime=true
RestrictSUIDSGID=true
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

Что делает каждый параметр:

  • NoNewPrivileges=true — процесс не может через execve или setuid получить дополнительные привилегии.

  • ProtectSystem=strict — большая часть файловой системы становится read‑only для процесса. Записывать можно только в /tmp (если PrivateTmp=true) и в специально разрешённые директории. Если сервису нужно писать в /var/lib/api, надо добавить:

ReadWritePaths=/var/lib/api
  • ProtectHome=true — /home, /root, /run/user становятся недоступны вообще.

  • PrivateTmp=true — сервис получает свой собственный /tmp и /var/tmp, изолированный от других сервисов.

  • PrivateDevices=true — недоступны устройства в /dev, кроме базовых (null, zero, random, tty).

  • ProtectKernelTunables=true — /proc/sys и /sys становятся read‑only.

  • ProtectKernelModules=true — нельзя загружать или выгружать kernel modules.

  • ProtectControlGroups=true — /sys/fs/cgroup становится read‑only, сервис не может менять собственные cgroup‑лимиты или влиять на чужие.

  • RestrictNamespaces=true — сервис не может создавать новые namespaces.

  • SystemCallFilter=@system‑service — сервис может вызывать только системные вызовы из набора, типичного для серверных приложений.

Несколько дополнительных параметров, которые имеет смысл включать для большинства сервисов:

  • RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 — ограничивает доступные сетевые семейства. Сервис может работать с TCP/UDP (AF_INET/AF_INET6) и unix‑сокетами (AF_UNIX), но не сможет открыть raw‑сокет, netlink, bluetooth, packet.

  • MemoryDenyWriteExecute=true — запрет на одновременно writable+executable страницы памяти. Это ломает классические эксплоиты с шеллкодом в данных. Для Java и других языков с JIT‑компиляцией это включать НЕ стоит, JIT именно так и работает, mark‑and‑execute страниц. Для нативных сервисов на C/Go/Rust без JIT включается без последствий.

  • ProtectClock=true — нельзя менять системное время.

  • LockPersonality=true — запрет на смену execution personality через personality(2).

  • DynamicUser=true — альтернатива ручному созданию пользователя. systemd создаёт случайный UID/GID на время работы сервиса, освобождает после остановки. Требует StateDirectory=, LogsDirectory=, CacheDirectory= для постоянных данных, потому что обычный chown не подойдёт. Не для всех сервисов работает, но для простых случаев заметно упрощает onboarding нового сервиса:

DynamicUser=true
StateDirectory=api
LogsDirectory=api

systemd сам создаст /var/lib/api и /var/log/api, выставит правильные права, и сервис увидит их как /var/lib/private/api и /var/log/api. После удаления unit‑файла директории остаются на диске, но без активного пользователя.

После включения hardening обязательно прогнать:

systemd-analyze security api.service

Команда даёт оценку от 0.0 (максимально защищён) до 10.0 (максимально открыт) и список параметров, которые ещё можно подкрутить для усиления изоляции.

Dependencies: After, Wants, Requires

Раздел [Unit] задаёт зависимости. Главные параметры:

  • After= — порядок старта. Сервис стартует после указанных, но это не значит, что указанные обязаны быть запущены, только что они стартанули раньше, если присутствуют.

  • Wants= — мягкая зависимость. Указанные сервисы стартуют вместе с этим, но если они не запустились, это не блокирует запуск зависящего.

  • Requires= — жёсткая зависимость. Если указанный сервис упал, этот сервис тоже останавливается.

  • BindsTo= — ещё жёстче Requires: рестарт указанного сервиса вызывает рестарт этого. П

Типичные значения для веб‑сервиса, который ходит в Postgres:

[Unit]
Description=API service
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service

network-online.target — встроенный target, который сигнализирует «сеть полностью готова», в отличие от network.target (просто «сетевой стек поднят»). Для большинства веб‑сервисов имеет смысл именно network‑online, иначе сервис может стартовать раньше, чем поднялся интерфейс, и упасть на попытке подключиться к базе по DNS.

Отдельный момент — раздел [Install]:

[Install]
WantedBy=multi-user.target

Это говорит systemd, что при systemctl enable api сервис будет запускаться при достижении multi-user.target — стандартного состояния «система работает». Без [Install] и WantedBy сервис не автостартует даже после enable, что часто бывает источником непонимания: «команда сработала без ошибок, а после ребута сервис не поднялся».

Resource limits

MemoryMax=2G
MemoryHigh=1.5G
CPUQuota=200%
LimitNOFILE=65536
OOMScoreAdjust=-100
  1. MemoryMax — жёсткий лимит памяти, при превышении OOM killer убьёт процесс. Это страховка от утечки или раздувания heap.

  2. MemoryHigh — мягкий лимит, при превышении systemd начинает усиливать throttling, но не убивает. Полезно как раннее предупреждение о росте до того, как сработает MemoryMax.

  3. CPUQuota — лимит на CPU. 200% значит «не больше двух полных ядер», 100% — одно ядро, 50% — пол‑ядра. Это процент от одного ядра, а не от всей системы.

  4. LimitNOFILE=65536 — максимальное число открытых файловых дескрипторов. Дефолт в Linux обычно 1024, что для веб‑сервиса с тысячами соединений мало: каждое TCP‑соединение — один дескриптор, плюс файлы, плюс сокеты.

  5. OOMScoreAdjust — приоритет при OOM killer. Значения от -1000 (никогда не убивать) до 1000 (убивать в первую очередь). Иногда имеет смысл поставить -100 или -200, чтобы при OOM в первую очередь ушли менее важные процессы вроде локальных утилит, а не основной сервис.

Логирование через journald

По умолчанию stdout и stderr сервиса попадают в journald — родной логгер systemd.

Команды для просмотра:

journalctl -u api                  # все логи сервиса
journalctl -u api -f               # follow, как tail -f
journalctl -u api --since "10 min ago"
journalctl -u api -p err           # только ошибки и выше
journalctl -u api -o json-pretty   # JSON с метаданными
journalctl -u api --since today -p warning..err

Если хочется перенаправить логи в файл (например, для долгого хранения, отдельно от journald), есть варианты:

StandardOutput=append:/var/log/api/app.log
StandardError=append:/var/log/api/app.log

Но обычно лучше оставить journald и прокинуть его в централизованный logging stack (Loki, Elasticsearch, Splunk) через journald‑exporter, vector или promtail. journald делает ротацию автоматически — по умолчанию ограничен ~10% размера диска, что обычно правильно: не нужно ставить logrotate и беспокоиться о том, чтобы лог не съел весь /var. Размер хранилища настраивается в /etc/systemd/journald.conf через SystemMaxUse=2G и SystemMaxFileSize=200M, если нужны другие лимиты.

Timers вместо cron

Для периодических задач в systemd стандарт — это таймеры, а не cron.

Таймер описывается двумя файлами. Сначала сама задача:

# /etc/systemd/system/backup.service
[Unit]
Description=Daily backup

[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/backup.sh

Затем таймер:

# /etc/systemd/system/backup.timer
[Unit]
Description=Daily backup timer

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=600

[Install]
WantedBy=timers.target

Активировать:

sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
sudo systemctl list-timers

OnCalendar=daily — это сокращние для «каждый день в 00:00». Полная грамматика — Mon..Fri 09:00, --* 03:30:00, *:0/15 (каждые 15 минут) описана в systemd.time(5).

Проверка выражений:

systemd-analyze calendar 'Mon *-*-* 03:00:00'

Покажет следующее срабатывание по календарному выражению.

Начиная с systemd 257 (декабрь 2024) появился параметр DeferReactivation=true. Если сервис всё ещё работает к моменту следующего срабатывания таймера, он не запустится повторно. Раньше для этого приходилось городить флаг‑файл, теперь решается одной строкой:

[Timer]
OnCalendar=*:0/5
DeferReactivation=true

Socket activation: сервисы, которые запускаются по требованию

Помимо обычных сервисов и таймеров, в systemd есть socket activation — модель, в которой сервис стартует только при первом подключении к его сокету.

Идея пришла из inetd, но в systemd сделана корректно: systemd сам открывает сокет на нужном порту и держит его, пока сервис не нужен. При первом подключении передаёт сокет в новый процесс через файловый дескриптор, сервис стартует и обслуживает соединение. Если в течение какого‑то времени новых соединений нет, можно автоматически останавливать сервис.

Описание socket‑юнита /etc/systemd/system/echo.socket:

[Unit]
Description=Echo server socket

[Socket]
ListenStream=8080
Accept=false

[Install]
WantedBy=sockets.target

И сам сервис /etc/systemd/system/echo.service:

[Unit]
Description=Echo server
Requires=echo.socket

[Service]
ExecStart=/usr/local/bin/echo-server
StandardInput=socket

Активация:

sudo systemctl daemon-reload
sudo systemctl enable --now echo.socket

После этого systemd слушает порт 8080. При первом подключении стартует echo-server и передаёт ему сокет через файловый дескриптор 3.

Итого

systemd unit‑файлы — это структурированный INI‑конфиг, в котором описаны параметры запуска, рестарта, окружения, безопасности и логирования сервиса. Сам формат стабилен годами и работает одинаково на всех современных дистрибутивах.

Главная ошибка при написании unit‑файла — копирование с примера без понимания.


Если вы только начинаете разбираться с Linux и пока не уверены в командах, файловой системе, правах доступа и базовой работе в терминале, начните с подготовительного курса «Linux для начинающих». Он поможет закрыть базу перед переходом к администрированию.

Бесплатные уроки по Linux и не только смотрите в дайджесте месяца.

Больше открытых уроков, подборок и других полезных материалов — в канале OTUS в MAX. Подписывайтесь, чтобы не пропустить новые вебинары, разборы и анонсы курсов.

Комментарии (3)


  1. Sap_ru
    01.06.2026 21:00

    Интересно: DynamicUse c StateDirectory/LogsDirectory/CacheDirectory после перезагрузки оставляет директории и файлы на диске без активного пользователя. Но что если система снова сосздаст динамического пользователя с тем же UID/GID (оно же динамическое)? Этот процесс/сервис получит доступ уже хранимым на диске дынным совсем другого процесса!
    Или как это работает?


  1. Granulex
    01.06.2026 21:00

    Один нюанс, который часто встречается в prod: After=network-online.target не гарантирует работающий DNS – target считается достигнутым, когда интерфейс получил IP, но не когда резолвер готов. На DHCP или после сетевого события сервис стартует, первый коннект к db.internal падает, Restart= подхватывает – и система выглядит живой, хотя первые 30 секунд работала вхолостую. Надёжнее: retry-loop в ExecStartPre или sd_notify с проверкой готовности внутри приложения.


    1. pda0
      01.06.2026 21:00

      Ну да, надо Required=named/dnsmasq/pdns добавлять, если DNS нужен.