הקצאת משאבים והתנהגות תחת מגבלה¶
הרשאה לבצע עבודה אינה הבטחה לקבל משאב¶
נניח ששירות רשאי לקרוא קובץ וליצור תהליכים. ההרשאות האלה אינן עונות לשאלה כמה זיכרון הוא יקצה, כמה זמן מעבד יקבל וכמה משימות ייצור. מרחבי שמות משנים את התצוגה של משאבים, בעוד קבוצות בקרה מנהלות חשבונאות ומגבלות לאורך עץ. תהליך יכול לראות מספר גדול של מעבדים ולהיות מוגבל בפועל לחלק מזמן המעבד שלהם.
הגרסה השנייה של ממשק קבוצות הבקרה, המכונה cgroup v2, משתמשת בהיררכיה אחידה. תהליך נמצא בקבוצה, והילדים שהוא יוצר יורשים את החברות המתאימה. העברת הורה לקבוצה אחרת אינה מעבירה אוטומטית ילדים שכבר נוצרו. מגבלה בענף פנימי חלה גם על צאצאיו, ולכן קבוצה עמוקה אינה יכולה להעניק לעצמה משאבים שההורה אינו רשאי לצרוך.
מי רשאי לנהל את העץ¶
נבדוק את הקובץ /proc/self/cgroup ואת סוג העיגון של /sys/fs/cgroup. בסביבת המעבדה נצפה למערכת בקרה מהגרסה השנייה. רשימת הבקרים הזמינים מופיעה ב־cgroup.controllers, והבקרים המופעלים עבור ילדים מופיעים ב־cgroup.subtree_control. קיום קובץ במערכת אינו מבטיח שהבקר מופעל בכל צומת בדרך אל התהליך.
האצלה מאפשרת למנהל שירות להעביר שליטה מוגבלת בתת־עץ למפעיל אחר. היא מחייבת גבול ברור של קבצים והרשאות, ועמידה בכללי מבנה העץ. בבקרי משאבים רגילים קיימת מגבלה על שילוב תהליכים בצומת פנימי שמפיץ משאבים לילדים. לכן לא ניצור תיקיות אקראיות ליד קבוצות שמנהל המערכת כבר מחזיק ונניח שזו סביבת ניסוי תקינה.
במעבדה נשתמש בפקודה systemd-run כדי לבקש ממנהל המערכת ליצור קבוצה עבור כל ריצה. הפרטים של יחידות שירות יילמדו בהמשך הפרק. כרגע היא משמשת ממשק מבוקר להקמת קבוצה, הוספת תהליך אליה והחלת מדיניות, בלי לשנות ידנית את העץ של שירותים אחרים.
תוכנית שמדווחת על הקבוצה שלה¶
נשמור את התוכנית הבאה בקובץ quota_probe.rs ונבנה באמצעות rustc --edition=2024 quota_probe.rs -o quota-probe. התוכנית אינה זקוקה לספריות חיצוניות. מצב המעבד עובד כשמונה שניות של זמן קיר, ומצב המשימות מחזיק ילדים עד להשלמת ניסיון היצירה. מצב הזיכרון נוגע בכל דף כדי שלא להסתפק בהזמנת מרחב כתובות.
use std::fs;
use std::hint::black_box;
use std::io;
use std::path::PathBuf;
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
use std::thread;
use std::time::{Duration, Instant};
fn group() -> io::Result<PathBuf> {
let text = fs::read_to_string("/proc/self/cgroup")?;
let path = text.lines().find_map(|line| line.strip_prefix("0::"))
.ok_or_else(|| io::Error::other("cgroup v2 required"))?;
if !path.starts_with('/') || path.split('/').any(|s| s == "..") {
return Err(io::Error::other("unexpected cgroup path"));
}
Ok(PathBuf::from("/sys/fs/cgroup").join(path.trim_start_matches('/')))
}
fn snapshot(root: &std::path::Path) {
for name in ["cpu.stat", "memory.current", "memory.events",
"pids.current", "pids.events", "memory.pressure"] {
match fs::read_to_string(root.join(name)) {
Ok(value) => println!("{name}:\n{value}"),
Err(error) => eprintln!("{name}: {error}"),
}
}
}
fn main() -> io::Result<()> {
let root = group()?;
println!("group={}", root.display());
snapshot(&root);
let mode = std::env::args().nth(1).unwrap_or_else(|| "cpu".into());
let started = Instant::now();
match mode.as_str() {
"cpu" => {
let mut value = 1_u64;
while started.elapsed() < Duration::from_secs(8) {
for _ in 0..10_000 {
value = black_box(value.wrapping_mul(6364136223846793005)
.wrapping_add(1));
}
}
println!("value={value}");
}
"memory" => {
let mib: usize = std::env::args().nth(2)
.unwrap_or_else(|| "48".into()).parse()
.map_err(|_| io::Error::other("invalid MiB"))?;
if mib > 128 { return Err(io::Error::other("lab limit is 128 MiB")); }
let mut bytes = vec![0_u8; mib * 1024 * 1024];
for index in (0..bytes.len()).step_by(4096) { bytes[index] = 1; }
black_box(&bytes);
snapshot(&root);
thread::sleep(Duration::from_secs(2));
}
"pids" => {
let stop = Arc::new(AtomicBool::new(false));
let mut threads = Vec::new();
for _ in 0..32 {
let stop = stop.clone();
match thread::Builder::new().spawn(move || {
while !stop.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(10));
}
}) {
Ok(handle) => threads.push(handle),
Err(error) => { println!("spawn rejected: {error}"); break; }
}
}
println!("children={}", threads.len());
snapshot(&root);
stop.store(true, Ordering::Relaxed);
for handle in threads { handle.join().unwrap(); }
}
_ => return Err(io::Error::other("expected cpu, memory, or pids")),
}
println!("wall={:?}", started.elapsed());
snapshot(&root);
Ok(())
}
הקוד מניח שתצוגת /proc ותצוגת עץ הבקרה תואמות בסביבת המכונה הווירטואלית. מכולה עם עיגון חלקי או מרחב שמות של קבוצות בקרה יכולה לדרוש התאמה של פתרון הנתיב. כשל קריאה מדווח במפורש, ואינו מוחלף בערך אפס שמייצר מדידה מטעה.
זמן מעבד מול זמן קיר¶
נפעיל את הריצה מתוך נתיב עבודה מוחלט, באמצעות הפקודה הבאה. האפשרות --pipe מעבירה את הפלט למסוף, והאפשרות --wait ממתינה לתוצאה. הריצה מבוצעת על ידי מנהל השירותים של המכונה ולכן דורשת כאן הרשאות מנהל.
sudo systemd-run --wait --pipe --collect \
--property=CPUQuota=25% --property=TasksMax=16 \
"$(pwd)/quota-probe" cpu
המכסה מבטאת רבע מזמן של מעבד לוגי אחד לאורך חלון הבקרה, ולא רבע מכל המעבדים במחשב. נשווה את ההפרש ב־usage_usec לזמן הקיר ואת העלייה ב־nr_throttled. תחת עומס המעבדה נצפה להגבלת זמן, אבל לא נדרוש מספר מדויק של איטרציות. תזמון, תחרות ועומס המארח עדיין משפיעים על התוצאה.
המשקל cpu.weight פועל אחרת. הוא משפיע על חלוקת זמן בין קבוצות מתחרות, ואינו בהכרח עוצר קבוצה כאשר אין מתחרים. ערבוב בין משקל למכסה יוצר בדיקות שגויות, למשל ציפייה ששירות בעל משקל נמוך תמיד יעבוד לאט. יש לבנות תחרות רלוונטית כדי לבדוק משקל.
זיכרון, לחץ והרג¶
נפעיל שתי ריצות נפרדות כדי להפריד לחץ זיכרון מהגעה לגבול הקשיח.
sudo systemd-run --wait --pipe --collect \
--property=MemoryHigh=32M --property=MemoryMax=64M \
--property=MemorySwapMax=0 "$(pwd)/quota-probe" memory 48
sudo systemd-run --wait --pipe --collect \
--property=MemoryHigh=32M --property=MemoryMax=64M \
--property=MemorySwapMax=0 "$(pwd)/quota-probe" memory 96
הגבול memory.high מפעיל לחץ ופעולות השבת זיכרון, ויכול לגרום למשימות להמתין. הגבול memory.max הוא גבול קשיח יותר, ובמצבים המתאימים אי־יכולת להישאר תחתיו יכולה להוביל למנגנון ההרג של הקבוצה. הריצה השנייה עשויה להסתיים לפני הדפסת המדידה האחרונה, ולכן נרשום גם את תוצאת מנהל השירות ולא נפרש חוסר פלט כהצלחה.
החשבונאות כוללת יותר מהווקטור שלנו. מטמון קבצים, מבני קרנל ומשאבים אחרים יכולים להיכלל בחשבון הקבוצה. ניתוח רק של גודל ההקצאות בשפת ראסט אינו מסביר בהכרח את memory.current. המדדים מסוג PSI מודדים זמן שבו עבודה נעצרת בגלל לחץ, והם מסייעים להבדיל בין צריכה גבוהה שאינה מזיקה לבין עיכוב שהמשתמש כבר מרגיש.
מספר משימות ופעולות אחסון¶
נריץ את מצב pids עם TasksMax=16. הבקר סופר משימות קרנל, ולכן גם תהליכונים נספרים. בתוכנית החד־תהליכית שלנו צפוי שניסיון יצירה ייכשל לפני שלושים ושניים הילדים. לא נקבע מראש שהמספר יהיה בדיוק חמישה־עשר בכל סביבת הפעלה, משום שיש לבדוק אילו משימות נוספות נמצאות באותה קבוצה.
הבקר io מסוגל להחיל מגבלות או משקלים לפי התקן, אך מדידה דרך מטמון הדפים אינה שקולה למדידת תעבורה אל ההתקן. כתיבה יכולה להסתיים במרחב המשתמש לפני שהדפים המלוכלכים נכתבו לאחסון. לכן בניסוי אחסון נציין את סוג הפעולה, ההתקן, מצב המטמון וגבול העמידות. מספר בתים שנמסרו ל־write לבדו אינו קצב הכתיבה לדיסק.
התיעוד של קבוצות הבקרה מגדיר את קובצי הבקרים ואת כללי ההאצלה. במערכת אמיתית נאגד את המדידות עם זמן תגובה ושגיאות השירות, כדי לראות האם ההגבלה שומרת על שאר המערכת במחיר צפוי או מייצרת גל של ניסיונות חוזרים.