第22章 - 综合实战从入库到上线

前面二十一章拆开讲规范、COG、STAC、TiTiler、缓存、成本。真正交付时它们是同一条链:任一环节的契约变了,下游全部失效。这一章按”准备 → 入库 → 编目 → 服务 → 前端 → 上线”走一遍,每步给命令和可判定的验收标准,最后给回滚方案。

链路长这样,箭头下方是每一步的契约:

原始影像              COG                  对象存储              编目                服务               前端
GeoTIFF  ──rio cogeo──▶ .tif ──HTTP PUT──▶ S3/OSS ──rio-stac──▶ STAC ──TiTiler──▶ tilejson ──▶ MapLibre
                       blocksize 512        Range 支持          href 绝对地址       tilesize 对齐       tileSize 对齐

三个”不可协商的契约”,先记住能省一半返工:

  1. COG 的内部块是 512×512 且带 overview。块尺寸与瓦片尺寸不匹配时,一次请求要碰十几个块,延迟从几十毫秒涨到几秒。
  2. 对象存储必须支持 HTTP Range(返回 206)。不支持时一个 COG 的瓦片请求会退化成整文件下载,成本直接失控。
  3. STAC Item 里 asset 的 href 必须是服务端可访问的绝对 URL,媒体类型必须带 COG 标记。相对路径要靠 self 链接解析,缺了就出现”Item 能读、瓦片全黑”(第 16、18 章都踩过)。

22.1 阶段一:固定版本

“昨天还好今天挂了”多半是版本漂移。整条链上真正需要钉住的是四个:rio-cogeo(产出 COG 的结构)、titiler(API 表面)、rasterio/GDAL(读取行为)、前端地图库(瓦片参数语义)。

python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate

pip install "rio-cogeo>=7,<8" "titiler-core>=2.4,<3" "titiler-extensions>=2.4,<3"
pip install "rio-tiler>=9,<10" "uvicorn[standard]"

rio cogeo --version
python -c "import titiler.core, rio_tiler; print(titiler.core.__version__, rio_tiler.__version__)"

TiTiler 2.x 要求 rio-tiler>=9.0,<10.0,这是硬约束:rio-tiler 9 删掉了 MultiBandReader,装错大版本会在 import 时就失败。2.0 还删了 tile_scale 与 @2x 后缀(第 12 章的变更表),照旧教程抄命令会拿到 404。

验收标准:三个版本号都能打印出来,且 titiler 的次版本号不小于 2.4。

22.2 阶段二:数据准备

两件事必须先做:统一坐标系与分辨率、确认 nodata。不同供应商的影像直接转 COG,会得到互相错位的瓦片,前端拼出来是”鬼影”。

# 基本信息:CRS、尺寸、波段、nodata
gdalinfo input.tif | head -40

# 需要做 Web 底图时转到 3857;已有的就不用转
gdalwarp -t_srs EPSG:3857 -tr 4 4 -r bilinear \
  -dstnodata 0 input.tif input_3857.tif

-dstnodata 0 这一步比想象中重要。没有 nodata 的影像在 TiTiler 里 nodata_type 是 None,透明区会按 0 参与渲染,出图一片黑(第 13 章的五个取值)。补 nodata 比事后用 colormap 把 0 映射成透明更稳。

验收标准:gdalinfo 里 Coordinate System 有明确 EPSG;NoData Value 已定义。

22.3 阶段三:转 COG

影响线上性能的参数只有四个:--blocksize、--overview-resampling、--cog-profile、--add-mask。

rio cogeo create \
  input_3857.tif output_cog.tif \
  --cog-profile deflate \
  --blocksize 512 \
  --overview-resampling average \
  --add-mask \
  --co PREDICTOR=2 --co ZLEVEL=6 \
  --use-cog-driver
参数 默认 本项目取值 理由
-p/--cog-profile deflate deflate 无损,GDAL 原生支持,分析可用
--blocksize 512 512 与 256/512 的瓦片输出匹配,一次请求碰 1~4 块
--overview-resampling nearest average 连续影像用面积平均;分类图必须留 nearest
--add-mask 关 开 让透明区有掩膜,避免黑边
--co PREDICTOR 无 2 水平差分,连续色调压缩率提升明显
--co ZLEVEL 6 6 压缩率与速度的平衡点
--use-cog-driver 关 开 用 GDAL 3.1+ 的 COG driver,IFD 排序更规范

