はじめに

AWS に触れたことのあるエンジニアなら誰もが、AWS Lambda のハンドラーが要求する以下のようなシグネチャに縛られずに、Web フレームワークを直接 Lambda で動かしたいと思ったことがあるはずです。

def lambda_handler(event, context):
  # 何かの処理

Lambda Web Adapter (以降 LWA と記載) は、これを実現するためのツールにおいて現時点でのデファクトスタンダードです。builders.flush でも紹介されており、こちらの記事では Express を例にとっていますが、今回は Echo という Go 製 Web フレームワークを使ったバックエンド API の構築を題材として、LWA の使いかたや TIPS を紹介します。

使いかた

LWA は、Dockerfile に 1 行追加するだけで動きます。この手軽さが素晴らしいですね。また、Go はシングルバイナリを置くだけで動くという特性があるので、コンテナ Lambda と親和性が非常に高いです。例えば以下のように書くことができます。

FROM --platform=${BUILDPLATFORM} golang:1.26.1-alpine AS build
WORKDIR /build
COPY . .
ARG TARGETOS TARGETARCH
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go mod tidy && go build -trimpath -o api ./

FROM alpine
# ここに注目!!
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
COPY --from=build /build/api /var/task/api
ENTRYPOINT ["/var/task/api"]

Lambda エクステンションとして LWA を指定しているだけです。これだけで Web フレームワークがコンテナ Lambda でそのまま動きます。

COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter

TIPS

基本的な使いかたがわかったので、アプリを書く際の TIPS を見ていきましょう。

ヘルスチェック用のエンドポイントを作る

LWA はアプリが起動しているかどうかをチェックします。つまり内部的なヘルスチェックをするためのエンドポイントが必要になります。このエンドポイントはデフォルトで / が設定され、環境変数 AWS_LWA_READINESS_CHECK_PATH で制御できます。Echo の場合は以下のように書けばよいでしょう。

package handler

import (
    "net/http"
    "github.com/labstack/echo/v4"
)

// OK は Lambda Web Adapter からのヘルスチェックに応答するためのハンドラーです。
func OK(c echo.Context) error {
    return c.String(http.StatusOK, "OK")
}

この OK ハンドラーを任意のルートに紐づけます。当然、認証で保護されていないパブリックなルートである必要があります。

func main() {
    e := echo.New()
    e.GET("/", handler.OK)
    e.Logger.Fatal(e.Start(":8000"))
}

サーバーの初期化は init に書く

公式ドキュメントに記載がありますが、Go のコードを Lambda で起動する場合、init() はコールドスタート時にのみ呼び出されます。ウォームスタート時は init() が完了した状態で呼び出されます。このため初期化処理はなるべく init() に書くことで、起動がより高速になります。ただし init() には引数も戻り値も書けないため、Echo インスタンスをグローバル変数で定義する必要があります。なお、公式ドキュメントのプログラム例も、この手法で S3 のクライアントを初期化しています。

var e *echo.Echo

// init に Echo インスタンスの初期化を書く
func init() {
    e = echo.New()
    ...
    // サーバー初期化設定
    ...
    e.GET("/", handler.OK)
}

// main は Echo インスタンスを Start するだけ
func main() {
    e.Logger.Fatal(e.Start(":8080"))
}

リクエスト ID を引き回す

Lambda のトラブルシューティングといえば、CloudWatch Logs でリクエスト ID を検索して、一連のリクエストの流れを掴むことが肝だと思います。しかし、LWA の場合は通常の Lambda と違ってリクエスト ID をそのまま CloudWatch Logs に書き込んでくれません。使用する Web フレームワークの作法でロギングする必要があります。

LWA の場合、リクエスト ID は X-Amzn-Lambda-Context という HTTP ヘッダーに JSON 形式で格納されているので、このオブジェクトをデコードしてリクエスト ID を取り出し、ロギングに使うことができます。

func GetLambdaRequestID(header http.Header) string {
    v := header.Get("X-Amzn-Lambda-Context")
    if v == "" {
        return ""
    }

    s := struct {
        RequestID string `json:"request_id"`
    }{}
    if err := json.Unmarshal([]byte(v), &s); err != nil {
        return ""
    }

    return s.RequestID
}

Echo のミドルウェアでアクセスログをとる場合、middleware.RequestLoggerWithConfig を使います。引数には専用の middleware.RequestLoggerConfig を渡しますが、LogRequestID というフィールドがあり、これを有効にすると X-Request-Id というヘッダーから自動で値を取得するようになります。

しかしリクエスト ID は X-Amzn-Lambda-Context に格納されており GetLambdaRequestID を使わないと取得できないので、以下のように BeforeNextFunc を使って X-Request-Id に載せ替えてしまえばよいでしょう。

func Logger() echo.MiddlewareFunc {
    return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
        LogRequestID:     true,
        BeforeNextFunc: func(c echo.Context) {
            h := c.Request().Header
            k := "X-Request-Id"
            v := GetLambdaRequestID(h)
            h.Set(k, v)
            c.Set(k, v)
        },
    })
}

認証に気をつける

LWA を使うと Lambda でそのまま API サーバーを起動でき、関数 URL 機能を使って直接 CloudFront のオリジンとして配信できるため、簡易的な用途であれば API Gateway を使う必要性がなくなります。これにより API Gateway 特有の設定や、従来から問題となることが多かったタイムアウト制限からナチュラルに解放されます。

しかしこの方法だと、API Gateway によって担保していた認証などの保護機能は別の形で維持する必要があります。こちらでは Lambda@Edge を利用して IAM 認証を行うパターンが紹介されています。Echo の場合は JWT 認証用のミドルウェアがあるので、これを使って認証を構築するのが一般的です。

おわりに

Lambda Web Adapter を実際のプロダクトに導入してみて、その便利さに感銘を受けたためブログにまとめました。

こういったツールや VPC レスなサービスが増えることによって、サーバーレス構成を選択するメリットが以前よりも増していると感じます。「枯れてきた」ということなのかもしれません。