chundev
日期:2026-05-17 技術棧:maplibre-gl · PMTiles · nginx · k3s · planetiler
網頁地圖(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 是一種單一檔案的圖磚封存格式,概念上像 ZIP —— 一個 .pmtiles 檔裡面包了成千上萬張圖磚。它的關鍵設計是為「雲端/靜態檔」最佳化:
z/x/y 對應到檔案裡的哪一段 byte range。client 先讀 header,要哪張圖磚就發一個 Range: header 只抓那一段 —— 不需要把整個檔案下載下來。實務上的效果:一個 .pmtiles 檔 + 一台支援 range request 的靜態檔伺服器 = 完整的圖磚服務。 不需要資料庫、不需要 tile server process。對 air-gap 部署來說這是決定性的優勢 —— 要帶進內網的就是「一個檔案」。
底圖圖磚有兩種,離線封裝的方式也不同:
| 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 裡其實非常單純 —— 就是「一台 nginx + 一顆掛了圖磚檔的 PVC」:
瀏覽器 (maplibre-gl)
│ pmtiles:// 協定 → HTTP Range Request
▼
┌───────────────┐
│ Ingress │ /tiles 前綴路由
└──────┬────────┘
▼
┌───────────────┐
│ tile nginx │ Deployment (靜態檔伺服器, 8080)
│ Service │ 設定來自 ConfigMap
└──────┬────────┘
▼ 唯讀掛載 /srv/tiles
┌───────────────┐
│ PVC │ NFS-backed,存 .pmtiles / glyphs / sprites
└───────────────┘
各元件:
nginx:alpine,純靜態檔伺服器,資源需求極小(64Mi RAM 等級)。nginx 對靜態檔預設就支援 range request,不需特別設定。ConfigMap:放 nginx 設定。重點是用 alias 把 URL 前綴對應到 PVC 掛載點:
server {
listen 8080;
types {
application/x-protobuf pbf;
application/octet-stream pmtiles;
}
location = / { return 200 "ok\n"; } # readiness probe 用
location /tiles/ {
alias /srv/tiles/; # /tiles/x.pmtiles → /srv/tiles/x.pmtiles
expires 7d;
}
}
用 alias(而非 root)剝掉 /tiles 前綴,Ingress 那層就不需要 rewrite annotation。
ReadWriteMany、NFS-backed 的儲存。圖磚是大型唯讀冷資料,交給 NFS 很合適;Deployment 以 readOnly: true 掛載。/tiles 的 Prefix path 指向 tile Service。這樣圖磚跟 app 同源,前端用 root-relative 的 /tiles/... 即可,免 CORS。圖磚怎麼進 PVC:build 在連網的打包機完成(產出 .pmtiles 等大型 binary,不進 git),再透過一個臨時 loader pod 掛上同一顆 PVC、kubectl cp 把檔案複製進去。
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.json 的 sources 指向 pmtiles://...、glyphs 與 sprite 指向自架路徑(如 /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 + nginx 靜態檔 | tileserver-gl 之類的 tile server | |
|---|---|---|
| 元件 | 一台 nginx + 一個檔案 | 一個常駐服務 + 設定 + 樣式管理 |
| 狀態 | 無狀態,可任意水平擴展 | 有 process 要顧 |
| Air-gap | 帶「一個檔案」進內網即可 | 要帶整套服務 image |
| 圖磚更新 | 換檔案 | 換檔案 / reload |
| 適用 | 圖磚相對固定的離線部署 | 需要動態產製、即時樣式運算 |
對「圖磚預先準備好、部署後不常變」的離線情境,PMTiles 靜態檔幾乎總是更簡單的選擇。
alias 對應前綴可省掉 Ingress 的 rewrite。NEXT_PUBLIC_* 是 build 期 inline 的,離線/線上模式必須在 image build 時用 build-arg 決定,不能等到 runtime。kubectl cp 灌進 PVC。