Skip to content

Split View: CloudNativePG 딥다이브 — PostgreSQL 을 쿠버네티스 안에서 누가 돌보는가

|

CloudNativePG 딥다이브 — PostgreSQL 을 쿠버네티스 안에서 누가 돌보는가

PostgreSQL 은 스스로를 돌보지 못한다

PostgreSQL 은 훌륭한 데이터베이스이지만, 컨테이너에 넣는다고 해서 자기 복제본을 만들고, 주(primary)가 죽었을 때 대기(standby)를 승격시키고, 매일 백업을 찍고, 어제 오후 세 시로 되돌리는 일을 스스로 하지는 않습니다. 그 일은 지금까지 사람이 했습니다.

CloudNativePG(CNPG)는 그 사람의 일을 쿠버네티스 컨트롤러가 계속 조정(reconcile)하는 것으로 바꾼 오퍼레이터입니다. 이 글은 CNPG 가 무엇을 어떻게 하는지를, 실제로 돌고 있는 운영 클러스터의 값으로 설명합니다.

오퍼레이터   cloudnative-pg 1.30.0  +  plugin-barman-cloud v0.14.0
클러스터     labhub-db-prod — PostgreSQL 18.4, 인스턴스 2, 볼륨 5Gi(NFS)
지금 상태    primary = labhub-db-prod-1, standby = labhub-db-prod-2, timeline 3

설계가 남다른 두 지점

StatefulSet 을 쓰지 않는다

쿠버네티스에서 상태 있는 것은 StatefulSet 으로 띄운다는 것이 상식입니다. CNPG 는 그러지 않습니다. 파드 하나하나를 오퍼레이터가 직접 만들고 지웁니다.

이유는 데이터베이스의 인스턴스가 서로 같지 않기 때문입니다. StatefulSet 은 "0번부터 순서대로, 똑같은 템플릿으로" 를 전제합니다. 그런데 데이터베이스에서는 "2번 인스턴스만 새 볼륨으로 다시 만들어 복제를 처음부터 받게 한다", "primary 는 마지막에 건드린다" 같은 결정이 필요합니다. 그 결정을 내리려면 파드를 개별로 다룰 수 있어야 합니다.

파드의 PID 1 은 postgres 가 아니다

CNPG 의 파드 안에서 1번 프로세스는 인스턴스 매니저입니다. 그것이 postgres 를 자식으로 띄우고, 준비 상태·활성 상태 프로브에 답하고, 승격 명령을 받고, WAL 을 보관소로 밀어 내고, 설정이 바뀌면 다시 읽습니다.

이 구조의 이점은 두 가지입니다. 오퍼레이터가 죽어도 데이터베이스는 계속 돕니다(매니저는 파드 안에 있습니다). 그리고 "이 인스턴스가 정말 살아 있는가" 를 쿠버네티스가 TCP 포트가 아니라 PostgreSQL 의 실제 응답으로 판단합니다.

역할별로 뜯어 보기

클러스터는 CR 하나다

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata: { name: labhub-db-prod }
spec:
  instances: 2
  imageName: ghcr.io/cloudnative-pg/postgresql:18.4-system-trixie
  storage: { size: 5Gi, storageClass: nfs-synology }
  primaryUpdateStrategy: unsupervised
  primaryUpdateMethod: restart
  postgresql:
    parameters: { wal_level: logical, archive_timeout: 5min }
  replicationSlots: { highAvailability: { enabled: true } }
  plugins:
    - name: barman-cloud.cloudnative-pg.io
      isWALArchiver: true

이 하나의 객체가 아래에 나오는 파드·서비스·비밀·인증서·복제·백업을 전부 만듭니다. 설정을 바꾸는 방법도 하나뿐입니다 — 이 CR 을 고치면 오퍼레이터가 차이를 계산해 필요한 만큼만 움직입니다.

복제와 역할

인스턴스 하나가 primary 이고 나머지는 스트리밍 복제로 따라오는 standby 입니다. primary 에서 본 지금 상태입니다.

application_name | state     | sync_state
labhub-db-prod-2 | streaming | async

