docker 深入理解之namespace


namespace 名稱空間

docker容器主要通過資源隔離來實現的,應該具有的6種資源隔

namespace 的六項隔離

namespace 系統調用參數 隔離的內容
UTS CLONE_NEWUTS 主機名域名
IPC CLONE_NEWIPC 信號量、消息隊列與共享內存
PID CLONE_NEWPID 進程編號
Network CLONE_NEWNET 網絡設備、網絡棧、端口等
MOUNT CLONE_NEWNS 掛載點
USER CLONE_NEWUSER 用戶與組

 

 

 

 

 

 

 

 

 

 

namespace API 操作4種方式

包括clone(),setns(),unshare()以及/proc下部分文件

1.通過clone()在創建進程的同時創建namespace,clone()實際是Linux系統調用fork()的一種更用的實現方式,它可以通過flags開控制使用多少功能。一共有20多種CLON_*的flag(標志位)參數來控制進程的方方面面(如是否與父進程共享虛擬內存等)

*1 child_func 傳入子進程運行的程序主函數

*2 child_stack 傳入子進程使用的棧空間

*3 args 則用於傳入用戶參數

查看/proc/pid/ns文件

# ls -l /proc/664/ns/
總用量 0
lrwxrwxrwx. 1 root root 0 3月   1 08:05 ipc -> ipc:[4026531839]
lrwxrwxrwx. 1 root root 0 3月   1 08:05 mnt -> mnt:[4026532432]
lrwxrwxrwx. 1 root root 0 3月   1 08:05 net -> net:[4026531956]
lrwxrwxrwx. 1 root root 0 3月   1 08:05 pid -> pid:[4026531836]
lrwxrwxrwx. 1 root root 0 3月   1 08:05 user -> user:[4026531837]
lrwxrwxrwx. 1 root root 0 3月   1 08:05 uts -> uts:[4026531838]

  如果兩個進程執行的namespace編號一樣,說明他們在同一個namespace下,否則就會創建不同的namespace里面./proc/pid/ns 里設置這些link的另一個作用,一旦上述link文件被打開,只要打開的文件描述符(fd)存在,那么就是該namesfpace下所有進程都結束,這個namespace也會一直存在,后續進程可以加進來。在docker中,通過文件描述符定位和加入一個已存在的namespace是最基本的方式。

    另外把/proc/[pid]/ns 目錄文件使用 --bind 方式掛起來可以起到同樣的作用

# touch  ~/cx
# mount --bind /proc/591/ns/uts ~/cx 

通過setns()加入一個已經存在的namespace

  進程在結束的情況下,也可以通過掛載的形式把namespace保留下來,保留下來的目的是為了后續進程加入做准備,在docker中 使用docker exec 命令在已經運行着的容器中執行一個新命令,就需要用到該方法。通過setens()系統調用,進程從原來的namespace加入某個已存在的namesfpace,使用該方法,通常不影響進程的調用者,也為新加入的pid namespace 生效,會在setns() 函數執行后使用clone()創建子進程執行命令,讓原先的進程結束運行

int setns(int fd, int nstype);

 *參數fd表示要加入的namespace的文件描述符,它是指向/proc/[pid]/ns目錄的文件描述符,可以通過直接打開該目錄連接或者打開一個掛載了該目錄下鏈接的文件得到

   *參數nytype表示讓調用者可以檢查fd指向的namespace類型是否符合實際要求,該參數為0表示不檢查

為了把新加入的namespace利用起來,需要引進execve()系列函數,此函數可以執行用戶命令,最常見的就是調用/bin/bash並接受參數,運行起一個shll

fd = open(argv[1], O_RDONLY);      獲取namespace的文件描述符
setns(fd, 0);   加入新的namespace
execvp(argv[2], &argv[2]);   執行程序 

  編譯后程序為setns-test,加入到namespace中這些shll命令了

# ./setns-test ~/cx  /bin/bash  

  通過unshare()在原先的進程上進行namespace隔離

       注意系統調用是unshare,它與clone很像,不同的是,unshare是運行在原先的進程上,不需要啟動新的進程

int unshare(int flage);

  調用unshare()的主要作用就是,不啟動新的進程就可以起到隔離效果,相當於跳出原先的namespace進行操作。這樣可以在原先的進程進行一些隔離的操作。Linux自帶的unshare命令,就是通過unshare()系統調用實現的。docker目前沒有使用

      fork()系統調用

    系統調用函數fork()並不屬於namespace的API。當程序調用fork()函數是時,系統會創建新的進程,為其分配資源,像存儲數據和代碼的空間,然后把原來進程的所有值都復制到新的進程中,只有少量數值與原來的進程值不同,相當於復制了本身。fork()的神奇之處在於它不僅僅被調用一次,卻能返回兩次(父子進程各一次),通過返回值不同就可以區分父進程與子進程,可能會有3中返回值

