Модуль 8. Защита проекта: Terraform, CI/CD, Security, Disaster Recovery и финальный отчёт

Цель модуля: Собрать всё воедино и защитить перед «архитектурным комитетом». Задеплоить весь стек через Terraform + GitHub Actions. Закрыть вопросы безопасности: секреты в Lockbox с авто-ротацией HMAC, govulncheck для зависимостей. Написать план Disaster Recovery для NFS с тестами через git fsck. Подготовить Cost-отчёт Serverless vs VPS vs K8s, планы миграции (YDB → PostgreSQL, Cloud Functions → VPS, NFS → S3), и защитить 8 ADR по единому шаблону с документом «Defense Q&A».

Стадия роста проекта: Защита. Система работает. Осталось одно — задеплоить в продакшен, защитить секреты, подготовить план восстановления при сбое NFS, и доказать комитету, что Serverless за ~50₽/мес — правильный выбор.

Перед началом: Модули 1–7 (весь курс)

Инструменты: Terraform, GitHub Actions, Yandex Lockbox, govulncheck, Trivy, Object Storage

Трудоёмкость: ~8 ч (Terraform + CI/CD 3 ч · Security + авто-ротация 2 ч · DR + git fsck 1 ч · Cost-отчёт + защита 2 ч)

🔥 Инцидент: NFS упал в пятницу вечером

Система работает три месяца. В пятницу вечером NFS-хранилище падает на 2 часа. Все bare-репозитории недоступны.

Вопросы, на которые нет ответа:

  • Есть ли бэкап bare-репозиториев? Нет.
  • Как быстро восстановим? Не знаем.
  • Сколько данных потеряно? Все push за последние сутки.
  • Валидны ли репозитории после восстановления? Не проверяли.

Вторая проблема: в Docker-образе git-gateway лежит .env с HMAC-ключом для вебхуков и токеном YDB. Образ случайно попал в публичный registry.

Третья проблема: в go.mod — зависимость с известной CVE. Никто не знает, потому что нет автоматической проверки.

Четвёртая проблема: ротация HMAC-ключа — ручная операция. Никто не помнит, когда последний раз меняли ключ.

Четыре задачи:

  1. Security — секреты в Lockbox, govulncheck в CI, авто-ротация HMAC-ключей.
  2. Disaster Recovery — снапшоты NFS раз в день, план восстановления, тест DR с git fsck.
  3. Эволюция — S3-хранилище как альтернатива NFS для Multi-AZ.
  4. Защита — Cost-отчёт, планы миграции, 8 ADR по единому шаблону, документ «Defense Q&A».

Сквозной User Story (финал): «Вася делает свой финальный git push origin main в production. Всё задеплоено через Terraform, секреты в Lockbox с авто-ротацией, бэкапы NFS настроены, DR-тест с git fsck проходит. Вася не знает обо всей этой кухне — он просто делает push. И это лучший комплимент архитектуре.»

📖 Теория: Security, DR и защита

Глоссарий модуля:

Lockbox — сервис управления секретами Yandex Cloud. Секреты не лежат в .env и в коде.

Авто-ротация HMAC — Cloud Function с Timer Trigger создаёт новую версию ключа раз в N дней с grace period.

govulncheck — инструмент Go для проверки зависимостей на известные CVE.

Ротация HMAC-ключа — периодическая смена ключа подписи вебхуков с grace period.

Disaster Recovery (DR) — план восстановления системы после катастрофического сбоя.

Снапшот NFS — точка восстановления файловой системы.

git fsck — проверка целостности Git-репозитория. Критичный шаг DR-теста.

Drift Detection — проверка расхождения между Terraform-кодом и реальным состоянием облака.

StorageLayer — абстракция хранения pack-файлов: NFS или S3.

Cost-отчёт — сравнение TCO: Serverless vs VPS vs K8s.

8.1. Security: секреты в Lockbox

hcl
# Terraform: секреты в Lockbox

resource "yandex_lockbox_secret" "hmac_key" {
  name = "webhook-hmac-key"
}

resource "yandex_lockbox_secret_version" "hmac_key_v1" {
  secret_id = yandex_lockbox_secret.hmac_key.id
  entries {
    key        = "hmac_key"
    text_value = var.webhook_hmac_key
  }
}

resource "yandex_lockbox_secret" "ydb_token" {
  name = "ydb-access-token"
}

resource "yandex_lockbox_secret_version" "ydb_token_v1" {
  secret_id = yandex_lockbox_secret.ydb_token.id
  entries {
    key        = "token"
    text_value = var.ydb_access_token
  }
}

# SA функций получает доступ только к нужным секретам
resource "yandex_resourcemanager_folder_iam_member" "functions-lockbox" {
  folder_id = var.folder_id
  role      = "lockbox.payloadViewer"
  member    = "serviceAccount:${yandex_iam_service_account.functions.id}"

  condition {
    title      = "specific-secrets"
    expression = "resource.id in ['${yandex_lockbox_secret.hmac_key.id}', '${yandex_lockbox_secret.ydb_token.id}']"
  }
}

Загрузка секретов в Cloud Function:

go
// internal/secrets/lockbox.go
type SecretManager struct {
    sdk *yc.SDK
}

