Ruby 3.3でのアップデートも要チェック!まちのコインでYJITを有効化したはなし

SREチームの長田です。 今回はRubyのJITコンパイラであるYJITのはなしです。

カヤックが開発・運用している地域通貨サービス「まちのコイン」は、Ruby on Railsを使用しています。 このまちのコインにてYJITを有効化し、その結果どのような影響があったのかを紹介します。

coin.machino.co

YJITとは

YJITは RubyのJITコンパイラです。 Ruby 3.1までは実験的な機能という位置づけでしたが、 Ruby 3.2から実用段階となりました。

Basic Block Versioning (BBV)を採用した遅延コンパイルにより、コード実行の高速化を図っているようです。 YJITそのものの話題については、今回は割愛させていただきます。

まちのコインの状況

まちのコインでは昨年6月末頃に Ruby 3.1.x から Ruby 3.2.x にアップデートを行いました。 その時点でYJITを利用できるようにはなっていたわけですが、当時はまだ「有効にしたらどうなるかねー」という会話があった程度でした。

その後他社の本番環境でのYJIT有効化事例が見られるようになり、 そろそろうちでも有効にしてみるかーとようやく動き始めたのが昨年の12月頃でした。

YJIT有効化・・・の前に

まずは計測できる状態にしなければ効果が測れないので、YJITのstatsをメトリクスとして取得するようにしました。

YJITのstatsは RubyVM::YJIT.runtime_stats で取得できます。 アプリケーションがリクエストを受けるたびにこれをログ出力しています。 必要に応じて一定の確率でログ出力するなどしてログの量を抑制すると良いでしょう。

Rails.logger.info(
    {
        app_mode: ENV.fetch('APP_MODE', nil),
        **RubyVM::YJIT.runtime_stats,
    },
)

app_mode はアプリケーションの動作モードです。 まちのコインではひとつのRailsアプリがいくつかのモードで動作しているので、 これを区別するためにログに含めています。

RailsアプリはECS上で動作しているので、出力したログは Amazon CloudWatch Logs に集約されます。 これに対して Amazon CloudWatch Metric Filter を設定し、でメトリクスとして集計・投稿しています。

以下に Terraform を使った設定例を示します。 今回は継続的に観察するメトリクスとして code_region_sizeyjit_alloc_size を選びました。

resource "aws_cloudwatch_log_metric_filter" "app-ruby-yjit" {
  for_each = toset([
    "code_region_size",
    "yjit_alloc_size",
  ])

  name           = "ruby-yjit-${each.key}"
  log_group_name = aws_cloudwatch_log_group.ecs-task["app"].name
  pattern        = "{$.${each.key} = *}"

  metric_transformation {
    namespace = "App/RubyYJIT"
    name      = each.key
    value     = "$.${each.key}"
    unit      = "Bytes"

    dimensions = {
      app_mode = "$.app_mode"
    }
  }
}

他のメトリクスについても必要があれば for_each で処理するリストに追加することになります。 CloudWatch に送信していないメトリクスを一時的に眺めたい場合は、CloudWatch Logs Insights でログを集計しています。

これで計測の準備ができたので、次はいよいよ有効化です。

YJITの有効化

まずは有効にして様子を見てみないとなんともならんということで、 RUBY_YJIT_ENABLE=1 を設定してデプロイしてみたところ、 APIのレスポンスタイムが2倍程度に悪化しました

悪化の原因はYJIT有効化によるメモリ使用量の増加でした。 まちのコインのRailsアプリはHTTPサーバーとしてunicornを使用しています *1。 YJIT有効化後にunicornのworkerが使用するメモリ量が増え、 unicorn worker killer に頻繁にkillされるようになり、 workerプロセスの再起動が頻発。 結果としてAPIレスポンスタイムが悪化した、というものでした。

対応として、unicorn worker killerの閾値調整と、ECS Taskに割り当てるメモリ量を増やしました。 元の閾値が必要以上に小さかったということも影響していたようです。 対応の結果、APIレスポンスタイムが平均 9%程度短縮 されました。 パラメータ変更のみで10%近く短縮できたのは大きいですね。

APIレスポンスタイムのグラフ

また、CPU使用率にも若干の減少が見られました。

CPU使用率のグラフ

Ruby 3.3.0 へのアップデート

昨年12月にリリースされた Ruby 3.3.0。 リリースノート によると YJITのパフォーマンスおよびメモリ使用量が大幅に改善したとのことだったので、早速試してみることにしました。

Ruby 3.2系から3.3系への変更は、インターフェイスの変更が含まれていないこともあり、ほぼ工数ゼロで移行完了しました。 しかし、 APIレスポンスタイムの短縮という観点では、ほとんど効果は見られませんでした

unircorn の after_fork でYJITを有効化

