CloudWatchをTerraform化してみた

# 本記事のテーマ
今回、AWSに構築している各種リソースを監視しているCloudWatchをTerraform化したので、その際の考慮ポイントを含めて紹介します。

# Terraform導入の背景
AWSに構築している各種サービスをCloudWatchで監視していますが、以下の問題を解決（改善）するためTerraform化することにしました。
- CloudWatchアラームを「東京リージョン」と「バージニアリージョン」に約900件！作成しており、管理が煩雑
- 新規リソースの追加タイミングで監視、アラームを手動作成しているため、メンテナンス効率が悪く、設定漏れや設定ミスなどの発生リスクも高い

# 作業環境
以下の通りです。

- クラウド環境：AWS
- Terraformがインストール済み
```ruby:Terraformバージョン
$ terraform --version
Terraform v1.6.6
on darwin_arm64
+ provider registry.terraform.io/hashicorp/aws v5.32.1
```

:::note info
Tips
チームで運用をする場合、Terraformのバージョン固定するtfenvを使用するとバージョン管理が楽になるかと思います
```ruby:#導入例
brew install anyenv
anyenv install tfenv
tfenv install 1.6.6
tfenv use 1.6.6
:::

# 構成図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3866933/aa10aa0b-c084-b5d4-7599-cafcf0e89be2.png)

CloudWatch監視の構成は、トリガー発火後にSNS Topic・Lambda経由でSlackへメッセージ通知する構成です。

また、アラームレベルを3段階（Warning/Alert/Critical）に分け、それぞれ通知先となるSlackチャンネルも分けていますので、SNS Topic・Lambdaも3種類作成しています。

上記構成では省略していますが、CloudFrontはバージニアリージョンに作成されるためCloudFront用としてCloudWatchアラーム、SNS Topic、Lambdaはバージニアリージョンにも作成しています。

今回は、Terraform導入ということをもあり、CloudWatchとSNS TopicのみTerraform化しました。

なお、上記構成には記載していませんが、Develop環境、Staging環境はProduction環境と同等なので省略しています。

# Terraform導入してみた

### ディレクトリ構成の検討
ディレクトリ構成は、CloudWatchアラームのメンテナンスのしやすさを考慮してstateファイルを細分化する方針としました。
ディレクトリ構成の要素は以下の3点です。

- 環境
- CloudWatchアラームレベル
- AWSサービス

また、CloudWatchから呼び出されるSNS Topicは共通化するためモジュールを分け、以下の構成としました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3866933/600f3d33-1955-70e1-e28d-338041f2b950.png)
***

### Terraformコード作成
各ディレクトリに配置する主なtfファイルとコード内容は以下の通りです。

|tfファイル|内容|
|:-|:-|
versions.tf|Terraformバージョンやstateファイルの保存先情報を記載|
variables.tf|tfファイル内で使用する変数を記載
data.tf|Terraformリソースとして管理していないAWSリソースを記載
main.tf|Provider、Resourceブロックおよび、localブロックを記載

Terraformコードを作成する際の方針は以下の通りとしました。

- Develop環境、Production環境でコードを流用出来るように環境依存する箇所は変数化する
- 通知アクションの有効/無効は、AWSコンソールで変更するため「actions_enabled」を「ignore_changes」とする
- しきい値は、各リソースでチューニングすることから変数化しない
- SNS Topicは、各アラームから参照を一元化 出来るようにterraform_remote_state として作成する

***
#### SNS Topicのコード化
以下のHashiCorp社の公式ドキュメントを参考に作成します。
※Resource: aws_sns_topic

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sns_topic

##### versions.tf

Terraformのバージョンや利用するプロバイダを指定します。
また、backendにstateファイルの保存先としてS3バケットを指定します。
Backendにはローカルとリモートを指定する事が出来ますが、ローカルにstateを保存するとリソース操作が競合してしまうのでS3にstateを保存しています。

```ruby:#versions.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.32.1"
    }
  }
 
  backend "s3" {
    bucket  = "objects"
    key     = "terraform/sns/production.tfstate"
    region  = "ap-northeast-1"
    profile = "xxx"
  }
 
  required_version = ">= 1.5.5"
}
```
***
##### variables.tf
tfファイルで使用できる変数を指定します。
以下のサンプルコードでは、環境名をフルネームと省略形で記載しています。
```ruby:#variables.tf
 
variable "environment" {
  default = "Production"
  type    = string
}
 
variable "env_3_letter" {
  default = "prd"
  type    = string
}
```
***
##### main.tf

SNS Topicリソースを記載します。`provider ブロック`と`resource ブロック`で構成されています。
サンプルコードは、東京リージョンとバージニアリージョンのWarningレベルのSNS Topicです。

`provider ブロック`では、リソースのプロバイダー情報を記載します。

```ruby:#main.tf
provider "aws" {
  region  = "ap-northeast-1"
  profile = "xxx"
  default_tags {
    tags = {　　　　　　　　　　           #タグ記載箇所
      Environment = var.environment
    }
  }
}

