kubelet и kube-proxy: что работает на каждой ноде
На каждом worker-узле работают три обязательных процесса: kubelet (primary node agent, запускает Pod-ы через CRI), kube-proxy (реализует Services через netfilter), и container runtime (containerd или CRI-O — фактически выполняет контейнеры). Плюс DaemonSet-ы от CNI (Calico, Cilium, …) и CSI плагинов.
В этом уроке препарируем работу узла до уровня gRPC вызовов между kubelet и runtime, iptables-цепочек kube-proxy, и того, что такое ClusterIP с точки зрения netfilter. Это килер-момент модуля.
Namespaces и cgroups: фундамент контейнерной изоляции
kubelet: что и как делает primary node agent
kubelet — Go-процесс на каждой ноде. Его задачи:
- Watch на apiserver объекты Pod-ов с
.spec.nodeName == own-hostname(или managed static pods). - Для каждого Pod-а:
- Pull container images через CRI image service.
- Создать Pod sandbox (network namespace, cgroup, pause container).
- Запустить контейнеры через CRI runtime service.
- Mount volumes (через CSI или встроенные volume plugins).
- Запускать probes (liveness, readiness, startup).
- Обновлять
.statusPod-а в apiserver (containerStatuses, podIP, conditions). - Обновлять Lease объект ноды (heartbeat).
- Cadvisor (встроен в kubelet) — собирает stats по ресурсам контейнеров, экспортирует через
/stats/summary.
kubelet listens:
:10250 — HTTPS, server для apiserver проксирования (logs, exec, port-forward, attach)
:10248 — HTTP, /healthz (только localhost)
(Старый read-only port :10255 удалён из kubelet давно — на v1.35 его нет; stats доступны через /stats/summary на :10250 после авторизации.)
CRI: gRPC между kubelet и runtime
Container Runtime Interface — gRPC API из двух сервисов:
service RuntimeService {
rpc RunPodSandbox(RunPodSandboxRequest) returns (RunPodSandboxResponse);
rpc StopPodSandbox(StopPodSandboxRequest) returns (StopPodSandboxResponse);
rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse);
rpc StartContainer(StartContainerRequest) returns (StartContainerResponse);
rpc StopContainer(StopContainerRequest) returns (StopContainerResponse);
rpc ExecSync(ExecSyncRequest) returns (ExecSyncResponse);
rpc Exec(ExecRequest) returns (ExecResponse);
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
rpc ContainerStats(ContainerStatsRequest) returns (ContainerStatsResponse);
// ...
}
service ImageService {
rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
rpc PullImage(PullImageRequest) returns (PullImageResponse);
rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse);
rpc ImageStatus(ImageStatusRequest) returns (ImageStatusResponse);
}
Когда kubelet запускает Pod, цепочка примерно такая:
До v1.24 Docker как runtime поддерживался через dockershim — shim внутри kubelet, который транслировал CRI вызовы в Docker API. В v1.24 dockershim удалён. Сейчас работает только через CRI-совместимые runtimes: containerd (с CRI plugin), CRI-O, krunvm для виртуализованных контейнеров, gVisor, Kata Containers.
Static pods: pods без apiserver
Помимо Pod-ов из apiserver, kubelet может запускать static pods — Pod-ы из локальных манифестов в /etc/kubernetes/manifests/. Это обычные YAML-файлы, kubelet их следит inotify-ом и применяет как Pod-ы.
ls /etc/kubernetes/manifests/
# etcd.yaml
# kube-apiserver.yaml
# kube-controller-manager.yaml
# kube-scheduler.yaml
В kubeadm-кластере все control plane процессы — это static pods! kubelet запускает их сам, без участия scheduler-а и apiserver-а. Это решает классическую chicken-and-egg проблему: чтобы запустить apiserver через apiserver, нужно… уже иметь apiserver.
Static pod-ы регистрируются в apiserver как mirror pods — обычные Pod-объекты с suffix -<nodename>:
kubectl get pods -n kube-system
# NAME READY STATUS
# etcd-master-1 1/1 Running
# kube-apiserver-master-1 1/1 Running
# kube-controller-manager-master-1 1/1 Running
# kube-scheduler-master-1 1/1 Running
Mirror pod read-only с точки зрения apiserver — изменить через kubectl edit нельзя. Чтобы изменить — нужно править manifest файл на диске ноды.
Static pods полезны для bootstrap (control plane) и для случаев, когда вам нужен Pod, который запустится даже если apiserver недоступен (например, network agent на ноде). В CKAD scope static pods упоминаются — экзамен может попросить создать static pod в /etc/kubernetes/manifests/.
kube-proxy: что такое Service на самом деле
Теперь killer-момент модуля. ClusterIP — это не реальный IP. На нём никто не слушает, никто не отвечает по нему через ping. Это правило в netfilter, которое DNAT-ит трафик на endpoint Pod.
# Создаём Service
kubectl expose deployment nginx --port=80 --target-port=80
kubectl get svc nginx
# NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
# nginx ClusterIP 10.96.123.45 <none> 80/TCP 1m
# Можно ли запинговать ClusterIP?
ping 10.96.123.45
# нет ответа — никто не отвечает на ICMP
# Но curl работает
curl http://10.96.123.45
# <html><body>Welcome to nginx!</body></html>
Что происходит при curl 10.96.123.45:80:
- ядро Linux в network namespace клиента смотрит routing table —
10.96.0.0/12(ClusterIP CIDR) маршрутизируется через default route на eth0 (или другой интерфейс). - Пакет идёт через netfilter PREROUTING (если входящий) или OUTPUT (если исходящий с самой ноды) хук.
- kube-proxy установил правила iptables: dst=10.96.123.45:80 → DNAT → 10.244.1.5:80 (один из Pod IP, выбранный random).
- Пакет с DNAT-ом идёт дальше по сети (через CNI overlay или routing) до ноды с целевым Pod-ом.
- Pod видит пакет с dst=своему IP, обрабатывает, отвечает. Ответ проходит обратный SNAT через conntrack state.
kube-proxy modes
kube-proxy умеет реализовать Service через три механизма netfilter:
iptables (default)
Стандартный и default-режим до сих пор (в v1.35 включая). kube-proxy генерирует chains в netfilter: KUBE-SERVICES → KUBE-SVC-* → KUBE-SEP-*. Linear search по правилам — медленный при тысячах Services (latency растёт линейно).
IPVS (GA с v1.11)
Использует IPVS (kernel-level Layer 4 load balancer). Hash-таблица вместо linear chain — O(1) lookup. Намного быстрее для больших кластеров (>1000 Services). Несколько scheduling алгоритмов: rr, lc, sh, dh.
nftables (alpha v1.29, beta v1.31, GA v1.33)
Замена iptables — современный API netfilter. Поддерживает set/map с O(1) lookup, верифицируется в kernel, легче отлаживать. По производительности близко к IPVS, но без зависимости от ipvs модуля. На v1.35 включается через --proxy-mode=nftables, default остаётся iptables.
iptables mode под микроскопом
Когда kube-proxy работает в iptables mode, он создаёт цепочки в таблице nat:
# Главная entry point
*nat
:PREROUTING ACCEPT
-A PREROUTING -m comment --comment "kubernetes service portals" -j KUBE-SERVICES
# KUBE-SERVICES — DNAT для всех Services
:KUBE-SERVICES
# match Service ClusterIP → jump в Service-specific chain
-A KUBE-SERVICES -d 10.96.123.45/32 -p tcp -m tcp --dport 80 -j KUBE-SVC-NPX46M4PTMTKRN6Y
# KUBE-SVC-NPX46M4PTMTKRN6Y — load balancing между endpoints
:KUBE-SVC-NPX46M4PTMTKRN6Y
# 33% chance → endpoint 1
-A KUBE-SVC-NPX46M4PTMTKRN6Y -m statistic --mode random --probability 0.33333 -j KUBE-SEP-AAAAA
# 50% chance из оставшихся 67% → endpoint 2
-A KUBE-SVC-NPX46M4PTMTKRN6Y -m statistic --mode random --probability 0.50000 -j KUBE-SEP-BBBBB
# fallback → endpoint 3
-A KUBE-SVC-NPX46M4PTMTKRN6Y -j KUBE-SEP-CCCCC
# KUBE-SEP-AAAAA — endpoint specific (один Pod)
:KUBE-SEP-AAAAA
# SNAT для hairpin (когда Pod-source = Pod-destination, чтобы ответ вернулся через kube-proxy)
-A KUBE-SEP-AAAAA -s 10.244.1.5/32 -j KUBE-MARK-MASQ
# реальный DNAT на Pod IP:port
-A KUBE-SEP-AAAAA -p tcp -m tcp -j DNAT --to-destination 10.244.1.5:80
Чтобы посмотреть это на реальной ноде:
# Цепочки в nat-таблице
iptables -t nat -L KUBE-SERVICES -n --line-numbers | head -20
# Конкретный Service
iptables-save | grep KUBE-SVC | head
iptables-save | grep KUBE-SEP | head
# В nftables mode
nft list table ip nat | head -50
Probability cascade важна: 33% → 50% → fallback. Не три раза по 33%! Каждый probability check — это --mode random --probability 1/(N-i), где i — порядковый номер. Так math работает: каждый endpoint получает равную долю траффика.
IPVS mode
Вместо linear chain iptables использует kernel IPVS module:
ipvsadm -L -n
# IP Virtual Server version 1.2.1 (size=4096)
# Prot LocalAddress:Port Scheduler Flags
# -> RemoteAddress:Port Forward Weight ActiveConn InActConn
# TCP 10.96.123.45:80 rr
# -> 10.244.1.5:80 Masq 1 0 0
# -> 10.244.2.7:80 Masq 1 0 0
# -> 10.244.3.9:80 Masq 1 0 0
rr — round-robin. Другие schedulers: lc (least connections), sh (source hashing — sticky sessions), dh (destination hashing).
Виртуальный server (ClusterIP) — на dummy interface kube-ipvs0:
ip addr show kube-ipvs0
# Виртуальный интерфейс с ClusterIP-ами всех Services
IPVS на больших кластерах (1000+ Services, 10000+ endpoints) даёт огромную разницу — iptables linear search становится bottleneck-ом.
EndpointSlices: что слушает kube-proxy
kube-proxy на ноде WATCH-ит EndpointSlice-объекты (не сами Service-ы):
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: nginx-abc12
namespace: default
labels:
kubernetes.io/service-name: nginx
addressType: IPv4
ports:
- name: http
port: 80
protocol: TCP
endpoints:
- addresses: ["10.244.1.5"]
conditions:
ready: true
serving: true
terminating: false
nodeName: worker-1
zone: us-east-1a
- addresses: ["10.244.2.7"]
conditions:
ready: true
serving: true
terminating: false
nodeName: worker-2
zone: us-east-1b
Один Service может иметь несколько EndpointSlice-ов (шардирование по 100 endpoint-ов в shard). Это масштабируемее, чем старые Endpoints-объекты (один Endpoints на Service, мог расти до мегабайт при тысяче endpoint-ов).
Когда Pod удаляется, kubelet ставит terminating: true в EndpointSlice и ready: false. kube-proxy убирает endpoint из rotation. Pod ещё некоторое время живой (terminationGracePeriodSeconds), но трафик на него уже не шлют. Это работает только если кубернетес-aware health check на LB и preStop хук в Pod-е дают graceful shutdown.
CNI: сеть Pod-ов
Container Network Interface — спецификация, как kubelet (через container runtime) конфигурирует сеть Pod-а. CNI plugin — это binary в /opt/cni/bin/, который вызывается с CNI command (ADD/DEL/CHECK) и stdin-ом JSON-конфигом.
CNI plugins для K8s:
- Calico — BGP + IP-in-IP / VXLAN encapsulation; полная NetworkPolicy реализация; eBPF mode для производительности.
- Cilium — eBPF-based, очень быстрый; продвинутая NetworkPolicy (L7); встроенная замена kube-proxy через eBPF.
- Flannel — простой VXLAN overlay; нет NetworkPolicy.
- Weave Net — encrypted mesh.
- AWS VPC CNI — Pod IP-ы из VPC subnet (нативная маршрутизация).
- GKE Dataplane V2 / Azure CNI Overlay — managed cloud CNI.
Когда kubelet запускает Pod sandbox:
1. Создаёт network namespace.
2. Вызывает CNI plugin:
/opt/cni/bin/calico ADD
stdin: {
"cniVersion": "1.0.0",
"name": "k8s-pod-network",
"type": "calico",
"ipam": {"type": "calico-ipam"},
"containerID": "abc...",
"ifName": "eth0",
"netns": "/proc/12345/ns/net"
}
3. CNI plugin:
- Allocates IP from IPAM (172.17.5.7)
- Creates veth-pair: veth-cali-abc (host) + eth0 (Pod ns)
- Sets routing on host: 172.17.5.7 → veth-cali-abc
- Sets routing in Pod ns: 0.0.0.0/0 → 169.254.1.1 (link-local)
- Outputs JSON with assigned IP
4. kubelet записывает IP в .status.podIP
NetworkPolicy enforcement
Сам Kubernetes не enforce-ит NetworkPolicy — это работа CNI plugin. Calico, Cilium, Weave умеют. Flannel — нет (нужно ставить отдельно Canal = Flannel + Calico).
Когда вы создаёте NetworkPolicy объект:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: db-only-from-app
spec:
podSelector:
matchLabels:
role: db
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
role: app
ports:
- protocol: TCP
port: 5432
CNI plugin watch-ит NetworkPolicy объекты, и для каждого Pod-а в кластере применяет соответствующие правила (через iptables, eBPF, OVS — в зависимости от CNI).
CSI: storage plugins
Container Storage Interface — спецификация для volume provisioning. CSI driver — это два компонента:
- Controller plugin — централизованный (часто Deployment в kube-system). Делает CreateVolume / DeleteVolume в backend (AWS EBS, Ceph, NFS).
- Node plugin — DaemonSet на каждой ноде. Делает NodePublishVolume (mount на конкретной ноде).
# StorageClass — описывает backend
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: ebs.csi.aws.com
parameters:
type: gp3
iops: "3000"
throughput: "125"
volumeBindingMode: WaitForFirstConsumer
# PVC — запрос на storage
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: my-data
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 10Gi
storageClassName: fast-ssd
Цепочка:
- PVC создан, ждёт provisioning.
- external-provisioner (sidecar в CSI controller plugin) видит PVC, вызывает CSI driver:
CreateVolume(size=10Gi, params). - CSI driver делает AWS API:
aws ec2 create-volume --size 10 --volume-type gp3. Получает VolumeIDvol-abc123. - external-provisioner создаёт PV объект в K8s с этим VolumeID, привязывает к PVC.
- Pod, использующий PVC, попадает на ноду.
- external-attacher делает
ControllerPublishVolume— attach EBS к EC2 instance. - kubelet вызывает CSI node plugin:
NodeStageVolume(mount к global mount point на ноде),NodePublishVolume(bind-mount в Pod-ный mount namespace). - Контейнер видит volume по
mountPath.
Killer-момент: трафик от Pod-A до Pod-B через Service
Соберём всё вместе. Pod-A в namespace default на node worker-1 хочет вызвать http://nginx.default.svc.cluster.local.
1. Pod-A: curl http://nginx.default.svc.cluster.local
2. DNS resolve через CoreDNS (Service в kube-system):
- Pod-A читает /etc/resolv.conf (kubelet положил туда CoreDNS IP)
- UDP запрос на CoreDNS:53
- CoreDNS отвечает: nginx.default.svc.cluster.local → 10.96.123.45 (ClusterIP)
3. Pod-A открывает TCP connection на 10.96.123.45:80
4. ядро Linux в Pod-namespace:
- Routing: 10.96.0.0/12 через default GW (на host через veth)
- Пакет уходит на host network namespace через veth pair
5. Host network namespace, netfilter NAT prerouting:
- iptables правило: dst=10.96.123.45:80 → KUBE-SVC-NPX46M4PTMTKRN6Y
- Random choice одного из 3 endpoint-ов: dst=10.244.2.7:80 (Pod-B на worker-2)
- DNAT в conntrack, dst-IP пакета теперь 10.244.2.7:80
6. CNI routing на host:
- 10.244.2.0/24 — это worker-2
- Пакет идёт через overlay (VXLAN) или native routing на worker-2
7. На worker-2 пакет приходит к Pod-B (10.244.2.7):
- CNI на worker-2 знает 10.244.2.7 → veth → Pod-B namespace
- Pod-B принимает пакет, обрабатывает
8. Ответ:
- Pod-B отвечает на src=10.244.2.7, dst=Pod-A IP
- Возвращается на worker-1 через тот же overlay
- На worker-1 conntrack делает reverse SNAT (восстанавливает Service IP как src)
- Пакет приходит в Pod-A namespace с src=10.96.123.45 (как будто от Service)
Никто не “проксировал” соединение — Linux kernel netfilter сделал NAT, и connection идёт напрямую между Pod-A и Pod-B. kube-proxy только настраивает правила, в data path его нет. Это объясняет почему K8s networking быстрый — нет user-space прокси, только kernel.
В IPVS mode цепочка такая же, но IPVS вместо iptables делает lookup в hash table. В nftables mode — set lookup. С eBPF (Cilium) — даже не netfilter, а eBPF program в TC ingress hook, который делает то же самое ещё раньше в data path.