本文讲的是DockOne微信分享(一一一):LAIN 平台远程进入容器功能设计与实现【编者的话】本次分享主要介绍在宜信大数据创新中心的开源 PaaS 平台LAIN中,基于 WebSocket 和 Docker Remote API 远程进入单进程容器功能的设计与实现。

LAIN 平台介绍

LAIN 是宜信大数据创新中心基于 Docker 开发的 PaaS 平台。目前已经开源至  GitHub

 并已经在内部生产化。

LAIN 提供了裸机之上、应用开发之下的 DevOps 问题的整体解决方案;规范了应用开发、测试、上线工作流,辅以 SCM 支持;提高了整体资源使用率,优化冗余资源池。

LAIN 通过 Swarm 实现 Docker 集群化,用 etcd 作为服务发现与底层元数据存储。同时还集成了我们自己开发的容器调度器、控制器以及权限管理系统等。

远程进入容器方案调研

相信开发过 PaaS 平台的同学都面对过平台用户远程进入容器调试的要求。

在多进程容器的场景下,这种需求似乎很容易实现,只需要在内部启动一个 sshd 进程即可。但是,这种设计会带来一些问题。

首先是权限管理。如何控制用户和容器的权限对应关系?在传统的虚拟机或物理服务器的使用场景中,公司都会为这些基础设施配备相应的堡垒机管理系统。但是对于一个 PaaS 平台配备这种系统会使得平台变得复杂且臃肿。

其次是配置管理。用户如果需要修改密码或者信任公钥,如何实现所有容器的 sshd 配置同步修改并保证一致?

最后则是容器进程管理。容器的 1 号进程不仅需要管理 sshd,而且 sshd 需要暴露端口给客户端连接。由于不同宿主机的环境的差异,很可能出现暴露端口不一致的情况,这也会严重影响用户的使用体验。

在单进程容器的场景下,上面的方案就行不通了。让用户直接登录到宿主机执行 docker exec -it 显然也不是一个可行的办法。

我们通过调研 Docker 的 API 及其实现,探究出了一种解决容器远程登录需求的方案。

Entry 应用

Entry 应用分为服务端和客户端部分,整体结构如下图。

其中,Entry 的服务端负责和 Docker 进程通信,获取容器内终端的输出并发送用户输入到终端。Entry 的服务端还负责对用户鉴权、接收用户来自终端的输入并将容器终端的输出返回给用户。

Entry 的客户端则分为命令行客户端和 Web 客户端,二者功能类似,均是负责连接服务端,并发送用户的键盘输入,接收并仿真出容器的终端输出。

Entry 服务端和 Docker 通信实现

在介绍服务端和 Docker 通信的实现之前,让我们先看一下 Docker 的一个 API。

通过介绍可以看出,该 API 通过 WebSocket 实现输入和输出的全双工传输。

受到这个 API 的启发,我们认为既然通过 WebSocket 可以传输容器的输入输出,那自然也可以传输用户的输入输出。最后只要将这两部分的输入输出对接即可实现。

使用 WebSocket 的好处是,因为 WebSocket 是通过 HTTP 协议建立连接的,因此数据传输可以直接利用统一的 Nginx 入口,不需要额外开放端口。在开发过程中,我们可以在 WebSocket 协议的基础上定义自己的消息格式,这样使我们的应用设计更为灵活。

go 语言中已经有很多优秀的 Docker 客户端库和 WebSocket 库。我们使用的是 github.com/fsouza/go-dockerclient 和 github.com/gorilla/websocket。

和 docker exec 相关的 API 有两个,第一个是下图中的 createExec。

其对应的参数结构体如下图:

在参数中,设置 AttachStdout,AttachStderr,AttachStdin 和 Tty 均为 true ,即我们需要打开终端并且获得其输入输出流。cmd 参数则设置为我们经常使用的 “docker exec -it xxx bash” 后面的 bash 进程。由于 Docker 自带的终端类型(dumb)太挫,这里在执行 bash 命令时设置临时环境变量 TERM=xterm-256 以提升使用体验。

第二个 API 则是 startExec。

其对应的参数的结构体如下图。

CreateExec 执行成功后,会产生对应的 ExecID 。参数中还需要给 stdOut/stdErr 和 stdIn 分别传入 writer 和 reader 用来写入或读取。而我们则需要从另一边读到或写入数据,这就像是管道的两端。

恰好,Go 语言提供了类似管道的 PipeReader 和 PipeWriter。通过 io.Pipe() 即可获得一对 writer 和 reader。然后,启动3个 goroutine 去处理这三类数据的输入输出,这样服务端和 Docker 的通信就基本完成了。

Entry 服务端和客户端通信实现

