ハイブリッド k3s · 第3回

ハイブリッド k3s #3: Pod 同士が通信できない — flannel VXLAN と vmnet

この記事の原文は Zenn で公開しています。


ホームラボ全体構成図

0. このシリーズについて

このシリーズは、上の構成図のように、今この瞬間も動いているホームラボをどう作ったのかを一つずつ記録していく記事です。

「これ、できるんだろうか?」という素朴な疑問から始めたトイプロジェクトは、満足のいく性能を手にし、壊しては組み直す過程を重ねるうちに、仕事で溜まったストレスを解いてくれる本物のおもちゃになってしまいました。リソースが潤沢なクラスタではありませんが、Kubernetes を存分に味わうには十分で、次に試したいことを次々と投げかけてくれます。

  • ノード 6台 — クラウド(AWS 東京)の Lightsail server 2台(コントロールプレーン + etcd) + 自宅(札幌)iMac 上の Lima VM agent 4台
  • 合計 19 vCPU / 61 GiB、ネームスペース 49個、Pod 248個(稼働 150)
  • デプロイは ArgoCD、認証は Keycloak OIDC、その上に CloudNativePG・Vault・CrowdSec・Prometheus/Grafana などが稼働中

1回目でクラウドのコントロールプレーン2台を立て、2回目で自宅 iMac を Lima VM 4台に分けて agent として迎え入れ、6ノードすべてが Ready になりました。今回はその先で起きたことです — ノードは全部つながったのに、肝心の Pod 同士が通信できなかった。flannel を覗き、パケットがどこを流れるのかを追い、結局同じ iMac 上の VM たちを vmnet で直接束ねる までの話です。

1. 6ノードが Ready なのに、Pod 同士は他人だった

前回の終わりの絵はきれいでした。kubectl get nodes を打つと、東京の server 2台と札幌の Lima agent 4台がすべて Ready で、INTERNAL-IP には Tailscale の 100.x アドレスが入っていました。

$ kubectl get nodes -o wide
NAME               STATUS   ROLES                AGE    VERSION        INTERNAL-IP     OS-IMAGE             CONTAINER-RUNTIME
ip-172-26-2-70…    Ready    control-plane,etcd   143d   v1.34.3+k3s1   100.99.x.x      Amazon Linux 2023   containerd://2.1.5-k3s1
ip-172-26-3-146…   Ready    control-plane,etcd   143d   v1.34.3+k3s1   100.71.x.x      Amazon Linux 2023   containerd://2.1.5-k3s1
lima-k3s-agent     Ready    <none>               143d   v1.34.3+k3s1   100.84.x.x      Ubuntu 24.04.3 LTS  containerd://2.1.5-k3s1
lima-k3s-agent-2   Ready    <none>               143d   v1.34.3+k3s1   100.98.x.x      Ubuntu 24.04.3 LTS  containerd://2.1.5-k3s1
lima-k3s-agent-3   Ready    <none>               143d   v1.34.3+k3s1   100.117.x.x     Ubuntu 24.04.3 LTS  containerd://2.1.5-k3s1
lima-k3s-agent-4   Ready    <none>               143d   v1.34.3+k3s1   100.90.x.x      Ubuntu 25.10        containerd://2.1.5-k3s1

できたと思いました。それで意気込んで色々と Pod を載せ始めたのですが、すぐに妙な壁にぶつかりました。同じノードに載った Pod 同士はちゃんと通信するのに、別々のノードに載った Pod 同士は通信できなかった のです。サービスが妙なところでタイムアウトし、ある Pod は DNS すら解決できませんでした。

最初は「ノードは全部 Ready なのになぜ?」と思いましたが、これは勘違いでした。Ready と「Pod ネットワークが通る」は 別の層の話 です。

  • ノード Ready — ノードの kubelet が apiserver へ「生きてるよ」というハートビートを送受信する コントロールプレーン の経路だ。前回までにやったのがまさにこれ — Tailscale 100.x でノードと apiserver が互いに届くようにしたこと。この経路が生きていればノードは Ready に見える。
  • Pod ↔ Pod(ノード境界を越える) — 別々のノードにいる Pod が直接パケットをやり取りする データプレーン の経路だ。これはコントロールプレーンとは まったく別の道 であり、その道を敷くのは kubelet ではなく CNI(ここでは flannel)だ。

ノード Ready と Pod 通信は別の平面 — コントロールプレーン vs データプレーン

6台すべて Ready で INTERNAL-IP が 100.x(Tailscale)なのは、コントロールプレーンが無事だ という意味でしかありません。前回完成したのは「ノードたちが一つのクラスタのメンバーとして認められる」ところまでで、ノード境界を越える Pod トラフィックを運ぶ道はまだ検証されていない 状態でした。

