返回博客
2026年8月28日Sergei Solod11 分钟阅读

在 Nginx 后面使用 Bunny Storage 两个月后,我为什么把图片、视频和音频迁到独立媒体服务器

两个月里,我没有使用 Bunny CDN,而是把 Bunny Storage 当作自建 Nginx 缓存后的私有 origin。Production log 暴露了 cold media 请求中的长尾连接超时,以及 MP4 上 Nginx Slice 与 ETag 不一致的另一类问题;最终我迁移到了一个更简单的私有 media origin。

Bunny StorageNginx视频缓存Cache miss媒体服务器

接近两个月,我的媒体分发路径一直是这样:

Browser
  ↓
工作服务器
  ↓
Nginx
  ↓
本地 proxy_cache
  ↓ cache MISS
Bunny Storage

图片、视频和音频存放在 Bunny Storage。用户不会直接访问 Bunny。我的 Nginx 从 [https://storage.bunnycdn.com](https://storage.bunnycdn.com) 请求 object,附加服务端的 AccessKey,把 response 缓存在本地 SSD,再由自己提供公开 URL。

先明确范围:这不是 Bunny CDN 的评测。我在这个实验里没有使用 Bunny CDN。Storage Zone + Pull Zone/CDN 是另一种分发架构,我仍然认为 Bunny CDN 是很强的产品。这里测试的是 Bunny Storage 直接作为我自建 Nginx cache 的 runtime origin。

为什么最初的架构看起来很合理

我的 workload 包含大量 immutable media object:AVIF、JPEG、PNG、MP4、WebM、音频以及其他静态文件,从小图片到大得多的视频都有。我不想把完整 dataset 放在 application server 上,但希望公开 URL、cache policy、redirect、缺失文件行为、Range 和 fallback 都由自己的 Nginx 控制。

                    ┌── HIT ── 本地 SSD
                    │
Browser → Nginx cache
                    │
                    └── MISS ── Bunny Storage

HIT 时效果非常好。Nginx 直接从本地磁盘返回 object,Bunny 根本不参与这个 request。恰恰是这种优秀表现,长期掩盖了真正重要的部分:cold MISS。

热门文件会掩盖 origin 的真实延迟

经常访问的图片更容易留在 cache 中。老旧、很少打开的文件更容易被 evict。因此老页面无意间变成了 origin benchmark。

7 月的一次 snapshot 中,大约有 36 GB media cache、超过 355,000 个 cache file,配置上限约 35 GB,root filesystem 使用率约 93%。附近其他 snapshot 中 cache entry 一度接近 400,000。再大的 cache 也仍然是有限的。

核心结论很简单:快速 HIT 不能证明 origin 快,只能证明本地 cache 快。

Production log 显示,有些请求在文件 body 开始传输前就失败了

upstream timed out
while connecting to upstream

以及:

upstream timed out ... while SSL handshaking to upstream

这和“大文件下载得慢”不是一回事。出现这些日志时,Nginx 仍然在尝试建立 upstream connection 或完成 TLS handshake。

7 月 30 日的一次 diagnostic snapshot 中,media error log 最后 5,000 行里有 317 条 upstream timeout 匹配。前一天的 snapshot 中有 578 条。它们是日志匹配数,不是 317 或 578 个独立用户,但足以说明这不是一次偶发 request。

坏情况并不集中在一个 Storage IP

109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193

我在这组地址中的多个 IP 上都见到过 failure。因此从我的服务器视角看,问题出现在 external origin path 的整体行为上,而不是某个永远故障的单一 IP。

仅凭日志,我无法证明根因究竟是 storage backend、routing、peering、我的 provider 到 Bunny 的网络路径、balancing,还是其他网络因素。我能证明的范围更窄:部分 cold MISS 无法在预期时间内建立 Bunny Storage upstream connection。

一次测量把 long tail 暴露得很清楚

109.61.89.53   total ≈ 29.8 ms
109.61.89.54   total ≈ 28.5 ms
109.61.89.57   total ≈ 42.7 ms
79.127.226.193 total ≈ 43.6 ms

但另一个实际测得的 path 完全不同:

109.61.89.55
TCP    1.017782 s
TLS    1.048306 s
TOTAL  1.054474 s

这并不意味着 Bunny Storage 永远需要一秒。大多数测量都只有几十 ms。真正重要的是 variance:同一个 origin hostname,偶尔会走到一个在有用数据开始传输之前就花掉一秒以上的 connection path。

对媒体页面来说,tail latency 比平均值更重要

如果 79 张图片在 30–50 ms 内完成,而一张需要一秒,平均值仍然可能很好看。用户看不到平均值,只会看到一个空白图片位。因此现在我更关注 p95、p99 和最慢的 cold MISS。

视频暴露了第二个完全不同的问题

浏览器可以对视频发送 byte Range:

Range: bytes=0-1048575

seek 之后可能请求:

Range: bytes=50000000-51048575

服务器返回 206 Partial Content 后,可以在不下载前面所有字节的情况下开始播放或跳转。

第一版 Range cache 把一个物理视频拆成了很多 cache key

proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_cache_key "$scheme|$host|$request_uri|range=$http_range";
proxy_cache_valid 200 206 301 302 30d;

Browser Range 是任意的。bytes=0-1048575bytes=0-999999、seek 后的其他 offset,都可能给同一个 MP4 生成不同 cache key。我没有测量这种 fragmentation 到底占用了多少 GB,因此不会编造数字。

后来我改成固定 1 MB slice

Nginx Slice module 可以把大 object 规范为固定 chunk:

0–1 MB
1–2 MB
2–3 MB
...
slice 1m;
proxy_set_header Range $slice_range;
proxy_cache_key "$scheme|$host|$uri|$slice_range";
proxy_cache_valid 200 206 30d;

从 cache reuse 的角度看,这漂亮得多。然后 production 开始出现:

etag mismatch in slice response while reading response header from upstream

MP4 slice 在 ETag 一致性上失败

7 月 30 日,同一个 MP4 的多个 slice subrequest 反复出现 etag mismatch in slice response,请求先后到达 109.61.89.53109.61.89.5779.127.226.193109.61.89.55 等地址。7 月 31 日,另一个 MP4 再次出现同类 error。

我最初把它口语化地称为“不同分片有不同签名”。技术上这个说法不准确。相关的是 ETag,不是 AccessKey、signed URL,也不是密码学签名。

Nginx 为什么拒绝拼接 ETag 不同的 slice

slice 1 → bytes 0–1 MB → ETag A
slice 2 → bytes 1–2 MB → ETag A

是相互一致的。但:

slice 1 → ETag A
slice 2 → ETag B

意味着 Nginx 无法再安全假设两段数据属于同一个 representation。盲目拼接可能把两个文件版本的字节混在一起。Nginx 关于 byte-range caching 的文章解释了这种 validator 检查。

我的日志能证明:Nginx 在同一个 MP4 的多个 slice response 之间看到了不兼容的 ETag。日志不能证明 ETag 为什么不同,因为当时我没有记录每个 subrequest 的 ETag 字面值。upstream address 的变化让 backend response 不一致成为一个合理解释,但它仍然只是推断。

这并不意味着 Bunny Storage 不支持 Range

Range 本身可以工作,我也明确 cache 过 206 Partial Content。具体问题是 Nginx Slice、多份 storage response,以及 Nginx 为了安全组装同一个 representation 所要求的 ETag consistency 组合在一起后的表现。

同时,视频也可能遇到普通的 origin connection timeout。因此我实际上有两类独立 failure mode:origin connection/latency,以及 Slice/ETag consistency。

最终我对 MP4 停用了 Slice

proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_cache_key "$scheme|$host|$uri";
proxy_no_cache $http_range;
proxy_cache_valid 200 30d;

Partial response 不再作为完整 object 缓存。这样消除了 MP4 slice assembly 问题,但 cold Range 又更依赖 external origin。

我意识到自己正在 Storage 前面手工搭一个 CDN 的一部分

Nginx 里逐渐堆上了 cache lock、stale、background update、Range、206、Slice、normalized chunk、keepalive、TLS reuse、retry、timeout tuning 和 custom cache key。每项单独看都合理,但我真正需要 origin 做的事情非常简单:保存 immutable file,然后返回字节。

替代 origin 被刻意设计得很简单

我把 media dataset 移到了一个 dedicated server 上,它的任务基本就是 Nginx + SSD。对于 pure static origin,如果网络和磁盘足够,约 1 vCPU、约 1 GB RAM、几百 GB SSD 的小机器可以作为合理的起点。

这不是通用 sizing rule。bandwidth、object size、concurrency、IOPS 和 MISS rate 都会影响需求。当前实际测量的 origin 资源更多,因此我不会把目前的 peak 当成严格的 1-vCPU/1-GB benchmark。

新架构

Browser
   ↓
工作服务器
   ↓
Nginx + 本地 media cache
   │
   ├── HIT
   └── MISS
          ↓
      WireGuard
          ↓
     media origin
          ↓
        Nginx
          ↓
         SSD

Origin 是 private。工作服务器只连接一个固定 private IP。用户侧 HTTPS 仍在工作服务器终止,origin 流量可以走已经加密的 WireGuard tunnel 内部 HTTP。

Cold path 里少了什么

工作服务器
↓
WireGuard
↓
固定 private IP
↓
Nginx
↓
SSD

对于这种直接的 server-to-server 关系,我不再需要 public DNS、从多个 storage address 中选择、public Internet path,以及和 Storage API 额外做 TLS handshake。

Cold MISS 便宜到体感上几乎和 HIT 一样

从工作服务器对真实文件做的小 Range test 通常是:

CONNECT ≈ 9.5–12.5 ms
TTFB    ≈ 19–23 ms
TOTAL   ≈ 19–23 ms

10/10、20/20 sequential 和 20/20 parallel 检查都通过。在 origin 本机,同一个小 request 一般只需要约 0.5–0.9 ms。

这些是小型 Range/TTFB test,不代表完整大视频能在 20 ms 下载完成。完整传输仍取决于文件大小和 throughput。

旧的 bad case 和新的 cold path 差距很大

旧结构正常测量时通常是 29–44 ms。实际观察到的 bad path 约为 1.054 s。新的 tiny cold request 约为 20 ms。

数学上把那个特定 1.054 s outlier 与 20 ms 比较,大约是 52× latency 差异。但这不是“我的服务器比 Bunny Storage 快 52 倍”的产品 benchmark,只是两个真实测量 path 的比较。

MP4 现在也更简单

Cold fill 时,Nginx 可以从 private origin 获取完整 MP4,存成一个 cache entry,之后浏览器的任意 byte Range 都从 local cache 返回。Fill 时可以清除 upstream Range,再通过 proxy_force_ranges 提供本地 partial response。

cold MISS
↓
从 private origin 获取完整 MP4
↓
一个 cached file
↓
client Range 本地返回 206

Full-object caching 也有代价

如果 cold MP4 是 500 MB,而第一个用户只想看十秒,cache fill 仍可能在两个服务器之间传输全部 500 MB。对于我的文件大小和访问模式,这个 trade-off 可以接受。对于大多为 cold 的 multi-GB 视频库,我会考虑稳定的 sliced cache、HLS/DASH、video CDN 或 Bunny Stream。

固定 slice 对其他大媒体仍然有意义

Nginx Slice 本身并不是坏功能。对于 immutable object 和 stable origin,它依然有用。区别是现在所有 chunk 都来自一个我控制的 origin 和一个我控制的 filesystem representation。

如果 MISS 足够便宜,更小的 cache 也可能感觉更快

旧 cache 大约 35–36 GB。新 cache 约稳定在 16 GB,因为我通过 min_free 刻意保留大约 8 GB 空间。

旧:
HIT  = 快
MISS = 有时非常痛苦

新:
HIT  = 快
MISS = 也足够快

大 cache 减少 MISS 数量。好的 origin 降低每次 MISS 的成本。对这个 workload 来说,后者比我原先想象的更重要。

真实 production traffic 也验证了 origin 的角色

一次 60 秒 snapshot 中,工作服务器向用户发送约 96.84 Mbit/s,从 media origin 接收约 7.07 Mbit/s。该窗口内没有出现新的 502503504 或 upstream timeout。

后续一次 30 秒 snapshot 中,public TX 为 61.36 Mbit/s,来自 origin 的 WireGuard RX 为 1.40 Mbit/s。origin bytes/public TX 比例为 2.28%。

我不会把它的倒数称作精确 cache-hit rate,因为 public traffic 不只有 media,byte 比例也不是 request 比例。但它清楚表明:cache warm-up 之后,origin 主要在处理 cold fill,而不是复制全部用户流量。

现在我会监控什么

$upstream_addr
$upstream_connect_time
$upstream_header_time
$upstream_response_time
$upstream_cache_status
$request_time
$status

把 HIT 和 MISS 分开,尤其关注 MISS p95/p99。

视频方面,我会测试起始 Range、中间 Range、末尾 suffix Range、用于 seek 的多个非连续 Range、cold/warm,并检查 206Content-RangeContent-LengthAccept-RangesETag。如果使用 Slice,还会明确验证多个 slice 之间的 validator consistency。

一个 cold URL 和 20 个不同 cold URL 不是同一种测试

proxy_cache_lock 适用于大量 request 同时需要同一个 cold cache key 的场景。它不会把 20 个不同文件变成一次 origin request。包含 50 张不同 cold image 的页面,可能比重复请求同一个 URL 更接近真实 origin 压力。

Self-hosting 带来控制权,不会免费带来可靠性

Private media server 让我更容易理解 critical path,但 backup、disk health、free space、更新、firewall、monitoring、restore 和 redundancy 都变成我的责任。

单一 origin 对未缓存 object 来说也是 single point of failure。High availability 需要额外工程。

什么情况下我仍然会选择 Bunny

如果需要全球用户分发、managed redundancy、快速增长的 storage,或者尽量减少基础设施运维,我完全会再次考虑 Bunny,并且会明确评估 Storage + Pull Zone/CDN 架构。

这个实验并不能证明一台 VPS 比 Bunny CDN 更好,也不能证明 Bunny Storage 总是很慢,更不能证明 Bunny 不支持 Range。它只说明:在我的 workload 中,把 Bunny Storage 直接作为自建 Nginx cache 的 runtime origin,会带来我不想继续管理的 cold-path 和 sliced-video 行为。

我现在采用的判断原则

两个月里,我调过 cache size、cache key、Range、206、Slice、1 MB chunk、keepalive、TLS reuse、retry、stale 和 timeout。最有效的改变反而更简单:换掉 origin。

如果用户想要的文件不在 cache 中,会发生什么?

如果答案是 stable origin、可预测的 TTFB、正确的 Range、一致的 validator 和可理解的 failure mode,那么 cache 只是优化。如果良好 UX 必须依赖“用户永远不要遇到 MISS”,cache 就是在掩盖更深层的架构问题。

对于我的 workload,一个小型、private、甚至有些无聊的 media origin 更合适。它不是 CDN,不是魔法,也不会消除运维责任。它只是让 cold path 变得无聊。经历两个月排查图片、connection timeout、video Range 和 etag mismatch in slice response 后,这种无聊正是我想要的。