SREチームの藤原です。
今回は Amazon CloudFront Functions をテストするためのOSSとして、cfft というものを書いたので紹介します。
3行でまとめ
- CloudFront Functionsのテストは手元ではできなくて面倒です
- CloudFront Functionsをテストする cfft というOSSを書きました
- KeyValueStoreの操作を含め、便利な使い方がいろいろありますのでどうぞご利用下さい
CloudFront Functionsをテストするのが面倒という問題
CloudFront Functions (以下CFF) は、AWSが提供するCDNであるAmazon CloudFrontのエッジノード上でリクエストやレスポンスの操作が行える、JavaScriptの実行環境です。典型的なユースケースとしては、キャッシュキーの正規化、ヘッダの操作やリダイレクトなどが挙げられます。
自分で任意のコードが書ける、ということは、当然テストを書きたくなります(よね?)
しかしCFFのランタイムはNode.JSではなく、ECMAScript(ES) 5.1に準拠し、ES 6以降の一部の機能が取り込まれた独自の軽量JavaScriptとなっていて、ランタイムそのものは公開されていません。そのため手元や一般的なCI環境ではCFFのコードを実行できません。当然テストもできません。1
CFFを実際に実行してテストするためには、CloudFrontのマネージメントコンソール上で実行するか、CloudFrontのAPIを呼び出す(aws cliを実行する)必要があります。CLIでもやれないことはないのですが、これを手書きするのはなかなか面倒なのでした。
aws cloudfront update-function
でコードを更新して、レスポンスのETag(関数のバージョン)を覚えておくaws cloudfront test-function
に関数名、ETag、イベントオブジェクト(JSON)を与えて実行する- 結果(関数がreturnしたobjectのJSON)を検証する
test-function
ではコードの実行がエラーなく成功したことしか確認できないため、関数の出力が期待した内容かどうかは自分で検証する必要があります
KeyValueStoreの取り扱いが面倒という問題
2023年11月には、CloudFront用のKeyValueStore(以下KVS)が使えるようになりました。
これはCloudFront上に任意のKey-Valueを登録しておいて、CFFからkeyを指定してvalueを読み取れるというものです。KVSとはいっても関数内からは読み取り専用なので、どちらかというと環境変数を読むような機能に近いのですが……ともあれ、これがあると設定値や秘匿値をコード内にハードコードしなくて済むので、魅力的な機能です。
しかしこれも使うにはちょっと面倒なことがあります。
以下のコードはCFFからKVSを扱うものですが、kvsId = '<KEY_VALUE_STORE_ID>'
の部分は、実際には kvsId = 'f0adde97-ab07-41f7-948c-aa9d39fc10aa'
のように、KVSのIDをハードコードする必要があります。
import cf from 'cloudfront'; const kvsId = '<KEY_VALUE_STORE_ID>'; const kvsHandle = cf.kvs(kvsId); async function handler(event) { const key = event.request.uri.split('/')[1] let value = "Not found" // Default value try { value = await kvsHandle.get(key); } catch (err) { console.log(`Kvs key lookup failed for ${key}: ${err}`); } var response = { statusCode: 200, statusDescription: 'OK', body: { encoding: 'text', data: `Key: ${key} Value: ${value}\n` } }; return response; }
せっかく設定値をハードコードしなくて済むのに、KVS IDだけはハードコードが必要なのですね。
また、KVSの値の操作をaws cliで行う場合にも、都度KVSのARNを指定する必要があります。この--kvs-arn
はIDの値だけでは受け付けてもらえないため、長いARNを指定する必要があります。面倒ですね。
$ aws cloudfront-keyvaluestore get-key \ --key foo \ --kvs-arn arn:aws:cloudfront::123456789012:key-value-store/f0adde97-ab07-41f7-948c-aa9d39fc10aa
実際使うにあたりこのように面倒なことがあったので、いつものようにOSSでツールを書いて解決しよう、と作成したのが cfft というツールです。
cfft
cfft はGoで実装されたCLIコマンドで、シングルバイナリで動作します。現在、以下の機能を持っています。
cfft init
: 既存のCFFを指定して、cfft 用の設定ファイルとコード、テストのための入力イベントを生成する- 関数が存在しない場合は新規にファイルを一式生成します
cfft test
: CFFをファイルの内容で更新し、設定ファイルにしたがって入力イベントを元にテストを実行し、出力が期待したものかどうか検証するcfft diff
: ローカルのコードとCFF上のコードの差分を表示するcfft render
: ローカルのコードをレンダリングした結果を表示する- 設定ファイルとローカルのファイルはテンプレートとして処理され、環境変数の展開などが可能です
cfft publish
: DEVELOPMENTステージでテストを実行した後、CFFをLIVEステージに公開するcfft kvs
: CloudFront KeyValueStoreの操作。list
,get
,put
,delete
,info
コマンドがありますcfft tf
: Terraformとの連携機能
典型的な使い方を紹介します。
cfftによるCFFのテスト
新規にCFFのコードとcfftの設定ファイルを一式作成してみましょう。
$ cfft init --name example 2024-02-21T14:26:57+09:00 [info] function example not found. using default code for viewer-request 2024-02-21T14:26:57+09:00 [info] creating function file: function.js 2024-02-21T14:26:57+09:00 [info] creating config file: cfft.yaml 2024-02-21T14:26:57+09:00 [info] creating event file event.json 2024-02-21T14:26:57+09:00 [info] done
関数のコードとして funciton.js
、設定ファイルとして cfft.yaml
、テスト用のイベントとして event.json
が生成されました。
新規に生成した関数は、単に console.log()
を出力するだけのものになっています。
既にCloudFront上に存在するCFFの名前を指定した場合は、そのコードの内容がfunction.js
に保存されています。
// function.js async function handler(event) { const request = event.request; console.log('on the edge'); return request; }
# cfft.yaml name: example comment: "" function: function.js runtime: cloudfront-js-2.0 testCases: - name: default event: event.json expect: "" ignore: "" env: {}
作成されたテストイベントは実行フェーズが viewer-request
(クライアントからのリクエストを受信した時点で実行される関数、主にヘッダの書き換えや認証に使う) で、リクエストはIPアドレス 1.2.3.4
から GET /index.html
が送信された、というものになっています。詳しい仕様は CloudFront Functions のイベント構造 を参照して下さい。
{ "version": "1.0", "context": { "eventType": "viewer-request" }, "viewer": { "ip": "1.2.3.4" }, "request": { "method": "GET", "uri": "/index.html", "headers": {}, "cookies": {}, "querystring": {} } }
この関数を次のように、クライアントのIPアドレスを x-client-ip
というヘッダにセットする(ログにも出す)ように書き換えてみましょう。
async function handler(event) { const request = event.request; - console.log('on the edge'); + const client_ip = event.viewer.ip ; + request.headers['x-client-ip'] = { value: client_ip }; + console.log(`client ip is ${client_ip}`); return request; }
cfft test --create-if-missing
で実行します。(--create-if-missing
は、関数の新規作成時のみ必要です)
2024-02-21T14:39:04+09:00 [info] function example not found 2024-02-21T14:39:04+09:00 [info] creating function example... 2024-02-21T14:39:05+09:00 [info] function example created 2024-02-21T14:39:07+09:00 [info] [testcase:default] testing function 2024-02-21T14:39:07+09:00 [info] [testcase:default] ComputeUtilization: 27 optimal 2024-02-21T14:39:07+09:00 [info] [testcase:default] [from:example] client ip is 1.2.3.4 2024-02-21T14:39:07+09:00 [info] 1 testcases passed, 0 testcases failed
CFFにexample
という関数が作成され、入力されたeventでテストが実行されました。
- ComputeUtilization は、CFFの実行時に消費したCPUコストです。ドキュメント
- これが100を超えた状態が継続すると、CFFの実行がスロットリングされてしまいます
[from:example] client ip is 1.2.3.4
の部分は、CFFがconsole.log
で出力したログです
この状態ではCFFの実行自体がエラーなく終了すればテスト成功になります。関数の出力結果の検証は行われていません。x-client-ip
ヘッダが期待通り設定されているかを検証してみましょう。
expect.json
というファイルを次の内容で作成します。この内容と、CFFで実行されるhandler
関数が出力した値が比較されます。今回はリクエストヘッダに x-client-ip: 1.2.3.4
が追加されているのが期待される状態なので、そのように記述してあります。
{ "request": { "method": "GET", "uri": "/index.html", "headers": { "x-client-ip": { "value": "1.2.3.4" } }, "cookies": {}, "querystring": {} } }
設定ファイルに expect: expect.json
を指定して、cfft test
を実行します。
testCases: - name: default event: event.json - expect: "" + expect: expect.json ignore: "" env: {}
$ cfft test 2024-02-21T14:43:58+09:00 [info] function example found 2024-02-21T14:43:59+09:00 [info] function is not changed 2024-02-21T14:43:59+09:00 [info] [testcase:default] testing function 2024-02-21T14:43:59+09:00 [info] [testcase:default] ComputeUtilization: 27 optimal 2024-02-21T14:43:59+09:00 [info] [testcase:default] [from:example] client ip is 1.2.3.4 2024-02-21T14:43:59+09:00 [info] [testcase:default] OK 2024-02-21T14:43:59+09:00 [info] 1 testcases passed, 0 testcases failed
1個のテストケースが問題なく成功したことが分かります。
更にリクエストヘッダ x-now
に、現在時刻のUNIX timeを設定するように関数を書き換えてみましょう。こうするとexpect.json
とは結果が異なる状態になるため、テストは失敗することが期待される状態です。
async function handler(event) {
const request = event.request;
const client_ip = event.viewer.ip;
request.headers['x-client-ip'] = { value: client_ip };
+ request.headers['x-now'] = { value: Date.now()+"" };
console.log(`client ip is ${client_ip}`);
return request;
}
$ cfft test 2024-02-21T15:04:18+09:00 [info] [testcase:default] testing function 2024-02-21T15:04:19+09:00 [info] [testcase:default] ComputeUtilization: 28 optimal 2024-02-21T15:04:19+09:00 [info] [testcase:default] [from:example] client ip is 1.2.3.4 --- expect +++ actual @@ -3,6 +3,9 @@ "headers": { "x-client-ip": { "value": "1.2.3.4" + }, + "x-now": { + "value": "1708495458809" } }, "method": "GET", 2024-02-21T15:04:19+09:00 [error] failed to run test case default, expect and actual are not equal 2024-02-21T15:04:19+09:00 [info] 0 testcases passed, 1 testcases failed 2024-02-21T15:04:19+09:00 [error] failed to run test case default, expect and actual are not equal
関数の出力と expect.json
の内容が異なるため、(期待通りに)テストが失敗しました。
この例のように実行時に値が決まるような関数の場合、結果が完全に一致していることを期待するのは難しいことがあります。設定ファイルのテストケースで ignore
を指定すると、結果から特定の値を除外して比較できます。ignore
に指定するのは jq のクエリです。
- ignore: "" + ignore: '.request.headers["x-now"]'
これを指定してcfft test
を実行すると、テストが成功します。(出力は割愛します)
CF KVSとの統合
cfftは、CloudFront KVSを使った関数を簡単に扱えます。
設定ファイルに kvs
セクションを以下のように指定して cfft test --create-if-missing
(KVSが存在しない場合、初回のみオプションが必要) を実行してみます。
kvs: name: hello
$ cfft test --create-if-missing 2024-02-21T15:23:28+09:00 [info] kvs hello not found, creating... 2024-02-21T15:23:37+09:00 [info] kvs hello is not ready yet. status: PROVISIONING 2024-02-21T15:23:46+09:00 [info] kvs hello created 2024-02-21T15:23:46+09:00 [info] function example found 2024-02-21T15:23:46+09:00 [info] kvsArn: arn:aws:cloudfront::123456789012:key-value-store/b2a91b52-468b-48cf-a1ad-0823d517a8b0 2024-02-21T15:23:46+09:00 [info] associating kvs hello to function example... 2024-02-21T15:23:47+09:00 [info] function is not changed 2024-02-21T15:23:47+09:00 [info] [testcase:default] testing function (略)
hello
という名前のCF KVSが作成され、CFFと関連付けられました。
cfft kvs info
を実行すると、KVSの情報が出力されます。
{ "Created": "2024-02-21T06:23:29.776Z", "ETag": "KVTVPDKIKX0DER", "ItemCount": 0, "KvsARN": "arn:aws:cloudfront::123456789012:key-value-store/b2a91b52-468b-48cf-a1ad-0823d517a8b0", "TotalSizeInBytes": 0, "LastModified": "2024-02-21T06:23:29.776Z", "ResultMetadata": {} }
それでは function.js
に、KVSを扱うコードを追加してみましょう。ここで cfft が便利なのは、{{ must_env "KVS_ID" }}
という記法で関連付いたKVSのIDを参照できることです。
+import cf from 'cloudfront'; +const kvsId = '{{ must_env "KVS_ID" }}'; +const kvsHandle = cf.kvs(kvsId); + async function handler(event) { const request = event.request; const client_ip = event.viewer.ip; request.headers['x-client-ip'] = { value: client_ip }; request.headers['x-now'] = { value: Date.now() + "" }; + try { + const world = await kvsHandle.get('world') + request.headers['x-world'] = { value: world }; + } catch (e) { + console.log(e); + } console.log(`client ip is ${client_ip}`); return request; }
ここではx-world
というヘッダに、KVSからworld
というkeyで引いた値を設定するコードを書いてみました。
cfft kvs put
でkey-valueを設定します。設定した値はget
で確認できます。
$ cfft kvs put world 'こんにちは' $ cfft kvs get world {"key":"world","value":"こんにちは"}
KVSに値を設定した後に cfft test
を実行すると… (設定後、数秒は新しい値が参照できない/古い値が見えることがあります)
$ cfft test 2024-02-21T15:36:05+09:00 [info] function example found 2024-02-21T15:36:05+09:00 [info] kvsArn: arn:aws:cloudfront::123456789012:key-value-store/b2a91b52-468b-48cf-a1ad-0823d517a8b0 2024-02-21T15:36:05+09:00 [info] associated kvs: arn:aws:cloudfront::123456789012:key-value-store/b2a91b52-468b-48cf-a1ad-0823d517a8b0 2024-02-21T15:36:05+09:00 [info] function is changed, updating... 2024-02-21T15:36:07+09:00 [info] [testcase:default] testing function 2024-02-21T15:36:08+09:00 [info] [testcase:default] ComputeUtilization: 0 optimal 2024-02-21T15:36:08+09:00 [info] [testcase:default] [from:example] client ip is 1.2.3.4 --- expect +++ actual @@ -4,7 +4,10 @@ "x-client-ip": { "value": "1.2.3.4" }, - "x-now": null + "x-now": null, + "x-world": { + "value": "こんにちは" + } }, "method": "GET", "uri": "/index.html" 2024-02-21T15:36:08+09:00 [error] failed to run test case default, expect and actual are not equal 2024-02-21T15:36:08+09:00 [info] 0 testcases passed, 1 testcases failed 2024-02-21T15:36:08+09:00 [error] failed to run test case default, expect and actual are not equal
x-world
ヘッダにKVSから引いた値が設定された結果 expect.json
と異なる出力になったため、テストが失敗していることが分かります。
Terraformとの連携とLIVEステージへのデプロイ
CFFには DEVELOPMENT / LIVE というステージの概念があり、一旦DEVELOPMENTステージで関数を更新した上でテストを行い、問題がなければLIVEステージに公開(publish)するという運用が想定されています。実際にCloudFront distributionで実行されるのは、LIVEステージにpublishされたコードです。
カヤックでは基本的にTerraformでAWSのリソースを管理しています。TerraformでCloudFront distributionを管理している場合、CFFをどうやってTerraformでデプロイするか(publishするか)という問題が出てきます。
cfft publish
コマンドによってcfft単独でLIVEステージへのpublishも可能です。しかしその場合、デプロイ操作が2段階(cfft publish
とterraform apply
)になるのが煩わしく、手動で実行する場合には更新漏れが起きる可能性があります。
できればLIVEステージへの公開はTerraformに任せてしまいたい、ということで、cfftは Terraform との連携方法を2種類用意しました。詳しくは Cooperate with Terraform を参照して下さい。
1. tf.json
を生成する方法
cfft tf --publish
を実行すると、Terraformの JSON Configuration Syntax として読み込み可能なJSONを標準出力に出力します。
$ cfft tf --publish > cff.tf.json
このJSONには aws_cloudfront_function リソースを構築できる内容が含まれているので、この.tf.jsonを配置した状態で terraform apply
を行えば、LIVEステージへのpublishはTerraformに任せることができます。
Terraformの CloudFront distribution の定義でも、素直にリソースへの参照として記述できます。
function_association { event_type = "viewer-request" function_arn = aws_cloudfront_function.some-function.arn }
全てを一度に構築しない場合は cfft test
によって事前にリソースが作成済みになっているので、import blockを使用してTerraformに既存リソースを取り込む記述をしておくとよいでしょう。
import { to = aws_cloudfront_function.some-function id = "some-function" }
2. external data sourcesを使う方法
もう一つの方法は、external data source を利用する方法です。
cfft tf --external
を実行すると、external data sourceで参照可能なJSONを出力します。これを利用すると、以下のように .tf にリソースを定義できます。
resource "aws_cloudfront_function" "some-function" { name = data.external.some-function.result["name"] runtime = data.external.some-function.result["runtime"] code = data.external.some-function.result["code"] comment = data.external.some-function.result["comment"] publish = true } data "external" "some-function" { program = ["cfft", "tf", "--external"] }
external data sourceは文字列以外の値を取り込めないため、publish
属性については.tf側でtrueを指定する必要があります。
この方法でも .tf.json 方式と同様、cfft test
によって事前にリソースが作成されている場合は、import block を定義しておくとよいでしょう。
その他の便利機能
長くなるので詳細は割愛しますが、ecspresso や lambroll をお使いのかたにはおなじみの機能、テストケースを可読性高く書くための便利な機能があります。
- 設定ファイル/定義ファイル類は YAML, JSON, Jsonnet で記述可能
- 設定ファイル/定義ファイル類は
{{ must_env }}
,{{ env }}
での環境変数展開ができる render
コマンドでファイルのレンダリング結果を確認できるdiff
コマンドでリソースの差分が確認できる- イベントの
request
とresponse
にはHTTPのメッセージをdumpした形式で記述できる 例:
version: "1.0" viewer: ip: 1.2.3.4 context: eventType: viewer-response request: | GET /index.html HTTP/1.1 Host: example.com User-Agent: Mozilla/5.0 Cookie: foo=bar, bar=baz Cookie: bar=xxx response: | HTTP/1.1 200 OK Content-Type: text/html Content-Length: 13 Set-Cookie: foo=bar; Secure; Path=/; Domain=example.com Hello World!
まとめ
CloudFront Functionsのテストは手元ではできないのでちょっと面倒です。
CloudFront Functionsをテストする cfft というOSSを書きました。CF KVSとの連携を含めて、便利な使い方がいろいろありますのでどうぞご利用下さい!
- 他のJavaScriptランタイムでコードを実行することは可能ですが、一部のCFFで使用できない機能を使用した場合でもエラーにならない可能性があり、テストとはいいがたいものになります↩