Go 語言實現容器 namespace 和 cgroups

【導讀】go 語言如何實現容器虛擬化和資源隔離?本文做了詳細介紹。

這篇博客將通過例子介紹並實現容器 UTS, PID, MountPoint Namespace 的隔離和 Cgroups 最大進程數量的限制, 本例環境:Ubuntu 14.04, 內核 4.4.0-31-generic, 且需要 root 權限

Namespaces

Linux 內核實現了 namespace,進而實現了輕量級虛擬化服務,在同一個 namespace 下的進程可以感知彼此的變化,但是不能看到其他的進程,從而達到了環境隔離的目的。namespace 有 6 項隔離,分別是 UTS(Unix Time-sharing System, 主機和域名), IPC(InterProcess Comms, 信號量、消息隊列和共享內存), PID(Process IDs, 進程編號), Network(網絡設備,網絡棧,端口等), Mount(掛載點 [文件系統]), User(用戶和用戶組)。

C 語言中可以通過clone()指定flags參數,在創建進程的同時創建 namespace。Linux 內核版本 3.8 之後的用戶可以通過ls \-l /proc/?/ns查看當前進程指向的 namespace 編號。(?表示當前運行的進程 ID 號)

UTS

先創建一個 UTS 隔離的新進程,這裏使用了 Sirupsen 的 logrus 庫,可以通過go get github.com/sirupsen/logrus獲取

 1package main
 2import (
 3        "os"
 4        "os/exec"
 5        "syscall"
 6        "github.com/sirupsen/logrus"
 7)
 8func main() {
 9        if len(os.Args) < 2 {
10                logrus.Errorf("missing commands")
11                return
12        }
13        switch os.Args[1] {
14        case "run":
15                run()
16        default:
17                logrus.Errorf("wrong command")
18                return
19        }
20}
21func run() {
22        logrus.Infof("Running %v", os.Args[2:])
23        cmd := exec.Command(os.Args[2], os.Args[3:]...)
24        cmd.Stdin = os.Stdin
25        cmd.Stdout = os.Stdout
26        cmd.Stderr = os.Stderr
27        cmd.SysProcAttr = &syscall.SysProcAttr{
28                Cloneflags: syscall.CLONE_NEWUTS,
29        }
30        check(cmd.Run())
31}
32func check(err error) {
33        if err != nil {
34                logrus.Errorln(err)
35        }
36}
37
38

在 Linux 環境下執行

1$ go run main.go run sh
2INFO[0000] Running [sh]
3root@ubuntu-14:~/shared#
4
5

此時在一個新的進程中執行了sh命令,由於指定了 flag syscall.CLONE_NEWUTS, 此時已經與之前的進程不在同一個 UTS namespace 中了。在新 sh 和原 sh 中分別執行ls \-l /proc/?/ns進行驗證

原 sh:

 1$ ls -l /proc/?/ns
 2total 0
 3lrwxrwxrwx 1 root root 0 Sep  2 16:26 cgroup -> cgroup:[4026531835]
 4lrwxrwxrwx 1 root root 0 Sep  2 16:26 ipc -> ipc:[4026531839]
 5lrwxrwxrwx 1 root root 0 Sep  2 16:26 mnt -> mnt:[4026531840]
 6lrwxrwxrwx 1 root root 0 Sep  2 16:26 net -> net:[4026531957]
 7lrwxrwxrwx 1 root root 0 Sep  2 16:26 pid -> pid:[4026531836]
 8lrwxrwxrwx 1 root root 0 Sep  2 16:26 user -> user:[4026531837]
 9lrwxrwxrwx 1 root root 0 Sep  2 16:26 uts -> uts:[4026531838] 
10
11

新 sh:

 1root@ubuntu-14:~/shared# ls -l /proc/?/ns
 2total 0
 3lrwxrwxrwx 1 root root 0 Sep  2 16:26 cgroup -> cgroup:[4026531835]
 4lrwxrwxrwx 1 root root 0 Sep  2 16:26 ipc -> ipc:[4026531839]
 5lrwxrwxrwx 1 root root 0 Sep  2 16:26 mnt -> mnt:[4026531840]
 6lrwxrwxrwx 1 root root 0 Sep  2 16:26 net -> net:[4026531957]
 7lrwxrwxrwx 1 root root 0 Sep  2 16:26 pid -> pid:[4026531836]
 8lrwxrwxrwx 1 root root 0 Sep  2 16:26 user -> user:[4026531837]
 9lrwxrwxrwx 1 root root 0 Sep  2 16:26 uts -> uts:[4026532197] 
10
11

