コネヒト開発者ブログ

コネヒト開発者ブログ

Lambda も Chatbot も SNS も使わない!EventBridge だけで CloudWatch Alarm のメッセージを加工し Slack へ post する方法

こんにちは。開発部プラットフォームグループでインフラエンジニアをしている @sasashuuu です。先日、Custom notifications are now available for AWS Chatbot で AWS Chatbot でのカスタム通知が行えるアップデートが発表されました。 実はこの方法以外にも、AWS EventBridge だけで CloudWatch Alarm のメッセージをさらに柔軟に加工して Slack へ post する方法があることをご存知でしょうか。Chatbot のアップデートが発表される前に当社で導入していたのですが、SNS や Chatbot を組み合わせたアーキテクチャを組まなくても良い他、 Lambda のランタイムの管理や実装の煩雑さのリスクなども減るので意外と活用できる方法なのではないかと思っております(実は当時 Chatbot を利用した上でメッセージ加工ができないかを模索した結果、できないことを知りこの方法に辿り着きました。そしてその方法を紹介しようとこのブログの8割ほどを執筆し終えた時に Chatbot のアップデートが発表され、なんとも言えない気持ちになったことは内緒です...)。この記事ではその方法についてご紹介したいと思います!

Before/After

まずメッセージの Before/After からお見せします。

ECS のタスク数が一定時間に必要な数から基準を下回った際に発生するアラートを例にご紹介します。

Before

Before の状態は Chatbot を通じて post されたデフォルトのメッセージとなっています。内容は充実しているのですが、Slack 通知をする上では情報過多な印象で、見慣れていなければどういった内容のアラートなのかを瞬時に把握し、対応するのはハードルが高いような印象でした。

After

After の状態は EventBridge を通じて加工されたデフォルトのメッセージとなっています。

メッセージの変更内容ですが、以下のような点を工夫しました。

  • 端的に何を示すアラートなのかを日本語で表現
  • 弊社の現場において Slack で通知する上で不要と判断した情報を除去
  • アラートに対するアクションを促せるような文言を追加

アーキテクチャ概要

タイトルや冒頭でも触れていましたが、AWS EventBridge を使用します。

重要となる構成要素は以下のラインナップです。

  • イベントパターン
    • AWS で発生したイベントを検知するためのパターン
  • ルール
    • AWS サービスのイベントを検知しターゲットへ送信するためのリソース
  • 入力トランスフォーマー
    • ターゲットへ渡すためのイベント(テキスト)をカスタマイズできる機能
  • ターゲット
    • 対象のイベントおよびパターンにマッチした際に送信先となるエンドポイント
  • API Destination
    • ターゲットに HTTP エンドポイントを指定できる機能

上記を組み合わせて作成した処理の流れをざっくりと説明すると以下のようになります。

実装手順

ここからは具体的な実装手順について解説します。構築時はコンソールでの作業を中心に進めていましたが、ここでは最終的に IaC へ落とし込んだ Terraform のコードをメインに解説させていただきますのでご了承ください。

Slack App を作成

Slack App が必要になるので、なければ作成をしてください。

https://api.slack.com/apps へアクセス後、下記の動線から作成が行えます。

Incomming Webhooks の作成

Incomming Webhooks を使用しますので、作成し取得しておきます。

下記の動線から作成&取得可能です。

書き込み権限の追加

Slack App へ chat:write の権限を追加します。

下記の動線から設定可能です。

Terraform の実装

下記はルールに関する定義です。

resource "aws_cloudwatch_event_rule" "demo_alert_alarm" {
  name = "demo-alert-alarm"
  event_pattern = jsonencode(
    {
      source = [
        "aws.cloudwatch"
      ],
      detail-type = [
        "CloudWatch Alarm State Change"
      ],
      resources = [
        {
          prefix = "arn:aws:cloudwatch:ap-northeast-1:xxxxxxxxxxxx:alarm:required-tasks-dev"
        }
      ],
      detail = {
        state = {
          value = [
            "ALARM"
          ]
        }
      }
    }
  )
}

