לדלג לתוכן

תצורה ויומנים בשירות פעיל

החלפת מדיניות בלי מצב מעורב

נניח ששירות מחזיק תצורה הכוללת גודל תור וזמן המתנה. מנהל משנה את שניהם, אבל תהליכון אחד כבר קרא את הערך הראשון ותהליכון אחר מעדכן את השני. בקשה יכולה לפעול לפי צירוף שלא הופיע באף גרסה מאושרת. נעילה סביב כל שדה בנפרד אינה פותרת את הבעיה, משום שהיחידה הלוגית היא תמונת התצורה השלמה.

נבנה תהליך טעינה בעל שני שלבים. בשלב הראשון קוראים, מפרשים ובודקים מועמד חדש בלי לשנות את המצב הפעיל. בשלב השני מפרסמים תמונה שלמה בפעולה קצרה. בקשה שכבר החזיקה תמונה ישנה רשאית לסיים לפיה, ובקשה חדשה מקבלת את החדשה. מזהה דור מאפשר להסביר ביומן איזו החלטה התקבלה לפי איזו תמונה.

תצורה קטנה עם חוזה קשיח

ניצור קובץ בשם service.conf עם שני שדות. המספר הראשון הוא גבול פריטים והשני הוא זמן המתנה באלפיות שנייה. נשמור את הקובץ בתיקייה שהגורם המנהל שולט בה, ונגדיר הרשאות שאינן מאפשרות למשתמשים אחרים לשנותו.

capacity=16
timeout_ms=500

התוכנית הבאה היא מעבדת טעינה אינטראקטיבית עצמאית עם התלות libc = "0.2". היא מקבלת את נתיב הקובץ כארגומנט. הפקודה show מציגה תמונה, הפקודה hold מתחילה בקשה שמחזיקה תמונה לשתי שניות, והפקודה reload מנסה לפרסם מועמד חדש. הפקודה quit מסיימת ומצטרפת לכל העבודות.

use std::fs::OpenOptions;
use std::io::{self, BufRead, Read};
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
use std::sync::{Arc, RwLock};
use std::time::Duration;

#[derive(Debug)]
struct Config { generation: u64, capacity: usize, timeout_ms: u64 }
struct Candidate { capacity: usize, timeout_ms: u64 }

fn load(path: &str) -> io::Result<Candidate> {
    let file = OpenOptions::new().read(true)
        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(path)?;
    let metadata = file.metadata()?;
    if !metadata.is_file() || metadata.mode() & 0o022 != 0 {
        return Err(io::Error::other("expected regular non-group-writable file"));
    }
    let uid = unsafe { libc::geteuid() };
    if metadata.uid() != uid && metadata.uid() != 0 {
        return Err(io::Error::other("unexpected config owner"));
    }
    let mut bytes = Vec::new();
    file.take(4097).read_to_end(&mut bytes)?;
    if bytes.len() > 4096 { return Err(io::Error::other("config too large")); }
    let text = std::str::from_utf8(&bytes).map_err(|_| io::Error::other("invalid UTF-8"))?;
    let mut capacity = None;
    let mut timeout_ms = None;
    for line in text.lines() {
        let (key, value) = line.split_once('=')
            .ok_or_else(|| io::Error::other("expected key=value"))?;
        match key {
            "capacity" if capacity.is_none() => {
                capacity = Some(value.parse::<usize>().map_err(|_| io::Error::other("capacity"))?);
            }
            "timeout_ms" if timeout_ms.is_none() => {
                timeout_ms = Some(value.parse::<u64>().map_err(|_| io::Error::other("timeout"))?);
            }
            _ => return Err(io::Error::other("unknown or repeated key")),
        }
    }
    let capacity = capacity.ok_or_else(|| io::Error::other("missing capacity"))?;
    let timeout_ms = timeout_ms.ok_or_else(|| io::Error::other("missing timeout"))?;
    if !(1..=1024).contains(&capacity) || !(1..=60_000).contains(&timeout_ms) {
        return Err(io::Error::other("config value out of range"));
    }
    Ok(Candidate { capacity, timeout_ms })
}

fn snapshot(store: &RwLock<Arc<Config>>) -> Arc<Config> {
    store.read().unwrap().clone()
}

fn publish(store: &RwLock<Arc<Config>>, value: Candidate) -> io::Result<u64> {
    let mut active = store.write().unwrap();
    let generation = active.generation.checked_add(1)
        .ok_or_else(|| io::Error::other("generation overflow"))?;
    *active = Arc::new(Config { generation, capacity: value.capacity,
        timeout_ms: value.timeout_ms });
    Ok(generation)
}

fn main() -> io::Result<()> {
    let path = std::env::args().nth(1).ok_or_else(|| io::Error::other("config path required"))?;
    let first = load(&path)?;
    let store = RwLock::new(Arc::new(Config { generation: 1,
        capacity: first.capacity, timeout_ms: first.timeout_ms }));
    let mut workers = Vec::new();
    for command in io::stdin().lock().lines() {
        match command?.as_str() {
            "show" => {
                let config = snapshot(&store);
                println!("generation={} capacity={} timeout_ms={}", config.generation,
                    config.capacity, config.timeout_ms);
            }
            "hold" => {
                if workers.len() >= 16 {
                    eprintln!("lab worker limit reached; quit and restart");
                    continue;
                }
                let config = snapshot(&store);
                workers.push(std::thread::spawn(move || {
                    std::thread::sleep(Duration::from_secs(2));
                    println!("completed generation={}", config.generation);
                }));
            }
            "reload" => match load(&path).and_then(|value| publish(&store, value)) {
                Ok(generation) => println!("reload accepted generation={generation}"),
                Err(error) => eprintln!("reload rejected: {error}"),
            },
            "quit" => break,
            _ => eprintln!("expected show, hold, reload, quit"),
        }
    }
    for worker in workers { worker.join().unwrap(); }
    Ok(())
}

