エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

goのLambdaはコンテナイメージでよくない?

【Unit4 ブログリレー8日目】

こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。 今日の記事は、タイトルのとおりgoのLambdaはコンテナイメージで管理するのがオススメって話です。

城島高原パーク(きじまこうげんパーク)は、大分県別府市の城島高原にある遊園地。本文には関係ありません。

go1.xランタイムのサポート終了

先日、AWS Lambdaの go1.x ランタイムのサポート2023/12/31で終了することが話題になっていました。 「なっていました」と伝聞なのは、Unit4ではすでにコンテナイメージでしか使っていないので、AWSからのメール通知が届いておらず、 明示的に告知しているサイトもない(Amazon Linux 1のサポート終了の記事とかはある)ので、SNSや各種サイトからの伝え聞いた感じです。

7/26の記事ですでに触れてはいますね。メールで通知されたのが最近ってことですかね。

aws.amazon.com

移行後は provided.al2 のカスタムランタイムを使うことになって、コンテナイメージとやる事はあんまり変わらないので、 このタイミングでコンテナイメージに移行するのはありだと思います。

コンテナイメージにする利点

コンテナイメージで運用する利点は3つあります。

1つ目は好きな環境で構築できる点です。 今回のようにランタイムのサポートなどを気にすることなく、コンテナとして動くように実装していれば、実行環境のことを気にする必要はありません。 後述するように今回は distroless 上で実行させるので、Amazon Linuxのサポートなどは関係なくなります。 また、事実上linuxのバイナリとして実行できるものは、環境さえそろえれば、何でも動かせます。 RustやC++で実行することもできます。

github.com

github.com

また、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がオススメです。

www.m3tech.blog

起動時間は遅くならないの?

コンテナイメージにすると、イメージのデプロイで遅くなりそうなのですが、 コンテナイメージが大きくなっても起動時間はあまり変わらないようです。 Unit4の運用の経験でも、PythonやNode.jsのランタイムに比べて遅い、という印象はありません。

dev.classmethod.jp

www.space-i.com

実際に運用しているログを見てみました。

例1は、RDBに接続してクエリを実行し、結果をREST APIで送信するLambda関数です。バッチなので10分に1回実行しています。イメージサイズは26MB程度です。 コールドスタートにかかる時間はだいたい20ms程度でした。

例1の起動時間

例2は、ユーザから送信された画像ファイルを読んで、サイズを変更し、S3に保存するLambda関数です。ユーザの求めに応じて実行されるので不定期です。イメージサイズは9MB程度です。 コールドスタートにかかる時間はだいたい35〜45ms程度でした。短期間で実行されればコールドスタートになりません。

例2の起動時間

比較として.zipで上げているPythonのLambda(.zipで上げているgoがなかったので)のログを示します。このLambdaはDynamoDBの中身を取得してS3に保存します。バッチで1日1回の実行です。 これは250ms程度でした。

PythonのLambdaの起動時間

上に挙げたコンテナイメージの例はコールドスタートでも速いですが、3000msを超える場合もあって、どういう条件で遅くなるのかよく分かりません。 以下の例では最初だけ3000ms以上かかっていますが、そのあとは250ms程度です。1回目が遅いのはECRにpushした直後に実行させたからかもしれません。 また、112MB程度あるので、イメージサイズも関係あるかもしれません。

3000msを超えた例

構成

ファイルの構成は以下のとおりです。 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.gogo/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にデプロイしないでローカルで動作確認するためのイメージです。 公式のドキュメントにも説明があります。

docs.aws.amazon.com

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 を使える

Lambdaの設定例

まとめ

  • goのコンテナイメージでLambdaを構築する実例を紹介しました。
  • 公式に用意されたイメージでローカル実行する方法を紹介しました。

We are hiring!

m3の多様なサービスを一緒に開発してくれる仲間を募集中です。お気軽にお問い合わせください。

jobs.m3.com