Systemd для непривилегированного пользователя

Уже год я работаю девопсом в банке. Это первая работа за много лет, где у меня нет полных прав в системе, то есть, делать что-либо под учёткой root и пользоваться sudo я не могу. Одновременно с этим на серверах могут быть дополнительные ограничения, например, может не быть доступа к планировщику cron, даже к личному через crontab -e. Также, в число моих обязанностей входит развёртывание собранных приложений (в основном это запуск файлов .jar), соответственно, также под непривилегированными учётками. Часто люди пытаются запускать такие приложения в фоне прямо в консольной сессии:

nohup "java -jar be-app-project-${VERSION}.jar --server.port=9090 &"

А потом, чтобы остановить приложение, приходится искать его идентификатор процесса (PID), используя порой вот такие громоздкие конструкции (видел в одном пайплайне развёртывания):

ps -ef | grep be-app-project-*.jar | grep -v grep | awk '$1=="project" {print $2}' | xargs kill

Можно, конечно, для отлавливания PID пользоваться pgrep, что несколько улучшит вид вышеприведённой строки, но общей кривизны решения это не меняет, к тому же, если пытаться такие практики применять в системах CI/CD типа Gitlab или Teamcity, то раннер может не запустить фоновый процесс и будет бесконечно висеть или выдаст ошибку.

Решением всех этих проблем является systemd, сервисы, таймеры и прочие сущности (юниты) которого будут работать в пространстве пользователя, т. е., для управления ими не требуется никакого повышения привилегий. Более того, отпадёт необходимость ухищряться в фоновом запуске приложения из консольной сессии и последующего отлавливания PID. Да и в целом грех не использовать systemd — это современная, гибкая и удобная система, основными препятствиями к освоению которой являются косность мышления, предубеждение против новых решений и нежелание учиться новому. С этим надо бороться по мере сил. С запуском непривилегированных юнитов есть один небольшой нюанс, о нём ниже.

Начнём с замены крона, опишу самый минимум. Предположим, каждые 3 минуты нужно запускать скрипт, который будет добавлять текущее время в текстовый файл. Создаём сам скрипт, например, по пути /home/user/scripts/add-time.sh, в котором обязательно нужно указать шебанг, чтобы скрипт гарантированно ассоциировался с bash.

#!/bin/bash
date +'%F %T' >> ~/add-time.txt

Затем сделать его исполняемым.

user@k3:~$ chmod u+x /home/user/scripts/add-time.sh

Теперь приступим к созданию службы, или сервиса, или демона. Все команды systemctl нужно будет запускать с параметром --user, что указывает на работу не c системными, а с пользовательскими юнитами. Следующая команда создаёт сервис add-time.service в каталоге ~/.config/systemd/user, где будут храниться все сервисы, таймеры и прочие юниты пользователя. Если этого каталога нет, он будет создан автоматически. Эту же команду можно использовать и для редактирования уже существующего юнита.

user@k3:~$ systemctl edit add-time --user --full --force

# Вставить в открывшийся редактор следующий текст и выйти с сохранением.
# В ExecStart= указывается абсолютный путь к скрипту. Скрипт должен быть исполняемым, иначе не заработает.
[Service]
ExecStart=/home/user/scripts/add-time.sh

Удобство команды systemctl edit в том, что после неё не нужно перечитывать список служб командой systemctl --user daemon-reload. Теперь создаём таймер, запускающий одноимённую службу с заданной периодичностью.

user@k3:~$ systemctl edit add-time.timer --user --full --force

# Вставить в открывшийся редактор следующий текст и выйти с сохранением.
[Timer]
OnCalendar=*:0/3

[Install]
WantedBy=timers.target

