Миграция в облако: как мы перенесли 200 серверов без даунтайма

Исходная ситуация

«АвтоДилер Про» — это SaaS-платформа для управления автосалонами: CRM, складской учёт, интеграция с банками для кредитования, онлайн-шоурум. На момент обращения к нам инфраструктура выглядела так:

  • 200 физических серверов в двух ЦОД (Москва и Санкт-Петербург)
  • PostgreSQL-кластер на 12 нод (5 ТБ данных)
  • Redis-кластер на 8 нод
  • Монолитное Java-приложение + 15 микросервисов на Go
  • Собственный S3-совместимый storage на MinIO (80 ТБ фотографий автомобилей)
  • Контракт на ЦОД заканчивался через 8 месяцев

Главное ограничение: платформу используют 3000+ автосалонов по всей России, SLA — 99.95%. Любой простой — это реальные потери для бизнеса клиентов.

Фаза 1: Assessment и маппинг зависимостей

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

# discovery_agent.py — агент сбора метаданных сервера
import psutil
import socket
import json
import subprocess

def collect_server_info():
    info = {
        "hostname": socket.gethostname(),
        "cpu_cores": psutil.cpu_count(logical=False),
        "ram_gb": round(psutil.virtual_memory().total / (1024**3), 1),
        "disks": [],
        "listening_ports": [],
        "established_connections": set(),
        "installed_services": [],
    }

    # Собираем информацию о дисках
    for partition in psutil.disk_partitions():
        usage = psutil.disk_usage(partition.mountpoint)
        info["disks"].append({
            "mount": partition.mountpoint,
            "total_gb": round(usage.total / (1024**3), 1),
            "used_gb": round(usage.used / (1024**3), 1),
        })

    # Маппим сетевые соединения для графа зависимостей
    for conn in psutil.net_connections(kind='inet'):
        if conn.status == 'LISTEN':
            info["listening_ports"].append(conn.laddr.port)
        elif conn.status == 'ESTABLISHED' and conn.raddr:
            info["established_connections"].add(conn.raddr.ip)

    info["established_connections"] = list(info["established_connections"])

    # Systemd-сервисы
    result = subprocess.run(
        ["systemctl", "list-units", "--type=service", "--state=running", "--no-pager"],
        capture_output=True, text=True
    )
    for line in result.stdout.strip().split('\n'):
        if '.service' in line:
            svc = line.strip().split()[0]
            info["installed_services"].append(svc)

    return info

На основе данных от агентов мы построили граф зависимостей — кто с кем общается. Результат визуализировали в Graphviz и обнаружили несколько сюрпризов:

  • 12 серверов-«призраков» — работают, потребляют ресурсы, но ни с кем не общаются (оказались забытыми dev-стендами)
  • Циклическая зависимость между сервисом уведомлений и биллингом
  • Сервис генерации PDF-отчётов жёстко привязан к локальному шрифтовому серверу

Дерево решений: lift-and-shift vs re-architect

Не всё нужно переписывать. Мы разработали матрицу принятия решений для каждого компонента:

# migration_decision.yaml — матрица решений
components:
  java_monolith:
    strategy: lift-and-shift
    reason: "Слишком большой для рефакторинга в рамках миграции"
    target: EC2 c5.4xlarge (16 vCPU, 32 GB RAM)
    notes: "Контейнеризируем позже, отдельным проектом"

  go_microservices:
    strategy: re-platform
    reason: "Уже контейнеризированы, переносим в Kubernetes"
    target: EKS managed node group
    notes: "Helm-чарты готовы, нужна адаптация конфигов"

  postgresql_cluster:
    strategy: re-platform
    reason: "Managed PostgreSQL снимает operational burden"
    target: RDS PostgreSQL 15, Multi-AZ, r6g.4xlarge
    notes: "CDC-миграция через Debezium"

  minio_storage:
    strategy: re-platform
    reason: "Нативный S3 дешевле и надёжнее"
    target: S3 Standard + S3 IA (для фото старше 90 дней)
    notes: "rclone для параллельного копирования"

  redis_cluster:
    strategy: re-platform
    target: ElastiCache Redis 7, cluster mode enabled
    notes: "Прогрев кэша после миграции"

  pdf_service:
    strategy: re-architect
    reason: "Зависимость от локального шрифтового сервера"
    target: Lambda + контейнерный образ со шрифтами
    notes: "Переписать на headless Chrome в контейнере"

План поэтапной миграции

Мы разделили миграцию на 5 волн по 4-6 недель каждая. Критический принцип — на каждом этапе система должна быть полностью работоспособной, даже если часть в облаке, а часть в ЦОД.

Между ЦОД и облаком подняли VPN-туннель через WireGuard с пропускной способностью 10 Gbps:

# /etc/wireguard/wg-cloud.conf на стороне ЦОД
[Interface]
PrivateKey = <DATACENTER_PRIVATE_KEY>
Address = 10.100.0.1/24
ListenPort = 51820
MTU = 1420
PostUp = iptables -A FORWARD -i wg-cloud -j ACCEPT
PostDown = iptables -D FORWARD -i wg-cloud -j ACCEPT

