2
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

Organization

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が無い前提で書いたのに対応してしまったのが一番痛かった…
もうアドベントカレンダーなんて二度と書きません(断言)

2019/12/5追記

開発するうちに大きく作りを変えてしまったので更新版を書きました。
https://qiita.com/qazx7412/items/151b2e9375e63c7a400d

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
2
Help us understand the problem. What are the problem?