マッチさせたい条件を HCL に記述し、event_pattern の値に定義しています。

下記はターゲットに関する定義です。

resource "aws_cloudwatch_event_target" "demo_alert_alarm" {
  arn      = aws_cloudwatch_event_api_destination.demo_alert.arn
  role_arn = aws_iam_role.demo_alert.arn
  rule     = aws_cloudwatch_event_rule.demo_alert_alarm.id

  input_transformer {
    input_paths = {
      account     = "$.account"
      alarmName   = "$.detail.alarmName"
      description = "$.detail.configuration.description"
      reason      = "$.state.reason"
      region      = "$.region"
      time        = "$.time"
    }
    input_template = <<EOF
    {
  "attachments": [
      {
          "color": "#E01D5A",
          "blocks": [
              {
                  "type": "section",
                  "text": {
                      "type": "mrkdwn",
                      "text": "*<alarmName> - ECSの必要なタスク数が足りていません*"
                  }
              },
              {
                  "type": "section",
                  "fields": [
                      {
                          "type": "mrkdwn",
                          "text": "*CloudWatch Alarm名:*\n<alarmName>"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*CloudWatch Alarmの詳細:*\n *<https://ap-northeast-1.console.aws.amazon.com/cloudwatch/xxxxxxxxxxxxxxx/<alarmName>?|AWS Console URL>*"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*AWSアカウント:*\n<account>"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*リージョン:*\n<region>"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*発生日時:*\n<time>"
                      }
                  ]
              },
              {
                  "type": "section",
                  "text": {
                      "type": "mrkdwn",
                      "text": "アラートの内容に注意してください。\\n必要であれば *<https://www.notion.so/xxxxxxxxxxxxxxx|システムアラート管理表 対応ログ>* から「 <alarmName> 」をアラーム名のプロパティでフィルターし、過去の記録を参考に対応してください。\\n( *<https://www.notion.so/xxxxxxxxxxxxxxx|システムアラートの対応記録方針>* を参考に今回の対応記録も残しましょう。)"
                  }
              }
          ]
      }
  ]
}
    EOF
  }
}

input_transformer では、local.input_path_settings で local values の EventBridge の変数用の定義を指定し、input_template では post する Slack のメッセージのヒアドキュメントを定義しています。このヒアドキュメントの中で、EventBridge の変数を のように使用し、メッセージで展開が行えます。

下記はターゲットに付随するリソースに関する定義です。

resource "aws_cloudwatch_event_api_destination" "demo_alert" {
  connection_arn                   = aws_cloudwatch_event_connection.demo_alert.arn
  http_method                      = "POST"
  invocation_endpoint              = "xxxxxxxxxxxxxxxxxxxxxxxxxxxx" # webhook URL はここでは管理しない
  invocation_rate_limit_per_second = 300
  name                             = "demo-alert"
  lifecycle {
    ignore_changes = [
      invocation_endpoint
    ]
  }
}

resource "aws_cloudwatch_event_connection" "demo_alert" {
  authorization_type = "API_KEY"
  name               = "demo-alert"
  auth_parameters {
    api_key {
      key   = "Authorization"
      value = "dummy"
    }
  }
}

aws_cloudwatch_event_api_destination, aws_cloudwatch_event_connection では イベントの送信先と接続設定に関する設定を行なっています。aws_cloudwatch_event_api_destination の invocation_endpoint には Webhook URL の値が設定されます。aws_cloudwatch_event_connection の auth_parameters.api_key の設定は使用しないため、適当な文字列の設定で大丈夫です。また、注意点としてここでは説明をわかりやすくするために、invocation_endpoint をマスクした値でハードコードしているような実装になっていますが、セキュアな情報となりますのでこの辺りの管理や参照などは適宜安全な方法で行ってください。

最後は IAM に関する定義です。

EventBridge が ApiDestination へイベントを送信するための権限などを設定しています。

