2026년 6월 4일 목요일
글 목록
Lv.3 중급공통
24분 읽기Lv.3 중급
시리즈멀티 리전에서 Patroni H/A 운용하기 · 파트 5시리즈 허브 보기

멀티 리전에서 Patroni H/A 운용하기 — Part 5: 장애 대응 Runbook 및 DR 훈련 시나리오

멀티 리전에서 Patroni H/A 운용하기 — Part 5: 장애 대응 Runbook 및 DR 훈련 시나리오

단일 노드 장애부터 DC 전체 장애, Split-Brain까지 — 실제 장애 상황에서 바로 꺼내 쓸 수 있는 Patroni 멀티 리전 HA 대응 Runbook 5종과 DR 훈련 시나리오, Post-Mortem 체크리스트를 정리한다.

Part 4에서는 Split-Brain 방지를 위한 세 가지 방어층(DCS Leader Lock, Watchdog, STONITH)을 구성했다. 방어 구조가 제자리를 잡았더라도, 실제 장애가 발생했을 때 어떤 순서로 판단하고 어떤 명령을 실행할지 사전에 정의되어 있지 않으면 그 구조는 작동하지 않는다.

이 글은 그 간극을 채우는 Runbook 모음이다. 멀티 리전 HA 환경에서 발생할 수 있는 다섯 가지 주요 장애 시나리오에 대해 — 각각 전제 조건, 판단 기준, 단계별 명령, 복구 후 검증 체크리스트를 포함했다. Runbook은 문서가 아니라 안전 시스템이다. 훈련하지 않은 Runbook은 실제 장애 때 작동하지 않는다.


1. Runbook 사용 전 공통 전제 조건

모든 Runbook 실행 전에 아래 항목을 반드시 확인한다.

공통 환경 변수 설정

터미널 세션을 열 때마다 아래 변수를 먼저 설정한다. 각 Runbook의 명령은 이 변수들을 참조한다.

# -- 공통 환경 변수 --
export PATRONI_CONF="/etc/patroni/patroni.yml"
export DC1_NODES="10.1.0.10 10.1.0.11 10.1.0.12"   # 서울 PG 노드
export DC2_NODES="10.2.0.10 10.2.0.11 10.2.0.12"   # 부산 PG 노드
export DC1_CLUSTER="pg-seoul-cluster"
export DC2_CLUSTER="pg-busan-standby"
export HAPROXY_HOST="10.1.0.20"
export ETCD_CERTS="--cacert=/etc/etcd/ssl/ca.pem \
  --cert=/etc/etcd/ssl/etcd-seoul.pem \
  --key=/etc/etcd/ssl/etcd-seoul-key.pem"

# -- 공통 헬스 체크 함수 --
check_cluster() {
  local CLUSTER=$1
  local CONF=$2
  echo "=== 클러스터 토폴로지 확인: $CLUSTER ==="
  patronictl -c $CONF topology
  echo ""
  echo "=== 복제 지연 확인 ==="
  for NODE in $DC1_NODES; do
    echo -n "  $NODE: "
    psql -h $NODE -U postgres -t -c \
      "SELECT CASE WHEN pg_is_in_recovery()
       THEN pg_last_wal_replay_lsn()::text
       ELSE pg_current_wal_lsn()::text END;" 2>/dev/null || echo "연결 실패"
  done
}

장애 대응 시작 전 공통 알림 체크리스트

장애 대응 시작 전:
□ 슬랙/PagerDuty 등 알림 채널에 장애 인지 공지
□ DBA/인프라 담당자 온콜 연락 완료
□ 현재 서비스 트래픽 수준 및 비즈니스 임팩트 파악
□ 읽기 전용 모드 전환 가능 여부 확인 (애플리케이션 레벨)
□ Runbook 최신 버전 확인 (버전 및 최종 수정일 기록 필수)

2. Runbook A — 단일 노드 장애 (자동 페일오버 확인)

트리거 조건: Primary 노드 1개가 응답 없음 / HAProxy 헬스 체크 실패 알림 수신

예상 RTO: 30초 ~ 2분 (자동 페일오버)

예상 RPO: 0 (Synchronous 모드) / 수 초 (Asynchronous 모드)

Step 1: 장애 노드 상태 파악 (목표 2분 이내)

# 클러스터 전체 상태 즉시 확인
patronictl -c $PATRONI_CONF list

# 장애 노드 직접 접근 시도
ping -c 3 10.1.0.10
ssh 10.1.0.10 "systemctl status patroni postgresql"