*在父進程中,fork()返回新創建子進程的ID

*在子進程中,fork()返回0

*如果出現錯誤,fork()返回一個負值

[root@mast ~]# vim cx.c
#include <unistd.h>
#include <stdio.h>
int main (){
        pid_t fpid;
        fpid=fork();
        if (fpid < 0)printf("error in fork!");
        else if (fpid==0) {
            printf("I am child. process id is %d\n",getpid());
        }
        else {
            printf("i am parent. process id is %d\n",getpid());
        }
        return 0;
        }
[root@mast ~]# gcc -Wall cx.c && ./a.out
i am parent. process id is 23877
I am child. process id is 23878

  使用fork()后,父進程有義務監控子進程的運行狀態,並在子進程退出后自己才能真正退出,否則子進程就會成為孤兒進程

UTS namespace

     UTS namespace提供了主機名和域的隔離,這樣docker容器就有獨立的主機名和域名了,在網絡中視為一個獨立節點,而非宿主機上一個進程,docker中,每個鏡像本身基本都以自身所提供的服務名稱來命名鏡像的hostname 

[root@mast ~]# cat uts.c 
#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>

#define STACK_SIZE (1024 * 1024)

static char child_stack[STACK_SIZE];
char* const child_args[] = {
     "/bin/bash",
     NULL
};

int child_main(void* args) {
     printf("在子進程中!\n");
     execv(child_args[0], child_args);
     return 1;
}

int main(){
     printf("程序開始:\n");
     int child_pid = clone(child_main, child_stack + STACK_SIZE, SIGCHLD, NULL);
     waitpid(child_pid, NULL, 0);
     printf("已退出\n");
     return 0;
}
[root@mast ~]#  gcc -Wall uts.c -o uts.o && ./uts.o
程序開始:
在子進程中!
[root@mast ~]# exit
exit
已退出

  修改代碼,加入UTS隔離,運行代需要root權限,以防止普通用戶任意修改主機名導致set-user-ID相關的應用運行出錯

[root@mast ~]# cat uts.c 
#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>

#define STACK_SIZE (1024 * 1024)

static char child_stack[STACK_SIZE];
char* const child_args[] = {
     "/bin/bash",
     NULL
};

int child_main(void* args) {
     printf("在子進程中!\n");
     sethostname("network",12);
     execv(child_args[0], child_args);
     return 1;
}

int main(){
     printf("程序開始:\n");
     int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWUTS | SIGCHLD, NULL);
     waitpid(child_pid, NULL, 0);
     printf("已退出\n");
     return 0;
}
[root@mast ~]#  gcc -Wall uts.c -o uts.o && ./uts.o
程序開始:
在子進程中!
[root@network ~]# hostname
network
[root@network ~]# exit
exit
已退出
[root@mast ~]# hostname
mast

  不加CLONE_NEWUTS,運行查看區別

[root@mast ~]# vim uts.c

#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>

#define STACK_SIZE (1024 * 1024)

static char child_stack[STACK_SIZE];
char* const child_args[] = {
     "/bin/bash",
     NULL
};

int child_main(void* args) {
     printf("在子進程中!\n");
     sethostname("network",12);
     execv(child_args[0], child_args);
     return 1;
}

int main(){
     printf("程序開始:\n");
     int child_pid = clone(child_main, child_stack + STACK_SIZE, SIGCHLD, NULL);
     waitpid(child_pid, NULL, 0);
     printf("已退出\n");
     return 0;
}
[root@mast ~]#  gcc -Wall uts.c -o uts.o && ./uts.o
程序開始:
在子進程中!
[root@network ~]# hostname
network
[root@network ~]# exit
exit
已退出
[root@mast ~]# hostname
network

  似乎沒什么區別,實際上不加CLONE_NEWUTS此參數進行隔離時,由於使用sethostname函數,所以宿主機的主機名被修改,而exit退出后看到的主機名還是原來的主機名,是因為bash只在登錄的時候讀取一次UTS,不會實時讀取最新主機名,當重新登錄或者使用uname進行查看時,就會發生變化

    IPC namespace  的實現

    進程間通信涉及的IPC資源包括常見的信號量,消息隊列 和共享內存。申請IPC資源就申請了一個全局唯一的32位id,所以IPC namespace中實際包括了系統的IPC標識符以及實現POSIX消息隊列的文件系統。在不同一個IPC namespace下實現的進程互相不可見的

