使用 Go 和 Linux Kernel 技術(shù)探究容器化原理
容器的優(yōu)勢

傳統(tǒng)模式的部署,直接將多個應(yīng)用運行在物理服務(wù)器上,如果其中一個應(yīng)用占用了大部分資源,可能會導(dǎo)致其他應(yīng)用的性能下降。
虛擬化部署時代,可以在單個物理服務(wù)器的 CPU 上運行多個虛擬機(VM),每個 VM 是一臺完整的計算機,在虛擬化硬件之上運行所有組件(包括了操作系統(tǒng))。因此,可以讓不同的應(yīng)用在 VM 之間安全地隔離運行,更好地利用物理服務(wù)器上的資源。
容器與 VM 類似,具有自己的文件系統(tǒng)、CPU、內(nèi)存、進程空間等,但與 VM 不同的是,容器之間共享操作系統(tǒng)(OS)。 所以,容器被認為是一種輕量級的操作系統(tǒng)層面的虛擬化技術(shù)。
相比于 VM ,輕量級的容器更適合云原生模式的實踐。
容器的本質(zhì)

容器是一種輕量級的操作系統(tǒng)層面的虛擬化技術(shù)。
重點是 “操作系統(tǒng)層面” ,即容器本質(zhì)上是利用操作系統(tǒng)提供的功能來實現(xiàn)虛擬化。
容器技術(shù)的代表之作 Docker ,則是一個基于 Linux 操作系統(tǒng),使用 Go 語言編寫,調(diào)用了 Linux Kernel 功能的虛擬化工具。
為了更好地理解容器的本質(zhì),我們來看看容器具體使用了哪些 Linux Kernel 技術(shù),以及在 Go 中應(yīng)該如何去調(diào)用。

1、NameSpace
NameSpace 即命名空間是 Linux Kernel 一個強大的特性,可用于進程間資源隔離。
由于容器之間共享 OS ,對于操作系統(tǒng)而言,容器的實質(zhì)就是進程,多個容器運行,對應(yīng)操作系統(tǒng)也就是運行著多個進程。
當進程運行在自己單獨的命名空間時,命名空間的資源隔離可以保證進程之間互不影響,大家都以為自己身處在獨立的一個操作系統(tǒng)里。這種進程就可以稱為容器。
回到資源隔離上,從 Kernel: 5.6 版本開始,已經(jīng)提供了 8 種 NameSpace ,這 8 種 NameSpace 可以對應(yīng)地隔離不同的資源( Docker 主要使用了前 6 種)。
| 命名空間 | 系統(tǒng)調(diào)用參數(shù) | 作用 |
|---|---|---|
| Mount (mnt) | CLONE_NEWNS | 文件目錄掛載隔離。用于隔離各個進程看到的掛載點視圖 |
| Process ID (pid) | CLONE_NEWPID | 進程 ID 隔離。使每個命名空間都有自己的初始化進程,PID 為 1,作為所有進程的父進程 |
| Network (net) | CLONE_NEWNET | 網(wǎng)絡(luò)隔離。使每個 net 命名空間有獨立的網(wǎng)絡(luò)設(shè)備,IP 地址,路由表,/proc/net 目錄等網(wǎng)絡(luò)資源 |
| Interprocess Communication (ipc) | CLONE_NEWIPC | 進程 IPC 通信隔離。讓只有相同 IPC 命名空間的進程之間才可以共享內(nèi)存、信號量、消息隊列通信 |
| UTS | CLONE_NEWUTS | 主機名或域名隔離。使其在網(wǎng)絡(luò)上可以被視作一個獨立的節(jié)點而非主機上的一個進程 |
| User ID (user) | CLONE_NEWUSER | 用戶 UID 和組 GID 隔離。例如每個命名空間都可以有自己的 root 用戶 |
| Control group (cgroup) Namespace | CLONE_NEWCGROUP | Cgroup 信息隔離。用于隱藏進程所屬的控制組的身份,使命名空間中的 cgroup 視圖始終以根形式來呈現(xiàn),保障安全 |
| Time Namespace | CLONE_NEWTIME | 系統(tǒng)時間隔離。允許不同進程查看到不同的系統(tǒng)時間 |
NameSpace 的具體描述可以查看 Linux man 手冊中的 NAMESPACES[1] 章節(jié),手冊中還描述了幾個 NameSpace API ,主要是和進程相關(guān)的系統(tǒng)調(diào)用函數(shù)。

clone()
int?clone(int?(*fn)(void?*),?void?*stack,?int?flags,?void?*arg,?...
?????????????????/*?pid_t?*parent_tid,?void?*tls,?pid_t?*child_tid?*/?);
clone() 用于創(chuàng)建新進程,通過傳入一個或多個系統(tǒng)調(diào)用參數(shù)( flags 參數(shù))可以創(chuàng)建出不同類型的 NameSpace ,并且子進程也將會成為這些 NameSpace 的成員。
setns()
int?setns(int?fd,?int?nstype);
setns() 用于將進程加入到一個現(xiàn)有的 Namespace 中。其中 fd 為文件描述符,引用 /proc/[pid]/ns/ 目錄里對應(yīng)的文件,nstype 代表 NameSpace 類型。
unshare()
int?unshare(int?flags);
unshare() 用于將進程移出原本的 NameSpace ,并加入到新創(chuàng)建的 NameSpace 中。同樣是通過傳入一個或多個系統(tǒng)調(diào)用參數(shù)( flags 參數(shù))來創(chuàng)建新的 NameSpace 。
ioctl()
int?ioctl(int?fd,?unsigned?long?request,?...);
ioctl() 用于發(fā)現(xiàn)有關(guān) NameSpace 的信息。
上面的這些系統(tǒng)調(diào)用函數(shù),我們可以直接用 C 語言調(diào)用,創(chuàng)建出各種類型的 NameSpace ,這是最直觀的做法。而對于 Go 語言,其內(nèi)部已經(jīng)幫我們封裝好了這些函數(shù)操作,可以更方便地直接使用,降低心智負擔。
先來看一個簡單的小工具(源自 Containers From Scratch ? Liz Rice ? GOTO 2018[2]):
package?main
import?(
?"os"
?"os/exec"
)
func?main()?{
?switch?os.Args[1]?{
?case?"run":
??run()
?default:
??panic("help")
?}
}
func?run()?{
?cmd?:=?exec.Command(os.Args[2],?os.Args[3:]...)
?cmd.Stdin?=?os.Stdin
?cmd.Stdout?=?os.Stdout
?cmd.Stderr?=?os.Stderr
?must(cmd.Run())
}
func?must(err?error)?{
?if?err?!=?nil?{
??panic(err)
?}
}
這個程序接收用戶命令行傳遞的參數(shù),并使用 exec.Command 運行,例如當我們執(zhí)行 go run main.go run echo hello 時,會創(chuàng)建出 main 進程, main 進程內(nèi)執(zhí)行 echo hello 命令創(chuàng)建出一個新的 echo 進程,最后隨著 echo 進程的執(zhí)行完畢,main 進程也隨之結(jié)束并退出。
[root@host?go]#?go?run?main.go?run?echo?hello
hello
[root@host?go]#

