[AWS CDK] Lambda@Edge関数とCloudFront DistributionのConstructは別スタックに分ければ、関数削除時のデプロイが1度で済むのか試してみた

結論:スタックを分けて依存関係を明確にしてみましたが、できませんでした
2022.02.26

こんにちは、CX事業本部 IoT事業部の若槻です。

以前のエントリでは、AWS CDKで構築したLambda@Edge関数を削除する際には次の順番で2回に分けてデプロイを行えば、削除時のエラーを回避できることを確認しました。

  1. 「CloudFront Distributionとの関数紐付け設定」を削除
  2. 「Lambda@Edge関数本体」を削除

今回は、AWS CDKでLambda@Edge関数とCloudFront DistributionのConstructは別スタックに分ければ、関数削除時のデプロイが1度で済むのでは?と考え、試してみました。

環境

  • typescript@3.9.10
  • aws-cdk@1.145.0

Distributionに紐付けたLambda@Edge関数が削除が失敗する

下記のようにAWS CDKで構築したLambda@Edge関数をスタックから削除しようとしています。関数はCloudFront Distributionで使用しているため、削除対象は「Lambda@Edge関数本体」と「CloudFront Distributionとの関数紐付け設定」の2箇所となります。記述を残すため削除箇所はコメントアウトとしています。

ちなみに以前のエントリでも同様の構成を構築していますが、今回は@aws-cdk/aws-cloudfront/experimental/EdgeFunctionを使用してLambda@Edge関数を作成しているという違いがあります。

lib/aws-cdk-app-stack.ts

import * as path from 'path';
import * as cdk from '@aws-cdk/core';
import * as cloudfront from '@aws-cdk/aws-cloudfront';
import * as iam from '@aws-cdk/aws-iam';
import * as s3 from '@aws-cdk/aws-s3';
import * as s3deploy from '@aws-cdk/aws-s3-deployment';
import * as cloudfront_origins from '@aws-cdk/aws-cloudfront-origins';
import * as lambda from '@aws-cdk/aws-lambda';

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

    const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    const originAccessIdentity = new cloudfront.OriginAccessIdentity(
      this,
      'OriginAccessIdentity',
      {
        comment: 'website-distribution-originAccessIdentity',
      }
    );

    const webSiteBucketPolicyStatement = new iam.PolicyStatement({
      actions: ['s3:GetObject'],
      effect: iam.Effect.ALLOW,
      principals: [
        new iam.CanonicalUserPrincipal(
          originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId
        ),
      ],
      resources: [`${websiteBucket.bucketArn}/*`],
    });

    websiteBucket.addToResourcePolicy(webSiteBucketPolicyStatement);

    //削除対象その1:Lambda@Edge関数本体
    /*
    const websiteAddHeaderFunction = new cloudfront.experimental.EdgeFunction(
      this,
      'WebsiteAddHeaderFunction',
      {
        code: lambda.Code.fromAsset(
          path.join(__dirname, '../src/lambda/website-add-header')
        ),
        handler: 'index.handler',
        runtime: lambda.Runtime.NODEJS_14_X,
      }
    );
    */

    const distribution = new cloudfront.Distribution(this, 'Distribution', {
      comment: 'website-distribution',
      defaultRootObject: 'index.html',
      errorResponses: [
        {
          ttl: cdk.Duration.seconds(300),
          httpStatus: 403,
          responseHttpStatus: 403,
          responsePagePath: '/error.html',
        },
        {
          ttl: cdk.Duration.seconds(300),
          httpStatus: 404,
          responseHttpStatus: 404,
          responsePagePath: '/error.html',
        },
      ],
      defaultBehavior: {
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        origin: new cloudfront_origins.S3Origin(websiteBucket, {
          originAccessIdentity,
        }),
        //削除対象その2:CloudFront Distributionとの関数紐付け設定
        /*
        edgeLambdas: [
          {
            eventType: cloudfront.LambdaEdgeEventType.ORIGIN_RESPONSE,
            functionVersion: websiteAddHeaderFunction.currentVersion,
          },
        ],
        */
      },
      priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
    });

    new s3deploy.BucketDeployment(this, 'WebsiteDeploy', {
      sources: [
        s3deploy.Source.data(
          '/index.html',
          `<html><body><h1>Hello World</h1></body></html>`
        ),
        s3deploy.Source.data(
          '/error.html',
          `<html><body><h1>Error!!!!!!!!!!!!!</h1></body></html>`
        ),
      ],
      destinationBucket: websiteBucket,
      distribution: distribution,
      distributionPaths: ['/*'],
    });
  }
}

しかし、CDKデプロイをしたところ下記のようにエラーとなってしまいます。

$  cdk deploy

✨  Synthesis time: 3.66s

AwsCdkAppStack2: deploying...
[0%] start: Publishing a8cd1d5633b0cf605e6b064436ab82e929d2c0de1d3236e04f367afa01e35eb8:current
[25%] success: Published a8cd1d5633b0cf605e6b064436ab82e929d2c0de1d3236e04f367afa01e35eb8:current
[25%] start: Publishing f98b78092dcdd31f5e6d47489beb5f804d4835ef86a8085d0a2053cb9ae711da:current
[50%] success: Published f98b78092dcdd31f5e6d47489beb5f804d4835ef86a8085d0a2053cb9ae711da:current
[50%] start: Publishing 1672de23d2863db55032bdee49865d2c7693f23e391c1de545c2711146148866:current
[75%] success: Published 1672de23d2863db55032bdee49865d2c7693f23e391c1de545c2711146148866:current
[75%] start: Publishing 0baf5196e4a110bb8bc37171a49b3e17f0a3892bbfee7a9ea4c704d61f80d3f6:current
[100%] success: Published 0baf5196e4a110bb8bc37171a49b3e17f0a3892bbfee7a9ea4c704d61f80d3f6:current
AwsCdkAppStack2: creating CloudFormation changeset...
[█████████████████████████████████████████▍················] (5/7)

11:30:51 PM | UPDATE_COMPLETE_CLEA | AWS::CloudFormation::Stack                      | AwsCdkAppStack2
11:30:55 PM | DELETE_FAILED        | AWS::Lambda::Function                           | WebsiteAddHeaderFunctionFnB1DF9EDC
Resource handler returned message: "Lambda was unable to delete arn:aws:lambda:us-east-1:XXXXXXXXXXXX:function:AwsCdkAppStack2-WebsiteAddHeaderFunctionFnB1DF9EDC-YsEivA3a1k0C:1 because it is a replicated function.
Please see our documentation for Deleting Lambda@Edge Functions and Replicas. (Service: Lambda, Status Code: 400, Request ID: a0abd53d-8c41-4175-b37b-a8f6258aa8bf, Extended Request ID: null)" (RequestToken: 3e444a5
3-f957-7ad4-89d2-c0b80ce9adb7, HandlerErrorCode: InvalidRequest)

原因

エラーによると、Lambdaがレプリカを持っているので削除できないと言っています。ここは以前のエントリと同じです。

Lambda was unable to delete arn:aws:lambda:us-east-1:XXXXXXXXXXXX:function:AwsCdkAppStack2-WebsiteAddHeaderFunctionFnB1DF9EDC-YsEivA3a1k0C:1 because it is a replicated function.

Lambda@Edgeでは高速な処理を行うためにエッジロケーションごとにレプリカが作成されます。ドキュメントを見ると、Lambda@Edge 関数を削除する前にレプリカが削除されている必要があり、またレプリカが削除される前にCloudFront Distributionとの関連付けが削除される必要があるとのことです。

You can delete a Lambda@Edge function only when the replicas of the function have been deleted by CloudFront. Replicas of a Lambda function are automatically deleted in the following situations:

- After you remove the last association for the function from all of your CloudFront distributions. If more than one distribution uses a function, the replicas are deleted only after you remove the function association from the last distribution.
- After you delete the last distribution that a function was associated with.

スタックを分けて削除デプロイしてみる

ここで「Lambda@Edge関数本体」と「CloudFront Distributionとの関数紐付け設定」の依存関係を明示的に定義し、後者が先に削除されるようにすれば良いのでは?と考え、両者を別々のスタックに分けて構築した上で、両者を削除してみます。削除箇所はコメントアウトしています。

「Lambda@Edge関数本体」のConstructを作成しているスタックです。作成したConstructをExportして読み取り可能としています。

lib/aws-cdk-app-func-stack.ts

import * as path from 'path';
import * as cdk from '@aws-cdk/core';
import * as cloudfront from '@aws-cdk/aws-cloudfront';
import * as lambda from '@aws-cdk/aws-lambda';

export class AwsCdkAppFuncStack extends cdk.Stack {
  //public readonly websiteAddHeaderFunction: cloudfront.experimental.EdgeFunction;

  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    //削除対象その1:Lambda@Edge関数本体
    /*
    this.websiteAddHeaderFunction = new cloudfront.experimental.EdgeFunction(
      this,
      'WebsiteAddHeaderFunction',
      {
        code: lambda.Code.fromAsset(
          path.join(__dirname, '../src/lambda/website-add-header')
        ),
        handler: 'index.handler',
        runtime: lambda.Runtime.NODEJS_14_X,
      }
    );
    */
  }
}

CDK Appの定義です。「Lambda@Edge関数本体」のConstructを、「CloudFront Distributionとの関数紐付け設定」を定義しているConstructに渡してImportしています(クロススタックスタック参照)。またaddDependency()を使用して後者のスタックが前者のスタックに依存するように明示的に定義しています。

bin/aws-cdk-app.ts

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';
import { AwsCdkAppStack } from '../lib/aws-cdk-app-stack';
import { AwsCdkAppFuncStack } from '../lib/aws-cdk-app-func-stack';

const app = new cdk.App();

const awsCdkAppFuncStack = new AwsCdkAppFuncStack(app, 'AwsCdkAppFuncStack', {
  env: {
    region: 'us-east-1',
  },
});

const awsCdkAppStack = new AwsCdkAppStack(app, 'AwsCdkAppStack', {
  env: {
    region: 'us-east-1',
  },
  //websiteAddHeaderFunction: awsCdkAppFuncStack.websiteAddHeaderFunction,
});

awsCdkAppStack.addDependency(awsCdkAppFuncStack);

「CloudFront Distributionとの関数紐付け設定」およびその他Constructを定義しているスタックです。「Lambda@Edge関数本体」のConstructを読み取って使用しています。

lib/aws-cdk-app-stack.ts

import * as cdk from '@aws-cdk/core';
import * as cloudfront from '@aws-cdk/aws-cloudfront';
import * as iam from '@aws-cdk/aws-iam';
import * as s3 from '@aws-cdk/aws-s3';
import * as s3deploy from '@aws-cdk/aws-s3-deployment';
import * as cloudfront_origins from '@aws-cdk/aws-cloudfront-origins';

export interface AwsCdkAppStackProps extends cdk.StackProps {
  //websiteAddHeaderFunction: cloudfront.experimental.EdgeFunction;
}

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

    const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    const originAccessIdentity = new cloudfront.OriginAccessIdentity(
      this,
      'OriginAccessIdentity',
      {
        comment: 'website-distribution-originAccessIdentity',
      }
    );

    const webSiteBucketPolicyStatement = new iam.PolicyStatement({
      actions: ['s3:GetObject'],
      effect: iam.Effect.ALLOW,
      principals: [
        new iam.CanonicalUserPrincipal(
          originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId
        ),
      ],
      resources: [`${websiteBucket.bucketArn}/*`],
    });

    websiteBucket.addToResourcePolicy(webSiteBucketPolicyStatement);

    const distribution = new cloudfront.Distribution(this, 'Distribution', {
      comment: 'website-distribution',
      defaultRootObject: 'index.html',
      errorResponses: [
        {
          ttl: cdk.Duration.seconds(300),
          httpStatus: 403,
          responseHttpStatus: 403,
          responsePagePath: '/error.html',
        },
        {
          ttl: cdk.Duration.seconds(300),
          httpStatus: 404,
          responseHttpStatus: 404,
          responsePagePath: '/error.html',
        },
      ],
      defaultBehavior: {
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        origin: new cloudfront_origins.S3Origin(websiteBucket, {
          originAccessIdentity,
        }),
        //削除対象その2:CloudFront Distributionとの関数紐付け設定
        /*
        edgeLambdas: [
          {
            eventType: cloudfront.LambdaEdgeEventType.ORIGIN_RESPONSE,
            functionVersion: props.websiteAddHeaderFunction.currentVersion,
          },
        ],
        */
      },
      priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
    });

    new s3deploy.BucketDeployment(this, 'WebsiteDeploy', {
      sources: [
        s3deploy.Source.data(
          '/index.html',
          `<html><body><h1>Hello World</h1></body></html>`
        ),
        s3deploy.Source.data(
          '/error.html',
          `<html><body><h1>Error!!!!!!!!!!!!!</h1></body></html>`
        ),
      ],
      destinationBucket: websiteBucket,
      distribution: distribution,
      distributionPaths: ['/*'],
    });
  }
}

上記のスタックの記述でCDKデプロイしてみたところ、エラーとなりました。

$  cdk deploy --all

✨  Synthesis time: 2.8s

AwsCdkAppFuncStack
AwsCdkAppFuncStack: deploying...
AwsCdkAppFuncStack: creating CloudFormation changeset...
[··························································] (0/6)

1:12:39 AM | UPDATE_ROLLBACK_IN_P | AWS::CloudFormation::Stack | AwsCdkAppFuncStack
Export AwsCdkAppFuncStack:ExportsOutputRefWebsiteAddHeaderFunctionFnCurrentVersion651BDCB06724ad72113458587c91dab8a63c2898D3258BE2 cannot be deleted as it is in use by AwsCdkAppStack
1:12:39 AM | UPDATE_ROLLBACK_IN_P | AWS::CloudFormation::Stack | AwsCdkAppFuncStack
Export AwsCdkAppFuncStack:ExportsOutputRefWebsiteAddHeaderFunctionFnCurrentVersion651BDCB06724ad72113458587c91dab8a63c2898D3258BE2 cannot be deleted as it is in use by AwsCdkAppStack

Exportしている側のスタックが先に削除されてしまっているような動作ですん。そもそもの問題として、スタック間の依存関係を定義しただけだとConstructの削除の順番をうまく制御できないようです。

おわりに

AWS CDKでLambda@Edge関数とCloudFront DistributionのConstructは別スタックに分ければ、関数削除時のデプロイが1度で済むのか試してみました。

結論としては、スタックを分けて依存関係を明確すればできるのでは?という思惑ははずれ、残念ながらできませんでした。

参考

以上