ブログに戻る
2026年8月28日Sergei Solod13 分で読めます

Nginxの背後でBunny Storageを2か月使って分かったこと――画像・動画・音声を専用メディアサーバーへ移した理由

Bunny CDNは使わず、Bunny Storageを自前Nginxキャッシュのprivate originとして2か月運用した。Production logから、cold mediaでのlong-tailな接続timeoutと、MP4におけるNginx SliceとETag不整合という別の問題が見え、最終的に単純なprivate media originへ移行した。

Bunny StorageNginx動画キャッシュCache missメディアサーバー

約2か月間、メディア配信経路は次の構成だった。

Browser
  ↓
稼働サーバー
  ↓
Nginx
  ↓
local proxy_cache
  ↓ cache MISS
Bunny Storage

画像、動画、音声はBunny Storageに置き、ユーザーがBunnyへ直接アクセスすることはない。Nginxが[https://storage.bunnycdn.com](https://storage.bunnycdn.com)へリクエストし、サーバー側のAccessKeyを付与して取得し、local SSDへcacheしたうえで公開URLを配信していた。

最初に範囲を明確にしておきたい。これはBunny CDNの評価ではない。この検証ではBunny CDNを使っていない。Storage ZoneとPull Zone/CDNを組み合わせる構成は別の配信モデルであり、Bunny CDN自体は今でも非常に優れた製品だと考えている。今回検証したのは、Bunny Storageを自前Nginx cacheのruntime originとして直接使う構成だ。

当初の構成が合理的に見えた理由

扱うのはAVIF、JPEG、PNG、MP4、WebM、音声など、多数のimmutableなstatic mediaだ。小さな画像から比較的大きな動画までサイズもさまざま。dataset全体をapplication serverに置きたくはない一方、公開URL、cache policy、redirect、missing fileの扱い、Range、fallbackは自分のNginxで管理したかった。

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

HIT時の動作は非常に良かった。Nginxがlocal diskから直接返すので、Bunnyはそのrequestに一切関与しない。むしろこの速さが、長い間cold MISSの本当の挙動を隠していた。

人気ファイルはoriginの実力を隠す

頻繁に読まれる画像はcacheに残りやすい。古く、たまにしか開かれないファイルはevictionされやすい。そのため、古いページが結果的にoriginのベンチマークになっていた。

7月のあるsnapshotでは、media cacheが約36 GB、cache fileが355,000件超、設定上限が約35 GB、root filesystem使用率が約93%だった。近い時期のsnapshotではcache entryが400,000件近くまで増えていた。これだけ大きくてもcacheは有限で、cold objectは必ず存在する。

重要なのは、速いHITは速いoriginを証明しないということだ。証明しているのはlocal cacheが速いという事実だけだ。

Production logでは、ファイル本体を受信する前に失敗していた

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 matchがあった。前日のsnapshotでは578件だった。これはunique user数ではなくlog行のmatch数だが、単発事故ではないことは十分分かる。

悪いケースは特定のstorage IPだけに偏っていなかった

109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193

この複数のaddressでfailureを確認した。したがって自分のサーバーから見れば、常に壊れている1つのIPではなく、external origin path全体の挙動が問題だった。

原因がstorage backend、routing、peering、自分のproviderからBunnyまでの経路、balancing、その他のnetwork要因のどれだったかは、このlogだけでは証明できない。証明できるのは、一部のcold MISSが想定時間内にBunny Storageへのupstream connectionを確立できなかったことだ。

1回の計測で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

一方、1つのpathだけは:

109.61.89.55
TCP    1.017782 s
TLS    1.048306 s
TOTAL  1.054474 s

Bunny Storageが常に1秒遅かったわけではない。大半は数十msだった。問題はvarianceで、同じorigin hostnameから、実データ転送以前に1秒を超えるconnection pathへ当たるケースがあったことだ。

メディアページでは平均よりtail latencyが効く

79枚が30–50 msで読み込めても、1枚だけ1秒かかれば、平均は良くてもユーザーには空白が見える。そこでgalleryでは、平均値よりp95、p99、最も遅いcold MISSを重視するようになった。

動画では、まったく別の問題が出た

Browserは動画にbyte Rangeを使える。

Range: bytes=0-1048575

seek後には:

Range: bytes=50000000-51048575

のようなrequestを送れる。206 Partial Contentなら、前の全byteを取得せずに再生やseekが可能になる。

最初のRange cacheは1つの動画を多数の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で次のerrorが出始めた。

etag mismatch in slice response while reading response header from upstream

MP4 sliceがETag consistencyで失敗していた

7月30日、1つのMP4に対するslice subrequestでetag mismatch in slice responseが繰り返され、request先には109.61.89.53109.61.89.5779.127.226.193109.61.89.55などが含まれていた。7月31日には別のMP4でも同種のerrorが再発した。

当初は「各chunkの署名が違う」と表現していたが、技術的には違う。問題になっていたのは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

では、2つが同じrepresentationに属する保証がなくなる。無条件に結合すると別versionのbyteを混ぜる危険がある。Nginxのbyte-range cachingの記事でも、このvalidator確認が説明されている。

自分のlogから確実に言えるのは、同一MP4のslice response間でNginxが互換性のないETagを検出したことだ。各subrequestのETag文字列そのものは記録していなかったため、なぜ異なったかの内部原因までは証明できない。upstream addressの切り替わりからbackend responseの不整合は十分考えられるが、それは推測として扱う。

Bunny StorageがRangeに対応していないという話ではない

Range自体は動作し、206 Partial Contentもcacheしていた。問題は、Nginx Slice、複数のStorage response、安全なassemblyに必要なETag consistencyという組み合わせだった。

さらに動画は通常のorigin timeoutにも遭遇していた。つまり、connection/origin latencyとSlice/ETag consistencyという2つの独立したfailure modeがあった。

最終的に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をcomplete objectとしてcacheしない構成に戻した。これでslice assemblyの問題はなくなったが、cold Rangeは再びexternal originへ依存しやすくなった。

Storageの前に自分でCDNの一部を作っていると気づいた

Nginxにはcache lock、stale、background update、Range、206、Slice、固定chunk、keepalive、TLS reuse、retry、timeout tuning、custom cache keyが増えていった。個別にはどれも正しいが、originに本当に必要なのはimmutable fileを保存してbyteを返すことだけだった。

代わりのoriginは意図的に単純にした

media datasetを、ほぼNginx + SSDだけを担当するdedicated serverへ移した。pure static originなら、networkとdiskが十分という条件で、1 vCPU、約1 GB RAM、数百GBのSSD程度の小さなmachineを検討の出発点にできる。

これは万能なsizing ruleではない。bandwidth、object size、concurrency、IOPS、MISS rateで変わる。現在計測しているoriginはより多くのresourceを持つため、今のpeakを正確な1-vCPU/1-GB benchmarkとは言っていない。

新しい構成

Browser
   ↓
稼働サーバー
   ↓
Nginx + local media cache
   │
   ├── HIT
   └── MISS
          ↓
      WireGuard
          ↓
     media origin
          ↓
        Nginx
          ↓
         SSD

originはprivateで、稼働サーバーは固定private IPへ接続する。ユーザー向けHTTPSは稼働サーバーでterminateし、originへの通信はすでに暗号化されたWireGuard tunnel内のHTTPでよい。

cold pathから消えたもの

稼働サーバー
↓
WireGuard
↓
固定private IP
↓
Nginx
↓
SSD

この1対1の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のcheckが成功した。origin自身では同じ小requestが約0.5–0.9 msだった。

これは小さなRange/TTFB testであって、大きな動画全体が20 msで転送されるという意味ではない。full transferはサイズとthroughputに依存する。

昔の悪いケースと今のcold pathには大きな差がある

旧構成の正常時は29–44 ms程度が多かった。実測した悪いpathは約1.054 s。現在のtiny cold requestは約20 msだ。

特定の1.054 s outlierを20 msと割れば約52倍のlatency差になる。ただし「自前サーバーがBunny Storageより52倍速い」というproduct比較ではなく、2つの具体的なobserved pathを比較しただけだ。

MP4も今は単純になった

cold fill時にNginxがprivate originからMP4全体を取得し、1つのcache entryとして保存し、その後はbrowserのbyte Rangeをlocal cacheから返せる。fill中のupstream Rangeは消し、proxy_force_rangesでlocalなpartial responseを返せる。

cold MISS
↓
private originからMP4全体を取得
↓
1つのcached file
↓
client Rangeをlocalに206で返す

full-object cachingにもコストはある

cold MP4が500 MBで、最初のuserが10秒だけ見たい場合でも、cache fillでは500 MBすべてをserver間転送する可能性がある。自分のfile sizeとaccess patternでは許容できる。大半がcoldなmulti-GB動画なら、安定したslice cache、HLS/DASH、video CDN、Bunny Streamなどを検討する。

他の大きなmediaではfixed sliceも依然有効

Nginx Slice自体が悪いわけではない。immutable objectとstable originの組み合わせなら有効だ。今はすべてのchunkが、自分で管理する1つのoriginと1つのfilesystem representationから来る。

MISSが安ければ小さいcacheでも速く感じる

旧cacheは約35–36 GBだった。新cacheはmin_freeで約8 GBを空けるようにしているため、約16 GBで安定した。

旧:
HIT  = 速い
MISS = ときどき痛い

新:
HIT  = 速い
MISS = 十分速い

大きなcacheはMISSの回数を減らす。良いoriginはMISS1回あたりのコストを下げる。このworkloadでは後者のほうが想像以上に重要だった。

実際のproduction trafficでもoriginの役割が確認できた

60秒snapshotでは、稼働サーバーがユーザーへ約96.84 Mbit/sを送信し、media originから約7.07 Mbit/sを受信していた。そのwindowでは新しい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比率ではない。ただ、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を明示的に検証する。

1つのcold URLと20個の異なるcold URLは同じtestではない

proxy_cache_lockは同じcold cache keyに多数のrequestが来る場合に有効だが、20個の別ファイルを1つのorigin requestへまとめるものではない。50枚の異なるcold imageを含むページは、同じURLを何度も叩くbenchmarkより現実的なorigin testになり得る。

self-hostingで得られるのは制御であって、無料の信頼性ではない

private media serverはcritical pathを理解しやすくする一方、backup、disk health、free space、update、firewall、monitoring、restore、redundancyは自分の責任になる。1台のoriginはuncached objectに対するsingle point of failureでもある。

それでもBunnyを選ぶ場面

グローバル配信、managed redundancy、急増するstorage、運用負担の最小化が必要なら、Bunnyを再び普通に検討する。その場合はStorage + Pull Zone/CDN構成を明示的に評価する。

この実験は、1台のVPSがBunny CDNより優れていること、Bunny Storageが常に遅いこと、BunnyがRangeに対応していないことを示してはいない。自分のworkloadでは、Bunny Storageを自前Nginx cacheのruntime originとして直接使ったとき、もう管理したくないcold-pathとsliced-videoの挙動が発生した、という話だ。

今使っている判断基準

2か月の間にcache size、cache key、Range、206、Slice、1 MB chunk、keepalive、TLS reuse、retry、stale、timeoutを改善した。最も効いた変更はもっと単純だった。originを変えたことだ。

ユーザーが欲しいファイルがcacheにないとき、何が起きるのか。

答えがstable origin、予測可能なTTFB、正しいRange、consistent validator、理解できるfailure modeなら、cacheはoptimizationになる。良いUXが「MISSに絶対遭遇しないこと」に依存するなら、そのcacheは深いarchitecture上の問題を隠している。

自分のworkloadでは、小さくprivateで退屈なmedia originのほうが合っていた。CDNではないし、魔法でもなく、運用責任も消えない。ただcold pathが退屈になった。画像、connection timeout、video Range、etag mismatch in slice responseを2か月追い続けた後では、その退屈さこそ欲しかったものだった。