deploy แล้วเว็บไม่อัปเดต — ไล่แคชห้าชั้นให้จบในครั้งเดียว
คำว่า "deploy เสร็จแล้ว" ที่แปลว่าไฟล์ขึ้นไปถึงเซิร์ฟเวอร์แล้ว กับที่แปลว่าคนเปิดเว็บเห็นของใหม่แล้ว เป็นคนละเรื่องกันคนละชั้น และระหว่างสองอันนั้นมีของคั่นอยู่ห้าชั้นที่ล้างคนละวิธี
พอดีช่วงหนึ่งผมต้องแก้หน้าเว็บให้ลูกค้าวันละหลายรอบ แล้วเจอคำถามเดิมซ้ำแทบทุกรอบว่า "แก้แล้วเหรอ ทำไมยังเหมือนเดิม" ตอนแรกผมก็ตอบไปว่ารอสักครู่ ลองล้างแคชเบราว์เซอร์ดู ซึ่งเป็นคำตอบที่ถูกอยู่บ้างเป็นบางครั้งและผิดเต็ม ๆ ในอีกหลายครั้ง พอโดนเข้าบ่อย ๆ ผมเลยนั่งไล่ให้ครบว่าไฟล์ที่เราอัปขึ้นไปจริง ๆ แล้วมันโดนอะไรคั่นไว้บ้างกว่าจะไปโผล่ที่หน้าจอคน
คำตอบคือห้าชั้นครับ และประเด็นที่ทำให้มันกวนใจกว่าที่ควรเป็นคือ แต่ละชั้นล้างคนละวิธี และการล้างผิดชั้นไม่ได้ให้ error อะไรออกมาเลย มันแค่ไม่เปลี่ยน เราเลยสรุปผิดต่อไปว่าไฟล์ยังไม่ขึ้น แล้วอัปซ้ำอีกรอบ วนอยู่อย่างนั้น
โดเมนกับ path ในบทความเป็นค่าตัวอย่างทั้งหมด
ห้าชั้นที่คั่นอยู่ระหว่างไฟล์กับคนอ่าน
ชั้นล่างสุดกับบนสุดคนส่วนใหญ่รู้จักดีอยู่แล้ว ที่พลาดกันประจำคือสามชั้นกลาง เพราะมันอยู่ในเครื่องเราเอง มองไม่เห็นจากข้างนอก และไม่มีอะไรบอกว่ามันยังถืออันเก่าอยู่
ชั้นที่ 1 opcache — ไฟล์ใหม่แต่ PHP ยังรันของเก่า
opcache เก็บผลการคอมไพล์ PHP ไว้ในหน่วยความจำเพื่อไม่ต้องแปลไฟล์ใหม่ทุก request ซึ่งเป็นเรื่องดีมากสำหรับประสิทธิภาพ ปัญหาอยู่ที่ค่าตัวเดียวนี้
; php.ini ของ pool ที่ใช้จริง
opcache.enable=1
opcache.validate_timestamps=0 ; <- ตัวนี้แปลว่า "ไม่ต้องไปดูว่าไฟล์เปลี่ยนไหม"
opcache.revalidate_freq=0
ถ้า validate_timestamps=0 แปลว่า PHP จะไม่สนใจเลยว่าไฟล์บนดิสก์เปลี่ยนไปแล้ว มันจะรันไบต์โค้ดชุดเดิมไปจนกว่าจะมีคนสั่งล้างหรือ reload กระบวนการ ส่วนถ้าตั้งเป็น 1 พร้อม revalidate_freq=60 ก็แปลว่าอาจต้องรอถึงหนึ่งนาที ซึ่งกำลังพอดีกับช่วงที่เราเทสต์แล้วบอกว่า "ไม่ขึ้น" หนึ่งรอบ
เช็คค่าจริงของ pool ที่เว็บใช้ ไม่ใช่ของ CLI เพราะสองตัวนี้คนละไฟล์ตั้งค่ากันได้
# ค่าฝั่ง CLI (มักไม่ตรงกับฝั่งเว็บ)
php -i | grep -E 'opcache.(enable|validate_timestamps|revalidate_freq)'
# ค่าฝั่งเว็บจริง — ต้องดูผ่าน request
printf '<?php phpinfo();' > ~/domains/app.example.com/public_html/_i.php
curl -s https://app.example.com/_i.php | grep -o 'validate_timestamps.*' | head -3
rm -f ~/domains/app.example.com/public_html/_i.php
วิธีล้างมีสองแบบ แบบแรกคือ reload กระบวนการ PHP-FPM ทั้ง pool ซึ่งทำได้ถ้าเรามีสิทธิ์ระดับ root แบบที่สองสำหรับโฮสต์แบบแชร์ที่ไม่มีสิทธิ์นั้น คือเรียก opcache_reset() ผ่านหน้าเว็บ เพราะฟังก์ชันนี้มีผลกับกระบวนการที่รันมันเท่านั้น สั่งจาก CLI ไม่มีผลกับฝั่งเว็บ
<?php
// _opc.php — วางไว้ที่ docroot ชั่วคราว แล้ว "ลบทันที" ที่ใช้เสร็จ
if (function_exists('opcache_reset')) {
var_dump(opcache_reset());
} else {
echo 'no opcache';
}
curl -s https://app.example.com/_opc.php # bool(true)
rm -f ~/domains/app.example.com/public_html/_opc.php
ทั้ง _i.php และ _opc.php เป็นไฟล์ที่ใครก็เรียกได้ถ้าเดาชื่อถูก ตัวแรกเปิดเผยเวอร์ชัน โมดูล และ path ทั้งเครื่อง ตัวที่สองให้คนนอกล้างแคชเว็บเราเล่นได้เรื่อย ๆ ใช้เสร็จลบทันทีในคำสั่งเดียวกัน อย่าปล่อยค้างข้ามคืนเด็ดขาด และอย่าตั้งชื่อให้เดาง่ายอย่าง info.php
ชั้นที่ 2 config cache — แก้ .env แล้วไม่มีอะไรเปลี่ยน
php artisan config:cache จะยุบไฟล์ config ทั้งหมดพร้อมค่าที่อ่านจาก .env ณ วินาทีนั้นเป็นไฟล์เดียว พอมีไฟล์นี้แล้ว ฟังก์ชัน env() ในโค้ดแอปจะคืนค่าว่างทันที เพราะ Laravel ไม่โหลด .env อีกต่อไป อาการที่ได้คือแก้ .env เท่าไรก็ไม่เปลี่ยน หรือหนักกว่านั้นคือค่าที่เคยใช้ได้กลายเป็นว่างหลัง deploy
php artisan config:clear # ทิ้งไฟล์รวม กลับไปอ่าน .env สด
php artisan config:cache # อบใหม่จากค่าปัจจุบัน
php artisan about --only=environment
กฎที่ผมยึดคือ โค้ดแอปต้องอ่าน config('x.y') เท่านั้น ห้ามเรียก env() นอกไฟล์ config ส่วนค่าที่มีอักขระพิเศษให้ครอบด้วยเครื่องหมายคำพูดเสมอ เพราะเครื่องหมาย # กลางบรรทัดจะถูกอ่านเป็นจุดเริ่มคอมเมนต์ แล้วค่าจะถูกตัดหายไปครึ่งหนึ่งแบบเงียบ ๆ
# ผิด — ค่าที่ได้จริงคือ Secret เฉย ๆ
APP_PASSPHRASE=Secret#2026
# ถูก
APP_PASSPHRASE="Secret#2026"
ชั้นที่ 3 route cache — เพิ่ม route ใหม่แล้ว 404
ชั้นนี้ตรงไปตรงมาที่สุด ถ้ามีไฟล์ route cache อยู่ route ที่เพิ่มเข้ามาใหม่จะไม่มีตัวตนจนกว่าจะอบใหม่ อาการคือ 404 ทั้งที่ route:list แสดงให้เห็น เพราะ route:list อ่านจากไฟล์ต้นฉบับ ไม่ได้อ่านจากไฟล์ที่อบไว้
php artisan route:clear && php artisan route:cache
curl -sI https://app.example.com/privacy | head -1 # ต้องได้ 200 ไม่ใช่ 404
มีเครื่องบางเครื่องที่ route cache ทำให้ทุก GET ตอบ 405 ซึ่งเป็นคนละเรื่องกับที่กำลังพูดถึงนี้ ผมแยกไปเขียนไว้ที่ route:cache แล้วทุก GET กลายเป็น 405 ใครเจออาการนั้นให้ข้ามไปอ่านเล่มนั้นแทน
ชั้นที่ 4 view cache — และกับดัก 500 ค้างหลังสั่งล้าง
blade ทุกไฟล์ถูกคอมไพล์เป็น PHP เก็บไว้ใน storage/framework/views/ โดยตั้งชื่อไฟล์ตามแฮชของ path ต้นฉบับ ปกติ Laravel จะเทียบเวลาแก้ไขไฟล์ว่าต้องคอมไพล์ใหม่ไหม ซึ่งทำงานได้ดีบนเครื่อง dev
ที่ต้องระวังคือบนโปรดักชันที่มีคนเข้าอยู่ ถ้าเราสั่ง view:clear เฉย ๆ จะมีช่วงหนึ่งที่ไฟล์คอมไพล์ถูกลบไปแล้วแต่ยังไม่ถูกสร้างใหม่ และ request ที่วิ่งเข้ามาพอดีในจังหวะนั้นจะได้ error แบบนี้
production.ERROR: filemtime(): stat failed for
/home/appuser/app/storage/framework/views/8f2c...c1.php
{"exception":"[object] (ErrorException(code: 0) ...
ที่ผมเจอแล้วงงอยู่พักหนึ่งคือมันไม่ใช่ error ชั่วคราวที่หายเองเสมอไป บางครั้งมัน ค้าง 500 ต่อไปเรื่อย ๆ ต่อให้เปิดซ้ำอีกสิบครั้ง ทางแก้ที่จบขาดคือไม่ปล่อยให้มันคอมไพล์ตอน runtime เลย ให้สั่งอบล่วงหน้าให้ครบทุกไฟล์ในจังหวะ deploy
php artisan view:clear
php artisan view:cache # precompile ทุก blade ไม่เหลือช่องให้แข่งกัน
curl -sI https://app.example.com/ | head -1
เมื่อ APP_DEBUG ปิดอยู่ หน้าเว็บจะบอกแค่ Server Error ซึ่งไม่มีข้อมูลอะไรเลย ของจริงอยู่ที่ storage/logs/laravel.log เสมอ อย่าเปิด APP_DEBUG บนเครื่องจริงเพื่อดู error เพราะหน้าจอ debug ของ Laravel แสดงค่าจาก .env ทั้งกองรวมถึงรหัสฐานข้อมูล ให้ไปอ่านที่ log แทน
ชั้นที่ 5 CDN — ไฟล์สแตติกที่ทับชื่อเดิมแล้วไม่เปลี่ยน
หน้า HTML ที่ออกจาก Laravel ปกติมี header บอกว่าห้ามแคช เลยไม่ค่อยมีปัญหา ตัวที่มีปัญหาคือไฟล์สแตติก ทั้ง js, css และรูป เพราะ CDN เก็บไว้ตามค่าอายุที่ตั้งไว้ ซึ่งค่าเริ่มต้นแบบ 4 ชั่วโมงเป็นเรื่องปกติมาก การอัปไฟล์ใหม่ทับชื่อเดิมไม่ได้ทำให้ของที่ถูกเก็บไว้แล้วหายไป
สิ่งที่ผมเคยลองแล้วไม่ช่วยคือสลับโดเมนเป็น DNS-only ชั่วคราว เพราะถึงคำขอจะวิ่งตรงเข้า origin แล้วจริง แต่เบราว์เซอร์กับตัวกลางระหว่างทางยังถือของเดิมอยู่ พอสลับกลับก็เจอของเก่าอีก และช่วงที่ปิดอยู่นั้นเว็บก็เปิดโล่งให้โลกเห็น IP จริงของเครื่องไปด้วย ไม่คุ้มเลย
กด purge ที่หน้าแผงควบคุม
ได้ผลทันทีกับไฟล์ที่ระบุ แต่ต้องทำเองทุกรอบ ลืมเมื่อไรก็กลับมาเป็นเหมือนเดิม และไม่ช่วยเรื่องแคชในเครื่องคนที่เคยเปิดไปแล้ว
เปลี่ยนชื่อไฟล์ที่อ้างถึง
ต่อท้ายด้วยเลขรุ่นหรือแฮช เช่น app.css?v=20261015a เมื่อชื่อเปลี่ยน ทุกชั้นรวมถึงเบราว์เซอร์ของคนที่เคยเปิด ถือว่าเป็นไฟล์คนละตัว ไม่ต้องพึ่ง purge อีกเลย
// config/app.php — เลขรุ่นของชุดไฟล์สแตติก อัปเดตตอน deploy
'asset_ver' => env('ASSET_VER', '20261015a'),
{{-- ใน blade --}}
<link rel="stylesheet" href="{{ asset('css/app.css') }}?v={{ config('app.asset_ver') }}">
<script src="{{ asset('js/site.js') }}?v={{ config('app.asset_ver') }}" defer></script>
ตารางที่ผมติดไว้ข้างจอ: เช็คแบบนี้ ยังพังทางไหนได้
มันบอกอะไรกับเราจริง ๆ
ls -l บนเซิร์ฟเวอร์ บอกแค่ว่าไฟล์ถึงดิสก์แล้ว · เปิดในเบราว์เซอร์ตัวเอง บอกแค่ว่าเครื่องเราเห็นอะไร · curl เปล่า ๆ บอกแค่ว่า origin ตอบอะไรกับ user agent ที่ไม่ใช่คน
ช่องที่แต่ละอันมองไม่เห็น
ไฟล์ถึงดิสก์แต่ opcache ยังรันของเก่า · เบราว์เซอร์เราเพิ่ง hard refresh ไปแต่ของคนอื่นยังเก่า · curl ได้ของใหม่จาก origin แต่คนจริงได้ของเก่าจาก CDN
วิธีตรวจที่ตรงกับความจริงที่สุดคือยิงด้วย user agent ของเบราว์เซอร์จริง พร้อมพารามิเตอร์สุ่มกันแคช แล้วอ่าน header ที่บอกสถานะแคชประกอบ
UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101 Firefox/151.0'
curl -sI -A "$UA" "https://app.example.com/css/app.css?v=$RANDOM" | grep -iE 'http/|age|cache-control|last-modified'
ลำดับล้างที่ผมใช้ประจำ — สคริปต์เดียวจบ
ผมเลิกจำเป็นข้อ ๆ แล้วเขียนเป็นสคริปต์วางไว้ที่เครื่องเลย เพราะลำดับมันสำคัญ ถ้าล้าง config ทีหลัง view จะได้ค่าผิดไปคอมไพล์ทิ้งไว้อีกรอบ
#!/bin/bash
# deploy-flush.sh — รันหลังอัปไฟล์เสร็จทุกครั้ง
set -e
APP=~/domains/app.example.com/app
DOC=~/domains/app.example.com/public_html
cd "$APP"
php artisan config:clear
php artisan config:cache
php artisan route:clear
php artisan route:cache # เครื่องไหน route cache มีปัญหา ให้เหลือแค่บรรทัดบน
php artisan view:clear
php artisan view:cache
printf '<?php opcache_reset();' > "$DOC/_opc.php"
curl -s "https://app.example.com/_opc.php" >/dev/null
rm -f "$DOC/_opc.php"
curl -sI "https://app.example.com/?v=$RANDOM" | head -1
- ล้างแล้วอบทันทีในบรรทัดถัดไป อย่าปล่อยให้เครื่องอยู่ในสถานะ "ล้างแล้วแต่ยังไม่อบ" ขณะมีคนเข้าเว็บอยู่ เพราะนั่นคือช่วงที่เกิดอาการ 500 ค้างจากไฟล์คอมไพล์ที่หายไป
- เรียก opcache_reset ผ่าน https ไม่ใช่ผ่าน CLI เพราะสั่งจาก CLI ไปล้างหน่วยความจำของกระบวนการ CLI ซึ่งไม่ใช่ตัวที่เสิร์ฟเว็บ ลบไฟล์ตัวช่วยในบรรทัดถัดไปเสมอ
- ปิดท้ายด้วยการยิงหน้าจริงแล้วอ่านสถานะ ถ้าบรรทัดสุดท้ายไม่ได้ 200 แปลว่า deploy รอบนี้ยังไม่จบ อย่าเพิ่งไปบอกใครว่าเสร็จ
ที่เหลือคือเรื่องของวินัยล้วน ๆ ครับ ผมยอมเสียเวลาเขียนสคริปต์วันนั้นครึ่งชั่วโมง แล้วไม่ต้องตอบคำถาม "ทำไมยังเหมือนเดิม" อีกเลยเป็นปี ส่วนใครที่ยังต้องล้างมือทุกรอบ อย่างน้อยขอให้จำสองข้อนี้ไว้คือ ล้างแล้วต้องอบต่อทันที และตรวจผลด้วยการยิงหน้าจริงเสมอ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย