Перейти к содержимому
·29 мин чтения

GitHub Actions CI/CD: деплой без простоя, который реально работает

Моя полная настройка GitHub Actions: параллельные тестовые джобы, кэширование Docker-сборок, SSH-деплой на VPS, zero-downtime через PM2 reload, управление секретами и паттерны воркфлоу, отточенные за два года.

Поделиться:X / TwitterLinkedIn

Каждый проект, над которым я работал, рано или поздно достигает одной и той же точки перелома: процесс деплоя становится слишком мучительным, чтобы делать его вручную. Вы забываете запустить тесты. Вы собираете локально, но забываете обновить версию. Вы заходите по SSH на продакшен и обнаруживаете, что тот, кто деплоил последним, оставил устаревший .env файл.

GitHub Actions решил эту проблему для меня два года назад. Не идеально с первого дня — первый воркфлоу, который я написал, был 200-строчным YAML-кошмаром, который в половине случаев падал по таймауту и ничего не кэшировал. Но итерация за итерацией я пришёл к тому, что деплоит этот сайт надёжно, без простоя, менее чем за четыре минуты.

Это тот самый воркфлоу, объяснённый секция за секцией. Не версия из документации. Версия, которая выживает при столкновении с продакшеном.

Понимание строительных блоков#

Прежде чем перейти к полному пайплайну, вам нужна чёткая ментальная модель того, как работает GitHub Actions. Если вы использовали Jenkins или CircleCI, забудьте большую часть того, что знаете. Концепции пересекаются приблизительно, но модель исполнения достаточно отличается, чтобы вас подловить.

Триггеры: когда запускается ваш воркфлоу#

yaml
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * 1" # Каждый понедельник в 6:00 UTC
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production

Четыре триггера, каждый служит своей цели:

  • push в main — это триггер деплоя в продакшен. Код смерджен? Выкатываем.
  • pull_request запускает CI-проверки на каждом PR. Здесь живут линтинг, проверка типов и тесты.
  • schedule — это cron для вашего репозитория. Я использую его для еженедельных аудитов зависимостей и очистки устаревшего кэша.
  • workflow_dispatch даёт вам кнопку ручного «Deploy» в интерфейсе GitHub с входными параметрами. Незаменим, когда нужно задеплоить стейджинг без изменения кода — может, вы обновили переменную окружения или нужно перетянуть базовый Docker-образ.

Одна вещь, которая подлавливает людей: pull_request запускается против мерж-коммита, а не HEAD ветки PR. Это означает, что ваш CI тестирует, как код будет выглядеть после мерджа. На самом деле это именно то, что вам нужно, но удивляет, когда зелёная ветка становится красной после ребейза.

Джобы, шаги и раннеры#

yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint

Джобы по умолчанию запускаются параллельно. Каждая джоба получает свежую виртуальную машину («раннер»). ubuntu-latest даёт вам достаточно мощную машину — 4 vCPU, 16 ГБ ОЗУ по состоянию на 2026 год. Бесплатно для публичных репозиториев, 2000 минут/месяц для приватных.

Шаги выполняются последовательно внутри джобы. Каждый шаг uses: подключает переиспользуемый экшен из маркетплейса. Каждый шаг run: выполняет команду оболочки.

Флаг --frozen-lockfile критически важен. Без него pnpm install может обновить ваш lockfile во время CI, что означает, что вы тестируете не те же зависимости, которые закоммитил разработчик. Я видел, как это вызывает фантомные падения тестов, которые исчезают локально, потому что lockfile на машине разработчика уже корректен.

Переменные окружения vs секреты#

yaml
env:
  NODE_ENV: production
  NEXT_TELEMETRY_DISABLED: 1
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
        run: |
          echo "$SSH_PRIVATE_KEY" > key.pem
          chmod 600 key.pem
          ssh -i key.pem deploy@$DEPLOY_HOST "cd /var/www/app && ./deploy.sh"

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

Секреты (${{ secrets.X }}) зашифрованы в хранилище, замаскированы в логах и доступны только воркфлоу в том же репозитории. Они настраиваются в Settings > Secrets and variables > Actions.

Строка environment: production значима. GitHub Environments позволяет привязывать секреты к конкретным целям деплоя. Ваш SSH-ключ для стейджинга и SSH-ключ для продакшена могут оба называться SSH_PRIVATE_KEY, но содержать разные значения в зависимости от того, какое окружение указано для джобы. Это также открывает возможность обязательных ревьюеров — вы можете заблокировать деплой в продакшен ручным одобрением.

Полный CI-пайплайн#

Вот как я структурирую CI-часть пайплайна. Цель: поймать каждую категорию ошибки за минимально возможное время.

yaml
name: CI
 
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
 
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm test -- --coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  build:
    name: Build
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: .next/
          retention-days: 1

Почему такая структура#

Lint, typecheck и test запускаются параллельно. Они не зависят друг от друга. Ошибка типов не блокирует работу линтера, и упавший тест не ждёт завершения проверки типов. При типичном запуске все три завершаются за 30-60 секунд, работая одновременно.

