open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 02 · 05

Надёжные CLI-скрипты: коды выхода, логирование, ретраи, сигналы

Скрипт, который крутится без присмотра в cron или CI, держится на четырёх контрактах: ненулевой код выхода при сбое, логи в stderr вместо print, ограниченные ретраи с backoff для временного I/O и атомарная запись, чтобы kill никогда не отдал обрезанный файл.

PY Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Ночной экспорт месяцами горел зелёным. Потом верхний API начал отдавать 500-е, и скрипт их «обработал» — тело было обёрнуто в try: ... except Exception: print("export failed"), и после печати управление свалилось с конца main, и процесс завершился. Завершился нулём. cron увидел код 0, пометил задачу успешной и не послал ни одного алерта. Дашборд снизу продолжал читать вчерашний файл, потому что сегодняшний так и не записался, и «вчерашний» постепенно стал «двухнедельной давности». Никого не разбудили пейджером. Сбой нашли, когда аналитик заметил, что дата модификации файла не двигалась четырнадцать дней. Каждый байт того инцидента предотвратим, и каждый фикс — это то, что скрипт без присмотра обязан делать по умолчанию: завершаться ненулём, логировать в stderr, ретраить временный сбой и писать атомарно.

Коды выхода: контракт с ОС

Каждая система автоматизации — cron, CI, Kubernetes, Makefile — делает одно допущение о твоём скрипте: код 0 значит «сработало», ненулевой — «нет». Когда ты нарушаешь этот контракт, вся цепочка алертов ломается вместе с ним. Процесс сообщает об успехе или сбое ровно одним числом — своим кодом выхода. 0 значит успех; что угодно ненулевое значит сбой. Это число не украшение — это единственное, на что смотрят cron, CI-раннеры, &&-цепочки, systemd и любой оркестратор, чтобы решить, запускать ли следующий шаг или поднимать алерт. backup.sh && upload.sh запустит upload, только если backup вышел с 0. CI помечает шаг красным только при ненуле. Так что код выхода и есть сигнал успеха, и ошибиться в нём — это и есть способ, которым упавшая задача рапортует зелёным.

import sys, logging

log = logging.getLogger("export")

def main() -> int:
    try:
        data = fetch_export()      # may raise on a 500
        write_atomic("/data/export.json", data)
        return 0                   # success — and only here
    except Exception:
        log.exception("export failed")  # ERROR + traceback to stderr
        return 1                   # FAILURE is now visible to cron/CI

if __name__ == "__main__":
    sys.exit(main())              # the return value becomes the exit code

Смертельный паттерн — обратный: поймать исключение, print сообщение и дать main свалиться с конца. Функция, свалившаяся с конца, возвращает None; sys.exit(None) завершается нулём. Ты только что сказал ОС, что задача удалась, тогда как она на самом деле упала. Либо дай исключению пробросить (Python завершится ненулём с трейсбэком), либо явно вызови sys.exit(1). Резервируй отдельные коды (2, 3, …) только если вызывающий реально ветвится на том, какой именно сбой — иначе 1 достаточно.

Почему это работает

Почему except Exception: print("error") с последующим проваливанием — один из самых опасных паттернов в скрипте без присмотра? Потому что он превращает сбой в УСПЕХ с точки зрения ОС: процесс выходит с 0, так что cron, CI и мониторинг все видят зелёное, ни один алерт не срабатывает, и сбой невидим, пока кто-то не заметит протухший вывод спустя дни. print к тому же ложится в stdout — часто отбрасываемый или, хуже, подмешанный в данные пайпа — без уровня и без таймстампа, так что теряется даже само сообщение. Надёжность требует, чтобы сбой был ГРОМКИМ и МАШИНОЧИТАЕМЫМ: логируй на ERROR в stderr и выходи ненулём, чтобы то, что следит за скриптом, реально узнало, что он упал.

Логирование, а не print: правило скрипта без присмотра

