第01章 - 影像云原生全景与架构定位
一景 30 米分辨率的多光谱影像,10000 × 10000 像素、3 个波段、uint16,未压缩约 570 MB,压成 COG 后仍有 300 MB 上下。业务方只想看某条河谷 2 km × 2 km 的假彩色合成。如果影像还躺在传统分发体系里,这个需求的处理方式是:把整景下载下来,用 QGIS 打开,裁切,导出 PNG。10 MB/s 的线路要半分钟,磁盘上多了一份临时文件,第二天同样的需求再来一次。
云原生影像要解决的就是这件事:让“看一小块”只传输一小块的字节。做到这一点需要四个构件同时成立——对象存储提供按字节寻址的底座,COG 让文件内部可以被随机定位,STAC 让“哪一景、哪一块、什么波段”变成可检索的元数据,动态瓦片服务把这三者拼成一次 HTTP 请求。缺任何一环,链路都退化成“下载整景”。
这一章先把四个构件各自的职责边界、当前版本基线和彼此之间的接口讲清楚,后面十章都是在这个骨架上填细节。
1.1 传统分发为什么贵
把影像发布成 Web 服务,行业里长期有两条路,两条路都有结构性成本。
第一条是 WMS/WMTS 切片农场。为了让任意缩放级别都能秒开,需要预先渲染每一级瓦片。一景 30 米分辨率的影像铺到 z18,瓦片数量是百万级;全国范围、多时相就是十亿级对象和对应的存储与渲染机队。数据一旦更新,切片要重跑;用户只访问过 3% 的范围,剩下 97% 的瓦片照样占着存储。
第二条是 整景下载 + 本地处理。省了切片成本,但每次分析都要搬运全量数据。一景 1.1 GB,一个县级的年度分析可能要几百景,网络和临时存储都成了瓶颈。
两条路的共同问题是:数据被复制成了服务形态。原始影像一份、切片一份、有时候还有 COG 一份,三份数据要同步、要校验、要计费。
云原生路线把这件事反过来:只保留一份原始 COG,服务在读的时候现算。代价从“预计算 + 存储”转移到了“每次请求多几次 HTTP 往返 + 一点 CPU”。当请求量不大、或者请求高度集中在少数区域时,这个交换非常划算;当请求量极大且区域固定时,缓存层会把成本再压回去。第 17、20 章会算这笔账,这里先记住结论:云原生不是无条件更便宜,它把固定成本换成了可变成本。
1.2 四个构件与它们的边界
1.2.1 对象存储:唯一的数据底座
对象存储(S3、OSS、COS、MinIO)提供三件影像服务真正需要的东西:HTTP GET 可寻址、字节范围读取、以及几乎无限的容量与按量计费。它不提供目录树语义(前缀不是目录)、不提供文件锁、不支持原地修改(对象只能整体覆盖)。
这条“不支持原地修改”的约束会一路传导到架构上:COG 一旦上传就不再改写,需要修正就重写一个新对象;STAC Item 的 href 指向的对象因此是稳定的,可以放心做 CDN 缓存和长 TTL。第 6 章会展开 Range 语义与 CDN 行为。
1.2.2 COG:让文件内部可随机定位
COG(Cloud Optimized GeoTIFF)不是新格式,它是 GeoTIFF 的一种内部组织约定,2023 年被 OGC 发布为标准:OGC 21-026,版本 1.0,批准日期 2023-05-08,发布日期 2023-07-14,类别是 OGC Implementation Standard。
标准要求里最关键的几条,直接决定了“能不能只读一小块”:
- 所有 IFD(Image File Directory)必须按瓦片组织,不能用条带(strip);
- 至少有一个全分辨率 IFD,其后跟着逐级缩小的降分辨率子文件(overview),每一级的缩小倍数在 2 到 10 之间;
- 最后一级 overview 必须小到只剩一个瓦片(TilesAcross 或 TilesDown 等于 1);
- 地理参考只写在全分辨率 IFD 里,overview 不重复写;
- 文件超过 4 GB 必须用 BigTIFF。
再加上一条推荐性条款(Recommendation 3):IFD 与瓦片数据要按“全分辨率 IFD → 各级 overview IFD → 最小 overview 瓦片 → … → 全分辨率瓦片”的顺序排列。这条不是强制,但它决定了打开文件需要几次 HTTP 请求——按推荐顺序排,一次 16 KB 的请求就能把所有 IFD 读完;排得乱,可能要三次往返。OGC 文档给的参照数字是:S3 单次往返 20–30 ms,叠加传输后常见 150 ms,三次就是额外约 540 ms 的感知延迟。
第 3 章会逐条拆这些要求,并用 rio-cogeo 的校验代码对照说明。
1.2.3 STAC:让文件可被检索
STAC(SpatioTemporal Asset Catalog)是一套 JSON 元数据规范,把“一景影像”描述成一个 GeoJSON Feature,附带时间、空间范围、资产列表。当前核心规范版本是 1.1.0(2024-09-10 发布),由四部分组成:Catalog(目录)、Collection(集合)、Item(条目),以及公共部分(Link、Asset、Common Metadata)。
STAC 解决的是“找”的问题,不是“读”的问题。它规定 Item 的 assets 里放什么 href,但不规定这个 href 指向的文件内部怎么组织——那个职责在 COG 那边。这条分工很重要:STAC 可以描述任何格式的资产(COG、NetCDF、Zarr、GeoParquet),COG 只负责让其中一种格式变得可流式读取。
与之配套的 STAC API 1.0.0(2023-04-24 发布)定义了 /search 等端点。注意版本号错位:数据规范是 1.1.0,API 规范仍是 1.0.0,两者的 conformance URI 里写的都是 v1.0.0。这个错位在写配置和做兼容性判断时经常踩到,第 10 章会专门说。
1.2.4 动态瓦片:让像素按需渲染
TiTiler 是一个基于 FastAPI + Rasterio/GDAL 的动态瓦片服务。它接收 z/x/y 与一个 COG 的 URL,在服务端读出对应窗口、做重采样与拉伸、返回 PNG/JPEG/WebP。当前核心包 titiler-core 的版本是 2.4.0,生态里还有 titiler-mosaic(MosaicJSON 镶嵌)与 titiler-extensions(算法、缓存等)。
它不存数据、不建索引,是一个无状态计算层。无状态意味着可以随便水平扩容,也意味着每次请求都要重新走一遍“读 COG 头 → 定位瓦片 → 解码 → 重采样”,所以缓存(第 17 章)和 COG 布局(第 3、5 章)对它的性能影响极大。
1.3 全链路架构
把四个构件串起来,一条完整的链路是这样:
┌──────────┐ ┌───────────┐ ┌──────────────┐ ┌─────────────┐
│ 原始影像 │──▶│ COG 生产 │──▶│ 对象存储 │◀──│ CDN / 缓存 │
│ GeoTIFF │ │ rio-cogeo │ │ S3 / OSS │ │ CloudFront │
└──────────┘ └─────┬─────┘ └──────┬───────┘ └──────┬──────┘
│ │ Range GET │
│ 元数据 │ │
▼ ▼ ▼
┌───────────┐ ┌──────────────┐ ┌─────────────┐
│ STAC 编目 │──▶│ STAC API │ │ TiTiler │
│ pystac │ │ stac-fastapi │ │ 动态瓦片 │
└───────────┘ │ + pgstac │ └──────┬──────┘
└──────┬───────┘ │
│ 检索 │ PNG/JPEG
▼ ▼
┌──────────────────────────────┐
│ 前端地图 / STAC Browser │
└──────────────────────────────┘
链路上有三条独立的数据流,容易混淆,值得单独指出:
- 元数据流:影像 → STAC Item(JSON)→ 索引库(PostgreSQL/pgstac)→ 检索接口。数据量小,更新频繁。
- 像素流:客户端 → TiTiler → 对象存储的 Range 请求 → 瓦片字节。数据量大,每次请求只碰几 KB 到几百 KB。
- 浏览流:STAC Browser 读 STAC API 或静态目录,展示缩略图与元数据,点开某个资产时才触发像素流。
三条流的故障表现完全不同:元数据流挂了是“搜不到”,像素流挂了是“搜到了但图裂”,浏览流挂了是“页面打不开”。排障时先分清是哪条流,比盲目看日志有效得多。
1.3.1 一次瓦片请求到底传了多少字节
用一个具体例子算清楚。沿用开头那景:10000 × 10000 像素、3 波段 uint16、512 × 512 瓦片、DEFLATE 压缩、5 级 overview,COG 约 300 MB。
客户端请求 z14 的某块瓦片,TiTiler 需要:
- 读文件头与 IFD:按 OGC 推荐布局约 2–6 KB(标准给的参照是 4096×4096、4 级 overview 时 5 个 IFD 及 TileOffsets 落在前 6 KB);
- 定位到对应 overview 层(WebMercator 在 z14 赤道处约 9.55 米/像素,30 米源数据约需 3 倍降采样,即第 2 级;纬度越高每像素地面范围越小,所需倍数越大,60°N 约 6 倍),读 1–4 个 512×512 瓦片块,uint16 压缩后每块约 60–150 KB;
- 解码、重采样到 256×256、拉伸、编码成 PNG,约 40–120 KB 返回。
总计从对象存储读 60 KB–600 KB(1 个块到 4 个块,加上几 KB 头部),返回给浏览器 40–120 KB。相比下载 300 MB,是两到三个数量级的差别。
这个估算里最容易被忽略的是“读几个块”:如果请求的瓦片刚好跨在 COG 块边界上,就要读 4 个块而不是 1 个。块大小与瓦片网格对齐能把这件事的概率降到最低,第 5 章讲 –blocksize 与 –tms 时会回到这里。
1.4 版本基线
下表是本教程写作时核实过的版本,代码示例按这些版本验证。版本差异导致的行为变化,会在对应章节单独标注。
| 组件 | 版本 | 核实来源 | 影响范围 |
|---|---|---|---|
| STAC 核心规范 | 1.1.0(2024-09-10) | stac-spec 仓库 CHANGELOG | Item/Collection/Catalog 字段、bands 进入 core |
| STAC API 规范 | 1.0.0(2023-04-24) | stac-api-spec CHANGELOG | /search 参数、conformance URI |
| COG 标准 | OGC 21-026 v1.0(2023-07-14) | docs.ogc.org/is/21-026 | 文件布局硬约束 |
| GDAL | 3.13.3 | PyPI gdal | COG driver、/vsicurl/ 行为 |
| rio-cogeo | 7.0.4 | PyPI 与仓库 init.py | rio cogeo 子命令与校验规则 |
| pystac | 1.16.0 | PyPI | 目录构建 API |
| stac-fastapi | 7.1.1 | 仓库 VERSION | 分层结构、扩展命名空间 |
| stac-fastapi-pgstac | 7.2.1 | PyPI | 后端装配、环境变量 |
| pgstac | 0.10.1 | GitHub release | 表结构、SQL 函数 |
| titiler-core | 2.4.0 | PyPI | 瓦片端点 |
| STAC Browser | 5.2.0 | GitHub release | 部署模式、配置项 |
两个 Python 版本约束值得提前记住:rio-cogeo 7.x 要求 Python ≥ 3.11,stac-fastapi 7.x 也要求 ≥ 3.11。想用 3.10 跑这套工具链会在安装阶段就失败。
1.5 最小可跑示例
四个构件各给一段能立刻验证的代码。这些示例不需要先读完后面十章。
1.5.1 只读文件头,判断是不是 COG
对象存储上的 COG 可以用一次 Range 请求检查布局。前 1 KB 里能看到 TIFF 魔数、第一个 IFD 的偏移量,以及 GDAL 3.1 之后写入的 ghost 区(GDAL_STRUCTURAL_METADATA)。
# 只取前 1 KB,不下载整个文件
curl -s -r 0-1023 "https://example-bucket.s3.amazonaws.com/landsat/scene.tif" -o head.bin
# TIFF 魔数:小端 II (0x4949) 或大端 MM (0x4d4d),第三、四字节是 42
xxd -l 16 head.bin
# 00000000: 4949 2a00 0800 0000 2d00 0000 0a00 0000 II*.........
# 看 ghost 区(GDAL >= 3.1 写入,用于标记布局是否被破坏)
strings head.bin | head -20
# GDAL_STRUCTURAL_METADATA_SIZE=000140 bytes
# LAYOUT=IFDS_BEFORE_DATA
# BLOCK_ORDER=ROW_MAJOR
# BLOCK_LEADER=SIZE_AS_UINT4
# BLOCK_TRAILER=LAST_4_BYTES_REPEATED
# KNOWN_INCOMPATIBLE_EDITION=NO
LAYOUT=IFDS_BEFORE_DATA 表示 IFD 在数据之前,这是 COG 的基本特征;KNOWN_INCOMPATIBLE_EDITION=YES 表示这个文件曾经被原地修改过、优化已失效——rio-cogeo 的校验会把它判成错误(见第 5 章)。
如果没有 ghost 区(GDAL < 3.1 或非 GDAL 工具生成),仍然可以用 IFD 偏移量判断:第一个 IFD 的偏移写在文件第 4–8 字节,值小于 300 说明头部紧凑,符合 COG 期望。
1.5.2 用 rasterio 只读一个窗口
import rasterio
from rasterio.windows import from_bounds
# 远程 COG,rasterio 内部走 GDAL /vsicurl/
url = "https://example-bucket.s3.amazonaws.com/landsat/scene.tif"
with rasterio.open(url) as src:
print(src.profile)
# {'driver': 'GTiff', 'dtype': 'uint16', 'width': 10000, 'height': 10000,
# 'count': 3, 'crs': CRS.from_epsg(32650), 'transform': Affine(...),
# 'blockxsize': 512, 'blockysize': 512, 'tiled': True}
# 只读 2 km x 2 km 的范围(UTM 50N,单位米)
window = from_bounds(500000, 4400000, 502000, 4402000, src.transform)
data = src.read([1, 2, 3], window=window, out_shape=(3, 256, 256))
print(data.shape, data.dtype) # (3, 256, 256) uint16
注意 out_shape 参数:它让 GDAL 在读取时直接做降采样,而不是先读全分辨率再缩。对 10000 像素的影像读 2 km 窗口,不指定 out_shape 会拿到约 67 × 67 像素的原始数据(30 米分辨率下 2 km 约 67 像素),指定后由 GDAL 选择合适层级——这个选择依赖 overview 是否存在。
1.5.3 用 pystac 读目录
import pystac
# 静态目录:读根 catalog,再取第一个 collection 的第一个 item
cat = pystac.Catalog.from_file("https://example.com/stac/catalog.json")
print(cat.id, cat.description)
for child in cat.get_children():
print("collection:", child.id)
for item in child.get_items():
print(" item:", item.id, item.datetime, item.bbox)
for key, asset in item.assets.items():
print(" asset:", key, asset.media_type, asset.href)
break
break
pystac 会自动解析相对链接——这也是第 7 章推荐用 pystac 而不是手写 JSON 的主要原因:链接的绝对化、self/root/parent 的补全,手工维护极易出错。
1.5.4 用 TiTiler 出一张瓦片
# TiTiler 默认把 COG 工厂挂在 /cog,瓦片路由带 TileMatrixSetId
curl -s -o tile.png \
"https://titiler.example.com/cog/tiles/WebMercatorQuad/12/3410/1612?url=https%3A%2F%2Fexample-bucket.s3.amazonaws.com%2Flandsat%2Fscene.tif&rescale=0%2C8000&colormap_name=viridis"
file tile.png
# tile.png: PNG image data, 256 x 256, 8-bit/color RGB, non-interlaced
url 参数必须整体 URL 编码,否则查询串里的 & 会把参数截断——这是接入 TiTiler 时最高频的错误,第 13 章会给出编码前后的对照。
1.6 常见误区
| 现象 | 真实原因 | 定位方法 |
|---|---|---|
| 瓦片服务很慢,但对象存储监控显示带宽不高 | COG 未分块(strip),每次读都要拉整行 | rio info 看 blockxsize 是否等于 width |
| 首屏要 2–3 秒才出图 | IFD 分散在文件各处,打开时多次往返 | rio cogeo validate 看 IFD offset 顺序 |
| 浏览器报 CORS 错误,curl 却正常 | 服务端未返回 Access-Control-Allow-Headers: range | 浏览器 Network 面板看预检请求 |
| STAC 里搜得到,点开图裂 | Item 的 href 是相对路径,前端解析基准错了 | 直接 curl 该 href |
| 同一份数据在 QGIS 正常、TiTiler 报错 | 数据不是 COG,TiTiler 仍能读但性能极差 | rio cogeo validate |
| 上传后校验通过,第二天变成 invalid | 有人用 GDAL 原地 update 过文件 | ghost 区 KNOWN_INCOMPATIBLE_EDITION=YES |
第三条值得展开:OGC 21-026 明确要求 HTTPS 服务在响应头里加 Access-Control-Allow-Headers: range,否则浏览器跨域发起 Range 请求时预检会失败。很多对象存储的默认 CORS 配置只允许 Content-Type,需要显式加 range。
1.7 生产建议
先定 COG 的块大小,再定瓦片服务的网格。 两者不匹配时,每次请求都要多读 1–3 个块。经验值:块 512 × 512,瓦片网格用 WebMercatorQuad,overview 因子取 2,级数算到“最小一级只剩一个块”为止。第 5 章会给完整计算。
把 STAC 元数据和像素分开部署。 元数据小、变化快,适合放数据库加 API;像素大、只读,适合放对象存储加 CDN。混在一起的典型后果是:为了更新一条元数据去重写整个 COG,或者为了加速像素访问去给数据库加缓存。
不要为“以防万一”预生成瓦片。 动态瓦片加 CDN 的组合在访问集中时命中率很高;只有在访问极度分散、且 QPS 很高时,预切片才更划算。判断依据是缓存命中率,不是主观感觉。
接受最终一致性。 对象存储的覆盖写、CDN 的 TTL、pgstac 的 search 缓存(默认有 TTL),三者叠加意味着“刚上传的影像立刻能搜到并看到图”需要额外设计。第 18 章会给一条可用的流水线顺序。
1.8 本章小结
| 要点 | 结论 |
|---|---|
| 云原生的本质 | 只保留一份 COG,服务在读时现算,把固定成本换成可变成本 |
| COG 的作用 | 让文件内部可按块随机定位,标准是 OGC 21-026 v1.0 |
| STAC 的作用 | 描述“有什么”,不描述“怎么读”;核心规范 1.1.0,API 规范 1.0.0 |
| TiTiler 的作用 | 无状态计算层,性能取决于 COG 布局与缓存 |
| 关键数字 | /vsicurl/ 默认 16 KB 块;IFD offset < 300;块 512;瓦片 256 |
| 版本底线 | Python ≥ 3.11;GDAL ≥ 3.1(COG driver 与 ghost 区) |
下一章进入 STAC 的数据模型,把 Catalog、Collection、Item、Asset、Link 五个对象和 1.1.0 相对 1.0.0 的变化逐字段拆开。