AWS Lambda実装の勘所:コールドスタートとIAMで詰まらないNode.js設計
Lambda(Node.js/TypeScript)で詰まる定番ポイントを実装目線で解説。ハンドラの書き方、コールドスタート対策、Layers、IAM最小権限、CloudWatchログ、SAMデプロイまでコピペで動くコード付き。
初めて本番に出したLambdaが、デモでは一瞬で返るのに、お客さんが触った最初の1回だけ3秒近くかかる。「壊れてる」と連絡が来て冷や汗をかきました。コードは正しい。遅いのは初回だけ。これがコールドスタートか、と身をもって知った瞬間です。
Lambdaは「関数を1個書いてアップロードするだけ」に見えて、実際に詰まるのはコードの外側です。初回が遅い、権限が足りなくて落ちる、ログがどこにも出ない、デプロイZIPが肥大化する。僕が順番に踏んだ地雷を、Node.js/TypeScript前提で、踏まずに済む形に整理しました。
この記事の要点
- Lambdaの遅さの正体は「初回だけ走る初期化」。クライアント生成は**ハンドラの外(モジュールスコープ)**に出すのが第一歩。
- IAMは
Resource: "*"をやめて具体的なARNにする。SAMのDynamoDBReadPolicyのようなポリシーテンプレートを使うと最小権限を踏み外しにくい。 - 共通ライブラリはLambda Layersに逃がすと、デプロイZIPが軽くなり関数本体の更新も速い。
- 秘密情報は環境変数に直書きせず、Secrets ManagerのARN参照で渡す。
- ログが出ないトラブルの大半は、実行ロールに
logs:CreateLogStream/logs:PutLogEventsが無いだけ。CloudWatch Logsの権限を最初に確認する。 - デプロイはSAMが最短。
sam build→sam local invoke→sam deployの3コマンドで回る。
まずハンドラの形を正しく書く
Node.jsのLambdaハンドラは、event(誰が・何を渡してきたか)を受け取って、結果を返す関数です。トリガーごとにeventの形が違うので、型を当てておくと事故が激減します。@types/aws-lambdaの型を使うのが鉄板です。
API Gatewayの裏に置く、ユーザー取得APIを例にします。Claude Codeに頼むなら、こう指示します。
claude -p "
/users/{userId} に GET が来たら DynamoDB の Users テーブルから
ユーザーを取得して返す Lambda を TypeScript で。
- ランタイム: Node.js 24
- テーブル名は環境変数 USERS_TABLE で受け取る
- 404 と 500 を適切に返す
- 型は @types/aws-lambda
- src/functions/getUser/index.ts に出力
"
出てくるのはこんなコードです。ここで一番大事なのは、DynamoDBクライアントをハンドラの外で作っていること。理由は次の章で説明します。
// src/functions/getUser/index.ts
import { APIGatewayProxyHandler, APIGatewayProxyResult } from "aws-lambda";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
// モジュールスコープ=コンテナ再利用時に作り直さない(コールドスタート対策の肝)
const ddb = DynamoDBDocumentClient.from(
new DynamoDBClient({ region: process.env.AWS_REGION })
);
const response = (statusCode: number, body: unknown): APIGatewayProxyResult => ({
statusCode,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
export const handler: APIGatewayProxyHandler = async (event) => {
const userId = event.pathParameters?.userId;
if (!userId) return response(400, { error: "userId is required" });
try {
const result = await ddb.send(
new GetCommand({ TableName: process.env.USERS_TABLE!, Key: { userId } })
);
if (!result.Item) return response(404, { error: `User '${userId}' not found` });
return response(200, result.Item);
} catch (err) {
console.error("DynamoDB error:", err); // CloudWatch Logs に残る
return response(500, { error: "Internal server error" });
}
};
ハンドラの中でevent.pathParameters?.userIdを読み、無ければ400、見つからなければ404、例外は500。入口でバリデーション、出口でステータスを正しく。この骨格はどのトリガーでも変わりません。API Gateway側のCORSや認証、レート制限の設計はAPI Gateway実装の記事に分けて書いたので、HTTP APIを組むときはそちらも見てください。DynamoDB側の設計でつまずいたら、DynamoDBの設計記事が役立ちます。
コールドスタートで詰まらない初期化の置き方
冒頭の「初回だけ3秒」の犯人は、ほぼこれです。
Lambdaは呼ばれると実行環境(コンテナ)を1個立ち上げます。立ち上げには時間がかかる。これがコールドスタート。一度立ち上がったコンテナはしばらく使い回されるので、2回目以降は速い。つまり遅いのは「初回」と「久しぶりの呼び出し」だけです。
ここで効くのが、初期化コードをどこに置くか。
// ❌ ハンドラの中で毎回クライアントを作る → 呼ばれるたびに初期化コストを払う
export const handler = async () => {
const ddb = new DynamoDBClient({});
// ...
};
// ✅ モジュールスコープで1度だけ → コンテナ再利用中は使い回す
const ddb = new DynamoDBClient({});
export const handler = async () => {
// ddb をそのまま使う
};
クライアント生成、設定の読み込み、DB接続の準備。ハンドラの外に出せるものは全部外。これだけで2回目以降の体感が変わります。
それでも初回の遅さが業務的に許せない場面はあります。決済画面のように「最初のお客さんを待たせたくない」ケースです。手はいくつかあります。
| 対策 | 効くケース | 注意点 |
|---|---|---|
| 依存を減らす | 全般 | 一番効く。使わないSDKを消すだけで初回が縮む |
| Provisioned Concurrency | 常に温めたい | 待機分の課金が発生する |
| RDS Proxy | VPC内でRDSに繋ぐ | VPC Lambdaは初回が延びがち。接続プールを肩代わりさせる |
| Layersで本体を軽く | パッケージが重い | 後述。本体ZIPが小さいと展開が速い |
僕の実感だと、まず依存を削るのが一番コスパがいいです。AWS SDK v3はモジュール単位でインポートできるので、@aws-sdk/client-dynamodbのように必要なものだけ入れる。aws-sdk(v2)を丸ごと持ってくるのはやめましょう。
Lambda Layersで共通コードを切り出す
関数が増えてくると、同じライブラリを全関数のZIPに詰めることになります。これが地味に効いてきて、デプロイは遅いし、node_modulesごと固めると250MBの上限にぶつかります。
そこでLayers。共通の依存やユーティリティを別パッケージにして、複数の関数から共有する仕組みです。本体のZIPが軽くなるので、ロジックだけ直したいときのデプロイが速くなります。
SAMならこう書きます。nodejs/配下にpackage.jsonを置くのがNode.js Layerの作法です。
# template.yaml(Layer 定義)
Resources:
CommonDepsLayer:
Type: AWS::Serverless::LayerVersion
Properties:
LayerName: common-deps
ContentUri: layers/common-deps/ # この下に nodejs/package.json を置く
CompatibleRuntimes:
- nodejs24.x
Metadata:
BuildMethod: nodejs24.x # sam build に Layer のビルドを任せる
GetUserFunction:
Type: AWS::Serverless::Function
Properties:
Handler: src/functions/getUser/index.handler
Runtime: nodejs24.x
Layers:
- !Ref CommonDepsLayer # 関数から Layer を参照
ディレクトリはlayers/common-deps/nodejs/package.jsonという形にして、そこに共有したい依存を書きます。sam buildがLayerごとビルドしてくれます。何でもかんでもLayerに入れると逆に管理が面倒になるので、本当に複数関数で共有するものだけに絞るのがコツです。
環境変数とSecrets Manager、IAM最小権限
設定値と秘密情報は分けて扱います。
USERS_TABLEやAPP_STAGEのような「見られても困らない設定」は環境変数でOK。一方、DBパスワード、外部APIキー、Webhookの署名シークレットは環境変数に直書き厳禁です。テンプレートやコンソールに平文で残るので、Secrets Managerに置いてARN参照で渡します。
# ❌ 平文で直書き → テンプレートに秘密が残る
Environment:
Variables:
DB_PASSWORD: "p@ssw0rd"
# ✅ Secrets Manager から解決して渡す
Environment:
Variables:
DB_PASSWORD: !Sub "{{resolve:secretsmanager:${AWS::StackName}/db-password}}"
IAMは「最小権限」が原則です。やりがちな失敗はResource: "*"やs3:*。動くけれど、その関数が乗っ取られたら全バケットが触れてしまう。Actionは絞る、Resourceは具体的なARNにします。
SAMにはよくあるパターンのポリシーテンプレートが用意されていて、これを使うと自分でJSONを書くより踏み外しにくいです。
GetUserFunction:
Type: AWS::Serverless::Function
Properties:
Handler: src/functions/getUser/index.handler
Runtime: nodejs24.x
Environment:
Variables:
USERS_TABLE: !Ref UsersTable
Policies:
- DynamoDBReadPolicy: # 読み取りだけを、このテーブルにだけ許可
TableName: !Ref UsersTable
手書きでJSONを作る場合は、ARNまで具体的に指定します。Claude Codeに生成させるとつい*を出してくるので、出てきたポリシーは必ず目視レビューしてください。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["dynamodb:GetItem"],
"Resource": "arn:aws:dynamodb:ap-northeast-1:123456789012:table/UsersTable"
},
{
"Effect": "Allow",
"Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:ap-northeast-1:123456789012:log-group:/aws/lambda/*"
}
]
}
IAMをもっと突き詰めたい人は、IAM最小権限の作り方にAccess Analyzerを使った検証手順までまとめてあります。
CloudWatch Logsでログが出ないを潰す
「ログが見当たらない」で半日溶かしたことがあります。原因はコードではなく、実行ロールにログを書く権限が無かっただけでした。
console.logやconsole.errorの出力は、自動でCloudWatch Logsの/aws/lambda/関数名というロググループに流れます。流れない時のチェック順はこうです。
- 実行ロールに
logs:CreateLogStreamとlogs:PutLogEventsがあるか(さっきのIAMの2つ目のステートメント) - 見ているリージョンは合っているか(東京で動かしてバージニアのログを見ていないか)
- ロググループ名の関数名は合っているか
- そもそもハンドラに到達しているか(権限不足で起動前に落ちていないか)
構造化ログにしておくと、後から検索しやすくなります。JSONで吐くだけでCloudWatch Logs Insightsでフィールド検索できます。
function log(level: "info" | "error", message: string, extra: Record<string, unknown> = {}) {
console.log(JSON.stringify({ level, message, time: new Date().toISOString(), ...extra }));
}
// 使うとき
log("info", "user fetched", { userId: "u-1" });
ログから先のメトリクスやアラームまで運用に乗せる話は、CloudWatch運用の記事に分けて書きました。
コピペで動く:ハンドラの単体テスト
デプロイしてからしか確認できない、はつらいです。ハンドラはただの関数なので、SDKをモックすればローカルでnpm testを通せます。Node.js 24 + Vitestでそのまま動く形にしました。
// test/getUser.test.ts
import { describe, expect, it, vi } from "vitest";
import type { APIGatewayProxyEvent } from "aws-lambda";
// DynamoDB の send をモックに差し替える
const mockSend = vi.fn();
vi.mock("@aws-sdk/lib-dynamodb", async () => {
const actual = await vi.importActual<typeof import("@aws-sdk/lib-dynamodb")>(
"@aws-sdk/lib-dynamodb"
);
return { ...actual, DynamoDBDocumentClient: { from: () => ({ send: mockSend }) } };
});
const { handler } = await import("../src/functions/getUser/index");
// 最小限の API Gateway イベントを組み立てるヘルパー
function event(userId?: string): APIGatewayProxyEvent {
return {
pathParameters: userId ? { userId } : null,
httpMethod: "GET",
path: userId ? "/users/" + userId : "/users",
headers: {},
multiValueHeaders: {},
queryStringParameters: null,
multiValueQueryStringParameters: null,
body: null,
isBase64Encoded: false,
requestContext: {} as APIGatewayProxyEvent["requestContext"],
resource: "/users/{userId}",
stageVariables: null,
};
}
describe("getUser Lambda", () => {
it("ユーザーが存在すれば 200 を返す", async () => {
process.env.USERS_TABLE = "UsersTable";
mockSend.mockResolvedValueOnce({ Item: { userId: "u-1", name: "Masa" } });
const res = await handler(event("u-1"), {} as never, vi.fn());
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).userId).toBe("u-1");
});
it("userId が無ければ 400 を返す", async () => {
const res = await handler(event(), {} as never, vi.fn());
expect(res.statusCode).toBe(400);
});
});
vi.fn()でDynamoDBの応答を差し替えているので、AWSに繋がずにハンドラの分岐を全部試せます。200と400の両方を押さえておけば、リファクタしても壊れたらすぐ気づけます。
SAMでビルド・ローカル実行・デプロイ
デプロイの最短ルートはSAMです。3コマンドで回ります。
# 1. ビルド(Layer も TypeScript もまとめて)
sam build
# 2. 本物のイベントを使ってローカルで叩く
sam local invoke GetUserFunction --event events/get-user.json
# 3. デプロイ(環境ごとに設定を分ける)
sam deploy --config-env dev
公開前にsam validate --lintでテンプレートの構文と作法をチェックしておくと、デプロイ中の失敗が減ります。Claude Codeに一連を任せるなら、こう頼みます。
claude -p "
sam validate --lint を実行し、通れば sam build。
events/get-user.json で sam local invoke GetUserFunction を実行して
statusCode が 200 か確認。問題なければ sam deploy --config-env dev。
失敗したら、どのステップでなぜ落ちたかを最後の数行のログ付きで報告して。
"
SAMより細かくインフラを書きたくなったら、CDKに寄せる選択もあります。判断材料はCloudFormation/CDKの記事にまとめました。デプロイのIAMやロールバックで迷ったら、まずそちらを見てください。
Lambdaが向く場面・向かない場面
サーバーレスは万能ではありません。無理に全部Lambdaに寄せると、かえって複雑になります。
| 向いている | 向いていない |
|---|---|
| 短時間で終わる処理(API、Webhook受信) | 常駐プロセス、長時間の動画変換 |
| 入力と出力が明確 | 低遅延の常時接続(WebSocket常駐など) |
| 再実行しても安全(冪等な処理) | 大きなモデルをメモリに載せ続ける処理 |
| トラフィックの波が大きい | 一定の高負荷が続く(コスト的にECSが有利な場合) |
判断に迷ったら、Claude Codeに「Lambdaにする理由」と「Lambdaにしない理由」を両方書かせると、勢いだけのサーバーレス化を止められます。常駐寄りの処理なら、ECS/Fargateや他の選択肢と並べて比べてください。
僕がやらかしたLambdaの失敗
正直に書きます。最初の数本は地雷の見本市でした。
ひとつ目は、タイムアウトをデフォルトの3秒のままにしていたこと。DynamoDBに外部APIを2本叩く処理が、本番でだけTask timed out。ローカルは速いから気づけませんでした。今は処理内容を見て10〜30秒に設定しています。
ふたつ目は、冪等性を考えていなかったこと。S3トリガーやEventBridge、非同期呼び出しは再試行される前提です。請求メールを送るLambdaが、再試行で同じ人に2通送ってしまった。イベントIDや注文IDを冪等性キーにして、「同じイベントなら2回目はスキップ」を入れてからは止まりました。
みっつ目は、S3トリガーの無限ループ。アップロードされた画像のサムネを同じバケットに書き戻したら、その書き込みがまたトリガーを引いて延々と回りました。thumbnails/プレフィックスを除外する1行で解決。コストの請求が来る前に気づけて運が良かったです。
// 自分が作ったファイルでまた発火しないように、先頭で弾く
if (key.startsWith("thumbnails/")) return;
よくある質問
Q. コールドスタートはどのくらい遅いですか? A. 関数の依存の大きさ次第です。軽い関数なら数百ミリ秒、VPC内で重い依存だと数秒に達することもあります。まず依存を減らし、それでも足りなければProvisioned Concurrencyを検討します。
Q. Node.jsのバージョンはどれを選べばいいですか?
A. サポート中の最新のLTS系ランタイム(執筆時点でnodejs24.xなど)を選ぶのが無難です。古いランタイムは順次終了するので、Lambda runtimesの公式ページで現行サポートを確認してください。
Q. IAMでResource: "*"はなぜ避けるのですか?
A. その関数が乗っ取られた場合の被害範囲が一気に広がるからです。特定テーブルへの読み取りしか要らないなら、そのARNだけに絞ります。SAMのポリシーテンプレートを使うと自然に最小権限になります。
Q. ログが本当に1行も出ません。何を見ればいいですか?
A. 実行ロールにlogs:CreateLogStreamとlogs:PutLogEventsがあるか、見ているリージョンと関数名が合っているかの順で確認します。権限不足だと起動前に落ちてログが残りません。
Q. デプロイZIPが250MBの上限に当たりました。
A. node_modulesを丸ごと固めているのが原因のことが多いです。使っていない依存を消し、共通ライブラリはLayersに分離します。AWS SDK v3は必要なクライアントだけインポートしてください。
実際に試した結果
この記事のハンドラとテストは、ローカルでnpm testが通る形に分けて確認しました。実AWSへのsam deployはアカウント・リージョン・Secret ARN・DynamoDBテーブルに依存するので、ここでは流していません。
実務で公開する前に僕がやるのは、sam validate --lint → sam build → sam local invokeでステータスを目視 → CloudWatch Logsに想定どおり出ているか確認 → IAMの差分レビュー、までを検証メモに残すことです。一番効いた改善は、結局「クライアント生成をハンドラの外に出す」と「タイムアウトを処理に合わせる」の2つでした。派手な最適化より、この地味な2点で初回の遅さとタイムアウト落ちがほぼ消えます。
Lambda、IAM、SAM、ログ監査までチームで本番運用に乗せたい場合は、権限設計やデプロイ承認の型を研修・相談でまとめて設計しています。まずは型を手元で試したい人は教材一覧もどうぞ。
参考リンク(AWS公式)
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。