CloudWatch Synthetics で 1つの Canary だけで複数のエンドポイントをモニタリングしてみた

CloudWatch Synthetics を節約して使いたい、そんなときあたなはどうしますか?
2022.01.07

CloudWatch Synthetics は Webサイトや API エンドポイントなどに対して、Webサービスの死活監視、ユーザー視点の API 応答監視などのいわゆる外形監視を行えるサービスです。

Canaryと呼ばれるリソース単位でモニタリング用のスクリプトを用意し定期実行することで外形監視を行います。Canaryの実行回数に応じて課金される料金体系です。

2022年1月7日現在、東京リージョンでは Canary 実行あたり 0.0019USD です。ということは1つの Canary で複数のエンドポイントのモニタリングを実現させるとモニタリング代を節約できるのでは?と考えたくなります。

なにも関連性のない個別のエンドポイントを1つの Canaryでチェック可能かやってみました。

Icons made by Freepik from www.flaticon.com

最新の料金はAWS公式サイトをご確認ください。

結論

  • 1つの Cannaryで複数のエンドポイントをモニタリングはステップ分けすることで実現できる。
  • 各ステップごとのSuccessPercentDurationの2種類のメトリクスは取得できる。
  • 個別のメトリクスがあるからエンドポイント個別にアラーム設定ができる。
  • Canary全体でより多くの種類のメトリクスもあるが関連性のないエンドポイントのモニタリングだと有用な値ではなくなる。
  • エンドポイントの対象の数だけスクリプトが長くなる。

複数のエンドポイントを監視したい

Canaryと呼ばれるリソース単位でモニタリング用のスクリプトを用意し、定期実行することで外形監視を行います。

1つの Cannaryで複数のエンドポイントをモニタリングするにはステップを利用します。

複数の HTTP リクエストに対して、リクエスト / レスポンスヘッダー、DNS ルックアップや TCP コネクションの時間、最初の 1 バイトを受信するまでの時間 ( Time to First Byte : TTFB ) を含む詳細なレポートを、単一のスクリプトで作成することができます。

モニタリングスクリプトをステップと呼ばれる単位に分けて書くことで、以下の様にステップ毎のモニタリング結果を取得できます。

複数ステップの実行例

複数ステップのCanaryを作成してみた

過去に作成したCloudWatch SyntheticsのCanalyをベースにステップを追加します。Canalyの作成方法は以下のリンクを参照ください。

Icons made by Freepik from www.flaticon.com

サンプルスクリプト

すべて期待したレスポンスかどうかを判定して正常と判断します。ステータスコード200 OKを確認するだけのヘルスチェックよりは多少外形監視感を出しておきます。

  • Step1 は API Gateway に POSTメソッドのリクエストを送ります。
  • Step2 は同じ API Gateway に GETメソッドのリクエストを送ります。
  • Step3 は外部のWeb APIサービスに GETメソッドのリクエストを送ります。
var synthetics = require('Synthetics');
const log = require('SyntheticsLogger');
const syntheticsConfiguration = synthetics.getConfiguration();


