kube-apiserver: REST API, auth, admission, watch streams
kube-apiserver — мозг кластера. Это HTTP/HTTPS REST API сервер, через который проходят все запросы: команды от kubectl, watch-стримы kubelet-ов, write-запросы controllers, аутентификация ServiceAccount-ов. Один процесс, один порт :6443, и при этом единственная точка, имеющая read/write доступ к etcd.
В этом уроке препарируем kube-apiserver до уровня конкретных HTTP-запросов, фаз admission control, и того, как watch-стримы реально работают на сетевом уровне.
Анатомия HTTP-запроса: что внутри request и response
kube-apiserver как HTTP-сервер
Структурно это монолитный Go-процесс, обслуживающий несколько HTTP endpoint-групп.
GET /healthz — liveness check (200 если жив)
GET /readyz — readiness check (готов принимать traffic)
GET /livez — расширенный health для подсистем
GET /version — версия API сервера (gitVersion, buildDate, ...)
GET /metrics — Prometheus metrics
GET /api/v1/pods — core/v1 API group: pods, services, configmaps, ...
GET /apis/apps/v1/deployments — apps API group: deployments, replicasets, daemonsets
GET /apis/batch/v1/jobs — batch API group: jobs, cronjobs
GET /apis/networking.k8s.io/v1/networkpolicies — networking API group
GET /openapi/v3 — OpenAPI 3.0 schema всех ресурсов
GET /api/v1/namespaces/{ns}/pods/{name}/log — proxy на kubelet logs
POST /api/v1/namespaces/{ns}/pods/{name}/exec — proxy на kubelet exec (WebSocket / SPDY)
core API group исторически живёт под /api/v1 (без слова “core” в URL). Все остальные группы — под /apis/<group>/<version>. Это историческая аномалия: первая версия API имела только /api, добавление groups сделали через /apis, чтобы не ломать совместимость.
API groups и versions
Каждый ресурс Kubernetes принадлежит к group/version. Это позволяет независимо эволюционировать разные части API.
Полный список групп можно получить через kubectl api-resources (показывает все ресурсы и их groups) или kubectl api-versions (только список group/version пар).
kubectl — это просто HTTP клиент
Здесь живёт ключевое понимание: kubectl ничего не делает сам. Это удобная обёртка, которая превращает команды в HTTP-запросы. Всё, что доступно через kubectl, доступно через curl к api-server-у.
# kubectl get pods
GET https://api-server:6443/api/v1/namespaces/default/pods
# kubectl get deployments -n kube-system
GET https://api-server:6443/apis/apps/v1/namespaces/kube-system/deployments
# kubectl create -f pod.yaml
POST https://api-server:6443/api/v1/namespaces/default/pods
Content-Type: application/json
# (YAML внутри kubectl сконвертирован в JSON)
# kubectl delete pod nginx
DELETE https://api-server:6443/api/v1/namespaces/default/pods/nginx
# kubectl patch deployment myapp -p '{"spec":{"replicas":5}}'
PATCH https://api-server:6443/apis/apps/v1/namespaces/default/deployments/myapp
Content-Type: application/strategic-merge-patch+json
# kubectl get pods --watch
GET https://api-server:6443/api/v1/namespaces/default/pods?watch=true&resourceVersion=12345
Запустите kubectl get pods -v=8 2>&1 | grep -E '(GET|POST|PUT|DELETE|PATCH)' — увидите все HTTP-запросы, которые kubectl делает. --v=9 покажет ещё и тела запросов/ответов. Это незаменимо для отладки RBAC и admission-проблем.
Watch streams — самое интересное
Watch — фундаментальный примитив Kubernetes. Через него работают абсолютно все компоненты: kubelet наблюдает за своими Pod-ами, scheduler — за unscheduled, controllers — за своими ресурсами.
Технически watch — это HTTP GET с ?watch=true, который не возвращает ответ сразу. Соединение остаётся открытым, и сервер шлёт events чанками по мере их появления.
GET /api/v1/namespaces/default/pods?watch=true&resourceVersion=12345&allowWatchBookmarks=true HTTP/2
HTTP/2 200 OK
Content-Type: application/json
Transfer-Encoding: chunked
{"type":"ADDED","object":{"kind":"Pod","metadata":{"name":"nginx-abc","resourceVersion":"12346",...}}}
{"type":"MODIFIED","object":{"kind":"Pod","metadata":{"name":"nginx-abc","resourceVersion":"12350",...}}}
{"type":"BOOKMARK","object":{"kind":"Pod","metadata":{"resourceVersion":"12400"}}}
{"type":"DELETED","object":{"kind":"Pod","metadata":{"name":"nginx-abc","resourceVersion":"12410",...}}}
...
Каждая строка — отдельный event. Транспорт — HTTP/1.1 chunked transfer encoding (или HTTP/2 streaming). Клиент держит соединение открытым потенциально часами. Если оно рвётся — клиент переоткрывает с указанием resourceVersion последнего полученного event, и сервер шлёт всё, что было после.
Resource version и watch контракт
resourceVersion — это монотонно растущее число, фактически revision из etcd. Каждое изменение объекта получает новый, больший resourceVersion. Это даёт строгую гарантию упорядоченности.
Контракт watch:
- Клиент делает
LIST→ получает текущий snapshot всех объектов + последний resourceVersion коллекции. - Клиент делает
WATCHс этим resourceVersion → сервер шлёт все события, начиная сразу после этого revision. - Клиент гарантированно увидит каждое изменение хотя бы раз (но порядок ADDED/MODIFIED/DELETED строго упорядочен).
- Если клиент отстал слишком сильно (compaction в etcd выкинул нужный revision) → сервер возвращает
410 Gone, клиент должен сделать новый LIST.
“Хотя бы раз” — это about MODIFIED events. Если объект изменили 5 раз быстро подряд, клиент может увидеть только последнее значение. Watch не гарантирует, что вы увидите КАЖДОЕ промежуточное состояние — только что финальное состояние будет правильным. Это называется “level-triggered” vs “edge-triggered” подход.
BOOKMARK events
Чтобы клиент знал актуальный resourceVersion даже без изменений в watched-ресурсах, сервер периодически шлёт BOOKMARK-события — синтетические события с обновлённым resourceVersion. Это позволяет клиенту переоткрыть соединение с свежим версионным якорем при потере коннекта.
Authentication: кто это к нам пришёл
Каждый запрос к API server проходит цепочку: authentication → authorization → admission control → запись в etcd. Если что-то возвращает deny на любом этапе — запрос отказывается.
Результат authentication — структура UserInfo: username, groups, UID, extra-fields. Дальше она передаётся в authorization.
ServiceAccount token — самый частый случай
Каждый Pod автоматически получает токен ServiceAccount через projected volume в /var/run/secrets/kubernetes.io/serviceaccount/token. Это JWT, подписанный приватным ключом apiserver. Содержит:
{
"iss": "https://kubernetes.default.svc.cluster.local",
"sub": "system:serviceaccount:default:my-app",
"aud": ["https://kubernetes.default.svc.cluster.local"],
"exp": 1715000000,
"kubernetes.io": {
"namespace": "default",
"pod": {"name": "my-app-abc", "uid": "..."},
"serviceaccount": {"name": "my-app", "uid": "..."}
}
}
С v1.22 (BoundServiceAccountTokenVolume GA) это bound projected tokens — короткоживущие (по умолчанию 1 час), привязанные к Pod-у (если Pod удалили — токен невалиден). Старые long-lived токены через автогенерируемые Secret отключены по умолчанию с v1.24 — apiserver их больше не создаёт автоматически.
Authorization: RBAC
После authentication apiserver знает (username, groups). Дальше — авторизация: имеет ли этот user право выполнить именно этот action на именно этом ресурсе.
В v1.35 default-режим — RBAC, основанный на четырёх типах объектов:
# Role / ClusterRole — описывает РАЗРЕШЕНИЯ
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: default
name: pod-reader
rules:
- apiGroups: [""] # core API group
resources: ["pods"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log"] # subresource
verbs: ["get"]
# RoleBinding / ClusterRoleBinding — связывает РАЗРЕШЕНИЯ с СУБЪЕКТАМИ
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
namespace: default
name: alice-can-read-pods
subjects:
- kind: User
name: alice
apiGroup: rbac.authorization.k8s.io
- kind: ServiceAccount
name: my-app
namespace: default
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
Разница Role vs ClusterRole: Role — namespaced (разрешения только в одном namespace), ClusterRole — cluster-wide. ClusterRoleBinding даёт ClusterRole во всех namespace. Можно использовать ClusterRole в RoleBinding — тогда разрешения ограничены namespace биндинга. Это позволяет переиспользовать одни и те же permission-наборы.
Кроме RBAC есть Node authorizer (специальный — позволяет kubelet делать только то, что нужно своим Pod-ам) и Webhook authorizer (внешний сервис принимает решение). ABAC формально не deprecated, но на практике почти не используется и не рекомендуется — RBAC покрывает все стандартные сценарии. Несколько authorizer-ов работают последовательно — если первый говорит deny, проверяется следующий, и так до конца.
Admission control: mutating и validating
Если authorization прошла — запрос идёт на admission controllers. Это набор плагинов и webhooks, которые могут модифицировать или отклонить запрос до записи в etcd.
Зачем нужны admission webhooks
Классические юзкейсы:
- PodSecurity — встроенный validating, проверяет соответствие Pod-а уровню (privileged / baseline / restricted) для namespace.
- OPA Gatekeeper / Kyverno — внешние validating webhooks для policy-as-code: запретить latest tag, требовать labels, и т.д.
- Istio sidecar injection — mutating webhook автоматически добавляет sidecar container с Envoy в Pod-ы определённых namespace.
- cert-manager — mutating webhook валидирует и нормализует Certificate ресурсы.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: enforce-image-policy
webhooks:
- name: deny-latest.example.com
rules:
- apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
operations: ["CREATE", "UPDATE"]
clientConfig:
service:
name: image-policy-webhook
namespace: policy-system
path: /validate
caBundle: <base64-encoded-CA>
admissionReviewVersions: ["v1"]
sideEffects: None
failurePolicy: Fail
timeoutSeconds: 5
failurePolicy: Fail означает, что если webhook недоступен — apiserver отклоняет запрос. Это создаёт single point of failure: если ваш validating webhook упал, нельзя создавать Pod-ы вообще. failurePolicy: Ignore безопаснее, но ослабляет policy enforcement. Тщательно настраивайте namespaceSelector и objectSelector, чтобы webhook не вызывался для kube-system.
Aggregated API server
API server расширяется двумя способами:
- CustomResourceDefinitions (CRD) — добавляют новые типы ресурсов в встроенный API. apiserver сам их обслуживает (хранит в etcd, валидирует через schema).
- APIService / Aggregation Layer — apiserver проксирует запросы на внешний API server для определённой group/version.
Aggregation layer используется когда стандартного CRD недостаточно: нужна custom storage (не etcd), specific endpoints (/scale, /exec-like proxy), сложная business logic.
apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
name: v1beta1.metrics.k8s.io
spec:
service:
name: metrics-server
namespace: kube-system
group: metrics.k8s.io
version: v1beta1
insecureSkipTLSVerify: true
groupPriorityMinimum: 100
versionPriority: 100
Это говорит apiserver: «все запросы к /apis/metrics.k8s.io/v1beta1/... проксируй на Service metrics-server в kube-system». Так работает metrics-server — он реализует metrics.k8s.io API группу, но storage и runtime — отдельный pod.
Прокси работает прозрачно для клиента: kubectl top nodes делает GET /apis/metrics.k8s.io/v1beta1/nodes на основной apiserver, тот форвардит на metrics-server, и ответ возвращается клиенту как будто он пришёл от kube-apiserver.
Killer-момент: что реально летит при kubectl get pods
Соберём всю цепочку в один пример. Команда:
kubectl get pods -n production --watch
Что происходит на уровне HTTP:
# Шаг 1. LIST — снять начальный снапшот
GET /api/v1/namespaces/production/pods?limit=500 HTTP/2
Authorization: Bearer eyJhbGciOi...
Accept: application/vnd.kubernetes.protobuf, application/json
HTTP/2 200 OK
Content-Type: application/json
{
"kind": "PodList",
"metadata": {"resourceVersion": "874523"},
"items": [...]
}
# Шаг 2. WATCH с того же resourceVersion — слушать изменения
GET /api/v1/namespaces/production/pods?watch=true&resourceVersion=874523&allowWatchBookmarks=true HTTP/2
Authorization: Bearer eyJhbGciOi...
HTTP/2 200 OK
Content-Type: application/json
Transfer-Encoding: chunked
{"type":"ADDED","object":{"kind":"Pod","metadata":{"name":"web-7","resourceVersion":"874524",...}}}
{"type":"MODIFIED","object":{"kind":"Pod","metadata":{"name":"web-7","resourceVersion":"874527",...}}}
{"type":"BOOKMARK","object":{"kind":"Pod","metadata":{"resourceVersion":"874600"}}}
# ...пока не Ctrl+C или сетевой обрыв
Внутри apiserver-а это не два независимых запроса. Запросы попадают в storage layer: первый делает etcd.Get с range query на префикс /registry/pods/production/, второй открывает etcd.Watch стрим начиная с revision 874523. Apiserver кэширует объекты в watch cache (in-memory), чтобы не дёргать etcd на каждый одинаковый запрос — все клиенты получают один и тот же стрим из shared cache.
Это объясняет масштабируемость: 1000 kubelet-ов могут одновременно держать watch на свои Pod-ы, и для etcd это всё ещё один Raft Watch стрим внутри apiserver-а.