干貨分享:用 Go 從頭實(shí)現(xiàn)一個(gè)迷你 Docker—Gocker
容器很受歡迎。容器已成為應(yīng)用程序在服務(wù)器上打包和運(yùn)行的默認(rèn)方式,最初是由 Docker 普及的?,F(xiàn)在,Docker 是公司的名稱和一個(gè)命令(一組命令),使您可以輕松管理容器(創(chuàng)建,運(yùn)行,刪除,網(wǎng)絡(luò))。但是,容器本身是從一組操作系統(tǒng)原語創(chuàng)建的。在本文中,我們將關(guān)注 Linux 操作系統(tǒng)上的容器,并簡單地說明為什么Windows 上的容器[1]根本不存在。
Linux 下沒有創(chuàng)建容器的單個(gè)系統(tǒng)調(diào)用。它們是利用 Linux 命名空間和控制組或 cgroups 構(gòu)成的松散構(gòu)造。
Gocker 是什么?
Gocker[2] 是一個(gè)使用 Go 編程語言從頭開始實(shí)現(xiàn) Docker 核心功能的項(xiàng)目。它主要目的是提供對(duì)容器在 Linux 系統(tǒng)調(diào)用級(jí)別上如何工作的理解。Gocker 允許你創(chuàng)建容器,管理容器鏡像(Image),在容器中執(zhí)行進(jìn)程等。

