3
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?

富士山登頂記念に、AWS Blocks という山も少しずつ登ってみる(第2弾:ご来光 ── 同じコードを AWS にデプロイし「本物の Amazon Location Service」で動かす)

3
Last updated at Posted at 2026-10-16

AWS Blocks は 2026年10月時点でパブリックプレビュー段階のサービスです。
本記事で使う CLI・API・自動生成されるコードや挙動は、今後のバージョンで変更される可能性があります。バージョン差により、記載どおりでは動かない箇所が出るかもしれません。本文中のコマンド出力・レスポンス・スクリーンショットは、執筆時点の実機(WSL2)+実 AWS で採取したものです(アカウントID・APIキー値は伏せ字、スクリーンショット内のユーザー名は置き換えています)。最新の仕様は公式ドキュメントをご確認ください。

第1弾では、AWS Blocks を ローカル完結・0円で動かし、「地名→標高」アプリを作りました。ただし、ローカルで動いたことと、そのまま AWS 上で動くことは、必ずしも同じではありません。

第2弾では、その境界を確かめます。ジオコーディングと地図表示を「ローカルは模擬・AWS は本物(Amazon Location Service)」で切り替えられるよう作り替え、実際に AWS へデプロイして動作を確認します。

日の出前

この記事でやること

具体的には、次の流れで進めます。

  1. なぜ「ローカルで動いた ≠ 全部が AWS で動く」なのかを整理し
  2. ジオコーディングと地図を DistributedTable 風に「ローカルは模擬・AWSは本物(Amazon Location Service)」で環境切替するよう自作し
  3. テンプレート由来の Todo 機能を外し、代わりに検索履歴を DistributedTable と Realtime で持たせ
  4. npm run sandbox で 実際に AWS へデプロイし
  5. デプロイ済みの API を叩いて実機で動作確認する

までをやります。今回は実 AWS アカウントを使い、実リソースを作ります(後片付けまで含めて解説します)。

検証環境

項目 値
検証時期 2026年10月
OS WSL2 (Ubuntu) / Linux 6.18 系
Node.js v24.14.1 / npm 11.13.0
AWS Blocks @aws-blocks/blocks 0.7.0
AWS CDK aws-cdk-lib 2.272.0 / aws-cdk 2.1144.0
AWS CLI v2.34 系
デプロイ先 検証用 AWS アカウント / ap-northeast-1(東京)
CDK Bootstrap 済み(Bootstrap version 32)

1. 「ローカルで動いた」の正体 ── Blocks が化けるもの/化けないもの

第1弾で強調した AWS Blocks の推しは「同じコードがローカルではエミュレータ、AWS では本物のマネージドリソースとして動く」でした。第2弾は、ここをもう少し正確に見るところから始めます。

Blocks が環境に応じて実体化する(=ローカルではエミュレートされる)のは、Blocks のプリミティブです。

コード上の宣言 ローカル AWS デプロイ後
AuthBasic ローカルファイル DynamoDB(ユーザー・確認コード)
DistributedTable ローカルファイル(.bb-data/ 配下の JSON) DynamoDB
Realtime プロセス内 pub/sub WebSocket (API Gateway) + 接続管理用の DynamoDB
ApiNamespace ローカル HTTP サーバ Lambda + API Gateway
Hosting Vite dev server S3 + CloudFront

AuthBasic は Cognito ではなく、ユーザー情報を DynamoDB に置く独自の認証です(セッションは JWT)。Cognito を使いたい場合は、別の Block の AuthCognito を選びます。また、ローカルの DistributedTable は純粋なインメモリではなく、プロジェクト直下の .bb-data/ に JSON として保存されていました。dev サーバを再起動してもデータは残ります。

一方で、第1弾の searchElevation の中で叩いていた Nominatim(ジオコーディング)と国土地理院API(標高)への fetch() は Block ではありません。ただの外部HTTP呼び出しなので、AWS にデプロイしても「Lambda から同じ外部URLを叩く」ままで、AWS のサービスには化けません。

つまり第1弾の地名検索・地図の部分には、AWS 側の実装がそもそも存在しなかったわけです。エミュレートされていたのは DynamoDB などの裏側だけでした。

Amazon Location は Block ではないので、Blocks の自動切替は効きません。そこで、DistributedTable(ローカルはファイル、AWS は DynamoDB)と同じように「ローカルは模擬・AWS は本物」となる切替を、地名検索と地図に自分で組み込むことにしました。

  • 地名検索:ローカル = Nominatim(模擬)/ AWS = Amazon Location Places(本物)
  • 地図:ローカル = OpenStreetMap タイル(模擬)/ AWS = Amazon Location Maps v2(本物)
  • 標高:Amazon Location に該当APIが無いので、両環境とも国土地理院のまま

2. Amazon Location Service v2 という選択

Amazon Location Service は地図・ジオコーディング・ルート計算を提供する AWS のマネージドサービスです。2024年11月に v2(スタンドアロンAPI) が登場し、使い勝手が大きく変わりました。

  • リソースの事前作成が不要:以前は「PlaceIndex」「Map」などのリソースをコンソールで作ってから使う必要がありましたが、v2 では geo-places / geo-maps / geo-routes という統一エンドポイントをそのまま叩けます。
  • 認証は2通り:
    • サーバ(Lambda)からは IAM ロール(SigV4)
    • ブラウザからの地図描画は API キー(公開アプリではリファラ制限の設定を推奨)

