本文发表于一年多前。旧文章可能包含过时内容。请检查页面中的信息自发布以来是否已变得不正确。

容器运行时接口流式传输详解

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与我联系,提出建议或反馈。