provider "aws" {
  region  = "us-east-1"
  profile = "xxx"
  alias   = "virginia"　　　　　　　　　　　#alias指定箇所
  default_tags {
    tags = {
      Environment = var.environment
    }
  }
}
```
ポイントとなる箇所を解説します。
- tags
    ```
    tags = {
        Environment = var.environment
    }
    ```
    resourceブロックで作成するリソースに一律にタグを設定することができます。
<br>

- alias
    ```
    alias   = "virginia"
    ```
    SNS Topicは東京リージョン（ap-northeast-1）とバージニアリージョン（us-east-1）にあるため、それぞれ記載します。東京リージョンは、Default Providerとして使用し、バージニアリージョンは`alias= "virginia"`として明示的に指定します。
<br>


`resource ブロック`ではSNS Topicのリソース情報を記載します。
```ruby:#main.tf
resource "aws_sns_topic" "prd_warning_tokyo" {
  display_name                = "${var.env_3_letter}_warning"　　　#変数を含め汎用性を持たせます
  name                        = "${var.env_3_letter}_warning"
  content_based_deduplication = false
  fifo_topic                  = false
  policy = templatefile(　　　　　　　　　　　　　　　　　　　　　　　　　#テンプレートから呼び出す形式とする
    "${path.module}/policy.json.tftpl",
    {
      Resource = [
        "arn:aws:sns:ap-northeast-1:123456789:${var.env_3_letter}_warning",
      ]
    }
  )
}
 