kubectl get nodes がすべて Ready でも、ノード間の Pod 通信は保証されません。コントロールプレーン(ノード↔apiserver)とデータプレーン(Pod↔Pod)は別の経路であり、後者を担うのは CNI です。だから次の問いは一つに絞られます — flannel はノード境界を越える Pod パケットを正確にどう運ぶのか?

2. flannel と VXLAN — Pod パケットはどうやってノード境界を越えるか

k3s は CNI として flannel を、その既定バックエンドとして VXLAN を使います。1回目で server を --flannel-backend vxlan で立て、2回目の agent もこの設定をそのまま引き継ぎました。(k3s Basic Network Options — flannel の既定バックエンドは vxlan、ほかに host-gwwireguard-nativenone が選べる)

Pod パケットの旅を2つの場合に分けて見てみます。

  • 同じノードの中 — すべての Pod はノードの cni0 ブリッジにぶら下がっている。同じノードの2つの Pod はこのブリッジ上で直接 L2 で出会う。ノード境界を越えないので速く、詰まることもない。(だから §1 で「同じノードの Pod 同士は通った」のだ。)
  • 別のノードへ — 宛先 Pod が別のノードにいると、パケットは cni0 を離れて flannel.1 という仮想インターフェースに入る。これが VXLAN の VTEP(VXLAN Tunnel Endpoint)だ。ここで元の Pod パケット(イーサネットフレームごと)が UDP パケットの中に丸ごとカプセル化 され、相手ノードへ送られる。

このカプセルを受け取る「アドレス」と「ポート」が肝です。

  • ポートは UDP 8472。 flannel の VXLAN バックエンドは Linux ではカーネル既定ポートの 8472/udp を使う(Windows でのみ IANA 標準の 4789)。したがって ノード同士は互いに 8472/udp で届く必要がある。 (flannel backends — “On Linux, defaults to kernel default, currently 8472” · k3s ネットワーク要件)
  • アドレスは各ノードが広告する public-ip flannel はノードごとに「私の VXLAN カプセルはこのアドレスで受け取る」という宛先(VTEP の外側 IP)をクラスタに広告する。既定値は そのノードのデフォルトルートのインターフェース IP だ(ノード別の実値は §5 で見る)。この「どのアドレスを広告するか」が今回ずっと足を引っ張る。

flannel VXLAN データフロー — cni0 から flannel.1 へ、VXLAN でカプセル化され 8472 で相手ノードに到達

カプセル化はタダではありません。VXLAN ヘッダ(外側 IP/UDP + VXLAN + 内側イーサネット)がパケットごとに 50バイト を余計に食います。そのため flannel は flannel.1 の MTU を ホストインターフェースの MTU から 50 を引いた値 に設定します。ホストが 1500 なら flannel.1 は 1450 になります。

agent ノード(lima-k3s-agent)で確認するとこうです。

$ cat /run/flannel/subnet.env
FLANNEL_NETWORK=10.42.0.0/16
FLANNEL_SUBNET=10.42.2.1/24
FLANNEL_MTU=1450
FLANNEL_IPMASQ=true

$ ip -br link | grep -E 'eth0|lima0|tailscale0|flannel'
eth0         UP   ... mtu 1500
lima0        UP   ... mtu 1500
flannel.1    UNKNOWN ... mtu 1450     # 1500 - 50(VXLAN)
tailscale0   UNKNOWN ... mtu 1280     # この 1280 が §5 で効いてくる

flannel.1 が VXLAN デバイスで 8472 に投げる、というのは -d(詳細)で一行に表れます。

$ ip -d link show flannel.1
5: flannel.1: <...> mtu 1450 qdisc noqueue state UNKNOWN ...
    vxlan id 1 local 192.168.105.2 dev lima0 srcport 0 0 dstport 8472 nolearning ttl auto ...

vxlan id 1dstport 8472 — VXLAN で 8472 に投げる、というのがこの一行にあります。末尾の local 192.168.105.2 dev lima0、つまり「このノードが VXLAN を どのアドレス・インターフェースで 送り出すか」が今回の本当の鍵なのですが — なぜその値なのかは §5 で解けます。

MTU — パケット1個の最大サイズ

上の出力に mtu 1500mtu 1450mtu 1280 が出てきました。この数字が §5 で決定的に足を引っ張るので、ここで押さえておきます。

MTU(Maximum Transmission Unit) は、1つのインターフェースが一度に運べるパケット1個の最大サイズ(バイト)です。イーサネット標準の既定値が 1500 なので、普通の NIC は 1500 から始まります。MTU より大きいパケットは細かく分割(fragmentation)して送るか、分割できなければそのまま捨てられます。分割は遅く、捨てられると通信が止まったように見えます。

