본문으로 건너뛰기

CNPG 클러스터

CloudNativePG 기반 PostgreSQL 3-node HA 클러스터로, Barman Cloud Plugin을 통해 MinIO에 WAL 아카이빙과 베이스 백업을 수행합니다.

  • Helm Chart 버전: cluster 0.4.0
  • PostgreSQL 버전: 16
  • 네임스페이스: database
  • 의존성: CNPG Operator, MinIO

1. 개요

3개 인스턴스(Primary 1 + Replica 2)를 StatefulSet으로 구성하며, podAntiAffinity: required로 각 인스턴스를 서로 다른 노드에 배치합니다. Barman Cloud Plugin이 WAL을 MinIO에 실시간 아카이빙하고, ScheduledBackup이 매일 자정 베이스 백업을 수행합니다. recovery overlay를 사용하여 특정 시점(PITR)으로 클러스터를 복구할 수 있습니다.


2. 사전 요구사항

  • CNPG Operator: 설치 완료 (cnpg-operator)
  • MinIO: postgresql-backups 버킷 생성 완료 (minio)

3. 디렉터리 구조

cnpg/cluster/
├── Makefile
└── kustomize/
├── base/
│ └── helm/
│ └── cluster/ # CNPG Cluster Chart (pull로 다운로드)
└── overlays/
├── dev/
│ ├── kustomization.yaml # secretGenerator, patch 포함
│ ├── helm/
│ │ ├── helm-chart.yaml # HelmChartInflationGenerator 설정
│ │ └── values.yaml # 클러스터 파라미터
│ └── resources/
│ ├── barman-object-store.yaml # ObjectStore CRD (MinIO 연결)
│ └── backup-schedule.yaml # ScheduledBackup CRD
└── recovery/
├── kustomization.yaml # PITR 복구용 patch 포함
├── helm/values.yaml
└── resources/
├── barman-object-store.yaml
└── backup-schedule.yaml

4. 사전 설정

4.1 Superuser Secret 생성

kubectl create secret generic postgres-superuser-secret \
--from-literal=username=postgres \
--from-literal=password='<strong-password>' \
-n database

4.2 MinIO 버킷 확인

백업 저장소로 사용할 버킷이 존재하는지 확인합니다.

POD=$(kubectl get pod -n storage -l app=minio -o jsonpath='{.items[0].metadata.name}')
kubectl -n storage exec $POD -- mc alias set local http://localhost:9000 admin <password>
kubectl -n storage exec $POD -- mc ls local/ | grep postgresql-backups

버킷이 없으면 생성합니다.

kubectl -n storage exec $POD -- mc mb local/postgresql-backups

