Terraform + Kubespray で KVM 上に Kubernetes クラスタを構築

## TL;DR
構成を定義した後、次のコマンドで Kubernetes cluster が作成できる：

```
$ terraform init
$ terraform apply -auto-approve
$ docker run --rm -it \
  --mount type=bind,source="$(pwd)"/inventory,dst=/inventory \
  --mount type=bind,source="$(pwd)"/generate_inventory.py,dst=/kubespray/generate_inventory.py \
  --mount type=bind,source="$(pwd)"/terraform.tfstate,dst=/kubespray/terraform.tfstate \
  --mount type=bind,source="${HOME}"/.ssh/id_rsa,dst=/root/.ssh/id_rsa \
  quay.io/kubespray/kubespray:v2.23.1 bash

# Inside a container
$ ansible-playbook -i generate_inventory.py cluster.yml
```

サンプルコード：

https://github.com/sawa2d2/k8s-on-kvm/tree/main/kubernetes

## 概要
Terraform + Kubespray で KVM 上に Kubernetes クラスタを構築する。大まかな手順は次の通り。

1. Terraform で VM を作成
1. 生成された `terraform.tfstate` から inventory 情報を抽出
1. inventory 情報と Kuberspray の Ansible playbooks から Kubernetes クラスタを作成

![dynamic_inventory.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261727/92f0f042-84bf-fcab-4443-fb489d572d27.png)

ネットワーク構成は下図の通り：

![network_architecture.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261727/1ad4dc1b-4ee3-97e0-3b20-6e036de2aef8.png)

クラスタの Public ネットワーク (`eth0` 側) と、Ansible が SSH ログインするためのネットワーク (`eth1` 側) を分けている。

## 前提条件
- terraform
- Container runtime (docker, podman, nerdctl, etc.)
- KVM packages
    - qemu-kvm
    - libvirt
- crdtools
- nmcli
- (Host OS: Ubuntu 22.04)


## 手順
### Host の事前設定
#### ネットワーク設定
仮想ブリッジ `br0` を作成し、既存のホストのネットワークインタフェース (例：`enp1s0`) を `br0` に接続する。このようにしておくと、後に作成する VM を `br0` に接続させることにより、LAN 内にある任意のホストから VM に直接アクセスできるようになる。

図で説明すると、次のように Before から After へ設定を変更する。

![network-diff.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261727/d77c6657-b340-085b-a5a7-0b6fec145990.png)


次ようなスクリプトを作成し、パラメータを書き換え、ホスト上で実行する。

```bash:create_br0.sh
HOST_IP=192.168.8.10
CIDR_PREFIX=24
GATEWAY=192.168.8.1
DNS=192.168.8.1
NWIF=enp1s0

# ブリッジを作成
nmcli con add type bridge con-name br0 ifname br0

# ブリッジに既存の NIC 設定を与える
nmcli con modify br0 \
ipv4.method manual \
ipv4.addresses "$HOST_IP/$CIDR_PREFIX" \
ipv4.gateway "$GATEWAY" \
ipv4.dns $DNS

# enp1s0 のマスターを br0 にする
nmcli con add type bridge-slave ifname $NWIF master br0

# 既存の接続を無効化
nmcli con down $NWIF

# br0 を有効化
nmcli con up br0
```

#### libvirt の default pool の確認
libvirt が利用する様々なリソースは、 default pool と呼ばれるディレクトリ： `/var/lib/libvirt/images` 内で管理される。したがって、まずは default pool が存在することを確認する。
```
# virsh pool-list --all
 Name      State    Autostart
-------------------------------
 default   active   yes
```

`default` pool がなければ次のコマンドで作成しておく：

```
# mkdir -p /var/lib/libvirt/images
# chmod 755 /var/lib/libvirt/images

# virsh pool-define /dev/stdin <<EOF
<pool type='dir'>
  <name>default</name>
  <target>
    <path>/var/lib/libvirt/images</path>
  </target>
</pool>
EOF

# virsh pool-start default
# virsh pool-autostart default
# virsh pool-list --all
```

