CI/CD с артефактами: как тестировать Docker-образ и деплоить только то, что прошло проверки
Разберём пайплайн, где build фиксируется артефактом, затем выполняются линтинг/тесты, и только после этого — деплой. Поясним, как сохранять воспроизводимость окружений.
Содержание
CI/CD с артефактами: как тестировать Docker-образ и деплоить только то, что прошло проверки
В современном CI/CD ключевая инженерная проблема звучит почти философски: что именно вы деплоите? На практике часто получается так, что пайплайн “успешно прогнал тесты”, но затем в продакшен уезжает не тот же самый код, не тот же образ, не те же зависимости. Причины разные: пересобрали образ без строгой привязки, использовали “latest”, подтянули плагины/модули во время деплоя, тестировали в одном окружении, а деплоили в другом.
Хорошая стратегия — строить Docker-образ один раз, фиксировать его как артефакт, затем выполнять линтинг/тесты и только затем деплоить. В этой статье разберём такой пайплайн: как хранить и переиспользовать образ, как добиться воспроизводимости окружений и как избежать типичных ловушек, когда “прошли проверки” на самом деле означает “что-то похожее на то, что мы собрали”.
Общая архитектура пайплайна: build → артефакт → проверки → деплой
Рассмотрим цель в виде требований:
- Build должен быть детерминированным: при одинаковом состоянии репозитория и зависимостей сборка воспроизводима (или хотя бы максимально близка к этому).
- Проверки должны применяться к тому же артефакту, который уйдёт в продакшен.
- Деплой должен ссылаться на конкретный образ с конкретным digest/тегом, а не на “latest”.
- Окружение тестов должно совпадать с окружением рантайма (или быть строго контролируемым).
Чтобы выполнить пункты 2–4, логика пайплайна обычно выглядит так:
- Этап 1 (build): собрать Docker-образ и “зафиксировать” его идентификатор (лучше — digest).
- Этап 2 (lint/test): запустить проверки, используя уже собранный образ, а не пересборку.
- Этап 3 (deploy): деплоить тот же образ по идентификатору, прошедшему тесты.
Ключевой момент: мы не создаём “образ для тестов” и отдельно “образ для деплоя”. Мы берём один и тот же артефакт.
Docker-артефакты: что именно фиксировать (tag vs digest)
Почему теги — не всегда надёжно
Тег вроде myapp:1.2.3 удобен для чтения, но он не гарантирует неизменность образа. Существует практика “переписывать” тег — случайно или из-за неправильной настройки регистри. Даже если вы так не делаете, остаётся проблема воспроизводимости: не очевидно, что именно находится под тегом.
Почему digest лучше
Docker-образ имеет content-addressable идентификатор — digest (например, sha256:...). Он зависит от содержимого слоёв и манифеста. Если вы храните digest, вы получаете ссылку на конкретный неизменяемый артефакт.
В пайплайне это обычно выражается так:
- после
docker buildx buildполучить digest образа; - сохранить digest как результат/артефакт шага сборки (в CI это может быть переменная, output job’а, файл, artifact’ы);
- последующие шаги используют digest.
Воспроизводимость окружений: как сделать build детерминированным
Воспроизводимость — это не “идеальная магия”, а совокупность дисциплин.
1) Фиксируйте версии зависимостей
- Node.js:
package-lock.json(илиpnpm-lock.yaml,yarn.lock) в репозитории. - Python:
requirements.txtс версиями или lock-файл через pip-tools/poetry. - JVM:
dependency-lock/ lock-файлы, управление репозиториями Maven/Gradle. - Системные зависимости в Dockerfile: фиксируйте версии пакетов и используйте стабильные base образа (и закрепляйте их digests, если нужно максимально строго).
2) Используйте фиксированные base-образы
Если вы пишете FROM python:3.12-slim, при очередной сборке base может “незаметно” обновиться. Для строгой воспроизводимости вы можете:
- использовать pinned digest базового образа;
- либо принимать, что воспроизводимость “почти” гарантируется за счёт политики обновлений (например, пересборка только при изменении lock-файлов и base).
3) Учитывайте поведение билд-среды
- Таймзоны, локали, переменные окружения.
- Конвейер
npm install/pip installдолжен быть одинаковым. - Используйте BuildKit и настройте кеш аккуратно (кеш ускоряет, но не должен менять содержимое непредсказуемо).
4) Секреты не должны влиять на собранный образ
Иначе вы “соберёте” один и тот же код, но с разным результатом в слоях. Секреты должны применяться на этапе выполнения (runtime) или в билд-тайме только в тех местах, где вы гарантируете отсутствие записи в финальные слои (BuildKit имеет --mount=type=secret).
Пайплайн: пример структуры jobs и артефактов
Покажем концепцию на уровне “jobs” (без привязки к конкретному CI), а затем продемонстрируем рабочие команды Docker.
Предлагаемая схема
build-image:- собирает образ;
- пушит в registry;
- публикует digest (как output или artifact).
lint-test:- тянет образ по digest;
- запускает lint/test внутри контейнера;
- падает, если тесты не прошли.
deploy:- выполняется только при успешном завершении предыдущего этапа;
- деплоит тот же digest.
Что важно для логики “только то, что прошло проверки”
lint-testне должен собирать образ заново.deployне должен вычислять digest заново и не должен использовать “tag”.- Между этапами нужно передать именно digest.
Практический пример: как получить digest и переиспользовать образ
Ниже — пример, как в BuildKit собрать образ и получить его digest. Формат зависит от инструментария, но базовая идея одинакова.
Вариант с buildx и записью digest в переменную
Предположим, что у вас есть репозиторий и Dockerfile. Используем buildx:
# Переменные
IMAGE_NAME="registry.example.com/myteam/myapp"
GIT_SHA="$(git rev-parse HEAD)"
TAG="${GIT_SHA}"
# Создаём образ и пушим в registry, фиксируя digest
# --provenance=true по желанию, зависит от registry/BuildKit версии
docker buildx build \
--platform=linux/amd64 \
--tag "${IMAGE_NAME}:${TAG}" \
--push \
.
Получить digest “напрямую” можно несколькими путями. Один из практичных — запросить digest после пуша по тегу, а затем работать с digest. Например:
DIGEST="$(docker buildx imagetools inspect "${IMAGE_NAME}:${TAG}" \
--format '{{.Manifest.Digest}}')"
echo "IMAGE_DIGEST=${DIGEST}"
Дальше этот IMAGE_DIGEST вы сохраняете как output шага build-image, чтобы следующие job’ы использовали его.
Почему иногда приходится делать inspect после пуша? Потому что API/CLI могут отличаться по возможностям возвращать digest напрямую в stderr/stdout в зависимости от версии buildx/BuildKit. Подход “после пуша → запросить digest” работает стабильно.
Подготовка к запуску тестов в контейнере
Допустим, ваш контейнер по умолчанию запускает приложение, но для тестов удобнее иметь отдельную команду. Это можно сделать через переменную окружения или отдельный entrypoint.
Допустим, Dockerfile устроен так, что внутри образа доступны инструменты для линтинга/тестов. Тогда lint-test может запускать:
# Запуск lint
docker run --rm "${IMAGE_NAME}@${IMAGE_DIGEST}" npm run lint
# Запуск тестов
docker run --rm "${IMAGE_NAME}@${IMAGE_DIGEST}" npm test
Важно: мы запускаем образ по digest (IMAGE@sha256:...), а не по тегу.
Как устроить Dockerfile, чтобы тесты действительно были “внутри образа”
Частая ошибка: вы строите runtime-образ “облегчённым” (multi-stage), а lint/test выполняете в отдельной stage или на хосте. Это повышает шанс “не те проверки”. Правильнее определить, как именно вы хотите, чтобы тесты выполнялись.
Есть два типовых подхода.
Подход A: один образ содержит и рантайм, и toolchain для проверок
Плюсы:
- проще и чётче: один образ — один источник истины.
Минусы:
- образ может стать тяжелее; в продакшен вы, возможно, не захотите toolchain.
Если у вас небольшое приложение и вы готовы к компромиссу по размеру — это рабочий путь.
Подход B: multi-stage и “проверочный” stage в рамках того же Dockerfile
Ключевой момент: чтобы деплой был к артефакту “прошедшему проверки”, вам нужно либо:
- чтобы именно проверочный stage использовался для генерации того же финального образа, либо
- чтобы тесты запускались в образе, который вы позже задеплоите.
То есть если финальный образ — runtime, а lint/test выполняется в отдельном builder, то вы должны быть уверены, что результат билд-steps (артефакт runtime) не зависит от того, что происходило в builder. Иначе “прошли тесты” относится к builder, а деплоится runtime.
Самый строгий вариант: использовать один и тот же финальный stage как runtime, а линт/тесты запускать так, чтобы они были совместимы с runtime образом (например, ваш пакет тестов и eslint можно держать в dev dependencies и при необходимости запускать их в контейнере).
Иногда это решается установкой dev-зависимостей в тестовом job’е на этапе выполнения контейнера (но тогда вы снова меняете окружение). Поэтому лучше продумать структуру контейнера и зависимостей.
Стратегия “не пересобирай”: запуск тестов внутри уже собранного контейнера
Чтобы соблюсти правило “тестируем то, что деплоим”, вы должны гарантировать, что тесты:
- запускаются в контейнере из
IMAGE@digest; - используют зависимости из образа (а не устанавливают их заново на хосте);
- не модифицируют артефакт так, что это меняет итог.
Запуск на примере Node:
# Внутри контейнера нет смысла монтировать исходники
# — мы тестируем артефакт, собранный из конкретного commit
docker run --rm \
"${IMAGE_NAME}@${IMAGE_DIGEST}" \
sh -lc "npm run lint && npm test"
Если ваша тестовая команда требует переменных окружения (например, подключения к базе), вам нужно:
- либо поднять зависимости (db, redis) как сервисы в CI,
- либо использовать мок/встроенную in-memory конфигурацию,
- либо передавать тестовые настройки через env/секреты.
Что делать с миграциями и конфигурацией: деплой артефакта ≠ деплой данных
Важно различать:
- код и зависимости, которые содержатся в Docker-образе;
- конфигурацию, которая настраивается окружением (env, config maps, secrets);
- данные и миграции, которые должны выполняться согласованно с кодом.
Даже при идеальном CI/CD вы не можете “застатичить” миграции внутри образа так, чтобы не возникло разницы между окружениями. Но вы можете сделать процесс управляемым:
- миграции выполняются на этапе деплоя, но с конкретной версией образа (digest), чтобы понимать, что миграции относятся к конкретному релизу;
- миграции должны быть идемпотентными или строго последовательными;
- если используется schema versioning, храните её в БД и документируйте поведение.
Пример схемы workflow (в терминах CI): build → test → deploy
Ниже — псевдоконфигурация, чтобы показать связи. Без привязки к конкретному CI, но формат команд и переменных отражает типичные реализации.
Job 1: build-image
- Собрать образ.
- Запушить в registry.
- Найти digest.
- Сохранить digest как output.
# Выполняется в job build-image
IMAGE_NAME="registry.example.com/myteam/myapp"
TAG="${GIT_SHA}"
docker buildx build --tag "${IMAGE_NAME}:${TAG}" --push .
DIGEST="$(docker buildx imagetools inspect "${IMAGE_NAME}:${TAG}" --format '{{.Manifest.Digest}}')"
# В CI вы делаете output: IMAGE_DIGEST=${DIGEST}
echo "IMAGE_DIGEST=${DIGEST}"
Job 2: lint-test
- Взять
IMAGE_DIGESTиз build-image. - Запустить lint/test внутри
IMAGE@digest.
# job lint-test
docker run --rm "${IMAGE_NAME}@${IMAGE_DIGEST}" sh -lc "npm run lint"
docker run --rm "${IMAGE_NAME}@${IMAGE_DIGEST}" sh -lc "npm test"
Job 3: deploy
- Выполнить деплой строго на digest.
# job deploy
# Например, kubectl set image или helm chart с образом по digest.
kubectl set image deployment/myapp myapp="${IMAGE_NAME}@${IMAGE_DIGEST}"
kubectl rollout status deployment/myapp
Здесь важно: деплой использует тот же digest, что и тесты.
Типичные ошибки и подводные камни
Ошибка 1: тесты запускаются по tag, который может переехать
Если lint-test использует myapp:${TAG}, а где-то в registry тег перезаписывают (или вы случайно собрали ещё раз), то тесты могут проверять не тот контент. Используйте digest.
Ошибка 2: “пересборка ради тестов”
Некоторые пайплайны делают так: build один, тесты делают на исходниках или пересобирают в job’е тестов. Это ломает модель “один артефакт”.
Правило: в job’е тестов вы не должны выполнять docker build (или другие шаги, создающие новый образ), если цель — тестировать конкретный артефакт.
Ошибка 3: образ воспроизводится, но зависимостей “докачивается” во время контейнера
Например, ваш entrypoint может делать npm ci при старте. Тогда содержимое контейнера меняется на каждом запуске. Если вы тестируете контейнер, который каждый раз скачивает зависимости, вы не контролируете точную комбинацию.
Решение: зависимости должны быть уже в образе, а установка при старте — запрещённой практикой для release-контейнеров.
Ошибка 4: различие между линтингом/тестами и runtime окружением
Линтеры могут требовать dev dependency, а runtime — нет. Если вы запускаете линтеры “в другом образе”, уверенность падает. Компромисс:
- либо тестировать в образе, максимально совпадающем с runtime;
- либо вводить строгие политики соответствия (что изменится между builder и runtime).
Ошибка 5: секреты попадают в слои образа
Если в Dockerfile выполняются команды с секретами и они оказываются “закэшированными” в слоях, это может и нарушить безопасность, и нарушить воспроизводимость. Используйте BuildKit secret mount и гарантируйте отсутствие записи в финальные слои.
Как обеспечить прозрачность: артефакт должен быть трассируем
Хорошая инженерная гигиена — чтобы по логам можно было сказать:
- какой commit создал образ;
- какой digest ушёл в тесты;
- какой digest ушёл в продакшен;
- какие тесты запускались и в каких параметрах.
Практика:
- сохраняйте metadata артефакта: commit SHA, builder version, Dockerfile hash (или хотя бы путь/ветку);
- публикуйте результаты тестов как separate artifacts (JUnit report, coverage);
- в деплой-логах фиксируйте digest.
Трассируемость важна не только для дебага, но и для доверия к процессу.
Мягкая рекомендация по углублению
Если вам нужно структурно разобрать CI/CD как систему — от базовой теории до настройки практических пайплайнов с артефактами, образами и окружениями — полезным продолжением может стать материал «CI-CD от теории до практики». Он помогает собрать в одну модель то, что в проектах часто “дописывается по ходу”, и логически связать проверки с тем, что реально деплоится.
Вывод: тестируйте артефакт, а не процесс сборки “где-то там”
Стратегия CI/CD с артефактами для Docker решает не косметическую, а фундаментальную задачу: уменьшить разрыв между “проверено” и “выпущено”. Для этого достаточно трёх принципов:
- Собрать образ один раз и зафиксировать неизменяемый идентификатор (digest).
- Запускать проверки внутри этого же образа по digest, без пересборок.
- Деплоить строго тот же digest, который прошёл линтинг/тесты.
Когда эти принципы соблюдены, воспроизводимость становится измеримой: вы не “надеетесь, что оно то же самое”, а управляете цепочкой
Комментарии
Пока нет комментариев