michimani.net

コンテナイメージを使った Lambda 関数のあれこれ

2022-11-04

Lambda 関数のパッケージタイプとしてコンテナイメージを指定できるようになってからもう 2 年が経とうとしています。最近になってちゃんと触るようになって色々わかってきたことがあるので、やったことをまとめておきます。

コンテナイメージを使った Lambda 関数の概要

まずは概要です。

と言っても難しいことはなく、

くらいです。

その他の Lambda 関数の機能 (環境変数、 Layer (Extension) など) については、従来の Lambda 関数と同等です。

Dockerfile

Lambda 関数用のコンテナイメージを作成する際、ベースとなるイメージは AWS が提供しているベースイメージを使う方法と、 alpine などの代替イメージを使う方法があります。それぞれメリット・デメリットがあるので簡単にまとめておきます。

また、今回は前提として Go で実装されたコードをもとにイメージを作成します。なので、下記ディレクトリ構成と main.go が存在していることとします。

.
├── Dockerfile
├── entry.sh
├── go.mod
├── go.sum
└── main.go
package main

import (
	"log"

	runtime "github.com/aws/aws-lambda-go/lambda"
)

type Response struct {
	Message string `json:"message"`
}

func handleRequest() (Response, error) {
	log.Println("start handler")
	defer log.Println("end handler")

	return Response{
		Message: "Hello AWS Lambda",
	}, nil
}

func init() {
	log.Println("cold start")
}

func main() {
	runtime.Start(handleRequest)
}

AWS ベースイメージを使う

AWS ベースイメージを使う場合は下記のような Dockerfile になります。

FROM public.ecr.aws/lambda/provided:al2 as build
RUN yum install -y golang
RUN go env -w GOPROXY=direct
ADD go.mod go.sum ./
RUN go mod download
ADD . .
RUN go build -o /main

FROM public.ecr.aws/lambda/provided:al2
COPY --from=build /main /main
ENTRYPOINT ["/main"]

AWS ベースイメージは ECR Public で公開されているので、 docker build するには ECR にログインしている必要があります。

AWS ベースイメージには、 Lambda Layer (Extension) を使用するための機構やローカルで実行するための RIE (Runtime Interface Emulator) も含まれており、ビルド後のイメージサイズは大きくなります。

後述しますが、 RIE だけをイメージに含めることもできるので、 AWS ベースイメージを使うシチュエーションとしては Lambda Layer (Extension) を使いたい場面となりそうです。

代替ベースイメージを使う

代替のベースイメージ (今回は alpine) を使う場合は下記のような Dockerfile になります。

FROM alpine as build
RUN apk add go git
RUN go env -w GOPROXY=direct
ADD go.mod go.sum ./
RUN go mod download
ADD . .
RUN go build -o /main

FROM alpine
COPY --from=build /main /main
ENTRYPOINT [ "/main" ]

ベースに使うイメージのサイズにもよりますが、必要最低限の構成となるため AWS ベースイメージを使う場合と比較してビルド後のイメージサイズは小さくなります。
イメージサイズが小さくなる分、 Lambda Layer (Extension) は使用できず、ローカルでの実行もできません。

Lambda Layer (Extension) を使用しない場合、AWS ベースイメージの代わりに代替となるベースイメージを使うことでイメージサイズを小さくできます。

とはいえローカルでの実行はできたほうが嬉しいので、その場合は下記のように RIE だけを追加するような Dockerfile を用意します。

FROM alpine as build
RUN apk add go git
RUN go env -w GOPROXY=direct
ADD go.mod go.sum ./
RUN go mod download
ADD . .
RUN go build -o /main

FROM alpine
COPY --from=build /main /main
ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie /usr/bin/aws-lambda-rie
RUN chmod 755 /usr/bin/aws-lambda-rie
COPY entry.sh /
RUN chmod 755 /entry.sh
ENTRYPOINT [ "/entry.sh" ]
CMD [ "/main" ]

ここで出てくる entry.sh は下記の内容とします。

#!/bin/sh
if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
  exec /usr/bin/aws-lambda-rie "$@"
else
  exec "$@"
fi

ローカル環境での実行

コンテナイメージを使った Lambda 関数はローカル環境での実行も容易です。

RIE によるローカルでの実行

ローカル環境での実行には RIE (Runtime Interface Emulator) を使用します。

前項で書いたように AWS ベースイメージを使用するか、代替ベースイメージに RIE を追加することでローカル環境で Lambda 関数を実行することができます。

