У меня на VPS живёт несколько мелких Python‑скриптов: один раз в минуту что‑то проверяет, другой по утрам собирает отчёт. Раньше они запускались через nohup python app.py & или в screen. Это работает до первой перезагрузки сервера или первого необработанного исключения. Потом скрипт тихо лежит, а узнаёшь об этом, когда кто‑то спросит, почему ничего не приходит.

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

Раскладка

/opt/myapp/            код и .venv
/opt/myapp/data/       единственная папка, куда скрипт пишет
/etc/myapp.env         секреты, chmod 600, владелец root

Скрипт работает от отдельного пользователя без shell:

sudo useradd --system --home-dir /opt/myapp --shell /usr/sbin/nologin myapp
sudo chown -R myapp: /opt/myapp/data

Файл /etc/myapp.env может принадлежать root: systemd читает EnvironmentFile сам, до того как сменить пользователя. Сам скрипт этот файл открыть не сможет, только получит переменные окружения.

Сервис с автоперезапуском

/etc/systemd/system/myapp.service:

[Unit]
Description=myapp worker
After=network-online.target
Wants=network-online.target
OnFailure=tg-notify@%n.service
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
User=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/.venv/bin/python app.py
EnvironmentFile=/etc/myapp.env
Environment=PYTHONUNBUFFERED=1
Restart=on-failure
RestartSec=5

NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/myapp/data

[Install]
WantedBy=multi-user.target

Что здесь важно:

  • Restart=on-failure перезапускает скрипт, если он завершился с ненулевым кодом или его убили сигналом. Если вы сами сделали systemctl stop, он не поднимется.

  • StartLimitIntervalSec и StartLimitBurst стоят в [Unit], а не в [Service]. Если скрипт упал 5 раз за 5 минут, systemd перестаёт его поднимать и переводит в состояние failed. Без этого скрипт с ошибкой в конфиге будет крутиться в цикле бесконечно.

  • PYTHONUNBUFFERED=1 нужен, чтобы print и логи попадали в журнал сразу, а не пачкой при завершении.

  • Блок из ProtectSystem=strict и соседей делает файловую систему read‑only для процесса, кроме ReadWritePaths. Для скрипта, который ходит в чужие API, это дешёвая страховка.

Запуск и проверка:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp

Чтобы убедиться, что перезапуск работает, убейте процесс: sudo systemctl kill -s KILL myapp. Через 5 секунд systemctl status снова покажет active, а systemctl show myapp -p NRestarts вернёт 1.

Логи: просто пишите в stdout

Отдельные лог‑файлы с ротацией не нужны. Всё, что скрипт пишет в stdout и stderr, попадает в journald вместе с трейсбэками. Полезные команды:

journalctl -u myapp -f                   # смотреть в реальном времени
journalctl -u myapp --since "1 hour ago"
journalctl -u myapp -p err               # только ошибки
journalctl --disk-usage

Чтобы -p err работал, journald должен знать уровень строки. Он понимает префикс вида <3> в начале строки (это уровни syslog), поэтому достаточно маленького форматтера:

import logging
import sys

class JournalFormatter(logging.Formatter):
    """Префикс <N> journald превращает в уровень записи."""
    PRIORITY = {logging.DEBUG: 7, logging.INFO: 6, logging.WARNING: 4,
                logging.ERROR: 3, logging.CRITICAL: 2}

    def format(self, record):
        prio = self.PRIORITY.get(record.levelno, 6)
        return f"<{prio}>{record.name}: {super().format(record)}"

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JournalFormatter("%(message)s"))
logging.basicConfig(level=logging.INFO, handlers=[handler])

Время и имя хоста в формат добавлять не нужно, journald пишет их сам. Сам префикс в журнале не виден: log.error("...") попадает туда как обычная строка, но находится через -p err.

Чтобы журнал не съел диск и пережил перезагрузку, в /etc/systemd/journald.conf я ставлю:

[Journal]
Storage=persistent
SystemMaxUse=500M

и делаю sudo systemctl restart systemd-journald.

Алерт в Telegram, когда что‑то упало

OnFailure=tg-notify@%n.service в юните выше означает: если myapp перешёл в состояние failed, запусти сервис tg-notify@myapp.service. Это шаблон, %i внутри него превращается в имя упавшего юнита, поэтому один шаблон обслуживает все скрипты на сервере.

/etc/systemd/system/tg-notify@.service:

