基于 imgproxy 为网站构建高效图片处理能力

痛点:图片处理的"最后一公里"

写博客的人大概都经历过这样的纠结:相机拍出来的照片一张 20MB,直接上传到服务器——读者打开页面要等 10 秒,每月 CDN 流量账单看着肉疼;压缩吧,不同页面需要不同尺寸,手动处理 100 张照片的痛苦谁做谁知道。

更麻烦的是现代 Web 对图片格式的要求:WebP 比 JPEG 体积小 30%-50%,AVIF 更小,但源图往往是 JPEG 或 PNG。每次新增一种格式就得重新压缩一遍,这谁顶得住。

理想的方案应该是:原始图片只存一份,不同尺寸、不同格式按需实时生成,并且生成一次后缓存下来,后续请求直接返回缓存——零处理开销。

这就是我部署 imgproxy 的原因。这篇文章记录我在 liigoo.net 博客中落地 imgproxy 的完整实践,包括架构设计、代码实现和一些踩坑经验。

imgproxy 是什么

imgproxy 是一个用 Go 语言编写的开源图片处理服务(MIT 协议),专为生产环境设计。它的核心能力:

  • URL-based API:所有处理参数编码在 URL 中,不需要写代码调用 SDK
  • HMAC-SHA256 签名:防止恶意用户构造 URL 消耗服务器资源
  • 实时处理:缩放、裁剪、锐化、格式转换、水印一气呵成
  • 极低资源占用:Go 单二进制文件,Docker 镜像约 20MB,内存占用通常不超过 100MB
  • 高吞吐:单进程可处理数百请求/秒,内置智能降采样避免 OOM

一句话概括:把图片 URL 丢给它,它返回处理好的图片

架构设计

我的博客部署在一台内网服务器上,Typecho(PHP)运行在 Docker 容器中,imgproxy 作为独立的 Docker 容器运行,只监听内网地址。整体架构如下:

image.png

整个流程分为两条链路:

读链路(页面访问)

  1. 浏览器请求页面 → Nginx → Typecho PHP
  2. PHP 渲染时调用 liigoo_preview_url() 获取缩略图 URL
  3. 函数先检查本地缓存:命中 → 直接返回缓存 URL(Apache 静态文件输出)
  4. 缓存未命中 → PHP 通过内网调用 imgproxy → imgproxy 下载并处理源图 → PHP 保存到本地缓存 → 返回缓存 URL
  5. 如果启用了 CDN 预生成模式,预览图直接走七牛云 CDN

写链路(文章发布)

  1. 作者在编辑器中粘贴图片或上传附件
  2. 保存文章时,后台自动提取文章中所有图片 URL
  3. 调用 imgproxy 批量生成 400px / 600px / 800px 三张预览图
  4. 如果开启了 OSS 上传,预览图自动上传七牛云,返回 CDN URL
  5. 预览图 URL 保存到 typecho_fieldsimage_settings 字段

核心实现

整个图片处理管线由三个 PHP 函数组成,全部在主题的 functions.php 中。

1. 动态缩略图 —— liigoo_thumb_url()

这是最基础的函数:传入原始图片 URL 和目标宽度,返回处理后的图片 URL。

function liigoo_thumb_url($originalUrl, $width, $height = 0) {
    // 检查是否为可处理的图片格式
    $ext = strtolower(pathinfo(parse_url($originalUrl, PHP_URL_PATH), PATHINFO_EXTENSION));
    if (!in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'avif', 'gif', 'bmp', 'svg'])) {
        return $originalUrl;
    }

    // 缓存 key = MD5(源URL + 处理参数)
    $cacheKey = md5($originalUrl . '|' . $width . '|' . $height . '|' . $format . '|' . $quality);

    // 缓存命中 → 直接返回,零处理开销
    if (file_exists($cacheFile)) {
        return $cacheUrl;
    }

    // 构建 imgproxy 签名 URL
    // URL 格式: /{signature}/{processing_options}/{base64_encoded_source_url}
    $processing = "rs:fit:{$width}:{$height}:1:0/g:sm/enlarge:0/q:{$quality}/f:{$format}";
    $signature = hmac_sha256($salt . $path, $key);

    // 通过内网调用 imgproxy,保存到本地缓存
    $imgData = file_get_contents("http://192.168.123.5:8081/{$signature}/{$path}");
    file_put_contents($cacheFile, $imgData);

    return $cacheUrl;
}

