Share Notes

chundev

View the Project on GitHub latteouka/share-notes

在 k3s 上自架離線地圖底圖:PMTiles + nginx 架構

日期:2026-05-17 技術棧:maplibre-gl · PMTiles · nginx · k3s · planetiler


TL;DR

網頁地圖(maplibre-gl)的底圖預設從 CDN 線上抓圖磚,在 air-gapped 內網會直接掛掉。自架離線底圖最輕量的做法是:把整套圖磚封裝成單一一個 PMTiles 檔,當成靜態檔用 nginx 提供,靠 HTTP Range Request 讓瀏覽器只抓需要的片段 —— 全程不需要跑專門的 tile server。本文說明這套架構在 k3s 上怎麼組起來、raster 與 vector 兩種底圖的差異、以及「每個底圖獨立決定線上/離線」的混合設計。


背景:為什麼底圖要自架

一張 web 地圖由兩層組成:底圖(街道、地形、地名)和疊加層(自己的標記、熱區、軌跡)。疊加層是自己的資料;底圖則來自圖磚服務 —— 預設多半直接打公開 CDN(CARTO、OpenStreetMap、Google 等)。

只要部署環境不能對外連網,這個預設就會壞掉:底圖空白、整張地圖不可用。常見情境是政府/金融的內網系統,或對資安等級有要求的 air-gapped 環境。解法只有一條:把底圖圖磚帶進內網、自己 serve。

問題是「圖磚」動輒是幾百萬到上億個小檔案(每個 zoom level 的每個 z/x/y 一張)。傳統作法要嘛跑一套 tile server(tileserver-gl 之類),要嘛把上百萬個檔案攤在磁碟上。兩種都很重。PMTiles 把這件事變簡單。


PMTiles:把上百萬張圖磚塞進一個檔案

PMTiles 是一種單一檔案的圖磚封存格式,概念上像 ZIP —— 一個 .pmtiles 檔裡面包了成千上萬張圖磚。它的關鍵設計是為「雲端/靜態檔」最佳化:

實務上的效果:一個 .pmtiles 檔 + 一台支援 range request 的靜態檔伺服器 = 完整的圖磚服務。 不需要資料庫、不需要 tile server process。對 air-gap 部署來說這是決定性的優勢 —— 要帶進內網的就是「一個檔案」。


raster vs vector:兩種底圖,封裝方式不同

底圖圖磚有兩種,離線封裝的方式也不同:

  Raster 圖磚 Vector 圖磚
內容 預先渲染好的圖片(PNG/JPG) 幾何資料(道路線、建物多邊形…),樣式由前端套
樣式 固定,圖片就是最終樣子 可換色、可改 —— style.json 決定
額外資產 需要 glyphs(字型 PBF)+ sprites(圖示)
體積 較大(每張都是完整圖片) 較小(去重 + 純資料)
來源範例 政府 WMTS 服務(如國土測繪中心的通用版電子地圖) OpenStreetMap → planetiler 產製

Raster 離線化:從一個 WMTS/XYZ 服務把目標範圍的圖磚整批預抓下來,存進 MBTiles(一個 SQLite 檔),再 pmtiles convert.pmtiles

Vector 離線化:用 planetiler 從 OSM 資料產製 OpenMapTiles schema 的向量圖磚,直接輸出成 .pmtiles。樣式則用一份開源 style.json(如 CARTO Positron),把裡面的 sources / glyphs / sprite 全部改指向自架路徑。

⚠️ 規模陷阱:raster 一個國家範圍很可行;但 vector 如果要全球涵蓋,planetiler 建一次 planet 需要的暫存磁碟是 planet.osm.pbf(現約 80GB)的 10 倍 ≈ 800GB,而且要跑數小時。一般筆電做不到 —— 全球向量底圖要嘛在大磁碟的伺服器上 build,要嘛改用別人預建好的 planet PMTiles(如 Protomaps 的每日 build)用 pmtiles extract 透過 range request 只抽需要的 zoom 範圍。


k3s 上的架構

離線圖磚服務在 k3s 裡其實非常單純 —— 就是「一台 nginx + 一顆掛了圖磚檔的 PVC」:

        瀏覽器 (maplibre-gl)
              │  pmtiles:// 協定 → HTTP Range Request
              ▼
        ┌───────────────┐
        │    Ingress    │   /tiles 前綴路由
        └──────┬────────┘
               ▼
        ┌───────────────┐
        │ tile nginx    │   Deployment (靜態檔伺服器, 8080)
        │  Service      │   設定來自 ConfigMap
        └──────┬────────┘
               ▼  唯讀掛載 /srv/tiles
        ┌───────────────┐
        │     PVC       │   NFS-backed,存 .pmtiles / glyphs / sprites
        └───────────────┘

各元件:

圖磚怎麼進 PVC:build 在連網的打包機完成(產出 .pmtiles 等大型 binary,不進 git),再透過一個臨時 loader pod 掛上同一顆 PVC、kubectl cp 把檔案複製進去。


前端:maplibre 怎麼讀 PMTiles

maplibre-gl 本身不認得 .pmtiles,要掛一個協定 plugin(module 載入時註冊一次即可):

import maplibregl from "maplibre-gl";
import { Protocol } from "pmtiles";

const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);

之後 style 裡的 source 用 pmtiles:// 開頭,maplibre 就會走這個協定、自動發 range request:

// raster 底圖:一份很小的 style,宣告一個 raster source 即可
const rasterStyle = {
  version: 8,
  sources: {
    base: {
      type: "raster",
      url: "pmtiles://https://your-host/tiles/base.pmtiles",
      tileSize: 256,
    },
  },
  layers: [{ id: "base", type: "raster", source: "base" }],
};

vector 底圖則是把整份 style.jsonsources 指向 pmtiles://...glyphssprite 指向自架路徑(如 /tiles/glyphs/{fontstack}/{range}.pbf)。


進階:每個底圖獨立決定線上/離線

「離線」不一定要全有全無。實務上常見的混合需求:本地底圖(raster,範圍小、好離線)走離線;跨國底圖(vector 全球,難離線)走線上。

做法是把「線上/離線」變成per-basemap 的判斷,而不是一個全域開關。用一個環境變數列出哪些底圖走離線:

OFFLINE_BASEMAPS=""           # 全部線上
OFFLINE_BASEMAPS="local"      # 本地底圖離線、其他線上
OFFLINE_BASEMAPS="local,osm"  # 兩者皆離線

getBasemapStyle(id) 對每個底圖各自查表:在清單裡 → 回傳 pmtiles:// 的離線 style;不在 → 回傳線上 CDN 的 style。好處是程式碼兩條路徑都保留,未來要把某個底圖從線上翻成離線,只是改一個環境變數,不用動 code。

部署面則用 build 參數帶入(注意 NEXT_PUBLIC_* 這類前端變數是 build 期 inline 進 bundle 的,要在 image build 時就決定):不同環境 build 出不同模式的 image —— 內網客戶端走離線、有外網的展示環境走線上。


為什麼選「PMTiles 靜態檔」而非 tile server

  PMTiles + nginx 靜態檔 tileserver-gl 之類的 tile server
元件 一台 nginx + 一個檔案 一個常駐服務 + 設定 + 樣式管理
狀態 無狀態,可任意水平擴展 有 process 要顧
Air-gap 帶「一個檔案」進內網即可 要帶整套服務 image
圖磚更新 換檔案 換檔案 / reload
適用 圖磚相對固定的離線部署 需要動態產製、即時樣式運算

對「圖磚預先準備好、部署後不常變」的離線情境,PMTiles 靜態檔幾乎總是更簡單的選擇。


學到的事


參考資料