第21章 - 成本安全与运维治理

21.1 动态影像服务的四类账单

云原生影像链路的成本不像静态网站那样线性。把账单拆成四项,才能看出优化点在哪:

成本项 计费单位 谁在放大它
存储 GB·月 保留原始影像与派生 COG、overview、多时相副本
请求 每万次 / 每百万次 每张瓦片的 Range 请求、LIST 请求、未命中缓存的回源
出口流量 GB 瓦片出向、CDN 回源、跨区域访问
计算 实例·小时 / vCPU·秒 实时重采样与编码、worker 数、缓存内存占用

四者不是独立的。加缓存能砍掉请求与计算,但缓存本身要内存或 CDN 费用;降低分辨率能砍掉存储与出口,但用户看到的是糊图。好的方案是找到”用户可接受画质”与”最低成本”的交点,而不是把四项各自压到最小。

21.2 对象存储请求:最容易被忽略的一笔

对象存储的计费通常分”请求次数”和”流量”,而请求类型之间的价格差异很大:LIST 类操作一般比 GET 贵一个数量级。TiTiler 的性能文档专门点了这条:

GDAL_DISABLE_READDIR_ON_OPEN 设为 FALSE 时,GDAL 每次打开文件都会发一次 LIST 请求。由于 LIST 请求比 GET 请求贵得多,在 requester-pays 桶上会带来意外成本。

这就是 20 章那个配置的成本面。一个没设 EMPTY_DIR 的部署,每次瓦片请求可能多出一次 LIST,按 20.7 的示例(回源 4500 QPS),一天就是接近 4 亿次多余请求。

请求数还与 Range 的粒度有关。一次瓦片读取至少两次 Range(文件头 + 数据),如果 COG 内部 tile 太大、或需要读多个波段,次数会更多。控制手段:

  • 用 GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES 合并相邻 Range。
  • 生成的 COG 内部 tile 不要过大(256 或 512 像素),否则单次读取的数据量偏大。
  • 减少重复打开:同一文件在同一请求内只应打开一次。

requester-pays 桶要显式声明付费方。 访问这类桶时必须设置 AWS_REQUEST_PAYER=requester,否则请求会被拒绝。官方 AWS 部署模板里就是通过环境变量传入的:

# deployment/aws/intro.md 中的写法
export TITILER_STACK_ENV='{"AWS_REQUEST_PAYER":"requester"}'

开了这个选项,所有读取都由你的账号付费,包括测试与爬虫触发的请求。这一点要在成本预算里单独列项,并配合限流。

21.3 出口流量与 CDN 回源率

出口流量通常是影像服务最大的一笔账单。计算方式很直接:

月出口 GB = 月瓦片请求数 × 平均单瓦片字节 / 1024³
         = 前端月请求 × (1 - CDN 命中率) × 平均字节 / 1024³   ← 回源部分
         + 前端月请求 × CDN 命中率 × 平均字节 / 1024³          ← 边缘到用户

CDN 的价值在于:边缘到用户的流量单价通常远低于对象存储直出。所以把命中率从 60% 提到 95%,不只是减少后端压力,还是直接的成本优化。

提升命中率的三件事,按投入产出排序:

  1. 让 URL 稳定可缓存。 查询参数顺序固定、不带时间戳/随机数、参数命名统一(TiTiler 提供 TITILER_API_LOWER_CASE_QUERY_PARAMETERS 把参数名统一成小写)。
  2. 设置合理的 Cache-Control。 TiTiler 默认 public, max-age=3600,中间件只对 GET/HEAD 且状态码 < 500 的响应加头,且不覆盖已有的 Cache-Control。历史影像可以放到 7 天甚至更长;当天新增数据用短 TTL。
  3. 把参数组合收敛。 每个不同的 rescale/colormap/bidx 组合都是一份独立缓存。给前端固定的几套渲染方案,而不是开放任意参数。
# 在网关/环境变量层调整缓存策略(TiTiler 的 settings 前缀是 TITILER_API_)
export TITILER_API_CACHECONTROL="public, max-age=86400"
export TITILER_API_LOWER_CASE_QUERY_PARAMETERS="TRUE"

注意 Cache-Control 一旦在应用层设长,CDN 与浏览器都会遵守。数据更新后需要 purge 或换 URL(例如带版本号参数),否则用户看到的是旧影像。

21.4 按需渲染 vs 预切瓦片:成本交叉点

