CloudFrontの作成、設定変更、キャッシュクリアが完了したら通知して欲しかったので作ってみた

CloudFrontの作成や、設定変更後のデプロイって、ちょっぴり時間かかりますよね(個人の意見です) やらかしちゃった後のキャッシュクリアとかも、完了するまではよはよ!もう急いで!ってなりますよね(あくまで個人の意見です) とはいえ、ずっとコンソールに張り付くのも、なんだかな。なので、状態が変化したら通知してくれるのイベントないかなーと探したんですが ちょっと見つからなかったので、作りました。
2019.02.26

AWS事業本部の梶原@福岡オフィスです。

CloudFrontの作成や、設定変更後のデプロイって、ちょっぴり時間かかりますよね(個人の意見です) やらかしちゃった後のキャッシュクリアとかも、完了するまではよはよ!もう急いで!ってなりますよね(個人の意見です) とはいえ、ずっとコンソールに張り付くのも、なんだかな~なので、状態が変化したら通知してくれるのイベントないかなーと探したんですがちょっと見つからなかったので、作りました。

構成はこんな感じです。

状態を監視するlambdaのリトライについてはSQSを使用しています。 詳しくはこちら

LambdaのリトライをAWS SQSを使ってやってみる

ざっくり動きを説明すると

1. CloudWatch EventRuleで、CloudTrailに出力されるCloudFrontのイベントを補足
2. 補足したらSQSのキューにメッセージを投入
3. SQSからLambdaを起動
3.1 完了していなければ、エラーを返すことによりリトライ
3.2 完了していれば、SNS通知を行い、正常終了

と言うことで、それでは各種リソースを作成していきます!というのも、結構手間なので CloudFormation一撃シリーズ化しました。 これで、メールアドレスだけあれば、通知がきますよ!(検証はちゃんとしてくださいね)

いるもの(前提条件)

  • AWSアカウント(各種権限)
  • メールアカウント(SNS通知用)
  • CluodTrailが有効になっていること

各種設定

CloudWatch Rule 設定

イベントソース:CloudFront

イベント名:

  • CreateDistribution(CloudFrontディストリビューションの作成),
  • CreateDistributionWithTags(CloudFrontディストリビューションの作成タグ付き),
  • UpdateDistribution(CloudFrontディストリビューションの設定更新)
  • CreateInvalidation(キャッシュクリア)

※補足 監視対象のイベント名はAPIリファレンスなどを参照してください。 https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_Operations.html

SQS 設定

  • デフォルトの可視性タイムアウト: 1 分
  • メッセージ保持期間: 4 日
  • メッセージ受信待機時間: 20 秒

1分間隔で、リトライして,Lambdaを実行するようにしています。取得(リトライ間隔)は調整ください、そんなに要求が厳しくなければ5分でも (CloudFormationのパラメータ化しています)

lambda に割り当てたRole権限

  • 基本的なLambdaの実行権限(マネージドポリシー)
  • SQSへのアクセス(マネージドポリシー)
  • CloudFrontへの読み取り専用アクセス(マネージドポリシー)
  • SNSへのPublish権限

リージョンについて

CloudFrontのイベントを補足するために、米国東部 (バージニア北部)でCloudFormationを実行する必要があります。 CloudWatchのルール以外の各種リソースはとくにリージョンの縛りはありませんが、動作確認は米国東部 (バージニア北部)のリソースで実施しています。 また、lambdaのソースを置くバケットなどもCloudFormationを実行するバケットと同じところに置く必要があります。

リソースの作成

CloudFormationテンプレートのポイント

長いので、全体はブログの最後に記載します。

  1. CloudWatch EventRuleで、CloudTrailに出力されるCloudFrontのイベントを補足
  2. 補足したらSQSのキューにメッセージを投入

の部分のテンプレートは下記になります。

CloudFrontEventsRule:
Type: AWS::Events::Rule
Properties:
Description: "CloudFrontEventsRule"
EventPattern:
source:
- aws.cloudfront
detail-type:
- AWS API Call via CloudTrail
detail:
eventSource:
- cloudfront.amazonaws.com
eventName:
- CreateDistribution
- CreateDistributionWithTags
- UpdateDistribution
- CreateInvalidation
State: "ENABLED"
Targets:
- Id: "CFEventSqsQueue"
Arn: !GetAtt CFEventSqsQueue.Arn