但是上面創(chuàng)建的進程太快退出了,不便于我們觀察。如果讓 main 進程啟動一個 bash 進程會怎樣呢?
為了直觀對比,我們先看看當前會話的進程信息。
[root@host?go]#?ps
??PID?TTY??????????TIME?CMD
?1115?pts/0????00:00:00?bash
?1205?pts/0????00:00:00?ps
[root@host?go]#?echo?$$
1115
[root@host?go]#
當前我們正處于 PID 1115 的 bash 會話進程中,繼續(xù)下一步操作:
[root@host?go]#?go?run?main.go?run?/bin/bash
[root@host?go]#?ps
??PID?TTY??????????TIME?CMD
?1115?pts/0????00:00:00?bash
?1207?pts/0????00:00:00?go
?1225?pts/0????00:00:00?main
?1228?pts/0????00:00:00?bash
?1240?pts/0????00:00:00?ps
[root@host?go]#?echo?$$
1228
[root@host?go]#?exit
exit
[root@host?go]#?ps
??PID?TTY??????????TIME?CMD
?1115?pts/0????00:00:00?bash
?1241?pts/0????00:00:00?ps
[root@host?go]#?echo?$$
1115
[root@host?go]#
在執(zhí)行 go run main.go run /bin/bash 后,我們的會話被切換到了 PID 1228 的 bash 進程中,而 main 進程也還在運行著(當前所處的 bash 進程是 main 進程的子進程,main 進程必須存活著,才能維持 bash 進程的運行)。當執(zhí)行 exit 退出當前所處的 bash 進程后,main 進程隨之結(jié)束,并回到原始的 PID 1115 的 bash 會話進程。
我們說過,容器的實質(zhì)是進程,你現(xiàn)在可以把 main 進程當作是 “Docker” 工具,把 main 進程啟動的 bash 進程,當作一個 “容器” 。這里的 “Docker” 創(chuàng)建并啟動了一個 “容器”。
為什么打了雙引號,是因為在這個 bash 進程中,我們可以隨意使用操作系統(tǒng)的資源,并沒有做資源隔離。
要想實現(xiàn)資源隔離,也很簡單,在 run() 函數(shù)增加 SysProcAttr 配置,先從最簡單的 UTS 隔離開始,傳入對應(yīng)的 CLONE_NEWUTS 系統(tǒng)調(diào)用參數(shù),并通過 syscall.Sethostname 設(shè)置主機名:
func?run()?{
?cmd?:=?exec.Command(os.Args[2],?os.Args[3:]...)
?cmd.Stdin?=?os.Stdin
?cmd.Stdout?=?os.Stdout
?cmd.Stderr?=?os.Stderr
?cmd.SysProcAttr?=?&syscall.SysProcAttr{
??Cloneflags:?syscall.CLONE_NEWUTS,
?}
?must(syscall.Sethostname([]byte("mycontainer")))
?must(cmd.Run())
}
這段代碼看似沒什么問題,但仔細思考一下。
syscall.Sethostname 這一行到底是哪個進程在執(zhí)行?main 進程還是 main 進程創(chuàng)建的子進程?
不用想,子進程都還沒 Run 起來呢!現(xiàn)在調(diào)用肯定是 main 進程在執(zhí)行,main 進程可沒進行資源隔離,相當于直接更改宿主機的主機名了。
子進程還沒 Run 起來,還不能更改主機名,等子進程 Run 起來后,又會進入到阻塞狀態(tài),無法再通過代碼方式更改到子進程內(nèi)的主機名。那有什么辦法呢?
看來只能把 /proc/self/exe 這個神器請出來了。
在 Linux 2.2 內(nèi)核版本及其之后,/proc/[pid]/exe 是對應(yīng) pid 進程的二進制文件的符號鏈接,包含著被執(zhí)行命令的實際路徑名。如果打開這個文件就相當于打開了對應(yīng)的二進制文件,甚至可以通過重新輸入 /proc/[pid]/exe 重新運行一個對應(yīng)于 pid 的二進制文件的進程。
對于 /proc/self ,當進程訪問這個神奇的符號鏈接時,可以解析到進程自己的 /proc/[pid] 目錄。
合起來就是,當進程訪問 /proc/self/exe 時,可以運行一個對應(yīng)進程自身的二進制文件。
這有什么用呢?繼續(xù)看下面的代碼:
package?main
import?(
?"os"
?"os/exec"
?"syscall"
)
func?main()?{
?switch?os.Args[1]?{
?case?"run":
??run()
?case?"child":
??child()
?default:
??panic("help")
?}
}
func?run()?{
?cmd?:=?exec.Command("/proc/self/exe",?append([]string{"child"},?os.Args[2:]...)...)
?cmd.Stdin?=?os.Stdin
?cmd.Stdout?=?os.Stdout
?cmd.Stderr?=?os.Stderr
?cmd.SysProcAttr?=?&syscall.SysProcAttr{
??Cloneflags:?syscall.CLONE_NEWUTS,
?}
?must(cmd.Run())
}
func?child()?{
?must(syscall.Sethostname([]byte("mycontainer")))
?cmd?:=?exec.Command(os.Args[2],?os.Args[3:]...)
?cmd.Stdin?=?os.Stdin
?cmd.Stdout?=?os.Stdout
?cmd.Stderr?=?os.Stderr
?must(cmd.Run())
}
func?must(err?error)?{
?if?err?!=?nil?{
??panic(err)
?}
}
在 run() 函數(shù)中,我們不再是直接運行用戶所傳遞的命令行參數(shù),而是運行 /proc/self/exe ,并傳入 child 參數(shù)和用戶傳遞的命令行參數(shù)。
同樣當執(zhí)行 go run main.go run echo hello 時,會創(chuàng)建出 main 進程, main 進程內(nèi)執(zhí)行 /proc/self/exe child echo hello 命令創(chuàng)建出一個新的 exe 進程,關(guān)鍵也就是這個 exe 進程,我們已經(jīng)為其配置了 CLONE_NEWUTS 系統(tǒng)調(diào)用參數(shù)進行 UTS 隔離。也就是說,exe 進程可以擁有和 main 進程不同的主機名,彼此互不干擾。
進程訪問 /proc/self/exe 代表著運行對應(yīng)進程自身的二進制文件。因此,按照 exe 進程的啟動參數(shù),會執(zhí)行 child() 函數(shù),而 child() 函數(shù)內(nèi)首先調(diào)用 syscall.Sethostname 更改了主機名(此時是 exe 進程執(zhí)行的,并不會影響到 main 進程),接著和本文最開始的 run() 函數(shù)一樣,再次使用 exec.Command 運行用戶命令行傳遞的參數(shù)。
總結(jié)一下就是, main 進程創(chuàng)建了 exe 進程(exe 進程已經(jīng)進行 UTS 隔離,exe 進程更改主機名不會影響到 main 進程), 接著 exe 進程內(nèi)執(zhí)行 echo hello 命令創(chuàng)建出一個新的 echo 進程,最后隨著 echo 進程的執(zhí)行完畢,exe 進程隨之結(jié)束,exe 進程結(jié)束后, main 進程再結(jié)束并退出。

那經(jīng)過 exe 這個中間商所創(chuàng)建出來的 echo 進程和之前由 main 進程直接創(chuàng)建的 echo 進程,兩者有何不同呢。
我們知道,創(chuàng)建 exe 進程的同時我們傳遞了 CLONE_NEWUTS 標識符創(chuàng)建了一個 UTS NameSpace ,Go 內(nèi)部幫我們封裝了系統(tǒng)調(diào)用函數(shù) clone() 的調(diào)用,我們也說過,由 clone() 函數(shù)創(chuàng)建出的進程的子進程也將會成為這些 NameSpace 的成員,所以默認情況下(創(chuàng)建新進程時無繼續(xù)指定系統(tǒng)調(diào)用參數(shù)),由 exe 進程創(chuàng)建出的 echo 進程會繼承 exe 進程的資源, echo 進程將擁有和 exe 進程相同的主機名,并且同樣和 main 進程互不干擾。
因此,借助中間商 exe 進程 ,echo 進程可以成功實現(xiàn)和宿主機( main 進程)資源隔離,擁有不同的主機名。

再次通過啟動 /bin/bash 進行驗證主機名是否已經(jīng)成功隔離:
[root@host?go]#?hostname
host
[root@host?go]#?go?run?main.go?run?/bin/bash
[root@mycontainer?go]#?hostname
mycontainer
[root@mycontainer?go]#?ps
??PID?TTY??????????TIME?CMD
?1115?pts/0????00:00:00?bash
?1250?pts/0????00:00:00?go
?1268?pts/0????00:00:00?main
?1271?pts/0????00:00:00?exe
?1275?pts/0????00:00:00?bash
?1287?pts/0????00:00:00?ps
[root@mycontainer?go]#?exit
exit
[root@host?go]#?hostname
host
[root@host?go]#
當執(zhí)行 go run main.go run /bin/bash 時,我們也可以在另一個 ssh 會話中,使用 ps afx 查看關(guān)于 PID 15243 的 bash 會話進程的層次信息:
[root@host?~]#?ps?afx
......
?1113??????????Ss?????0:00??\_?sshd:?root@pts/0
?1115?pts/0????Ss?????0:00??|???\_?-bash
?1250?pts/0????Sl?????0:00??|???????\_?go?run?main.go?run?/bin/bash
?1268?pts/0????Sl?????0:00??|???????????\_?/tmp/go-build2476789953/b001/exe/main?run?/bin/bash
?1271?pts/0????Sl?????0:00??|???????????????\_?/proc/self/exe?child?/bin/bash
?1275?pts/0????S+?????0:00??|???????????????????\_?/bin/bash
......
以此類推,新增資源隔離只要繼續(xù)傳遞指定的系統(tǒng)調(diào)用參數(shù)即可:
package?main
import?(
?"fmt"
?"os"
?"os/exec"
?"syscall"
)
func?main()?{
?switch?os.Args[1]?{
?case?"run":
??run()
?case?"child":
??child()
?default:
??panic("help")
?}
}
func?run()?{
?fmt.Println("[main]",?"pid:",?os.Getpid())
?cmd?:=?exec.Command("/proc/self/exe",?append([]string{"child"},?os.Args[2:]...)...)
?cmd.Stdin?=?os.Stdin
?cmd.Stdout?=?os.Stdout
?cmd.Stderr?=?os.Stderr
?cmd.SysProcAttr?=?&syscall.SysProcAttr{
??Cloneflags:?syscall.CLONE_NEWUTS?|
???syscall.CLONE_NEWPID?|
???syscall.CLONE_NEWNS,
??Unshareflags:?syscall.CLONE_NEWNS,
?}
?must(cmd.Run())
}
func?child()?{
?fmt.Println("[exe]",?"pid:",?os.Getpid())
?must(syscall.Sethostname([]byte("mycontainer")))
?must(os.Chdir("/"))
?must(syscall.Mount("proc",?"proc",?"proc",?0,?""))
?cmd?:=?exec.Command(os.Args[2],?os.Args[3:]...)
?cmd.Stdin?=?os.Stdin
?cmd.Stdout?=?os.Stdout
?cmd.Stderr?=?os.Stderr
?must(cmd.Run())
?must(syscall.Unmount("proc",?0))
}
func?must(err?error)?{
?if?err?!=?nil?{
??panic(err)
?}
}
Cloneflags 參數(shù)新增了 CLONE_NEWPID 和 CLONE_NEWNS 分別隔離進程 pid 和文件目錄掛載點視圖,Unshareflags: syscall.CLONE_NEWNS 則是用于禁用掛載傳播(如果不設(shè)置該參數(shù),container 內(nèi)的掛載會共享到 host ,掛載傳播不在本文的探討范圍內(nèi))。
當我們創(chuàng)建 PID Namespace 時,exe 進程包括其創(chuàng)建出來的子進程的 pid 已經(jīng)和 main 進程隔離了,這一點可以通過打印 os.Getpid() 結(jié)果或執(zhí)行 echo $$ 命令得到驗證。但此時還不能使用 ps 命令查看,因為 ps 和 top 等命令會使用 /proc 的內(nèi)容,所以我們才繼續(xù)引入了 Mount Namespace ,并在 exe 進程掛載 /proc 目錄。
Mount Namespace 是 Linux 第一個實現(xiàn)的 Namespace ,其系統(tǒng)調(diào)用參數(shù)是 CLONE_NEWNS ( New Namespace ) ,是因為當時并沒意識到之后還會新增這么多的 Namespace 類型。
[root@host?go]#?ps
??PID?TTY??????????TIME?CMD
?1115?pts/0????00:00:00?bash
?3792?pts/0????00:00:00?ps
[root@host?go]#?echo?$$
1115
[root@host?go]#?go?run?main.go?run?/bin/bash
[main]?pid:?3811
[exe]?pid:?1
[root@mycontainer?/]#?ps
??PID?TTY??????????TIME?CMD
????1?pts/0????00:00:00?exe
????4?pts/0????00:00:00?bash
???15?pts/0????00:00:00?ps
[root@mycontainer?/]#?echo?$$
4
[root@mycontainer?/]#?exit
exit
[root@host?go]#
此時,exe 作為初始化進程,pid 為 1 ,創(chuàng)建出了 pid 4 的 bash 子進程,而且已經(jīng)看不到 main 進程了。
剩下的 IPC 、NET、 USER 等 NameSpace 就不在本文一一展示了。
2、Cgroups
借助 NameSpace 技術(shù)可以幫進程隔離出自己單獨的空間,成功實現(xiàn)出最簡容器。但是怎樣限制這些空間的物理資源開銷(CPU、內(nèi)存、存儲、I/O 等)就需要利用 Cgroups 技術(shù)了。
限制容器的資源使用,是一個非常重要的功能,如果一個容器可以毫無節(jié)制的使用服務(wù)器資源,那便又回到了傳統(tǒng)模式下將應(yīng)用直接運行在物理服務(wù)器上的弊端。這是容器化技術(shù)不能接受的。
Cgroups 的全稱是 Control groups 即控制組,最早是由 Google 的工程師(主要是 Paul Menage 和 Rohit Seth)在 2006 年發(fā)起,一開始叫做進程容器(process containers)。在 2007 年時,因為在 Linux Kernel 中,容器(container)這個名詞有許多不同的意義,為避免混亂,被重命名為 cgroup ,并且被合并到 2.6.24 版本的內(nèi)核中去。
Android 也是憑借這個技術(shù),為每個 APP 分配不同的 cgroup ,將每個 APP 進行隔離,而不會影響到其他的 APP 環(huán)境。
Cgroups 是對進程分組管理的一種機制,提供了對一組進程及它們的子進程的資源限制、控制和統(tǒng)計的能力,并為每種可以控制的資源定義了一個 subsystem (子系統(tǒng))的方式進行統(tǒng)一接口管理,因此 subsystem 也被稱為 resource controllers (資源控制器)。
幾個主要的 subsystem 如下( Cgroups V1 ):
| 子系統(tǒng) | 作用 |
|---|---|
| cpu | 限制進程的 cpu 使用率 |
| cpuacct | 統(tǒng)計進程的 cpu 使用情況 |
| cpuset | 在多核機器上為進程分配單獨的 cpu 節(jié)點或者內(nèi)存節(jié)點(僅限 NUMA 架構(gòu)) |
| memory | 限制進程的 memory 使用量 |
| blkio | 控制進程對塊設(shè)備(例如硬盤) io 的訪問 |
| devices | 控制進程對設(shè)備的訪問 |
| net_cls | 標記進程的網(wǎng)絡(luò)數(shù)據(jù)包,以便可以使用 tc 模塊(traffic control)對數(shù)據(jù)包進行限流、監(jiān)控等控制 |
| net_prio | 控制進程產(chǎn)生的網(wǎng)絡(luò)流量的優(yōu)先級 |
| freezer | 掛起或者恢復(fù)進程 |
| pids | 限制 cgroup 的進程數(shù)量 |
| 更多子系統(tǒng)參考 Linux man cgroups[3]文檔 | https://man7.org/linux/man-pages/man7/cgroups.7.html |
借助 Cgroups 機制,可以將一組進程(task group)和一組 subsystem 關(guān)聯(lián)起來,達到控制進程對應(yīng)關(guān)聯(lián)的資源的能力。如圖:

