ข้อผิดพลาดบนโปรดักชันที่มีประโยชน์ที่สุดอย่างหนึ่งที่ผมเจอหลังดีพลอย ดูเผิน ๆ แทบไม่มีอะไรน่าสนใจเลย:
Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js
มันอยู่ในกระแสข้อผิดพลาดเดียวกับปัญหาจากเครื่องมือวิเคราะห์ สคริปต์โฆษณา ข้อความทั่วไปอย่าง Script error. และวิดีโอที่เล่นแล้วถูกขัดจังหวะ ส่วนใหญ่เป็นสัญญาณรบกวน แต่กรณีนี้ต่างออกไป เพราะทรัพยากรที่โหลดไม่สำเร็จเป็นของแอป Next.js ของผมเอง ถ้าเบราว์เซอร์ดึงไฟล์นั้นไม่ได้จริง ๆ บางส่วนของหน้าอาจหยุดทำงาน
สิ่งที่ผม ยังไม่รู้ จากล็อกนั้นคือเหตุใดชังก์จึงโหลดล้มเหลว อาจเป็นปัญหาเครือข่ายชั่วคราว พร็อกซีหรือ CDN อาจมีปัญหา หรือไฟล์อาจหายไปจริง ๆ อีกความเป็นไปได้คือหน้าเก่ายังคงขอชังก์จากดีพลอยก่อนหน้า หลังจากเซิร์ฟเวอร์เปลี่ยนไปใช้บิลด์ใหม่แล้ว
กรณีสุดท้ายถูกมองข้ามได้ง่าย เพราะดีพลอยใหม่อาจทำงานปกติทุกอย่าง ผู้เข้าชมใหม่ได้รับเวอร์ชันใหม่และใช้งานได้ตามปกติ ขณะที่แท็บซึ่งเปิดมาหลายชั่วโมงยังคงเป็นไคลเอนต์ของเวอร์ชันก่อนหน้าโดยไม่มีใครสังเกต
บทความนี้พูดถึงช่องว่างด้านความเข้ากันได้นั้นโดยตรง: ทำไมแท็บ Next.js เก่าจึงพังหลังดีพลอยได้ HTML ที่ล้าสมัยกับทรัพยากร /_next/static ที่หายไปทำให้เวอร์ชันไม่ตรงกันอย่างไร เหตุใดการลบไฟล์แบบรุนแรงจึงทำให้ปัญหาหนักขึ้น และผมจะออกแบบการดีพลอย การเก็บทรัพยากรเก่า การเฝ้าระวัง และการกู้คืนอย่างไร เพื่อให้รีลีสที่สำเร็จไม่ทิ้งผู้ใช้ที่เปิดแอปค้างไว้กับหน้าที่เสีย
บทเรียนแรก: อย่าถือว่าทุกสคริปต์ที่ล้มเหลวคือปัญหาจากการดีพลอย
กระแสข้อผิดพลาดเดิมรวมความล้มเหลวหลายชนิดที่ไม่เหมือนกันเลย สคริปต์วิเคราะห์และโฆษณาของบุคคลที่สามอาจถูกบล็อกโดยตัวกรองเนื้อหา ตัวกรอง DNS ฟังก์ชันความเป็นส่วนตัว การจำกัดตามภูมิภาค โปรแกรมป้องกันไวรัส หรือเครือข่ายของผู้ใช้ คำสัญญาจาก play() ของวิดีโออาจถูกขัดจังหวะด้วย pause() ที่เรียกภายหลัง โดยที่ตัวแอปไม่ได้เสีย ส่วนข้อความข้ามต้นทางแบบกว้าง ๆ อย่าง Script error. ก็มักมีข้อมูลไม่พอสำหรับวินิจฉัย
ในทางกลับกัน ถ้าชังก์ของ Next.js ที่เป็นของแอปเองโหลดไม่สำเร็จ ควรให้ความสำคัญต่างกัน เส้นแบ่งที่มีประโยชน์ไม่ใช่ “มีข้อผิดพลาด JavaScript หรือไม่” แต่ใกล้เคียงกับแบบนี้:
ทรัพยากรของบุคคลที่สามโหลดไม่สำเร็จ
-> โดยมากกระทบการวัดผลหรือฟังก์ชันเสริม
/_next/static/*.js ของแอปเองโหลดไม่สำเร็จ
-> โค้ดบางส่วนของแอปอาจใช้งานไม่ได้
การแยกแบบนี้สำคัญ เพราะระบบรายงานที่ส่งเสียงมากเกินไปอาจกลบความล้มเหลวที่สัมพันธ์กับหน้าที่เสียจริง ๆ ในกรณีของผม เหตุการณ์สำคัญคือคำขอไปยัง /_next/static/chunks/9253.647385b4be0958e4.js ล็อกยืนยันได้ว่าสคริปต์ของแอปเองโหลดไม่สำเร็จ แต่ไม่ได้ยืนยันว่าเวอร์ชันที่ไม่ตรงกันหลังดีพลอยเป็นสาเหตุ
ผมตั้งใจรักษาเส้นแบ่งของหลักฐานนี้ให้ชัดเจน: สาเหตุที่เป็นไปได้ยังไม่ใช่สาเหตุที่ยืนยันแล้ว
แท็บเบราว์เซอร์ที่ยังเปิดอยู่คือไคลเอนต์ของรีลีสเก่าในทางปฏิบัติ
กรอบความคิดที่ช่วยให้ผมมองปัญหานี้ชัดขึ้นนั้นง่ายมาก: หลังดีพลอย อาจมีแอปหลายเวอร์ชันยังทำงานอยู่พร้อมกัน
สมมติว่ารีลีส A เปิดใช้งานเวลา 10:00 ผู้ใช้เปิดหน้าและได้รับ HTML พร้อม JavaScript ที่จำเป็นสำหรับเส้นทางนั้น เวลา 10:30 รีลีส B เข้ามาแทน ผู้เข้าชมใหม่ได้รับ B แต่แท็บที่เปิดอยู่ก่อนแล้วไม่ได้เปลี่ยนเป็น B อัตโนมัติเพียงเพราะเซิร์ฟเวอร์เปลี่ยน
ในแท็บนั้นอาจยังมี:
- รันไทม์ JavaScript ที่โหลดมาจากรีลีส A;
- การอ้างอิงเส้นทางและชังก์ที่ A สร้างไว้;
- ข้อมูลนำทางที่ A โหลดล่วงหน้า;
- สถานะ React ที่สร้างขึ้นขณะ A ทำงาน;
- โมดูลที่แยกโค้ดจาก A และดาวน์โหลดแล้ว;
- การอ้างอิงโมดูลของ A ที่ ยังไม่ได้ ดาวน์โหลด
ข้อสุดท้ายคือจุดที่ปัญหามักปรากฏให้เห็น
หากทุกชังก์ที่หน้าอาจต้องใช้ในอนาคตอยู่ในแคชของเบราว์เซอร์แล้ว ผู้ใช้อาจใช้งานต่อโดยไม่รู้ว่ามีอะไรผิดปกติ แต่แอป Next.js สมัยใหม่แยกโค้ดออกเป็นส่วน ๆ การเปลี่ยนเส้นทาง การนำเข้าแบบไดนามิก หน้าต่างซ้อน ตัวแก้ไข หรือฟังก์ชันที่ใช้ภายหลัง อาจต้องใช้ไฟล์ JavaScript เพิ่ม รันไทม์เก่าจึงส่งคำขอไปยัง URL ของทรัพยากรที่เคยถูกต้องในรีลีส A
ถ้าเซิร์ฟเวอร์ยังเก็บทรัพยากรนั้น ทุกอย่างอาจทำงานต่อได้ แต่ถ้าดีพลอยลบไฟล์ไปแล้ว ไคลเอนต์เก่าอาจได้รับ 404 แม้รีลีส B เองจะทำงานสมบูรณ์
ชังก์ที่มีแฮชของเนื้อหาถูกออกแบบมาให้แคชระยะยาว
Next.js ตั้งใจให้ทรัพยากรที่ไม่เปลี่ยนจริง ๆ มีอายุแคชนาน เอกสารการโฮสต์เองฉบับปัจจุบันระบุว่าทรัพยากรที่ไม่เปลี่ยนและมีแฮช SHA ในชื่อไฟล์จะถูกส่งด้วยนโยบายหนึ่งปี เช่น:
Cache-Control: public, max-age=31536000, immutable
นโยบายนี้สมเหตุสมผล เพราะ URL เปลี่ยนเมื่อเนื้อหาเปลี่ยน ไฟล์ที่ชื่อมาจากเนื้อหาไม่จำเป็นต้องตรวจสอบซ้ำทุกคำขอ หากบิลด์ภายหลังให้ข้อมูลคนละชุด ก็ควรได้ URL ของทรัพยากรคนละตัว
ผลที่สำคัญแต่ถูกมองข้ามได้ง่ายคือ URL เก่ายังมีความหมายตราบใดที่เอกสารเก่าหรือรันไทม์เก่ายังอ้างถึงมันได้
การที่เบราว์เซอร์สามารถแคชทรัพยากรที่มีแฮชได้นานหนึ่งปีไม่ช่วยอะไร ถ้าเบราว์เซอร์ไม่เคยดาวน์โหลดไฟล์นั้นก่อนดีพลอย และตอนที่ต้องใช้ครั้งแรก เซิร์ฟเวอร์ต้นทางได้ลบไฟล์ไปแล้ว
ดังนั้น “ไฟล์สแตติกของเราไม่เปลี่ยน” กับ “เราลบไฟล์สแตติกของรีลีสก่อนหน้าได้ทันที” จึงไม่ใช่ข้อความเดียวกัน ความไม่เปลี่ยนทำให้การเก็บทรัพยากรเก่าปลอดภัย แต่ไม่ได้ทำให้ไคลเอนต์เก่าหยุดร้องขอทรัพยากรเหล่านั้น
คู่มือโฮสต์ Next.js ด้วยตนเองฉบับปัจจุบัน ระบุชัดว่าไฟล์ JavaScript หรือ CSS ที่หายไปเป็นหนึ่งในอาการของเวอร์ชันไม่ตรงกันระหว่างการดีพลอยแบบหลายเซิร์ฟเวอร์หรือแบบทยอยเปลี่ยน ปัญหาอยู่ในตระกูลเดียวกันแม้ความไม่ตรงกันจะเกิดระหว่างแท็บเก่ากับเซิร์ฟเวอร์ต้นทางที่เพิ่งอัปเดต แทนที่จะเป็นสองเซิร์ฟเวอร์ที่ทำงานพร้อมกัน
เวอร์ชันสามารถแยกจากกันได้หลายทาง
คำว่า “ปัญหาแคช” กว้างเกินไปสำหรับใช้วินิจฉัย ผมแยกอย่างน้อยสี่กลไก เพราะแต่ละแบบต้องแก้ต่างกัน
1. แท็บเก่าขอทรัพยากรที่ไม่เคยโหลดก่อนดีพลอย
นี่คือกรณีคลาสสิกของแท็บที่เปิดค้างนาน เอกสารและรันไทม์มาจากรีลีส A รีลีส B แทนที่ไฟล์บนเซิร์ฟเวอร์ ต่อมาผู้ใช้ทำสิ่งที่เรียกชังก์ของ A ซึ่งโหลดเมื่อจำเป็น หากทรัพยากรของ A ถูกลบ คำขอก็ล้มเหลว
2. HTML เก่าชี้ไปยังชังก์ที่ไม่มีอยู่แล้ว
CDN รีเวิร์สพร็อกซี Service Worker แคชของเบราว์เซอร์ หรือชั้นโฮสต์สแตติกอาจเก็บเอกสาร HTML รุ่นเก่าไว้นานกว่าที่คาด เอกสารนั้นยังอาจอ้างทรัพยากรของ A ขณะที่ต้นทางเหลือเพียง B
กรณีนี้อันตรายเป็นพิเศษเมื่อ HTML ได้รับนโยบาย immutable ระยะยาวโดยผิดพลาด JavaScript ที่มีแฮชกับ HTML ไม่ควรถูกปฏิบัติเป็นวัตถุแคชชนิดเดียวกัน ชังก์เป็นสิ่งไม่เปลี่ยนได้เพราะ URL ถูกกำหนดเวอร์ชันตามเนื้อหา ส่วน HTML เป็นตัวเลือกว่ากลุ่ม URL ของชังก์ใดควรทำงานร่วมกัน
3. การดีพลอยแบบทยอยเปลี่ยนหรือหลายอินสแตนซ์ให้บริการคนละรีลีส
ลองนึกถึงอินสแตนซ์ Next.js สองตัวหลังตัวกระจายโหลด ตัวหนึ่งอยู่บน B แล้ว อีกตัวยังเป็น A เอกสารอาจมาจากเวอร์ชันหนึ่ง ส่วนคำขอนำทางถัดไปไปตกที่อีกเวอร์ชัน เอกสาร Next.js ปัจจุบันเรียกกรณีนี้ว่าเวอร์ชันไม่ตรงกัน และระบุว่าอาจทำให้ทรัพยากรหาย Server Functions ไม่ตรงกัน และการนำทางล้มเหลว
ค่าเริ่มต้นที่ปลอดภัยที่สุดคือบิลด์เพียงครั้งเดียว แล้วใช้ผลลัพธ์บิลด์เดียวกันกับทุกอินสแตนซ์ที่อยู่ในดีพลอยเดียวกัน เอกสารการโฮสต์เองของ Next.js ยังแนะนำให้ใช้บิลด์เดียวกันและรหัสบิลด์ที่สอดคล้องกันในทุกคอนเทนเนอร์ แทนการบิลด์แต่ละสำเนาแยกกัน
4. ตัวกระบวนการดีพลอยเผยแพร่ไฟล์ผิดลำดับ
แม้ไม่มีแท็บเก่า การอัปโหลดที่ไม่เป็นอะตอมก็สร้างสถานะที่เป็นไปไม่ได้ชั่วคราวได้:
HTML ใหม่มองเห็นได้แล้ว
+
ไฟล์ชังก์ใหม่ยังใช้งานไม่ได้
หรือกลับกัน:
HTML เก่ายังมองเห็นได้
+
ไฟล์ชังก์เก่าถูกลบแล้ว
ช่วงเวลาสั้นมากก็เพียงพอ ผู้ใช้ต้องเข้ามาตรงจังหวะนั้นเพียงครั้งเดียว
รูปแบบดีพลอยที่อันตรายคือ “แทนที่ทุกอย่างแล้วลบต้นไม้เก่า”
สคริปต์ดีพลอยแบบง่ายมักเริ่มจากอะไรประมาณนี้:
build
rsync --delete new-output/ production/
restart
มันน่าสนใจเพราะไดเรกทอรีโปรดักชันตรงกับบิลด์ล่าสุดเสมอ แต่ไม่เป็นมิตรกับไคลเอนต์ที่มีอายุยาว
สำหรับทรัพยากรสแตติกที่มีแฮช การล้างไดเรกทอรีให้เหลือรีลีสเดียวแทบไม่เกิดประโยชน์ต่อเบราว์เซอร์ ไฟล์เก่าไม่ชนกับไฟล์ใหม่เพราะ URL ต่างกัน การลบช่วยประหยัดพื้นที่ดิสก์เป็นหลัก แต่เปลี่ยนทุกการอ้างอิงที่ยังใช้ได้ของไคลเอนต์เก่าให้กลายเป็น 404 ที่อาจเกิดขึ้น
ตอนนี้ผมมองชังก์เก่าเป็นส่วนประกอบเพื่อรักษาความเข้ากันได้ของการดีพลอย ไม่ใช่ขยะ
ไม่ได้หมายความว่าต้องเก็บทุกบิลด์ตลอดไป แต่การล้างควรเป็นนโยบายการเก็บแยกต่างหาก ไม่ใช่ผลข้างเคียงของการเผยแพร่รีลีสล่าสุด
การเก็บทรัพยากรเก่ามีประโยชน์ แต่ช่วงเวลาจำกัดใด ๆ ก็ไม่ใช่คำตอบทั้งหมด
ระบบที่โฮสต์เองสามารถเก็บทรัพยากรเก่าของ /_next/static ไว้ในช่วงผ่อนผัน ระยะเวลาที่เหมาะสมขึ้นอยู่กับรูปแบบการใช้งาน เว็บไซต์ที่ผู้ใช้เปิดหน้า อ่านสองนาทีแล้วออก มีความเสี่ยงต่างจากแอปที่เปิดไว้ทั้งวันทำงาน
วิธีคิดช่วงการเก็บขั้นต่ำแบบหนึ่งคือ:
ช่วงการเก็บ >=
อายุที่คาดว่า HTML เก่าจะยังคงอยู่
+ อายุใช้งานจริงของแท็บที่เปิดค้างนาน
+ ช่วงเวลาสำหรับย้อนกลับรีลีส
+ ระยะเผื่อการกระจายของดีพลอย
นี่ไม่ใช่การรับประกันทางคณิตศาสตร์ แท็บเบราว์เซอร์อาจเปิดค้างเป็นสัปดาห์ ไม่มีจำนวนชั่วโมงจำกัดใดที่ทำให้ข้อผิดพลาดของไคลเอนต์เก่าเป็นไปไม่ได้
เพราะเหตุนี้ผมจึงชอบการป้องกันหลายชั้น:
- เก็บทรัพยากรที่ไม่เปลี่ยนของรีลีสก่อนหน้าไว้นานพอให้เซสชันเก่าทั่วไปทำงานต่อได้;
- ตรวจจับเวอร์ชันไม่ตรงกัน เพื่อให้ไคลเอนต์ย้ายไปรีลีสปัจจุบันได้;
- ให้การโหลดใหม่ที่ปลอดภัยเพียงครั้งเดียว หรือเส้นทางกู้คืนที่ผู้ใช้มองเห็นได้ เมื่อทรัพยากรหายจริง;
- เฝ้าระวังชังก์ของแอปเองที่หายไป เพื่อปรับช่วงการเก็บจากข้อมูลจริง
ชั้นการเก็บช่วยป้องกันความล้มเหลวส่วนใหญ่ ส่วนชั้นกู้คืนจัดการกรณีปลายหางที่ช่วงการเก็บแบบจำกัดไม่สามารถกำจัดได้ทั้งหมด
อย่าลบชังก์เก่าโดยดูแค่อายุไฟล์
กฎง่าย ๆ อย่าง “ลบไฟล์ทุกไฟล์ที่เก่ากว่าเจ็ดวัน” ก็อาจผิด รีลีสปัจจุบันอาจนำไฟล์เก่าที่มีแฮชกลับมาใช้ เพราะเนื้อหาไม่ได้เปลี่ยน จึงมีเวลาปรับปรุงบนดิสก์เก่า
รูปแบบเก็บกวาดไฟล์ที่แข็งแรงกว่าควรรู้จักรีลีส:
- เก็บแมนิเฟสต์หรือรายการทรัพยากรของทุกรีลีสที่ยังอยู่ในช่วงความเข้ากันได้;
- สร้างเซตสหภาพของเส้นทางทรัพยากรทั้งหมดที่รีลีสเหล่านั้นอ้างถึง;
- อย่าลบสิ่งใดที่อยู่ในเซตที่ป้องกันนี้;
- ลบทรัพยากรที่ไม่มีใครอ้างถึงหลังพ้นช่วงผ่อนผันเพิ่มเติมเท่านั้น
ถ้ากลไกนี้มากเกินไปสำหรับดีพลอยขนาดเล็ก การตั้งใจให้ไดเรกทอรีทรัพยากรสแตติกมีพื้นที่เผื่อ มักถูกกว่าการตามหาความผิดพลาดหายากบนไคลเอนต์ ไฟล์ที่มีแฮชเหมาะกับแนวทางนี้มาก เพราะเนื้อหาเดียวกันสามารถใช้ URL ที่เสถียรซ้ำได้ หรืออย่างน้อยไม่สามารถเขียนทับเนื้อหาที่ไม่เกี่ยวข้องภายใต้ชื่อแฮชเดียวกัน
กฎที่ผมจะหลีกเลี่ยงนั้นง่ายมาก: อย่าให้ --delete บนต้นไม้ /_next/static ที่ใช้ร่วมกันเป็นส่วนหนึ่งของงานเดียวกับที่เปิดใช้รีลีสใหม่
Next.js มีการป้องกันเวอร์ชันไม่ตรงกันอย่างชัดเจน แต่ไม่ได้เก็บทรัพยากรเก่าให้
Next.js ปัจจุบันรองรับ deploymentId สำหรับป้องกันเวอร์ชันไม่ตรงกัน การตั้งค่าอาจเป็นแบบนี้:
// next.config.js
const nextConfig = {
deploymentId: process.env.DEPLOYMENT_VERSION,
}
module.exports = nextConfig
ตาม เอกสาร Next.js ปัจจุบันเกี่ยวกับ deploymentId เมื่อกำหนดค่านี้ URL ของทรัพยากรสแตติกที่เฟรมเวิร์กจัดการจะมีพารามิเตอร์ ?dpl=<deploymentId> คำขอนำทางฝั่งไคลเอนต์จะพกข้อมูลดีพลอย และเซิร์ฟเวอร์จะส่งรหัสดีพลอยของตัวเองในคำตอบ เมื่อ Next.js พบว่าไม่ตรงกันระหว่างการนำทาง มันสามารถเปลี่ยนไปทำการนำทางแบบเต็มหน้า แทนการนำทางภายในไคลเอนต์ด้วยข้อมูลที่เข้ากันไม่ได้
?dpl=<deploymentId>
x-deployment-id
x-nextjs-deployment-id
data-dpl-id
กลไกนี้มีประโยชน์ แต่ไม่ควรคาดหวังเกินกว่าสิ่งที่ทำได้จริง เอกสารระบุชัดว่า Next.js ไม่ได้อ่านพารามิเตอร์ ?dpl= ขาเข้าเพื่อส่งคำขอไปยังเวอร์ชันเฉพาะ พารามิเตอร์มีไว้หลีกเลี่ยงแคชเก่า หากต้นทางที่โฮสต์เองลบทรัพยากรเก่าออกจากดิสก์แล้ว พารามิเตอร์ในคำขอไม่สามารถสร้างไฟล์นั้นกลับมาได้
ดังนั้นผมมอง deploymentId เป็นกลไกตรวจจับความไม่ตรงกันและช่วยกู้คืน ไม่ใช่สิ่งทดแทนการดีพลอยที่เป็นระเบียบหรือการเก็บทรัพยากรเก่า
บนแพลตฟอร์มที่มีการกำหนดเส้นทางตามเวอร์ชัน โครงสร้างพื้นฐานอาจทำได้มากกว่า ตัวอย่างเช่น เอกสาร Vercel ปัจจุบันเกี่ยวกับการป้องกันเวอร์ชันไม่ตรงกัน อธิบายการตรึงเวอร์ชัน ซึ่งทำให้คำขอที่เฟรมเวิร์กจัดการยังคงไปยังดีพลอยที่เคยให้บริการไคลเอนต์นั้นตั้งแต่แรก นี่เป็นความสามารถของแพลตฟอร์ม ไม่ใช่สิ่งที่ผมจะสมมติว่ามีใน Nginx หรือ CDN ทุกแบบ
รหัสบิลด์และรหัสดีพลอยแก้ปัญหาที่เกี่ยวข้องกันแต่ไม่เหมือนกัน
Next.js ยังสร้างรหัสบิลด์ระหว่าง next build หากหลายคอนเทนเนอร์ควรให้บริการดีพลอยเดียวกัน ก็ไม่ควรกลายเป็นคนละบิลด์โดยไม่รู้ตัว เพียงเพราะแต่ละเซิร์ฟเวอร์รันขั้นตอนบิลด์ของตัวเอง
รหัสบิลด์ที่กำหนดแน่นอนได้สามารถผูกกับรหัสรีลีส เช่น คอมมิตของ Git:
// next.config.js
const nextConfig = {
generateBuildId: async () => process.env.GIT_SHA,
deploymentId: process.env.GIT_SHA,
}
module.exports = nextConfig
ตัวอย่างนี้มีไว้ประกอบคำอธิบาย ไม่ได้คัดลอกจากโค้ดโปรดักชันของผม กฎด้านสถาปัตยกรรมที่สำคัญคือรีลีสเชิงตรรกะหนึ่งชุดควรมีผลลัพธ์บิลด์ที่สอดคล้องกันหนึ่งชุด และมีตัวตนของดีพลอยเดียวกันในทุกอินสแตนซ์ที่ให้บริการมัน
generateBuildId ระบุตัวบิลด์ของ Next.js ส่วน deploymentId ถูกบันทึกไว้โดยเฉพาะสำหรับการป้องกันเวอร์ชันไม่ตรงกันและหลีกเลี่ยงแคชจากดีพลอยอื่น ทั้งสองเกี่ยวข้องกัน แต่การถือว่าชื่อมีความหมายเดียวกันจะทำให้แกะปัญหายากขึ้น
ผมจะเผยแพร่ทรัพยากรก่อนเปลี่ยนทราฟฟิกไปยังเอกสารใหม่
ลำดับดีพลอยที่ปลอดภัยกว่าถูกออกแบบให้ไม่สมมาตร ทรัพยากรใหม่ที่ไม่เปลี่ยนสามารถมีอยู่ก่อนที่ใครจะอ้างถึงได้ แต่ HTML ใหม่ไม่ควรอ้างถึงทรัพยากรที่ยังไม่พร้อม
ในเชิงแนวคิด ผมต้องการลำดับนี้:
1. บิลด์รีลีส B เพียงครั้งเดียว
2. อัปโหลดทรัพยากร /_next/static ของ B
3. ตรวจสอบว่าทรัพยากรที่ต้องใช้ดาวน์โหลดได้จริง
4. เริ่มหรือเตรียมเซิร์ฟเวอร์และรันไทม์ของ B
5. ตรวจสอบสถานะของ B
6. เปลี่ยนทราฟฟิกเอกสารใหม่ไปยัง B แบบอะตอม
7. คงทรัพยากรสแตติกของ A ให้เข้าถึงได้
8. เฝ้าระวัง B
9. ค่อยล้างทรัพยากรเก่าภายหลัง
ถ้าแอปเป็นการส่งออกแบบสแตติก หลักการเดียวกันยังใช้ได้: อัปโหลดทรัพยากรที่มีเวอร์ชันก่อน แล้วค่อยเผยแพร่ HTML ที่อ้างถึงมัน ถ้าเป็นการเรนเดอร์บนเซิร์ฟเวอร์หลังรีเวิร์สพร็อกซี ให้เตรียมเซิร์ฟเวอร์ใหม่และเปลี่ยนทราฟฟิกเมื่อยืนยันว่าเซิร์ฟเวอร์ทำงานปกติแล้วเท่านั้น
การย้อนกลับก็ควรสมมาตร การเก็บไดเรกทอรีของรีลีสก่อนหน้าและทรัพยากรสแตติกของมันทำให้ย้อนแอปกลับได้ โดยไม่ต้องพยายามสร้างไฟล์เก่าขึ้นมาใหม่หลังเกิดปัญหา
อย่างไรก็ตาม นี่ไม่ได้ทำให้ทุกการย้อนกลับปลอดภัย การย้ายฐานข้อมูลหรือสัญญาระหว่างระบบหลังบ้านที่เข้ากันไม่ได้อาจทำให้แอปเวอร์ชันเก่าใช้ไม่ได้ แม้ JavaScript ยังอยู่ การเก็บทรัพยากรสแตติกแก้ปัญหาความเข้ากันได้ของไฟล์สแตติก ไม่ใช่ทุกปัญหาความเข้ากันได้ระหว่างรีลีสของทั้งระบบ
ไดเรกทอรีทรัพยากรที่ไม่เปลี่ยนแบบใช้ร่วมกันเหมาะกับการโฮสต์เองแบบเรียบง่าย
สำหรับดีพลอย Nginx ขนาดเล็ก วิธีตรงไปตรงมาคือแยกรีลีสปัจจุบันของแอปออกจากที่เก็บทรัพยากรสแตติกแบบใช้ร่วมกัน
ตัวอย่างโครงสร้างอาจเป็น:
/srv/app/releases/2026-08-13-a/
/srv/app/releases/2026-08-13-b/
/srv/app/current -> /srv/app/releases/2026-08-13-b/
/srv/app/shared/_next/static/...
แต่ละดีพลอยเพิ่มไฟล์ /_next/static ใหม่ลงในไดเรกทอรีร่วม โดยไม่ลบไฟล์ของรีลีสก่อนหน้าที่ยังอยู่ในช่วงเก็บ Nginx สามารถให้บริการเส้นทางนี้ด้วยนโยบายไม่เปลี่ยน:
location ^~ /_next/static/ {
root /srv/app/shared;
add_header Cache-Control "public, max-age=31536000, immutable";
}
การตั้งค่านี้เป็นเพียงตัวอย่าง ไม่ได้อ้างว่าเป็นการตั้งค่า Nginx จริงที่ผมใช้ ระบบจริงต้องคำนึงถึงสิทธิ์ ประเภท MIME รูปแบบการบีบอัด พฤติกรรม CDN และโครงสร้างผลลัพธ์ของบิลด์อย่างละเอียด
ประเด็นสำคัญคือสถาปัตยกรรม: ตัวชี้ที่เปลี่ยนได้ไปยังรีลีสปัจจุบัน กับที่เก็บทรัพยากรแบบมีเวอร์ชันซึ่งส่วนใหญ่เพิ่มอย่างเดียว มีวงจรชีวิตต่างกัน
HTML ต้องใช้นโยบายแคชต่างจากชังก์ที่มีแฮช
วิธีสร้างปัญหานี้ขึ้นมาใหม่ได้ง่ายที่สุดคือแคช HTML ราวกับเป็นทรัพยากรที่มีแฮชของเนื้อหา
สำหรับหน้า Next.js ที่เรนเดอร์แบบไดนามิก เฟรมเวิร์กจะใช้ความหมายของคำตอบที่ไม่ให้แคชสำหรับเนื้อหาไดนามิกเฉพาะผู้ใช้โดยทั่วไป หน้าสแตติกและ ISR ใช้นโยบายต่างออกไป และ CDN อาจแคชได้อย่างถูกต้อง ส่วนการส่งออกแบบสแตติกที่ Nginx ให้บริการยิ่งขึ้นอยู่กับเฮดเดอร์ที่ผู้ดูแลกำหนด
ดังนั้นผมไม่ใช้กฎแคชข้อเดียวกับ “ทั้งเว็บไซต์” แต่แบ่งตามชนิดของวัตถุ:
ทรัพยากรที่มีแฮชใน /_next/static
max-age ยาว
immutable
เก็บไว้ได้อย่างปลอดภัย
HTML / เอกสารเส้นทาง
ต้องสามารถย้ายไปรีลีสใหม่ได้
นโยบายขึ้นอยู่กับรูปแบบการเรนเดอร์
ต้องไม่อยู่ได้นานกว่าทรัพยากรที่มันอ้างถึง
RSC / ข้อมูลนำทาง / ข้อมูล API
มีกฎความเข้ากันได้และความสดใหม่แยกต่างหาก
หากมี CDN อาจจำเป็นต้องล้างเส้นทางของเอกสารใหม่หลังดีพลอย ทั้งนี้ขึ้นกับการออกแบบแคช แต่การล้างชังก์เก่าที่มีแฮชออกจาก CDN เพียงเพราะมีรีลีสใหม่ มักให้ผลตรงข้าม ถ้าต้นทางลบด้วย คุณกำลังลบสำเนาสุดท้ายที่อาจช่วยไคลเอนต์เก่าได้
คู่มือแคช CDN ของ Next.js มีประโยชน์ตรงที่แยกการแคชหน้าออกจากนโยบาย immutable หนึ่งปีของทรัพยากร /_next/static
การโหลดใหม่อัตโนมัติคือเครื่องมือกู้คืน ไม่ใช่กลยุทธ์ดีพลอยหลัก
คำตอบที่พบบ่อยเมื่อชังก์ล้มเหลวคือ “โหลดหน้าใหม่ก็พอ” มันมักได้ผลเพราะการนำทางเต็มหน้าดึงเอกสารปัจจุบัน ซึ่งอ้างถึงบิลด์ปัจจุบัน
แต่การโหลดใหม่แบบไม่เลือกทุกครั้งที่สคริปต์ผิดพลาดสร้างปัญหาใหม่:
- ความล้มเหลวของสคริปต์บุคคลที่สามอาจทำให้โหลดใหม่โดยไม่จำเป็น;
- เซิร์ฟเวอร์ล่มจริงอาจทำให้เกิดวงจรโหลดใหม่ไม่สิ้นสุด;
- แบบฟอร์มที่ยังไม่บันทึกอาจสูญเสียข้อมูลผู้ใช้;
- สถานะคอมโพเนนต์ React หายเมื่อมีการนำทางเต็มหน้า;
- ดีพลอยที่เสียชุดเดิมอาจล้มเหลวซ้ำอีก
เอกสาร Next.js ปัจจุบันเองก็เตือนว่า การนำทางเต็มหน้าที่ใช้กู้คืนจากเวอร์ชันไม่ตรงกันอาจทำให้สถานะคอมโพเนนต์ เช่น useState หายไป ขณะที่สถานะใน URL หรือที่เก็บถาวรของเบราว์เซอร์ยังอาจอยู่
ถ้าผมเพิ่มการกู้คืนฝั่งไคลเอนต์ ผมต้องการให้ขอบเขตแคบและลองเพียงครั้งเดียว ตัวอย่างการทำงานอาจเป็นแบบนี้:
const RECOVERY_KEY = 'next-chunk-recovery-attempted'
function isOwnNextAsset(url: string) {
try {
const parsed = new URL(url, window.location.href)
return (
parsed.origin === window.location.origin &&
parsed.pathname.startsWith('/_next/static/')
)
} catch {
return false
}
}
window.addEventListener(
'error',
(event) => {
const target = event.target
if (!(target instanceof HTMLScriptElement)) return
if (!isOwnNextAsset(target.src)) return
reportChunkFailure({
page: window.location.href,
asset: target.src,
})
if (sessionStorage.getItem(RECOVERY_KEY)) return
sessionStorage.setItem(RECOVERY_KEY, '1')
window.location.reload()
},
true,
)
นี่เป็นเพียงตัวอย่างโดยตั้งใจ ระบบโปรดักชันควรพิจารณาชังก์ของสไตล์ชีต รูปแบบข้อผิดพลาดของเฟรมเวิร์กที่รู้จัก ขั้นตอนใช้งานที่การโหลดใหม่ทำลายงานของผู้ใช้ และวิธีล้างเครื่องหมายกู้คืนหลังโหลดสำเร็จ
สำหรับตัวแก้ไข ขั้นตอนชำระเงิน หรือแบบฟอร์มยาว ผมอาจเลือกแสดงข้อความ “มีเวอร์ชันใหม่ โปรดบันทึกงานแล้วโหลดหน้าใหม่” แทนการบังคับรีเฟรช
ข้อมูลเฝ้าระวังควรบอกได้ว่านี่คือเวอร์ชันไม่ตรงกันจริงหรือไม่
ข้อความว่า “โหลดสคริปต์ไม่สำเร็จ” อย่างเดียวไม่พอ ถ้าต้องแยกชังก์เก่าที่ถูกลบออกจากปัญหาเครือข่ายแบบสุ่ม ผมต้องมีบริบทของดีพลอย
ช่องข้อมูลที่มีประโยชน์ได้แก่:
- URL ของทรัพยากรที่โหลดไม่สำเร็จ;
- URL ของหน้าปัจจุบัน;
- ทรัพยากรนั้นเป็นของแอปเองหรือไม่;
- รหัสรีลีสหรือรหัสดีพลอยที่ไคลเอนต์มองเห็น;
- เบราว์เซอร์และระบบปฏิบัติการ;
navigator.onLineใช้เป็นสัญญาณอ่อน ไม่ใช่หลักฐานว่าการเชื่อมต่อใช้ได้;- เวลาที่ผ่านไปตั้งแต่โหลดหน้า;
- ข้อผิดพลาดเกิดหลังดีพลอยไม่นานหรือไม่;
- นี่เป็นความพยายามกู้คืนครั้งแรกหรือไม่;
- สถานะ HTTP เมื่อสังเกตจากฝั่งเซิร์ฟเวอร์ได้;
- รีลีสที่กำลังให้บริการคำขอจริงบนต้นทางหรือพร็อกซี
เมื่อมีข้อมูลเหล่านี้ รูปแบบต่าง ๆ จะให้ความหมายมากขึ้น
หากผู้ใช้จำนวนมากบนคนละเครือข่ายร้องขอ URL ชังก์เก่าที่มีแฮช แล้วต้นทางตอบ 404 ทันทีหลังรีลีส การไม่เก็บทรัพยากรเก่าจะเป็นคำอธิบายที่หนักแน่น หากมีผู้ใช้เพียงคนเดียวเจอความล้มเหลวระดับเครือข่ายโดยไม่มีคำตอบ HTTP ความเป็นไปได้เรื่องเวอร์ชันไม่ตรงกันจะต่ำลงมาก ถ้าชังก์ตอบ 200 แต่เป็น MIME ผิดชนิดหรือเป็นหน้า HTML ข้อผิดพลาด ปัญหาอยู่ที่การกำหนดเส้นทางหรือการตั้งค่าพร็อกซี ไม่ใช่แค่การเก็บไฟล์
ผมจะแยกการแจ้งเตือนสำหรับชังก์ของแอปเองออกจากความล้มเหลวของทรัพยากรบุคคลที่สามด้วย นี่คือการเปลี่ยนระบบเฝ้าระวังที่ได้ข้อสรุปโดยตรงที่สุดจากล็อกเดิม: สัญญาณสำคัญถูกปะปนกับสัญญาณรบกวนของเบราว์เซอร์จำนวนมากที่ไม่เกี่ยวกับแอปเสีย
การทดสอบทำซ้ำไม่ซับซ้อน แต่ต้องรักษาแท็บเก่าไว้
บั๊กประเภทนี้หลุดจากการทดสอบรีลีสตามปกติได้ง่าย เพราะวิศวกรมักรีเฟรชทันทีหลังดีพลอย การรีเฟรชทำลายเงื่อนไขที่เราต้องการทดสอบพอดี
การทดสอบด้วยมือที่ดีกว่าคือ:
- ดีพลอยรีลีส A;
- เปิดแท็บในสภาพใกล้โปรดักชันโดยเปิดแคชเบราว์เซอร์ไว้;
- ใช้งานแอปเพียงบางส่วน เพื่อให้บางเส้นทางหรือฟังก์ชันที่โหลดเมื่อจำเป็นยังไม่ถูกโหลด;
- ปล่อยแท็บนั้นเปิดค้าง;
- ดีพลอยรีลีส B;
- อย่า รีเฟรชแท็บเก่า;
- เรียกเส้นทางหรือฟังก์ชันไดนามิกที่ต้องใช้โค้ดซึ่งยังไม่เคยโหลด;
- ตรวจสอบ Network และ Console;
- ยืนยันว่า URL ทรัพยากรเก่ายังตอบ 200;
- ยืนยันว่าการตรวจพบเวอร์ชันไม่ตรงกันทำการนำทางเต็มหน้าแบบควบคุมเมื่อเหมาะสม
ผมจะทำการทดสอบเดิมซ้ำโดยมี CDN อยู่ด้านหน้า มีเซิร์ฟเวอร์สองอินสแตนซ์ระหว่างดีพลอยแบบทยอยเปลี่ยน และหลังช่วงการเก็บที่กำหนดหมดอายุ
ข้อผิดพลาดในการทดสอบที่มองข้ามได้คือเปิด “Disable cache” ใน DevTools กับทุกอย่าง วิธีนี้มีประโยชน์กับการวินิจฉัยบางแบบ แต่เปลี่ยนพฤติกรรมของเบราว์เซอร์ กรณีแท็บเปิดค้างนานควรถูกทดสอบด้วยแคชที่สมจริงด้วย เพราะแคชเบราว์เซอร์เป็นส่วนหนึ่งของระบบ
ไม่ใช่ทุกปัญหาชังก์จะแก้ได้ด้วยการเก็บไฟล์เก่า
การเก็บทรัพยากรเก่ามีพลังเพราะแก้กลไกที่เฉพาะเจาะจงเพียงหนึ่งแบบ ไม่ควรถูกเปลี่ยนเป็นคำอธิบายครอบจักรวาลอีกคำหนึ่ง
ชังก์ของแอปเองอาจล้มเหลวเพราะ:
- คำขอไปไม่ถึงเซิร์ฟเวอร์เลย;
- การเชื่อมต่อถูกตัด;
- ส่วนขยายของเบราว์เซอร์บล็อกคำขอ;
- โหนดขอบของ CDN มีปัญหาชั่วคราว;
- Nginx ส่งเส้นทางผิด;
- เซิร์ฟเวอร์ตอบเอกสารข้อผิดพลาด HTML แทน JavaScript;
- การบีบอัดหรือ
Content-Encodingเสียหาย; - สิทธิ์ของไฟล์ผิด;
- ดีพลอยไม่สมบูรณ์และไม่เคยอัปโหลดชังก์;
- ไฟล์เคยอยู่แต่ถูกลบเร็วเกินไป;
- ไคลเอนต์กับเซิร์ฟเวอร์อยู่บนรีลีสที่เข้ากันไม่ได้
รหัสตอบกลับและเวลาที่เกิดเหตุมีความสำคัญ 404 ซ้ำสำหรับ URL เก่าที่มีแฮชเนื้อหาหลังทุกรีลีส เล่าเรื่องต่างจาก ERR_CONNECTION_RESET ครั้งเดียวบนเครือข่ายมือถืออย่างสิ้นเชิง
เพราะเหตุนี้ผมจะไม่เขียนเหตุการณ์เดิมใหม่ว่า “ผมพิสูจน์แล้วว่า HTML เก่าทำให้เว็บเสีย” ผมไม่ได้พิสูจน์ถึงขั้นนั้น สิ่งที่เห็นคือชังก์ของแอปเองล้มเหลวจริง และผมระบุเวอร์ชันไม่ตรงกันเป็นหนึ่งในกลไกที่ร้ายแรงพอจะออกแบบระบบเพื่อป้องกัน
ดีพลอยที่ปลอดภัยที่สุดถือว่าไคลเอนต์เก่าเป็นส่วนหนึ่งของขอบเขตรีลีส
ความผิดพลาดที่ลึกกว่าคือคิดว่าดีพลอยแทนที่เวอร์ชัน A ด้วย B ในชั่วขณะเดียว
บนเซิร์ฟเวอร์ ลิงก์เชิงสัญลักษณ์หรือระบบจัดการคอนเทนเนอร์อาจทำให้ดูเหมือนเป็นเช่นนั้น แต่บนเครือข่าย วัตถุ CDN เก่าอาจยังอยู่ ในเบราว์เซอร์ เอกสารของ A อาจทำงานต่ออีกนานหลัง B เปิดใช้ ระหว่างรีลีสแบบทยอยเปลี่ยน เซิร์ฟเวอร์ทั้งสองเวอร์ชันอาจทำงานพร้อมกัน และเมื่อย้อนกลับ B อาจหายไปแล้ว A กลับมาเป็นเวอร์ชันปัจจุบัน
ดังนั้นขอบเขตรีลีสที่แท้จริงคือช่วงเวลา ไม่ใช่จุดเวลา
ตอนนี้กฎดีพลอยแอป Next.js ของผมตั้งอยู่บนแนวคิดนี้:
- บิลด์หนึ่งครั้งต่อรีลีสเชิงตรรกะหนึ่งชุด อย่าให้แต่ละสำเนาสร้างผลลัพธ์คนละชุดโดยไม่รู้ตัว
- เผยแพร่ทรัพยากรที่ไม่เปลี่ยนก่อนเผยแพร่การอ้างอิงถึงมัน
- เก็บทรัพยากรเก่าที่มีแฮชตามช่วงความเข้ากันได้ที่กำหนดไว้อย่างตั้งใจ
- อย่าให้ HTML ที่เปลี่ยนได้ใช้นโยบายแคชเดียวกับชังก์ที่มีแฮช
- ใช้
deploymentIdเมื่อรูปแบบดีพลอยอาจเกิดเวอร์ชันไม่ตรงกัน - ใช้การป้องกันระดับแพลตฟอร์มเมื่อผู้ให้บริการมีการกำหนดเส้นทางตามเวอร์ชันจริงเท่านั้น
- ให้การกู้คืนลองเพียงครั้งเดียวและคำนึงถึงสถานะของผู้ใช้
- เฝ้าระวังชังก์ของแอปเองที่ล้มเหลวเป็นสัญญาณโปรดักชันแยกต่างหาก
- ทดสอบดีพลอยโดยปล่อยแท็บเก่าไว้เปิดอยู่
- ล้างทรัพยากรเก่าภายหลัง ไม่ใช่ระหว่างเปิดใช้รีลีสใหม่
หลักที่ผมใช้ตอนนี้
บิลด์ผ่านและหน้าที่เพิ่งเปิดทำงานปกติ ไม่ได้พิสูจน์ว่าดีพลอยปลอดภัยสำหรับผู้ใช้ที่อยู่ในแอปมาก่อนแล้ว
แท็บเก่าไม่ใช่เศษเหลือที่ไม่มีความหมาย มันคือไคลเอนต์จริงที่กำลังรันรีลีสก่อนหน้าจริง
เมื่อผมเริ่มมองการดีพลอยแบบนี้ ปัญหาชังก์ก็ลึกลับน้อยลง แฮชเนื้อหาให้ตัวตนที่เสถียรแก่ทรัพยากร การแคชระยะยาวทำให้ตัวตนนั้นมีประสิทธิภาพ แต่ระบบดีพลอยต้องเคารพตัวตนนั้นนานพอ หรือให้วิธีที่ควบคุมได้แก่ไคลเอนต์ในการย้ายไปข้างหน้า
ผมไม่จำเป็นต้องให้ทุกรีลีสเก่ามีชีวิตอยู่ตลอดไป สิ่งที่ต้องการคือให้ระบบผ่านช่วงเวลาที่ไคลเอนต์เก่ากับเซิร์ฟเวอร์ใหม่อยู่ร่วมกันอย่างถูกต้องได้
นี่คือข้อตกลงการดีพลอยที่ผมให้ความสำคัญ: ผู้ใช้ใหม่ได้รับรีลีสใหม่ ผู้ใช้เดิมไม่เสียไฟล์ที่เวอร์ชันปัจจุบันของตนยังรู้วิธีร้องขอ และความไม่ตรงกันที่เหลืออยู่จะไปสู่เส้นทางกู้คืนที่ออกแบบไว้ แทนที่จะจบลงที่หน้าที่พัง