👁️

AWS CDK CloudFront監視入門 TypeScript #4

に公開

導入

これまでAWSCDKを使用してCloudFrontによるコンテンツ配信を構築してきました。

AWS CDK S3静的ウェブサイトホスティング構築 TypeScript #1

AWS CDK S3+CloudFrontウェブサイト構築 Typescript #2

AWS CDK S3+CloudFront+Route53+ACM ウェブサイト構築 TypeScript #3

コンテンツ配信の構築は一旦区切りとしたので今回からモニタリング構築やらセキュリティ構築やらの運用方面に手を伸ばしていきます。

今回は監視編。CloudFrontのアクセスを取得したりアラートを飛ばします。

CloudFrontで設定できそうなモニタリング項目

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/logging-and-monitoring.html

CloudFrontのモニタリングとして設定できそうなサービスは主に以下(今回@Edgeには触れていません)

・CloudWatchアラーム

CloudFrontはデフォルトでリクエスト数やエラー数などのメトリクスをCloudWatchに出力しています。

メトリクスに対して閾値を設定することでSNSによるアラート通知が可能になります。

Cloud Trailログ

こちらはAWSリソースに対しての操作ログの記録です。

オリジンであるS3バケットなどのリソースに対する操作をログとして残せる他

特定操作(削除など)をCloudWatch Logsと連携することでSNS通知することも可能です。

・CloudFrontアクセスログ

CloudFrontのアクセスログをS3バケットに格納することで記録・分析が可能になります。

これら標準ログの他、Kinesisとも連携することでリアルタイムアクセスログも作成可能ですが、料金が高くなってしまうのでリアルタイムログは今回割愛します。

というわけで今回はCloudWatchアラーム、CloudTrailログ、CloudFrontアクセスログを取得できるように構築していきます。

構築

CloudWatchアラーム

モニタリング時に都度CloudFrontの画面に行くのは手間なので最初にCloudWatch側にCloudFrontのメトリクスを一覧できるダッシュボードを作成します。

今回、モニタリングリソースはCloudFrontと別スタックで作成しようと思うのでファイルを新たに作成してコードを記述していきます。

そして、CloudFrontのメトリクスを取得するにはCloudFrontディストリビューションと同じus-east-1にCloudWatchダッシュボードを作成する必要があります。

そのためus-east-1のモニタリングリソースとap-northeast-1のモニタリングリソース用にそれぞれファイルを作成していきます。

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudwatch.Dashboard.html

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudwatch.GraphWidgetProps.html

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudwatch.MetricProps.html

lib\monitoring-us.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
    aws_cloudwatch as cloudwatch,
    aws_cloudwatch_actions as actions,
    aws_sns as sns,
    aws_sns_subscriptions as subscriptions,
} from 'aws-cdk-lib';

interface MonitoringUSStackProps extends cdk.StackProps {
    distributionId: string;
    email: string;
}

