NimとAWS Lambdaでサーバーレス「大石泉すき」作成の初見RTAをした話


「大石泉すき」アドベントカレンダー22日目の記事です。
今回は先日開催されたアイマスハッカソン2019にてサーバーレス「大石泉すき」作成RTAをしたのでその報告をいたします。

大石泉と僕

本題へ入る前にまずは私と大石泉の関係についてお話をさせていただこうかと思います。
私は前に某所で記事にさせて頂いたのですが、NGsのみんなが色々なことを通知してくれるslack botを運用してたりします。

これらのbotにはエラーが起きたときに、slackに用意してあるアラート用チャンネルに通知を飛ばすようにしてあります。
そのエラーを教えてくれるのが他でもない大石泉さんというわけです。

いつも自分がダサい実装をしてエラーを起こすと優しく通知をしてくれる最高のアイドル大石泉へ感謝の気持ちを込めて「大石泉すき」を届けたい…

そんな思いを持ちつつちょうど11月の終わり頃、なんとちょうど「大石泉すき」アドベントカレンダーなるものを発見、しかもまたすぐその次の土曜にアイマスハッカソンが開催を予定している…これはもう「大石泉すき」をハッカソンで作る他ないじゃないですか。
他にネタがなかったのではって?何言ってるかよくわかんないね。

ということで、「大石泉すき」アドベントカレンダーのためにランタイムからサーバーレス「大石泉すき」を作成するRTAを行うことにしました。
あ、RTAと言いつつチャートなどを作って厳密にやったわけではないので安定のガバでお送りいたします。

前提とか

今回はAWS Lambdaのcustom runtimeという任意の言語でサーバーレス開発を行う事のできる仕組みを利用して開発をします。
Lambdaやサーバーレスとはなにか知らないという方にざっくりとした説明をすると、コードやビルド結果をAWSなどのクラウド上に登録しておくと、設定したトリガー(apiやcronなど)から自分でサーバーを作成することなしにコードを実行してくれるAWS LambdaというサービスがAWSにあります。
こういったサーバーを(基本的に)意識しなくてもいいようになっているサービス郡などを指してサーバーレスと言ったりします。
詳しくはここらへんを読むとわかりやすいのではないかと思います。(丸投げ)
私はLambdaのcustom runtimeのランタイム作成自体はすでにいくらかの経験があります。

レギュレーション

調べてはいませんがおそらくLambdaとcustom runtimeでのサーバーレス「大石泉すき」の作成を行っている先駆者兄貴はいないと思われれるので私が決めさせていただきました。
レギュレーションは下記の通りです。

  • custom runtimeのコードを事前に用意するのは無し。
  • 一般に公開されているドキュメント、コードを参考にするしたり(ライセンス上問題がなければ)コピペを行うのはあり。
    • ただしRTA走行前に公開されている使用言語のcustom runtimeのコードはNG。
  • Serverless FrameworkなどのLambdaへのデプロイを自動化するツールは使ってよい。
  • ハッカソンのスタート宣言と同時にタイマースタート、作成したcustom runtimeでapiをデプロイし、そこから「大石泉すき」が帰ってくることが確認できたところでタイマーストップ。

言語

今回使用する言語について下記の3つを検討しました。

  • Kotlin/Native
  • julia
  • Nim

これらはの言語は、Lambdaと相性のいいネイティブバイナリを出力する言語のうち、上2つは特定のアイドルと名前が似ていることを条件に選びました。
Nimはもともとある程度の勝算があったので入れました。「にむ」と「やむ」って似てない?とかロゴが
デレステの通知に出てくるアイコンになんとなく似てるとかで許していただきたい…
各言語それぞれ開催前のロケハンではHelloWorldをローカルで実行できるところまで確認しています。

雑&薄い当日のふりかえり

会場到着〜ハッカソン開始

それでは当日何をしていたのか、大体時系列順につらつら書いていきます。
今回のアイマスハッカソンは関東、関西の併催で、関東はBearTailさんが会場でした。
大体10:15くらい(だったと思う…ちゃんと覚えていない)に開始が宣言され、開発を開始しました。

開発開始〜昼食休憩

まずKotlin/Nativeでの開発を試みました…がこれは失敗します。
敗因は主に私がKotlin…というよりJVMのパッケージ管理周りの知識が不足していたことです。
12時頃(これも記憶が曖昧…)の昼食休憩まで間に、当日のハッカソンの終了時間を考えると1からGradleを使ってKotlin/Nativeを使ってビルドをできるようになるまでとても時間内に収まらない…と判断したので涙を飲んで言語変更することにしました。