# Patroni REST API 응답 확인
curl -s --max-time 5 http://10.1.0.10:8008/health | python3 -m json.tool

# etcd에서 현재 Leader 키 확인
etcdctl $ETCD_CERTS \
  --endpoints=https://10.1.0.10:2379,https://10.2.0.10:2379,https://10.3.0.10:2379 \
  get /db/pg-seoul-cluster/leader

Step 2: 자동 페일오버 진행 모니터링

Patroni는 TTL 만료 후 자동으로 새 Leader를 선출한다. 개입 없이 상태를 관찰한다.

# 실시간 클러스터 상태 모니터링 (5초마다 갱신)
watch -n 5 "patronictl -c $PATRONI_CONF list"

# 페일오버 완료 확인 포인트:
# 1. 새 Leader 노드가 'running' 상태로 표시됨
# 2. 장애 노드가 목록에서 사라지거나 'stopped' 상태
# 3. 나머지 Replica가 새 Primary에 streaming 상태로 연결됨

Step 3: 페일오버 후 검증

# 새 Primary 확인
NEW_PRIMARY=$(patronictl -c $PATRONI_CONF list | grep Leader | awk '{print $4}')
echo "새 Primary: $NEW_PRIMARY"

# 새 Primary에서 쓰기 가능 여부 테스트
psql -h $NEW_PRIMARY -U postgres -c "
  CREATE TABLE IF NOT EXISTS failover_test (
    id SERIAL PRIMARY KEY,
    created_at TIMESTAMPTZ DEFAULT now(),
    note TEXT
  );
  INSERT INTO failover_test (note) VALUES ('failover_$(date +%s)');
  SELECT * FROM failover_test ORDER BY created_at DESC LIMIT 1;
"

# HAProxy가 새 Primary로 라우팅하는지 확인
psql -h $HAPROXY_HOST -p 5000 -U postgres -c \
  "SELECT inet_server_addr(), pg_is_in_recovery();"
# 결과: 새 Primary IP | f (false)

# 복제 상태 확인
psql -h $NEW_PRIMARY -U postgres -c "
  SELECT application_name, state, sync_state, write_lag
  FROM pg_stat_replication;
"

Step 4: 장애 노드 복구 및 재합류

# 장애 원인 해결 후 (서비스 재시작, HW 교체 등)
# 장애 노드에서:
systemctl start patroni

# Patroni가 자동으로 pg_basebackup 또는 pg_rewind로 동기화 시작
# 진행 상황 모니터링:
journalctl -fu patroni

# 재합류 성공 확인
patronictl -c $PATRONI_CONF list
# 장애 노드가 Replica로 streaming 상태여야 함

# 자동 재합류 실패 시 수동 reinitialize:
patronictl -c $PATRONI_CONF reinit $DC1_CLUSTER pg-seoul-1 --force

3. Runbook B — DC 전체 장애 (2DC Standby Cluster 수동 승격)

트리거 조건: DC1(서울) 전체 연결 불가 / DC1의 모든 노드 응답 없음

아키텍처: Part 3 기준 (비동기 복제 + Standby Cluster)

⚠️ 이 Runbook은 수동 페일오버이므로 절차를 절대 서두르지 않는다. 각 단계는 순서대로 완료 후 다음으로 진행한다.

예상 RTO: 5분 ~ 20분 (운영자 숙련도에 따라 다름)

예상 RPO: 마지막 복제 지연 값 (수 초 ~ 수 분)

사전 체크리스트 (승격 전 반드시 수행)

□ 1. DC1의 모든 PostgreSQL 인스턴스가 완전히 정지되었는가?
      -> DC1 노드에 SSH 접근 불가 + Patroni REST API 타임아웃 확인
□ 2. DC1 etcd 클러스터가 완전히 중단되었는가?
      -> etcdctl endpoint health 응답 없음 확인
□ 3. DC2 Standby Leader의 현재 LSN과 복제 지연을 기록했는가?
□ 4. DC1으로 향하는 모든 애플리케이션 트래픽이 차단되었는가?
□ 5. STONITH가 필요한 경우 수행했는가? (Part 4 참조)
□ 6. 승격 결정권자(DBA 리더 / 인프라 책임자)의 승인을 받았는가?

Step 1: DC1 완전 중단 확인

승격 전 DC1이 살아있을 가능성을 배제해야 한다. DC1이 부분적으로 살아있는 상태에서 승격하면 Split-Brain이 발생한다.

