TT Lab
블로그

한국어 English 日本語 中文

시작하기
블로그

블로그

서버 2대로는 왜 HA 가 안 되나 2: 증인 한 대가 바꾸는 것과 남는 함정

상태: 실측 (2026-10-31). TT Lab 실습 VM 세 대(KubeVirt, Ubuntu 24.04) 위의 Docker 에서 잰 값입니다. 각 시험은 한 번씩 했고, VM 전원 정지는 하지 않았습니다(미실측). 구성과 시험 방법은 1편에 있습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 원인을 확인하지 못한 해석은 「추정」 으로 표시합니다.

목차

1. 한눈에 보기

1편에서 데이터 노드 2대만으로는 한 대를 잃을 때 다섯 서비스가 모두 멈춘다는 것을 봤습니다. 이번에는 투표만 하는 증인 VM 을 더한 구성(데이터 노드 db1·db2 + 증인)에 세 종류의 장애를 넣었습니다.

결과를 먼저 적습니다.

2. 장애 종류별 최장 쓰기 중단

최장 쓰기 중단은 성공한 쓰기와 다음 성공 사이의 가장 긴 틈(초)입니다. 괄호 안은 실패 수 / 시도 수입니다. ClickHouse 는 기본 삽입, quorum=2 는 insert_quorum=2 로 넣은 삽입, S3 는 HAProxy 뒤 SeaweedFS S3 게이트웨이 2개입니다.

증인이 있을 때: 장애 종류별 최장 쓰기 중단 장애는 모두 90초. 고립 = 세션망 차단, 정지 = 컨테이너 전부 docker stop db1 고립 db1 정상 정지 증인 정지 0 30 60 90 120 150 초 PostgreSQL 23.7 3.7 0.1 Redis 105 134.8 0.1 OpenSearch 66.3 0.7 0.1 ClickHouse 5.3 2.4 5.4 ClickHouse quorum=2 60.6 123.6 5.3 SeaweedFS S3 95.7 131.1 27.6 점선 = 장애 90초. 0.1 은 끊김 없음(쓰기 간격 수준)
그림: 증인이 있을 때: 장애 종류별 최장 쓰기 중단
서비스 db1 고립 90초 db2 고립 90초 db1 고립 40초 db2 정상 정지 90초 db1 정상 정지 90초 증인 정지 90초
PostgreSQL 23.7 (400/5910) 28.3 (491/5915) 33.7 (601/3120) 3.4 (25/5944) 3.7 (34/5943) 0.09 (0/4780)
Redis 105.0 (471/4364) 105.1 (1964/5855) 55.2 (371/2463) 268.8 ※ (5349/5968) 134.8 (2673/5970) 0.09 (0/4782)
OpenSearch 66.3 (16/2347) 64.7 (15/2364) 40.2 (9/1196) 0.3 (0/2990) 0.7 (0/2986) 0.13 (0/2392)
ClickHouse 5.3 (1/1476) 5.2 (1/1476) 5.2 (1/776) 2.5 (2/1491) 2.4 (2/1490) 5.4 (1/1175)
ClickHouse quorum=2 60.6 (303/1475) 59.9 (300/1451) 10.0 (51/752) 122.9 (601/1488) 123.6 (602/1486) 5.3 (1/1175)
SeaweedFS S3 95.7 (19/898) 111.2 (11/955) 61.2 (11/505) 122.4 (11/898) 131.1 (12/856) 27.6 (2/1055)

※ db2 정상 정지의 Redis 268.8초는 공식 이미지의 결함 때문에 다시 띄운 Redis 가 재시작 고리에 빠져 끝내 돌아오지 못한 값입니다(4.5절). 고친 뒤 같은 시험을 db1 에 한 것이 오른쪽에서 두 번째 열입니다. 쓰기 간격은 PostgreSQL·Redis 0.05초라 0.09초는 끊김이 없었다는 뜻입니다.

읽기(조회) 실패는 거의 없었습니다. OpenSearch _count 는 시험마다 0회에서 1회, ClickHouse·S3 는 증인 있는 구성의 모든 시험에서 0회에서 1회였습니다.

2.1 주인이 바뀌기까지

장애를 넣은 시각부터 잰 초입니다.

db1 고립 90초 db2 고립 90초 db1 고립 40초 db2 정상 정지 db1 정상 정지
Patroni 새 primary 22.6 27.4 32.7 3.1 2.8
Sentinel 새 master 6.5 6.6 7.0 7.9 7.8
OpenSearch 노드 제거(yellow) 64.8 65.2 제거 전에 복구 0.6 0.5
SeaweedFS 새 master 리더 12.1 리더가 고립 노드가 아니었음 리더가 고립 노드가 아니었음 영향 없음 15.0
복구 뒤 합류 PG 15.5(pg_rewind 와 재시작 포함), OpenSearch green 5.1 PG 6.6, OpenSearch green 2.8 PG 14.2, OpenSearch green 6.9 OpenSearch green 23.2(시작 기준) OpenSearch green 26.3

2.2 서비스별로 읽기

2.3 증인을 꺼도

PostgreSQL Redis OpenSearch ClickHouse 기본 / quorum=2 S3
실패 0 / 4780 실패 0 / 4782 실패 0 / 2392 5.4초 1건 / 5.3초 1건 27.6초, 2건

ClickHouse 와 S3 의 짧은 끊김은 그 순간 Keeper 리더와 SeaweedFS master 리더가 증인에 있었기 때문입니다. 리더를 다시 뽑는 데 각각 약 5초와 12초가 걸렸고, S3 는 볼륨 서버가 새 리더에 다시 붙을 때까지 더 끊겼습니다. 증인은 「표만 던지는」 자리지만 Raft 에서는 리더가 될 수도 있다는 점을 기억해 둘 만합니다.

3. 분할: 고립된 옛 주인이 쓰기를 받는가

자동 전환이 된다고 끝이 아닙니다. 끊긴 쪽의 옛 주인이 그 사이 쓰기를 받았다가 나중에 버리면, 앱은 성공 응답을 받고도 데이터를 잃습니다. db1(그때의 주인)을 40초 고립시키고, db1 안에서 0.5초마다 자기 로컬 서비스에 직접 썼습니다. 복구 뒤 그 쓰기가 남았는지 대조했습니다.

서비스 고립된 옛 주인이 받은 쓰기 복구 뒤 남은 것 막아 준 것
PostgreSQL 0. 고립 직후부터 실패 해당 없음 동기 복제(복제본 확인을 기다림) + 합의 저장소를 잃으면 강등
Redis 고립 뒤 0초에서 10.7초 사이 22건 0건(22건 모두 소실) min-replicas-max-lag 10 이 지난 뒤에야 NOREPLICAS. 그 10초가 소실 창
OpenSearch 100건 100건 ※ 방금 만든 색인이라 복제본이 in-sync 집합에 들기 전이었다(일반 색인은 쓰기가 막혔다). 분기는 없었다
ClickHouse 0. Keeper 과반을 잃어 삽입이 매달림 해당 없음 Keeper 과반
SeaweedFS filer 0. 시간 초과 해당 없음 master 과반

과반 규칙을 그대로 따르는 PostgreSQL·ClickHouse·SeaweedFS 는 고립된 쪽이 쓰기를 거절했습니다. 손실은 Redis 에서만 나왔습니다.

4. 운영자가 꼭 알아야 할 함정

여기부터는 이번 측정에서 실제로 겪은 것입니다.

4.1 Redis min-replicas-max-lag 는 곧 분할 때의 소실 창이다

Redis 분할: 고립된 옛 주인이 받은 22건 db1(주인)의 세션망을 끊고, db1 안에서 0.5초마다 직접 썼다 0 +5 +10 +15 +20초 db1 옛 주인(고립) 쓰기 성공 22건 NOREPLICAS db2 새 주인 복제본 +7.0초 Sentinel 이 승격 min-replicas-max-lag 10 복구하자 db1 은 db2 의 복제본이 되어 db2 의 데이터로 덮였다. 성공 응답을 받은 22건이 복구 뒤 0건(전부 소실) 그 사이 db2 쪽의 확인받은 쓰기 손실은 0건
그림: Redis 분할: 고립된 옛 주인이 받은 22건

Redis 의 복제는 비동기입니다. 주인이 쓰기를 받으면 곧바로 성공을 돌려주고, 복제본에는 나중에 보냅니다. min-replicas-to-write 1 과 min-replicas-max-lag 10 은 「최근 10초 안에 응답한 복제본이 1개 이상 있을 때만 쓰기를 받는다」 는 뜻입니다. 고립된 주인은 복제본의 마지막 응답이 10초를 넘기기 전까지 쓰기를 계속 받습니다.

실측에서 그 창은 정확히 그렇게 열렸습니다. 고립된 db1 은 처음 10.7초 동안 22건을 성공으로 받았고, 그 사이 +7.0초에 Sentinel 이 db2 를 새 주인으로 세웠습니다. 복구 뒤 db1 은 db2 의 복제본이 되어 db2 의 데이터로 덮였고, 22건은 하나도 남지 않았습니다. 앱 쪽에서 보면 「성공」 응답을 받은 쓰기입니다.

선택지는 셋이고, 어느 것도 공짜가 아닙니다.

이번 구성은 첫째 설정(1, 10)이었고, 그 대가가 「분할 때 최대 약 10초의 쓰기 소실」 과 「데이터 노드 하나를 잃으면 쓰기 정지」 두 가지로 모두 측정됐습니다.

4.2 실패로 응답했는데 저장된 쓰기, 그리고 멱등 키

실패로 응답했는데 저장된 쓰기 시간 초과·정족수 미달 응답이 와도 한쪽에는 이미 써졌다 앱 id=42 삽입 ① 삽입 ③ 실패 응답(시간 초과) DB ② 복제본 하나에 저장 다시 보내면 멱등 키 없음 id=42 가 두 줄 (같은 행이 중복된다) 다시 보내면 멱등 키 있음 id=42 가 한 줄 OpenSearch: _id 지정 ClickHouse: 중복 제거 토큰 실측(증인 있는 구성): OpenSearch 고립 시험마다 9–16건, ClickHouse quorum=2 실패 응답의 거의 전부, S3 2–4건
그림: 실패로 응답했는데 저장된 쓰기

확인받은 쓰기의 손실은 0건이었지만, 반대 방향의 어긋남이 있었습니다. 실패 응답을 받았는데 실제로는 저장된 쓰기입니다.

증인 있는 구성의 시험 OpenSearch ClickHouse quorum=2 S3
db1 고립 90초 16 / 실패 16 303 / 실패 303 4 / 실패 19
db2 고립 90초 15 / 실패 15 300 / 실패 300 3 / 실패 11
db1 고립 40초 9 / 실패 9 51 / 실패 51 4 / 실패 11
db2 정상 정지 90초 0 / 실패 0 601 / 실패 601 2 / 실패 11
db1 정상 정지 90초 0 / 실패 0 601 / 실패 602 2 / 실패 12

(표의 값은 「실패 응답인데 저장된 수 / 실패 응답 수」)

클라이언트가 실패를 보고 다시 보내면 같은 행이 두 번 들어갑니다. 그래서 다시 보내는 쓰기에는 멱등 키가 필요합니다. OpenSearch 는 문서 _id 를 앱이 정해서 넣고, ClickHouse 는 insert_deduplication_token 을 붙입니다. 앱의 재시도 로직이 「실패했으니 없을 것」 이라고 가정하는지 먼저 확인할 일입니다.

4.3 ClickHouse Keeper 강제 복구 직후의 정족수 창

1편 7.1절에서 본 내용입니다. 2대뿐인 구성에서 Keeper 를 --force-recovery 로 단일 서버로 되살린 직후 약 20초 동안, 상대 복제본이 고립된 채였는데도 insert_quorum=2 삽입 98건이 성공으로 응답했습니다. 그 뒤에는 TOO_FEW_LIVE_REPLICAS 로 1346건이 실패했습니다. 복구된 Keeper 에 상대의 옛 「살아 있음」 표시가 세션 시간 초과(30초) 전까지 남아 있었다고 추정합니다(추정). 강제 복구 뒤에는 세션 시간이 지날 때까지 정족수 삽입의 성공을 믿지 않습니다.

4.4 OpenSearch 는 고립된 노드를 알아채는 데 65초가 걸렸다

정상 정지에서는 노드가 스스로 클러스터를 떠나므로 쓰기 중단이 1초 미만이었습니다. 고립은 달랐습니다. 노드가 빠지기까지(yellow) 64.8초와 65.2초가 걸렸고, 그동안 그 노드에 primary 샤드가 있는 색인으로 가는 쓰기가 멈췄습니다. 검색(_count)은 고립 시험마다 0회에서 1회만 실패했습니다.

