Learning Platform
Глоссарий Troubleshooting
Урок 09.05 · 22 мин
Продвинутый
Service discoveryDNSEnv varsExternalNameHeadlessgRPCService mesh

Service discovery patterns

Все Services работают одинаково на уровне сети (виртуальный IP + правила kube-proxy). Но как клиент находит Service — это отдельная история, и для разных приложений нужны разные подходы. DNS, env vars, headless service, service mesh — у каждого свой контекст.

В этом уроке разбираем все основные паттерны service discovery в Kubernetes, с акцентом на тонкие моменты, которые ломают приложения в проде.


DNS и service discovery в Docker bridge network

Главный, дефолтный, рекомендуемый паттерн. Pod пишет на имя Service-а как на обычный хост:

import requests
response = requests.get("http://web/api/users")
# web → DNS → CoreDNS → 10.96.0.10 (ClusterIP) → DNAT → pod

Что происходит за кулисами:

  1. requests.get → DNS resolve web через системный resolver.
  2. Resolver читает /etc/resolv.conf, видит ndots:5 и search domains.
  3. Пробует web.default.svc.cluster.local → DNS query на CoreDNS.
  4. CoreDNS возвращает ClusterIP 10.96.0.10.
  5. TCP connect на 10.96.0.10:80.
  6. netfilter DNAT на один из endpoint Pod IPs.
  7. Запрос доходит до 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.

TIP

В разных 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 чтобы они появились.

WARNING

Это причина, по которой 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.

gRPC + ClusterIP = unbalanced traffic
gRPC clientgRPC устанавливает одно HTTP/2 соединение и держит его. Все RPC мультиплексируются через стримы внутри.
connect to ClusterIP
kube-proxy DNATnetfilter/IPVS выбирает endpoint при установке TCP. Это решение фиксируется в conntrack на всё соединение.
все RPC через одно TCP
backend Pod A100% RPC client-а попадают сюда. Получает всю нагрузку.
backend Pod BПростаивает.
backend Pod CПростаивает.

Результат: в кластере с 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 минут
]
WARNING

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.

NOTE

Service mesh — за scope CKAD. Но знать что такая абстракция есть и зачем — важно: вы встретите её в production. На CKAD вопросы про service discovery — это DNS, headless, ExternalName.


Discovery patterns: cheat sheet

PatternКогда использоватьМинусы
DNS + ClusterIPStateless HTTP, REST API, defaultНе подходит для long TCP (gRPC)
Env varsОчень legacy app, не умеющая DNSRace condition, static
ExternalNameAlias на external resource, migrationТолько DNS, без port mapping
Service без selector + EndpointsProxy на external IP с port mappingEndpoints управляются вручную
Headless + client-side LBgRPC, stateful clients, peer-to-peerСложнее в клиенте
StatefulSet headlessDatabase clusters с named podsТолько в StatefulSet
Service meshMature production, L7 routing/mTLS/observabilityOperational 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

Проверка знанийKnowledge check
У вас gRPC сервис с 10 backend Pods, exposed как ClusterIP Service. Клиенты — gRPC apps в другом namespace. В мониторинге видно: один Pod получает 100% RPS, остальные простаивают. В чём проблема и как её решить?
ОтветAnswer
Проблема в том, что gRPC использует HTTP/2 с long-lived TCP-соединениями: клиент устанавливает одно соединение и мультиплексирует через него все RPC. ClusterIP делает балансировку на L4 — выбирает endpoint при установке TCP-connect и фиксирует это в conntrack. Дальше весь трафик одного соединения идёт на ОДИН Pod. Один клиент = одно соединение = один backend получает все RPC этого клиента. Если клиентов мало, и все они открыли соединения в момент когда один Pod был чем-то предпочтительнее — он получит 100% трафика. Решения: (1) Использовать **headless service** (clusterIP: None) — клиент через DNS получит все Pod IPs и сделает client-side load balancing на L7 (gRPC supports `round_robin` policy через grpc-go или grpc-python). (2) Поставить **service mesh** (Istio/Linkerd) — sidecar Envoy делает L7 LB на каждый RPC, не на каждое соединение. (3) Hack — настроить keepalive с reconnect каждые N минут, чтобы соединения рандомизировались. Рекомендуется headless + client-side LB как стандарт для gRPC.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Pod создан в namespace `app`. После этого создан Service `cache` в том же namespace. Почему в env Pod-а нет переменной `CACHE_SERVICE_HOST`?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5