# DC1 모든 노드 연결 불가 확인
for NODE in $DC1_NODES; do
  echo -n "DC1 $NODE: "
  timeout 5 bash -c "echo >/dev/tcp/$NODE/5432" 2>/dev/null \
    && echo "연결됨 - 아직 살아있음!" \
    || echo "연결 불가 OK"
done

# DC2에서 바라보는 DC1 Primary 연결 상태
psql "host=10.1.0.10,10.1.0.11,10.1.0.12 port=5432 \
      user=replicator password=SecureRepPass123! \
      connect_timeout=5 \
      target_session_attrs=read-write sslmode=require" \
  -c "SELECT 1;" 2>&1 | grep -E "error|FATAL|could not"
# "could not connect to the server" -> DC1 완전 중단 확인 OK

Step 2: DC2 현재 상태 및 데이터 지연 확인

# DC2 Standby Cluster 상태
patronictl -c /etc/patroni/patroni.yml topology  # DC2 노드에서 실행

# DC2 Standby Leader의 수신된 마지막 WAL 위치 확인
psql -h 10.2.0.10 -U postgres -c "
  SELECT
    pg_last_wal_receive_lsn()                    AS receive_lsn,
    pg_last_wal_replay_lsn()                     AS replay_lsn,
    now() - pg_last_xact_replay_timestamp()      AS replay_delay,
    pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() AS fully_replayed;
"

# 복제 지연이 있다면 반드시 기록 (Post-Mortem 용)
# fully_replayed = t  -> 데이터 손실 없음
# fully_replayed = f  -> replay_delay 만큼의 데이터 손실 가능

Step 3: STONITH 수행 (DC1이 부분적으로 살아있을 가능성이 있는 경우)

# AWS 환경 예시: DC1 인스턴스 강제 중지
python3 /usr/local/bin/stonith-aws.py i-0123456789abcdef0 stop  # pg-seoul-1
python3 /usr/local/bin/stonith-aws.py i-0123456789abcdef1 stop  # pg-seoul-2
python3 /usr/local/bin/stonith-aws.py i-0123456789abcdef2 stop  # pg-seoul-3

# STONITH 완료 후 재확인
for INSTANCE in i-0123456789abcdef0 i-0123456789abcdef1 i-0123456789abcdef2; do
  aws ec2 describe-instances --instance-ids $INSTANCE \
    --query 'Reservations[].Instances[].State.Name' --output text
  # 모두 "stopped" 또는 "terminated" 이어야 함
done

Step 4: DC2 Standby Cluster 승격

# Patroni 4.1+ 권장 방법
patronictl -c /etc/patroni/patroni.yml promote-cluster $DC2_CLUSTER

# 구버전 또는 수동 방법
# patronictl -c /etc/patroni/patroni.yml edit-config \
#   --set standby_cluster=null --force

# 승격 후 상태 확인
patronictl -c /etc/patroni/patroni.yml topology

# 예상 결과:
# pg-busan-1  Leader  running  TL+1
# pg-busan-2  Replica running  TL+1
# pg-busan-3  Replica running  TL+1

# PostgreSQL에서 직접 확인
psql -h 10.2.0.10 -U postgres -c "SELECT pg_is_in_recovery();"
# f (false) -> Primary로 승격 완료 OK

Step 5: 애플리케이션 엔드포인트 전환

# HAProxy를 DC2 노드들로 업데이트 (또는 DNS 전환)
# 방법 1: HAProxy 설정 교체 및 reload
cp /etc/haproxy/haproxy-dc2.cfg /etc/haproxy/haproxy.cfg
haproxy -c -f /etc/haproxy/haproxy.cfg  # 설정 검증
systemctl reload haproxy

# 방법 2: DNS TTL을 미리 낮춰뒀다면 DNS 레코드 업데이트
# (AWS Route53 예시)
aws route53 change-resource-record-sets \
  --hosted-zone-id Z1234567890ABC \
  --change-batch '{
    "Changes": [{
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "db.example.com",
        "Type": "A",
        "TTL": 60,
        "ResourceRecords": [{"Value": "10.2.0.10"}]
      }
    }]
  }'

# 전환 후 실제 트래픽 라우팅 확인
psql -h db.example.com -p 5000 -U appuser -c \
  "SELECT inet_server_addr(), pg_is_in_recovery();"

Step 6: 승격 후 즉시 체크리스트

