lenec ru

← все посты

Service discovery: client-side, server-side и где они ломаются

16K

Сценарий, который повторяется на каждом проекте с микросервисами на старте: команда делает первые два сервиса, прописывает 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-механизме.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.