今回は「単純な置き換え」ではなく、環境で切り替える方針にしました(DistributedTable のローカル模擬↔DynamoDB と同じ発想を自作します)。

機能 ローカル(模擬) AWS(本物) AWS時の認証
地名→座標 Nominatim Amazon Location Places SearchText(@aws-sdk/client-geo-places) Lambda の IAM ロール geo-places:SearchText
地図 MapLibre + OSM ラスタタイル Amazon Location Maps v2 ベクタタイル(MapLibre 互換) API キー
座標→標高 国土地理院 国土地理院(共通) 不要

切替の判定には、CDK がデプロイ時にだけ渡す環境変数(MAP_API_KEY_NAME)の有無を使います。


3. バックエンド実装:ローカル↔AWS の切替を自作する

まず SDK を2つ入れます(執筆時点では 3.1147.0 でした)。

npm install @aws-sdk/client-geo-places @aws-sdk/client-location

aws-blocks/index.ts に「AWSモード判定」と2つのジオコーダを用意し、searchElevation で切り替えます。

import { GeoPlacesClient, SearchTextCommand } from '@aws-sdk/client-geo-places';

const REGION = process.env.APP_REGION ?? process.env.AWS_REGION ?? 'ap-northeast-1';
const geoPlaces = new GeoPlacesClient({ region: REGION });
const JAPAN_BIAS: [number, number] = [138.0, 36.0];

// MAP_API_KEY_NAME は CDK がデプロイ時にだけ渡す env。これを "AWSモード" 判定に使う。
const USE_AMAZON_LOCATION = !!process.env.MAP_API_KEY_NAME;

// AWS用(本物):Amazon Location Places / SearchText。Lambda の IAM ロールで認証。
async function geocodeWithAmazonLocation(place: string) {
  const geo = await geoPlaces.send(new SearchTextCommand({
    QueryText: place, BiasPosition: JAPAN_BIAS, MaxResults: 1, Language: 'ja',
    IntendedUse: 'Storage',   // 結果を検索履歴に保存するので必須(§5)
  }));
  const top = geo.ResultItems?.[0];
  if (!top?.Position) throw new Error(`場所が見つかりませんでした: ${place}`);
  const [longitude, latitude] = top.Position;       // Amazon Location は [経度, 緯度]
  return { longitude, latitude, address: top.Address?.Label ?? top.Title ?? place };
}

