Kubernetes deployment guide for 0.4.0-beta. RFC 0001 v2 schema. Acknowledge the beta-stage limits (e2e not fully verified, no secret rotation yet) before adopting in production.
- Kubernetes 1.28+ (depends on CEL XValidation).
- A StorageClass with dynamic PVC provisioning.
- Access to a container registry —
ghcr.io/keiailab/*(or your own mirror).
ghcr.io/keiailab/postgres-operator:<version>
Build:
make docker-build IMG=ghcr.io/keiailab/postgres-operator:dev
make docker-push IMG=ghcr.io/keiailab/postgres-operator:devghcr.io/keiailab/pg:18 (PG_MAJOR=18)
ghcr.io/keiailab/pg:17 (optional)
Build:
make docker-build-pg PG_MAJOR=18 PG_IMG=ghcr.io/keiailab/pg:18
make docker-push-pg PG_IMG=ghcr.io/keiailab/pg:18This image is defined by Dockerfile.pg — a 2-stage build (golang:1.25-bookworm
to build cmd/instance, plus postgres:18-bookworm base + pgBackRest + a
UID/GID 70 user). The ENTRYPOINT is /usr/local/bin/instance (the
instance manager runs as PID 1 and forks postgres as a child). A
BackupJob.spec.executionMode=job runner can override the command and
invoke the pgbackrest binary in the same image directly.
helm install postgres-operator charts/postgres-operator \
--namespace postgres-operator-system --create-namespace \
--set image.repository=ghcr.io/keiailab/postgres-operator \
--set image.tag=devmake build-installer
kubectl apply -f dist/install.yamlkubectl apply -f config/samples/postgres_v1alpha1_postgrescluster_dev.yamlExpected outcome:
- ConfigMap (postgresql.conf + pg_hba.conf), Headless Service, and one StatefulSet.
- ServiceAccount + Role + RoleBinding (the instance Pod gets
leases+PVC fence patchpermissions). - Pod boot flow:
initdbinit container — initializes PGDATA on a fresh PVC.- Instance manager starts as PID 1.
- Forks the postgres child.
- K8s lease-based election converges → primary elected →
pg_promote(). /readyzreturns 200 → Pod Ready.
# Status
kubectl get postgrescluster quickstart -o yaml | yq '.status'
kubectl get sts,svc,pod -l app.kubernetes.io/instance=quickstartInside the Pod via the Unix socket (peer auth, dev only):
kubectl exec quickstart-shard-0-0 -c postgres -- \
psql -h /var/run/postgresql -U postgres -c 'SELECT version()'From another Pod in the cluster (scram-sha-256):
psql "host=quickstart-shard-0-headless.default.svc.cluster.local user=postgres dbname=postgres"
Passwords are supplied through Kubernetes Secrets. For production, materialize
those Secrets from Infisical with External Secrets Operator and reference the
resulting names from PostgresUser.spec.passwordSecretRef,
Pooler.spec.pgbouncer.authSecretRef, or externalClusters[].password.
config/samples/postgres_v1alpha1_postgrescluster_prod.yaml — multi-shard
- router, replicas=2 (3-way HA), monitoring enabled, custom StorageClass.
Before applying in production verify:
- The StorageClass is fast SSD-backed (alpha recommendation: 1 GB+ / min IOPS).
- replicas ≥ 1 (HA — recommended by RFC 0001 §3).
- For RPO=0, set
spec.postgresql.synchronous.numbermust be ≤shards.replicas. - monitoring.serviceMonitor + the Prometheus operator are pre-installed.
- backup.enabled — only meaningful after the F04 follow-up PR.
The operator exposes a structured synchronous-replication surface. The
user does not write the PostgreSQL GUC synchronous_standby_names
directly; the operator generates it from the shard Pod names and the
primary_conninfo application_name.
apiVersion: postgres.keiailab.io/v1alpha1
kind: PostgresCluster
metadata:
name: quicksync
spec:
postgresVersion: "18"
shardingMode: none
shards:
initialCount: 1
replicas: 2
storage: {size: 10Gi}
postgresql:
synchronous:
method: any
number: 1
dataDurability: requiredmethod=anyuses PostgreSQL'sANY N (...)quorum form.method=firstusesFIRST N (...)priority form.dataDurability=requiredblocks commits whenever the requested number of standbys is unavailable.dataDurability=preferredlowers the quorum to the current ready replica count, and when no replicas are Ready temporarily disables synchronous replication to preserve write availability.- Configuration changes are propagated to the StatefulSet Pod template annotation as a ConfigMap hash, which triggers a shard Pod rolling reconcile.
The spec.imageCatalogRef shape lets you reference an
ImageCatalog (namespace-scoped) or a ClusterImageCatalog
(cluster-scoped). When a catalog entry changes, the referencing
PostgresCluster's StatefulSet Pod-template image and its
postgres.keiailab.io/postgres-image-catalog-sha256 annotation change
together, which triggers a Kubernetes rollout.
apiVersion: postgres.keiailab.io/v1alpha1
kind: ImageCatalog
metadata:
name: postgresql
namespace: default
spec:
images:
- major: 18
image: ghcr.io/keiailab/pg:18
---
apiVersion: postgres.keiailab.io/v1alpha1
kind: PostgresCluster
metadata:
name: quickcatalog
namespace: default
spec:
imageCatalogRef:
apiGroup: postgresql.cnpg.io
kind: ImageCatalog
name: postgresql
major: 18
shardingMode: none
shards:
initialCount: 1
replicas: 1
storage: {size: 10Gi}Compatibility / safety rules:
apiGroupaccepts empty,postgres.keiailab.io, orpostgresql.cnpg.io(the latter is retained for ecosystem compatibility).imageCatalogRef.majoris the single source of truth for image / bin directory selection, in place ofpostgresVersion. If both are present they must match.- If the catalog or the major entry cannot be located, the operator does
not fall back to a default image. Instead it fails with
status.phase=Degraded,Ready=False,Reason=ImageCatalogRejected.
The operator exposes the externalClusters + bootstrap.pg_basebackup.source +
replica.enabled/source surface for declaring a standalone replica
cluster. In this mode the ordinal-0 Pod does not run initdb; it
runs pg_basebackup from the external source and writes
standby.signal and primary_conninfo. The instance manager runs with
POSTGRES_REPLICA_CLUSTER=standalone, using a persistent-follower
election so local promotion never occurs.
apiVersion: postgres.keiailab.io/v1alpha1
kind: PostgresCluster
metadata:
name: quickreplica
namespace: default
spec:
postgresVersion: "18"
externalClusters:
- name: primary-eu
connectionParameters:
host: primary-eu-rw.data.svc
port: "5432"
user: streaming_replica
dbname: postgres
sslmode: verify-full
password:
name: primary-eu-replication-password
key: password
sslKey:
name: primary-eu-replication
key: tls.key
sslCert:
name: primary-eu-replication
key: tls.crt
sslRootCert:
name: primary-eu-ca
key: ca.crt
bootstrap:
pg_basebackup:
source: primary-eu
replica:
enabled: true
source: primary-eu
shardingMode: none
shards:
initialCount: 1
replicas: 0
storage: {size: 10Gi}Fail-closed rules:
- If
replica.enabled=true, bothreplica.sourceandbootstrap.pg_basebackup.sourceare required and must be equal. - The source name must exist in
externalClusters[].name. - If
connectionParameters.hostis missing the Pod is not created; insteadstatus.phase=Degraded,Ready=False,Reason=ReplicaClusterRejected. - If
password,sslKey,sslCert, orsslRootCertis set, bothnameandkeymust be supplied. Any omission fails closed asReplicaClusterRejected.
Current scope:
- The streaming
pg_basebackup+ continuous-recovery path and the local-promotion lockout are verified by envtest / unit tests. - The password Secret is injected via the
PRIMARY_PASSWORDSecret env, converted into/tmp/primary.pgpass. The TLS client key / cert / root cert come from a projected Secret mounted in an init container and copied to/tmp/primary-client.*, then wired intoprimary_conninfo'spassfile/sslkey/sslcert/sslrootcert. - WAL-archive / object-store hybrid, distributed-topology demotion / promotion token, and live cross-cluster drill are follow-up.
Hibernation is opted in via the cnpg.io/hibernation annotation (kept
for ecosystem-tool compatibility). Hibernation preserves the shard
StatefulSet's and PVC template's ownership while scaling the database
Pod count to zero. PVCs are not deleted, so the cluster can be
rehydrated later.
kubectl annotate postgrescluster quickstart --overwrite cnpg.io/hibernation=on
kubectl get postgrescluster quickstart -o \
'jsonpath={.status.conditions[?(@.type=="cnpg.io/hibernation")]}'
# Rehydrate
kubectl annotate postgrescluster quickstart --overwrite cnpg.io/hibernation=off
# or remove the annotation
kubectl annotate postgrescluster quickstart cnpg.io/hibernation-Expected state during hibernation:
- shard StatefulSet
spec.replicas=0. status.phase=Hibernated.status.conditions[type=cnpg.io/hibernation].status=True.Ready=False,Progressing=False.- If a native router is on, its Deployment is also
replicas=0.
./hack/smoke.sh # tears down on exit
./hack/smoke.sh --keep # keep the cluster (debugging)
PG_MAJOR=17 POSTGRES_VERSION=17 CR_NAME=quickstart17 ./hack/smoke.sh
PG_MAJOR=18 POSTGRES_VERSION=18 CR_NAME=quickstart18 ./hack/smoke.sh
PG_MAJOR=17 POSTGRES_VERSION=17 CR_NAME=quickstart17ha SHARD_REPLICAS=1 ./hack/smoke.sh
PG_MAJOR=18 POSTGRES_VERSION=18 CR_NAME=quickstart18ha SHARD_REPLICAS=1 ./hack/smoke.sh
SMOKE_POOLER=1 CR_NAME=quickstartpooler ./hack/smoke.sh
SMOKE_HIBERNATION=1 CR_NAME=quickstarthibernate ./hack/smoke.sh
PG_MAJOR=18 POSTGRES_VERSION=18 CR_NAME=quickstart18fo SHARD_REPLICAS=1 SMOKE_FAILOVER=1 ./hack/smoke.shThe script:
- Creates the kind cluster
postgres-operator-smoke. - Builds the operator + PG images locally and loads them into kind
(
SMOKE_POOLER=1also loads the PgBouncer image). - Applies
dist/install.yamlserver-side. - Applies the quickstart sample.
- Waits up to 5 minutes for
StatefulSet.ReadyReplicas ≥ 1. - Verifies a
psql -c 'SELECT 1'round-trip. - With
SMOKE_HIBERNATION=1: exercises the hibernation annotationcnpg.io/hibernation=on/off, StatefulSetreplicas=0, PVC preservation, and a marker-rowSELECTon rehydration. - With
SMOKE_POOLER=1: creates the Pooler CR + the PgBouncer auth Secret, runspsql SELECT 1through the Pooler Service, blocks new clients whenspec.paused=true, reconnects afterspec.paused=false, patchespgbouncer.parametersand confirms a configHash change with an in-placeSIGHUPreload (no Pod replacement) and a successful re-connection. - With
SHARD_REPLICAS≥1: observes the streaming standby inpg_stat_replication. - With
SMOKE_FAILOVER=1: deletes the primary Pod and measures the standby-promotion RTO.
Enabling the PgBouncer exporter sidecar adds stable selector labels to
the Pooler Pods / Service. In a Prometheus Operator environment, manage
the PodMonitor directly (no auto-generation).
kubectl apply -f config/samples/postgres_v1alpha1_pooler_podmonitor.yamlSee docs/operator-guide/pooler-monitoring.md for the full example.
PG_MAJOR selects the base major of the runtime image to build;
POSTGRES_VERSION is wired into PostgresCluster.spec.postgresVersion.
The 0.4.0-beta smoke matrix is PG17 + PG18. SHARD_REPLICAS is mapped
1:1 to spec.shards.replicas.
- Secret integration is incomplete — in alpha, the postgres user password relies on trust / peer auth. scram-sha-256 host auth is enabled only by ConfigMap. Kubernetes Secret + dynamic password rotation is a follow-up cycle (alongside F04 backup).
- HA verification is bounded — on 2026-05-07 the PG18
SHARD_REPLICAS=1 SMOKE_FAILOVER=1smoke confirmed that primary-Pod deletion → standby promotion takes RTO 21 s (< 30 s), the CR status primary converges, and the restarted old primary rejoins as standby. chaos-mesh kill / network partition, multi-node failure, and full pgBackRest-integrated production HA are F05 follow-up. - Standby reconstruction is bounded — the restarted old primary's
marker is created when the existing PGDATA and current-primary-endpoint
comparison indicates so, and the instance manager runs
pg_rewindwith the samePRIMARY_ENDPOINTand writesstandby.signal/primary_conninfo. The first-boot and rejoin standbys use the Pod name asapplication_name, so synchronous replication's standby names align. Onpg_rewindfailure we fall back to a freshpg_basebackup; if that also fails the original data dir is restored. The failure cause is surfaced inPostgresCluster.status.shards[].replicas[].reason/message. Live divergent-WAL rewind drill and external fencing / STONITH-class verification are F03 / F05 follow-up. - Synchronous replication has no live verification yet — the
postgresql.synchronousCRD / schema, the required / preferred config rendering, the ConfigMap-hash rolling reconcile, and the standbyapplication_namewiring are all unit-test-pinned. A real commit latency / RPO=0 kind drill is F05 follow-up. - Hibernation lacks live measurement — the hibernation annotation
cnpg.io/hibernation=on/off, StatefulSet scale-to-zero / restore, PVC-template preservation, and the condition / phase surface are envtest-verified, and aSMOKE_HIBERNATION=1kind drill path was added. Actual PVC data-preservation rehydrationSELECTround-trips are F05 follow-up. - Only single-shard is GA —
shardingMode=native+ multi-shard + router become meaningful after P2. This alpha guarantees onlyshardingMode=none(single shard).
| Symptom | Cause / remedy |
|---|---|
| Pod stuck in ImagePullBackOff | ghcr.io/keiailab/pg:18 not present in the cluster registry. Run make docker-build-pg + kind load docker-image, or push to a private mirror. |
| PgBouncer image kind-load fails with an OCI-index digest error | hack/smoke.sh falls back to a single-platform ctr images import when kind load docker-image fails. The upstream PgBouncer image attestation manifest can trigger this on Docker Desktop arm64. |
CRD apply fails with metadata.annotations: Too long |
dist/install.yaml exceeds the client-side apply size limit. Use kubectl apply --server-side -f dist/install.yaml instead. |
PgBouncer Pod CrashLoops on a read-only-rootfs with /tmp/.s.PGSQL.5432 |
The operator should render unix_socket_dir = to disable the Unix socket. If the ConfigMap lacks that line, rebuild with the latest operator image. |
A single-member quickstart CrashLoops after PVC label postgres.keiailab.io/fenced=true |
Legacy alpha-image bug in single-member election-stop handling. The latest PG runtime image skips the PVC fence / fast demote when POSTGRES_MEMBER_COUNT=1 and leadership stops. |
| Pod CrashLoopBackOff during initdb | PVC ownership issue. The StorageClass may not propagate fsGroup. Confirm that the SecurityContext FSGroup=70 is applied. |
/readyz 503 with "starting election" |
Normal bootstrap phase. If it persists for 30–60 s, leases RBAC is missing. Inspect kubectl get role,rolebinding -l app.kubernetes.io/instance=<cluster>. |
/readyz 503 with "postgres not ready" |
The postgres child does not answer on the local DSN. Inside the Pod run kubectl exec ... -c postgres -- ls /var/run/postgresql — if the Unix socket is missing, double-check unix_socket_directories in postgresql.conf. |
| Reconcile loops endlessly | Check the controller log. The webhook CEL XValidation may be rejecting the CR — kubectl get postgrescluster <name> -o yaml's events. |
- ADR 0002 — instance-manager PID 1 model.
- ADR 0006 — dataplane SecurityContext.
- RFC 0001 — PostgresCluster CRD v2 schema.
- RFC 0003 — election + fencing interface.