关键细节:

  • URL 规范化:对源图 URL 的 path 先 rawurldecoderawurlencode,防止含有中文或空格的文件名被双重编码
  • 格式转换:JPEG → WebP(80% 质量),PNG → PNG(90% 质量),GIF 保持原格式
  • 缓存优先:只有缓存未命中时才请求 imgproxy,后续请求直接走本地文件
  • 降级策略:如果 imgproxy 不可用,fallback 到公网 imgproxy URL 让浏览器直接加载

2. 预生成预览图 —— liigoo_generate_previews()

这个函数在文章保存时调用,一次性生成三张固定尺寸的预览图:

function liigoo_generate_previews($originalUrl, $oss = 0) {
    $sizes = [400, 600, 800];
    $previews = [];

    foreach ($sizes as $width) {
        // 构建 imgproxy 处理参数
        $processing = "rs:fit:{$width}:0:1:0/g:sm/enlarge:0/q:{$quality}/f:{$format}";

        // 调用 imgproxy 获取处理后的图片
        $imgData = file_get_contents($imgproxyUrl);

        if ($oss) {
            // 上传到七牛云 CDN
            $qiniuKey = "blog/uploads/{$date}/{$hash}_{$width}.{$format}";
            $cdnUrl = liigoo_upload_to_qiniu($tmpFile, $qiniuKey);
            $previews[$width] = $cdnUrl;
        } else {
            // 保存到本地缓存
            file_put_contents($cacheFile, $imgData);
            $previews[$width] = $localUrl;
        }
    }

    return $previews; // ['400' => '...', '600' => '...', '800' => '...']
}

两种模式的选择:

  • 本地模式oss=0):预览图保存在服务器本地 /app/usr/cache/thumb/,适合小流量站点
  • CDN 模式oss=1):预览图上传七牛云,通过 CDN 分发,适合需要加速的场景

3. 统一入口 —— liigoo_preview_url()

这是模板调用的统一入口,自动选择最佳预览图:

function liigoo_preview_url($originalUrl, $width, $post = null) {
    // 检查是否有预生成的预览图
    $settings = liigoo_image_settings($post);
    if (!empty($settings['previews'][$originalUrl])) {
        // 精确匹配或选最接近的较大尺寸
        // 400px 请求 → 优先返回 400px,其次 600px、800px
    }
    // 无预生成 → 回退到动态 imgproxy
    return liigoo_thumb_url($originalUrl, $width);
}

URL 签名机制

imgproxy 使用 HMAC-SHA256 对处理 URL 进行签名,防止恶意构造请求消耗服务器资源。签名流程:

签名 = Base64(HMAC-SHA256(SALT + 处理路径, KEY))
处理路径 = /处理参数/Base64(源图URL)

例如,将 https://cdn.liigoo.net/photo.jpg 压缩为 400px WebP 的完整 URL:

http://192.168.123.5:8081/{签名}/rs:fit:400:0:1:0/g:sm/q:80/f:webp/{Base64(源图URL)}

KEY 和 SALT 各 32 字节,通过环境变量注入,不写入代码。即使有人知道你的 imgproxy 地址,没有 KEY/SALT 也无法构造有效请求。

处理参数详解

我的博客使用以下核心参数组合:

参数含义我的设置
rs:fit:{w}:{h}:1:0等比例缩放,不放大根据场景设置宽度
g:sm智能锐化(Sharpening with masking)始终启用
enlarge:0禁止放大(小图不拉伸)始终启用
q:{n}JPEG/WebP 质量WebP 80%, PNG 90%
f:{fmt}输出格式JPEG→WebP, PNG→PNG

为什么选这些参数

  • enlarge:0 至关重要——如果用户上传了 300px 的小图,却请求 800px 的缩略图,imgproxy 默认会放大。对于照片站这是灾难(模糊+锯齿),必须禁止
  • g:sm 比单纯 sharpen 更好——带 masking 的锐化只锐化边缘区域,不会把噪点也锐化出来
  • WebP 而非 AVIF:AVIF 压缩率更高但编码慢 5-10 倍,实时处理场景下不可接受。WebP 是当前性价比最优解

缓存策略

缓存是整个管线的核心——imgproxy 再快也快不过直接读文件。