async 는 primary 가 커밋을 확정할 때 standby 의 확인을 기다리지 않는다는 뜻입니다. 빠르지만, primary 가 죽는 순간 standby 에 아직 도착하지 않은 트랜잭션은 잃습니다. 이 클러스터는 지연이 사실상 0 이지만 그것은 보장이 아니라 지금의 부하가 작아서입니다.

복제 슬롯 HA 가 켜져 있어 standby 가 잠시 끊겨도 primary 가 그 standby 가 아직 못 받은 WAL 을 지우지 않습니다. 이것이 없으면 네트워크가 몇 분만 끊겨도 standby 를 처음부터 다시 만들어야 합니다.

세 개의 서비스

앱은 파드 이름으로 붙지 않습니다. 오퍼레이터가 만든 세 서비스 중 하나로 붙습니다.

서비스가리키는 곳용도
labhub-db-prod-rw지금 primary 하나쓰기. 앱은 여기에 붙는다
labhub-db-prod-rostandby 들만읽기 분산
labhub-db-prod-r아무 인스턴스읽기(primary 포함)

페일오버가 나면 오퍼레이터가 -rw 의 엔드포인트만 바꿉니다. 앱은 연결이 끊긴 것을 보고 다시 붙기만 하면 됩니다. 앱 설정에 primary 의 주소가 적혀 있지 않다는 것, 이것이 데이터베이스를 옮기고 교체할 자유의 출발점입니다.

페일오버와 스위치오버, 그리고 timeline

primary 가 죽으면 오퍼레이터가 가장 앞선 standby 를 골라 승격시킵니다. 이때 PostgreSQL 은 timeline 을 하나 올립니다 — 역사가 갈라졌다는 표시입니다. 이 클러스터는 timelineID: 3 입니다. 지금까지 두 번 primary 가 바뀐 이력이 그 숫자에 남아 있습니다.

계획된 교체는 스위치오버입니다. kubectl cnpg promote labhub-db-prod labhub-db-prod-2 로 standby 를 지목하면, 오퍼레이터가 옛 primary 를 정리하고 새 primary 를 세우고 옛 것을 standby 로 되돌립니다. 데이터 손실 없이 역할만 바뀝니다.

롤링 업데이트

이미지나 재시작이 필요한 설정이 바뀌면 오퍼레이터는 standby 부터 갈아 끼우고, primary 는 맨 마지막에 손댑니다. 그 마지막 단계를 어떻게 할지가 두 값으로 정해집니다.

  • primaryUpdateStrategy: unsupervised — 사람 승인 없이 진행한다(supervised 면 primary 앞에서 멈추고 기다린다).
  • primaryUpdateMethod: restart — primary 를 제자리에서 재시작한다(switchover 면 먼저 standby 를 승격시켜 primary 의 중단 시간을 줄인다).

마이너 판올림(18.4 → 18.5)은 imageName 만 바꾸면 이 절차로 굴러갑니다.

설정은 CR 에, reload 와 restart 는 오퍼레이터가 가른다

postgresql.parameters 에 적은 값은 오퍼레이터가 postgresql.conf 로 옮기고, 그 항목이 reload 로 되는지 restart 가 필요한지를 판단해 알맞은 쪽으로 처리합니다. 이 클러스터가 손댄 것은 둘입니다.

  • wal_level: logical — 논리 복제와 변경 데이터 캡처(CDC)가 가능하도록.
  • archive_timeout: 5min — 다음 절의 WAL 보관 주기.

백업 — 두 종류가 합쳐져야 복구가 된다

가장 오해가 많은 부분입니다. "백업을 찍었다" 는 말은 절반만 말한 것입니다.

WAL 보관(archiving). PostgreSQL 은 모든 변경을 먼저 WAL(쓰기 전 로그)에 씁니다. CNPG 는 WAL 조각이 차거나 archive_timeout(5분)이 지날 때마다 그 조각을 오브젝트 스토리지로 밀어 냅니다. 즉 가장 최근 5분 안의 변경은 아직 보관소에 없을 수 있습니다 — 이것이 이 클러스터의 RPO(복구 시점 목표)입니다.

