محدودیت حافظه Docker Compose برای جلوگیری از OOM
با deploy.resources و mem_limit برای هر container سقف RAM و CPU بگذارید، خطای exit 137 را بشناسید و با تنظیم swap و اندازه مناسب از OOM در VPS جلوگیری کنید.
محدودیت حافظه Docker Compose چه کاری انجام میدهد
محدودیت حافظه Docker Compose یک سقف سخت است که هسته Linux برای cgroup (گروه کنترل؛ قابلیت هسته برای اندازهگیری منابع مجموعهای از پردازهها) یک container اعمال میکند. اگر deploy.resources.limits.memory را برای یک service تنظیم کنید، آن container هرگز نمیتواند بیش از عددی که نوشتهاید حافظه مصرف کند. هنگام تلاش برای عبور از این مقدار، هسته یکی از پردازههای داخل container را متوقف میکند و container معمولاً با کد خروج 137 خارج میشود.
این موضوع در VPS اهمیت بیشتری دارد؛ زیرا مقدار RAM ثابت است و حافظه اضافی از host برای استفاده وجود ندارد. یک container دارای نشت حافظه یا query نامناسب میتواند تمام pageهای آزاد یک سرور 8GB را مصرف کند. سپس هسته پردازهای را که از نظر خود بدترین وضعیت را دارد متوقف میکند؛ این پردازه اغلب یک database یا session مربوط به SSH است، نه container ایجادکننده مشکل. محدودیتها یک قطعی کامل سرور را به اختلال در یک service تبدیل میکنند که دوباره راهاندازی میشود.
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 را نشان دهد. اگر ستون limit مقدار کامل RAM مربوط به host را نشان میدهد، تنظیم اعمال نشده است و تا زمانی که این مشکل برطرف نشود، ادامه این راهنما کمکی نخواهد کرد. اگر فایل compose برای شما جدید است، مبانی Docker Compose برای VPS ساختار فایلی را که این راهنما بر اساس آن ساخته شده است پوشش میدهد.
deploy.resources.limits یا mem_limit: کدامیک اعمال میشود
برای یک مفهوم واحد، دو شکل نوشتاری وجود دارد و همین موضوع باعث سردرگمی میشود.
mem_limit، mem_reservation، memswap_limit، cpus و cpu_shares کلیدهای سرویس در سطح بالا هستند که از قالبهای قدیمیتر فایل Compose به ارث رسیدهاند. deploy.resources از schema مربوط به Swarm آمده است و اکنون بخشی از Compose Specification است؛ همان قالبی که docker compose امروزه میخواند.
هر دو مورد روی یک host واحد کار میکنند. Compose V2، یعنی plugin مربوط به docker compose، هنگام اجرای docker compose up، deploy.resources.limits و deploy.resources.reservations را اعمال میکند؛ حتی اگر هیچ Swarm clusterای وجود نداشته باشد. بخشهای مخصوص 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 برحسب nano CPU است؛ بنابراین 1.5 بهصورت 1500000000 نمایش داده میشود. وجود 0 در هر فیلد به این معناست که محدودیتی تنظیم نشده است. کوچکترین محدودیت حافظهای که Docker میپذیرد 6m است و container با مقداری کمتر از آن شروع نمیشود.
وقتی یک کانتینر به حد مجاز میرسد چه رخ میدهد
سرعت کانتینر کم نمیشود. متوقف میشود.
وقتی یک فرایند برای یک page درخواست میدهد و cgroup از قبل به memory.max رسیده است، kernel ابتدا هر چیزی را که بتواند در همان cgroup آزاد میکند: ابتدا clean page cache و سپس pageهایی که امکان swap کردن دارند. اگر reclaim فضای کافی آزاد نکند، OOM (out of memory) killer در cgroup یک فرایند را از داخل کانتینر انتخاب میکند و برای آن SIGKILL میفرستد. با متوقف شدن PID 1 کانتینر، خود کانتینر پایان مییابد. 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 را نادیده گرفته است. این تمایز ساعتها زمان صرفهجویی میکند، چون این دو مشکل هیچ ارتباطی با یکدیگر ندارند.
دو محل دیگر نیز این رویداد را ثبت میکنند. daemon را بهصورت زنده پایش کنید:
docker events --filter event=oomسپس log مربوط به 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) آغاز میشود. خطی که پیشوند Memory cgroup را ندارد، نشاندهنده OOM در host است؛ یعنی خود ماشین RAM کافی نداشته است. جلوگیری از همین خرابی هدف limitها است. مشاهده این وضعیت یعنی مجموع limitهای شما بیش از حد زیاد است یا برخی سرویسها اصلاً limit ندارند.
با restart: unless-stopped، یک حلقه OOM بهخوبی پنهان میماند، زیرا سرویس یک ثانیه پس از متوقف شدن، در docker compose ps دوباره up به نظر میرسد. ستون uptime و تعداد restartها را بررسی کنید و limit را با یک healthcheck که برنامه را unhealthy گزارش میکند همراه کنید تا کانتینری که مرتب متوقف میشود، بدون پایش مداوم شما قابل مشاهده باشد.
رزرو یک راهنماست؛ محدودیت، قانون است
reservations.memory (یعنی mem_reservation قدیمیتر) یک کف نرم است. Docker آن را محدودیت نرمی توصیف میکند که زمانی فعال میشود که daemon فشار رقابتی یا کمبود حافظه را در میزبان تشخیص دهد. این مقدار هرگز مانع عبور یک container از آن نمیشود و هرگز تضمین نمیکند هنگام درخواست container، حافظه آزاد باشد. این مقدار فقط kernel را بهسمت reclaim کردن حافظه از containerهایی هدایت میکند که ابتدا بالاتر از reservation خود هستند.
بنابراین، reservation بهتنهایی از چیزی محافظت نمیکند. از آن برای مشخصکردن سرویسی استفاده کنید که میخواهید هنگام فشار منابع با آن رفتار مساعدی شود و برای ایمنی به limit متکی باشید. reservation را پایینتر از limit نگه دارید؛ در غیر این صورت container شروع نمیشود: 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 نیست. این مقدار، مجموع حافظه و swap است. با mem_limit: 1g و memswap_limit: 2g، کانتینر 1GB RAM و 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 را کندتر میکند، نه اینکه احتمال وقوع آن را کاهش دهد؛ زیرا فرایندی که دچار نشت حافظه است، همانقدر که RAM را پر میکند، swap را نیز پر میکند. در همین حال، کانتینری که روی فضای ذخیرهسازی اشتراکی VPS بهطور مداوم از swap استفاده میکند، همه سرویسهای دیگر روی همان سرور را کند میکند. برای هر چیزی که به تأخیر حساس است، تعیین یک محدودیت صحیح بدون swap باعث میشود خطا سریعتر و قابلپیشبینیتر رخ دهد.
چرا میزان مصرف حافظه بدتر از مقدار واقعی به نظر میرسد
رقم MEM USAGE در docker stats شامل page cache است؛ بنابراین کانتینری که فایلهای بزرگ را میخواند، بهتدریج به limit خود نزدیک میشود و در همان سطح باقی میماند. این وضعیت طبیعی است و نشانه memory leak نیست، زیرا clean cache پیش از آنکه OOM killer فراخوانی شود، قابل reclaim است. سرویسی مانند یک media server خودمیزبان Jellyfin دقیقاً به همین دلیل بهطور دائمی نزدیک سقف خود به نظر میرسد.
عدد را از داخل کانتینر به 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 است؛ یعنی working setای که نمیتوان آن را حذف کرد. file page cache است و امکان حذف آن وجود دارد. limit را بر اساس anon بهعلاوه یک حاشیه تنظیم کنید، نه بر اساس مقدار کل. فایل memory.events موضوع را بهطور قطعی مشخص میکند: مقدار بالاتر از صفر در شمارنده oom_kill یعنی kernel از زمان شروع کانتینر، چیزی را در آن کشته است؛ افزایش شمارنده max نیز یعنی کانتینر در همین لحظه به سقف خود رسیده است. هر دو command به یک shell و coreutils درون image نیاز دارند؛ بنابراین در imageهای distroless یا scratch شکست میخورند.
محدودیتهای اندازهگذاری در یک VPS با 8GB
کار را از میزبان شروع کنید، نه از برنامهها. در یک VPS با 8GB، حدود 1GB را برای kernel، Docker daemon، sshd، journald و shell ورود خودتان کنار بگذارید. در این حالت تقریباً 7GB برای تخصیص باقی میماند و مجموع محدودیت همه containerها باید کمتر از این مقدار باشد. تخصیص بیشازحد تا روزی کار میکند که دو سرویس همزمان به اوج مصرف برسند.
یک تقسیمبندی عملی در یک سیستم 8GB:
- Reverse proxy: محدودیت 128m. این یک فرایند کوچک است و چنین محدودیت فشردهای، بارگذاری مجدد خارج از کنترل config را فوراً آشکار میکند.
- PostgreSQL: محدودیت 2g، با تنظیم
shared_buffersروی حدود 512MB در config پایگاهداده. - Application container: محدودیت 1g.
- Background worker: محدودیت 512m.
- Media یا file service: محدودیت 2g که بیشتر آن برای page cache مصرف میشود.
این اعداد را مستقیماً در stack خودتان کپی نکنید. سرویسها را یک روز تحت بار واقعی اجرا کنید، docker stats را پایش کنید، بیشترین مقدار anon را برای هر container ثبت کنید و برای حاشیه اطمینان، تقریباً نصف همان مقدار را نیز اضافه کنید. محدودیتی که بیشازحد فشرده تنظیم شده باشد از نداشتن محدودیت بدتر است، زیرا هنگام افزایش عادی network traffic، یک سرویس سالم را متوقف میکند.
یک نکته مهم نیازمند توضیح جداگانه است. بیشتر runtimeها تا زمانی که این محدودیت را به آنها اعلام نکنید، از آن مطلع نیستند. PostgreSQL بهراحتی shared_buffers و work_mem را بیشتر از محدودیت container تنظیم میکند و در نهایت متوقف میشود. یک JVM (Java virtual machine) به -XX:MaxRAMPercentage=75 نیاز دارد تا heap خود را بر اساس محدودیت cgroup، نه RAM میزبان، اندازهگذاری کند. Node.js به --max-old-space-size برحسب megabytes نیاز دارد که کمتر از محدودیت container تنظیم شود؛ در غیر این صورت garbage collector اجازه میدهد heap آنقدر رشد کند تا kernel مداخله کند. cgroup مذاکره نمیکند. آن را متوقف میکند.
محدودیتهای CPU کاملاً متفاوت عمل میکنند
cpus: "1.5" به معنای 150% از یک هسته است که بهصورت سهمیه CFS (زمانبند کاملاً منصفانه) اعمال میشود. کانتینر در هر دوره 100ms، مقدار 150ms زمان CPU دریافت میکند و این زمان میان همه threadهای آن بهاشتراک گذاشته میشود. پس از مصرف کامل این سهمیه، kernel آن را تا دوره بعدی منتظر نگه میدارد.
این تفاوت مهم است. کانتینری که از محدودیت حافظه خود عبور کند، kill میشود. کانتینری که از محدودیت CPU خود عبور کند، throttle میشود و با سرعت کمتر به کار ادامه میدهد. بنابراین میتوان محدودیت CPU را با مقدار نسبتاً تهاجمی تنظیم کرد، اما محدودیت حافظه به فضای اضافی نیاز دارد.
cpu_shares ابزار متفاوتی است: یک وزن نسبی که فقط هنگام اشباع واقعی CPUها اهمیت دارد. دو کانتینر با shares برابر با 1024 و 512، یک هسته شلوغ را تقریباً با نسبت دو به یک میان خود تقسیم میکنند و در یک سیستم idle، هیچکدام محدود نمیشوند. برای رتبهبندی سرویسها بر اساس اهمیت از shares استفاده کنید و زمانی که به یک سقف واقعی نیاز دارید از cpus استفاده کنید؛ برای مثال، برای جلوگیری از آنکه یک کار transcode شبانه، web server شما را از منابع محروم کند.
FAQ
آیا deploy.resources.limits بدون Docker Swarm کار میکند؟
بله. Compose V2 هنگام اجرای 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 در kernel علت رایج این وضعیت است؛ اما اگر برنامه SIGTERM را نادیده بگیرد، پایان مهلت خاموشسازی نیز همین کد را تولید میکند. برای تشخیص این دو وضعیت، docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container> را اجرا کنید. true 137 بهدلیل کمبود حافظه رخ میدهد و false 137 چنین نیست.
باید deploy.resources.limits.memory را تنظیم کنم یا mem_limit را؟
هر دو مورد با docker compose کار میکنند. deploy.resources.limits.memory قالب فعلی Compose Specification است و برای فایل جدید گزینه پیشفرض بهتری محسوب میشود. اگر بخشهای دیگر فایل شما از کلیدهای سطح بالای قدیمی استفاده میکنند، mem_limit را حفظ کنید. تنظیم هر دو مورد برای یک سرویس فقط خوانایی فایل را کاهش میدهد؛ بنابراین یکی را انتخاب کنید و نتیجه را با docker inspect بررسی کنید.
چرا کانتینر من بدون خاتمه یافتن، در مقدار کامل حد حافظه خود باقی میماند؟
مقدار مصرف در docker stats شامل page cache است. kernel هنگام فشار حافظه، این cache را حذف میکند و در نتیجه الزاماً OOM kill رخ نمیدهد. docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat را اجرا کنید و مقدار anon را بخوانید؛ این مقدار working set غیرقابلبازیابی را نشان میدهد. مقدار بالای file در کنار مقدار پایین anon نشان میدهد که کانتینر درگیر ورودی و خروجی دیسک است، نه اینکه در آستانه خاتمه باشد.
در یک VPS با حافظه 8GB چه مقدار RAM را باید تخصیصنیافته باقی بگذارم؟
حدود 1GB را برای kernel، Docker daemon، sshd، journald و shell خودتان کنار بگذارید. سپس مجموع limitهای همه کانتینرها را کمتر از 7GB باقیمانده نگه دارید. مقدار اوج anon را برای هر کانتینر، در شرایط بار واقعی و بهمدت یک روز پایش کنید و سپس اعداد را نهایی کنید. مجموع را بهعنوان بودجه در نظر بگیرید، نه مقداری که باید کاملاً پر شود.