☰
用Docker部署weserv-images:打造非侵入式图片处理中间件
2026/9/29 15:06:31 网站建设 项目流程

如果你维护过一个图片规模不小的站点,大概率被下面这几件事烦过:同一张原图要生成五六种尺寸,前端一换设计就要重新跑批量任务;线上图片格式还停留在 JPEG,可明明一半以上用户用的浏览器都支持 WebP;一到活动高峰期,图片服务被刷得 CPU 忽高忽低,秒开变成白屏。我这两年的解法,是把图片处理从业务代码里抽出来,单独做成一个中间件:用 Docker 部署 weserv-images,业务代码一行不改,前端只需要把图片地址的前缀换掉,就能按需获得裁剪、缩放、格式转换、质量压缩的结果。这篇文章就把这套方案讲透——底层原理、部署步骤、接入方式、参数实测,以及我在生产环境里踩过的坑。

1. 把图片处理从业务代码里拆出来:存储膨胀和带宽账单教会我的事

1.1 业务代码生成缩略图的两条老路,各有各的痛

先说我早年干过的一件事。团队第一个业务系统里,图片处理是写在后端服务里的:上传原图时,用 GD 或 Pillow 同步生成 300px、600px、1200px 三个版本,扔到对象存储。这个方案看着简单,问题其实一大堆。

第一是存储膨胀。一套原图加三套派生图,存储量直接翻四倍。当时觉得也没多少钱,后来线上图片累积到几十 TB 的量级,光每月的存储账单就让人肉疼。更要命的是,这些派生图真正被高频使用的比例很低,很多尺寸只是“可能将来要用”,相当于一直在为不确定性交钱。

第二是逻辑侵入。图片处理代码和业务代码搅在一起,每次调整尺寸都要发版测试。前端同事为了一个 banner 变体提需求,后端要改模型、改定时任务、改清洗脚本,一个看似简单的小改动,链路长得吓人。后面我才意识到,问题不在“怎么处理图片”,而在“图片处理和业务逻辑压根不该住在同一个进程里”。

1.2 另一种思路:代理式处理,业务端只认 URL

后来我接触到了另一种做法——把图片处理摘成一个独立的代理服务。前端请求的还是一张图片 URL,但这个 URL 指向中间件,中间件负责从原始图地址拉图、执行画布操作、返回处理结果。业务系统不感知处理细节,存储层永远只保留一份原图。

这个思路的本质是“用到时再处理,结果按需返回”,而不是“上传时全量加工”。它天然消灭了重复存储,也让尺寸参数变成了 URL 上的 QueryString。前端想改列表图尺寸?改模板即可,后端不用发版。

举个例子,原来前端拿到的图片地址是这样:

https://cdn.example.com/uploads/photo.jpg

接入中间件之后变成:

https://img.example.com/?url=cdn.example.com/uploads/photo.jpg&w=400&fit=cover

这个变化看着小,但它把“图片怎么处理”的决定权从前端展示层拉到了统一入口,规则集中、参数透明、缓存可控。

1.3 什么项目适合这个方案,什么情况别硬上

做了三四个中间件项目之后,我总结了一套判断标准。

适合的,基本是这几类:内容型网站(文章、社区、BBS),图片来自用户上传或编辑器;电商场景的商品图,需要列表图、详情图、购物车小图等多种尺寸;以及原图源在外站、需要代理拉取并做缓存的场景。

不适合的,也对应三种。强合规、强私有的图片,比如证件照、合同附件,不要让中间件去代抓,访问控制要绝对收紧;日访问只有几百张的小站点,用脚本定时裁剪更省心,没必要多维护一个常驻服务;对图片处理有极高精度要求的场景,比如医疗影像、印刷设计,这类中间件也不是按那个精度设计的。

中间件不是银弹,它的收益在大流量、多格式、多尺寸的组合场景里才明显。流量小的时候,你只会觉得它多占用了一份运维精力。

