第12章 - TiTiler 架构与部署
影像上云的项目走到出图这一步,都会撞上同一堵墙:数据已经躺在对象存储里,COG 也切好了,但浏览器要的是 XYZ 瓦片,不是 GeoTIFF。中间那层把”读一个窗口”翻译成”返回一张 256×256 PNG”的服务,就是 TiTiler。
这一层看起来只是”加个 HTTP 壳”,实际上决定了后面所有章节的形态:瓦片是动态生成还是预切、渲染参数从 URL 来还是从配置来、缓存键怎么设计、鉴权放在哪一跳、成本记在计算还是出口。先把 TiTiler 的装配方式搞清楚,后面十几个问题都有统一的答案。
12.1 预切金字塔的账算不过来
一张 10980×10980 的 Sentinel-2 单景,切到 z=18 的 WebMercatorQuad,全量瓦片大约 6.9×10^10 张,不可能全切。真实项目只切数据覆盖范围,我按这个口径算一遍:
| 覆盖范围 | z=14 瓦片数 | z=14 体积(按 30 KB/张) | z=18 体积 |
|---|---|---|---|
| 1°×1° | 约 2.1×10^3 | 约 62 MB | 约 16 GB |
| 10°×10° | 约 2.1×10^5 | 约 6.2 GB | 约 1.6 TB |
| 全国(约 60°×35°) | 约 4.3×10^6 | 约 129 GB | 约 33 TB |
算法很朴素:z=14 时全球 16384 列覆盖 360°,1° 约 45.5 列,1°×1° 约 2070 张;每升一级乘 4,z=14 到 z=18 是 4^4=256 倍。这张表说明了预切方案的边界:小范围、低层级、更新周期以月计的数据,静态金字塔还能接受;覆盖到省域以上、要支持到 z=18 的浏览,预切在存储和预处理时间上都不成立。
动态切片的成本结构完全不同:不存瓦片,每次请求现读 COG 的一个窗口。一次 z=14 瓦片请求对应源数据约 1.5 m 分辨率的 700×700 像素窗口,COG 内部按 512×512 分块,大约命中 4 个内部块,加上文件头和 IFD,一次请求是 5~10 个 HTTP Range 请求、50~300 ms。关键结论是:动态切片的成本从”存储 + 预处理”搬到了”计算 + 出口”,命中率决定成败。这也是第 17 章要专门讲 CDN 的原因。
12.2 包结构与依赖边界
TiTiler 不是单体应用,2.x 拆成五个可独立安装的分发,src/titiler/ 下一层一个:
| 包 | PyPI 名 | 职责 | 关键类 |
|---|---|---|---|
| titiler.core | titiler-core | 单数据集/多资产瓦片端点、依赖注入、中间件、算法与色带注册表 | TilerFactory、MultiBaseTilerFactory、TMSFactory、AlgorithmFactory、ColorMapFactory |
| titiler.mosaic | titiler-mosaic | 基于 cogeo-mosaic backend 的镶嵌端点 | MosaicTilerFactory |
| titiler.extensions | titiler-extensions | COG 校验与查看器、STAC 查看器、STAC Render、WMTS/WMS 能力文档 | cogValidateExtension、cogViewerExtension、stacExtension、stacRenderExtension、wmtsExtension |
| titiler.xarray | titiler-xarray | Zarr/NetCDF 多维数据切片 | xarray 版 TilerFactory |
| titiler.application | titiler-application | 把上面几组装成可直接跑的 FastAPI app | main.app |
依赖上有一条硬约束写在 titiler/core/pyproject.toml:requires-python = ">=3.11",2.0.0 起要求 rio-tiler>=9.0,<10.0。rio-tiler 9 删掉了 MultiBandReader,这就是 MultiBandTilerFactory 在 2.x 被整个移除的原因——不是 API 审美问题,是下层能力没了。
实际读数据的永远是 rio-tiler,TiTiler 只做三件事:把 URL query 解析成 rio-tiler 的调用参数、把 ImageData 渲染成字节流、把异常映射成 HTTP 状态码。理解这条分工,就不会在 TiTiler 里找”重采样算法实现”——那是 GDAL 的活。
12.3 Factory 模式:路由是被装配出来的
TilerFactory 是一个 attrs 类,实例化时把一组端点注册到自己的 APIRouter 上。官方文档列全了它的属性,我按”改哪个会改变 API 表面”重新分组:
| 属性 | 默认值 | 改变什么 |
|---|---|---|
reader |
必填,如 rio_tiler.io.Reader |
读数据的方式 |
path_dependency |
DatasetPathParams(query 参数 url) |
数据集地址怎么传 |
layer_dependency |
BidxExprParams(bidx/expression) |
波段选择 |
dataset_dependency |
DatasetParams(nodata/unscale/resampling/reproject) |
读取与重投影 |
tile_dependency |
TileParams(buffer/padding) |
瓦片边缘处理 |
process_dependency |
algorithms.dependency(algorithm/algorithm_params) |
后处理算法 |
colormap_dependency |
ColorMapParams(colormap_name/colormap) |
色带 |
render_dependency |
ImageRenderingParams(rescale/color_formula/return_mask) |
渲染 |
environment_dependency |
lambda: {} |
每次请求的 rasterio.Env |
supported_tms |
morecantile.tms(全部) |
{tileMatrixSetId} 的合法取值 |
router_prefix |
空字符串 | 端点前缀 |
extensions |
[] |
追加的扩展端点 |
add_preview / add_part / add_viewer |
True |
是否挂 /preview、/bbox+/feature、/{tms}/map.html |
add_ogc_maps |
False |
是否挂 OGC Maps 的 /map |
这张表就是 TiTiler 的”配置面”。想把 url 换成 path、想加一个必须带的 token、想只支持 WebMercatorQuad,改的都是这里,不用碰端点代码。下面是一个只允许两种 TMS 的实例:
"""限定 TMS 的 COG 服务。"""
from fastapi import FastAPI
from morecantile import tms as default_tms
from morecantile.defaults import TileMatrixSets
from rio_tiler.io import Reader
from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers
from titiler.core.factory import TilerFactory
# TileMatrixSets 是 attrs 类,唯一字段是 tilematrixsets: dict
# get() 返回的是 deepcopy,所以这里缓存 TMS 对象是安全的
two_tms = TileMatrixSets(
{
"WebMercatorQuad": default_tms.get("WebMercatorQuad"),
"WorldCRS84Quad": default_tms.get("WorldCRS84Quad"),
}
)
app = FastAPI(title="COG Tiler")
cog = TilerFactory(reader=Reader, supported_tms=two_tms)
# 只在这一处写 prefix;factory 自己不要再写 router_prefix
app.include_router(cog.router, prefix="/cog", tags=["COG"])
add_exception_handlers(app, DEFAULT_STATUS_CODES)
@app.get("/healthz")
def healthz():
return {"ok": True}
限制 TMS 的实际效果是路径参数变成 Literal,传 /cog/tiles/FooQuad/1/1/1 会直接 422,而不是跑到 GDAL 里才报错。这个差异在压测时很重要:非法 TMS 的处理成本从”打开文件”降到”路由校验”。
12.4 路由布局与 2.x 的破坏性变化
titiler.application 组装出的完整路径如下(前缀来自 main.py 的 include_router):
| 路径 | 来源 | 说明 |
|---|---|---|
/cog/tiles/{tileMatrixSetId}/{z}/{x}/{y}[.{format}] |
TilerFactory + /cog |
单 COG 瓦片 |
/cog/{tileMatrixSetId}/tilejson.json |
同上 | TileJSON 3.0.0 文档 |
/cog/{tileMatrixSetId}/map.html |
同上 | 内置查看器 |
/cog/info、/cog/statistics、/cog/point/{lon},{lat} |
同上 | 元数据与点查 |
/stac/tiles/{tileMatrixSetId}/{z}/{x}/{y} |
MultiBaseTilerFactory + /stac |
以 STAC Item 为输入 |
/mosaicjson/tiles/{tileMatrixSetId}/{z}/{x}/{y} |
MosaicTilerFactory + /mosaicjson |
镶嵌 |
/zarr/tiles/{tileMatrixSetId}/{z}/{x}/{y} |
xarray factory + /zarr |
Zarr |
/tileMatrixSets、/algorithms、/colorMaps |
TMSFactory / AlgorithmFactory / ColorMapFactory | 挂在根上,无前缀 |
/healthz、/、/conformance、/api、/api.html |
application | 健康检查、OGC Common 落地页、OpenAPI |
/api 是 OpenAPI JSON,/api.html 是 Swagger UI,在 FastAPI(openapi_url="/api", docs_url="/api.html") 里定死。做网关白名单时要把这两个排除,否则会暴露全部端点结构。
版本差异是这一节的重点。0.19 到 2.x 的瓦片路由变化,网上一半教程还停在旧写法:
| 能力 | 0.x / 1.x | 2.x |
|---|---|---|
| 瓦片路径 | /tiles/{tms}/{z}/{x}/{y} |
同左({tms} 从 0.19 起必填) |
| 高分辨率后缀 | /tiles/{tms}/{z}/{x}/{y}@2x.png |
已删除,改用 ?tilesize=512 |
| 瓦片尺寸 | 固定 256 | 默认取 TMS 的 tileWidth × tileHeight,可用 tilesize 覆盖 |
| tilejson 默认尺寸 | 256 | 512 |
| map.html 默认尺寸 | 256 | 256 |
多资产 assets |
可选 | 必填,/info、/statistics 用 assets=:all: |
多资产 bidx |
有效 | 被忽略,改用 assets=B02|bidx=1 |
asset_indexes/asset_expression |
支持 | 删除 |
MultiBandTilerFactory |
存在 | 删除 |
| 查看器路径 | /{tms}/map |
/{tms}/map.html |
assets=B02|bidx=1 这个写法在 dependencies.py 的 _parse_asset() 里解析:先按 | 切出资产名,剩下每段按 key=value 解析,认识的 key 只有 bidx、expression、bands 三个。写成 assets=B02|indexes=1 不会报错,只是这个选项被静默丢弃——排查”为什么我选的波段没生效”时最该先看这里。
12.5 部署:进程模型与镜像
TiTiler 是纯 CPU 密集的同步代码:端点函数是普通 def,Starlette 会把它们丢进线程池。每张瓦片的处理链是 GDAL 读块 → numpy 重采样 → rio-color → PIL 编码,全程占 CPU。这决定了进程模型:
- uvicorn 多 worker:
--workers N,每个 worker 独立进程、独立 GDAL 缓存。N 一般取 vCPU 数,再横向扩副本。 - gunicorn + UvicornWorker:官方 compose 两种都给了一份,
gunicorn -k uvicorn.workers.UvicornWorker --workers 1。容器里我更倾向 uvicorn 直跑,少一层 master 进程的心跳和超时配置。 - 不要用 async 框架的”高并发”心智:并发能力由线程池和 GDAL 决定,不是由 async 决定。
RIO_TILER_MAX_THREADS默认是 CPU 数 × 5(rio-tiler 的MAX_THREADS),这个值直接决定 mosaic 端点一次能并发读多少个 COG。
镜像方面,2.3.0 起基础镜像换成 cgr.dev/chainguard/wolfi-base,运行时用户是 nonroot、uid 65532(2.2.2 时是 uid 1000)。这个变化会让挂载本地目录做开发的人措手不及:容器内 /data 的属主如果是 root,nonroot 用户读不了。解决办法是 chmod -R a+r,或者用 --user $(id -u):$(id -g) 覆盖。
最小启动命令:
docker run --rm -p 8000:8000 \
-e GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
-e GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES \
-e VSI_CACHE=TRUE \
-e VSI_CACHE_SIZE=5000000 \
ghcr.io/developmentseed/titiler:latest \
uvicorn titiler.application.main:app --host 0.0.0.0 --port 8000 --workers 4
# 确认起来了
curl -s http://127.0.0.1:8000/healthz
/healthz 返回的是版本集合,不是简单的 ok,这对排查”到底是 TiTiler 的问题还是 GDAL 的问题”很有用:
{"versions": {"titiler": "<x.y.z>", "rasterio": "...", "gdal": "...", "proj": "...", "geos": "..."}}
生产配置全部走环境变量,前缀 TITILER_API_,也可以放 .env:
| 环境变量 | 默认值 | 作用 |
|---|---|---|
TITILER_API_CORS_ORIGINS |
* |
逗号分隔的允许来源 |
TITILER_API_CORS_ALLOW_METHODS |
GET |
允许的方法 |
TITILER_API_CACHECONTROL |
public, max-age=3600 |
写进响应的 Cache-Control |
TITILER_API_ROOT_PATH |
空 | 反向代理挂子路径时必须设 |
TITILER_API_DISABLE_COG / _STAC / _MOSAIC / _ZARR |
False |
关掉不用的端点组 |
TITILER_API_DEBUG |
False |
请求日志与 Server-Timing |
TITILER_API_LOWER_CASE_QUERY_PARAMETERS |
False |
兼容大小写混写的旧客户端 |
TITILER_API_GLOBAL_ACCESS_TOKEN |
空 | 全局 access_token 校验 |
TITILER_API_TELEMETRY_ENABLED |
False |
打开 OTLP 链路追踪 |
反向代理挂子路径是高频故障点。控制器里写了 --root-path=/api/v1/titiler(compose 里的 nginx-titiler 就是这么起的),响应里 tilejson.json 生成的瓦片地址才会带正确前缀;漏写就会出现”tilejson 里的 tiles 指向 /cog/tiles/...,浏览器 404”。
Kubernetes 上最简的 Deployment 片段:
containers:
- name: titiler
image: ghcr.io/developmentseed/titiler:2.4.0
args: ["uvicorn", "titiler.application.main:app",
"--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
env:
- {name: GDAL_DISABLE_READDIR_ON_OPEN, value: "EMPTY_DIR"}
- {name: GDAL_HTTP_MERGE_CONSECUTIVE_RANGES, value: "YES"}
- {name: VSI_CACHE, value: "TRUE"}
- {name: VSI_CACHE_SIZE, value: "5000000"}
- {name: RIO_TILER_MAX_THREADS, value: "32"}
- {name: TITILER_API_ROOT_PATH, value: "/api/v1/titiler"}
resources:
requests: {cpu: "1", memory: "1Gi"}
limits: {cpu: "2", memory: "2Gi"}
readinessProbe:
httpGet: {path: /healthz, port: 8000}
initialDelaySeconds: 5
periodSeconds: 10
内存 limit 给 2Gi 有依据:GDAL 块缓存(GDAL_CACHEMAX)+ VSI 缓存(每句柄 VSI_CACHE_SIZE)+ numpy 数组(单次读取受 RIO_TILER_MAX_ARRAY_SIZE 限制,默认 1 GB)三者叠加。把 VSI_CACHE_SIZE 设成 512 MB 再配 32 线程,光缓存就能把节点打爆——第 20 章展开。
12.6 请求处理链路
一次 GET /cog/tiles/WebMercatorQuad/12/3421/1567?url=...&rescale=0,3000&colormap_name=viridis 的完整路径:
FastAPI 路由匹配
└─ 依赖注入(path/query 解析成各个 dependency 对象)
├─ path_dependency -> url
├─ layer_dependency -> bidx / expression
├─ dataset_dependency -> nodata / resampling / reproject
├─ colormap_dependency-> colormap_name 查表
└─ render_dependency -> rescale / color_formula
└─ rasterio.Env(**environment_dependency) # 请求级 GDAL 环境
└─ Reader(url) # 打开 COG,读 IFD
└─ src_dst.tile(z, x, y, tilesize=..., **params)
└─ GDAL:按 TMS 算窗口 -> 跨块时多次 Range 读 -> WarpedVRT 重投影
└─ post_process(algorithm) # 可选,如 hillshade
└─ render_image(...) # rescale -> color_formula -> 编码
└─ Response(content, media_type, headers) # Cache-Control 由中间件补
中间件顺序也值得记:main.py 里的注册顺序是 CORS → Compression → CacheControl →(debug 时)Logger/TotalTime。CacheControlMiddleware 只在三种条件同时满足时写头:方法为 GET/HEAD、状态码 < 500、响应里还没有 Cache-Control。所以”错误不缓存”不用额外配置,5xx 天然跳过;但 404 会被缓存 3600 秒,瓦片边界外的大量 404 会把 CDN 塞满无效条目,第 17 章给规避办法。
CompressionMiddleware 的 minimum_size=1,但 exclude_mediatype 排除了 image/jpeg、image/png、image/jp2、image/webp。这是对的:这些格式已压缩,再 gzip 只浪费 CPU。JSON 响应(tilejson、info)会被压缩,tilejson 动辄几 KB,收益明显。
12.7 排错:五个真实错误
404,路径里没有 tileMatrixSetId。 从旧教程抄来的 /cog/tiles/12/3421/1567.png 在 2.x 匹配不到任何路由。正确写法是 /cog/tiles/WebMercatorQuad/12/3421/1567.png。
{"detail": "..."} 配 500,日志里有 RasterioIOError。 DEFAULT_STATUS_CODES 把 RasterioIOError 映射成 500,把 MissingBands/MissingAssets/InvalidExpression 映射成 400,InvalidAssetName/InvalidBandName/TileOutsideBounds 映射成 404。也就是说 500 基本等价于”文件打不开”:URL 拼错、对象不存在、权限不足,或者 /vsis3/ 前缀没配对。
InvalidDatatypeWarning: Invalid type: uint16 for the png driver。 render_image() 对输出格式的合法 dtype 有白名单:png 接受 uint8/uint16,jpeg/jpg/webp 只接受 uint8,jp2 接受 uint8/int16/uint16。源数据是 uint16 又显式要 .jpg,就会触发这条警告并强制按 min/max 拉伸,图像可能整片发白。
不指定格式时返回了 PNG,体积比预期大。 自动选择逻辑是:掩膜里有被掩掉的像元就出 PNG,否则出 JPEG;并且自动模式下 PNG 的合法 dtype 被收窄成 uint8。想要 JPEG 的体量就得显式 .jpg 加 rescale,否则 uint16 数据会被压成 8 位 PNG。
装完 rasterio wheel 后 GDAL 找不到数据目录。 官方文档明确写着:用 rasterio wheel 时 GDAL_DATA 和 PROJ_LIB 必须取消设置。镜像里如果从宿主机继承了这两个变量,会看到 PROJ: proj_create_from_database: Cannot find proj.db 之类的报错。
12.8 生产建议
- 按端点组拆进程。
/cog和/mosaicjson的资源画像完全不同:前者单请求、低内存、高并发;后者一次读十几到几十个 COG,内存和线程占用高一个量级。用TITILER_API_DISABLE_*拆成两个 Deployment 分别扩缩容,比混在一起调参有效。 workers与RIO_TILER_MAX_THREADS要一起算。总并发线程数约等于 workers × MAX_THREADS。4 个 worker 配默认的CPU×5线程,在 2 核容器上就是 40 个线程抢 2 个核,上下文切换会吃掉收益。经验值是workers = vCPU、RIO_TILER_MAX_THREADS = 8,mosaic 场景单独调到 32~64。root_path与网关前缀保持一致,并且只在一处配置。前缀错位的表现是 tilejson 里的 URL 缺一段,很难从服务端日志看出来。/healthz不要只探 TCP。它不走 GDAL,探活成功不代表能出图;真正要监控的是/cog/info的 P95 延迟和 5xx 比例。- 镜像锁 digest 而不是 tag。官方 Dockerfile 把
wolfi-base和uv都锁了 digest,原因是 Wolfi 是滚动发行版,同一个 tag 隔几天拉到的 glibc 版本就可能不同。
12.9 本章要点
| 结论 | 依据 |
|---|---|
| 省域以上、要到 z=18 的浏览必须用动态切片 | z=14 到 z=18 是 256 倍存储放大,10°×10° 覆盖在 z=18 约 1.6 TB |
2.x 瓦片路由必须带 {tileMatrixSetId} |
factory.py 注册 /tiles/{tileMatrixSetId}/{z}/{x}/{y} |
@2x 缩放后缀已删除,改用 ?tilesize=512 |
2.0.0a1 变更记录与 v2 迁移文档 |
多资产端点 assets 必填、bidx 被忽略 |
MultiBaseTilerFactory 与 2.0 迁移文档 |
assets=B02\|bidx=1 只认 bidx/expression/bands |
dependencies._parse_asset() |
| 镜像以 nonroot uid 65532 运行 | 2.3.0 变更记录 |
Cache-Control 只补 5xx 以下的 GET/HEAD |
CacheControlMiddleware 三个条件 |
rasterio wheel 下 GDAL_DATA/PROJ_LIB 必须清空 |
官方 performance_tuning 文档 |
下一章把 /cog 这组端点逐个拆开,说明每个 query 参数背后的 rio-tiler 调用和返回结构。