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

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が無い前提で書いたのに対応してしまったのが一番痛かった…

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

コメント

このブログの人気の投稿

投稿時間:2021-06-17 05:05:34 RSSフィード2021-06-17 05:00 分まとめ(1274件)

投稿時間:2021-06-20 02:06:12 RSSフィード2021-06-20 02:00 分まとめ(3871件)

投稿時間:2020-12-01 09:41:49 RSSフィード2020-12-01 09:00 分まとめ(69件)