500行程式碼手寫docker-以新名稱空間執行程式

藍胖子的程式設計夢發表於2023-05-19

(2)500行程式碼手寫docker-以新名稱空間執行程式

本系列教程主要是為了弄清楚容器化的原理,紙上得來終覺淺,絕知此事要躬行,理論始終不及動手實踐來的深刻,所以這個系列會用go語言實現一個類似docker的容器化功能,最終能夠容器化的執行一個程式。

本章的原始碼已經上傳到github,地址如下:

https://github.com/HobbyBear/tinydocker/tree/chapter2

本章要完成的任務則是golang啟動一個sh的程式,並且sh的程式將在新的名稱空間中執行。

屆時執行效果如下:

tty.gif

在正式開始編寫程式碼前,先來看看linux namespace涉及的一些原理。

linux namespace 原理

名稱空間是linux為了隔離各種資源而形成的一個概念,不同型別的名稱空間能夠對不同型別的資源進行隔離。

目前linux支援的名稱空間有:

名稱空間
syscall.CLONE_NEWUTS 對主機名進行隔離
syscall.CLONE_NEWPID 對pid空間進行隔離
syscall.CLONE_NEWNS 對mount名稱空間進行隔離
syscall.CLONE_NEWNET 對網路進行隔離
syscall.CLONE_NEWIPC 對程式通訊元件進行隔離,我認為主要是針對訊息佇列

對主機名的隔離 和ipc程式訊息通訊的隔離 比較好理解,不同uts 名稱空間和ipc名稱空間,其主機名和各自在ipc名稱空間內部建立的 ipc元件對彼此都不可見。

著重來看下剩下的3種型別的名稱空間。

syscall.CLONE_NEWPID

核心在為新程式分配pid時對不同pid的namespace是進行了隔離的,同一個pid 的namespace下的pid是不會重複的,但不同pid的namespace下的pid可以重複。

當執行mount 命名掛載procfs時,也是先從當前程式 獲取到 與程式相同的pid namespace,然後進行掛載的。所以後續你會看到當一個處於新pid namespace裡的程式,執行mount 重新掛載proc檔案系統後,proc檔案系統中程式號為1的程式就變了。

syscall.CLONE_NEWNS

再來看看mnt namespace, 當執行完一個mount 命名後,再訪問掛載的目標目錄,你會發現目標目錄的內容已經變成了你指定的掛載目錄,mnt namespace就是為了讓你的掛載目錄對於其他的mnt namespace變成不可見的,其他mnt namespace該目錄下依然是原先的目錄結構。

??❗️不過注意下,當用systemd作為init程式啟動時,mount 預設的掛載方式是共享模式,這意味著你在一個mnt namespace下執行mount命令後的掛載對其他mnt 的namespace是可見的。

比如我在新mnt namespace下掛載procfs,這將會導致主機上的procfs失效,然後你訪問主機的/proc 目錄將會發現主機/proc目錄下的內容和新mnt namespace /proc目錄下的內容是一樣的。所以當你回到主機的mnt namespace去執行top命令時,將會提示你需要將procfs重新程式掛載

解決這個問題的辦法則是將新mnt namespace設定為私有模式,後續我會在程式碼裡體現這一部分。

syscall.CLONE_NEWNET

最後我們來看看network namespace起的作用,網路傳輸過程涉及到網路裝置,域名解析,路由表,防火牆等等網路配置,network namespace的出現就是為了將這些各種各樣的網路配置進行隔離。所以你會發現,當你最開始進入一個新network namespace時,你用ping 命名是ping不通任何地址的,因為你並沒有為新的network namespace配置任何網路配置資訊,比如路由表。

在大致瞭解了各種名稱空間之後,那麼究竟該如何在建立一個程式時指定新名稱空間呢,讓我們來看看用go如何實現。

golang 如何以新名稱空間啟動程式

cmd := exec.Command(initCmd, os.Args[1:]...)
		cmd.SysProcAttr = &syscall.SysProcAttr{
			Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
				syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
		}
		cmd.Env = os.Environ()
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		err = cmd.Run()

golang 可以透過exec包下的Command 方法構建一個Command結構體,呼叫其Run 方法將會啟動一個新的程式,其本質也是先clone系統呼叫 然後再 進行exec呼叫,可以看到在構建Command 時指定了clone所需要的flag引數 。

??❗️clone系統呼叫其實和fork系統呼叫類似,不過clone系統呼叫可以指定在建立子程式時對哪些資源進行復制,比如上述例子中我們指定了各種名稱空間的flag,這代表新啟動的子程式將會在新的名稱空間下執行。

Command 啟動結構體其實有兩種方式,一種是Start 方式,一種就是像程式碼裡的Run 方法啟動。

兩種方法區別在於呼叫Start 不會等待子程式結束,而Run 方法將會等待子程式結束。而這裡為什麼要呼叫Run 方法呢,因為這裡需要用到標準輸入輸出流,可以看到,我將控制檯輸入輸出流傳遞給了Command的Stddin,Stdout引數,如果父程式在呼叫Start後關閉了程式,程式關閉將導致自身的檔案描述符也關閉,所以標準輸入輸出也會關閉,那麼子程式將不不能從標準輸入中獲取到資訊了。

其父子進行通訊的原理是透過建立一個管道,透過管道將標準輸入的訊息傳遞給了子程式,子程式也通道管道將自身的輸出 輸出到 標準輸出。

總之,到這裡算是明白瞭如何用golang啟動一個新程式,並且新程式將擁有自己的名稱空間。

現在讓我們看下完整的這段程式碼

func main() {
	switch os.Args[1] {
	case "run":
		initCmd, err := os.Readlink("/proc/self/exe")
		if err != nil {
			fmt.Println("get init process error ", err)
			return
		}
		os.Args[1] = "init"
		cmd := exec.Command(initCmd, os.Args[1:]...)
		cmd.SysProcAttr = &syscall.SysProcAttr{
			Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
				syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
		}
		cmd.Env = os.Environ()
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		err = cmd.Run()
		if err != nil {
			fmt.Println(err)
		}
		fmt.Println("init proc end", initCmd)
		return
	case "init":
		cmd := os.Args[2]
		err := syscall.Exec(cmd, os.Args[2:], os.Environ())
		if err != nil {
			fmt.Println("exec proc fail ", err)
			return
		}
		fmt.Println("forever exec it ")
		return
	default:
		fmt.Println("not valid cmd")
	}
}

來簡單分析下這段程式碼,如果傳遞給程式的引數是run 那麼將會在一個新的名稱空間內 啟動一個子程式,子程式執行的程式碼也是當前可執行程式的程式碼。

???‍? /proc/self/exe 是一個軟連結,程式內部讀取到的連結是自身可執行檔案的路徑。比如執行

root@ecs-295280:~/projects/tinydocker# ls -l  /proc/self/exe
lrwxrwxrwx 1 root root 0 May 11 17:32 /proc/self/exe -> /usr/bin/ls

ls 執行後返回/usr/bin/ls

接著傳遞給子程式init 引數 ,子程式接手到init引數後將會用 後續的 可執行檔案 程式 呼叫exec覆蓋當前程式。所以可以看到 用init 引數啟動的程式,是新的名稱空間內的第一個程式,後續用exec系統呼叫,將覆蓋這個程式的堆疊,記憶體空間等資訊,從而讓init 後面的可執行檔案變成名稱空間內的第一個程式。

我們執行這段程式時便可以這樣執行。

root@ecs-295280:~/projects/tinydocker# ./tinydocker run /bin/ls
go.mod	main.go  ReadMe.md  tinydocker
init proc end /root/projects/tinydocker/tinydocker
root@ecs-295280:~/projects/tinydocker# ./tinydocker run /bin/sh
#
#
#
#
#

可以看到run 後面接著 執行/bin/ls 成功輸出了當前目錄下的檔案,執行 /bin/sh後啟動了一個sh程式以便於我們同控制檯進行互動,這得益於我們 在cmd.Run 啟動新程式前 將標準輸入輸出賦值給了cmd的標準輸入輸出引數。

不過可以看到 輸出的目錄還是主機上的目錄,並沒有達到隔離的效果,這是因為即使宣告瞭建立新程式時在新的名稱空間內部,但是因為沒有重新掛載相關目錄,新的mnt namespace依然是繼承自主機的mnt namespace,所以在沒有重新掛載的前提下,新的mnt namespace下看到的目錄和主機的是一樣的。

所以現在讓我們來重新掛載下目錄。

為程式重新掛載根檔案系統

首先要明白尋找檔案系統地址的原理,當程式在尋找地址時,會首先判斷地址時相對地址還是絕對地址,如果是相對地址則會從程式當前的地址開始尋找,如果是以‘/’ 開頭,則說明是絕對地址,那麼將會從根路徑開始尋找,根路徑涉及到兩個點,一個是mnt namespace的根路徑,一個是程式自身的根路徑,比如程式將自身根路徑設定為/home 那麼程式自身在尋找/lanpangzi 時,實際是從 /home/lanpangzi 開始尋找。

