Docker 底層原理淺析

作者:vitovzhong,騰訊 TEG 應用開發工程師

容器的實質是進程,與宿主機上的其他進程是共用一個內核,但與直接在宿主機執行的進程不同,容器進程運行在屬於自己的獨立的命名空間。命名空間隔離了進程間的資源,使得 a,b 進程可以看到 S 資源,而 c 進程看不到。

1.  演進

對於統一開發、測試、生產環境的渴望,要遠遠早於 docker 的出現。我們先來了解一下在 docker 之前出現過哪些解決方案。

1.1 vagrant

Vagarant 是筆者最早接觸到的一個解決環境配置不統一的技術方案。它使用 Ruby 語言編寫,由 HashCorp 公司在 2010 年 1 月發佈。Vagrant 的底層是虛擬機,最開始選用的是 virtualbox。一個個已經配置好的虛擬機被稱作 box。用戶可自由在虛擬機內部的安裝依賴庫和軟件服務,並將 box 發佈。通過簡單的命令,就能夠拉取 box,將環境搭建起來。

// 拉取一個ubuntu12.04的box
$ vagrant init hashicorp/precise32

// 運行該虛擬機
$ vagrant up

// 查看當前本地都有哪些box
$ vagrant box list

如果需要運行多個服務,也可以通過編寫 vagrantfile,將相互依賴的服務一起運行,頗有如今 docker-compose 的味道。

config.vm.define("web") do |web|web.vm.box = "apache"
end
config.vm.define("db") do |db|db.vm.box = "mysql”
end

1.2 LXC (LinuX Container)

在 2008 年,Linux 2.6.24 將 cgroups 特性合入了主幹。Linux Container 是 Canonical 公司基於 namespace 和 cgroups 等技術,瞄準容器世界而開發的一個項目,目標就是要創造出運行在 Linux 系統中,並且隔離性良好的容器環境。當然它最早也就見於 Ubuntu 操作系統上。

2013 年,在 PyCon 大會上 Docker 正式面世。當時的 Docker 是在 Ubuntu 12.04 上開發實現的,只是基於 LXC 之上的一個工具,屏蔽掉了 LXC 的使用細節(類似於 vagrant 屏蔽了底層虛擬機),讓用戶可以一句  docker run  命令行便創建出自己的容器環境。

2.  技術發展

容器技術是操作系統層面的虛擬化技術,可以概括爲使用  Linux 內核的 cgroup,namespace 等技術,對進程進行的封裝隔離。早在  Docker 之前,Linux 就已經提供了今天的 Docker 所使用的那些基礎技術。Docker 一夜之間火爆全球,但技術上的積累並不是瞬間完成的。我們摘取其中幾個關鍵技術節點進行介紹。

2.1 Chroot

軟件主要分爲系統軟件和應用軟件,而容器中運行的程序並非系統軟件。容器中的進程實質上是運行在宿主機上,與宿主機上的其他進程共用一個內核。而每個應用軟件運行都需要有必要的環境,包括一些 lib 庫依賴之類的。所以,爲了避免不同應用程序的 lib 庫依賴衝突,很自然地我們會想是否可以把他們進行隔離,讓他們看到的庫是不一樣的。基於這個樸素的想法,1979 年, chroot 系統調用首次問世。來舉個例子感受一下。在 devcloud 上申請的雲主機,現在我的 home 目錄下準備好了一個 alpine 系統的 rootfs,如下:

在該目錄下執行:

chroot rootfs/ /bin/bash

然後將 / etc/os-release 打印出來,就看到是”Alpine Linux”,說明新運行的 bash 跟 devcloud 主機上的 rootfs 隔離了。

2.1 Namespace

簡單來說  namespace 是由 Linux 內核提供的,用於進程間資源隔離的一種技術,使得 a,b 進程可以看到 S 資源;而 c 進程看不到。它是在 2002 年 Linux 2.4.19 開始加入內核的特性,到 2013 年 Linux 3.8 中 user namespace 的引入,對於我們現在所熟知的容器所需的全部 namespace 就都實現了。

Linux 提供了多種 namespace,用於對多種不同資源進行隔離。容器的實質是進程,但與直接在宿主機執行的進程不同,容器進程運行在屬於自己的獨立的命名空間。因此容器可以擁有自己的 root 文件系統、自己的網絡配置、自己的進程空間,甚至自己的用戶 ID 空間。

還是來看一個簡單的例子,讓我們有個感性認識,namespace 到底是啥,在哪裏能直觀的看到。在 devcloud 雲主機上,執行:ls-l /proc/self/ns  看到的就是當前系統所支持的 namespace。

接着我們使用 unshare 命令,運行一個 bash,讓它不使用當前的 pid namespace:

unshare --pid --fork --mount-proc bash

然後運行: ps -a 看看當前 pid namespace 下的進程都有哪些:

在新起的 bash 上執行:ls -l /proc/self/ns, 發現當前 bash 的 pid namespace 與之前是不相同的。

既然 docker 就是基於內核的 namespace 特性來實現的,那麼我們可以簡單來認證一下,執行指令:

 docker run –pid host --rm -it alpine sh

運行一個簡單的 alpine 容器,讓它與主機共用同一個 pid namespace。然後在容器內部執行指令 ps -a 會發現進程數量與 devcloud 機器上的一樣;執行指令 ls -l /proc/self/ns/ 同樣會看到容器內部的 pid namespace 與 devcloud 機器上的也是一樣。

2.2 cgroups

cgroups 是 namespace 的一種,是爲了實現虛擬化而採取的資源管理機制,決定哪些分配給容器的資源可被我們管理,分配容器使用資源的多少。容器內的進程是運行在一個隔離的環境裏,使用起來,就好像是在一個獨立於宿主的系統下操作一樣。這種特性使得容器封裝的應用比直接在宿主運行更加安全。例如可以設定一個 memory 使用上限,一旦進程組(容器)使用的內存達到限額再申請內存,就會出發 OOM(out of memory),這樣就不會因爲某個進程消耗的內存過大而影響到其他進程的運行。

還是來看個例子感受一下。在 devcloud 機器上運行一個 apline 容器,限制只能使用前 2 個 CPU 且只能使用 1.5 個核:

docker run --rm -it --cpus "1.5" --cpuset-cpus 0,1 alpine

然後再開啓一個新的終端,先看看系統上有哪些資源是我們可以控制的:

cat /proc/cgroups

最左邊一側就是可以設置的資源了。接着我們需要找到這些控制資源分配的信息都放在哪個目錄下:

mount | grep cgroup

然後我們找到剛剛運行的 alpine 鏡像的 cgroups 配置:

cat /proc/`docker inspect --format='{{.State.Pid}}' $(docker ps -ql)`/cgroup

這樣,把二者拼接起來,就可以看到這個容器的資源配置了。我們先來驗證 cpu 的用量是否是 1.5 個核:

cat /sys/fs/cgroup/cpu,cpuacct/docker/c1f68e86241f9babb84a9556dfce84ec01e447bf1b8f918520de06656fa50ab4/cpu.cfs_period_us

輸出 100000,可以認爲是單位,然後再看配額:

cat /sys/fs/cgroup/cpu,cpuacct/docker/c1f68e86241f9babb84a9556dfce84ec01e447bf1b8f918520de06656fa50ab4/cpu.cfs_quota_us

輸出 150000,與單位相除正好是設置的 1.5 個核,接着驗證是否使用的是前兩個核心:

cat /sys/fs/cgroup/cpuset/docker/c1f68e86241f9babb84a9556dfce84ec01e447bf1b8f918520de06656fa50ab4/cpuset.cpus

輸出 0-1。

目前來看,容器的資源配置都是按照我們設定的來分配的,但實際真能在 CPU0-CPU1 上限制使用 1.5 個核嗎?我們先看一下當前 CPU 的用量:

docker stats $(docker ps -ql)

因爲沒有在 alpine 中運行程序,所以 CPU 用量爲 0,我們現在回到最開始執行 docker 指令的 alpine 終端,執行一個死循環:

i=0; while true; do i=i+i; done

再來觀察當前的 CPU 用量:

接近 1,但爲啥不是 1.5?因爲剛剛運行的死循環只能跑在一個核上,所以我們再打開一個終端,進入到 alpine 鏡像中,同樣執行死循環的指令,看到 CPU 用量穩定在了 1.5,說明資源的使用量確實是限制住了的。

現在我們對 docker 容器實現了進程間資源隔離的黑科技有了一定認識。如果單單就隔離性來說,vagrant 也已經做到了。那麼爲什麼是 docker 火爆全球?是因爲它允許用戶將容器環境打包成爲一個鏡像進行分發,而且鏡像是分層增量構建的,這可以大大降低用戶使用的門檻。

3.  存儲

Image 是 Docker 部署的基本單位,它包含了程序文件,以及這個程序依賴的資源的環境。Docker Image 是以一個 mount 點掛載到容器內部的。容器可以近似理解爲鏡像的運行時實例,默認情況下也算是在鏡像層的基礎上增加了一個可寫層。所以,一般情況下如果你在容器內做出的修改,均包含在這個可寫層中。

3.1  聯合文件系統(UFS)

Union File System 從字面意思上來理解就是 “聯合文件系統”。它將多個物理位置不同的文件目錄聯合起來,掛載到某一個目錄下,形成一個抽象的文件系統。

如上圖,從右側以 UFS 的視角來看,lowerdir 和 upperdir 是兩個不同的目錄,UFS 將二者合併起來,得到 merged 層展示給調用方。從左側的 docker 角度來理解,lowerdir 就是鏡像,upperdir 就相當於是容器默認的可寫層。在運行的容器中修改了文件,可以使用 docker commit 指令保存成爲一個新鏡像。

3.2 Docker 鏡像的存儲管理

有了 UFS 的分層概念,我們就很好理解這樣的一個簡單 Dockerfile:

FROM alpine
COPY foo /foo
COPY bar /bar

在構建時的輸出所代表的含義了。

但是使用 docker pull 拉取的鏡像文件,在本地機器上存儲在哪,又是如何管理的呢?還是來實際操作認證一下。在 devcloud 上確認當前 docker 所使用的存儲驅動(默認是 overlay2):

docker info --format '{{.Driver}}'

以及鏡像下載後的存儲路徑(默認存儲在 / var/lib/docker):

docker info --format '{{.DockerRootDir}}'

當前我的 docker 修改了默認存儲路徑,配置到 / data/docker-data,我們就以它爲例進行展示。先查看一下該目錄下的結構:

tree -L 1 /data/docker-data

關注一下其中的 image 和 overlay2 目錄。前者就是存放鏡像信息的地方,後者則是存放具體每一分層的文件內容。我們先深入看一下 image 目錄結構:

tree -L 2 /data/docker-data/image/

留心這個 imagedb 目錄,接下來以我們以最新的 alpine 鏡像爲例子,看看 docker 是如何管理鏡像的。執行指令:

docker pull alpine:latest

緊接着查看它的鏡像 ID:docker image ls alpine:latest

記住這個 ID a24bb4013296,現在可以看一下 imagedb 目錄下的變化:

tree -L 2 /data/docker-data/image/overlay2/imagedb/content/ | grep
a24bb4013296

多了這麼一個鏡像 ID 的文件,它是一個 json 格式的文件,這裏包含了該鏡像的參數信息:

jq .
/data/docker-data/image/overlay2/imagedb/content/sha256/a24bb4013296f61e89ba57005a7b3e52274d8edd3ae2077d04395f806b63d83e

接下來我們看看將一個鏡像運行起來之後會有什麼變化。運行一個 alpine 容器,讓它 sleep10 分鐘:

docker run --rm -d alpine sleep 600

然後找到它的 overlay 掛載點:

docker inspect --format='{{.GraphDriver.Data}}' $(docker ps -ql) | grep MergedDir

結合上一節講到的 UFS 文件系統,可以 ls 一下:

ls /data/docker-data/overlay2/74e92699164736980c9e20475388568f482671625a177cb946c4b136e4d94a64/merged

看到它就是合併後所呈現在 alpine 容器的文件系統。先進入到容器內:

docker exec -it $(docker ps -ql) sh

緊接着新開一個終端查看容器運行起來後跟鏡像相比,有哪些修改:

docker diff $(docker ps -ql)

在 / root 目錄下,增加了 sh 的歷史記錄文件。然後我們在容器中手動增加一個 hello.txt 文件:

echo 'Hello Docker' > hello.txt

這時候來看看容器默認在鏡像之上增加的可寫層 UpperDir 目錄的變化:

ls /data/docker-data/overlay2/74e92699164736980c9e20475388568f482671625a177cb946c4b136e4d94a64/diff

這就認證了 overlay2 驅動是將鏡像和可寫層的內容 merged 之後,供容器作爲文件系統使用。多個運行的容器共用一份基礎鏡像,而各自有獨立的可寫層,節省了存儲空間。

這個時候,我們也可以回答一下鏡像的實際內容是存儲在哪裏呢:

cat /data/docker-data/overlay2/74e92699164736980c9e20475388568f482671625a177cb946c4b136e4d94a64/lower

查看這些分層:

ls /data/docker-data/overlay2/l/ZIIZFSQUQ4CIKRNCMOXXY4VZHY/

就是 UFS 中低層的鏡像內容。

總結

這一次跟大家分享了 Docker 所使用的底層技術,包括 namespace,cgroups 和 overlay2 聯合文件系統,着重介紹了隔離環境是如何在宿主機上演進實現的。通過實際手動操作,對這些概念有了真實的感受。希望下一次爲大家再介紹 docker 的網絡實現機制。

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