コンテナイメージ内に RIE が含まれている場合、下記の手順でローカルでの起動・実行が可能です。

  1. docker build

    docker build -t container-lambda-function:local .
    
  2. docker run

    docker run \
    --rm \
    -p 9000:8080 \
    container-lambda-function:local
    

    (ポートは任意)

    コンテナを起動すると、 Lambda 関数が実行されるのではなく、実行可能な状態になります。

    標準出力には下記のようなログが出力されます。

    03 Nov 2022 13:39:12,623 [INFO] (rapid) exec '/main' (cwd=/var/task, handler=)
    
  3. invoke

    実際に Lambda 関数を実行するには、

    http://localhost:9000/2015-03-31/functions/function/invocations
    

    に対して GET もしくは POST リクエストを送ります。

    curl 'http://localhost:9000/2015-03-31/functions/function/invocations'
    

    curl のレスポンスとしては下記のような出力が得られます。

    {"message":"Hello AWS Lambda"}
    

    また、起動しているコンテナイメージの標準出力では、下記のような出力が得られます。

    START RequestId: b8a7e4ed-381d-4925-8a7a-48a90a4dd2e4 Version: $LATEST
    2022/11/03 13:44:08 cold start
    2022/11/03 13:44:08 start handler
    2022/11/03 13:44:08 end handler
    END RequestId: b8a7e4ed-381d-4925-8a7a-48a90a4dd2e4
    REPORT RequestId: b8a7e4ed-381d-4925-8a7a-48a90a4dd2e4  Init Duration: 0.34 msDuration: 10.43 ms       Billed Duration: 11 ms  Memory Size: 3008 MB    Max Memory Used: 3008 MB
    

    これは実際の Lambda 関数が出力するログと同等の内容になっています。

一方で、 RIE はイメージ内に含めずにローカル環境にインストールして使用することもできます。1 ただ、開発者のローカル環境に RIE をインストールしてもらうのは面倒なので、コンテナイメージ内に含めてしまうのが良いかなと思います。

ローカル環境に RIE をインストールしてそれを使う場合、コンテナ起動時のコマンドが下記のようになります。

docker run -d -v ~/.aws-lambda-rie/aws-lambda \
--entrypoint /aws-lambda/aws-lambda-rie \
-p 9000:8080 \
container-lambda-function:local

イベントの再現

Lambda 関数の実行時には API Gateway からの Payload、 S3 や SQS 等からのイベントを受け取ることがあります。
ローカル環境でそれらの受け取りを再現するには、 curl でのリクエスト時にメソッドを POST に、イベントの JSON をリクエストボディに入れることで再現できます。

例えば、 SQS 駆動の Lambda 関数を再現したい場合は、下記のようなリクエストを送ります。

curl -X POST \
-H 'Content-Type: application/json' \
-d '{"Records": [{"messageId": "message-id", "eventSource": "event-source", "body": "{\"key\": \"value\"}"}]}' \
'http://localhost:9000/2015-03-31/functions/function/invocations'

SQS 駆動の Lambda 関数の実装例はこちら。

sqs-lambda-example/lambda at main · michimani/sqs-lambda-example

コールドスタートとウォームスタート

Lambda 関数の特徴として、コールドスタートとウォームスタートがあります。
コンテナイメージを使った Lambda 関数のローカル実行ではこれらの再現も可能です。

具体的な動作としては、 docker run でコンテナを起動してから最初に curl でリクエスト送った際の実行はコールドスタートとなり、それ以降はウォームスタートの挙動となります。起動中のコンテナを停止して起動しなおせば、またコールドスタートの挙動を確認できます。

例であげている Go のコードでは、コールドスタート時のみ init() 関数が実行され、それ以降は実行されないことが確認できます。

コールドスタート時のログ。

START RequestId: b8a7e4ed-381d-4925-8a7a-48a90a4dd2e4 Version: $LATEST
2022/11/03 13:44:08 cold start
2022/11/03 13:44:08 start handler
2022/11/03 13:44:08 end handler
END RequestId: b8a7e4ed-381d-4925-8a7a-48a90a4dd2e4
REPORT RequestId: b8a7e4ed-381d-4925-8a7a-48a90a4dd2e4  Init Duration: 0.34 msDuration: 10.43 ms       Billed Duration: 11 ms  Memory Size: 3008 MB    Max Memory Used: 3008 MB

ウォームスタート時のログ。

