# PostgreSQL을 standalone에서 복제 구성으로 옮길 때 실제로 정해지는 것들
단일 인스턴스로 돌던 PostgreSQL을 고가용성 구성으로 바꾸기로 합니다. 차트에서 replication 모드를 켜면 스탠바이가 하나 뜨고, 복제가 시작되고, 일단 동작합니다.
문제는 그 뒤입니다. 스탠바이가 재시작하자 프라이머리의 쓰기가 멈추거나, 읽기를 분산했더니 방금 저장한 데이터가 조회되지 않거나, 몇 주 뒤 프라이머리 디스크가 가득 찹니다. 셋 다 복제 자체가 아니라 복제와 함께 정해지는 설정에서 나옵니다.
이 글에서는 복제를 켤 때 실질적으로 결정되는 것이 무엇인지, 각 선택이 무엇을 얻고 잃는지 정리합니다.
# 1. 복제는 WAL을 흘려보내는 것이다
먼저 동작 방식입니다.
Streaming replication allows a standby server to stay more up-to-date than is possible with file-based log shipping. The standby connects to the primary, which streams WAL records to the standby as they're generated, without waiting for the WAL file to be filled.
스탠바이가 프라이머리에 접속해 WAL 레코드를 받아 재생합니다. 파일 단위가 아니라 레코드 단위로 흐르므로 지연이 작습니다.
기본값이 중요합니다.
Streaming replication is asynchronous by default ... in which case there is a small delay between committing a transaction in the primary and the changes becoming visible in the standby.
기본은 비동기입니다. 커밋이 스탠바이 도달을 기다리지 않습니다. 즉 복제를 켜기만 했다면 프라이머리가 죽었을 때 마지막 몇 건이 사라질 수 있는 상태입니다. 이것을 바꾸려면 다음 절의 설정을 건드려야 하고, 그러면 대가가 따라옵니다.
# 2. 동기 복제를 켜면 스탠바이가 프라이머리를 멈출 수 있다
동기 복제는 synchronous_standby_names를 채우고 synchronous_commit을 정하는 것으로 켜집니다. 값마다 기다리는 지점이 다릅니다.
| 값 | 커밋이 기다리는 것 | 잃을 수 있는 것 |
|---|---|---|
off | 프라이머리 WAL 기록조차 안 기다림 | 프라이머리 크래시 시 최근 커밋 |
local | 프라이머리 디스크 기록 | 프라이머리 소실 시 스탠바이에 아직 복제되지 않은 커밋 |
remote_write | 스탠바이 OS까지 도달 | 스탠바이 OS 크래시 |
on | 양쪽 디스크 기록 | 동시 크래시일 때만 |
remote_apply | 스탠바이가 재생까지 완료 | 거의 없음 |
문서는 각 값의 보장 수준을 이렇게 설명합니다.
remote_write: ... wait for confirmation that the standby has received the commit record and written it out to its own operating system, but not for the data to be flushed to disk on the standby. This setting provides a weaker guarantee of durability than
ondoes
on: each commit of a write transaction will wait until confirmation is received that the commit has been written to the write-ahead log on disk of both the primary and standby server. The only possibility that data can be lost is if both the primary and the standby suffer crashes at the same time.
remote_apply: ... wait until the current synchronous standbys report that they have replayed the transaction, making it visible to user queries. In simple cases, this allows for load balancing with causal consistency.
remote_apply가 4절의 읽기 일관성 문제를 푸는 열쇠이지만, 가장 느립니다.
그리고 여기에 이 글에서 가장 중요한 경고가 붙습니다.
Such transaction commits may never be completed if any one of the synchronous standbys should crash.
동기 스탠바이가 죽으면 커밋이 영원히 완료되지 않습니다. 가용성을 높이려고 도입한 구성이 가용성을 떨어뜨리는 지점입니다. 스탠바이 하나짜리 동기 복제는 장애 지점을 하나에서 둘로 늘리는 것과 같습니다.
문서의 권고도 명확합니다.
If you really cannot keep as many synchronous standbys as requested then you should decrease the number of synchronous standbys that transaction commits must wait for responses from in
synchronous_standby_names(or disable it) and reload the configuration file on the primary server.
즉 사람이 개입해 설정을 바꿔야 풀립니다. 자동 회피가 없습니다.
실용적인 대응은 스탠바이를 둘 이상 두고 정족수 방식을 쓰는 것입니다.
# 셋 중 아무 하나만 응답하면 커밋 - 하나가 죽어도 멈추지 않는다
synchronous_standby_names = 'ANY 1 (s1, s2, s3)'
synchronous_commit = on
The method
ANYspecifies a quorum-based synchronous replication and makes transaction commits wait until their WAL records are replicated to at least the requested number of synchronous standbys in the list.
스탠바이가 하나뿐이라면 동기 복제를 켜지 않는 편이 낫습니다. 내구성을 얻는 대신 가용성을 잃는 교환인데, 스탠바이 하나로는 그 교환이 성립하지 않습니다.
# 3. 복제 슬롯이 프라이머리 디스크를 채운다
복제 슬롯은 스탠바이가 아직 못 받은 WAL을 프라이머리가 지우지 않도록 보장합니다.
Replication slots provide an automated way to ensure that the primary server does not remove WAL segments until they have been received by all standbys
유용한 기능인데, 그 보장이 무조건적이라는 것이 문제입니다.
Beware that replication slots can cause the server to retain so many WAL segments that they fill up the space allocated for
pg_wal.
스탠바이가 죽어 있거나 오래 뒤처지면 프라이머리는 그 WAL을 계속 쌓습니다. 스탠바이 하나가 며칠 꺼져 있으면 프라이머리 디스크가 찹니다. 그리고 pg_wal이 가득 차면 프라이머리가 쓰기를 멈춥니다. 스탠바이 장애가 프라이머리 장애로 번지는 두 번째 경로입니다.
상한이 있습니다.
max_slot_wal_keep_sizecan be used to limit the size of WAL files retained by replication slots.
# 이 크기를 넘으면 슬롯을 무효화하고 WAL을 지운다
max_slot_wal_keep_size = '64GB'
대가는 명확합니다. 상한을 넘긴 스탠바이는 슬롯이 무효화되어 스트리밍으로 따라잡을 수 없고, 베이스 백업부터 다시 만들어야 합니다. 그럼에도 프라이머리를 지키는 쪽이 낫습니다. 스탠바이 재구축은 계획된 작업이고, 프라이머리 디스크 고갈은 장애입니다.
값을 정하는 기준은 "스탠바이가 얼마나 오래 꺼져 있어도 자동 복구되기를 원하는가"입니다. 평소 WAL 생성 속도에 그 시간을 곱하면 됩니다.
# 4. 읽기를 분산하면 방금 쓴 것이 안 보인다
복제를 켜는 흔한 동기 중 하나가 읽기 부하 분산입니다. 여기서 비동기 복제의 성질이 드러납니다. 스탠바이는 프라이머리보다 항상 조금 뒤에 있습니다.
그래서 다음이 실제로 발생합니다.
사용자가 저장 → 프라이머리에 커밋
곧바로 조회 → 스탠바이로 라우팅 → 아직 없음 → "저장이 안 됐다"
지연이 보통 1초 미만이라 대부분의 조회는 문제가 없고, 저장 직후 조회에서만 나타납니다. 재현이 어렵고 사용자만 겪는 형태의 버그입니다.
선택지가 셋입니다.
(A) 쓰기 후 읽기는 프라이머리로 보낸다. 애플리케이션이 판단합니다. 가장 확실하고, 라우팅 규칙이 코드에 들어옵니다.
(B) remote_apply를 쓴다. 커밋이 스탠바이 재생까지 기다리므로 커밋 반환 시점에 스탠바이에서도 보입니다. 문서가 "causal consistency"라고 부르는 것이 이것입니다. 대가는 커밋 지연이고, 2절의 가용성 위험도 함께 옵니다.
(C) 지연을 감시하고 임계값을 넘으면 스탠바이를 라우팅에서 뺀다. 완화책이고, 방금 쓴 것 문제는 그대로 남습니다.
기준을 정하자면, 읽기 분산이 필요한 이유가 "프라이머리 부하"라면 (A)가 맞습니다. 대부분의 조회는 스탠바이로 가고 쓰기 직후만 프라이머리로 가므로 분산 효과는 유지됩니다. (B)는 모든 쓰기를 느리게 만들어 문제를 푸는 방식이라 비용이 큽니다.
한 가지 더 있습니다. 스탠바이에서 오래 도는 조회는 WAL 재생과 충돌해 취소될 수 있습니다. hot_standby_feedback을 켜면 이 취소가 줄어들지만, 프라이머리가 그만큼 정리(vacuum)를 미루게 되어 프라이머리 쪽 테이블 팽창이 생깁니다. 스탠바이의 편의가 프라이머리의 비용이 되는 구조이므로, 분석용 장기 조회는 별도 인스턴스로 빼는 편이 낫습니다.
# 5. 복제는 페일오버가 아니다
가장 흔한 오해입니다. 스트리밍 복제는 데이터를 옮기는 것까지만 합니다. 프라이머리가 죽었을 때 스탠바이를 승격시키고, 애플리케이션의 연결을 옮기고, 옛 프라이머리가 돌아왔을 때 다시 쓰기를 받지 않도록 막는 일은 전부 별개입니다.
필요한 조각이 셋입니다.
- 승격 판단: 정말 죽었는가, 아니면 네트워크만 끊겼는가
- 연결 전환: 애플리케이션이 새 프라이머리를 찾는 경로
- 펜싱: 옛 프라이머리가 쓰기를 받지 못하게 차단
세 번째가 빠지면 양쪽이 프라이머리라고 믿는 상태가 됩니다. 네트워크 분단 시 양쪽에서 각각 쓰기를 받고, 복구할 때 어느 쪽이 진짜인지 판정할 수 없습니다. 데이터 손실보다 나쁜 결과입니다.
그래서 복제만 켜고 "HA가 됐다"고 하면 안 됩니다. 자동 페일오버가 없다면 그것은 재해 복구용 사본이지 고가용성이 아닙니다. 둘 다 유효한 선택이지만 이름을 구분해야 기대가 어긋나지 않습니다.
Kubernetes에서 돌린다면 배치도 함께 정해야 합니다. 프라이머리와 스탠바이가 같은 노드에 올라가면 복제의 의미가 없습니다. 파드를 서로 다른 노드로 흩는 방법은 파드를 흩뿌리는 두 가지 방법 (opens new window)에 정리했습니다. 선호(preferred)가 아니라 강제(required)로 두어야 합니다. 노드가 부족할 때 같은 노드에 몰리는 것보다 스탠바이가 안 뜨는 편이 낫습니다.
# 6. 전환 절차
standalone에서 복제 구성으로 가는 과정에서 결정할 것들입니다.
- 스탠바이를 먼저 붙인다. 베이스 백업을 만들고 스트리밍을 시작합니다. 이 단계는 프라이머리에 읽기 부하만 줍니다.
- 비동기로 며칠 돌린다. 지연, WAL 생성량, 슬롯 크기를 관찰합니다. 3절의 값을 여기서 정합니다.
- 필요하면 동기로 전환한다. 스탠바이가 둘 이상 있고 정족수를 쓸 수 있을 때만.
- 읽기 라우팅을 붙인다. 4절의 선택을 코드에 반영합니다.
- 페일오버를 붙이고 시험한다. 시험하지 않은 페일오버는 없는 것과 같습니다.
2번을 건너뛰지 않는 것이 요령입니다. 설정값을 문서의 예시에서 베껴 오면 그 클러스터의 WAL 생성량과 맞지 않습니다. 관찰이 값을 정합니다.
애플리케이션 쪽 연결 설정도 함께 봐야 합니다. 프라이머리가 바뀌었을 때 기존 커넥션 풀이 옛 주소를 붙들고 있으면 전환이 끝나도 복구되지 않습니다. 커넥션 수명과 타임아웃이 이 회복 시간을 정하고, 관련 설정은 GORM에서 WithContext를 빼먹으면 무엇이 안 되는가 (opens new window)에 정리했습니다.
# 7. 직접 확인하는 방법
복제 상태와 지연을 봅니다.
-- 프라이머리에서: 연결된 스탠바이와 각 단계의 진행 위치
SELECT application_name, state, sync_state,
pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_behind_bytes,
write_lag, flush_lag, replay_lag
FROM pg_stat_replication;
sync_state가 async인데 동기로 설정했다고 믿고 있다면 synchronous_standby_names의 이름이 스탠바이의 application_name과 안 맞는 것입니다. 이름 불일치로 동기 복제가 조용히 비동기로 도는 것이 흔한 오설정입니다.
슬롯이 얼마나 WAL을 붙들고 있는지 확인합니다.
-- 3절의 디스크 고갈 위험 구간
SELECT slot_name, active, wal_status,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;
active가 false인데 retained가 계속 커지면 죽은 스탠바이가 프라이머리 디스크를 먹고 있는 것입니다. wal_status가 lost면 이미 슬롯이 무효화되어 재구축이 필요합니다.
스탠바이 쪽에서 지연을 시간으로 봅니다.
-- 스탠바이에서: 초 단위 지연
SELECT now() - pg_last_xact_replay_timestamp() AS replication_delay;
이 값을 메트릭으로 내보내고 임계값 알림을 걸면 4절의 문제를 미리 잡을 수 있습니다.
페일오버는 시험으로만 검증됩니다.
# 계획된 전환을 정기적으로 돌려 본다 - 실패는 시험에서 발견해야 한다
# 전환 후 확인할 것: 새 프라이머리 승격, 애플리케이션 재연결 시간, 옛 프라이머리 차단
# 8. 트러블슈팅
| 증상 | 원인 | 조치 |
|---|---|---|
| 스탠바이 재시작에 프라이머리 쓰기 중단 | 동기 스탠바이 하나뿐 | 정족수 방식 또는 비동기 |
동기로 설정했는데 async로 표시 | application_name 불일치 | 이름 대조 |
| 프라이머리 디스크가 계속 참 | 비활성 슬롯이 WAL 보존 | max_slot_wal_keep_size |
| 스탠바이가 따라잡지 못함 | 슬롯 무효화(lost) | 베이스 백업부터 재구축 |
| 방금 저장한 것이 안 보임 | 복제 지연 + 읽기 분산 | 쓰기 후 읽기는 프라이머리로 |
| 스탠바이에서 쿼리가 취소됨 | WAL 재생과 충돌 | hot_standby_feedback 검토 |
| 프라이머리 테이블이 팽창 | 피드백으로 vacuum 지연 | 장기 조회는 별도 인스턴스 |
| 프라이머리 장애 시 자동 복구 안 됨 | 복제에는 페일오버가 없음 | 별도 도구, 펜싱 포함 |
| 두 인스턴스가 같은 노드 | 배치 제약이 선호 수준 | 강제(required)로 변경 |
# 9. 마무리
- 스트리밍 복제의 기본은 비동기입니다. 켜는 것만으로는 프라이머리 소실 시 최근 커밋이 사라질 수 있습니다.
- 동기 복제는 동기 스탠바이가 죽으면 커밋이 완료되지 않습니다. 스탠바이가 하나뿐이라면 장애 지점을 늘리는 선택이고, 정족수를 쓸 수 있을 때만 켤 만합니다.
- 복제 슬롯은 스탠바이가 받을 때까지 WAL을 보존합니다. 스탠바이가 죽어 있으면 프라이머리 디스크를 채워 프라이머리를 멈춥니다. 상한을 걸어 프라이머리를 지키는 편이 낫습니다.
- 읽기 분산은 "방금 쓴 것이 안 보이는" 문제를 만듭니다. 쓰기 직후 조회만 프라이머리로 보내는 것이 비용 대비 효과가 가장 좋습니다.
- 복제는 페일오버가 아닙니다. 승격 판단·연결 전환·펜싱이 없으면 재해 복구용 사본이지 고가용성이 아닙니다.
- 설정값은 문서 예시가 아니라 며칠 관찰한 결과에서 나와야 합니다. 비동기로 먼저 돌려 보는 단계를 건너뛰지 않습니다.