エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

AWS Lambda 新機能 Custom Runtime を作ってみた

こんにちは、プログラミング言語が大好きなエムスリーエンジニアの園田です。

この記事は エムスリーアドベントカレンダー 3日目の記事となります。

先日、AWS 最大の年間イベントである re:Invent 2018 でラスベガスに渡航していました。

f:id:ryoheisonoda:20181130113248j:plain

基調講演で発表された数々の新サービス、日本時間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 Runtime では以下の順序で処理を実行するように実装します。

  1. 初期化処理
  2. イベントループ

シンプルですね。イベントループが従来の 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_APIbootstrap 実行プロセスの環境変数に設定された 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におこしください!!

 jobs.m3.com