베이스 백업. 데이터 디렉터리 전체의 사본입니다. 이 클러스터는 ScheduledBackup 이 매일 03:30 UTC 에 찍고, 최근 사흘 것이 모두 completed 입니다.

복구는 둘을 합칩니다. 베이스 백업을 풀고, 그 뒤의 WAL 을 원하는 시각까지 재생합니다. 그래서 "어제 오후 세 시" 로 돌아갈 수 있고(PITR), 돌아갈 수 있는 가장 이른 시각이 firstRecoverabilityPoint 로 상태에 적힙니다.

이 클러스터는 백업을 CNPG 에 내장된 barmanObjectStore 가 아니라 CNPG-I 플러그인(plugin-barman-cloud)으로 합니다. 그래서 spec.backup 이 비어 있고 대신 plugins[].isWALArchiver: true 가 서 있습니다. 백업 로직을 오퍼레이터 본체에서 떼어 낸 것이 최근 CNPG 의 방향입니다.

그 밖의 기능

  • 선언적 롤·DBmanaged.rolesDatabase CR 로 사용자와 데이터베이스를 YAML 로 관리.
  • 인증서 — 서버·클라이언트 TLS 인증서를 오퍼레이터가 발급하고 갱신.
  • PoolerPooler CR 하나로 PgBouncer 를 앞에 세운다. 연결이 수백 개로 늘면 그때.
  • 지표monitoring.enablePodMonitor: true 면 Prometheus 가 긁는다.
  • 복제 클러스터 — 다른 클러스터(다른 데이터센터)에 standby 를 두는 DR 구성.
  • 볼륨 스냅샷 백업, 하이버네이션(볼륨은 두고 파드만 내림), 스토리지 온라인 확장.

실제 클러스터에서 확인되는 것과 함정

여기서부터는 문서가 아니라 겪은 것입니다.

상태 필드가 멈춰 있다. 백업은 매일 찍히는데 status.lastSuccessfulBackup 은 8월 23일에 멈춰 있습니다. 플러그인 방식으로 옮긴 뒤로 Cluster 의 상태 필드와 cnpg_collector_* 지표가 갱신되지 않기 때문입니다. 이 값으로 알림을 걸면 거짓 경보가 됩니다. 판정은 Backup 객체의 phase 와 barman_cloud_* 지표로 해야 합니다.

첫 백업은 복구가 안 될 수 있다. 아카이빙을 켠 직후에 찍은 백업은 completed 로 떠도, 그 백업이 시작된 WAL 위치(beginWal)가 보관소에 없어 복구에 실패한 적이 있습니다. pg_switch_wal() 로 WAL 을 한 번 넘긴 뒤 백업을 다시 찍고, 반드시 복구를 해 봐야 합니다. 백업은 "찍혔다" 가 아니라 "되살려 봤다" 로만 확인됩니다.

async 복제와 인스턴스 둘. 위에서 본 대로 async 는 페일오버 때 마지막 몇 트랜잭션을 잃을 수 있습니다. 동기 복제를 켜면 해결되지만 인스턴스가 둘뿐일 때는 standby 가 죽는 순간 쓰기가 멈추는 반대 위험이 생깁니다. 셋으로 늘린 뒤에 켜는 것이 순서입니다.

NFS 위의 데이터베이스. 볼륨이 NFS(5Gi)입니다. 지금 데이터는 7.8MB 라 용량은 문제가 아니지만, WAL 의 fsync 가 로컬 디스크보다 느리고 CNPG 문서도 로컬 스토리지를 권합니다. 부하가 커지면 첫 번째로 옮길 것입니다.

지표가 꺼져 있다. enablePodMonitor 가 꺼져 있어 Prometheus 에 DB 지표가 없습니다. 연결 수·복제 지연·WAL 보관 실패를 보려면 켜야 합니다.

한 줄로

