本文發表於一年多前。舊文章可能包含過時內容。請檢查頁面中的資訊自發布以來是否已變得不正確。
容器執行時介面流式傳輸詳解
Kubernetes 容器執行時介面 (CRI) 是 kubelet 和容器執行時之間的主要連線。這些執行時必須提供一個 gRPC 伺服器,該伺服器必須實現 Kubernetes 定義的 Protocol Buffer 介面。這個 API 定義會隨著時間的推移而演變,例如當貢獻者新增新功能或某些欄位被棄用時。
在這篇博文中,我想深入探討三個非同尋常的遠端過程呼叫 (RPC) 的功能和歷史,它們在工作方式上確實非常突出:Exec
、Attach
和 PortForward
。
Exec 可用於在容器內執行特定命令,並將輸出流式傳輸到像 kubectl 或 crictl 這樣的客戶端。它還允許透過標準輸入 (stdin) 與該程序互動,例如,當用戶想在現有工作負載中執行一個新的 Shell 例項時。
Attach 將當前執行程序的輸出透過標準 I/O 從容器流式傳輸到客戶端,並且也允許與它們進行互動。這在使用者想要檢視容器內部情況並能與程序互動時特別有用。
PortForward 可用於將埠從主機轉發到容器,以便能夠使用第三方網路工具與其互動。這允許它繞過特定工作負載的 Kubernetes 服務並與其網路介面進行互動。
它們有什麼特別之處?
CRI 的所有 RPC 要麼使用 gRPC 一元呼叫進行通訊,要麼使用伺服器端流式傳輸功能(目前只有 GetContainerEvents
)。這意味著幾乎所有的 RPC 都接收單個客戶端請求,並且必須返回單個伺服器響應。Exec
、Attach
和 PortForward
也是如此,它們的協議定義如下:
// Exec prepares a streaming endpoint to execute a command in the container.
rpc Exec(ExecRequest) returns (ExecResponse) {}
// Attach prepares a streaming endpoint to attach to a running container.
rpc Attach(AttachRequest) returns (AttachResponse) {}
// PortForward prepares a streaming endpoint to forward ports from a PodSandbox.
rpc PortForward(PortForwardRequest) returns (PortForwardResponse) {}
這些請求攜帶了伺服器完成工作所需的一切,例如 ContainerId
或在 Exec
情況下要執行的命令 (Cmd
)。更有趣的是,它們的所有響應都只包含一個 url
:
message ExecResponse {
// Fully qualified URL of the exec streaming server.
string url = 1;
}
message AttachResponse {
// Fully qualified URL of the attach streaming server.
string url = 1;
}
message PortForwardResponse {
// Fully qualified URL of the port-forward streaming server.
string url = 1;
}
為什麼是這樣實現的呢?嗯,這些 RPC 的原始設計文件甚至早於 Kubernetes 增強提案 (KEP),最初是在 2016 年提出的。在將此功能引入 CRI 的倡議開始之前,kubelet 對 Exec
、Attach
和 PortForward
都有原生實現。在此之前,一切都繫結到 Docker 或後來被放棄的容器執行時 rkt。
與 CRI 相關的設計文件還詳細闡述了使用原生 RPC 流式傳輸來實現 exec、attach 和 port forward 的選項。但這種方法的缺點超過了優點:kubelet 仍會造成網路瓶頸,未來的執行時在選擇伺服器實現細節方面將失去自由。此外,另一個選項是讓 Kubelet 實現一個可移植的、與執行時無關的解決方案,但這個方案最終也被放棄了,因為這意味著需要維護另一個專案,而這個專案無論如何都將依賴於執行時。
這意味著,Exec
、Attach
和 PortForward
的基本流程被提議為如下所示:
像 crictl 或 kubelet(透過 kubectl)這樣的客戶端使用 gRPC 介面向執行時請求一個新的 exec、attach 或 port forward 會話。執行時實現了一個流式伺服器,該伺服器也管理活動的會話。這個流式伺服器為客戶端提供了一個 HTTP 端點以供連線。客戶端升級連線以使用 SPDY 流式協議或(將來)WebSocket 連線,並開始來回傳輸資料。
這種實現方式讓執行時可以靈活地按照自己想要的方式實現 Exec
、Attach
和 PortForward
,並且還提供了一個簡單的測試路徑。執行時可以更改底層實現以支援任何型別的功能,而完全無需修改 CRI。
在過去幾年中,許多對這種整體方法的小型增強已被合併到 Kubernetes 中,但總體模式始終保持不變。kubelet 原始碼已轉變為一個可重用的庫,如今容器執行時可以使用它來實現基本的流式傳輸能力。
流式傳輸實際上是如何工作的?
乍一看,這三個 RPC 的工作方式似乎相同,但事實並非如此。可以將 Exec 和 Attach 的功能歸為一組,而 PortForward 則遵循一個獨特的內部協議定義。
Exec 和 Attach
Kubernetes 將 Exec 和 Attach 定義為“遠端命令”,其協議定義存在於五個不同版本中:
# | 版本 | 說明 |
---|---|---|
1 | channel.k8s.io | 初始(無版本)的 SPDY 子協議(#13394、#13395) |
2 | v2.channel.k8s.io | 解決了第一個版本中存在的問題 (#15961) |
3 | v3.channel.k8s.io | 增加了對調整容器終端大小的支援 (#25273) |
4 | v4.channel.k8s.io | 增加了使用 JSON 錯誤來支援退出碼 (#26541) |
5 | v5.channel.k8s.io | 增加了對 CLOSE 訊號的支援 (#119157) |
除此之外,還有一個全面的努力,即作為 KEP #4006 的一部分,用 WebSockets 替換 SPDY 傳輸協議。執行時在其生命週期中必須滿足這些協議,以與 Kubernetes 的實現保持同步。
讓我們假設客戶端使用最新(v5
)版本的協議,並透過 WebSockets 進行通訊。在這種情況下,一般的流程將是:
客戶端使用 CRI 為 Exec 或 Attach 請求一個 URL 端點。
- 伺服器(執行時)驗證請求,將其插入連線跟蹤快取中,併為該請求提供 HTTP 端點 URL。
客戶端連線到該 URL,升級連線以建立 WebSocket,並開始流式傳輸資料。
- 在 Attach 的情況下,伺服器必須將主容器程序的資料流式傳輸到客戶端。
- 在 Exec 的情況下,伺服器必須在容器內建立子程序命令,然後將輸出流式傳輸到客戶端。
如果需要標準輸入(stdin),那麼伺服器也需要監聽它,並將其重定向到相應的程序。
為已定義的協議解釋資料相當簡單:每個輸入和輸出資料包的第一個位元組定義了實際的流:
第一個位元組 | 型別 | 描述 |
---|---|---|
0 | 標準輸入 | 從標準輸入流式傳輸的資料 |
1 | 標準輸出 | 流向標準輸出的資料 |
2 | 標準錯誤 | 流向標準錯誤的資料 |
3 | 流錯誤 | 發生了流式傳輸錯誤 |
4 | 流調整大小 | 終端調整大小事件 |
255 | 流關閉 | 流應被關閉(對於 WebSockets) |
現在,執行時應該如何使用所提供的 kubelet 庫來實現 Exec 和 Attach 的流式伺服器方法呢?關鍵在於 kubelet 中的流式伺服器實現定義了一個名為 Runtime
的介面,如果實際的容器執行時想要使用該庫,就必須實現這個介面:
// Runtime is the interface to execute the commands and provide the streams.
type Runtime interface {
Exec(ctx context.Context, containerID string, cmd []string, in io.Reader, out, err io.WriteCloser, tty bool, resize <-chan remotecommand.TerminalSize) error
Attach(ctx context.Context, containerID string, in io.Reader, out, err io.WriteCloser, tty bool, resize <-chan remotecommand.TerminalSize) error
PortForward(ctx context.Context, podSandboxID string, port int32, stream io.ReadWriteCloser) error
}
所有與協議解釋相關的內容都已準備就緒,執行時只需要實現實際的 Exec
和 Attach
邏輯。例如,容器執行時 CRI-O 是像這樣(虛擬碼)做的:
func (s StreamService) Exec(
ctx context.Context,
containerID string,
cmd []string,
stdin io.Reader, stdout, stderr io.WriteCloser,
tty bool,
resizeChan <-chan remotecommand.TerminalSize,
) error {
// Retrieve the container by the provided containerID
// …
// Update the container status and verify that the workload is running
// …
// Execute the command and stream the data
return s.runtimeServer.Runtime().ExecContainer(
s.ctx, c, cmd, stdin, stdout, stderr, tty, resizeChan,
)
}
埠轉發
與從工作負載流式傳輸 IO 資料相比,將埠轉發到容器的工作方式有些不同。伺服器仍然需要為客戶端提供一個 URL 端點以供連線,但隨後容器執行時必須進入容器的網路名稱空間,分配埠,並來回流式傳輸資料。沒有像 Exec 或 Attach 那樣簡單的協議定義。這意味著客戶端將流式傳輸純 SPDY 幀(無論是否帶有額外的 WebSocket 連線),這些幀可以使用像 moby/spdystream 這樣的庫來解釋。
幸運的是,kubelet 庫已經提供了 PortForward
介面方法,該方法必須由執行時實現。CRI-O 透過(簡化)以下方式實現:
func (s StreamService) PortForward(
ctx context.Context,
podSandboxID string,
port int32,
stream io.ReadWriteCloser,
) error {
// Retrieve the pod sandbox by the provided podSandboxID
sandboxID, err := s.runtimeServer.PodIDIndex().Get(podSandboxID)
sb := s.runtimeServer.GetSandbox(sandboxID)
// …
// Get the network namespace path on disk for that sandbox
netNsPath := sb.NetNsPath()
// …
// Enter the network namespace and stream the data
return s.runtimeServer.Runtime().PortForwardContainer(
ctx, sb.InfraContainer(), netNsPath, port, stream,
)
}
未來的工作
與其他方法相比,Kubernetes 為 Exec
、Attach
和 PortForward
這幾個 RPC 提供的靈活性確實非常出色。儘管如此,容器執行時必須跟上最新、最好的實現,才能以有意義的方式支援這些功能。支援 WebSockets 的普遍努力不僅僅是 Kubernetes 的事,它也需要容器執行時以及像 crictl
這樣的客戶端來支援。
例如,crictl
v1.30 為子命令 exec
、attach
和 port-forward
提供了一個新的 --transport
標誌(#1383, #1385),允許在 websocket
和 spdy
之間進行選擇。
CRI-O 正在走一條實驗性的道路,將流式伺服器的實現移入 conmon-rs(容器監視器 conmon 的替代品)。conmon-rs 是原始容器監視器的 Rust 實現,它允許使用支援的庫直接流式傳輸 WebSockets(#2070)。這種方法的主要好處是,即使 CRI-O 沒有執行,conmon-rs 也可以保持活動的 Exec、Attach 和 PortForward 會話開放。直接使用 crictl 時的簡化流程將如下所示:
所有這些增強功能都需要迭代的設計決策,而最初構思精良的實現則為這些決策奠定了基礎。我真心希望你喜歡這次關於 CRI RPC 歷史的簡短旅程。歡迎隨時透過官方 Kubernetes Slack與我聯絡,提出建議或反饋。