現在來看看替換程式能夠看到的檔案範圍時涉及的兩種方式,這兩種方式也是和剛才提到的根路徑涉及的兩個點有關。

chroot 替換方式

首先是chroot的方式,使用chroot可以替換程式自身的根目錄,這樣程式自身能夠尋找到的範圍就變到了設定的根目錄下。

不過在看具體的程式碼前,我們還得有這麼一個目錄,到時候讓程式把這個目錄設定為其根目錄,這個目錄下的下檔案通常會用linux的根目錄檔案系統(rootfs)填充,可以從這個網址上下載

之後我們便可以用chroot 替換程式的根目錄了。

syscall.Chroot("./ubuntu-base-16.04.6-base-amd64")
syscall.Chdir("/")

我的程式目錄如下

(base) ➜  tinydocker git:(main) ✗ tree -L 1
.
├── ReadMe.md
├── go.mod
├── main.go
├── ubuntu-base-16.04.6-base-amd64
└── ubuntu-base-16.04.6-base-amd64.tar.gz

注意為什麼在更換了程式根目錄後為啥還有個syscall.Chdir 切換程式當前工作目錄的系統呼叫,因為即使切換了程式的根目錄,程式還是在tinydocker 下,由於 相對地址的定址不會尋找根目錄,所以如果此時程式用相對地址'./go.mod'去訪問go mod檔案還是能訪問到,當使用syscall.Chdir後,將會更新程式的當前目錄到ubuntu-base-16.04.6-base-amd64下,這樣後續程式對檔案的訪問範圍將限制在ubuntu-base-16.04.6-base-amd64目錄下。

不過chroot切換 檔案系統根目錄的方式只能改變該程式能看到的檔案範圍,並不能改變mnt namespace的根目錄,所以替換的並不徹底。

可以用nsenter 命令進入mnt namespace 去看下mnt namespace的目錄有哪些驗證這一點。

首先是我在新程式下,檢視根目錄下的目錄

root@ecs-295280:~/projects/tinydocker# ./tinydocker run /bin/sh
# ls /
bin  boot  dev	etc  home  lib	lib64  media  mnt  opt	proc  root  run  sbin  srv  sys  tmp  usr  var

執行的sh程式pid是 184295 ,然後我再在主機上用nsenter 命令 進入該程式的名稱空間 檢視該mnt namespace 根目錄下有哪些檔案

root@ecs-295280:~# nsenter -t 184295 -m ls /
bin   CloudResetPwdUpdateAgent	conf  etc   lib    lib64   log	       media  opt   root  sbin	src  swapfile  tmp  var
boot  CloudrResetPwdAgent	dev   home  lib32  libx32  lost+found  mnt    proc  run   snap	srv  sys       usr
root@ecs-295280:~#

可以看到兩個目錄下的檔案是不一樣的,而nsenter進入mnt namespace 下檢視的根目錄 檔案 則是我主機的mnt namespace上的根目錄檔案。

關於nsenter 使用的一些引數設定如下:

nsenter [options] [program [arguments]]

options:
-t, --target pid:指定被進入名稱空間的目標程式的pid
-m, --mount[=file]:進入mount命令空間。如果指定了file,則進入file的命令空間
-u, --uts[=file]:進入uts命令空間。如果指定了file,則進入file的命令空間
-i, --ipc[=file]:進入ipc命令空間。如果指定了file,則進入file的命令空間
-n, --net[=file]:進入net命令空間。如果指定了file,則進入file的命令空間
-p, --pid[=file]:進入pid命令空間。如果指定了file,則進入file的命令空間
-U, --user[=file]:進入user命令空間。如果指定了file,則進入file的命令空間
-G, --setgid gid:設定執行程式的gid
-S, --setuid uid:設定執行程式的uid
-r, --root[=directory]:設定根目錄
-w, --wd[=directory]:設定工作目錄

如果沒有給出program,則預設執行$SHELL。

pivot_root 替換方式

接著來看看pivot root 的方式,使用pivot root 的方式替換掛載目錄,可以把mnt 名稱空間的根目錄也替換掉。

func PivotRoot(newroot string, putold string) (err error) 

