← все статьи
8 мин

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 как код — это не инструмент, это культура. Пайплайн, который не ревьюится, не тестируется и не рефакторится — превращается в тот же легаси-долг, что и остальной код. Относитесь к нему так же.

Следующая →Аутентификация в 2025: JWT, sessions, passkeys