CI-пайплайн вглубь: GitHub Actions без боли и лишних минут

Раннер есть — а конвейера нет: как спроектировать CI-пайплайн, а не свалить всё в один job. Структура workflow (jobs, зависимости, стадии), кэширование зависимостей и слоёв, matrix-сборки, reusable workflows и composite actions, секреты через OIDC вместо долгоживущих токенов, артефакты между job'ами, required checks как гейты и борьба за скорость (что параллелить, что кэшировать, где резать)

CI легко завести и трудно сделать быстрым и поддерживаемым: сначала один job на всё, потом он растёт до пятнадцати минут, кэш не работает, секреты лежат в plaintext, а копипаста одного и того же yaml расползлась по десяти репозиториям. Эта статья — про пайплайн как спроектированный артефакт: как разложить его на стадии, чем и что кэшировать, как переиспользовать конфигурацию и как не платить за CI больше минутами, чем он экономит. На примере GitHub Actions, но принципы переносимы.

Первая, флагманская статья серии «CI/CD на практике». Дополняет self-hosted runnerСкоро (железо) дизайном самого конвейера.

Ретрофутуристическая схема в стиле «Полдень. XXI век»: CI-конвейер как приборная панель с четырьмя последовательными секциями-стадиями lint→test→build→publish, соединёнными трубопроводом; сбоку — картридж кэша, вставляемый в приёмник перед стадией сборки; над стадией публикации — башенка выдачи ключей: на одной цепочке одиночный ключ в кольце шкалы обратного отсчёта, на другой — пустое кольцо там, где висела бы связка постоянных ключей; на входе в магистраль — полосатый шлагбаум с рядом круглых индикаторов обязательных проверок

В статье

Пайплайн — это спроектированный артефакт

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

Разница видна на траектории, по которой 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)]

flowchart LR
    lint[lint] --> test[test]
    test --> build[build]
    build --> publish[publish]
    build -. "artifact" .-> publish
    publish --> registry[(registry)]
Стадии пайплайна стенда: цепочка needs от дешёвого к дорогому; артефакт сборки доезжает до публикации отдельно

Стадии стоит разложить по стоимости и вероятности провала. Дешёвое и часто падающее — раньше: 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 (см. выше). Версии актуальны на дату написания — проверяйте текущие, прежде чем копировать.

Документация

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

Обсуждение в Telegram

Присоединиться →

Комментарии