Kubernetes 的容器运行时接口(CRI)是 kubelet 与容器运行时之间的主要连接点。这些运行时必须提供一个符合 Kubernetes 定义的 Protocol Buffer 接口的 gRPC 服务器。随着新特性的添加或某些字段的弃用,这个 API 定义会随时间演变。
在本文中,我们将深入探讨三个非凡的远程过程调用(RPC):Exec、Attach 和 PortForward。这些 RPC 在工作方式上非常突出。
- Exec:允许在容器内运行特定命令,并将输出流式传输到客户端,如 kubectl 或 crictl。它还允许使用标准输入(stdin)与该进程进行交互,例如,如果用户想在现有工作负载中启动一个新的 shell 实例。
- Attach:通过标准 I/O 将当前运行进程的输出流式传输到客户端,并允许与它们进行交互。这对于用户想要查看容器中正在发生的事情并能够与进程进行交互非常有用。
- PortForward:可以用于将主机上的端口转发到容器,使用第三方网络工具与之交互。这允许绕过 Kubernetes 服务对特定工作负载进行交互,并直接与其网络接口通信。
这些 RPC 有什么特别之处?所有 CRI 的 RPC 要么使用 gRPC 单向调用进行通信,要么使用服务器端流式传输功能(目前只有 GetContainerEvents)。这意味着大多数 RPC 都是接收一个客户端请求并返回一个服务器响应。Exec、Attach 和 PortForward 也是如此,它们的协议定义如下:
// Exec 准备一个流式传输端点来执行容器中的命令。
rpc Exec(ExecRequest) returns (ExecResponse) {}
// Attach 准备一个流式传输端点连接到正在运行的容器。
rpc Attach(AttachRequest) returns (AttachResponse) {}
// PortForward 准备一个流式传输端点从 PodSandbox 转发端口。
rpc PortForward(PortForwardRequest) returns (PortForwardResponse) {}
请求携带了服务器执行工作所需的一切,例如 ContainerId 或 Exec 中要运行的命令(Cmd)。更有趣的是,它们的响应只包含一个 url:
message ExecResponse {
// exec 流式传输服务器的完全限定 URL。
string url = 1;
}
message AttachResponse {
// attach 流式传输服务器的完全限定 URL。
string url = 1;
}
message PortForwardResponse {
// port-forward 流式传输服务器的完全限定 URL。
string url = 1;
}
这种实现方式的原因是什么?这些 RPC 的原始设计文档甚至早于 Kubernetes 增强提案(KEPs),最初在 2016 年提出。在将这些功能引入 CRI 的倡议开始之前,kubelet 已经有了 Exec、Attach 和 PortForward 的本地实现。那时,一切都绑定在 Docker 或后来被放弃的容器运行时 rkt 上。
CRI 相关的设计文档还详细说明了使用原生 RPC 流式传输执行、附加和端口转发的选项。这种方法的缺点是:kubelet 仍然会创建网络瓶颈,未来的运行时在选择服务器实现细节时也不会自由。此外,Kubelet 实现一个便携式、与运行时无关的解决方案的另一个选项也被放弃了,因为这将意味着另一个需要维护的项目,而这个项目无论如何都会依赖于运行时。
这意味着,Exec、Attach 和 PortForward 的基本流程如下:
- 客户端(如 crictl 或通过 kubectl 的 kubelet)使用 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 子协议 |
2 | v2.channel.k8s.io | 解决了第一个版本中存在的问题 |
3 | v3.channel.k8s.io | 添加了对调整容器终端大小的支持 |
4 | v4.channel.k8s.io | 添加了使用 JSON 错误支持退出代码 |
5 | v5.channel.k8s.io | 添加了对 CLOSE 信号的支持 |
此外,还有一个总体的努力,即使用 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 是执行命令并提供流的接口。
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 {
// 根据提供的 containerID 检索容器
// …
// 更新容器状态并验证工作负载是否正在运行
// …
// 执行命令并将数据流式传输
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 {
// 根据提供的 podSandboxID 检索 pod sandbox
sandboxID, err := s.runtimeServer.PodIDIndex().Get(podSandboxID)
sb := s.runtimeServer.GetSandbox(sandboxID)
// …
// 获取该 sandbox 的磁盘上的网络命名空间路径
netNsPath := sb.NetNsPath()
// …
// 进入网络命名空间并流式传输数据
return s.runtimeServer.Runtime().PortForwardContainer(
ctx, sb.InfraContainer(), netNsPath, port, stream,
)
}
未来工作
Kubernetes 为 RPCs Exec、Attach 和 PortForward 提供的灵活性与其他方法相比确实非常出色。然而,容器运行时必须跟上最新和最好的实现,以有意义地支持这些特性。支持 WebSockets 的总体努力不仅是 Kubernetes 的事情,还必须得到容器运行时以及像 crictl 这样的客户端的支持。
例如,crictl v1.30 引入了一个新的 --transport 标志,用于 exec、attach 和 port-forward 子命令(#1383,#1385),以允许在 websocket 和 spdy 之间选择。
CRI-O 正在通过将流式传输服务器实现移动到 conmon-rs(容器监视器 conmon 的替代品)来走一条实验性的道路。conmon-rs 是原始容器监视器的 Rust 实现,允许直接使用支持的库流式传输 WebSockets(#2070)。这种方法的主要好处是,即使 CRI-O 不在运行,conmon-rs 也可以保持 Exec、Attach 和 PortForward 会话的活跃。使用 crictl 直接使用时的简化流程如下: