Service Discovery и балансировка

IP-адрес пода живёт часы, а иногда минуты. Как один сервис вообще находит другой?

Service Discovery — механизм, который позволяет сервису узнать актуальные сетевые адреса живых инстансов другого сервиса, не зная их заранее.

Зачем это нужно на практике

В монолите вызов соседнего модуля — это вызов функции: адрес известен в момент компиляции. В распределённой системе всё иначе. Сервис заказов хочет позвать сервис оплаты, но:

  • инстансов оплаты сейчас три, через минуту при автоскейлинге станет семь, ночью останется один;
  • у каждого инстанса свой IP, и он меняется при каждом перезапуске;
  • один из инстансов прямо сейчас перезапускается после выката новой версии, и слать туда трафик нельзя;
  • ещё один жив, но подвис на 40 секунд на сборке мусора.

Записать IP в конфиг невозможно физически: к моменту, когда конфиг доедет до прода, адрес уже протухнет. Нужен механизм, который отвечает на вопрос «где сейчас живые инстансы сервиса payments?» — и отвечает в момент запроса, а не в момент сборки.

Реестр сервисов

Классическое решение — service registry: база «сервис → список живых адресов». Consul, etcd, Eureka, Zookeeper. В неё попадают записи двумя способами:

СпособКак работаетМинус
Self-registrationинстанс сам при старте пишет «я живой, вот мой адрес» и шлёт heartbeatкаждый сервис тащит библиотеку реестра, код инфраструктуры смешивается с бизнес-кодом
Third-party registrationза инстансами следит платформа (оркестратор) и сама ведёт реестрнужна платформа — но именно так и делает Kubernetes

Запись в реестре живёт по TTL: инстанс обязан регулярно подтверждать, что он жив (heartbeat). Перестал — запись выкидывают. Разумный TTL — единицы секунд: слишком большой означает, что трафик ещё десятки секунд будет литься в мёртвый инстанс.

Client-side vs server-side discovery

Есть две принципиально разные схемы, кто именно выбирает конечный инстанс.

КритерийClient-sideServer-side
Кто выбирает инстанссам вызывающий сервис: получил список адресов из реестра и балансирует локальнопромежуточный балансировщик (виртуальный IP, прокси, sidecar)
Сетевых хоповодиндва (через прокси)
Сложность в кодевысокая: библиотека в каждом сервисе и на каждом языкенулевая: сервис ходит по одному имени
ПримерEureka + Ribbon, gRPC с headless-сервисомKubernetes Service, Envoy/service mesh

Исторически (эпоха Netflix OSS) правил client-side. Сегодня в 9 случаях из 10 побеждает server-side: он не требует ничего от прикладного кода и одинаково работает для Java, Go и Python.

DNS-подход в Kubernetes

Kubernetes прячет весь этот механизм за самой старой абстракцией интернета — DNS-именем. Вы описываете Service, и он становится стабильным именем поверх нестабильного множества подов:

apiVersion: v1
kind: Service
metadata:
  name: payments        # это и есть имя, по которому будут звать
  namespace: prod
spec:
  selector:
    app: payments       # все поды с этой меткой — кандидаты
  ports:
    - port: 8080
      targetPort: 8080

После этого сервис заказов просто делает запрос по имени — и не знает ни про какие IP:

# внутри кластера, из любого пода
curl http://payments.prod.svc.cluster.local:8080/health

# в том же namespace хватает короткого имени
curl http://payments:8080/health

Под капотом происходит вот что: у Service есть стабильный виртуальный адрес (ClusterIP), DNS кластера отдаёт именно его, а kube-proxy на каждой ноде держит правила iptables/IPVS, которые подменяют этот виртуальный адрес на IP одного из готовых подов. Список готовых подов лежит в объекте EndpointSlice и обновляется автоматически при каждом запуске, падении и выкате. Никакой библиотеки в коде, никакой регистрации — платформа сделала это за вас.

Где именно происходит балансировка

ClusterIP балансирует на уровне соединений (L4), а не запросов. Пока клиент открывает новое TCP-соединение на каждый запрос — всё честно, round-robin. Но современные клиенты держат keep-alive, а gRPC и вовсе мультиплексирует тысячи вызовов в одном HTTP/2-соединении. Итог: соединение один раз «прилипло» к поду №2 — и все запросы час летят туда, а поды №1 и №3 простаивают. Классическая, очень обидная проблема: вы отскейлили сервис до десяти подов, а нагрузку держат два.

Лечится это балансировкой на уровне запросов (L7): sidecar-прокси из service mesh, ingress-контроллер или клиентский балансировщик gRPC поверх headless-сервиса (clusterIP: None, DNS отдаёт сразу все IP подов). Алгоритмы на выбор:

АлгоритмКогда брать
Round-robinдефолт, когда запросы примерно одинаковые
Least connectionsзапросы разной длительности, есть «тяжёлые»
Least request / EWMAинстансы разной мощности или «шумные соседи»
Consistent hashingнужна привязка: кэш на инстансе, sticky-сессия

Health-checks

Discovery бесполезен, если реестр считает живым уже мёртвый инстанс. Поэтому у каждого сервиса есть проверки здоровья. В Kubernetes их три, и путать их — самая частая ошибка новичков.

