У меня на 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), а не на боевом боте.