michimani.net

CloudFront Functions を AWS CLI で触る ― ついでにブログの URL 正規化を Lambda@Edge から移行した

2021-05-04

CloudFront Functions という新しい機能がリリースされたので AWS CLI で触ってみます。ついでに、 Lambda@Edge でやっているこのブログの URL 正規化処理を CloudFront Functions に移行してみました。

目次

CloudFront Functions とは

Amazon CloudFront announces the general availability of CloudFront Functions, a new serverless edge compute capability. You can use this new CloudFront feature to run JavaScript functions across 225+ CloudFront edge locations in 90 cities across 47 countries. CloudFront Functions is built for lightweight HTTP(S) transformations and manipulations, allowing you to deliver richer, more personalized content with low latency to your customers.

Amazon CloudFront announces CloudFront Functions, a lightweight edge compute capability

CloudFront のエッジロケーションで、 JavaScript で実装された関数を実行できるというものです。

CloudFront には似たような機能として Lambda@Edge というのがありますが、本当にざっくりとしたイメージでは同じような機能だという認識です。が、もちろん違いはあり、詳細については既にクラメソさんの記事で解説されています。

エッジで爆速コード実行!CloudFront Functionsがリリースされました! | DevelopersIO

ちなみに、今回 触る範囲で気になる差は下記の部分です。

CloudFront Functions Lambda@Edge
ランタイム JavaScript Node.js, Python
最大パッケージサイズ 10 KB 1 MB1
最大メモリ 2 MB 128 MB2
最大実行時間 1ms 未満 5 秒3

CloudFront Functions は、略して CF2 と呼ぶようです。

Announcing CloudFront Functions (CF2), a new serverless edge compute capability for lightweight customizations. CF2 runs custom Javascript code at all of CloudFront’s 225+ edge locations with minimal latency and will cost ~1/6th that of Lambda@Edge.. https://t.co/tlD5dvwpmj

— Amazon CloudFront (@cloudfront) May 3, 2021

Lambda@Edge でやってたことをそのままやる

今回やるのは、 このブログに対して既に Lambda@Edge でやっている URL の正規化を、 CloudFront Functions に移行します。

Lambda@Edge での正規化については下記の記事で書いています。

Lambda@Edge で静的サイトの URL を正規化する - michimani.net

Viewer Request をトリガーに、 / 無しのリクエストを / にリダイレクトしたり、 / でのリクエストに /index..html を補完してオリジンにリクエストを流したり、ということをやっています。

CloudFront Functions の要件に合うのか

前述したとおり Lambda@Edge と CloudFront Functions とでは違いがいくつかあるので、そこをクリアできるか確認しておきます。

ランタイム

これは同じ処理を JavaScript で書き換えればよいだけなので、作業は発生するもの移行は可能です。ちなみに今回対象となっている Lambda@Edge は Node.js で実装しているものなので、大きな変更はなさそうです。

最大パッケージサイズ

Lambda@Edge で使っている Lambda 関数のサイズは 991.0 byte なので、 10 KB 以内に収まっています。

最大メモリ

これはどうしようもないので 2 MB でがんばります。

最大実行時間

ここが一番の問題です。
CloudFront Functions では最大実行時間が 1ms 未満 ということで、本当に軽い処理の実行しかできなさそうです。とはいえ、 URL の正規化程度であれば実行できそうですが、念のため現状の Lambda@Edge の処理にどれくらいかかっているのか確認しておきます。ちなみに Lambda 関数に割り当てられているメモリは 128 MB です。

確認方法としては、 Lambda 関数の実行ログから REPORT 内の DurationBilled Duration の差をみます。

$ aws logs get-log-events \
--log-group-name ${LOG_GP_NAME} \
--log-stream-name "${LOG_ST_NAME}" \
--query "events[?contains(message, \`REPORT\`)].message" \
--output text

  REPORT RequestId: 889e0e28-4980-48f3-a134-8327666ccc97	Duration: 6.44 ms	Billed Duration: 7 ms	Memory Size: 128 MB	Max Memory Used: 63 MB	Init Duration: 164.61 ms
	REPORT RequestId: d2e219b5-757a-487f-aa3e-148db76e1292	Duration: 113.00 ms	Billed Duration: 113 ms	Memory Size: 128 MB	Max Memory Used: 64 MB
	REPORT RequestId: 9005ebdf-0783-4577-b45a-426db365a5a4	Duration: 128.46 ms	Billed Duration: 129 ms	Memory Size: 128 MB	Max Memory Used: 65 MB
	REPORT RequestId: e0facf39-87a9-4fb6-a2a5-98example2dc6	Duration: 1.35 ms	Billed Duration: 2 ms	Memory Size: 128 MB	Max Memory Used: 65 MB
	REPORT RequestId: f12d9777-142d-4d07-8aa9-6eexample2ebb	Duration: 5.51 ms	Billed Duration: 6 ms	Memory Size: 128 MB	Max Memory Used: 65 MB
	REPORT RequestId: ad884280-69a2-4784-9883-6aexample6a0c	Duration: 1.36 ms	Billed Duration: 2 ms	Memory Size: 128 MB	Max Memory Used: 65 MB
	REPORT RequestId: 93c493a1-f07d-405f-8702-14examplee3d3	Duration: 1.33 ms	Billed Duration: 2 ms	Memory Size: 128 MB	Max Memory Used: 65 MB
	REPORT RequestId: 280d7b33-9cee-4aa4-89f7-afexamplebf8e	Duration: 1.30 ms	Billed Duration: 2 ms	Memory Size: 128 MB	Max Memory Used: 65 MB
	REPORT RequestId: 26d94ea2-90fe-470b-8140-6bexample9432	Duration: 1.16 ms	Billed Duration: 2 ms	Memory Size: 128 MB	Max Memory Used: 65 MB
	REPORT RequestId: 2691e2c7-14e2-45f4-bd48-02examplee3de	Duration: 1.15 ms	Billed Duration: 2 ms	Memory Size: 128 MB	Max Memory Used: 65 MB
	REPORT RequestId: e42686f5-5d52-4fdf-906a-40example24b2	Duration: 1.10 ms	Billed Duration: 2 ms	Memory Size: 128 MB	Max Memory Used: 65 MB

シェルスクリプト力が無いので出力結果をスプレッドシートに貼り付けて確認してみると、最大で 0.9 ms くらい、平均では 0.7334 ms ということがわかりました。実行時間的には問題なさそうですが、ログからも分かる通り毎回 65 MB くらいメモリを使ってのこの結果です。 CloudFront Functions の 2 MB でいけるかどうかは、 Functions のテストを実施したタイミングでわかります。

AWS CLI でやってみる

AWS CLI では、下記の 8 個のサブコマンドが追加されています。 なお、この記事を書いている時点 (2021/05/04) では v1 の最新バージョン 1.19.64 でのみ対応しており、 v2 の最新バージョン 2.2.1 ではまだ使えません。
v1 では 1.19.64 以降で、 v2 では 2.2.2 以降で利用可能になっています。

やることとしては、 CloudFront Functions 用のスクリプトを作って、 Function を作って、テストして、公開して、ディストリビューションと紐付ける、です。

CloudFront Functions 用のスクリプトを作成

CloudFront Functions の Function の実装方法については、下記 CloudFront のドキュメントを参考にします。

Writing function code (CloudFront Functions programming model) - Amazon CloudFront

書き方としては、 event を受け取る関数 handler を実装します。

function handler(event) {
  var request = event.request;
  var uri = request.uri;

  /**
    いろいろやる
  */
  
  // リダイレクトさせたい場合
  if (hoge) {
    var response = {
      statusCode: 302,
      statusDescription: 'Found',
      headers: {
        "location": {
          "value": redirectUrl
        }
      }
    }

    return response;
  }

  return request;
}

