TT Lab
ブログ

한국어 English 日本語 中文

はじめる
ブログ

ブログ

サーバー2台ではなぜHAができないのか(2):証人が1台変わるものと残された罠

ステータス:実測(2026-10-31)。 TT Labのテスト用VM3台(KubeVirt、Ubuntu 24.04)上のDockerで計測した値です。各テストは1回ずつ実施し、VMの電源強制断は行っていません(未計測)。構成とテスト方法は第1部に記載しています。作成規則:実測データを基本とし、計測できなかったものは「未計測」、原因を確認できなかった解釈は「推定」と表記します。

目次

1. 概要

第1部では、データノード2台だけでは1台を失ったときに5つのサービスすべてが停止することを見ました。今回は投票のみを行う証人VMを追加した構成(データノードdb1・db2+証人)に3種類の障害を投入しました。

結果を先に述べます。

2. 障害種類別の最長書き込み停止時間

最長書き込み停止時間は、成功した書き込みと次の成功の間の最も長い間隔(秒)です。括弧内は失敗数/試行数です。 ClickHouse は通常の挿入、 quorum=2 は insert_quorum=2 で実行した挿入、 S3 はHAProxyの背後にある2つのSeaweedFS S3ゲートウェイです。

증인이 있을 때: 장애 종류별 최장 쓰기 중단 장애는 모두 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に対して行ったのが右から2番目の列です。書き込み間隔は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 新プライマリ 22.6 27.4 32.7 3.1 2.8
Sentinel 新マスター 6.5 6.6 7.0 7.9 7.8
OpenSearch ノード削除 (yellow) 64.8 65.2 削除前に復旧 0.6 0.5
SeaweedFS 新マスターリーダー 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マスターのリーダーが証人上で稼働していたためです。リーダーの再選出にそれぞれ約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件。タイムアウト 該当なし マスターの過半数

過半数ルールをそのまま遵守する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件も残りませんでした。 アプリケーション側から見ると「成功」レスポンスを受け取った書き込みです。

選択肢は3つありますが、どれもタダではありません。

今回の構成は1つ目の設定(1, 10)であり、その代償として「分割時の最大約10秒の書き込みロスト」と「データノードを1つ失うと書き込み停止」という2つのコストが測定されました。

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はデフォルトの障害検知設定(1回の確認のタイムアウト10秒、3回連続失敗で障害と判定)でノードを監視します。孤立状態ではこの判定を2段階経るため、約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を使用し、フェイルオーバーを1度経験し、その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にマージされ、次のバージョンからリリースされる予定です。

同じ系統の小さな落とし穴がもう1つあります。 Sentinelは requirepass をACLハッシュ行に書き換えます。 設定ファイルからパスワードを読み取るヘルスチェックが破損するため、今回は認証前の応答(NOAUTH)も「生存」とみなすようにしました。

4.6 立会人は3つ目の障害ドメインに配置する

今回のテストで、立会人VMはdb2と 同一の物理サーバー 上にありました。VM単位の障害しか投入しなかったため結果に影響はありませんでしたが、その物理サーバーがダウンするとdb2と立会人を同時に失い、3票中2票が消滅します。その結果、2台だけの構成と同様に停止します。

立会人の価値は「どちらのデータノードとも同時に死なない」点にあります。運用では立会人を 3つ目の障害ドメイン、すなわち別のラック、別の建物、別の電源とスイッチ、あるいはクラウドの小さなVMに配置します。立会人の役割は投票を行うことだけなので、小さなVMで十分です。今回の立会人は2 vCPU・2 GiBで、セットアップ直後に1.5 GiBを使用しました。ただしセクション2.3で見たように、立会人もRaftリーダーになる可能性があるため、データノードとの往復遅延が大きすぎるとリーダー再選挙中の通信途絶が長くなる可能性があります(未測定)。

4.7 SeaweedFS:ボリュームサーバーが1台では書き込めないが、即座に失敗もしない

レプリケーション 001 においてボリュームサーバーが1台だけになると、新しい書き込みを配置する場所がなくなります。ここまでは設定どおりです。問題は、失敗がリクエストごとに約10秒間ハングした後にようやく返ってきたことです。2つの要因が重なっていました。

どちらの問題もアップストリームに報告しました。ボリュームサーバーが新しいリーダーの検出に時間がかかる問題については修正PR(seaweedfs#12259、再現テストで25秒が4秒に短縮)、復帰したmasterがリーダーを引きずり下ろす問題についてはissues(seaweedfs#12260)を提出しました。同じ実験を -raftHashicorp(PreVoteが有効なRaft)で実行するとリーダーは揺らぎませんでした。運用環境でデータノード2台でSeaweedFSを使用する場合、1台が脱落している間はアップロードが即座に失敗せずハングすることを、アプリケーションのタイムアウトに反映させておきます。ボリュームサーバーのディスクをごっそり失ったケースでは、 volume.fix.replication -apply が30.7秒ですべての24ボリュームを整合させ、その間S3の書き込み失敗は1件、読み込み失敗は0件でした。filerを1台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 1つのルールにポート15個まで(「too many ports specified」) ルールを2つに分割する
コンテナには /dev/watchdog がない Patroniのフェンシングが合意ストレージのTTLのみに依存している。プライマリサーバーがダウンせず停止したままTTLを超過すると、一時的にマスターが2つ存在しうる ホストのwatchdogを渡すかsoftdog(未測定)
docker stop 猶予30秒 猶予を使い切るコンテナがあるためノード全体の一斉停止に30秒かかった 正常停止手順のタイムバジェットに組み込む

5. 本測定の限界

数値を参考にする前に条件をご確認ください。

6. まとめ: 2 台 + 証人構成を採用する際のチェックリスト

確認項目 理由(本記事の実測値)
証人をデータノードとは異なる障害ドメインに配置する 同じ場所で同時に停止した場合、2 台だけの構成と同じになる
Redis の min-replicas-* を意識して選定する (1, 10) は分断時に 22 件消失、ノードを 1 つ 90 秒失うと 105 秒間書き込みが停止
リトライする書き込みには冪等キーを付与する 失敗の応答だったが保存された書き込みが OpenSearch·ClickHouse·S3 で発生した
ClickHouse の強制復旧後、セッション時間中はクォーラムを信用しない 98 件の偽の成功
OpenSearch の孤立検知時間を予算に組み込む デフォルト値で 65 秒
公式 redis:8 + Sentinel の場合はモジュールフォルダを空にする フェイルオーバー後の再起動ループ
手動昇格の手順を事前に記載し、フェンシングから実行する 2 台のみの場合の唯一の出口。サービスごとに 0.9 秒から 44.2 秒

TT からの一言。 証人の友だちができると多数決がうまく機能します。しかし荷物を持つ友だちは相変わらず 2 人だけなので、1 人が休むと「相棒が来たら荷物を下ろすね」と言い出すサービスがあります。そして「失敗しました」という言葉を聞いて再送する時は、必ず名札(冪等キー)を付けてください。同じ荷物が 2 回入ってくると困りますからね!

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

コメント

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

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