Service Discovery: як Service знаходить Pods¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Поди приходять і йдуть. Їхні IP-адреси змінюються при перестворенні. Якщо под web-0 мав IP 10.20.0.2, а після рестарту отримав 10.20.0.5, клієнти мусять якось дізнатися нову адресу. Service вирішує це: стабільна точка входу, яка завжди знає, де знаходяться потрібні поди.
Як це працює¶
Service не проксює трафік. Він зберігає список endpoints - IP:port адрес подів, які збігаються з selector. ServiceController оновлює цей список кожні 5 секунд.
graph TB
SVC["Service: web-service<br/>selector: app=web<br/>ports: 80 → 8080"]
P1["web-0<br/>labels: app=web<br/>IP: 10.20.0.2<br/>Phase: Running"]
P2["web-1<br/>labels: app=web<br/>IP: 10.20.0.3<br/>Phase: Running"]
P3["web-2<br/>labels: app=web<br/>IP: 10.20.0.4<br/>Phase: Failed"]
P4["api-0<br/>labels: app=api<br/>IP: 10.20.0.5<br/>Phase: Running"]
SVC -->|"labels match + Running + has IP"| P1
SVC -->|"labels match + Running + has IP"| P2
SVC -.->|"labels match, NOT Running"| P3
SVC -.->|"labels DON'T match"| P4
EP["Endpoints:<br/>10.20.0.2:8080<br/>10.20.0.3:8080"]
P1 --> EP
P2 --> EP
Service type¶
type Service struct {
Kind string `json:"kind"`
Metadata ObjectMeta `json:"metadata"`
Spec ServiceSpec `json:"spec"`
Status ServiceStatus `json:"status"`
}
type ServiceSpec struct {
Selector map[string]string `json:"selector"`
Ports []ServicePort `json:"ports"`
Type ServiceType `json:"type"`
}
type ServicePort struct {
Name string `json:"name,omitempty"`
Port int `json:"port"` // зовнішній порт
TargetPort int `json:"target_port"` // порт поду
NodePort int `json:"node_port,omitempty"`
Protocol string `json:"protocol,omitempty"`
}
type ServiceStatus struct {
ClusterIP string `json:"cluster_ip,omitempty"`
Endpoints []string `json:"endpoints,omitempty"`
}
Port - як клієнт бачить сервіс. TargetPort - реальний порт контейнера. Service з port 80 → target_port 8080 дозволяє подам слухати на 8080, а клієнтам звертатися на 80.
ServiceController¶
type ServiceController struct {
store *Store
logger *log.Logger
}
func (sc *ServiceController) Run(stopCh <-chan struct{}) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
sc.logger.Println("service controller started")
for {
select {
case <-stopCh:
sc.logger.Println("service controller stopped")
return
case <-ticker.C:
sc.reconcile()
}
}
}
func (sc *ServiceController) reconcile() {
services, err := sc.store.ListServices("")
if err != nil { return }
for _, svc := range services {
sc.reconcileService(svc)
}
}
func (sc *ServiceController) reconcileService(svc *Service) {
pods, err := sc.store.ListPods(svc.Metadata.Namespace)
if err != nil { return }
var endpoints []string
for _, pod := range pods {
if matchLabels(pod.Metadata.Labels,
svc.Spec.Selector) &&
pod.Status.Phase == PodRunning {
if pod.Status.PodIP != "" {
for _, port := range svc.Spec.Ports {
endpoints = append(endpoints,
fmt.Sprintf("%s:%d",
pod.Status.PodIP,
port.TargetPort))
}
}
}
}
svc.Status.Endpoints = endpoints
sc.store.UpdateService(svc)
}
Три умови для потрапляння в endpoints:
1. Labels збігаються з selector (matchLabels)
2. Pod Running - Failed і Pending поди не обслуговують трафік
3. Є IP-адреса - под без IP не може отримувати запити
Що відбувається при зміні подів¶
sequenceDiagram
participant SC as ServiceController
participant Store as BoltDB
Note over SC: Reconcile #1 (T+0)
SC->>Store: ListPods("default")
Note over SC: web-0 Running IP:10.20.0.2<br/>web-1 Running IP:10.20.0.3
SC->>Store: UpdateService<br/>endpoints: [10.20.0.2:8080, 10.20.0.3:8080]
Note over Store: web-0 crashes → Phase: Failed
Note over SC: Reconcile #2 (T+5s)
SC->>Store: ListPods("default")
Note over SC: web-0 Failed<br/>web-1 Running IP:10.20.0.3
SC->>Store: UpdateService<br/>endpoints: [10.20.0.3:8080]
Note over Store: ReplicationController створює web-2<br/>Agent запускає → Running, IP:10.20.0.5
Note over SC: Reconcile #3 (T+10s)
SC->>Store: ListPods("default")
Note over SC: web-1 Running IP:10.20.0.3<br/>web-2 Running IP:10.20.0.5
SC->>Store: UpdateService<br/>endpoints: [10.20.0.3:8080, 10.20.0.5:8080]
Ніхто не сповіщує ServiceController про зміни. Він сам перевіряє кожні 5 секунд. Под впав - endpoints оновляться за 5 секунд. Новий под стартував - теж 5 секунд.
Service в JSON¶
{
"kind": "Service",
"metadata": {
"name": "web-service",
"namespace": "default"
},
"spec": {
"selector": {"app": "web"},
"ports": [
{"port": 80, "target_port": 8080}
],
"type": "ClusterIP"
},
"status": {
"endpoints": [
"10.20.0.2:8080",
"10.20.0.3:8080"
]
}
}
Порівняння з Kubernetes¶
У справжньому Kubernetes Service Discovery складніший:
| Kubernetes | Shepherd | |
|---|---|---|
| ClusterIP | Віртуальний IP, kube-proxy маршрутизує | Тільки список endpoints |
| DNS | pod може звернутися до web-service.default.svc |
Немає DNS |
| kube-proxy | iptables/ipvs rules для балансування | Немає проксі |
| Endpoints | Окремий ресурс Endpoints | Поле в Service.status |
| Load balancing | Round-robin через iptables | Клієнт сам вибирає |
У Shepherd endpoints - просто список IP:port. Клієнт (інший под або зовнішня система) сам вибирає, до якого endpoint звернутися. В Kubernetes kube-proxy приховує це за одним ClusterIP.
Що може піти не так¶
Без kube-proxy і ClusterIP наші endpoints - це інформаційний список. Ніхто не маршрутизує трафік. Клієнт має сам вибрати endpoint і звернутися напряму. Якщо вибраний endpoint впав між моментом вибору і запитом - клієнт отримає connection refused.
В Kubernetes kube-proxy постійно оновлює iptables rules і автоматично перенаправляє трафік на живі endpoints.
- Вікно у 5 секунд між reconcile означає, що Failed-под ще до пів-хвилини може лишатися в endpoints (поки агент позначить його Failed, а потім контролер перерахує список). Це класична причина "привидних" connection refused після рестарту.
- Ми додаємо в endpoints под, як тільки він
Running— алеRunningне означаєReady. Без readiness-проби трафік піде на под, що ще піднімає застосунок усередині. У Kubernetes саме для цього існує окремий стан Ready, відмінний від Running.
💡 Цікаві факти¶
- ClusterIP у Kubernetes — це віртуальний IP, якого фізично не існує на жодному інтерфейсі. Жоден контейнер не слухає на цій адресі; kube-proxy просто розставляє iptables/IPVS-правила, що на льоту переписують destination на IP реального поду. Пакет до ClusterIP ніколи не "доходить" до самого ClusterIP.
- Об'єкт
Endpointsу Kubernetes врешті вперся у масштаб: один сервіс із тисячами подів = один величезний об'єкт, який повністю переписувався при кожній зміні. Тому з'явивсяEndpointSlice— розбиття на шматки приблизно по 100 endpoints. - DNS у Kubernetes (CoreDNS) повертає для звичайного сервісу саме ClusterIP, а не IP подів. А ось для headless-сервісу (
clusterIP: None) DNS віддає список усіх IP подів напряму — і це найближче до того, що робить наш Shepherd. - kube-proxy історично пройшов три режими: userspace (проксі реально пропускав трафік через себе), потім iptables, потім IPVS — кожен крок прибирав хоп і збільшував масштаб.
- Режим IPVS не спирається ні на що специфічне для Kubernetes — він керує ядровим IP Virtual Server, тим самим підсистемою балансування, що стоїть за LVS ще з кінця 1990-х. Kubernetes не винаходив кластерне балансування — він перевикористав 20-річний модуль ядра.
- Справжній Kubernetes не опитує за таймером, як наш 5-секундний тік. Контролер EndpointSlice керується подіями через informers: він стежить за API-сервером і перераховує endpoints тієї ж миті, як под змінює фазу — майже миттєві оновлення замість фіксованого polling.
- kube-proxy технічно опційний. Проекти на кшталт Cilium повністю замінюють його на eBPF, роблячи DNAT прямо в ядрі через eBPF-мапи замість дедалі більшої купи iptables-правил — саме це прибирає "обрив" масштабування iptables на великих кластерах.
- Сервісу навіть не потрібен selector. ExternalName-сервіс — це просто CNAME на зовнішнє DNS-ім'я (взагалі без endpoints), а ще можна вручну написати об'єкт Endpoints, щоб націлити Service на IP поза кластером — наприклад, на керовану базу даних.
sessionAffinity: ClientIPприв'язує конкретного клієнта до того самого поду. Під капотом це модуль iptablesrecent, що запам'ятовує source IP на час таймауту — sticky-сесія "для бідних" без жодного коду в застосунку.
Що я зрозумів, поки розбирався з темою¶
Найбільше переключило тумблер у голові розуміння, що Service — це не проксі і не мережевий пристрій, а просто запис у базі, який хтось регулярно перераховує. Я звик думати про сервіс як про "щось, що балансує трафік", а коли написав ServiceController на 30 рядків, дійшло: уся "магія" service discovery — це цикл, що звіряє лейбли і складає список IP. Балансування і маршрутизація — окрема, цілком відділена відповідальність (kube-proxy), і плутати їх із самим discovery — помилка.
Що можна покращити¶
- Додати readiness-перевірку: класти под у endpoints лише коли він реально готовий приймати трафік, а не просто Running.
- Реалізувати найпростіший віртуальний IP + клієнтський балансувальник, щоб клієнт не вибирав endpoint вручну.
- Винести endpoints в окремий ресурс (як EndpointSlice), щоб великий сервіс не переписував увесь об'єкт при кожній зміні.
- Додати DNS-ім'я для сервісу замість роздачі сирого списку IP:port.
- Перевести контролер із фіксованого 5-секундного тіку на оновлення за подіями, щоб endpoints реагували тієї ж миті, як под змінює фазу.
Спробуй сам¶
# Спочатку створи deployment:
sheepctl apply -f examples/deployment.json
# Потім створи service:
sheepctl apply -f - <<'EOF'
{"kind":"Service","metadata":{"name":"web-svc"},"spec":{"selector":{"app":"web"},"ports":[{"port":80,"target_port":8080}]}}
EOF
# Подивись endpoints:
sheepctl get services
# web-svc ClusterIP endpoints: [10.20.0.2:8080, 10.20.0.3:8080]
# Зупини один под - endpoints зменшаться:
sheepctl delete pod web-0
sleep 10
sheepctl get services # один endpoint зник
Service знаходить поди. Далі - Node Health: як кластер виявляє мертві ноди.
Ресурси¶
- Kubernetes Service — модель сервісу та endpoints
- Labels and Selectors — як сервіси обирають поди
- EndpointSlices — чому старий об'єкт Endpoints розбили на шматки
- Virtual IPs and Service Proxies — як kube-proxy перетворює ClusterIP на реальну маршрутизацію
- DNS for Services and Pods — CoreDNS, headless-сервіси, SRV-записи
- Topology Aware Routing — як тримати трафік у межах зони, щоб зрізати cross-zone витрати
- Cilium kube-proxy replacement — маршрутизація сервісів на eBPF замість iptables
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Replication Controller | Наступна: Node Health
