2021/12/2にAWSCDKv2の安定版がリリースされました
これを期に、弊社でもちょうどログのアラーム定義を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の始め方
基本的には以下の公式サイトに沿って進めていきます。
- 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が実行できるようにクレデンシャルやプロファイルの設定が済んでいる必要があります。
上記が済んだら、一度ビルドしてbootstrapまで試してみることをおすすめします。
- ビルド
npm run build
- bootstrap実行
npm run cdk bootstrap
ブートストラップとはデプロイに必要なIAMロールのプロビジョニングなどを自動で行ってくれるコマンドです。プロジェクトを作成したら一度行っておくと良いです。
アラームを定義する
TypeScriptで開発する際は、ホットリロードを有効にしておくとスムーズに開発出来ると思います。
ターミナルを別で開いて以下のコマンドを実行して置いておきます。
npm run watch
ここから本題ですが、そのままの状態だとlib
の配下のtsファイルに書き込んでいくようになってしまうので、私は以下のようにフォルダ分けして管理しやすくしました。もっと良い分け方が有りましたらコメントなどで教えていただければ幸いです
cdk
...
├── lib
│ ├── conf
│ │ └── conf.ts # 共通の定数と実行するターゲットを指定
│ ├── index.ts # メイン処理
│ ├── stacks # 各々の処理を追加していくところ
│ │ ├── coudwatch-alarm-logmetrics.ts
│ │ └── index.ts
│ └── util
│ └── load-config.ts # yamlファイルから変数を読み込む関数
...
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ファイルから変数を読む処理を書いています。
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
で定義している変数名と型を一致させておく必要があります。
#
# 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
を実行するようにして、こちらでネストされたスタックを構築するような処理を記載するようにしました。要するにメインの処理です。
#!/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しておきます。
export { CloudWatchAlarmLogMetrics, CloudWatchAlarmLogMetricsProps } from "./coudwatch-alarm-logmetrics";
具体的な定義はそれぞれファイルを分けています。
今回の例ではCWAgentで飛ばして生成されたロググループにメトリクスフィルターを設定して、ERRORという文字列をカウントするようにしています。
そのメトリクスをアラームで検出し、SNSで発報しているという流れです。
AWSCDKv2の大きな変更点としては、v1までは例えばCloudWatchとLogsを使いたい場合は、別々のパッケージを都度インストールし、importする必要がありましたが、v2からはaws-cdk-lib
に統合された形になっています。
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リファレンスを読みながら詳細を見ていったほうが良いです。
とはいえVSCodeで書いていて、補完も効くのである程度なんとなく書けます。
- メトリックフィルターの定義
- メトリックの定義
- アラームの定義
デプロイ
デプロイはすごく簡単で、以下のコマンドを実行するだけです。
npm run cdk deploy
デプロイが走り、うまく行けばCloudFormation
のスタックに流れてきます。
エラーになった際も、CloudFormation
からたどると原因を特定しやすいです。
その他にもnpm run cdk synth
でCloudFormation
のテンプレートが出力できたり、npm run cdk diff
でデプロイ済みのスタックと差分表示ができたりします。
まとめ
プレビュー版の解説記事はあるものの、そこから破壊的変更がなされている箇所も多く、結局英語のAPIリファレンスとにらめっこしながら実装せざるを得ませんでした
SAMやServerless Frameworkのような宣言的な記載ではなく、アプリを作るときのように書けるのでナチュラルにインフラ部分も構築しやすいなと思いました。
サーバーレスで組む場合もかなり使い勝手が良いなと思うので、他のリソースに関しても今後試していけたらなと思います。