ステータス: 実測 (2026-10-31)。 TT Labの検証用VM 3台(KubeVirt、Ubuntu 24.04)上のDockerで測定した値です。各テストは1回ずつ行い、VMの電源強制停止は行っていません(未測定)。数値はすべて、生データ(試行ごとに1行ずつ残した書き込みログと復旧後の突き合わせ結果)から抽出しています。 作成ルール: 実測データを基本とし、測定できなかったものは「未測定」、原因を確認できなかった解釈は「推定」として表示します。
目次
- 1. 質問: 2台あれば1台が死んでも問題ないのではないか
- 2. なぜ過半数には3台が必要なのか
- 3. 5つのサービスはそれぞれ何でプライマリを決めるのか
- 4. 設定の抜粋
- 5. テスト方法
- 6. 本当に2台しかない場合
- 7. 手動昇格: 人間による「相手は死んだ」の宣言
- 8. まとめ
- 参考
1. 質問: 2台あれば1台が死んでも問題ないのではないか
オンプレミス環境でデータベースの冗長化を求められた際、真っ先に思い浮かぶ構成はサーバー2台です。1台がプライマリ(primary)として書き込みを受け付け、もう1台がレプリカを保持して、プライマリがダウンした際に引き継ぎます。サーバーコストもラックのスペースも2台が最も安価です。
本シリーズは、その構成が 自動では動作しないことを5つのサービスで直接確認した記録です。結論から述べます。
- 2台だけの構成では、どちらか一方の1台を失うと、5つのサービスの書き込みがすべて停止しました。 60秒間のネットワーク隔離に対し、PostgreSQLは108秒、SeaweedFS S3は108秒、その他は34秒から62秒でした。レプリカ側を失っただけでも停止しました。
- 停止状態を解消する方法は、 人間による手動昇格のみでした。手順を事前に用意していても、サービスごとに0.9秒から44.2秒がかかりました。
- データを持たない小さな監視用VM(2 vCPU・2 GiB)を1台追加すると、 自動切り替えが可能になります。ノードを隔離したテストにおいて、PostgreSQLは22.6秒から32.7秒で昇格し、確認された書き込みの損失は0件でした。この結果については パート2で詳しく見ていきます。
2. なぜ過半数には3台が必要なのか
2.1 停止と遮断を区別できない
db1とdb2が互いを監視していると仮定します。db1から見ると、db2からの応答が途絶えました。可能性は2つあります。
- db2が本当に停止した。
- db2は正常だが、両者間のネットワークのみが遮断された(ネットワークパーティション)。
db1単体では、この2つを区別する方法がありません。どちらも「応答なし」としてのみ見えるためです。1番だと信じてdb1がプライマリになったものの、実際には2番だった場合、db2も依然として自分がプライマリであると考えて書き込みを受け付けます。 スプリットブレイン(split brain) が発生し、ネットワークが復旧した際には、両側のデータがすでに乖離しています。どちらか一方の書き込みを破棄しなければなりません。
2.2 だからこそ「過半数を所持する側のみがプライマリになる」
この問題を回避する標準的なルールが 過半数(クォーラム、quorum) です。投票メンバーN個のうち、過半数を超える数(⌊N/2⌋ + 1)の同意を得た側のみがプライマリになることができます。ネットワークがどのように分断されても、過半数を持つ側は最大で1つしか存在しないため、プライマリが複数になることはありません。
問題となるのは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つ |
サービスごとに選定した理由とその代償は以下の通りです。
- 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に格納します。 2つのfilerが同じメタデータを参照するため、1つのfilerがダウンしてももう1つのfilerがそのまま引き継ぎます。その代わり、PostgreSQLの障害がfilerに波及します。
- SeaweedFSのレプリケーション
001は「同一データセンター・同一ラック内の別サーバーに追加のコピーを1つ」 を意味します。ボリュームサーバーが2台しかない場合、すべてのファイルが2サーバーに1つずつ配置され、1サーバーが脱落するとこの規則を満たす場所がなくなるため、新規の書き込みが失敗します。 - フロントエンドにはHAProxyを使用しています。 PostgreSQLはPatroni RESTの
GET /primaryが200であるノードにのみ転送し、ClickHouseとS3は稼働している方に振り分けます。アプリサーバーの位置(今回は監視ノードVM)に1つ、そしてfilerがPostgreSQLを見つけられるようにデータノードごとに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. テスト方法
- テストクライアントはアプリサーバーの位置(監視ノードVM)で実行されました。書き込み間隔はPostgreSQL・Redisが0.05秒、OpenSearchが0.1秒、ClickHouse・S3が0.2秒、タイムアウト制限は2秒から5秒です。試行ごとに1行ずつ記録し、終了後にストレージを読み込んで 確認済みの書き込み(成功応答を受け取った書き込み)がすべて残っているかを突き合わせました。役割の変化は0.25秒ごとに監視しました。
- 隔離とは、そのノード内でのセッション網インターフェースの入出力を iptables でドロップすることです。ノードは稼働しているものの相手と通信できなくなった状態、すなわちネットワーク分断です。 相手側から見ると電源が切れた状態と区別がつきません。
- 正常停止はそのノードのすべてのコンテナを
docker stop(猶予30秒)で終了させることです。 - 最大書き込み中断は、成功した書き込みと次の成功の間の最も長い間隔です。VM間のクロックのずれは±0.3秒以内でした(sshの往復で測定した上限)。
6. 本当に2台だけのとき
6.1 どのように2台にしたのか
立会者(Witness)がいる構成から立会者側を取り除きました。
- etcd:
member removeで立会者を外し、 実質2メンバーにしました。 - Sentinel: 立会者の Sentinel を停止し、
SENTINEL RESETを送信しました。残った2台が互いのみを認識し、クォーラムは2となります。 - OpenSearch:
voting_config_exclusionsで立会者を外し、停止しました。すると 投票構成が db2 の1台に減少しました。 2は偶数であるため、OpenSearch が 奇数に調整したためです(実測)。 - ClickHouse Keeper・SeaweedFS master: 立会者のものを停止しました。3台構成から2台のみが稼働している状態のため、「過半数である2を両方とも満たす必要がある」という 算数は2メンバーの場合と同じですが、メンバー数を実際に減らす再構成は行っていおらず(2メンバーへの再構成自体は未実測)、そのままです。
6.2 1台を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, フェイルオーバーなし |
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つあります。
- 「1台残っているのだから動き続けるだろう」という期待が PostgreSQL において最も大きく裏切られました。 レプリカを失っただけであるにもかかわらず、プライマリサーバーが自ら停止しました。
etcd の2メンバーで過半数を失ったためです。これは不具合ではなく、スプリットブレインを防ぐための正常な動作です。Patroni の
failsafe_modeは合意ストアを失ってもすべてのメンバーと REST で通信できればプライマリを維持する機能ですが、ネットワーク分断では相手に通信できないためこのケースを救うことはできません (設定の確認のみで、未実測)。 - OpenSearch は「どちら側を失うか」によって結果が分かれました。 投票構成が db2 の1台であったため、db1 を失った場合は稼働を続け、db2 を失った場合は停止しました。孤立した db2 は単独でクラスターとして振る舞い、自身のローカル書き込みを77件受け付け、復旧後に77件すべてが維持されていました。db1 側にはクラスターが存在しなかったため、データが乖離することはありませんでした。
- どちら側を失った場合でも、確認された書き込みの損失は0件でした。 停止するだけでデータを失わないことが、過半数ルールによって保証される点です。
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 手動昇格における注意点
- 「相手が本当に停止していること」を人間が保証しなければなりません。 もし相手が稼働し続けて書き込みを受け付けていた場合、この瞬間にプライマリが2つ存在することになります。運用の現場では、相手の電源を切るかネットワークを切断する フェンス(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 を新規追加し、データディレクトリをクリアして起動 → Patroni のreinit db2→ Redis のREPLICAOFと Sentinel の再登録 → OpenSearch は db2 のデータをクリアして合流(unsafe-bootstrapで既存クラスターとは UUID が異なる) → Keeper を3サーバー構成に再度拡張 → SeaweedFS master peers の復元 →fs.configureにより001を復元 →volume.fix.replication -apply.
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人いればいいのよ。そのお友達は荷物(データ)を持つ必要すらないんだから!
参考
- ClickHouse Keeper ガイド(構成例と単一ノード強制復旧の手順): https://clickhouse.com/docs/guides/sre/keeper/clickhouse-keeper (2026-10-31 閲覧)