イベントパターンを変更することで、キャッシュクリアだけのイベントを補足、また特定のディストリビューションだけ補足することもできますので ぜひカスタマイズしてみてください。 CloudTrailのイベントソースの書き方などは結構、試行錯誤しました。判れば簡単ですが他のイベントを補足する際などにも参考になるかと

lambda関数は特に変な処理はさせていないと思うで、適時カスタマイズしてください。 むしろエラー処理とかすっとばしてるので、カスタマイズ前提でお願いします。

CloudFormationの実行

テンプレートと、lambdaのソースはS3バケットに置いてます。 ログイン後、リージョンを米国東部 (バージニア北部)に変更して、下記'ここをクリック'を押下してCloudFormationを実行し、パラメータに通知先のメールアドレスを入れてください。

ここをクリック

正常にリソースが作成されたら、下記のような感じになるかと思います。

下記件名でSNS通知の確認がメールアドレス宛に来ていると思うので、承認(Confirmation)してください。 AWS Notification - Subscription Confirmation

動作確認

CloudFrontの作成または設定変更を行うとSQSにイベントが通知され、lambdaが動きます。 CloudFrontのStatusDeployedになるまで、1分間隔でリトライを行います。

キャッシュのクリアの場合はInvalidationStatusCompletedになるまで1分間隔でリトライします。

リトライを続けても4日間でメッセージは消えるようになっているので、万が一エラーが出続けても4日経過すると消えます。

まとめ

実装自体は、そうでもなかったのですが、やっぱり動作確認に結構時間がとられました。(あくまで個人の意見です) 実際にこのまま使うことは想定していないですが、CloudTrailのイベントの補足の仕方、CloudFrontに限らずステータスの完了を待つなどの 時に使えるテクニックではないでしょうか?

また、SNS通知でなく、再度別のSQSキューに入れて、後続処理を流していくなどの使い方などもできるかもしれません。 結構ニッチなニーズな気はしますが、どなたかの役にたつと幸いです。

テンプレート

AWSTemplateFormatVersion: '2010-09-09'
Metadata:
AWS::CloudFormation::Interface:
ParameterGroups:
- Label:
default: "SQS Settings"
Parameters:
- ReceiveMessageWaitTimeSeconds
- VisibilityTimeout
- MessageRetentionPeriod
- Label:
default: "SNS Settings"
Parameters:
- EMailAddress
ParameterLabels:
VisibilityTimeout:
default: "VisibilityTimeout"
ReceiveMessageWaitTimeSeconds:
default: "ReceiveMessageWaitTimeSeconds"
MessageRetentionPeriod:
default: "MessageRetentionPeriod"
EMailAddress:
default: "E-Mail Address"
Parameters:
VisibilityTimeout:
Type: Number
Default: 60
ReceiveMessageWaitTimeSeconds:
Type: Number
Default: 20
MessageRetentionPeriod:
Type: Number
Default: 345600
EMailAddress:
Type: String
Default: hogefuga@exsample.com

Resources:
CFEventSqsQueue:
Type: AWS::SQS::Queue
Properties:
ReceiveMessageWaitTimeSeconds: !Ref ReceiveMessageWaitTimeSeconds
VisibilityTimeout: !Ref VisibilityTimeout
MessageRetentionPeriod: !Ref MessageRetentionPeriod

CloudFrontEventsRule:
Type: AWS::Events::Rule
Properties:
Description: "CloudFrontEventsRule"
EventPattern:
source:
- aws.cloudfront
detail-type:
- AWS API Call via CloudTrail
detail:
eventSource:
- cloudfront.amazonaws.com
eventName:
- CreateDistribution
- CreateDistributionWithTags
- UpdateDistribution
- CreateInvalidation
State: "ENABLED"
Targets:
- Id: "CFEventSqsQueue"
Arn: !GetAtt CFEventSqsQueue.Arn

CFEventSqsQueuePolicy:
Type: AWS::SQS::QueuePolicy
Properties:
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: AWSEvents_CloudFrontEventsRule_Id1
Effect: Allow
Principal:
Service: events.amazonaws.com
Action: sqs:SendMessage
Resource: !GetAtt CFEventSqsQueue.Arn
Condition:
ArnEquals:
aws:SourceArn: !GetAtt CloudFrontEventsRule.Arn
Queues:
- !Ref CFEventSqsQueue

