第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%,不只是减少后端压力,还是直接的成本优化。
提升命中率的三件事,按投入产出排序:
- 让 URL 稳定可缓存。 查询参数顺序固定、不带时间戳/随机数、参数命名统一(TiTiler 提供
TITILER_API_LOWER_CASE_QUERY_PARAMETERS把参数名统一成小写)。 - 设置合理的
Cache-Control。 TiTiler 默认public, max-age=3600,中间件只对GET/HEAD且状态码 < 500 的响应加头,且不覆盖已有的Cache-Control。历史影像可以放到 7 天甚至更长;当天新增数据用短 TTL。 - 把参数组合收敛。 每个不同的
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、固定镜像版本、清理冷数据、给付费桶设预算 |