رفتن به محتوا
LoopX

محدودیت منابع

توی این درس یاد می‌گیری چرا کانتینر بدون محدودیت می‌تواند کل سرور را از پا بیندازد و چطور با --memory و --cpus سقف مصرف حافظه و پردازنده بگذاری. OOM killer را در عمل می‌بینی (کد خروج ۱۳۷ و OOMKilled=true)، با docker stats مصرف زنده را می‌خوانی، با --pids-limit جلوی تکثیر بی‌نهایت پروسه را می‌گیری، با docker update محدودیت را در لحظه عوض می‌کنی و همه را در Compose هم می‌نویسی. در تمرین اصلی یک کانتینر را محدود می‌کنی و رفتارش زیر بار را می‌بینی.

مسئله: یک کانتینر، کل سرور

Section titled “مسئله: یک کانتینر، کل سرور”

به‌طور پیش‌فرض یک کانتینر می‌تواند تمام حافظه و CPU میزبان را بخورد. اگر اپی نشتی حافظه (memory leak) داشته باشد یا حلقه‌ی بی‌پایان، فقط خودش نمی‌میرد: کل سرور کند می‌شود و کانتینرهای دیگر (حتی دیتابیس) را هم می‌کشد.

تشبیه: سهم هر مستأجر از ساختمان

Section titled “تشبیه: سهم هر مستأجر از ساختمان”

یک ساختمان مشترک را با مستأجرها شریک شده‌ای. اگر هر مستأجر بتواند هر قدر آب و برق بخواهد مصرف کند، یکی که شیر را باز بگذارد، کل ساختمان را بی‌آب می‌کند. سهمیه برای هر واحد (حافظه و CPU) یعنی مشکل یک واحد فقط در همان واحد می‌ماند.

cgroup های هسته‌ی لینوکس سقف مصرف هر کانتینر را اعمال می‌کنند. از سقف حافظه که رد شود، OOM killer پروسه را می‌کشد؛ از سقف CPU که بیشتر بخواهد، فقط کند می‌شود (throttle).

مثال ۱: محدودیت را بگذار و ببین

Section titled “مثال ۱: محدودیت را بگذار و ببین”
Terminal window
docker run -d --name lx-rl1 --memory 64m --cpus 0.5 python:3.12-alpine sleep 120 >/dev/null
docker inspect lx-rl1 --format 'Memory={{.HostConfig.Memory}} بایت NanoCpus={{.HostConfig.NanoCpus}}'
echo "داخل کانتینر (cgroup):"
docker exec lx-rl1 sh -c 'echo " memory.max = $(cat /sys/fs/cgroup/memory.max)"; echo " cpu.max = $(cat /sys/fs/cgroup/cpu.max)"'
خروجی
Memory=67108864 بایت NanoCpus=500000000
داخل کانتینر (cgroup):
memory.max = 67108864
cpu.max = 50000 100000

--memory 64m یعنی ۶۴ مگابایت (۶۷۱۰۸۸۶۴ بایت) و --cpus 0.5 یعنی نصف یک هسته. داکر این‌ها را در cgroup کانتینر می‌نویسد؛ داخل کانتینر هم همان فایل‌ها را می‌بینی. در cpu.max عدد 50000 100000 یعنی «در هر پنجره‌ی ۱۰۰ میلی‌ثانیه‌ای، فقط ۵۰ میلی‌ثانیه CPU».

مثال ۲: docker stats، مصرف زنده

Section titled “مثال ۲: docker stats، مصرف زنده”
Terminal window
docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.CPUPerc}}\t{{.PIDs}}' lx-rl1
خروجی
NAME MEM USAGE / LIMIT MEM % CPU % PIDS
lx-rl1 1.82MiB / 64MiB 2.84% 0.00% 1

ستون‌ها: مصرف و سقف حافظه (1.7MiB / 64MiB)، درصد، درصد CPU و تعداد پروسه‌ها. بدون --no-stream خروجی زنده است و مثل top هر ثانیه تازه می‌شود (با Ctrl+C خارج شو). برای چند کانتینر همزمان اسم‌ها را پشت هم بنویس یا اسمی ندهی تا همه را نشان بدهد.

⚡ بررسی سریع

در docker stats ستون MEM USAGE / LIMIT چه نشان می‌دهد؟

مثال ۳: رد شدن از سقف حافظه، OOM killer

Section titled “مثال ۳: رد شدن از سقف حافظه، OOM killer”
Terminal window
docker run --name lx-rl2 --memory 64m python:3.12-alpine python -c "x = b'a' * (200 * 1024 * 1024); print('تمام شد')" 2>&1 | tail -1
docker inspect lx-rl2 --format 'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}'
docker rm lx-rl2 >/dev/null
خروجی
ExitCode=137 OOMKilled=true

برنامه خواست ۲۰۰ مگابایت بگیرد در حالی که سقف ۶۴ بود. هسته آن را کشت: هیچ پیام خطایی از خود برنامه نیست (پروسه بی‌صدا مرد)، کد خروج 137 (= ۱۲۸ + سیگنال ۹) و OOMKilled=true. اگر کانتینری ناگهانی با ۱۳۷ می‌میرد، اول OOMKilled را ببین (همان چیزی که در درس عیب‌یابی ناگفته مانده بود).

مثال ۴: همین برنامه با سقف کافی

Section titled “مثال ۴: همین برنامه با سقف کافی”
Terminal window
docker run --rm --memory 400m python:3.12-alpine python -c "x = b'a' * (200 * 1024 * 1024); print('تمام شد، ۲۰۰ مگابایت گرفتم')"
خروجی
تمام شد، ۲۰۰ مگابایت گرفتم

با سقف ۴۰۰ مگابایت همان برنامه کار می‌کند. پس پیدا کردن سقف مناسب یعنی اندازه‌گیری مصرف واقعی (با docker stats زیر بار معمولی و اوج) و گذاشتن حاشیه‌ی معقول. سقف خیلی کم، اپ سالم را می‌کشد؛ سقف خیلی زیاد، محافظت نمی‌کند.

دو حلقه‌ی بی‌پایان (که می‌توانند دو هسته را بخورند) داخل کانتینری با --cpus 0.5:

Terminal window
docker run -d --name lx-rl3 --cpus 0.5 alpine sh -c 'yes > /dev/null & yes > /dev/null & wait' >/dev/null
sleep 3
cpu() { docker exec lx-rl3 awk '/^usage_usec/{print $2}' /sys/fs/cgroup/cpu.stat; }
u1=$(cpu); sleep 5; u2=$(cpu)
echo "CPU مصرف‌شده در ۵ ثانیه: $(( (u2 - u1) / 50000 ))٪ از یک هسته (سقف ۵۰٪)"
echo "تعداد دفعاتی که throttle شد: $(docker exec lx-rl3 awk '/^nr_throttled/{print ($2 > 0 ? "بیشتر از صفر" : "صفر")}' /sys/fs/cgroup/cpu.stat)"
docker rm -f lx-rl3 >/dev/null
خروجی
CPU مصرف‌شده در ۵ ثانیه: 52٪ از یک هسته (سقف ۵۰٪)
تعداد دفعاتی که throttle شد: بیشتر از صفر

با اینکه دو پروسه می‌خواستند هسته‌ها را پر کنند، کل کانتینر حدود ۵۰٪ یک هسته گرفت (این عدد را از شمارنده‌ی cpu.stat گرفتم که دقیق‌تر از یک نمونه‌ی docker stats است؛ نمونه‌ی لحظه‌ای ممکن است کمی بالا یا پایین باشد). ۱۰۰٪ در docker stats یعنی یک هسته‌ی کامل (پس روی ۴ هسته تا ۴۰۰٪ ممکن است). برخلاف حافظه، اینجا هیچ پروسه‌ای نمی‌میرد؛ فقط کند می‌شود.

مثال ۶: محدودیت تعداد پروسه (--pids-limit)

Section titled “مثال ۶: محدودیت تعداد پروسه (--pids-limit)”
Terminal window
docker run -d --name lx-rl4 --pids-limit 10 alpine sleep 300 >/dev/null
docker exec lx-rl4 sh -c 'for i in $(seq 1 30); do sleep 100 & done; wait' 2>&1 | sort -u | head -2
docker stats --no-stream --format 'تعداد پروسه‌ها در کانتینر: {{.PIDs}}' lx-rl4
docker rm -f lx-rl4 >/dev/null
خروجی
sh: can't fork: Resource temporarily unavailable
تعداد پروسه‌ها در کانتینر: 9

با سقف ۱۰ پروسه، ساختن ۳۰ پروسه نیمه‌کاره ماند: خطای «can’t fork: Resource temporarily unavailable» و تعداد پروسه‌ها هرگز از ۱۰ بیشتر نشد. این سپر در برابر «fork bomb» (برنامه‌ای که پروسه‌ها را بی‌نهایت تکثیر می‌کند) و نشتی پروسه است.

