CloudNativePG 1.28: установка и тест отказоустойчивости

Установка CloudNativePG (CNPG 1.28) и первый тест: имитация временного сбоя

Это первая статья из серии, посвящённой изучению CloudNativePG (CNPG) — оператора Kubernetes для PostgreSQL, который автоматизирует обеспечение высокой доступности в контейнерных средах.

PostgreSQL сам по себе поддерживает физическую потоковую репликацию (physical streaming replication), однако не содержит логики оркестрации — нет автоматического продвижения реплики, масштабирования или переключения при сбое. Такие инструменты, как Patroni, закрывают этот пробел, реализуя консенсус (через etcd, Consul, ZooKeeper, Kubernetes или Raft) для управления состоянием кластера. В Kubernetes базы данных нередко разворачивают с помощью StatefulSet, которые обеспечивают стабильные сетевые идентификаторы и постоянное хранилище для каждого экземпляра. CloudNativePG, в отличие от этого подхода, определяет специфичные для PostgreSQL ресурсы через механизм пользовательских определений ресурсов (Custom Resource Definitions, CRDs), вводя следующие объекты:

  • ImageCatalog — каталоги образов PostgreSQL

  • Cluster — описание кластера PostgreSQL

  • Database — декларативное управление базами данных

  • Pooler — пул соединений PgBouncer

  • Backup — запросы на резервное копирование по требованию

  • ScheduledBackup — автоматическое резервное копирование по расписанию

  • Publication — публикации логической репликации

  • Subscription — подписки логической репликации

Установка: управляющий уровень для PostgreSQL

Здесь используется CNPG 1.28 — первый релиз с поддержкой отказоустойчивости на основе кворума (quorum-based failover). В более ранних версиях продвигался наиболее актуальный резервный узел без гарантии отсутствия потери данных (что подходит для аварийного восстановления, но не для строгой высокой доступности).

Установим компоненты оператора:

kubectl apply --server-side -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.28/releases/cnpg-1.28.0.yaml

CRD и контроллер разворачиваются в пространстве имён cnpg-system. Проверим статус развёртывания:

kubectl rollout status deployment -n cnpg-system cnpg-controller-manager
deployment "cnpg-controller-manager" successfully rolled out

Это Deployment определяет CloudNativePG Controller Manager — компонент управляющего уровня (control plane). Он работает как один под и непрерывно согласует ресурсы кластера PostgreSQL с их желаемым состоянием через Kubernetes API:

kubectl get deployments -n cnpg-system -o wide
NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR
cnpg-controller-manager 1/1 1 1 11d manager ghcr.io/cloudnative-pg/cloudnative-pg:1.28.0 app.kubernetes.io/name=cloudnative-pg

Контейнеры пода прослушивают порты для метрик (8080/TCP) и настройки вебхуков (9443/TCP) и взаимодействуют с CRD CNPG в цикле согласования:

kubectl describe deploy -n cnpg-system cnpg-controller-manager
Name: cnpg-controller-manager
Namespace: cnpg-system
CreationTimestamp: Thu, 15 Jan 2026 21:04:25 +0100
Labels: app.kubernetes.io/name=cloudnative-pg
Annotations: deployment.kubernetes.io/revision: 1
Selector: app.kubernetes.io/name=cloudnative-pg
Replicas: 1 desired | 1 updated | 1 total | 1 available | 0 unavailable
StrategyType: RollingUpdate
MinReadySeconds: 0
RollingUpdateStrategy: 25% max unavailable, 25% max surge
Pod Template:
Labels: app.kubernetes.io/name=cloudnative-pg
Service Account: cnpg-manager
Containers:
manager:
Image: ghcr.io/cloudnative-pg/cloudnative-pg:1.28.0
Ports: 8080/TCP (metrics), 9443/TCP (webhook-server)
Host Ports: 0/TCP (metrics), 0/TCP (webhook-server)
SeccompProfile: RuntimeDefault
Command:
/manager
Args:
controller
--leader-elect
--max-concurrent-reconciles=10
--config-map-name=cnpg-controller-manager-config
--secret-name=cnpg-controller-manager-config
--webhook-port=9443
Limits:
cpu: 100m
memory: 200Mi
Requests:
cpu: 100m
memory: 100Mi
Liveness: http-get https://:9443/readyz delay=0s timeout=1s period=10s #success=1 #failure=3
Readiness: http-get https://:9443/readyz delay=0s timeout=1s period=10s #success=1 #failure=3
Startup: http-get https://:9443/readyz delay=0s timeout=1s period=5s #success=1 #failure=6
Environment:
OPERATOR_IMAGE_NAME: ghcr.io/cloudnative-pg/cloudnative-pg:1.28.0
OPERATOR_NAMESPACE: (v1:metadata.namespace)
MONITORING_QUERIES_CONFIGMAP: cnpg-default-monitoring
Mounts:
/controller from scratch-data (rw)
/run/secrets/cnpg.io/webhook from webhook-certificates (rw)
Volumes:
scratch-data:
Type: EmptyDir (a temporary directory that shares a pod's lifetime)
Medium:
SizeLimit: <unset>
webhook-certificates:
Type: Secret (a volume populated by a Secret)
SecretName: cnpg-webhook-cert
Optional: true
Node-Selectors: <none>
Tolerations: <none>
Conditions:
Type Status Reason
---- ------ ------
Progressing True NewReplicaSetAvailable
Available True MinimumReplicasAvailable
OldReplicaSets: <none>
NewReplicaSet: cnpg-controller-manager-6b9f78f594 (1/1 replicas created)
Events: <none>

Развёртывание: уровень данных (кластер PostgreSQL)

Управляющий уровень отвечает за логику оркестрации. Сами экземпляры PostgreSQL — уровень данных (data plane) — управляются через пользовательский ресурс Cluster в CNPG.

Создадим отдельное пространство имён:

kubectl delete namespace lab
kubectl create namespace lab
namespace/lab created

Вот минимальная спецификация кластера с высокой доступностью:

  • 3 экземпляра: 1 первичный, 2 горячих резервных реплики

  • Синхронный коммит на 1 реплику

  • Отказоустойчивость на основе кворума включена

cat > lab-cluster-rf3.yaml <<'YAML'
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: cnpg
spec:
instances: 3
postgresql:
synchronous:
method: any
number: 1
failoverQuorum: true
storage:
size: 1Gi
YAML
kubectl -n lab apply -f lab-cluster-rf3.yaml

CNPG создаёт поды с семантикой stateful-хранилища, используя PersistentVolumeClaim для хранения данных:

kubectl -n lab get pvc -o wide
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE VOLUMEMODE
cnpg-1 Bound pvc-76754ba4-e8bd-4218-837f-36aa0010940f 1Gi RWO hostpath <unset> 42s Filesystem
cnpg-2 Bound pvc-3b231dcc-b973-43f8-a429-80222bd51420 1Gi RWO hostpath <unset> 26s Filesystem
cnpg-3 Bound pvc-b8e4c6a0-bbcb-445d-9267-ffe38a1a8685 1Gi RWO hostpath <unset> 10s Filesystem

Эти PVC привязываются к PersistentVolume, предоставленным их классом хранения:

kubectl -n lab get pv -o wide
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE VOLUMEMODE
pvc-3b231dcc-b973-43f8-a429-80222bd51420 1Gi RWO Delete Bound lab/cnpg-2 hostpath <unset> 53s Filesystem
pvc-76754ba4-e8bd-4218-837f-36aa0010940f 1Gi RWO Delete Bound lab/cnpg-1 hostpath <unset> 69s Filesystem
pvc-b8e4c6a0-bbcb-445d-9267-ffe38a1a8685 1Gi RWO Delete Bound lab/cnpg-3 hostpath <unset> 37s Filesystem

Экземпляры PostgreSQL работают в подах:

kubectl -n lab get pod -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
cnpg-1 1/1 Running 0 3m46s 10.1.0.141 docker-desktop <none> <none>
cnpg-2 1/1 Running 0 3m29s 10.1.0.143 docker-desktop <none> <none>
cnpg-3 1/1 Running 0 3m13s 10.1.0.145 docker-desktop <none> <none>

В Kubernetes поды обычно считаются равнозначными, однако PostgreSQL использует единственный первичный узел, а остальные поды выступают репликами только для чтения. CNPG отслеживает, в каком поде работает первичный экземпляр:

kubectl -n lab get cluster
NAME AGE INSTANCES READY STATUS PRIMARY
cnpg 4m 3 3 Cluster in healthy state cnpg-1

Поскольку роли подов могут меняться при переключении (switchover) или аварийном переходе (failover), доступ приложений осуществляется через сервисы, которые направляют трафик к нужным экземплярам:

kubectl -n lab get svc -o wide
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR
cnpg-r ClusterIP 10.97.182.192 <none> 5432/TCP 4m13s cnpg.io/cluster=cnpg,cnpg.io/podRole=instance
cnpg-ro ClusterIP 10.111.116.164 <none> 5432/TCP 4m13s cnpg.io/cluster=cnpg,cnpg.io/instanceRole=replica
cnpg-rw ClusterIP 10.108.19.85 <none> 5432/TCP 4m13s cnpg.io/cluster=cnpg,cnpg.io/instanceRole=primary

Это конечные точки для подключения к PostgreSQL:

  • cnpg-rw — подключение к первичному узлу для согласованного чтения и записи

  • cnpg-ro — подключение к одному из резервных узлов для чтения с возможным устареванием данных

  • cnpg-r — подключение к первичному или резервному узлу для чтения с возможным устареванием данных

Балансировка нагрузки для операций чтения выполняется по принципу round-robin, как при использовании списка хостов, — нагрузка равномерно распределяется по всем репликам.

Настройка клиентского доступа

CNPG создаёт учётные данные в секрете Kubernetes с именем cnpg-app для пользователя app:

kubectl -n lab get secrets
NAME TYPE DATA AGE
cnpg-app kubernetes.io/basic-auth 11 8m48s
cnpg-ca Opaque 2 8m48s
cnpg-replication kubernetes.io/tls 2 8m48s
cnpg-server kubernetes.io/tls 2 8m48s

При необходимости пароль можно получить командой kubectl -n lab get secret cnpg-app -o jsonpath='{.data.password}' | base64 -d).

