AWS Lambda で任意の言語を実行する

Lambda は標準で Go や Python などが使えますが、これらの言語以外でも自分で runtime を作りさえすればあらゆる言語で使えるようになります。
Custom AWS Lambda runtimes

今まで runtime を自作するのは難しそうだなぁと思っていましたが、昨年12月のアップデートでコンテナイメージが使えるようになり、また AWS Lambda Runtime Interface Emulator もできたことで Lambda の挙動をローカルで実験するのが手軽になったので、試しに bash runtime を作ってみました。

Tutorial – Publishing a custom runtime - AWS Lambda

bash runtime

以下が Lambda で bash スクリプトを実行するための例です。簡単のためエラーハンドリングはしていません。

  • Dockerfile
  • main.sh
    • Lambda で実行するスクリプト(handler)
    • LAMBDA_TASK_ROOT に配置する
  • bootstrap
    • entrypoint から呼ばれるプログラム
    • LAMBDA_RUNTIME_DIR に配置する
    • request_id, event を GET して、handler にそれらを渡して、handler の返り値を POST するということを永遠繰り返す

Dockerfile

FROM public.ecr.aws/lambda/provided:al2

COPY bootstrap $LAMBDA_RUNTIME_DIR/bootstrap
COPY main.sh $LAMBDA_TASK_ROOT/

CMD ["main.sh"]

bootstrap

#!/bin/bash

RUNTIME_URL="http://$AWS_LAMBDA_RUNTIME_API/2018-06-01"

function post() {
    request_id=$1
    response=$2
    url="$RUNTIME_URL/runtime/invocation/$request_id/response"
    curl -X POST $url -d "$response"
}

while true
do
    HEADERS="$(mktemp)"
    EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "$RUNTIME_URL/runtime/invocation/next")
    RESPONSE=$(bash ${LAMBDA_TASK_ROOT}/${_HANDLER} $EVENT_DATA)
    REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
    post $REQUEST_ID $RESPONSE
done

main.sh

#!/bin/bash

EVENT=$1
echo "Hello:$EVENT"

AWS Lambda execution environment - AWS Lambda

実装したのはこの図の Runtime + Function のところです。(extensions のところはまたいつか深ぼるかも)

bootstrap からわかるようにやっていることは極めて単純で curl -X GET http://$AWS_LAMBDA_RUNTIME_API/2018-06-01/runtime/invocation/next (next invocation と呼ばれる)で必要な情報とってきて、handler でコネコネして、curl -X POST "http://$AWS_LAMBDA_RUNTIME_API/2018-06-01/runtime/invocation/$request_id/response" -d $response (Invocation response と呼ばれる)で所定の場所に結果を渡しているだけです。

この GET/POST さえしておけば Lambda としてとりあえず動きます。なので sokcet が使える普通の言語であればシュッとその言語用の runtime を作ることができます。

※ このスクリプトでは handler に event (body)しか渡していませんが、通常の Lambda では context として header も渡します。

$ aws lambda invoke --cli-binary-format raw-in-base64-out \
  --function-name lambda_container \
  --payload '"John"' /dev/stderr > /dev/null
Hello:"John"

この bash runtime では next invocation, invocation response の2つの runtime API しか使っていませんが、他にエラー時に使用する Invocation error, Initialization error があります。 エラーハンドリングもちゃんとする場合はこれらの API も使います。 AWS Lambda runtime API

entrypoint からの動きを追ってみる

AWS の公式イメージを使って、所定のプログラムを所定の場所に置くと動くことがわかりましたが、ブラックボックス感が否めないので entrypoint から動きを追ってみます。

$ docker inspect public.ecr.aws/lambda/provided:al2 | jq .[].Config.Entrypoint[]
"/lambda-entrypoint.sh"

デフォルトの entrypoint が /lambda-entrypoint.sh とわかったので中を覗いていきます。

#!/bin/sh
# Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.

