AWS CDK で CloudWatch Synthetics Canary を設定する方法

Introduction

様々な外形監視のパターンが実現できる CloudWatch Synthetics Canary は今年 4月末に GA になっていたんですが、GUI 上で一個ずつ設定するのに結構時間かかるし、Lambda のコードもテキストファイルに書くほどの壁など色々扱いにくかったので、実際に本番環境で運用しているプロダクションは結構少ないと思われます。そして、Terraform もまだサポートしていない (*1) ことを見つけてちょっとコード化は諦めていたんですが、なんと 5日前に CDK サポートリリースが出てきたので早速試した経験を共有します。

*1) https://github.com/terraform-providers/terraform-provider-aws/pull/13140

プレスリリース

https://aws.amazon.com/jp/about-aws/whats-new/2020/04/amazon-cloudwatch-synthetics-generally-available

必須条件

  • AWS CDK
    •  v1.59.0 or later

関連プルリクエスト

https://github.com/aws/aws-cdk/pull/8824

Goal

  • Github ページを 10分間隔で外形監視
    • 連続で 2回失敗したらアラート
  • Github API を 3分間隔で外形監視
    • 1回失敗したらアラート

登場人物

  • CloudWatch
    • Synthetics Canary
    • Alarm

Getting Started

  • バージョン
    • Node.js: v12.12.0
    • TypeScript: v3.7.5
    • AWS CDK: v1.59.0

管理画面向けの外形監視コードを実装

lambda/canary/nodejs/node_modules/screen-canary.js

var synthetics = require('Synthetics');
const log = require('SyntheticsLogger');

const screenCanary = async function () {

    const URL = "https://github.com/sano307";

    try {
        let page = await synthetics.getPage();

        const response = await page.goto(URL, {waitUntil: 'networkidle2', timeout: 0});
        if (!response || response.status() !== 200) {
            throw "Failed to load page!";
        }

        await page.waitFor(5000);
        await synthetics.takeScreenshot('loaded', 'loaded');

        let pageTitle = await page.title();
        log.info('Page title: ' + pageTitle);
    } catch (e) {
        console.log(e);
        throw e;
    }
};

exports.handler = async () => {
    return await screenCanary();
};
  • まず、Puppeteer をラッパーした Synthetics パッケージと Synthetics ログ専用の SyntheticsLogger パッケージが必要です。こちらのパッケージは package.json に追加する必要なく AWS 上にデプロイされると勝手に参照できるようになるので気にしなくても大丈夫です。
  • 続いて Github ページの情報を取得する必要があるんですが、そこに page.goto() が使えます。ページの取得判定基準として様々な HTML Window event が選べるんですが、今回はちょっとゆるくするために networkidle2 を設定しました。そして、timeout を 0 に設定しているんですが、timeout を設定して置くと、たまに Navigation Timeout が出てくる障害が起きたので、timeout なしにしました。
  • page.waitFor() でページの情報が完全に取得できるまで待っていたり、synthetics.takeScreenshot() でページの画面を撮ったりなど Puppeteer の API で可能な挙動は問題なく追加できるので、外形監視の自由度がかなり高くなった気がします。
  • Canary の Lambda ファイルの経路にはちょっとこだわりがありまして、必ず nodejs/node_modules 下に Lambda ファイルを配置しなければならないです。おそらく該当する経路にある Lambda ファイルは Canary 向けの Lambda だと判定し Synthetics 関連のパッケージが勝手に紐付けられる仕様になっているんじゃないかと思っています。
  • また、Synthetics パッケージが手元にないため TypeScript から JavaScript への変換ができないので、CDK プロジェクトが TypeScript である方は Canary Lambda をコミット対象にする必要があります。

.gitignore