今回は Viewer Request をトリガーに実行するので、 return する先はオリジン (S3) となります。 ただし、リクエストに location ヘッダーを付与することで、オリジンに到達する前にリダイレクトされることになります。ここは Lambda@Edge と同じです。

今回やりたいのは下記のとおりです。

/ ありが正規の URL としたいため、 301 (恒久的な) リダイレクトとしています。

function handler(event) {
  var host = 'https://michimani.net';
  var request = event.request;
  var requestUri = request.uri;

  // トップページへのリクエストに対しては何もしない
  if (requestUri == '' || requestUri == '/') {
    return request;
  }

  // `/` 無し または `index.html` 付きは `/` にリダイレクトする
  if (requestUri.match(/((page|post|posts|tags|categories|archives|about)(\/.*[^\/])?|\/index\.html)$/)) {
    var redirectUrl = host + requestUri.replace('/index.html', '') + '/';
    var response = {
      statusCode: 301,
      statusDescription: 'Found',
      headers: {
        location: {
            value: redirectUrl
        }
      }
    }

    // Viewer に対してレスポンスを返す
    return response;
  }

  // オリジンに対しては `/index.html` を補完してリクエストする
  var actualUri = requestUri.replace(/\/$/, '\/index.html');
  request.uri = actualUri;

  // オリジンへリクエスト
  return request;
}

これを cf2-redirect.js として保存しておきます。

Optimizing request URI for CloudFront with CloudFront Functions (CF2) that has HUGO site that deployed to a S3 bucket as origin. | GitHub gist

Function を作成

Function の作成には cloudfront reate-function コマンドを使います。

$ aws cloudfront create-function help
...
SYNOPSIS
            create-function
          --name <value>
          --function-config <value>
          --function-code <value>
          [--cli-input-json <value>]
          [--generate-cli-skeleton <value>]

--function-config では CommentRuntime を指定しますが、現時点で Runtime に指定できるのは cloudfront-js-1.0 のみです。

先ほど作成したスクリプトをもとに、 Function を作成します。

$ aws cloudfront create-function \
--name cf2-redirect \
--function-config Comment="Redirect Function",Runtime=cloudfront-js-1.0 \
--function-code file://cf2-redirect.js

{
    "Location": "https://cloudfront.amazonaws.com/2020-05-31/function/arn:aws:cloudfront::XXXXXXXXXXXX:function/cf2-redirect",
    "ETag": "ETVEXAMPLE",
    "FunctionSummary": {
        "Name": "cf2-redirect",
        "Status": "UNPUBLISHED",
        "FunctionConfig": {
            "Comment": "Redirect Function",
            "Runtime": "cloudfront-js-1.0"
        },
        "FunctionMetadata": {
            "FunctionARN": "arn:aws:cloudfront::XXXXXXXXXXXX:function/cf2-redirect",
            "Stage": "DEVELOPMENT",
            "CreatedTime": "2021-05-04T14:26:41.142Z",
            "LastModifiedTime": "2021-05-04T14:26:41.142Z"
        }
    }
}

ステータスが UNPUBLISHED 、 ステージは DEVELOPMENT になっています。

describe-function で確認してみます。

$ aws cloudfront describe-function \
--name cf2-redirect

{
    "ETag": "ETVEXAMPLE",
    "FunctionSummary": {
        "Name": "cf2-redirect",
        "Status": "UNPUBLISHED",
        "FunctionConfig": {
            "Comment": "Redirect Function",
            "Runtime": "cloudfront-js-1.0"
        },
        "FunctionMetadata": {
            "FunctionARN": "arn:aws:cloudfront::XXXXXXXXXXXX:function/cf2-redirect",
            "Stage": "DEVELOPMENT",
            "CreatedTime": "2021-05-04T14:26:41.142Z",
            "LastModifiedTime": "2021-05-04T14:26:41.185Z"
        }
    }
}

ちなみに get-function では、指定した Function のコードを ouput として取得できます。

Function をテスト

作成した Function をテストしてみます。テストするには cloudfront test-function コマンドを使います。

$ aws cloudfront test-function help
...
SYNOPSIS
            test-function
          --name <value>
          --if-match <value>
          [--stage <value>]
          --event-object <value>
          [--cli-input-json <value>]
          [--generate-cli-skeleton <value>]

--if-match には、対象の Function の ETag の値を指定します。(describe-function で確認)

--event-object については、下記 CloudFront のドキュメントを参考にして、次のような JSON オブジェクトを作成して event-object.json として保存しておきます。

{
  "version": "1.0",
  "context": {
    "distributionDomainName": "test.cloudfront.net",
    "distributionId": "TESTDISTID",
    "eventType": "viewer-request",
    "requestId": "TEST-REQUEST-ID"
  },
  "viewer": {
    "ip": "111.111.111.111"
  },
  "request": {
    "method": "GET",
    "uri": "/categories/aws",
    "headers": {
      "host": {
        "value": "test.example.com"
      },
      "user-agent": {
        "value": "test user agent0"
      }
    }
  }
}

CloudFront Functions event structure - Amazon CloudFront

上記のイベントでは https://michimani.net/category/aws へのリクエストをテストするので、 / ありへのリダイレクトレスポンスが取得できれば OK です。

$ CFF_NAME="cf2-redirect"
$ aws cloudfront test-function \
--name "${CFF_NAME}" \
--if-match $( \
  aws cloudfront describe-function \
  --name "${CFF_NAME}" \
  --query "ETag" \
  --output text) \
--event-object file://event-object.json

{
    "TestResult": {
        "FunctionSummary": {
            "Name": "cf2-redirect",
            "Status": "UNPUBLISHED",
            "FunctionConfig": {
                "Comment": "Redirect Function",
                "Runtime": "cloudfront-js-1.0"
            },
            "FunctionMetadata": {
                "FunctionARN": "arn:aws:cloudfront::XXXXXXXXXXXX:function/cf2-redirect",
                "Stage": "DEVELOPMENT",
                "CreatedTime": "2021-05-04T14:26:41.142Z",
                "LastModifiedTime": "2021-05-04T15:28:21.410Z"
            }
        },
        "ComputeUtilization": "35",
        "FunctionExecutionLogs": [],
        "FunctionErrorMessage": "",
        "FunctionOutput": "{\"response\":{\"headers\":{\"location\":{\"value\":\"https://michimani.net/categories/aws/\"}},\"statusDescription\":\"Found\",\"cookies\":{},\"statusCode\":301}}"
    }
}

FunctionOutput を見てみると、 301 リダイレクト用のレスポンスが返っているのがわかります。また、 ComputeUtilization の値が 35 ということで、これは実行時間が 1ms 未満であることを示しています。

It also shows the compute utilization, which is a number between 0 and 100 that indicates the amount of time that the function took to run as a percentage of the maximum allowed time. For example, a compute utilization of 35 means that the function completed in 35% of the maximum allowed time.

Testing functions - Amazon CloudFront

Function を Publish

Function のテストができたら、 cloudfront publish-function コマンドで publish します。

$ aws cloudfront publish-function help
...
SYNOPSIS
            publish-function
          --name <value>
          --if-match <value>
          [--cli-input-json <value>]
          [--generate-cli-skeleton <value>]

必要なのは Function の名前と ETag なので、下記のように実行します。

$ aws cloudfront publish-function \
--name "${CFF_NAME}" \
--if-match $( \
  aws cloudfront describe-function \
  --name "${CFF_NAME}" \
  --query "ETag" \
  --output text)
  
{
    "FunctionSummary": {
        "Name": "cf2-redirect",
        "Status": "UNASSOCIATED",
        "FunctionConfig": {
            "Comment": "Redirect Function",
            "Runtime": "cloudfront-js-1.0"
        },
        "FunctionMetadata": {
            "FunctionARN": "arn:aws:cloudfront::XXXXXXXXXXXX:function/cf2-redirect",
            "Stage": "LIVE",
            "CreatedTime": "2021-05-04T16:05:34.967Z",
            "LastModifiedTime": "2021-05-04T16:05:34.967Z"
        }
    }
}

Distribution との紐付け

Distribution との紐付けは CloudFront Functions 関連の API ではなく CloudFront の update-distribution で行います。 マネジメントコンソール上では CloudFront Functions の画面の Associate タブから実行できます。

update-distribution を行うには DistributionConfig が必要になるので、既存の DistributionConfig を get-distribution で取得します。

$ CF_DIST_ID="YOURDISTID"
$ aws cloudfront get-distribution \
--id ${CF_DIST_ID} \
| jq ".Distribution.DistributionConfig" \
> distribution-config.json

そして、 DistributionConfig の LambdaFunctionAssociations を削除して、代わりに FunctionAssociations の部分を下記のように記述します。

{
  "FunctionAssociations": {
    "Quantity": 1,
      "Items": [
        {
          "FunctionARN": "<CloudFront Function の ARN>",
          "EventType": "viewer-request"
        }
      ]
  }
}

Function の ARN は下記コマンドで取得します。

$ aws cloudfront describe-function \
--name "${CFF_NAME}" \
--query "FunctionSummary.FunctionMetadata.FunctionARN" \
--output text

更新前後の差分は下記のようになります。

--- distribution-config.json	2021-05-05 00:45:35.000000000 +0900
+++ distribution-config-update.json	2021-05-05 00:59:53.000000000 +0900
@@ -59,18 +59,18 @@
     "SmoothStreaming": false,
     "Compress": false,
     "LambdaFunctionAssociations": {
+      "Quantity": 0
+    },
+    "FunctionAssociations": {
       "Quantity": 1,
       "Items": [
         {
-          "LambdaFunctionARN": "arn:aws:lambda:us-east-1:XXXXXXXXXXXX:function:CFRedirectIndexDocument:29",
+          "FunctionARN": "arn:aws:cloudfront::XXXXXXXXXXXX:function/cf2-redirect",
           "EventType": "viewer-request",
           "IncludeBody": false
         }
       ]
     },
-    "FunctionAssociations": {
-      "Quantity": 0
-    },
     "FieldLevelEncryptionId": "",
     "ForwardedValues": {
       "QueryString": true,

更新用の JSON ができたので、 cloudfront update-distribution コマンドで更新します。

$ aws cloudfront update-distribution \
--id ${CF_DIST_ID} \
--if-match $( \
  aws cloudfront get-distribution \
  --id ${CF_DIST_ID} \
  --query "ETag" \
  --output text) \
--distribution-config file://distribution-config-update.json

まとめ

CloudFront Functions を AWS CLI で触ってみるついでに、ブログの URL 正規化処理を Lambda@Edge から移行した話でした。

CloudFront Functions は Lambda@Edge と比較して、よりクライアントに近いところで動くため、レスポンスの高速化が期待できます。また、料金についても安くなり、今回の移行により毎月 Lambda@Edge で 0.1 USD くらいかかっていたのが、おそらく無料枠の範囲内に収まりそうです。

2,000,000 CloudFront Function Invocations
Invocation pricing is $0.10 per 1 million invocations ($0.0000001 per request).

CDN Pricing | Free Tier Eligible, Pay-as-you-go | Amazon CloudFront

Lambda@Edge と比較して実行時間やメモリ、パッケージサイズの制限は厳しいですが、今回のようなパスルーティング程度の処理であればメリットしか無いので、すぐにでも移行するのが良いかなと思いました。


  1. Viewer トリガーの場合。 Origin トリガーの場合は 50 MB。 ↩︎

  2. Viewer トリガーの場合。 Origin トリガーの場合は 10 GB。 ↩︎

  3. Viewer トリガーの場合。 Origin トリガーの場合は 30 秒。 ↩︎


comments powered by Disqus