Service discovery: client-side, server-side и где они ломаются
Сценарий, который повторяется на каждом проекте с микросервисами на старте: команда делает первые два сервиса, прописывает hardcoded URL http://orders-service:8080 в конфиге payments. Работает. Через полгода сервисов десять, у каждого по 5–10 инстансов, в облаке появляется autoscaling, и hardcoded адреса превращаются в источник постоянной боли.
Service discovery — про то, чтобы клиент мог найти живого экземпляра нужного сервиса без захардкоженных адресов. На бумаге задача простая: «дай адрес сервиса orders». В реальности — это балансировка, healthchecks, обновление списков, разрешение конфликтов между несколькими дата-центрами, отказоустойчивость самого discovery-сервиса.
Разберу две основные модели (client-side и server-side), их плюсы и минусы, и как этот выбор влияет на остальную инфраструктуру.
Что вообще делает service discovery
Без discovery клиент должен знать конкретные адреса инстансов сервиса. orders-1.cluster.local:8080, orders-2.cluster.local:8080. Эти адреса меняются при деплое, скейлинге, падении нод. Хардкодить их нереально.
Discovery — это слой, который:
- Регистрирует инстансы при старте: «я orders, мой адрес 10.0.1.5:8080, живой».
- Снимает с регистрации при остановке или падении (часто через healthchecks).
- Отвечает на запрос «дай мне живые инстансы orders».
Эти три функции есть в любой реализации, отличия — где живёт клиентская логика выбора инстанса.
Client-side discovery
Клиент сам спрашивает discovery-сервис «дай мне список orders», получает адреса, сам выбирает один (по round-robin, random, weighted и т.д.), сам делает запрос.
payments client discovery-service
| |
|---> getInstances("orders")--->|
|<----[10.0.1.5, 10.0.1.6]------|
|
| (выбираю 10.0.1.5)
|
|---> HTTP 10.0.1.5:8080------->orders-instance-1
|<------------ response--------|Реализации: Eureka (Netflix), Consul, кастомные на основе ZooKeeper.
Что хорошего:
- Клиент решает, что делать при сбое: ретраить на другой инстанс, балансировать с учётом нагрузки, кешировать список локально.
- Меньше хопов: клиент идёт к сервису напрямую, без proxy.
Что плохо:
- Логика балансировки и discovery — в каждом клиенте. Это значит — в каждом языке, в каждой команде. Многоязычная экосистема страдает: «у нас Java-клиент Eureka работает, а Go-клиент с глюками».
- Клиент зависит от discovery-сервиса. Discovery упал — клиенты не знают, куда стучаться.
- Каждый клиент кеширует свой список. Обновления распространяются медленно.
Server-side discovery
Между клиентом и сервисом стоит балансировщик/прокси. Клиент знает только адрес балансировщика. Балансировщик знает discovery, выбирает инстанс, проксирует запрос.
payments client load balancer orders-instance-1
| | |
|--->HTTP lb:80-->| |
| |---> HTTP 10.0.1.5---->|
| |<------- response------|
|<-- response------|Реализации: Kubernetes Service (с kube-proxy), AWS ELB, NGINX/HAProxy перед сервисами.
Что хорошего:
- Клиент тривиальный: знает один адрес, делает обычный HTTP-запрос. Никакой логики discovery в коде.
- Балансировщик — общая инфраструктура, поддерживается отдельной командой. Не дублируется в каждом сервисе.
- Многоязычная среда работает одинаково: HTTP-клиент любого языка достаточен.
Что плохо:
- Дополнительный сетевой хоп. Латентность возрастает (обычно несущественно, но в high-perf — заметно).
- Балансировщик — точка отказа. Падение балансировщика — все клиенты не достучатся, даже если сервис жив.
- Сложнее тонкие стратегии балансировки (sticky sessions, weighted round-robin, custom failure detection): они выполняются на стороне балансировщика, и иногда требуют отдельной конфигурации.
Service mesh — server-side с особенностями
Современный вариант server-side — service mesh (Istio, Linkerd). Балансировщик — это sidecar Envoy рядом с каждым подом. Клиент идёт в свой sidecar, тот знает топологию и проксирует к нужному инстансу через его sidecar.
payments-pod orders-pod-1
┌──────────────────┐ ┌──────────────────┐
│ app │ │ app │
│ | │ │ ↑ │
│ v │ │ | │
│ envoy-sidecar │--mTLS-->envoy-sidecar--->│ │
└──────────────────┘ └──────────────────┘Технически это «client-side через прокси»: каждый sidecar клиента имеет полный список инстансов orders и сам решает, куда. Но из перспективы кода приложения — это выглядит как server-side: app просто ходит в localhost:port своего sidecar.
Mesh обычно использует control plane (Istiod, Linkerd controller), который собирает endpoint'ы из API Kubernetes и раздаёт sidecar'ам.
Healthchecks
Без healthchecks discovery-сервис не знает, что инстанс умер. Клиент будет получать его в списке, отправлять запросы, получать ошибки.
Подходы:
Active health checks
Discovery-сервис сам периодически дёргает /health на инстансах. Если несколько проверок подряд провалились — инстанс снимается из реестра.
Плюс — discovery всегда знает актуальное состояние. Минус — нагрузка на инстансы (особенно если их сотни).
Passive health checks
Балансировщик/клиент засекает ошибки реальных запросов. Если инстанс отвечает 5xx или таймаутится в N подряд — помечается как нездоровый, не получает запросы временно.
Плюс — никакой дополнительной нагрузки. Минус — обнаружение медленнее: проблему замечают только когда реальный пользовательский запрос пострадал.
Self-reported
Инстанс сам периодически шлёт «я живой» в discovery (heartbeat). Не приходит heartbeat за N секунд — снят с регистрации.
Плюс — даже глубинные проблемы инстанса (зависший процесс, переполненный пул потоков) ловятся, если приложение перестаёт слать heartbeat. Минус — false-positive при сетевых лагах между инстансом и discovery.
На практике обычно комбинация: self-reported heartbeat + active /health checks + passive детекция в балансировщике. Каждый ловит свой класс проблем.
Кеширование и propagation delay
Важная цифра, которую часто забывают: сколько времени проходит между «сервис умер» и «клиенты перестали посылать запросы».
В Eureka — может быть до 90 секунд по дефолту (heartbeat interval × failure threshold + клиентский кеш). Это значит — после смерти инстанса клиенты ещё полторы минуты будут долбить мёртвый адрес.
В Kubernetes Service через iptables — обычно до 30 секунд (kube-proxy syncing).
В Istio — секунды (xDS push с control plane).
Эту цифру надо знать и проектировать circuit breaker'ы и retry-логику с её учётом. Если propagation delay = 30 секунд, ваш breaker должен открываться быстрее, чтобы не страдать от мёртвых инстансов в реестре.
Подводные камни
Несколько вещей, которые я ловил.
Discovery-сервис — single point of failure. Eureka упал — клиенты не знают, куда стучаться. Решение — кеш на клиенте (продолжать использовать последний известный список) + кластеризация самого Eureka. Не один инстанс, не два — три и больше.
DNS как discovery. Простейший вариант — через DNS: orders.svc.cluster.local резолвится в IP-адреса. Удобно, но имеет свои трюки: TTL DNS-записей, кеширование на JVM (java сериализует негативные ответы навсегда — известный гвоздь), невозможность тонкой балансировки.
Зомби-инстансы в реестре. Инстанс упал, но не успел deregister. Heartbeat не приходит, но пока не сработал timeout — он висит. Клиенты пытаются туда стучаться. Стандартный нашатырь — короткие heartbeat-интервалы (5-10 секунд) и быстрый failure threshold.
Несоответствие healthcheck'а реальному состоянию. /health возвращает 200, потому что ходит по тривиальной логике. А база, к которой сервис должен ходить, недоступна. Клиенты получают 200 на /health, но 500 на бизнес-эндпоинты. Healthcheck должен включать проверку критичных downstream-зависимостей (deep healthcheck).
Но не делайте deep слишком тяжёлым: проверка с 5 запросами в БД на каждом healthcheck создаёт нагрузку и может сама стать причиной проблем.
Service discovery без security. Любой может зарегистрироваться как «orders» и принимать чужие запросы. Discovery должен иметь аутентификацию: only-trusted-services могут регистрироваться, и клиенты должны проверять, что отвечает легитимный инстанс (mTLS).
Cross-region. Один Eureka на оба дата-центра — узкое место по сети и точка отказа. Лучше — отдельные кластеры discovery в каждом регионе с механизмом cross-region failover (если основной region упал, клиенты могут пойти в backup).
Когда вам не нужен service discovery
Несколько случаев.
- Один сервис — нечего обнаруживать.
- Статичная инфраструктура без autoscaling — конфиги и так знают адреса.
- Маленький проект (3-5 сервисов): managed-балансировщик облака решает всё.
Service discovery как отдельный продукт оправдан, когда у вас десятки сервисов и динамическое масштабирование. До этого — простые средства (DNS, hardcoded балансировщики) дешевле и понятнее.
Что запомнить
Service discovery — это про абстракцию адреса от ID сервиса. Client-side даёт гибкость и нагружает клиентов; server-side даёт простоту клиентов и зависит от proxy/балансировщика. Service mesh — это server-side с особенностями.
Какую модель выбрать — определяется зрелостью команды, языковой экосистемой и инфраструктурой. Маленький проект — server-side через managed-LB. Многоязычная зрелая инфраструктура — service mesh. Java/Spring-стек на нескольких desktop-командах — client-side через Eureka/Consul тоже работает, но требует общей библиотеки.
Главное — учитывайте propagation delay в дизайне устойчивости. Если ваше discovery считает инстанс живым ещё 60 секунд после реального падения, ваши клиенты обязательно увидят это в виде ошибок. Circuit breaker, retry с jitter, fallback — это не про «правильную теорию», а про закрытие конкретной щели в discovery-механизме.