START RequestId: d5016f63-3648-423c-bb7a-597548b83da1 Version: $LATEST
2022/11/03 14:00:20 start handler
2022/11/03 14:00:20 end handler
END RequestId: d5016f63-3648-423c-bb7a-597548b83da1
REPORT RequestId: d5016f63-3648-423c-bb7a-597548b83da1  Duration: 1.41 ms     Billed Duration: 2 ms    Memory Size: 3008 MB    Max Memory Used: 3008 MB

コールドスタート時には init() 関数内で出力しているログがあり、さらに Init Duration の情報も出力されていますが、ウォームスタート時にはそれらがありません。

Lambda 関数のこの挙動によって DB へのコネクションが増え続ける罠がありますが、それについてもローカル環境で再現することができます。

michimani/lambda-rdb-test: In this repository, you can try to see how the number of DB connections changes depending on the implementation method when connecting to RDB from Lambda functions implemented in Go.

Lambda Runtime API の利用

Lambda には Lambda Runtime API 2 というものが用意されており、実行中の Lambda 関数に関する情報を取得したり、動作を与えることができます。 RIE を使ったローカル実行では、Lambda Runtime API の挙動についても確認することができます。

Lambda Runtime API のエンドポイントは、Lambda 関数実行時に自動的に設定される AWS_LAMBDA_RUNTIME_API という環境変数に設定されており、 RIE を使ったローカル実行時にもこの環境変数は設定されています。

Go では下記のような実装によって、実行中の Lambda 関数の Request ID を取得することができます。

const (
	runtimeAPIEnvKey           = "AWS_LAMBDA_RUNTIME_API"
	runtimeRequestIDHeaderName = "Lambda-Runtime-Aws-Request-Id"
)

func getRequestID(client *http.Client) (string, error) {
	host := os.Getenv(runtimeAPIEnvKey)
	if host == "" {
		return "", fmt.Errorf("host is empty")
	}

	url := fmt.Sprintf("http://%s/2018-06-01/runtime/invocation/next", host)
	req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil)
	if err != nil {
		return "", err
	}

	res, err := client.Do(req)
	if err != nil {
		return "", err
	}

	rHeader := res.Header
	rIds, exists := rHeader[runtimeRequestIDHeaderName]
	if !exists {
		return "", fmt.Errorf("'%s' header does not exists.", runtimeRequestIDHeaderName)
	}

	if len(rIds) == 0 {
		return "", fmt.Errorf("Value of '%s' header is empty.", runtimeRequestIDHeaderName)
	}

	return rIds[0], nil
}

関数全体の実装はこちら。

base-of-lambda-container-image/main.go at main · michimani/base-of-lambda-container-image

Lambda Layer (Extension) の利用

Lambda には Lambda Layer (Extension) という機能があります。ローカル実行ではこの機能の挙動も確認することができます。これに関しては後述します。

CI/CD

コンテナイメージを使った Lambda 関数の CI/CD について考えてみます。

従来の Lambda 関数であれば、該当バージョンのコード一式を zip 化して S3 Bucket にアップロードし、 lambda update-function-code にてソースとなるコードをしてい

基本的な更新方法

従来の Lambda 関数であれば、該当バージョンのコード一式を zip 化して更新、もしくは zip を S3 Bucket にアップロードして更新していましたが、コンテナイメージを使った Lambda の場合はフローが異なります。

まず、変更後のコードでコンテナイメージを作成し、 ECR Repository に push します。 そして、 lambda update-function-codeImageURI を更新します。

このときの URI はハッシュ付き URI でもよいですが、 latest 等のタグ付き URI でも可です。むしろ、タグ付き URI を使って更新したほうが、リソースを CFn や Terraform で管理している場合には差分が発生しないのでおすすめです。

タグ付き URI で更新した場合でも、実際に利用されるイメージのハッシュ付き URI については lambda get-function で確認することができます。

aws lambda get-function \
--function-name 'lambda-parameters-extension-function' \
--query 'Code' | grep Uri
"ImageUri": "000000000000.dkr.ecr.ap-northeast-1.amazonaws.com/container-lambda-function:latest",
"ResolvedImageUri": "000000000000.dkr.ecr.ap-northeast-1.amazonaws.com/container-lambda-function@sha256:c0b63302fa2ad2a78e6da8b37b8d4394c1b231a0963d82abd06d37dd4fd66277"

更新したあとは、実行基盤が変わってコールドスタートとなるタイミングから新しいコードでの実行となります。

CI/CD を構築する際の注意点

