본문 바로가기
삵
Amazon Web Services

Patroni와 etcd로 PostgreSQL 자동 승격

아아무로레이·2026년 10월 15일·조회 0

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 버전 업그레이드, 복제 지연 모니터링이 모두 내 몫이 된다. 그 비용을 감당할 수 있을 때 선택한다.

자주 묻는 질문

PostgreSQL 프로세스만 죽이면 왜 failover가 안 일어나나?

Patroni가 같은 노드의 Postgres를 감시하다가 죽으면 즉시 다시 띄우기 때문이다. 리더 키는 계속 갱신되므로 리더가 바뀌지 않는다. 자동 승격을 재현하려면 리더의 Patroni를 kill -9로 강제 종료하거나 etcd로 가는 네트워크를 차단해 리더 키 갱신을 끊어야 한다.

정상 종료와 비정상 종료에서 승격 속도가 왜 다른가?

systemctl stop patroni로 정상 종료하면 Patroni가 리더 키를 즉시 반납해 TTL을 기다리지 않고 바로 승격이 시작된다. 반면 kill -9나 네트워크 차단 같은 비정상 종료에서는 키를 반납하지 못해 ttl(기본 30초)이 지나 키가 만료된 뒤에야 승격이 일어난다.

etcd는 왜 노드 수를 홀수로 맞추나?

etcd는 Raft 합의로 과반 쿼럼이 있어야 쓰기가 가능하다. 3노드면 1대가 죽어도 과반 2가 유지돼 리더 키 쓰기가 이어진다. 짝수 노드는 과반 경계가 애매해지고 장애 내성이 늘지 않으므로, 3이나 5처럼 홀수로 둔다.

ttl과 loop_wait는 어떻게 조정하나?

ttl은 리더 키 수명(기본 30초), loop_wait는 Patroni 루프 주기(기본 10초)다. ttl을 줄이면 비정상 장애 시 승격이 빨라지지만, 일시적 지연을 장애로 오판해 불필요한 승격이 날 위험이 커진다. loop_wait와 retry_timeout을 함께 보면서 네트워크 상태에 맞춰 신중히 내린다.

synchronous_mode는 항상 켜야 하나?

아니다. 동기 복제를 강제하면 데이터 유실을 거의 0으로 만들지만, 승격 가능한 동기 복제본이 없을 때 쓰기를 멈추므로 가용성과 지연을 희생한다. 유실이 치명적인 금전 거래 같은 경우에 켜고, 일반 서비스는 비동기 기본값에 maximum_lag_on_failover로 안전선을 두는 선택이 무난하다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.