CI легко завести и трудно сделать быстрым и поддерживаемым: сначала один job на всё, потом он растёт до пятнадцати минут, кэш не работает, секреты лежат в plaintext, а копипаста одного и того же yaml расползлась по десяти репозиториям. Эта статья — про пайплайн как спроектированный артефакт: как разложить его на стадии, чем и что кэшировать, как переиспользовать конфигурацию и как не платить за CI больше минутами, чем он экономит. На примере GitHub Actions, но принципы переносимы.
Первая, флагманская статья серии «CI/CD на практике». Дополняет self-hosted runnerСкоро (железо) дизайном самого конвейера.
В статье
- Пайплайн — это спроектированный артефакт
- Структура: job’ы,
needs, стадии - Кэш зависимостей: что он реально экономит
- Порядок слоёв Docker решает глубину инвалидации
- Переиспользование: matrix, reusable workflows, composite actions
- Секреты: OIDC вместо долгоживущих ключей
- Гейты: required checks и merge queue
- Валидация: workflow тоже код
- Пиннинг версий: тег — движущаяся ссылка
- Скорость и стоимость
- Демо и версии
- Документация
Пайплайн — это спроектированный артефакт
Раннер — это железо: где выполняются шаги. Про то, как поднять свой, есть отдельная статьяСкоро. Здесь — про то, что на этом железе выполняется: сам конвейер. И конвейер — не список команд, а артефакт, который проектируют.
Разница видна на траектории, по которой CI обычно деградирует. Сначала один job на всё: собрать, протестировать, залить. Он работает и растёт — до пятнадцати минут, из которых половина уходит на то, что не менялось. Потом этот yaml копируют во второй репозиторий, в третий, и любое исправление приходится вносить в десять мест. Секреты лежат долгоживущими токенами в настройках, потому что «так быстрее». Каждый из этих шагов по отдельности разумен, а сумма — конвейер, который дорого гонять и страшно трогать.
Всё, что ниже, — про то, как этого не допустить: разложить работу на стадии, кэшировать то, что не менялось, вынести общее в один источник правды, заменить постоянные ключи на короткоживущие и поставить на вход ворота, которые нельзя обойти. Пример — на GitHub Actions, но структура переносима: те же стадии, тот же кэш, те же гейты есть в GitLab CI, Forgejo Actions и почти любой современной системе. Числа, где они приведены, сняты с digital-cookbook/infrastructure/cicd — рабочего стенда с этим пайплайном и локально воспроизводимыми замерами.
Структура: job’ы, needs, стадии
Единица планирования в GitHub Actions — job. Job выполняется на своём раннере, в своей файловой системе, изолированно от других. Внутри job — шаги, они делят окружение. Между job’ами по умолчанию нет ни общего диска, ни порядка: они стартуют параллельно.
Порядок задаёт needs. needs: test означает «не начинай, пока test не прошёл зелёным». Из этого собирается граф стадий — не список, а зависимости:
flowchart LR
lint[lint] --> test[test]
test --> build[build]
build --> publish[publish]
build -. "artifact" .-> publish
publish --> registry[(registry)]
Стадии стоит разложить по стоимости и вероятности провала. Дешёвое и часто падающее — раньше: lint ловит опечатку за секунды, и нет смысла гонять пятиминутный test, если код не отформатирован. Дорогое — позже: build не запустится, пока тесты не зелёные, а publish — пока нечего публиковать.
Цепочка в стенде строгая (lint → test → build → publish), но needs описывает именно зависимости, а не порядок ради порядка. Если lint и test друг от друга не зависят — а обычно так и есть, — их стоит развести параллельно (test без needs: lint, build с needs: [lint, test]): тогда обе проверки идут одновременно, и общее время пайплайна определяется медленнейшей, а не суммой. Условие на ветку — тот же инструмент экономии: publish осмыслен только на main, и if: github.ref == 'refs/heads/main' на нём избавляет от бессмысленной публикации с каждого pull request.
jobs:
lint:
runs-on: ${{ inputs.runs-on }}
steps: [ ... ]
test:
needs: lint
runs-on: ${{ inputs.runs-on }}
steps: [ ... ]
build:
needs: test
runs-on: ${{ inputs.runs-on }}
steps: [ ... ]
publish:
needs: build
permissions:
contents: read
id-token: write # OIDC — про это ниже
steps: [ ... ]Отдельный вопрос — вести ли job до первой ошибки или собирать все. Для матрицы версий (о ней ниже) по умолчанию включён fail-fast: true: упала одна ячейка — гасятся остальные. Это экономит минуты, но прячет картину: вы видите, что сломалось на Go 1.25, и не знаете, сломано ли на 1.26. Когда важно увидеть все проблемы за один прогон, fail-fast: false того стоит.
Артефакты между стадиями не передаются сами — у job’ов нет общего диска. Собранный на стадии build бинарь доезжает до publish только через actions/upload-artifact и actions/download-artifact:
# в build:
- name: Собрать бинарь
run: CGO_ENABLED=0 go build -o out/app .
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: app-${{ inputs.go-version }}-${{ inputs.runs-on }}
path: app/out/app
if-no-files-found: error # нет файла — это ошибка, а не пустой аплоадif-no-files-found: error здесь не украшение. Без него шаг, не нашедший файла (переименовали, промахнулись путём), молча загрузит пустоту, а publish упадёт позже и невнятно — на стадии, где искать причину дольше всего.
Кэш зависимостей: что он реально экономит
На GitHub-hosted раннерах каждый прогон CI начинает с чистого листа: свежая машина, пустые каталоги. Первое, что делает сборка, — заново качает зависимости и заново компилирует то, что не менялось. Кэш существует, чтобы этого не делать. (На self-hosted раннере чистота не гарантирована: рабочий каталог и кэши могут пережить прогон — это и ускоряет сборку, и создаёт риск, что зелёный результат опирается на артефакты предыдущего запуска.)
Важно понимать, что именно кэшируется, потому что это два разных кэша с разным сроком жизни. Кэш зависимостей (GOMODCACHE в Go, ~/.m2 в Maven, node_modules/~/.npm в Node) — скачанные пакеты; он меняется только при изменении списка зависимостей. Кэш сборки (GOCACHE, incremental-компиляция) — результаты компиляции; он инвалидируется правкой кода.
Разницу видно на замере со стенда. Один и тот же Go-проект собирается с холодным кэшем, потом с тёплым, потом — после правки исходника:
прогон 1 прогон 2 прогон 3 независимый
холодный: 29.254 с 12.328 с 11.619 с 18.808 с
тёплый (без правок): 5.571 с 5.593 с 5.367 с 10.388 с
(×5.3) (×2.2) (×2.2) (×1.8)
после правки кода: 6.400 с 7.266 с 5.776 с —Тёплая сборка заметно быстрее холодной — это и есть то, ради чего кэш восстанавливают между прогонами. Ключевое во второй и третьей строке: правка кода не роняет кэш зависимостей. Компилятору пришлось пересобрать изменённый пакет, но качать модули заново не понадобилось — они взяты из GOMODCACHE, и сборка после правки осталась близка к тёплой, а не к холодной.
Оговорка, без которой числа врут. Локальные секунды — не тайминги job’а в облаке, и величину выигрыша обобщать нельзя: по четырём прогонам, включая независимый на другой машине, она колеблется от ×1.8 до ×5.3. Причина в том, что в холодное время входит скачивание зависимостей — оно зависит от сети, прогрева прокси и загрузки машины, поэтому разброс возникает в основном в числителе. Переносится характер, а не множитель: тёплый кэш заметно дешевле холодного, а правка кода защищена от перекачки зависимостей. Насколько именно — измеряйте у себя.
В GitHub Actions это восстановление вешается на ключ. Ключ привязывают к содержимому lock-файла, а restore-keys дают частичное попадание, когда точного нет:
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
${{ steps.paths.outputs.build }} # GOCACHE
${{ steps.paths.outputs.mod }} # GOMODCACHE
key: go-${{ inputs.go-version }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
go-${{ inputs.go-version }}-hashFiles('**/go.sum') в ключе означает: сменился go.sum — сменился ключ, кэш перестраивается. Не сменился — берётся как есть. restore-keys — запасной вариант: точного ключа нет (например, добавили зависимость), но кэш от предыдущего go.sum всё равно полезнее пустоты, и по префиксу берётся он.
Порядок слоёв Docker решает глубину инвалидации
Если пайплайн собирает Docker-образ (детально про саму сборку — в статье про production-сборку), в игру вступает второй кэш — слоёв. BuildKit кэширует результат каждой инструкции Dockerfile и переиспользует его, пока не изменился ни сам слой, ни один из предыдущих. Последнее слово — ключевое: инвалидация каскадна, изменение раннего слоя обнуляет все слои ниже.
Отсюда правило, которое видно на замере. Правильный Dockerfile копирует манифесты зависимостей и качает их до копирования исходников:
COPY go.mod go.sum ./
RUN go mod download # слой зависимостей — выше кода
COPY . .
RUN go build -o /out/app . # слой сборки — нижеТогда правка кода инвалидирует только COPY . . и сборку, а дорогой слой go mod download остаётся из кэша. «Плохой» порядок (COPY . . до go mod download) обнуляет и скачивание зависимостей на любой правке кода. Замер со стенда — пересборка после правки одной строки кода:
порядок CACHED-слоёв слой go mod download
правильный (зависимости до COPY . .) 4 кэширован
плохой (COPY . . до download) 2 пересобранЧисло CACHED-слоёв — прямое наблюдение из вывода BuildKit, а не оценка. При правильном порядке слой скачивания зависимостей помечен CACHED и не выполняется; при плохом — выполняется заново на каждую правку кода. Тёплая сборка без изменений в обоих прогонах стенда шла в 3.6–4.6 раза быстрее холодной — но эта экономия держится ровно до тех пор, пока порядок слоёв защищает дорогие слои от частых правок.
Переиспользование: matrix, reusable workflows, composite actions
Три уровня дублирования — три инструмента против него.
Matrix убирает копипасту внутри одного workflow. Нужно проверить на нескольких версиях языка и платформах — не пишут четыре почти одинаковых job’а, а задают оси:
strategy:
fail-fast: false
matrix:
go-version: ["1.25", "1.26"]
runs-on: [ubuntu-latest, macos-latest]GitHub разворачивает это в четыре параллельных прогона. fail-fast: false здесь осознанный: за один прогон видно все сочетания, где есть проблема, а не только первое.
Composite action убирает повтор внутри репозитория — последовательность шагов, которая нужна в каждом job’е. Установка языка и восстановление кэша — ровно такая последовательность, и она вынесена в один action, который вызывают все стадии:
runs:
using: composite
steps:
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ inputs.go-version }}
- name: Определить пути кэша Go
id: paths
shell: bash
run: |
echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
echo "mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with: { ... }Reusable workflow убирает повтор между репозиториями — весь пайплайн целиком. Логика стадий описана один раз в reusable-build.yml с входами; вызывающий репозиторий сводится к матрице и событиям.
Здесь есть деталь, которую легко пропустить, а она решает, работает ли вся затея. Форма пути определяет область видимости:
# ТОЛЬКО тот же репозиторий:
uses: ./.github/workflows/reusable-build.yml
# Любой другой репозиторий — {owner}/{repo}/путь@{ref}:
uses: khorost-tech/digital-cookbook/.github/workflows/reusable-build.yml@<commit-sha>Локальная форма через границу репозитория не работает вовсе. Значит, обещание «правим один файл — меняется поведение в десяти репозиториях» достигается только вторым синтаксисом, и путь в нём отсчитывается от корня того репозитория, а не от каталога, где лежит вызывающий workflow. Ref стоит указывать полным commit SHA: ветка и тег — движущиеся ссылки, и вызываемый пайплайн может измениться под вами без единой правки у вас.
Отсюда же вытекает требование к расположению: вызываемый workflow должен лежать непосредственно в .github/workflows корня своего репозитория. Файл в подкаталоге вызвать извне нельзя — как бы аккуратно он ни был написан.
Вторая ловушка — composite action внутри такого workflow. Относительный путь ./.github/actions/setup-cache резолвится в репозитории вызывающего, а не там, где лежит reusable workflow. То есть workflow, который внутри себя использует локальный composite, при внешнем вызове потребует, чтобы такой же action существовал у каждого вызывающего, — либо ссылки на него по полному пути {owner}/{repo}/path/to/action@{sha}.
Оба ограничения объясняют, почему в стенде показан только внутренний вызов: он лежит в подкаталоге репозитория с примерами, и его reusable workflow в принципе не может быть вызван снаружи как есть. Схему конвейера это демонстрирует полностью, готовый межрепозиторный шаблон — нет; чтобы получить второе, workflow нужно вынести в корневой .github/workflows отдельного репозитория и развязать composite. Тогда принцип работает как «один источник правды», применённый к CI: пайплайн перестаёт быть копипастой и становится зависимостью — с версией, которую вы выбираете сами.
Секреты: OIDC вместо долгоживущих ключей
Классический способ дать пайплайну доступ к реестру или облаку — положить в секреты репозитория постоянный токен. Он работает и потому опасен: живёт, пока его не отзовут, лежит в настройках, утекает в лог одной неосторожной строкой и даёт доступ всякому, кто может запустить workflow.
OIDC-федерация убирает постоянный секрет вовсе. Job запрашивает у GitHub короткоживущий подписанный токен (id-token), удостоверяющий «это workflow такой-то в таком-то репозитории», и меняет его у провайдера на временный доступ. Постоянных ключей в репозитории нет — есть доверие между провайдером и конкретным workflow.
Здесь важнее синтаксиса одно правило, на котором конструкция чаще всего и ломается: по цепочке вызовов права можно только сохранить или урезать, но не повысить. Вызываемый reusable workflow не может выдать себе больше, чем дал вызывающий, сколько бы он ни просил. Поэтому id-token: write обязан появиться у caller‘а:
permissions:
contents: read # минимум на уровне workflow
jobs:
ci:
permissions:
contents: read
id-token: write # без этого OIDC внутри reusable не получит токен
uses: ./.github/workflows/reusable-build.ymlОбъявление permissions в самом reusable workflow при этом не лишнее — оно ограничивает права сверху вниз (publish получает id-token, остальные стадии нет), но создать их из ничего не может. Пайплайн, где id-token: write стоит только в вызываемом workflow, выглядит корректно и молча не работает: токен не выдаётся, и обмен падает уже в облаке.
publish:
# Публикуем только с main и только по push: на pull_request и в merge queue
# публиковать нечего, а минуты раннера тратились бы на каждый прогон.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
id-token: write # ограничение сверху, а не выдача правДальше в дело вступает action провайдера — aws-actions/configure-aws-credentials с role-to-assume или google-github-actions/auth с workload_identity_provider, — который меняет id-token на временный доступ.
Честная оговорка про стенд: сам обмен там не выполняется. Он требует настроенного доверия на стороне облака (workload identity provider или IAM-роль с trust policy на конкретный репозиторий), которого у демонстрационного стенда нет, поэтому в шаге публикации стоит заглушка, печатающая место обмена. Проверено живьём в стенде другое: структура пайплайна, правильная выдача прав и то, что всё это проходит статическую валидацию. Само получение облачного доступа по OIDC — не проверялось.
Тот же принцип наименьших прав применяется к environments — окружение прода можно защитить ручным подтверждением и списком тех, кто его даёт. Подпись артефактов в CI (cosign) — соседняя тема, разобранная в статье про supply chainготовится, с 24 ноября.
Гейты: required checks и merge queue
Пайплайн, который можно обойти, — не гейт, а рекомендация. Ворота в main ставит branch protection: required checks — список job’ов, которые обязаны быть зелёными до слияния. Не прошёл test — кнопка слияния недоступна, независимо от того, кто и как торопится.
У required checks есть тонкость масштаба. Когда в main часто вливают, между «проверки прошли на моей ветке» и «мой код слился» вклинивается чужой merge: проверки были зелёными на старом состоянии main, а вливается код в новое. Merge queue закрывает это окно — выстраивает слияния в очередь и прогоняет проверки на том состоянии, в котором код реально окажется в main, а не на том, в котором его тестировали. Для активного репозитория это разница между «проверки были зелёными» и «main зелёный».
Включить очередь в настройках недостаточно: она ставит проверки на временную ветку слияния и шлёт для этого отдельное событие. Workflow, который на него не подписан, в очереди просто не запустится — и слияние закончится неудачей по таймауту ожидания обязательных проверок:
on:
push:
branches: [main]
pull_request:
merge_group: # обязательно, если включена merge queueОшибка тем неприятнее, что до включения очереди всё работает, а после — ломается на ровном месте, причём у всех сразу.
Валидация: workflow тоже код
Workflow-файл — код, который выполняется в облаке и стоит минут. Ошибку в нём — опечатку в ${{ }}-выражении, обращение к недоступному контексту, неверный needs — обычная сборка сайта или ревью глазами не ловит — она проявится только на запуске, потратив время раннера. Ловит статический анализатор — actionlint.
На стенде это отдельное демо: actionlint прогоняется на реальных workflow (они валидны) и на заведомо битом — с обращением к контексту inputs там, где его нет:
=== РАБОЧИЙ ВАРИАНТ: реальные workflow стенда ===
actionlint: чисто — все workflow валидны.
=== ПАДАЮЩИЙ ВАРИАНТ: workflow с битым выражением ===
.github/workflows/broken-demo.yml:9:24:
property "nonexistent" is not defined in object type {} [expression]
|
9 | - run: echo "${{ inputs.nonexistent }}"
| ^~~~~~~~~~~~~~~~~~Человеческий глаз пропускает ${{ inputs.nonexistent }}. Контекст inputs наполняют только триггеры workflow_call и workflow_dispatch, и только параметрами, объявленными в самом workflow. У этого workflow триггер push, параметров нет, поэтому inputs — пустой объект, о чём и говорит object type {}. actionlint отвергает такой workflow до запуска. Это тот же принцип, что и с любым другим кодом: дешевле поймать статически, чем на прогоне. actionlint встаёт в пайплайн как ещё один шаг стадии lint — тогда битый workflow не доедет до main.
Пиннинг версий: тег — движущаяся ссылка
Ещё одна вещь, которую стоит сделать один раз и забыть. uses: actions/checkout@v7 выглядит как версия, но тег — обычная ссылка, и владелец репозитория может переставить её на другой коммит. Всё, что делает этот action, выполняется в вашем пайплайне с вашими правами и вашими секретами, поэтому GitHub рекомендует пинить сторонние actions по полному commit SHA, оставляя тег рядом комментарием:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0Комментарий с тегом здесь не декоративный: он делает диффы читаемыми, и по нему Dependabot умеет обновлять пиннинг, поднимая и SHA, и тег.
Скорость и стоимость
Всё вышеперечисленное сходится к двум цифрам: сколько пайплайн идёт и сколько стоит. Рычагов немного, и они уже названы по частям — здесь просто вместе.
Параллелить независимое. lint и test не зависят друг от друга — пусть идут одновременно, а не в очередь. Граф needs для того и нужен: он выражает реальные зависимости, а не привычный порядок команд.
Кэшировать дорогое и стабильное. Зависимости и слои Docker меняются реже кода — их восстановление между прогонами срезает основную часть холодного времени. Порядок слоёв определяет, насколько глубоко правка кода этот кэш роняет.
Резать лишнее и флейки. Стадия, которая гоняется на каждом pull request, но нужна только на main, — потраченные минуты. Флейкающий тест, который перезапускают до зелёного, — не пройденная проверка, а отложенная; лечится он в тесте, а не в пайплайне. Про то, как встроить тесты в конвейер осмысленно, — отдельный разговор про тестирование.
Стоимость минут — не абстракция: на self-hosted раннере это его загрузка, на облачном — счёт. И то и другое растёт быстрее всего там, где пайплайн делает заново то, что не менялось. Поэтому кэш и порядок стадий — не микрооптимизация, а основная статья экономии.
Демо и версии
Пример — digital-cookbook/infrastructure/cicd, раздел github-actions: reusable workflow со стадиями lint → test → build → publish, composite action с кэшем, матрица версий и платформ, схема OIDC-публикации.
Что в нём проверено живьём, а что нет — стоит разделить честно. Проверено: структура и валидность всех workflow (actionlint), оба замера эффекта кэша (cache-effect-demo.sh, layer-cache-demo.sh), сборка и тесты приложения. Не проверено прогоном: сам конвейер в облаке GitHub Actions, обмен OIDC-токена на облачный доступ (нужна настроенная сторона провайдера) и вызов reusable workflow из другого репозитория — стенд лежит в подкаталоге, поэтому такой вызов для него невозможен в принципе, и псевдо-пример из стенда убран, чтобы не выдавать синтаксис за работающий сценарий. Все числа статьи — в FIXTURES.md стенда.
Версии на момент замеров: Go 1.26.5, Docker 29.6.2, buildx 0.35.0, actionlint 1.7.12. Actions — checkout v7.0.1, setup-go v7.0.0, cache v6.1.0, upload-artifact v7.0.1, download-artifact v8.0.1; в стенде они пиньены по commit SHA (см. выше). Версии актуальны на дату написания — проверяйте текущие, прежде чем копировать.
Документация
- GitHub Actions: workflow syntax — референс синтаксиса.
- Reusable workflows — синтаксис внешнего вызова и правило «права только сохраняются или уменьшаются».
- Composite actions.
- OIDC в GitHub Actions.
- События, запускающие workflow:
merge_group— почему без него не работает merge queue. - Безопасное использование actions — рекомендация пинить по полному commit SHA.
actions/cacheи caching dependencies.actionlint— статический анализатор workflow.
Дальше по серии — деплой без простоя и миграции схемыготовится, с 25 октября: пайплайн собрал и выложил образ, но выкатить его под живым трафиком, не уронив старые экземпляры на несовместимой схеме, — отдельная задача.
Комментарии