Entry 服务端本质上是一个 WebSocket 服务端,接受来自客户端的 WebSocket 请求并通过 WebSocket 传输数据。在高级语言中,对 WebSocket 的操作是以消息为单位的。我们可以定义每条消息内数据的协议格式。

Entry 中,使用 protobuf3 定义数据的序列化协议。如下图。

RequestMessage 代表客户端发送的请求信息,包括 PLAIN 和 WINCH 两种类型。

PLAIN 代表用户一般的输入信息。WINCH 则代表终端窗口的大小改变请求。

因为不同大小的终端窗口,显示的结果不一样,因此我们需要根据用户的实际情况改变终端的显示方式。幸运的是,Docker 提供了 ResizeExecTTY 这种改变窗口大小的 API,恰好满足了我们的需求。

PLAIN 消息中,content 是用户输入的字节数组。WINCH 中,content 则是一个包含终端长和宽的一个 JSON 串的字节数组。

服务端在接收到 PLAIN 消息后,会原封不动地发给 Docker。在接收到 WINCH 消息后,会解析该 JSON ,然后调用改变窗口大小的接口。在这里需要注意一下,在 Docker 1.12 以后,只有 StartExec 成功执行后,才能执行 ResizeExecTTY 。

ResponseMessage 代表服务端返回的信息,包括 STDOUT,STDERR, CLOSE 和 PING 四种。

  • STDOUT:携带的是容器终端的标准输出数据。
  • STDERR:携带的是容器终端的标准错误数据。
  • CLOSE:是服务端连接关闭的消息。
  • PING:则是服务端为保持 WebSocket 连接存活发送的信息。

终端显示的效果,例如颜色、进度条等,都是通过一系列终端控制字符实现的。这些本质上也是 ASCII字符。因此在客户端输出的时候,不需要考虑数据如何,只需要直接 print 即可。

下面介绍一下客户端和服务端通信的工作流程。

首先,客户端向服务端发送 WebSocket 连接请求。对于命令行客户端,在请求头中会携带客户端的 token 信息(用于身份验证),以及要登录的容器信息。对于 Web 客户端,由于浏览器中的 WebSocket 客户端不支持自定义 header,因此需要通过别的方式发送身份验证信息。这个后面会讲到。

服务端收到连接建立请求后,判断如果是命令行客户端,则从头中拿出token和容器信息,在权限系统中判断该用户是否有权限登录容器。如果没有权限,则关闭连接并返回 CLOSE 信息,信息中会携带错误原因。如果是 web 客户端,则服务端会等待客户端发送的第一条 PLAIN 信息,这个信息中会包含要登录的容器信息和 token。之后服务端会做同样的鉴权工作。

鉴权完成后,服务端会保持和客户端的连接,并负责传输客户端和 Docker 的输入输出。同时每隔一定时间会向客户端发送一条 PING 信息,保证连接的存活。

当客户端先断开连接时,服务端会断开和 Docker 的连接,并回收相应的 IO 资源。

当 Docker 先断开连接时,服务端会向客户端发送 CLOSE 信息,并关闭 websocket 连接,同时回收相应的 IO 资源。

在数据的传输过程中,考虑到传输的数据有可能包含中文等非 ASCII 字符,因此我们统一字符编码均为 UTF-8。然而,传输给客户端时使用的发送缓冲区大小是有限的。

为了保证每次发送的数据都符合 UTF-8 编码规则,Entry 使用如下算法计算一个字节数组中,最长的合法 UTF-8 编码的前缀位置。

由于一个合法 UTF-8 编码最长4个字节,因此最多循环4次就可以找到最后一个 UTF-8 的开始字节。

得到最长的合法 UTF-8 编码子数组后,将该子数组的数据放到消息中发送给客户端,剩下的字节则放到缓冲区开始,待下次发送。

从这个流程中我们可以看出,Entry 实际上就是一个终端和 Docker 的高级代理。

Entry 的命令行客户端实现

Entry 命令行客户端集成至 LAIN 的命令行工具 lain-cli 中,通过 lain enter 命令指定集群名称、应用名称以及容器编号即可远程登录,使用体验和 SSH 登录是一样的。

Entry 的命令行客户端使用 Python 实现,protobuf3 的定义可以用 protobuf3 自动生成对应的 Python 代码。

在命令行的实现中,客户端在建立 WebSocket 连接后,会首先得到当前终端窗口的大小,并发送一个 WINCH 信息,这样可以在一开始就适配当前的终端大小。在交互过程中,除了要处理键盘输入、WebSocket 的返回,客户端还需要监听窗口大小改变的事件,这个在程序中体现为 signal.SIGWINCH 系统信号。当收到该信号时,客户端需要再次获得更新后的窗口大小,并发送 WINCH 消息给服务端。

