gitaspen docs

Релиз и выкат без простоя

Как собрать воспроизводимый релиз и выкатить его так, чтобы пользователи не увидели перерыва, а неудачный выкат откатывался за секунды.

19 минут

Как собрать воспроизводимый релиз и выкатить его так, чтобы пользователи не увидели перерыва, а неудачный выкат откатывался за секунды.

Исходное состояние: работающий контур (домен → вход → сервисы) с одной копией приложения. Результат: две копии, артефакт выпуска с контрольными суммами, выкат и откат одной командой.

Порядок реализован двумя скриптами; документ объясняет, что они делают и почему именно так:

СкриптГде запускаетсяЧто делает
tools/build-release.shмашина сборкипроверки, образы, состав поставки, каталог выпуска
tools/deploy.shсервер (кроме push)перенос, загрузка, подъём копии, миграции, переключение, откат

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

Предусловия

УсловиеПроверкаОжидается
Docker и Compose на сервере и на машине сборкиdocker compose versionDocker 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) ┤
             └── зелёная (поднята с новой версией, проверяется)

Копии называются по цвету, а не «старая» и «новая»: имя не должно зависеть от того, какая версия в ней сейчас. После каждого выката цвета меняются ролями, поэтому «новая копия» — это состояние, а не название.

Что это даёт: пользователь не видит перезапуска; проверка идёт на настоящей рабочей среде, а не на стенде; откат не требует пересборки.

Чего это не даёт: несовместимые изменения схемы данных так не откатываются — база общая для обеих копий. Об этом отдельный раздел ниже.


Как устроены две копии

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

yaml
# 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

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

Где записан активный цвет. В отдельном подключаемом файле, а не в основной конфигурации:

nginx
# 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-блока сломал бы запуск, попади он под эту маску.

nginx
# 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, ниже — список служб, где запущены обе копии и вход.


Сборка релиза

Релиз собирается из зафиксированного состояния кода: сборка из рабочего каталога с незакоммиченными правками невоспроизводима — по образу нельзя понять, что в нём.

bash
./tools/build-release.sh          # результат: release/<метка времени>/

Шаги внутри скрипта, каждый останавливает сборку при отказе:

ШагЧем выполняется
1рабочий каталог чистgit status --porcelain
2поиск секретов в файлах и в историиgitleaks (gitleaks dir, gitleaks git)
3сборка образов, загрузка базовых по digestdocker compose build, docker pull
4проверка образов на известные уязвимостиdocker scout cves либо trivy
5каталог выпуска с меткой времени UTC
6файлы времени выполненияtar
7состав поставки (SBOM) в формате SPDXdocker 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:

bash
# с машины сборки
ssh admin@example.com 'mkdir -p /srv/app/release'
./tools/deploy.sh push release/<метка> admin@example.com:/srv/app

push перед отправкой сверяет контрольные суммы и копирует каталог через rsync --partial (прерванная передача продолжается, а не начинается заново); если rsync не установлен — через scp. Проверка сумм повторяется на сервере при выкате: она отвечает на вопрос, доехал ли артефакт целиком, и это единственный способ отличить повреждённую передачу от повреждённой сборки.

Проверка:
ssh admin@example.com 'cd /srv/app/release/<метка> && sha256sum -c SHA256SUMS'

Ожидается: OK по каждому файлу.


Выкат

bash
# на сервере, из каталога установки
./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:

  1. текущий active.inc копируется во временный файл;
  2. пишется новый — с меткой цвета в первой строке и строками set по числу переменных;
  3. nginx -t проверяет конфигурацию; при ошибке прежний файл возвращается, трафик не тронут;
  4. 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заполнить значения, добавить проверкуужесточить ограничение

Правило: сначала выкатывается код, умеющий работать и со старой, и с новой схемой; изменение схемы, ломающее старый код, идёт только после того, как старый код перестал работать.


Откат

bash
./tools/deploy.sh rollback

Команда делает две вещи.

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

Откат инфраструктуры. Возвращает файлы времени выполнения того выката, при котором эта копия работала: compose.prod.yml, конфигурацию входа, сам скрипт выката — из .runtime-history/<метка> по указателю edge/state/previous-runtime. Без этого шага трафик вернулся бы на прежнюю копию, но правила её запуска остались бы новыми, и первый же перезапуск контейнера поднял бы её по ним. После восстановления вход перечитывает конфигурацию.

Чего откат не делает: не отменяет применённые миграции и не удаляет загруженные образы. Схема возвращается отдельно — исправлением вперёд или восстановлением из копии (база данных, резервное копирование).

Откат возможен, пока прежняя копия не заменена следующим выкатом. Отсюда правило: не выкатывать следующую версию, пока предыдущая не подтверждена как рабочая — иначе откатываться станет некуда.

Снятие

Вернуть сервер в состояние «одна копия» или убрать контур целиком:

bash
# остановить свободный цвет, оставив активный работать
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. Если параметр пуст, скрипт печатает предупреждение и продолжает: это осознанный выбор для сервиса без своей базы, а не значение по умолчанию для боевого контура. Что именно запускать и как проверять восстановление — в резервном копировании.

Инструкция не помогла?

Откройте исходник документа по ссылке «Предложить правку» — там же видно, что и когда в нём менялось.