【TypeScript】AWS CDKv2でCloudWatchのログアラームを設定する

2021/12/2にAWSCDKv2の安定版がリリースされました :tada: 

https://aws.amazon.com/jp/about-aws/whats-new/2021/12/aws-cloud-development-kit-cdk-generally-available/

これを期に、弊社でもちょうどログのアラーム定義をIaCで管理しようとしていたタイミングだったので、CDKv2で管理していくことにしました。
日本語の記事がまだ少なかったので、備忘も含めて記事にします。

# tl;dr
- AWS CDKv2がstableになったので、CloudWatchの設定をやってみる(TypeScript)
- yamlファイルで定義した変数をCDKに組み込んで例えばAnsibleのリポジトリと共用出来ると便利
- デプロイもコマンド一発で、差分表示なんかもできて便利

# 環境
- OS
    - macOS BigSur (Intel Core i9)
- node: 16.10.0
- npm: 7.24.0
- npx: 7.24.0

# CDKの始め方
基本的には以下の公式サイトに沿って進めていきます。

https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/hello_world.html

- aws-cdkをグローバルにインストールします

```
npm install -g aws-cdk
```

- その後、空のディレクトリ上で以下のコマンドを実行します。

```
cdk init app --language typescript
```

初期ディレクトリが構築されます。
以下のようなディレクトリ構成で作成されます。

```
cdk
├── README.md
├── bin
│   └── cdk.ts # cdk実行のエントリーポイント
├── cdk.json
├── cdk.out # CloudFormationテンプレートが出力されるディレクトリ
├── jest.config.js
├── lib # このフォルダの下に定義を設定していく
│   ...
├── node_modules
├── package-lock.json
├── package.json
├── test
│   └── cdk.test.ts
└── tsconfig.json
```

加えて今回はyamlファイルを扱いたいので、以下のコマンドでインストールしておきます。

```
npm install --save js-yaml
npm install --save-dev @types/js-yaml
```

またAWSCLIが実行できるようにクレデンシャルやプロファイルの設定が済んでいる必要があります。

- [macOS での AWS CLI バージョン 2 のインストール、更新、アンインストール - AWS Command Line Interface](https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/install-cliv2-mac.html)

上記が済んだら、一度ビルドしてbootstrapまで試してみることをおすすめします。

- ビルド

```
npm run build
```

- bootstrap実行

```
npm run cdk bootstrap
```

:::note info
ブートストラップとはデプロイに必要なIAMロールのプロビジョニングなどを自動で行ってくれるコマンドです。プロジェクトを作成したら一度行っておくと良いです。
:::

[ブートストラップ-AWSクラウド開発キット（CDK）v2](https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/bootstrapping.html)


# アラームを定義する
TypeScriptで開発する際は、ホットリロードを有効にしておくとスムーズに開発出来ると思います。
ターミナルを別で開いて以下のコマンドを実行して置いておきます。

```
npm run watch
```

ここから本題ですが、そのままの状態だと`lib`の配下のtsファイルに書き込んでいくようになってしまうので、私は以下のようにフォルダ分けして管理しやすくしました。もっと良い分け方が有りましたらコメントなどで教えていただければ幸いです :bow: 

```
cdk
...
├── lib
│   ├── conf
│   │   └── conf.ts # 共通の定数と実行するターゲットを指定
│   ├── index.ts # メイン処理
│   ├── stacks # 各々の処理を追加していくところ
│   │   ├── coudwatch-alarm-logmetrics.ts
│   │   └── index.ts
│   └── util
│       └── load-config.ts # yamlファイルから変数を読み込む関数
...
```

### conf.ts

グローバルに定義しておきたい定数をこちらに示しておくようにします。

```typescript:lib/conf/conf.ts
export const constVariable = {
    topicArn: '<設定したいARN>',
    account: '<アカウントID>',
    region: '<リージョン>'
}
export const targets = [
    <適応対象のサーバーのホスト名を並べておく>
];
```

accountとregionは後述する`Stack`を継承したクラスの`props`として渡すので、こちらで外だししておきます。
弊社ではSNSトピックは共通のものを使って単一のアクションを通知先へアラームを発報させていますので、`topicArn`としてここで定義しておきます

### load-config.ts
今回Ansibleで利用しているログの定義やEC2インスタンスの`instance_id`などをyamlファイルと共通化して管理したかったので、こちらでyamlファイルから変数を読む処理を書いています。

