AWSのEC2インスタンスでKubernetesを作ってみる

# 概要

AWSのEC2インスタンスで、kubeadmとAnsible Playbookを使ってKubernetesを作ってみました。手順を本記事にまとめます。
EKSだとControl Planeの料金だけでひと月に73ドル(2024年6月23日時点で)掛かり結構高額なので、なるべく安くKubernetesを作って遊びたいというのがモチベーションです。

# 本記事の対象者

* マネージドなKubernetesサービス(EKS,AKS,GKE等)**以外**でK8sを作ってみたい
* KubernetesをMinikubeやkindではなく、物理マシンでControl PlaneとWorker Nodeで分けて自力で作ってみたい
* しかしながら自宅にKubernetesの構築を手軽に試せるような物理マシンがない

# Kubernetes全般の環境情報

* OS : Ubuntu 22.04
* コンテナランタイム : containerd
* Kubernetesのバージョンは以下

```
$ kubectl version
Client Version: v1.29.6
Kustomize Version: v5.0.4-0.20230601165947-6ce0bf390ce3
Server Version: v1.29.6

$ kubelet --version
Kubernetes v1.29.6

$ kubeadm version
kubeadm version: &version.Info{Major:"1", Minor:"29", GitVersion:"v1.29.6", GitCommit:"062798d53d83265b9e05f14d85198f74362adaca", GitTreeState:"clean", BuildDate:"2024-06-11T20:22:13Z", GoVersion:"go1.21.11", Compiler:"gc", Platform:"linux/amd64"}
```

# Kubernetes構築手順

## (1) EC2インスタンスの作成

あくまでお試し用なので、Control PlaneとWorker Node用のインスタンスをそれぞれ1台作ることにします。

### 24/06/01追記

TerraformでControl PlaneとWorker NodeのEC2インスタンスを作るソースを以下GitHubにアップしました。もしよろしければお使いください。

https://github.com/showchan33/k8s-ec2-terraform/blob/main/README.ja.md

### インスタンス情報

#### AMI
* ID
    * ami-07c589821f2b353aa
* name
    * amazon/ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-20231207
#### インスタンスタイプ
* Control Plane
    * t3a.small : 2vCPU, 2GiBメモリ
* Worker Node
    * t3.micro : 2vCPU, 1GiBメモリ