肝は カプセル化するほど中に入れられる実サイズが減る ことです。箱をもっと大きな箱で包み直すと、外箱のサイズ(1500)はそのままでも、中に入れられる物は梱包材のぶん減るのと同じです。だからインターフェースごとに MTU が違います。

インターフェースMTUなぜこの値か
eth0 / lima01500カプセル化なしの素のイーサネット既定値
flannel.114501500 − 50。VXLAN ヘッダ(50B)で一度包んでも外側 1500 を超えないための上限
tailscale01280WireGuard 暗号化オーバーヘッド + どんな回線でも安全なよう保守的に取った値(IPv6 の最小 MTU)

包むほど縮む MTU — 1500 → 1450(VXLAN) → 1230(Tailscale の上の VXLAN)

flannel VXLAN が物理イーサネット(1500)の上で回るなら 1450 でうまく収まります。問題はノード間通信を Tailscale の上に 載せるときです — Pod パケットが VXLAN で一度、その上に WireGuard でもう一度、二重に包まれて 中に入れられるサイズが 1280 − 50 = 1230 程度までさらに縮みます。flannel は依然 1450 と信じてパケットを送り出すのに、トンネルがそれだけ受けられないと、その差が断片化・ドロップ・再送として噴き出します。この「二重カプセル化」がなぜレイテンシまで悪化させるのかは §5 で扱います。

ノード間の Pod 通信は、結局「ノード同士が互いの public-ip へ UDP 8472 をやり取りできるか」に還元されます。これが 塞がれれば Pod ネットワークは切れ(§3)、経路が 遅ければ Pod ネットワークは遅くなります(§5)。tailscale0 の MTU 1280 — VXLAN をこの上に載せると上限がさらに減る、ということまで覚えておけば十分です。

3. ノード間通信ができず、8472 を開けた

§2 の結論どおりなら、ノード境界を越える Pod 通信は「ノード同士が互いの public-ip へ 8472(VXLAN)をやり取りできるか」に絞られます。だから通信ができないとき、最もそれらしい疑いは一つでした — 8472 が塞がれている。

1回目でファイアウォールを王道どおり閉じておいたからです。apiserver(6443)・kubelet(10250)・flannel VXLAN(8472) といったクラスタポートは公開網に開けず、プライベート/tailnet の中でだけ届くようにする — 露出面を最小にする正しい初期設定です(1回目のファイアウォール表)。だから「ノード境界を越える VXLAN が公開網で塞がれて、ノード間の Pod 通信ができないのだ」という仮説が自然でした。

ノードの中では flannel が 8472 をちゃんと開いて待ち受けていました。

$ sudo ss -ulnp | grep 8472
UNCONN  0   0   0.0.0.0:8472   0.0.0.0:*

ポートはノードの中で開いているのに、別のノードの Pod には届かない — なら塞がれているのはノードの中ではなくノード 同士 の道だ、と見ました。それで「とにかく通したい」気持ちから、原則を破って Lightsail のファイアウォールに 8472/udp インバウンドを開けました。 ノード間の Pod 通信は通り、そのまま運用に入りました。

8472 を閉じる vs 開ける — 閉じるとノード間 VXLAN が塞がると見た。開けると通るがポートが露出する

注意 — 推奨構成ではありません。 8472 は 6443 のように証明書・トークンで守られたポートではなく、公開網に開けること自体が露出面を増やします。「通ったからこれが答えだ」とそのときは思いましたが、「ポートを開けたら通った」が「そのポートのおかげで通った」を意味するわけではありません — この判断は §8 で実際の経路を見て覆ります。

ノード間の Pod 通信ができないとき、まず疑う場所が ノード間の 8472/udp(VXLAN)の到達性 なのは合理的な出発点です。ただしここではそれを公開網に開けてしまい、これは借金でした。そして次の節ですぐに請求書が飛んできます。

4. Longhorn を載せたら再起動が止まらなくなった

Pod 同士が通じるようになったので、いよいよ「本物のクラスタらしく」状態を持つワークロードを載せてみたくなりました。選んだのは Longhorn — Kubernetes 用の分散ブロックストレージです。

Longhorn を選んだ理由は単純です。k3s 既定のストレージ(local-path)はノード1台のディスクに紐づく hostPath なので レプリケーションがありません — Pod が別のノードに移ると、データはついてきません。「ノードが死んでもデータが生き残る」をやってみるには、複数ノードにデータを複製する分散ストレージが要ります。Longhorn はその席を埋める CNCF プロジェクトで、ボリュームごとに専用コントローラ(Longhorn Engine) を立てて各ボリュームをマイクロサービスのように扱い、そのボリュームを 複数ノードのローカルディスクに replica として同期複製 します。(Longhorn — What is Longhorn?)

