ระบาดวิทยาของ Core Dump: การแก้ไขบั๊กที่ค้างคามานาน 18 ปี
ใช้การวิเคราะห์ข้อมูลในระดับภาพรวมของระบบเพื่อแกะรอยสาเหตุปัญหาที่ซับซ้อนในโครงสร้างพื้นฐานด้านข้อมูล
โมเดลและเอเจนต์จาก OpenAI ต้องพึ่งพาโครงสร้างพื้นฐานข้อมูลที่รองรับการขยายตัวได้มากขึ้น เพื่อใช้ในการค้นหาข้อมูลที่จำเป็นระหว่างช่วงเวลาการอนุมาน หรือก็คือช่วงที่โมเดลกำลังคิดหาคำตอบให้กับคำถามของคุณ บริการบางส่วนถูกเขียนขึ้นด้วยภาษา C++ ซึ่งการควบคุมระบบในระดับต่ำช่วยให้เราเพิ่มประสิทธิภาพการทำงานสูงสุดและลดการใช้หน่วยความจำลงได้ ข้อได้เปรียบด้านประสิทธิภาพเหล่านี้มีความสำคัญต่อการขยายระบบของเรา แต่การที่ภาษา C++ ขาดความปลอดภัยด้านหน่วยความจำ ทำให้บั๊กอาจเขียนข้อมูลไปยังตำแหน่งหน่วยความจำที่ไม่ถูกต้องหรือไม่มีอยู่จริง จนทำให้ระบบล่มได้
เมื่อไม่กี่เดือนก่อนเราพบปัญหาระบบล่มภายในบริการ Rockset ซึ่งเป็นส่วนประกอบเฉพาะของโครงสร้างพื้นฐานข้อมูล ChatGPT ของเรา โดยบริการนี้มีความสำคัญต่อปลั๊กอินข้อมูลจำนวนมากและการค้นหาข้อมูลในบทสนทนา ทุกครั้งที่เกิดการล่ม ฟังก์ชัน C++ ปกติดูเหมือนจะทำงานจนเสร็จ แล้วคืนการทำงานไปยังแอดเดรสที่ไม่ถูกต้อง ทำให้เคอร์เนลหยุดการทำงานของโปรแกรมเนื่องจากตัวชี้คำสั่งไม่ได้ชี้ไปที่โค้ดอีกต่อไป ในบางครั้งช่องเก็บที่อยู่สำหรับส่งค่ากลับในสแต็กเฟรมกลับมีค่าเป็น NULL บางครั้ง Stack Pointer ที่เป็นรีจิสเตอร์ของ CPU ดูเหมือนจะคลาดเคลื่อนไป 8 ไบต์ ราวกับว่า %rsp ถูกลดค่าลงระหว่างที่โปรแกรมกำลังทำงานตามปกติ ในทั้งสองกรณี ระบบล่มขณะฟังก์ชันกำลังคืนการทำงาน
นี่ไม่ใช่ลักษณะความผิดปกติที่มักเกิดจากโค้ดของแอปพลิเคชัน การเขียนข้อมูลผิดพลาดไปยังตำแหน่งที่อยู่สำหรับส่งค่ากลับที่บันทึกไว้เป็นเรื่องที่เป็นไปได้ แต่มีโอกาสเกิดขึ้นน้อยมาก บั๊กที่ทำให้ %rsp คลาดเคลื่อนไป 8 ไบต์ โดยไม่เกี่ยวข้องกับ Inline Assembly, setcontext หรือ longjmp (ซึ่งเราไม่ได้ใช้เลย) ถือว่าแปลกมาก เพราะโค้ดที่ผ่านการคอมไพล์จะปรับรีจิสเตอร์นี้โดยตรงเฉพาะในส่วน prologue และ epilogue ของฟังก์ชัน สมมติฐานทุกข้อที่เรา (หรือ ChatGPT) คิดออก ล้วนมีหลักฐานหนักแน่นที่หักล้างได้ ทำให้บั๊กตัวนี้ดูเหมือนว่าเป็นไปไม่ได้ที่จะเกิดขึ้นจริง
สิ่งที่เราเชื่อมาตลอดว่าเป็นปัญหาเดียว สุดท้ายกลับกลายเป็นบั๊กสองตัวที่ไม่เกี่ยวข้องกัน ซึ่งบังเอิญถูกค้นพบในเวลาเดียวกัน ประการแรก คือความเสียหายของฮาร์ดแวร์ที่เกิดขึ้นโดยไม่มีสัญญาณเตือนบนโฮสต์ Azure เครื่องหนึ่ง ซึ่งทำให้ CPU คำนวณค่าผิดพลาด ประการที่สอง คือปัญหา Race Condition ที่ค้างมานาน 18 ปีใน GNU libunwind ซึ่งเป็นบั๊กที่ไม่มีใครสังเกตเห็นในไลบรารีโอเพนซอร์สที่ใช้งานกันอย่างแพร่หลาย
บทความนี้บอกเล่ากระบวนการที่เราค้นหาต้นตอและแก้ไขปัญหาระบบล่มที่ดูเหมือนอธิบายไม่ได้ ด้วยการคิดแบบนักระบาดวิทยาและสร้างชุดข้อมูลคุณภาพสูงจากเหตุการณ์ระบบล่มทั้งระบบ
ก่อนอื่นเราจะมาเจาะลึกเกี่ยวกับ Rockset กัน Rockset เป็นระบบข้อมูลแบบคลาวด์เนทีฟสำหรับการค้นหาและการวิเคราะห์ข้อมูลแบบเรียลไทม์ ซึ่ง OpenAI นำมาใช้กับงานภายในหลายด้าน เช่น Sync Connector (OpenAI เข้าซื้อกิจการ Rockset ในปี 2567) เราใช้การอัปเดตแบบสตรีมมิงเพื่อรักษาดัชนีของฐานความรู้ในพื้นที่ทำงานให้เป็นปัจจุบันอยู่เสมอ เพื่อให้ ChatGPT สามารถค้นหาข้อมูลที่เกี่ยวข้องเมื่อต้องตอบคำถามหรือดำเนินการต่างๆ
เลเยอร์การดำเนินการของ Rockset เขียนด้วย C++ ภาษา C++ ให้สิทธิ์เข้าถึง CPU ในระดับต่ำ ซึ่งเป็นผลดีต่อประสิทธิภาพและการประมวลผล แต่ก็ทำให้บั๊กของแอปพลิเคชันอาจนำไปสู่การเข้าถึงหน่วยความจำที่ไม่ถูกต้องและเกิด Segfault ได้ เพื่อช่วยติดตามปัญหาเหล่านี้ เราใช้ตัวจัดการสัญญาณแจ้งเตือนร้ายแรงของ folly เพื่อบันทึก stack trace เมื่อระบบล่ม และอัปโหลด Core Dump ที่เกี่ยวข้อง (ภาพรวมสถานะของโปรแกรมในขณะที่ระบบล่ม) ไปยัง Azure Blob Storage เพื่อวิเคราะห์ในภายหลัง โหนดประมวลผลคำสั่งค้นหา (query processing leaves) ทุกตัวของ Rockset มีการทำสำเนาไว้ ทำให้ผลกระทบต่อผู้ใช้เมื่อเกิดการล่มมีน้อยที่สุด อย่างไรก็ตาม segfault ทุกครั้งล้วนเกิดจากบั๊กที่ต้องได้รับการแก้ไข เพื่อให้เป็นไปตามเป้าหมายด้านความน่าเชื่อถือและคุณภาพของเรา
วิธีการแรกของเราคือการจัดการกับไฟล์เหล่านี้เหมือนกับปัญหาการดีบั๊กทั่วไป โดยตรวจสอบไฟล์ Core Dump สองสามชุดอย่างละเอียด ตั้งสมมติฐาน และค่อยๆ ตัดสมมติฐานเหล่านั้นออกไปทีละข้อ
การล่มส่วนใหญ่เกิดขึ้นภายใน DocumentTree::updateDocument จากเหตุการณ์ระบบล่มเหล่านี้ ดูเหมือนว่า updateDocument จะเรียกใช้ฟังก์ชันลึกลับตัวหนึ่งที่เราเรียกว่า X จากนั้นสแต็กก็เกิดความเสียหายในระหว่างที่ X กำลังทำงาน ก่อนที่ X จะคืนการทำงานไปยังแอดเดรสที่ไม่ใช่โค้ดที่สามารถรันได้ ในบางกรณีเฟรมที่เพิ่งถูกดึงออกจากสแต็กของ X ดูเหมือนจะถูกต้อง ยกเว้นเพียงค่าแอดเดรสส่งกลับที่บันทึกไว้เป็น NULL ในกรณีอื่นๆ Stack Pointer เองดูเหมือนจะผิดปกติ แต่เฟรมที่ถูกต้องถัดไปยังคงดูเหมือนจะเป็น updateDocument
เราไม่รู้ว่าสแต็กเริ่มเสียหายตั้งแต่เมื่อไร ทำให้เราต้องไล่หาสาเหตุเพื่อหาต้นตอในวงกว้างมาก updateDocument เป็นเมธอดขนาดใหญ่ที่มีการทำ inlining จำนวนมาก ทำให้มีฟังก์ชันที่อาจเป็น X มากเกินกว่าจะไล่ตรวจสอบได้ทั้งหมด
ปัญหานี้เกิดจากบั๊กในโค้ด C++ ของเราหรือเปล่า หรือเป็นปัญหาจากคอมไพเลอร์หรือการลิงก์โค้ด หรือเป็นปัญหาที่เกิดจากไลบรารีรันไทม์ตัวใดตัวหนึ่งของเรา หรือเป็นบั๊กในคอร์เนล Linux ที่เกี่ยวข้องกับการส่งสัญญาณหรือการสลับบริบท หรือเป็นปัญหาที่พบได้ยากยิ่งกว่าเดิม ถ้านี่เป็นการเขียนหน่วยความจำผิดตำแหน่ง ทำไมสภาพแวดล้อมสเตจที่ใช้ ASAN ของเราถึงตรวจจับไม่ได้
เราใช้ล็อกของแอปพลิเคชันเพื่อพยายามค้นหาเหตุการณ์ทั้งหมดที่เกิดปัญหานี้ แต่การจำแนกบั๊กที่ทำให้สแต็กเสียหายจากล็อกเพียงอย่างเดียวนั้นทำได้ยาก เพราะแม้แต่ Stack Trace ที่บันทึกไว้ก็เสียหายหรือหายไป ไม่ว่าเราจะปรับคำสั่งค้นหาล็อกอย่างไร ก็ยังหลีกเลี่ยงผลลัพธ์ลวงหรือผลลัพธ์ที่ขาดหายไปไม่ได้เลย เราตรวจสอบ Core Dump เพิ่มเติมด้วยตนเองและพบตัวอย่างอื่น ๆ อีก แต่กระบวนการนี้ใช้แรงงานมากเกินไป จนไม่สามารถสร้างชุดข้อมูลที่เชื่อถือได้
ในขั้นนี้ของการสืบสวน เราได้ตัดประเด็นเรื่องบั๊กของฮาร์ดแวร์ออกไป (ซึ่งต่อมาพบว่าเป็นข้อสรุปที่ผิด) เนื่องจากพบการล่มในหลายภูมิภาคและบนฮาร์ดแวร์หลายประเภท ดังนั้นเราจึงยังคงมองหาสาเหตุที่เกิดจากซอฟต์แวร์เท่านั้น เราใช้เวลาหลายวันวิเคราะห์การล่มจาก %rsp ที่คลาดเคลื่อนเพียงกรณีเดียวอย่างเข้มข้น โดยอาศัยข้อมูลใน stack และรีจิสเตอร์เพื่อย้อนรอยสิ่งที่เกิดขึ้นก่อนระบบล่ม แม้จะได้เบาะแสบางอย่างจากการวิเคราะห์นี้ แต่เพราะเรายังยึดติดกับข้อสรุปเดิมที่ว่าบั๊กทั้งหมดมีสาเหตุเดียวกัน จึงทำให้เรายังคงติดหล่มอยู่กับปัญหาเดิม
ก่อนจะไปถึงจุดเปลี่ยนสำคัญของการสืบสวนครั้งนี้ ขออธิบายก่อนว่าเราดึงข้อมูลประเภทใดออกมาจากไฟล์ Core Dump
Rockset ถูกคอมไพล์ด้วย -fno-omit-frame-pointer ดังนั้นเฟรมสแต็กที่กำลังทำงานอยู่จึงเข้าถึงได้ผ่าน %rbp เสมอ และผู้เรียกจะสร้างลิงก์ลิสต์ของ Frame Pointer
บนระบบ Linux x86_64 ตัว AMD64 System V ABI มีการสำรองพื้นที่ 128 ไบต์ไว้ใต้ %rsp เพื่อใช้เป็น Red Zone พื้นที่ส่วนนี้เป็นพื้นที่ที่โค้ดใน userspace สามารถใช้งานได้ และภายใต้ข้อกำหนดของ ABI เคอร์เนลรับประกันว่าจะไม่แก้ไขหรือเขียนทับพื้นที่นี้เมื่อมีการส่งสัญญาณ
Red Zone มีบทบาทสำคัญอย่างยิ่งในการดีบักปัญหาการล่มที่เกิดขึ้นหลังการรีเทิร์น เพราะช่วยเก็บรักษาข้อมูลบางส่วนจากช่วงก่อนการรีเทิร์นเอาไว้ เมื่อเกิด SIGSEGV ขึ้น ตัวจัดการสัญญาณอันตรายของ Folly จะทำงานบนสแต็กของเธรดที่เกิดการล่ม เฟรมสแต็กที่ไม่ทำงานแล้ว (เพราะฟังก์ชันได้คืนค่าไปแล้ว) จะถูกตัวจัดการสัญญาณเขียนทับ ยกเว้นในพื้นที่ 128 ไบต์สุดท้าย นั่นคือเหตุผลที่เราพูดได้ว่า “เฟรมสแต็กของ X ที่เพิ่งถูกดึงออกมาดูเหมือนถูกต้อง ยกเว้นที่แอดเดรสคืนค่าที่เป็น NULL” Red Zone ช่วยเก็บข้อมูลของเฟรมสแต็กที่ไม่ได้ใช้งานแล้วไว้บางส่วน หรือในบางกรณีก็เหลือเพียงช่วงท้ายของเฟรมสแต็กเพียงตัวเดียว
เราพบเหตุการณ์ระบบล่มจากสแต็กที่คลาดเคลื่อนอยู่หนึ่งเคส ซึ่งฟังก์ชันทั้งหมดที่เกี่ยวข้องมีขนาดเล็กมาก สิ่งนี้ทำให้เราเห็นว่า %rsp คลาดเคลื่อนระหว่างการทำงานของฟังก์ชันที่ค่อนข้างเรียบง่าย และหลังจากนั้นยังมีการเรียกใช้ฟังก์ชันอื่นสำเร็จอีกหลายครั้ง โปรแกรมล่มก็ต่อเมื่อฟังก์ชันที่กำลังทำงานอยู่พยายามคืนค่ากลับในท้ายที่สุดเท่านั้น ไม่มีเส้นทางโค้ดใดเหล่านั้นที่ใช้ข้อยกเว้น รวมถึง Inline Assembly, setcontext หรือ longjmp ดังนั้นหาก Stack Pointer เปลี่ยนไปจริงตามลักษณะที่คอร์บ่งชี้ ก็ไม่มีบั๊กใดในโค้ดระดับผู้ใช้งานที่ฟังดูสมเหตุสมผลพอจะอธิบายปัญหานี้ได้
สิ่งนี้ทำให้เราหันไปมุ่งตรวจสอบฝั่งเคอร์เนลแทน
Rockset ใช้สัญญาณในเชิงรุกมากกว่าโปรแกรมส่วนใหญ่ การประมวลผลคำสั่งค้นหาถูกแบ่งออกเป็นงานขนาดเล็กที่ทำงานได้อย่างรวดเร็วหลายงาน เพื่อให้สามารถแลกเปลี่ยนข้อมูลระหว่างกันได้ แนวทางนี้มีความสำคัญต่อการรองรับเวิร์กโหลดที่มี QPS สูงได้อย่างมีประสิทธิภาพ แต่ก็ทำให้การติดตามการใช้ CPU ของแต่ละคำสั่งค้นหาเป็นเรื่องยาก เนื่องจากงานของหลายๆ คำสั่งถูกนำไปรวมใช้งานบนเธรดพูลเดียวกัน
วิธีที่เราใช้คือสิ่งที่เรียกว่า coarse_thread_cputime_clock ซึ่งประมาณค่าการทำงานของ clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) ได้โดยมีต้นทุนต่ำพอสำหรับการเก็บตัวอย่างทุกจุดเปลี่ยนของงาน API อย่าง timer_create สามารถนำมาใช้เพื่อกำหนดตารางเวลาในการส่งสัญญาณแบบเป็นระยะ โดยอ้างอิงจากเกณฑ์การนับเวลาหลายรูปแบบ รวมถึงการสะสมเวลาของ CPU เราตั้งให้มีการส่งสัญญาณ (SIGUSR2) ทุกๆ ไม่กี่มิลลิวินาทีของเวลา CPU และเมื่อได้รับสัญญาณแล้วตัวจัดการสัญญาณจะเข้าไปอัปเดตค่าที่เก็บไว้ใน thread-local แม้ว่างานจำนวนมากจะไม่เห็นว่า coarse clock ขยับระหว่างที่กำลังทำงาน แต่ผลรวมของค่าส่วนต่างทั้งหมดก็จะได้ค่าประมาณของเวลา CPU ที่แท้จริงของแต่ละคำสั่งค้นหาโดยไม่เกิดอคติ
เนื่องจากเรามีการส่งสัญญาณบ่อยมาก บั๊กที่พบน้อยครั้งในเคอร์เนลที่เกี่ยวข้องกับการสลับบริบท หรือการส่งสัญญาณจึงดูมีความเป็นไปได้ เราใช้เวลาศึกษารายงานบั๊ก ซอร์สโค้ดของเคอร์เนล และแพตช์ของเคอร์เนลที่ Azure ปรับแต่งเพิ่มเติม เราทดลองทดสอบระบบภายใต้ภาวะกดดันสูงไปแล้ว เรายังไม่พบอะไรที่ดูมีความเกี่ยวข้องกับปัญหานี้เลย
เมื่อถึงจุดนั้น เราจึงตัดสินใจถอยออกมาตั้งหลักและลองใช้แนวทางอื่น
มีแนวทางกว้างๆ สองทางในการดีบั๊กปัญหาลักษณะนี้
แนวทางแรกคือทำตัวเหมือนแพทย์ มุ่งความสนใจไปที่ผู้ป่วยเพียงรายเดียว ตรวจหลายอย่าง และพยายามวินิจฉัยจากหลักฐานเชิงลึกของกรณีนั้น
ส่วนอีกแนวทางคือทำตัวเหมือนนักระบาดวิทยา โดยมองภาพรวมของทั้งประชากร และตั้งคำถามว่ามีรูปแบบใดที่ไม่อาจมองเห็นได้จากการศึกษาผู้ป่วยเพียงรายเดียว บั๊กนี้เริ่มปรากฏในเวอร์ชันใดเวอร์ชันหนึ่งโดยเฉพาะหรือเปล่า ปัญหานี้เกี่ยวข้องกันกับฮาร์ดแวร์ SKU เฉพาะรุ่น (รุ่นของ CPU และเซิร์ฟเวอร์) พื้นที่การใช้งาน หรือเวอร์ชันของเคอร์เนลตัวใดตัวหนึ่งหรือเปล่า อาการที่ดูเหมือนจะเป็นกลุ่มอาการเดียว อาจซ่อนหลายกลุ่มที่มีลักษณะแตกต่างกันไว้หรือไม่
ที่ผ่านมาเราอยู่ในโหมดหมอเป็นหลัก จุดเปลี่ยนสำคัญคือการตัดสินใจว่าเราจำเป็นต้องรวบรวมข้อมูลประชากรที่มีคุณภาพสูง
ก่อนหน้านี้ความพยายามของเราในการค้นหาเหตุการณ์ทั้งหมดของปัญหานี้แบบอัตโนมัติไม่ประสบความสำเร็จ เพราะเราใช้การค้นหาข้อความในล็อก ตัว Core Dump เองมีข้อมูลมากกว่ามาก แต่การตรวจสอบด้วยตนเองไม่สามารถรองรับการวิเคราะห์ในวงกว้างได้ เราตัดสินใจทุ่มเทความพยายามในการสร้างไปป์ไลน์ที่สามารถวิเคราะห์ไฟล์ Core Dump ได้โดยอัตโนมัติ
เราให้ ChatGPT เขียนสคริปต์เพื่อดาวน์โหลดข้อมูลส่วนต้นของไฟล์ Core แต่ละไฟล์ จากนั้นทำการดึงข้อมูลรีจิสเตอร์ กรองสิ่งที่รู้ว่าเป็นผลบวกลวงโดยใช้ล็อก และติดป้ายกำกับเหตุขัดข้องนั้นโดยอัตโนมัติว่าเป็น return-to-null, misaligned-stack หรืออื่นๆ จากนั้นเราจึงรันสคริปต์ดังกล่าวแบบขนานกับไฟล์ Core Dump ของ Rockset ที่เกิดขึ้นในระบบโปรดักชันทั้งหมดตลอดหนึ่งปีที่ผ่านมา
นี่เป็นจุดเปลี่ยนสำคัญ
เมื่อเราได้ชุดข้อมูลที่ทำความสะอาดแล้ว ความสัมพันธ์ระหว่างตัวแปรก็ปรากฏชัดทันที สิ่งที่เราเคยมองว่าเป็นบั๊กแปลกๆ เพียงตัวเดียว จริงๆ แล้วเป็นกลุ่มการล่มของระบบที่แยกจากกันสองกลุ่ม
ไฟล์ Core Dump ประเภท return-to-null กระจายตัวอยู่ตามคลัสเตอร์และภูมิภาคต่างๆ เป็นจำนวนมาก ความถี่ในการเกิดเหตุการณ์เพิ่มสูงขึ้นเมื่อไม่นานมานี้ แต่ไม่มีวันเริ่มต้นที่ชัดเจนและไม่มีขอบเขตของโครงสร้างพื้นฐานที่แยกส่วนกันอย่างชัดเจน
เหตุขัดข้องแบบ misaligned-stack มีลักษณะที่แตกต่างออกไปโดยสิ้นเชิง ทั้งหมดมาจากภูมิภาคเดียวกัน มีวันที่เริ่มต้นที่ชัดเจน และไม่เคยเกิดขึ้นบนโหนดที่ทำงานมาเป็นเวลานาน แม้ว่าปัญหาจะเกี่ยวข้องกับ Azure VM หลายเครื่อง (เครื่องเสมือนที่โฮสต์อยู่บนคลาวด์) แต่รูปแบบที่พบดูเหมือนว่าเป็นเครื่องจริงเพียงเครื่องเดียวที่มีฮาร์ดแวร์เสีย ซึ่งก่อให้เกิดปัญหากับ VM ใดก็ตามที่บังเอิญถูกจัดสรรให้ไปทำงานบนเครื่องนั้น
นั่นคือช่วงเวลาที่เรารู้ว่า ตลอดมาเราเข้าใจผิดโดยคิดว่าบั๊กสองตัวเป็นปัญหาเดียวกัน เพราะเรานำตัวอย่างที่ขัดแย้งกับสมมติฐานจากบั๊กทั้งสองมาปะปนกัน เราจึงไม่สามารถหาคำอธิบายเพียงหนึ่งเดียวที่สอดคล้องกันได้
เมื่อได้รายการโหนด Kubernetes และการประทับเวลาที่ชัดเจน เราจึงสามารถสืบหาเหตุขัดข้องจากการที่สแต็กจัดเรียงไม่ตรงตำแหน่งกลับไปยังโฮสต์จริงเพียงเครื่องเดียว และนำโฮสต์นั้นออกจากการใช้งานได้อย่างง่ายดาย
เราไม่สามารถสร้างเหตุการณ์หน่วยความจำรีจิสเตอร์เสียหายซ้ำบนโฮสต์เครื่องนั้นในสภาพแวดล้อมที่มีการควบคุมได้เลย แม้ว่าจะผ่านการทดสอบภายใต้สภาวะกดดันมานานหลายสัปดาห์ก็ตาม อย่างไรก็ตามเมื่อเรานำโฮสต์ที่มีปัญหาออกจากระบบ เหตุขัดข้องจากสแต็กที่ไม่ตรงตำแหน่งก็หายไปทันที
การนำโฮสต์ที่มีปัญหาออกจากระบบไม่ใช่ทางแก้ถาวร เพราะไม่ได้ป้องกันไม่ให้ปัญหาเดียวกันเกิดขึ้นอีกกับโฮสต์เครื่องใหม่ อย่างไรก็ตามเราสามารถปรับเปลี่ยนซอฟต์แวร์เพื่อให้ตรวจพบและจัดการปัญหาได้โดยง่าย หากเกิดเหตุการณ์ในลักษณะเดียวกันนี้ซ้ำอีก เราปรับปรุงตัวจัดการสัญญาณร้ายแรงของเราให้รวมสถานะของรีจิสเตอร์ไว้ด้วย เพื่อให้เราตรวจพบการเกิดขึ้นซ้ำได้จากล็อกเพียงอย่างเดียว โดยไม่จำเป็นต้องใช้ Core Dump เราปรับปรุงส่วนควบคุมให้เน้นการนำ VM กลับมาใช้ซ้ำมากกว่าการหมุนเวียนใช้งาน ซึ่งช่วยให้การตรวจหาโหนดที่มีปัญหาในระดับโครงสร้างพื้นฐานของเราทำได้สะดวกขึ้นมาก เรายังได้อัปเดตคู่มือปฏิบัติการของเรา (รวมถึงโมเดลทางความคิดของทีมเรา) ให้ครอบคลุมความเป็นไปได้นี้ด้วย
เมื่อแยกปัญหาการขัดข้องที่เกิดจากโฮสต์ที่มีปัญหาออกไปแล้ว ก็ทำให้เข้าใจสาเหตุของ Core Dump แบบ return-to-null ที่เหลืออยู่ได้ง่ายขึ้นมาก ก่อนหน้านี้เราได้ตัดประเด็นเรื่องการคลายข้อยกเว้นออกไปแล้ว เพราะเราคิดว่ามีหลักฐานค้าน นั่นคือการขัดข้องในเส้นทางของโค้ดที่ไม่ได้ใช้ข้อยกเว้นอย่างแน่นอน แต่ตัวอย่างค้านเหล่านั้นทั้งหมดมาจากคลัสเตอร์ความเสียหายจากฮาร์ดแวร์
เมื่อเรากลับมาตรวจสอบไฟล์ Core Dump ที่เหลือด้วยมุมมองนั้น เราพบว่าข้อสรุปเดิมของเรานั้นผิดพลาดโดยสิ้นเชิง เพราะแท้จริงแล้วการขัดข้องทั้งหมดเกิดขึ้นระหว่างการคลายข้อยกเว้น
เมื่อ C++ โยนข้อยกเว้น รันไทม์จะต้องค้นหาว่าบล็อก catch ใดควรได้รับข้อยกเว้นนั้น และต้องเรียกใช้ตัวทำลายหรือตัวจัดการการล้างข้อมูลใดบ้างในระหว่างทาง คอมไพเลอร์เป็นผู้สร้างเมตาดาตานี้ขึ้นมา แต่การจับคู่จริงจะเกิดขึ้นแบบไดนามิกในระหว่างการทำงานของโปรแกรม
การคลายข้อยกเว้นไม่ได้ถูกดำเนินการโดยฟังก์ชันที่เรียกใช้ throw โดยตรง แต่ถูกจัดการโดยฟังก์ชันตัวช่วยที่ถูกเรียกจากโค้ดที่คอมไพล์แล้ว รูทีนรันไทม์เหล่านั้นจะตรวจสอบสแต็ก ดึงข้อมูลเมตาดาตาเกี่ยวกับฟังก์ชันที่พบในสแต็ก ค้นหาตัวจัดการการล้างข้อมูลและบล็อก catch แบบไดนามิก จากนั้นจึงโอนการควบคุมไปยังตำแหน่งหนึ่งในนั้น การโอนการควบคุมนั้นรวมไปถึงการคลายเฟรมสแต็กทั้งหมดที่อยู่ระหว่างกลาง (รวมถึงเฟรมของฟังก์ชันตัวช่วยเหล่านั้นด้วย)
ในเชิงปฏิบัติ กระบวนการนี้ใกล้เคียงกับการทำ longjmp หรือการสลับไฟเบอร์มากกว่าการเรียกใช้และส่งค่ากลับแบบปกติ รีจิสเตอร์แบบ callee-saved ต้องได้รับการคืนค่า รวมถึงรีจิสเตอร์ของเฟรมสแต็ก %rbp และ %rsp ด้วย
ไบนารีของเราลิงก์กับไลบรารีสองตัวที่มีการใช้งานฟังก์ชันที่ทำหน้าที่คลายสแต็กเมื่อเกิดข้อยกเว้นใน C++ ได้แก่ libgcc และ GNU libunwind นิยามของ GNU libunwind เป็นนิยามที่ตัวเชื่อมโยงแบบไดนามิกเลือกใช้ เรื่องนี้สร้างความประหลาดใจให้เรามาก เนื่องจากเราเคยคาดการณ์ว่า libgcc ควรจะทำงานได้ดีกว่าจากกฎเรื่อง symbol versioning แต่ผลจากการตรวจสอบไฟล์ไบนารีขณะทำงานจริงกลับแสดงให้เห็นว่าไม่ใช่แบบนั้น
ถึงจุดนี้ สมมติฐานในการทำงานของเราก็เปลี่ยนไป เนื่องจากเราได้ลดความเข้มงวดของข้อสมมติอีกข้อหนึ่งที่เราเคยตั้งไว้ในตอนที่ยังคิดว่ามีบั๊กเพียงตัวเดียว
บางทีเราอาจไม่ได้เห็นฟังก์ชันปกติคืนค่าเป็น NULL บางทีเราอาจกำลังเห็นการถ่ายโอนระหว่างการ unwind ซึ่งโดยทั่วไปคือการคืนค่ารีจิสเตอร์ในสไตล์ setcontext โดยตัวชี้คำสั่งปลายทางกลายเป็น NULL ก่อนที่การควบคุมจะถูกถ่ายโอนไป กล่าวอีกนัยหนึ่งก็คือ เป็นข้อมูลที่ผิดพลาดจากไลบรารีคลายสแต็ก ไม่ใช่ความผิดพลาดที่ช่องเก็บแอดเดรสส่งกลับบนสแต็กอย่างที่เข้าใจ
นั่นช่วยจำกัดขอบเขตของปัญหาให้แคบลงอย่างมาก สาเหตุมีความเป็นไปได้สองทาง คือ GNU libunwind คำนวณสถานะปลายทางออกมาผิด หรืออีกทางหนึ่งคือมันคำนวณสถานะที่ถูกต้องได้แล้ว แต่ข้อมูลเกิดถูกทำให้เสียหายเสียก่อนที่จะถูกนำไปใช้จริง
เราได้อ่านซอร์สโค้ดของ GNU libunwind และพบว่ามันทำการสังเคราะห์ ucontext_t ขึ้นมาบนสแต็ก จากนั้นจึงเติมข้อมูลสถานะของรีจิสเตอร์ที่ต้องการสำหรับเฟรมของตัวจัดการการล้างข้อมูล แล้วจึงส่งพอยน์เตอร์ไปยังโครงสร้างข้อมูลนั้นให้กับรูทีนภาษาแอสเซมบลีภายในที่ชื่อว่า _Ux86_64_setcontext
มาถึงตอนนี้ เราก็มีองค์ประกอบทั้งหมดครบแล้ว
ucontext_t ที่ถูกสังเคราะห์ขึ้นอยู่ในหนึ่งในเฟรมสแต็กที่ถูกคลายโดย _Ux86_64_setcontext ระหว่างการทำงานของฟังก์ชันนั้น _Ux86_64_setcontext กำลังอ่านจากโครงสร้างข้อมูลหลังจากที่เปลี่ยน %rsp ไปแล้วหรือไม่ ซึ่ง ณ จุดนั้นโครงสร้างข้อมูลดังกล่าวไม่ได้เป็นส่วนหนึ่งของสแต็กที่ใช้งานอยู่อีกต่อไป การทำเช่นนั้นจะทำให้มันเสี่ยงต่อการถูกเขียนทับหรือทำให้เสียหายจากการส่งสัญญาณ เช่น SIGUSR2 ที่เราส่งกันบ่อยๆ
คำตอบคือใช่
ต่อไปนี้คือคำสั่งหกรายการสุดท้ายของ _Ux86_64_setcontext ใน GNU libunwind เวอร์ชันที่เราใช้อยู่ ซึ่งส่วนใหญ่ประกอบด้วยคำสั่ง mov ที่โหลดข้อมูลจากหน่วยความจำไปยังรีจิสเตอร์ปลายทาง:
(%rdi ชี้ไปยัง ucontext_t ที่ถูกจัดสรรบนสแต็ก และแมโคร UC_MCONTEXT_* ก็เพียงแค่ขยายเป็นออฟเซ็ตคงที่ที่ใช้เก็บรีจิสเตอร์เฉพาะตัวหนึ่งไว้)
คำสั่งแรกคือจุดเริ่มต้นของช่วงเวลาการเกิด Race Condition อัปเดต %rsp ให้ชี้ไปยังตำแหน่งล่างสุดใหม่ของสแต็กที่ใช้งานอยู่ ทันทีที่สิ่งนี้เกิดขึ้น โครงสร้างข้อมูลที่ %rdi ชี้ไปจะไม่เป็นส่วนหนึ่งของสแต็กที่ใช้งานอยู่ (หรือ Red Zone) อีกต่อไป และมันก็ไม่ใช่พื้นที่ที่ห้ามเคอร์เนลแตะต้องอีกต่อไป
โดยปกติแล้วเรื่องนี้ไม่ทำให้เกิดปัญหา แต่ถ้ามีสัญญาณเข้ามาในจังหวะที่พอเหมาะพอเจาะ (หรือจังหวะที่แย่) เคอร์เนลจะสร้างเฟรมสัญญาณที่ตำแหน่ง %rsp-128 ซึ่งอาจเขียนทับหน่วยความจำที่ %rdi ชี้อยู่
หากเหตุการณ์นั้นเกิดขึ้นก่อนที่คำสั่งถัดไปจะอ่านค่า UC_MCONTEXT_GREGS_RIP(%rdi) ตัวชี้คำสั่งที่กู้คืนมาอาจเสียหายได้ แต่ในการล่มที่เราพบ ค่านั้นกลับกลายเป็น NULL
นั่นคือบั๊ก
การทำงานระดับ assembly นี้ยังช่วยอธิบายหนึ่งในข้อสังเกตที่ทำให้เราสับสนได้ด้วย นั่นคือเหตุใดฟังก์ชัน X ถึงมีค่า NULL อยู่ในช่องเก็บแอดเดรสสำหรับการส่งกลับของเฟรมสแต็กก่อนหน้า
setcontext ถูกเขียนขึ้นเพื่อกู้คืนรีจิสเตอร์ทั้งหมด รวมถึง %rdi ดังนั้นจึงไม่สามารถใช้รีจิสเตอร์นั้นเพื่ออ่าน UC_MCONTEXT_GREGS_RIP(%rdi) ในช่วงสุดท้ายของการถ่ายโอนการควบคุมได้ แทนที่จะเป็นเช่นนั้น มันกลับอ่านค่านั้นตั้งแต่ก่อนหน้านี้ บันทึกลงในสแต็ก อีกทั้งยังกู้คืนรีจิสเตอร์เพิ่มเติมอีกหลายตัว แล้วจึงใช้ retq อ่านค่าที่บันทึกไว้เพื่อส่งต่อการควบคุมการทำงาน
สิ่งที่ดูจาก Core Dump แล้วเหมือนว่าเป็น "ฟังก์ชันส่งกลับค่าเป็น NULL" แท้จริงแล้วคือ "ตัวคลายข้อยกเว้นสร้างแอดเดรสสำหรับส่งกลับเป้าหมายไว้บนสแต็ก แต่แอดเดรสเป้าหมายนั้นถูกทำให้เสียหายก่อนที่การถ่ายโอนจะเสร็จสมบูรณ์" เราเคยสันนิษฐานว่าการที่ช่องเก็บแอดเดรสสำหรับการส่งกลับเสียหาย น่าจะเกิดกับข้อมูลในตำแหน่งนั้นโดยตรง เพราะเราไม่รู้ว่าจุดไหนที่มีการเขียนข้อมูล (ที่อาจเสียหายได้) ลงในช่องดังกล่าวอย่างตั้งใจ
สิ่งที่ทำให้บั๊กนี้ดูเหลือเชื่อคือช่วงเสี่ยงที่พบนั้นแคบมาก สำหรับ Race Condition ลักษณะนี้ เหตุการณ์ภายนอก (สัญญาณ) จะต้องเกิดขึ้นในช่วงระหว่างสองขั้นตอนที่อีกเธรดหนึ่งกำลังดำเนินการอยู่ ยิ่งขั้นตอนเหล่านั้นเกิดขึ้นใกล้กันมากเท่าไร โอกาสที่จะเกิดสภาวะ Race Condition ก็ยิ่งน้อยลงเท่านั้น
ในกรณีนี้ช่วงเสี่ยงมีขนาดเพียงหนึ่งคำสั่งเท่านั้น ต้องมีการส่งสัญญาณหลังจากที่ %rsp ถูกเปลี่ยนแปลงแล้ว แต่ก่อนที่คำสั่งถัดไปจะโหลด %rip CPU แบบ super-scalar และ out-of-order รุ่นใหม่สามารถประมวลผลคำสั่งง่ายๆ หลายคำสั่งได้ภายในหนึ่งรอบสัญญาณนาฬิกา ทำให้ช่วงจังหวะที่เสี่ยงมีความยาวเพียงประมาณ 100 พิโควินาที
ทันทีที่เราค้นพบ Race Condition นี้ เราคิดเป็นอย่างแรกว่ามันน่าจะเกิดขึ้นน้อยเกินกว่าจะเป็นสาเหตุของอัตราการล่มที่สังเกตได้ เราเจอปัญหาการล่มแบบ return-to-null มากกว่าสิบครั้งต่อวันทั่วทั้งระบบ Race Condition ที่เกิดขึ้นในช่วงคำสั่งเพียงคำสั่งเดียวระหว่างการจัดการข้อยกเว้นจะอธิบายการล่มจำนวนมากขนาดนั้นได้จริงหรือ
เราจึงหันมาใช้การประมาณค่าแบบ Fermat หากช่วงเสี่ยงมีขนาดประมาณ วินาที และมีการส่ง SIGUSR2 ทุกๆ วินาทีของเวลา CPU ก็หมายความว่า ตัวจัดการการล้างข้อมูลข้อยกเว้นหรือบล็อก catch แต่ละตัว จึงมีโอกาสที่จะแพ้ Race Condition อยู่ที่ประมาณ
Rockset ใช้ข้อยกเว้นเป็นส่วนหนึ่งของกลไก Backpressure ภายในสำหรับการนำเข้าข้อมูล โฮสต์ที่โอเวอร์โหลดเพียงเครื่องเดียวอาจเกิดข้อยกเว้นได้ในระดับประมาณ ข้อยกเว้นต่อวินาที นั่นหมายความว่า ระยะเวลาเฉลี่ยระหว่างความขัดข้องของโฮสต์ที่ใช้ Backpressure คือ วินาที หรือระบบล่มหนึ่งครั้งทุกๆ ไม่กี่ชั่วโมง ในระดับของระบบทั้งหมด ความน่าจะเป็นเพียงเท่านี้ก็มากเกินพอที่จะอธิบายความถี่ของการล่มที่พบได้
บั๊กของ GNU libunwind ตัวนี้มีมานานแล้ว โดยมีอายุมากกว่า 18 ปี และปรากฏอยู่ในเวอร์ชัน x86_64 รุ่นแรกที่รองรับการคลายสแต็ก (unwinding) สำหรับข้อยกเว้นของ C++
แล้วทำไมมันถึงเพิ่งปรากฏให้เห็นตอนนี้
ความถี่ของการเกิดเหตุขัดข้องมีสัดส่วนโดยประมาณสัมพันธ์กับจำนวนข้อยกเว้นที่เกิดขึ้นและจำนวนสัญญาณที่ระบบได้รับ นอกจากนี้ยังขึ้นอยู่กับว่าตัวจัดการสัญญาณใช้พื้นที่ในสแต็กมากเพียงใด
Rockset มีลักษณะที่แตกต่างจากระบบทั่วไปในทั้งสามด้าน Rockset มีการโยนข้อยกเว้นในอัตราที่สูงเพื่อเป็นส่วนหนึ่งของการควบคุมการทำงานเกินกำลังตามปกติ เราส่งสัญญาณ SIGUSR2 บ่อยเป็นพิเศษเนื่องจากใช้ coarse_thread_cputime_clock และเมื่อต้นปีนี้ เรายังได้ปรับให้ตัวจัดการสัญญาณ SIGUSR2 ใช้สแต็กมากขึ้นโดยเพิ่มการเรียกใช้ timer_getoverrun เพื่อให้เราสามารถคำนวณจำนวนสัญญาณที่รวมกันได้
การเปลี่ยนแปลงครั้งล่าสุดนั้นดูเหมือนจะมีความสำคัญ ถ้าตัวจัดการใช้พื้นที่สแต็กน้อยมากพอ ก็อาจไม่เข้าไปเขียนทับส่วนความจำของ ucontext_t ที่ค้างไว้ ก่อนหน้าการเปลี่ยนแปลงครั้งนั้น เราไม่พบการล่มลักษณะนี้เลย หลังจากมีการเปลี่ยนแปลง อัตราการเกิดปัญหายังคงอยู่ในระดับต่ำจนกระทั่งเราได้เพิ่มโหลดสำหรับการใช้งานบางกรณีที่ทำให้กลไก Backpressure ต้องทำงานหนักขึ้น
กล่าวอีกนัยหนึ่งคือ บั๊กใน libunwind มีอยู่มาตลอด เพียงแต่ผลคูณของอัตราการเกิดข้อยกเว้น อัตราการเกิดสัญญาณ และการใช้สแต็กของตัวจัดการ เพิ่งจะเกินขีดจำกัดที่ทำให้สังเกตเห็นได้ในการปฏิบัติงานเมื่อไม่นานมานี้เอง
กลไกนี้ยังอธิบายเหตุที่บั๊กของฮาร์ดแวร์และบั๊กของ libunwind ส่วนใหญ่ขัดข้องภายใน DocumentTree::updateDocument ได้ด้วย ปัญหาขัดข้องจาก libunwind ส่วนใหญ่เกิดขึ้นกับเมธอดนี้ เพราะเมธอดนี้ทำงานอยู่เสมอ ณ จุดที่เราโยนข้อยกเว้นเพื่อใช้ Backpressure กับการนำเข้าข้อมูล ปัญหานี้ยังเกิดขึ้นบ่อยครั้งกับเหตุแครชที่เกิดจากความไม่ตรงตำแหน่งของ %rsp เนื่องจากฮาร์ดแวร์โหนดที่เสียหายเป็น SKU ที่เราใช้สำหรับการนำเข้าข้อมูลจำนวนมาก และต้องใช้เวลาส่วนใหญ่ของ CPU ในการประมวลผลเมธอดนั้น
การบรรเทาปัญหาเบื้องต้นของเราคือการเปลี่ยนจาก GNU libunwind ไปใช้ libgcc unwinder แทน การเปลี่ยนแปลงนี้ถือว่าคุ้มค่าในตัวเอง เพราะ unwinder ของ libgcc ได้รับการปรับปรุงมาอย่างต่อเนื่องเพื่อลดการแย่งใช้ล็อก ซึ่งมีความสำคัญเมื่อขยายระบบไปยัง VM ขนาดใหญ่
เรายังได้ส่งมอบโค้ดตัวอย่างสำหรับทดสอบเพื่อยืนยันปัญหาและแพตช์แก้ไข(เปิดในหน้าต่างใหม่)ไปยังโครงการ GNU libunwind รวมถึงตรวจสอบแล้วว่า unwinder ตัวอื่นไม่มีปัญหาในลักษณะเดียวกันนี้
การดีบั๊กนี้สอนเรามากมายเกี่ยวกับรายละเอียดเฉพาะของการเชื่อมโยงแบบไดนามิก ข้อมูลเมตาดาตา DWARF การส่งสัญญาณ Linux รวมถึง System V ABI และกลไกการจัดการข้อยกเว้นของ C++ แต่บทเรียนสำคัญนั้นกลับเรียบง่ายกว่าสิ่งที่กล่าวมาทั้งหมด
ขั้นตอนที่สำคัญที่สุดไม่ใช่การอ่าน Assembly อย่างชาญฉลาด หรือการมีความรู้เชิงลึกเกี่ยวกับรายละเอียดเหล่านั้น แต่เป็นการสร้างชุดข้อมูลคุณภาพสูง หากไม่มีชุดข้อมูลนี้ เราจะยังคงมองปรากฏการณ์สองอย่างที่ต่างกันว่าเป็นเรื่องเดียวกัน และพยายามใช้การวิเคราะห์หาคำอธิบายจากความสับสนนั้น เมื่อเรามีข้อมูลประชากรที่ถูกต้องและครบถ้วน โครงสร้างของปัญหาก็ชัดเจนขึ้นมาทันที นั่นคือกลุ่มการขัดข้องกลุ่มหนึ่งเกิดจากโฮสต์ที่มีปัญหา และอีกกลุ่มหนึ่งเกิดจาก Race Condition ใน libunwind เมื่อมีข้อมูลที่ดีขึ้น การดีบั๊กก็ง่ายขึ้น
สำหรับระบบโครงสร้างพื้นฐานอย่าง Rockset เรื่องนี้สำคัญมาก การตรวจสอบครั้งนี้ตอกย้ำความมุ่งมั่นของเราในการฝังระบบวัดและเก็บข้อมูลเชิงลึก การตรวจสอบแบบอัตโนมัติ และการปรับปรุงเครื่องมือด้านปฏิบัติการของเราอย่างต่อเนื่อง ความน่าเชื่อถือไม่ได้เป็นเพียงเรื่องของการแก้ไขบั๊กหลังจากที่เกิดขึ้นแล้วเท่านั้น แต่เป็นเรื่องของการสร้างข้อมูล เวิร์กโฟลว์ และทักษะที่จะเปลี่ยนปัญหาที่ดูเหมือนเป็นไปไม่ได้ ให้กลายเป็นปัญหาที่สามารถวินิจฉัยและแก้ไขได้
ผู้เขียน
By Nathan BronsonและMember of Technical Staff