OpenSearch 는 기본 장애 감지 설정(확인 한 번의 시간 제한 10초, 3회 연속 실패해야 장애로 판정)으로 노드를 지켜봅니다. 고립에서는 이 판정을 두 단계에 걸쳐 거치므로 약 65초가 된 것으로 봅니다. cluster.fault_detection.* 값을 줄이면 이 시간을 줄일 수 있지만, 줄인 값으로는 재지 않았습니다(미실측). 일시적인 네트워크 흔들림에도 노드를 빼게 되므로, 줄인다면 그 값으로 다시 재 보아야 합니다.

4.5 공식 redis:8 이미지와 Sentinel 의 재시작 고리

2절 표의 268.8초가 이것입니다. 원인은 이렇게 맞물렸습니다.

  1. 공식 redis:8 이미지의 진입점은 모듈 폴더에 있는 .so 를 모두 --loadmodule 로 붙여 Redis 를 띄웁니다.
  2. Sentinel 은 장애 조치 때 Redis 에 CONFIG REWRITE 를 보냅니다. 그러면 실행 중인 설정이 파일에 다시 쓰이면서 그 loadmodule 줄들이 redis.conf 에 박힙니다.
  3. 다음 시작에서 진입점이 붙인 모듈과 파일에 박힌 모듈이 겹쳐 같은 모듈을 두 번 싣다가 Can't load module ... server aborting 으로 죽습니다. 컨테이너는 다시 시작되고, 다시 죽습니다.

Sentinel 을 쓰고, 장애 조치를 한 번 겪고, 그 Redis 를 다시 시작할 때에만 드러나므로 설치 직후의 시험으로는 보이지 않습니다. 모듈을 쓰지 않는다면 해결은 간단합니다. 모듈 폴더를 빈 tmpfs 로 덮어 아예 싣지 않습니다. 이미 박힌 줄은 지웁니다.

  redis:
    image: redis:8.8.4
    command: ["redis-server", "/conf/redis.conf"]
    tmpfs:
      - /usr/local/lib/redis/modules   # 진입점이 붙일 모듈이 없게 한다
sed -i '/^loadmodule /d' /path/to/redis.conf   # 이미 박힌 줄 지우기

