Надёжные CLI-скрипты: коды выхода, логирование, ретраи, сигналы
Скрипт, который крутится без присмотра в cron или CI, держится на четырёх контрактах: ненулевой код выхода при сбое, логи в stderr вместо print, ограниченные ретраи с backoff для временного I/O и атомарная запись, чтобы kill никогда не отдал обрезанный файл.
Ночной экспорт месяцами горел зелёным. Потом верхний 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 blocklog.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 никогда не отдал испорченный вывод?
- 01Объясни, почему catch-print-return-0 — самый опасный паттерн в скрипте без присмотра, и что именно делать вместо него.
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.