这是架构选型里最实际的一道题。两种方式的成本曲线方向相反:

维度 预切瓦片(静态) 按需渲染(动态)
存储 全部瓦片落盘,通常比原始 COG 大数倍 只存 COG(含 overview,约 +30%)
请求 一次 GET 一个文件 每张瓦片 ≥2 次 Range
计算 离线一次性 每次请求
延迟 低且稳定 波动大,受存储延迟影响
更新 需重切,几乎全量 换数据文件即可
参数化 固定(投影、拉伸、色带都定死) 任意组合

判断方法用”瓦片复用率”:

预切成本 ≈ 瓦片总数 × 单瓦片存储单价 × 保留月数 + 生成计算成本
动态成本 ≈ 月请求数 × (Range 请求单价 × 平均 Range 数 + 出口单价 × 平均字节)

当 月请求数 × 平均瓦片字节  远大于  瓦片总数 × 平均瓦片字节 时,预切更划算

直白地说:同一批瓦片被反复看,预切;每个用户看到的都不一样,动态。

典型的分界线:

  • 面向公众的底图、变化很少(年度更新):预切 + CDN,最省。
  • 内部专业用户、按需调参(色带、波段、拉伸均不同)、数据每天更新:动态渲染。
  • 混合形态:常见参数组合预热成静态瓦片,长尾参数走动态。这是成本与灵活性的平衡点。

TiTiler 官方文档对这两条路线的描述与此一致:静态瓦片”更快、更简单”,但”要为所有用户预先做决定”(投影、拉伸),且”有些瓦片永远不会被加载”;动态渲染”延迟更高,每张瓦片至少两次 GET”,但支持任意投影、任意拉伸与波段运算。

21.5 SSRF:url 参数是一把双刃剑

TiTiler 的核心设计是”用一个 url 查询参数指向任意 COG”。这带来了极大的灵活性,也带来了 SSRF(服务端请求伪造)风险:攻击者可以让你的服务去请求内网地址。

# 危险示范:服务端会真的去请求这个地址
curl "https://titiler.example.com/cog/tilejson.json?url=http://169.254.169.254/latest/meta-data/"

如果部署环境的出口能访问云厂商元数据端点、K8s API、内部管理后台,这个参数就是一个跳板。默认实现(DatasetPathParams)只做了一层:把 query 参数原样传成数据集路径,不做任何来源校验。

四层缓解措施,从弱到强:

21.5.1 只允许白名单前缀

最直接的做法是自定义 path_dependency,校验 URL 前缀:

from fastapi import Query

ALLOWED_PREFIXES = (
    "https://storage.example.com/imagery/",
    "s3://my-imagery-bucket/",
)

def RestrictedPathParams(url: str = Query(..., description="Dataset URL")) -> str:
    """只接受白名单前缀的数据集 URL。"""
    if not url.startswith(ALLOWED_PREFIXES):
        raise ValueError("Dataset URL is not in the allowlist")
    return url

cog = TilerFactory(path_dependency=RestrictedPathParams)

注意 startswith 的坑:https://storage.example.com.evil.com/ 也能通过。要按 URL 解析后比较 scheme + host + path 前缀,而不是裸字符串前缀。

21.5.2 不直接暴露 URL,改用标识符

更彻底的做法是前端只传数据集 ID,服务端自己映射到 URL:

from fastapi import Query, HTTPException
import boto3

def ItemPathParams(item_id: str = Query(..., description="Dataset ID")) -> str:
    """按 ID 从登记表取 URL,前端无法指定任意地址。"""
    obj = boto3.client("s3").get_object(Bucket="registry", Key=f"items/{item_id}.json")
    return json.loads(obj["Body"].read())["href"]

cog = TilerFactory(path_dependency=ItemPathParams)

前端看到的是 ?item_id=S2B_20240101,SSRF 面基本关闭。代价是多一次查表(要加缓存)。

21.5.3 签名 URL 与 base64 编码

直接对象存储需要鉴权时,TiTiler 官方给了两种方案(docs/src/examples/code/working_with_signed_urls.md):

  • URL 编码:把带 &\ 的签名 URL 整体 base64,避免与请求自身的 query 参数冲突。
  • 服务端签名:path_dependency 里调用云厂商 SDK 生成预签名 URL。
import base64
from fastapi import Query

