春の入門祭り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段階で計測し、それぞれを比較します。
- ベースライン計測:パケットロスなしの状態で計測
- 半壊状態の計測:worker2(home-lab-3)のOS上で
tc qdisc add ... netem loss 50%を実行し、パケットロス50%を発生させた状態で計測 - 完全停止状態の計測: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 |
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/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/ |
20000リクエスト全成功、1620RPS、p50が2ms、p99が109ms。自宅サーバの3ノードk3sクラスタとしてはこんなもんかなと思います。
これがベースラインの数字。以降、半壊と完全停止の結果はこれと比較していきます。
ちなみに、実行中は以下の画像のように、レイテンシ分布が更新されながら進んでいくのが見えます。
実験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 |
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' |
定量計測
$ sudo kubectl -n zombie-exp exec debug -- oha -n 20000 -c 30 -t 2s --disable-keepalive http://nginx.zombie-exp.svc.cluster.local/ |
数字を並べてみるとこんな感じ。
- 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 |
PodとEndpointsの状態も確認します。
$ sudo kubectl -n zombie-exp get pod -o wide |
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' |
定量計測
$ sudo kubectl -n zombie-exp exec debug -- oha -n 20000 -c 30 -t 2s --disable-keepalive http://nginx.zombie-exp.svc.cluster.local/ |
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。この関係はどこか見覚えがあって、なんだか親近感が湧くのは僕だけでしょうか。