Ruby 3.3 からYJITに追加された機能として、ランタイムでの有効化があります。

  • RubyVM::YJIT.enable を追加し、実行時にYJITを有効にできるようにしました
    • コマンドライン引数や環境変数を変更せずにYJITを開始できます。Rails 7.2はこの方法を使用して デフォルトでYJITを有効にします。
    • これはまた、アプリケーションの起動が完了した後にのみYJITを有効にするために使用できます。YJITの他のオプションを使用しながら起動時にYJITを無効にしたい場合は、--yjit-disable を使用できます。

Ruby 3.3.0 リリース より

YJITの有効化をアプリケーション起動時ではなく、 unicorn workerのfork後に行うことでメモリ使用量を抑えられる場合があるとのことでしたので、 config/unicorn.rb に以下のようなコードを追加しました。

after_fork do |_server, _worker|
  if rails_env == 'production'
    RubyVM::YJIT.enable
    Rails.logger.info('YJIT enabled')
  end
end

結果としては、20MB程度だった code_region_size が15MB程度に減少しました (オレンジの線がmax、青の線がaverage)。

code_region_sizeのグラフ

しかし、もともとの code_region_size が少なかったため、 ECS Task全体で見たときのメモリ使用量減少についてはあまり効果がありませんでした。

--yjit-exec-mem-size の調整

--yjit-exec-mem-size はYJITが生成するコード量を制限する設定値です。 YJITのドキュメントには以下のように説明されています ((--yjit-exec-mem-size のデフォルト値は、以降のバージョンで48MiBに変更されるようです。 https://github.com/ruby/ruby/pull/9685))

--yjit-exec-mem-size=N: size of the executable memory block to allocate, in MiB (default 64 MiB)

前述のとおり、code_region_size は15MB程度と小さかったため、調整は行いませんでした。

より小さな値を設定すればその分メモリ量は削減できるはずですが、 例えば --yjit-exec-mem-size=8 として半分に制限したとしても、 現状のECS Taskあたりのunicorn worker数16 * 8MiB = 128MiB 程度しか削減できないことになります。

--yjit-call-threshold の調整

--yjit-call-threshold はYJITがコンパイルするメソッドの呼び出し回数の閾値です。

こちらもYJITのドキュメントを参照すると、以下のように説明されています。

--yjit-call-threshold=N: number of calls after which YJIT begins to compile a function. It defaults to 30, and it's then increased to 120 when the number of ISEQs in the process reaches 40,000.

デフォルト値は30ですが、説明の後半にあるようにISEQs(コンパイルされたバイトシーケンス量)が40,000に達すると 自動で120に引き上げられるようです。 コンパイル量が一定以上になると新たにコンパイルされにくくなるということですね ((YJITのstatsには compiled_iseq_count というISEQsを得られるものがあり、 この値と照らし合わせて、自動引き上げの閾値である40,000を超えているかどうかを確認するとよさそうです。 まちのコインの場合は5500〜6500程度でした))。

こちらもは挙動の確認をしたかったので30→60→120と変化させて観察してみたのですが、 code_region_size に影響が出るほどではありませんでした。 こちらも元の code_region_size が小さいため、観測できるほどの変化がなかったようです。

まとめ

YJITを有効化するだけで平均9%のレスポンスタイム短縮効果が得られたのは、非常にコスパの良いチューニングでした。 他社事例のように10%単位での高速化は見られませんでしたが、それでも十分な結果と言えるでしょう。

YJIT有効化部分を読んで、「そんなにいきなり有効にして大丈夫なの?」と思った方もいるかもしれません。 実際のところ厳密な回帰テストなどは行わず、本番環境適用前の検証はCIの通過と、 クライアントアプリとの疎通確認環境での簡単な動作確認のみでした。

検証を簡略化できた理由として、 「エラーバジェットの残量が十分にあった」こと、 「不具合が発生したとしてもすぐにロールバックできる仕組みがある」こと、 の2点が挙げられます。

「YJITの有効化」で触れたとおり、有効化直後はレスポンスタイムの悪化がありましたが、 エラーバジェットを枯渇させるほどのものではありませんでした。 今回の件は、エラーバジェットを正しく使えた事例としても意義があったと思います。

これからもエラーバジェットをうまく使って、新しい技術や最新のアップデートを取り入れていきたいと考えています。

参考資料


カヤックでは試行錯誤が好きなエンジニアを募集しています! hubspot.kayac.com

*1:最近のRailsでは、デフォルトのHTTPサーバーとしてpumaが使われています。 まちのコインではマルチテナントをデータベースレベルで実現するためにApartmentを使用しており、 これがマルチスレッド方式であるpumaとの相性が悪く、pumaではなくunicornを採用したという歴史があります。 まちのコインのApartmentについては こちらの記事 を御覧ください。

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で使用できない機能を使用した場合でもエラーにならない可能性があり、テストとはいいがたいものになります