if [ $# -ne 1 ]; then
  echo "entrypoint requires the handler name to be the first argument" 1>&2
  exit 142
fi
export _HANDLER="$1"

RUNTIME_ENTRYPOINT=/var/runtime/bootstrap
if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
  exec /usr/local/bin/aws-lambda-rie $RUNTIME_ENTRYPOINT
else
  exec $RUNTIME_ENTRYPOINT
fi

第1引数に handler 名をとり、/var/runtime/bootstrap を実行しています。 本物の Lambda ではAWS_LAMBDA_RUNTIME_API に値が入っているので /var/runtime/bootstrap を実行し、値が入っていなかった場合(ローカルで実行した場合)は aws-lambda-rie (runtime interface emulator) を使ってエミュレーションしています。

/var/runtime/bootstrap 以降は書いたプログラムのままなので、どうやら内部的に複雑なことはしていないようです。

alpine base でイメージを作る

bootstrap が動けばいいだけなら alpine linux をもとにしたイメージでも動くのでは?と思ったので試しに作ってみました。

Dockerfile

FROM alpine:3.13.0

RUN apk add curl

ENV LAMBDA_RUNTIME_DIR=/var/runtime \
    LAMBDA_TASK_ROOT=/var/task

COPY bootstrap /$LAMBDA_RUNTIME_DIR/bootstrap
COPY main.sh $LAMBDA_TASK_ROOT/

WORKDIR $LAMBDA_TASK_ROOT

ENTRYPOINT ["/var/runtime/bootstrap"]
CMD ["main.sh"]

main.sh

#!/bin/sh

EVENT=$1
echo "HelloWorld:$EVENT"

bootstrap

#!/bin/sh

_HANDLER=$1

RUNTIME_URL="http://$AWS_LAMBDA_RUNTIME_API/2018-06-01"

function post() {
    request_id=$1
    response=$2
    url="$RUNTIME_URL/runtime/invocation/$request_id/response"
    echo post url: $url
    curl -X POST $url -d "$response"
}

while true
do
    HEADERS="$(mktemp)"
    EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "$RUNTIME_URL/runtime/invocation/next")
    RESPONSE=$(sh ${LAMBDA_TASK_ROOT}/${_HANDLER} $EVENT_DATA)
    REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
    post $REQUEST_ID $RESPONSE
done

image サイズは公式のイメージをもとにした場合は 303 MB でしたが、alpine を使った場合は 9.69 MB まで減りました。

rie 使ってまずはローカルで動くか試してみます。

$ curl -Lo aws-lambda-rie https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie
$ chmod +x aws-lambda-rie
$ docker build -t lambda_alpine .
$ docker run --rm -p 9000:8080 \
    -v $(pwd)/aws-lambda-rie:/aws-lambda-rie \
    --entrypoint="/aws-lambda-rie" \
    lambda_alpine /var/runtime/bootstrap main.sh
$ curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '"John"'
HelloWorld:John

ひとまず、ローカルで実験する限りは Lambda っぽく動いてくれました。

続いて、イメージを ECR にあげて、本物の Lambda で動かしてみます。

$ docker push ...
$ aws lambda update-function-code ...
$ aws lambda invoke --cli-binary-format raw-in-base64-out --function-name lambda_container --payload '"John"' /dev/stderr > /dev/null
HelloWorld:"John"

無事にほしい結果が返ってきました。runtime API の扱いさえちゃんとしていれば Lambda 用にソフトウェア追加する必要はどうやらないようです。 これなら bootstrap を一つ書けば既存の docker image を Lambda 用に編集するというのも容易にできそうですね。

Lambda の副作用問題

副作用のあるプログラムをLambda で動かすともろにその影響を受けるというのは AWS Lambda を使っている人には有名ですが、bootstrap の中で同じ関数を繰り返し実行していることを考えれば至極当然だとわかりますね。

# main.py
x = []
n = 0

def handler(event, context):
    global n
    x.append(n)
    n += 1
    return str(x)
$ aws lambda invoke --function-name lambda_container /dev/stderr > /dev/null
"[0]"
$ aws lambda invoke --function-name lambda_container /dev/stderr > /dev/null
"[0, 1]"
$ aws lambda invoke --function-name lambda_container /dev/stderr > /dev/null
"[0, 1, 2]"
$ aws lambda invoke --function-name lambda_container /dev/stderr > /dev/null
"[0, 1, 2, 3]"