고친 뒤 같은 시험(db1 정상 정지)에서 Redis 는 정상으로 돌아왔고, 남은 134.8초 중단은 4.1절의 min-replicas 대가였습니다. 이 문제는 업스트림에 이미 보고되어 있고(redis/docker-library-redis#468, redis/redis#14178), Redis 본체 쪽 고침 (redis/redis#16009)이 2026-10-29 에 병합되어 다음 판부터 풀릴 예정입니다.

같은 줄기의 작은 함정이 하나 더 있습니다. Sentinel 은 requirepass 를 ACL 해시 줄로 고쳐 씁니다. 설정 파일에서 비밀번호를 읽어 오는 헬스체크는 깨지므로, 이번에는 인증 전 응답(NOAUTH)도 「살아 있음」 으로 보게 했습니다.

4.6 증인은 세 번째 장애 영역에 둔다

이번 시험에서 증인 VM 은 db2 와 같은 물리 서버 위에 있었습니다. VM 단위 장애만 넣었기 때문에 결과에는 영향이 없었지만, 그 물리 서버가 죽으면 db2 와 증인을 함께 잃어 3표 중 2표가 사라집니다. 그러면 2대뿐인 구성과 똑같이 멈춥니다.

증인의 가치는 「데이터 노드 어느 쪽과도 함께 죽지 않는다」 는 데서 나옵니다. 운영에서는 증인을 세 번째 장애 영역, 곧 다른 랙, 다른 건물, 다른 전원과 스위치, 혹은 클라우드의 작은 VM 에 둡니다. 증인이 하는 일은 표를 던지는 것뿐이라 작은 VM 으로 충분합니다. 이번 증인은 2 vCPU·2 GiB 에서 설치 직후 1.5 GiB 를 썼습니다. 다만 2.3절에서 봤듯이 증인도 Raft 리더가 될 수 있으므로 데이터 노드와의 왕복 지연이 너무 크면 리더 재선출 동안의 끊김이 길어질 수 있습니다(미실측).

4.7 SeaweedFS: 볼륨 서버 하나로는 쓰지 못하는데, 바로 실패하지도 않는다

복제 001 에서 볼륨 서버가 하나 남으면 새 쓰기를 둘 자리가 없습니다. 여기까지는 설정대로입니다. 문제는 실패가 요청마다 약 10초씩 매달린 뒤에야 왔다는 것입니다. 두 가지가 겹쳤습니다.

두 가지 모두 업스트림에 올렸습니다. 볼륨 서버가 새 리더를 늦게 찾는 문제는 고침 PR (seaweedfs#12259, 재현에서 25초가 4초로)을, 돌아온 master 가 리더를 끌어내리는 문제는 이슈(seaweedfs#12260)를 냈습니다. 같은 실험에서 -raftHashicorp(PreVote 가 켜진 raft)로 돌리면 리더가 흔들리지 않았습니다. 운영에서 데이터 노드 2대로 SeaweedFS 를 쓴다면, 한 대가 빠진 동안 업로드가 바로 실패하지 않고 매달린다는 것을 앱의 시간 제한에 반영해 둡니다. 볼륨 서버 디스크를 통째로 잃은 경우는 volume.fix.replication -apply 가 30.7초에 24개 볼륨을 모두 맞췄고, 그동안 S3 쓰기 실패는 1건, 읽기 실패는 0건이었습니다. filer 하나를 60초 멈춘 시험에서는 S3 쓰기 748건 중 실패가 0건이었습니다.

4.8 짧은 함정 모음

함정 증상 대처
OpenSearch node.roles: ${OS_ROLES}(yml 안 환경 변수 치환) 목록이 아니라 문자열 하나가 되어 노드끼리 클러스터를 만들지 못한다. DEBUG 로그에서만 보인다 환경 변수 node.roles=cluster_manager,data,ingest(쉼표 목록)로 준다
OpenSearch 3.9 증인 힙 320m·한도 700m OOM 재시작 고리 힙 384m·한도 1100m
SeaweedFS 이미지는 uid 1000 으로 내려가 돈다 root 600 인 S3 설정 파일을 못 읽어 S3 가 죽는다 파일을 1000 소유로
SeaweedFS master 한도 128m 89 MiB 까지 오른다 192m
ClickHouse 26.8 HTTP 삽입의 기본값 async_insert=1, wait_for_async_insert=1 로 나왔다 정족수 시험은 async_insert=0 으로 따로
ClickHouse Keeper Raft 포트 listen_host 를 줘도 모든 주소(9234)에서 열렸다 호스트 방화벽으로 바깥 인터페이스를 막는다
iptables -m multiport 한 규칙에 포트 15개까지(「too many ports specified」) 규칙을 둘로 나눈다
컨테이너에는 /dev/watchdog 이 없다 Patroni 펜싱이 합의 저장소 TTL 에만 기댄다. 주 서버가 죽지 않고 멈춘 채 TTL 을 넘기면 주인이 잠깐 둘일 수 있다 호스트 watchdog 을 넘기거나 softdog(미실측)
docker stop 유예 30초 유예를 다 쓰는 컨테이너 때문에 노드 전체 정지가 30초 걸렸다 정상 정지 절차의 시간 예산에 넣는다

5. 이 측정의 한계

숫자를 가져다 쓰기 전에 조건을 확인해 주십시오.

6. 정리: 2대 + 증인으로 갈 때의 점검표

점검 이유(이 글의 실측)
증인을 데이터 노드와 다른 장애 영역에 같은 곳에서 함께 죽으면 2대뿐인 구성과 같다
Redis 의 min-replicas-* 를 의식해서 고른다 (1, 10) 은 분할 때 22건 소실, 노드 하나를 90초 잃으면 105초 쓰기 정지
재시도하는 쓰기에 멱등 키 실패 응답인데 저장된 쓰기가 OpenSearch·ClickHouse·S3 에서 나왔다
ClickHouse 강제 복구 뒤 세션 시간 동안 정족수를 믿지 않는다 98건이 거짓 성공
OpenSearch 고립 감지 시간을 예산에 기본값으로 65초
공식 redis:8 + Sentinel 이면 모듈 폴더를 비운다 장애 조치 뒤 재시작 고리
수동 승격 절차를 미리 적고 펜싱부터 2대뿐일 때의 유일한 출구. 서비스별 0.9초에서 44.2초

티티 한마디. 증인 친구가 생기니 다수결이 잘 돌아가요. 그런데 짐을 드는 친구는 여전히 둘뿐이라, 한 명이 쉬면 「짝이 와야 짐을 내려놓을래」 하는 서비스가 있어요. 그리고 「실패했어요」 라는 말을 듣고 다시 보낼 땐 꼭 이름표(멱등 키)를 붙여 주세요. 같은 짐이 두 번 들어오면 곤란하거든요!

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

댓글

아직 댓글이 없습니다.

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