1. 結論(この記事で得られること)
この記事を読むと、以下が手に入ります。
- AWS LambdaでAPIGateway経由のmultipart/form-dataを確実に処理する実装パターン
- busboy / multiparty など Node.js パーサーライブラリの使い分け基準
- バイナリデータが壊れる原因と、API Gateway の設定ポイント(これ、意外と見落とします)
- AI を使って問題切り分けを 10 分で終わらせるプロンプト例
- 実務で必要なテスト観点(メモリ・タイムアウト・ファイルサイズ上限)
正直、私も最初に Lambda で画像アップロード機能を実装したとき、「バイナリが壊れてるんだけど…?」って 3 時間ハマりました。API Gateway の 「binaryMediaTypes」 設定を知らなかったんです。この記事では、そういう地雷を全部まとめて回避します。
2. 前提(環境・読者層)
想定読者
- Lambda + API Gateway でファイルアップロード API を作る必要がある方
- multipart/form-data の仕様は知っているが、Lambda 特有の罠を避けたい方
- Python や他の言語経験者で Node.js の Lambda に初挑戦する方
検証環境
- Node.js 18.x / 20.x(Lambda ランタイム)
- API Gateway REST API または HTTP API
- SAM / Serverless Framework / CDK いずれでも OK(記事では SAM のテンプレート例を提示)
3. Before:よくあるつまずきポイント
3-1. 「「event.body」 がただの文字列で、どうパースすれば…」
Lambda の event オブジェクトには 「event.body」 という文字列(または Base64 エンコード済み文字列)が入ってきます。通常の JSON なら 「JSON.parse()」 で終わりですが、multipart/form-data は boundary で区切られた複雑な構造。手でパースするのは現実的じゃない。
// ❌ こんな感じで来る
{
"body": "------WebKitFormBoundary...",
"isBase64Encoded": false,
"headers": {
"content-type": "multipart/form-data; boundary=----WebKitFormBoundary..."
}
}
3-2. 「画像ファイルが壊れる(バイナリ化けする)」
API Gateway がデフォルトでは バイナリを想定していない ため、「isBase64Encoded: false」 で来たバイナリが UTF-8 文字列として扱われて壊れます。
- 画像ファイルをアップロードしても、S3 に保存した画像が開けない
- 「Content-Length」 がずれる
- 文字コード変換で一部バイトが失われる
3-3. 「ローカルでは動くのに Lambda だとエラー」
Express や Koa のミドルウェア(multer など)は、ストリームやファイルシステムへの一時保存を前提にしています。Lambda の 「/tmp」 は容量制限があり、大きなファイルだと溢れます。
4. After:基本的な解決パターン
4-1. API Gateway の設定(必須)
REST API の場合
SAM テンプレートで 「BinaryMediaTypes」 を設定:
Resources:
MyApi:
Type: AWS::Serverless::Api
Properties:
StageName: prod
BinaryMediaTypes:
- "multipart/form-data"
- "image/*"
- "application/octet-stream"
これで、該当する Content-Type のリクエストは Base64 エンコードされて 「event.body」 に入り、「isBase64Encoded: true」 になります。
HTTP API の場合
HTTP API はデフォルトでバイナリを扱えますが、ペイロードフォーマットを 「2.0」 にしておくと楽です。
4-2. Node.js でのパース(busboy を使う最小実装)
const Busboy = require('busboy');
exports.handler = async (event) => {
// Base64 デコード
const body = event.isBase64Encoded
? Buffer.from(event.body, 'base64')
: Buffer.from(event.body);
const busboy = Busboy({
headers: {
'content-type': event.headers['content-type'] || event.headers['Content-Type']
}
});
const fields = {};
const files = [];
return new Promise((resolve, reject) => {
busboy.on('file', (fieldname, file, info) => {
const { filename, encoding, mimeType } = info;
const chunks = [];
file.on('data', (data) => chunks.push(data));
file.on('end', () => {
files.push({
fieldname,
filename,
mimeType,
buffer: Buffer.concat(chunks)
});
});
});
busboy.on('field', (fieldname, value) => {
fields[fieldname] = value;
});
busboy.on('finish', () => {
resolve({
statusCode: 200,
body: JSON.stringify({
fields,
files: files.map(f => ({
name: f.filename,
size: f.buffer.length,
type: f.mimeType
}))
})
});
});
busboy.on('error', reject);
busboy.write(body);
busboy.end();
});
};
ポイント
- 「busboy.write()」 で Buffer を流し込む(ストリームとして扱える)
- ファイル本体は 「chunks」 配列に溜めて、最後に 「Buffer.concat()」 で結合
- 「/tmp」 には書かない → メモリで完結(小〜中サイズファイル向け)