```typescript:lib/util/load-config.ts
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';

// yamlに書いてある変数のうち、実際に使いたいものをtypeで定義しておく
type YamlProps = {
    local_hostname: string,
    cwagent_env_prefix: string,
    cwagent_logs: string[],
    cwagent_alarms: string[],
    instance_id: string
};

/**
 * 指定したパスにあるyamlファイルから変数を取り出すメソッド
 * 
 * @param filename - 相対パスでの指定が可能
 * @returns YamlProps
 */
export const loadConfig = (filename: string) => {
    const yamlPath = path.resolve(filename);
    const yamlProps = yaml.load(fs.readFileSync(yamlPath, 'utf-8')) as YamlProps;
    return yamlProps;
}
```

で、yamlファイルは以下のように定義しておきます。
`load-config.ts`の`YamlProps`で定義している変数名と型を一致させておく必要があります。

```yaml:
#
# cdk variables
#
cwagent_env_prefix: Development
cwagent_logs:
  - /var/log/app/access.log
  - /var/log/app/error.log
cwagent_alarms:
  - /var/log/app/error.log
instance_id: YOUR_INSTANCE_ID
```

### index.ts
実際のCDKでの定義を後から追加しやすいように、`bin/cdk.ts`から更に`lib/index.ts`を実行するようにして、こちらでネストされたスタックを構築するような処理を記載するようにしました。要するにメインの処理です。

```typescript:lib/index.ts
#!/usr/bin/env node
import 'source-map-support/register';
import { CloudWatchAlarmCWAgentProcstat, CloudWatchAlarmCWAgentProcstatProps } from '../lib/stacks';
import { CloudWatchAlarmLogMetrics, CloudWatchAlarmLogMetricsProps } from '../lib/stacks';
import { constVariable, targets } from '../lib/conf/conf';
import { loadConfig } from '../lib/util/load-config';
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';

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

        // logのERROR検知用アラーム定義
        // config.tsで定義したtargetsでループを回す
        targets.forEach(target => {
            const hostname = target;
            // load-config.tsのloadConfigでansibleフォルダのhost_varsフォルダからyamlファイルを読み込んで、`conf`に格納。
            const conf = loadConfig(`../host_vars/${hostname}`);

            conf.cwagent_alarms.forEach(cwagent_alarm => {
                const logMetricsProps: CloudWatchAlarmLogMetricsProps = {
                    hostname: hostname,
                    local_hostname: conf.local_hostname,
                    cwagent_env_prefix: conf.cwagent_env_prefix,
                    log_group_name: cwagent_alarm,
                    topicArn: constVariable.topicArn,
                }
                new CloudWatchAlarmLogMetrics(this, `CloudWatchAlarmLogMetrics-${logMetricsProps.hostname}-${cwagent_alarm}`, logMetricsProps);
            })
        });
    }
}
```

この親スタックから`new CloudWatchAlarmLogMetrics()`でネステッドスタックを必要な分だけインスタンス化していきます。
親となるスタックは`Stack`を継承させ、その下にネストさせるスタックは`NestedStack`を継承して作成します。
どちらもインスタンス化するときの第二引数がスタック名になるので、わかりやすい名前をつけておきます。
ループさせるなら複数のスタックが作成されても名前が一意になるように変数を仕込んでおくと良いと思います。


### stacksの中
実際にCDKで作成したいリソースを定義していく部分で、こちらは今後目的ごとにファイルを増やしていこうと思っていますので、index.tsでまとめてexportしておきます。

```typescript:lib/staks/index.ts
export { CloudWatchAlarmLogMetrics, CloudWatchAlarmLogMetricsProps } from "./coudwatch-alarm-logmetrics";
```

具体的な定義はそれぞれファイルを分けています。
今回の例では**CWAgent**で飛ばして生成されたロググループにメトリクスフィルターを設定して、**ERROR**という文字列をカウントするようにしています。
そのメトリクスをアラームで検出し、SNSで発報しているという流れです。
AWSCDKv2の大きな変更点としては、v1までは例えばCloudWatchとLogsを使いたい場合は、別々のパッケージを都度インストールし、importする必要がありましたが、v2からは`aws-cdk-lib`に統合された形になっています。