Gocker 的功能
Gocker 可以模擬 Docker 的內(nèi)核,讓你管理 Docker 鏡像(從 Docker Hub 獲?。?,運(yùn)行容器,列出正在運(yùn)行的容器或在已經(jīng)運(yùn)行的容器中運(yùn)行進(jìn)程:
- 在容器中運(yùn)行進(jìn)程
- gocker run <--cpus=cpus-max> <--mem=mem-max> <--pids=pids-max> <image[:tag]> </path/to/command>
- 列出正在運(yùn)行的容器
- gocker ps
- 在運(yùn)行的容器中執(zhí)行進(jìn)程
- gocker exec </path/to/command>
- 列出本地可用的鏡像
- gocker images
- 刪除本地可用的鏡像
- gocker rmi
其他功能
- Gocker 使用 Overlay 文件系統(tǒng)快速創(chuàng)建容器,而無需復(fù)制整個(gè)文件系統(tǒng),同時(shí)還可以在多個(gè)容器實(shí)例之間共享同一容器鏡像。
- Gocker 容器擁有自己的網(wǎng)絡(luò)命名空間,并且能夠訪問 Internet。請(qǐng)參閱下面的限制。
- 您可以控制系統(tǒng)資源,例如 CPU 百分比,RAM 數(shù)量和進(jìn)程數(shù)。Gocker 通過利用 cgroups 實(shí)現(xiàn)了這一目標(biāo)。
Gocker 容器隔離性
用 Gocker 創(chuàng)建的容器擁有自己的以下命名空間(請(qǐng)參見 run.go 和 network.go):
- 文件系統(tǒng) File system (via chroot)
- PID
- IPC
- UTS (hostname)
- Mount
- Network
在創(chuàng)建用于限制以下內(nèi)容的 cgroup 時(shí),除非你在 gocker run 命令中指定了 --mem,--cpus 或 --pids 選項(xiàng),否則容器將使用無限的資源。這些標(biāo)志分別限制了容器可以使用的最大 RAM,CPU 內(nèi)核和 PID。
- CPU 核心數(shù)
- RAM
- PID 數(shù)量(限制進(jìn)程)
命名空間(Namespaces)基礎(chǔ)
所有 Linux 計(jì)算機(jī)在啟動(dòng)時(shí)都是 “default” 命名空間的一部分。在計(jì)算機(jī)上創(chuàng)建的進(jìn)程也繼承默認(rèn)命名空間。換句話說,因?yàn)樗袑?duì)象也都存在于默認(rèn)命名空間中,進(jìn)程可以看到正在運(yùn)行的其他進(jìn)程,網(wǎng)絡(luò)接口,掛載點(diǎn),名為 IPC 的對(duì)象或權(quán)限允許的文件。當(dāng)創(chuàng)建一個(gè)進(jìn)程時(shí),我們可以告訴 Linux 為我們創(chuàng)建一個(gè)新的 PID 命名空間,在這種情況下,新進(jìn)程及其任何后代形成一個(gè)新的層次結(jié)構(gòu)或 PID,而新創(chuàng)建的初始進(jìn)程為 PID 1,就像 Linux 機(jī)器上特殊的初始化進(jìn)程一樣。假設(shè)使用新的 PID 命名空間創(chuàng)建了一個(gè)名為 “new_child” 的進(jìn)程。當(dāng)該進(jìn)程或其后代使用諸如 getpid() 或 getppid() 之類的系統(tǒng)調(diào)用時(shí),它們會(huì)在新命名空間中看到 PID。例如,對(duì)于這兩個(gè)系統(tǒng)調(diào)用,在新創(chuàng)建的 PID 命名空間中的 new_child 將獲得 1。而當(dāng)您從默認(rèn)命名空間查看 new_child 的 PID 時(shí),當(dāng)然不會(huì)為其分配 1(那是默認(rèn)命名空間中的 init 了)。
Linux 操作系統(tǒng)提供了在創(chuàng)建進(jìn)程時(shí)或與之關(guān)聯(lián)的正在運(yùn)行的進(jìn)程創(chuàng)建新命名空間的方法。所有命名空間,無論其類型如何,都被分配了內(nèi)部 ID。命名空間是一種內(nèi)核對(duì)象。一個(gè)進(jìn)程只能屬于一個(gè)命名空間。例如,假設(shè)一個(gè)進(jìn)程 new_child 的 PID 命名空間設(shè)置為內(nèi)部 ID 為 0x87654321 的命名空間,它不能屬于另一個(gè) PID 命名空間。但是,可能存在其他屬于同一 PID 命名空間 0x87654321 的其他進(jìn)程。同樣,new_child 的后代將自動(dòng)屬于相同的 PID 命名空間。命名空間是繼承的。
你可以使用 lsns 實(shí)用程序列出計(jì)算機(jī)中的各種命名空間。即使您的計(jì)算機(jī)上沒有運(yùn)行任何容器,也很可能會(huì)看到與各種命名空間相關(guān)的其他進(jìn)程。這表明,命名空間并不僅僅是在容器的上下文中使用。它們可以在任何地方使用。它們提供隔離。它們是一項(xiàng)強(qiáng)大的安全功能。在現(xiàn)代 Linux 系統(tǒng)上,您會(huì)看到 init,systemd,幾個(gè)系統(tǒng)守護(hù)程序,Chrome,Slack,當(dāng)然還有使用各種命名空間的 Docker 容器。讓我們看一看我機(jī)器上的 lsns 實(shí)用程序的輸出:
- NS TYPE NPROCS PID USER COMMAND
- 4026532281 mnt 1 313 root /usr/lib/systemd/systemd-udevd
- 4026532282 uts 1 313 root /usr/lib/systemd/systemd-udevd
- 4026532313 mnt 1 483 systemd-timesync /usr/lib/systemd/systemd-timesyncd
- 4026532332 uts 1 483 systemd-timesync /usr/lib/systemd/systemd-timesyncd
- 4026532334 mnt 1 502 root /usr/bin/NetworkManager --no-daemon
- 4026532335 mnt 1 503 root /usr/lib/systemd/systemd-logind
- 4026532336 uts 1 503 root /usr/lib/systemd/systemd-logind
- 4026532341 pid 1 1943 shuveb /opt/google/chrome/nacl_helper
- 4026532343 pid 2 1941 shuveb /opt/google/chrome/chrome --type=zygote
- 4026532345 net 50 1941 shuveb /opt/google/chrome/chrome --type=zygote
- 4026532449 mnt 1 547 root /usr/lib/boltd
- 4026532489 mnt 1 580 root /usr/lib/bluetooth/bluetoothd
- 4026532579 net 1 1943 shuveb /opt/google/chrome/nacl_helper
- 4026532661 mnt 1 766 root /usr/lib/upowerd
- 4026532664 user 1 766 root /usr/lib/upowerd
- 4026532665 pid 1 2521 shuveb /opt/google/chrome/chrome --type=renderer
- 4026532667 net 1 836 rtkit /usr/lib/rtkit-daemon
- 4026532753 mnt 1 943 colord /usr/lib/colord
- 4026532769 user 1 1943 shuveb /opt/google/chrome/nacl_helper
- 4026532770 user 50 1941 shuveb /opt/google/chrome/chrome --type=zygote
- 4026532771 pid 1 2010 shuveb /opt/google/chrome/chrome --type=renderer
- 4026532772 pid 1 2765 shuveb /opt/google/chrome/chrome --type=renderer
- 4026531835 cgroup 294 1 root /sbin/init
- 4026531836 pid 237 1 root /sbin/init
- 4026531837 user 238 1 root /sbin/init
- 4026531838 uts 289 1 root /sbin/init
- 4026531839 ipc 292 1 root /sbin/init
- 4026531840 mnt 283 1 root /sbin/init
- 4026531992 net 236 1 root /sbin/init
- 4026532912 pid 2 3249 shuveb /usr/lib/slack/slack --type=zygote
- 4026532914 net 2 3249 shuveb /usr/lib/slack/slack --type=zygote
- 4026533003 user 2 3249 shuveb /usr/lib/slack/slack --type=zygote
即使您沒有顯式創(chuàng)建命名空間,進(jìn)程也將成為默認(rèn)命名空間的一部分。所有命名空間的詳細(xì)信息都記錄在 /proc 文件系統(tǒng)中。您可以通過輸入 ls -l /proc/self/ns/來查看您的 Shell 進(jìn)程所屬的命名空間。這是我電腦的結(jié)果。另外,這些大多是從 init 繼承的:
- ➜ ~ ls -l /proc/self/ns
- total 0
- lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 cgroup -> 'cgroup:[4026531835]'
- lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 ipc -> 'ipc:[4026531839]'
- lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 mnt -> 'mnt:[4026531840]'
- lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 net -> 'net:[4026531992]'
- lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 pid -> 'pid:[4026531836]'
- lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 pid_for_children -> 'pid:[4026531836]'
- lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 user -> 'user:[4026531837]'
- lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 uts -> 'uts:[4026531838]'
沒有容器的命名空間
從 lsns 的輸出中,我們看到容器并不是唯一使用命名空間的對(duì)象。為此,讓我們創(chuàng)建一個(gè)具有自己的 PID 命名空間的 shell 實(shí)例。我們將使用 unshare 實(shí)用程序來做到這一點(diǎn)。“unshare” 這個(gè)名字很明顯。還有一個(gè)同名的 Linux 系統(tǒng)調(diào)用[3],可讓您取消共享默認(rèn)命名空間,從而使調(diào)用進(jìn)程加入新創(chuàng)建的命名空間。
- ➜ ~ sudo unshare --fork --pid --mount-proc /bin/bash
- [root@kodai shuveb]# ps aux
- USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
- root 1 0.5 0.0 8296 4944 pts/1 S 08:59 0:00 /bin/bash
- root 2 0.0 0.0 8816 3336 pts/1 R+ 08:59 0:00 ps aux
- [root@kodai shuveb]#
在以上調(diào)用中,unshare 實(shí)用程序正在派生一個(gè)新進(jìn)程,調(diào)用 unshare() 系統(tǒng)調(diào)用以創(chuàng)建一個(gè)新的 PID 命名空間,然后在其中執(zhí)行 /bin/bash。我們還告訴 unshare 實(shí)用程序在新進(jìn)程中掛載 proc 文件系統(tǒng)。這是 ps 實(shí)用程序從其獲取信息的地方。從 ps 命令的輸出中,您確實(shí)可以看到該 shell 擁有一個(gè)新的 PID 命名空間(PID 為 1),并且由于 ps 是由具有新 PID 命名空間的 shell 啟動(dòng)的,因此它繼承了該 Shell 并獲得 PID 為 2。作為練習(xí),您可以弄清楚在此容器中運(yùn)行的 Shell 進(jìn)程在主機(jī)上的 PID 是什么。
命名空間的類型
了解 PID 命名空間后,讓我們嘗試了解其他命名空間以及它們的含義。命名空間手冊(cè)頁[4]討論了 8 種不同的命名空間。以下是帶有簡短說明的各種類型,以及指向相關(guān)手冊(cè)頁的鏈接:
NamespaceFlagIsolatesCgroup[5]CLONE_NEWCGROUPCgroup root directoryIPC[6]CLONE_NEWIPCSystem V IPC, POSIX message queuesNetwork[7]CLONE_NEWNETNetwork devices,stacks, ports, etc.Mount[8]CLONE_NEWNSMount pointsPID[9]CLONE_NEWPIDProcess IDsTime[10]CLONE_NEWTIMEBoot and monotonic clocksUser[11]CLONE_NEWUSERUser and group IDsUTS[12]CLONE_NEWUTSHostname and NIS domain name
您可以想象使用這些命名空間為新的或現(xiàn)有的流程做什么。當(dāng)它們?cè)谕慌_(tái)計(jì)算機(jī)上運(yùn)行時(shí),您幾乎可以將它們隔離在一個(gè)虛擬機(jī)上運(yùn)行。您可以將多個(gè)進(jìn)程隔離在各自的命名空間中,并在同一主機(jī)內(nèi)核上運(yùn)行。這比運(yùn)行多個(gè)虛擬機(jī)要有效得多。
創(chuàng)建新的命名空間或加入現(xiàn)有的命名空間
默認(rèn)情況下,當(dāng)您使用 fork() 創(chuàng)建進(jìn)程時(shí),子進(jìn)程將繼承調(diào)用 fork() 的進(jìn)程的命名空間。如果您希望創(chuàng)建的新進(jìn)程成為新命名空間的一部分,該怎么辦?但 fork() 沒有參數(shù),不允許我們?cè)趧?chuàng)建子進(jìn)程之前對(duì)其進(jìn)行控制。然而,您可以使用 clone() 系統(tǒng)調(diào)用來施加這種控制,從而可以非常精細(xì)地控制它創(chuàng)建的新進(jìn)程。
有關(guān) clone() 的說明
在 Linux 下,雖然有不同的系統(tǒng)調(diào)用,例如 fork(),vfork() 和 clone() 來創(chuàng)建新進(jìn)程。但是在內(nèi)部,內(nèi)核中的 fork() 和 vfork() 只是使用不同的參數(shù)調(diào)用 clone()。圍繞內(nèi)核源代碼(為了更好的說明,我進(jìn)行了一些編輯)非常容易理解。在文件kernel/fork.c[13] 中,您可以看到以下內(nèi)容:
- SYSCALL_DEFINE0(fork)
- {
- struct kernel_clone_args args = {
- .exit_signal = SIGCHLD,
- };
- return _do_fork(&args);
- }
- SYSCALL_DEFINE0(vfork)
- {
- struct kernel_clone_args args = {
- .flags = CLONE_VFORK | CLONE_VM,
- .exit_signal = SIGCHLD,
- };
- return _do_fork(&args);
- }
- SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
- int __user *, parent_tidptr,
- int __user *, child_tidptr,
- unsigned long, tls)
- {
- struct kernel_clone_args args = {
- .flags = (lower_32_bits(clone_flags) & ~CSIGNAL),
- .pidfd = parent_tidptr,
- .child_tid = child_tidptr,
- .parent_tid = parent_tidptr,
- .exit_signal = (lower_32_bits(clone_flags) & CSIGNAL),
- .stack = newsp,
- .tls = tls,
- };
- if (!legacy_clone_args_valid(&args))
- return -EINVAL;
- return _do_fork(&args);
- }
如您所見,這三個(gè)系統(tǒng)調(diào)用僅使用不同的參數(shù)調(diào)用 _do_fork()。_do_fork() 實(shí)現(xiàn)創(chuàng)建新進(jìn)程的邏輯。
使用 clone() 創(chuàng)建具有新命名空間的進(jìn)程
Gocker 通過 Go 的 “exec” 包使用 clone() 系統(tǒng)調(diào)用執(zhí)行以下操作。在處理與運(yùn)行容器有關(guān)的內(nèi)容的 run.go[14] 中,您可以看到以下內(nèi)容:
- cmd = exec.Command("/proc/self/exe", args...)
- cmd.Stdin = os.Stdin
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- cmd.SysProcAttr = &syscall.SysProcAttr{
- Cloneflags: syscall.CLONE_NEWPID |
- syscall.CLONE_NEWNS |
- syscall.CLONE_NEWUTS |
- syscall.CLONE_NEWIPC,
- }
- doOrDie(cmd.Run())
在 syscall.SysProcAttr 中,我們可以傳入 Cloneflags,然后將其傳遞給對(duì) clone() 系統(tǒng)調(diào)用。細(xì)心的讀者會(huì)注意到,我們不在這里設(shè)置單獨(dú)的網(wǎng)絡(luò)命名空間。在 Gocker 中,我們?cè)O(shè)置了一個(gè)虛擬以太網(wǎng)接口,將其添加到新的網(wǎng)絡(luò)命名空間,并使用另一個(gè) Linux 系統(tǒng)調(diào)用使容器加入該命名空間。我們將在后面討論。
使用 unshare() 創(chuàng)建和加入新的命名空間
如果要為現(xiàn)有進(jìn)程創(chuàng)建新的命名空間,則不必使用 clone() 創(chuàng)建新的子進(jìn)程,Linux 提供了 unshare()[15] 系統(tǒng)調(diào)用。
加入其他進(jìn)程所屬的命名空間
為了加入文件引用的命名空間或加入其他進(jìn)程所屬的命名空間,Linux 提供了setns()[16] 系統(tǒng)調(diào)用。我們將很快看到,這非常有用。
Gocker 如何創(chuàng)建容器
由于 Gocker 的主要目的是幫助理解 Linux 容器,因此保留了一些來自 Gocker 的日志消息。從這個(gè)意義上講,它比運(yùn)行 Docker 更為冗長。讓我們看一下日志,以指導(dǎo)我們執(zhí)行程序。然后,我們可以進(jìn)行深入分析,看看實(shí)際情況如何:
- ➜ sudo ./gocker run alpine /bin/sh
- 2020/06/13 12:37:53 Cmd args: [./gocker run alpine /bin/sh]
- 2020/06/13 12:37:53 New container ID: 33c20f9ee600
- 2020/06/13 12:37:53 Image already exists. Not downloading.
- 2020/06/13 12:37:53 Image to overlay mount: a24bb4013296
- 2020/06/13 12:37:53 Cmd args: [/proc/self/exe setup-netns 33c20f9ee600]
- 2020/06/13 12:37:53 Cmd args: [/proc/self/exe setup-veth 33c20f9ee600]
- 2020/06/13 12:37:53 Cmd args: [/proc/self/exe child-mode --img=a24bb4013296 33c20f9ee600 /bin/sh]
- / #
在這里,我們要求 Gocker 從 Alpine Linux 鏡像運(yùn)行 shell。稍后我們將了解如何管理鏡像(Image)?,F(xiàn)在,請(qǐng)注意以 “ Cmd args:” 開頭的日志行。此行表示產(chǎn)生了一個(gè)新進(jìn)程。第一行日志向我們顯示了由于運(yùn)行 Gocker 命令而使 shell 程序啟動(dòng)的過程。但是,到最后,我們看到了另外三個(gè)進(jìn)程。最后一個(gè)是帶有參數(shù) “child-mode” 的 /bin/sh,我們?cè)?Alpine Linux 鏡像中使用它。在此之前,我們看到其他兩個(gè)進(jìn)程分別帶有參數(shù) “setup-netns” 和 “setup-veth”。這些命令設(shè)置了一個(gè)新的網(wǎng)絡(luò)命名空間,并設(shè)置了一個(gè)虛擬以太網(wǎng)設(shè)備對(duì)的容器端,使容器分別與外界通信。
由于各種原因,Go 語言不直接支持 fork() 系統(tǒng)調(diào)用。我們通過創(chuàng)建一個(gè)新進(jìn)程來解決此限制,但是要在其中再次執(zhí)行當(dāng)前程序。/proc/self/exe 指向當(dāng)前正在運(yùn)行的可執(zhí)行文件的路徑。我們根據(jù)命令行傳遞不同的命令行參數(shù)來調(diào)用適當(dāng)?shù)暮瘮?shù)(當(dāng)在子進(jìn)程中 fork() 返回時(shí)將調(diào)用該函數(shù))。
源代碼的組織
Gocker 源代碼通過命令(如參數(shù))組織在文件中。例如,主要服務(wù)于 gocker run 命令行參數(shù)的函數(shù)位于 run.go 文件中。類似地,gocker exec 主要需要的功能在 exec.go 文件中。這并不意味著這些文件是獨(dú)立的。它們從其他文件中自由調(diào)用函數(shù)。還有一些文件可以實(shí)現(xiàn)通用功能,例如 cgroups.go 和 utils.go。
運(yùn)行容器
在 main.go[17] 中,您可以看到是否運(yùn)行了 Gocker 命令,我們檢查以確保 gocker0 橋接器已啟動(dòng)并正在運(yùn)行。否則,我們通過調(diào)用完成工作的 setupGockerBridge() 來啟動(dòng)它。最后,我們調(diào)用函數(shù) initContainer(),該函數(shù)在 run.go 中實(shí)現(xiàn)。讓我們仔細(xì)看看該函數(shù):
- func initContainer(mem int, swap int, pids int, cpus float64,
- src string, args []string) {
- containerID := createContainerID()
- log.Printf("New container ID: %s\n", containerID)
- imageShaHex := downloadImageIfRequired(src)
- log.Printf("Image to overlay mount: %s\n", imageShaHex)
- createContainerDirectories(containerID)
- mountOverlayFileSystem(containerID, imageShaHex)
- if err := setupVirtualEthOnHost(containerID); err != nil {
- log.Fatalf("Unable to setup Veth0 on host: %v", err)
- }
- prepareAndExecuteContainer(mem, swap, pids, cpus, containerID,
- imageShaHex, args)
- log.Printf("Container done.\n")
- unmountNetworkNamespace(containerID)
- unmountContainerFs(containerID)
- removeCGroups(containerID)
- os.RemoveAll(getGockerContainersPath() + "/" + containerID)
- }
首先,我們通過調(diào)用 createContainerID() 創(chuàng)建唯一的容器 ID。然后,我們調(diào)用 downloadImageIfRequired(),以便可以從Docker Hub 下載容器鏡像(如果本地尚不可用)。Gocker 使用 /var/run/gocker/containers 中的子目錄來掛載容器根文件系統(tǒng)。createContainerDirectories() 會(huì)解決這個(gè)問題。mountOverlayFileSystem() 知道如何處理多層 Docker 鏡像,并在 /var/run/gocker/containers/<container-id>/fs/mnt 上為可用鏡像安裝合并的文件系統(tǒng)。盡管這看起來令人生畏,但如果您閱讀源代碼,這并不難理解。覆蓋(Overlay)文件系統(tǒng)允許您創(chuàng)建一個(gè)堆疊的文件系統(tǒng),其中較低的層(在這種情況下是 Docker 根文件系統(tǒng))是只讀的,而任何更改都將保存到 “upperdir”,而無需更改較低層中的任何文件。這允許許多容器共享一個(gè) Docker 鏡像。當(dāng)我們?cè)谔摂M機(jī)上下文中說“鏡像”時(shí),它通常是指磁盤鏡像。但是在這里,它只是一個(gè)目錄或一組目錄(奇特的名字:layers),帶有構(gòu)成 Docker “鏡像”根文件系統(tǒng)的文件,可以使用 Overlay 文件系統(tǒng)掛載該文件來創(chuàng)建根文件系統(tǒng)一個(gè)新的容器。
接下來,我們創(chuàng)建一個(gè)虛擬的以太網(wǎng)配對(duì)設(shè)備,它非常類似于調(diào)用 setupVirtualEthOnHost() 的管道。它們采用名稱 veth0_ <container-id> 和 veth1_ <container-id> 的形式。我們將一對(duì)中的 veth0 部分連接到主機(jī)上的網(wǎng)橋 gocker0。稍后,我們將在容器內(nèi)部使用該對(duì)的 veth1 部分。它們就像管道一樣,是從具有自己的網(wǎng)絡(luò)命名空間的容器內(nèi)部進(jìn)行網(wǎng)絡(luò)通信的秘鑰。隨后,我們將介紹如何在容器內(nèi)設(shè)置 veth1 部件。
最后,調(diào)用 prepareAndExecuteContainer(),它實(shí)際上在容器中執(zhí)行該過程。當(dāng)此函數(shù)返回時(shí),容器已完成執(zhí)行。最后,我們進(jìn)行一些清理并退出。讓我們看看 prepareAndExecuteContainer() 的作用。它實(shí)際上創(chuàng)建了我們看到的日志的 3 個(gè)進(jìn)程,并使用 setup-netns,setup-veth 和 child-mode 參數(shù)運(yùn)行相同的 gocker 二進(jìn)制文件。
設(shè)置可在容器內(nèi)工作的網(wǎng)絡(luò)
設(shè)置新的網(wǎng)絡(luò)命名空間非常容易。您只需將 CLONE_NEWNET 包含在傳遞給 clone() 系統(tǒng)調(diào)用的標(biāo)志位掩碼中即可。棘手的是確保容器內(nèi)部可以具有網(wǎng)絡(luò)接口,通過該接口可以與外部進(jìn)行通信。在 Gocker 中,我們創(chuàng)建的第一個(gè)新命名空間是網(wǎng)絡(luò)的命名空間。當(dāng)使用 setup-ns 和 setup-veth 參數(shù)調(diào)用 gocker 時(shí)會(huì)發(fā)生這種情況。首先,我們?cè)O(shè)置一個(gè)新的網(wǎng)絡(luò)命名空間。setns() 系統(tǒng)調(diào)用可以將調(diào)用進(jìn)程的命名空間設(shè)置為由文件描述符所引用的命名空間,該文件描述符指向 /proc/<pid>/ns 中的文件,該文件列出了進(jìn)程所屬的所有命名空間。讓我們看一下 setupNewNetworkNamespace() 函數(shù),該函數(shù)是通過使用 setup-netns 作為參數(shù)調(diào)用 gocker 而被調(diào)用的。(譯注:即上文提到的 Cmd args: [/proc/self/exe setup-netns 33c20f9ee600] )
- func setupNewNetworkNamespace(containerID string) {
- _ = createDirsIfDontExist([]string{getGockerNetNsPath()})
- nsMount := getGockerNetNsPath() + "/" + containerID
- if _, err := syscall.Open(nsMount,
- syscall.O_RDONLY|syscall.O_CREAT|syscall.O_EXCL,
- 0644); err != nil {
- log.Fatalf("Unable to open bind mount file: :%v\n", err)
- }
- fd, err := syscall.Open("/proc/self/ns/net", syscall.O_RDONLY, 0)
- defer syscall.Close(fd)
- if err != nil {
- log.Fatalf("Unable to open: %v\n", err)
- }
- if err := syscall.Unshare(syscall.CLONE_NEWNET); err != nil {
- log.Fatalf("Unshare system call failed: %v\n", err)
- }
- if err := syscall.Mount("/proc/self/ns/net", nsMount,
- "bind", syscall.MS_BIND, ""); err != nil {
- log.Fatalf("Mount system call failed: %v\n", err)
- }
- if err := unix.Setns(fd, syscall.CLONE_NEWNET); err != nil {
- log.Fatalf("Setns system call failed: %v\n", err)
- }
- }
每當(dāng) Linux 內(nèi)核中的最后一個(gè)進(jìn)程終止時(shí),它都會(huì)自動(dòng)刪除該命名空間。但是,有一種技術(shù)可以通過綁定來保留命名空間,即使其中沒有任何進(jìn)程。在 setupNewNetworkNamespace() 函數(shù)中,我們使用此技術(shù)。我們首先打開進(jìn)程的網(wǎng)絡(luò)命名空間文件,該文件位于 /proc/self/ns/net 中。然后,我們使用 CLONE_NEWNET 參數(shù)調(diào)用 unshare() 系統(tǒng)調(diào)用。這會(huì)將與其所屬的命名空間解除關(guān)聯(lián),并創(chuàng)建一個(gè)新的新網(wǎng)絡(luò)命名空間,同時(shí)將其設(shè)置為該進(jìn)程的網(wǎng)絡(luò)命名空間。然后,我們將此進(jìn)程的網(wǎng)絡(luò)命名空間專用文件的綁定到一個(gè)已知的文件名,即 /var/run/gocker/net-ns/<container-id>。該文件可隨時(shí)用于引用該網(wǎng)絡(luò)命名空間。現(xiàn)在,我們可以退出此進(jìn)程,但是由于此進(jìn)程的新網(wǎng)絡(luò)命名空間已綁定到新文件上,因此內(nèi)核將保留此命名空間。
接下來,使用 setup-veth 參數(shù)調(diào)用 gocker。這將調(diào)用函數(shù) setupContainerNetworkInterfaceStep1() 和 setupContainerNetworkInterfaceStep2()。在第一個(gè)函數(shù)中,我們查找 veth1_<container-id> 接口,并將其命名空間設(shè)置為在上一步中創(chuàng)建的新網(wǎng)絡(luò)命名空間。原本該接口將在主機(jī)上不可見。但問題是:由于它與 veth0_<container-id> 接口配對(duì),該接口在主機(jī)上仍然可見,因此加入此網(wǎng)絡(luò)命名空間的任何進(jìn)程都可以與主機(jī)進(jìn)行通信。第二個(gè)函數(shù)將 IP 地址添加到網(wǎng)絡(luò)接口,并將 gocker0 網(wǎng)橋設(shè)置為其默認(rèn)網(wǎng)關(guān)設(shè)備。
現(xiàn)在,主機(jī)上有一個(gè)網(wǎng)絡(luò)接口,而新的網(wǎng)絡(luò)命名空間上有一個(gè)可以相互通信的接口。而且由于該網(wǎng)絡(luò)命名空間可以由文件引用,因此我們可以隨時(shí)使用 setns() 系統(tǒng)調(diào)用打開該文件并加入該網(wǎng)絡(luò)命名空間。這正是我們要做的。
此后,prepareAndExecuteContainer() 調(diào)用將設(shè)置一個(gè)新進(jìn)程,該進(jìn)程使用 child-mode 參數(shù)運(yùn)行 gocker。這是最后一個(gè)進(jìn)程,將產(chǎn)生我們要在容器中運(yùn)行的命令。讓我們看一下運(yùn)行 child-mode 的進(jìn)程的新命名空間。我們之前已經(jīng)看過了這段代碼:
- cmd = exec.Command("/proc/self/exe", args...)
- cmd.Stdin = os.Stdin
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- cmd.SysProcAttr = &syscall.SysProcAttr{
- Cloneflags: syscall.CLONE_NEWPID |
- syscall.CLONE_NEWNS |
- syscall.CLONE_NEWUTS |
- syscall.CLONE_NEWIPC,
- }
- doOrDie(cmd.Run())
在這里,我們?cè)O(shè)置新的 PID,mount,UTS 和 IPC 命名空間。請(qǐng)記住,我們有一個(gè)通過文件可以引用的新網(wǎng)絡(luò)命名空間。我們只需要加入它。我們將很快完成。child-mode 進(jìn)程將調(diào)用函數(shù) execContainerCommand()。這里代碼:
- func execContainerCommand(mem int, swap int, pids int, cpus float64,
- containerID string, imageShaHex string, args []string) {
- mntPath := getContainerFSHome(containerID) + "/mnt"
- cmd := exec.Command(args[0], args[1:]...)
- cmd.Stdin = os.Stdin
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- imgConfig := parseContainerConfig(imageShaHex)
- doOrDieWithMsg(syscall.Sethostname([]byte(containerID)), "Unable to set hostname")
- doOrDieWithMsg(joinContainerNetworkNamespace(containerID), "Unable to join container network namespace")
- createCGroups(containerID, true)
- configureCGroups(containerID, mem, swap, pids, cpus)
- doOrDieWithMsg(copyNameserverConfig(containerID), "Unable to copy resolve.conf")
- doOrDieWithMsg(syscall.Chroot(mntPath), "Unable to chroot")
- doOrDieWithMsg(os.Chdir("/"), "Unable to change directory")
- createDirsIfDontExist([]string{"/proc", "/sys"})
- doOrDieWithMsg(syscall.Mount("proc", "/proc", "proc", 0, ""), "Unable to mount proc")
- doOrDieWithMsg(syscall.Mount("tmpfs", "/tmp", "tmpfs", 0, ""), "Unable to mount tmpfs")
- doOrDieWithMsg(syscall.Mount("tmpfs", "/dev", "tmpfs", 0, ""), "Unable to mount tmpfs on /dev")
- createDirsIfDontExist([]string{"/dev/pts"})
- doOrDieWithMsg(syscall.Mount("devpts", "/dev/pts", "devpts", 0, ""), "Unable to mount devpts")
- doOrDieWithMsg(syscall.Mount("sysfs", "/sys", "sysfs", 0, ""), "Unable to mount sysfs")
- setupLocalInterface()
- cmd.Env = imgConfig.Config.Env
- cmd.Run()
- doOrDie(syscall.Unmount("/dev/pts", 0))
- doOrDie(syscall.Unmount("/dev", 0))
- doOrDie(syscall.Unmount("/sys", 0))
- doOrDie(syscall.Unmount("/proc", 0))
- doOrDie(syscall.Unmount("/tmp", 0))
- }
在這里,我們將容器的主機(jī)名設(shè)置為容器 ID,加入之前創(chuàng)建的新網(wǎng)絡(luò)命名空間,創(chuàng)建允許我們控制 CPU,PID 和 RAM 使用率的 Linux 控制組,并加入這些 Cgroup,然后復(fù)制主機(jī)的 DNS 解析文件進(jìn)入容器的文件系統(tǒng),對(duì)已安裝的 Overlay 文件系統(tǒng)執(zhí)行 chroot(),掛載所需的文件系統(tǒng),以使容器能夠平穩(wěn)運(yùn)行,設(shè)置本地網(wǎng)絡(luò)接口,根據(jù)容器鏡像的建議設(shè)置環(huán)境變量并最終運(yùn)行用戶希望我們運(yùn)行的命令?,F(xiàn)在,此命令將在一組新的命名空間中運(yùn)行,從而使它幾乎完全與主機(jī)隔離。
限制容器資源
除了使用命名空間實(shí)現(xiàn)隔離之外,容器的另一個(gè)重要特征:限制容器可以消耗的資源量的能力。Linux 下的 Cgroup 很簡單,通過它我們能夠做到這一點(diǎn)。雖然命名空間是通過諸如 unshare(),setns() 和 clone() 之類的系統(tǒng)調(diào)用來實(shí)現(xiàn)的,但 Cgroup 是通過創(chuàng)建目錄并將文件寫入虛擬文件系統(tǒng)(位于 /sys/fs/cgroup 下)來管理的。在 Cgroups 虛擬文件系統(tǒng)層次結(jié)構(gòu)中,每個(gè)容器創(chuàng)建了 3 個(gè)目錄:
- /sys/fs/cgroup/pids/gocker/<container-id>
- /sys/fs/cgroup/cpu/gocker/<container-id>
- /sys/fs/cgroup/mem/gocker/<container-id>
對(duì)于每個(gè)創(chuàng)建的目錄,內(nèi)核都會(huì)添加各種文件,從而可以自動(dòng)配置該 cgroup。
這是我們配置容器的方式:
- 當(dāng)容器啟動(dòng)時(shí),我們將創(chuàng)建 3 個(gè)目錄,每個(gè)目錄用于我們關(guān)心的三個(gè) cgroup:CPU,PID 和 Memory。
- 然后,我們通過寫入該目錄內(nèi)的文件來設(shè)置 cgroup 的限制。例如,要設(shè)置容器中允許的最大 PID 數(shù)量,我們將該最大數(shù)量寫入 /sys/fs/cgroup/pids/gocker/<cont-id>/pids.max。這將配置此 Cgroup。
- 現(xiàn)在,我們可以通過將其 PID 添加到 /sys/fs/cgroup/pids/gocker/<cont-id>/cgroup.procs 中來添加需要由該 Cgroup 控制的進(jìn)程。
這就是全部。一旦添加了要由 Cgroup 控制的進(jìn)程,內(nèi)核將自動(dòng)將所有進(jìn)程后代的 PID 添加到適當(dāng)?shù)?Cgroup 的 cgroup.procs 文件中。我們?cè)谌萜鳎ㄌ砑拥搅松厦娴?3 個(gè) Cgroups 中)中啟動(dòng)一個(gè)進(jìn)程,并且該進(jìn)程是容器啟動(dòng)其他進(jìn)程的祖先進(jìn)程,所以所有限制也都會(huì)被繼承。
限制 CPU
讓我們嘗試將容器可以使用的 CPU 限制為主機(jī)系統(tǒng) 1 個(gè) CPU 內(nèi)核的 20%。讓我們開始一個(gè)受此限制的容器,安裝 Python 并運(yùn)行一個(gè) while 循環(huán)。我們通過向 gocker 傳遞 --cpu = 0.2 標(biāo)志來實(shí)現(xiàn):
- sudo ./gocker run --cpus=0.2 alpine /bin/sh
- 2020/06/13 18:14:09 Cmd args: [./gocker run --cpus=0.2 alpine /bin/sh]
- 2020/06/13 18:14:09 New container ID: d87d44b4d823
- 2020/06/13 18:14:09 Image already exists. Not downloading.
- 2020/06/13 18:14:09 Image to overlay mount: a24bb4013296
- 2020/06/13 18:14:09 Cmd args: [/proc/self/exe setup-netns d87d44b4d823]
- 2020/06/13 18:14:09 Cmd args: [/proc/self/exe setup-veth d87d44b4d823]
- 2020/06/13 18:14:09 Cmd args: [/proc/self/exe child-mode --cpus=0.2 --img=a24bb4013296 d87d44b4d823 /bin/sh]
- / # apk add python3
- fetch http://dl-cdn.alpinelinux.org/alpine/v3.12/main/x86_64/APKINDEX.tar.gz
- fetch http://dl-cdn.alpinelinux.org/alpine/v3.12/community/x86_64/APKINDEX.tar.gz
- (1/10) Installing libbz2 (1.0.8-r1)
- (2/10) Installing expat (2.2.9-r1)
- (3/10) Installing libffi (3.3-r2)
- (4/10) Installing gdbm (1.13-r1)
- (5/10) Installing xz-libs (5.2.5-r0)
- (6/10) Installing ncurses-terminfo-base (6.2_p20200523-r0)
- (7/10) Installing ncurses-libs (6.2_p20200523-r0)
- (8/10) Installing readline (8.0.4-r0)
- (9/10) Installing sqlite-libs (3.32.1-r0)
- (10/10) Installing python3 (3.8.3-r0)
- Executing busybox-1.31.1-r16.trigger
- OK: 53 MiB in 24 packages
- / # python3
- Python 3.8.3 (default, May 15 2020, 01:53:50)
- [GCC 9.3.0] on linux
- Type "help", "copyright", "credits" or "license" for more information.
- >>> while True:
- ... pass
- ...
在宿主機(jī)器運(yùn)行 top,查看在容器內(nèi)部運(yùn)行的 python 進(jìn)程占用了多少 CPU。

