رفتن به محتوای اصلی

Pedram Sarani

یادداشت‌های SRE و DevOps

بازگشت به مقاله‌ها

هسته 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 می‌رسیم.

Diagram showing how CPU limits become quota and period, and how thread count affects throttling time.

شکل ۱: در سازوکار کنترل پهنای باند 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 است.

Diagram comparing ideal proportional CPU sharing in CFS with vruntime-based task selection, showing that the task with the smallest vruntime runs next.

شکل ۲: هر تسک با توجه به وزن خودش 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 زودتر انتخاب می‌شود.

Diagram showing how EEVDF first filters tasks by lag eligibility and then selects the eligible task with the earliest 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
Diagram comparing CPU weight-based proportional sharing with quota-based bandwidth limits and throttling under cgroup v2.

شکل ۴: 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 شناخته می‌شود.

Diagram showing how Kubernetes CPU requests and limits map to cgroup v2 cpu.weight and cpu.max, including BestEffort, Guaranteed, and no-limit cases.

شکل ۵: 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 است.

Diagram showing how short CPU bursts can exhaust cgroup quota and cause throttling in two periods even though average CPU usage is only 40% of the two-CPU limit

شکل ۶: میانگین پنج‌دقیقه‌ای شکل مصرف داخل 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 کافی نیست.

Diagram explaining cgroup v2 cpu.stat metrics, throttle ratio calculation, and illustrative throttling thresholds for different workloads.

شکل ۷: تعداد 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 تبدیل کنیم.

Diagram explaining cgroup v2 CPU pressure stall information and an illustrative timeline where PSI rises before CPU throttling becomes visible.

شکل ۸: مصرف 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 حذف شد؛ چون قرارداد استاندارد نیست و کیوبلت آن را نمی‌شناسد.

Diagram showing how cpu.max.burst adds extra CPU-time budget to a cgroup period, allowing short CPU spikes to finish with less or no throttling without raising the long-term average CPU usage.

شکل ۹: 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 درباره لینوکس و کوبرنتیز است.

منابع و مطالعهٔ بیشتر

  1. EEVDF Schedulerمستندات رسمی

    Linux Kernel Documentationdocs.kernel.orgبازبینی: ۲۱ تیر ۱۴۰۵

    برای مدل lag و virtual deadline، انتخاب taskهای eligible، delayed dequeue و امکان درخواست slice سفارشی.

  2. CFS Bandwidth Controlمستندات رسمی

    Linux Kernel Documentationdocs.kernel.orgبازبینی: ۲۱ تیر ۱۴۰۵

    برای quota و period، پخش runtime بین sliceهای لوکال، hierarchy، cpu.max.burst و آمار throttling.

  3. Control Group v2مستندات رسمی

    Linux Kernel Documentationdocs.kernel.orgبازبینی: ۲۱ تیر ۱۴۰۵

    برای cpu.weight، cpu.max، cpu.max.burst، cpu.stat، cpu.pressure و تفاوت رفتار fair-class با سیستم‌های زمان‌بندی مبتنی بر BPF.

  4. PSI - Pressure Stall Informationمستندات رسمی

    Linux Kernel Documentationdocs.kernel.orgبازبینی: ۲۱ تیر ۱۴۰۵

    برای تعریف some و full، میانگین‌های avg10 و avg60 و avg300 و محدودیت CPU full در سطح کل سیستم.

  5. Extensible Scheduler Classمستندات رسمی

    Linux Kernel Documentationdocs.kernel.orgبازبینی: ۲۱ تیر ۱۴۰۵

    برای معماری sched_ext، چرخه اجرای task و مسئولیت منطق زمان‌بندی مبتنی بر BPF در تفسیر وزن و bandwidth مربوط به cgroup.

  6. Kubernetes Documentationkubernetes.ioبازبینی: ۲۱ تیر ۱۴۰۵

    برای stable شدن In-Place Pod Resize در نسخه 1.35، resize subresource، resizePolicy و وضعیت‌های Pending و InProgress.

  7. Kubernetes Feature Gatesمستندات رسمی

    Kubernetes Documentationkubernetes.ioبازبینی: ۲۱ تیر ۱۴۰۵

    برای وضعیت نسخه‌ای KubeletPSI، InPlacePodVerticalScaling و DisableCPUQuotaWithExclusiveCPUs.

  8. Kubernetes Documentationkubernetes.ioبازبینی: ۲۱ تیر ۱۴۰۵

    برای CPU Manager، سیاست static، تخصیص CPUهای exclusive و محدودیت‌های isolation.

  9. OpenContainersgithub.comبازبینی: ۲۱ تیر ۱۴۰۵

    برای نگاشت جدید و غیرخطی cpu.shares به cpu.weight در نسخه‌های جدید ران‌تایم‌های OCI.

  10. Kubernetes Sourcegithub.comبازبینی: ۲۱ تیر ۱۴۰۵

    برای تبدیل CPU request و limit به shares و quota و رفتار حذف quota برای کانتینرها و پادهای دارای CPUهای exclusive.

  11. Linux Kernel Sourcegithub.comبازبینی: ۲۱ تیر ۱۴۰۵

    برای اینکه fix مربوط به placement در نسخه 6.13 بعدا در فوریه 2026 revert شد و نباید 6.13 را پایان قطعی آن مسئله دانست.