resource "aws_sns_topic" "prd_warning_virginia" {
  provider                    = aws.virginia　　　　　　　　　   　　#バージニアリージョンは"provider"を指定する
  display_name                = "${var.env_3_letter}_warning"
  name                        = "${var.env_3_letter}_warning"
  content_based_deduplication = false
  fifo_topic                  = false
  policy = templatefile(
    "${path.module}/policy.json.tftpl",
    {
      Resource = [
        "arn:aws:sns:us-east-1:123456789:${var.env_3_letter}_warning",
      ]
    }
  )
}
```
ポイントとなる箇所を解説します。
- provider
    ```
    provider = aws.virginia
    ```
    バージニアリージョンのリソースには、providerとして`aws.virginia`を記載します。東京リージョンのリソースはDefault Providerとして使用しているので記載は不要です。
<br>

- name
    ```
    name = "${var.env_3_letter}_warning"
    ```
    各環境でコードを流用しやすくするため、変数を含めて記載します。Develop環境へ流用する場合は、Terraformリソース名箇所を変更するのみでコードの流用が可能となります。
    ```ruby:#例：
    resource "aws_sns_topic" "prd_warning_tokyo" {
    ↓
    resource "aws_sns_topic" "dev_warning_tokyo" {
    ```
<br>

- policy
    ```
    policy = templatefile(
       "${path.module}/policy.json.tftpl",
       {
         Resource = [
           "arn:aws:sns:us-east-1:123456789:${var.env_3_letter}_warning",
         ]
       }
     )
     ```
     アクセスポリシーは、「Resource」以外は共通であるためtemplatefile 関数を使用し、ポリシーのjsonファイルをテンプレートとして参照します。
ポリシーのjsonファイルは同じ階層に以下の`policy.json.tftpl`として作成します。
「Resource」箇所は`${jsonencode("${Resource}")}`としてmain.tfから引用するようにします。
    ```ruby:#policy.json.tftpl
    {
        "Id": "__default_policy_ID",
        "Statement": [
            {
                "Action": [
                    "SNS:Publish",
                    "SNS:RemovePermission",
                    "SNS:SetTopicAttributes",
                    "SNS:DeleteTopic",
                    "SNS:ListSubscriptionsByTopic",
                    "SNS:GetTopicAttributes",
                    "SNS:AddPermission",
                    "SNS:Subscribe"
                ],
                "Condition": {
                    "StringEquals": {
                        "AWS:SourceOwner": "123456789"
                    }
                },
                "Effect": "Allow",
                "Principal": {
                    "AWS": "*"
                },
                "Resource": ${jsonencode("${Resource}")},
                "Sid": "__default_statement_ID"
            }
        ],
        "Version": "2008-10-17"
    }
    ```

***
##### output.tf

Terraform管理したSNS Topicを別構成のCloudWatchアラームから参照出来るようにoutput.tfに記載します。
これによりstateファイルにSNS Topic情報がoutputとして記載され、他のResourceからDataSourceとして参照する事が出来ます。
```ruby:#output.tf
 
#tokyo
output "prd_warning_tokyo_arn" {　　　　　　　　　　　                    #リソース名が特定出来る名称とする
  description = "東京リージョン用 SlackのwarningチャンネルのSNS Topic"　　　#説明を追加する事で可読性を高める
  value       = aws_sns_topic.prd_warning_tokyo.arn　　　　　　　　　　　 #SNS TopicのARN情報を指定
}
 
#virginia
output "prd_warning_virginia_arn" {
  description = "バージニアリージョン用 SlackのwarningチャンネルのSNS Topic"
  value       = aws_sns_topic.prd_warning_virginia.arn
}
```

ポイントとなる箇所を解説します。
- output
    ```
    output "prd_warning_tokyo_arn" 
    ```
    outputブロックのTerraformリソース名には、リソース名称を記述します。
    なお、複数のSNS Topicがある場合は、リソースが特定出来る名称とします。
<br>

- description
    ```
    description = "東京リージョン用 SlackのwarningチャンネルのSNS Topic"
    ```
    descriptionにリソースの説明を記載する事でより可読性が高まります。
<br>

- value
    ```
    value       = aws_sns_topic.prd_warning_tokyo.arn
    ```
    valueには、他の構成から参照したい属性を指定します。属性とは、VPC IDやインスタンスID、今回のようにSNS TopicのARN情報の指定が可能です。SNS TopicのARN情報を参照する場合は、以下のように記載します。![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3866933/e8395000-01ed-e697-8c95-fe9849aa018e.png)

:::note info
Tips
[公式サイト](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sns_topic)を参考にしても具体的な記載内容が分からないという方は、import機能がおすすめです。
作成済みのAWSリソースを自動でコード化するTerraform 1.5 で追加された機能です。
- 公式サイトの[Import](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sns_topic#import)を参考にImportブロックを記載します。
- 以下コマンドを実行するとgenerated.tfのファイルが生成されますので、生成されたコードを参考にコードを作成します。
    ```
    terraform plan -generate-config-out=generated.tf
    ```
:::
***

#### CloudWatchのコード化
CloudWatchも同様に以下のHashiCorp社の公式ドキュメントを参考に作成します。
※Resource: aws_cloudwatch_metric_alarm

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_metric_alarm

##### versions.tf
versions.tfは、SNS Topicと同様に作成します。
またvariables.tf には、Dimensionに指定する項目でDataSourceでは取得不可の項目を指定します。
下記サンプルコードでは、ElastiCacheのCacheClusterID情報を取得しています。


```ruby:#variables.tf
 
variable "env_3_letter" {
  default = "Prd"
  type    = string
}
 
variable "alarm_level" {
  default = "ALERT"
  type    = string
}
 
variable "elasticache-redis-01" {　　　#変数としてElastiCacheのCacheClusterID情報を取得
  default = "prd-redis-01"
  type    = string
}
```
****

##### data.tf
DataSourceを使用する事でTerraformリソースとして管理していないAWSリソースをTerraformリソースとして参照することができます。
data.tfにTerraform管理としなかった監視対象リソースを記載します。

下記サンプルコードでは、EC2情報を取得しています。
```ruby:#data.tf
data "aws_instance" "Bastion" {
  filter {
    name   = "tag:Name"
    values = ["PrdBastion"]
  }
}
```
また、別構成で管理しているSNS TopicのARN情報を取得するため、terraform_remote_stateを記載します。
SNS Topicのbackend情報を記載する事でstateファイルで管理されているSNS Topic情報をDataSourceとして取得します。
```ruby:#data.tf
data "terraform_remote_state" "sns" {
  backend = "s3"
  config = {
    bucket  = "objects"
    key     = "terraform/sns/production.tfstate"
    region  = "ap-northeast-1"
    profile = "xxx"
  }
}
```
***

##### main.tf

CloudWatchリソースを記載します。`provider ブロック`と`resource ブロック`で構成されています。
サンプルコードは、東京リージョンで実行中のEC2に対するCPU使用率監視です。

`provider ブロック`では、監視対象のメトリクスが取得されているプロバイダー情報を記載します。
該当メトリクスは、東京リージョンで取得しているため、region には、`ap-northeast-1` を指定します。
```ruby:#main.tf
provider "aws" {
  region  = "ap-northeast-1"
  profile = "xxx"
  default_tags {
    tags = {
      Environment = var.environment
    }
  }
}
```

`resource ブロック`では、CloudWatchアラーム情報を記載します。
```ruby:main.tf
resource "aws_cloudwatch_metric_alarm" "_ALERT_EC2_Bastion_CPUUtilization" {
  alarm_name      = "(${title(var.alarm_level)})[Relux][${title(var.env_3_letter)}Bastion]CPUUtilization"             #アラーム名を指定
  actions_enabled = false
  alarm_actions = data.terraform_remote_state.sns.outputs.prd_warning_tokyo_arn             #DataSourceで取得したSNS Topicのbackend情報から取得
  ok_actions = data.terraform_remote_state.sns.outputs.prd_warning_tokyo_arn
  dimensions = {
    InstanceId = data.aws_instance.Bastion.id                 #DataSourceから取得
  }
  evaluation_periods  = 1
  datapoints_to_alarm = 1
  metric_name         = "CPUUtilization"
  namespace           = "AWS/EC2"
  period              = 300
  statistic           = "Average"
  threshold           = 85                                    #しきい値を指定
  comparison_operator = "GreaterThanOrEqualToThreshold"
  treat_missing_data  = "breaching"
  lifecycle {                                                 #lifecycle にignore_changesを指定する事でAWSコンソールの変更を無視
    ignore_changes = [
      actions_enabled,
    ]
  }
}
```
ポイントとなる箇所を解説します。
- alarm_name
    ```
    alarm_name= "(${title(var.alarm_level)})[Relux][${title(var.env_3_letter)}Bastion]CPUUtilization"
    ```
    アラーム名を指定します。
    SNS Topicと同様に各環境でコードを流用しやすくするため、変数を含めて記載します。
<br>

- alarm_actions
    ```
    alarm_actions = data.terraform_remote_state.sns.outputs.prd_warning_tokyo_arn
    ```
    アラーム発生時のアクション指定します。
    SNS TopicのARN情報は、DataSourceで取得したSNS Topicのbackend情報から取得します。
コードの各ブロックの説明です。
    - terraform_remote_state.sns ・・ DataSourceで指定した定義名
    - prd_warning_tokyo_arn・・SNS Topicのoutput.tfで指定した定義名
※ok_actionsも同様です
<br>

- dimensions
    ```
    dimensions = {
        InstanceId = data.aws_instance.Bastion.id
      }
    ```
    ディメンションを指定します。
EC2のCPUUtilizationのディメンションでは、インスタンスIDが必要となります。
該当インスタンスの情報はDataSourceで取得していますので、その中でインスタンスID情報の`id`を末尾に指定する事で取得が可能となります。
その他取得可能な属性の詳細は、HashiCorp社の公式ドキュメントを参考ください。
※Resource: aws_instance > Attribute Reference
https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/instance
<br>

- threshold
    ```
    threshold           = 85
    ```
    しきい値を指定します。
サンプルコードでは、85%以上の時にアラームとなる設定で視覚的に分かりやすいですが、RDSの空きメモリ監視（FreeableMemory）などは、byteで指定するため判読性が下がります。
一例ですが、以下の方法を取ることで判読性を上げることが出来ます。
    - variables.tfにしきい値を記載
        ```ruby:variables.tf
        variable "alarm_threshold_percent_factor" {
          default = 0.05
          type    = number
        }
        ```
    - main.tfにlocalsブロックとして総メモリ数を記載
        ```ruby:main.tf
        locals {
          rds_spec = tomap({
            "db_m6g_large" = {
              total_memory_bytes = 8589934592
            }
          })
        }
        ```
    - thresholdにceil関数を使用してしきい値を算出
        ```ruby:main.tf
        threshold = ceil(local.rds_spec.db_m6g_large.total_memory_bytes * var.alarm_threshold_percent_factor)
        ```
<br>

- ignore_changes
    ```
    lifecycle {
        ignore_changes = [
          actions_enabled,
        ]
      }
    ```
    CloudWatchアクションの有効・無効設定をメンテナンス都度、Terraformコードを修正となると運用効率が低下するため、通知アクションのみAWSコンソールで変更する運用としました。
通常AWSリソースを変更した場合、stateファイルとの差分が発生しTerraformコードも変更されます。
そこで、AWSコンソールで変更した場合でもTerraformコード側を無視するようlifecycleにactions_enabledを設定します。
lifecycle に`ignore_changes`を指定する事でAWSコンソールの変更を無視する 挙動になります。

:::note info
Tips
コードを記載しているとインデントがずれる事がありますが、以下のコマンドで自動フォーマットしてくれます。
```
terraform fmt
```
※Command: fmt
https://developer.hashicorp.com/terraform/cli/v1.5.x/commands/fmt
:::

# 終わりに
今回は、CloudWatchのTerraform化のコード事例について紹介しました。
Terraform化により、環境毎のメンテナンス性も向上しチームメンバーのコードレビュが入ることで設定ミスのリスク低下に繋がりました。
今回はCloudWatchとSNS Topicのみが対象でしたので、今後はDataSourceで定義したAWSサービスもTerraform化したいと思います。