Build ожидает все три. Строка needs: [lint, typecheck, test] означает, что джоба сборки начнётся только если lint, typecheck И test — все пройдут успешно. Нет смысла собирать проект с ошибками линтера или типов.

concurrency с cancel-in-progress: true — огромная экономия времени. Если вы пушите два коммита подряд, первый CI-запуск отменяется. Без этого устаревшие запуски будут расходовать ваш бюджет минут и захламлять UI проверок.

Загрузка покрытия с if: always() означает, что вы получаете отчёт о покрытии даже когда тесты падают. Это полезно для отладки — вы можете видеть, какие тесты упали и что они покрывали.

Быстрый отказ vs запуск всех#

По умолчанию, если одна джоба в матрице падает, GitHub отменяет остальные. Для CI мне на самом деле нужно такое поведение — если линтер упал, мне неинтересны результаты тестов. Сначала исправьте линтер.

Но для матриц тестов (скажем, тестирование на Node 20 и Node 22) вы можете захотеть видеть все ошибки сразу:

yaml
test:
  strategy:
    fail-fast: false
    matrix:
      node-version: [20, 22]
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: pnpm/action-setup@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
        cache: "pnpm"
    - run: pnpm install --frozen-lockfile
    - run: pnpm test

fail-fast: false позволяет обоим вариантам матрицы завершиться. Если Node 22 падает, а Node 20 проходит, вы видите эту информацию сразу, вместо того чтобы перезапускать.

Кэширование для скорости#

Самое значительное улучшение скорости CI — это кэширование. Холодный pnpm install на проекте среднего размера занимает 30-45 секунд. С прогретым кэшем — 3-5 секунд. Умножьте это на четыре параллельные джобы, и вы экономите две минуты на каждом запуске.

Кэш хранилища pnpm#

yaml
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: "pnpm"

Эта одна строка кэширует хранилище pnpm (~/.local/share/pnpm/store). При попадании в кэш pnpm install --frozen-lockfile просто создаёт жёсткие ссылки из хранилища вместо скачивания. Одно это сокращает время установки на 80% при повторных запусках.

Если вам нужно больше контроля — скажем, вы хотите кэшировать с учётом ОС — используйте actions/cache напрямую:

yaml
- uses: actions/cache@v4
  with:
    path: |
      ~/.local/share/pnpm/store
      node_modules
    key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-

Фоллбэк через restore-keys важен. Если pnpm-lock.yaml изменился (новая зависимость), точный ключ не совпадёт, но совпадение по префиксу всё равно восстановит большинство закэшированных пакетов. Скачается только разница.

Кэш сборки Next.js#

У Next.js есть собственный кэш сборки в .next/cache. Кэширование его между запусками обеспечивает инкрементальные сборки — перекомпилируются только изменённые страницы и компоненты.

yaml
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      nextjs-${{ runner.os }}-

Эта трёхуровневая стратегия ключей означает:

  1. Точное совпадение: те же зависимости И тот же исходный код. Полное попадание в кэш, сборка почти мгновенная.
  2. Частичное совпадение (зависимости): зависимости те же, но исходный код изменился. Сборка перекомпилирует только изменённые файлы.
  3. Частичное совпадение (только ОС): зависимости изменились. Сборка переиспользует что может.

Реальные цифры из моего проекта: холодная сборка занимает ~55 секунд, закэшированная — ~15 секунд. Это снижение на 73%.

Кэширование слоёв Docker#

Docker-сборки — это где кэширование даёт максимальный эффект. Полная Docker-сборка Next.js — установка зависимостей ОС, копирование исходников, запуск pnpm install, запуск next build — занимает 3-4 минуты вхолодную. С кэшированием слоёв — 30-60 секунд.

yaml
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha использует встроенный бэкенд кэширования GitHub Actions. mode=max кэширует все слои, а не только финальные. Это критически важно для многоступенчатых сборок, где промежуточные слои (вроде pnpm install) — самые дорогие для пересборки.

Удалённый кэш Turborepo#

Если вы в монорепозитории с Turborepo, удалённое кэширование — это трансформация. Первая сборка загружает результаты задач в кэш. Последующие сборки скачивают вместо пересчёта.

yaml
- run: pnpm turbo build --remote-only
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Я видел, как CI монорепозитория снижался с 8 минут до 90 секунд с удалённым кэшем Turbo. Но есть нюанс: требуется аккаунт Vercel или self-hosted сервер Turbo. Для одно-приложенческих репозиториев это избыточно.

Сборка и пуш Docker-образа#

Если вы деплоите на VPS (или любой сервер), Docker обеспечивает воспроизводимые сборки. Тот же образ, что запускается в CI — тот же образ, что работает в продакшене. Больше никакого «у меня работает», потому что машина и есть образ.

Многоступенчатый Dockerfile#

Прежде чем перейти к воркфлоу, вот Dockerfile, который я использую для Next.js:

dockerfile
# Этап 1: Зависимости
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
 
# Этап 2: Сборка
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
 
# Этап 3: Продакшен
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

Три этапа, чёткое разделение. Финальный образ ~150МБ вместо ~1.2ГБ, которые получились бы при копировании всего. Только продакшен-артефакты попадают на этап runner.

Воркфлоу сборки и пуша#

yaml
name: Build and Push Docker Image
 
on:
  push:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Давайте разберём ключевые решения.

GitHub Container Registry (ghcr.io)#

Я использую ghcr.io вместо Docker Hub по трём причинам:

  1. Аутентификация бесплатна. GITHUB_TOKEN автоматически доступен в каждом воркфлоу — не нужно хранить учётные данные Docker Hub.
  2. Близость. Образы скачиваются из той же инфраструктуры, на которой работает ваш CI. Скачивание во время CI — быстрое.
  3. Видимость. Образы привязаны к вашему репозиторию в интерфейсе GitHub. Вы видите их во вкладке Packages.

Мультиплатформенные сборки#

yaml
platforms: linux/amd64,linux/arm64

Эта строка добавляет примерно 90 секунд к сборке, но оно того стоит. Образы ARM64 нативно запускаются на:

  • Apple Silicon Mac (M1/M2/M3/M4) при локальной разработке с Docker Desktop
  • Инстансах AWS Graviton (на 20-40% дешевле x86-эквивалентов)
  • Бесплатном ARM-тире Oracle Cloud

Без этого ваши разработчики на Mac серии M запускают x86-образы через эмуляцию Rosetta. Это работает, но заметно медленнее и иногда выявляет странные баги, специфичные для архитектуры.

QEMU обеспечивает слой кросс-компиляции. Buildx оркестрирует мультиархитектурную сборку и пушит список манифестов, чтобы Docker автоматически скачивал правильную архитектуру.

Стратегия тегирования#

yaml
tags: |
  type=sha,prefix=
  type=ref,event=branch
  type=raw,value=latest,enable={{is_default_branch}}

Каждый образ получает три тега:

  • abc1234 (SHA коммита): неизменяемый. Вы всегда можете задеплоить конкретный коммит.
  • main (имя ветки): изменяемый. Указывает на последнюю сборку из этой ветки.
  • latest: изменяемый. Устанавливается только для ветки по умолчанию. Это то, что тянет ваш сервер.

Никогда не деплойте latest в продакшен, не записав SHA где-нибудь. Когда что-то сломается, вам нужно знать, какой именно latest. Я сохраняю задеплоенный SHA в файле на сервере, который читает health endpoint.

SSH-деплой на VPS#

Здесь всё соединяется воедино. CI пройден, Docker-образ собран и запушен, теперь нужно сказать серверу скачать новый образ и перезапуститься.

SSH-экшен#

yaml
deploy:
  name: Deploy to Production
  needs: [build-and-push]
  runs-on: ubuntu-latest
  environment: production
 
  steps:
    - name: Deploy via SSH
      uses: appleboy/ssh-action@v1
      with:
        host: ${{ secrets.DEPLOY_HOST }}
        username: ${{ secrets.DEPLOY_USER }}
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        port: ${{ secrets.SSH_PORT }}
        script_stop: true
        script: |
          set -euo pipefail
 
          APP_DIR="/var/www/akousa.net"
          IMAGE="ghcr.io/${{ github.repository }}:latest"
          DEPLOY_SHA="${{ github.sha }}"
 
          echo "=== Деплой $DEPLOY_SHA ==="
 
          # Скачать последний образ
          docker pull "$IMAGE"
 
          # Остановить и удалить старый контейнер
          docker stop akousa-app || true
          docker rm akousa-app || true
 
          # Запустить новый контейнер
          docker run -d \
            --name akousa-app \
            --restart unless-stopped \
            --network host \
            -e NODE_ENV=production \
            -e DATABASE_URL="${DATABASE_URL}" \
            -p 3000:3000 \
            "$IMAGE"
 
          # Ожидание проверки здоровья
          echo "Ожидание проверки здоровья..."
          for i in $(seq 1 30); do
            if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
              echo "Проверка здоровья пройдена на попытке $i"
              break
            fi
            if [ "$i" -eq 30 ]; then
              echo "Проверка здоровья не пройдена после 30 попыток"
              exit 1
            fi
            sleep 2
          done
 
          # Записать задеплоенный SHA
          echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
 
          # Очистить старые образы
          docker image prune -af --filter "until=168h"
 
          echo "=== Деплой завершён ==="

Альтернатива: скрипт деплоя#

Для чего-то более сложного, чем простой pull-and-restart, я выношу логику в скрипт на сервере, вместо того чтобы встраивать её в воркфлоу:

bash
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
 
log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
 
log "Начало деплоя..."
 
# Логин в GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
 