[Unit]
Description=Telegram alert for %i

[Service]
Type=oneshot
EnvironmentFile=/etc/tg-notify.env
ExecStart=/usr/local/bin/tg-notify %i

/usr/local/bin/tg-notify, только стандартная библиотека:

#!/usr/bin/env python3
"""Шлёт в Telegram имя упавшего юнита и хвост его логов."""
import os
import socket
import subprocess
import sys
import urllib.parse
import urllib.request

unit = sys.argv[1]
logs = subprocess.run(
    ["journalctl", "-u", unit, "-n", "20", "--no-pager", "-o", "cat"],
    capture_output=True, text=True,
).stdout
# лимит Telegram 4096 символов на сообщение, оставляем запас
text = f"? {socket.gethostname()}: {unit} упал\n\n{logs}"[:4000]

api = os.environ.get("TG_API", "https://api.telegram.org")
url = f"{api}/bot{os.environ['TG_TOKEN']}/sendMessage"
data = urllib.parse.urlencode({"chat_id": os.environ["TG_CHAT_ID"], "text": text}).encode()
urllib.request.urlopen(url, data=data, timeout=10)

В /etc/tg-notify.env лежат TG_TOKEN и TG_CHAT_ID. Токен выдаёт @BotFather. Чтобы узнать chat_id, напишите своему боту любое сообщение и откройте https://api.telegram.org/bot<TOKEN>/getUpdates: нужное число лежит в message.chat.id. Написать боту первым нужно обязательно, иначе он не сможет отправить вам сообщение.

В алерт попадает трейсбэк, поэтому часто причина видна прямо в Telegram, без захода на сервер.

Сколько будет алертов

Тут я наткнулся на неочевидную вещь. В systemd 255 при Restart=on-failure сервис между падением и перезапуском всё равно проходит через состояние failed, и OnFailure срабатывает на каждое падение, даже если systemd тут же поднимает процесс. Я проверил это на user‑юнитах: одно падение даёт один запуск tg-notify и успешный перезапуск, цикл из пяти падений даёт пять запусков.

Если вы хотите получать сообщение только тогда, когда systemd сдался, добавьте в [Service]:

RestartMode=direct

Эта опция появилась в systemd 254 (в Ubuntu 22.04 стоит 249, там её нет). С ней единичные падения перезапускаются молча, а алерт приходит один раз, когда сработал StartLimitBurst. Я оставляю режим по умолчанию: скрипт, который падает раз в час, тоже стоит заметить.

Таймеры вместо cron

Для задач по расписанию я использую таймеры systemd. Главная причина в том, что OnFailure работает и для них, а cron молча отправляет вывод упавшей задачи в локальную почту, которую никто не читает.

/etc/systemd/system/myapp-report.service:

[Unit]
Description=myapp daily report
OnFailure=tg-notify@%n.service

[Service]
Type=oneshot
User=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/.venv/bin/python report.py
EnvironmentFile=/etc/myapp.env

/etc/systemd/system/myapp-report.timer:

[Unit]
Description=Run myapp report every weekday

[Timer]
OnCalendar=Mon..Fri 09:00
Persistent=true
RandomizedDelaySec=120

[Install]
WantedBy=timers.target

Persistent=true запускает пропущенную задачу, если в 09:00 сервер был выключен. Включается таймер, а не сервис: sudo systemctl enable --now myapp-report.timer. Проверить расписание можно командой systemd-analyze calendar "Mon..Fri 09:00", а увидеть все таймеры и время следующего запуска — командой systemctl list-timers.

Чего эта схема не покрывает

Если упал сам сервер или пропала сеть, алерт отправить некому. Для этого нужна проверка снаружи: сервер раз в несколько минут отмечается в каком‑нибудь сервисе «пульса» (например, open‑source healthchecks, его можно поднять у себя на другой машине), и тот пишет вам, если отметки прекратились. Также systemd не заметит, что скрипт завис и не падает. Для этого есть WatchdogSec= и sd_notify, но это тема для отдельной статьи.

Для пары скриптов на одном VPS мне этой схемы хватает уже давно: при перезагрузке всё поднимается само, логи лежат в одном месте, а о каждом падении systemd сообщает через tg-notify. Перед тем как полагаться на алерты, отправьте себе тестовое сообщение: сам я проверял скрипт на локальной заглушке Bot API (для этого в нём есть переменная TG_API), а не на боевом боте.

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