Определим псевдоним командной строки для запуска пода с клиентом PostgreSQL с этими учётными данными:

for role in rw ro r
do
alias pg${role}='kubectl -n lab run client${role} --rm -it --restart=Never \
--env PGHOST="cnpg-'${role}'" \
--env PGUSER="app" \
--env PGPASSWORD="$(kubectl -n lab get secret cnpg-app -o jsonpath='{.data.password}' | base64 -d)" \
--image=postgres:18 --'
done

Псевдоним pgrw используется для запуска клиента PostgreSQL с подключением к первичному узлу.

Стандартная нагрузка через PgBench

С помощью определённого выше псевдонима инициализируем таблицы PgBench:

pgrw pgbench -i
dropping old tables...
creating tables...
generating data (client-side)...
vacuuming...
creating primary keys...
done in 0.10 s (drop tables 0.02 s, create tables 0.01 s, client-side generate 0.04 s, vacuum 0.01 s, primary keys 0.01 s).

Запустим тест на 10 минут с выводом прогресса каждые 5 секунд:

pgrw pgbench -T 600 -P 5
progress: 5.0 s, 1541.4 tps, lat 0.648 ms stddev 0.358, 0 failed
progress: 10.0 s, 1648.6 tps, lat 0.606 ms stddev 0.154, 0 failed
progress: 15.0 s, 1432.7 tps, lat 0.698 ms stddev 0.218, 0 failed
progress: 20.0 s, 1581.3 tps, lat 0.632 ms stddev 0.169, 0 failed
progress: 25.0 s, 1448.2 tps, lat 0.690 ms stddev 0.315, 0 failed
progress: 30.0 s, 1640.6 tps, lat 0.609 ms stddev 0.155, 0 failed
progress: 35.0 s, 1609.9 tps, lat 0.621 ms stddev 0.223, 0 failed

Имитация сбоя

В другом терминале проверим, какой под является первичным:

kubectl -n lab get cluster
NAME AGE INSTANCES READY STATUS PRIMARY
cnpg 40m 3 3 Cluster in healthy state cnpg-1

Я использовал Kubeadm, где поды работают в Docker-контейнерах (в отличие от Kind, где каждый рабочий узел — это Docker-контейнер).

Через графический интерфейс Docker Desktop я приостановил (paused) контейнер в поде первичного узла.

Запросы PgBench зависают, так как первичный узел, к которому установлено подключение, перестаёт отвечать.

После восстановления пода PgBench продолжает работу без разрыва соединения.

Kubernetes отслеживает работоспособность подов с помощью проб живости и готовности (liveness/readiness probes) и перезапускает контейнеры при их сбое. В данном случае, обнаружив приостановку через Docker, Kubernetes восстанавливает контейнер, снимая паузу. Здесь сервис восстановил Kubernetes, а не CNPG.

Тем временем CNPG независимо отслеживал состояние PostgreSQL и инициировал переключение при сбое ещё до того, как Kubernetes перезапустил под:

franck.pachot@M-C7Y646J4JP cnpg % kubectl -n lab get cluster
NAME AGE INSTANCES READY STATUS PRIMARY
cnpg 3m6s 3 2 Failing over cnpg-1

Kubernetes восстановил сервис примерно через 30 секунд, однако CNPG к тому моменту уже запустил процедуру переключения. Это означает новый простой.

Спустя несколько минут cnpg-1 перезапустился, и PgBench завершился с ошибкой:

WARNING: canceling the wait for synchronous replication and terminating connection due to administrator command
DETAIL: The transaction has already committed locally, but might not have been replicated to the standby.
pgbench: error: client 0 aborted in command 10 (SQL) of script 0; perhaps the backend died while processing

Поскольку cnpg-1 оставался живым и исправным, он по-прежнему оставался первичным узлом, однако все соединения были разорваны.

Воспроизведение с Kubeadm

В Docker Desktop при использовании Kubeadm, где каждый под Kubernetes работает в Docker-контейнере, приостановку можно выполнить так же, как я делал это вручную:

namespace="lab"
cluster="cnpg"
docker pause $(
docker ps --filter "name=$(
kubectl -n "${namespace}" get cluster "${cnpg}" -o jsonpath='{.status.currentPrimary}'
)" --format "{{.ID}}"
)

Как и при ручном выполнении, kubelet расценивает это как нездоровое состояние и инициирует восстановление, чтобы поддерживать желаемое состояние пода.

Воспроизведение с Kind

В Docker Desktop при использовании Kind каждый узел Kubernetes работает в Docker-контейнере. Для взаимодействия с контейнерами можно использовать crictl, однако у него нет команды pause. Того же эффекта можно добиться, заморозив cgroup (идея взята из freeze.sh):

namespace="lab"
cluster="cnpg"
# Get the primary's node name which is the docker container name
primary_pod=$(
kubectl -n ${namespace} get cluster ${cluster} -o jsonpath='{.status.currentPrimary}'
)
primary_node=$(
kubectl -n ${namespace} get pod ${primary_pod} -o jsonpath='{.spec.nodeName}'
)
# get the Cgroup2 file to freeze the cgroup
freeze_file=/sys/fs/cgroup$(
docker exec -i "${primary_node}" bash <<SH
cat /proc/\$(
crictl inspect \$(
crictl ps --output json |
jq -r --arg POD ${primary_pod} '.containers[] |
select(.labels["io.kubernetes.pod.name"]==\$POD and .metadata.name=="postgres") |
.id'
) | jq -r '.info.pid'
)/cgroup | cut -d: -f3
SH
)/cgroup.freeze
# Freeze the pod's container for 30 seconds
echo "Primary pod: ${primary_pod} node: ${primary_node} cgroup2 freeze file: ${freeze_file}"
docker exec -i "${primary_node}" bash -x <<SH
cat "${freeze_file}"
echo 1 > "${freeze_file}"
echo 0 > "${freeze_file}"
SH