缓存目录结构:
/app/usr/cache/thumb/
├── a1b2c3d4e5f6.webp   # MD5(源URL|宽度|高度|格式|质量)
├── f7e8d9c0b1a2.webp
└── ...
  • 缓存 key = MD5(源 URL + 所有处理参数),保证不同参数不会互相覆盖
  • 永久缓存:源图不变则处理结果不变,无需过期策略
  • 手动清理rm -rf /app/usr/cache/thumb/* 强制重新生成
  • Warm-up:文章发布时预生成预览图,读者访问时永远命中

CDN 集成

对于需要加速的场景,预览图直接上传七牛云并通过 CDN 分发:

function liigoo_upload_to_qiniu($localPath, $key) {
    // 使用原始 HMAC-SHA1 签名 API,无需 SDK
    $policy = ['scope' => "liigoo:$key", 'deadline' => time() + 3600];
    $sign = hmac_sha1(base64($policy), $secretKey);
    $token = "$accessKey:$sign:$encodedPolicy";

    // POST 上传
    curl_post('https://up-z0.qiniup.com/', [
        'token' => $token,
        'key' => $key,
        'file' => new CURLFile($localPath),
    ]);

    return "https://cdn.liigoo.net/$key";
}

不需要 SDK——七牛云的 HMAC-SHA1 签名上传 API 只需要几十行代码。

部署配置

imgproxy 用 Docker 部署,一行命令搞定:

docker run -d --name imgproxy --restart always \
  -p 8081:8080 \
  -e IMGPROXY_KEY=9436f8873b0b9c2d8e5a7f1c3d4e6a8b \
  -e IMGPROXY_SALT=1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d \
  -e IMGPROXY_MAX_SRC_RESOLUTION=150 \
  -e IMGPROXY_ENABLE_WEBP_DETECTION=1 \
  darthsim/imgproxy:latest

几个重要的环境变量:

  • IMGPROXY_MAX_SRC_RESOLUTION:源图最大分辨率(MP),我设置为 150,因为某些摄影作品分辨率很高
  • IMGPROXY_KEY / IMGPROXY_SALT:签名密钥,与 PHP 端配置一致
  • 容器只监听 192.168.123.5:8081,不暴露公网端口——所有请求由 PHP 端代理

PHP 端通过可拔插配置管理 imgproxy 连接:

// usr/themes/liigoo/config.php
'imgproxy' => [
    'endpoint' => getenv('LIIGOO_IMGPROXY_ENDPOINT') ?: 'http://192.168.123.5:8081',
    'key'      => getenv('LIIGOO_IMGPROXY_KEY')      ?: '...',
    'salt'     => getenv('LIIGOO_IMGPROXY_SALT')     ?: '...',
],

所有配置支持环境变量覆盖,本地开发用 config.local.php 覆盖。

效果对比

以一个摄影文章页面(11 张照片)为例:

指标优化前(原图 CDN)优化后(imgproxy + 缓存)
单张图片大小8-15 MB (JPEG)80-150 KB (WebP)
首屏图片总大小~120 MB~1.5 MB
首屏加载时间8-12 秒1.2-1.8 秒
CDN 月流量~80 GB~6 GB
服务端 CPU0%(直接输出)<0.5%(首次生成后走缓存)

关键数字:图片体积减少 95%+,加载时间缩短 80%+

与其它方案对比

方案优点缺点
手动压缩完全可控费时费力,改版要重新处理
Cloudinary/imgixSaaS 省心贵,图片在第三方服务器
Nginx image_filter无额外服务功能极简,不支持 WebP
imgproxy自托管,功能全,性能好需要自己部署维护

对于自托管博客来说,imgproxy 是功能性和可控性的最佳平衡点。

总结

imgproxy 是我博客基础设施中最满意的组件之一。它解决了图片处理的痛点,同时几乎没有增加运维负担——Docker 部署后基本上不需要管它。

几条核心经验:

  1. 缓存是关键:永远不要让 imgproxy 重复处理同一张图。一次处理 + 永久缓存 = 零开销
  2. 内网直连:PHP 和 imgproxy 之间走内网,延迟低至毫秒级。不要走公网
  3. 签名必须开启:没有签名的 imgproxy 等于公开的计算资源,会被恶意利用
  4. enlarge:0 别忘:小图被放大是图片处理中最常见的 bug
  5. WebP 是当前最优解:压缩率接近 AVIF,编码速度远超 AVIF

如果你也在维护一个需要处理大量图片的网站,强烈推荐试试 imgproxy。


imgproxy 官网:https://imgproxy.net
本文架构图:FigJam

暂无评论