インストールは Helm で一行、最初はうまくいくように見えました。Pod が Running で立ち、ボリュームも作られました。

ところがすぐに エラーと再起動が果てしなく繰り返され 始めました。ボリュームが Degraded に落ち、replica の同期がタイムアウトし、それを直そうと再ビルドが回り、その負荷でまた失敗し — 自分で自分を強める悪循環でした。クラスタを新しく立てたわけでもないのにノードが揺さぶられ、その上の他の Pod まで巻き込まれました。

原因は ノード間レイテンシ でした。Longhorn の同期複製は、書き込み1回ごとに replica たちが「書いた」と応答するまで待ちます。だから Longhorn 公式ドキュメントは 「throughput・IOPS よりも latency のほうがボリュームの安定性にずっと重要だ」 と明記し(Longhorn Best Practices)、トラブルシューティングガイドはさらに踏み込んで 1ノードで複数ボリュームが同時に I/O するときはノード間遅延を 20ms 未満に 保つよう勧めています(Longhorn KB — volume readonly / I/O error)。

ところが自宅ノード(lima)同士の遅延は、そのはるか上でした。測ってみると replica の同期書き込み遅延が平均 200ms+ — 推奨基準 20ms の10倍を超えます。同期複製が書き込みのたびにこの遅延をまるごと食うので、ボリュームは Degraded から抜け出せず、再ビルドと再起動が無限に回りました。結局 Longhorn は lima(自宅)ノードを事実上ストレージから外すことになり、「ノードが複数ある本物のクラスタで stateful を回す」という目標は、この遅延の上では不可能に見えました。

Longhorn の同期複製は最も遅いノードを待つ — 推奨 20ms vs 実測 200ms+

引っかかる点はもう一つありました。その遅すぎる自宅ノード4台は、物理的に同じ iMac 1台の中 に入っています。同じ機械の中の VM 同士なのに、どうして 200ms が出るのでしょうか。次の節でその経路を追ってみます。

5. 同じホストなのになぜ遅い? — 隣のノードへ渡るために東京まで往復していた

§4 の謎はこれでした。同じ iMac の中の VM 4台なのに、ノード間遅延が 200ms。パケットが実際にどこを流れるのか、追ってみます。

手がかり 1 — 同じホストの VM 同士が直接届く道がない。 各 VM のアドレスを打つとおかしいです。

$ limactl shell k3s-agent   -- ip -4 addr show eth0 | grep inet
    inet 192.168.5.15/24 ... eth0
$ limactl shell k3s-agent-2 -- ip -4 addr show eth0 | grep inet
    inet 192.168.5.15/24 ... eth0

2つの VM の eth0どちらも 192.168.5.15 です。Lima の既定 user-mode ネットワークはサブネットが 192.168.5.0/24 で固定されていて、各 VM が独立した NAT の裏で同じアドレスを受け取ります。だからホストからも、他のゲスト(VM)からも直接届きません — 同じ iMac の中にいても互いを知りません。Lima 公式ドキュメントもこの制限について「ホストや他のゲストからアクセスするには VMNet を使え」と案内しています(Lima — user-mode network)。

手がかり 2 — 直接の道がないので、隣の VM へ行くときも遠距離用の道を通る。 VM 同士が直接届かないので、ノード間パケット(Pod の VXLAN を含む)は唯一の共通経路 — 1回目・2回目で両サイトを束ねた Tailscale(100.x) — を通ります。すぐ隣の VM へ行くのも同じです。Tailscale は NAT 越しの遠距離をつなぐ道具なので、直結(P2P)ができないと中継サーバ(DERP)を経由します。tailscale netcheck はこうでした。

$ tailscale netcheck
   * MappingVariesByDestIP: true       ← P2P 直結が難しい NAT (endpoint-dependent mapping)
   * Nearest DERP: Tokyo (30.3ms)

MappingVariesByDestIP: true で直結ができないので、Tailscale は最も近い 東京 DERP リレーへ迂回 します。札幌の VM A が隣の VM B へ送るパケットが、家を出て東京を踏んでまた札幌に戻ってきたのです。

同じ iMac の VM なのにパケットが東京 DERP を踏んで戻ってくる

手がかり 3 — 測るとその迂回がそのまま見える。 同じ iMac の VM 同士の遅延です。

lima ↔ lima-2 :  87 ms (max 202, jitter 54)
lima ↔ lima-3 : 133 ms (max 268, jitter 92)

