Cách giới hạn RAM và CPU cho Docker Compose trên VPS
Hướng dẫn cấu hình deploy.resources để ngăn lỗi OOM và mã 137. Đặt giới hạn cứng giúp container không chiếm hết RAM làm sập hệ thống hoặc ngắt kết nối SSH của bạn.
Giới hạn bộ nhớ trong Docker Compose hoạt động như thế nào
Giới hạn bộ nhớ trong Docker Compose là một ngưỡng cứng mà Linux kernel áp đặt lên cgroup (control group, tính năng kernel dùng để đo lường tài nguyên cho một nhóm tiến trình) của một container. Thiết lập deploy.resources.limits.memory cho một service thì container đó sẽ không bao giờ được phép sử dụng quá con số bạn đã chỉ định. Khi container cố vượt ngưỡng này, kernel sẽ kill một tiến trình bên trong nó và container thường sẽ thoát với mã lỗi 137.
Điều này đặc biệt quan trọng trên VPS, nơi dung lượng RAM là cố định và không có bộ nhớ host dư thừa để bù đắp. Một container bị rò rỉ bộ nhớ (memory leak) hoặc thực hiện một truy vấn lỗi có thể chiếm dụng toàn bộ các trang bộ nhớ trống trên một máy chủ 8GB. Khi đó, kernel sẽ kill bất kỳ tiến trình nào mà nó đánh giá là "tệ nhất", thường là database hoặc phiên SSH của bạn thay vì container gây ra sự cố. Việc đặt giới hạn giúp chuyển đổi tình trạng sập toàn bộ máy chủ thành việc chỉ một service bị khởi động lại.
services:
app:
image: ghcr.io/example/app:1.4
deploy:
resources:
limits:
cpus: "1.5"
memory: 1g
reservations:
memory: 256mÁp dụng cấu hình và xác nhận giới hạn đã có hiệu lực:
docker compose up -d
docker stats --no-streamCột MEM USAGE / LIMIT sẽ hiển thị giá trị tương tự như 142MiB / 1GiB. Nếu cột giới hạn hiển thị toàn bộ dung lượng RAM của host, nghĩa là thiết lập chưa được áp dụng và phần còn lại của hướng dẫn này sẽ không có tác dụng cho đến khi bạn sửa được lỗi đó. Nếu bạn mới làm quen với file compose, các kiến thức cơ bản về Docker Compose cho VPS sẽ bao gồm cấu trúc file mà hướng dẫn này dựa trên đó.
deploy.resources.limits hoặc mem_limit: cái 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 service key ở cấp cao nhất, được kế thừa từ những định dạng Compose file cũ hơn. deploy.resources bắt nguồn từ schema của Swarm và hiện là một phần của Compose Specification, tức định dạng mà docker compose đọc hiện nay.
Cả 2 cách đều hoạt động trên một host. Docker Compose, 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. Plugin này hiện ở dòng v5 (v5.6.0 là bản release ổn định mới nhất tại thời điểm kiểm tra vào 2026-10-10). Plugin chuyển thẳng từ v2 lên v5, bỏ qua v3 và v4 để tránh nhầm với các phiên bản Compose file cũ. Không có bản release v5 nào thay đổi cách xử lý resource limit. Các phần hướng đến Swarm trong block deploy là những key khác, chẳng hạn placement, update_config và endpoint_mode. Chúng mô tả việc scheduling và rollout trên cluster gồm nhiều node cho docker stack deploy. 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ó limit.
Chọn một cách viết cho mỗi project. Việc ghi mem_limit: 512m và deploy.resources.limits.memory: 1g trên cùng một service không chỉ khó đọc. Compose Specification quy định rằng khi đặt mem_limit thì giá trị này phải nhất quán với limits.memory, nhưng không nói giá trị nào được ưu tiên nếu 2 giá trị khác nhau. Dù dùng cách viết nào, hãy xác nhận giá trị daemon thực sự đã áp dụng:
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemoryReservation}} {{.HostConfig.NanoCpus}}' app-1Giá trị memory được tính bằng bytes, nên 1g sẽ in ra 1073741824. CPU được tính bằng nano CPU, nên 1.5 sẽ in ra 1500000000. 0 trong bất kỳ field nào có nghĩa là chưa đặt limit. Memory limit nhỏ nhất mà Docker chấp nhận là 6m. Nếu 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ó sẽ dừng.
Khi một process yêu cầu một page và cgroup đã đạt memory.max, kernel trước tiên sẽ reclaim những gì có thể trong cgroup đó: page cache sạch, sau đó là các page có thể swap. Nếu reclaim 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 đó. Khi PID 1 của container bị kill, container sẽ kết thúc. Exit code 137 đơn giản là 128 cộng signal 9, vì vậy 137 là dấu hiệu của bất kỳ SIGKILL nào, không tự nó chứng minh có OOM.
docker compose ps -a
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' app-1true 137 là một lần OOM kill. false 137 thường có nghĩa là một thành phần khác đã gửi SIGKILL (một program cũng có thể chủ động thoát với code 137, nhưng trường hợp này hiếm), và nguyên nhân thường là docker compose stop đã hết thời gian grace period 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 nhiều giờ xử lý, vì hai vấn đề không liên quan đến nhau.
Sự kiện này còn được ghi ở 2 nơi khác. Theo dõi daemon theo thời gian thực:
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 cgroup kill sẽ in ra một dòng bắt đầu bằng Memory cgroup out of memory: Killed process 24713 (node). Dòng này có nghĩa là một container đã chạm giới hạn riêng của nó. Dòng này vẫn có thể xuất hiện khi host còn rất nhiều RAM trống. Dòng không có prefix Memory cgroup là host OOM. Điều đó có 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ếu thấy lỗi này, thường là tổng các giới hạn quá cao, một số service hoàn toàn không có giới hạn, hoặc một thành phần bên ngoài Docker đang dùng nhiều RAM hơn phần headroom bạn để lại.
Với restart: unless-stopped, một OOM loop rất khó nhận ra vì service hiển thị là đang chạy trong docker compose ps chỉ một giây sau khi đã chết. 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 healthcheck báo app ở trạng thái không khỏe mạnh, để nhận biết container liên tục bị dừng mà không cần tự theo dõi.
Reservation là một gợi ý, limit mới là quy tắc
reservations.memory (phiên bản cũ hơn là mem_reservation) là một ngưỡng mềm. Docker mô tả đây là giới hạn mềm được kích hoạt khi daemon phát hiện sự tranh chấp tài nguyên hoặc bộ nhớ trên host ở mức thấp. Nó không bao giờ ngăn container vượt quá ngưỡng này, và cũng không đảm bảo bộ nhớ sẽ luôn sẵn sàng khi container yêu cầu. Nó chỉ ưu tiên kernel thu hồi bộ nhớ từ các container đang vượt quá mức reservation trước.
Vì vậy, reservation tự nó không bảo vệ được gì cả. Hãy dùng nó để đánh dấu các service bạn muốn được ưu tiên khi hệ thống chịu tải, và dựa vào limit để đảm bảo an toàn. Hãy giữ mức reservation thấp hơn limit, nếu không container sẽ không khởi động được: Docker sẽ từ chối cấu hình với lỗi Minimum memory limit can not be less than memory reservation limit.
Swap accounting, cần sự trung thực
Hầu hết các image VPS được phát hành mà không có swap file. Hãy 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ỉ là giới hạn RAM thuần túy.
memswap_limit không phải là dung lượng swap. Đó là tổng của bộ nhớ cộng với swap. Với mem_limit: 1g và memswap_limit: 2g, container nhận được 1GB RAM và 1GB swap. Việc đặt hai giá trị này bằng nhau khiến container không có swap. Thiết lập mem_limit và để trống memswap_limit cho phép container sử dụng swap tối đa bằng với giới hạn bộ nhớ của nó.
Ubuntu 24.04 và Debian 13 sử dụng cgroup v2 theo mặc định, trong đó swap là một bộ đếm riêng biệt (memory.swap.max) và hoạt động mà không cần thiết lập thêm. Thông báo cũ Your kernel does not support swap limit capabilities xuất phát từ các host cgroup v1 khởi động mà không có swapaccount=1. Trên các host đó, giới hạn bộ nhớ vẫn áp dụng trong khi phần swap bị bỏ qua.
Hãy trung thực về những gì swap mang lại cho bạn. Nó làm cho quá trình OOM kill diễn ra chậm hơn, chứ không phải ít khả năng xảy ra hơn, vì một tiến trình bị rò rỉ bộ nhớ sẽ lấp đầy swap cũng nhanh như khi nó lấp đầy RAM. Trong khi đó, một container liên tục ghi/đọc swap trên bộ lưu trữ VPS dùng chung sẽ làm chậm mọi dịch vụ khác trên máy chủ. Đối với bất kỳ tác vụ nào nhạy cảm với độ trễ, việc đặt giới hạn chính xác mà không có swap sẽ giúp hệ thống fail nhanh hơn và có thể dự đoán được hơn.
Vì sao mức sử dụng memory trông cao hơn thực tế
Trên Linux, chỉ số MEM USAGE trong docker stats chỉ trừ page cache không hoạt động (inactive_file trên cgroup v2). Vì vậy, cache vừa được sử dụng vẫn được tính vào mức dùng memory. Một container đọc các file lớn sẽ tăng dần đến gần limit rồi duy trì ở đó. Đây là hành vi bình thường, không phải memory leak, vì clean cache được thu hồi trước khi OOM killer được gọi. Một service như media server Jellyfin tự host sẽ luôn trông như đang gần chạm ngưỡng vì chính lý do này.
Hãy tách con số này thành cache và working set thực 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ể 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 dự phòng, không dựa trên tổng số. File memory.events đưa ra bằng chứng rõ ràng: bộ đếm 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 bộ đếm max tăng dần nghĩa là container hiện đang bị giữ ở mức ceiling. 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ừ host, không phải từ các app. Trên VPS 8GB, hãy chừa 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 mọi container phải thấp hơn mức này. Việc cấp phát vượt mức vẫn hoạt động cho đến ngày hai service cùng đạt đỉnh.
Một cách phân bổ phù hợp 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 việc 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, trong đó phần lớn sẽ là page cache.
Không sao chép các con số này vào stack của bạn. Hãy chạy các service dưới tải thực tế trong một ngày, monitor docker stats, lấy giá trị đỉnh anon 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 ở mức bình thường.
Có một bẫy cần lưu ý riêng. Một số runtime bỏ qua giới hạn nếu bạn không thông báo cho chúng, còn những runtime có đọc giới hạn cũng có thể không tự sizing theo cách bạn mong đợi. PostgreSQL có thể thoải mái đặt shared_buffers và work_mem vượt quá giới hạn của container rồi bị kill. JVM hiện đại (Java virtual machine) mặc định phát hiện giới hạn cgroup, nhưng các bản build HotSpot như OpenJDK sau đó chỉ cấp 25 percent giới hạn này cho heap. Vì vậy, -XX:MaxRAMPercentage=75 thường là cách để cho heap sử dụng nhiều hơn trong giới hạn, đồng thời vẫn chừa chỗ cho memory bên ngoài heap. Node.js đã lấy giới hạn cgroup làm cơ sở để sizing heap mặc định từ version 12, ở mức khoảng một nửa giới hạn container và ổn định quanh 2GB. Vì vậy, chỉ đặt --max-old-space-size theo megabyte khi muốn dùng một ceiling khác, và giữ giá trị này thấp hơn giới hạn container để garbage collector hoạt động trước khi kernel can thiệp. Ollama cũng tương tự nhưng dùng một knob 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 biệt
cpus: "1.5" có nghĩa là 150% của một nhân, được thực thi dưới dạng quota CFS (completely fair scheduler). Container nhận được 150ms thời gian CPU trong mỗi chu kỳ 100ms, chia sẻ cho tất cả các thread của nó. Khi dùng hết lượng này, kernel sẽ bắt nó đợi đến chu kỳ tiếp theo.
Đây là điểm khác biệt quan trọng. Một container vượt quá giới hạn bộ nhớ sẽ bị kill. Một container vượt quá giới hạn CPU sẽ bị throttled và tiếp tục chạy, nhưng chậm hơn. Vì vậy, việc đặt giới hạn CPU một cách quyết liệt là an toàn, trong khi giới hạn bộ nhớ cần có khoảng dự phòng.
cpu_shares là một công cụ khác: một trọng số tương đối chỉ có tác dụng khi CPU thực sự bị bão hòa. Hai container với shares là 1024 và 512 sẽ chia sẻ một nhân đang bận theo tỷ lệ khoảng hai trên một, và trên một máy chủ đang rảnh rỗi thì không container nào bị hạn chế. Hãy dùng shares để xếp hạng mức độ ưu tiên của các service, và dùng cpus khi bạn cần một mức trần thực sự, ví dụ để ngăn một tác vụ transcode chạy đêm làm cạn kiệt tài nguyên của web server.
FAQ
deploy.resources.limits có hoạt động khi không dùng Docker Swarm không?
Có. Docker Compose, plugin docker compose (hiện đang ở dòng v5), á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 nếu không áp dụng giới hạn. Các key bên trong deploy được viết với Swarm là các key dành cho cluster, chẳng hạn placement, update_config và endpoint_mode.
Mã thoát 137 trong Docker Compose có nghĩa là gì?
Gần như luôn có nghĩa là process chính đã nhận SIGKILL, vì 137 bằng 128 cộng signal 9. Chương trình cũng có thể chủ động thoát với mã 137, nhưng trường hợp này hiếm. Nguyên nhân phổ biến là kernel OOM killer. Tuy nhiên, shutdown timeout cũng tạo ra cùng mã này khi 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.
Tôi nên đặt mem_limit hay deploy.resources.limits.memory?
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 top-level cũ. Compose Specification yêu cầu hai giá trị này nhất quán khi cùng được đặt, vì vậy hãy chọn một cách và kiểm tra kết quả bằng docker inspect.
Vì sao container của tôi vẫn dùng đến giới hạn memory tối đa mà không bị kill?
Giá trị usage trong docker stats vẫn tính cả page cache vừa được sử dụng. Kernel sẽ giải phóng page cache này khi có áp lực memory, 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 đi cùng giá trị anon thấp cho thấy container đang thực hiện input/output trên disk, không phải sắp bị kill.
Tôi nên để trống bao nhiêu RAM trên một VPS 8GB?
Để lại 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 xem tổng này là một ngân sách, không phải dung lượng cần dùng hết.