Rust 中的容器運行時——第二部分


本系列的第一部分描述了文件系統佈局以及運行時如何將容器進程囚禁在容器的根文件系統中。

第二部分更深入地探討了實現,並展示了運行時如何創建子進程以及它們如何通信,直到用戶定義的進程啓動。它還將描述如何設置僞終端並展示其重要性Unix 套接字。

到本部分結束時,我們應該有一個可與 Docker 互操作的基本運行時。

Clone

0部分簡要解釋了clone系統調用。它就像 fork/vfork,但有更多選項來控制子進程。實際上,fork的一些實現將調用傳播到 clone()。 除了控制執行上下文的哪些部分從父進程共享之外,克隆調用爲我們提供了爲子堆棧創建單獨內存塊的可能性。clonenix 實現具有以下簽名:

pub fn clone( 
cb: CloneCb <'_>, 
stack: &mut [ u8 ] , 
flags: CloneFlags , 
signal: Option < c_int > 
) -> Result < Pid >

如果指定了signal參數,將在子進程終止時發送回父進程。 下面是描述create命令和父子關係的代碼片段:

pub fn create(create: Create) {
    let container_id = create.id;
    let root = create.root;
    let bundle = create.bundle;

    // Load config.json specification file
    let spec = match Spec::try_from(Path::new(&bundle).join("config.json").as_path()) {
        Ok(spec) => spec,
        Err(err) ={
            error!("{}", err);
            exit(1);
        }
    };

    const STACK_SIZE: usize = 4 * 1024 * 1024; // 4 MB
    let ref mut stack: [u8; STACK_SIZE] = [0; STACK_SIZE];

    // Take namespaces from config.json
    let spec_namespaces = spec.linux.namespaces.into_iter()
        .map(|ns| to_flags(ns))
        .reduce(|a, b| a | b);

    let clone_flags = match spec_namespaces {
        Some(flags) => flags,
        None => CloneFlags::empty(),
    };

    let child = clone(Box::new(child_fun), stack, clone_flags, None);

    // Parent process
}

OCI創建命令的基本佈局

從上面的觀點來看,一個問題是父母和孩子之間的溝通。在生成線程的情況下,可以說創建一個內存chanel可以解決這個問題 (Rustmulti-producer, single-consumer queue提供了出色的支持)。在我們的示例中,情況並非如此,因爲它正在創建 (或者更好地說是克隆) 一個具有獨立內存空間的新進程。

Rust官網解釋對多生產者、單消費者隊列 multi-producer, single-consumer queue 說明 : https://doc.rust-lang.org/std/sync/mpsc/index.html

進程間通信 (IPC) 是一組允許進程彼此通信的技術。最廣泛使用的兩種是:

在我們的示例中,我們將使用Unix套接字 (AF_UNIX) 在父進程和子進程之間建立一個 “Client-->Server 端的”channel。容器進程將綁定到Unix套接字,並偵聽來自父進程的傳入連接。當執行的不同部分通過或失敗時,兩個進程都將使用sockets套接字連接來通知對方。當調用start命令時,套接字連接也可以派上用場,通知容器進程啓動用戶定義的程序。下圖更好地描述了 “協議”:

運行時和容器進程通信

Unix 套接字

對於那些不熟悉Unix (domain) Sockets的人來說,這個 Linux 特性有望令人興奮(至少對我來說是)。Unix 套接字是一種進程間通信機制,它在運行在同一臺機器上的進程之間建立雙向數據交換通道。可以將它們視爲不使用網絡堆棧發送和接收數據的 TCP/IP 套接字,而是文件系統上的文件。

在容器運行時的情況下,Unix 套接字爲運行時父進程和子進程提供雙向數據交換。該交換通道對於容器運行時至關重要!如果子進程中出現問題怎麼辦?父進程如何繼續?或者孩子如何知道啓動命令何時被調用?

