Модуль 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-ключа — ручная операция. Никто не помнит, когда последний раз меняли ключ.
Четыре задачи:
- Security — секреты в Lockbox, govulncheck в CI, авто-ротация HMAC-ключей.
- Disaster Recovery — снапшоты NFS раз в день, план восстановления, тест DR с git fsck.
- Эволюция — S3-хранилище как альтернатива NFS для Multi-AZ.
- Защита — 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:
gotype 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_TOKEN | GitHub Secrets | Только CI/CD pipeline |
TF_ACCESS_KEY / TF_SECRET_KEY | GitHub Secrets | Только Terraform step |
FUNCTIONS_BUCKET | GitHub Secrets | Только upload step |
WEBHOOK_HMAC_KEY | Yandex 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:
| Сценарий | RTO | RPO | Действие |
|---|---|---|---|
| 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-плана:
| Сценарий | RTO | RPO | % валидных репо | Проверка |
|---|---|---|---|---|
| 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 fsckdr_test_git_ls_remote: boolean (работает ли git ls-remote)
8.4. Cost-отчёт: Serverless vs VPS vs K8s
| Параметр | Serverless (MVP) | VPS | K8s |
|---|---|---|---|
| Стоимость/мес (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 Start | 10–100 мс (Go) | Нет | Нет |
| Администрирование | Минимум | Среднее | Высокое |
| Масштабирование | Автоматическое | Ручное | HPA |
| Точка миграции | > 1k RPS или push > 32 MB | > 10k RPS | Production |
Вывод: для 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):
Шаги:
-
Dual-write (неделя 1-2): Добавить PostgreSQL в Terraform. Изменить Repository layer: запись в YDB и PostgreSQL одновременно. Мониторинг: расхождение данных < 0.01%.
-
Backfill (неделя 2-3): Скрипт копирования исторических данных из YDB в PostgreSQL. Проверка целостности.
-
Read from PG (неделя 3-4): Переключить чтение на PostgreSQL. YDB только для записи (для отката). Мониторинг: latency, error rate.
-
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):
Шаги:
-
Containerize (неделя 1): Написать Dockerfile для каждой Cloud Function. Заменить Cloud Function SDK на стандартный
net/http. Тесты: локально через docker-compose. -
Deploy (неделя 2): Поднять VPS (4 vCPU, 8 GB RAM). Настроить nginx как reverse proxy. Настроить TLS, мониторинг (Prometheus + Grafana).
-
Dual-run (неделя 2-3): Направить 10% трафика на VPS (через API Gateway canary). Мониторинг: latency, error rate, стоимость. Gradual rollout: 10% → 30% → 50% → 100%.
-
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):
Сравнение 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) | Плюс | Минус |
|---|---|---|---|
| sv1 | Serverless (не VPS, не K8s) | ~50₽/мес; автомасштаб | Cold Start; 32 MB push; 10 мин таймаут |
| sv2 | io.Copy + parseCGIHeaders (не io.ReadAll + cmd.Stdout = w) | O(1) память; корректные HTTP-заголовки | Сложнее отлаживать |
| sv3 | YDB Serverless (не PostgreSQL) | 0₽/мес при 10 RU/с | Нет FOR UPDATE; YQL ≠ SQL |
| sv3 | Retry + conflict_rate (не FOR UPDATE) | Оптимистичные блокировки без внешних сервисов | Latency при конфликтах |
| sv4 | Outbox + Timer Trigger с concurrency_limit=1 (не goroutine) | Нет гонок; нет долгоживущих процессов | Задержка до 5 сек |
| sv5 | concurrency_limit + идемпотентность ДО слота (не chan struct{}) | Платформа ограничивает; 409/200 не тратят слот | Нет гибкости |
| sv6 | Mark merge-failed + retry (не ROLLBACK) | Git не трогаем; точечный retry | Ручной шаг для администратора |
| sv7 | Cloud Monitoring SDK (не Prometheus) | Управляемый; cost-алерты | Привязка к YC; нет PromQL |
| sv7 | Кардинальность как архитектурный запрет | Нет взрыва рядов; стабильный мониторинг | Ограничение детализации |
| sv8 | Lockbox + govulncheck + миграция (не .env + Gitea) | Least privilege; CVE-scan; путь роста | Сложнее Terraform |
| sv8 | Авто-ротация HMAC + S3-эволюция + e2e | Zero-downtime ротация; путь масштабирования | Сложнее Terraform |
8.7. Компромиссы (Trade-offs)
| Решение | Плюс | Минус | Когда выбирать |
|---|---|---|---|
| Lockbox (не .env) | Least privilege; ротация; аудит | Сложнее Terraform | Всегда для production |
| Авто-ротация HMAC | Zero-downtime; нет ручной операции | Доп. Cloud Function | Всегда для вебхуков |
| govulncheck в CI | CVE ловятся до продакшена | Доп. шаг в CI | Всегда |
| Снапшоты NFS раз в день | DR без ручных бэкапов | Стоимость снапшотов | Всегда для NFS |
| DR-тест с git fsck | Реальная проверка восстановления | Долгий прогон | Раз в неделю |
| Terraform + Drift Detection | IaC; воспроизводимость | Learning curve | Всегда |
| S3 вместо NFS | Multi-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 юзеров. |
Правила защиты:
- Всегда с цифрами. Не «быстро», а «p99 = 180 мс». Не «дёшево», а «50₽/мес vs 500₽/мес».
- Всегда с планом миграции. Не «это лучшее решение», а «это лучшее для MVP, при > X мигрируем на Y».
- Всегда с trade-offs. Не «только плюсы», а «плюсы X, минусы Y, риски Z».
- Всегда с тестами. Не «мы думаем, что работает», а «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 рублей. Тесты проходят."
Вопросы студенту:
- Почему «секреты в .env» недопустимы в production?
- Что произойдёт, если NFS упадёт на 2 часа без бэкапов?
- Почему «где-то 50 рублей» — не аргумент для CFO?
- Почему ручная ротация HMAC — операционный риск?
🧩 Проверь себя
🤖 Инструкция для LLM — Режим Модуля 8
Привет! Я завершаю курс «Git Hosting на Go: Serverless MVP на Yandex Cloud» и готовлюсь к защите. Выступи в роли Наставника-Архитектора.
Дай короткий ответ (2–3 предложения): почему секреты в
.envнедопустимы в production, и как Lockbox с IAM-политикойconditionрешает эту проблему.Выдай задание:
- Убрать все секреты из
.env, создать в Lockbox через Terraform, настроить авто-ротацию HMAC.- Написать GitHub Actions: тесты, govulncheck, Trivy, build, terraform apply, drift detection.
- Настроить DR: снапшоты NFS раз в день, Object Storage бэкап, план восстановления, тест DR с git fsck.
- Реализовать абстракцию StorageLayer (NFS + S3) и план миграции.
- Подготовить 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, которые нужно уметь защищать. И ты теперь умеешь.»