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

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;
}