🏗️ Раздел 17 · Вопрос #12

Как реализовать горизонтальное масштабирование микросервисов

Чтобы микросервис можно было масштабировать горизонтально, он обязан быть абсолютно Stateless (без сохранения локального состояния):


🟢 Junior Level

Горизонтальное масштабирование (Scale Out) — это увеличение производительности системы за счёт добавления новых однотипных экземпляров (реплик / подов) микросервиса за балансировщиком нагрузки.

Вертикальное масштабирование (Scale Up):
[Сервер 4 CPU / 16GB RAM] ──(Замена "железа" с простоем)──► [Сервер 32 CPU / 128GB RAM]

Горизонтальное масштабирование (Scale Out):
                                               ┌──► [Инстанс #1 (4 CPU)]
[Клиенты] ──► [Load Balancer / K8s Ingress] ───┼──► [Инстанс #2 (4 CPU)]
                                               └──► [Инстанс #3 (4 CPU)]

Главные отличия от вертикального масштабирования:

  1. Отсутствие предела роста: Вертикальное масштабирование упирается в физический потолок стоимости и характеристик одного сервера. Горизонтальное масштабирование позволяет объединять сотни недорогих серверов.
  2. Отказоустойчивость (HA): Если при вертикальном масштабировании единственный сервер выходит из строя — падает вся система. При горизонтальном масштабировании падение 1 инстанса из 10 незаметно для пользователей: балансировщик просто исключает упавший инстанс.
  3. Без простоя (Zero Downtime): Новые инстансы запускаются и выводятся из эксплуатации на лету по мере изменения нагрузки.

🟡 Middle Level

1. Золотое правило: Архитектура Stateless (12-Factor App)

Чтобы микросервис можно было масштабировать горизонтально, он обязан быть абсолютно Stateless (без сохранения локального состояния):

АНТИПАТТЕРН (Stateful в памяти):
[Инстанс 1] ──► Хранит HttpSession, локальный счетчик или аватар пользователя в /tmp
[Клиент]    ──► Следующий запрос прилетел в [Инстанс 2] ──► Ошибка 401 Unauthorized!

ПРАВИЛЬНО (Stateless):
[Инстанс 1] ──┐
[Инстанс 2] ──┼──► [Redis Cluster: Сессии, кэш] ──► [PostgreSQL: Бизнес-данные]
[Инстанс 3] ──┘
  1. Сессии пользователей: Хранятся во внешнем распределённом хранилище (Spring Session Data Redis) или клиент передает самодостаточный криптографический JWT-токен.
  2. Файлы: Загружаются не на локальный диск сервера, а в распределённое объектное хранилище (S3 / MinIO).
  3. Фоновые шедулеры (@Scheduled): При запуске 10 инстансов шедулер по умолчанию сработает 10 раз одновременно. Для предотвращения этого используют распределённые блокировки (ShedLock) или выносят задачи в Kubernetes CronJob.
// Защита от параллельного запуска шедулера на 10 репликах
@Scheduled(cron = "0 0 * * * *")
@SchedulerLock(name = "billingTask", lockAtLeastFor = "15s", lockAtMostFor = "5m")
public void runHourlyBilling() {
    billingService.processInvoices();
}

2. Автомасштабирование в Kubernetes: HPA v2

В Kubernetes масштабирование реализуется через Horizontal Pod Autoscaler (HPA):

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: payment-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: payment-service
  minReplicas: 3
  maxReplicas: 30
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 80
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300 # Ждать 5 минут перед сбросом реплик (защита от "флэпа")

🔴 Senior Level

1. Ловушка пула соединений БД: HikariCP Exhaustion

Самая частая авария в продакшене при автоскейлинге:

  • Разработчики оставили дефолтный пул соединений к базе данных в каждом поде: maximum-pool-size: 20.
  • В нормальном режиме при 5 репликах сервис держит $5 \times 20 = 100$ соединений к PostgreSQL.
  • Наступил «Черная пятница», HPA резко смасштабировал сервис до 50 подов.
  • 50 подов одновременно открывают $50 \times 20 = 1000$ соединений к PostgreSQL.
  • PostgreSQL исчерпывает max_connections (обычно 200–500) и начинает аварийно реджектить любые запросы, роняя все остальные сервисы компании, использующие эту СУБД.
[50 Подов Spring Boot] ──(1000 прямых соединений: КРАХ БД 💥)──► [PostgreSQL]

Решение для Senior: Connection Pooling Proxy
[50 Подов Spring Boot] ──► [PgBouncer / Odyssey (Transaction Pooling)] ──(100 соединений)──► [PostgreSQL]

2. KEDA: Масштабирование по бизнес-событиям (Event-Driven Autoscaling)

Классический HPA по CPU/Memory бессилен для асинхронных потребителей очередей (Kafka / RabbitMQ):

  • Консьюмер Kafka читает сообщения, делает I/O запросы и потребляет всего 10% CPU.
  • При этом очередь сообщений (Consumer Lag) растет с нуля до 5 000 000 сообщений. HPA «не видит» проблемы, потому что CPU низкий.
  • Решение: Внедрение KEDA (Kubernetes Event-driven Autoscaling):
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: kafka-consumer-scaler
spec:
  scaleTargetRef:
    name: order-consumer-deployment
  minReplicaCount: 2
  maxReplicaCount: 40
  triggers:
  - type: kafka
    metadata:
      bootstrapServers: kafka:9092
      consumerGroup: order-processing-group
      topic: order-events
      lagThreshold: "100" # На каждые 100 сообщений отставания добавляется новый под

3. Graceful Shutdown & Zero-Downtime Deployment

Когда HPA уменьшает число подов (Scale Down) или происходит новый деплой:

  1. Kubernetes отправляет процессу сигнал SIGTERM и исключает под из Endpoints сервиса.
  2. Spring Boot обязан корректно завершить текущие обрабатываемые HTTP-запросы и закрыть соединения:
server:
  shutdown: graceful # Дожидается завершения активных запросов
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s # Максимальное время ожидания перед SIGKILL
  1. В Kubernetes preStop hook добавляется задержка (sleep 5), чтобы kube-proxy на всех нодах кластера успел обновить таблицы iptables до того, как контейнер начнет останавливаться.

4 Tricky Questions

1. Как организовать горизонтальное масштабирование сервиса с WebSockets?

Ответ: WebSocket-соединение является Stateful: между браузером и конкретным подом поддерживается постоянный TCP-сокет. Если Клиент A подключен к Поду 1, а Клиент B — к Поду 2, Под 1 не может напрямую отправить сообщение Клиенту B. Архитектурное решение:

  1. Балансировка: Балансировщик настраивается на поддержку Upgrade HTTP $\to$ WebSocket.
  2. Message Broker Backplane: Все поды микросервиса подписываются на общий брокер сообщений (Redis Pub/Sub, RabbitMQ или Kafka). Когда Под 1 хочет отправить сообщение пользователю, он публикует событие в Redis. Все остальные поды получают его, и Под 2 пересылает сообщение в локальный открытый сокет Клиента B.

2. Почему масштабирование HPA по утилизации памяти (Memory Utilization) в Java требует особой осторожности?

Ответ: JVM резервирует память кучи (Heap) заранее при старте приложения (-Xmx). Даже если приложение простаивает и не выполняет запросов, занятая память JVM не отдается операционной системе немедленно (пока не отработает Garbage Collector). Если настроить HPA на memory averageUtilization: 70%, то после первого же всплеска нагрузки память кучи заполнится объектами, метрика утилизации зафиксируется на уровне 80–90% и HPA раздует деплоймент до maxReplicas, удерживая максимальное число подов бесконечно. Правило: В Java-микросервисах масштабирование настраивают по CPU, RPS или бизнес-метрикам (Lag в очереди), но не по Memory.

3. Как инвалидировать локальный кэш (Caffeine) при горизонтальном масштабировании на 20 инстансов?

Ответ: Если в памяти пода закэширована сущность (например, цена товара), а запрос на обновление прилетел на другой под:

  1. Двухуровневый кэш (L1/L2): L1 — быстрый in-memory Caffeine в каждом поде (TTL 30–60 сек); L2 — общий Redis.
  2. Event-Driven Cache Eviction: При обновлении данных под публикует событие CacheInvalidationEvent(productId) в топик Redis Pub/Sub или Kafka. Все 20 подов слушают этот топик и удаляют ключ из своего локального кэша Caffeine.

4. Что такое эффект «Flapping» (дребезг масштабирования) и как от него защититься?

Ответ: Flapping возникает, когда нагрузка пульсирует вокруг порогового значения: HPA видит CPU 80% $\to$ добавляет под. CPU падает до 50% $\to$ HPA тут же убивает под. Нагрузка снова 80% $\to$ под поднимается. Постоянный запуск/убийство подов перегружает Kubernetes API, нагружает сеть и забивает процессор JVM компиляцией JIT. Защита: Настройка stabilizationWindowSeconds в блоке behavior.scaleDown (например, 300 секунд). HPA ждет 5 минут стабильно низкой нагрузки перед тем, как окончательно удалить под.


🎯 Шпаргалка для интервью

Главное:

  • Горизонтальное масштабирование — запуск $N$ идентичных реплик сервиса за балансировщиком.
  • Главное требование: Сервис обязан быть Stateless (сессии в Redis, файлы в S3, блокировки в ShedLock/Redis).
  • В Kubernetes автомасштабирование реализуется через HPA v2 (CPU, RPS) и KEDA (для очередей Kafka/RabbitMQ).
  • Главный риск для СУБД: Истощение пула соединений (max_connections) при взрывном росте подов. Требуется PgBouncer / ProxySQL.
  • Не масштабируйте Java-сервисы по памяти (JVM держит Heap).
  • Завершение подов должно быть Graceful (server.shutdown=graceful + preStop sleep).

Чек-лист готовности сервиса к Scale Out:

  1. Приложение не хранит состояние в локальных static полях или сессиях памяти.
  2. Фоновые шедулеры защищены распределенной блокировкой (ShedLock).
  3. Пул соединений БД ограничен, перед СУБД развернут connection pooler.
  4. Настроен Liveness/Readiness probe и Graceful Shutdown.

Красные флаги (Чего говорить НЕЛЬЗЯ):

  • ❌ «Чтобы масштабировать сервис, мы включаем Sticky Sessions на балансировщике и храним сессии в памяти» (Sticky sessions ломают равномерную балансировку и теряют данные пользователей при падении ноды).
  • ❌ «Мы настраиваем HPA в Kubernetes по использованию оперативной памяти JVM» (JVM не отдает память обратно ОС, HPA зависнет на максимуме реплик).
  • ❌ «Каждая реплика может открывать по 100 соединений к БД, база выдержит» (50 реплик по 100 коннектов гарантированно положат реляционную СУБД).

Связанные темы: