Пул-реквест на три строки ждёт вердикта двадцать две минуты. Шесть из них Gradle качает те же библиотеки, что качал на прошлом прогоне, одиннадцать гоняет тесты в один поток на четырёхъядерном раннере. Разработчик за это время ушёл в другую задачу, и красный тест он увидит, когда правку придётся вспоминать заново. Такой конвейер не сломан — его просто завели «чтобы был» и ни разу не разобрали по шагам.
Порядок этапов на обеих дорожках один и тот же, а почти всё время съедают два из них — скачивание зависимостей и тесты. Кеш зависимостей убирает первый почти целиком, maxParallelForks делит второй между ядрами раннера. Компиляция и сборка образа остаются как были. Числа примерные — важны пропорции.
Из каких шагов состоит конвейер и что чего ждёт
Слово «параллельно» в описании конвейера обычно означает «мы так хотели». Что может идти рядом, а что обязано ждать, решает не файл конвейера, а входы каждого инструмента: одним хватает исходников, другим нужен байткод, третьим — готовый образ.
Рядом с компиляцией по-настоящему идут только Gitleaks и Dependency-Check — им хватает исходников и списка зависимостей. Error Prone отдельным шагом не бывает: это расширение javac, оно живёт внутри компиляции. SpotBugs читает байткод, интеграционные тесты грузят собранные классы, Trivy смотрит на готовый образ — все трое ждут. Числа те же, что на дорожках выше: 22 минуты без кешей, 9 с ними.
Порядок в цепочке — от дешёвого к дорогому: если код не компилируется, десять минут интеграционных тестов не нужны никому. И гейт стоит на каждом шаге, а не в конце: красная компиляция обрывает конвейер на второй минуте, красный Trivy — на семнадцатой, и в обоих случаях образ в реестр не попадает.
Файл конвейера: что идёт на PR, а что только на main
Вот конвейер с графа выше целиком — .github/workflows/ci.yml для GitHub Actions; в GitLab та же схема пишется через stages, needs и rules.
name: ci
on:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew build spotbugsMain --no-daemon
- uses: actions/upload-artifact@v4
if: always()
with:
name: reports
path: build/reports/
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
image:
needs: [build, gitleaks]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
load: true
tags: app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: app:${{ github.sha }}
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: 1
- if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- if: github.event_name == 'push'
run: |
docker tag app:${{ github.sha }} ghcr.io/${{ github.repository }}:${{ github.sha }}
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
Два повода для запуска живут в одном файле. На пул-реквесте идёт всё, кроме публикации; на push в main — то же плюс публикация и Dependency-Check: ещё одно задание той же формы, что build, с if: github.event_name == 'push', командой dependencyCheckAnalyze и NVD_API_KEY из секретов. Публиковать образ с произвольной ветки нельзя не из вежливости: реестр заполняется образами недоделанных веток, и однажды один из них кто-нибудь развернёт. На пул-реквесте из чужого форка это и не сработает: GITHUB_TOKEN там выдаётся только на чтение, а секреты репозитория в такой прогон не передаются вовсе — NVD_API_KEY был бы пустым, и это вторая причина держать Dependency-Check на main.
concurrency с cancel-in-progress: без него второй push в тот же PR встаёт в очередь за первым, и раннер ещё десять минут проверяет коммит, который никому не нужен; с ним старый прогон отменяется в момент нового push.
Задания без needs стартуют одновременно на отдельных раннерах, поэтому Gitleaks и сборка — разные задания, а не шаги одного. Образ ждёт обоих: с секретом в коде он не должен даже собраться. permissions у него заужены до чтения кода и записи в пакеты — токен по умолчанию умеет больше, чем нужно push. Ловушка с регистром в имени образа для ghcr.io разобрана в статье про реестры и сборку в CI.
Кеши Gradle: три разных и один ключ
Команда включает кеш ~/.gradle/caches, а конвейер по-прежнему качает на каждом прогоне сто с лишним мегабайт. Это не зависимости — это дистрибутив самого Gradle: wrapper кладёт его в ~/.gradle/wrapper/dists, и в первый кеш он не попал.
У Gradle три кеша, и ускоряют они разное. Кеш зависимостей (~/.gradle/caches/modules-2) хранит скачанные jar-файлы — это те самые шесть минут с верхней дорожки, которые превращаются в полминуты. Build cache (~/.gradle/caches/build-cache-1, включается org.gradle.caching=true) хранит результаты задач по хешу их входов: модуль, в котором ничего не менялось, не перекомпилируется, а задача test, у которой не изменились ни классы, ни ресурсы, помечается FROM-CACHE и не запускается вовсе. Configuration cache (.gradle/configuration-cache в каталоге проекта, org.gradle.configuration-cache=true) хранит граф задач и экономит фазу конфигурации — на многомодульном проекте это десятки секунд перед каждой сборкой.
Ключ кеша — вторая половина вопроса. С хешем коммита в ключе кеш не попадёт никогда: каждый прогон ищет ключ, которого ещё нет. С постоянным ключом он перестанет обновляться: сохраняется кеш только при промахе, и через месяц в нём прошлогодние версии, а новые качаются мимо. Рабочая схема — точный ключ по хешу файлов сборки, hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle.lockfile'), плюс restore-keys с префиксом: при промахе поднимается ближайший старый кеш, после прогона сохраняется новый. На репозиторий GitHub даёт 10 ГБ кеша и вытесняет лишнее по давности — кеш, который только растёт, вытеснит сам себя.
Всё это делает gradle/actions/setup-gradle из файла выше: сохраняет зависимости, дистрибутив wrapper и build cache, строит ключи с восстановлением ближайшего, а заодно сверяет контрольную сумму wrapper с официальной. В GitLab то же собирают руками: GRADLE_USER_HOME переносят внутрь каталога проекта, потому что GitLab кеширует только пути внутри него, и в cache.key.files кладут gradle-wrapper.properties и файлы сборки.
Одна и та же сборка на любом раннере
«У меня собирается» чаще всего означает разную Java: на ноутбуке 21, на раннере 17, и record-паттерн из свежего PR не компилируется. Версию фиксируют в сборке, а не в памяти команды:
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
С toolchain Gradle ищет ровно JDK 21 и, не найдя, падает с внятным сообщением, а не собирает «какой есть»; с плагином foojay-resolver в settings.gradle.kts недостающий JDK он скачает сам. На раннере JDK ставит setup-java, он же выставляет JAVA_HOME.
Вторая причина невоспроизводимости — динамические версии: 1.+, latest.release, SNAPSHOT. Два прогона одного коммита в разные дни получают разные jar-файлы, и «build once» ломается ещё до начала CD. Лечит блокировка версий: dependencyLocking { lockAllConfigurations() } в сборке и gradle.lockfile в репозитории; обновляют его через --write-locks отдельным коммитом с понятным диффом.
И --no-daemon в командах файла выше: на одноразовом раннере прогретую JVM демона переиспользовать некому, а сам он — ещё один процесс на сотни мегабайт рядом с тестами и контейнерами.
Слои образа: что пересобирается при правке одной строки
Кеш Gradle ускоряет сборку, но образ на выходе всё равно может ехать в реестр целиком. Обычный «толстый» jar — один файл, в котором лежит и ваш код, и все библиотеки; в образе он ложится одним слоем. Поправили строчку — слой пересобрался целиком, вместе с сорока мегабайтами зависимостей, которые не менялись, и каждый узел кластера скачает его заново.
Слой с библиотеками справа меняется только тогда, когда меняется версия какой-то зависимости, — раз в неделю, не сорок раз в день. Всё остальное время в реестр и на узлы едет один мегабайт вместо сорока одного. Размеры примерные, важно соотношение.
Правая стопка — это layered jar: начиная со Spring Boot 2.4 bootJar собирает его так по умолчанию, а в Dockerfile java -Djarmode=layertools -jar app.jar extract (в Spring Boot 3.3 и новее — -Djarmode=tools) распаковывает jar на части, и каждая копируется отдельной инструкцией COPY — зависимости раньше, свой код последним. Без Dockerfile то же даёт bootBuildImage: buildpacks раскладывают образ по слоям сами. Как Docker решает, какой слой переиспользовать, разобрано в статье про многоступенчатую сборку и слои.
Тесты в CI: Testcontainers и что с ним ломается
Unit-тесты — один класс без Spring-контекста, базы и сети, секунды на весь набор — идут внутри шага компиляции и первыми говорят «нет». Интеграционные поднимают контекст и настоящие зависимости: PostgreSQL в контейнере через Testcontainers, внешние HTTP-сервисы — через WireMock.
@SpringBootTest
@Testcontainers
class OrderServiceIT {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
OrderRepository orderRepository;
@Test
void shouldSaveOrder() {
Order saved = orderRepository.save(new Order("ORD-1"));
assertThat(orderRepository.findById(saved.getId())).isPresent();
}
}
Здесь важна вторая аннотация. @Container только поднимает контейнер — Spring про него ничего не знает и полезет в базу по адресу из application.yml: тест либо упадёт на старте, либо тихо уйдёт в чужую базу и будет портить там данные. Связывает их @ServiceConnection (Spring Boot 3.1 и новее): Spring сам берёт у контейнера адрес, логин и пароль и подставляет их в spring.datasource.*. На версиях до 3.1 то же делают руками через @DynamicPropertySource.
Testcontainers запускает настоящий PostgreSQL в Docker прямо из теста, значит, раннеру нужен доступ к Docker-демону, и он не даётся сам собой. У GitHub-раннеров демон есть только на Linux-образах: на macOS его нет вовсе, на Windows доступны лишь Windows-контейнеры. В GitLab на общих раннерах Docker включают отдельно — сервисом docker:dind или пробросом сокета хоста. Это первое, что проверяют, когда интеграционные тесты идут локально, но не стартуют в CI.
Дальше — ловушка со временем. static под @Testcontainers даёт один контейнер на класс: поднимается перед тестами класса, гасится после. Десять интеграционных классов — десять запусков PostgreSQL подряд, ровно та трата минут, от которой хотели уйти. Чтобы контейнер поднялся один раз на прогон, его выносят в общий базовый класс и запускают сами: статическое поле плюс postgres.start() в статическом блоке, без @Container — тогда контейнер живёт, пока жива JVM.
И тут «пока жива JVM» встречается с maxParallelForks. Форк — это отдельная JVM со своими статическими полями, так что maxParallelForks = 4 — четыре PostgreSQL, четыре служебных контейнера Ryuk (Testcontainers поднимает по одному на JVM) и четыре Spring-контекста на одном раннере. На четырёх ядрах и 16 ГБ это проходит и даёт те самые 11 → 3,5 минуты с нижней дорожки; на двух ядрах и 7 ГБ четыре JVM встают в очередь за процессором, время не падает, а память складывается — контекст с базой это около полугигабайта на JVM плюс сам PostgreSQL. Поэтому форки задают отдельно для unit-задачи и отдельно для интеграционной, и для второй число считают от раннера, а не берут «сколько ядер». Альтернатива — одна JVM, один контейнер и параллельность внутри JUnit (junit.jupiter.execution.parallel.enabled=true): контейнер общий, зато тесты делят одну базу, и каждому нужна своя схема или откат транзакции после теста.
Нестабильный тест — тот, что падает раз в десять прогонов, — чинят, а не перезапускают: почти всегда он ждёт через Thread.sleep вместо ожидания по условию, и та же гонка живёт в коде, который он проверяет. Автоперезапуск прячет её до продакшена и приучает команду не читать красное; Thread.sleep(10_000) вдобавок стоит десять секунд на каждом прогоне навсегда — ждут через Awaitility, пока условие не выполнится. И тесты не ходят в интернет: обращение к общему стенду или публичному API роняет прогон от чужих причин — лёг соседний сервис, пропала сеть у раннера. Всё внешнее поднимают контейнером или заменяют WireMock.
Отчёты: куда смотрит человек, когда красное
Gradle после прогона оставляет JUnit XML в build/test-results/test/ и HTML-отчёт в build/reports/tests/test/. Без отдельных действий упавший тест — одна строка в логе на три тысячи, и искать её каждый ревьюер будет заново.
В файле выше отчёты выгружаются артефактом с if: always(): без этого условия шаг пропускается, когда предыдущий упал, — ровно тогда, когда отчёт нужен. JUnit XML читает действие вроде dorny/test-reporter и выкладывает падения аннотациями в PR: имя теста, сообщение и строка во вкладке «Files changed». В GitLab это встроено — artifacts: reports: junit: показывает упавшие тесты в виджете merge request.
Покрытие считает JaCoCo: jacocoTestReport кладёт HTML в build/reports/jacoco/test/html/, а порог проверяет jacocoTestCoverageVerification. На PR нужен процент по изменённым строкам, а не общий: его дают SonarQube (условие на «новый код») или действие, которое пишет в PR комментарий с покрытием диффа.
Quality gates: что вправе блокировать слияние
Отчёт, который никто не читает, — не проверка, а шум: находки копятся, потому что «некогда разбираться», и через год их находит аудит. Отсюда правило: гейт либо закрывает слияние, либо его нет.
| Проверка | Инструмент | Блокирует | Где стоит |
|---|---|---|---|
| Ошибки, которые видит компилятор | Error Prone | Да | внутри javac |
| Баги в байткоде, инъекции, слабая криптография | SpotBugs + FindSecBugs | Да, по уровню серьёзности | после компиляции |
| Секреты в коде и истории | Gitleaks | Да, всегда | рядом с компиляцией |
| Уязвимые зависимости | OWASP Dependency-Check | Да, по порогу CVSS | на main и ночью |
| Уязвимости в образе | Trivy | Да, по уровню серьёзности | после сборки, до push |
if (ids.contains(orderId)), где ids — List<String>, а orderId — Long, компилируется, всегда даёт false и уходит в прод. Error Prone ловит такое во время javac: это расширение компилятора со списком типовых ошибок — equals несовместимых типов, перепутанные параметры, забытый @Override.
SQL, склеенный из строки запроса, поток, который не закрыли, MessageDigest.getInstance("MD5") для пароля — этого компилятор не видит. SpotBugs читает байткод, а расширение FindSecBugs добавляет к нему проверки на инъекции, XSS и слабую криптографию.
Токен, положенный в application-local.yml «на минутку» и закоммиченный вместе с правкой, — типичная находка Gitleaks. Секрет в истории считается скомпрометированным в момент попадания: боты вычитывают свежие коммиты быстрее, чем автор заметит. Поэтому блокировка безусловная, а после срабатывания секрет отзывают, а не вычищают из истории. Оговорка, без которой гейт даёт ложное чувство защиты: CI по умолчанию клонирует репозиторий с fetch-depth: 1 — один снимок без истории, и сканировать будет нечего; в файле выше у Gitleaks стоит fetch-depth: 0. Всю историю гоняют раз в неделю отдельным заданием, на PR хватает его коммитов, а локально тот же Gitleaks ставят pre-commit хуком.
Код не менялся месяц, а вчера в библиотеке, которую он использует, нашли уязвимость уровня Log4Shell — ради этого стоит OWASP Dependency-Check: сверяет зависимости с базой NVD и падает при CVSS выше порога (failBuildOnCVSS = 7). Отсюда и его место — слияние в main, ночной прогон и релиз, а не каждый PR: уязвимость приходит из базы, а не из вашего диффа. И главная причина, по которой этот гейт выключают: с конца 2023 года NVD отдаёт данные через API с лимитом — без ключа 5 запросов в 30 секунд. На тысячу зависимостей инструмент выкачивает около двухсот страниц, и без ключа первый прогон висит часами или падает по таймауту; с бесплатным NVD_API_KEY лимит 50 запросов в 30 секунд, и прогон занимает две-три минуты. Скачанную базу держат в кеше между прогонами, иначе каждая ночь начинается с полной загрузки.
Ваш код чист, но базовый образ с JRE несёт уязвимость в системной библиотеке — это зона Trivy. Он сканирует готовый образ, поэтому стоит после сборки и обязательно до push: образ в реестре уже можно развернуть. Настройка, без которой гейт не проживёт недели, — ignore-unfixed: true: в любом базовом образе есть CVE, для которых исправления ещё нет, и без флага не соберётся ничего; такие находки уходят в задачу со сроком, блокируют только те, где патч уже выпущен.
Порог на общий процент покрытия — слабый гейт. Покрытие измеряет, какие строки выполнялись, а не проверены ли они, и порог поощряет тесты «ради процентов»: вызвать метод и ничего не проверить.
Полезнее покрытие изменённого кода: новые строки в PR должны быть затронуты тестами, а общий процент показывают как тренд, не как шлагбаум. Упавший с 78 до 76 общий процент при удалении мёртвого кода вместе с его тестами — не повод блокировать.
Блокировать слияние должно то, что не обсуждается: сборка, падающие тесты, ошибки анализа уровня «баг», критичные уязвимости, секреты. Предупреждения уровня «стиль» показывают в отчёте, но не блокируют — иначе их глушат аннотациями, и настоящие тонут в шуме. По той же причине форматер не делают гейтом: каждый PR превращается в спор о скобках, а стиль выравнивается автоформатером при сохранении файла.
Что получается на выходе и куда едет тег
Результат успешного CI — Docker-образ с тегом, равным хешу коммита: по тегу всегда можно понять, что именно развёрнуто. Чтобы это же было видно изнутри работающего сервиса, Spring Boot умеет положить в jar метаданные сборки:
springBoot {
buildInfo()
}
Это кладёт в jar файл build-info.properties — группу, артефакт, версию и время сборки; в образ он попадает вместе с jar. Хеша коммита там нет: его добавляет отдельный плагин git-commit-id, который генерирует рядом git.properties. И увидеть всё это по HTTP сразу не получится: по умолчанию Spring Boot отдаёт наружу только health, остальные эндпоинты открывают явно, а полный хеш вместо укороченного включают настройкой:
management:
endpoints:
web:
exposure:
include: health,info
info:
git:
mode: full
После этого /actuator/info возвращает версию, время сборки и хеш коммита — на вопрос «что на стенде» отвечает сервис, а не тот, кто последним деплоил.
Дальше тег должен попасть туда, откуда его подхватит развёртывание. При GitOps это отдельный репозиторий манифестов: конвейер или бот вроде Renovate открывает в нём PR со строкой image: ghcr.io/org/app:<sha>, оператор кластера применяет изменение после слияния. Продвижение на прод — тот же тег в манифесте прода, откат — git revert. Полная петля от PR до включения флага нарисована в статье про ветки и релизный цикл.
Порядок, в котором разбирают медленный конвейер
Ориентир — 15 минут на PR. Если не укладывается, идут сверху вниз и не перескакивают:
- Кеши — зависимости, wrapper, build cache.
- Параллельные задания — Gitleaks и Dependency-Check рядом с компиляцией.
- Форки в тестах — с расчётом контейнеров и памяти.
- Ревизия тестов —
Thread.sleepи контейнер на класс вместо контейнера на прогон. - Раннер мощнее — только когда всё выше исчерпано.
Глубже: миграции базы в конвейере: кто накатывает и на каком шагерасширенное
Конвейер выше собирает и тестирует код, а схему базы меняет кто-то другой, и в половине команд этот кто-то это приложение при старте. Работает, пока реплика одна. С тремя репликами три пода одновременно берут блокировку миграции, упавшая миграция отправляет под в бесконечный перезапуск, а долгая срывает проверку старта, и выкат разваливается там, где тестов не было.
Отдельный шаг перед выкатом. Миграцию выполняет конвейер, а не приложение: шаг между публикацией образа и выкатом запускает Liquibase или Flyway против базы окружения (тем же образом с профилем без веб-сервера, либо контейнером Liquibase с папкой миграций из того же коммита), ждёт завершения и только при успехе передаёт управление выкату. В Kubernetes это Job с уникальным именем на версию, о чём статья про деплой; у приложения при старте остаётся только validate, который сверяет код со схемой и роняет под на расхождении, но ничего не меняет.
Совместимость по шагам. Во время выката старая и новая версии работают с одной базой, и откат возвращает старую версию к новой схеме. Поэтому миграция обязана быть совместима с предыдущей версией кода: добавить колонку с умолчанием и допуском NULL, писать в обе, перелить данные пакетами, переключить чтение, удалить старую, и это пять выкатов, а не один. Что запрещено одним шагом (переименование, смена типа, обязательная колонка без умолчания) и как ставить lock_timeout перед ALTER, разбирает статья про эволюцию схемы; правило N-1 и тест «старая версия против новой схемы» есть в статье про корректное завершение и базу.
Если миграция прошла, а выкат нет. Это штатная ситуация, ради которой и нужна совместимость: старая версия продолжает работать с новой схемой, откатывать DDL не нужно, и следующий выкат просто повторит попытку. Откат миграций (rollback у Liquibase, undo у Flyway) держат для стендов и не считают частью аварийного плана: удаление колонки с данными назад не откатывается. Если миграция упала посередине, её чинят вперёд новой миграцией, а PostgreSQL с транзакционным DDL хотя бы не оставит половину.
Проверка в CI. Каждая миграция прогоняется в тестах на Testcontainers с чистой базы (это делает Spring Boot сам) и, отдельным шагом, на снимке схемы прода с образцом данных: миграция, которая проходит на пустой таблице за миллисекунду, на таблице в миллиард строк держит блокировку минуты. Снимок обновляют раз в месяц, данные в нём обезличены.
Глубже: секреты конвейера: федерация, маскирование и сборки из форковрасширенное
Один пункт про секреты в списке ошибок не покрывает главного: конвейер это самый привилегированный код в компании, у него есть доступ и к реестру, и к облаку, и к базе, и о нём думают меньше всего.
Откуда доступы. Секреты живут в хранилище системы сборки или во внешнем хранилище (Vault), а не в репозитории и не в переменных, видимых всем заданиям. Доступ в облако и в реестр конвейер получает без вечных ключей: задание предъявляет облаку токен OIDC своей системы сборки и получает временные права роли с условием на репозиторий и ветку, о чём статья про права в облаке. Вечный ключ, если он остался (старая система, внешний партнёр), живёт с датой ротации и правами на одну операцию. Токен самой системы сборки, которым задание ходит в свой репозиторий, тоже режут до минимума: чтение кода и запись в реестр пакетов, а не «всё».
Маскирование. Система сборки прячет в журнале значения известных ей секретов, но не их производные: base64 от пароля, строку подключения, в которую пароль подставлен, вывод set -x или отладочную печать переменных окружения. Правило: секреты не печатают вовсе, отладку включают без них, а новые секреты, полученные внутри задания (временный токен), регистрируют для маскирования явно.
Сборки по pull request из форков. Задание, запущенное чужим кодом, не должно видеть секретов, и системы сборки по умолчанию так и делают: у сборки из форка секретов нет, и она падает на шаге, которому нужен реестр. Обход, который выглядит удобным (событие, дающее форку доступ к секретам базового репозитория), позволяет любому прислать pull request со скриптом, который выводит секреты наружу; его не включают, а шаги с секретами держат только для событий из своего репозитория и после ревью.
Утёкший секрет. Секрет, попавший в журнал сборки, в коммит или в артефакт, считается скомпрометированным: ротируют немедленно, потом ищут, кто им пользовался, и только потом чистят следы; порядок действий разобран в статье про секреты в разделе безопасности. Сканер секретов в хуке и в CI ловит это до слияния и стоит дешевле любого разбора.
Глубже: цепочка поставки артефакта: SBOM, подпись, неизменяемые тегирасширенное
Артефакт собирается один раз и едет по окружениям. Вопрос, на который конвейер обязан отвечать: то, что запустилось в проде, это ровно то, что собрано из этого коммита, и из чего оно состоит.
Состав. SBOM это список всего, что вошло в артефакт: зависимости с версиями, базовый образ, его пакеты. Плагин CycloneDX для Gradle генерирует его на шаге сборки, сканер образа добавляет пакеты операционной системы, и файл публикуется рядом с образом. Ценность видна в день следующей уязвимости уровня Log4Shell: вопрос «где у нас эта библиотека» решается запросом по SBOM всех сервисов, а не неделей раскопок.
Подпись. Образ подписывают на шаге публикации (cosign умеет подписывать без ключей, по токену OIDC того же задания, и запись уходит в публичный журнал), а среда выполнения проверяет подпись перед запуском: политика в кластере отвергает образ без подписи от вашего конвейера. Так подмена образа в реестре или запуск образа, собранного на ноутбуке, становится невозможной по устройству, а не по договорённости. Тем же способом подписывают SBOM и свидетельство о происхождении (кто, из какого коммита, каким конвейером собрал).
Неизменяемость. Тег в реестре по умолчанию можно перезаписать, и 1.4.2 завтра может означать другой образ. Реестры умеют запрещать перезапись тегов, и это включают для боевых реестров; а выкат ссылается на дайджест, который перезаписать нельзя в принципе. Сами действия конвейера тоже закрепляют по хешу коммита, а не по плавающему тегу версии: действие, которое обновили под вами, это чужой код с вашими секретами.
Срок хранения. Реестр без политики растёт до размеров базы и стоит соответственно; политика оставляет последние N версий на сервис и всё, что выкачено, а старое удаляет. Откат требует, чтобы предыдущий образ остался, поэтому «выкаченные» помечают и из чистки исключают. Тот же порядок для артефактов сборки в хранилище пакетов и для отчётов тестов.
Коротко
- Конвейер — файл с двумя поводами для запуска: на PR всё, кроме публикации, на
main— то же плюс образ в реестр.concurrencyгасит устаревшие прогоны,needsзадаёт, что чего ждёт. - Рядом с компиляцией идут только Gitleaks и Dependency-Check; Error Prone живёт внутри
javac, SpotBugs и интеграционные тесты ждут байткод, Trivy — образ. - Кешей у Gradle три плюс дистрибутив wrapper; ключ — хеш файлов сборки с
restore-keys, иначе кеш либо не попадает, либо протухает.setup-gradleделает это сам. - Воспроизводимость держат toolchain на JDK и
gradle.lockfileна версиях; динамические версии ломают «build once» до начала CD. - Гейт либо закрывает слияние, либо его нет: блокируют сборка, тесты, баги, критичные CVE и секреты; стиль и общий процент покрытия — отчёт. Форк тестов — отдельная JVM:
maxParallelForks = 4с Testcontainers это четыре PostgreSQL, число считают от памяти раннера. - Dependency-Check — на
mainи ночью, только сNVD_API_KEY; Gitleaks — сfetch-depth: 0; Trivy — доpushи сignore-unfixed. - Тег образа — SHA коммита; в манифест деплоя он попадает отдельным PR. Layered jar: при правке кода заново едет один мегабайт вместо сорока одного.
- Миграции накатывает конвейер отдельным шагом до выката, приложение при старте только проверяет; миграция совместима с предыдущей версией, DDL назад не откатывают, а проверяют на снимке схемы прода.
- Секреты конвейера: хранилище и федерация OIDC вместо вечных ключей, ничего не печатать, сборкам из форков секретов не давать, утёкшее ротировать первым.
- Цепочка поставки: SBOM рядом с образом, подпись при публикации и проверка перед запуском, теги без перезаписи и выкат по дайджесту, политика хранения с исключением выкаченного.
Что пощупать
Конвейер практикума remodov/marketplace-system — один файл .github/workflows/ci.yml: он запускается на pull request и на main, поднимает PostgreSQL и Redis как сервисы задания, гоняет тесты трёх сервисов и веб-клиента и той же проверкой, что и разработчик локально, смотрит на манифесты и образ.
Код: ci.yml, check-deploy.py.
Сделаем сами
Ветка step-15-delivery-and-observability.
Что почитать дальше
- Принципы конвейера — правила, на которых стоит этот файл: артефакт один раз, зелёный
main. - Ветки и релизный цикл — куда едет тег после CI: PR в репозиторий манифестов, Argo CD, флаг.
- Security Style Guide — те же гейты с кодами правил, порогами и подавлением находок со сроком.
- Test Strategy Style Guide — слои тестов, которые конвейер исполняет, и что из них обязан проверять каждый PR.