kubeadm+flannelでkubernetesクラスタのIPv6環境構築

# はじめに
これは[KMC Advent Calendar 2023](https://adventar.org/calendars/8840)の記念すべき1日目の記事です。
明日はtaiseiさんの「スイカゲームくらい自給自足すっか」です。

本記事ではIPv4/IPv6 DualStack環境のKuberneteクラスタを構築します。
構築した環境は[GitHub](https://github.com/segre5458/k8s-ipv6)に公開しています。

# 構成
```bash
NAME         STATUS   ROLES           AGE     VERSION   INTERNAL-IP      EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION      CONTAINER-RUNTIME
akatsuki-1   Ready    control-plane   3h13m   v1.28.2   192.168.20.121   <none>        Ubuntu 22.04.3 LTS   5.15.0-88-generic   cri-o://1.27.1
akatsuki-2   Ready    <none>          177m    v1.28.2   192.168.20.122   <none>        Ubuntu 22.04.3 LTS   5.15.0-88-generic   cri-o://1.27.1
akatsuki-3   Ready    <none>          177m    v1.28.2   192.168.20.123   <none>        Ubuntu 22.04.3 LTS   5.15.0-88-generic   cri-o://1.27.1
```
Master1台、Worker2台の計3台構成です。
OSはUbuntu22.04、コンテナランタイムはcri-o、CNIはflannelを使用しています。
Ubuntuのインストール、ネットワークの設定は完了し名前解決可能であることを前提としています。

# 事前準備
対象マシン: 全マシン

Kubernetesクラスタを構築するための各種パッケージのインストール等を行います[^1]。
一連の処理はAnsibleのRole化して[GitHub](https://github.com/segre5458/infra/blob/master/ansible/roles/k8s/tasks/main.yml)に公開しています。

## Swap無効化
まずスワップを無効にします
```bash
sudo swapoff -a
```
続いて/etc/fstabからスワップの設定を削除します

```bash:etc/fstab
# /dev/mapper/swap none swap defaults 0 0
```

## cri-oの設定とインストール
```bash
cat <<EOF | sudo tee /etc/modules-load.d/crio.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter
cat <<EOF | sudo tee /etc/sysctl.d/99-kubernetes-cri.conf
net.bridge.bridge-nf-call-iptables  = 1
net.ipv4.ip_forward = 1
net.bridge.bridge-nf-call-ip6tables = 1
EOF
sudo sysctl --system

cat <<EOF | sudo tee /etc/apt/sources.list.d/devel:kubic:libcontainers:stable.list
deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_22.04/ /
EOF
cat <<EOF | sudo tee /etc/apt/sources.list.d/devel:kubic:libcontainers:stable:cri-o:1.27.list
deb http://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable:/cri-o:/1.27/xUbuntu_22.04/ /
EOF
curl -L https://download.opensuse.org/repositories/devel:kubic:libcontainers:stable:cri-o:1.27/xUbuntu_22.04/Release.key | sudo apt-key --keyring /etc/apt/trusted.gpg.d/libcontainers.gpg add -
curl -L https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_22.04/Release.key | sudo apt-key --keyring /etc/apt/trusted.gpg.d/libcontainers.gpg add -

sudo apt update
sudo apt install -y cri-o cri-o-runc
sudo systemctl daemon-reload
sudo systemctl enable crio
sudo systemctl start crio
```

## Kubernetesインストール
```bash
sudo apt update && sudo apt install -y apt-transport-https curl
curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg |  apt-key add -
cat <<EOF |  tee /etc/apt/sources.list.d/kubernetes.list
deb https://apt.kubernetes.io/ kubernetes-xenial main
EOF

sudo apt update
sudo apt install -y kubelet kubeadm kubectl
cat <<EOF |  tee /etc/default/kubelet
KUBELET_EXTRA_ARGS=--container-runtime-endpoint='unix:///var/run/crio/crio.sock'
EOF
```

# IPv6 Forwardingの有効化
対象マシン: 全マシン

ここからがIPv6向けの設定になります。

IPv6 パケット転送を有効にします
```bash
sudo sysctl -w net.ipv6.conf.all.forwarding=1
```

# クラスタ作成
対象マシン: Master

Masterでクラスタを作成します[^2]
```yaml:init/akatsuki-1.yaml
apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
clusterName: akatsuki
networking:
  podSubnet: 10.245.0.0/16,2001:db8:43:0::/56
  serviceSubnet: 10.97.0.0/16,2001:db8:43:1::/112
---
apiVersion: kubeadm.k8s.io/v1beta3
kind: InitConfiguration
localAPIEndpoint:
  advertiseAddress: 192.168.20.121
  bindPort: 6443
nodeRegistration:
  kubeletExtraArgs:
    node-ip: 192.168.20.121,240b:250:9800:4a20::121
```
podSabunetには `10.245.0.0/16` と `2001:db8:43:0::/56` 、serviceSubnetには `10.97.0.0/16` と `2001:db8:43:1::/112` を指定しています。
localAPIEndpointは1つのアドレスしか設定することはできないので、ここはIPv4アドレスを指定しています。
node-ipには各ノードのIPアドレスを指定します。IPv4/v6 DualStackの場合はここでv4とv6のアドレスを指定します。今回は `192.168.20.121` と `240b:250:9800:4a20::121` を指定しています。

ファイル作成後、クラスタを初期化します。
```bash
sudo kubeadm init --config=init/akatsuki-1.yaml
```

```bash
Your Kubernetes control-plane has initialized successfully!
```
と表示されれば成功です。

その下に表示されるコマンドを実行します。
```bash
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
```

また、その下のworkerのjoinコマンドを控えておいてください
```bash
kubeadm join 192.168.20.121:6443 --token clvldh.vjjwg16ucnhp94qr \
        --discovery-token-ca-cert-hash sha256:a4863cde706cfc580a439f842cc65d5ef112b7b2be31628513a9881cf0d9fe0e
```

# クラスタへの参加
対象マシン: Worker2台

続いてWorker nodeに対してクラスタへの参加の処理を行います。

Workerの内1台で `init/akatsuki-2.yaml` というファイルを作成します。
```yaml:init/akatsuki-2.yaml
apiVersion: kubeadm.k8s.io/v1beta3
kind: JoinConfiguration
discovery:
  bootstrapToken:
    apiServerEndpoint: 192.168.20.121:6443
    token: "clvldh.vjjwg16ucnhp94qr"
    caCertHashes:
    - "sha256:a4863cde706cfc580a439f842cc65d5ef112b7b2be31628513a9881cf0d9fe0e"
    # change auth info above to match the actual token and CA certificate hash for your cluster
nodeRegistration:
  kubeletExtraArgs:
    node-ip: 192.168.20.122,240b:250:9800:4a20::122
```
apiServerEndpoinには `init/akatsuki-1.yaml` で `advertiseAddress` に指定したものを設定してください。
tokenとcaCertHashesは先ほど控えていただいた `kubeadm join` に記載されているものを設定してください。
node-ipにはWorkerのIPアドレスを指定して下さい。

以下のコマンドでクラスタに参加できます。
```bash
sudo kubeadm join --config=init/akatsuki-2.yaml
```

もう1台のWorkerにも同様のファイルを作成します。
```yaml:init/akatsuki-3.yaml
apiVersion: kubeadm.k8s.io/v1beta3
kind: JoinConfiguration
discovery:
  bootstrapToken:
    apiServerEndpoint: 192.168.20.121:6443
    token: "clvldh.vjjwg16ucnhp94qr"
    caCertHashes:
    - "sha256:a4863cde706cfc580a439f842cc65d5ef112b7b2be31628513a9881cf0d9fe0e"
    # change auth info above to match the actual token and CA certificate hash for your cluster
nodeRegistration:
  kubeletExtraArgs:
    node-ip: 192.168.20.123,240b:250:9800:4a20::123
```

以下のコマンドでクラスタに参加します。
```bash
sudo kubeadm join --config=init/akatsuki-3.yaml
```

# flannelの設定
対象マシン: Master1台

CNIとしてflannelの設定を行います[^3]。
Master上でflannelのconfigを取得します。
```bash
wget https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml -O init/kube-flannel.yaml
```

IPv6を有効にするため一部設定を変更します。
`net-conf.json` の設定を行っている箇所があるため、そこを以下のように変更します。
```yaml:init/kube-flannel.yaml
  net-conf.json: |
    {
      "Network": "10.245.0.0/16",
      "IPv6Network": "2001:db8:43:0::/56",
      "EnableIPv4": true,
      "EnableIPv6": true,
      "Backend": {
        "Type": "vxlan"
      }
    }
```
NetworkとIPv6Networkには `init/akatsuki-1.yaml` で `podSubnet` に指定したものを設定してください。
`"EnableIPv6": true` とすることでIPv6を有効にします(デフォルトではfalseです)。

最後にflannelをapplyします。
```bash
kubectl apply -f init/kube-flannel.yml
```


`kubectl get nodes -o wide` の結果、StatusがReadyになってるはずです。
```bash:kubectl get nodes -o wide
NAME         STATUS   ROLES           AGE     VERSION   INTERNAL-IP      EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION      CONTAINER-RUNTIME
akatsuki-1   Ready    control-plane   3h13m   v1.28.2   192.168.20.121   <none>        Ubuntu 22.04.3 LTS   5.15.0-88-generic   cri-o://1.27.1
akatsuki-2   Ready    <none>          177m    v1.28.2   192.168.20.122   <none>        Ubuntu 22.04.3 LTS   5.15.0-88-generic   cri-o://1.27.1
akatsuki-3   Ready    <none>          177m    v1.28.2   192.168.20.123   <none>        Ubuntu 22.04.3 LTS   5.15.0-88-generic   cri-o://1.27.1
```
INTERNAL-IPにはIPv4アドレスしか付いていませんが、 `kubectl describe nodes/akatsuki-1` にIPv6アドレスがついていると思います。
```bash:kubectl describe nodes/akatsuki-1
Addresses:
  InternalIP:  192.168.20.121
  InternalIP:  240b:250:9800:4a20::121
  Hostname:    akatsuki-1
```

以上でIPv4/IPv6 DualStackのKubernetes環境の構築は完了です。

# IPv6のサービスを作成
ここからはIPv6アドレスでWebページを作成する方法を説明します。

今回はHelmとfluxを用います。
Webページのデプロイにはmetallbとingress-nginxを用います。
本質的な部分はmetallbとingress-nginxのServiceの設定になりますので、ご自身の環境にあった方法でDeployしてください。

ファイル構成の詳細については割愛します。
同様の環境を構築したい場合は https://github.com/segre5458/k8s-ipv6 をCloneしてください。

## metallb
metallbでLoadBalancingを行います。
基本的にほぼすべてのサービスはデフォルトでIPv4しかサポートしていないため、手動で変更する必要があります。
```yaml:metallb/service.yaml
apiVersion: v1
kind: Service
metadata:
  annotations:
    meta.helm.sh/release-name: metallb
    meta.helm.sh/release-namespace: metallb-system
  labels:
    app.kubernetes.io/instance: metallb
    app.kubernetes.io/managed-by: Helm
    app.kubernetes.io/name: metallb
    helm.toolkit.fluxcd.io/name: metallb
    helm.toolkit.fluxcd.io/namespace: metallb-system
  name: metallb-webhook-service
  namespace: metallb-system
spec:
  internalTrafficPolicy: Cluster
  ipFamilies:
  - IPv4
  - IPv6
  ipFamilyPolicy: PreferDualStack
  ports:
  - port: 443
    protocol: TCP
    targetPort: 9443
  selector:
    app.kubernetes.io/component: controller
    app.kubernetes.io/instance: metallb
    app.kubernetes.io/name: metallb
  sessionAffinity: None
  type: ClusterIP
status:
  loadBalancer: {}
```
重要なのは以下の部分です
```yaml
  ipFamilies:
  - IPv4
  - IPv6
  ipFamilyPolicy: PreferDualStack
```
ipFamilyPolicyにはSingleStack/PreferDualStack/RequireDualStackを指定することができます。
DualStackにするにはPreferDualStackを指定すれば良いです。
具体的な設定内容は以下のとおりです。
詳しくは[Opensourcetechブログ](https://www.opensourcetech.tokyo/entry/20230315/1678813284)や[IPv4/IPv6 dual-stack | Kubernetes](https://kubernetes.io/docs/concepts/services-networking/dual-stack/#services)を参照してください。
- ipFamilyPolicy
    - SingleStack：IPv4 or IPv6のどちらか
    - PreferDualStack：DualStack or SingleStackのどちらか
    - RequireDualStack：DualStack(それ以外は失敗する)
- ipFamilies
    - IPv4/IPv6のどちらか、もしくは両方の指定が可能
    - IPv4/IPv6のどれが適用されるかは、クラスタの構成とipFamilyPolicyで決定される

IP Advertiseの設定は以下の通りになります[^4]。
```yaml:metallb-ip/ip_pool.yaml
---
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: metallb
  namespace: metallb-system
spec:
  addresses:
  - 192.168.20.231-192.168.20.235
  - 240b:250:9800:4a20:100::1-240b:250:9800:4a20:100::5
  autoAssign: true
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: metallb
  namespace: metallb-system
spec:
  ipAddressPools:
  - metallb
```
addressにはIPv4とIPv6両方を指定することができます。

## ingress-nginx
続いてingressの設定を行います。

```yaml:ingress-nginx/values.yaml
controller:
  watchIngressWithoutClass: true
  extraArgs:
    enable-ssl-passthrough: true
  service:
    enableHttp: true
    enableHttps: true
    ipFamilyPolicy: PreferDualStack
    ipFamilies:
    - IPv4
    - IPv6
    ports:
      http: 80
      https: 443
    targetPorts:
      http: http
      https: https
    type: LoadBalancer
```
serviceのIPFamilyPolicy,ipFamiliesにもmetallbのserviceと同様に設定します。
metallbのserviceに `autoAssign: true` の設定が入っているため、ingress-nginxでIPアドレスを指定する必要はありません。

applyした結果は以下の通りになります。

```bash:kubectl get service -n ingress-nginx
kubernetes-admin@akatsuki kube | segre@akatsuki-1 | 05:03:52
NAME                                 TYPE           CLUSTER-IP     EXTERNAL-IP                                PORT(S)                      AGE
ingress-nginx-controller             LoadBalancer   10.97.168.10   192.168.20.231,240b:250:9800:4a20:100::1   80:31865/TCP,443:31116/TCP   48m
ingress-nginx-controller-admission   ClusterIP      10.97.76.0     <none>                                     443/TCP                      54m
```
EXTERNAL-IPにIPv4/v6アドレスが1つずつ付いているのが確認できます。

## Webアプリ
最後にWebアプリをデプロイします。

```yaml:webapp/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp
spec:
  ingressClassName: "nginx"
  rules:
  - host: myapp.marokiki.net
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: myapp-service
            port:
              number: 80
```
hostは `myapp.marokiki.net` にしています。

applyした結果は以下の通りになります。
```bash:kubectl get ingress -A
NAMESPACE   NAME    CLASS   HOSTS                ADDRESS                                    PORTS   AGE
webapp      myapp   nginx   myapp.marokiki.net   192.168.20.231,240b:250:9800:4a20:100::1   80      3h4m
```
ADDRESSにIPv4/v6アドレスが1つずつ付いているのが確認できます。

## レコード追加
DNSサーバーに先ほど作成したhost名のAAAAレコードを追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3245951/fde06666-3e1f-5d80-25c9-35202fbdebde.png)

http://myapp.marokiki.net で公開されていることが確認できます！
```bash:curl myapp.marokiki.net
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>
```
```bash:nslookup myapp.marokiki.net
名前:    myapp.marokiki.net
Address:  240b:250:9800:4a20:100::1
```
# おわりに
お疲れ様でした！
メインのKubernetesクラスタではIPv6に対応するためにkubeadm resetを何度も行いました......
デフォルトで対応してくれることを願うばかりです。
今回の方法では毎度Serviceを手直しする必要がありますが、Dynamic Admission Control等を使うともう少しうまくできるかもしれません。
今後もKubernetesでIPv6をうまく使っていきたいと思います。

# 参考文献
[^1]: [Raspberry Pi 4にKubernetesを入れる](https://qiita.com/greenteabiscuit/items/6fce805185350eab6f7a)
[^2]: [Dual-stack support with kubeadm](https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/dual-stack-support/)
[^3]: [flannel configuration](https://github.com/flannel-io/flannel/blob/master/Documentation/configuration.md)
[^4]: [Metallb Configuration](https://metallb.universe.tf/configuration/)