気が付けば2016年も末…サーバレスがブームだった時代は過ぎ、なんだかサーバレスが当たり前の時代になりましたね。じゃあnginxもいい加減サーバレスになってもいいと思いませんか?
なにが必要だったか
nginxにはOpenRestyという、いい感じにモジュールが追加されたディストリビューション(に近いもの)があります。
素のnginxの機能はそのままに、luaで高速に動的な結果を返すことができ、簡単なアプリケーションであれば、これだけで作ることができます。あまり大きな規模のアプリケーションを作るのに向いているとは思わないですが、DBにアクセスするだけとか、内部APIに制限を掛けて外部に出すとか、リクエスト形式を変えるとか、そういうときに大変便利なサーバです。(それ以上のこともできます)
一方でAWSにはAWS Lambdaといういい感じのサーバレスな実行環境があり、Amazon API Gatewayという仕組みと組み合わせることで、アプリケーションサーバを構築することができます。あまり大きな規模のアプリケーションを作るのに向いているとは思わないですが、AWSサービスにアクセスするだけとか、内部APIに制限を掛けて外部に出すとか、リクエスト形式を変えるとか、そういうときに大変便利なサーバレス環境です。(それ以上のこともできます)
言い換えると、OpenRestyはサーバフル環境でのアプリケーションサーバの代替品であり、LambdaはAWSサーバレス環境でのアプリケーションサーバの代替品です。
これはもう組み合わせてプログラムがどっちでも使えるようにするしかないですよね。
どうするか
共通化ということだと、どちらの環境でも動くプログラムを用意するのが理想的ですが、たとえばOpenRestyのLuaコードをJavaやPython上で動かせるように頑張ったとすると、間違いなく開発負荷は増大しますし、実際には互換コード上の問題で運用(保守)負荷も増えるでしょう。
となると、nginxをexecするのがなんだかんだで最善のように考えられます。AWS Lambda環境は「サーバレス」ですが実質Amazon Linuxのコンテナなので、普通のコンテナでできることはたいていできます。もちろんプロセスのexecもできます。Lambdaコンテナは再利用されるので、運が良ければ、execしたプロセスごとコンテナを再利用できて、2回目からはexecしないで済むかもしれません。(できます)
また、できれば、nginxの設定ファイルにもバイナリにも手を加えたくはないです。共通化と言っても、設定全てやり直しというのでは、ありがたみが半減します。サーバ環境で使っていたnginxをそのまま持ってきたら動く、というのが理想です。(だいたいできます)
なお言語
パフォーマンス測定されているのを見ると、Pythonが一番良さそうなのでPythonにします。Linuxというプラットフォーム上も、Pythonが一番いいような気がします。
実装
Pythonでリクエストを再送させてみる
API Gateway経由のLambda環境ではリクエストは分解されて到達します。どういうことかというと、Lambdaは汎用的なもので、API Gatewayから起動したものでも、他のイベントソースから起動したものでも、lambda_handlerに対して第一引数でjsonで言うところのobjectの形で呼び出し元の情報が渡されます。Pythonならdictになります。
httpリクエストも分解されてこのobject内に別々に入っているので、拾い集めて、再び一つにまとめて元のリクエストを作成する必要があります。幸いなことにAPI GatewayにはLambdaプロキシ統合というウェブフレームワーク用の機能が今年の9月頃にリリースされたので、同じようなことを力技で達成していた先人に比べるとだいぶ楽に実現できるようです。マッピングテンプレートに手を触れる必要はなくなりました。とはいえ、httpヘッダを拾い集めてプロトコルを再構築するのは大変なので、Pythonのhttplibに任せます。
実際にどういうデータが入ってくるかは現時点ではあまりドキュメント化されていません。とりあえず下記のプログラムをLambdaコンソールからテスト実行してハンドラどのようなデータが渡されているかを眺めてみます。
import json
import httplib
def lambda_handler(event, context):
conn = httplib.HTTPConnection('xxx.xxx.xxx.xxx', port=80)
body = json.dumps(event)
conn.request("GET", "/", body, {})
return {
"statusCode": 200,
"body": "{}"
}
xxx.xxx.xxx.xxxのサーバではnc -l 80
してたと思ってください。こんなデータが取れます。
{
"body":"{\"test\":\"body\"}",
"resource":"/{proxy+}",
"requestContext":{
"resourceId":"123456",
"apiId":"1234567890",
"resourcePath":"/{proxy+}",
"httpMethod":"POST",
"requestId":"c6af9ac6-7b61-11e6-9a41-93e8deadbeef",
"stage":"prod",
"identity":{
"apiKey":null,
"userArn":null,
"sourceIp":"127.0.0.1",
"caller":null,
"cognitoIdentityId":null,
"user":null,
"cognitoIdentityPoolId":null,
"userAgent":"Custom User Agent String",
"accountId":null,
"cognitoAuthenticationType":null,
"cognitoAuthenticationProvider":null
},
"accountId":"123456789012"
},
"queryStringParameters":{
"foo":"bar"
},
"httpMethod":"POST",
"pathParameters":{
"proxy":"path/to/resource"
},
"headers":{
"Via":"1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)",
"Accept-Language":"en-US,en;q=0.8",
"CloudFront-Is-Desktop-Viewer":"true",
"CloudFront-Is-SmartTV-Viewer":"false",
"CloudFront-Forwarded-Proto":"https",
"X-Forwarded-Port":"443",
"X-Forwarded-For":"127.0.0.1, 127.0.0.2",
"CloudFront-Viewer-Country":"US",
"Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Upgrade-Insecure-Requests":"1",
"Host":"1234567890.execute-api.us-east-1.amazonaws.com",
"X-Forwarded-Proto":"https",
"X-Amz-Cf-Id":"cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==",
"CloudFront-Is-Tablet-Viewer":"false",
"Cache-Control":"max-age=0",
"User-Agent":"Custom User Agent String",
"CloudFront-Is-Mobile-Viewer":"false",
"Accept-Encoding":"gzip, deflate, sdch"
},
"stageVariables":{
"baz":"qux"
},
"path":"/path/to/resource"
}
あー、なんかnode.jsでの公式ドキュメントっぽくはあるんだけどちょっとpath違うよね…
Lambdaコンソールで見えるjsonがそのまま第一引数に入るようですね。
ということがわかったので、これを元にhttpリクエストを再構築します。
import json
import logging
import subprocess
import httplib
import urllib
def lambda_handler(event, context):
conn = httplib.HTTPConnection('xxx.xxx.xxx.xxx', port=80)
method = event["httpMethod"]
params = event["queryStringParameters"]
uri = event["path"] + ("?" + urllib.urlencode(params)) if params else ""
headers = event["headers"]
body = None
conn.request(method, uri, body, headers)
return {
"statusCode": 200,
"body": "{}"
}
POST /path/to/resource?foo=bar HTTP/1.1
Content-Length: 0
Via: 1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)
Accept-Language: en-US,en;q=0.8
CloudFront-Is-Desktop-Viewer: true
CloudFront-Is-SmartTV-Viewer: false
CloudFront-Forwarded-Proto: https
X-Forwarded-Port: 443
X-Forwarded-For: 127.0.0.1, 127.0.0.2
CloudFront-Viewer-Country: US
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Upgrade-Insecure-Requests: 1
Host: 1234567890.execute-api.us-east-1.amazonaws.com
X-Forwarded-Proto: https
X-Amz-Cf-Id: cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==
CloudFront-Is-Tablet-Viewer: false
Cache-Control: max-age=0
User-Agent: Custom User Agent String
CloudFront-Is-Mobile-Viewer: false
Accept-Encoding: gzip, deflate, sdch
それっぽいリクエストになりました。
nginxをLambda環境で動かす
さて、次はnginxをLambda環境で起動させなければなりません。Lambdaは専用のコンテナの中で一般ユーザ権限で動きます。書き込みができるディスクは/tmp
しかありません。
また、外部からの接続は不可能とのことですが、ポートをlisten
することはできるようです。しかし、rootでないので、80番はlisten
できない気がします。
それぞれ順番に対応します。
nginxのディレクトリ構成
まずは、ディレクトリ構成を変えない方法を検討します。/tmp
しか書き込めないので、ログやらソケットファイルやらは/tmp
以下である必要があります。普段からログを/tmp
に出している奇特な人はいないと思いますし、それで運用したいとも思わないでしょう。fluentdのようなものでただちに転送削除する場合はわからないですが。
/tmp
以下にログを出すようにリビルドしたり、設定ファイルで調整することはできますが、それは結構なコストです。できれば自動でどうにかしたいですし、luaプログラムを設置しているパス情報もLambda環境に合わせて変えるようなことは避けたいです。
となると思い浮かぶのはchroot
ですが、chroot
するにはroot権限が必要です。じゃあfakechrootですね。fakechrootは一部のシステムコールを乗っ取って、一般ユーザでもchroot
できたように見せかけるプログラムです。どこまでできるのか実はあんまり知らないんですが、少なくともfakechroot
されたプロセスから見たときのファイルシステムの/
をchroot
したディレクトリに見せかけることが可能です。
fakechrootはAmazon Linuxではyum
で入りますが、Lambda環境でyum
というわけにはいかないので、yum
で得たバイナリを直接持ち込むことにします。と、試してみたらLambda環境にはchroot
も用意されていませんでした。fakechroot
は名前に反してchroot
まではしてくれず、chroot
と組み合わせて使う必要があります。しょうがないのでPythonでos.chroot
しました。
なお、いまさらですが、Lambdaは持ち込むもの一式を1つのzipファイルに固めてアップロードすると、/var/task
以下に展開して実行してくれます。OpenResty(nginx)、fakechrootは今回作成したLambdaハンドラと合わせて全部zipに固めて持ち込みます。
ディレクトリ構成の再構築
fakechrootの場合、シンボリックリンクでchroot
の外を参照できるので、/tmp/root
あたりに、nginx専用のchroot環境をシンボリックリンクを使いながら作成します。ログディレクトリ、pidファイル、socketファイルの作成場所が確実に/tmp
以下になるように調整します。擬似root一式をzipに埋めておいて、cpするか、逆に書き込みが必要なディレクトリに/tmp
へのシンボリックリンクをあらかじめ用意しておいた方が楽でしたね。
listenポートの調整
これはどうにもなりません。fakechroot
もそこまではどうにもならないようなので、設定ファイルをプログラムで実行前に書き換えるようにします。
それ以外の設定ファイル調整
実行ユーザ/実行グループの無効化が必要かと思いきや、調整不要でした。nginxかしこい。
rpath
問題
OpenRestyを普通にビルドすると、LuaJITを共有ライブラリとしてリンクします。OpenRestyを公式リポジトリ(CentOS6)から連れてくると、他にも依存している共有ライブラリがあることがわかります。これらの共有ライブラリは標準的なライブラリのパスではなく、rpath
と呼ばれる設定としてnginxのバイナリにフルパスで記録されています。
困ったことに、fakechroot
してもrpath
(や、ld-linux.soが見るパス)までは書き換えることができません。もちろん、Lambda環境にはLuaJITの共有ライブラリなんか用意されていませんし、これらの共有ライブラリがないと、そもそもnginxが起動すらしません。
そこで、LD_LIBRARY_PATH
を使います。LD_LIBRARY_PATH
は大雑把にいうと、rpath
に先だって参照されるディレクトリを指定する環境変数です。ここにあらかじめ共有ライブラリの本当のパスを設定してやることで無事にライブラリをロードすることができます。
rpath
はビルド時に-rpath
で指定されていたりしますし、正常環境でバイナリに対してldd
するとだいたい想像が付きますが、人間が手打ちするのも面倒なので、readelf -d
で読みだして、chroot
前にLD_LIBRARY_PATH
を設定してやることにします。
ただし、これはあくまでも擬似的な手法なので、rpath
からロードした共有ライブラリが別なrpath
を持っていて、それぞれ同名のライブラリをロードするが、実体は別、というような嫌がらせめいた構成だと破綻します。見たことはないですが。
Lambdaからのレスポンス
調子に乗ってLambdaからnginxのレスポンスヘッダをそのまま返していると、transfer-encoding:chunked
が入っていたりするとAPI Gatewayのおそらくエッジサーバあたりが永久にレスポンス待ちになり、ログは200
で記録されているのに、クライアントは長時間待たされ、504 Gateway Timeout
という状態になって発狂しそうになります(なりました)。レスポンスヘッダはちゃんと選ばないとダメなようです。
できあがり
そんなわけで出来上がったものをこちらに用意しておきました。
https://github.com/nishemon/lambda-exec-wrapper
モノとしては何かを起動して実行してhttpリクエストをぶち込むだけのプログラムになるので、fakechrootする/しない、実行するバイナリ、通信方法、設定ファイルの書き換えなどの動作の詳細は設定ファイルに切り出してみました。だいたいどんな動作をしているかわかるかと思います。これで、nginx以外を動かしたくなっても安心ですね。テストしてないけど。
{
"fakechroot": {
"rewrite": {
# 設定ファイルのlistenをUNIXドメインソケットに変更
"/usr/local/openresty/nginx/conf/.+\.conf$": {
"^\s+listen\s.*$": "listen unix:/usr/local/run/nginx.sock;"
}
},
# /から/tmp/rootへリンクが必要なもの
"symlink": [ "/dev" ]
},
# 以下、chroot環境下でのパス
"mkdir": ["/usr/local/openresty/nginx/logs", "/usr/local/run",
"exec": "/usr/local/openresty/nginx/sbin/nginx",
"connection": "unix:/usr/local/run/nginx.sock",
# nginxに渡すヘッダーを追加または書き換える
"header": {
"Host": "www.example.org"
}
}
OpenRestyの準備
Lambdaハンドラがあっても、Lambda用にOpenResty用のバイナリやリソースファイルを手動で切り出すのは大変です。stowでも使ってない限り、野良ビルド派でも、パッケージ派でも、まずここでLambdaに載せる気がなくなりますよね。
しかし今どきはoverlayfs
があるので、これを利用するとパッケージでもビルドでもこんな感じでOpenRestyをLambda用に構成できます。/
をoverlayしてるだけなので、なるべく素のAmazon Linuxでやってください。
mkdir rootimg fakeroot work
sudo mount -t overlay overlay -o lowerdir=/,upperdir=rootimg,workdir=work fakeroot
sudo chroot fakeroot
yum install fakechroot-libs
## ここで 公式のリポジトリ(CentOS6)を設定: http://openresty.org/en/linux-packages.html
yum --disablerepo="*" --enablerepo="openresty" install --releasever=6 openresty
## または、yumの代わりに野良ビルドしたものをmake install
exit
sudo umount fakeroot
## overlayfs用のディレクトリとyumのゴミを捨てる
sudo rm -fr work fakeroot rootimg/{root,var,etc,tmp}
パフォーマンス
というところで、ほとんど実環境のままで動作することはお分かりいただけたかと思います。気になるのは実環境とのパフォーマンス差ですよね。nginxの共有メモリにnginxの起動時刻を置いて、それを返すだけのコードで測定してみます。
似た動作をする完全Python実装のLambdaとt2.micro(http)、t2.micro(http)+ELB(http/https)での比較です。測定はAPI Gatewayの関係で手元のabが使えなかったので、こいつをもう首にしちゃいまして、time curl
を10回ぐらい叩くという原始的な手法で行いました。Lambdaはコンテナ単位では並列実行しないので、並列アクセス時のパフォーマンスは考えなくてもいいはずです。
初回を除く平均 | 初回を除く最小値 | 初回を除く最大値 | 初回 | API Gateway初回 | |
---|---|---|---|---|---|
API Gateway + Lambda(nginx) | 142 | 124 | 150 | 389~1444 | 411 |
API Gateway + Lambda(Python) | 152 | 139 | 174 | 536 | 406 |
Lambdaログ | 2.16 | 0.73 | 12.83 | 1.23~12 | 0.95 |
t2.micro | 8.3 | 8 | 10 | - | - |
with ELB(http) | 13.1 | 13 | 15 | 265 | - |
with ELB(https) | 89.9 | 87 | 93 | 357 | - |
単位はすべて[ms]です。
Lambda内の通信によるオーバーヘッドはほぼ0と見て良さそうです。初回起動がたまに大きくなりますが、これがexecのせいなのか、名前解決なのか、zipファイルが重いせいなのか、重いエッジサーバがあるのかは調査しきれませんでした。ELBでもなぜか初回は遅かったので、名前解決の都合っぽくはあります。Lambda自体で実行時間として記録されているのは初回の最大でも10msぐらいです。ただ、これに本当にnginxの起動が入っているかというと、だいぶ怪しいようです。
一番右端の「API Gateway初回」はLambdaをLambdaコンソールからテストによって起動したと考えられるコンテナにAPI Gatewayでアクセスした場合の初回実行時間です。Lambdaの起動以外に、Lambdaへの経路の確認のようなものが入るんでしょうか?それともやっぱり名前解決?これ単体でも結構大きいですね。
ちなみに、この測定時はLambdaをメモリ128M設定で動かしています。Lambdaに割り当てられるCPUはメモリ量に比例するんですが、このときメモリを1Gにしてもレコードはほぼ同じでしたので省略しています。
参考としてフィボナッチ数列も計算させてLambdaで使えるCPU性能自体も計測しておきます。
/f/40 | API Gateway + Lambda(128M) | API Gateway + Lambda(192M) | API Gateway + Lambda(1G) | t2.small |
---|---|---|---|---|
ms | 12571 | 8098 | 1616 | 978 |
SSL/API Gatewayのオーバーヘッドを勘案すると、Lambda最高性能の1.5G設定時にCPU100%になると推測できます。
あとは単純な四則演算でLambdaで動かした場合の速度はほぼ求められると思います。
以下は実験に使用したOpenResty設定です。コンパクトに抑えるために設定ファイル内にluaコードを埋めています。
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
lua_shared_dict info 1m;
init_by_lua_block {
ngx.shared.info:set('time', ngx.time())
function fib(num)
if num <= 2 then
return 1
end
return fib(num - 2) + fib(num - 1)
end
}
server {
listen 80;
server_name localhost;
location / {
content_by_lua "ngx.say(ngx.shared.info:get('time'))";
}
location ~ /f/(\d+) {
set $value $1;
content_by_lua "ngx.say(fib(tonumber(ngx.var.value)))";
}
}
}
まとめ
というわけでサーバがあっても無くてもOpenRestyを使えるようにする話でした。これでいつでも安心してOpenRestyを使えますね。