自宅オンプレKubernetes構築記

ジョブカン事業部のアドベントカレンダー20日目です。

https://qiita.com/advent-calendar/2024/jobcan

ジョブカン採用管理で開発を担当しています、@maeda3net です。

自宅で[Kubernetes](https://kubernetes.io/ja/)（以下、k8s）環境を構築してみた記事になります。
基本的には公式の手順に則る形にはなりますが、私の求めた構成に合わせていくつかの変更を加えています。
つまずいた点なども記載しているので、これからk8sを構築しようとしている方の参考になれば幸いです。

## 背景
ジョブカン採用管理では基盤としてk8sを利用しています。
日々の運用において決まった手順の実行などを行なっていますが、私自身k8sへの深い理解があるわけではなく即応性にかけるなと感じることが多々ありました。
気軽に試せる環境があれば、もっとk8sについて理解を深められるのではないかと思い立ち、手始めにk8s環境を構築してみることにしました。

## いざ構築

構築にあたり、利用するマシンと想定する構成は下記の通りです。
1コントロールプレーン、1ワーカーノード構成を目指します。

|マシン|役割|OS|
|:-----|:----|:----|
|Lenovo Thinkpad E595|コントロールプレーン|Ubuntu 24.04.1（Noble Numbat）|
|Raspberry Pi 5|ワーカーノード|Debian 12（Bookworm）|

![オンプレk8s構成枠.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3621684/61932a5e-4c6c-41c5-2ef9-99e33129b344.png)

`172.22.4.x` なネットワークはインターネットと疎通可能です。
制約の都合上、このネットワークではIPを固定するのが困難なため、別途ノード間通信用のLANとして `192.168.2.0/24` のLANを構築しました。
また、k8sのPodネットワークとしては `10.244.0.0/16` としています。

### 手順
基本的にk8s公式ページの手順に則り進めます。

https://kubernetes.io/ja/docs/setup/production-environment/

大雑把な流れとしては下記の通りとなります。
1. containerd（CRI）のセットアップ
1. kubeadmのインストール
1. クラスタのセットアップ
    * calico（CNI）のセットアップ

### containerdのセットアップ
まずはCRIとしてcontainerdをセットアップします。

#### コンテナランタイム共通設定
overlayfsのモジュールやPod間通信のフィルタリングに必要なbr_netfilterを有効化し、設定をします。
```shell
$ cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
$ sudo modprobe overlay
$ sudo modprobe br_netfilter
$ cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF
$ sudo sysctl --system
```

#### インストール
下記手順より、`apt-get` を使った方法で進めます。

https://github.com/containerd/containerd/blob/main/docs/getting-started.md

この場合、Dockerのaptリポジトリを使うことになりますが、最終的にインストールするのは必要な `containerd.io` のみインストールします。
```shell
$ sudo apt install -y containerd.io
```

#### 設定
次にcontainerdの設定を進めます。
`disabled_plugins` に `cri` が含まれないようにします。
今回は `disabled_plugins` ごとコメントアウトしてしまいます。
また、`runc` が `systemd cgroup` ドライバを使うように設定をします。
```toml
# disabled_plugins = ["cri"]
...
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
  ...
  [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
    SystemdCgroup = true
```
上記設定が完了したらcontainerdをを再起動して設定を反映します。
```shell
$ sudo systemctl restart containerd
```

### kubeadmのインストール
公式の手順に則り、aptパッケージとしてインストールします。
最後に不用意に更新されないために `apt hold` してバージョンを固定します。
```shell
$ sudo apt-get update
$ sudo apt-get install -y apt-transport-https ca-certificates curl gpg
$ curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.31/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
$ echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.31/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
$ sudo apt-get update
$ sudo apt-get install -y kubelet kubeadm kubectl
$ sudo apt-mark hold kubelet kubeadm kubectl
```
また、swapも無効化しておきます。
```shell
$ sudo swapoff -a
$ sudo sed -i '/ swap / s/^/$/' /etc/fstab
```

ここまでの手順はk8sのノード共通で行います。

---

### クラスタの作成
ここからはノードごとの設定となります。

#### コントロールプレーンの初期化
Podネットワークとして `10.244.0.0/16`、コントロールプレーンのAPIサーバーへのadvertise addressは `192.168.2.10` とします。
```shell
$ sudo kubeadm init --pod-network-cidr=10.244.0.0/16 --cri-socket=/run/containerd/containerd.sock --apiserver-advertise-address=192.168.2.10
```

:::note info
当初は `--cri-socket` オプションをつけていなかったのですが、これがないとPodの展開時などに `/var/run/containerd/containerd.sock` を参照しようとするも存在せず、うまく動作しないといった事象に遭遇しました。
そのためinit時の引数で予め設定するようにしました。
:::

上記初期化コマンドが正常終了すると、クラスタ設定をホームディレクトリにコピーするよう指示があるため従います。
```shell
$ mkdir -p $HOME/.kube
$ sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
$ sudo chown $(id -u):$(id -g) $HOME/.kube/config
```
また、初期化コマンド正常終了時に下記のようなコマンドが出力されますが、ワーカーノードのjoinに必要なのでメモります。
```shell
kubeadm join 192.168.2.10:6443 --token SOME_TOKEN_HERE \
        --discovery-token-ca-cert-hash sha256:[SOME_SHA256_HERE]
```
#### CNIのセットアップ
クイックスタートの手順に従いcalicoをセットアップします。

https://docs.tigera.io/calico/latest/getting-started/kubernetes/quickstart

基本的に手順の通り進めているため、URL直指定でのリソース作成をしていますが、中身の確認は事前にしています。
1. オペレータのセットアップ
    ```shell
    $ kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.29.1/manifests/tigera-operator.yaml
    ```
1. 必要なカスタムリソースのセットアップ 
    ここではPodネットワークのCIDR設定が入っており、今回は `10.244.0.0/16` で構成しているため、URL直指定での実行ではなく、ダウンロードの上編集して適用しています。
    ```shell
    $ wget https://raw.githubusercontent.com/projectcalico/calico/v3.29.0/manifests/custom-resources.yaml
    ```
    custom-resource.yamlの一部抜粋
    ```yaml
    apiVersion: operator.tigera.io/v1
    kind: Installation
    metadata:
      name: default
    spec:
      calicoNetwork:
        ipPools:
        - name: default-ipv4-ippool
          blockSize: 26
          cidr: 10.244.0.0/16
          ...
    ```
    リソースの作成をし、立ち上がりを確認します。
    ```shell
    $ kubectl create -f custom-resources.yaml
    $ watch kubectl get pods -n calico-system
    ```

#### 立ち上がらないcalico
ここでcalicoのPodのうち1つについて起動が一向に完了しませんでした。Podをdescribeしてみると下記エラーが出ていました。
```
Warning  NetworkNotReady  3m19s (x25 over 4m7s)  kubelet            network is not ready: container runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady message:Network plugin returns error: cni plugin not initialized
```

下記を参考にcontainerdを再起動してしばらく経つと当該Podの起動に成功しました。

https://github.com/projectcalico/calico/issues/8694 

---

### ワーカーの作成
コントロールプレーン作成時に出力されたコマンドを実行して設定完了です。
```
kubeadm join 192.168.2.10:6443 --token SOME_TOKEN_HERE \
        --discovery-token-ca-cert-hash sha256:[SOME_SHA256_HERE]
```

#### 立ち上がらないkube-proxy
join時にkube-proxyがContainerCreatingで止まってしまっていました。
詳細を見てみると `/run/systemd/resolve/resolv.conf` が解決されておらず立ち上がらないようでした。
```shell
$ kubectl get po  -n kube-system
NAME                                    READY   STATUS              RESTARTS   AGE
coredns-7c65d6cfc9-2l5dl                1/1     Running             0          14m
coredns-7c65d6cfc9-qjs8h                1/1     Running             0          14m
etcd-k8s-master-01                      1/1     Running             0          14m
kube-apiserver-k8s-master-01            1/1     Running             0          14m
kube-controller-manager-k8s-master-01   1/1     Running             0          14m
kube-proxy-j8llm                        0/1     ContainerCreating   0          3m34s
kube-proxy-nq4xs                        1/1     Running             0          14m
kube-scheduler-k8s-master-01            1/1     Running             0          14m
$ kubectl describe po kube-proxy-j8llm -n kube-system
Name:                 kube-proxy-j8llm
Namespace:            kube-system
...
Events:
  Type     Reason                  Age                  From               Message
  ----     ------                  ----                 ----               -------
  Normal   Scheduled               3m53s                default-scheduler  Successfully assigned kube-system/kube-proxy-j8llm to k8s-worker-01
  Warning  FailedCreatePodSandBox  8s (x18 over 3m53s)  kubelet            Failed to create pod sandbox: open /run/systemd/resolve/resolv.conf: no such file or directory
```
こちらは下記を参考にkubeletの設定で `/etc/resolv.conf` を使用するようにし、kubeletを再起動することで解消しました。

https://notes.myuu.dev/kubelet-resolv

```diff
# /var/lib/kubelet/config.yaml
- resolvConf: /run/systemd/resolve/resolv.conf
+ resolvConf: /etc/resolv.conf
```
以下は修正後のログです。
正常にイメージのプルができるようになっていることが確認できます。
```shell
Events:
  Type     Reason                  Age                     From               Message
  ----     ------                  ----                    ----               -------
  Normal   Scheduled               9m28s                   default-scheduler  Successfully assigned kube-system/kube-proxy-j8llm to k8s-worker-01
  Warning  FailedCreatePodSandBox  3m52s (x26 over 9m28s)  kubelet            Failed to create pod sandbox: open /run/systemd/resolve/resolv.conf: no such file or directory
  Normal   Pulling                 7s                      kubelet            Pulling image "registry.k8s.io/kube-proxy:v1.31.3"
```

### INTERNAL IPの調整
`kubectl get nodes -o wide` を実行すると、INTERNAL-IPがいずれも `172.22.4.x` を指していました。
`192.168.2.0/24` でないとIPの固定化は行なっていないので困ります。

調べてみると、どうやら `KUBELET_CONFIG_ARGS` に `--node-ip` オプションを追加することで指定できるようなので、オプションの追加をしました。 

https://stackoverflow.com/questions/54942488/how-to-change-the-internal-ip-of-kubernetes-worker-nodes

ノードごとに `systemctl edit kubelet` でkubeletのサービスファイルの当該環境変数にオプションを追加してkubeletを再起動して完了です。
```diff
# /etc/systemd/system/kubelet.service.d/override.conf
[Service]
Environment="KUBELET_KUBECONFIG_ARGS=--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubernetes/kubelet.conf"
-Environment="KUBELET_CONFIG_ARGS=--config=/var/lib/kubelet/config.yaml"
+Environment="KUBELET_CONFIG_ARGS=--config=/var/lib/kubelet/config.yaml --node-ip=192.168.2.10"
# This is a file that "kubeadm init" and "kubeadm join" generates at runtime, populating the KUBELET_KUBEADM_ARGS variable dynamically
EnvironmentFile=-/var/lib/kubelet/kubeadm-flags.env
# This is a file that the user can use for overrides of the kubelet args as a last resort. Preferably, the user should use
# the .NodeRegistration.KubeletExtraArgs object in the configuration files instead. KUBELET_EXTRA_ARGS should be sourced from this file.
EnvironmentFile=-/etc/default/kubelet
ExecStart=
ExecStart=/usr/bin/kubelet $KUBELET_KUBECONFIG_ARGS $KUBELET_CONFIG_ARGS $KUBELET_KUBEADM_ARGS $KUBELET_EXTRA_ARGS
```

### いざHello World
Podが展開できるようになったはずなので、Hello Worldで動作を確認します。
```shell
$ kubectl run hello-world --image hello-world --restart=Never
pod/hello-world created
$ kubectl get po
NAME          READY   STATUS    RESTARTS   AGE
hello-world   1/1     Running   0          55s
$ kubectl logs pod/hello-world
Hello from Docker!
This message shows that your installation appears to be working correctly.
To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (arm64v8)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.
To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash
Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/
For more examples and ideas, visit:
 https://docs.docker.com/get-started/
```
無事、Hello Worldコンテナの出力が確認できました。
これで構築完了です。

## まとめ
当初想定より構築に手間取りつつも、先人の知恵を借りつつ一通り動作する環境を整えることができました。 
実は途中で手順を飛ばしてエラーで連携できなかったり、動的IP環境のままで進めてノード間の通信ができなくなりリセットするなどしていましたが、それらの経験も含めてk8sのベースとなる構成要素について理解が深まりました。 
構築はまだまだ入り口に過ぎませんが、今回作成したクラスタを用いて運用をしてみたり異常な状況を作ってトラブルシュートしてみたりして知見を貯めていこうと思います。

## お知らせ
DONUTSでは新卒中途問わず積極的に採用活動を行っています。
我々ジョブカン事業部も、一緒に働くエンジニアを募集しています。

https://www.donuts.ne.jp/recruit/

https://recruit.jobcan.jp/donuts/show/B001/1396297