[kubeadmのインストール](https://kubernetes.io/ja/docs/setup/production-environment/tools/kubeadm/install-kubeadm/)にも記載があるように、Control Planeを構築するEC2インスタンスには2GiBのメモリが必要でした。試しにそれよりもメモリが小さい``t3.nano``や``t3.micro``でやってみましたが、``kubeadm init``の実行途中にメモリ不足とみなされてエラーになってしまうようです。
一方でワーカーノードだと2GiB未満のメモリのマシンでも``kubeadm join``に成功しました。（作れはしても、すぐにリソースが枯渇してしまうはずですが...）

## (2) セキュリティグループ設定

Kubernetes公式の以下のドキュメントに従って設定します。

https://kubernetes.io/ja/docs/reference/networking/ports-and-protocols/

若干自信がないですが、私はControl PlaneとWorker Nodeに対して以下のように設定しました。

| 通信の方向 | プロトコル | ポート番号 | 用途 | 通信許可するIP範囲 | 適用対象 |
| --- | --- | --- | --- | --- | --- |
| inbound | TCP | 6443 | kube-apiserverで利用 | 全てのノード | Control Planeのみ |
| inbound | TCP | 10250 | kubeletで利用 | 全てのノード | Control Plane, Worker両方 |
| inbound | TCP | 30000-32767 | NodePortのServiceリソースで利用 | 全てのノード | Control Plane, Worker両方 |
| outbound | 全て | 全て | インスタンスから外部にアクセス | 0.0.0.0/0 | Control Plane, Worker両方 |

※ 上記はKubernetesのシステムで最低限開ける必要があるポートです。最終的にはCNI(Container Network Interface)のインストールも必要になりますが、使うCNIによって適宜開けるプロトコルとポートの追加が必要です。

## (3) Kubernetesの構築

[kubeadmのインストール](https://kubernetes.io/ja/docs/setup/production-environment/tools/kubeadm/install-kubeadm/)を参考にして、Ansibleでスクリプトを作りました。詳しくは筆者作成の下記GitHubをご覧ください。

https://github.com/showchan33/k8s-setup

こちらのツールを使うにはAnsible Playbookを実行するマシンが別途必要となります。実行するマシンが（ネットワーク的な意味での）存在する場所によって、それに応じたSSHのセキュリティルールの追加やインターネットゲートウェイの作成等が必要となります。

[README.md](https://github.com/showchan33/k8s-setup/blob/main/README.md)に従い事前準備ができたら、Ansible PlaybookでControl PlaneとWorker Nodeを構築します。

#### Control Planeの構築
```
ansible-playbook site_control-plane.yml
```

#### Worker Nodeの構築
```
ansible-playbook site_worker.yml
```

構築完了後、Control PlaneにSSH等で接続すると、以下のようにKubernetesが出来上がっているのを確認できます。今回ケースでは、Control Planeのホスト名は``ip-10-0-0-5``です。

```
(Control Planeで実行)
$ kubectl get pod -A
NAMESPACE     NAME                                  READY   STATUS    RESTARTS   AGE
kube-system   coredns-5dd5756b68-r9gdq              0/1     Pending   0          41m
kube-system   coredns-5dd5756b68-zhksc              0/1     Pending   0          41m
kube-system   etcd-ip-10-0-0-5                      1/1     Running   0          42m
kube-system   kube-apiserver-ip-10-0-0-5            1/1     Running   0          42m
kube-system   kube-controller-manager-ip-10-0-0-5   1/1     Running   0          42m
kube-system   kube-proxy-96bcm                      1/1     Running   0          41m
kube-system   kube-proxy-ghl74                      1/1     Running   0          39m
kube-system   kube-scheduler-ip-10-0-0-5            1/1     Running   0          42m
```

### kubectlを実行するクライアントの変更方法について（読み飛ばしてもOK）

Control PlaneのEC2インスタンスに毎回SSH接続してkubectlを実行したり、Helmをインストールしたりするのは結構面倒です。k8sのAPIサーバにアクセスするために必要なツールが既にインストールされているマシンがあるならば、それを使いたくなるでしょう。
そこで以下のサイトを参考にすると、Control Planeにある ``~/.kube/config``を別マシンの ``~/.kube/config``にマージすることができ、Control PlaneのEC2インスタンス以外からkubectlを実行できるようになります。

https://qiita.com/TsuyoshiUshio@github/items/e1c4eca7e601bbeec088#%EF%BC%92%E3%81%A4%E3%81%AE-config-%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AE%E3%83%9E%E3%83%BC%E3%82%B8

例えば ``kubeconfig1``と``kubeconfig2``という二つのkubeconfigファイルがあったとすると、以下のコマンドで一つのkubeconfigにマージできます。

```
KUBECONFIG=kubeconfig1:kubeconfig2 |
kubectl config view --merge --flatten > ~/.kube/config
```

しかし今回のケースではこれだけでは不十分でした。APIサーバのadvertise addressがプライベートIPの ``10.0.0.5`` であるにも関わらず、Control Planeと`kubectl`を実行したいクライアントはインターネットでのみ繋がっているからです。
苦肉の策ですが、この問題はクライアント上で、以下のようにiptablesを使って「Control PlaneのパブリックIPを ``10.0.0.5`` にNATする」ことで解決可能です。

```
sudo iptables -t nat -A OUTPUT -p tcp \
-d 10.0.0.5 -j DNAT \
--to-destination [Control PlaneのパブリックIP]
```

これで、Control Planeとプライベートネットワークでつながっていないマシンからもkubectlコマンド等で接続可能になります。

ちなみに設定を消すときは ``-A OUTPUT`` の部分を ``-D OUTPUT`` に変更します。

## (4-1) CNIのインストール（Flannelの場合）

Kubernetes内のネットワークを設定するためのツールをインストールします。今回はFlannelを使ってみます。（後述しますがCalicoだとうまくいかずに断念しました...）

https://github.com/flannel-io/flannel

### セキュリティグループにFlannel関連のルールを追加

[こちら](https://github.com/coreos/coreos-kubernetes/blob/master/Documentation/kubernetes-networking.md#port-allocation)を参考に、Flannelがノード間で通信を行えるようにするため、以下のルールを追加します。

| 通信の方向 | プロトコル | ポート番号 | 用途 | 通信許可するIP範囲 |
| --- | --- | --- | --- | --- |
| inbound | UDP | 8472 | flannel overlay network - vxlan backend | 全てのノード |

※ Flannelの設定によっては8472ではなく、8285を開ける必要があるかもしれません

### Flannelのデプロイ

Flannelのマニフェスト``kube-flannel.yml``で編集が必要な部分があるため、一旦Control Planeにマニフェストをダウンロードします。

```
(Control Planeで実行)
sudo apt -y install wget
wget https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml
```

ダウンロードした``kube-flannel.yml``で、PodのCIRDの値を``kubeadm init``コマンドで設定したものに変更します。
```
vi kube-flannel.yml
```

```yaml:kube-flannel.yml
...
  net-conf.json: |
    {
      "Network": "10.244.0.0/16", # 設定したPodのCIDRに変更
      "Backend": {
        "Type": "vxlan"
      }
    }
...
```

修正したFlannelのマニフェストをapplyします。

```
$ kubectl apply -f kube-flannel.yml
(実行結果)
namespace/kube-flannel created
serviceaccount/flannel created
clusterrole.rbac.authorization.k8s.io/flannel created
clusterrolebinding.rbac.authorization.k8s.io/flannel created
configmap/kube-flannel-cfg created
daemonset.apps/kube-flannel-ds created
```
Flannelのリソースが新たにデプロイされているのを確認できれば成功です。
```
$ kubectl get pod -n kube-flannel
NAME                    READY   STATUS    RESTARTS   AGE
kube-flannel-ds-c2d6d   1/1     Running   0          37s
kube-flannel-ds-cntpk   1/1     Running   0          37s
```

以上でEC2インスタンス上にKubernetesを構築できました🙌

## (4-2) CNIのインストール（Ciliumの場合）（24/06/08 追記）

Ciliumもインストールできることを確認できました。
セキュリティグループは[こちら](https://docs.cilium.io/en/stable/operations/system_requirements/#firewall-rules)を参考に以下のルールを追加します。

| 通信の方向 | プロトコル | ポート番号 | 用途 | 通信許可するIP範囲 |
| --- | --- | --- | --- | --- |
| inbound | UDP | 8472 | VXLAN overlay | 全てのノード |
| inbound | TCP | 4240 | health checks | 全てのノード |

あとは[公式ドキュメント](https://docs.cilium.io/en/stable/installation/k8s-install-kubeadm/)等にしたがってCiliumをインストールします。
ちなみに私は、自分が過去に投稿した以下の記事のやり方を参考にしてCiliumをインストールできました。

https://qiita.com/showchan33/items/f336d46af383d4c746d4

注意点ですが、Workerノードのインスタンスタイプは少なくとも``t3.micro``（2vCPU, 1GiBメモリ）相当のスペックはあった方がよさそうです。``t3.micro``よりもスペックが一段階低い``t3.nano``（2vCPU, 0.5GiBメモリ）の場合、ciliumのDaemonSetがいつまで経ってもReadyにならずに正常稼働しませんでした。

（24/06/16 追記）
上記に関してですが、正常稼働しなかったのはスペックの問題ではなく、CiliumのデフォルトのCIDRが``10.0.0.0/8``で（[ドキュメント](https://docs.cilium.io/en/stable/network/concepts/ipam/cluster-pool/#check-for-conflicting-node-cidrs)にそう書いてあった...）、EC2インスタンスに使っていたサブネット``10.0.0.0/27``と完全にカブっていたことが原因だったかもしれません。今回のケースでは、Helmチャートのvalueで例えば以下のように、``10.0.0.0/27``と重複しないものに変更する必要がありました。

```yaml
ipam:
  operator:
    clusterPoolIPv4PodCIDRList: ["192.168.0.0/16"] # CIRDはあくまで一例です
```

# 構築で行き詰まった点

ほぼ筆者の備忘メモなので読み飛ばしていただいてもいいです。

## containerdの設定不備でkube-apiserverが立ち上がらない...

今回はコンテナランタイムでcontainerdを使いましたが、aptでcontainerdをインストールしただけのデフォルトの状態だと、kube-systemのPodが以下のようにリスタートを繰り返してKubernetesが正常稼働しない事態になりました。

```
$ kubectl get po -A
NAMESPACE     NAME                                  READY   STATUS    RESTARTS       AGE
kube-system   coredns-5dd5756b68-8jgt4              0/1     Pending   0              3m25s
kube-system   coredns-5dd5756b68-9wq97              0/1     Pending   0              3m25s
kube-system   etcd-ip-10-0-0-5                      0/1     Running   106 (9s ago)   2m25s
kube-system   kube-apiserver-ip-10-0-0-5            0/1     Running   80 (25s ago)   4m40s
kube-system   kube-controller-manager-ip-10-0-0-5   0/1     Running   96 (9s ago)    2m43s
kube-system   kube-proxy-8bwp7                      1/1     Running   3 (3s ago)     3m25s
kube-system   kube-scheduler-ip-10-0-0-5            0/1     Running   108 (9s ago)   5m15s
```

調査と試行錯誤した結果、「今回使ったAMIのデフォルト設定だとsystemdがcgroupドライバーを使うようにする必要があるのに、containerdの設定はそうなっていない」ことがわかりました。そこで以下の対応を行い解決できました。

#### containerdの設定ファイルを作成

```
$ sudo su -
# containerd config dump > /etc/containerd/config.toml
# logout
```

#### 作成した設定ファイルで、``SystemdCgroup``の値が``false``になっているのを``true``に変更する

```
sudo vi /etc/containerd/config.toml
```

以下のように変更
```toml:/etc/containerd/config.toml
...
            SystemdCgroup = true
...
```

#### containerdおよびkubeleteのサービスをrestart

```
sudo systemctl restart containerd
sudo systemctl restart kubelet
```



### 参考情報　

https://stackoverflow.com/questions/76041535/control-plane-pods-in-restart-loop-after-installation-with-kube-adm

https://kubernetes.io/ja/docs/setup/production-environment/container-runtimes/#systemd-cgroup%E3%83%89%E3%83%A9%E3%82%A4%E3%83%90%E3%83%BC%E3%82%92%E6%A7%8B%E6%88%90%E3%81%99%E3%82%8B

## CNIがCalicoだと異なるノードのPodが通信できない（未解決）

[Quickstart for Calico on Kubernetes](https://docs.tigera.io/calico/latest/getting-started/kubernetes/quickstart)や[筆者記事](https://qiita.com/showchan33/items/e0cce753d9c7c646a983)にしたがってCNIをCalicoで作ろうとしましたが、どうやっても異なるノードのPod同士が通信できなくて断念しました...

試してみたことは以下です。
* Calicoバージョンを以下のように変えてみる
    * v3.27.0(現時点の最新), v3.26.4, v3.24.6
* Installationリソースのパラメータ ``spec.calicoNetwork.ipPools[].encapsulation``を``VXLANCrossSubnet``から``IPIP``に変えてみる

怪しいと思っている部分はネットワークのルーティングに関する設定で、以下に具体的に説明します。

既にCalicoの構築に成功しているK8sクラスター上では、``vxlan.calico``というネットワークインターフェースがあり、ここを経由するネットワーク経路が以下のように定義されていました。

```
$ sudo ip route | grep vxlan
10.128.75.0/26 via 10.128.75.1 dev vxlan.calico onlink
...
```

一列目のCIDR（10.128.75.0/26等）は、PodのCIDRの範囲に含まれています。

このルートがEC2インスタンスで構築した場合だと存在せず、代わりに以下のように ``ens5``というAWSサブネットのインターフェースに紐づけられています。

```
$ sudo ip route | grep onlink
192.168.91.64/26 via 10.0.0.5 dev ens5 proto 80 onlink
```

ちなみにEC2インスタンスのノード上にも``vxlan.calico``のインターフェースは作られているのですが、それを使うルートは一つも定義されていません。

ヤマ勘なので間違っている可能性が高いのですが「本当はこのルートは``ens5``ではなくて、``vxlan.calico``に作られるべきなのでは？」というのが現状の考えです。

これに関連しそうな内容をGitHubのissueで見つけました。まだあまりやり取りされていないようなので、今後の解決を期待したいです。

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