print пишет в stdout без уровня, без таймстампа и без контроля маршрутизации. В интерактивной сессии это нормально; в cron-задаче это невидимо — терминала нет, а stdout часто перехватывается как данные задачи или отбрасывается. Модуль logging даёт то, что нужно скрипту без присмотра: уровни (DEBUG/INFO/WARNING/ERROR), таймстампы и адресат, настроенный один раз. Два важных правила: диагностика идёт в stderr, реальный вывод — в stdout (чтобы нижестоящий | jq видел чистые данные, а не строки лога), а уровень задаётся флагом или переменной окружения, чтобы можно было поднять DEBUG, не правя код.

import logging, os, sys

logging.basicConfig(
    level=os.environ.get("LOG_LEVEL", "INFO"),
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
    stream=sys.stderr,            # diagnostics to stderr; stdout stays clean for data
)
log = logging.getLogger("export")

log.info("starting export run")          # routine progress
log.error("upstream returned 503")       # a problem worth alerting on
log.exception("unexpected failure")      # ERROR + full traceback, inside an except block

log.exception — то, за чем тянешься внутри блока except: он логирует на ERROR и прикрепляет трейсбэк, что ровно и нужно отправить в агрегатор логов. Когда видишь cron-задачу с print-ами для диагностики, спроси себя: куда реально уходит этот вывод и кто читает его в 3 часа ночи при сбое? print-отладка cron-задачи не даёт ничего grep-абельного; структурированные логи ищутся, отправляются и стыкуются прямо с observability.

Ретраи с backoff: переживаем временный I/O

Сетевые и API-вызовы падают временно: таймаут, 503, rate-limit. Надёжная реакция — не «сдаться» и не «ретраить вечно», а ограниченный ретрай с экспоненциальным backoff и джиттером. Жди ~1с, потом ~2с, потом ~4с, каждое со случайной добавкой, чтобы флот скриптов не ретраил в унисон и не затоптал восстанавливающийся сервис, вплоть до лимита попыток. Два жёстких ограничения: ретраить только идемпотентные или временные операции и уважать Retry-After, когда сервер его шлёт.

import time, random, logging

log = logging.getLogger("export")

def with_backoff(op, *, attempts=5, base=1.0, cap=30.0):
    for i in range(attempts):
        try:
            return op()
        except TransientError as e:           # ONLY transient/retryable errors
            if i == attempts - 1:
                raise                          # budget exhausted -> let it fail
            delay = min(cap, base * 2 ** i) + random.uniform(0, 1)  # backoff + jitter
            log.warning("attempt %d failed (%s), retrying in %.1fs", i + 1, e, delay)
            time.sleep(delay)

Асимметрия — в этом весь смысл. Ограниченный backoff превращает временный 503 в не-событие — третья попытка проходит, и никто не замечает. Неограниченный или мгновенный ретрай делает обратное: он молотит уже задыхающийся сервис, превращая мигание в аварию. А ретрай неидемпотентного POST (списать карту, послать письмо) может применить побочный эффект дважды — так что ретрай чтения и идемпотентные записи, а неидемпотентные операции сделай идемпотентными через ключ, прежде чем их ретраить.

Сигналы и атомарная запись: умираем чисто

Процесс без присмотра убивают. Оркестратор шлёт SIGTERM (сигнал ОС с просьбой завершиться корректно), чтобы его остановить; человек шлёт SIGINT (сигнал прерывания, он же Ctrl-C) через терминал. По умолчанию оба разматывают стек — но если ты на середине записи файла, дефолт оставляет обрезанный файл, удержанный лок или temp-каталог, забитый мусором. Две дополняющие защиты: обработать сигнал (или опереться на try/finally + контекстные менеджеры), чтобы сбросить буферы и прибраться, и никогда не писать настоящий файл на месте — пиши temp-файл, затем os.replace, который атомарен на POSIX-файловой системе. Kill в любой момент оставит либо старый целый файл, либо новый целый файл, но никогда полузаписанный.

