TerraformとAnsibleでK8sクラスタ自動構築

# はじめに

こんにちは。Soliです。
[はじめてのアドベントカレンダー Advent Calendar 2024](https://qiita.com/advent-calendar/2024/first-time) 18日目の記事として、自宅Kubernetesクラスタを構築するために整備した、TerraformとAnsibleについて、書いていこうと思います。

現在、3つのVPSを契約しており、その上で分散SNSのMisskeyを中心にいくつかのアプリケーションが動いております。
これらのメンテナンスにかける手間が非常にかさんでおり、かねてからKubernetes基盤への移行を検討していました。

そんなことを思いながら就職し、配属先でKubernetesとTerraform, Ansibleをガッツリ触ることになり、これは勉強するのにもちょうどいいタイミングだと思ったので、本格的に移行プロジェクトを開始しました。

まずは第一歩として
**Kubernetesクラスタを、Proxmox基板上に、手作業なしで構築する**
というマイルストーンを設定し、無事クリアしましたので、その成果物をまとめていこうというのが今回の記事です。

:::note info
記事を書く動機

TerraformとAnsibleでK8sクラスタ作るなんてググればサクッと出てくるやろ〜と思っていた時期がありました。
思ったほど無かったので、無いなら作れの精神で書き始めました。
:::

# クラスタ概要

それでは、本題に入ります。まず、作成するProxmoxクラスタと、Kubernetesクラスタは以下のような構成になります。

Proxmoxクラスタは、物理ノード3台とします。
うち2台にはコントロールプレーン, ワーカー, ロードバランサーノードの3VM、1台にはコントロールプレーン, ワーカーノードの2VMを作成します。

Kubernetesのコントロールプレーンの冗長化には最低3つのノードが必要なので、各Proxmoxノードひとつずつ置きます。
コントロールプレーンの冗長化には、ロードバランサーが必要になるので、HAProxyを用います。
また、Keepalivedを用いて、ロードバランサー自体の冗長化も行います。

![Kubernets構成図](https://media.soli0222.com/polestar/d356e077-effe-416f-bc35-fec01c91cf0c.png)

:::note info
pke？ oky?

pke は Polester Kubernetes Engine の略です。
前述したMisskeyサーバーの名前がPolesterなので、GKEやVKE, OKEに習って命名しました。

oky はクラスタのリージョン名のような物です。
ホロライブの[猫又おかゆ](https://www.youtube.com/@NekomataOkayu)さんの配信を見ながら作業をしていたので、okyクラスタとしました。
:::

そして、これを構築するためのTerraformとAnsibleのファイルは以下のリポジトリにあります。

https://github.com/Soli0222/pke

では、TerraformとAnsibleそれぞれの内容について解説していきます。

# Terraform

TerraformではProxmox上のVM作成と最低限の設定を行います。
具体的には、ホスト名, ユーザー名, 鍵, IPアドレスの設定をしています。

Terraformのコマンドはすべて、`pke/terraform`内で実行します。
なので、まず読み込まれるのは`main.tf`です

```terraform:main.tf
module "oky" {
  source = "./modules/oky"
}
```

こちらには、`modules`ディレクトリ内に配置されたクラスタごとのディレクトリを読み込んでいます。クラスタの追加があった場合には、ここにモジュールの追加と`modules`ディレクトリ内に追加するだけという仕組みです。

では、次に`oky`モジュールを見ていきます。ここには、Proxmoxに接続するための情報が入った`variables.tf`、Proxmoxに接続し、VM作成の前準備をする`main.tf`、作成するVMごとのファイルが存在します。

まずは`modules/oky/variables.tf`を見ていきます。
ここには、Proxmoxに接続するための情報と、VMに設定するための公開鍵のパスを設定します。

```terraform:modules/oky/variables.tf
variable "proxmox_config" {
  sensitive = true
  type = object({
    endpoint = string
    username = string
    password = string
    pub_key_file = string
  })
  default = {
    endpoint = "https://proxmox_addr:port"
    username = "proxmox username"
    password = "proxmox password"
    pub_key_file = "/path/to/your/public_key.pub"
  }
}
```

これでProxmoxを操作出来るようになったので、次は`modules/oky/main.tf`を見ていきます。

ここでは、`variables.tf`にて設定した情報をもとに、Proxmoxへ接続、公開鍵を`ssh_public_key`に入れ込みます。
また、OSのイメージを各Proxmoxノードにダウンロードします。このイメージを元にVMを作成します。

```terraform:modules/oky/main.tf
terraform {
  required_providers {
    proxmox = {
      source = "bpg/proxmox"
      version = "0.66.2"
    }
  }
}

provider "proxmox" {
  endpoint = "${var.proxmox_config.endpoint}"
  username = "${var.proxmox_config.username}"
  password = "${var.proxmox_config.password}"
  insecure = true
}

data "local_file" "ssh_public_key" {
  filename = "${var.proxmox_config.pub_key_file}"
}

resource "proxmox_virtual_environment_download_file" "oky-pve-1-image" {
  content_type = "iso"
  datastore_id = "local"
  node_name    = "oky-pve-1"

  url = "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
}

resource "proxmox_virtual_environment_download_file" "oky-pve-2-image" {
  content_type = "iso"
  datastore_id = "local"
  node_name    = "oky-pve-2"

  url = "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
}

resource "proxmox_virtual_environment_download_file" "oky-pve-3-image" {
  content_type = "iso"
  datastore_id = "local"
  node_name    = "oky-pve-3"

  url = "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
}
```

これで、VMを作れるようになったので、実際にVMを作成するファイルを見ていきます。

ファイルはVMの数だけ存在し、今回は`pke-${クラスタ名}-${ノードロール}-${番号}.tf`という命名規則で付けています。ここでは、コントロールプレーン用のノード1番目、つまり`pke-oky-cp-1.tf`を見ていきます。

ここでは、VM名やCPUのコア数, メモリ, ストレージ, IPアドレス, VMのOS上に作るユーザーの設定を行います。

```terraform:pke-oky-cp-1.tf
resource "proxmox_virtual_environment_vm" "pke-oky-cp-1" {
  name      = "pke-oky-cp-1"
  node_name = "oky-pve-1"

  agent {
    enabled = false
  }
  stop_on_destroy = true

  startup {
    order      = "3"
    up_delay   = "60"
    down_delay = "60"
  }

  cpu {
    cores = 4
  }

  memory {
    dedicated = 4096
  }

  disk {
    datastore_id = "local-lvm"
    file_id = proxmox_virtual_environment_download_file.oky-pve-1-image.id
    interface    = "virtio0"
    iothread     = true
    discard      = "on"
    size         = 50
  }

  initialization {
    ip_config {
      ipv4 {
        address = "192.168.20.51/24"
        gateway = "192.168.20.1"
      }
    }
    
    user_account {
      username = "ubuntu"
      keys     = [trimspace(data.local_file.ssh_public_key.content)]
    }
  }

  network_device {
    bridge = "vmbr0"
  }

}

```

これをVMごとに名前や、VMを作成するノード、各要素の値を変えて作成しています。
なので、`modules/oky`ディレクトリはこのようになっています。

```
% tree .
.
└── oky
    ├── main.tf
    ├── pke-oky-cp-1.tf
    ├── pke-oky-cp-2.tf
    ├── pke-oky-cp-3.tf
    ├── pke-oky-lb-1.tf
    ├── pke-oky-lb-2.tf
    ├── pke-oky-wk-1.tf
    ├── pke-oky-wk-2.tf
    ├── pke-oky-wk-3.tf
    ├── variables.tf
    └── variables.tf.example
```

一度作ってしまえば、いつでも破壊と再生を繰り返せますし、特定VMだけ消したいときは、対応するファイルを消して`terraform plan`と`terraform apply`をすればいいし、VMの追加もファイルを追加するだけです。便利ですね。

これで、VMの作成はできましたので、次はVMの詳細な設定とKubernetesクラスタの作成をするためのAnsibleを見ていきます。

# Ansible

AnsibleではVMに対して必要な設定の投入、HAProxy, Keepalived, CRI-O, Kubernetesのインストールを行ったうえで、Kubernetesクラスタの構築、Calicoのインストールを行います。  
Terraformのように、Ansible関連のファイルはすべて`pke/ansible`に置いていきます。

## インベントリ

まず作成するファイルは、管理対象のホストを記述したインベントリファイルです。
ここにホスト名とIPアドレス、各ホストが属するグループを書きます。

グループは実行する対象をグルーピングしたもので、例えばロードバランサーのグループを作っておけば、このグループを指定するだけで、ロードバランサーのホストだけに作業を行うことができます。

```:hosts/pke-oky/inventory
[pke-oky-vm]
pke-oky-lb-1 ansible_host=192.168.20.31
pke-oky-lb-2 ansible_host=192.168.20.32
pke-oky-cp-1 ansible_host=192.168.20.51
pke-oky-cp-2 ansible_host=192.168.20.52
pke-oky-cp-3 ansible_host=192.168.20.53
pke-oky-wk-1 ansible_host=192.168.20.101
pke-oky-wk-2 ansible_host=192.168.20.102
pke-oky-wk-3 ansible_host=192.168.20.103

[pke-oky-vm:vars]
ansible_port=22
ansible_user=ubuntu
ansible_ssh_private_key_file=/Users/soli/.ssh/id_ed25519.pub

[pke-oky-lb]
# lbノードのホスト

[pke-oky-lb-leader]
# lbノードでマスターになるホスト ここではlb-01

[pke-oky-k8s]
# cp,wkノードのホスト

[pke-oky-cp]
# cpノードのホスト

[pke-oky-cp-leader]
# cpノードでマスターになるホスト ここではcp-01

[pke-oky-cp-follower]
# cpノードでleader以外のノード ここではcp-02,03

[pke-oky-wk]
# wkノードのホスト
```

## ロール

次に、実行するロールを決めます。ロールとは、実行したいいろいろなものを、カテゴリごとにひとまとめにした物です。
今回必要なのは以下の8つになります。

- 初期設定
- HAProxyのインストールとセットアップ
- Keepalivedのインストールとセットアップ
- CRI-Oのインストール
- Kubernetes関連コンポーネントのインストール
- Kubernetesの初期化
- コントロールプレーンをクラスタに参加
- ワーカーをクラスタに参加

これの中身はクソ長いので、当該のファイルはGitHubのリンクを張っておきます。

### 初期設定

https://github.com/Soli0222/pke/blob/main/ansible/roles/01-all-vm-config/tasks/main.yaml

初期設定ロールでは、すべてのVMに対して、必要最低限な設定の投入やセットアップを行います。

まずはKubernetesでIPv4パケットのフォワーディングを有効にするための設定です。
(カーネルモジュールの設定、最新のドキュメントでは消えてる気がする https://kubernetes.io/docs/setup/production-environment/container-runtimes/#install-and-configure-prerequisites)

次に、apt update系のタスクです。
dpkgのファイルを消していますが、これは使っているcloud-imageのせいかapt updateがことごとく失敗してしまうので、その解決のためにやっています。

apt updateができたら、nfsを使うために、nfs-commonのインストールと、日本語関係のインストールを行います。

最後にリブートしてここは完了です。

### HAProxy

https://github.com/Soli0222/pke/blob/main/ansible/roles/02-install-haproxy/tasks/main.yaml

次に、HAProxyをインストールします。また、コンフィグファイルをコピーして起動します。

### Keepalived

https://github.com/Soli0222/pke/blob/main/ansible/roles/03-install-keepalived/tasks/main.yaml

ここもHAProxyと同じく、Keepalivedのインストール、コンフィグファイルをコピー、起動という流れです。

### CRI-O

今回はコンテナランタイムにCRI-Oを使います。

https://github.com/Soli0222/pke/blob/main/ansible/roles/04-install-cri-o/tasks/main.yaml


基本的には、公式サイト通りの手順で、リポジトリキーの取得と追加、インストール、設定の追加と起動を行います。

https://cri-o.io/

### Kubernetes

https://github.com/Soli0222/pke/blob/main/ansible/roles/05-install-kubernetes/tasks/main.yaml

おなじみkubelet, kubeadm, kubeadmのインストールです。公式の言うとおりですね。

https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-kubeadm/

### Kubeadm init

https://github.com/Soli0222/pke/blob/main/ansible/roles/06-init-cp-kubernetes/tasks/main.yaml

Keepalivedで設定したVirtual IPをコントロールプレーンのエンドポイント、Pod NetworkのCIDRを記載したコンフィグファイルを元に、Kubeadm initし、生成されるkubeconfigをホームディレクトリに配置する作業をしています。

この作業はcp-01のみで行います。

### コントロールプレーンをクラスタに参加

cp-01に対して、cp-02,03もコントロールプレーンとして参加させます。

https://github.com/Soli0222/pke/blob/main/ansible/roles/07-join-cp-kubernetes/tasks/main.yaml

cp-01でクラスタに参加するためのトークンを発行するとともに、証明書の秘密鍵を取得、これを使ってクラスタに参加します。

### ワーカーノードをクラスタに参加

https://github.com/Soli0222/pke/blob/main/ansible/roles/08-join-wk-kubernetes/tasks/main.yaml

最後にワーカーノードをクラスタに参加させます。
基本的には、コントロールプレーンと同様のタスクです。

<br>
リポジトリ上では、このあとcalicoのインストールまで行っていますが、正直このタイミングではなくマニフェスト関連のほうに移動させようと考えているので、ここでは紹介しません。

## プレイブック

ロールができあがったので、これをひとまとめにします。
一度に実行するロールと、ロールを実行するホストを定義する、プレイブックというyamlファイルを作成します。

```yaml:oky-cluster.yaml
- name: All VM Config
  hosts: pke-oky-vm
  become: true
  roles:
    - 01-all-vm-config

- name: Install HAProxy
  hosts: pke-oky-lb
  become: true
  roles:
    - 02-install-haproxy

- name: Install Keepalived
  hosts: pke-oky-lb
  become: true
  roles:
    - 03-install-keepalived

- name: Install CRI-O
  hosts: pke-oky-k8s
  become: true
  roles:
    - 04-install-cri-o

- name: Install Kubernetes
  hosts: pke-oky-k8s
  become: true
  roles:
    - 05-install-kubernetes
    
- name: Init Control Plane Kubernetes
  hosts: pke-oky-k8s
  become: true
  roles:
    - 06-init-cp-kubernetes

- name: Join Control Plane Kubernetes
  hosts: pke-oky-k8s
  become: true
  roles:
    - 07-join-cp-kubernetes

- name: Join Worker Kubernetes
  hosts: pke-oky-k8s
  become: true
  roles:
    - 08-join-wk-kubernetes
```

## 実行

これで準備が整ったので、実行します。うまく行くようお祈りを捧げましょう。

```bash
$ ansible-playbook -i hosts/pke-oky/inventory oky-cluster.yaml
```

自分はこれで、クラスタの破壊と再生を繰り返しました。

# おわりに

これでめでたくKubernetesクラスタをコマンド数発で作れるようになりました。
がしかし、これがゴールではなくスタートです。

この記事を書いている間にも、このリポジトリはアップデートされており、terraform, ansibleのほかにもう一つ、manifestというディレクトリが追加されています。

ここでは、CNIのインストールや、ストレージ周りの設定、その他Kubernetes上で動かすいろいろなものをhelmでまとめています。ここからが本番ですね。

もろもろ整備ができたら、また記事を書こうと思います。(記事書くといろいろ様子がおかしいところを見つけるきっかけにもなるので)

ご覧いただき、ありがとうございました。