Справочник DBA: базы данных изнутри читается по порядку

Статья 13.2 · раздел 13 «Инфраструктура и облако» · 96 из 109

PostgreSQL в Kubernetes: оператор вместо StatefulSet, failover в два шага и fsync 892 мкс на томе против 0 на tmpfs

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнерах postgres:18 по 1 ГБ, Docker 29.7.2, именованный том Docker на btrfs поверх NVMe и tmpfs-монтирование; pg_test_fsync из образа, pgbench -s 1; Kubernetes не запускался; код: CloudNativePG v1.30.1, Zalando postgres-operator v2.0.3; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

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 и цикл идёт заново.

DBA / 01

Цикл reconcile оператора и объекты Kubernetes вокруг кластера

Цикл reconcile оператора и объекты Kubernetes вокруг кластераТри шага цикла: наблюдение за статусом инстансов, сравнение targetPrimary с currentPrimary и действие, то есть установка targetPrimary в pending, затем в имя реплики. Цикл повторяется через 1 секунду. Вокруг цикла три объекта Kubernetes: под и PVC на инстанс без StatefulSet, два PodDisruptionBudget, которые защищают от eviction, но не от kubectl delete, и Lease, из-за которого повышается один primary за раз. Таймауты из документации: switchoverDelay 3600 секунд, stopDelay 1800, smartShutdownTimeout 180.1 наблюдениестатус каждого инстансастрока 4572 сравнениеtargetPrimary иcurrentPrimary3 действиеtargetPrimary: pending,затем имя репликиrequeue через 1 с, пока выбор не завершёнпод + PVCна каждый инстанс,без StatefulSet2 PodDisruptionBudgetдержат eviction,не держат kubectl deleteLeaseповышается одинprimary за разТаймауты по документации: smartShutdownTimeout 180 с, stopDelay 1800 с, switchoverDelay 3600 с
Оператор не ждёт события, а раз за разом сверяет статус с желаемым состоянием; всё, что защищает primary, лежит в объектах вокруг цикла, и каждый из них закрывает свой путь отказа.

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 узла.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru