AWS Lambda Runtime Interface Clients / Emulator とはなにか

以前の記事を先に読むと理解が進むかも(?) goropikari.hatenablog.com

AWS Lambda がコンテナイメージをサポートしたのと時を同じくして Runtime Interface Clients (RIC) と Runtime Interface Emulator (RIE) というツールが公開されました。

RIC / RIE は AWS が配布している各言語用のイメージの中にはすでに含まれており、また暗黙的に動いているので普通に Lambda を使う分にはこれらのツールについて意識することはおそらくありません。

ではどんなときに意識するとよいのかというと次のような場合が考えられると思います

  • AmazonLinux 以外の OS イメージを使いたい
    • Debian が使いたいー
    • Alpine が使いたいー
    • etc...
  • 既存のコンテナイメージを Lambda 用に改造したい
  • Lambda の挙動をローカルでテストしたい

これらのことを簡単に実現させますよというのが RIC とRIE です。

Lambda はどう動いているのか

RIC/RIE を説明する前に Lambda はどのような流れで実行されるのかを確認しておきます。

Lambda

AWS Lambda execution environment

  1. API Gateway や S3 event などで Lambda が着火すると、まず Lambda Service は Execution Environment というものを作成します。
  2. その上で runtime などの初期化がなされます。
  3. その後 runtime は Runtime API を介して Lambda Service と通信し、event、context を取得、Function (Handler) にそれらを渡します
  4. Function の返り値を Runtime API を通して Lambda Service に渡します。
  5. Invoke がされなくなるまで 3~4 を繰り返します
  6. 一定時間の間 invoke がされなかったとき、Lambda Service は runtime をシャットダウンし、Execution Environment を削除します。

Runtime Interface Clients

先に説明したとおり runtime は Lambda Service と通信する必要があります。 ですが、世にあるコンテナイメージは Lambda Service と通信する機能を用意していません。 そんなときに Lambda Service とおしゃべりする仕組みを提供してくれるのが Runtime Interface Clients (RIC)です。 RIC を使わず Lambda Service と通信しようとすると前回の記事の bootstrap のようなものを自分で書かねばならず面倒です。

RIC は各言語用にパッケージとして提供されており、現在提供されているのは Lambda が標準でサポートしている以下の6種類です。

ref: Runtime support for Lambda container images

それ以外の言語は公式には用意されていないので自分で作りましょう!

一例として RIC を使って素の Python イメージを Lambda 用に変えてみます。

Dockerfile

FROM python:3.9-slim

WORKDIR /var/task
COPY main.py .

RUN pip install awslambdaric

ENTRYPOINT ["/usr/local/bin/python", "-m", "awslambdaric"]
CMD ["main.handler"]

main.py

def handler(event, context):
    return f"Hello {event}"
$ docker build ...
$ docker push ...
$ aws lambda update-funciton-code ...
$ aws lambda invoke ... --payload '"John"'
Hello John

前回の記事の ENTRYPOINT ["/var/runtime/bootstrap"] の部分が ENTRYPOINT ["/usr/local/bin/python", "-m", "awslambdaric"] に置き換わった感じです。

他の言語でも似たような感じで使うことができます。

既存のイメージを Lambda 用に改造したい場合は、handler 関数作って、RIC 入れて、ENTRYPOINT, CMD を上記のような感じで書けば Lambda で動かせるようになります。ね、簡単でしょ?

Runtime Interface Emulator

RIC によって Lambda Service と通信する手段を獲得しましたが、我々のローカル環境には Lambda Service はいないので作ったプログラムをテストできません。困りました。 そんなときに Lambda Service の代わりをしてくれるのが Runtime Interface Emulator (RIE) です。こちらは RIC と違い言語縛りはありません。現時点では Linux x86-64 用のバイナリのみ配布されています。とはいえ Go 製のツールなのでソースコードから自分でビルドすれば Mac でも動くと思います(試していませんが)。コンテナイメージ内で使われることを想定しているっぽいので今後も Linux 以外の OS はサポートしないと思います。

使い方は以下のように ENTRYPOINT, CMD で書いた内容をそのまま引数に取ります。

./aws-lambda-rie /usr/local/bin/python -m awslambdaric main.handler

このとき注意点としては python への PATH は絶対 PATH で書きます。

RIE を使うと 8080, 9001 port で Listen している HTTP server が立ち上がります。9001 が Runtime API 用で、8080 が Lambda 関数を invoke するための口です。

$ curl -XPOST "http://localhost:8080/2015-03-31/functions/function/invocations" -d '"John"'
"Hello John"

POST すると実行時間などが出力されますが、本物の Lambda でやった場合と比べるとかけ離れた数値を返すので参考になりません。

START RequestId: 762d8fc3-3fe8-4d08-8690-212e727fbbcc Version: $LATEST
END RequestId: 762d8fc3-3fe8-4d08-8690-212e727fbbcc
REPORT RequestId: 762d8fc3-3fe8-4d08-8690-212e727fbbcc  Init Duration: 0.28 ms  Duration: 64.73 ms  Billed Duration: 100 ms Memory Size: 3008 MB    Max Memory Used: 3008 MB    
START RequestId: ccacb48a-a5e2-4200-a7b6-51559ea2332e Version: $LATEST
END RequestId: ccacb48a-a5e2-4200-a7b6-51559ea2332e
REPORT RequestId: ccacb48a-a5e2-4200-a7b6-51559ea2332e  Duration: 1.31 ms   Billed Duration: 100 ms Memory Size: 3008 MB    Max Memory Used: 3008 MB

自作 Lambda Service

RIE の動きを見て、とりあえず HTTP server を2つ建てておけば良さそうな雰囲気を察したので雑にオレオレ Lambda Service を作ってみました。

