Skip to content

Split View: CloudNativePG로 쿠버네티스에 Postgres 띄우고 죽여보기 — 페일오버 23초 실측

|

CloudNativePG로 쿠버네티스에 Postgres 띄우고 죽여보기 — 페일오버 23초 실측

들어가며 — 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

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 -rw service 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.

References