□ DC2 Primary에서 쓰기 테스트 성공
□ 애플리케이션 연결 정상 (에러 로그 없음)
□ DC2 내부 Replica 복제 재개 확인
□ 알림 채널에 "DC2 승격 완료" 공지
□ DC1 복구 계획 수립 시작 (Part 3의 demote-cluster 절차 참조)

4. Runbook C — 네트워크 파티션 (부분 연결 장애)

트리거 조건: 일부 노드만 통신 불가 / 클러스터 일부가 고립됨

핵심 판단 기준: etcd Quorum이 유지되는가?

# etcd 클러스터 상태 즉시 확인
etcdctl $ETCD_CERTS \
  --endpoints=https://10.1.0.10:2379,https://10.2.0.10:2379,https://10.3.0.10:2379 \
  endpoint health --write-out=table

# 출력 예시:
# +------------------------+--------+---------------------------+
# |       ENDPOINT         | HEALTH |    ERROR                  |
# +------------------------+--------+---------------------------+
# | https://10.1.0.10:2379 |  true  |                           |
# | https://10.2.0.10:2379 |  true  |                           |
# | https://10.3.0.10:2379 | false  | context deadline exceeded |
# +------------------------+--------+---------------------------+
# -> 2/3 노드 정상 = Quorum 유지 -> 클러스터 정상 운영 가능

etcdctl $ETCD_CERTS \
  --endpoints=https://10.1.0.10:2379,https://10.2.0.10:2379,https://10.3.0.10:2379 \
  member list -w table

케이스별 대응

케이스 1: etcd Quorum 유지 (2/3 이상 정상)
  -> 자동으로 처리됨. 모니터링만 지속.
  -> 고립된 노드가 Primary였다면 자동 페일오버 진행 확인
  -> 네트워크 복구 후 고립 노드 자동 재합류 확인

케이스 2: etcd Quorum 손실 (과반 미만 정상)
  -> 클러스터 전체가 읽기 전용 모드로 전환됨
  -> 네트워크 복구 최우선
  -> 복구 불가 시 Runbook D (etcd 장애) 참조
  -> DCS Failsafe Mode가 활성화된 경우: 모든 멤버에게 직접 연결 시도
# 네트워크 파티션 중 Patroni 로그 모니터링
journalctl -fu patroni | grep -E "DCS|leader|promote|demote|failover"

# 파티션 해소 후 각 노드 상태 확인
for NODE in $DC1_NODES; do
  echo "=== $NODE ==="
  curl -s --max-time 3 http://$NODE:8008/patroni 2>/dev/null \
    | python3 -c "import sys,json; d=json.load(sys.stdin); \
                  print(f'role={d[\"role\"]}, timeline={d[\"timeline\"]}')" \
    || echo "응답 없음"
done

5. Runbook D — etcd 클러스터 장애

트리거 조건: etcd 전체 또는 과반 노드 다운 / Patroni 로그에 "DCS not accessible" 빈번 출력

즉각 결과: 모든 Patroni 클러스터 읽기 전용 전환 (DCS Failsafe Mode가 비활성화된 경우)

Step 1: etcd 상태 파악

# etcd 클러스터 상태 확인
etcdctl $ETCD_CERTS \
  --endpoints=https://10.1.0.10:2379,https://10.2.0.10:2379,https://10.3.0.10:2379 \
  endpoint status --write-out=table

# 정상 etcd 노드 수 확인
HEALTHY=$(etcdctl $ETCD_CERTS \
  --endpoints=https://10.1.0.10:2379,https://10.2.0.10:2379,https://10.3.0.10:2379 \
  endpoint health 2>/dev/null | grep -c "is healthy")
echo "정상 etcd 노드: $HEALTHY / 3"

if [ "$HEALTHY" -ge 2 ]; then
  echo "Quorum 유지 중 -> Patroni 자동 복구 대기"
else
  echo "Quorum 손실! 즉각 복구 필요"
fi

Step 2: etcd 노드 재시작 시도

# 장애 etcd 노드에서 (예: 싱가포르 노드)
ssh pg-singapore-1

# 디스크 공간 및 메모리 확인 (etcd 다운의 흔한 원인)
df -h /var/lib/etcd
free -h
journalctl -u etcd --since "10 minutes ago" | tail -50

# etcd 재시작
systemctl restart etcd

# 재시작 후 클러스터 합류 확인
etcdctl $ETCD_CERTS \
  --endpoints=https://10.1.0.10:2379 \
  member list

