運行時指的是程序的生命周期階段或使用特定語言來執行程序。容器運行時的功能與它類似——它是運行和管理容器所需組件的軟件。這些工具可以更輕松地安全執行和高效部署容器,是容器管理的關鍵組成部分。在容器化架構中,容器運行時負責從存儲庫加載容器鏡像、監控本地系統資源、隔離系統資源以供容器使用以及管理容器生命周期。
容器運行時的常見示例是 runC、containerd 和 Docker。容器運行時主要分為三種類型——低級運行時、高級運行時以及沙盒或虛擬化運行時。
在容器技術中,容器運行時可以分為三種類型:低級運行時、高級運行時以及沙盒或虛擬化運行時。
1.?低級運行時:指的是負責容器隔離和生命周期管理的基本運行時組件。在這種運行時中,容器是通過Linux內核的cgroups和namespace機制進行隔離和管理的。常見的低級運行時包括Docker的runc、lxc等。這種運行時通常具有輕量化和高性能的優點,但缺乏高級特性和管理工具。
2.?高級運行時:是在低級運行時的基礎上,提供了更豐富的特性和管理工具的容器運行時。這些特性可以包括容器網絡、存儲、監控、鏡像傳輸、鏡像管理、鏡像API等功能,以及各種管理工具等。常見的高級運行時包括Docker、Containerd和CRI-O等。這種運行時通常具有更為豐富的特性和管理工具,但也帶來了更高的復雜性和資源消耗。
3.?沙盒或虛擬化運行時:是在容器運行時中使用沙盒技術或虛擬化技術實現容器隔離和管理的運行時。這種運行時通常具有更強的隔離性和安全性,但也會帶來更高的性能開銷和復雜性。常見的沙盒或虛擬化運行時包括gVisor、Kata Containers等。
總的來說,容器運行時的不同類型具有各自的優缺點和適用場景。在選擇容器運行時時,需要根據實際需求和限制進行權衡和選擇。
低級容器運行時
低級容器運行時?(Low level Container Runtime),一般指按照 OCI 規范實現的、能夠接收可運行文件系統(rootfs) 和 配置文件(config.json)并運行隔離進程的實現。
這種運行時只負責將進程運行在相對隔離的資源空間里,不提供存儲實現和網絡實現。但是其他實現可以在系統中預設好相關資源,低級容器運行時可通過 config.json 聲明加載對應資源。
低級運行時的特點是底層、輕量、靈活,限制也很明顯:
??只認識 rootfs 和 config.json,不認識鏡像?(下文簡稱 image),不具備鏡像存儲功能,也不能執行鏡像的構建、推送、拉取等(我們無法使用 runC, kata-runtime 處理鏡像)
??不提供網絡實現,所以真正使用時,往往需要利用?CNI?之類的實現為容器添加網絡
??不提供持久實現,如果容器是有狀態應用需要使用文件系統持久狀態,單機環境可以掛載宿主機目錄,分布式環境可以自搭 NFS,但多數會選擇云平臺提供的?CSI?存儲實現
??與特定操作系統綁定無法跨平臺,比如 runC 只能在 Linux 上使用;runhcs 只能在 Windows 上使用
解決了這些限制中一項或者多項的容器運行時,就叫做高級容器運行時 (High level Container Runtime)。
高級容器運行時第一要務
高級容器運行時首先要做的是打通 OCI image spec 和 runtime spec,直白來說就是高效處理 image 到 rootfs 和 config.json 的轉換。config.json 的生成比較簡單,運行時可以結合 image config 和請求方需求直接生成;較為復雜的部分是 image 到 rootfs 的轉換,這涉及鏡像拉取、鏡像存儲、鏡像 layer 解壓、解壓 layer 文件系統(fs layer) 的存儲、合并 fs layer 為 rootfs。
鏡像拉取模塊先從 image registry 獲取清單(manifest)文件,處理過程不僅需要兼容 OCI image 規范,考慮到 Docker 生態,也需兼容 Docker image 規范(所幸兩者區別并不大)。運行時實現先從 manifest 獲取 layer list,先檢查對應 layer 在本地是否存在,如果不存在則下載對應 layer。下載的 layer tar 或者 tar.gz 一般直接存儲磁盤,為實現快速處理,需要建立索引,比如從 reference:tag (如 docker.io/library/redis:6.0.5-alpine) 到 manifest 存儲路徑的映射;當然,layer 的訪問比 image 高頻,layer sha256 值到對應存儲路徑也會被索引。因此 ,運行時一般會圍繞 image 索引和 image layer 存儲組織獨立模塊對其他模塊提供服務。
如果要轉換 image layers 到 rootfs,就要逐層解壓 layers 為 filesystem layer(fs layer) 再做合并。這帶來了幾個問題,首先是 fs layer 同樣需要存儲磁盤多次復用,那么就需要有一個方式從 image 映射到對應 fs layers;接著類似 image layer,需要建立索引維系 fs layers 之間的父子關系,盡可能復用里層文件,避免重復工作;最后是層次復用帶來的煩惱,隔離進程運行之后會發生 rootfs 寫入,需要以某種方式避免更改發生到共享的 fs layers。
??第一個問題一般使用 image config 文件中的 diffID 解決,每解壓一層 layer,就使用上一層 fs layer id 和 本層 diffID 的拼接串做 sha256 hash,輸出結果作為本層對應的 fs layer id(最里層 id 為其 diffID),接著建立 id 到磁盤路徑索引。因此只要通過 image manifest 文件找到 image config 文件,即可找到所有 fs layers,詳細實現方式見?OCI image spec layer chain id。
??第二個問題解決方式很簡單,在每個 fs layer 索引存儲上一層 fs layer id 即可。
??第三個問題,一般通過 UnionFS 提供的 CopyOnWrite 技術解決,簡單來說,就是使用空文件夾,在鏡像對應 fs layer 最外層之上再生成一層 layer,使用 UnionFS 合并(準確來說是掛載 mount)時將其聲明為 work 目錄(或者說 upper 目錄)。UnionFS 掛載出 rootfs 之后,隔離進程所做的任何寫操作(包括刪除)都只體現在 work layer,而不會影響其他 fs layer。(詳細介紹可以參考?陳皓的介紹文章)
最后,高級運行時需要充當隔離進程管理者角色,而一個低級運行時(如 runC )可能同時被多個高級運行時使用。同時試想,如果隔離進程退出,如何以最快的方式恢復運行?高級運行時實現一般都會引入 container 抽象(或者說 container meta),meta 存儲了 ID、 image 信息、低級運行時描述、OCI spec (json config)、 work layer id 以及 K-V 結構的 label 信息。因此只要創建出 container meta,后續所有與隔離進程相關操作,如進程運行、進程信息獲取、進程 attach、進程日志獲取,均可通過 container ID 進行。
containerd
containerd 是一個高度模塊化的高級運行時,所有模塊均以 RPC service 形式加載(gRPC 或者 TTRPC),所有模塊均可插拔。不同插件通過聲明互相依賴,由 containerd 核心實現統一加載,使用方可以使用 Go 語言實現編寫插件實現更豐富的功能。不過這種設計使得 containerd 擁有強大的跨平臺能力,并能夠作為一個組件輕松嵌入其他軟件,也帶來一個弊端,模塊之間功能互調也從簡單的函數調用,變成了更為昂貴的 RPC 調用。
注:TTRPC?是一種基于 gRPC 的改良通信協議。
containerd 架構圖
containerd 大多功能模塊很容易與上文提到的「第一要務」相聯系 :
??Content,以 image layer 哈希值(一般使用 sha256 算法生成)為索引,支持快速 layer 快速查找和讀取,并支持對 layer 添加 label。索引和 label 信息存儲在 boltDB。
??Images,在 boltDB 中存儲了 reference 到 manifest layer 的映射,結合 Content 可以組織完整的 image 信息。
??Snapshot,存儲、處理解壓后的 fs layers 和容器 work layer,索引信息同樣存儲在 boltDB。Snapshot 內置支持多種 UnionFS(如 overlay,aufs,btrfs)。
??Containers,以 container ID 為索引,在 boltDB 中存儲了低級運行時描述、 snapshot 文件系統類型、 snapshotKey(work layer id)、image reference 等信息。
??Diff,可用于比對 image layer tar 和 fs layers 差異輸出 diffID,可以校驗 image config 中的 diffID,同樣也能比對 fs layers 之間的差異。
基于以上模塊,containerd 提供了 namespace 隔離,實現上是在各模塊的內容放置于不同目錄樹,達到資源隔離效果。比如,它可以一邊服務于 Docker,一邊服務 k8s kubelet,做到兩不沖突。
還有重要模塊是 Tasks (runtime.PlatformRuntime),它負責容器進程管理和與低級運行時打交道,對上統一了容器進程運行接口。v1 版 Tasks 只支持 Linux,1.2.0 (2018/11) 后 containerd 正式支持 Windows,新引入的 v2 版 Tasks 核心邏輯使用平臺無關代碼實現,因此可以在 Go 語言支持的大部分平臺運行(包括 macOS darwin/amd64)。
containerd 運行容器,一般先從 Images 模塊觸發,結合 Snapshot 模塊建立新的容器 fs layer,加上低級運行時信息,組合成 container 結構體。containerd 利用 container 結構體,將之前的所有 Snapshots 轉換為 Mounts 對象(聲明了所有子文件夾的位置和掛載方式),結合低級運行時、OCI spec、鏡像等信息在請求體中,向 Tasks 模塊提交任務請求。Tasks 模塊 Manager 根據任務低級運行時信息(如 io.containerd.runc.v1),組合出統一的 containerd-shim 進程運行命令,通過系統調用啟動 shim 進程,并同步建立與 shim 進程的 TTRPC 通信。隨后將任務交給 shim 進程管理。shim 進程接到請求后,判知 Mounts 長度大于 0,則會按照 Mounts 聲明的掛載方式,使用 overlay、aufs 等聯合文件系統將所有子文件夾組成容器運行需要的 rootfs,結合 OCI spec 調用低級運行時運行容器進程并將結果返回給 containerd 進程。
使用 shim 進程管理容器進程好處很多,containerd clash,containerd-shim 進程和容器進程不會受影響,containerd 恢復后只需讀取運行目錄的 socket 文件及 pid 恢復與 shim 進程通信即可快速還原 Tasks 信息(Unix 平臺),同一容器進程出現問題,對于其他進程來說是隔離。最重要的是,通過統一的 shim 接口,同一套 containerd 代碼可以同時兼容多個不同的運行時,也能同時兼容不同操作系統平臺。
containerd 不提供容器網絡和容器應用狀態存儲解決方案,而是把它們留給了更高層的實現。
container 在其?介紹?中提到:其設計目的是成為大系統中的一個組件(如 Kubernetes, Docker),而非直接供用戶使用。
containerd is designed to be embedded into a larger system, rather than being used directly by developers or end-users。
下文會展示這意味著什么。
CRI-O
相比 containerd,CRI-O 的高級運行時功能基于若干開源庫實現,不同模塊之間為純粹 Go 語言依賴,而非通信協議:
? containers/image 庫用于 Image 下載,下載過程類似 2 階段提交。不同來源的鏡像(如 Docker, Openshift, OCI)先被統一為 ImageSource 通用抽象,接著被分為 3 部分進行處理:blob 被放置在系統臨時文件夾,manifest 和 signature 緩存在內存(Put*)。之后,鏡像內容 Commit 至 containers/storage 庫。
??CRI-O 大部分業務邏輯集中在 containers/storage 之上
??LayerStore 接口統一處理 image layer(不包括 config layer) 和 fs layer,鏡像 Commit 存儲時,LayerStore 先調用 fs 驅動實現(如 overlay)在磁盤創建 fs layer 目錄并記錄層次關系,接著調用 ApplyDiff 方法,解壓內容被存放在 layer 目錄(經驅動實現),未解壓內容被存放在 image layer 目錄,fs layer 層次關系存儲在 json 文件。
??ImageStore 接口處理 image meta,包括 manifest、config 和 signature,meta 與 layer 關聯索引存儲在 json 文件。
??ContainerStore 接口管理 container meta,創建 container 的步驟和存儲 image layer 代碼路徑近乎重合,只不過前者被限制為 read 模式,后者為 readWrite,且沒有 ApplyDiff(diff 送空),meta 與 layer 關聯索引也存儲在 json 文件。
containers/storage 庫 container meta 沒有 namespace 概念,但提供一個 metadata 字段(string 類型)可以存儲任意內容,CRI-O 便是將包括 namespace 在內的業務信息序列化為 json string 存儲其中。
CRI-O 運行容器進程時,先確保對應 image 存在(不存在則嘗試下載),隨之基于 image top layer 創建 UnionFS,同時生成 OCI spec config.json,之后,根據請求方提供的低級運行時信息(RuntimeHandler),使用不同包裝實現操作容器進程。
??如果 RuntimeHandler 為非 VM 類型,創建并委托監視進程?conmon?操作低級運行時創建容器。之后,conmon 在特定路徑提供一個可與容器進程通信的 socket 文件,并負責持續監視容器進程并負責將其 stream 寫入指定日志文件。容器進程創建成功之后,CRI-O 直接與低級運行時交互執行 start、delete、update 等操作,或者通過 socket 文件直接與容器進程交互。
??如果 RuntimeHandler 為 VM,則創建并委托 containerd-shim 進程處理間接容器進程(請求包含完整 rootfs,Mounts 為 空)。與非 VM 類型不同,此后所有容器進程相關操作均通過 shim 完成。
CRI-O 架構圖
CRI-O 依靠 CNI 插件(默認路徑 /opt/cni/bin)為容器進程提供網絡實現。其邏輯一般在低級運行時創建完隔離進程返回后,獲取 pid 后將對應的 network namespace path(/proc/{pid}/ns/net)交給 CNI 處理,CNI 會根據配置會往對應 namespace 添加好網卡。一般地,容器進程會在 cni 網橋上獲得一個獨立 IP 地址,這個 IP 地址能與宿主機通信,如果 CNI 配置了 flannel 之類的 overlay 實現,容器甚至能夠與其他主機的同一網段容器進程通信,具體視配置而定。細節方面可以參考?這篇介紹。
如果指定由其管理 network namespace 生命周期(配置 manage_ns_lifecycle),則會在創建 sandbox 容器時采用類似?理解 OCI#給 runC 容器綁定虛擬網卡?的方式創建虛擬網卡,隨后通過 OCI json config 傳遞對應路徑給低級運行時。同樣地,當 sandbox 容器銷毀時,CRI-O 會自動回收對應 namespace 資源。這部分邏輯的網絡相關代碼使用 C 語言實現,在 CRI-O 中以名為 pinns 的二進制程序發行。
需要指出的是,CRI-O 使用文件掛載方式配置容器 hostname, dns server 等,而非 CNI 插件。
Docker
Docker 是一個大而完備的高級運行時,其用戶端核心叫做?Docker Engine,由 3 部分構成:Docker Server (docker daemon, 簡稱 dockerd)、REST API 和 Docker cli。借助 Docker Engine 既能便捷地運行容器進程進行集成開發、也能快速構建分發鏡像。
img
如上圖所示,Docker Engine 的核心是 dockerd,既驅動鏡像的構建分發,也為容器運行提供成熟的持久實現和網絡實現。Docker cli 使用 REST API 與 dockerd 交互。
與上文其他運行時不同,dockerd 以 image config 為核心,使用 config layer 的 sha256 hash 值索引 image 抽象,而不是 manifest。實際上,dockerd 根本不存儲 manifest。dockerd 也不存儲 image layers(tar, tar.gz 等),而只存儲解壓后的 layer fs 和一些必要的索引。
??鏡像下載時,dockerd 先自 registry 獲取 manifest 文件,隨后并行下載存儲 image layers 和 config layer。與 containers/storag 類似,image layer 解壓內容由 fs 驅動實現(如 overlay) 存儲至新建的子目錄中(如 /var/lib/docker/overlay2/{new-dir}),不同的是,隨后 dockerd 只是以 layer chainID 為索引,存儲 fs new-dir、diffID、parent chainID、size 等必要信息,并不存儲未解壓 tar 或 tar.gz。image layers 和 config layer 均存儲完成后,再以 image reference 為索引,建立 reference 至 image ID 映射。作為鏡像分發模塊的一部分,dockerd 還會以 manifest layer digest 為索引,建立 digest 至 diffID 映射;以 diffID 為索引,建立 diffID 至 repository 和 digest 映射。
??鏡像推送不過是鏡像下載的逆過程。dockerd 先使用 reference 獲取 imageID(也即 image config),隨后以 imageID 為中心組織出目標 manifest,對應的 layer fs 開始被壓縮成目標格式(一般是 tar.gz)。layers 開始上傳時,自分發模塊獲取 diffID 至 repository 和 digest 信息,發起遠程請求確認對應 layer 是否已存在,存在則跳過上傳,最終以 manifest 為中心的鏡像被分發至對應 Registry 實現。
Docker Engine 配套了成熟的鏡像構建技術,它使得開發者只需提供一個目錄、一份 Dockerfile,外加一行?docker build?命令即可構建鏡像。簡單來看,鏡像構建過程即是把應用依賴的文件系統和運行環境轉化為 image layers 和 config 的過程,構建結果是能夠索引到構建結果的 reference,即我們熟悉的 tag。但簡單的接口后面隱藏著非常多的考量,比如怎樣提高鏡像構建速度,比如怎樣檢查構建期錯誤。我們已經知道一份鏡像包含多份 layers,基于什么鏡像構建新鏡像就會在之前的 layers 上構建新 layers。實際上,dockerd 會將 Dockerfile 中的每一行命令轉化為一個構建子步驟,每執行一步,都可能產生中間鏡像和中間容器。COPY,?ADD?等文件傳輸命令一般直接產生中間鏡像,RUN、ENV、EXPOSE?等運行命令會產生中間容器。每成功一步,該步驟產生的中間鏡像或者 config 就會成為下一步的基礎,產生的中間容器隨之被移除,產生的中間鏡像會被保存供后續復用。構建結束時,最后一步產生的鏡像會被關聯到 tag(如果指定了)。dockerd 維護了鏡像構建過程產生的 parent-child 關系,使用?docker image ls?命令羅列鏡像時,沒有 tag 且存在 child 的鏡像會被過濾,如此便過濾了中間鏡像。此外,docker cli 會將中間結果輸出到控制臺,這樣如果構建出錯,用戶可以利用間鏡像和中間容器排查問題。
Docker 容器創建運行相較 containerd 和 CRI-O 有更多高層的存儲和網絡抽象,如使用?-v,--volume?命令即可聲明運行時需掛載的文件系統,使用?-p,--publish?即可聲明 host 網絡至容器網絡映射,這些聲明信息會被持久在 docker 工作目錄下的 containers 子目錄。
執行運行命令之際,dockerd 首先生成容器讀寫層并通過 UnionFS 與 fs layers 一道轉化為 rootfs。接著,image config 中的環境、啟動參數等信息被轉化為 OCI runtime spec 參數。同時類似 CRI-O,dockerd 會為容器生成一些特殊的文件,如 /etc/hosts, /etc/hostname, /etc/resolv.conf, /dev/shim 等,隨之這些特殊文件與 volume 聲明或者 mount 聲明一起作為 dockerd Mount 抽象轉化為 OCI runtime spec Mount 參數。最后,rootfs、OCI runtime spec 和低級運行時信息通過 RPC 請求傳遞給 containerd,劇情變得和 containerd 運行容器一致。
不難發現,雖然持久掛載驅動各異,但對運行時而言,本質都是將宿主機某類型的文件目錄映射到容器文件系統中。因此對于低級運行時而言,掛載邏輯可以統一。dockerd 在此之上發展了豐富的持久業務層,以便于用戶使用。mount 用于直接將宿主機目錄掛載至容器文件系統;volume 相對 bind mounts 優勢是對應文件持久在 dockerd 的工作目錄,由 dockerd 管理,同時具有跨平臺能力。tmpfs 則由操作系統提供容器讀寫層之外的臨時存儲能力。
dockerd 支持多種網絡驅動,其基礎抽象叫做 endpoint,可以簡單將 endpoint 理解為網卡背后的網絡資源。對于每一 endpoint,dockerd 都會通過 IPAM 實現在 docker0 網橋上分配 IP 地址,接著通過 bridge 等驅動為容器創建網卡,如果使用?publish?參數配置了容器至宿主機的 port 映射,dockerd 會往宿主機 iptable 添加對應網絡規則,同時還可能會啟動 docker proxy 服務 forward 流量到容器。容器的所有 endpoints 被放置在 sandbox 抽象中。準備好網絡資源后,dockerd 調用 containerd 運行容器時,會在 OCI spec 中設置 Prestart Hook 命令,該命令包含了設置網絡的必要信息(容器ID,容器進程ID,sandbox ID)。低級運行時實現如 runC 會在容器進程被創建但未被運行前調用該命令,該命令最終將容器ID,容器進程ID,sandbox ID 傳遞給 dockerd,dockerd 隨即將 sandbox 中的所有 endpoint 資源綁定到容器網絡 namespace 中(也是 /proc/{ctr-pid}/ns/net)。
總結
上文簡述了 containerd, CRI-O 和 Docker 運行時的基本原理和其基于低級運行時提供的高級功能。Docker 作為提供功能最多最高層實現,放在最后是方便漸進式理解容器技術構成。
實際上,目前容器生態的技術和 OCI 標準,大都源自 Docker。Docker 抽離其容器管理邏輯發展出了 containerd 項目,并隨后使用它作為自己的低層運行時。
libnetwork 庫?賦能了 docker (19.03) 網絡實現,也演化自 Docker。
上文提到,Docker 鏡像構建過程會產生中間鏡像和中間容器,這類中間產物提升了構建速度,但是也帶來了使用負擔(看著莫名其妙,清理費勁)。同時,很多公司有持續、大規模構建鏡像的需求,他們往往希望負責構建鏡像系統能夠以 HTTP 或者 gRPC 的方式對其他系統暴露服務,而 dockerd 在設計上只是一個本地服務。因此在 2017 后,dockerd 中的構建功能逐步發展成了?buildkit 項目,對應考量見?docker issuse 32925。Docker 在 18.06 版本后開始支持 buildkit,使用此種方式構建鏡像有著相近的性能且不會產生中間鏡像和中間容器。
從 Docker 業務層越變越薄的情況可以看出,隨著社區對 OCI 規范的靠攏,容器技術模塊朝著越來越精細化的方向發展,同時模塊的復用程度變得越來越強。如果某家公司想要加強容器的隔離能力,只需關心如何結合操作系統技術實現低級運行并基于 containerd 提供 shim 實現即可迅速將自家技術集成進 Docker 或者 Kubernetes,這樣就沒有必要把高級運行時提供的能力再實現一遍。這種類比可以推廣到網絡、存儲、鏡像分發等方面。
CRI-O 項目初衷是嫌棄 Docker 功能太多,打算做一個 Kubernetes 專用運行時,不需要鏡像構建、不需要鏡像推送、不需要復雜的網絡和存儲。但它的業務層同樣很薄,代碼多復用社區的 containers/storage 庫和 containers/image 庫,同時會利用 containerd-shim 運行 vm container。運行 Linux container 情況下,純 C 的 conmon 守護進程實現相較 Go 實現的 containerd-shim 有更少的內存消耗。
另外兩個運行時?PouchContainer?和?frakti?社區的日趨死寂在另一面反映了這種演進趨勢。PouchContainer 最近一次發布還在 2019 年 1 月,frakti 是 2018 年 11 月。隨著 containerd 跨平臺能力的加強和其對 Kubernetes 的直接支持(2018/11 1.2.0 引入 shim-v2、CRI 插件),很多低級運行時,如 gvisor、kata-runtime,更趨向于直接提供 containerd-shim 實現以集成進容器生態,而不是再造一邊輪子。PouchContainer 試圖打造一個鏡像分發速度更快(利用 P2P),強隔離(利用 vm container、lxcfs 等),隨著 containerd 和 Docker 的演進,這些 feature 優勢變得越來越小,開源社區對 PouchContainer 的興趣越來越弱實屬當然。frakti 目的是打造一個支持 runV(kata-runtime 前身)的 Kubernetes 運行時,隨著 runV 和 Clear Containers 合而為 kata-containers 項目,而后者可利用 containerd-shim 直接集成進生態,frakti 便變得越來越無意義。
編輯:黃飛
?
評論
查看更多