เกือบสองเดือน เส้นทางส่ง media ของผมเป็นแบบนี้:
Browser
↓
working server
↓
Nginx
↓
local proxy_cache
↓ cache MISS
Bunny Storage
รูป วิดีโอ และเสียงอยู่ใน Bunny Storage ผู้ใช้ไม่ได้เชื่อมต่อ Bunny โดยตรง Nginx ของผมขอ object จาก [https://storage.bunnycdn.com](https://storage.bunnycdn.com) ใส่ AccessKey ฝั่งเซิร์ฟเวอร์ เก็บ response ลง SSD ในเครื่อง แล้วเป็นคนให้บริการ public URL เอง
ต้องแยกขอบเขตให้ชัด: นี่ไม่ใช่การทดสอบ Bunny CDN ผมไม่ได้ใช้ Bunny CDN ในกรณีนี้ สถาปัตยกรรม Storage Zone + Pull Zone/CDN เป็นอีกแบบหนึ่ง และผมยังมองว่า Bunny CDN เป็นผลิตภัณฑ์ที่ดีมาก สิ่งที่ทดสอบคือ Bunny Storage ในบทบาท runtime origin ของ Nginx cache ของผมเอง
ทำไมสถาปัตยกรรมเดิมดูสมเหตุสมผล
Workload มีไฟล์ immutable จำนวนมาก: AVIF, JPEG, PNG, MP4, WebM, audio และ static file อื่น ๆ ตั้งแต่รูปเล็กไปจนถึงวิดีโอขนาดใหญ่ ผมไม่อยากวาง dataset ทั้งหมดบน application server แต่ต้องการควบคุม public 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
ไฟล์ยอดนิยมซ่อน latency จริงของ origin
รูปที่ถูกเรียกบ่อยมักยังอยู่ใน cache ส่วนไฟล์เก่าที่แทบไม่มีคนเปิดมีโอกาสถูก evict มากกว่า ดังนั้นหน้าที่เก่าจึงกลายเป็น origin benchmark โดยไม่ตั้งใจ
Snapshot หนึ่งในเดือนกรกฎาคมมี media cache ราว 36 GB, cache file มากกว่า 355,000 ไฟล์, limit ราว 35 GB และ root filesystem ใช้งานประมาณ 93% Snapshot ใกล้เคียงบางช่วงมี entry เกือบ 400,000 รายการ แม้ cache ใหญ่ขนาดนี้ก็ยังมีขีดจำกัด
บทเรียนคือ HIT ที่เร็วไม่ได้พิสูจน์ว่า origin เร็ว มันพิสูจน์แค่ว่า local cache เร็ว
Production log แสดง failure ก่อนเริ่มรับ body ของไฟล์
upstream timed out
while connecting to upstream
และ:
upstream timed out ... while SSL handshaking to upstream
นี่ไม่ใช่กรณีไฟล์ใหญ่ดาวน์โหลดนาน แต่ Nginx ยังอยู่ในขั้นสร้าง upstream connection หรือทำ TLS handshake
ใน diagnostic snapshot วันที่ 30 กรกฎาคม 5,000 บรรทัดสุดท้ายของ media error log มี upstream-timeout match 317 ครั้ง วันก่อนหน้ามี 578 ครั้ง นี่คือจำนวนบรรทัด log ไม่ใช่จำนวนผู้ใช้ แต่ชัดเจนว่าไม่ใช่เหตุการณ์เดียว
กรณีแย่ไม่ได้ผูกกับ storage IP เดียว
109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193
ผมเห็น failure บนหลาย address ในชุดนี้ ดังนั้นจากมุมมองเซิร์ฟเวอร์ผม ปัญหาคือพฤติกรรมของ external origin path โดยรวม ไม่ใช่ IP เดียวที่เสียตลอด
จาก log ผมพิสูจน์ไม่ได้ว่า root cause คือ storage backend, routing, peering, path จาก provider ของผมไป Bunny, balancing หรือ network factor อื่น แต่พิสูจน์ได้ว่า cold MISS บางส่วนสร้าง Bunny Storage connection ไม่ทันเวลาที่คาดไว้
Measurement หนึ่งทำให้ 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 ช้าหนึ่งวินาทีเสมอ ส่วนใหญ่มี latency แค่หลักสิบ ms สิ่งสำคัญคือ variance: hostname เดียวกันอาจพาไปยัง connection path ที่ใช้มากกว่าหนึ่งวินาทีก่อนเริ่มส่งข้อมูลจริง
หน้า media สนใจ tail latency มากกว่าค่าเฉลี่ย
ถ้า 79 รูปโหลดใน 30–50 ms แต่อีกหนึ่งรูปใช้หนึ่งวินาที ค่าเฉลี่ยยังดูดี ผู้ใช้ไม่ได้เห็นค่าเฉลี่ย เขาเห็นช่องว่างหนึ่งช่อง ดังนั้น p95, p99 และ cold MISS ที่ช้าที่สุดจึงสำคัญกับผมมากขึ้น
วิดีโอเผยปัญหาอีกประเภทหนึ่ง
Browser ขอ byte Range ได้:
Range: bytes=0-1048575
และหลัง seek:
Range: bytes=50000000-51048575
Response 206 Partial Content ทำให้เล่นหรือ seek โดยไม่ต้องดาวน์โหลด byte ก่อนหน้าทั้งหมด
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 และ offset หลัง seek ต่างเป็น key คนละตัวสำหรับ MP4 เดียวกัน ผมไม่ได้วัดว่า fragmentation นี้กินพื้นที่กี่ GB จึงไม่ใส่ตัวเลขที่ไม่มีหลักฐาน
จากนั้นเปลี่ยนเป็น slice คงที่ 1 MB
Nginx Slice module สามารถ normalize 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 consistency
วันที่ 30 กรกฎาคม subrequest ของ MP4 เดียวกันเกิด etag mismatch in slice response ซ้ำ ๆ ขณะ request ไปยัง 109.61.89.53, 109.61.89.57, 79.127.226.193, 109.61.89.55 และ address อื่น วันที่ 31 กรกฎาคม error ประเภทเดียวกันเกิดกับ MP4 อีกไฟล์
ตอนแรกผมอธิบายแบบไม่เป็นทางการว่าแต่ละชิ้นมี “signature ต่างกัน” แต่ technical term ที่ถูกต้องคือ ETag ไม่ใช่ AccessKey, signed URL หรือ cryptographic signature
ทำไม Nginx ไม่รวม slice ที่ ETag ต่างกัน
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 เดียวกัน การรวมแบบไม่ตรวจสอบอาจผสม byte จากคนละ version บทความ Nginx เรื่อง byte-range caching อธิบาย validator check นี้
Log ของผมพิสูจน์ว่า Nginx เห็น ETag ที่ไม่ compatible ระหว่าง slice response ของ MP4 เดียวกัน แต่ไม่พิสูจน์สาเหตุภายในที่แน่นอน เพราะตอนนั้นไม่ได้ log ค่า literal ETag ของทุก subrequest การเปลี่ยน upstream address ทำให้ backend response inconsistency เป็นคำอธิบายที่เป็นไปได้ แต่ยังเป็น inference
นี่ไม่ได้หมายความว่า Bunny Storage ไม่รองรับ Range
Range ทำงาน และผม cache 206 Partial Content อยู่ ปัญหาเฉพาะคือการรวม Nginx Slice, หลาย storage response และ ETag consistency ที่ Nginx ต้องใช้เพื่อประกอบ representation อย่างปลอดภัย
วิดีโอยังเจอ origin timeout แบบปกติได้ด้วย จึงมี failure mode แยกกันสองแบบ: origin connection/latency และ Slice/ETag consistency
สุดท้ายผมเลิกใช้ Slice กับ 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 จะไม่ถูกเก็บเหมือนเป็น complete object ปัญหา assembly หายไป แต่ cold Range กลับไปพึ่ง external origin มากขึ้น
ผมเริ่มเห็นว่ากำลังสร้างส่วนหนึ่งของ CDN ไว้หน้า Storage เอง
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 ไปยัง dedicated server ที่แทบมีแค่ Nginx + SSD สำหรับ pure static origin เซิร์ฟเวอร์เล็กราว 1 vCPU, RAM ประมาณ 1 GB และ SSD หลายร้อย GB เป็นจุดเริ่มต้นที่พิจารณาได้หาก network และ disk เหมาะสม
นี่ไม่ใช่ sizing rule ที่ใช้ได้กับทุกระบบ Bandwidth, object size, concurrency, IOPS และ MISS rate มีผล และ origin ที่ใช้วัดปัจจุบันมี resource มากกว่า ดังนั้น peak ปัจจุบันไม่ใช่ benchmark ของเครื่อง 1-vCPU/1-GB โดยตรง
สถาปัตยกรรมใหม่
Browser
↓
working server
↓
Nginx + local media cache
│
├── HIT
└── MISS
↓
WireGuard
↓
media origin
↓
Nginx
↓
SSD
Origin เป็น private Working server คุยกับ private IP คงที่เพียงตัวเดียว Public HTTPS ยังจบที่ working server ส่วน traffic ไป origin ใช้ HTTP ภายใน WireGuard tunnel ที่เข้ารหัสแล้วได้
อะไรหายไปจาก cold path
working server
↓
WireGuard
↓
fixed private IP
↓
Nginx
↓
SSD
สำหรับการเชื่อมตรงระหว่างสองเซิร์ฟเวอร์นี้ ไม่ต้องมี public DNS, เลือกจากหลาย storage address, public Internet path และ TLS handshake เพิ่มเติมกับ Storage API
Cold MISS ถูกพอจนแทบไม่ต่างจาก HIT ในสายตา
Test Range ขนาดเล็กบนไฟล์จริงมักได้:
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
นี่เป็น test Range/TTFB ขนาดเล็ก ไม่ได้แปลว่าวิดีโอเต็มโหลดใน 20 ms การส่งทั้งไฟล์ยังขึ้นกับขนาดและ throughput
กรณีแย่เดิมกับ cold path ใหม่ต่างกันมาก
Measurement ปกติเดิมมักอยู่ราว 29–44 ms กรณีแย่ที่วัดได้อยู่ราว 1.054 s ส่วน tiny cold request ใหม่ประมาณ 20 ms
ถ้าเปรียบเทียบ outlier 1.054 s นั้นกับ 20 ms จะต่างกันราว 52× ทาง latency แต่ไม่ได้หมายความว่า “เซิร์ฟเวอร์ผมเร็วกว่า Bunny Storage 52 เท่า” เป็นเพียงการเทียบสอง path ที่สังเกตจริง
MP4 ตอนนี้ง่ายขึ้นด้วย
ตอน cold fill Nginx สามารถโหลด MP4 เต็มจาก private origin เก็บเป็น cache entry เดียว แล้วให้ byte Range ใด ๆ ของ browser จาก local cache ได้ สามารถตัด Range ที่ส่ง upstream ระหว่าง fill และใช้ proxy_force_ranges สำหรับ local partial response
cold MISS
↓
MP4 เต็มจาก private origin
↓
หนึ่ง cached file
↓
client Range จาก local cache เป็น 206
Full-object caching มีต้นทุนเช่นกัน
ถ้า cold MP4 ขนาด 500 MB และ user แรกดูเพียงสิบวินาที การ fill cache อาจส่งเต็ม 500 MB ระหว่างเซิร์ฟเวอร์ สำหรับขนาดและ pattern ของผม trade-off นี้รับได้ แต่สำหรับ library multi-GB ที่ส่วนใหญ่ cold ผมจะดู stable slice cache, HLS/DASH, video CDN หรือ Bunny Stream
Fixed slice ยังเหมาะกับ media ใหญ่อื่น
Nginx Slice ไม่ใช่ feature ที่แย่ กับ immutable object และ stable origin ยังมีประโยชน์ ความต่างคือทุก chunk มาจาก origin เดียวและ filesystem representation เดียวที่ผมควบคุม
Cache เล็กลงก็รู้สึกเร็วขึ้นได้หาก MISS ถูก
Cache เดิมราว 35–36 GB Cache ใหม่คงที่แถว 16 GB เพราะผมตั้ง min_free ให้เหลือพื้นที่ประมาณ 8 GB
เดิม:
HIT = เร็ว
MISS = บางครั้งเจ็บปวด
ใหม่:
HIT = เร็ว
MISS = เร็วพอเช่นกัน
Cache ใหญ่ลดจำนวน MISS Origin ที่ดีลดต้นทุนของทุก MISS สำหรับ workload นี้อย่างหลังสำคัญกว่าที่คิด
Production traffic จริงยืนยันบทบาท origin
Snapshot 60 วินาทีหนึ่ง Working server ส่งให้ user ราว 96.84 Mbit/s และรับจาก media origin ราว 7.07 Mbit/s ไม่มี 502, 503, 504 หรือ upstream timeout ใหม่ในช่วงนั้น
Snapshot 30 วินาทีภายหลังมี public TX 61.36 Mbit/s และ WireGuard RX จาก origin 1.40 Mbit/s อัตรา origin bytes/public TX เท่ากับ 2.28%
ผมไม่เรียกค่ากลับกันว่า cache-hit rate แบบแม่นยำ เพราะ public traffic ไม่ได้มีแค่ media และ byte ratio ไม่ใช่ request ratio แต่มันแสดงชัดว่าเมื่อ cache warm แล้ว origin ทำงานกับ cold fill เป็นหลัก
ตอนนี้ผมจะ monitor อะไร
$upstream_addr
$upstream_connect_time
$upstream_header_time
$upstream_response_time
$upstream_cache_status
$request_time
$status
แยก HIT กับ MISS โดยเฉพาะ p95/p99 ของ MISS
สำหรับวิดีโอ ผมจะ test Range ช่วงต้น ช่วงกลาง suffix Range ช่วงท้าย หลาย non-sequential Range สำหรับ seek, cold/warm และตรวจ 206, Content-Range, Content-Length, Accept-Ranges, ETag หากใช้ Slice จะตรวจ validator consistency ระหว่างหลาย slice โดยตรง
cold URL หนึ่งอันกับ cold URL ต่างกัน 20 อันไม่ใช่ test เดียวกัน
proxy_cache_lock ช่วยเมื่อ request จำนวนมากต้องการ cold cache key เดียวกัน แต่ไม่ได้รวมไฟล์ 20 ไฟล์เป็น origin request เดียว หน้าเว็บที่มี cold image ต่างกัน 50 รูปอาจเป็น origin test ที่สมจริงกว่าการยิง URL เดิมซ้ำ ๆ
Self-hosting ให้การควบคุม ไม่ได้ให้ reliability ฟรี
Private media server ทำให้ critical path เข้าใจง่ายขึ้น แต่ backup, disk health, free space, update, firewall, monitoring, restore และ redundancy กลายเป็นหน้าที่ผม Origin ตัวเดียวก็เป็น single point of failure สำหรับ object ที่ยังไม่ cached ได้
เมื่อไรผมยังเลือก Bunny
ถ้าต้องการ global delivery, managed redundancy, storage ที่โตเร็ว หรืออยากลดงานดูแลระบบ ผมยังพิจารณา Bunny แน่นอน และจะประเมิน Storage + Pull Zone/CDN โดยตรง
การทดลองนี้ไม่ได้พิสูจน์ว่า VPS ตัวเดียวดีกว่า Bunny CDN, Bunny Storage ช้าเสมอ หรือ Bunny ไม่รองรับ Range มันแสดงแค่ว่าใน workload ของผม การใช้ Bunny Storage เป็น runtime origin โดยตรงของ Nginx cache ทำให้เกิด cold-path และ sliced-video behavior ที่ผมไม่อยากดูแลต่อ
กฎที่ผมใช้ตอนนี้
ในสองเดือนผมปรับ cache size, cache key, Range, 206, Slice, chunk 1 MB, keepalive, TLS reuse, retry, stale และ timeout การปรับที่แรงที่สุดกลับง่ายกว่า: เปลี่ยน origin
ถ้าไฟล์ที่ผู้ใช้ต้องการไม่ได้อยู่ใน cache จะเกิดอะไรขึ้น?
ถ้าคำตอบคือ stable origin, TTFB ที่คาดเดาได้, Range ถูกต้อง, validator สอดคล้อง และ failure mode เข้าใจได้ cache ก็คือ optimization แต่ถ้า UX ที่ดีขึ้นอยู่กับว่าผู้ใช้ต้องไม่เจอ MISS เลย cache กำลังซ่อนปัญหาสถาปัตยกรรมที่ลึกกว่า
สำหรับ workload ของผม media origin เล็ก ๆ แบบ private และน่าเบื่อเหมาะกว่า มันไม่ใช่ CDN ไม่ใช่เวทมนตร์ และไม่ลดความรับผิดชอบด้าน operation มันแค่ทำให้ cold path น่าเบื่อ หลังจาก debug รูป connection timeout, video Range และ etag mismatch in slice response อยู่สองเดือน ความน่าเบื่อนั่นคือสิ่งที่ผมต้องการพอดี