export class MonitoringUSStack extends cdk.Stack {
    constructor(scope: Construct, id:string, props: MonitoringUSStackProps) {
        super(scope, id, props);

        // CloudWatchダッシュボードの作成
        const dashboard = new cloudwatch.Dashboard(this, 'CloudWatchDashboard', {
            dashboardName: 'CloudFront-Metrics',
        });
        // ダッシュボードに表示したいウィジェットを追加
        dashboard.addWidgets(
            // 1.コンテンツリクエスト数
            new cloudwatch.GraphWidget({
                title: 'CloudFront Requests',
                left: [
                    // コンテンツリクエスト数メトリクスを指定
                    new cloudwatch.Metric({
                        // メトリクスを取得するサービスの選択
                        namespace: 'AWS/CloudFront',
                        // 取得するリージョンの指定(今回CloudFrontなのでus-east-1)
                        region: 'us-east-1',
                        // 取得するリソースとリージョンの指定
                        dimensionsMap: { DistributionId: props.distributionId, Region: 'Global'},
                        // 使用するメトリクスの選択
                        metricName: 'Requests',
                        // 使用する統計の選択
                        statistic: cloudwatch.Stats.SUM,
                        // 取得間隔
                        period: cdk.Duration.minutes(5),
                    })
                ]
            }),
            new cloudwatch.GraphWidget({
                title: '4xx and 5xx Errors',
                left: [
                    new cloudwatch.Metric({
                        namespace: 'AWS/CloudFront',
                        region: 'us-east-1',
                        dimensionsMap: { DistributionId: props.distributionId, Region: 'Global'},
                        metricName: '4xxErrorRate',
                        statistic: cloudwatch.Stats.AVERAGE,
                        period: cdk.Duration.minutes(5),
                    }),
                    new cloudwatch.Metric({
                        namespace: 'AWS/CloudFront',
                        region: 'us-east-1',
                        dimensionsMap: { DistributionId: props.distributionId, Region: 'Global'},
                        metricName: '5xxErrorRate',
                        statistic: cloudwatch.Stats.AVERAGE,
                        period: cdk.Duration.minutes(5),
                    }),
                ],
            }),
        );

CloudWatch上にCloudFront-Metricsという名前のダッシュボードが作成され、リクエスト数とエラー数が一覧できるようになります。

次にエラー数が一定以上に達した時にメール通知を飛ばせるようにしていきます。

Amazon SNSでメールアドレスを登録し

CloudWatchで指定した閾値に達した時に通知を行うアラートを設定していきます。

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_sns.Topic.html

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudwatch.Alarm.html

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudwatch_actions.SnsAction.html

lib\monitoring-us.ts
        // CloudWatchアラームの作成
        const alarm = new cloudwatch.Alarm(this, '4xxErrorAlarm', {
            alarmName: 'CloudFront-4xxErrors-Alarm',
            // アラームが監視するメトリクス
            metric: new cloudwatch.Metric({
                namespace: 'AWS/CloudFront',
                region: 'us-east-1',
                dimensionsMap: { DistributionId: props.distributionId, Region: 'Global'},
                metricName: '4xxErrorRate',
                statistic: 'Average',
                period: cdk.Duration.minutes(5),
            }),
            // 閾値
            threshold: 100,
            // 評価期間
            evaluationPeriods: 1,
        });
        // アラーム発生時のアクションを指定
        alarm.addAlarmAction(new actions.SnsAction(usAlarmSNS));
    }
}

デプロイするとSNSのサブスクリプション先に承認メッセージが届きますので承認しましょう。

(有効期限は48時間です)

CloudTrail

CloudTrailではアカウント発行時点で過去90日間の操作ログ(管理イベント)が記録されており

誰がどのリソースに対してどんな操作を行ったかを確認することができます。

一方で90日より前の記録を確認することができなかったり、

リソース内のアクティビティ(S3バケットへの読み書き)等といったデータイベントは記録しません。

https://repost.aws/ja/knowledge-center/cloudtrail-data-management-events

そのため、これら履歴を確認するにはCloudTrailログをS3バケットもしくはCloudWatch Logsに対して出力し

記録するデータイベントを指定する必要があります。

まずは、ログ格納用のS3バケットを格納しそこにCloudTrailログを出力するようにします。

ログの保持期間はCloudTrailより長い180日に指定。

そして、データイベントとしてCloudFrontのオリジンS3バケットへの読み書きを記録します。

後々、データイベントを検知して通知するためにS3だけでなくCloudWatch LogsのロググループにもCloudTrailログを出力させておきます。

今回オリジンバケットのログを取得するのでs3_cloudfront-stack.tsからPropsでオリジンバケットを受け取ります。

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_logs.LogGroup.html

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudtrail.Trail.html

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudtrail.Trail.html#addwbrs3wbreventwbrselectors3selector-options

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_logs.MetricFilter.html

lib\monitoring.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
    aws_cloudwatch as cloudwatch,
    aws_cloudwatch_actions as actions,
    aws_logs as logs,
    aws_sns as sns,
    aws_sns_subscriptions as subscriptions,
    aws_s3 as s3,
    aws_cloudtrail as cloudtrail,
} from 'aws-cdk-lib';

interface MonitoringStackProps extends cdk.StackProps {
    email: string;
    originBucket: s3.Bucket;
}

export class MonitoringStack extends cdk.Stack {