#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>

#define STACK_SIZE (1024 * 1024)

static char child_stack[STACK_SIZE];
char* const child_args[] = {
     "/bin/bash",
     NULL
};

int child_main(void* args) {
     printf("在子進程中!\n");
     sethostname("net",12);
     execv(child_args[0], child_args);
     return 1;
}

int main(){
     printf("程序開始:\n");
     int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
     waitpid(child_pid, NULL, 0);
     printf("已退出\n");
     return 0;
}
[root@mast ~]# ipcmk -Q
消息隊列 id:0
[root@mast ~]# ipcs -q

--------- 消息隊列 -----------
鍵        msqid      擁有者  權限     已用字節數 消息      
0x54ed33bf 0          root       644        0            0       
[root@mast ~]#  gcc -Wall ipc.c -o ipc.o && ./ipc.o
程序開始:
在子進程中!
[root@net ~]# ipcs -q

--------- 消息隊列 -----------
鍵        msqid      擁有者  權限     已用字節數 消息      

[root@net ~]# exit
exit
已退出
[root@mast ~]# ipcs -q

--------- 消息隊列 -----------
鍵        msqid      擁有者  權限     已用字節數 消息      
0x54ed33bf 0          root       644        0            0    

  目前使用IPC namespace機制的系統不多,比較有名的PostgreSQL。docker也使用的IPC namespace實現了容器與宿主機、容器與容器之間的IPC隔離

PID namespace  的實現

     PID namespace 隔離非常實用,它對進程的PID重新標號,即兩個不同的namespace下的進程可以有相同的PID。每個PID namespace 都有自己的計算程序。內核為所有的PID namespace 維護了一個樹狀的結構,最頂層是系統初始化時創建的,被稱為root namespace  ,而它創建的新的PID namespace 被稱為child namespace 樹的子節點 而原來的PID namespace就是新建的namespace的父節點。通過這種方式,不同的PID namespace會形成一個層級結構,所屬父節點可以看子節點中的進程,可以通過信號等手段對子節點中的進程產生影響,反之子節點無法看到父節點的 PID namespace 中任何內容

     * 每個 PID namespace中的第一的進程的“PID 1” ,都像傳統的Linux中init進程一樣擁有特殊權限

     * 一個namespace中進程,不可能通過kill或prtace影響父節點或者兄弟節點中進程,因為其他節點的進程PID在此節點的namespace中沒有意義,也不存在

     * 如果新的PID namespace中掛載了proc文件系統,會發現其下面只顯示同屬一個PID namespace 中的其他進程

     * root namespace 中可以看到所有進程,並且遞歸包含所有子節點中的進程

[root@mast ~]# vim pid.c 

#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>

#define STACK_SIZE (1024 * 1024)

static char child_stack[STACK_SIZE];
char* const child_args[] = {
     "/bin/bash",
     NULL
};

int child_main(void* args) {
     printf("在子進程中!\n");
     sethostname("net",12);
     execv(child_args[0], child_args);
     return 1;
}

int main(){
     printf("程序開始:\n");
     int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
     waitpid(child_pid, NULL, 0);
     printf("已退出\n");
     return 0;
}
[root@mast ~]# echo $$ 
1472
[root@mast ~]# gcc  -Wall pid.c -o pid.o && ./pid.o
程序開始:
在子進程中!
[root@net ~]# echo $$
1
[root@net ~]# exit
exit
已退出
[root@mast ~]# echo $$ 
1472

 PID namespace 中的init進程

    Unix 系統中,PID為1 的為init ,地位特殊,它被稱為所有進程的父進程,維護一張進程表,不斷檢查進程狀態,一旦發現某子進程因為父進程錯誤稱為孤兒進程init就會負責收養這個子進程並最終回收資源,結束進程,所以在要實現的容器中,啟動第一個進程也要有實現類似init的功能,維護后續啟動進程的運行狀態

   信號與init進程

    內核為pid namespace中init進程賦予了一些特殊權限信號屏蔽。如果init中沒有編寫處理某個信號的代碼邏輯,那么與init在同一個PID namespace下的進程(既使有超級權限)發生給它的該信號都會屏蔽。這個功能主要防止init進程被誤殺。當父節點PID namespace中的進程發送同樣的信號給子節點中的init進程,這時候父節點中的進程發送的信號,如果不是SIGKILL(銷毀進程)或SIGSTOP(暫停進程)也會被屏蔽。如果發送SIGKILL或SIGSTOP,子節點就會強行執行(無法通過代碼捕捉進行特殊處理),也就說父節點中進程有權終止子節點中的進程

