กลับไปที่บล็อก
13 สิงหาคม 2569Sergei Solod49 นาทีในการอ่าน

ทำไมแท็บ Next.js เก่าจึงพังหลังดีพลอย: HTML ที่ค้างอยู่ ชังก์ที่หายไป และเวอร์ชันไม่ตรงกัน

หลังการดีพลอยครั้งหนึ่ง ระบบเฝ้าระวังบนโปรดักชันของผมบันทึกว่าชังก์ของแอป Next.js เองโหลดไม่สำเร็จ ล็อกยืนยันได้ว่าเกิดความล้มเหลว แต่ไม่ได้ยืนยันสาเหตุ บทความนี้ใช้เหตุการณ์นั้นอธิบายแท็บที่เปิดค้าง HTML ที่ล้าสมัย ทรัพยากร /_next/static ที่หายไป เวอร์ชันไม่ตรงกัน การเก็บไฟล์เก่า deploymentId ลำดับการเผยแพร่ การเฝ้าระวัง และการกู้คืนแบบควบคุม

Next.jsการดีพลอยเวอร์ชันไม่ตรงกันแคชเว็บความน่าเชื่อถือส่วนหน้าทรัพยากรสแตติก

ข้อผิดพลาดบนโปรดักชันที่มีประโยชน์ที่สุดอย่างหนึ่งที่ผมเจอหลังดีพลอย ดูเผิน ๆ แทบไม่มีอะไรน่าสนใจเลย:

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 เก่าจะยังคงอยู่
    + อายุใช้งานจริงของแท็บที่เปิดค้างนาน
    + ช่วงเวลาสำหรับย้อนกลับรีลีส
    + ระยะเผื่อการกระจายของดีพลอย

นี่ไม่ใช่การรับประกันทางคณิตศาสตร์ แท็บเบราว์เซอร์อาจเปิดค้างเป็นสัปดาห์ ไม่มีจำนวนชั่วโมงจำกัดใดที่ทำให้ข้อผิดพลาดของไคลเอนต์เก่าเป็นไปไม่ได้

เพราะเหตุนี้ผมจึงชอบการป้องกันหลายชั้น:

  1. เก็บทรัพยากรที่ไม่เปลี่ยนของรีลีสก่อนหน้าไว้นานพอให้เซสชันเก่าทั่วไปทำงานต่อได้;
  2. ตรวจจับเวอร์ชันไม่ตรงกัน เพื่อให้ไคลเอนต์ย้ายไปรีลีสปัจจุบันได้;
  3. ให้การโหลดใหม่ที่ปลอดภัยเพียงครั้งเดียว หรือเส้นทางกู้คืนที่ผู้ใช้มองเห็นได้ เมื่อทรัพยากรหายจริง;
  4. เฝ้าระวังชังก์ของแอปเองที่หายไป เพื่อปรับช่วงการเก็บจากข้อมูลจริง

ชั้นการเก็บช่วยป้องกันความล้มเหลวส่วนใหญ่ ส่วนชั้นกู้คืนจัดการกรณีปลายหางที่ช่วงการเก็บแบบจำกัดไม่สามารถกำจัดได้ทั้งหมด

อย่าลบชังก์เก่าโดยดูแค่อายุไฟล์

กฎง่าย ๆ อย่าง “ลบไฟล์ทุกไฟล์ที่เก่ากว่าเจ็ดวัน” ก็อาจผิด รีลีสปัจจุบันอาจนำไฟล์เก่าที่มีแฮชกลับมาใช้ เพราะเนื้อหาไม่ได้เปลี่ยน จึงมีเวลาปรับปรุงบนดิสก์เก่า

รูปแบบเก็บกวาดไฟล์ที่แข็งแรงกว่าควรรู้จักรีลีส:

  1. เก็บแมนิเฟสต์หรือรายการทรัพยากรของทุกรีลีสที่ยังอยู่ในช่วงความเข้ากันได้;
  2. สร้างเซตสหภาพของเส้นทางทรัพยากรทั้งหมดที่รีลีสเหล่านั้นอ้างถึง;
  3. อย่าลบสิ่งใดที่อยู่ในเซตที่ป้องกันนี้;
  4. ลบทรัพยากรที่ไม่มีใครอ้างถึงหลังพ้นช่วงผ่อนผันเพิ่มเติมเท่านั้น

ถ้ากลไกนี้มากเกินไปสำหรับดีพลอยขนาดเล็ก การตั้งใจให้ไดเรกทอรีทรัพยากรสแตติกมีพื้นที่เผื่อ มักถูกกว่าการตามหาความผิดพลาดหายากบนไคลเอนต์ ไฟล์ที่มีแฮชเหมาะกับแนวทางนี้มาก เพราะเนื้อหาเดียวกันสามารถใช้ 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 ข้อผิดพลาด ปัญหาอยู่ที่การกำหนดเส้นทางหรือการตั้งค่าพร็อกซี ไม่ใช่แค่การเก็บไฟล์