// ローカル用(模擬):Nominatim(OpenStreetMap)。AWS認証情報が無くても動く。
async function geocodeWithNominatim(place: string) {
  const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(place)}&format=json&limit=1`;
  const res = await fetch(url, { headers: { 'User-Agent': 'aws-blocks-trial/1.0 (elevation-demo)' } });
  const hits = await res.json() as Array<{ lat: string; lon: string; display_name: string }>;
  if (!hits?.length) throw new Error(`場所が見つかりませんでした: ${place}`);
  return { longitude: Number(hits[0].lon), latitude: Number(hits[0].lat), address: hits[0].display_name };
}

// ... ApiNamespace の中 ...
async searchElevation(place: string) {
  // 1) 地名 → 緯度経度:ローカルは模擬(Nominatim)・AWSは本物(Amazon Location)。
  //    DistributedTable の「ローカル模擬↔DynamoDB」と同じ発想を自作している。
  const { longitude, latitude, address } = USE_AMAZON_LOCATION
    ? await geocodeWithAmazonLocation(place)
    : await geocodeWithNominatim(place);

  // 2) 座標 → 標高(国土地理院 標高API。両環境共通)
  // ...(省略)...
  return { address, latitude, longitude, elevation };
},

これで npm run dev(ローカル)では Nominatim、npm run sandbox / deploy(AWS)では Amazon Location が使われます。実際に叩くと、ローカルの searchElevation("富士山") は Nominatim 由来の [138.7308, 35.3628] / 3537.8m、AWS 側は Amazon Location 由来の [138.734, 35.337] / 2377.9m を返しました(この差は §7.5 で扱います)。地図側も同じく、getMapConfig() が APIキーを返せないローカルでは OSM に、AWS では Amazon Location Maps にフォールバック/切替します。

注意:これは忠実なモックではありません
DistributedTable のローカル実装は DynamoDB と同じ振る舞いをするエミュレータなので、ローカルで動いたものは本番でもそのまま動きます。一方、今回の切替はローカル(Nominatim)と AWS(Amazon Location)でサービス自体が別なので、返る座標も標高も一致しません(上の富士山がその例です)。オフラインで動かすためにプロバイダを差し替えているだけで、ローカルの結果は本番の挙動を保証しません。

DynamoDB は振る舞いを再現できますが(公式の DynamoDB Local もあります)、Amazon Location の価値は独自の地理データそのものなので、ローカルでは再現できません。Block になっていないのも、このあたりが理由だと思います。本番と同じ結果でローカル開発したい場合は、ローカルからも本物の Amazon Location を呼ぶことになります(AWS の認証情報と課金が必要です)。

Nominatim の利用ポリシー:公開されている Nominatim のサーバーには利用ポリシーがあり、アプリを識別できる User-Agent を付けること、リクエストは1秒に1回までにすること、などが求められています。コードを流用する場合は、User-Agent を自分のアプリ名に変え、短い間隔で連続して呼ばないようにしてください。

ハマり①:BiasPosition は実質必須
QueryText だけで投げると、こう怒られます(AWS CLI での出力):

ValidationException: Exactly one of the following fields must be set:
  'BiasPosition', 'Filter.BoundingBox', 'Filter.Circle'.

検索結果の並び替えヒントに見える BiasPosition ですが、API リファレンス上は「BiasPosition / Filter.BoundingBox / Filter.Circle のうちちょうど1つを指定必須」という仕様です(併用も不可)。日本中心の座標を1つ渡して解決しました。

座標順の落とし穴:Amazon Location は GeoJSON 準拠で [経度, 緯度] を返します(緯度・経度の順ではない)。MapLibre も [lng, lat] なので、そのまま flyTo / Marker に渡せて相性は良いです。


4. 地図の API キーと「CFN が返してくれない値」問題

ブラウザの MapLibre から Amazon Location の地図を描くには API キーが要ります。CDK(aws-blocks/index.cdk.ts)で作ります。

import { CfnAPIKey } from 'aws-cdk-lib/aws-location';

const mapApiKey = new CfnAPIKey(blocksStack, 'MapApiKey', {
  keyName: `${RESOURCE_PREFIX}-map`,
  noExpiry: true,
  restrictions: {
    allowActions: ['geo-maps:*'],
    allowResources: [`arn:aws:geo-maps:${blocksStack.region}::provider/default`],
  },
});

ここで問題が1つ。AWS::Location::APIKey は、CloudFormation の戻り値としてキーの「値」(v1.public.…)を返してくれません(返るのは Arn / KeyArn などだけ)。つまり「作ったキーの値をフロントの環境変数に埋める」が素直にはできません。

解決策として、Lambda が実行時に DescribeKey でキー値を取得してフロントに返す 小さな public API を足しました。

import { LocationClient, DescribeKeyCommand } from '@aws-sdk/client-location';

let cachedMapConfig: { region: string; apiKey: string } | null = null;

// ApiNamespace の中(認証不要)
async getMapConfig() {
  if (cachedMapConfig) return cachedMapConfig;
  const keyName = process.env.MAP_API_KEY_NAME;   // ← CDK から env で渡す
  if (!keyName) throw new Error('デプロイ環境でのみ有効');
  const loc = new LocationClient({ region: REGION });
  const res = await loc.send(new DescribeKeyCommand({ KeyName: keyName }));
  cachedMapConfig = { region: REGION, apiKey: res.Key! };
  return cachedMapConfig;
}

CDK 側では、Blocks が公開している blocksStack.handler(=バックエンド Lambda の NodejsFunction)に対して、IAM 権限の付与・環境変数・タグを後付けします。ここが「Blocks が用意したスタックに、生の CDK で手を入れる」部分です。

import * as iam from 'aws-cdk-lib/aws-iam';
import { Tags } from 'aws-cdk-lib';

const RESOURCE_PREFIX = 'sandbox-sparkdev1';

// (1) ジオコーディング権限(地図用 API キーの allowResources と同じく provider/default に絞る)
blocksStack.handler.addToRolePolicy(new iam.PolicyStatement({
  actions: ['geo-places:SearchText'],
  resources: [`arn:aws:geo-places:${blocksStack.region}::provider/default`],
}));

// (2) 地図APIキー(前掲の CfnAPIKey)… mapApiKey

// (3) キー値を実行時取得するための DescribeKey 権限 + キー名を env で伝える
blocksStack.handler.addToRolePolicy(new iam.PolicyStatement({
  actions: ['geo:DescribeKey'],
  resources: [mapApiKey.attrArn],
}));
blocksStack.handler.addEnvironment('MAP_API_KEY_NAME', `${RESOURCE_PREFIX}-map`);
blocksStack.handler.addEnvironment('APP_REGION', blocksStack.region);

// (4) 全リソースに Name / Project タグ
Tags.of(blocksStack).add('Name', RESOURCE_PREFIX);
Tags.of(blocksStack).add('Project', 'aws-blocks-trial');

本番向け補足:今回はデモなので、API キーにリファラ制限を付けておらず、API も認証なしで呼べるようにしています。公開する場合は、次の点を検討してください。

  • API キーは restrictions.allowReferers(例:https://example.com/*)で、使えるドメインを絞る
  • searchElevation は、呼ばれるたびに従量課金の SearchText と DynamoDB への書き込みが発生する(§5)。誰でも呼べる状態で公開すると、第三者に繰り返し呼ばれた分も課金される。AuthBasic でログインした人だけが呼べるようにする、API Gateway に AWS WAF(レートベースルールなど)を付ける、などで保護する

フロント:Amazon Location の地図 → 失敗時は OSM にフォールバック

src/ElevationMap.tsx は、まず getMapConfig() を呼んで Amazon Location Maps v2 のスタイルURLを組み立てます。ローカル dev(キー無し)では例外になるので、その場合は第1弾の OSM ラスタタイルにフォールバックします。

const cfg = await api.getMapConfig();                          // 型付きで直接呼べる
const style = `https://maps.geo.${cfg.region}.amazonaws.com/v2/styles/Standard/descriptor?key=${cfg.apiKey}`;
map.current = new maplibregl.Map({ container, style, center: [139.6917, 35.6895], zoom: 8 });

