TT Lab
블로그

한국어 English 日本語 中文

시작하기
블로그

블로그

서버 2대로는 왜 HA 가 안 되나 1: PostgreSQL·Redis·OpenSearch·ClickHouse·SeaweedFS 실측

상태: 실측 (2026-10-31). TT Lab 실습 VM 세 대(KubeVirt, Ubuntu 24.04) 위의 Docker 에서 잰 값입니다. 각 시험은 한 번씩 했고, VM 전원 정지는 하지 않았습니다(미실측). 숫자는 모두 원자료(시도마다 한 줄씩 남긴 쓰기 기록과 복구 뒤 대조 결과)에서 뽑았습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 원인을 확인하지 못한 해석은 「추정」 으로 표시합니다.

목차

1. 질문: 2대면 한 대가 죽어도 되지 않나

온프레미스에서 데이터베이스를 이중화하라는 요구를 받으면 가장 먼저 떠오르는 그림은 서버 2대입니다. 한 대가 주인(primary)으로 쓰기를 받고, 다른 한 대가 사본을 들고 있다가 주인이 죽으면 이어받습니다. 서버값도 랙 자리도 2대가 가장 쌉니다.

이 시리즈는 그 그림이 자동으로는 동작하지 않는다는 것을 다섯 가지 서비스로 직접 확인한 기록입니다. 결론부터 적습니다.

2. 과반이 왜 3을 요구하나

2.1 죽은 것과 끊긴 것을 구별할 수 없다

db1 과 db2 가 서로를 감시한다고 해 봅시다. db1 에서 보면 db2 의 응답이 끊겼습니다. 가능성은 둘입니다.

  1. db2 가 정말 죽었다.
  2. db2 는 멀쩡한데 둘 사이의 네트워크만 끊겼다(분할, network partition).

db1 혼자서는 이 둘을 구별할 방법이 없습니다. 둘 다 「응답 없음」 으로만 보이기 때문입니다. 1번이라고 믿고 db1 이 주인이 되었는데 사실은 2번이었다면, db2 도 여전히 자기가 주인이라 생각하고 쓰기를 받습니다. 주인이 둘(split brain) 이 되고, 네트워크가 돌아왔을 때 두 쪽의 데이터는 이미 갈라져 있습니다. 어느 한쪽의 쓰기는 버려야 합니다.

2.2 그래서 「과반을 가진 쪽만 주인이 된다」

이 문제를 피하는 표준 규칙이 과반(정족수, quorum) 입니다. 투표 멤버 N 개 중 절반을 넘는 수(⌊N/2⌋ + 1)의 동의를 얻은 쪽만 주인이 될 수 있습니다. 네트워크가 어떻게 갈라지든 과반을 가진 쪽은 최대 하나뿐이므로 주인이 둘이 될 수 없습니다.

과반의 산수: 2대와 2대 + 증인 둘 사이의 선이 끊겼을 때(분할), 각 쪽이 셀 수 있는 표 데이터 노드 2대 db1 표 1 db2 표 1 멤버 2, 과반 = 2 어느 쪽도 1표뿐 양쪽 모두 쓰기 정지 주인 쪽도 스스로 내려온다(두 주인 방지) 2대 + 증인 db1 표 1 db2 표 1 증인 표 1 db1 + 증인 = 2/3 과반. 이쪽이 계속한다 멤버 수 과반 견디는 장애 1 1 0 2 2 0 3 2 1 4 3 1 5 3 2 2대는 1대와 똑같이 장애를 하나도 견디지 못한다
그림: 과반의 산수: 2대와 2대 + 증인

문제는 2대일 때입니다. 멤버가 2면 과반은 2입니다. 선이 끊기면 양쪽 모두 1표뿐이라 어느 쪽도 주인이 될 수 없고, 원래 주인이던 쪽도 스스로 내려옵니다. 그렇지 않으면 상대 쪽에서 무슨 일이 일어나는지 모르는 채로 쓰기를 받게 되기 때문입니다. 그림 아래 표처럼 2대는 1대와 똑같이 장애를 하나도 견디지 못합니다. 장애 하나를 견디려면 투표 멤버가 3이어야 합니다.

2.3 세 번째는 데이터 없이 표만 던져도 된다

