OSM ベクトルタイル自前ホスティング構築記録

Cloudflare R2 + Workers + PMTiles

コミュニティ運営のタイルサーバー tile.openstreetmap.jp への依存をやめ、 日本全国の地図タイルを月額ほぼ 0 円で自前配信するまでの全記録。

目次

  1. 背景と課題
  2. 設計判断
  3. アーキテクチャ
  4. コスト設計 — 月額ほぼ 0 円のからくり
  5. 構築ステップ全記録
  6. つまずきと解決
  7. 動作確認の結果
  8. ライブデモ
  9. ライセンスと帰属表示
  10. 運用 — 月次更新フロー
  11. まとめ

1. 背景と課題

地域の危険度を可視化するアプリ hazardmap(Web / Android / iOS)は、地図表示に OSMFJ(OpenStreetMap Foundation Japan)が運営する無料コミュニティタイルサーバー tile.openstreetmap.jp を利用していた。 しかし次の課題があった。

そこで「日本全国の OSM ベクトルタイルを自前ホスティングし、外部タイルサーバーへの依存を完全に廃止する」 ことにした。要件定義〜詳細設計は naughty-ghost/hazardmap リポジトリ(非公開)で実施し、 実体のインフラは複数アプリから共用できる汎用基盤として独立リポジトリ naughty-ghost/map-tiles に切り出した。

主な要件
  • 常時稼働するタイルサーバー(TileServer GL / Martin 等)は運用しない — 静的ファイル配信方式
  • 現行スタイル 2 種(ライト / ダーク)と同等の見た目を維持する
  • 月 1 回程度の頻度で OSM 元データを取り込み、タイルを差し替えられる運用フロー
  • 現行と同等以上の読み込み速度・タイル取得エラー率の低減

2. 設計判断

論点 採用した選択 理由
タイルの持ち方 PMTiles(単一静的ファイル) 数百万個のタイルを 1 ファイルにまとめ、HTTP Range Request で必要な部分だけ読める。 オブジェクトストレージに置くだけで配信でき、タイルサーバーの常時稼働が不要
配信方式 エッジ(Worker)で z/x/y URL に展開 クライアントは標準のタイル URL を叩くだけ。フロントエンドに pmtiles ライブラリの追加が不要で、変更は「スタイル URL の差し替えのみ」に収まる。 CDN キャッシュがタイル単位で効く
ホスティング先 Cloudflare R2 + Workers + CDN エグレス(転送量)課金が無料。読み取り偏重のタイル配信に最適。 Protomaps 公式のサーバーレス実装がそのまま使える。S3 + CloudFront だと転送量課金が支配的になる
タイル生成ツール Planetiler 現行スタイルは OpenMapTiles スキーマ前提。Planetiler は同スキーマを公式プロファイルで 生成できるため、スタイル定義を無改変で流用でき「見た目同等」を最小工数で満たせる
グリフ(フォント) 現行サーバーの実物を流用 再生成ではなく配信中の実物(Migu 系 3 スタック)を取得して配置することで、 ラベル描画の見た目同等を確実に担保

3. アーキテクチャ

構成(上から下へリクエストが流れる):

  1. クライアント(MapLibre GL) — Web / Android / iOS。style.json の URL を参照するだけ
  2. Cloudflare CDN — tiles.naughty-ghost.org(カスタムドメイン・キャッシュ 24h)
  3. Cloudflare Worker(map-tiles)/tiles/… → PMTiles から Range Request でタイル切り出し。 /styles/…/fonts/… → R2 の静的ファイルをそのまま配信。/tiles/{source}.json → TileJSON をメタデータから動的生成
  4. Cloudflare R2(バケット: map-tiles / apac) — japan.pmtiles(1.67GB)+ スタイル 2 種 + スプライト + グリフ 768 ファイル

エンドポイント契約

パス 内容
/styles/{name}/style.json MapLibre スタイル(osm-bright-ja = ライト / maptiler-basic-ja = ダーク)
/tiles/{source}/{z}/{x}/{y}.mvt ベクトルタイル本体(source = japan | takeshima | hoppo)
/tiles/{source}.json TileJSON(zoom 0–14、帰属表示を含む)
/fonts/{fontstack}/{range}.pbf グリフ(Migu 系 3 スタック × 256 range)
/styles/{name}/sprite*.{json,png} スプライト(アイコン・国道番号バッジ等)

