Edited at

ServerlessFrameworkとLambdaでサーバーレスCrystalしてみた話改めCustomRuntimeでCrystal動かしてみた話


まえがきのまえがき

この記事の執筆は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です

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に関する話はここここなどにまとまっています)

具体例としてはこんな感じ


handler.js

'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できるんじゃないかということです

そのためには


  1. コンパイルするための環境を用意しコンパイルする

  2. コンパイルした実行ファイルを一緒にしてデプロイしてやる

ことが必要です

そして実はこのやり方でサーバーレスCrystalを実現しているデプロイツールが存在します


Apex/Up

Apexと同じ作者によるサーバーレス用のデプロイツールがUpです(日本語での紹介記事

コンパイル用のコンテナ作るために思想錯誤している最中に偶然見つけました

なんとCrystalに対応しています

このUpというツールはPaaSのように普通に作ったAPIやWebアプリをデプロイできるのが特徴らしいです

普段使っているServerlessFrameworkとかはどちらかというとCFnのFaaSに寄った形での強化版みたいなサムシングだったのでまるっきり思想が違いますね

デプロイしてみます

コードはサンプルほぼそのままです


up.json

{

"name": "up-crystal",
"profile": "<プロファイル名>",
"regions": [
"ap-northeast-1"
]
}


src/main.cr

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を呼び出します


handler.js

'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));
});
};



src/main.cr

require "json"

stdin = JSON.parse(STDIN.gets_to_end)

p stdin["body"].to_json


標準入力でLambdaに渡ってきたデータをCrystalにぶち込んで同じく標準出力から結果を返してもらっています

いろいろ雑なのは許してほしい

あとはこれをコンテナでコンパイルしてServerlessFrameworkでデプロイしてやればいいですね

dockerの方はApex/Upと同じですがコンパイル元と先をそれぞれsrc/main.crbuildfile/main に変更しています

デプロイするのにいちいち2つのコマンドを両方打つのはだるいので今回は一つにまとめておきます


deploy.sh

docker run --rm -v $(pwd):/src -w /src \

tjholowaychuk/up-crystal crystal build \
--link-flags -static -o buildfile/main src/main.cr &&\
sls deploy


serverless.yml

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が動くはずですね

スクリーンショット 2018-11-22 16.59.02.png

動きました


最後に

ということで無事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


bootstrap

#!/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で書いてやりましょう


src/main.cr

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

出来たらコンソールからアップロードして実行してやりましょう

スクリーンショット 2018-11-30 14.12.04.png

無事動きました


真・ServerlessFramework

動かしたけど手でいちいちzipで上げるとか面倒くさくて死にそうになるのでServerlessFrameworkでもどうにかします

最終的な物はここにおいてあります

作るのにあたっては

というわけでまずbootstrap


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


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?知らない子ですね…


hello/main.cr

require "./../runtime/handler"

def hello(event)
event
end

lambda_handler(hello)


それでこっちが呼び出しているhandlerの本体です

前の例のbootstrapとほぼ同じです


runtime/handler.cr

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がある物だけをコンパイルします


deploy.sh

stg=$1

[ "$stg" = "" ] && stg="dev"

ls $(pwd)/src/ |
while read line; do
$(pwd)/build.sh $line || exit 1
done &&
sls deploy -s $stg



build.sh

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...

そしたらまた動くか試してみましょう

スクリーンショット 2018-12-04 17.05.42.png

こちらでもちゃんと動きましたね

(あ、ちなみにこれはあくまで自分用兼単に動かして見たかった事による産物なのでちゃんとライブラリ化してみたいなことは考えてないです…そういうのはもっと出来る人がやってどうぞ


まとめ

というわけでCustom RuntimeでもサーバーレスCrystal出来ました

待望の機能が来た歓喜と結構な時間を書けて書いた記事が無に帰した絶望が入り混じった複雑な気分です

正直Rubyが無い前提で書いたのに対応してしまったのが一番痛かった…

もうアドベントカレンダーなんて二度と書きません(断言)