const apiCanaryBlueprint = async function () {

	syntheticsConfiguration.setConfig({
		restrictedHeaders: [], // Value of these headers will be redacted from logs and reports
		restrictedUrlParameters: [] // Values of these url parameters will be redacted from logs and reports
	});

	// --- Step 1 ---
	const stepName1 = "Step1 POST https://api.example.com/v1/translate";

	// Set request option
	let requestOptionsStep1 = {
		hostname: 'api.example.com',
		method: 'POST',
		path: '/v1/translate',
		port: '443',
		protocol: 'https:',
		body: '東京からのチェックです',
		headers: {}
	};
	requestOptionsStep1['headers']['User-Agent'] = [synthetics.getCanaryUserAgentString(), requestOptionsStep1['headers']['User-Agent']].join(' ');

	// Set step config option
	let stepConfig1 = {
		includeRequestHeaders: true,
		includeResponseHeaders: true,
		includeRequestBody: true,
		includeResponseBody: true,
		continueOnHttpStepFailure: true
	};

	const validateSuccessful1 = async function (res) {
		return new Promise((resolve, reject) => {
			if (res.statusCode < 200 || res.statusCode > 299) {
				throw res.statusCode + ' ' + res.statusMessage;
			}

			let responseBody = '';
			res.on('data', (d) => {
				responseBody += d;
			});

			res.on('end', () => {
				// Add validation on 'responseBody' here if required.
				const body = JSON.parse(responseBody);
				const translateText = body.output_text;
				const expectText = 'It\'s a check from Tokyo';
				console.log(translateText);

				if (translateText != expectText) {
					reject('expected ' + expectText + ' not to be ' + translateText);
				}

				resolve();
			});
		});
	};

	// --- Step 2 ---
	const stepName2 = "Step2 GET https://api.example.com/v1/test";

	// Set request option
	let requestOptionsStep2 = {
		hostname: 'api.example.com',
		method: 'GET',
		path: '/v1/test',
		port: '443',
		protocol: 'https:',
		headers: {}
	};
	requestOptionsStep2['headers']['User-Agent'] = [synthetics.getCanaryUserAgentString(), requestOptionsStep1['headers']['User-Agent']].join(' ');

	// Set step config option
	let stepConfig2 = {
		includeRequestHeaders: true,
		includeResponseHeaders: true,
		includeRequestBody: true,
		includeResponseBody: true,
		continueOnHttpStepFailure: true
	};

	const validateSuccessful2 = async function (res) {
		return new Promise((resolve, reject) => {
			if (res.statusCode != 200) {
				throw res.statusCode + ' ' + res.statusMessage;
			}

			let responseBody = '';
			res.on('data', (d) => {
				responseBody += d;
			});

			res.on('end', () => {
				// Add validation on 'responseBody' here if required.
				const body = JSON.parse(responseBody);
				const resMsg = body.message;
				const expectText = 'test';
				console.log(resMsg);

				if (resMsg != expectText) {
					reject('expected ' + expectText + ' not to be ' + resMsg);
				}


				resolve();
			});
		});
	};

	// --- Step 3 ---
	const stepName3 = "Step3 GET https://weather.tsukumijima.net/api/forecast/city/013010";

	// Set request option
	let requestOptionsStep3 = {
		hostname: 'weather.tsukumijima.net',
		method: 'GET',
		path: '/api/forecast/city/013010', // Location code is Abashiri city
		port: '443',
		protocol: 'https:',
		headers: {}
	};
	requestOptionsStep3['headers']['User-Agent'] = [synthetics.getCanaryUserAgentString(), requestOptionsStep1['headers']['User-Agent']].join(' ');

	// Set step config option
	let stepConfig3 = {
		includeRequestHeaders: true,
		includeResponseHeaders: true,
		includeRequestBody: true,
		includeResponseBody: true,
		continueOnHttpStepFailure: true
	};

	const validateSuccessful3 = async function (res) {
		return new Promise((resolve, reject) => {
			if (res.statusCode != 200) {
				throw res.statusCode + ' ' + res.statusMessage;
			}

			let responseBody = '';
			res.on('data', (d) => {
				responseBody += d;
			});

			res.on('end', () => {
				// Add validation on 'responseBody' here if required.
				const body = JSON.parse(responseBody);
				const resMsg = body.publishingOffice;
				const expectText = '網走地方気象台';
				console.log(resMsg);

				if (resMsg != expectText) {
					reject('expected ' + expectText + ' not to be ' + resMsg);
				}


				resolve();
			});
		});
	};

	// Execute
	await synthetics.executeHttpStep(stepName1, requestOptionsStep1, validateSuccessful1, stepConfig1);
	await synthetics.executeHttpStep(stepName2, requestOptionsStep2, validateSuccessful2, stepConfig2);
	await synthetics.executeHttpStep(stepName3, requestOptionsStep3, validateSuccessful3, stepConfig3);

};

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

ステップごとにスクリプト分割できると嬉しいのですけど、エンドポイントの追加でどんどん長くなっていきますね。

Step1

以下のリンクで紹介している内容と同じです。CloudWatch Synthetics から POST したチェック用の文字列(日本語)が翻訳されてレスポンスで期待した翻訳(日本語)になって受け取れることを確認します。

Step2

所定のパスに対して GET メソッドのリクエストを送るとすると、API Gateway の裏にある Lambda が以下のレスポンスを返します。 レスポンスからmessageキーがtestであるか判定します。

{"message":"test"}

Step3

天気予報 API(livedoor 天気互換)を利用させて頂きました。北海道の網走のローケーションコードを指定したGETメソッドのリクエストを送ります。 レスポンスからpublishingOfficeキーが網走地方気象台であるか判定します。

観測

30分に1回の定期実行し放置した結果です。設定したステップごとに実行結果と、実行時間(期間)を確認できます。すべてのステップは成功しています。

失敗させてみた

レスポンス内容を変更して成功判定を失敗させてみました。

Canary全体画面から失敗していることがわかります。

Canaryを選択して確認するとどこのステップが失敗したか個別に確認できます。

展開するとヘッダー、ボディを確認できます。レスポンスのmessageキーがtesttestになっており、期待したtestではないので失敗判定されてました。

メトリクス

各ステップ毎のSuccessPercentと、Durationの2項目は確認できます。

Canary単位のメトリクスも用意されています。CanaryのDurationですと各ステップ毎の合計値にあたります。今回のように関連性のない複数のエンドポイントをチェックするCanaryですと有用な値としては使えそうにないです。その他も同様に各ステップ全体での成功率も有用とは言えないでしょう。API Gatewayで提供しているサービスで内部的にお天気APIを利用していれば価値はでてくるかなと思います。

まとめ

1つの Cannaryで複数のエンドポイントをモニタリングはステップ分けすることでできました。スクリプトが長くなったり、関連性のないAPIのチェックだと取得できるメトリクスをすべて活かせなかったりしました。デメリットを踏まえてお財布と相談してください。

  • 各ステップごとのSuccessPercentDurationの2種類のメトリクスは取得できる。
  • 個別のメトリクスがあるからエンドポイント個別にアラーム設定ができる。
  • Canary全体でより多くの種類のメトリクスもあるが関連性のないエンドポイントのモニタリングだと有用な値ではなくなる。
  • エンドポイントの対象の数だけスクリプトが長くなる。

おわりに

お天気APIを叩いたら久々に網走の気象台を思い出しました。小高い台地の上にあり、たいへんの風通しの良いところにあります。

気象台最寄りに宿泊施設がありましてちょっと気になっています。ワーケーションにいかがでしょうか?

網走 ゲストハウス 民泊 - 網走 ゲストハウス 民泊