GitHub Actions, GitLab CI, Drone и другие: сравнение современных платформ CI
В предыдущей статье трека — Jenkins — мы разобрали классическую модель: отдельный сервер, который сам себе и планировщик, и веб-интерфейс, и склад плагинов. Современные платформы CI построены иначе, и разница не косметическая. Она в том, кто владеет состоянием, где живёт описание пайплайна и кто платит за железо.
Эта статья — практическое сравнение. Мы соберём один и тот же реальный проект (Go-сервис с тестами, линтером, сборкой Docker-образа и публикацией в реестр) на трёх платформах, посмотрим на архитектурные различия, а потом честно посчитаем деньги: когда SaaS-минуты дешевле своих раннеров, а когда наоборот.
Откуда взялась нынешняя архитектура
Полезно понимать эволюцию — она объясняет, почему у GitLab CI есть stages, у GitHub Actions — uses:, а у Drone всё крутится вокруг Docker-образов.
Ключевой перелом — 2011 год и .travis.yml. До него конфигурация сборки была свойством сервера, после — свойством коммита. Это меняет всё: пайплайн ревьюится, откатывается, ветвится вместе с кодом. Ветка feature/x может собираться иначе, чем main, и это не требует ничьих прав администратора.
Второй перелом — отделение control plane от исполнителей. Сервер только хранит очередь заданий и логи; вычисления делают раннеры/агенты, которые могут быть где угодно.
построение DAG заданий"] SCH --> Q[("Очередь заданий")] end Q -.->|long-poll / webhook| RA["Раннер: SaaS-пул
эфемерная ВМ"] Q -.->|long-poll| RB["Раннер: свой,
Docker executor"] Q -.->|long-poll| RC["Раннер: свой,
Kubernetes executor"] RA --> ART["Артефакты, кэш, логи"] RB --> ART RC --> ART ART --> REG["Реестр образов / пакеты"] style forge fill:#1f6feb22,stroke:#1f6feb
Обратите внимание на направление стрелок к раннерам: они пунктирные и исходят от раннера. Раннер сам ходит за работой (long-polling), сервер не инициирует соединение. Практическое следствие: свой раннер можно поставить внутри закрытого контура без единого входящего порта — ему нужен только исходящий HTTPS. Это то, чего у классического Jenkins-мастера с JNLP-агентами добиться было ощутимо сложнее.
Общая модель: события, задания, DAG
Все современные платформы описываются одной абстракцией:
- Триггер — событие (push, merge request, тег, расписание, ручной запуск, вызов из другого пайплайна).
- Workflow / pipeline — набор заданий с зависимостями.
- Job — единица планирования: выполняется целиком на одном раннере, в одной файловой системе.
- Step — команда или переиспользуемый блок внутри задания.
- Артефакты и кэш — способ передать данные между заданиями (через хранилище платформы, не через диск).
Различие в терминах, но не в сути:
| Понятие | GitHub Actions | GitLab CI | Drone / Woodpecker | Tekton |
|---|---|---|---|---|
| Файл конфигурации | .github/workflows/*.yml |
.gitlab-ci.yml |
.drone.yml / .woodpecker.yml |
CRD в кластере |
| Единица планирования | job |
job |
step (в pipeline) |
Task |
| Шаг | step (run или uses) |
элемент script |
контейнер | step в Task |
| Группировка | needs (явный DAG) |
stages + needs |
depends_on |
Pipeline |
| Переиспользование | actions, reusable workflows | include, CI components |
плагины-образы | Task в каталоге |
| Исполнитель | runner (VM/контейнер) | runner (7 executors) | контейнер по шагу | под в Kubernetes |
Жизненный цикл задания стоит держать в голове — именно на переходах между состояниями возникает большинство «непонятных» проблем.
Состояние Stuck (в GitLab оно так и называется) — классическая ошибка новичка: задание помечено тегом docker-arm64, а раннера с таким тегом никто не зарегистрировал. Пайплайн висит вечно и не падает. Всегда ставьте таймаут на задание.
GitHub Actions
Модель: пайплайн — набор workflow-файлов, каждый подписан на события репозитория. Главная особенность — экосистема переиспользуемых шагов (uses:), которая одновременно и сильнейшая сторона, и главный источник проблем с безопасностью.
Рабочий конфиг для нашего Go-сервиса:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
workflow_dispatch: # ручной запуск из UI
# Отменяем предыдущие запуски по той же ветке — экономия минут
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
permissions:
contents: read # принцип наименьших прав: по умолчанию всё read
env:
GO_VERSION: "1.23"
jobs:
lint:
runs-on: ubuntu-24.04
steps:
# Пиннинг по SHA, а не по тегу: тег в чужом репозитории можно переписать
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
with:
go-version: ${{ env.GO_VERSION }}
cache: true # кэш модулей и build cache «из коробки»
- uses: golangci/golangci-lint-action@v6
with:
version: v1.62
test:
runs-on: ubuntu-24.04
strategy:
fail-fast: false # не гасить всю матрицу из-за одной ячейки
matrix:
go: ["1.22", "1.23"]
include:
- go: "1.23"
coverage: true
services:
# Сервисные контейнеры доступны по localhost — удобно для интеграционных тестов
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s --health-retries 10
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "${{ matrix.go }}", cache: true }
- name: Тесты с гонками
run: go test -race -timeout 5m -coverprofile=cover.out ./...
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/postgres?sslmode=disable
- name: Отчёт о покрытии
if: matrix.coverage
run: go tool cover -func=cover.out | tail -1
image:
needs: [lint, test]
if: github.event_name == 'push'
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write # публикация в ghcr.io
id-token: write # OIDC-токен для облака, без долгоживущих ключей
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # временный токен запуска
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true # SLSA-аттестация сборки
Что здесь важно по существу:
concurrency— самая недооценённая экономия. Разработчик пушит пять раз подряд, платформа честно запускает пять пайплайнов. Отмена предыдущих режет счёт на десятки процентов.- Пиннинг по SHA. Тег
@v4в чужой action — это указатель, который владелец может передвинуть. Инцидент сtj-actions/changed-filesв марте 2025 года — ровно этот сценарий: злоумышленник переписал теги, и action начала выгружать секреты из памяти раннера в логи (разбор StepSecurity). Пиннинг по SHA плюсpermissions: read— минимальная гигиена. id-token: writeи OIDC. Вместо того чтобы класть в секреты вечныйAWS_SECRET_ACCESS_KEY, раннер получает короткоживущий токен и обменивает его на роль. Подробнее — в статье о безопасности конвейера.
Обмен OIDC-токена стоит представлять целиком:
в репозитории не существует
Сильные стороны GitHub Actions: нулевой порог входа, огромный marketplace, бесплатность для публичных репозиториев, отличная интеграция с самим GitHub (статусы, окружения, деплой-гейты, GITHUB_TOKEN со скоупами).
Слабые: YAML-выражения ${{ }} — это отдельный мини-язык без нормальной отладки; матрицы плохо параметризуются динамически (приходится генерировать JSON в предыдущем задании и подавать через fromJSON); нет встроенного способа запустить пайплайн локально (act — эмуляция, не эквивалент); marketplace — это чужой код с полным доступом к раннеру.
Динамическая матрица — приём, который приходится знать:
discover:
runs-on: ubuntu-24.04
outputs:
services: ${{ steps.gen.outputs.list }}
steps:
- uses: actions/checkout@v4
- id: gen
# Собираем список изменённых сервисов и отдаём JSON-массив
run: |
list=$(ls services | jq -R -s -c 'split("\n")[:-1]')
echo "list=$list" >> "$GITHUB_OUTPUT"
build:
needs: discover
strategy:
matrix:
service: ${{ fromJSON(needs.discover.outputs.services) }}
runs-on: ubuntu-24.04
steps:
- run: echo "Собираю ${{ matrix.service }}"
GitLab CI/CD
Модель: один файл .gitlab-ci.yml, стадии по умолчанию последовательные, но с needs превращаются в DAG. Главное отличие от GitHub — раннеры это первоклассная сущность с семью исполнителями (shell, docker, docker-autoscaler, kubernetes, ssh, instance, custom), и self-managed установка всей платформы официально поддерживается.
# .gitlab-ci.yml
stages: [lint, test, build, deploy]
variables:
GO_VERSION: "1.23"
DOCKER_BUILDKIT: "1"
# Кэш модулей внутри рабочей директории — иначе GitLab его не заархивирует
GOPATH: "$CI_PROJECT_DIR/.go"
default:
image: golang:1.23-alpine
interruptible: true # аналог cancel-in-progress
retry:
max: 2
when: [runner_system_failure, stuck_or_timeout_failure] # НЕ script_failure
.go_cache: &go_cache
cache:
key:
files: [go.sum] # ключ инвалидируется при смене зависимостей
paths: [.go/pkg/mod, .cache/go-build]
policy: pull # большинство заданий только читают кэш
lint:
stage: lint
<<: *go_cache
image: golangci/golangci-lint:v1.62
script: [golangci-lint run --timeout 5m]
test:
stage: test
<<: *go_cache
cache:
key: { files: [go.sum] }
paths: [.go/pkg/mod, .cache/go-build]
policy: pull-push # это задание кэш и наполняет
services:
- name: postgres:16-alpine
alias: db
variables:
POSTGRES_PASSWORD: test
DATABASE_URL: "postgres://postgres:test@db:5432/postgres?sslmode=disable"
script:
- apk add --no-cache gcc musl-dev
- go test -race -coverprofile=cover.out ./...
- go tool cover -func=cover.out | tail -1
coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/' # покрытие в MR-виджет
artifacts:
when: always
reports:
junit: report.xml # тесты отображаются во вкладке MR
expire_in: 1 week
build:image:
stage: build
needs: [lint, test] # DAG: не ждём всю стадию целиком
image: gcr.io/kaniko-project/executor:debug
script:
# Kaniko собирает образ без демона Docker и без привилегированного режима
- /kaniko/executor
--context "$CI_PROJECT_DIR"
--dockerfile Dockerfile
--destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
--cache=true --cache-ttl=168h
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
- if: '$CI_COMMIT_TAG'
deploy:prod:
stage: deploy
needs: ["build:image"]
environment:
name: production
url: https://app.example.com
when: manual # ручной гейт
rules:
- if: '$CI_COMMIT_TAG'
script: [./deploy.sh "$CI_COMMIT_TAG"]
Несколько вещей, за которые GitLab CI любят:
rulesзаметно выразительнее, чемonly/exceptи чемif:в GitHub: можно комбинировать условия ветки, изменённых файлов (changes:), переменных и задаватьwhen: manual/never/delayedпрямо в правиле.includeи CI Components: пайплайн можно собрать из кусков, лежащих в другом репозитории, с версионированием через каталог компонентов (документация).- Parent-child pipelines — для монорепозиториев это лучше динамических матриц: родительский пайплайн генерирует YAML, дочерние запускаются независимо и имеют свои собственные статусы.
- Отчёты:
artifacts:reportsумеет junit, coverage, SAST, dependency scanning — всё это подтягивается прямо в виджет merge request.
Регистрация своего раннера — три команды, и это принципиально проще, чем поднимать Jenkins-агента:
# Раннер на своей машине, Docker executor
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.com/" \
--token "glrt-XXXXXXXXXXXX" \
--executor "docker" \
--docker-image "alpine:3.20" \
--docker-volumes "/cache" \
--description "hetzner-cpx41-01"
# Проверяем
$ sudo gitlab-runner list
Runtime platform arch=amd64 os=linux pid=3120 revision=v17.5.2
hetzner-cpx41-01 Executor=docker Token=glrt-XXXX URL=https://gitlab.com/
Слабые стороны: self-managed GitLab тяжёл (Rails-монолит, PostgreSQL, Redis, Gitaly, Sidekiq — реалистичный минимум 8 ГБ RAM на небольшую инсталляцию); YAML-якоря и extends дают запутанные наследования; лицензионные функции (например, окружения с approvals, multi-project pipelines в полном виде) сидят в Premium/Ultimate.
Drone и Woodpecker: минимализм
Drone был первым, кто довёл идею «шаг = контейнер» до предела: сервер — один Go-бинарник плюс база (SQLite/Postgres), раннер — ещё один бинарник. Всё.
Важный юридический нюанс: после покупки Harness Drone перелицензирован под Polyform Small Business — бесплатно только для компаний менее 100 сотрудников и с выручкой менее 1 $ млн. Поэтому сообщество ушло в форк Woodpecker CI (Apache 2.0), который сегодня активнее развивается (woodpecker-ci.org).
# .woodpecker.yml — синтаксис почти совместим с .drone.yml
when:
- event: [push, pull_request]
steps:
- name: lint
image: golangci/golangci-lint:v1.62
commands:
- golangci-lint run --timeout 5m
- name: test
image: golang:1.23
environment:
DATABASE_URL: "postgres://postgres:test@db:5432/postgres?sslmode=disable"
commands:
- go test -race ./...
- name: build-push
image: woodpeckerci/plugin-docker-buildx
settings:
repo: registry.example.com/app
tags: [latest, "${CI_COMMIT_SHA}"]
username: { from_secret: registry_user }
password: { from_secret: registry_pass }
when:
- event: push
branch: main
services:
- name: db
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: test
Обратите внимание: нет отдельного понятия «шаг-скрипт» против «шаг-плагин». Плагин — это просто образ, которому в переменные окружения передали settings. Написать свой плагин = собрать образ с одним бинарником. Это радикально прозрачнее, чем action.yml с composite-шагами.
Развернуть Woodpecker целиком:
# docker-compose.yml — весь CI-сервер
services:
woodpecker-server:
image: woodpeckerci/woodpecker-server:v2
ports: ["8000:8000"]
volumes: ["woodpecker-data:/var/lib/woodpecker"]
environment:
WOODPECKER_OPEN: "false"
WOODPECKER_HOST: "https://ci.example.com"
WOODPECKER_GITEA: "true"
WOODPECKER_GITEA_URL: "https://git.example.com"
WOODPECKER_GITEA_CLIENT: "${GITEA_CLIENT}"
WOODPECKER_GITEA_SECRET: "${GITEA_SECRET}"
WOODPECKER_AGENT_SECRET: "${AGENT_SECRET}"
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v2
command: agent
depends_on: [woodpecker-server]
volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
environment:
WOODPECKER_SERVER: "woodpecker-server:9000"
WOODPECKER_AGENT_SECRET: "${AGENT_SECRET}"
WOODPECKER_MAX_WORKFLOWS: "4"
volumes:
woodpecker-data:
Ресурсы сервера — порядка 100 МБ RAM. Для сравнения: GitLab Omnibus на той же нагрузке съест 6–8 ГБ. Если у вас Gitea/Forgejo и десяток репозиториев, разница между «CI за 100 МБ» и «CI за 8 ГБ» — это разница между VPS за €5 и €30 в месяц.
Слабые стороны: нет marketplace сопоставимого масштаба, отчёты о тестах примитивные, нет встроенных окружений и approvals, матрицы есть, но без выразительности GitHub. Это инструмент для команд, которые хотят понятный YAML и полный контроль, а не батарейки в комплекте.
Остальные игроки — коротко и по делу
| Платформа | Модель исполнения | Кому подходит | Главный подвох |
|---|---|---|---|
| CircleCI | SaaS, свои образы, orbs как переиспользуемые пакеты |
командам, где важны скорость и test splitting | биллинг в «кредитах», сложно предсказать счёт |
| Buildkite | control plane SaaS, агенты только ваши | компаниям со своим железом и строгим комплаенсом | платите за пользователей, инфраструктура целиком на вас |
| Azure Pipelines | SaaS + self-hosted агенты | .NET-миру, гибридным сценариям с Azure | YAML-схема многословна; см. Azure |
| TeamCity | сервер + агенты, конфиг в UI или в Kotlin DSL | сложным сборкам JVM, монорепозиториям | лицензия по агентам; Kotlin DSL — сильная сторона и барьер |
| Tekton | CRD в Kubernetes, шаг = контейнер в поде | платформенным командам, строящим свой внутренний CI | «конструктор», а не продукт: UI, триггеры, RBAC — ваша работа |
| Argo Workflows | DAG-движок в Kubernetes | ML- и data-пайплайнам, длинным задачам | не заточен под VCS-события; см. GitOps |
| Forgejo/Gitea Actions | совместимость с синтаксисом GitHub Actions | тем, кто хочет уйти с GitHub, не переписывая YAML | поддерживается подмножество actions; marketplace надо зеркалить |
| Concourse | всё есть ресурс, полностью stateless | любителям строгой модели и воспроизводимости | крутая кривая обучения, сообщество сжалось |
Отдельно про Dagger: это не платформа, а способ описать пайплайн кодом (Go/Python/TypeScript), который потом исполняется контейнерами на любой платформе. Решает главную боль — «пайплайн можно проверить только в CI». С Dagger вы запускаете тот же граф локально. Цена — ещё один слой абстракции и BuildKit-демон.
Сравнение по трём осям, которые реально решают
Не «какая платформа лучше», а «что вы платите». Три валюты: деньги, время инженеров на эксплуатацию, время на обучение команды.
Стоимость: считаем, а не гадаем
Порядок цен на середину 2025 года (обязательно сверяйтесь с актуальным прайсом — тарифы меняются каждый год):
| Позиция | Цена | Комментарий |
|---|---|---|
| GitHub Actions, Linux 2 vCPU | 0.008 $/мин | публичные репозитории — бесплатно |
| GitHub Actions, Linux 4 vCPU | 0.016 $/мин | larger runners не тратят бесплатный лимит |
| GitHub Actions, Windows 2 vCPU | 0.016 $/мин | ровно ×2 к Linux |
| GitHub Actions, macOS 3 vCPU | 0.08 $/мин | ×10 — macOS почти всегда узкое место бюджета |
| Бесплатный лимит GitHub Free / Team | 2 000 / 3 000 мин/мес | только для приватных репозиториев |
| GitLab SaaS, бесплатный тариф | 400 compute-минут/мес | сильно урезали в 2023 |
| GitLab Premium | 29 $/польз./мес + 10 000 минут | доп. минуты ~10 $ за 1 000 |
| Свои раннеры в GitLab | 0 compute-минут | считается только железо |
| Hetzner CCX23 (4 выделенных vCPU) | ≈ €24/мес | круглосуточно, без лимита минут |
| Hetzner CPX41 (8 vCPU shared) | ≈ €26/мес | хорошее соотношение для сборок |
Точка безубыточности своих раннеров считается тривиально:
$$M_{\text{безуб}} = \frac{C_{\text{железо}} + C_{\text{эксплуатация}}}{p_{\text{минута}}}$$
Подставим: сервер за $26, обслуживание 2 часа инженера в месяц по 50 $/ч = $100, цена SaaS-минуты 4 vCPU $0.016.
$$M_{\text{безуб}} = \frac{26 + 100}{0.016} = 7,875 \text{ минут в месяц}$$
Если считать только железо (раннер поднят один раз через Terraform и живёт сам) — порог падает до 1 625 минут. Между этими двумя числами лежит вся честность разговора.
Практический вывод: до ~1 500 минут в месяц самостоятельный хостинг раннеров не окупается никогда. Команда из пяти человек с пайплайном на 6 минут и 40 сборками в день — это ~1 200 минут. SaaS дешевле, спорить не о чем. А вот монорепозиторий с матрицей на 12 ячеек, где один прогон стоит 90 минут суммарного времени, выходит на 20–40 тысяч минут — там свои раннеры экономят тысячи долларов в год.
Отдельная строка — macOS. Один прогон iOS-сборки на 20 минут стоит $1.6. Тысяча сборок в месяц = 1 $ 600, что дороже, чем купить Mac mini M4 и амортизировать его за год. Это едва ли не единственный случай, когда «своё железо» окупается почти мгновенно.
Не забудьте и про хранилище: артефакты и кэш в GitHub тарифицируются (0.008 $/ГБ/день сверх лимита). Пайплайн, который каждый прогон кладёт 500 МБ артефактов с retention 90 days, за месяц накапливает десятки гигабайт. Ставьте retention-days: 7 для всего, кроме релизных артефактов, — это бесплатная экономия.
Где на самом деле уходит время сборки
Стоимость минут — половина картины. Вторая — сколько минут вы тратите на накладные расходы, а не на полезную работу.
Три вывода из этой картинки:
- Холодный старт стоит ~40 секунд на каждое задание. Матрица из 12 ячеек по 3 задания — это 24 минуты чистых накладных расходов за прогон. Дробить пайплайн на мелкие задания «для красоты DAG» — дорого.
- Кэш через сеть медленнее локального диска в разы. GitHub Actions cache даёт порядка 10 лимитов на репозиторий; при большом
node_modulesвосстановление занимает столько же, сколько установка с нуля. Меряйте, прежде чем добавлять шаг кэша. - CPU у SaaS-раннеров слабый. Стандартный
ubuntu-latest— это 2 vCPU shared. Выделенные 4 ядра на Hetzner дают компиляцию в 2+ раза быстрее. Если задание CPU-bound, переход на свои раннеры сокращает время ожидания разработчика, а это дороже минут.
Как выбирать: рабочий алгоритм
Бесплатно, решение принято"] C -->|нет| E{"Больше 5 000 минут/мес
или нужен GPU/ARM/macOS?"} E -->|нет| D E -->|да| F["GitHub Actions +
self-hosted runners через ARC
или actions-runner-controller"] B -->|нет| G{"GitLab?"} G -->|да| H{"Есть требование
закрытого контура?"} H -->|нет| I["GitLab SaaS + свои раннеры
минуты не тарифицируются"] H -->|да| J["GitLab self-managed.
Заложите 0.5 FTE на эксплуатацию"] G -->|нет| K{"Gitea / Forgejo /
свой сервер?"} K -->|да| L{"Нужны отчёты, окружения,
approvals?"} L -->|нет| M["Woodpecker CI.
100 МБ RAM, YAML на полстраницы"] L -->|да| N["Forgejo Actions —
синтаксис GitHub, свой хостинг"] K -->|нет| O{"Уже есть Kubernetes
и платформенная команда?"} O -->|да| P["Tekton / Argo Workflows.
Только если строите платформу"] O -->|нет| Q["Не изобретайте.
Возьмите SaaS ближайшего forge"] style D fill:#3fb95033,stroke:#3fb950 style M fill:#3fb95033,stroke:#3fb950 style J fill:#f0883e33,stroke:#f0883e style P fill:#f0883e33,stroke:#f0883e
Главное правило: CI-платформа должна быть там же, где код. Комбинация «код на GitHub, CI в Jenkins» или «GitLab плюс отдельный Drone» почти всегда добавляет боли (двойная аутентификация, рассинхрон статусов, вебхуки, которые ломаются) без соразмерной выгоды. Исключения бывают — например, Buildkite при коде на GitHub, когда нужны свои агенты и очень жёсткий комплаенс, — но это осознанное решение, а не «так исторически сложилось».
Типичные ошибки
1. Секреты в логах. Платформы маскируют значения секретов, но только буквальное совпадение. Секрет, прошедший через base64 или попавший в JSON с экранированием, утечёт в открытом виде. Правило: set -x в шаге, который трогает секреты, — запрещён.
2. pull_request_target без понимания. В GitHub Actions обычный pull_request от форка не получает секретов — это защита. Разработчик натыкается на «мой workflow не видит токен», гуглит, находит pull_request_target и меняет триггер. Теперь workflow исполняется в контексте базовой ветки с полными секретами, а если он ещё и делает checkout кода из PR — любой человек в интернете может выполнить свой код с вашими ключами. Это самая распространённая критическая уязвимость GitHub Actions (разбор GitHub Security Lab).
3. Ретраи на script_failure. Соблазн поставить retry: 2 на всё огромен — «флаки же». В результате настоящие баги маскируются, а время сборки растёт втрое. Ретраить можно только инфраструктурные отказы: runner_system_failure, таймауты, сетевые ошибки. Флакающий тест надо чинить или карантинить явно.
4. Кэш как источник правды. Кэш может исчезнуть в любой момент (вытеснение по объёму, смена ключа, зачистка). Пайплайн, который падает без кэша, сломан. Кэш ускоряет, но не является зависимостью.
5. Один гигантский job. Соблазн: всё в одном задании, ничего не надо передавать. Проблема: упало на 18-й минуте — перезапускаете все 18. Разумный компромисс — 3–6 заданий, разделённых по естественным границам (линт / тесты / сборка образа / деплой), а не по каждой команде.
6. latest в образах раннера и сервисов. postgres:16-alpine — воспроизводимо. postgres:latest — однажды утром ваши тесты сломает мажорное обновление, и вы потратите день на «почему упало, я же ничего не менял».
7. Отсутствие таймаутов. Значение по умолчанию у GitHub — 360 минут. Зависшее задание съест шесть часов оплаченного времени. Ставьте timeout-minutes: 15 явно на каждое задание.
8. Игнорирование стоимости хранения. Проверьте счёт за artifacts/packages, прежде чем оптимизировать минуты, — нередко хранилище стоит дороже вычислений.
Практика продакшена: что делают зрелые команды
- Пиннинг и аудит зависимостей пайплайна. Все внешние actions — по SHA, обновление через Dependabot/Renovate с ревью. Внутренние переиспользуемые workflow — в отдельном репозитории с тегами.
- Собственный слой абстракции. Пайплайн вызывает
make ci-test,make ci-build, а не двадцать строк YAML. Это делает миграцию между платформами вопросом одного дня и позволяет разработчику воспроизвести CI локально. - Автомасштабируемые раннеры. GitHub —
actions-runner-controllerв Kubernetes; GitLab —docker-autoscalerexecutor c AWS/Hetzner. Эфемерный раннер на spot-инстансе живёт минуты и даёт двойную выгоду: цена и изоляция (никакого состояния между сборками). - Метрики самого пайплайна. DORA-метрики (частота деплоя, lead time, MTTR, доля неудачных изменений) плюс p50/p95 времени сборки и процент флаков. Без цифр «CI стал медленным» — эмоция, а не задача. Подробности — в статье про наблюдаемость.
- Бюджет времени сборки как SLO. Например: «p95 пайплайна на PR ≤ 10 минут». Превысили — заводим задачу. Без явного порога время сборки растёт монотонно и необратимо.
- Отдельные раннеры для защищённых веток. Задания, у которых есть доступ к продакшн-секретам, идут на выделенный пул раннеров с ограниченным сетевым доступом. Сборка PR от внешнего контрибьютора и деплой в прод не должны делить один хост.
Мини-итог
- Современные платформы CI отличаются от Jenkins не синтаксисом, а моделью: конфиг в репозитории, control plane без состояния, раннеры сами забирают работу.
- GitHub Actions — лучший старт и лучшая экосистема; платите порогом безопасности (чужие actions) и слабым языком выражений.
- GitLab CI — самая выразительная модель пайплайна (
rules,needs,include, parent-child) и бесплатные свои раннеры; платите весом self-managed инсталляции. - Woodpecker/Drone — минимализм, который честно работает на малом масштабе; платите отсутствием батареек.
- Tekton/Argo — не продукты, а конструкторы; берите, только если строите внутреннюю платформу.
- Считайте деньги по формуле безубыточности: до ~1 500 минут в месяц свои раннеры не окупаются, после 8 000 — окупаются почти всегда. macOS — исключение, там своё железо выгодно сразу.
- Держите бизнес-логику сборки в скриптах, а не в YAML: это ваша страховка от вендор-лока.
Источники
- GitHub Actions — официальная документация и страница тарифов
- GitLab CI/CD — справочник по
.gitlab-ci.ymlи документация раннеров - Woodpecker CI — документация · Drone
- Tekton Pipelines · Argo Workflows
- GitHub Security Lab: Preventing pwn requests
- OpenID Connect в GitHub Actions
- Форсгрен Н., Хамбл Дж., Ким Дж. «Ускоряйся! Наука DevOps» — источник DORA-метрик
- Хамбл Дж., Фарли Д. «Непрерывное развёртывание ПО» — базовая книга по устройству конвейера
- Dagger — пайплайны как код
Что дальше
Мы научились быстро и дёшево доводить коммит до собранного, проверенного и опубликованного артефакта. Следующий вопрос — как этот артефакт попадает к пользователям без простоя и с возможностью мгновенно откатиться: Continuous Delivery и стратегии релизов: blue-green, canary, feature flags, откаты.