Step 3: etcd 완전 재구성 (최후 수단)

etcd 데이터가 손상되어 재시작으로 복구 불가능한 경우에만 사용한다. Patroni는 patroni.dynamic.json에서 DCS 설정을 복원하므로, 데이터 디렉토리만 초기화해도 클러스터 설정은 자동 재등록된다.

# 이 절차는 etcd 데이터를 완전히 초기화한다.

# 1. Patroni 일시 정지 (자동 페일오버 방지)
patronictl -c $PATRONI_CONF pause $DC1_CLUSTER --wait

# 2. 모든 etcd 노드에서 서비스 중지 및 데이터 삭제
for NODE in $DC1_NODES; do
  ssh $NODE "systemctl stop etcd && rm -rf /var/lib/etcd/data"
done

# 3. 서울 노드(첫 번째)에서 새 클러스터 초기화
# etcd.conf.yml: initial-cluster-state: new 확인 후
ssh 10.1.0.10 "systemctl start etcd"

# 4. 도쿄, 싱가포르 노드 추가
# etcd.conf.yml에서 initial-cluster-state를 "existing"으로 변경 후 시작
ssh 10.2.0.10 "systemctl start etcd"
ssh 10.3.0.10 "systemctl start etcd"

# 5. Patroni 재개
patronictl -c $PATRONI_CONF resume $DC1_CLUSTER --wait

# 6. Patroni가 patroni.dynamic.json에서 설정 복원 확인
patronictl -c $PATRONI_CONF show-config
patronictl -c $PATRONI_CONF list

6. Runbook E — Split-Brain 감지 및 긴급 복구

트리거 조건:

  • PMM/모니터링 시스템에서 Primary 노드 2개 이상 감지
  • HAProxy stats에서 두 노드 모두 /primary 200 응답
  • 애플리케이션 레벨에서 데이터 불일치 감지

⚠️ Split-Brain은 최고 등급의 긴급 상황이다. 모든 쓰기를 즉시 차단하고 복구를 시작한다. 낮은 LSN 노드를 살리면 더 많은 데이터가 손실된다.

Step 1: Split-Brain 확정 확인

# 모든 노드의 역할과 LSN 즉시 확인
echo "=== Split-Brain 진단 ==="
for NODE in $DC1_NODES $DC2_NODES; do
  echo -n "$NODE: "
  psql -h $NODE -U postgres -t --no-align -c \
    "SELECT inet_server_addr()::text || ' | is_recovery=' || pg_is_in_recovery()::text
          || ' | lsn=' || CASE WHEN pg_is_in_recovery()
                         THEN pg_last_wal_replay_lsn()::text
                         ELSE pg_current_wal_lsn()::text END;" 2>/dev/null \
    || echo "연결 실패"
done

# 두 노드에서 is_recovery=false가 나오면 Split-Brain 확정

Step 2: 즉각 쓰기 차단

판단하는 동안 추가 쓰기를 방지하기 위해 두 Primary 모두 차단한다.

# iptables로 두 Primary 모두의 5432 포트 즉시 차단
for NODE in $DC1_NODES $DC2_NODES; do
  ssh $NODE "iptables -I INPUT -p tcp --dport 5432 -j DROP" &
done
wait
echo "모든 노드 쓰기 차단 완료"

Step 3: 신규 Primary 결정 (LSN 기준)

LSN이 높은 노드가 더 많은 데이터를 가진 노드다. 반드시 이 노드를 살린다.

# LSN 비교
echo "=== LSN 비교 ==="
for NODE in $DC1_NODES $DC2_NODES; do
  LSN=$(psql -h $NODE -U postgres -t --no-align -c \
    "SELECT pg_current_wal_lsn();" 2>/dev/null)
  echo "$NODE: LSN = $LSN"
done

# LSN이 낮은 노드(데이터가 적은 쪽)를 중지
# 예시: pg-seoul-1이 구 Primary로 판명된 경우:
ssh 10.1.0.10 "systemctl stop patroni; systemctl stop postgresql"

Step 4: 데이터 손실 범위 파악

# 분기 발생 시점 확인 (pg_waldump 활용)
# 구 Primary에서 (중지된 노드):
pg_waldump -n 100 --path=/var/lib/postgresql/17/main/pg_wal \
  --start=<분기_LSN> 2>/dev/null | head -50

