블로그로 돌아가기
2026년 8월 28일Sergei Solod13 분 읽기

Nginx 뒤에서 Bunny Storage를 두 달 운영한 뒤 이미지·비디오·오디오를 전용 미디어 서버로 옮긴 이유

두 달 동안 Bunny CDN이 아니라 Bunny Storage를 자체 Nginx 캐시의 private origin으로 사용했다. Production log에서는 cold media의 long-tail connection timeout과 MP4에서 Nginx Slice와 ETag 불일치라는 별도 문제가 확인됐고, 결국 더 단순한 private media origin으로 이전했다.

Bunny StorageNginx비디오 캐시Cache miss미디어 서버

거의 두 달 동안 미디어 전달 경로는 다음과 같았다.

Browser
  ↓
작업 서버
  ↓
Nginx
  ↓
local proxy_cache
  ↓ cache MISS
Bunny Storage

이미지, 비디오, 오디오는 Bunny Storage에 저장했다. 사용자가 Bunny에 직접 접근하는 구조는 아니었다. Nginx가 [https://storage.bunnycdn.com](https://storage.bunnycdn.com)에서 object를 요청하고 서버 측 AccessKey를 추가한 뒤, response를 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, audio 등 수많은 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의 실제 latency를 숨겼다

자주 요청되는 이미지는 cache에 남기 쉽다. 오래됐고 드물게 열리는 파일은 eviction될 가능성이 더 높다. 그래서 오래된 페이지들이 의도치 않게 origin benchmark가 됐다.

7월의 한 snapshot에서는 media cache 약 36 GB, cache file 355,000개 이상, 설정된 최대치 약 35 GB, root filesystem 사용률 약 93%가 보였다. 인접한 snapshot에서는 entry가 400,000개에 근접했다. 이 정도로 커도 cache는 유한하다.

핵심은 단순하다. 빠른 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줄에서 upstream timeout match가 317개였다. 전날 snapshot에서는 578개였다. unique user 수가 아니라 log line match 수지만, 단일 request 수준의 문제는 아니었다.

나쁜 경우는 특정 storage IP 하나에만 집중되지 않았다

109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193

여러 address에서 failure를 봤다. 따라서 내 서버 입장에서는 늘 나쁜 한 IP가 아니라 external origin path의 동작 전체가 문제였다.

원인이 storage backend, routing, peering, 내 provider에서 Bunny까지의 path, balancing, 기타 network 요소 중 무엇이었는지는 log만으로 증명할 수 없다. 증명할 수 있는 것은 일부 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가 항상 1초 느렸다는 뜻은 아니다. 대부분은 수십 ms였다. 중요한 것은 variance였다. 같은 origin hostname이 실제 파일 전송을 시작하기도 전에 1초가 넘는 connection path로 이어지는 경우가 있었다.

미디어 페이지에서는 평균보다 tail latency가 더 잘 보인다

79개 이미지가 30–50 ms에 로드되고 한 장만 1초가 걸려도 평균값은 좋아 보일 수 있다. 사용자는 평균을 보지 않는다. 빈 이미지 하나를 본다. 그래서 gallery에서는 p95, p99, 가장 느린 cold MISS를 더 중요하게 보게 됐다.

비디오는 완전히 다른 두 번째 문제를 드러냈다

Browser는 byte Range를 요청할 수 있다.

Range: bytes=0-1048575

seek 후에는:

Range: bytes=50000000-51048575

206 Partial Content로 응답하면 앞부분 전체를 다운로드하지 않고도 재생과 seek가 가능하다.

첫 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는 arbitrary하다. bytes=0-1048575, bytes=0-999999, seek 후 다른 offset이 모두 같은 MP4에 대해 다른 cache key가 된다. 이 fragmentation이 정확히 몇 GB를 차지했는지는 측정하지 않았으므로 숫자를 만들지는 않는다.

그다음 고정 1 MB slice로 바꿨다

Nginx Slice module은 큰 resource를 고정 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일 한 MP4의 반복 slice subrequest에서 etag mismatch in slice response가 발생했고 request는 109.61.89.53, 109.61.89.57, 79.127.226.193, 109.61.89.55 등의 주소로 갔다. 7월 31일에는 다른 MP4에서도 같은 종류의 error가 다시 나왔다.

처음에는 이를 “조각마다 다른 서명”이라고 표현했는데 기술적으로는 잘못된 설명이었다. 관련 값은 ETag이며 AccessKey, signed URL, 암호학적 signature가 아니다.

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

라면 두 조각이 같은 representation에 속한다고 안전하게 가정할 수 없다. 무작정 합치면 서로 다른 파일 version의 byte가 섞일 수 있다. Nginx의 byte-range caching 문서도 이 validator 검사를 설명한다.

내 log가 증명하는 것은 같은 MP4의 slice response 사이에서 Nginx가 incompatible ETag를 봤다는 사실이다. 각 subrequest의 literal ETag 값을 별도로 기록하지 않았기 때문에 왜 달랐는지의 내부 원인은 증명할 수 없다. upstream address가 바뀐 사실은 backend response inconsistency를 plausible하게 만들지만 inference로 남긴다.

Bunny Storage가 Range를 지원하지 않는다는 뜻은 아니다

Range 자체는 동작했고 206 Partial Content도 cache하고 있었다. 구체적인 문제는 Nginx Slice, 여러 storage response, 그리고 안전한 assembly에 필요한 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를 complete object로 저장하지 않도록 했다. Slice assembly 문제는 사라졌지만 cold Range가 다시 external origin에 더 의존하는 trade-off가 생겼다.

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을 저장하고 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 machine의 benchmark라고 말하지 않는다.

새 architecture

Browser
   ↓
작업 서버
   ↓
Nginx + local media cache
   │
   ├── HIT
   └── MISS
          ↓
      WireGuard
          ↓
     media origin
          ↓
        Nginx
          ↓
         SSD

Origin은 private이다. 작업 서버는 하나의 fixed private IP와 통신한다. 사용자 HTTPS는 작업 서버에서 끝나고, origin traffic은 이미 암호화된 WireGuard tunnel 내부에서 HTTP를 사용할 수 있다.

Cold path에서 사라진 것

작업 서버
↓
WireGuard
↓
fixed private IP
↓
Nginx
↓
SSD

이 직접 연결에서는 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이며 전체 대용량 video가 20 ms에 다운로드된다는 뜻이 아니다. 전체 전송은 크기와 throughput에 달려 있다.

예전 bad case와 새 cold path는 큰 차이가 난다

과거 정상 측정은 주로 29–44 ms였다. 나쁜 observed path는 약 1.054 s였다. 새로운 tiny cold request는 약 20 ms다.

그 특정 outlier와 20 ms를 비교하면 latency 차이는 약 52×다. 하지만 “내 서버가 Bunny Storage보다 52배 빠르다”는 product 비교가 아니라 두 실제 측정 path의 비교다.

MP4도 지금은 더 단순하다

Cold fill에서 Nginx가 private origin으로부터 MP4 전체를 받아 하나의 cache entry로 저장하고, 이후 browser의 arbitrary byte Range를 local cache에서 응답할 수 있다. Fill 때 upstream Range를 제거하고 proxy_force_ranges로 local partial response를 만들 수 있다.

cold MISS
↓
private origin에서 전체 MP4
↓
하나의 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 video library라면 안정적인 slice cache, HLS/DASH, video CDN, Bunny Stream 등을 고려할 것이다.

다른 큰 media에는 fixed slice가 여전히 유용하다

Nginx Slice 자체가 나쁜 것은 아니다. immutable object와 stable origin에서는 유용하다. 이제 모든 chunk가 내가 제어하는 하나의 origin과 하나의 filesystem representation에서 나온다는 점이 다르다.

MISS가 싸면 더 작은 cache도 더 빠르게 느껴질 수 있다

이전 cache는 약 35–36 GB였다. 새 cache는 min_free로 약 8 GB를 비워두면서 약 16 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를 받았다. 해당 window에는 새로운 502, 503, 504, 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를 본다.

비디오는 first Range, middle Range, suffix Range, seek를 흉내 낸 여러 non-sequential Range, cold/warm을 별도로 테스트하고 206, Content-Range, Content-Length, Accept-Ranges, ETag를 확인한다. Slice를 사용한다면 여러 slice에서 validator consistency를 명시적으로 검증한다.

하나의 cold URL과 20개의 서로 다른 cold URL은 같은 테스트가 아니다

proxy_cache_lock은 같은 cold cache key로 request가 몰릴 때 유용하다. 20개의 서로 다른 file을 하나의 origin request로 합치는 기능은 아니다. 따라서 50개의 서로 다른 cold image가 있는 페이지가 같은 URL을 반복하는 benchmark보다 더 현실적인 origin test일 수 있다.

Self-hosting은 제어권을 주지만 reliability를 무료로 주지는 않는다

Private media server는 critical path를 이해하기 쉽게 만들지만 backup, disk health, free space, update, firewall, monitoring, restore, redundancy는 내 책임이 된다. 단일 origin은 uncached object의 single point of failure가 될 수도 있다.

그래도 Bunny를 다시 선택할 상황

글로벌 사용자 배포, managed redundancy, 빠르게 증가하는 storage, 운영 부담 최소화가 필요하다면 Bunny를 다시 충분히 검토할 것이다. 그때는 Storage + Pull Zone/CDN 구성도 명시적으로 평가할 것이다.

이 실험은 VPS 한 대가 Bunny CDN보다 좋다는 것, Bunny Storage가 항상 느리다는 것, Bunny가 Range를 지원하지 않는다는 것을 보여주지 않는다. 내 workload에서 Bunny Storage를 Nginx cache의 direct runtime origin으로 사용했을 때 더 이상 관리하고 싶지 않은 cold-path 및 sliced-video behavior가 나왔다는 의미다.

지금 사용하는 기준

두 달 동안 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를 두 달 동안 추적하고 나니 바로 그 지루함이 필요했다.