出於這些目的,容器運行時實現了 IPC通道。這些是使用 Unix域套接字的雙向通道。一個進程充當 “server端”,其他進程(稱爲 “client 端”)連接到服務器進程。

簡而言之,這裏有一個關於 Rust 代碼外觀的粗略概念:

Unix (domain) Sockets 解釋 : https://man7.org/linux/man-pages/man7/unix.7.html

pub struct IpcChannel {
    fd: i32,
    sock_path: String,
    _client: Option<i32>,
}

impl IpcChannel {
    pub fn new(path: &String) -> Result<IpcChannel> {
        let socket_raw_fd = socket(
            AddressFamily::Unix,
            SockType::SeqPacket,
            SockFlag::SOCK_CLOEXEC,
            None,
        )
        .map_err(|_| Error {
            msg: "unable to create IPC socket".to_string(),
            err_type: ErrorType::Runtime,
        })?;

        let sockaddr = SockAddr::new_unix(Path::new(path)).map_err(|_| Error {
            msg: "unable to create unix socket".to_string(),
            err_type: ErrorType::Runtime,
        })?;

        bind(socket_raw_fd, &sockaddr).map_err(|_| Error {
            msg: "unable to bind IPC socket".to_string(),
            err_type: ErrorType::Runtime,
        })?;

        listen(socket_raw_fd, 10).map_err(|_| Error {
            msg: "unable to listen IPC socket".to_string(),
            err_type: ErrorType::Runtime,
        })?;
        Ok(IpcChannel {
            fd: socket_raw_fd,
            sock_path: path.clone(),
            _client: None,
        })
    }

    pub fn connect(path: &String) -> Result<IpcChannel> {
        let socket_raw_fd = socket(
            AddressFamily::Unix,
            SockType::SeqPacket,
            SockFlag::SOCK_CLOEXEC,
            None,
        )
        .map_err(|_| Error {
            msg: "unable to create IPC socket".to_string(),
            err_type: ErrorType::Runtime,
        })?;

        let sockaddr = SockAddr::new_unix(Path::new(path)).map_err(|_| Error {
            msg: "unable to create unix socket".to_string(),
            err_type: ErrorType::Runtime,
        })?;

        connect(socket_raw_fd, &sockaddr).map_err(|_| Error {
            msg: "unable to connect to unix socket".to_string(),
            err_type: ErrorType::Runtime,
        })?;

        Ok(IpcChannel {
            fd: socket_raw_fd,
            sock_path: path.clone(),
            _client: None,
        })
    }

    pub fn accept(&mut self) -> Result<(){
        let child_socket_fd = nix::sys::socket::accept(self.fd).map_err(|_| Error {
            msg: "unable to accept incoming socket".to_string(),
            err_type: ErrorType::Runtime,
        })?;

        self._client = Some(child_socket_fd);
        Ok(())
    }

    pub fn send(&self, msg: &str) -> Result<(){
        let fd = match self._client {
            Some(fd) => fd,
            None => self.fd,
        };

        write(fd, msg.as_bytes()).map_err(|err| Error {
            msg: format!("unable to write to unix socket {}", err),
            err_type: ErrorType::Runtime,
        })?;

        Ok(())
    }

    pub fn recv(&self) -> Result<String> {
        let fd = match self._client {
            Some(fd) => fd,
            None => self.fd,
        };
        let mut buf = [0; 1024];
        let num = read(fd, &mut buf).unwrap();

        match std::str::from_utf8(&buf[0..num]) {
            Ok(str) => Ok(str.trim().to_string()),
            Err(_) => Err(Error {
                msg: "error while converting byte to string {}".to_string(),
                err_type: ErrorType::Runtime,
            }),
        }
    }

    pub fn close(&self) -> Result<(){
        close(self.fd).map_err(|_| Error {
            msg: "error closing socket".to_string(),
            err_type: ErrorType::Runtime,
        })?;

        std::fs::remove_file(&self.sock_path).map_err(|_| Error {
            msg: "error removing socket".to_string(),
            err_type: ErrorType::Runtime,
        })?;

        Ok(())
    }
}

