不過,我相信你在學習和使用 Kubernetes 項目的過程中,已經不止一次地想要問這樣一個問題:為什么我們會需要 Pod?
是啊,我們在前面已經花了很多精力去解讀 Linux 容器的原理、分析了 Docker 容器的本質,終於,“Namespace 做隔離,Cgroups 做限制,rootfs 做文件系統”這樣的“三句箴言”可以朗朗上口了,為什么 Kubernetes 項目又突然搞出一個 Pod 來呢?
要回答這個問題,我們還是要一起回憶一下我曾經反復強調的一個問題:容器的本質到底是什么?
你現在應該可以不假思索地回答出來:容器的本質是進程。
沒錯。容器,就是未來雲計算系統中的進程;容器鏡像就是這個系統里的“.exe”安裝包。那么 Kubernetes 呢? 你應該也能立刻回答上來:Kubernetes 就是操作系統! 非常正確。 現在,就讓我們登錄到一台 Linux 機器里,執行一條如下所示的命令:
pstree -g
這條命令的作用,是展示當前系統中正在運行的進程的樹狀結構。它的返回結果如下所示:
systemd(1)-+-accounts-daemon(1984)-+-{gdbus}(1984)
| `-{gmain}(1984)
|-acpid(2044)
...
|-lxcfs(1936)-+-{lxcfs}(1936)
| `-{lxcfs}(1936)
|-mdadm(2135)
|-ntpd(2358)
|-polkitd(2128)-+-{gdbus}(2128)
| `-{gmain}(2128)
|-rsyslogd(1632)-+-{in:imklog}(1632)
| |-{in:imuxsock) S 1(1632)
| `-{rs:main Q:Reg}(1632)
|-snapd(1942)-+-{snapd}(1942)
| |-{snapd}(1942)
| |-{snapd}(1942)
| |-{snapd}(1942)
| |-{snapd}(1942)
不難發現,在一個真正的操作系統里,進程並不是“孤苦伶仃”地獨自運行的,而是以進程組的方式,“有原則地”組織在一起。比如,這里有一個叫作 rsyslogd 的程序,它負責的是 Linux 操作系統里的日志處理。可以看到,rsyslogd 的主程序 main,和它要用到的內核日志模塊 imklog 等,同屬於 1632 進程組。這些進程相互協作,共同完成 rsyslogd 程序的職責。
注意:我在本篇中提到的“進程”,比如,rsyslogd 對應的 imklog,imuxsock 和 main,嚴格意義上來說,其實是 Linux 操作系統語境下的“線程”。這些線程,或者說,輕量級進程之間,可以共享文件、信號、數據內存、甚至部分代碼,從而緊密協作共同完成一個程序的職責。所以同理,我提到的“進程組”,對應的也是 Linux 操作系統語境下的“線程組”。這種命名關系與實際情況的不一致,是 Linux 發展歷史中的一個遺留問題。
項目所做的,其實就是將“進程組”的概念映射到了容器技術中,並使其成為了這個雲計算“操作系統”里的“一等公民”。
Kubernetes 項目之所以要這么做的原因,我在前面介紹 Kubernetes 和 Borg 的關系時曾經提到過:在 Borg 項目的開發和實踐過程中,Google 公司的工程師們發現,他們部署的應用,往往都存在着類似於“進程和進程組”的關系。更具體地說,就是這些應用之間有着密切的協作關系,使得它們必須部署在同一台機器上。
而如果事先沒有“組”的概念,像這樣的運維關系就會非常難以處理。
我還是以前面的 rsyslogd 為例子。已知 rsyslogd 由三個進程組成:一個 imklog 模塊,一個 imuxsock 模塊,一個 rsyslogd 自己的 main 函數主進程。這三個進程一定要運行在同一台機器上,否則,它們之間基於 Socket 的通信和文件交換,都會出現問題。
現在,我要把 rsyslogd 這個應用給容器化,由於受限於容器的“單進程模型”,這三個模塊必須被分別制作成三個不同的容器。而在這三個容器運行的時候,它們設置的內存配額都是 1 GB。
再次強調一下:容器的“單進程模型”,並不是指容器里只能運行“一個”進程,而是指容器沒有管理多個進程的能力。這是因為容器里 PID=1 的進程就是應用本身,其他的進程都是這個 PID=1 進程的子進程。可是,用戶編寫的應用,並不能夠像正常操作系統里的 init 進程或者 systemd 那樣擁有進程管理的功能。比如,你的應用是一個 Java Web 程序(PID=1),然后你執行 docker exec 在后台啟動了一個 Nginx 進程(PID=3)。可是,當這個 Nginx 進程異常退出的時候,你該怎么知道呢?這個進程退出后的垃圾收集工作,又應該由誰去做呢?
假設我們的 Kubernetes 集群上有兩個節點:node-1 上有 3 GB 可用內存,node-2 有 2.5 GB 可用內存。
這時,假設我要用 Docker Swarm 來運行這個 rsyslogd 程序。為了能夠讓這三個容器都運行在同一台機器上,我就必須在另外兩個容器上設置一個 affinity=main(與 main 容器有親密性)的約束,即:它們倆必須和 main 容器運行在同一台機器上。
然后,我順序執行:“docker run main”“docker run imklog”和“docker run imuxsock”,創建這三個容器。
這樣,這三個容器都會進入 Swarm 的待調度隊列。然后,main 容器和 imklog 容器都先后出隊並被調度到了 node-2 上(這個情況是完全有可能的)。
可是,當 imuxsock 容器出隊開始被調度時,Swarm 就有點懵了:node-2 上的可用資源只有 0.5 GB 了,並不足以運行 imuxsock 容器;可是,根據 affinity=main 的約束,imuxsock 容器又只能運行在 node-2 上。
這就是一個典型的成組調度(gang scheduling)沒有被妥善處理的例子。
在工業界和學術界,關於這個問題的討論可謂曠日持久,也產生了很多可供選擇的解決方案。 比如,Mesos 中就有一個資源囤積(resource hoarding)的機制,會在所有設置了 Affinity 約束的任務都達到時,才開始對它們統一進行調度。而在 Google Omega 論文中,則提出了使用樂觀調度處理沖突的方法,即:先不管這些沖突,而是通過精心設計的回滾機制在出現了沖突之后解決問題。
可是這些方法都談不上完美。資源囤積帶來了不可避免的調度效率損失和死鎖的可能性;而樂觀調度的復雜程度,則不是常規技術團隊所能駕馭的。
但是,到了 Kubernetes 項目里,這樣的問題就迎刃而解了:Pod 是 Kubernetes 里的原子調度單位。這就意味着,Kubernetes 項目的調度器,是統一按照 Pod 而非容器的資源需求進行計算的。
所以,像 imklog、imuxsock 和 main 函數主進程這樣的三個容器,正是一個典型的由三個容器組成的 Pod。Kubernetes 項目在調度時,自然就會去選擇可用內存等於 3 GB 的 node-1 節點進行綁定,而根本不會考慮 node-2。
像這樣容器間的緊密協作,我們可以稱為“超親密關系”。這些具有“超親密關系”容器的典型特征包括但不限於:互相之間會發生直接的文件交換、使用 localhost 或者 Socket 文件進行本地通信、會發生非常頻繁的遠程調用、需要共享某些 Linux Namespace(比如,一個容器要加入另一個容器的 Network Namespace)等等。
這也就意味着,並不是所有有“關系”的容器都屬於同一個 Pod。比如,PHP 應用容器和 MySQL 雖然會發生訪問關系,但並沒有必要、也不應該部署在同一台機器上,它們更適合做成兩個 Pod。
不過,相信此時你可能會有第二個疑問: 對於初學者來說,一般都是先學會了用 Docker 這種單容器的工具,才會開始接觸 Pod。
而如果 Pod 的設計只是出於調度上的考慮,那么 Kubernetes 項目似乎完全沒有必要非得把 Pod 作為“一等公民”吧?這不是故意增加用戶的學習門檻嗎?
沒錯,如果只是處理“超親密關系”這樣的調度問題,有 Borg 和 Omega 論文珠玉在前,Kubernetes 項目肯定可以在調度器層面給它解決掉。
不過,Pod 在 Kubernetes 項目里還有更重要的意義,那就是:容器設計模式。
為了理解這一層含義,我就必須先給你介紹一下Pod+的實現原理。
首先,關於 Pod 最重要的一個事實是:它只是一個邏輯概念。 也就是說,Kubernetes 真正處理的,還是宿主機操作系統上 Linux 容器的 Namespace 和 Cgroups,而並不存在一個所謂的 Pod 的邊界或者隔離環境。
那么,Pod 又是怎么被“創建”出來的呢?
答案是:Pod,其實是一組共享了某些資源的容器。 具體的說:Pod 里的所有容器,共享的是同一個 Network Namespace,並且可以聲明共享同一個 Volume。 那這么來看的話,一個有 A、B 兩個容器的 Pod,不就是等同於一個容器(容器 A)共享另外一個容器(容器 B)的網絡和 Volume 的玩兒法么?
這好像通過 docker run --net --volumes-from 這樣的命令就能實現嘛,比如:
docker run --net=B --volumes-from=B --name=A image-A ...
但是,你有沒有考慮過,如果真這樣做的話,容器 B 就必須比容器 A 先啟動,這樣一個 Pod 里的多個容器就不是對等關系,而是拓撲關系了。
所以,在 Kubernetes 項目里,Pod 的實現需要使用一個中間容器,這個容器叫作 Infra 容器。在這個 Pod 中,Infra 容器永遠都是第一個被創建的容器,而其他用戶定義的容器,則通過 Join Network Namespace 的方式,與 Infra 容器關聯在一起。這樣的組織關系,可以用下面這樣一個示意圖來表達:

