CloudWatch Alarm発報時に、メトリクスのディメンションをSlackに通知する

## はじめに
CloudWatchメトリクスに対しAlarmを仕掛け、SNS、AWS Chatbotを介してSlackへ、メトリクスの状況変化の発報を行うという構成は、皆さんお使いかと思います。
ただ、[Alarmの特性上](https://dev.classmethod.jp/articles/tsnote-cloudwatch-metrics-insights-alarms-multiple-monitoring-individual-notification/)、ディメンションを指定したメトリクスを対象にすることができないため、この形での発報だけでは、例えばどのEC2インスタンスのCPU利用率が閾値を超えたか、といった、ディメンションのどの対象が閾値を超えたか、をSlack上から知ることはできない、ということになります。

そこで、このディメンションの情報のSlackへの連携を、保守コストの高いアプリケーションロジックの実装(Lambdaなど)なしに実現する、をゴールとし、本記事をお届けします。

## 構成
最終的な構成は以下のようになりました。
![名称未設定ファイル (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375883/1e2bae06-12f7-4c21-020d-7566b9ff32ec.png)

- EventBridgeルールで、対象のCloudWatch Alarmに対し、CloudWatch Alarm State Changeで、`ALARM`状態への変更時にStep Functionsステートマシンを呼び出す
- Step Functionsステートマシンで、`GetMetricData`タスクで目的のメトリクス値を取得し、`Call HTTPS APIs`タスクでSlackのIncoming Webhookへ送信

ということをやっています。

## EventBridge
EventBridgeでは、イベントパターンとターゲットを指定していきます。イベントバスは、こだわりが無ければデフォルトイベントバスでよいかと思います。

イベントパターンは、今回は以下のようにいたしました。
`source`、`detail-type`それぞれ対象のAWSサービス、イベントタイプを指定する固定値で、detailにユーザー側で指定する値を含めます。
筆者の例では、特定の複数の強権限ロールのいずれかへのAssumeRoleがあった場合に`ALARM`状態になるCloudWatch Alarm`Detect-AssumeRole-To-PowerRole`について、`ALARM`状態に変化した場合のみ、をトリガー対象としています。
```json
{
  "source": ["aws.cloudwatch"],
  "detail-type": ["CloudWatch Alarm State Change"],
  "detail": {
    "alarmName": ["Detect-AssumeRole-To-PowerRoles"],
    "state": {
      "value": ["ALARM"]
    }
  }
}
```

次に、ターゲットは以下のように指定しました。
```
ターゲットタイプ： AWS のサービス
ターゲットを選択：Step Functions ステートマシン
ステートマシン: (実行するステートマシン名をプルダウンから選択)
実行ロール: この特定のリソースについて新しいロールを作成
```
:::note info
実行ロールは、`この特定のリソースについて新しいロールを作成`から作成すると以下のようなロール・ポリシーが作成されます。
#### ロール・信頼関係
```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "events.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
```
#### ポリシー
```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "states:StartExecution"
            ],
            "Resource": [
                "${指定したStep Functions ステートマシンのARN}"
            ]
        }
    ]
}
```

ありがちな、自動生成すると余計な権限がついてくる、ということはなく、最小権限でした。
:::


## Step Functions~Slack
Step Functionsステートマシンは以下の通りです。
#### 実行ロール
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375883/ff3f0101-7d96-9420-20fe-dda9e84093cc.png)
`新しいロールを作成`により、`cloudwatch:GetMetricData`以外の権限については、Resourceの指定も含めた最小権限を達成できます。

#### グラフ
![stepfunctions_graph.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375883/8aeea3de-18c1-ef57-5e7a-cb94a83f9cef.png)
#### コード

```json
{
  "Comment": "A description of my state machine",
  "StartAt": "GetMetricData",
  "States": {
    "GetMetricData": {
      "Type": "Task",
      "Arguments": {
        "EndTime": "{% $states.context.Execution.StartTime %}",
        "MetricDataQueries": [
          {
            "Expression": "SELECT MAX(AssumeRoleEventCount) FROM CloudTrailMetrics GROUP BY AccountId, RoleArn, principalId",
            "Id": "q1",
            "Period": 10,
            "Label": "Detect-AssumeRole"
          }
        ],
        "StartTime": "{% $fromMillis($toMillis($states.context.Execution.StartTime) - 5*60*1000,'[Y0001]-[M01]-[D01]T[H01]:[m]:[s][Z]') %}"
      },
      "Resource": "arn:aws:states:::aws-sdk:cloudwatch:getMetricData",
      "Next": "Call HTTPS APIs"
    },
    "Call HTTPS APIs": {
      "Type": "Task",
      "Resource": "arn:aws:states:::http:invoke",
      "Arguments": {
        "ApiEndpoint": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
        "Method": "POST",
        "Headers": {
          "content-type": "application/json"
        },
        "Authentication": {
          "ConnectionArn": "arn:aws:events:ap-northeast-1:123456781234:connection/MyStateMachine-GetMetricData-DummyConnection/858546a1-af18-4e63-9417-43cc7d940ce6"
        },
        "RequestBody": {
          "text": "{% $join($states.input.MetricDataResults.Label, '\\n') %}"
        }
      },
      "Retry": [
        {
          "ErrorEquals": [
            "States.ALL"
          ],
          "BackoffRate": 2,
          "IntervalSeconds": 1,
          "MaxAttempts": 3,
          "JitterStrategy": "FULL"
        }
      ],
      "End": true
    }
  },
  "QueryLanguage": "JSONata"
}
```

### GetMetricData
`GetMetricData`タスクでのポイントは、
- `StartTime`と`EndTime`の取り扱い
-  `MetricDataQueries`の`Expression`
です。

まず、`StartTime`と`EndTime`の取り扱いについてです。

このStep Functionsステートマシンは、CloudWatch AlarmがALARM状態に変化したことをトリガーに発火するものです。
つまり、確認するメトリクスの範囲は、`EndTime`がステートマシンの実行タイミングと同時、`StartTime`をステートマシンの実行タイミングの数分前(今回は5分前までとします)とすればよい、ということです。

実行タイミングは[Contextオブジェクト](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/input-output-contextobject.html)から取得できます。
`"EndTime": "{% $states.context.Execution.StartTime %}"`
とすることで、`EndTime`をステートマシンの実行タイミングと同時としています。

次に、`StartTime`です。Step Functionsで時刻を扱う上で取りうる方法として、JSONataの関数が使用できます。
そのために、`QueryLanguage`フィールドで`JSONata`を指定しています。

:::note info
Step FunctionsでのJSONataのサポートは、[変数と同時に2024/11/22より利用可能となった](https://aws.amazon.com/about-aws/whats-new/2024/11/aws-step-functions-variables-jsonata-transformations/)新しい機能で、豊富な組み込み関数による柔軟なデータ変換、というメリットを享受できるようになりました。
:::

` "StartTime": "{% $fromMillis($toMillis($states.context.Execution.StartTime) - 5*60*1000,'[Y0001]-[M01]-[D01]T[H01]:[m]:[s][Z]') %}"`
として、「ステートマシンの実行タイミングの5分前」を取得しています。
`toMillis`でISO8601拡張形式をエポックミリ秒に変換の上、5分前(`5*60*1000ミリ秒前`)を計算し、`fromMillis`でISO8601拡張形式に戻しています。

次に、`MetricDataQueries`の`Expression`についてです。
[MetricDataQuery](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_MetricDataQuery.html)では、`Expression`としてMetrics Insights queryを指定します。
今回の例では、
`"Expression": "SELECT MAX(AssumeRoleEventCount) FROM CloudTrailMetrics GROUP BY AccountId, RoleArn, principalId"`
としており、CloudTrailログのCloudWatch ロググループに設定した、特定の複数の強権限ロールへのAssumeRoleをフィルターパターンとしたメトリクスフィルターで設定したディメンション、`AccountId`、`RoleArn`、`principalId`を`GROUP BY`句に含めることができています。

### Call HTTPS APIs
`Call HTTPS APIs`タスクでは、
- `Authentication`の設定
- 入力を基にした`RequestBody`の組み立て
がポイントです。

まず、`Authentication`の設定についてです。

そもそも、本記事をお読みの方の多くはご存じであろうと思いますが、SlackのIncoming Webhookでは認証が不要です。
しかし、`Call HTTPS APIs`タスクでは、[APIの認証情報を安全に管理するためのEventBridge 接続リソースの指定が必須](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/call-https-apis.html#connect-http-task-fields)となっており、`Authentication`もしくは`InvocationConfig`フィールドの`ConnectionArn`として、EventBridge接続の接続 ARNを指定する必要があります。
これは省略できないものとなっているため、適当な値を設定したEventBridge接続を作成することとなります。
Step Functions Workflow Studio上では、`新しい接続を作成`よりEventBridge接続を作成します。
![スクリーンショット 2024-12-09 044449.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375883/ed312014-a589-a20a-0713-4ddea7a1982b.png)
SlackのIncoming Webhookでは、APIタイプを`パブリック`とし、認証タイプを`APIキー`で適当な値を指定、で正常に呼び出しが可能なことを確認しています。
![スクリーンショット 2024-12-09 044658.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375883/d3d72233-33b3-47ff-c71a-796463b418bf.png)

次に、入力を基にした`RequestBody`の組み立てについてです。
`GetMetricData`タスクの出力、すなわち`Call HTTPS APIs`タスクの入力は以下のような形式になっています。
```json
{
  "Messages": [],
  "MetricDataResults": [
    {
      "Id": "q1",
      "Label": "Detect-AssumeRole 123456781234 arn:aws:iam::123456781234:role/AdminRole AROXXXXXXXXXXXXXXXXX:sato_taro@works.com",
      "StatusCode": "Complete",
      "Timestamps": [
        "2024-12-08T22:33:00Z"
      ],
      "Values": [
        1
      ]
    },
    {
      "Id": "q1",
      "Label": "Detect-AssumeRole 123456781234 arn:aws:iam::123456781234:role/IAMPowerRole AROXXXXXXXXXXXXXXXXX:kato_jiro@works.com",
      "StatusCode": "Complete",
      "Timestamps": [
        "2024-12-08T22:33:00Z"
      ],
      "Values": [
        1
      ]
    }
  ]
}
```
`AccountId`、`RoleArn`、`principalId`のグループ化条件が、`MetricDataResults`の中の`Label`フィールドとなっていることが分かります。
すなわち、`MetricDataResults`のLabelを抽出することで、過去5分間の間に、どのAWSアカウントのどのプリンシパルが、自AWSアカウントのどのIAMロールにAssumeRoleしたか、を把握することができる、ということです。

こうしたJSONの操作にも、JSONataの組み込み関数が活用できます。
`RequestBody`は以下のように定義しています。
```json
"RequestBody": {
  "text": "{% $join($states.input.MetricDataResults.Label, '\\n') %}"
}
```
`$states.input.MetricDataResults.Label`では、`$states.input`で入力を呼び出しており、後ろの`.MetricDataResults.Label`では、[JSON配列に対するJSONataによる操作](https://docs.jsonata.org/simple#navigating-json-arrays)により、`Label`の値すべてを一つの配列として返しています。
その配列を、[関数join](https://docs.jsonata.org/string-functions#join)により、改行コードをセパレータに指定し、改行を含む一つの文字列として連結しています。

連結した文字列をSlackへIncoming Webhookで送ると、Slack側で以下のようにポストされることとなります。
![スクリーンショット 2024-12-09 060744.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375883/823c42aa-ca2e-d487-1d6f-6a1387b11093.png)

以上で、CloudWatch AlarmのALARM状態をトリガーとし、ディメンションのどの対象が閾値を超えたか、をSlack上で知ることができるようになりました。

## 最後に
最初はCloudWatchダッシュボードと、Step Functionsの`GetMetricWidgetImage`タスクを使い、ダッシュボードの画像をSlackに連携することを考えていました。
が、SlackのIncoming Webhooksでは直接画像を取り扱う方法はなく、[attachments](https://api.slack.com/reference/surfaces/formatting#attachments)の`image_url`に画像URLを指定するか、SlackへのFileUploadを行うWebAPI/SDKを扱う(注: [旧来のfiles.upload APIは2025/3で廃止](https://zenn.dev/slack/articles/7ce5065cc4daa7) )かのどちらかが必要になります。
前者は、パブリックにアクセスできる画像URLを用意する必要があり、ファイルアップロードから削除までのライフサイクルを考えたり、そもそもメトリクスを示す画像をパブリックにアクセスできるURLに置くことのセキュリティ上の懸念などを考えると難しいところがあります。
後者は、SDKを使う場合はLambdaの利用が避けられず、APIを直接叩く場合も、[引用記事](https://zenn.dev/slack/articles/7ce5065cc4daa7#%E7%9B%B4%E6%8E%A5-api-%E3%82%92%E5%88%A9%E7%94%A8%E3%81%99%E3%82%8B%E5%A0%B4%E5%90%88)にある流れをStep FunctionsのStatesの複雑な組み合わせで実現するか、Lambdaを利用するか、になることと、Incoming Webhookだけでの実現と異なり、Slackのアクセストークンを扱う必要が出てくることから、手軽さに欠けることになります。
そのため方針転換をし、今回の形に落ち着きました。

ただ、メトリクスグラフをそのままSlackで見られる形の方が使い勝手がいいシチュエーションもあるかと思います。
ダッシュボードの画像をSlackに連携するいい方法が思いついたら、また別記事を投稿しようと思います。