容器运行时接口流式传输详解
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) {}
请求携带允许服务器执行工作所需的一切,例如,在 Exec
的情况下,ContainerId
或要运行的命令 (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) |
最重要的是,正在努力使用 WebSockets 替换 SPDY 传输协议,作为 KEP #4006 的一部分。运行时必须在其生命周期内满足这些协议,以便与 Kubernetes 实现保持同步。
假设客户端使用最新 (v5
) 版本的协议并通过 WebSockets 进行通信。在这种情况下,一般流程将是
客户端使用 CRI 请求 Exec 或 Attach 的 URL 端点。
- 服务器(运行时)验证请求,将其插入连接跟踪缓存,并为该请求提供 HTTP 端点 URL。
客户端连接到该 URL,升级连接以建立 WebSocket,并开始流式传输数据。
- 在 Attach 的情况下,服务器必须将主容器进程数据流式传输到客户端。
- 在 Exec 的情况下,服务器必须在容器内创建子进程命令,然后将输出流式传输到客户端。
如果需要 stdin,则服务器还需要侦听它并将其重定向到相应的进程。
解释已定义协议的数据非常简单:每个输入和输出数据包的第一个字节 定义 实际的数据流
第一个字节 | 类型 | 描述 |
---|---|---|
0 | 标准输入 | 从 stdin 流式传输的数据 |
1 | 标准输出 | 流式传输到 stdout 的数据 |
2 | 标准错误 | 流式传输到 stderr 的数据 |
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,
)
}
PortForward
与从工作负载流式传输 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 为 RPC Exec
、Attach
和 PortForward
提供的灵活性确实非常出色。尽管如此,容器运行时必须跟上最新和最伟大的实现,才能以有意义的方式支持这些功能。支持 WebSockets 的总体努力不仅是纯粹的 Kubernetes 问题,还必须由容器运行时以及 crictl
等客户端支持。
例如,crictl
v1.30 为子命令 exec
、attach
和 port-forward
添加了一个新的 --transport
标志(#1383,#1385),以允许在 websocket
和 spdy
之间进行选择。
CRI-O 正在尝试一种实验性的路径,将流媒体服务器的实现迁移到 conmon-rs 中 (conmon-rs 是容器监视器 conmon 的替代品)。conmon-rs 是原始容器监视器的 Rust 实现,并允许直接使用支持的库进行 WebSockets 流式传输 (#2070)。这种方法的主要好处是,即使 CRI-O 没有运行,conmon-rs 也可以保持活动的 Exec、Attach 和 PortForward 会话打开。当直接使用 crictl 时,简化的流程将如下所示:
所有这些增强都需要迭代设计决策,而最初构思完善的实现则作为这些增强的基础。我真诚地希望您喜欢这次关于 CRI RPC 历史的简短旅程。欢迎随时通过 官方 Kubernetes Slack 联系我,提出建议或反馈。