CloudWatch ダッシュボードを定期的にSlack通知する(ソース付き)


はじめに

最近TypeScriptが楽しいので、実益兼ねて作ったものをご紹介します

どんなものを作るか

AWS上でサーバレスに構築したLPを個人で開発・保守・運用しています👷‍♂️

今まで、メトリクスの異常値(CloudFrontのリクエスト数増加、Lambdaでのエラー発生、請求金額など)については、CloudWatchアラーム -> SNS -> Chatbot -> Slack という経路で適宜通知を行っていたのですが、
平常時の値についてはダッシュボードを作成したもののあまり見れておらず、Slack上でもう少し手軽&こまめに確認したい...と考えていました

AWS SDKにはGetMetricWidgetImageという、メトリクスを画像出力できるというそのものズバリなやつがありますが、
CloudWatchダッシュボードをそのまま出力する機能は提供されておらず、何かしら工夫が必要となります

ということで、TypeScriptとServerless Frameworkを用いて、CloudWatchダッシュボードを日次でSlackに通知する仕組みを開発することとしました🐔

どうやって作るか

この上なく簡単な構成図がこちらです

ダッシュボードはコンソールで手ポチで作成した上で、JSONエクスポートしたものをソースコードから読み込み利用します
Lambda上でダッシュボードの定義ファイルを読み込み、メトリクス毎に画像出力します

そのまま画像毎にSlackに投稿しても悪くはないですが、好みとしてはその時のメトリクスの断面を残しておきたかったので、merge-imagesというライブラリを用いて各メトリクス画像を1枚の画像にまとめることとします

本ライブラリの動作にはnode-canvasが必要になりますが、例によってLambda上では動かず困っていたところ、
渡りに船⛴とばかりにnode-canvas-lambdaなるLambda Layerを作っている方がいたので、こちらをありがたく読み込んで使います

作ったもの

コードは以下になります

構築方法

ダッシュボードを作成した上で、 アクション -> ソースの表示/編集 で表示されるJSON定義をコピーし、widgets/sample.json を上書きしてください

widgets/sample.json
{
  "comment": "replace me!"
}

serverless.ymldeploymentBucketschedule、関数のenvironmentなどを適宜変更してください

serverless.yml
service:service:
  name: cw-metrics-notifier

provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
  stage: prod
  deploymentBucket: xxxxxxx
  memorySize: 256
  timeout: 60
  environment:
    SLACK_TOKEN: ${env:SLACK_TOKEN}
    SLACK_METRICS_CHANNEL_ID: ${env:SLACK_METRICS_CHANNEL_ID}
  iamRoleStatements:
    - Effect: Allow
      Action:
        - cloudwatch:GetMetricWidgetImage
      Resource: "*"

functions:
  widgets:
    handler: src/widgets.scheduledHandler
    environment:
      # widgets/ 配下のファイル名(カンマ区切り可)
      WIDGETS_NAMES: sample
      # マージ画像でx方向にいくつまでメトリクスを並べるか
      METRICS_X_COUNT: 2
      # 何日前までを表示対象にするか
      METRICS_DAYS_AGO: 7
    events:
      - schedule:
          rate: cron(0 0 * * ? *)
          enabled: true
    layers:
      - { Ref: NodeCanvasLambdaLayer }
      - { Ref: CanvasLib64LambdaLayer }

layers:
  nodeCanvas:
    package:
      artifact: vendor/node-canvas-lambda/node12_canvas_layer.zip
  canvasLib64:
    package:
      artifact: vendor/node-canvas-lambda/canvas-lib64-layer.zip

package:
  include:
    - widgets/

plugins:
  - serverless-plugin-typescript
  - serverless-plugin-optimize

custom:
  optimize:
    exclude:
      - canvas
    includePaths:
      - widgets/

node-canvas-lambdaの取得については、npm scriptsに書いてあるので気にしなくてもokです

package.json(抜粋)
{
  "scripts": {
    "predeploy": "rm -rf .build; [[ -d vendor/node-canvas-lambda ]] || (cd vendor && git clone https://github.com/jwerre/node-canvas-lambda.git)",
    "deploy": "dotenv -- sls deploy -v"
  }
}

直してみてよさそうだったらデプロイ

ターミナル
# インストール
$ npm ci

# 環境変数情報を追加
$ cp -p .env.sample .env
$ cat .env
AWS_ACCESS_KEY_ID=xxxxx
AWS_SECRET_ACCESS_KEY=xxxxx

SLACK_TOKEN=xoxp-xxxxx
SLACK_METRICS_CHANNEL_ID=Cxxxxx

# デプロイ
$ npm run deploy

スケジュールした時間が来ると、Slackの所定のチャンネルにダッシュボードっぽい画像が投稿されます🍨

実際のコードはGitHubを見て頂くのがよいですが、肝となるメトリクスの画像出力とマージのコードを貼っておきます
MetricWidgetに何が渡せるかはこちらを見るといい感じに書いてあります

あと、これはちゃんと調査できてないのですが、timeSeriesタイプ以外のメトリクスはいい感じに描画されない感じがしたので、取得対象外としています
そしてグラフのキャプションに日本語などマルチバイト文字を入れると文字化けします...

widgets.ts(抜粋)
  // ウィジェット毎にpng画像を生成し、マージ用元データを作成する
  const imageSources = await Promise.all(
    widgets
      .filter(widgetPart => widgetPart?.properties?.view === 'timeSeries')
      .map(async (widgetPart, index) => {
        const properties = widgetPart.properties;
        const imagePath = path.join(imagesDir, `${index}.png`);

        // +0000 形式
        const timezone = moment()
          .format('Z')
          .replace(':', '');

        const cw = new CloudWatch({
          region: properties.region,
        });

        // AWSにリクエストし、結果をファイル保存
        const output = await cw
          .getMetricWidgetImage({
            MetricWidget: JSON.stringify({
              ...properties,
              start: `-PT${metricsDaysAgo * 24}H`,
              end: 'PT0H',
              timezone,
              width: imageWidth,
              height: imageHeight,
            }),
          })
          .promise();

        await fs.writeFile(imagePath, output.MetricWidgetImage);

        // マージ用元データを作成し返却
        // 描画位置の指定をおこない、画像を metricsXCount ずつ横に並べる
        const source: ImageSource = {
          src: imagePath,
          x: imageWidth * (index % metricsXCount),
          y: imageHeight * Math.floor(index / metricsXCount),
        };

        return source;
      }),
  );

  // 画像のマージ
  // ライブラリ都合で Canvas.Image が存在しないとエラーとなったため以下指定
  const canvas: any = Canvas;
  canvas.Image = Image;

  const mergedImageBase64 = await mergeImages(imageSources, {
    Canvas: canvas,
    width: imageWidth * metricsXCount,
    height: imageHeight * Math.ceil(imageSources.length / metricsXCount),
  });

  // Base64形式からファイルに変換
  const metricsData = mergedImageBase64.replace(/^data:image\/png;base64,/, '');
  const metricsPath = path.join(imagesDir, `${widgetsName}.png`);
  await fs.writeFile(metricsPath, metricsData, 'base64');

今回は主旨から逸れるので触れませんが、.github/workflows/ 配下にGitHub Actionsでデプロイする用のワークフローも入れていますので、必要あらばご利用ください

まとめ

TypeScriptは、静的型付けのメリットを手軽に享受しつつ、Node製の各種ライブラリを活用できつつ、Lambdaはじめサーバレスとの親和性も高く、フロントも書けるんだからだいぶ最高感ありますね...!
どんどん使っていきたいと思います🍚