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