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-side | Server-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:
- Под стартует. Его IP уже есть, но в EndpointSlice его нет — трафик не идёт.
- Проходит
startupProbe, затем первая успешнаяreadinessProbe. - Kubernetes добавляет IP пода в EndpointSlice;
kube-proxyна всех нодах обновляет правила. Трафик пошёл. - Пода решают выключить (выкат, скейлдаун). Kubernetes одновременно убирает его из EndpointSlice и посылает процессу
SIGTERM. - Приложение обязано доработать уже принятые запросы и только потом завершиться (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-пауза.