Standard スタイルのままだと、地名のラベルは英語で表示されます。スタイルの中身を見ると、ラベルは ["coalesce", ["get", "name:en"], ["get", "name"]](英語名、なければ現地の名前)になっていました。そこで、スタイルを読み込んだあとに name:en を name:ja に差し替えています。

map.current.on('style.load', () => {
  const m = map.current;
  if (!m) return;
  for (const layer of m.getStyle().layers) {
    if (layer.type !== 'symbol') continue;
    const textField = m.getLayoutProperty(layer.id, 'text-field');
    if (textField === undefined) continue;
    const replaced = JSON.parse(JSON.stringify(textField).replaceAll('"name:en"', '"name:ja"'));
    m.setLayoutProperty(layer.id, 'text-field', replaced);
  }
});

Amazon Location が配信するフォント(glyphs)には漢字・かなが入っていません。それでも日本語が表示されるのは、MapLibre が漢字・かなをブラウザのフォントで描画するためです(localIdeographFontFamily の既定値 sans-serif)。


5. 検索履歴を残す:Todo を外して DistributedTable と Realtime を使う

第1弾のプロジェクトは react テンプレートから作ったので、テンプレートの Todo 機能がそのまま残っていました。Todo は DistributedTable と Realtime を使うサンプルなので、このままデプロイしても DynamoDB や WebSocket API は作られます。ただ、それは記事に出てこない Todo 機能のためのリソースで、地図アプリ自身は DynamoDB も WebSocket も使っていません。

そこで Todo を外し、地図アプリ自身にデータを持たせることにしました。検索に成功するたびに結果を DistributedTable に保存し、Realtime で「保存した」と通知します。画面では地図の下に「最近の検索」を表示し、別のタブや別の人が検索しても、その場で一覧が増えます。

// 検索履歴。全員共通の履歴なので partition key は固定値、sort key は時刻順に並ぶ searchId
const searchSchema = z.object({
  feed: z.string(),                  // partition key(固定値 'public')
  searchId: z.string(),              // sort key:`${時刻(ms)}-${乱数}`
  place: z.string(),                 // 入力した地名
  address: z.string(),
  latitude: z.number(),
  longitude: z.number(),
  elevation: z.number().nullable(),
  geocoder: z.enum(['amazon-location', 'nominatim']),
  searchedAt: z.number(),
});

const searches = new DistributedTable(scope, 'searches', {
  schema: searchSchema,
  key: { partitionKey: 'feed', sortKey: 'searchId' },
});
const FEED = 'public';

// 検索が保存されたことを、接続中のブラウザへ通知する
const rt = new Realtime(scope, 'live', {
  namespaces: {
    searches: Realtime.namespace(z.object({ searchId: z.string() })),
  },
});

API 側は、searchElevation の最後に保存と通知を足し、一覧取得と購読用の API を2つ追加しました。

async searchElevation(place: string) {
  // 1) 2) ジオコーディングと標高取得は §3 のとおり

  // 3) 検索履歴に保存し、他のブラウザへ通知(失敗しても検索結果は返す)
  try {
    const searchedAt = Date.now();
    // 文字列のまま時刻順に並ぶよう、桁数をそろえる
    const searchId = `${searchedAt.toString().padStart(15, '0')}-${Math.random().toString(36).slice(2, 8)}`;
    await searches.put({
      feed: FEED, searchId, place, address, latitude, longitude, elevation,
      geocoder: USE_AMAZON_LOCATION ? 'amazon-location' : 'nominatim',
      searchedAt,
    });
    await rt.publish('searches', FEED, { searchId });
  } catch (err) {
    console.error('検索履歴の保存に失敗:', err);
  }

  return { address, latitude, longitude, elevation };
},

/** 最近の検索履歴(新しい順) */
async listSearches(limit: number = 20) {
  return await Array.fromAsync(
    searches.query({ where: { feed: { equals: FEED } }, order: 'desc', limit: Math.min(limit, 50) }),
  );
},

/** 検索履歴の更新通知を受け取るチャネル */
async subscribeSearches() {
  return rt.getChannel('searches', FEED);
},

フロント(src/ElevationMap.tsx)では、最初に一覧を読み込み、通知が来るたびに読み直します。

useEffect(() => {
  loadHistory();                                   // api.listSearches() で一覧を取得
  let sub;
  (async () => {
    const channel = await api.subscribeSearches();
    sub = channel.subscribe(() => loadHistory());  // 誰かが検索したら読み直す
    await sub.established;
  })();
  return () => sub?.unsubscribe();
}, [loadHistory]);

Amazon Location の検索結果を保存するなら IntendedUse: 'Storage':SearchText の IntendedUse は、結果を自分のインフラに保存するかどうかを示す項目で、既定値は SingleUse(保存しない)です。今回は返ってきた住所と緯度経度を DynamoDB に保存するので、利用規約に従うには Storage を指定する必要があります(API リファレンス)。§3 の geocodeWithAmazonLocation に入れているのはこのためです。Storage を指定したリクエストは「Stored」という料金区分になり、保存しない場合より単価が上がります(Places pricing)。保存しない設計にするなら、履歴には入力した地名と標高だけを残し、Amazon Location が返した住所や座標は保存しない、という作り方もあります。

DynamoDB のテーブル定義も、WebSocket の接続先も、コードには一切出てきません。ここが Blocks らしいところで、同じコードがローカルでは .bb-data/ のファイルとプロセス内 pub/sub、AWS では DynamoDB と WebSocket API で動きます。

ローカルで npm run test:e2e を実行すると、テンプレートの Todo 用テストを書き換えた検索履歴のテスト(保存・新しい順・Realtime 通知)も含めて通りました。

✔ auth: starts signed out
✔ auth: sign up creates account and signs in
✔ searches: searchElevation saves a history entry
✔ searches: listed newest first
✔ realtime: a search notifies subscribers
ℹ tests 5
ℹ pass 5

partition key を固定値にしている点について:全件が同じ partition に入るので、書き込みが集中すると DynamoDB の partition あたりの上限に当たります。デモ規模なら問題ありませんが、実運用ならユーザーごとや日付ごとに partition を分けるなどの対応が必要です。

Todo を外して再デプロイしたときの警告:Todo 入りでデプロイ済みのスタックに、Todo を外したコードをデプロイし直すと、完了時に次の警告が出ました。

⚠️  Some resources failed to delete but were skipped. These resources may still exist and could incur charges. Clean them up manually.

スタックのイベントを見ると、DELETE_FAILED になっていたのは todos テーブルの GSI を管理するカスタムリソース(AWS::CloudFormation::CustomResource)でした。テーブルとその権限が先に消えたあとで、カスタムリソースの削除処理が dynamodb:DescribeTable を呼んで権限エラーになり、スキップされたようです。todos テーブル自体は削除されていて、課金されるリソースは残っていませんでした。最初から Todo を外した状態でデプロイすれば、この警告は出ません。

ログイン機能(AuthBasic)はテンプレートのまま残しましたが、今回の API はどれも認証なしで呼べます。


6. AWS へデプロイする

