Установка 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, в которой я также рассмотрю такие отказы, как сетевые разделы и проблемы с хранилищем, а также пул соединений.