# 손실된 트랜잭션 목록 작성 (비즈니스 복구용)
# PostgreSQL 로그에서 분기 이후 커밋된 트랜잭션 확인
grep "COMMIT" /var/log/postgresql/postgresql-*.log \
  | tail -100 > /tmp/lost_transactions.txt

Step 5: 살아남은 Primary로 클러스터 정상화

# 살아남은 Primary (LSN이 높은 쪽)에서 iptables 차단 해제
ssh $SURVIVING_PRIMARY "iptables -D INPUT -p tcp --dport 5432 -j DROP"

# 구 Primary를 Replica로 재초기화
# (data 디렉토리 삭제 후 basebackup으로 재동기화)
ssh $OLD_PRIMARY "rm -rf /var/lib/postgresql/17/main/*"
ssh $OLD_PRIMARY "systemctl start patroni"

# 재합류 확인
patronictl -c $PATRONI_CONF list

# iptables 규칙 전체 정리
for NODE in $DC1_NODES $DC2_NODES; do
  ssh $NODE "iptables -D INPUT -p tcp --dport 5432 -j DROP" 2>/dev/null
done

7. DR 훈련 시나리오 (Chaos Engineering)

DR 훈련은 최소 분기별 1회 수행하는 것이 권장된다. 훈련을 하지 않은 Runbook은 실제 장애 때 작동하지 않는다는 것이 현업에서 반복적으로 증명되어 왔다. 훈련에는 반드시 정해진 역할과 시간 제한이 있어야 하며, 각 훈련은 측정 가능한 결과를 남겨야 한다.

훈련 준비

# 훈련 전 현재 데이터 상태 스냅샷
psql -U postgres -h $CURRENT_PRIMARY -c "
  CREATE TABLE IF NOT EXISTS dr_drill_log (
    drill_id TEXT,
    event TEXT,
    ts TIMESTAMPTZ DEFAULT now(),
    lsn PG_LSN DEFAULT pg_current_wal_lsn()
  );
  INSERT INTO dr_drill_log (drill_id, event)
  VALUES ('drill_$(date +%Y%m%d_%H%M)', 'drill_start');
"

# 훈련 중 지속 쓰기 부하 생성 (별도 터미널, 선택 사항)
# pgbench -h $CURRENT_PRIMARY -U postgres -d postgres \
#         -c 5 -j 2 -T 300 --no-vacuum &

시나리오 1: Primary 노드 프로세스 강제 종료

목표: 자동 페일오버 완료 시간 측정 / Watchdog 동작 확인

echo "=== 훈련 시나리오 1 시작: $(date) ==="

# Primary 노드의 Patroni 프로세스 강제 종료 (kill -9)
PRIMARY_NODE=$(patronictl -c $PATRONI_CONF list \
  | grep Leader | awk '{print $2}' | cut -d: -f1)
echo "현재 Primary: $PRIMARY_NODE"

ssh $PRIMARY_NODE "kill -9 \$(pgrep -f 'patroni')"
FAILOVER_START=$(date +%s)

# 페일오버 완료까지 대기 및 시간 측정
while true; do
  NEW_LEADER=$(patronictl -c $PATRONI_CONF list 2>/dev/null \
    | grep Leader | awk '{print $2}')
  if [ -n "$NEW_LEADER" ] && [ "$NEW_LEADER" != "$PRIMARY_NODE:5432" ]; then
    FAILOVER_END=$(date +%s)
    echo "페일오버 완료: $NEW_LEADER"
    echo "소요 시간: $((FAILOVER_END - FAILOVER_START))초"
    break
  fi
  sleep 1
done

# 검증
patronictl -c $PATRONI_CONF topology
psql -h $HAPROXY_HOST -p 5000 -U postgres -c \
  "SELECT inet_server_addr(), pg_is_in_recovery();"

시나리오 2: 네트워크 파티션 시뮬레이션 (iptables)

목표: 리전 간 네트워크 절단 시 etcd Quorum 동작 확인

# 이 시나리오는 Primary 노드에서 실행하면 실제 페일오버가 발생한다.
# 반드시 Replica 노드에서 실행하거나, 사전에 팀에 공지한다.

echo "=== 훈련 시나리오 2: 리전 간 파티션 시뮬레이션 ==="

REPLICA_NODE="10.2.0.10"

ssh $REPLICA_NODE "iptables -I INPUT -s 10.1.0.0/24 -j DROP
                   iptables -I OUTPUT -d 10.1.0.0/24 -j DROP"
PARTITION_START=$(date +%s)