コンテナイメージを使った Lambda 関数関連のリソースを定義する際には、いくつか依存関係がありリソース作成に関しては下記の順序で実施する必要があります。

  1. ECR Repository を作成
  2. Lambda 関数のコードを実装
  3. ECR Repository に push
  4. Lambda 関数のリソースを作成 (対象となる ECR Repository にイメージが存在している必要があるため)

CI/CD パイプラインを作成する際にも上記に注意しながら順番にリソースを定義していく必要があります。具体的には、まずは ECR に push するところまでのパイプラインを作って一度実行し、その後に関数を更新するステップを追加する、といった段階を踏むことになります。

GitHub Actions による CI/CD

コンテナイメージを使った Lambda 関数の更新を GitHub Actions で実施する場合の実装例です。

dev ブランチへの push で dev 環境用の Lambda 関数を、 main ブランチへの push で production 環境用の Lambda 関数を、それぞれ更新するようなサンプルになっています。

michimani/container-lambda-cicd: コンテナイメージを利用する Lambda 関数の CI/CD のサンプル。

認証情報については、予め作成しておいた IAM Role の ARN のリポジトリの Secrets に設定しておいて、 yaml 内では ${{ secrets.ASSUME_ROLE_ARN }} で使用します。外部から設定が必要な情報はそれだけです。

Lambda Layer (Extension) の使い方

コンテナイメージを使用した Lambda 関数での Lambda Layer (Extension) の使い方についてです。

概要

従来の Lambda 関数では、マネジメントコンソールから Layer を選択、もしくは Lambda リソース内の Layers に使用したいレイヤーを指定すればよかったですが、コンテナイメージを使用した Lambda 関数ではこの方法では使えません。

方法としては、使用したい Layer (Extension) のバイナリをコンテナイメージに含めることで実現します。 配置するディレクトリも決まっており、 /opt/extensions 配下にバイナリを配置します。

Layer (Extension) については AWS 公式/3rd party 問わずコンテナイメージとして公開されているものもありますが、公開されていないものもあります。コンテナイメージとして公開されていない AWS 公式の Layer (Extension) に関しては、対処の Layer (Extension) の ARN をもとに lambda get-layer-version-by-arn で取得できます。

他の注意点として、 Dockerfile のところでも触れましたが、 Layer (Extension) を使用したい場合は AWS が提供しているベースイメージを使用する必要があります。

具体的な方法については下記の AWS 公式ブログに記載されています。

コンテナイメージ内でLambda レイヤーと拡張機能を動作させる | Amazon Web Services ブログ

Parameter and Secret Lambda Extension を使ったサンプル

例として、先日発表されていた Parameter and Secret Lambda Extension をコンテナイメージを使用する Lambda 関数で使う場合を考えます。

Extension のバイナリ取得

まずは Extension のバイナリを取得します。

Parameter and Secret Lambda Extension の ARN については下記の公式ドキュメントに記載されています。

Use AWS Secrets Manager secrets in AWS Lambda functions - AWS Secrets Manager

東京 (ap-northeast-1) リージョンの ARN は

arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension:2

なので、下記コマンドで zip 形式の Extension を取得します。

curl $(
  aws lambda get-layer-version-by-arn \
  --arn 'arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension:2' \
  --query 'Content.Location' --output text
) --output ps-ex.zip

AWS 公式ブログ内では Dockerfile 内に記述されていましたが、 アクセスキーやシークレットを Dockerfile 内に記述したり環境変数で渡したりするのが面倒だった (直球) ので、ローカル環境にダウンロードしてくる方法をとっています。

コンテナイメージの作成

下記のような Dockerfile でコンテナイメージを作成します。まずはローカルでの実行を想定しているため entry.sh を含めるようにしています。

 1FROM public.ecr.aws/lambda/provided:al2 as build
 2RUN yum install -y golang unzip
 3RUN go env -w GOPROXY=direct
 4ADD go.mod go.sum ./
 5RUN go mod download
 6ADD . .
 7RUN go build -o /main
 8RUN mkdir -p /opt
 9ADD ./ps-ex.zip ./
10RUN unzip ps-ex.zip -d /opt
11RUN rm ps-ex.zip
12
13FROM public.ecr.aws/lambda/provided:al2
14COPY --from=build /main /main
15COPY entry.sh /
16RUN chmod 755 /entry.sh
17RUN mkdir -p /opt/extensions
18WORKDIR /opt/extensions
19COPY --from=build /opt/extensions .
20ENTRYPOINT [ "/entry.sh" ]
21CMD ["/main"]

