Open9

コンテナ (AL2023) で AWS Lambda を構築するのに必要な事項のメモ

hassaku63hassaku63

なんとなく知ったような気になっているが、実はよく知らないしソラで説明できんなと思ったので、公式ドキュメントや手元で行ったサンプル実装をきちんと整理しておく。

いったんは Amazon Linux 2023 で AMD64 を使う想定で書いていく。

また、ターゲットとする開発言語は Rust を想定する。コンパイルが必要な言語がいいのので Go でもいいが、とりあえず自分は Rust の練習をしてみたいので Rust 想定。

hassaku63hassaku63

https://docs.aws.amazon.com/ja_jp/linux/al2023/ug/lambda.html

Amazon Linux 2023 を使う場合は、provided.al2023 を使う。 Amazon Linux 2 ベースの provided.al2 は 約 109MB だったが、それよりも大幅に小さい約 40MB であることが嬉しい。

AL2023 の特有な情報は以下参照。

https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html

https://docs.aws.amazon.com/lambda/latest/dg/images-create.html

また、Lambda の基盤で何が起きてるかの整理には以下の記事が有用。

https://aws.amazon.com/jp/builders-flash/202104/new-lambda-container-development-2

この図が特に良い。

ランタイム API との対話

[引用] ランタイム API との対話 - コンテナ利用者に捧げる AWS Lambda の新しい開発方式 ! 第 2 回

hassaku63hassaku63

コンテナによる Lambda デプロイについて押さえていく

Working with Lambda container images

https://docs.aws.amazon.com/lambda/latest/dg/images-create.html

ベースイメージの選定で3つに大別される。

Python や Node など、言語ランタイムまで揃った状態でメンテしてくれいてるのが "Using an AWS base image for Lambda" の選択肢。また、この種類に分類されるベースイメージは runtime interface emulator を包含している。

Go (2.x) や Rust など、言語ランタイムがサポートされていないものは "Using as AWS OS-only base image" を選択する必要がある。今回の想定ではここに該当する。種類に分類されるベースイメージも、runtime interface emulator を包含している。このエミュレータは環境変数によって invocation timeout などを設定できるので地味に便利。詳しいことはリンク先を読むこと。以下のようなコマンドで簡単にエミュレータを動かせる。

memo - Runtime interface emulator (RIE) について

RIE の話題はちょっと主題から外れる(しかし開発の利便性のため触れてはおきたい)のでこちらで。

https://docs.aws.amazon.com/lambda/latest/dg/images-test.html

言語サポートしているイメージなら以下の要領。

# platform はコンテナイメージのフォーマット指定であることに注意
$ docker run --platform linux/amd64 -p 9000:8080 docker-image:test

# Invoke lambda with event payload
$ curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{}'

Go や Rust なら、コンテナイメージとは別にコンパイル結果のバイナリも AMD64 なり ARM 64 なりランタイムに応じたコンパイルターゲットを指定する必要がある。また、OS-only のイメージを使うのでエミュレータを動かす手順も微妙に違う。

例えば Go 2.x なら以下。

$ docker run -d -v ~/.aws-lambda-rie:/aws-lambda -p 9000:8080 \
  --entrypoint /aws-lambda/aws-lambda-rie \
  docker-image:test \
    /main

RIE のバイナリを呼び出しつつ CMD でバイナリを指定する。

上記は Go の例だが Lambda の実行環境でコンパイルされたバイナリで(かつ Lambda に標準で含まれない共有ライブラリへの参照などが含まれない限り)であれば、どの言語だろうが関係はないはずなので、基本的には Rust もこれと同じことをすれば良い。

Invoke する手順は前述と同じ。

$ curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"payload":"hello world!"}'

ローカルでのテストはTesting Lambda container images locally も参照。

認証とか SG とかそういうのはサポートしないし、外部サービスとの連携や X-Ray なんかも含まれない。
もしハンドラ内でクレデンシャルを使いたいなら、AWS_ACCESS_KEY_ID などの環境変数をセットすれば利用可能。

また、 AWS_LAMBDA_FUNCTION_TIMEOUT が設定可能なので、Invocation timeout を検証することもできる。

hassaku63hassaku63

イメージの構成方法について。CMD と ENTRYPOINT の話

Go の場合は以下のような要領。

https://docs.aws.amazon.com/lambda/latest/dg/go-image.html#go-image-clients

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

コンパイルしたバイナリを ENTRYPOINT に指定すれば良い。ただし RIE を使う場合は前述しているように ENTRYPOINT を RIE のバイナリに、CMD でコンパイルしたアプリケーションバイナリを指定する必要があり、作法が違うので注意。

$ docker run -d -p 9000:8080 \
  --entrypoint /usr/local/bin/aws-lambda-rie \
  docker-image:test ./main
hassaku63hassaku63

Go 2.x では

とりあえず、CDK でデプロイすることを目指したい。

GoFunction は zip デプロイなので、これを使わないなら例えば以下のような書き方になる。

CDK プロジェクトの標準的なディレクトリ構造に、 src/ で Go のソースを配置する構造を前提とする。

// CDK
// lib/stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from "aws-cdk-lib/aws-lambda";
import { Platform } from "aws-cdk-lib/aws-ecr-assets";
import * as path from 'path';