세 번째 멤버가 꼭 데이터를 가진 서버일 필요는 없습니다. 과반 계산에 필요한 것은 표뿐이기 때문입니다. 데이터는 두 대에 두고, 세 번째 자리에는 투표만 하는 작은 프로세스를 둡니다. 이것을 증인(witness) 또는 tie-breaker 라고 부릅니다. 이번 실험의 증인 VM 은 2 vCPU·2 GiB 였고, 설치 직후 실제 사용 메모리는 1.5 GiB 였습니다.

3. 다섯 서비스는 각자 무엇이 주인을 정하나

다섯 서비스 모두 과반을 쓰지만, 과반을 세는 주체는 서비스마다 다릅니다. 증인에 무엇을 두어야 하는지도 그래서 달라집니다.

서비스 HA 방식 주인을 정하는 것 증인에 둔 것
PostgreSQL 17 Patroni 4.1 + 스트리밍 복제, 동기 복제(synchronous_mode: true, strict: false), pg_rewind etcd 3멤버(상호 TLS) etcd 멤버 하나
Redis 8.8 주 1 + 복제본 1, AOF everysec Sentinel 3개, quorum 2 Sentinel 하나
OpenSearch 3.9 데이터 노드 2, 색인 복제본 1, 보안 플러그인 켬 cluster_manager 투표 노드 3 데이터 없는 cluster_manager 전용 노드
ClickHouse 26.8 샤드 1 × 복제본 2, ReplicatedMergeTree ClickHouse Keeper 3개(Raft) Keeper 하나
SeaweedFS 4.51 master 3(Raft), volume 2(복제 001), filer 2(PostgreSQL 공유), S3 게이트웨이 2 master Raft 3 master 하나
실습 구성: 데이터 노드 2대와 증인 1대 증인에는 데이터가 없다. 표를 던지는 투표 멤버만 둔다 db1 4 vCPU · 6 GiB db2 4 vCPU · 6 GiB 증인 2 vCPU · 2 GiB PostgreSQL PG 주, Patroni etcd PG 복제, Patroni etcd etcd (투표만) Redis Redis 주 Sentinel Redis 복제 Sentinel Sentinel (투표만) OpenSearch 데이터 + 투표 데이터 + 투표 투표 전용 데이터 없음 ClickHouse 복제본 Keeper 복제본 Keeper Keeper (투표만) SeaweedFS master, volume filer, s3 master, volume filer, s3 master (투표만) 증인 VM 은 앱 서버 자리도 겸했다: 시험 클라이언트와 HAProxy(주 서버를 찾아 보내는 앞단)
그림: 실습 구성: 데이터 노드 2대와 증인 1대

서비스마다 고른 이유와 그 대가는 이렇습니다.

데이터 노드의 컨테이너 메모리 한도 합계는 약 4.6 GiB(OpenSearch 1.4g, ClickHouse 1.2g, PostgreSQL 768m, Redis 384m 등)였고, 설치 직후 실제 사용량은 데이터 노드 2.5 GiB, 증인 1.5 GiB 였습니다.

4. 설정 발췌

비밀번호와 키는 빠져 있습니다(실제로는 노드의 .env 파일, 권한 600 에만 두었습니다). 주소는 노드 이름이나 변수로 바꿔 적었습니다. 모든 서비스는 세션망 주소와 127.0.0.1 에만 묶었습니다.

etcd(Patroni 의 합의 저장소). 세 노드에 같은 compose 를 두고 노드 이름만 바꿉니다. 마지막 줄은 2대뿐일 때의 수동 복구 자리입니다.

services:
  etcd:
    image: gcr.io/etcd-development/etcd:v3.6.16
    network_mode: host
    command:
      - etcd
      - --name=${NODE_NAME}
      - --initial-cluster=${ETCD_INITIAL_CLUSTER}   # db1, db2, witness 세 멤버
      - --client-cert-auth                          # 클라이언트·피어 모두 상호 TLS
      - --peer-client-cert-auth
      - --heartbeat-interval=100
      - --election-timeout=1000
      - --force-new-cluster=${ETCD_FORCE_NEW:-false} # 살아남은 한 대를 단일 멤버로 다시 세울 때만 true
    mem_limit: 256m

Patroni. 리더 키 수명 ttl 이 「주 서버가 사라진 뒤 승격까지」 의 상한을 정합니다.

bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    synchronous_mode: true          # 커밋이 복제본에 닿은 뒤 확인
    synchronous_mode_strict: false  # 복제본이 죽으면 동기 요구를 내리고 계속
    failsafe_mode: false
    postgresql:
      use_pg_rewind: true
      use_slots: true
watchdog:
  mode: off                         # 컨테이너에 /dev/watchdog 이 없다

Redis 와 Sentinel. min-replicas-* 두 줄이 이 시리즈에서 가장 큰 값을 치르게 한 설정입니다(2편).

# redis.conf
appendonly yes
appendfsync everysec
min-replicas-to-write 1     # 10초 안에 응답한 복제본이 1개 이상일 때만 쓰기를 받는다
min-replicas-max-lag 10

# sentinel.conf
sentinel monitor mymaster <db1> 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000

OpenSearch. 처음 한 번 셋이 모여 투표 구성을 만듭니다. 역할은 환경 변수로 줍니다(2편 함정 참고).

# opensearch.yml
cluster.initial_cluster_manager_nodes: ["db1", "db2", "witness"]
# docker-compose.yml 의 environment
node.roles: cluster_manager,data,ingest   # 증인은 cluster_manager 만

ClickHouse Keeper. Raft 멤버 셋 중 하나가 증인입니다. 세션 시간 초과 30초는 2편의 함정과 이어집니다.

<coordination_settings>
    <operation_timeout_ms>10000</operation_timeout_ms>
    <session_timeout_ms>30000</session_timeout_ms>
</coordination_settings>
<raft_configuration>
    <server><id>1</id><hostname>db1</hostname><port>9234</port></server>
    <server><id>2</id><hostname>db2</hostname><port>9234</port></server>
    <server><id>3</id><hostname>witness</hostname><port>9234</port></server>
</raft_configuration>

SeaweedFS master. 증인에는 master 만 띄웁니다.

  master:
    profiles: ["data", "witness"]
    command:
      - master
      - -peers=${DB1_IP}:9333,${DB2_IP}:9333,${WITNESS_IP}:9333
      - -defaultReplication=001
    mem_limit: 192m            # 128m 에서 89MiB 까지 오르는 것을 보고 늘렸다(실측)

HAProxy(앱 쪽). 앱은 127.0.0.1:5000 만 압니다. 주 서버가 바뀌면 HAProxy 가 1초 간격 검사 두 번으로 따라갑니다.

listen pg_primary
    bind 127.0.0.1:5000
    option httpchk GET /primary
    http-check expect status 200
    default-server inter 1s fall 2 rise 2 on-marked-down shutdown-sessions
    server db1 <db1>:5432 check port 8008
    server db2 <db2>:5432 check port 8008

5. 시험 방법

6. 정말 2대뿐일 때

6.1 어떻게 2대로 만들었나

증인이 있는 구성에서 증인 쪽을 걷어 냈습니다.

6.2 한 대를 60초 끊으면

그때 PostgreSQL 과 Redis 의 주인은 db2 였습니다. 그래서 db1 고립은 「복제본 쪽을 잃는 것」, db2 고립은 「주인 쪽을 잃는 것」 입니다.

2대뿐일 때 한 대를 잃으면: 최장 쓰기 중단 증인을 뺀 2대 구성, 한 대의 세션망을 60초 끊었다(한 번씩) 복제본 쪽(db1)을 60초 고립 주인 쪽(db2)을 60초 고립 0 30 60 90 120 초 PostgreSQL 108.4 107.8 Redis 50.3 61.1 OpenSearch 34.2 62.3 ClickHouse 60.5 60.9 SeaweedFS S3 108.9 107.9 점선 = 고립 60초. 막대가 점선보다 길면 복구 뒤에도 더 멈춰 있었다는 뜻
그림: 2대뿐일 때 한 대를 잃으면: 최장 쓰기 중단
서비스 복제본 쪽(db1) 60초 고립 주인 쪽(db2) 60초 고립 왜
PostgreSQL 108.4초 중단. 살아 있던 주 서버가 +23초에 스스로 강등 107.8초 중단. 승격 없음 etcd 2멤버의 과반은 2. 상대를 잃으면 리더 키를 갱신할 수 없어 주인 쪽도 내려온다. 복구 뒤에도 키 만료를 기다려 47초가 더 걸렸다
Redis 50.3초. 주인은 남았지만 +11초부터 NOREPLICAS 61.1초. MasterNotFound, 전환 없음 Sentinel 2개 중 1개로는 장애 조치 권한(과반)을 얻지 못한다
OpenSearch 34.2초 뒤 계속(db2 혼자 투표 구성) 62.3초, 복구까지 멈춤 투표 구성이 db2 한 대뿐. db2 를 잃으면 클러스터가 없다
ClickHouse 60.5초(삽입이 매달림, 읽기 실패 0) 60.9초 Keeper 3자리 중 1개만 닿는다
SeaweedFS S3 108.9초 107.9초 +18초부터 master 리더 없음, 그리고 복제 001

