こんにちは、プログラミング言語が大好きなエムスリーエンジニアの園田です。
この記事は エムスリーアドベントカレンダー 3日目の記事となります。
先日、AWS 最大の年間イベントである re:Invent 2018 でラスベガスに渡航していました。
基調講演で発表された数々の新サービス、日本時間29日の基調講演で AI・機械学習、内部統制・コンプライアンス、ブロックチェーンと新サービスの発表が相次ぎました。
日本時間30日の基調講演ではサーバレス関連の新サービスや機能追加が続々と発表されました。アプリケーションエンジニアとしてはこちらのほうが胸アツでしたね。
その中でも個人的に特にインパクトが大きかったのが、 AWS Lambda Function の Ruby 対応 でした。
次に気になったのが、 AWS Lambda Custom Runtime です。要はどんな言語ランタイムでも Lambda で実行できるようになったということですね。
ローンチ時ですでに AWS から C++ と Rust の Custom Runtime が提供されており、パートナー企業からも以下のランタイムが提供されているということでした。
- Elixir
- Erlang
- N|Solid
- COBOL
- PHP
Elixir は再三このブログでも取り上げていますが、弊社でもプロダクション利用しており、「おっ」と思いました。
ところが、ないんです。あの言語が。私の推しラン(推しLanguage)がないのです。
そう、 Nim 言語 がないのです。
というわけで、 Nim の Lambda Runtime を実装してみました。
本記事は Nim アドベントカレンダー 3日目の記事でもあります。
Lambda Custom Runtime のインタフェースを理解する
公式ドキュメントはこちらです。
- Custom AWS Lambda Runtimes - AWS Lambda
- AWS Lambda Runtime Interface - AWS Lambda
- Tutorial – Publishing a Custom Runtime - AWS Lambda
Custom Runtime では以下の順序で処理を実行するように実装します。
- 初期化処理
- イベントループ
シンプルですね。イベントループが従来の Lambda Handler にあたります。
エントリポイント
Custom Runtime では、インスタンス作成時に Lambda の実行ディレクトリ(環境変数 LAMBDA_TASK_ROOT
)の直下にある、実行可能な bootstrap
というファイル(ファイル名固定、拡張子なし)を実行します。
この bootstrap
の中に上記の初期化処理とイベントループが実装されていれば、どんな言語であれ Lambda 化が可能という仕組みです。チュートリアルではシェルでの実装例が示されています。
初期化処理
Lambda インスタンスの実行時に1回だけ実行される処理です。通常は環境変数の読み込みなどを行います。
Nim はコンパイルされてシングルバイナリになるため、初期化処理はシェルで実装し、バイナリを実行するだけとします。
イベントループ(Lambda Handler)
イベントループは Lambda の InvokeFunction を処理するための処理です。
Runtimes API に対して HTTP リクエストを実行することで、Lambda の実行環境からイベントデータの取得や結果のレスポンスを返すことを役割とします。
イベントデータの取得
Runtimes API の next エンドポイントに GET リクエストを実行することで、イベントキューのリクエストを取得することができます。
curl -sSL "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next"
AWS_LAMBDA_RUNTIME_API
は bootstrap
実行プロセスの環境変数に設定された Runtime ごと(?)に一意になるエンドポイントだと思われます。
この GET リクエストのレスポンスボディが InvokeFunction に渡されたイベントデータとなっています。
イベントデータの処理とエラー処理
取得したイベントデータを入力値としてビジネスロジックを実行します。正常に終了した場合は success エンドポイントにレスポンスを POST します。
curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response" -d "$RESPONSE"
REQUEST_ID
は、 next エンドポイント実行時のレスポンスヘッダに含まれています。
処理中にエラーが発生した場合は、 invocation error エンドポイントにエラーデータを POST します。
Nim での実装
エントリポイントの作成
エントリポイントとなる bootstrap
を実装します。こちらは Nim ではなく sh で実装します。チュートリアルにあるサンプルを参考に実装します。
#!/bin/sh set -euo pipefail # $_HANDLER には Lambda のハンドラー設定値が格納されています。 # Node.jsとかでおなじみの `index.handler` とかですね。 # Nim はシングルバイナリなので、ハンドラーにはバイナリファイル名を指定してあるものとします。 EXEC="$LAMBDA_TASK_ROOT/$_HANDLER" # 実行可能バイナリがなければ初期化エラーのエンドポイントにエラーをPOSTします # リクエストボディの形式に決まりはありません 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
忘れずに実行権限を付与しておきます。
chmod +x bootstrap
イベントループ(Lambda Handler)の実装
次に、イベントループを Nim で実装します。今回は単純な echo サーバを実装してみます。
import json, httpclient, os, strutils let # Runtimes API のエンドポイント Prefix runtimeApiEndpointPrefix = "http://" & getEnv("AWS_LAMBDA_RUNTIME_API") & "/2018-06-01/runtime/invocation/" # next API のエンドポイント nextEndpoint = runtimeApiEndpointPrefix & "next" # HttpClient インスタンス client = newHttpClient() type # Lambda のお作法として、Contextを構造体とする InvocationContext* = object of RootObj requestId*: string deadline*: string functionArn*: string traceId*: string eventData*: JsonNode # next エンドポイントからイベントの Context を取得する proc getInvocationContext(client: HttpClient): InvocationContext = let nextResponse = client.request(nextEndpoint, httpMethod = HttpGet) return InvocationContext( requestId: nextResponse.headers["Lambda-Runtime-Aws-Request-Id"], deadline: nextResponse.headers["Lambda-Runtime-Deadline-Ms"], functionArn: nextResponse.headers["Lambda-Runtime-Invoked-Function-Arn"], traceId: nextResponse.headers["Lambda-Runtime-Trace-Id"], eventData: parseJson(nextResponse.bodyStream) ) # Context を JSON 文字列に無理やり変換 proc toJson(context: InvocationContext): string = let jsonNode = %*{ "requestId": context.requestId, "deadline": context.deadline.parseInt(), "functionArn": context.functionArn, "traceId": context.traceId, "event": context.eventData } return $jsonNode # echo レスポンスを Lambda に送信する proc echoResponse(client: HttpClient, context: InvocationContext): void = discard client.request( runtimeApiEndpointPrefix & context.requestId & "/response", httpMethod = HttpPost, body = context.toJson()) # エラー処理 proc handleInvocationError(client: HttpClient, context: InvocationContext, e:ref Exception, message: string): void = let errorData = %*{ "errorMessage": message, "errorType": repr(e) } discard client.request( runtimeApiEndpointPrefix & context.requestId & "/error", httpMethod = HttpPost, body = $errorData) # イベントループ while true: # GET リクエストでイベントデータを取得 let context = client.getInvocationContext() try: # POST リクエストでレスポンスデータを登録 client.echoResponse(context) except: let e = getCurrentException() msg = getCurrentExceptionMsg() # POST リクエストでエラーデータを登録 handleInvocationError(client, context, e, msg)
上記を aws_lambda_nim_example.nim
というファイル名で保存します。
ビルド用のシェルを作成
build.sh
という名前で作成しました。nim をコンパイルして bootstrap と一緒に zip に固めているだけです。
#!/bin/sh MAIN=$1 ~/.nimble/bin/nim c -d:release $MAIN.nim zip $MAIN.zip bootstrap $MAIN
ビルド用 Dockerfile
現在のLambda実行環境であるAmazonLinux 2017.03.1.20170812 の Docker イメージでビルド環境を構築します。
FROM amazonlinux:2017.03.1.20170812 RUN yum install -y xz gcc RUN curl https://nim-lang.org/choosenim/init.sh -sSf > init.sh \ && chmod +x init.sh \ && ./init.sh -y \ && rm -f init.sh RUN mkdir /work WORKDIR /work RUN yum install -y zip COPY build.sh /build.sh
docker-compose.yml を作成します。
version: "3" services: nim: build: context: . image: aws-lambda-nim-builder command: /build.sh aws_lambda_nim_example volumes: - .:/work
最終的なファイル構成は以下のようになりました。
. ├── Dockerfile ├── aws_lambda_nim_example.nim ├── bootstrap ├── build.sh └── docker-compose.yml
ビルド実行
docker-compose を利用して nim をコンパイルします。
docker-compose build docker-compose up
コンパイル実行後のファイルは以下のようになりました。 aws_lambda_nim_example.zip
が Lambda にアップロードするファイルとなります。
. ├── Dockerfile ├── aws_lambda_nim_example ├── aws_lambda_nim_example.nim ├── aws_lambda_nim_example.zip ├── bootstrap ├── build.sh └── docker-compose.yml
Lambda Function の作成
Lambda 実行用のロールはすでにあるものとします。 handler
にバイナリファイル名を指定して作成します。
aws lambda create-function \ --function-name "aws-lambda-nim-example" \ --zip-file "fileb://aws_lambda_nim_example.zip" \ --handler "aws_lambda_nim_example" \ --runtime provided \ --role arn:aws:iam::999999999999:role/lambda_basic_execution
Lambda 関数が作成されました。
{ "FunctionName": "aws-lambda-nim-example", "FunctionArn": "arn:aws:lambda:ap-northeast-1:999999999999:function:aws-lambda-nim-example", "Runtime": "provided", "Role": "arn:aws:iam::999999999999:role/lambda_basic_execution", "Handler": "aws_lambda_nim_example", "CodeSize": 102317, "Description": "", "Timeout": 3, "MemorySize": 128, "LastModified": "2018-11-30T01:12:38.342+0000", "CodeSha256": "oSjewil21oL7C1w+gSJ2NthQv2m6ONETSb5RtVtup4c=", "Version": "$LATEST", "TracingConfig": { "Mode": "PassThrough" }, "RevisionId": "ddbf1688-345f-460a-b7d0-e0662b9d0f2c" }
実際に実行してみます。response.txt にレスポンスの中身を出力します。
aws lambda invoke \ --function-name "aws-lambda-nim-example" \ --payload '{"text":"Hello"}' \ response.txt
{ "StatusCode": 200, "ExecutedVersion": "$LATEST" }
レスポンスの中身を cat response.txt | jq .
で見てみます。
{ "requestId": "3db41196-f442-11e8-a8b4-9d64ba70d9be", "deadline": 1543542600555, "functionArn": "arn:aws:lambda:ap-northeast-1:999999999999:function:aws-lambda-nim-example", "traceId": "Root=1-5c009745-36997a167a83da36a3c93059;Parent=62cea21b272ebe72;Sampled=0", "event": { "text": "Hello" } }
event
にちゃんとリクエスト時のデータが格納されていますね!!
まとめ
AWS Lambda で Nim のようなマイナー 神 言語でも実行できることが確認できました!!
今回はシングルバイナリにコンパイルされる言語だったためシンプルでしたが、Runtime と実行コードが別になる言語(PHPなど)では Runtime だけを含めたレイヤ(新機能のLambda Layer)を作成するのがベストプラクティスとなります。
ただ、こういった足回りのコードを運用する必要があるため、よっぽどのことがない限りはサポートされている言語で実装することをおすすめします。
エンジニア募集!!
エムスリーでは、共に医療 × テクノロジーの未来を切り拓いてくれる仲間を募集中です! AWS 以外にも GCP や Firebase などのクラウドも活用しています!興味がある方はカジュアル面談やTechtalkにおこしください!!