ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Docker部署weserv-images:打造非侵入式图片处理中间件

用Docker部署weserv-images:打造非侵入式图片处理中间件 如果你维护过一个图片规模不小的站点大概率被下面这几件事烦过同一张原图要生成五六种尺寸前端一换设计就要重新跑批量任务线上图片格式还停留在 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/?urlcdn.example.com/uploads/photo.jpgw400fitcover这个变化看着小但它把“图片怎么处理”的决定权从前端展示层拉到了统一入口规则集中、参数透明、缓存可控。1.3 什么项目适合这个方案什么情况别硬上做了三四个中间件项目之后我总结了一套判断标准。适合的基本是这几类内容型网站文章、社区、BBS图片来自用户上传或编辑器电商场景的商品图需要列表图、详情图、购物车小图等多种尺寸以及原图源在外站、需要代理拉取并做缓存的场景。不适合的也对应三种。强合规、强私有的图片比如证件照、合同附件不要让中间件去代抓访问控制要绝对收紧日访问只有几百张的小站点用脚本定时裁剪更省心没必要多维护一个常驻服务对图片处理有极高精度要求的场景比如医疗影像、印刷设计这类中间件也不是按那个精度设计的。中间件不是银弹它的收益在大流量、多格式、多尺寸的组合场景里才明显。流量小的时候你只会觉得它多占用了一份运维精力。2. weserv-images 核心原理libvips 引擎与“一切皆 URL”的处理管线2.1 一个请求在 weserv-images 内部是怎么流转的weserv-images 的本质是一个图片代理服务。一次请求到达时它按顺序做四件事解析 URL QueryString拿到原图地址和处理参数用 HTTP 把原图拉回来内置超时和重试调用底层引擎执行缩放、裁剪、格式转换把结果写回响应并在本地留下磁盘缓存。完整请求长这样https://img.example.com/?urlcdn.example.com/uploads/photo.jpgw600fitcoverq80整个流程里业务端唯一感知到的变化是图片地址的 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.04Docker 24.0 以上机器配置 2C4G。图片处理确实是 CPU 密集但内存门槛不算高小流量场景 4G 完全够。如果并发高或者原图都是大图建议加到 4C8G并把后面的 Nginx 缓存和磁盘缓存分开规划。先确认 Docker 守护进程正常systemctl status docker docker version如果机器还没装 DockerUbuntu 上可以用官方脚本快速装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/?urlimages.weserv.nl/logo.pngw200正常情况会返回 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: - TZAsia/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 接入的核心原则业务系统保持无感接入中间件之前我给自己定了三条规矩不开发任何新接口不改动图片上传路径不在业务端维护一套处理参数。要做到这三点唯一要做的事就是把业务系统输出图片地址的那段逻辑统一替换成“中间件地址 原图完整地址 处理参数”。原先的 HTMLimg srchttps://cdn.example.com/uploads/photo.jpg alt接入后img srchttps://img.example.com/?urlcdn.example.com/uploads/photo.jpgw400fitcover alt上传逻辑没动存储没动后端接口没动只有最终输出的 HTML 变了。这其实就是非侵入式中间件的全部意义对上游存储只读对下游业务只提供 URL 改写能力。4.2 三种落地姿势按侵入程度从小到大排我实际用过三种方式这里按推荐程度从低到高说。方法一前端模板层改写。前端框架封装一个 Image 组件组件内部自动拼参数。好处是上线快不需要后端发版坏处是参数逻辑分散在各处适合快速验证。方法二后端渲染层改写。PHP 模板或 Java 模板引擎在输出图片地址时统一调用工具函数。参数由服务端下发便于做 AB 测试和动态配置排查问题时链路也清楚。对于多数团队我最推荐这一种。方法三CDN 或网关层 rewrite。在 CDN 回源规则里把图片路径重写成中间件地址这是最彻底的“无侵入”但需要 CDN 支持规则改写而且排障不如前面两种直观适合已经有成熟 CDN 体系的团队。4.3 旧图片地址不能一夜全换兼容层怎么设计线上页面、老 App 版本里的图片地址不可能立刻全部替换。这里我踩过一个教训新地址刚切完旧地址立刻 404当天线上裂图一大片。后来我学乖了在中间件前加一层 URL 兼容层——旧路径形如/uploads/photo.jpg新路径形如/?urlcdn.example.com/uploads/photo.jpgw400用 Nginx rewrite 把两种格式都映射到后端。location /uploads/ { if ($args ) { return 200; } rewrite ^/uploads/(.*)$ /?urlcdn.example.com/uploads/$1 last; }这个规则的核心思想是老的路径仍然可用但已经被中间件接管并且会在返回时带上处理参数。灰度观察一段时间确认访问量稳定后再让业务端输出新格式风险就小多了。5. 生产环境每天都在用的参数组合缩略、裁剪、转格式与压缩5.1 基础缩放固定宽度、等比缩放最常用的缩放就两个。固定宽度、高度自适应https://img.example.com/?urlcdn.example.com/u/1.jpgw300等比缩放并填满固定盒子https://img.example.com/?urlcdn.example.com/u/1.jpgw300h300fitcoverfitcover的概念跟 CSS 里的object-fit: cover一样先等比缩放再居中裁剪输出尺寸恰好是 300x300。这是列表图、头像场景的标准搭配我几乎每天在用。5.2 智能裁剪人物的眼睛保持在画面中心固定居中裁剪有个大问题竖构图的人物居中裁出来很可能脸被切掉一半。那个时候我一度怀疑中间件是不是只适合风景图直到我配置并实测了智能裁剪。weserv-images 支持基于显著性分析的裁剪在处理人像、商品图时会自动选择画面信息量最高的区域。我拿一组产品图实测过开启智能裁剪之后带人物的图基本都能把人物眼睛保持在不远离画面中心的位置。这个功能对头像裁剪尤其有价值用户头像本身构图不可控靠固定规则怎么都优化不好。5.3 自动格式协商WebP/AVIF 让带宽下降三到五成我接入中间件时最看重的是格式自动协商。weserv-images 可以根据请求的Accept头自动返回 WebP或者保持原 JPEG/PNG 格式。对浏览器来说这是透明的不需要前端写一堆 UA 判断。带格式协商的请求示例https://img.example.com/?urlcdn.example.com/u/1.jpgw800outputwebp实测效果很直观同一张 800x600 的 JPEG 原图转成 WebP 后体积大约下降 30% 到 50%再进一步转 AVIF体积还能继续降但 AVIF 编码的 CPU 开销更高实时处理时机器要留余量。就我这边的经验全站默认切 WebP带宽账单能肉眼可见地降下来。5.4 质量参数q 不是越低越好用q参数控制质量https://img.example.com/?urlcdn.example.com/u/1.jpgw800q75这里的原则是别无脑压到 60 以下。图片一旦出现摩尔纹或色带用户观感会断崖式下降省的那点带宽根本不值。我的经验值摄影类图片 q 在 75 到 85UI 截图和图形类图片可以到 70再低就很危险。5.5 滤镜、模糊与水印偶尔用但要用对场合灰度图在活动页里比较常见比如把某些不上架的奖品置灰。模糊可以用在背景图毛玻璃效果上。水印功能我反而用得不多因为业务有专门的图文排版系统但如果你临时要给一批外链图打水印这个功能确实是即时可用的。下面是一个灰度的例子https://img.example.com/?urlcdn.example.com/u/1.jpgw400filtgreyscale这类滤镜参数不影响原图只影响输出结果所以即使前端误用重新拼一个 URL 就能恢复代价很低。6. 吞吐量怎么顶住缓存设计、并发限制与容量评估6.1 磁盘缓存命中率决定一切weserv-images 默认会在本地磁盘缓存处理结果。同一个 URL 参数如果重复请求直接命中缓存返回不再执行真实的图片处理。所以这个中间件的吞吐能力很大程度上取决于缓存命中率而不是单张图片处理速度。在做缓存策略时我最关注的一点是参数可控性。图片 URL 参数如果无限发散缓存就永远打不中比如有些同事习惯在 URL 上拼随机数或时间戳这会让每个请求都变成真实处理。我会在 Nginx 层把这些无关参数剥掉只保留图片处理相关的 QueryString 再做缓存键。6.2 Nginx 层加一道缓存后端压力可以小一个数量级流量上来之后我还会在 Nginx 上加一层 proxy_cache专门缓存中间件响应proxy_cache_path /var/cache/nginx/weserv levels1:2 keys_zoneweserv:10m max_size10g inactive7d; 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 能到两三千但一旦带w800outputwebp这种真实参数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 每周清一次超过保留时间的数据。第四图片中间件不只服务于前端页面。后端的分享卡片、海报生成、邮件图片渲染都可以复用同一套接口。所以从规划第一天起就把它当公司级基础设施来对待而不是某个项目的附属品。这套中间件方案在我这边稳定跑了一年多中间经历过活动流量波峰也经历过上游原图被恶意刷取但整体没有出过大问题。如果你也在被图片存储、带宽和前端多尺寸需求折磨建议先用上述步骤搭一个最小实例跑几天真实流量再决定要不要全量切换。
返回列表