EventBridgeからDiscordのエンドポイントを直接叩いて通知するしくみをCDKで実装してみました

監視の内容をシンプルにDiscordに通知したいけど、Lambda関数の管理までしたくない・・・そんな時EventBridgeの機能で十分通知できるかもしれない。ということでCDKを使いながら、実装してみました。
2022.12.06

こんにちは。AWS事業本部コンサルティング部に所属している今泉(@bun76235104)です。

監視と通知大事ですよね。

今回はCloudWatch Alarmでアラーム状態になったイベントについて、シンプルに整えてDiscordに送信してみました。

今回の構成

以下のようなシンプルな構成です。

20221205_discord_notification_architecture

ちょっと前まであれば、メッセージの整形・通知の部分にLambdaを使う選択肢が多かったと思います。

今回はEventBridgeの入力トランスフォーマーという機能を使って整形し、Discordに通知してみます。

たとえば、Slackの場合どんな方法があるか、こちらの記事に分かりやすくまとめられていたので興味のある方はご確認ください。

今回実装してみた環境は以下のとおりです。

Key Value
OS macOS Monterey
node v18.12.1
言語 TypeScript
cdk v2.53.0

なお、今回紹介しているコードの全文は以下リポジトリに格納しています。

下準備: DiscordのWebhookを作成する

今回はDiscordへメッセージを投稿するためにWebhookを作成します。

投稿したいチャンネルの設定を開きます。

20221205_discord_eventbridge_setting1

次にサイドバーの「連携サービス」をクリックして、「ウェブフックを作成」をクリックします。

20221205_discord_eventbridge_settting2

作成されたら、「ウェブフックURLをコピー」をクリックしておきます。

20221205_discord_eventbridge_setting3

このURLは次の項目で利用するため控えておいてください。

下準備: .envの作成

CDKを利用する上で環境変数や機密情報を取り扱う方法はいくつかあるかと思いますが、今回はWebhookのURLを誤って公開しないように.envのしくみを利用しています。

contexnt.jsonを利用する方法などもありますので、気になる方は以下記事が分かりやすかったのでご参照ください。

CDKコードのルートディレクトリで以下のようにdotenvをインストールしておきます。

npm i dotenv

また.envというファイルを作成して、以下のように環境変数にWebhookのURLを設定しておきます。

ENDPOINT='https://discord.com/api/webhooks/hoge'

次に最も重要な手順ですが.gitignoreに.envを追加して、誤ってコミットしてしまうことを防ぎましょう。

.gitignore

*.env

次に環境変数を読み込むtsファイルのトップで以下のようにdotenvを準備します。

lib/eventbridge_discord-stack.ts

import * as dotenv from 'dotenv';
dotenv.config();
//  以下その他モジュールなどの読み込みのため割愛
import * as cdk from 'aws-cdk-lib';

Discord通知のためのCDKコードを書いてみた

今回は実際にEC2インスタンスを立ち上げて、CPU使用率を監視してみます。

以下のメトリクスの中から CPUUtilization を監視します。

まずは、CDKで以下のようにサンプル用のEC2を立ち上げる準備をします。

lib/eventbridge_discord-stack.ts

export class EventbridgeDiscordStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    // EC2作成
    const vpc = new Vpc(this, 'Vpc', {
      cidr: '10.100.0.0/16',
      maxAzs: 2,
      natGateways: 0,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'isolated',
          subnetType: SubnetType.PRIVATE_ISOLATED,
        },
      ],
    });
    const ami = new AmazonLinuxImage({
      generation: AmazonLinuxGeneration.AMAZON_LINUX_2,
      cpuType: AmazonLinuxCpuType.X86_64,
    });
    const ec2 = new Instance(this, 'sampleInstance', {
      vpc: vpc,
      instanceType: InstanceType.of(InstanceClass.T2, InstanceSize.MICRO),
      machineImage: ami,
    });
  }
}

次に、メトリクス監視のためのCloudWatchAlarmのリソースを作成します。

lib/eventbridge_discord-stack.ts

    // 先ほどのEC2作成部分からの続き
    // CloudWatch Alarm
    const ec2CpuAlarm = new Alarm(this, 'testEC2CpuAlarm', {
      metric: new Metric({
        namespace: 'AWS/EC2',
        metricName: 'CPUUtilization',
        dimensionsMap: {
          InstanceId: ec2.instanceId,
        },
        statistic: 'Average',
        period: Duration.minutes(1),
      }),
      evaluationPeriods: 1,
      // 今回アラートのテストのため閾値を0.005%に指定
      threshold: 0.005,
      comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
    });

最後にこのAlarmを監視して、Discordに通知するためのリソースを作成します。

    //EventBridge Rule
    const connection = new Connection(this, 'Connection', {
      authorization: Authorization.apiKey(
        'Token if Nedded',
        // 今回特にTokenやAPI Keyは利用しない
        SecretValue.unsafePlainText('hoge')
      ),
      description: 'Connection with API Key Token If Needed',
    });
    const destination = new ApiDestination(this, 'Destination', {
      connection,
      // 最初に取得したDiscordのWebhookエンドポイント
      endpoint: process.env.ENDPOINT ?? '',
      description: 'POST Discrod API',
    });
    const rule = new Rule(this, 'testAlarmRule', {
      ruleName: 'testAlarmRule',
      eventPattern: {
        source: ['aws.cloudwatch'],
        detailType: ['CloudWatch Alarm State Change'],
        resources: [ec2CpuAlarm.alarmArn],
      },
    });
    rule.addTarget(
      new cdk.aws_events_targets.ApiDestination(destination, {
        // 入力トランスフォーマーでメッセージを整形
        // https://aws.amazon.com/jp/premiumsupport/knowledge-center/eventbridge-human-readable-notifications/
        event: RuleTargetInput.fromObject({
          content: `:loudspeaker: アラート :loudspeaker:
アラート名: ${EventField.fromPath('$.detail.alarmName')}
アラート理由: ${EventField.fromPath('$.detail.state.reason')}`,
        }),
      })
    );