#### Linux image の入手
本記事では VM の OS として、CentOS の後継である Rocky Linux を用いる。[download.rockylinux.org](https://download.rockylinux.org/pub/rocky/9.2/images/x86_64/) より配布のイメージファイルを、libvirt のデフォルトプール `/var/lib/libvirt/images/` にダウンロードする：
```
# curl -L -o /var/lib/libvirt/images/Rocky-9-GenericCloud.latest.x86_64.qcow2 https://download.rockylinux.org/pub/rocky/9.2/images/x86_64/Rocky-9-GenericCloud.latest.x86_64.qcow2
```

### Terraform による VM のプロビジョニング
#### cloud-init の設定
cloud-init は、多くの Linux distribution で採用されている VM の初期設定を自動化するための仕組みである。設定は次の2つの設定ファイルを用意して与えることができる。

- `cloud_init.cfg`: ユーザ設定を記述
- `network_config.cfg`: ネットワーク設定を記述


```yaml:cloud_init.cfg
#cloud-config
users:
  - name: root
    ssh-authorized-keys:
      - "<YOUR_SSH_KEY>"
```

:::

ネットワーク設定は `network_config.cfg` から与える。

VM ごとに異なる値（例：静的 IP アドレスなど）は、`${foo}` のようなプレースホルダーで記述したテンプレートを作成しておく。Terraform のテンプレート機能によって値が代入され、VM ごとの network_config.cfg を生成することができる。

```yaml:network_config.cfg.tpl
version: 2
ethernets:
  eth0:
    dhcp4: no
    addresses: [${ip}]
    gateway4: ${gateway}
    nameservers:
      addresses: ${nameservers}
```

### Terraform ファイルの用意

:::note
本節は、Kubespray 用の VM を構築するための Terraform コードの実装方法について解説します。実装するのではなく、import して使いたい場合は、本記事内 TIPS の [『本記事の-Terraform-コードを-module-として使う』](#本記事の-terraform-コードを-module-として使う) を参照してください。
:::

ファイルは次の4つからなる：

- provider.tf
- main.tf
- variables.tf
- output.tf



#### provider.tf
Terraform Libvirt Provider の使用を宣言する。
```tf:main.tf
terraform {
  required_providers {
    libvirt = {
      source  = "dmacvicar/libvirt"
      version = "0.7.1"
    }
  }
}

provider "libvirt" {
  uri = var.libvirt_uri
}
```

#### main.tf
主に VM の作成に関する処理を記述する。
```main.tf
locals {
  cluster_cidr_splitted      = split("/", var.cidr)
  cluster_cidr_subnet        = local.cluster_cidr_splitted[0]
  cluster_cidr_prefix        = local.cluster_cidr_splitted[1]
  cluster_nameservers_string = "[\"${join("\", \"", var.nameservers)}\"]"

  # Auto-calculate mac address from IP 
  cluster_ips_parts = [for vm in var.vms : split(".", vm.public_ip)]
  cluster_mac_addrs = [
    for ip_parts in local.cluster_ips_parts : format(
      "52:54:00:%02X:%02X:%02X",
      tonumber(ip_parts[1]),
      tonumber(ip_parts[2]),
      tonumber(ip_parts[3])
    )
  ]
  private_ips_parts = [for vm in var.vms : split(".", vm.private_ip)]
  private_mac_addrs = [
    for ip_parts in local.private_ips_parts : format(
      "52:54:00:%02X:%02X:%02X",
      tonumber(ip_parts[1]),
      tonumber(ip_parts[2]),
      tonumber(ip_parts[3])
    )
  ]
}

data "template_file" "user_data" {
  count    = length(var.vms)
  template = file(var.vms[count.index].cloudinit_file)
}

data "template_file" "network_config" {
  count    = length(var.vms)
  template = file("${path.module}/network_config.cfg")
  vars = {
    ip          = var.vms[count.index].public_ip
    cidr_prefix = local.cluster_cidr_prefix
    gateway     = var.gateway
    nameservers = local.cluster_nameservers_string
  }
}

resource "libvirt_cloudinit_disk" "commoninit" {
  count          = length(var.vms)
  name           = "commoninit_${var.vms[count.index].name}.iso"
  user_data      = data.template_file.user_data[count.index].rendered
  network_config = data.template_file.network_config[count.index].rendered
}

locals {
  volume_list      = { for vm in var.vms : "${vm.name}" => flatten([for volume in vm.volumes : volume]) }
  volume_name_list = [for vm, volumes in local.volume_list : [for volume in volumes : { "name" : "${vm}_${volume.name}", "disk" : volume.disk }]]
  volumes          = flatten(local.volume_name_list)
  volumes_indexed  = { for index, volume in local.volumes : volume.name => index }
}

resource "libvirt_domain" "vm" {
  count  = length(var.vms)
  name   = var.vms[count.index].name
  vcpu   = var.vms[count.index].vcpu
  memory = var.vms[count.index].memory

  disk {
    volume_id = libvirt_volume.system[count.index].id
  }

  cloudinit = libvirt_cloudinit_disk.commoninit[count.index].id
  autostart = true

  # Public network
  network_interface {
    bridge    = var.bridge
    addresses = [var.vms[count.index].public_ip]
    mac       = local.cluster_mac_addrs[count.index]
  }

  # Private network
  network_interface {
    network_name = "default"
    addresses    = [var.vms[count.index].private_ip]
    mac          = local.private_mac_addrs[count.index]
  }

  qemu_agent = true

  cpu {
    mode = "host-passthrough"
  }

  graphics {
    type        = "vnc"
    listen_type = "address"
  }

  # Makes the tty0 available via `virsh console`
  console {
    type        = "pty"
    target_port = "0"
    target_type = "serial"
  }
}

resource "libvirt_volume" "system" {
  count          = length(var.vms)
  name           = "${var.vms[count.index].name}_system.qcow2"
  pool           = var.pool
  format         = "qcow2"
  base_volume_id = var.vm_base_image_uri
  size           = var.vms[count.index].disk
}
```

#### variables.tf
変数を宣言する。

```hcl:variables.tf
variable "libvirt_uri" {
  type = string
}

variable "vm_base_image_uri" {
  type = string
}

variable "bridge" {
  type = string
}

variable "gateway" {
  type = string
}

variable "cidr" {
  type = string
}

variable "nameservers" {
  type = list(string)
}

variable "pool" {
  type    = string
  default = "default"
}

variable "vms" {
  type = list(
    object({
      name           = string
      vcpu           = number
      memory         = number
      disk           = number
      public_ip      = string
      private_ip     = string
      cloudinit_file = string

      kube_control_plane = bool
      kube_node          = bool
      etcd               = bool
    })
  )
}

```

#### output.tf
この後の工程で Ansible に渡すホストの情報を出力する。
```output.tf
locals {
  kubespray_hosts_keys = ["name", "kube_control_plane", "kube_node", "etcd"]
  kubespray_hosts = [for vm in var.vms :
    merge(
      {
        for key, value in vm : key => value if contains(local.kubespray_hosts_keys, key)
      },
      {
        ip        = vm.public_ip
        access_ip = vm.private_ip
    })
  ]
}

output "kubespray_hosts" {
  value = local.kubespray_hosts
}
```

### Terraform の実行
Terraform を実行し、VM をプロビジョニングする。

```
$ terraform init
$ terraform apply -auto-approve
```


VM が作成され、稼働していることを確認する。

```
$ virsh list --all
 Id   Name           State
------------------------------
 1    k8s-master-1   running
 2    k8s-worker-1   running
 3    k8s-worker-2   running
```

次のコマンドで、作成した VM が`br0` に接続されていることが確認できる。
```
$ ip link show master br0
bridge name     bridge id               STP enabled     interfaces
2: enp1s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq master br0 state UP mode DEFAULT group default qlen 1000
    link/ether 48:21:0b:57:b2:52 brd ff:ff:ff:ff:ff:ff
5: vnet4: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br0 state UNKNOWN mode DEFAULT group default qlen 1000
    link/ether fe:54:00:00:00:04 brd ff:ff:ff:ff:ff:ff
6: vnet5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br0 state UNKNOWN mode DEFAULT group default qlen 1000
    link/ether fe:54:00:00:00:02 brd ff:ff:ff:ff:ff:ff
7: vnet6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br0 state UNKNOWN mode DEFAULT group default qlen 1000
    link/ether fe:54:00:00:00:03 brd ff:ff:ff:ff:ff:ff
8: vnet7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br0 state UNKNOWN mode DEFAULT group default qlen 1000
    link/ether fe:54:00:00:00:01 brd ff:ff:ff:ff:ff:ff
```

### Kubespray による kubernetes cluster の作成
[Kubespray](https://github.com/kubernetes-sigs/kubespray) は、Kubenetes クラスタの構築を自動化をする Ansible playbook を提供するオープンソースのプロジェクトである。手順は [kubespray/docs/setting-up-your-first-cluster.md at master · kubernetes-sigs/kubespray](https://github.com/kubernetes-sigs/kubespray/blob/master/docs/setting-up-your-first-cluster.md#set-up-kubespray) に従う。

まず kubespray のリポジトリをクローンする。
```
$ git clone git@github.com:kubernetes-sigs/kubespray.git
$ cd kubespray
```

次に 既存のサンプルをコピーし、設定ファイルの土台を作成する。
```
$ git checkout release-2.23
$ cp -rfp inventory/sample inventory/mycluster
```

本記事ではコンテナランタイムに crio を使うことにする（デフォルトだと containerd になっている）。`group_vars/k8s_cluster/k8s_cluster.yml` の `container_manager` の値を crio に書き換える：
```group_vars/k8s_cluster/k8s_cluster.yml
## Container runtime
## docker for docker, crio for cri-o and containerd for containerd.
## Default: containerd
container_manager: crio
```

より細かいオプションを設定する場合は、`inventory/mycluster/group_vars/` 内のファイルを修正する([*補足:プロキシの設定](#TIPS))。

#### Inventory の作成
次の2通りの方法を紹介するが、どちらか一方を選べば良い。

1. Dynamic Inventory を用いる方法
1. 手動で hosts.yaml を編集する方法

##### (方法1) Dynamic Inventory を用いる
terraform 実行後に生成される `terraform.tfstate` から動的に inventory 情報を抽出する。（

これを行うには、JSON で記述された inventory 情報を出力だけのするスクリプト (実装例：[`./generate_inventory.py`](https://github.com/sawa2d2/k8s-on-kvm/blob/main/kubernetes/generate_inventory.py)) を用意すればよい。下記は、`terraform.state` の `.outputs` を読み込み、インベントリを作成するスクリプトの実装例である：
```generate_inventory.py
#!/usr/bin/env python3

import json
import re


def main():
    output = get_outputs()
    hosts = output['kubespray_hosts']['value']
    libvirt_uri = output['libvirt_uri']['value']

    hostvars = {}
    kube_control_plane = []
    kube_node = []
    etcd = []

    for host in hosts:
        name = host['name']
        ip = host['ip']
        access_ip = host['access_ip']
        hostvars.update({
          name: {
              "ansible_host": access_ip,
              "ip": ip,
          }
        })

        regex = r"^qemu(\+ssh)?://([^/]*)/.*"
        res = re.match(regex, libvirt_uri)
        if res:
          hostname = res[2]
          if hostname != "":
            hostvars[name].update({
              "ansible_ssh_common_args": f"-J {hostname}"
          })

        if host["kube_control_plane"]:
            kube_control_plane.append(name)
        if host["kube_node"]:
            kube_node.append(name)
        if host["etcd"]:
            etcd.append(name)

    inventory = {
        "_meta": {
            "hostvars": hostvars,
        },
        "kube_control_plane": kube_control_plane,
        "kube_node": kube_node,
        "etcd": etcd,
        "k8s_cluster": {
            "children": [
                "kube_control_plane",
                "kube_node",
            ]
        }
    }


    print(json.dumps(inventory))


def get_outputs():
    tfstate_path = './terraform.tfstate'
    with open(tfstate_path) as f:
        tfstate = json.load(f)
    return tfstate['outputs']


main()
```


この `./generate_inventory.py` を実行すると次のような JSON が出力される：
```json
{
  "_meta": {
    "hostvars": {
      "storage1": {
        "ansible_host": "192.168.122.201",
        "ip": "192.168.8.201"
      },
      "storage2": {
        "ansible_host": "192.168.122.202",
        "ip": "192.168.8.202"
      },
      "storage3": {
        "ansible_host": "192.168.122.203",
        "ip": "192.168.8.203"
      }
    }
  },
  "kube_control_plane": [
    "storage1"
  ],
  "kube_node": [
    "storage1",
    "storage2",
    "storage3"
  ],
  "etcd": [
    "storage1"
  ],
  "k8s_cluster": {
    "children": [
      "kube_control_plane",
      "kube_node"
    ]
  }
}
```

これをインベントリとして指定し、Kubernetes クラスタを作成する：

```
$ docker pull quay.io/kubespray/kubespray:v2.23.1
$ docker run --rm -it \
  --mount type=bind,source="$(pwd)"/inventory,dst=/inventory \
  --mount type=bind,source="$(pwd)"/generate_inventory.py,dst=/kubespray/generate_inventory.py \
  --mount type=bind,source="$(pwd)"/terraform.tfstate,dst=/kubespray/terraform.tfstate \
  --mount type=bind,source="${HOME}"/.ssh/id_rsa,dst=/root/.ssh/id_rsa \
  quay.io/kubespray/kubespray:v2.23.1 bash

# Inside a container
$ ansible-playbook -i generate_inventory.py cluster.yml
…
PLAY RECAP *****************************************************************************************************************
localhost                  : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
k8s-master-1               : ok=765  changed=138  unreachable=0    failed=0    skipped=1263 rescued=0    ignored=8   
k8s-worker-1               : ok=583  changed=104  unreachable=0    failed=0    skipped=795  rescued=0    ignored=2   
k8s-worker-2               : ok=523  changed=82   unreachable=0    failed=0    skipped=763  rescued=0    ignored=1   

Tuesday 01 August 2023  13:16:52 +0000 (0:00:00.041)       0:59:01.983 ********                                                                                      ===============================================================================      
```

なお、途中で失敗するなどしてインストールをやり直したくなった場合、次でリセットできる。
```
$ ansible-playbook -i ./generate_inventory.py reset.yml
```

Kubespray における Dynamic Inventory の詳細は、Kubespray 公式レポジトリの https://github.com/kubernetes-sigs/kubespray/blob/master/docs/aws.md#dynamic-inventory も参照。


##### (方法2) 手動で hosts.yaml を編集する
kubespray のコンテナを立ち上げる。
```
$ docker pull quay.io/kubespray/kubespray:v2.23.1
$ docker run --rm -it \
  --mount type=bind,source="$(pwd)"/inventory/mycluster,dst=/inventory \
  --mount type=bind,source="${HOME}"/.ssh/id_rsa,dst=/root/.ssh/id_rsa \
  quay.io/kubespray/kubespray:v2.23.1 bash
```

コンテナの中に入るので、次のコマンドを実行する。
```
$ declare -a IPS=(192.168.8.201 192.168.8.202 192.168.8.203)
$ CONFIG_FILE=/inventory/hosts.yaml python3 contrib/inventory_builder/inventory.py ${IPS[@]}
```

inventory ファイル `/inventory/mycluster/hosts.yaml` が生成されるので、適宜、ホスト情報を修正する。本記事では下記の設定を用いる：
```hosts.yaml
all:
  hosts:
    storage1:
      ansible_host: 192.168.122.201
      ip: 192.168.8.201
    storage2:
      ansible_host: 192.168.122.202
      ip: 192.168.8.202
    storage3:
      ansible_host: 192.168.122.203
      ip: 192.168.8.203
  children:
    kube_control_plane:
      hosts:
        storage1:
    kube_node:
      hosts:
        storage1:
        storage2:
        storage3:
    etcd:
      hosts:
        storage1:
    k8s_cluster:
      children:
        kube_control_plane:
        kube_node:
```

```
$ docker run --rm -it \
  --mount type=bind,source="$(pwd)"/inventory,dst=/inventory \
  --mount type=bind,source="$(pwd)"/cluster.yml,dst=/kubespray/cluster.yml \
  --mount type=bind,source="${HOME}"/.kube,dst=/root/.kube \
  --mount type=bind,source="${HOME}"/.ssh/id_rsa,dst=/root/.ssh/id_rsa \
  quay.io/kubespray/kubespray:v2.23.1 bash

# Inside a container
$ ansible-playbook -i /inventory/hosts.yaml cluster.yml
```

#### Kubernetes cluster へのアクセス

いずれかの master node から認証情報 `admin.conf` を取ってくる。
```
$ mkdir -p ~/.kube
$ scp root@192.168.8.201:/etc/kubernetes/admin.conf ~/.kube
```

admin.conf 内の clusters.cluster.server の IP は、`127.0.0.1` になっているので、Public IP (例：`192.168.8.201`) に修正する。
```
apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: <CERTIFICATE_AUTHORITY_DATA>
    server: https://192.168.8.201:6443
  name: cluster.local
...
```

.bashrc などで次の環境変数を設定する。
```
$ export KUBECONFIG=$HOME/.kube/admin.conf 
```

以上で `kubectl` でクラスタにアクセスできるようになる。
```
$ kubectl cluster-info
Kubernetes control plane is running at https://192.168.8.201:6443

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

$ kubectl get nodes
NAME           STATUS   ROLES           AGE   VERSION
k8s-master-1   Ready    control-plane   70m   v1.26.5
k8s-worker-1   Ready    <none>          68m   v1.26.5
k8s-worker-2   Ready    <none>          68m   v1.26.5
```

-----

## TIPS
### Kubespray で proxy を設定する
proxy を設定する場合は、`inventory/mycluster/group_vars/all/all.yml`の次の項目を編集する。
```
http_proxy: 'http://your-proxy-server:8080'
https_proxy: 'http://your-proxy-server:8080'
no_proxy: 'localhost,127.0.0.1,.yourdomain.com'
```

### Kubespray に独自の playbook を組み込む
例えば、`cluster.yml` の適用後に独自の追加処理を実行させる場合について。

[Kubespray の `cluster.yml` の内容](https://github.com/kubernetes-sigs/kubespray/blob/master/cluster.yml) を見ると、単に `playbooks/cluster.yml` を import しているだけなので、追加のタスクを行う場合は `cluster.yml` の末尾に独自の処理を追加した、次のような `cusotmized_cluster.yml` を作成する。

```customized_cluster.yml
---
# This role assumes to call Kubespray's `playbooks/cluster`.
- name: Install Kubernetes
  ansible.builtin.import_playbook: playbooks/cluster.yml

- name: Registernqualified
  hosts: all
  tasks:
    - ansible.builtin.file:
        path: /etc/containers/registries.conf.d
        state: directory
        mode: '0755'
    - ansible.builtin.copy:
        dest: /etc/containers/registries.conf.d/01-unqualified.conf
        content: |

          unqualified-search-registries = ['docker.io', 'quay.io']

- name: Download amind.conf to localhost
  hosts: kube_control_plane
  run_once: true
  tasks:
    - ansible.builtin.fetch:
        src: /etc/kubernetes/admin.conf
        dest: ~/.kube/admin.conf
        flat: yes
    - delegate_to: localhost
      ansible.builtin.replace:
        path: ~/.kube/admin.conf
        regexp: '127.0.0.1'
        replace: "{{ hostvars[inventory_hostname]['ansible_default_ipv4']['address'] }}"
```

上の例では、クラスタ作成後の次のタスク：
- Unqualified registries (`docker.io` と `quay.io`) の追加
- admin.conf をローカルにダウンロードし、API サーバのアドレスを Public IP に置換

を自動化する。あとは Kubespray の `cluster.yml` をこれに置き換え、通常通り playbook を実行すればよい。

```
$ docker run --rm -it \
  --mount type=bind,source="$(pwd)"/inventory,dst=/inventory \
  --mount type=bind,source="$(pwd)"/.terraform/modules/kubernetes/kubernetes/generate_inventory.py,dst=/kubespray/generate_inventory.py \
  --mount type=bind,source="$(pwd)"/terraform.tfstate,dst=/kubespray/terraform.tfstate \
  --mount type=bind,source="$(pwd)"/cluster.yml,dst=/kubespray/cluster.yml \
  --mount type=bind,source="${HOME}"/.kube,dst=/root/.kube \
  --mount type=bind,source="${HOME}"/.ssh/id_rsa,dst=/root/.ssh/id_rsa \
  quay.io/kubespray/kubespray:v2.23.1 bash

# Inside a container
ansible-playbook -i generate_inventory.py cluster.yml
```


公式のガイド [kubespray/docs/integration.md at master · kubernetes-sigs/kubespray · GitHub](https://github.com/kubernetes-sigs/kubespray/blob/master/docs/integration.md)) も参照。

### オフライン環境で Terraform を実行する
通常、Provider はインターネット経由で自動的にダウンロードされるが、オフライン環境の場合は、ホームディレクトリ下に `~/terraform.d/providers` を用意し、そこに Provider をおく。

例として、Terraform Libvirt Provider 使う場合、[Releases · dmacvicar/terraform-provider-libvirt](https://github.com/dmacvicar/terraform-provider-libvirt/releases) からバイナリをダウンロードし次のように配置する：
```
~/terraform.d/providers/
└── providers/
    └── registry.terraform.io/
        └── dmacvicar/
            └── libvirt/
                └── 0.7.1/
                    └── linux_amd64/
                        ├── CHANGELOG.md
                        ├── LICENSE
                        ├── README.md
                        └── terraform-provider-libvirt_v0.7.4
```


### リモートホストに対して Terraform を適用する
ローカルから Terraform を実行し、リモートホスト上に VM を作成することができる。

まずはリモートホスト上で、[Host の事前設定](https://qiita.com/sawa2d2/items/c592dcbd958f69441068#host-%E3%81%AE%E4%BA%8B%E5%89%8D%E8%A8%AD%E5%AE%9A) を完了させた上で、libvirtd が稼働していることを確認する：
```
# Remote side
$ systemctl status libvirtd
```

クライアント側から `qemu+ssh://` プロトコルを用いて接続を確認する：
```
# Client side
$ virsh -c qemu+ssh://<user>@<remote_ip>/system list
 Id   Name   State
--------------------

```

確認できたら、`main.tf` で下記のようにリモートホストを指定し、クライアント側で `terraform apply` を実行する。

```main.tf
locals {
  user_home_directory = pathexpand("~")
}

provider "libvirt" {
  uri = "qemu+ssh://<remote-user>@<remote_host>/system?keyfile=${local.user_home_directory}/.ssh/id_rsa&known_hosts_verify=ignore"
}
```

#### 踏み台サーバを経由する場合
踏み台サーバを経由してリモートにアクセスする場合、リモートの22番ポートをローカルの適当なポート (例:50000) にフォワーディングする。

```
# Client side
$ ssh -C -N -f -L 50000:<remote-user>@<remote-host>:22 <bastion-host> -p <bastion-port>
$ virsh -c qemu+ssh://<remote-user>@localhost:50000/system list
 Id   Name   State
--------------------

```

```main.tf
locals {
  user_home_directory = pathexpand("~")
}

provider "libvirt" {
  uri = "qemu+ssh://<remote-user>@localhost:50000/system?keyfile=${local.user_home_directory}/.ssh/id_rsa&known_hosts_verify=ignore"
}
```

### 本記事の Terraform コードを module として使う
下記ような `main.tf` を用意する。例では1台の master node と 2台の worker nodes からなる合計3台のVMが作成されるが、所望の構成になるよう適宜パラメータを修正する。

```hcl:main.tf
output "kubespray_hosts" {
  value = module.kubernetes.kubespray_hosts
}

output "libvirt_uri" {
  value = module.kubernetes.libvirt_uri
}

locals {
  user_home_directory = pathexpand("~")
}

module "kubernetes" {
  source = "github.com/sawa2d2/k8s-on-kvm//kubernetes/"

  ## Localhost:
  # libvirt_uri = "qemu:///system"
  ## Remote:
  # libvirt_uri = "qemu+ssh://<user>@<remote-host>/system?keyfile=${local.user_home_directory}/.ssh/id_rsa&known_hosts_verify=ignore"
  ## Remote via bastion:
  ##   Forward port in advance.
  ##   $ ssh -C -N -f -L 50000:<remote-user>@<remote-host>:22 <bastion-host> -p <bastion-port>
  # libvirt_uri = "qemu+ssh://<remote-user>@localhost:50000/system?keyfile=${local.user_home_directory}/.ssh/id_rsa&known_hosts_verify=ignore"
  libvirt_uri = "qemu:///system"

  # Download the image by:
  #   sudo curl -L -o /var/lib/libvirt/images/Rocky-9-GenericCloud.latest.x86_64.qcow2 https://download.rockylinux.org/pub/rocky/9.2/images/x86_64/Rocky-9-GenericCloud.latest.x86_64.qcow2 
  vm_base_image_uri = "/var/lib/libvirt/images/Rocky-9-GenericCloud.latest.x86_64.qcow2"
  pool              = "default"

  # Cluster network
  bridge      = "br0"
  cidr        = "192.168.8.0/24"
  gateway     = "192.168.8.1"
  nameservers = ["192.168.8.1"]

  vms = [
    {
      name           = "k8s-master-1"
      vcpu           = 4
      memory         = 16000                    # in MiB
      disk           = 100 * 1024 * 1024 * 1024 # 100 GB
      public_ip      = "192.168.8.101"
      private_ip     = "192.168.122.201"
      cloudinit_file = "cloud_init.cfg"
      volumes        = []

      kube_control_plane = true
      kube_node          = true
      etcd               = true
    },
    {
      name           = "k8s-worker-1"
      vcpu           = 4
      memory         = 16000                    # in MiB
      disk           = 100 * 1024 * 1024 * 1024 # 100 GB
      public_ip      = "192.168.8.102"
      private_ip     = "192.168.122.202"
      cloudinit_file = "cloud_init.cfg"
      volumes        = []

      kube_control_plane = false
      kube_node          = true
      etcd               = false
    },
    {
      name           = "k8s-worker-2"
      vcpu           = 2
      memory         = 8000                     # in MiB
      disk           = 100 * 1024 * 1024 * 1024 # 100 GB
      public_ip      = "192.168.8.103"
      private_ip     = "192.168.122.203"
      cloudinit_file = "cloud_init.cfg"
      volumes        = []

      kube_control_plane = false
      kube_node          = true
      etcd               = false
    },
  ]
}
```


## 参考資料
### Terraform および Dynamic Inventory 関連
- [End-to-End Application Provisioning with Ansible and Terraform - IBM Blog](https://www.ibm.com/blog/end-to-end-application-provisioning-with-ansible-and-terraform/)
- [Integrating Ansible and Jenkins with Terraform to make a powerful infrastructure | by Ankush Chavan | Medium](https://ankush-chavan.medium.com/integrating-ansible-with-terraform-to-make-a-powerful-infrastructure-50795c36f78b)

### libvirt の設定関連
- [error when default storage pool is missing · Issue #8 · simon3z/virt-deploy](https://github.com/simon3z/virt-deploy/issues/8#issuecomment-73111541) ... default pool の作成方法など

### Kuberspray 関連
- [kubesprayを使ったKubernetesのインストール | Kubernetes](https://kubernetes.io/ja/docs/setup/production-environment/tools/kubespray/)
- [An introduction to Kubespray | Enable Sysadmin](https://www.redhat.com/sysadmin/kubespray-deploy-kubernetes)

### ブリッジ設定関連
- [KVMでゲストOSをブリッジ接続する - Qiita](https://qiita.com/yoshiyasu1111/items/8d07a4fd55116fba07f7)

## 関連記事
https://qiita.com/sawa2d2/items/6d3c0b015144eba44ea2