func (sm *SecretManager) GetSecret(ctx context.Context, secretID, key string) (string, error) {
    payload, err := sm.sdk.Serverless().Lockbox().Payload().Get(ctx, &lockbox.GetPayloadRequest{
        SecretId: secretID,
    })
    if err != nil {
        return "", fmt.Errorf("get secret payload: %w", err)
    }

    for _, entry := range payload.Entries {
        if entry.Key == key {
            return entry.TextValue, nil
        }
    }
    return "", fmt.Errorf("key %s not found in secret %s", key, secretID)
}

8.1.1. Автоматическая ротация HMAC-ключей

Ротация HMAC-ключа не должна быть ручной операцией. Cloud Function с Timer Trigger раз в 30 дней создаёт новую версию ключа и помечает старую как «to be expired» с grace period 24 часа.

hcl
# Terraform: Cloud Function для ротации HMAC

resource "yandex_function" "hmac-rotator" {
  name              = "hmac-key-rotator"
  runtime           = "golang122"
  memory            = 128
  timeout           = 30
  concurrency_limit = 1

  service_account_id = yandex_iam_service_account.rotator.id

  environment = {
    LOCKBOX_SECRET_ID = yandex_lockbox_secret.hmac_key.id
    ROTATION_DAYS     = 30
    GRACE_HOURS       = 24
  }

  package {
    bucket = yandex_storage_bucket.functions.id
    object = "hmac-rotator.zip"
  }
}

resource "yandex_function_trigger" "hmac-rotation-timer" {
  name = "hmac-rotation-monthly"

  timer {
    cron_expression = "0 3 1 * *"  # 1-е число каждого месяца в 03:00
  }

  function {
    id = yandex_function.hmac-rotator.id
  }
}

# SA с правами на Lockbox (только для этого секрета)
resource "yandex_iam_service_account" "rotator" {
  name = "hmac-rotator-sa"
}

resource "yandex_resourcemanager_folder_iam_member" "rotator-lockbox" {
  folder_id = var.folder_id
  role      = "lockbox.editor"
  member    = "serviceAccount:${yandex_iam_service_account.rotator.id}"

  condition {
    title      = "hmac-secret-only"
    expression = "resource.id == '${yandex_lockbox_secret.hmac_key.id}'"
  }
}
go
// cmd/hmac-rotator/main.go
package main

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "fmt"
    "log/slog"
    "os"
    "strconv"
    "time"

    yc "github.com/yandex-cloud/go-sdk"
    "github.com/yandex-cloud/go-genproto/yandex/cloud/lockbox/v1"
)

func Handler(ctx context.Context) error {
    sdk, err := yc.Build(ctx, yc.Config{})
    if err != nil {
        return fmt.Errorf("build SDK: %w", err)
    }

    secretID := os.Getenv("LOCKBOX_SECRET_ID")
    graceHours, _ := strconv.Atoi(os.Getenv("GRACE_HOURS"))

    // (1) Генерируем новый ключ
    newKey := make([]byte, 32)
    if _, err := rand.Read(newKey); err != nil {
        return fmt.Errorf("generate key: %w", err)
    }
    newKeyHex := hex.EncodeToString(newKey)

    // (2) Создаём новую версию в Lockbox
    _, err = sdk.LockboxSecret().Payload().Create(ctx, &lockbox.CreatePayloadRequest{
        SecretId: secretID,
        Entries: []*lockbox.PayloadEntryChange{
            {Key: "hmac_key", Value: &lockbox.PayloadEntryChange_TextValue{TextValue: newKeyHex}},
            {Key: "previous_key", Value: &lockbox.PayloadEntryChange_TextValue{TextValue: getCurrentKey(ctx, sdk, secretID)}},
            {Key: "previous_key_expires_at", Value: &lockbox.PayloadEntryChange_TextValue{
                TextValue: time.Now().Add(time.Duration(graceHours) * time.Hour).Format(time.RFC3339),
            }},
        },
    })
    if err != nil {
        return fmt.Errorf("create new version: %w", err)
    }

    slog.Info("HMAC key rotated",
        "secret_id", secretID,
        "grace_period_hours", graceHours,
    )
    return nil
}

func getCurrentKey(ctx context.Context, sdk *yc.SDK, secretID string) string {
    payload, err := sdk.LockboxSecret().Payload().Get(ctx, &lockbox.GetPayloadRequest{
        SecretId: secretID,
    })
    if err != nil {
        return ""
    }
    for _, entry := range payload.Entries {
        if entry.Key == "hmac_key" {
            return entry.GetTextValue()
        }
    }
    return ""
}

Обновлённый HMACVerifier с поддержкой grace period:

go
type HMACVerifier struct {
    sdk            *yc.SDK
    secretID       string
    cachedCurrent  []byte
    cachedPrevious []byte
    cachedExpiry   time.Time
    lastRefresh    time.Time
}

func (v *HMACVerifier) Verify(payload []byte, signature string) bool {
    v.refreshKeysIfNeeded()

    // Проверяем текущим ключом
    if verifyHMAC(payload, signature, v.cachedCurrent) {
        return true
    }

    // Grace period: проверяем предыдущим ключом
    if time.Now().Before(v.cachedExpiry) && len(v.cachedPrevious) > 0 {
        return verifyHMAC(payload, signature, v.cachedPrevious)
    }

    return false
}