    constructor(scope: Construct, id: string, props:MonitoringStackProps) {
        super(scope, id, props);


        // 監視ログ格納用のS3バケット
        const cloudtrailLogBucket = new s3.Bucket(this, 'LogBucket', {
            bucketName: `s3-cloudtrail-logbucket-${cdk.Aws.ACCOUNT_ID}-${cdk.Aws.REGION}`,
            removalPolicy: cdk.RemovalPolicy.DESTROY,
            autoDeleteObjects: true,
        });
        // CloudTrailログの保持期間をライフサイクルルールで指定
        cloudtrailLogBucket.addLifecycleRule({
            prefix: `AWSLogs/${cdk.Aws.ACCOUNT_ID}/CloudTrail`,
            expiration: cdk.Duration.days(180),
        });

        // CloudWatchLogsロググループ作成
        const logGroup = new logs.LogGroup(this, 'LogGroup', {
            logGroupName: `CloudWatchLogs-LogGroup-${cdk.Aws.ACCOUNT_ID}`,
            removalPolicy: cdk.RemovalPolicy.DESTROY,
        });

        // SNSトピックの作成
        const topic = new sns.Topic(this, 'AlarmTopic', {
            topicName: `sns-topic-${cdk.Aws.ACCOUNT_ID}`,
        });
        // トピックにサブスクリプションを追加
        topic.addSubscription(new subscriptions.EmailSubscription(props.email));

        // CloudTrail証跡
        const trail = new cloudtrail.Trail(this, 'Trail', {
            trailName: `trail-${cdk.Aws.ACCOUNT_ID}`,
            // 出力先S3バケット指定
            bucket: cloudtrailLogBucket,
            // CloudWatch Logsへのログ出力を有効化
            sendToCloudWatchLogs: true,
            // 出力先ロググループ指定
            cloudWatchLogGroup: logGroup,
            // ロググループ側のログ保持期間指定
            cloudWatchLogsRetention: logs.RetentionDays.ONE_WEEK,
            // グローバルな管理イベントを単一S3バケットに格納させる
            isMultiRegionTrail: true,
        });
        // CloudFrontオリジンバケットのデータイベントを取得
        trail.addS3EventSelector(
            [{ bucket: props.originBucket }],
            {
                readWriteType: cloudtrail.ReadWriteType.WRITE_ONLY,
            },
        );

        // オリジンバケットのデータイベントを監視するメトリクス作成
        new logs.MetricFilter(this, 'S3PutAndDelete', {
            // 参照するロググループの指定
            logGroup: logGroup,
            // メトリクス名前空間
            metricNamespace: 'S3DataEvent',
            // メトリクス名
            metricName: 'PutDeleteCount',
            // メトリクスのフィルター
            filterPattern: logs.FilterPattern.literal('{$.eventName="PutObject" || $.eventName="DeleteObject"}'),
            // 対象イベント発生時にメトリクスに計上する数
            metricValue: '1',
        });

        // メトリクスを基にアラーム作成
        new cloudwatch.Alarm(this, 'S3PutDeleteAlarm', {
            metric: new cloudwatch.Metric({
                // logsで作成した名前空間を指定
                namespace: 'S3DataEvent',
                metricName: 'PutDeleteCount',
                // 
                statistic: cloudwatch.Stats.SUM,
                period: cdk.Duration.minutes(5),
            }),
            threshold: 1,
            evaluationPeriods: 1,
        }).addAlarmAction(new actions.SnsAction(topic));
    }
}

CloudFrontログ

最後にCloudFrontログを有効化してS3バケットに保存するように設定します。

本来はこのログを保存するS3バケットもmonitoring.tsに記述しモニタリングリソース用スタック内で管理するべきでしたが、

スタックの依存関係上難しかったため本シリーズではS3CloudFrontStack内で管理します。

