16. Disaster recovery

Введение: Когда отказ — это вопрос «когда», а не «если»

Представьте атомную подводную лодку в автономном плавании на километровой глубине. Ни один капитан и ни один старший механик не надеются на «авось» и не ждут момента, когда в третьем отсеке забьет фонтан забортной воды или откажет главный циркуляционный насос реактора, чтобы впервые в жизни открыть инструкцию по аварийной борьбе за живучесть корабля. Экипаж отрабатывает аварийные тревоги каждую неделю: аварийные дизель-генераторы запускаются под нагрузкой, изолирующие дыхательные аппараты проверяются по секундомеру, а переборочные люки задраиваются за считанные секунды в полной темноте.

Disaster Recovery (DR, аварийное восстановление) в архитектуре высоконагруженных баз данных — это точно такая же борьба за живучесть боевого корабля. Это не просто «регулярно запускать скрипт бэкапа». Это комплексная инженерная и организационная стратегия, гарантирующая, что при катастрофическом сбое (физическая потеря дата-центра, коррупция файловой системы, человеческая ошибка администратора, целенаправленная атака вирусов-вымогателей) система сможет восстановить работоспособность в строго оговоренные сроки с минимальной потерей данных. Для инженера уровня Senior/Lead план DR — это не опция, а нерушимый архитектурный контракт, определяющий надежность всего бизнеса.

Статистика неумолима: 60% компаний, потерявших критические данные без возможности быстрого восстановления, закрываются в течение полугода после инцидента. При этом 90% организаций хотя бы раз сталкивались с неудачным восстановлением из бэкапа, который на бумаге «вроде бы успешно создавался каждый день». Разница между иллюзией безопасности и реальной живучестью — в деталях реализации, регулярности учений и глубоком понимании механики СУБД на уровне дисков, транзакционных логов и сетевого стека.

В этой статье мы разберем:

  • Фундаментальные метрики надежности: RPO, RTO, RLO и их инженерные компромиссы.
  • Архитектуру стратегий резервного копирования: полные, инкрементальные, снапшоты, логические против физических бэкапов.
  • Механику Point-in-Time Recovery (PITR) под капотом: как WAL, чекпоинты и архивация взаимодействуют для восстановления на произвольный момент.
  • Идиоматичную реализацию агентов бэкапа и валидаторов в Go с учетом конкурентности, таймаутов и аллокаций.
  • Процедуры аварийного переключения (failover) и отката (failback): автоматизация, кворумы, предотвращение расщепления мозга (split-brain).
  • Тестирование восстановления: почему «бэкап без проверки восстановления — это не бэкап».
  • Типичные ловушки, антипаттерны и каверзные вопросы с хардовых собеседований.

Фундаментальные метрики: RPO, RTO, RLO

Прежде чем проектировать стратегию, необходимо определить бизнес-требования в измеримых величинах.

Метрика Определение Инженерная интерпретация Типичные значения
RPO (Recovery Point Objective) Максимальный период потери данных, допустимый бизнесом Частота бэкапов / лаг репликации От 0 (синхронная реплика) до 24 часов (ежедневный бэкап)
RTO (Recovery Time Objective) Максимальное время простоя системы до восстановления работоспособности Скорость развертывания, применения бэкапа, переключения От секунд (автоматический failover) до дней (ручное восстановление)
RLO (Recovery Level Objective) Гранулярность восстановления: вся БД, таблица, строка, транзакция Поддержка logical backup, point-in-time, частичного восстановления Таблица/строка для OLTP, вся БД для аналитики
flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    Business["Бизнес-требования"]:::warning --> RPO["RPO: Сколько данных можно потерять?"]
    Business --> RTO["RTO: Как быстро нужно восстановиться?"]
    Business --> RLO["RLO: Что восстанавливать: всё или часть?"]

    RPO --> Strategy1["Синхронная репликация (RPO=0)"]
    RPO --> Strategy2["Асинхронная репликация + WAL архив (RPO=секунды)"]
    RPO --> Strategy3["Ежедневный бэкап (RPO=24ч)"]

    RTO --> Infra1["Автоматический failover с health checks"]
    RTO --> Infra2["Ручное переключение с проверкой целостности"]
    RTO --> Infra3["Восстановление из холодного хранилища (часы)"]
  

Стратегии резервного копирования: Полные, инкрементальные, снапшоты

Выбор типа бэкапа определяет скорость создания, размер хранилища и время восстановления.

Физические против логических бэкапов