6.1 前提

  • AWS アカウントと認証情報(~/.aws/credentials またはSSO等)
  • 対象リージョンで CDK Bootstrap 済み(未実施なら npx cdk bootstrap aws://<ACCOUNT_ID>/ap-northeast-1)

6.2 -C cdk 条件を忘れない(ハマり②)

Blocks の CDK アプリは Node の conditional exports を使って「本物の CDK 構造」と「モック」を出し分けています。手元で cdk synth を試すと、条件指定なしではこう止まります:

Error: Missing --conditions=cdk: Building Blocks will silently load
mock implementations instead of CDK constructs.

npm run sandbox(= startSandbox)は内部で cdk deploy を NODE_OPTIONS=--conditions=cdk 付き・アプリを npm exec tsx -- -C cdk index.cdk.ts として起動してくれるので、通常フローなら意識不要です。自分で synth を回して確認したいときだけ -C cdk を付けます。

6.3 デプロイ実行

npm run sandbox

startSandbox は「シークレット作成 → cdk deploy --all → API URL 取得 → クライアントコード生成 → CDK watch + ローカル dev サーバ常駐」という流れです。CIやワンショット用途では deployOnly: true を渡すと、常駐せずデプロイだけして終われます。

デプロイ出力(抜粋):

🚀 Deploying to AWS...
   (This may take a few minutes on first deploy)
...
 ✅  elevation-app-123abc-sandbox-456def

✨  Deployment time: 120.01s

⚠️  Stack deployed using Express Mode. Resources still stabilizing: StackResources, MapApiKey, ...

Outputs:
...ApiUrl = https://abc123defg.execute-api.ap-northeast-1.amazonaws.com/prod/aws-blocks/api
...DefaultComputeAPIEndpoint... = https://abc123defg.execute-api.ap-northeast-1.amazonaws.com/prod/
...RealtimeWsUrl = wss://xyz789abcd.execute-api.ap-northeast-1.amazonaws.com/rt
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:123456789012:stack/elevation-app-123abc-sandbox-456def/...

✅ Sandbox deployed!
📡 API URL: https://abc123defg.execute-api.ap-northeast-1.amazonaws.com/prod/aws-blocks/api

BLOCKS_DEPLOYED api=https://abc123defg.execute-api.ap-northeast-1.amazonaws.com/prod/aws-blocks/api

最後の BLOCKS_DEPLOYED の行は、CI やスクリプト(コーディングエージェント含む)がデプロイ完了と URL を拾うための1行です。また、CloudFormation の Express Mode でデプロイされるため、完了表示の時点ではまだ一部のリソースが安定化中である旨が表示されます(今回は数十秒後には問題なく呼び出せました)。

第1弾で「化ける」と説明したとおり、宣言した Block がコードを変えずに AWS のリソースとして実体化しました。今回のアプリでの対応は次のとおりです。

コード上の宣言 作られたリソース
DistributedTable(scope, 'searches', …) DynamoDB テーブル …-my-app-searches
Realtime(scope, 'live', …) API Gateway の WebSocket API + 接続管理用の DynamoDB テーブル …-my-app-live-connections
ApiNamespace(scope, 'api', …) Lambda + API Gateway(REST API、ステージ prod)
AuthBasic(scope, 'auth', …) DynamoDB テーブル …-my-app-auth-users / …-my-app-auth-codes

なお 0.7.0 では、アプリの処理を担う Lambda(ApiNamespace の実体)は既定で arm64(Graviton)、API Gateway のステージ prod には sandbox 用のスロットリング(200 rps / バースト 400)が付いた状態で作られていました。arm64 になるのは、Blocks の Lambda 用 Construct が、指定がなければ Architecture.ARM_64 を使うようになっているためです。一方、GSI の管理や S3 の自動削除などに使われる CDK のカスタムリソース用の Lambda は、CDK の既定値の x86_64 のままでした。

コンソールでも順に見ていきます。まず DistributedTable です。CloudFormation のリソース一覧では、my-app → searches → table の下に AWS::DynamoDB::Table ができていました。

CloudFormation スタック:DistributedTable 'searches' から作られた DynamoDB テーブル

テーブルを開くと、パーティションキー feed・ソートキー searchId と、コードで指定したキーがそのまま設定されています。キャパシティーはオンデマンドです(項目数は DynamoDB が約6時間ごとに更新する概算値なので、作った直後は 0 のままです)。

DynamoDB テーブル …-my-app-searches の設定(パーティションキー feed / ソートキー searchId)

次は ApiNamespace です。DefaultCompute の下に Lambda 関数と API Gateway の REST API が並んでいます。

CloudFormation スタック:ApiNamespace から作られた Lambda 関数と REST API

Lambda 側から見ると、API Gateway がトリガーとしてつながっています。

Lambda 関数の概要:トリガーは API Gateway

ランタイム設定のアーキテクチャは arm64 です。

Lambda のランタイム設定:Node.js 24.x / arm64

REST API のステージ prod には、レート 200・バースト 400 のスロットリングが付いています。

API Gateway(REST API)のステージ prod:レート 200 / バースト 400

最後に Realtime です。WebSocket API BlocksRtWebSocket ができていて、ルートは $connect / $default / $disconnect の3つです。$connect の統合先は、REST API と同じ Lambda 関数でした。

API Gateway(WebSocket API)BlocksRtWebSocket のルートと Lambda 統合

WebSocket API のステージ rt も、コンソールでは同じくレート 200・バースト 400 と表示されていました。

API Gateway(WebSocket API)のステージ rt:レート 200 / バースト 400

地図用の API キーは、Blocks ではなく CDK で自分で宣言したリソースです(§4)。

Amazon Location コンソールに作られた API キー(sandbox-sparkdev1-map)


7. 動作確認(実 AWS エンドポイント)

デプロイ済み API(POST /aws-blocks/api、JSON-RPC 2.0)を curl で直接叩きます。

7.1 ジオコーディング(Amazon Location Places)

curl -s -X POST "$API_URL" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"api.searchElevation","params":["富士山"]}'

レスポンス:

{"jsonrpc":"2.0","result":{
  "address":"〒418-0112 静岡県富士宮市北山 富士山",
  "latitude":35.33657,"longitude":138.73401,"elevation":2377.9
},"id":1}

阿蘇山でも:

{"result":{"address":"〒869-2612 熊本県阿蘇市一の宮町宮地6029-1 阿蘇山",
  "latitude":32.89923,"longitude":131.10277,"elevation":910.5}}

住所ラベルが郵便番号付きで返るのは、Amazon Location の実用的な長所です。一方で、富士山の標高が第1弾より低いことに気づきます。これは精度低下ではなくジオコーダの代表点の違いで、面白いので次節でちゃんと検証します。

7.2 地図設定(DescribeKey 経由)

curl -s -X POST "$API_URL" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"api.getMapConfig","params":[]}'
{"result":{"region":"ap-northeast-1","apiKey":"v1.public.ey…(伏せ字)"}}

Lambda の geo:DescribeKey 権限でキー値を取得できています。さらに、そのキーで Maps v2 のスタイル記述子が引けることも確認しました:

curl -s -o /dev/null -w "HTTP %{http_code}\n" \
  "https://maps.geo.ap-northeast-1.amazonaws.com/v2/styles/Standard/descriptor?key=$KEY"
# → HTTP 200 (style v8 / 160 layers / source: "aws")

7.3 検索履歴(DynamoDB)

上の2回の検索のあとで、listSearches を呼びます。

curl -s -X POST "$API_URL" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":3,"method":"api.listSearches","params":[]}'
{"jsonrpc":"2.0","result":[
  {"feed":"public","searchId":"001791957078884-kn3mzp","place":"阿蘇山",
   "address":"〒869-2612 熊本県阿蘇市一の宮町宮地6029-1 阿蘇山",
   "latitude":32.89923,"longitude":131.10277,"elevation":910.5,
   "geocoder":"amazon-location","searchedAt":1791957078884},
  {"feed":"public","searchId":"001791957078355-r3degq","place":"富士山",
   "address":"〒418-0112 静岡県富士宮市北山 富士山",
   "latitude":35.33657,"longitude":138.73401,"elevation":2377.9,
   "geocoder":"amazon-location","searchedAt":1791957078355}
],"id":3}

