bootkube を利用した bare-metal Kubernetes クラスタの構築

*この記事は [Z Lab Advent Calendar 2018](https://qiita.com/advent-calendar/2018/zlab) の 17 日目の記事です.*

Kubernetes クラスタを自前で作ろうとするとき, みなさんはどんなツールを使っているでしょうか? kubeadm ですかね? まぁ, そうですよね. でも今回はもっと硬派に bootkube を使ってみたいと思います.

## bootkube

- <https://github.com/kubernetes-incubator/bootkube>

この記事では bootkube を利用して, [packet](https://www.packet.com/) という bare-metal のクラウドサービス上に Kubernetes クラスタを作る手順を紹介したいと思います.

[こちらのガイド](https://github.com/coreos/matchbox/tree/master/examples/terraform/bootkube-install)によると bootkube を利用して bare-metal の Kubernetes クラスタを作る方法はざっくりと次の通りのようです.

1. [matchbox](https://github.com/coreos/matchbox) をセットアップする
2. [Typhoon](https://typhoon.psdn.io/) から提供されている Terraform モジュールに必要な設定を記述する
3. その Terraform モジュールを利用して bootkube を実行する

簡単そうですね!!

## matchbox のセットアップ

- <https://github.com/coreos/matchbox>

matchbox とは bare-metal マシンに Container Linux をセットアップするための仕組みを提供するサービスで, PXE という network booting のためのエンドポイントを提供します.

bootkube ではクラスタに参加させるマシンの MAC アドレスと, それらに対応するコンフィグレーション (Ignition や Cloud-Config など) を matchbox に登録しておくことで, 個別の OS イメージを用意することなくクラスタを作れるようにしています.

matchbox 自身のセットアップ方法は次のリンクにまとめられています. 

- <https://github.com/coreos/matchbox/blob/master/Documentation/deployment.md>

この記事ではこのインストラクションに従って matchbox を構築したものとして進めていきますが. 実際に構築する上では次のことに注意してください.

- この matchbox のサービスは packet 上にある bare-metal マシンからアクセスできる必要があるので, インターネットからアクセスできるようにしておくこと
- 後述する Terraform モジュールで利用する必要があるため HTTP API だけでなく gRPC API も有効にしておくこと
- 同じく Terraform モジュールで利用するため CA 証明書とクライアント証明書を保存しておくこと

参考までにこの記事を書くときに利用した Terraform ファイルを置いておきます.

- <https://github.com/kkohtaka/matchbox-on-packet>

## packet への bare-metal マシンの用意

- <https://www.packet.net>

matchbox がセットアップできたところで, 早速 Kubernetes クラスタを構成する bare-metal マシンを packet に用意します.

上述したように bootkube では利用するマシンの MAC アドレスを事前に知っておく必要があります. packet では作成するマシンの MAC アドレスを前もって知ることができないため, PXE による network booting を行う前に, 一度適当な OS イメージを利用して起動して MAC アドレスを確認します.

公式の CLI を利用して確認していきます.

- <https://github.com/packethost/packet-cli>

はじめに SSH 公開鍵を登録します.

```bash
$ ssh-key create --key "$(cat ~/.ssh/id_rsa.pub)" --label default
+--------------------------------------+---------+----------------------+
|                  ID                  |  LABEL  |       CREATED        |
+--------------------------------------+---------+----------------------+
| ********-****-****-****-************ | default | 2018-12-05T15:17:16Z |
+--------------------------------------+---------+----------------------+
```

プロジェクトを作成します.

```bash
$ packet project create --name bootkube
+--------------------------------------+----------+----------------------+
|                  ID                  |   NAME   |       CREATED        |
+--------------------------------------+----------+----------------------+
| ********-****-****-****-************ | bootkube | 2018-12-05T14:43:20Z |
+--------------------------------------+----------+----------------------+
```

ここで得られた ID を環境変数 `PROJECT_ID` として, controller-001 と worker-001 という二つのマシンを作ります. `--ipxe-script-url` オプションに指定する値は, 構築した matchbox のエンドポイントに応じて修正してください.

```bash
$ packet device create \
    --hostname controller-001 \
    --project-id ${PROJECT_ID} \
    --facility nrt1 \
    --plan t1.small.x86 \
    --operating-system custom_ipxe \
    --ipxe-script-url http://matchbox.example.com:8080/boot.ipxe
+--------------------------------------+----------------+--------------------------+--------+----------------------+
|                  ID                  |    HOSTNAME    |            OS            | STATE  |       CREATED        |
+--------------------------------------+----------------+--------------------------+--------+----------------------+
| ********-****-****-****-************ | controller-001 | Container Linux - Stable | queued | 2018-12-05T15:18:38Z |
+--------------------------------------+----------------+--------------------------+--------+----------------------+
$ packet device create \
    --hostname worker-001 \
    --project-id ${PROJECT_ID} \
    --facility nrt1 \
    --plan t1.small.x86 \
    --operating-system custom_ipxe \
    --ipxe-script-url http://matchbox.example.com:8080/boot.ipxe
+--------------------------------------+------------+--------------------------+--------+----------------------+
|                  ID                  |  HOSTNAME  |            OS            | STATE  |       CREATED        |
+--------------------------------------+------------+--------------------------+--------+----------------------+
| ********-****-****-****-************ | worker-001 | Container Linux - Stable | queued | 2018-12-05T15:19:37Z |
+--------------------------------------+------------+--------------------------+--------+----------------------+
```

マシンの状態がアクティブになったら, 次のようなコマンドで IP アドレスと MAC アドレスが確認できます.

```bash
$ packet device get \
    --project-id ${PROJECT_ID} \
    --json \
  | jq -M '.[] | {id:.id, hostname:.hostname, ip:.ip_addresses[0].address, mac:.network_ports[1].data.mac}'
{
  "id": "********-****-****-****-************",
  "hostname": "worker-001",
  "ip": "***.***.***.***",
  "mac": "**:**:**:**:**:**"
}
{
  "id": "********-****-****-****-************",
  "hostname": "controller-001",
  "ip": "***.***.***.***",
  "mac": "**:**:**:**:**:**"
}
```

ここで, 少し面倒なのですが bootkube ではマシンにアクセスするための DNS 名が必要なため, それぞれのマシンの IP アドレスを利用して DNS 名を登録しておきます. 以降は次のように DNS レコードを登録したものとして進めたいと思います.

| DNS | A レコード | 用途 |
|---|---|---|
| controller-001.packet.example.com | controller-001 の IP アドレス | controller-001 へのアクセスに利用 |
| worker-001.packet.example.com | worker-001 の IP アドレス | worker-001 へのアクセスに利用 |
| k8s.packet.example.com | controller-001 の IP アドレス | kube-apiserver へのアクセスに利用 |

ちなみのこの時点では matchbox にコンフィグレーションが登録されていないため, 作成したマシンはいつまで経っても起動しません. matchbox サービスのログを確認すると次のようなメッセージが出力されていて, 対応するプロファイル (コンフィグレーションと MAC アドレスの紐づけ) が無いことが分かります.

```
time="2018-12-07T08:16:53Z" level=info msg="HTTP GET /boot.ipxe"
time="2018-12-07T08:16:53Z" level=info msg="HTTP GET /ipxe?uuid=00000000-0000-0000-0000-000000000000&mac=**-**-**-**-**-**&domain=&hostname=controller-001&serial=***************"
time="2018-12-07T08:16:53Z" level=info msg="No matching profile" labels=map[uuid:00000000-0000-0000-0000-000000000000 mac:**:**:**:**:**:** domain: hostname:controller-001 serial:***************]
time="2018-12-07T08:17:02Z" level=info msg="HTTP GET /boot.ipxe"
time="2018-12-07T08:17:02Z" level=info msg="HTTP GET /ipxe?uuid=00000000-0000-0000-0000-000000000000&mac=**-**-**-**-**-**&domain=&hostname=worker-001&serial=***************"
time="2018-12-07T08:17:02Z" level=info msg="No matching profile" labels=map[hostname:worker-001 serial:*************** uuid:00000000-0000-0000-0000-000000000000 mac:**:**:**:**:**:** domain:]
```

## bootkube の準備

- <https://github.com/coreos/matchbox/tree/master/examples/terraform/bootkube-install>

ここまででクラスタを設定するのに必要な情報が手に入ったので, いよいよ bootkube を使っていきます. ここでは matchbox のリポジトリにある examples/terraform/bootkube-install というディレクトリにある Terraform ファイルがほとんどそのまま使えるので, これを利用したいと思います.

このディレクトリにある terraform.tfvars.example ファイルを元に, これまでに手に入れた情報から次のような terraform.tfvars ファイルを作ります.

```toml:terraform.tfvars
matchbox_http_endpoint = "http://matchbox.example.com:8080" # matchbox の HTTP エンドポイント
matchbox_rpc_endpoint = "matchbox.example.com:8081"         # matchbox の gRPC エンドポイント

ssh_authorized_key = "ADD ME"  # マシンへの SSH ログインに使う公開鍵

cluster_name = "my-cluster"  # クラスタ名
os_channel = "coreos-stable"
os_version = "1911.4.0"

# Machines
# controller/worker のホスト名, MAC アドレス, DNS 名をそれぞれ指定します.
controller_names = [ "controller-001" ]
controller_macs = [ "**:**:**:**:**:**" ]
controller_domains = [ "controller-001.packet.example.com" ]
worker_names = [ "worker-001" ]
worker_macs = [ "**:**:**:**:**:**" ]
worker_domains = [ "worker-001.packet.example.com" ]

# Bootkube
k8s_domain_name = "k8s.packet.example.com"  # クラスタの DNS 名 (kube-apiserver へアクセスする際に利用)
asset_dir = "assets"  # kubeconfig や種証明書ファイルなどの出力先ディレクトリ
```

また, matchbox 構築時に作成したクライアント証明書 (ca.crt, client.crt, client.key) を `$HOME/.matchbox/` ディレクトリに置きます. (これは今回利用する Terraform ファイルの仕様です.)

さらに, 追加で必要な Terraform プロバイダをインストールしておきます. `go get` でソースコードからバイナリを作ってから,

```bash
$ go get -u github.com/coreos/terraform-provider-matchbox
$ go get -u github.com/coreos/terraform-provider-ct
```

$HOME/.terraformrc を追加, 編集して Terraform CLI から利用できるようにします.

```:$HOME/.terraformrc
providers {
  matchbox = "$GOPATH/bin/terraform-provider-matchbox"
  ct       = "$GOPATH/bin/terraform-provider-ct"
}
```

最後に, Terraform の実行時のそれぞれのマシンへの SSH 接続を可能にするため, ssh-agent に秘密鍵を登録しておきます.

```bash
$ ssh-add ~/.ssh/id_rsa
```

これで準備はできました!

## bootkube の実行

では早速, 実行してみましょう.

```bash
$ terraform init
$ terraform plan
$ terraform apply
```

Terraform を実行するとクラスタに必要な証明書 (etcd や kube-apiserver などの TLS 通信に利用される) の作成や, matchbox へのプロファイルの登録などが行われますが, 証明書を各マシンへデプロイする処理で先へ進まなくなると思います.

```
...
module.cluster.null_resource.copy-worker-secrets: Still creating... (2m30s elapsed)
module.cluster.null_resource.copy-controller-secrets: Still creating... (2m30s elapsed)
module.cluster.null_resource.copy-worker-secrets: Still creating... (2m40s elapsed)
module.cluster.null_resource.copy-controller-secrets: Still creating... (2m40s elapsed)
module.cluster.null_resource.copy-worker-secrets: Still creating... (2m50s elapsed)
module.cluster.null_resource.copy-controller-secrets: Still creating... (2m50s elapsed)
...
```

これは各マシンがまだ起動していないため当然の結果です. そこで各マシンをもう一度 network booting させることにより Terraform の処理を先に進めます. 先程は matchbox にプロファイルが登録されていなかったため起動に失敗していましたが今度は成功するはずです. そこで Terraform とは別のシェルから各マシンに対して次のようなコマンドを実行します.

```bash
curl https://api.packet.net/devices/${DEVICE_ID}/actions \
  -v \
  --header "X-Auth-Token: ${PACKET_TOKEN}" \
  --header 'Content-Type: application/json' \
  --data @- <<EOT
{
  "type": "reinstall",
  "force_delete": false,
  "operating_system": "custom_ipxe",
  "ipxe_script_url": "http://matchbox.example.com:8080/boot.ipxe"
}
EOT
```

ここでは CLI によって network booting を行わせられるための方法が見つからなかったため, packet の [REST API](https://www.packet.com/developers/api/) を直接実行しています. 環境変数 `DEVICE_ID` には controller-001/worker-001 を作成したときのレスポンスに含まれていた ID を, `PACKET_TOKEN` には API のアクセストークンを指定してください. 成功すると matchbox に登録したプロファイルを利用して各マシンが起動するはずです. 成功したときの matchbox のログは次のような感じです.

```
time="2018-12-08T03:24:12Z" level=info msg="HTTP GET /"
time="2018-12-08T03:25:52Z" level=info msg="HTTP GET /boot.ipxe"
time="2018-12-08T03:25:52Z" level=info msg="HTTP GET /ipxe?uuid=00000000-0000-0000-0000-000000000000&mac=**-**-**-**-**-**&domain=&hostname=controller-001&serial=***************"
time="2018-12-08T03:25:52Z" level=debug msg="Matched an iPXE config" labels=map[domain: hostname:controller-001 serial:*************** uuid:00000000-0000-0000-0000-000000000000 mac:**:**:**:**:**:**] profile=example-container-linux-install-controller-001
time="2018-12-08T03:26:09Z" level=info msg="HTTP GET /boot.ipxe"
time="2018-12-08T03:26:09Z" level=info msg="HTTP GET /ipxe?uuid=00000000-0000-0000-0000-000000000000&mac=**-**-**-**-**-**&domain=&hostname=worker-001&serial=***************"
time="2018-12-08T03:26:09Z" level=debug msg="Matched an iPXE config" labels=map[mac:**:**:**:**:**:** domain: hostname:worker-001 serial:*************** uuid:00000000-0000-0000-0000-000000000000] profile=example-container-linux-install-worker-001
time="2018-12-08T03:26:43Z" level=info msg="HTTP GET /ignition?uuid=00000000-0000-0000-0000-000000000000&mac=**-**-**-**-**-**"
time="2018-12-08T03:26:43Z" level=debug msg="Matched an Ignition or Container Linux Config template" group=install-controller-001 labels=map[uuid:00000000-0000-0000-0000-000000000000 mac:**:**:**:**:**:**] profile=example-container-linux-install-controller-001
time="2018-12-08T03:27:03Z" level=info msg="HTTP GET /ignition?uuid=00000000-0000-0000-0000-000000000000&mac=**-**-**-**-**-**"
time="2018-12-08T03:27:03Z" level=debug msg="Matched an Ignition or Container Linux Config template" group=install-worker-001 labels=map[mac:**:**:**:**:**:** uuid:00000000-0000-0000-0000-000000000000] profile=example-container-linux-install-worker-001
time="2018-12-08T03:27:37Z" level=info msg="HTTP GET /ignition?uuid=00000000-0000-0000-0000-000000000000&mac=**-**-**-**-**-**&os=installed"
time="2018-12-08T03:27:37Z" level=debug msg="Matched an Ignition or Container Linux Config template" group=example-controller-001 labels=map[os:installed uuid:00000000-0000-0000-0000-000000000000 mac:**:**:**:**:**:**] profile=example-controller-controller-001
time="2018-12-08T03:27:37Z" level=warning msg="warning parsing Ignition JSON: error: incorrect config version (too new)"
time="2018-12-08T03:27:57Z" level=info msg="HTTP GET /ignition?uuid=00000000-0000-0000-0000-000000000000&mac=**-**-**-**-**-**&os=installed"
time="2018-12-08T03:27:57Z" level=debug msg="Matched an Ignition or Container Linux Config template" group=example-worker-001 labels=map[os:installed uuid:00000000-0000-0000-0000-000000000000 mac:**:**:**:**:**:**] profile=example-worker-worker-001
time="2018-12-08T03:27:57Z" level=warning msg="warning parsing Ignition JSON: error: incorrect config version (too new)"
```

すると止まっていた Terraform による証明書のデプロイ処理も進み始め, Terraform による bootkube の実行まで完了するはずです. bootkube の実行は Terraform によって controller マシンの bootkube.service という Systemd サービスにより実行されます.

```
module.cluster.null_resource.bootkube-start: Still creating... (3m0s elapsed)
module.cluster.null_resource.bootkube-start: Still creating... (3m10s elapsed)
module.cluster.null_resource.bootkube-start: Still creating... (3m20s elapsed)
module.cluster.null_resource.bootkube-start: Still creating... (3m30s elapsed)
module.cluster.null_resource.bootkube-start: Creation complete after 3m32s (ID: 359689546407774373)

Apply complete! Resources: 59 added, 0 changed, 0 destroyed.
```

Terraform の実行が完了すると `assets/auth/` ディレクトリ内に kubeconfig ファイルができているので kubectl で確認してみます.

```
$ kubectl --kubeconfig=assets/auth/kubeconfig get nodes
NAME                                STATUS    ROLES               AGE       VERSION
controller-001.packet.example.com   Ready     controller,master   5m38s     v1.12.3
worker-001.packet.example.com       Ready     node                5m38s     v1.12.3
$ kubectl --kubeconfig=assets/auth/kubeconfig get all --all-namespaces
NAMESPACE     NAME                                                           READY     STATUS    RESTARTS   AGE
kube-system   pod/coredns-7bbc5998b9-tz9mm                                   1/1       Running   0          5m4s
kube-system   pod/coredns-7bbc5998b9-wr6tk                                   1/1       Running   0          5m4s
kube-system   pod/flannel-d7shx                                              2/2       Running   2          5m4s
kube-system   pod/flannel-jw9px                                              2/2       Running   0          5m4s
kube-system   pod/kube-apiserver-r87qg                                       1/1       Running   2          4m38s
kube-system   pod/kube-controller-manager-64b8b56ffd-bcdc7                   1/1       Running   0          5m4s
kube-system   pod/kube-controller-manager-64b8b56ffd-ktg6k                   1/1       Running   2          5m4s
kube-system   pod/kube-proxy-85l6s                                           1/1       Running   0          5m4s
kube-system   pod/kube-proxy-9r2hd                                           1/1       Running   0          5m4s
kube-system   pod/kube-scheduler-769d859ff5-4c7j7                            1/1       Running   0          5m3s
kube-system   pod/kube-scheduler-769d859ff5-vhhct                            1/1       Running   0          5m3s
kube-system   pod/pod-checkpointer-9nhkn                                     1/1       Running   0          4m38s
kube-system   pod/pod-checkpointer-9nhkn-controller-001.packet.example.com   1/1       Running   0          3m51s

NAMESPACE     NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)         AGE
default       service/kubernetes   ClusterIP   10.3.0.1     <none>        443/TCP         5m18s
kube-system   service/coredns      ClusterIP   10.3.0.10    <none>        53/UDP,53/TCP   5m14s

NAMESPACE     NAME                              DESIRED   CURRENT   READY     UP-TO-DATE   AVAILABLE   NODE SELECTOR                     AGE
kube-system   daemonset.apps/flannel            2         2         2         2            2           <none>                            5m14s
kube-system   daemonset.apps/kube-apiserver     1         1         1         1            1           node-role.kubernetes.io/master=   5m14s
kube-system   daemonset.apps/kube-proxy         2         2         2         2            2           <none>                            5m14s
kube-system   daemonset.apps/pod-checkpointer   1         1         1         1            1           node-role.kubernetes.io/master=   5m14s

NAMESPACE     NAME                                      DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
kube-system   deployment.apps/coredns                   2         2         2            2           5m14s
kube-system   deployment.apps/kube-controller-manager   2         2         2            2           5m14s
kube-system   deployment.apps/kube-scheduler            2         2         2            2           5m14s

NAMESPACE     NAME                                                 DESIRED   CURRENT   READY     AGE
kube-system   replicaset.apps/coredns-7bbc5998b9                   2         2         2         5m4s
kube-system   replicaset.apps/kube-controller-manager-64b8b56ffd   2         2         2         5m4s
kube-system   replicaset.apps/kube-scheduler-769d859ff5            2         2         2         5m4s
```

クラスタができていますね! Yay!!

## まとめ

1. bootkube を利用して packet 上に bare-metal な Kubernetes クラスタを作ることに成功しました.
2. このとき, OS のセットアップには matchbox というサービスを利用して PXE による network booting を行いました.