exit 137 ไม่ใช่ disk เต็ม: กัน Jest OOM ใน Docker และ CI

exit 137 ไม่ใช่ disk เต็ม: กัน Jest OOM ใน Docker และ CI

TL;DRexit code 137 ตอน build/test คือ RAM เต็ม (OOM) ไม่ใช่ disk เต็ม. docker prune คืน disk ได้ แต่ไม่คืน RAM → ไม่มีวันแก้ปัญหานี้. ตัวจริงที่แก้คือบีบเพดาน หน่วยความจำให้ Jest เองด้วย maxWorkers + workerIdleMemoryLimit และใน monorepo ต้องคุม จำนวน package ที่รันพร้อมกันด้วย --workspace-concurrency

เรื่องมันเริ่มจาก…

CI / verify gate ของผมเป็น Docker build ที่รัน lint + tsc + unit test ของทั้ง monorepo (pnpm + Nx + Jest). อยู่ดีๆ วันหนึ่งมันก็เด้ง:

ERROR: process "/bin/sh -c pnpm ... run test" did not complete successfully: exit code: 137

สัญชาตญาณแรกคือ “build บ่อยๆ ไม่เคยเคลียร์ Docker เลย — disk คงเต็ม” เลยไล่ docker system prune, docker builder prune คืน disk ไปหลาย GB แล้วรันใหม่. มันก็ยังเด้ง 137 เหมือนเดิม — เพราะเรา กำลังแก้ผิดทรัพยากร

137 คืออะไรกันแน่

137 = 128 + 9. เลข 9 คือสัญญาณ SIGKILL — โปรเซสถูกฆ่าแบบไม่ทันตั้งตัว. ในบริบท Docker/CI ตัวที่ยิง SIGKILL บ่อยที่สุดคือ OOM killer ของ kernel เมื่อ RAM หมด

นี่คือกับดักทางความคิดที่หลายคนพลาด: RAM กับ disk เป็นคนละทรัพยากรกัน

ทรัพยากรอาการเมื่อเต็มdocker prune ช่วยไหม
Disk (images / build cache)no space left on device✅ คืน disk
RAM (หน่วยความจำของ Docker VM / runner)exit 137 (OOM)❌ ไม่เกี่ยวเลย

บน Docker Desktop (Mac/Windows) ยิ่งชัด เพราะ container ทั้งหมดรันใน Linux VM ที่ถูกจำกัด RAM ไว้ (ดูได้ด้วย docker info | grep "Total Memory" — เครื่องผมตอนนั้นมีแค่ ~7.6 GiB). พอ build หนักๆ ชนเพดานนั้น → OOM → 137. prune เท่าไรก็ไม่ช่วย

ทำไม unit test ถึงกิน RAM มหาศาล

ต้องเข้าใจก่อนว่า Jest รันเทสต์ยังไง เพราะนี่คือหัวใจของเรื่อง

Jest มี main process หนึ่งตัวคอยหา test file ทั้งหมด แล้วแจกไปให้ worker process หลายตัว รันขนานกัน:

main process (orchestrator)
   ├── worker 1  →  test A → เสร็จรับ test D ต่อ …
   ├── worker 2  →  test B → เสร็จรับ test E ต่อ …
   └── worker 3  →  test C …

จุดตายอยู่ตรงนี้: worker แต่ละตัวคือ Node process แยก = V8 heap ของตัวเอง (มี ts-jest, โค้ดที่ถูก import, coverage data ของมันเอง) กินตัวละราว 300 MB – 1 GB. แถม worker ยังถูก reuse ข้ามหลายไฟล์ — ไม่ตายหลังจบไฟล์ แต่รับไฟล์ถัดไปเรื่อยๆ

RAM รวม ≈ (จำนวน worker) × (ขนาดต่อ worker) — สองค่าที่เราจะจูนไปคุมสองตัวแปรนี้พอดี

maxWorkers: '50%' — คุม จำนวน worker

