ESPN DeportesCómo las Valkyries fueron de la expansión hasta las Finales WNBAESPNFIFA president Infantino received undisclosed $1.7M World Cup paymentThe Jerusalem PostHerzog honors Israel Bonds for raising billions post-Oct 7 while mourning northern school tragedyInquirer300 kilos of trash collected in Sarangani Bay mangrove cleanupBollywood HungamaSadhu Tiwari to perform Nayyi Navelli song ‘Awadhiya Ke Raja’ at Ram Ki Paidi in AyodhyaRTP DesportoLens - Sporting CPDaily MaverickHow the Middle East conflict is reshaping the multipolar global orderRadio Times'It was jaw dropping!'The Hollywood Reporter‘Queen Silvia’ Ditches the Glass Slipper for Un-Cinderella Royal Romance3DNewsМарсоход NASA Perseverance побил рекорд пробега на другой планете — и останавливаться не собираетсяZDF heuteEntdecken Sie das ZDF-NachrichtenstudioBBC NewsMurder investigation after four people die in arson attack
The Daily Newsstand · Free, Always
Tuesday, October 13, 2026

Как я запускаю маленькие Python‑скрипты на VPS: systemd, логи, автоперезапуск и алерты в Telegram

Translate

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.