Worker は protomaps/PMTiles 公式の Cloudflare 実装をベースに、上記のパス構成と静的ファイル配信を追加した約 250 行の TypeScript。 新しいタイルソースを追加したくなったら {name}.pmtiles を R2 に置くだけで /tiles/{name}/… として配信される。

4. コスト設計 — 月額ほぼ 0 円のからくり

項目 単価 本件の見込み
R2 ストレージ $0.015/GB・月(無料枠 10GB) $0(実測 約1.7GB)
R2 読み取り(Class B) $0.36/100万件(無料枠 1,000万/月) $0
R2 書き込み(Class A) $4.50/100万件(無料枠 100万/月) $0(月次アップロードのみ)
Workers 無料枠 10万リクエスト/日 $0
CDN・DNS・TLS・エグレス 無料 $0
ポイント: Workers 無料プランが「実質の支出上限」として機能する

R2 には支出上限(ハードキャップ)を設定する機能がない。しかし本構成では R2 への読み取りは すべて Worker 経由であり、Workers 無料プランは 10万リクエスト/日を超えると 課金ではなくエラーで停止する。つまり Worker が構造的なボトルネックとなり、 R2 の読み取りオペレーションは無料枠に到達し得ない。 課金が発生し得るのはストレージの無料枠超過分だけ(現状は枠内なので 0 円)。

加えて R2 の使用量通知を 5GB(定常の約 2.5 倍)に設定し、二重アップロードや マルチパートの不完全パーツ残留といった異常を早期検知できるようにした。 トラフィックが恒常的に増えたら Workers Paid($5/月・1,000万リクエスト込み)へ移行する。

5. 構築ステップ全記録

  1. リポジトリ作成と Worker プロジェクトの雛形作成naughty-ghost/map-tiles

    Protomaps 公式の Cloudflare Worker 実装を取得してエンドポイント契約に合わせて改変し、 wrangler 設定・スタイル生成スクリプト・運用手順書(README)とともに push。 TypeScript 型チェックを通してからコミットした。

  2. Cloudflare 側の準備(ダッシュボード + wrangler login)

    R2 の有効化(無料枠でも支払い方法の登録が必須)と npx wrangler login による OAuth 認証。ドメイン naughty-ghost.org は既に Cloudflare 管理下だったため ゾーン追加は不要だった。

  3. R2 バケット作成(location hint: apac)

    npx wrangler r2 bucket create map-tiles --location apac

    利用者が日本のため、データの物理配置をアジア太平洋リージョンに寄せた。

  4. タイル生成 — Planetiler(Java 21(Corretto ポータブル版)/ 処理 13 分 / 出力 1.67GB)

    java -Xmx8g -jar planetiler.jar --download --area=japan --output=japan.pmtiles

    Geofabrik の日本抽出データ(2.3GB)と水域等の共通データを自動ダウンロードし、 日本全国 zoom 0–14 のベクトルタイルを生成。事前見積もりは「10GB・1〜2 時間」だったが、 実際は 1.67GB・13 分で完了した。

  5. スタイル・スプライト・グリフの準備(スクリプト 2 本で自動化)

    build-styles.mjs が現行スタイル 2 種を取得して sources / glyphs / sprite の URL を 自前 CDN に書き換え、スプライトも同時取得。続いて fetch-fonts.mjs が スタイルの参照する Migu 系 3 フォントスタック × 全 256 range = 768 グリフファイルを 取得(失敗 0 件、計 28MB)。

  6. R2 アップロード — rclone(計 786 オブジェクト)

    rclone copy japan.pmtiles r2:map-tiles/ --s3-no-check-bucket \
      --s3-upload-cutoff=100M --s3-chunk-size=100M
    rclone copy dist/ r2:map-tiles/ --s3-no-check-bucket

    1.67GB の本体は 100MB チャンクのマルチパートアップロード。wrangler の 300MiB 制限を超えるため rclone(S3 互換 API)を採用した。

  7. Worker デプロイ + カスタムドメイン(wrangler deploy 一発)

    wrangler.tomlcustom_domain = true を書いておくだけで、 デプロイと同時に tiles.naughty-ghost.org の DNS レコードと TLS 証明書が 自動作成された。Worker 本体のアップロードは gzip 後 10.6KB。

6. つまずきと解決

実作業で遭遇した問題は 4 件。いずれも原因を特定して解決済み。

事象 原因 解決
npm install が ERESOLVE で失敗 wrangler v4 の peer dependency が @cloudflare/workers-types の v5 系に更新されていた devDependencies を ^5.20260730.1 に更新
rclone lsd r2: が 403 バケット限定トークンではアカウント全体の ListBuckets が拒否される正常な挙動 バケット直接指定(rclone lsf r2:map-tiles)で確認するよう変更。エラーではなかった
アップロードが PutObject 403 で全滅 ①作成されたトークンが読み取り専用だった ②権限修正後もバケット存在確認(HeadBucket)が 403 になり、rclone がバケットを作成しようとして失敗 ①トークンを「オブジェクトの読み取りと書き込み」に修正 ②--s3-no-check-bucket フラグで存在確認をスキップ(バケット限定トークンの定石)
HEAD リクエストだけ 500 Worker のキャッシュ書き込み処理が HEAD と干渉(GET は全パス正常) MapLibre は GET しか使わないため、GET のみ許可(他メソッドは 405)に修正してデプロイ

7. 動作確認の結果

【合格】ライト / ダーク両スタイルを、ズーム 4 段階(z4 全国 / z10 都市 / z14 タイル最大 / z16 オーバーズーム)で実表示確認。

確認項目 結果
タイル配信(GET、全パス) 200 OK — TileJSON・タイル・スタイル・グリフ・スプライトすべて正常
日本語ラベル(Migu グリフ) 正常 — 市区町村名・丁目・道路名まで描画
スプライト(国道番号バッジ等) 正常
z16 オーバーズーム 正常 — z14 タイルの拡大描画(建物・小路まで精細)
帰属表示 正常 — TileJSON の attribution に © OpenMapTiles と © OpenStreetMap contributors の両方を確認
TileJSON の zoom 範囲 0–14 — 設計の期待値どおり
国外陸地(台湾・朝鮮半島等) 設計どおり非表示 — 日本抽出データのため。許容済みの既知の制限
takeshima / hoppo ソース 【未配置】TileJSON が 404(コンソールエラーのみで他地域の描画に影響なし)。データ確保を継続検討中

8. ライブデモ

以下は実際に稼働中の自前配信基盤(tiles.naughty-ghost.org)からタイルを読み込む MapLibre の地図。

9. ライセンスと帰属表示

対象 ライセンス 対応
タイルデータ(OSM) ODbL TileJSON の attribution で「© OpenStreetMap contributors」を表示(義務)
生成タイル(OpenMapTiles スキーマ) CC-BY 「© OpenMapTiles」のクレジットも必要と Planetiler 生成ログで判明。attribution に自動付与されていることを確認済み
Worker コード BSD-3-Clause(protomaps/PMTiles 由来) ソースコードに出典を明記
グリフ(Migu フォント) IPA フォントライセンス v1.0 再配布可を確認し README に記録
スタイル JSON・スプライト BSD 3-Clause(コード)/ CC BY 4.0(デザイン) osm-bright-ja は openmaptiles/osm-bright-gl-style、 maptiler-basic-ja は openmaptiles/maptiler-basic-gl-style 由来と確認。CC BY 4.0 が義務付ける OpenMapTiles へのクレジット表示は TileJSON の attribution 経由で充足済み

10. 運用 — 月次更新フロー

月 1 回、3 コマンド + キャッシュパージで最新の OSM データに差し替えられる。

# 1. 最新データで再生成(--download が japan-latest.osm.pbf を自動取得)
java -Xmx8g -jar planetiler.jar --download --area=japan --output=japan.pmtiles

# 2. R2 へ差し替えアップロード
rclone copy japan.pmtiles r2:map-tiles/ --s3-no-check-bucket \
  --s3-upload-cutoff=100M --s3-chunk-size=100M

# 3. Cloudflare ダッシュボード → キャッシュ → すべてをパージ

プレフィックス指定パージは Enterprise プラン限定のため全パージで運用(タイルは 24 時間で 再キャッシュされるため実用上の影響は軽微)。スタイル・グリフ・スプライトは変更時のみ再アップロード。 所要時間は生成 13 分 + アップロード数分。

11. まとめ

成果まとめ

外部依存だった地図タイル配信を、約半日の作業で自前基盤に置き換える準備が整った。 ランニングコストは実質 0 円(すべて無料枠内・課金経路も構造的に遮断)、 月次更新は15 分程度の定型作業。基盤は汎用設計のため、 今後の別アプリでもスタイル URL を参照するだけで同じ地図が使える。

更新履歴