フューチャー技術ブログ

分散システム入門: 信頼性の低いネットワークを再現してみる

春の入門祭り2026の4本目です。

はじめに

こんにちは、自宅サーバの運用って実質盆栽だよなって思い始めてる、盆栽未経験の内堀です。今回は自宅サーバでネットワークフォルトを再現してみたよというお話です。

分散システムを勉強していると「ノードが完全に死ぬよりも、中途半端に死んでいるほうが厄介」という話に必ず出会います。完全に死ねばクラスタが検知して切り離せますが、半分生きているとシステムからは正常に見えてしまい、悪さをし続けるからです。というわけで、分散システム入門の第一歩として、今回はこの「中途半端に死んだノード」を自宅サーバ上で再現してみます。具体的には、50%パケロスする設定をノードに仕込み(本記事ではゾンビノードと呼びます)、クラスタへの影響を測定します。

結論から書くと、3ノードのKubernetesクラスタで1ノードのネットワークだけ半壊させたところ、クラスタ全体のスループットが1/14、p99レイテンシが15倍に悪化しました。以下、この数字に至るまでの話を書いていきます。

実験の概要

構成

3ノード(home-lab-1, home-lab-2, home-lab-3)のk3sクラスタで、以下を用意します。

  • nginxのDeployment(replicas=3、各ノードに1Podずつ配置されるようtopologySpreadConstraintsで制約)
  • 上記を束ねるClusterIP Service
  • 計測用のデバッグPod(curlとohaが入ったalpineベース、control-planeで元々負荷の軽いhome-lab-1に固定)

実験の流れ

以下の3段階で計測し、それぞれを比較します。

  1. ベースライン計測:パケットロスなしの状態で計測
  2. 半壊状態の計測:worker2(home-lab-3)のOS上で tc qdisc add ... netem loss 50% を実行し、パケットロス50%を発生させた状態で計測
  3. 完全停止状態の計測:worker2上で systemctl stop k3s-agent を実行し、Kubernetesから完全に切り離された状態で計測

各段階で、デバッグPodからService経由でnginxを叩いて挙動を観察します。具体的には、軽い疎通確認としてcurlを60回ループで回して各Podへの振り分けを見て、その後ohaで20000リクエスト投げてSuccess rate、RPS、レイテンシ分布を計測します。

実験

実験1:ベースラインの計測

まずはパケットロスなしの状態で計測します。比較対象になる数字を取るのが目的です。

Podが3ノードに1つずつ分散配置されていること、ServiceのEndpointsに3つのPod IPが揃っていることを確認します。

$ sudo kubectl -n zombie-exp get pod -o wide
NAME READY STATUS RESTARTS AGE IP NODE
nginx-84888755c4-l7xtn 1/1 Running 0 13h 10.42.2.82 home-lab-3
nginx-84888755c4-vf9zs 1/1 Running 0 20h 10.42.1.213 home-lab-2
nginx-84888755c4-z94f4 1/1 Running 0 20h 10.42.0.31 home-lab-1

$ sudo kubectl -n zombie-exp get endpoints nginx
NAME ENDPOINTS AGE
nginx 10.42.0.31:80,10.42.1.213:80,10.42.2.82:80 20h

3Pod、3ノードに分散、Endpointsに全部入っている状態。期待通りです。

疎通確認

デバッグPodからcurlを60回ループで回して、各Podにほぼ均等に振り分けられていることを見ます。nginxはpostStartで自分のhostname(=Pod名)をindex.htmlに書き込んでいるので、レスポンスを見ればどのPodが応答したかわかります。

$ sudo kubectl -n zombie-exp exec debug -- sh -c 'for i in $(seq 1 60); do curl -s --max-time 2 http://nginx.zombie-exp.svc.cluster.local/ || echo FAIL; done | sort | uniq -c'
19 nginx-84888755c4-l7xtn
19 nginx-84888755c4-vf9zs
22 nginx-84888755c4-z94f4

19/19/22でほぼ均等。FAILは0件。

定量計測

ohaで20000リクエスト、コネクション並列度30、--disable-keepalive でリクエストごとにTCP接続を張り直す設定で計測します。keep-aliveを切っているのは、後段の半壊状態でTCPハンドシェイクのSYNパケットがロスする様子をはっきり見るためです(keep-aliveを有効にすると同じコネクションを使い回してしまい、ロスの影響が見えにくくなります)。

$ sudo kubectl -n zombie-exp exec debug -- oha -n 20000 -c 30 -t 2s --disable-keepalive http://nginx.zombie-exp.svc.cluster.local/