읽을 거리는 셋입니다.

7. 수동 승격: 사람이 「상대는 죽었다」 고 선언하기

2대뿐인 구성에서 멈춘 서비스를 다시 돌리려면 사람이 개입해야 합니다. 「상대는 죽었으니 남은 한 대가 과반이다」 라고 선언하는 것입니다. db2 를 계속 고립시킨 채, db1 에서 서비스별로 차례대로 했습니다. 절차는 스크립트로 미리 적어 두었습니다.

서비스 절차 그 절차 시작부터 쓰기 재개까지 고립부터 쓰기 재개까지
PostgreSQL ① etcd 를 --force-new-cluster=true 로 단일 멤버로 다시 세움(8.7초에 건강) ② Patroni 가 옛 리더 키 만료(ttl 30)를 기다린 뒤 db1(동기 복제본)을 승격 44.2초 60.2초
Redis REPLICAOF NO ONE → CONFIG SET min-replicas-to-write 0 → CONFIG REWRITE. Sentinel 은 SENTINEL REMOVE 뒤 db1 을 quorum 1 로 다시 SENTINEL MONITOR 0.9초 59.9초
OpenSearch 컨테이너 정지 → bin/opensearch-node unsafe-bootstrap(같은 데이터 볼륨) → 시작. 클러스터는 red(db2 에 primary 가 있던 색인) 27.0초 87.3초
ClickHouse Keeper 를 단일 서버 구성 + --force-recovery 로 시작 → 2.5초에 standalone → 9초에 테이블 is_readonly=0 11.1초 98.1초
SeaweedFS master 를 -peers=none 단일로 다시 띄움(1.4초에 리더) → fs.configure -locationPrefix=/buckets/ -replication=000 -apply 로 복제 요구를 낮춤 24.2초 121.2초

절차는 고립 15.9초 뒤에 PostgreSQL 부터 시작해 한 서비스씩 차례로 했습니다. 그래서 뒤 순서일수록 「고립부터」 의 값이 큽니다. 실제 장애에서 걸리는 시간은 「알아차리고 결정하는 시간 + 오른쪽에서 두 번째 열」 에 가깝습니다. 확인받은 쓰기의 손실은 다섯 서비스 모두 0건이었습니다.

PostgreSQL 이 가장 오래 걸린 까닭은 etcd 를 다시 세운 뒤에도 옛 리더 키가 만료되기를 기다려야 하기 때문입니다. 이 30초가 곧 ttl 입니다.

7.1 수동 승격에서 조심할 것

8. 정리

질문 답(실측)
2대로 자동 HA 가 되나 안 된다. 한 대를 잃으면 다섯 서비스 모두 쓰기가 멈췄다
복제본만 잃어도 멈추나 PostgreSQL(etcd 2멤버)은 주 서버가 스스로 내려와 108.4초 멈췄다
데이터를 잃었나 확인받은 쓰기의 손실은 0건. 멈출 뿐 잃지 않는다
2대로 버티려면 수동 승격. 서비스별 0.9초에서 44.2초, 펜싱이 먼저
자동으로 넘어가게 하려면 투표 멤버를 3으로. 세 번째는 데이터 없는 작은 증인이면 된다

증인을 더한 구성이 장애 종류마다 어떻게 동작했는지, 그리고 「데이터 노드는 여전히 2대」 라는 조건이 남기는 함정은 2편에서 봅니다.

티티 한마디. 둘이서 다수결을 하면 언제나 1 대 1 이에요. 그래서 둘 다 「내가 정해도 되나?」 하고 멈춰 버리죠. 표만 던져 줄 친구 하나만 더 있으면 돼요. 그 친구는 짐(데이터)을 들 필요도 없어요!

참고

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다