1. 程式人生 > 其它 >Docker 原理探索:namespace

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 在這其中起到了 “資源隔離” 的作用。