Summary:
Success rate: 100.00%
Requests/sec: 1620.8811

Response time distribution:
50.00% in 2.0985 ms
90.00% in 69.1044 ms
99.00% in 108.9357 ms

Status code distribution:
[200] 20000 responses

20000リクエスト全成功、1620RPS、p50が2ms、p99が109ms。自宅サーバの3ノードk3sクラスタとしてはこんなもんかなと思います。
これがベースラインの数字。以降、半壊と完全停止の結果はこれと比較していきます。

ちなみに、実行中は以下の画像のように、レイテンシ分布が更新されながら進んでいくのが見えます。

image.png

実験2:home-lab-3をゾンビノードにして計測

worker2(home-lab-3)にパケットロス50%を注入します。Linuxの tc コマンドの netem モジュールを使います。

$ ssh worker2 "sudo tc qdisc add dev eth0 root netem loss 50%"

これでeth0から出入りするパケットの50%がランダムに落ちるようになります。

Kubernetesから見たノードの状態

ここが今回の実験の核心です。50%のパケロスを発生させた直後の状態を見ます。

$ sudo kubectl get nodes
NAME STATUS ROLES AGE VERSION
home-lab-1 Ready control-plane 73d v1.34.3+k3s3
home-lab-2 Ready <none> 73d v1.34.3+k3s3
home-lab-3 Ready <none> 73d v1.34.3+k3s3

$ sudo kubectl -n zombie-exp get endpoints nginx
NAME ENDPOINTS AGE
nginx 10.42.0.31:80,10.42.1.213:80,10.42.2.82:80 20h

home-lab-3はReadyのまま。Endpointsからも外れていません。これがゾンビノードです。

なぜこうなるかというと、Kubernetesはkubeletがapiserverに対して定期的にハートビートを送ることでノードの生死を判定しています。50%のパケロスがあっても、TCPの再送機構によってハートビートはなんとか届くので、Kubernetesから見ればノードは正常稼働中ということになります。

疎通確認

次に、curlでの各Podへの振り分け状況を確認します。FAILが7件発生。home-lab-3上のPod(l7xtn)に振り分けられたリクエストは、半分くらいは2秒のタイムアウト内に応答が返ってきますが、半分くらいはパケロスによって失敗してFAIL扱いになります。

ベースラインではFAIL=0だったところに、いきなり12%が失敗するようになりました。「ノードはReady、でもユーザのリクエストは落ちる」が起きている状態です。

$ sudo kubectl -n zombie-exp exec debug -- sh -c 'for i in $(seq 1 60); do curl -s --max-time 2 http://nginx.zombie-exp.svc.cluster.local/ || echo FAIL; done | sort | uniq -c'
7 FAIL
18 nginx-84888755c4-l7xtn
18 nginx-84888755c4-vf9zs
17 nginx-84888755c4-z94f4

定量計測

$ sudo kubectl -n zombie-exp exec debug -- oha -n 20000 -c 30 -t 2s --disable-keepalive http://nginx.zombie-exp.svc.cluster.local/

Summary:
Success rate: 90.50%
Total: 219.8244 sec
Requests/sec: 90.9817

Response time distribution:
50.00% in 0.0008 sec
90.00% in 0.8245 sec
99.00% in 1.6341 sec

Error distribution:
[1899] timeout

数字を並べてみるとこんな感じ。

  • Success rateが90.5%に低下
  • 1899件のタイムアウトエラー
  • RPSが1620→91と、ベースラインの5.6%まで激減
  • 20000リクエスト消化に12秒だったのが、220秒
  • p99が109ms→1634msと、約15倍に悪化

正直、ここまで悪化するとは思っていませんでした。1台のゾンビノードが混ざったことで、クラスタ全体のスループットが18分の1まで落ち込みました。
面白いのがp50で、0.8msとベースラインの2.1msより速く見えます。これは「運よくロスに当たらず1発で通ったリクエスト」が半分弱あって、それらは普通に高速だからです。ロスに当たったリクエストはTCPの再送タイムアウト(初期RTOが1秒)に引っかかってp90以降で秒オーダーまでぶっ飛びます。「半分は普通に速い、半分は秒オーダーで遅い」という2山型(バイモーダル)の分布になっています。

実験3:home-lab-3を完全停止させて計測

最後に、worker2のk3s-agentを完全に止めて計測します。「ネットワーク半壊」と「ノード完全停止」のどちらがマシか、という比較が目的です。

Kubernetesから見たノードの状態

実験2ではhome-lab-3はReadyでしたが、今回はNotReadyになりました。

