Service discovery patterns
Все Services работают одинаково на уровне сети (виртуальный IP + правила kube-proxy). Но как клиент находит Service — это отдельная история, и для разных приложений нужны разные подходы. DNS, env vars, headless service, service mesh — у каждого свой контекст.
В этом уроке разбираем все основные паттерны service discovery в Kubernetes, с акцентом на тонкие моменты, которые ломают приложения в проде.
DNS и service discovery в Docker bridge network
DNS-based discovery (recommended)
Главный, дефолтный, рекомендуемый паттерн. Pod пишет на имя Service-а как на обычный хост:
import requests
response = requests.get("http://web/api/users")
# web → DNS → CoreDNS → 10.96.0.10 (ClusterIP) → DNAT → pod
Что происходит за кулисами:
requests.get→ DNS resolvewebчерез системный resolver.- Resolver читает
/etc/resolv.conf, видит ndots:5 и search domains. - Пробует
web.default.svc.cluster.local→ DNS query на CoreDNS. - CoreDNS возвращает ClusterIP
10.96.0.10. - TCP connect на
10.96.0.10:80. - netfilter DNAT на один из endpoint Pod IPs.
- Запрос доходит до backend Pod.
Плюсы:
- Полностью декларативный — приложение не знает ничего о K8s.
- Stable name — IP может меняться, имя остаётся.
- Cross-namespace —
web.other-namespace.svc.cluster.local. - TTL-кеширование через resolver и CoreDNS cache.
Минусы:
- Зависит от CoreDNS — если он лежит, всё ломается.
- DNS-cache в клиенте может держать старый IP после изменения Service (timeout-issues).
- L4 LB — не подходит для долгоживущих connections (см. ниже про gRPC).
Cross-namespace
# Из default namespace писать на api в production:
curl http://api.production
# или
curl http://api.production.svc
# или (FQDN):
curl http://api.production.svc.cluster.local
Имя api.production будет дополнено search domain svc.cluster.local. Это удобный shortcut.
В разных namespaces могут быть Services с одинаковым именем — это нормально. Их различает namespace в DNS-имени. Часто prod/staging инфраструктура использует одинаковые имена db, cache, api в разных namespaces для лёгкого переключения.
Env vars (legacy)
Старый механизм, до DNS. kubelet проактивно инжектит в env Pod-а переменные для каждого Service, который существовал в namespace на момент создания Pod-а.
Для Service web (ClusterIP 10.96.0.10, port 80) каждый новый Pod в том же namespace получит:
WEB_SERVICE_HOST=10.96.0.10
WEB_SERVICE_PORT=80
WEB_PORT=tcp://10.96.0.10:80
WEB_PORT_80_TCP=tcp://10.96.0.10:80
WEB_PORT_80_TCP_PROTO=tcp
WEB_PORT_80_TCP_PORT=80
WEB_PORT_80_TCP_ADDR=10.96.0.10
Имя переменной — название Service в UPPER_CASE с заменой - на _.
$ kubectl exec mypod -- env | grep WEB_SERVICE
WEB_SERVICE_HOST=10.96.0.10
WEB_SERVICE_PORT=80
Race condition
Самая большая проблема env-vars:
T=0: создан Pod app (без Service web)
T=5: env vars Pod app НЕ содержат WEB_SERVICE_HOST
T=10: создан Service web
T=10+: новые Pods будут иметь WEB_SERVICE_HOST, но app не получит — env vars фиксируются на create-time
Если создать Service ПОСЛЕ Pod-а — env vars не появятся, нужно пересоздать Pod чтобы они появились.
Это причина, по которой env-vars discovery не рекомендуется. DNS-based не имеет такой проблемы — он динамический. Env vars остались только для backward compatibility с очень старыми приложениями.
Кейс, когда env vars полезны
Иногда нужен environment-variable интерфейс — например, приложение жёстко читает DB_HOST из env, без рефакторинга. Тогда удобно объявить env вручную, ссылаясь на Service:
spec:
containers:
- name: app
image: myapp:1.0
env:
- name: DB_HOST
value: "db" # просто Service name (через DNS)
- name: DB_PORT
value: "5432"
Это не auto-injected env vars, а просто статичное значение. Приложение делает db:5432 → DNS resolve.
ExternalName: DNS alias
Уже разбирали в уроке 2. Это DNS CNAME alias без ClusterIP, без endpoints, без selectors:
apiVersion: v1
kind: Service
metadata:
name: prod-db
namespace: app
spec:
type: ExternalName
externalName: my-db.aws-rds.amazonaws.com
Pod в namespace app пишет prod-db:5432 → DNS возвращает CNAME → дальше резолвится до реального RDS endpoint.
Когда полезно:
- Migration: external resource будет постепенно переезжать в кластер. Сначала ExternalName на external адрес, потом меняем на обычный Service с selector — приложение продолжает писать на
prod-db. - Abstraction: тестовый environment может ссылаться на mock external endpoint, prod — на реальный, через тот же Service name.
- Cross-cluster: на одном кластере объявить ExternalName, указывающий на Service в другом кластере (через external DNS).
Ограничения:
- Только DNS, никакого port mapping. Если внешний сервис на порту 5432, и Pod пишет на 5432 — ок. Если порты разные — нужен другой механизм.
- Нет TLS termination, нет load balancing, нет health checks.
DNS aliases через Service без selector + manual Endpoints
Альтернатива ExternalName, когда нужен реальный proxy через kube-proxy:
apiVersion: v1
kind: Service
metadata:
name: external-api
spec:
ports:
- port: 80
targetPort: 8080
---
apiVersion: v1
kind: Endpoints
metadata:
name: external-api # имя совпадает с Service
subsets:
- addresses:
- ip: 203.0.113.42 # external IP
ports:
- port: 8080
Тут уже есть ClusterIP, kube-proxy делает DNAT на external IP. Поддерживает port mapping (Service на 80, target на 8080).
Headless service для client-side load balancing
Это критический паттерн, особенно для gRPC и stateful clients.
apiVersion: v1
kind: Service
metadata:
name: api
spec:
clusterIP: None # headless
selector:
app: api
ports:
- port: 8080
DNS возвращает A-записи на каждый Pod IP:
$ dig +short api.default.svc.cluster.local
10.244.1.5
10.244.2.7
10.244.3.9
Клиент видит все backends → может балансить сам, на уровне приложения (L7), может выбирать конкретный, может держать connection pool ко всем.
Killer момент: gRPC over ClusterIP
Это самая частая проблема production deployment-ов gRPC сервисов в K8s.
Проблема
gRPC использует HTTP/2 с long-lived connections. Клиент устанавливает одно TCP-соединение до backend и мультиплексирует через него все RPC-вызовы.
ClusterIP Service делает балансировку на уровне TCP connect через netfilter/IPVS — выбирает endpoint один раз. Дальше весь трафик одного TCP-соединения идёт на один Pod.
Результат: в кластере с 10 gRPC backends один Pod получает 100% трафика, остальные — простаивают. При scale-up новые Pods не получают трафик от существующих клиентов (они не открывают новые соединения). Это известная проблема, на которую наступают почти все.
Решение 1: Headless service + client-side LB
Headless Service возвращает все Pod IPs через DNS. gRPC client с правильной конфигурацией умеет балансить между ними:
# grpc-python: использовать round_robin policy
channel = grpc.insecure_channel(
"api.default.svc.cluster.local:8080",
options=[("grpc.lb_policy_name", "round_robin")],
)
// Go gRPC:
conn, err := grpc.Dial(
"dns:///api.default.svc.cluster.local:8080",
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
)
gRPC client делает DNS resolve, получает все IPs, открывает соединение к каждому, и балансит RPC между ними на L7. Никакого kube-proxy в этой схеме.
Решение 2: Service mesh
Установить Istio, Linkerd или похожий sidecar-based mesh. Каждый Pod получает sidecar proxy (Envoy/Linkerd2-proxy), который перехватывает все исходящие соединения через iptables redirect.
Sidecar делает L7 LB: понимает gRPC, может балансить запросы внутри одного соединения, делает retries, circuit breaking, observability. ClusterIP всё ещё используется (как DNS-name), но фактический пакет проксируется sidecar-ом.
Решение 3: Periodic reconnect
В худшем случае можно настроить gRPC client с keepalive timeout и периодически реконнектиться. При каждом новом connect — новый endpoint от kube-proxy. Это hack, но иногда работает (особенно если deployments редкие).
options = [
("grpc.keepalive_time_ms", 30000),
("grpc.keepalive_timeout_ms", 10000),
("grpc.max_connection_age_ms", 300000), # реконнект каждые 5 минут
]
Reconnect-подход не решает unequal scale: новые Pods не получат трафика до следующего reconnect. И сами reconnects добавляют latency. Это последнее средство, не первое.
Headless service для StatefulSet
В StatefulSet нужно обращаться к конкретному Pod по имени. Например, в Postgres-кластере primary — db-0, replicas — db-1, db-2.
Headless service создаёт DNS-имена на каждый Pod:
apiVersion: v1
kind: Service
metadata:
name: db
spec:
clusterIP: None
selector:
app: db
ports:
- port: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: db
spec:
serviceName: db # ссылка на headless service
replicas: 3
selector:
matchLabels:
app: db
template:
metadata:
labels:
app: db
spec:
containers:
- name: postgres
image: postgres:16
DNS:
db-0.db.default.svc.cluster.local → 10.244.1.5
db-1.db.default.svc.cluster.local → 10.244.2.7
db-2.db.default.svc.cluster.local → 10.244.3.9
db.default.svc.cluster.local → [10.244.1.5, 10.244.2.7, 10.244.3.9]
Приложение знает: primary = db-0, replicas = db-1 и db-2. Имена не зависят от Pod-рестартов (StatefulSet хранит identity).
Service mesh: override kube-proxy
Service mesh (Istio, Linkerd, Consul Connect, Cilium Service Mesh) — это надстройка над K8s сетью, реализующая L7 service discovery + LB + security.
Архитектура:
- Каждый Pod получает sidecar container (Envoy в Istio, linkerd2-proxy в Linkerd).
- iptables в Pod redirect-ит весь ingress/egress трафик в sidecar.
- Sidecar делает L7 LB, mTLS, retries, observability, traffic shaping.
Pod A → 10.96.0.10:80 (Service ClusterIP, через iptables redirect)
→ sidecar A
→ L7 routing: выбирает endpoint Pod B по custom rules
→ mTLS handshake с sidecar B
→ sidecar B → app container B
kube-proxy в такой схеме либо обходится stop-gap (Istio выключает его для меченых Service-ов), либо используется только для control plane.
Service mesh — за scope CKAD. Но знать что такая абстракция есть и зачем — важно: вы встретите её в production. На CKAD вопросы про service discovery — это DNS, headless, ExternalName.
Discovery patterns: cheat sheet
| Pattern | Когда использовать | Минусы |
|---|---|---|
| DNS + ClusterIP | Stateless HTTP, REST API, default | Не подходит для long TCP (gRPC) |
| Env vars | Очень legacy app, не умеющая DNS | Race condition, static |
| ExternalName | Alias на external resource, migration | Только DNS, без port mapping |
| Service без selector + Endpoints | Proxy на external IP с port mapping | Endpoints управляются вручную |
| Headless + client-side LB | gRPC, stateful clients, peer-to-peer | Сложнее в клиенте |
| StatefulSet headless | Database clusters с named pods | Только в StatefulSet |
| Service mesh | Mature production, L7 routing/mTLS/observability | Operational overhead |
Практика: переключение между паттернами
Деплоим простой API:
apiVersion: v1
kind: Service
metadata:
name: api
spec:
selector:
app: api
ports:
- port: 80
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: myapi:1.0
ports:
- containerPort: 8080
Это ClusterIP — DNS-based. Для тестов конвертируем в headless:
spec:
clusterIP: None # ← добавили
# Для DNS test:
kubectl run debug --rm -it --image=busybox -- sh
/ # nslookup api
# С ClusterIP: один Address
# С headless: несколько Address