AWS CDK を使って、サーバレスに HTTP リダイレクトする環境を作る 3 つの方法

Posts

この記事では、AWS CDK を使って、サーバレスな HTTP リダイレクト環境を構築する 3 つの方法を説明する。
CDK の基本的な使い方は、次の記事で書いたのでここでは説明しない。
AWS CDK で CloudFront Function を使ったリダイレクト環境を構築する

CloudFront と関数を実行するサービスを組み合わせてリダイレクトを実現する

リダイレクトを設定する状況としては、Web サーバーの移転などドメインごと引っ越しすることも多いため、構築する環境には独自ドメイン(カスタムドメイン)を設定したい。
AWS が提供するサーバレスなサービスのうち、カスタムドメインと SSL 証明書を設定できるサービスには CloudFront と AWS API Gateway がある。

この記事では、CloudFront と、関数を実行するサービスを組み合わせた、次の 3 種類の構成をまとめる。
リダイレクトの場合 GET メソッドで処理すればよいため、複数の HTTP メソッドで異なる処理を実行できる API Gateway を使う必要はないからだ。

  • CloudFront + CloudFront function
  • CloudFront + AWS Lambda
  • CloudFront + Lambda@Edge

図:リダイレクトの3種類の構成を表している

動作を確認した環境

  • Node.js v18.12.1
  • AWS CDK v2.58.1

構成 1:CloudFront + CloudFront Functions

HTTP リダイレクトのような、ヘッダーを付与して返すだけの軽い処理には、この構成が一番向いている。

CDK のソースコード

lib/cf-function-example-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import {
  aws_s3 as s3,
  aws_s3_deployment as s3deploy,
  aws_cloudfront as cf,
  aws_cloudfront_origins as cfo,
  aws_iam as iam,
  aws_certificatemanager as acm,
} from "aws-cdk-lib";

interface CFFunctionExampleStackProps extends cdk.StackProps {
  env: {
    region: string;
  }
  certArn: string;
  customDomain: string;
}

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

    // CloudFront Function を使う場合は、オリジンに S3 バケットを指定する
    const bucket = new s3.Bucket(this, "S3Bucket", {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      versioned: false,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
    const identity = new cf.OriginAccessIdentity(this, "OriginAccessIdentity", {
      comment: `${bucket.bucketName} access identity`,
    });
    const bucketPolicyStatement = new iam.PolicyStatement({
      actions: ["s3:GetObject"],
      effect: iam.Effect.ALLOW,
      principals: [identity.grantPrincipal],
      resources: [`${bucket.bucketArn}/*`],
    });
    bucket.addToResourcePolicy(bucketPolicyStatement);

    // CloudFront Function を作成する
    const cfFunction = new cf.Function(this, "CloudFrontFunction", {
      code: cf.FunctionCode.fromFile({
        filePath: "src/index.js",
      }),
    });

    const distribution = new cf.Distribution(this, "CloudFrontDistribution", {
      comment: "cloudfront distribution",
      defaultRootObject: "index.html",
      defaultBehavior: {
        allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cf.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: cf.CachePolicy.CACHING_OPTIMIZED,
        viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        origin: new cfo.S3Origin(bucket, {
          originAccessIdentity: identity,
        }),
        // CloudFront Function を指定する
        functionAssociations: [
          {
            function: cfFunction,
            eventType: cf.FunctionEventType.VIEWER_REQUEST,
          },
        ],
      },
      priceClass: cf.PriceClass.PRICE_CLASS_100,
      certificate: acm.Certificate.fromCertificateArn(
        this,
        'CustomDomainCertificate',
        props.certArn
      ),
      domainNames: [props.customDomain],
    });

    new s3deploy.BucketDeployment(this, "BucketDeployment", {
      sources: [],
      destinationBucket: bucket,
      distribution: distribution,
      distributionPaths: ["/*"],
    });
  }
}
src/index.js
function handler(event) {
  var newUrl = `https://new.example.com`;
  var response = {
    statusCode: 302,
    statusDescription: "Moved Permanently",
    headers: { location: { value: newUrl } },
  };
  return response;
}
bin/cf-function-example.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { CFFunctioExampleStack } from '../lib/cf-function-example-stack';

const app = new cdk.App();
const REGION = "ap-northeast-1";
// ドメインの SSL 証明書の Arn を指定する
const CERT = "arn:aws:acm:us-east-1-1:123456789012:certificate/1234abcd-..."
// カスタムドメイン名を指定する
const DOMAIN = "old.example.com";

new CFFunctioExampleStack(app, 'CFFunctioExampleStack', {
  env: {
    region: REGION,
  },
  certArn: CERT,
  customDomain: DOMAIN,
});

Cloud Functions が向いていないケース

CloudFront Functions の最大関数サイズは 10KB であるため、JavaScript のソースコードが大きくなる場合はこの構成が難しくなる。
参照:CloudFront Functions に対する制限