EMailSNSTopic:
Type: AWS::SNS::Topic
Properties:
Subscription:
- Endpoint: !Ref EMailAddress
Protocol: email

CFGetStatusLambdaFunc:
Type: AWS::Lambda::Function
Properties:
Handler: index.handler
Role: !GetAtt LambdaExecutionRole.Arn
Environment:
Variables:
SNS_TOPIC_ARN: !Ref EMailSNSTopic
Code:
S3Bucket: pub-devio-blog-vtuisp2o
S3Key: lambda/lambda-cf-getevent.zip
Runtime: nodejs8.10
Timeout: 30

EventSourceMapping:
Type: AWS::Lambda::EventSourceMapping
Properties:
BatchSize: 1
EventSourceArn: !GetAtt CFEventSqsQueue.Arn
FunctionName: !GetAtt CFGetStatusLambdaFunc.Arn

LambdaExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action:
- sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
- arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole
- arn:aws:iam::aws:policy/CloudFrontReadOnlyAccess
Path: "/"
Policies:
- PolicyName: LambdaExecutionRole-SnsPublishPolicy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- sns:Publish
Resource: arn:aws:sns:*:*:*

lambda関数

var AWS = require('aws-sdk');

var SNS_TOPIC_ARN = process.env.SNS_TOPIC_ARN;

exports.handler = async(event) => {
// SQSで1メッセージのみ取得
var body = JSON.parse(event.Records[0].body);

var response;
switch (body.detail.eventName) {
case 'CreateDistribution':
case 'CreateDistributionWithTags':
case 'UpdateDistribution':
console.info(body.detail.eventName + ' Event');
response = getDistributionStatus(body);
break;
case 'CreateInvalidation':
console.info(body.detail.eventName + ' Event');
response = getInvalidationStatus(body);
break;
default:
console.warn('Not Support Event ' + body.detail.eventName);
return;
}

return response;
};

async function getDistributionStatus(body) {
console.log(JSON.stringify(body, null, 4));

// CoudFront getDistribution
var cloudfront = new AWS.CloudFront();

var params;
switch (body.detail.eventName) {
case 'CreateDistribution':
case 'CreateDistributionWithTags':
params = {
Id: body.detail.responseElements.distribution.id
};
break;
case 'UpdateDistribution':
params = {
Id: body.detail.requestParameters.id
};
break;
}

var distribution = await cloudfront.getDistribution(params).promise();
console.log(JSON.stringify(distribution, null, 4));

if (distribution.Status != 'Deployed') {
console.info('DistributionId:' + params.Id + ' Distribution Status ' + distribution.Status + ' to retry');
// throw Error
const error = new Error("Retry getDistribution");
throw error;
}

console.info('DistributionId:' + params.Id + ' Distribution Status ' + distribution.Status);

// SNS publish
var message = JSON.stringify(distribution, null, 4);
var result = snsPublish(
message,
'distribution Status ' + distribution.Status,
SNS_TOPIC_ARN
);
console.log(JSON.stringify(result, null, 4));

return distribution;
}

async function getInvalidationStatus(body) {
// CoudFront getInvalidation
var cloudfront = new AWS.CloudFront();
var params = {
DistributionId: body.detail.requestParameters.distributionId,
Id: body.detail.responseElements.invalidation.id
};
var invalidation = await cloudfront.getInvalidation(params).promise();
console.log(JSON.stringify(invalidation, null, 4));

if (invalidation.Status != 'Completed') {
console.info('DistributionId:' + params.DistributionId + ' Invalidation Status ' + invalidation.Status + ' to retry');
// throw Error
const error = new Error("Retry getInvalidation");
throw error;
}

console.info('DistributionId:' + params.DistributionId + ' Invalidation Status ' + invalidation.Status);

// SNS publish
var message = JSON.stringify(invalidation, null, 4);
var result = snsPublish(
message,
'Invalidation Completed',
SNS_TOPIC_ARN
);
console.log(JSON.stringify(result, null, 4));

return invalidation;
}

async function snsPublish(message, subject, topicArn) {

var sns = new AWS.SNS();
var params = {
Message: message,
Subject: subject,
TopicArn: topicArn
};

var result = await sns.publish(params).promise();

return result;
}