# Скачивание с повтором
for attempt in 1 2 3; do
  if docker pull "$IMAGE"; then
    log "Образ успешно скачан на попытке $attempt"
    break
  fi
  if [ "$attempt" -eq 3 ]; then
    log "ОШИБКА: Не удалось скачать образ после 3 попыток"
    exit 1
  fi
  log "Попытка $attempt не удалась, повтор через 5с..."
  sleep 5
done
 
# Функция проверки здоровья
health_check() {
  local port=$1
  local max_attempts=30
  for i in $(seq 1 $max_attempts); do
    if curl -sf "http://localhost:$port/api/health" > /dev/null 2>&1; then
      return 0
    fi
    sleep 2
  done
  return 1
}
 
# Запуск нового контейнера на альтернативном порту
docker run -d \
  --name akousa-app-new \
  --env-file "$APP_DIR/.env.production" \
  -p 3001:3000 \
  "$IMAGE"
 
# Проверка здоровья нового контейнера
if ! health_check 3001; then
  log "ОШИБКА: Новый контейнер не прошёл проверку здоровья. Откат."
  docker stop akousa-app-new || true
  docker rm akousa-app-new || true
  exit 1
fi
 
log "Новый контейнер здоров. Переключение трафика..."
 
# Переключение upstream в Nginx
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Остановка старого контейнера
docker stop akousa-app || true
docker rm akousa-app || true
 
# Переименование нового контейнера
docker rename akousa-app-new akousa-app
 
log "Деплой завершён."

Воркфлоу тогда сводится к одной SSH-команде:

yaml
script: |
  cd /var/www/akousa.net && ./deploy.sh

Это лучше, потому что: (1) логика деплоя версионирована на сервере, (2) вы можете запустить её вручную через SSH для отладки, и (3) вам не нужно экранировать YAML внутри YAML внутри bash.

Стратегии нулевого простоя#

«Нулевой простой» звучит как маркетинг, но имеет точное значение: ни один запрос не получает connection refused или 502 во время деплоя. Вот три реальных подхода, от простейшего к наиболее надёжному.

Стратегия 1: PM2 Cluster Mode Reload#

Если вы запускаете Node.js напрямую (не в Docker), кластерный режим PM2 даёт вам самый простой путь к нулевому простою.

bash
# ecosystem.config.js уже содержит:
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 reload (не restart) выполняет роллинг-рестарт. Он запускает новых воркеров, ждёт их готовности, затем убивает старых воркеров по одному. Ни в один момент количество воркеров, обслуживающих трафик, не равно нулю.

Флаг --update-env перезагружает переменные окружения из конфига экосистемы. Без него ваши старые переменные сохраняются даже после деплоя, который изменил .env.

В вашем воркфлоу:

yaml
- name: Deploy and reload PM2
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.DEPLOY_HOST }}
    username: ${{ secrets.DEPLOY_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      cd /var/www/akousa.net
      git pull origin main
      pnpm install --frozen-lockfile
      pnpm build
      pm2 reload ecosystem.config.js --update-env

Это то, что я использую для этого сайта. Просто, надёжно, и простой — буквально ноль. Я тестировал это с генератором нагрузки, отправляющим 100 запросов/сек во время деплоя. Ни одной 5xx.

Стратегия 2: Blue/Green с Nginx Upstream#

Для Docker-деплоев blue/green даёт чёткое разделение между старой и новой версией.

Концепция: запустить старый контейнер («blue») на порту 3000, а новый («green») на порту 3001. Nginx указывает на blue. Вы запускаете green, проверяете его здоровье, переключаете Nginx на green, затем останавливаете blue.

Конфигурация Nginx upstream:

nginx
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
    server 127.0.0.1:3000;
}
nginx
# /etc/nginx/sites-available/akousa.net
server {
    listen 443 ssl http2;
    server_name akousa.net;
 
    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

Скрипт переключения:

bash
#!/bin/bash
set -euo pipefail
 
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
 
if [ "$CURRENT_PORT" = "3000" ]; then
  NEW_PORT=3001
  OLD_PORT=3000
else
  NEW_PORT=3000
  OLD_PORT=3001
fi
 
echo "Текущий: $OLD_PORT -> Новый: $NEW_PORT"
 
# Запуск нового контейнера на альтернативном порту
docker run -d \
  --name "akousa-app-$NEW_PORT" \
  --env-file /var/www/akousa.net/.env.production \
  -p "$NEW_PORT:3000" \
  "ghcr.io/akousa/akousa-net:latest"
 
# Ожидание здоровья
for i in $(seq 1 30); do
  if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
    echo "Новый контейнер здоров на порту $NEW_PORT"
    break
  fi
  [ "$i" -eq 30 ] && { echo "Проверка здоровья не пройдена"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
  sleep 2
done
 
# Переключение Nginx
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Остановка старого контейнера
sleep 5  # Дать время завершиться текущим запросам
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
 
echo "Переключено с :$OLD_PORT на :$NEW_PORT"

5-секундный sleep после перезагрузки Nginx — это не лень, а время на завершение. Перезагрузка Nginx — graceful (существующие соединения сохраняются), но некоторым long-polling соединениям или потоковым ответам нужно время для завершения.

Стратегия 3: Docker Compose с проверками здоровья#

Для более структурированного подхода Docker Compose может управлять blue/green переключением:

yaml
# docker-compose.yml
services:
  app:
    image: ghcr.io/akousa/akousa-net:latest
    restart: unless-stopped
    env_file: .env.production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      rollback_config:
        parallelism: 0
        order: stop-first
    ports:
      - "3000:3000"

Ключевая строка — order: start-first. Она означает «запусти новый контейнер до остановки старого». В сочетании с parallelism: 1 вы получаете роллинг-обновление — по одному контейнеру за раз, всегда поддерживая ёмкость.

Деплой командой:

bash
docker compose pull
docker compose up -d --remove-orphans

Docker Compose наблюдает за healthcheck и не направляет трафик на новый контейнер, пока он не пройдёт проверку. Если healthcheck падает, failure_action: rollback автоматически откатывает на предыдущую версию. Это максимально близко к роллинг-деплоям в стиле Kubernetes, что можно получить на одном VPS.

Управление секретами#

Управление секретами — это одна из тех вещей, которые легко сделать «почти правильно» и катастрофически неправильно в оставшихся граничных случаях.

GitHub Secrets: основы#

yaml
# Устанавливаются через интерфейс GitHub: Settings > Secrets and variables > Actions
 
steps:
  - name: Use a secret
    env:
      DB_URL: ${{ secrets.DATABASE_URL }}
    run: |
      # Значение маскируется в логах
      echo "Подключение к базе данных..."
      # Это напечатает "Connecting to ***" в логах
      echo "Connecting to $DB_URL"

GitHub автоматически маскирует значения секретов в выводе логов. Если ваш секрет p@ssw0rd123 и любой шаг печатает эту строку, в логах будет ***. Это работает хорошо, с одной оговоркой: если ваш секрет короткий (например, 4-значный PIN), GitHub может его не замаскировать, потому что он может совпасть с безобидными строками. Делайте секреты достаточно сложными.

Секреты с привязкой к окружению#

yaml
jobs:
  deploy-staging:
    environment: staging
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = staging.akousa.net
 
  deploy-production:
    environment: production
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = akousa.net

Одно и то же имя секрета, разные значения для каждого окружения. Поле environment джобы определяет, какой набор секретов внедряется.

Продакшен-окружения должны иметь включённую опцию required reviewers. Это означает, что пуш в main запускает воркфлоу, CI работает автоматически, но джоба деплоя приостанавливается и ждёт, пока кто-то нажмёт «Approve» в интерфейсе GitHub. Для сольного проекта это может казаться избыточным. Для всего, что имеет пользователей, это спасение, когда вы случайно мерджите что-то сломанное.

OIDC: больше никаких статических учётных данных#

Статические учётные данные (ключи доступа AWS, JSON-файлы сервисных аккаунтов GCP), хранящиеся в GitHub Secrets — это ответственность. Они не истекают, их нельзя ограничить конкретным запуском воркфлоу, и если они утекут, придётся ротировать их вручную.

OIDC (OpenID Connect) решает эту проблему. GitHub Actions выступает как провайдер идентификации, а ваш облачный провайдер доверяет ему выпускать короткоживущие учётные данные на лету:

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Требуется для OIDC
      contents: read
 
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: eu-central-1
 
      - name: Push to ECR
        run: |
          aws ecr get-login-password --region eu-central-1 | \
            docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.com

Никаких ключей доступа. Никаких секретных ключей. Экшен configure-aws-credentials запрашивает временный токен у AWS STS, используя OIDC-токен GitHub. Токен привязан к конкретному репозиторию, ветке и окружению. Он истекает после завершения запуска воркфлоу.

Настройка на стороне AWS требует IAM OIDC identity provider и политики доверия роли:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:akousa/akousa-net:ref:refs/heads/main"
        }
      }
    }
  ]
}

Условие sub критически важно. Без него любой репозиторий, каким-то образом получивший данные вашего OIDC-провайдера, мог бы принять роль. С ним только ветка main вашего конкретного репозитория может.

У GCP есть эквивалентная настройка с Workload Identity Federation. У Azure — федеративные учётные данные. Если ваш облачный провайдер поддерживает OIDC, используйте его. В 2026 году нет причин хранить статические облачные учётные данные.

SSH-ключи деплоя#

Для деплоев на VPS через SSH сгенерируйте отдельную пару ключей:

bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""

Добавьте публичный ключ в ~/.ssh/authorized_keys сервера с ограничениями:

restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy

Префикс restrict отключает перенаправление портов, перенаправление агента, выделение PTY и перенаправление X11. Префикс command= означает, что этот ключ может только выполнить скрипт деплоя. Даже если приватный ключ скомпрометирован, атакующий может запустить ваш скрипт деплоя и ничего больше.

