Serverless Framework でエラーを検知して Webhook で Slack に通知を飛ばす方法

#はじめに

AppSync の Lambda リゾルバを書く際に [Serverless Framework](https://www.serverless.com/) を使用したのですが、
デプロイ後のバグ調査の際、毎回ブラウザから AWS Console を開いて該当 Lambda の CloudWatch のログを見に行くのが面倒でした。。 :upside_down: 

そのため、エラーレポートの仕組みが欲しくなり、Lambda のエラーを Slack に通知する仕組みを Serverless Framework で実装する方法について調査したので、備忘録も兼ねて記事にまとめました :writing_hand:

<details><summary>追記 (2020/08/19)</summary><div>

ローカルから Lambda 関数のログを確認したいだけなら、Serverless Framework CLI の [logs コマンド](https://www.serverless.com/framework/docs/providers/aws/cli-reference/logs/) もしくは、 AWS CLI の [logs コマンド](https://qiita.com/kai_kou/items/60bbe314b74b9eaf7126) で可能みたいです :mag:
</div></details>

#動作環境

- [Serverless Framework](https://www.npmjs.com/package/serverless/v/1.78.1) 1.78.1
- Node.js 12.18.1
  - [Slack Incoming Webhooks](https://www.npmjs.com/package/@slack/webhook/v/5.0.3) 5.0.3
  - [AWS SDK for JavaScript](https://www.npmjs.com/package/aws-sdk/v/2.518.0) 2.518.0

# 1. Slack で Webhook URL を発行する

まずは [公式サイトの手順](https://slack.com/intl/ja-jp/help/articles/115005265063-Slack-%E3%81%A7%E3%81%AE-Incoming-Webhook-%E3%81%AE%E5%88%A9%E7%94%A8) に従って Webhook URL を発行します :earth_americas: 

無事発行できると、
`https://hooks.slack.com/services/~~~~~/~~~~~/~~~~~~~~~~~` のようなフォーマットの URL が取得出来るはずなのでメモっておきます :memo:

# 2. 必要な npm パッケージをインストールする

Slack の Incoming Webhook の仕組みを使用し、
チャンネルにメッセージを送信するための npm パッケージをインストールします :arrow_down:

```bash
npm install @slack/webhook --save
```

# 3. Slack にエラーレポートを送信する Lambda 関数を作成する (TypeScript)

Serverless Framework の handler に Slack にエラーレポートを送信する関数を追加します :arrow_down: 

```typescript:handlers/Reporter.ts
import { gunzip } from "zlib";
import { IncomingWebhook } from "@slack/webhook";

/**
 * CloudWatch のログ情報
 */
interface CloudWatchLogContent {
  messageType: string;
  owner: string;
  logGroup: string;
  logStream: string;
  subscriptionFilters: string[];
  logEvents: {
    id: string;
    timestamp: string;
    message: string;
  }[];
}

/**
 * Lambda リゾルバの型定義
 */
type LambdaResolver<TEvent = any> = (event: TEvent) => Promise<any> | any;

/**
 * CloudWatch のロググループに出現するエラーを通知する
 * @param event 該当するエラーログの内容
 * @return {object} event オブジェクトをそのまま返却する
 */
export const NotifyError: LambdaResolver = async (event: any) => {
/**
{
  awslogs: {
    data: 'H4sIAAA...'
  }
}
CloudWatch から呼ばれた際の event には上記フォーマットでデータが入っている。
data 内には Base64 でエンコードされた gzip 形式で圧縮されたデータが入っているので、
gzip 形式のデータを解凍しつつ、Base64 デコードを行い JSON 文字列を取得するための関数
*/
  const gunzipAsync = async (base64Logs): Promise<string> => {
    return new Promise(function (resolve, reject) {
      gunzip(base64Logs, function (err, binary) {
        err ? reject(err) : resolve(binary.toString("ascii"));
      });
    });
  };

// 1. Base64 でエンコードされた gzip 形式で圧縮されたデータを Base64 でデコードし、gzip のバイナリとして取得する
// gunzipAsync 関数で gzip 解凍して ascii 文字列として取得することで CloudWatch のログ内容を JSON 文字列で取得する
  const base64Logs = Buffer.from(event["awslogs"]["data"], "base64");
  const uncompressedLogs = await gunzipAsync(base64Logs);
  console.log(uncompressedLogs);

// 2. 取得した JSON 文字列を CloudWatchLogContent に変換して取得する
  const content = <CloudWatchLogContent>JSON.parse(uncompressedLogs);
  console.log(content);

// 3. 発行した Slack の Webhook URL で IncomingWebhook クラスを生成し、
// send 関数で Slack チャンネル名 (ex. #serverless-error-report) と、
// CloudWatchLogContent の内容を元に作成したテキストを引数に指定して、
// 該当する Slack チャンネルにテキストを投稿する
  const webhook = new IncomingWebhook("<1. で発行した Slack の Incoming Webhook URL>");
  await webhook.send({
    channel: "<通知したいチャンネル名 (例: #serverless-error-report)>",
    icon_emoji: "hammer", // icon_emoji パラメタを指定すると Slack へのメッセージ通知の際のアイコンを変更することが可能
    text: `*Group*\n_${content.logGroup}_\n\n*Message*\n\`\`\`${content.logEvents[0].message}\`\`\``,
  });
  return event;
};
```

#4. 3. の関数をデプロイする関数として追加し、各種 Lambda 関数の CloudWatch のログを監視するイベントと紐付ける

`serverless.yml` に [手順 3.](https://qiita.com/nikaera/items/27c1ba5f8fd2e61425c4#3-slack-%E3%81%AB%E3%82%A8%E3%83%A9%E3%83%BC%E3%83%AC%E3%83%9D%E3%83%BC%E3%83%88%E3%82%92%E9%80%81%E4%BF%A1%E3%81%99%E3%82%8B-lambda-%E9%96%A2%E6%95%B0%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B-typescript) で作成した関数 `NotifyError` を記載すると共に、
監視したい関数の CloudWatch ロググループを `events` の `cloudwatchLog` に定義し、`filter` に `ERROR` を指定します :heavy_check_mark: 

これで、
該当ロググループに `ERROR` が含まれていた場合、
都度 Lambda 関数が実行されるようになります :fire: :arrow_down: 

```yml:serverless.yml
#...
# 3. で作成した Slack にエラーレポートを送信する関数 NotifyError を functions に追記し、
# events を用いて、他 Lambda 関数のロググループに 'ERROR' が出力されていた場合、 NotifyError 関数が実行されるようにする
functions:
#...
  NotifyError:
    handler: CloudWatch.NotifyError
    events:
      - cloudwatchLog:
          logGroup: /aws/lambda/${self:service.name}-${self:provider.stage}-TestFunction1
          filter: ERROR
      - cloudwatchLog:
          logGroup: /aws/lambda/${self:service.name}-${self:provider.stage}-TestFunction2
          filter: ERROR
      - cloudwatchLog:
          logGroup: /aws/lambda/${self:service.name}-${self:provider.stage}-TestFunction3
          filter: ERROR
#...
```

:arrow_up: が完了したら `sls deploy` で `NotifyError` 関数をデプロイします :keyboard:

最後に AWS CLI で CloudWatch にイベントログデータを送信してみて、
本当に Slack に通知が飛んでくるか動作確認を行いましょう :hammer_pick:

##注意事項 (複数の `cloudwatchLog` を定義した場合)

`events` に複数の `cloudwatchLog` を定義した場合、
関数のデプロイ後、AWS Console で該当する Lambda 関数を見に行くと、
正しく CloudWatch のイベントが紐付けられていないように見えます :arrow_down:
![492d4761e7fadb62612e539c17b3d05f.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/86070/b6f36790-392e-1178-772c-601c2c808130.png)


[同様のケース](https://ja.stackoverflow.com/a/57320)が発生している方は他にもいらっしゃるようですが、
[手順 5.](https://qiita.com/nikaera/items/27c1ba5f8fd2e61425c4#5-aws-cli-%E3%82%92%E5%88%A9%E7%94%A8%E3%81%97%E3%81%A6cloudwatch-%E3%81%AB%E3%82%A4%E3%83%99%E3%83%B3%E3%83%88%E3%83%AD%E3%82%B0%E3%83%87%E3%83%BC%E3%82%BF%E3%82%92%E9%80%81%E4%BF%A1%E3%81%97%E3%81%A6-slack-%E3%81%AB%E9%80%9A%E7%9F%A5%E3%81%8C%E9%A3%9B%E3%82%93%E3%81%A7%E3%81%8F%E3%82%8B%E3%81%8B%E6%A4%9C%E8%A8%BC%E3%81%99%E3%82%8B) で Slack への通知まで確認出来れば問題なく設定できています :+1:

#5. AWS CLI を利用してCloudWatch にイベントログデータを送信して Slack に通知が飛んでくるか検証する

CloudWatch にイベントログデータを送信するコマンドです :white_check_mark: :arrow_down:

```bash
aws logs put-log-events \
    --log-group-name '<該当するロググループ名>(例: /aws/lambda/test-dev-TestFunction1)' \
    --log-stream-name '<ログストリーム名(例: test-stream)>' --log-events \
    timestamp=(node -e 'console.log(Date.now())'),message="This is ERROR"
```

コマンド実行後、
AWS Console から CloudWatch の該当するロググループのログストリームを見に行くと、
`This is ERROR` という文字列が出力されている事が確認できるはずです :mag:

あとは Slack に通知が飛んできたことまで確認できれば動作確認完了です！ :tada: :arrow_down: 
![4a5d063485ed635c83e3ea1917043673.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/86070/969bb18a-ffd4-33f3-c3e2-4052518b4047.png)

#おわりに

Serverless Framework 内で完結する形で、
Lambda 関数のエラーを捕捉して Slack に通知を飛ばす方法についてまとめました :writing_hand:

`events` には `cloudwatchLog` の他にも `eventBridge` というものも指定できます。
`eventBridge` を使用すると CloudWatch 以外の様々な AWS サービスのイベント駆動で Lambda 関数を実行することが可能です :muscle:

`events` を有効活用することで効率よくイベント駆動の処理を書いていけるので、
是非とも有効活用していきましょう！ :runner: :dash:

#参考リンク

- [Serverless Framework - AWS Lambda Events - CloudWatch Log](https://www.serverless.com/framework/docs/providers/aws/events/cloudwatch-log/)
- [EventBridgeとLambdaを連携させるServerless Frameworkの設定方法](https://dev.classmethod.jp/articles/serverless-framework-eventbridge-lambda/)
- [サーバーレスイベントバスのAmazon EventBridge](https://www.acrovision.jp/service/aws/?p=1028)
- [aws-cliでAWS CloudWatchにログを書き込む](https://qiita.com/zaburo/items/c90645afc0862184c823)