CodePipelineからAWS Lambdaを呼び出してCloudFrontのキャッシュを削除(Invalidation)してみた

こんにちは、佐伯です。

CodePipelineからAWS Lambdaを呼び出してCloudFrontのキャッシュ削除(Invalidation)、キャッシュ削除のステータス確認、SNSへ通知までを行うLambda Functionを作ってみました。

CodePipelineからAWS Lambdaを呼び出すときどういう感じで作ればいいか?というのが本エントリの主旨です。

やってみた

書くこと

CodePipelineのアクション設定、CodePipelineから呼び出すLambda Functionのコードについて書きます。その他リソースの作成・設定手順やSlack通知のコードもググればいっぱい出てくると思うので省略させて頂きます!

構成

以下のようなS3にファイルを配置しCloudFrontで静的サイトを配信しているケースを想定してみました。

パイプライン処理は、CodeCommitにPushしたソースをCodePipelineでS3にデプロイ後、AWS Lambdaを呼び出しLambda FunctionでInvalidatioinとInvalidationのステータスチェックを行い、Invalidationが完了したらSNSへPublishする感じです。

CodePipelineからAWS Lambda呼び出し時の動作

CodePipelineからAWS Lambdaを呼び出す場合、CodePipelineはAWS Lambdaへジョブリクエストを送信します。Lambda Functionはジョブを実行し、実行結果をCodePipelineへ送信してジョブのステータスを知らせます。実行結果に継続トークンを含めることでジョブが完了していないことをCodePipelineへ知らせることができ、未完了のジョブはCodePipelineによって再実行される、といった仕組みのようです。

参考リンク: CodePipeline でのカスタムアクションの作成と追加 - CodePipeline

AWS Lambda の呼び出しアクションの制限事項

継続トークンを実行結果に含めることでCodePipelineがLambda Functionを再実行してくれますが、AWS Lambdaの呼び出しアクションのタイムアウトは1時間となっていますので、1時間以上かかる処理はAWS Lambda以外の方法で実現する必要があります。

参考リンク: AWS CodePipeline 制限 - CodePipeline

CodePipelineのアクション設定

CodePipelineのDeployステージの後にInvalidationステージを追加し、アクションを作成します。設定は以下の通りです。

ユーザーパラメーターにパイプライン名、CloudFrontディストリビューションID、SNSトピックARNの情報をJSONで設定しています。このパラメーターはCodePipelineからLambda Functionが呼び出される際にイベントとして渡され、Lambda Functionで参照することができます。以下のJSONは見やすいように改行していますが、実際の設定は改行を含めずに設定しています。

{
    "PipelineName": "example-pipeline",
    "DistributionId": "E1EVDTCEXAMPLE",
    "SnsTopicArn": "arn:aws:sns:ap-northeast-1:XXXXXXXXXXXX:example-topic"
}

Lambda Functionのサンプルコード

本題のCodePipelineから呼び出すLambda Functionのコードを書いてみました。ランタイムはPython3.7で動作することを確認しています。

import boto3
import json
import logging
import time
import traceback

logger = logging.getLogger()
logger.setLevel(logging.INFO)

cp = boto3.client('codepipeline')
cf = boto3.client('cloudfront')
sns = boto3.client('sns')

def create_invalidation(distribution_id):
    logger.info('Creating invalidation')
    res = cf.create_invalidation(
        DistributionId=distribution_id,
        InvalidationBatch={
        'Paths': {
            'Quantity': 1,
            'Items': ['/*'],
        },
        'CallerReference': str(time.time())
        }
    )

    invalidation_id = res['Invalidation']['Id']
    logger.info('InvalidationId is %s', invalidation_id)
    return invalidation_id

def monitor_invalidation_state(distribution_id, invalidation_id):
    res = cf.get_invalidation(
        DistributionId=distribution_id,
        Id=invalidation_id
    )

    return res['Invalidation']['Status']

def put_job_success(job_id):
    logger.info('Putting job success')
    cp.put_job_success_result(jobId=job_id)

def continue_job_later(job_id, invalidation_id):
    continuation_token = json.dumps({'InvalidationId':invalidation_id})
    logger.info('Putting job continuation')

    cp.put_job_success_result(
        jobId=job_id,
        continuationToken=continuation_token
    )

