【Unit4 ブログリレー8日目】
こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。 今日の記事は、タイトルのとおりgoのLambdaはコンテナイメージで管理するのがオススメって話です。
go1.xランタイムのサポート終了
先日、AWS Lambdaの go1.x
ランタイムのサポート2023/12/31で終了することが話題になっていました。
「なっていました」と伝聞なのは、Unit4ではすでにコンテナイメージでしか使っていないので、AWSからのメール通知が届いておらず、
明示的に告知しているサイトもない(Amazon Linux 1のサポート終了の記事とかはある)ので、SNSや各種サイトからの伝え聞いた感じです。
7/26の記事ですでに触れてはいますね。メールで通知されたのが最近ってことですかね。
移行後は provided.al2
のカスタムランタイムを使うことになって、コンテナイメージとやる事はあんまり変わらないので、
このタイミングでコンテナイメージに移行するのはありだと思います。
コンテナイメージにする利点
コンテナイメージで運用する利点は3つあります。
1つ目は好きな環境で構築できる点です。 今回のようにランタイムのサポートなどを気にすることなく、コンテナとして動くように実装していれば、実行環境のことを気にする必要はありません。 後述するように今回は distroless 上で実行させるので、Amazon Linuxのサポートなどは関係なくなります。 また、事実上linuxのバイナリとして実行できるものは、環境さえそろえれば、何でも動かせます。 RustやC++で実行することもできます。
また、docker上であれば、後に紹介するように、ローカルの動作テストも同じ環境で実行できます。 ローカルのMacでは動いたけど、Lambda上の環境では動かなかった、という状況を避けやすくなります。
2つ目は、コード管理のしやすさです。
.zipファイルを上げる方式では、goのビルドから.zipファイルの作成、アップロードまでを自分で用意することが多いと思いますが、
コンテナイメージであれば、Dockerfileに書けば、docker build
, docker push
でビルドからアップロードまでできます。
他のWebサービスと同じフローで管理できるのは利点です。
3つ目は、これはコンテナイメージに限りませんが、arm64(Graviton2)を使えばx86に比べて20%安く実行できます。
goの場合はアーキテクチャが変わってもほぼコード変更なしにarm64対応できるはずなので、arm64で動かした方が得です。
なお、料金だけでなく、実行速度の点でもarm64の方がお得です。
今回コンテナイメージを使わず provided.al2
のカスタムランタイムに移行させる場合であっても安くなるのは同じなのでarm64がオススメです。
起動時間は遅くならないの?
コンテナイメージにすると、イメージのデプロイで遅くなりそうなのですが、 コンテナイメージが大きくなっても起動時間はあまり変わらないようです。 Unit4の運用の経験でも、PythonやNode.jsのランタイムに比べて遅い、という印象はありません。
実際に運用しているログを見てみました。
例1は、RDBに接続してクエリを実行し、結果をREST APIで送信するLambda関数です。バッチなので10分に1回実行しています。イメージサイズは26MB程度です。 コールドスタートにかかる時間はだいたい20ms程度でした。
例2は、ユーザから送信された画像ファイルを読んで、サイズを変更し、S3に保存するLambda関数です。ユーザの求めに応じて実行されるので不定期です。イメージサイズは9MB程度です。 コールドスタートにかかる時間はだいたい35〜45ms程度でした。短期間で実行されればコールドスタートになりません。
比較として.zipで上げているPythonのLambda(.zipで上げているgoがなかったので)のログを示します。このLambdaはDynamoDBの中身を取得してS3に保存します。バッチで1日1回の実行です。 これは250ms程度でした。
上に挙げたコンテナイメージの例はコールドスタートでも速いですが、3000msを超える場合もあって、どういう条件で遅くなるのかよく分かりません。 以下の例では最初だけ3000ms以上かかっていますが、そのあとは250ms程度です。1回目が遅いのはECRにpushした直後に実行させたからかもしれません。 また、112MB程度あるので、イメージサイズも関係あるかもしれません。
構成
ファイルの構成は以下のとおりです。
hello
, bye
, version
の3つのLambda関数を作る想定です。
. ├── Dockerfile └── go ├── functions │ ├── bye │ │ ├── functions.go -> ../../functions.go │ │ └── main.go │ ├── hello │ │ ├── functions.go -> ../../functions.go │ │ └── main.go │ └── version │ ├── functions.go -> ../../functions.go │ └── main.go ├── functions.go ├── go.mod └── go.sum
処理本体
go/functions.go
に処理の本体が本体が書かれています。
今回はサンプルなので、直接関数を記述していますが、実際には wire でDI済のファイルを参照させることが多いです。
package main import ( "context" "fmt" "log/slog" "os" "time" ) // version ビルド時にldflagsで設定する var version string var jst = time.FixedZone("Asia/Tokyo", 9*60*60) type HelloRequest struct { Name string `json:"name"` } type ByeRequest struct { Name string `json:"name"` } type Response struct { Message string `json:"message"` Timestamp time.Time `json:"timestamp"` } func generateResponse(message string) Response { return Response{message, time.Now().In(jst)} } func getLogger() *slog.Logger { return slog.New(slog.NewJSONHandler(os.Stdout, nil)) } func Hello(ctx context.Context, req HelloRequest) (Response, error) { getLogger().Info("Hello", "req", req) return generateResponse(fmt.Sprintf("Hello %s", req.Name)), nil } func Bye(ctx context.Context, req ByeRequest) (Response, error) { getLogger().Info("Bye", "req", req) return generateResponse(fmt.Sprintf("Bye %s", req.Name)), nil } func Version(ctx context.Context) (Response, error) { getLogger().Info("Version") return generateResponse(fmt.Sprintf("Version=%s", version)), nil }
main.go
go/functions/
以下にLambda関数ごとにディレクトリを切って、それぞれに main.go
を置きます。
ここに置く main.go
は go/functions.go
内で定義した関数を呼び出すだけにしています。
go/functions.go
はシンボリックリンクで参照します。
package main import "github.com/aws/aws-lambda-go/lambda" func main() { lambda.Start(Hello) }
Dockerfile
Dockerfileは以下のように作ります。
# ビルド FROM golang:1.21.0 AS builder COPY ./go /workspace/go WORKDIR /workspace/go ENV ARCH="arm64" ARG VERSION RUN go mod download && \ GOOS=linux GOARCH=${ARCH} CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /functions/hello ./functions/hello/* && \ GOOS=linux GOARCH=${ARCH} CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /functions/bye ./functions/bye/* && \ GOOS=linux GOARCH=${ARCH} CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /functions/version ./functions/version/* # 本番実行用 # 2023/08/29 の latest FROM gcr.io/distroless/static@sha256:2368c04cb307fd5244b92de95bd2bde6a7eb0eb4b9a0428cb276beeae127f118 as aws COPY --from=builder /functions /functions # entrypointはlambdaの設定で上書きされる ENTRYPOINT ["/functions/hello"] # ローカル実行用 FROM public.ecr.aws/lambda/provided:al2 as local COPY --from=builder /functions /functions ENTRYPOINT ["/usr/local/bin/aws-lambda-rie"]
goベースのコンテナイメージでは一般的だと思いますが、イメージは2ステージに分けてビルドします。
まず golang
のイメージ内でコンパイルしておいて、ビルドしたバイナリを実行させたいイメージにコピーします。
コンパイルは、
イメージ内の /functions/
以下にLambda関数ごとにバイナリをコンパイルして置きます。
この例では /functions/hello
, /functions/bye
, /functions/version
の3ファイルができます。
本番実行用イメージ
本番実行用イメージでは、 shellなどは必要ないですし、不要なものがない方がセキュリティ上安全なので distroless をベースにします。 また、distrolessにすればイメージのサイズも小さくなります。上の例では21.5MBで収まりました。
なお、distrolessのイメージのタグは、バージョンなどがついていないようで、
gcr.io/distroless/static:latest-arm64
などと最新を指定しておくと環境を固定できないので、ハッシュを指定して固定しています。
一時期distrolessの最新のイメージではLambdaが起動しないことがあったので、動くことが確認できているイメージに固定する方がいいと思います。
ビルドは --target aws
を指定します。
docker build --build-arg VERSION="v1" --target aws -t lambda-sample .
ローカル実行用イメージ
ローカル実行用イメージは、AWSにデプロイしないでローカルで動作確認するためのイメージです。 公式のドキュメントにも説明があります。
public.ecr.aws/lambda/provided:al2
をベースにします。ビルドに --target local
を指定します。
docker build --build-arg VERSION="v1" --target local -t lambda-sample-local .
ローカルで実行
ローカル実行用のイメージをENTRYPOINTを指定して起動すると、8080でhttpリクエストを受けつけるので、
/2015-03-31/functions/function/invocation
(これは固定) にPOSTすると実行できます。
# hello docker run -p 9000:8080 --rm lambda-sample-local /functions/hello # bye docker run -p 9000:8080 --rm lambda-sample-local /functions/bye # version docker run -p 9000:8080 --rm lambda-sample-local /functions/version
# hello curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"name": "fukubaya"}' {"message":"Hello fukubaya","timestamp":"2023-08-29T22:17:26.412269963+09:00"} # bye curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"name": "fukubaya"}' {"message":"Bye fukubaya","timestamp":"2023-08-29T22:20:14.309488659+09:00"} # version curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{}' {"message":"Version=v1","timestamp":"2023-08-29T22:21:05.852958133+09:00"}
# コンテナのログ docker run -p 9000:8080 --rm lambda-sample-local /functions/hello 29 Aug 2023 13:21:53,908 [INFO] (rapid) exec '/functions/hello' (cwd=/var/task, handler=) START RequestId: b4a242a6-afba-48bf-b813-a4a3d1166840 Version: $LATEST 29 Aug 2023 13:21:56,412 [INFO] (rapid) extensionsDisabledByLayer(/opt/disable-extensions-jwigqn8j) -> stat /opt/disable-extensions-jwigqn8j: no such file or directory 29 Aug 2023 13:21:56,412 [INFO] (rapid) Configuring and starting Operator Domain 29 Aug 2023 13:21:56,412 [INFO] (rapid) Starting runtime domain 29 Aug 2023 13:21:56,412 [WARNING] (rapid) Cannot list external agents error=open /opt/extensions: no such file or directory 29 Aug 2023 13:21:56,412 [INFO] (rapid) Starting runtime without AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN , Expected?: false {"time":"2023-08-29T13:21:56.611534115Z","level":"INFO","msg":"Hello","req":{"name":"fukubaya"}} END RequestId: 146bc3d5-bbb6-4ec8-8b13-b998768b7db1 REPORT RequestId: 146bc3d5-bbb6-4ec8-8b13-b998768b7db1 Init Duration: 0.32 ms Duration: 219.43 ms Billed Duration: 220 ms Memory Size: 3008 MB Max Memory Used: 3008 MB
Lambdaの設定
必要な設定を指定すれば生成できます。 事前に有効なコンテナイメージをECRのpushしておく必要があります。
- 関数名
- 適当に識別できる名前を設定する
- コンテナイメージURI
- ECRのURI
- ENTORYPOINT
- イメージ内のバイナリの位置を指定する
- アーキテクチャ
- arm64 で graviton2 を使える
まとめ
- goのコンテナイメージでLambdaを構築する実例を紹介しました。
- 公式に用意されたイメージでローカル実行する方法を紹介しました。
We are hiring!
m3の多様なサービスを一緒に開発してくれる仲間を募集中です。お気軽にお問い合わせください。