たとえば、あるパスのときはそれに対応するパスへリダイレクトさせたいとする。
このようにリダイレクト先を切り替える場合、大量の URL マッピングが必要だと CloudFront Functions のサイズ上限に達してしまう。
その場合には、後述の別構成を検討することになる。

また、CloudFront Function はキャッシュを持たず、リクエストのたびに関数が実行される。
そのためキャッシュのヒット率が高い場合には、キャッシュヒットしていれば関数の処理コストがかからない、後述の構成の方が安く済むこともある。
参照:Lambda 関数URLを実戦投入してシンプルなリダイレクト処理を構築する(コスト編)

構成 2:CloudFront + AWS Lambda

CloudFront + AWS Lambda の構成では、CloudFront Function の代わりに、AWS Lambda で関数を実行する。
AWS Lambda の場合の最大関数サイズは 250MB なので、大量の URL マッピングにも耐えうる。

AWS Lambda Function URLs(Lambda 関数 URL)にはカスタムドメインを設定できないため、Lambda 単独では、この記事のユースケースに対応できない。
そのため、Lambda 関数 URL をオリジンに指定した CloudFront を配置する必要がある。

CDK のソースコード

lib/lambda-example-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import {
  aws_lambda as lambda,
  aws_iam as iam,
  aws_cloudfront as cf,
  aws_cloudfront_origins as cfo,
  aws_certificatemanager as acm,
  Fn,
} from "aws-cdk-lib";

interface LambdaExampleStackProps extends cdk.StackProps {
  env: {
    region: string;
  },
  certArn: string;
  customDomain: string;
}

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

    const executionRole = new iam.Role(this, "secureLambdaRole", {
      roleName: "lambdaSecureExecutionRole",
      assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AWSLambdaBasicExecutionRole"
        ),
        iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMReadOnlyAccess"),
      ],
    });

    const lambdaFunction = new lambda.Function(this, "Redirect", {
      functionName: "redirect-lambda-function",
      runtime: lambda.Runtime.NODEJS_18_X,
      code: lambda.AssetCode.fromAsset("src"),
      handler: "index.handler",
      timeout: cdk.Duration.seconds(300),
      role: executionRole,
      environment: {
        TZ: "Asia/Tokyo",
      },
    });

    // Lambda 関数 URL を発行する
    const lambdaUrl = lambdaFunction.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE,
    });

    new cf.Distribution(this, "CloudFrontDistribution", {
      comment: "cloudfront distribution",
      defaultBehavior: {
        allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cf.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: cf.CachePolicy.CACHING_OPTIMIZED,
        viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        // CloudFront のオリジンに、発行した Lambda 関数 URL を指定する
        origin: new cfo.HttpOrigin(Fn.select(2, Fn.split('/', lambdaUrl.url)))
      },
      priceClass: cf.PriceClass.PRICE_CLASS_100,
      certificate: acm.Certificate.fromCertificateArn(
        this,
        'CustomDomainCertificate',
        props.certArn
      ),
      domainNames: [props.customDomain],
    });
  }
}
src/index.js
exports.handler = async (_event) => {
  const newUrl = "https://new.example.com";
  const response = {
    statusCode: 302,
    statusDescription: "Moved Permanently",
    headers: {
      Location: newUrl,
    },
  };
  return response;
};
bin/lambda-example.js
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { LambdaExampleStack } from '../lib/lambda-example-stack';

const app = new cdk.App();
const REGION = "ap-northeast-1";
// ドメインの SSL 証明書の Arn を指定する
const CERT = "arn:aws:acm:us-east-1-1:123456789012:certificate/1234abcd-..."
// カスタムドメイン名を指定する
const DOMAIN = "old.example.com";

new LambdaExampleStack(app, 'LambdaExampleStack', {
  env: {
    region: REGION,
  },
  certArn: CERT,
  customDomain: DOMAIN,
});

構成 3:CloudFront + AWS Lambda@Edge acm

AWS Lambda@Edge は、CloudFront のエッジロケーションからコードを実行する Lambda 関数である。
他の 2 つの構成に比べ、アクセス数がとても大きくなるような場合は + Lambda@Edge の構成のほうが低コストになることがあるようだった。
参照:CloudFront FunctionsはLambda@Edgeより安い。それ本当?!

CDK のソースコード

lib/lambda-edge-example-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import {
  aws_s3 as s3,
  aws_cloudfront as cf,
  aws_lambda as lambda,
  aws_cloudfront_origins as cfo,
  aws_iam as iam,
  aws_certificatemanager as acm,
} from "aws-cdk-lib";

