9.10 - בדיקת שרת תחת עומס וכשלי רשת - הרצאה
ניסוי רשת שאפשר לחזור עליו¶
שרת שמצליח לענות פעם אחת על המחשב שלנו עדיין לא עבר בדיקת עומס. צריך לבדוק כיצד הוא מתנהג כאשר לקוח איטי, כאשר הודעות מגיעות באיחור, כאשר חיבור נקטע באמצע מסגרת וכאשר מספר הבקשות גדול מהתקציב שהגדרנו. המטרה אינה לייצר גרף מרשים, אלא למצוא היכן ההבטחות של המערכת מפסיקות להתקיים.
נבנה סביבת רשת מקומית עם שני מרחבי רשת וממשק וירטואלי שמחבר ביניהם. כך נוכל להוסיף עיכוב ואובדן בלי להסתמך על האינטרנט ובלי לשנות את הנתיב הרגיל של המחשב. בפרק הבא נלמד לעומק את חוזי הבידוד; כאן נשתמש במרחבים ככלי מעבדה מוגדר.
לפני כל מדידה נרשום גרסת קרנל, תצורת בנייה, מספר עובדים, גודל בקשה, מספר לקוחות ומדיניות חיבור. מדידה שפותחת חיבור חדש לכל בקשה אינה שקולה למדידה שממחזרת חיבור קיים. שתיהן יכולות להיות שימושיות, אבל הן עונות על שאלות שונות.
שרת מדידה קטן¶
השרת הבא מחזיק ארבעה עובדים קבועים. כל עובד מקבל חיבור, קורא שמונה בתים, מחזיר אותם וסוגר את החיבור. זהו כלי מדידה מוגבל, לא מימוש של השרת המלא בפרויקט: אין בו אות סגירה מסודר או פרוטוקול בקשות מורכב.
שמרו את הקוד בקובץ echo.rs. הוא משתמש בספרייה התקנית בלבד.
use std::io::{self, Read, Write};
use std::net::TcpListener;
use std::sync::Arc;
use std::time::Duration;
fn main() -> io::Result<()> {
let address = std::env::args().nth(1).unwrap_or_else(|| "127.0.0.1:43000".into());
let listener = Arc::new(TcpListener::bind(address)?);
println!("ready: {}", listener.local_addr()?);
let mut workers = Vec::new();
for _ in 0..4 {
let listener = listener.clone();
workers.push(std::thread::spawn(move || -> io::Result<()> {
loop {
let (mut stream, _) = match listener.accept() {
Ok(connection) => connection,
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
};
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
let mut bytes = [0_u8; 8];
if stream.read_exact(&mut bytes).is_ok() {
let _ = stream.write_all(&bytes);
}
}
}));
}
for worker in workers { worker.join().unwrap()?; }
Ok(())
}
ארבעה לקוחות שאינם שולחים גוף יכולים להחזיק את כל העובדים עד שמגבלות הקריאה פגות. העובדה הזאת אינה הפתעה שתתגלה רק בגרף; היא נובעת ישירות מהארכיטקטורה. הניסוי מאפשר למדוד את ההשפעה ולבדוק האם המדיניות מתאימה לשירות שנרצה לבנות.
מחולל עומס שמדווח גם כשל¶
הלקוח הבא יוצר ארבעה עובדים, וכל אחד מבצע מאה בקשות באופן סדרתי. הוא מודד מהתחלת החיבור עד קבלת תשובה מאומתת. המדדים של זמני ההצלחה מוצגים בנפרד ממספר הכשלים, כדי שכשל מהיר לא ייעלם בתוך ממוצע שנראה טוב.
שמרו את הקוד בקובץ load.rs ובנו אותו כבינארי נפרד.
use std::io::{self, Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::time::{Duration, Instant};
fn request(address: SocketAddr, id: u64) -> io::Result<()> {
let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(2))?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
let expected = id.to_be_bytes();
stream.write_all(&expected)?;
let mut received = [0_u8; 8];
stream.read_exact(&mut received)?;
if received != expected { return Err(io::Error::new(io::ErrorKind::InvalidData, "wrong response")); }
Ok(())
}
fn percentile(sorted: &[u128], percent: usize) -> Option<u128> {
if sorted.is_empty() { return None; }
let rank = (sorted.len() * percent).div_ceil(100).max(1);
sorted.get(rank - 1).copied()
}
fn main() -> io::Result<()> {
let address: SocketAddr = std::env::args().nth(1)
.unwrap_or_else(|| "127.0.0.1:43000".into()).parse()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
let started = Instant::now();
let mut workers = Vec::new();
for worker in 0..4_u64 {
workers.push(std::thread::spawn(move || {
let mut samples = Vec::new();
for index in 0..100_u64 {
let start = Instant::now();
let result = request(address, worker * 100 + index);
samples.push((start.elapsed().as_micros(), result.is_ok()));
}
samples
}));
}
let mut samples = Vec::new();
for worker in workers { samples.extend(worker.join().unwrap()); }
let failed = samples.iter().filter(|sample| !sample.1).count();
let mut successful: Vec<_> = samples.iter().filter(|sample| sample.1).map(|sample| sample.0).collect();
successful.sort_unstable();
println!("attempts={}, failed={}, wall={:?}", samples.len(), failed, started.elapsed());
println!("success-only microseconds: p50={:?}, p99={:?}, max={:?}",
percentile(&successful, 50), percentile(&successful, 99), successful.last());
Ok(())
}
הדוגמה משתמשת בשיטת דירוג מפורשת לאחוזונים. היא אינה מציגה טווח ביטחון או טוענת שארבע מאות בקשות מספיקות לאפיין אירועים נדירים מאוד. כאשר מגדילים את הניסוי, צריך לשמור גם התפלגות לפי לקוח ולפי זמן, כדי לא להסתיר תקופות קצרות של תקיעה בתוך אוסף גדול.
שתי סביבות רשת מקומיות¶
הפקודות הבאות מיועדות למכונת לינוקס של המעבדה עם הרשאות ניהול רשת. השתמשו בשמות שמורים לניסוי רק לאחר שבדקתם שאינם קיימים. אין צורך להזיז ממשק פיזי או לשנות נתיב ברירת מחדל של המחשב.
sudo ip netns add amit-client
sudo ip netns add amit-server
sudo ip link add amit-c type veth peer name amit-s
sudo ip link set amit-c netns amit-client
sudo ip link set amit-s netns amit-server
sudo ip -n amit-client addr add 192.0.2.2/30 dev amit-c
sudo ip -n amit-server addr add 192.0.2.1/30 dev amit-s
sudo ip -n amit-client link set lo up
sudo ip -n amit-server link set lo up
sudo ip -n amit-client link set amit-c up
sudo ip -n amit-server link set amit-s up
rustc --edition=2024 -O echo.rs -o echo
rustc --edition=2024 -O load.rs -o load
sudo ip netns exec amit-server ./echo 192.0.2.1:43000
השרת נשאר פעיל במסוף הראשון. במסוף שני, מאותה תיקיית מעבדה, מפעילים תחילה מדידת בסיס ורק אחר כך מוסיפים כשל מכוון.
sudo ip netns exec amit-client ./load 192.0.2.1:43000
sudo ip netns exec amit-server tc qdisc add dev amit-s root netem delay 20ms 5ms loss 1%
sudo ip netns exec amit-client ./load 192.0.2.1:43000
sudo ip netns exec amit-server tc -s qdisc show dev amit-s
העיכוב מוגדר כאן בכיוון היציאה של השרת בלבד. אין לצפות אוטומטית להכפלה סימטרית של אותו עיכוב בשני הכיוונים. האובדן יכול להשפיע גם על הקמת החיבור וגם על נתוני התשובה, ולכן התפלגות הזמן עשויה להשתנות בצורה שאינה רק הוספת קבוע לכל בקשה.
לאחר סיום הניסוי עוצרים את תהליך השרת במסוף שבו הופעל, ואז מסירים את שני מרחבי המעבדה. התיעוד של ניהול מרחבי רשת מסביר מדוע תהליך חי יכול להמשיך להחזיק מרחב גם אחרי הסרת שמו.
מה מחולל העומס אינו רואה¶
כל עובד בלקוח מחכה לתשובה לפני שהוא יוצר בקשה חדשה. לכן כשהשרת נעשה איטי, קצב הבקשות שהלקוח מציע יורד. זהו עומס במעגל סגור. הוא מתאים לשאלה כיצד מתנהגים מספר נתון של לקוחות ממתינים, אבל אינו מדמה מקור שממשיך לייצר בקשות בקצב קבוע גם בזמן תקיעה.
כדי לבדוק עומס מוצע קבוע צריך לתזמן מועדי יצירה בנפרד מההשלמות ולמדוד גם איחור ביחס למועד המתוכנן. אם המחולל עצמו אינו עומד בקצב, צריך לדווח על כך במקום לייחס את התוצאה כולה לשרת. מדידת זמני שירות בלבד יכולה להחמיץ זמן המתנה של בקשות שטרם התחילו בפועל.
הוגנות וסגירה תחת כשל¶
נוסיף בהמשך לקוח שקורא תשובות לאט ונבדוק אם לקוחות אחרים ממשיכים להתקדם. נמדוד זיכרון תורים, מספר פעולות פעילות ותקציב לכל חיבור. הצלחה של כל הבקשות הקצרות אינה מספיקה אם השרת צובר זיכרון לאורך זמן ומפסיק לעבוד אחרי שעה.
בבדיקת סגירה נתחיל עומס, נפסיק קבלת בקשות חדשות ונבדוק אילו בקשות כבר התקבלו לאחריות השירות. לאחר מכן נוודא שהתשובות מתרוקנות או שמוחזר מצב כשל מתועד בתוך המועד. הריגת התהליך החיצונית היא כלי הזרקת כשל, ואינה הוכחה שהסגירה המסודרת שלו נכונה.
התיעוד של הדמיית תנאי רשת מגדיר את המנגנון והגבולות שלו. בדוח הסופי נפרסם את הפקודות, הנתונים וההשערה שנבדקה, ונפריד בין תוצאה שנמדדה לבין הסבר שעדיין דורש ניסוי נוסף.