Kubernetes для самых маленьких. Часть 34: Как работает etcd?
Все части серии «Kubernetes для самых маленьких»Всё, что кластер знает о себе, — каждый Deployment, Secret, узел, аренда лидера и событие — лежит в одном месте: в etcd. В части 2 он появился одной строкой в списке компонентов control plane, в части 3 мы видели его след в resourceVersion и watch, в части 4 kubeadm поднял его static pod-ом на k8s-master-1, а в части 12 мы включали шифрование Secret перед записью в него. Сегодня разберём сам etcd: как устроены ключи и значения, что такое ревизии и почему база растёт, как Raft договаривается о записи и что значит потеря кворума, как смотреть на etcd через etcdctl, почему ему нужен быстрый диск, и главное — как делать снапшоты, где их хранить и как по ним восстановить кластер.
На стенде один мастер, а значит, один член etcd. Это учебная конфигурация: потеря диска k8s-master-1 без резервной копии означает потерю кластера целиком. Поэтому половина части — про резервное копирование.
Что лежит в etcd
etcd — распределённое хранилище «ключ — значение» с сильной согласованностью: любое чтение видит все записи, подтверждённые до него, на каком бы члене кластера оно ни выполнялось. Клиенты ходят к нему по gRPC (протокол удалённого вызова поверх HTTP/2 с сообщениями в protobuf) на порт 2379. Ключи — произвольные байтовые строки, упорядоченные лексикографически, поэтому запрос «все ключи с префиксом» дешёв: это проход по отсортированному диапазону.
Единственный клиент etcd в кластере — kube-apiserver. Ни kubelet, ни контроллеры, ни планировщик к etcd не обращаются: они работают с API, а apiserver переводит объекты в ключи. Схема ключей:
/registry/<ресурс>/<неймспейс>/<имя> объекты в неймспейсе
/registry/<ресурс>/<имя> объекты уровня кластера
/registry/<группа>/<ресурс>/<неймспейс>/<имя> ресурсы из CRD и части API-групп
Например:
| Ключ | Объект |
|---|---|
/registry/pods/demo/hello-599d6c557d-97s8z |
Pod |
/registry/deployments/demo/hello |
Deployment (группа apps, но префикс без неё — так исторически) |
/registry/services/specs/demo/hello |
Service |
/registry/endpointslices/demo/hello-w68dr |
EndpointSlice |
/registry/secrets/demo/hello-tls |
Secret |
/registry/minions/k8s-node-1 |
Node (старое имя узлов в API — minion) |
/registry/leases/kube-node-lease/k8s-node-1 |
Lease: пульс kubelet |
/registry/leases/kube-system/kube-controller-manager |
Lease: кто сейчас лидер controller-manager |
/registry/crd.projectcalico.org/bgpconfigurations/default |
ресурс из CRD, здесь — Calico |
Значения встроенных типов записываются в protobuf — двоичном формате сериализации Google, где поля закодированы номерами и длинами, а не именами. Перед сообщением стоит магический префикс k8s\x00, за ним обёртка runtime.Unknown с apiVersion и kind и сам объект. Ресурсы из CRD хранятся в JSON: для них у apiserver нет скомпилированных protobuf-схем. Secret, если включено шифрование из части 12, лежит с префиксом k8s:enc:<провайдер>:v1:<имя ключа>: и дальше шифротекст; без шифрования — тот же protobuf, где значения Secret видны после base64-декодирования, то есть открытым текстом.
Отсюда первое правило безопасности: доступ к etcd равен полному доступу к кластеру. Тот, кто может читать ключи, читает все Secret, включая токены сервисных учётных записей с правами администратора; кто может писать — может создать себе ClusterRoleBinding в обход RBAC и admission-проверок, которые apiserver выполняет перед записью. Поэтому etcd в kubeadm слушает только с проверкой клиентских сертификатов, а сертификаты выдаёт его собственный CA.
Ревизии и MVCC
etcd хранит не только текущее значение ключа, а историю. Это MVCC (multi-version concurrency control): каждая запись создаёт новую версию, старые остаются, пока их не удалят явно. Счётчик версий — ревизия — один на всё хранилище и монотонно растёт с каждой транзакцией. У каждого ключа три числа:
create_revision— ревизия, при которой ключ появился;mod_revision— ревизия последнего изменения;version— сколько раз ключ менялся с момента создания.
metadata.resourceVersion объекта в Kubernetes — это и есть mod_revision его ключа, переданный клиенту как непрозрачная строка. Отсюда механизм из части 3: watch с resourceVersion=N превращается в watch etcd «с ревизии N», и etcd отдаёт все изменения после неё из истории. Оптимистичная блокировка — тоже на ревизиях: update объекта apiserver выполняет как транзакцию «если mod_revision ключа всё ещё равна той, что прислал клиент, — записать», и при расхождении клиент получает 409 Conflict.
На диске MVCC-хранилище — файл member/snap/db, база bbolt (встраиваемое хранилище «ключ — значение» на B+-дереве в одном файле, форк BoltDB). Ключ в ней — пара «ревизия, подномер», значение — исходный ключ etcd и данные. Отдельный индекс в памяти сопоставляет ключ etcd со списком его ревизий.
История растёт бесконечно, если её не обрезать. Обрезка — compaction: «забыть все версии старше ревизии N, кроме последней версии каждого ключа». В кластере Kubernetes её делает не сам etcd, а kube-apiserver: раз в 5 минут (флаг --etcd-compaction-interval) он вызывает compact до ревизии, которая была текущей пять минут назад. Поэтому watch с очень старой resourceVersion получает ошибку 410 Gone (too old resource version): нужных ревизий в etcd больше нет, и клиент обязан перечитать список заново. На практике ошибка приходит и раньше: watch обслуживает кеш apiserver в памяти, а он держит лишь недавние изменения, так что 410 может прийти, пока в самом etcd старая ревизия ещё читается.
Compaction освобождает место внутри файла bbolt, но не уменьшает сам файл: освободившиеся страницы просто идут под новые записи. Вернуть место файловой системе может только дефрагментация — etcdctl defrag: etcd переписывает базу в новый файл без пустых страниц. Пока она идёт, член кластера не обслуживает ни чтения, ни записи, а на однонодовом etcd это значит паузу всего API на секунды или десятки секунд. Делать её стоит редко и в окно обслуживания; документация Kubernetes советует для этого утилиту etcd-defrag, которая дефрагментирует членов по одному и только при выполнении заданных условий (например, если свободно больше половины файла).
Квота. Размер базы ограничен флагом --quota-backend-bytes; по умолчанию это 2 ГиБ, рекомендуемый потолок — 8 ГиБ. kubeadm квоту не задаёт, значит, на стенде действует 2 ГиБ. Когда база доходит до квоты, etcd поднимает тревогу NOSPACE на весь кластер и переходит в режим обслуживания: принимает только чтения и удаления, а на запись отвечает etcdserver: mvcc: database space exceeded. Для Kubernetes это авария: ни один объект не создаётся и не обновляется, kubelet не может продлить Lease узла, и через несколько десятков секунд контроллер узлов начинает считать их NotReady. Выход — освободить место (compaction до текущей ревизии, затем defrag) и снять тревогу etcdctl alarm disarm: сама она не исчезает, даже когда места снова много, и запись остаётся запрещённой до этой команды. Обычный кластер на сотни подов занимает десятки, от силы сотни мегабайт, так что упереться в квоту можно только при утечке: оператор, создающий тысячи объектов, гигантские ConfigMap, события без срока жизни. Метрики размера базы — ниже, на них стоит повесить предупреждение на 70–80 % квоты. Если квоту нужно поднять, её задают в конфигурации kubeadm:
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
etcd:
local:
extraArgs:
- name: quota-backend-bytes
value: "8589934592" # 8 ГиБ
Raft: как несколько членов договариваются
Сильная согласованность при нескольких членах держится на протоколе консенсуса Raft. Его идея: члены кластера ведут одинаковый упорядоченный журнал операций, и операция считается выполненной только тогда, когда она записана в журнал большинства. Каждый член применяет журнал к своей копии bbolt в одном и том же порядке и потому приходит к одинаковому состоянию.
Роли:
- лидер — единственный, кто принимает записи. Остальные члены пересылают ему записи клиентов;
- ведомые (followers) — принимают от лидера новые записи журнала и подтверждают их;
- кандидат — ведомый, который перестал слышать лидера и пытается стать лидером сам.
kube-apiserver
│ 1. Put /registry/pods/demo/hello-...
▼
┌───────────┐ 2. AppendEntries: запись 1843322 ┌─────────────┐
│ etcd-1 │─────────────────────────────────►│ etcd-2 │
│ (лидер) │──────────────────┐ │ (ведомый) │
└───────────┘ │ └─────────────┘
▲ │ ┌─────────────┐
│ └──────────────►│ etcd-3 │
│ │ (ведомый) │
│ 3. «записал в WAL» от etcd-2 └─────────────┘
│ 4. большинство (2 из 3) есть: запись зафиксирована,
│ применена к bbolt, ответ apiserver
Порядок записи: лидер добавляет операцию в свой журнал, рассылает её ведомым сообщением AppendEntries, каждый ведомый записывает её на диск и подтверждает. Как только запись есть у большинства (с учётом самого лидера), она зафиксирована (committed): лидер применяет её и отвечает клиенту, а ведомые применят её, узнав из следующего сообщения, до какого номера журнал зафиксирован.
Журнал на диске — WAL (write-ahead log, журнал упреждающей записи) в member/wal/. Член подтверждает запись лидеру только после того, как она дописана в WAL и сброшена на диск вызовом fdatasync. Отсюда чувствительность etcd к диску, о которой ниже. Время от времени etcd делает снимок своего состояния (по умолчанию каждые 10 000 записей, флаг --snapshot-count) и отрезает старую часть журнала: отстающий ведомый получит снимок целиком, а не журнал с начала времён.
Выборы. Лидер рассылает пульс (heartbeat) каждые 100 мс. Ведомый, не услышавший лидера дольше таймаута выборов (по умолчанию 1000 мс, со случайной добавкой, чтобы кандидаты не появлялись одновременно), увеличивает номер срока (term) и просит голоса у остальных. Голос отдают, только если журнал кандидата не отстаёт от собственного: лидером не может стать член, у которого нет зафиксированных записей. Кандидат с голосами большинства становится лидером нового срока. Перед настоящими выборами etcd проводит «предварительное голосование» (pre-vote): член, отрезанный от сети, сначала спрашивает, готовы ли за него голосовать, и не сбивает действующего лидера увеличением срока, когда сеть вернётся.
Кворум — большинство членов: ⌊n/2⌋ + 1.
| Членов | Кворум | Выдерживает отказ |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 |
| 3 | 2 | 1 |
| 4 | 3 | 1 |
| 5 | 3 | 2 |
| 7 | 4 | 3 |
Из таблицы видно, почему число членов делают нечётным: четвёртый член не добавляет устойчивости по сравнению с тремя (оба выдерживают один отказ), но увеличивает кворум и число машин, которые должны подтвердить каждую запись. Два члена хуже одного: отказ любого из них останавливает запись, а вероятность отказа одной из двух машин вдвое выше. Три члена — минимум для отказоустойчивости, пять — когда нужно пережить отказ одной машины во время обслуживания другой; больше пяти почти не делают, потому что каждая запись ждёт подтверждения от большинства, и задержка растёт.
Потеря кворума — когда живых членов меньше большинства. Тогда лидера нет и выбрать его нельзя: запись невозможна, а чтения etcd по умолчанию делает линеаризуемыми (через подтверждение у лидера), так что и они не проходят: etcdctl get завершается с context deadline exceeded, а etcdctl endpoint status показывает в колонке ошибок etcdserver: no leader. Отвечает только чтение без такого подтверждения, etcdctl get --consistency=s: оно отдаёт локальную копию, возможно устаревшую. Для Kubernetes это значит: API не отвечает, ни один объект не создаётся и не меняется, контроллеры и планировщик стоят. Уже запущенные контейнеры при этом продолжают работать — kubelet не убивает их из-за потери связи с API, — но ничего не перезапускается на других узлах и не масштабируется. Если вернуть упавших членов нельзя, выход один: восстановить кластер etcd из снапшота (или, в крайнем случае, запустить уцелевшего члена с --force-new-cluster — он забудет остальных и станет кластером из одного).
На стенде один член, кворум — он сам. Отказоустойчивый control plane с тремя мастерами и etcd на каждом из них, общий адрес API через kube-vip (плавающий IP-адрес, который держит один из мастеров) и обслуживание членов разобраны в серии про Kubernetes на BGP, здесь повторять не будем.
etcd в kubeadm
kubeadm запускает etcd static pod-ом: kubelet на мастере читает манифест /etc/kubernetes/manifests/etcd.yaml и запускает контейнер сам, без участия API (иначе API некому было бы поднять). Основные флаги из манифеста:
user@k8s-master-1:~$ sudo grep -E -- '--(name|data-dir|listen|advertise|initial|cert|key|trusted|peer|client-cert|snapshot)' /etc/kubernetes/manifests/etcd.yaml
- --advertise-client-urls=https://192.168.10.17:2379
- --cert-file=/etc/kubernetes/pki/etcd/server.crt
- --client-cert-auth=true
- --data-dir=/var/lib/etcd
- --initial-advertise-peer-urls=https://192.168.10.17:2380
- --initial-cluster=k8s-master-1=https://192.168.10.17:2380
- --key-file=/etc/kubernetes/pki/etcd/server.key
- --listen-client-urls=https://127.0.0.1:2379,https://192.168.10.17:2379
- --listen-metrics-urls=http://127.0.0.1:2381
- --listen-peer-urls=https://192.168.10.17:2380
- --name=k8s-master-1
- --peer-cert-file=/etc/kubernetes/pki/etcd/peer.crt
- --peer-client-cert-auth=true
- --peer-key-file=/etc/kubernetes/pki/etcd/peer.key
- --peer-trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt
- --snapshot-count=10000
- --trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt
Порты:
| Порт | Что | Кто ходит |
|---|---|---|
| 2379 | клиентский API, gRPC поверх TLS | kube-apiserver, etcdctl |
| 2380 | обмен между членами (Raft), TLS | другие члены etcd |
| 2381 | метрики и проверки здоровья, HTTP без TLS, только 127.0.0.1 |
kubelet (проверки /livez и /readyz), curl на узле |
TLS здесь — шифрование соединения с проверкой сертификатов, причём сертификат предъявляет и клиент. Данные лежат в /var/lib/etcd на диске мастера (volume etcd-data типа hostPath). Сертификаты — в /etc/kubernetes/pki/etcd/, у etcd свой CA, отдельный от CA кластера:
| Файл | Назначение |
|---|---|
ca.crt, ca.key |
CA etcd; ключ — самый ценный файл на мастере после ca.key кластера |
server.crt, server.key |
серверный сертификат на 2379, имена localhost, k8s-master-1, адреса 127.0.0.1, 192.168.10.17 |
peer.crt, peer.key |
сертификат для обмена между членами на 2380, и серверный, и клиентский |
healthcheck-client.crt, .key |
клиентский сертификат для проверок и для etcdctl |
Клиентский сертификат самого apiserver — /etc/kubernetes/pki/apiserver-etcd-client.crt, подписан тем же CA etcd. Важная деталь: аутентификация пользователей и ролей в etcd kubeadm не включает, поэтому любой клиентский сертификат, подписанный CA etcd, даёт полный доступ ко всем ключам. Разницы между healthcheck-client и apiserver-etcd-client для etcd нет; ключи этих сертификатов доступны только root (0600), и так должно оставаться.
etcdctl — сетевой клиент etcd, etcdutl — утилита для работы с файлами данных напрямую, без запущенного etcd. С версии 3.6 их обязанности разделены строго: etcdctl snapshot save снимает снапшот по сети, а snapshot status и snapshot restore из etcdctl удалены и есть только в etcdutl. Обе утилиты есть в образе etcd, поэтому без установки можно так:
$ kubectl -n kube-system exec etcd-k8s-master-1 -- etcdctl \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/healthcheck-client.crt \
--key=/etc/kubernetes/pki/etcd/healthcheck-client.key \
endpoint health
https://127.0.0.1:2379 is healthy: successfully committed proposal: took = 96.565732ms
Но для восстановления etcd API работать не будет, поэтому утилиты стоит заранее положить на мастер — той же версии, что etcd в кластере (kubeadm 1.36 ставит etcd 3.6.8):
user@k8s-master-1:~$ ETCD_VER=v3.6.8
user@k8s-master-1:~$ curl -fsSLO https://github.com/etcd-io/etcd/releases/download/${ETCD_VER}/etcd-${ETCD_VER}-linux-amd64.tar.gz
user@k8s-master-1:~$ curl -fsSLO https://github.com/etcd-io/etcd/releases/download/${ETCD_VER}/SHA256SUMS
user@k8s-master-1:~$ sha256sum --ignore-missing -c SHA256SUMS
etcd-v3.6.8-linux-amd64.tar.gz: OK
user@k8s-master-1:~$ tar xzf etcd-${ETCD_VER}-linux-amd64.tar.gz
user@k8s-master-1:~$ sudo install -m 0755 etcd-${ETCD_VER}-linux-amd64/etcdctl etcd-${ETCD_VER}-linux-amd64/etcdutl /usr/local/bin/
Чтобы не повторять четыре флага, etcdctl читает их из переменных окружения. Ключи читает только root, поэтому работаем в root-сессии:
user@k8s-master-1:~$ sudo -i
root@k8s-master-1:~# export ETCDCTL_ENDPOINTS=https://127.0.0.1:2379
root@k8s-master-1:~# export ETCDCTL_CACERT=/etc/kubernetes/pki/etcd/ca.crt
root@k8s-master-1:~# export ETCDCTL_CERT=/etc/kubernetes/pki/etcd/healthcheck-client.crt
root@k8s-master-1:~# export ETCDCTL_KEY=/etc/kubernetes/pki/etcd/healthcheck-client.key
Переменная ETCDCTL_API=3 из старых инструкций больше не нужна: третья версия API используется по умолчанию с etcd 3.4. Флаг и переменную для одного параметра смешивать нельзя: etcdctl --cert=… при заданной ETCDCTL_CERT завершится ошибкой conflicting environment variable is shadowed by corresponding command-line flag.
Смотрим руками
Состояние члена:
root@k8s-master-1:~# etcdctl endpoint status -w table
+------------------------+------------------+---------+-----------------+---------+--------+-----------------------+--------+-----------+------------+-----------+------------+--------------------+--------+--------------------------+-------------------+
| ENDPOINT | ID | VERSION | STORAGE VERSION | DB SIZE | IN USE | PERCENTAGE NOT IN USE | QUOTA | IS LEADER | IS LEARNER | RAFT TERM | RAFT INDEX | RAFT APPLIED INDEX | ERRORS | DOWNGRADE TARGET VERSION | DOWNGRADE ENABLED |
+------------------------+------------------+---------+-----------------+---------+--------+-----------------------+--------+-----------+------------+-----------+------------+--------------------+--------+--------------------------+-------------------+
| https://127.0.0.1:2379 | feb950520d2b15b7 | 3.6.8 | 3.6.0 | 35 MB | 3.4 MB | 91% | 2.1 GB | true | false | 4 | 2948483 | 2948482 | | | false |
+------------------------+------------------+---------+-----------------+---------+--------+-----------------------+--------+-----------+------------+-----------+------------+--------------------+--------+--------------------------+-------------------+
DB SIZE — размер файла bbolt, IN USE — сколько в нём занято. Разница видна в колонке PERCENTAGE NOT IN USE: 91 % файла — освобождённые compaction страницы, которые вернёт defrag. Кластер без приложений быстро переписывает одни и те же Lease, поэтому файл здесь намного больше данных. QUOTA — та самая квота: 2,1 ГБ в десятичных единицах и есть 2 ГиБ. RAFT TERM — номер срока: на одном члене он растёт только при перезапусках; в кластере из трёх частый рост — признак того, что лидер теряется, обычно из-за медленного диска или сети. RAFT INDEX и RAFT APPLIED INDEX — номер последней записи журнала и последней применённой; у здорового члена они равны или почти равны.
Члены кластера:
root@k8s-master-1:~# etcdctl member list -w table
+------------------+---------+--------------+----------------------------+----------------------------+------------+
| ID | STATUS | NAME | PEER ADDRS | CLIENT ADDRS | IS LEARNER |
+------------------+---------+--------------+----------------------------+----------------------------+------------+
| feb950520d2b15b7 | started | k8s-master-1 | https://192.168.10.17:2380 | https://192.168.10.17:2379 | false |
+------------------+---------+--------------+----------------------------+----------------------------+------------+
IS LEARNER — член-ученик: новый член, который сначала догоняет журнал без права голоса и не влияет на кворум, а потом повышается до полноправного. Так добавляют членов в работающий кластер, не рискуя кворумом.
Ключи нашего приложения из неймспейса demo, без событий, которых там ещё десяток:
root@k8s-master-1:~# etcdctl get /registry/ --prefix --keys-only | grep '/demo/' | grep -v '/events/'
/registry/configmaps/demo/hello-config
/registry/configmaps/demo/kube-root-ca.crt
/registry/deployments/demo/hello
/registry/endpointslices/demo/hello-w68dr
/registry/networkpolicies/demo/default-deny
/registry/pods/demo/hello-599d6c557d-97s8z
/registry/pods/demo/hello-599d6c557d-kkvlg
/registry/replicasets/demo/hello-599d6c557d
/registry/secrets/demo/ghcr-pull
/registry/secrets/demo/hello-tls
/registry/serviceaccounts/demo/default
/registry/services/endpoints/demo/hello
/registry/services/specs/demo/hello
Сколько всего ключей и какие ресурсы занимают больше всего:
root@k8s-master-1:~# etcdctl get /registry/ --prefix --keys-only | grep -v '^$' | cut -d/ -f3 | sort | uniq -c | sort -rn | head -5
82 clusterroles
69 clusterrolebindings
53 serviceaccounts
33 crd.projectcalico.org
32 apiextensions.k8s.io
На почти пустом учебном кластере впереди объекты RBAC и определения CRD, всего ключей меньше пятисот. В кластере с приложениями на первое место обычно выходят события (events): каждое событие из kubectl describe — ключ в etcd, живущий час (флаг --event-ttl у apiserver). Для этого apiserver привязывает их к аренде (lease) etcd — объекту со сроком жизни, по истечении которого etcd сам удаляет все привязанные ключи:
root@k8s-master-1:~# EV=/registry/events/calico-system/calico-typha-55d594d885-dsh7d.18e50a011236234d
root@k8s-master-1:~# etcdctl lease timetolive $(printf '%x' $(etcdctl get $EV -w json | jq '.kvs[0].lease'))
lease 15b7a1a2e932ca15 granted with TTL(3660s), remaining(3604s)
Срок чуть больше часа: apiserver не заводит аренду на каждое событие, а минуту использует одну и ту же для всех, поэтому прибавляет эту минуту к сроку. Не путать с объектами Lease Kubernetes из kube-node-lease: это обычные ключи, поле lease у них пусто.
Значение — protobuf с префиксом k8s\x00:
root@k8s-master-1:~# etcdctl get /registry/deployments/demo/hello --print-value-only | head -c 32 | hexdump -C
00000000 6b 38 73 00 0a 15 0a 07 61 70 70 73 2f 76 31 12 |k8s.....apps/v1.|
00000010 0a 44 65 70 6c 6f 79 6d 65 6e 74 12 ae 15 0a ce |.Deployment.....|
00000020
Видны apiVersion и kind, дальше двоичные поля. Декодировать целиком умеет утилита auger из проекта etcd, но на практике проще спросить API: kubectl get вернёт ровно этот объект в YAML. Secret при включённом шифровании:
root@k8s-master-1:~# etcdctl get /registry/secrets/demo/hello-tls --print-value-only | head -c 48 | hexdump -C | head -3
00000000 6b 38 73 3a 65 6e 63 3a 73 65 63 72 65 74 62 6f |k8s:enc:secretbo|
00000010 78 3a 76 31 3a 6b 65 79 31 3a 93 9e 86 16 fc cf |x:v1:key1:......|
00000020 94 dd b8 04 4f 14 6a 7d 0f 8d 8d 81 0c ae ae 56 |....O.j}.......V|
Префикс говорит, каким провайдером и ключом из EncryptionConfiguration зашифрована запись, остальное — шифротекст.
Ревизии и resourceVersion. Возьмём ConfigMap приложения и поменяем его:
root@k8s-master-1:~# etcdctl get /registry/configmaps/demo/hello-config -w json | jq '.kvs[0] | {create_revision, mod_revision, version}'
{
"create_revision": 2206993,
"mod_revision": 2206993,
"version": 1
}
$ kubectl -n demo patch configmap hello-config --type merge -p '{"data":{"GREETING":"hi"}}'
configmap/hello-config patched
$ kubectl -n demo get configmap hello-config -o jsonpath='{.metadata.resourceVersion}{"\n"}'
2207071
root@k8s-master-1:~# etcdctl get /registry/configmaps/demo/hello-config -w json | jq '.kvs[0] | {create_revision, mod_revision, version}'
{
"create_revision": 2206993,
"mod_revision": 2207071,
"version": 2
}
root@k8s-master-1:~# etcdctl get /registry/configmaps/demo/hello-config --rev=2206993 --print-value-only | strings | tail -2
GREETING
hello
resourceVersion совпадает с mod_revision, а старое значение пока читается по старой ревизии. В течение десяти минут apiserver сделает compaction (он сжимает до ревизии, бывшей текущей на прошлом проходе; на стенде старое значение прожило чуть больше семи минут), и тот же запрос ответит etcdserver: mvcc: required revision has been compacted.
Метрики здоровья — на порту 2381 самого узла:
user@k8s-master-1:~$ curl -s http://127.0.0.1:2381/metrics | grep -E '^etcd_(server_has_leader|server_leader_changes_seen_total|mvcc_db_total_size_in(_use_in)?_bytes|server_quota_backend_bytes) '
etcd_mvcc_db_total_size_in_bytes 3.5418112e+07
etcd_mvcc_db_total_size_in_use_in_bytes 3.436544e+06
etcd_server_has_leader 1
etcd_server_leader_changes_seen_total 1
etcd_server_quota_backend_bytes 2.147483648e+09
Квота 2 ГиБ, файл занимает 35 МБ — запас огромный.
Производительность: всё упирается в fsync
Каждая запись в Kubernetes — смена статуса пода, продление Lease узла каждые 10 секунд, событие — это запись в etcd, а каждая запись etcd ждёт fdatasync журнала WAL. fdatasync — системный вызов, который возвращается только тогда, когда данные физически сохранены на носителе, а не лежат в кеше страниц ядра. На SSD или NVMe с защитой от потери питания это доли миллисекунды, на медленном диске — десятки миллисекунд, и каждая из них прибавляется к задержке каждой записи в API.
Хуже того, медленный диск ломает Raft. Пока лидер ждёт диска, он может не успеть отправить пульс, ведомые решат, что лидера нет, и начнут выборы; кластер на это время перестаёт принимать записи. В журнале etcd это видно по предупреждениям slow fdatasync и apply request took too long (ожидаемое время в них — 100 мс), а в метриках — по росту etcd_server_leader_changes_seen_total.
Отсюда требования:
- локальный SSD или NVMe под
/var/lib/etcd, а не сетевой диск: NFS и SMB (сетевые файловые системы) и iSCSI (блочный диск по сети) добавляют к каждомуfdatasyncсетевую задержку и делят её с чужой нагрузкой; - не делить диск с тяжёлыми соседями: образы контейнеров, логи,
emptyDirподов на мастере. Хорошая практика — отдельный том под/var/lib/etcd; - в виртуальной машине — диск с гарантированными IOPS, без «ленивых» режимов кеширования гипервизора, которые подтверждают запись до её сохранения: они дают быстрые цифры и теряют данные при сбое хоста.
Мерило — 99-й перцентиль длительности fdatasync журнала, метрика etcd_disk_wal_fsync_duration_seconds: он должен быть меньше 10 мс. Вторая метрика — etcd_disk_backend_commit_duration_seconds, время фиксации пачки изменений в bbolt: 99-й перцентиль меньше 25 мс. В Prometheus из части 32:
histogram_quantile(0.99, sum by (le, instance) (rate(etcd_disk_wal_fsync_duration_seconds_bucket[5m])))
На узле видно и без Prometheus — гистограмма в сыром виде:
user@k8s-master-1:~$ curl -s http://127.0.0.1:2381/metrics | grep -E '^etcd_disk_wal_fsync_duration_seconds_(bucket\{le="0.00[48]"\}|bucket\{le="0.0(16|32|64)"\}|bucket\{le="0.(128|256)"\}|count)'
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.004"} 0
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.008"} 0
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.016"} 1162
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.032"} 237943
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.064"} 310419
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.128"} 361250
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.256"} 366913
etcd_disk_wal_fsync_duration_seconds_count 368315
Корзины накопительные: ни один вызов из 368 315 не уложился в 8 мс и лишь 0,3 % — в 16 мс, а 99-й перцентиль лежит между 128 и 256 мс. Требованию в 10 мс этот диск не отвечает: учебный стенд работает на виртуальных машинах с динамическим диском на общем накопителе. Один член etcd на почти пустом кластере такое терпит, потому что пульсы отправлять некому, хотя журнал полон apply request took too long, а в кластере из трёх членов на таких дисках лидер терялся бы регулярно. Боевому кластеру такой диск не подходит.
Чтобы метрики собирал Prometheus из кластера, порт 2381 должен слушать ещё и адрес узла: в конфигурации kubeadm это etcd.local.extraArgs с listen-metrics-urls: http://127.0.0.1:2381,http://192.168.10.17:2381 (первым остаётся 127.0.0.1: по нему kubelet проверяет здоровье пода), плюс правило файрвола, пускающее туда только узлы кластера. Метрики идут без TLS, но секретов в них нет.
Диск для будущего мастера проверяют до установки — утилитой fio (генератор нагрузки на ввод-вывод), в режиме, похожем на запись etcd в WAL: последовательная запись блоками около 2 КБ с fdatasync после каждой:
user@k8s-master-1:~$ sudo apt-get install -y fio
user@k8s-master-1:~$ sudo mkdir /var/lib/etcd-fio && sudo fio --rw=write --ioengine=sync --fdatasync=1 \
--directory=/var/lib/etcd-fio --size=22m --bs=2300 --name=etcd-disk-test
...
fsync/fdatasync/sync_file_range:
sync (msec): min=17, max=799, avg=84.26, stdev=57.01
sync percentiles (msec):
| 1.00th=[ 23], 5.00th=[ 26], 10.00th=[ 29], 20.00th=[ 35],
| 30.00th=[ 50], 40.00th=[ 66], 50.00th=[ 70], 60.00th=[ 84],
| 70.00th=[ 100], 80.00th=[ 121], 90.00th=[ 157], 95.00th=[ 190],
| 99.00th=[ 271], 99.50th=[ 300], 99.90th=[ 485], 99.95th=[ 642],
user@k8s-master-1:~$ sudo rm -rf /var/lib/etcd-fio
Смотрим строку 99.00th: 271 мс при пороге 10 мс, и даже самый быстрый вызов занял 17 мс. Тест подтвердил то же, что метрики, а на таком диске шёл 14 минут: 22 МиБ блоками по 2300 байт — это почти 10 тысяч fdatasync. На подходящем SSD те же вызовы занимают около миллисекунды, а тест — считаные секунды.
Снапшоты
Снапшот — согласованная копия всей базы на одной ревизии, файл в формате bbolt. Снимается по сети с работающего члена без остановки кластера:
root@k8s-master-1:~# mkdir -p /var/backups/etcd
root@k8s-master-1:~# etcdctl snapshot save /var/backups/etcd/snapshot.db
{"level":"info","ts":"2026-11-05T07:59:13.528512Z","caller":"snapshot/v3_snapshot.go:83","msg":"created temporary db file","path":"/var/backups/etcd/snapshot.db.part"}
{"level":"info","ts":"2026-11-05T07:59:13.531968Z","logger":"client","caller":"v3@v3.6.8/maintenance.go:236","msg":"opened snapshot stream; downloading"}
{"level":"info","ts":"2026-11-05T07:59:13.661844Z","caller":"snapshot/v3_snapshot.go:96","msg":"fetching snapshot","endpoint":"https://127.0.0.1:2379"}
{"level":"info","ts":"2026-11-05T07:59:13.905603Z","logger":"client","caller":"v3@v3.6.8/maintenance.go:302","msg":"completed snapshot read; closing"}
{"level":"info","ts":"2026-11-05T07:59:14.259633Z","caller":"snapshot/v3_snapshot.go:111","msg":"fetched snapshot","endpoint":"https://127.0.0.1:2379","size":"35 MB","took":"731.068883ms","etcd-version":"3.6.0"}
{"level":"info","ts":"2026-11-05T07:59:14.275147Z","caller":"snapshot/v3_snapshot.go:121","msg":"saved","path":"/var/backups/etcd/snapshot.db"}
Snapshot saved at /var/backups/etcd/snapshot.db
Server version 3.6.0
root@k8s-master-1:~# etcdutl snapshot status /var/backups/etcd/snapshot.db -w table
+----------+----------+------------+------------+---------+
| HASH | REVISION | TOTAL KEYS | TOTAL SIZE | VERSION |
+----------+----------+------------+------------+---------+
| caf81aa3 | 2207072 | 497 | 35 MB | 3.6.0 |
+----------+----------+------------+------------+---------+
snapshot save снимает базу с одного члена, поэтому в кластере из трёх --endpoints указывают ровно на один. Права у файла — 0600. В конец файла etcd дописывает контрольную сумму, и restore проверяет её. Файл member/snap/db, скопированный руками из каталога данных, такой суммы не имеет: restore отвечает snapshot missing hash but --skip-hash-check=false, и восстановить его можно только с --skip-hash-check — а если копировали на работающем etcd, ещё и может оказаться несогласованным. Копировать каталог данных вместо снапшота не нужно.
Снапшот содержит все Secret кластера. Пароли баз данных, ключи TLS, токены доступа к реестру, ключи облачных провайдеров — всё, что лежит в Secret, лежит и в файле снапшота. Без шифрования из части 12 — открытым текстом, который достаётся из файла обычной strings. Обращаться со снапшотом нужно как с самым чувствительным файлом в инфраструктуре:
- шифровать до того, как он покинет мастер;
- хранить вне кластера: снапшот, лежащий в PVC того же кластера, погибнет вместе с ним и доступен любому, кто получит права на этот том;
- не класть на общий NFS-экспорт, к которому могут достучаться поды. В части 22 мы видели, что ограничение экспорта адресами узлов не защищает от подов: их трафик выходит через NAT с адресом узла, и для сервера NFS он неотличим от трафика самого узла. Снапшот на таком экспорте — все Secret кластера, доступные любому поду, которому удалось смонтировать шару или чей том с
sec=sysи подходящим UID на ней лежит; - ограничить доступ на запись и удаление: тот, кто взломал кластер, не должен суметь стереть резервные копии.
Если шифрование Secret в etcd включено, в снапшоте шифротекст, и для восстановления нужен ключ из файла EncryptionConfiguration на мастере. Этот файл копируют отдельно от снапшотов и хранят отдельно: смысл шифрования в том, чтобы утечка снапшота без ключа ничего не давала.
Снапшот — не вся резервная копия control plane. Чтобы поднять мастер на новой машине, нужны ещё:
/etc/kubernetes/pki/— CA кластера, CA etcd, CA front-proxy с ключами и параsa.key/sa.pub, которой подписаны токены сервисных учётных записей. Без старых CA все kubeconfig, kubelet на воркерах и токены станут недействительными;- конфигурация kubeadm (
kubectl -n kube-system get configmap kubeadm-config -o yamlесть и в самом снапшоте, но удобнее иметь её рядом) и файлEncryptionConfiguration.
Каталог pki — такие же секреты, как снапшот, и требует того же обращения.
Регулярные снапшоты: CronJob
Снапшот по расписанию — работа для CronJob из части 9. Схема:
k8s-master-1, под с hostNetwork
┌──────────────────────────────────────────────┐
│ init-контейнер etcd:3.6.8-0 │
│ etcdctl snapshot save /work/etcd.db │
│ │ 127.0.0.1:2379, healthcheck-client │
│ ▼ │
│ emptyDir /work │
│ │ │
│ ▼ │
│ контейнер etcd-backup │
│ age -r <открытый ключ> (шифрование) │
│ rclone copyto (выгрузка) │
└───────┬──────────────────────────────────────┘
│ HTTPS, только запись
▼
┌──────────────────────────────────────────────┐
│ S3-хранилище вне кластера │
│ backup.example.com │
│ версии, запрет удаления, сроки хранения │
└──────────────────────────────────────────────┘
Решения, из которых она сложилась:
- Под на мастере с
hostNetwork: true. etcd слушает127.0.0.1:2379и адрес мастера; в сетевом неймспейсе узла под достучится до локального адреса, а сертификат etcd не нужно копировать никуда, кроме самого мастера. На мастер под попадает поnodeSelectorи toleration к taintnode-role.kubernetes.io/control-plane(часть 13). - Монтируются только три файла:
ca.crtи параhealthcheck-client. Ключ CA etcd в под не попадает. - Шифрование открытым ключом. age — простая утилита шифрования файлов с короткими ключами: открытый ключ
age1…шифрует, только закрытый расшифровывает. В кластере лежит только открытый ключ; закрытый хранится вне инфраструктуры (менеджер паролей, бумажная копия в сейфе). Взломав кластер, прочитать старые снапшоты нельзя. - Выгрузка в S3-совместимое хранилище вне кластера. S3 — протокол объектного хранилища Amazon, который реализуют многие системы, в том числе собственные установки на своих серверах. rclone — утилита копирования файлов в облачные и объектные хранилища. Учётная запись для выгрузки имеет право только записывать новые объекты (
PutObject), без чтения и удаления; в бакете включены версии и блокировка объектов (object lock, запрет удаления до истечения срока). Срок хранения задают правила жизненного цикла бакета, а не скрипт в кластере: так скомпрометированный кластер не может ни прочитать, ни стереть копии.
Образ для второго контейнера собирается по части 5:
FROM alpine:3.22
RUN apk add --no-cache age rclone ca-certificates
Манифест. Неймспейс — отдельный, с уровнем Pod Security privileged (часть 30): поды с hostPath и hostNetwork на уровнях baseline и restricted не допускаются. Права создавать поды в этом неймспейсе равны правам администратора кластера — давать их через RBAC (часть 29) никому, кроме администраторов.
apiVersion: v1
kind: Namespace
metadata:
name: etcd-backup
labels:
pod-security.kubernetes.io/enforce: privileged
---
apiVersion: v1
kind: ConfigMap
metadata:
name: etcd-backup
namespace: etcd-backup
data:
AGE_RECIPIENT: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p # открытый ключ
RCLONE_CONFIG_BACKUP_TYPE: s3
RCLONE_CONFIG_BACKUP_PROVIDER: Other
RCLONE_CONFIG_BACKUP_ENDPOINT: https://backup.example.com
RCLONE_CONFIG_BACKUP_REGION: us-east-1 # регион, который ждёт хранилище
BUCKET: k8s-etcd-snapshots
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: etcd-snapshot
namespace: etcd-backup
spec:
schedule: "17 * * * *" # каждый час в 17 минут
timeZone: Etc/UTC
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 900
template:
spec:
restartPolicy: Never
hostNetwork: true
automountServiceAccountToken: false
imagePullSecrets:
- name: ghcr-pull
nodeSelector:
node-role.kubernetes.io/control-plane: ""
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
initContainers:
- name: snapshot
image: registry.k8s.io/etcd:3.6.8-0
command:
- etcdctl
- --endpoints=https://127.0.0.1:2379
- --cacert=/pki/ca.crt
- --cert=/pki/client.crt
- --key=/pki/client.key
- snapshot
- save
- /work/etcd.db
securityContext:
runAsUser: 0
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- { name: etcd-ca, mountPath: /pki/ca.crt, readOnly: true }
- { name: etcd-client, mountPath: /pki/client.crt, readOnly: true }
- { name: etcd-key, mountPath: /pki/client.key, readOnly: true }
- { name: work, mountPath: /work }
containers:
- name: upload
image: ghcr.io/example/etcd-backup:1.0
command: ["/bin/sh", "-ec"]
args:
- |
name="etcd-$(date -u +%Y%m%dT%H%M%SZ).db.age"
age -r "$AGE_RECIPIENT" -o "/work/$name" /work/etcd.db
rm -f /work/etcd.db
rclone copyto --no-check-dest --s3-no-check-bucket --s3-no-head \
"/work/$name" "backup:$BUCKET/$name"
echo "uploaded $name"
envFrom:
- configMapRef: { name: etcd-backup }
- secretRef: { name: etcd-backup-s3 }
securityContext:
runAsUser: 0
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { memory: 256Mi }
volumeMounts:
- { name: work, mountPath: /work }
volumes:
- name: etcd-ca
hostPath: { path: /etc/kubernetes/pki/etcd/ca.crt, type: File }
- name: etcd-client
hostPath: { path: /etc/kubernetes/pki/etcd/healthcheck-client.crt, type: File }
- name: etcd-key
hostPath: { path: /etc/kubernetes/pki/etcd/healthcheck-client.key, type: File }
- name: work
emptyDir: { sizeLimit: 4Gi }
Пояснения к деталям:
- Два контейнера. Образ etcd — distroless, то есть без дистрибутива внутри: в нём есть
etcd,etcdctlиetcdutl, но нет оболочки, поэтому снапшот снимает init-контейнер одной командой, а шифрование и выгрузку делает основной контейнер. Init-контейнеры выполняются до основных, так что к стартуuploadфайл уже на месте. - Root без привилегий. Ключ
healthcheck-client.keyна узле принадлежит root с правами0600, и снапшот etcdctl пишет с теми же правами. Оба контейнера работают с UID 0, но без единой capability, без повышения привилегий и с файловой системой только для чтения: root внутри пода остаётся владельцем своих файлов, и только. concurrencyPolicy: Forbid— если прошлый запуск завис, новый не стартует параллельно.activeDeadlineSecondsограничивает время Job, чтобы зависший запуск не висел вечно.emptyDirсsizeLimit— на диске узла, не в памяти: снапшот на сотни мегабайт вmedium: Memoryзанял бы память пода. Ограничение размера защищает диск мастера: при превышении kubelet выселит под.imagePullSecrets— образetcd-backupлежит в частном реестре, а Secret действует только в своём неймспейсе, поэтому вetcd-backupнужна своя копияghcr-pull(часть 5).- Учётные данные S3 — в Secret, который создают командой, а не манифестом в Git:
$ read -rsp 'S3 secret key: ' S3_SECRET; echo
$ kubectl -n etcd-backup create secret generic etcd-backup-s3 \
--from-literal=RCLONE_CONFIG_BACKUP_ACCESS_KEY_ID=k8s-etcd-writer \
--from-literal=RCLONE_CONFIG_BACKUP_SECRET_ACCESS_KEY="$S3_SECRET"
secret/etcd-backup-s3 created
$ unset S3_SECRET
rclone берёт настройки удалённого хранилища backup из переменных RCLONE_CONFIG_BACKUP_*, файл конфигурации не нужен. Три флага нужны из-за учётной записи с правом только на запись, которой запрещены все чтения. --no-check-dest отключает проверку, нет ли уже такого объекта в хранилище: без него rclone первым делом отправляет HeadObject и завершается с api error Forbidden. --s3-no-check-bucket отключает проверку бакета, --s3-no-head — чтение объекта после записи. Запрет работает и в обратную сторону: этот ключ не может ни перечислить объекты, ни скачать, ни удалить их.
Политика хранения задаётся в бакете: например, ежечасные снапшоты хранятся 7 дней, а раз в сутки отдельный CronJob с другим префиксом кладёт копию, которая живёт 90 дней. Частота зависит от того, сколько изменений в кластере вы готовы потерять: для кластера, где всё описано в Git (об этом в части 41), потеря часа обычно восстанавливается повторным применением манифестов, а не бедой.
Контроль. Резервная копия, о провале которой никто не знает, хуже отсутствующей: на неё рассчитывают. kube-state-metrics (экспортёр метрик о состоянии объектов API) из стека Prometheus публикует kube_cronjob_status_last_successful_time, и правило оповещения «последний успешный запуск etcd-snapshot старше двух часов» ловит и падения, и случай, когда CronJob перестал запускаться вовсе. И раз в квартал снапшот стоит восстанавливать на отдельной виртуальной машине: только так проверяется, что ключ age на месте, файлы расшифровываются и процедура ниже работает.
Проверка вручную:
$ kubectl -n etcd-backup create job --from=cronjob/etcd-snapshot etcd-snapshot-manual
job.batch/etcd-snapshot-manual created
$ kubectl -n etcd-backup wait --for=condition=complete --timeout=120s job/etcd-snapshot-manual
job.batch/etcd-snapshot-manual condition met
$ kubectl -n etcd-backup logs job/etcd-snapshot-manual -c upload
2026/11/05 08:06:53 NOTICE: Config file "/root/.rclone.conf" not found - using defaults
uploaded etcd-20261105T080653Z.db.age
$ kubectl -n etcd-backup get cronjob etcd-snapshot -o jsonpath='{.status}{"\n"}'
{"lastSuccessfulTime":"2026-11-05T08:06:57Z"}
Сообщение про файл конфигурации безвредно: настройки rclone берёт из переменных. Ручной Job обновляет lastSuccessfulTime, но не колонку LAST SCHEDULE в kubectl get cronjob: там только запуски по расписанию. Файл в хранилище начинается со строки age-encryption.org/v1, и strings не найдёт в нём ни одного имени объекта.
Восстановление однонодового control plane
Сценарий: на k8s-master-1 кто-то удалил неймспейс с данными, или оператор испортил сотни объектов, или база повреждена. Машина и каталог /etc/kubernetes/pki целы; нужно вернуть состояние API на момент снапшота.
Главное правило из документации Kubernetes: пока работает хоть один kube-apiserver, etcd не восстанавливают. Сначала останавливаются все apiserver, потом восстанавливается etcd, потом apiserver запускаются снова. Иначе apiserver с кешем в памяти продолжит писать поверх восстановленной базы.
Шаг 0. Расшифровать снапшот на рабочей станции, где есть закрытый ключ, и скопировать на мастер:
$ age -d -i ~/secure/etcd-backup.agekey -o etcd.db etcd-20261105T080653Z.db.age
$ scp etcd.db user@k8s-master-1.example.com:/tmp/etcd.db
$ rm etcd.db
user@k8s-master-1:~$ sudo chmod 600 /tmp/etcd.db
user@k8s-master-1:~$ sudo etcdutl snapshot status /tmp/etcd.db -w table
+----------+----------+------------+------------+---------+
| HASH | REVISION | TOTAL KEYS | TOTAL SIZE | VERSION |
+----------+----------+------------+------------+---------+
| 15f337e0 | 2207961 | 577 | 35 MB | 3.6.0 |
+----------+----------+------------+------------+---------+
Шаг 1. Остановить static pods control plane. kubelet следит за каталогом манифестов и останавливает под, чей файл исчез (проверка раз в 20 секунд). Убираем все четыре — etcd, apiserver, controller-manager, scheduler — в соседний каталог:
user@k8s-master-1:~$ sudo mkdir -p /etc/kubernetes/manifests-stopped
user@k8s-master-1:~$ sudo mv /etc/kubernetes/manifests/*.yaml /etc/kubernetes/manifests-stopped/
user@k8s-master-1:~$ sudo crictl ps --name 'etcd|kube-apiserver|kube-controller-manager|kube-scheduler'
CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD NAMESPACE
Пустой список — все остановлены; на стенде это заняло полминуты. Копию манифестов в другом каталоге kubelet не читает: он смотрит только в /etc/kubernetes/manifests.
Шаг 2. Восстановить в каталог данных. Старый каталог не удаляем, а переименовываем: если что-то пойдёт не так, к нему можно вернуться. Восстанавливаем на прежнее место, /var/lib/etcd. Документация предлагает и другой путь — новый каталог с правкой hostPath в манифесте, — но в кластере kubeadm манифест при kubeadm upgrade (часть 35) сгенерируется заново с каталогом из конфигурации kubeadm, и etcd внезапно запустится на старых данных.
user@k8s-master-1:~$ sudo mv /var/lib/etcd /var/lib/etcd.old
user@k8s-master-1:~$ sudo etcdutl snapshot restore /tmp/etcd.db \
--name k8s-master-1 \
--initial-cluster k8s-master-1=https://192.168.10.17:2380 \
--initial-advertise-peer-urls https://192.168.10.17:2380 \
--data-dir /var/lib/etcd \
--bump-revision 1000000000 --mark-compacted
...
2026-11-05T08:08:49Z info snapshot/v3_snapshot.go:396 bumping latest revision {"latest-revision": 2207961, "bump-amount": 1000000000, "new-latest-revision": 1002207961}
2026-11-05T08:08:49Z info snapshot/v3_snapshot.go:413 marking revision compacted {"revision": 1002207961}
...
2026-11-05T08:08:50Z info snapshot/v3_snapshot.go:333 restored snapshot {"path": "/tmp/etcd.db", "wal-dir": "/var/lib/etcd/member/wal", "data-dir": "/var/lib/etcd", "snap-dir": "/var/lib/etcd/member/snap"}
--name, --initial-cluster и --initial-advertise-peer-urls должны совпадать с флагами из манифеста: restore создаёт новый кластер из одного члена с новым идентификатором, и etcd при запуске сверит их со своими. Два последних флага важны именно для Kubernetes. После восстановления ревизии откатываются назад, а kubelet, контроллеры и прочие клиенты помнят resourceVersion из «будущего» — больше текущей ревизии базы. --bump-revision поднимает ревизию на миллиард, чтобы каждая новая запись была заведомо новее всего, что клиенты видели. --mark-compacted помечает всю старую историю как сжатую: каждый watch получит 410 Gone, и клиенты перечитают списки объектов заново, а не будут ждать событий, которые никогда не придут.
Шаг 3. Запустить etcd, убедиться, что он жив, затем остальных:
user@k8s-master-1:~$ sudo mv /etc/kubernetes/manifests-stopped/etcd.yaml /etc/kubernetes/manifests/
user@k8s-master-1:~$ sudo etcdctl --endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/healthcheck-client.crt \
--key=/etc/kubernetes/pki/etcd/healthcheck-client.key endpoint health
https://127.0.0.1:2379 is healthy: successfully committed proposal: took = 3.530884ms
user@k8s-master-1:~$ sudo mv /etc/kubernetes/manifests-stopped/*.yaml /etc/kubernetes/manifests/
user@k8s-master-1:~$ sudo rmdir /etc/kubernetes/manifests-stopped
Шаг 4. Перезапустить компоненты, державшие состояние. Документация Kubernetes рекомендует перезапустить kube-scheduler, kube-controller-manager и kubelet, чтобы ни один из них не опирался на данные из кеша. Static pods только что запущены заново, kubelet перезапускаем на всех узлах по очереди:
user@k8s-master-1:~$ sudo systemctl restart kubelet
user@k8s-node-1:~$ sudo systemctl restart kubelet
user@k8s-node-2:~$ sudo systemctl restart kubelet
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
k8s-master-1 Ready control-plane 11d v1.36.5
k8s-node-1 Ready <none> 11d v1.36.5
k8s-node-2 Ready <none> 11d v1.36.5
$ kubectl get pods -A | grep -v Running
NAMESPACE NAME READY STATUS RESTARTS AGE
etcd-backup etcd-snapshot-manual-84l7s 0/1 Completed 0 3m12s
Завершённый под ручного снапшота — единственное, что не Running. Каталог /var/lib/etcd.old удаляют, когда стало ясно, что кластер работает, а /tmp/etcd.db — сразу.
Ревизии теперь начинаются с миллиарда, а watch из прошлого получает 410:
$ kubectl -n demo patch configmap hello-config --type merge -p '{"data":{"GREETING":"again"}}'
configmap/hello-config patched
$ kubectl -n demo get configmap hello-config -o jsonpath='{.metadata.resourceVersion}{"\n"}'
1002208190
$ kubectl get --raw "/api/v1/namespaces/demo/configmaps?watch=1&resourceVersion=2207071&timeoutSeconds=2"
{"type":"ERROR","object":{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"too old resource version: 2207071 (1002207961)","reason":"Expired","code":410}}
Что происходит после восстановления
API снова показывает мир на момент снапшота, а узлы живут в настоящем. Контроллеры и kubelet сводят одно с другим, как в любом цикле согласования, и это надо понимать заранее:
- Объекты, созданные после снапшота, исчезли. Их поды на узлах kubelet сочтёт лишними и остановит: под, которого нет в API, на узле не живёт. Deployment, созданный за пять минут до сбоя, нужно применить заново — и тут выигрывает кластер, где все манифесты лежат в Git.
- Объекты, удалённые после снапшота, вернулись. На стенде Deployment
helloудалили после снапшота, и его поды к моменту восстановления уже завершились. После восстановления они появились снова под теми же именами, а kubelet запустил их контейнеры заново:RESTARTSстал 1, адрес пода сменился. Удалённый Secret тоже снова доступен. Если удаление было исправлением (отозвали доступ, удалили скомпрометированный ключ), его придётся повторить. - Тома. PV, динамически созданные после снапшота, пропали из API, но каталоги на NFS-сервере и данные в них остались: это сироты, которые надо найти и разобрать вручную. И наоборот, PV, удалённый после снапшота вместе с данными, вернётся в API, а данных под ним уже не будет.
- Узлы. Узел, присоединённый после снапшота, сам перерегистрирует себя: kubelet создаёт объект Node, если его нет. Bootstrap-токены, выпущенные после снапшота, недействительны.
- Сертификаты cert-manager, выпущенные после снапшота, отсутствуют в Secret, и cert-manager выпустит их заново.
- IPAM Calico. Calico с хранилищем в API Kubernetes держит распределение адресов подов в своих объектах, то есть тоже в etcd. После отката они могут разойтись с реальностью; проверить согласованность помогает
calicoctl ipam check(calicoctl — утилита управления Calico, ставится отдельно; нужна переменнаяDATASTORE_TYPE=kubernetes). На стенде после отката она ответилаCheck complete; found 0 problems, но там за это время почти ничего не менялось.
Для кластера из нескольких членов процедура та же, но restore выполняется на каждом мастере из одного и того же файла снапшота, со своими --name и --initial-advertise-peer-urls и общим --initial-cluster со всеми членами.
Если всё сделано по этой части, у кластера есть ежечасный зашифрованный снапшот вне кластера, копия pki и проверенная процедура возврата. С этим можно спокойно браться за самую рискованную плановую операцию — смену версии.
Источники
- Operating etcd clusters for Kubernetes — документация Kubernetes: снапшоты, восстановление, дефрагментация.
- kube-apiserver — флаги
--etcd-compaction-intervalи--event-ttl. - Maintenance и Disaster recovery — документация etcd: compaction,
defrag, квота,etcdutl snapshot restore,--bump-revision,--mark-compacted. - Hardware recommendations, Tuning и Metrics — требования к диску, таймауты, метрики
fsync. - Failure modes и Data model — кворум, ревизии, bbolt.
- The Raft Consensus Algorithm — описание протокола.
- etcd-defrag — утилита дефрагментации.
- age и rclone: Amazon S3, глобальные флаги rclone — шифрование и выгрузка снапшотов.
- CronJob metrics — метрики kube-state-metrics.
Дальше — часть 35: обновление кластера. Политика версий и допустимый перекос между компонентами, kubeadm upgrade на мастере и воркерах, сертификаты kubeadm и их продление, обновление ОС узлов и замена узла.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.