Next.js 图片怎么这么贵?!——Cloudflare 原生 Image Resizing 避坑与最佳实践

在现代前端开发中,Next.js 提供的 <Image> 组件无疑是一项极具吸引力的特性,它能自动实现图片的懒加载、响应式裁剪和格式优化。有这些能力加持,用户就能更快打开图片,这对用户体验、网站 SEO 都有很大帮助。

这项功能依赖部署平台,Vercel,Cloudflare 都支持。众所周知,Vercel 各种服务收费很贵,我有一位朋友为了逃离 Vercel 的高昂账单,决定将项目部署在 Cloudflare 上。我们本以为能高枕无忧,谁知随着用户量的攀升,Cloudflare 上的默认 Next.js 图片优化竟然也成了一个隐藏的“钱包黑洞”。由于配置机制的缺陷,她的账单悄然无声的上升到每个月近 100 美元。于是紧急找我帮忙进行优化。

今天,我将结合这次帮她“抢救钱包”的实战过程以及相关的 HTTP Header 对比,带你深入剖析 Next.js 默认图片优化在 Serverless 环境下的计费致命伤,并分享基于 OpenNext 官方推荐的 Cloudflare Image Resizing 终极解决架构。

通过 HTTP Header 找到问题根源

在排查博客的带宽和计算成本时,我对比了另一个系统中两种不同图片请求策略返回的 HTTP Header。正是这份对比,揭开了成本暴增的秘密。

首先,我们看看默认启用 Next.js 图片优化(走 /_next/images/xxx返回的 Header:

alt-svc: h3=":443"; ma=86400
content-length: 145570
content-type: image/webp
date: Fri, 24 Apr 2026 02:06:35 GMT
server: cloudflare

看出问题了吗?这里竟然完全没有 Cache-Control,也没有 cf-cache-status,甚至没有 ETagLast-Modified

这意味着什么?

  1. 浏览器不缓存:因为没有 Cache-Control,浏览器极大概率在每次刷新页面时都会重新发起图片请求。

  2. 边缘节点不拦截:没有 CDN 的缓存命中状态,意味着这个请求直接穿透了 Cloudflare 的边缘网络,打到了运行在 Serverless 上的 Worker。

  3. 每次请求都可能烧钱:由于请求被丢给了 OpenNext 的 Image Optimization Worker,每一次加载都需要重新执行一遍繁重的图片缩放和格式转换算法。如果是按“调用次数 + 计算时长”计费的平台,随着流量的增长,这笔账单将极其惊人。

当然,按照 Cloudflare 的说法,他们会处理图片转换后的缓存,所以图片转换是“唯一转换”,每个月只计费一次。我们姑且信他们。

尝试抢救?徒劳无功

于是我就想:既然没缓存头,那我在项目里或者 CDN 上强行加上不就行了?

实测不行。

经过我的实际“踩坑”尝试:

  • 尝试在 public/_headers 中为 /_next/images 配置缓存头?完全无效。

  • 尝试在 Cloudflare 控制台的 Cache Rules 里拦截并覆写缓存规则?依然无法生效。

由于底层路由和 Worker 机制的限制,Next.js 默认的图片处理端点仿佛成了一个无法被外层有效拦截和缓存的“黑洞”。这使得放弃默认方案成了我们的唯一出路。

回归文档,使用 custom loader + /cdn-cgi/image

习惯使用 AI 开发后,我们常常会忘记,有更可靠的知识,散落在互联网的各处。于是我再次回到 OpenNext 官方文档,根据它的建议,我们可以实现一个自定义 loader,绕开 Next.js 的底层处理,直接拥抱 Cloudflare 原生的 Image Resizing 服务(即 /cdn-cgi/image)。

配置非常简单。首先,在项目根目录创建一个 image-loader.ts

// image-loader.ts
import type { ImageLoaderProps } from "next/image"; 

const normalizeSrc = (src: string) => { 
  return src.startsWith("/") && !src.startsWith('//') ? src.slice(1) : src; 
}; 

export default function cloudflareLoader({ src, width, quality }: ImageLoaderProps) { 
  const params = [`format=auto`, `width=${width}`]; 
  if (quality) { 
    params.push(`quality=${quality}`); 
  } 
  
  // 本地开发环境直接返回原图
  if (process.env.NODE_ENV !== "production") { 
    return src;
  } 
  
  // 生产环境拼接 Cloudflare 的处理前缀
  return `/cdn-cgi/image/${params.join(",")}/${normalizeSrc(src)}`; 
}

随后,在 next.config.ts 中开启自定义 Loader:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    loader: "custom",
    loaderFile: "./image-loader.ts",
  },
};
export default nextConfig;

当这套方案上线后,我们还需要在 Cloudflare Dashboard 中补上临门一脚——针对图片路径配置一条 Cache Rule。与之前的徒劳无功不同,/cdn-cgi/image 会完全继承并尊重你配置的缓存规则,这次拦截终于能稳定生效了

配置完成后,我们再来看新架构下的 Header:

age: 29255
cache-control: max-age=16070400
cf-cache-status: MISS
cf-int-resized: {"original_bytes":928292,"pixel_width":1200,"unique_transformations_billing":true}
content-type: image/avif
etag: "cfh10Mvj2y-Xjiaq9qLu9RBV1pShlRi4gXGnY8YkBODQ:69c8dc2c-e2a24"