可以看到這裏兩個只有 uts 所指向的 ID 不同,因爲之前只指定 UTS 的隔離。在新 sh 中執行hostname newhost更改當前的 hostname, 可以看到這裏的 hostname 已經被改成了 newhost, 但是原來的 sh 中依然是 ubuntu-14, 同樣證明 UTS 隔離成功了。

爲了在啓動 sh 的同時就能夠將其 hostname 修改爲新的 hostname,下面將run()函數拆分成run()child()。將這個過程分成創建新的 namespace 和修改 hostname 兩步,這樣就可以保證修改 namespace 的時候已經在新的 namespace 中了,避免修改主機的 hostname。這裏的/proc/self/exe就是當前正在執行的命令,在這裏就是go run main.go

 1func run() {
 2        logrus.Info("Setting up...")
 3        cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
 4        cmd.Stdin = os.Stdin
 5        cmd.Stdout = os.Stdout
 6        cmd.Stderr = os.Stderr
 7        cmd.SysProcAttr = &syscall.SysProcAttr{
 8                Cloneflags: syscall.CLONE_NEWUTS,
 9        }
10        check(cmd.Run())
11}
12func child() {
13        logrus.Infof("Running %v", os.Args[2:])
14        cmd := exec.Command(os.Args[2], os.Args[3:]...)
15        cmd.Stdin = os.Stdin
16        cmd.Stdout = os.Stdout
17        cmd.Stderr = os.Stderr
18        check(syscall.Sethostname([]byte("newhost")))
19        check(cmd.Run())
20}
21
22

然後對main()函數進行相應的修改

 1func main() {
 2        if len(os.Args) < 2 {
 3                logrus.Errorf("missing commands")
 4                return
 5        }
 6        switch os.Args[1] {
 7        case "run":
 8                run()
 9        case "child":
10                child()
11        default:
12                logrus.Errorf("wrong command")
13                return
14        }
15}
16
17

再次執行命令可以看到進入時 hostname 已經是 newhost 了

1$ go run main.go run sh
2INFO[0000] Setting up...
3INFO[0000] Running [sh]
4root@newhost:~/shared#
5
6

PID

爲了進行 PID 的隔離將run()函數中cmd.SysProcAttr修改爲

1Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID,
2
3

此時再次運行,並執行ps查看當前進程,發現和主機上一樣,並沒有被隔離。這是因爲ps總是查看/proc,如果要進行隔離,則需要修改 root。

下面獲取一個 unix 文件系統,可以選擇 docker 的 busybox 鏡像,並將其導出。

1docker pull busybox
2docker run -d busybox top -b
3
4

此時獲得剛剛的容器的 containerID,然後執行

1docekr export -o busybox.tar <剛纔容器的ID>
2
3

即可在當前目錄下得到一個 busybox 的壓縮包,用如下命令解壓即可得到我們需要的文件系統

1mkdir busybox
2tar -xf busybox.tar -C busybox/
3
4

查看一下 busybox 目錄

1$ ls busybox
2bin  dev  etc  home  proc  root  sys  tmp  usr  var
3
4

接下來通過syscall.Chroot()將 root 修改爲 busybox 的目錄,然後在進入 shell 之後通過os.Chdir()切換到新的根目錄下,然後通過syscall.Mount("proc", "proc", "proc", 0, "")掛載虛擬文件系統proc(proc是一個僞文件系統,只存在於內存中,以文件系統的方式爲訪問系統內核數據的操作提供接口,/proc目錄下的文件記錄了正在運行的進程的相關信息), 運行結束之後還要卸載剛纔掛載的proc

修改之後的代碼

 1func child() {
 2        ...
 3        check(syscall.Sethostname([]byte("newhost")))
 4        check(syscall.Chroot("/root/busybox"))
 5        check(os.Chdir("/"))
 6        // func Mount(source string, target string, fstype string, flags uintptr, data string) (err error)
 7        // 前三個參數分別是文件系統的名字,掛載到的路徑,文件系統的類型
 8        check(syscall.Mount("proc", "proc", "proc", 0, ""))
 9        check(cmd.Run())
10        check(syscall.Unmount("proc", 0))
11}
12
13

修改之後再次執行,並使用ps查看當前 namespace 下進程的情況,得到了期望的狀態

 1go run test.go run sh
 2INFO[0000] Setting up...
 3INFO[0000] Running [sh]
 4/ # ps
 5PID   USER     TIME   COMMAND
 6    1 root       0:00 /proc/self/exe child sh
 7    4 root       0:00 sh
 8    5 root       0:00 ps
 9/ #
10
11

child()中再掛載一個tmpfs,將代碼改爲

1...
2check(syscall.Mount("proc", "proc", "proc", 0, ""))
3check(syscall.Mount("tempdir", "temp", "tmpfs", 0, ""))
4check(cmd.Run())
5check(syscall.Unmount("proc", 0))
6check(syscall.Unmount("temp", 0))
7
8

