PostgreSQL 고가용성: 서버 세 대로 하는 active-standby
Patroni·etcd 자동 장애 조치와 따라가는 클라이언트
목표
etcd 세 대 위에 Patroni 클러스터를 세우고, 리더를 떼어 내 자동 장애 조치와 옛 리더의 복귀를 확인한 뒤, 앱이 연결 문자열과 가상 IP 로 새 리더를 따라가게 합니다.
왜 중요한가
앞 모듈의 split-brain 은 '누가 주 서버인가' 를 각 서버가 혼자 판단해서 생겼습니다. Patroni 는 그 판단을 etcd 의 다수결 하나로 모으고, 다수에 닿지 못한 주 서버를 스스로 내려오게 합니다. 그런데 구축이 끝났다고 HA 가 되는 것은 아닙니다. pg_hba 한 줄, VIP 의 조건 검사 하나가 빠져도 평소에는 아무 증상이 없다가 장애가 나는 날에만 드러납니다. 그래서 이 실습은 세우고 끝내지 않고, 리더를 실제로 떼어 내고 다시 붙여 봅니다.
단계
- db1·db2·witness 에서 etcd 를 띄워 세 멤버짜리 클러스터 하나로 묶으세요. 멤버 이름은 서버 이름과 같고, 피어 주소(
2380)와 클라이언트 주소(2379)는 각 서버의 세션망 주소여야 합니다. 설정은 각 서버의/etc/default/etcd에 둡니다. witness 에서etcdctl --endpoints=172.30.1.2:2379,172.30.1.3:2379,172.30.1.4:2379 endpoint health가 셋 다 healthy 여야 합니다. - db1 의
/etc/patroni/config.yml을 쓰고(scopepgha, namedb1) Patroni 를 띄워 클러스터를 부트스트랩하세요. REST API 와 PostgreSQL 의connect_address는 db1 의 세션망 주소여야 합니다. pg_hba 에는 세션망과 127.0.0.1 의 복제 줄을 둡니다. db1 이 리더가 되어야 합니다. - db2 에도 같은 틀의
/etc/patroni/config.yml(namedb2, db2 의 세션망 주소)을 두고 Patroni 를 띄우세요. db2 가 리더에게서 베이스백업을 받아 streaming 상태의 대기 서버가 되어야 합니다. patronictl edit-config로 클러스터 설정에synchronous_mode: true를 켜세요. 대기 서버가Sync Standby가 되어야 합니다.synchronous_standby_names를 직접 고치지 않습니다.- 리더에 앱 계정
app(비밀번호apppass)과 그 계정이 주인인 데이터베이스appdb, 표orders(id bigserial primary key, note text not null, at timestamptz not null default now())를 만드세요. 그리고 두 서버를 모두 적고 쓰기를 받는 서버만 고르며connect_timeout(10초 이하)을 둔 libpq 연결 문자열을/root/ha/dsn에 한 줄로 두세요.psql "$(cat /root/ha/dsn)"이 리더에 붙어야 합니다. - 지금 리더인 db1 을 세션망에서 떼어 내세요. db1 에서 iptables 로 eth1 의 들어오고 나가는 패킷을 모두 버리되 ssh(22)는 남깁니다. db2 가 새 리더가 될 때까지 기다린 뒤, 같은 연결 문자열(
/root/ha/dsn)로insert into orders(note) values ('after-failover')를 하세요. 떼어 낸 db1 은 쓰기를 받지 않아야 합니다. - db1 의 iptables 규칙을 걷으세요. db1 의 Patroni 가 스스로 되감기(pg_rewind)를 하고 새 리더를 따르는 대기 서버로 돌아와야 합니다. db1 이 streaming 이고 타임라인이 리더와 같으며, 6단계의
after-failover주문이 db1 에도 보여야 합니다. - db1 과 db2 에 keepalived 를 설정해 가상 IP
172.30.1.50/24를 eth1 에 올리세요. VRRP 는 세션망에서 유니캐스트(상대 서버 주소)로 주고받고, 우선순위는 db1 110·db2 100 입니다. VIP 는 언제나 Patroni 리더에만 있어야 하고, witness 에서psql -h 172.30.1.50 -U app -d appdb가 쓰기를 받는 서버에 붙어야 합니다.
참고
- 이 터미널은 witness 입니다. 앱 서버이자 관리 콘솔이라고 생각하세요. 다른 서버는
ssh db1·ssh db2로 들어가고exit로 돌아옵니다. 세 서버 모두 root 입니다. - 서버 안에서 PostgreSQL 에 관리자로 붙을 때는
sudo -iu postgres psql입니다. witness 에서 앱 계정으로는psql -h db1 -U app -d appdb이고 비밀번호는/root/.pgpass에 들어 있습니다. - 주소는 세션망(eth1)의 것입니다: db1
172.30.1.2, db2172.30.1.3, witness172.30.1.4. 이름(db1·db2)으로 써도 이 주소로 풀립니다. 첫 NIC 의 주소(10.0.2.2)는 세 서버가 모두 같아서 서로를 부르는 데 쓸 수 없습니다. - 채점기는 witness 에서
ssh db1·ssh db2로 서버를 들여다봅니다. 22번 포트와 root 의 ssh 열쇠는 건드리지 마세요. - 세 VM 은 세션이 끝나면 함께 사라지고 남는 것이 없습니다. 60분을 넘기면 화면의 +시간으로 연장하세요.
- 세 서버에 etcd·Patroni(3.2)·PostgreSQL 16 이, db1·db2 에 keepalived 가 깔려 있습니다. etcd 와 Patroni 는 아직 떠 있지 않고, 패키지가 만든 PostgreSQL 클러스터는 걷어 두었습니다(Patroni 가 직접 만듭니다).
- witness 에서
patronictl list를 바로 쓸 수 있게/etc/patroni/patronictl.yml을 두었습니다(scopepgha). - 형식 예시의 주소 192.0.2.x 는 문서용 표본 대역입니다. 이 실습의 세션망 주소로 바꿔 쓰세요.
- 흔한 실수: etcd 의 피어 주소를 localhost 로 두는 것, 그리고 keepalived 에 조건 검사 없이 우선순위만 두는 것.
세 서버의 etcd 를 하나로 묶는다
db1·db2·witness 에서 etcd 를 띄워 세 멤버짜리 클러스터 하나로 묶으세요. 멤버 이름은 서버 이름과 같고, 피어 주소(2380)와 클라이언트 주소(2379)는 각 서버의 세션망 주소여야 합니다. 설정은 각 서버의 /etc/default/etcd 에 둡니다. witness 에서 etcdctl --endpoints=172.30.1.2:2379,172.30.1.3:2379,172.30.1.4:2379 endpoint health 가 셋 다 healthy 여야 합니다.
세 서버의 설정은 이름·주소만 다르고 ETCD_INITIAL_CLUSTER 는 같습니다. 한 대만 먼저 띄우면 나머지를 기다리느라 systemctl 이 돌아오지 않으니 systemctl start --no-block etcd 로 세 대를 거의 동시에 띄웁니다. 피어 주소에 localhost 를 쓰면 서로를 찾지 못합니다.
db1 에서 Patroni 로 클러스터를 세운다
db1 의 /etc/patroni/config.yml 을 쓰고(scope pgha, name db1) Patroni 를 띄워 클러스터를 부트스트랩하세요. REST API 와 PostgreSQL 의 connect_address 는 db1 의 세션망 주소여야 합니다. pg_hba 에는 세션망과 127.0.0.1 의 복제 줄을 둡니다. db1 이 리더가 되어야 합니다.
형식 예시의 틀에서 이름과 주소만 바꿉니다. 파일은 postgres 가 읽을 수 있어야 합니다(chown postgres:postgres). Patroni 는 자기 PostgreSQL 에게도 127.0.0.1 로 복제 연결을 엽니다(되감기 전에 타임라인을 확인할 때). 그 줄이 없으면 7단계에서 db1 이 돌아오지 못합니다. 리더 확인은 witness 에서 patronictl list 입니다.
db2 를 대기 서버로 붙인다
db2 에도 같은 틀의 /etc/patroni/config.yml(name db2, db2 의 세션망 주소)을 두고 Patroni 를 띄우세요. db2 가 리더에게서 베이스백업을 받아 streaming 상태의 대기 서버가 되어야 합니다.
Patroni 가 베이스백업부터 대기 서버 설정까지 해 줍니다. 손으로 했던 1모듈의 3단계와 4단계가 무엇을 대신한 것인지 비교해 보세요. patronictl list 의 State 가 streaming, TL 이 리더와 같아야 합니다.
Patroni 의 동기 모드
patronictl edit-config 로 클러스터 설정에 synchronous_mode: true 를 켜세요. 대기 서버가 Sync Standby 가 되어야 합니다. synchronous_standby_names 를 직접 고치지 않습니다.
patronictl -c /etc/patroni/patronictl.yml edit-config -s synchronous_mode=true --force 처럼 한 줄로도 됩니다. 동기 모드에서는 Patroni 가 synchronous_standby_names 를 관리하고, 동기 대상이 아닌 서버는 승격 후보에서 뺍니다. 확인은 curl -s http://172.30.1.2:8008/config.
앱이 리더를 찾아가는 연결 문자열
리더에 앱 계정 app(비밀번호 apppass)과 그 계정이 주인인 데이터베이스 appdb, 표 orders(id bigserial primary key, note text not null, at timestamptz not null default now()) 를 만드세요. 그리고 두 서버를 모두 적고 쓰기를 받는 서버만 고르며 connect_timeout(10초 이하)을 둔 libpq 연결 문자열을 /root/ha/dsn 에 한 줄로 두세요. psql "$(cat /root/ha/dsn)" 이 리더에 붙어야 합니다.
리더에는 PGPASSWORD=supass psql -h 172.30.1.2 -U postgres 로 붙습니다. 연결 문자열은 URI 꼴(postgresql://app@db1:5432,db2:5432/appdb?target_session_attrs=read-write)이나 키값 꼴(host=db1,db2 ...) 모두 됩니다. target_session_attrs 가 없으면 첫 서버가 대기 서버여도 그냥 붙습니다. connect_timeout 이 없으면 죽은 서버에 대한 TCP 연결 시도를 2분 넘게 기다린 뒤에야 다음 서버로 넘어갑니다.
리더를 떼어 낸다
지금 리더인 db1 을 세션망에서 떼어 내세요. db1 에서 iptables 로 eth1 의 들어오고 나가는 패킷을 모두 버리되 ssh(22)는 남깁니다. db2 가 새 리더가 될 때까지 기다린 뒤, 같은 연결 문자열(/root/ha/dsn)로 insert into orders(note) values ('after-failover') 를 하세요. 떼어 낸 db1 은 쓰기를 받지 않아야 합니다.
규칙은 -I INPUT 1 -i eth1 -p tcp --dport 22 -j ACCEPT, -I INPUT 2 -i eth1 -j DROP 처럼 ssh 를 먼저 허용하고 나머지를 버립니다(OUTPUT 은 --sport 22). db1 의 Patroni 는 etcd 의 다수에 닿지 못해 리더 열쇠를 갱신할 수 없고, 스스로 내려옵니다. ssh db1 journalctl -u patroni 에서 그 순간을 찾아보세요. 새 리더는 30초 안팎에 섭니다.
옛 리더가 되감겨 돌아온다
db1 의 iptables 규칙을 걷으세요. db1 의 Patroni 가 스스로 되감기(pg_rewind)를 하고 새 리더를 따르는 대기 서버로 돌아와야 합니다. db1 이 streaming 이고 타임라인이 리더와 같으며, 6단계의 after-failover 주문이 db1 에도 보여야 합니다.
넣은 규칙을 같은 꼴로 -D 해서 지우면 됩니다. db1 은 떼어진 동안 옛 타임라인에 WAL 을 조금 더 남겼을 수 있어서 Patroni 가 되감기를 돌립니다. 오래 running 에 머문다면 ssh db1 journalctl -u patroni 에서 pg_hba 와 rewind 를 찾아보세요.
리더를 따라다니는 가상 IP
db1 과 db2 에 keepalived 를 설정해 가상 IP 172.30.1.50/24 를 eth1 에 올리세요. VRRP 는 세션망에서 유니캐스트(상대 서버 주소)로 주고받고, 우선순위는 db1 110·db2 100 입니다. VIP 는 언제나 Patroni 리더에만 있어야 하고, witness 에서 psql -h 172.30.1.50 -U app -d appdb 가 쓰기를 받는 서버에 붙어야 합니다.
우선순위만 두면 VIP 는 리더가 아니라 우선순위가 높은 db1 에 머뭅니다. 지금 리더는 db2 입니다. vrrp_script 로 자기 Patroni 의 /primary(리더일 때만 200)를 묻고 track_script 로 묶으세요. 설정 파일은 /etc/keepalived/keepalived.conf 입니다. PostgreSQL 이 0.0.0.0 에서 듣고 있는 이유도 여기에 있습니다.