Terraform 連載2024 の2本目の記事です。
はじめに TIG真野です。
Terraformファイルをコード生成するため、hclwriteというGoパッケージの使い方を調べました。
モチベーション ある複数のリソースをセットで定義する設計開発ルールがあったとします。AWSの例ですが、以下のようにDynamoDBとその監視をCloudwatch Metricsを用いてセットで行いたいとします。
resource "aws_dynamodb_table" "myproduct_read" { name = "${terraform.workspace} -myproduct-read" billing_mode = "PAY_PER_REQUEST" hash_key = "user_id" range_key = "content_id" deletion_protection_enabled = true attribute { name = "user_id" type = "S" } attribute { name = "content_id" type = "S" } } resource "aws_cloudwatch_metric_alarm" "myproduct_read_dynamodb_throttledrequests" { alarm_name = "${aws_dynamodb_table.myproduct_read.name} -ThrottledRequests" comparison_operator = "GreaterThanOrEqualToThreshold" datapoints_to_alarm = "1" evaluation_periods = "1" metric_name = "ThrottledRequests" namespace = "AWS/DynamoDB" period = "60" statistic = "Maximum" threshold = "1" alarm_actions = aws_sns_topic.myproduct_alert.arn dimensions = { TableName = aws_dynamodb_table.read.name } }
ここではスロットリング数だけを監視していますが、もう1~5個くらい監視したい項目があったとします。この例のように、あるリソースの追加に合わせて整合性を保ちつつ別のリソースを追加することは難しく、抜け漏れがちです。
正攻法だとTerraform module化でしょう。しかし、このケースではモジュール化するにしてはリソース数が少なく、モジュール化すること自体が新規参画した開発者にとって認知負荷が高いことを考えると、もう少しプロダクトが成長して、関連するリソースが増えるかどうかを待ってから対応を考えたいケースもあるでしょう。もちろんチームの方針としてこれくらいでもすぐにモジュール化に取り掛かる場合もあるかと思いますが、チームのTerraform習熟度にバラツキがあり設計パターンを抑えたいなど、様々な背景があったとします。
こうした場面で、あるTerraformで定義したリソース(ここではDynamoDB)をインプットに別のコード(Cloudwatch Metrics)を生成し、モジュール化しなくとも不整合が生じにくい開発フローを整備したいと思います。これでモジュール化の判断を先送りにできますね。
ちなみに、通常、DynamoDBはそこまで数が増えない(ほいほい増えるようであればおそらくDynamoDBを使うべきではない)し、監視項目もそう変更しないだろうから、コード生成もモジュール化しなくても良いんじゃないか? という意見もあるかと思いますが、それはそれとします。
整合性チェック この回のケースではRego(Conftest )を使って整合性チェックを入れるのも有効でしょう。しかし開発チームにRego経験者はほとんど供給され無いと思うので、学習コストが多少なりとも掛かります。また、不整合を検知できるのであれば自動でFixしてくれた方が開発者フレンドリーです。
そのため、この記事では .tf ファイルの自動生成に注目します。
利用パッケージ Terraformの .tf コードをパース、生成する方法として有名なのは、hashicorp/hcl を用いることです。Go言語でインポートしてライブラリとして使えます。HCLはTerraformの.tfファイルが利用するファイルフォーマットのことです。
管理されているパッケージはいくつかあり、以下のようなものが含まれていて、いい感じに使い分けるリテラシーが求められます。
パッケージの概略はthaimさんのZenn記事のHCLファイルを hashicorp/hcl で読み書きする が実装もありイメージしやすいです。
ここでも簡単に一覧を載せます。
今回はTerraformコードの細かい解析は不要であるため、hclwriteパッケージを利用します。
hclwrite でファイル読み込み バージョンは hashicorp/hcl/v2 v2.20.0
を利用します。
まずは.tfファイルを読み込みます。
hclwrite.ParseConfig()
でパースしたいファイルを指定します。
package mainimport ( "fmt" "log" "os" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclwrite" ) func main () { if len (os.Args) == 1 { log.Fatalf("Usage: %s <filepath>\n" , os.Args[0 ]) } hclFilePath := os.Args[1 ] file, err := os.ReadFile(os.Args[1 ]) if err != nil { log.Fatalf("Usage: %s <filepath>\n" , os.Args[1 ]) } tfFile, diags := hclwrite.ParseConfig(file, hclFilePath, hcl.Pos{Line: 1 , Column: 1 }) if diags != nil && diags.HasErrors() { log.Fatalf("hclwrite parse: %s" , diags) } if tfFile == nil { log.Fatalf("parse result is nil: %s" , hclFilePath) } blocks := tfFile.Body().Blocks() referenceNames := make ([]string , 0 , len (blocks)) for _, b := range blocks { if b.Type() != "resource" || b.Labels()[0 ] != "aws_dynamodb_table" { continue } referenceNames = append (referenceNames, b.Labels()[1 ]) } fmt.Println(referenceNames) }
結果の取得は tfFile.Body().Blocks()
という部分で取得できます。
Blockとはなにかですが、 resource
、module
、locals
のようなTerraform上でインデントをともなうような塊を指します。例えば、resource "aws_dynamodb_table" "table1" {...}
といった定義が10あれば、for文が10呼ばれます。
次にわかりにくいのが b.Labels()
の部分です。これは aws_dynamodb_table
, table1
といったブロックを開くときに設定されるTerraformのリソースタイプ、リソース名が入ります。
今回はTerraformのリソース名を取得して表示するとします。
次のようなファイルがあるとします。
dynamodb_table.tf resource "aws_dynamodb_table" "myproduct_read" { name = "${terraform.workspace} -myproduct-read" billing_mode = "PAY_PER_REQUEST" hash_key = "user_id" range_key = "content_id" deletion_protection_enabled = true attribute { name = "user_id" type = "S" } attribute { name = "content_id" type = "S" } } resource "aws_dynamodb_table" "myproduct_content" { name = "${terraform.workspace} -myproduct-read" billing_mode = "PAY_PER_REQUEST" hash_key = "content_id" deletion_protection_enabled = true attribute { name = "content_id" type = "S" } }
さきほどのコードを実行すると、Terraformリソース名が取れています。
$ go run . ../example/dynamodb_table.tf [myproduct_read myproduct_content]
属性を取得するためには、b.Body().GetAttribute()
などで取得できますので、目的に応じて条件を追加できます。
空リソース生成 次に.tfファイルを生成します。
hclwrite.NewFile()
で初期化し、そこにブロック(Terraformリソース)を追加していきます。
今回は新規コード生成なので、先頭に // DO NOT EDIT
コメントを追加しましょう。
hclwriteを用いるとコメントを追加する便利な関数は(おそらく)存在しないので、いきなりですがトークンレベルの操作となる、AppendUnstructedTokens()
を用います。
func main () { newFile := hclwrite.NewFile() newFile.Body().AppendUnstructuredTokens(hclwrite.Tokens{ { Type: hclsyntax.TokenIdent, Bytes: []byte ("// DO NOT EDIT, MADE BY hclwrite-dynamodb-generator\n" ), }, }) for _, resourceName := range referenceNames { newFile.Body().AppendUnstructuredTokens(hclwrite.Tokens{ {Type: hclsyntax.TokenNewline, Bytes: []byte ("\n" )}, }) labels := []string {"aws_cloudwatch_metric_alarm" , fmt.Sprintf("dynamodb_throttledrequests_%s" , resourceName)} newFile.Body().AppendNewBlock("resource" , labels) } out := hclwrite.Format(newFile.BuildTokens(nil ).Bytes()) _, _ = fmt.Fprint(os.Stdout, string (out)) }
for 文の中にある、 AppendNewBlock()
が今回出力したい本丸の、aws_cloudwatch_metric_alarm
リソースを追加する部分です。
実行すると次のような空リソースが生成されます。
$ go run . ../example/dynamodb_table_one.tf // DO NOT EDIT, MADE BY hclwrite-dynamodb-generator resource "aws_cloudwatch_metric_alarm" "dynamodb_throttledrequests_myproduct_read" { } resource "aws_cloudwatch_metric_alarm" "dynamodb_throttledrequests_myproduct_content" { }
属性の追加 SetAttributeRaw()
を用いて各属性ごとに項目を追加していきます。 SetAttributeRaw()
は低レベルのAPIで、トークンを直接追加します。今回、"${aws_dynamodb_table.myproduct_read.name}-throttledrequests"
といったリファレンスを追加したいため利用しています。SetAttributeValue()
を使う方法だと、 $
がエスケープされて、 $$
と出力されてしまうためです。
また、特記したいことはzclconf/go-cty というライブラリの型で値を競ってしないとならないことです。ここでさらに別のライブラリ? と一瞬焦る気持ちがありますが、慣れていきましょう。
属性の型がオブジェクトであり、その中にリファレンスが入ると、再び AppendNewBlock()
を呼び出す必要があるなど、生成したい定義によっては試行錯誤する必要があるので、注意してください。
func main () { for _, resourceName := range referenceNames { newFile.Body().AppendUnstructuredTokens(hclwrite.Tokens{ {Type: hclsyntax.TokenNewline, Bytes: []byte ("\n" )}, }) labels := []string {"aws_cloudwatch_metric_alarm" , fmt.Sprintf("dynamodb_throttledrequests_%s" , resourceName)} resource := newFile.Body().AppendNewBlock("resource" , labels).Body() resource.SetAttributeRaw("alarm_name" , hclwrite.Tokens{ { Type: hclsyntax.TokenIdent, Bytes: []byte (`"${aws_dynamodb_table.myproduct_read.name}-throttledrequests"` ), }, }) resource.SetAttributeValue("comparison_operator" , cty.StringVal("GreaterThanOrEqualToThreshold" )) resource.SetAttributeValue("datapoints_to_alarm" , cty.StringVal("1" )) resource.SetAttributeValue("evaluation_periods" , cty.StringVal("1" )) resource.SetAttributeValue("metric_name" , cty.StringVal("ThrottledRequests" )) resource.SetAttributeValue("namespace" , cty.StringVal("AWS/DynamoDB" )) resource.SetAttributeValue("period" , cty.StringVal("60" )) resource.SetAttributeValue("statistic" , cty.StringVal("Maximum" )) resource.SetAttributeValue("threshold" , cty.StringVal("1" )) resource.SetAttributeTraversal("alarm_actions" , hcl.Traversal{ hcl.TraverseRoot{Name: "aws_sns_topic" }, hcl.TraverseAttr{Name: "myproduct_alert" }, hcl.TraverseAttr{Name: "arn" }, }) dimensions := resource.AppendNewBlock("dimensions" , nil ).Body() dimensions.SetAttributeTraversal("TableName" , hcl.Traversal{ hcl.TraverseRoot{Name: "aws_dynamodb_table" }, hcl.TraverseAttr{Name: "myproduct_read" }, hcl.TraverseAttr{Name: "name" }, }) } out := hclwrite.Format(newFile.BuildTokens(nil ).Bytes()) _, _ = fmt.Fprint(os.Stdout, string (out)) }
これを実行すると次のようにTerraformコードが生成されます。
$ go run . example/dynamodb_table_one.tf // DO NOT EDIT, MADE BY hclwrite-dynamodb-generator resource "aws_cloudwatch_metric_alarm" "dynamodb_throttledrequests_myproduct_read" { alarm_name = "${aws_dynamodb_table.myproduct_read.name} -throttledrequests" comparison_operator = "GreaterThanOrEqualToThreshold" datapoints_to_alarm = "1" evaluation_periods = "1" metric_name = "ThrottledRequests" namespace = "AWS/DynamoDB" period = "60" statistic = "Maximum" threshold = "1" alarm_actions = aws_sns_topic.myproduct_alert.arn dimensions { TableName = aws_dynamodb_table.myproduct_read.name } } resource "aws_cloudwatch_metric_alarm" "dynamodb_throttledrequests_myproduct_content" { alarm_name = "${aws_dynamodb_table.myproduct_read.name} -throttledrequests" comparison_operator = "GreaterThanOrEqualToThreshold" datapoints_to_alarm = "1" evaluation_periods = "1" metric_name = "ThrottledRequests" namespace = "AWS/DynamoDB" period = "60" statistic = "Maximum" threshold = "1" alarm_actions = aws_sns_topic.myproduct_alert.arn dimensions { TableName = aws_dynamodb_table.myproduct_read.name } }
hclwriteパッケージ自体は文法チェックを行いません。そのため alarm_actions = aws_sns_topic.myproduct_alert.arn
としれっと存在しないリソースを参照してもエラーにはなりません。
説明を省きましたが hclwrite.Format()
でフォーマットをかけられるので、お手軽です。
今回使用したコードの全量は↓のリポジトリにコミットしています。
https://github.com/ma91n/hclwrite-dynamodb
hclwriteパッケージを利用すべきか 究極的には生成したいTerraformコードがどのようなものであるかに依存しますが、あるTerraformコードを読み取って別のファイルを生成するだけであれば、hclwriteパッケージを用いてもそれほど難しくはありません。
今回の内容であれば、パース部分もHCLの構造を無視し、スクラッチで解析しても良さそうなレベルではあります。しかし、複数のリソースタイプが混ざったり、ある属性の条件でのみを対象としたいといった拡張はしばしばありえるので、こういったライブラリを用いてパースすると良いでしょう。
一方で生成側です。今回の用途だと、既存ファイルの更新ではなく新規生成です。しかも成果物の構造のシンプル。この場合は、hclwrite
の仕様を学んでゴリッと出力するより、Go Templateなどお好きなてプレートエンジンで生成するほうが遥かにメンテナンスがしやすいいと思います。HCLのフォーマットだけは使っても良いかもしれませんが、全てをhclwriteで閉じて生成するのは、出力がこれだけであれば割に合わない気がしました。
hclwrite
ですが主な用途はすでに存在するTerraformのコードを破壊せず、一律でタグを付けたり属性を変えたりといった用途に向いているパッケージのようです。
最後に hclwriteというパッケージを用いてあるTerraformリソースから、別のリソースを生成しました。新規生成については別のテンプレートエンジンを利用する方が良いかなと個人的には思います。
こういった開発フローは少し特殊で、通常はTerraformモジュール化などを試みると思いますが、モジュール化するにしてはリソース対象が少なく、ちょっと抽象度が弱いんだよな~といった場面では、コード生成案も考えてみても良いのではないでしょうか。