🧨

AWS Lambda handler はどのように呼ばれるのか

2023/12/12に公開

はじめに

この記事はAWS Lambda と Serverless Advent Calendar 2023 12 日目の記事です。

AWS Lambda の handler 関数は Python の場合ドキュメントにあるように eventcontext という2つの引数を取ります。

def handler_name(event, context): 
    ...
    return some_value

一方で Go の場合 handler 関数はドキュメントにあるように context.Context と各呼び出し元のサービスに応じた event を引数に取る関数です。しかし AWS Lambda for Go パッケージの lambda.Start() のコメントを読むと他のシグネチャも受け付けることができるようです。

さらに experimental なクレートですが Rust Runtime for AWS Lambda を使うと Lambda の handler 関数はドキュメントにあるように event を引数に取ります。

このようにプログラミング言語ごとに handler 関数のシグネチャが異なるのはなぜなのかを調べました。

Lambda Runtime

結論からいうと各言語ごとに Lambda Runtime が異なるからです。ドキュメントから引用します。

A runtime provides a language-specific environment that relays invocation events, context information, and responses between Lambda and the function.

Lambda (個々の関数というより AWS Lambda というサービスそのもの)が handler 関数を実行するときに、イベントや context 情報を Lambda -> handler に渡したり、レスポンスやエラーを適切に handler -> Lambda に渡したりするのが Lambda Runtime の役割です。そしてこの Runtime が言語ごとに違うため handler の引数などのシグネチャが言語ごとに変わってきます。

custom runtime

まずは自分たち独自の runtime を実装する方法を通して runtime を深掘りします。ドキュメントはこちらです。こちらのドキュメントだともう少し詳しく書いていますね。

A runtime runs the function's setup code, reads the handler name from an environment variable, and reads invocation events from the Lambda runtime API. The runtime passes the event data to the function handler, and posts the response from the handler back to Lambda.

runtime は Lambda function のコードを実行し、環境変数から handler 名を読み取り、Lambda runtime API からイベントを読み取り、そのイベントを handler に渡し、実行結果を Lambda サービスに返却する役割を担います。

ここで出てきた Lambda runtime API というのはドキュメントのこの辺りで触れられている REST API です。Lambda サービスと実行されている Lambda 関数の間でイベントの情報やレスポンスの情報をやりとりする API です。

runtime-api

ここからは各言語の runtime をみていきます。Lambda のデプロイは zip で固めたものとコンテナイメージで固めたものの二種類の方法で行えますが、コンテナイメージで固めた方にはドキュメントによると runtime やコンテナで Lambda を実行するのに必要な依存物が含まれているようなのでこちらをみていきます。リポジトリはこれで言語ごとにブランチが分かれています。

Python runtime

Python 3.12 のブランチをみてみます。CPU アーキテクチャごとにディレクトリが異なるのでまずは x86-64の方から。

みたところ複数の .tar ファイルと Dockerfile しかありません。Dockerfile は以下のようになっていて .tar ファイルをコピーしてきて環境変数を読み込んでシェルスクリプトを実行しています。

FROM scratch

ADD 08e61bf1511d156676b2e9ac9eea5a8727c2c5a67fa4d7a957cee02af4a51252.tar.xz /
ADD 2af890e6c018deda6f3c648ecacfcaaaedac77737202a4734789c2c6a5a7d311.tar.xz /
ADD 528988914a845cdff18ac62a93ad1a76850dc47e91976198a85ab64d4cb831fe.tar.xz /
ADD aa4e4959b3fbf905b5ef2fe18f50ed003430318578c8469d15605b11edee79fb.tar.xz /
ADD b83aa73bb4514ef90e8899e6baf6404d1d792bc2587b4d0dff1f9c9c3458c9b0.tar.xz /
ADD feb815887f22ce6ea2814b716320e5a1b255f00f3ed05a1f21f0d8640b117208.tar.xz /

ENV LANG=en_US.UTF-8
ENV TZ=:/etc/localtime
ENV PATH=/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin
ENV LD_LIBRARY_PATH=/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib
ENV LAMBDA_TASK_ROOT=/var/task
ENV LAMBDA_RUNTIME_DIR=/var/runtime

WORKDIR /var/task

ENTRYPOINT ["/lambda-entrypoint.sh"]

この lambda-entrypoint.sh の中身が気になりますね。もう少し粘ってみましょう。

Lambda の公式ベースイメージは ECR Public にホストされています。そしてコンテナイメージは docker save コマンドで tar ファイルにして保存することができます。なので以下のようなコマンドを実行して手元でコンテナイメージの中身を見てみましょう。

$ docker image pull public.ecr.aws/lambda/python:3.6.2023.12.05.17
$ docker save -o test.tar public.ecr.aws/lambda/python:3.6.2023.12.05.17
$ tar -xvf test.tar

複数のディレクトリが生成されてそれぞれ layer.tar があると思うのでこれらを全て解凍します。その上で sudo find . -name '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

... /usr/local/bin/aws-lambda-rie/var/runtime/bootstrap を実行しているようですがバイナリファイルのようで何をしているかこれ以上はわかりませんでした。

気を取り直して Go をみていきましょう。

Go

Go の runtime もあります(リポジトリはこちら)が Go はコンパイルされてネイティブコードが生成されるため Lambda は custom runtime として扱うようです。ドキュメントはこちら

Because Go compiles to native code, Lambda treats Go as a custom runtime. We recommend that you use the provided.al2023 or provided.al2 base image for custom runtimes to build container images for Go functions.

