Trong gần hai tháng, đường đi của media trong hệ thống của tôi như sau:
Browser
↓
server hoạt động
↓
Nginx
↓
proxy_cache local
↓ cache MISS
Bunny Storage
Ảnh, video và audio nằm trong Bunny Storage. Người dùng không truy cập Bunny trực tiếp. Nginx của tôi yêu cầu object từ [https://storage.bunnycdn.com](https://storage.bunnycdn.com), thêm AccessKey phía server, cache response trên SSD local rồi tự phục vụ public URL.
Cần xác định phạm vi ngay từ đầu: đây không phải bài test Bunny CDN. Tôi không dùng Bunny CDN trong thử nghiệm này. Storage Zone + Pull Zone/CDN là kiến trúc phân phối khác, và tôi vẫn đánh giá Bunny CDN là một sản phẩm rất tốt. Thứ tôi test cụ thể là Bunny Storage trong vai trò runtime origin của cache Nginx do tôi quản lý.
Vì sao kiến trúc ban đầu trông hợp lý
Workload gồm rất nhiều immutable media object: AVIF, JPEG, PNG, MP4, WebM, audio và file static khác, từ ảnh nhỏ đến video lớn hơn nhiều. Tôi không muốn để toàn bộ dataset trên application server, nhưng muốn Nginx của mình kiểm soát public URL, cache policy, redirect, missing file, Range và fallback.
┌── HIT ── SSD local
│
Browser → Nginx cache
│
└── MISS ── Bunny Storage
Với HIT, hệ thống chạy rất tốt. Nginx trả object trực tiếp từ local disk nên Bunny không tham gia request. Chính thành công đó che mất hành vi thực sự quan trọng: cold MISS.
File phổ biến che giấu latency thực của origin
Ảnh được yêu cầu thường xuyên có xu hướng ở lại cache. File cũ hiếm khi mở dễ bị evict hơn. Vì vậy các trang cũ vô tình trở thành origin benchmark.
Một snapshot tháng 7 cho thấy khoảng 36 GB media cache, hơn 355.000 cache file, giới hạn khoảng 35 GB và root filesystem dùng khoảng 93%. Các snapshot lân cận tiến gần 400.000 entry. Cache lớn đến đâu vẫn hữu hạn.
Bài học chính: HIT nhanh không chứng minh origin nhanh; nó chỉ chứng minh local cache nhanh.
Log production cho thấy request fail trước khi body file được truyền
upstream timed out
while connecting to upstream
và:
upstream timed out ... while SSL handshaking to upstream
Đây không phải trường hợp file lớn tải chậm. Nginx vẫn đang cố thiết lập upstream connection hoặc hoàn tất TLS.
Trong diagnostic snapshot ngày 30 tháng 7, 5.000 dòng cuối media error log có 317 match upstream timeout. Snapshot hôm trước có 578. Đây là số dòng log chứ không phải số user duy nhất, nhưng rõ ràng không phải một request đơn lẻ.
Case xấu không gắn với một storage IP duy nhất
109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193
Tôi thấy failure ở nhiều address trong tập này. Vì vậy từ góc nhìn server của tôi, vấn đề nằm ở hành vi của external origin path nói chung chứ không phải một IP luôn lỗi.
Log không thể chứng minh nguyên nhân chính xác nằm ở storage backend, routing, peering, đường mạng của provider tôi đến Bunny, balancing hay yếu tố khác. Tôi chỉ có thể khẳng định rằng một số cold MISS không thiết lập được kết nối Bunny Storage trong thời gian mong đợi.
Một phép đo phơi bày long tail rất rõ
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
Nhưng một path đo được hoàn toàn khác:
109.61.89.55
TCP 1.017782 s
TLS 1.048306 s
TOTAL 1.054474 s
Điều này không có nghĩa Bunny Storage luôn chậm một giây. Phần lớn phép đo chỉ ở mức vài chục ms. Vấn đề là variance: cùng một origin hostname đôi khi dẫn đến connection path mất hơn một giây trước cả khi dữ liệu hữu ích bắt đầu truyền.
Với media page, tail latency quan trọng hơn giá trị trung bình
Nếu 79 ảnh tải trong 30–50 ms và một ảnh mất một giây, average vẫn có thể đẹp. User không nhìn average; họ nhìn một ô ảnh trống. Vì vậy p95, p99 và cold MISS chậm nhất trở nên quan trọng hơn với tôi.
Video bộc lộ vấn đề thứ hai, hoàn toàn khác
Browser có thể yêu cầu byte Range:
Range: bytes=0-1048575
và sau khi seek:
Range: bytes=50000000-51048575
Response 206 Partial Content cho phép playback và seek mà không phải tải toàn bộ byte trước đó.
Range cache đầu tiên chia một video vật lý thành nhiều 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 là arbitrary. bytes=0-1048575, bytes=0-999999 và offset sau seek tạo key khác nhau cho cùng một MP4. Tôi không đo chính xác fragmentation này tốn bao nhiêu GB nên không đưa ra con số không có dữ liệu.
Sau đó tôi chuyển sang slice cố định 1 MB
Nginx Slice module có thể normalize object lớn thành chunk cố định:
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;
Đây là cấu hình tốt hơn nhiều cho cache reuse. Rồi production bắt đầu ghi:
etag mismatch in slice response while reading response header from upstream
MP4 slice fail ở tính nhất quán ETag
Ngày 30 tháng 7, các slice subrequest lặp lại của một MP4 sinh etag mismatch in slice response, trong khi request đi đến 109.61.89.53, 109.61.89.57, 79.127.226.193, 109.61.89.55 và các address khác. Ngày 31 tháng 7, cùng loại error xuất hiện với MP4 khác.
Lúc đầu tôi gọi nó một cách không chính xác là các phần có “signature khác nhau”. Thuật ngữ đúng là ETag, không phải AccessKey, signed URL hay chữ ký mật mã.
Vì sao Nginx không ghép các slice có ETag khác nhau
slice 1 → bytes 0–1 MB → ETag A
slice 2 → bytes 1–2 MB → ETag A
là nhất quán. Nhưng:
slice 1 → ETag A
slice 2 → ETag B
khiến Nginx không thể chắc chắn hai phần thuộc cùng một representation. Ghép mù có thể trộn byte của hai version khác nhau. Bài viết Nginx về byte-range caching mô tả kiểm tra validator này.
Log của tôi chứng minh Nginx thấy ETag không tương thích giữa các slice của cùng MP4. Chúng không chứng minh nguyên nhân nội bộ chính xác vì tôi không log literal ETag của từng subrequest. Việc upstream address thay đổi khiến backend response inconsistency là giải thích hợp lý, nhưng vẫn là inference.
Điều này không có nghĩa Bunny Storage không hỗ trợ Range
Range hoạt động và tôi cache 206 Partial Content. Vấn đề cụ thể là tổ hợp Nginx Slice, nhiều storage response và ETag consistency cần thiết cho assembly an toàn.
Video cũng có thể gặp origin timeout thông thường. Vì vậy có hai failure mode độc lập: origin connection/latency và Slice/ETag consistency.
Cuối cùng tôi bỏ Slice cho MP4
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 không còn được lưu như complete object. Vấn đề assembly biến mất, nhưng cold Range lại phụ thuộc nhiều hơn vào external origin.
Tôi nhận ra mình đang tự xây một phần CDN phía trước Storage
Nginx đã có cache lock, stale, background update, Range, 206, Slice, normalized chunk, keepalive, TLS reuse, retry, timeout tuning và custom cache key. Mỗi phần đều hợp lý, nhưng nhu cầu thật với origin rất đơn giản: giữ immutable file và trả byte.
Origin thay thế được cố ý giữ đơn giản
Tôi chuyển media dataset sang dedicated server mà công việc gần như chỉ là Nginx + SSD. Với pure static origin, một máy nhỏ khoảng 1 vCPU, gần 1 GB RAM và vài trăm GB SSD có thể là điểm bắt đầu hợp lý nếu network và disk đáp ứng.
Đây không phải sizing rule cho mọi hệ thống. Bandwidth, object size, concurrency, IOPS và MISS rate vẫn quyết định. Origin dùng cho các phép đo hiện tại có nhiều resource hơn, nên tôi không coi peak hiện tại là benchmark chính xác cho máy 1-vCPU/1-GB.
Kiến trúc mới
Browser
↓
server hoạt động
↓
Nginx + local media cache
│
├── HIT
└── MISS
↓
WireGuard
↓
media origin
↓
Nginx
↓
SSD
Origin là private. Working server nói chuyện với một fixed private IP. Public HTTPS vẫn terminate ở working server; traffic tới origin có thể dùng HTTP bên trong WireGuard tunnel đã được mã hóa.
Những gì biến mất khỏi cold path
working server
↓
WireGuard
↓
fixed private IP
↓
Nginx
↓
SSD
Đối với kết nối trực tiếp server-to-server này, tôi không còn cần public DNS, chọn giữa nhiều storage address, public Internet path và TLS handshake riêng với Storage API.
Cold MISS đủ rẻ để cảm giác gần giống HIT
Test Range nhỏ trên file thật thường là:
CONNECT ≈ 9.5–12.5 ms
TTFB ≈ 19–23 ms
TOTAL ≈ 19–23 ms
Các check 10/10, 20/20 sequential và 20/20 parallel đều thành công. Trên origin, cùng request nhỏ đó mất khoảng 0.5–0.9 ms.
Đây là test Range/TTFB nhỏ, không phải thời gian tải toàn bộ video. Full transfer vẫn phụ thuộc kích thước và throughput.
Case xấu cũ và cold path mới khác nhau rất nhiều
Các phép đo cũ bình thường thường khoảng 29–44 ms. Bad case quan sát được khoảng 1.054 s. Tiny cold request mới khoảng 20 ms.
So outlier 1.054 s cụ thể đó với 20 ms cho khoảng 52× chênh lệch latency. Đây không phải tuyên bố “server của tôi nhanh hơn Bunny Storage 52×”, mà chỉ là so sánh hai path thực tế đã đo.
MP4 giờ cũng đơn giản hơn
Trong cold fill, Nginx có thể lấy toàn bộ MP4 từ private origin, lưu một cache entry, sau đó trả bất kỳ byte Range nào của browser từ local cache. Có thể bỏ upstream Range khi fill và dùng proxy_force_ranges cho local partial response.
cold MISS
↓
MP4 đầy đủ từ private origin
↓
một cached file
↓
client Range trả local dạng 206
Full-object caching cũng có chi phí
Nếu cold MP4 là 500 MB và user đầu tiên chỉ muốn xem mười giây, cache fill vẫn có thể truyền đủ 500 MB giữa hai server. Với kích thước file và access pattern của tôi, trade-off này chấp nhận được. Với library multi-GB phần lớn cold, tôi sẽ cân nhắc stable slice cache, HLS/DASH, video CDN hoặc Bunny Stream.
Fixed slice vẫn hữu ích cho media lớn khác
Nginx Slice không phải feature tệ. Với immutable object và stable origin, nó vẫn hữu ích. Khác biệt là mọi chunk giờ đến từ một origin và một filesystem representation do tôi kiểm soát.
Cache nhỏ hơn vẫn có thể cảm giác nhanh hơn nếu MISS rẻ
Cache cũ khoảng 35–36 GB. Cache mới ổn định khoảng 16 GB vì tôi cố ý giữ khoảng 8 GB trống bằng min_free.
cũ:
HIT = nhanh
MISS = đôi khi đau
mới:
HIT = nhanh
MISS = cũng đủ nhanh
Cache lớn giảm số MISS. Origin tốt giảm chi phí của từng MISS. Với workload này, điều thứ hai quan trọng hơn tôi nghĩ.
Production traffic thực xác nhận vai trò của origin
Trong snapshot 60 giây, working server gửi khoảng 96.84 Mbit/s tới user và nhận khoảng 7.07 Mbit/s từ media origin. Trong window đó không có 502, 503, 504 hay upstream timeout mới.
Trong snapshot 30 giây sau đó, public TX là 61.36 Mbit/s và WireGuard RX từ origin là 1.40 Mbit/s. Tỷ lệ origin bytes/public TX là 2.28%.
Tôi không gọi phần nghịch đảo là cache-hit rate chính xác vì public traffic không chỉ có media và tỷ lệ byte không phải tỷ lệ request. Nhưng nó cho thấy sau warm-up, origin chủ yếu phục vụ cold fill.
Bây giờ tôi sẽ monitor gì
$upstream_addr
$upstream_connect_time
$upstream_header_time
$upstream_response_time
$upstream_cache_status
$request_time
$status
Tách HIT và MISS, đặc biệt p95/p99 MISS.
Với video, tôi sẽ test first Range, middle Range, suffix Range, nhiều non-sequential Range để seek, cold/warm và kiểm tra 206, Content-Range, Content-Length, Accept-Ranges, ETag. Nếu dùng Slice, tôi sẽ xác nhận validator consistency giữa nhiều slice.
Một cold URL và 20 cold URL khác nhau không phải cùng một test
proxy_cache_lock hữu ích khi nhiều request muốn cùng một cold cache key. Nó không biến 20 file khác nhau thành một origin request. Một page với 50 cold image khác nhau có thể là test origin thực tế hơn benchmark lặp lại một URL.
Self-hosting cho quyền kiểm soát, không cho reliability miễn phí
Private media server giúp critical path dễ hiểu hơn, nhưng backup, disk health, free space, update, firewall, monitoring, restore và redundancy trở thành trách nhiệm của tôi. Một origin cũng có thể là single point of failure cho object chưa được cache.
Khi nào tôi vẫn chọn Bunny
Nếu cần global delivery, managed redundancy, storage tăng nhanh hoặc giảm tối đa công việc vận hành, tôi vẫn sẽ đánh giá Bunny và sẽ test rõ Storage + Pull Zone/CDN.
Experiment này không chứng minh một VPS tốt hơn Bunny CDN, Bunny Storage luôn chậm hay Bunny không hỗ trợ Range. Nó cho thấy với workload của tôi, Bunny Storage làm direct runtime origin của Nginx cache tạo ra cold-path và sliced-video behavior mà tôi không còn muốn quản lý.
Nguyên tắc tôi dùng bây giờ
Trong hai tháng tôi đã tối ưu cache size, cache key, Range, 206, Slice, chunk 1 MB, keepalive, TLS reuse, retry, stale và timeout. Cải tiến mạnh nhất lại đơn giản hơn: đổi origin.
Điều gì xảy ra khi file user cần không có trong cache?
Nếu câu trả lời là stable origin, TTFB có thể dự đoán, Range chính xác, validator nhất quán và failure mode dễ hiểu, cache là optimization. Nếu UX tốt phụ thuộc vào việc user không bao giờ gặp MISS, cache đang che một vấn đề kiến trúc sâu hơn.
Với workload của tôi, một media origin nhỏ, private và nhàm chán phù hợp hơn. Nó không phải CDN, không phải phép màu và không loại bỏ trách nhiệm vận hành. Nó chỉ làm cold path trở nên nhàm chán. Sau hai tháng debug ảnh, connection timeout, video Range và etag mismatch in slice response, đó chính xác là thứ tôi muốn.