michimani.net

Lambda Function URLs でカスタムドメインを使う構成を AWS CDK v2 (Go) で構築する

2023-02-16

Lambda 関数に対して URL を発行して外部から HTTP で実行できるようにする Function URLs ですが、そのままだと自動で発行されるドメインに対してリクエストを送ることになります。今回は、任意のドメインで Function URLs を利用する構成を AWS CDK を使って構築してみた話です。

構成については構成図を見ればわかるので、記事の内容としては AWS CDK での実装にコメントを足していくような感じになります。

まえがき

タイトルにもある通り、 Lambda Function URLs をカスタムドメインで使う構成を AWS CDK v2 (Go) で構築してみます。

Function URLs を使って Lambda 関数に対して URL を発行すると、ランダムな英数字を含んだ下記のような URL が発行されます。

https://{ランダムな英数字}.lambda-url.{リージョン}.on.aws/

今回は、 前段に CloudFront を配置することで任意のドメインで Function URLs による Lambda 関数の実行ができるようにします。

ソースコードは下記に置いてあるので、お手元でも試していただけます。

aws-cdk-go-examples/lambda-function-urls-with-custom-domain at main · michimani/aws-cdk-go-examples

構成図

構成図としては下記のようになります。

C4Component Container_Boundary(r53, "Route 53") { Component(h, "HostedZone", "hosted zone","imported by HostedZoneID") Component(r, "RecordSet", "custom domain record","e.g) api.example.com -> xxxxxx.cloudfront.net") Rel(r, h, "") } Container_Boundary(acm, "Certificate Manager") { Component(c, "Certificate", "SSL Certificate", "imported by ARN") } Container_Boundary(cf,"CloudFront") { Component(oap, "Origin Access Policy", "", "") Component(dist, "Distribution", "", "") Component(cp, "Cache Policy", "", "") Rel_L(dist, cp, "", "") Rel_L(dist, oap,"", "") } Container_Boundary(lambda, "Lambda") { Component(url1, "Function URL", "", "for default behavior") Component(url2, "Function URL", "", "for hello behavior") Component(url3, "Function URL", "", "for bye behavior") Component(fn1, "Function", "", "simple-response-default") Component(fn2, "Function", "", "simple-response-hello") Component(fn3, "Function", "", "simple-response-bye") Rel(url1, fn1, "","") Rel(url2, fn2, "","") Rel(url3, fn3, "","") } Rel_D(dist, c, "", "") Rel_D(r, dist, "", "") Rel_D(dist, url1, "","default") Rel_D(dist, url2, "","/hello pattern") Rel_D(dist, url3, "","/bye pattern") UpdateElementStyle(h, $bgColor="grey") UpdateElementStyle(c, $bgColor="grey") UpdateLayoutConfig($c4ShapeInRow="3", $c4BoundaryInRow="1")

Lambda 関数を 3 つ用意してそれぞれに Function URL を発行、CloudFront の Behavior でそれぞれの Function URL にリクエストが分配されるようにします。

背景が水色になっているリソースを AWS CDK で構築します。灰色になっている Route53:HostedZoneCertificateManager:Certificate は、あらかじめ作成されているものを HostedZoneID と ZoneName および ARN でそれぞれインポートして利用します。

AWS CDK で作成するリソース

今回は Route 53, Certificate Manager, CloudFront, Lambda に関連するリソースを作成します。

Route 53

Route 53 関連では、 任意のドメインから CloudFront のドメインに対する A レコードとなる RecordSet を作成します。その際、 RecordSet を紐付かせる HostedZone が必要になりますが、これについては既に存在している HostedZone を HostedZoneID と ZoneName をもとにしてインポートします。

hostedZone := awsroute53.HostedZone_FromHostedZoneAttributes(scope, jsii.String("MyHostedZone"), &awsroute53.HostedZoneAttributes{
  HostedZoneId: &hostedZoneID,
  ZoneName:     &zoneName,
})

インポートした HostedZone を使って、 CloudFront に対する A レコードを作成します。 CloudFront に対する A レコードのターゲットについては awsroute53targets.NewCloudFrontTarget() を使って作成できます。

// scope     constructs.Construct
// dist      awscloudfront.Distribution
// subDomain string  // このドメインで Lambda 関数を実行できるようにする

aliasTarget := awsroute53.RecordTarget_FromAlias(
  awsroute53targets.NewCloudFrontTarget(dist),
)

props := &awsroute53.RecordSetProps{
  RecordName: jsii.String(subDomain),
  RecordType: awsroute53.RecordType_A,
  Zone:       hostedZone,
  Target:     aliasTarget,
}

awsroute53.NewRecordSet(scope, jsii.String("RecordSet"), props)

Certificate Manager

Certificate Manager については、既に存在している Certificate を ARN でインポートして利用します。

ceritificate := awscertificatemanager.Certificate_FromCertificateArn(
  stack, 
  jsii.String("ImportedCertificate"),
  jsii.String("arn:aws:acm:us-east-1:000000000000:certificate/hoge"))

CloudFront

CloudFront 関連では、 CachePolicy, OriginRequestPolicy, Distribution のリソースを作成します。 それぞれ特に難しいところはない1 のですが、 Distribution のカスタムオリジンとして Function URL の ドメイン をしていするところで小細工が必要でした。 Distribution のカスタムオリジンに Function URL を利用する場合、設定するのは URL ではなく ドメイン部分のみ となります。

CloudFront ディストリビューションのオリジンとして Lambda 関数 URL を使用するには、オリジンドメインとして Lambda 関数 URL の完全なドメイン名を指定します。

CloudFront ディストリビューションでのさまざまなオリジンの使用 - Amazon CloudFront

AWS CDK において Function URL については awslambda.FunctionUrl.Url() で参照できるのですが、 Distribution と同じ scope 内で FunctionURL を作成している場合には DistributionProps 内で利用する時点ではまだ値が確定していません。なので、 strings.Replace などを使って https:// を取り除くことができません。そのため、 CloudFormation の Fn を利用して URL からドメイン部分のみを抜き出す必要があります。具体的には、下記のような関数を用意して加工しました。

var slash string = "/"

func functionURLDomain(furl awslambda.FunctionUrl) *string {
	// function url format: https://hoge.lambda-url.ap-northeast-1.on.aws/
	// split: ["https:", "", "hoge.lambda-url.ap-northeast-1.on.aws", ""]
	splitURL := awscdk.Fn_Split(&slash, furl.Url(), jsii.Number(4))
	return (*splitURL)[2]
}

AWS CDK には CloudFormation の Fn を再現するための関数が要されており、今回は文字列を分割する Fn_Split を使っています。

このようにすることで、 cdk synth を実行した際の CFn テンプレートは下記のような形で生成されます。

AWSCDKGoExampleFunctionURLFunctionCFDistribution6BF356B9:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Origins:
          - ConnectionAttempts: 1
            ConnectionTimeout: 5
            CustomOriginConfig:
              OriginProtocolPolicy: https-only
              OriginSSLProtocols:
                - TLSv1.2
            DomainName:
              Fn::Select:
                - 2
                - Fn::Split:
                    - /
                    - Fn::GetAtt:
                        - AwsCdkGoExampleSimpleResponseDefaultUrlB01107C8
                        - FunctionUrl
            Id: LambdaFunctionUrlsWithCustomDomainStackAWSCDKGoExampleFunctionURLFunctionCFDistributionOrigin181CAA1CC
            OriginCustomHeaders:
              - HeaderName: x-aws-cdk-go-example-from
                HeaderValue: aws-cdk-go-example-cf

また、任意の Origin Custom Header を設定しておくことで、後述する Lambda 関数の実装にて直接 Function URL の URL を叩かれたときの対策とすることができます。今回は x-aws-cdk-go-example-from というヘッダに aws-cdk-go-example-cf という値を設定するようにしています。

customHeaderForFunction := map[string]*string{
  "x-aws-cdk-go-example-from": jsii.String("aws-cdk-go-example-cf"),
}