func (v *HMACVerifier) refreshKeysIfNeeded() {
    // Обновляем ключи раз в час
    if time.Since(v.lastRefresh) < time.Hour {
        return
    }

    payload, err := v.sdk.LockboxSecret().Payload().Get(context.Background(), &lockbox.GetPayloadRequest{
        SecretId: v.secretID,
    })
    if err != nil {
        slog.Error("failed to refresh HMAC keys", "err", err)
        return
    }

    for _, entry := range payload.Entries {
        switch entry.Key {
        case "hmac_key":
            v.cachedCurrent = []byte(entry.GetTextValue())
        case "previous_key":
            v.cachedPrevious = []byte(entry.GetTextValue())
        case "previous_key_expires_at":
            v.cachedExpiry, _ = time.Parse(time.RFC3339, entry.GetTextValue())
        }
    }
    v.lastRefresh = time.Now()
}

ADR-SV8-ADD: «Автоматическая ротация HMAC: Cloud Function + Timer Trigger vs ручное обновление. Grace period 24 часа для zero-downtime ротации».

8.2. CI/CD: GitHub Actions

yaml
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Run tests
        run: go test ./... -race -coverprofile=coverage.out

      - name: govulncheck
        run: |
          go install golang.org/x/vuln/cmd/govulncheck@latest
          govulncheck ./...

      - name: Trivy scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: 'git-hosting:latest'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build functions
        run: make build

      - name: Upload artifacts
        run: make upload
        env:
          YC_TOKEN: ${{ secrets.YC_TOKEN }}
          FUNCTIONS_BUCKET: ${{ secrets.FUNCTIONS_BUCKET }}

      - name: Terraform apply
        run: |
          cd terraform
          terraform init \
            -backend-config="access_key=${{ secrets.TF_ACCESS_KEY }}" \
            -backend-config="secret_key=${{ secrets.TF_SECRET_KEY }}"
          terraform plan -out=tfplan
          terraform apply tfplan
        env:
          YC_TOKEN: ${{ secrets.YC_TOKEN }}

      - name: Drift check
        run: |
          cd terraform
          terraform plan -detailed-exitcode

Секреты CI — в GitHub Secrets:

СекретГде хранитсяКто имеет доступ
YC_TOKENGitHub SecretsТолько CI/CD pipeline
TF_ACCESS_KEY / TF_SECRET_KEYGitHub SecretsТолько Terraform step
FUNCTIONS_BUCKETGitHub SecretsТолько upload step
WEBHOOK_HMAC_KEYYandex LockboxТолько Cloud Functions через SA

8.3. Disaster Recovery: бэкапы NFS

hcl
# Terraform: снапшоты NFS раз в день

resource "yandex_storage_filesystem_snapshot" "git-repos-daily" {
  name            = "git-repos-snapshot-${formatdate("YYYY-MM-DD", timestamp())}"
  file_system_id  = yandex_storage_filesystem.git-repos.id

  schedule {
    cron_expression = "0 3 * * *"
  }

  retention_days = 7
}

# Бэкап в Object Storage
resource "yandex_storage_bucket" "git-backups" {
  bucket = "git-hosting-backups"
  acl    = "private"

  versioning {
    enabled = true
  }

  lifecycle_rule {
    enabled = true
    expiration {
      days = 30
    }
  }
}

План Disaster Recovery:

СценарийRTORPOДействие
NFS упал (< 1 часа)15 мин0 данныхYandex Cloud восстанавливает автоматически
NFS упал (> 1 часа)1 часДо 24 часов данныхВосстановление из снапшота
NFS уничтожен4 часаДо 24 часов данныхВосстановление из Object Storage бэкапа
YDB упал30 мин0 данныхYDB Serverless восстанавливается автоматически
Message Queue упал15 минДо 30 сек событийСобытия остаются в outbox_events

Бэкапы YDB: Yandex Cloud автоматически создаёт бэкапы YDB Serverless каждые 6 часов. Бэкапы хранятся 7 дней. RPO: 6 часов. RTO: 30 минут.

Тест DR-плана с проверкой целостности Git

yaml
# .github/workflows/dr-test.yml
name: DR Test (Weekly)

on:
  schedule:
    - cron: '0 3 * * 0'  # Каждое воскресенье в 03:00 UTC