Entry 的 Web 客户端实现

在 Web 页面中完全模拟命令行终端是一件非常困难的事情,如果自己开发插件,需要处理各种终端控制字符。

在这里向大家推荐一个十分好用的模拟终端的 js 库,

xterm.js

。这个库的安装与使用不需要任何第三方依赖,既可以以 js 文件的形式引入到页面中,也可以通过 npm 安装。

Web 客户端的实现和命令行类似,也是要监听键盘输入、WebSocket 返回和窗口改变。在监听键盘输入和窗口改变时,xterm.js 提供了 data 和 resize 两个事件,因此只需要实现这两个事件的 handler 就可以了。

在浏览器中,按 esc 键会失去当前组件的焦点。但是在命令行中,我们肯定会遇到需要使用 esc 键的情况,例如 vim 中从 INSERT 切换为 NORMAL。因此实现时需要特殊处理 esc 按键事件。

在序列化和反序列化数据时需要注意的是,因为之前在 protobuf3 中定义的 content 字段是字节数组类型。而 web 客户端是无法使用 protobuf3 生成代码的。因此这里需要使用 JSON 替代 protobuf3 序列化消息。而 go 语言中,对字节数组的 JSON 序列化则是将其转化为 Base64 编码的字符串。因此 Web 客户端在发送数据时,也需要将输入转化为 Base64 编码字符串,这样在服务端才能正确反序列化消息。同理,在接收到 WebSocket 的返回消息时,也需要用 Base64 解码以获得正确的输出。

由于服务端需要处理两种不同客户端的请求,因此我们将序列化和反序列化的逻辑抽象出来,定义了自己的序列化,反序列化的函数类型。Go 语言中 json 天生就满足该类型。

然后包装了 protobuf 的实现。

以上是 Web 客户端的实现细节。

Entry 的后续工作

目前的 Entry 还存在一些问题。

首先是浏览器兼容性问题。目前测试 Web 客户端在 Chrome 和 Firefox 浏览器是工作正常的。但是在 Safari 浏览器中,会出现卡住的情况,这个原因目前还在定位中。

其次是非正常退出的问题。如果客户端不是通过 exit 命令,而是直接断掉 websocket 连接,就会有 bash 进程残留在容器中。而一个 bash 进程多多少少还是会占一些资源的。如果 Docker 将来能提供 StopExec 类似的接口,这个问题应该可以解决。

目前,我们在开发 Entry 的新功能 attach,即attach到容器的标准输出,提供了一个远程获得进程标准输出的功能。依赖的接口是 AttachToContainerNonBlocking ,其他的实现则可以复用enter的部分。

总结

Entry 作为远程终端和Docker的高级通信代理,提供了身份验证、输入输出数据传输等功能,是一种远程登录容器的解决方案。该方案同时适用于单容器和多容器场景。

但是,远程登录容器也应该只用于线上调试,而不能等同使用虚拟机。更为完善的日志收集系统、监控报警机制才是 PaaS 平台调试和监控线上服务运行情况的关键。

以上就是我的分享内容,欢迎大家就该问题一起讨论交流。谢谢。

问题与回答

Q:服务端迭代升级的过程怎么保证和旧版本的客户端兼容?

A:服务端升级时,不修改已有协议,只会增加协议。因为协议定义已经一致,因此协议上层的业务逻辑的修改不会影响到客户端使用。

Q:是否允许多用户同时访问,是否有可能互相干扰?

A:不同的用户和服务端连接时肯定是建立不同的 WebSocket 连接,而对于同一个容器执行两次 exec 产生的也是 bash 进程。但是 gorilla/websocket 这个库,对于同一个 WebSocket 连接,不能有两个以上的 goroutine 同时读或者同时写,因此实现时要注意并发控制。

Q:不同宿主机的容器是怎么实现通信的?

A:Entry 是用作平台用户(应用开发运维人员)从自己的机器上远程登录的。这个问题应该属于平台的网络设计部分,我们平台用的 Calico 实现的容器网络,这里就不展开讨论了。

Q:连接时 token 在传输过程中会有被篡改的可能吗?

A:即使客户端伪造 token,如果 token 和我们的身份系统中记录的不符,也无法登录容器。而在传输过程中,我们使用 WSS(基于 HTTPS ),连接信息是加密的,token不会被窃取。

Q: 除了 CLI 和 Web 接口,以后可能有 SDK 接口么?这样可能第三方会使用。

A:客户端的实现关键就是对消息的序列化反序列化,而我们是使用的 protobuf3定义的,protobuf3 可以生成各种主流语言的代码,因此不需要单独开发 SDK。

本文作者:白渐