Добавьте приватный ключ в GitHub Secrets как SSH_PRIVATE_KEY. Это единственные статические учётные данные, которые я допускаю — SSH-ключи с принудительными командами имеют очень ограниченный радиус поражения.

Воркфлоу PR: превью-деплои#

Каждый PR заслуживает превью-окружения. Оно ловит визуальные баги, которые юнит-тесты пропускают, позволяет дизайнерам делать ревью без клонирования кода и значительно упрощает жизнь QA.

Деплой превью при открытии PR#

yaml
name: Preview Deploy
 
on:
  pull_request:
    types: [opened, synchronize, reopened]
 
jobs:
  preview:
    runs-on: ubuntu-latest
    environment:
      name: preview-${{ github.event.number }}
      url: ${{ steps.deploy.outputs.url }}
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Build preview image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Deploy preview
        id: deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            PORT=$((4000 + PR_NUM))
            IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
 
            docker pull "$IMAGE"
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
 
            docker run -d \
              --name "preview-${PR_NUM}" \
              --restart unless-stopped \
              -e NODE_ENV=preview \
              -p "${PORT}:3000" \
              "$IMAGE"
 
            echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
 
      - name: Comment PR with preview URL
        uses: actions/github-script@v7
        with:
          script: |
            const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
            const body = `### Preview Deployment
 
            | Status | URL |
            |--------|-----|
            | :white_check_mark: Deployed | [${url}](${url}) |
 
            _Last updated: ${new Date().toISOString()}_
            _Commit: \`${{ github.sha }}\`_`;
 
            // Find existing comment
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
 
            const botComment = comments.data.find(c =>
              c.user.type === 'Bot' && c.body.includes('Preview Deployment')
            );
 
            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

Вычисление порта (4000 + PR_NUM) — прагматичный хак. PR #42 получает порт 4042. Пока у вас нет нескольких сотен открытых PR, коллизий не будет. Конфигурация Nginx с wildcard маршрутизирует pr-*.preview.akousa.net на правильный порт.

Очистка при закрытии PR#

Превью-окружения, которые не очищаются, потребляют диск и память. Добавьте джобу очистки:

yaml
name: Cleanup Preview
 
on:
  pull_request:
    types: [closed]
 
jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Remove preview container
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
            docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
            echo "Превью для PR #${PR_NUM} очищено."
 
      - name: Deactivate environment
        uses: actions/github-script@v7
        with:
          script: |
            const deployments = await github.rest.repos.listDeployments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              environment: `preview-${{ github.event.number }}`,
            });
 
            for (const deployment of deployments.data) {
              await github.rest.repos.createDeploymentStatus({
                owner: context.repo.owner,
                repo: context.repo.repo,
                deployment_id: deployment.id,
                state: 'inactive',
              });
            }

Обязательные проверки статуса#

В настройках репозитория (Settings > Branches > Branch protection rules) потребуйте эти проверки перед мерджем:

  • lint — нет ошибок линтера
  • typecheck — нет ошибок типов
  • test — все тесты проходят
  • build — проект успешно собирается

Без этого кто-нибудь обязательно мерджнет PR с упавшими проверками. Не злонамеренно — они увидят «2 из 4 проверок пройдено» и решат, что остальные две ещё выполняются. Заблокируйте это.

Также включите «Require branches to be up to date before merging.» Это принуждает перезапуск CI после ребейза на последний main. Это ловит случай, когда два PR по отдельности проходят CI, но конфликтуют при объединении.

Уведомления#

Деплой, о котором никто не знает — это деплой, которому никто не доверяет. Уведомления замыкают обратную связь.

Slack Webhook#

yaml
- name: Notify Slack
  if: always()
  uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
    webhook-type: incoming-webhook
    payload: |
      {
        "blocks": [
          {
            "type": "header",
            "text": {
              "type": "plain_text",
              "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
            }
          },
          {
            "type": "section",
            "fields": [
              {
                "type": "mrkdwn",
                "text": "*Repository:*\n${{ github.repository }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Branch:*\n${{ github.ref_name }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
              },
              {
                "type": "mrkdwn",
                "text": "*Triggered by:*\n${{ github.actor }}"
              }
            ]
          },
          {
            "type": "actions",
            "elements": [
              {
                "type": "button",
                "text": {
                  "type": "plain_text",
                  "text": "View Run"
                },
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
              }
            ]
          }
        ]
      }

if: always() — критически важно. Без него шаг уведомления пропускается, когда деплой падает — а это именно тот момент, когда оно нужно больше всего.

GitHub Deployments API#

Для более богатого отслеживания деплоев используйте GitHub Deployments API. Это даёт вам историю деплоев в интерфейсе репозитория и позволяет ставить статус-бейджи:

yaml
- name: Create GitHub Deployment
  id: deployment
  uses: actions/github-script@v7
  with:
    script: |
      const deployment = await github.rest.repos.createDeployment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        ref: context.sha,
        environment: 'production',
        auto_merge: false,
        required_contexts: [],
        description: `Deploying ${context.sha.substring(0, 7)} to production`,
      });
      return deployment.data.id;
 