CNPG 의 역할은 "지금 primary 가 누구인지, 그 앞의 주소가 무엇인지, 어제로 되돌릴 수 있는지" 를 사람이 아니라 컨트롤러가 항상 답하게 하는 것입니다. 이 클러스터는 그 셋이 갖춰져 있고, 남은 숙제는 복구 훈련을 주기적으로 하는 것, 인스턴스 셋과 동기 복제, 그리고 지표를 켜는 것입니다.

CloudNativePG deep dive — who looks after PostgreSQL inside Kubernetes

PostgreSQL does not look after itself

PostgreSQL is an excellent database, but putting it in a container does not make it create its own replicas, promote a standby when the primary dies, take a backup every day, or rewind to three o'clock yesterday afternoon. Until now a person did that.

CloudNativePG (CNPG) is the operator that turns that person's job into something a Kubernetes controller reconciles continuously. This post explains what CNPG does and how, using the values of a cluster that is actually running in production.

operator   cloudnative-pg 1.30.0  +  plugin-barman-cloud v0.14.0
cluster    labhub-db-prod — PostgreSQL 18.4, 2 instances, 5Gi volume (NFS)
state      primary = labhub-db-prod-1, standby = labhub-db-prod-2, timeline 3

Two places where the design is unusual

It does not use a StatefulSet

Common sense says stateful things in Kubernetes run as StatefulSets. CNPG does not. The operator creates and deletes every pod itself.

The reason is that database instances are not interchangeable. A StatefulSet assumes "from ordinal 0 upward, all from the same template". A database needs decisions like "recreate only instance 2 on a fresh volume so it re-replicates from scratch" or "touch the primary last". To make those decisions you must be able to handle pods individually.

PID 1 in the pod is not postgres

Process 1 inside a CNPG pod is the instance manager. It starts postgres as a child, answers the readiness and liveness probes, receives promotion commands, ships WAL to the archive, and reloads configuration when it changes.

Two things follow. The database keeps running if the operator dies (the manager lives inside the pod). And "is this instance really alive" is judged by Kubernetes from PostgreSQL's actual response, not from a TCP port.

Role by role

A cluster is one CR

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata: { name: labhub-db-prod }
spec:
  instances: 2
  imageName: ghcr.io/cloudnative-pg/postgresql:18.4-system-trixie
  storage: { size: 5Gi, storageClass: nfs-synology }
  primaryUpdateStrategy: unsupervised
  primaryUpdateMethod: restart
  postgresql:
    parameters: { wal_level: logical, archive_timeout: 5min }
  replicationSlots: { highAvailability: { enabled: true } }
  plugins:
    - name: barman-cloud.cloudnative-pg.io
      isWALArchiver: true

This one object produces everything below — pods, services, secrets, certificates, replication, backups. There is also only one way to change anything: edit this CR, and the operator computes the difference and moves only as much as needed.

Replication and roles

One instance is the primary; the rest are standbys following it through streaming replication. This is what the primary reports right now:

application_name | state     | sync_state
labhub-db-prod-2 | streaming | async

async means the primary does not wait for the standby's acknowledgement before confirming a commit. It is fast, but any transaction that has not yet reached the standby at the moment the primary dies is lost. This cluster's lag is effectively zero — because the load is small today, not because anything guarantees it.

Replication slot HA is enabled, so if a standby drops off for a while the primary does not delete the WAL that standby has not yet received. Without it, a few minutes of network trouble would mean rebuilding the standby from scratch.

Three services

Applications do not connect to pod names. They connect to one of three services the operator creates.

ServicePoints atPurpose
labhub-db-prod-rwthe current primarywrites — the app connects here
labhub-db-prod-rostandbys onlyread scaling
labhub-db-prod-rany instancereads (primary included)

On failover the operator only changes the endpoints of -rw. The application sees a dropped connection and reconnects. The fact that no primary address is written in the app's configuration is where the freedom to move and replace the database begins.

Failover, switchover, and the timeline

When the primary dies the operator picks the most advanced standby and promotes it. PostgreSQL then increments the timeline — a mark that history has forked. This cluster is at timelineID: 3; the two times its primary has changed so far are recorded in that number.