def put_job_failure(job_id, err):
    logger.error('Putting job failed')
    message = 'Function exception: ' + str(err)
    cp.put_job_failure_result(
        jobId=job_id,
        failureDetails={
            'type': 'JobFailed',
            'message': message
        }
    )

def sns_publish(sns_topic_arn, pipeline_name, job_id, job_status):
    logger.info('Publish to SNS topic')

    message = 'PipelineName: ' + pipeline_name + '\n'
    message += 'JobId: ' + job_id + '\n'
    message += 'Status: ' + job_status + '\n'

    res = sns.publish(
        TopicArn=sns_topic_arn,
        Message=message
    )

    messaeg_id = res['MessageId']
    logger.info('SNS Messaeg ID is %s', messaeg_id)

def lambda_handler(event, context):
    try:
        job_id = event['CodePipeline.job']['id']
        job_data = event['CodePipeline.job']['data']

        user_parameters = json.loads(
            job_data['actionConfiguration']['configuration']['UserParameters']
        )

        pipeline_name = user_parameters['PipelineName']
        distribution_id = user_parameters['DistributionId']
        sns_topic_arn = user_parameters['SnsTopicArn']

        if 'continuationToken' in job_data:
            continuation_token = json.loads(job_data['continuationToken'])
            invalidation_id = continuation_token['InvalidationId']
            logger.info('InvalidationId is %s', invalidation_id)
            status = monitor_invalidation_state(distribution_id, invalidation_id)
            logger.info('Invalidation status is %s', status)
            if not status == 'Completed':
                continue_job_later(job_id, invalidation_id)
            else:
                sns_publish(sns_topic_arn, pipeline_name, job_id, job_status='success')
                put_job_success(job_id)
        else:
            invalidation_id = create_invalidation(distribution_id)
            continue_job_later(job_id, invalidation_id)
    except Exception as err:
        logger.error('Function exception: %s', err)
        traceback.print_exc()
        sns_publish(sns_topic_arn, pipeline_name, job_id, job_status='failed')
        put_job_failure(job_id, err)

    logger.info('Function complete')
    return "Complete."

以下AWSドキュメントにもサンプルコードがあり、こちらも参考になると思います。

参考リンク: CodePipeline で パイプラインに AWS Lambda 関数を呼び出す - CodePipeline

サンプルコードの補足

