Open Snapshot 是基于 Ktor 和 snapshot parser 的开放构图服务。
请求体为 snapshot DSL 文本,响应为 image/png、image/jpeg 或 image/webp。
curl -X POST http://localhost:8080/snapshot `
-H "Content-Type: text/plain" `
--data '<Snapshot type="png"><Container width="200" height="100" color="#FF0000FF"/></Snapshot>' `
--output result.png根标签 <Snapshot> 的属性:
| 属性 | 说明 |
|---|---|
type |
输出格式:png(默认)、jpg、webp,其他值会被解析器拒绝 |
background |
画布背景色,支持 CSS 颜色语法;8 位十六进制为 #RRGGBBAA |
debug |
绘制布局辅助信息,默认 false,需显式写 debug="true" |
画布尺寸由 DSL 内容布局决定,并受 max-canvas-width、max-canvas-height、max-canvas-pixels 限制。
容器与文本:
<Snapshot type="png" background="#FF102030">
<Column>
<Row>
<Container color="#FF0000" width="200" height="200"/>
<Container color="#FFFFFF" width="200" height="200">
<Text color="#0000FF" fontSize="20">hello snapshot 🤣</Text>
</Container>
</Row>
</Column>
</Snapshot>网络图片(受数量、单图大小、解码尺寸与私网拦截限制):
<Snapshot type="webp">
<Image width="200" height="200" url="https://example.com/cover.png" fit="COVER" noCache="true"/>
</Snapshot>fit 可选 FILL、CONTAIN、COVER、FIT_WIDTH、FIT_HEIGHT、NONE、SCALE_DOWN。
noCache="true" 会跳过缓存,默认 false(布尔属性必须写出值,裸写属性会回落到默认值)。
内嵌图片可使用 dataUri(PNG、JPEG、WebP),不能与 url 同时出现:
<Snapshot type="png">
<Image width="32" height="32" dataUri="data:image/png;base64,iVBORw0KGgo..."/>
</Snapshot>URL 与 Data URI 共用每次渲染的图片数量和总解码像素预算。Data URI 还会在解码前检查 Base64 长度,并校验声明 MIME 与实际文件签名,防止压缩图片或伪造格式造成内存放大。
指定字体族,避免中文或 Emoji 缺字(多个字体用英文逗号分隔,且不能带空格):
<Snapshot type="png">
<Text fontSize="24" fontFamily="Noto Serif SC,Noto Color Emoji">原神,启动!🤣</Text>
</Snapshot>更多标签与属性见 snapshot 框架的使用说明(docs/usage)。
错误统一为 JSON,并带同样的 requestId:
{"code":"PARSE_ERROR","message":"...","requestId":"..."}code |
HTTP | 说明 |
|---|---|---|
EMPTY_REQUEST |
400 | 请求体为空或只有空白字符 |
PARSE_ERROR |
400 | DSL 语法或属性错误;可用位置会写入 message,属性错误可附带 diagnostic |
RENDER_ERROR |
400 | 超出画布限制或布局失败 |
IMAGE_LOAD_ERROR |
400 | 图片地址非法、非 HTTP(S)、私网地址、超时或格式错误 |
REQUEST_TOO_LARGE |
413 | 请求体超过 max-request-size |
RENDER_TIMEOUT |
504 | 渲染超过 max-render-timeout-ms |
FONT_NOT_FOUND |
400 | /fonts.png 的 family 中包含不存在的字体族 |
INVALID_QUERY |
400 | 查询参数不合法(例如 offset/limit 不是非负整数) |
RATE_LIMITED |
429 | 触发限流(Ktor 限流插件直接返回 429,带 Retry-After) |
QUEUE_FULL |
503 | 等待渲染槽位的请求数超过 max-render-queue,带 Retry-After |
QUEUE_TIMEOUT |
503 | 排队等待超过 render-queue-timeout-ms,带 Retry-After |
SERVICE_UNAVAILABLE |
503 | 服务正在排水,不再接受新渲染 |
NOT_READY |
503 | /ready 探测未通过 |
UNAUTHORIZED |
401 | 缺少或提供了错误的 API Key / 管理凭据 |
INTERNAL_ERROR |
500 | 未预期的内部错误,message 不包含内部细节 |
属性错误会额外返回可选的 diagnostic,例如 <Row mainAxisAlignment="MIDDLE"/>:
{
"code": "PARSE_ERROR",
"message": "属性值无效 [mainAxisAlignment],元素 <Row>\n格式:以下取值之一,区分大小写\n...",
"requestId": "...",
"diagnostic": {
"attributeName": "mainAxisAlignment",
"elementName": "Row",
"valueState": "present",
"position": { "offset": 34, "line": 1, "column": 35 },
"format": "以下取值之一,区分大小写",
"allowedValues": ["START", "END", "CENTER", "SPACE_BETWEEN", "SPACE_AROUND", "SPACE_EVENLY"],
"examples": ["START"]
}
}message是可变化、可多行的可读说明,不要匹配整条文案或用关键词判断错误类型。渲染错误消息最多 1024 个 UTF-16 代码单元;超长内容以…结尾,位置会单独保留。diagnostic只在 Parser 提供属性诊断时出现;结构错误、组件组合约束等可能没有。图片解码错误也可能带诊断,但错误码仍为IMAGE_LOAD_ERROR。valueState:missing为属性缺失,unassigned为只写属性名,empty为空字符串,present为其余已赋值输入(包括纯空白)。不返回rawValue或原值预览。position.offset是从 0 开始的 UTF-16 字符偏移;行、列从 1 开始,列也按 UTF-16 计数。未知位置或未知字段省略,不用 0 代替未知。elementName、format也可能省略。allowedValues、examples总是数组;对边框等复合格式,候选值可能仅对应其中一个字段,必须同时遵循format。只报告首个错误,不汇总全部问题。- 名称最长 128、格式说明最长 512;候选最多 64 项(每项最长 128),示例最多 3 项(每项最长 160)。过长候选/示例会省略而不是改写为无效值;信息被裁剪时返回
truncated: true。 - 属性错误消息按诊断信息重新生成,不透传原始异常与 cause。结构错误保留受限源码上下文;JSON 上下文和错误图会隐藏
url、dataUri、password、token、apiKey、authorization属性值及显式 URI。不要在其他 DSL 文本中放入密钥。 - 渲染失败日志记录请求 ID、错误码、异常类型、属性名与位置,不记录原始异常消息或 cause;内部错误额外保留有限的栈帧定位信息。
上述结构化诊断需要包含 ParseException.attributeDiagnostic 的 Parser 快照。构建使用滚动 0.0.0-SNAPSHOT,升级时需刷新依赖并运行回归测试;不要仅凭版本字符串判断能力。
请求带 ?errorImage=png 时,解析失败会返回一张错误卡片(PNG)而不是 JSON,便于在调试工具里直接查看:
curl -X POST "http://localhost:8080/snapshot?errorImage=png" `
-H "Content-Type: text/plain" `
--data '<Snapshot><Container width="1"height="1"/></Snapshot>' `
--output error.png卡片包含:错误标题、带行号的源码摘录(错误行浅红底、^ 指向出错列)、错误消息、位置与 requestId。属性错误按独立额度展示摘要、格式、候选值与示例,避免长摘要挤掉修正提示;消息最多 768 个 UTF-16 代码单元、8 个逻辑行,每行最多 160 个代码单元(显示时可自动折行)。敏感属性和 URI 用等长占位符隐藏,以保留定位。
- 只在解析失败时生效;解析成功时该参数被忽略,正常出图;
- HTTP 状态仍为
400,并额外返回X-Snapshot-Error-Code、X-Snapshot-Error-Position、X-Snapshot-Error-Location; - 不带该参数时行为完全不变(仍是 JSON 错误);
errorImage只支持png,其他值回退 JSON; - 错误卡片渲染失败或超时(含渲染队列已满)时回退 JSON,绝不掩盖原始解析错误;
- 摘录范围由
error-image.max-lines、max-columns、context-lines控制,error-image.enabled=false可关闭; - 非解析类错误(画布超限、图片加载失败等)仍返回 JSON。
成功与失败响应都带标准 Server-Timing,调用方与浏览器 DevTools 的 Timing 面板可直接看到耗时构成:
Server-Timing: queue;dur=0.3, render;dur=12.4, image;dur=8.1, total;dur=14.2
X-Snapshot-Cache: miss
X-Snapshot-Image-Count: 2| 段 / 头 | 含义 | 出现条件 |
|---|---|---|
queue;dur |
等待渲染槽位的耗时(含被拒绝前的等待) | 经历过排队 |
render;dur |
纯渲染耗时(解析 + 布局 + 光栅化 + 编码) | 实际执行了渲染 |
image;dur |
本请求在远程图片上消耗的时间(含等待同 URL 并发去重) | 请求中加载过网络图片 |
cache;desc=hit |
渲染结果缓存命中 | 命中时(此时没有 render 段) |
total;dur |
服务端处理总耗时 | 能进入路由的所有响应 |
X-Snapshot-Cache |
hit / miss |
/snapshot 且启用渲染结果缓存 |
X-Snapshot-Image-Count |
本请求真正发起的图片下载次数(304 复用不计入) | 有下载时 |
说明:
- 耗时单位为毫秒、保留 1 位小数;错误响应(如
400 PARSE_ERROR)同样带total,但不含未发生的阶段; 429由限流插件在进入路由前产生,无法附加这些头,请通过snapshot_rate_limited_total指标观察;- 浏览器 JS 读取需要服务端显式暴露(已加入
Access-Control-Expose-Headers); timing-headers.enabled=false可关闭全部耗时头。
- 渲染在专用线程池上执行,线程名为
snapshot-render-*,不会占用 Netty 连接处理线程; - 线程池大小与并发上限都由
max-concurrent-renders决定;有空闲槽位时立即执行, 否则进入等待队列(上限max-render-queue,等待上限render-queue-timeout-ms); - 队列已满返回
503 QUEUE_FULL,排队超时返回503 QUEUE_TIMEOUT,两者都带Retry-After; max-render-timeout-ms是排队加渲染的总预算;阻塞式渲染无法被中途打断,超时会在渲染返回后生效;- 相同 DSL 的重复请求可由渲染结果缓存直接返回,不占用渲染槽位;
- 网络图片缓存按 URL 去重,同一地址的并发请求只下载与解码一次。
| 接口 | 说明 |
|---|---|
GET /health |
存活检查:进程可响应即返回 OK |
GET /ready |
就绪检查:渲染链路可用且未在排水时返回 READY,否则 503 + NOT_READY |
GET /metrics |
Prometheus 文本指标(text/plain; version=0.0.4) |
GET /openapi.yaml |
OpenAPI 定义(需开启公开文档) |
GET /ai-guide.md |
AI 使用指南(需开启公开文档) |
GET /fonts |
返回可用字体列表;鉴权与限流同 /snapshot |
GET /fonts.png |
字体预览图;鉴权与限流同 /snapshot,支持 family、offset、limit |
GET /cacheInfo |
查看网络图片缓存与渲染结果缓存统计(管理接口) |
POST /cacheClear |
清理网络图片缓存与渲染结果缓存(管理接口) |
所有响应都会带有 X-Request-Id(调用方传入的合法值会被沿用,否则生成 UUID),错误响应体中的
requestId 与之相同。
完整的接口定义见 docs/openapi.yaml(OpenAPI 3.0,包含全部接口、错误码与响应示例)。
设置 snapshot.public-docs-enabled: true,或在容器环境中设置 SNAPSHOT_PUBLIC_DOCS_ENABLED=true,即可通过服务自身的 GET /openapi.yaml 和 GET /ai-guide.md 获取 OpenAPI 定义与 AI 使用指南。两个文档在构建时打包进 JAR,无需额外挂载;默认关闭时路由不注册并返回 404。开启后根路径 / 也会显示文档链接。公开文档仅包含静态说明,不包含运行时配置或密钥,且不改变 /snapshot 的鉴权和限流。
OpenAPI 的服务器地址使用相对路径 /,因此经 Nginx 以站点根路径发布时,导入工具会请求当前站点;若部署在子路径下,需相应调整文档中的 servers。
AI 工具应先阅读当前服务的 /ai-guide.md 和 /openapi.yaml,了解请求/响应、鉴权、资源限制、字体与诊断约定,再通过 Snapshot 文档站的 llms.txt 索引 按任务读取单页 Markdown。需要完整的 Parser 上下文时使用 DSL 专题合集,无需默认加载全站合集;llms-small.txt 也不是短摘要。框架参考由文档站统一生成,服务只维护自身调用说明,不代理或重复打包整套框架文档。这些外部文档不受 SNAPSHOT_PUBLIC_DOCS_ENABLED 控制,访问时不应附带服务 API Key 或用户请求内容。
/snapshot 接收 Parser 文本模板;Kotlin DSL 的参数名、默认值和颜色写法不能直接套用。文档站跟随 Snapshot 快照更新,不代表当前服务部署了同版本引擎;LSP/静态诊断也不能替代实际渲染验证。更新本仓库的两份文档后,需要重新构建并部署服务才能更新其公开内容,文档站上线不会自动替换 JAR 内的指南。
默认情况下 /snapshot 开放调用,仅受限流保护。需要限制调用方时,配置任意一种凭据来源即可启用鉴权:
# 单个 Key
SNAPSHOT_API_KEY=replace-with-a-long-random-key
# 多个 Key(名称:密钥,逗号分隔);名称会出现在日志、指标与限流桶键中
SNAPSHOT_API_KEYS=web-frontend:key-0123456789abcdef,partner-a:key-fedcba9876543210也可以在 YAML 中配置,并给需要访问管理接口的 Key 加 admin: true:
snapshot:
api-keys:
- name: web-frontend
key: "key-0123456789abcdef"
- name: ops
key: "key-fedcba9876543210"
admin: true约束:每个 Key 至少 16 位;名称需匹配 [A-Za-z0-9._-]{1,32} 且不可重复;最多 32 个 Key。
轮换方式:把新 Key 与旧 Key 同时列在配置里,等调用方切换完成后删除旧 Key,全程无需停机。
启用后 /snapshot 默认必须携带以下任一种凭据,否则返回 401 UNAUTHORIZED:
curl -X POST http://localhost:8080/snapshot `
-H "X-API-Key: $SNAPSHOT_API_KEY" `
-H "Content-Type: text/plain" `
--data '<Snapshot type="png"><Container width="200" height="100" color="#FF0000FF"/></Snapshot>' `
--output result.pngcurl -X POST http://localhost:8080/snapshot `
-H "Authorization: Bearer $SNAPSHOT_API_KEY" `
-H "Content-Type: text/plain" `
--data '<Snapshot type="png"><Container width="200" height="100" color="#FF0000FF"/></Snapshot>' `
--output result.png凭据按 SHA-256 摘要查找(不逐字符比较密钥),管理令牌使用定长比较。建议通过环境变量注入,而不是写入配置文件或命令行参数。
如果希望保留 API Key 的高配额通道,同时继续开放匿名访问,可显式开启:
SNAPSHOT_ANONYMOUS_ACCESS_ENABLED=true匿名请求仍按来源 IP 使用 rate-limit.requests 配额;有效 API Key 使用独立的凭据配额。
显式携带错误 API Key 的请求仍返回 401,不会无声降级为匿名访问。该开关默认 false。
缓存管理接口接受带 admin: true 的 API Key 或管理令牌;凭据有效但非管理员返回 403 FORBIDDEN,未认证返回 401 UNAUTHORIZED。/fonts 和 /fonts.png 不属于管理接口,与 /snapshot 使用同一鉴权与限流规则:
SNAPSHOT_ADMIN_ENDPOINTS_ENABLED=true
SNAPSHOT_ADMIN_TOKEN=replace-with-a-long-random-token| 调用方 | 计桶方式 | 配置项 | 默认值 |
|---|---|---|---|
| 匿名(未携带凭据) | 按来源 IP | rate-limit.requests / window-ms |
6 次 / 60 秒 |
| 已认证(有效 API Key) | 按凭据名称 | rate-limit.credential-requests / credential-window-ms |
60 次 / 60 秒 |
| 缓存管理接口 | 按管理凭据或来源 IP | rate-limit.admin-requests / admin-window-ms |
6 次 / 60 秒 |
三层互不影响:已认证调用方不再占用匿名 IP 桶,匿名流量也不会吃掉凭据配额。
- 未超限的响应带
X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset; - 超限返回
429并带Retry-After,同时计入snapshot_rate_limited_total{scope="anonymous|credential|admin"}。
SNAPSHOT_METRICS_ACCESS=open # 默认:任何调用方都可读取
SNAPSHOT_METRICS_ACCESS=credential # 需要有效 API Key
SNAPSHOT_METRICS_ACCESS=admin # 需要管理凭据/health 与 /ready 探针始终开放,不受该配置影响。
核心限制位于 src/main/resources/application.yaml:
ktor:
deployment:
shutdownGracePeriod: 10000
shutdownTimeout: 15000
snapshot:
trust-proxy-headers: false
# trusted-proxy-cidrs:
# - 192.0.2.10/32
max-request-size: 1048576
max-concurrent-renders: 4
max-render-timeout-ms: 30000
max-document-elements: 4096
max-document-depth: 128
# 渲染队列背压:0 表示不排队、直接拒绝
max-render-queue: 32
render-queue-timeout-ms: 5000
# 渲染结果缓存:相同 DSL 直接返回上次输出
render-cache:
enabled: true
max-entries: 256
max-bytes: 67108864
ttl-ms: 60000
# 解析错误高亮图(?errorImage=png)
error-image:
enabled: true
max-lines: 8
max-columns: 80
context-lines: 2
# 耗时响应头:Server-Timing 与 X-Snapshot-Cache / X-Snapshot-Image-Count
timing-headers:
enabled: true
# 留空表示开放调用;填写后 /snapshot 需要 API Key,多 Key 见 api-keys 列表。
api-key: ""
# 配置 API Key 后是否仍允许匿名调用;默认关闭。
anonymous-access-enabled: false
# 是否对外提供 /openapi.yaml 与 /ai-guide.md;默认关闭。
public-docs-enabled: false
access-log-enabled: true
access-log-skip-paths:
- /health
- /ready
- /metrics
metrics-enabled: true
# /metrics 访问控制:open / credential / admin
metrics-access: open
rate-limit:
# 匿名按来源 IP
requests: 6
window-ms: 60000
# 已认证按凭据
credential-requests: 60
credential-window-ms: 60000
# 管理接口
admin-requests: 6
admin-window-ms: 60000
max-canvas-width: 4096
max-canvas-height: 4096
max-canvas-pixels: 16777216
image:
max-image-num-once: 10
max-single-image-size: 5242880
memory-cache-num-limit: 100
max-cache-bytes: 268435456
max-image-width: 4096
max-image-height: 4096
max-image-pixels: 16777216
max-total-image-pixels: 16777216
allow-private-hosts: false
connect-timeout-ms: 10000
read-timeout-ms: 10000
# 图片缓存 TTL:0 表示永不过期
cache-ttl-ms: 600000
# 瞬时失败重试
max-retries: 1
retry-backoff-ms: 200配置只在一处解析(SnapshotConfig),并在启动时一次性校验:非法值会让服务启动失败,
错误信息会同时列出全部问题,而不是修一个报一个。例如:
Invalid snapshot configuration:
- snapshot.max-request-size must be a positive integer, but was '-1'
- snapshot.metrics-enabled must be 'true' or 'false', but was 'maybe'
几项容易踩错的约束:
snapshot.api-key至少 16 位;snapshot.admin-endpoints-enabled=true时必须提供snapshot.admin-token、SNAPSHOT_ADMIN_TOKEN,或一个admin: true的 API Key;- 环境变量优先于配置文件:
SNAPSHOT_API_KEY、SNAPSHOT_API_KEYS、SNAPSHOT_ADMIN_TOKEN、SNAPSHOT_METRICS_ACCESS; - 列表配置(CORS 域名、字体族、访问日志跳过路径)在 YAML 中写成列表,在环境变量 /
-P:覆盖中用英文逗号分隔,例如SNAPSHOT_CORS_ALLOWED_HOSTS="localhost:3000,127.0.0.1:3000"。
下表列出 application.yaml 中的全部配置项、默认值,以及对应的容器环境变量(docker/entrypoint.sh 会转成 Ktor -P: 覆盖参数)。
| 配置项 | 默认值 | 环境变量 |
|---|---|---|
ktor.deployment.host |
0.0.0.0 |
SNAPSHOT_SERVER_HOST |
ktor.deployment.port |
8080 |
SNAPSHOT_SERVER_PORT |
ktor.deployment.shutdownGracePeriod |
10000 |
SNAPSHOT_SHUTDOWN_GRACE_MS |
ktor.deployment.shutdownTimeout |
15000 |
SNAPSHOT_SHUTDOWN_TIMEOUT_MS |
snapshot.trust-proxy-headers |
false |
SNAPSHOT_TRUST_PROXY_HEADERS |
snapshot.trusted-proxy-cidrs |
空列表 | SNAPSHOT_TRUSTED_PROXY_CIDRS(逗号分隔) |
snapshot.max-request-size |
1048576 |
SNAPSHOT_MAX_REQUEST_SIZE |
snapshot.max-concurrent-renders |
4 |
SNAPSHOT_MAX_CONCURRENT_RENDERS |
snapshot.max-render-timeout-ms |
30000 |
SNAPSHOT_MAX_RENDER_TIMEOUT_MS |
snapshot.max-document-elements |
4096 |
SNAPSHOT_MAX_DOCUMENT_ELEMENTS |
snapshot.max-document-depth |
128 |
SNAPSHOT_MAX_DOCUMENT_DEPTH |
snapshot.max-render-queue |
32 |
SNAPSHOT_MAX_RENDER_QUEUE |
snapshot.render-queue-timeout-ms |
5000 |
SNAPSHOT_RENDER_QUEUE_TIMEOUT_MS |
snapshot.render-cache.enabled |
true |
SNAPSHOT_RENDER_CACHE_ENABLED |
snapshot.render-cache.max-entries |
256 |
SNAPSHOT_RENDER_CACHE_MAX_ENTRIES |
snapshot.render-cache.max-bytes |
67108864 |
SNAPSHOT_RENDER_CACHE_MAX_BYTES |
snapshot.render-cache.ttl-ms |
60000 |
SNAPSHOT_RENDER_CACHE_TTL_MS |
snapshot.error-image.enabled |
true |
SNAPSHOT_ERROR_IMAGE_ENABLED |
snapshot.error-image.max-lines |
8 |
SNAPSHOT_ERROR_IMAGE_MAX_LINES |
snapshot.error-image.max-columns |
80 |
SNAPSHOT_ERROR_IMAGE_MAX_COLUMNS |
snapshot.error-image.context-lines |
2 |
SNAPSHOT_ERROR_IMAGE_CONTEXT_LINES |
snapshot.timing-headers.enabled |
true |
SNAPSHOT_TIMING_HEADERS_ENABLED |
snapshot.api-key |
空(开放调用) | SNAPSHOT_API_KEY |
snapshot.api-keys(列表) |
空 | SNAPSHOT_API_KEYS(名称:密钥[:admin]) |
snapshot.anonymous-access-enabled |
false |
SNAPSHOT_ANONYMOUS_ACCESS_ENABLED |
snapshot.public-docs-enabled |
false |
SNAPSHOT_PUBLIC_DOCS_ENABLED |
snapshot.admin-endpoints-enabled |
false |
SNAPSHOT_ADMIN_ENDPOINTS_ENABLED |
snapshot.admin-token |
空 | SNAPSHOT_ADMIN_TOKEN |
snapshot.metrics-enabled |
true |
SNAPSHOT_METRICS_ENABLED |
snapshot.metrics-access |
open |
SNAPSHOT_METRICS_ACCESS |
snapshot.access-log-enabled |
true |
SNAPSHOT_ACCESS_LOG_ENABLED |
snapshot.access-log-skip-paths(列表) |
/health、/ready、/metrics |
SNAPSHOT_ACCESS_LOG_SKIP_PATHS |
snapshot.cors.allowed-hosts(列表) |
localhost:3000、127.0.0.1:3000 |
SNAPSHOT_CORS_ALLOWED_HOSTS |
snapshot.font-family-names(列表) |
Inter、Noto Serif SC、DejaVu Serif、Noto Color Emoji |
SNAPSHOT_FONT_FAMILY_NAMES |
snapshot.rate-limit.requests |
6 |
SNAPSHOT_RATE_LIMIT_REQUESTS |
snapshot.rate-limit.window-ms |
60000 |
SNAPSHOT_RATE_LIMIT_WINDOW_MS |
snapshot.rate-limit.credential-requests |
60 |
SNAPSHOT_RATE_LIMIT_CREDENTIAL_REQUESTS |
snapshot.rate-limit.credential-window-ms |
60000 |
SNAPSHOT_RATE_LIMIT_CREDENTIAL_WINDOW_MS |
snapshot.rate-limit.admin-requests |
6 |
SNAPSHOT_RATE_LIMIT_ADMIN_REQUESTS |
snapshot.rate-limit.admin-window-ms |
60000 |
SNAPSHOT_RATE_LIMIT_ADMIN_WINDOW_MS |
snapshot.max-canvas-width |
4096 |
SNAPSHOT_MAX_CANVAS_WIDTH |
snapshot.max-canvas-height |
4096 |
SNAPSHOT_MAX_CANVAS_HEIGHT |
snapshot.max-canvas-pixels |
16777216 |
SNAPSHOT_MAX_CANVAS_PIXELS |
snapshot.image.max-image-num-once |
10 |
SNAPSHOT_MAX_IMAGE_NUM |
snapshot.image.max-single-image-size |
5242880 |
SNAPSHOT_MAX_SINGLE_IMAGE_SIZE |
snapshot.image.memory-cache-num-limit |
100 |
SNAPSHOT_MEMORY_CACHE_NUM_LIMIT |
snapshot.image.max-cache-bytes |
268435456 |
SNAPSHOT_MAX_CACHE_BYTES |
snapshot.image.max-image-width |
4096 |
SNAPSHOT_MAX_IMAGE_WIDTH |
snapshot.image.max-image-height |
4096 |
SNAPSHOT_MAX_IMAGE_HEIGHT |
snapshot.image.max-image-pixels |
16777216 |
SNAPSHOT_MAX_IMAGE_PIXELS |
snapshot.image.max-total-image-pixels |
16777216 |
SNAPSHOT_MAX_TOTAL_IMAGE_PIXELS |
snapshot.image.allow-private-hosts |
false |
SNAPSHOT_ALLOW_PRIVATE_HOSTS |
snapshot.image.connect-timeout-ms |
10000 |
SNAPSHOT_IMAGE_CONNECT_TIMEOUT_MS |
snapshot.image.read-timeout-ms |
10000 |
SNAPSHOT_IMAGE_READ_TIMEOUT_MS |
snapshot.image.cache-ttl-ms |
600000 |
SNAPSHOT_IMAGE_CACHE_TTL_MS |
snapshot.image.max-retries |
1 |
SNAPSHOT_IMAGE_MAX_RETRIES |
snapshot.image.retry-backoff-ms |
200 |
SNAPSHOT_IMAGE_RETRY_BACKOFF_MS |
密钥类变量(SNAPSHOT_API_KEY、SNAPSHOT_API_KEYS、SNAPSHOT_ADMIN_TOKEN)由应用直接读取环境变量,
不会转成 JVM 命令行参数,因此不会出现在进程列表里;其余变量由 docker/entrypoint.sh 转成 -P: 覆盖参数。
发布前验证:
./gradlew test
./gradlew build/health只表示进程还活着,不触发重启以外的判断,容器HEALTHCHECK使用它;/ready会验证渲染链路:首次调用执行一次 1×1 冒烟渲染(解析 → 布局 → Skia → 编码), 并检查字体环境与网络图片缓存是否就绪,通过后缓存结果。
因此负载均衡应使用 /ready 判断是否继续转发流量,用 /health 判断是否需要重启容器。
应用监听 Ktor 的停机事件:
- 收到
SIGTERM(docker stop、Kubernetes 终止)后先进入排水状态; - 排水期间
/ready返回503 NOT_READY,新的/snapshot请求返回503 SERVICE_UNAVAILABLE并带Retry-After: 5,已在执行的渲染继续完成; - 等待时间由
ktor.deployment.shutdownGracePeriod决定,超时上限为ktor.deployment.shutdownTimeout。
Netty 引擎至少会等待 shutdownGracePeriod,所以不要把它设置得远大于实际渲染耗时。
默认值为 10s + 15s,Compose 中对应 stop_grace_period: 30s。若调大了
snapshot.max-render-timeout-ms,应同步调大排水时间与容器停止超时。
由于 Netty 在停机开始时就会关闭监听端口,单实例部署仍可能出现连接失败。
要做到零中断滚动更新,请运行两个及以上副本,并让负载均衡依据 /ready 摘除节点。
snapshot.access-log-enabled: true 时每个请求输出一行结构化日志,探针路径默认不记录:
event=snapshot.access requestId=df6577b3-... ip=203.0.113.10 method=POST path=/snapshot status=200 durationMs=19 bytes=93
日志包含请求 ID、来源 IP、方法、路径、状态码、耗时和响应字节数,不记录 DSL 与图片地址;
IP 与匿名限流使用同一来源地址。默认取连接地址;同时开启 trust-proxy-headers、配置 trusted-proxy-cidrs 且连接对端属于可信网段时,才从 X-Forwarded-For 右侧开始反向跳过可信代理,取第一个不可信地址。X-Real-IP 不参与解析。
日志字段中的控制字符与空格会被替换,避免日志注入。
GET /metrics 输出 Prometheus 文本格式。snapshot.metrics-enabled: false 可以关闭该接口。
| 指标 | 类型 | 说明 |
|---|---|---|
snapshot_ready / snapshot_draining |
gauge | 当前就绪与排水状态 |
snapshot_uptime_seconds |
gauge | 进程运行时长 |
snapshot_render_in_flight |
gauge | 正在执行的渲染数 |
snapshot_render_pending |
gauge | 等待渲染槽位的请求数 |
snapshot_render_queue_wait_seconds |
histogram | 排队等待槽位的耗时分布 |
snapshot_image_fetch_seconds |
histogram | 单次请求在远程图片上消耗的时间分布 |
snapshot_render_queue_rejected_total{reason} |
counter | 背压拒绝数,reason 为 full 或 timeout |
snapshot_render_cache_hits_total / _misses_total / _evictions_total |
counter | 渲染结果缓存命中、未命中与淘汰 |
snapshot_render_cache_entries / _bytes |
gauge | 渲染结果缓存条目数与占用字节 |
snapshot_renders_total{outcome} |
counter | 渲染结果:success、empty_request、parse_error、render_error、image_error、too_large、timeout、rate_limited、unavailable、internal_error |
snapshot_render_duration_seconds |
histogram | 渲染耗时分布 |
snapshot_render_output_bytes_total |
counter | 输出图片总字节数 |
snapshot_http_requests_total{path,method,status} |
counter | 各路由请求数与状态码 |
snapshot_http_request_duration_seconds{path} |
summary | 各路由请求耗时 |
snapshot_image_cache_hits_total / snapshot_image_cache_misses_total |
counter | 网络图片缓存命中与未命中 |
snapshot_image_cache_evictions_total / _expirations_total |
counter | 图片缓存容量淘汰与 TTL 过期次数 |
snapshot_image_revalidations_total{result} |
counter | 条件请求结果:not_modified(复用)或 updated |
snapshot_image_downloads_total / snapshot_image_download_failures_total / _download_retries_total |
counter | 图片下载次数、失败次数与重试次数 |
snapshot_rate_limited_total{scope} |
counter | 被限流拒绝的请求数,按层级(anonymous/credential/admin)区分 |
snapshot_image_cache_entries / snapshot_image_cache_bytes |
gauge | 缓存图片数量与估算占用 |
路径标签收敛在固定集合内(未知路径记为 /other),不会因外部输入产生高基数。
Prometheus 抓取示例(容器拓扑下走内部网络,Nginx 默认对公网返回 404):
scrape_configs:
- job_name: open-snapshot
metrics_path: /metrics
static_configs:
- targets: ["snapshot:8080"]-
URL 与 Data URI 图片共用
max-image-num-once和max-total-image-pixels,缓存命中也计入预算; -
两种来源都受单图编码大小、解码宽高和像素数限制;
-
Data URI 仅接受 PNG、JPEG、WebP 的严格 Base64,并校验声明格式与文件签名;
-
URL 图片额外限制为 HTTP(S),默认阻止本机、私网、链路本地、保留网段及云元数据地址。
-
全局 LRU 缓存同时受
memory-cache-num-limit(数量)与max-cache-bytes(总字节)约束; -
淘汰与清空只丢弃引用,不会关闭可能正被其他并发渲染绘制的图片: Skiko 的
Managed在对象不可达后由 Reference Cleaner 回收本地内存; -
同一 URL 的并发请求只下载与解码一次,其余调用共享同一结果;
-
文档树还受
max-document-elements与max-document-depth限制,防止递归构建 Widget 时栈溢出或长时间占用渲染槽位。
cache-ttl-ms 控制缓存新鲜度(默认 10 分钟,0 表示永不过期):
- 条目未过期 → 直接命中;
- 条目过期且响应带
ETag→ 发起If-None-Match条件请求,304时复用原图片并刷新过期时间,200时替换为新图片; - 条目过期且没有
ETag→ 重新下载。
瞬时故障(5xx、408、429、连接或读取失败)会按 retry-backoff-ms × 重试次数 退避重试,
最多 max-retries 次;4xx 等确定性失败不重试。
图片相关指标:snapshot_image_cache_hits_total、_misses_total、_entries、_bytes、
_evictions_total(容量淘汰)、_expirations_total(TTL 过期)、
snapshot_image_revalidations_total{result="not_modified|updated"}、
snapshot_image_downloads_total、_download_failures_total、_download_retries_total。
GET /fonts 返回当前实际安装的字体族,每行一个;GET /fonts.png 支持按需渲染。两者无需启用管理接口,但会与 /snapshot 共用相应身份的限流配额。如果已配置 API Key 且未允许匿名访问,请在下面的命令中添加普通 API Key 请求头。字体预览每页默认 10 个、最多 20 个,避免一次性出图过大:
# 只渲染指定字体(逗号分隔,名称大小写不敏感)
curl --get --data-urlencode "family=DejaVu Serif,Inter" \
http://localhost:8080/fonts.png --output fonts.png
# 分页:每页 10 个字体,第 2 页
curl "http://localhost:8080/fonts.png?offset=10&limit=10" --output fonts-page2.pngfamily中有不存在的字体族时返回400 FONT_NOT_FOUND(消息里列出未知名称);offset/limit不是非负整数或limit > 20时返回400 INVALID_QUERY;省略或设为 0 时每页 10 个;- 相同查询会命中渲染结果缓存,重复拉取不会再次渲染。
渲染结果缓存把「相同 DSL」的重复请求直接变成一次内存读取,跳过解析、布局与光栅化,也不占用渲染槽位:
- 键为 DSL 文本的 SHA-256;
render-cache.enabled可整体关闭; - 同时受
max-entries与max-bytes约束,按 LRU 淘汰; ttl-ms到期后重新渲染:DSL 中引用的网络图片内容可能变化;- DSL 中出现
noCache="true"时跳过缓存,尊重调用方要最新图片的意图; POST /cacheClear会同时清空图片缓存与渲染结果缓存,GET /cacheInfo返回两者的条目与字节数;- 命中情况见
snapshot_render_cache_hits_total/_misses_total/_evictions_total与_entries/_bytes。
队列背压让过载表现为快速失败而不是请求堆积:
max-render-queue:等待渲染槽位的请求数上限,超出返回503 QUEUE_FULL;设为0表示不排队;render-queue-timeout-ms:排队等待上限,超出返回503 QUEUE_TIMEOUT;- 两者都带
Retry-After,并计入snapshot_render_queue_rejected_total{reason="full|timeout"}; - 排队耗时分布见
snapshot_render_queue_wait_seconds,当前等待数见snapshot_render_pending。
容量建议:max-concurrent-renders 按 CPU 核数设置,max-render-queue 取并发数的 4–8 倍,
render-queue-timeout-ms 不超过调用方可接受的延迟(例如 1–5 秒)。
镜像在 Linux 构建阶段重新解析 Skiko 平台依赖,不能直接把 Windows 上生成的 fat JAR 放进 Linux 容器。
snapshot 当前从 GitHub Packages 获取,即使是公开包也需要拥有 read:packages 权限的 classic PAT。
先准备仅用于构建的 BuildKit secret:
mkdir -p .secrets
printf '%s' 'YOUR_GITHUB_USERNAME' > .secrets/gpr_user
printf '%s' 'YOUR_GITHUB_CLASSIC_PAT' > .secrets/gpr_key.secrets 已被 Docker 构建上下文和 Git 忽略,凭据不会复制到镜像层。启动服务和 Nginx 反代:
docker compose up -d --build
docker compose ps
curl http://127.0.0.1/health默认监听宿主机 80 端口。需要改端口时:
HTTP_PORT=8080 docker compose up -dCompose 默认把仓库中的 src/main/resources/application.yaml 只读挂载到容器的
/app/config/application.yaml。可以复制一份生产配置并通过环境变量切换挂载文件:
cp src/main/resources/application.yaml docker/application.production.yaml
SNAPSHOT_CONFIG_FILE=./docker/application.production.yaml docker compose up -d修改挂载文件后需要重启容器:
docker compose restart snapshot标量配置也可以通过环境变量覆盖。先复制示例文件:
cp .env.example .env例如:
HTTP_PORT=8080
SNAPSHOT_MAX_CONCURRENT_RENDERS=2
SNAPSHOT_MAX_RENDER_TIMEOUT_MS=20000
SNAPSHOT_RATE_LIMIT_REQUESTS=6
SNAPSHOT_RATE_LIMIT_WINDOW_MS=60000
SNAPSHOT_MAX_CANVAS_PIXELS=8388608
SNAPSHOT_MAX_IMAGE_NUM=5
SNAPSHOT_MAX_CACHE_BYTES=134217728
SNAPSHOT_ALLOW_PRIVATE_HOSTS=false
SNAPSHOT_IMAGE_CONNECT_TIMEOUT_MS=5000
SNAPSHOT_IMAGE_READ_TIMEOUT_MS=10000
SNAPSHOT_ACCESS_LOG_ENABLED=true
SNAPSHOT_METRICS_ENABLED=true
SNAPSHOT_SHUTDOWN_GRACE_MS=10000
SNAPSHOT_SHUTDOWN_TIMEOUT_MS=15000
SNAPSHOT_API_KEY=replace-with-a-long-random-key
SNAPSHOT_API_KEYS=web-frontend:key-0123456789abcdef,partner-a:key-fedcba9876543210
SNAPSHOT_ANONYMOUS_ACCESS_ENABLED=true
SNAPSHOT_METRICS_ACCESS=open
SNAPSHOT_RATE_LIMIT_CREDENTIAL_REQUESTS=120
SNAPSHOT_RATE_LIMIT_ADMIN_REQUESTS=4
SNAPSHOT_MAX_RENDER_QUEUE=16
SNAPSHOT_RENDER_QUEUE_TIMEOUT_MS=3000
SNAPSHOT_RENDER_CACHE_TTL_MS=300000
SNAPSHOT_IMAGE_CACHE_TTL_MS=600000
SNAPSHOT_IMAGE_MAX_RETRIES=2配置优先级为:非空环境变量 > 绑定挂载的 YAML > 镜像内默认 YAML。CORS 域名、字体族与 api-keys 列表属于列表配置,建议直接修改挂载的 YAML。SNAPSHOT_ADMIN_TOKEN、SNAPSHOT_API_KEY 与 SNAPSHOT_API_KEYS 由应用直接读取环境变量,不会被转换为 JVM 命令行参数,因此不会出现在容器的进程列表里;其余标量配置由 docker/entrypoint.sh 转换为 -P: 覆盖参数。GitHub PAT 等构建秘密不要写入 .env 或 YAML,仍然使用 .secrets 中的 BuildKit secret。
容器拓扑为 Internet -> Nginx -> open-snapshot:8080,应用端口不会直接暴露到宿主机。Nginx 默认包含:
- 1 MiB 请求体限制;
- 每个客户端 IP 每分钟 6 个请求,允许瞬时突发 3 个;
- 35 秒上游读取超时;
X-Forwarded-*与X-Request-Id请求头;/health与/ready探针转发(均不写访问日志);/metrics对公网返回 404,仅供内部网络抓取。
容器停机时 Docker 先发送 SIGTERM,Compose 通过 stop_grace_period: 30s
为应用的排水窗口留出时间;Kubernetes 部署时请相应设置 terminationGracePeriodSeconds。
应用及 Compose 默认不信任转发头,因此默认日志与匿名限流按 Nginx 连接 IP 计。若需区分客户端,先确认反代连接到应用时的实际 IP,设置 SNAPSHOT_TRUST_PROXY_HEADERS=true 和 SNAPSHOT_TRUSTED_PROXY_CIDRS=<代理IP>/32(IPv6 为 /128);多级反代可用逗号分隔的精确网段。Docker 容器 IP 可能重建后变化,需为代理固定 IP 或重新核对配置。不要把整个 Docker 私网或 0.0.0.0/0 设为可信;也不要将应用端口直接暴露给不可信网络。Nginx 会覆盖客户端提供的 X-Forwarded-For,而应用会从实际 TCP 对端反向校验可信链。头缺失、畸形或过长时回退到连接 IP;旧版直接信任最左侧 XFF 的行为已移除。
生产环境建议在 Nginx 前或 Nginx 内配置 TLS。若由云负载均衡器或 CDN 终止 TLS,当前配置可以直接作为内部 HTTP 反代使用。
工作流 .github/workflows/publish-container.yml 会先运行测试,再构建并发布多架构镜像到:
ghcr.io/<owner>/<repository>
触发规则:
main分支提交:推送main、latest和sha-*标签;v1.2.3标签:推送1.2.3、1.2、1和sha-*标签;- Pull Request:只测试和构建
linux/amd64,不推送镜像; - 手动执行:通过 GitHub Actions 的
workflow_dispatch。
如果 open-snapshot 仓库已经被授予读取 muedsa/snapshot GitHub Package 的权限,工作流会直接使用 GITHUB_TOKEN。否则需要在仓库 Actions Secrets 中配置:
SNAPSHOT_GPR_USER
SNAPSHOT_GPR_KEY
其中 SNAPSHOT_GPR_KEY 应为具有 read:packages 权限的 classic PAT。首次推送后可在 GHCR Package 设置中把镜像调整为公开可见。
This project was created using the Ktor Project Generator.
Here are some useful links to get you started:
Here's a list of features included in this project:
| Name | Description |
|---|---|
| Forwarded Headers | Allows handling proxied headers (X-Forwarded-*) |
| Rate Limiting | Ktor 官方按来源 IP 的请求限流 |
| Content Negotiation | Provides automatic content conversion according to Content-Type and Accept headers |
| CORS | Enables Cross-Origin Resource Sharing (CORS) |
To build or run the project, use one of the following tasks:
| Task | Description |
|---|---|
./gradlew test |
Run the tests |
./gradlew build |
Build the project |
./gradlew run |
Run the server |
If the server starts successfully, you'll see the following output:
2024-12-04 14:32:45.584 [main] INFO Application - Application started in 0.303 seconds.
2024-12-04 14:32:45.682 [main] INFO Application - Responding at http://0.0.0.0:8080