Как я запускаю маленькие Python‑скрипты на VPS: systemd, логи, автоперезапуск и алерты в Telegram
У меня на 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.targetPersistent=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), а не на боевом боте.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.