这简直是一场华丽的性能蜕变:

  1. 半年强缓存max-age=16070400 让浏览器在未来 186 天内都可以直接从本地读取图片,从根源上掐断了无效请求。

  2. 协商缓存支持etag 的加入让 CDN 和浏览器能优雅地处理缓存刷新。

  3. 更极致的体积content-type 变成了 image/avif,相比 WebP 提供了更激进的压缩率。

为什么 MISSAge 共存?

细心的朋友可能已经注意到,上面的新 Header 中有一个看似矛盾的现象:
既然 Cloudflare 负责缓存,为什么 cf-cache-status 显示的是 MISS(未命中缓存),但同时又存在一个长达 8 小时的 age: 29255 呢?如果它真的 MISS 了,那是谁生成了这张图片?

这是一个非常经典的 CDN 架构问题。当我们看到 cf-cache-status: MISS 时,确实代表着离用户最近的这个 Cloudflare 边缘节点没有找到处理好的图片,它被迫触发了背后的 Image Resizing 服务进行了重新计算

那长达 8 个小时的 Age 是怎么回事?
答案藏在源站(Origin)里。当 Image Resizing 被触发时,它必须去源站拉取原始图片。此时你的源站(或者前面的 R2/S3 存储桶)可能本身自带了缓存,把一张缓存了 29255 秒的原图丢给了 Cloudflare。Cloudflare 恪守 HTTP 协议规范,在完成图片压缩后,将原图的 Age 标头原封不动地透传给了最终用户

所以事实是残酷的:这次请求确实触发了底层的重新计算。

聊胜于无的“独立转换”

既然触发了重算,那么这次请求需要我买单吗?
如果你仔细看 Header 中的内部调试信息,你会发现字段:"unique_transformations_billing": true

这表示,Cloudflare 大善人在计费逻辑上还是有所保留的:它按“独立转换数(Unique Transformations)”计费,而不是按“计算次数”计费!

如果是原生的 Next.js API,缓存一失效,计算发生多少次,你就得付多少次的钱。而 Cloudflare 的逻辑是:只要“原图URL + 缩放参数”这个组合在当月出现过,不管底层的边缘节点因为错失缓存帮你重算了 1 次还是 10k 次,整个月它都计算 1 次的额度。

极限压榨:如何让计算成本降到“每年一次”?

虽然不用按次掏钱,但出于架构师的极致追求,我们仍希望这张图片生成一次后,能安稳地躺在缓存里。那么,如何真正实现让重新转换降至“每年一次”呢?

1. 将边缘缓存(Edge Cache)强拉至一年

使用 Custom loader 之后,我们就可以绕过 /_next/images 的限制,给生成的图片加上超长缓存。
前往 Rules -> Cache Rules,新建规则:匹配 /cdn-cgi/images/*),将 Edge Cache TTLBrowser Cache TTL 均强制设置为 1 year

2. 认识现实的骨感:LRU 缓存淘汰

配置了 1 年,边缘节点就会真的帮你存 1 年吗?当然不是。CDN 节点不是免费的无限网盘。基于 LRU(最近最少使用)算法,如果一张冷门文章的图片几个月无人问津,节点随时会将它扫地出门。等到有朝一日再次被请求,依然会发生 MISS 并触发重新计算。

3. 终极奥义:开启 Cache Reserve(缓存预留)

如果你绝不允许缓存被淘汰触发任何无谓的重算,你需要祭出 Cloudflare 的大杀器——Cache Reserve
一旦开启这个功能,Cloudflare 会把所有命中过边缘节点的内容,偷偷备份到廉价的 R2 对象存储中。当边缘节点因为容量拮据把你的图片踢掉后,下次请求到来时,Cloudflare 会直接从 R2 把处理好的图片“捞”出来返回,彻底掐断触发 Image Resizing 重新计算的可能性。由于对象存储的费用远低于计算服务,这几乎是实现“单次计算,终身静态化”的最优解。

4. 胜利的宣告:来看看命中缓存后的完美 Header

当这些优化手段生效,或者你的热点图片被频繁访问时,再次抓包你将看到属于胜利者的 Header:

Cache-Control: max-age=16070400
Cf-Cache-Status: HIT
Cf-Int-Resized: {"original_bytes":928292,"pixel_width":1200,"image_header":"{\"fit\":\"scale-down\",\"quality\":70,\"width\":1200}","unique_transformations_billing":true}
Server-Timing: cfCacheStatus;desc="HIT", cfEdge;dur=8,cfOrigin;dur=0

请紧盯最后这几行:

  1. Cf-Cache-Status 终于变成了梦寐以求的 HIT

  2. 更绝的是 Server-Timing 字段揭示了性能真相:cfEdge;dur=8 意味着边缘节点仅仅花了区区 8 毫秒;而 cfOrigin;dur=0 代表源站耗时为 0 毫秒! 这意味着这次请求完完全全是由 Cloudflare 从节点的内存/硬盘里秒传的,没有触发 Image Resizing,没有消耗一丝一毫的计算资源。

至此,我们终于得见胜利的曙光。

结语

在构建现代 Web 架构时,我们很容易沉浸在框架带来的便利中,却忽略了基础设施底层的运行机制。通过简单的 custom loader 结合 Cloudflare 强大的边缘计算网络,我们不仅能显著降低带宽与计算成本,还能让全球用户享受到更极致的加载体验。

如果你也在使用 Next.js 和 OpenNext,不妨现在就打开控制台,看看你的图片请求头吧——或许,你正在浪费一笔本可以轻松省下的巨款。

觉得文章有帮助?

如果我的分享对你有所启发,欢迎通过赞助来支持我持续创作。

❤️ 赞助我

评论