Перейти до змісту

Service Discovery: як Service знаходить Pods

Service Discovery: як Service знаходить Pods

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Поди приходять і йдуть. Їхні 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 прив'язує конкретного клієнта до того самого поду. Під капотом це модуль iptables recent, що запам'ятовує 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: як кластер виявляє мертві ноди.

Ресурси

Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow

Попередня: Replication Controller | Наступна: Node Health