Roblox用言語LuauをAWS Lambdaで動かしてみた(副題: サーバーサイドから見たLuau)

皆様はじめまして。またもしご存知な奇特な方はお久しぶりです。
ambrでサーバーサイドエンジニアをしている回路(@qazx7412)と申します。

普段は主にgoghのサーバーサイドの開発を担当しております。

gogh.gg

ですが今回はgoghとは関係無い話、弊社で最近取り組んでいるRobloxに関しての話をさせていただければと思います。

prtimes.jp

前置き

Robloxとはご存知の通り(?)ユーザーがエクスペリエンスというものを作成してアップロードして公開することができるゲームプラットフォームのようなサービスです。 このエクスペリエンスとはゲームあるいはVRChatで言うところのワールド的な概念なのですが、これはユーザーが作成したスクリプトを含めてアップロードすることが出来ます。

そしてRobloxのスクリプトで使用できる言語として提供されている言語がLuauです。

このLuauという言語、事実上Robloxのための言語ではあるのですが、実はRobloxとは関係無いところでも使用できるスタンドアロン版とでも言うべき実装がいくつかOSS化され公開されており、(使おうと思えば)Roblox以外のところでも使用することが出来ます。

github.com

こちらはLuau公式の実装。

github.com

またこちらは公式とは別開発されているLuneと呼ばれるランタイムで、JS/TSで言うところのDenoに近いものでしょうか。

今回はこちらのLuneの方を使用して、AWS Lambdaで動作するプログラムを作成しつつLuau自体の所感も述べて行けたらなと思います。

⚠注意⚠

今回の記事はどちらかといえばクライアントエンジニアや、もしくはそもそもRobloxで初めてプログラミングに触れたようなちゃんとしたサーバーサイドのバックボーンが無い方にもリーチする内容であると思うので本題に入っていく前にいくつか注意書きをさせてください。
今回表題の通りAWS LambdaでLuau、正確にはLuneを実行する例を紹介していくのですがこれはambrやまたは筆者個人として業務等に用いるような環境でAWS Lambdaや他のプラットホームにてLuauやluneを使用することを推奨するものではありません。 これは主に下記の要因によるものです。

  • 今回使用するLuneはメジャーバージョンに1.xに到達しておらずまだ動作を保証できるものではない。
  • 今回紹介するAWS Lambda用ランタイムはあくまで実装例であり、公式にサポートされるものではない。

今回の上記に関連する内容に関して問題が発生した場合、ambrや筆者はサポート等は出来かねますのでご了承いただければと思います。

本題

ということでつまらない前置きは済ませたので本題に入って行きます。

今回は単独の言語としてLuauというものを評価する題材としてAWS Lambda用の独自ランタイムを作成していきます。

まず前提として(前提ばっかだなこの記事…)AWS Lambdaとはなにかですが、AWSが提供するFaaS(Function as a Service)と呼ばれるもので簡単に言うと「スクリプトやビルドしたプログラミングをデプロイしておくとなんか他サービスと連携していい感じに実行してくれるやつ」です。

aws.amazon.com

基本的に登録したプログラムを実行している間だけサーバーが立ち上がり実行される形式になっていて、よほどの時間実行されるようなことをしなければ安価どころかほぼ無料で使用できる反面、状況によって実行毎にサーバーの起動がオーバーヘッドになったり複数のサーバーが立ちやすい性質上RDBのような永続的なコネクション張るタイプのミドルウェアとの相性が悪いというデメリットを持ったピーキーなサービスです。
一般的には趣味の範囲やデメリットが許容できる小規模プロダクトでbotやアプリのサーバーサイドに使用されることが多いです。

そんなLambda、本来は事前に公式にランタイムが用意された言語しか使用出来ないのですが、カスタムランタイムという仕組みを使用してハンドラーを独自に実装することによって非対応の言語を半ば無理やり使用することが出来ます。
今回はこのカスタムランタイムを使用してLuau(Lune)をLambdaで動かすことでLuauという言語に触れて行きます。

実装例は下記に置いてありますので必要に応じて参照いただければと思います。

github.com

1. ランタイム込でのLambdaへのデプロイ(アップロード)

さてLambdaでカスタムランタイムを使用してデプロイを行う場合、Lambda側からは当然実行のためのランタイム等は用意されません。
GoやRust等のシングルバイナリ(いわゆる単体で起動できる実行ファイル)を出力できる言語なら良いのですが、Luauはそうではありません。
(厳密にはLuneを使用すれば実は実行ファイルにできるですが、一部依存があり完璧なシングルバイナリにはならずLambdaでは通常実行出来ないです)