jobs:
  dr-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Create NFS snapshot
        id: snapshot
        run: |
          SNAPSHOT_NAME="dr-test-$(date +%Y%m%d-%H%M%S)"
          yc compute snapshot create \
            --name="$SNAPSHOT_NAME" \
            --disk-id=$(yc compute disk get --name=git-repos-disk --format=json | jq -r .id)
          echo "snapshot_name=$SNAPSHOT_NAME" >> $GITHUB_OUTPUT
          echo "timestamp=$(date +%s)" >> $GITHUB_OUTPUT

      - name: Restore from snapshot to temp disk
        run: |
          yc compute disk create \
            --name="dr-test-disk" \
            --source-snapshot-name="${{ steps.snapshot.outputs.snapshot_name }}" \
            --zone=ru-central1-a \
            --size=50

      - name: Mount restored NFS
        run: |
          sudo mkdir -p /mnt/nfs-restored
          sudo mount -t nfs <nfs-mount-path> /mnt/nfs-restored

      - name: Validate Git repos integrity
        run: |
          FAILED=0
          TOTAL=0

          find /mnt/nfs-restored -maxdepth 1 -type d -name "*.git" | while read repo; do
            TOTAL=$((TOTAL + 1))
            echo "Validating $repo"

            if GIT_DIR="$repo" git fsck --quick 2>&1; then
              echo "✓ $repo: valid"
            else
              FAILED=$((FAILED + 1))
              echo "✗ $repo: FAILED git fsck"
            fi
          done

          echo "total=$TOTAL" >> $GITHUB_ENV
          echo "failed=$FAILED" >> $GITHUB_ENV

          if [ "$FAILED" -gt 0 ]; then
            echo "❌ DR test failed: $FAILED repos failed git fsck"
            exit 1
          fi
          echo "✓ All $TOTAL repos validated successfully"

      - name: Test git ls-remote on restored repos
        run: |
          FIRST_REPO=$(find /mnt/nfs-restored -maxdepth 1 -type d -name "*.git" | head -1)
          if [ -n "$FIRST_REPO" ]; then
            git ls-remote "$FIRST_REPO" || {
              echo "❌ git ls-remote failed on restored repo"
              exit 1
            }
          fi

      - name: Record RTO metric
        run: |
          RTO=$(($(date +%s) - ${{ steps.snapshot.outputs.timestamp }}))
          echo "RTO: $RTO seconds"
          echo "Valid repos: $valid / $total"

      - name: Cleanup
        if: always()
        run: |
          sudo umount /mnt/nfs-restored || true
          yc compute disk delete --name="dr-test-disk" || true
          yc compute snapshot delete --name="${{ steps.snapshot.outputs.snapshot_name }}" || true

Обновлённая таблица DR-плана:

СценарийRTORPO% валидных репоПроверка
NFS упал (< 1 часа)15 мин0 данных100%Yandex Cloud auto-restore
NFS упал (> 1 часа)1 часДо 24 часов100%Снапшот + git fsck
NFS уничтожен4 часаДо 24 часов100%Object Storage + git fsck

Метрики DR-теста:

  • dr_test_success: boolean (прошёл/не прошёл)
  • dr_test_rto_seconds: время восстановления
  • dr_test_valid_repos_percent: % валидных репо после git fsck
  • dr_test_git_ls_remote: boolean (работает ли git ls-remote)

8.4. Cost-отчёт: Serverless vs VPS vs K8s

ПараметрServerless (MVP)VPSK8s
Стоимость/мес (12 юзеров)~50₽ (только NFS)~500₽ (VPS 2 vCPU)~3000₽ (3 ноды)
Стоимость/мес (50 юзеров)~50₽ (free-tier)~500₽~3000₽
Стоимость/мес (100 юзеров)~200₽ (NFS 100 GB)~1000₽ (VPS 4 vCPU)~3000₽
Cold Start10–100 мс (Go)НетНет
АдминистрированиеМинимумСреднееВысокое
МасштабированиеАвтоматическоеРучноеHPA
Точка миграции> 1k RPS или push > 32 MB> 10k RPSProduction

Вывод: для MVP на 12 пользователей Serverless экономит 10–60× по сравнению с VPS/K8s. При росте > 1k RPS — миграция на VPS. Паттерны те же — инфраструктура меняется.

8.5. Планы миграции при росте

Миграция YDB → PostgreSQL (> 100 юзеров или > 10 RU/с)

Триггер: YDB RU/с > 8 постоянно (80% free-tier).

План (Dual-write → Backfill → Read → Cutover):

100%
🖱 Перетащите • Колесо для зума

Шаги:

  1. Dual-write (неделя 1-2): Добавить PostgreSQL в Terraform. Изменить Repository layer: запись в YDB и PostgreSQL одновременно. Мониторинг: расхождение данных < 0.01%.

  2. Backfill (неделя 2-3): Скрипт копирования исторических данных из YDB в PostgreSQL. Проверка целостности.

  3. Read from PG (неделя 3-4): Переключить чтение на PostgreSQL. YDB только для записи (для отката). Мониторинг: latency, error rate.

  4. Cutover (неделя 4): Полное переключение на PostgreSQL. YDB отключён (сохранить снапшот на 30 дней).

RTO: 4 недели. RPO: 0 (dual-write).

Миграция Cloud Functions → VPS (> 1k RPS)

Триггер: Cloud Functions invocations > 800k/мес или push > 32 MB.

План (Containerize → Deploy → Dual-run → Cutover):

100%
🖱 Перетащите • Колесо для зума

Шаги:

  1. Containerize (неделя 1): Написать Dockerfile для каждой Cloud Function. Заменить Cloud Function SDK на стандартный net/http. Тесты: локально через docker-compose.

  2. Deploy (неделя 2): Поднять VPS (4 vCPU, 8 GB RAM). Настроить nginx как reverse proxy. Настроить TLS, мониторинг (Prometheus + Grafana).

  3. Dual-run (неделя 2-3): Направить 10% трафика на VPS (через API Gateway canary). Мониторинг: latency, error rate, стоимость. Gradual rollout: 10% → 30% → 50% → 100%.

  4. Cutover (неделя 3-4): 100% трафика на VPS. Cloud Functions отключены (сохранить код в Git).

RTO: 4 недели. RPO: 0 (dual-run).

8.5.1. Эволюция: S3-хранилище как альтернатива NFS

