🏗️ Розділ 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 конектів гарантовано покладуть реляційну СУБД).

Пов’язані теми: