CloudFront FunctionsをテストするOSS、cfftを公開しました

SREチームの藤原です。

今回は Amazon CloudFront Functions をテストするためのOSSとして、cfft というものを書いたので紹介します。

github.com

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でもやれないことはないのですが、これを手書きするのはなかなか面倒なのでした。

  1. aws cloudfront update-function でコードを更新して、レスポンスのETag(関数のバージョン)を覚えておく
  2. aws cloudfront test-function に関数名、ETag、イベントオブジェクト(JSON)を与えて実行する
  3. 結果(関数が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 publishterraform 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 を定義しておくとよいでしょう。

その他の便利機能

長くなるので詳細は割愛しますが、ecspressolambroll をお使いのかたにはおなじみの機能、テストケースを可読性高く書くための便利な機能があります。

  • 設定ファイル/定義ファイル類は YAML, JSON, Jsonnet で記述可能
  • 設定ファイル/定義ファイル類は {{ must_env }}, {{ env }} での環境変数展開ができる
  • renderコマンドでファイルのレンダリング結果を確認できる
  • diffコマンドでリソースの差分が確認できる
  • イベントの requestresponse には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との連携を含めて、便利な使い方がいろいろありますのでどうぞご利用下さい!

カヤックではOSSが好きなエンジニアを募集しています!


  1. 他のJavaScriptランタイムでコードを実行することは可能ですが、一部のCFFで使用できない機能を使用した場合でもエラーにならない可能性があり、テストとはいいがたいものになります