У меня система из двух контейнеров: веб-сервис и телеграм-бот с планировщиком. Код лежит внутри образа, а не в подключенном каталоге - то есть контейнер не смотрит в папку на сервере, где файл можно поправить на месте и перезапустить процесс. Из-за этого любая правка, даже в одну строку, означает пересборку образа. Точки «поправить на живую» просто нет.
Это звучит как неудобство, но на самом деле это подарок: раз выкладка все равно всегда одна и та же процедура, ее можно один раз выстроить так, чтобы сломанная версия физически не доезжала до пользователей. К концу статьи вы получите этот порядок шагов и поймете, почему быстрый путь в обход тестов обходится дороже, чем кажется в момент, когда вы его выбираете.
Соблазн быстрой выкладки
Быстрый путь - это одна команда: пересобрать и поднять. Она есть почти в любом проекте, и она обходит тесты целиком. Соблазн понятен: правка маленькая, полный цикл идет дольше, а «тут нечего ломать».
Проблема в том, что у бота нет страницы с ошибкой. Веб-сервис, который не поднялся, виден сразу - кто-то откроет и увидит. Бот, который не поднялся, выглядит как молчание: пользователь пишет и не получает ответа, а планировщик просто не делает работу в назначенное время. Никто не получает сообщение об ошибке, потому что сообщать некому - процесс не запустился.
Поэтому у меня быстрый путь существует, но он не режим работы, а исключение. Он нужен для срочной заплатки, когда что-то горит прямо сейчас. И он оставляет долг: после него обязательно прогоняется полный цикл. Если долг не отдать, вы получаете сервер, про который никто не знает, проходят ли на нем тесты, - и следующая выкладка будет ремонтировать сразу две проблемы вместо одной.
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 синхронизации не показывает к удалению ни одного файла, который вам нужен.
- После каждого использования аварийного быстрого пути полный цикл был прогнан, и вы можете сказать, когда именно.
Если свести все к одной фразе: тесты до подъема, ожидание с ограничением, дешевая проверка готовности и честный список исключений. Четыре решения, каждое из которых стоит один вечер, - и сломанная версия перестает быть событием для пользователей.