هسته CPU را سریعتر نمیکند؛ فقط زمان را سهمیهبندی میکند
از CFS و EEVDF تا cpu.weight، cpu.max، throttling و PSI؛ این مقاله توضیح میدهد چرا در کوبرنتیز، CPU بیشتر همیشه برنامه را سریعتر نمیکند، limit چطور به سهمیه زمانی تبدیل میشود و چرا یک پاد با میانگین مصرف پایین هم ممکن است throttle شود.
فرض کنیم P99 یک سرویس ناگهان بالا رفته، اما کانتینر کرش نکرده، خبری از OOMKilled نیست و نمودار CPU هم مصرف متوسط حدود ۰٫۸ سیپییو را نشان میدهد؛ یعنی ۴۰ درصد محدودیت دو سیپییو. در چنین وضعیتی throttling یکی از احتمالهای جدی است؛ البته نه تنها احتمال، ولی احتمالی که خیلی راحت پشت میانگینها پنهان میشود.
ماجرا این است: در workloadهای معمول کوبرنتیز که تردها زیر fair-class اجرا میشوند، cgroup در هر پریود فقط مقدار مشخصی CPU-time دارد. اگر سهم خودش یا یکی از والدهایش تمام شود، تردهای runnable باید تا برگشتن runtime صبر کنند. کانتینر pause نشده، پروسس هم نمرده؛ فقط برای مدتی اجازه اجرا روی CPU ندارد.
البته هر مکثی throttling نیست. انتظار برای دیسک یا شبکه، major page fault، وضعیت TASK_UNINTERRUPTIBLE یا گیر کردن روی mutex در کرنل هم میتواند سرویس را کند کند، بدون اینکه cpu.max نقشی داشته باشد.
پس قبل از اینکه limit را بالا ببریم، باید بفهمیم توقف واقعا از quota آمده یا نه. برای رسیدن به جواب، مسیر را از خود سیستم زمانبندی لینوکس شروع میکنیم، بعد به تفاوت weight و bandwidth، فایلهای cpu.stat و cpu.pressure و در نهایت کوبرنتیز و sched_ext میرسیم.
شکل ۱: در سازوکار کنترل پهنای باند CFS، سهمیه در آغاز هر بازه دوباره پر میشود. اگر این سهمیه زودتر تمام شود، بارکاری تا شروع بازه بعدی محدود میماند.
۱. عدالت در CPU یعنی چه؟ ایده پردازندهای که وجود ندارد
در لینوکس، CFS یا Completely Fair Scheduler سالها مسئول fair scheduling بود. ایده اصلیاش هم ساده است: اگر چند تسک با وزن برابر داریم، هرکدام باید تقریبا سهم برابری از زمان CPU بگیرند.
روی CPU واقعی، همه taskها نمیتوانند همزمان اجرا شوند. برای همین CFS برای هر تسک یک vruntime نگه میداشت. هر تسک که نسبت به وزن خودش CPU کمتری گرفته بود، vruntime پایینتری داشت و زودتر دوباره انتخاب میشد.
فرمول سادهشده vruntime در CFS
vruntime_delta = real_time_delta × (1024 / weight)
هرچه weight بیشتر باشد، vruntime آرامتر جلو میرود و task فرصت بیشتری برای اجرا میگیرد. هرچه weight کمتر باشد، vruntime سریعتر بالا میرود و task زودتر جای خودش را به بقیه میدهد.
در مدل داخلی CFS، وزن پیشفرض task با nice برابر صفر، عدد 1024 است.
شکل ۲: هر تسک با توجه به وزن خودش virtual runtime جمع میکند و تسک عقبمانده زودتر به CPU برمیگردد.
پیش از ورود به EEVDF باید دو تصمیم را از هم جدا کنیم. در سیستم چندهستهای یک runqueue سراسری وجود ندارد و هر سیپییو صف اجرای خودش را دارد. کرنل ابتدا با سازوکارهایی مثل wakeup placement، load balancing، CPU affinity و cpuset مشخص میکند یک تسک روی کدام سیپییو قرار بگیرد یا چه زمانی مهاجرت کند. توضیح EEVDF در ادامه درباره انتخاب entity بعدی روی runqueue همان سیپییو است، نه انتخاب یک تسک از میان تمام تسکهای کل ماشین.
۲. عبور از CFS؛ تصمیمگیری EEVDF روی دو محور
گذار لینوکس به EEVDF از کرنل ۶.۶ شروع شد. الگوریتم Earliest Eligible Virtual Deadline First بهجای اینکه فقط عقبماندگی نسبی تسکها را ببیند، انتخاب را در دو مرحله انجام میدهد.
محور اول: امکان اجرا بر پایه lag
برای هر تسک یک مقدار lag داریم:
lag = ideal CPU service - actual CPU service
lag > 0 → task is owed CPU time
lag = 0 → task is exactly at its fair share
lag < 0 → task has received more than its fair share
lag_i = w_i × (V - v_i)
در فرمول دوم، V میانگین وزنی virtual runtime و v_i همان vruntime مربوط به تسک است. lag صفر یا مثبت یعنی تسک از سهم عادلانه خودش جلو نیفتاده و واجد شرایط انتخاب است.
یک ظرافت مهم اینجا وجود دارد. واجد شرایط نبودن به معنی «ممنوعیت مطلق اجرا» نیست. وقتی چند entity روی runqueue رقابت میکنند، EEVDF از بین واجدشرایطها انتخاب میکند؛اما اگر فقط یک تسک آماده اجرا باقی مانده باشد، کرنل همان را اجرا میکند. قرار نیست CPU فقط بهخاطر lag منفی بیکار بماند.
رفتار تسکها هنگام خواب و بیدارشدن
از کرنل ۶.۱۲ و با فعالبودن قابلیت DELAY_DEQUEUE، تسکی که موقتا sleep میشود ممکن است بلافاصله از runqueue حذف نشود. برای مدتی همانجا میماند تا lag منفی آن با جلو رفتن زمان مجازی کمتر شود. این کار اجازه نمیدهد یک تسک با خوابهای کوتاه، مصرف اضافه قبلیاش را پاک کند و هر بار دوباره جلو بیفتد.
محور دوم: Virtual Deadline
بعد از مشخص شدن واجدشرایطها، تسک با virtual deadline زودتر انتخاب میشود:
virtual_deadline = virtual_eligible_time + (request / weight)
در نتیجه، request کوتاهتر میتواند latency انتخاب را کم کند، بدون اینکه لزوما سهم بلندمدت CPU را بیشتر کند.
اسم الگوریتم همان دو مرحله انتخاب را نشان میدهد
EEVDF = Earliest Eligible Virtual Deadline First
اول تسک باید در رقابت معمول eligible باشد. بعد از میان eligibleها، تسک با virtual deadline زودتر انتخاب میشود.
شکل ۳: EEVDF روی دو محور lag و virtual deadline تصمیم میگیرد؛ با این استثنا که در حالت تکentity، CPU را بیدلیل idle نمیگذارد.
۳. دو قرارداد متفاوت: weight در برابر bandwidth
کرنل برای CPU دو نوع قرارداد جدا دارد. قاطی کردن این دو، ریشه بخش بزرگی از برداشتهای اشتباه درباره request و limit است.
قرارداد وزن با cpu.weight
وزن فقط هنگام رقابت معنی پیدا میکند. اگر دو cgroup فعال، فرزندان یک والد مشترک باشند و روی یک سیپییو با وزنهای ۲۰۰ و ۱۰۰ رقابت کنند، گروه اول در همان سطح سلسلهمراتب باید تقریبا دو برابر گروه دوم سهم بگیرد.
root@pedram:~# cat /sys/fs/cgroup/<path>/cpu.weight
وقتی نود بیکار است، ورکلود با weight پایین هم میتواند CPU آزاد را مصرف کند. بنابراین cpu.weight سقف نیست؛ فقط مشخص میکند زیر contention سهمها چطور پخش شوند.
این نسبت سراسری نیست. در cgroup v2، سهم پردازنده در هر سطح میان فرزندان فعال همان والد تقسیم میشود و سپس سهم هر گروه میان فرزندان خودش توزیع میشود. بنابراین وزن دو cgroup در شاخههای متفاوت را نمیتوان بدون درنظرگرفتن وزن و رقابت والدهایشان مستقیما با هم مقایسه کرد.
در cgroup v2، مقدار weight بین ۱ تا ۱۰۰۰۰ است و مقدار پیشفرض ۱۰۰ محسوب میشود. این عدد را با weight داخلی تسک در منطق زمانبندی که برای nice صفر معمولا 1024 است نباید قاطی کنیم(دوتا چیز جدا حساب میشن).
قرارداد bandwidth با cpu.max
فایل cpu.max در cgroup v2 دو مقدار دارد:
MAX PERIOD
برای نمونه:
200000 100000
این مقدار یعنی گروه در هر پریود صد میلیثانیهای، حداکثر دویست میلیثانیه CPU-time دارد. روی یک سیستم چندهستهای، دویست میلیثانیه CPU-time در صد میلیثانیه wall time یعنی ظرفیت متوسط دو CPU.
اگر runtime تمام شود، تردهای fair-class تا پریود بعدی throttle میشوند؛ حتی وقتی چند CPU روی نود بیکار مانده باشند.
برای workload معمول کوبرنتیز، مدل ذهنی ساده این است:
requests.cpu → relative weight
limits.cpu → quota and period
شکل ۴: weight فقط هنگام رقابت اثر دارد؛ bandwidth برای fair-class یک سقف زمانی میسازد.
۴. limit چطور به زمان تبدیل میشود؟
وقتی در کوبرنتیز این مقدار را مینویسیم:
resources:
limits:
cpu: "2"
کیوبلت limit را به quota و پریود برای CRI تبدیل میکند و رانتایم آن را روی cgroup مینویسد.
allowed_cpu_rate = quota / period
limits.cpu: "2"
quota = 200000 µs
period = 100000 µs
cpu.max = "200000 100000"
limits.cpu: "500m"
quota = 50000 µs
period = 100000 µs
cpu.max = "50000 100000"
این نسبت سقف متوسط مجاز را نشان میدهد، نه ظرفیت تضمینشده. مصرف واقعی همچنان به تعداد سیپییوهای قابلاستفاده، cpuset و میزان رقابت روی نود بستگی دارد.
پریود پیشفرض در کوبرنتیز صد میلیثانیه است. نام فیلد کیوبلت cpuCFSQuotaPeriod و نام فلگ آن —cpu-cfs-quota-period است.
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cpuCFSQuota: true
cpuCFSQuotaPeriod: 100ms
مقدار cpu.max | معنی کاربردی |
|---|---|
"100000 100000" | ظرفیت متوسط یک CPU |
"200000 100000" | ظرفیت متوسط دو CPU |
"50000 100000" | ظرفیت متوسط نیم CPU |
"max 100000" | بدون limit مربوط به bandwidth |
والدها هم میتوانند throttle کنند
cgroup ساختار درختی دارد. ممکن است کانتینر هنوز quota خودش را تمام نکرده باشد، اما چون cgroup پاد یا یکی از والدهای بالاتر runtime ندارد، باز هم throttle شود.
leaf cgroup consumes its own quota
ancestor cgroup consumes its quota
در حالت دوم، باقیماندن runtime در cgroup فرزند کمکی نمیکند؛ چون محدودیت اعمالشده در یکی از والدها همچنان کل زیرشاخه را متوقف میکند. هنگام عیبیابی نباید فقط cpu.max و cpu.stat کانتینر را ببینیم، بلکه باید مسیر cgroup را تا والدها دنبال کنیم. محدودیتهای سطح بالاتر در cgroup v2 توسط فرزند قابل دورزدن نیستند.برای نمونه، ابتدا روی نود مسیر cgroup پروسس کانتینر را پیدا میکنیم:
root@pedram:~# cat /proc/<pid>/cgroup
سپس از cgroup کانتینر بهسمت والدها بالا میرویم و در هر سطح مقدارهای cpu.max و شمارندههای throttling را بررسی میکنیم:
cg="/sys/fs/cgroup$(cut -d: -f3 /proc/<pid>/cgroup)"
while [ "$cg" != "/sys/fs/cgroup" ]; do
echo "== $cg =="
cat "$cg/cpu.max"
grep -E 'nr_periods|nr_throttled|throttled_usec' "$cg/cpu.stat"
cg=$(dirname "$cg")
done
موقع بازتولید مشکل، ببینیم شمارندههای throttling در کدام سطح بالا میروند. اگر در cgroup کانتینر تغییری نیست اما در یکی از والدها nr_throttled یا throttled_usec زیاد میشود، محدودیت از همان والد اعمال شده است. مقدار cpu.max هم کوتا و پریود همان سطح را نشان میدهد.
چرا هنوز اسم CFS Bandwidth Control را میبینیم؟
این اسم تاریخی همان زیرسیستم است. EEVDF روش انتخاب fair task را تغییر داد، اما منطق کوتا و پریود برای fair-class همچنان با نام CFS Bandwidth Control شناخته میشود.
شکل ۵: request و limit ابتدا در کیوبلت و CRI ترجمه میشوند و رانتایم آنها را در hierarchy مربوط به cgroup اعمال میکند.
۵. چرا با مصرف متوسط ۰٫۸ سیپییو هم throttle میخوریم؟
در این مثال، محدودیت ورکلود دو سیپییو است و داشبورد مصرف متوسط را حدود ۰٫۸ سیپییو نشان میدهد؛ یعنی ۴۰ درصد همان محدودیت دو سیپیویی، نه ۴۰ درصد ظرفیت کل نود. داشبورد معمولا مصرف را در بازههای یک یا پنج دقیقهای میانگین میگیرد، اما bandwidth controller با دورههایی مثل صد میلیثانیه کار میکند. برای همین ممکن است burstهای کوتاه در نمودار میانگین دیده نشوند.
فرض کنیم ورکلود هشت ترد دارد و محدودیت آن دو CPU است.در هر دوره صد میلیثانیهای، چون محدودیت برابر دو سیپییو است، ورکلود مجموعا دویست میلیثانیه زمان پردازنده در اختیار دارد. اگر هر هشت ترد همزمان روی هشت CPU اجرا شوند، کل این سهمیه تقریبا در بیستوپنج میلیثانیه مصرف میشود و تردها باید تا شروع دوره بعدی منتظر بمانند.
این عدد یک مثال سادهشده است؛ چون affinity، رقابت روی نود و رفتار خود برنامه روی نتیجه اثر میگذارند. نکته اصلی این است که همزمانی بالا میتواند سهمیه را خیلی زودتر از پایان دوره تمام کند.
runtime چطور بین CPUها پخش میشود؟
کرنل quota را در یک global pool نگه میدارد و runtime را در اسلایسهای کوچک به runqueue لوکال هر CPU میدهد.
root@pedram:~# sysctl kernel.sched_cfs_bandwidth_slice_us
مقدار پیشفرض slice معمولا پنج میلیثانیه است:
kernel.sched_cfs_bandwidth_slice_us = 5000
در پیادهسازی فعلی، slice دادهشده به یک CPU در پایان پریود کاملا expire نمیشود. اگر همه تردهای آن cgroup روی همان CPU از حالت runnable خارج شوند، تقریبا تمام runtime باقیمانده میتواند به گلوبال پول برگردد و حدود یک میلیثانیه لوکال بماند. همین مقدار کوچک ممکن است در پریود بعدی یک burst کوتاه بسازد.
این رفتار را با cpu.max.burst قاطی نکنیم. runtime باقیمانده در slice لوکال، جزئی از accounting هر CPU است و بین coreها منتقل نمیشود؛ اما cpu.max.burst اعتبار جداگانهای در سطح خود cgroup است.
شکل ۶: میانگین پنجدقیقهای شکل مصرف داخل periodهای صد میلیثانیهای را نشان نمیدهد.
۶. متریکهای cpu.stat؛ تعداد و مدت را جدا ببینیم
فایل cpu.stat در cgroup v2 حداقل سه مقدار عمومی دارد:
usage_usec
user_usec
system_usec
این سه مقدار مصرف همه پروسسهای cgroup را حساب میکنند.
وقتی CPU controller فعال باشد، فیلدهای زیر هم دیده میشوند:
nr_periods
nr_throttled
throttled_usec
nr_bursts
burst_usec
طبق مستندات فعلی کرنل، این پنج فیلد فقط پروسسهای fair-class را حساب میکنند. اگر ورکلود زیر sched_ext یا scheduling class دیگری اجرا میشود، نباید بدون بررسی همان منطق زمانبندی، این counterها را نماینده کل ورکلود فرض کنیم.
یک خروجی نمونه میتواند شبیه این باشد:
usage_usec 4392847291
user_usec 3125482930
system_usec 1267364361
nr_periods 521948
nr_throttled 162847
throttled_usec 84762138272
nr_bursts 14391
burst_usec 3219847
مقدار nr_bursts تعداد دورههایی را نشان میدهد که مصرف گروه از سهمیه عادی عبور کرده و از اعتبار burst استفاده شده است. مقدار burst_usec هم مجموع زمان استفاده از این اعتبار را برحسب میکروثانیه نشان میدهد.
نسبت پریودهای throttleشده از این فرمول به دست میآید:
throttle_period_ratio = nr_throttled / nr_periods
این نسبت فقط میگوید در چند درصد پریودها دستکم یک throttling ثبت شده است. از روی آن نمیفهمیم هر توقف چند میکروثانیه یا چند ده میلیثانیه طول کشیده.
برای تحلیل درست، این سیگنالها را باید کنار هم بزاریم:
throttled period rate
throttled time
request rate
P95/P99 latency
CPU pressure
متریکهای Prometheus
100
* rate(container_cpu_cfs_throttled_periods_total[5m])
/ rate(container_cpu_cfs_periods_total[5m])
برای دیدن زمان throttling:
rate(container_cpu_cfs_throttled_seconds_total[5m])
هر سری سطح کانتینر، آمار cpu.stat همان cgroup را نشان میدهد. اگر throttling از cgroup پاد یا یکی از والدهای بالاتر اعمال شود، ممکن است شمارندههای کانتینر تغییری نکنند. بنابراین صفر بودن متریکهای کانتینر، throttling والد را رد نمیکند؛ در این حالت باید سطح پاد و والد یا مسیر cgroup روی نود را هم بررسی کنیم.
خروجی این کوئری درصد وال تایم نیست، بلکه نرخ افزایش زمان تجمعی throttling است. چون کرنل مدت throttling مربوط به runqueueهای سیپییوهای مختلف را با هم جمع میکند، این نرخ در یک ورکلود چندسیپییویی میتواند از یک نیز بیشتر شود. بنابراین باید آن را بهعنوان شدت تجمعی throttling در کنار نسبت periodهای محدودشده، PSI، مصرف پردازنده و latency بررسی کنیم.
برای جداکردن «مصرف پردازنده» از «منتظرماندن برای پردازنده»: در صورت انتشار این متریک توسط cAdvisor، زمان انتظار روی runqueue را هم بررسی میکنیم:
rate(container_cpu_schedstat_runqueue_seconds_total[5m])
این counter مدت زمانی را جمع میکند که پردازشهای کانتینر runnable بودهاند، اما روی runqueue منتظر دریافت CPU ماندهاند. بالا رفتن آن میتواند رقابت واقعی، محدودیت ناشی از affinity یا cpuset و اشباعشدن بخشی از سیپییوها را نشان دهد؛ حتی وقتی مصرف متوسط کل نود پایین است. برای بررسی یک پروسس مشخص نیز /proc/<pid>/schedstat نقطه شروع مناسبی است.
یک threshold ثابت مثل پنج یا بیستوپنج درصد برای همه سرویسها معنی ندارد. یک batch job شاید ratio بالا را تحمل کند، اما یک سرویس حساس به latency ممکن است با چند توقف کوتاه در زمان بد، SLO را از دست بدهد. بیسلاین را از رفتار سالم همان سرویس باید بسازیم.
مسیر cgroup را درست پیدا کنیم
داخل کانتینری که cgroup namespace دارد، این فایلها معمولا cgroup همان کانتینر را نشان میدهند:
root@pedram:~# cat /sys/fs/cgroup/cpu.stat
root@pedram:~# cat /sys/fs/cgroup/cpu.max
برای دیدن hierarchy کامل معمولا باید روی نود، مسیر واقعی کانتینر، پاد و والدهایش را پیدا کنیم. خروجی leaf داخل کانتینر برای پیدا کردن parent throttling کافی نیست.
شکل ۷: تعداد periodهای throttleشده و مدت throttling دو سیگنال متفاوت هستند و باید کنار latency دیده شوند.
۷. متریک دوم؛ PSI درباره انتظار چه میگوید؟
مکانیزم PSI یا Pressure Stall Information بهجای اینکه فقط مصرف CPU را ببیند، زمانی را اندازه میگیرد که کمبود resource روی تسکها اثر گذاشته است.
root@pedram:~# cat /proc/pressure/cpu
خروجی میتواند شبیه این باشد:
some avg10=2.45 avg60=1.23 avg300=0.48 total=12450923
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
مقدار some سهم زمانی را نشان میدهد که دستکم بعضی تسکها برای CPU منتظر بودهاند. مقدار full زمانی است که همه تسکهای non-idle در scope موردنظر همزمان stalled بودهاند.
برای CPU، مقدار full در سطح کل سیستم تعریف نشده و برای سازگاری صفر گزارش میشود. در سطح cgroup اما معنا دارد:
root@pedram:~# cat /sys/fs/cgroup/<path>/cpu.pressure
معنادار بودن full به وجود cpu.max وابسته نیست. contention یا محدودیت در یکی از والدها هم میتواند همه taskهای یک cgroup را همزمان متوقف کند.
PSI و throttling دو چیز متفاوتاند
متریک throttling میگوید bandwidth controller محدودیت را enforce کرده است. PSI میگوید تسکها بهخاطر کمبود CPU چه مقدار stall تجربه کردهاند.
ممکن است PSI بالا برود ولی nr_throttled صفر باشد؛ مثلا وقتی limit نداریم اما CPU شلوغ است. برعکس، ممکن است throttling ثبت شود ولی average مربوط به PSI در یک پنجره بلند تغییر زیادی نکند. پس PSI همیشه قبل از throttling هشدار نمیدهد و باید این دو را کنار هم بخوانیم.
وضعیت PSI در کوبرنتیز
قابلیت KubeletPSI در نسخه ۱.۳۳ آلفا و خاموش بود، در نسخههای ۱.۳۴ و ۱.۳۵ به بتا رسید و پیشفرض روشن شد، و از کوبرنتیز ۱.۳۶ به GA رسید.
این قابلیت دادههای PSI را از طریق Summary API و متریکهای کیوبلت منتشر میکند. پشتیبانی کرنل و استفاده از cgroup v2 هم باید روی نود بررسی شود.
برای PSI هم threshold جهانی نداریم. عدد مناسب به تعداد ورکرها، الگوی latency و SLO سرویس بستگی دارد. اول رفتار سالم را ثبت کنیم، بعد deviation معنادار را به alert تبدیل کنیم.
شکل ۸: مصرف CPU، throttling و PSI سه زاویه متفاوت از رفتار یک workload را نشان میدهند.
۸. دو نوع burst؛ cpu.max.burst را با slice لوکال قاطی نکنیم
فایل cpu.max.burst در cgroup v2 یک اعتبار burst در سطح خود cgroup تعریف میکند. مقدار آن باید بین صفر و MAX مربوط به cpu.max باشد و مقدار پیشفرض صفر است.
root@pedram:~# echo "100000 100000" > /sys/fs/cgroup/my-cgroup/cpu.max
root@pedram:~# echo 50000 > /sys/fs/cgroup/my-cgroup/cpu.max.burst
در این مثال، ظرفیت پایه یک CPU است و cgroup در صورت داشتن underrun قبلی میتواند تا پنجاه میلیثانیه اعتبار اضافه برای burst داشته باشد.
این قابلیت ظرفیت پردازشی جدیدی ایجاد نمیکند. ورکلود میتواند بخشی از سهم استفادهنشده قبلی را هنگام burst مصرف کند و در همان بازه فشار بیشتری به بقیه ورکلودها وارد کند. مصرف پایدار آن همچنان باید با میانگین محدودیت تعیینشده سازگار بماند.
تفاوت با runtime باقیمانده روی هر CPU
در کنار cpu.max.burst، کنترلر پهنای باند ممکن است حدود یک میلیثانیه از سهمیه مصرفنشده را در ذخیره لوکال هر سیپییو نگه دارد. این مقدار کوچک به سیپییو دیگری منتقل نمیشود و ممکن است در دوره بعدی امکان یک burst کوتاه را فراهم کند.
پس با دو مفهوم جدا طرفیم:
cpu.max.burst
→ bounded cgroup-level credit based on underrun
cpu-local retained slice
→ small runtime remainder on a local runqueue
وضعیت در کوبرنتیز
کوبرنتیز فیلد استانداردی در Pod spec برای تنظیم cpu.max.burst ندارد. اضافه کردن یک annotation دلخواه هم بهخودیخود چیزی را روی نود تغییر نمیدهد.
برای استفاده از این قابلیت، یک component روی نود مثل agent یا runtime hook باید مقدار cgroup را بنویسد. پروژههایی مثل Koordinator برای این کار، API و ایجنت مخصوص خودشان را دارند.\
نمونه annotation قبلی با نام cpu-burst.kubernetes.io/burst حذف شد؛ چون قرارداد استاندارد نیست و کیوبلت آن را نمیشناسد.
شکل ۹: burst میتواند spike کوتاه را نرم کند، اما ظرفیت پایدار workload را بالا نمیبرد.
۹. request و limit در کوبرنتیز به چه چیزی تبدیل میشوند؟
مسیر requests.cpu تا cpu.weight
کوبرنتیز در CRI هنوز CPU request را بهشکل shares میفرستد. رانتایم روی cgroup v2 این shares را به cpu.weight تبدیل میکند.
مدل تاریخی تبدیل خطی اینطور بود:
cpu.weight = 1 + ((shares - 2) × 9999) / 262142
در این مدل، 1024 shares که تقریبا معادل request یک CPU است، به weight نزدیک 40 تبدیل میشد؛ درحالیکه مقدار پیشفرض cgroup v2 برابر 100 است.
نسخههای جدید کتابخانه OpenContainers cgroups از یک مدل غیرخطی استفاده میکنند که minimum، maximum و default دو مدل را بهتر روی هم میاندازد:
l = log2(shares)
exponent = (l² + 125l) / 612 - 7/34
cpu.weight = ceil(10^exponent)
در این مدل، 1024 shares به عددی نزدیک default یعنی 100 میرسد.
برای حداقل CPU shares، تبدیل فعلی OpenContainers مقدار weight برابر ۱ میدهد، نه ۲. جدولهایی که BestEffort را همیشه cpu.weight=2 نشان میدهند ممکن است بر اساس مدل قدیمی یا یک لایه دیگر از hierarchy باشند.
از دید اسکجولر کوبرنتیز، مقدار
requestبرای انتخاب نود و محاسبه ظرفیت استفاده میشود. بعد از قرار گرفتن پاد روی نود، این مقدار در کرنل به وزن نسبی تبدیل میشود و بهخودیخود یک سیپییو فیزیکی را رزرو نمیکند؛ مگر اینکه CPU Manager و تخصیص سیپییو اختصاصی وارد ماجرا شوند.
این وزن فقط در سطح کانتینر معنا پیدا نمیکند. کانتینر داخل
cgroupپاد قرار دارد و خود پاد نیز زیر والدهای بالاتر، مانند گروه QoS، قرار میگیرد. بنابراین سهم واقعی کانتینر به وزن و رقابت موجود در تمام این سطوح وابسته است. اگر منابع سطح پاد تعریف شده باشند، همان سطح نیز در تقسیم وزن و اعمال محدودیت نقش دارد.
مسیر limits.cpu تا cpu.max
برای ورکلود معمول fair-class:
resources.limits.cpu: 500m
→ quota = 50000
→ period = 100000
→ cpu.max = "50000 100000"
بدون CPU limit، مقدار leaf معمولا محدودیت bandwidth ندارد:
cpu.max = "max 100000"
البته یکی از والدها همچنان میتواند محدودیت داشته باشد.
QoS را با رزرو CPU یکی نگیریم
پاد دارای QoS برابر Guaranteed فقط وقتی برای CPUهای exclusive واجد شرایط میشود که CPU Manager روی سیاست static باشد و request مربوط به CPU کانتینر عدد صحیح باشد.
Guaranteed بهتنهایی cpu.max را حذف نمیکند. اگر limit برابر request باشد ولی CPU اختصاصی نگرفته باشیم، quota هنوز میتواند throttling ایجاد کند.
خود CPU throttling هم بهتنهایی دلیل eviction نیست. eviction مسیر جداگانه کیوبلت برای pressureهایی مثل memory و disk است.
۱۰. برای workload حساس؛ CPUهای exclusive چه کمکی میکنند؟
سیاست static در CPU Manager میتواند CPUهای exclusive را به کانتینرهای واجد شرایط بدهد.
شرایط اصلی اینها هستند:
Pod QoS is Guaranteed
container CPU request is an integer
CPU Manager policy is static
نمونه تنظیم کیوبلت:
cpuManagerPolicy: static
reservedSystemCPUs: "0-1"
نمونه resource کانتینر:
resources:
requests:
cpu: "2"
memory: "1Gi"
limits:
cpu: "2"
memory: "1Gi"
cpuset اجرای userspace کانتینر را به CPUهای مشخص محدود میکند، اما isolation کامل سختافزاری نمیسازد. IRQ، ksoftirqd، kworker، RCU و تردهای کرنل هنوز ممکن است روی همان CPU اجرا شوند.
سیپییو اختصاصی در CPU Manager یک logical CPU است و لزوما یک هسته فیزیکی کامل نیست. روی سیستمهایی که SMT فعال دارند، دو logical CPU ممکن است siblingهای یک هسته فیزیکی باشند و منابع اجرایی همان هسته را با هم شریک شوند. اگر ورکلود به تخصیص هسته فیزیکی کامل نیاز دارد، گزینه full-pcpus-only در تنظیمات حالت static باعث میشود تمام threadهای یک هسته فیزیکی با هم تخصیص داده شوند. این گزینه همچنان فعالیتهای کرنل، وقفهها و اشتراک cacheهای سطوح بالاتر را حذف نمیکند.
برای workloadهای بسیار حساس به latency باید تنظیمات سیستمعامل مثل IRQ affinity، reserved CPUs، NUMA alignment و در بعضی سناریوها nohz_full را هم بررسی کرد.
حذف quota برای CPUهای exclusive
قابلیت DisableCPUQuotaWithExclusiveCPUs به کیوبلت اجازه میدهد quota کانتینری را که واقعا سیپییو اختصاصی گرفته غیرفعال کند. Guaranteed بودن بهتنهایی کافی نیست. در مسیر فعلی کیوبلت، اگر پاد دارای سیپییو اختصاصی باشد، اعمال quota در سطح cgroup پاد نیز غیرفعال میشود.
این رفتار throttling ناشی از quota کانتینر و پاد را حذف میکند، اما همه علتهای latency را از بین نمیبرد. والدهای بالاتر، cpuset، توپولوژی پردازنده، رقابت سختافزاری و فعالیتهای کرنل همچنان میتوانند روی اجرای ورکلود اثر بگذارند.
۱۱. تغییر limit بدون ساخت دوباره پاد؛ In-Place Pod Resize
قابلیت In-Place Pod Resize از کوبرنتیز ۱.۳۵ stable و پیشفرض فعال است. با این قابلیت میتوانیم request یا limit مربوط به CPU و memory کانتینر را بدون recreate کردن پاد تغییر دهیم.
عبارت «بدون downtime» را نباید مطلق به کار برد. مستندات میگویند resize میتواند از disruption جلوگیری کند؛ اما نتیجه به resizePolicy، نوع resource، رانتایم و وضعیت نود بستگی دارد.
برای CPU معمولا این policy استفاده میشود:
spec:
containers:
- name: app
resizePolicy:
- resourceName: cpu
restartPolicy: NotRequired
- resourceName: memory
restartPolicy: RestartContainer
با kubectl نسخه ۱.۳۲ یا جدیدتر میتوان resize subresource را patch کرد:
root@pedram:~# kubectl patch pod my-pod \
--subresource=resize \
--type=merge \
-p '{
"spec": {
"containers": [{
"name": "app",
"resources": {
"requests": {"cpu": "1000m"},
"limits": {"cpu": "2000m"}
}
}]
}
}'
مقدار desired و مقدار واقعا اعمالشده:
root@pedram:~# kubectl get pod my-pod \
-o jsonpath='{.status.containerStatuses[?(@.name=="app")].resources}'
کیوبلت وضعیت درخواست تغییر منابع را با شرطهای PodResizePending و PodResizeInProgress گزارش میکند:
root@pedram:~# kubectl get pod my-pod \
-o jsonpath='{range .status.conditions[*]}{.type}{"\t"}{.status}{"\t"}{.reason}{"\t"}{.message}{"\n"}{end}'
مقادیر Deferred و Infeasible دلیلهایی هستند که زیر شرط PodResizePending گزارش میشوند. تغییر spec به معنی اعمال فوری و قطعی در رانتایم نیست.
محدودیتهای مهم
اول اینکه resize نباید QoS class پاد را تغییر دهد.
دوم اینکه در تنظیمات پیشفرض کوبرنتیز ۱.۳۶ و بدون فعالکردن feature gateهای آلفای InPlacePodVerticalScalingExclusiveCPUs و InPlacePodVerticalScalingExclusiveMemory، پادهایی که زیر حالت static مربوط به CPU Manager یا Memory Manager مدیریت میشوند، از مسیر عمومی تغییر منابع درجا قابل تغییر نیستند.
سوم اینکه فقط CPU و memory را میتوان از این مسیر resize کرد.
چهارم اینکه وضعیت واقعی اعمالشده را باید از status خواند، نه فقط از spec.
وضعیت VPA
حالت InPlaceOrRecreate به نسخه خود VPA مربوط است، نه به وضعیت feature در کوبرنتیز ۱.۳۵. این حالت در VPA نسخه ۱.۴ آلفا، در ۱.۵ بتا و در ۱.۶ پایدار شد و به قابلیت resize کوبرنتیز ۱.۳۳ یا جدیدتر تکیه دارد.
در این مود، VPA اول in-place update را امتحان میکند و اگر ممکن نباشد میتواند پاد را دوباره بسازد. خود اسم مود هم نشان میدهد که zero-disruption تضمین قطعی نیست.
۱۲. sched_ext؛ جایی که cpu.max دیگر بدیهی نیست
قابلیت sched_ext از کرنل ۶.۱۲ وارد شد و اجازه میدهد policy زمانبندی با BPF پیادهسازی شود.
struct sched_ext_ops {
s32 (*select_cpu)(struct task_struct *p, s32 prev_cpu, ...);
void (*enqueue)(struct task_struct *p, u64 enq_flags);
void (*dispatch)(s32 cpu, struct task_struct *prev);
void (*running)(struct task_struct *p);
void (*stopping)(struct task_struct *p, bool runnable);
// ...
};
منطق زمانبندی مبتنی بر BPF میتواند queue، dispatch، slice و placement را خودش مدیریت کند. اگر این منطق خطا کند یا stall شود، کرنل مکانیزم fallback دارد. همین انعطاف باعث میشود نتوانیم رفتار cgroup در fair scheduler را خودکار به sched_ext بدهیم.
رفتار cpu.weight
در مستندات فعلی cgroup v2، فایل cpu.weight روی fair-class اثر دارد. برای تسکهای زیر منطق زمانبندی مبتنی بر BPF فقط وقتی معنی پیدا میکند که آن پیادهسازی callback مربوط به cgroup_set_weight را داشته باشد و واقعا weight را در policy خودش اعمال کند.
جهت رابطه هم مهم است. callback به این معنی نیست که منطق زمانبندی مبتنی بر BPF از داخل خودش cpu.weight را تنظیم میکند. وقتی تنظیم cgroup عوض میشود، کرنل تغییر را به آن اطلاع میدهد و خود پیادهسازی تصمیم میگیرد چه برداشتی از آن داشته باشد.
رفتار cpu.max
طبق مستندات فعلی cgroup v2، فایلهای cpu.max و cpu.max.burst فقط پروسسهای fair-class را محدود میکنند. بنابراین این فرض که cpu.max مستقل از sched_ext همیشه همان رفتار CFS را ادامه میدهد، درست نیست.
نسخههای جدید sched_ext callback مربوط به تغییر bandwidth در cgroup دارند، اما enforce کردن قرارداد quota به policy و پیادهسازی همان منطق بستگی دارد. وجود callback به معنی CFS throttling خودکار نیست.
همین مرز در cpu.stat هم دیده میشود. counterهای nr_periods، nr_throttled و throttled_usec فقط fair-class را حساب میکنند.
قبل از استفاده از sched_ext روی ورکلود کانتینری، این رفتارها را برای همان پیادهسازی بهتر است تست کنیم:
interpretation of cpu.weight
enforcement of cpu.max
hierarchical bandwidth behavior
meaning of cpu.stat counters
fallback behavior
۱۳. چند برداشت اشتباه که باید کنار گذاشت
«میانگین CPU پایین است، پس throttling نداریم»
نه. میانگین بلندمدت شکل مصرف داخل پریود را نشان نمیدهد. cpu.stat، مدت throttling و latency را کنار هم ببینیم.
«هر nr_throttled بالایی یعنی latency خراب شده»
نه. nr_throttled فقط تعداد پریودهای محدودشده را نشان میدهد، نه مدت توقف و اثر آن روی کاربر. throttled time و SLO همان سرویس هم لازماند.
«با EEVDF دیگر throttling نداریم»
برای ورکلودهای معمول fair-class درست نیست. EEVDF انتخاب تسک را عوض میکند؛ bandwidth controller همچنان کوتا را enforce میکند.
«تسک با lag منفی هیچوقت اجرا نمیشود»
درست نیست. واجد شرایط بودن وقتی مهم است که چند entity رقابت میکنند. اگر فقط یک entity runnable مانده باشد، EEVDF همان را اجرا میکند.
«requests.cpu یعنی CPU رزروشده»
مقدار request در زمانبندی پاد برای انتخاب نود و محاسبه ظرفیت استفاده میشود. بعد از اجرای پاد روی نود، این مقدار فقط سهم نسبی آن را هنگام رقابت بر سر سیپییو تعیین میکند؛ نه اینکه یک سیپییو فیزیکی برای پاد رزرو شود. تخصیص سیپییو اختصاصی موضوع جداگانهای است و به CPU Manager مربوط میشود.
«Guaranteed یعنی بدون throttling»
Guaranteed فقط یکی از شرطهای CPU Manager static است. تا وقتی CPU exclusive نگرفتهای و quota حذف نشده، cpu.max میتواند همچنان اعمال شود.
«cpu.max.burst با یک annotation استاندارد فعال میشود»
کوبرنتیز چنین API استانداردی ندارد. یک agent روی نود یا پلتفرم اختصاصی باید مقدار cgroup را تغییر دهد.
«PSI همیشه زودتر از throttling هشدار میدهد»
هیچ ترتیب زمانی عمومی بین این دو وجود ندارد. PSI فشار و stall را میسنجد؛ counter throttling اعمال bandwidth limit را.
«در sched_ext هم cpu.max همان رفتار CFS را دارد»
طبق مستندات فعلی، cpu.max برای fair-class است. منطق زمانبندی مبتنی بر BPF باید semantics مربوط به bandwidth را خودش پشتیبانی کند.
نتیجه: limit یک قرارداد زمانی است
وقتی limits.cpu: "1" میگذاریم و پریود همان صد میلیثانیه پیشفرض است، برای ورکلود معمول fair-class قراردادی میسازیم که در هر صد میلیثانیه wall time، حدود صد میلیثانیه CPU-time دارد.
این قرارداد تعداد تردها را محدود نمیکند. چند ترد میتوانند runtime را خیلی سریعتر مصرف کنند. محدودیت هم فقط روی leaf نیست؛ یک cgroup والد هم ممکن است ورک لود را throttle کند.
برای عیبیابی، بهجای یک نمودار تنها، این مسیر را برویم بهتر است:
read cpu.max for the leaf and its ancestors
check cpu.stat for frequency and duration
compare cpu.pressure with latency
inspect application parallelism
record kernel, kubelet, and runtime versions
test the active sched_ext policy separately
جعبهابزار سریع
# cgroup CPU controls
root@pedram:~# cat /sys/fs/cgroup/cpu.max
root@pedram:~# cat /sys/fs/cgroup/cpu.weight
root@pedram:~# cat /sys/fs/cgroup/cpu.stat
root@pedram:~# cat /sys/fs/cgroup/cpu.pressure
# system-wide CPU pressure
root@pedram:~# cat /proc/pressure/cpu
# CFS bandwidth slice
root@pedram:~# sysctl kernel.sched_cfs_bandwidth_slice_us
# kernel version
root@pedram:~# uname -r
# tasks in D-state
root@pedram:~# ps -eo state,pid,comm | grep '^D'
# pod resize status
root@pedram:~# kubectl get pod my-pod -o yaml
100
* rate(container_cpu_cfs_throttled_periods_total[5m])
/ rate(container_cpu_cfs_periods_total[5m])
rate(container_cpu_cfs_throttled_seconds_total[5m])
© saranipedram.github.io
این مقاله بخشی از مجموعه یادداشتهای SRE درباره لینوکس و کوبرنتیز است.
منابع و مطالعهٔ بیشتر
- EEVDF Schedulerمستندات رسمی
برای مدل lag و virtual deadline، انتخاب taskهای eligible، delayed dequeue و امکان درخواست slice سفارشی.
- CFS Bandwidth Controlمستندات رسمی
برای quota و period، پخش runtime بین sliceهای لوکال، hierarchy، cpu.max.burst و آمار throttling.
- Control Group v2مستندات رسمی
برای cpu.weight، cpu.max، cpu.max.burst، cpu.stat، cpu.pressure و تفاوت رفتار fair-class با سیستمهای زمانبندی مبتنی بر BPF.
- PSI - Pressure Stall Informationمستندات رسمی
برای تعریف some و full، میانگینهای avg10 و avg60 و avg300 و محدودیت CPU full در سطح کل سیستم.
- Extensible Scheduler Classمستندات رسمی
برای معماری sched_ext، چرخه اجرای task و مسئولیت منطق زمانبندی مبتنی بر BPF در تفسیر وزن و bandwidth مربوط به cgroup.
برای stable شدن In-Place Pod Resize در نسخه 1.35، resize subresource، resizePolicy و وضعیتهای Pending و InProgress.
- Kubernetes Feature Gatesمستندات رسمی
برای وضعیت نسخهای KubeletPSI، InPlacePodVerticalScaling و DisableCPUQuotaWithExclusiveCPUs.
- CPU Management Policies on the Nodeمستندات رسمی
برای CPU Manager، سیاست static، تخصیص CPUهای exclusive و محدودیتهای isolation.
برای نگاشت جدید و غیرخطی cpu.shares به cpu.weight در نسخههای جدید رانتایمهای OCI.
برای تبدیل CPU request و limit به shares و quota و رفتار حذف quota برای کانتینرها و پادهای دارای CPUهای exclusive.
برای اینکه fix مربوط به placement در نسخه 6.13 بعدا در فوریه 2026 revert شد و نباید 6.13 را پایان قطعی آن مسئله دانست.