Docker介紹
Docker的英文翻譯是“搬運工”,他搬運的東西就是常說的集裝箱Container,Container 里面裝的是任意類型的 App,開發人員可以通過 Docker 將App 變成一種標准化的、可移植的、自管理的組件,可以在任何主流的操作系統中開發、調試和運行。
Docker其實是容器化技術的具體技術實現之一,采用go語言開發。很多人剛接觸Docker時,認為它就是一種更輕量級的虛擬機,這種認識其實是錯誤的,Docker和虛擬機有本質的區別。容器本質上講就是運行在操作系統上的一個進程,只不過加入了對資源的隔離和限制。而Docker是基於容器的這個設計思想,基於Linux Container技術實現的核心管理引擎。
Docker 組件
Docker 使用 C/S (客戶端/服務器)體系的架構,Docker 客戶端與 Docker 守護進程通信,Docker 守護進程負責構建,運行和分發 Docker 容器。
Docker 客戶端和守護進程可以在同一個系統上運行,也可以將 Docker 客戶端連接到遠程 Docker 守護進程。Docker 客戶端和守護進程使用 REST API 通過UNIX套接字或網絡接口進行通信。
Docker Damon
docker daemon(dockerd),一般也會被稱為 docker engine。,用來監聽 Docker API 的請求和管理 Docker 對象,比如鏡像、容器、網絡和 Volume。
Docker Client
docker client 是 和 Docker 進行交互的最主要的方式方法,比如 可以通過 docker run 命令來運行一個容器,然后 的這個 client 會把命令發送給上面的 Dockerd,讓他來做真正事情。
Containerd-shim
它是 containerd 的組件,是容器的運行時載體,主要是用於剝離 containerd 守護進程與容器進程,引入shim,允許runc 在創建和運行容器之后退出,並將 shim 作為容器的父進程,而不是 containerd 作為父進程,這樣做的目的是當 containerd 進程掛掉,而 shim 還正常運行,可以保證容器不受影響。此外,shim 也可以收集和報告容器的退出狀態,不需要 containerd 來 wait 容器進程。 在 docker 宿主機上看到的 shim 也正是代表着一個個通過調用 containerd 啟動的 docker 容器。
該程序的安裝路徑為:/usr/bin/docker-containerd-shim
Containerd
在宿主機中管理完整的容器生命周期:容器鏡像的傳輸和存儲、容器的執行和管理、存儲和網絡等。
該程序的安裝路徑為:/usr/bin/docker-containerd
RunC
RunC 是一個輕量級的工具,用來運行容器,容器作為 runC 的子進程開啟,在不需要運行一個 Docker daemon 的情況下可以嵌入到其他各種系統,也就是說可以不用通過 docker 引擎,直接運行容器。docker是通過Containerd調用 runC 運行容器的,該程序的安裝路徑為:/usr/bin/docker-runc
Docker Registry
用來存儲 Docker 鏡像的倉庫,Docker Hub 是 Docker 官方提供的一個公共倉庫,而且 Docker 默認也是從 Docker Hub 上查找鏡像的,當然 也可以很方便的運行一個私有倉庫,當 使用 docker pull 或者 docker run 命令時,就會從 配置的 Docker 鏡像倉庫中去拉取鏡像,使用 docker push 命令時,會將 構建的鏡像推送到對應的鏡像倉庫中。
Images
鏡像(Image)就是一堆只讀層(read-only layer)的統一視角
左邊有多個只讀層,它們重疊在一起。除了最下面一層,其它層都會有一個指針指向下一層。這些層是Docker內部的實現細節,並且能夠在主機(運行Docker的機器)的文件系統上訪問到。
統一文件系統(union file system)技術能夠將不同的層整合成一個文件系統,為這些層提供了一個統一的視角,這樣就隱藏了多層的存在,在用戶的角度看來,只存在一個文件系統。 可以在圖片的右邊看到這個視角的形式。
在主機文件系統上找到有關這些層的文件。需要注意的是,在一個運行中的容器內部,這些層是不可見的。存在於/var/lib/docker/aufs
目錄下。
Container
容器是一個鏡像的可運行的實例,可以使用 Docker REST API 或者 CLI 來操作容器,容器的實質是進程,但與直接在宿主執行的進程不同,容器進程運行於屬於自己的獨立的命名空間。因此容器可以擁有自己的 root 文件系統、自己的網絡配置、自己的進程空間,甚至自己的用戶 ID 空間。容器內的進程是運行在一個隔離的環境里,使用起來,就好像是在一個獨立於宿主的系統下操作一樣。這種特性使得容器封裝的應用比直接在宿主運行更加安全。
容器的定義和鏡像幾乎一模一樣,也是一堆層的統一視角,唯一區別在於容器的最上面那一層是可讀可寫的。
底層技術支持
Namespaces(做隔離)、CGroups(做資源限制)、UnionFS(鏡像和容器的分層) the-underlying-technology Docker 底層架構分析
容器與虛擬機技術的對比
重新分析下 docker 和 傳統 VM 的區別:
遷移性和性能:
- 傳統 VM: 需要基於 Hypervisor 的硬件虛擬化技術,模擬出 CPU,內存等硬件。然后在其上搭建一套完整的操作系統,自然在性能上會有很大的損失。遷移自然更不用說,傳統的 ova 導出后就是一個完整的操作系統。
- Docker:Docker 將 Hypervisor 的位置換成自己的 Docekr Engine. 然后運行的容器僅僅是一個特殊的進程,自然性能不會有太大的損失。並且可以應用和其所需要的系統文件打包成鏡像,無論在哪讀可以正常運行,而且相對於 ova 來說體積也小了更多。(需要內核支持)
一般來說,運行着 CentOS 的 KVM,啟動后,在不做優化的前提下,需要占用 100~200 M 內存。在加上用戶對宿主機的調用,需要通過虛擬化軟件攔截和處理,又是一層性能損耗,特別是對計算資源,網絡和磁盤I/O等。
隔離性:
- 傳統 VM:由於是虛擬化出一套完整的操作系統,所以隔離性非常好。比如微軟的 Azure 平台,就是在 Windows 服務器上,虛擬出大量的 Linux 虛擬機。
- Docker:在隔離性上相差就很多了,因為本身上容器就是一種進程,而所有的進程都需要共享一個系統內核。
- 這就意味着,在 Windows 上運行 Linux 容器,或者 Linux 宿主機運行高版本內核的容器就無法實現。
- 在 Linux 內核中,有許多資源和對象不能 Namespace 化,如時間,比如通過
settimeofday(2) 系統調用
修改時間,整個宿主機的實際都會被修改。 - 安全的問題,共享宿主機內核的事實,容器暴露出的攻擊面更大。
資源的限制:
- 傳統 VM:非常便於管理,控制資源的使用,依賴於虛擬的操作系統。
- Docker:由於 docker 內資源的限制通過 Cgroup 實現,而 Cgroup 有很多不完善的地方,比如
- 對 /proc 的處理問題。進入容器后,執行
top
命令,看到的信息和宿主機是一樣的,而不是配置后的容器的數據。(可以通過 lxcfs 修正)。 - 在運行 java 程序時,給容器內設置的內存為 4g,使用默認的 jvm 配置。而默認的 jvm 讀取的內存是宿主機(可能大於 4g),這樣就會出現 OOM 的情況。
- 對 /proc 的處理問題。進入容器后,執行
解決的問題
-
容器是如何進行隔離的?
在創建新進程時,通過 Namespace 技術,如 PID namespaces 等,實現隔離性。讓運行后的容器僅能看到本身的內容。
比如,在容器運行時,會默認加上 PID, UTS, network, user, mount, IPC, cgroup 等 Namespace.
-
容器是如何進行資源限制的?
通過 Linux Cgroup 技術,可為每個進程設定限制的 CPU,Memory 等資源,進而設置進程訪問資源的上限。
-
簡述下 docker 的文件系統?
docker 的文件系統稱為 rootfs,它的實現的想法來自與 Linux unionFS 。將不同的目錄,掛載到一起,形成一個獨立的視圖。並且 docker 在此基礎上引入了層的概念,解決了可重用性的問題。
在具體實現上,rootfs 的存儲區分根據 linux 內核和 docker 本身的版本,分為
overlay2
,overlay
,aufs
,devicemapper
等。rootfs(鏡像)其實就是多個層的疊加,當多層存在相同的文件時,上層的文件會將下層的文件覆蓋掉。 -
容器的啟動過程?
- 指定 Linux Namespace 配置
- 設置指定的 Cgroups 參數
- 切換進程的根目錄
-
容器內運行多個應用的問題?
首先更正一個概念, 都說容器是一個單進程的應用,其實這里的單進程不是指在容器中只允許着一個進程,而是指只有一個進程時可控的。在容器內當然可以使用 ping,ssh 等進程,但這些進程時不受 docker 控制的。
容器內的主進程,也就是 pid =1 的進程,一般是通過 DockerFile 中 ENTRYPOINT 或者 CMD 指定的。如果在一個容器內如果存在着多個服務(進程),就可能出現主進程正常運行,但是子進程退出掛掉的問題,而對於 docker 來說,僅僅控制主進程,無法對這種意外的情況作出處理,也就會出現,容器明明正常運行,但是服務已經掛掉的情況,這時編排系統就變得非常困難。而且多個服務,在也不容易進行排障和管理。
所以如果真的想要在容器內運行多個服務,一般會通過帶有
systemd
或者supervisord
這類工具進行管理,或者通過--init
方法。其實這些方法的本質就是讓多個服務的進程擁有同一個父進程。但考慮到容器本身的設計,就是希望容器和服務能夠同生命周期。所以這樣做,有點背道而馳的意味。
docker目錄結構
/var/lib/docker/
├── containers
├── image
│ └── overlay2
│ ├── distribution
│ ├── imagedb
│ │ ├── content
│ │ │ └── sha256
│ │ └── metadata
│ │ └── sha256
│ ├── layerdb
│ └── repositories.json
├── network
│ └── files
│ └── local-kv.db
├── overlay2
│ └── l
├── plugins
│ ├── storage
│ │ └── blobs
│ │ └── tmp
│ └── tmp
├── swarm
├── tmp
├── trust
└── volumes
└── metadata.db
容器實現原理
從本質上,容器其實就是一種沙盒技術。就好像把應用隔離在一個盒子內,使其運行。因為有了盒子邊界的存在,應用於應用之間不會相互干擾。並且像集裝箱一樣,拿來就走,隨處運行。其實這就是 PaaS 的理想狀態。
實現容器的核心,就是要生成限制應用運行時的邊界。 編譯后的可執行代碼加上數據,叫做程序。而把程序運行起來后,就變成了進程,也就是所謂的應用。如果能在應用啟動時,給其加上一個邊界,這樣不就能實現期待的沙盒嗎?
在 Linux 中,實現容器的邊界,主要有兩種技術 Cgroups
和 Namespace
,Cgroups 用於對運行的容器進行資源的限制,Namespace 則會將容器隔離起來,實現邊界。
這樣看來,容器只是一種被限制的了特殊進程而已。
容器的隔離:Namespace
在介紹 Namespace
前,先看一個實驗:
# 使用 python3.6.8 的官方鏡像,建立了一個運行 django 的環境
# 進入該容器后,使用 ps 命令,查看運行的進程
root@8729260f784a:/src# ps -A
PID TTY TIME CMD
1 ? 00:01:22 gunicorn
22 ? 00:01:20 gunicorn
23 ? 00:01:24 gunicorn
25 ? 00:01:30 gunicorn
27 ? 00:01:16 gunicorn
41 pts/0 00:00:00 bash
55 pts/0 00:00:00 ps
可以看到,容器內 PID =1 的進程,是 gunicorn 啟動的 django 應用。PID =1 的進程是系統啟動時的第一個進程,也稱 init 進程。其他的進程,都是由它管理產生的。而此時,PID=1 確實是 django 進程。
接着,退出容器,在宿主機執行 ps 命令
[root@localhost ~]# ps -ef | grep gunicorn
root 9623 8409 0 21:29 pts/0 00:00:00 grep --color=auto gunicorn
root 30828 30804 0 May28 ? 00:01:22 /usr/local/bin/python /usr/local/bin/gunicorn -c gunicorn_config.py ctg.wsgi
root 31171 30828 0 May28 ? 00:01:20 /usr/local/bin/python /usr/local/bin/gunicorn -c gunicorn_config.py ctg.wsgi
root 31172 30828 0 May28 ? 00:01:24 /usr/local/bin/python /usr/local/bin/gunicorn -c gunicorn_config.py ctg.wsgi
root 31174 30828 0 May28 ? 00:01:30 /usr/local/bin/python /usr/local/bin/gunicorn -c gunicorn_config.py ctg.wsgi
root 31176 30828 0 May28 ? 00:01:16 /usr/local/bin/python /usr/local/bin/gunicorn -c gunicorn_config.py ctg.wsgi
如果以宿主機的視角,發現 django 進程 PID 變成了 30828。這也就不難證明,在容器中,確實做了一些處理。把明明是 30828 的進程,變成了容器內的第一號進程,同時在容器還看不到宿主機的其他進程。這也說明容器內的環境確實是被隔離了。
這種處理,其實就是 Linux 的 Namespace
機制。比如,上述將 PID 變成 1 的方法就是通過PID Namespace
。在 Linux 中創建線程的方法是 clone
, 在其中指定 CLONE_NEWPID
參數,這樣新創建的進程,就會看到一個全新的進程空間。而此時這個新的進程,也就變成了 PID=1 的進程。
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);
Namespace概念
namespace 是 Linux 內核用來隔離內核資源的方式。通過 namespace 可以讓一些進程只能看到與自己相關的一部分資源,而另外一些進程也只能看到與它們自己相關的資源,這兩撥進程根本就感覺不到對方的存在。具體的實現方式是把一個或多個進程的相關資源指定在同一個 namespace 中。
Linux namespaces 是對全局系統資源的一種封裝隔離,使得處於不同 namespace 的進程擁有獨立的全局系統資源,改變一個 namespace 中的系統資源只會影響當前 namespace 里的進程,對其他 namespace 中的進程沒有影響。
namespace 的用途
Linux 內核實現 namespace 的一個主要目的就是實現輕量級虛擬化(容器)服務。在同一個 namespace 下的進程可以感知彼此的變化,而對外界的進程一無所知。這樣就可以讓容器中的進程產生錯覺,認為自己置身於一個獨立的系統中,從而達到隔離的目的。也就是說 linux 內核提供的 namespace 技術為 docker 等容器技術的出現和發展提供了基礎條件。
我們可以從 docker 實現者的角度考慮該如何實現一個資源隔離的容器。比如是不是可以通過 chroot 命令切換根目錄的掛載點,從而隔離文件系統。為了在分布式的環境下進行通信和定位,容器必須要有獨立的 IP、端口和路由等,這就需要對網絡進行隔離。同時容器還需要一個獨立的主機名以便在網絡中標識自己。接下來還需要進程間的通信、用戶權限等的隔離。最后,運行在容器中的應用需要有進程號(PID),自然也需要與宿主機中的 PID 進行隔離。也就是說這六種隔離能力是實現一個容器的基礎,讓我們看看 linux 內核的 namespace 特性為我們提供了什么樣的隔離能力:
名稱 | 宏定義 | 隔離的資源 |
---|---|---|
IPC | CLONE_NEWIPC | System V IPC(信號量、消息隊列和共享內容)和POSIX message queuest |
Network | CLONE_NEWNET | Network devices,stacks,ports,etc(網絡設備、網絡棧、端口等)。 |
Mount | CLONE_NEWNS | Mount points(文件系統掛載點) |
PID | CLONE_NEWPID | Process IDS(進程編號) |
User | CLONE_NEWUSER | User adn group IDs(用戶和用戶組) |
UTS | CLONE_NEWUTS | Hostname and NIS domain name(主機名和NIS域名) |
Cgroup | CLONE_NEWCGROUP | Cgroup root directory(cgroup的根目錄) |
namespace 的發展歷史
Linux 在很早的版本中就實現了部分的 namespace,比如內核 2.4 就實現了 mount namespace。大多數的 namespace 支持是在內核 2.6 中完成的,比如 IPC、Network、PID、和 UTS。還有個別的 namespace 比較特殊,比如 User,從內核 2.6 就開始實現了,但在內核 3.8 中才宣布完成。同時,隨着 Linux 自身的發展以及容器技術持續發展帶來的需求,也會有新的 namespace 被支持,比如在內核 4.6 中就添加了 Cgroup namespace。
Linux 提供了多個 API 用來操作 namespace,它們是 clone()、setns() 和 unshare() 函數,為了確定隔離的到底是哪項 namespace,在使用這些 API 時,通常需要指定一些調用參數:CLONE_NEWIPC、CLONE_NEWNET、CLONE_NEWNS、CLONE_NEWPID、CLONE_NEWUSER、CLONE_NEWUTS 和 CLONE_NEWCGROUP。如果要同時隔離多個 namespace,可以使用 | (按位或)組合這些參數。同時我們還可以通過 /proc 下面的一些文件來操作 namespace。下面就讓讓我們看看這些接口的簡要用法。
查看進程所屬的 namespace
從版本號為 3.8 的內核開始,/proc/[pid]/ns 目錄下會包含進程所屬的 namespace 信息,使用下面的命令可以查看當前進程所屬的 namespace 信息:
ll /proc/$$/ns
首先,這些 namespace 文件都是鏈接文件。鏈接文件的內容的格式為 xxx:[inode number]。其中的 xxx 為 namespace 的類型,inode number 則用來標識一個 namespace,我們也可以把它理解為 namespace 的 ID。如果兩個進程的某個 namespace 文件指向同一個鏈接文件,說明其相關資源在同一個 namespace 中。
其次,在 /proc/[pid]/ns
里放置這些鏈接文件的另外一個作用是,一旦這些鏈接文件被打開,只要打開的文件描述符(fd)存在,那么就算該 namespace 下的所有進程都已結束,這個 namespace 也會一直存在,后續的進程還可以再加入進來。
除了打開文件的方式,我們還可以通過文件掛載的方式阻止 namespace 被刪除。比如我們可以把當前進程中的 uts 掛載到 ~/uts 文件:
touch ~/uts
sudo mount --bind /proc/$$/ns/uts ~/uts
使用 stat 命令檢查下結果
很神奇吧,~/uts 的 inode 和鏈接文件中的 inode number 是一樣的,它們是同一個文件。
clone() 函數
我們可以通過 clone() 在創建新進程的同時創建 namespace。clone() 在 C 語言庫中的聲明如下:
/* Prototype for the glibc wrapper function */
#define _GNU_SOURCE
#include <sched.h>
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);
實際上,clone() 是在 C 語言庫中定義的一個封裝(wrapper)函數,它負責建立新進程的堆棧並且調用對編程者隱藏的 clone() 系統調用。Clone() 其實是 linux 系統調用 fork() 的一種更通用的實現方式,它可以通過 flags 來控制使用多少功能。一共有 20 多種 CLONE_ 開頭的 falg(標志位) 參數用來控制 clone 進程的方方面面(比如是否與父進程共享虛擬內存等),下面我們只介紹與 namespace 相關的 4 個參數:
- fn:指定一個由新進程執行的函數。當這個函數返回時,子進程終止。該函數返回一個整數,表示子進程的退出代碼。
- child_stack:傳入子進程使用的棧空間,也就是把用戶態堆棧指針賦給子進程的 esp 寄存器。調用進程(指調用 clone() 的進程)應該總是為子進程分配新的堆棧。
- flags:表示使用哪些 CLONE_ 開頭的標志位,與 namespace 相關的有CLONE_NEWIPC、CLONE_NEWNET、CLONE_NEWNS、CLONE_NEWPID、CLONE_NEWUSER、CLONE_NEWUTS 和 CLONE_NEWCGROUP。
- arg:指向傳遞給 fn() 函數的參數。
在后續的文章中,我們主要通過 clone() 函數來創建並演示各種類型的 namespace。
setns() 函數
通過 setns() 函數可以將當前進程加入到已有的 namespace 中。setns() 在 C 語言庫中的聲明如下:
#define _GNU_SOURCE
#include <sched.h>
int setns(int fd, int nstype);
和 clone() 函數一樣,C 語言庫中的 setns() 函數也是對 setns() 系統調用的封裝:
- fd:表示要加入 namespace 的文件描述符。它是一個指向 /proc/[pid]/ns 目錄中文件的文件描述符,可以通過直接打開該目錄下的鏈接文件或者打開一個掛載了該目錄下鏈接文件的文件得到。
- nstype:參數 nstype 讓調用者可以檢查 fd 指向的 namespace 類型是否符合實際要求。若把該參數設置為 0 表示不檢查。
前面我們提到:可以通過掛載的方式把 namespace 保留下來。保留 namespace 的目的是為以后把進程加入這個 namespace 做准備。在 docker 中,使用 docker exec 命令在已經運行着的容器中執行新的命令就需要用到 setns() 函數。為了把新加入的 namespace 利用起來,還需要引入 execve() 系列的函數,該函數可以執行用戶的命令,比較常見的用法是調用 /bin/bash 並接受參數運行起一個 shell。
unshar() 函數 和 unshare 命令
通過 unshare 函數可以在原進程上進行 namespace 隔離。也就是創建並加入新的 namespace 。unshare() 在 C 語言庫中的聲明如下:
#define _GNU_SOURCE
#include <sched.h>
int unshare(int flags);
和前面兩個函數一樣,C 語言庫中的 unshare() 函數也是對 unshare() 系統調用的封裝。調用 unshare() 的主要作用就是:不啟動新的進程就可以起到資源隔離的效果,相當於跳出原先的 namespace 進行操作。
系統還默認提供了一個叫 unshare 的命令,其實就是在調用 unshare() 系統調用。
容器的限制:Cgroups
通過 Namespace 技術,實現了容器和容器間,容器與宿主機之間的隔離。但這還不夠,想象這樣一種場景,宿主機上運行着兩個容器。雖然在容器間相互隔離,但以宿主機的視角來看的話,其實兩個容器就是兩個特殊的進程,而進程之間自然存在着競爭關系,自然就可以將系統的資源吃光。當然, 不能允許這么做的。
Cgroups
就是 Linux 內核中用來為進程設置資源的一個技術。
Linux Cgroups 全稱是 Linux Control Group,主要的作用就是限制進程組使用的資源上限,包括 CPU,內存,磁盤,網絡帶寬。
還可以對進程進行優先級設置,審計,掛起和恢復等操作。
在之前的版本中,可通過 libcgroup tools
來管理 cgroup, 在 RedHat7 后,已經改為通過 systemctl
來管理。
systemd
在 Linux 中的功能就是管理系統的資源。而為了管理的方便,衍生出了一個叫 Unit
的概念,比如一個 unit
可以有比較寬泛的定義,比如可以表示抽象的服務,網絡的資源,設備,掛載的文件系統等。為了更好的區分,Linux 將 Unit
的類型主要分為 12 種。
類型 | 作用 |
---|---|
.automount |
用於自動掛載配置的掛載點 |
.swap |
描述系統的交換區,反映了設備或文件的路徑 |
.target |
在系統啟動或者改變狀態時,為其他 unit 提供同步點 |
.path |
定義的文件路徑,用於激活。 |
.service |
一個服務或者一個應用,具體定義在配置文件中。 |
.socket |
一個網絡或者 IPC socket,FIFO buffer. |
.device |
描述一個需要被 systemd udev 或 sysfs 文件系統管理的設備 |
.mount |
定義的掛載點 |
.timer |
定時器 |
.snapshot |
被 systemctl snapshot 命令自動創建的單元 |
.slice |
用於關聯 Linux Control Group 節點,根據關聯的 slice 來限制進程。一個管理單元的組。Slice 並不包含任何進程,僅僅管理由 service 和 scope 組成的層級結構。 |
.scope |
systemd 從 bus 接口收到消息后自動創建。Scope 封裝了任意進程通過 fork() 函數開啟或停止的進程,並且在 systemd 運行時注冊。例如:用戶 sessions,容器和虛擬機。 |
在 Cgroup
中,主要使用的是 slice,、scope、service 這三種類型。
如創建一個臨時 cgroup,然后對其啟動的進程進行資源限制:
# 創建一個叫 toptest 的服務,在名為 test 的 slice 中運行
systemd-run --unit=toptest --slice=test top -b
Running as unit toptest.service.
現在 toptest 的服務已經運行在后台了
# 通過 systemd-cgls 來查看 Cgroup 的信息
[root@localhost ~]# systemd-cgls
├─1 /usr/lib/systemd/systemd --switched-root --system --deserialize 22
├─test.slice
│ └─toptest.service
│ └─6490 /usr/bin/top -b
# 通過 systemctl status 查看服務的狀態
[root@localhost ~]# systemctl status toptest
現在對運行的 toptest 服務進行資源的限制。
# 先看下,沒有被限制前的 Cgroup 的信息, 6490 為進程 PID
[root@localhost ~]# cat /proc/6490/cgroup
11:pids:/test.slice
10:blkio:/test.slice
9:hugetlb:/
8:cpuset:/
7:memory:/test.slice
6:devices:/test.slice
5:net_prio,net_cls:/
4:perf_event:/
3:freezer:/
2:cpuacct,cpu:/test.slice
1:name=systemd:/test.slice/toptest.service
# 對其使用的 CPU 和 內存進行限制
systemctl set-property toptest.service CPUShares=600 MemoryLimit=500M
# 再次查看 Cgroup 的信息,發現在 cpu 和 memory 追加了一些內容。
[root@localhost ~]# cat /proc/6490/cgroup
11:pids:/test.slice
10:blkio:/test.slice
9:hugetlb:/
8:cpuset:/
7:memory:/test.slice/toptest.service
6:devices:/test.slice
5:net_prio,net_cls:/
4:perf_event:/
3:freezer:/
2:cpuacct,cpu:/test.slice/toptest.service
1:name=systemd:/test.slice/toptest.service
這時可以在 /sys/fs/cgroup/memory/test.slice
和 /sys/fs/cgroup/cpu/test.slice
目錄下,多出了一個叫 toptest.service 的目錄。
在其目錄下 cat toptest.service/cpu.shares
可以發現,里面的 CPU 被限制了 600.
回到 Docker,其實 docker 和 上面做的操作基本一致,具體需要限制哪些資源就是在 docker run
里指定:
docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash
關於 docker 具體的限制,可以在 sys/fs/cgroup/cpu/docekr/
等文件夾來查看。
容器鏡像 - rootfs
容器技術的核心是通過 Namespace
限制了容器看到的視野,通過 Cgroup
限制了容器可訪問的資源。 但關於 Mount Namespace
還有一些特殊的地方,需要着重關注下。
Mount Namespace
特殊之處在於,除了在修改時需要進程對文件系統掛載點的認證,還需要顯式聲明需要掛載那些目錄。在 Linux 系統中,有一個叫 chroot
的命令,可以改變進程的根目錄到指定的位置。而 Mount Namespace
正是基於 chroot 的基礎上發展出來的。
在容器內,應該看到完全獨立的文件系統,而且不會受到宿主機以及其他容器的影響。這個獨立的文件系統,就叫做 容器鏡像。它還有一個更專業的名字叫 rootfs
, rootfs
中包含了一個操作系統所需要的文件,配置和目錄,但並不包含系統內核。 因為在 Linux 中,文件和內核是分開存放的,操作系統只有在開啟啟動時才會加載指定的內核。這也就意味着,所有的容器都會共享宿主機上操作系統的內核。
rootfs和layer的設計
任何程序運行時都會有依賴,無論是開發語言層的依賴庫,還是各種系統lib、操作系統等,不同的系統上這些庫可能是不一樣的,或者有缺失的。為了讓容器運行時一致,docker將依賴的操作系統、各種lib依賴整合打包在一起(即鏡像),然后容器啟動時,作為它的根目錄(根文件系統rootfs),使得容器進程的各種依賴調用都在這個根目錄里,這樣就做到了環境的一致性。
不過,這時發現了另一個問題:難道每開發一個應用,都要重復制作一次rootfs?
比如, 現在用Debian操作系統的ISO做了一個rootfs,然后又在里面安裝了Golang環境,用來部署 的應用A。那么另一個同事在發布他的Golang應用B時,希望能夠直接使用 安裝過Golang環境的rootfs,而不是重復這個流程,那么UnionFS就派上用場了。
Docker鏡像的設計中,引入了層(layer)的概念,也就是說,用戶制作鏡像的每一步操作,都會生成一個層,也就是一個增量rootfs(一個目錄),這樣應用A和應用B所在的容器共同引用相同的Debian操作系統層、Golang環境層(作為只讀層),而各自有各自應用程序層,和可寫層。當然,這個想法不是憑空臆造出來的,而是用到了一種叫作聯合文件系統(Union File System)的能力。
Union File System 也叫 UnionFS,最主要的功能是將多個不同位置的目錄聯合掛載(union mount)到同一個目錄下。
再次強調: rootfs只是一個操作系統所包含的文件、配置和目錄,並不包括操作系統內核。這就意味着,如果應用程序需要配置內核參數、加載額外的內核模塊,以及跟內核進行直接的交互, 就需要注意了:這些操作和依賴的對象,都是宿主機操作系統的內核,它對於該機器上的所有容器來說是一個“全局變量”,牽一發而動全身。
鏡像中的層都是讀寫的,那么運行着的容器的運行時數據是存儲在哪里?
鏡像和容器在存儲上的主要差別就在於容器多了一個讀寫層。鏡像由多個只讀層組成,通過鏡像啟動的容器在鏡像之上加了一個讀寫層。通過下圖知道可以通過 docker commit
命令基於運行時的容器生成新的鏡像,那么 commit 做的其中一個工作就是將讀寫層數據寫入到新的鏡像中:

Container最上面是一個可寫的容器層,以及若干只讀的鏡像層組成,Container的數據就存放在這些層中,這樣的分層結構最大的特性是Copy-On-Write(寫時復制):
1、新數據會直接存放在最上面的Container層。
2、修改現有的數據會先從Image層將數據復制到容器層,修改后的數據直接保存在Container層,Image層保持不變。
由此可以看出,每個步驟都將創建一個imgid, 一直追溯到的base鏡像的id 。關於
最后一列是每一層的大小。最后一層只是啟動bash,所以沒有文件變更,大小是0 。創建的鏡像是在base鏡像之上的,並不是完全復制一份base,然后修改,而是共享base的內容。這時候,如果新建一個新的鏡像,同樣也是共享base鏡像。
那修改了base鏡像,會不會導致創建的鏡像也被修改? 不會!因為不允許修改歷史鏡像,只允許修改容器,而容器只可以在最上面的容器層進行寫和變更
所有寫入或者修改運行時容器的數據都會存儲在讀寫層,當容器停止運行的時候,讀寫層的數據也會被同時刪除掉。因為鏡像層的數據是只讀的,所有如果運行同一個鏡像的多個容器副本,那么多個容器則可以共享同一份鏡像存儲層。

UnionFS
聯合文件系統(Union File System):它可以把多個目錄(也叫分支)內容聯合掛載到同一個目錄下,而目錄的物理位置是分開的。UnionFS允許只讀和可讀寫目錄並存,就是說可同時刪除和增加內容。UnionFS應用的地方很多,比如在多個磁盤分區上合並不同文件系統的主目錄,或把幾張CD光盤合並成一個統一的光盤目錄(歸檔)。另外,具有寫時復制(copy-on-write)功能UnionFS可以把只讀和可讀寫文件系統合並在一起,虛擬上允許只讀文件系統的修改可以保存到可寫文件系統當中。
Union 文件系統是Docker鏡像的基礎。鏡像可以通過分層來進行繼承,基於基礎鏡像(沒有父鏡像) ,可以制作各 種具體的應用鏡像。
而bootfs (boot file system)主要包含 ”bootloader“ 和 ”kernel“ , bootloader主要是引導加載kernel, Linux剛啟動時會加載bootfs文件系統, 在Docker鏡像的最底層是bootfs。這一層與典型的Linux/Unix系統是一樣的 ,包含boot加載器和內核。當boot加載完成之后整個內核就都在內存中了,此時內存的使用權已由bootfs轉交給內核,此時系統也會卸載bootfs.
rootfs (root file system) , 在bootfs之上。包含的就是典型Linux系統中的/dev, /proc, /bin, /etc等標准目錄和文件。rootfs就是各種不同的操作系統發行版,比如Ubuntu , Centos等等。
平時安裝進虛擬機的 Centos 都是好幾個 G,為什么 docker 才 200MB?
對於一個精簡的 OS,rootfs 可以是很小,只需要包含最基本的命令,工具和程序庫就可以了,因為底層直接用 Host 的 kernel,自己只需要提供 rootfs 就可以了,由此可見對於不同的 linux 發行版,bootfs 基本是一致的,rootfs 會有差別,因此不同的發行版可以公用 bootfs。
各Linux版本的UnionFS
由於各種原因, Linux各發行版實現的UnionFS各不相同,所以Docker在不同linux發行版中使用的也不同。可以通過docker info
來查看docker使用的是哪種。
上層鏡像層中的文件覆蓋了底層鏡像層中的文件。這樣就使得文件的更新版本作為一個新鏡像層添加到鏡像當中。Docker通過存儲引擎(新版本采用快照機制)的方式來實現鏡像層堆棧,並保證多鏡像層對外展示為統一的文件 系統。
UnionFS的其他實現分別有: aufs, device mapper, btrfs, overlayfs, vfs, zfs。aufs是ubuntu 常用的,device mapper 是 centos,btrfs 是 SUSE,overlayfs ubuntu 和 centos 都會使用,現在最新的 docker 版本中默認兩個系統都是使用的 overlayfs,vfs 和 zfs 常用在 solaris 系統。
顧名思義, 每種存儲引擎都基於Linux 中對應的 文件系統或者塊設備技術, 並且每種存儲引擎都有其獨有的性能特點。
可以通過 docker info
來查詢使用的存儲驅動,這里的是 overlay2
。
[root@localhost ~]# docker infoClient: Debug Mode: falseServer: Containers: 4 Running: 4 Paused: 0 Stopped: 0 Images: 4 Server Version: 19.03.8 Storage Driver: overlay2
Overlay2
在 Linux 的主機上,OverlayFS
一般有兩個目錄,但在顯示時具體會顯示為一個目錄。這兩個目錄被稱為層,聯合在一起的過程稱為 union mount
。 在其下層的目錄稱為 lowerdir
, 上層的目錄稱為 upperdir
。兩者聯合后,暴露出來的視圖稱為 view
, 聽起來有點抽象,先看下整體結構:
可以看到,lowerdir
其實對應的就是鏡像層,upperdir
對應的就是容器器。而 merged
對應的就是兩者聯合掛載之后的內容。而且,當鏡像層和容器層擁有相同的文件時,會以容器層的文件為准(最上層的文件為准)。通常來說,overlay2
支持最多 128 lower 層。
下面實際看下容器層和鏡像具體的體現, 這台 linux 主機上,運行着 4 個 container。
Docker 一般的存儲位置在 /var/lib/docker
,先看下里面的結構:
[root@localhost docker]# ls -l /var/lib/dockertotal 16drwx------. 2 root root 24 Mar 4 03:39 builderdrwx--x--x. 4 root root 92 Mar 4 03:39 buildkitdrwx------. 7 root root 4096 Jun 1 10:36 containersdrwx------. 3 root root 22 Mar 4 03:39 imagedrwxr-x---. 3 root root 19 Mar 4 03:39 networkdrwx------. 69 root root 8192 Jun 1 15:01 overlay2drwx------. 4 root root 32 Mar 4 03:39 pluginsdrwx------. 2 root root 6 Jun 1 15:00 runtimesdrwx------. 2 root root 6 Mar 4 03:39 swarmdrwx------. 2 root root 6 Jun 1 15:01 tmpdrwx------. 2 root root 6 Mar 4 03:39 trustdrwx------. 3 root root 45 May 18 10:28 volumes
需要着重關注的是 container
, image
, overlay2
這幾個文件夾。
container
:這個不用多說,正在運行或創建的容器會在這個目錄下。mage
:對應記錄的就是鏡像。overlay2
:記錄的是每個鏡像下包含的lowerrdir
.
之前提到,unionfs 的實現可能有多種,比如 overlay2、aufs、devicemapper`等。那么自然在 image 文件夾下,就會存在多種驅動的文件夾,:
image/└── overlay2 ├── distribution ├── imagedb │ ├── content │ └── metadata ├── layerdb │ ├── mounts │ ├── sha256 │ └── tmp └── repositories.json
這里的 imagedb
和 layerdb
, 就是存儲元數據的地方。前面說過,容器的文件系統構成就是通過 image 層 和 container 層聯合構成的,而每個 image 可能是由多個層構成。這就意味着,每個層可能會被多個 image 引用。那么之間是如何關聯的呢?答案就在 imagedb
這個文件下。
這里 以 mysql
鏡像為例:
# 查看 mysql 的鏡像 id[root@localhost docker]# docker image lsREPOSITORY TAG IMAGE ID CREATED SIZEctg/mysql 5.7.29 84164b03fa2e 3 months ago 456MB# 進入到 imagedb/content/sha256 目錄, 可以找到對應的鏡像 id[root@localhost docker]# ls -l image/overlay2/imagedb/content/sha256/...-rw-------. 1 root root 6995 Apr 27 02:45 84164b03fa2ecb33e8b4c1f2636ec3286e90786819faa4d1c103ae147824196a# 接着看下里面記錄的內容, 這里截取有用的部分cat image/overlay2/imagedb/content/sha256/84164b03fa2ecb33e8b4c1f2636ec3286e90786819faa4d1c103ae147824196a{......... "os": "linux", "rootfs": { "type": "layers", "diff_ids": [ "sha256:f2cb0ecef392f2a630fa1205b874ab2e2aedf96de04d0b8838e4e728e28142da", "sha256:a9f6b7c7101b86ffaa53dc29638e577dabf5b24150577a59199d8554d7ce2921", "sha256:0c615b40cc37ed667e9cbaf33b726fe986d23e5b2588b7acbd9288c92b8716b6", "sha256:ad160f341db9317284bba805a3fe9112d868b272041933552df5ea14647ec54a", "sha256:1ea6ef84dc3af6506c26753e9e2cf7c0d6c1c743102b85ebd3ee5e357d7e9bc4", "sha256:6fce4d95d4af3777f3e3452e5d17612b7396a36bf0cb588ba2ae1b71d139bab9", "sha256:6de3946ea0137e75dcc43a3a081d10dda2fec0d065627a03800a99e4abe2ede4", "sha256:a35a4bacba4d5402b85ee6e898b95cc71462bc071078941cbe8c77a6ce2fca62", "sha256:1ff9500bdff4455fa89a808685622b64790c321da101d27c17b710f7be2e0e7e", "sha256:1cf663d0cb7a52a3a33a7c84ff5290b80966921ee8d3cb11592da332b4a9e016", "sha256:bcb387cbc5bcbc8b5c33fbfadbce4287522719db43d3e3a286da74492b7d6eca" ] }}
可以看到 mysql
鏡像由 11 層組成,其中 f2cb
是最低層,bcb3
是最上層。
接着, 看下 layerdb
的內容:
[root@localhost docker]# ls -l image/overlay2/layerdb/total 8drwxr-xr-x. 6 root root 4096 May 13 13:38 mountsdrwxr-xr-x. 39 root root 4096 Apr 27 02:51 sha256drwxr-xr-x. 2 root root 6 Apr 27 02:51 tmp# 首先看下 sha256 目錄下的內容[root@localhost docker]# ls -l image/overlay2/layerdb/sha256/total 0....drwx------. 2 root root 71 Apr 27 02:45 bbb9cccab59a16cb6da78f8879e9d07a19e3a8d49010ab9c98a2c348fa116c87drwx------. 2 root root 71 Apr 27 02:45 f2cb0ecef392f2a630fa1205b874ab2e2aedf96de04d0b8838e4e728e28142da....
可以發現,在這里僅能找到最底層的層 ID,原因在於層之間的關聯是通過 chainID 的方式保存的,簡單來說就是通過 sha256 算法后能計算出一層的容器 id.
比如這里,最底層 id 是 f2cb0ecef392f2a630fa1205b874ab2e2aedf96de04d0b8838e4e728e28142da
, 上一層 id 是 a9f6b7c7101b86ffaa53dc29638e577dabf5b24150577a59199d8554d7ce2921
, 那么對應在 sha256 目錄下的下一層 id 的計算方法就是:
[root@localhost docker]# echo -n "sha256:f2cb0ecef392f2a630fa1205b874ab2e2aedf96de04d0b8838e4e728e28142da sha256:a9f6b7c7101b86ffaa53dc29638e577dabf5b24150577a59199d8554d7ce2921" | sha256sumbbb9cccab59a16cb6da78f8879e9d07a19e3a8d49010ab9c98a2c348fa116c87 -
接着 可以在 sha256
目錄下,找到 bbb9..
這層的內容。
現在把鏡像和層關聯起來,但之前說過,image 目錄下存的都是元數據。真實的 rootfs 其實在另一個地方 - /docker/overlay2
下。
# 通過查詢 cache-id,得到就是真實的 rootfs 層[root@localhost docker]# cat image/overlay2/layerdb/sha256/f2cb0ecef392f2a630fa1205b874ab2e2aedf96de04d0b8838e4e728e28142da/cache-id2996b24990e75cbd304093139e665a45d96df8d7e49334527827dcff820dbf16[
進入到 /docker/overlay2
下查看:
[root@localhost docker]# ls -l overlay2/total 4...drwx------. 3 root root 47 Apr 27 02:45 2996b24990e75cbd304093139e665a45d96df8d7e49334527827dcff820dbf16...drwx------. 2 root root 4096 May 13 13:38 l
這樣真實的 rootfs 層也被找到了。
這里重新梳理下, 先是在 mage/overlay2/imagedb/content/sha256/
,根據 image id 查看該 image 具有所有的層ID,然后根據最底層ID和上層ID通過 sha256 計算得到,引用的上一層 ID, 依次類推,關聯所有的層。最后通過每一層的 cache-id
,將元數據和真實的 rootfs 層數據對應起來了。
AUFS
AUFS是 UnionFS 的一種實現,全稱為 Advanced Multi-Layered Unification Filesystem,是早期 Docker 版本默認的存儲驅動,最新的 Docker 版本默認使用 OverlayFS。
AUFS 將鏡像層(只讀)組織成多個目錄,在 AUFS 的術語中成為 branch。運行時容器文件會作為一層容器層(container lay,讀寫)覆蓋在鏡像層之上。最后通過聯合掛載技術進行呈現。下圖是 AUFS 的文章組織架構的示意圖。由於 AUFS 可以算是一種過時的技術,所以這里就不在贅述了。

總結 - rootfs的構成
每個 rootfs 由鏡像層(lowerdir)和 容器層(upperdir)構成,其中鏡像層只能只讀,而容器層則能讀寫。而且鏡像層可有最多128層構成。
其實,rootfs 構成還有另外一層,但由於在進行提交或編譯時,不會把這層加進去,所以就沒把這層算在rootfs里面,但實際上存在的。
在之前查看 /var/lib/docker/overlay2/
下鏡像層,會看到好幾個以 -init
結尾的目錄,而且數量恰好等於容器的數量。這層夾在鏡像層之上,容器層之下。是由 docker 內部單獨生成的一層,專門用於存放 etc/hosts
、/etc/resolv.conf
等配置信息。存在的目的,是由於用戶在容器啟動時,需要配置一些特定的值,比如 hostname,dns 等,但這些配置僅對當前容器有效,放到其他環境下自然有別的配置,所以這層被單獨拿出來,在提交鏡像時,忽略這一層。
容器運行時
容器運行時顧名思義就是要掌控容器運行的整個生命周期,以 docker 為例,其作為一個整體的系統,主要提供的功能如下:
- 制定容器鏡像格式
- 構建容器鏡像
docker build
- 管理容器鏡像
docker images
- 管理容器實例
docker ps
- 運行容器
docker run
- 實現容器鏡像共享
docker pull/push
然而這些功能均可由小的組件單獨實現,且沒有相互依賴。而后 Docker 公司與 CoreOS 和 Google 共同創建了 OCI (Open Container Initial),並提供了兩種規范:
filesystem bundle(文件系統束): 定義了一種將容器編碼為文件系統束的格式,即以某種方式組織的一組文件,並包含所有符合要求的運行時對其執行所有標准操作的必要數據和元數據,即config.json 與 根文件系統。
而后,Docker、Google等開源了用於運行容器的工具和庫 runc,作為 OCI 的一種實現參考。在此之后,各種運行時工具和庫也慢慢出現,例如 rkt、containerd、cri-o 等,然而這些工具所擁有的功能卻不盡相同,有的只有運行容器(runc、lxc),而有的除此之外也可以對鏡像進行管理(containerd、cri-o)。目前較為流行的說法是將容器運行時分成了 low-level 和 high-level 兩類。
low-level: 指的是僅關注運行容器的容器運行時,調用操作系統,使用 namespace 和 cgroup 實現資源隔離和限制。high-level: 指包含了更多上層功能,例如 grpc調用,鏡像存儲管理等。
不同工具的關系如下圖:
low-level runtime
low-level runtime 關注如何與操作系統交互,創建並運行容器。目前常見的 low-level runtime有:
- lmctfy -- 是Google的一個項目,它是Borg使用的容器運行時
- runc -- 目前使用最廣泛的容器運行時。它最初是作為Docker的一部分開發的,后來被提取出來作為一個單獨的工具和庫。其實現了 OCI 規范,包含
config.json
文件和容器的根文件系統。 - rkt -- CoreOS開發的Docker/runc的一個流行替代方案,提供了其他 low-level runtimes (如runc)所提供的所有特性。
創建一個簡單的 runtime。
以 busybox 鏡像作為運行時的一個根文件系統,首先創建一個臨時目錄並將 busybox 中的所有文件解壓縮到目錄中
CID=$(docker create busybox)ROOTFS=$(mktemp -d)docker export $CID | tar -xf - -C $ROOTFS
限制需要創建 cgroup 對內存和cpu進行限制
UUID=$(uuidgen)cgcreate -g cpu,memory:$UUID# 內存限制設置為 100MBcgset -r memory.limit_in_bytes=100000000 $UUID# cpu 限制設置為 512mcgset -r cpu.shares=512 $UUID
上面 cpu.shares
是相對於同時運行的其他進程的CPU。單獨運行的容器可以使用整個CPU,但是如果其他容器正在運行,它們會按照比例分配cpu資源。除此以外,還可以對cpu內核數量的使用進行限制:
# 設置檢查CPU使用情況的頻率,單位是微秒cgset -r cpu.cfs_period_us=1000000 $UUID# 設置任務在一個時間段內在一個核心上運行的時間量,單位是微秒cgset -r cpu.cfs_quota_us=2000000 $UUID
然后使用 unshare
命令在 cgroug 中執行命令,它可以實現 namespace 的隔離。
cgexec -g cpu,memory:$UUID \> unshare -uinpUrf --mount-proc \> sh -c "/bin/hostname $UUID && chroot $ROOTFS /bin/sh"/ echo "Hello from in a container"Hello from in a container
最后,在執行結束后,通過下面的指令清理環境
cgdelete -r -g cpu,memory:$UUIDrm -r $ROOTFS
high-level runtime
High-level runtimes相較於low-level runtimes位於堆棧的上層。low-level runtimes負責實際運行容器,而High-level runtimes負責傳輸和管理容器鏡像,解壓鏡像,並傳遞給low-level runtimes來運行容器。目前主流的 high-level runtime 有:
- docker
- containerd
- rkt
這里以 containerd 為例具體解析整個架構以及工作原理。
Containerd
containerd 的架構圖如圖
其中,grpc 模塊向上層提供服務接口,metrics 則提供監控數據(cgroup 相關數據),兩者均向上層提供服務。containerd 包含一個守護進程,該進程通過本地 UNIX 套接字暴露 grpc 接口。
storage 部分負責鏡像的存儲、管理、拉取等metadata 管理容器及鏡像的元數據,通過bootio存儲在磁盤上task -- 管理容器的邏輯結構,與 low-level 交互event -- 對容器操作的事件,上層通過訂閱可以知道發生了什么事情Runtimes -- low-level runtime(對接 runc)
containerd 主要流程如下:
圖中的 containerEngine 在 docker 中就是 docker-containerd 組件,創建容器記錄的metadata,並請求 containerd 的 task 模塊,task 模塊會在 runtime 中創建 task 實例,分別會加入 task list, 監控 cgroup 等操作,每個 task 實例則調用 shim 去創建container。
containerd-shim
containerd-shim 是 containerd 的一個組件,主要是用於剝離 containerd 守護進程與容器進程。containerd 通過 shim 調用 runc 的包函數來啟動容器。當執行 pstree
命令時,可以看到如下的進程關系:
引入shim,允許runc 在創建和運行容器之后退出,並將 shim 作為容器的父進程,而不是 containerd 作為父進程,這樣做的目的是當 containerd 進程掛掉,由於 shim 還正常運行,因此可以保證容器不受影響。此外,shim 也可以收集和報告容器的退出狀態,不需要 containerd 來 wait 容器進程。
當有需求去替換 runc 運行時工具庫時,例如替換為安全容器 kata container 或 Google 研發的 gViser,則需要增加對應的shim(kata-shim等),以上兩者均有自己實現的 shim。
容器創建過程
首先,用戶通過Docker client輸入docker run
來創建被運行一個容器。Docker client主要的工作是通過解析用戶所提供的一系列參數后,分別發送了這樣兩條請求:
"POST", "/containers/create?"+containerValues"POST", "/containers/"+createResponse.ID
這樣client的工作也就完成了,很顯然client做的事情很少,主要是負責給Docker daemon發送請求。不過,通過client所發送的兩條請求, 可以很自然的把docker run
的整個執行過程分成create與start兩個階段。
Create階段
這階段Docker daemon的主要工作是對client提交的POST表單進行分析整理,獲得具有可移植性的配置參數結構體config和不可移植的配置結構體hostconfig。然后daemon會調用daemon.newContainer函數來創建一個基本的container對象,並將config和hostconfig中保存的信息填寫到container對象中。當然此時的container對象並不是一個具體的物理容器,它其中保存着所有用戶指定的參數和Docker生成的一些默認的配置信息。最后,Docker會將container對象進行JSON編碼,然后保存到其對應的狀態文件中。
上述過程完成后,一個容器的基本配置信息就已經完全具備,用戶可以使用docker inspect
來查看這個容器所對應的各種配置信息。
start階段
完成了create階段后,client緊接着會發送start請求來啟動一個真正的物理容器。當Docker daemon接收到這個start請求后,會使用在create階段配置好的container對象中的各種配置參數來完成volume掛點的注冊,容器網絡的創建和創建並啟動物理容器等工作。下面先對容器的網絡環境創建過程做一個介紹。
volume掛載點的注冊
Docker daemon在將hostconfig配置到容器配置信息的過程中,會調用daemon.registerMountPoints函數對client提供的POST表單中的volume相關信息進行注冊,並以mountpoint的形式存儲在容器的配置信息中,在真正啟動物理容器的時候才會進行掛載。
Volume的掛載點可以分為兩類,一類為使用其他容器中的掛載點,另一類為用戶指定的綁定掛載。下面 來看一下兩種volume掛載點的注冊流程。
- 使用其他容器中的掛載點:在對這類掛載點進行注冊時,首先會使用容器的id在Docker daemon中查找對應的結構體。然后遍歷其中的所有掛載點,並且將其中的掛載點信息全部都注冊到當前的容器結構體之中。
- 用戶指定的綁定掛載:用戶指定的綁定掛載可以有Source Path:Destination Path的格式,也可以是Name:Destination Path的格式。如果用戶輸入的參數是Source Path:Destination Path的格式那么,daemon會解析其中的Source Path和Destination Path,並使用它們注冊對應的掛載點。如果用戶輸入的參數是Name:Destination Path的格式,那么daemon會查找用戶提供的Name是否已經對應了一個使用
docker volume
已經創建好了的掛載點信息,如果是的話,則會使用這個掛載點的信息和用戶提供的Destination Path進行本容器的掛載點注冊。如果這個Name在daemon中沒有對應的掛載點的話,daemon則會在其默認文件夾下創建一個目錄,作為掛載點中的Source Path,然后使用用戶提供的Destination Path和自行創建的Source Path進行本容器的掛載點注冊。
在完成了掛載點的注冊之后,daemon會將所有的掛載點信息更新到容器的配置信息中,以備后續使用。
網絡的創建
Docker daemon使用client提供的POST表單中網絡相關的參數,通過調用daemon.initializeNetworking函數來完成容器網絡棧的創建和配置。daemon.initializeNetworking函數則通過對Docker的網絡依賴庫(即libnetwork)的一系列調用,來完成容器的網絡棧創建和配置等工作。
要理解Docker容器的網絡部分的執行流程,那么首先要清楚libnetwork中的三個核心概念。
- 沙盒(Sandbox):一個沙盒包含了一個容器網絡棧的信息。沙盒可以對容器的接口,路由和DNS設置等進行管理。沙盒的實現可以是Linux Network Namespace, FreeBSD Jail或者類似的機制。一個沙盒可以有多個端點(Endpoint)和多個網絡(Network)。
- 端點(Endpoint):一個端點可以加入一個沙盒和一個網絡。端點的實現可以是veth pair, Open vSwitch內部端口或者相似的設備。一個端點只可以屬於一個網絡並且只屬於一個沙盒。
- 網絡(Network):一個網絡是一組可以直接互相聯通的端點。網絡的實現可以是Linux bridge,VLAN等等。一個網絡可以包含多個端點。
清楚了以上三個核心概念之后, 從Docker源碼的角度並通過Docker中默認的網絡模式(bridge模式)來看一下容器網絡棧的創建過程。
- 在Docker daemon啟動之后,會創建一個默認的network,其本質工作就是創建了一個名為docker0的默認網橋。
- 確定默認網橋之后,daemon會調用container.BuildCreateEndpointOptions來創建此容器中endpoint的配置信息。然后再調用Network.CreateEndpoint使用上面配置好的信息創建對應的endpoint。在bridge模式中,libnetwork創建的設備是veth pair。Libnetwork中調用netlink.LinkAdd(veth)進行了veth pair的創建,把其中的的一個veth設備是加入到docker0網橋中,另一個則是為了sandbox所准備的。
- 接下來daemon會調用daemon.buildSandboxOptions來創建此容器的sandbox,然后調用Network.NewSandbox來創建屬於此容器的新的sandbox。libnetwork在接收到創建sandbox的請求后,會使用系統調用為容器創建一個新的netns,並將這個netns的路徑返寫入到對應容器的配置信息中,以便后續的使用。
- 最后,daemon會調用ep.Join(sb)將endpoint加入到容器對應的sandbox中。先將endpoint加入到容器對應的sandbox中,然后對endpoint的ip信息和gateway等信息進行配置,並將所有的信息更新到對應容器的配置信息中。
容器的創建和啟動
在完成創建容器的各種准備工作之后,Docker daemon會通過對libcontainer的一系列調用來完成容器的創建和啟動工作。Libcontainer是Docker的運行時庫,它可以通過調用者提供的配置參數來創建並運行一個容器出來,下面 來看一Docker是如何使用之前配置的結構體中的各項參數,通過libcontainer創建並運行一個容器的。
一、創建邏輯容器Container與邏輯進程process
所謂的邏輯容器container和邏輯進程process並非時真正運行着的容器和進程,而是libcontainer中所定義的結構體。邏輯容器container中包含了namespace,cgroups,device和mountpoint等各種配置信息。邏輯進程process中則包含了容器中所要運行的指令以其參數和環境變量等。
Docker daemon會調用execdriver.Run來完成和libcontainer的一系列交互工作。首先將會將所有和新建容器相關的參數裝入可以被libcontainer使用的結構體config中。然后使用config作為參數來調用libcontainer.New()生成用來產生container的工廠factory。再調用factory.Create(config),就會生成一個將config包含其中的邏輯容器container。接下來調用newProcess(config)來將config中關於容器內所要運行命令的相關信息填充到process結構體中,這個結構體即為邏輯進程process。使用container.Start(process)來啟動邏輯容器。
二、啟動邏輯容器container
Docker daemon會調用linuxContainer.Start來啟動邏輯容器。這個函數的主要工作就是調用newParentProcess()來生成parentprocess實例(結構體)和用於runC與容器內init進程相互通信的管道。
在parentprocess實例中,除了有記錄了將來與容器內進程進行通信的管道與各種基本配置等,還有一個極為重要的字段就是其中的cmd。
cmd字段是定義在os/exec包中的一個結構體。os/exec包主要用於創建一個新的進程,並在這個進程中執行指定的命令。開發者可以在工程中導入os/exec包,然后將cmd結構體進行填充,即將所需運行程序的路徑和程序名,程序所需參數,環境變量,各種操作系統特有的屬性和拓展的文件描述符等。
在Docker中程序將cmd的應用路徑字段Path填充為/proc/self/exe(即為應用程序本身,Docker)。參數字段Args填充為init,表示對容器進行初始化。SysProcAttr字段中則填充了各種Docker所需啟用的namespace(其中包括前面所講到的netns路徑)等屬性。
然后調用parentprocess.cmd.Start()啟動物理容器中的init進程。接下來將物理容器中init進程的進程號加入到Cgroup控制組中,對容器內的進程實施資源控制。再把配置參數通過管道傳送給init進程。最后通過管道等待init進程根據上述配置完成所有的初始化工作,或者出錯退出。
三、物理容器的配置和創建
容器中的init進程首先會調用StartInitialization()函數,通過管道從父進程接收各種配置參數。然后對容器進行如下配置:
- 將init進程加入其指定的namespace中,這里會將init進程加入到前面已經創建好的netns中,這樣init進程就擁有了自己獨立的網絡棧,完成了網絡創建和配置的最后一步。
- 設置進程的會話ID。
- 使用系統調用,將前面注冊好的掛載點全部掛載到物理主機上,這樣就完成了volume的創建。
- 對指定目錄下的文件系統進行掛載,並切換根目錄到新掛載的文件系統下。設置hostname,加載profile信息。
- 最后使用exec系統調用來執行用戶所指定的在容器中運行的程序。
這樣就完成了一個容器的創建和啟動過程。
Docker 原生健康檢查
自 1.12 版本之后,Docker 引入了原生的健康檢查實現,可以在 Dockerfile 中聲明應用自身的健康檢測配置。健康檢查 HEALTHCHECK
指令告訴 Docker 如何檢查容器是否仍在工作。 它能夠監測類似一個服務器雖然服務進程仍在運行, 但是陷入了死循環, 不能響應新的請求的情況。
當一個容器有指定健康檢查時, 它除了普通狀態之外, 還有健康狀態 (health status) 。 健康狀態的初始狀態是正在啟動 (starting
) , 一旦通過了一個健康檢查, 它將變成健康 (healthy
) (不管之前的狀態是什么), 經過一定數量的連續失敗之后, 它將變成不健康 (unhealthy
) 。
健康檢查指令有兩種形式:
HEALTHCHECK [OPTIONS] CMD command
通過運行容器內的一個指令來檢查容器的健康情況;HEALTHCHECK NONE
禁用任何(包括基層至父鏡像)健康檢查指令。
可以出現在 CMD
之前的選項有:
--interval=DURATION
間隔時間, 默認30s
(30秒);--timeout=DURATION
超時時間, 默認30s
(30秒);--start-period=DURATION
啟動時間, 默認 0s, 如果指定這個參數, 則必須大於 0s ;--retries=N
重試次數, 默認 3 ;
健康檢查會在容器啟動后的間隔時間內運行, 在上一次檢查完成之后, 按照指定的間隔時間再次運行。
如果單次健康檢查的時間超過了指定的超時時間,則認為是失敗的。
如果連續失敗次數超過了指定的重試次數, 則容器的健康狀態將被視為不健康 (unhealthy) 。
start-period
為需要啟動的容器提供了初始化的時間段, 在這個時間段內如果檢查失敗, 則不會記錄失敗次數。 如果在啟動時間內成功執行了健康檢查, 則容器將被視為已經啟動, 如果在啟動時間內再次出現檢查失敗, 則會記錄失敗次數。
一個 Dockerfile 中只能有一個健康檢查指令生效, 如果出現了多個, 則只有最后一個有效。
在 CMD
關鍵字之后的 command
可以是一個 shell 命令(例如: HEALTHCHECK CMD /bin/check-running
)或者一個 exec 數組(與其它 Dockerfile 命令相同, 參考 [ENTRYPOINT])。
該命令的返回值說明了容器的狀態:
- 0: healthy - 容器健康, 可以使用;
- 1: unhealthy - 容器工作不正常, 需要診斷;
- 2: reserved - 保留, 不要使用這個返回值;
例如, 每隔 5 分鍾檢查一個網絡服務器能夠在 3 秒內響應主頁的請求:
HEALTHCHECK --interval=5m --timeout=3s \ CMD curl -f http://localhost/ || exit 1
為了幫助調試失敗信息, 任何向 stdout 或者 stderr 的文本輸出會被記錄下來(使用 UTF-8 編碼), 並保存在容器的健康狀態中, 可以使用 docker inspect
命令查詢。 健康健康檢查的錯誤輸出應該盡可能的簡短, 目前只保存前面的 4k 字符。
當容器的健康狀態發生變化時, 將會產生一個 health_status
事件, 這個時間將會攜帶新的狀態。
指令使用
如果沒有為容器指定健康檢查指令, 則使用 docker ps
時, 返回列表如下:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES72d9db1c503d beginor/jexus:5.8.3.0 "docker-entrypoint.s…" 9 days ago Up 7 days 0.0.0.0:8088->80/tcp jexus
在 status 那一列只能顯示 Up 7 days
, 表示 7 天前啟動, 不能顯示容器的健康狀況。
如果指定了容器指定健康檢查指令, 則輸出為:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES10ec32c21b2e beginor/jexus:5.8.3.1 "docker-entrypoint.s…" 2 weeks ago Up 1 days (healthy) 0.0.0.0:8088->80/tcp jexus
可以看到, 在 status 那一列顯示為 Up 1 days (healthy)
。
鏡像創建過程
擴展
BuildKit
從版本18.09開始,Docker支持由moby / buildkit 項目提供的用於執行構建的新后端。與舊的實現相比,BuildKit后端提供了許多好處。例如,BuildKit可以:
- 檢測並跳過執行未使用的構建階段
- 並行構建獨立的構建階段
- 兩次構建之間僅增量傳輸構建上下文中的已更改文件
- 在構建上下文中檢測並跳過傳輸未使用的文件
- 使用具有許多新功能的外部Dockerfile實現
- 避免其他API(中間圖像和容器)的副作用
- 優先考慮構建緩存以進行自動修剪
要使用BuildKit后端,需要DOCKER_BUILDKIT=1
在CLI上設置環境變量 ,然后再調用docker build
。
要了解基於BuildKit的構建可用的實驗性Dockerfile語法,請參閱BuildKit存儲庫中的文檔。
Buildx
由於Docker守護進程中的某些限制,Docker並未充分發揮BuildKit的全部功能。因此,對Docker客戶端CLI進行了擴展以提供插件框架,該框架允許使用插件來擴展可用的CLI功能。一個名為Buildx的實驗性插件會繞過守護程序中的舊版構建函數,它使用BuildKit后端進行所有構建。該工具提供了所有熟悉的鏡像構建命令和功能,但通過一些特定於BuildKit的附加功能對其進行了擴充。
buildx
是Docker CLI插件,用於BuildKit擴展的構建功能:https://github.com/docker/buildx
主要特征:
- 熟悉的用戶界面來自
docker build
- 帶有容器驅動程序的完整BuildKit功能
- 多個構建器實例支持
- 用於跨平台圖像的多節點構建
- 撰寫構建支持
- 在制品:高級構建構造(
bake
) - 容器內驅動程序支持(Docker和Kubernetes)
BuildKit(通過Buildx擴展)支持多個構建器實例。這是一項重要功能,其他鏡像構建工具中沒有類似功能。它實際上意味着可以出於構建目的共享一組構建器實例。也可能為一個項目分配了一組構建器實例,而另一個項目則分配了另一組。
$ docker buildx lsNAME/NODE DRIVER/ENDPOINT STATUS PLATFORMSdefault * docker default default running linux/amd64, linux/386
默認情況下,Buildx插件以Docker驅動程序為目標,該驅動程序使用Docker守護程序提供的BuildKit庫,但存在其固有的局限性。另一個驅動程序是docker-container,它透明地在容器內啟動BuildKit來執行構建。它可以提供BuildKit全部功能。第三個用於Kubernetes的驅動程序可以讓BuildKit實例以Pod的方式在Kubernetes中運行。這特別有趣,因為它可以啟動在Kubernetes中運行的BuildKit的構建——全部來自Docker CLI。這是否是理想的工作流程,這完全取決於個人或公司的選擇。
參考
https://help.aliyun.com/document_detail/58588.html Docker 容器健康檢查機制
https://beginor.github.io/2018/03/11/healthy-check-instruction-of-docker.html Docker 容器的健康檢查
http://www.dockone.io/article/1239 Docker run執行流詳解(以volume,network和libcontainer為線索)
https://www.jianshu.com/p/3ba255463047 Docker技術原理之Linux UnionFS(容器鏡像)
https://haojianxun.github.io/2018/05/03/理解docker的rootfs和分層構建聯合掛載的概念/
https://www.zhihu.com/column/c_1196004665702080512 深入理解container--容器運行時
https://learnku.com/articles/47844 Docker 鏡像講解
https://www.crblog.cc/docker/_book/chapter1/08docker鏡像分層技術.html