CloudWatch DashboardをTerraformでコード化した話
この記事は terraform Advent Calendar 2024 14日目の記事です。
はじめに
Finatextでインフラエンジニアをやっているぐらにゅです。
AWSで利用しているリソースのメトリクスをダッシュボードでパッと見たくなったので、CloudWatch Dashboardを利用してダッシュボードの作成を行うことにしました。
この時、「簡単にダッシュボードが作れるようになったら嬉しいよね」ということでTerraformでModuleを作ったのですが、色々と気づきがあったので今回記事にまとめました。
今回書くこと/書かないこと
今回書くこと
- DashboardをTerraformでコード化する時に考えたことや実装例
- コード化した後の運用で気づいたこと
今回書かないこと
- AWS CloudWatch Dashboardに関すること全般
- Terraform Moduleの設計に関する知識
コード化する時に考えたことや実装
Moduleのディレクトリ/ファイル構成について
まずAWS CloudWatch DashboardはTerraformでどのように記述するのだろう?と思って、aws-providerの公式ドキュメントを確認しました。
ダッシュボードの核となる、ダッシュボード本体部分はJSONで記述する必要があります。
dashboard_body
にjsoncode
で直接記述してもよいのですが、別ファイル管理にしたいなあと思ったので、ダッシュボード本体をテンプレートとして切り出すことにしました。
ということで、以下のようなディレクトリ構成になりました。
modules/cloudwatch_dashboard/
├── main.tf
├── output.tf
├── variable.tf
└── dashboard.json.tpl
今回はALB/ECS/RDSあたりが基本的に利用しているAWSサービスなのですが、場合によってはALBではなくNLBを利用していたり、3サービスにプラスしてCloudFrontやLambdaも見たいケースがありました。
これらを一つのJSONファイルで書いた結果、1000行以上ある上にとても管理しづらい見た目のテンプレートファイルが完成したので、AWSサービス単位で分割して以下の構成としました。
modules/cloudwatch_dashboard/
├── main.tf
├── output.tf
├── variable.tf
└── widgets/
├── alb.json.tpl
├── cloudfront.json.tpl
├── ecs.json.tpl
├── lambda.json.tpl
├── nbl.json.tpl
└── rds.json.tpl
実装について
実際にDashboard Moduleを作るときに考えたことを、実際のコードの一部を例として出しながら書いていきます。
Moduleを呼び出す側
Dashboard Moduleを呼び出す側の関心事としては、「どのリソースをダッシュボード管理しているかがシンプルに書ける」「パッと見ただけでわかる」の2点だと考えました。
なので、たとえばECS/ALB/RDSをダッシュボードとして表示したい場合、以下のように記述することができればその2点が満たせるのではないかと思いました。
module "dashboard" {
source = "modules/cloudwatch_dashboard"
name = "my_dashboard"
ecs_services = [
aws_ecs_service.example1,
aws_ecs_service.example2,
]
alb_target_groups = [
aws_lb_target_group.example
]
rds_instances = [
aws_db_instance.example1,
aws_db_instance.example2,
]
}
Module側
ディレクトリ構成の項でも書きましたが、ユースケースとしてECS/ALB/RDSをダッシュボードとして表示したいケースもあれば、ECS/NLB/RDSを表示したいケース、ECS/ALB/RDS/CloudFront/Lambdaを表示したいケースもあります。
なおかつ、Moduleを呼び出す側の項で書いたコードのような呼び出しができるということは、以下の点を満たす必要があります。
- ユースケースで出てきているAWSサービス全てについて、Module側ではメトリクス提供できるように定義を用意する
- 呼び出し側では使いたいAWSサービスのみlistとして渡される(使わないものは記述しない = 空のlistが渡される)ので、それを前提とする
ここでは主にALBに関するコードを例として挙げながら、どのような実装をしたのかを簡単に解説します。
テンプレートファイル
コード化のコアとなる、メトリクスに関するテンプレートファイルです。
実際は他のメトリクスもダッシュボードに表示しているのですが、全量貼ると長くなってしまうので、TargetGroupのHealthyHostCount/UnHealthyHostCountに関する箇所のみ抜粋しました。
[{
"height": 6,
"width": 6,
"y": 0,
"x": 0,
"type": "metric",
"properties": {
"metrics": [
%{ for key, alb_target_group in alb_target_groups}
[
"AWS/ApplicationELB",
"HealthyHostCount",
"TargetGroup",
"${alb_target_group.arn_suffix}",
"LoadBalancer",
"${alb_target_group.alb_arn}",
{
"region": "ap-northeast-1",
"label": "${alb_target_group.name}"
}
]%{ if key != length(alb_target_groups) - 1 },%{ endif }
%{ endfor }
],
"view": "timeSeries",
"stacked": false,
"region": "ap-northeast-1",
"title": "ALB/HealthyHostCount",
"period": 60,
"stat": "Average",
"yAxis": {
"left": {
"min": 0
}
}
}
},
{
"height": 6,
"width": 6,
"y": 0,
"x": 6,
"type": "metric",
"properties": {
"metrics": [
%{ for key, alb_target_group in alb_target_groups }
[
"AWS/ApplicationELB",
"UnHealthyHostCount",
"TargetGroup",
"${alb_target_group.arn_suffix}",
"LoadBalancer",
"${alb_target_group.alb_arn}",
{
"region": "ap-northeast-1",
"label": "${alb_target_group.name}"
}
]%{ if key != length(alb_target_groups) - 1 },%{ endif }
%{ endfor }
],
"view": "timeSeries",
"stacked": false,
"region": "ap-northeast-1",
"title": "ALB/UnHealthyHostCount",
"period": 60,
"stat": "Average",
"yAxis": {
"left": {
"min": 0
}
}
}
}]
Terraformのコード
alb_target_groups
が空の状態で入ってくるパターンがあるので、lengthが0以上であることを確認し、中身がある場合にはメトリクスを表示するにあたり必要な情報をalb_target_groups
にセットしてテンプレートファイルを呼び出します。
その結果、上記で記載したテンプレートファイルにalb_target_groups
でセットした情報が入ったJSONがalb_json
に定義されます。
locals {
alb_json = length(var.alb_target_groups) > 0 ? templatefile("${path.module}/widgets/alb.json.tpl", {
alb_target_groups = [
for alb_target_group in var.alb_target_groups : {
arn_suffix = alb_target_group.arn_suffix
arn = alb_target_group.arn
name = alb_target_group.name
}
]
}) : ""
}
ALB以外についても同様のことを行い、compact関数で空が入っているものを除外 + aws_cloudwatch_dashboard
に渡せる形式に整えた上で、aws_cloudwatch_dashboard
に渡します。
locals {
resource_list = compact([
local.alb_json,
local.ecs_json,
local.rds_json,
local.lambda_json,
local.cloudfront_json,
local.nlb_json
])
resource_objects = flatten([for resource in local.resource_list : jsondecode(resource)])
}
resource "aws_cloudwatch_dashboard" "main" {
dashboard_name = var.name
dashboard_body = jsonencode({
widgets = local.resource_objects
})
}
コード化した後の運用で気づいたこと
無事コード化を終え、他のチームでもこのModuleを使ってダッシュボードを作成してもらいました。その結果気づいたことがあったので、その点についても書こうと思います。
マネコンで配置を変えるとドリフトが発生する
マネコンから手でダッシュボードに配置されているウィジェットを移動させると、JSONのX・Yの値が書き変わってしまうので、ドリフトが発生することがわかりました。
解決方法として以下の2パターンがありますが、どちらもメリット・デメリットがあるので以下にまとめました。
-
dashboard_body
をignore_changesにいれる- メリット: ウィジェットのレイアウトが自由にできる
- デメリット: Terraform経由で新しくメトリクスを追加しようとするとignore_changesから
dashboard_body
を外す or ダッシュボードを削除してからTerraform Applyを行う必要がある
- Terraform Apply時に上書きする
- メリット: Terraformで管理しているメトリクスが表示されているという状態が担保される(ignore_changesした場合と比較して)
- デメリット: 手動での配置変更回数が多い場合にTerraform Planの結果がnoisyになる、ウィジェットのレイアウトが自由に変更できない
「どれだけダッシュボードのレイアウトを自由に変更したいか」「メトリクスの追加頻度はどのくらいか」などによって、どちらを選ぶか変わりそうだなあと思いました。
今回は、「メトリクス追加をもれなくやりたいものの、Terraform Planの結果が結構ノイズになりやすい」という状況だったので、前者を選択することにしました。
もう少しノイズが出にくいように整えられたら、dashboard_body
のignore_changesを外したいなと考えています。
他のリポジトリでも使いたくなった
他のTerraformリポジトリでも使いたい!という要望があり、その時の対応についても書きます。
同じModuleを別のTerraformリポジトリに持っていくという案もあったのですが、「すぐにリファクタリングしたくなるだろうなあ」と思っていたので、今回はModule Sourcesで利用してもらうことにしました。
例として「Githubでコード管理をしている/ブランチとしてmainを参照したい場合」の記載例を記します。
module "dashboard" {
source = "git@github.com:example_repo/example.git//modules/cloudwatch_dashboard?ref=main"
}
まとめ
他の人に使ってもらった結果「簡単にダッシュボードが作れるの便利」という感想をもらえて嬉しかったですが、まだまだ課題はたくさんあるなあというのが正直な気持ちです。
Terraformとダッシュボードのコード化の相性があまり良くなさそうかも?と感じる一方で、「見てほしいメトリクスをいい感じに揃えたい」「ダッシュボードを作った後も更新できるようにしたい」という視点で考えると、Terraform Moduleで管理するのが一番良いアプローチなのかなあとも思いました。
もしも「こういう管理方法がいいよ!」というのをご存知の方がいらっしゃったらぜひ教えてください。
Discussion