第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 对齐
三个”不可协商的契约”,先记住能省一半返工:
- COG 的内部块是 512×512 且带 overview。块尺寸与瓦片尺寸不匹配时,一次请求要碰十几个块,延迟从几十毫秒涨到几秒。
- 对象存储必须支持 HTTP Range(返回
206)。不支持时一个 COG 的瓦片请求会退化成整文件下载,成本直接失控。 - 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 必须含查询串 |
| 回滚 | 对象不可变 + 命名带版本,回滚即改指针 |