CI/CD и выпуск версий: как код попадает на сервер и как откатить релиз
Наш сервис заказов уже работает: FastAPI принимает запросы за Caddy, хранит заказы в PostgreSQL, использует Redis и получает секреты при запуске. Теперь разработчик меняет обработку доставки. В заказ добавляется почтовый индекс, а перед отправкой запроса в платёжный сервис приложение проверяет адрес.
На компьютере разработчика всё работает. Код попадает в репозиторий, затем на сервер. Через несколько минут после выпуска часть запросов на создание заказа начинает отвечать ошибкой: у старых заказов почтового индекса ещё нет.
Возникают вопросы, которые нельзя решить одной командой docker compose up:
- Какие проверки должны остановить такой выпуск?
- Как узнать, какой именно код находится в работающем контейнере?
- Как убедиться, что staging и production получили один и тот же образ?
- Можно ли вернуть старый образ после изменения таблицы заказов?
- Что делать, если новый код уже отправил запросы в платёжный сервис?
CI/CD связывает ответы в управляемый процесс. Разберём его на том же сервисе заказов, не меняя архитектуру предыдущих глав.
Что именно мы выпускаем
CI, непрерывная интеграция - автоматическая проверка изменений, объединённых в общей ветке: установка зависимостей, тесты, сборка и другие проверки.
CD, непрерывная доставка или развёртывание - подготовка проверенного результата к выпуску и его перенос в окружения. При непрерывной доставке переход в production может требовать отдельного решения; при автоматическом развёртывании он выполняется без ручного шага после прохождения заданных условий.
Важно различать несколько объектов:
| Объект | Что это | Для чего нужен |
|---|---|---|
| Коммит | Состояние исходного кода | Понять, какие изменения вошли в выпуск |
| Docker-образ | Собранная файловая система и параметры запуска приложения | Запустить одинаковый программный артефакт в разных окружениях |
| Тег образа | Удобное имя, например orders-api:release-42 |
Найти образ человеку и автоматизации |
| Digest образа | Идентификатор конкретного содержимого образа | Указать точный артефакт для выпуска и отката |
| Развёртывание | Запуск образа с конфигурацией и секретами окружения | Сделать версию работающей |
| Релиз | Наблюдаемое изменение поведения для пользователей | Оценить результат и при необходимости остановить его |
Коммит и образ не взаимозаменяемы. Один коммит, собранный в разное время с изменившимися зависимостями или базовым образом, может дать разные результаты. Тег тоже может быть переназначен. Digest привязан к содержимому опубликованного образа; ссылка вида orders-api@sha256:... указывает на конкретный артефакт. Это различие описано в документации Docker об image digest.
Для нашего цикла цель звучит так: один проверенный образ API проходит staging, затем production; в записи о выпуске остаётся его digest и digest предыдущей рабочей версии.
Путь изменения от задачи до production
Для изменения обработки доставки конвейер будет таким:
Изменение кода -> проверка запроса на слияние -> объединение в основную ветку -> сборка образа -> проверка собранного образа -> публикация в реестре -> staging -> production -> наблюдение.
Каждый переход должен иметь условие. Если условие не выполнено, следующий этап не начинается.
| Этап | Что проверяем | Какой риск остаётся |
|---|---|---|
| Статический анализ и проверка формата | Очевидные ошибки кода и нарушения принятых правил | Корректность поведения не доказана |
| Модульные тесты | Отдельные функции, включая обработку пустого индекса | Не доказана работа с реальной БД и Redis |
| Интеграционные тесты | API вместе с временными PostgreSQL и Redis | Не доказана конфигурация рабочего сервера |
| Проверка образа | Запуск именно собранного контейнера и его основных маршрутов | Не доказана работа с production-данными |
| Staging | Образ с отдельными настройками и внешними интеграциями | Объём и разнообразие рабочих данных могут отличаться |
| Проверка после выпуска | Реальные ответы, ошибки и задержки | Некоторые редкие ошибки проявятся позднее |
Для нашего изменения нужен как минимум тест заказа без почтового индекса. Это не абстрактное требование к проценту покрытия. Тест фиксирует старое состояние данных, с которым новая версия обязана взаимодействовать.
Проверки запроса на слияние не должны получать рабочие секреты или право публикации образа. После объединения изменений CI повторно проверяет тот коммит, из которого будет собран релиз. Публикация образа разрешается только после успешных проверок. Для GitHub Actions права задания задают явно; рекомендации по минимальным разрешениям и закреплению используемых actions за конкретными commit SHA приведены в документации GitHub.
Короткий вывод: CI не состоит из одного тестового скрипта. Это последовательность проверок с разными границами применимости, привязанная к конкретному коммиту и будущему образу.
Как получить образ, которому можно доверять
Предположим, тесты исходного кода прошли. Следующий шаг - собрать контейнерный образ API. В нём находятся код, Python, установленные библиотеки и команда запуска. Рабочих паролей PostgreSQL и ключа платёжного сервиса внутри образа быть не должно: как показано в предыдущей главе, они передаются экземпляру приложения при запуске.
Чтобы повторная сборка не менялась неожиданно, нужны контролируемые входы:
- зависимости Python зафиксированы в lock-файле или другом проверяемом списке точных версий;
- версия базового образа выбрана осознанно, а для строгой фиксации закреплена по digest;
- в контекст сборки не попадают локальные
.env, файлы секретов, виртуальное окружение и временные данные; - процесс обновления зависимостей и базового образа существует, потому что неизменность без обновлений оставит известные уязвимости.
Docker рекомендует исключать лишние файлы через .dockerignore и при необходимости закреплять базовый образ по digest. Если сборке всё-таки нужен секрет, например для скачивания частного пакета, его передают через build secret, а не через ARG или ENV, значения которых могут раскрыться через образ и его метаданные. Рекомендации по сборке Docker, документация build secrets.
Есть тонкое различие между тестированием кода и тестированием образа. Тесты могут пройти в окружении CI, где установлена одна версия библиотеки, а контейнер затем соберётся с другой. Поэтому после сборки полезно запустить именно этот образ с временными PostgreSQL и Redis и проверить основные маршруты API. В нашем примере это получение заказа, создание заказа со старым форматом адреса и маршрут /health/ready.
Для одного целевого типа сервера упрощённая часть сборки выглядит так:
IMAGE=ghcr.io/example/orders-api
COMMIT=$(git rev-parse HEAD)
docker buildx build \
--platform linux/amd64 \
--load \
--tag "$IMAGE:$COMMIT" \
.
После этого CI запускает проверки с полученным локальным образом. Если они прошли, тот же образ отправляется в реестр:
docker push "$IMAGE:$COMMIT"
docker buildx imagetools inspect "$IMAGE:$COMMIT"
Команды предполагают, что задание уже вошло в реестр с ограниченными правами публикации. imagetools inspect показывает опубликованный digest; его необходимо сохранить как результат сборки. Возможности этой команды показаны в справке Docker. Для нескольких платформ порядок сборки и проверок усложняется: нужно убедиться, что проверена платформа, на которой образ действительно запустится.
Тег с commit SHA помогает человеку связать образ с кодом, но для развёртывания мы используем digest. Сам по себе digest подтверждает неизменность выбранного содержимого, но не доказывает, что сборку выполнил доверенный конвейер. При более строгих требованиях к цепочке поставки добавляют сведения о происхождении сборки и составе образа, например provenance и SBOM. Docker описывает эти артефакты в документации attestations.
Короткий вывод: проверенным считается не «код, который однажды прошёл тесты», а конкретный образ, собранный из известного коммита, проверенный как контейнер и опубликованный под сохранённым digest.
Почему staging и production получают один образ
Представим, что CI построил образ для staging, а перед production собрал его ещё раз. Между сборками обновился базовый образ или была заново разрешена зависимость. Получились два образа из одного коммита. Проверка staging больше не относится строго к тому, что запущено в production.
Поэтому после тестирования образ продвигают, а не пересобирают:
Digest A -> staging с тестовой конфигурацией -> проверка -> production с рабочей конфигурацией.
Изменяются адреса сервисов, секреты и другие параметры окружения. Содержимое приложения остаётся тем же. Это продолжает правило из предыдущей главы: конфигурация относится к конкретному развёртыванию, а рабочие секреты не запекаются в образ.
GitHub Actions, например, позволяет выделить staging и production как отдельные deployment environments, ограничить ветки, настроить правила перехода и доступ к секретам для соответствующих заданий. Конкретные возможности зависят от настроек репозитория и плана; механизм описан в документации GitHub.
Для каждого выпуска полезно хранить запись:
| Поле | Пример |
|---|---|
| Коммит | a42f... |
| Образ | ghcr.io/example/orders-api@sha256:... |
| Предыдущий рабочий образ | Другой сохранённый digest |
| Версия конфигурации | Идентификатор набора настроек, без значений секретов |
| Версия схемы БД | Идентификатор применённой миграции |
| Куда выпущен | Staging или production |
| Результат проверок | Успех, ошибка, откат |
| Время и инициатор | Данные журнала выпуска |
Идентификаторы нужны не для отчётности. Во время сбоя они дают ответ на практический вопрос: что сейчас запущено и к чему именно можно вернуться.
Как образ попадает на наш сервер
В предыдущих главах API слушал порт 8000 в контейнере, публиковался на 127.0.0.1:8000 сервера, а Caddy передавал ему запросы для api.example.com. PostgreSQL, Redis и секреты остаются отдельными компонентами. Меняется только способ выбора образа API.
В Compose-файле достаточно привязать его к обязательному параметру:
services:
api:
image: "${IMAGE_REF:?IMAGE_REF is required}"
ports:
- "127.0.0.1:8000:8000"
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=2)"
interval: 10s
timeout: 3s
retries: 3
start_period: 20s
Это фрагмент существующего сервиса: настройки БД, Redis и секреты остаются в его конфигурации. Маршрут /health/ready должен отвечать успешно, только когда экземпляр способен принимать запросы. Например, если без PostgreSQL создание заказа невозможно, проверка готовности должна это учитывать. Если Redis используется лишь как кеш и у приложения есть безопасный переход на базу, падение Redis не обязательно делает API неготовым.
На сервере значение IMAGE_REF берут из записи прошедшего проверки релиза:
export IMAGE_REF='ghcr.io/example/orders-api@sha256:<digest>'
docker compose config --quiet
docker compose pull api
docker compose up -d --no-deps --wait api
curl -fsS https://api.example.com/health/ready
<digest> здесь нужно заменить сохранённым digest образа. pull получает указанный образ. up при изменении образа пересоздаёт сервис api, а --no-deps не пересоздаёт PostgreSQL и Redis. --wait опирается на состояние запуска и настроенный healthcheck. Поведение этих команд описано в справке Docker Compose и руководстве по применению Compose в production.
После команды проверки готовности релиз ещё нельзя автоматически считать успешным. Нужно выполнить smoke-тест основных внешних маршрутов и посмотреть показатели работы: долю ошибок создания заказа, ошибки обращения к PostgreSQL и платёжному сервису, задержку ответов. Маршрут готовности может отвечать 200, пока редкая ветка обработки заказа возвращает 500.
На нашем сервере пока работает один экземпляр API. Его пересоздание способно дать короткий перерыв в обслуживании. Это ограничение текущей схемы, а не ошибка команды выпуска.
Как выпустить версию без остановки единственного рабочего экземпляра
Если требуется обслуживать запросы во время переключения, можно расширить уже знакомую архитектуру до двух слотов.
- Рабочий API остаётся на
127.0.0.1:8000. - Кандидат запускается отдельно на
127.0.0.1:8001с новым digest. - Оба используют совместимую схему PostgreSQL и нужные им версии секретов.
- Caddy сначала направляет публичный трафик на порт 8000.
- После проверки кандидата маршрут Caddy переводится на порт 8001.
- Старый экземпляр временно сохраняется для быстрого возврата.
Здесь важно не публиковать оба контейнера на одном и том же порту. Для кандидата нужен отдельный Compose-проект или иной способ запуска с отдельным локальным портом. Переключение Caddy выполняют изменением upstream и перезагрузкой конфигурации; Caddy рекомендует применять изменения работающего сервиса через reload, а не остановку и новый запуск. Документация Caddy.
Это вариант подхода blue-green. Он сокращает паузу при смене процесса, но требует запаса ресурсов и совместимости старой и новой версии с общими зависимостями. Долгие запросы и соединения нужно учитывать отдельно при завершении старого экземпляра.
Короткий вывод: выпуск образа и переключение пользовательского трафика могут быть разными действиями. Разделение даёт время проверить кандидат до того, как он станет публичным.
База данных меняет правила отката
Вернёмся к почтовому индексу. Новая версия API хочет поле shipping_postcode в таблице orders.
Опасный вариант миграции: удалить старое поле адреса, добавить новое обязательное поле и сразу выпустить новый код. Если код ошибётся, возвращение прежнего образа не вернёт старую структуру таблицы. Старое приложение может перестать работать.
Для управляемого перехода сначала выполняют расширение совместимой схемы:
ALTER TABLE orders
ADD COLUMN shipping_postcode text;
Без NOT NULL и обязательного значения старые строки получают NULL; прежняя версия приложения может продолжать работать с таблицей. Это поведение соответствует документации PostgreSQL.
Дальше новая версия API должна корректно обработать отсутствие индекса в исторических заказах. После выпуска можно заполнить данные там, где они известны, проверить полноту и лишь в отдельном позднем изменении вводить более строгое ограничение. Если старое поле планируется удалить, его удаляют после завершения периода, когда может понадобиться откат старого приложения.
Эту последовательность называют expand -> migrate -> contract:
- Expand: добавить новые структуры, сохранив работу старого кода.
- Migrate: перевести код и данные на новое представление.
- Contract: удалить старые структуры, когда они больше не нужны.
Не всякую миграцию можно или нужно автоматически «откатить назад». Обратная миграция может потерять новые данные или выполняться слишком долго. Поэтому план отката приложения готовят до применения несовместимого изменения схемы.
Для нашего релиза это означает: если shipping_postcode добавлено как необязательное поле, возврат старого образа обычно остаётся возможным. Если новая версия успела записать в существующее поле status значение, которое старый код не понимает, одной совместимости структуры уже недостаточно. Нужно учитывать и совместимость данных.
Как выглядит откат при ошибке
Предположим, после переключения на новую версию доля ошибок создания заказа выросла. Мы сохранили digest предыдущего рабочего образа и знаем, что новая колонка в БД не мешает старому коду.
Для одноэкземплярного варианта откат технически похож на выпуск:
export IMAGE_REF='ghcr.io/example/orders-api@sha256:<previous_digest>'
docker compose pull api
docker compose up -d --no-deps --wait api
curl -fsS https://api.example.com/health/ready
После этого проверяем создание заказа, а не только маршрут готовности. Если использовались два слота, сначала можно вернуть маршрут Caddy на ещё работающий старый экземпляр и затем разбираться с новым.
При откате нужно задавать три вопроса:
- Образ: остался ли прежний digest доступным в реестре или на сервере?
- Конфигурация: совместимы ли текущие настройки и секреты со старым кодом?
- Данные: способна ли старая версия прочитать и обработать то, что записала новая?
Возврат образа меняет исполняемый код. Он не восстанавливает автоматически прежнюю базу, не удаляет отправленные сообщения и не отменяет платёж, который уже выполнил внешний сервис. В нашем примере повторная отправка платежа после отката может привести к двойному списанию, если операция не защищена ключом идемпотентности. Такие последствия требуют отдельной сверки или компенсирующей операции.
Восстановление всей базы из резервной копии ради обычного неудачного выпуска тоже опасно: вместе с ошибочными изменениями можно потерять корректные заказы, созданные после копии. Его рассматривают как отдельный сценарий восстановления данных с оценкой потерь, а не как стандартную кнопку «откатить релиз».
Короткий вывод: быстрый откат возможен только тогда, когда старый образ сохранён, конфигурация совместима, а данные и схема заранее допускают возвращение старого кода.
Когда остановить выпуск автоматически
Конвейер должен явно отличать сбой проверки от сбоя работающего сервиса.
| Сигнал | Действие |
|---|---|
| Не прошли тесты исходного кода | Не собирать релиз |
| Собранный контейнер не проходит проверку | Не публиковать или не продвигать образ |
| Staging не проходит smoke-тест | Не переходить в production |
| Новый экземпляр не становится готовым | Не переключать на него Caddy |
| После переключения растут ошибки заказов | Остановить расширение выпуска и вернуть прежний маршрут или digest |
| Обнаружена несовместимая миграция данных | Оценить восстановление или исправление вперёд; не выполнять слепой откат образа |
Полезен ограниченный период наблюдения после выпуска. Например, сравнить долю ошибок и задержку обработки заказов с состоянием до выпуска, проверить обращения к платёжному сервису и журнал исключений. Важна не конкретная универсальная цифра, а заранее выбранные для сервиса показатели и пороги. Иначе решение об откате принимается на ощущениях, когда пользователи уже столкнулись с ошибкой.
Нужно также отличать готовность экземпляра от успеха релиза. Готовность отвечает на вопрос «можно ли направлять сюда запросы сейчас?». Успех релиза отвечает на более широкий вопрос «правильно ли выполняются пользовательские сценарии и не ухудшились ли показатели?».
Типичные ошибки в процессе выпуска
На сервере собирают код заново. Тогда staging и production могут получить разные зависимости или базовые образы. Кроме того, для выпуска серверу требуется доступ к исходникам и инструментам сборки. Надёжнее доставить уже проверенный образ по digest.
Используют latest как единственный идентификатор. Невозможно уверенно сказать, какое содержимое скрывалось за тегом в момент выпуска. Сохраняйте digest каждого релиза и предыдущей рабочей версии.
Тестируют только исходники. Ошибка в Dockerfile, отсутствующий файл или неверная команда запуска проявятся уже после тестов. Запускайте собранный образ с необходимыми зависимостями до продвижения.
Передают рабочий секрет на этапе сборки. Такой секрет рискует оказаться в образе, истории слоёв или метаданных. Runtime-секреты передаются при запуске; секреты, действительно нужные сборщику, используют через специальный механизм build secrets. Документация Docker.
Считают успешный healthcheck доказательством исправной бизнес-логики. /health/ready не заменяет проверку заказа без индекса, работы платёжной интеграции и мониторинг реальных ошибок.
Откатывают код после разрушительной миграции. Старый образ не сможет работать с удалёнными полями или неизвестными значениями. Совместимость на время отката нужно проектировать до выпуска.
Не сохраняют прошлый образ. Политика очистки реестра может удалить digest, который записан в плане отката. Срок хранения образов должен покрывать ожидаемый период возврата.
Как собрать весь процесс для нашего API
Для сервиса заказов достаточно следующего проверяемого порядка:
- Изменение API и совместимая миграция попадают в запрос на слияние. Тест отдельно покрывает заказ без почтового индекса.
- CI проверяет объединённый коммит, собирает образ и запускает его с временными PostgreSQL и Redis.
- Образ публикуется в реестре. CI сохраняет его digest и связывает с коммитом.
- Этот digest развёртывается на staging с его собственными настройками, базой и ключами.
- После проверки тот же digest назначается production. Рабочие секреты поступают при запуске, как описано в предыдущей главе.
- Новый экземпляр проходит readiness и проверки основных маршрутов. Затем в течение согласованного времени наблюдаются реальные ошибки и задержки.
- При проблеме трафик возвращается на старый экземпляр или развёртывается предыдущий digest, если схема и данные остались совместимыми.
Так CI/CD становится связью между всеми уже разобранными частями инфраструктуры. Образ фиксирует программу, конфигурация и секреты задают окружение, Caddy направляет запросы, а PostgreSQL хранит состояние, которое нельзя откатить одной заменой контейнера. Хороший выпуск управляет всеми этими связями, а не только выполняет команду запуска.