echo "파티션 시작. 30초 후 복구..."
sleep 30

# 파티션 해소
ssh $REPLICA_NODE "iptables -D INPUT -s 10.1.0.0/24 -j DROP
                   iptables -D OUTPUT -d 10.1.0.0/24 -j DROP"
PARTITION_END=$(date +%s)

echo "파티션 지속 시간: $((PARTITION_END - PARTITION_START))초"

# 복구 후 클러스터 상태 확인
sleep 15  # 재연결 안정화 대기
patronictl -c $PATRONI_CONF topology

시나리오 3: etcd 노드 1개 장애

목표: etcd Quorum 유지 하에 클러스터 안정성 확인

echo "=== 훈련 시나리오 3: etcd 노드 1개 장애 ==="

# 싱가포르 etcd 중단
ssh 10.3.0.10 "systemctl stop etcd"
ETCD_STOP=$(date)
echo "etcd 중단 시각: $ETCD_STOP"

# 클러스터 정상 운영 확인 (Quorum 2/3 유지)
etcdctl $ETCD_CERTS \
  --endpoints=https://10.1.0.10:2379,https://10.2.0.10:2379 \
  endpoint health

# Patroni 클러스터 정상 여부
patronictl -c $PATRONI_CONF list

# 쓰기 테스트 (정상이어야 함)
psql -h $HAPROXY_HOST -p 5000 -U postgres -c \
  "INSERT INTO dr_drill_log (drill_id, event)
   VALUES ('drill_$(date +%Y%m%d)', 'etcd_node_down_write_test');"

# 10분 후 etcd 복구
sleep 600
ssh 10.3.0.10 "systemctl start etcd"

# etcd 재합류 확인
etcdctl $ETCD_CERTS \
  --endpoints=https://10.1.0.10:2379,https://10.2.0.10:2379,https://10.3.0.10:2379 \
  member list

시나리오 4: Standby Cluster 승격 훈련 (계획된 전환)

목표: 수동 승격 절차 숙달 / RTO 측정

echo "=== 훈련 시나리오 4: Standby Cluster 계획된 승격 훈련 ==="
DRILL_START=$(date +%s)

# DC1을 계획적으로 중지 (훈련이므로 정상 절차)
patronictl -c $PATRONI_CONF pause $DC1_CLUSTER --wait
for NODE in $DC1_NODES; do
  ssh $NODE "systemctl stop patroni" &
done
wait

# DC2 Standby 승격 (Runbook B Step 4 참조)
patronictl -c /etc/patroni/patroni.yml promote-cluster $DC2_CLUSTER

PROMOTE_TIME=$(date +%s)
echo "승격 소요 시간: $((PROMOTE_TIME - DRILL_START))초"

# 쓰기 테스트
psql -h 10.2.0.10 -U postgres -c \
  "INSERT INTO dr_drill_log (drill_id, event)
   VALUES ('drill_$(date +%Y%m%d)', 'standby_promoted');"

# -- 훈련 완료 후 원복 --
# DC1 재기동 (Standby 모드로 재전환)
# DC2에 Permanent Slot 등록 후 DC1 Patroni 재시작
# (Part 3 Step 6 참조)

DR 훈련 결과 기록 양식

매 훈련마다 아래 양식을 작성해 팀 위키에 보관한다.

## DR 훈련 기록

- 훈련 일시: YYYY-MM-DD HH:MM ~ HH:MM
- 훈련 참여자: DBA, 인프라 담당자 이름
- 시나리오: [ ] 1 (Primary kill) [ ] 2 (파티션) [ ] 3 (etcd) [ ] 4 (Standby 승격)

### 측정 결과
| 항목                      | 목표값           | 실측값 | 달성 여부 |
|---------------------------|------------------|--------|-----------|
| RTO (서비스 복구 시간)    | 5분 이내         | __ 분  | OK/NG     |
| RPO (데이터 손실)         | 0 (sync)/30초    | __ 초  | OK/NG     |
| 페일오버 감지 시간        | 30초 이내        | __ 초  | OK/NG     |
| 애플리케이션 재연결 시간  | 60초 이내        | __ 초  | OK/NG     |

### 발견된 문제점 및 개선 사항
- [ ] Runbook 단계 X의 명령어 오류 발견 -> 수정 필요
- [ ] 특정 노드 SSH 키 만료 발견
- [ ] HAProxy DNS 전환 시간이 목표보다 긺