مثال ۷: تغییر محدودیت در لحظه با docker update

Section titled “مثال ۷: تغییر محدودیت در لحظه با docker update”
Terminal window
docker run -d --name lx-rl5 --memory 64m alpine sleep 120 >/dev/null
echo "قبل: $(docker exec lx-rl5 cat /sys/fs/cgroup/memory.max)"
docker update --memory 128m --memory-swap 128m lx-rl5 >/dev/null
echo "بعد: $(docker exec lx-rl5 cat /sys/fs/cgroup/memory.max)"
docker rm -f lx-rl5 >/dev/null
خروجی
قبل: 67108864
بعد: 134217728

بدون ریستارت کانتینر، سقف از ۶۴ به ۱۲۸ مگابایت رسید. برای اورژانس عالی است (کانتینر دارد OOM می‌خورد و می‌خواهی موقتاً سقف را بالا ببری). تغییر دائمی را در دستور اجرا یا Compose هم بنویس.

مثال ۸: اپ «حافظه‌ی میزبان» را می‌بیند، نه سقف خودش

Section titled “مثال ۸: اپ «حافظه‌ی میزبان» را می‌بیند، نه سقف خودش”
Terminal window
docker run --rm --memory 64m python:3.12-alpine sh -c 'echo "MemTotal در /proc/meminfo: $(grep MemTotal /proc/meminfo | tr -s " " | cut -d" " -f2) KB"; echo "سقف واقعی (cgroup): $(( $(cat /sys/fs/cgroup/memory.max) / 1024 )) KB"'
خروجی
MemTotal در /proc/meminfo: 4010356 KB
سقف واقعی (cgroup): 65536 KB

/proc/meminfo داخل کانتینر هنوز حافظه‌ی کل میزبان را نشان می‌دهد، ولی سقف واقعی کمتر است. برنامه‌هایی که اندازه‌ی کش یا heap خود را از /proc/meminfo حساب می‌کنند (بعضی نسخه‌های قدیمی Java، Node، کتابخانه‌های C) بیش از حد می‌گیرند و OOM می‌خورند. نسخه‌های جدید زبان‌ها cgroup را می‌فهمند؛ همیشه با docker stats زیر بار بسنج.

rl/compose.yaml
name: lxrl
services:
worker:
image: alpine
command: sleep 120
mem_limit: 128m
cpus: 0.5
pids_limit: 50
Terminal window
cd rl
docker compose up -d >/dev/null 2>&1
docker inspect lxrl-worker-1 --format 'Memory={{.HostConfig.Memory}} NanoCpus={{.HostConfig.NanoCpus}} Pids={{.HostConfig.PidsLimit}}'
docker compose down >/dev/null 2>&1
خروجی
Memory=134217728 NanoCpus=500000000 Pids=50

کلیدهای mem_limit، cpus و pids_limit همان محدودیت‌ها را در Compose می‌گذارند. (شکل قدیمی‌تر deploy.resources.limits هم در Compose نسخه‌ی ۲ پشتیبانی می‌شود.) و restart: unless-stopped + سقف حافظه یعنی کانتینر نشتی‌دار به‌جای از کار انداختن سرور، کشته و دوباره بالا می‌آید.

محدودیت‌ها را cgroupهای هسته‌ی لینوکس اعمال می‌کنند (control groups: سلسله‌مراتبی از گروه پروسه‌ها با سهمیه‌ی منابع). داکر برای هر کانتینر یک cgroup می‌سازد و مقدارها را در فایل‌هایی مثل memory.max و cpu.max می‌نویسد؛ هسته بقیه را انجام می‌دهد.

  • حافظه: وقتی مصرف cgroup به memory.max برسد، هسته اول سعی می‌کند حافظه‌ی کش را آزاد کند؛ اگر نشد، OOM killer یکی از پروسه‌های همان cgroup را می‌کشد (کانتینری که پروسه‌ی اصلی‌اش کشته شود، متوقف می‌شود). نه کانتینرهای دیگر را.
  • CPU: --cpus 0.5 با سهمیه‌ی زمانی (cpu.max) اعمال می‌شود: در هر دوره‌ی ۱۰۰ میلی‌ثانیه‌ای فقط ۵۰ میلی‌ثانیه اجرا. اگر بیشتر بخواهد، پروسه تا دوره‌ی بعد منتظر می‌ماند (throttling). --cpu-shares یک وزن نسبی است که فقط وقتی CPU کم باشد اثر می‌گذارد.
  • swap: --memory-swap مجموع حافظه و swap است؛ برابر --memory بگذاری یعنی swap ندارد. اگر آن را ندهی و swap روی میزبان فعال باشد، داکر به‌طور پیش‌فرض به اندازه‌ی --memory هم swap اجازه می‌دهد (مجموع دو برابر)؛ در تمرین اصلی برنامه با سقف ۵۰ مگابایت تا ۹۰ مگابایت جلو رفت و بعد کشته شد، به همین دلیل. swap زیاد، کانتینر را به‌جای مردن، به‌شدت کند می‌کند.
  • روی Docker Desktop (مک و ویندوز) کانتینرها داخل یک ماشین مجازی‌اند که خودش سقف حافظه و CPU دارد (تنظیمات Docker Desktop)؛ در مثال ۸ MemTotal همان سقف VM بود.
می‌خواهم… گزینه
سقف حافظه --memory 512m (g برای گیگابایت)
حافظه+swap --memory-swap 512m (برابر memory: بدون swap)
سقف CPU --cpus 1.5
وزن نسبی CPU --cpu-shares 512 (پیش‌فرض ۱۰۲۴)
انتخاب هسته‌ها --cpuset-cpus 0,1
سقف تعداد پروسه --pids-limit 100
تغییر در لحظه docker update --memory 1g ctr
مشاهده docker stats، docker inspect
کلید Compose معادل
mem_limit --memory
cpus --cpus
pids_limit --pids-limit
deploy.resources.limits.memory/cpus همان، به شکل Swarm/نسخه‌ی جدید

۱) سقف حافظه‌ی خیلی کم و کشته شدن اپ سالم

Section titled “۱) سقف حافظه‌ی خیلی کم و کشته شدن اپ سالم”

مثال ۳ و ۴. راه‌حل: مصرف واقعی را زیر بار بسنج و حاشیه بگذار (مثلاً ۱.۵ برابر اوج).

Terminal window
docker run --rm --memory 64mega alpine true 2>&1 | head -1 | cut -c1-120
خروجی
invalid argument "64mega" for "-m, --memory" flag: invalid suffix: 'mega'

واحد درست b، k، m، g است. راه‌حل: 64m.

Terminal window
docker run --rm --memory 1m alpine true 2>&1 | head -1 | cut -c1-150
خروجی
docker: Error response from daemon: Minimum memory limit allowed is 6MB

داکر برای حافظه حداقلی (حدود ۶ مگابایت) لازم دارد. راه‌حل: سقف واقع‌بینانه.

۴) تصور اینکه --cpus پروسه را می‌کشد

Section titled “۴) تصور اینکه --cpus پروسه را می‌کشد”

CPU فقط throttle می‌شود؛ اپ کند می‌شود (latency بالا) ولی نمی‌میرد. راه‌حل: مصرف CPU و تأخیر را در مانیتورینگ ببین.

۵) اعتماد به /proc/meminfo یا free در کانتینر

Section titled “۵) اعتماد به /proc/meminfo یا free در کانتینر”

مثال ۸. راه‌حل: خواندن /sys/fs/cgroup/memory.max یا استفاده از نسخه‌ای از runtime که cgroup را می‌فهمد.

✎ تمرینآسان

یک کانتینر alpine sleep با سقف ۳۲ مگابایت بساز و با docker inspect مقدار Memory آن را (بر حسب بایت) بخوان.

دیدن جواب
Terminal window
docker run -d --name lx-e1 --memory 32m alpine sleep 60 >/dev/null
docker inspect lx-e1 --format '{{.HostConfig.Memory}}'
docker rm -f lx-e1 >/dev/null
خروجی
33554432
✎ تمرینمتوسط

تمرین اصلی: یک کانتینر را محدود کن و رفتارش زیر بار را ببین. اپ پایتونی را اجرا کن که هر ثانیه ۱۰ مگابایت حافظه‌ی بیشتر می‌گیرد، با سقف ۵۰ مگابایت. نشان بده (۱) با docker stats مصرف بالا می‌رود (۲) در پایان کشته می‌شود (۳) OOMKilled=true است.