新しい順に返り、geocoder が amazon-location になっているので、AWS 側のジオコーダで検索した結果が DynamoDB に保存されていることが分かります。DynamoDB コンソールで …-my-app-searches テーブルの項目を見ると、この2件(阿蘇山・富士山の2行)に加えて、このあとの Realtime の確認やブラウザから検索した分も入っていて、全部で6件でした。

DynamoDB の「テーブルアイテムの探索」:feed は全件 public、searchId は時刻順

Realtime も、AWS 上で通知が届くことを確認しました。Node から subscribeSearches() で購読しておき、searchElevation('高尾山') を呼ぶと、Lambda の rt.publish が WebSocket 経由で届き、受け取った searchId が listSearches の先頭と一致しました。

7.4 ブラウザ

npm run sandbox はデプロイ後にローカル dev サーバを立ち上げ、AWS 上の API にプロキシします。http://localhost:3000 を開けば、地図は Amazon Location(Standard スタイルのベクタタイル)で描画され、検索は Amazon Location Places が答えます。地図の下には「最近の検索」が表示され、ブラウザのタブを2つ開いて片方で検索すると、もう片方の一覧にもすぐ行が増えます。

下の画面は、左右に並べた2つのタブです。右のタブで「BIPROGY」を検索すると、左のタブの「最近の検索」の先頭にも、同じ行(3.8 m)が追加されました。

2つのタブを並べた画面:右で検索すると、左の「最近の検索」にも同じ行が届く

7.5 精度検証:「AWS にしたら標高がズレた」の正体

第2弾を動かして最初に引っかかったのが、富士山の標高が、ローカル(Nominatim, 3537.8m)より AWS(Amazon Location, 2377.9m)の方が低く出たことでした。今回の設計だと「富士山」で同じ検索をしても、ローカル=Nominatim / AWS=Amazon Location と別のジオコーダが答えるので、代表点が変わり結果もズレます。「Amazon Location にしたら精度が落ちた?」と疑いたくなりますが、実データを並べると話は逆でした。

同じパイプライン(ジオコーダの返す代表点 → 国土地理院で標高)で、地名を変えて叩いた実測値です:

クエリ ジオコーダ 返ってきた座標 標高 評価
東京駅 Amazon Location [139.7655, 35.6816] 3 m ✅ 正確
高尾山 Amazon Location [139.2432, 35.6250] 592 m ✅ ほぼ正確(実 599m)
富士山 Amazon Location(AWS) [138.7340, 35.3366] 2377.9 m ⚠️ 富士宮口五合目付近
富士山 Nominatim(ローカル) [138.7308, 35.3628] 3537.8 m ⚠️ 火口の底(第1弾の大内院付近)。これも山頂ではない
富士山 剣ヶ峰 Amazon Location [138.7278, 35.3604] 3758.3 m ✅ ほぼ山頂(実 3776m)
富士山 剣ヶ峰 Nominatim(ローカル) [137.5536, 36.1065] 3025.3 m ❌ 乗鞍岳の剣ヶ峰(岐阜県高山市)

分かったこと:

  • 全般的な精度低下ではない。駅・建物・高尾山クラスは Amazon Location でも正確に代表点を返す。
  • ズレるのは「富士山」のような巨大な自然地物だけ。ジオコーダが選ぶ代表点(ラベル位置)が、Amazon Location では富士宮口五合目寄り、Nominatim では火口の底(第1弾で見た大内院付近)と食い違うのが原因です。どちらも山頂ではないので、Nominatim が正確なわけでもありません(たまたま高い点を指しただけ)。
  • 最後の2行を見ると、富士山 剣ヶ峰 と具体的に投げた場合、Amazon Location は 3758.3m(ほぼ山頂) を返しました。Amazon Location の精度が低いのではなく、「富士山」という語が曖昧なだけです。
  • 一方 Nominatim に同じ 富士山 剣ヶ峰 を投げると、乗鞍岳の剣ヶ峰(3025.3m)が返ってきました。「剣ヶ峰」という地名は各地にあり、このケースでは Amazon Location のほうが「富士山の」剣ヶ峰をうまく選べていました。

「富士山」だと富士宮口五合目寄りで 2377.9m

「富士山 剣ヶ峰」と具体的に指定すると山頂付近の 3758.3m(実 3776m)を返す

ジオコーダを変えると、曖昧な地名の代表点は変わります。標高のような「点の値」を扱うアプリでは、その差がそのまま結果に出ます。第1弾と同じく、実際に叩いて、どの点の標高なのかを確かめるのが大事でした。


8. 後片付け(重要)

サンドボックスは実リソースです。検証が終わったら消します。

npm run sandbox:destroy

destroy の前に、npm run sandbox が完全に止まっているかを確認してください。今回、止めたつもりでも cdk watch とローカル dev サーバのプロセスが残っていて、destroy が Other CLIs ... are currently reading from cdk.out というエラーで失敗しました。ps などで cdk watch が残っていないかを見て、残っていれば止めてから実行します。

