Docker 引擎是用來運行和管理容器的核心軟件。通常人們會簡單地將其代指為 Docker 或 Docker 平臺。
如果你對 VMware 略知一二,那么可以將 Docker 引擎理解為 ESXi 的角色。
基于開放容器計劃(OCI)相關標準的要求,Docker 引擎采用了模塊化的設計原則,其組件是可替換的。
從多個角度來看,Docker 引擎就像汽車引擎——二者都是模塊化的,并且由許多可交換的部件組成。
汽車引擎由許多專用的部件協同工作,從而使汽車可以行駛,例如進氣管、節氣門、氣缸、火花塞、排氣管等。
Docker 引擎由許多專用的工具協同工作,從而可以創建和運行容器,例如 API、執行驅動、運行時、shim 進程等。
Docker 引擎由如下主要的組件構成:Docker 客戶端(Docker Client)、Docker 守護進程(Docker daemon)、containerd 以及 runc。它們共同負責容器的創建和運行。
總體邏輯如下圖所示。
Docker 首次發布時,Docker 引擎由兩個核心組件構成:LXC 和 Docker daemon。
Docker daemon 是單一的二進制文件,包含諸如 Docker 客戶端、Docker API、容器運行時、鏡像構建等。
LXC 提供了對諸如命名空間(Namespace)和控制組(CGroup)等基礎工具的操作能力,它們是基于 Linux 內核的容器虛擬化技術。
下圖闡釋了在 Docker 舊版本中,Docker daemon、LXC 和操作系統之間的交互關系。
對 LXC 的依賴自始至終都是個問題。
首先,LXC 是基于 Linux 的。這對于一個立志于跨平臺的項目來說是個問題。
其次,如此核心的組件依賴于外部工具,這會給項目帶來巨大風險,甚至影響其發展。
因此,Docker 公司開發了名為 Libcontainer 的自研工具,用于替代 LXC。
Libcontainer 的目標是成為與平臺無關的工具,可基于不同內核為 Docker 上層提供必要的容器交互功能。
在 Docker 0.9 版本中,Libcontainer 取代 LXC 成為默認的執行驅動。
隨著時間的推移,Docker daemon 的整體性帶來了越來越多的問題。難于變更、運行越來越慢。這并非生態(或Docker公司)所期望的。
Docker 公司意識到了這些問題,開始努力著手拆解這個大而全的 Docker daemon 進程,并將其模塊化。
這項任務的目標是盡可能拆解出其中的功能特性,并用小而專的工具來實現它。這些小工具可以是可替換的,也可以被第三方拿去用于構建其他工具。
這一計劃遵循了在 UNIX 中得以實踐并驗證過的一種軟件哲學:小而專的工具可以組裝為大型工具。
這項拆解和重構 Docker 引擎的工作仍在進行中。不過,所有容器執行和容器運行時的代碼已經完全從 daemon 中移除,并重構為小而專的工具。
目前 Docker 引擎的架構示意圖如下圖所示,圖中有簡要的描述。
當 Docker 公司正在進行 Docker daemon 進程的拆解和重構的時候,OCI 也正在著手定義兩個容器相關的規范(或者說標準)。
鏡像規范和容器運行時規范,兩個規范均于 2017 年 7 月發布了 1.0 版。
Docker 公司參與了這些規范的制定工作,并貢獻了許多的代碼。
從 Docker 1.11 版本(2016 年初)開始,Docker 引擎盡可能實現了 OCI 的規范。例如,Docker daemon 不再包含任何容器運行時的代碼——所有的容器運行代碼在一個單獨的 OCI 兼容層中實現。
默認情況下,Docker 使用 runc 來實現這一點。runc 是 OCI 容器運行時標準的參考實現。
如上圖中的 runc 容器運行時層。runc 項目的目標之一就是與 OCI 規范保持一致。
目前 OCI 規范均為 1.0 版本,我們不希望它們頻繁地迭代,畢竟穩定勝于一切。
除此之外,Docker 引擎中的 containerd 組件確保了 Docker 鏡像能夠以正確的 OCI Bundle 的格式傳遞給 runc。
其實,在 OCI 規范以 1.0 版本正式發布之前,Docker 引擎就已經遵循該規范實現了部分功能。
如前所述,runc 是 OCI 容器運行時規范的參考實現。Docker 公司參與了規范的制定以及 runc 的開發。
去粗取精,會發現 runc 實質上是一個輕量級的、針對 Libcontainer 進行了包裝的命令行交互工具(Libcontainer 取代了早期 Docker 架構中的 LXC)。
runc 生來只有一個作用——創建容器,這一點它非常拿手,速度很快!不過它是一個 CLI 包裝器,實質上就是一個獨立的容器運行時工具。
因此直接下載它或基于源碼編譯二進制文件,即可擁有一個全功能的 runc。但它只是一個基礎工具,并不提供類似 Docker 引擎所擁有的豐富功能。
有時也將 runc 所在的那一層稱為“OCI 層”,如上圖所示。關于 runc 的發布信息見 GitHub 中 opencontainers/runc 庫的 release。
在對 Docker daemon 的功能進行拆解后,所有的容器執行邏輯被重構到一個新的名為 containerd(發音為 container-dee)的工具中。
它的主要任務是容器的生命周期管理——start | stop | pause | rm....
containerd 在 Linux 和 Windows 中以 daemon 的方式運行,從 1.11 版本之后 Docker 就開始在 Linux 上使用它。
Docker 引擎技術棧中,containerd 位于 daemon 和 runc 所在的 OCI 層之間。Kubernetes 也可以通過 cri-containerd 使用 containerd。
如前所述,containerd 最初被設計為輕量級的小型工具,僅用于容器的生命周期管理。然而,隨著時間的推移,它被賦予了更多的功能,比如鏡像管理。
其原因之一在于,這樣便于在其他項目中使用它。比如,在 Kubernetes 中,containerd 就是一個很受歡迎的容器運行時。
然而在 Kubernetes 這樣的項目中,如果 containerd 能夠完成一些諸如 push 和 pull 鏡像這樣的操作就更好了。
因此,如今 containerd 還能夠完成一些除容器生命周期管理之外的操作。不過,所有的額外功能都是模塊化的、可選的,便于自行選擇所需功能。
所以,Kubernetes 這樣的項目在使用 containerd 時,可以僅包含所需的功能。
containerd 是由 Docker 公司開發的,并捐獻給了云原生計算基金會(Cloud Native Computing Foundation, CNCF)。2017 年 12 月發布了 1.0 版本,具體的發布信息見 GitHub 中的 containerd/ containerd 庫的 releases。
現在我們對 Docker 引擎已經有了一個總體認識,也了解了一些歷史,下面介紹一下創建新容器的過程。
常用的啟動容器的方法就是使用 Docker 命令行工具。下面的docker container run命令會基于 alpine:latest 鏡像啟動一個新容器。
$ docker container run --name ctr1 -it alpine:latest sh
當使用 Docker 命令行工具執行如上命令時,Docker 客戶端會將其轉換為合適的 API 格式,并發送到正確的 API 端點。
API 是在 daemon 中實現的。這套功能豐富、基于版本的 REST API 已經成為 Docker 的標志,并且被行業接受成為事實上的容器 API。
一旦 daemon 接收到創建新容器的命令,它就會向 containerd 發出調用。daemon 已經不再包含任何創建容器的代碼了!
daemon 使用一種 CRUD 風格的 API,通過 gRPC 與 containerd 進行通信。
雖然名叫 containerd,但是它并不負責創建容器,而是指揮 runc 去做。
containerd 將 Docker 鏡像轉換為 OCI bundle,并讓 runc 基于此創建一個新的容器。
然后,runc 與操作系統內核接口進行通信,基于所有必要的工具(Namespace、CGroup等)來創建容器。容器進程作為 runc 的子進程啟動,啟動完畢后,runc 將會退出。
至此,容器啟動完畢。整個過程如下圖所示。
將所有的用于啟動、管理容器的邏輯和代碼從 daemon 中移除,意味著容器運行時與 Docker daemon 是解耦的,有時稱之為“無守護進程的容器(daemonless container)”,如此,對 Docker daemon 的維護和升級工作不會影響到運行中的容器。
在舊模型中,所有容器運行時的邏輯都在 daemon 中實現,啟動和停止 daemon 會導致宿主機上所有運行中的容器被殺掉。
這在生產環境中是一個大問題——想一想新版 Docker 的發布頻次吧!每次 daemon 的升級都會殺掉宿主機上所有的容器,這太糟了!
幸運的是,這已經不再是個問題。
shim 是實現無 daemon 的容器(用于將運行中的容器與 daemon 解耦,以便進行 daemon 升級等操作)不可或缺的工具。
前面提到,containerd 指揮 runc 來創建新容器。事實上,每次創建容器時它都會 fork 一個新的 runc 實例。
不過,一旦容器創建完畢,對應的 runc 進程就會退出。因此,即使運行上百個容器,也無須保持上百個運行中的 runc 實例。
一旦容器進程的父進程 runc 退出,相關聯的 containerd-shim 進程就會成為容器的父進程。作為容器的父進程,shim 的部分職責如下。
保持所有 STDIN 和 STDOUT 流是開啟狀態,從而當 daemon 重啟的時候,容器不會因為管道(pipe)的關閉而終止。
將容器的退出狀態反饋給 daemon。
在 Linux 系統中,前面談到的組件由單獨的二進制來實現,具體包括 dockerd(Docker daemon)、docker-containerd(containerd)、docker-containerd-shim (shim) 和 docker-runc (runc)。
通過在 Docker 宿主機的 Linux 系統中執行 ps 命令可以看到以上組件的進程。當然,有些進程只有在運行容器的時候才可見。
當所有的執行邏輯和運行時代碼都從 daemon 中剝離出來之后,問題出現了—— daemon 中還剩什么?
顯然,隨著越來越多的功能從 daemon 中拆解出來并被模塊化,這一問題的答案也會發生變化。
不過,daemon 的主要功能包括鏡像管理、鏡像構建、REST API、身份驗證、安全、核心網絡以及編排。