ですのでLambdaへデプロイを行う際にはコードの他に実行ランタイムとその実行に必要なライブラリを含めてあげる必要があります。

Lambdaではデプロイ時にzipファイルにまとめてからアップロードするのでそこに入れ込んでしまうか、もしくはECR(AWSのDockerコンテナレジストリ)を介してDockerコンテナでデプロイができるのでこのDockerfile内でインストールしておくかが主な対応方法になります。
前者は出来たとしてもハチャメチャなことになるのは目に見えているので今回は後者で解説をします。

まずDockerfile全体はこんな感じです。

# サンプルなので雑にlatest
FROM public.ecr.aws/lambda/provided:latest

# Luneの実行に必要なライブラリ導入(ちゃんとチェックしてないので不要なのあるかも…)
RUN dnf update -y
RUN dnf install -y curl-minimal
RUN dnf install -y unzip
RUN dnf install -y glibc
RUN dnf install -y gcc
RUN dnf install -y gcc-c++
RUN dnf install -y libstdc++
RUN dnf install -y libstdc++-devel
RUN dnf clean all

# ランタイム導入用ディレクトリ(ここにパスを通す)
WORKDIR /work/bin

# Lune導入
ENV ZIP_URL=https://github.com/lune-org/lune/releases/download/v0.8.5/lune-0.8.5-linux-x86_64.zip
RUN curl -L $ZIP_URL -o /tmp/archive.zip
RUN unzip -q /tmp/archive.zip
RUN rm /tmp/archive.zip

ENV PATH="/work/bin:${PATH}"

# 実際にLambdaの処理の起点となるディレクトリ
WORKDIR /var/runtime/
COPY ./src ./

# 実行ファイルにまとめる
RUN lune build main.luau -o bootstrap
RUN chmod +x bootstrap

# 今回の例では使用しないのでダミー文字列
CMD ["dummyHandler"]

まずLuneの導入について、(少なくとも検証を行ったタイミングでは)lunaはdnf等のパッケージ管理ツールには対応していません。(一応brewには対応しているらしい) ですのでGitHubのリリースページから直接curlで取得します。

ちなみにこの例で対象にしているv0.8.5はあくまで検証時のバージョンであって最新版では無いのでご注意ください。

# ランタイム導入用ディレクトリ(ここにパスを通す)
WORKDIR /work/bin

# Lune導入
ENV ZIP_URL=https://github.com/lune-org/lune/releases/download/v0.8.5/lune-0.8.5-linux-x86_64.zip
RUN curl -L $ZIP_URL -o /tmp/archive.zip
RUN unzip -q /tmp/archive.zip
RUN rm /tmp/archive.zip

ENV PATH="/work/bin:${PATH}"

次に実行ファイルへのビルドについて。

今回は下記のように実行ファイルへのビルドしています。
これはカスタムランタイムでは /var/runtime/bootstrap を起点として実行されるので、それに合わせて実行ファイルにしています。

# 実際にLambdaの処理の起点となるディレクトリ
WORKDIR /var/runtime/
COPY ./src ./

# 実行ファイルにまとめる
RUN lune build main.luau -o bootstrap
RUN chmod +x bootstrap

もし信仰の都合でどうしてもビルドはしたくない場合、下記のような起動用スクリプトを別途用意してもよいでしょう。

#!/bin/sh

 lune run main.luau

2. 独自ハンドラー

ではランタイムを含めることが出来たので本丸のハンドラーを実装していきます。

ハンドラーに関してはAWSが公式でbashでの実装例を上げているのでこちらを見ていきましょう。

docs.aws.amazon.com

# 実装例からハンドラー部分を抜粋
while true
do
  HEADERS="$(mktemp)"
  # Get an event. The HTTP request will block until one is received
  EVENT_DATA=$(curl -sS -LD "$HEADERS" "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")

  # Extract request ID by scraping response headers received above
  REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)

  # Run the handler function from the script
  RESPONSE=$($(echo "$_HANDLER" | cut -d. -f2) "$EVENT_DATA")

  # Send the response
  curl "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response"  -d "$RESPONSE"
done

まあざっくりと言うと「無限ループの中で内部に存在するリクエスト情報を出力するweb API(入力と出力がややこしい…)から入力を手に入れて、なんらかの処理の結果をまた別のレスポンスを送る用のweb APIに流すのを繰り返す」のが基本的なカスタムランタイム…つまりLambdaのハンドラーで行っている処理ということになります。

ではこれを実用性を踏まえつつLuauで実装していきます。

例の如く実装例の全体から。

local process = require("@lune/process")
local net = require("@lune/net")

