Гайд «как сделать unit systemd» существует в тысяче экземпляров, и все они заканчиваются на systemctl enable --now myapp. После этого сервис запускается при загрузке — и на этом его живучесть заканчивается.
Живучесть — это другое. Это ответ на четыре вопроса: что происходит, когда процесс упал; что происходит, когда он не упал, но съел всю память; что происходит с его фоновыми задачами при остановке; и как вы вообще узнаете, что что-то из этого случилось. Ниже — unit, который на эти вопросы отвечает, и разбор каждой строки, которая в нём не декоративна.
#Что понадобится
- Дистрибутив с systemd (все актуальные общего назначения; версия systemd влияет на доступность отдельных директив — проверяйте
systemctl --versionи справку своей системы). - Отдельный системный пользователь под сервис. Запуск от
root— самая частая и самая дорогая экономия времени. - Приложение, которое запускается одной командой, не требует интерактива и пишет логи в стандартный вывод, а не в свой файл.
- Абсолютные пути ко всему: интерпретатору, рабочему каталогу, каталогу данных.
Последний пункт стоит отдельного предупреждения. Приложение, читающее конфигурацию «из текущего каталога», под systemd получит другой текущий каталог и другой домашний каталог. Классический исход — сервис поднялся, ответил 200 и работает с пустой базой, которую только что создал рядом. Данные при этом целы, просто приложение смотрит не туда.
#Шаг 1. Минимальный unit, от которого отталкиваемся
Файл /etc/systemd/system/persona.service:
[Unit]
Description=Personal AI service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=persona
Group=persona
WorkingDirectory=/opt/persona
Environment=PERSONA_DATA_DIR=/var/lib/persona
EnvironmentFile=-/etc/persona/env
ExecStart=/opt/persona/.venv/bin/python -m uvicorn app.web.main:app --host 127.0.0.1 --port 8000
[Install]
WantedBy=multi-user.target
Две детали, которые не декоративны уже здесь. After=network-online.target вместо network.target: первый означает «сеть реально поднялась и адрес есть», второй — «подсистема сети инициализирована», чего для исходящих соединений мало. И EnvironmentFile=-/etc/persona/env с дефисом: дефис означает «отсутствие файла не ошибка», без него сервис не стартует, пока файла нет.
Секреты кладутся в этот EnvironmentFile с правами 0600, а не в сам unit: unit читается любым пользователем системы.
#Шаг 2. Перезапуск при падении — и предохранитель от цикла
Restart=always
RestartSec=5
StartLimitIntervalSec=300
StartLimitBurst=5
Restart=always поднимает процесс после любого выхода, включая нулевой код. Restart=on-failure мягче, но у веб-сервера нет сценария «корректно завершился сам», так что always честнее.
Пара StartLimitIntervalSec / StartLimitBurst — это тот предохранитель, который обычно забывают. Без него сервис, падающий на старте из-за опечатки в конфиге, будет перезапускаться вечно, забивая журнал и создавая иллюзию активности. С ним после пяти падений за пять минут systemd переводит юнит в состояние failed и останавливается — и вы увидите честное «сервис лежит» вместо «сервис постоянно перезапускается, наверное само пройдёт».
Обратная сторона: из состояния failed сервис сам не вернётся. Поэтому systemctl is-failed обязан попасть в мониторинг — иначе вы обменяли шумный отказ на тихий. Какие ещё проверки нужны сервису без дежурной смены, разобрано в статье про мониторинг личного сервиса.
#Шаг 3. Потолок памяти вместо ночного OOM
ИИ-приложение — плохой сосед по памяти. Загруженная модель, кэш эмбеддингов, разросшийся пул соединений: рост обычно не мгновенный, а плавный, поэтому обнаруживается ночью, когда убийца из-за нехватки памяти выбирает жертву на своё усмотрение — и это может оказаться база данных, а не ваш процесс.
MemoryMax=3G
MemoryHigh=2500M
TasksMax=512
MemoryHigh — мягкий порог: при его превышении ядро начинает агрессивно давить процесс, но не убивает. MemoryMax — жёсткий: превышение означает смерть вашего процесса, а не соседнего. Это и есть смысл настройки: вы заранее выбираете жертву. TasksMax ограничивает число потоков и процессов — страховка от библиотеки, решившей поднять пул по числу ядер на каждый запрос.
Конкретные значения выставляются от замера, а не из статьи: посмотрите systemctl status под нагрузкой и возьмите запас порядка трети сверху.
#Шаг 4. Корректная остановка фоновых задач
Это место, где обычный unit тихо портит данные.
По умолчанию systemctl stop шлёт SIGTERM главному процессу, ждёт TimeoutStopSec (в типовой конфигурации — полторы минуты) и добивает SIGKILL. Если у вашего приложения есть фоновые воркеры — распознавание текста, эмбеддинги, ночная обработка, — то при KillMode=control-group (значение по умолчанию) сигнал уйдёт всей группе, и это как раз хорошо. Плохо другое: воркер в середине транзакции получает SIGKILL через полторы минуты, а пакетная обработка может идти дольше.
KillMode=mixed
KillSignal=SIGTERM
TimeoutStopSec=90
KillMode=mixed шлёт SIGTERM только главному процессу, а SIGKILL — всей группе. Это даёт приложению шанс самому корректно погасить воркеров и закрыть транзакции. Условие работоспособности: приложение действительно обрабатывает SIGTERM и не игнорирует его.
Проверяется это ровно одним способом — остановить сервис под нагрузкой и посмотреть, что в журнале: строки корректного завершения воркеров или обрыв на полуслове. Если журнал не даёт ответа, значит логов не хватает; минимальный набор полей, по которому такие вещи вообще расследуются, — в статье про структурированные логи.
#Шаг 5. Ужать права
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/persona
ProtectSystem=strict делает всю файловую систему доступной только на чтение, кроме явно перечисленного в ReadWritePaths. Это не «безопасность ради безопасности»: чаще всего эта директива ловит не злоумышленника, а собственную ошибку — приложение, которое пытается писать временные файлы туда, где им не место, падает на стенде, а не в проде через полгода.
#Проверка результата
Проверять надо не «сервис запустился», а четыре свойства по отдельности.
sudo systemctl daemon-reload
sudo systemctl enable --now persona.service
# 1. Работает и слушает свой порт
systemctl is-active persona.service
ss -ltnp | grep 8000
# 2. Переживает падение: убить главный процесс и убедиться, что вернулся
sudo systemctl kill -s SIGKILL persona.service
sleep 8 && systemctl is-active persona.service # ждём: active
# 3. Ограничения реально применились
systemctl show persona.service -p MemoryMax -p TasksMax -p Restart
# 4. Возвращается после перезагрузки
sudo reboot # после входа: systemctl is-active persona.service
Критерий приёмки для четвёртого пункта — не «сервис запущен», а «сервис отвечает по HTTP и отдаёт нужную версию сборки». Открытый порт не доказывает, что за ним свежий код: осиротевший процесс с прошлой версии держит сокет и выглядит абсолютно живым. Ручка живости должна отдавать версию, и деплой должен её сверять — на Windows этот же провал выглядит ещё злее и разобран в статье про автозапуск на Windows.
#Что может пойти не так
Сервис в состоянии failed и не поднимается. Смотрите systemctl status и journalctl -u persona -n 100 --no-pager. Если причина — start-limit-hit, сервис исчерпал лимит перезапусков: сначала чините причину падений, потом systemctl reset-failed persona.
Приложение стартовало с пустой базой. Проверьте WorkingDirectory и переменную с путём к данным в systemctl show persona -p Environment. Почти всегда виноват относительный путь.
Логи не пишутся никуда. Приложение пишет в свой файл при работающем journald, либо наоборот. Пусть пишет в стандартный вывод, а journalctl разбирается дальше.
Пользовательский unit умирает вместе с сеансом. Если вы положили сервис в ~/.config/systemd/user/, он останавливается при выходе пользователя, пока для учётной записи не включён «задержанный» режим (loginctl enable-linger). Для сервиса, который должен работать всегда, надёжнее системный unit.
После обновления кода сервис отдаёт старое. systemctl restart перезапускает процесс, но не пересобирает окружение. Если у вас сборка фронтенда или кэш зависимостей — они не в зоне ответственности systemd. То же касается и контейнерного варианта — там своя механика, разобранная в гайде про запуск Persona в контейнере.
#Частые вопросы
#Чем Restart=always отличается от Restart=on-failure?
on-failure перезапускает только при ненулевом коде выхода или сигнале, always — при любом завершении. Для веб-сервера, у которого нет легитимного сценария самостоятельного завершения, always точнее описывает намерение. Для одноразовой задачи — наоборот, вредно.
#Нужен ли отдельный пользователь под сервис?
Да, и это дешевле любых других мер. Отдельный пользователь плюс ProtectSystem=strict ограничивают ущерб от произвольной ошибки в коде или зависимости. Запуск от root делает каждую такую ошибку потенциально фатальной для всей машины.
#Как задать секреты, не показывая их всей системе?
Через EnvironmentFile с правами 0600, принадлежащий пользователю сервиса. Не через Environment= в unit-файле: его содержимое видно любому в выводе systemctl show.
#Почему сервис стартует раньше сети и падает?
Потому что network.target означает не то, что от него ждут. Нужен After=network-online.target вместе с Wants=network-online.target — и целевой юнит ожидания должен быть включён в системе, иначе зависимость ни на что не влияет.
#Можно ли ограничить процессор так же, как память?
Да, директивами вида CPUQuota. Но для личного сервиса это обычно не нужно: процессор он делит с самим собой, и ограничение только замедлит ответы. Память ограничивать стоит всегда, процессор — только когда на машине есть второй важный жилец.
#Достаточно ли systemd, чтобы сервис считался поднятым?
Нет. systemd отвечает за жизнь процесса, а не за корректность ответа и не за свежесть кода. Полный маршрут — от автозапуска через домен, прокси и мониторинг — описан в статье о том, как поднять свой ИИ-сервис.