vegman.dev
Все статьи

Деплой, который не ломает бота: тесты до подъема, а не после

2026-04-23кейс

У меня система из двух контейнеров: веб-сервис и телеграм-бот с планировщиком. Код лежит внутри образа, а не в подключенном каталоге - то есть контейнер не смотрит в папку на сервере, где файл можно поправить на месте и перезапустить процесс. Из-за этого любая правка, даже в одну строку, означает пересборку образа. Точки «поправить на живую» просто нет.

Это звучит как неудобство, но на самом деле это подарок: раз выкладка все равно всегда одна и та же процедура, ее можно один раз выстроить так, чтобы сломанная версия физически не доезжала до пользователей. К концу статьи вы получите этот порядок шагов и поймете, почему быстрый путь в обход тестов обходится дороже, чем кажется в момент, когда вы его выбираете.

Соблазн быстрой выкладки

Быстрый путь - это одна команда: пересобрать и поднять. Она есть почти в любом проекте, и она обходит тесты целиком. Соблазн понятен: правка маленькая, полный цикл идет дольше, а «тут нечего ломать».

Проблема в том, что у бота нет страницы с ошибкой. Веб-сервис, который не поднялся, виден сразу - кто-то откроет и увидит. Бот, который не поднялся, выглядит как молчание: пользователь пишет и не получает ответа, а планировщик просто не делает работу в назначенное время. Никто не получает сообщение об ошибке, потому что сообщать некому - процесс не запустился.

Поэтому у меня быстрый путь существует, но он не режим работы, а исключение. Он нужен для срочной заплатки, когда что-то горит прямо сейчас. И он оставляет долг: после него обязательно прогоняется полный цикл. Если долг не отдать, вы получаете сервер, про который никто не знает, проходят ли на нем тесты, - и следующая выкладка будет ремонтировать сразу две проблемы вместо одной.

flowchart TB
  S1["1. Синхронизация файлов"] --> S2["2. Сборка образов"]
  S2 --> S3["3. Тесты в отдельном<br/>одноразовом контейнере"]
  S3 -->|"прошли"| S4["4. Подъем рабочих контейнеров"]
  S3 -->|"упали"| STOP["Стоп.<br/>Пользователи остаются<br/>на рабочей версии"]
  S4 --> H["Проверка после подъема:<br/>ловит «не поднялось»"]
Четыре шага строго в этом порядке: тесты стоят до подъема, а не после

Порядок, который защищает: тесты до подъема

Полный цикл - это четыре шага строго в таком порядке:

  • синхронизация файлов на сервер
  • сборка образов
  • прогон модульных тестов в отдельном одноразовом контейнере
  • подъем рабочих контейнеров

Одноразовый контейнер - это контейнер, который поднимается из свежесобранного образа, выполняет одну задачу и умирает, не оставаясь в работе. Тесты гоняются именно в нем: в той же среде, что и продакшн, но без последствий.

Весь смысл - в том, что третий шаг стоит перед четвертым. Если тесты падают, до подъема дело не доходит. На сервере продолжает работать предыдущая версия - та самая, которая вчера работала. Пользователь не видит ничего, потому что для него ничего и не произошло. Это принципиально другое поведение, чем «подняли, увидели ошибку, откатились»: между «подняли» и «увидели» всегда есть окно, и в этом окне бот молчит.

Почему именно до, а не после

Есть и вторая причина, менее очевидная. Мои тесты открывают ту же базу знаний, что и рабочий процесс. Когда я запускала их поверх уже поднятых контейнеров, возникала гонка - ситуация, когда два процесса лезут в один ресурс одновременно и результат зависит от того, кто успел первым. Тест мог упасть не потому, что код плохой, а потому, что в тот момент к базе обратился живой бот.

Падающий через раз тест - худший вид теста: ему перестают верить, а потом перестают смотреть. Порядок «тесты до подъема» убирает гонку без единой строчки кода: в момент прогона рабочие контейнеры еще не запущены, конкурировать не с кем.

Проверка после подъема и ее границы

После подъема скрипт ждет, пока сервис ответит, что готов. Здесь два правила, каждое из которых я узнала не из документации.

Ожидание должно быть ограничено по времени. Без ограничения скрипт выкладки однажды висел бесконечно - он ждал ответа, которого уже не будет. Хуже всего не то, что версия сломана, а то, что выкладка не заканчивается ни успехом, ни провалом, и вы не знаете, в каком состоянии система. Ограничение превращает зависание в честный отрицательный результат, с которым можно что-то делать.