かなりざっくりですがLambda Functionの処理は以下のような流れになります。また、サンプルコードではInvalidationのPathは/*に固定し、全キャッシュを削除する形にしていますので、参考にされる際はご注意ください。

  1. イベントからパイプライン名、CloudFrontディストリビューションID、SNSトピックARNを取得
  2. CloudFrontのキャッシュを削除を実行
  3. CodePipelineへ継続トークンを含めたジョブ実行結果を送信
  4. CodePipelineがLambda Functionを再実行
  5. イベントにcontinuationTokenが含まれている場合はCloudFrontのキャッシュ削除のステータスを確認
  6. キャッシュ削除が完了していない場合: CodePipelineへ継続トークンを含めた実行結果を送信
  7. キャッシュ削除が完了している場合: SNSトピックへメッセージをPublish
  8. キャッシュ削除が完了している場合: CodePipelineへ継続トークンなしで実行結果を送信

実行

適当にファイルを更新してパイプラインを実行した際のLambda Functionのログが以下となります。(各種IDなどは一部変更しています)

START RequestId: ab7c19c4-0552-4d9d-993c-f5deeexample Version: $LATEST
[INFO] 2019-03-06T05:25:08.878Z ab7c19c4-0552-4d9d-993c-f5deeexample Creating invalidation
[INFO] 2019-03-06T05:25:09.908Z ab7c19c4-0552-4d9d-993c-f5deeexample InvalidationId is I22E4C6EXAMPLE
[INFO] 2019-03-06T05:25:09.909Z ab7c19c4-0552-4d9d-993c-f5deeexample Putting job continuation
[INFO] 2019-03-06T05:25:10.171Z ab7c19c4-0552-4d9d-993c-f5deeexample Function complete
END RequestId: ab7c19c4-0552-4d9d-993c-f5deeexample
REPORT RequestId: ab7c19c4-0552-4d9d-993c-f5deeexample Duration: 1330.52 ms Billed Duration: 1400 ms Memory Size: 128 MB Max Memory Used: 85 MB

START RequestId: 5ff1fc7d-6b2d-444a-ba06-b9788example Version: $LATEST
[INFO] 2019-03-06T05:25:40.610Z 5ff1fc7d-6b2d-444a-ba06-b9788example InvalidationId is I22E4C6EXAMPLE
[INFO] 2019-03-06T05:25:41.593Z 5ff1fc7d-6b2d-444a-ba06-b9788example Invalidation status is InProgress
[INFO] 2019-03-06T05:25:41.593Z 5ff1fc7d-6b2d-444a-ba06-b9788example Putting job continuation
[INFO] 2019-03-06T05:25:41.721Z 5ff1fc7d-6b2d-444a-ba06-b9788example Function complete
END RequestId: 5ff1fc7d-6b2d-444a-ba06-b9788example
REPORT RequestId: 5ff1fc7d-6b2d-444a-ba06-b9788example Duration: 1118.73 ms Billed Duration: 1200 ms Memory Size: 128 MB Max Memory Used: 86 MB

START RequestId: 91bfb2a8-ae58-42eb-ab0c-4bc16example Version: $LATEST
[INFO] 2019-03-06T05:26:13.941Z 91bfb2a8-ae58-42eb-ab0c-4bc16example InvalidationId is I22E4C6EXAMPLE
[INFO] 2019-03-06T05:26:14.708Z 91bfb2a8-ae58-42eb-ab0c-4bc16example Invalidation status is InProgress
[INFO] 2019-03-06T05:26:14.708Z 91bfb2a8-ae58-42eb-ab0c-4bc16example Putting job continuation
[INFO] 2019-03-06T05:26:14.807Z 91bfb2a8-ae58-42eb-ab0c-4bc16example Function complete
END RequestId: 91bfb2a8-ae58-42eb-ab0c-4bc16example
REPORT RequestId: 91bfb2a8-ae58-42eb-ab0c-4bc16example Duration: 887.00 ms Billed Duration: 900 ms Memory Size: 128 MB Max Memory Used: 86 MB

START RequestId: 9ae598c1-66b7-4df9-a14d-4c623example Version: $LATEST
[INFO] 2019-03-06T05:26:45.168Z 9ae598c1-66b7-4df9-a14d-4c623example InvalidationId is I22E4C6EXAMPLE
[INFO] 2019-03-06T05:26:46.92Z 9ae598c1-66b7-4df9-a14d-4c623example Invalidation status is Completed
[INFO] 2019-03-06T05:26:46.92Z 9ae598c1-66b7-4df9-a14d-4c623example Publish to SNS topic
[INFO] 2019-03-06T05:26:46.301Z 9ae598c1-66b7-4df9-a14d-4c623example SNS Messaeg ID is d43d48e4-5d66-54ac-9e1c-1c1d0example
[INFO] 2019-03-06T05:26:46.301Z 9ae598c1-66b7-4df9-a14d-4c623example Putting job success
[INFO] 2019-03-06T05:26:46.428Z 9ae598c1-66b7-4df9-a14d-4c623example Function complete
END RequestId: 9ae598c1-66b7-4df9-a14d-4c623example
REPORT RequestId: 9ae598c1-66b7-4df9-a14d-4c623example Duration: 1280.82 ms Billed Duration: 1300 ms Memory Size: 128 MB Max Memory Used: 86 MB

AWSマネジメントコンソール上もCompletedになっていました!

まとめ

CodePipelineからAWS Lambdaを呼び出す場合、アクションで設定したユーザーパラメーターをLambda Functionから参照することができます。それによって複数のパイプラインから同じLambda Functionを呼び出しても同様の処理を行うことができそうです。また、継続トークンを使用することで再実行までしてくれるCodePipeline最高!という気持ちになりました。

コーディングが必要でLambda Functionのメンテはしなくてはなりませんが、CodePipelineからAWS Lambdaを呼び出すことでCI/CDの幅が広がるのではないかと考えています。例えば、デプロイ後にAWS LambdaからのHTTPリクエストのテストを行ったり、Route 53のCNAME入れ替えを自動化したりなどのユースケースがあるかと思います。

本エントリの内容が少しでもお役にたてれば幸いです。