[AWS CDK] CloudFrontの標準ログV2をL1 Constructで設定してみたかった

[AWS CDK] CloudFrontの標準ログV2をL1 Constructで設定してみたかった

リソースの新規作成と更新とで渡されたパラメーターがそのまま使われるのかどうか異なる
Clock Icon2025.01.29

カスタムリソースを使わずにCloudFrontの標準ログV2の設定をしたい

こんにちは、のんピ(@non____97)です。

皆さんはカスタムリソースを使わずにCloudFrontの標準ログV2の設定をしたいなと思ったことはありますか? 私はあります。

以下記事でカスタムリソースを使ってCloudFrontの標準ログV2を設定する方法を紹介しました。

https://dev.classmethod.jp/articles/aws-cdk-cloudfront-standard-log-v2-custom-resource/

こちらの記事を書いてから気づいたのですが、L1 Constructでも標準ログV2の設定ができるようです。

使用するL1 Constructはaws_logs.CfnDeliverySourceaws_logs.CfnDeliveryDestinationなどと、サービスがCloudWatch Logsなのがポイントです。

実際に試してみたので紹介します。

いきなりまとめ

  • CfnDeliverys3SuffixPathは冪等性がないため、カスタムリソースを使うのが良さそう
    • 新規作成時は自動的にAWSLogs/{account-id}/CloudFront/が付与されるが、更新時は付与されない
    • カスタムリソースを使用せずにリソースが新規作成されるのか、更新されるのかをAWS CDKのコード内で判断する方法が無いように思える

やってみた

検証環境

[AWS CDK] CloudFrontの標準ログV2をカスタムリソースとして設定してみた検証環境構成図.png

検証環境は全てAWS CDKでデプロイしました。使用したコードは以下GitHubリポジトリに保存しています。

https://github.com/non-97/cloudfront-s3-website/tree/v4.0.1

こちらのベースとなったコードの詳細な説明は以下記事をご覧ください。

https://dev.classmethod.jp/articles/aws-cdk-cloudfront-s3-website/

https://dev.classmethod.jp/articles/aws-cdk-cloudfront-s3-website-log-analytics/

https://dev.classmethod.jp/articles/cloudfront-s3-website-webp-image-delivery/

L1 Constructを用いた標準ログV2の設定

L1 Constructを用いた標準ログV2の設定の箇所を抜粋すると以下のとおりです。

./lib/construct/contents-delivery-construct.ts
    // CloudFront Standard Log V2
    if (props.enableLogAnalytics.includes("cloudFrontStandardLogV2")) {
      // Remove CloudFront Standard Log Legacy
      const cfnDistribution = this.distribution.node
        .defaultChild as cdk.aws_cloudfront.CfnDistribution;
      cfnDistribution.addPropertyDeletionOverride("DistributionConfig.Logging");

      const logPrefix = this.getStandardLogV2Prefix(props.logFilePrefix);

      props.cloudFrontAccessLogBucketConstruct.bucket.addToResourcePolicy(
        new cdk.aws_iam.PolicyStatement({
          actions: ["s3:PutObject"],
          effect: cdk.aws_iam.Effect.ALLOW,
          principals: [
            new cdk.aws_iam.ServicePrincipal("delivery.logs.amazonaws.com"),
          ],
          resources: [
            `${props.cloudFrontAccessLogBucketConstruct.bucket.bucketArn}/${logPrefix.awsLogObjectPrefix}*`,
          ],
          conditions: {
            StringEquals: {
              "s3:x-amz-acl": "bucket-owner-full-control",
              "aws:SourceAccount": cdk.Stack.of(this).account,
            },
            ArnLike: {
              "aws:SourceArn": `arn:aws:logs:${cdk.Stack.of(this).region}:${
                cdk.Stack.of(this).account
              }:delivery-source:cf-${this.distribution.distributionId}`,
            },
          },
        })
      );

      const cloudFrontStandardLogDeliverySourceName = `cf-${this.distribution.distributionId}`;
      const cloudFrontStandardLogDeliveryDestinationName = `cf-${this.distribution.distributionId}-s3`;

      const cloudFrontStandardLogDeliverySource =
        new cdk.aws_logs.CfnDeliverySource(
          this,
          "CloudFrontStandardLogDeliverySource",
          {
            name: cloudFrontStandardLogDeliverySourceName,
            resourceArn: this.distribution.distributionArn,
            logType: "ACCESS_LOGS",
          }
        );

      const cloudFrontStandardLogDeliveryDestination =
        new cdk.aws_logs.CfnDeliveryDestination(
          this,
          "CloudFrontStandardLogDeliveryDestination",
          {
            name: cloudFrontStandardLogDeliveryDestinationName,
            outputFormat: "parquet",
            destinationResourceArn:
              props.cloudFrontAccessLogBucketConstruct.bucket.bucketArn,
          }
        );

      new cdk.custom_resources.AwsCustomResource(
        this,
        "CloudFrontStandardLogDelivery",
        {
          logRetention: cdk.aws_logs.RetentionDays.ONE_WEEK,
          serviceTimeout: cdk.Duration.seconds(180),
          timeout: cdk.Duration.seconds(120),
          installLatestAwsSdk: true,
          onCreate: {
            action: "createDelivery",
            parameters: {
              deliverySourceName: cloudFrontStandardLogDeliverySource.name,
              deliveryDestinationArn:
                cloudFrontStandardLogDeliveryDestination.attrArn,
              s3EnableHiveCompatiblePath: false,
              s3DeliveryConfiguration: {
                enableHiveCompatiblePath: false,
                suffixPath: logPrefix.logPrefix.split(
                  logPrefix.awsLogObjectPrefix
                )[1],
              },
            },
            physicalResourceId:
              cdk.custom_resources.PhysicalResourceId.fromResponse(
                "delivery.id"
              ),
            service: "CloudWatchLogs",
          },
          onUpdate: {
            action: "updateDeliveryConfiguration",
            parameters: {
              id: new cdk.custom_resources.PhysicalResourceIdReference(),
              s3EnableHiveCompatiblePath: false,
              s3DeliveryConfiguration: {
                enableHiveCompatiblePath: true,
                suffixPath: logPrefix.logPrefix,
              },
            },
            service: "CloudWatchLogs",
          },
          onDelete: {
            action: "deleteDelivery",
            parameters: {
              id: new cdk.custom_resources.PhysicalResourceIdReference(),
            },
            service: "CloudWatchLogs",
          },
          policy: cdk.custom_resources.AwsCustomResourcePolicy.fromStatements([
            new cdk.aws_iam.PolicyStatement({
              actions: [
                "logs:CreateDelivery",
                "logs:DeleteDelivery",
                "logs:UpdateDeliveryConfiguration",
              ],
              resources: ["*"],
            }),
          ]),
        }
      );
    }

はい、お気づきになられたと思いますがcdk.aws_logs.CfnDeliveryを使用せずにカスタムリソースを使っています。

これはCfnDeliveryもとい、AWS::Logs::DeliveryS3SuffixPathに冪等性がないためです。

前回の記事で消化しているように、CreateDeliveryでプレフィックスを指定する/しないに関わらずAWSLogs/{account-id}/CloudFront/が必ず付与されます。一方、更新処理であるUpdateDeliveryConfigurationをする際はAWSLogs/{account-id}/CloudFront/が付与されません。

AWS公式ドキュメントには、プレフィックスの指定有無でAWSLogs/{account-id}/CloudFront/が付与されるように紹介されていますが、記事投稿のタイミングで私が確認したところ、プレフィックスを指定した場合でも付与されました。

If you specified a prefix for your S3 bucket, your logs appear under that path. If you don't specify a prefix, CloudFront will automatically append the AWSLogs/<account-ID>/CloudFront prefix for you.

Example: Bucket with a prefix

If you specify the following bucket name with a prefix: amzn-s3-demo-bucket.s3.amazonaws.com/MyLogPrefix

Your logs will appear under the following path: amzn-s3-demo-bucket.s3.amazonaws.com/MyLogPrefix/logs

Example: Bucket without a prefix

If you specify the bucket name only: amzn-s3-demo-bucket.s3.amazonaws.com

Your logs will appear under the following path: amzn-s3-demo-bucket.s3.amazonaws.com/AWSLogs/123456789012/CloudFront/logs

Configure standard logging (v2) - Amazon CloudFront

これによって何が起きるのかというと、リソースの作成時と更新時でプレフィックスを変更していなくとも、プレフィックスが変更されてしまいます。

例えばプレフィックスに{DistributionId}/{yyyy}/{MM}/{dd}/{HH}した場合、リソースが作成されるタイミングではAWSLogs/{account-id}/CloudFront/{DistributionId}/{yyyy}/{MM}/{dd}/{HH}が設定され、リソースが更新されるタイミングでは{DistributionId}/{yyyy}/{MM}/{dd}/{HH}となります。

  • リソースの新規作成後

2.createとupdateとの場合わけ1.png

  • リソースの更新後

3.2回目の更新の場合.png

では、「最初からAWSLogs/{account-id}/CloudFront/{DistributionId}/{yyyy}/{MM}/{dd}/{HH}をプレフィックスとして指定すれば良いのでは」となるところです、これはProvided suffixPath contains reference(s) to invalid fields. Please consult documentation for a list of valid fields for log type.とエラーになります。どうやら{account-id}はユーザーからの入力としては受け付けないようです。

また、AWSLogs/<AWSアカウントID>/CloudFront/{DistributionId}/{yyyy}/{MM}/{dd}/{HH}をプレフィックスとして指定すると、リソース新規作成時はAWSLogs/{account-id}/CloudFront/AWSLogs/<AWSアカウントID>/CloudFront/{DistributionId}/{yyyy}/{MM}/{dd}/{HH}となってしまいます。

1.初回デプロイ時.png

そこで考えるのがリソースの新規作成と更新処理とでS3SuffixPathに指定する値を変更することです。

例えば、リソースの新規作成時にはAWSLogs/{account-id}/CloudFront/が補完されるため{DistributionId}/{yyyy}/{MM}/{dd}/{HH}を指定し、リソースの更新時にはAWSLogs/{account-id}/CloudFront/が付与されないため、AWSLogs/<AWSアカウントID>/CloudFront/{DistributionId}/{yyyy}/{MM}/{dd}/{HH}を指定するといった具合です。

肝心なリソースの新規作成なのか更新処理なのかを判断する方法ですが、AWS CDKのコード上でのみ判定を行うとなるとカスタムリソースを使うしかないと考えています。(良い方法があれば教えてください)

cdk.out/manifest.jsoncdk.out/WebsiteStack.template.jsonから、判定する方法も試してみたのですが、これらファイルはcdk deployだけではなく、cdk diffcdk synthなどシンセサイズのタイミングでも更新が走るため、不適切です。

例えば、以下のようにConstructパスが現時点のcdk.out/manifest.json内に存在するか否かで判定する方法は以下のとおりです。

      const cloudFrontStandardLogDelivery = new cdk.aws_logs.CfnDelivery(
        this,
        "CloudFrontStandardLogDelivery",
        {
          deliverySourceName: cloudFrontStandardLogDeliverySource.name,
          deliveryDestinationArn:
            cloudFrontStandardLogDeliveryDestination.attrArn,
          s3EnableHiveCompatiblePath: false,
          s3SuffixPath: logPrefix.logPrefix.split(
            logPrefix.awsLogObjectPrefix
          )[1],
        }
      );

      // Constructパスが現時点の`cdk.out/manifest.json`内に存在する = 既にリソースが存在する = 更新処理と判断し、プレフィックス内の AWSLogs/<AWSアカウントID>/CloudFront がある状態で渡す
      if (this.resourceExists(cloudFrontStandardLogDelivery)) {
        cloudFrontStandardLogDelivery.s3SuffixPath = logPrefix.logPrefix;
      }
      console.log(
        `cloudFrontStandardLogDelivery.s3SuffixPath : ${cloudFrontStandardLogDelivery.s3SuffixPath}`
      );
    }
  }

  private resourceExists(resource: cdk.CfnResource): boolean {
    try {
      const manifestPath = path.join("cdk.out", "manifest.json");

      if (!fs.existsSync(manifestPath)) {
        console.log(
          "manifest.json does not exist. This might be the first deployment."
        );
        return false;
      }

      const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));

      const resourcePath = resource.cfnOptions?.metadata?.["aws:cdk:path"];
      if (!resourcePath) {
        console.log("Resource path not found in metadata");
        return false;
      }

      const resourceMetadata =
        manifest?.artifacts?.WebsiteStack?.metadata?.[`/${resourcePath}`];
      return resourceMetadata !== undefined;
    } catch (error) {
      console.log("Error checking resource existence:", error);
      return false;
    }
  }

こちらのコードでCloudFrontStandardLogDeliveryが存在しない状態でcdk diffを叩くと1回目は確かに意図したプレフィックスを渡していますが、ただし、2回目からはまだCloudFrontStandardLogDeliveryがデプロイされていないにも関わらず、AWSLogs/<AWSアカウントID>/CloudFront/が付与されてしまっています。

初回のcdk diff
px cdk diff --no-change-set
Bundling asset WebsiteStack/ContentsDeliveryConstruct/RewriteToWebpLambdaEdge/Code/Stage...

  cdk.out/bundling-temp-a0b0205c5ae59be4b27b17777568e9cab4257b303b38ab098d49bca58284c947/index.mjs  731b

⚡ Done in 9ms
cloudFrontStandardLogDelivery.s3SuffixPath : {DistributionId}/{yyyy}/{MM}/{dd}/{HH}
Stack WebsiteStack
Resources
[+] AWS::Logs::Delivery ContentsDeliveryConstruct/CloudFrontStandardLogDelivery ContentsDeliveryConstructCloudFrontStandardLogDelivery2C76C0AA

✨  Number of stacks with differences: 1
2回目のcdk diff
>  (feature/logging-v2 →⚡=) npx cdk diff --no-change-set
Bundling asset WebsiteStack/ContentsDeliveryConstruct/RewriteToWebpLambdaEdge/Code/Stage...

  cdk.out/bundling-temp-a0b0205c5ae59be4b27b17777568e9cab4257b303b38ab098d49bca58284c947/index.mjs  731b

⚡ Done in 11ms
update
cloudFrontStandardLogDelivery.s3SuffixPath : AWSLogs/<AWSアカウントID>/CloudFront/{DistributionId}/{yyyy}/{MM}/{dd}/{HH}
Stack WebsiteStack
Resources
[+] AWS::Logs::Delivery ContentsDeliveryConstruct/CloudFrontStandardLogDelivery ContentsDeliveryConstructCloudFrontStandardLogDelivery2C76C0AA

✨  Number of stacks with differences: 1

他にもリソースの物理IDをCfnOutputとして出力するように設定しておき、リソースの作成時にFn.ImportValueCfnOutputの結果をインポートしてConditionsで空かどうかで判断することも考えたのですが、Cannot use Fn::ImportValue in Conditions.とエラーになったため実現できませんでした。

その他にも方法あるかも知れませんが、個人的にはCfnDeliveryをそのまま使用するのは難しいと考えます。

一方、カスタムリソースではCreateUpdateDeleteのリクエストタイプごとに処理を振り分けることが可能です。

動作確認

カスタムリソースを使用する方式で動作確認をします。

まず、リソース新規作成時です。プレフィックスには{DistributionId}/{yyyy}/{MM}/{dd}/{HH}を指定しました。

4.create.png

はい、自動的にAWSLogs/{account-id}/CloudFront/が付与され、AWSLogs/{account-id}/CloudFront/{DistributionId}/{yyyy}/{MM}/{dd}/{HH}となっていますね。

続いて更新です。Constructに渡すプレフィックスは変更せずに、enableHiveCompatiblePathfalseからtrueに変更します。

./lib/construct/contents-delivery-construct.ts
          onUpdate: {
            action: "updateDeliveryConfiguration",
            parameters: {
              id: new cdk.custom_resources.PhysicalResourceIdReference(),
              s3EnableHiveCompatiblePath: false,
              s3DeliveryConfiguration: {
+               enableHiveCompatiblePath: true,
                suffixPath: logPrefix.logPrefix,
              },
            },
            physicalResourceId:
              cdk.custom_resources.PhysicalResourceId.fromResponse(
                "delivery.id"
              ),
            service: "CloudWatchLogs",
          },

こちらでcdk deployすると、Apache Hive互換プレフィックスが有効になったとともに、AWSLogs/{account-id}/CloudFront/{DistributionId}/{yyyy}/{MM}/{dd}/{HH}AWSLogs/<AWSアカウントID>/CloudFront/{DistributionId}/{yyyy}/{MM}/{dd}/{HH}

5.update後のパーティショニング.png

正しく、リソースの新規作成時と更新時とで処理を振り分けられていますね。

リソースの新規作成と更新とで渡されたパラメーターがそのまま使われるのかどうか異なる

CloudFrontの標準ログV2をL1 Constructで設定してみようとしてみました。

結果としては、CfnDeliveryについては、s3SuffixPathに冪等性がないため、カスタムリソースを使うのが良いと考えます。

個人的にはあまりよろしくない挙動だと思うので、APIの動きが見直されることを願っています。

また、カスタムリソースを使用せずにリソースの新規作成、更新を判定する方法をご存知の方がいらっしゃれば教えてください。

この記事が誰かの助けになれば幸いです。

以上、クラウド事業本部 コンサルティング部の のんピ(@non____97)でした!

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.