……が、ここで最後のハマり③に遭遇しました。DynamoDB / Lambda / API Gateway / WebSocket / S3 / IAM ロールはすべてスルッと消えたのに、MapApiKey(Amazon Location の APIキー)だけ削除に失敗して、スタックが DELETE_FAILED になります:

DELETE_FAILED | AWS::Location::APIKey | MapApiKey
Resource handler returned message: "Invalid request provided:
  'sandbox-sparkdev1-map' must be inactive for 90 days before it can be deleted."

Amazon Location の API キーには「削除するには 90 日間 inactive である必要がある」という保護があり、ForceDelete を指定していないキーは CloudFormation から消せません。npm run sandbox:destroy は 60 秒おきにリトライしますが、この制約は待っても変わらないので、このままでは失敗し続けます。

なお、キーを作ってほぼ使わないまま数分で destroy したときは、エラーにならずに消えました。地図を 40 分ほど表示して使ったあとだと、上のエラーになりました。実際に地図を使ったあとで片付ける、という普通の流れではほぼ確実に当たると思っておいたほうがよさそうです。

すでに DELETE_FAILED になった場合は、キーだけ先に delete-key --force-delete で強制削除してから、スタックを削除し直します:

# 1) API キーを強制削除(90日ルールをバイパス)
aws location delete-key --key-name sandbox-sparkdev1-map --force-delete --region ap-northeast-1

# 2) DELETE_FAILED になったスタックを削除し直す(キーはもう無いので今度は通る)
aws cloudformation delete-stack --stack-name <stack名> --region ap-northeast-1
aws cloudformation wait  stack-delete-complete --stack-name <stack名> --region ap-northeast-1

これで list-keys も describe-stacks も空になり、課金対象のリソースが残っていないことを確認できます。

最初から防ぐ:forceDelete: true

AWS::Location::APIKey には ForceDelete プロパティがあります(CFN リファレンス)。CDK では CfnAPIKey に forceDelete: true を足すだけです:

const mapApiKey = new CfnAPIKey(blocksStack, 'MapApiKey', {
  keyName: `${RESOURCE_PREFIX}-map`,
  noExpiry: true,
  // スタック削除時に「90日 inactive」ルールを回避してキーを削除する。
  forceDelete: true,
  restrictions: { /* 前掲と同じ */ },
});

これを付けてデプロイし直し、同じように地図を 35 分ほど使ってから npm run sandbox:destroy を実行したところ、今度は API キーも含めて一度で削除できました。サンドボックスや CI のように作っては消す環境では、最初から付けておくのがおすすめです。本番用のキーでは、うっかり削除を防ぐ保護として残しておく、という考え方もあると思います。

コスト感:今回作られるのは DynamoDB(オンデマンド)/ Lambda / API Gateway / WebSocket / Location APIキー など。デモ規模のリクエストなら無料枠〜数円程度ですが、SearchText は検索履歴に保存するため IntendedUse: 'Storage' にしており、保存しない場合(Core)より単価が高い Stored の料金で課金されます(§5)。放置はせず、使い終わったら片付けましょう。Tags.of(...).add('Name', 'sandbox-sparkdev1') のようにタグを付けておくと、消し忘れの棚卸しもしやすくなります。


ご来光
(これから富士山に登る方のために、次の写真は、一番最後に載せます)

9. まとめ

  • 第1弾の「ローカルで動く」は、Blocks のプリミティブ(DynamoDB 等)のエミュレーションで、外部 fetch(Nominatim/国土地理院)は AWS 化されていませんでした。
  • ジオコーディングと地図を Amazon Location Service v2 に差し替え、npm run sandbox 一発で 実 AWS にデプロイしました。
  • テンプレートの Todo を外し、検索履歴を DistributedTable と Realtime で持たせました。DistributedTable→DynamoDB、Realtime→WebSocket API、ApiNamespace→Lambda+API GW が、地図アプリ自身のコードから宣言どおり実体化しました。
  • Blocks のスタックにも blocksStack.handler.addToRolePolicy(...) や Tags.of(...) で普通に CDK で手を入れられる(IAM・APIキー・タグを後付け)のは実用上うれしいポイント。
  • 実機で踏んだ罠:SearchText の BiasPosition 実質必須/API キー値は CFN が返さない → DescribeKey/--conditions=cdk/API キーの 90 日削除保護(forceDelete: true で回避)/標高だけは Amazon Location に無いので国土地理院据え置き。
  • ローカルで動いたコードがそのまま AWS のマネージドインフラに乗り、地図・地名も AWS(Amazon Location)になるところまで来て、AWS Blocks の狙いがよく分かりました。

🏔️ 次回予告:八合目でご来光は拝めました。ただ、今回の剣ヶ峰は地名検索で指し示しただけです。次はいよいよ山頂へ。お鉢巡りをして、自分の足で剣ヶ峰に立ちます。テーマは「自分で Building Block を作る」です(第3弾に続きます)。


参考


BIPROGYグループの技術への取り組み

We Are Hiring!


いよいよ山頂へ

3
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
3
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?