HexavilleでAWS Lambda Custom Runtimeを試してみました (Server Side Swift)
この頃は保険数理がやりたくてずっと数学をやっていたのですが、先日のAWS re:Invent 2018で AWS LambdaがCustom Runtime(以下、独自ランタイム)をサポートしたということで、重い腰を上げてHexavilleのSwift実行を、独自ランタイムに変更するべきか検証してみました。
Hexavilleを知らない人が大多数だと思いますので、軽く触れると、HexavilleはSwift専用のサーバーレスWebアプリケーション開発フレームワークで、プロジェクトの生成からデプロイまでをエンドツーエンドで行えるものです。(ServerlessのSwift特化版)
従来のWebアプリケーション開発フロー(ルーティングしてレスポンスハンドラを書いて…)を一切変えることなく、サーバーレスWebアプリケーションを開発できるのが最大の特徴です。
HexavilleにおけるSwift実行からレスポンスまでのフローは、
- Node.jsからSwiftのELFをプロセスとして実行
- Node <- >Swift間はドメスティックなプロトコルをIPCでやり取り(ログなどの標準出力や、処理結果)
- HexavilleからCRLFの終端を受け取るとNode.jsはSwiftプロセスを破棄し、lambdaの終了関数を実行する
しかし、この手法はプロセス生成やIPC前後の値のシリアライズ/デシリアライズに結構なオーバーヘッドがあって、せっかくSwiftで書いているのに、Node.jsのみで実装されたアプリケーションに比べ、リクエスト数に応じて1〜2割くらい性能劣化があるのが事実でした。(もちろん計算量の多いプログラムであれば、オーバーヘッドよりSwiftのパフォーマンスが勝るケースもあります。)
この問題を改善しようと実験的に生まれたのが、https://github.com/noppoMan/node-native-extension-in-swift
という、Node.jsのネイティブアドオン機構を利用し、Swiftプログラムを共有ライブラリ化して、実行時にリンクしちゃえ というかなりワイルドなものです。
これをHexavilleに導入しようしようとずっと思っていたのですが、時の流れは怖いもので気づけばもう2018年も終わりが近づいてきた、という矢先にre:Invent 2018で衝撃の発表がありました。
それが「Custom AWS Lambda Runtimes」です。
Custom AWS Lambda Runtimes(独自ランタイム)
もうすでにいろいろな記事が出ているので、この記事内での詳細な解説は控えますが、独自ランタイムとは「シェルスクリプトをLambdaから直接実行できる」というものです。
「え、これまでも別にShellは実行できたのでは?」
と思うかもしれませんが、それはNode.jsやPythonなどLambdaでサポートされた言語からプロセスとしてShellを実行していたというもので、これはプログラミング言語を介さずにLambdaに登録したshellの関数が直接実行できるというものです。
よって、「UbuntuなどでビルドしたSwiftアプリのELF」と「そのELFの実行結果を`echo`するシェルスクリプト」をzip化して、lambda関数として登録すれば、これまでの面倒な工程を踏まずに非常に簡単に、Lambda上でSwiftが実行できるようになりました。
僕らの開発している、AWSSDKSwiftでも、バージョン2.0.2から独自ランタイムが利用できるようになったので、興味ある方はぜひご利用ください。(`runtime: .provided`のように指定するだけです。)
気になるパフォーマンス
独自ランタイムであれば、Node.jsからのプロセス生成や値の受け渡しのオーバーヘッドが無くなり、パフォーマンスも向上するのでは、という淡い期待を抱いて早速両者のベンチマークを取ってみました。
ベンチマークテストの条件
Node.js Runtimeと独自ランタイム上にそれぞれ同様のSwift製のプログラム(GET /でWelcome to Hexaville! というテキストをレスポンスする)を配置し、wrkコマンドでベンチマークを取得してみました。
実行コマンド: wrk -d 30s -t 4 -c 20 https://xxxxxxx
ベンチマーク結果
Node.js 8.1 Runtime
Running 30s test @ https://xx.execute-api.xx.amazonaws.com/staging
4 threads and 20 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 215.59ms 33.22ms 506.45ms 85.40%
Req/Sec 23.04 10.21 50.00 61.11%
2768 requests in 30.07s, 1.76MB read
Requests/sec: 92.05
Transfer/sec: 59.78KB
独自ランタイム
Running 30s test @ https://xx.execute-api.xx.amazonaws.com/staging
4 threads and 20 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 380.08ms 41.44ms 691.87ms 79.12%
Req/Sec 13.78 7.26 40.00 81.15%
1566 requests in 30.08s, 0.99MB read
Requests/sec: 52.05
Transfer/sec: 33.81KB
衝撃の結果が出ました。
30秒間に処理されたリクエスト数が、
- Node.js Runtime: 2768
- 独自ランタイム: 1566
と約1.8倍もNode.js Runtimeの方が、独自ランタイムに比べて処理速度が優れておりました😱
この差が一体どこで生まれているのか詳しく分析できていないのですが、
- 独自ランタイムは、shellスクリプトということで最適化しづらい(Runtimeプログラムをプーリングできないので、毎回初期化処理が走る?)
- 独自ランタイムではイベントデータがAWSのどこかに一時的に保持されていて、それをhttp通信(多分イントラ)を用いて取得する仕様であるため、ここのネットワークオーバーヘッドがかかっている(これは隠匿されているだけで、他のRuntimeも同様の可能性もあります。)
などがまず考えらました。
ということで、独自ランタイムを試してみたのですが、Node.js Runtimeからの移行は一旦ペンディングして、これからの進化をもう少し待ちたいと思いました。
ただ、未だLambdaに対応していない言語でそこまで速度を気にせず簡単にサーバーレス化したいという要件に関しては、独自ランタイムは非常に素晴らしいものだと感じました。
次のブログでは、Swiftをネイティブアドオン化するやつに挑戦して、それのレポートをかければと思います。
P.S. 独自ランタイムのデバッグに関して
独自ランタイムを使う際のティップスに関して最後に一つだけ。
Lambdaの独自ランタイム上では、デバッグログなどを標準出力(Swiftではprint関数)に書き込むと、Lambda関数の処理結果として扱われてしまう点に注意が必要です。よって、独自ランタイム上で標準出力への書き込みをcallするのは基本的には一回のみで、プログラムの終了時に行うべきです。
その代りに、独自ランタイムでは、stderrがcloudwatch logsと繋がっているので、デバッグログを出力したい場合は、
Shell
# 標準出力を標準エラーにリダイレクトする
echo $SOMETHING 1>&2
Swift
FileHandle.standardError.write("foo")
を利用して、標準エラーにログを書き込むようにしましょう。