2. weserv-images 核心原理:libvips 引擎与“一切皆 URL”的处理管线

2.1 一个请求在 weserv-images 内部是怎么流转的

weserv-images 的本质是一个图片代理服务。一次请求到达时,它按顺序做四件事:解析 URL QueryString,拿到原图地址和处理参数;用 HTTP 把原图拉回来,内置超时和重试;调用底层引擎执行缩放、裁剪、格式转换;把结果写回响应,并在本地留下磁盘缓存。

完整请求长这样:

https://img.example.com/?url=cdn.example.com/uploads/photo.jpg&w=600&fit=cover&q=80

整个流程里,业务端唯一感知到的变化是图片地址的 host 变了,参数则是标准的处理指令。这个“一切皆 URL”的设计,决定了它天然就是非侵入式的——只要输出图片地址的这层逻辑愿意改前缀,后面的事情全部交给中间件。

2.2 底层引擎是 libvips,不是 GD,也不是直接调 ImageMagick

这是我选它而不是自己写脚本的一个关键原因。weserv-images 的图像处理底层是 libvips,即便你听说过它,我也再强调一遍它在图片处理里的地位。

libvips 最突出的特点是流式处理和内存控制。处理超大图片时,它只在内存里保留当前需要的扫描区域,而不是把整张全尺寸位图一次性塞进去。这意味着同样的机器配置,跑同样的缩放任务,libvips 的内存占用可以低一个量级,并发吞吐反而更高。

我用一个小例子说明差别:一张 8000x6000 的 JPEG,用传统 GD 加载并缩放,一个 PHP-FPM 进程可能吃掉两三百 MB 内存,并发一高直接 OOM;换成 libvips 之后,同样的操作内存占用可能不到 100MB,而且处理更早开始输出。生产环境里这是一个决定性的优势。

2.3 和自研图像接口、托管服务的取舍

自研图片接口的优势是逻辑完全可控,但图片处理的边界情况实在太多了:EXIF 方向、GIF 帧数限制、透明通道、CMYK 转 RGB、超大尺寸内存上限,随便一项都能让你额外加班一周。如果你的团队不是专业做图像引擎的,我更建议站在开源项目肩膀上。

托管图片优化服务也有不少人用。它的优点是省心,但有两道现实门槛:图片源如果在自己内网环境,托管服务去拉内网原图往往受公网可达性限制;费用模型按处理量和存储量计费,流量一大账单会很刺激。自建 weserv-images 的好处是可控,镜像现成,数据通路完全在自己手里,也方便跟现有 Nginx、监控体系集成。

2.4 开源形态:一个容器,一层配置

weserv-images 在 GitHub 开源,官方也提供 Docker 镜像。它允许通过配置限制可处理的上游源,也允许开启签名机制来防盗用。这些细节决定了它从小工具到生产级组件的距离,后面安全部分我会专门推演。

现在你只需要记住一句话:它是一个把“图片处理能力”封装成网络服务的组件,而网络服务最大的好处,就是可以让任何语言写的业务系统通过标准 HTTP 来调用它。

3. Docker 部署全流程:一台干净 Linux 机器上的起手式

3.1 环境准备:2C4G 起步够不够

我实际部署过的环境是 Ubuntu 22.04,Docker 24.0 以上,机器配置 2C4G。图片处理确实是 CPU 密集,但内存门槛不算高,小流量场景 4G 完全够。如果并发高或者原图都是大图,建议加到 4C8G,并把后面的 Nginx 缓存和磁盘缓存分开规划。

先确认 Docker 守护进程正常:

systemctl status docker docker version

如果机器还没装 Docker,Ubuntu 上可以用官方脚本快速装:

curl -fsSL https://get.docker.com | sh systemctl enable --now docker

生产环境不建议这么鲁莽安装,但演示环境图省事可以这么干。正式环境建议用包管理方式安装,顺便加上 Docker 仓库的 GPG 校验。

3.2 单机运行:先跑通,再谈优化

拉镜像并启动,用最简单的命令:

docker pull weserv/images docker run -d --name weserv \ -p 8080:80 \ --restart unless-stopped \ weserv/images

启动后先做本地验证:

curl -I "http://127.0.0.1:8080/?url=images.weserv.nl/logo.png&w=200"

正常情况会返回 200,响应类型是图片。这一步跑通,说明 libvips 引擎、PHP 进程、网络出口都正常。之后,我再把127.0.0.1:8080换成正式域名,接上 HTTPS。

值得注意的是,容器默认的工作目录和缓存目录都在容器内。如果不挂卷,容器重启缓存全部丢失,第一次大量请求会重新触发处理,直冲上游存储。所以长期跑,卷必须挂。

3.3 用 docker-compose 管理:缓存目录、资源限制、环境变量

单机 docker run 够用,但我从来不会在正式环境这么裸跑。至少要用 docker-compose 把资源限制、重启策略、目录挂载固化下来。

以下是我常用的 compose 配置:

services: weserv: image: weserv/images:latest container_name: weserv restart: unless-stopped ports: - "127.0.0.1:8080:80" environment: - TZ=Asia/Shanghai volumes: - ./cache:/var/cache/weserv deploy: resources: limits: memory: 2g cpus: '2.0'

启动命令:

docker compose up -d docker compose logs -f weserv

有几个细节值得解释。端口只绑定在127.0.0.1,外面一律走 Nginx 或其他网关反代,这是安全底线。CPU 和内存限制,是为了防止某个超大图片请求把整机资源吃光。磁盘缓存目录挂出来,是为了容器重建时缓存还能复用。

3.4 前置 Nginx 反代与域名配置

Nginx 配置我一般这样写:

server { server_name img.example.com; listen 80; access_log /var/log/nginx/weserv.access.log; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 120s; proxy_buffering on; } }

这里有个容易忽略的点:图片响应一般不小,proxy_buffering建议保持默认的 on,让 Nginx 先把上游响应收完再发给客户端。如果你关了缓冲,Nginx 会边收边发,一旦客户端网速慢,上游占用会持续很久,并发一高就拖垮后端。

3.5 HTTPS:不要让重定向丢掉 QueryString

生产环境必然要上 HTTPS。我用 certbot 给img.example.com签证书,Nginx 里补 443 配置。但这里有个非常容易踩的坑:HTTP 跳 HTTPS 时,如果写成简单重定向,QueryString 可能被丢掉,图片请求直接 404。

我的做法是:

