Як реалізувати горизонтальне масштабування мікросервісів
Щоб мікросервіс можна було масштабувати горизонтально, він зобов'язаний бути абсолютно 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)]
Головні відмінності від вертикального масштабування:
- Відсутність межі зростання: Вертикальне масштабування впирається у фізичну стелю вартості та характеристик одного сервера. Горизонтальне масштабування дозволяє об’єднувати сотні недорогих серверів.
- Відмовостійкість (HA): Якщо при вертикальному масштабуванні єдиний сервер виходить з ладу — падає вся система. При горизонтальному масштабуванні падіння 1 інстанса з 10 непомітне для користувачів: балансувальник просто виключає інстанс, що впав.
- Без простою (Zero Downtime): Нові інстанси запускаються та виводяться з експлуатації на льоту відповідно до зміни навантаження.
🟡 Middle Level
1. Золоте правило: Архітектура Stateless (12-Factor App)
Щоб мікросервіс можна було масштабувати горизонтально, він зобов’язаний бути абсолютно Stateless (без збереження локального стану):
АНТИПАТЕРН (Stateful у пам'яті):
[Інстанс 1] ──► Зберігає HttpSession, локальний лічильник або аватар користувача в /tmp
[Клієнт] ──► Наступний запит прилетів в [Інстанс 2] ──► Помилка 401 Unauthorized!
ПРАВИЛЬНО (Stateless):
[Інстанс 1] ──┐
[Інстанс 2] ──┼──► [Redis Cluster: Сесії, кеш] ──► [PostgreSQL: Бізнес-дані]
[Інстанс 3] ──┘
- Сесії користувачів: Зберігаються в зовнішньому розподіленому сховищі (
Spring Session Data Redis) або клієнт передає самодостатній криптографічний JWT-токен. - Файли: Завантажуються не на локальний диск сервера, а в розподілене об’єктне сховище (S3 / MinIO).
- Фонові планувальники (
@Scheduled): При запуску 10 інстансів планувальник за замовчуванням спрацює 10 разів одночасно. Для запобігання цьому використовують розподілені блокування (ShedLock) або виносять завдання в KubernetesCronJob.
// Захист від паралельного запуску планувальника на 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) або відбувається новий деплой:
- Kubernetes відправляє процесу сигнал
SIGTERMі виключає под з Endpoints сервісу. - Spring Boot зобов’язаний коректно завершити поточні оброблювані HTTP-запити та закрити з’єднання:
server:
shutdown: graceful # Дочікується завершення активних запитів
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # Максимальний час очікування перед SIGKILL
- У Kubernetes
preStophook додається затримка (sleep 5), щобkube-proxyна всіх нодах кластера встиг оновити таблиціiptablesдо того, як контейнер почне зупинятися.
4 Tricky Questions
1. Як організувати горизонтальне масштабування сервісу з WebSockets?
Відповідь: WebSocket-з’єднання є Stateful: між браузером і конкретним подом підтримується постійний TCP-сокет. Якщо Клієнт A підключений до Пода 1, а Клієнт B — до Пода 2, Под 1 не може безпосередньо відправити повідомлення Клієнту B. Архітектурне рішення:
- Балансування: Балансувальник налаштовується на підтримку Upgrade HTTP $\to$ WebSocket.
- 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 інстансів?
Відповідь: Якщо в пам’яті пода закешована сутність (наприклад, ціна товару), а запит на оновлення прилетів на інший под:
- Дворівневий кеш (L1/L2): L1 — швидкий in-memory Caffeine у кожному поді (TTL 30–60 с); L2 — спільний Redis.
- 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+preStopsleep).
Чек-лист готовності сервісу до Scale Out:
- Застосунок не зберігає стан у локальних
staticполях або сесіях пам’яті. - Фонові планувальники захищені розподіленим блокуванням (
ShedLock). - Пул з’єднань БД обмежений, перед СУБД розгорнутий connection pooler.
- Налаштовано
Liveness/Readiness probeтаGraceful Shutdown.
Червоні прапорці (Чого говорити НЕ МОЖНА):
- ❌ «Щоб масштабувати сервіс, ми вмикаємо Sticky Sessions на балансувальнику і зберігаємо сесії в пам’яті» (Sticky sessions ламають рівномірне балансування і втрачають дані користувачів при падінні ноди).
- ❌ «Ми налаштовуємо HPA в Kubernetes за використанням оперативної пам’яті JVM» (JVM не віддає пам’ять назад ОС, HPA зависне на максимумі реплік).
- ❌ «Кожна репліка може відкривати по 100 з’єднань до БД, база витримає» (50 реплік по 100 конектів гарантовано покладуть реляційну СУБД).