RDS를 쓰면 Multi-AZ 토글 하나로 장애 조치가 끝난다. 그런데 EC2에 PostgreSQL을 직접 올려 쓰는 환경이라면 그 자동 승격을 손으로 만들어야 한다. 예전에 keepalived와 VIP로 액티브-스탠바이를 묶어 쓰던 글을 올린 적이 있는데, 노드가 셋 이상으로 늘면 그 방식은 금세 한계가 온다. 요즘은 Patroni로 가는 게 사실상 표준이라 이 조합을 한 번 정리해 둔다.
결론부터 말하자면, etcd를 합의 저장소로 두고 PostgreSQL 노드마다 Patroni를 붙이면 리더가 죽었을 때 남은 복제본 중 하나가 자동으로 승격한다. 애플리케이션은 HAProxy의 단일 포트만 바라보고, HAProxy가 Patroni의 상태 엔드포인트를 폴링해 현재 리더로만 쓰기를 보낸다. 스플릿브레인은 etcd의 리더 키와 TTL이 막아 준다.
1. Patroni와 etcd가 하는 일
Patroni는 각 PostgreSQL 인스턴스 옆에서 도는 관리 데몬이다. 자기 노드의 Postgres를 띄우고 멈추고, 복제를 걸고, 클러스터에서 누가 리더인지 합의 저장소에 등록한다. 이 합의 저장소를 DCS(Distributed Configuration Store)라고 부르고, 여기서는 etcd를 쓴다. etcd는 Raft 합의 알고리즘으로 여러 노드가 같은 값을 보장하는 키-값 저장소다.
핵심은 리더 키(leader key)다. 현재 리더인 Patroni는 etcd에 자기 이름으로 된 리더 키를 써 두고, loop_wait(기본 10초)마다 돌아오는 자기 루프에서 이 키의 수명을 갱신한다. 이 키에는 ttl(기본 30초)이 걸려 있다. 리더가 살아 있으면 TTL이 만료되기 전에 계속 갱신되지만, 리더가 갱신에 실패하면 TTL이 지나 키가 etcd에서 사라진다. 그 순간 남은 노드들이 리더 자리를 두고 경합하고, 복제 지연이 가장 적은 노드가 새 리더로 승격한다.
즉 자동 승격은 "리더 키가 사라졌다"는 신호에서 출발한다. 어떤 경로로 이 키가 사라지느냐에 따라 승격 속도가 달라지는데, 그 차이는 6절에서 직접 재현한다.
2. 노드 배치
EC2 인스턴스 3대를 서로 다른 가용영역에 띄우고, 각 노드에 PostgreSQL 16, Patroni, etcd를 함께 올린다. etcd는 쿼럼(과반) 기반이라 홀수 노드가 필요하고, 3노드면 1대가 죽어도 과반 2가 유지된다.
node-a 10.0.1.10 AZ ap-northeast-2a postgres + patroni + etcd node-b 10.0.2.10 AZ ap-northeast-2c postgres + patroni + etcd node-c 10.0.3.10 AZ ap-northeast-2d postgres + patroni + etcd
HAProxy는 별도 노드(혹은 애플리케이션 노드)에 두고, 실무에서는 HAProxy 자체도 2대로 이중화한다. 이 글에서는 라우팅 동작에 집중해 HAProxy 1대로 설명한다.
Patroni는 Python 패키지다. 최신 4.1.5 기준 분류자에 Python 3.6부터 3.14까지 올라와 있다. pip로 깔 때는 노드의 Python과 etcd 드라이버를 같이 맞춘다.
$ python3 --version Python 3.11.9 $ pip install 'patroni[etcd3]' psycopg2-binary Successfully installed patroni-4.1.5 python-etcd3-...
버전 번호는 설치 시점에 따라 다를 수 있다. etcd3 API를 쓰므로 extras는 etcd3로 받는다(v2 프로토콜을 쓰면 etcd).
3. etcd 클러스터 띄우기
각 노드에서 etcd를 같은 초기 클러스터 구성으로 기동한다. node-a의 서비스 환경 파일 예시다.
ETCD_NAME=node-a ETCD_LISTEN_PEER_URLS=http://10.0.1.10:2380 ETCD_LISTEN_CLIENT_URLS=http://10.0.1.10:2379,http://127.0.0.1:2379 ETCD_INITIAL_ADVERTISE_PEER_URLS=http://10.0.1.10:2380 ETCD_ADVERTISE_CLIENT_URLS=http://10.0.1.10:2379 ETCD_INITIAL_CLUSTER=node-a=http://10.0.1.10:2380,node-b=http://10.0.2.10:2380,node-c=http://10.0.3.10:2380 ETCD_INITIAL_CLUSTER_STATE=new ETCD_INITIAL_CLUSTER_TOKEN=sarc-etcd
세 노드를 올린 뒤 멤버와 상태를 확인한다. 아래 endpoint status 출력은 지면을 위해 일부 컬럼만 추린 것으로, 실제로는 DB SIZE, IS LEARNER, RAFT APPLIED INDEX, ERRORS 컬럼이 더 붙는다.
$ etcdctl --endpoints=http://10.0.1.10:2379,http://10.0.2.10:2379,http://10.0.3.10:2379 endpoint status -w table +------------------+------------------+---------+-----------+-----------+------------+ | ENDPOINT | ID | VERSION | IS LEADER | RAFT TERM | RAFT INDEX | +------------------+------------------+---------+-----------+-----------+------------+ | 10.0.1.10:2379 | 8e9e05c52164694d | 3.5.x | true | 2 | 18 | | 10.0.2.10:2379 | 91bc3c398fb3c146 | 3.5.x | false | 2 | 18 | | 10.0.3.10:2379 | fd422379fda50e48 | 3.5.x | false | 2 | 18 | +------------------+------------------+---------+-----------+-----------+------------+
여기서 etcd의 "IS LEADER"는 etcd 내부 Raft 리더일 뿐, PostgreSQL 리더와는 별개다. 처음엔 이 둘을 헷갈리기 쉬운데, Postgres 리더는 Patroni가 뒤에서 따로 선출한다.
4. Patroni 설정
노드마다 patroni.yml을 둔다. node-a 예시이고, 다른 노드는 name과 IP만 바꾼다.
scope: sarc-ha
name: node-a
restapi:
listen: 10.0.1.10:8008
connect_address: 10.0.1.10:8008
etcd3:
hosts: 10.0.1.10:2379,10.0.2.10:2379,10.0.3.10:2379
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
postgresql:
use_pg_rewind: true
parameters:
wal_level: replica
hot_standby: "on"
max_wal_senders: 10
max_replication_slots: 10
initdb:
- encoding: UTF8
- data-checksums
postgresql:
listen: 10.0.1.10:5432
connect_address: 10.0.1.10:5432
data_dir: /var/lib/postgresql/16/main
authentication:
replication:
username: replicator
password:
superuser:
username: postgres
password:
몇 가지를 짚는다. maximum_lag_on_failover는 승격 후보가 될 수 있는 복제 지연 상한으로, 이 값(기본 1MB)을 넘게 밀린 복제본은 리더 경합에서 빠진다. 오래된 데이터로 승격하는 사고를 막는 안전장치다.
use_pg_rewind: true는 옛 리더가 복구돼 돌아올 때 전체 재구성 없이 타임라인을 맞춰 복제본으로 재합류시키는 설정이다. 이게 없으면 승격 후 옛 리더를 수동으로 pg_basebackup부터 다시 떠야 할 때가 많다.
세 노드에서 Patroni를 기동하면 하나가 리더로 부트스트랩되고 나머지가 스트리밍 복제로 따라붙는다. 스트리밍 복제는 리더의 WAL(트랜잭션을 먼저 기록하는 로그)을 복제본이 실시간으로 받아 재생하는 PostgreSQL 기본 복제 방식이다.
$ patronictl -c /etc/patroni.yml list + Cluster: sarc-ha (74920...) --------+----+-----------+ | Member | Host | Role | State | TL | Lag in MB | +--------+-----------+---------+-----------+----+-----------+ | node-a | 10.0.1.10 | Leader | running | 1 | | | node-b | 10.0.2.10 | Replica | streaming | 1 | 0 | | node-c | 10.0.3.10 | Replica | streaming | 1 | 0 | +--------+-----------+---------+-----------+----+-----------+
5. HAProxy 라우팅
애플리케이션이 노드 IP를 직접 들고 있으면 승격 때마다 설정을 고쳐야 한다. HAProxy를 앞에 두고 쓰기용 포트 하나로 묶으면, 현재 리더가 누구든 애플리케이션은 같은 주소만 본다.
Patroni의 REST API는 리더일 때만 GET /에 200을 돌려주고, 건강한 복제본은 GET /replica에 200을 준다. HAProxy의 httpchk로 이 엔드포인트를 폴링하면 트래픽이 자동으로 현재 리더(또는 복제본)로만 간다.
global
maxconn 200
defaults
mode tcp
timeout connect 5s
timeout client 30m
timeout server 30m
# 쓰기: 현재 리더로만
listen pg_primary
bind *:5000
option httpchk GET /
http-check expect status 200
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
server node-a 10.0.1.10:5432 check port 8008
server node-b 10.0.2.10:5432 check port 8008
server node-c 10.0.3.10:5432 check port 8008
# 읽기: 건강한 복제본으로 분산
listen pg_replicas
bind *:5001
option httpchk GET /replica
http-check expect status 200
balance roundrobin
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
server node-a 10.0.1.10:5432 check port 8008
server node-b 10.0.2.10:5432 check port 8008
server node-c 10.0.3.10:5432 check port 8008
애플리케이션은 쓰기를 5000, 읽기를 5001로 보낸다. pg_primary는 리더 한 노드만 200을 주므로 자연스럽게 리더로만 연결된다. on-marked-down shutdown-sessions는 리더가 바뀔 때 옛 리더로 남아 있던 기존 연결을 끊어, 승격 직후 엉뚱한 노드로 쓰기가 가는 일을 줄인다.
6. 리더 장애 주입과 자동 승격
여기가 핵심이다. 장애 방식에 따라 승격 경로가 둘로 갈린다. 먼저 하지 말아야 할 실수부터 짚는다. PostgreSQL 프로세스만 죽이면 Patroni가 이를 감지해 같은 노드에서 Postgres를 바로 다시 띄운다. 리더 키는 그대로라 failover가 일어나지 않는다. 그래서 Postgres만 kill하는 테스트로는 자동 승격을 볼 수 없다.
실제 장애에 가까운 재현은 리더 노드의 Patroni를 비정상 종료시키거나 네트워크를 끊는 것이다. 이러면 리더가 etcd의 리더 키를 갱신하지 못한다.
# node-a(리더)에서 Patroni를 강제 종료 $ sudo pkill -9 -f patroni # 또는 리더가 etcd에 닿지 못하게 막는다 $ sudo iptables -A OUTPUT -p tcp --dport 2379 -j DROP
이 경우 리더 키는 갱신이 멈춘 뒤 ttl(30초)이 지나야 etcd에서 만료된다. 키가 사라지면 남은 복제본이 경합해 하나가 승격한다. 즉 비정상 종료에서는 승격까지 최대 TTL만큼 기다린다. 다른 노드에서 지켜보면 이렇게 넘어간다.
$ patronictl -c /etc/patroni.yml list + Cluster: sarc-ha -------------------+----+-----------+ | Member | Host | Role | State | TL | Lag in MB | +--------+-----------+---------+-----------+----+-----------+ | node-a | 10.0.1.10 | Replica | stopped | | unknown | | node-b | 10.0.2.10 | Leader | running | 2 | | | node-c | 10.0.3.10 | Replica | streaming | 2 | 0 | +--------+-----------+---------+-----------+----+-----------+
타임라인(TL)이 1에서 2로 올라간 게 승격이 일어났다는 표시다. 승격에 걸린 시간은 네트워크와 TTL 설정에 따라 다르지만, 기본값에서는 대략 TTL(30초) 안쪽 수준으로 본다.
한편 systemctl stop patroni로 리더를 정상 종료하면 동작이 다르다. 이때 Patroni는 종료 과정에서 리더 키를 즉시 반납한다. TTL 만료를 기다릴 필요 없이 바로 경합이 시작돼 승격이 훨씬 빠르다. 그래서 TTL 만료 경로를 눈으로 확인하려면 systemctl stop이 아니라 위의 kill이나 네트워크 차단을 써야 한다. 계획된 리더 교체가 목적이라면 애초에 장애 주입이 아니라 스위치오버를 쓴다.
$ patronictl -c /etc/patroni.yml switchover Current cluster topology Master [node-a] Candidate ['node-b', 'node-c'] []: node-b When should the switchover take place (e.g. 2026-10-15T14:00 ) [now]: now Are you sure you want to switchover cluster sarc-ha, demoting current master node-a? [y/N]: y Successfully switched over to "node-b"
7. 스플릿브레인 방지
스플릿브레인은 네트워크가 갈라지면서 양쪽이 각자 자기를 리더로 믿고 둘 다 쓰기를 받는 상태다. 이러면 데이터가 갈라져 되돌리기 어렵다. 이 구성이 스플릿브레인을 막는 근거는 세 가지다.
첫째, 리더 자격은 etcd 리더 키 하나로만 정해진다. etcd는 과반 쿼럼이 있어야 쓰기가 되므로, 네트워크가 2대와 1대로 갈리면 소수파(1대) 쪽에서는 etcd에 리더 키를 쓸 수도 갱신할 수도 없다.
둘째, 소수파에 고립된 옛 리더는 리더 키를 갱신하지 못하는 것을 스스로 감지하고, Patroni가 자기 PostgreSQL을 읽기 전용으로 강등한다. 과반 쪽에서 새 리더가 올라오는 동안, 고립된 쪽은 쓰기를 받지 못한다.
셋째, HAProxy는 Patroni의 GET / 상태만 보고 리더를 고른다. 강등된 노드는 더 이상 200을 주지 않으므로 쓰기 포트 대상에서 빠진다. 결국 한 시점에 쓰기를 받는 노드는 하나로 수렴한다.
더 엄격한 보장이 필요하면 synchronous_mode: true를 켠다. 동기 복제를 강제해, 승격 가능한 동기 복제본이 없으면 차라리 쓰기를 멈춰 데이터 유실을 0에 가깝게 만든다. 가용성보다 무결성을 우선하는 선택이라 지연과 쓰기 거부를 감수해야 한다. 금전 거래처럼 유실이 치명적인 경우에만 권한다.
8. 언제 이 구성을 쓰나
관리형 서비스를 쓸 수 있는 환경이면 RDS Multi-AZ나 Aurora가 운영 부담이 훨씬 적다. Patroni를 직접 구성하는 건 온프레미스나 EC2 자체 운영처럼 관리형을 못 쓰거나, DCS와 승격 정책을 손끝까지 통제해야 하는 경우다. 일단 올리면 etcd 쿼럼 관리, Patroni 버전 업그레이드, 복제 지연 모니터링이 모두 내 몫이 된다. 그 비용을 감당할 수 있을 때 선택한다.