ตั้งค่า Docker Compose กันคอนเทนเนอร์กิน RAM จน OOM
กำหนด memory และ CPU ใน Docker Compose ป้องกันคอนเทนเนอร์เดียวทำ VPS ล่ม พร้อมอธิบาย deploy.resources, mem_limit, exit 137, swap และการจัดสรรขนาด
ข้อจำกัดหน่วยความจำของ Docker Compose ทำงานอย่างไร
ข้อจำกัดหน่วยความจำของ Docker Compose คือเพดานสูงสุดที่เคอร์เนล Linux กำหนดให้กับ cgroup (control group ซึ่งเป็นคุณลักษณะของเคอร์เนลสำหรับวัดการใช้ทรัพยากรของกลุ่มโปรเซส) ของคอนเทนเนอร์หนึ่งรายการ กำหนด deploy.resources.limits.memory ให้กับ service แล้วคอนเทนเนอร์นั้นจะใช้หน่วยความจำเกินค่าที่ระบุไม่ได้ เมื่อพยายามใช้เกิน เคอร์เนลจะยุติโปรเซสภายในคอนเทนเนอร์ และคอนเทนเนอร์มักจะหยุดทำงานด้วยรหัส 137
เรื่องนี้สำคัญที่สุดบน VPS ซึ่งมี RAM คงที่และไม่มีหน่วยความจำของโฮสต์สำรองให้ยืม คอนเทนเนอร์เพียงรายการเดียวที่มี memory leak หรือ query ที่มีปัญหา อาจใช้ทุก page ที่ว่างอยู่บนเครื่องขนาด 8GB ได้ จากนั้นเคอร์เนลจะยุติโปรเซสที่เห็นว่ามีปัญหามากที่สุด ซึ่งมักเป็น database หรือ SSH session ของคุณ แทนที่จะเป็นคอนเทนเนอร์ที่ก่อปัญหา ข้อจำกัดจะเปลี่ยนเหตุขัดข้องของทั้งเซิร์ฟเวอร์ให้เหลือเพียง service เดียวที่ restart
services:
app:
image: ghcr.io/example/app:1.4
deploy:
resources:
limits:
cpus: "1.5"
memory: 1g
reservations:
memory: 256mนำไปใช้และตรวจสอบว่าข้อจำกัดมีผลแล้ว:
docker compose up -d
docker stats --no-streamคอลัมน์ MEM USAGE / LIMIT ควรแสดงค่าประมาณ 142MiB / 1GiB หากคอลัมน์ข้อจำกัดแสดง RAM ทั้งหมดของโฮสต์ แสดงว่าการตั้งค่าไม่มีผล และคำแนะนำส่วนที่เหลือจะใช้ไม่ได้จนกว่าจะตั้งค่าสำเร็จ หาก compose file ยังเป็นเรื่องใหม่สำหรับคุณ พื้นฐาน Docker Compose สำหรับ VPS จะอธิบายโครงสร้างไฟล์ที่ใช้เป็นพื้นฐานในหัวข้อนี้
deploy.resources.limits หรือ mem_limit: ค่าใดมีผล
มีการเขียน 2 รูปแบบสำหรับแนวคิดเดียวกัน จึงทำให้เกิดความสับสน
mem_limit, mem_reservation, memswap_limit, cpus และ cpu_shares เป็นคีย์ระดับบนสุดของ service ที่สืบทอดมาจากรูปแบบไฟล์ Compose รุ่นเก่า deploy.resources มาจาก schema ของ Swarm และปัจจุบันเป็นส่วนหนึ่งของ Compose Specification ซึ่งเป็นรูปแบบที่ docker compose อ่านในปัจจุบัน
ทั้งสองรูปแบบใช้งานได้บนโฮสต์เดียว Compose V2 ซึ่งเป็นปลั๊กอิน docker compose จะใช้ deploy.resources.limits และ deploy.resources.reservations เมื่อคุณเรียกใช้ docker compose up โดยไม่ต้องมีคลัสเตอร์ Swarm ส่วนที่ใช้เฉพาะกับ Swarm ในบล็อก deploy คือคีย์อื่นๆ ได้แก่ mode, placement, update_config และ endpoint_mode ซึ่งมีความหมายต่อ docker stack deploy และจะถูกละเว้นโดย docker compose up ดังนั้นคำแนะนำทั่วไปที่ว่า "deploy ต้องใช้ Swarm" จึงไม่ถูกต้องสำหรับส่วน resources และหากทำตามคำแนะนำนี้ service ของคุณจะไม่มีการจำกัดทรัพยากรเลย
เลือกใช้รูปแบบใดรูปแบบหนึ่งต่อโปรเจกต์ การเขียน mem_limit: 512m และ deploy.resources.limits.memory: 1g ใน service เดียวกันทำให้ได้ไฟล์ที่อ่านแล้วไม่สามารถทราบได้ทันทีว่าค่าใดมีผล แทนที่จะคาดเดาว่าค่าใดถูกใช้ ให้สอบถาม daemon:
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemoryReservation}} {{.HostConfig.NanoCpus}}' app-1ค่าหน่วยความจำเป็นไบต์ ดังนั้น 1g จะแสดงเป็น 1073741824 ค่า CPU ใช้หน่วย nano CPUs ดังนั้น 1.5 จะแสดงเป็น 1500000000 ค่า 0 ในฟิลด์ใดๆ หมายความว่าไม่ได้กำหนดขีดจำกัดไว้ ขีดจำกัดหน่วยความจำต่ำสุดที่ Docker ยอมรับคือ 6m และหากต่ำกว่านั้น container จะไม่เริ่มทำงาน
จะเกิดอะไรขึ้นเมื่อ container ถึงขีดจำกัด
container จะไม่ทำงานช้าลง แต่จะหยุดทำงาน
เมื่อ process ขอ page และ cgroup ใช้ถึง memory.max แล้ว kernel จะเรียกคืนหน่วยความจำภายใน cgroup เท่าที่ทำได้ก่อน โดยเริ่มจาก page cache ที่สะอาด จากนั้นจึงเรียกคืน page ที่สามารถ swap ได้ หากการเรียกคืนหน่วยความจำไม่เพียงพอ OOM (out of memory) killer ของ cgroup จะเลือก process ภายใน container และส่ง SIGKILL ไปยัง process นั้น หาก kill PID 1 ของ container, container จะหยุดทำงาน Exit code 137 มีค่าเท่ากับ 128 บวก signal 9 ดังนั้น 137 จึงเป็นร่องรอยของ SIGKILL ทุกกรณี ไม่ใช่หลักฐานว่าเกิด OOM โดยตัวมันเอง
docker compose ps -a
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' app-1true 137 คือ OOM kill ส่วน false 137 หมายถึงมีสิ่งอื่นส่ง SIGKILL และสาเหตุที่พบบ่อยคือ docker compose stop ใช้ช่วงเวลาผ่อนผัน 10 วินาทีจนหมด เพราะแอปไม่ตอบสนองต่อ SIGTERM ความแตกต่างนี้ช่วยลดเวลาในการตรวจสอบได้มาก เพราะปัญหาทั้งสองไม่มีสาเหตุร่วมกัน
มีอีก 2 ตำแหน่งที่บันทึกเหตุการณ์นี้ ให้ monitor daemon แบบเรียลไทม์:
docker events --filter event=oomจากนั้นอ่าน kernel log ซึ่งเป็นบันทึกที่ยังคงอยู่หลังการ restart:
sudo dmesg -T | grep -i -E 'memory cgroup out of memory|killed process'การ kill โดย cgroup จะแสดงบรรทัดที่ขึ้นต้นด้วย Memory cgroup out of memory: Killed process 24713 (node) ส่วนบรรทัดที่ไม่มี prefix Memory cgroup คือ host OOM ซึ่งหมายความว่าเครื่องมี RAM ไม่เพียงพอ นี่คือความล้มเหลวที่การกำหนดขีดจำกัดมีไว้เพื่อป้องกัน ดังนั้นหากพบเหตุการณ์นี้ แสดงว่าผลรวมของขีดจำกัดสูงเกินไป หรือมีบาง service ที่ไม่ได้กำหนดขีดจำกัดไว้เลย
เมื่อใช้ restart: unless-stopped, OOM loop จะตรวจพบได้ยาก เพราะ service จะแสดงสถานะทำงานใน docker compose ps เพียง 1 วินาทีหลังจากหยุดทำงาน ให้ตรวจสอบคอลัมน์ uptime และจำนวนครั้งที่ restart และกำหนดขีดจำกัดควบคู่กับ healthcheck ที่รายงานว่าแอปไม่พร้อมใช้งาน เพื่อให้มองเห็น container ที่หยุดทำงานซ้ำ ๆ ได้โดยไม่ต้อง monitor ด้วยตนเอง
Reservation เป็นเพียงคำแนะนำ ส่วน limit คือกฎที่ต้องปฏิบัติตาม
reservations.memory (หรือ mem_reservation รุ่นเก่ากว่า) เป็นค่าขั้นต่ำแบบผ่อนปรน Docker อธิบายว่าค่านี้เป็น soft limit ซึ่งจะมีผลเมื่อ daemon ตรวจพบการแย่งใช้ทรัพยากรหรือหน่วยความจำบน host ต่ำ ค่านี้ไม่เคยหยุด container ไม่ให้ใช้หน่วยความจำเกินค่า reservation และไม่เคยรับประกันว่าจะมีหน่วยความจำว่างเมื่อ container ร้องขอ ค่านี้เพียงทำให้ kernel มีแนวโน้มเรียกคืนหน่วยความจำจาก container ที่ใช้เกินค่า reservation ก่อน
ดังนั้น reservation ไม่ได้ปกป้องสิ่งใดด้วยตัวเอง ใช้ค่านี้เพื่อระบุ service ที่ต้องการให้ได้รับการจัดสรรทรัพยากรอย่างเหมาะสมเมื่อระบบมีแรงกดดัน และใช้ limit เป็นกลไกความปลอดภัย กำหนด reservation ให้ต่ำกว่า limit มิฉะนั้น container จะเริ่มทำงานไม่ได้: Docker จะปฏิเสธการตั้งค่าด้วย Minimum memory limit can not be less than memory reservation limit.
Swap accounting อย่างตรงไปตรงมา
อิมเมจ VPS ส่วนใหญ่ไม่มีไฟล์ swap มาให้เลย เรียกใช้ swapon --show และ free -h หากจำนวน swap รวมเป็นศูนย์ การตั้งค่าที่เกี่ยวข้องกับ swap ด้านล่างจะไม่มีผล และขีดจำกัดหน่วยความจำของคุณจะเป็นขีดจำกัด RAM เพียงอย่างเดียว
memswap_limit ไม่ใช่ปริมาณ swap แต่เป็นผลรวมของหน่วยความจำกับ swap เมื่อใช้ mem_limit: 1g และ memswap_limit: 2g คอนเทนเนอร์จะได้รับ RAM 1GB และ swap 1GB การตั้งค่า 2 ค่านี้ให้เท่ากันจะทำให้คอนเทนเนอร์ไม่มี swap เลย การตั้งค่า mem_limit และไม่กำหนด memswap_limit จะทำให้คอนเทนเนอร์ใช้ swap ได้สูงสุดเท่ากับขนาดขีดจำกัดหน่วยความจำอีกครั้ง
Ubuntu 24.04 และ Debian 13 ใช้ cgroup v2 เป็นค่าเริ่มต้น โดย swap จะมีตัวนับแยกต่างหาก (memory.swap.max) และการทำงานนี้ไม่ต้องตั้งค่าเพิ่มเติม ข้อความเก่า Your kernel does not support swap limit capabilities มาจากโฮสต์ cgroup v1 ที่บูตโดยไม่มี swapaccount=1 ในโฮสต์เหล่านั้น ขีดจำกัดหน่วยความจำยังมีผล แต่จะไม่สนใจส่วนของ swap
ควรระบุให้ชัดเจนว่า swap ช่วยอะไรได้บ้าง Swap ทำให้การ kill โดย OOM ช้าลง แต่ไม่ได้ทำให้เกิดขึ้นได้ยากลง เพราะโปรเซสที่หน่วยความจำรั่วจะใช้ swap จนเต็มได้เช่นเดียวกับการใช้ RAM จนเต็ม ขณะเดียวกัน คอนเทนเนอร์ที่ใช้ swap อย่างหนักบนพื้นที่จัดเก็บ VPS ที่ใช้ร่วมกันจะทำให้บริการอื่นทั้งหมดบนเครื่องช้าลง สำหรับงานที่ไวต่อเวลาแฝง การกำหนดขีดจำกัดที่ถูกต้องโดยไม่ใช้ swap จะทำให้ล้มเหลวได้เร็วกว่าและคาดการณ์ได้มากกว่า
เหตุใดการใช้หน่วยความจำจึงดูแย่กว่าความเป็นจริง
ค่าตัวเลข MEM USAGE ใน docker stats รวม page cache ไว้ด้วย ดังนั้น container ที่อ่านไฟล์ขนาดใหญ่จะใช้หน่วยความจำเพิ่มขึ้นจนเกือบถึงขีดจำกัด แล้วคงอยู่ในระดับนั้น ซึ่งเป็นเรื่องปกติและไม่ใช่ memory leak เพราะระบบจะเรียกคืน clean cache ก่อนที่จะเรียก OOM killer เสมอ บริการอย่าง เซิร์ฟเวอร์สื่อ Jellyfin ที่โฮสต์เอง จึงดูเหมือนใช้หน่วยความจำใกล้ขีดจำกัดตลอดเวลาด้วยเหตุผลนี้
แยกตัวเลขดังกล่าวเป็น cache และ working set จริงจากภายใน container:
docker compose exec app grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat
docker compose exec app cat /sys/fs/cgroup/memory.eventsanon คือ anonymous memory หรือ working set ที่ไม่สามารถทิ้งได้ file คือ page cache ซึ่งสามารถเรียกคืนได้ กำหนดขีดจำกัดโดยอ้างอิงจาก anon บวกเผื่อพื้นที่ ไม่ใช่จากค่ารวม ไฟล์ memory.events ยืนยันได้โดยตรง: ค่า counter oom_kill ที่มากกว่า 0 หมายความว่า kernel ได้ kill บางสิ่งใน container นี้นับตั้งแต่เริ่มทำงาน และค่า counter max ที่เพิ่มขึ้นหมายความว่า container กำลังถูกจำกัดอยู่ที่ขีดจำกัดในขณะนี้ ทั้งสองคำสั่งต้องใช้ shell และ coreutils ภายใน image ดังนั้นจะทำงานล้มเหลวบน image แบบ distroless หรือ scratch
ขีดจำกัดการจัดสรรทรัพยากรบน VPS ขนาด 8GB
เริ่มพิจารณาจากโฮสต์ ไม่ใช่จากแอป บน VPS ขนาด 8GB ควรสงวนไว้ประมาณ 1GB สำหรับเคอร์เนล, Docker daemon, sshd, journald และ shell สำหรับการเข้าสู่ระบบของคุณเอง จะเหลือประมาณ 7GB สำหรับจัดสรร และผลรวมของขีดจำกัดคอนเทนเนอร์ทั้งหมดควรต่ำกว่าค่านี้ การจัดสรรทรัพยากรเกินจริงอาจทำงานได้จนถึงวันที่บริการ 2 รายการใช้ทรัพยากรสูงสุดพร้อมกัน
การแบ่งทรัพยากรที่ใช้งานได้บนเครื่องขนาด 8GB:
- Reverse proxy: จำกัดที่ 128m เป็นโปรเซสขนาดเล็ก และขีดจำกัดที่ต่ำระดับนี้จะตรวจจับการโหลดการกำหนดค่าซ้ำที่ทำงานผิดปกติได้ทันที
- PostgreSQL: จำกัดที่ 2g โดยตั้งค่า
shared_buffersไว้ที่ประมาณ 512MB ในการกำหนดค่าฐานข้อมูล - คอนเทนเนอร์แอปพลิเคชัน: จำกัดที่ 1g
- เวิร์กเกอร์เบื้องหลัง: จำกัดที่ 512m
- บริการสื่อหรือไฟล์: จำกัดที่ 2g ซึ่งส่วนใหญ่จะใช้เป็น page cache
อย่านำตัวเลขเหล่านี้ไปใช้กับ stack ของคุณโดยตรง ให้เรียกใช้บริการภายใต้โหลดจริงเป็นเวลา 1 วัน ตรวจสอบ docker stats บันทึกค่า anon สูงสุดของแต่ละคอนเทนเนอร์ แล้วเพิ่มเผื่อไว้อีกประมาณครึ่งหนึ่ง การตั้งขีดจำกัดให้ต่ำเกินไปแย่กว่าการไม่ตั้งขีดจำกัด เพราะจะทำให้บริการที่ยังทำงานปกติถูกหยุดระหว่างที่ปริมาณ network traffic เพิ่มขึ้นตามปกติ
มีข้อผิดพลาดประการหนึ่งที่ควรกล่าวถึงแยกต่างหาก รันไทม์ส่วนใหญ่จะไม่ทราบขีดจำกัดนี้ หากคุณไม่ได้แจ้งให้รันไทม์ทราบ PostgreSQL จะจัดสรรค่า shared_buffers และ work_mem ให้สูงเกินขีดจำกัดของคอนเทนเนอร์ได้ และโปรเซสจะถูกหยุด JVM (Java virtual machine) ต้องใช้ -XX:MaxRAMPercentage=75 เพื่อจัดสรร heap ตามขีดจำกัดของ cgroup แทนการอ้างอิง RAM ของโฮสต์ Node.js ต้องใช้ --max-old-space-size โดยระบุหน่วยเป็นเมกะไบต์และตั้งค่าให้ต่ำกว่าขีดจำกัดของคอนเทนเนอร์ มิฉะนั้น garbage collector จะปล่อยให้ heap ขยายจนเคอร์เนลเข้ามาหยุดการทำงาน cgroup จะไม่เจรจาต่อรอง แต่จะหยุดโปรเซสทันที
ขีดจำกัด CPU มีพฤติกรรมแตกต่างกันโดยสิ้นเชิง
cpus: "1.5" หมายถึง 150% ของ CPU core เดียว โดยบังคับใช้เป็นโควตา CFS (completely fair scheduler) คอนเทนเนอร์จะได้รับเวลา CPU 150ms ในทุกช่วงเวลา 100ms และเวลานี้ใช้ร่วมกันระหว่างทุก thread ของคอนเทนเนอร์ เมื่อใช้เวลาครบแล้ว kernel จะทำให้คอนเทนเนอร์รอจนกว่าจะเริ่มช่วงเวลาถัดไป
นี่คือความแตกต่างที่สำคัญ คอนเทนเนอร์ที่ใช้หน่วยความจำเกินขีดจำกัดจะถูกหยุดการทำงาน ส่วนคอนเทนเนอร์ที่ใช้ CPU เกินขีดจำกัดจะถูกจำกัดอัตราการทำงานและยังทำงานต่อ แต่ช้าลง ดังนั้นจึงสามารถกำหนดขีดจำกัด CPU ให้สูงได้อย่างปลอดภัย ขณะที่ขีดจำกัดหน่วยความจำต้องเผื่อพื้นที่ไว้
cpu_shares เป็นเครื่องมืออีกประเภทหนึ่ง คือค่าน้ำหนักสัมพัทธ์ที่มีผลเฉพาะเมื่อ CPU ถูกใช้งานจนเต็มจริง ๆ คอนเทนเนอร์ 2 รายการที่มีค่า shares เป็น 1024 และ 512 จะแบ่ง CPU core ที่มีงานเต็มโดยประมาณในอัตรา 2 ต่อ 1 และเมื่อเครื่องไม่มีภาระงาน ทั้ง 2 รายการจะไม่ถูกจำกัด ใช้ shares เพื่อจัดลำดับความสำคัญของ service และใช้ cpus เมื่อต้องการกำหนดเพดานที่แท้จริง เช่น ป้องกันไม่ให้งานแปลงไฟล์วิดีโอช่วงกลางคืนใช้ทรัพยากรมากจน web server ทำงานต่อไม่ได้
FAQ
deploy.resources.limits ทำงานโดยไม่มี Docker Swarm หรือไม่
ทำงาน Compose V2 จะใช้ deploy.resources.limits และ deploy.resources.reservations เมื่อคุณเรียกใช้ docker compose up บนโฮสต์เดียว ตรวจสอบได้ด้วย docker inspect --format '{{.HostConfig.Memory}}' <container> ซึ่งจะแสดงขีดจำกัดเป็นไบต์ และแสดง 0 เมื่อไม่ได้ใช้ขีดจำกัด คีย์ภายใน deploy ที่ต้องใช้ Swarm จริง ๆ ได้แก่ mode, placement, update_config และ endpoint_mode
รหัสการออก 137 ใน Docker Compose หมายถึงอะไร
หมายความว่ากระบวนการหลักได้รับ SIGKILL เนื่องจาก 137 เท่ากับ 128 บวกกับ signal 9 สาเหตุที่พบบ่อยคือ kernel OOM killer แต่ timeout ในการปิดระบบก็ทำให้ได้รหัสเดียวกันเมื่อแอปพลิเคชันไม่ตอบสนองต่อ SIGTERM เรียกใช้ docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container> เพื่อแยกสาเหตุทั้งสองกรณี true 137 หมายถึงการหยุดกระบวนการเนื่องจากหน่วยความจำไม่เพียงพอ ส่วน false 137 ไม่ได้หมายถึงกรณีดังกล่าว
ควรกำหนด mem_limit หรือ deploy.resources.limits.memory
ใช้รายการใดรายการหนึ่งได้กับ docker compose deploy.resources.limits.memory เป็นรูปแบบปัจจุบันของ Compose Specification และเป็นค่าเริ่มต้นที่เหมาะสมกว่าสำหรับไฟล์ใหม่ ให้คง mem_limit ไว้ หากส่วนอื่นของไฟล์ใช้คีย์ระดับบนสุดแบบเก่าอยู่แล้ว การกำหนดทั้งสองรายการให้กับ service เดียวจะทำให้ไฟล์อ่านยากขึ้น ดังนั้นให้เลือกใช้เพียงรายการเดียวและตรวจสอบผลลัพธ์ด้วย docker inspect
เหตุใดคอนเทนเนอร์จึงใช้หน่วยความจำจนถึงขีดจำกัดเต็ม โดยไม่ถูกหยุดการทำงาน
ค่าการใช้งานใน docker stats รวม page cache ด้วย ซึ่ง kernel จะปล่อยคืนเมื่อมีแรงกดดันด้านหน่วยความจำ แทนที่จะเรียกใช้ OOM kill เรียกใช้ docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat และอ่านค่า anon ซึ่งเป็น working set ที่ไม่สามารถเรียกคืนได้ หากค่า file สูง ขณะที่ค่า anon ต่ำ แสดงว่าคอนเทนเนอร์กำลังทำ disk input และ output ไม่ใช่คอนเทนเนอร์ที่กำลังจะหยุดทำงาน
ควรเหลือ RAM ที่ไม่ได้จัดสรรไว้เท่าใดบน VPS ขนาด 8GB
เหลือไว้ประมาณ 1GB สำหรับ kernel, Docker daemon, sshd, journald และ shell ของคุณ จากนั้นให้ผลรวมของขีดจำกัดคอนเทนเนอร์ทั้งหมดต่ำกว่า 7GB ที่เหลือ ตรวจสอบค่าสูงสุดของ anon ต่อคอนเทนเนอร์ภายใต้โหลดจริงเป็นเวลา 1 วันก่อนกำหนดค่า และให้ถือผลรวมดังกล่าวเป็นงบประมาณ ไม่ใช่เป้าหมายที่ต้องใช้ให้เต็ม