OOMKilled (Exit Code 137): ทำไม Kubernetes ถึงยิงเป้าแอปทิ้ง ทั้งที่ในกราฟ JVM แรมยังเหลือเฟือ?
⏱️ ใช้เวลาอ่านประมาณ: 4 นาที 30 วินาที
บนแดชบอร์ด Grafana กราฟ JVM Heap Memory นิ่งสนิท สวยงาม ใช้ไปแค่ 60% จากที่จองไว้...
แต่พอกะพริบตาอีกที Pod บน Kubernetes ก็ดับวูบและ Restart ตัวเองใหม่อย่างน่าฉงน พอพิมพ์คำสั่ง kubectl describe pod ตรวจสอบ สาเหตุการตายกลับโผล่ขึ้นมาเป็นตัวแดงแจ๋:
Reason: OOMKilled
Exit Code: 137
ไม่มี Exception, ไม่มี Error Log, และไม่มีแม้แต่ OutOfMemoryError พ่นออกมาในแอป! ทำไมระบบถึงสั่งประหารชีวิต Container ของเราอย่างเลือดเย็น ทั้งที่โปรแกรมรายงานว่าแรมยังไม่เต็ม?
🔫 มือปืนประจำระบบ: Linux OOM Killer & cgroups
ก่อนอื่นต้องเข้าใจรหัสลับก่อน: Exit Code 137 เกิดจากสัญญาณ 128 + 9 (SIGKILL) แปลว่ากระบวนการไม่ได้ปิดตัวเองอย่างสุภาพ แต่ถูก Linux Kernel สั่งยิงเป้าทิ้งทันที
บน Kubernetes เมื่อคุณกำหนด:
limits:
memory: "1Gi"
Linux จะสร้างรั้วกั้นขึ้นมาเรียกว่า Control Groups (cgroups) หากโปรแกรมภายใน Container เบิกใช้หน่วยความจำรวมแตะเส้นขอบ 1Gi เมื่อไหร่ มือปืนที่ชื่อ OOM Killer (Out-of-Memory Killer) จะเข้ามาชักปืนยิง SIGKILL ปลิดชีพกระบวนการนั้นทิ้งในระดับฮาร์ดแวร์ทันทีโดยไม่มีสิทธิ์สั่งเสีย!
👻 กับดักภาพลวงตา: Memory ไม่ได้มีแค่ Heap
หลายคนตั้งค่า JVM ไว้แบบตายใจ เช่น:
JVM -Xmx (Max Heap) = 800MB
แล้วคิดว่าเหลือพื้นที่ปลอดภัยตั้ง 200MB! แต่ความจริงคือ Heap เป็นแค่ส่วนหนึ่งของภูเขาน้ำแข็ง ที่โปรแกรมใช้งาน:
- Metaspace & Class Data: พื้นที่เก็บ Metadata ของคลาส ยิ่งใช้ Framework ใหญ่ มี Reflection หรือ Dynamic Class Loading เยอะ ส่วนนี้ยิ่งบวม
- Thread Stacks: ถ้าแอปเปิด 500 เธรด โดยแต่ละเธรดจอง Stack 1MB แรมจะหายไปทันที 500MB อยู่นอกเหนือ Heap!
- Off-Heap / Direct Memory: ไลบรารีเน็ตเวิร์กสายโหดอย่าง Netty (เบื้องหลัง Spring WebFlux, gRPC, Kafka Client) จะเบิกหน่วยความจำตรงจาก OS (Direct ByteBuffer) ข้ามหัว JVM เพื่อความเร็วสูงสุด
- GZIP / Native Libraries: การบีบอัดไฟล์, Image Processing หรือไลบรารี C/C++ (JNI) จองแรมตรงกับ Linux Kernel
"เมื่อ Heap กินไป 600MB แต่ Netty กับ Thread Stacks พากันแอบจอง Native Memory เพิ่มอีก 450MB... ผลรวมทะลุ 1,050MB ปุ๊บ cgroups จะสั่งลั่นไกสังหารแอปทันที โดยที่ JVM ยังไม่ทันรู้ตัวเลยว่าตัวเองผิดอะไร!"
🛡️ 3 คาถาปัดเป่ารหัส 137
1. ใช้สูตร 70/30 สำหรับ Container Memory Limit:
อย่าตั้งค่า -Xmx ให้ปริ่มขอบ Container Limit โดยทั่วไปควรคุมให้ Heap ไม่เกิน 60% - 70% ของขนาด Container Memory Limit ทั้งหมด เพื่อเว้นที่ว่าง 30% ให้กับ Off-Heap, Metaspace และ OS Overhead
2. เลิกฮาร์ดโค้ด -Xmx แล้วให้ JVM คำนวณตาม cgroups อัตโนมัติ:
ตั้งแต่ Java 10 (และ Java 8u191+) JVM รองรับคอนเทนเนอร์อย่างชาญฉลาด ให้ใช้ Flag สัดส่วนแทนค่าคงที่:
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=70.0
3. วัดด้วยตัวชี้วัดของ Container เสมอ:
บนระบบมอนิเตอร์ ให้เลิกดูเฉพาะกราฟแอปภายใน แต่ให้ดู Metric container_memory_working_set_bytes จาก cAdvisor/Prometheus เป็นหลัก เพราะนี่คือตัวเลขจริงที่ Linux Kernel ใช้ตัดสินใจว่าจะเหนี่ยวไก OOM Killer หรือไม่
💡 5-Min Takeaway
"บนโลก Container พื้นที่หน่วยความจำไม่ได้ถูกตัดสินจากมุมมองของโปรแกรม แต่ถูกตัดสินจากกฎเหล็กของ Linux Kernel" อย่าหลงเชื่อเพียงตัวเลข Heap Memory ที่ดูสงบสุข เผื่อพื้นที่ให้ Native Memory และ Stack ของเธรดเสมอ และตรวจสอบ Working Set Size ของ Container เป็นประจำ เพื่อไม่ให้แอปพลิเคชันของคุณถูกมือปืน OOM Killer สั่งประหารกลางอากาศในยามที่โหลดพีค