props := &awscloudfront.DistributionProps{
  Enabled: jsii.Bool(true),
  DefaultBehavior: &awscloudfront.BehaviorOptions{
    CachePolicy:         cachePolicy,
    OriginRequestPolicy: originRequestPolicy,
    Origin: awscloudfrontorigins.NewHttpOrigin(defaultFnURLDomain, &awscloudfrontorigins.HttpOriginProps{
      ConnectionAttempts: jsii.Number(1),
      ConnectionTimeout:  awscdk.Duration_Seconds(jsii.Number(5)),
      CustomHeaders:      &customHeaderForFunction,
      ProtocolPolicy:     awscloudfront.OriginProtocolPolicy_HTTPS_ONLY,
      OriginSslProtocols: &[]awscloudfront.OriginSslPolicy{
        awscloudfront.OriginSslPolicy("TLS_V1_2"),
      },
    }),
    ViewerProtocolPolicy: awscloudfront.ViewerProtocolPolicy_HTTPS_ONLY,
  },
  HttpVersion:   awscloudfront.HttpVersion_HTTP2,
  PriceClass:    awscloudfront.PriceClass_PRICE_CLASS_200,
  EnableLogging: jsii.Bool(false),
}

aws-cdk-go-examples/cloudfront.go at main · michimani/aws-cdk-go-examples

Lambda

Lambda 関連では Function と Function URL のリソースを作成しますが、それぞれ特筆するべき点はないのでリソースの定義については割愛します。一方で、 Lambda 関数の実装については工夫すべき点がありました。 CloudFront のところでも書きましたが、今回は Lambda 関数をFunction URL ではなく任意のドメインで実行できるようにするため、 Function URL を直接叩かれたときにはエラーにしたいです。これを実現するためには、 Lambda 関数内で CloudFront からのリクエスト時に付与されるカスタムヘッダを検査します。

func handleRequest(ctx context.Context, httpRequest events.APIGatewayProxyRequest) (response, error) {
	lctx, ok := lambdacontext.FromContext(ctx)
	if !ok {
		return jsonResponse(http.StatusInternalServerError, errorBody{Error: "failed to parse lambda context"}, nil)
	}

	if !isAvailableAccess(httpRequest) {
		return jsonResponse(http.StatusForbidden, errorBody{Error: "forbidden"}, nil)
	}

	body := okBody{
		RequestID: lctx.AwsRequestID,
		Message:   message,
		Time:      time.Now().Format(time.RFC3339Nano),
	}

	customHeader := map[string]string{
		"x-aws-cdk-example": "lambda-function-urls-with-custom-domain",
	}

	return jsonResponse(http.StatusOK, body, customHeader)
}

func isAvailableAccess(req events.APIGatewayProxyRequest) bool {
	// invoke from specified CloudFront
	if h, ok := req.Headers[customHeaderKeyFromCloudFront]; !ok {
		fmt.Printf("custom header %s does not exists. req:%v", customHeaderKeyFromCloudFront, req)
		return false
	} else if h != customHeaderValueFromCloudFront {
		fmt.Printf("custom header value is invalid. req:%v", req)
		return false
	}

	return true
}

ハンドラ関数で events.APIGatewayProxyRequest を受け取るようにしておくことで、リクエストヘッダを検査することができます。

上記の実装であれば、 Function URL を直接叩かれたとき (= 特定のリクエストヘッダが含まれないとき) は 403 のレスポンスが返ることになります。

aws-cdk-go-examples/main.go at main · michimani/aws-cdk-go-examples

まとめ

Lambda Function URL を任意のドメインで実行できるようにする構成を AWS CDK v2 (Golang) で構成してみた話でした。
Function URL が発表されたときは 「カスタムドメイン対応してないのかー」と思っていましたが、前段に CloudFront を置けば実現可能であるということがわかりました。

前段に CloudFront を置くということは、キャッシュの恩恵を受けられるということになります。また、 WAF を利用することで DDoS 攻撃や悪意のある bot 等からのアクセスについても対策することができます。

今回のように新たに CloudFront を用意するところからとなるとちょっと大変ですが、既に CloudFront を利用したアプリケーションがある状態であれば Behavior の追加だけで実現できることになるので、サーバサイドの実装を Lambda で作って API として使うというのがサクッとできてしまいそうです。


  1. DistributionProps の構造体がデカすぎる問題はありますが、これは CloudFront 扱う上での宿命なので諦めます ↩︎


comments powered by Disqus