محدودیت حافظه در Docker Compose برای جلوگیری از OOM
با تنظیم deploy.resources و mem_limit در Docker Compose از مصرف بیش از حد RAM توسط کانتینرها و کرش کردن کل سرور با خطای 137 جلوگیری کنید. راهنمای عملی مدیریت منابع VPS.
محدودیت حافظه در Docker Compose چه کاری انجام میدهد
محدودیت حافظه در Docker Compose یک سقف سختگیرانه است که هسته لینوکس روی cgroup (گروه کنترل، قابلیتی در هسته که منابع را برای مجموعهای از پردازشها اندازهگیری میکند) یک کانتینر اعمال میکند. مقدار deploy.resources.limits.memory را برای یک سرویس تنظیم کنید تا آن کانتینر هرگز نتواند بیش از عددی که نوشتهاید استفاده کند. هنگامی که کانتینر سعی میکند از این حد فراتر رود، هسته یکی از پردازشهای داخل کانتینر را میکشد و کانتینر معمولاً با کد 137 خارج میشود.
این موضوع در VPS اهمیت زیادی دارد، جایی که مقدار RAM ثابت است و حافظه اضافی برای قرض گرفتن از میزبان وجود ندارد. یک کانتینر با نشت حافظه یا یک کوئری مخرب، تمام صفحات آزاد را در یک سرور 8GB اشغال میکند. سپس هسته لینوکس هر پردازشی را که تشخیص دهد وضعیت بدتری دارد میکشد، که اغلب دیتابیس یا نشست SSH شماست، نه آن کانتینری که مشکل را ایجاد کرده است. محدودیتها باعث میشوند که خرابی کل سرور به توقف و راهاندازی مجدد یک سرویس محدود شود.
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 برای شما جدید است، اصول Docker Compose برای VPS ساختار فایلی که این تنظیمات بر پایه آن بنا شده را پوشش میدهد.
deploy.resources.limits یا mem_limit: کدامیک اعمال میشود
دو نگارش متفاوت برای یک مفهوم وجود دارد که باعث سردرگمی میشود.
mem_limit، mem_reservation، memswap_limit، cpus و cpu_shares کلیدهای سطح بالای سرویس هستند که از فرمتهای قدیمیتر فایل Compose به ارث رسیدهاند. deploy.resources از طرحواره Swarm آمده و اکنون بخشی از مشخصات Compose است که فرمتی است که docker compose امروزه میخواند.
هر دو روی یک میزبان واحد کار میکنند. Compose V2، یعنی همان پلاگین docker compose، وقتی docker compose up را اجرا میکنید، deploy.resources.limits و deploy.resources.reservations را اعمال میکند، بدون اینکه نیازی به کلاستر Swarm باشد. بخشهای مخصوص Swarm در بلوک deploy، کلیدهای دیگر هستند: mode، placement، update_config و endpoint_mode برای docker stack deploy معنا دارند و توسط docker compose up نادیده گرفته میشوند. بنابراین، این توصیه رایج که «deploy به Swarm نیاز دارد» برای زیربخش resources اشتباه است و پیروی از آن باعث میشود سرویسهای شما بدون هیچ محدودیتی باقی بمانند.
در هر پروژه، یکی از این دو نگارش را انتخاب کنید. نوشتن mem_limit: 512m و deploy.resources.limits.memory: 1g در یک سرویس باعث میشود فایلی داشته باشید که خواندن آن در یک نگاه برای کسی ممکن نباشد. بهجای حدس زدن اینکه کدام عدد اعمال شده است، از daemon بپرسید:
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemoryReservation}} {{.HostConfig.NanoCpus}}' app-1مقادیر حافظه بر حسب بایت هستند، بنابراین 1g به صورت 1073741824 نمایش داده میشود. CPU بر حسب نانو-CPU است، بنابراین 1.5 به صورت 1500000000 نمایش داده میشود. مقدار 0 در هر فیلد به این معنی است که هیچ محدودیتی تعیین نشده است. کوچکترین محدودیت حافظهای که Docker میپذیرد 6m است و کمتر از آن، container از شروع شدن خودداری میکند.
وقتی کانتینر به محدودیت میرسد چه اتفاقی میافتد
کانتینر کند نمیشود، بلکه متوقف میشود.
هنگامی که یک پردازش درخواست صفحه (page) میکند و cgroup در memory.max خود قرار دارد، هسته ابتدا آنچه را که میتواند در آن cgroup بازیابی میکند: کش صفحات تمیز (clean page cache) و سپس صفحاتی که قابل swap هستند. اگر این بازیابی فضای کافی آزاد نکند، قاتل OOM (کمبود حافظه) در cgroup یک پردازش را در داخل کانتینر انتخاب کرده و به آن SIGKILL میفرستد. کشتن PID 1 کانتینر باعث پایان یافتن کانتینر میشود. کد خروج 137 در واقع حاصل جمع 128 و سیگنال 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 به دوره مهلت ده ثانیهای است، زیرا برنامه SIGTERM را نادیده گرفته است. تشخیص این تفاوت ساعتها در زمان صرفهجویی میکند، زیرا این دو مشکل هیچ وجه اشتراکی با هم ندارند.
دو مکان دیگر نیز این رویداد را ثبت میکنند. دیمون را بهصورت زنده مشاهده کنید:
docker events --filter event=oomسپس لاگ هسته را بخوانید که رکوردی است که پس از راهاندازی مجدد باقی میماند:
sudo dmesg -T | grep -i -E 'memory cgroup out of memory|killed process'کشتن توسط cgroup خطی را چاپ میکند که با Memory cgroup out of memory: Killed process 24713 (node) شروع میشود. خطی که فاقد پیشوند Memory cgroup باشد، یک OOM در سطح میزبان (host) است؛ یعنی خود ماشین با کمبود RAM مواجه شده است. این همان شکستی است که محدودیتها برای جلوگیری از آن طراحی شدهاند، بنابراین مشاهده آن نشان میدهد که مجموع محدودیتهای شما بیش از حد است یا برخی سرویسها اصلاً محدودیتی ندارند.
با restart: unless-stopped، یک حلقه OOM به خوبی پنهان میماند، زیرا سرویس یک ثانیه پس از مرگ، در docker compose ps دوباره بالا میآید. ستون uptime و تعداد restart را بررسی کنید و محدودیت را با یک healthcheck که وضعیت ناسالم برنامه را گزارش میدهد جفت کنید تا کانتینری که مدام متوقف میشود، بدون نیاز به نظارت مستقیم شما، قابل مشاهده باشد.
رزرو یک پیشنهاد است، محدودیت یک قانون
reservations.memory (که در نسخههای قدیمیتر mem_reservation نامیده میشد) یک کف نرم (soft floor) است. Docker آن را به عنوان یک محدودیت نرم توصیف میکند که وقتی daemon با رقابت بر سر منابع یا کمبود حافظه روی میزبان مواجه میشود، فعال میگردد. این مقدار هرگز مانع از فراتر رفتن مصرف حافظه کانتینر از این حد نمیشود و تضمین نمیکند که هنگام درخواست کانتینر، حافظه حتماً آزاد باشد. این پارامتر فقط باعث میشود هسته سیستمعامل (kernel) اولویت آزادسازی حافظه را به کانتینرهایی بدهد که از حد رزرو خود فراتر رفتهاند.
بنابراین، رزرو به تنهایی از چیزی محافظت نمیکند. از آن برای مشخص کردن سرویسی استفاده کنید که میخواهید در شرایط فشار کاری با آن مدارا شود، و برای امنیت سیستم به محدودیت (limit) تکیه کنید. مقدار رزرو را همیشه کمتر از محدودیت نگه دارید، در غیر این صورت کانتینر اجرا نخواهد شد: Docker پیکربندی را با خطای Minimum memory limit can not be less than memory reservation limit رد میکند.
محاسبه Swap، بدون تعارف
بیشتر ایمیجهای VPS اصلاً فایلی برای swap ندارند. دستورات swapon --show و free -h را اجرا کنید. اگر مجموع swap صفر باشد، تمام تنظیمات مربوط به swap که در ادامه میآیند بیاثر هستند و محدودیت حافظه شما صرفاً یک سقف برای RAM خواهد بود.
مقدار memswap_limit نشاندهنده میزان swap نیست. این مقدار مجموع حافظه RAM و swap است. با تنظیم mem_limit: 1g و memswap_limit: 2g، کانتینر 1GB رم و 1GB فضای swap دریافت میکند. برابر قرار دادن این دو مقدار باعث میشود کانتینر عملاً هیچ 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 باعث میشود فرآیند OOM kill کندتر رخ دهد، نه اینکه احتمال آن کمتر شود؛ زیرا یک فرآیند دارای نشت حافظه (leaking process)، همانطور که RAM را پر میکند، swap را نیز پر خواهد کرد. در همین حال، کانتینری که در حال استفاده شدید از swap روی فضای ذخیرهسازی اشتراکی VPS است، سرعت تمام سرویسهای دیگر روی آن سرور را کاهش میدهد. برای هر سرویسی که به تأخیر (latency) حساس است، تعیین یک محدودیت دقیق بدون swap باعث میشود سرویس سریعتر و قابلپیشبینیتر با خطا مواجه شود.
چرا میزان مصرف حافظه بدتر از آنچه هست به نظر میرسد
عدد MEM USAGE در docker stats شامل page cache است، بنابراین کانتینری که فایلهای حجیم میخواند، مصرف حافظهاش به سمت حد مجاز بالا میرود و در همان سطح باقی میماند. این وضعیت عادی است و نشت حافظه (leak) محسوب نمیشود، زیرا cache تمیز پیش از آنکه OOM killer وارد عمل شود، بازپسگیری میشود. سرویسی مانند یک سرور رسانهای Jellyfin که به صورت self-hosted اجرا میشود دقیقاً به همین دلیل، همواره نزدیک به سقف حافظه خود دیده میشود.
عدد را از داخل کانتینر به دو بخش cache و working set واقعی تقسیم کنید:
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 به این بحث خاتمه میدهد: اگر شمارنده oom_kill عددی بزرگتر از صفر باشد، یعنی هسته سیستمعامل از زمان شروع کانتینر، چیزی را در آن کشته است و اگر شمارنده max در حال افزایش باشد، یعنی کانتینر در حال حاضر در سقف ظرفیت خود نگه داشته شده است. هر دو دستور به shell و coreutils در داخل image نیاز دارند، بنابراین در imageهای distroless یا scratch اجرا نمیشوند.
محدودیتهای اندازهگیری روی یک VPS با 8GB رم
از میزبان (host) شروع کنید، نه از برنامهها. روی یک VPS با 8GB رم، حدود 1GB را برای هسته (kernel)، دیمون Docker، sshd، journald و شل ورود خودتان کنار بگذارید. این کار حدود 7GB فضا برای تخصیص باقی میگذارد و مجموع محدودیتهای هر کانتینر باید زیر این مقدار بماند. بیشتخصیص (Overcommitting) تا روزی که دو سرویس همزمان به اوج مصرف برسند، بهخوبی کار میکند.
یک تقسیمبندی عملی روی یک سرور 8GB:
- Reverse proxy: محدودیت 128m. این یک پردازش کوچک است و چنین محدودیت سختگیرانهای، پیکربندیهای معیوب در زمان reload را بلافاصله شناسایی میکند.
- PostgreSQL: محدودیت 2g، با تنظیم
shared_buffersروی حدود 512MB در فایل پیکربندی دیتابیس. - کانتینر برنامه: محدودیت 1g.
- پردازش پسزمینه (Background worker): محدودیت 512m.
- سرویس رسانه یا فایل: محدودیت 2g، که بخش بزرگی از آن صرف page cache خواهد شد.
این اعداد را عیناً در stack خود کپی نکنید. سرویسها را یک روز تحت بار واقعی اجرا کنید، docker stats را زیر نظر بگیرید، مقدار اوج anon برای هر کانتینر را بردارید و حدود نصف آن را به عنوان فضای اطمینان (headroom) اضافه کنید. محدودیت بیش از حد سختگیرانه از نداشتن محدودیت بدتر است، زیرا باعث میشود یک سرویس سالم در طول یک جهش ترافیکی عادی توسط سیستم کشته شود.
یک تله وجود دارد که باید به آن توجه ویژه داشت. محدودیت برای اکثر runtimeها نامرئی است مگر اینکه آن را به آنها اعلام کنید. PostgreSQL با خوشحالی shared_buffers و work_mem را فراتر از محدودیت کانتینر خود تنظیم میکند و در نهایت کشته میشود. یک JVM (ماشین مجازی جاوا) به -XX:MaxRAMPercentage=75 نیاز دارد تا heap خود را بر اساس محدودیت cgroup تنظیم کند، نه بر اساس رم میزبان. Node.js به --max-old-space-size بر حسب مگابایت نیاز دارد که باید کمتر از محدودیت کانتینر تنظیم شود، در غیر این صورت garbage collector اجازه میدهد heap تا حدی رشد کند که هسته سیستمعامل مداخله کرده و آن را متوقف کند. Ollama نیز همین وضعیت را با یک پارامتر متفاوت دارد، زیرا افزایش num_ctx باعث رشد KV cache به اندازه صدها مگابایت میشود و کانتینر در میانه یک prompt طولانی از کار میافتد. cgroup مذاکره نمیکند؛ فقط میکشد.
محدودیتهای CPU رفتار کاملاً متفاوتی دارند
cpus: "1.5" به معنای 150 درصد از یک هسته است که به عنوان سهمیه CFS (زمانبند کاملاً منصفانه) اعمال میشود. کانتینر در هر دوره 100 میلیثانیهای، 150 میلیثانیه زمان CPU دریافت میکند که بین تمام تردهای آن تقسیم میشود. هنگامی که این سهمیه تمام شود، هسته سیستمعامل کانتینر را تا شروع دوره بعدی متوقف میکند.
این تفاوت بسیار مهم است. کانتینری که از محدودیت حافظه فراتر رود، توسط سیستم کشته (kill) میشود. اما کانتینری که از محدودیت CPU فراتر رود، محدود (throttle) شده و به کار خود با سرعت کمتر ادامه میدهد. بنابراین، تعیین محدودیت CPU به صورت تهاجمی ایمن است، در حالی که محدودیت حافظه به فضای خالی (headroom) نیاز دارد.
cpu_shares ابزار متفاوتی است: یک وزن نسبی که تنها زمانی اهمیت پیدا میکند که CPUها واقعاً اشباع شده باشند. دو کانتینر با سهمهای 1024 و 512، یک هسته پرمشغله را تقریباً با نسبت دو به یک تقسیم میکنند و در سیستمی که بیکار است، هیچکدام محدود نمیشوند. از سهمها (shares) برای اولویتبندی سرویسها استفاده کنید و زمانی که به یک سقف واقعی نیاز دارید، از cpus استفاده کنید؛ برای مثال، برای جلوگیری از اینکه یک عملیات تبدیل ویدیو (transcode) شبانه، منابع وبسرور شما را تخلیه نکند.
FAQ
آیا deploy.resources.limits بدون Docker Swarm کار میکند؟
بله. نسخه دوم Compose هنگام اجرای docker compose up روی یک میزبان تکی، deploy.resources.limits و deploy.resources.reservations را اعمال میکند. این موضوع را با docker inspect --format '{{.HostConfig.Memory}}' <container> تأیید کنید؛ این دستور محدودیت را به بایت نمایش میدهد و در صورت عدم اعمال محدودیت، 0 را چاپ میکند. کلیدهای داخل deploy که واقعاً به Swarm نیاز دارند، mode، placement، update_config و endpoint_mode هستند.
کد خروج 137 در Docker Compose به چه معناست؟
این یعنی پردازش اصلی سیگنال SIGKILL را دریافت کرده است، زیرا 137 حاصل جمع 128 و سیگنال 9 است. OOM killer هسته سیستمعامل دلیل رایج این اتفاق است، اما اگر یک برنامه SIGTERM را نادیده بگیرد، در زمان اتمام مهلت خاموشی (shutdown timeout) نیز همین کد تولید میشود. برای تشخیص تفاوت، 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 است و برای فایلهای جدید گزینه بهتری محسوب میشود. اگر بقیه فایل شما از کلیدهای سطح بالای قدیمی استفاده میکند، mem_limit را حفظ کنید. تنظیم هر دو مورد برای یک سرویس فقط خواندن فایل را دشوارتر میکند، بنابراین یکی را انتخاب کرده و نتیجه را با docker inspect بررسی کنید.
چرا کانتینر من بدون اینکه کشته شود، در حد نهایی حافظه خود باقی مانده است؟
عدد میزان مصرف در docker stats شامل page cache نیز میشود که هسته سیستمعامل در شرایط فشار، بهجای فعال کردن OOM kill، آن را حذف میکند. دستور docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat را اجرا کرده و مقدار anon را بخوانید؛ این همان working set است که قابل بازپسگیری نیست. مقدار بالای file در کنار مقدار پایین anon نشاندهنده کانتینری است که در حال انجام عملیات ورودی/خروجی دیسک است، نه کانتینری که در آستانه توقف قرار دارد.
روی یک VPS با 8GB رم، چه مقدار حافظه را باید تخصیصنیافته باقی بگذارم؟
حدود 1GB را برای هسته سیستمعامل، Docker daemon، sshd، journald و shell خود کنار بگذارید و مجموع محدودیتهای تمام کانتینرها را زیر 7GB باقیمانده نگه دارید. پیش از نهایی کردن اعداد، به مدت یک روز مقدار اوج anon هر کانتینر را تحت بار واقعی مشاهده کنید و مجموع آن را به عنوان یک بودجه در نظر بگیرید، نه هدفی برای پر کردن کامل آن.