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

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

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

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