Cgroups 的層級結(jié)構(gòu)稱為 hierarchy (即 cgroup 樹),是一棵樹,由 cgroup 節(jié)點組成。
系統(tǒng)可以有多個 hierarchy ,當創(chuàng)建新的 hierarchy 時,系統(tǒng)所有的進程都會加入到這個 hierarchy 默認創(chuàng)建的 root cgroup 根節(jié)點中,在樹中,子節(jié)點可以繼承父節(jié)點的屬性。
對于同一個 hierarchy,進程只能存在于其中一個 cgroup 節(jié)點中。如果把一個進程添加到同一個 hierarchy 中的另一個 cgroup 節(jié)點,則會從第一個 cgroup 節(jié)點中移除。
hierarchy 可以附加一個或多個 subsystem 來擁有對應(yīng)資源(如 cpu 和 memory )的管理權(quán),其中每一個 cgroup 節(jié)點都可以設(shè)置不同的資源限制權(quán)重,而進程( task )則綁定在 cgroup 節(jié)點中,并且其子進程也會默認綁定到父進程所在的 cgroup 節(jié)點中。
基于 Cgroups 的這些運作原理,可以得出:如果想限制某些進程的內(nèi)存資源,就可以先創(chuàng)建一個 hierarchy ,并為其掛載 memory subsystem ,然后在這個 hierarchy 中創(chuàng)建一個 cgroup 節(jié)點,在這個節(jié)點中,將需要控制的進程 pid 和控制屬性寫入即可。
接下來我們就來實踐一下。
Linux 一切皆文件。
在 Linux Kernel 中,為了讓 Cgroups 的配置更直觀,使用了目錄的層級關(guān)系來模擬 hierarchy ,以此通過虛擬的樹狀文件系統(tǒng)的方式暴露給用戶調(diào)用。
創(chuàng)建一個 hierarchy ,并為其掛載 memory subsystem ,這一步我們可以跳過,因為系統(tǒng)已經(jīng)默認為每個 subsystem 創(chuàng)建了一個默認的 hierarchy ,我們可以直接使用。
例如 memory subsystem 默認的 hierarchy 就在 /sys/fs/cgroup/memory 目錄。
[root@host?go]#?mount?|?grep?memory
cgroup?on?/sys/fs/cgroup/memory?type?cgroup?(rw,nosuid,nodev,noexec,relatime,memory)
[root@host?go]#?cd?/sys/fs/cgroup/memory
[root@host?memory]#?pwd
/sys/fs/cgroup/memory
[root@host?memory]#
只要在這個 hierarchy 目錄下創(chuàng)建一個文件夾,就相當于創(chuàng)建了一個 cgroup 節(jié)點:
[root@host?memory]#?mkdir?hello
[root@host?memory]#?cd?hello/
[root@host?hello]#?ls
cgroup.clone_children???????????memory.kmem.slabinfo????????????????memory.memsw.failcnt?????????????memory.soft_limit_in_bytes
cgroup.event_control????????????memory.kmem.tcp.failcnt?????????????memory.memsw.limit_in_bytes??????memory.stat
cgroup.procs????????????????????memory.kmem.tcp.limit_in_bytes??????memory.memsw.max_usage_in_bytes??memory.swappiness
memory.failcnt??????????????????memory.kmem.tcp.max_usage_in_bytes??memory.memsw.usage_in_bytes??????memory.usage_in_bytes
memory.force_empty??????????????memory.kmem.tcp.usage_in_bytes??????memory.move_charge_at_immigrate??memory.use_hierarchy
memory.kmem.failcnt?????????????memory.kmem.usage_in_bytes??????????memory.numa_stat?????????????????notify_on_release
memory.kmem.limit_in_bytes??????memory.limit_in_bytes???????????????memory.oom_control???????????????tasks
memory.kmem.max_usage_in_bytes??memory.max_usage_in_bytes???????????memory.pressure_level
[root@host?hello]#
其中我們創(chuàng)建的 hello 文件夾內(nèi)的所有文件都是系統(tǒng)自動創(chuàng)建的。常用的幾個文件功能如下:
| 文件名 | 功能 |
|---|---|
| tasks | cgroup 中運行的進程( PID)列表。將 PID 寫入一個 cgroup 的 tasks 文件,可將此進程移至該 cgroup |
| cgroup.procs | cgroup 中運行的線程群組列表( TGID )。將 TGID 寫入 cgroup 的 cgroup.procs 文件,可將此線程組群移至該 cgroup |
| cgroup.event_control | event_fd() 的接口。允許 cgroup 的變更狀態(tài)通知被發(fā)送 |
| notify_on_release | 用于自動移除空 cgroup 。默認為禁用狀態(tài)(0)。設(shè)定為啟用狀態(tài)(1)時,當 cgroup 不再包含任何任務(wù)時(即,cgroup 的 tasks 文件包含 PID,而 PID 被移除,致使文件變空),kernel 會執(zhí)行 release_agent 文件(僅在 root cgroup 出現(xiàn))的內(nèi)容,并且提供通向被清空 cgroup 的相關(guān)路徑(與 root cgroup 相關(guān))作為參數(shù) |
| memory.usage_in_bytes | 顯示 cgroup 中進程當前所用的內(nèi)存總量(以字節(jié)為單位) |
| memory.memsw.usage_in_bytes | 顯示 cgroup 中進程當前所用的內(nèi)存量和 swap 空間總和(以字節(jié)為單位) |
| memory.max_usage_in_bytes | 顯示 cgroup 中進程所用的最大內(nèi)存量(以字節(jié)為單位) |
| memory.memsw.max_usage_in_bytes | 顯示 cgroup 中進程的最大內(nèi)存用量和最大 swap 空間用量(以字節(jié)為單位) |
| memory.limit_in_bytes | 設(shè)定用戶內(nèi)存(包括文件緩存)的最大用量 |
| memory.memsw.limit_in_bytes | 設(shè)定內(nèi)存與 swap 用量之和的最大值 |
| memory.failcnt | 顯示內(nèi)存達到 memory.limit_in_bytes 設(shè)定的限制值的次數(shù) |
| memory.memsw.failcnt | 顯示內(nèi)存和 swap 空間總和達到 memory.memsw.limit_in_bytes 設(shè)定的限制值的次數(shù) |
| memory.oom_control | 可以為 cgroup 啟用或者禁用“內(nèi)存不足”(Out of Memory,OOM) 終止程序。默認為啟用狀態(tài)(0),嘗試消耗超過其允許內(nèi)存的任務(wù)會被 OOM 終止程序立即終止。設(shè)定為禁用狀態(tài)(1)時,嘗試使用超過其允許內(nèi)存的任務(wù)會被暫停,直到有額外內(nèi)存可用。 |
| 更多文件的功能說明可以查看 kernel 文檔中的 cgroup-v1/memory[4] | https://www.kernel.org/doc/Documentation/cgroup-v1/memory.txt |
在這個 hello cgroup 節(jié)點中,我們想限制某些進程的內(nèi)存資源,只需將對應(yīng)的進程 pid 寫入到 tasks 文件,并把內(nèi)存最大用量設(shè)定到 memory.limit_in_bytes 文件即可。
[root@host?hello]#?cat?memory.oom_control
oom_kill_disable?0
under_oom?0
[root@host?hello]#?cat?memory.failcnt
0
[root@host?hello]#?echo?100M?>?memory.limit_in_bytes
[root@host?hello]#?cat?memory.limit_in_bytes
104857600
[root@host?hello]#
hello cgroup 節(jié)點默認啟用了 OOM 終止程序,因此,當有進程嘗試使用超過可用內(nèi)存時會被立即終止。查詢 memory.failcnt 可知,目前還沒有進程內(nèi)存達到過設(shè)定的最大內(nèi)存限制值。
我們已經(jīng)設(shè)定了 hello cgroup 節(jié)點可使用的最大內(nèi)存為 100M ,此時新啟動一個 bash 會話進程并將其移入到 hello cgroup 節(jié)點中:
[root@host?hello]#?/bin/bash
[root@host?hello]#?echo?$$
4123
[root@host?hello]#?cat?tasks
[root@host?hello]#?echo?$$?>?tasks
[root@host?hello]#?cat?tasks
4123
4135
[root@host?hello]#?cat?memory.usage_in_bytes
196608
[root@host?hello]#
后續(xù)在此會話進程所創(chuàng)建的子進程都會加入到該 hello cgroup 節(jié)點中(例如 pid 4135 就是由于執(zhí)行 cat 命令而創(chuàng)建的新進程,被系統(tǒng)自動加入到了 tasks 文件中)。
繼續(xù)使用 memtester[5] 工具來測試 100M 的最大內(nèi)存限制是否生效:
[root@host?hello]#?memtester?50M?1
memtester?version?4.5.1?(64-bit)
Copyright?(C)?2001-2020?Charles?Cazabon.
Licensed?under?the?GNU?General?Public?License?version?2?(only).
pagesize?is?4096
pagesizemask?is?0xfffffffffffff000
want?50MB?(52428800?bytes)
got??50MB?(52428800?bytes),?trying?mlock?...locked.
Loop?1/1:
??Stuck?Address???????:?ok
??Random?Value????????:?ok
??Compare?XOR?????????:?ok
??Compare?SUB?????????:?ok
??Compare?MUL?????????:?ok
??Compare?DIV?????????:?ok
??Compare?OR??????????:?ok
??Compare?AND?????????:?ok
??Sequential?Increment:?ok
??Solid?Bits??????????:?ok
??Block?Sequential????:?ok
??Checkerboard????????:?ok
??Bit?Spread??????????:?ok
??Bit?Flip????????????:?ok
??Walking?Ones????????:?ok
??Walking?Zeroes??????:?ok
??8-bit?Writes????????:?ok
??16-bit?Writes???????:?ok
Done.
[root@host?hello]#?memtester?100M?1
memtester?version?4.5.1?(64-bit)
Copyright?(C)?2001-2020?Charles?Cazabon.
Licensed?under?the?GNU?General?Public?License?version?2?(only).
pagesize?is?4096
pagesizemask?is?0xfffffffffffff000
want?100MB?(104857600?bytes)
got??100MB?(104857600?bytes),?trying?mlock?...over?system/pre-process?limit,?reducing...
got??99MB?(104853504?bytes),?trying?mlock?...over?system/pre-process?limit,?reducing...
got??99MB?(104849408?bytes),?trying?mlock?...over?system/pre-process?limit,?reducing...
......
[root@host?hello]#?cat?memory.failcnt
1434
[root@host?hello]#
可以看到當 memtester 嘗試申請 100M 內(nèi)存時,失敗了,而 memory.failcnt 報告顯示內(nèi)存達到 memory.limit_in_bytes 設(shè)定的限制值(100M)的次數(shù)為 1434 次。
如果想要刪除 cgroup 節(jié)點,也只需要刪除對應(yīng)的文件夾即可。
[root@host?hello]#?exit
exit
[root@host?hello]#?cd?../
[root@host?memory]#?rmdir?hello/
[root@host?memory]#
經(jīng)過上面對 Cgroups 的使用和實踐,可以將其應(yīng)用到我們之前的 Go 程序中:
package?main
import?(
?"fmt"
?"io/ioutil"
?"os"
?"os/exec"
?"path/filepath"
?"strconv"
?"syscall"
)
func?main()?{
?switch?os.Args[1]?{
?case?"run":
??run()
?case?"child":
??child()
?default:
??panic("help")
?}
}
func?run()?{
?fmt.Println("[main]",?"pid:",?os.Getpid())
?cmd?:=?exec.Command("/proc/self/exe",?append([]string{"child"},?os.Args[2:]...)...)
?cmd.Stdin?=?os.Stdin
?cmd.Stdout?=?os.Stdout
?cmd.Stderr?=?os.Stderr
?cmd.SysProcAttr?=?&syscall.SysProcAttr{
??Cloneflags:?syscall.CLONE_NEWUTS?|
???syscall.CLONE_NEWPID?|
???syscall.CLONE_NEWNS,
??Unshareflags:?syscall.CLONE_NEWNS,
?}
?must(cmd.Run())
}
func?child()?{
?fmt.Println("[exe]",?"pid:",?os.Getpid())
?cg()
?must(syscall.Sethostname([]byte("mycontainer")))
?must(os.Chdir("/"))
?must(syscall.Mount("proc",?"proc",?"proc",?0,?""))
?cmd?:=?exec.Command(os.Args[2],?os.Args[3:]...)
?cmd.Stdin?=?os.Stdin
?cmd.Stdout?=?os.Stdout
?cmd.Stderr?=?os.Stderr
?must(cmd.Run())
?must(syscall.Unmount("proc",?0))
}
func?cg()?{
?mycontainer_memory_cgroups?:=?"/sys/fs/cgroup/memory/mycontainer"
?os.Mkdir(mycontainer_memory_cgroups,?0755)
?must(ioutil.WriteFile(filepath.Join(mycontainer_memory_cgroups,?"memory.limit_in_bytes"),?[]byte("100M"),?0700))
?must(ioutil.WriteFile(filepath.Join(mycontainer_memory_cgroups,?"notify_on_release"),?[]byte("1"),?0700))
?must(ioutil.WriteFile(filepath.Join(mycontainer_memory_cgroups,?"tasks"),?[]byte(strconv.Itoa(os.Getpid())),?0700))
}
func?must(err?error)?{
?if?err?!=?nil?{
??panic(err)
?}
}
我們在 exe 進程添加了對 cg() 函數(shù)的調(diào)用,代碼相對簡單,和我們的實踐流程幾乎是一致的,區(qū)別只在于為 notify_on_release 文件設(shè)定為 1 值,使得當我們的 exe 進程退出后,可以自動移除所創(chuàng)建的 cgroup 。
[root@host?go]#?go?run?main.go?run?/bin/bash
[main]?pid:?4693
[exe]?pid:?1
[root@mycontainer?/]#?ps
??PID?TTY??????????TIME?CMD
????1?pts/2????00:00:00?exe
????4?pts/2????00:00:00?bash
???15?pts/2????00:00:00?ps
[root@mycontainer?/]#?cat?/sys/fs/cgroup/memory/mycontainer/tasks
1
4
16
[root@mycontainer?/]#?cat?/sys/fs/cgroup/memory/mycontainer/notify_on_release
1
[root@mycontainer?/]#?cat?/sys/fs/cgroup/memory/mycontainer/memory.limit_in_bytes
104857600
[root@mycontainer?/]#
使用 memtester 測試和結(jié)果預(yù)期一致:
[root@mycontainer?/]#?memtester?100M?1
memtester?version?4.5.1?(64-bit)
Copyright?(C)?2001-2020?Charles?Cazabon.
Licensed?under?the?GNU?General?Public?License?version?2?(only).
pagesize?is?4096
pagesizemask?is?0xfffffffffffff000
want?100MB?(104857600?bytes)
got??100MB?(104857600?bytes),?trying?mlock?...over?system/pre-process?limit,?reducing...
got??99MB?(104853504?bytes),?trying?mlock?...over?system/pre-process?limit,?reducing...
got??99MB?(104849408?bytes),?trying?mlock?...over?system/pre-process?limit,?reducing...
......
[root@mycontainer?/]#?exit
exit
[root@host?go]#
同樣篇幅問題,剩下的 subsystem 也不在本文一一展示了。
其實到這里,我們已經(jīng)通過 NameSpace 技術(shù)幫進程隔離出自己單獨的空間,并使用 Cgroups 技術(shù)限制和監(jiān)控這些空間的資源開銷,這種特殊的進程就是容器的本質(zhì)。可以說,我們本篇文章的目的已達成,可以結(jié)束了。

