Lambda + S3インメモリ検索で郵便番号APIを作った話【OpenSearch不要・月額ほぼ0円】

TL;DR

  • 日本郵便の公式CSVデータ(約12万件)をS3に置き、Lambda起動時にメモリに展開
  • インメモリで中間一致検索を実現(OpenSearch不要)
  • コールドスタート約1〜2秒、ウォームスタート数ms
  • 月額コストはS3のみ(ほぼ0円)

背景

AIエージェント向けのユーティリティMCPサーバー「Thousand API」を個人開発しています。

https://www.thousand-api.com/ja

郵便番号↔住所変換を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+LambdaOpenSearch 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エージェントから「東京都調布市布田の郵便番号は?」と聞くと確実に返してくれます。

https://www.thousand-api.com/ja/docs/network

Freeプランで月1,000回まで無料です。よければ試してみてください。

Thousand API

Posted by 千原 耕司