lib\s3_cloudfront-stack.ts
    // cloudfrontログ出力用バケットの作成
    const cloudfrontLogBucket = new s3.Bucket(this, 'CloudFrontLogBucket', {
      bucketName: `s3-cloudfront-logbucket-${cdk.Aws.ACCOUNT_ID}-${cdk.Aws.REGION}`,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });
    cloudfrontLogBucket.addLifecycleRule({
      prefix: `AWSLogs/${cdk.Aws.ACCOUNT_ID}/CloudFront`,
      expiration: cdk.Duration.days(30),
    });

    const distribution = new cloudfront.Distribution(this, 'Distribution', {
      defaultBehavior: {
        origin: cloudfront_origins.S3BucketOrigin.withOriginAccessControl(
          s3Bucket,{
            originAccessLevels: [cloudfront.AccessLevel.READ],
          },
        ),          
      },
      defaultRootObject: 'index.html',
      errorResponses: [
        {
          httpStatus: 404,
          responsePagePath: '/error.html',
          ttl: cdk.Duration.seconds(0),
        },
        {
          httpStatus: 403,
          responsePagePath: '/error.html',
          ttl: cdk.Duration.seconds(0),
        },
      ],
      domainNames: [props.domainName],
      certificate: certificate,
      // ログ出力有効化
      enableLogging: true,
      // ログ格納先
      logBucket: cloudfrontLogBucket,
    });
    this.distributhinId = distribution.distributionId;

以上で各リソースの記述が完了しました。

app側でStack間のリソース情報受渡しを記述してデプロイしていきます。

bin\s3_cloudfront.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { S3CloudfrontStack } from '../lib/s3_cloudfront-stack';
import { CertificateStack } from '../lib/certificate-stack';
import { Route53Stack } from '../lib/route53-stack';
import { MonitoringUSStack } from '../lib/monitoring-us';
import { MonitoringStack } from '../lib/monitoring';

const app = new cdk.App();
const domainName = app.node.tryGetContext('domainName');
const email = app.node.tryGetContext('email');
const account = process.env.CDK_DEFAULT_ACCOUNT;

const route53Stack = new Route53Stack(app, 'Route53Stack', {
    env: {
        region: 'ap-northeast-1',
        account: account
    },
    domainName: domainName,
    crossRegionReferences: true,
})

const certificateStack = new CertificateStack(app, 'CertificateStack', {
    env: { 
        region: 'us-east-1',
        account: account,
    },
    domainName: domainName,
    hostedZone: route53Stack.hostedZone,
    crossRegionReferences: true,
});

const s3CloudfrontStack = new S3CloudfrontStack(app, 'S3CloudfrontStack', {
    env: {
        region: 'ap-northeast-1',
        account: account,
    },
    domainName: domainName,
    certificateArn: certificateStack.certificateArn,
    hostedZone: route53Stack.hostedZone,
    crossRegionReferences: true,
});

const monitoringStack = new MonitoringStack(app, 'MonitoringStack', {
    env: {
        region: 'ap-northeast-1',
        account: account,
    },
    email: email,
    originBucket: s3CloudfrontStack.originBucket,
})

new MonitoringUSStack(app, 'MonitoringUSStack', {
    env: {
        region: 'us-east-1',
        account: account,
    },
    distributionId: s3CloudfrontStack.distributhinId,
    email: email,
    crossRegionReferences: true,
});

デプロイ後、サブスクリプション先に承認メッセージが届きます。

CloudWatch

Request数とエラーレートを表示するグラフがそれぞれ1つのダッシュボード上に表示されます。

エラー率が一定以上に達するとSNSから通知が届きます。

CloudTrail

CloudTrailの証跡に新しくデータイベントログを取得する証跡ができています。

オリジンS3バケットに適当なファイルを追加してみると

CloudTrailのデータイベントをトリガーとしてSNS通知が届きます。

CloudFrontログ

CloudFrontディストリビューションのLoggingタブでS3バケットにログが送信されているのを確認できます。

ログまで見た人は感じると思いますが、S3バケットに出力されたログをそのまま分析するのは

あまりにも大変です。

Athena等を使ったアクセスログ分析は今後やっていきたいところ。

おわり

今回はCloudFrontディストリビューションに対していくつかのモニタリング設定をしました。

実際の運用ではもっとしっかり設定をするものですが

サービス使用の入門編として簡単に設定してみました。

最後までお読みいただきありがとうございました。

ソースコード

https://github.com/michinoku-YoRHa/awscdk-s3-cloudfront/tree/v4-monitoring

ネタ集め中

Discussion