TT Lab
ブログ

한국어 English 日本語 中文

はじめる
ブログ

ブログ

サーバー2台でHAが構成できない理由 1: PostgreSQL・Redis・OpenSearch・ClickHouse・SeaweedFSの実測

ステータス: 実測 (2026-10-31)。 TT Labの検証用VM 3台(KubeVirt、Ubuntu 24.04)上のDockerで測定した値です。各テストは1回ずつ行い、VMの電源強制停止は行っていません(未測定)。数値はすべて、生データ(試行ごとに1行ずつ残した書き込みログと復旧後の突き合わせ結果)から抽出しています。 作成ルール: 実測データを基本とし、測定できなかったものは「未測定」、原因を確認できなかった解釈は「推定」として表示します。

目次

1. 質問: 2台あれば1台が死んでも問題ないのではないか

オンプレミス環境でデータベースの冗長化を求められた際、真っ先に思い浮かぶ構成はサーバー2台です。1台がプライマリ(primary)として書き込みを受け付け、もう1台がレプリカを保持して、プライマリがダウンした際に引き継ぎます。サーバーコストもラックのスペースも2台が最も安価です。

本シリーズは、その構成が 自動では動作しないことを5つのサービスで直接確認した記録です。結論から述べます。

2. なぜ過半数には3台が必要なのか

2.1 停止と遮断を区別できない

db1とdb2が互いを監視していると仮定します。db1から見ると、db2からの応答が途絶えました。可能性は2つあります。

  1. db2が本当に停止した。
  2. db2は正常だが、両者間のネットワークのみが遮断された(ネットワークパーティション)。

db1単体では、この2つを区別する方法がありません。どちらも「応答なし」としてのみ見えるためです。1番だと信じてdb1がプライマリになったものの、実際には2番だった場合、db2も依然として自分がプライマリであると考えて書き込みを受け付けます。 スプリットブレイン(split brain) が発生し、ネットワークが復旧した際には、両側のデータがすでに乖離しています。どちらか一方の書き込みを破棄しなければなりません。

2.2 だからこそ「過半数を所持する側のみがプライマリになる」

この問題を回避する標準的なルールが 過半数(クォーラム、quorum) です。投票メンバーN個のうち、過半数を超える数(⌊N/2⌋ + 1)の同意を得た側のみがプライマリになることができます。ネットワークがどのように分断されても、過半数を持つ側は最大で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台構成と同様に障害を1つも耐えられません。1つの障害に耐えるには、投票メンバーが3台である必要があります。

2.3 3台目はデータを持たせず投票権だけでもよい

3番目のメンバーは、必ずしもデータを保持するサーバーである必要はありません。過半数の計算に必要なのは 投票のみだからです。データは2台に置き、3番目の位置には投票のみを行う小さなプロセスを配置します。これを ビットネス(witness) またはタイブレーカー(tie-breaker)と呼びます。今回の実験の監視用VMは2 vCPU・2 GiBであり、インストール直後の実使用メモリは1.5 GiBでした。

3. 5つのサービスはそれぞれ何でプライマリを決めるのか

5つのサービスすべてで過半数が使用されますが、過半数をカウントする主体はサービスごとに異なります。監視ノードに何を配置すべきかも、それによって変わります。

