Lambda + S3インメモリ検索で郵便番号APIを作った話【OpenSearch不要・月額ほぼ0円】
TL;DR
- 日本郵便の公式CSVデータ(約12万件)をS3に置き、Lambda起動時にメモリに展開
- インメモリで中間一致検索を実現(OpenSearch不要)
- コールドスタート約1〜2秒、ウォームスタート数ms
- 月額コストはS3のみ(ほぼ0円)
背景
AIエージェント向けのユーティリティMCPサーバー「Thousand API」を個人開発しています。
郵便番号↔住所変換をAPIとして提供しようとしたとき、最初に思い浮かんだのはOpenSearch Serverlessでした。全文検索・部分一致検索が得意で、日本語にも強い。ただし最小構成でも 月額$700〜 かかります。個人開発のSaaSに載せるには現実的ではありません。
次に考えたのがDynamoDBです。郵便番号→住所の正引きはPK直引きで高速にできます。ただし「布田」「調布」のような中間一致の逆引きはDynamoDBが苦手です。begins_with の前方一致止まりです。
最終的に選んだのがS3 + Lambdaインメモリ検索でした。
アーキテクチャ
flowchart TD A["日本郵便CSV (Shift-JIS)"] -- scripts/postal/transform.ts(変換) --> B["postal_jp.json(約30MB)"] -- scripts/postal/upload.ts(アップロード) --> C["S3バケット(postal/JP/postal_data.json)"] -- Lambda起動時にロード(1回のみ) --> D["Lambdaメモリ(512MB確保)"] -- インメモリフィルタリング --> APIレスポンス
シンプルです。S3からJSONを読み込んでメモリに展開し、あとはJavaScriptの Array.filter で検索するだけです。
データ変換
日本郵便の公式CSVはShift-JISエンコーディングです。iconv-lite で変換します。
// scripts/postal/transform.ts(抜粋)
import * as fs from "fs";
import iconv from "iconv-lite";
import { parse } from "csv-parse/sync";
const raw = fs.readFileSync("KEN_ALL.CSV");
const decoded = iconv.decode(raw, "Shift_JIS");
const records = parse(decoded, { skip_empty_lines: true });
const postal: PostalRecord[] = records
.filter((row: string[]) => row[13] !== "2") // 廃止レコードを除外
.map((row: string[]) => ({
code: row[2],
code_formatted: `${row[2].slice(0, 3)}-${row[2].slice(3)}`,
prefecture: row[6],
prefecture_kana: row[3],
city: row[7],
city_kana: row[4],
town: row[8] === "以下に掲載がない場合" ? "" : row[8],
town_kana: row[5] === "イカニケイサイガナイバアイ" ? "" : row[5],
full_address: row[6] + row[7] + (row[8] === "以下に掲載がない場合" ? "" : row[8]),
country: "JP" as const,
}));
変換後は約12万件・30MBのJSONファイルになります。
Lambdaでのデータロード
ポイントはモジュールレベルでのシングルトンキャッシュです。
// packages/api/src/lib/postal-loader.ts
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
let cachedData: PostalRecord[] | null = null;
let loadPromise: Promise<PostalRecord[]> | null = null;
export async function getPostalData(): Promise<PostalRecord[]> {
// キャッシュがあれば即返す
if (cachedData) return cachedData;
// ロード中なら同じPromiseを待つ(二重ロード防止)
if (loadPromise) return loadPromise;
loadPromise = (async () => {
const s3 = new S3Client({});
const command = new GetObjectCommand({
Bucket: process.env.POSTAL_DATA_BUCKET!,
Key: "postal/JP/postal_data.json",
});
const res = await s3.send(command);
const body = await res.Body!.transformToString("utf-8");
cachedData = JSON.parse(body);
return cachedData!;
})();
return loadPromise;
}
Lambdaコンテナが再利用される(ウォームスタート)場合、2回目以降は cachedData がすでに存在するため、S3へのアクセスが発生しません。
検索の実装
正引き(郵便番号→住所)はハイフンを除去して完全一致するだけです。
export function findByCode(data: PostalRecord[], code: string): PostalRecord[] {
const normalized = code.replace(/-/g, "");
return data.filter(r => r.code === normalized);
}
逆引き(住所キーワード→郵便番号)はループ内で中間一致検索します。
export function findByAddress(
data: PostalRecord[],
keyword: string,
limit: number
): PostalRecord[] {
const results: PostalRecord[] = [];
for (const record of data) {
if (results.length >= limit) break;
if (
record.full_address.includes(keyword) ||
record.prefecture_kana.includes(keyword) ||
record.city_kana.includes(keyword) ||
record.town_kana.includes(keyword)
) {
results.push(record);
}
}
return results;
}
Array.filter ではなく for ループにしているのは limit に達した時点で打ち切るためです。filter は全件走査してしまいます。
カナフィールドも検索対象に含めているのは「フダ」「チョウフシ」のようなカナ入力にも対応するためです。
CDKの設定
Lambda関数のメモリとタイムアウトを通常より大きく設定します。
// infra/lib/api-stack.ts(抜粋)
const postalFn = new nodejs.NodejsFunction(this, "PostalFn", {
memorySize: 512, // 30MBのJSONを展開するのに必要
timeout: Duration.seconds(30), // コールドスタート時のS3ロードを考慮
environment: {
POSTAL_DATA_BUCKET: postalDataBucket.bucketName,
},
});
postalDataBucket.grantRead(postalFn);
実際の計測結果
| 条件 | レイテンシ |
|---|---|
| コールドスタート(初回) | 1,200〜1,800ms |
| ウォームスタート(2回目以降) | 5〜15ms |
コールドスタートの内訳:
- Lambda起動: 約200ms
- S3からのダウンロード(30MB): 約800ms ← ボトルネック
- JSONパース・メモリ展開: 約300ms
コールドスタートのボトルネックはS3ダウンロードとJSONパースです。ここは limit では改善できません。将来的にProvisioned Concurrencyで解消する予定ですが、個人開発の初期段階では費用対効果を見て判断します。
ウォームスタートはメモリ上のデータに対して直接フィルタリングするだけなので5〜15msで返ります。12万件を for ループで走査しても高速です。また limit による早期打ち切りで、マッチ件数が多い場合でも不要な走査を省いています。
OpenSearchと比較すると
| 項目 | S3+Lambda | OpenSearch Serverless |
|---|---|---|
| 月額コスト | ほぼ0円(S3のみ) | $700〜 |
| 検索速度(ウォーム) | 5〜15ms | 数ms |
| 検索速度(コールド) | 1〜2秒 | 数ms |
| 中間一致検索 | ✅ 対応 | ✅ 対応 |
| 全文検索(形態素解析等) | ❌ 非対応 | ✅ 対応 |
| 運用コスト | 低(サーバーレス) | 中(クラスター管理) |
| データ更新 | S3にアップロードするだけ | インデックス再構築が必要 |
郵便番号検索のような「完全一致 + 部分一致」のシンプルな用途であれば、S3+Lambdaインメモリで十分です。
データ更新
日本郵便は毎月末に郵便番号データを更新します。現在は手動でスクリプトを実行する運用です。
# ダウンロード → 変換 → S3アップロードを一括実行
npm run postal:all
将来的にはEventBridge + Lambdaで月次自動更新を予定しています。
まとめ
- 12万件程度のデータなら S3+Lambdaインメモリで十分
- コールドスタート(1〜2秒)のボトルネックはS3ダウンロードとJSONパース。
limitが効くのはその後のインメモリ検索処理 - ウォームスタート時は
limitによる早期打ち切りで不要な走査を省き、5〜15msを実現 - OpenSearch不要でコストをほぼゼロに抑えられる
この郵便番号APIはThousand APIのMCPサーバーで lookup_postal_code ツールとして公開しています。AIエージェントから「東京都調布市布田の郵便番号は?」と聞くと確実に返してくれます。
Freeプランで月1,000回まで無料です。よければ試してみてください。




