Docker 原理探索:namespace
簡介
本文是閱讀 《Docker 容器和容器雲》 的讀書筆記,書中第三章詳細講解了 Docker 的核心原理,本文主要是 Linux namespace 機制。namespace 一般都會有父子關係,一般來說是父 namespace 可以建立、修改、訪問子 namespace,而放過來不行,namespace 提供了某種程度上的資源隔離,使到子 namespace 中的程序操作不會影響到父程序。
UTS 隔離
UTS(UNIX Time-sharing System) namespace 提供了主機名和域名的隔離,執行程式碼需要 root 許可權,否則將會失敗。
#include <cerrno> #include <csignal> #include <iostream> #include <sched.h> #include <unistd.h> #include <signal.h> #include <sys/wait.h> constexpr int STACK_SIZE = 1024 * 1024; static char child_stack[STACK_SIZE]; char* const child_args[] = { "/bin/bash", NULL }; int child_main(void* args) { std::cout << "child process" << std::endl; sethostname("NewNamespace", 12); execv(child_args[0], child_args); return 1; } int main() { // root permission is required int child_pid = clone(child_main, child_stack + STACK_SIZE, SIGCHLD, NULL); std::cout << child_pid << " " << errno << std::endl; waitpid(child_pid, NULL, 0); std::cout << "main exited" << std::endl; return 0; }
IPC 隔離
程序間通訊涉及的資源主要包括訊號量,訊息佇列和共享記憶體。
在上面程式碼中的 clone 函式中,增加 CLONE_NEWIPC
這個識別符號,可以建立一個 IPC 資源隔離的程序。
int child_pid = clone(child_main, child_stack + STACK_SIZE,
CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
相關命令:
ipcmk -Q # 建立訊息佇列
ipcs -q # 列舉出所有的訊息佇列
使用上面的命令,我們可以現在宿主上建立一個訊息佇列,然後執行可執行程式,在裡面檢視訊息佇列,我們會發現找不到。
(base) percent1@ubuntu:~/code/cmake_cpp_cuda/build$ ipcs -q ------ Message Queues -------- key msqid owner perms used-bytes messages 0x785e4c5f 0 percent1 644 0 0 0x5329368e 32769 root 644 0 0 0x875bbc3e 65538 percent1 644 0 0 0x2ab3fc5d 98307 percent1 644 0 0 (base) percent1@ubuntu:~/code/cmake_cpp_cuda/build$ sudo ./cpp/docker/docker 1961014 0 child process (base) root@NewNamespace:~/code/cmake_cpp_cuda/build# ipcs -q ------ Message Queues -------- key msqid owner perms used-bytes messages
PID 隔離
PID namespace 是樹狀結構的,系統啟動的時候,會建立一個 root namespace,在這個 namespace 中可以新創建出子 namespace,從而可以形成樹狀的層級關係。這種層級關係就像程序一樣,比如父 PID namespace 可以看到子 PID namespace 中的所有程序,反過來不行;每個 PID namespace 中的第一個程序 PID 1 具有特權;
unshare 和 setns 允許使用者在原有程序中建立名稱空間並進行隔離,但是當前程序不會進入新的名稱空間,它的子程序會。原因是,如果當前程序進入了新的 PID 名稱空間,那麼當前程序的 PID 會發生變化,存在一定不合理的之處。Docker exec 原理大概就是使用 setns 加入已經存在的名稱空間,最終還是要呼叫 clone 函式。
PID 隔離,在上面程式碼的基礎上加上 CLONE_NEWPID
標誌位,即可隔離 PID 名稱空間。
int child_pid = clone(child_main, child_stack + STACK_SIZE,
CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
編譯之後,執行,我們可以使用 echo $$
檢視當前程序的 PID,可以看到在新的名稱空間中,PID 是 1。如果使用 ps aux
等命令,還是可以看到父 PID namespace 中的程序,原因是這個命令應該是通過 /proc
下面的檔案來檢視正在執行程式的。可以使用命令 mount -t proc proc /proc
掛載一個新的 proc 目錄,之後就可以看到只有兩個程序。但是退出來的時候再次執行 ps aux
會報錯,提示 mount -t proc proc /proc
才可以,但是執行起來會發現需要 root 許可權,而 sudo 因為修改了這個目錄的原因,不能使用 tty 來輸入密碼。此時我們可以使用 echo 'yourpassword' | sudo -S mount -t proc proc /proc
來修復。
(base) root@NewNamespace:~/code/cmake_cpp_cuda/build# echo $$
1961014
(base) root@NewNamespace:~/code/cmake_cpp_cuda/build# sudo ./cpp/docker/docker
1995381 0
child process
(base) root@NewNamespace:~/code/cmake_cpp_cuda/build# echo $$
1
檔案系統隔離
檔案系統的掛載狀態有幾種:共享掛載,從屬掛載,共享從屬掛載,不可繫結掛載。如下圖所示,箭頭的含義表示,一邊的修改會影響到另一邊。預設情況下,mount 都是 private 掛載的,兩個不同的 namespace 之間是互相隔離的。
修改程式碼為如下,重新編譯執行後,再執行 mount -t proc proc /proc
,當前程序修改檔案系統的掛載點,不會影響到父名稱空間。不過在這之前需要將 /proc 目錄變成 private 型別的,使用這個命令可以設定:mount --make-private proc
。
int child_pid = clone(child_main, child_stack + STACK_SIZE,
CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
網路隔離
network namespace 提供了關於網路資源的隔離:網路裝置,IPv4 和 IPv6 協議棧,IP 路由表,防火牆,/proc/net 目錄,/sys/class/net 目錄,socket 等資源。
在上面的程式碼中加上 CLONE_NEWNET
標誌位就可以實現網路隔離。書中這部分的討論不多,先跳過。
使用者隔離
user namespace 讓普通使用者的程序可以通過 clone 建立的新程序在新 user namespace 中可以擁有不同的使用者和使用者組,這意味著在新的 user namespace 中,可以使用 root 許可權。簡單來說,user namespace 可以讓普通使用者擁有 root 許可權。不過,這並非意味著一個普通使用者就可以任意使用 root 許可權了,因為許可權的檢查最終還是要受限於父 user namespace。比如 /etc/sudoers
這個檔案,就不能使用這種方式來修改。舉個可以用 root 許可權的例子,前面我們使用不同的標誌位,有的是需要 root 許可權的,可是不會違反父 user namespace 的約束,所以可以先啟動 CLONE_NEWUSER
這個標誌位的程序,然後再啟動含有其他標誌位的程序。
#include <bits/types/FILE.h>
#include <cerrno>
#include <csignal>
#include <cstdio>
#include <iostream>
#include <sched.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/capability.h>
constexpr int STACK_SIZE = 1024 * 1024;
static char child_stack[STACK_SIZE];
char* const child_args[] = {
"/bin/bash",
NULL
};
void set_uid_map(pid_t pid, int inside_id, int outside_id, int length) {
char path[256];
sprintf(path, "/proc/%d/uid_map", getpid());
FILE* uid_map = fopen(path, "w");
fprintf(uid_map, "%d %d %d", inside_id, outside_id, length);
fclose(uid_map);
}
void set_gid_map(pid_t pid, int inside_id, int outside_id, int length) {
char path[256];
sprintf(path, "/proc/%d/gid_map", getpid());
FILE* gid_map = fopen(path, "w");
fprintf(gid_map, "%d %d %d", inside_id, outside_id, length);
fclose(gid_map);
}
int child_main(void* args) {
std::cout << "child process " << geteuid() << " " << getegid() << std::endl;
set_uid_map(getpid(), 0, 1008, 1); // id -u => 1008
set_gid_map(getpid(), 0, 1008, 1); // id -g => 1008
cap_t caps = cap_get_proc();
std::cout << cap_to_text(caps, NULL) << std::endl;
execv(child_args[0], child_args);
return 1;
}
int main() {
int child_pid = clone(child_main, child_stack + STACK_SIZE,
CLONE_NEWUSER | SIGCHLD, NULL);
std::cout << child_pid << " " << errno << std::endl;
waitpid(child_pid, NULL, 0);
std::cout << "main exited" << std::endl;
return 0;
}
總結
使用了以上介紹的隔離技術,一個 Docker 容器的雛形已經形成。在編碼的過程中,可以深刻的體會到,Docker 容器的本質,其實就是一個程序!namespace 在這其中起到了 “資源隔離” 的作用。