--web-optimized 在需要”按 TMS 对齐 overview”时用,它会让 overview 的尺寸与 WebMercatorQuad 的层级严格对应,代价是转换慢一些。纯展示用途可以开;分析用途不必。

转完必须校验,不能”看起来能打开”就上传:

rio cogeo validate output_cog.tif
# output_cog.tif is a valid cloud optimized GeoTIFF

# CI 里用严格模式:缺 overview、块尺寸不一致都会失败
rio cogeo validate --strict output_cog.tif

# 看结构
rio cogeo info output_cog.tif

验收标准:validate --strict 通过;info 里 COG: True、Tiled: True、BlockSize 512x512、至少 3 层 overview;Internal Mask: True 或 Alpha Band: True。

22.4 阶段四:上传对象存储

aws s3 cp output_cog.tif \
  s3://geo-bucket/cog/beijing-ortho/2026/beijing_ortho_2026.tif \
  --endpoint-url https://oss.example.com \
  --content-type "image/tiff; application=geotiff; profile=cloud-optimized" \
  --cache-control "public, max-age=31536000, immutable"

三个头部各有作用:Content-Type 决定浏览器直连时的行为;Cache-Control 让 COG 本身可长缓存(对象不可变);绝不能设 Content-Encoding: gzip——TIFF 内部已经按块压缩,再套一层 HTTP 压缩会让 Range 请求失效。

Range 必须验证,这是整条链路最容易翻车的地方:

# 取前 16 字节,期望 206
curl -s -D - -o /dev/null -H "Range: bytes=0-15" \
  "https://oss.example.com/cog/beijing-ortho/2026/beijing_ortho_2026.tif" | head -12

# HTTP/2 206
# content-range: bytes 0-15/2147483648
# accept-ranges: bytes

看到 200 而不是 206,说明存储或 CDN 忽略了 Range。此时先查 CDN 是否配了”回源整文件”,再查 S3 兼容层是否支持分段读。

验收标准:206 + 正确的 Content-Range 总长度;传输字节数等于 16,不是整个文件。

22.5 阶段五:编目

一个影像一个 Item,assets 指向 COG。用 rio-stac 生成,不要手写 JSON:

from rio_stac.stac import create_stac_item

item = create_stac_item(
    "s3://geo-bucket/cog/beijing-ortho/2026/beijing_ortho_2026.tif",
    id="beijing_ortho_2026",
    collection="beijing-ortho",
    input_datetime="2026-04-01T00:00:00Z",
    asset_name="visual",
    asset_href="https://oss.example.com/cog/beijing-ortho/2026/beijing_ortho_2026.tif",
    asset_media_type="image/tiff; application=geotiff; profile=cloud-optimized",
    with_proj=True,
    with_raster=True,
    with_eo=True,
)
item.set_self_href(
    "https://catalog.example.com/collections/beijing-ortho/items/beijing_ortho_2026"
)

import json
from pathlib import Path