昼食休憩

昼食休憩中にjuliaがNim、どちらを使うか検討します。
近くのロッ○リアで加蓮ポテトをキメながら必死に調べたところ、juliaはそれなりに手間がありそうでハッカソン終了までに終わらない可能性がありそう…
ということでjuliaには日和ってしまったので勝算のあったNimで開発を行うことにしました。

後半戦開始〜開発完了

昼食から戻るとすぐNimでの開発を開始します。
正直ここでの開発は、ほぼ初触りとなるNimの言語仕様と格闘しながらですが、ほぼスムーズに進みました。
つまずいたところといえばJSONの取り扱い程度でした。
今回のRTAの一応のゴールである「大石泉すき」API開発の開発は15:40分ほどに完了しています。

本来 msg となっていなければならないところが mag なってたりして、当時の私がよほど焦ってたというのがよくわかりますね。
ここまで計測に関してガバが過ぎたので、参考記録にはなってしまいますが、結果は開始から終了が10:15〜15:40で 5時間25分 でした。

今回の成果に関してはまた次の章で解説しますが成果物はこちらにおいてあります。
誤字やインデントの修正、ビルド時にオプションを追加したりしていますがだいたいはほぼ当日のままです。
https://github.com/limit7412/lambda-nim-sls

また時間が余ったのでハッカソン終了までにもうちょっと開発をすることにしました。
slash commandを実行したらランダムで反応を返してくれるだけのslack botです。
(コミットログを見られるとバレてしまいますが、結局終了後までかかってしまっていますね。)
https://github.com/limit7412/izumin-suki-bot

作ったものの解説

では作ったものの解説を行います。
前述のとおり言語はNimで、LambdaへのデプロイにはServerless Framework(以下Serverless)を使用しました。

custom runtimeでLambdaを実行するには以下の2つの要件を満たす必要があります。

  • Lambdaは起動時にデプロイしたファイルがおいてあるディレクトリの ./bootstrap というファイルを実行する。なので形式は問わずbootstrapというファイルをLambda上にて実行可能な形式で用意する必要がある。
  • Lambda上で実行されるプログラミングの中で無限ループを実装し、その中で下記の処理を繰り返す。(この処理はイベントループと呼ばれています)
    • Lambdaへの入力を出力する(ややこしい…)web apiからデータを受け取る。
    • 実行の結果を出力ためのapiへ結果を返す。

ではディレクトリ構成を見ましょう。

.
├ src/
│  ├ hander.nim
│  └ main.nim
├ deploy.sh
├ lambda.nimble
└ serverless.yml

上からsrcというディレクトリに入っているのが実際にLambda上で実行されるコード、deploy.shはビルドとデプロイの手順をまとめたデプロイスクリプト、lambda.nimbleがNimのパッケージマネージャーのnimbleの設定ファイル、そしてserverless.ymlがServerlessの設定ファイルです。

設定ファイル

まずはserverless.ymlから見ていきましょう。

serverless.yml
service: serverless-nim-sls

custom:
  defaultStage: dev
  api_version: v0

provider:
  name: aws
  runtime: provided
  timeout: 20
  region: ap-northeast-1
  stage: ${opt:stage, self:custom.defaultStage}

functions:
  test:
    handler: test
    events:
      - http:
          path: test
          method: get
          integration: lambda

/test というパスにgetでアクセスが来たら test と名付けられたハンドラーを実行するLambdaファンクションtestを定義してあります。正直大したことは書いてありませんね。

lambda.nimbleの方も見ていきましょう。

lambda.nimble
# Package

version       = "0.1.0"
author        = "limit7412"
description   = "my serverless nim runtime for sls"
license       = "MIT"
srcDir        = "src"
bin           = @["main"]

backend       = "cpp"

# Dependencies

requires "nim >= 1.0.2"

これも大したことは書いてありませんね。
src というディレクトリの main.nim を起点にビルドを行うように設定してあります。
またNimはCやC++などに一度トランスパイルをしてからネイティブにコンパイルされるという作りになっているので、今回はC++を選択しています。これにとくになんらかの意図はありません。

デプロイスクリプト

これらの設定を踏まえてデプロイ用のシェルを見ていきます。

deploy.sh
#!/bin/bash

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

[ -e bootstrap ] && sudo rm bootstrap

sudo docker run --rm -v $(pwd):/src -w /src \
nimlang/nim nimble build -d:ssl && \
mv main bootstrap               && \
sudo chmod +x bootstrap         || exit 1

