Как реализовать горизонтальное масштабирование микросервисов
Чтобы микросервис можно было масштабировать горизонтально, он обязан быть абсолютно 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 коннектов гарантированно положат реляционную СУБД).