NFS — единая точка отказа. При росте > 100 юзеров или появлении требования Multi-AZ — миграция на S3-compatible хранилище (Yandex Object Storage).

Абстракция StorageLayer:

go
// internal/storage/interface.go
package storage

import "context"

// StorageLayer абстрагирует хранение pack-файлов Git
type StorageLayer interface {
    GetPackFile(ctx context.Context, repoID, packName string) ([]byte, error)
    PutPackFile(ctx context.Context, repoID, packName string, data []byte) error
    ListPackFiles(ctx context.Context, repoID string) ([]string, error)
    DeletePackFile(ctx context.Context, repoID, packName string) error
    RepoExists(ctx context.Context, repoID string) (bool, error)
}

NFS-реализация (текущая):

go
// internal/storage/nfs.go
type NFSLayer struct {
    basePath string // /var/git
}

func (n *NFSLayer) GetPackFile(ctx context.Context, repoID, packName string) ([]byte, error) {
    path := filepath.Join(n.basePath, repoID+".git", "objects", "pack", packName)
    return os.ReadFile(path)
}

func (n *NFSLayer) PutPackFile(ctx context.Context, repoID, packName string, data []byte) error {
    dir := filepath.Join(n.basePath, repoID+".git", "objects", "pack")
    if err := os.MkdirAll(dir, 0755); err != nil {
        return err
    }
    path := filepath.Join(dir, packName)
    return os.WriteFile(path, data, 0644)
}

S3-реализация (эволюция):

go
// internal/storage/s3.go
type S3Layer struct {
    client *s3.Client
    bucket string
    prefix string // "git-repos/"
}

func (s *S3Layer) GetPackFile(ctx context.Context, repoID, packName string) ([]byte, error) {
    key := fmt.Sprintf("%s%s/objects/pack/%s", s.prefix, repoID, packName)
    result, err := s.client.GetObject(ctx, &s3.GetObjectInput{
        Bucket: &s.bucket,
        Key:    &key,
    })
    if err != nil {
        return nil, err
    }
    defer result.Body.Close()
    return io.ReadAll(result.Body)
}

func (s *S3Layer) PutPackFile(ctx context.Context, repoID, packName string, data []byte) error {
    key := fmt.Sprintf("%s%s/objects/pack/%s", s.prefix, repoID, packName)
    _, err := s.client.PutObject(ctx, &s3.PutObjectInput{
        Bucket: &s.bucket,
        Key:    &key,
        Body:   bytes.NewReader(data),
    })
    return err
}

Миграция с NFS на S3 (zero-downtime):

100%
🖱 Перетащите • Колесо для зума

Сравнение NFS vs S3:

КритерийNFS (текущий)S3 (эволюция)
Стоимость~50₽/мес за 50 GB~10₽/мес за 50 GB
Multi-AZНет (NFS в одной зоне)Да (S3 реплицируется)
RTO при сбое1 час (снапшот)< 1 мин (managed)
RPOДо 24 часов0 (синхронная репликация)
Latency чтения~5 мс~50 мс
Latency записи~10 мс~100 мс
git-http-backendРаботает нативноНужен FUSE или кастомный backend
Сложность миграции—4 недели

Когда мигрировать:

  • 100 юзеров

  • Требуется Multi-AZ
  • RPO = 0 критичен
  • Стоимость NFS > 200₽/мес

ADR-SV8-ADD: «NFS vs S3 для pack-файлов: стоимость, RTO/RPO, сложность миграции. Миграция при > 100 юзеров или требовании Multi-AZ».

8.6. Сводная таблица Trade-offs курса

МодульРешение (Serverless MVP)ПлюсМинус
sv1Serverless (не VPS, не K8s)~50₽/мес; автомасштабCold Start; 32 MB push; 10 мин таймаут
sv2io.Copy + parseCGIHeaders (не io.ReadAll + cmd.Stdout = w)O(1) память; корректные HTTP-заголовкиСложнее отлаживать
sv3YDB Serverless (не PostgreSQL)0₽/мес при 10 RU/сНет FOR UPDATE; YQL ≠ SQL
sv3Retry + conflict_rate (не FOR UPDATE)Оптимистичные блокировки без внешних сервисовLatency при конфликтах
sv4Outbox + Timer Trigger с concurrency_limit=1 (не goroutine)Нет гонок; нет долгоживущих процессовЗадержка до 5 сек
sv5concurrency_limit + идемпотентность ДО слота (не chan struct{})Платформа ограничивает; 409/200 не тратят слотНет гибкости
sv6Mark merge-failed + retry (не ROLLBACK)Git не трогаем; точечный retryРучной шаг для администратора
sv7Cloud Monitoring SDK (не Prometheus)Управляемый; cost-алертыПривязка к YC; нет PromQL
sv7Кардинальность как архитектурный запретНет взрыва рядов; стабильный мониторингОграничение детализации
sv8Lockbox + govulncheck + миграция (не .env + Gitea)Least privilege; CVE-scan; путь ростаСложнее Terraform
sv8Авто-ротация HMAC + S3-эволюция + e2eZero-downtime ротация; путь масштабированияСложнее Terraform

8.7. Компромиссы (Trade-offs)

