一个列表页面最多展示 50 条记录,每条记录只需要一张方形小图,但数据库保存的是原图 Key。客户端把 Key 拼成对象存储地址后,实际下载的仍是压缩后的高清图。
如果一张原图按 400 KB 估算,50 张就是约 19.5 MiB。页面上的图片可能只有 180 rpx,网络传输却按原图尺寸付费。对象存储设置长期缓存只能减少重复下载,解决不了第一次打开页面时的流量。
这次我在 RustFS 前增加了 imagor。数据库继续保存原图 Key,应用服务根据展示位置生成签名 URL,客户端再选择合适的规格。
最初只有一份 360x360 缩略图。继续测试后发现,固定方图只解决列表问题:内容页需要保留比例的展示图,带小字或编码图形的图片又不能使用较低质量。最后保留了少量明确规格,没有为每一种组件尺寸生成新变体。
接入后的请求链路
原图和缩略图没有混在数据库里:
数据库原图 Key
│
├─ 保存、编辑、归档 ─────> RustFS 原图
│
└─ 稳定的公开展示
│
├─ 应用服务生成签名 URL
▼
imagor
│
├─ S3 Loader 从 RustFS 读取原图
├─ libvips 裁切并输出 WebP
└─ Result Storage 把结果写回 RustFS接口保留原图字段,同时返回有限的展示规格:
{
"id": 123,
"image": "images/ORIGINAL_KEY.jpg",
"thumbnailUrl": "https://IMAGE_HOST/SIGNATURE/360x360/center/middle/filters:format(webp):quality(75)/images/ORIGINAL_KEY.jpg",
"displayUrl": "https://IMAGE_HOST/SIGNATURE/fit-in/1280x1280/filters:format(webp):quality(82)/images/ORIGINAL_KEY.jpg"
}这些是向前兼容的响应字段。旧客户端会忽略它们,新客户端遇到旧服务端或 imagor 未配置时仍可回退到 image。
为什么 URL 里有原图 Key 还要签名
签名不是用来隐藏图片 Key 的。它限制的是图片处理参数。
如果公开 /unsafe/,任何人都可以不断改变尺寸、滤镜和输出格式,让服务器生成大量不同结果。签名相当于应用服务对这组处理参数盖章。只有拿到 IMAGOR_SECRET 的服务才能生成有效 URL,客户端只拿到最终签名。
imagor 支持 SHA-1、SHA-256 和 SHA-512。这次使用 SHA-256,并把 Base64 签名截断到 40 个字符:
IMAGOR_SIGNER_TYPE=sha256
IMAGOR_SIGNER_TRUNCATE=40Node.js 侧的构造方式如下:
import { createHmac } from "node:crypto";
function signImagorPath(path: string, secret: string) {
return createHmac("sha256", secret)
.update(path)
.digest("base64")
.slice(0, 40)
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function createThumbnailUrl(imageKey: string, publicUrl: string, secret: string) {
const encodedKey = imageKey
.split("/")
.map((part) => encodeURIComponent(part))
.join("/");
const path =
`360x360/center/middle/filters:format(webp):quality(75)/${encodedKey}`;
const signature = signImagorPath(path, secret);
return `${publicUrl}/${signature}/${path}`;
}参与 HMAC 的内容是域名之后、签名之前的完整路径,不带开头的 /。应用服务和 imagor 的密钥、算法、截断长度必须完全一致。
IMAGOR_SECRET 只放在 imagor 和应用服务。不要写入客户端环境变量、前端包或公开仓库。密钥更换后,旧签名 URL 会失效,但 RustFS 中的原图和已有结果文件不会因此被删除。
Docker Compose 配置
imagor 和 RustFS 位于同一个 Docker 网络时,可以直接使用容器名访问 S3 端口:
services:
imagor:
image: shumc/imagor:1.9.2
restart: unless-stopped
environment:
PORT: "8000"
IMAGOR_SECRET: "${IMAGOR_SECRET}"
IMAGOR_SIGNER_TYPE: "sha256"
IMAGOR_SIGNER_TRUNCATE: "40"
AWS_REGION: "us-east-1"
AWS_ACCESS_KEY_ID: "${RUSTFS_ACCESS_KEY}"
AWS_SECRET_ACCESS_KEY: "${RUSTFS_SECRET_KEY}"
S3_ENDPOINT: "http://rustfs:9000"
S3_FORCE_PATH_STYLE: "1"
S3_LOADER_BUCKET: "media"
S3_RESULT_STORAGE_BUCKET: "media"
S3_RESULT_STORAGE_BASE_DIR: "imagor-cache"
IMAGOR_CACHE_HEADER_TTL: "8760h"
IMAGOR_CACHE_HEADER_SWR: "168h"
IMAGOR_REQUEST_TIMEOUT: "30s"
IMAGOR_LOAD_TIMEOUT: "10s"
IMAGOR_PROCESS_CONCURRENCY: "4"
IMAGOR_PROCESS_QUEUE_SIZE: "100"
networks:
- storage
networks:
storage:
external: true数据库里的 Key 已经是 images/xxx.jpg,因此没有设置 S3_LOADER_BASE_DIR。处理结果写入同一 Bucket 下的 imagor-cache/,不会覆盖原图。
生产环境不要设置:
IMAGOR_UNSAFE=1它会允许未签名请求直接使用图片处理接口。
用 Caddy 提供 HTTPS
原图域名继续代理 RustFS,图片处理使用另一个域名:
object.example.com {
encode gzip zstd
header Cache-Control "public, max-age=31536000, immutable"
reverse_proxy rustfs:9000
}
image.example.com {
encode gzip zstd
reverse_proxy imagor:8000
}imagor 会为成功响应设置 Cache-Control,因此没有在 Caddy 中强制覆盖图片处理域名的缓存头。否则 403、404 或 500 也可能带上一年的缓存时间。
Caddy、imagor 和 RustFS 在同一个 Docker 网络时,代理目标使用 imagor:8000。如果 Caddy 运行在宿主机,则需要映射 imagor 端口,并将目标改成宿主机能够访问的地址。
应用服务如何选择规格
应用服务配置公开域名和签名参数:
IMAGOR_PUBLIC_URL="https://image.example.com"
IMAGOR_SECRET="REDACTED_SECRET"
IMAGOR_SIGNER_TYPE="sha256"
IMAGOR_SIGNER_TRUNCATE=40接口按原图 Key 计算 URL,不把处理结果写回数据库,也不需要做迁移。这次使用三种规格:
- 方形缩略图:
360x360/center/middle/filters:format(webp):quality(75)。 - 普通展示图:
fit-in/1280x1280/filters:format(webp):quality(82)。 - 高保真图形:
fit-in/1200x1200/filters:format(webp):quality(100)。
方形缩略图用于卡片和网格。普通展示图保留长宽比,可以覆盖内容页、轮播和全屏查看。全屏没有再单独增加 1920x1920 规格,因为它会为同一原图制造另一份 Result Storage 对象和另一条首次请求。
高保真图形只用于包含小字、二维码或锐利边缘的图片。质量 100 不等于无损,但能减少低质量 WebP 对识别和阅读的影响。普通照片没有必要使用这档配置。
原图仍然有用途。保存到本地、重新编辑和长期归档时,应直接使用对象存储里的原文件。imagor 地址是展示派生物,不是新的主数据。
不要转换所有图片字段
接入 WebP 后,我一度想把响应里的所有图片地址都交给 imagor。这样写起来统一,存储结果却会快速膨胀。
适合转换的是应用自己管理、会被重复公开展示且原图 Key 稳定的图片。下面几类直接保留原地址更合适:
- 外部平台提供的头像和图片。这些资源通常已经压缩并带有自己的缓存,再处理只会多出一份派生对象。
- 频繁编辑的后台资源。内容还没有稳定下来时,先使用本地压缩结果或对象存储原图,能避免每次修改都留下新的缓存。
- 只用于保存、下载或再次编辑的原文件。这些场景需要完整输入,不需要展示规格。
服务端应显式决定哪些字段生成变体,不要递归扫描对象,只要遇到 image、avatar 一类名称就自动替换。字段白名单虽然朴素,但出了问题容易查。
客户端仍要兼容旧服务端。新增 thumbnailUrl、displayUrl 或对应数组字段时,保留原图字段作为回退。图片什么时候挂载、轮播如何限制请求数量,属于客户端加载问题,单独记录在 Taro 微信小程序图片提前加载问题排查。
公网检查
部署后先检查外层链路。下面是这次实际观察到的结果,域名已经泛化:
HTTP 308 跳转 HTTPS
HTTPS 首页 200
imagor 版本 v1.9.2
/unsafe/... 403
错误签名 403
/params 403
原图 200 image/jpeg
原图测试文件 47,052 bytes
原图 Cache-Control public, max-age=31536000, immutable
连续公网热请求 约 34–36 ms/unsafe/... 和错误签名返回 403,说明生产签名保护已经生效。403 响应没有长期缓存头。TLS 证书域名匹配,HTTP 也会自动跳转到 HTTPS。
有效签名 URL 还要按规格分别检查。最简单的方式是从真实接口复制一条图片地址:
curl -I "https://image.example.com/SIGNED_PATH"重点确认:
- 状态码是
200。 Content-Type是image/webp。- 图片尺寸和处理参数一致。
- 成功响应带有预期的
Cache-Control。 - 第二次请求不再重复处理原图。
- RustFS 的
imagor-cache/下出现结果文件。
如果客户端是微信小程序,还要把图片域名登记为合法服务器域名。开发工具可以关闭域名校验,真机不会因此自动放行。
缓存和密钥更换
图片 URL 由处理参数、原图 Key 和签名组成。尺寸、质量或格式改变后会得到另一条 URL,不会和原有规格混用。Result Storage 也会把它们保存为不同对象,所以规格数量本身就是存储成本。
一年缓存适合内容寻址或不会覆盖的对象 Key。如果应用会使用相同 Key 覆盖原图,应缩短客户端缓存时间,或者在 Key 中加入版本。Result Storage 可以避免 imagor 重复计算,但它代替不了浏览器和小程序自己的网络缓存。
修改 IMAGOR_SECRET 后,应用服务会生成新签名。旧 URL 无法再通过鉴权,因此 imagor 和应用服务需要同时更新并重启。不要把签名结果写入数据库;按请求根据原图 Key 生成,密钥轮换会轻松很多。