pivot root 系統呼叫有些限制,它要求傳入兩個目錄,首先newroot 和putold目錄是要求在同一個掛載名稱空間中,putold會存放之前舊的名稱空間的檔案,並且newroot 和putold處於的掛載名稱空間和舊的掛載名稱空間不能是同一個。

由於子程式預設會繼承父程式的名稱空間,所以需要對新名稱空間的目錄進行重新掛載一下。

syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, "")
syscall.Mount(newroot, newroot, "bind", syscall.MS_BIND|syscall.MS_REC, "")

注意在使用mount bind 重新掛載 newroot之前,我先透過一條mount語句宣告瞭掛載名稱空間從根目錄開始以及其子目錄都是私有模式掛載,因為我的主機是ubuntu,預設的init程式已經由systemd程式代替,systemd程式的預設掛載方式是共享掛載,後續會導致pivot_root的呼叫失敗,宣告為私有模式掛載後,再呼叫mount bind對newroot目錄重新掛載,那麼newroot目錄就會被單獨掛載到新名稱空間了。

此時我再在主機上把程式啟動起來,然後執行nsenter 命令進入程式的mnt namepspace去檢視根目錄就發現它和主機上面的根目錄不一樣了。以下是相關命令。

將程式啟動起來

root@ecs-295280:~# cd projects/tinydocker/
root@ecs-295280:~/projects/tinydocker# ./tinydocker run /bin/sh
#

檢視程式的程式號

root@ecs-295280:~# ps -ef | grep /bin/sh
root      186426  186410  0 10:40 pts/0    00:00:00 ./tinydocker run /bin/sh
root      186430  186426  0 10:40 pts/0    00:00:00 /bin/sh
root      186534  186506  0 10:45 pts/1    00:00:00 grep --color=auto /bin/sh

其中186430是 我啟動的程式,進入該程式的mnt namespace 去執行 ls命令檢視根目錄

root@ecs-295280:~# nsenter -t 186430 -m /bin/ls /
bin  boot  dev	etc  home  lib	lib64  media  mnt  opt	proc  root  run  sbin  srv  sys  tmp  usr  var
root@ecs-295280:~#

發現mnt namepsace的根目錄已經被新的newroot下的rootfs替換掉了,已經和主機的根目錄不同了。

為程式掛載proc檔案系統

看到這裡,還沒有結束,我們剛剛僅僅把系統的根檔案系統替換掉了,不過這個時候,如果你執行top命令會發現它提示你錯誤。

# top
Error, do this: mount -t proc proc /proc

這是由於top命令預設會從/proc 路徑去讀取核心的程式資訊,而替換了根檔案系統後,/proc下還沒有掛載procfs,所以需要重新掛載下。

?????‍♀️ procfs是一個記憶體檔案系統,當用mount 掛載proc型別的檔案系統時,核心會從當前程式的pid namespace下找到該pid namespace下的所有程式,然後將程式的各種資訊透過訪問 /proc目錄的方式暴露出來。這樣再訪問/proc目錄時,就能訪問到該pid namespace下的所有程式資訊了。

新增上掛載proc檔案系統的程式碼。

defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")

再執行top命令.

top - 03:04:51 up 14 days, 10:23,  0 users,  load average: 0.00, 0.00, 0.00
Tasks:   2 total,   1 running,   1 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.0 us,  0.0 sy,  0.0 ni, 99.7 id,  0.3 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem :  2030524 total,   193608 free,   159664 used,  1677252 buff/cache
KiB Swap:        0 total,        0 free,        0 used.  1623628 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S %CPU %MEM     TIME+ COMMAND
      1 root      20   0    4500    752    684 S  0.0  0.0   0:00.00 sh
      4 root      20   0   36528   3044   2640 R  0.0  0.1   0:00.00 top

可以看到當前我的pid namespace下有兩個程式,一個是sh程式,一個是top程式,而sh由於是該名稱空間下執行的第一個程式,所以程式號為1。

這樣便完成了在新名稱空間內部執行一個程式,並且將程式的檔案系統和主機的檔案系統進行了隔離。不過隔離僅僅做到這一步還不算完,回憶下,當我們用docker啟動一個程式時,是不是可以用同一份映象啟動多個容器,類比下現在的實現,你會發現,如果用一份rootfs來啟動多個程式,那麼多個程式最後改變的將會是同一個rootfs下的檔案,這樣將達不到將檔案系統隔離的目的。

所以在下面一講,我將演示下如何用核心聯合檔案系統的特質,達到一份映象多次執行的效果。

相關文章