8    一旦init進程被銷毀。同意PID namespace中的其他進程也隨之收到SIGKILL信號而被銷毀。理論上,該PID namespace也不存在了,但如果/proc/[pid]/ns/pid 處於被掛起或者打開的狀態。namespace就會保留下來然而保留下來的namespace 無法通setns()或fork()創建進程,所以沒什么作用

   當一個容器內存在多個進程時,容器內的init進程可以對信號進行捕捉,當SIGTERM或SIGINT 等信號到來時,對其子進程信息進行保存、資源回收等處理工作,在docker daemon源碼中也可以看到類似處理方式,當結束信號來臨,結束容器並回收相應的資源

   unshare()和setns()

   unshare()允許用戶在原有進程中創建名稱空間進行隔離。但創建PID namespace后,原先unshare()調用者進程不進入新的PID namespace,接下來創建的子進程才會進入新的PID namespace,這個子進程也隨之稱為新namespace的init進程

   類似的,sentns()創建新的PID namespace時 ,調用者進程也不進去新的PID namespace,而是隨后創建的子進程進入

   因為調用getpid()函數得到的PID是根據調用者所在的PID namespace而決定返回那個PID ,進入新的PID namespace會導致PID 發生變化。而對用戶態的程序和函數庫來說它們都認為進程的PID 是常量,PID變化會引起這些進程崩潰。換句話說,一旦進程創建后,那么它的PID namespace的關系就確定下來了,進程不會更改它們對應的PID namespace ,在docker exec 會使用setns()函數加入一個已經存在的命名空間,但最終還是會調用clone()函數,原因就在於此

mount namespace 

  mount namespace 通過隔離文件系統掛載點對隔離文件系統提供支持,同時也是第一個Linux namespace,所以標志位比較特殊,隔離后,不同mount namespace 中的文件結構發生變化也互不影響。可以通過/proc/[pid]/mounts 查看到所以namespace中文件設備統計信息,包括掛載文件的名、文件系統類型、掛載位置等

  進程在創建mount namespace時, 會把當前的文件系統結構復制給新的namespace 。新的namespace中所有mount 操作都只影響自身的文件系統,對外界不產生任何影響。這種做法非常嚴格的實現了隔離,但對某些情況可能並不適用。例如父節點namespace中進程掛載一張光盤,這是子節點的namespace復制目錄的結構是無法自動掛載上這張光盤的,因為這種操作會影響父節點的文件系統;2006 年引入的掛載傳播(mount propagation)解決了這個問題,這樣的關系包含共享關系和從屬關系,系統用這些關系決定任何掛載對象中的掛載事件如何傳播到其他掛載對象上

*  共享關系:如果兩個掛載對象具有共享關系,那么掛載對象中的掛載事件會傳播到另一個掛載對象上,反之亦然

*  從屬掛載:如果兩個掛載對象形成從屬關系,那么一個掛載對象中的掛載事件會傳播到另一個掛載對象上,反之不行;在這種關系里,從屬對象是事件接受者

掛載狀態有

名稱  
共享掛載 傳播事件的掛載對象
從屬掛載 接受傳播事件的掛載對象
共享/從屬掛載 即具備傳播事件也具備接受傳播事件的掛載對象
私有掛載 既不傳播也不接受事件的掛載對象
不可綁定掛載 另一種特殊掛載對象,他們與私有掛載相似,但不允許執行綁定掛載

 

 

 

 

 

圖來自網絡

最上一層的mount namespace  下的 /bin 目錄 與 child namespace 通過master slave 方式進行掛載傳播,當mount namespace中的/bin 目錄發生變化時,發生的掛載事件能夠自動傳播到 child namespace中;/lib 目錄使用完全共享掛載,各 namespace 之間發生變化時都會影響;proc 目錄使用私有掛載傳播方式,各namespace之間互相隔離;最后/root目錄一般管理員所有,不能讓其他namespace掛載綁定

[root@mast ~]# cat mount.c 
#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>

#define STACK_SIZE (1024 * 1024)

static char child_stack[STACK_SIZE];
char* const child_args[] = {
     "/bin/bash",
     NULL
};

int child_main(void* args) {
     printf("在子進程中!\n");
     sethostname("net",12);
     execv(child_args[0], child_args);
     return 1;
}

