Cách giới hạn RAM cho Docker Compose tránh lỗi OOM
Hướng dẫn thiết lập deploy.resources để container không chiếm hết RAM trên VPS. Tránh lỗi exit 137 và ngăn chặn việc hệ thống tự tiêu diệt tiến trình quan trọng do thiếu bộ nhớ.
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ức trần cứng mà nhân Linux áp đặt lên cgroup (control group, tính năng của nhân dùng để đo lường tài nguyên cho một nhóm tiến trình) của một container. Bạn thiết lập deploy.resources.limits.memory cho một service và container đó sẽ không bao giờ được phép sử dụng quá con số bạn đã ghi. Khi container cố gắng vượt mức này, nhân Linux sẽ tiêu diệt một tiến trình bên trong container đó, 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ớ dư thừa từ host để vay mượn. Một container bị rò rỉ bộ nhớ hoặc thực hiện một truy vấn lỗi sẽ chiếm dụng mọi trang bộ nhớ trống trên một máy chủ 8GB. Khi đó, nhân Linux sẽ tiêu diệt bất kỳ tiến trình nào mà nó đánh giá là "tệ nhất", thường là cơ sở dữ liệu hoặc phiên SSH của bạn thay vì container gây ra vấn đề. Các giới hạn giúp chuyển đổi sự cố 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 thực hiện thành công. Nếu bạn chưa quen với file compose, các kiến thức cơ bản về Docker Compose cho VPS sẽ giải thích về cấu trúc file mà hướng dẫn này dựa trên đó.
deploy.resources.limits hay mem_limit: cái nào được áp dụng
Có hai cách viết cho cùng một ý tưởng, đây là lý do gây nhầm lẫn.
mem_limit, mem_reservation, memswap_limit, cpus và cpu_shares là các khóa dịch vụ cấp cao nhất được kế thừa từ các định dạng file Compose cũ. deploy.resources xuất phát từ schema của Swarm và hiện là một phần của Compose Specification, định dạng mà docker compose đọc ngày nay.
Cả hai đều hoạt động trên một host đơn lẻ. Compose V2, plugin docker compose, áp dụng deploy.resources.limits và deploy.resources.reservations khi bạn chạy docker compose up, mà không cần bất kỳ cluster Swarm nào. Các phần chỉ dành cho Swarm trong khối deploy là các khóa còn lại: mode, placement, update_config và endpoint_mode có ý nghĩa với docker stack deploy và 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 tiểu mục resources, và làm theo nó sẽ khiến các dịch vụ của bạn không có giới hạn nào cả.
Hãy chọn một cách viết cho mỗi dự án. Việc viết cả mem_limit: 512m và deploy.resources.limits.memory: 1g trên cùng một dịch vụ sẽ tạo ra một file mà không ai có thể đọc nhanh được. Thay vì đoán xem con số nào được áp dụng, hãy hỏi daemon:
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemoryReservation}} {{.HostConfig.NanoCpus}}' app-1Các giá trị bộ nhớ được tính bằng byte, vì vậy 1g hiển thị là 1073741824. CPU được tính bằng nano CPU, vì vậy 1.5 hiển thị là 1500000000. Một 0 trong bất kỳ trường nào nghĩa là không có giới hạn nào được thiết lập. Giới hạn bộ nhớ nhỏ nhất mà Docker chấp nhận là 6m, và thấp hơn mức đó container sẽ từ chối khởi động.
Điều gì xảy ra khi container chạm ngưỡng giới hạn
Container không chạy chậm lại. Nó bị dừng đột ngột.
Khi một tiến trình yêu cầu một page và cgroup đã đạt đến memory.max, kernel trước tiên sẽ thu hồi những gì có thể bên trong cgroup đó: clean page cache, sau đó là các page có thể swap. Nếu việc thu hồi không giải phóng đủ bộ nhớ, trình OOM (out of memory) killer của cgroup sẽ chọn một tiến trình bên trong container và gửi tín hiệu SIGKILL. Việc tiêu diệt PID 1 của container sẽ làm container đó kết thúc. Exit code 137 đơn giản là 128 cộng với tín hiệu 9, vì vậy 137 là dấu hiệu của bất kỳ SIGKILL nào, không phải là bằng chứng riêng biệt cho lỗi OOM.
docker compose ps -a
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' app-1true 137 là một trường hợp OOM kill. false 137 có nghĩa là một tác nhân khác đã gửi SIGKILL, và nguyên nhân thường gặp là docker compose stop đã chạm ngưỡng thời gian chờ mười giây vì ứng dụng phớt lờ SIGTERM. Sự phân biệt này giúp tiết kiệm hàng giờ đồng hồ, vì hai vấn đề này hoàn toàn không liên quan đến nhau.
Có hai nơi khác ghi lại sự kiện này. Hãy theo dõi daemon trực tiếp:
docker events --filter event=oomSau đó đọc log của kernel, đây là bản ghi tồn tại sau khi khởi động lại:
sudo dmesg -T | grep -i -E 'memory cgroup out of memory|killed process'Một lệnh kill từ cgroup sẽ in ra một dòng bắt đầu bằng Memory cgroup out of memory: Killed process 24713 (node). Một dòng không có tiền tố Memory cgroup là lỗi OOM của host, nghĩa là bản thân máy chủ đã hết RAM. Đây là lỗi mà các giới hạn được thiết lập để ngăn chặn, vì vậy nếu thấy nó, đó là dấu hiệu cho thấy tổng các giới hạn của bạn quá cao, hoặc một số dịch vụ không được thiết lập giới hạn nào cả.
Với restart: unless-stopped, một vòng lặp OOM rất khó phát hiện, vì dịch vụ trông có vẻ đang chạy trong docker compose ps chỉ một giây sau khi nó 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 một healthcheck báo cáo ứng dụng không khỏe để một container liên tục bị chết có thể được nhìn thấy mà không cần bạn phải theo dõi trực tiếp.
Reservation chỉ là 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 tối thiểu mềm. Docker mô tả đây là một giới hạn mềm được kích hoạt khi daemon phát hiện sự tranh chấp hoặc tình trạng thiếu bộ nhớ trên host. Nó không bao giờ ngăn container vượt quá mức này và cũng không đảm bảo bộ nhớ sẽ khả dụ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 không tự bảo vệ được gì cả. Hãy dùng nó để đánh dấu dịch vụ bạn muốn được ưu tiên khi hệ thống chịu áp lực, 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, một cách trung thực
Hầu hết các image VPS khi phát hành đều 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ị bằng nhau khiến container không có swap. Việc đặt mem_limit và để memswap_limit không thiết lập cho phép container swap tối đa bằng kích thước 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à tính năng này 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 được á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 chậm hơn chứ không phải ít 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ễ, một giới hạn chính xác không có swap sẽ thất bại nhanh hơn và có thể dự đoán được hơn.
Tại sao mức sử dụng bộ nhớ trông có vẻ cao hơn thực tế
Số liệu MEM USAGE trong docker stats bao gồm cả page cache, vì vậy một container đọc các tệp tin lớn sẽ tăng dần mức sử dụng cho đến gần giới hạn và duy trì ở đó. Đây là hiện tượng bình thường và không phải là rò rỉ bộ nhớ, vì cache sạch sẽ được thu hồi trước khi OOM killer được kích hoạt. Một dịch vụ như máy chủ media Jellyfin tự lưu trữ sẽ luôn hiển thị mức sử dụng gần ngưỡng giới hạn vì lý do này.
Hãy tách con số này thành cache và tập hợp làm việc thực tế (working set) 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à bộ nhớ ẩn danh (anonymous memory), tập hợp làm việc không thể bị xóa bỏ. file là page cache, phần có thể bị xóa. Hãy đặt giới hạn dựa trên anon cộng thêm một khoảng dự phòng, thay vì dựa trên tổng số. Tệp memory.events sẽ giải quyết vấn đề này một cách dứt khoát: bộ đếm oom_kill lớn hơn không có nghĩa là kernel đã tiêu diệt một tiến trình nào đó trong container này kể từ khi khởi động, và bộ đếm max tăng dần có nghĩa là container hiện đang bị giữ ở ngưỡng giới hạn. Cả hai lệnh này đều yêu cầu shell và coreutils bên trong image, vì vậy chúng sẽ không hoạt động trên các image distroless hoặc scratch.
Giới hạn kích thước trên VPS 8GB
Hãy bắt đầu từ host, đừng bắt đầu từ ứng dụng. Trên một VPS 8GB, hãy để dành khoảng 1GB cho kernel, Docker daemon, sshd, journald và shell đăng nhập của bạn. Bạn còn lại khoảng 7GB để phân bổ, và tổng giới hạn của mọi container phải nằm trong mức này. Việc cấp phát vượt mức (overcommitting) vẫn hoạt động bình thường cho đến ngày hai dịch vụ cùng đạt đỉnh tải.
Một cách phân bổ khả thi trên máy 8GB:
- Reverse proxy: giới hạn 128m. Đây là tiến trình nhỏ, và giới hạn chặt chẽ này giúp phát hiện ngay lập tức lỗi reload cấu hình gây treo.
- PostgreSQL: giới hạn 2g, với
shared_buffersđược đặt khoảng 512MB trong cấu hình cơ sở dữ liệu. - Container ứng dụng: giới hạn 1g.
- Background worker: giới hạn 512m.
- Dịch vụ media hoặc file: giới hạn 2g, phần lớn sẽ là page cache.
Đừng sao chép các con số này vào stack của bạn. Hãy chạy các dịch vụ dưới tải thực tế trong một ngày, theo dõi docker stats, lấy giá trị anon đỉnh của mỗi container và cộng thêm khoảng một nửa làm khoảng dự phòng. Đặt giới hạn quá chặt còn tệ hơn là không đặt giới hạn, vì nó sẽ giết chết một dịch vụ đang hoạt động bình thường trong lúc lưu lượng truy cập tăng đột biến.
Có một cái bẫy cần lưu ý riêng. Giới hạn này vô hình với hầu hết các runtime trừ khi bạn khai báo cho chúng biết. PostgreSQL sẽ thoải mái đặt kích thước shared_buffers và work_mem vượt quá giới hạn container và bị giết. JVM (Java virtual machine) cần -XX:MaxRAMPercentage=75 để xác định 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, đặt thấp hơn giới hạn container, nếu không bộ thu gom rác (garbage collector) sẽ để heap tăng trưởng cho đến khi kernel can thiệp. Cgroup không thương lượng. Nó sẽ giết tiến trình.
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 hạn ngạch CFS (completely fair scheduler). Container nhận được 150ms thời gian CPU trong mỗi chu kỳ 100ms, được chia sẻ cho tất cả các luồng của nó. Khi sử dụng hết thời gian này, kernel sẽ bắt nó đợi đến chu kỳ tiếp theo.
Đó là sự tương phản 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. Do đó, 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ỉ quan trọng khi CPU thực sự bị quá tải. 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 một, và trên một máy đang rảnh rỗi thì không container nào bị hạn chế. Hãy sử dụng shares để xếp hạng mức độ ưu tiên của các dịch vụ, và sử dụng cpus khi bạn cần một mức trần thực sự, ví dụ như để ngăn chặn một tác vụ chuyển mã 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 mà không cần Docker Swarm không?
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 đơn lẻ. Xác nhận điều này bằng docker inspect --format '{{.HostConfig.Memory}}' <container>, lệnh này sẽ in ra giới hạn theo byte và in 0 khi không có giới hạn nào được áp dụng. Các khóa bên trong deploy thực sự yêu cầu Swarm là mode, placement, update_config và endpoint_mode.
Mã thoát 137 trong Docker Compose có nghĩa là gì?
Nó có nghĩa là tiến trình chính đã nhận SIGKILL, vì 137 là 128 cộng với tín hiệu 9. OOM killer của kernel là nguyên nhân phổ biến, nhưng thời gian chờ tắt máy cũng tạo ra mã tương tự khi một ứng dụng bỏ qua SIGTERM. Chạy docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container> để phân biệt chúng. true 137 là do bị giết vì thiếu bộ nhớ, 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à định dạng theo Compose Specification hiện tại và là lựa chọn mặc định tốt hơn cho một file mới. Giữ lại mem_limit nếu phần còn lại trong file của bạn đã sử dụng các khóa cấp cao nhất cũ hơn. Việc đặt cả hai trên một service chỉ làm file khó đọc hơn, vì vậy hãy chọn một và xác minh kết quả bằng docker inspect.
Tại sao container của tôi đạt giới hạn bộ nhớ tối đa mà không bị giết?
Số liệu sử dụng trong docker stats bao gồm page cache, thứ mà kernel sẽ giải phóng khi chịu áp lực 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à tập hợp làm việc (working set) không thể thu hồi. Giá trị file cao bên cạnh giá trị anon thấp nghĩa là container đang thực hiện nhập xuất dữ liệu (I/O), không phải là container sắp chết.
Tôi nên để dành bao nhiêu RAM không phân bổ trên một VPS 8GB?
Hãy để dành khoảng 1GB cho kernel, Docker daemon, sshd, journald và shell của chính bạn, sau đó giữ tổng giới hạn của tất cả các container dưới mức 7GB còn lại. Theo dõi giá trị anon cao nhất của mỗi container dưới tải thực tế trong một ngày trước khi chốt các con số, và hãy coi tổng số đó là một ngân sách thay vì một mục tiêu cần lấp đầy.