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 を使う日が来るかも(?)