לדלג לתוכן

הפעלה לפי ביקוש והקשחת סביבת השירות

נקודת קצה שחיה יותר מהתהליך

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

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

העברת בעלות דרך חוזה הפעלה

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

נשתמש בפרויקט בשם activated_probe עם התלות libc = "0.2" וקישור ל־libsystemd, כפי שהכנו בהרצאה הקודמת. התוכנית הבאה מקבלת בדיוק סוקט האזנה אחד של רשת האינטרנט, מטפלת בבקשה קצרה ויוצאת. הקריאה שמנקה משתני סביבה מתבצעת לפני יצירת תהליכונים.

use std::fs::OpenOptions;
use std::io::{self, Read, Write};
use std::net::TcpListener;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::time::Duration;

#[link(name = "systemd")]
unsafe extern "C" {
    fn sd_listen_fds(unset_environment: libc::c_int) -> libc::c_int;
    fn sd_is_socket(fd: libc::c_int, family: libc::c_int,
        kind: libc::c_int, listening: libc::c_int) -> libc::c_int;
}

fn listener() -> io::Result<TcpListener> {
    let count = unsafe { sd_listen_fds(1) };
    if count < 0 { return Err(io::Error::from_raw_os_error(-count)); }
    if count != 1 {
        for offset in 0..count.max(0) {
            drop(unsafe { OwnedFd::from_raw_fd(3 + offset) });
        }
        return Err(io::Error::other("exactly one activation descriptor required"));
    }
    let fd = unsafe { OwnedFd::from_raw_fd(3) };
    let valid = unsafe { sd_is_socket(fd.as_raw_fd(), libc::AF_INET,
        libc::SOCK_STREAM, 1) };
    if valid < 0 { return Err(io::Error::from_raw_os_error(-valid)); }
    if valid != 1 { return Err(io::Error::other("expected IPv4 stream listener")); }
    Ok(TcpListener::from(fd))
}

fn main() -> io::Result<()> {
    let listener = listener()?;
    println!("pid={} endpoint={}", std::process::id(), listener.local_addr()?);
    let (mut stream, peer) = listener.accept()?;
    stream.set_read_timeout(Some(Duration::from_secs(2)))?;
    stream.set_write_timeout(Some(Duration::from_secs(2)))?;
    let mut request = [0_u8; 4];
    stream.read_exact(&mut request)?;
    if &request != b"ping" { return Err(io::Error::other("invalid request")); }
    let state = std::env::var_os("STATE_DIRECTORY")
        .ok_or_else(|| io::Error::other("state directory missing"))?;
    let path = std::path::PathBuf::from(state).join("requests.log");
    let mut log = OpenOptions::new().create(true).append(true).open(path)?;
    writeln!(log, "pid={} peer={peer}", std::process::id())?;
    stream.write_all(b"pong")?;
    Ok(())
}

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

שתי יחידות עם אחריות שונה

נעתיק את התוכנית לנתיב /usr/local/libexec/amit-activated-lab במכונת המעבדה. ניצור את יחידת הסוקט בשם /etc/systemd/system/amit-activated-lab.socket עם התוכן הבא. יש לוודא מראש שהפורט שנבחר אינו תפוס.

[Unit]
Description=Amit activation endpoint

[Socket]
ListenStream=127.0.0.1:39091
Accept=no
Backlog=32

ניצור לצידה את הקובץ amit-activated-lab.service. יחידת הסוקט מזהה את השירות התואם לפי השם. במצב Accept=no השירות מקבל את הסוקט המאזין ומבצע בעצמו קבלה של חיבורים.

[Unit]
Description=Amit activated worker
Requires=amit-activated-lab.socket
After=amit-activated-lab.socket

[Service]
Type=exec
ExecStart=/usr/local/libexec/amit-activated-lab
DynamicUser=yes
StateDirectory=amit-activated-lab
UMask=0077
NoNewPrivileges=yes
PrivateTmp=yes
PrivateNetwork=yes
ProtectSystem=strict
ProtectHome=yes
RestrictAddressFamilies=AF_UNIX
MemoryMax=64M
TasksMax=8
TimeoutStopSec=3

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

sudo systemctl daemon-reload
sudo systemctl start amit-activated-lab.socket
systemctl is-active amit-activated-lab.socket
systemctl is-active amit-activated-lab.service

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

use std::io::{self, Read, Write};
use std::time::Duration;
fn main() -> io::Result<()> {
    let mut stream = std::net::TcpStream::connect("127.0.0.1:39091")?;
    stream.set_read_timeout(Some(Duration::from_secs(4)))?;
    stream.set_write_timeout(Some(Duration::from_secs(4)))?;
    stream.write_all(b"ping")?;
    let mut reply = [0_u8; 4];
    stream.read_exact(&mut reply)?;
    assert_eq!(&reply, b"pong");
    println!("reply received");
    Ok(())
}

גבול חדש אינו מבטל הפניה קיימת

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

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

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

סגירה, אפשרויות משותפות וקצב התחלה

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

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

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