РешениеПлюсМинусКогда выбирать
Lockbox (не .env)Least privilege; ротация; аудитСложнее TerraformВсегда для production
Авто-ротация HMACZero-downtime; нет ручной операцииДоп. Cloud FunctionВсегда для вебхуков
govulncheck в CICVE ловятся до продакшенаДоп. шаг в CIВсегда
Снапшоты NFS раз в деньDR без ручных бэкаповСтоимость снапшотовВсегда для NFS
DR-тест с git fsckРеальная проверка восстановленияДолгий прогонРаз в неделю
Terraform + Drift DetectionIaC; воспроизводимостьLearning curveВсегда
S3 вместо NFSMulti-AZ; RPO=0; дешевлеLatency выше; нужен FUSE> 100 юзеров или Multi-AZ

Вывод: для production Git-хостинга — Lockbox + авто-ротация HMAC + govulncheck + снапшоты NFS + DR-тест с git fsck + Terraform. См. ADR-SV8.

8.8. Стандартизированный шаблон ADR

Все 8 ADR курса приводятся к единому шаблону:

markdown
# ADR-{N}: {Название решения}

## Статус
Принят | {дата}

## Проблема
{1–2 предложения: какую проблему решаем}

## Контекст
- {Текущее состояние системы}
- {Ограничения (free-tier, latency, стоимость)}
- {Бизнес-требования}

## Варианты решения

### Вариант 1: {Название}
- **Плюсы:** ...
- **Минусы:** ...
- **Стоимость:** X ₽/мес
- **Latency:** Y мс (p99)
- **RTO/RPO:** Z мин / W данных

### Вариант 2: {Название}
- **Плюсы:** ...
- **Минусы:** ...
- **Стоимость:** X ₽/мес
- **Latency:** Y мс (p99)
- **RTO/RPO:** Z мин / W данных

### Вариант 3 (если есть): {Название}
...

## Критерии выбора
| Критерий | Вес | Вариант 1 | Вариант 2 | Вариант 3 |
|----------|-----|-----------|-----------|-----------|
| Стоимость | 30% | 8/10 | 5/10 | 3/10 |
| Latency (p99) | 25% | 6/10 | 9/10 | 7/10 |
| RTO | 20% | 7/10 | 8/10 | 9/10 |
| Сложность реализации | 15% | 9/10 | 6/10 | 4/10 |
| Поддерживаемость | 10% | 8/10 | 7/10 | 5/10 |
| **Итого** | **100%** | **7.6** | **7.1** | **5.7** |

## Решение
Выбран **Вариант {N}: {Название}**, потому что:
1. {Аргумент 1 с цифрами}
2. {Аргумент 2 с цифрами}
3. {Аргумент 3 с цифрами}

## Последствия (Trade-offs)
- **Плюсы:** {что получили}
- **Минусы:** {чем пожертвовали}
- **Риски:** {что может пойти не так}

## План миграции / отката

### Триггер миграции
{При каком условии мигрируем: > X RPS, > Y ₽/мес, > Z% error rate}

### План миграции
1. {Шаг 1}: {описание} — {длительность}
2. {Шаг 2}: {описание} — {длительность}
3. {Шаг 3}: {описание} — {длительность}

**RTO миграции:** X недель
**RPO миграции:** 0 (dual-write) / Y данных

### План отката
{Как вернуться к предыдущему решению, если миграция провалилась}

## Связь с другими модулями
- **Модуль {N}:** {как это решение влияет}
- **Модуль {M}:** {как это решение влияет}

8.9. Шаблон защиты перед архитектурным комитетом

Документ «Defense Q&A»: готовые ответы на жёсткие вопросы

Вопрос комитетаГотовый ответ (с цифрами)
«Почему не Gitea?»Gitea на VPS = 500₽/мес + администрирование. Наш MVP = 50₽/мес (только NFS) + автомасштаб. Для 12 юзеров экономия 10×. Gitea выбираем, если нужен time-to-value < 1 дня.
«Что при росте 10× (120 юзеров)?»Cloud Functions invocations вырастут до 840k/мес (84% free-tier). YDB RU/с = 8 (80% free-tier). Мигрируем на VPS: Containerize → Deploy → Dual-run → Cutover (4 недели, RPO=0).
«Что если NFS упадёт на 2 часа?»DR-план: восстановление из снапшота за 1 час. RPO = до 24 часов данных (между снапшотами). git fsck проверяет целостность. Тест DR раз в неделю в CI.
«Почему Serverless, а не K8s?»K8s = 3000₽/мес (3 ноды). Для 12 юзеров загрузка 3–5%. Serverless = 50₽/мес. При > 1k RPS мигрируем на K8s. Паттерны те же — инфраструктура меняется.
«Почему YDB, а не PostgreSQL?»PostgreSQL = 500₽/мес минимум. YDB Serverless = 0₽ при 10 RU/с. Для MVP экономия 500₽/мес. При > 100 юзеров или > 10 RU/с — миграция на PostgreSQL (4 недели, dual-write).
«Где точка отказа?»NFS — единая точка отказа. DR-план: снапшоты раз в день, RTO 1 час. Эволюция: миграция на S3 при > 100 юзеров (Multi-AZ, RPO=0).
«Что если Outbox Relay упадёт?»concurrency_limit=1 + Timer Trigger каждые 5 сек. Если функция упадёт — следующий запуск через 5 сек подхватит pending-события. DLQ ловит события после 5 неудач.
«Как доказать, что Saga работает?»Cloud Monitoring: метрика saga_transitions_total, алерт при saga_merge_failed_total > 5 за 10 мин. Логи через yc logging read с pr_id. e2e-тест push → merge в CI nightly.
«Почему идемпотентность ДО concurrency-слота?»Если 409/200 no-op входят в слот — при 100 конкурентных push все 5 слотов займутся 409, реальные push не пройдут. Проверка SHA до слота = 409/200 не тратят ресурс.
«Что если превысим free-tier?»Cost-алерты: YDB RU/с > 8, invocations > 800k, outbox_pending > 50. Telegram-уведомления за 20% до лимита. План миграции на VPS готов.
«Почему parseCGIHeaders, а не cmd.Stdout = w?»cmd.Stdout = w пишет CGI-заголовки в тело HTTP. Клиент получает мусор. parseCGIHeaders отделяет заголовки от тела по пустой строке, устанавливает w.Header().
«Как защищено от Directory Traversal?»assertSafeRepo: filepath.IsAbs + Clean + Rel. Отклоняет абсолютные пути и "..". Тесты покрывают 6 кейсов.
«Где секреты?»Lockbox, не .env. SA функций имеет lockbox.payloadViewer только на нужные секреты (IAM condition). Авто-ротация HMAC раз в 30 дней с grace period 24 часа.
«Есть ли CVE в зависимостях?»govulncheck в CI падает при CRITICAL CVE. Trivy scan падает при CRITICAL/HIGH CVE в Docker-образе.
«Как мигрировать YDB → PostgreSQL?»Dual-write → Backfill → Read from PG → Cutover. 4 недели. RPO = 0 (dual-write). Триггер: > 10 RU/с постоянно или > 100 юзеров.

