커스터마이즈한 PostgreSQL 16 이미지를 StatefulSet 으로 올리고, 데이터 디렉터리는 volumeClaimTemplates 로 Pod 마다 PVC 를 붙인다. 오퍼레이터(Patroni · CloudNativePG)를 쓰지 않고 entrypoint.sh 와 초기화 컨테이너로 직접 구성하는 경우의 설계다. 레플리카는 2 로 고정해 master-replica 쌍으로 운영한다.
Pod 이 재시작해도 원래 역할로 돌아온다는 보장이 없으므로, 서비스 셀렉터를 Pod 이름에 고정하면 안 된다. 각 Pod 이 자신의 역할을 판정해 레이블을 붙이고, 쓰기 서비스는 그 레이블을 셀렉터로 잡는다.
Service pg-write → selector: role=master
Service pg-read → selector: app=pgsql (전체)
역할 판정은 pg_is_in_recovery() 결과를 쓰고, 레이블 갱신은 Pod 안에서 kubectl 을 쓰지 않고 Downward API 와 사이드카(또는 컨트롤러)로 처리한다. 컨테이너 안에서 kubectl 을 호출하려면 ServiceAccount 권한이 필요해 보안 범위가 넓어진다.
SELECT pg_is_in_recovery(); -- false = master, true = replica
WAL 스트리밍 복제를 쓰고, replica 는 초기화 시 pg_basebackup 으로 master 를 복제한다.
pg_basebackup -h ${PRIMARY_HOST} -U ${POSTGRESQL_REPL_USER} \
-D ${POSTGRESQL_DATA} -Fp -Xs -P -R
-R 이 standby.signal 과 primary_conninfo 를 만들어 준다. master 쪽에는 복제 슬롯을 미리 만들어 두어 replica 가 밀려도 WAL 이 지워지지 않게 한다.
SELECT pg_create_physical_replication_slot('replica_1');
POSTGRESQL_* 로 통일해 초기화 컨테이너와 메인 컨테이너가 같은 규칙을 쓰게 한다.initdb 는 데이터 디렉터리가 비어 있을 때만 실행한다. PG_VERSION 파일 존재 여부로 판정하고, 실패 시 중간 산출물을 지워 다음 기동에서 다시 시도할 수 있게 한다.POD_NAME 을 Downward API 로 받아 스크립트 안에서 잘라 쓴다. valueFrom 만으로는 문자열 일부를 뽑을 수 없다.각 Pod 에 NFS(RWX) PVC 를 붙이고 매주 토요일 02:00 에 전체 덤프를 남긴다. 실행 시점에 Pod 이 재시작 중이었으면 기동 직후 보완 백업을 한 번 더 수행한다.
# 사이드카 루프 개요
LAST_MARK=/backup/.last_full_dump # yyyyMMdd 기록
now=$(date +%Y%m%d%H%M)
dow=$(date +%u); hhmm=$(date +%H%M)
if [ "$dow" = "6" ] && [ "$hhmm" = "0200" ]; then
run_dump
elif [ "$dow" = "6" ] || [ "$dow" = "7" ]; then
# 토요일 02:00 을 지나쳤는데 기록이 없으면 보완 백업
[ "$(cat $LAST_MARK 2>/dev/null)" != "$(date +%Y%m%d -d 'last saturday')" ] && run_dump
fi
run_dump() {
pg_dumpall -h 127.0.0.1 -U ${POSTGRESQL_USER} \
| gzip > /backup/$(hostname)_$(date +%Y%m%d_%H%M).sql.gz
date +%Y%m%d > $LAST_MARK
}
master 에서만 덤프하도록 pg_is_in_recovery() 로 걸러도 되지만, 각 Pod 이 자기 것을 남기면 전환 이력과 무관하게 백업이 유지된다.
특정 Pod 이 실패해 네트워크 전환이 일어나는 경우 자체 스크립트로는 split-brain 을 완전히 막기 어렵다. 합의 저장소 없이 역할을 판정하기 때문이다. 운영 규모가 커지면 Patroni 나 CloudNativePG 같은 오퍼레이터로 옮기는 편이 안전하다.