import os, signal, tempfile, json

def write_atomic(path: str, obj) -> None:
    d = os.path.dirname(path) or "."
    fd, tmp = tempfile.mkstemp(dir=d)          # same filesystem -> rename is atomic
    try:
        with os.fdopen(fd, "w") as f:
            json.dump(obj, f)
            f.flush()
            os.fsync(f.fileno())               # durable before the swap
        os.replace(tmp, path)                  # atomic: readers see old OR new, never partial
    except BaseException:
        os.unlink(tmp)                         # killed mid-write -> no orphan temp
        raise

def handle_term(signum, frame):
    raise SystemExit(143)                      # turn SIGTERM into a clean unwind (128+15)

signal.signal(signal.SIGTERM, handle_term)     # finally/with blocks now run on stop

Поскольку os.replace атомарен, окна для kill, в котором можно было бы увидеть частичный файл, просто не существует. Соедини это с идемпотентностью — скрипт будет перезапущен (ретрай, ручной перезапуск, наложившийся тик cron), так что check-before-act, пиши атомарно и бери lockfile, чтобы отказать второму одновременному запуску. Идемпотентный скрипт можно безопасно запустить дважды; неидемпотентный портит состояние на втором.

Выбери лучший вариант

Ты владеешь ночным cron-экспортом, который ходит во флапающий верхний API и пишет JSON-файл, потребляемый дашбордом. Как сделать его надёжным, чтобы сбой никогда не был тихим, а kill никогда не отдал обрезанный файл?

Викторина

cron-скрипт ловит исключение, печатает 'export failed' и сваливается с конца main без вызова sys.exit. Какой вывод сделает cron и почему?

Викторина

Какие операции безопасно оборачивать в цикл ретрая с backoff и какую очистку скрипт обязан сделать, чтобы SIGTERM никогда не отдал испорченный вывод?

Вспомните перед уходом
  1. 01
    Объясни, почему catch-print-return-0 — самый опасный паттерн в скрипте без присмотра, и что именно делать вместо него.
  2. 02
    Пройди по четырём контрактам надёжности скрипта без присмотра: логирование, ретраи, сигналы и идемпотентность — с конкретным механизмом для каждого.
Итог

Скрипт, крутящийся без присмотра в cron или CI, судят по четырём контрактам, каждый из которых нарушил инцидент с тихим экспортом. Первый: код выхода — контракт с ОС: 0 — успех, ненуль — сбой, и cron, CI и &&-цепочки ветвятся только на этом числе — так что смертельный паттерн это поймать исключение, напечатать сообщение и свалиться с конца main, что возвращает None, выходит с 0 и рапортует сбой зелёным без алерта; фикс — дать исключению пробросить или sys.exit(1). Второй: используй logging, а не print: диагностика в stderr с уровнями и таймстампами, реальные данные в stdout, уровень из флага или окружения, и log.exception внутри except, чтобы отправить трейсбэк — print-отладка невидима в cron-задаче. Третий: ретрай временного I/O ограниченным экспоненциальным backoff плюс джиттер до потолка, но только для временных/идемпотентных операций и с уважением к Retry-After, потому что неограниченный или мгновенный ретрай затаптывает восстанавливающийся сервис, а ретрай неидемпотентного POST применяет дважды. Четвёртый: умирай чисто: обрабатывай SIGTERM и SIGINT (или используй try/finally и контекстные менеджеры), чтобы сбросить буферы и прибраться, и пиши в temp-файл, затем os.replace, чтобы kill в любой миг оставил старый или новый целый файл, но никогда обрезанный — и сделай скрипт идемпотентным через check-before-act, атомарную запись и lockfile, чтобы неизбежный перезапуск не обработал дважды и не испортил состояние. Вместе: падай громко, логируй в stderr, ретрай ограниченно, пиши атомарно. Теперь, когда встретишь cron-задачу, которая catch-print-return-0, — знаешь, какой именно тихий четырнадцатидневный сбой она прячет.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.