轉載自:K8sMeetup社群
ID:Kuberneteschina2
作者:吳葉磊(PingCAP)
編輯:小君君(才雲)
近年來,Runtime(容器執行時)發展迅速,種類也日漸豐富:Docker、rkt、containerd、cri-o、Kata、gVisor……面對這麼多的選擇,如果你正打算部署一個容器系統或 Kubernetes 叢集,你會如何選擇呢?在這篇文章中,來自 PingCAP 的工程師吳葉磊將從典型的 Runtime 架構、OCI、CRI 與被濫用的名詞“Runtime”等方向,生動闡述什麼是 Runtime 以及它們的關係和特點。
在剛開始接觸 Kubernetes 的時候,相信很多人都經常搞不懂 CRI 與 OCI 的聯絡和區別,也不知道為什麼要墊那麼多的“shim”(尤其是 containerd-shim 和 dockershim 這兩個完全沒什麼關聯的東西都被稱為 shim)。這篇文章就和大家一起聊聊 Kubernetes Runtime,把下麵這張 Landscape 裡的核心專案陳述清楚:
透過本文你將瞭解到:
-
典型的 Runtime 架構;
-
大話容器歷史;
-
OCI、CRI 與被濫用的名詞“Runtime”;
-
containerd 和 CRI-O;
-
強隔離容器:Kata、gVisor、firecracker;
-
安全容器與 Serverless。
典型的 Runtime 架構
首先,本文從最常見的 Runtime 方案 Docker 說起:
當 Kubelet 想要建立一個容器時,它需要以下幾個步驟:
-
Kubelet 透過 CRI 介面(gRPC)呼叫 dockershim,請求建立一個容器,CRI(容器執行時介面,Container Runtime Interface)。在這一步中 , Kubelet 可以視作一個簡單的 CRI Client,而 dockershim 就是接收請求的 Server。目前 dockershim 的程式碼其實是內嵌在 Kubelet 中的,所以接收呼叫的就是 Kubelet 行程;
-
dockershim 收到請求後,它會轉化成 Docker Daemon 能聽懂的請求,發到 Docker Daemon 上,並請求建立一個容器;
-
Docker Daemon 早在 1.12 版本中就已經將針對容器的操作移到另一個守護行程 containerd 中了。因此 Docker Daemon 仍然不能幫人們建立容器,而是需要請求 containerd 建立一個容器;
-
containerd 收到請求後,並不會自己直接去操作容器,而是建立一個叫做 containerd-shim 的行程,讓 containerd-shim 去操作容器。這是因為容器行程需要一個父行程來做諸如收集狀態、維持 stdin 等 fd 開啟工作。假如這個父行程就是 containerd,那每次 containerd 掛掉或升級後,整個宿主機上所有的容器都需要退出,但是引入了 containerd-shim 就規避了這個問題(containerd 和 shim 並不是父子行程關係);
-
建立容器是需要做一些設定 namespace 和 Cgroups、掛載 root filesystem 的操作。這些事已經有了公開的規範 OCI(Open Container Initiative,開放容器標準)。它的一個參考實現叫做 runc。containerd-shim 在這一步需要呼叫 runc 這個命令列工具,來啟動容器;
-
runc 啟動完容器後,它會直接退出,containerd-shim 則會成為容器行程的父行程,負責收集容器行程的狀態,上報給 containerd。併在容器中 pid 為 1 的行程退出後接管容器中的子行程,然後進行清理,確保不會出現僵屍行程。
Docker Daemon 和 dockershim 看上去就像是兩個不幹活的元件,Kubelet 為啥不直接呼叫 containerd 呢?
當然是可以的!但是,在瞭解這個之前,大家不妨先看看為什麼現在的架構如此繁冗。
大話容器歷史
其實 Kubernetes 最開始的 Runtime 架構遠沒這麼複雜:Kubelet 想要建立容器可以直接通知 Docker Daemon,那時也不存在 containerd。Docker Daemon 自行調節libcontainer
庫就可以把容器跑起來。
而熟悉容器和容器編排歷史的讀者應該知道,在這之後就是容器圈的一系列政治鬥爭。先是大佬們認為 Runtime 標準不能被 Docker 一家公司控制,於是推出了開放容器標準 OCI。Docker 則把libcontainer
封裝起來 , 變成 runc 捐獻出來,作為 OCI 的參考實現。
此時 rkt 也想從 Docker 那邊分一杯羹,希望 Kubernetes 原生支援 rkt 作為 Runtime,並且 PR 也成功的合進去了。接觸過一塊業務同時接兩個需求方的讀者應該都知道類似這樣的事情處理起來很麻煩,Kubernetes 中負責維護 Kubelet 的小組 sig-node 也被這件事狠狠的坑了一把。
後來,大家認為這樣做是不行的。今天能有 rkt,明天就能有其他的什麼出來,長此以往,sig-node 小組的工作便無法進行下去(每天都需要處理相容性的 bug)。於是,Kubernetes v1.5 推出了 CRI 機制(即容器執行時介面,Container Runtime Interface)。Kubernetes 藉此告知大家 , 只要能實現這個介面,誰都可以做 Runtime。
不過 CRI 本身只是 Kubernetes 的一個標準。當時的 Kubernetes 尚未達到如今這般舉足輕重的地位,容器執行時也不會與 Kubernetes 綁死,只提供 CRI 介面。於是就有了 shim(墊片),一個 shim 的職責就是作為 Adapter 將各種容器執行時本身的介面適配到 Kubernetes 的 CRI 介面上。
接下來,Docker 的 Swarm 為進軍 PaaS 市場,做了個架構切分,將容器操作都移動到一個單獨的 Daemon 行程 containerd 中,讓 Docker Daemon 專門負責上層的封裝編排。可惜 Swarm 並沒有 Kubernetes 那般功能強大。失敗之後,Docker 公司就把 containerd 專案捐給 CNCF,專心做 Docker 企業版。
經過這些事情之後,就是讀者們在上一張圖中看到的那些東西了。儘管現在已經有 CRI-O、containerd-plugin 這樣更精簡輕量的 Runtime 架構,但是 dockershim 這一套作為經受了最多生產環境考驗的方案,迄今為止仍是 Kubernetes 預設的 Runtime 實現。
瞭解這些具體的架構,有時能幫助人們在排除故障時少走彎路,但更重要的是它們能作為一個例子 , 幫助人們更好地理解整個 Kubernetes Runtime 背後的設計邏輯 。
OCI、CRI 與被濫用的名詞“Runtime”
OCI,也就是前文提到的“開放容器標準”,在官方檔案中主要規定了兩點:
-
容器映象應該是什麼樣的,即 ImageSpec。它大致規定的是,你的容器映象需要是一個壓縮了的檔案夾,檔案夾裡以 xxx 結構放入 xxx 檔案中;
-
容器要需要能接收哪些指令,這些指令的行為是什麼,即 RuntimeSpec。簡單來說,它規定的就是“容器”要能夠執行“create”“start”“stop”“delete”這些命令,並且行為要規範。
runc 為什麼叫參考實現?因為它能按照標準將符合標準的容器映象執行起來。
標準的好處就是方便搞創新,只要研發的東西符合標準,在生態圈裡就能與其它工具一起愉快地工作。那研發人員自行研發的映象就可以用任意的工具去構建,“容器”也不一定非要用 namespace 和 Cgroups 來做隔離。這就讓各種虛擬化容器可以更好地參與到遊戲當中。
而 CRI 更簡單,單純是一組 gRPC 介面,看一眼 kubelet/apis/cri/services.go 就能歸納出幾套核心介面:
-
一套針對容器操作的介面,包括建立、啟停容器等;
-
一套針對映象操作的介面,包括拉取映象、刪除映象等;
-
還有一套針對 PodSandbox(容器沙箱環境)的操作介面。
現在我們可以找到很多符合 OCI 標準或相容了 CRI 介面的專案,這些專案大體構成了整個 Kuberentes 的 Runtime 生態:
- OCI Compatible:runc、Kata(以及它的前身 runV 和 Clear Containers)、gVisor。其它比較偏門的還有 Rust 寫的 railcar;
- CRI Compatible:Docker(藉助 dockershim)、containerd(藉助 CRI-containerd)、CRI-O、frakti 等。
很多讀者可能在最開始學習 Kubernetes 的時候,弄不清 OCI 和 CRI 的區別與聯絡。其中一大原因就是社群裡糟糕的命名:這上面的專案統統可以稱為容器執行時(Container Runtime),彼此之間區分的辦法就是給“容器執行時”這個詞加上各種定語和從句來進行修飾。Go 語言的開源貢獻者和專案成員 Dave Cheney 曾說過:
Good naming is like a good joke. If you have to explain it, it’s not funny.
顯然 Container Runtime 在這裡就不是一個好名字了,更準確的說法是:cri-runtime 和 oci-runtime。透過這個粗略的分類,就可以總結出整個 Runtime 架構萬變不離其宗的三層抽象:
1 Orchestration API -> Container API -> Kernel API
這其中 Kubernetes 已經是 Orchestration API 的事實標準。而在 Kubernetes 中,Container API 的介面標準就是 CRI,由 cri-runtime 實現。Kernel API 的規範是 OCI,由 oci-runtime 實現。
根據這個思路 , 我們就很容易理解下麵這兩種東西:
-
各種更為精簡的 cri-runtime;
-
各種“強隔離”容器方案。
containerd 和 CRI-O
讀者們在第一節就看到現在的 Runtime 實在是有點複雜,後來人們就有了直接拿 containerd 做 oci-runtime 的方案。當然,除了 Kubernetes 之外,containerd 還要接諸如 Swarm 等排程系統,因此它不會去直接實現 CRI。這個適配工作就要交給一個 shim 了。
在 containerd v1.0 中,對 CRI 的適配透過一個單獨的行程CRI-containerd
來完成:
containerd v1.1 中做的又更漂亮一點,砍掉 CRI-containerd 行程,直接把適配邏輯作為外掛放進 containerd 主行程中:
但在 containerd 做這些事情之前,社群就已經有了一個更為專註的 cri-runtime: CRI-O。它非常純粹,可以相容 CRI 和 OCI,做一個 Kubernetes 專用的執行時:
其中conmon
就對應 containerd-shim,大體意圖是一樣的。
CRI-O 和 containerd(直接呼叫)的方案比起預設的 dockershim 簡潔很多,但沒什麼生產環境的驗證案例。本人所知道的僅僅是 containerd 在 GKE 上是 beta 狀態。因此假如你對 Docker 沒有特殊的政治恨意,大可不必把 dockershim 這套換掉。
強隔離容器:Kata、gVisor、firecracker
一直以來 Kubernetes 都有一個被詬病的點:難以實現真正的多租戶。
為什麼這麼說呢?讀者們先考慮一下什麼樣是理想的多租戶狀態:
理想來說,平臺的各個租戶(tenant)之間應該無法感受到彼此的存在,表現得就像每個租戶獨佔整個平臺一樣。具體來說就是,我不能看到其它租戶的資源,我的資源跑滿了,也不能影響其它租戶的資源使用。我無法從網路或核心上攻擊其它租戶。
Kubernetes 當然做不到,其中最大的兩個原因是:
-
kube-apiserver 是整個叢集中的單例,並且沒有多租戶概念;
-
預設的 oci-runtime 是 runc,而 runc 啟動的容器是共享內核的。
一個典型的解決方案就是提供一個新的 OCI 實現,用 VM 來跑容器,實現核心上的硬隔離。runV 和 Clear Containers 都是這個思路。因為這兩個專案做得事情是很類似,後來就合併成了一個專案 Kata Container。Kata 的一張圖很好地解釋了基於虛擬機器的容器與基於 namespaces 和 Cgroups 的容器間的區別:
當然,沒有系統是完全安全的。假如 hypervisor 存在漏洞,那麼使用者仍有可能攻破隔離。但所有的事情都要對比而言,在共享內核的情況下,暴露的攻擊面是非常大的,做安全隔離的難度就像在美利堅和墨西哥之間修 The Great Wall。而當核心隔離之後,只要守住 hypervisor 這道關子就後顧無虞了。
一個 VM 中跑一個容器,聽上去隔離性很不錯,但不是說虛擬機器又笨重又不好管理才切換到容器的嗎,怎麼又要走回去了?
Kata 告訴你,虛擬機器沒那麼邪惡,只是以前沒玩好:
-
不好管理是因為沒有遵循“不可變基礎設施”,以前大家都在虛擬機器上瘋狂的試探。這臺裝 Java 8,那臺裝 Java 6,Admin 是要 angry 的。現在,Kata 則支援 OCI 映象,完全可以用上 Dockerfile + 映象,讓不好管理成為了過去時;
-
笨重是因為之前要虛擬化整個系統。現在我們只著眼於虛擬化應用,那就可以裁剪掉很多功能,把 VM 做得很輕量。因此即便用虛擬機器來做容器,Kata 還是可以將容器啟動時間壓縮得非常短,啟動後在記憶體上和 IO 上的 overhead 也盡可能去最佳化。
不過話說回來,Kubernetes 上的排程單位是 Pod,是容器組,Kata 虛擬機器裡的一個容器。那同一個 Pod 間的容器應該如何做 namespace 的共享?
這就要說回前文講到的 CRI 中,針對 PodSandbox(容器沙箱環境)的操作介面了。本文第一節刻意簡化了場景,只考慮建立一個容器,而沒有討論建立一個Pod。大家都知道,真正啟動 Pod 裡定義的容器之前,Kubelet 會先啟動一個 infra 容器,並執行 /pause 讓 infra 容器的主行程永遠掛起。
這個容器存在的目的就是維持住整個 Pod 的各種 namespace。真正的業務容器只要加入 infra 容器的 network 等 namespace 就能實現對應 namespace 的共享。而 infra 容器創造的這個共享環境則被抽象為 PodSandbox。每次 Kubelet 在建立 Pod 時,就會先呼叫 CRI 的RunPodSandbox
介面啟動一個沙箱環境,再呼叫CreateContainer
在沙箱中建立的容器。
這裡就已經說出答案了,對於 Kata Container 而言,只要在RunPodSandbox
呼叫中建立一個 VM,之後再往 VM 中新增容器就可以了。最後執行 Pod 的樣子就是這樣的:
說完了 Kata,其實 gVisor 和 firecracker 都不言自明瞭,大體上都是類似的,只是:
-
gVisor 並不會去建立一個完整的 VM,而是實現了一個叫“Sentry”的使用者態行程來處理容器的 syscall,而攔截 syscall 並重定向到 Sentry 的過程則由 KVM 或 ptrace 實現;
-
firecracker 稱自己為 microVM,即輕量級虛擬機器,它本身還是基於 KVM 的。不過 KVM 通常使用 QEMU 來虛擬化除 CPU 和記憶體外的資源,比如 IO 裝置、網路裝置。firecracker 則使用 rust 實現了最精簡的裝置虛擬化,為的就是壓榨虛擬化的開銷,越輕量越好。
安全容器與 Serverless
你可能覺得安全容器對自己而言沒什麼用:大不了在每個產品線上都部署 Kubernetes,機器池都隔離掉,從基礎設施的層面就隔離掉。
這麼做當然可以,但同時也要知道,這種做法最終其實是以 IaaS 的方式在賣資源,是做不了真正的 PaaS 乃至 Serverless 的。
Serverless 要做到所有的使用者容器或函式按需使用計算資源,那必須滿足兩點:
-
多租戶強隔離:使用者的容器或函式都是按需啟動按秒計費,我們可不能給每個使用者預先分配一坨隔離的資源。因此我們要保證整個 Platform 是多租戶強隔離的;
-
極度輕量:Serverless 的第一個特點是執行時沙箱會更頻繁地建立和銷毀;第二個特點是切分的粒度會非常非常細,細中細就是 FaaS,一個函式就要一個沙箱。因此就要求兩點:
-
沙箱啟動刪除必須飛快;
-
沙箱佔用的資源越少越好。
這兩點在 long-running,粒度不大的容器執行環境下可能不明顯,但在 Serverless 環境下就會急劇被放大。這時候去做 MicroVM 的 ROI 就比以前要高很多。想想 , 用傳統的 KVM 去跑 FaaS,那還不得虧到姥姥家了?
結語
整篇文章的內容非常多,但 rkt、lxc、lxd 都還沒涉及。為控制篇幅,這裡只提供類比,大家可以自行拓展閱讀:rkt 跟 Docker 一樣是一個容器引擎,特點是無 daemon,目前這個專案基本不活躍;lxc 是 Docker 最早使用的容器工具集,位置可以類比 runc,提供跟 kernel 打交道的庫 & 命令列工具;lxd 則是基於 lxc 的一個容器引擎,只不過大多數容器引擎的標的是容器化應用,lxd 的標的則是容器化作業系統。
最後 , 這篇文章涉及內容較多,如有紕漏,敬請指正!
朋友會在“發現-看一看”看到你“在看”的內容