local _HANDLER: string = process.env._HANDLER
local AWS_LAMBDA_RUNTIME_API: string = process.env.AWS_LAMBDA_RUNTIME_API

local lambda = {
  handler = function(name: string, callback: (any) -> (any))
    if (name ~= _HANDLER) then
      return
    end

    while true do
      local response = net.request("http://"..AWS_LAMBDA_RUNTIME_API.."/2018-06-01/runtime/invocation/next")
      local event = net.jsonDecode(response.body)
      local requestId: string = response.headers["lambda-runtime-aws-request-id"]

      local ok, body = pcall(callback, event.body)
      if not ok then
        local url ="http://"..AWS_LAMBDA_RUNTIME_API.."/2018-06-01/runtime/invocation/"..requestId.."/error"
        body = {
          statusCode = 500,
          body = net.jsonEncode({
            msg = "Internal Lambda Error",
          }),
        }
      end

      local url = "http://"..AWS_LAMBDA_RUNTIME_API.."/2018-06-01/runtime/invocation/"..requestId.."/response"
      net.request({
        url = url,
        method = "POST",
        body = net.jsonEncode(body)
      })
    end
  end
}

return {
  lambda = lambda
}

はてなブログってLuau用のハイライトは無いんですね。(まあそりゃそうか…)

まず環境変数の取得から。
Luneで環境変数を取得するためのライブラリが用意されているのでこれを使用します。

_HANDLER はこの後デプロイツールの章で指定するLambda関数ごとに指定する文字列になります。
AWS_LAMBDA_RUNTIME_API はLambda側で設定されているもので、ハンドラーでの各内部APIのアクセス先です。

local process = require("@lune/process")
--(略)
local _HANDLER: string = process.env._HANDLER
local AWS_LAMBDA_RUNTIME_API: string = process.env.AWS_LAMBDA_RUNTIME_API

ハンドラーの外部からのインターフェイスとなる部分。

引数としてハンドラーの名前と実際の処理となるcallbackを受け、名前が環境変数_HANDLER と一致すれば処理を実行するようにしています。
これはこうすることで複数のLambda関数をデプロイするときに、LuauやDockerのビルドを1回で済むようにできるからです。

ここの部分はLuauでのエクスポートや、コールバック関数などの仕様が見て取れて色々興味深いですね。
例えばコールバック関数はLuauが漸進的型付け(でいいはず…)なので雑にanyが使えるので引数や返り値を定義したりジェネリクスで引き回したりする必要が無いので作る分には楽ですね。
あと地味に非等価演算子の不一致が ~= なのにビビります。

local lambda = {
  handler = function(name: string, callback: (any) -> (any))
    if (name ~= _HANDLER) then
      return
    end
--(略)
  end
}

return {
  lambda = lambda
}
// 通常静的型付け言語だとジェネリクスで引き回したり引数や返り値ごとにハンドラーを用意する必要がある
private def handler[A: Reader](callback: A => Response): Lambda.type

次にループの最初にリクエストを取得する部分。

今回はHTTPクライアントとJSONパーサーはLuneに組み込みで用意されているものを使用しました。
これはRoblox向け開発で使用するHttpServiceとは違うものなので注意。

あまり語ることも無い気がしますが、漸進的型付け故にJSONの取り扱いが楽なのは助かりますね。

local net = require("@lune/net")
--(略)
    while true do
      local response = net.request("http://"..AWS_LAMBDA_RUNTIME_API.."/2018-06-01/runtime/invocation/next")
      local event = net.jsonDecode(response.body)
      local requestId: string = response.headers["lambda-runtime-aws-request-id"]
--(略)
    end

次にcallbackで入ってきたLambda関数の実態となる処理にリクエストの内容を渡して結果のハンドリングをするところ。

LuauというよりはLuaの特徴だと思うのですが、エラーハンドリングをtry/catchではなくエラー処理用の関数を使用して行うのは面白いですね。
これは記法的にはif文を使うからGoで多値変換の最後でエラーを返却したりする作法に似た感じになってますが、あくまで範囲が限定的なtry/catchと考えたほうがいいように見えますね。(結局throwのノリでerrorを返すしネストした関数のerrorも拾うので)

      local ok, body = pcall(callback, event.body)
      if not ok then
        local url ="http://"..AWS_LAMBDA_RUNTIME_API.."/2018-06-01/runtime/invocation/"..requestId.."/error"
        body = {
          statusCode = 500,
          body = net.jsonEncode({
            msg = "Internal Lambda Error",
          }),
        }
      end

最後に結果を返却するところ。