resource "aws_iam_role" "demo_alert" {
  assume_role_policy = jsonencode({
    "Statement" : [
      {
        "Action" : "sts:AssumeRole",
        "Effect" : "Allow",
        "Principal" : {
          "Service" : "events.amazonaws.com"
        }
      }
    ],
    "Version" : "2012-10-17"
  })
  managed_policy_arns = [
    aws_iam_policy.demo_alert.arn,
  ]
  max_session_duration = 3600
  name                 = "demo-alert"
}

resource "aws_iam_policy" "demo_alert" {
  name = "demo-alert"
  policy = jsonencode({
    "Statement" : [
      {
        "Action" : [
          "events:InvokeApiDestination"
        ],
        "Effect" : "Allow",
        "Resource" : [
          "arn:aws:events:ap-northeast-1:xxxxxxxxxxxx:api-destination/demo-alert/*"
        ]
      }
    ],
    "Version" : "2012-10-17"
  })
}

ここまで解説した内容の実装は、CloudWatch Alarm の state だと ALARM に関連する内容のものとなっています。ですが、実際にはアラートが復旧した場合の OK 通知も必要となる場合がほとんどでしょう。そのため下記のリソースは state ごとに 1セットで作成するので必要に応じて追加してください。(その他は共用で利用)

  • aws_cloudwatch_event_rule
  • aws_cloudwatch_event_target

最終的に出来上がった全体のコードはこちらになります。

resource "aws_cloudwatch_event_rule" "demo_alert_alarm" {
  name = "demo-alert-alarm"
  event_pattern = jsonencode(
    {
      source = [
        "aws.cloudwatch"
      ],
      detail-type = [
        "CloudWatch Alarm State Change"
      ],
      resources = [
        {
          prefix = "arn:aws:cloudwatch:ap-northeast-1:xxxxxxxxxxxx:alarm:required-tasks-dev"
        }
      ],
      detail = {
        state = {
          value = [
            "ALARM"
          ]
        }
      }
    }
  )
}

resource "aws_cloudwatch_event_rule" "demo_alert_ok" {
  name = "demo-alert-ok"
  event_pattern = jsonencode(
    {
      source = [
        "aws.cloudwatch"
      ],
      detail-type = [
        "CloudWatch Alarm State Change"
      ],
      resources = [
        {
          prefix = "arn:aws:cloudwatch:ap-northeast-1:xxxxxxxxxxxx:alarm:required-tasks-dev"
        }
      ],
      detail = {
        state = {
          value = [
            "OK"
          ]
        }
      }
    }
  )
}

resource "aws_cloudwatch_event_target" "demo_alert_alarm" {
  arn      = aws_cloudwatch_event_api_destination.demo_alert.arn
  role_arn = aws_iam_role.demo_alert.arn
  rule     = aws_cloudwatch_event_rule.demo_alert_alarm.id

  input_transformer {
    input_paths = {
      account     = "$.account"
      alarmName   = "$.detail.alarmName"
      description = "$.detail.configuration.description"
      reason      = "$.state.reason"
      region      = "$.region"
      time        = "$.time"
    }
    input_template = <<EOF
    {
  "attachments": [
      {
          "color": "#E01D5A",
          "blocks": [
              {
                  "type": "section",
                  "text": {
                      "type": "mrkdwn",
                      "text": "*<alarmName> - ECSの必要なタスク数が足りていません*"
                  }
              },
              {
                  "type": "section",
                  "fields": [
                      {
                          "type": "mrkdwn",
                          "text": "*CloudWatch Alarm名:*\n<alarmName>"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*CloudWatch Alarmの詳細:*\n *<https://ap-northeast-1.console.aws.amazon.com/cloudwatch/xxxxxxxxxxxxxxx/<alarmName>?|AWS Console URL>*"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*AWSアカウント:*\n<account>"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*リージョン:*\n<region>"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*発生日時:*\n<time>"
                      }
                  ]
              },
              {
                  "type": "section",
                  "text": {
                      "type": "mrkdwn",
                      "text": "アラートの内容に注意してください。\\n必要であれば *<https://www.notion.so/xxxxxxxxxxxxxxx|システムアラート管理表 対応ログ>* から「 <alarmName> 」をアラーム名のプロパティでフィルターし、過去の記録を参考に対応してください。\\n( *<https://www.notion.so/xxxxxxxxxxxxxxx|システムアラートの対応記録方針>* を参考に今回の対応記録も残しましょう。)"
                  }
              }
          ]
      }
  ]
}
    EOF
  }
}

