CDK for Terraformでfor_eachをなんとか表現してみた
こんにちは、クラウドエースの阿部です。
これまでCDK for Terraform(以降、CDKTF)について以下のような記事を作成しました。
Terraformでは、複数リソースを作成するとき、1つ1つリソースを定義せず可変させたいパラメータのみリスト化してfor_each
メタ引数を使って複製する方法が常套手段になっています。
しかし、ざっくり調査した限りではComplex Typeのリストをfor_each
に入れる事ができずTerraform HCLのような書き方ができなかったのでがっかりしていました。
この記事はfor_each
についてもう少し何とかならないかなぁと頑張ってみた記録になります。
Terraform HCLで表現できる内容
Terraform HCLでは以下のように書けるコードを、CDKTFでどう書けるか探っていきたいと思います。
locals {
region = "asia-northeast1"
vm_configs = [
{
name = "bravo"
zone = "asia-northeast1-a"
machine_type = "e2-medium"
},
{
name = "charlie"
zone = "asia-northeast1-b"
machine_type = "e2-micro"
},
{
name = "delta"
zone = "asia-northeast1-c"
machine_type = "f1-micro"
}
]
}
resource "google_compute_network" "network" {
name = "cdktf-test"
routing_mode = "REGIONAL"
auto_create_subnetworks = false
}
resource "google_compute_subnetwork" "subnetwork" {
name = "cdktf-test"
ip_cidr_range = "10.10.0.0/16"
network = google_compute_network.network.self_link
region = local.region
private_ip_google_access = true
}
resource "google_compute_instance" "main" {
for_each = { for v in local.vm_configs : v["name"] => v }
name = each.value["name"]
zone = each.value["zone"]
machine_type = each.value["machine_type"]
boot_disk {
initialize_params {
image = "debian-os-cloud/debian-10"
size = 10
type = "pd-standard"
}
}
network_interface {
network = google_compute_network.network.self_link
subnetwork = google_compute_subnetwork.subnetwork.self_link
}
tags = ["iap"]
}
cdktf convertを使ってコードを生成してみる
CDKTFには cdktf convert
というtfファイルからCDKTFコードに変換するコマンドがあります。これを使ってコードを変換します。
前述のTerraformサンプルコード(sample.tf
)に対して以下のようなコマンドを実行してみます。
cat sample.tf | cdktf convert
すると、以下のような結果が出力されます。
/*Provider bindings are generated by running cdktf get.
See https://cdk.tf/provider-generation for more details.*/
import * as google from "./.gen/providers/google";
/*The following providers are missing schema information and might need manual adjustments to synthesize correctly: google.
For a more precise conversion please use the --provider flag in convert.*/
const region = "asia-northeast1";
const vmConfigs = [
{
machine_type: "e2-medium",
name: "bravo",
zone: "asia-northeast1-a",
},
{
machine_type: "e2-micro",
name: "charlie",
zone: "asia-northeast1-b",
},
{
machine_type: "f1-micro",
name: "delta",
zone: "asia-northeast1-c",
},
];
const googleComputeNetworkNetwork = new google.ComputeNetwork(this, "network", {
auto_create_subnetworks: false,
name: "cdktf-test",
routing_mode: "REGIONAL",
});
const googleComputeSubnetworkSubnetwork = new google.ComputeSubnetwork(
this,
"subnetwork",
{
ip_cidr_range: "10.10.0.0/16",
name: "cdktf-test",
network: googleComputeNetworkNetwork.selfLink,
private_ip_google_access: true,
region: region,
}
);
const googleComputeInstanceMain = new google.ComputeInstance(this, "main", {
boot_disk: [
{
initialize_params: [
{
image: "debian-os-cloud/debian-10",
size: 10,
type: "pd-standard",
},
],
},
],
machine_type: '${each.value["machine_type"]}',
name: '${each.value["name"]}',
network_interface: [
{
network: googleComputeNetworkNetwork.selfLink,
subnetwork: googleComputeSubnetworkSubnetwork.selfLink,
},
],
tags: ["iap"],
zone: '${each.value["zone"]}',
});
/*In most cases loops should be handled in the programming language context and
not inside of the Terraform context. If you are looping over something external, e.g. a variable or a file input
you should consider using a for loop. If you are looping over something only known to Terraform, e.g. a result of a data source
you need to keep this like it is.*/
googleComputeInstanceMain.addOverride(
"for_each",
`\${{ for v in ${vmConfigs} : v["name"] => v }}`
);
なんというか、これが正解コードなのか……?という気持ちになりながら、このコードをmain.ts
にコピペして試してみます。
しかし、このコード、うまく動きません。よく見るとgoogle.ComputeNetwork
の属性で、auto_create_subnetworks
やrouting_mode
といったスネークケースな属性名が使われています。
これらは本来autoCreateSubnetworks
やroutingMode
といったキャメルケースになっていなければならないはずで、誤変換されています。(他のリソースも属性名が誤っています。)
変換時に出力されたコメントをよく読むと「The following providers are missing schema information and might need manual adjustments to synthesize correctly」と書いてあるので然もありなんですね。
このあたりの属性名を修正して試して見ます。
余計なコメントを削除しつつ手修正したコードが以下になります。
const region = "asia-northeast1";
const vmConfigs = [
{
machine_type: "e2-medium",
name: "bravo",
zone: "asia-northeast1-a",
},
{
machine_type: "e2-micro",
name: "charlie",
zone: "asia-northeast1-b",
},
{
machine_type: "f1-micro",
name: "delta",
zone: "asia-northeast1-c",
},
];
const googleComputeNetworkNetwork = new google.ComputeNetwork(this, "network", {
autoCreateSubnetworks: false,
name: "cdktf-test",
routingMode: "REGIONAL",
});
const googleComputeSubnetworkSubnetwork = new google.ComputeSubnetwork(
this,
"subnetwork",
{
ipCidrRange: "10.10.0.0/16",
name: "cdktf-test",
network: googleComputeNetworkNetwork.selfLink,
privateIpGoogleAccess: true,
region: region,
}
);
const googleComputeInstanceMain = new google.ComputeInstance(this, "main", {
bootDisk: {
initializeParams: {
image: "debian-os-cloud/debian-10",
size: 10,
type: "pd-standard",
},
},
machineType: '${each.value["machine_type"]}',
name: '${each.value["name"]}',
networkInterface: [
{
network: googleComputeNetworkNetwork.selfLink,
subnetwork: googleComputeSubnetworkSubnetwork.selfLink,
},
],
tags: ["iap"],
zone: '${each.value["zone"]}',
});
googleComputeInstanceMain.addOverride(
"for_each",
`\${{ for v in ${vmConfigs} : v["name"] => v }}`
);
しかし、このコードもcdktf diff
実行時にエラーになります。
│ Error: Missing item separator
│
│ on cdk.tf.json line 39, in resource.google_compute_instance.main (main):
│ 39: "for_each": "${{ for v in [object Object],[object Object],[object Object] : v[\"name\"] => v }}",
│
│ Expected a comma to mark the beginning of the next item.
╵
メッセージから \${{ for v in ${vmConfigs} : v["name"] => v }}
の部分の変換が意図していないように見えるため、 cdktf.out
ディレクトリにあるTerraformコードファイル(cdk.tf.json
)を確認してみます。
該当部分は以下のようになっていました。
"resource": {
"google_compute_instance": {
"main": {
...(snip)...
"for_each": "${{ for v in [object Object],[object Object],[object Object] : v[\"name\"] => v }}",
...(snip)...
}
},
エラーメッセージの通りですね。Typescriptのテンプレートリテラル に指定した ${vmConfigs}
がそのまま文字列として変換されて cdk.tf.json
に出力されてしまったようです。
ここを何とかする必要がありそうです。いろいろと試して見ましたが、vmConfigs
をTypescriptのArrayではなくTerraformのLocal変数として定義し、${vmConfigs}
をlocal.vm_configs
と修正することで動作しました。
new TerraformLocal(this, "vm_configs", [
{
machine_type: "e2-medium",
name: "bravo",
zone: "asia-northeast1-a",
},
{
machine_type: "e2-micro",
name: "charlie",
zone: "asia-northeast1-b",
},
{
machine_type: "f1-micro",
name: "delta",
zone: "asia-northeast1-c",
},
]);
googleComputeInstanceMain.addOverride(
"for_each",
`\${{ for v in local.vm_configs : v["name"] => v }}`
);
"resource": {
"google_compute_instance": {
"main": {
...(snip)...
"for_each": "${{ for v in local.vm_configs : v[\"name\"] => v }}",
...(snip)...
}
},
ただし、ここまで読んでお気づきと思いますが、cdktf convert
で得られるようなコードは、TypescriptからTerraform HCL(JSON)へどのように変換されるかを想像しながらコードを記述しなければならないため、書いていて結構つらいと思います。
特に、IDEのコード補間の恩恵を一切受けられず、cdktf synth
をこまめに実行して様子をみるということをしないと正しいコードが得られないといった状況です。
一応、この方法でfor_each
は表現できそうですが、実用的かというと、ちょっといまいちな感じです。
もっとTypescriptらしくコードを書きたい
Terraformのfor_each
は、設定言語としてのHCLをプログラマブルに記述するための仕組みです。
Typescriptはそれ自体がプログラミング言語であり、プログラミング言語の機能を使用してリソースを複製するコードを書けばよいのです。
(当たり前のことをそれらしく言っているだけという)
今回のケースであればvmConfigで可変させた設定項目をArrayとして定義しているので、Arrayの各要素に対して処理を行うループ処理をかけば十分ということになります。
実はterraform-cdkリポジトリのサンプルコードにaws-prebuiltがあり、そこで今回求めているコードのサンプルがありました。
このコードを参考に、やりたい処理を記述してみます。
const region = "asia-northeast1";
const vmConfigs = [
{
machineType: "e2-medium",
name: "bravo",
zone: "asia-northeast1-a",
},
{
machineType: "e2-micro",
name: "charlie",
zone: "asia-northeast1-b",
},
{
machineType: "f1-micro",
name: "delta",
zone: "asia-northeast1-c",
},
];
const network = new ComputeNetwork(this, 'network', {
name: 'cdktf-test',
routingMode: 'REGIONAL',
autoCreateSubnetworks: false,
});
const subnet = new ComputeSubnetwork(this, 'subnetwork', {
name: 'cdktf-test',
region: region,
ipCidrRange: '10.10.0.0/16',
network: network.selfLink,
privateIpGoogleAccess: true
});
vmConfigs.map((i) => {
return new ComputeInstance(this, `instance-${i.name}`, {
name: i.name,
zone: i.zone,
machineType: i.machineType,
bootDisk: {
initializeParams: {
image: 'ubuntu-os-cloud/ubuntu-2004-lts',
size: 10,
type: 'pd-standard'
},
},
networkInterface: [
{
network: network.selfLink,
subnetwork: subnet.selfLink,
}
],
tags: ["iap"]
});
});
こんな感じで記述できました。ポイントは、リソースIDはテンプレートリテラルで可変させるようにする箇所です。
上記サンプルではインスタンス名を付与する形で instance-${i.name}
と記述しています。
Typescriptのスキルが低すぎてこういう書き方ができることが分かっていませんでしたが、書いてみるとあっさり動作します
ただ、こうして記述したコードはちょっとしたデメリットがあります。CDKTFが生成するTerraformコード(cdk.tf.json
)では、1つ1つ作成したようなコードになるということです。
該当部分だけ抜き出すと以下のようになっていました。(instance-bravo
、instance-charlie
、instance-delta
と3つのリソースをべた書きしたようなコードになります)
"google_compute_instance": {
"instance-bravo": {
"machine_type": "e2-medium",
"name": "bravo",
"zone": "asia-northeast1-a"
...(snip)...
},
"instance-charlie": {
"machine_type": "e2-micro",
"name": "charlie",
"zone": "asia-northeast1-b"
...(snip)...
},
"instance-delta": {
"machine_type": "f1-micro",
"name": "delta",
"zone": "asia-northeast1-c"
...(snip)...
}
},
CDKTFで生成したTerraformコードを流用するケースでは問題になるかも知れませんが、CDKTFのみで運用するのであればこういう動作だと割り切ってしまえば問題ないと思います。
まとめ
CDKTFでTerraformのfor_each
そのものを表現しようとすると厳しいです。
しかし、Typescript等の言語機能でループさせてリソース作成すれば問題無く定義できそうです。
今後も勉強が必要だなと思います。(特にTypescript!)
Discussion