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

Gocker 的功能
Gocker 可以模擬 Docker 的內(nèi)核,讓你管理 Docker 鏡像(從 Docker Hub 獲取),運行容器,列出正在運行的容器或在已經(jīng)運行的容器中運行進程:
- 在容器中運行進程
- gocker run <--cpus=cpus-max> <--mem=mem-max> <--pids=pids-max> <image[:tag]> </path/to/command>
- 列出正在運行的容器
- gocker ps
- 在運行的容器中執(zhí)行進程
- gocker exec </path/to/command>
- 列出本地可用的鏡像
- gocker images
- 刪除本地可用的鏡像
- gocker rmi
其他功能
- Gocker 使用 Overlay 文件系統(tǒng)快速創(chuàng)建容器,而無需復(fù)制整個文件系統(tǒng),同時還可以在多個容器實例之間共享同一容器鏡像。
- Gocker 容器擁有自己的網(wǎng)絡(luò)命名空間,并且能夠訪問 Internet。請參閱下面的限制。
- 您可以控制系統(tǒng)資源,例如 CPU 百分比,RAM 數(shù)量和進程數(shù)。Gocker 通過利用 cgroups 實現(xiàn)了這一目標。
Gocker 容器隔離性
用 Gocker 創(chuàng)建的容器擁有自己的以下命名空間(請參見 run.go 和 network.go):
- 文件系統(tǒng) File system (via chroot)
- PID
- IPC
- UTS (hostname)
- Mount
- Network
在創(chuàng)建用于限制以下內(nèi)容的 cgroup 時,除非你在 gocker run 命令中指定了 --mem,--cpus 或 --pids 選項,否則容器將使用無限的資源。這些標志分別限制了容器可以使用的最大 RAM,CPU 內(nèi)核和 PID。
- CPU 核心數(shù)
- RAM
- PID 數(shù)量(限制進程)
命名空間(Namespaces)基礎(chǔ)
所有 Linux 計算機在啟動時都是 “default” 命名空間的一部分。在計算機上創(chuàng)建的進程也繼承默認命名空間。換句話說,因為所有對象也都存在于默認命名空間中,進程可以看到正在運行的其他進程,網(wǎng)絡(luò)接口,掛載點,名為 IPC 的對象或權(quán)限允許的文件。當創(chuàng)建一個進程時,我們可以告訴 Linux 為我們創(chuàng)建一個新的 PID 命名空間,在這種情況下,新進程及其任何后代形成一個新的層次結(jié)構(gòu)或 PID,而新創(chuàng)建的初始進程為 PID 1,就像 Linux 機器上特殊的初始化進程一樣。假設(shè)使用新的 PID 命名空間創(chuàng)建了一個名為 “new_child” 的進程。當該進程或其后代使用諸如 getpid() 或 getppid() 之類的系統(tǒng)調(diào)用時,它們會在新命名空間中看到 PID。例如,對于這兩個系統(tǒng)調(diào)用,在新創(chuàng)建的 PID 命名空間中的 new_child 將獲得 1。而當您從默認命名空間查看 new_child 的 PID 時,當然不會為其分配 1(那是默認命名空間中的 init 了)。
Linux 操作系統(tǒng)提供了在創(chuàng)建進程時或與之關(guān)聯(lián)的正在運行的進程創(chuàng)建新命名空間的方法。所有命名空間,無論其類型如何,都被分配了內(nèi)部 ID。命名空間是一種內(nèi)核對象。一個進程只能屬于一個命名空間。例如,假設(shè)一個進程 new_child 的 PID 命名空間設(shè)置為內(nèi)部 ID 為 0x87654321 的命名空間,它不能屬于另一個 PID 命名空間。但是,可能存在其他屬于同一 PID 命名空間 0x87654321 的其他進程。同樣,new_child 的后代將自動屬于相同的 PID 命名空間。命名空間是繼承的。
你可以使用 lsns 實用程序列出計算機中的各種命名空間。即使您的計算機上沒有運行任何容器,也很可能會看到與各種命名空間相關(guān)的其他進程。這表明,命名空間并不僅僅是在容器的上下文中使用。它們可以在任何地方使用。它們提供隔離。它們是一項強大的安全功能。在現(xiàn)代 Linux 系統(tǒng)上,您會看到 init,systemd,幾個系統(tǒng)守護程序,Chrome,Slack,當然還有使用各種命名空間的 Docker 容器。讓我們看一看我機器上的 lsns 實用程序的輸出:
- 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)建命名空間,進程也將成為默認命名空間的一部分。所有命名空間的詳細信息都記錄在 /proc 文件系統(tǒng)中。您可以通過輸入 ls -l /proc/self/ns/來查看您的 Shell 進程所屬的命名空間。這是我電腦的結(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 的輸出中,我們看到容器并不是唯一使用命名空間的對象。為此,讓我們創(chuàng)建一個具有自己的 PID 命名空間的 shell 實例。我們將使用 unshare 實用程序來做到這一點。“unshare” 這個名字很明顯。還有一個同名的 Linux 系統(tǒng)調(diào)用[3],可讓您取消共享默認命名空間,從而使調(diào)用進程加入新創(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 實用程序正在派生一個新進程,調(diào)用 unshare() 系統(tǒng)調(diào)用以創(chuàng)建一個新的 PID 命名空間,然后在其中執(zhí)行 /bin/bash。我們還告訴 unshare 實用程序在新進程中掛載 proc 文件系統(tǒng)。這是 ps 實用程序從其獲取信息的地方。從 ps 命令的輸出中,您確實可以看到該 shell 擁有一個新的 PID 命名空間(PID 為 1),并且由于 ps 是由具有新 PID 命名空間的 shell 啟動的,因此它繼承了該 Shell 并獲得 PID 為 2。作為練習,您可以弄清楚在此容器中運行的 Shell 進程在主機上的 PID 是什么。
命名空間的類型
了解 PID 命名空間后,讓我們嘗試了解其他命名空間以及它們的含義。命名空間手冊頁[4]討論了 8 種不同的命名空間。以下是帶有簡短說明的各種類型,以及指向相關(guān)手冊頁的鏈接:
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)有的流程做什么。當它們在同一臺計算機上運行時,您幾乎可以將它們隔離在一個虛擬機上運行。您可以將多個進程隔離在各自的命名空間中,并在同一主機內(nèi)核上運行。這比運行多個虛擬機要有效得多。
創(chuàng)建新的命名空間或加入現(xiàn)有的命名空間
默認情況下,當您使用 fork() 創(chuàng)建進程時,子進程將繼承調(diào)用 fork() 的進程的命名空間。如果您希望創(chuàng)建的新進程成為新命名空間的一部分,該怎么辦?但 fork() 沒有參數(shù),不允許我們在創(chuàng)建子進程之前對其進行控制。然而,您可以使用 clone() 系統(tǒng)調(diào)用來施加這種控制,從而可以非常精細地控制它創(chuàng)建的新進程。
有關(guān) clone() 的說明
在 Linux 下,雖然有不同的系統(tǒng)調(diào)用,例如 fork(),vfork() 和 clone() 來創(chuàng)建新進程。但是在內(nèi)部,內(nèi)核中的 fork() 和 vfork() 只是使用不同的參數(shù)調(diào)用 clone()。圍繞內(nèi)核源代碼(為了更好的說明,我進行了一些編輯)非常容易理解。在文件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);
- }
如您所見,這三個系統(tǒng)調(diào)用僅使用不同的參數(shù)調(diào)用 _do_fork()。_do_fork() 實現(xiàn)創(chuàng)建新進程的邏輯。
使用 clone() 創(chuàng)建具有新命名空間的進程
Gocker 通過 Go 的 “exec” 包使用 clone() 系統(tǒng)調(diào)用執(zhí)行以下操作。在處理與運行容器有關(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,然后將其傳遞給對 clone() 系統(tǒng)調(diào)用。細心的讀者會注意到,我們不在這里設(shè)置單獨的網(wǎng)絡(luò)命名空間。在 Gocker 中,我們設(shè)置了一個虛擬以太網(wǎng)接口,將其添加到新的網(wǎng)絡(luò)命名空間,并使用另一個 Linux 系統(tǒng)調(diào)用使容器加入該命名空間。我們將在后面討論。
使用 unshare() 創(chuàng)建和加入新的命名空間
如果要為現(xiàn)有進程創(chuàng)建新的命名空間,則不必使用 clone() 創(chuàng)建新的子進程,Linux 提供了 unshare()[15] 系統(tǒng)調(diào)用。
加入其他進程所屬的命名空間
為了加入文件引用的命名空間或加入其他進程所屬的命名空間,Linux 提供了setns()[16] 系統(tǒng)調(diào)用。我們將很快看到,這非常有用。
Gocker 如何創(chuàng)建容器
由于 Gocker 的主要目的是幫助理解 Linux 容器,因此保留了一些來自 Gocker 的日志消息。從這個意義上講,它比運行 Docker 更為冗長。讓我們看一下日志,以指導(dǎo)我們執(zhí)行程序。然后,我們可以進行深入分析,看看實際情況如何:
- ➜ 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 鏡像運行 shell。稍后我們將了解如何管理鏡像(Image)?,F(xiàn)在,請注意以 “ Cmd args:” 開頭的日志行。此行表示產(chǎn)生了一個新進程。第一行日志向我們顯示了由于運行 Gocker 命令而使 shell 程序啟動的過程。但是,到最后,我們看到了另外三個進程。最后一個是帶有參數(shù) “child-mode” 的 /bin/sh,我們在 Alpine Linux 鏡像中使用它。在此之前,我們看到其他兩個進程分別帶有參數(shù) “setup-netns” 和 “setup-veth”。這些命令設(shè)置了一個新的網(wǎng)絡(luò)命名空間,并設(shè)置了一個虛擬以太網(wǎng)設(shè)備對的容器端,使容器分別與外界通信。
由于各種原因,Go 語言不直接支持 fork() 系統(tǒng)調(diào)用。我們通過創(chuàng)建一個新進程來解決此限制,但是要在其中再次執(zhí)行當前程序。/proc/self/exe 指向當前正在運行的可執(zhí)行文件的路徑。我們根據(jù)命令行傳遞不同的命令行參數(shù)來調(diào)用適當?shù)暮瘮?shù)(當在子進程中 fork() 返回時將調(diào)用該函數(shù))。
源代碼的組織
Gocker 源代碼通過命令(如參數(shù))組織在文件中。例如,主要服務(wù)于 gocker run 命令行參數(shù)的函數(shù)位于 run.go 文件中。類似地,gocker exec 主要需要的功能在 exec.go 文件中。這并不意味著這些文件是獨立的。它們從其他文件中自由調(diào)用函數(shù)。還有一些文件可以實現(xiàn)通用功能,例如 cgroups.go 和 utils.go。
運行容器
在 main.go[17] 中,您可以看到是否運行了 Gocker 命令,我們檢查以確保 gocker0 橋接器已啟動并正在運行。否則,我們通過調(diào)用完成工作的 setupGockerBridge() 來啟動它。最后,我們調(diào)用函數(shù) initContainer(),該函數(shù)在 run.go 中實現(xiàn)。讓我們仔細看看該函數(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() 會解決這個問題。mountOverlayFileSystem() 知道如何處理多層 Docker 鏡像,并在 /var/run/gocker/containers/<container-id>/fs/mnt 上為可用鏡像安裝合并的文件系統(tǒng)。盡管這看起來令人生畏,但如果您閱讀源代碼,這并不難理解。覆蓋(Overlay)文件系統(tǒng)允許您創(chuàng)建一個堆疊的文件系統(tǒng),其中較低的層(在這種情況下是 Docker 根文件系統(tǒng))是只讀的,而任何更改都將保存到 “upperdir”,而無需更改較低層中的任何文件。這允許許多容器共享一個 Docker 鏡像。當我們在虛擬機上下文中說“鏡像”時,它通常是指磁盤鏡像。但是在這里,它只是一個目錄或一組目錄(奇特的名字:layers),帶有構(gòu)成 Docker “鏡像”根文件系統(tǒng)的文件,可以使用 Overlay 文件系統(tǒng)掛載該文件來創(chuàng)建根文件系統(tǒng)一個新的容器。
接下來,我們創(chuàng)建一個虛擬的以太網(wǎng)配對設(shè)備,它非常類似于調(diào)用 setupVirtualEthOnHost() 的管道。它們采用名稱 veth0_ <container-id> 和 veth1_ <container-id> 的形式。我們將一對中的 veth0 部分連接到主機上的網(wǎng)橋 gocker0。稍后,我們將在容器內(nèi)部使用該對的 veth1 部分。它們就像管道一樣,是從具有自己的網(wǎng)絡(luò)命名空間的容器內(nèi)部進行網(wǎng)絡(luò)通信的秘鑰。隨后,我們將介紹如何在容器內(nèi)設(shè)置 veth1 部件。
最后,調(diào)用 prepareAndExecuteContainer(),它實際上在容器中執(zhí)行該過程。當此函數(shù)返回時,容器已完成執(zhí)行。最后,我們進行一些清理并退出。讓我們看看 prepareAndExecuteContainer() 的作用。它實際上創(chuàng)建了我們看到的日志的 3 個進程,并使用 setup-netns,setup-veth 和 child-mode 參數(shù)運行相同的 gocker 二進制文件。
設(shè)置可在容器內(nèi)工作的網(wǎng)絡(luò)
設(shè)置新的網(wǎng)絡(luò)命名空間非常容易。您只需將 CLONE_NEWNET 包含在傳遞給 clone() 系統(tǒng)調(diào)用的標志位掩碼中即可。棘手的是確保容器內(nèi)部可以具有網(wǎng)絡(luò)接口,通過該接口可以與外部進行通信。在 Gocker 中,我們創(chuàng)建的第一個新命名空間是網(wǎng)絡(luò)的命名空間。當使用 setup-ns 和 setup-veth 參數(shù)調(diào)用 gocker 時會發(fā)生這種情況。首先,我們設(shè)置一個新的網(wǎng)絡(luò)命名空間。setns() 系統(tǒng)調(diào)用可以將調(diào)用進程的命名空間設(shè)置為由文件描述符所引用的命名空間,該文件描述符指向 /proc/<pid>/ns 中的文件,該文件列出了進程所屬的所有命名空間。讓我們看一下 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)
- }
- }
每當 Linux 內(nèi)核中的最后一個進程終止時,它都會自動刪除該命名空間。但是,有一種技術(shù)可以通過綁定來保留命名空間,即使其中沒有任何進程。在 setupNewNetworkNamespace() 函數(shù)中,我們使用此技術(shù)。我們首先打開進程的網(wǎng)絡(luò)命名空間文件,該文件位于 /proc/self/ns/net 中。然后,我們使用 CLONE_NEWNET 參數(shù)調(diào)用 unshare() 系統(tǒng)調(diào)用。這會將與其所屬的命名空間解除關(guān)聯(lián),并創(chuàng)建一個新的新網(wǎng)絡(luò)命名空間,同時將其設(shè)置為該進程的網(wǎng)絡(luò)命名空間。然后,我們將此進程的網(wǎng)絡(luò)命名空間專用文件的綁定到一個已知的文件名,即 /var/run/gocker/net-ns/<container-id>。該文件可隨時用于引用該網(wǎng)絡(luò)命名空間?,F(xiàn)在,我們可以退出此進程,但是由于此進程的新網(wǎng)絡(luò)命名空間已綁定到新文件上,因此內(nèi)核將保留此命名空間。
接下來,使用 setup-veth 參數(shù)調(diào)用 gocker。這將調(diào)用函數(shù) setupContainerNetworkInterfaceStep1() 和 setupContainerNetworkInterfaceStep2()。在第一個函數(shù)中,我們查找 veth1_<container-id> 接口,并將其命名空間設(shè)置為在上一步中創(chuàng)建的新網(wǎng)絡(luò)命名空間。原本該接口將在主機上不可見。但問題是:由于它與 veth0_<container-id> 接口配對,該接口在主機上仍然可見,因此加入此網(wǎng)絡(luò)命名空間的任何進程都可以與主機進行通信。第二個函數(shù)將 IP 地址添加到網(wǎng)絡(luò)接口,并將 gocker0 網(wǎng)橋設(shè)置為其默認網(wǎng)關(guān)設(shè)備。
現(xiàn)在,主機上有一個網(wǎng)絡(luò)接口,而新的網(wǎng)絡(luò)命名空間上有一個可以相互通信的接口。而且由于該網(wǎng)絡(luò)命名空間可以由文件引用,因此我們可以隨時使用 setns() 系統(tǒng)調(diào)用打開該文件并加入該網(wǎng)絡(luò)命名空間。這正是我們要做的。
此后,prepareAndExecuteContainer() 調(diào)用將設(shè)置一個新進程,該進程使用 child-mode 參數(shù)運行 gocker。這是最后一個進程,將產(chǎn)生我們要在容器中運行的命令。讓我們看一下運行 child-mode 的進程的新命名空間。我們之前已經(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())
在這里,我們設(shè)置新的 PID,mount,UTS 和 IPC 命名空間。請記住,我們有一個通過文件可以引用的新網(wǎng)絡(luò)命名空間。我們只需要加入它。我們將很快完成。child-mode 進程將調(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))
- }
在這里,我們將容器的主機名設(shè)置為容器 ID,加入之前創(chuàng)建的新網(wǎng)絡(luò)命名空間,創(chuàng)建允許我們控制 CPU,PID 和 RAM 使用率的 Linux 控制組,并加入這些 Cgroup,然后復(fù)制主機的 DNS 解析文件進入容器的文件系統(tǒng),對已安裝的 Overlay 文件系統(tǒng)執(zhí)行 chroot(),掛載所需的文件系統(tǒng),以使容器能夠平穩(wěn)運行,設(shè)置本地網(wǎng)絡(luò)接口,根據(jù)容器鏡像的建議設(shè)置環(huán)境變量并最終運行用戶希望我們運行的命令?,F(xiàn)在,此命令將在一組新的命名空間中運行,從而使它幾乎完全與主機隔離。
限制容器資源
除了使用命名空間實現(xiàn)隔離之外,容器的另一個重要特征:限制容器可以消耗的資源量的能力。Linux 下的 Cgroup 很簡單,通過它我們能夠做到這一點。雖然命名空間是通過諸如 unshare(),setns() 和 clone() 之類的系統(tǒng)調(diào)用來實現(xiàn)的,但 Cgroup 是通過創(chuàng)建目錄并將文件寫入虛擬文件系統(tǒng)(位于 /sys/fs/cgroup 下)來管理的。在 Cgroups 虛擬文件系統(tǒng)層次結(jié)構(gòu)中,每個容器創(chuàng)建了 3 個目錄:
- /sys/fs/cgroup/pids/gocker/<container-id>
- /sys/fs/cgroup/cpu/gocker/<container-id>
- /sys/fs/cgroup/mem/gocker/<container-id>
對于每個創(chuàng)建的目錄,內(nèi)核都會添加各種文件,從而可以自動配置該 cgroup。
這是我們配置容器的方式:
- 當容器啟動時,我們將創(chuàng)建 3 個目錄,每個目錄用于我們關(guān)心的三個 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 控制的進程。
這就是全部。一旦添加了要由 Cgroup 控制的進程,內(nèi)核將自動將所有進程后代的 PID 添加到適當?shù)?Cgroup 的 cgroup.procs 文件中。我們在容器(添加到了上面的 3 個 Cgroups 中)中啟動一個進程,并且該進程是容器啟動其他進程的祖先進程,所以所有限制也都會被繼承。
限制 CPU
讓我們嘗試將容器可以使用的 CPU 限制為主機系統(tǒng) 1 個 CPU 內(nèi)核的 20%。讓我們開始一個受此限制的容器,安裝 Python 并運行一個 while 循環(huán)。我們通過向 gocker 傳遞 --cpu = 0.2 標志來實現(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
- ...
在宿主機器運行 top,查看在容器內(nèi)部運行的 python 進程占用了多少 CPU。

Cgroup將CPU限制為20%
從另一個終端,讓我們使用 gocker exec 命令在同一容器內(nèi)啟動另一個 python 進程,并在其中運行 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 個 python 進程,在不受 Cgroup 限制的情況下,不出意外的話,將消耗 2 個完整的 CPU 內(nèi)核?,F(xiàn)在,讓我們看一下主機上 top 命令的輸出:

Cgroup通過2個進程將CPU限制為20%
從主機 top 命令的輸出中可以看到,兩個 python 進程(都運行循環(huán))都限制為每個 CPU 占用 10%。容器的 20% CPU 配額由調(diào)度程序公平分配給容器中的 2 個進程。請注意,也可以指定一個以上 CPU 內(nèi)核的余量。例如,如果要允許一個容器最大使用 2 個半核心,請在標志中將其指定為 --cpu = 2.5。
限制 PID
在新的 PID 命名空間中運行 Shell 程序的容器似乎消耗 7 個 PID。這意味著,如果您啟動一個 PID 上限為 7 的新容器,則將無法在 Shell 上啟動其他進程。讓我們對此進行測試。(盡管容器中只有 2 個處于運行狀態(tài)的進程,但我不確定為什么要消耗 7 個 PID。這需要進一步研究。)
- ➜ 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
開啟一個新容器,將最大允許內(nèi)存設(shè)置為 128M?,F(xiàn)在,我們將在其中安裝 python,并分配大量 RAM。這應(yīng)該會觸發(fā)內(nèi)核的內(nèi)存不足(OOM),使其殺死我們的 python 進程。讓我們看看實際情況:
- ➜ 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 使用,但它將允許容器使用無限的交換空間。當 swap 設(shè)置為零時,容器將被完全限制為所允許的 RAM 值。