容器其實是一種沙盒技術。顧名思義,沙盒就是能夠像一個集裝箱一樣,把你的應用“裝”起來的技術。這樣,應用與應用之間,就因為有了邊界而不至於相互干擾;而被裝進集裝箱的應用,也可以被方便地搬來搬去,這不就是+PaaS+最理想的狀態嘛。
不過,這兩個能力說起來簡單,但要用技術手段去實現它們,可能大多數人就無從下手了。
所以,我就先來跟你說說這個“邊界”的實現手段
假如,現在你要寫一個計算加法的小程序,這個程序需要的輸入來自於一個文件,計算完成后的結果則輸出到另一個文件中。
由於計算機只認識 0 和 1,所以無論用哪種語言編寫這段代碼,最后都需要通過某種方式翻譯成二進制文件,才能在計算機操作系統中運行起來。
而為了能夠讓這些代碼正常運行,我們往往還要給它提供數據,比如我們這個加法程序所需要的輸入文件。這些數據加上代碼本身的二進制文件,放在磁盤上,就是我們平常所說的一個“程序”,也叫代碼的可執行鏡像(executable image)。
然后,我們就可以在計算機上運行這個“程序”了。
首先,操作系統從“程序”中發現輸入數據保存在一個文件中,所以這些數據就被會加載到內存中待命。同時,操作系統又讀取到了計算加法的指令,這時,它就需要指示 CPU 完成加法操作。而 CPU 與內存協作進行加法計算,又會使用寄存器存放數值、內存堆棧保存執行的命令和變量。同時,計算機里還有被打開的文件,以及各種各樣的 I/O 設備在不斷地調用中修改自己的狀態。
就這樣,一旦“程序”被執行起來,它就從磁盤上的二進制文件,變成了計算機內存中的數據、寄存器里的值、堆棧中的指令、被打開的文件,以及各種設備的狀態信息的一個集合。像這樣一個程序運起來后的計算機執行環境的總和,就是我們今天的主角:進程。
所以,對於進程來說,它的靜態表現就是程序,平常都安安靜靜地待在磁盤上;而一旦運行起來,它就變成了計算機里的數據和狀態的總和,這就是它的動態表現。
而容器技術的核心功能,就是通過約束和修改進程的動態表現,從而為其創造出一個“邊界”。
對於 Docker 等大多數 Linux 容器來說,Cgroups 技術是用來制造約束的主要手段,而 Namespace 技術則是用來修改進程視圖的主要方法。
你可能會覺得 Cgroups 和 Namespace 這兩個概念很抽象,別擔心,接下來我們一起動手實踐一下,你就很容易理解這兩項技術了。
假設你已經有了一個 Linux 操作系統上的 Docker 項目在運行,比如我的環境是 Ubuntu 16.04 和 Docker CE 17.03。
接下來,讓我們首先創建一個容器來試試
$ docker run -it busybox /bin/sh
這個命令是 Docker 項目最重要的一個操作,即大名鼎鼎的 docker run。 而 it 參數告訴了 Docker 項目在啟動容器后,需要給我們分配一個文本輸入 / 輸出環境,也就是 TTY,跟容器的標准輸入相關聯,這樣我們就可以和這個 Docker 容器進行交互了。而 /bin/sh 就是我們要在 Docker 容器里運行的程序。
所以,上面這條指令翻譯成人類的語言就是:請幫我啟動一個容器,在容器里執行 /bin/sh,並且給我分配一個命令行終端跟這個容器交互。
這樣,我的 Ubuntu 16.04 機器就變成了一個宿主機,而一個運行着 /bin/sh 的容器,就跑在了這個宿主機里面。
上面的例子和原理,如果你已經玩過 Docker,一定不會感到陌生。此時,如果我們在容器里執行一下 ps 指令,就會發現一些更有趣的事情:
/ # ps PID USER TIME COMMAND 1 root 0:00 /bin/sh 10 root 0:00 ps
可以看到,我們在 Docker 里最開始執行的/bin/sh,就是這個容器內部的第 1 號進程(PID=1),而這個容器里一共只有兩個進程在運行。這就意味着,前面執行的/bin/sh,以及我們剛剛執行的 ps,已經被 Docker 隔離在了一個跟宿主機完全不同的世界當中。
這究竟是怎么做到呢?
本來,每當我們在宿主機上運行了一個 /bin/sh 程序,操作系統都會給它分配一個進程編號,比如PID=100。這個編號是進程的唯一標識,就像員工的工牌一樣。所以PID=100,可以粗略地理解為這個 /bin/sh 是我們公司里的第 100 號員工,而第 1 號員工就自然是比爾+·+蓋茨這樣統領全局的人物。
而現在,我們要通過 Docker 把這個 bin/sh 程序運行在一個容器當中。這時候,Docker 就會在這個第 100 號員工入職時給他施一個“障眼法”,讓他永遠看不到前面的其他 99 個員工,更看不到比爾+·+蓋茨。這樣,他就會錯誤地以為自己就是公司里的第 1 號員工。
這種機制,其實就是對被隔離應用的進程空間做了手腳,使得這些進程只能看到重新計算過的進程編號,比如 PID=1。可實際上,他們在宿主機的操作系統里,還是原來的第 100 號進程。
這種技術,就是 Linux 里面的 Namespace 機制。而 Namespace 的使用方式也非常有意思:它其實只是 Linux 創建新進程的一個可選參數。我們知道,在 Linux 系統中創建線程的系統調用是 clone(),比如:
int pid = clone(main_function, stack_size, SIGCHLD, NULL);
這個系統調用就會為我們創建一個新的進程,並且返回它的進程號 pid。
而當我們用 clone() 系統調用創建一個新進程時,就可以在參數中指定 CLONE_NEWPID 參數,比如:
int pid = clone(main_function, stack_size, SIGCHLD, NULL);
這個系統調用就會為我們創建一個新的進程,並且返回它的進程號 pid。
而當我們用 clone() 系統調用創建一個新進程時,就可以在參數中指定 CLONE_NEWPID 參數,
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);
這時,新創建的這個進程將會“看到”一個全新的進程空間,在這個進程空間里,它的 PID 是 1。之所以說“看到”,是因為這只是一個“障眼法”,在宿主機真實的進程空間里,這個進程的 PID 還是真實的數值,比如 100。
當然,我們還可以多次執行上面的 clone() 調用,這樣就會創建多個 PID Namespace,而每個 Namespace 里的應用進程,都會認為自己是當前容器里的第 1 號進程,它們既看不到宿主機里真正的進程空間,也看不到其他 PID Namespace 里的具體情況。
而除了我們剛剛用到的 PID Namespace,Linux 操作系統還提供了 Mount、UTS、IPC、Networ 和 User 這些 Namespace,用來對各種不同的進程上下文進行“障眼法”操作。
比如,Mount Namespace,用於讓被隔離進程只看到當前 Namespace 里的掛載點信息;Network Namespace,用於讓被隔離進程看到當前 Namespace 里的網絡設備和配置。
這就是 Linux 容器最基本的實現原理了。
所以,Docker 容器這個聽起來玄而又玄的概念,實際上是在創建容器進程時,指定了這個進程所需要啟用的一組 Namespace 參數。這樣,容器就只能“看”到當前 Namespace 所限定的資源、文件、設備、狀態,或者配置。而對於宿主機以及其他不相關的程序,它就完全看不到了。
所以說,容器,其實是一種特殊的進程而已。+總結+談到為“進程划分一個獨立空間”的思想,相信你一定會聯想到虛擬機。而且,你應該還看過一張虛擬機和容器的對比圖。
所以,在這個對比圖里,我們應該把 Docker 畫在跟應用同級別並且靠邊的位置。這意味着,用戶運行在容器里的應用進程,跟宿主機上的其他進程一樣,都由宿主機操作系統統一管理,只不過這些被隔離的進程擁有額外設置過的 Namespace 參數。而 Docker 項目在這里扮演的角色,更多的是旁路式的輔助和管理工作。
我在后續分享 CRI 和容器運行時的時候還會專門介紹到,其實像+Docker+這樣的角色甚至可以去掉。
這樣的架構也解釋了為什么 Docker 項目比虛擬機更受歡迎的原因。
這是因為,使用虛擬化技術作為應用沙盒,就必須要由 Hypervisor 來負責創建虛擬機,這個虛擬機是真實存在的,並且它里面必須運行一個完整的 Guest OS 才能執行用戶的應用進程。這就不可避免地帶來了額外的資源消耗和占用。
根據實驗,一個運行着 CentOS 的 KVM 虛擬機啟動后,在不做優化的情況下,虛擬機自己就需要占用 100~200 MB 內存。此外,用戶應用運行在虛擬機里面,它對宿主機操作系統的調用就不可避免地要經過虛擬化軟件的攔截和處理,這本身又是一層性能損耗,尤其對計算資源、網絡和磁盤 I/o 的損耗非常大。
而相比之下,容器化后的用戶應用,卻依然還是一個宿主機上的普通進程,這就意味着這些因為虛擬化而帶來的性能損耗都是不存在的;而另一方面,使用 Namespace 作為隔離手段的容器並不需要單獨的 Guest OS,這就使得容器額外的資源占用幾乎可以忽略不計。
所以說,“敏捷”和“高性能”是容器相較於虛擬機最大的優勢,也是它能夠在 PaaS 這種更細粒度的資源管理平台上大行其道的重要原因。
不過,有利就有弊,基於 Linux Namespace 的隔離機制相比於虛擬化技術也有很多不足之處,其中最主要的問題就是:隔離得不徹底。
首先,既然容器只是運行在宿主機上的一種特殊的進程,那么多個容器之間使用的就還是同一個宿主機的操作系統內核。
盡管你可以在容器里通過 Mount Namespace 單獨掛載其他不同版本的操作系統文件,比如 CentOS 或者 Ubuntu,但這並不能改變共享宿主機內核的事實。這意味着,如果你要在 Windows 宿主機上運行 Linux 容器,或者在低版本的 Linux 宿主機上運行高版本的 Linux 容器,都是行不通的。
而相比之下,擁有硬件虛擬化技術和獨立 Guest OS 的虛擬機就要方便得多了。最極端的例子是,Microsoft 的雲計算平台 Azure,實際上就是運行在 Windows 服務器集群上的,但這並不妨礙你在它上面創建各種 Linux 虛擬機出來。
其次,在 Linux 內核中,有很多資源和對象是不能被 Namespace 化的,最典型的例子就是:時間。
這就意味着,如果你的容器中的程序使用+settimeofday(2) 系統調用修改了時間,整個宿主機的時間都會被隨之修改,這顯然不符合用戶的預期。相比於在虛擬機里面可以隨便折騰的自由度,在容器里部署應用的時候,“什么能做,什么不能做”,就是用戶必須考慮的一個問題。
此外,由於上述問題,尤其是共享宿主機內核的事實,容器給應用暴露出來的攻擊面是相當大的,應用“越獄”的難度自然也比虛擬機低得多。
更為棘手的是,盡管在實踐中我們確實可以使用 Seccomp 等技術,對容器內部發起的所有系統調用進行過濾和甄別來進行安全加固,但這種方法因為多了一層對系統調用的過濾,一定會拖累容器的性能。何況,默認情況下,誰也不知道到底該開啟哪些系統調用,禁止哪些系統調用。
所以,在生產環境中,沒有人敢把運行在物理機上的 Linux 容器直接暴露到公網上。當然,我后續會講到的基於虛擬化或者獨立內核技術的容器實現,則可以比較好地在隔離與性能之間做出平衡。
在介紹完容器的“隔離”技術之后,我們再來研究一下容器的“限制”問題。
也許你會好奇,我們不是已經通過 Linux Namespace 創建了一個“容器”嗎,為什么還需要對容器做“限制”呢?
我還是以 PID Namespace 為例,來給你解釋這個問題。
雖然容器內的第 1 號進程在“障眼法”的干擾下只能看到容器里的情況,但是宿主機上,它作為第 100 號進程與其他所有進程之間依然是平等的競爭關系。這就意味着,雖然第 100 號進程表面上被隔離了起來,但是它所能夠使用到的資源(比如 CPU、內存),卻是可以隨時被宿主機上的其他進程(或者其他容器)占用的。當然,這個 100 號進程自己也可能把所有資源吃光。這些情況,顯然都不是一個“沙盒”應該表現出來的合理行為。
而Linux Cgroups 就是 Linux 內核中用來為進程設置資源限制的一個重要功能。
有意思的是,Google+的工程師在+2006+年發起這項特性的時候,曾將它命名為“進程容器”(process container)。實際上,在 Google 內部,“容器”這個術語長期以來都被用於形容被 Cgroups+限制過的進程組。后來 Google 的工程師們說,他們的 KVM 虛擬機也運行在 Borg 所管理的“容器”里,其實也是運行在 Cgroups“容器”當中。這和我們今天說的 Docker 容器差別很大。
Linux Cgroups 的全稱是 Linux Control Group。它最主要的作用,就是限制一個進程組能夠使用的資源上限,包括 CPU、內存、磁盤、網絡帶寬等等。
此外,Cgroups+還能夠對進程進行優先級設置、審計,以及將進程掛起和恢復等操作。在今天的分享中,我只和你重點探討它與容器關系最緊密的“限制”能力,並通過一組實踐來帶你認識一下 Cgroups。+在 Linux 中,Cgroups+給用戶暴露出來的操作接口是文件系統,即它以文件和目錄的方式組織在操作系統的 /sys/fs/group 路徑下。在 Ubuntu 16.04 機器里,我可以用 mount 指令把它們展示出來,這條命令是:
$ mount -t cgroup cpuset on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset) cpu on /sys/fs/cgroup/cpu type cgroup (rw,nosuid,nodev,noexec,relatime,cpu) cpuacct on /sys/fs/cgroup/cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct) blkio on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio) memory on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory) ...
它的輸出結果,是一系列文件系統目錄。如果你在自己的機器上沒有看到這些目錄,那你就需要自己去掛載 Cgroups,具體做法可以自行+Google。
可以看到,在 /sys/fs/cgroup 下面有很多諸如 cpuset、cpu、memory 這樣的子目錄,也叫子系統。這些都是我這台機器當前可以被 Cgroups 進行限制的資源種類。而在子系統對應的資源種類下,你就可以看到該類資源具體可以被限制的方法。比如,對 CPU 子系統來說,我們就可以看到如下幾個配置文件,這個指令是:
$ ls /sys/fs/cgroup/cpu cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us cpu.shares notify_on_release cgroup.procs cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat tasks
如果熟悉 Linux CPU 管理的話,你就會在它的輸出里注意到 cfs_period 和 cfs_quota 這樣的關鍵詞。這兩個參數需要組合使用,可以用來限制進程在長度為 cfs_period 的一段時間內,只能被分配到總量為 cfs_quota 的 CPU 時間。
而這樣的配置文件又如何使用呢?
你需要在對應的子系統下面創建一個目錄,比如,我們現在進入 /sys/fs/cgroup/cpu 目錄下:
root@ubuntu:/sys/fs/cgroup/cpu$ mkdir container root@ubuntu:/sys/fs/cgroup/cpu$ ls container/ cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us cpu.shares notify_on_release cgroup.procs cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat tasks
這個目錄就稱為一個“控制組”。你會發現,操作系統會在你新創建的 container 目錄下,自動生成該子系統對應的資源限制文件。
現在,我們在后台執行這樣一條腳本:
$ while : ; do : ; done & [1] 226
顯然,它執行了一個死循環,可以把計算機的 CPU 吃到 100% ,根據它的輸出,我們可以看到這個腳本在后台運行的進程號(PID)是 226。
這樣,我們可以用+top+指令來確認一下 CPU 有沒有被打滿:
$ top %Cpu0 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
在輸出里可以看到,CPU 的使用率已經 100%2 了(%Cpu0:100.0us)。
而此時,我們可以通過查看 container 目錄下的文件,看到 container 控制組里的 CPU quota 還沒有任何限制(即:-1),CPU period 則是默認的 100 ms(100000 us):
$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us -1 $ cat /sys/fs/cgroup/cpu/container/cpu.cfs_period_us 100000
結合前面的介紹,你應該能明白這個操作的含義,它意味着在每 100 ms 的時間里,被該控制組限制的進程只能使用 20 m 的 CPU 時間,也就是說這個進程只能使用到 20% 的 CPU 帶寬。
接下來,我們把被限制的進程的 PID 寫入 container 組里的 tasks 文件,上面的設置就會對該進程生效了:
$ echo 226 > /sys/fs/cgroup/cpu/container/tasks
我們可以用+top+指令查看一下:
$ top %Cpu0 : 20.3 us, 0.0 sy, 0.0 ni, 79.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
可以看到,計算機的 CPU 使用率立刻降到了 20% (% Cpu: us)。
除 CPU 子系統外,Cgroups 的每一項子系統都有其獨有的資源限制能力,比如:
- blkio,為塊設備設定I/0限制,一般用於磁盤等設備;
- cpuset,為進程分配單獨的 CPU 核和對應的內存節點;
- memory,為進程設定內存使用的限制。
Linux Cgroups 的設計還是比較易用的,簡單粗暴地理解呢,它就是一個子系統目錄加上一組資源限制文件的組合。而對於 Docker 等 Linux 容器項目來說,它們只需要在每個子系統下面,為每個容器創建一個控制組(即創建一個新目錄),然后在啟動容器進程之后,把這個進程的 PID 填寫到對應控制組的 tasks 文件中就可以了。
而至於在這些控制組下面的資源文件里填上什么值,就靠用戶執行 docker run 時的參數指定了,比如這樣一條命令:
$ docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash
在啟動這個容器后,我們可以通過查看 Cgroups 文件系統下,CPU 子系統中,“docker”這個控制組里的資源限制文件的內容來確認
$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_period_us 100000 $ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_quota_us 20000
這就意味着這個 Docker 容器,只能使用到 20% 的 CPU 帶寬。