そもそもnjsとはなんぞや?という感じですが、簡単に言うとNginxをJavaScriptで制御できるようにするためのモジュールです。
njsだけでなくメジャーなものでLuaというスクリプト言語があり、そちらの方が歴史も古く、nginxに対する制御も色々できるようですが、新しい構文を覚えるのが面倒だったので選択しませんでした。
njs自体の初出がここ最近ということもあり、ほとんどネット上に情報がなかったので筆を取りました。
経緯とか
個人でNext.jsで開発をしていたのですが、ローカルでLambda関数の実行ができてそのコードをそのままAWSに上げて実行することができた方が楽なのでまずバックエンド側の開発環境を作ろうというところが始まりでした。
まぁ既にDockerイメージにそういう感じのがあるだろうと思って探すと、amazon/aws-lambda-ruby:2.7
というイメージがありました。
早速使ってみると
curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"payload":"hello world!"}'
といった感じで実行するとのことでした。
おや・・・?これだとパスとか指定できなくない?
という感じで少し困りましたが、**Lambda関数毎にDockerコンテナ作ってURLとコンテナを紐付けるミドルウェア的なルーター的なコンテナがあればいいんじゃね?**という解決策を思いつきました。(後から調べるとAWS SAM Localを使うのが一般的という感じがしたので当時の自分に誰か教えてくれ。)
最初は脳死でRailsとかLaravelでルーティングしようとか思っていたのですが、ルーティングだけ使うのにフレームワークを入れるのは明らかにオーバースペックだったので、じゃあphp-fpmとかRackだけ入れてルーティング用のスクリプトを噛ませようと思いましたが、ブラウザ→Nginx→hoge.rb→Lambdaというリクエストのリレーは効率が悪いのと、結局hoge.rbからHttpClientを実行する必要があるので管理が面倒と感じて、nginxだけでリクエストとレスポンスを整形してプロキシできないか?という感じで調べてnjsに辿り着いたというわけです。
なので最初からnjsを使おうと思って使ったわけではありませんでした。
とりあえず作ってみる
えみゅれーたーって言ってみたいのでapi_gateway_emulatorとしています。
あくまで「っぽいもの」なので、自分がローカルで開発するための最低限の部分しか作っていないのでご注意ください。
# 1.19なら最初からnjsのモジュールが使える
FROM nginx:1.19-alpine
RUN apk --update add \
bash \
&& rm -rf /var/cache/apk/*
WORKDIR /api_gateway_emulator/js
# njsのモジュールを読み込むconf
COPY ./docker/settings/api_gateway_emulator/nginx.conf /etc/nginx/
# ルーティング等を行うconf
COPY ./docker/settings/api_gateway_emulator/default.conf /etc/nginx/conf.d/
COPY ./docker/settings/api_gateway_emulator/js/* /api_gateway_emulator/js/
FROM amazon/aws-lambda-ruby:2.7
# ローカルにfunctionsディレクトリを作り、Lambda用のファイルを入れておく
COPY ./functions/* /var/task/
...
# モジュール読み込み
load_module modules/ngx_http_js_module.so;
load_module modules/ngx_stream_js_module.so;
...
http {
...
}
# jsファイルをインポート
js_import /api_gateway_emulator/js/main.js;
# jsの関数をインポートしてconf上で使用できるようにする
js_set $createUrl main.createUrl;
server {
# subrequest(後述)を利用した場合、
# デフォルトのDNSサーバーで名前解決できなくなるので
# DockerNetworkで名前解決をする
resolver 127.0.0.11 ipv6=off;
...
# バッファを設定する
# 値は適当ですがファイルアップロードをしたい場合等に調整する必要があります
subrequest_output_buffer_size 600k;
# Originは開発環境に合わせて変更する
add_header Access-Control-Allow-Origin 'http://localhost:22281';
add_header Access-Control-Allow-Methods 'GET,POST,PUT,DELETE,OPTIONS';
add_header Access-Control-Allow-Headers 'Accept,Authorization,Cache-Control,Content-Type,Keep-Alive,Origin,User-Agent,Cookie';
add_header Access-Control-Allow-Credentials true;
# preflight対応
if ($request_method = 'OPTIONS') {
return 200;
}
# ここを通してLambdaのコンテナにプロキシする
location /container/proxy {
proxy_pass $url;
}
# まずここにマッチする
location / {
# subrequest後に元のURIが保持されないようなのであらかじめ変数に入れておく
# ここでlocalhost:9000/2015-03-31/...からコンテナ名への変換を行う
# (http://container_name:9000/2015-03-31/...といった形)
set $url $createUrl;
# このjs内でLambdaが受け取れる形にRequestの整形をする
# その後、/container/proxyへsubrequestを行う
js_content main.requestLambda;
}
...
}
default.confは結構ハマりポイントが多かったです。
subrequestはNginxの内部向けのリクエストと解釈していますが解釈として正しいかはわかりません。
/container/proxy
はsubrequestを送るパスですが、別に名前はなんでもいいです。
js_set
もNode.jsのimportっぽいようなそうでもない感じで、set $url $createUrl;
も分かりにくいと思いました。
実際は$urlという変数に$createUrlという変数の中に入った関数(第一引数にリクエスト情報が入ったオブジェクトが暗黙的に入る)の結果をセットする
という挙動になります。
続いてnjsの中身ですが、情報がないのでひたすらリファレンスを見るしかないです。
なのでnjsの記事を他の人も書いてくれという気持ちを込めてこの記事を書いています。
Lambda用のマッピングはこちらを参考にしました。
// 必ず引数rをとる
// rはリクエストに関する全ての値が入っている
function requestLambda(r) {
// Lambda用にリクエストを整形
r.subrequest("/container/proxy", {
method: r.method,
body: JSON.stringify({
"httpMethod": r.method,
"headers": getHeaders(r),
"body": r.requestBody,
"queryStringParameters": r.args
})
})
.then((result) => {
// レスポンスの整形
var body = JSON.parse(result.responseBody);
var responseBody = {};
if ("body" in body) {
responseBody = body.body;
}
// ヘッダを読み取ったり整形したり
if ("headers" in body) {
...
if ("Location" in body.headers) {
// リダイレクトの場合
r.return(301, body.headers["Location"]);
return;
}
}
// r.returnでブラウザにレスポンスを返して終了
r.return(body.statusCode, responseBody);
})
.catch((result) => {
// エラーの場合
// r.errorはコンソールにログ出すのに便利
r.error(result.message);
r.return(result.status, result.message);
});
}
...
// 実際にコンテナ用のアドレスに変換している箇所
function createUrl(r) {
var domain = convertSlash(r.uri);
return "http:/" + domain + ":9000/2015-03-31/functions/function/invocations";
}
// conf上で使う関数をエクスポートする
export default { createUrl, requestLambda };
流れをまとめると、 ブラウザから/hogehogeにリクエスト
→location /でキャッチ
→njsでリクエスト整形
->/container/proxyにsubrequestを送る
→Lambdaコンテナへproxyされる
→njsでレスポンス整形
→ブラウザにレスポンスを返す
という感じになります。
実装は以上で、あとはdocker-compose.ymlを見ていきましょう。
version: '3'
services:
api_gateway_emulator:
build:
context: .
dockerfile: ./docker/images/api_gateway_emulator/Dockerfile
networks:
- api_gateway_emulator
ports:
- 22280:80
command: bash -c "/usr/sbin/nginx -g 'daemon off;'"
base: &lambda
container_name: base
build:
context: .
dockerfile: ./docker/images/lambda/Dockerfile
volumes:
- ./functions:/var/task:cached
command: base.handler
networks:
- api_gateway_emulator
tty: true
# 関数が増えた場合はbaseを継承して追加していくだけ
google_redirect:
<<: *lambda
container_name: google_redirect
command: google_redirect.handler
networks:
api_gateway_emulator:
external: true
関数が増えてもcontainer_nameとcommandを追加していくだけなので簡単です。
この状態でdocker-compose upをすればcurl http://localhost:22280/base
のようにすれば結果が返ってきます。
あとはdocker networkで他のコンテナから繋いだりして開発していくことができます。
解説用にコード改変や省略をしているので、全コードはgitに置いてあります。
誰かの参考になれば幸いです。
あとがき
今回njsでAPI Gatewayっぽいものを作ってみましたが、かなり色々応用が効く技術だと思います。
開発環境で外部APIを利用箇所用のMockサーバーを作ったりできますし、LuaだとできるようですがNginxから直接MySQLを実行できるようになればマイクロサービスであればバックエンドのフレームワークが不要になるかもしれません。
そうなるとnjs部分をTypeScriptで記述したりすることにもなると思います。
Luaすごい。