etcd: распределённое key-value хранилище
etcd — это сердце Kubernetes. Не api-server, не scheduler, не controller-manager — а именно etcd, потому что в нём живёт state кластера. Если упадёт api-server — поднимется заново, прочитает state из etcd, продолжит работу. Если потеряется etcd без бэкапа — кластер мёртв навсегда, потому что нет источника правды.
В этом уроке разбираем etcd как distributed system: Raft consensus, MVCC, watch-стримы, lease, encryption at rest. Это знание формально входит в CKA scope, но для CKAD понимать etcd важно — все performance и consistency гарантии apiserver наследуются именно от него.
etcd как KV store
etcd — это distributed key-value store, написанный в CoreOS (теперь под CNCF), с одной главной фичей: strong consistency через Raft consensus. Это значит, что после успешного write все последующие read любым клиентом любого узла увидят это значение (или более новое).
Ключи — произвольные строки (но Kubernetes использует префиксы /registry/...). Значения — произвольные байты (Kubernetes хранит protobuf-сериализованные API объекты).
# Прямой доступ через etcdctl (только на control plane узлах кластера)
ETCDCTL_API=3 etcdctl \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
get /registry/pods/default/nginx --print-value-only | od -c | head
Этот вывод покажет бинарные данные — protobuf encoding API объекта Pod. Сериализуется он схемой из k8s.io/api/core/v1.
В Kubernetes используется etcd v3 API (не v2 — v2 deprecated). Различие важное: v3 хранит ключи в lexicographic-упорядоченной BTREE-структуре (а не в иерархической как в v2), и поддерживает range queries — etcdctl get --prefix /registry/pods/ возвращает все Pod-ы кластера за один запрос.
Синхронизация: race conditions, mutex, atomic — основа distributed consistency
Raft consensus: как etcd достигает согласия
etcd — кластер из 3, 5, 7 узлов (нечётное число). Один из них — leader, остальные — followers. Все writes идут через leader. Followers только реплицируют.
Quorum и почему нечётное число
Write quorum = N/2 + 1. Это минимальное число узлов, которое должно подтвердить запись, чтобы она считалась успешной.
| Total nodes | Write quorum | Failure tolerance |
|---|---|---|
| 1 | 1 | 0 (нет HA) |
| 3 | 2 | 1 |
| 4 | 3 | 1 (то же, что 3) |
| 5 | 3 | 2 |
| 6 | 4 | 2 (то же, что 5) |
| 7 | 4 | 3 |
Чётное число узлов — анти-паттерн: добавление одного узла не даёт прироста tolerance, но даёт прирост latency (больше пиров для репликации в hot path). Production-стандарт — 3 узла (минимальный HA) или 5 (можно сделать maintenance одной ноды без потери HA).
Leader election
Если followers не получают heartbeat от leader-а за election timeout (по умолчанию 1 сек), начинается election: один из followers объявляет себя candidate, увеличивает term, голосует за себя, рассылает RequestVote всем остальным. Кто получает majority голосов — становится новым leader-ом.
Это объясняет, почему etcd чувствителен к network latency. Если RTT между узлами > election timeout — постоянные re-elections, write latency взрывается, кластер деградирует. Для multi-DC топологии нужно делать timeout больше (--election-timeout 5000ms).
Никогда не растягивайте etcd кластер через слабые сетевые линки (между регионами, через VPN). Используйте multi-cluster federation вместо этого. etcd best practice — все members в одном DC с RTT < 5ms, лучше < 1ms.
MVCC: версионность всего
etcd v3 — это Multi-Version Concurrency Control store. Это значит, что каждое изменение не перезаписывает старое значение — оно создаёт новую версию с новым revision.
revision — глобальный монотонно растущий счётчик. Каждый успешный write увеличивает его на 1. Любой read возвращает значение на указанном revision (или на самом свежем, если revision не указан).
# Поток изменений
Put /registry/pods/default/nginx → revision 100, value V1
Put /registry/pods/default/nginx → revision 105, value V2
Put /registry/pods/default/web → revision 106, value W1
Delete /registry/pods/default/nginx → revision 110
# Чтение на разных revision
Get /registry/pods/default/nginx --rev=100 → V1
Get /registry/pods/default/nginx --rev=105 → V2
Get /registry/pods/default/nginx --rev=110 → not found
Get /registry/pods/default/nginx → not found (latest)
Это даёт два важных свойства:
- Time-travel queries — можно прочитать state на любом historical revision (до compaction).
- Точная consistency для watch — клиент может попросить «шли мне все изменения после revision X», и сервер пошлёт ровно их, в правильном порядке, без пропусков и дубликатов.
Связь revision и Kubernetes resourceVersion
Помните resourceVersion из урока про apiserver? Это и есть etcd revision. Когда apiserver делает Put в etcd и получает новый revision — он кладёт его в .metadata.resourceVersion объекта при возврате клиенту. Так что resourceVersion — не просто counter, а конкретный revision в etcd.
ResourceVersion — это per-cluster, а не per-object. Когда вы видите Pod с resourceVersion=874523, это значит что глобальный revision на момент этого изменения был 874523. Следующий Put в любой объект кластера получит 874524.
Watch на уровне etcd
etcd v3 поддерживает long-lived watch на key range, начиная с указанного revision. Это native gRPC streaming, поверх которого работает Kubernetes watch.
# Подписаться на все изменения под /registry/pods/, начиная с revision 100
etcdctl watch --prefix /registry/pods/ --rev=100
Сервер шлёт events стримом:
PUT
/registry/pods/default/nginx
<protobuf bytes>
DELETE
/registry/pods/default/nginx
Каждый event имеет kv.mod_revision — на каком revision произошло это изменение. Если клиент потерял соединение, он переподключается с --rev=<last seen mod_revision + 1>.
Compaction: чистка истории
MVCC хранит все revisions, что значит history растёт бесконечно. Чтобы это не убило диск, etcd делает compaction — удаляет revisions старше указанного.
# Compact всё старше revision 5000
etcdctl compact 5000
После этого etcdctl get --rev=4000 вернёт ошибку — данные стёрты. Kubernetes apiserver автоматически делает compaction каждые 5 минут (compact-ит revision на 5 минут моложе самого старого живого watch).
Если клиент watch отстал больше чем на compacted-окно — он получает ErrCompacted и должен сделать LIST + новый WATCH. Этот сценарий ловится apiserver-ом и транслируется как 410 Gone для kubectl/SDK клиентов.
Lease: TTL для key
Lease — это механизм автоматического удаления ключа по TTL. Lease создаётся отдельно, имеет ID и duration. Дальше можно привязать к нему один или несколько ключей — когда lease expires (если не renewed), все привязанные ключи удаляются.
# Создать lease на 60 секунд
etcdctl lease grant 60
# → lease 32695410dcc0ca06 granted with TTL(60s)
# Привязать ключ к lease
etcdctl put /test/key1 "value" --lease=32695410dcc0ca06
# Через 60 секунд ключ автоматически удалится — если не сделать renew
etcdctl lease keep-alive 32695410dcc0ca06
# → стрим renewals, продлевает lease каждые ~20 сек
Зачем это Kubernetes
Два главных юзкейса:
-
Node heartbeat / node lease — каждый kubelet раз в 10 секунд обновляет свой Lease объект в
kube-node-leasenamespace. Если NodeController не видит renewal в течение--node-monitor-grace-period(default 50 сек в v1.32+, исторически 40 сек) — нода считается NotReady. Это легковесная замена старого механизма «обновлять весь Node объект» (которое создавало много write traffic). -
Leader election — controller-manager, scheduler, и другие компоненты, которые работают в HA, используют Lease как distributed lock. Один из инстансов держит lease (renew-ит периодически), он же leader. Если renewals прекращаются — lease expires, другой инстанс claim-ит, становится новым лидером.
apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
name: kube-controller-manager
namespace: kube-system
spec:
holderIdentity: ip-10-0-1-12_a1b2c3d4
leaseDurationSeconds: 15
acquireTime: "2026-05-13T09:00:00Z"
renewTime: "2026-05-13T09:42:30Z"
leaseTransitions: 3
Это объект в API server, но под капотом — обычный etcd ключ, обычно без attached etcd-lease (поскольку renew логика тут в apiserver-е).
Encryption at rest: Secrets — это не secrets
По умолчанию etcd хранит данные в открытом виде на диске. Файл /var/lib/etcd/member/snap/db — это boltdb, который можно скачать и прочитать. Secrets в нём — это base64-encoded, не encrypted.
Чтобы Secrets были реально зашифрованы, нужен конфиг encryption at rest в apiserver:
# EncryptionConfiguration в файле, путь передаётся apiserver-у через --encryption-provider-config=/etc/kubernetes/encryption.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
- configmaps # опционально — обычно только secrets
providers:
- aescbc: # шифрование AES-CBC с 32-byte key
keys:
- name: key1
secret: <base64-encoded-32-byte-key>
- identity: {} # fallback — read без шифрования (для миграции)
После применения этой конфигурации apiserver шифрует Secret-ы при записи в etcd. Существующие Secrets остаются незашифрованными до тех пор, пока их не пересохранить — обычно через kubectl get secrets --all-namespaces -o json | kubectl replace -f -.
Provider aescbc discouraged с v1.28 — для in-cluster шифрования рекомендуется aesgcm (AES-GCM, 32-byte key, лучше performance). Production-уровень — KMS v2 (GA с v1.29): внешний KMS (AWS KMS, GCP KMS, Vault) хранит KEK, apiserver запрашивает DEK через KMS API. Это даёт key rotation без перешифровывания всего etcd, full audit и hardware-bound keys.
Что хранится в etcd кроме API объектов
Помимо /registry/<resource>/..., etcd хранит:
/registry/services/specs/...— Service objects/registry/services/endpoints/...— Endpoints objects (legacy, заменены EndpointSlices)/registry/leases/...— Lease objects для leader election и node heartbeats/registry/events/...— Events (с TTL — по умолчанию 1 час)/registry/namespaces/...— Namespace objects/registry/csidrivers/...,/registry/csinodes/...— CSI driver state
Один типичный production-кластер хранит несколько GB данных в etcd. Самая частая причина distended etcd — много Events или массовая утечка resourceVersion-ов из-за неправильного использования watch.
Backup и restore
Хотя backup — формально CKA scope, концептуально важно понять. etcd snapshot — это консистентное состояние на конкретном revision:
# Снять snapshot
ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
snapshot save /backup/etcd-$(date +%Y%m%d-%H%M%S).db
# Восстановить из snapshot
ETCDCTL_API=3 etcdctl snapshot restore /backup/etcd-20260513.db \
--data-dir=/var/lib/etcd-new \
--initial-cluster=master-1=https://10.0.1.10:2380 \
--initial-advertise-peer-urls=https://10.0.1.10:2380 \
--name=master-1
Это однопоточный snapshot всего boltdb на конкретном revision. Backup нужно делать регулярно (cron каждые 30 минут — типичная частота для production). Без backup потеря большинства etcd узлов = полная потеря кластера.
Killer-момент: что лежит для одного Pod-а
Чтобы окончательно понять, посмотрим что etcd хранит для одного-единственного Pod-а после kubectl run nginx --image=nginx.
# Ключ
/registry/pods/default/nginx
# Decoded protobuf — упрощённо
apiVersion: v1
kind: Pod
metadata:
name: nginx
namespace: default
uid: 7c8b9d10-1234-...
resourceVersion: "874523"
creationTimestamp: "2026-05-13T09:00:00Z"
generation: 1
spec:
containers:
- name: nginx
image: nginx
ports: [...]
resources: {}
terminationMessagePath: /dev/termination-log
imagePullPolicy: Always
restartPolicy: Always
terminationGracePeriodSeconds: 30
dnsPolicy: ClusterFirst
serviceAccountName: default
serviceAccount: default
nodeName: worker-3 # ← scheduler записал это
schedulerName: default-scheduler
...
status:
phase: Running
conditions:
- type: Ready
status: "True"
...
hostIP: 10.0.2.13
podIP: 10.244.3.7
startTime: "2026-05-13T09:00:02Z"
containerStatuses:
- name: nginx
state:
running:
startedAt: "2026-05-13T09:00:05Z"
ready: true
restartCount: 0
image: nginx:latest
imageID: docker.io/library/nginx@sha256:abc...
containerID: containerd://def456... # ← kubelet записал это
Это один объект, но он наполняется разными компонентами:
spec(без nodeName) — записан apiserver-ом при созданииspec.nodeName— записан scheduler-ом через bindingstatus.*— постоянно обновляется kubelet-ом (heartbeat-ы статуса)- Каждое такое обновление — это новый revision в etcd, новый resourceVersion объекта
Объект живёт в etcd с момента создания до удаления Pod-а. При удалении он не сразу стирается — сначала помечается deletionTimestamp, kubelet видит это, останавливает контейнеры, удаляет финализаторы — и только тогда apiserver реально делает delete в etcd.