
วันที่แอปของเรายิง DDoS ใส่ตัวเอง
กองงานอัปโหลดที่ค้างอยู่ ถูกปล่อยออกมาทีเดียวทั้งหมดตอนเปิดแอป กลายเป็นการเปลี่ยนทุก client ของ GeekBye ให้เป็นการโจมตีแบบ denial-of-service เล็กๆ ใส่เซิร์ฟเวอร์ของเราเอง วิธีแก้ — และบันได connection-liveness ที่มันบังคับให้เราต้องสร้าง — เป็นหนึ่งในสิ่งที่มีประโยชน์ที่สุดที่ v2 สอนเรา
วิศวกรระบบกระจายทุกคนสุดท้ายก็ต้องได้เจอกับการแห่พร้อมกัน (thundering herd): client จำนวนมหาศาลทำสิ่งเดียวกันในเสี้ยววินาทีเดียวกัน แล้วทรัพยากรร่วมที่อยู่ข้างหลังก็ยุบลง โดยปกติมันเป็น client ของคนอื่น ใน GeekBye v2.0.1 มันเป็นของเรา และผู้โจมตีก็คือแอปของเราเอง
แอปจดประชุมโจมตีตัวเองได้ยังไง
GeekBye อัปโหลดไฟล์บันทึกเสียงไปที่ Google Drive ของคุณได้ ถ้าบันทึกไหนอัปโหลดทันทีไม่ได้ — คุณออฟไลน์อยู่ แอปปิดไป Drive สะดุด — มันจะเข้าไปอยู่ในกองงานค้างเพื่อลองใหม่ทีหลัง ก็สมเหตุสมผลดี
ความล้มเหลวอยู่ที่ ตอนไหน ที่ "ทีหลัง" เกิดขึ้น พอเปิดแอปครั้งถัดไป แอปจะพยายามระบายกองงานค้างทั้งหมดในทีเดียว ผู้ใช้คนหนึ่งที่มีบันทึกค้างสะสมทั้งสัปดาห์ กลายเป็นการระเบิดของ request อัปโหลดพร้อมกันในวินาทีที่เขาเปิดแอป คูณด้วยผู้ใช้ทุกคนที่เปิดแอปตอนเช้า แล้ว backend ของเราก็ถูกสั่งให้ยืนยันตัวตน ตรวจ rate และประมวลผลกำแพงทราฟฟิกในไม่กี่วินาทีเดียวกัน — จาก client ที่ในทางเทคนิคแล้วทุกตัวก็ทำตัวเรียบร้อยดี
backend ของเราทำสิ่งที่ถูกต้อง คือ rate-limit กระแสน้ำหลากนั้น แล้วความเสียหายลำดับที่สองก็เริ่มตรงนั้น response ของ rate-limit คือ HTTP 429 และ 429 ถ้าไม่ระวังมันหน้าตาเหมือนความล้มเหลวอย่างอื่นมากๆ:
- การดึงข้อมูลโปรไฟล์ ตอนเปิดแอปได้
429มา แล้วแอปปฏิบัติต่อมันเหมือน auth ล้มเหลว — เตะผู้ใช้ออกจาก session ที่ยังใช้ได้ดีๆ ชั่วครู่ - การเชื่อมต่อถอดเสียง ได้
429แบบทั่วไปมา แล้วโชว์ ป็อปอัปให้อัปเกรดว่าใช้ลิมิตเสียงหมดแล้ว — บอกผู้ใช้ที่จ่ายเงินว่าพวกเขาชน quota ที่จริงๆ ยังไม่ได้ชน
ดังนั้นต้นเหตุเดียว — กองงานค้างที่ไม่ได้จัดจังหวะ — ผลิตอาการที่มองเห็นได้สามอย่าง: เซิร์ฟเวอร์ตึง, การหลุด login แบบผิดๆ, และการยัดขายอัปเกรดแบบผิดๆ นี่คือรูปทรงคลาสสิกของ self-DoS: ภาระงานนั้นแย่อยู่แล้ว แต่สิ่งที่ผู้ใช้รู้สึกจริงๆ คือการ ตีความอาการของภาระงานนั้นผิด
วิธีแก้ ในสองชั้น
หยุดการแห่ถล่ม ตอนนี้กองงานอัปโหลดถูกจัดจังหวะแล้ว — request กระจายออกด้วย backoff และมี cooldown หลังจากทุก 429 เพื่อให้ client ถอยห่างจากเซิร์ฟเวอร์ที่กำลังตึง แทนที่จะไปกดทับมันหนักขึ้น การแห่พร้อมกัน (thundering herd) กลายเป็นแถวคิวที่เป็นระเบียบ
หยุดการตีความผิด 429 หรือ 5xx ชั่วคราวตอนเปิดแอปไม่เตะคุณออกจากระบบอีกแล้ว — client แยกแยะ "เซิร์ฟเวอร์แค่ยุ่งชั่วครู่" ออกจาก "session ของคุณใช้ไม่ได้แล้ว" และ 429 แบบ rate-limit ทั่วไปบนเส้นทางถอดเสียงก็ไม่ปลอมตัวเป็น error ของ quota เสียงอีก มีแต่ response quota ตัวจริงเท่านั้นที่จะโชว์ป็อปอัปอัปเกรด บทเรียนที่ติดตัว: error code ไม่ใช่ความหมายของ error 429 แปลว่า "ช้าลงหน่อย" ไม่ใช่ "คุณไม่มีสิทธิ์" และไม่ใช่ "quota คุณหมดแล้ว" — และ client ต้องรู้ความต่างนี้
บันได liveness ที่ตามมา
การจัดจังหวะฝูงที่แห่ ทำให้ปัญหาที่เงียบกว่านั้นโผล่ออกมา: ตอนที่การเชื่อมต่อมัน เสีย จริงๆ ภายใต้ภาระงาน เราสังเกตเห็นเร็วแค่ไหน? ช้ากว่าที่เราอยากให้เป็น ดังนั้น v2.0.4 จึงสร้างบันได connection-liveness สำหรับการถอดเสียงเรียลไทม์ขึ้นมา:
- Heartbeat — socket ถอดเสียงถูก ping เป็นช่วงเวลาคงที่ เพื่อให้การเชื่อมต่อที่ตายไปเงียบๆ ถูกตรวจเจอในไม่กี่วินาที แทนที่จะรอให้เสียงชิ้นถัดไปล้มเหลว
- การเตะให้ reconnect เมื่อเน็ตกลับมา — เมื่อ OS รายงานว่าเน็ตกลับมาแล้ว แอปจะรีบสร้างการเชื่อมต่อขึ้นใหม่ทันที แทนที่จะรอไปสะดุดเจอเอาเอง
- liveness แบบ control ping/pong — การวิ่งไปกลับระดับแอปพลิเคชัน ที่ยืนยันไม่ใช่แค่ว่า "socket เปิดอยู่" แต่ยืนยันว่า "ปลายทางอีกฝั่งกำลังตอบจริงๆ"
- ประตู reconnect ที่ service เป็นเจ้าของ — มีที่เดียวที่ตัดสินใจว่าจะ reconnect ไหม แทนที่จะให้หลายส่วนของแอปแย่งกันทำแล้วเหยียบเท้ากันเอง
ไม่มีอันไหนหรูหราเลย แต่ทั้งหมดนั่นแหละคือเหตุผลที่ session บน v2 รู้สึกเหมือนมันแค่…ต่ออยู่ตลอด
มันเกิดขึ้นอีกในสัปดาห์นี้ — ต่ำลงไปหนึ่งชั้น
นี่คือส่วนที่ทำให้เรื่องนี้เป็นมากกว่าเรื่องเล่าวีรกรรม ความล้มเหลวคลาสเดียวกันนี้โผล่มาอีกเมื่อไม่กี่วันก่อน ต่ำลงไปจากเราหนึ่งระดับ client ตัวเดียวยิง การเริ่ม session ถอดเสียง 21 ครั้งภายในไม่ถึง 400 มิลลิวินาที — แล้วไปสะดุดลิมิตระดับทั้งบัญชีของผู้ให้บริการเสียงของเรา ที่ 15 request พร้อมกัน การแห่ถล่มโครงสร้างพื้นฐานร่วม เหมือนกับกองงานอัปโหลดเป๊ะ แค่เล็งไปที่ตัว dependency แทนที่จะเป็น backend ของเราเอง
รูปทรงเหมือนกันเป๊ะ และวิธีแก้ก็เหมือนกัน: client ที่แห่ถล่มต้องมี single-flight guard เพื่อไม่ให้มันยิงยี่สิบครั้งเพื่อเจตนาเดียว และทรัพยากรร่วมต้องมีเพดานต่อผู้ใช้ เพื่อไม่ให้ client ตัวเดียวกินพูลจนหมด เราสร้างเวอร์ชันฝั่ง client ของสิ่งนี้มาก่อนแล้ว — ตัวจัดจังหวะการอัปโหลด คือ แพตเทิร์นนี้เอง ตอนนี้เราเอามันไปใช้กับการเริ่ม session การแห่พร้อมกัน (thundering herd) ไม่ใช่บั๊กที่แก้ครั้งเดียวจบ แต่มันคือรูปทรงที่คุณจะเรียนรู้ที่จะจำมันได้ ทุกที่ที่ client ไปเจอกับลิมิตที่แชร์กัน
สามสิ่งที่ควรเก็บกลับไป
- client ของคุณเองคือ load test ที่คุณไม่ได้นัดไว้ อะไรก็ตามที่ทำงานเป็นชุดตอนเปิดแอป, retry ตอนเปิดแอป, หรือ reconnect ตอนตื่นจาก sleep ล้วนเป็นการแห่พร้อมกัน (thundering herd) ที่รอแค่ให้มีผู้ใช้มากพอ จัดจังหวะมันเสียก่อนที่คุณจะมีผู้ใช้มากพอ
- แยก code ออกจากความหมาย
429คือ status ที่ถูกอ่านผิดมากที่สุดเท่าที่มีอยู่ "ช้าลงหน่อย" ไม่ใช่ "logout" และไม่ใช่ "จ่ายเงินให้เรา" กำหนดเส้นทางความล้มเหลวแต่ละอย่างไปยังสิ่งที่มันหมายถึงจริงๆ - liveness เป็นบันได ไม่ใช่ flag "การเชื่อมต่อยังมีชีวิตอยู่ไหม?" มีคำตอบที่ซื่อสัตย์อยู่หลายคำในหลายชั้น — socket เปิดอยู่, byte กำลังไหล, ปลายอีกฝั่งกำลังตอบ, มีเน็ต แอปที่แข็งแรงจะตรวจมากกว่าหนึ่งอย่าง
นี่คือกลไกไม่หรูหราที่อยู่ใต้ความนิ่งของ GeekBye v2 สำหรับฟีเจอร์ความน่าเชื่อถือที่มันเปิดทางให้ ดูทำไม AI notetaker ของคุณถึงหยุดเมื่อ Wi-Fi ห่วย และการถอดเสียงสดเมื่อไฟร์วอลล์บล็อก WebSocket (v2.0.8) สำหรับรีลีสข้างเคียงในซีรีส์นี้ ดูทำไม AI notetaker ของคุณถึงหยุดบันทึกเสียงกลางประชุม (v2.0.9)