Тип Инструменты Плюсы Минусы Когда использовать
Физический (raw files) pg_basebackup, xtrabackup, LVM/ZFS снапшоты Быстрое создание/восстановление, точная копия блоков Требует остановки или режима hot backup, привязан к версии СУБД, большой размер Большие БД (>100 ГБ), строгий RTO, идентичное железо
Логический (SQL dump) pg_dump, mysqldump, SELECT INTO OUTFILE Портативность, возможность восстановления отдельных таблиц, сжатие Медленная загрузка/выгрузка, не сохраняет точные блоки, нет PITR без WAL Малые/средние БД, миграция между версиями, частичное восстановление
Снапшоты ФС LVM, ZFS, EBS snapshots, Ceph RBD Мгновенное создание, консистентность на уровне блоков Требует поддержки ФС, может блокировать запись на момент снимка Виртуализированные среды, облачные инфраструктуры

Инкрементальные бэкапы и дедупликация

Полный бэкап 1 ТБ базы каждый день — это 30 ТБ в месяц и огромные окна резервирования. Инкрементальные стратегии экономят место и время:

  • Уровневые бэкапы (Level 0/1/2): Полный снимок (L0) раз в неделю, инкрементальные (L1) — ежедневно, дифференциальные (L2) — каждый час.
  • Дедупликация на уровне блоков: Инструменты вроде borgbackup, restic, pgbackrest разбивают данные на чанки, хешируют их и хранят только уникальные. Повторяющиеся блоки (например, неизмененные страницы БД) не дублируются.
  • Copy-on-Write снапшоты: Файловые системы ZFS/Btrfs создают снапшоты мгновенно, копируя только измененные блоки при записи.

Point-in-Time Recovery: Механика WAL и архивации

PITR — это «машина времени» для базы данных: возможность восстановить состояние на любой момент в прошлом, а не только на момент создания бэкапа.

Как работает PITR в PostgreSQL

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    BaseBackup["Физический бэкап (base backup)"]:::data --> ArchiveWAL["Непрерывная архивация WAL"]
    ArchiveWAL --> Storage["Хранилище: S3 / NFS / Glacier"]

    Recovery["Запрос на восстановление к времени T"]:::entry --> FetchBase["Восстановить base backup"]
    FetchBase --> ReplayWAL["Применить WAL сегменты последовательно"]
    ReplayWAL --> StopAt["Остановить replay на T (recovery_target_time)"]
    StopAt --> Promote["Promote: база переходит в read-write режим"]
  
  1. Base backup: Снимок файлов данных на момент t0.
  2. WAL archiving: Каждый завершенный сегмент WAL (16 МБ по умолчанию) копируется в надежное хранилище через archive_command.
  3. Recovery: При восстановлении СУБД загружает base backup, затем последовательно применяет WAL-сегменты, останавливаясь на заданном времени, транзакции или метке.

Настройка архивации и восстановление

Bash
# postgresql.conf
wal_level = replica                    # Минимум для архивации
archive_mode = on
archive_command = 'aws s3 cp %p s3://my-bucket/wal/%f' # Надежная доставка
archive_timeout = 300                  # Принудительная ротация каждые 5 минут

# recovery.conf (или postgresql.auto.conf в новых версиях)
restore_command = 'aws s3 cp s3://my-bucket/wal/%f %p'
recovery_target_time = '2024-06-15 14:30:00 UTC'
recovery_target_action = promote       # После восстановления стать мастером

Идиоматичная реализация агентов бэкапа в Go

Написание надежного агента бэкапа в Go требует учета конкурентности, обработки ошибок, таймаутов и эффективного использования памяти.

Go
package backup

import (
    "context"
    "database/sql"
    "fmt"
    "io"
    "log"
    "math/rand"
    "os"
    "os/exec"
    "path/filepath"
    "time"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/s3"
)

// BackupAgent управляет созданием и загрузкой бэкапов
type BackupAgent struct {
    db            *sql.DB
    s3Client      *s3.Client
    bucket        string
    retentionDays int
}

func NewBackupAgent(db *sql.DB, bucket string) (*BackupAgent, error) {
    cfg, err := config.LoadDefaultConfig(context.Background())
    if err != nil {
        return nil, fmt.Errorf("load AWS config: %w", err)
    }
    return &BackupAgent{
        db:            db,
        s3Client:      s3.NewFromConfig(cfg),
        bucket:        bucket,
        retentionDays: 30,
    }, nil
}

