Rust 中的容器運行時——第二部分
本系列的第一部分描述了文件系統佈局以及運行時如何將容器進程囚禁在容器的根文件系統中。
第二部分更深入地探討了實現,並展示了運行時如何創建子進程以及它們如何通信,直到用戶定義的進程啓動。它還將描述如何設置僞終端並展示其重要性Unix
套接字。
到本部分結束時,我們應該有一個可與 Docker
互操作的基本運行時。
Clone
第0
部分簡要解釋了clone
系統調用。它就像 fork/vfork
,但有更多選項來控制子進程。實際上,fork
的一些實現將調用傳播到 clone()
。 除了控制執行上下文的哪些部分從父進程共享之外,克隆調用爲我們提供了爲子堆棧創建單獨內存塊的可能性。clone
的 nix
實現具有以下簽名:
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
可以解決這個問題 (Rust
對multi-producer, single-consumer queue
提供了出色的支持)。在我們的示例中,情況並非如此,因爲它正在創建 (或者更好地說是克隆) 一個具有獨立內存空間的新進程。
Rust官網解釋對多生產者、單消費者隊列 multi-producer, single-consumer queue
說明 : https://doc.rust-lang.org/std/sync/mpsc/index.html
進程間通信 (IPC
) 是一組允許進程彼此通信的技術。最廣泛使用的兩種是:
-
shared memory
共享內存 -
sockets
套接字
在我們的示例中,我們將使用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: true
在config.json
文件中設置了標誌。之後,容器運行時有責任創建一個所謂的 “僞終端”(pty
)。
爲簡化起見,PTY
是一對(主從)通信設備,其行爲類似於真正的終端。從文本輸入到處理信號,任何發送到主機的命令都會被轉發到從機端。PTY
是 Linux
內核的一個非常重要且常用的特性(ssh
使用它!)。 現在很簡單
SIGINT 信號: https://dsa.cs.tsinghua.edu.cn/oj/static/unix_signal.html
“僞終端”(pty):https://linux.die.net/man/7/pty
-
如果 terminal: true 容器運行時創建一個 PTY
-
從屬描述符進入子進程
-
主描述符進入調用進程(在本例中爲 Docker)
但是子進程如何將主描述符發送給 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