interface LambdaEdgeExampleStackProps extends cdk.StackProps {
  env: {
    region: string;
  },
  certArn: string;
  customDomain: string;
}

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

    // Lambda@Edge を使う場合は、オリジンに S3 バケットを指定する
    const bucket = new s3.Bucket(this, "S3Bucket", {
      versioned: false,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
    const identity = new cf.OriginAccessIdentity(this, "OriginAccessIdentity", {
      comment: `${bucket.bucketName} access identity`,
    });
    const bucketPolicyStatement = new iam.PolicyStatement({
      actions: ["s3:GetObject"],
      effect: iam.Effect.ALLOW,
      principals: [identity.grantPrincipal],
      resources: [`${bucket.bucketArn}/*`],
    });
    bucket.addToResourcePolicy(bucketPolicyStatement);

    // Lambda@Edge 関数 を作成する
    const edgeFunction = new cf.experimental.EdgeFunction(this, "LambdaEdge", {
      code: lambda.Code.fromAsset("src"),
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_18_X,

    });

    new cf.Distribution(this, "CloudFrontDistribution", {
      comment: "cloudfront distribution",
      defaultBehavior: {
        allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cf.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: cf.CachePolicy.CACHING_OPTIMIZED,
        viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        origin: new cfo.S3Origin(bucket, {
          originAccessIdentity: identity,
        }),
        // Lambda@Edge Function を指定する
        edgeLambdas: [
          {
            eventType: cf.LambdaEdgeEventType.ORIGIN_REQUEST,
            functionVersion: edgeFunction.currentVersion,
          },
        ],
      },
      priceClass: cf.PriceClass.PRICE_CLASS_100,
      certificate: acm.Certificate.fromCertificateArn(
        this,
        'CustomDomainCertificate',
        props.certArn
      ),
      domainNames: [props.customDomain],
    });
  }
}
src/index.js
exports.handler = async (_event) => {
  const newUrl = "https://new.example.com";
  const response = {
    statusCode: 302,
    statusDescription: "Moved Permanently",
    headers: {
      location: [
        {
          key: "Location",
          value: newUrl,
        },
      ],
    },
  };
  return response;
};
bin/lambda-edge-example.ts
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { LambdaEdgeExampleStack } from "../lib/lambda-edge-example-stack";

const REGION = "us-east-1";
// ドメインの SSL 証明書の Arn を指定する
const CERT = "arn:aws:acm:us-east-1-1:123456789012:certificate/1234abcd-..."
// カスタムドメイン名を指定する
const DOMAIN = "old.example.com";

const app = new cdk.App();
new LambdaEdgeExampleStack(app, "LambdaEdgeExampleStack", {
  env: {
    region: REGION,
  },
  certArn: CERT,
  customDomain: DOMAIN,
});

おまけ:Serverless Framework v3 を使って、CloudFront + Lambda の環境を作る

AWS Lambda の場合、AWS CDK ではなく Serverless Framework を使った構築もできる。
CloudFront と組み合わせる場合は serverless-api-cloudfront というプラグインもあるが、Serverless Framework v3 には対応していなかった。
そのため CloudFront を構築するための CloudFormation テンプレートを resources プロパティに記述する。

Serverless Framework で、Lambda 関数 URL を使う場合のポイントは、次の 2 つ。

  • Lambda 関数 URL を発行する
    functions.<function name>.urltrue にする
    functions:
      api:
        handler: src/index.handler
        url: true
    
  • CloudFront のオリジンに、発行した Lambda 関数 URL を指定する
    Lambda Function URL の Arn を自動で取得するため、CloudFormation 組み込み関数を使う。
    DomainName: !Select [2, !Split ["/", !GetAtt ApiLambdaFunctionUrl.FunctionUrl]]
    

Serverless の設定ファイルの詳細は、次のとおり。
Lambda 関数の中身は、構成 2src/index.js と同じなので割愛する。

serverless.yml
service: example
frameworkVersion: '3'

provider:
  name: aws
  runtime: nodejs18.x
  region: ap-northeast-1

functions:
  api:
    handler: src/index.handler
    url: true

resources:
  Resources:
    DistributionForLambda:
      Type: AWS::CloudFront::Distribution
      Properties:
        DistributionConfig:
          Enabled: true
          Comment: ""
          HttpVersion: http2
          PriceClass: PriceClass_100
          Origins:
            -
              Id: LambdaFunctionURL
              DomainName: !Select [2, !Split ["/", !GetAtt ApiLambdaFunctionUrl.FunctionUrl]]
              OriginPath: ""
              CustomOriginConfig:
                OriginProtocolPolicy: https-only
                OriginSSLProtocols:
                  - "TLSv1.2"
          DefaultCacheBehavior:
            TargetOriginId: LambdaFunctionURL
            AllowedMethods:
              - GET
              - HEAD
            CachedMethods:
              - GET
              - HEAD
            ForwardedValues:
              QueryString: false
              Headers:
                - Accept
              Cookies:
                Forward: none
            ViewerProtocolPolicy: redirect-to-https