- name: Deploy
  run: |
    # ... собственно шаги деплоя ...
 
- name: Update deployment status
  if: always()
  uses: actions/github-script@v7
  with:
    script: |
      const deploymentId = ${{ steps.deployment.outputs.result }};
      await github.rest.repos.createDeploymentStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        deployment_id: deploymentId,
        state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
        environment_url: 'https://akousa.net',
        log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
        description: '${{ job.status }}' === 'success'
          ? 'Deployment succeeded'
          : 'Deployment failed',
      });

Теперь вкладка Environments в GitHub показывает полную историю деплоев: кто что деплоил, когда, и успешно ли.

Email только при ошибке#

Для критических деплоев я также отправляю email при ошибке. Не через встроенный email GitHub Actions (слишком шумный), а через целевой вебхук:

yaml
- name: Alert on failure
  if: failure()
  run: |
    curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
      -H "Content-Type: application/json" \
      -d '{
        "subject": "DEPLOY FAILED: ${{ github.repository }}",
        "body": "Commit: ${{ github.sha }}\nActor: ${{ github.actor }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
      }'

Это моя последняя линия обороны. Slack хорош, но он тоже шумный — люди отключают уведомления в каналах. Email «DEPLOY FAILED» со ссылкой на запуск привлекает внимание.

Полный файл воркфлоу#

Вот всё, соединённое в единый, готовый к продакшену воркфлоу. Это очень близко к тому, что реально деплоит этот сайт.

