Релиз и выкат без простоя
Как собрать воспроизводимый релиз и выкатить его так, чтобы пользователи не увидели перерыва, а неудачный выкат откатывался за секунды.
19 минутКак собрать воспроизводимый релиз и выкатить его так, чтобы пользователи не увидели перерыва, а неудачный выкат откатывался за секунды.
Исходное состояние: работающий контур (домен → вход → сервисы) с одной копией приложения. Результат: две копии, артефакт выпуска с контрольными суммами, выкат и откат одной командой.
Порядок реализован двумя скриптами; документ объясняет, что они делают и почему именно так:
| Скрипт | Где запускается | Что делает |
|---|---|---|
tools/build-release.sh | машина сборки | проверки, образы, состав поставки, каталог выпуска |
tools/deploy.sh | сервер (кроме push) | перенос, загрузка, подъём копии, миграции, переключение, откат |
Оба скрипта — образцы для копирования в проект. Параметры вынесены в шапку каждого файла: имена образов, список файлов времени выполнения, имена служб, адреса проверки готовности. Ниже по тексту скрипты эти параметры только читают, так что правится одно место.
Предусловия
| Условие | Проверка | Ожидается |
|---|---|---|
| Docker и Compose на сервере и на машине сборки | docker compose version | Docker Compose version v2.… |
| контур работает через вход | curl -sfI https://example.com/ | HTTP/2 200 |
| у сервиса есть ручка готовности | curl -sf https://example.com/readyz | код 200, в теле — версия |
| каталог установки на сервере | ls /srv/app/compose.prod.yml | путь напечатан |
| инструменты сборки | git --version && gitleaks version && docker scout version | три строки с версиями |
| резервная копия базы восстанавливается | резервное копирование | проверка восстановлением пройдена |
Последнее условие — не формальность: выкат с миграциями без проверенной копии не имеет пути назад.
Место в цепочке
| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| работающий контур: домен → вход → сервисы; база со схемой и таблицей версий; проверенные копии | сборка релиза, выкат, откат | наблюдение за выкаченной версией |
Что предыдущее звено обязано обеспечить:
- у каждого сервиса есть ручка готовности, по которой видно, что он может принимать трафик. Без неё выкат становится «подождать и надеяться»;
- миграции применяет один исполнитель, а не старт каждой копии — требование базы данных, и оно определяет порядок шагов выката;
- секреты лежат на сервере и подставляются при запуске, а не собираются в образ (секреты).
Что этот документ оставляет следующему: у каждой версии есть метка выпуска, манифест с идентификаторами образов и состав поставки — по ним наблюдение отвечает на вопрос «что именно сейчас работает» и «затрагивает ли новая уязвимость эту версию».
Идея
Две одинаковые копии приложения — «синяя» (blue) и «зелёная» (green). Трафик в каждый момент идёт только в одну. Новая версия поднимается в свободной копии, проверяется, и лишь затем на неё переключается вход. Прежняя копия остаётся запущенной — откат означает вернуть указатель обратно.
┌── синяя (принимает трафик)
вход (nginx) ┤
└── зелёная (поднята с новой версией, проверяется)Копии называются по цвету, а не «старая» и «новая»: имя не должно зависеть от того, какая версия в ней сейчас. После каждого выката цвета меняются ролями, поэтому «новая копия» — это состояние, а не название.
Что это даёт: пользователь не видит перезапуска; проверка идёт на настоящей рабочей среде, а не на стенде; откат не требует пересборки.
Чего это не даёт: несовместимые изменения схемы данных так не откатываются — база общая для обеих копий. Об этом отдельный раздел ниже.
Как устроены две копии
Копия — это набор служб с суффиксом цвета. Постоянная часть одна: вход, который держит порт наружу и при выкате не пересоздаётся.
# compose.prod.yml
x-app: &app
image: example-app:prod
build: ./backend
restart: unless-stopped
environment:
APP_DATABASE_URL: ${APP_DATABASE_URL:?переменная не задана}
x-app-gateway: &app-gateway
image: example-web-gateway:prod
build: ./gateway
restart: unless-stopped
x-web: &web
image: example-web:prod
build: ./frontend
restart: unless-stopped
services:
# Постоянная часть: держит порт, разводит трафик по активному цвету.
edge:
image: nginx:alpine@sha256:<digest>
ports: ['127.0.0.1:8080:80']
volumes:
- ./edge/conf.d:/etc/nginx/conf.d:ro
networks: [edge-net]
restart: unless-stopped
app-blue: { <<: *app, networks: [app-blue-net, data-net] }
app-green: { <<: *app, networks: [app-green-net, data-net] }
app-gateway-blue: { <<: *app-gateway, networks: [edge-net, app-blue-net], environment: { APP_UPSTREAM: app-blue:8080 } }
app-gateway-green: { <<: *app-gateway, networks: [edge-net, app-green-net], environment: { APP_UPSTREAM: app-green:8080 } }
web-blue: { <<: *web, networks: [edge-net] }
web-green: { <<: *web, networks: [edge-net] }
# Разовый исполнитель миграций: профиль tools не поднимается вместе со стеком.
migrate:
image: example-app:prod
profiles: ['tools']
command: ['/app/migrate', 'up']
environment:
APP_DATABASE_URL: ${APP_DATABASE_URL:?переменная не задана}
networks: [data-net]
restart: 'no'
networks:
edge-net:
app-blue-net:
app-green-net:
data-net:
internal: trueПортов нет ни у одной цветной службы: наружу смотрит только вход (сетевой контур). Поэтому и проверка готовности выполняется изнутри — из контейнера входа, а не с рабочей машины.
Где записан активный цвет. В отдельном подключаемом файле, а не в основной конфигурации:
# edge/conf.d/active.inc — единственное, что меняется при переключении
# active-color: blue
set $app_upstream app-gateway-blue;
set $static_upstream web-blue;Расширение .inc, а не .conf: основная конфигурация nginx подключает conf.d/*.conf, и файл с
директивами set вне server-блока сломал бы запуск, попади он под эту маску.
# edge/conf.d/gateway.conf — постоянная часть, при выкате не меняется
server {
listen 80;
server_name example.com;
# Встроенный DNS Docker: имена служб разрешаются на каждом запросе.
resolver 127.0.0.11 valid=5s;
include /etc/nginx/conf.d/active.inc;
location /api/ {
proxy_pass http://$app_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://$static_upstream;
}
}Переменная в proxy_pass обязательна. Без переменной nginx разрешает имя upstream один раз при
старте и отказывается запускаться, если контейнера этого цвета сейчас нет. С переменной имя
разрешается на каждом запросе через встроенный DNS Docker — отсюда строка resolver 127.0.0.11.
docker compose -f compose.prod.yml up -d
./tools/deploy.sh statusОжидается: активный цвет: blue, свободный цвет: green, ниже — список служб, где запущены обе
копии и вход.
Сборка релиза
Релиз собирается из зафиксированного состояния кода: сборка из рабочего каталога с незакоммиченными правками невоспроизводима — по образу нельзя понять, что в нём.
./tools/build-release.sh # результат: release/<метка времени>/Шаги внутри скрипта, каждый останавливает сборку при отказе:
| № | Шаг | Чем выполняется |
|---|---|---|
| 1 | рабочий каталог чист | git status --porcelain |
| 2 | поиск секретов в файлах и в истории | gitleaks (gitleaks dir, gitleaks git) |
| 3 | сборка образов, загрузка базовых по digest | docker compose build, docker pull |
| 4 | проверка образов на известные уязвимости | docker scout cves либо trivy |
| 5 | каталог выпуска с меткой времени UTC | — |
| 6 | файлы времени выполнения | tar |
| 7 | состав поставки (SBOM) в формате SPDX | docker scout sbom либо syft |
| 8 | архив образов | docker save | gzip |
| 9 | манифест: коммит, версии подмодулей, идентификаторы образов | git, docker image inspect |
| 10 | контрольные суммы и их немедленная проверка | sha256sum |
Два прогона gitleaks отвечают на разные вопросы: dir — что лежит в файлах сейчас, git — что
когда-либо было закоммичено. Секрет, удалённый последним коммитом, остаётся в истории и утечёт
вместе с репозиторием. У подмодуля своя история — его корень перечисляется в параметре
SECRET_SCAN_PATHS отдельной строкой.
Порог остановки на шаге 4 — критические и высокие. Разобранные находки, о которых известно, что к
этой сборке они не применимы, объявляются явно: для docker scout — документом OpenVEX
(--vex-location), для trivy — файлом .trivyignore. Подавление без разбора превращает шаг
проверки в формальность, поэтому список пустой, пока таких находок нет.
Состав каталога выпуска:
release/20260731T163002Z/
├── images.tar.gz образы одним архивом
├── runtime.tar.gz compose.prod.yml, edge/conf.d/gateway.conf, tools/deploy.sh
├── sbom/*.spdx.json пакеты и версии внутри каждого образа
├── MANIFEST коммит, версии подмодулей, идентификаторы образов
└── SHA256SUMS контрольные суммы всего перечисленногоАртефакт неизменяем: та же метка времени всегда означает тот же набор образов. Файлы времени
выполнения едут вместе с образами, потому что без них выкат не воспроизводится — compose и
конфигурация входа задают, как именно образы запускаются. Изменяемые данные установки (.env,
каталог секретов, файл активного цвета, состояние) в артефакт не входят: он их не перезаписывает.
Тег образа постоянный (example-app:prod), версию несёт манифест. Контейнер держит тот
идентификатор образа, с которым был создан, поэтому загрузка нового образа под тем же тегом не
трогает уже запущенную копию. Это и делает откат мгновенным — и это же причина, по которой при
выкате нельзя запускать docker compose up -d без имён служб: такая команда пересоздаст и активную
копию, откатываться станет некуда.
Секреты не входят в релиз. Пароли и ключи живут на сервере (.env, файлы окружения) и
подставляются при запуске. Поэтому сборка на машине разработчика не требует боевых значений: для
подстановки в конфигурацию достаточно заглушек, если эти переменные нужны только во время работы, а
не во время сборки.
cd release/<метка> && sha256sum -c SHA256SUMS && cat MANIFESTОжидается: по строке OK на каждый файл; в манифесте — коммит и идентификатор каждого образа.
Любая строка FAILED означает, что артефакт собран не полностью и выкатывать его нельзя.
Перенос на сервер
Два способа, различаются только тем, откуда сервер берёт образы.
Через реестр — если сервер в него ходит: docker push на машине сборки, docker compose pull
на сервере. Требуется реестр и учётные данные к нему на сервере.
Архивом — если сервер в реестр не ходит: своего реестра нет либо сервер намеренно не выпущен в
интернет. Тогда единица переноса — каталог выпуска целиком, вместе с SHA256SUMS:
# с машины сборки
ssh admin@example.com 'mkdir -p /srv/app/release'
./tools/deploy.sh push release/<метка> admin@example.com:/srv/apppush перед отправкой сверяет контрольные суммы и копирует каталог через rsync --partial
(прерванная передача продолжается, а не начинается заново); если rsync не установлен — через
scp. Проверка сумм повторяется на сервере при выкате: она отвечает на вопрос, доехал ли артефакт
целиком, и это единственный способ отличить повреждённую передачу от повреждённой сборки.
ssh admin@example.com 'cd /srv/app/release/<метка> && sha256sum -c SHA256SUMS'Ожидается: OK по каждому файлу.
Выкат
# на сервере, из каталога установки
./tools/deploy.sh deploy release/<метка>Что происходит по шагам:
| № | Шаг | Что при отказе |
|---|---|---|
| 1 | проверка контрольных сумм артефакта | выкат не начинается |
| 2 | загрузка образов (docker load) | выкат не начинается |
| 3 | установка файлов времени выполнения с сохранением прежних | прежние возвращаются, вход перечитывает конфигурацию |
| 4 | подъём свободного цвета новыми образами | см. шаг 7 |
| 5 | резервная копия базы | выкат останавливается до миграций |
| 6 | миграции — один разовый исполнитель | выкат останавливается до переключения |
| 7 | проверка готовности новой копии | трафик остаётся на прежнем цвете, конфигурация возвращается |
| 8 | переключение входа | то же |
Ключевой момент — шаг 7: переключение происходит только после успешной проверки готовности. Если проверка не прошла, трафик остаётся на прежней копии, скрипт печатает последние строки журналов поднятой копии, возвращает прежние файлы времени выполнения и завершается с ненулевым кодом. Выкат считается несостоявшимся; простоя при этом нет.
Проверяется именно ручка готовности, а не «жив»: копия, которая запустилась, но ещё не может
отвечать, не должна получать трафик. Запрос выполняется из контейнера входа
(docker compose exec edge wget …), потому что у копий нет опубликованных портов — снаружи их не
видно вовсе.
Перед установкой новых файлов времени выполнения прежние сохраняются в .runtime-history/<метка>, а
путь к ним записывается в edge/state/previous-runtime. Отсюда работает откат инфраструктуры —
см. раздел «Откат».
Как переключается вход
Переключение — это две операции: переписать edge/conf.d/active.inc и выполнить nginx -s reload.
Именно перезагрузка (reload), а не перезапуск (restart). По сигналу перезагрузки мастер-процесс nginx перечитывает конфигурацию и запускает новых рабочих; прежние дорабатывают уже принятые запросы и завершаются сами. Слушающий сокет при этом не закрывается ни на мгновение — открытые соединения не рвутся, новые продолжают приниматься. Перезапуск контейнера закрыл бы сокет, и на это время клиенты получали бы отказ в соединении: то есть ровно тот простой, ради отсутствия которого держатся две копии.
Порядок внутри switch_color:
- текущий
active.incкопируется во временный файл; - пишется новый — с меткой цвета в первой строке и строками
setпо числу переменных; nginx -tпроверяет конфигурацию; при ошибке прежний файл возвращается, трафик не тронут;nginx -s reload; при ошибке прежний файл возвращается и перезагрузка повторяется на нём.
Каталог edge/conf.d подключён в контейнер входа как том, поэтому файл, заменённый на сервере,
виден внутри сразу — отдельного копирования в контейнер не требуется.
./tools/deploy.sh status
curl -sf https://example.com/readyz
docker compose -f compose.prod.yml psОжидается: status печатает цвет, на который переключились; ответ /readyz содержит версию из
нового выпуска; в ps запущены обе копии, прежняя — в состоянии Up, а не Exited.
Миграции базы данных
Правила самих миграций (инструмент, именование файлов, таблица версий, блокировки) — в базе данных. Здесь только то, что относится к выкату.
Чем применяются. Разовым контейнером из того же образа, что и приложение: служба migrate в
compose.prod.yml с профилем tools, запускается как docker compose run --rm migrate. Профиль
нужен, чтобы служба не поднималась вместе со стеком. Внутри контейнера работает мигратор из стека
сервиса — тот же, что применяет миграции в разработке.
Когда применяются. После подъёма свободной копии и до переключения входа. Один исполнитель на выкат: если бы миграции применяла каждая копия при старте, две копии, поднятые одновременно, вошли бы в них одновременно.
Из порядка следует, что новая копия какое-то время работает на старой схеме и может не выходить в
готовность. Это ожидаемо: при restart: unless-stopped она перезапускается, а проверка готовности
выполняется уже после миграций и ждёт до READY_ATTEMPTS × READY_INTERVAL секунд (по умолчанию две
минуты).
Требование к содержанию. База общая для обеих копий, поэтому во время выката с ней одновременно работают старая и новая версии: каждая миграция должна быть совместима с предыдущей версией кода.
Безопасные изменения: добавление таблицы, добавление необязательного поля, добавление индекса.
Опасные изменения выполняются в два выката:
| Задача | Выкат 1 | Выкат 2 |
|---|---|---|
| переименовать поле | добавить новое, писать в оба, читать из старого | читать из нового, удалить старое |
| удалить поле | перестать использовать в коде | удалить из схемы |
сузить тип или добавить NOT NULL | заполнить значения, добавить проверку | ужесточить ограничение |
Правило: сначала выкатывается код, умеющий работать и со старой, и с новой схемой; изменение схемы, ломающее старый код, идёт только после того, как старый код перестал работать.
Откат
./tools/deploy.sh rollbackКоманда делает две вещи.
Откат трафика. Проверяет, что прежняя копия готова, и возвращает на неё указатель входа. Она всё это время работала, поэтому откат занимает время перезагрузки конфигурации входа, а не время сборки.
Откат инфраструктуры. Возвращает файлы времени выполнения того выката, при котором эта копия
работала: compose.prod.yml, конфигурацию входа, сам скрипт выката — из .runtime-history/<метка>
по указателю edge/state/previous-runtime. Без этого шага трафик вернулся бы на прежнюю копию, но
правила её запуска остались бы новыми, и первый же перезапуск контейнера поднял бы её по ним.
После восстановления вход перечитывает конфигурацию.
Чего откат не делает: не отменяет применённые миграции и не удаляет загруженные образы. Схема возвращается отдельно — исправлением вперёд или восстановлением из копии (база данных, резервное копирование).
Откат возможен, пока прежняя копия не заменена следующим выкатом. Отсюда правило: не выкатывать следующую версию, пока предыдущая не подтверждена как рабочая — иначе откатываться станет некуда.
Снятие
Вернуть сервер в состояние «одна копия» или убрать контур целиком:
# остановить свободный цвет, оставив активный работать
docker compose -f compose.prod.yml stop app-green app-gateway-green web-green
# убрать контур целиком, сохранив данные
docker compose -f compose.prod.yml down # без --volumes
# освободить место от старых образов, сохранив образы предыдущего выпуска
docker image prune --filter 'until=168h'Что при этом не удаляется: тома с данными (down без --volumes их не трогает), внешняя база,
резервные копии, каталоги выпусков в release/ и сохранённые конфигурации в .runtime-history/.
Их удаляют вручную и по одному, потому что каждый из них — единственный путь назад для своего вида
отказа.
Что должно быть в приложении
Служебные ручки. Минимум две, и они отвечают разное:
/livez— процесс запущен и отвечает; используется для перезапуска зависшего контейнера;/readyz— зависимости доступны (база, очередь), можно давать трафик; используется при выкате.
Разделение важно: приложение может быть живым, но ещё не готовым — например, пока не применились миграции. Если проверять только «жив», трафик переключится на копию, которая ещё не может отвечать.
Корректное завершение. По сигналу остановки сервис перестаёт принимать новые запросы, дорабатывает текущие и завершается. Иначе при переключении часть запросов оборвётся.
Версия в ответе служебной ручки — по ней видно, какая копия сейчас принимает трафик.
Типичные отказы
| Признак | Причина | Что делать |
|---|---|---|
есть незафиксированные изменения | сборка запущена из грязного рабочего каталога | зафиксировать или убрать правки; релиз собирается только из зафиксированного состояния |
| gitleaks нашёл секрет в истории | значение когда-то было закоммичено | отозвать значение, заменить новым; чистка истории сама по себе не отменяет утечку |
| сборка падает на подстановке переменных | требуются переменные времени выполнения | передать заглушки: в образ они не попадают |
неполный каталог выпуска удалён | шаг сборки отказал | смотреть сообщение выше: оно называет шаг и код возврата |
контрольные суммы не сошлись | передача оборвалась либо каталог скопирован во время сборки | перенести заново; сверять суммы до и после переноса |
в архиве времени выполнения посторонний путь | архив собран не этим скриптом либо изменён | пересобрать выпуск; выкат принимает только объявленные пути |
в active.inc строка не соответствует объявленному цвету | файл правили вручную наполовину | привести файл к одному цвету, затем deploy.sh status |
| новая копия не выходит в готовность | приложение не поднялось, миграции не применились, недоступна зависимость | смотреть напечатанные журналы копии; трафик остался на прежней — простоя нет |
nginx: [emerg] host not found in upstream | в proxy_pass имя без переменной, контейнер этого цвета отсутствует | вынести имя в переменную и добавить resolver 127.0.0.11 |
| после выката часть запросов с ошибками | нет корректного завершения у прежней копии | реализовать обработку сигнала остановки |
| откат не помогает | несовместимая миграция уже изменила схему | восстанавливать из резервной копии; на будущее — двухшаговые миграции |
| откатываться некуда: обе копии на новой версии | при выкате выполнен up -d без имён служб либо два выката подряд | поднять прежний образ по идентификатору из манифеста предыдущего выпуска |
| места на диске не хватает после нескольких релизов | копятся старые образы и каталоги выпусков | docker image prune с ограничением по возрасту, оставляя предыдущий выпуск |
Резервные копии
Выкат без резервной копии — операция без права на ошибку. Минимум: снимок базы перед применением миграций и проверка, что снимок восстанавливается. Непроверенная резервная копия равнозначна её отсутствию.
Снимок снимается шагом 5 выката — командой из параметра BACKUP_CMD в tools/deploy.sh. Если
параметр пуст, скрипт печатает предупреждение и продолжает: это осознанный выбор для сервиса без
своей базы, а не значение по умолчанию для боевого контура. Что именно запускать и как проверять
восстановление — в резервном копировании.
Откройте исходник документа по ссылке «Предложить правку» — там же видно, что и когда в нём менялось.