$ sudo kubectl get nodes
NAME STATUS ROLES AGE VERSION
home-lab-1 Ready control-plane 73d v1.34.3+k3s3
home-lab-2 Ready <none> 73d v1.34.3+k3s3
home-lab-3 NotReady <none> 73d v1.34.3+k3s3

PodとEndpointsの状態も確認します。

$ sudo kubectl -n zombie-exp get pod -o wide
NAME READY STATUS RESTARTS AGE IP NODE
nginx-84888755c4-blc65 0/1 Pending 0 27s <none> <none>
nginx-84888755c4-l7xtn 1/1 Terminating 1 14h 10.42.2.82 home-lab-3
nginx-84888755c4-vf9zs 1/1 Running 0 21h 10.42.1.213 home-lab-2
nginx-84888755c4-z94f4 1/1 Running 0 21h 10.42.0.31 home-lab-1

$ sudo kubectl -n zombie-exp get endpoints nginx
NAME ENDPOINTS AGE
nginx 10.42.0.31:80,10.42.1.213:80 21h

home-lab-3上のPod(l7xtn)がTerminatingになり、EndpointsからもIP 10.42.2.82 が外れました。Service経由のトラフィックは健全な2Podだけに流れます。新しいPod(blc65)はPendingですが、各ノードに1Podまでの制約をかけているので置き場所がない、というだけで、Endpointsには影響しません。期待通りの挙動です。

疎通確認

curlで各Podへの振り分け状況を確認します。
FAIL=0。home-lab-3上のPod(l7xtn)はEndpointsから外れているので、振り分け先には現れません。健全な2Podだけで応答が返っています。

$ sudo kubectl -n zombie-exp exec debug -- sh -c 'for i in $(seq 1 60); do curl -s --max-time 2 http://nginx.zombie-exp.svc.cluster.local/ || echo FAIL; done | sort | uniq -c'
34 nginx-84888755c4-vf9zs
26 nginx-84888755c4-z94f4

定量計測

$ sudo kubectl -n zombie-exp exec debug -- oha -n 20000 -c 30 -t 2s --disable-keepalive http://nginx.zombie-exp.svc.cluster.local/

Summary:
Success rate: 100.00%
Requests/sec: 1267.3896

Response time distribution:
50.00% in 1.9522 ms
90.00% in 91.6390 ms
99.00% in 193.9529 ms

Status code distribution:
[200] 20000 responses

Success rateは100%に戻りました。RPSは1267で、ベースラインの78%程度。Pod数が3→2に減ったので、スループットは下がりましたが、それだけです。p99も194msとベースラインの109msから少し悪化していますが、実験2の1634msと比べれば誤差みたいなものです。
Kubernetesがhome-lab-3をNotReadyと判定し、Endpointsから自動で外してくれたおかげで、リクエストはちゃんと返ってきます。ゾンビノードが混ざっているときと比べると、随分と平和な数字です。

実験結果のまとめ

3つの実験結果を表にまとめます。

指標 ベースライン ゾンビノードあり 完全停止
Success rate 100.00% 90.50% 100.00%
RPS 1620 91 1267
p50 2.1 ms 0.8 ms 1.9 ms
p90 69 ms 824 ms 92 ms
p99 109 ms 1634 ms 194 ms
timeout 0 1899 0
20000リクエスト消化時間 12 sec 220 sec 16 sec

ゾンビノードありと完全停止を並べてみると、こうです。

  • スループット:完全停止の1/14
  • p99:完全停止の8.4倍
  • Success rate:完全停止より9.5ポイント低下

ノードが「壊れている」という点はどちらも同じです。違うのは、Kubernetesがそれを検知できるかどうか。検知できれば勝手に退避してくれるけど、検知できなければ放置されたまま。たったそれだけの差で、結果がガラッと変わりました。

冒頭で書いた「中途半端に死んでるほうが厄介」を、実験を通して確認できました。

おわりに

今回はシンプルなHTTPリクエストのみを確認しましたが、これがDB接続のような状態を持つ処理であれば、コネクションを掴んだまま離さないリクエストがプールを食いつぶし、連鎖的な障害を招くことは容易に想像できます。

しかも厄介なことに、Kubernetes側の仕組みだけでは半壊状態を検知することが難しく、ノードがReadyである限り異常として処理されません。実際にネットワークフォルトを再現してみたことで、ゾンビノードが全体にどのような被害を及ぼすのか、解像度が上がりました。

それにしても、不調なのに「大丈夫です」と返すWorkerと、それを真に受けて普通に仕事を振り続けるControl plane。この関係はどこか見覚えがあって、なんだか親近感が湧くのは僕だけでしょうか。