export class FirstGoFunctionStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const f = new lambda.DockerImageFunction(this, 'FirstGoFunction', {
        functionName: 'FirstGoFunction',
        code: lambda.DockerImageCode.fromImageAsset(path.join(__dirname, '../src/'), {
          buildArgs: {
            HANDLER: 'firstgofunc',
          },
          platform: Platform.LINUX_AMD64,
        }),
      environment: {},
    });
    new cdk.CfnOutput(this, 'FirstGoFunctionArn', { value: f.functionArn });
  }
}

buildArgs でコンテナイメージのプラットフォームを指定している。これは docker build で指定するのと同じ。HANDLER は自前で導入した。 src/cmd/${handler_name}/ でハンドラ実装するようなディレクトリ構造を想定したので、どのハンドラのバイナリを見るかを CDK 側で制御できるようにしたかったのでこうした。

src/ 以下の主要なソースは以下。 src/cmd/${handler_name}/main.go でハンドラを実装する構造を前提に Dockerfile, Makefile を構成している。コンパイルしたバイナリは src/bin/${handler_name} に出力される。

Dockerfile

# Build environment
FROM golang:1.21 as build

ENV CGO_ENABLED=0
ENV GOOS=linux
ENV GOARCH=amd64

WORKDIR /app

# `.dockerignore` をよしなに設定しておくこと。例えば .env 系の ignore など
COPY . . 

RUN go mod download \
    && make build

# Copy artifacts to a clean image
FROM public.ecr.aws/lambda/provided:al2023
ARG HANDLER

COPY --from=build /app/bin/${HANDLER} ./main

ENTRYPOINT ./main

Makefile

今回のケースでは AMD64 のランタイム上で動かす想定だったので、Go のコンパイルターゲットも AMD64 で動かす必要がある。GOOSGOARCH の指定がそのへんに関係している部分

SRC_DIR := cmd
BIN_DIR := bin
FUNCTIONS := $(notdir $(wildcard $(SRC_DIR)/*))

.PHONY: build $(FUNCTIONS)

build: $(FUNCTIONS)

$(FUNCTIONS):
	@CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o $(BIN_DIR)/$@ $(SRC_DIR)/$@/*.go

.PHONY: clean
clean:
	rm -rf $(BIN_DIR)/*

handler - src/cmd/firstgofunc/main.go

package main

import (
	"context"
	"fmt"

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

type MyEvent struct {
	Name string `json:"name"`
}

func HandleRequest(ctx context.Context, event *MyEvent) (*string, error) {
	if event == nil {
		return nil, fmt.Errorf("received nil event")
	}
	message := fmt.Sprintf("Hello %s!", event.Name)
	return &message, nil
}

func main() {
	lambda.Start(HandleRequest)
}
hassaku63hassaku63

Rust では

https://docs.aws.amazon.com/lambda/latest/dg/lambda-rust.html

Rust runtime client という experimental なツールがある。これは cargo のサブコマンドとして lambda を追加する。このサブコマンドはさらにサブコマンドを持っていて、lambda ハンドラの雛形ソースを生成したり、ターゲット指定付きのコンパイルやデプロイをサポートする。

この CLI ツールが cargo build などを隠蔽しちゃっているが、CDK で扱うなら実際に何をしているが理解できてる必要がある。

とはいえ、Go で書かれたハンドラをコンテナデプロイするのとほぼ要領は同じはず。使用するベースイメージは同一なので、違いはコンパイルの過程のみのはず。

最終的に ENTRYPOINT に指定するバイナリが適切なバイナリフォーマット(つまりコンパイルターゲット)でコンパイルできていればよいので、そこがどうなっているかを調べればOK。あとは Dockerfile の書き方とか

比較的最近の日本語記事だと、2022/06 時点で書かれたユニークビジョンさんのものが観測上もっとも新しい。

https://qiita.com/aoyagikouhei/items/4ca1acccb876c5ab60c8

あるいは、cargo の lambda サブコマンドを提供しているツールのソースから必要そうなものやドキュメントを眺めるのもアリ。

https://github.com/cargo-lambda/cargo-lambda

hassaku63hassaku63

ユニークビジョンさんの記事、cargo-lambda のどちらも、ターゲットは x86_64-unknown-linux-musl を使っているっぽい。

簡単に ChatGPT や検索で調べてみると、musl を使う理由は「glibc 非依存なシングルバイナリを作りやすい」が最有力に思える。

参考まで、ChatGPT によるとざっくり以下のような回答があった。

  • glibc よりもサイズが軽い
  • 依存関係の静的リンク
  • glibc よりも機能が少ない
  • MIT ライセンス(glibc は LGPL)

https://blog.rust-jp.rs/tatsuya6502/posts/2019-12-statically-linked-binary/

↑「実践Rust入門」を補足する記事らしい。scratch イメージに musl ターゲットのバイナリだけ配置すると、きちんと動くのにサイズは 2.0MB を切る。Alpiine よりも軽くなる、というのが大変興味深い。

musl についても説明がある。以下 wikipidia より引用

musl は、効率的な静的リンクを可能にし、レースコンディションやリソースの枯渇による内部障害など、既存の実装に見られる様々なワーストケースを回避して、実時間品質の堅牢性を持つようにゼロから設計されている[6]。
[引用] https://ja.wikipedia.org/wiki/Musl

この記事の続編 では sqlite3 を musl にリンクしてコンパイルする方法が紹介されているので、EC2/ECS のようなデーモンで起動するようなものを作ろうと思ったらこちらも見てみるといいかもしれない