server.py

# https://stackoverflow.com/a/60753

import json
import re
import uuid
from http.server import BaseHTTPRequestHandler, HTTPServer
from queue import Queue
from socketserver import ThreadingMixIn
from threading import Thread

inputs = Queue()
results = Queue()


class Lambda(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path == '/2015-03-31/functions/function/invocations':
            content_length = int(self.headers['Content-Length'])
            post_data = self.rfile.read(content_length)
            inputs.put(post_data)
            self.send_response(200)
            self.send_header("Content-type", "application/json")
            self.end_headers()
            self.wfile.write(results.get())


class RuntimeAPI(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/2018-06-01/runtime/invocation/next':
            self.send_response(200)
            self.send_header("Content-type", "application/json")
            self.send_header("Lambda-Runtime-Aws-Request-Id",
                             str(uuid.uuid4()))
            self.end_headers()
            self.wfile.write(inputs.get())

    def do_POST(self):
        if re.match(r"/2018-06-01/runtime/invocation/.*/response", self.path):
            self.send_response(200)
            self.end_headers()
            content_length = int(self.headers['Content-Length'])
            post_data = self.rfile.read(content_length)
            results.put(post_data)


class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
    daemon_threads = True


def serve_on_port(port, handler):
    server = ThreadingHTTPServer(("localhost", port), handler)
    server.serve_forever()


if __name__ == '__main__':
    Thread(target=serve_on_port, args=[8080, Lambda]).start()
    serve_on_port(9001, RuntimeAPI)

RIC が AWS_LAMBDA_RUNTIME_API という環境変数を使うのでそれだけセットして実行します。

# terminal 1
$ python server.py

# terminal 2
$ export AWS_LAMBDA_RUNTIME_API=localhost:9001
$ /usr/local/bin/python -m awslambdaric main.handler

# terminal 3
$ curl -XPOST "http://localhost:8080/2015-03-31/functions/function/invocations" -d '"John"'
"Hello John"

無事にそれっぽい動きをしてくれました。

機能を絞ってしまえば、案外自分でも書けるものですね。

現時点で RIE は Logs API に対応しておらず Lambda extension の動作検証ができないので、案外早くオレオレ Lambda Service を使う日が来るかも(?)

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]"

sops で localstack の KMS key を使う

sops を使っていて、これ localstack の KMS key を使うことできないかなぁとふと思ってみたので試してみました。

endpont-url を受け取れるように修正すれば一応動かせました。

github.com

特に実用性はないのですけど、備忘録的に残しておきます。 localstack を使ったローカルテストをするときには役に立つかも?

動作確認環境

  • ArchLinux
  • awscli v2.1.13
  • localstack v0.12.4

修正点

対した変更はしていませんが、endpoint-url を環境変数から吸えるようにしました。

   config := aws.Config{
        Region:   aws.String(matches[1]),
        Endpoint: aws.String(os.Getenv("AWS_ENDPOINT_URL")),
    }

sops/keysource.go at localstack · goropikari/sops · GitHub

$ git clone -b localstack --depth 1 https://github.com/goropikari/sops.git
$ docker build -t sops -f Dockerfile-localstack .
$ id=$(docker create sops)
$ docker cp $id:/go/bin/sops sops-localstack
$ docker rm -v $id

$ docker run --rm -p 4566:4566 localstack/localstack:0.12.4
$ export AWS_ENDPOINT_URL=http://localhost:4566
$ alias awsl="aws --endpoint-url $AWS_ENDPOINT_URL"
$ keyarn=$(awsl kms create-key --query KeyMetadata.Arn --output text)
$ export SOPS_KMS_ARN=$keyarn
$ ./sops-localstack foo.json
$ ./sops-localstack -d foo.json
{
        "hello": "localstack"
}

参考

boot2docker - Docker - how can I copy a file from an image to a host? - Stack Overflow

2020年 振り返り

勉強記録を見直すと2020年は低レイヤの勉強と雰囲気で使っていたものをちゃんと学び直した1年だったようだ。

読んだ・受講した

途中まで読んだ

現在取り組んでいる

AWS Lambda をコンテナイメージで動かすときの Terraform の書き方

AWS Lambda がコンテナイメージをサポートするようになりましたが、Terraform で Lambda のリソースを定義するのにてこづったので書き方を残しておきます。

aws.amazon.com

動作確認時のソフトウェア

  • awscli 2.1.11
  • terraform 0.14.2
  • terraform-provider-aws 3.21.0

Terraform の書き方

Lambda のリソースを作るときに ECR にイメージがすでにある必要があるので、Lambda を作る前に ECR のリポジトリと Lambda で使う IAM を作っておきます。 ECR リポジトリができたら Docker image を push しておきます。

resource "aws_lambda_function" "lambda_container" {
  function_name = "lambda_container"
  role          = aws_iam_role.lambda_container.arn
  package_type  = "Image"
  image_uri     = "${aws_ecr_repository.lambda_containter.repository_url}:latest"
  timeout       = 60

  lifecycle {
    ignore_changes = [image_uri]
  }
}

resource "aws_iam_role" "lambda_container" {
...
}

resource "aws_ecr_repository" "lambda_containter" {
...
}

Terraform で Lambda を作るときは package_type = "Image" にし、image_uri に ECR にイメージ URI をタグを含めて指定します。 Lambda 関数を update するとそのたびに Terraform で差分が出てしまうので ignore_changesimage_uri を入れておくと良いです。

Terraform の公式ドキュメントを見ると handler が Required になっていますが、package_typeImage にした場合は不要です。

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function

実際に使用したコード github.com