なお、今回は利用していませんが、APIキーなどの機密情報を利用する場合、Secrets Managerに格納可能です。

その場合、Connection の部分を以下のように変更するイメージです。

    const secret = new Secret(this, 'Secret', {
      secretName: 'ChatWorkApiKey',
      generateSecretString: {
        generateStringKey: 'password',
        secretStringTemplate: JSON.stringify({
          apiKey: process.env.APIKEY ?? '',
        }),
      },
    });
   const connection = new Connection(this, 'Connection', {
      authorization: Authorization.apiKey(
        'X-ChatWorkToken',
        SecretValue.secretsManager(secret.secretArn, {
          jsonField: 'apiKey',
        })
      ),
      description: 'Connection with API Key',
    });

通知のメッセージに関しては入力トランスフォーマーという機能を使って加工しています。

20221205_discord_evenbridge_transformer

どのような設定で、どのようにメッセージが加工されているか確認するためデプロイしてみます。

cdk deploy

EC2インスタンスなどの必要リソースが作られて、しばらく待っていることで無事Discordに通知が届きました!

20221205_discord_eventbridge_notification_image

入力トランスフォーマーではEventBridgeに渡ってくる各サービスからの情報を加工しています。

各サービスからどのような構造のJSONでどのような情報があるかはこちらで確認できます。

例えばCloudWatch AlarmのページでJSON構造体が確認できます。

今回は以下のようなJSONがEventBridgeに渡されます。

{
  "version": "0",
  "id": "c4c1c1c9-6542-e61b-6ef0-8c4d36933a92",
  "detail-type": "CloudWatch Alarm State Change",
  "source": "aws.cloudwatch",
  "account": "123456789012",
  "time": "2019-10-02T17:04:40Z",
  "region": "us-east-1",
  "resources": ["arn:aws:cloudwatch:us-east-1:123456789012:alarm:ServerCpuTooHigh"],
  "detail": {
    "alarmName": "ServerCpuTooHigh",
    "configuration": {
      "description": "Goes into alarm when server CPU utilization is too high!",
      "metrics": [{
        "id": "30b6c6b2-a864-43a2-4877-c09a1afc3b87",
        "metricStat": {
          "metric": {
            "dimensions": {
              "InstanceId": "i-12345678901234567"
            },
            "name": "CPUUtilization",
            "namespace": "AWS/EC2"
          },
          "period": 300,
          "stat": "Average"
        },
        "returnData": true
      }]
    },
    "previousState": {
      "reason": "Threshold Crossed: 1 out of the last 1 datapoints [0.0666851903306472 (01/10/19 13:46:00)] was not greater than the threshold (50.0) (minimum 1 datapoint for ALARM -> OK transition).",
      "reasonData": "{\"version\":\"1.0\",\"queryDate\":\"2019-10-01T13:56:40.985+0000\",\"startDate\":\"2019-10-01T13:46:00.000+0000\",\"statistic\":\"Average\",\"period\":300,\"recentDatapoints\":[0.0666851903306472],\"threshold\":50.0}",
      "timestamp": "2019-10-01T13:56:40.987+0000",
      "value": "OK"
    },
    "state": {
      "reason": "Threshold Crossed: 1 out of the last 1 datapoints [99.50160229693434 (02/10/19 16:59:00)] was greater than the threshold (50.0) (minimum 1 datapoint for OK -> ALARM transition).",
      "reasonData": "{\"version\":\"1.0\",\"queryDate\":\"2019-10-02T17:04:40.985+0000\",\"startDate\":\"2019-10-02T16:59:00.000+0000\",\"statistic\":\"Average\",\"period\":300,\"recentDatapoints\":[99.50160229693434],\"threshold\":50.0}",
      "timestamp": "2019-10-02T17:04:40.989+0000",
      "value": "ALARM"
    }
  }
}

上のJSONから$.detail.alarmName$.detail.state.reasonを利用して、出力を整形しています。

{
  "detail-alarmName": "$.detail.alarmName",
  "detail-state-reason": "$.detail.state.reason"
}
{"content":":loudspeaker: アラート :loudspeaker:\nアラート名: <detail-alarmName>\nアラート理由: <detail-state-reason>"}

出力値はDiscordのメッセージ投稿APIの仕様に合わせています。

で以下の様な形でメッセージが届いている形です。

20221205_discord_eventbridge_actual_notification_image

これで一通りの確認ができました。

再掲となりますが、コード全文をみたい場合は以下リポジトリをご確認ください。

まとめ

  • 簡単な通知ならLambdaを組まないでもEventBridgeでDiscordに通知できた
  • Discordに限らずさまざまなエンドポイントに応用ができる

なお、今回Chatworkへの通知も試してみたのですが、2022.12.5現在のChatworkの仕様で入力トランスフォーマーが利用できないようで、上記方法ではメッセージを加工できませんでした。

同じことをやる場合でも、さまざまなやり方が考えられて面白いですね。

これからもいろいろ検証していこうと思います!