# AWS Synthetics Canary directory
!/lambda/canary/nodejs/node_modules/
!/lambda/canary/nodejs/node_modules/*

管理画面向けの外形監視を設定

lib/synthetics-canary-stack.ts

...
import { Canary, Code, Schedule, Test } from '@aws-cdk/aws-synthetics';
import * as path from 'path';

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

    const screenCanary = new Canary(this, 'screen-canary', {
      canaryName: 'screen-canary',
      schedule: Schedule.rate(cdk.Duration.minutes(10)),
      test: Test.custom({
        code: Code.fromAsset(path.join(__dirname, '../lambda/canary')),
        handler: 'screen-canary.handler'
      })
    })
  }
}
  • 適当にリソース名を設定し、外形監視の間隔を 10分に設定しましょう。
  • あと、Canary Lambda ファイルと紐づく必要があるんですが、nodejs/node_modules の上位経路まで設定する必要があります。今回は ../lambda/canary/nodejs/node_modules なので ../lambda/canary まで設定して置けば大丈夫です。

管理画面向けの外形監視にアラームを設定

lib/synthetics-canary-stack.ts

...
import { Alarm, ComparisonOperator } from '@aws-cdk/aws-cloudwatch';

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

    ...

    new Alarm(this, 'screen-canary-alarm', {
      alarmName: 'screen-canary-alarm',
      metric: screenCanary.metricSuccessPercent(),
      statistic: "SampleCount",
      threshold: 1,
      comparisonOperator: ComparisonOperator.LESS_THAN_THRESHOLD,
      period: cdk.Duration.minutes(10),
      evaluationPeriods: 2
    })
  }
}
  • 続いてアラートなんですが、条件通り 2回失敗したら引っかかるように設定しましょう。
  • ちなみに evaluationPeriodsperiod で設定した間隔を何回評価 window として扱うかを指定するオプションです。今回だと period が 10分で evaluationPeriods が 2なので 20分が評価 window になります。

API 向けの外形監視コードを実装

lambda/canary/nodejs/node_modules/api-canary.js

var synthetics = require('Synthetics');
const log = require('SyntheticsLogger');
const https = require('https');

const apiCanary = async function () {

    const verifyRequest = async function (requestOption) {
      return new Promise((resolve, reject) => {
        log.info("Making request with options: " + JSON.stringify(requestOption));
        let req = https.request(requestOption);

        req.on('response', (res) => {
          log.info(`Status Code: ${res.statusCode}`)
          log.info(`Response Headers: ${JSON.stringify(res.headers)}`)
          
          const responseCode = res.statusCode;
          if (responseCode !== 200) {
             reject("Failed: " + requestOption.path);
          }
          
          res.on('data', (d) => {
            log.info("Response: " + d);
          });
          res.on('end', () => {
            resolve();
          })
        });

        req.on('error', (error) => {
          reject(error);
        });

        req.end();
      });
    }

    const headers = {
        "Authorization": "token GITHUB_TOKEN"
    }
    headers['User-Agent'] = [synthetics.getCanaryUserAgentString(), headers['User-Agent']].join(' ');
    
    const requestOptions = {
        "hostname": "api.github.com",
        "method": "GET",
        "path": "/user",
        "port": 443
    }
    requestOptions['headers'] = headers;

    await verifyRequest(requestOptions);
};

exports.handler = async () => {
    return await apiCanary();
};
  • Github API を叩く必要があるので、Personal access tokens を発行する必要があります。トークンの発行ページ (*2) に入って scope は repo だけチェックして置きましょう。

*2) Github profile → settings → Developer Settings → Personal access tokens → Generate new token

  • 発行したトークンを GITHUB_TOKEN に設定してユーザー情報を返してくれる api.github.com/user を試してみましょう。
curl -s -H "Authorization: token GITHUB_TOKEN" https://api.github.com | grep current_user_url
  "current_user_url": "https://api.github.com/user",

API 向けの外形監視を設定

lib/synthetics-canary-stack.ts

...

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

    ...
    
    const apiCanary = new Canary(this, 'api-canary', {
      canaryName: 'api-canary',
      schedule: Schedule.rate(cdk.Duration.minutes(3)),
      test: Test.custom({
        code: Code.fromAsset(path.join(__dirname, '../lambda/canary')),
        handler: 'api-canary.handler'
      })
    })
  }
}
  • こちらも適当にリソース名を設定し、外形監視の間隔を 3分に設定しましょう。

API 向けの外形監視にアラームを設定

lib/synthetics-canary-stack.ts

...

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

    ...
    
    new Alarm(this, 'api-canary-alarm', {
      alarmName: 'api-canary-alarm',
      metric: apiCanary.metricSuccessPercent(),
      evaluationPeriods: 1,
      threshold: 1,
      statistic: "SampleCount",
      comparisonOperator: ComparisonOperator.LESS_THAN_THRESHOLD,
      period: cdk.Duration.minutes(3),
    })
  }
}
  • API 側のアラートは 1回失敗したら引っかかる条件だったので、評価 window の拡張はしません。なので、evaluationPeriods は 1にしています。

Stack 一覧

bin/cdk.ts

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';
import { SyntheticsCanaryStack } from '../lib/synthetics-canary-stack';

const app = new cdk.App();

new SyntheticsCanaryStack(app, 'synthetics-canary-stack', {
  env: {
    account: 'xxxxxxxxxxxx',
    region: 'ap-northeast-1'
  }
})

Deploy & Confirm

cdk deploy --require-approval never

Github ページの外形監視

期待通り動いていることが分かります。

synthetics.takeScreenshot() で撮ったスクリーンショットもちゃんと表示されていますね。加えて JS や CSS などの取得情報は HAR ファイル から確認できるし、ログも成功・失敗に関係なく自動的に S3 へアップロードされるので、トラブルシューティングが結構楽な気がします。

Github API の外形監視

Bonus

まだ CDK には反映されていない機能なんですが、Canary を特定な VPC 内部で配置するのも可能です。外形監視したいリソースが Inbound IP を制限している場合、この機能を使って同じ Security Group を Canary に設定することで外形監視ができるようになります。

ですが、今回進めている案件の環境で VPC を設定した Canary を試した時に下のエラーが出て来ました。不規則的に Lambda がタイムアウトされることが分かったんですが、原因が特定できず丸 2日が溶けてしまいました。

Kinds Error
画面系 (Puppeteer 採用) net::ERR_CONNECTION_TIMED_OUT
net::ERR_TIMED_OUT
API系 (HTTPS パッケージ採用) Error: connect ETIMEDOUT

色々調べた結果、Canary には private subnet のみ設定しないといけないルール (*3) がありました。僕の設定だと public subnetprivate subnet が 1つずつ紐づいていたので、おそらく Lambda が public subnet 上で実行されたらインターネットゲートウェイ経由で失敗が発生し、失敗の数がしきい値を超えるとかある時間が経ったら private subnet に切り替えて NAT ゲートウェイ経由で成功になり、不規則的に成功と失敗が混ぜて起きていたかなと推測しています。

*3) https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_VPC.htmlNo test result returned" Error 参考

Summary

監視の設定がコードでかけるのはレビューのやりやすさとか監視追加の速さなど色々メリットがあると思っています。ぜひみなさん試してみてください!

この記事が誰かのお役に立てれば幸いです。

以上、CX事業本部 MADチーム、キム (@sano3071) でした。

Reference