Прямая заморозка cgroup не обновляет состояние среды выполнения контейнеров так, как это делает docker pause, поэтому kubelet не воспринимает ситуацию как нездоровое состояние — несмотря на то что процессы заморожены и база данных не отвечает. Автоматического восстановления не происходит, потому что kubelet не видит проблемы. Чтобы воспроизвести поведение, описанное выше, мы размораживаем cgroup через 30 секунд.

Заморозка cgroup вместо приостановки Docker-контейнера лучше имитирует реальные временные сбои — зависания процессов, нехватку ресурсов или сетевые разделы, — поскольку в реальной жизни отказы не оставляют систему в таком чистом состоянии, как docker pause.

Выводы

Этот тест наглядно показывает, как PostgreSQL и Kubernetes взаимодействуют под управлением CloudNativePG. Проверки работоспособности подов в Kubernetes и логика аварийного переключения CloudNativePG выполняются в независимых контурах управления:

  • Kubernetes перезапускает контейнеры при сбое проб живости или готовности.

  • CloudNativePG (CNPG) оценивает работоспособность базы данных по состоянию репликации, кворуму и доступности менеджера экземпляров.

Кратковременная приостановка контейнера запустила проверку изоляции первичного узла в CNPG. Когда первичный узел теряет связь как с Kubernetes API, так и с остальными участниками кластера, CNPG останавливает его, чтобы предотвратить ситуацию split-brain («разделённый мозг»). Хронология событий:

  • T+0с — Первичный узел приостановлен. CNPG фиксирует изоляцию.

  • T+30с — Kubernetes перезапускает нездоровый контейнер.

  • T+180с — CNPG инициирует переключение при сбое.

  • T+275с — Остановка первичного узла разрывает клиентские соединения.

Поскольку CNPG и Kubernetes действуют по разным временным шкалам, исходный под перезапустился в роли первичного («самостоятельное переключение»), когда ни одна из реплик не оказалась подходящим кандидатом для продвижения. CNPG отдаёт приоритет целостности данных, а не скорости восстановления, и, не имея протокола консенсуса вроде Raft, опирается на:

  • состояние Kubernetes API

  • потоковую репликацию PostgreSQL

  • проверки работоспособности менеджера экземпляров

Это может привести к дополнительным простоям при временных сбоях, однако предотвращает split-brain и снижает риск сложных аварийных переключений из-за кратковременных отказов. Соответствующее обсуждение:

Мы наблюдали перезапуск первичного узла. Приложение должно учитывать, что при перезапуске между локальным коммитом и получением подтверждения от реплики могут остаться транзакции в неопределённом состоянии (in-doubt commits), как поясняется в syncrep.c:

Если ожидание синхронной репликации активно, мы не можем ни подтвердить коммит, ни вызвать ERROR или FATAL. Поэтому в таком случае выдаётся предупреждение WARNING (которое некоторые клиенты могут интерпретировать).

Облачные системы могут отказывать самыми разными способами. В этом тесте я использовал docker pause, чтобы заморозить процессы и сымитировать первичный узел, переставший отвечать клиентам и проверкам работоспособности. Это повторяет аналогичный тест, который я проводил с Yugabyte: YugabyteDB Recovery Time Objective (RTO) with PgBench: continuous availability with max. 15s latency on infrastructure failure.

Эта статья открывает серию материалов о CNPG, в которой я также рассмотрю такие отказы, как сетевые разделы и проблемы с хранилищем, а также пул соединений.

Логотип сообщества DEV
© 2026 meganuke