ドキュメントに載っているサンプルの Dockerfile は以下です。マルチステージビルドを使ってビルドの成果物を Amazon Linux 2023 のベースイメージにコピーして entrypoint を指定しています。

FROM golang:1.20 as build
WORKDIR /helloworld
# Copy dependencies list
COPY go.mod go.sum ./
# Build with optional lambda.norpc tag
COPY main.go .
RUN go build -tags lambda.norpc -o main main.go
# Copy artifacts to a clean image
FROM public.ecr.aws/lambda/provided:al2023
COPY --from=build /helloworld/main ./main
ENTRYPOINT [ "./main" ]

この場合だとシンプルにビルドした成果物を見れば良さそうです。main.go は以下のような感じです。

package main

import (
	"context"
	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

func handler(ctx context.Context, event events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	response := events.APIGatewayProxyResponse{
		StatusCode: 200,
		Body:       "\"Hello from Lambda!\"",
	}
	return response, nil
}

func main() {
	lambda.Start(handler)
}

main 関数を見ると大事なのは lambda.Start のようです。というわけで aws-lambda-go/lambda.Start のコードを読んでいきます。

lambda.Start

Start 関数は interface 型の handler を引数に取り StartWithOptions を実行しています。

func Start(handler interface{}) {
	StartWithOptions(handler)
}

StartWithOptions は以下のように newHandler でラップされた handler 関数を start しています。

func StartWithOptions(handler interface{}, options ...Option) {
	start(newHandler(handler, options...))
}

まずは newHandler の方から見ていきましょう。handler をラップしてると思いきやどうやら handlerOptions 構造体を返しているようです。この構造体の定義はこんな感じで元の handler 関数に加えていくつかフィールドが追加されています。

type handlerOptions struct {
	handlerFunc
	baseContext              context.Context
	jsonResponseEscapeHTML   bool
	jsonResponseIndentPrefix string
	jsonResponseIndentValue  string
	enableSIGTERM            bool
	sigtermCallbacks         []func()
}

次に start を見ます。必要なところだけ抜き出すとこんな感じ。

type startFunction struct {
	env string
	f   func(envValue string, handler Handler) error
}

var (
	runtimeAPIStartFunction = &startFunction{
		env: "AWS_LAMBDA_RUNTIME_API",
		f:   startRuntimeAPILoop,
	}
	startFunctions = []*startFunction{runtimeAPIStartFunction}
)

func start(handler *handlerOptions) {
	var keys []string
	for _, start := range startFunctions {
		config := os.Getenv(start.env)
		if config != "" {
			// in normal operation, the start function never returns
			// if it does, exit!, this triggers a restart of the lambda function
			err := start.f(config, handler)
		}
		keys = append(keys, start.env)
	}
}

環境変数 AWS_LAMBDA_RUNTIME_API がセットされていれば startRuntimeAPILoopnewHandler の返り値を渡しているようです。環境変数 AWS_LAMBDA_RUNTIME_API がセットされているかは Amazon Linux 2023 の Lambda 用ベースイメージ Dockerfile を読む限りではわかりませんでした。ビルドした Lambda は動いたのでこの環境変数はセットされているものとして次に進みます。

startRuntimeAPILoopここで定義されています。 newRuntimeAPIClient で Lambda Runtime API を叩くクライアント(中身は普通の HTTP クライアント)を初期化し、next を実行して Lambda Runtime API にリクエストしリクエスト ID などの情報を取得し、handleInvoke に取得した情報と handler を渡しています。次に handleInvoke を見ていきます。

handleInvokeここで定義されています。ざっくり処理内容をまとめると、Runtime API からのレスポンスを色々検証し、context.Context を組み立て(ここで Runtime API からのレスポンスより Trace ID を取得し、その ID を環境変数にセットし、context.Context に埋め込むことをやっています)、callBytesHandlerFunc を呼ぶ流れです。callBytesHandlerFuncこの部分でいよいよ handler 関数が呼ばれます。

func callBytesHandlerFunc(ctx context.Context, payload []byte, handler handlerFunc) (response io.Reader, invokeErr *messages.InvokeResponse_Error) {
	defer func() {
		if err := recover(); err != nil {
			invokeErr = lambdaPanicResponse(err)
		}
	}()
	response, err := handler(ctx, payload)
	if err != nil {
		return nil, lambdaErrorResponse(err)
	}
	return response, nil
}

長かったですがどのようにしてユーザーが書いた handler 関数が呼ばれるかと、渡される context.Context はどのように作られるかなんとなくイメージがついたのではないかと思います。

こぼれ話

もともと今年のアドベントカレンダーの記事のタイトルは「色々なイベントソースで発火する Lambda を計装する」にするつもりでした。API Gateway -> Lambda だったり SQS -> Lambda だったり S3 event notification -> Lambda と発火させて、それぞれのイベントソースごとに適切に OpenTelemetry で計装するコードを書いてみようと思っていました。しかし手始めに API Gateway -> Lambda のコードを書いたときによくよく考えると ctx に Trace ID が含まれているからやることがあまりないなと気づいてしまいました( Lambda 関数の中の処理を計装する場合 tracer.Start()ctx を渡すとうまいこと Span が生成できてしまう)。Context Propagation を頑張るのかなと思っていたのに予定が崩れたので逆にどこで ctx に Trace ID がセットされているか調べようと思いこの記事が出来上がりました。

おわりに

今までは handler 関数を書く方に集中していて Lambda Runtime の方はおざなりだったので腰を据えてコードを読めてよかったです。

Discussion