resource "aws_cloudwatch_event_target" "demo_alert_ok" {
  arn      = aws_cloudwatch_event_api_destination.demo_alert.arn
  role_arn = aws_iam_role.demo_alert.arn
  rule     = aws_cloudwatch_event_rule.demo_alert_ok.id

  input_transformer {
    input_paths = {
      account     = "$.account"
      alarmName   = "$.detail.alarmName"
      description = "$.detail.configuration.description"
      reason      = "$.state.reason"
      region      = "$.region"
      time        = "$.time"
    }
    input_template = <<EOF
    {
  "attachments": [
      {
          "color": "#2DB57C",
          "blocks": [
              {
                  "type": "section",
                  "text": {
                      "type": "mrkdwn",
                      "text": "*<alarmName> - ECSの必要なタスク数が足りていません*"
                  }
              },
              {
                  "type": "section",
                  "fields": [
                      {
                          "type": "mrkdwn",
                          "text": "*CloudWatch Alarm名:*\n<alarmName>"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*CloudWatch Alarmの詳細:*\n *<https://ap-northeast-1.console.aws.amazon.com/cloudwatch/xxxxxxxxxxxxxxx/<alarmName>?|AWS Console URL>*"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*AWSアカウント:*\n<account>"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*リージョン:*\n<region>"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*発生日時:*\n<time>"
                      }
                  ]
              },
              {
                  "type": "section",
                  "text": {
                      "type": "mrkdwn",
                      "text": "引き続きアラートに注意してください"
                  }
              }
          ]
      }
  ]
}
    EOF
  }
}

resource "aws_cloudwatch_event_api_destination" "demo_alert" {
  connection_arn                   = aws_cloudwatch_event_connection.demo_alert.arn
  http_method                      = "POST"
  invocation_endpoint              = "xxxxxxxxxxxxxxxxxxxxxxxxxxxx" # webhook URL はここでは管理しない
  invocation_rate_limit_per_second = 300
  name                             = "demo-alert"
  lifecycle {
    ignore_changes = [
      invocation_endpoint
    ]
  }
}

resource "aws_cloudwatch_event_connection" "demo_alert" {
  authorization_type = "API_KEY"
  name               = "demo-alert"
  auth_parameters {
    api_key {
      key   = "Authorization"
      value = "dummy"
    }
  }
}

resource "aws_iam_role" "demo_alert" {
  assume_role_policy = jsonencode({
    "Statement" : [
      {
        "Action" : "sts:AssumeRole",
        "Effect" : "Allow",
        "Principal" : {
          "Service" : "events.amazonaws.com"
        }
      }
    ],
    "Version" : "2012-10-17"
  })
  managed_policy_arns = [
    aws_iam_policy.demo_alert.arn,
  ]
  max_session_duration = 3600
  name                 = "demo-alert"
}

resource "aws_iam_policy" "demo_alert" {
  name = "demo-alert"
  policy = jsonencode({
    "Statement" : [
      {
        "Action" : [
          "events:InvokeApiDestination"
        ],
        "Effect" : "Allow",
        "Resource" : [
          "arn:aws:events:ap-northeast-1:xxxxxxxxxxxx:api-destination/demo-alert/*"
        ]
      }
    ],
    "Version" : "2012-10-17"
  })
}

additional

ここまでは基本的な実装方法をお伝えしましたが、対象の CloudWatch Alarm リソースが増える度に定義が増えてしまう冗長な設計となっていました。応用として下記のように local values に CloudWatch Alarm ごとの固有の設定に関する定義を切り出し、loop させて DRY に実装する方法もおすすめです。ここでは詳しくは解説しませんが、一部のサンプルコードをご紹介しておきます。

