Split View: CloudNativePG로 쿠버네티스에 Postgres 띄우고 죽여보기 — 페일오버 23초 실측
CloudNativePG로 쿠버네티스에 Postgres 띄우고 죽여보기 — 페일오버 23초 실측
- 들어가며 — Postgres를 오퍼레이터에게 맡긴다는 것
- 1부 — 설치: 매니페스트 하나
- 2부 — 3인스턴스 클러스터 만들기
- 3부 — 복제 확인
- 4부 — 프라이머리를 죽인다: 페일오버 23.1초
- 5부 — 정직한 숫자와 함정
- 마치며
- 참고 자료
들어가며 — Postgres를 오퍼레이터에게 맡긴다는 것
쿠버네티스에서 상태 있는(stateful) 데이터베이스를 돌리는 건 오래 금기처럼 여겨졌습니다. CloudNativePG(CNPG)는 그 금기를 정면으로 깨는 오퍼레이터입니다 — Postgres의 고가용성·페일오버·백업·롤링 업그레이드를 CRD 하나로 선언하면, 오퍼레이터가 프라이머리 선출과 레플리카 관리를 대신해 줍니다. 이 글은 소개가 아니라 실측입니다: 진짜 8노드 클러스터에 CNPG를 깔고, 3인스턴스 Postgres를 띄우고, 정말로 프라이머리를 죽여서 페일오버가 몇 초 걸리는지 재봤습니다. 오퍼레이터를 직접 짜 본 Rust GPU 오퍼레이터 편과 짝을 이루는, "잘 만든 오퍼레이터를 쓰는" 쪽의 이야기입니다.
1부 — 설치: 매니페스트 하나
CNPG 설치는 단출합니다. 릴리스 매니페스트 하나를 apply하면 끝입니다.
kubectl apply --server-side -f \
https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.30/releases/cnpg-1.30.0.yaml
이 한 줄이 CRD 여러 개(clusters, poolers, scheduledbackups, publications, subscriptions 등), cnpg-system 네임스페이스의 컨트롤러 디플로이먼트, RBAC, 웹훅을 모두 깝니다. 컨트롤러가 뜨는 데 20초쯤 걸렸습니다:
$ kubectl -n cnpg-system rollout status deploy/cnpg-controller-manager
deployment "cnpg-controller-manager" successfully rolled out
NAME READY UP-TO-DATE AVAILABLE AGE
cnpg-controller-manager 1/1 1 1 16s
2부 — 3인스턴스 클러스터 만들기
Postgres 클러스터는 Cluster CR로 선언합니다. 프라이머리 1 + 레플리카 2, 즉 3인스턴스로 잡았습니다.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pg-test
namespace: cnpg-test
spec:
instances: 3
storage:
size: 1Gi
storageClass: nfs-client # 홈랩의 NFS — 함정은 5부에서
bootstrap:
initdb:
database: appdb
owner: appuser
apply하면 오퍼레이터가 부트스트랩을 시작합니다. 상태 전이를 실시간으로 지켜봤습니다:
Setting up primary
→ Waiting for the instances to become active
→ ready=1 Creating a new replica ← 프라이머리 뜬 뒤 레플리카 복제 시작
→ ready=2 Creating a new replica
→ ready=3 Cluster in healthy state ← 약 2분 만에 3/3 정상
3개 인스턴스는 서로 다른 노드(cubi02·cubi03·cubi04)에 자동 분산됐습니다 — 오퍼레이터가 anti-affinity로 한 노드에 몰리지 않게 합니다. CNPG는 접속용 서비스도 셋 만들어 줍니다:
서비스 역할
──────────── ─────────────────────────────
pg-test-rw 읽기·쓰기 → 항상 현재 프라이머리로 라우팅
pg-test-ro 읽기 전용 → 레플리카들로 로드밸런싱
pg-test-r 아무 인스턴스나 (읽기)
-rw 서비스가 핵심입니다 — 애플리케이션은 이 이름 하나만 바라보면, 페일오버로 프라이머리가 바뀌어도 자동으로 새 프라이머리에 연결됩니다.
3부 — 복제 확인
프라이머리에 1000행을 넣고 세 인스턴스가 모두 같은지 봤습니다.
$ 프라이머리(pg-test-1)에 INSERT 1000행
INSERT 0 1000
$ 각 인스턴스 행 수
pg-test-1 (primary): 1000 rows
pg-test-2 (replica): 1000 rows ← 복제됨
pg-test-3 (replica): 1000 rows ← 복제됨
스트리밍 복제가 즉시 세 노드를 동기화했습니다. 이제 진짜 실험입니다.
4부 — 프라이머리를 죽인다: 페일오버 23.1초
가장 궁금했던 것 — 프라이머리가 갑자기 사라지면 몇 초 만에 복구될까? --grace-period=0 --force로 프라이머리 파드를 즉사시키고, 새 프라이머리가 나올 때까지 0.5초 간격으로 폴링하며 시간을 쟀습니다.
=== FAILOVER TEST: killing primary pg-test-1 ===
deleted at t0; polling for new primary...
=== NEW PRIMARY: pg-test-2 (was pg-test-1) ===
failover time: 23.1 s
rows after failover: 1000 ← 데이터 무손실
23.1초 만에 pg-test-2가 새 프라이머리로 승격됐고, 1000행이 그대로 살아 있었습니다. 그리고 죽었던 pg-test-1은 버려지지 않습니다 — 오퍼레이터가 자동으로 다시 데려와 레플리카로 재편입시킵니다:
=== 자가 복구 후 최종 역할 ===
pg-test-1: replica ← 죽었다 살아나 레플리카로 강등 복귀
pg-test-2: primary ← 승격된 새 프라이머리
pg-test-3: replica
$ 새 프라이머리에 추가 쓰기 → 정상, 총 1500행
새 프라이머리는 곧바로 쓰기를 받았고(1000→1500행), 클러스터는 다시 3/3 healthy로 돌아왔습니다. 사람 개입 0번. 이게 오퍼레이터의 값어치입니다.
5부 — 정직한 숫자와 함정
블로그는 검증된 것만 쓴다는 원칙대로, 이 실험의 한계도 그대로 남깁니다.
- 23초는 빠른가? 상황에 따라 다릅니다. CNPG의 페일오버 시간은 프라이머리 사망 감지(헬스체크 주기), 레플리카 승격, 그리고
-rw서비스 엔드포인트 갱신의 합입니다. 프로덕션에서는 파드 삭제가 아니라 노드 장애가 더 흔하고, 그 경우 노드 감지 시간(node-monitor-grace-period등)이 더해져 더 길어질 수 있습니다. 반대로 튜닝하면 더 짧아집니다. "23초"는 이 홈랩·이 설정의 실측치이지 보편 상수가 아닙니다. - NFS 스토리지의 함정. 저는
nfs-client(NFS provisioner) 스토리지를 썼는데, Postgres를 NFS 위에 올리는 건 프로덕션에서는 권장되지 않습니다 — fsync 보장·파일 잠금 이슈 때문입니다. 홈랩 테스트로는 잘 돌았지만, 실서비스라면 로컬 SSD나 블록 스토리지(Ceph RBD 등)를 써야 합니다. - 동기 vs 비동기 복제. 이 테스트는 기본(비동기) 복제였습니다. 비동기에서는 이론상 프라이머리 사망 직전 극소량의 미복제 트랜잭션이 유실될 수 있습니다. 무손실이 필요하면 CNPG의 동기 복제(
minSyncReplicas)를 켜야 하고, 그 대가로 쓰기 지연이 늘어납니다.
마치며
CNPG는 "쿠버네티스에서 DB는 위험하다"는 통념을 실측으로 반박합니다 — 매니페스트 하나로 3노드 HA Postgres가 뜨고, 프라이머리를 즉사시켜도 23초 만에 스스로 복구하며, 죽은 노드는 레플리카로 되돌아옵니다. 물론 스토리지·복제 모드·페일오버 튜닝이라는 진짜 숙제가 남지만, 그건 "DB를 오퍼레이터에게 맡길 수 있는가"의 문제가 아니라 "어떻게 잘 맡길까"의 문제입니다. 다음엔 백업(ScheduledBackup)과 특정 시점 복구(PITR)를 같은 방식으로 죽여보며 검증해 볼 참입니다.
참고 자료
Spinning Up and Killing Postgres on Kubernetes with CloudNativePG — Failover Measured at 23 Seconds
- Introduction — What It Means to Hand Postgres Over to an Operator
- Part 1 — Installation: A Single Manifest
- Part 2 — Building a 3-Instance Cluster
- Part 3 — Verifying Replication
- Part 4 — Killing the Primary: 23.1-Second Failover
- Part 5 — Honest Numbers and Pitfalls
- Closing
- References
Introduction — What It Means to Hand Postgres Over to an Operator
Running a stateful database on Kubernetes was long treated almost as a taboo. CloudNativePG (CNPG) is an operator that breaks that taboo head-on — declare Postgres's high availability, failover, backups, and rolling upgrades with a single CRD, and the operator handles primary election and replica management for you. This article is not an introduction but a measurement: I installed CNPG on a real 8-node cluster, brought up a 3-instance Postgres, and actually killed the primary to measure how many seconds failover takes. It is the counterpart to the Rust GPU operator piece where I wrote an operator myself — this is the story of the "using a well-built operator" side.
Part 1 — Installation: A Single Manifest
Installing CNPG is minimal. Apply a single release manifest and you're done.
kubectl apply --server-side -f \
https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.30/releases/cnpg-1.30.0.yaml
This one line installs everything — several CRDs (clusters, poolers, scheduledbackups, publications, subscriptions, and more), the controller Deployment in the cnpg-system namespace, RBAC, and webhooks. The controller took about 20 seconds to come up:
$ kubectl -n cnpg-system rollout status deploy/cnpg-controller-manager
deployment "cnpg-controller-manager" successfully rolled out
NAME READY UP-TO-DATE AVAILABLE AGE
cnpg-controller-manager 1/1 1 1 16s
Part 2 — Building a 3-Instance Cluster
A Postgres cluster is declared with a Cluster CR. I set it to 1 primary + 2 replicas, i.e., 3 instances.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pg-test
namespace: cnpg-test
spec:
instances: 3
storage:
size: 1Gi
storageClass: nfs-client # NFS in the homelab — the pitfall is in Part 5
bootstrap:
initdb:
database: appdb
owner: appuser
Once applied, the operator begins bootstrapping. I watched the state transitions in real time:
Setting up primary
→ Waiting for the instances to become active
→ ready=1 Creating a new replica ← replica cloning starts once the primary is up
→ ready=2 Creating a new replica
→ ready=3 Cluster in healthy state ← 3/3 healthy in about 2 minutes
The three instances were automatically spread across different nodes (cubi02, cubi03, cubi04) — the operator uses anti-affinity to keep them from piling onto a single node. CNPG also creates three connection services for you:
Service Role
──────────── ─────────────────────────────
pg-test-rw read/write → always routed to the current primary
pg-test-ro read-only → load-balanced across replicas
pg-test-r any instance (reads)
The -rw service is the key — if the application looks at just this one name, then even when the primary changes due to failover, it automatically connects to the new primary.
Part 3 — Verifying Replication
I inserted 1000 rows into the primary and checked whether all three instances matched.
$ INSERT 1000 rows into the primary (pg-test-1)
INSERT 0 1000
$ Row count per instance
pg-test-1 (primary): 1000 rows
pg-test-2 (replica): 1000 rows ← replicated
pg-test-3 (replica): 1000 rows ← replicated
Streaming replication synchronized all three nodes instantly. Now for the real experiment.
Part 4 — Killing the Primary: 23.1-Second Failover
The thing I was most curious about — if the primary suddenly disappears, how many seconds until recovery? I instantly killed the primary pod with --grace-period=0 --force, then measured the time by polling at 0.5-second intervals until a new primary appeared.
=== FAILOVER TEST: killing primary pg-test-1 ===
deleted at t0; polling for new primary...
=== NEW PRIMARY: pg-test-2 (was pg-test-1) ===
failover time: 23.1 s
rows after failover: 1000 ← zero data loss
In 23.1 seconds, pg-test-2 was promoted to the new primary, and all 1000 rows were still there. And the pg-test-1 that had died is not thrown away — the operator automatically brings it back and re-enrolls it as a replica:
=== Final roles after self-healing ===
pg-test-1: replica ← died and came back, demoted to replica
pg-test-2: primary ← the newly promoted primary
pg-test-3: replica
$ Additional writes to the new primary → OK, 1500 rows total
The new primary immediately accepted writes (1000→1500 rows), and the cluster returned to 3/3 healthy. Zero human intervention. This is what an operator is worth.
Part 5 — Honest Numbers and Pitfalls
In keeping with the principle that this blog writes only what has been verified, I leave the limitations of this experiment intact, too.
- Is 23 seconds fast? It depends on the situation. CNPG's failover time is the sum of detecting the primary's death (the health-check interval), promoting a replica, and updating the
-rwservice endpoint. In production, node failures are more common than pod deletions, and in that case the node-detection time (node-monitor-grace-period, etc.) is added on, which can make it longer. Conversely, with tuning it gets shorter. The "23 seconds" is a measured value for this homelab and this configuration, not a universal constant. - The NFS storage pitfall. I used
nfs-client(NFS provisioner) storage, but putting Postgres on NFS is not recommended in production — because of fsync guarantees and file-locking issues. It ran fine as a homelab test, but for a real service you should use local SSD or block storage (Ceph RBD, etc.). - Synchronous vs. asynchronous replication. This test used the default (asynchronous) replication. With asynchronous replication, in theory a tiny number of unreplicated transactions right before the primary's death can be lost. If you need zero loss, you have to turn on CNPG's synchronous replication (
minSyncReplicas), and in exchange write latency increases.
Closing
CNPG refutes, with real measurements, the conventional wisdom that "databases on Kubernetes are dangerous" — a single manifest brings up a 3-node HA Postgres, it recovers on its own within 23 seconds even when you kill the primary outright, and the dead node comes back as a replica. Of course the real homework of storage, replication mode, and failover tuning remains, but that is not a question of "can you hand a DB to an operator" — it is a question of "how do you hand it over well." Next, I plan to verify backups (ScheduledBackup) and point-in-time recovery (PITR) the same way, by killing things.