1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

1
Last updated at Posted at 2026-06-10

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インメモリ検索でした。


アーキテクチャ

シンプルです。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回まで無料です。よければ試してみてください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?