看穿容器的外表,Linux容器实现原理演示
來源 | 多選參數
責編 | 程序鍋
頭圖 | 下載于視覺中國
容器技術的核心功能,就是通過約束和修改進程的動態表現,從而為其創造出一個“邊界”也就是獨立的“運行環境”。下面我們使用 C 語言和 Namespace 技術來手動創建一個容器,演示 Linux 容器最基本的實現原理。
什么是容器?容器其實是一種特殊的進程而已,只是這個進程運行在自己的 “運行環境” 中,比如有自己的文件系統而不是使用主機的文件系統(文件系統這個對我來說印象是最深刻的,也是讓人對容器很更好理解的一個切入點)。
有一個計算數值總和的小程序,這個程序的輸入來自一個文件,計算完成后的結果則輸出到另一個文件中。為了讓這個程序可以正常運行,除了程序本身的二進制文件之外還需要數據,而這兩個東西放在磁盤上,就是我們平常所說的一個“程序”,也就是代碼的可執行鏡像。
當“程序”被執行起來之后,它就從磁盤上的二進制文件變成了計算機內存中的數據、寄存器里的值、堆棧中的指令、被打開的文件,以及各種設備的狀態信息的一個集合。像這樣一個程序運行起來后的計算機執行環境的總和,就是進程,而計算機執行環境的總和就是它的動態表現。
而容器技術的核心功能,就是通過約束和修改進程的動態表現,從而為其創造出一個“邊界”也就是獨立的“運行環境”。那么怎么去造成這個邊界呢?
對于 Docker 等大多數 Linux 容器來說,Cgroups 技術是用來制造約束的主要手段;
Namespace 技術則是用來修改進程視圖的主要方法;
下面我們使用 C 語言和 Namespace 技術來手動創建一個容器,演示 Linux 容器最基本的實現原理。
自己實現一個容器
Linux 中關于 Namespace 的系統調用主要有這么三個:
clone()---實現線程的系統調用,用來創建一個新的進程,同時可以設置 Namespace 的一些參數。
unshare()---使某個進程脫離某個 namespace。
setns()---把某進程加入到某個 namespace。
我們使用 clone 來創建一個子進程,通過創建出來的效果可以看到,子進程的 PID 是跟在父親節點后面的,而不是 1。
1#define?_GNU_SOURCE2#include?<sys/types.h>3#include?<sys/wait.h>4#include?<sys/mount.h>5#include?<stdio.h>6#include?<sched.h>7#include?<signal.h>8#include?<unistd.h>9 10#define?STACK_SIZE?(1024?*?1024) 11static?char?container_stack[STACK_SIZE]; 12 13char*?const?container_args[]?=?{ 14????"/bin/bash", 15????NULL 16}; 17 18int?container_main(void*?arg)?{ 19????printf("Container?[%5d]?-?inside?the?container!\n",?getpid()); 20????execv(container_args[0],?container_args); 21????printf("Something's?wrong!\n"); 22????return?1; 23} 24 25int?main()?{ 26????printf("Parent?[%5d]?-?start?a?container!\n",?getpid()); 27????int?container_id?=?clone(container_main,?container_stack?+?STACK_SIZE,?SIGCHLD,?NULL); 28????waitpid(container_id,?NULL,?0); 29????printf("Parent?-?container?stopped!\n"); 30????return?0; 31}接下去這段代碼我們給創建出來的進程設置 PID namespace 和 UTS namespace。從實際的效果我們可以看到子進程的 pid 為 1,而子進程中打開的 bash shell 顯示的主機名為 container_dawn。是不是有點容器那味了?這里子進程在自己的 PID Namespace 中的 PID 為 1,因為 Namespace 的隔離機制,讓這個子進程誤以為自己是第 1 號進程,相當于整了個障眼法。但是,實際上這個進程在宿主機的進程空間中的編號不為 1,是一個真實的數值,比如 14624。
1int?container_main(void*?arg)?{2????printf("Container?[%5d]?-?inside?the?container!\n",?getpid());3????sethostname("container_dawn",?15);4????execv(container_args[0],?container_args);5????printf("Something's?wrong!\n");6????return?1;7}89int?main()?{ 10????printf("Parent?[%5d]?-?start?a?container!\n",?getpid()); 11????int?container_id?=?clone(container_main,?container_stack?+?STACK_SIZE,? 12????????????????????????????????CLONE_NEWUTS?|?CLONE_NEWPID?|?SIGCHLD,?NULL); 13????waitpid(container_id,?NULL,?0); 14????printf("Parent?-?container?stopped!\n"); 15????return?0; 16}最后我們改變一下這個進程可以看到的文件系統,我們首先使用 docker export 將 busybox 鏡像導出成一個 rootfs 目錄,這個 rootfs 目錄的情況如圖所示,已經包含了?/proc、/sys 等特殊的目錄。
接下去,我們在代碼中使用 chroot()?函數將創建出來的子進程的根目錄改變成上述的 rootfs 目錄。從實現的效果來看,創建出來的子進程的 PID 為 1,并且這個子進程將上述提到的 rootfs 目錄當成了自己的根目錄。
1char*?const?container_args[]?=?{2????"/bin/sh",3????NULL4};56int?container_main(void*?arg)?{7????printf("Container?[%5d]?-?inside?the?container!\n",?getpid());89????if?(chdir("./rootfs")?||?chroot("./")?!=?0)?{ 10????????perror("chdir/chroot"); 11????} 12 13????execv(container_args[0],?container_args); 14????printf("Something's?wrong!\n"); 15????return?1; 16} 17 18int?main()?{ 19????printf("Parent?[%5d]?-?start?a?container!\n",?getpid()); 20????int?container_id?=?clone(container_main,?container_stack?+?STACK_SIZE,? 21????????????????????????????????CLONE_NEWUTS?|?CLONE_NEWPID?|?CLONE_NEWNS?|?SIGCHLD,?NULL); 22????waitpid(container_id,?NULL,?0); 23????printf("Parent?-?container?stopped!\n"); 24????return?0; 25}需要注意的是所使用的 shell 需要改一下,因為 busybox 中沒有 /bin/bash,假如還是 /bin/bash 的話是會報錯的,因為 chroot 改變子進程的根目錄視圖之后,最終是從 rootfs/bin/ ?中找 bash 這個程序的。
上面其實已經基本實現了一個容器,接下去我們實現一下 Docker 卷的基本原理(假設你已經知道卷是什么了)。在代碼中,我們將 /tmp/t1 這個目錄掛載到 rootfs/mnt 這個目錄中,并采用 MS_BIND 的方式,這種方式使得 rootfs/mnt (進入容器之后就是 mnt 目錄)的視圖其實就是 /tmp/t1 的視圖,你對 rootfs/mnt 的修改其實就是對 /tmp/t1 修改,rootfs/mnt 相當于 /tmp/t1 的另一個入口而已。當然,在實驗之前,你先確保 /tmp/t1 和 rootfs/mnt 這兩個目錄都已經被創建好了。實驗效果見代碼之后的那張圖。
1char*?const?container_args[]?=?{2????"/bin/sh",3????NULL4};56int?container_main(void*?arg)?{7????printf("Container?[%5d]?-?inside?the?container!\n",?getpid());89????/*模仿?docker?中的?volume*/ 10????if?(mount("/tmp/t1",?"rootfs/mnt",?"none",?MS_BIND,?NULL)!=0)?{ 11????????perror("mnt"); 12????} 13 14????/*?隔離目錄?*/ 15????if?(chdir("./rootfs")?||?chroot("./")?!=?0)?{ 16????????perror("chdir/chroot"); 17????} 18 19????execv(container_args[0],?container_args); 20????printf("Something's?wrong!\n"); 21????return?1; 22} 23 24int?main()?{ 25????printf("Parent?[%5d]?-?start?a?container!\n",?getpid()); 26????int?container_id?=?clone(container_main,?container_stack?+?STACK_SIZE,? 27????????????????????????????????CLONE_NEWUTS?|?CLONE_NEWPID?|?CLONE_NEWNS?|?SIGCHLD,?NULL); 28????waitpid(container_id,?NULL,?0); 29????printf("Parent?-?container?stopped!\n"); 30????return?0; 31}除了上述所使用的 PID、UTS、Mount namespace,Linux 操作系統還提供了 IPC、Network 和 User 這些 Namespace。
總結
通過上面我們可以看到,容器的創建和普通進程創建沒什么區別。都是父進程先創建一個子進程,只是對于容器來說,這個子進程接下去通過內核提供的隔離機制再給自己創建一個獨立的資源環境。
同理,在使用 Docker 的時候,其實也并沒有一個真正的 Docker 容器運行在宿主機里面。Docker 項目啟動還是用戶原來的應用進程,只是在創建進程的時候,Docker 為這個進程指定了它所需要啟用的一組 Namespace 參數。這樣,這個進程只能“看”到當前 Namespace 所限定的資源、文件、設備、狀態或者配置。而對于宿主機以及其他不相關的程序,這個進程就完全看不到了。這時,進程就會以為自己是 PID Namespace 里面 1 號進程,只能看到各自 Mount Namespace 里面掛載的目錄和文件,只能訪問 Network Namespace 里的網絡設備。這種就使得進程運行在一個獨立的“運行環境”里,也就是容器里面。
因此,對接一開始所說的,還想再嘮叨一句:容器其實就是一種特殊的進程而已。**只是這個進程和它運行所需的所有資源都打包在了一起,進程執行時所使用的資源也都是打包中的。相比虛擬機的方式,本質是進程的容器則僅僅是在操作系統上劃分出了不同的“運行環境”,從而使得占用資源更少,部署速度更快。
更多閱讀推薦
?用三國殺講分布式算法,舒適了吧?
?云原生體系下的技海浮沉
如何通過 Serverless 輕松識別驗證碼?
5G與金融行業融合應用的場景探索
打破“打工人”魔咒,RPA 來狙擊!
總結
以上是生活随笔為你收集整理的看穿容器的外表,Linux容器实现原理演示的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 终止中台乱象 《2021年中国中台市场研
- 下一篇: 中国电子云发布专属云CECSTACK 以