ผมจะแยกการแจ้งเตือนสำหรับชังก์ของแอปเองออกจากความล้มเหลวของทรัพยากรบุคคลที่สามด้วย นี่คือการเปลี่ยนระบบเฝ้าระวังที่ได้ข้อสรุปโดยตรงที่สุดจากล็อกเดิม: สัญญาณสำคัญถูกปะปนกับสัญญาณรบกวนของเบราว์เซอร์จำนวนมากที่ไม่เกี่ยวกับแอปเสีย

การทดสอบทำซ้ำไม่ซับซ้อน แต่ต้องรักษาแท็บเก่าไว้

บั๊กประเภทนี้หลุดจากการทดสอบรีลีสตามปกติได้ง่าย เพราะวิศวกรมักรีเฟรชทันทีหลังดีพลอย การรีเฟรชทำลายเงื่อนไขที่เราต้องการทดสอบพอดี

การทดสอบด้วยมือที่ดีกว่าคือ:

  1. ดีพลอยรีลีส A;
  2. เปิดแท็บในสภาพใกล้โปรดักชันโดยเปิดแคชเบราว์เซอร์ไว้;
  3. ใช้งานแอปเพียงบางส่วน เพื่อให้บางเส้นทางหรือฟังก์ชันที่โหลดเมื่อจำเป็นยังไม่ถูกโหลด;
  4. ปล่อยแท็บนั้นเปิดค้าง;
  5. ดีพลอยรีลีส B;
  6. อย่า รีเฟรชแท็บเก่า;
  7. เรียกเส้นทางหรือฟังก์ชันไดนามิกที่ต้องใช้โค้ดซึ่งยังไม่เคยโหลด;
  8. ตรวจสอบ Network และ Console;
  9. ยืนยันว่า URL ทรัพยากรเก่ายังตอบ 200;
  10. ยืนยันว่าการตรวจพบเวอร์ชันไม่ตรงกันทำการนำทางเต็มหน้าแบบควบคุมเมื่อเหมาะสม

ผมจะทำการทดสอบเดิมซ้ำโดยมี 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 เมื่อรูปแบบดีพลอยอาจเกิดเวอร์ชันไม่ตรงกัน
  • ใช้การป้องกันระดับแพลตฟอร์มเมื่อผู้ให้บริการมีการกำหนดเส้นทางตามเวอร์ชันจริงเท่านั้น
  • ให้การกู้คืนลองเพียงครั้งเดียวและคำนึงถึงสถานะของผู้ใช้
  • เฝ้าระวังชังก์ของแอปเองที่ล้มเหลวเป็นสัญญาณโปรดักชันแยกต่างหาก
  • ทดสอบดีพลอยโดยปล่อยแท็บเก่าไว้เปิดอยู่
  • ล้างทรัพยากรเก่าภายหลัง ไม่ใช่ระหว่างเปิดใช้รีลีสใหม่

หลักที่ผมใช้ตอนนี้

บิลด์ผ่านและหน้าที่เพิ่งเปิดทำงานปกติ ไม่ได้พิสูจน์ว่าดีพลอยปลอดภัยสำหรับผู้ใช้ที่อยู่ในแอปมาก่อนแล้ว

แท็บเก่าไม่ใช่เศษเหลือที่ไม่มีความหมาย มันคือไคลเอนต์จริงที่กำลังรันรีลีสก่อนหน้าจริง

เมื่อผมเริ่มมองการดีพลอยแบบนี้ ปัญหาชังก์ก็ลึกลับน้อยลง แฮชเนื้อหาให้ตัวตนที่เสถียรแก่ทรัพยากร การแคชระยะยาวทำให้ตัวตนนั้นมีประสิทธิภาพ แต่ระบบดีพลอยต้องเคารพตัวตนนั้นนานพอ หรือให้วิธีที่ควบคุมได้แก่ไคลเอนต์ในการย้ายไปข้างหน้า

ผมไม่จำเป็นต้องให้ทุกรีลีสเก่ามีชีวิตอยู่ตลอดไป สิ่งที่ต้องการคือให้ระบบผ่านช่วงเวลาที่ไคลเอนต์เก่ากับเซิร์ฟเวอร์ใหม่อยู่ร่วมกันอย่างถูกต้องได้

นี่คือข้อตกลงการดีพลอยที่ผมให้ความสำคัญ: ผู้ใช้ใหม่ได้รับรีลีสใหม่ ผู้ใช้เดิมไม่เสียไฟล์ที่เวอร์ชันปัจจุบันของตนยังรู้วิธีร้องขอ และความไม่ตรงกันที่เหลืออยู่จะไปสู่เส้นทางกู้คืนที่ออกแบบไว้ แทนที่จะจบลงที่หน้าที่พัง