AWS CDK v2でLambda Function URLのEndpointにカスタムドメイン利用のCloudFrontを経由してアクセスする構成を作る

目次

はじめに

Lambda Function URL の Endpoint にカスタムドメイン利用の CloudFront を経由してアクセスする構成を作ります。 似たような構成として

  • Lambda + API Gateway
  • Lambda@Edge

というのもありますが、今回はこちらで作ってみます。

ポイント

  • Lambda Function URL の Endpoint URL を自動で取得して CloudFront の Origin に設定する(やや面倒)。
  • Lambda Function は DynamoDB にアクセスするだけの単純なものとします。
  • カスタムドメインの新しい A レコードを作成して、その A レコードを CloudFront の Alias に設定します。
    • カスタムドメインは Route53 の Hosted Zone にあるとします。
    • SSL 証明書も ACM で発行します。

Version

  • aws-cdk: 2.46.0

書き方

こんな感じで書きます。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";

import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";
import * as nodejs from "aws-cdk-lib/aws-lambda-nodejs";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as route53 from "aws-cdk-lib/aws-route53";
import * as targets from "aws-cdk-lib/aws-route53-targets";
import * as acm from "aws-cdk-lib/aws-certificatemanager";

const NAME = "MyAwesomeResource";

const domainName = "mydomain.example.com";
const cloudFrontHostName = "my-awesome-resource.mydomain.example.com";

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

    const { functionUrl, lambdaFunction } = this.createLambdaFunction();

    // get token for CloudFront Origin
    const lambdaHostName = cdk.Lazy.uncachedString({
      produce: (context) => {
        const resolved = context.resolve(functionUrl.url);
        return { "Fn::Select": [2, { "Fn::Split": ["/", resolved] }] } as any;
      },
    });

    this.createCloudFront(lambdaHostName);
  }

  // create lambda function
  createLambdaFunction(): {
    functionUrl: lambda.FunctionUrl;
    lambdaFunction: lambda.Function;
  } {
    // NodejsFunction
    const lambdaFunction = new nodejs.NodejsFunction(this, `${NAME}Lambda`, {
      functionName: `${NAME}-function`,
      entry: "src/lambda/index.ts", // lambda function source code
      handler: "handler",
      memorySize: 128,
      bundling: {
        minify: true,
        sourceMap: true,
        target: "es2020",
        externalModules: ["aws-sdk"],
      },
      runtime: lambda.Runtime.NODEJS_16_X,
    });

    // add function url
    const functionUrl = lambdaFunction.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE, // no auth
      cors: {
        allowedMethods: [lambda.HttpMethod.GET], // only GET
        allowedOrigins: ["*"], // allow all origins for CORS
      },
    });

    // add tags for lambda function
    cdk.Tags.of(lambdaFunction).add("Name", `${NAME}Lambda`);
    cdk.Tags.of(lambdaFunction).add("Project", NAME);

    // allow lambda function to get dynamodb items in tables that start with "my-awesome-resource."
    lambdaFunction.addToRolePolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ["dynamodb:GetItem"],
        resources: [
          `arn:aws:dynamodb:${this.region}:${this.account}:table/my-awesome-resource.*`,
        ],
      })
    );

    return { lambdaFunction, functionUrl };
  }

  createCloudFront(originHost: string) {
    // Hosted Zone
    const hostedZoneId = route53.HostedZone.fromLookup(this, "hostedZoneId", {
      domainName: domainName,
    });

    // SSL Certificate
    const certificateManagerCertificate = new acm.DnsValidatedCertificate(
      this,
      "CertificateManagerCertificate",
      {
        domainName: cloudFrontHostName,
        hostedZone: hostedZoneId,
        region: "us-east-1",
        validation: acm.CertificateValidation.fromDns(),
      }
    );

    // CloudFront
    const cloudFront = new cloudfront.CloudFrontWebDistribution(
      this,
      `${NAME}CloudFront`,
      {
        comment: `${NAME} CloudFront`,
        originConfigs: [
          {
            behaviors: [
              {
                isDefaultBehavior: true,
                allowedMethods:
                  cloudfront.CloudFrontAllowedMethods.GET_HEAD_OPTIONS, // only GET HEAD OPTIONS
                compress: true,
                forwardedValues: {
                  queryString: true, // forward query string
                  // no cookies
                  cookies: {
                    forward: "none",
                  },
                },
              },
            ],
            // Lambda Function URL
            customOriginSource: {
              domainName: originHost,
            },
          },
        ],
        // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PriceClass.html
        priceClass: cloudfront.PriceClass.PRICE_CLASS_200, // Price Class 200.
        viewerCertificate: cloudfront.ViewerCertificate.fromAcmCertificate(
          certificateManagerCertificate,
          {
            securityPolicy: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
            aliases: [cloudFrontHostName],
          }
        ),
      }
    );
    // add tags to CloudFront
    cdk.Tags.of(cloudFront).add("Name", `${NAME}CloudFront`);
    cdk.Tags.of(cloudFront).add("Project", NAME);

    // Route53
    new route53.ARecord(this, `${NAME}ARecord`, {
      recordName: cloudFrontHostName,
      zone: hostedZoneId,
      target: route53.RecordTarget.fromAlias(
        new targets.CloudFrontTarget(cloudFront)
      ),
    });
  }
}

さいごに

Lambda@Edge があるのであまりこういう構成は必要ないかもしれませんが、 部分的には参考になるコードもあると思うので、メモ代わりに残しておきます。