すぐ隣の VM なのに数十〜百 ms、東京を往復した値です。このとき自宅ノード同士の Pod VXLAN は宛先(public-ip)が tailnet(100.x)なので、flannel VXLAN がさらに Tailscale(WireGuard)の上に乗り、§2 の二重カプセル化と MTU 1280 の制約まで重なります。Longhorn の同期複製が書き込みのたびにこの往復を食ったので、200ms は当然の結果でした。

ここまでは 同じ iMac の中の VM 同士(lima↔lima) の話です。自宅↔東京をつなぐ クロスサイト は経路がまた違い(そこでは二重カプセル化ではありません)、§8 で別に見ます。

ノードが同じ物理ホストにあるからといって速いわけではありません。 仮想化ネットワークが VM を隔離すると、すぐ隣の VM との通信ですらオーバーレイを通って遠くへ迂回しうる。近いトラフィックは近くで終わらせるべきです — その道を §6・§7 で探します。

6. どう直すか

問題ははっきりしました。同じ iMac 上の VM 4台が 互いに直接届く LAN を持たず、すぐ隣の VM へのトラフィックまで遠距離用の Tailscale・DERP を経由している。直す道を候補として並べて検討しました。

ノード間遅延の解決候補の比較 — 1〜4 は遅い経路の上で包み方を変えるだけ、5(vmnet)は経路そのものを変える

  • flannel を tailscale0 に固定(--flannel-iface=tailscale0) — VXLAN を tailnet の上に明示的に載せる。だが根本問題(同じホストなのに遠距離迂回・二重カプセル)はそのまま。さらに server は VPC アドレス、agent だけ tailscale0 にすると宛先が食い違い、片方向だけ通る非対称で壊れる(両方を変える必要があり、1回目の server 構成まで手を入れることになる)。遅延も減らない。
  • flannel の host-gw バックエンド — カプセル化なしでノード IP へ直接ルーティングして最速。ただし 「すべてのノード間に直接 L2 接続」が前提 だ(k3s ドキュメント)。東京↔札幌に L2 はなく、user-mode ネットワークで隔離された VM 同士にも L2 はない。
  • flannel の wireguard-native バックエンド — VXLAN の代わりに WireGuard で暗号化。セキュリティには良いが、依然として同じ迂回経路の上を走るので遅延はそのまま。
  • Tailscale を P2P に強制 — DERP の代わりに直結させる。だが直結を阻むのは Lima 既定 user-mode ネットワークの VM 隔離 なので、Tailscale 側の設定だけでは解けない。

1〜4 の共通点は 遅い経路の上で包み方を変えるだけ ということです。経路そのもの(同じ機械の中なのに外へ出て戻ってくる)はそのままなので遅延が減りません。そこで発想を裏返しました。

  • 同じホストだという事実を活かす — VM たちに本物の LAN を与える。 4台が1つの iMac の中にいるのだから、ホストが仮想 LAN を敷いて VM 同士を 直接 L2 で通信させれば、Tailscale・DERP をそもそも経由しない。これは Lima ドキュメントが user-mode ネットワークの隔離について勧めるまさにその方法(VMNet)で、macOS では socket_vmnet で実装する。← 採用。

候補を選ぶ基準は一つでした — 「遅延の根本原因(直接経路がない)を取り除くか」。 1〜4 は「より速く」か「別の VPN で包み直す」試みで経路はそのままですが、vmnet は 経路そのものを家の中の LAN に変えます。 こうすると Tailscale は東京↔札幌の遠距離を担い、同じホストのトラフィックは家の中で終わります。次の節で適用して結果を確認します。

7. socket_vmnet で家の中に LAN を敷く

7-1. vmnet と socket_vmnet

§5 で見た問題は「同じ iMac 上の VM たちが互いに直接届く LAN を持たない」でした。その空白を埋めるのが vmnet です。

vmnet は macOS が持つ仮想ネットワークフレームワーク(vmnet.framework)です — VM に NAT・ブリッジ・ホストネットワークを作ってあげる Apple 公式 API です。ただしこれを直接使うには VM プロセスが root 権限・entitlement を持つ必要があって面倒です。socket_vmnet は Lima プロジェクトが作った小さなデーモンで、この vmnet.framework を包んで Unix ソケットで 公開します。socket_vmnet デーモンだけが root で回り、VM たちはそのソケットにつなぐだけ — VM 自体は root で回す必要がありません。(だから 7-2 のインストールがバイナリを root 所有の /opt に置いて sudoers を敷くのです。)(socket_vmnet)

socket_vmnet は3つのモードを与えます。

  • shared — プライベートサブネット(192.168.105.0/24)+ インターネット NAT。同じ socket_vmnet につながった VM たちが1つの仮想スイッチ(L2)に乗って互いに直接通信 する。← 我々に必要なもの。
  • bridged — ホストの物理 LAN(例: en0)に VM を直接合流させる。
  • host — インターネットなしの隔離網。

