tailscale と headscale を使っておうち-クラウド横断 IPv4/IPv6 デュアルスタック k3s クラスタを作る

## TL; DR

- AWS Lightsail を複数台使って K3s のクラスタを運用してたよ
- 円安で高いから、自宅のノードをうまくクラスタに組み込んで Lightsail のノード数を減らすことに挑んだよ
- tailscale の VPN で Lightsail のノードと自宅のノードを結んで IPv4 と IPv6 のデュアルスタック環境の K3s クラスタを組んでみたよ
- MTU の設定や tailscale の UDP GRO で少しハマりポイントがあったけど何とかなったよ
- 少しだけ表示に時間はかかるようになったけど思ったよりも短くて、うまくいったよ
  - 現在この環境で運用しているサイト: [私のホームページ(react)](https://www.soora.jp/)、[私のブログ(WordPress)](https://blog.soora.jp/)
- 書いてから公開するまで大分寝かせたので，一部若干情報が古い可能性があるよ

## 完成予想図

![k3s-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259685/7d1698c0-a777-4342-5ea5-76c909f599aa.png)


## 背景

現在 K3s クラスタを AWS の Lightsail を複数契約して構築していますが、学生ゆえに(円安により)資金が足りず計算資源が慢性的に不足しノードが頻繁に NotReady となっており、サービスが停止してしまう状況です。そこで自宅とクラウドを VPN で接続し、先日購入した N100 のマシンをノードとしてクラスタに組み込むことで計算資源の増設することを試みます。

VPN は wireguard や SoftEther などが候補でしたが、以前参加したインターンで一緒だった友人に [tailscale](https://tailscale.com/) を教えてもらい、これが動的 IP アドレス環境にも対応していることがわかったので、おうち-クラウド間の VPN には tailscale を使ってみることにします。**自宅のような動的な IP アドレス環境でも利用できる**ことに強く惹かれました。ただ可能な限りベンダロックイン(のような状況)は避けたいので、今回 tailscale のコーディネーションサーバはオープンソース実装である [headscale](https://headscale.net/) の方を利用してみます。

## 環境

### おうち

- 回線は 1Gbps の光回線で動的 IPv4 と IPv6 のデュアルスタック
- ノードは以下の1台
  - home0: 4コア4スレッド CPU, 16GB RAM、1TB SSD, Ubuntu Server 24.04

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">もう一台増やした！ <a href="https://t.co/GJyLJZeSYA">pic.twitter.com/GJyLJZeSYA</a></p>&mdash; Sora (@soora_jp) <a href="https://twitter.com/soora_jp/status/1770097430048239769?ref_src=twsrc%5Etfw">March 19, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

### クラウド

- AWS Lightsail
- ノードは以下の2台
  - vps0: 2コア2スレッド CPU, 2GB RAM, 60GB Storage, Amazon Linux 2023
  - vps1: 2コア2スレッド CPU, 2GB RAM, 60GB Storage, Ubuntu 22.04

OS を分けているのは私自身がどちらでも使えるように勉強するためなので、どちらかに統一してももちろん大丈夫です。

## 構築

### Lightsail のノード作成

ここはそう難しくないので飛ばします。わからない方は[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/lightsail/latest/userguide/how-to-create-amazon-lightsail-instance-virtual-private-server-vps.html)を参考にしてみてください。

### headscale とは

headscale のインストールの前に tailscale と headscale について少し説明します。

まず tailscale は wireguard という VPN のプロトコルを利用した VPN サービスであり、アーキテクチャとしてはコントロールプレーンとデータプレーンに別れています。コントロールプレーンでは [coordination server](https://tailscale.com/glossary/coordination-server) を中心として暗号鍵やトポロジの変化、アクセスポリシの変化の情報を VPN 内の各デバイスがやり取りし、データプレーンでは目的の通信を (基本的には) デバイス間で P2P で行います。これによって効率的に、そして容易に VPN が利用できるようになっています。

では headscale は何なのかというと、この coordination server をオープンソースで実装したものであり、tailscale ではクラウドサービスとして提供されている coordination server が self-hosted な環境で利用できるようになります (なので基本的には各デバイスでは tailscale を利用し、セットアップ時に coordination server として headscale のサーバを指定します)。なお headscale は tailscale とは異なるコミュニティで開発されています。

### headscale のインストール

それでは実際に headscale のインストールをしていきます。クラウド側のノードの方がより安定しているので、headscale はクラウド側のノードに導入します。また Docker などのインストールもしたくないので、今回はコンテナを使わずに直にインストールを行います。

```sh
HEADSCALE_VERSION=v0.22.3

curl -L https://github.com/juanfont/headscale/releases/download/v0.22.3/headscale_0.22.3_linux_amd64 -o headscale
sudo install headscale /usr/bin/headscale

# set completion (optional)
echo 'source <(headscale completion bash)' >> ~/.bashrc
source ~/.bashrc

sudo mkdir -p /etc/headscale
sudo curl -L https://raw.githubusercontent.com/juanfont/headscale/v0.22.3/config-example.yaml -o /etc/headscale/config.yaml
sudo adduser --no-create-home --system --user-group --shell /sbin/nologin headscale
```

`/etc/headscale/config.yaml` に config ファイルができるので、各自の環境に応じて設定を変更してください (本当は載せたいですが、セキュリティにも関わるため、念の為非公開とさせてください)。

:::note warn
ここでハマる人はあまりいないと思いますが、headscale のホスト名は K3s 上の DNS 権威サーバで運用する予定のないものにすることをおすすめします。何らかのトラブル等によりすべてのノードに同時に接続できなくなった場合、名前解決に失敗し、tailscale ネットワークとクラスタの再構築が不可能になります (一応 hosts ファイルで対応する手もあるっちゃあります)。
::::

設定の変更後は以下のコマンドで設定内容が正しいかの確認が可能です。

```sh:
sudo headscale configtest
```

headscale は unit ファイルも公開されているので、以下のようにすることで、systemd にサービスとして登録できます。

```sh
sudo curl \
  -L https://raw.githubusercontent.com/juanfont/headscale/v0.22.3/docs/packaging/headscale.systemd.service \
  -o /etc/systemd/system/headscale.service
sudo systemctl start headscale
sudo systemctl status headscale
sudo systemctl enable headscale
```

最後に k3s のノードがネットワークに接続するためのノード3台分の token を発行します。出力された token はあとで tailscale のセットアップを行う際に利用するので、記録しておいてください。

```sh
$ sudo headscale user create vps0
$ sudo headscale user create vps1
$ sudo headscale user create home0
$ sudo headscale preauthkeys create -u vps0 --expiration 1d
$ sudo headscale preauthkeys create -u vps1 --expiration 1d
$ sudo headscale preauthkeys create -u home0 --expiration 1d
```

### tailscale のインストール

次に各ノードに tailscale のクライアントをインストールしていきます。インストール方法は[公式ドキュメント](https://tailscale.com/kb/1031/install-linux)の通りですが、coodination server と token を設定するためのオプションを加えます。
\<headscale-hostname\> には config.yaml で設定した FQDN を、
また \<generated-auth-key\> には先程作成した token を入力してください。

```sh
$ curl -fsSL https://tailscale.com/install.sh | sh
$ sudo tailscale up \
  --login-server <headscale-hostname> \
  --auth-key <generated-auth-key> \
  --force-reauth
```

これで3台のノードが1つの VPN 上で相互に接続できるようになりました。試しに ping を打ってみます。IP アドレスは各自の環境によって変更してください。`tailscale status`を実行することで、ネットワーク上にある他のノードの IP アドレスが確認できます。

```sh
$ ping 100.64.0.a
PING 100.64.0.a (100.64.0.a) 56(84) bytes of data.
64 bytes from 100.64.0.a: icmp_seq=1 ttl=127 time=11.2 ms
64 bytes from 100.64.0.a: icmp_seq=2 ttl=127 time=11.9 ms
64 bytes from 100.64.0.a: icmp_seq=3 ttl=127 time=11.6 ms
^C
--- 100.64.0.a ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 11.214/11.555/11.900/0.280 ms
```

疎通も確認できました！

#### ハマりポイント① : MTU の設定

MTU を適切に設定しないとパケロスが発生し、ノード間通信に失敗してしまう場合があります。今回は以下のようになるように tailscale0 の MTU を設定しました。単位はすべてオクテット(バイト)です。

1. enp1s0(物理NIC): 1492
2. tailscale0: 1408
3. flannel.1: 1358
4. flannel.v6.1: 1338
5. cni0: 1300

tailscale0 の MTU を変更するには /etc/default/tailscaled に以下の行を追加し，`systemctl restart tailscaled` でサービスの再起動を行います。その際 flannel の仮想 NIC が消えてしまうことがあるため、消えてしまった場合は k3s のサービスの再起動も合わせて行ってください。

```env
TS_DEBUG_MTU=1408
```

::::note info
ポイントは tailscale0 の MTU で、デフォルトでは 1280 に設定されていますが、これでは IPv6 でパケロスが発生してしまいます。

IPv6 では最小 MTU として 1280 が規定されており、アプリケーションは 1280 オクテットまでのパケット(ペイロードではない)であれば送信できることが RFC8200 で保証されています。したがって単に Tailscale を利用するのであれば tailsacle0 が 1280 オクテットでも通信可能ですが、今回は上位の flannel で vxlan が動作しているため、アプリケーションが 1280 オクテットを送信してしまうと、vxlan に関するヘッダが上位の仮想 NIC である flannel.1/flannelv6.1 で付加されることで、tailscale0 到着時に 1280 オクテットを超えてしまうことになります。ここでフラグメント化が禁止されているパケットは tailscale0 ではもちろんフラグメント化されないので、パケットドロップが発生することになります。

flannel(vxlan) で付加されるヘッダとしては以下のものがあります。

- 上位パケットの MAC ヘッダ(14オクテット)
- VXLAN ヘッダ(8オクテット)
- UDP ヘッダ(8オクテット)
- IP ヘッダ(IPv4: 20オクテット, IPv6: 40オクテット)

したがって、アプリケーションレベルで 1280 オクテットの MTU をサポートしようとすると，tailscale0 の MTU は IPv4 のみの場合は 50 オクテット増の 1330 オクテット以上、IPv6 を利用する場合は 70 オクテット増の 1350 オクテット以上を設定する必要があるのです。

一方で大きければよいという問題でもなく、もちろん物理回線にも MTU があります。今回自宅で利用している回線の MTU は 1492 オクテットとなっており、こちらからも逆算して tailscale0 の MTU の上限を求める必要があります。

ここで tailscale0 で付加されるヘッダとしては以下のものがあります。

- Wireguard ヘッダ (16オクテット)
- Wireguard authentication tag (16オクテット)
- UDP ヘッダ (8オクテット)
- IP ヘッダ (IPv4: 20オクテット, IPv6: 40オクテット)

したがって、tailscale0 の MTU は IPv4 のみの場合は 60 オクテット減の 1432 オクテット以下、IPv6 を利用する場合は 80 オクテット減の 1412 オクテット以下を設定する必要があります。

今回はデュアルスタック環境なので 1412 オクテットを設定したいのですが、Wireguard がパケットの暗号化を行う際に暗号化パケットデータを**16オクテット単位に切り上げてしまうケースが確認された**ため、念の為 1412 オクテット以下で最大の 16 の倍数である 1408 オクテットを tailscale0 の MTU として設定することにします。

(Wireguard はストリーム暗号を利用しているので通常はパケット長は変化しないはずなのですが……原因がわかる方はぜひ教えてください)

![datalenがなぜかすべて16の倍数になっているの図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259685/28d6b3ea-8647-b1f9-2e44-ac9bbd3c1ccc.png)
::::

::::note info
ちなみに flannel.1 や flannelv6.1 は tailscale0 の MTU を見て自動的に MTU を設定してくれます。その値は IPv4 用の仮想 NIC である flannel.1 が tailscale0 の MTU より 50 オクテット小さい 1358 オクテット、IPv6 用の仮想 NIC である flannel-v6.1 が同様に 70 オクテット小さい 1338 オクテットとなります。
::::

::::note info
さらにちなみに物理 NIC の MTU が 1492 オクテットなのは、自宅の回線が PPPoE を利用しているため(IPv6でも)です。1500 オクテットから PPP ヘッダ分の 8 オクテットが減ってしまいます。なおここの値に関してはご家庭で契約している回線業者が情報を出していると思うので、各自で確認してください。また物理 NIC の MTU の変更は ip コマンドで可能です。
::::

### K3s のインストール

それでは作成した VPS 上で K3s のクラスタを作っていきます。ポイントとしては、各サーバの Internal な IP アドレスとして tailscale の IP アドレスを指定することです。

::::note info
実は K3s ではまだ experimental ですが tailscale を用いた環境構築をサポートしており、[ドキュメント](https://docs.k3s.io/networking/distributed-multicloud#integration-with-the-tailscale-vpn-provider-experimental)に従えば自動的に tailscale のセットアップも行ってくれるようですが、ここではすべて手動で設定していきます。
::::

K3s では k8s の master が server、node が agent と呼ばれます。今回は先程 headscale をインストールした AWS のノードである vps0 を server に、もう一台の AWS のノードである vps1 と自宅のノード home0 を agent にします。 

ポイントは node-ip と node-external-ip の設定において、node-ip に tailscale の IP アドレスを、node-external-ip にノードが持つ外部と疎通可能な IP アドレスを指定することです。こうすることでノード間通信は tailscale のネットワークを利用し、外部からのトラフィックは物理 NIC から受け付けてくれるようになります。

`--flannel-ipv6-masq` を指定することで IPv6 でも ClusterIP と external-ip の NAT を行ってくれるようになり、`--flannel-iface` を指定することで flannel は tailscale にフレームを送信してくれるようになります。

なお自宅のノードでは外部からのトラフィックは受け付けないため、node-external-ip の設定は行っていません。

::::note warn
これからインストールされる方は大丈夫ですが、過去にインストールされた方は k3s (厳密には flannel) のバージョンに注意してください。flannel のバージョンが 0.25.2 より前のものを利用している場合、[こちらの Issue](https://github.com/k3s-io/k3s/issues/9957) にある通り、IPv6 環境で再起動ごとに MAC アドレスが変化してしまい、正常に通信できなくなるケースがあります。対応している k3s のバージョンは、1.29.6、1.30.2 以降になります。
::::

```sh:on vps0 (k3s-server)
$ curl -sfL https://get.k3s.io | \
  K3S_KUBECONFIG_MODE="644" \
  K3S_NODE_NAME=vps0 \
  INSTALL_K3S_EXEC="\
    --flannel-ipv6-masq \
    --flannel-iface tailscale0 \
    --node-ip=<vps0-tailscale-ipv4-address>,<vps0-tailscale-ipv6-address> \
    --node-external-ip=<vps0-lightsail-instance-ipv4-addres>,<vps0-lightsail-instance-ipv6-address> \
    --advertise-address=<vps0-tailscale-ipv4-address> \
    --cluster-cidr=10.42.0.0/16,2001:cafe:42::/56 \
    --service-cidr=10.43.0.0/16,2001:cafe:43::/112
  " \
  sh -s - server --cluster-init
$ export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
# set completion (optional)
$ echo "source <(kubectl completion bash)" >> ~/.bashrc
$ source ~/.bashrc
```

```sh:on vps1 (k3s-agent)
$ curl -sfL https://get.k3s.io | \
  K3S_URL=https://<vps0-tailscale-ipv4-address>:6443 \
  K3S_TOKEN=<k3s-token>\
  K3S_NODE_NAME=vps1 \
  INSTALL_K3S_EXEC=" \
    --flannel-iface tailscale0 \
    --node-ip=<vps1-tailscale-ipv4-address>,<vps1-tailscale-ipv6-address> \
    --node-external-ip=<vps1-lightsail-instance-ipv4-address>,<vps1-lightsail-instance-ipv6-address> \
  " \
  sh -s - agent
```

```sh:on vps1 (k3s-agent)
$ curl -sfL https://get.k3s.io | \
  K3S_URL=https://<vps0-tailscale-ipv4-address>:6443 \
  K3S_TOKEN=<k3s-token>\
  K3S_NODE_NAME=home0 \
  INSTALL_K3S_EXEC=" \
    --flannel-iface tailscale0 \
    --node-ip=<home0-tailscale-ipv4-address>,<home0-tailscale-ipv4-address> \
  " \
  sh -s - agent
```

:::note info
Amazon Linux では k3s のインストール時に、SELinux の設定が必要となります。以下ようにパッケージをインストールすることで設定が可能です。なおバージョンについてはご自身の Amazon Linux のバージョンを確認して選択してください。以下は Amazon Linux 2023 の例です。

```sh
$ sudo dnf install -y https://github.com/k3s-io/k3s-selinux/releases/download/v1.5.stable.1/k3s-selinux-1.5-1.el8.noarch.rpm
```
::::

以下のコマンドでクラスタ内のノードの一覧と設定された IP アドレスが確認できます。

```sh
$ kubectl get nodes -o wide
NAME      STATUS   ROLES                       AGE    VERSION        INTERNAL-IP                      EXTERNAL-IP                                 OS-IMAGE                       KERNEL-VERSION   CONTAINER-RUNTIME
home0     Ready    <none>                      134d   v1.29.4+k3s1   <home0-tailscale-ipv4-address>   <home0-local-ipv4-address>                  Ubuntu 24.04 LTS               6.8.**********   containerd://1.7.*******
vps0      Ready    control-plane,etcd,master   134d   v1.29.4+k3s1   <vps0-tailscale-ipv4-address>    <vps0-lightsail-instance-ipv6-address>      Amazon Linux 2023.4.20240513   6.1.**********   containerd://1.7.*******
vps1      Ready    <none>                      134d   v1.29.4+k3s1   <vps1-tailscale-ipv4-address>    <vps1-lightsail-instance-ipv6-address>      Ubuntu 22.04.1 LTS             6.5.**********   containerd://1.7.*******
```

#### ハマりポイント② : UDP GRO によるパケットの結合

Linux Kernel のバージョンが 6.2 以降の場合、UDP GRO が利用されるようになり[本来はスループットが向上する](https://tailscale.com/blog/quic-udp-throughput)のですが、tailscale の UDO GRO と VXLAN (flannelで利用) の相性が悪く、[ノード間の通信に失敗してしまうバグがあります](https://github.com/tailscale/tailscale/issues/11026)。UDP GRO を無効化することで通信が行えるようになります。該当するノードでは UDP GRO を無効化するために、/etc/default/tailscaled ファイルに以下の行を追加します。

```
TS_TUN_DISABLE_UDP_GRO=true
```

例として IPv4 と IPv6 のデュアルスタック環境において、サービスディスカバリのための DNS のクエリで A と AAAA レコードの問い合わせがほぼ同時に行われた場合、受信時に誤ってパケットが結合されてしまい、名前解決・サービスディスカバリに失敗してしまいます。

## クラスタ完成！

これまでの手順で晴れてクラウドと自宅をまたいだ K3s クラスタの構築ができました！
あとは好きなサービスをデプロイして確認してみてください！

デモとして、この環境で公開しているサービスをいくつか紹介します。

- [私のホームページ (react)](https://www.soora.jp/)
- [私のブログ (WordPress)](https://blog/soora.jp/)

私のブログは WordPress を用いており、データベースはクラウド側ノードで、バックエンドの一部の機能が自宅ノードで動作しています。バックエンドで通信が往復するため少し表示に時間はかかってしまいますが、思っていたよりも小さく許容範囲内なので、個人で遊ぶ分には十分でコスパは最高です！

## スループット測定

とはいえ、オーバヘッド大きそうだけど、実際のところスループットはどうなの？という話ですが、(面倒ですが) 測定してみます。iperf3 を用いて、home0, home0 をそれぞれクライアントとサーバとし、UDP モードでいい感じのトラフィック量を流します。

比較としては、ベアメタル環境 (仮想化など一切行わず、実 NIC の IP アドレスに直接)、tailscale 環境、Pod 間通信の環境の3つの環境で、IPv4 と IPv6 のそれぞれで比較してみます。

::::note info
tailscale 環境と Pod 間通信の環境では、サーバを IPv4 アドレスで指定しても、実際の通信は tailscale が IPv6 を用いて行っている点に注意が必要です。
::::

| 環境 | IP バージョン | 下りスループット (Mbps) | 上りスループット (Mbps) |
|:----:|:--:|--:|--:|
| ベアメタル | 4| 837 | 522 |
| ベアメタル | 6 | 802 | 567 |
| tailscale | 4 | **306** | **380** |
| tailscale | 6 | **322** | **328** |
| Pod 間通信 | 4 | 372 | 378 |
| Pod 間通信 | 6 | 358 | 348 |

結果、ベアメタルと比較すると、tailscale と Pod 間通信ともにやはりスループットは極端に低下しました。tailscale よりも Pod 間通信の方が性能がいいのは、測定時間が10秒と短く変動も数十Mbps単位で発生していたので、恐らく測定時のネットワーク状況による変動だと考えられますが、これだけ上回っていると何か他の要因もあるかもしれません。

また今回の負荷テストで分かったのは、今回はサーバ側は2コアの環境でしたが、Pod 間通信において大きなトラフィック (1Gbps) を流すとサーバが停止してしまうことがありました。恐らく tailscale と iptables, flannel(vxlan) の処理でキャパシティオーバしてしまうのだと思います。tailscale を挟むと上りと下りのスループットが同程度になったのも CPU がボトルネックになっていることの現れではないでしょうか。受信側が4コアの自宅ノードときは問題なかったので、この環境を安定して動かすには少なくとも3コア以上、4コア程度は必要そうです。

## まとめ

今回、K3s のクラスタを AWS Lightsail の VPS のノードと自宅のノードを混ぜて tailscale 上で構築し、うまく動作させることができました！

## 今後の展望

- 自宅ノードを増やす
- 自宅ノードを server にして HA 構成にする
- CA/CD を導入する
- K3s ではなく本家の k8s を (再度) 動かせるようにする

## 参考文献

### k3s インストール時のパラメータ設定

- [Kubernetesクラスターを「クラウド↔自宅ネットワーク」間で作る（Tailscale編）](https://qiita.com/showchan33/items/7500bcb73b10be437e49)
- [Tailscale を使っている状態で k3s クラスタを構築したい](https://tech.natsuneko.blog/entry/2024/04/05/220429)

### MTU の設定

- [k3s-io/k3s: k3s won't startup with tailscale if ipv6 applied #4743
](https://github.com/k3s-io/k3s/issues/4743)
- [tailscale/tailscale: IPv6 problems with vxlan and tailscale #3575
](https://github.com/tailscale/tailscale/issues/3575)
- [MTU tweaking, r/Tailscale, reddit.com](https://www.reddit.com/r/Tailscale/comments/10z8b9n/mtu_tweaking/)