AWS Lambda で Go の API サーバーを作るには、provided.al2023 ランタイムに bootstrap という名前の Linux 向けバイナリを置き、API Gateway の HTTP API から呼び出します。Go 専用だった go1.x ランタイムはすでに廃止されており、2026 年 10 月時点で Go を動かす現役のランタイムは provided.al2023 です1 2 。
この記事では、github.com/ aws/ aws-lambda-go の events.APIGatewayV2HTTPRequest を RouteKey で振り分ける小さな TODO API を作ります。arm64 向けのビルド、AWS SAM の template.yaml、go test で回すユニットテスト、Claude Code に作らせるときの指示の出し方までを順に説明します。コードは Go 1.26.2 と aws-lambda-go v1.55.1 で go vet と go test を通したものです。
Go の Lambda 関数は「OS だけのランタイム(OS-only runtime)」の上で、自分でビルドした実行ファイルとして動きます。Go はネイティブのバイナリにコンパイルされるので、Node.js や Python のような言語ランタイムが要りません3 。
必要な約束は 3 つだけです。
実行ファイルの名前を bootstrap にする(zip のルートに置く)1
ランタイムに provided.al2023 を選ぶ(Amazon Linux 2023 ベース)2
main から lambda.Start(ハンドラー) を呼ぶ。aws-lambda-go がイベントの受け取りと応答の返送を受け持つ3
API Gateway HTTP API が受けたリクエストをイベント JSON にして Lambda の bootstrap に渡し、Go のハンドラーが RouteKey で処理を振り分けて応答を返す流れ 図: 筆者作成
HTTP API は、リクエストを「ペイロード形式 2.0」の JSON にして Lambda に渡します。aws-lambda-go ではこの JSON が events.APIGatewayV2HTTPRequest 型になります。RouteKey には GET / todos/ {id} のような「メソッド+ルートのパス」が入ります4 。
理由は 2 つあります。1 つはランタイムの入れ替えです。もう 1 つは arm64 の安さです。
AWS は 2023 年 7 月に、Amazon Linux 1 の終了に合わせて go1.x を廃止すると発表しました5 。ランタイム一覧では go1.x の廃止日は 2024 年 1 月 8 日です。さらに Amazon Linux 2 ベースの provided.al2 も 2026 年 7 月 31 日に廃止されました2 。
ランタイム OS 廃止日 関数の作成ブロック 関数の更新ブロック go1.xAmazon Linux 2024-01-08 2024-02-08 2027-08-31 provided.al2Amazon Linux 2 2026-07-31 2027-07-29 2027-08-31 provided.al2023Amazon Linux 2023 2029-06-30(予定) 2029-07-31(予定) 2029-08-31(予定)
(2026 年 10 月時点の AWS Lambda のランタイム一覧より2 )
古い関数が残っていても、更新ブロックの日(2027 年 8 月 31 日)を過ぎるとコードを更新できません。いまのうちに provided.al2023 で作り直しておくのが安全です。AWS の説明では、移行に必要なのはビルド方法とランタイムの指定の変更だけで、コードの変更は要りません3 。
Google トレンド(日本・過去 90 日、2026-10-05 取得)の相対値で、「Go言語」は直近 4 週の平均が 2.0、その前の 8 週の平均が 0.3 でした。「AWS Lambda」は 2.3 と 1.9 です。どちらも値は小さいものの上向きです6 。
Google トレンドの検索の相対値(日本・過去 90 日) データを表で見る Google トレンドの検索の相対値(日本・過去 90 日) 語 前 8 週の平均 直近 4 週の平均 Go言語 0.3 2 AWS Lambda 1.9 2.3 Cloudflare Workers 0.8 2
出典: Google トレンド(日本・過去 90 日、2026-10-05 取得)の相対値。同じ回で取得した 3 語
ここからは手を動かします。前提は Go 1.26.2(macOS)です。AWS へのデプロイは最後の節で手順だけ示します。
mkdir todo-api && cd todo-api
go mod init example.com/todo-api
go get github.com/aws/aws-lambda-go@latest
mkdir -p cmd/api
ファイル構成は次のとおりです。ハンドラーを main パッケージから分けておくと、テストが書きやすくなります。
todo-api/
├── go.mod
├── handler.go # ルーティングと処理(テスト対象)
├── handler_test.go # テーブル駆動テスト
├── cmd/api/main.go # lambda.Start を呼ぶだけ
├── Makefile # sam build が呼ぶビルド手順
└── template.yaml # AWS SAM のテンプレート
handler.go の中心部分です。保存先は Store インターフェースにして、テストではメモリ実装を使います。
type Handler struct {
Store Store
NewID func () string
}
func (h *Handler) Handle(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error ) {
switch req.RouteKey {
case "GET /health" :
return jsonResponse(http.StatusOK, map [string ]string {"status" : "ok" })
case "GET /todos" :
todos, err := h.Store.List(ctx)
if err != nil {
return errorResponse(http.StatusInternalServerError, "failed to list todos" )
}
return jsonResponse(http.StatusOK, todos)
case "GET /todos/{id}" :
t, err := h.Store.Get(ctx, req.PathParameters["id" ])
if errors.Is(err, ErrNotFound) {
return errorResponse(http.StatusNotFound, "todo not found" )
}
if err != nil {
return errorResponse(http.StatusInternalServerError, "failed to get todo" )
}
return jsonResponse(http.StatusOK, t)
case "POST /todos" :
body, err := requestBody(req)
if err != nil {
return errorResponse(http.StatusBadRequest, "invalid body" )
}
var in struct {
Title string `json:"title"`
}
if err := json.Unmarshal(body, &in); err != nil || strings.TrimSpace(in.Title) == "" {
return errorResponse(http.StatusBadRequest, "title is required" )
}
t := Todo{ID: h.NewID(), Title: strings.TrimSpace(in.Title)}
if err := h.Store.Put(ctx, t); err != nil {
return errorResponse(http.StatusInternalServerError, "failed to save todo" )
}
return jsonResponse(http.StatusCreated, t)
default :
return errorResponse(http.StatusNotFound, "route not found" )
}
}
func requestBody (req events.APIGatewayV2HTTPRequest) ([]byte , error ) {
if req.IsBase64Encoded {
return base64.StdEncoding.DecodeString(req.Body)
}
return []byte (req.Body), nil
}
func jsonResponse (status int , v any) (events.APIGatewayV2HTTPResponse, error ) {
b, err := json.Marshal(v)
if err != nil {
return events.APIGatewayV2HTTPResponse{}, err
}
return events.APIGatewayV2HTTPResponse{
StatusCode: status,
Headers: map [string ]string {"Content-Type" : "application/json" },
Body: string (b),
}, nil
}
func errorResponse (status int , msg string ) (events.APIGatewayV2HTTPResponse, error ) {
return jsonResponse(status, map [string ]string {"error" : msg})
}
ポイントは 3 つです。
RawPath ではなく RouteKey で分ける : パス変数の切り出しは API Gateway が済ませ、PathParameters["id"] に入れてくれます4
本文は IsBase64Encoded を見る : HTTP API はバイナリと判断した本文を Base64 にして渡すことがあります4
エラーも JSON で返す : Content-Type をそろえておくと、クライアント側の処理が単純になります
Store と、そのメモリ実装 MemoryStore(sync.RWMutex で守った map)は記事では省略します。本番では DynamoDB などの実装に差し替えます。
package main
import (
"crypto/rand"
"encoding/hex"
"github.com/aws/aws-lambda-go/lambda"
todoapi "example.com/todo-api"
)
func newID () string {
b := make ([]byte , 8 )
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func main () {
h := &todoapi.Handler{Store: todoapi.NewMemoryStore(), NewID: newID}
lambda.Start(h.Handle)
}
DB クライアントや設定の読み込みは main で 1 回だけ行います。ハンドラーの中で毎回作ると、呼び出しのたびに初期化の時間がかかります。
ハンドラーはただの関数なので、イベントの構造体を作って呼ぶだけでテストできます。AWS のアカウントも Docker も要りません。
func newTestHandler () *Handler {
return &Handler{Store: NewMemoryStore(), NewID: func () string { return "t1" }}
}
func TestHandle (t *testing.T) {
tests := []struct {
name string
req events.APIGatewayV2HTTPRequest
wantStatus int
wantBody string
}{
{
name: "ヘルスチェック" ,
req: events.APIGatewayV2HTTPRequest{RouteKey: "GET /health" },
wantStatus: http.StatusOK,
wantBody: `{"status":"ok"}` ,
},
{
name: "作成" ,
req: events.APIGatewayV2HTTPRequest{RouteKey: "POST /todos" , Body: `{"title":"牛乳を買う"}` },
wantStatus: http.StatusCreated,
wantBody: `{"id":"t1","title":"牛乳を買う","done":false}` ,
},
{
name: "Base64 の本文も受け付ける" ,
req: events.APIGatewayV2HTTPRequest{
RouteKey: "POST /todos" ,
Body: "eyJ0aXRsZSI6IuODoeODouOCkuabuOOBjyJ9" ,
IsBase64Encoded: true ,
},
wantStatus: http.StatusCreated,
wantBody: `{"id":"t1","title":"メモを書く","done":false}` ,
},
{
name: "タイトルが空なら 400" ,
req: events.APIGatewayV2HTTPRequest{RouteKey: "POST /todos" , Body: `{"title":" "}` },
wantStatus: http.StatusBadRequest,
wantBody: `{"error":"title is required"}` ,
},
{
name: "存在しない ID なら 404" ,
req: events.APIGatewayV2HTTPRequest{
RouteKey: "GET /todos/{id}" ,
PathParameters: map [string ]string {"id" : "nope" },
},
wantStatus: http.StatusNotFound,
wantBody: `{"error":"todo not found"}` ,
},
}
for _, tt := range tests {
t.Run(tt.name, func (t *testing.T) {
res, err := newTestHandler().Handle(context.Background(), tt.req)
if err != nil {
t.Fatalf("unexpected error: %v" , err)
}
if res.StatusCode != tt.wantStatus {
t.Errorf("status = %d, want %d" , res.StatusCode, tt.wantStatus)
}
if res.Body != tt.wantBody {
t.Errorf("body = %s, want %s" , res.Body, tt.wantBody)
}
})
}
}
構造体を直接作るテストだけでは、JSON のフィールド名の食い違いに気づけません。そこで、実際のイベント JSON を lambda.NewHandler に流すテストも 1 本足します。Lambda が渡すのと同じ JSON のバイト列から、応答の JSON までを通しで確かめられます。
func TestInvokeWithEventJSON (t *testing.T) {
payload, err := os.ReadFile("testdata/get-health.json" )
if err != nil {
t.Fatal(err)
}
h := lambda.NewHandler(newTestHandler().Handle)
out, err := h.Invoke(context.Background(), payload)
if err != nil {
t.Fatal(err)
}
var res struct {
StatusCode int `json:"statusCode"`
Body string `json:"body"`
}
if err := json.Unmarshal(out, &res); err != nil {
t.Fatal(err)
}
if res.StatusCode != 200 || res.Body != `{"status":"ok"}` {
t.Errorf("got %d %s" , res.StatusCode, res.Body)
}
}
testdata/ get-health.json は、API Gateway のドキュメントにある形式 2.0 の例4 をもとに routeKey と rawPath を書き換えたものです。
go test と go vet を実行してすべてのテストが通り、続けて arm64 向けの bootstrap をビルドして ELF の ARM aarch64 バイナリができたことを確かめる様子 筆者の環境(macOS・Go 1.26.2)で実行した出力
AWS の公式手順どおり、Linux・arm64 向けにクロスコンパイルします1 。
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -tags lambda.norpc -o bootstrap ./cmd/api
zip function.zip bootstrap
GOARCH=arm64: Graviton(Arm)で動かす。x86_64 なら amd64
CGO_ENABLED=0: C ライブラリに依存しない静的バイナリにする。公式手順でも任意の指定として載っています1
-tags lambda.norpc: 旧 go1.x ランタイムでしか使わない RPC の部分を外し、パッケージを小さくする1
手元で lambda.norpc の有無を比べると、バイナリの大きさは次のとおりでした。
ビルド bootstrap の大きさ -tags lambda.norpc あり8,461,524 バイト(約 8.1 MiB) なし 11,149,229 バイト(約 10.6 MiB)
(筆者の環境で、この記事のコードを GOOS=linux GOARCH=arm64 CGO_ENABLED=0 でビルドした値。依存やバージョンで変わります)
デプロイは AWS SAM(Serverless Application Model)でまとめるのが手軽です。関数と HTTP API を 1 つのテンプレートに書けます。
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Go の TODO API(provided.al2023 / arm64 / HTTP API)
Globals:
Function:
Timeout: 10
MemorySize: 128
Resources:
TodoFunction:
Type: AWS::Serverless::Function
Metadata:
BuildMethod: makefile
Properties:
CodeUri: .
Handler: bootstrap
Runtime: provided.al2023
Architectures:
- arm64
Events:
Health:
Type: HttpApi
Properties:
Path: /health
Method: GET
ListTodos:
Type: HttpApi
Properties:
Path: /todos
Method: GET
GetTodo:
Type: HttpApi
Properties:
Path: /todos/{id}
Method: GET
CreateTodo:
Type: HttpApi
Properties:
Path: /todos
Method: POST
Outputs:
ApiUrl:
Description: HTTP API のエンドポイント
Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/"
HttpApi イベントで ApiId を書かないと、SAM が ServerlessHttpApi という HTTP API を自動で作ります。ペイロード形式の既定値は 2.0 です7 。
BuildMethod: makefile にすると、sam build は build-{論理 ID} というターゲットを呼びます。成果物は $(ARTIFACTS_DIR) に置きます7 。
build-TodoFunction:
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -tags lambda.norpc -o $(ARTIFACTS_DIR) /bootstrap ./cmd/api
AWS の Go のドキュメントには BuildMethod: go1.x を使う例もあります。こちらは CodeUri に main.go があるフォルダを指定する形です1 。この記事は cmd/ api に main を置き、ビルドフラグも自分で決めたいので makefile を選びました。
sam build
sam deploy --guided
sam deploy は AWS アカウントに実際のリソースを作り、料金が発生します。この記事では、AWS SAM CLI 1.166.2 で sam validate --lint と sam build を実行し、.aws-sam/ build/ TodoFunction/ bootstrap(arm64)ができる「Build Succeeded」までを確かめました。実際のデプロイはしていません。
デプロイ後は、出力された ApiUrl に curl で確かめます。
curl -s "$API_URL /health"
curl -s -X POST "$API_URL /todos" -H 'content-type: application/json' -d '{"title":"牛乳を買う"}'
ここまでの形が決まっていれば、Claude Code(Anthropic のターミナル向け AI コーディングエージェント)に任せられる部分は大きいです。コツは「約束」を先に渡し、テストで終わりを判定させることです。
リポジトリに CLAUDE.md を置き、守ってほしい約束を書く
作るものを 1 回の指示で伝え、テストが通るまで続けるよう頼む
差分を読み、go test と go vet を自分でも流す
CLAUDE.md の例です。
# todo-api
- AWS Lambda(provided.al2023 / arm64)で動く Go の API。API Gateway HTTP API(ペイロード形式 2.0)から呼ばれる
- ハンドラーは events.APIGatewayV2HTTPRequest を受け取り、RouteKey で振り分ける
- ルートを足したら template.yaml の Events にも同じ Path と Method を足す
- 変更のたびに go vet ./... と go test ./... を実行し、通ってから終える
- ビルドは make build-TodoFunction ARTIFACTS_DIR=.out(-tags lambda.norpc を外さない)
- sam deploy や aws コマンドで AWS のリソースを変更しない
指示の例です。
DELETE /todos/{id} を追加して。Store インターフェースに Delete を足し、
MemoryStore を実装、handler_test.go のテーブルに成功・404 のケースを追加。
template.yaml の Events にもルートを足し、go vet と go test が通るまで直して。
「template.yaml にも足す」を書くのが大事です。HTTP API は、テンプレートに無いルートへのリクエストを Lambda に渡さず {"message":"Not Found"} を返します4 。コードだけ直しても、デプロイ後に届きません。テスト駆動の進め方は Claude Code で Go のテスト駆動開発 で、CDK で同じ構成を書かせる方法は AWS CDK で Lambda+API Gateway+DynamoDB を Claude Code に書かせる で詳しく扱っています。
新しく作るなら、arm64 と HTTP API を基本にします。理由は料金と、Go のバイナリならアーキテクチャを変えてもコードを変えなくてよいことです。
Lambda の実行時間の単価(米国東部・バージニア北部、最初の段階) データを表で見る Lambda の実行時間の単価(米国東部・バージニア北部、最初の段階) アーキテクチャ 100 万 GB-秒あたり(USD)(USD) x86_64 16.667 arm64 13.333
出典: AWS Price List API(AWSLambda、us-east-1、2026-10-01 公開版)の GB-秒単価 0.0000166667 USD と 0.0000133334 USD を 100 万倍した値
2026 年 10 月時点の米国東部(バージニア北部)では、実行時間の単価は arm64 が x86_64 より約 2 割安くなっています。リクエスト料金は 100 万件あたり 0.20 USD で、どちらも同じです8 。
API Gateway の料金は、HTTP API が 100 万リクエストあたり 1.00 USD、REST API が 3.50 USD です(米国東部、最初の段階)9 。
観点 HTTP API REST API Function URL 100 万リクエストあたりの料金 1.00 USD9 3.50 USD9 追加料金なし(Lambda の料金のみ)10 統合のタイムアウト上限 30 秒4 — 関数のタイムアウトまで ペイロードの上限 10 MB4 — — 向いている用途 ふつうの JSON API API キーや使用量プラン、変換が要る API 試作、Webhook
AWS は、本番向けで認証の選択肢やカスタムドメイン、スロットリングが要るなら API Gateway、試作や単純な用途なら Function URL を勧めています10 。この記事の TODO API のような JSON API なら HTTP API で足ります。
既存の net/ http のサーバーをそのまま載せたい場合は、イベント型を使わない方法もあります。Lambda Web Adapter で既存の Go(net/http)を Lambda に載せる で、この記事の方式との比較表つきで説明しています。
多いのは 2 つです。1 つは、実行ファイルの名前が bootstrap でないか、zip のルートに無いことです1 。もう 1 つは、Architectures と GOARCH が食い違っていることです。arm64 の関数に amd64 のバイナリを置くと起動しません。file bootstrap で ARM aarch64 と出るか確かめます。
ハンドラーの default が返す route not found なのか、API Gateway の {"message":"Not Found"} なのかで原因が変わります。後者は、テンプレートにルートが無くてリクエストが Lambda まで届いていません4 。
HTTP API の統合タイムアウトの上限は 30 秒で、引き上げられません4 。長い処理は SQS などに積んで非同期にするか、Function URL を使います。最大 90 分まで動かせる新しい実行形態は Lambda Managed Instances とは で扱っています。
この記事の MemoryStore は動作確認用です。実行環境は使い回されることもあれば、新しく作られることもあります。同時に複数の実行環境が動くこともあるので、本番では DynamoDB などの外部ストアを使います。
Go の Lambda は provided.al2023 に bootstrap を置く。go1.x と provided.al2 は廃止済み(2026 年 10 月時点)
HTTP API のイベントは events.APIGatewayV2HTTPRequest で受け、RouteKey で振り分けるとパス解析が要らない
ビルドは GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -tags lambda.norpc -o bootstrap
ハンドラーはふつうの関数なので、構造体のテーブル駆動テストとイベント JSON のテストで手元で確かめられる
Claude Code には CLAUDE.md で約束(ルートの追加先、テストのコマンド、デプロイ禁止)を渡す
次にやることは、Store を DynamoDB の実装に差し替えて、sam deploy --guided で自分のアカウントに出してみることです。
呼び出しは続けられますが、セキュリティパッチは当たらず、サポートの対象外です。関数の更新は 2027 年 8 月 31 日からブロックされる予定です(2026 年 10 月時点)2 。コードの変更は要らず、bootstrap でビルドし直して provided.al2023 に切り替えるだけです3 。
C ライブラリに依存しない Go のコードなら arm64 を選びます。GOARCH を変えてビルドし直すだけで、実行時間の単価は約 2 割安くなります(米国東部、2026 年 10 月時点)8 。cgo で x86 専用のライブラリを使う場合だけ x86_64 にします。
必須ではありません。RPC の部分は旧 go1.x ランタイムでしか使わないので、provided.al2023 なら外して問題ありません1 。筆者の環境では、バイナリが約 10.6 MiB から約 8.1 MiB に減りました。
AWS SAM CLI の sam local start-api を使うと、Docker の中で関数を動かして HTTP で呼べます。この記事では Docker を使わず、イベント JSON を lambda.NewHandler に流すテストで代わりに確かめました。テストなら CI でもそのまま回せます。
使えます。イベントを net/ http のリクエストに変換するライブラリを挟む方法と、Lambda Web Adapter でサーバーごと動かす方法があります。ルートが数本ならこの記事のように RouteKey の switch で十分です。ルーターを使う既存のサーバーを移すなら Lambda Web Adapter が手軽です。