Kubespray と Terraform でクラスターを構築する
Terraform がインベントリを作り、Kubespray を呼び、その上にデプロイする
目標
同じVMの中で、OpenTofuがkubesprayのインベントリをテンプレートで作り、kubesprayの実行をラップしてクラスターを構築し、構築したクラスターに対して、kubernetesプロバイダーとhelmプロバイダーで、ネームスペース・RBAC・アプリケーションを宣言します。外で変更されたものをplanで捉えて、元に戻します。
なぜ重要なのか
現場のクラスターのライフサイクルは、普通は3つの層です。ノードを作る層(クラウド・仮想化)、ノードの上にKubernetesをインストールする層(kubespray)、クラスターの上にチームの領域と権限とアプリを用意する層です。Terraformは、最初の層と3つ目の層に強く、2つ目の層はAnsibleに任せるのが一般的です。このラボは、その境界を1台のVMの中でつなぎ合わせてみます。何をTerraformのstateとして持ち、何をkubesprayの冪等性に任せ、宣言と実際が食い違ったとき、誰がそれに気づくのかです。 ノードを作る最初の層は、このVMでは行いません。このプラットフォームの仮想化APIをラボの中で呼ばせると、分離が壊れるからです。インストールを含めて、約20分待ちます。
ステップ
hashicorp/localプロバイダーで3種類のlocal_fileを宣言してください(書き込み先:/root/ks/tf/cluster/main.tf)。inventory(/root/ks/inventory/lab/inventory.iniを、/root/ks/tf/cluster/inventory.tftplのテンプレートとnodes変数で作る)、host_vars(ノードごとに/root/ks/inventory/lab/host_vars/<노드>.ymlにansible_connectionを書く)、version(/root/ks/inventory/lab/group_vars/k8s_cluster/zz-terraform.ymlにkube_version: <변수>を書く)です(プレースホルダーはノード名と変数です)。kube_version変数のデフォルト値は1.35.8、nodesのデフォルト値はnode1の1台(コントロールプレーンとワーカーを兼ね、local接続)です。tofu validateが通過する必要があります。/root/ks/tf/clusterでtofu applyを使って、3つのファイルを作成してください。作成された/root/ks/inventory/lab/inventory.iniが、stateのlocal_file.inventoryの内容と同じで、ansible-inventoryで読み取ったときに、node1がkube_control_plane・etcd・kube_nodeにあり、kube_versionが1.35.8と展開される必要があります。terraform_data "kubespray"を加えて(対象:/root/ks/tf/cluster/main.tf)、インベントリ・バージョン・host_varsのファイルの内容が変わったときにだけ作り直されるように(triggers_replace)し、作られるときにlocal-execで/opt/ks/kubesprayからansible-playbook -i /root/ks/inventory/lab/inventory.ini cluster.ymlを実行して、出力を残すようにしてください(保存先:/root/ks/logs/tf-cluster.log。HOME=/rootを渡します)。tofu applyでインストールまで終わったら(約7分)、stateにterraform_data.kubesprayがあり、ログのPLAY RECAPがfailed=0で、node1がReadyである必要があります。/root/ks/tf/clusterでtofu plan -detailed-exitcodeをそのままで1回、-var kube_version=1.36.4を付けて1回実行してみてください(適用しません)。次のフィールドを書いてください(書き込み先:/root/ks/tf/plan.json)。steady_exit(最初のplanの終了コード)、bump_exit(2回目の終了コード)、bump_replaces(2回目のplanが変更または作り直すと言っているリソースのアドレスのソートした配列)です。hashicorp/kubernetes(3.2.1)とhashicorp/helm(3.3.0)のプロバイダーを/root/.kube/configで設定し、ネームスペースteam-a(ラベルowner=platform)、ServiceAccountdeployer、deploymentsを作成して直せて、podsとservicesは読み取りだけを許可するRoledeployerとそのRoleBinding、そしてローカルチャート/opt/ks/charts/helloをteam-aにレプリカ2でインストールするhelm_release "hello"を宣言して、tofu applyしてください(書き込み先:/root/ks/tf/apps/main.tf)。deployerは、team-aでdeploymentsを作成でき、ノードは削除できない必要があり、helloのPod2つがReadyである必要があります。kubectl label ns team-a owner=someone-else --overwriteでネームスペースのラベルを外で変更してから、/root/ks/tf/appsでtofu plan -detailed-exitcodeを使って差分を捉え、出力を残してください(保存先:/root/ks/tf/drift-plan.txt)。次のフィールドを書きます(書き込み先:/root/ks/tf/drift.json)。exit_code(そのplanの終了コード)、drifted(変更すると出てきたリソースのアドレスのソートした配列)です。まだ適用しません。/root/ks/tf/appsでtofu applyを使って、差分を元に戻してください。終わったら、team-aのownerラベルがplatformで、tofu plan -detailed-exitcodeが0である必要があります。そして、/root/ks/tf/clusterのplanも0である必要があります(クラスター側の宣言もそのまま)。
参考
- OpenTofu 1.12.6が
/usr/local/bin/tofu(とterraform)に、プロバイダー(local 2.9.1・kubernetes 3.2.1・helm 3.3.0)がプラグインキャッシュ/opt/ks/tf-plugin-cacheに、あらかじめダウンロードしてあります。設定はTF_CLI_CONFIG_FILE=/root/.tofurcです。 - kubespray v2.32.0が
/opt/ks/kubesprayに、インベントリディレクトリ/root/ks/inventory/labのgroup_varsが用意されています。inventory.ini・host_vars・バージョンのファイルは、Terraformが作ります。 - よくあるミス: クラスターを作るモジュールと、その上にデプロイするモジュールを1つにまとめることです。最初のplanで、まだないkubeconfigを読み取ることになります。
- よくあるミス: Terraformが作ったinventory.iniを手で直すことです。次のapplyが元に戻し、内容が変わったので、kubesprayも再び呼ばれます。
- ドキュメント: OpenTofu: templatefile・OpenTofu: terraform_data・OpenTofu: plan -detailed-exitcode・Kubernetes provider・Helm provider
インベントリをテンプレートで作る
hashicorp/localプロバイダーで3種類のlocal_fileを宣言してください(書き込み先: /root/ks/tf/cluster/main.tf)。inventory(/root/ks/inventory/lab/inventory.iniを、/root/ks/tf/cluster/inventory.tftplのテンプレートとnodes変数で作る)、host_vars(ノードごとに/root/ks/inventory/lab/host_vars/<노드>.ymlにansible_connectionを書く)、version(/root/ks/inventory/lab/group_vars/k8s_cluster/zz-terraform.ymlにkube_version: <변수>を書く)です(プレースホルダーはノード名と変数です)。kube_version変数のデフォルト値は1.35.8、nodesのデフォルト値はnode1の1台(コントロールプレーンとワーカーを兼ね、local接続)です。tofu validateが通過する必要があります。
templatefile()は、%{ for } … %{ endfor }ディレクティブで繰り返し、~は改行を消費します。ノードのロールを変数(マップ)に置けば、ノードを増やすとき、マップに1行を加えるだけで、インベントリとhost_varsが一緒に変わります。レシピがk8s-cluster.ymlに書いておいたバージョンの行は、削除してあります。バージョンの管理主体を、Terraform 1つにするためです。OpenTofuは、terraformという名前でも呼び出せます。
ファイルだけを先にapply
/root/ks/tf/clusterでtofu applyを使って、3つのファイルを作成してください。作成された/root/ks/inventory/lab/inventory.iniが、stateのlocal_file.inventoryの内容と同じで、ansible-inventoryで読み取ったときに、node1がkube_control_plane・etcd・kube_nodeにあり、kube_versionが1.35.8と展開される必要があります。
stateには、Terraformが作ったファイルの内容がそのまま入っています。誰かがinventory.iniを手で直すと、次のplanがそれを元に戻すと出ます。このファイルの管理主体が、今やTerraformだという意味です。tofu state show local_file.inventoryで内容を見られます。
terraform_dataでkubesprayを呼ぶ
terraform_data "kubespray"を加えて(対象: /root/ks/tf/cluster/main.tf)、インベントリ・バージョン・host_varsのファイルの内容が変わったときにだけ作り直されるように(triggers_replace)し、作られるときにlocal-execで/opt/ks/kubesprayからansible-playbook -i /root/ks/inventory/lab/inventory.ini cluster.ymlを実行して、出力を残すようにしてください(保存先: /root/ks/logs/tf-cluster.log。HOME=/rootを渡します)。tofu applyでインストールまで終わったら(約7分)、stateにterraform_data.kubesprayがあり、ログのPLAY RECAPがfailed=0で、node1がReadyである必要があります。
local-execは、Terraformを動かす場所(このVM)でコマンドを実行します。コマンドが失敗すると、リソースがtaintedのまま残り、次のapplyが再び呼びます。kubesprayのkubeモジュールは、HOMEが空だとkubeconfigを見つけられないので、environmentで渡します。applyが7分間ターミナルを占有するので、systemd-runやtmuxで起動するほうが安全です。
変更がなければ何もしない: バージョンを変えるとどうなるか
/root/ks/tf/clusterでtofu plan -detailed-exitcodeをそのままで1回、-var kube_version=1.36.4を付けて1回実行してみてください(適用しません)。次のフィールドを書いてください(書き込み先: /root/ks/tf/plan.json)。steady_exit(最初のplanの終了コード)、bump_exit(2回目の終了コード)、bump_replaces(2回目のplanが変更または作り直すと言っているリソースのアドレスのソートした配列)です。
-detailed-exitcodeは、変わるものがなければ0を、あれば2を返します。バージョンを変えると何が作り直されるかを見てください。terraform_dataが作り直されると、cluster.ymlが再び呼ばれます。kubesprayのアップグレードは、cluster.ymlではなくupgrade-cluster.ymlです。Terraformは、その違いを知りません。tofu plan -jsonのresource_changesが、アドレスと動作(updateやreplace)を教えてくれます。
構築したクラスターに宣言でデプロイする
hashicorp/kubernetes(3.2.1)とhashicorp/helm(3.3.0)のプロバイダーを/root/.kube/configで設定し、ネームスペースteam-a(ラベルowner=platform)、ServiceAccountdeployer、deploymentsを作成して直せて、podsとservicesは読み取りだけを許可するRoledeployerとそのRoleBinding、そしてローカルチャート/opt/ks/charts/helloをteam-aにレプリカ2でインストールするhelm_release "hello"を宣言して、tofu applyしてください(書き込み先: /root/ks/tf/apps/main.tf)。deployerは、team-aでdeploymentsを作成でき、ノードは削除できない必要があり、helloのPod2つがReadyである必要があります。
クラスターを構築するルートモジュールと、その上にデプロイするルートモジュールを分ける理由があります。1つのモジュールでクラスターを作り、同じapplyでそのクラスターに対してプロバイダーを設定すると、最初のplanのときに、まだないkubeconfigを読み取る必要があります。helmプロバイダー3.xでは、kubernetesの設定がブロックではなくkubernetes = {{ ... }}属性で、setもリストの属性です。権限は、kubectl auth can-i ... --as=system:serviceaccount:team-a:deployerで確認します。
誰かが外で変更した
kubectl label ns team-a owner=someone-else --overwriteでネームスペースのラベルを外で変更してから、/root/ks/tf/appsでtofu plan -detailed-exitcodeを使って差分を捉え、出力を残してください(保存先: /root/ks/tf/drift-plan.txt)。次のフィールドを書きます(書き込み先: /root/ks/tf/drift.json)。exit_code(そのplanの終了コード)、drifted(変更すると出てきたリソースのアドレスのソートした配列)です。まだ適用しません。
planは、まず実際の状態を読み取って(refresh)stateと比較し、そのあと宣言と比較します。外で変更されたものが宣言と異なれば、宣言どおりに戻す計画が出ます。これがドリフトの検知で、定期的にplanを実行して、終了コード2を通知につなげるのが、よくある運用方式です。
宣言に戻す
/root/ks/tf/appsでtofu applyを使って、差分を元に戻してください。終わったら、team-aのownerラベルがplatformで、tofu plan -detailed-exitcodeが0である必要があります。そして、/root/ks/tf/clusterのplanも0である必要があります(クラスター側の宣言もそのまま)。
applyは、planと同じ比較をもう一度行って、差分を宣言のとおりに合わせます。ドリフトを元に戻すか、それとも、外で変更したものが正しいので宣言を直すかは、人が判断することです。今回は、宣言が正しいと判断して、元に戻します。