Path("beijing-ortho").mkdir(exist_ok=True)
Path("beijing-ortho/beijing_ortho_2026.json").write_text(
    json.dumps(item.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8"
)

三个字段容易写错:stac_version 必须是 1.1.0;asset_media_type 必须带 profile=cloud-optimized(否则 STACReader 的媒体类型白名单会把它筛掉);set_self_href() 不能省,相对路径要靠它解析。

规模上去之后换 pgstac(第 9 章):

docker run -d --name stac -p 8081:8081 \
  -e POSTGRES_HOST_READER=db -e POSTGRES_HOST_WRITER=db \
  -e POSTGRES_USER=postgres -e POSTGRES_PASS=postgres -e POSTGRES_DBNAME=postgis \
  ghcr.io/stac-utils/stac-fastapi-pgstac:latest

验收标准:python -m json.tool item.json 能解析;assets.visual.href 用 curl -I 返回 200 且支持 Range;bbox 落在 [-180,180]×[-90,90] 内;用 stac-validator 过一遍 schema。

22.6 阶段六:起服务

# app.py
from fastapi import FastAPI

from rio_tiler.io import Reader
from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers
from titiler.core.factory import TilerFactory

app = FastAPI(title="COG Tile Service")
cog = TilerFactory(reader=Reader, add_ogc_maps=True)
app.include_router(cog.router, prefix="/cog", tags=["COG"])
add_exception_handlers(app, DEFAULT_STATUS_CODES)
uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4

启动后按顺序验证,每一步都有明确预期:

BASE="http://127.0.0.1:8000/cog"
SRC="https://oss.example.com/cog/beijing-ortho/2026/beijing_ortho_2026.tif"

# 1) 元数据:确认 dtype、nodata_type、band_descriptions
curl -s "$BASE/info?url=$SRC" | jq '{bounds, dtype, nodata_type}'

# 2) TileJSON:注意 tiles 里已经带了 url 参数
curl -s "$BASE/WebMercatorQuad/tilejson.json?url=$SRC&rescale=0,3000" | jq '{minzoom, maxzoom, tiles}'

# 3) 一张瓦片
curl -s -o tile.png -w '%{http_code} %{content_type} %{size_download}\n' \
  "$BASE/tiles/WebMercatorQuad/10/843/388.png?url=$SRC&rescale=0,3000"

第 2 步返回的 tiles 长这样(关键在于 url 与 rescale 都在里面):

{
  "minzoom": 6,
  "maxzoom": 18,
  "tiles": ["http://127.0.0.1:8000/cog/tiles/WebMercatorQuad/{z}/{x}/{y}?url=https%3A%2F%2F...&rescale=0%2C3000&tilesize=512"]
}

这一点和第 13 章的实现一致:tilejson 端点会把请求里除 tilematrixsetid、tile_format、minzoom、maxzoom 之外的 query 全部带到瓦片模板上,缺省时补 tilesize。所以前端直接吃 tj.tiles 就能出图,不需要自己再拼 url——网上”tilejson 不含 url”的说法是 0.x 时代的记忆,2.x 不成立。

验收标准:/info 的 bounds 与 Item 的 bbox 一致;瓦片返回 200 且尺寸等于 tilesize(默认 512,TMS 矩阵 256 时是 256);同一瓦片连续两次请求,第二次明显更快(GDAL/VSI 缓存生效)。

22.7 阶段七:接前端

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <title>STAC + COG + TiTiler</title>
  <link href="https://unpkg.com/maplibre-gl@6/dist/maplibre-gl.css" rel="stylesheet" />
  <style>body { margin: 0; } #map { height: 100vh; }</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/maplibre-gl@6/dist/maplibre-gl.js"></script>
<script>
  const TITILER = "http://127.0.0.1:8000";
  const COG = "https://oss.example.com/cog/beijing-ortho/2026/beijing_ortho_2026.tif";

  const map = new maplibregl.Map({
    container: "map",
    style: {
      version: 8,
      sources: {},
      layers: [{ id: "bg", type: "background", paint: { "background-color": "#eef1f5" } }],
    },
    center: [116.35, 39.9],
    zoom: 9,
  });

  map.on("load", async () => {
    const tjUrl =
      `${TITILER}/cog/WebMercatorQuad/tilejson.json` +
      `?url=${encodeURIComponent(COG)}&rescale=0,3000&tilesize=512`;
    const tj = await fetch(tjUrl).then((r) => {
      if (!r.ok) throw new Error(`tilejson ${r.status}`);
      return r.json();
    });

    map.addSource("cog", {
      type: "raster",
      tiles: tj.tiles,          // 已包含 url 与渲染参数
      tileSize: 512,            // 与 tilesize=512 成对
      minzoom: tj.minzoom,
      maxzoom: tj.maxzoom,
      bounds: tj.bounds,
      attribution: "© 影像提供方",
    });
    map.addLayer({ id: "cog-layer", type: "raster", source: "cog" });
    map.fitBounds(tj.bounds, { padding: 40, duration: 0 });
  });

  map.on("error", (e) => console.error("[map]", e.error && e.error.message));
</script>
</body>
</html>

tileSize 与 tilesize 必须成对:请求 tilejson 时带 tilesize=512,前端写 tileSize: 512;只改一边就会模糊(第 19 章的对照表)。bounds 让 MapLibre 在数据范围外不请求瓦片,直接消掉大部分 404。

验收标准:首屏 3 秒内出现影像;缩放时无空洞;map.on("error") 无输出;Network 里瓦片请求的 query 带 url。

22.8 阶段八:缓存与鉴权

proxy_cache_path /var/cache/nginx/tiles levels=1:2 keys_zone=tiles:100m
                 max_size=10g inactive=7d use_temp_path=off;

location /cog/tiles/ {
    proxy_pass http://titiler:8000;
    proxy_cache tiles;
    # 必须包含查询串,否则不同 url 会串瓦片
    proxy_cache_key "$scheme$request_method$host$uri$args";
    proxy_cache_valid 200 206 7d;
    proxy_cache_valid 404 1m;
    add_header X-Cache-Status $upstream_cache_status;
}

proxy_cache_key 那行是重点:默认 key 不含查询串时,不同 url= 的请求会命中同一份缓存,前端看到的影像张冠李戴。加 $args 之后,参数顺序不同仍算不同键,所以客户端要固定拼参顺序(第 17 章)。

鉴权放在网关或网关前置的 CDN,令牌走 Authorization 头;只有确实无法加头时才退化为 query 里的短期 ticket,并且接受它污染缓存键的代价。

验收标准:同一瓦片第二次请求 X-Cache-Status: HIT;无 token 返回 401;换一份 COG 的请求不命中旧缓存。

22.9 上线检查清单

阶段 检查项 判定
入库 COG 校验 rio cogeo validate --strict 通过
入库 块与金字塔 BlockSize 512x512,overview ≥ 3 层
存储 Range Range: bytes=0-15 返回 206
存储 缓存头 Cache-Control 含 max-age,无 Content-Encoding
编目 版本 stac_version 为 1.1.0
编目 资产类型 含 profile=cloud-optimized
编目 绝对地址 assets.*.href 是 https 绝对 URL
服务 瓦片 200,尺寸与 tilesize 一致
服务 错误语义 范围外 404(TileOutsideBounds),不返回 500
服务 缓存头 响应含 Cache-Control: public, max-age=...
前端 首屏 3 秒内出图
前端 一致性 tileSize 与 tilesize 成对
缓存 命中率 稳定运行后 > 80%
成本 单瓦片传输量 平均远小于 COG 体积(说明 Range 生效)

最后一行是成本红线:如果监控看到单瓦片平均传输量在 MB 级,说明 Range 没生效,整文件在被反复下载。这类问题通常在上线后第一周才从账单上发现。

22.10 排错

现象 原因 修法
瓦片全黑或全透明 nodata_type 为 None,透明区按 0 渲染 重新生成 COG 补 nodata 或 --add-mask
瓦片模糊 分类图用了 average/bilinear 建金字塔 用 nearest 重新生成
422 tileMatrixSetId 拼错,或 bidx 写成逗号串 用 WebMercatorQuad;bidx 重复传参
拼接有缝 buffer 未裁边,或源数据间有空隙 出图不加 buffer;生产阶段做重叠
首次慢、之后正常 冷缓存 上线前遍历 z0–z6 预热
浏览器 CORS 报错 设了 crossOrigin 但服务端未放行 配置 TITILER_API_CORS_ORIGINS
500 且日志有 RasterioIOError 凭证或路径错 检查读取角色的 s3:GetObject
tilejson 里的地址是内网 未设 root_path 或代理改了 Host 设 TITILER_API_ROOT_PATH

22.11 回滚

上线失败时要能退回去,所以三个东西必须版本化:

对象 版本化方式 回滚动作
COG 文件 key 里带日期或哈希 切回旧 key
STAC Item 目录用 git;pgstac 靠分区 恢复旧 Item / 重建分区
MosaicJSON 文件名带内容哈希 指向旧文件
服务镜像 tag + digest 回滚 Deployment
前端配置 与 tilejson URL 解耦 改配置不改代码

把”缓存失效”变成”新 URL”是最省事的回滚策略:所有对象不可变、命名带版本,回滚就是改一个指针。反过来,原地覆盖文件再清 CDN,是最贵的做法——延迟高、易漏、还无法回退到任意历史版本。

22.12 本章要点

要点 事实
COG 关键参数 --blocksize 512、--overview-resampling、--add-mask、--co PREDICTOR=2
校验 rio cogeo validate --strict,CI 必用
Range 验证 Range: bytes=0-15 必须返回 206
STAC Item stac_version 为 1.1.0;资产类型带 profile=cloud-optimized
媒体类型白名单 写错会让 STACReader 筛掉资产
TiTiler 2.x 路由 /cog/tiles/{tms}/{z}/{x}/{y}、/cog/{tms}/tilejson.json
tilejson 透传 tiles 里已含 url 与渲染参数,前端不必补
尺寸成对 请求 tilesize=512 时前端写 tileSize: 512
缓存键 反代 key 必须含查询串
回滚 对象不可变 + 命名带版本,回滚即改指针