```typescript:lib/stacks/coudwatch-alarm-logmetrics.ts
import { NestedStack, NestedStackProps, aws_cloudwatch, Duration, aws_cloudwatch_actions, aws_sns, aws_logs } from 'aws-cdk-lib';
import { Statistic, TreatMissingData } from 'aws-cdk-lib/aws-cloudwatch';
import { MetricFilterOptions } from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';

export type CloudWatchAlarmLogMetricsProps = {
  hostname: string,
  local_hostname: string,
  cwagent_env_prefix: string,
  log_group_name: string,
  topicArn: string,
}

/**
 * 指定されたロググループにERRORの文字列を検知するメトリックフィルターを追加
 * それをCloudWatch Alarmに登録し、検知したら指定したSNS Topicに基づいてアクションを行う
 * 
 * @param scope  Construct
 * @param id スタックの名前になる
 * @param customProps CloudWatchAlarmLogMetricsProps
 * @param props - NestedStackProps?
 * 
 */
export class CloudWatchAlarmLogMetrics extends NestedStack {
  constructor(scope: Construct, id: string, customProps: CloudWatchAlarmLogMetricsProps, props?: NestedStackProps) {
    super(scope, id, props);

    const metricNameSpace = 'LogMetrics';
    const logGroupName = `${customProps.local_hostname}${customProps.log_group_name}`;
    const metricName = `[ERROR]${logGroupName}`;
    const alarmName = `[${customProps.cwagent_env_prefix}] CloudWatchLog:${customProps.hostname}:${customProps.log_group_name}:error_detect (>=1count)`;

    // メトリックフィルターの定義
    // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_logs.MetricFilter.html
    const metricFilterProps: MetricFilterOptions = {
      metricNamespace: metricNameSpace,
      metricName: metricName,
      filterPattern: aws_logs.FilterPattern.literal('ERROR'),
      metricValue: '1'
    }

    // LogGroupの取得
    const logGroup = aws_logs.LogGroup.fromLogGroupName(this, 'LogGroup', logGroupName);

    // LogGroupにメトリクスフィルターを追加
    logGroup.addMetricFilter('MetricFilter', metricFilterProps);

    // メトリックの定義
    // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudwatch.Metric.html
    const metric = new aws_cloudwatch.Metric({
      namespace: metricNameSpace,
      metricName: metricName,
      period: Duration.seconds(60),
      statistic: Statistic.SUM,
    });

    // アラームの定義
    // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudwatch.Alarm.html
    const alarm = new aws_cloudwatch.Alarm(this, 'Alarm', {
      alarmName: alarmName,
      alarmDescription: alarmName,
      comparisonOperator: aws_cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
      metric: metric,
      threshold: 1,
      evaluationPeriods: 1,
      datapointsToAlarm: 1,
      actionsEnabled: true,
      treatMissingData: TreatMissingData.NOT_BREACHING
    });

    // SNS Topicの取得
    const topic = aws_sns.Topic.fromTopicArn(this, 'Topic', customProps.topicArn);

    // AlarmにSNS Topicを追加
    alarm.addAlarmAction(new aws_cloudwatch_actions.SnsAction(topic));
  }
}

```

CDKv2がstableになってから少し書き方が変わっていて日本語の記事がないので、基本的には[公式のAPIリファレンス](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib-readme.html)を読みながら詳細を見ていったほうが良いです。
とはいえVSCodeで書いていて、補完も効くのである程度なんとなく書けます。

- メトリックフィルターの定義
    - https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_logs.MetricFilter.html
- メトリックの定義
    - https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudwatch.Metric.html
- アラームの定義
    - https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudwatch.Alarm.html

# デプロイ
デプロイはすごく簡単で、以下のコマンドを実行するだけです。

```
npm run cdk deploy
```

デプロイが走り、うまく行けば`CloudFormation`のスタックに流れてきます。
![cloudformation_stack.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/f44f6ba2-649d-746f-7662-90a6bf0296a6.png)



エラーになった際も、`CloudFormation`からたどると原因を特定しやすいです。

その他にも`npm run cdk synth`で`CloudFormation`のテンプレートが出力できたり、`npm run cdk diff`でデプロイ済みのスタックと差分表示ができたりします。

# まとめ
プレビュー版の解説記事はあるものの、そこから破壊的変更がなされている箇所も多く、結局英語のAPIリファレンスとにらめっこしながら実装せざるを得ませんでした:sweat_smile:
SAMやServerless Frameworkのような宣言的な記載ではなく、アプリを作るときのように書けるのでナチュラルにインフラ部分も構築しやすいなと思いました。
サーバーレスで組む場合もかなり使い勝手が良いなと思うので、他のリソースに関しても今後試していけたらなと思います。