### 다음 훈련 일정
- 예정일: YYYY-MM-DD
- 시나리오: (이번 훈련에서 발견된 취약점 위주로 선정)

8. 장애 대응 의사결정 트리

실제 장애 상황에서 어떤 Runbook을 실행할지 빠르게 판단하기 위한 플로우차트다. 첫 번째 질문은 항상 patronictl list를 실행할 수 있는가이다.


9. 복구 후 Post-Mortem 체크리스트

장애 복구 완료 후 24시간 이내에 Post-Mortem을 작성한다. 단순한 사후 보고가 아니라, 재발 방지 액션 아이템을 만드는 것이 목적이다. Post-Mortem은 복구가 완전히 끝났을 때 비로소 닫힌다.

즉각 확인 (복구 직후 30분 이내)

# 클러스터 최종 상태 스냅샷
patronictl -c $PATRONI_CONF topology > /tmp/post_recovery_topology.txt
patronictl -c $PATRONI_CONF show-config > /tmp/post_recovery_config.txt

# 복제 지연이 0으로 수렴했는지 확인
psql -h $NEW_PRIMARY -U postgres -c "
  SELECT application_name, state, sync_state,
         write_lag, flush_lag, replay_lag
  FROM pg_stat_replication;
"

# 데이터 일관성 기본 확인 (주요 테이블 행 수 비교)
for NODE in $DC1_NODES; do
  echo -n "$NODE: "
  psql -h $NODE -U postgres -d mydb -t --no-align -c \
    "SELECT COUNT(*) FROM orders;" 2>/dev/null || echo "접근 불가"
done

# etcd 상태 최종 확인
etcdctl $ETCD_CERTS \
  --endpoints=https://10.1.0.10:2379,https://10.2.0.10:2379,https://10.3.0.10:2379 \
  endpoint health --write-out=table

# Watchdog 상태 확인
curl -s http://localhost:8008/patroni | python3 -m json.tool \
  | grep -E "role|watchdog|timeline"

Post-Mortem 문서 작성 양식

## Post-Mortem 보고서

- 장애 발생 시각: YYYY-MM-DD HH:MM:SS (KST)
- 장애 감지 시각: (모니터링 알림 시각)
- 복구 완료 시각: YYYY-MM-DD HH:MM:SS (KST)
- 총 다운타임: __ 분 __ 초
- 영향 범위: 쓰기 불가 / 읽기 불가 / 완전 중단
- 담당자: DBA 이름

### 타임라인
| 시각    | 이벤트                    |
|---------|---------------------------|
| T+00:00 | 장애 발생 (추정)          |
| T+00:XX | 모니터링 알림 수신        |
| T+00:XX | 담당자 온콜 응답          |
| T+00:XX | 장애 원인 파악 완료       |
| T+00:XX | 복구 작업 시작            |
| T+00:XX | 서비스 복구 완료          |

### 근본 원인 (Root Cause)
(5-Why 분석 결과)

### 즉각 조치 사항
- (완료) 기존 조치 내용
- (진행 중) 조치 중인 내용
- (예정) 향후 조치 예정 내용

### 재발 방지 액션 아이템
| 항목                            | 담당자   | 기한       | 상태 |
|---------------------------------|----------|------------|------|
| Watchdog mode를 required로 변경 | DBA      | YYYY-MM-DD | []   |
| etcd 디스크 I/O 알림 추가       | 인프라   | YYYY-MM-DD | []   |
| DR 훈련 월 1회로 증가           | DBA 리더 | YYYY-MM-DD | []   |

### Runbook 개선 사항
(이번 장애에서 Runbook의 어떤 부분이 부정확했는가?)

이 시리즈의 다음 글인 Part 6에서는 멀티 리전 Patroni 클러스터의 일상 운영을 지탱하는 모니터링·자동화·Best Practices를 다룬다. Prometheus + Grafana 기반 Patroni 대시보드 구성, patroni_exporter 핵심 알림 규칙, pgBackRest 멀티 리전 백업 전략, 자주 쓰는 patronictl 명령어 치트 시트가 포함된다.


참고 자료

이 글 공유하기

시리즈 내비게이션

멀티 리전에서 Patroni H/A 운용하기

현재 글 5 · 6 편 공개

같은 주제 더 보기·대표 시리즈로 시작

English

최신 글을 RSS로 받아보세요

RSS로 새 글과 시리즈 업데이트를 바로 받아볼 수 있습니다.

RSS 구독 안내 보기