Docker Compose giới hạn memory để tránh lỗi OOM
Đặt giới hạn memory và CPU trong Docker Compose để container lỗi không hạ VPS. Phân biệt deploy.resources, mem_limit, lỗi exit 137, swap và cách sizing.
Giới hạn memory trong Docker Compose làm gì
Giới hạn memory trong Docker Compose là mức trần cứng mà Linux kernel áp dụng cho cgroup của một container (control group, tính năng của kernel dùng để đo tài nguyên cho một nhóm process). Đặt deploy.resources.limits.memory cho một service thì container đó không bao giờ được dùng quá số lượng bạn đã ghi. Khi vượt mức, kernel sẽ kill một process bên trong container và container thường thoát với code 137.
Điều này đặc biệt quan trọng trên VPS, nơi dung lượng RAM cố định và không có memory dự phòng của host để mượn. Một container bị memory leak hoặc chạy query lỗi có thể chiếm toàn bộ page còn trống trên máy 8GB. Sau đó kernel sẽ kill process mà nó đánh giá là tệ nhất. Process đó thường là database hoặc session SSH của bạn, thay vì container gây ra vấn đề. Giới hạn biến sự cố toàn bộ server thành sự cố của một service có thể restart.
services:
app:
image: ghcr.io/example/app:1.4
deploy:
resources:
limits:
cpus: "1.5"
memory: 1g
reservations:
memory: 256mÁp dụng và xác nhận giới hạn đang có hiệu lực:
docker compose up -d
docker stats --no-streamCột MEM USAGE / LIMIT phải hiển thị giá trị tương tự 142MiB / 1GiB. Nếu cột giới hạn hiển thị toàn bộ RAM của host, cấu hình chưa được áp dụng. Phần còn lại của hướng dẫn này sẽ không giúp được gì cho đến khi cấu hình hoạt động. Nếu compose file còn mới với bạn, phần kiến thức cơ bản về Docker Compose cho VPS trình bày cấu trúc file mà hướng dẫn này sử dụng.
deploy.resources.limits hay mem_limit: mục nào được áp dụng
Có 2 cách viết cho cùng một ý, nên phần này dễ gây nhầm lẫn.
mem_limit, mem_reservation, memswap_limit, cpus và cpu_shares là các key service ở cấp cao nhất, được kế thừa từ những định dạng Compose cũ hơn. deploy.resources xuất phát từ schema của Swarm và hiện là một phần của Compose Specification, là định dạng mà docker compose đọc hiện nay.
Cả 2 cách đều hoạt động trên một host. Compose V2, plugin docker compose, áp dụng deploy.resources.limits và deploy.resources.reservations khi bạn chạy docker compose up, ngay cả khi không có Swarm cluster nào. Các phần chỉ dành cho Swarm trong block deploy là những key còn lại: mode, placement, update_config và endpoint_mode có ý nghĩa với docker stack deploy nhưng bị docker compose up bỏ qua. Vì vậy, lời khuyên phổ biến rằng “deploy cần Swarm” là sai đối với subsection resources. Làm theo lời khuyên đó sẽ khiến service của bạn hoàn toàn không có giới hạn.
Mỗi project chỉ nên chọn một cách viết. Viết mem_limit: 512m và deploy.resources.limits.memory: 1g trên cùng một service khiến file khó đọc ngay từ cái nhìn đầu tiên. Thay vì đoán giá trị nào được áp dụng, hãy hỏi daemon:
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemoryReservation}} {{.HostConfig.NanoCpus}}' app-1Giá trị memory được tính bằng byte, nên 1g sẽ hiển thị là 1073741824. CPU được tính bằng nano CPU, nên 1.5 sẽ hiển thị là 1500000000. Giá trị 0 trong bất kỳ field nào nghĩa là chưa đặt giới hạn. Giới hạn memory nhỏ nhất mà Docker chấp nhận là 6m. Thấp hơn mức này, container sẽ không khởi động.
Điều gì xảy ra khi container chạm giới hạn
Container không chạy chậm lại. Nó dừng.
Khi một process yêu cầu một page nhưng cgroup đã chạm memory.max, kernel trước tiên sẽ thu hồi những gì có thể trong cgroup đó: page cache sạch, sau đó là các page có thể swap. Nếu việc thu hồi không giải phóng đủ bộ nhớ, OOM (out of memory) killer của cgroup sẽ chọn một process bên trong container và gửi SIGKILL cho process đó. Việc kill PID 1 của container sẽ kết thúc container. Exit code 137 đơn giản là 128 cộng với signal 9, nên 137 là dấu hiệu của mọi SIGKILL, không tự nó chứng minh đã xảy ra OOM.
docker compose ps -a
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' app-1true 137 là OOM kill. false 137 có nghĩa là có thành phần khác đã gửi SIGKILL, và nguyên nhân thường gặp là docker compose stop chạm thời gian chờ 10 giây vì app bỏ qua SIGTERM. Phân biệt được hai trường hợp này giúp tiết kiệm hàng giờ xử lý sự cố, vì chúng không liên quan đến nhau.
Sự kiện này còn được ghi ở 2 nơi. Theo dõi daemon trực tiếp:
docker events --filter event=oomSau đó đọc kernel log. Đây là bản ghi vẫn còn sau khi restart:
sudo dmesg -T | grep -i -E 'memory cgroup out of memory|killed process'Một lần kill do cgroup sẽ in ra một dòng bắt đầu bằng Memory cgroup out of memory: Killed process 24713 (node). Dòng không có tiền tố Memory cgroup là OOM của host, nghĩa là chính máy đó đã hết RAM. Đây là lỗi mà các giới hạn cần ngăn chặn, nên nếu thấy lỗi này thì tổng các giới hạn đang quá cao hoặc một số service hoàn toàn không có giới hạn.
Với restart: unless-stopped, một vòng lặp OOM rất khó nhận ra vì service xuất hiện là đang chạy trong docker compose ps chỉ 1 giây sau khi nó dừng. Hãy kiểm tra cột uptime và số lần restart, đồng thời kết hợp giới hạn với một healthcheck báo app ở trạng thái không khỏe để phát hiện container liên tục dừng mà không cần tự theo dõi.
Reservation là gợi ý, limit mới là quy tắc
reservations.memory (mem_reservation cũ hơn) là mức sàn mềm. Docker mô tả đây là soft limit được kích hoạt khi daemon phát hiện host đang tranh chấp tài nguyên hoặc sắp hết memory. Nó không bao giờ ngăn container sử dụng vượt mức này, và cũng không bao giờ đảm bảo memory sẽ còn trống khi container yêu cầu. Nó chỉ khiến kernel ưu tiên reclaim từ những container đang vượt reservation trước.
Vì vậy, reservation không tự bảo vệ được gì. Hãy dùng nó để đánh dấu service cần được ưu tiên khi hệ thống chịu áp lực, và dựa vào limit để bảo đảm an toàn. Giữ reservation thấp hơn limit, nếu không container sẽ không khởi động: Docker từ chối cấu hình với Minimum memory limit can not be less than memory reservation limit.
Quản lý swap, nói thẳng
Phần lớn image VPS không có swap file. Chạy swapon --show và free -h. Nếu tổng swap bằng 0, mọi thiết lập liên quan đến swap bên dưới đều không có tác dụng, và giới hạn bộ nhớ của bạn chỉ giới hạn RAM.
memswap_limit không phải là dung lượng swap. Đây là tổng của RAM và swap. Với mem_limit: 1g và memswap_limit: 2g, container có 1GB RAM và 1GB swap. Đặt hai giá trị này bằng nhau sẽ khiến container không có swap. Đặt mem_limit và không khai báo memswap_limit sẽ cho phép container dùng swap tối đa bằng dung lượng của giới hạn bộ nhớ.
Ubuntu 24.04 và Debian 13 mặc định dùng cgroup v2, trong đó swap là một bộ đếm riêng (memory.swap.max), nên cơ chế này hoạt động mà không cần cấu hình thêm. Thông báo cũ Your kernel does not support swap limit capabilities xuất hiện trên các host cgroup v1 được boot mà không có swapaccount=1. Trên các host đó, giới hạn bộ nhớ vẫn được áp dụng, còn phần swap bị bỏ qua.
Hãy đánh giá đúng lợi ích của swap. Swap làm một tiến trình bị OOM kill chậm hơn, chứ không làm giảm khả năng xảy ra OOM kill, vì một tiến trình bị rò rỉ bộ nhớ sẽ lấp đầy swap cũng dễ như lấp đầy RAM. Trong khi đó, một container liên tục đọc ghi swap trên storage VPS dùng chung sẽ làm mọi service khác trên máy chậm theo. Với mọi workload nhạy cảm về độ trễ, đặt giới hạn phù hợp và tắt swap sẽ khiến lỗi xảy ra nhanh hơn và dễ dự đoán hơn.
Vì sao mức sử dụng memory trông cao hơn thực tế
Con số MEM USAGE trong docker stats bao gồm page cache, nên container đọc các file lớn sẽ tăng dần đến giới hạn rồi giữ ở mức đó. Đây là hành vi bình thường, không phải memory leak, vì clean cache sẽ được thu hồi trước khi OOM killer được gọi. Một service như Jellyfin media server tự host sẽ trông như luôn gần chạm giới hạn chính vì lý do này.
Tách số liệu thành cache và working set thực tế từ bên trong container:
docker compose exec app grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat
docker compose exec app cat /sys/fs/cgroup/memory.eventsanon là anonymous memory, tức working set không thể bị loại bỏ. file là page cache, có thể được thu hồi. Hãy đặt limit dựa trên anon cộng thêm một khoảng đệm, không dựa trên tổng số liệu. File memory.events cho câu trả lời dứt khoát: counter oom_kill lớn hơn 0 nghĩa là kernel đã kill một tiến trình trong container này kể từ khi container khởi động, còn counter max tăng dần nghĩa là container hiện đang bị giữ ở mức giới hạn. Cả hai lệnh đều cần shell và coreutils bên trong image, nên sẽ fail trên image distroless hoặc scratch.
Giới hạn tài nguyên trên VPS 8GB
Hãy bắt đầu từ máy chủ, không phải từ các ứng dụng. Trên VPS 8GB, hãy dành khoảng 1GB cho kernel, Docker daemon, sshd, journald và shell đăng nhập của bạn. Như vậy còn khoảng 7GB để phân bổ, và tổng giới hạn của tất cả container phải thấp hơn mức này. Overcommit vẫn hoạt động cho đến ngày hai service cùng đạt mức tải cao nhất.
Một cách phân bổ khả thi trên máy 8GB:
- Reverse proxy: giới hạn 128m. Đây là một process nhỏ, và giới hạn chặt như vậy sẽ phát hiện ngay một lần reload config bị runaway.
- PostgreSQL: giới hạn 2g, với
shared_buffersđặt khoảng 512MB trong database config. - Application container: giới hạn 1g.
- Background worker: giới hạn 512m.
- Media hoặc file service: giới hạn 2g, phần lớn sẽ được dùng làm page cache.
Không dùng nguyên các con số này cho stack của bạn. Hãy chạy các service dưới tải thực tế trong một ngày, theo dõi docker stats, lấy giá trị anon cao nhất của từng container rồi cộng thêm khoảng một nửa làm headroom. Đặt giới hạn quá chặt còn tệ hơn không đặt giới hạn, vì nó sẽ kill một service đang khỏe trong lúc traffic tăng đột biến nhưng vẫn bình thường.
Có một lỗi dễ mắc cần được nói riêng. Hầu hết runtime không biết về giới hạn này nếu bạn không khai báo cho chúng. PostgreSQL sẽ vô tư đặt shared_buffers và work_mem vượt quá giới hạn của container rồi bị kill. JVM (Java virtual machine) cần -XX:MaxRAMPercentage=75 để đặt kích thước heap dựa trên giới hạn cgroup thay vì RAM của host. Node.js cần --max-old-space-size tính bằng megabyte và phải thấp hơn giới hạn của container, nếu không garbage collector sẽ để heap tăng cho đến khi kernel can thiệp. Ollama cũng tương tự nhưng dùng một tùy chọn khác, vì tăng num_ctx sẽ làm KV cache tăng thêm hàng trăm megabyte và container sẽ chết giữa chừng khi xử lý một prompt dài. cgroup không thương lượng. Nó kill.
Giới hạn CPU hoạt động hoàn toàn khác
cpus: "1.5" có nghĩa là 150% của một core, được áp dụng dưới dạng quota CFS (completely fair scheduler). Container nhận 150ms thời gian CPU trong mỗi chu kỳ 100ms, dùng chung cho tất cả thread của nó. Khi dùng hết quota, kernel buộc container phải chờ đến chu kỳ tiếp theo.
Đây là điểm khác biệt quan trọng. Container vượt memory limit sẽ bị kill. Container vượt CPU limit sẽ bị throttle và vẫn tiếp tục chạy, chỉ chậm hơn. Vì vậy, có thể đặt CPU limit khá sát mức sử dụng thực tế, còn memory limit cần có phần headroom.
cpu_shares là một công cụ khác: trọng số tương đối, chỉ có tác dụng khi CPU thực sự bão hòa. Hai container có shares lần lượt là 1024 và 512 sẽ chia một core đang bận theo tỷ lệ xấp xỉ hai-một; trên máy đang rảnh, cả hai đều không bị giới hạn. Dùng shares để xếp hạng mức độ ưu tiên của các service, và dùng cpus khi cần một mức trần thực sự, chẳng hạn để ngăn một job transcode chạy ban đêm chiếm hết tài nguyên của web server.
FAQ
###
Có. Compose V2 áp dụng deploy.resources.limits và deploy.resources.reservations khi bạn chạy docker compose up trên một host duy nhất. Xác nhận bằng docker inspect --format '{{.HostConfig.Memory}}' <container>. Lệnh này in giới hạn theo byte và in 0 khi không áp dụng giới hạn. Các key bên trong deploy thực sự yêu cầu Swarm là mode, placement, update_config và endpoint_mode.
###
Điều này có nghĩa là process chính đã nhận SIGKILL, vì 137 bằng 128 cộng signal 9. Kernel OOM killer là nguyên nhân phổ biến, nhưng timeout khi shutdown cũng tạo cùng mã này nếu app bỏ qua SIGTERM. Chạy docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container> để phân biệt hai trường hợp. true 137 là memory kill, còn false 137 thì không.
###
Cả hai đều hoạt động với docker compose. deploy.resources.limits.memory là dạng hiện tại trong Compose Specification và là lựa chọn mặc định tốt hơn cho file mới. Giữ mem_limit nếu phần còn lại của file đã dùng các key cấp cao nhất kiểu cũ. Đặt cả hai trên cùng một service chỉ làm file khó đọc hơn, vì vậy hãy chọn một cách và xác minh kết quả bằng docker inspect.
###
Giá trị usage trong docker stats bao gồm page cache. Kernel sẽ loại bỏ page cache khi có áp lực bộ nhớ, thay vì kích hoạt OOM kill. Chạy docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat và đọc giá trị anon. Đây là working set không thể reclaim. Giá trị file cao cùng với giá trị anon thấp cho thấy container đang thực hiện input và output trên disk, không phải sắp bị kill.
###
Chừa khoảng 1GB cho kernel, Docker daemon, sshd, journald và shell của bạn. Sau đó giữ tổng giới hạn của tất cả container dưới 7GB còn lại. Theo dõi giá trị anon cao nhất của từng container dưới tải thực tế trong 1 ngày trước khi chốt các con số. Hãy coi tổng này là một ngân sách, không phải mục tiêu phải dùng hết.