(Lima — VMNet)

構造がどう変わるかが肝です。

  • 以前 — 各 VM には eth0(Lima 既定 user-mode、192.168.5.15、互いに隔離)と tailscale0(100.x)しかなかった。VM 同士が直接届く道がなく、ノード間トラフィック(Pod VXLAN を含む)が tailscale0 へ漏れて §5 の遠距離迂回が起きた。
  • 以後 — 各 VM に lima0(vmnet shared、192.168.105.x)インターフェースがもう一枚できる。 eth0tailscale0 はそのまま残り、k3s ノードの InternalIP も依然 Tailscale 100.x なのでクラスタのアイデンティティ・メンバーシップは変わらない。変わるのはデータ経路だけだ — lima0 がノードのデフォルトルートになり、§2 で見た「flannel はデフォルトルートのインターフェースを VXLAN 宛先(public-ip)に選ぶ」という規則どおり、flannel が VXLAN 宛先を lima0 に移す。 だから自宅ノード同士の Pod 通信が tailnet・DERP を経由せず vmnet の上ですぐ終わる。

要するに 足す変更 です — クラスタを作り直したりノードのアイデンティティに触れたりせず、インターフェースを一枚(lima0)足して、同じホストのトラフィックの道だけを速い LAN に差し替える。

vmnet 適用前後の構造 — lima0 を一枚足す

7-2. socket_vmnet のインストール(ホスト = iMac)

socket_vmnet は root で回るデーモンなので、バイナリは ユーザーが勝手に書き換えられない root 所有のパス になければなりません。Lima は Homebrew でのインストールをセキュリティ上推奨していないので、ソースからビルドして /opt/socket_vmnet に入れます。(Lima — VMNet)

git clone https://github.com/lima-vm/socket_vmnet
cd socket_vmnet
git checkout v1.2.2          # リリースページで最新の安定タグを確認
make
sudo make PREFIX=/opt/socket_vmnet install.bin
# → /opt/socket_vmnet/bin/socket_vmnet (root 所有)

7-3. Lima sudoers の登録

Lima が socket_vmnet を root で起動できるよう、sudoers の断片を敷きます。

limactl sudoers > etc_sudoers.d_lima
less etc_sudoers.d_lima                 # 内容を確認してから
sudo install -o root etc_sudoers.d_lima /etc/sudoers.d/lima
rm etc_sudoers.d_lima

7-4. 各 VM に共有ネットワークを付ける

各 VM の ~/.lima/<vm>/lima.yaml に shared ネットワークを追加します。このネットワークが 192.168.105.0/24(ゲートウェイ 192.168.105.1)を敷き、VM ごとに lima0 インターフェースでそのレンジのアドレスを与えます。

networks:
  - lima: shared
    interface: lima0

注意 — networks: キーの重複。 lima.yaml に(コメントとしてでも)networks: キーがすでにあると、ファイル末尾にそのまま付け足すと YAML のキー重複でパースエラーになります。既存のキーの下にマージしてください。

7-5. 順次再起動

一度に1台ずつ、ノードが再び Ready になるのを確認しながら再起動します(クラスタへの影響を最小化)。

for VM in k3s-agent k3s-agent-2 k3s-agent-3 k3s-agent-4; do
  limactl stop  "$VM"
  limactl start "$VM"
  kubectl --context k3s-lightsail wait --for=condition=Ready node/lima-"$VM" --timeout=180s
done

再起動すると k3s が立ち上がり直して flannel が自分のインターフェースを選び直します。今やデフォルトルートが lima0(192.168.105.1)になっているので、flannel は §2・§7-1 で見た規則どおり VXLAN 宛先(public-ip)を vmnet アドレスで広告し直します--flannel-iface のような追加フラグなしで。

7-6. 確認

各 VM が lima0 で vmnet アドレスを受け取ったか見ます。

$ limactl shell k3s-agent -- ip -4 addr show lima0 | grep inet
    inet 192.168.105.2/24 ... lima0

そして同じ iMac の VM 同士で ping を測り直します。

$ limactl shell k3s-agent -- ping -c5 -q 192.168.105.3
5 packets transmitted, 5 received, 0% packet loss
rtt min/avg/max/mdev = 0.449/0.571/0.639/0.064 ms

87〜133 ms だった同じホストの VM 間遅延が 0.5 ms 台 に落ちました。東京を往復していたパケットが、今は iMac の中で終わります。

socket_vmnet 適用前後 — 87〜133ms の東京迂回から 0.5ms の lima0 直通へ

flannel は既定で「デフォルトルートのインターフェース」を VXLAN 宛先に選びます。 だからノードにより速い直接経路(ここでは vmnet の lima0)を敷いてそれをデフォルトルートにすれば、flannel が勝手にその道へ乗り換えます — CNI 側の追加設定なしで。