def DatasetPathParams(url: str = Query(...), base64_encoded: bool = Query(None)) -> str:
    if base64_encoded:
        url = base64.b64decode(url).decode()
    return url

cog = TilerFactory(path_dependency=DatasetPathParams)

第二种(服务端签名)更安全:前端只传对象键,签名在服务端完成,同时天然限定了可访问的桶与前缀。

21.5.4 网络层兜底

代码层校验之外,还要在网络层断掉出口:K8s 用 NetworkPolicy 限制 Pod 只能访问对象存储与必要的外部域名;云上用 VPC Endpoint 访问对象存储,避免走公网;元数据服务用 IMDSv2 并限制 hop limit。这些是纵深防御的最后一道。

21.6 鉴权与限流

21.6.1 内置的全局 token

TiTiler 的 ApiSettings 提供了一个最简的全局访问控制:

# 设置后所有端点都要求 ?access_token=...
export TITILER_API_GLOBAL_ACCESS_TOKEN="your-strong-token"

实现见 main.py:用 APIKeyQuery(name="access_token") 取参,缺失时返回 401,detail 为 Missing access_token;值不匹配也返回 401,detail 为 Invalid access_token。它适合内部服务或演示环境,不适合作为面向多租户的鉴权方案——只有一把钥匙,无法撤销单个用户、无法审计。

21.6.2 自定义 token / JWT

官方示例 tiler_with_auth.md 给了完整做法:用 python-jose 签发 JWT,在 path_dependency 里解析并校验 scope。核心优势是可以按用户签发、设过期时间、带 scope(例如 tiles:read),也能在网关层统一校验。

from jose import JWTError
from fastapi import HTTPException, Query, Security
from fastapi.security.api_key import APIKeyQuery

api_key_query = APIKeyQuery(name="access_token", auto_error=False)

def DatasetPathParams(url: str = Query(...), token: str = Security(api_key_query)) -> str:
    if not token:
        raise HTTPException(status_code=401, detail="Missing access_token")
    try:
        claims = AccessToken.from_string(token)   # 校验签名与过期
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid access_token")
    if "tiles:read" not in claims.scope:
        raise HTTPException(status_code=403, detail="Insufficient scope")
    return url

21.6.3 限流

TiTiler 本身不带限流,要放在网关层(API Gateway、Nginx、Envoy、Cloudflare)。限流维度按业务定:

维度 粒度 适用
来源 IP 每秒请求数 阻断爬虫与异常客户端
用户/租户 每分钟瓦片数 多租户配额
参数组合 单 URL 的 QPS 防止把某个昂贵组合打成热点
全局 并发连接数 保护后端与存储配额

限流要和缓存配合:先让 CDN 吸收可缓存流量,再对回源限流。直接在前端入口限流,会把正常用户的重复浏览也挡掉。

21.7 可观测性:指标与错误码

TiTiler 的错误处理把异常映射成固定状态码(errors.py 的 DEFAULT_STATUS_CODES),这些码本身就是监控的分类维度:

异常 HTTP 含义 是否该告警
TileOutsideBounds 404 请求的瓦片超出数据范围 否,正常
TileNotFoundError 404 瓦片不存在 视情况
InvalidAssetName / InvalidBandName 404 资产/波段名错 否,客户端问题
MissingBands / MissingAssets 400 参数缺波段或资产 否
InvalidExpression / InvalidFormat / InvalidColorFormat 400 参数非法 否,但可看趋势
RasterioIOError / RasterioError 500 底层读取失败 是
RioTilerError 500 渲染/读取错误 是

这条映射说明一件事:404 多不一定是故障。前端拿到的数据 bbox 之外本来就会请求到越界瓦片,返回 404 是设计行为。告警要基于 5xx 比例,而不是总错误率。

核心指标清单:

指标 采集方式 用途
请求延迟 p50/p95/p99 应用中间件或网关日志 感知卡顿与排队
5xx 比例 网关/应用日志 唯一的强告警项
缓存命中率 CDN 报表 + 应用侧 X-Cache 成本与容量核心
回源 QPS CDN 报表 后端容量规划
对象存储请求数 云监控 成本异常检测
出口流量 云监控 成本主项
活跃数据集数 请求日志按 url 聚合 找出冷数据,考虑下线

延迟的来源要分开看:应用自身的处理时间、对象存储的响应时间、编码时间。TiTiler 在 debug 模式下会挂 TotalTimeMiddleware 与 LoggerMiddleware(main.py),响应头里带总耗时,可用于分层定位;生产环境不建议常开,开销与日志量都不小。