int main(){
     printf("程序開始:\n");
     int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
     waitpid(child_pid, NULL, 0);
     printf("已退出\n");
     return 0;
}

  CLONE_NEWNS  生效之后子進程進行的掛載與卸載操作都將只作用於這個mount namespace 因此在上下文種提到的處於單獨PID namespace隔離中的進程在加入 mount namespace的隔離后,即使該進程重新掛載了/proc文件系統,當進程退出后, root mount namespace 主機的/proc文件系統是不會被破壞的

network namespace

    network namespace 主要提供網絡資源的隔離,包括網絡設備、ipv4 和ipv6 協議棧、IP路由表、防火牆、/proc/net 目錄、/sys/class/net目錄、套接字等。一個物理的網絡設備最多存在於一個network namespace中,可以通過創建veth pair (虛擬網絡設備對:有對端兩端類似管道,如果數據從一段傳入另一端也能接受到,反之亦然)不同的network namespace間創建通道,已達到通信目的

  一般情況下,物理網絡設備在最初的root namespace(表示系統默認的namespace)中,但如果有多個物理網卡,也可以把其中一個分配給新創建的network namespace,需要注意的是當新建的network namespace被釋放是(所有的內部進程都將終止並且namespace文件沒有被掛載或被打開),在這個namespace中物理網卡會返回到root namespace,而非創建該進程的父進程所在的namespace。

   當然說到network namespace時, 指的未必是真正的網絡隔離,而是把網絡獨立出來,給外部用戶一種透明的感覺,仿佛在於一個獨立網絡通信,為達到目的,容器經典做法就是創建一個veth pair,把一端綁定到docker0網橋上,另一端接入新建的namespace中連接物理設備,再通過把多個設備接入物理網橋或者進行路由轉發,來實現通信目的

   在建立veth pari 之前新舊namespace通過pipe(管道)通信,以docker daemom 啟動容器的過程為例,假設容器內初始化的進程稱為init 。docker daemom 在宿主機上負責創建這個veth pair ,把一端邦定到docker0網橋上,另一端接入新的network namespace進程中,這個過程執行期間,docker daemom 和init就通過pipe進行通信,直到管道的另一端傳來docker daemom關於veth設備的信息,並關閉管道。init進程才結束等待的過程,並把eth0 啟動起來 

 

 

user namespace

     user namespac主要隔離了安全相關的標識符(identifier)和屬性(attribute),包括用戶ID、用戶組ID、root目錄、key以及特殊權限,通俗的講,一個普通用戶的進程通過clone()創建的新進程在新的user namespace中可以擁有不同的用戶和用戶組。這意味着一個進程在容器外屬於一個沒有特權的普通用戶,但它創建的容器進程卻屬於擁有所有權限的超級用戶,這個技能為容器提供了極大的自由。 

     user namespace 是目前的6個namespace中最后一個支持的,並且知道Linux內核3.8版本的時侯還未完全實現(還有部分文件系統不支持)。user namespace 實際上並不成熟,很多開發版擔心安全問題,在編譯內核的時候並未開啟USER_NS。docker在1.10版本里對user namespace進行支持。只要在啟動docker daemom的時候指定了--userns-remap,那么用戶運行容器時,容器內部的root用戶並不等於宿主機內的root用戶,而是映射到宿主機的普通用戶。

     Linux中,特權用戶的user的ID0 最后將看到userID 非0 的用戶啟動user namespace后userID 可以變成0 

     *user namespace被創建后,第一個進程被賦予了該namespace中全部權限,這樣init進程就可以完成所有必要的初始化工作,而不會因權限不足出錯

      從namespace內部觀察到的uid和gid已經與外部不同了,默認顯示為65534,表示尚未與外部的namespace用戶映射。此時需要對user namespace內部這個初始user和它外部namespace的某個用戶簡歷映射,這樣可以保證當涉及一些對外部namespace的操作時系統可以檢查其權限。同樣用戶組也要建立

      還有一點雖然不易發現,但值得注意,用戶在新的namespace中有全部權限,但它在創建它的父namespace中不具備任何權限,就算調用和創建它的進程有全部權限也是如此,由此哪怕root用戶調用了clone()在user namespace中創建出新的用戶,在外部也沒有任何權限

      最后user namespace的創建其實是一個層層嵌套的樹狀結構,最上層的節點是root namespace,新創建的user namespace都有一個父節點user namespace,以及零個或多個子節點user namespace,這一點與PID namespace 十分相似

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM