גבולות משאב וסמכות במערכת מלאה¶
תור מוגבל אינו מספיק אם כל connection מקצה payload גדול לפני שהוא מנסה להיכנס אליו. הרשאות מינימליות אינן מספיקות אם worker יורש fd שמעניק גישה לקובץ סודי. בשלב האינטגרציה נבדוק את כל המסלול, כולל המקומות שנמצאים לפני ה־API שעליו שמנו מגבלה.
נרצה admission שמכריע יחד על מספר בקשות ועל מספר byte-ים. שימוש בשני atomics נפרדים יכול להשאיר הזמנה חלקית אם הבדיקה השנייה נכשלת, ולכן נשתמש כאן ב־Mutex אחד וב־permit שאינו ניתן לשכפול. זהו קוד מלא לניסוי, והתקציב שלו סופר byte-ים לוגיים של payload, לא את כל RSS של התהליך.
use std::sync::{Arc, Mutex};
struct Used { count: usize, bytes: usize }
struct Budget { used: Mutex<Used>, max_count: usize, max_bytes: usize }
struct Permit { owner: Arc<Budget>, bytes: usize }
impl Budget {
fn reserve(self: &Arc<Self>, bytes: usize) -> Option<Permit> {
let mut used = self.used.lock().unwrap();
let next_count = used.count.checked_add(1)?;
let next_bytes = used.bytes.checked_add(bytes)?;
if next_count > self.max_count || next_bytes > self.max_bytes { return None; }
used.count = next_count;
used.bytes = next_bytes;
Some(Permit { owner: Arc::clone(self), bytes })
}
}
impl Drop for Permit {
fn drop(&mut self) {
let mut used = self.owner.used.lock().unwrap();
used.count = used.count.checked_sub(1).unwrap();
used.bytes = used.bytes.checked_sub(self.bytes).unwrap();
}
}
fn main() {
let budget = Arc::new(Budget {
used: Mutex::new(Used { count: 0, bytes: 0 }), max_count: 2, max_bytes: 10,
});
let first = budget.reserve(7).unwrap();
assert!(budget.reserve(4).is_none());
drop(first);
let _second = budget.reserve(4).unwrap();
}
נרכוש permit אחרי אימות header קטן ולפני הקצאת body. אם try_reserve של ה־Vec נכשל, נשחרר את ה־permit ונחזיר שגיאת משאב. אם המשימה עוברת ל־spawn_blocking, נעביר אליה גם את ה־permit; ביטול הממתין לא ישחרר תקציב בזמן שהעבודה החוסמת עדיין מחזיקה את ה־buffer. ה־unwrap על poison בדוגמה מבטא כשל תשתית חמור, לא התאוששות אוטומטית ממצב שאיננו יכולים לסמוך עליו.
נוסיף תקציב fd-ים נפרד. כל connection, קובץ worker, eventfd ומתאר שהתקבל ב־SCM_RIGHTS נספר עד שבעליו סוגר אותו. דחיית request לא מספיקה אם שכחנו לסגור ancillary descriptors שנמסרו יחד איתו. בבדיקת עומס נעקוב אחרי מספר הרשומות ב־proc של התהליך ונבדוק חזרה ל־baseline לאחר סיום המחזור. מגבלת RLIMIT_NOFILE מגינה ברמה אחרת, אבל כשל EMFILE צריך עדיין לקבל טיפול שמאפשר לשירות להמשיך או להיסגר באופן מוגדר.
הסמכות אינה רק UID. תהליך worker יכול להחזיק capability, namespace שממפה משאבים, fd פתוח או socket שמאפשר לו לדבר עם רכיב חזק יותר. נצמצם את מה שהוא יורש ונגדיר API צר למפקח. הרשאת כתיבה בתיקיית הנתונים מאפשרת להחליף קבצים ושמות, ולכן שירות שמניח תיקייה מהימנה צריך לוודא מי רשאי לשנות אותה. אין טעם לבדוק owner של קובץ ואז לפתוח אותו דרך path שניתן להחליף בין הפעולות.
בדיקות שלילה הן חלק מן הפונקציונליות: worker מנסה לפתוח נתיב מחוץ לשורש הנתונים, ליצור socket מסוג שאינו נדרש, להעביר fd נוסף או להגדיל קובץ מעבר למכסה. נרצה כשל צפוי וראיה שהשירות המרכזי נשאר תקין. בדיקת "הבקשה הטובה הצליחה" אינה מוכיחה שהגבול נאכף.
נשלב את החשבונאות הפנימית עם cgroup ועם unit sandboxing כדי להגביל גם הוצאות שלא נספרות במודל, כמו allocator, stacks ו־page cache לפי החשבונאות של המערכת. התיעוד על מגבלות תהליך ועל סביבת הרצת שירות מגדיר את המנגנונים. הדוח שלנו יאמר איזה גבול הוא חשבונאי, איזה נאכף ב־kernel ואיזה נשען על חוזה API.
נבחן בקשה שמחזיקה buffer של 4 KiB, קובץ שהתקבל ב־SCM_RIGHTS ותוצאה בגודל זהה. בתקופת החישוב קיימים שני אזורי נתונים אם הטרנספורמציה אינה מתבצעת במקום, וגם fd אחד שמחזיק את הקלט. אחרי שהלקוח נעלם, כל אלה עדיין שייכים ל־worker עד לסיום העבודה או לביטול שהושלם. אם המפקח מחזיר את היתר ה־fd מיד בסגירת socket הלקוח, בקשות חדשות יכולות לעבור admission למרות שמתארים ישנים עדיין פתוחים. לכן התקציב אינו נספר לפי מספר connections בלבד, אלא לפי המשאבים שהמערכת עדיין חייבת להחזיק עבור עבודה שכבר התקבלה.