또는 MinIO Console(https://minio.cnapcloud.com) → Buckets → Create Bucket에서 postgresql-backups 버킷을 생성합니다.

4.3 MinIO 접근 키 생성

PostgreSQL 백업 전용 서비스 계정을 생성합니다.

POD=$(kubectl get pod -n storage -l app=minio -o jsonpath='{.items[0].metadata.name}')
kubectl -n storage exec $POD -- mc alias set local http://localhost:9000 admin <password>
kubectl -n storage exec $POD -- mc admin user svcacct add \
--access-key "<access-key>" \
--secret-key "<secret-key>" \
local admin

또는 MinIO Console(https://minio.cnapcloud.com) → Access Keys → Create access key에서 생성합니다.

4.4 S3 자격증명 업데이트

overlays/dev/kustomization.yamlsecretGenerator에 위에서 생성한 접근 키를 입력합니다.

secretGenerator:
- name: s3-store-creds
literals:
- ACCESS_KEY_ID=<minio-access-key>
- ACCESS_SECRET_KEY=<minio-secret-key>
type: Opaque

5. 배포

5.1 Namespace 생성

make namespace

5.2 Helm Chart 다운로드

make pull

CNPG cluster Chart v0.4.0을 kustomize/base/helm/cluster/에 다운로드합니다.

5.3 배포 설정

overlays/dev/helm/values.yaml — 클러스터 구성의 핵심 파라미터입니다.

# overlays/dev/helm/values.yaml
fullnameOverride: "pg-cluster"

type: postgresql
version:
postgresql: "16"

mode: standalone

cluster:
instances: 3

# 각 인스턴스를 서로 다른 zone 노드에 배치
affinity:
podAntiAffinityType: required
topologyKey: topology.kubernetes.io/zone

storage:
size: 4Gi
storageClass: "" # NFS 사용 금지 — NFSv4 stateid 락 오류가 발생할 수 있음

# 백업 사용 시 WAL 스토리지 활성화 필수
walStorage:
enabled: true
size: 1Gi
storageClass: "" # NFS 사용 금지 — WAL 파일 잠금(lock) 오류가 발생할 수 있음

# 리소스 설정 (워크로드에 맞게 조정)
resources: {}
# limits:
# cpu: 2000m
# memory: 8Gi
# requests:
# cpu: 2000m
# memory: 8Gi

superuserSecret: "postgres-superuser-secret"

# 클러스터 초기화 시 실행할 SQL (초기 Role/DB 생성)
initdb:
postInitSQL:
- CREATE ROLE keycloak LOGIN PASSWORD '<password>';
- CREATE DATABASE keycloak OWNER keycloak;
# 필요한 Role/Database 추가

monitoring:
enabled: true
prometheusRule:
enabled: true

additionalLabels:
prometheus: main

스토리지 주의사항: PostgreSQL 데이터 및 WAL PVC에는 NFS StorageClass를 사용하지 않습니다. NFS 환경에서는 PostgreSQL의 파일 잠금이 정상적으로 동작하지 않아 could not open file, lock file, Permission denied 등의 오류가 발생하거나 클러스터가 기동하지 않을 수 있습니다. storageClass를 비워 두는 경우에도 기본 StorageClass가 NFS인지 확인하고, 반드시 블록 스토리지 또는 PostgreSQL의 파일 잠금을 지원하는 CSI 스토리지를 사용합니다.

overlays/dev/resources/barman-object-store.yaml — MinIO 백업 저장소 연결을 설정합니다.

apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: s3-store
spec:
configuration:
destinationPath: s3://postgresql-backups/
endpointURL: http://minio.storage.svc.cluster.local:9000
s3Credentials:
accessKeyId:
name: s3-store-creds
key: ACCESS_KEY_ID
secretAccessKey:
name: s3-store-creds
key: ACCESS_SECRET_KEY
wal:
compression: gzip
retentionPolicy: "5d"

overlays/dev/resources/backup-schedule.yaml — 일별 베이스 백업 스케줄을 설정합니다.

apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: pg-cluster-daily-backup
spec:
schedule: "0 0 0 * * *" # 매일 자정
backupOwnerReference: self
cluster:
name: pg-cluster
method: plugin
pluginConfiguration:
name: barman-cloud.cloudnative-pg.io

overlays/dev/kustomization.yaml — Barman Cloud Plugin patch와 startup probe를 포함합니다.

patches:
- target:
kind: Cluster
name: pg-cluster
patch: |-
- op: add
path: /spec/plugins
value:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: s3-store

# 대용량 DB 기동/복구 시 startup probe 실패 방지 (최대 1200초 대기)
- op: add
path: /spec/probes
value:
startup:
initialDelaySeconds: 10
timeoutSeconds: 5
periodSeconds: 10
failureThreshold: 120

5.4 배포 실행

make preview # 적용 전 매니페스트 확인
make apply # 클러스터에 적용

클러스터 초기화에 약 2~3분 소요됩니다. 클러스터가 Cluster in healthy state 메시지와 함께 Ready 상태가 되면 완료입니다.


6. 설치 후 검증

6.1 클러스터 상태 확인

kubectl get cluster pg-cluster -n database

예상 결과:

NAME AGE INSTANCES READY STATUS PRIMARY
pg-cluster 5m 3 3 Cluster in healthy state pg-cluster-1

READYINSTANCES와 같고 STATUSCluster in healthy state이면 정상입니다.

6.2 Primary/Replica 구성 확인

kubectl -n database exec pg-cluster-1 -- psql -U postgres -c \
"SELECT inet_server_addr() AS node_ip, 'primary' AS role
UNION ALL
SELECT client_addr, 'replica' FROM pg_stat_replication;"

예상 결과 (Primary는 Unix 소켓 연결로 node_ip가 비어 있음):

node_ip | role
--------------+---------
| primary
10.244.x.x | replica
10.244.x.x | replica
(3 rows)

확인 사항: replica 2개가 표시되면 정상입니다.

6.3 서비스 엔드포인트 확인

kubectl get svc -n database | grep pg-cluster

예상 결과:

pg-cluster-r ClusterIP 10.96.x.x <none> 5432/TCP # 읽기 전용 (Primary + Replica)
pg-cluster-ro ClusterIP 10.96.x.x <none> 5432/TCP # 읽기 전용 (Replica only)
pg-cluster-rw ClusterIP 10.96.x.x <none> 5432/TCP # 읽기/쓰기 (Primary only)

6.4 백업 상태 확인

ObjectStore와 ScheduledBackup 리소스가 정상 생성되었는지 확인합니다.

kubectl get objectstore,scheduledbackup -n database

첫 번째 백업이 실행된 후(자정 이후) 실행 이력을 확인합니다.

kubectl get backup -n database

예상 결과 (phase: completed):

NAME AGE CLUSTER METHOD PHASE BACKUP ID
pg-cluster-daily-backup-<ts> 1h pg-cluster plugin completed 20260326T000000

6.5 Barman 메트릭 확인

kubectl -n database exec pg-cluster-1 -- python3 -c \
"import urllib.request; print(urllib.request.urlopen('http://localhost:9187/metrics').read().decode())" \
| grep barman_cloud

예상 결과:

barman_cloud_cloudnative_pg_io_first_recoverability_point 1.77397e+09
barman_cloud_cloudnative_pg_io_last_available_backup_timestamp 1.77440e+09
barman_cloud_cloudnative_pg_io_last_failed_backup_timestamp 0

확인 사항:

  • last_available_backup_timestamp: 0이 아니면 최근 성공 백업 존재
  • last_failed_backup_timestamp: 0이면 실패 없음

7. 운영

7.1 PITR 복구 (Point-in-Time Recovery)

recovery overlay를 사용하여 특정 시점으로 클러스터를 복구합니다.

주의
복구는 기존 클러스터와 PVC를 먼저 제거한 후 수행해야 합니다. 기존 데이터가 남아 있으면 복구가 정상적으로 진행되지 않습니다.

참고
운영 환경에 적용하기 전에 반드시 rehersal overlay(make rehersal)로 복구를 먼저 검증합니다. rehersal은 별도 네임스페이스(rehersal)에 클러스터를 생성하여 복구 동작을 확인할 수 있습니다.

overlays/recovery/kustomization.yamltargetTime을 복구 목표 시점으로 설정합니다. targetTime은 현재 시각보다 과거이면서 백업 보관 시작 시점 이후여야 하며, 미래 시각으로 설정하면 WAL을 끝까지 replay해도 목표에 도달하지 못해 복구가 실패합니다. recoveryTarget 블록 전체를 생략하면 S3에 archive된 마지막 WAL(최신 시점)까지 복구합니다. 자세한 동작은 7.1.1을 참고합니다.

patches:
- target:
kind: Cluster
name: pg-cluster
patch: |-
- op: add
path: /spec/bootstrap
value:
recovery:
source: source
# recoveryTarget 생략 시: archive된 마지막 WAL(최신 시점)까지 복구
recoveryTarget:
# 미래 시각 금지 (복구 실패). 백업 보관 시작 ~ 현재 사이여야 함
targetTime: "2026-02-28 15:00:00+00" # 복구 목표 시점 (UTC)

- op: add
path: /spec/plugins
value:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: s3-store-recovery
serverName: pg-cluster-recovery # 기존 serverName과 달라야 함

- op: replace
path: /spec/externalClusters
value:
- name: source
plugin:
name: barman-cloud.cloudnative-pg.io
parameters:
barmanObjectName: s3-store-recovery
serverName: pg-cluster # 복구 원본 백업 폴더명

복구 절차

  1. dev overlay 동기화 (기준 상태)
    ArgoCD cnpg-cluster 앱을 dev overlay와 Synced 상태로 맞추고, 복구 작업 중 selfHeal이 개입하지 않도록 auto-sync를 일시 중지합니다.

  2. 기존 클러스터 삭제

    kubectl delete cluster pg-cluster -n database
    kubectl get pvc,pv -n database # pg-cluster-* PVC/PV가 모두 삭제됐는지 확인

    Retain 정책 등으로 PV가 남으면 수동 삭제합니다. 기존 데이터가 남아 있으면 복구가 실패합니다.

  3. 복구 폴더 사전 정리
    MinIO postgresql-backups 버킷에 이전 시도의 pg-cluster-recovery/가 있으면 삭제합니다.

    POD=$(kubectl -n storage get pod -o name | grep minio | head -1)
    kubectl -n storage exec $POD -- mc rm --recursive --force local/postgresql-backups/pg-cluster-recovery/
  4. recovery overlay로 재생성

    make apply DEPLOY_ENV=recovery
  5. 복구 검증

    kubectl cnpg status pg-cluster -n database

    데이터, Continuous Archiving: OK를 확인합니다.

  6. 복구 폴더 사후 정리
    pg-cluster-recovery/는 임시 격리용이므로 검증 후 삭제합니다. 남겨두면 다음 복구 재시도 시 기존 백업과 충돌합니다.

    kubectl -n storage exec $POD -- mc rm --recursive --force local/postgresql-backups/pg-cluster-recovery/
  7. dev overlay 재동기화
    ArgoCD cnpg-cluster 앱을 다시 dev overlay와 동기화하고 auto-sync를 재개합니다. spec.bootstrap·spec.externalClustersignoreDifferences(7.1.1)로 라이브 값이 유지되고, serverName은 원본 pg-cluster로 통합됩니다. 복귀 직후 kubectl cnpg backup pg-cluster -n database로 새 타임라인 기준 base backup을 1개 확보합니다.

7.1.1 복구 시 유의사항 (실전 사례)

복구 시점(targetTime) 설정
targetTime은 반드시 현재 시각보다 과거이면서 백업 보관 시작 시점 이후여야 합니다. 미래 시각으로 설정하면 WAL을 끝까지 replay해도 목표 시점에 도달하지 못해 FATAL: recovery ended before configured recovery target was reached로 복구가 실패합니다. 정확한 시점을 모르면 recoveryTarget 자체를 생략해 사용 가능한 WAL 끝(최신 시점)까지 복구합니다.

recoveryTarget을 생략하면 "시각" 기준이 아니라 barman-cloud(S3)에 실제로 archive된 마지막 WAL 세그먼트가 기준이 됩니다. 원본 클러스터가 살아있는 동안 archive_command로 성공적으로 push한 WAL 끝까지 replay하고, 더 가져올 WAL이 없으면 그 지점에서 종료 후 승격합니다. 이는 "지금 이 순간"이 아니라 "원본이 마지막으로 WAL을 archive한 시점"이므로, archive_timeout: 5min 설정상 최악의 경우 장애 직전 최대 5분의 트랜잭션은 유실될 수 있습니다. 특정 사고 시점 직전으로 복구해야 한다면 targetTime(또는 targetLSN/targetName)을 반드시 명시합니다.

ArgoCD 설정 변경
recovery로 부트스트랩된 클러스터는 spec.bootstrap.recoveryspec.externalClusters(source)를 갖는데, 두 필드 모두 immutable이라 이후 제거할 수 없습니다. ArgoCD가 추적하는 dev overlay에는 이 필드들이 없으므로, ServerSideApply + prune 상태에서 selfHeal이 spec.externalClusters를 지우려다 admission webhook(External cluster source not found)에 막혀 sync가 반복 실패합니다. argocd/apps/database/cnpg-cluster.yaml에 아래 설정을 추가해 두 필드를 sync 대상에서 제외합니다. ignoreDifferences만으로는 sync 시 git 값이 그대로 재적용되므로 RespectIgnoreDifferences=true를 함께 설정해야 라이브 값이 유지됩니다.

spec:
ignoreDifferences:
- group: postgresql.cnpg.io
kind: Cluster
name: pg-cluster
jsonPointers:
- /spec/bootstrap # 복구 부트스트랩 (생성 시에만 참조, 이후 inert)
- /spec/externalClusters # bootstrap.recovery.source가 참조 → 같이 유지 필요
syncPolicy:
syncOptions:
- RespectIgnoreDifferences=true # ignore된 필드는 git이 아닌 라이브 값을 유지

이 두 필드는 git(dev overlay)에는 없고 라이브에만 남는 영구 drift 상태가 되지만, immutable이며 복구 후에는 클러스터 생성 시에만 참조되는 inert 필드이므로 실질적인 영향은 없습니다.

7.2 설정 롤백

values.yaml 변경 후 문제가 발생한 경우:

git checkout <이전 커밋> -- cnpg/cluster/kustomize/overlays/dev/helm/values.yaml
make apply

주의: PVC를 삭제하면 데이터가 영구 삭제됩니다. make delete는 PVC를 보존합니다.


8. Troubleshooting

8.1 백업 실패 — MinIO 버킷 없음

kubectl get backup -n database에서 phase: failed, ObjectStore 로그에 S3 접근 오류가 발생합니다.

원인: MinIO PV 재초기화 또는 버킷 삭제 후 barman이 버킷 자동 생성에 실패합니다 (botocore 버그).

해결: 버킷을 수동으로 생성합니다.

POD=$(kubectl get pod -n storage -l app=minio -o jsonpath='{.items[0].metadata.name}')
kubectl -n storage exec $POD -- mc mb local/postgresql-backups

8.2 Pod 기동 시간 초과 — startup probe 실패

WAL 복구 또는 대용량 DB 기동 시 Pod가 반복 재시작됩니다.

원인: startup probe failureThreshold 내에 PostgreSQL 기동이 완료되지 않음

해결: kustomization.yaml의 patch에서 failureThreshold를 늘립니다 (현재 120 = 1200초). 이미 적용되어 있으면 정상 기동을 기다립니다.

8.3 PV 손상 — 인스턴스 재구축

특정 Pod가 CrashLoopBackOff 상태로 반복 재시작됩니다.

원인: PV 손상으로 PostgreSQL이 데이터 파일 체크섬 오류를 감지하고 기동을 거부

해결: Pod와 PVC를 함께 삭제합니다. PVC만 단독 삭제하면 Pod가 남아 Pending 상태가 되며 CNPG가 재생성하지 않습니다.

# 손상된 인스턴스 번호 확인 (예: pg-cluster-2)
kubectl get pods -n database -l cnpg.io/cluster=pg-cluster

# Pod + 데이터 PVC + WAL PVC 함께 삭제 (pg-cluster-2인 경우)
kubectl delete pod/pg-cluster-2 pvc/pg-cluster-2 pvc/pg-cluster-2-wal -n database

삭제 후 CNPG가 새 번호의 인스턴스(예: pg-cluster-4)와 PVC를 새로 프로비저닝하고 primary에서 스트리밍 복제로 재구축합니다. kubectl get cluster pg-cluster -n database에서 READY 3, STATUS Cluster in healthy state가 되면 완료입니다.

주의: replica에만 적용합니다. primary가 손상된 경우 CNPG가 정상 replica를 자동 promote하므로, promote 완료 후 구 primary(현재 replica)에 위 절차를 적용합니다.

8.4 Failover 고착 — 오퍼레이터 상태 폴링 실패

kubectl get cluster pg-cluster -n databaseSTATUSFailing over에서 수 분 이상 멈추고, READY가 목표 인스턴스 수보다 적습니다. join 인스턴스의 PVC와 join Job은 완료됐는데 해당 Pod가 생성되지 않습니다.

원인: CNPG 오퍼레이터가 특정 인스턴스의 instance manager API(https://<pod-ip>:8000/pg/status) 폴링에 타임아웃하여 해당 인스턴스를 unhealthy로 판정하고, 그 인스턴스가 primary이면 failover를 반복 시도하며 Failing over 단계에 고착됩니다. 실제 PostgreSQL은 정상 동작(primary가 쓰기 수용, replica 동기)하며 오퍼레이터의 헬스체크 경로만 끊긴 상태입니다. 흔한 트리거:

  • 노드 재부팅 후 DHCP로 노드 IP 변경 → 오퍼레이터와 인스턴스 간 overlay 경로 불일치
  • CNI overlay(flannel VXLAN)의 FDB/라우트 stale
  • 오퍼레이터 Pod의 stale 커넥션

진단: 오퍼레이터 판정과 무관하게 실제 역할과 동기 상태를 직접 확인합니다.

# 각 인스턴스의 실제 역할 확인 (f = primary, t = replica)
kubectl exec -n database <인스턴스> -c postgres -- psql -tAc 'SELECT pg_is_in_recovery()'

# primary WAL 위치와 replica 재생 위치 비교 (동일하면 동기 정상)
kubectl exec -n database <primary> -c postgres -- psql -tAc 'SELECT pg_current_wal_lsn()'
kubectl exec -n database <replica> -c postgres -- psql -tAc 'SELECT pg_last_wal_replay_lsn()'

# 노드 InternalIP가 실제 노드 IP와 일치하는지 (재부팅 후 변경 여부)
kubectl get nodes -o wide

# 오퍼레이터가 폴링에 실패한 대상 확인
kubectl logs -n database deploy/cloudnative-pg | grep -E 'Cannot extract Pod status|initiating a failover'

primary가 1개이고 replica 재생 위치가 primary WAL 위치와 같으면 데이터는 정상이며 스플릿브레인이 아닙니다.

해결: primary 정상을 확인한 뒤 오퍼레이터 Pod를 재시작합니다. 실행 중인 PostgreSQL에는 영향이 없습니다.

kubectl delete pod -n database -l app.kubernetes.io/name=cloudnative-pg
kubectl get cluster pg-cluster -n database -w

재시작 후에도 폴링 타임아웃이 지속되면 CNI overlay 문제이므로 해당 노드에서 overlay를 재생성합니다.

sudo systemctl restart k3s-agent # worker 노드
sudo systemctl restart k3s # server 노드

Failing over가 해소되면 대기 중이던 join 인스턴스 Pod가 생성되고 READY <목표수>, STATUS Cluster in healthy state에 도달합니다.

주의: 노드 IP가 재부팅으로 바뀐 경우 DHCP 예약으로 노드 IP를 고정한 뒤 위 절차를 적용합니다. IP를 고정하지 않으면 재부팅마다 재발합니다.

8.5 Primary 장애 — 자동 Failover

Primary Pod가 삭제되거나 노드 장애가 발생하면 CNPG가 자동으로 replica를 promote합니다.

# 장애 후 클러스터 상태 확인
kubectl get cluster pg-cluster -n database

예상 복구 순서:

STATUS: Failing over → replica promote 중
STATUS: Waiting for ... → 새 primary(pg-cluster-3) 확정
STATUS: Cluster in healthy state PRIMARY: pg-cluster-3

구 primary는 자동으로 새 replica로 재참여합니다. Failover 및 재참여 완료까지 수 분 소요될 수 있으므로 STATUSCluster in healthy state로 돌아올 때까지 기다립니다.

8.6 노드 Drain 중 무중단 Failover

CNPG는 노드 drain 시 Eviction 이벤트를 감지하여 계획된 switchover를 수행합니다. Primary를 마지막에 종료하므로 PodDisruptionBudget이 보장되는 한 무중단입니다.

kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data

drain 중 클러스터 상태를 모니터링합니다.

kubectl get cluster pg-cluster -n database -w

8.7 CREATE DATABASE 행 — NFSv4 stateid 락 고착

CREATE DATABASE 실행이 응답 없이 무한 대기합니다. pg_stat_activity에서 wait_eventLWLock / RelationMapping으로 멈춰 있고, pg_terminate_backend()를 호출해도 반환값은 t이지만 세션이 종료되지 않고 그대로 남습니다. 같은 인스턴스에 대한 이후의 모든 CREATE DATABASE 시도가 동일하게 멈추며, primary가 failover되어도 새 primary에서 재현됩니다. 반면 일반 INSERT/UPDATE, 새로 만든 클러스터에서의 CREATE DATABASE는 정상 동작합니다.

원인
스토리지가 NFS(nfs-server-provisioner, storageClass nfs)인 환경에서, PostgreSQL 데이터 파일에 대한 NFSv4 클라이언트의 open/lock 상태(stateid) 동기화가 커널 레벨에서 깨지는 경우가 있습니다. 이 상태에서는 파일을 물리적으로 복사하며 relmap을 갱신하는 오퍼레이션(CREATE DATABASE, VACUUM FULL, CLUSTER, 테이블스페이스 이동 등)만 영향을 받고 WAL 기반 오퍼레이션은 영향을 받지 않습니다. WAL은 파일 하나에 순차 append 후 주기적으로 fsync하지만, 위 오퍼레이션들은 다수의 개별 파일에 대해 open/read/write/fsync를 반복하며 NFSv4 lock reclaim 경로를 타기 때문입니다. 한 번 stateid 동기화가 깨진 프로세스는 커널 D-state(uninterruptible disk sleep)로 남아 시그널(SIGTERM/SIGKILL, pg_terminate_backend() 포함)로 종료할 수 없고, 뒤이은 CREATE DATABASE는 이 프로세스가 쥔 락 뒤에서 대기하게 됩니다.

진단
기본 postgres 컨테이너에는 straceps의 wchan 옵션이 없고 ptrace 권한도 기본 debug 프로필에는 없으므로, SYS_PTRACE를 추가한 커스텀 프로필로 디버그 컨테이너를 붙입니다.

cat <<'EOF' > /tmp/ptrace-profile.yaml
securityContext:
runAsUser: 0
runAsGroup: 0
runAsNonRoot: false
capabilities:
add: ["SYS_PTRACE"]
EOF

kubectl -n database debug <인스턴스> --image=nicolaka/netshoot \
--target=postgres --container=debugger \
--custom=/tmp/ptrace-profile.yaml -- \
sh -c 'for p in /proc/[0-9]*; do pid=$(basename $p); st=$(awk "/^State/{print \$2,\$3}" $p/status 2>/dev/null); wc=$(cat $p/wchan 2>/dev/null); cmd=$(cat $p/comm 2>/dev/null); echo "$pid $st wchan=$wc cmd=$cmd"; done'

D (disk 상태이면서 wchannfs_set_open_stateid_locked인 postgres 프로세스가 있으면 이 문제로 확정합니다.

해결
D-state 프로세스는 시그널로 회수할 수 없으므로, 해당 인스턴스(Pod)를 삭제해 새 NFS 마운트로 재기동하는 방법뿐입니다. Primary인 경우 CNPG가 자동으로 replica를 promote합니다.

kubectl -n database delete pod <문제 인스턴스>
kubectl -n database get cluster pg-cluster -w

이 조치는 증상을 일시적으로 해소할 뿐 재발을 막지 못합니다. failover로 승격된 새 primary에서 동일 증상이 다시 재현된 사례가 있습니다. 근본 해결은 storageClass를 NFS에서 로컬 블록 스토리지(local-path 등)로 교체하는 것이며, PostgreSQL 공식 문서도 NFS를 데이터 디렉터리로 사용하지 않도록 권고합니다. 마이그레이션은 기존 barman-cloud 백업(MinIO)에서 bootstrap.recovery로 새 storageClass 클러스터를 복구하는 방식으로 수행합니다.


부록. 체크리스트

배포 전:

  • CNPG Operator 설치 완료
  • MinIO postgresql-backups 버킷 존재 확인
  • postgres-superuser-secret 생성 완료
  • MinIO 접근 키 생성 완료
  • kustomization.yaml S3 자격증명 업데이트 (SOPS 암호화)
  • values.yaml initdb SQL 확인 (Role/Database 목록)

배포:

  • make namespace 실행
  • make pull 실행
  • make preview 확인
  • make apply 실행

검증:

  • kubectl get clusterCluster in healthy state 확인
  • Primary 1개 + Replica 2개 구성 확인 (pg_stat_replication)
  • pg-cluster-r/ro/rw 서비스 3개 확인
  • kubectl get backupphase: completed 확인 (첫 백업 후)
  • Barman 메트릭 last_available_backup_timestamp != 0 확인