Cgroup將CPU限制為20%
從另一個(gè)終端,讓我們使用 gocker exec 命令在同一容器內(nèi)啟動(dòng)另一個(gè) python 進(jìn)程,并在其中運(yùn)行 while 循環(huán)。
- ➜ sudo ./gocker ps
- 2020/06/13 18:21:10 Cmd args: [./gocker ps]
- CONTAINER ID IMAGE COMMAND
- d87d44b4d823 alpine:latest /usr/bin/python3.8
- ➜ sudo ./gocker exec d87d44b4d823 /bin/sh
- 2020/06/13 18:21:24 Cmd args: [./gocker exec d87d44b4d823 /bin/sh]
- / # python3
- Python 3.8.3 (default, May 15 2020, 01:53:50)
- [GCC 9.3.0] on linux
- Type "help", "copyright", "credits" or "license" for more information.
- >>> while True:
- ... pass
- ...
現(xiàn)在有 2 個(gè) python 進(jìn)程,在不受 Cgroup 限制的情況下,不出意外的話,將消耗 2 個(gè)完整的 CPU 內(nèi)核。現(xiàn)在,讓我們看一下主機(jī)上 top 命令的輸出:

Cgroup通過2個(gè)進(jìn)程將CPU限制為20%
從主機(jī) top 命令的輸出中可以看到,兩個(gè) python 進(jìn)程(都運(yùn)行循環(huán))都限制為每個(gè) CPU 占用 10%。容器的 20% CPU 配額由調(diào)度程序公平分配給容器中的 2 個(gè)進(jìn)程。請(qǐng)注意,也可以指定一個(gè)以上 CPU 內(nèi)核的余量。例如,如果要允許一個(gè)容器最大使用 2 個(gè)半核心,請(qǐng)?jiān)跇?biāo)志中將其指定為 --cpu = 2.5。
限制 PID
在新的 PID 命名空間中運(yùn)行 Shell 程序的容器似乎消耗 7 個(gè) PID。這意味著,如果您啟動(dòng)一個(gè) PID 上限為 7 的新容器,則將無法在 Shell 上啟動(dòng)其他進(jìn)程。讓我們對(duì)此進(jìn)行測試。(盡管容器中只有 2 個(gè)處于運(yùn)行狀態(tài)的進(jìn)程,但我不確定為什么要消耗 7 個(gè) PID。這需要進(jìn)一步研究。)
- ➜ sudo ./gocker run --pids=7 alpine /bin/sh
- [sudo] password for shuveb:
- 2020/06/13 18:28:00 Cmd args: [./gocker run --pids=7 alpine /bin/sh]
- 2020/06/13 18:28:00 New container ID: 920a577165ef
- 2020/06/13 18:28:00 Image already exists. Not downloading.
- 2020/06/13 18:28:00 Image to overlay mount: a24bb4013296
- 2020/06/13 18:28:00 Cmd args: [/proc/self/exe setup-netns 920a577165ef]
- 2020/06/13 18:28:00 Cmd args: [/proc/self/exe setup-veth 920a577165ef]
- 2020/06/13 18:28:00 Cmd args: [/proc/self/exe child-mode --pids=7 --img=a24bb4013296 920a577165ef /bin/sh]
- / # ls -l
- /bin/sh: can't fork: Resource temporarily unavailable
- / #
限制 RAM
開啟一個(gè)新容器,將最大允許內(nèi)存設(shè)置為 128M?,F(xiàn)在,我們將在其中安裝 python,并分配大量 RAM。這應(yīng)該會(huì)觸發(fā)內(nèi)核的內(nèi)存不足(OOM),使其殺死我們的 python 進(jìn)程。讓我們看看實(shí)際情況:
- ➜ sudo ./gocker run --mem=128 --swap=0 alpine /bin/sh
- 2020/06/13 18:30:30 Cmd args: [./gocker run --mem=128 --swap=0 alpine /bin/sh]
- 2020/06/13 18:30:30 New container ID: b22bbc6ee478
- 2020/06/13 18:30:30 Image already exists. Not downloading.
- 2020/06/13 18:30:30 Image to overlay mount: a24bb4013296
- 2020/06/13 18:30:30 Cmd args: [/proc/self/exe setup-netns b22bbc6ee478]
- 2020/06/13 18:30:30 Cmd args: [/proc/self/exe setup-veth b22bbc6ee478]
- 2020/06/13 18:30:30 Cmd args: [/proc/self/exe child-mode --mem=128 --swap=0 --img=a24bb4013296 b22bbc6ee478 /bin/sh]
- / # apk add python3
- fetch http://dl-cdn.alpinelinux.org/alpine/v3.12/main/x86_64/APKINDEX.tar.gz
- fetch http://dl-cdn.alpinelinux.org/alpine/v3.12/community/x86_64/APKINDEX.tar.gz
- (1/10) Installing libbz2 (1.0.8-r1)
- (2/10) Installing expat (2.2.9-r1)
- (3/10) Installing libffi (3.3-r2)
- (4/10) Installing gdbm (1.13-r1)
- (5/10) Installing xz-libs (5.2.5-r0)
- (6/10) Installing ncurses-terminfo-base (6.2_p20200523-r0)
- (7/10) Installing ncurses-libs (6.2_p20200523-r0)
- (8/10) Installing readline (8.0.4-r0)
- (9/10) Installing sqlite-libs (3.32.1-r0)
- (10/10) Installing python3 (3.8.3-r0)
- Executing busybox-1.31.1-r16.trigger
- OK: 53 MiB in 24 packages
- / # python3
- Python 3.8.3 (default, May 15 2020, 01:53:50)
- [GCC 9.3.0] on linux
- Type "help", "copyright", "credits" or "license" for more information.
- >>> a1 = bytearray(100 * 1024 * 1024)
- Killed
- / #
需要注意的一件事是,我們使用 --swap = 0 將分配給該容器的 swap 設(shè)置為零。否則,Cgroup 雖然限制 RAM 使用,但它將允許容器使用無限的交換空間。當(dāng) swap 設(shè)置為零時(shí),容器將被完全限制為所允許的 RAM 值。