// RunFullBackup создает физический бэкап и загружает в S3
func (ba *BackupAgent) RunFullBackup(ctx context.Context) error {
    // 1. Создаем временную директорию
    backupDir, err := os.MkdirTemp("", "pg-backup-*")
    if err != nil {
        return fmt.Errorf("create temp dir: %w", err)
    }
    defer os.RemoveAll(backupDir) // Гарантированная очистка

    // 2. Запускаем pg_basebackup через exec (в продакшене лучше использовать libpq bindings)
    cmd := exec.CommandContext(ctx, "pg_basebackup",
        "-D", backupDir,
        "-Ft", // Формат tar
        "-z",  // Сжатие
        "-P",  // Прогресс
    )
    // ... настройка env, аутентификации ...

    if err := cmd.Run(); err != nil {
        return fmt.Errorf("pg_basebackup failed: %w", err)
    }

    // 3. Загружаем архив в S3 с прогрессом и ретраями
    archivePath := filepath.Join(backupDir, "base.tar.gz")
    if err := ba.uploadWithRetry(ctx, archivePath, fmt.Sprintf("backups/%s/base.tar.gz", time.Now().Format("2006-01-02"))); err != nil {
        return fmt.Errorf("upload to S3: %w", err)
    }

    // 4. Очищаем старые бэкапы согласно политике хранения
    if err := ba.cleanupOldBackups(ctx); err != nil {
        log.Printf("WARN: cleanup failed: %v", err)
        // Не прерываем основной процесс, но логируем
    }

    return nil
}

func (ba *BackupAgent) cleanupOldBackups(ctx context.Context) error {
    // Логика очистки по retention policy
    return nil
}

// uploadWithRetry загружает файл с экспоненциальной задержкой при ошибках
func (ba *BackupAgent) uploadWithRetry(ctx context.Context, localPath, s3Key string) error {
    const maxRetries = 5
    baseDelay := 1 * time.Second

    for attempt := 0; attempt < maxRetries; attempt++ {
        file, err := os.Open(localPath)
        if err != nil {
            return fmt.Errorf("open file: %w", err)
        }

        _, err = ba.s3Client.PutObject(ctx, &s3.PutObjectInput{
            Bucket: aws.String(ba.bucket),
            Key:    aws.String(s3Key),
            Body:   file,
        })
        file.Close()

        if err == nil {
            return nil
        }

        // Экспоненциальная задержка с джиттером
        delay := baseDelay * time.Duration(1<<attempt)
        jitter := time.Duration(rand.Int63n(int64(delay) / 4))
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(delay + jitter):
            // Продолжаем следующий ретрай
        }
    }

    return fmt.Errorf("upload failed after %d retries", maxRetries)
}

Процедуры аварийного переключения: Failover и Failback

Бэкапы бесполезны, если вы не можете быстро переключиться на резервную инфраструктуру.

Автоматический failover с кворумом

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    HealthCheck["Health check мастера каждые 5 сек"]:::error --> Detect["Обнаружение недоступности"]
    Detect --> Elect["Выбор новой реплики через кворум"]
    Elect --> Promote["Promote реплики в мастер"]
    Promote --> Redirect["Переключение трафика: DNS / Proxy / Service Mesh"]
    Redirect --> Verify["Валидация: тестовые запросы, целостность"]
    Verify --> Notify["Оповещение команды, начало расследования"]
  
  1. Обнаружение: Не просто ping, а проверка SELECT 1 с таймаутом, репликационного лага, доступности дисков.
  2. Выбор жертвы: Не любая реплика подходит. Критерии: минимальный лаг, наличие всех WAL, достаточные ресурсы, географическая близость к пользователям.
  3. Кворум: Чтобы избежать split-brain, переключение требует подтверждения от большинства узлов (например, 2 из 3 в кластере Patroni).
  4. Переключение трафика: Обновление DNS (с низким TTL), перенастройка HAProxy, или обновление endpoint в Kubernetes Service.

Failback: Возврат к нормальной конфигурации

После устранения причины сбоя старый мастер нужно вернуть в строй. Это не просто «запустить сервер».

  1. Остановка записи на новом мастере (или переключение в read-only).
  2. Репликация от нового мастера к старому (теперь он становится репликой).
  3. Синхронизация до нулевого лага.
  4. Плановое переключение обратно (или оставление новой топологии, если она стабильнее).

Тестирование восстановления: Почему «бэкап без проверки — это не бэкап»

Самая распространенная ошибка: бэкапы создаются годами, но ни разу не тестировались на восстановление. При реальном инциденте выясняется, что:

  • Архив WAL поврежден.
  • Версия СУБД на новом сервере несовместима.
  • Не хватает места на диске для восстановления.
  • Скрипт восстановления содержит баг, который не проявлялся при сухом запуске.

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

  1. Еженедельно: Восстановление случайного бэкапа в изолированную среду, проверка целостности (pg_checksums, SELECT COUNT(*)), запуск смоук-тестов приложения.
  2. Ежемесячно: Полное восстановление на инфраструктуру, близкую к продакшену, с измерением реального RTO.
  3. Ежеквартально: Disaster Recovery Drill — имитация потери дата-центра, переключение на резервный регион, проверка работы всего стека.