[Peer]
PublicKey = <CLOUD_PUBLIC_KEY>
AllowedIPs = 10.200.0.0/16
Endpoint = cloud-vpn.autodealerpro.internal:51820
PersistentKeepalive = 25

Миграция базы данных: CDC-подход

Самая сложная часть. 5 ТБ PostgreSQL нельзя мигрировать через pg_dump/pg_restore — это займёт часы, а значит, будет даунтайм. Мы использовали Change Data Capture через Debezium.

Схема работы:

  1. Начальный снэпшот: pg_basebackup на реплику, восстановление в RDS через рестор
  2. Запуск Debezium Connector для захвата изменений из WAL
  3. Потоковая репликация изменений в RDS через Kafka
  4. Верификация консистентности данных
  5. Переключение приложения на RDS (DNS cutover)
// debezium-connector-config.json
{
  "name": "autodealerpro-pg-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "pg-master.dc1.internal",
    "database.port": "5432",
    "database.user": "debezium_replicator",
    "database.dbname": "autodealerpro",
    "database.server.name": "adp_prod",
    "plugin.name": "pgoutput",
    "slot.name": "debezium_migration",
    "publication.name": "dbz_publication",
    "table.include.list": "public.*",
    "snapshot.mode": "initial",
    "tombstones.on.delete": false,
    "decimal.handling.mode": "string",
    "transforms": "route",
    "transforms.route.type": "org.apache.kafka.connect.transforms.RegexRouter",
    "transforms.route.regex": "([^.]+)\\.([^.]+)\\.([^.]+)",
    "transforms.route.replacement": "adp_cdc_$3"
  }
}

На стороне RDS мы написали consumer, который применял изменения:

# cdc_consumer.py — применение CDC-событий к целевой БД
import json
from kafka import KafkaConsumer
import psycopg2

consumer = KafkaConsumer(
    bootstrap_servers=['kafka-1:9092', 'kafka-2:9092', 'kafka-3:9092'],
    group_id='cdc-migration-consumer',
    auto_offset_reset='earliest',
    value_deserializer=lambda m: json.loads(m.decode('utf-8')),
)

# Подписка на все CDC-топики
consumer.subscribe(pattern='adp_cdc_.*')

conn = psycopg2.connect(
    host='adp-prod.cluster-xyz.eu-central-1.rds.amazonaws.com',
    dbname='autodealerpro',
    user='migration_user',
)

applied = 0
for message in consumer:
    payload = message.value.get('payload', {})
    op = payload.get('op')  # c=create, u=update, d=delete
    table = message.topic.replace('adp_cdc_', '')

    with conn.cursor() as cur:
        if op == 'c':
            after = payload['after']
            cols = ', '.join(after.keys())
            vals = ', '.join(['%s'] * len(after))
            cur.execute(
                f"INSERT INTO {table} ({cols}) VALUES ({vals}) ON CONFLICT DO NOTHING",
                list(after.values())
            )
        elif op == 'u':
            after = payload['after']
            pk = list(payload['source'].get('pk', {}).keys())[0] if 'pk' in payload.get('source', {}) else 'id'
            sets = ', '.join([f"{k} = %s" for k in after.keys() if k != pk])
            cur.execute(
                f"UPDATE {table} SET {sets} WHERE {pk} = %s",
                [v for k, v in after.items() if k != pk] + [after[pk]]
            )
        elif op == 'd':
            before = payload['before']
            pk = 'id'
            cur.execute(f"DELETE FROM {table} WHERE {pk} = %s", [before[pk]])

    conn.commit()
    applied += 1
    if applied % 10000 == 0:
        print(f"Applied {applied} changes, lag: {message.timestamp}")

Мы гнали CDC-поток 3 недели, пока lag не стабилизировался на уровне менее 100 мс. После этого можно было переключаться.

DNS Cutover: момент истины

Переключение трафика мы делали через взвешенный DNS. Это позволило переводить трафик постепенно:

# Terraform-конфигурация Route53 weighted routing
resource "aws_route53_record" "api_weighted_dc" {
  zone_id = aws_route53_zone.main.zone_id
  name    = "api.autodealerpro.ru"
  type    = "A"
  ttl     = 30  # Низкий TTL для быстрого переключения

  weighted_routing_policy {
    weight = var.dc_weight  # Начинаем с 100, уменьшаем до 0
  }

  set_identifier = "datacenter"
  records        = ["185.100.50.10"]  # IP ЦОД
}

resource "aws_route53_record" "api_weighted_cloud" {
  zone_id = aws_route53_zone.main.zone_id
  name    = "api.autodealerpro.ru"
  type    = "A"

  alias {
    name                   = aws_lb.api.dns_name
    zone_id                = aws_lb.api.zone_id
    evaluate_target_health = true
  }

  weighted_routing_policy {
    weight = var.cloud_weight  # Начинаем с 0, увеличиваем до 100
  }

  set_identifier = "cloud"
}

