本文發表於一年多前。舊文章可能包含過時內容。請檢查頁面中的資訊自發布以來是否已變得不正確。

容器執行時介面流式傳輸詳解

Kubernetes 容器執行時介面 (CRI)kubelet容器執行時之間的主要連線。這些執行時必須提供一個 gRPC 伺服器,該伺服器必須實現 Kubernetes 定義的 Protocol Buffer 介面。這個 API 定義會隨著時間的推移而演變,例如當貢獻者新增新功能或某些欄位被棄用時。

在這篇博文中,我想深入探討三個非同尋常的遠端過程呼叫 (RPC) 的功能和歷史,它們在工作方式上確實非常突出:ExecAttachPortForward

Exec 可用於在容器內執行特定命令,並將輸出流式傳輸到像 kubectlcrictl 這樣的客戶端。它還允許透過標準輸入 (stdin) 與該程序互動,例如,當用戶想在現有工作負載中執行一個新的 Shell 例項時。

Attach 將當前執行程序的輸出透過標準 I/O 從容器流式傳輸到客戶端,並且也允許與它們進行互動。這在使用者想要檢視容器內部情況並能與程序互動時特別有用。

PortForward 可用於將埠從主機轉發到容器,以便能夠使用第三方網路工具與其互動。這允許它繞過特定工作負載的 Kubernetes 服務並與其網路介面進行互動。

它們有什麼特別之處?

CRI 的所有 RPC 要麼使用 gRPC 一元呼叫進行通訊,要麼使用伺服器端流式傳輸功能(目前只有 GetContainerEvents)。這意味著幾乎所有的 RPC 都接收單個客戶端請求,並且必須返回單個伺服器響應。ExecAttachPortForward 也是如此,它們的協議定義如下:

// 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 對 ExecAttachPortForward 都有原生實現。在此之前,一切都繫結到 Docker 或後來被放棄的容器執行時 rkt

與 CRI 相關的設計文件還詳細闡述了使用原生 RPC 流式傳輸來實現 exec、attach 和 port forward 的選項。但這種方法的缺點超過了優點:kubelet 仍會造成網路瓶頸,未來的執行時在選擇伺服器實現細節方面將失去自由。此外,另一個選項是讓 Kubelet 實現一個可移植的、與執行時無關的解決方案,但這個方案最終也被放棄了,因為這意味著需要維護另一個專案,而這個專案無論如何都將依賴於執行時。

這意味著,ExecAttachPortForward 的基本流程被提議為如下所示:

CRI Streaming flow

像 crictl 或 kubelet(透過 kubectl)這樣的客戶端使用 gRPC 介面向執行時請求一個新的 exec、attach 或 port forward 會話。執行時實現了一個流式伺服器,該伺服器也管理活動的會話。這個流式伺服器為客戶端提供了一個 HTTP 端點以供連線。客戶端升級連線以使用 SPDY 流式協議或(將來)WebSocket 連線,並開始來回傳輸資料。

這種實現方式讓執行時可以靈活地按照自己想要的方式實現 ExecAttachPortForward,並且還提供了一個簡單的測試路徑。執行時可以更改底層實現以支援任何型別的功能,而完全無需修改 CRI。

在過去幾年中,許多對這種整體方法的小型增強已被合併到 Kubernetes 中,但總體模式始終保持不變。kubelet 原始碼已轉變為一個可重用的庫,如今容器執行時可以使用它來實現基本的流式傳輸能力。

流式傳輸實際上是如何工作的?

乍一看,這三個 RPC 的工作方式似乎相同,但事實並非如此。可以將 ExecAttach 的功能歸為一組,而 PortForward 則遵循一個獨特的內部協議定義。

Exec 和 Attach

Kubernetes 將 ExecAttach 定義為“遠端命令”,其協議定義存在於五個不同版本中:

