CloudWatch SyntheticsをCDKで管理する

この記事は[ぷりぷりあぷりけーしょんず Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ppap)の1日目の記事です。
初っ端遅刻で失礼します。

# はじめに

今年の4月にAWS CloudWatch Syntheticsの一般提供が開始されました
https://aws.amazon.com/jp/about-aws/whats-new/2020/04/amazon-cloudwatch-synthetics-generally-available/

ざっくりサービスを説明するとREST APIやWebの動作を死活監視し、CI/CDなどでは見逃しがちな特定条件で発生し得るレイテンシの大きなタイミングや想定しない挙動をチェックし、閾値を超えるとアラームを鳴らしてくれる優れものです。

https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries.html

今回はWebの挙動監視をし、設定からNode.jsのスクリプトまでコード管理できるようにCDKで実装をしてみました。
CDKやAWSアカウントなどの環境構築については説明を省いてつらつらと手順を書いていきます。
CDKは筆者の好みによりTypeScriptで書いていきます。

## プロジェクトの用意

```bash
$ mkdir sample-cloudwatch-synthetics
$ cd sample-cloudwatch-synthetics
$ cdk init --language typescript
$ npm install @aws-cdk/aws-synthetics　@aws-cdk/aws-cloudwatch 
```

## 監視用スクリプトの作成

スクリプト作成の公式ドキュメントはこちら
https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_WritingCanary.html

### フォルダ階層の設定

> 複数の .js ファイルがある場合や、スクリプトに依存関係がある場合は、それらのすべてを単一の ZIP ファイル (フォルダ構造は nodejs/node_modules/myCanaryFilename.js file and other folders and files) にバンドルできます。

上述の通り監視用のCanaryスクリプトのフォルダ構造は`nodejs/node_modules/myCanaryFilename.js`とする必要があるので今回は監視対象ごとにスクリプトを管理できるように以下のようにしておきました

```bash
src
└── sample-synthetics
    └── nodejs
        └── node_modules
            └── index.js
```

### スクリプトの作成

Canaryスクリプト用のライブラリ関数の公式はこちら。

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_Function_Library.html

ドキュメント内にもあるとおりCanaryスクリプトではPuppeteerのラッパーライブラリである`Synthetics`パッケージと `Synthetics`ログ専用の`SyntheticsLogger`パッケージを使用します、がAWS上にデプロイされれば自動的に使えるので`package.json`を用意する必要はありません。（ローカル実行はどうするんやという点については未調査）

今回はGoogle検索で`Qiita`と検索して検索結果の一番上にQiitaのページが表示されるかを監視してみます。

```js:src/nodejs/node_modules/index.js
const synthetics = require('Synthetics');
const log = require('SyntheticsLogger');

const pageLoadBlueprint = async function () {
    try {
        // Googleのページにアクセス
        const page = await synthetics.getPage();
        await page.goto('https://www.google.com/', { waitUntil: 'domcontentloaded', timeout: 10000 });

        // 検索バーに'Qiita'と入力し検索
        await page.type('input[name="q"]', 'Qiita');
        await page.keyboard.press('Enter');
        await page.waitFor(5000);

        // 検索結果の１番上をクリック
        page.click('div#search a');
        await page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 60000 });

        // 'Qiita'のページにアクセスできていることを確認
        const pageTitle = await page.title();
        if (pageTitle === 'Qiita') {
            log.info('Success')
        } else {
            throw new Error('Not Qiita Page!!');
        }
    } catch (e) {
        console.log(e);
        throw e;
    }
};

exports.handler = async () => {
    return await pageLoadBlueprint();
};
```

## CDKでリソースの作成

30分に一度syntheticsが動いて30分のうちに一度でもメトリクスが確認できなかったら鳴るアラームを設定していきます。

```typescript:lib/sample-cloud-watch-synthetics-stack.ts
import * as path from 'path';
import * as cdk from '@aws-cdk/core';
import { Duration } from '@aws-cdk/core';
import { Alarm, ComparisonOperator } from '@aws-cdk/aws-cloudwatch';
import { Canary, Code, Runtime, Schedule, Test } from '@aws-cdk/aws-synthetics';
import { TreatMissingData } from '@aws-cdk/aws-cloudwatch/lib/alarm';

export class SampleCloudwatchSyntheticsStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const APP_NAME = 'SampleCloudwatchSynthetics';
    const SRC_DIR = '../src'

    const canary = new Canary(this, `${APP_NAME}-canary`, {
      canaryName: 'sample-canary',
      schedule: Schedule.rate(Duration.minutes(30)),
      runtime: Runtime.SYNTHETICS_1_0,
      test: Test.custom({
        code: Code.fromAsset(path.join(__dirname, `${SRC_DIR}/sample-synthetics`)),
        handler: 'index.handler',
      }),
    });

    new Alarm(this, `${APP_NAME}-alarm`, {
      alarmName: `${APP_NAME}-Alarm`,
      alarmDescription: 'Qiitaのページにたどり着けるか',
      metric: canary.metricSuccessPercent(),
      threshold: 1,
      comparisonOperator: ComparisonOperator.LESS_THAN_THRESHOLD,
      period: cdk.Duration.minutes(30),
      evaluationPeriods: 1,
      treatMissingData: TreatMissingData.MISSING
    });
  }
}
```

`canaryName`を`sample-canary`としていますが下のようなエラーに阻まれ仕方なくこうしました

```
Canary name is too large, must be between 1 and 21 characters, but is 33 (got "SampleCloudwatchSynthetics-Canary")

Canary name must be lowercase, numbers, hyphens, or underscores (got "SampleCanary")
```

## デプロイ

```bash
$ npx cdk deploy
```

### AWCコンソールでチェック

![canary.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227898/ee6e4370-fdb5-6fb7-3d9a-ffc07224e548.png)

リソースが作成され成功していることが確認できます。
これでGoogle検索で`Qiita`と検索した時にトップに`Qiita`のWebページがヒットすることが監視できるようになりました。
監視できるようになったので、次回はアラームがなった時にSlackに通知されるようにCDKで設定していってみたいと思います。

#### ちなみに

作成したリソースを削除するときはdestroyで瞬時に消せます。

```bash
$ npx cdk destroy
```