但是除了利用 NameSpace 和 Cgroups 來實現(xiàn) 容器(container) ,在 Docker 中,還使用到了一個 Linux Kernel 技術(shù):UnionFS 來實現(xiàn) 鏡像(images) 功能。
鑒于本篇文章的主旨 —— 使用 Go 和 Linux Kernel 技術(shù)探究容器化原理的主要技術(shù)點是 NameSpace 和 Cgroups 。鏡像的實現(xiàn)技術(shù) UnionFS 屬于加餐內(nèi)容,可自行選擇是否需要消化。
3、UnionFS
UnionFS 全稱 Union File System (聯(lián)合文件系統(tǒng)),在 2004 年由紐約州立大學(xué)石溪分校開發(fā),是為 Linux、FreeBSD 和 NetBSD 操作系統(tǒng)設(shè)計的一種分層、輕量級并且高性能的文件系統(tǒng),可以 把多個目錄內(nèi)容聯(lián)合掛載到同一個目錄下 ,而目錄的物理位置是分開的,并且對文件系統(tǒng)的修改是類似于 git 的 commit 一樣 作為一次提交來一層層的疊加的 。
在 Docker 中,鏡像相當于是容器的模板,一個鏡像可以衍生出多個容器。鏡像利用 UnionFS 技術(shù)來實現(xiàn),就可以利用其 分層的特性 來進行鏡像的繼承,基于基礎(chǔ)鏡像,制作出各種具體的應(yīng)用鏡像,不同容器就可以直接 共享基礎(chǔ)的文件系統(tǒng)層 ,同時再加上自己獨有的改動層,大大提高了存儲的效率。
以該 Dockerfile 為例[6]:
FROM?ubuntu:18.04
LABEL?org.opencontainers.image.authors="[email protected]"
COPY?.?/app
RUN?make?/app
RUN?rm?-r?$HOME/.cache
CMD?python?/app/app.py
鏡像的每一層都可以代表 Dockerfile 中的一條指令,并且除了最后一層之外的每一層都是只讀的。
在該 Dockerfile 中包含了多個命令,如果命令修改了文件系統(tǒng)就會創(chuàng)建一個層(利用 UnionFS 的原理)。
首先 FROM 語句從 ubuntu:18.04 鏡像創(chuàng)建一個層 【1】,而 LABEL 命令僅修改鏡像的元數(shù)據(jù),不會生成新鏡像層,接著 COPY 命令會把當前目錄中的文件添加到鏡像中的 /app 目錄下,在層【1】的基礎(chǔ)上生成了層【2】。
第一個 RUN 命令使用 make 構(gòu)建應(yīng)用程序,并將結(jié)果寫入新層【3】。第二個 RUN 命令刪除緩存目錄,并將結(jié)果寫入新層【4】。最后,CMD 指令指定在容器內(nèi)運行什么命令,只修改了鏡像的元數(shù)據(jù),也不會產(chǎn)生鏡像層。
這【4】個層(layer)相互堆疊在一起就是一個鏡像。當創(chuàng)建一個新容器時,會在 鏡像層(image layers) 上面再添加一個新的可寫層,稱為 容器層(container layer) 。對正在運行的容器所做的所有更改,例如寫入新文件、修改現(xiàn)有文件和刪除文件,都會寫入到這個可寫容器層。