Rust 中使用 Unix 套接字的 IPC 通道

服務器調用新方法並綁定到.sock文件。然後它調用accept並等待傳入的連接。另一方面,客戶端只是調用同一個.sock文件的connect,在此之後,服務器和客戶端可以交換消息。最後,兩個進程都調用close,通信就完成了。 請注意,我使用了SOCK_SEQPACKET套接字,因爲消息是按順序排列的,它是基於連接的,並且消息會立即全部刷新 (與SOCK_STREAM相反)。

終端

爲了在容器啓動後與容器進行良好的交互,如果用戶請求終端,運行時應該能夠提供終端接口。 當運行這樣的Docker命令時:

docker run alpine ping 8.8.8.8

您將看到ping命令向谷歌的DNS發送ICMP請求的輸出。ping命令的輸出是通過Docker管道傳輸的,但是當我們想要停止命令 (使用Ctrl+C) 時,什麼也不會發生。這是因爲當按下SIGINT鍵組合時,信號被髮送給Docker,而不是將命令傳遞給實際的容器進程。

另一方面,運行時:

Docker run -it alpine ping 8.8.8.8

並按Ctrl+C,命令立即終止,就像在主機上運行一樣。這是爲什麼呢?

這是因爲在第一個示例中,容器進程沒有實例化的終端,因此用戶和 Docker 都無法通過 tty將信號轉發到容器。 幸運的是,該-t選項terminal: trueconfig.json文件中設置了標誌。之後,容器運行時有責任創建一個所謂的 “僞終端”(pty)。

爲簡化起見,PTY 是一對(主從)通信設備,其行爲類似於真正的終端。從文本輸入到處理信號,任何發送到主機的命令都會被轉發到從機端。PTYLinux 內核的一個非常重要且常用的特性(ssh使用它!)。 現在很簡單

SIGINT 信號: https://dsa.cs.tsinghua.edu.cn/oj/static/unix_signal.html

“僞終端”(pty):https://linux.die.net/man/7/pty

但是子進程如何將主描述符發送給 Docker 呢?

嘆息…… 這是一個真正的PITA需要找出解決方案超出了OCI運行時規範的範圍。runc開發了一個解決方案,此處描述了其步驟。

PITA : https://www.allacronyms.com/PITA/Pain_In_The_Ass

runc 解決方案 : https://github.com/opencontainers/runc/blob/master/docs/terminals.md#detached-new-terminal

我們的朋友 Unix 套接字來幫忙了。Docker 創建一個 Unix 域套接字並將其作爲console-socket參數傳遞給容器運行時。在容器運行時創建 PTY後,它使用SCM_RIGHTS將主端發送到同一個 Unix 套接字。

SCM_RIGHTS : https://man7.org/linux/man-pages/man3/cmsg.3.html

結論

最後,我們有一個隨時可以測試的 OCI 容器運行時!

這部分解釋了克隆clone系統調用以及它如何將執行上下文與父進程分離。它還具有靈活的 API,以便我們可以爲進程指定新堆棧。

Unix 域套接字在這裏發揮着重要作用,因爲它們同步整個父子通信並在雙方出現錯誤時處理潛在場景。

第二部分總結了Rust系列中的容器運行時。實驗性容器運行時的整個源代碼可以在這個Github repo上找到。隨意提出問題或指出實施中有趣的事情。

Github Repo: https://github.com/penumbra23/pura

5.3 參考資料

Clone man page[1]

Unix Domain Sockets [2]

runc terminal modes [3]

參考資料

[1]

Clone man page: https://man7.org/linux/man-pages/man2/clone.2.html

[2]

Unix Domain Sockets: https://man7.org/linux/man-pages/man7/unix.7.html

[3]

runc terminal modes: https://github.com/opencontainers/runc/blob/master/docs/terminals.md

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/TGiI5ftI85Mqu1Bs095dqg