دیدن جواب
Terminal window
docker run -d --name lx-e2 --memory 50m python:3.12-alpine python -c "
import time
data = []
while True:
data.append(b'x' * 10 * 1024 * 1024)
print(len(data) * 10, 'MB', flush=True)
time.sleep(1)
" >/dev/null
sleep 2; echo "حدود ۲ ثانیه: $(docker stats --no-stream --format '{{.MemUsage}}' lx-e2 2>/dev/null || echo 'کانتینر مرده')"
for i in $(seq 20); do [ "$(docker inspect lx-e2 --format '{{.State.Status}}')" = exited ] && break; sleep 1; done
docker inspect lx-e2 --format 'وضعیت={{.State.Status}} کد خروج={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}'
echo "آخرین خروجی اپ قبل از مرگ: $(docker logs lx-e2 2>&1 | tail -1)"
docker rm -f lx-e2 >/dev/null
خروجی
حدود ۲ ثانیه: 34.38MiB / 50MiB
وضعیت=exited کد خروج=137 OOMKilled=true
آخرین خروجی اپ قبل از مرگ: 90 MB

عدد آخر (مثلاً ۹۰ مگابایت) از سقف ۵۰ مگابایت بیشتر است چون سقف فقط حافظه را شامل می‌شود و داکر به‌طور پیش‌فرض به همان اندازه swap هم اجازه می‌دهد. برای سقف سخت‌تر --memory-swap 50m هم بگذار تا مجموع حافظه و swap همان ۵۰ باشد.

✎ تمرینسخت

یک کانتینر با --restart on-failure:3 و سقف حافظه‌ی کم بساز که همیشه OOM می‌خورد. نشان بده کانتینر دقیقاً ۳ بار دوباره شروع می‌شود و بعد دست می‌کشد (RestartCount=3) و وضعیت نهایی exited با OOMKilled=true است.

دیدن جواب
Terminal window
docker run -d --name lx-e3 --memory 30m --restart on-failure:3 python:3.12-alpine python -c "x = b'a' * (100 * 1024 * 1024)" >/dev/null
for i in $(seq 60); do
[ "$(docker inspect lx-e3 --format '{{.State.Status}}')" = exited ] && [ "$(docker inspect lx-e3 --format '{{.RestartCount}}')" -ge 3 ] && break; sleep 1
done
docker inspect lx-e3 --format 'وضعیت={{.State.Status}} RestartCount={{.RestartCount}} OOMKilled={{.State.OOMKilled}}'
docker rm -f lx-e3 >/dev/null
خروجی
وضعیت=exited RestartCount=3 OOMKilled=true
؟ آزمونک
  1. اگر کانتینر از سقف --memory رد شود چه می‌شود؟

  2. اگر از سقف --cpus رد شود چه می‌شود؟

  3. --cpus 0.5 یعنی…

  4. برای دیدن مصرف زنده‌ی کانتینرها؟

  5. چرا free یا /proc/meminfo داخل کانتینر گمراه‌کننده است؟

  6. --pids-limit برای چه چیزی خوب است؟

  • بدون محدودیت، یک کانتینر می‌تواند همه‌ی منابع میزبان را بخورد؛ --memory، --cpus، --pids-limit را بگذار.
  • حافظه سقف سخت است: عبور = OOM killer، کد خروج 137، OOMKilled=true. CPU سقف نرم است: فقط throttle.
  • مصرف را با docker stats بسنج؛ سقف را با حاشیه از اوج مصرف بگذار؛ با docker update در لحظه عوض کن.
  • /proc/meminfo در کانتینر گمراه‌کننده است؛ سقف واقعی در /sys/fs/cgroup/memory.max.
  • در Compose: mem_limit، cpus، pids_limit؛ همراه restart تا کانتینر مشکل‌دار دوباره بالا بیاید.
  • روی Docker Desktop سقف VM هم عامل است.
برگه‌ی تقلب این درس
دستورکاری که می‌کند
docker run --memory 512m --cpus 1 IMGسقف حافظه و CPU
docker run --memory 512m --memory-swap 512m IMGبدون swap
docker run --pids-limit 100 IMGسقف تعداد پروسه
docker stats [--no-stream]مصرف زنده/یک‌باره
docker inspect C --format '{{.State.OOMKilled}}'آیا OOM کشته؟
docker update --memory 1g Cتغییر سقف در لحظه
cat /sys/fs/cgroup/memory.maxسقف واقعی حافظه داخل کانتینر
mem_limit / cpus / pids_limitمعادل در Compose