1. 程式人生 > 其它 >使用 Go 剖析Linux容器實現原理

使用 Go 剖析Linux容器實現原理

容器的本質

容器是一種輕量級的作業系統層面的虛擬化技術。

重點是 “作業系統層面” ,即容器本質上是利用作業系統提供的功能來實現虛擬化。

容器技術的代表之作 Docker ,則是一個基於 Linux 作業系統,使用 Go 語言編寫,呼叫了 Linux Kernel 功能的虛擬化工具。

網管叨bi叨 分享軟體開發和系統架構設計基礎、Go 語言和Kubernetes。 200篇原創內容 公眾號

為了更好地理解容器的本質,我們來看看容器具體使用了哪些 Linux Kernel 技術,以及在 Go 中應該如何去呼叫。

1、NameSpace

NameSpace 即名稱空間是 Linux Kernel 一個強大的特性,可用於程序間資源隔離。

由於容器之間共享 OS ,對於作業系統而言,容器的實質就是程序,多個容器執行,對應作業系統也就是執行著多個程序。

當程序執行在自己單獨的名稱空間時,名稱空間的資源隔離可以保證程序之間互不影響,大家都以為自己身處在獨立的一個作業系統裡。這種程序就可以稱為容器。

回到資源隔離上,從 Kernel: 5.6 版本開始,已經提供了 8 種 NameSpace ,這 8 種 NameSpace 可以對應地隔離不同的資源( Docker 主要使用了前 6 種)。

名稱空間 系統呼叫引數 作用
Mount (mnt) CLONE_NEWNS 檔案目錄掛載隔離。用於隔離各個程序看到的掛載點檢視
Process ID (pid) CLONE_NEWPID 程序 ID 隔離。使每個名稱空間都有自己的初始化程序,PID 為 1,作為所有程序的父程序
Network (net) CLONE_NEWNET 網路隔離。使每個 net 名稱空間有獨立的網路裝置,IP 地址,路由表,/proc/net 目錄等網路資源
Interprocess Communication (ipc) CLONE_NEWIPC 程序 IPC 通訊隔離。讓只有相同 IPC 名稱空間的程序之間才可以共享記憶體、訊號量、訊息佇列通訊
UTS CLONE_NEWUTS 主機名或域名隔離。使其在網路上可以被視作一個獨立的節點而非主機上的一個程序
User ID (user) CLONE_NEWUSER 使用者 UID 和組 GID 隔離。例如每個名稱空間都可以有自己的 root 使用者
Control group (cgroup) Namespace CLONE_NEWCGROUP Cgroup 資訊隔離。用於隱藏程序所屬的控制組的身份,使名稱空間中的 cgroup 檢視始終以根形式來呈現,保障安全
Time Namespace CLONE_NEWTIME 系統時間隔離。允許不同程序檢視到不同的系統時間

NameSpace 的具體描述可以檢視 Linux man 手冊中的 NAMESPACES[1] 章節,手冊中還描述了幾個 NameSpace API ,主要是和程序相關的系統呼叫函式。

clone()
int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...
                 /* pid_t *parent_tid, void *tls, pid_t *child_tid */ );

clone() 用於建立新程序,通過傳入一個或多個系統呼叫引數( flags 引數)可以創建出不同型別的 NameSpace ,並且子程序也將會成為這些 NameSpace 的成員。

setns()
int setns(int fd, int nstype);

setns() 用於將程序加入到一個現有的 Namespace 中。其中 fd 為檔案描述符,引用 /proc/[pid]/ns/ 目錄裡對應的檔案,nstype 代表 NameSpace 型別。

unshare()
int unshare(int flags);

unshare() 用於將程序移出原本的 NameSpace ,並加入到新建立的 NameSpace 中。同樣是通過傳入一個或多個系統呼叫引數( flags 引數)來建立新的 NameSpace 。

ioctl()
int ioctl(int fd, unsigned long request, ...);

ioctl() 用於發現有關 NameSpace 的資訊。

上面的這些系統呼叫函式,我們可以直接用 C 語言呼叫,創建出各種型別的 NameSpace ,這是最直觀的做法。而對於 Go 語言,其內部已經幫我們封裝好了這些函式操作,可以更方便地直接使用,降低心智負擔。

先來看一個簡單的小工具(源自 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)
 }
}

這個程式接收使用者命令列傳遞的引數,並使用 exec.Command 執行,例如當我們執行 go run main.go run echo hello 時,會創建出 main 程序, main 程序內執行 echo hello 命令創建出一個新的 echo 程序,最後隨著 echo 程序的執行完畢,main 程序也隨之結束並退出。

[root@host go]# go run main.go run echo hello
hello
[root@host go]#

但是上面建立的程序太快退出了,不便於我們觀察。如果讓 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 會話程序中,繼續下一步操作:

[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]#

在執行 go run main.go run /bin/bash 後,我們的會話被切換到了 PID 1228 的 bash 程序中,而 main 程序也還在執行著(當前所處的 bash 程序是 main 程序的子程序,main 程序必須存活著,才能維持 bash 程序的執行)。當執行 exit 退出當前所處的 bash 程序後,main 程序隨之結束,並回到原始的 PID 1115 的 bash 會話程序。

我們說過,容器的實質是程序,你現在可以把 main 程序當作是 “Docker” 工具,把 main 程序啟動的 bash 程序,當作一個 “容器” 。這裡的 “Docker” 建立並啟動了一個 “容器”。

為什麼打了雙引號,是因為在這個 bash 程序中,我們可以隨意使用作業系統的資源,並沒有做資源隔離。

要想實現資源隔離,也很簡單,在 run() 函式增加 SysProcAttr 配置,先從最簡單的 UTS 隔離開始,傳入對應的 CLONE_NEWUTS 系統呼叫引數,並通過 syscall.Sethostname 設定主機名:

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 這一行到底是哪個程序在執行?main 程序還是 main 程序建立的子程序?

不用想,子程序都還沒 Run 起來呢!現在呼叫肯定是 main 程序在執行,main 程序可沒進行資源隔離,相當於直接更改宿主機的主機名了。

子程序還沒 Run 起來,還不能更改主機名,等子程序 Run 起來後,又會進入到阻塞狀態,無法再通過程式碼方式更改到子程序內的主機名。那有什麼辦法呢?

看來只能把 /proc/self/exe 這個神器請出來了。

在 Linux 2.2 核心版本及其之後,/proc/[pid]/exe 是對應 pid 程序的二進位制檔案的符號連結,包含著被執行命令的實際路徑名。如果開啟這個檔案就相當於打開了對應的二進位制檔案,甚至可以通過重新輸入 /proc/[pid]/exe 重新執行一個對應於 pid 的二進位制檔案的程序。

對於 /proc/self ,當程序訪問這個神奇的符號連結時,可以解析到程序自己的 /proc/[pid] 目錄。

合起來就是,當程序訪問 /proc/self/exe 時,可以執行一個對應程序自身的二進位制檔案。

這有什麼用呢?繼續看下面的程式碼:

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() 函式中,我們不再是直接執行使用者所傳遞的命令列引數,而是執行 /proc/self/exe ,並傳入 child 引數和使用者傳遞的命令列引數。

同樣當執行 go run main.go run echo hello 時,會創建出 main 程序, main 程序內執行 /proc/self/exe child echo hello 命令創建出一個新的 exe 程序,關鍵也就是這個 exe 程序,我們已經為其配置了 CLONE_NEWUTS 系統呼叫引數進行 UTS 隔離。也就是說,exe 程序可以擁有和 main 程序不同的主機名,彼此互不干擾。

程序訪問 /proc/self/exe 代表著執行對應程序自身的二進位制檔案。因此,按照 exe 程序的啟動引數,會執行 child() 函式,而 child() 函式內首先呼叫 syscall.Sethostname 更改了主機名(此時是 exe 程序執行的,並不會影響到 main 程序),接著和本文最開始的 run() 函式一樣,再次使用 exec.Command 執行使用者命令列傳遞的引數。

總結一下就是, main 程序建立了 exe 程序(exe 程序已經進行 UTS 隔離,exe 程序更改主機名不會影響到 main 程序), 接著 exe 程序內執行 echo hello 命令創建出一個新的 echo 程序,最後隨著 echo 程序的執行完畢,exe 程序隨之結束,exe 程序結束後, main 程序再結束並退出。

那經過 exe 這個中間商所創建出來的 echo 程序和之前由 main 程序直接建立的 echo 程序,兩者有何不同呢。

我們知道,建立 exe 程序的同時我們傳遞了 CLONE_NEWUTS 識別符號建立了一個 UTS NameSpace ,Go 內部幫我們封裝了系統呼叫函式 clone() 的呼叫,我們也說過,由 clone() 函式創建出的程序的子程序也將會成為這些 NameSpace 的成員,所以預設情況下(建立新程序時無繼續指定系統呼叫引數),由 exe 程序創建出的 echo 程序會繼承 exe 程序的資源, echo 程序將擁有和 exe 程序相同的主機名,並且同樣和 main 程序互不干擾。

因此,藉助中間商 exe 程序 ,echo 程序可以成功實現和宿主機( main 程序)資源隔離,擁有不同的主機名。

再次通過啟動 /bin/bash 進行驗證主機名是否已經成功隔離:

[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]#

當執行 go run main.go run /bin/bash 時,我們也可以在另一個 ssh 會話中,使用 ps afx 檢視關於 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
......

以此類推,新增資源隔離只要繼續傳遞指定的系統呼叫引數即可:

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 引數新增了 CLONE_NEWPID 和 CLONE_NEWNS 分別隔離程序 pid 和檔案目錄掛載點檢視,Unshareflags: syscall.CLONE_NEWNS 則是用於禁用掛載傳播(如果不設定該引數,container 內的掛載會共享到 host ,掛載傳播不在本文的探討範圍內)。

當我們建立 PID Namespace 時,exe 程序包括其創建出來的子程序的 pid 已經和 main 程序隔離了,這一點可以通過列印 os.Getpid() 結果或執行 echo $$ 命令得到驗證。但此時還不能使用 ps 命令檢視,因為 ps 和 top 等命令會使用 /proc 的內容,所以我們才繼續引入了 Mount Namespace ,並在 exe 程序掛載 /proc 目錄。

Mount Namespace 是 Linux 第一個實現的 Namespace ,其系統呼叫引數是 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 ,創建出了 pid 4 的 bash 子程序,而且已經看不到 main 程序了。

剩下的 IPC 、NET、 USER 等 NameSpace 就不在本文一一展示了。

2、Cgroups

藉助 NameSpace 技術可以幫程序隔離出自己單獨的空間,成功實現出最簡容器。但是怎樣限制這些空間的物理資源開銷(CPU、記憶體、儲存、I/O 等)就需要利用 Cgroups 技術了。

限制容器的資源使用,是一個非常重要的功能,如果一個容器可以毫無節制的使用伺服器資源,那便又回到了傳統模式下將應用直接執行在物理伺服器上的弊端。這是容器化技術不能接受的。

Cgroups 的全稱是 Control groups 即控制組,最早是由 Google 的工程師(主要是 Paul Menage 和 Rohit Seth)在 2006 年發起,一開始叫做程序容器(process containers)。在 2007 年時,因為在 Linux Kernel 中,容器(container)這個名詞有許多不同的意義,為避免混亂,被重新命名為 cgroup ,並且被合併到 2.6.24 版本的核心中去。

Android 也是憑藉這個技術,為每個 APP 分配不同的 cgroup ,將每個 APP 進行隔離,而不會影響到其他的 APP 環境。

Cgroups 是對程序分組管理的一種機制,提供了對一組程序及它們的子程序的資源限制、控制和統計的能力,併為每種可以控制的資源定義了一個 subsystem (子系統)的方式進行統一介面管理,因此 subsystem 也被稱為 resource controllers (資源控制器)。

幾個主要的 subsystem 如下( Cgroups V1 ):

子系統 作用
cpu 限制程序的 cpu 使用率
cpuacct 統計程序的 cpu 使用情況
cpuset 在多核機器上為程序分配單獨的 cpu 節點或者記憶體節點(僅限 NUMA 架構)
memory 限制程序的 memory 使用量
blkio 控制程序對塊裝置(例如硬碟) io 的訪問
devices 控制程序對裝置的訪問
net_cls 標記程序的網路資料包,以便可以使用 tc 模組(traffic control)對資料包進行限流、監控等控制
net_prio 控制程序產生的網路流量的優先順序
freezer 掛起或者恢復程序
pids 限制 cgroup 的程序數量
更多子系統參考 Linux man cgroups[3]文件 https://man7.org/linux/man-pages/man7/cgroups.7.html

藉助 Cgroups 機制,可以將一組程序(task group)和一組 subsystem 關聯起來,達到控制程序對應關聯的資源的能力。如圖:

Cgroups 的層級結構稱為 hierarchy (即 cgroup 樹),是一棵樹,由 cgroup 節點組成。

系統可以有多個 hierarchy ,當建立新的 hierarchy 時,系統所有的程序都會加入到這個 hierarchy 預設建立的 root cgroup 根節點中,在樹中,子節點可以繼承父節點的屬性。

對於同一個 hierarchy,程序只能存在於其中一個 cgroup 節點中。如果把一個程序新增到同一個 hierarchy 中的另一個 cgroup 節點,則會從第一個 cgroup 節點中移除。

hierarchy 可以附加一個或多個 subsystem 來擁有對應資源(如 cpu 和 memory )的管理權,其中每一個 cgroup 節點都可以設定不同的資源限制權重,而程序( task )則繫結在 cgroup 節點中,並且其子程序也會預設繫結到父程序所在的 cgroup 節點中。

基於 Cgroups 的這些運作原理,可以得出:如果想限制某些程序的記憶體資源,就可以先建立一個 hierarchy ,併為其掛載 memory subsystem ,然後在這個 hierarchy 中建立一個 cgroup 節點,在這個節點中,將需要控制的程序 pid 和控制屬性寫入即可。

接下來我們就來實踐一下。

Linux 一切皆檔案。

在 Linux Kernel 中,為了讓 Cgroups 的配置更直觀,使用了目錄的層級關係來模擬 hierarchy ,以此通過虛擬的樹狀檔案系統的方式暴露給使用者呼叫。

建立一個 hierarchy ,併為其掛載 memory subsystem ,這一步我們可以跳過,因為系統已經預設為每個 subsystem 建立了一個預設的 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 目錄下建立一個資料夾,就相當於建立了一個 cgroup 節點:

[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]#

其中我們建立的 hello 資料夾內的所有檔案都是系統自動建立的。常用的幾個檔案功能如下:

檔名 功能
tasks cgroup 中執行的程序( PID)列表。將 PID 寫入一個 cgroup 的 tasks 檔案,可將此程序移至該 cgroup
cgroup.procs cgroup 中執行的執行緒群組列表( TGID )。將 TGID 寫入 cgroup 的 cgroup.procs 檔案,可將此執行緒組群移至該 cgroup
cgroup.event_control event_fd() 的介面。允許 cgroup 的變更狀態通知被髮送
notify_on_release 用於自動移除空 cgroup 。預設為禁用狀態(0)。設定為啟用狀態(1)時,當 cgroup 不再包含任何任務時(即,cgroup 的 tasks 檔案包含 PID,而 PID 被移除,致使檔案變空),kernel 會執行 release_agent 檔案(僅在 root cgroup 出現)的內容,並且提供通向被清空 cgroup 的相關路徑(與 root cgroup 相關)作為引數
memory.usage_in_bytes 顯示 cgroup 中程序當前所用的記憶體總量(以位元組為單位)
memory.memsw.usage_in_bytes 顯示 cgroup 中程序當前所用的記憶體量和 swap 空間總和(以位元組為單位)
memory.max_usage_in_bytes 顯示 cgroup 中程序所用的最大記憶體量(以位元組為單位)
memory.memsw.max_usage_in_bytes 顯示 cgroup 中程序的最大記憶體用量和最大 swap 空間用量(以位元組為單位)
memory.limit_in_bytes 設定使用者記憶體(包括檔案快取)的最大用量
memory.memsw.limit_in_bytes 設定記憶體與 swap 用量之和的最大值
memory.failcnt 顯示記憶體達到 memory.limit_in_bytes 設定的限制值的次數
memory.memsw.failcnt 顯示記憶體和 swap 空間總和達到 memory.memsw.limit_in_bytes 設定的限制值的次數
memory.oom_control 可以為 cgroup 啟用或者禁用“記憶體不足”(Out of Memory,OOM) 終止程式。預設為啟用狀態(0),嘗試消耗超過其允許記憶體的任務會被 OOM 終止程式立即終止。設定為禁用狀態(1)時,嘗試使用超過其允許記憶體的任務會被暫停,直到有額外記憶體可用。
更多檔案的功能說明可以檢視 kernel 文件中的 cgroup-v1/memory[4] https://www.kernel.org/doc/Documentation/cgroup-v1/memory.txt

在這個 hello cgroup 節點中,我們想限制某些程序的記憶體資源,只需將對應的程序 pid 寫入到 tasks 檔案,並把記憶體最大用量設定到 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 節點預設啟用了 OOM 終止程式,因此,當有程序嘗試使用超過可用記憶體時會被立即終止。查詢 memory.failcnt 可知,目前還沒有程序記憶體達到過設定的最大記憶體限制值。

我們已經設定了 hello cgroup 節點可使用的最大記憶體為 100M ,此時新啟動一個 bash 會話程序並將其移入到 hello cgroup 節點中:

[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]#

後續在此會話程序所建立的子程序都會加入到該 hello cgroup 節點中(例如 pid 4135 就是由於執行 cat 命令而建立的新程序,被系統自動加入到了 tasks 檔案中)。

繼續使用 memtester[5] 工具來測試 100M 的最大記憶體限制是否生效:

[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 記憶體時,失敗了,而 memory.failcnt 報告顯示記憶體達到 memory.limit_in_bytes 設定的限制值(100M)的次數為 1434 次。

如果想要刪除 cgroup 節點,也只需要刪除對應的資料夾即可。

[root@host hello]# exit
exit
[root@host hello]# cd ../
[root@host memory]# rmdir hello/
[root@host memory]#

經過上面對 Cgroups 的使用和實踐,可以將其應用到我們之前的 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() 函式的呼叫,程式碼相對簡單,和我們的實踐流程幾乎是一致的,區別只在於為 notify_on_release 檔案設定為 1 值,使得當我們的 exe 程序退出後,可以自動移除所建立的 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 測試和結果預期一致:

[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 也不在本文一一展示了。

其實到這裡,我們已經通過 NameSpace 技術幫程序隔離出自己單獨的空間,並使用 Cgroups 技術限制和監控這些空間的資源開銷,這種特殊的程序就是容器的本質。可以說,我們本篇文章的目的已達成,可以結束了。

但是除了利用 NameSpace 和 Cgroups 來實現 容器(container) ,在 Docker 中,還使用到了一個 Linux Kernel 技術:UnionFS 來實現 映象(images) 功能。

鑑於本篇文章的主旨 —— 使用 Go 和 Linux Kernel 技術探究容器化原理的主要技術點是 NameSpace 和 Cgroups 。映象的實現技術 UnionFS 屬於加餐內容,可自行選擇是否需要消化。

3、UnionFS

UnionFS 全稱 Union File System (聯合檔案系統),在 2004 年由紐約州立大學石溪分校開發,是為 Linux、FreeBSD 和 NetBSD 作業系統設計的一種分層、輕量級並且高效能的檔案系統,可以 把多個目錄內容聯合掛載到同一個目錄下 ,而目錄的物理位置是分開的,並且對檔案系統的修改是類似於 git 的 commit 一樣 作為一次提交來一層層的疊加的 。

在 Docker 中,映象相當於是容器的模板,一個映象可以衍生出多個容器。映象利用 UnionFS 技術來實現,就可以利用其 分層的特性 來進行映象的繼承,基於基礎映象,製作出各種具體的應用映象,不同容器就可以直接 共享基礎的檔案系統層 ,同時再加上自己獨有的改動層,大大提高了儲存的效率。

以該 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 中包含了多個命令,如果命令修改了檔案系統就會建立一個層(利用 UnionFS 的原理)。

首先 FROM 語句從 ubuntu:18.04 映象建立一個層 【1】,而 LABEL 命令僅修改映象的元資料,不會生成新映象層,接著 COPY 命令會把當前目錄中的檔案新增到映象中的 /app 目錄下,在層【1】的基礎上生成了層【2】。

第一個 RUN 命令使用 make 構建應用程式,並將結果寫入新層【3】。第二個 RUN 命令刪除快取目錄,並將結果寫入新層【4】。最後,CMD 指令指定在容器內執行什麼命令,只修改了映象的元資料,也不會產生映象層。

這【4】個層(layer)相互堆疊在一起就是一個映象。當建立一個新容器時,會在 映象層(image layers) 上面再新增一個新的可寫層,稱為 容器層(container layer) 。對正在執行的容器所做的所有更改,例如寫入新檔案、修改現有檔案和刪除檔案,都會寫入到這個可寫容器層。

對於相同的映象層,每一個容器都會有自己的可寫容器層,並且所有的變化都儲存在這個容器層中,所以多個容器可以共享對同一個底層映象的訪問,並且擁有自己的資料狀態。而當容器被刪除時,其可寫容器層也會被刪除,如果使用者需要持久化容器裡的資料,就需要使用 Volume 掛載到宿主機目錄。

看完 Docker 映象的運作原理,讓我們回到其實現技術 UnionFS 本身。

目前 Docker 支援的 UnionFS 有以下幾種型別:

聯合檔案系統 儲存驅動 說明
OverlayFS overlay2 當前所有受支援的 Linux 發行版的 首選 儲存驅動程式,並且不需要任何額外的配置
OverlayFS fuse-overlayfs 僅在不提供對 rootless 支援的主機上執行 Rootless Docker 時才首選
Btrfs 和 ZFS btrfs 和 zfs 允許使用高階選項,例如建立快照,但需要更多的維護和設定
VFS vfs 旨在用於測試目的,以及無法使用寫時複製檔案系統的情況下使用。此儲存驅動程式效能較差,一般不建議用於生產用途
AUFS aufs Docker 18.06 和更早版本的首選儲存驅動程式。但是在沒有 overlay2 驅動的機器上仍然會使用 aufs 作為 Docker 的預設驅動
Device Mapper devicemapper RHEL (舊核心版本不支援 overlay2,最新版本已支援)的 Docker Engine 的預設儲存驅動,有兩種配置模式:loop-lvm(零配置但效能差) 和 direct-lvm(生產環境推薦)
OverlayFS overlay 推薦使用 overlay2 儲存驅動

在儘可能的情況下,推薦使用 OverlayFS 的 overlay2 儲存驅動,這也是當前 Docker 預設的儲存驅動(以前是 AUFS 的 aufs )。

可檢視 Docker 使用了哪種儲存驅動:

[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 的現代聯合檔案系統,在 2014 年被合併到 Linux Kernel (version 3.18)中,相比 AUFS 其速度更快且實現更簡單。 overlay2 (Linux Kernel version 4.0 或以上)則是其推薦的驅動程式。

overlay2 由四個結構組成,其中:

  • lowerdir :表示較為底層的目錄,對應 Docker 中的只讀映象層
  • upperdir :表示較為上層的目錄,對應 Docker 中的可寫容器層
  • workdir :表示工作層(中間層)的目錄,在使用過程中對使用者不可見
  • merged :所有目錄合併後的聯合掛載點,給使用者暴露的統一目錄檢視,對應 Docker 中使用者實際看到的容器內的目錄檢視

這是在 Docker 文件中關於 overlay 的架構圖[7],但是對於 overlay2 也同樣可以適用:

其中 lowerdir 所對應的映象層( Image layer ),實際上是可以有很多層的,圖中只畫了一層。

細心的小夥伴可能會發現,圖中並沒有出現 workdir ,它究竟是如何工作的呢?

我們可以從讀寫的視角來理解,對於讀的情況:

  • 檔案在 upperdir ,直接讀取
  • 檔案不在 upperdir ,從 lowerdir 讀取,會產生非常小的效能開銷
  • 檔案同時存在 upperdir 和 lowerdir 中,從 upperdir 讀取(upperdir 中的檔案隱藏了 lowerdir 中的同名檔案)

對於寫的情況:

  • 建立一個新檔案,檔案在 upperdir 和 lowerdir 中都不存在,則直接在 upperdir 建立
  • 修改檔案,如果該檔案在 upperdir 中存在,則直接修改
  • 修改檔案,如果該檔案在 upperdir 中不存在,將執行 copy_up 操作,把檔案從 lowerdir 複製到 upperdir ,後續對該檔案的寫入操作將對已經複製到 upperdir 的副本檔案進行操作。這就是 寫時複製(copy-on-write)
  • 刪除檔案,如果檔案只在 upperdir 存在,則直接刪除
  • 刪除檔案,如果檔案只在 lowerdir 存在,會在 upperdir 中建立一個同名的空白檔案(whiteout file),lowerdir 中的檔案不會被刪除,因為他們是隻讀的,但 whiteout file 會阻止它們繼續顯示
  • 刪除檔案,如果檔案在 upperdir 和 lowerdir 中都存在,則先將 upperdir 中的檔案刪除,再建立一個同名的空白檔案(whiteout file)
  • 刪除目錄和刪除檔案是一致的,會在 upperdir 中建立一個同名的不透明的目錄(opaque directory),和 whiteout file 原理一樣,opaque directory 會阻止使用者繼續訪問,即便 lowerdir 內的目錄仍然存在

說了半天,好像還是沒有講到 workdir 的作用,這得理解一下,畢竟人家在使用過程中對使用者是不可見的。

但其實 workdir 的作用不可忽視。想象一下,在刪除檔案(或目錄)的場景下(檔案或目錄在 upperdir 和 lowerdir 中都存在),對於 lowerdir 而言,倒沒什麼,畢竟只讀,不需要理會,但是對於 upperdir 來講就不同了。在 upperdir 中,我們要先刪除對應的檔案,然後才可以建立同名的 whiteout file ,如何保證這兩步必須都執行,這就涉及到了原子性操作了。

workdir 是用來進行一些中間操作的,其中就包括了原子性保證。在上面的問題中,完全可以先在 workdir 建立一個同名的 whiteout file ,然後再在 upperdir 上執行兩步操作,成功之後,再刪除掉 workdir 中的 whiteout file 即可。

而當修改檔案時,workdir 也在充當著中間層的作用,當對 upperdir 裡面的副本進行修改時,會先放到 workdir ,然後再從 workdir 移到 upperdir 裡面去。

理解完 overlay2 運作原理,接下來正式進入到演示環節。

首先可以來看看在 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 目錄實際上就是容器內使用者看到的目錄檢視。

回到技術本身,我們可以自己來嘗試一下如何使用 mount 的 overlay 掛載選項[8] :

首先建立好 lowerdir(建立了 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 目錄內的檔案:

[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 內的檔案時,會執行 copy_up 操作,把檔案從 lowerdir 複製到 upperdir ,後續對該檔案的寫入操作將對已經複製到 upperdir 的副本檔案進行操作。

其它的讀寫情況,大家就可以自行嘗試了。

總結

其實容器的底層原理並不難,本質上就是一個特殊的程序,特殊在為其建立了 NameSpace 隔離執行環境,用 Cgroups 為其控制了資源開銷,這些都是站在 Linux 作業系統的肩膀上實現的,包括 Docker 的映象實現也是利用了 UnionFS 的分層聯合技術。

我們甚至可以說幾乎所有應用的本質都是 上層調下層 ,下層支撐著上層 。

網管叨bi叨 分享軟體開發和系統架構設計基礎、Go 語言和Kubernetes。 200篇原創內容 公眾號

參考資料

[1]

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 的架構圖: 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