상태: 실측 (2026-10-31). TT Lab 실습 VM 세 대(KubeVirt, Ubuntu 24.04) 위의 Docker 에서 잰 값입니다. 각 시험은 한 번씩 했고, VM 전원 정지는 하지 않았습니다(미실측). 숫자는 모두 원자료(시도마다 한 줄씩 남긴 쓰기 기록과 복구 뒤 대조 결과)에서 뽑았습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 원인을 확인하지 못한 해석은 「추정」 으로 표시합니다.
목차
- 1. 질문: 2대면 한 대가 죽어도 되지 않나
- 2. 과반이 왜 3을 요구하나
- 3. 다섯 서비스는 각자 무엇이 주인을 정하나
- 4. 설정 발췌
- 5. 시험 방법
- 6. 정말 2대뿐일 때
- 7. 수동 승격: 사람이 「상대는 죽었다」 고 선언하기
- 8. 정리
- 참고
1. 질문: 2대면 한 대가 죽어도 되지 않나
온프레미스에서 데이터베이스를 이중화하라는 요구를 받으면 가장 먼저 떠오르는 그림은 서버 2대입니다. 한 대가 주인(primary)으로 쓰기를 받고, 다른 한 대가 사본을 들고 있다가 주인이 죽으면 이어받습니다. 서버값도 랙 자리도 2대가 가장 쌉니다.
이 시리즈는 그 그림이 자동으로는 동작하지 않는다는 것을 다섯 가지 서비스로 직접 확인한 기록입니다. 결론부터 적습니다.
- 2대뿐인 구성에서는 어느 쪽 한 대를 잃든 다섯 서비스의 쓰기가 전부 멈췄습니다. 60초 고립에 PostgreSQL 108초, SeaweedFS S3 108초, 나머지는 34초에서 62초였습니다. 복제본 쪽만 잃어도 멈췄습니다.
- 멈춘 상태를 푸는 길은 사람이 하는 수동 승격뿐이었습니다. 절차를 미리 적어 두어도 서비스마다 0.9초에서 44.2초가 걸렸습니다.
- 데이터를 갖지 않는 작은 증인 VM(2 vCPU·2 GiB) 하나를 더하면 자동 전환이 됩니다. 노드를 고립시킨 시험에서 PostgreSQL 은 22.6초에서 32.7초 만에 승격했고 확인받은 쓰기의 손실은 0건이었습니다. 이 결과는 2편에서 자세히 봅니다.
2. 과반이 왜 3을 요구하나
2.1 죽은 것과 끊긴 것을 구별할 수 없다
db1 과 db2 가 서로를 감시한다고 해 봅시다. db1 에서 보면 db2 의 응답이 끊겼습니다. 가능성은 둘입니다.
- db2 가 정말 죽었다.
- db2 는 멀쩡한데 둘 사이의 네트워크만 끊겼다(분할, network partition).
db1 혼자서는 이 둘을 구별할 방법이 없습니다. 둘 다 「응답 없음」 으로만 보이기 때문입니다. 1번이라고 믿고 db1 이 주인이 되었는데 사실은 2번이었다면, db2 도 여전히 자기가 주인이라 생각하고 쓰기를 받습니다. 주인이 둘(split brain) 이 되고, 네트워크가 돌아왔을 때 두 쪽의 데이터는 이미 갈라져 있습니다. 어느 한쪽의 쓰기는 버려야 합니다.
2.2 그래서 「과반을 가진 쪽만 주인이 된다」
이 문제를 피하는 표준 규칙이 과반(정족수, quorum) 입니다. 투표 멤버 N 개 중 절반을 넘는 수(⌊N/2⌋ + 1)의 동의를 얻은 쪽만 주인이 될 수 있습니다. 네트워크가 어떻게 갈라지든 과반을 가진 쪽은 최대 하나뿐이므로 주인이 둘이 될 수 없습니다.
문제는 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 하나 |
서비스마다 고른 이유와 그 대가는 이렇습니다.
- PostgreSQL 은 동기 복제를 골랐습니다. 커밋 확인이 복제본에 닿은 뒤에 오므로, 자동 승격 때 확인받은 쓰기를 잃지 않습니다.
strict: false라서 복제본이 죽으면 Patroni 가 동기 요구를 내리고 쓰기를 계속 받습니다. 그 대가는 복제본이 없는 동안의 커밋이 한 벌만 남는다는 것입니다. 가용성 쪽을 고른 선택입니다. - OpenSearch 에는 「투표만 하는」 역할이 없습니다. 3.9.0 배포물에서
voting_only를 찾았지만 나오지 않았습니다(이 역할은 Elasticsearch 쪽 기능입니다). 그래서 증인은 데이터를 갖지 않는cluster_manager전용 노드로 두었습니다. 증인도 클러스터 관리자로 뽑힐 수 있으므로 힙을 너무 작게 잡으면 안 됩니다. 힙 384m·컨테이너 한도 1100m 로 잡았고, 한도 700m 에서는 OOM 으로 죽었습니다(실측). - ClickHouse 는 ZooKeeper 대신 Keeper 를 썼습니다. 별도 JVM 이 필요 없고 서버와 같은 판으로 묶입니다. Keeper 3개는 곧바로 리더 1·팔로워 2로 섰습니다.
- SeaweedFS filer 는 메타데이터를 PostgreSQL 에 둡니다. 두 filer 가 같은 메타데이터를 보므로 한 filer 가 죽어도 다른 filer 가 그대로 이어받습니다. 대신 PostgreSQL 장애가 filer 에 번집니다.
- SeaweedFS 의 복제
001은 「같은 데이터센터·같은 랙의 다른 서버에 사본 하나 더」 라는 뜻입니다. 볼륨 서버가 둘뿐이면 모든 파일이 두 서버에 한 벌씩 놓이고, 한 서버가 빠지면 이 규칙을 만족할 자리가 없어 새 쓰기가 실패합니다. - 앞단은 HAProxy 입니다. PostgreSQL 은 Patroni REST 의
GET /primary가 200 인 노드로만 보내고, ClickHouse 와 S3 는 살아 있는 쪽으로 나눠 보냅니다. 앱 서버 자리(이번에는 증인 VM)에 하나, 그리고 filer 가 PostgreSQL 을 찾도록 데이터 노드마다 하나씩 두었습니다.
데이터 노드의 컨테이너 메모리 한도 합계는 약 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. 시험 방법
- 시험 클라이언트는 앱 서버 자리(증인 VM)에서 돌았습니다. 쓰기 간격은 PostgreSQL·Redis 0.05초, OpenSearch 0.1초, ClickHouse·S3 0.2초, 시간 제한은 2초에서 5초입니다. 시도마다 한 줄씩 기록하고, 끝난 뒤 저장소를 읽어 확인받은 쓰기(성공 응답을 받은 쓰기)가 모두 남았는지 대조했습니다. 역할 변화는 0.25초마다 감시했습니다.
- 고립은 그 노드 안에서 세션망 인터페이스의 입출력을 iptables 로 버리는 것입니다. 노드는 살아 있지만 상대와 끊긴 상태, 곧 분할입니다. 상대 쪽에서 보면 전원이 꺼진 것과 구별되지 않습니다.
- 정상 정지는 그 노드의 모든 컨테이너를
docker stop(유예 30초)으로 내리는 것입니다. - 최장 쓰기 중단은 성공한 쓰기와 다음 성공 사이의 가장 긴 틈입니다. VM 사이의 시계 차이는 ±0.3초 안이었습니다(ssh 왕복으로 잰 상한).
6. 정말 2대뿐일 때
6.1 어떻게 2대로 만들었나
증인이 있는 구성에서 증인 쪽을 걷어 냈습니다.
- etcd:
member remove로 증인을 빼서 실제 2멤버로 만들었습니다. - Sentinel: 증인의 Sentinel 을 멈추고
SENTINEL RESET을 보냈습니다. 남은 둘이 서로만 알고, quorum 은 2입니다. - OpenSearch:
voting_config_exclusions로 증인을 빼고 멈췄습니다. 그러자 투표 구성이 db2 한 대로 줄었습니다. 2는 짝수라 OpenSearch 가 홀수로 맞춘 것입니다(실측). - ClickHouse Keeper·SeaweedFS master: 증인의 것을 멈췄습니다. 세 자리 구성에서 둘만 살아 있는 상태라 「과반 2를 둘이 모두 내야 한다」 는 산수는 2멤버와 같지만, 멤버 수를 실제로 줄이는 재구성은 하지 않았습니다(2멤버 재구성 자체는 미실측).
6.2 한 대를 60초 끊으면
그때 PostgreSQL 과 Redis 의 주인은 db2 였습니다. 그래서 db1 고립은 「복제본 쪽을 잃는 것」, db2 고립은 「주인 쪽을 잃는 것」 입니다.
| 서비스 | 복제본 쪽(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 |
읽을 거리는 셋입니다.
- 「한 대가 남았으니 계속 돌겠지」 라는 기대가 PostgreSQL 에서 가장 크게 어긋났습니다. 복제본만 잃었는데도 주 서버가 스스로 내려왔습니다.
etcd 2멤버에서 과반을 잃었기 때문입니다. 이것은 결함이 아니라 두 주인을 막기 위한 정상 동작입니다. Patroni 의
failsafe_mode는 합의 저장소를 잃어도 모든 멤버와 REST 로 닿으면 주인을 유지하는 기능인데, 분할에서는 상대에 닿지 못하므로 이 경우를 구하지 못합니다 (설정만 확인, 미실측). - OpenSearch 는 「어느 쪽을 잃느냐」 에 따라 결과가 갈렸습니다. 투표 구성이 db2 한 대였으므로 db1 을 잃으면 계속 돌고, db2 를 잃으면 멈췄습니다. 고립된 db2 는 혼자 클러스터 노릇을 하며 자기 로컬 쓰기 77건을 받았고 복구 뒤 77건 모두 남았습니다. db1 쪽에는 클러스터가 없었으므로 데이터가 갈라지지는 않았습니다.
- 어느 쪽을 잃든 확인받은 쓰기의 손실은 0건이었습니다. 멈출 뿐 잃지는 않는다는 것이 과반 규칙이 주는 보장입니다.
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 수동 승격에서 조심할 것
- 「상대가 정말 죽었다」 를 사람이 보장해야 합니다. 상대가 살아서 쓰기를 받고 있었다면 이 순간 주인이 둘이 됩니다. 운영에서는 상대의 전원을 끄거나 네트워크를 끊는 펜싱(fencing) 을 먼저 하고 승격합니다.
- ClickHouse Keeper 강제 복구 직후 약 20초 동안
insert_quorum=2삽입이 성공으로 응답했습니다. 고립 기준 +100초에서 +120초 사이 98건입니다. db2 는 여전히 고립된 채였으니 복제본 2개의 확인은 있을 수 없습니다. 복구된 Keeper 상태에 db2 의 옛 「살아 있음」 표시가 세션 만료 전까지 남아 산 복제본 2로 셌다고 추정합니다(추정. 근거는 성공이 세션 시간 초과 30초 안에 몰렸다는 것과, 그 뒤로는TOO_FEW_LIVE_REPLICAS로 1346건이 실패했다는 것). 강제 복구 뒤에는 세션 시간이 지날 때까지 정족수 삽입의 성공 응답을 믿으면 안 됩니다. - 되돌리기(죽었던 노드를 다시 넣기)는 시험하지 않았습니다(미실측). 순서만 적습니다. etcd
member add로 db2 를 새로 넣고 데이터 디렉터리를 비워 시작 → Patronireinit db2→ RedisREPLICAOF와 Sentinel 재등록 → OpenSearch 는 db2 데이터를 비우고 합류(unsafe-bootstrap한 클러스터와 UUID 가 다르다) → Keeper 는 3서버 구성으로 다시 확장 → SeaweedFS master peers 복원 →fs.configure로001복원 →volume.fix.replication -apply.
8. 정리
| 질문 | 답(실측) |
|---|---|
| 2대로 자동 HA 가 되나 | 안 된다. 한 대를 잃으면 다섯 서비스 모두 쓰기가 멈췄다 |
| 복제본만 잃어도 멈추나 | PostgreSQL(etcd 2멤버)은 주 서버가 스스로 내려와 108.4초 멈췄다 |
| 데이터를 잃었나 | 확인받은 쓰기의 손실은 0건. 멈출 뿐 잃지 않는다 |
| 2대로 버티려면 | 수동 승격. 서비스별 0.9초에서 44.2초, 펜싱이 먼저 |
| 자동으로 넘어가게 하려면 | 투표 멤버를 3으로. 세 번째는 데이터 없는 작은 증인이면 된다 |
증인을 더한 구성이 장애 종류마다 어떻게 동작했는지, 그리고 「데이터 노드는 여전히 2대」 라는 조건이 남기는 함정은 2편에서 봅니다.
티티 한마디. 둘이서 다수결을 하면 언제나 1 대 1 이에요. 그래서 둘 다 「내가 정해도 되나?」 하고 멈춰 버리죠. 표만 던져 줄 친구 하나만 더 있으면 돼요. 그 친구는 짐(데이터)을 들 필요도 없어요!
참고
- ClickHouse Keeper 안내서(구성 예와 단일 노드 강제 복구 절차): https://clickhouse.com/docs/guides/sre/keeper/clickhouse-keeper (2026-10-31 열람)