График переключения: 0% → 5% → 10% → 25% → 50% → 75% → 100%. На каждом этапе мы выдерживали паузу в 2-4 часа и мониторили ошибки.

Оптимизация затрат

Облако может быть дороже ЦОД, если не оптимизировать. Мы применили несколько стратегий:

  • Right-sizing: после месяца работы в облаке проанализировали реальное потребление. 60% инстансов были oversized — уменьшили
  • Reserved Instances: для стабильных workloads (БД, базовый слой приложения) купили RI на 1 год — экономия 35%
  • Spot Instances: для batch-процессов (генерация отчётов, обработка фото) — экономия до 70%
  • S3 Lifecycle: фотографии старше 90 дней в S3 IA, старше 1 года в S3 Glacier — экономия 60% на хранении
  • Автоскейлинг: ночью нагрузка падает в 5 раз — скейлим вниз
# autoscaling.tf — HPA + Cluster Autoscaler конфигурация
resource "aws_autoscaling_group" "app" {
  min_size         = 4   # Ночной минимум
  max_size         = 24  # Дневной пик
  desired_capacity = 8

  mixed_instances_policy {
    instances_distribution {
      on_demand_base_capacity                  = 4
      on_demand_percentage_above_base_capacity = 25
      spot_allocation_strategy                 = "capacity-optimized"
    }

    launch_template {
      launch_template_specification {
        launch_template_id = aws_launch_template.app.id
        version            = "$Latest"
      }

      override {
        instance_type = "c5.2xlarge"
      }
      override {
        instance_type = "c5a.2xlarge"
      }
      override {
        instance_type = "c6i.2xlarge"
      }
    }
  }

  tag {
    key                 = "k8s.io/cluster-autoscaler/enabled"
    value               = "true"
    propagate_at_launch = true
  }
}

Безопасность: улучшения при миграции

Миграция — отличный повод подтянуть безопасность. Что мы внедрили:

  • VPC с приватными подсетями: БД и внутренние сервисы изолированы, доступ только через NAT Gateway
  • AWS Secrets Manager: вместо конфигов с паролями на серверах
  • IAM Roles: для сервисов вместо статических ключей
  • WAF: на ALB для защиты от SQL-инъекций и XSS
  • GuardDuty: для обнаружения аномалий
  • Шифрование: EBS, S3, RDS — всё зашифровано KMS-ключами

Миграция мониторинга

В ЦОД у клиента был Zabbix. Мы перевели всё на CloudWatch + Prometheus + Grafana:

# prometheus-values.yaml для Helm
server:
  retention: 30d
  resources:
    requests:
      memory: 4Gi
      cpu: 2
  persistentVolume:
    size: 200Gi

  remoteWrite:
    - url: "https://aps-workspaces.eu-central-1.amazonaws.com/workspaces/ws-xxx/api/v1/remote_write"
      sigv4:
        region: eu-central-1

alertmanager:
  config:
    route:
      group_by: ['alertname', 'cluster', 'service']
      group_wait: 30s
      group_interval: 5m
      repeat_interval: 4h
      receiver: 'telegram'
      routes:
        - match:
            severity: critical
          receiver: 'pagerduty'
    receivers:
      - name: 'telegram'
        webhook_configs:
          - url: 'http://alertmanager-bot:8080/alerts'
      - name: 'pagerduty'
        pagerduty_configs:
          - service_key: '<PD_KEY>'

Обучение команды

Мигрировать серверы мало — нужно мигрировать людей. Мы провели:

  • 40 часов воркшопов по AWS для команды из 12 инженеров
  • Парное дежурство: наш инженер + инженер клиента в течение 2 месяцев
  • Создание runbook-ов для 30 типовых операций
  • Game days — имитация аварий для тренировки реагирования

Результаты: TCO и метрики

МетрикаДо (ЦОД)После (облако)
Ежемесячные затраты на инфраструктуру4.2 млн руб.2.5 млн руб. (-40%)
Время деплоя45 минут8 минут
MTTR (среднее время восстановления)2.5 часа15 минут
Uptime за 3 месяца99.91%99.98%
Время на инфраструктурные задачи60% рабочего времени ops20%
Даунтайм при миграции0 секунд

Ключевые уроки

  1. Assessment — это не формальность. Месяц на аудит сэкономил нам 3 месяца проблем.
  2. Не всё нужно переписывать. Lift-and-shift для монолита + re-platform для БД — оптимальный баланс.
  3. CDC — единственный способ мигрировать большие БД без даунтайма.
  4. Взвешенный DNS позволяет откатить миграцию за 30 секунд.
  5. Люди важнее серверов. Инвестиции в обучение команды — это инвестиции в стабильность.

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

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#assessment#aws secrets manager:#cdcподход#cutover#guardduty:#iam roles:#liftandshift#rearchitect
Комментарии 0

Оставить комментарий

загрузка...