Таймер в systemd исключительно гибкий, он более функционален, чем cron. Например, можно запускать пропущенные задания, если сервер в нужный момент не работал, запускать задания через определённое время после запуска системы или в зависимости от активности/неактивности сервиса, настраивать точность срабатывания таймера для распределения нагрузки, когда в одно и то же время задание начинается на многих серверах и т. д. Секция [Install] здесь нужна для того, чтобы таймер можно было прописать в автозагрузку, т. е. он продолжит срабатывать после перезапуска сервера.

Теперь запустим таймер, пропишем его в автозагрузку и проверим его состояние. Видно, что таймер запускает сервис add-time.service и до его запуска осталась 1 мин 48 сек.

# Запуск
user@k3:~$ systemctl --user start add-time.timer
# Автозагрузка
user@k3:~$ systemctl --user enable add-time.timer
Created symlink /home/user/.config/systemd/user/timers.target.wants/add-time.timer → /home/user/.config/systemd/user/add-time.timer.
# Статус
user@k3:~$ systemctl --user status add-time.timer
● add-time.timer
     Loaded: loaded (/home/user/.config/systemd/user/add-time.timer; enabled; vendor preset: enabled)
     Active: active (waiting) since Wed 2025-04-16 15:46:08 MSK; 3s ago
    Trigger: Wed 2025-04-16 15:48:00 MSK; 1min 48s left
   Triggers: ● add-time.service

Apr 16 15:46:08 k3 systemd[952]: Started add-time.timer.

Через некоторое время видно результат в текстовом файле:

user@k3:~$ cat add-time.txt
2025-04-16 15:48:43
2025-04-16 15:51:43
2025-04-16 15:54:43
2025-04-16 15:57:43
2025-04-16 16:00:43
2025-04-16 16:03:43

Теперь про тот небольшой нюанс запуска пользовательских непривилегированных сервисов systemd, о котором я упоминал. Дело в том, что такие сервисы работают только тогда, когда пользователь работает в системе. Если он не зашёл, его сервисы не работают. К счастью, можно включить возможность выполнения сервисов пользователя вне зависимости от его логина. Сделать это нужно один раз, но команда включения при повторном применении ошибок не даёт, поэтому её можно спокойно использовать без предварительной проверки, например, в плейбуках Ansible.

# "Linger: no" - службы работать без входа в систему не будут
user@k3:~$ loginctl user-status
user (1000)
           Since: Sat 2025-04-12 12:24:26 MSK; 4 days ago
           State: active
        Sessions: *2
          Linger: no
    [...]

# Включить для текущего пользователя
user@k3:~$ loginctl enable-linger $USER

# Теперь порядок
user@k3:~$ loginctl user-status
user (1000)
           Since: Sat 2025-04-12 12:24:26 MSK; 4 days ago
           State: active
        Sessions: *2
          Linger: yes
    [...]

Ну а что касается развёртывания приложений, то там просто делается юнит-файл сервиса и кладётся в нужное место любым удобным способом, например, тем же Ansible, используя шаблоны, когда можно заполнять какие-то части файла из переменных или по условию.

[Unit]
Description=Project App Backend {{ backend_maven_version }}

[Service]
Restart=always
Environment=REPORT_PATH={{ backend_report_directory }}
ExecStart=/usr/bin/java -Xrs -jar {{ backend_lib_directory }}/be-app-project-{{ backend_maven_version }}.jar

[Install]
WantedBy=multi-user.target

Шаги копирования шаблона и запуска службы в плейбуке.

- name: "Copy unit file"
  template:
    src: "{{ service_name }}.service.j2"
    dest: "~/.config/systemd/user/{{ service_name }}.service"

- name: "Start service"
  systemd:
    scope: user
    daemon_reload: true
    name: "{{ service_name }}"
    state: started
    enabled: true

Теперь можно просто и удобно работать со службами systemd (которые, к слову, могут и перезапускаться сами в случае сбоя, быть зависимыми друг от друга и много чего ещё) и не вспоминать про ловлю процессов и висящие в фоне терминалы, и всё это безо всяких привилегий root.