8. 結果 — vmnet が変えたもの、そして 8472 の真実

適用後、flannel が広告する VXLAN 宛先(public-ip)をノード別に見ると、2つのサイトがくっきり分かれます。

$ kubectl --context k3s-lightsail get nodes \
    -o custom-columns='NAME:.metadata.name,PUBLIC-IP:.metadata.annotations.flannel\.alpha\.coreos\.com/public-ip'
NAME               PUBLIC-IP
ip-172-26-2-70     172.26.2.70      # 東京(server) = VPC
ip-172-26-3-146    172.26.3.146
lima-k3s-agent     192.168.105.2    # 自宅(lima) = vmnet
lima-k3s-agent-2   192.168.105.3
lima-k3s-agent-3   192.168.105.4
lima-k3s-agent-4   192.168.105.5

自宅ノード4台は今や互いの vmnet アドレス(192.168.105.x)で VXLAN を直接やり取りします — 0.5 ms。

§3 で開けた 8472 は — 閉じようとして実際の経路を見た

vmnet で自宅ノードの中は片付いたので、いよいよ §3 で原則を破って開けておいた 8472 を閉じる番でした。ところがいざ閉じようとすると怖くなりました — 閉じたらノード間の Pod 通信がまた切れないか。 §3 で「開けたら通った」と思っていたからです。

そこで閉じる前に、推測ではなく 実際の経路を見ました。 あるノードから別サイトのノードの Pod へ行く道を問えばいいだけです。

# 自宅(lima)ノードから東京(server)ノードの Pod へ
$ ip route get 10.42.0.235
10.42.0.235 dev tailscale0 table 52 src 100.84.x.x ...

dev flannel.1 ではなく dev tailscale0 でした。つまり クロスサイトの Pod トラフィックは flannel VXLAN(8472)ではなく Tailscale で直接流れていた のです。

仕組みはこうです。このクラスタは各ノードが 自分の pod CIDR(10.42.N.0/24)を Tailscale のサブネットルートとして広告 し、互いに受け入れる(accept-routes)ようになっています。だから遠隔ノードの Pod レンジ宛てのトラフィックは flannel を経由せず、暗号化された Tailscale(WireGuard)の上を直接通ります。これは k3s が分散・マルチクラウドのクラスタを Tailscale で束ねる公式のやり方でもあります(k3s — Distributed/multicloud, Tailscale — Subnet routers)。なので §2・§5 で見た「VXLAN を WireGuard の上にさらに包む二重カプセル化」は同じホスト(lima↔lima)の昔の経路の話であって、クロスサイトは WireGuard 一重(ひとえ) です。

経路を確認したので閉じました — 公開(0.0.0.0/0)ではなく VPC プライベート(172.26.0.0/16)に制限。そして閉じてから測り直しました。

クロスサイト (自宅→東京 Pod)   : 20〜26 ms   (Tailscale、そのまま)
自宅ノード同士 (lima↔lima)      : 0.4 ms      (vmnet、そのまま)
server 同士 (server↔server)     : 0.3 ms      (VPC VXLAN、そのまま)
6ノード Ready                   : 6/6

何も壊れませんでした。公開 8472 はノード間通信の実際の経路ではなく、ただの名残でした。 8472 は今も使われていますが — lima↔lima は vmnet、server↔server は VPC — どちらもプライベートネットワークの中だけです。

クロスサイトは Tailscale で流れていた — 公開 8472 は名残

なぜわざわざ閉じるのか。VXLAN は認証も暗号化もないプロトコル です(RFC 7348 のセキュリティ考慮事項も「VXLAN 自体は認証・暗号化を提供しない」と明記)。識別子は VNI だけで flannel の既定値が 1 なので、公開網で 8472 に届きさえすれば、誰でも Pod オーバーレイ(10.42.0.0/16)にパケットを注入できます。6443 には証明書・トークンというゲートがありますが、8472 にはそれすらありません。だからついでに apiserver(6443)・kubelet(10250)も公開網で閉じて VPC/tailnet に絞り、SSH(22)は tailnet 専用に制限しました。

「変えたら通った」が「その変更のおかげで通った」ではありません。 8472 を開けて通信が通ったのは事実ですが、実際にトラフィックを運んでいたのは Tailscale で、公開 8472 は最初からただの名残でした。推測で開け閉めする前に ip route get実際の経路を一度見ること — それが、高くつく露出を減らす一番安いやり方です。

残る限界 — クロスサイトは距離のぶん遠い

