Deploy static site ขึ้น VPS แล้วผู้ใช้ไม่เห็นของใหม่ — ETag, rsync และ cache ที่คุณไม่เคยตั้ง
TL;DR — build hash ให้แล้ว มี ETag แล้ว ยังไม่พอ. เพราะ (1) web server ส่วนใหญ่ ไม่ส่ง
Cache-Controlเลย → เบราว์เซอร์ใช้ heuristic เดาเอง แล้วไม่ถามด้วยซ้ำ → ETag ที่มีอยู่ไม่ถูกใช้ (2) ETag ของ Caddy คำนวณจากmtime + sizeไม่ใช่เนื้อไฟล์ (3)rsync -aก็ตัดสิน “เปลี่ยนไหม” ด้วยmtime + sizeเหมือนกัน → เนื้อต่างแต่ metadata เท่ากัน = ข้ามไฟล์ไปเลย. ทางแก้:Cache-Control: no-cache(ไม่ใช่no-store)
immutableเฉพาะไฟล์ที่มี hash +rsync -c
ปัญหา: ทุกอย่างเขียว แต่ผู้ใช้ยังเห็นเว็บเวอร์ชันเก่า
สถานการณ์ที่เจอบ่อยมากกับ SPA ที่ deploy ขึ้น VPS เอง (Caddy/nginx เสิร์ฟไฟล์ static):
- CI เขียว ✅
rsyncขึ้นเครื่องสำเร็จ ✅- เปิดเว็บใน incognito → เห็นของใหม่ ✅
- แต่ผู้ใช้ที่เคยเข้ามาก่อน ยังเห็นของเก่า ❌ (บางคนเจอจอขาว)
คำถามแรกที่ทุกคนถาม — และผมก็ถาม — คือ “Vite ใส่ hash ในชื่อไฟล์ให้แล้วไม่ใช่เหรอ? แล้วมันมี ETag อยู่แล้วด้วย ทำไมยังพัง”
คำตอบอยู่ที่ 3 ชั้นที่ต่างคนต่างตัดสินใจ และทั้งสามชั้นตัดสินจากข้อมูลที่อ่อนกว่าที่เราคิด
ชั้นที่ 1: hash ครอบไม่ถึง entry point
Vite ใส่ content hash ในชื่อไฟล์ของ asset:
dist/assets/index-LThigBuy.js
dist/assets/index-BM4B51Ye.css
เนื้อเปลี่ยน → ชื่อเปลี่ยน → cache ยาวแค่ไหนก็ปลอดภัย เพราะ URL ใหม่ = ของใหม่เสมอ
แต่ index.html hash ไม่ได้ — มันคือจุดเริ่มที่เบราว์เซอร์ต้องขอจาก URL คงที่
ถ้า hash แล้วจะไม่มีใครรู้ว่าต้องขออันไหน. ไฟล์ config ที่ generate ตอน deploy
(/config.js) ก็เหตุผลเดียวกัน
⇒ ไฟล์ที่ URL คงที่แต่เนื้อเปลี่ยนได้ คือจุดที่ cache เป็นปัญหา — ไม่ใช่ asset
ชั้นที่ 2: server ไม่ส่ง Cache-Control → เบราว์เซอร์เดาเอง
ตรงนี้ผมวัดจริง ไม่ได้เดา — Caddy file_server เปล่า ๆ:
curl -sSI http://localhost:8899/index.html
HTTP/1.1 200 OK
Etag: "dkhxan6gc2a52n"
Last-Modified: Thu, 06 Aug 2026 14:16:08 GMT
มี ETag มี Last-Modified — แต่ไม่มี Cache-Control เลยสักตัว
นี่คือหัวใจของเรื่อง เพราะ HTTP บอกว่า: ไม่มี Cache-Control และไม่มี Expires →
เบราว์เซอร์ใช้ heuristic caching ได้ (RFC 9111 §4.2.2)
ซึ่งโดยทั่วไป ≈ 10% ของอายุไฟล์ (now − Last-Modified)
ระหว่างที่ยังถือว่า “สด” เบราว์เซอร์เสิร์ฟจาก cache โดยไม่ยิง request เลย
⇒ ETag ไม่ได้ถูกใช้ — ETag ช่วยได้ต่อเมื่อเบราว์เซอร์ ยอมถาม
แล้วมันแย่กว่าที่คิด เพราะ rsync -a เก็บ mtime ของตอน build
rsync -a (มี -t อยู่ข้างใน) รักษา mtime ของต้นทาง — และต้นทางคือ artifact ที่ build ไว้
# ไฟล์ที่ build ไว้ 30 วันก่อน แล้วเพิ่ง deploy วันนี้
Last-Modified: Tue, 30 Jun 2026 17:00:00 GMT
⇒ heuristic ให้ freshness ≈ 3 วัน ⇒ ผู้ใช้ได้ index.html เก่าได้ถึง 3 วันโดยไม่ถามเซิร์ฟเวอร์
และ index.html เก่าชี้ไปที่ asset hash เดิม ซึ่งถ้า deploy ใช้ rsync --delete
ไฟล์นั้นถูกลบไปแล้ว → จอขาว
ชั้นที่ 3: ETag ไม่ใช่ hash ของเนื้อไฟล์
ข้อนี้เป็นข้อที่ทำให้ผมต้องแก้แผนทั้งหมด. ลองวัดว่า Caddy คำนวณ ETag จากอะไร:
printf 'AAAA' > web/a.js && touch -d '2026-01-01' web/a.js
# → Etag: "dfck9c65szk04"
printf 'BBBB' > web/a.js && touch -d '2026-01-01' web/a.js # เนื้อต่าง ขนาดเท่า mtime เท่า
# → Etag: "dfck9c65szk04" ← เหมือนเดิม!
printf 'AAAA' > web/a.js && touch -d '2026-06-06' web/a.js # เนื้อเดิม mtime ใหม่
# → Etag: "dj19ybnftvk04" ← เปลี่ยน
printf 'AAAAAA' > web/a.js && touch -d '2026-01-01' web/a.js # ขนาดต่าง
# → Etag: "dfck9c65szk06" ← ลงท้าย 04 → 06
อ่านค่าออกมาได้เลยว่า ETag = <mtime ฐาน36><size ฐาน36>
| เปลี่ยนอะไร | ETag เปลี่ยนไหม |
|---|---|
| เนื้อ (ขนาดเท่าเดิม, mtime เท่าเดิม) | ❌ ไม่เปลี่ยน |
| mtime | ✅ เปลี่ยน |
| ขนาด | ✅ เปลี่ยน |
⚠️ นี่ไม่ใช่บั๊กของ Caddy — nginx ก็ใช้ mtime + content_length เหมือนกัน. มันคือ
การแลกที่จงใจ: อ่าน stat() ราคาถูกกว่าอ่านทั้งไฟล์มา hash ทุก request.
แต่แปลว่า ETag เป็น proxy ที่ อ่อน ของคำถาม “ไฟล์เปลี่ยนไหม” ไม่ใช่ตัวยืนยันเนื้อ
ชั้นที่ 3.5: rsync ก็ตัดสินด้วยเกณฑ์เดียวกัน — พังตั้งแต่ก่อนถึง cache
พอรู้ว่า ETag มาจาก mtime + size คำถามถัดมาคือ “แล้ว mtime จะเปลี่ยนแน่ไหม”
ซึ่งพาไปเจอปัญหาที่อยู่ก่อนหน้าชั้น cache ด้วยซ้ำ
rsync ค่าเริ่มต้นตัดสินว่าไฟล์เปลี่ยนไหมจาก size + mtime (quick check)
⇒ เนื้อต่างแต่สองค่านี้เท่ากัน = rsync ข้ามไฟล์ไปเลย ไม่ copy
แล้ว index.html เสี่ยงข้อนี้เชิงโครงสร้าง เพราะ Vite ใช้ hash ยาวคงที่:
<script type="module" src="/assets/index-LThigBuy.js"></script>
<script type="module" src="/assets/index-Xy7Qm2Kp.js"></script>
เนื้อต่าง จำนวนไบต์เท่ากันเป๊ะ — และนี่คือกรณีปกติ ทุก build ที่แก้โค้ด ไม่ใช่ edge case
⇒ index.html เหลือ mtime เป็นตัวแปรเดียว ที่กันไม่ให้พลาด
ทุกวันนี้มันยังไม่พังเพราะ build คนละครั้งได้ mtime คนละค่า — แต่นั่นคือ “บังเอิญได้ตัวแปร
ที่ยังต่าง” ไม่ใช่ “กลไกรับประกัน” และของแบบนี้จะพังวันที่มีคนเพิ่ม reproducible build
(SOURCE_DATE_EPOCH) หรือ normalize timestamp ในภาพ Docker
ทางแก้: 3 บรรทัด
1. no-cache — ไม่ใช่ no-store
ชื่อสองอันนี้ชวนเข้าใจผิดที่สุดใน HTTP:
| directive | ความหมายจริง |
|---|---|
no-store | ห้ามเก็บลง cache เลย → ทุกครั้งโหลดใหม่ทั้งไฟล์ |
no-cache | เก็บได้ แต่ต้องถามก่อนใช้ทุกครั้ง |
สำหรับ index.html / config.js เราต้องการอันหลัง — ได้ความถูกต้องเท่ากัน
แต่จ่ายแค่ 304 · 0 bytes แทนที่จะโหลดใหม่ทั้งไฟล์:
curl -sS -o /dev/null -w '%{http_code} %{size_download}\n' \
-H "If-None-Match: \"dkhxan6gc2a52n\"" http://localhost:8899/index.html
# → 304 0
2. matcher กลับด้าน — immutable เฉพาะที่มี hash
พอ no-cache ราคาถูก เราก็ไม่ต้องไล่ enumerate path ทีละอันว่าอันไหนต้อง revalidate
กลับด้านเลย: cache ยาวเฉพาะของที่มี hash · ที่เหลือ revalidate หมด
app.example.com {
root * /srv/web
encode gzip zstd
# มี hash ในชื่อ ⇒ เนื้อเปลี่ยน = ชื่อเปลี่ยน ⇒ ปลอดภัยที่จะ cache ยาวสุด
@immutable path /assets/*
header @immutable Cache-Control "public, max-age=31536000, immutable"
# ที่เหลือ = URL คงที่แต่เนื้อเปลี่ยนได้ → ถามก่อนใช้เสมอ
@revalidate not path /assets/*
header @revalidate Cache-Control "no-cache"
try_files {path} /index.html
file_server
}
ข้อดีที่ได้ฟรีจากการกลับด้าน: SPA route ครอบด้วย. /activities/123 ถูก try_files
เสิร์ฟเป็น index.html เหมือนกัน — ถ้าเราไล่ระบุ path เอง อันนี้จะหลุด
วัดยืนยัน:
| path | Cache-Control |
|---|---|
/ | no-cache |
/index.html | no-cache |
/config.js | no-cache |
/activities/abc | no-cache |
/assets/index-abc123.js | public, max-age=31536000, immutable |
3. rsync -c — ตัดสินจากเนื้อ ไม่ใช่ metadata
- rsync -az --delete dist/ deploy@vps:/srv/web/
+ rsync -azc --delete dist/ deploy@vps:/srv/web/
-c ให้ rsync เทียบ checksum ของเนื้อไฟล์ แทน size + mtime
ราคาถูกมากสำหรับ dist ของ SPA (ไม่กี่สิบไฟล์ ไม่กี่ MB) และมันตัดปัญหาทั้งคลาสทิ้ง
สายความน่าเชื่อถือที่ได้กลายเป็น:
checksum (rsync) → ไฟล์ถูก copy → mtime เปลี่ยน → ETag เปลี่ยน → เบราว์เซอร์ได้ 200 ของใหม่
แทนที่จะเป็น “หวังว่า mtime จะบังเอิญต่าง”
เคสที่ควรไล่ให้ครบก่อนปิดเรื่อง
| สถานการณ์ | ขนาด | mtime | ผล |
|---|---|---|---|
| build ใหม่ (แก้โค้ด) | เท่าเดิม | ต่าง | ✅ copy · ETag เปลี่ยน |
| deploy tag เดิมซ้ำ | เท่า | เท่า | ✅ ข้ามถูกแล้ว — เนื้อเหมือนกันจริง |
| rollback ไป build เก่า | เท่า | ย้อนหลัง | ✅ rsync เทียบว่า “ต่าง” ไม่ใช่ “ใหม่กว่า” ⇒ copy |
ข้อสุดท้ายสำคัญกว่าที่คิด — ระบบ deploy จำนวนมากพลาดตรงนี้เพราะไปใช้ --update
(-u) ซึ่ง ข้ามไฟล์ที่ปลายทางใหม่กว่า ⇒ rollback ไม่ทำงาน โดยไม่มี error
ถ้าย้ายไป S3 / CloudFront / R2 ล่ะ
หลักการเดิมทั้งหมด แต่กลไกเปลี่ยน — จดไว้กันลืม:
| เรื่อง | VPS (Caddy/nginx) | object storage + CDN |
|---|---|---|
Cache-Control | header ใน config | ตั้งเป็น metadata ตอน upload (--cache-control) |
| revalidate | ETag ของ server | ETag ของ S3 = MD5 ของ object (แข็งแรงกว่า) แต่ CDN ต้องตั้ง cache behavior แยก |
| ของเก่าค้างที่ edge | ไม่มี edge | ต้อง invalidate ตอน deploy ไม่งั้นค้างจนหมด TTL |
| SPA routing | try_files | CloudFront Function / custom error 404→/index.html |
| security headers | import ใน config | response-headers policy — ไม่ย้ายตาม = หายเงียบ |
ข่าวดีคือ ชั้นที่ 1 ไม่ขยับเลย: แยกไฟล์ที่มี hash ออกจากไฟล์ที่ URL คงที่ เป็นการออกแบบที่ไม่ผูกกับ host — ย้ายเมื่อไรก็ยังใช้ได้
สรุป
สามชั้นนี้ตัดสินใจแยกกัน และทุกชั้นตัดสินจากข้อมูลที่อ่อนกว่าที่เราคิด:
- build hash ให้เฉพาะ asset — entry point (
index.html,config.js) ไม่ได้ hash - server ส่ง ETag แต่ไม่ส่ง
Cache-Control→ เบราว์เซอร์เดาเอง → ETag ไม่ถูกใช้ · และ ETag เองก็มาจากmtime + sizeไม่ใช่เนื้อ - rsync ก็ตัดสินด้วย
mtime + sizeเหมือนกัน → พังตั้งแต่ยังไม่ถึงชั้น cache
แก้ด้วย 3 อย่าง: no-cache สำหรับไฟล์ที่ URL คงที่ · immutable เฉพาะไฟล์ที่มี hash ·
rsync -c ให้ตัดสินจากเนื้อจริง
บทเรียนที่กว้างกว่านั้น: “มันมี ETag อยู่แล้ว” ไม่ได้แปลว่ามีใครใช้มัน และกลไก
ที่ดู “ควรจะทำงาน” ควรถูกวัด ไม่ใช่ถูกสันนิษฐาน — ทุกตัวเลขในบทความนี้มาจาก
curl -I กับ Caddy จริง ซึ่งใช้เวลาไม่ถึงสิบนาที แต่เปลี่ยนคำตอบไป 2 รอบ
