
- Lambda@Edge と CloudFront Functions の違い
- なぜ Lambda@Edge だったのか?
- CloudFront KeyValueStore の何が嬉しいのか?
- CloudFront KeyValueStore について知っておいたほうがいいこと
- 実際に再実装した際に遭遇した大変だったこと
- CloudFront Functions に移行できなかった部分
- 効果測定 (CloudFrontにキャッシュされていない場合)
- まとめ
こんにちは。ITインフラ本部 SRE部の周です。
現在EmbeddedSREとして、DMMブックスを開発・運用する電子書籍事業部に参画しており、主にAWS関連業務を担当しています。その一環として、商品のパッケージ画像の非公開チェック機能を高速化させるために従来のLambda@Edge から新しくCloudFront Functions に切り替え実装しました。
Lambda@Edge と CloudFront Functions の違い
まず、Lambda@Edge と CloudFront Functions の違いを明確にしようと思います。
| Lambda@Edge | CloudFront Functions | |
|---|---|---|
| ランタイム | Node.js, Python, Ruby, Java, .NET, Amazon Linux(RustやGoなど用) |
cloudfront-js (JavaScriptの一部の機能しかサポートしていません) |
| 実行速度 | 複雑なタスクを実行できる分、比較的レイテンシーが大きい | 非常に高速である |
| 用途と複雑性 | より複雑なアプリケーションロジックをエッジで実行できる | URLの書き換えなど、非常に小規模で高速な実行が必要な用途に向いている |
DMMブックスで使用されている商品のパッケージ画像の非公開チェックは「CloudFront で配信される商品のパッケージ画像の中で、未公開作品の場合には now printing と呼ばれる画像を代わりに表示する」というシンプルな機能です。シンプルな機能であり、かつ高速にコンテンツを配信したいため、本来 CloudFront Functions の方が適しています。
なぜ Lambda@Edge だったのか?
作品の公開日時データはAuroraに保存されているため、非公開チェックの処理の際は何かしらの手段を用いてアクセスする必要があります。
直接AuroraやElastiCache、S3等にアクセスできたとしても、 CloudFront Functions のコンピューティング使用率制限を簡単に超えてしまいます。 そのため、CloudFront KeyValueStore がリリースされるまでは、CloudFront Functions では実装できませんでした。
CloudFront KeyValueStore の何が嬉しいのか?
CloudFront Functions は高速に実行できる代わりに Lambda@Edge よりも制限が厳しいです。
作品の公開日時といった、何かしらのデータを参照して処理する場合で直接 CloudFront Functions のソースコードにハードコーディングできるレベルであればまだ良いのですが、頻繁な更新、あるいは大量データから検索する必要があるなどの場合に、どうしてもどこかにデータを保存しなければいけません。
今までは CloudFront Functions から使用できる保存場所がなく、Lambda@Edge を使わざるを得ないことも多かったと思います。
しかしCloudFront KeyValueStore という、 非常に低レイテンシーでCloudFront Functions から呼び出せるKeyValueStore がリリースされたおかげで、この問題を解決できるようになりました。
CloudFront KeyValueStore について知っておいたほうがいいこと
CloudFront KeyValueStore は比較的新しくリリースされた機能であるため、AWSの他のサービスよりも出回っている情報がまだ少ないです。
今回の商品のパッケージ画像処理の高速化作業で重要だと感じたポイントをいくつかまとめておきます。
CloudFront KeyValueStore はETag という仕組みによってバージョン管理
CloudFront KeyValueStore は複数の更新リクエストを並列に処理できないため、1つずつ順番にリクエストを送信しなければなりません。
データを保存したい場合はまず該当する CloudFront KeyValueStore の ETag の値を取得する必要があります。
$ aws cloudfront-keyvaluestore describe-key-value-store --kvs-arn <KeyValueStoreのARN>
{
"ETag": "XXXXXXXXXXXXXXXX",
"ItemCount": 1,
"TotalSizeInBytes": 10,
"KvsARN": "arn:aws:cloudfront::00000000000:key-value-store/00000000-0000-0000-0000-000000000000",
"Created": "2024-03-14T09:02:12.595000+09:00",
"LastModified": "2024-03-14T09:37:11.771000+09:00"
}
次に、取得した ETag の値を使用してデータを CloudFront KeyValueStore に保存します。
$ aws cloudfront-keyvaluestore put-key --key '/sample' --value 'sample' --kvs-arn <KeyValueStoreのARN> --if-match <ETag>
{
"ETag": "XXXXXXXXXXXXXXXX",
"ItemCount": 2,
"TotalSizeInBytes": 23
}
正しくデータを保存できた場合は、新しい ETag も同時に取得できます。1つの ETag は一回しか使用できない ので、複数のデータを保存する際は都度最新のETag を取得してから保存リクエストを送信する必要があります。
CloudFront のエッジサーバーによっては反映に少しラグがある
CloudFront KeyValueStore に保存したデータは同時に全てのエッジサーバーに反映されるわけではありません。
実験として新しいデータを保存してすぐ CloudFront Functions から値を取得してみます。
まず下記のコードで CloudFront Functions を作成し、任意の CloudFront のビューワーリクエストに関連付けさせます。
リクエストURIをキーとして CloudFront KeyValueStore にアクセスして値を取り出すだけの処理です。
import cf from 'cloudfront' const kvsId = '00000000-0000-0000-0000-000000000000' const kvsHandle = cf.kvs(kvsId) async function handler(event) { let key = event.request.uri try { let value = await kvsHandle.get(key) console.log(`[INFO] kvs: ${key}: ${value}`) return { statusCode: 200, statusDescription: 'Success', } } catch (err) { if (err.toString() !== 'Error: KeyNotFound') { console.log(`[ERROR] Kvs key lookup failed for ${key}: ${err}`) } else { console.log(`[INFO] Kvs key not found for ${key}`) } return { statusCode: 500, statusDescription: 'Internal Server Error', } } }
次にデータの保存コマンドを実行してから、すぐに CloudFront にアクセスしてレスポンスを確認します。
そうすると、データを取得できた場合(200)とデータを取得できなかった場合(500)のレスポンスが混ざって返ってくることが分かります。
$ aws cloudfront-keyvaluestore put-key --key '/test' --value 'test' --kvs-arn <KeyValueStoreのARN> --if-match <ETag> $ curl -I https://<CloudFrontのDNS名>/test HTTP/2 200 server: CloudFront date: Thu, 14 Mar 2024 01:00:38 GMT content-length: 0 x-cache: FunctionGeneratedResponse from cloudfront via: 1.1 1a14b40ef6c4ba4b405703e2217e79c6.cloudfront.net (CloudFront) x-amz-cf-pop: NRT20-P1 x-amz-cf-id: rw1wklm8PnH2BIAm-3E-lhl1HCUXQ9Qdvd83Lr3fX7R94D_dW6RB9g== $ curl -I https://<CloudFrontのDNS名>/test HTTP/2 500 server: CloudFront date: Thu, 14 Mar 2024 01:00:38 GMT content-length: 0 x-cache: FunctionGeneratedResponse from cloudfront via: 1.1 64e0542a84a2ed807451f1be5fec7a18.cloudfront.net (CloudFront) x-amz-cf-pop: NRT20-P1 x-amz-cf-id: IMUYw4dHJHH1tklf4ElRnemQbeMAjVnU5vACfaXxWRFVG6nakTR_vw== $ curl -I https://<CloudFrontのDNS名>/test HTTP/2 200 server: CloudFront date: Thu, 14 Mar 2024 01:00:39 GMT content-length: 0 x-cache: FunctionGeneratedResponse from cloudfront via: 1.1 a491e094d88d6b601fcc0862c4bda40a.cloudfront.net (CloudFront) x-amz-cf-pop: NRT20-P1 x-amz-cf-id: SQ24uKzGn8Pqeo4vCvex9NAo0AcrJbtU3u9OSfLoHnJXhqjZKKaHZw==
CloudWatch Logs のログからもデータが取得できる場合とできない場合が発生することが読み取れます。
ちなみに CloudFront Functions のログはus-east-1 リージョンの CloudWatch Logs で /aws/cloudfront/function/ファンクション名 という名前のロググループに保存されています。
I2RwSwOzElwqRYDHMUGGVXY2WEAfrXcSQ7wH0-LrfR6-gvvVirTRkg== START DistributionID: E3PRGEQJ9KXVWD I2RwSwOzElwqRYDHMUGGVXY2WEAfrXcSQ7wH0-LrfR6-gvvVirTRkg== [INFO] Kvs key not found for /test I2RwSwOzElwqRYDHMUGGVXY2WEAfrXcSQ7wH0-LrfR6-gvvVirTRkg== END rm1Q_jD9Y6xzg_NfYiI_E_5gT4n9e2Ws1O40FsvLAEn_jIOlp9G9dQ== START DistributionID: E3PRGEQJ9KXVWD rm1Q_jD9Y6xzg_NfYiI_E_5gT4n9e2Ws1O40FsvLAEn_jIOlp9G9dQ== [INFO] Kvs key not found for /test rm1Q_jD9Y6xzg_NfYiI_E_5gT4n9e2Ws1O40FsvLAEn_jIOlp9G9dQ== END p5ZPNyTAZD1WerDWlNoruPynNsC84M30GnBCXuUyI0DZuARyAsFhqQ== START DistributionID: E3PRGEQJ9KXVWD p5ZPNyTAZD1WerDWlNoruPynNsC84M30GnBCXuUyI0DZuARyAsFhqQ== [INFO] kvs: /test: test p5ZPNyTAZD1WerDWlNoruPynNsC84M30GnBCXuUyI0DZuARyAsFhqQ== END 5Pxcoi-YHyHOZbV5JeehVJHn83xz2NlWZKcJuR7ZhnyfO2mEilEqmA== START DistributionID: E3PRGEQJ9KXVWD 5Pxcoi-YHyHOZbV5JeehVJHn83xz2NlWZKcJuR7ZhnyfO2mEilEqmA== [INFO] Kvs key not found for /test 5Pxcoi-YHyHOZbV5JeehVJHn83xz2NlWZKcJuR7ZhnyfO2mEilEqmA== END jyTUuJraiAuJU-j4f1hrdySFqTlNL7pEyrkUEuEdQwuCw1i2Oh_S7A== START DistributionID: E3PRGEQJ9KXVWD jyTUuJraiAuJU-j4f1hrdySFqTlNL7pEyrkUEuEdQwuCw1i2Oh_S7A== [INFO] kvs: /test: test jyTUuJraiAuJU-j4f1hrdySFqTlNL7pEyrkUEuEdQwuCw1i2Oh_S7A== END rw1wklm8PnH2BIAm-3E-lhl1HCUXQ9Qdvd83Lr3fX7R94D_dW6RB9g== START DistributionID: E3PRGEQJ9KXVWD rw1wklm8PnH2BIAm-3E-lhl1HCUXQ9Qdvd83Lr3fX7R94D_dW6RB9g== [INFO] kvs: /test: test rw1wklm8PnH2BIAm-3E-lhl1HCUXQ9Qdvd83Lr3fX7R94D_dW6RB9g== END IMUYw4dHJHH1tklf4ElRnemQbeMAjVnU5vACfaXxWRFVG6nakTR_vw== START DistributionID: E3PRGEQJ9KXVWD IMUYw4dHJHH1tklf4ElRnemQbeMAjVnU5vACfaXxWRFVG6nakTR_vw== [INFO] Kvs key not found for /test IMUYw4dHJHH1tklf4ElRnemQbeMAjVnU5vACfaXxWRFVG6nakTR_vw== END
次は実際にデータを保存してから反映されるまでの時間を計測してみます。 下記のスクリプトで、データを保存した直後からループでデータを取得し続け、終わるまでの時間を計測します。
#!/bin/bash set -eu key="/latency-test-key-01" kvs_arn="<使用するCloudFrontKeyValueStoreのARN>" etag=$(aws cloudfront-keyvaluestore describe-key-value-store --kvs-arn $kvs_arn | jq -r '.ETag') aws cloudfront-keyvaluestore put-key --key $key --value "test" --kvs-arn $kvs_arn --if-match $etag for _ in {1..100} do # 現在のタイムスタンプを取得 timestamp=$(date "+%Y-%m-%d %H:%M:%S") # curl コマンドを実行し、HTTP/2 のステータスコードを取得 status_code=$(curl -sI "https://<CloudFrontのDNS>${key}" | grep HTTP/2 | cut -d' ' -f2) # タイムスタンプとステータスコードを出力 echo "${timestamp} ${status_code}" # 1 秒待機 sleep 1 done
下記のログを見ると、 10:26:07 にアクセスを開始して 10:26:47 から 200 と 500 が混ざって返ってくるようになりました。 10:26:54 からは常に 200 が返ってくるようになりました。 したがって、40秒ほどで一部反映されるようになり、1分ほどで全てのエッジサーバーに反映されるようになりました。
2024-03-21 10:26:07 500 2024-03-21 10:26:08 500 2024-03-21 10:26:09 500 2024-03-21 10:26:10 500 2024-03-21 10:26:11 500 2024-03-21 10:26:12 500 2024-03-21 10:26:13 500 2024-03-21 10:26:15 500 2024-03-21 10:26:16 500 2024-03-21 10:26:17 500 2024-03-21 10:26:18 500 2024-03-21 10:26:19 500 2024-03-21 10:26:20 500 2024-03-21 10:26:21 500 2024-03-21 10:26:22 500 2024-03-21 10:26:23 500 2024-03-21 10:26:24 500 2024-03-21 10:26:25 500 2024-03-21 10:26:27 500 2024-03-21 10:26:28 500 2024-03-21 10:26:29 500 2024-03-21 10:26:30 500 2024-03-21 10:26:31 500 2024-03-21 10:26:32 500 2024-03-21 10:26:33 500 2024-03-21 10:26:34 500 2024-03-21 10:26:35 500 2024-03-21 10:26:36 500 2024-03-21 10:26:37 500 2024-03-21 10:26:38 500 2024-03-21 10:26:40 500 2024-03-21 10:26:41 500 2024-03-21 10:26:42 500 2024-03-21 10:26:43 500 2024-03-21 10:26:44 500 2024-03-21 10:26:45 500 2024-03-21 10:26:46 500 2024-03-21 10:26:47 200 2024-03-21 10:26:48 200 2024-03-21 10:26:49 500 2024-03-21 10:26:50 200 2024-03-21 10:26:52 200 2024-03-21 10:26:53 500 2024-03-21 10:26:54 200 2024-03-21 10:26:55 200 2024-03-21 10:26:56 200 2024-03-21 10:26:57 200 2024-03-21 10:26:58 200 2024-03-21 10:26:59 200 2024-03-21 10:27:00 200 2024-03-21 10:27:01 200 2024-03-21 10:27:02 200 2024-03-21 10:27:03 200 2024-03-21 10:27:05 200 2024-03-21 10:27:06 200 2024-03-21 10:27:07 200 2024-03-21 10:27:08 200 2024-03-21 10:27:09 200 2024-03-21 10:27:10 200 2024-03-21 10:27:11 200
実際に再実装した際に遭遇した大変だったこと
商品のパッケージ画像の非公開チェックを CloudFront Functions + CloudFront KeyValueStore で再実装した際に、うまくいかなかった点も少し紹介します。
cloudfront-js と Node.js は互換性がない
URLやヘッダーを書き換えたりするような極めてシンプルな処理の場合にはあまり影響はありませんが、CloudFront KeyValueStore がリリースされたことで、CloudFront Functions で少し複雑な処理を行おうとするとランタイムの差で困ることがあるかもしれません。
文字列として CloudFront KeyValueStore に保存した作品公開日時を Date オブジェクトに変換する場合には一般的に下記のような実装が考えられます。
let date = new Date('2024-02-15 09:00:00+09:00') console.log(`[INFO] date: ${date}`)
もちろん、この実装はローカルのNode.jsのランタイムでは実行できます。

ローカルでテストしてから、CloudFront Functions にデプロイすると下記のように Invalid Date になります。

ちなみにこの場合、例外は発生せず、そのまま処理が進みます。下記のような比較は常に false と判定されるため、少し気付きにくいです。
そのため、今回は仕方なく 正規表現でパースすることにしました。
let date = new Date('2024-02-15 09:00:00+09:00') let now = new Date() console.log(date > now)
Terraform がまだ CloudFront Functions をサポートしていない
DMMブックスの AWS は Terraformで管理されているため、今回も Terraform で各種リソースを定義しましたが、ここで1つ大きな落とし穴がありました。
2024年3月15日時点で、Terraform の CloudFront KeyValueStore の対応はまだ不完全なため、リソースを作成することは可能でも「CloudFront Functions と CloudFront KeyValueStore の関連付け」を定義できません。
リソースを Terraform で作成してから、AWS コンソールから手動で関連付けを設定しましたが、 terraform apply を実行すると関連付けが消されるため、CloudFront Functions から CloudFront KeyValueStore にアクセスできなくなります。
Terraform でデプロイできないため、今回は下記のように aws-cli を使ってデプロイする処理を CI に入れました。
--function-config 引数に CloudFront KeyValueStore の ARN を指定することで関連付けられます。
aws cloudfront update-function \
--name "<CloudFront Functions の名前>" \
--if-match "<CloudFront Functions のETag>" \
--function-code "fileb://<CloudFront Functions のソースコードが保存されたファイルパス>" \
--function-config Comment="Updated at $(date)",Runtime=cloudfront-js-2.0,KeyValueStoreAssociations="{Quantity=1,Items=[{KeyValueStoreARN=\"<CloudFront KeyValueStore の ARN>\"}]}"
更新リクエストは1つずつしか受け付けない
CloudFront KeyValueStore は ETag の仕組みを使って、順番にリクエストを送信する必要があります。
公開日時情報を Aurora からCloudFront KeyValueStore に取り込むバッチを10分毎に動かすようにしていましたが、バッチの実行が10分以内に終了しないことがあり、その間に2つのバッチが同時に実行されるタイミングが発生していることに気付きました。
CloudFront KeyValueStore へのリクエストは正しい順番で実行しないとエラーが発生するため、更新バッチが複数同時に動作するとエラーになってしまいます。
そのためバッチの実行間隔を、前回のバッチが確実に実行完了している20分間に増やしました。
Golangから使う場合の情報がとにかく少ない
比較的新しくリリースされた機能であり、インターネット上でも情報が少ないため、まず生成AIは役に立ちませんでした。
「CloudFront Functions で CloudFront KeyValueStore から値を取得するサンプルを Golang で書いてください」と指示してみましたが、ChatGPT(GPT-4) も Gemini Advanced も現時点で間違った回答しか生成しません。
AWSのドキュメントや各種インターネット上の記事を調べてもあまり情報がないので、ここに Golang での実装方法を残しておきます。
必要なパッケージ
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/cloudfrontkeyvaluestore" "github.com/aws/aws-sdk-go-v2/service/cloudfrontkeyvaluestore/types"
クライアントの初期化
cfg, err := config.LoadDefaultConfig(context.Background()) if err != nil { log.Fatalf("Unable to load SDK config, %v", err) } kvsClient := cloudfrontkeyvaluestore.NewFromConfig(cfg)
ETag の取得
input := &cloudfrontkeyvaluestore.DescribeKeyValueStoreInput{
KvsARN: aws.String("<使用するCloudFrontKeyValueStoreのARN>"),
}
output, err := kvsClient.DescribeKeyValueStore(context.Background(), input)
if err != nil {
fmt.Printf("failed to describe KVS: %s.", err)
} else if output.ETag == nil {
fmt.Println("ETag not found in KVS description.")
} else {
fmt.Println("ETag: ", *output.ETag)
}
データの保存
input := &cloudfrontkeyvaluestore.PutKeyInput{
IfMatch: aws.String("<事前に取得したETagの値>"),
Key: aws.String("<保存したいデータのキー>"),
Value: aws.String("<保存したいデータの値>"),
KvsARN: aws.String("<使用するCloudFrontKeyValueStoreのARN>"),
}
if _, err := kvsClient.PutKey(context.Background(), input); err != nil {
fmt.Printf("failed to put key: %s", err)
} else {
fmt.Println("Successfully put key")
}
データの一括保存 (2024年3月21日時点で確認したところ、一回の保存数が32を超えるとAWSの上限に引っかかります)
keyValues := map[string]string{ "key1": "value1", "key2": "value2", } items := make([]types.PutKeyRequestListItem, 0, len(keyValues)) for key, value := range keyValues { items = append(items, types.PutKeyRequestListItem{ Key: aws.String(key), Value: aws.String(value), }) } input := &cloudfrontkeyvaluestore.UpdateKeysInput{ IfMatch: aws.String("<事前に取得したETagの値>"), Puts: items, KvsARN: aws.String("<使用するCloudFrontKeyValueStoreのARN>"), } if _, err := kvsClient.UpdateKeys(context.Background(), input); err != nil { fmt.Printf("failed to update keys: %s", err) } else { fmt.Println("Successfully updated keys") }
CloudFront Functions に移行できなかった部分
| 元々 Lambda@Edge で実現していた機能 | CloudFront Functions への移行 |
|---|---|
| 非公開のパッケージ画像へのアクセスは now printing へリダイレクトする | ◯ |
| S3にまだ画像をアップロードしていない場合は now printing へリダイレクトする | ✗ |
今回の作業で、商品のパッケージ画像の非公開チェック機能を CloudFront Functions に移行することができました。しかし、S3に画像が存在しない場合のリダイレクトは CloudFront Functions では実現できませんでした。
オリジン(S3)レスポンスが403や404の場合にビューワーレスポンスイベントのエッジ関数を呼び出すことができません。 また、CloudFront Functions はオリジンレスポンスイベントに対応していません。
そのため、今回は S3に画像が存在しない場合のリダイレクト処理だけ Lambda@Edge に残すことにしました。
CloudFront オリジンが HTTP ステータスコード 400 以上を返す場合はビューワーレスポンスイベントのエッジ関数を呼び出しません。
効果測定 (CloudFrontにキャッシュされていない場合)
以前
S3から非公開商品のリストをダウンロードしていたため、17ms 前後のレイテンシーが発生していました。

現在

S3から非公開商品のリストをダウンロードしなくなったため、ほぼ 5ms 以内でレスポンスが返ってくるようになりました。
これに加えて 1ms 未満の CloudFront Functions のレイテンシーが発生するため、合計で 6ms 前後のレイテンシーになります。
まとめ
今回は、DMMブックスで使用している商品のパッケージ画像の非公開チェック処理を Lambda@Edge から CloudFront Functions に切り替えることで処理の高速化を実現できました。同じような状況で仕方なく Lambda@Edge で実装してきた方も多いのではないでしょうか?そのような方々にとってこの記事がお役に立てれば幸いです。