vmnet が直したのは 同じホストの中(自宅ノード同士) です。東京クラウド ↔ 札幌自宅の区間は物理的に離れているので Tailscale でつなぎ、その遅延(数十 ms)は、物理的な距離が生む、縮めようのない値です。

そこで 配置で補います — ノード間の同期通信が多い(レイテンシに敏感な)ワークロードは 自宅ノード群の中に集めて おきます。2回目で自宅ノードに付けておいた node-type=lima ラベルを nodeSelector で使います。

$ kubectl --context k3s-lightsail get nodes -L node-type
NAME               ...   NODE-TYPE
ip-172-26-2-70     ...   lightsail
ip-172-26-3-146    ...   lightsail
lima-k3s-agent     ...   lima
lima-k3s-agent-2   ...   lima
lima-k3s-agent-3   ...   lima
lima-k3s-agent-4   ...   lima

ワークロードのマニフェストではこう書きます(ラベルがなければ kubectl label node lima-k3s-agent node-type=lima で先に付けます)。

spec:
  template:
    spec:
      nodeSelector:
        node-type: lima       # この Pod 群は自宅ノード(vmnet、0.5ms)だけに

最終図 — 近いものは vmnet、遠いものは Tailscale、配置は nodeSelector

一つのクラスタでも、ノード間の遅延は均一ではありません。 速い区間(同じホスト = vmnet)と遅い区間(遠距離 = Tailscale)を認め、nodeSelector でワークロードを遅延に合わせて配置すれば、遅い区間を回避できます。

9. 用語整理

  • flannel / VXLAN — k3s 既定の CNI が flannel、既定バックエンドが VXLAN。ノード境界を越える Pod パケットを UDP(8472)でカプセル化して運ぶ。
  • VTEP / flannel.1 — VXLAN カプセルを包んで解く終端点。ノードごとに flannel.1 インターフェースとして存在する。
  • flannel の public-ip — 各ノードが「私の VXLAN はこのアドレスで受け取る」と広告する宛先。既定値はノードのデフォルトルートのインターフェース IP。
  • MTU — 一度に送れるパケットの最大サイズ。カプセル化するほど減る(イーサネット 1500 → VXLAN 1450 → Tailscale の上で 1230)。
  • 二重カプセル化 — VXLAN パケットをさらに WireGuard で包むこと。オーバーヘッド・MTU の制約・遅延が重なる。(このクラスタでは vmnet 適用前の lima↔lima でのみ起き、クロスサイトは該当なし。)
  • Tailscale / DERP — WireGuard ベースのメッシュ VPN。直結(P2P)ができないと DERP 中継サーバへ迂回する。
  • Tailscale サブネットルート — あるノードがあるレンジ(ここでは自分の pod CIDR)を --advertise-routes で広告し、別のノードが --accept-routes で受けて、そのレンジのトラフィックを tailnet(WireGuard)で運ぶこと。クロスサイトの Pod 通信はこの道を流れる。
  • NAT(endpoint-dependent mapping) — 宛先ごとにポートマッピングが変わる NAT。P2P 直結が難しく DERP に落ちる。
  • vmnet(vmnet.framework) — macOS が VM に NAT・ブリッジ・ホストネットワークを提供する Apple のフレームワーク。
  • socket_vmnetvmnet.framework を Unix ソケットで公開する root デーモン。VM は root なしでそのソケットにつなぎ、共有 LAN(192.168.105.0/24)を受けて、同じホストの VM 同士が直接 L2 で通信する。
  • Lima user-mode ネットワーク — Lima 既定のネットワーク(192.168.5.0/24 固定)。VM を互いに・ホストから隔離する。
  • node-type ラベル / nodeSelector — ノードに付けたラベル(lima/lightsail)。ワークロードを特定のノード群に配置するときに使う。

10. 次は

ノード間通信まで片付いて、いよいよこの6ノードの上にいろいろなサービスを安定して載せられるようになりました。

結局、Longhorn のおかげで見つかったこの重要な問題はうまく解決され、今も安定して運用中です。ただ、ノードのスペックの限界から Longhorn は諦めることにしました。もう少し余裕のある環境が整ったら、また入れてテストすることを宿題として残しておきます。

今回までに書いた内容で、ある程度いろいろなサービスを動かせるようになりましたが、最適化の問題やその他の細かい問題は、修正を重ねながら運用してきています。あとで記事がある程度まとまったら、こうした内容も集めて整理してみようと思います。

次回は CloudNativePG(CNPG) について話す予定です。ちょうどノード間のネットワーク問題も解決したので、内部でクラスタリングしてサービスを運用する練習と検証をしてみたくて CNPG を構成しました。その CNPG は今、いくつものサービスのメイン DB として活躍しています。

お忙しいなか、最後まで読んでくださってありがとうございます。


参考 / 出典