kubectl delete pod на primary выглядит как перезапуск, а для базы это отказ: поду приходит SIGTERM, потоковая репликация рвётся, и дальше всё решает оператор, а не Kubernetes. Сам по себе StatefulSet failover не даёт: он пересоздаёт под, но не выбирает новый primary. Эту работу делает оператор: держит желаемое состояние кластера и чинит отклонения циклом reconcile. Что именно происходит при потере primary, разобрано в статье про failover в Patroni, а как коммит доходит до диска под оператором, в статье про fsync и путь записи. Здесь на коде двух операторов разобрано, чем они отличаются, что защищает primary от выселения и от чего не защищает, а на двух томах Docker показано, как тип хранилища меняет латентность fsync.
Что измерено, а что прочитано
Настоящего кластера Kubernetes в опыте нет: kind, k3d и minikube в Docker требуют --privileged, а стенд его запрещает. Поэтому переключение primary, eviction и reconcile разобраны по коду CloudNativePG v1.30.1 и Zalando postgres-operator v2.0.3 (оба релиза текущие на 28.10.2026) и по их документации. Время переключения после kubectl delete pod не измерялось, и в статье его нет. Измерено то, что не требует Kubernetes: fsync на двух типах хранилища под контейнером PostgreSQL. Сетевой диск облака не измерен ни в каком виде. Оператор Crunchy PGO не разбирался.
Оператор вместо StatefulSet
Два оператора решают одну задачу по-разному.
CloudNativePG не использует StatefulSet: он сам создаёт поды и PVC, а в SetupWithManager объявляет, чем владеет (Owns(&corev1.Pod{}), Owns(&corev1.PersistentVolumeClaim{}), Owns(&policyv1.PodDisruptionBudget{}), строки 1286-1292). Причина из документации: StatefulSet строит все поды из одного шаблона, а у PostgreSQL роли разные, и порядок обновления зависит от роли, а не от порядкового номера. Решение о failover принимает сам оператор.
Zalando оставляет StatefulSet, а решение о failover отдаёт Patroni внутри пода. Оператор лишь просит Patroni о плановом переключении: Switchover вызывает c.patroni.Switchover(curMaster, candidate.Name, scheduled_at) (cluster.go, строка 1911). Как Patroni выбирает лидера по ключу в DCS, описано в отдельной статье.
Цикл reconcile и failover в два шага
Порядок в reconcile CloudNativePG читается прямо по номерам строк cluster_controller.go v1.30.1: оператор собирает статус каждого инстанса (строка 457, GetStatusFromInstances), затем первым делом решает вопрос primary (handleSwitchover, строка 586) и только потом приводит в порядок ресурсы (reconcileResources, строка 595). Если выбор primary не завершён, функция возвращает RequeueAfter: 1 * time.Second и цикл идёт заново.
Цикл reconcile оператора и объекты Kubernetes вокруг кластера
Failover в CloudNativePG идёт в два шага, чтобы старый primary не успел вернуться в репликацию (reconcileTargetPrimaryForNonReplicaCluster, тег v1.30.1):
// The current primary is not correctly working, and we need to elect a new one
// but before doing that we need to wait for all the WAL receivers to be
// terminated. To make sure they eventually terminate we signal the old primary
// (if is still alive) to shut down by setting the apiv1.PendingFailoverMarker as
// target primary.
if cluster.Status.TargetPrimary == cluster.Status.CurrentPrimary {
...
err := r.setPrimaryInstance(ctx, cluster, apiv1.PendingFailoverMarker)
if err != nil {
return "", err
}
Первый шаг не называет нового primary, а ставит в targetPrimary маркер pending: старый под видит его и останавливается, и только когда все WAL receiver на репликах закрыты (AreWalReceiversDown), оператор записывает имя самой продвинутой реплики. Документация называет причину: безопасность от расхождения timeline между новым primary и репликами. Повышение дополнительно защищено объектом Lease: повысить себя может только тот, кто его получил. Пауза перед началом задаётся failoverDelay, по умолчанию 0 с; в коде это evaluateFailoverDelay, и при нуле она возвращает nil сразу.
Zalando устроен иначе: оператор вытаскивает primary с узла, который выводят из эксплуатации, и при этом вызывает Patroni (MigrateMasterPod, pod.go, строки 266-276, тег v2.0.3):
// there are two cases for each postgres cluster that has its master pod on the node to migrate from:
// - the cluster has some replicas - migrate one of those if necessary and failover to it
// - there are no replicas - just terminate the master and wait until it respawns
// in both cases the result is the new master up and running on a new node.
if masterCandidatePod == nil {
if _, err = c.movePodFromEndOfLifeNode(oldMaster); err != nil {
return fmt.Errorf("could not move pod: %v", err)
}
return nil
}
Для кластера из одного пода код прямо предупреждает в журнале: migrating single pod cluster ... this will cause downtime of the Postgres cluster until pod is back (строка 263).
Опыт 1: fsync под PVC зависит от типа хранилища
Что стоит за PVC, оператор не меняет: PostgreSQL коммитит по fdatasync на тот том, который ему дали (путь записи описан в статье про fsync). Документация CloudNativePG говорит о том же словами: сетевое хранилище даёт «те же проблемы пропускной способности и латентности, что и в традиционной среде», локальное «гарантирует более высокую и предсказуемую производительность». Чтобы увидеть разницу, не нужен кластер: два контейнера с разным хранилищем под /var/lib/postgresql.
docker volume create dba-k8s-vol
docker run -d --name dba-k8s-vol --memory 1g -e POSTGRES_PASSWORD=pg \
-v dba-k8s-vol:/var/lib/postgresql postgres:18
docker run -d --name dba-k8s-tmpfs --memory 1g -e POSTGRES_PASSWORD=pg \
--tmpfs /var/lib/postgresql postgres:18
for c in dba-k8s-vol dba-k8s-tmpfs; do
docker exec $c sh -c 'df -T /var/lib/postgresql | tail -1;
/usr/lib/postgresql/18/bin/pg_test_fsync -s 3 -f /var/lib/postgresql/t.out'
done
Вывод целиком длиннее; ниже файловая система и блок «одна запись 8 КБ» для каждого контейнера:
-- вывод: dba-k8s-vol
/dev/nvme1n1p5 btrfs 521577472 447168476 21514244 96% /var/lib/postgresql
open_datasync 1115.165 ops/sec 897 usecs/op
fdatasync 1121.586 ops/sec 892 usecs/op
fsync 1106.588 ops/sec 904 usecs/op
open_sync 914.917 ops/sec 1093 usecs/op
-- вывод: dba-k8s-tmpfs
tmpfs tmpfs 32841080 39848 32801232 1% /var/lib/postgresql
open_datasync 3528778.119 ops/sec 0 usecs/op
fdatasync 2695566.478 ops/sec 0 usecs/op
fsync 2660775.131 ops/sec 0 usecs/op
open_sync 3547085.390 ops/sec 0 usecs/op
Именованный том Docker здесь лежит на btrfs поверх NVMe: fdatasync стоит 892 мкс на 8 КБ. На tmpfs тот же вызов занимает 0 мкс на операцию (вывод округлён до микросекунды): данные живут только в памяти, и fsync ничего не записывает. Это нижняя граница «хранилища, которое ничего не стоит», а не рабочий вариант: такой том не переживает остановку контейнера. Сетевой диск облака в эту шкалу не входит, он не измерен: его число зависит от провайдера, класса диска и нагрузки, и его нужно мерить тем же pg_test_fsync на PVC самого кластера.
Опыт 2: что это делает с коммитом
Один клиент, один фиксирующий коммит за раз: время транзакции складывается из работы и ожидания fsync WAL.
for c in dba-k8s-vol dba-k8s-tmpfs; do echo "== $c"; docker exec $c sh -c \
'pgbench -U postgres -i -s 1 -q postgres >/dev/null 2>&1;
pgbench -U postgres -N -c1 -T10 postgres 2>&1 | grep -E "tps|latency average"'; done
-- вывод
== dba-k8s-vol
latency average = 1.725 ms
tps = 579.658661 (without initial connection time)
== dba-k8s-tmpfs
latency average = 0.106 ms
tps = 9443.539297 (without initial connection time)
wal_sync_method = fdatasync, synchronous_commit = on, fsync = on на обоих контейнерах. Разница в 1,62 мс на транзакцию (1,725 против 0,106): единственное, чем контейнеры отличаются, — хранилище под /var/lib/postgresql, настройки и лимит памяти одинаковы. Для базы в кластере отсюда следует одно: класс хранилища в StorageClass — решение о латентности коммита, а оператор его не лечит. Поэтому прежде чем переносить базу в Kubernetes, мерят fsync на PVC будущего кластера, а не берут цифру из чужого описания.
Eviction: что защищает primary, а что нет
Примечание о терминах: выселение (eviction) идёт через Eviction API и учитывает PodDisruptionBudget, а прямое удаление пода его обходит. Документация Kubernetes советует инструментам использовать Eviction API «вместо прямого удаления подов»; kubectl drain так и делает, а kubectl delete pod нет.
Zalando создаёт два PDB. Первый держит minAvailable: 1 и добавляет в селектор роль primary (spilo-role=master), если не отключено pdb_master_label_selector (k8sres.go, строки 2295-2304, тег v2.0.3):
// if PodDisruptionBudget is disabled or if there are no DB pods, set the budget to 0.
if (pdbEnabled != nil && !(*pdbEnabled)) || c.Spec.NumberOfInstances <= 0 {
minAvailable = intstr.FromInt(0)
}
// define label selector and add the master role selector if enabled
labels := c.labelsSet(false)
if pdbMasterLabelSelector == nil || *c.OpConfig.PDBMasterLabelSelector {
labels[c.OpConfig.PodRoleLabel] = string(Master)
}
Документация объясняет эффект так: бюджет «не даёт убить мастера в кластере из одного узла и/или последний работающий экземпляр». CloudNativePG делает то же через два PDB на кластер и дополнительно переключает primary перед drain узла; в кластере из одного инстанса drain узла блокируется, а в кластере из трёх и больше за раз корректно гасится одна реплика.
Но к kubectl delete pod на primary бюджет не причастен. Документация CloudNativePG прямо просит не удалять под с primary, а сначала сделать switchover, иначе риск для RPO. Под получает SIGTERM, instance manager делает CHECKPOINT и smart shutdown на smartShutdownTimeout (по умолчанию 180 с), затем fast shutdown в пределах stopDelay (1800 с). Если primary вообще не отвечает на pg_isready, fast shutdown пропускается и сразу идёт immediate. Плановое переключение использует switchoverDelay, по умолчанию 3600 с: это потолок ожидания остановки старого primary, а не типичное время. Нужная цифра для твоего кластера получается только замером в нём; у Zalando остановка пода ограничена pod_terminate_grace_period (по умолчанию 5 мин).
Когда это плохая идея
- Одиночный инстанс. Drain узла блокируется в CloudNativePG, а в Zalando перенос primary без реплики с недоступного узла по документации вызывает простой, пока под не вернётся.
- Локальный диск. Документация Kubernetes: если узел с локальным томом нездоров, том недоступен, под не запускается, а приложение должно переносить «такую сниженную доступность и потенциальную потерю данных» (volumes).
- Сетевой диск без замера. Как в опыте 1, fsync определяет коммит; число для твоего PVC неизвестно, пока его не измерили.
- Нет людей, которые знают Kubernetes. FAQ CloudNativePG советует каждому, кто хочет использовать оператор в полную силу, получить сертификат CKA. Если команда не готова читать события и статусы подов, база вне кластера проще.
Что из этого следует
Оператор переносит в код те же решения, что и Patroni: кто primary, когда безопасно повышать, что делать со старым. Поэтому проверять нужно его параметры и защиту, а не «жив ли под». Пауза failoverDelay по умолчанию нулевая, а kubectl delete pod обходит PDB. Отдельно проверяется хранилище: оно либо даёт приемлемый fsync на твоём PVC, либо база не годится для кластера.
Чек-лист
- Измерь
pg_test_fsyncиpgbench -c1на PVC будущего кластера до переноса, а не после. - Проверь
kubectl get pdb: бюджет есть для каждого кластера и не отключён флагом ради удобства обновлений. - Не удаляй под primary командой
kubectl delete pod: сначала плановый switchover. - Выставь
failoverDelay,switchoverDelayиstopDelayосознанно, прочитав в документации, что каждый из них оставляет в RPO и RTO. - Для кластера из одного инстанса заранее реши, что считаешь допустимым простоем при drain узла.