locals {
  input_paths = {
    account     = "$.account"
    alarmName   = "$.detail.alarmName"
    description = "$.detail.configuration.description"
    reason      = "$.state.reason"
    region      = "$.region"
    time        = "$.time"
  }

  config = [
    {
      target_arn     = aws_cloudwatch_event_api_destination.demo_alert.arn
      name_prefix    = "demo-alert"
      prefix_pattern = "arn:aws:cloudwatch:ap-northeast-1:xxxxxxxxxxxx:alarm:required-tasks-dev"
      alert = {
        alarm = {
          main_message = "*<alarmName> - ECSの必要なタスク数が足りていません*"
          sub_message  = "アラートの内容に注意してください。\\n必要であれば *<https://www.notion.so/xxxxxxxxxxxxxxx|システムアラート管理表 対応ログ>* から「 <alarmName> 」をアラーム名のプロパティでフィルターし、過去の記録を参考に対応してください。\\n( *<https://www.notion.so/xxxxxxxxxxxxxxx|システムアラートの対応記録方針>* を参考に今回の対応記録も残しましょう。)"
          color        = "#E01D5A"
          state_type   = "ALARM"
        }
        ok = {
          main_message = "*<alarmName> - ECSの必要なタスク数が足りていません*"
          sub_message  = "引き続きアラートに注意してください"
          color        = "#2DB57C"
          state_type   = "OK"
        }
      }
    }
  ]
}

resource "aws_cloudwatch_event_rule" "demo_alert" {
  for_each = {
    for config in local.config : config.name_prefix => {
      name_prefix    = config.name_prefix
      prefix_pattern = config.prefix_pattern
      alert          = config.alert
    }
  }

  name = format("%s-%s", each.value.name_prefix, "alarm")
  event_pattern = jsonencode(
    {
      source = [
        "aws.cloudwatch"
      ],
      detail-type = [
        "CloudWatch Alarm State Change"
      ],
      resources = [
        {
          prefix = each.value.prefix_pattern
        }
      ],
      detail = {
        state = {
          value = [each.value.alert.alarm.state_type]
        }
      }
    }
  )
}

resource "aws_cloudwatch_event_target" "demo_alert" {

  for_each = {
    for config in local.config : config.name_prefix => {
      name_prefix    = config.name_prefix
      prefix_pattern = config.prefix_pattern
      alert          = config.alert
      target_arn     = config.target_arn
    }
  }

  arn      = each.value.target_arn
  role_arn = aws_iam_role.demo_alert.arn
  rule     = aws_cloudwatch_event_rule.alarm[each.value.name_prefix].id

  input_transformer {
    input_paths    = local.input_paths
    input_template = <<EOF
    {
  "attachments": [
      {
          "color": "${each.value.alert.alarm.color}",
          "blocks": [
              {
                  "type": "section",
                  "text": {
                      "type": "mrkdwn",
                      "text": "${each.value.alert.alarm.main_message}"
                  }
              },
              {
                  "type": "section",
                  "fields": [
                      {
                          "type": "mrkdwn",
                          "text": "*CloudWatch Alarm名:*\n<alarmName>"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*CloudWatch Alarmの詳細:*\n *<https://ap-northeast-1.console.aws.amazon.com/cloudwatch/xxxxxxxxxxxxxxx/<alarmName>?|AWS Console URL>*"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*AWSアカウント:*\n<account>"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*リージョン:*\n<region>"
                      },
                      {
                          "type": "mrkdwn",
                          "text": "*発生日時:*\n<time>"
                      }
                  ]
              },
              {
                  "type": "section",
                  "text": {
                      "type": "mrkdwn",
                      "text": "${each.value.alert.alarm.sub_message}"
                  }
              }
          ]
      }
  ]
}
    EOF
  }
}

おわりに

今回は、EventBridge で CloudWatch Alarm のメッセージを加工し、Slack へ post する方法をご紹介しました。Lambda などを使わずとも柔軟にメッセージを加工できるのは個人的に発見でした。皆さんも機会があればぜひ試してみてください!