CI/CD как код: от GitHub Actions до self-hosted runners
Когда пайплайн настроен через UI — кнопки, чекбоксы, дропдауны — он существует только в одном экземпляре, не версионируется и умирает вместе с учётной записью администратора. Разработчик, пришедший в команду, не может понять, что именно запускается при пуше в main. Ревью изменений в CI невозможно: нет диффа, нет истории.
CI/CD как код решает это фундаментально. Пайплайн — такой же код, как и остальное приложение: он живёт в репозитории, проходит ревью в PR, откатывается через git revert, имеет историю изменений. Это не просто удобство — это обязательное условие масштабируемой разработки. Разберём, как устроена эта концепция на практике: от синтаксиса GitHub Actions до стратегий кэширования и self-hosted runners.
GitHub Actions: архитектура и синтаксис
GitHub Actions строится вокруг трёх сущностей: workflow (файл в .github/workflows/), job (набор шагов, выполняемых на одном runner) и step (отдельная команда или action). Между jobs можно задать зависимости через needs, передавать данные через outputs.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
PYTHON_VERSION: "3.12"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
lint:
name: Lint & Type Check
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install dev deps
run: pip install ruff mypy
- name: Ruff lint
run: ruff check .
- name: Mypy
run: mypy app/ --ignore-missing-imports
test:
name: Tests
runs-on: ubuntu-22.04
needs: lint
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test_db
POSTGRES_USER: test
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install deps
run: pip install -r requirements.txt
- name: Run tests
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test_db
run: pytest --tb=short -q
build:
name: Build & Push Docker
runs-on: ubuntu-22.04
needs: test
if: github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
Несколько важных деталей. actions/checkout@v4 — версия закреплена явно, не @main. Плавающие ссылки на теги — вектор supply chain атак: злоумышленник может обновить action и получить выполнение произвольного кода в контексте вашего репозитория. Используйте конкретные версии или SHA коммита.
services — встроенный способ поднять зависимости (PostgreSQL, Redis) прямо в job без docker-compose. GitHub Actions запустит контейнер и откроет порты на localhost runner'а.
Composite actions и reusable workflows
По мере роста числа репозиториев выясняется, что одни и те же шаги копируются из workflow в workflow: настройка Python, кэширование зависимостей, загрузка Docker layer cache. Это техдолг. Два инструмента решают проблему по-разному.
Composite action — переиспользуемый набор шагов, упакованный в один action. Живёт в отдельном репозитории или в .github/actions/ текущего репо.
# .github/actions/setup-python-env/action.yml
name: Setup Python Environment
description: Install Python, restore pip cache, install deps
inputs:
python-version:
description: Python version
default: "3.12"
requirements-file:
description: Path to requirements file
default: requirements.txt
runs:
using: composite
steps:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ inputs.python-version }}-${{ hashFiles(inputs.requirements-file) }}
restore-keys: |
pip-${{ runner.os }}-${{ inputs.python-version }}-
- name: Install dependencies
shell: bash
run: pip install -r ${{ inputs.requirements-file }}
Теперь в любом workflow достаточно одной строки:
- name: Setup Python
uses: ./.github/actions/setup-python-env
with:
python-version: "3.12"
Reusable workflows — более мощный инструмент: переиспользуется целый job или группа jobs, со своими secrets, inputs и outputs. Вызываются через uses: на уровне job:
# .github/workflows/reusable-deploy.yml
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
image-tag:
required: true
type: string
secrets:
DEPLOY_SSH_KEY:
required: true
jobs:
deploy:
runs-on: ubuntu-22.04
environment: ${{ inputs.environment }}
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ vars.DEPLOY_HOST }}
username: deploy
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
docker pull ${{ inputs.image-tag }}
docker compose up -d --no-deps app
# Основной workflow вызывает reusable:
deploy-staging:
needs: build
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: staging
image-tag: ghcr.io/org/app:${{ github.sha }}
secrets:
DEPLOY_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
Разница: composite action — это шаги внутри job, reusable workflow — это целый job со своим runner. Второе нужно, когда требуется другое окружение, другие permissions или матрица environments.
GitLab CI: .gitlab-ci.yml, stages vs DAG
GitLab CI работает иначе: конфигурация в одном файле .gitlab-ci.yml в корне репозитория, runner'ы регистрируются в GitLab и могут быть shared или project-specific.
# .gitlab-ci.yml
stages:
- lint
- test
- build
- deploy
variables:
PYTHON_VERSION: "3.12"
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
# Шаблон (YAML anchors) для переиспользования
.python-base: &python-base
image: python:${PYTHON_VERSION}-slim
cache:
key:
files:
- requirements.txt
paths:
- .cache/pip
before_script:
- pip install -r requirements.txt --quiet
lint:
<<: *python-base
stage: lint
script:
- pip install ruff mypy --quiet
- ruff check .
- mypy app/ --ignore-missing-imports
test:
<<: *python-base
stage: test
services:
- name: postgres:16
alias: postgres
variables:
POSTGRES_DB: test_db
POSTGRES_USER: test
POSTGRES_PASSWORD: test
DATABASE_URL: "postgresql://test:test@postgres:5432/test_db"
script:
- pytest --tb=short -q --junitxml=report.xml
artifacts:
reports:
junit: report.xml
when: always
expire_in: 1 week
build:
stage: build
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
- docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" "$CI_REGISTRY_IMAGE:latest"
- docker push "$CI_REGISTRY_IMAGE:latest"
only:
- main
deploy:
stage: deploy
image: alpine:3.19
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$DEPLOY_SSH_KEY" | ssh-add -
script:
- ssh -o StrictHostKeyChecking=no deploy@$DEPLOY_HOST "docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA && docker compose up -d --no-deps app"
only:
- main
environment:
name: production
url: https://myapp.example.com
Классический stages-подход выполняет все jobs одного stage параллельно, потом переходит к следующему. Это не всегда оптимально: если build зависит только от test, а не от lint, можно начать build параллельно с lint.
DAG mode (Directed Acyclic Graph) через needs: позволяет строить явные зависимости между jobs, игнорируя stages:
# DAG: build стартует как только test прошёл,
# не ждёт завершения lint
lint:
stage: validate
script: ruff check .
test:
stage: validate
script: pytest -q
build:
stage: package
needs: [test] # явная зависимость только от test
script: docker build ...
DAG сокращает время пайплайна, особенно в монорепозиториях с десятками jobs. Единственное ограничение: job с needs: не может зависеть от job в более позднем stage (чтобы не создавать циклы).
Self-hosted runners: когда оправдано
GitHub-hosted runners дают 2 CPU, 7 GB RAM, 14 GB SSD. Это достаточно для большинства задач. Self-hosted имеет смысл в конкретных сценариях:
- Производительность: сборка занимает 20+ минут, нужен мощный железный сервер с 16+ CPU и быстрым SSD
- Стоимость: при высоком объёме минут GitHub Actions становится дорогим; свои машины дешевле
- Специфичное железо: ARM, GPU для ML-тестов, macOS с Apple Silicon
- Доступ к внутренним ресурсам: тесты против staging БД во внутренней сети, деплой в закрытый кластер
- Compliance: код и артефакты не должны покидать контролируемую инфраструктуру
Security concerns — главный риск self-hosted. По умолчанию любой участник репозитория может запустить workflow на вашем runner, если workflow triggered by pull_request от форка. Правила безопасности:
# Никогда не используйте self-hosted для публичных репозиториев
# без явных мер защиты.
# 1. Ограничьте, кто может запускать workflows:
# Settings → Actions → General → Fork pull request workflows →
# Require approval for all outside collaborators
# 2. Запускайте runner в изолированном контейнере:
# actions/runner-container-hooks позволяет запускать каждый job
# в свежем Docker-контейнере
# 3. Используйте ephemeral runners через --ephemeral флаг:
./config.sh --url https://github.com/org/repo \
--token TOKEN \
--ephemeral # runner удаляется после одного job
# 4. Никогда не передавайте secrets в environment переменных напрямую
# в shell команды — они попадут в логи. Используйте files или stdin:
echo "${{ secrets.DB_PASSWORD }}" | myapp --password-stdin
Автоскейлинг self-hosted runners. Держать постоянно запущенные машины нерентабельно, если CI запускается нечасто. Решения:
# actions-runner-controller (ARC) для Kubernetes:
# Автоматически создаёт runner pods при появлении jobs в очереди
# Конфигурация AutoscalingRunnerSet (ARC v0.6+):
apiVersion: actions.github.com/v1alpha1
kind: AutoscalingRunnerSet
metadata:
name: my-runner-set
namespace: arc-runners
spec:
githubConfigUrl: https://github.com/org/repo
githubConfigSecret: my-runner-secret
maxRunners: 10
minRunners: 0 # масштаб до нуля когда нет jobs
template:
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
resources:
requests:
cpu: "2"
memory: "4Gi"
limits:
cpu: "4"
memory: "8Gi"
Для менее сложных случаев подходит philips-labs/terraform-aws-github-action-runners — Terraform-модуль для AWS, поднимает EC2 Spot Instance при появлении queued jobs через Lambda.
Monorepo CI: path filters и affected-only builds
Монорепозиторий с 10+ сервисами, где каждый push запускает полный CI всех сервисов — классическая проблема. Тест одного микросервиса не должен запускаться, если изменился только другой.
Path filters в GitHub Actions:
# Запуск CI только при изменениях в конкретном сервисе
on:
push:
paths:
- 'services/api/**'
- 'shared/lib/**' # shared зависимость
# Или через dorny/paths-filter для более гибкой логики:
jobs:
changes:
runs-on: ubuntu-22.04
outputs:
api: ${{ steps.filter.outputs.api }}
frontend: ${{ steps.filter.outputs.frontend }}
shared: ${{ steps.filter.outputs.shared }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'services/api/**'
- 'shared/**'
frontend:
- 'services/frontend/**'
- 'shared/**'
shared:
- 'shared/**'
test-api:
needs: changes
if: needs.changes.outputs.api == 'true'
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- run: cd services/api && pytest
test-frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- run: cd services/frontend && npm test
Nx — инструмент для монорепозиториев с встроенным dependency graph. Вычисляет, какие проекты затронуты изменением, и запускает только их:
# nx affected: запускает только задачи для затронутых проектов
# Сравнивает с базовой ветой (обычно main)
npx nx affected --target=test --base=origin/main
npx nx affected --target=build --base=origin/main
# В CI: сравниваем с последним успешным коммитом в main
npx nx affected --target=test \
--base=$NX_BASE \
--head=$NX_HEAD
# GitHub Actions с Nx:
jobs:
ci:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # нужен полный граф для nx affected
- uses: nrwl/nx-set-shas@v4 # устанавливает NX_BASE и NX_HEAD
- run: npm ci
- name: Run affected tests
run: npx nx affected --target=test --parallel=3
- name: Run affected builds
run: npx nx affected --target=build --parallel=2
Turborepo — альтернатива от Vercel, написанная на Rust, быстрее Nx на крупных монорепозиториях:
# turbo.json: описание pipeline и зависимостей между задачами
# {
# "pipeline": {
# "build": { "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] },
# "test": { "dependsOn": ["build"] },
# "lint": {}
# }
# }
# Запуск только изменённых пакетов:
turbo run test --filter=...[origin/main]
# С кэшированием (локальным и remote):
turbo run build --cache-dir=.turbo
Кэширование: dependency cache, Docker layers, build artifacts
Кэширование — самый быстрый способ ускорить CI без изменения кода. Три уровня: зависимости, Docker слои, build артефакты.
Dependency cache в GitHub Actions:
# Python: pip cache
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('requirements*.txt') }}
restore-keys: |
pip-${{ runner.os }}-
# Node.js: npm cache
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
# Gradle (Android/Java):
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
Docker layer cache. Каждый раз пересобирать образ с нуля — расточительство. BuildKit поддерживает внешний кэш через registry:
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myrepo/myapp:${{ github.sha }}
# Внешний кэш через registry (работает между runner'ами)
cache-from: type=registry,ref=myrepo/myapp:buildcache
cache-to: type=registry,ref=myrepo/myapp:buildcache,mode=max
Правило эффективного Dockerfile для кэширования: копируйте файлы зависимостей (requirements.txt, package.json) перед копированием кода приложения. Тогда слой с pip install кэшируется и не пересобирается при каждом изменении кода.
# Хороший порядок слоёв Dockerfile:
FROM python:3.12-slim
WORKDIR /app
# Сначала только файл зависимостей (меняется редко)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Потом весь код (меняется часто)
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Build artifacts — передача скомпилированных файлов между jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 1
deploy:
needs: build
runs-on: ubuntu-22.04
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy
run: rsync -avz dist/ deploy@server:/var/www/app/
Матрица решений: что выбрать
Не существует универсального ответа. Выбор зависит от контекста:
- Стартап, GitHub, <5 разработчиков: GitHub Actions hosted runners. Просто, без инфраструктуры, бесплатные минуты покрывают большинство нужд.
- Продукт с высокой нагрузкой на CI: self-hosted runners на Spot Instance + actions/cache + Docker layer cache. Экономит 60–80% стоимости минут.
- GitLab Self-Managed: GitLab CI с DAG + shared runners + GitLab Registry. Всё в одной экосистеме, встроенный Container Registry, хорошая интеграция с Kubernetes.
- Монорепозиторий с TypeScript: Turborepo или Nx + GitHub Actions + Remote Cache (Vercel/Nx Cloud или self-hosted). Затронутые сборки сокращают время CI в 3–10 раз на крупных репо.
- Compliance/закрытая сеть: GitLab Self-Managed + self-hosted runners в DMZ. Код, артефакты, секреты — всё внутри периметра.
Главный принцип: CI должен быть быстрым и детерминированным. Slow CI означает, что разработчики перестают его запускать или игнорируют ошибки. Инвестируйте в кэширование, разбивайте пайплайн на независимые jobs, настраивайте affected-only builds — это окупается на горизонте нескольких месяцев.
CI/CD как код — это не инструмент, это культура. Пайплайн, который не ревьюится, не тестируется и не рефакторится — превращается в тот же легаси-долг, что и остальной код. Относитесь к нему так же.