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

Label Selectors: як зв'язати ресурси без foreign keys

Label Selectors: як зв'язати ресурси без foreign keys

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


У SQL ти зв'язуєш таблиці через foreign keys. У Kubernetes зв'язок між ресурсами - через labels. Deployment не має поля "мої поди". Він має selector: "всі поди з labels app=web". Service не знає конкретних подів. Він знає selector. Це фундаментальне рішення, яке впливає на всю архітектуру.

Labels - це просто map

type ObjectMeta struct {
    Name      string            `json:"name"`
    Namespace string            `json:"namespace"`
    UID       string            `json:"uid"`
    Labels    map[string]string `json:"labels,omitempty"`
    CreatedAt time.Time         `json:"created_at"`
}

Labels - map[string]string. Ключ і значення - довільні рядки. Жодної схеми, жодної валідації. Конвенція - через домовленість: app=web, tier=frontend, version=v2.

matchLabels - 8 рядків, які зв'язують все

func matchLabels(nodeLabels, selector map[string]string) bool {
    if len(selector) == 0 {
        return true // пустий selector збігається з будь-чим
    }
    for k, v := range selector {
        if nodeLabels[k] != v {
            return false
        }
    }
    return true
}

Логіка проста: кожен ключ selector повинен бути в labels ресурсу з тим самим значенням. Це AND-логіка: app=web AND tier=frontend означає обидва повинні збігатися.

Хто з ким зв'язаний

graph TB
    DEP["Deployment: web<br/>selector: app=web, tier=frontend"]
    SVC["Service: web-svc<br/>selector: app=web"]

    P1["Pod: web-0<br/>labels: app=web, tier=frontend"]
    P2["Pod: web-1<br/>labels: app=web, tier=frontend"]
    P3["Pod: debug-pod<br/>labels: app=web, tier=debug"]
    P4["Pod: api-0<br/>labels: app=api"]

    DEP -->|"match<br/>(app=web AND tier=frontend)"| P1
    DEP -->|"match"| P2
    DEP -.->|"no match<br/>(tier != frontend)"| P3

    SVC -->|"match (app=web)"| P1
    SVC -->|"match"| P2
    SVC -->|"match"| P3
    SVC -.->|"no match"| P4

Зверни увагу: Service з selector app=web збирає три поди (web-0, web-1 і debug-pod), а Deployment з selector app=web, tier=frontend - тільки два (web-0 і web-1). Різна кількість ключів = різна вибірка.

Три типи зв'язків через labels

Deployment -> Pods

ReplicationController знаходить поди Deployment'а через selector:

func (rc *ReplicationController) reconcileDeployment(
    dep *Deployment) {
    allPods, _ := rc.store.ListPods(dep.Metadata.Namespace)

    var matchingPods []*Pod
    for _, pod := range allPods {
        if matchLabels(pod.Metadata.Labels, dep.Spec.Selector) {
            matchingPods = append(matchingPods, pod)
        }
    }

    current := len(matchingPods)
    desired := dep.Spec.Replicas
    // scale up або down...
}

Deployment не зберігає список подів. Кожен reconcile він шукає їх заново.

Service -> Pods

ServiceController будує endpoints з Running подів, що збігаються:

func (sc *ServiceController) reconcileService(svc *Service) {
    pods, _ := sc.store.ListPods(svc.Metadata.Namespace)

    var endpoints []string
    for _, pod := range pods {
        if matchLabels(pod.Metadata.Labels, svc.Spec.Selector) &&
            pod.Status.Phase == PodRunning &&
            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)
}

Три умови: labels збігаються, під Running, є IP. Якщо під впав - він автоматично зникає з endpoints на наступному reconcile.

Pod -> Node (nodeSelector)

Scheduler перевіряє, чи labels ноди збігаються з nodeSelector поду:

func (s *Scheduler) filterNodes(nodes []*Node,
    pod *Pod) []*Node {
    var feasible []*Node
    for _, node := range nodes {
        // ...
        if !matchLabels(node.Metadata.Labels,
            pod.Spec.NodeSelector) {
            continue
        }
        feasible = append(feasible, node)
    }
    return feasible
}

Под з nodeSelector: {gpu: "true"} потрапить тільки на ноду з label gpu=true.

Як labels потрапляють на под

Коли ReplicationController створює под для Deployment'а, він копіює labels з template і додає labels з selector:

func (rc *ReplicationController) createPodForDeployment(
    dep *Deployment, index int) {
    pod := &Pod{
        Metadata: ObjectMeta{
            Name: fmt.Sprintf("%s-%d", dep.Metadata.Name, index),
            Labels: mergeLabels(
                dep.Spec.Template.Metadata.Labels,
                dep.Spec.Selector),
        },
        Spec: dep.Spec.Template.Spec,
    }
    rc.store.CreatePod(pod)
}

func mergeLabels(sets ...map[string]string) map[string]string {
    result := make(map[string]string)
    for _, s := range sets {
        for k, v := range s { result[k] = v }
    }
    return result
}

Чому не foreign keys

Foreign keys Labels
Створення поду Оновити Deployment.pods[] Нічого, labels вже є
Перестворення поду (новий UID) Оновити foreign key Labels ті самі, зв'язок зберігається
Видалення Deployment CASCADE DELETE подів Контролер видалить при reconcile
Динамічне додавання INSERT в зв'язкову таблицю Додай label на под
Query JOIN + WHERE Лінійний перебір + matchLabels

Labels простіші для динамічних систем, де ресурси постійно створюються і видаляються.

Нюанс

Label matching - повний перебір. Кожен reconcile контролер проходить по всіх подах в namespace і перевіряє labels кожного. Для 10 подів це миттєво. Для 10,000 - вже помітно.

В Kubernetes це вирішено через informers і кеші: при старті контролер завантажує всі поди в пам'ять, потім отримує тільки зміни через Watch API. У Shepherd ми кожні 5 секунд читаємо всі поди з BoltDB. Для навчального проекту - нормально.

Другий момент - немає валідації labels. Нічого не заважає поставити app=web на под, який не має стосунку до web deployment'у. В Kubernetes Admission Webhooks можуть це перевіряти.

Третій - selectors можуть перетинатися. Якщо два Deployments мають selector app=web, обидва "усиновлять" одні й ті самі поди і почнуть воювати за кількість реплік. Наш matchLabels цього не помічає. В Kubernetes для цього у Deployment є selector плюс унікальний pod-template-hash на ReplicaSet.

Цікаві факти

  • У "великому" Kubernetes selectors бувають двох видів: equality-based (app=web) і set-based (tier in (frontend, cache), env != prod). Наш matchLabels реалізує лише перший, найпростіший.
  • Ключ label у Kubernetes може мати опціональний префікс-домен (example.com/team), а значення обмежене 63 символами і має відповідати DNS-подібному формату. "Довільний рядок" — це спрощення, яке працює тільки в навчальному проекті.
  • Labels і annotations виглядають однаково (обидва map[string]string), але призначення різне: за labels можна шукати й селектити, annotations — лише сховище метаданих, по них селектити не можна.
  • Той самий механізм selector лежить в основі майже всього: Services, NetworkPolicies, PodAffinity, navіть kubectl get pods -l app=web. Один примітив — десятки застосувань.

Що я зрозумів, поки розбирався з темою

Поки писав ці вісім рядків matchLabels, до мене дійшло, чому Kubernetes свідомо відмовився від foreign keys. Не тому, що labels "елегантніші" — а тому, що под у цій системі ефемерний. Він народжується і вмирає десятки разів на день, щоразу з новим UID. Будь-яке посилання за ID протухло б миттєво. Labels же переживають перестворення: новий под з тими самими labels автоматично знову "свій". Loose coupling тут не стиль — це єдиний спосіб звʼязати те, що постійно зникає.

Що можна покращити

  • Додати set-based операції (in, notin, exists) — це перетворює пласкі labels на повноцінну мову запитів.
  • Збудувати інвертований індекс label -> []podUID замість лінійного перебору. Тоді вибірка за selector стає O(збігів), а не O(всіх подів).
  • Ввести перевірку перетину selectors на створення Deployment/Service і відхиляти конфліктні — щоб два контролери не билися за одні поди.
  • Додати валідацію формату ключів і значень (довжина, дозволені символи), як це робить Kubernetes, щоб ловити помилки до запису в стор.

Спробуй сам

# Подивись labels подів:
sheepctl describe pod web-0 | grep -A5 labels
# Створи service з тим самим selector:
sheepctl apply -f - <<'EOF'
{"kind":"Service","metadata":{"name":"test-svc"},"spec":{"selector":{"app":"web"},"ports":[{"port":80,"target_port":8080}]}}
EOF
sheepctl get services  # побачиш endpoints з Running подів

Далі - чому под завжди створюється як Pending і що це дає для reliability.

Ресурси

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

Попередня: Desired vs Actual State