サービス HA方式 プライマリを決定するもの 監視ノードに配置したもの
PostgreSQL 17 Patroni 4.1 + ストリーミングレプリケーション、 同期レプリケーション(synchronous_mode: true, strict: false)、pg_rewind etcd 3メンバー(相互TLS) etcdメンバー1つ
Redis 8.8 プライマリ1 + レプリカ1、AOF everysec Sentinel 3つ、quorum 2 Sentinel 1つ
OpenSearch 3.9 データノード2、インデックスレプリカ1、セキュリティプラグイン有効 cluster_manager投票ノード3 データを持たないcluster_manager専用ノード
ClickHouse 26.8 シャード1 × レプリカ2、 ReplicatedMergeTree ClickHouse Keeper 3つ(Raft) Keeper 1つ
SeaweedFS 4.51 master 3(Raft)、volume 2(レプリケーション 001)、filer 2(PostgreSQL共有)、S3ゲートウェイ2 master Raft 3 master 1つ
실습 구성: 데이터 노드 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の合意ストア)。 3つのノードに同じ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行は、本シリーズにおいて最も大きな代償を払わせた設定です(パート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。 初回のみ3台が集まって投票構成を作成します。役割は環境変数で与えます(パート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メンバー3つのうち1つが監視ノードです。セッションタイムアウト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秒間隔のチェック2回で追従します。

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台にしたのか

立会者(Witness)がいる構成から立会者側を取り除きました。

6.2 1台を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台のみの構成で1台を失った場合: 最大書き込み中断
サービス レプリカ側(db1)を60秒間孤立 プライマリ側(db2)を60秒間孤立 理由
PostgreSQL 108.4秒の中断. 稼働していたプライマリサーバーが +23秒に自ら降格 107.8秒の中断. 昇格なし etcd の2メンバーの過半数は2。相手を失うとリーダーキーを更新できず、 プライマリ側も停止する. 復旧後もキーの有効期限切れを待つためさらに47秒かかった
Redis 50.3秒。プライマリは残ったが +11秒から NOREPLICAS 61.1秒。 MasterNotFound, フェイルオーバーなし 2つの Sentinel のうち1つだけではフェイルオーバーの権限(過半数)を得られない
OpenSearch 34.2秒後に継続(db2 単独の投票構成) 62.3秒、復旧まで停止 投票構成が db2 の1台のみ。db2 を失うとクラスターが存在しない
ClickHouse 60.5秒(挿入がブロック、読み取り失敗 0) 60.9秒 Keeper 3台のうち1台にしか到達できない
SeaweedFS S3 108.9秒 107.9秒 +18秒から master リーダー不在、およびレプリケーション 001

読み解くべきポイントは3つあります。

7. 手動昇格: 人間が「相手は死んだ」と宣言すること

2台のみの構成で停止したサービスを再稼働させるには、人間の介入が必要です。「相手は死んだので残りの1台が過半数である」と宣言するのです。 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 から始めて1サービスずつ順次実行しました。そのため、後の順番になるほど「孤立からの」の値が大きくなります。実際の障害で要する時間は「異常に気づいて判断を下す時間 + 右から2番目の列」に近いです。確認された書き込みの損失は5つのサービスすべてで0件でした。

PostgreSQL に最も時間がかかった理由は、etcd を再構築した後も旧リーダーキーの有効期限が切れるのを待つ必要があるためです。この30秒こそが ttl です。

7.1 手動昇格における注意点

8. まとめ

質問 回答(実測)
2台で自動 HA は可能か 不可能。1台を失うと、5つのサービスすべてで書き込みが停止した
レプリカを失っただけでも停止するか PostgreSQL(etcd 2メンバー)はプライマリサーバーが自ら停止し、108.4秒間停止した
データを喪失したか 確認された書き込みの損失は0件。停止するだけでデータは失われない
2台で耐障害性を維持するには 手動昇格。サービスごとに 0.9秒〜44.2秒、フェンスを先に実施
自動的にフェイルオーバーさせるには 投票メンバーを3台にする。3台目はデータを持たない軽量な立会者(Witness)で十分である

立会者を追加した構成が障害の種類ごとにどのように動作するか、そして「データノードは依然として2台である」という条件が残す落とし穴については、 第2部で解説します。

ティティの一言。 2人で多数決をとっても、いつだって1対1になっちゃうの。だからどっちも「私が決めてもいいのかな?」って止まってしまうんだよね。票を投げてくれるお友達がもう1人いればいいのよ。そのお友達は荷物(データ)を持つ必要すらないんだから!

参考

ログインするといいねできます

コメント

まだコメントはありません。

ログインするとコメントできます