CPU คอร์เดียว สคริปต์ debug ตัวเดียว แล้วทุกแชทก็ช้าพร้อมกัน
10 พฤศจิกายน 2569 · ThaiPo
เดือนมิถุนายน 2026 ทุกแชทของ ThaiPo ช้าลงพร้อมกันในวินาทีเดียว คำแปลที่ปกติมาถึงในไม่กี่วินาที กลายเป็นมาช้าไปสองถึงสี่นาที พร้อมกันทุกแชท ไม่มีอะไร crash และไม่มี error ใน log สักบรรทัด
ตัวการคือเราเอง นั่งอยู่ใน shell บนเครื่อง production กำลังตั้งใจช่วยแก้อีกเรื่องหนึ่งที่ไม่เกี่ยวกันเลย โพสต์นี้คือ post-mortem รวมถึงส่วนที่ว่าดีไซน์ของระบบเปิดช่องให้เรื่องนี้เกิดได้ยังไง และส่วนของดีไซน์นั้นที่เราตัดสินใจเก็บไว้เหมือนเดิม

งบเวลาหนึ่งวินาที
เวลามีคนส่งข้อความในแชท LINE ที่มี ThaiPo อยู่ LINE จะเรียก webhook ของเราแล้วรอคำตอบรับ และรอไม่นานด้วย: ประมาณหนึ่งวินาที ถ้าตอบไม่ทัน LINE จะถือว่าส่งไม่สำเร็จ แล้วค่อยลองใหม่ "ทีหลัง" ซึ่งคำว่าทีหลังในที่นี้หมายถึงหลักนาที ไม่ใช่มิลลิวินาที
เส้นทาง webhook ของเรามีหลายทอดกว่าที่คิด มีแค่ web app ที่มี address สาธารณะ มันจึงมี proxy route เล็กๆ อยู่ตัวหนึ่ง: รับ request จาก LINE แล้วส่ง raw bytes ต่อผ่าน private network ไปให้ ingest service ฝั่ง ingest ตรวจ signature เขียน event ลง object storage (ซึ่งเป็น source of truth ของเรา) เขียน marker object เล็กๆ อีกหนึ่งตัว แล้วจึงตอบกลับ proxy ตัวนี้ทำงานแบบ forward-and-wait: มันถือ request ของ LINE ค้างไว้ตลอดทั้งรอบไปกลับ แล้วส่งต่อ status ที่ได้กลับมาตามนั้น
งบหนึ่งวินาทีจึงถูกใช้ไปกับสี่อย่างที่ต่อกันเป็นอนุกรม:
- CPU ของเครื่อง web เพื่อรับ request แล้วส่งต่อ
- หนึ่ง hop บน private network ไปยัง serving plane
- CPU ของเครื่อง serving เพื่อ verify แล้วกระจายงานต่อ
- latency ของ object storage สำหรับทุก write ที่ ingest ต้องรอก่อนจะตอบ 200
วันปกติทั้งหมดนี้จบได้สบายๆ และยังเหลือเวลา ปัญหาคือทั้งสี่อย่างไม่มีอันไหนมีเงินสำรองของตัวเอง อะไรก็ตามที่ทำให้อันใดอันหนึ่งช้าลง ก็จ่ายออกจากวินาทีเดียวกันนั้นทั้งหมด
เซสชัน debug
ตอนนั้น web app รันอยู่บนเครื่องเดียว มี shared CPU core เล็กๆ แค่คอร์เดียว และเครื่องเดียวกันนี้ก็เป็นที่ที่ debug สะดวกที่สุดด้วย Go service ของเรา ship เป็น distroless container ที่ไม่มี shell อยู่ข้างในเลย ในขณะที่ container ของ web มีทั้ง shell มี Node runtime และมี credentials ของ bucket ถ้าอยากเข้าไปดู object ที่เก็บไว้ มันก็อยู่ตรงนั้น login รอไว้แล้ว แทบจะกวักมือเรียก
เรากำลังไล่ตามคำถามเรื่องการแปลที่ไม่เกี่ยวกับเรื่องนี้ และอยากประกอบประวัติบางส่วนขึ้นมาดู ก็เลยรันสคริปต์บนเครื่องนั้น ให้มัน list และ fetch object หลายพันตัวจาก bucket แล้วก็รันอีกตัว แต่ละตัวรันอยู่พักใหญ่ และกิน shared core คอร์เดียวนั้นเท่าที่จะกินได้ ซึ่งก็คือทั้งคอร์
proxy route อยู่บนคอร์เดียวกันนั้น มันไม่ได้ crash ไม่ได้ log error มันแค่ได้คิวใช้ CPU ช้ากว่าปกตินิดเดียว และ "นิดเดียว" นั้นก็มากพอจะดันการตอบรับให้เลย timeout ของ LINE LINE ทำตามที่สัญญาไว้ทุกอย่าง: เลิกรอ delivery พวกนั้น แล้วส่งซ้ำในอีกหลายนาทีให้หลัง
และเพราะ proxy คือประตูหน้าของทุกอย่าง เรื่องนี้จึงไม่ใช่แค่แชทเดียวที่ช้า แต่เป็นทุกแชทที่มีข้อความกำลังเดินทางอยู่ในช่วงเวลาเหล่านั้น
เบาะแส
สิ่งที่ทำให้วินิจฉัยได้คือ boolean ตัวเดียว LINE ติด redelivery flag มากับ webhook event ทุกตัว และ ingest ก็ log ค่านี้ไว้คู่กับ event พอเอา log มาเรียงตามเวลา เรื่องทั้งหมดก็เล่าตัวเองจบ:
- ก่อนสแกน: event เข้ามาห่างกันสี่ถึงแปดวินาที redelivery flag เป็น false
- ระหว่างสแกน: ช่องว่าง 132 และ 250 วินาที redelivery flag เป็น true ในหลายแชทพร้อมกัน
ช่วงที่ช้าตรงกับ shell session ของเราเองเป๊ะ การเอาเหตุขัดข้องมาเทียบกับ command history ของตัวเองแล้วพบว่าทาบกันสนิท ให้ความรู้สึกแบบหนึ่งที่อธิบายยาก เราไม่แนะนำให้ลอง
การที่ redelivery flag เป็น true ไปหมดทุกที่คือเบาะแสสำคัญ model ช้าทำให้คำแปลช้าหนึ่งข้อความ queue ค้างทำให้ช้าหนึ่ง shard มีแต่ประตูหน้าที่ช้าเท่านั้นที่ทำให้ LINE ต้องส่งซ้ำเอง และส่งซ้ำให้ทุกคน มีอย่างน้อยหนึ่งข้อความที่ผ่านไปสิบนาทีแล้วก็ยังมาไม่ถึง serving plane เราจึงตัดความเป็นไปได้ที่ webhook หายไปเลยจริงๆ ออกไม่ได้
อะไรเปลี่ยนไปบ้าง
เครื่อง web สองเครื่อง แต่ละเครื่อง CPU มากขึ้น shared core คอร์เดียวเป็น margin ที่น้อยเกินไป สำหรับ process ที่อยู่ข้างใน timeout หนึ่งวินาทีของคนอื่น ตอนนี้ web app รันบนสองเครื่อง เครื่องละสอง shared core ต้องแก้สองรอบกว่าจะจบ: การเพิ่ม CPU รอบแรกถูก revert กลับไปเหลือคอร์เดียวแบบเงียบๆ ทั้งที่ comment ใน config ยังบรรยายถึง fix นั้นอยู่ กว่าเราจะจับได้ก็ผ่านไปสองเดือน ซึ่งก็คือตอนที่เครื่องที่สองมาถึง
เครื่อง serving สองเครื่อง serving plane อยู่บนเส้นทางเดียวกัน ติด deadline เดียวกัน เหตุผลเดียวกันจึงใช้กับมันด้วย
จำนวนทั้งสองถูกเขียนไว้ว่าเป็นตัวรับน้ำหนัก ห้ามแตะ ไฟล์ conventions ของ repo ซึ่งเป็นไฟล์แรกที่ contributor ทุกคน (จะเป็นคนหรือไม่ใช่คนก็ตาม) ต้องอ่าน เขียนด้วยตัวหนาว่าอย่าลดอันไหนกลับไปเหลือหนึ่ง พร้อมบอกเหตุผล จำนวนเครื่องเป็นของประเภทที่จะโดน "optimise" ในบ่ายวันเงียบๆ โดยใครสักคนที่กำลังนั่งดูบิล ส่วน learning plane ที่ทำงานเบื้องหลังนั้นตรงกันข้าม ตั้งใจให้มีเครื่องเดียวต่อไป: ไม่มีใครรอมันอยู่
งานสแกนหนักๆ ไม่รันบนเครื่อง ingress เด็ดขาด lookup ที่ขอบเขตแคบจริงๆ (หนึ่งแชท หนึ่งวัน object ไม่กี่ตัว) ยังพอรับได้ อะไรที่ใหญ่กว่านั้นให้รันบนเครื่องใช้แล้วทิ้งที่มีอยู่เพื่องานนั้นงานเดียว หรือไม่ก็ดึง object ลงมาประมวลผลบนเครื่องตัวเอง
Log ฟรี แต่ shell บน production ไม่ฟรี การอ่าน log ไม่สร้าง load ให้เครื่องที่ผลิต log นั้น ส่วน shell session ใช้ CPU ร่วมกับงานที่เครื่องนั้นทำเลี้ยงชีพ ตอนนี้เราหยิบ log ก่อนเสมอ และมอง shell บน production ว่าเป็นของที่มีราคาติดมาด้วย แม้จะแค่ "อ่านเฉยๆ" ก็ตาม
Tradeoff ที่เราเก็บไว้
วิธีแก้เชิงโครงสร้างที่เห็นชัดที่สุดคือให้ proxy ตอบรับทันที: คืน 200 ให้ LINE ทันทีที่ request มาถึง แล้วค่อย forward ไป ingest ใน background การแย่ง CPU จะไม่มีผลอีกต่อไป เพราะการ ack ไม่ต้องรออะไรแล้ว
เราพิจารณาทางนี้อย่างจริงจัง และยังไม่ได้ทำ
forward-and-wait แปลว่า 200 ที่ LINE ได้รับเป็นประโยคที่เป็นความจริง: event อยู่ใน object storage อย่าง durable แล้ว ถ้าติดต่อ serving plane ไม่ได้ proxy ตอบ 503 ถ้า write ลง storage ล้มเหลว ingest ตอบ error ทั้งสองกรณี LINE ยังเก็บข้อความไว้แล้วส่งซ้ำให้ และเพราะ ingest ใช้ event id ของ LINE เองเป็น key ของ event ที่เก็บ redelivery ที่มาถึงสองรอบจริงๆ ก็เป็นแค่การเขียนทับที่ไม่มีพิษภัย
ack-first กลับด้านทั้งหมดนี้ proxy จะพูดว่า "ได้รับแล้ว" ก่อนที่ใครจะได้รับอะไรจริงๆ และถ้า serving ล่ม ก็จะกลายเป็นข้อความที่หายไปเงียบๆ โดยมีที่พักอยู่แค่ใน memory ของ web process เราต้องสร้าง durable buffer ของตัวเองขึ้นมาก่อน เพื่อทดแทน retry ที่ LINE ให้เราฟรีๆ incident ครั้งนี้จะกลายเป็นมองไม่เห็น ซึ่งฟังดูเหมือนดีขึ้น จนกระทั่งสังเกตว่าความล้มเหลวที่มันซ่อนให้ คืออันที่ร้ายแรงน้อยกว่า
ช้ายังกู้คืนได้ หายกู้คืนไม่ได้ proxy จึงยังรอเหมือนเดิม และเราเลือกซื้อ headroom เพิ่ม แทนที่จะซ่อน dependency เอาไว้
สรุป
ถ้า process ของคุณต้องตอบให้ทันภายใน timeout ของคนอื่น ทุกอย่างที่ใช้ CPU ร่วมกับมันคือส่วนหนึ่งของงบ latency ของคุณ รวมถึงตัวคุณเองที่นั่งอยู่หน้า prompt กำลังรันสคริปต์สั้นๆ แค่ตัวเดียว เขียนงบนั้นลงไป ระบุชื่อทุก hop ในนั้น เผื่อเครื่องสำรองให้เส้นทางนี้ แล้วไปขุดคุ้ยข้อมูลเก่าที่อื่น
ThaiPo ออกแบบ สร้าง และดูแลโดยนักพัฒนาคนเดียว Steven Acreman
ลองใช้ในแชทของคุณเอง
ThaiPo อยู่ใน LINE และแปลทุกข้อความให้สองทาง ฟรีตลอดไป เริ่มใช้ฟรี หรือ เพิ่มเพื่อน @thaipo.ai ใน LINE แล้วบอทจะส่งลิงก์สมัครให้ในแชทเลย