# 快速看一次请求的分层耗时(debug 模式)
export TITILER_API_DEBUG=TRUE
curl -s -D - -o /dev/null "https://titiler.example.com/cog/tiles/WebMercatorQuad/12/3300/1500?url=..."

21.8 运维治理清单

端点收敛。 不用 STAC 就关掉 /stac,不用 Zarr 就关掉 /zarr。TiTiler 提供了对应的开关:

export TITILER_API_DISABLE_STAC=TRUE
export TITILER_API_DISABLE_MOSAIC=TRUE
export TITILER_API_DISABLE_ZARR=TRUE

端点是攻击面,也是维护成本。官方 AWS 模板里就是用这套开关只保留 /cog。

CORS 收紧。 默认 cors_origins="*"、cors_allow_methods="GET",生产必须改成显式域名。带 allow_credentials=True 时通配来源会被浏览器拒绝,一定要提前验证。

保留 /healthz 且不缓存。 演示应用里 CacheControlMiddleware 对 /healthz 做了排除,健康检查不能被缓存。

版本与镜像固定。 TiTiler 2.3.0 起镜像基于 wolfi-base、以 nonroot(uid 65532)运行;2.2.2 起默认用户也变过。升级时挂载目录与临时目录权限要一起回归,否则会出现”GDAL 临时文件写不进去导致性能骤降”这类问题。

定期清理冷数据。 用请求日志按 url 聚合,把长期零请求的数据集下线或转归档存储。影像业务里冷数据往往占了存储账单的大头。

给 requester-pays 桶加预算告警。 一旦开放 AWS_REQUEST_PAYER=requester,任何外部触发的请求都由你付费。按天设预算阈值告警,比月底看账单好。

# 省成本的组合:长缓存 + 只读端点 + 健康检查
env:
  - name: TITILER_API_CACHECONTROL
    value: "public, max-age=86400"
  - name: TITILER_API_CORS_ORIGINS
    value: "https://app.example.com"
  - name: TITILER_API_DISABLE_ZARR
    value: "TRUE"
  - name: GDAL_DISABLE_READDIR_ON_OPEN
    value: "EMPTY_DIR"

21.9 排错与审计

账单异常增长但流量没涨。 查对象存储请求数,重点看 LIST 与 GET 的比例。常见原因是 GDAL_DISABLE_READDIR_ON_OPEN 被改回默认、或者新上线的数据依赖外部 .ovr 而被迫用 FALSE。

CDN 命中率突然下降。 前端是否加了随机参数、是否改了参数顺序、是否引入了新的参数组合。命中率下降 5 个点,回源可能翻倍。

大量 401。 检查 token 的传递链:query 参数是否被 TileJSON 透传、网关是否剥离了 query、token 是否过期。内置 access_token 与自定义 JWT 的参数名必须一致。

大量 500 RasterioIOError。 通常是存储侧问题:权限失效、桶策略变更、跨区域网络、requester-pays 未声明。看应用日志里的完整堆栈,RasterioIOError 会带上底层 HTTP 错误。

疑似被当作代理使用。 请求日志里出现大量非业务域名的 url 参数、目标为内网地址或元数据端点。立即启用白名单 path_dependency,并在网络层封禁出口。这类滥用通常伴随异常高的回源与流量。

审计要求。 至少记录:时间、客户端 IP、url 参数、请求的 z/x/y、状态码、耗时、响应字节数。这些字段足以还原任何一次访问,也足以做成本归因(按数据集、按租户)。

21.10 本章小结

主题 结论
四类成本 存储、请求、出口、计算;互相制衡,不能各自压到最小
请求成本 LIST 比 GET 贵一个量级;GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR 是最大的单点优化
出口与 CDN 命中率是最敏感的杠杆;URL 稳定、参数收敛、TTL 合理
动静取舍 瓦片被反复看→预切;每人看到的都不同→动态;可混合
SSRF url 参数可指向任意地址;用白名单、ID 映射、服务端签名、网络出口四层防
鉴权 内置 global_access_token 适合内部;JWT 适合多租户;限流放网关
告警 基于 5xx 比例,不要用总错误率;404 常是正常的越界请求
治理 关掉不用的端点、收紧 CORS、固定镜像版本、清理冷数据、给付费桶设预算