Задача
Сервис A ходит в сервис B через обычный ClusterIP. Оба на gRPC. Под нагрузкой B масштабировали с 3 реплик до 10.
Ожидание: нагрузка размажется по десяти подам. Реальность: новые 7 подов простаивают, вся нагрузка по-прежнему на исходных трёх.
Вопрос: почему добавленные реплики не получают трафика?
Что кажется очевидным
«Endpoints не обновились» или «probe не прошли». Но kubectl get endpointslices показывает все 10 адресов, все поды Ready.
Как это работает на самом деле
Ключ в том, на каком уровне работает балансировка Kubernetes.
Как разобрано в задаче 6, ClusterIP — это правило DNAT в ядре. Подмена адреса происходит при установке соединения: пакет SYN попадает под правило, для него выбирается бэкенд, и дальше всё соединение идёт в этот под — таково свойство отслеживания соединений (conntrack).
То есть балансировка Kubernetes — это балансировка соединений (L4), а не запросов (L7).
Для обычного HTTP/1.1 без keep-alive это почти незаметно: каждый запрос — новое соединение, распределение получается равномерным. Но:
- gRPC работает поверх HTTP/2 и мультиплексирует все запросы в одном долгоживущем TCP-соединении;
- клиент установил соединение один раз — и оно прилипло к конкретному поду;
- новые поды не получат ничего, пока клиент не переустановит соединения.
Отсюда и картина: 3 старых соединения — 3 нагруженных пода, независимо от того, сколько реплик добавлено.
Та же проблема касается любых пулов долгоживущих соединений: подключения к базам, HTTP-клиенты с keep-alive, WebSocket.
Ответ
Kubernetes балансирует соединения, а не запросы. gRPC/HTTP/2 держит одно долгоживущее соединение, которое уже привязано к конкретному поду, поэтому новые реплики не получают трафик — они появились после установки соединений.
Что с этим делать
Есть четыре рабочих подхода, от простого к основательному.
1. Клиентская балансировка
Клиент сам получает список адресов подов и держит соединение с каждым, распределяя запросы. Для этого нужен headless Service:
apiVersion: v1
kind: Service
metadata:
name: backend-headless
spec:
clusterIP: None # headless: DNS вернёт адреса всех подов
selector:
app: backend
ports:
- port: 50051С clusterIP: None DNS-запрос возвращает не один виртуальный адрес, а список IP всех готовых подов. gRPC-клиенты это поддерживают штатно:
grpc.Dial("dns:///backend-headless.default.svc.cluster.local:50051",
grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin":{}}]}`))Минус: клиент должен уметь пере-резолвить DNS и подхватывать изменения состава подов.
2. Ограничить время жизни соединений
Простой приём, если менять клиента нельзя: заставить соединения периодически переустанавливаться. На стороне сервера это настройки вроде максимального возраста соединения (в gRPC — MAX_CONNECTION_AGE), на стороне клиента — ограничение времени жизни в пуле.
Соединения будут регулярно перезаключаться и попадать на новые поды. Грубо, но работает без переписывания архитектуры.
3. Прокси с балансировкой на уровне запросов
Поставить перед сервисом прокси, понимающий HTTP/2: Envoy, NGINX, HAProxy или ingress-контроллер. Он держит соединения с каждым бэкендом и распределяет запросы, а не соединения.
4. Service mesh
Istio, Linkerd и подобные ставят sidecar-прокси к каждому поду и решают эту задачу прозрачно для приложения — заодно давая ретраи, таймауты, mTLS и наблюдаемость. Цена — заметная сложность инфраструктуры; ради одной балансировки внедрять mesh не стоит, но если он уже есть, проблема решена.
Что ещё стоит знать про Service
sessionAffinity: ClientIPприбивает клиента к одному поду по IP. Иногда нужно, но с этим ожиданием стоит быть осторожным: это ухудшает равномерность.externalTrafficPolicy: Localсохраняет исходный IP клиента для внешнего трафика и убирает лишний прыжок, но требует наличия пода на ноде, принявшей трафик, иначе трафик будет отброшен.- Топологически-осознанная маршрутизация позволяет предпочитать бэкенды в той же зоне — экономит на межзональном трафике, но может ухудшить балансировку.
Headless+ StatefulSet дают стабильные DNS-имена видаpod-0.svc, что нужно, когда клиенту важно обращаться к конкретному экземпляру.
Диагностика
# Все ли поды в endpoints (тут обычно всё в порядке)
kubectl get endpointslices -l kubernetes.io/service-name=backend
# Реальное распределение: сравните нагрузку по подам
kubectl top pods -l app=backend
# Сколько установленных соединений держит конкретный под
kubectl exec backend-xxx -- ss -tn state establishedЕсли endpoints полные, а нагрузка перекошена — это почти всегда описанный случай с долгоживущими соединениями.