open atlas
↑ К треку
CI/CD-пайплайны CICD · 08 · 03

Джобы, матрица и переиспользование: DAG, лимиты fan-out и вложенность reusable

Джобы образуют DAG через `needs`; матрица разворачивает один джоб во множество ног с `max-parallel` и `fail-fast`, управляющими ценой. Reusable workflows (`workflow_call`) факторизуют пайплайны в вызываемые единицы — лимит 4 уровня вложенности и 20 уникальных вызываемых файлов.

CICD Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Счёт за CI утроился за ночь, и никто не мержил фичу. Причина — одна благонамеренная матрица. У платформенной команды был тест-джоб, покрывавший 4 ОС и 5 версий языка — аккуратные 20 ног — и кто-то добавил третье измерение database: [postgres, mysql, sqlite, mssql], чтобы «быть тщательным». Это декартово произведение: 4 × 5 × 4 = 80 джобов, каждый поднимает свежий раннер, каждый тянет те же зависимости. Хуже, fail-fast был выключен из-за флакости, поэтому одна падающая нога больше не замыкала остальные 79 — каждый запуск платил за все 80 до конца. Фиксом было не удалить покрытие; фиксом было понять, что матрица умножает, что include/exclude дают покрыть значимые комбинации без полного произведения, и что max-parallel ограничивает одновременное горение. Они срезали 80 ног до выверенных 14 через include, поставили max-parallel: 6, и счёт вернулся вниз — с лучше выбранным покрытием, чем давала слепая сетка.

Джобы — это DAG, а не скрипт

Большинство проблем со стоимостью и задержкой CI можно отследить до того, что граф джобов не был спроектирован осознанно — либо всё выполняется последовательно, когда в этом не было необходимости, либо всё параллельно, когда нужен был порядок. Понять DAG — значит понять, где именно чинить и то, и другое.

Джобы по умолчанию выполняются параллельно; needs: рисует рёбра зависимостей, сериализующие их в DAG (directed acyclic graph — направленный ациклический граф):

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      artifact: ${{ steps.pack.outputs.name }}
    steps: [ ... ]
  test:
    needs: build               # ждёт build, читает его outputs
    runs-on: ubuntu-latest
  deploy:
    needs: [build, test]       # ждёт оба
    if: ${{ github.ref == 'refs/heads/main' }}
    timeout-minutes: 15        # убей зависший деплой, не жги 6ч

Каждый джоб выполняется на свежем раннере без общей файловой системы — потому данные между джобами идут через outputs (короткие строки) или артефакты (файлы), никогда через диск. Джоб не наследует ничего из чужого рабочего каталога. Два продакшн-контроля живут на этом уровне: timeout-minutes (дефолт — щедрые 360 минут / 6 часов на джоб — слишком долго для большинства работы, поэтому задай реальный потолок, иначе зависший шаг сожжёт часы времени раннера) и условия if:, закрывающие целые джобы. needs также пробрасывает сбой: если build падает, test и deploy пропускаются — если только нижестоящий джоб не подпишется выполниться всё равно через if: ${{ always() }} или if: ${{ !cancelled() }}.

Матрица: одно определение джоба, много ног

strategy.matrix разворачивает один джоб в одну ногу на комбинацию — декартово произведение каждого измерения:

strategy:
  fail-fast: false       # не отменяй соседей при падении одной ноги
  max-parallel: 6        # максимум 6 ног выполняются одновременно
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    node: [18, 20, 22]
    include:
      - { os: ubuntu-latest, node: 22, coverage: true }  # добавить/дополнить ногу
    exclude:
      - { os: macos-latest, node: 18 }                   # выкинуть комбинацию

Числа, управляющие ценой и поведением:

  • Оно умножает. 3 os × 3 node = 9 ног; добавление любого третьего измерения умножает снова. Это взрыв из Hook — матрица — самое лёгкое место в Actions, чтобы случайно увеличить счёт в 10 раз.
  • fail-fast (дефолт true) отменяет все выполняющиеся и стоящие в очереди ноги в момент падения одной — быстрая обратная связь, но теряешь полную картину сбоя. Поставь false, чтобы видеть каждую падающую комбинацию, ценой оплаты их всех.
  • max-parallel ограничивает, сколько ног выполняются разом; без него ноги выполняются настолько параллельно, насколько позволяет лимит concurrency твоих раннеров (а матрица ограничена 256 ногами на запуск workflow).
  • include/exclude — инструменты точности: exclude убирает сгенерированную комбинацию; include добавляет разовую ногу или дополняет существующую лишними ключами — способ получить выверенное покрытие вместо слепой сетки.

Вместе эти четыре рычага значат, что от взорвавшейся матрицы всегда можно оправиться: ограничь горение через max-parallel, обрежь произведение через exclude/include, реши, нужна ли быстрая обратная связь или полная картина, через fail-fast. Без max-parallel на матрице, о которой не подумал, одно новое измерение за ночь превращает управляемые 9 ног в пожар из 80 раннеров.

Викторина

У матрицы os: [ubuntu, windows, macos] и node: [18, 20, 22] с fail-fast: false и без max-parallel. Нога node-18 на windows падает быстро. Сколько ног всего доходят до конца и почему?

Почему это работает

Почему матрица умножает, а не зипует измерения попарно? Потому что обычная потребность — настоящее покрытие: каждая ОС против каждого рантайма — и произведение выражает это прямо. Попарная семантика «зип» тихо пропускала бы комбинации и прятала баги совместимости, проявляющиеся лишь на конкретном пересечении. Размен в том, что произведение растёт мультипликативно, поэтому язык даёт exclude, чтобы вырезать ненужные комбинации, и include, чтобы вернуть немногие нужные дополнительные — позволяя начать с полного покрытия и обрезать, или начать разреженно и дополнить. Дисциплина сеньора — трактовать каждое новое измерение как множитель счёта, а не как бесплатную ось.

Reusable workflows: факторизуй пайплайн

Когда та же последовательность build-test-scan появляется в двадцати репозиториях, copy-paste гниёт. Reusable workflow — это workflow с on: workflow_call, который другие workflow вызывают как один джоб:

# .github/workflows/build.yml  (reusable workflow)
on:
  workflow_call:
    inputs:
      node: { type: string, required: true }
    secrets:
      token: { required: true }

# вызывающий
jobs:
  ci:
    uses: my-org/ci/.github/workflows/build.yml@v2   # пин к тегу/SHA
    with: { node: "20" }
    secrets: { token: ${{ secrets.NPM_TOKEN }} }

Жёсткие лимиты, которые стоит запомнить: цепочка вызовов вложена до 4 уровней вглубь (reusable workflow вызывает другой, и так далее — workflow 4-го уровня сам уже не может вызвать reusable workflow), и один файл workflow может ссылаться максимум на 20 уникальных reusable workflows по всему дереву. Пинь ссылку uses: к тегу или SHA, никогда к движущейся ветке, по той же supply-chain-причине, по которой пинишь экшены — @main позволяет вызываемому workflow меняться под тобой. Секреты не наследуются автоматически; ты либо передаёшь их явно под secrets:, либо форвардишь все через secrets: inherit. Так крупные организации держат одно проаудированное определение пайплайна и вызывают его везде, вместо двадцати расходящихся копий.

Викторина

Организация факторизует пайплайн в reusable workflows: верхний вызывающий uses 'ci.yml', который uses 'build.yml', который uses 'scan.yml', который uses 'sign.yml'. Кто-то добавляет пятый уровень, где sign.yml вызывает 'notify.yml'. Что происходит?

Вспомните перед уходом
  1. 01
    Объясни, как матрица вычисляет свои ноги, и три рычага, управляющих её ценой и обратной связью.
  2. 02
    Что такое reusable workflow и какие лимиты и supply-chain-правила к нему применяются?
Итог

Джобы — единица параллелизма: они выполняются одновременно, пока рёбра needs: не сериализуют их в DAG, каждый на свежем раннере без общего диска, поэтому данные пересекают границы джобов только через outputs (короткие строки) или артефакты (файлы). Два контроля живут здесь, и оба важны на сеньорском масштабе: timeout-minutes по умолчанию — расточительные шесть часов, поэтому задай реальный потолок, иначе зависший шаг сожжёт часы раннера, а if: закрывает целые джобы, тогда как needs пробрасывает сбой вниз, если джоб не подпишется обратно через always() или !cancelled(). strategy.matrix разворачивает один джоб в декартово произведение измерений — самый лёгкий способ случайно умножить счёт за CI, ведь третья ось превращает девять ног в восемьдесят — и ты управляешь этим через fail-fast (отмена соседей при первом сбое ради скорости или false, чтобы видеть каждый сбой и платить за все), max-parallel (потолок параллельности, с 256 ногами как жёстким максимумом) и include/exclude для выверенного, а не слепого покрытия. Reusable workflows, объявленные через on: workflow_call, факторизуют пайплайн в вызываемые, версионно-зафиксированные единицы, чтобы одно проаудированное определение служило многим репозиториям; они вложены максимум на четыре уровня, файл может ссылаться максимум на двадцать уникальных вызываемых workflow, ссылка uses: должна быть зафиксирована к тегу или SHA по тем же supply-chain-причинам, по которым пинишь экшены, а секреты передаются явно или через secrets: inherit — никогда тихо. Сквозная линия: джобы образуют граф, матрица его умножает, а переиспользование его факторизует — каждое рычаг и на корректность, и на стоимость. Теперь, когда встретишь счёт за CI, утроившийся за ночь, или reusable workflow, сломавшийся после того как кто-то поднял зависимость, — сразу знаешь, какой рычаг проверить первым: измерение матрицы, прокравшееся незаметно, отсутствующий max-parallel или пин @main, ушедший в дрейф.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.