PG Resilience Lab
PostgreSQL 클러스터를 10가지 방법으로 고장 내며 RTO, RPO, 원장 무결성을 측정한 HA/DR 실험실
이 프로젝트가 증명하는 것
- 장애 10종 × 3회, 성공 응답 이체 91,700건 중 유실 0건 · 중복 0건 · 원장 불일치 0건
- 동기 복제 대기 중인 커밋을 cancel하면 '성공' 응답 뒤 유실(72건)됨을 측정하고 fencing을 terminate로 바꿔 0건
- 17 → 18 업그레이드 두 방식 비교: pg_upgrade --link 쓰기 공백 22.6초, 논리 복제 + HAProxy 전환 2.4초
- 실수로 실행한 DROP TABLE을 pg_waldump로 찾은 xid 직전까지 PITR, 중앙값 32초
- 역할
- 단독: 클러스터 설계, 장애 주입과 측정 자동화, 결과 분석, ADR 6개와 한국어 런북
- 기간
- 2026.10 – 2026.10
- 기술 스택
- PostgreSQL
- repmgr
- pgBackRest
- HAProxy
- Docker
- k6
- Bash
- Python
- NestJS
핵심 수치
| 항목 | 값 | 구분 | 증빙 |
|---|---|---|---|
| 성공 응답 뒤 사라진 이체 (30회) | 0 / 91,700 | 측정값 | 증빙 보기 |
| RTO 중앙값 · primary kill -9 | 7.07 s | 측정값 | 증빙 보기 |
| RTO 중앙값 · 네트워크 격리 45초 | 27.91 s | 측정값 | 증빙 보기 |
| RTO 중앙값 · split-brain | 28.2 s | 측정값 | 증빙 보기 |
| RTO 중앙값 · DROP TABLE → PITR | 32.03 s | 측정값 | 증빙 보기 |
| 17 → 18 논리 복제 전환 쓰기 공백 | 2.43 s | 측정값 | 증빙 보기 |
| 17 → 18 pg_upgrade --link 쓰기 공백 | 22.6 s | 측정값 | 증빙 보기 |
| cancel로 푼 동기 커밋 중 성공 응답 뒤 유실 | 72 / 620 | 측정값 | 증빙 보기 |
| 보안 점검 통과 | 18 / 18 | 측정값 | 증빙 보기 |
해결하려는 문제
HA 구성은 대개 '설정했다'에서 끝나고, primary가 실제로 죽었을 때 몇 초 만에 쓰기가 돌아오는지, 성공 응답을 받은 거래가 사라지지 않는지는 측정하지 않습니다. 이 실험실은 LedgerCore에 초당 20건 이체를 보내는 동안 primary 정지, 강제 종료, 서버 종료와 재시작, 네트워크 격리, WAL 디스크 가득 참, 데이터 디렉터리 삭제, 테이블 파일 손실, 실수로 실행한 DROP TABLE, split-brain의 10가지 장애를 각 3회 주입하고, 매 실행마다 RTO, 성공 응답 뒤 사라진 이체(RPO), 중복 반영, 복식부기 불변식을 계산합니다.
아키텍처
PostgreSQL 17 노드 3대(repmgr 5.5, primary 1 + standby 2)와 witness가 복제 네트워크에, LedgerCore API와 HAProxy가 앱 네트워크에 있습니다. 복제망만 끊으면 클라이언트는 옛 primary에 닿는 split-brain을 재현할 수 있습니다. synchronous_standby_names = 'ANY 1 (pg1, pg2, pg3)'이고, 노드마다 health agent(:8008)가 '복구 중이 아니고, fence되지 않았고, 3초 안에 응답한 동기 standby가 있다'일 때만 /primary에 200을 돌려주며 HAProxy는 1초마다 이를 확인합니다. 고립된 primary는 read-only로 바뀌고 세션을 terminate합니다. 백업은 TLS 서버 모드의 pgBackRest 전용 저장소 호스트(비동기 WAL 아카이브, standby에서 백업)가 맡고, 모든 연결은 자체 CA의 verify-full TLS입니다. runner 컨테이너가 k6 부하, 장애 주입, 치유, 분석을 자동으로 실행합니다.
데이터 모델
핵심 제약 조건과 인덱스
- synchronous_standby_names = 'ANY 1 (pg1, pg2, pg3)': standby 하나를 잃어도 쓰기가 계속됨
- HAProxy :5000은 동기 정족수가 살아 있는 primary에만 연결
- 모든 연결 hostssl + verify-full, scram-sha-256, 네트워크로 들어오는 superuser 거부, credcheck 최소 16자
- ledger_app 역할: statement_timeout 5s · lock_timeout 2s · idle_in_transaction_session_timeout 10s
핵심 설계 결정
ADR-0001 repmgr + witness
- 상황
- 노드 3대에서 자동 failover가 필요하고, 학습한 과정의 도구로 메커니즘을 직접 다뤄 보고 싶었습니다.
- 결정
- repmgr 5.5 + witness, primary_visibility_consensus, standby_disconnect_on_failover, 자식 노드가 끊기면 primary fence
- 트레이드오프
- DCS 잠금이 없어 split-brain 방지를 정족수 라우팅과 fencing으로 보완해야 함
ADR-0002 동기 정족수 ANY 1
- 상황
- FIRST 1 (pg2)처럼 동기 standby를 하나만 지정하면 pg2가 멈출 때 모든 쓰기가 멈춥니다.
- 결정
- synchronous_standby_names = 'ANY 1 (pg1, pg2, pg3)'
- 트레이드오프
- pg2를 25초 멈췄을 때 FIRST 1은 실패 시도 12,119회와 포기한 이체 213건, ANY 1은 실패 0회. standby 두 대가 모두 끊기면 여전히 쓰기가 멈춤
ADR-0003 정족수를 확인하는 HAProxy health check
- 상황
- 첫 실행에서 격리됐다 돌아온 primary가 스스로 fence하기 전 약 18초 동안 HAProxy에 primary로 노출됐습니다.
- 결정
- /primary는 recovery 아님 + fence 아님 + 3초 안에 응답한 동기 standby가 있을 때만 200
- 트레이드오프
- standby가 모두 느리면 정상 primary도 쓰기 대상에서 빠짐. 그 경우 동기 복제가 어차피 커밋을 막고 있음
ADR-0006 동기 대기 해제는 cancel이 아니라 terminate
- 상황
- 분할 중 primary에서 동기 standby를 기다리는 세션을 정리해야 합니다. statement_timeout은 이 대기를 끊지 않습니다.
- 결정
- fencing은 pg_terminate_backend만 사용, 런북에서 SyncRep 대기에 cancel 금지
- 트레이드오프
- 클라이언트는 연결 오류를 받고 다시 시도해야 함. LedgerCore는 멱등 키로 같은 이체를 안전하게 재시도
개선 전후 측정
장애 대응 기록
- 증상
- ack-leak 실험 중 standby 두 대가 동시에 승격에 실패했고, 로그에는 recovery ended before configured recovery target was reached가 남았습니다.
- 원인
- 앞선 시나리오 09의 PITR이 recovery_target_xid를 postgresql.auto.conf에 남겼고, 이후 만든 모든 standby clone이 이 설정을 복사했습니다. 그래서 승격할 때마다 이미 지난 복구 목표를 기다렸습니다.
- 해결
- PITR 직후 ALTER SYSTEM RESET recovery_target_*를 실행하고, clone과 rejoin 때도 recovery_target 설정을 방어적으로 지우도록 했습니다.
- 재발 방지
- 시나리오 09의 치유 단계가 PITR 뒤 전체 백업을 새로 받고, 수정 뒤 30회 캠페인(PITR 3회 포함)에서 같은 실패는 다시 나오지 않았습니다.
테스트와 품질
- 테스트 수
- —
- 커버리지
- —
- CI
- —
- 테스트 도구
- 장애 시나리오 10종 × 3회, 실험 8종, 보안 점검 18개(security/run.sh), bash -n, LedgerCore 단위 테스트 3개(연결 끊김 수정)
배포와 운영
- 배포 환경
- Docker Compose, 한 대 호스트. Windows에서 .\lab.ps1 init으로 무작위 비밀번호 .env를 만들고 .\lab.ps1 up이 이미지 빌드, 인증서 발급, 클러스터 부트스트랩, 첫 전체 백업, pg_cron 작업 설치까지 한 번에 실행합니다.
- 백업
- pgBackRest 전용 저장소 호스트(TLS 서버 모드, zstd), 비동기 WAL 아카이브, 전체 백업 매주 · 차등 매일 · 증분 4시간마다, standby에서 백업. 매일 최신 백업을 임시로 복원해 원장 무결성과 체크섬을 검사합니다(복원 1.8초 + 복구 1.4초, 위반 0). S3 repo2(aes-256-cbc)는 설정만 있습니다.
- 롤백
- pg_upgrade가 실패하면 기존 17 데이터 디렉터리로 자동 복귀합니다. .\lab.ps1 baseline은 고장 난 노드를 rejoin하거나 다시 만들고 pg1을 primary로 switchover해 기준 상태로 되돌립니다.
- 모니터링
- 노드별 health agent(:8008), HAProxy stats(:8404), 노드마다 repmgr 이벤트를 모아 주입 시각 기준 타임라인으로 저장. PG Insight로 동기 정족수, WAL 아카이빙 실패, 테이블 bloat를 확인했습니다.
스크린샷
한계와 회고
현재 한계
- 모든 컨테이너가 한 대 호스트에서 동작: 실제 서버 장애가 아니라 HA 메커니즘(감지, 선출, fencing, 라우팅, 복구)을 검증합니다. 같은 이유로 동기 복제 비용도 실제보다 작게 나옵니다.
- 수치는 2 vCPU 리눅스 샌드박스에서 같은 패키지의 Ubuntu 기반 대체 이미지로 측정했습니다. 저장소의 Dockerfile은 postgres:17-bookworm과 PGDG 패키지를 씁니다.
- repmgr에는 DCS leader lock이 없어 split-brain 방지는 동기 정족수, 정족수 기반 라우팅, soft fence의 조합입니다. Patroni + etcd가 더 강한 보장을 줍니다.
- S3 저장소(repo2)는 설정만 하고 측정에는 쓰지 않았습니다. PgBouncer는 없습니다.
다시 만든다면
- 같은 시나리오를 Patroni + etcd로 돌려 repmgr와 RTO, split-brain 동작을 비교하겠습니다.
- 서버 두 대 이상에서 실행해 동기 복제의 실제 네트워크 비용을 측정하겠습니다.
핵심 코드
node/bin/pg-health-agent정족수가 있는 primary만 /primary에 200
docker/haproxy/haproxy.cfg1초 검사, fall 2, 내려간 서버 세션 종료
node/bin/fence-selfread-only 전환 + terminate (cancel 금지)
scenarios/09-accidental-drop-pitr.shpg_waldump로 xid 찾기 → PITR → standby delta 재구성
measure/analyze.pyRTO, RPO, 중복, 원장 무결성 계산
experiments/ack-leak.shcancel과 terminate의 유실 측정
upgrade/logical.sh17 → 18 논리 복제, sequence 동기화, HAProxy 전환
docs/runbook.ko.md장애별 대응 절차 (한국어)
현재 상태와 다음 단계
30회 실행에서 성공 응답을 받은 이체 91,700건 중 사라진 이체 0건, 두 번 반영된 이체 0건, 원장 불일치 0건이었습니다. RTO 중앙값은 프로세스 장애(정지, kill -9, 재시작, 디스크 가득 참, 파일 손실) 6.7~7.5초, 서버 종료 24.7초, 네트워크 격리 27.9초, split-brain 28.2초, PITR 32.0초, 데이터 디렉터리 삭제 35.9초입니다. 처음 실행에서 데이터가 위험해지거나 클러스터가 스스로 복구하지 못한 문제 12가지를 찾아 고쳤고, 그중 하나는 LedgerCore API가 DB 연결 끊김에 프로세스째 죽는 버그였습니다. 17 → 18 업그레이드는 pg_upgrade --link가 쓰기 공백 22.6초, 논리 복제 + HAProxy 전환이 2.4초였고 두 방식 모두 유실 0건입니다.