הקוד מחזיק נעילת קריאה רק כדי לשכפל את ההפניה. העבודה עצמה אינה מחזיקה את הנעילה לשתי שניות, ולכן טעינה חדשה יכולה להתפרסם בזמן שהעבודה הישנה מתקדמת. התמונה הישנה תשתחרר כאשר לא יישארו לה מחזיקים. הגבולות כאן מתארים תצורה בלבד, ומנגנון hold הוא כלי בדיקה שאינו תור הייצור של השירות.

הקריאה והפרסום נעשים על ידי מנהל טעינה יחיד. אם כמה תהליכונים יטענו במקביל, מועמד שנקרא מוקדם יכול להתפרסם אחרי מועמד חדש יותר. נעילת הפרסום לבדה אינה קובעת איזה מועמד אמור לנצח. נדרשת סריאליזציה של פעולת הטעינה השלמה או בדיקת דור וגרסת מקור מתאימה.

קובץ שנכתב באמצע הקריאה

פתיחה אחת ובדיקת המטא־נתונים של אותו מתאר מונעות ערבוב בין בדיקה של קובץ אחד לקריאה מקובץ אחר בעקבות החלפת שם. האפשרות O_NOFOLLOW מונעת מעקב אחר קישור סמלי ברכיב האחרון, אך אינה מאבטחת לבדה כל רכיב תיקייה בדרך. לכן סמכות על התיקייה והנתיב היא חלק מדרישות ההפעלה.

כתיבה במקום לאותו קובץ יכולה עדיין לגרום לקורא לראות תוכן לא עקבי. המפרסם צריך לכתוב קובץ זמני באותה מערכת קבצים, לבדוק אותו ולהחליף את השם בפעולת שינוי שם מתאימה. אם נדרשת עמידות בפני נפילת חשמל, נוסיף סנכרון של הקובץ ושל התיקייה לפי החוזה של מערכת הקבצים. פרסום אטומי לקוראים ועמידות לאחר קריסה הן שתי דרישות שונות.

שינוי כל תצורה אינו חייב להיות נתמך בזמן ריצה. החלפת פורט, זהות או פורמט אחסון יכולה לדרוש מעבר מורכב יותר. נפרסם רשימה של שדות הניתנים לטעינה מחדש, ונחזיר שגיאה ברורה כאשר שינוי דורש הפעלה מחדש. התעלמות שקטה משדה מייצרת מצב שבו הקובץ והתהליך מספרים סיפורים שונים.

אירוע מובנה ולא מחרוזת עמומה

היומן צריך לאפשר לקשור בקשה, דור תצורה, החלטה ותוצאה. הדפסה של מחרוזת מובנית לפלט הרגיל עדיין מגיעה בדרך כלל לשדה ההודעה של היומן. כדי ליצור שדות נפרדים במערכת journald נשתמש בממשק הייעודי. הדוגמה הבאה היא תוכנית נפרדת עם אותה תלות וקישור לספריית המערכת.

use std::io;
#[link(name = "systemd")]
unsafe extern "C" {
    fn sd_journal_sendv(iov: *const libc::iovec, n: libc::c_int) -> libc::c_int;
}
fn main() -> io::Result<()> {
    let fields: [&[u8]; 4] = [b"MESSAGE=Configuration accepted",
        b"PRIORITY=6", b"AMIT_EVENT=CONFIG_RELOAD", b"AMIT_GENERATION=2"];
    let vectors: Vec<libc::iovec> = fields.iter().map(|field| libc::iovec {
        iov_base: field.as_ptr() as *mut libc::c_void,
        iov_len: field.len(),
    }).collect();
    let result = unsafe { sd_journal_sendv(vectors.as_ptr(), vectors.len() as libc::c_int) };
    if result < 0 { return Err(io::Error::from_raw_os_error(-result)); }
    Ok(())
}

המערכים נשארים בחיים לאורך הקריאה, והמצביעים אינם משמשים את הספרייה לאחר החזרה בהתאם לחוזה השליחה. נוכל לחפש את האירוע בעזרת journalctl AMIT_EVENT=CONFIG_RELOAD -o json. הפלט הצפוי כולל שדות נפרדים, אך הרשאות צפייה והגדרות שמירת יומן משפיעות על מה שנראה.

יומן שאינו ערוץ אישור עסקי

הגבלת קצב, מקום פנוי ומדיניות שמירה יכולים לגרום לכך שלא כל אירוע יישמר לאורך זמן. אסור להפוך הצלחת שליחת הודעת יומן להוכחה שפעולה עסקית נשמרה בעמידות. אם צריך רישום מחייב של עסקה, נגדיר מנגנון אחסון ופרוטוקול אישור מתאים, ונשתמש ביומן התפעולי כדי לחקור אותו.

נימנע מרישום סודות, תוכן מלא של בקשות או נתיבים רגישים בלי צורך. גם שדה מובנה יכול להכיל מידע פרטי. במקום להדפיס את כל קובץ התצורה לאחר כשל, נרשום סוג שגיאה, שם שדה מותר ודור פעיל. נבדוק עומס יומן כדי לוודא שהדיווח עצמו אינו הופך לצוואר בקבוק.

החוזה של שדות היומן מופיע בממשק השליחה, והגדרות השמירה והגבלת הקצב מפורטות בתצורת היומן. בניסוי הסופי נטען קובץ שבור ונוכיח שהשירות ממשיך לשרת לפי הדור האחרון שאושר.