Проверка готовности должна быть дешевой. У меня обработчик, отвечающий на вопрос «ты жив?», лез за статистикой базы. Пока база отвечала быстро, все было прекрасно. Когда база подвисала, обработчик подвисал вместе с ней, ожидание упиралось в ограничение по времени, и выкладка ложно считалась неудачной - хотя сервис был поднят и работал.

Проверка готовности отвечает ровно на один вопрос: процесс поднялся и принимает запросы. Она не отвечает на вопрос «все ли хорошо в системе». Как только вы начинаете подмешивать в нее содержательные проверки, она наследует все их способы сломаться.

Отсюда ее границы. Проверка после подъема ловит «не поднялось». Она не ловит «поднялось, но считает неправильно» - это работа тестов, которые прошли раньше. Не пытайтесь закрыть вторым инструментом то, что должен был поймать первый.

flowchart LR
  R["Репозиторий"] -->|"едет"| SRV["Сервер"]
  subgraph KEEP["Остается на сервере"]
    K1["Секреты и .env"]
    K2["База данных"]
    K3["Журналы"]
    K4["Файлы, созданные<br/>пользователями"]
  end
  SRV --- KEEP
  DEL["Удаление лишнего<br/>без списка исключений"] -.->|"стирает"| KEEP
Синхронизация с удалением приводит каталог к состоянию репозитория - вместе с тем, чего в репозитории нет

Что не должно уезжать вместе с кодом

Отдельная категория граблей - синхронизация файлов с удалением лишнего. Такая синхронизация приводит каталог на сервере к состоянию репозитория: копирует новое и заодно стирает все, чего в репозитории нет.

Однажды она стерла у меня рабочий конфиг с плановыми показателями. Файл был нужен системе, но его не было в репозитории - и с точки зрения синхронизации он был мусором. Формально все отработало правильно. Фактически я потеряла данные, которые существовали в одном экземпляре.

Вывод простой: все, что живет только на сервере, должно быть в списке исключений. Как минимум:

  • файлы окружения с секретами
  • каталоги баз данных
  • журналы
  • пользовательские данные

Полезно держать в голове разделение: репозиторий владеет кодом, сервер владеет состоянием. Код едет с машины разработчика на сервер и может быть перезаписан без сожалений - он воспроизводится из истории. Состояние рождается на сервере и не воспроизводится ниоткуда. Если файл создается или меняется в процессе работы системы, он не собственность репозитория, и синхронизация не имеет права его трогать.

Применить у себя

Что проверить на практике

  • Найдите в своих скриптах быстрый путь и посмотрите, обходит ли он тесты. Если да - переименуйте его так, чтобы имя честно говорило, что это аварийный режим.
  • Разложите выкладку на четыре шага и убедитесь, что падающие тесты действительно останавливают скрипт. Проверяется это тем, что тестовый шаг возвращает ненулевой код, а скрипт на нем прерывается, а не идет дальше.
  • Проверьте, что тесты идут до подъема рабочих контейнеров и не делят состояние с живым процессом. Если тесты и продакшн открывают одно хранилище - порядок шагов это уже лечит.
  • Откройте обработчик проверки готовности и посмотрите, куда он ходит. Если он читает базу, считает записи или собирает статистику - уберите это оттуда.
  • Найдите место, где скрипт ждет готовности, и убедитесь, что у ожидания есть ограничение по времени. Потом испытайте его: выложите заведомо нерабочую версию и посмотрите, закончится ли скрипт сам.
  • Выпишите список всего, что живет только на сервере, и сверьте его со списком исключений синхронизации. Перед реальным прогоном запустите синхронизацию в режиме dry-run - «показать, что будет сделано, но ничего не делать» - и прочитайте список файлов, помеченных к удалению.

Как понять, что получилось

Выкладка настроена правильно, если выполняются все пять условий:

  • Вы намеренно ломаете один тест, запускаете полный цикл - и рабочие контейнеры не перезапускаются. На сервере продолжает жить старая версия, пользователи ничего не заметили.
  • Выкладка всегда заканчивается: либо успехом, либо явной неудачей с понятной причиной. Состояния «висит уже десять минут» не существует.
  • Подтормаживающая база не делает выкладку неудачной. Проверка готовности говорит только про сам процесс.
  • dry-run синхронизации не показывает к удалению ни одного файла, который вам нужен.
  • После каждого использования аварийного быстрого пути полный цикл был прогнан, и вы можете сказать, когда именно.

Если свести все к одной фразе: тесты до подъема, ожидание с ограничением, дешевая проверка готовности и честный список исключений. Четыре решения, каждое из которых стоит один вечер, - и сломанная версия перестает быть событием для пользователей.