Правила защиты:

  1. Всегда с цифрами. Не «быстро», а «p99 = 180 мс». Не «дёшево», а «50₽/мес vs 500₽/мес».
  2. Всегда с планом миграции. Не «это лучшее решение», а «это лучшее для MVP, при > X мигрируем на Y».
  3. Всегда с trade-offs. Не «только плюсы», а «плюсы X, минусы Y, риски Z».
  4. Всегда с тестами. Не «мы думаем, что работает», а «e2e-тест в CI nightly, DR-тест раз в неделю».

🛠 Практические задания

Задача 1: Security — секреты в Lockbox + авто-ротация HMAC ✅ Готово, когда: [ ] Нет .env в репозитории и в Docker-образе [ ] Terraform: yandex_lockbox_secret для HMAC-ключа и YDB-токена [ ] IAM-политика: SA функций имеет lockbox.payloadViewer только на нужные секреты [ ] Cloud Function hmac-rotator с Timer Trigger (раз в 30 дней) [ ] HMACVerifier поддерживает grace period 24 часа [ ] govulncheck в CI: падает при CRITICAL CVE

Задача 2: CI/CD — GitHub Actions ✅ Готово, когда: [ ] go test ./... -race проходит [ ] govulncheck ./... падает при CRITICAL CVE [ ] Trivy scan падает при CRITICAL/HIGH CVE [ ] terraform plan в CI для Drift Detection [ ] Секреты CI в GitHub Secrets

Задача 3: Disaster Recovery — бэкапы NFS + тест с git fsck ✅ Готово, когда: [ ] Terraform: снапшоты NFS раз в день, retention 7 дней [ ] Object Storage бэкап с versioning [ ] DR-план: таблица сценариев × RTO × RPO × действие [ ] DR-тест в CI: git fsck --quick для всех репозиториев [ ] DR-тест в CI: git ls-remote на восстановленном репо [ ] Метрики DR-теста: dr_test_success, dr_test_rto_seconds, dr_test_valid_repos_percent

Задача 4: Эволюция — S3-хранилище ✅ Готово, когда: [ ] Интерфейс StorageLayer с двумя реализациями (NFS, S3) [ ] ADR: «NFS vs S3: стоимость, RTO/RPO, сложность миграции» [ ] План миграции NFS → S3 (dual-write → cutover)

Задача 5: Cost-отчёт + 8 ADR + защита ✅ Готово, когда: [ ] Cost-отчёт: таблица для 12/50/100 пользователей, точка миграции [ ] 8 ADR по единому шаблону с количественными trade-offs [ ] Каждый ADR содержит план миграции / отката [ ] Планы миграции: YDB → PostgreSQL, Cloud Functions → VPS, NFS → S3 [ ] Документ «Defense Q&A» с 15 вопросами и ответами [ ] Защита: ответы на вопросы «почему не Gitea?», «почему не VPS?», «что при росте 10×?»

⚠️ Антипаттерн для разбора

Защита: "Мы написали Git-хостинг на Go. Он работает на Serverless.
Секреты лежат в .env. Бэкапов нет, но Yandex Cloud надёжный.
Стоимость — ну, где-то 50 рублей. Тесты проходят."

Вопросы студенту:

  1. Почему «секреты в .env» недопустимы в production?
  2. Что произойдёт, если NFS упадёт на 2 часа без бэкапов?
  3. Почему «где-то 50 рублей» — не аргумент для CFO?
  4. Почему ручная ротация HMAC — операционный риск?

🧩 Проверь себя

1. Что должно быть в документе «Defense Q&A»?
2. Зачем git fsck в DR-тесте?
3. NFS упал на 2 часа. Что делает DR-план?
4. Зачем абстракция StorageLayer?
5. Cost-отчёт должен содержать...
6. Зачем авто-ротация HMAC-ключей?
7. Почему «потому что Serverless модный» — не аргумент?
8. Где должны храниться секреты в production?
9. Как мигрировать YDB → PostgreSQL без простоя?
10. Зачем единый шаблон ADR?
11. Drift Detection — это...
12. Как мигрировать Cloud Functions → VPS без простоя?
13. Когда мигрировать NFS → S3?
14. Зачем govulncheck в CI?

🤖 Инструкция для LLM — Режим Модуля 8

Привет! Я завершаю курс «Git Hosting на Go: Serverless MVP на Yandex Cloud» и готовлюсь к защите. Выступи в роли Наставника-Архитектора.

Дай короткий ответ (2–3 предложения): почему секреты в .env недопустимы в production, и как Lockbox с IAM-политикой condition решает эту проблему.

Выдай задание:

  1. Убрать все секреты из .env, создать в Lockbox через Terraform, настроить авто-ротацию HMAC.
  2. Написать GitHub Actions: тесты, govulncheck, Trivy, build, terraform apply, drift detection.
  3. Настроить DR: снапшоты NFS раз в день, Object Storage бэкап, план восстановления, тест DR с git fsck.
  4. Реализовать абстракцию StorageLayer (NFS + S3) и план миграции.
  5. Подготовить Cost-отчёт, планы миграции (YDB → PostgreSQL, Cloud Functions → VPS, NFS → S3) и защитить 8 ADR по единому шаблону.

Покажи Антипаттерн защиты и попроси объяснить.

Скажи: «Я готов выступить в роли Архитектурного комитета. Пришли мне свои 8 ADR, Cost-отчёт, DR-план и планы миграции, и я задам тебе 3 жёстких вопроса: "Почему не Gitea?", "Что при росте 10×?", "Что будет, если NFS упадёт на 2 часа?"»

НЕ ПИШИ за меня Terraform и CI/CD. Дождись моих материалов. После этого задай неудобные вопросы.

📝 Финальный чек-лист курса (Критерии сдачи)

  • Архитектура: 4 Cloud Functions на Yandex Cloud.
  • Git Smart HTTP: io.Copy стриминг, parseCGIHeaders, exec.Command с context, assertSafeRepo.
  • YDB: метаданные, атомарный UPDATE с условием, EXPLAIN в CI, 10 RU/с free-tier.
  • YDB: retry с exponential backoff при конфликтах, метрика conflict_rate + алерт.
  • Outbox: атомарная запись в YDB, Timer Trigger, concurrency_limit=1, DLQ с Redrive Policy в Terraform.
  • Bulkhead: concurrency_limit=5 на git-gateway, идемпотентность ДО слота, Idempotency-Key для REST.
  • Saga: стейт-машина PR merge, компенсация = mark failed (не ROLLBACK), различение конфликт vs таймаут, логирование переходов.
  • e2e тест: полный цикл git push → merge в CI nightly.
  • Observability: OTel + Jaeger, Cloud Monitoring SDK, cost-алерты, yc logging read, PII masking.
  • Observability: кардинальность проверена (нет ID в labels), PII-middleware во всех функциях.
  • Security: секреты в Lockbox, govulncheck в CI, ротация HMAC, Trivy scan.
  • Security: автоматическая ротация HMAC через Cloud Function + Timer Trigger.
  • DR: снапшоты NFS раз в день, Object Storage бэкап, RTO/RPO план, тест DR.
  • DR: тест с git fsck + git ls-remote после восстановления.
  • IaC: Terraform для всего стека, Drift Detection в CI, ClickOps запрещён.
  • Документация: 8 ADR по единому шаблону с количественными trade-offs и планом миграции.
  • Cost-отчёт: Serverless vs VPS vs K8s для 12/50/100 юзеров, точка миграции.
  • Планы миграции: YDB → PostgreSQL, Cloud Functions → VPS.
  • Эволюция: S3-хранилище как альтернатива NFS описана в ADR-SV8.
  • Защита: единый шаблон ADR + документ Q&A для комитета.

🎓 Мостик в реальный мир

«Поздравляю! Ты прошёл путь от первого git clone через Cloud Function до production-ready Git-хостинга за ~50₽/мес. Ты умеешь не только реализовать Git Smart HTTP в Serverless, но и доказать, что это правильное решение — цифрами, ADR, и Cost-отчётом. Твои 8 ADR, Terraform-манифест, DR-план с git fsck, планы миграции и Cost-отчёт — это именно те артефакты, которые показывают уровень инженера на собеседованиях.»

«Куда идти дальше:

  • Архитектура распределённых систем — если хочешь понять, как те же паттерны (Outbox, Saga, Bulkhead) работают на полном стеке: PostgreSQL, Kafka, K8s. Паттерны те же — инфраструктура другая.
  • Git Hosting: от 10k до 100k RPS — если хочешь масштабировать Git-хостинг: Storage Nodes, Citus, CDN, шардирование. Те же паттерны, другие цифры.

Добро пожаловать в реальный мир. Здесь нет идеальных решений — есть trade-offs, которые нужно уметь защищать. И ты теперь умеешь.»