#版本說明
1channel.k8s.io初始(無版本)的 SPDY 子協議(#13394#13395
2v2.channel.k8s.io解決了第一個版本中存在的問題 (#15961)
3v3.channel.k8s.io增加了對調整容器終端大小的支援 (#25273)
4v4.channel.k8s.io增加了使用 JSON 錯誤來支援退出碼 (#26541)
5v5.channel.k8s.io增加了對 CLOSE 訊號的支援 (#119157)

除此之外,還有一個全面的努力,即作為 KEP #4006 的一部分,用 WebSockets 替換 SPDY 傳輸協議。執行時在其生命週期中必須滿足這些協議,以與 Kubernetes 的實現保持同步。

讓我們假設客戶端使用最新(v5)版本的協議,並透過 WebSockets 進行通訊。在這種情況下,一般的流程將是:

  1. 客戶端使用 CRI 為 ExecAttach 請求一個 URL 端點。

    • 伺服器(執行時)驗證請求,將其插入連線跟蹤快取中,併為該請求提供 HTTP 端點 URL。
  2. 客戶端連線到該 URL,升級連線以建立 WebSocket,並開始流式傳輸資料。

    • Attach 的情況下,伺服器必須將主容器程序的資料流式傳輸到客戶端。
    • Exec 的情況下,伺服器必須在容器內建立子程序命令,然後將輸出流式傳輸到客戶端。

    如果需要標準輸入(stdin),那麼伺服器也需要監聽它,並將其重定向到相應的程序。

為已定義的協議解釋資料相當簡單:每個輸入和輸出資料包的第一個位元組定義了實際的流:

第一個位元組型別描述
0標準輸入從標準輸入流式傳輸的資料
1標準輸出流向標準輸出的資料
2標準錯誤流向標準錯誤的資料
3流錯誤發生了流式傳輸錯誤
4流調整大小終端調整大小事件
255流關閉流應被關閉(對於 WebSockets)

現在,執行時應該如何使用所提供的 kubelet 庫來實現 ExecAttach 的流式伺服器方法呢?關鍵在於 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
}

所有與協議解釋相關的內容都已準備就緒,執行時只需要實現實際的 ExecAttach 邏輯。例如,容器執行時 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 端點以供連線,但隨後容器執行時必須進入容器的網路名稱空間,分配埠,並來回流式傳輸資料。沒有像 ExecAttach 那樣簡單的協議定義。這意味著客戶端將流式傳輸純 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 為 ExecAttachPortForward 這幾個 RPC 提供的靈活性確實非常出色。儘管如此,容器執行時必須跟上最新、最好的實現,才能以有意義的方式支援這些功能。支援 WebSockets 的普遍努力不僅僅是 Kubernetes 的事,它也需要容器執行時以及像 crictl 這樣的客戶端來支援。

例如,crictl v1.30 為子命令 execattachport-forward 提供了一個新的 --transport 標誌(#1383, #1385),允許在 websocketspdy 之間進行選擇。

CRI-O 正在走一條實驗性的道路,將流式伺服器的實現移入 conmon-rs(容器監視器 conmon 的替代品)。conmon-rs 是原始容器監視器的 Rust 實現,它允許使用支援的庫直接流式傳輸 WebSockets(#2070)。這種方法的主要好處是,即使 CRI-O 沒有執行,conmon-rs 也可以保持活動的 ExecAttachPortForward 會話開放。直接使用 crictl 時的簡化流程將如下所示:

sequenceDiagram autonumber participant crictl participant runtime as 容器執行時 participant conmon-rs Note over crictl,runtime: 容器執行時介面 (CRI) crictl->>runtime: Exec、Attach、PortForward Note over runtime,conmon-rs: Cap’n Proto runtime->>conmon-rs: 提供 Exec、Attach、PortForward 服務 conmon-rs->>runtime: HTTP 端點 (URL) runtime->>crictl: 響應 URL crictl-->>conmon-rs: 連線升級到 WebSocket conmon-rs-)crictl: 流式傳輸資料

所有這些增強功能都需要迭代的設計決策,而最初構思精良的實現則為這些決策奠定了基礎。我真心希望你喜歡這次關於 CRI RPC 歷史的簡短旅程。歡迎隨時透過官方 Kubernetes Slack與我聯絡,提出建議或反饋。