sls deploy -s $stg

bootstrapをバイナリとして作成して、そのままServerlessでデプロイしています。
まずビルドをするのにdockerを利用していますが、これはcustom runtimeの実行環境がLinuxなのでmacなどからでもデプロイできるようにするためです。
またコンテナ上で実行しているnimbleに -d:ssl というオプションがついていますがこのオプションがないとNimやnimbleでビルドしたファイルからhttpsができません。
ビルドが成功したらファイル名の変更と実行権限の付与を行っています。
nimbleはビルドされる起点となるファイルの名前と出力されるバイナリの名前を別々にできないので変更を行っています。

ハンドラー

最後に本命のイベントループとそれを呼び出すハンドラーの実装を見ていきましょう。

main.nim
import hander
import json

when isMainModule:
  "test".hander do (event: JsonNode) -> JsonNode:
    return %*{
      "msg": "大石泉すき"
    }

起点となるmain.nimです。
私はcustom runtimeを作成するときはいつもSinatra系のフレームワークを目指す感じで、ハンドラー名(といってしまっていいのだろうか…)とそこで動くコールバックという形でイベントループを呼び出すハンドラーという形で作っています。
今回はNimのUFCS(func(x) みたいな関数をメソッドっぽく x.func() といった形で呼び出せる機能)を使ってきれいに書けてるんじゃないかと思います。

ではハンドラーから呼び出される実際のイベントループの処理を見ましょう。

hander.nim
import os
import httpClient
import json

proc hander*(name: string, callback: proc(
    e: JsonNode): JsonNode) =
  if name != os.getEnv("_HANDLER").string:
    return

  let api = os.getEnv("AWS_LAMBDA_RUNTIME_API").string
  while true:
    var nextClient = newHttpClient()
    let event = nextClient.request("http://" & api &
      "/2018-06-01/runtime/invocation/next", httpMethod = HttpGet)
    let requestId = event.headers["lambda-runtime-aws-request-id"]

    var returnUrl = "http://" & api & "/2018-06-01/runtime/invocation/" & requestId
    var resClient = newHttpClient()
    try:
      let result = callback(event.body.parseJson)
      let _ = resClient.postContent(returnUrl & "/response", body = $result)
    except:
      let _ = resClient.postContent(returnUrl & "/error", body = $ %*{
          "msg": "Internal Lambda Error"
        })

上から順を追って説明します。
まずLambda関数のハンドラーの名前は環境変数 _HANDLER に入っているので、一致しないものは早期リターンさせています。

次にイベントループの本体です。
http://{"AWS_LAMBDA_RUNTIME_API"}/2018-06-01/runtime/invocation/nextというURLがLambdaへの入力を出力するapiになります。
ここから入力された情報などやrequestIdを取得します。
このrequestIdは、結果を返却するためのapiのURLを作成するのに必要なので必ず受け取る必要があります。

そうしたらapiから渡ってきた入力はjsonになっているので適当にバースしてやってcallbackにを突っ込んでそれえぞれのLambdaファンクションの処理を動かします。
最後に結果を返却するためのapiは成功時用(http://{"AWS_LAMBDA_RUNTIME_API"}/2018-06-01/runtime/invocation/{requestId}/response)と失敗時用(http://{"AWS_LAMBDA_RUNTIME_API"}/2018-06-01/runtime/invocation/{requestId}/error)でわかれているのでtry、exceptでエラーハンドリングをして結果に応じて返してやります。
ところで今回、Goっぽい感じで使っていない変数を _ みたいに処理した(vscodeの拡張にも怒られなくなったし)んですけど、これってNim的にだめだったりするんですかね?
Nimに自信ニキがいたら教えてほしい…

完走した感想(激うまギャグ)

Kotlin/NativeやろうっていうのにJVM系の知識ほぼ0なのは致命的でしたね…
でもネイティブバイナリ出力言語箱推しPとしてはKotlin/Nativeでサーバーレス「大石泉すき」とサーバーレス「音無小鳥すき」をするのは諦めたくないところなのでどこかでリベンジしたい。

あとNimなんですけど書いてみた感想はPythonっていうよりはGo + js + coffeescriptっていう印象を持ちました。
UFCS、書いててとても気持ちよかったです。私はシェルでパイプを脳死で繋ぐのが好きな人間なのでこういうのは好みです。

ところで振り返ってみるとなんかいつの間にかcustom runtime作りまくる人になってしまっているのでそろそろ脱却したい。(今回書いた解説だって言語を変えて何回もしてる感あるし…)