A planned change is a switchover. kubectl cnpg promote labhub-db-prod labhub-db-prod-2 names the standby; the operator drains the old primary, brings up the new one, and turns the old one into a standby. Only the roles change, with no data loss.

Rolling updates

When the image or a restart-requiring setting changes, the operator replaces the standbys first and touches the primary last. Two values decide how that last step goes.

  • primaryUpdateStrategy: unsupervised — proceed without human approval (supervised stops in front of the primary and waits).
  • primaryUpdateMethod: restart — restart the primary in place (switchover promotes a standby first to shorten the primary's downtime).

A minor upgrade (18.4 → 18.5) is just a change of imageName, run through this procedure.

Configuration lives in the CR; reload vs restart is the operator's call

Values in postgresql.parameters are written to postgresql.conf by the operator, which knows whether each one takes effect on reload or needs a restart and handles it accordingly. This cluster touches two:

  • wal_level: logical — so logical replication and change-data capture are possible.
  • archive_timeout: 5min — the WAL archiving interval from the next section.

Backups — two kinds must be combined before you can restore

This is the most misunderstood part. "We took a backup" is only half a sentence.

WAL archiving. PostgreSQL writes every change to the WAL (write-ahead log) first. Whenever a WAL segment fills up or archive_timeout (5 minutes) elapses, CNPG ships that segment to object storage. So changes from the last five minutes may not be in the archive yet — that is this cluster's RPO.

Base backup. A copy of the entire data directory. A ScheduledBackup takes one every day at 03:30 UTC; the last three days are all completed.

A restore combines the two. Unpack the base backup, then replay the WAL after it up to the moment you want. That is how "three o'clock yesterday afternoon" becomes reachable (PITR), and the earliest reachable moment is recorded in the status as firstRecoverabilityPoint.

This cluster does its backups not through the built-in barmanObjectStore but through a CNPG-I plugin (plugin-barman-cloud). That is why spec.backup is empty and plugins[].isWALArchiver: true stands in its place. Moving backup logic out of the operator core is the direction recent CNPG releases have taken.

Other features

  • Declarative roles and databasesmanaged.roles and the Database CR manage users and databases as YAML.
  • Certificates — the operator issues and renews server and client TLS certificates.
  • Pooler — one Pooler CR puts PgBouncer in front. For when connections grow into the hundreds.
  • Metrics — with monitoring.enablePodMonitor: true Prometheus scrapes them.
  • Replica clusters — a standby in another cluster (another datacenter) for DR.
  • Volume snapshot backups, hibernation (keep the volumes, remove the pods), online storage resize.

What the real cluster shows, and the traps

From here on this is not documentation but experience.

A status field is frozen. Backups run daily, yet status.lastSuccessfulBackup stopped at August 23. After the move to the plugin, the Cluster's status fields and the cnpg_collector_* metrics stop updating. Alert on those and you get false alarms. Judge by the Backup objects' phase and the barman_cloud_* metrics instead.

The first backup may not be restorable. A backup taken right after archiving was switched on showed completed, yet restoring it failed because the WAL position it started at (beginWal) was not in the archive. Rotate WAL once with pg_switch_wal(), take the backup again, and actually restore it. A backup is confirmed only by "we brought it back", never by "it was taken".

Async replication with two instances. As shown above, async can lose the last few transactions on failover. Synchronous replication fixes that, but with only two instances it creates the opposite risk — writes stop the moment the standby dies. Grow to three first, then switch it on.

A database on NFS. The volume is NFS (5Gi). Data is 7.8MB today so capacity is not the issue, but WAL fsync on NFS is slower than on local disk and the CNPG documentation recommends local storage. It is the first thing to move when load grows.

Metrics are off. enablePodMonitor is disabled, so Prometheus has no database metrics. Turn it on to see connections, replication lag and WAL archive failures.

In one line

CNPG's role is to make a controller — not a person — always able to answer "who is the primary right now, what address is in front of it, and can we go back to yesterday." This cluster has all three. What remains is regular restore drills, three instances with synchronous replication, and metrics switched on.