server { listen 80; server_name img.example.com; return 301 https://$host$request_uri; }

$request_uri带完整原始参数,能保证?url=...&w=...这种长串参数原样传递。别用$uri,它不带参数。

4. 非侵入式接入:业务代码不升级,只改 URL 前缀

4.1 接入的核心原则:业务系统保持无感

接入中间件之前,我给自己定了三条规矩:不开发任何新接口;不改动图片上传路径;不在业务端维护一套处理参数。要做到这三点,唯一要做的事就是把业务系统输出图片地址的那段逻辑,统一替换成“中间件地址 + 原图完整地址 + 处理参数”。

原先的 HTML:

<img src="https://cdn.example.com/uploads/photo.jpg" alt="">

接入后:

<img src="https://img.example.com/?url=cdn.example.com/uploads/photo.jpg&w=400&fit=cover" alt="">

上传逻辑没动,存储没动,后端接口没动,只有最终输出的 HTML 变了。这其实就是非侵入式中间件的全部意义:对上游存储只读,对下游业务只提供 URL 改写能力。

4.2 三种落地姿势,按侵入程度从小到大排

我实际用过三种方式,这里按推荐程度从低到高说。

方法一:前端模板层改写。前端框架封装一个 Image 组件,组件内部自动拼参数。好处是上线快,不需要后端发版,坏处是参数逻辑分散在各处,适合快速验证。

方法二:后端渲染层改写。PHP 模板或 Java 模板引擎在输出图片地址时统一调用工具函数。参数由服务端下发,便于做 AB 测试和动态配置,排查问题时链路也清楚。对于多数团队,我最推荐这一种。

方法三:CDN 或网关层 rewrite。在 CDN 回源规则里把图片路径重写成中间件地址,这是最彻底的“无侵入”,但需要 CDN 支持规则改写,而且排障不如前面两种直观,适合已经有成熟 CDN 体系的团队。

4.3 旧图片地址不能一夜全换:兼容层怎么设计

线上页面、老 App 版本里的图片地址不可能立刻全部替换。这里我踩过一个教训:新地址刚切完,旧地址立刻 404,当天线上裂图一大片。后来我学乖了,在中间件前加一层 URL 兼容层——旧路径形如/uploads/photo.jpg,新路径形如/?url=cdn.example.com/uploads/photo.jpg&w=400,用 Nginx rewrite 把两种格式都映射到后端。

location /uploads/ { if ($args = "") { return 200; } rewrite ^/uploads/(.*)$ /?url=cdn.example.com/uploads/$1 last; }

这个规则的核心思想是:老的路径仍然可用,但已经被中间件接管,并且会在返回时带上处理参数。灰度观察一段时间,确认访问量稳定后再让业务端输出新格式,风险就小多了。

5. 生产环境每天都在用的参数组合:缩略、裁剪、转格式与压缩

5.1 基础缩放:固定宽度、等比缩放

最常用的缩放就两个。固定宽度、高度自适应:

https://img.example.com/?url=cdn.example.com/u/1.jpg&w=300

等比缩放并填满固定盒子:

https://img.example.com/?url=cdn.example.com/u/1.jpg&w=300&h=300&fit=cover

fit=cover的概念跟 CSS 里的object-fit: cover一样,先等比缩放,再居中裁剪,输出尺寸恰好是 300x300。这是列表图、头像场景的标准搭配,我几乎每天在用。

5.2 智能裁剪:人物的眼睛保持在画面中心

固定居中裁剪有个大问题:竖构图的人物,居中裁出来很可能脸被切掉一半。那个时候我一度怀疑中间件是不是只适合风景图,直到我配置并实测了智能裁剪。

weserv-images 支持基于显著性分析的裁剪,在处理人像、商品图时会自动选择画面信息量最高的区域。我拿一组产品图实测过,开启智能裁剪之后,带人物的图基本都能把人物眼睛保持在不远离画面中心的位置。这个功能对头像裁剪尤其有价值,用户头像本身构图不可控,靠固定规则怎么都优化不好。

5.3 自动格式协商:WebP/AVIF 让带宽下降三到五成

我接入中间件时,最看重的是格式自动协商。weserv-images 可以根据请求的Accept头自动返回 WebP,或者保持原 JPEG/PNG 格式。对浏览器来说这是透明的,不需要前端写一堆 UA 判断。

带格式协商的请求示例:

https://img.example.com/?url=cdn.example.com/u/1.jpg&w=800&output=webp

实测效果很直观:同一张 800x600 的 JPEG 原图,转成 WebP 后体积大约下降 30% 到 50%;再进一步转 AVIF,体积还能继续降,但 AVIF 编码的 CPU 开销更高,实时处理时机器要留余量。就我这边的经验,全站默认切 WebP,带宽账单能肉眼可见地降下来。

5.4 质量参数:q 不是越低越好

用q参数控制质量:

https://img.example.com/?url=cdn.example.com/u/1.jpg&w=800&q=75

这里的原则是别无脑压到 60 以下。图片一旦出现摩尔纹或色带,用户观感会断崖式下降,省的那点带宽根本不值。我的经验值:摄影类图片 q 在 75 到 85,UI 截图和图形类图片可以到 70,再低就很危险。

5.5 滤镜、模糊与水印:偶尔用,但要用对场合

灰度图在活动页里比较常见,比如把某些不上架的奖品置灰。模糊可以用在背景图毛玻璃效果上。水印功能我反而用得不多,因为业务有专门的图文排版系统,但如果你临时要给一批外链图打水印,这个功能确实是即时可用的。

下面是一个灰度的例子:

https://img.example.com/?url=cdn.example.com/u/1.jpg&w=400&filt=greyscale

这类滤镜参数不影响原图,只影响输出结果,所以即使前端误用,重新拼一个 URL 就能恢复,代价很低。

6. 吞吐量怎么顶住:缓存设计、并发限制与容量评估

6.1 磁盘缓存命中率决定一切

weserv-images 默认会在本地磁盘缓存处理结果。同一个 URL 参数如果重复请求,直接命中缓存返回,不再执行真实的图片处理。所以这个中间件的吞吐能力,很大程度上取决于缓存命中率,而不是单张图片处理速度。

在做缓存策略时,我最关注的一点是参数可控性。图片 URL 参数如果无限发散,缓存就永远打不中,比如有些同事习惯在 URL 上拼随机数或时间戳,这会让每个请求都变成真实处理。我会在 Nginx 层把这些无关参数剥掉,只保留图片处理相关的 QueryString 再做缓存键。

6.2 Nginx 层加一道缓存:后端压力可以小一个数量级

流量上来之后,我还会在 Nginx 上加一层 proxy_cache,专门缓存中间件响应:

proxy_cache_path /var/cache/nginx/weserv levels=1:2 keys_zone=weserv:10m max_size=10g inactive=7d; location / { proxy_cache weserv; proxy_cache_key $host$uri$is_args$args; proxy_cache_valid 200 24h; proxy_cache_valid 404 1m; proxy_pass http://127.0.0.1:8080; }

这层缓存生效后,相同图片参数的第二个请求几乎不会再打到后端进程。从我的监控看,命中率超过 70% 之后,后端 CPU 从持续的 80% 掉到 20% 上下,效果非常明显。

缓存失效策略我建议按图片更新频率来定。如果图片经常换,就把proxy_cache_valid 200压到 2 小时;如果原图基本不变,24 小时甚至更长都可以。想强制刷新,就在 URL 后面加版本参数。

6.3 并发限制与系统观测指标

容器里的 libvips 使用的线程数、进程并发量,直接决定 CPU 争夺程度。建议在 compose 里限制 CPU 数和单容器内存上限。如果并发特别高,优先横向扩容:前置负载均衡,后端挂两台实例,共享同一套缓存目录,注意清理和一致性。

监控方面,我重点看三个指标:请求量、缓存命中率、5xx 比例。请求量和缓存命中率可以从 Nginx access log 和proxy_cache计数里拿。5xx 比例必须盯死,因为中间件一旦抖动,页面就是大范围裂图。

6.4 容量评估:别拿静态文件那套 QPS 来想象

给一个粗略模型:普通缩放和转码请求,在 4 核机器上能支撑的动态处理 QPS,远低于静态文件的几万 QPS。我之前压测过一个单实例,空请求 QPS 能到两三千,但一旦带w=800&output=webp这种真实参数,QPS 会降两个量级。

所以容量规划一定按“动态处理请求”算。先压真实参数组合,再估算单机峰值,然后留出至少 50% 余量。如果线上活动预期流量高,提前扩实例或者加 CDN,别临时抱佛脚。

7. 上线前必须做好的安全加固:防 SSRF、防资源耗尽、防签名绕过

7.1 SSRF 风险:代理服务最容易被盯上的点

weserv-images 的形态是“接收用户传来的 URL,服务端去请求”,这正是 SSRF(服务端请求伪造)的经典场景。攻击者可以传入127.0.0.1、10.0.0.8这类内网地址,让中间件去访问内网管理接口或云元数据接口,从而绕过网络隔离。

我见过不少直接把图片代理裸跑在公网上的案例,结果内网被扫了个底朝天。这里必须把安全措施前置到接入层,不能指望中间件自身一定万无一失。

7.2 上游域名白名单:黑名单永远有遗漏

防 SSRF 的第一道墙,是限制上游域名。如果你的业务原图地址可控,那就直接在配置里允许特定域名,其他域名一律拒绝。白名单比黑名单可靠得多,因为黑名单永远追不上攻击者的变种。

示意写法如下(具体变量名以官方文档和镜像版本为准):

environment: ALLOWED_DOMAINS: cdn.example.com,img.example.com

加了白名单之后,攻击者想用url参数访问内网地址,在入口就被挡掉了。前提是配置真的生效,建议上线后专门做一轮绕过测试。

7.3 协议与端口限制

就算域名被白名单放行,也要限制协议只能是 HTTP/HTTPS,并禁止访问非标准端口。内网很多服务的端口是 8000、8080、3306 这些,代理如果允许任意端口,端口扫描和横向探测就会容易很多。

我的加固组合是:域名白名单 + 只允许 80/443 + 对重定向行为做限制。这能挡掉绝大多数基础探测。要在中间件内部严格限制的话,最好在入口层再加一层网关策略,而不是只靠中间件自身配置。

7.4 防资源耗尽:超大图、超长编码、高频率

攻击者不一定要靠 SSRF 打穿内网,他可以直接用一张几十 MB 的巨型图反复请求,把 CPU 打满,这和 DDoS 几乎没有区别。所以必须从源头上限制处理代价。

我的经验是:源文件大小上限先收紧,比如 20MB;图片最大边控制在 8000px 以内,超过直接返回错误;单 IP 访问频率在网关层做限流。正常业务很少会有单张超过这个量级的图,真有的大图应该走专门的高清通道,别让中间件去扛。

7.5 签名机制:让 URL 不能随便构造

如果你想让图片服务更可控,可以给中间件启用签名机制。只有带正确签名的 URL 才会被处理,签名由服务端根据原图地址和处理参数生成。这能防止恶意参数遍历,还能保证只有自己的业务客户端能用这个服务。

签名校验放在接入层比较灵活,比如在 Nginx 上用 Lua 脚本校验 token,校验通过再转发给中间件。这个方案的价值在于:就算中间件本身有漏洞,公网请求进不来,攻击面瞬间小了很多。

8. 我的维护体会:镜像版本、日志脱敏与缓存清理

最后补几个纯个人的经验,都是踩了坑之后才长记性的。

第一,镜像版本别一直追 latest。我吃过一次亏,某次升级后默认行为变了,旧缓存全部失效,前端大面积裂图。现在我会固定到具体版本,升级前先在测试环境跑一遍常见参数用例,确认输出和旧版本一致再切。

第二,日志里涉及到图片原始地址的部分,最好脱敏再进日志平台。图片地址看起来只是文件名,但很多业务会把手机号、订单号拼在路径里,打到 ELK 之后再被同事一查,容易引发合规问题。

第三,磁盘缓存一定要设上限并定时清理,不然半年后一个几十 GB 的 cache 目录会静静躺在那里。我这边用 cron 每周清一次超过保留时间的数据。

第四,图片中间件不只服务于前端页面。后端的分享卡片、海报生成、邮件图片渲染都可以复用同一套接口。所以从规划第一天起就把它当公司级基础设施来对待,而不是某个项目的附属品。

这套中间件方案在我这边稳定跑了一年多,中间经历过活动流量波峰,也经历过上游原图被恶意刷取,但整体没有出过大问题。如果你也在被图片存储、带宽和前端多尺寸需求折磨,建议先用上述步骤搭一个最小实例,跑几天真实流量再决定要不要全量切换。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询