對于相同的鏡像層,每一個容器都會有自己的可寫容器層,并且所有的變化都存儲在這個容器層中,所以多個容器可以共享對同一個底層鏡像的訪問,并且擁有自己的數(shù)據(jù)狀態(tài)。而當容器被刪除時,其可寫容器層也會被刪除,如果用戶需要持久化容器里的數(shù)據(jù),就需要使用 Volume 掛載到宿主機目錄。

看完 Docker 鏡像的運作原理,讓我們回到其實現(xiàn)技術(shù) UnionFS 本身。
目前 Docker 支持的 UnionFS 有以下幾種類型:
| 聯(lián)合文件系統(tǒng) | 存儲驅(qū)動 | 說明 |
|---|---|---|
| OverlayFS | overlay2 | 當前所有受支持的 Linux 發(fā)行版的 首選 存儲驅(qū)動程序,并且不需要任何額外的配置 |
| OverlayFS | fuse-overlayfs | 僅在不提供對 rootless 支持的主機上運行 Rootless Docker 時才首選 |
| Btrfs 和 ZFS | btrfs 和 zfs | 允許使用高級選項,例如創(chuàng)建快照,但需要更多的維護和設(shè)置 |
| VFS | vfs | 旨在用于測試目的,以及無法使用寫時復(fù)制文件系統(tǒng)的情況下使用。此存儲驅(qū)動程序性能較差,一般不建議用于生產(chǎn)用途 |
| AUFS | aufs | Docker 18.06 和更早版本的首選存儲驅(qū)動程序。但是在沒有 overlay2 驅(qū)動的機器上仍然會使用 aufs 作為 Docker 的默認驅(qū)動 |
| Device Mapper | devicemapper | RHEL (舊內(nèi)核版本不支持 overlay2,最新版本已支持)的 Docker Engine 的默認存儲驅(qū)動,有兩種配置模式:loop-lvm(零配置但性能差) 和 direct-lvm(生產(chǎn)環(huán)境推薦) |
| OverlayFS | overlay | 推薦使用 overlay2 存儲驅(qū)動 |
在盡可能的情況下,推薦使用 OverlayFS 的 overlay2 存儲驅(qū)動,這也是當前 Docker 默認的存儲驅(qū)動(以前是 AUFS 的 aufs )。
可查看 Docker 使用了哪種存儲驅(qū)動:
[root@host?~]#?docker?-v
Docker?version?20.10.15,?build?fd82621
[root@host?~]#?docker?info?|?grep?Storage
?Storage?Driver:?overlay2
[root@host?~]#
OverlayFS 其實是一個類似于 AUFS 的、面向 Linux 的現(xiàn)代聯(lián)合文件系統(tǒng),在 2014 年被合并到 Linux Kernel (version 3.18)中,相比 AUFS 其速度更快且實現(xiàn)更簡單。 overlay2 (Linux Kernel version 4.0 或以上)則是其推薦的驅(qū)動程序。
overlay2 由四個結(jié)構(gòu)組成,其中:
lowerdir :表示較為底層的目錄,對應(yīng) Docker 中的只讀鏡像層 upperdir :表示較為上層的目錄,對應(yīng) Docker 中的可寫容器層 workdir :表示工作層(中間層)的目錄,在使用過程中對用戶不可見 merged :所有目錄合并后的聯(lián)合掛載點,給用戶暴露的統(tǒng)一目錄視圖,對應(yīng) Docker 中用戶實際看到的容器內(nèi)的目錄視圖
這是在 Docker 文檔中關(guān)于 overlay 的架構(gòu)圖[7],但是對于 overlay2 也同樣可以適用:

其中 lowerdir 所對應(yīng)的鏡像層( Image layer ),實際上是可以有很多層的,圖中只畫了一層。
細心的小伙伴可能會發(fā)現(xiàn),圖中并沒有出現(xiàn) workdir ,它究竟是如何工作的呢?
我們可以從讀寫的視角來理解,對于讀的情況:
文件在 upperdir ,直接讀取 文件不在 upperdir ,從 lowerdir 讀取,會產(chǎn)生非常小的性能開銷 文件同時存在 upperdir 和 lowerdir 中,從 upperdir 讀取(upperdir 中的文件隱藏了 lowerdir 中的同名文件)
對于寫的情況:
創(chuàng)建一個新文件,文件在 upperdir 和 lowerdir 中都不存在,則直接在 upperdir 創(chuàng)建 修改文件,如果該文件在 upperdir 中存在,則直接修改 修改文件,如果該文件在 upperdir 中不存在,將執(zhí)行 copy_up 操作,把文件從 lowerdir 復(fù)制到 upperdir ,后續(xù)對該文件的寫入操作將對已經(jīng)復(fù)制到 upperdir 的副本文件進行操作。這就是 寫時復(fù)制(copy-on-write) 刪除文件,如果文件只在 upperdir 存在,則直接刪除 刪除文件,如果文件只在 lowerdir 存在,會在 upperdir 中創(chuàng)建一個同名的空白文件(whiteout file),lowerdir 中的文件不會被刪除,因為他們是只讀的,但 whiteout file 會阻止它們繼續(xù)顯示 刪除文件,如果文件在 upperdir 和 lowerdir 中都存在,則先將 upperdir 中的文件刪除,再創(chuàng)建一個同名的空白文件(whiteout file) 刪除目錄和刪除文件是一致的,會在 upperdir 中創(chuàng)建一個同名的不透明的目錄(opaque directory),和 whiteout file 原理一樣,opaque directory 會阻止用戶繼續(xù)訪問,即便 lowerdir 內(nèi)的目錄仍然存在
說了半天,好像還是沒有講到 workdir 的作用,這得理解一下,畢竟人家在使用過程中對用戶是不可見的。
但其實 workdir 的作用不可忽視。想象一下,在刪除文件(或目錄)的場景下(文件或目錄在 upperdir 和 lowerdir 中都存在),對于 lowerdir 而言,倒沒什么,畢竟只讀,不需要理會,但是對于 upperdir 來講就不同了。在 upperdir 中,我們要先刪除對應(yīng)的文件,然后才可以創(chuàng)建同名的 whiteout file ,如何保證這兩步必須都執(zhí)行,這就涉及到了原子性操作了。
workdir 是用來進行一些中間操作的,其中就包括了原子性保證。在上面的問題中,完全可以先在 workdir 創(chuàng)建一個同名的 whiteout file ,然后再在 upperdir 上執(zhí)行兩步操作,成功之后,再刪除掉 workdir 中的 whiteout file 即可。
而當修改文件時,workdir 也在充當著中間層的作用,當對 upperdir 里面的副本進行修改時,會先放到 workdir ,然后再從 workdir 移到 upperdir 里面去。
理解完 overlay2 運作原理,接下來正式進入到演示環(huán)節(jié)。
首先可以來看看在 Docker 中啟動了一個容器后,其掛載點是怎樣的:
[root@host?~]#?mount?|?grep?overlay
[root@host?~]#?docker?run?-d?-it?ubuntu:18.04?/bin/bash
cb25841054d9f037ec5cf4c24a97a05f771b43a358dd89b40346ca3ab0e5eaf4
[root@host?~]#?mount?|?grep?overlay
overlay?on?/var/lib/docker/overlay2/56bbb1dbdd636984e4891db7850939490ece5bc7a3f3361d75b1341f0fb30b85/merged?type?overlay?(rw,relatime,lowerdir=/var/lib/docker/overlay2/l/OOPFROHUDK727Z5QKNPWG5FBWV:/var/lib/docker/overlay2/l/6TWQL4UC7XYLZWZBKPS6F4IKLF,upperdir=/var/lib/docker/overlay2/56bbb1dbdd636984e4891db7850939490ece5bc7a3f3361d75b1341f0fb30b85/diff,workdir=/var/lib/docker/overlay2/56bbb1dbdd636984e4891db7850939490ece5bc7a3f3361d75b1341f0fb30b85/work)
[root@host?~]#?ll?/var/lib/docker/overlay2/56bbb1dbdd636984e4891db7850939490ece5bc7a3f3361d75b1341f0fb30b85/merged
total?76
drwxr-xr-x??2?root?root?4096?Apr?28?08:04?bin
drwxr-xr-x??2?root?root?4096?Apr?24??2018?boot
drwxr-xr-x??1?root?root?4096?May?10?11:17?dev
drwxr-xr-x??1?root?root?4096?May?10?11:17?etc
drwxr-xr-x??2?root?root?4096?Apr?24??2018?home
drwxr-xr-x??8?root?root?4096?May?23??2017?lib
drwxr-xr-x??2?root?root?4096?Apr?28?08:03?lib64
drwxr-xr-x??2?root?root?4096?Apr?28?08:03?media
drwxr-xr-x??2?root?root?4096?Apr?28?08:03?mnt
drwxr-xr-x??2?root?root?4096?Apr?28?08:03?opt
drwxr-xr-x??2?root?root?4096?Apr?24??2018?proc
drwx------??2?root?root?4096?Apr?28?08:04?root
drwxr-xr-x??5?root?root?4096?Apr?28?08:04?run
drwxr-xr-x??2?root?root?4096?Apr?28?08:04?sbin
drwxr-xr-x??2?root?root?4096?Apr?28?08:03?srv
drwxr-xr-x??2?root?root?4096?Apr?24??2018?sys
drwxrwxrwt??2?root?root?4096?Apr?28?08:04?tmp
drwxr-xr-x?10?root?root?4096?Apr?28?08:03?usr
drwxr-xr-x?11?root?root?4096?Apr?28?08:04?var
[root@host?~]#
可以看到,掛載后的 merged 目錄包括了 lowerdir 、upperdir 、workdir 目錄,而 merged 目錄實際上就是容器內(nèi)用戶看到的目錄視圖。
回到技術(shù)本身,我們可以自己來嘗試一下如何使用 mount 的 overlay 掛載選項[8] :

首先創(chuàng)建好 lowerdir(創(chuàng)建了 2 個) 、upperdir 、workdir、 merged 目錄,并為 lowerdir 和 upperdir 目錄寫入一些文件:
[root@host?~]#?mkdir?test_overlay
[root@host?~]#?cd?test_overlay/
[root@host?test_overlay]#?mkdir?lower1
[root@host?test_overlay]#?mkdir?lower2
[root@host?test_overlay]#?mkdir?upper
[root@host?test_overlay]#?mkdir?work
[root@host?test_overlay]#?mkdir?merged
[root@host?test_overlay]#?echo?'lower1-file1'?>?lower1/file1.txt
[root@host?test_overlay]#?echo?'lower2-file2'?>?lower2/file2.txt
[root@host?test_overlay]#?echo?'upper-file3'?>?upper/file3.txt
[root@host?test_overlay]#?tree
.
|--?lower1
|???`--?file1.txt
|--?lower2
|???`--?file2.txt
|--?merged
|--?upper
|???`--?file3.txt
`--?work
5?directories,?3?files
[root@host?test_overlay]#
使用 mount 命令的 overlay 選項模式進行掛載:
[root@host?test_overlay]#?mount?-t?overlay?overlay?-olowerdir=lower1:lower2,upperdir=upper,workdir=work?merged
[root@host?test_overlay]#?mount?|?grep?overlay
......
overlay?on?/root/test_overlay/merged?type?overlay?(rw,relatime,lowerdir=lower1:lower2,upperdir=upper,workdir=work)
[root@host?test_overlay]#
此時進入 merged 目錄就可以看到所有文件了:
[root@host?test_overlay]#?cd?merged/
[root@host?merged]#?ls
file1.txt??file2.txt??file3.txt
[root@host?merged]#
我們嘗試修改 lowerdir 目錄內(nèi)的文件:
[root@host?merged]#?echo?'lower1-file1-hello'?>?file1.txt
[root@host?merged]#?cat?file1.txt
lower1-file1-hello
[root@host?merged]#?cat?/root/test_overlay/lower1/file1.txt
lower1-file1
[root@host?merged]#?ls?/root/test_overlay/upper/
file1.txt??file3.txt
[root@host?merged]#?cat?/root/test_overlay/upper/file1.txt
lower1-file1-hello
[root@host?merged]#
和之前我們所說的一致,當修改 lowerdir 內(nèi)的文件時,會執(zhí)行 copy_up 操作,把文件從 lowerdir 復(fù)制到 upperdir ,后續(xù)對該文件的寫入操作將對已經(jīng)復(fù)制到 upperdir 的副本文件進行操作。
其它的讀寫情況,大家就可以自行嘗試了。
總結(jié)
其實容器的底層原理并不難,本質(zhì)上就是一個特殊的進程,特殊在為其創(chuàng)建了 NameSpace 隔離運行環(huán)境,用 Cgroups 為其控制了資源開銷,這些都是站在 Linux 操作系統(tǒng)的肩膀上實現(xiàn)的,包括 Docker 的鏡像實現(xiàn)也是利用了 UnionFS 的分層聯(lián)合技術(shù)。
我們甚至可以說幾乎所有應(yīng)用的本質(zhì)都是?上層調(diào)下層 ,下層支撐著上層 。
參考資料
Linux man 手冊中的 NAMESPACES: https://man7.org/linux/man-pages/man7/namespaces.7.html
[2]源自 Containers From Scratch ? Liz Rice ? GOTO 2018: https://www.youtube.com/watch?v=8fi7uSYlOdc
[3]Linux man cgroups: https://man7.org/linux/man-pages/man7/cgroups.7.html
[4]cgroup-v1/memory: https://www.kernel.org/doc/Documentation/cgroup-v1/memory.txt
[5]memtester: https://pyropus.ca./software/memtester/
[6]以該 Dockerfile 為例: https://docs.docker.com/storage/storagedriver/
[7]overlay 的架構(gòu)圖: https://docs.docker.com/storage/storagedriver/overlayfs-driver/#how-the-overlay-driver-works
[8]mount 的 overlay 掛載選項: https://man7.org/linux/man-pages/man8/mount.8.html
推薦閱讀