如上圖所示,這個 Pod 里有兩個用戶容器 A 和 B,還有一個 Infra 容器。很容易理解,在 Kubernetes 項目里,Infra 容器一定要占用極少的資源,所以它使用的是一個非常特殊的鏡像,叫作:k8s.gcr.io/pause。這個鏡像是一個用匯編語言編寫的、永遠處於“暫停”狀態的容器,解壓后的大小也只有 100~200 KB 左右。
而在 Infra 容器“Hold 住”Network Namespace 后,用戶容器就可以加入到 Infra 容器的 Network Namespace 當中了。所以,如果你查看這些容器在宿主機上的 Namespace 文件(這個 Namespace+文件的路徑,我已經在前面的內容中介紹過),它們指向的值一定是完全一樣的。 這也就意味着,對於 Pod 里的容器 A 和容器 B 來說:
- 它們可以直接使用 localhost 進行通信;
- 它們看到的網絡設備跟 Infra 容器看到的完全一樣;
- 一個 Pod 只有一個 IP 地址,也就是這個 Pod 的 Network Namespace 對應的 IP 地址;
- 當然,其他的所有網絡資源,都是一個 Pod 一份,並且被該 Pod 中的所有容器共享;
- Pod 的生命周期只跟 Infra 容器一致,而與容器 A 和 B 無關。
而對於同一個 Pod 里面的所有用戶容器來說,它們的進出流量,也可以認為都是通過 Infra 容器完成的。這一點很重要,因為將來如果你要為 Kubernetes 開發一個網絡插件時,應該重點考慮的是如何配置這個 Pod 的 Network Namespace,而不是每一個用戶容器如何使用你的網絡配置,這是沒有意義的。
這就意味着,如果你的網絡插件需要在容器里安裝某些包或者配置才能完成的話,是不可取的:Infra 容器鏡像的 rootfs 里幾乎什么都沒有,沒有你隨意發揮的空間。當然,這同時也意味着你的網絡插件完全不必關心用戶容器的啟動與否,而只需要關注如何配置 Pod,也就是 Infra 容器的 Network Namespace 即可。 有了這個設計之后,共享 Volume 就簡單多了:Kubernetes 項目只要把所有 Volume 的定義都設計在 Pod 層級即可。
這樣,一個 Volume 對應的宿主機目錄對於 Pod 來說就只有一個,Pod 里的容器只要聲明掛載這個 Volume,就一定可以共享這個 Volume 對應的宿主機目錄。比如下面這個例子
apiVersion: v1
kind: Pod
metadata:
name: two-containers
spec:
restartPolicy: Never
volumes:
- name: shared-data
hostPath:
path: /data
containers:
- name: nginx-container
image: nginx
volumeMounts:
- name: shared-data
mountPath: /usr/share/nginx/html
- name: debian-container
image: debian
volumeMounts:
- name: shared-data
mountPath: /pod-data
command: ["/bin/sh"]
args: ["-c", "echo Hello from the debian container > /pod-data/index.html"]
在這個例子中,debian-container 和 nginx-container 都聲明掛載了 shared-data 這個 Volume。而 shared-data 是 hostPath 類型。所以,它對應在宿主機上的目錄就是:/data。而這個目錄,其實就被同時綁定掛載進了上述兩個容器當中。
這就是為什么,nginx-container 可以從它的/usr/share/nginx/html 目錄中,讀取到 debian-container 生成的 index.html 文件的原因。
明白了 Pod 的實現原理后,我們再來討論“容器設計模式”,就容易多了。 Pod 這種“超親密關系”容器的設計思想,實際上就是希望,當用戶想在一個容器里跑多個功能並不相關的應用時,應該優先考慮它們是不是更應該被描述成一個 Pod 里的多個容器。為了能夠掌握這種思考方式,你就應該盡量嘗試使用它來描述一些用單個容器難以解決的問題。
第一個最典型的例子是:WAR 包與 Web 服務器。
我們現在有一個 Java Web 應用的 WAR 包,它需要被放在 Tomcat 的 webapps 目錄下運行起來。
假如,你現在只能用 Docker 來做這件事情,那該如何處理這個組合關系呢?
一種方法是,把 WAR 包直接放在 Tomcat 鏡像的 webapps 目錄下,做成一個新的鏡像運行起來。可是,這時候,如果你要更新 WAR 包的內容,或者要升級 Tomcat 鏡像,就要重新制作一個新的發布鏡像,非常麻煩。
另一種方法是,你壓根兒不管 WAR 包,永遠只發布一個 Tomcat 容器。不過,這個容器的 webapps 目錄,就必須聲明一個 hostPath 類型的 Volume,從而把宿主機上的 WAR 包掛載進 Tomcat 容器當中運行起來。不過,這樣你就必須要解決一個問題,即:如何讓每一台宿主機,都預先准備好這個存儲有 WAR 包的目錄呢?這樣來看,你只能獨立維護一套分布式存儲系統了。
實際上,有了 Pod 之后,這樣的問題就很容易解決了。我們可以把 WAR 包和 Tomcat 分別做成鏡像,然后把它們作為一個 Pod 里的兩個容器“組合”在一起。這個 Pod 的配置文件如下所示:
apiVersion: v1
kind: Pod
metadata:
name: javaweb-2
spec:
initContainers:
- image: geektime/sample:v2
name: war
command: ["cp", "/sample.war", "/app"]
volumeMounts:
- mountPath: /app
name: app-volume
containers:
- image: geektime/tomcat:7.0
name: tomcat
command: ["sh","-c","/root/apache-tomcat-7.0.42-v2/bin/start.sh"]
volumeMounts:
- mountPath: /root/apache-tomcat-7.0.42-v2/webapps
name: app-volume
ports:
- containerPort: 8080
hostPort: 8001
volumes:
- name: app-volume
emptyDir: {}
在這個 Pod 中,我們定義了兩個容器,第一個容器使用的鏡像是 geektim/sample:v2,這個鏡像里只有一個 WAR 包(sample.war)放在根目錄下。而第二個容器則使用的是一個標准的 Tomcat 鏡像。
不過,你可能已經注意到,WAR 包容器的類型不再是一個普通容器,而是一個 Init Container 類型的容器。 在 Pod 中,所有 Init Container 定義的容器,都會比 spec.containers 定義的用戶容器先啟動。並且,Init Container 容器會按順序逐一啟動,而直到它們都啟動並且退出了,用戶容器才會啟動。
所以,這個 Init Container 類型的 WAR 包容器啟動后,我執行了一句"cp /sample.war /app",把應用的 WAR 包拷貝到 /app 目錄下,然后退出。
而后這個 /app 目錄,就掛載了一個名叫 app-volume 的 Volume。
接下來就很關鍵了。Tomcat 容器,同樣聲明了掛載 app-volume 到自己的 webapps 目錄下。
所以,等 Tomcat 容器啟動時,它的 webapps 目錄下就一定會存在 sample.war 文件:這個文件正是 WAR 包容器啟動時拷貝到這個 Volume 里面的,而這個 Volume 是被這兩個容器共享的。
像這樣,我們就用一種“組合”方式,解決了 WAR 包與 Tomcat 容器之間耦合關系的問題。
實際上,這個所謂的“組合”操作,正是容器設計模式里最常用的一種模式,它的名字叫:sidecar。
顧名思義,sidecar 指的就是我們可以在一個 Pod 中,啟動一個輔助容器,來完成一些獨立於主進程(主容器)之外的工作。
比如,在我們的這個應用 Pod 中,Tomcat 容器是我們要使用的主容器,而 WAR 包容器的存在,只是為了給它提供一個 WAR 包而已。所以,我們用 Init Container 的方式優先運行 WAR 包容器,扮演了一個 sidecar 的角色。
第二個例子,則是容器的日志收集
比如,我現在有一個應用,需要不斷地把日志文件輸出到容器的 /var/log 目錄中。
這時,我就可以把一個 Pod 里的 Volume 掛載到應用容器的 /var/log 目錄上。 然后,我在這個 Pod 里同時運行一個 sidecar 容器,它也聲明掛載同一個 Volume 到自己的 /var/log 目錄上。
這樣,接下來 sidecar 容器就只需要做一件事兒,那就是不斷地從自己的 /var/log 目錄里讀取日志文件,轉發到 MongoDB 或者 Elasticsearch 中存儲起來。這樣,一個最基本的日志收集工作就完成了。 跟第一個例子一樣,這個例子中的 sidecar 的主要工作也是使用共享的 Volume 來完成對文件的操作。
但不要忘記,Pod 的另一個重要特性是,它的所有容器都共享同一個 Network Namespace。這就使得很多與 Pod 網絡相關的配置和管理,也都可以交給 sidecar 完成,而完全無須干涉用戶容器。這里最典型的例子莫過於 Istio 這個微服務治理項目了。 Istio 項目使用 sidecar 容器完成微服務治理的原理,我在后面很快會講解到。
備忘
Pod,實際上是在扮演傳統基礎設施里“虛擬機”的角色;而容器,則是這個虛擬機里運行的用戶程序。
k8s.gcr.io/pause 的存在使得一個pod中的所有鏡像可以:
- 它們可以直接使用 localhost 進行通信;
- 它們看到的網絡設備跟 Infra 容器看到的完全一樣;
- 一個 Pod 只有一個 IP 地址,也就是這個 Pod 的 Network Namespace 對應的 IP 地址;
- 當然,其他的所有網絡資源,都是一個 Pod 一份,並且被該 Pod 中的所有容器共享;
- Pod 的生命周期只跟 Infra 容器一致,而與容器 A 和 B 無關。