// jest.config.base.ts (หรือ jest.config.js)
export default {
  maxWorkers: '50%',
};
  • ค่า default ของ Jest ตอน run ปกติ = จำนวน core − 1 → เครื่อง 8 core ได้ 7 workers
  • '50%' = คิดเป็นเปอร์เซ็นต์ของจำนวน CPU core: max(1, floor(cores × 0.5))
    • 8 core → 4 · 16 core → 8 · 2 core → 1 (เหลือ 1 = รันใน main เลย ไม่ fork = ประหยัดสุด)
  • ทำไมลด RAM: จาก 7 → 4 workers บนเครื่อง 8 core ≈ ตัด worker ทิ้งครึ่งหนึ่ง → RAM peak ลงราวครึ่งหนึ่ง
  • ทำไมใช้ '50%' ไม่ใช่ตัวเลขตายตัวอย่าง 4: มันปรับตามเครื่องเอง — CI 2-core ได้ 1, laptop 12-core ได้ 6 → เขียนครั้งเดียวถูกทุกเครื่อง ไม่ต้องมานั่งจูนต่อเครื่อง
  • แลกกับ: ขนานน้อยลง ช้าลงนิดหน่อย (ถ้า suite ไม่ได้ยักษ์มากแทบไม่รู้สึก)

workerIdleMemoryLimit — คุม ขนาด ต่อ worker

export default {
  maxWorkers: '50%',
  workerIdleMemoryLimit: '512MB',
};

เพราะ worker ถูก reuse ข้ามไฟล์ RAM ของมันจึง ค่อยๆ ไต่ขึ้นเรื่อยๆ จาก memory ที่สะสม ไม่ถูกคืน — โดยเฉพาะถ้าใช้ coverageProvider: 'v8' ซึ่งขึ้นชื่อเรื่องสะสม coverage data ในตัว worker

กลไกของมัน:

  1. หลัง worker รันจบแต่ละไฟล์ (ตอน idle ระหว่างรอไฟล์ถัดไป) Jest เช็ค RSS ของ worker
  2. ถ้าเกิน 512MB → Jest kill worker ตัวนั้นทิ้ง แล้ว fork ตัวใหม่ (heap สะอาด RSS ตกลงมา) ก่อนส่งไฟล์ถัดไปให้

ผลคือ แทนที่ worker จะไต่ขึ้นไปเรื่อยๆ จนโดน kernel OOM-kill (137) มันถูก recycle แบบมีการควบคุม เพดานอยู่ที่ราว 512 MB ต่อตัว แลกกับ overhead ตอน fork ใหม่เล็กน้อย (เกิดเฉพาะเมื่อชนเพดาน)

ภาพรวม: ล็อกทั้งแกนกว้างและแกนสูง

ค่าคุมอะไรกันปัญหาอะไร
maxWorkers: '50%'จำนวน worker พร้อมกัน (กว้าง)RAM peak จากขนานมากเกินไป
workerIdleMemoryLimitขนาด ต่อ worker (สูง)RAM ไต่ขึ้นจาก leak สะสม

ลองคิดเป็นตัวเลขบนเครื่อง 8-core VM 7.6 GB:

ก่อน:  7 workers × ไต่ถึง ~900MB  ≈ 6.3GB  + งานอื่นในเครื่อง 2–3GB  → เกิน → 137
หลัง:  4 workers × เพดาน ~512MB   ≈ 2.0GB  + งานอื่นในเครื่อง 2–3GB  → พอดี ไม่ล้ม

กับดักเพิ่มของ monorepo: จำนวน package ที่รันพร้อมกัน

สองค่าข้างบนเป็น per-jest (ต่อการรัน Jest หนึ่งครั้ง). แต่ถ้าคุณรันเทสต์ทั้ง monorepo ด้วย pnpm แบบนี้:

pnpm --filter "..." run test

pnpm จะรันสคริปต์ หลาย package พร้อมกัน (ดีฟอลต์ --workspace-concurrency=4) → Jest 4 ตัว ต่างคนต่างคิด 50% ของตัวเอง → 4 × 4 = 16 workers ซ้อนกัน = ระเบิดเหมือนเดิม

ทางแก้คือคุมอีกชั้น ให้ verify ทีละ package (เหมาะกับ container/CI ที่ RAM จำกัด):

pnpm --filter "..." --workspace-concurrency=1 run test

กฎง่ายๆ: Jest config คุม RAM ภายในแต่ละ package ส่วน --workspace-concurrency คุมว่า กี่ package พร้อมกัน. ใน container ต้องมีทั้งสองชั้นถึงจะบีบ RAM ได้จริง

Config พร้อมใช้

Jest (ใช้ได้กับทุกเครื่อง + CI):

// jest.config.base.ts
import type { Config } from 'jest';

const config: Config = {
  // ...ของเดิม
  maxWorkers: '50%',              // จำนวน worker = ครึ่งหนึ่งของ core (ปรับตามเครื่อง)
  workerIdleMemoryLimit: '512MB', // recycle worker ที่บวมเกิน 512MB
};

export default config;

Dockerfile / CI step ของ monorepo:

RUN pnpm --filter "<globs>" --workspace-concurrency=1 --if-present run test

⚠️ ระวังตอน find-replace: ถ้า replace --if-present run แบบ global มันจะไปโดนบรรทัดอื่น (เช่น run prisma:generate) ด้วย และถ้าเผลอตัด space ออกจะกลายเป็น runtest ที่พังเงียบๆ — เช็กทุกบรรทัดหลังแก้เสมอ

ของแถม: ฝั่งเครื่อง (ทำครั้งเดียว ไม่ต้อง commit)

สิ่งที่แก้ได้จริงและ “ติดไปกับ repo” คือ config ข้างบน (ทุกคนที่ clone ได้ประโยชน์ + CI ด้วย). ส่วนฝั่งเครื่องเป็นตัวเสริม:

  • เพิ่ม RAM ให้ Docker Desktop (เช่น 8 → 12 GB) — ช่วยเฉพาะเครื่องคุณ

  • เปิด BuildKit GC ใน ~/.docker/daemon.json ให้ build cache ล้างตัวเองอัตโนมัติ:

    { "builder": { "gc": { "enabled": true, "defaultKeepStorage": "10GB" } } }
  • เคลียร์ disk แบบปลอดภัย (ไม่แตะ volume/DB): docker builder prune -f + docker image prune -f. อย่าใส่ --volumes เด็ดขาด เพราะจะลบข้อมูลใน database

สรุป

  1. exit 137 = RAM ไม่ใช่ disk — อย่าเสียเวลา prune เพื่อแก้ OOM
  2. Jest กิน RAM = จำนวน worker × ขนาดต่อ worker → คุมทั้งสองแกน: maxWorkers (กว้าง) + workerIdleMemoryLimit (สูง)
  3. ใน monorepo เพิ่มชั้น --workspace-concurrency เพื่อคุมจำนวน package ที่รันพร้อมกัน
  4. แก้ที่ repo (config) = ช่วยทุกคน + CI · แก้ที่ เครื่อง (RAM/GC) = ช่วยเฉพาะเครื่องคุณ

เลือกจูนที่ config ก่อนเสมอ แล้วปัญหา 137 จะไม่ตามไปหลอกทีมคุณอีก 🚀

Supawut Thomas

Supawut Thomas

Software Developer

มีประสบการณ์พัฒนา Software ระดับ Enterprise มากกว่า 10 ปี ผ่านงานจริงหลากหลายโปรเจกต์องค์กร — เชื่อว่าความรู้ที่ดีที่สุดคือความรู้ที่มาจากประสบการณ์จริง และอยากแบ่งปันสิ่งเหล่านั้นให้เพื่อน Developer ทุกคนได้นำไปพัฒนาตัวเองได้ดีขึ้นในทุกๆ วัน