まえがきのまえがき
この記事の執筆はCustom Runtimeの公開前でした
なので基本re:Invent2018以前の前提で書かれてます
一応Custom Runtimeについても追記しておきましたので「うるせぇ結論だけ聞かせろ」って方は追記部分だけお読みください
あとなんかこんなのがとかいつの間にか出来てるっぽいのでちゃんとした話を聞きたい人はそっちに行けばいいと思います(投げやり)
以下本文
ハンズラボでエンジニアをしている回路(@qazx7412)です
普段はpostforのバックエンドの担当としてServerlessFrameworkやAWSやPythonと格闘したり、臨時で他のチームのテストを手伝ったりしたりしてます
今回はAWSでのサーバーレスで活躍するLambdaに関する話ですでした(過去形)
tl;dr
- nodeのexec等を使って叩いてやればLambdaで対応していない言語で強引にサーバーレスすることができる
- Apex/UpではDocker上でLambdaで動くようにコンパイルしてCrystalに対応をしてる
- 参考にしてServerlessFrameworkでデプロイしてみた
- (追記)Custom Runtimeでも動かしてみました
- (追記)ついでにServerlessFrameworkでそれっぽく動かして見ました
ことの始まり
最近プライベートでの開発する量が増えました
それで考えるようになったことがインフラをどうやって無料で確保するかです
業務ならともかく個人なら財布のことを考えて無料か実質無料くらいのコストで済ませたいですね
実際自分の場合はGCPの無料枠のf1-microインスタンスや、みんな大好きherokuの無料枠や、さくらインターネットのArukasの無料枠でコンテナが一つ無料で動かせるのとかを活用してます
しかし本音としてはは普段触り慣れていてるAWSを使いたいところです
そこで出てくるのがLambdaです
AWS Lambdaだよ
コード書いてをアップロードするだけで実行してくれるすごいやつだよ
言わずとしれたAWSのFaaSです
関数のリクエスト数とメモリと実行時間による課金なので個人でちょっとしたリクエストが少ない感じの物を動かす程度ならばお財布に優しいサービスです
ですがこういったFaas(やPaaS等)の宿命として対応している言語しか使えないといった制約があります
実際執筆時現在使える言語はこんな感じ
- C#(.NET Core 1.0, 2.0, 2.1)
- Go 1.x
- Java8
- Node.js (6.10, 8.10)
- Python(2.7, 3.6, 3.7)
- Ruby 2.5 <- New!!
RubyとPHP以外の主要な言語にはおおよそ対応してる感じですかね? Ruby来ちゃったんだよなぁ…
ですが私はプライベートではこれまでRubyを使って来ましたし最近Crystalを触り始めたのでこれで開発をしてみたい欲があります
ですがCrystalでサーバーレスをしようとしても マイナー言語なので Lambdaでは対応していないので使えません
でも個人で使うならもっと自分の好きに言語を選びたい…
なのでどうにかする方法を考えます
Lambdaで非対応な言語を動かす
公式で対応していない言語をLambdaで動かすにはいくつかの方法があります
例えばJavaが動くということはJVM言語が動くはずなのでScalaやKotlinを動かしてみるとか、TypeScriptやCoffeeScript、ElmにOpalみたいなAltJSをクロスコンパイルしてやるとか今更わざわざCoffeeScriptとか使いたいかどうかはともかく、対応している言語から他の言語を呼び出す(例えばPython3からrustを呼び出したり出来る)とかWebAssemblyとかやりようはいろいろあります
そしてこれらの手段に対応していないような言語でも最終手段があります
先程Lambdaのことを「コードをアップロードするといい感じに実行してくれるすごいやつ」みたいなことを書きましたが厳密にはこれは正確ではありません
正確には「コードをアップロードすると実行するためのコンテナを用意してくれて実行してくれるすごいやつ」です
何当たり前のことを言ってるんだって感じですが実際にはLambdaはコンテナの上で動作しています
なのでLambdaに使いたい言語を動かすのに必要な物を全部固めてアップロードしてnodeのexecのようなコマンドを実行する機能で無理やり叩いて実行してやれば理論上どんな言語でも動作するはずです
例えばRubyを動かすことを考えるなら、Traveling Rubyを突っ込んで実行してやれば無理やりですがサーバーレスRubyを実現することができます
(サーバーレスRubyに関する話はここやここなどにまとまっています)
具体例としてはこんな感じ
'use strict';
const exec = require('child_process').exec;
module.exports.hello_crystal = (event, context, callback) => {
const child = exec('/path/to/ruby ./path/to/script.rb');
child.stdout.on('data', (result) => {
callback(null,result);
});
child.stderr.on('data', (result) => {
callback(result);
});
};
まとめると今動かしたいCrystalを動かすならLambdaで動くようにコンパイルしてやってアップロードし、それを叩いてやればサーバーレスCrystalできるんじゃないかということです
そのためには
- コンパイルするための環境を用意しコンパイルする
- コンパイルした実行ファイルを一緒にしてデプロイしてやる
ことが必要です
そして実はこのやり方でサーバーレスCrystalを実現しているデプロイツールが存在します
Apex/Up
Apexと同じ作者によるサーバーレス用のデプロイツールがUpです(日本語での紹介記事)
コンパイル用のコンテナ作るために思想錯誤している最中に偶然見つけました
なんとCrystalに対応しています
このUpというツールはPaaSのように普通に作ったAPIやWebアプリをデプロイできるのが特徴らしいです
普段使っているServerlessFrameworkとかはどちらかというとCFnのFaaSに寄った形での強化版みたいなサムシングだったのでまるっきり思想が違いますね
デプロイしてみます
コードはサンプルほぼそのままです
{
"name": "up-crystal",
"profile": "<プロファイル名>",
"regions": [
"ap-northeast-1"
]
}
require "http/server"
port = ENV["PORT"].to_i
server = HTTP::Server.new(port) do |ctx|
ctx.response.content_type = "text/plain"
ctx.response.print "新たな光に会いに行こう"
end
server.listen
デプロイも簡単です
$ up
build: 120 files, 8.3 MB (718ms)
deploy: version 1 (9.454s)
stack: complete (22.085s)
それでできたAPIGatewayのアドレスを叩いてみましょう
$ curl https://hagehage.execute-api.ap-northeast-1.amazonaws.com/production/
新たな光に会いに行こう
拍子抜けするほど簡単に動きました
とりあえずサーバーレスでCrystalするという当初の目的自体は達成できました
しかし個人的にはサーバーレスならyamlでいろいろなを定義したいなというお気持ちがあるのでこれをServerlessFrameworkで動かすことを考えていきたいと思います
これがlambda向けにコンパイルするときのコマンドのようですが、これを参考にすれば他のデプロイツールでも動かせるはずです
$ docker run --rm -v $(pwd):/src -w /src tjholowaychuk/up-crystal crystal build --link-flags -static -o server main.cr
ServerlessFramework
ということで本命のServerlessFrameworkでCrystalを動かしてみます
今回は入ってきたJSONをそのまま帰すだけの簡単なAPIを作ってみます
.
├ buildfile/
│ └ main
├ src/
│ └ main.cr
├ deploy.sh
├ handler.js
└ serverless.yml
nodeのexecでコマンドを叩いてCrystalを呼び出します
'use strict';
const exec = require('child_process').exec;
module.exports.hello_crystal = (event, context, callback) => {
const child = exec(`echo '${JSON.stringify(event)}' | ./buildfile/main`);
child.stderr.on('data', (result) => {
callback(JSON.parse(result));
});
child.stdout.on('data', (result) => {
const stdout = result
.replace(/\"{/g,'{')
.replace(/}\"/g,'}')
.replace(/\\\"/g,'"')
.split('\n')[0];
callback(null,JSON.parse(stdout));
});
};
require "json"
stdin = JSON.parse(STDIN.gets_to_end)
p stdin["body"].to_json
標準入力でLambdaに渡ってきたデータをCrystalにぶち込んで同じく標準出力から結果を返してもらっています
いろいろ雑なのは許してほしい
あとはこれをコンテナでコンパイルしてServerlessFrameworkでデプロイしてやればいいですね
dockerの方はApex/Upと同じですがコンパイル元と先をそれぞれsrc/main.cr
と buildfile/main
に変更しています
デプロイするのにいちいち2つのコマンドを両方打つのはだるいので今回は一つにまとめておきます
docker run --rm -v $(pwd):/src -w /src \
tjholowaychuk/up-crystal crystal build \
--link-flags -static -o buildfile/main src/main.cr &&\
sls deploy
service: serverlesshellocrystal
custom:
defaultStage: dev
api_version: v0
common: common
provider:
name: aws
runtime: nodejs8.10
region: ap-northeast-1
stage: ${opt:stage, self:custom.defaultStage}
profile: <プロファイル名>
functions:
hello_crystal:
handler: handler.hello_crystal
events:
- http:
path: test
method: post
integration: lambda
ということで早速デプロイしちゃいます
$ ./deploy.sh
/opt/crystal/embedded/lib/../lib/libevent.a(evutil.o): In function `test_for_getaddrinfo_hacks':
evutil.c:(.text+0x1488): warning: Using 'getaddrinfo' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
/opt/crystal/embedded/lib/../lib/libevent.a(evutil.o): In function `evutil_unparse_protoname':
evutil.c:(.text+0xf0d): warning: Using 'getprotobynumber' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service .zip file to S3 (1.96 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
..............
Serverless: Stack update finished...
Service Information
service: serverlesshellocrystal
stage: dev
region: ap-northeast-1
stack: serverlesshellocrystal-dev
api keys:
None
endpoints:
POST - https://hage.execute-api.ap-northeast-1.amazonaws.com/dev/test
functions:
hello_crystal: serverlesshellocrystal-dev-hello_crystal
Serverless: Removing old service artifacts from S3...
あとはこのエンドポイントにpostでバシバシ叩いてやればこれで今度こそLambdaでCrystalが動くはずですね
動きました
最後に
ということで無事LambdaでCrystalを動かしてサーバーレスCrystalすることに成功しました
でも正直冷静に考えるとこんなオーバーヘッドを気にしないやり方ならJVMで動くScalaとかいろいろ先人の知見がありそうなRustとかのほうがよかったのでは…?
まぁTraveling Rubyを入れて頑張るよりは良いとは思うしRubyな人がちょっとLambdaでなんか動かしたいとかなら使ってもいいのではって気はする
今回はたまたま自分が触ってたからCrystalを題材にしましたが(少なくともLambdaでは)他の非対応な言語でもサーバーレス出来るかもしれない可能性はあると思うので諦めきれない人は頑張ってみるといいと思います
~~でも本当は公式がherokuのビルドパックみたいな仕組みを作ってくれるのが一番なんじゃないかな(露骨な要求)~~はえーよホセ
…という話だったのさ
というのがCustom Runtime発表前の話だったのです
でも来ちゃったんですよねぇ…悲しいなぁ
くよくよしててもしゃあないのでCustom RuntimeでもサーバーレスCrystalやってみます
最終的な物はこちらに置いてあります
Custom Runtime
まず公式ドキュメントを読む限りCustom RuntimeでLambdaを動かす場合は bootstrap
を起点にして実行されるようです
.
├ bootstrap
└ function.sh
#!/bin/sh
set -euo pipefail
# Initialization - load function handler
source $LAMBDA_TASK_ROOT/"$(echo $_HANDLER | cut -d. -f1).sh"
# Processing
while true
do
HEADERS="$(mktemp)"
# Get an event
EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
# Execute the handler function from the script
RESPONSE=$($(echo "$_HANDLER" | cut -d. -f2) "$EVENT_DATA")
# Send the response
curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response" -d "$RESPONSE"
done
このbootstrapはここではbashで書かれていますがどうやらこのファイルはbashである必要は無いようです
そしてこのコードを読む感じではループの中で http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next
から入ってきたデータとリクエストIDを取得して http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/<リクエストID>/response
に返却する値を再び放り込んで上げれば動くということのようです
とりあえずLambda向けにCrystalをコンパイルできてはいるので同じようなコードをCrystalで書いてやりましょう
require "http/client"
while true
response = HTTP::Client.get "http://#{ENV["AWS_LAMBDA_RUNTIME_API"]}/2018-06-01/runtime/invocation/next"
event_data = response.body
request_id = response.headers["Lambda-Runtime-Aws-Request-Id"]
url : String = "http://#{ENV["AWS_LAMBDA_RUNTIME_API"]}/2018-06-01/runtime/invocation/#{request_id}/response"
HTTP::Client.post url, body: event_data
end
そうしたらコンパイルしてzipで固めてやります
$ docker run --rm -v $(pwd):/src -w /src tjholowaychuk/up-crystal crystal build --link-flags -static -o bootstrap src/main.cr
$ zip lambda-crystal.zip bootstrap
無事動きました
真・ServerlessFramework
動かしたけど手でいちいちzipで上げるとか面倒くさくて死にそうになるのでServerlessFrameworkでもどうにかします
最終的な物はここにおいてあります
作るのにあたっては
というわけでまずbootstrap
#!/bin/sh
set -euo pipefail
EXEC="$LAMBDA_TASK_ROOT/buildfile/$_HANDLER"
if [ ! -x "$EXEC" ]; then
ERROR="{\"errorMessage\" : \"$_HANDLER is not found.\", \"errorType\" : \"HandlerNotFoundException\"}"
curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/init/error" -d "$ERROR"
exit 1
fi
$EXEC
こちらを参考にさせていただきました
内容は同じですので何も言うことなし次serverless.yml
service: serverless-crystal-sls
custom:
defaultStage: dev
api_version: v0
common: common
provider:
name: aws
runtime: provided
region: ap-northeast-1
stage: ${opt:stage, self:custom.defaultStage}
profile: <プロファイル名>
functions:
hello:
handler: hello
events:
- http:
path: test
method: post
integration: lambda
今までの物とほぼ同じです
ただ今回はhandlerはディレクトリを指定しています
中に入っているmain.cr
が各Lambda関数という感じです
.
├ buildfile/
│ └ hello
├ src/
│ ├ hello
│ │ └ main.cr
│ └ runtime
│ └ handler.cr
├ bootstrap
├ build.sh
├ deploy.sh
└ serverless.yml
中身はいろいろイマイチですがなんとなくhandlerっぽくしました
context?知らない子ですね…
require "./../runtime/handler"
def hello(event)
event
end
lambda_handler(hello)
それでこっちが呼び出しているhandlerの本体です
前の例のbootstrapとほぼ同じです
require "json"
require "http/client"
macro lambda_handler(func)
module Lambda
extend self
def run
while true
response = HTTP::Client.get "http://#{ENV["AWS_LAMBDA_RUNTIME_API"]}/2018-06-01/runtime/invocation/next"
event = JSON.parse(response.body)
request_id = response.headers["Lambda-Runtime-Aws-Request-Id"]
body = {{ func }} event["body"]
url : String = "http://#{ENV["AWS_LAMBDA_RUNTIME_API"]}/2018-06-01/runtime/invocation/#{request_id}/response"
HTTP::Client.post url, body: body.to_json
end
end
end
end
Lambda.run()
あとはmain.cr
がある物だけをコンパイルします
stg=$1
[ "$stg" = "" ] && stg="dev"
ls $(pwd)/src/ |
while read line; do
$(pwd)/build.sh $line || exit 1
done &&
sls deploy -s $stg
func=$1
[ -n $func ] || exit 1
# main.crが存在する物だけを対象にしたい
[ -f $(pwd)/src/$func/main.cr ] || exit 0
docker run --rm -v $(pwd):/src -w /src \
tjholowaychuk/up-crystal crystal build \
--link-flags -static -o buildfile/$func src/$func/main.cr && \
chmod +x src/$func
$ ./deploy.sh
/usr/lib/gcc/x86_64-linux-gnu/4.8/../../../x86_64-linux-gnu/libcrypto.a(dso_dlfcn.o): In function `dlfcn_globallookup':
(.text+0x11): warning: Using 'dlopen' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
T-C-P-S-ocket.o: In function `initialize':
/opt/crystal/src/socket/tcp_socket.cr:98: warning: Using 'getaddrinfo' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
/opt/crystal/embedded/lib/../lib/libevent.a(evutil.o): In function `evutil_unparse_protoname':
evutil.c:(.text+0xf0d): warning: Using 'getprotobynumber' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: WARNING: Function hello has timeout of 300 seconds, however, it's attached to API Gateway so it's automatically limited to 30 seconds.
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service .zip file to S3 (2.04 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
..............
Serverless: Stack update finished...
Service Information
service: serverless-crystal-sls
stage: dev
region: ap-northeast-1
stack: serverless-crystal-sls-dev
api keys:
None
endpoints:
POST - https://hagehage.execute-api.ap-northeast-1.amazonaws.com/dev/test
functions:
hello: serverless-crystal-sls-dev-hello
layers:
None
Serverless: Removing old service artifacts from S3...
こちらでもちゃんと動きましたね
(あ、ちなみにこれはあくまで自分用兼単に動かして見たかった事による産物なのでちゃんとライブラリ化してみたいなことは考えてないです…そういうのはもっと出来る人がやってどうぞ
まとめ
というわけでCustom RuntimeでもサーバーレスCrystal出来ました
待望の機能が来た歓喜と結構な時間を書けて書いた記事が無に帰した絶望が入り混じった複雑な気分です
正直Rubyが無い前提で書いたのに対応してしまったのが一番痛かった…
もうアドベントカレンダーなんて二度と書きません(断言)
2019/12/5追記
開発するうちに大きく作りを変えてしまったので更新版を書きました。
https://qiita.com/qazx7412/items/151b2e9375e63c7a400d