LuneのHTTPクライアントでPOST(というよりGET以外の)リクエストを送るにはこんな感じ。
先ほどあえて触れなかったエラー時の処理も同様と言った感じで特に語るところも無いでしょうか。
強いて言うなら素直な作りをしているのは好印象です。(そうじゃない言語やライブラリが世の中にはいっぱいある…)

      local url = "http://"..AWS_LAMBDA_RUNTIME_API.."/2018-06-01/runtime/invocation/"..requestId.."/response"
      net.request({
        url = url,
        method = "POST",
        body = net.jsonEncode(body)
      })

後は起点となるところ(この記事の例ではmain.luau)から下記みたいな感じで呼び出して上げましょう。
前述の通り環境変数_HANDLER を第一引数としているので、呼び出したハンドラーのうちデプロイツールで設定したもののみ実行されます。

local net = require("@lune/net")
--  この例では独自ランタイムをmain.luauに対して./runtime/serverless.luauに置いてある
local serverless = require("runtime/serverless")

serverless.lambda.handler('hello', function(event)
  return {
    statusCode = 200,
    body = net.jsonEncode({
      msg = "hello luau",
    }),
  }
end)

3. デプロイツール(Serverless Framework)

というわけでハンドラーまで出来たのでデプロイをしていきます。

今回は例としてServerless Frameworkを使用するのですが、極論どのツールを使用しても良いので好みや環境に応じて読み替えてください。
(というかぶっちゃけもし今業務などで使うならSAMやCDK等を推奨します…Serverless Frameworkを使うのは私が慣れてるのと個人ならこっちのほうが使いやすいからです)

www.serverless.com

ではServerless Frameworkでの設定を行っていきます。
./serverless.yml に下記のように設定を記述します。

ecr 配下に記述してあるのが最初に作成したDockerfileを指定してAWS上(ECR)にpushをするようにする設定です。
appImage がこちらで自由に設定できるイメージの名前になっているのでLambda関数側の設定で指定します。

また functions 配下が関数側の指定になっていて、 image からどのDockerイメージを使用するか指定していて、今回は appImage を指定することで ecr 側で指定したDockerfile(をpushしたECR)を使用することが出来ます。
また command がDockerfileのCMDの値を上書きするオプションになっていて、Lambda用Dockerfileではこれが環境変数_HANDLER につながっているのでここでコード上のどのハンドラーに紐づけるかを指定出来ます。

service: (任意の名前)

provider:
  name: aws
  runtime: provided
  timeout: 20
  region: ap-northeast-1
  ecr:
    images:
      appImage:
        # Dockerfileのパス
        path: ./
        platform: linux/amd64

functions:
  hello:
    image:
      name: appImage
      command:
        - hello
    events:
      - http:
          path: test
          method: get

後はServerless Frameworkは自身とDocker Desktop(かそれに類するもの)の導入とAWSのクレデンシャル設定さえ終えていればデプロイ出来ます。
コマンドは下記の通り。

# デプロイ
$ sls deploy

# 削除(ECRは多少コストがかかるので終わったら削除推奨)
$ sls remove

後は成功するとURLが表示されるのでそこにリクエストを飛ばしてみましょう。
ちなみにECRは多少コストがかかるので終わったら削除推奨なのと、もし使用する場合はライフサイクルポリシーを設定して節約をするといいでしょう。

あとがき

というわけでLuneを使用したLambdaでのLuauの実行と、そこからLuauに触れてみた話でした。

最初に述べた通り私はRoblox開発自体には関わってはいないのですが、隣のチームがLuauを使って開発しているのを見て興味が湧いたので「まぁ今後機会があるかもわからんしちょっとやってみるか」くらいのノリでやったら案外出来てしまったので記事にしてみました。

Lambdaのカスタムランタイム作成はランタイム等のインフラ面を含めたその言語の色々なところが見れるので、私は新しい言語を学習する際にはよくやっています。
(その御蔭でGitHubの自分のリポジトリがカスタムランタイムだらけになってしまってたりするのですが…)

特にインフラ面から学べることは多く実践的なDockerでの対応方法や、LuauだったらLuneのような別途ランタイム開発がされていること、また言語によってはパッケージ管理の対応等を見ることが出来ます。
また言語自体も基礎構文や、HTTPリクエスト、JSONの取り扱い、callback、エラーハンドリング、ローカルインポート/エクスポート等の少し込み入った部分を学ぶのに良い教材だと思います。

実際Luauでは漸進的型付けの型がありつつcallbackの引数や返り値、JSONの取り扱いが楽なところ、Luaから引き継いだ演算子やエラーハンドリング、エクスポートの癖など色々なところが見えて面白かったと思います。