ПробаВопрос, на который отвечаетЧто делает при провале
startupProbe«Приложение вообще запустилось?»даёт медленному старту время, откладывая остальные пробы
readinessProbe«Готов ли я сейчас принимать трафик?»под убирают из EndpointSlice — трафик перестаёт идти, но под живёт
livenessProbe«Я не завис насмерть?»под убивают и перезапускают
readinessProbe:
  httpGet:
    path: /health/ready   # тут можно проверить коннект к своей БД
    port: 8080
  periodSeconds: 5
  failureThreshold: 2

livenessProbe:
  httpGet:
    path: /health/live    # тут — ТОЛЬКО «процесс жив и отвечает»
    port: 8080
  periodSeconds: 10
  failureThreshold: 3

Запомните разницу до автоматизма: readiness — «уберите от меня трафик, я занят/прогреваюсь/потерял базу», liveness — «я сломан безнадёжно, убейте меня». Проверять в liveness доступность внешней базы — верный способ устроить аварию: база моргнула на 20 секунд, все пятьдесят подов дружно объявили себя мёртвыми, Kubernetes честно их перезапустил — и теперь у вас нет ни базы, ни сервиса, а холодный старт добавит ещё минуту простоя.

Как это работает

Жизненный цикл инстанса с точки зрения discovery:

  1. Под стартует. Его IP уже есть, но в EndpointSlice его нет — трафик не идёт.
  2. Проходит startupProbe, затем первая успешная readinessProbe.
  3. Kubernetes добавляет IP пода в EndpointSlice; kube-proxy на всех нодах обновляет правила. Трафик пошёл.
  4. Пода решают выключить (выкат, скейлдаун). Kubernetes одновременно убирает его из EndpointSlice и посылает процессу SIGTERM.
  5. Приложение обязано доработать уже принятые запросы и только потом завершиться (graceful shutdown в пределах terminationGracePeriodSeconds).

В четвёртом шаге спрятана классическая гонка: удаление из endpoints и рассылка новых правил iptables по всем нодам асинхронны. Процесс может умереть за миллисекунды, а какая-то нода ещё пару секунд будет слать на него трафик — и пользователи поймают 502 на ровном месте, при обычном выкате. Стандартное лекарство — preStop-хук с паузой в 5–10 секунд перед завершением: под перестаёт быть готовым, но продолжает отвечать, пока правила разъезжаются по кластеру.

Частые ошибки

  • Liveness, который проверяет зависимости. Поход в БД или в соседний сервис внутри /health/live превращает мигание сети в лавину перезапусков. Зависимости — в readiness, и то осторожно.
  • Нет readiness вообще. Под получает трафик до того, как прогрелся JIT/пул соединений/кэш. Каждый выкат сопровождается всплеском ошибок и таймаутов.
  • Нет graceful shutdown и preStop. Сотни оборванных запросов на каждом деплое, а команда думает, что «Kubernetes нестабилен».
  • Кэш DNS в приложении. Отдельный привет JVM: старые версии кэшировали DNS-ответы навечно (networkaddress.cache.ttl). Сервис переехал — а клиент год ходит на мёртвый IP. Проверяйте настройки резолвера в своём рантайме.
  • Перекос из-за keep-alive. Отскейлили сервис, нагрузка не разошлась. Смотрите не на число подов, а на RPS по каждому поду: если он неравномерный — вам нужна L7-балансировка.
  • Хардкод IP «на время». Временное решение живёт дольше всех. Единственный допустимый адрес в конфиге — DNS-имя.

Итоги

  • Адреса инстансов в кластере меняются постоянно — discovery отвечает на вопрос «кто жив прямо сейчас».
  • Реестр наполняется либо самими сервисами (self-registration), либо платформой; в Kubernetes это делает сама платформа.
  • Server-side discovery через DNS-имя Service победил client-side: он ничего не требует от прикладного кода.
  • ClusterIP балансирует соединения (L4). При keep-alive и gRPC это даёт перекос — нужна балансировка запросов (L7): mesh, ingress или headless + клиентский LB.
  • readiness убирает трафик, liveness убивает под. Не проверяйте внешние зависимости в liveness — устроите каскадные рестарты.
  • Плавный выкат без ошибок = readiness + graceful shutdown + preStop-пауза.
Проверьте себя
1. Сервис оплаты на 20 секунд потерял связь со своей базой данных. Какая проба должна отреагировать, чтобы под не перезапустили зря?
AlivenessProbe — пусть Kubernetes перезапустит под, это починит соединение
BreadinessProbe — под временно убирают из балансировки, но не убивают
CstartupProbe — она отвечает за доступность зависимостей
DНикакая: проверки здоровья не должны знать про базу
2. Вы отскейлили gRPC-сервис с 2 до 10 подов, но нагрузку по-прежнему держат 2 пода. Наиболее вероятная причина?
AService Discovery не успел обновить EndpointSlice
BgRPC мультиплексирует запросы в одном долгоживущем HTTP/2-соединении, а ClusterIP балансирует соединения (L4), а не запросы
CУ новых подов не прописан livenessProbe
DDNS-имя сервиса указывает только на первые два пода