8-11 行目で、ローカルにダウンロードした Extension の zip をビルド用のステージに追加し unzip しています。

そして 18-19 行目で、最終的に作成されるイメージの /opt/extensions ディレクトリに Extension のバイナリを配置しています。

実装

Parameter and Secret Lambda Extension は http://localhost:2773 でリクエストを受け付けているので、下記のような実装で利用します。

const (
	// Endpoint for getting parameter by Parameters and Secrets Lambda Extension.
	exGetParameterEndpoint = "http://localhost:2773/systemsmanager/parameters/get"

	// Header key of secret token
	secretTokenHeaderKey = "X-Aws-Parameters-Secrets-Token"

	// Query parameter key
	queryParameterKeyForName    = "name"
	queryParameterKeyForVersion = "version"
)

// Struct of response from AWSParametersAndSecretsLambdaExtension API
type resultFromExtension struct {
	Parameter struct {
		ARN              string
		DateType         string
		LastModifiedDate time.Time
		Name             string
		Selector         string
		SourceResult     *string
		Type             string
		Value            string
		Version          int
	}
	ResultMetadata any
}

// Get a value using Parameters and Secrets Lambda Extension.
func getValueByUsingExtension(key string, version int) (string, error) {
	// Get a value from extension
	// https://docs.aws.amazon.com/systems-manager/latest/userguide/ps-integration-lambda-extensions.html
	query := url.Values{}
	query.Add(queryParameterKeyForName, key)
	query.Add(queryParameterKeyForVersion, fmt.Sprintf("%d", version))
	queryStr := query.Encode()

	url := fmt.Sprintf("%s?%s", exGetParameterEndpoint, queryStr)
	req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil)
	if err != nil {
		return "", err
	}

	// set X-Aws-Parameters-Secrets-Token header
	req.Header.Add(secretTokenHeaderKey, os.Getenv("AWS_SESSION_TOKEN"))

	// call Extension API
	client := http.Client{}
	res, err := client.Do(req)
	if err != nil {
		return "", err
	}
	defer res.Body.Close()

	buf := new(bytes.Buffer)
	if _, err := buf.ReadFrom(res.Body); err != nil {
		return "", err
	}
	bodyString := buf.String()

	if res.StatusCode != http.StatusOK {
		return "", fmt.Errorf("Failed to get parameter by using extension. statusCode:%d body:%s", res.StatusCode, bodyString)
	}

	exRes := resultFromExtension{}
	if err := json.Unmarshal([]byte(bodyString), &exRes); err != nil {
		return "", err
	}

	return exRes.Parameter.Value, nil
}

Extension を使った場合のログ

Extension を使った場合、コールドスタート時に下記のようなログが確認できます。

[AWS Parameters and Secrets Lambda Extension] 2022/11/03 15:20:32 PARAMETERS_SECRETS_EXTENSION_LOG_LEVEL is not present. Log level set to info.
[AWS Parameters and Secrets Lambda Extension] 2022/11/03 15:20:32 INFO Systems Manager Parameter Store and Secrets Manager Lambda Extension 1.0.94
[AWS Parameters and Secrets Lambda Extension] 2022/11/03 15:20:32 INFO Serving on port 2773

詳細な実装サンプルについてはこちら。

michimani/lambda-parameters-extension: Sample code of using Parameter and Secret Lambda Extension.

まとめ

コンテナイメージを使った Lambda 関数について、最近触っていてわかったことなどをまとめてみました。

Lambda 関数は便利で利用できる場面も多い反面、ローカルでの動作確認が (SAM 等のツールを別途インストールする必要があったりで) 面倒な印象でした。

それが、コンテナイメージを使うことで (実際には RIE を使用することで) 実際の環境とほぼ同じような挙動を確認することができるという点が、個人的には一番嬉しいです。
この一点だけでもコンテナイメージ Lambda を使う価値はあるかなと思っています。

他には、CI/CD に関しても イメージをビルドして ECR に push するところまでは ECS タスクの場合と同じなので、そのあたりの共通化 (もしくはほぼ流用) ができるというのも嬉しいポイントです。

ということで、コンテナイメージを使った Lambda 関数はいいぞ。


  1. コンテナイメージを使用して Go Lambda 関数をデプロイする - AWS Lambda  ↩︎

  2. AWS Lambda ランタイム API - AWS Lambda  ↩︎


comments powered by Disqus