yaml
name: CI/CD Pipeline
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      skip_tests:
        description: "Skip tests (emergency deploy)"
        required: false
        type: boolean
        default: false
 
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 
env:
  NODE_VERSION: "22"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  # ============================================================
  # CI: Lint, проверка типов и тесты параллельно
  # ============================================================
 
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run ESLint
        run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run TypeScript compiler
        run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    if: ${{ !inputs.skip_tests }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run tests with coverage
        run: pnpm test -- --coverage
 
      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  # ============================================================
  # Build: только после прохождения CI
  # ============================================================
 
  build:
    name: Build Application
    needs: [lint, typecheck, test]
    if: always() && !cancelled() && needs.lint.result == 'success' && needs.typecheck.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
            nextjs-${{ runner.os }}-
 
      - name: Build Next.js application
        run: pnpm build
 
  # ============================================================
  # Docker: сборка и пуш образа (только ветка main)
  # ============================================================
 
  docker:
    name: Build Docker Image
    needs: [build]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Set up QEMU for multi-platform builds
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract image metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  # ============================================================
  # Deploy: SSH на VPS и обновление
  # ============================================================
 
  deploy:
    name: Deploy to Production
    needs: [docker]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://akousa.net
 
    steps:
      - name: Create GitHub Deployment
        id: deployment
        uses: actions/github-script@v7
        with:
          script: |
            const deployment = await github.rest.repos.createDeployment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: context.sha,
              environment: 'production',
              auto_merge: false,
              required_contexts: [],
              description: `Deploy ${context.sha.substring(0, 7)}`,
            });
            return deployment.data.id;
 
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script_stop: true
          command_timeout: 5m
          script: |
            set -euo pipefail
 
            APP_DIR="/var/www/akousa.net"
            IMAGE="ghcr.io/${{ github.repository }}:latest"
            SHA="${{ github.sha }}"
 
            echo "=== Деплой $SHA начат $(date) ==="
 
            # Скачать новый образ
            docker pull "$IMAGE"
 
            # Запустить новый контейнер на альтернативном порту
            docker run -d \
              --name akousa-app-new \
              --env-file "$APP_DIR/.env.production" \
              -p 3001:3000 \
              "$IMAGE"
 
            # Проверка здоровья
            echo "Запуск проверки здоровья..."
            for i in $(seq 1 30); do
              if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
                echo "Проверка здоровья пройдена (попытка $i)"
                break
              fi
              if [ "$i" -eq 30 ]; then
                echo "ОШИБКА: Проверка здоровья не пройдена"
                docker logs akousa-app-new --tail 50
                docker stop akousa-app-new && docker rm akousa-app-new
                exit 1
              fi
              sleep 2
            done
 
            # Переключение трафика
            sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
            sudo nginx -t && sudo nginx -s reload
 
            # Время завершения текущих запросов
            sleep 5
 
            # Остановка старого контейнера
            docker stop akousa-app || true
            docker rm akousa-app || true
 
            # Переименование и сброс порта
            docker rename akousa-app-new akousa-app
            sudo sed -i 's/server 127.0.0.1:3001/server 127.0.0.1:3000/' /etc/nginx/conf.d/upstream.conf
            # Примечание: мы не перезагружаем Nginx здесь, потому что изменилось имя контейнера,
            # а не порт. Следующий деплой будет использовать правильный порт.
 
            # Запись деплоя
            echo "$SHA" > "$APP_DIR/.deployed-sha"
            echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
 
            # Очистка старых образов (старше 7 дней)
            docker image prune -af --filter "until=168h"
 
            echo "=== Деплой завершён $(date) ==="
 
      - name: Update deployment status
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const deploymentId = ${{ steps.deployment.outputs.result }};
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: deploymentId,
              state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
              environment_url: 'https://akousa.net',
              log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
            });
 
      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ github.sha }}`>"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Actor:*\n${{ github.actor }}"
                    }
                  ]
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": { "type": "plain_text", "text": "View Run" },
                      "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                    }
                  ]
                }
              ]
            }
 
      - name: Alert on failure
        if: failure()
        run: |
          curl -sf -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
            -H "Content-Type: application/json" \
            -d '{
              "subject": "DEPLOY FAILED: ${{ github.repository }}",
              "body": "Commit: ${{ github.sha }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }' || true

Пошаговое описание процесса#

Когда я пушу в main:

  1. Lint, Type Check и Test запускаются одновременно. Три раннера, три параллельные джобы. Если любая из них падает, пайплайн останавливается.
  2. Build запускается только если все три прошли. Он проверяет, что приложение компилируется и даёт рабочий результат.
  3. Docker собирает продакшен-образ и пушит его в ghcr.io. Мультиплатформенный, с кэшированием слоёв.
  4. Deploy подключается по SSH к VPS, скачивает новый образ, запускает новый контейнер, проверяет его здоровье, переключает Nginx и выполняет очистку.
  5. Уведомления отправляются независимо от результата. Slack получает сообщение. GitHub Deployments обновляется. Если деплой упал, отправляется email-алерт.

Когда я открываю PR:

  1. Lint, Type Check и Test запускаются. Те же проверки качества.
  2. Build запускается для проверки, что проект компилируется.
  3. Docker и Deploy пропускаются (условия if ограничивают их только веткой main).

Когда нужен экстренный деплой (пропуск тестов):

  1. Нажмите «Run workflow» во вкладке Actions.
  2. Выберите skip_tests: true.
  3. Lint и typecheck всё равно запускаются (вы не можете их пропустить — я не настолько себе доверяю).
  4. Тесты пропускаются, build запускается, Docker собирается, деплой выполняется.

Это мой воркфлоу, который я использую уже два года. Он пережил миграции серверов, мажорные обновления Node.js, замену npm на pnpm и добавление 15 инструментов на этот сайт. Общее время от пуша до продакшена: в среднем 3 минуты 40 секунд. Самый медленный шаг — мультиплатформенная Docker-сборка, занимающая ~90 секунд. Всё остальное закэшировано до почти мгновенного.

Уроки двух лет итераций#

Закончу ошибками, которые я совершил, чтобы вам не пришлось.

Закрепляйте версии экшенов. uses: actions/checkout@v4 — нормально, но для продакшена рассмотрите uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (полный SHA). Скомпрометированный экшен может слить ваши секреты. Инцидент с tj-actions/changed-files в 2025 году доказал, что это не теория.

Не кэшируйте всё. Однажды я закэшировал node_modules напрямую (а не только хранилище pnpm) и потратил два часа на отладку фантомной ошибки сборки, вызванной устаревшими нативными биндингами. Кэшируйте хранилище пакетного менеджера, а не установленные модули.

Устанавливайте таймауты. Каждая джоба должна иметь timeout-minutes. По умолчанию — 360 минут (6 часов). Если ваш деплой зависнет из-за обрыва SSH-соединения, вы не хотите обнаружить это через шесть часов, когда исчерпаете месячный бюджет минут.

yaml
jobs:
  deploy:
    timeout-minutes: 15
    runs-on: ubuntu-latest

Используйте concurrency с умом. Для PR cancel-in-progress: true всегда правильно — никому не нужен результат CI для коммита, который уже перезаписан force-push. Для продакшен-деплоев установите false. Вы не хотите, чтобы быстрый следующий коммит отменил деплой, который в процессе выкатки.

Тестируйте файл воркфлоу. Используйте act (https://github.com/nektos/act) для локального запуска воркфлоу. Он не поймает всё (секреты недоступны, и среда раннера отличается), но поймает синтаксические ошибки YAML и очевидные логические баги до пуша.

Следите за расходами на CI. Минуты GitHub Actions бесплатны для публичных репозиториев и дёшевы для приватных, но они накапливаются. Мультиплатформенные Docker-сборки потребляют вдвое больше минут (по одной на платформу). Матричные стратегии тестирования умножают время выполнения. Следите за страницей биллинга.

Лучший CI/CD-пайплайн — тот, которому вы доверяете. Доверие складывается из надёжности, наблюдаемости и постепенного улучшения. Начните с простого пайплайна lint-test-build. Добавьте Docker, когда понадобится воспроизводимость. Добавьте SSH-деплой, когда понадобится автоматизация. Добавьте уведомления, когда понадобится уверенность. Не стройте полный пайплайн в первый день — вы ошибётесь с абстракциями.

Похожие записи