Go
// Пример интеграционного теста восстановления в Go
func TestPITRRecovery(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
    defer cancel()

    // 1. Создаем тестовую БД и наполняем данными
    originalDB := setupTestDB(t)
    insertTestData(ctx, t, originalDB, 10000)

    // 2. Создаем бэкап через агент
    agent, _ := NewBackupAgent(originalDB, "test-bucket")
    backupID := time.Now().Format("2006-01-02-15-04-05")
    if err := agent.RunFullBackup(ctx); err != nil {
        t.Fatalf("backup failed: %v", err)
    }

    // 3. Эмулируем катастрофу: удаляем оригинал
    originalDB.Close()
    dropTestDB(t)

    // 4. Восстанавливаем из бэкапа в новую БД
    restoredDB, err := restoreFromBackup(ctx, "test-bucket", backupID)
    if err != nil {
        t.Fatalf("restore failed: %v", err)
    }
    defer restoredDB.Close()

    // 5. Валидируем целостность: счетчики, контрольные суммы, бизнес-инварианты
    var count int
    if err := restoredDB.QueryRow("SELECT COUNT(*) FROM orders").Scan(&count); err != nil {
        t.Errorf("query failed: %v", err)
    }
    if count != 10000 {
        t.Errorf("data loss: expected 10000 rows, got %d", count)
    }

    // 6. Проверяем, что можно писать (база в read-write режиме)
    _, err = restoredDB.Exec("INSERT INTO audit_log (event) VALUES ('recovery_test')")
    if err != nil {
        t.Errorf("write test failed: %v", err)
    }
}

Ловушки, антипаттерны и вопросы с собеседований

  1. Бэкапы без шифрования и контроля доступа

    • Проблема: Бэкап в публичном S3-бакете утекает, и злоумышленник получает полную копию продакшена с персональными данными.
    • Решение: Всегда шифруйте бэкапы на стороне клиента (перед загрузкой), используйте IAM-политики с минимальными привилегиями, включайте логирование доступа к хранилищу.
  2. Игнорирование размера бэкапа при восстановлении

    • Проблема: Бэкап 2 ТБ, а на целевом сервере только 1.5 ТБ свободного места. Восстановление падает на 90%, оставляя базу в неработоспособном состоянии.
    • Решение: Всегда проверяйте доступное место перед восстановлением, используйте сжатие, рассмотрите восстановление с фильтрацией (только критичные схемы).
  3. Отсутствие документации и runbooks

    • Проблема: При инциденте в 3 часа ночи инженер не помнит, как запустить восстановление, или скрипт зависит от локальных путей, которых нет на новом сервере.
    • Решение: Документируйте процедуры в формате runbooks с пошаговыми командами, храните их в репозитории вместе с кодом, проводите регулярные тренировки.
  4. Сравнение с другими экосистемами

    • Java: Часто полагаются на фреймворки вроде Flyway/Liquibase для миграций, но бэкапы остаются за пределами приложения. Инструменты вроде pgbackrest имеют Java-клиенты, но основная логика — на уровне СУБД.
    • C#/.NET: Интеграция с Azure Backup, SQL Server Management Studio. Много GUI-инструментов, но меньше гибкости в автоматизации.
    • Go: Философия «явных зависимостей». Вы пишете агентов бэкапа, валидаторов, оркестраторов восстановления как обычные Go-сервисы. Это дает полный контроль над таймаутами, ретраями, логированием и интеграцией с observability-стеком, но требует больше кода и дисциплины.
  5. Психологический антипаттерн: «У нас же есть бэкапы»

    • Проблема: Команда успокаивается наличием бэкапов и перестает инвестировать в мониторинг, тестирование и автоматизацию восстановления.
    • Решение: Регулярно проводите post-mortem учений, измеряйте реальные RPO/RTO, делайте метрики восстановления частью SLA.

Итог

Disaster Recovery — это не набор скриптов, а культура инженерной ответственности. Ключевые принципы для уровня Senior/Lead:

  • Определяйте RPO/RTO явно и проектируйте архитектуру под эти метрики, а не под «максимальную надежность».
  • Используйте комбинацию стратегий: физические бэкапы для быстрого восстановления, WAL-архивация для PITR, репликация для минимизации лага.
  • Автоматизируйте не только создание, но и проверку восстановления. Бэкап без тестового восстановления — это иллюзия безопасности.
  • Пишите идиоматичный код агентов бэкапа: с контекстами, ретраями, джиттером, контролем аллокаций и интеграцией с observability.
  • Документируйте runbooks и проводите регулярные учения. В момент реального инцидента нет времени на эксперименты.

Освоив принципы аварийного восстановления, вы сможете гарантировать выживаемость системы даже при катастрофических сбоях. Но чтобы предотвратить катастрофу до ее наступления, необходима непрерывная наблюдаемость за состоянием хранилища: от системных счетчиков до планов медленных запросов. В следующей статье мы перейдем к фундаментальным практикам мониторинга СУБД: 17. Мониторинг баз данных (а также затронем глобальные стратегии в 3. Multi Leader).