執行go run main.go run sh後使用mount查看已掛載的文件系統

1/ # mount
2proc on /proc type proc (rw,relatime)
3tempdir on /temp type tmpfs (rw,relatime) 
4
5

繼續執行touch /temp/HELLOtemp目錄下創建一個文件。然後在主機中執行ls /root/busybox/temp可以看到剛剛創建的文件。這是因爲現在還沒有添加掛載點的隔離。

Cloneflags更新爲Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,再次重複上面的步驟,主機中將不能再看到容器內創建的文件。這裏 mount point 的隔離所使用的 flag 是CLONE_NEWNS,因爲它是 Linux 實現的第一個 namespace, 人們也沒有意識到將來會有更多的 namespace。

此時在主機上再調用mount也不能看到容器中的掛載情況,但是可以通過/proc/<pid>/mounts這個文件查看。

在容器中執行sleep 1000創建一個耗時 1000 秒的進程。然後在主機上通過pidof sleep獲取這個進程的 pid,接下來查看這個進程的掛載情況。

1$ pidof sleep
24286
3$ cat /proc/4286/mounts
4proc /proc proc rw,relatime 0 0
5tempdir /temp tmpfs rw,relatime 0 0
6
7

/proc/<pid>/下的文件還記錄了這個進程的其他信息,比如/proc/<pid>/environ記錄了它的環境變量,可以通過cat /proc/<pid>/environ | tr '\n' '\0'查看,tr '\n' '\0'去掉字符間多餘的空格。

Cgroups

cgroups 可以用於限制 namespace 隔離起來的資源,爲資源設置權重,計算使用量,操控任務啓停

Cgroups 組件

資源限制

可以通過mount | grep cgroup查看已掛載的 subsystem。cgroup 相關的文件在/sys/fs/cgroup下,如果使用了 docker 的話在這個目錄下還會有一個docker目錄,其中是 docker 的 cgroup 的相關文件

定義一個新的函數cg(), 限制容器的最大進程數

 1func cg() {
 2        cgPath := "/sys/fs/cgroup/"
 3        pidsPath := filepath.Join(cgPath, "pids")
 4        // 在/sys/fs/cgroup/pids下創建container目錄
 5        os.Mkdir(filepath.Join(pidsPath, "container"), 0755)
 6        // 設置最大進程數目爲20
 7        check(ioutil.WriteFile(filepath.Join(pidsPath, "container/pids.max"), []byte("20"), 0700))
 8        // 將notify_on_release值設爲1,當cgroup不再包含任何任務的時候將執行release_agent的內容
 9        check(ioutil.WriteFile(filepath.Join(pidsPath, "container/notify_on_release"), []byte("1"), 0700))
10        // 加入當前正在執行的進程
11        check(ioutil.WriteFile(filepath.Join(pidsPath, "container/pids.procs"), []byte(strconv.Itoa(os.Getpid())), 0700))
12}
13
14

child()函數中調用cg()進行資源限制

1func child() {
2        ...
3        cmd := exec.Command(os.Args[2], os.Args[3:]...)
4        cg()
5        cmd.Stdin = os.Stdin
6        ...
7}
8
9

運行go run main.go run sh後在主機中的/sys/fs/cgroup/pids/container下可以看到剛剛進行的限制的內容。

編寫一個腳本進行測試。這裏將創建 100 個執行sleep的進程

1d() { sleep 1000; }
2for i in $(seq 1 100)
3do
4    echo "sleep $i\n"
5    d&
6done
7
8

下面在容器中執行這個腳本test.sh

 1/ # sh test.sh
 2sleep 1\n
 3sleep 2\n
 4sleep 3\n
 5sleep 4\n
 6sleep 5\n
 7sleep 6\n
 8sleep 7\n
 9sleep 8\n
10sleep 9\n
11sleep 10\n
12sleep 11\n
13sleep 12\n
14sleep 13\n
15sleep 14\n
16sleep 15\n
17test.sh: line 7: can't fork
18/ # test.shtest.shtest.shtest.shtest.shtest.shtest.shtest.shtest.sh: : : : : : line line line line : line : line 7777line 7line 7: : : : 7: : 7: can't forkline : can't forkcan't fork: can't forkcan't forkcan't forkcan't fork
197can't fork
20:
21can't fork
22test.sh: line 7: can't fork
23test.sh: line 7: can't fork
24test.sh: line 7: can't fork
25test.sh: line 7: can't fork
26test.sh: line 7: can't fork
27
28

可以看到在執行過程中只調用了 15 次sleep就被不能繼續執行了。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/khy2fEcehKrtVSDbjPXcJA