exit 137 ไม่ใช่ disk เต็ม: กัน Jest OOM ใน Docker และ CI
TL;DR —
exit 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
กลไกของมัน:
- หลัง worker รันจบแต่ละไฟล์ (ตอน idle ระหว่างรอไฟล์ถัดไป) Jest เช็ค RSS ของ worker
- ถ้าเกิน
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
สรุป
- exit 137 = RAM ไม่ใช่ disk — อย่าเสียเวลา prune เพื่อแก้ OOM
- Jest กิน RAM = จำนวน worker × ขนาดต่อ worker → คุมทั้งสองแกน:
maxWorkers(กว้าง) +workerIdleMemoryLimit(สูง) - ใน monorepo เพิ่มชั้น
--workspace-concurrencyเพื่อคุมจำนวน package ที่รันพร้อมกัน - แก้ที่ repo (config) = ช่วยทุกคน + CI · แก้ที่ เครื่อง (RAM/GC) = ช่วยเฉพาะเครื่องคุณ
เลือกจูนที่ config ก่อนเสมอ แล้วปัญหา 137 จะไม่ตามไปหลอกทีมคุณอีก 🚀