众所周知,容器是基于操做系统内核的一种轻量级的虚拟化技术。其能够类比于虚拟机,但其自己并非虚拟机。在传统的虚拟机使用场景中,每一个用户都会经过堡垒机,根据本身被分配的权限,登陆某些机器的某些帐号。当应用部署逐渐转移到基于容器技术的PaaS平台上后,让用户进入容器进行观察、调试应用已经成为了PaaS平台的一个重要且必备的功能。
远程进入容器功能的传统实现方式是基于虚拟机的思想,在每一个容器中启动一个sshd进程。因为容器PID为1的进程的特殊性,为了保证容器不停,容器的ENTRYPOINT须要设置为相似于Supervisord这样的进程管理程序。在这种多进程容器的使用场景中,用户经过ssh-client指定容器的IP远程链接到容器,让用户感受到本身好像就在使用虚拟机。可是,这种方案会带来如下问题:前端
- 权限管理。如何控制哪些用户可以登陆哪些容器?如何和平台已有的权限管理系统集成?这种状况每每都须要经过堡垒机系统控制。而在PaaS中,引入单独的堡垒机系统会增长PaaS的复杂度以及维护成本。
- 登陆方式选择。不管使用密码仍是私钥验证登陆,容器内的密码或者authorized_keys的管理都须要经过加入额外的程序解决,无疑会增长容器的复杂度。同时还要面对同权限容器的密码或authorized_keys的一致性问题。
基于以上问题,在咱们的LAIN平台中,设计出了基于WebSocket协议与Docker Remote API的远程登陆方案。LAIN(https://laincloud.com)是一个基于Docker的PaaS。其面向技术栈多样寻求高效运维方案的高速发展中的组织,DevOps人力缺少的startup以及我的开发者。LAIN经过统一高效的开发工做流,下降应用运维复杂度;在IaaS / 私有IDC裸机的基础上直接提供应用开发,集成,部署,运维的一揽子解决方案。
该方案的总体架构图以下:
git

从图中能够看出,在LAIN中实现容器远程登陆支持须要如下两个组件:github
- Entry应用。负责以下工做:
- 调用Docker Remote API
- 经过WebSocket 传递stdin,stdout和stderr。
- 根据protobuf3协议对各种消息进行序列化与反序列化。
- 对用户登陆的鉴权。
Entry是基于Go语言开发的,并依赖以下代码库:
- github.com/gorilla/websocket:WebSocket的服务端实现。
- github.com/fsouza/go-dockerclient:Go语言的Docker客户端。
- github.com/golang/protobuf/proto:protobuf协议的支持库。
- 基于命令行的客户端。负责以下工做:
- WebSocket链接请求的发送。
- 监听键盘输入、窗口变化事件以及WebSocket返回的stream。
- 将远端的stdout,stderr输出到本地终端的标准输出和标准错误。
Entry的工做流程
经过命令行客户端远程登陆容器的过程及其实现以下:golang
- 用户经过客户端命令向Entry应用发送WebSocket链接请求。
- Entry应用接收到用户请求,获得请求Header中的access_token以及要进入的容器信息,经过调用LAIN的console接口判断该用户是否有权限进入容器。若是没有权限,则直接通知客户端鉴权失败,本次链接结束。
- 若是经过了权限验证,则WebSocket链接会被创建。紧接着Entry会去调用 execCreate 这个Docker Remote API。在调用时,须要指定Tty,AttachStdin、AttachStdout和AttachStderr参数均为true,Cmd参数为bash,这样才能得到bash进程的标准输入输出和错误。
若是调用execCreate成功,调用请求会返回该Exec的ID,Entry会继续根据这个ID调用execStart接口。在调用时,须要指定Detach和Tty为false,这样才能链接到bash进程的标准输入输出和错误。调用execCreate成功后,会返回一个HTTP的stream。在Entry中则经过3个goroutine分别处理stdin,stdout和stderr。
- 客户端会同时监听WebSocket链接与键盘输入,对于WebSocket返回的Message,客户端会经过Entry制定的protobuf3消息格式反序列化出消息结构,并根据消息的类型,将数据发送到本地终端的stdout或stderr。对于键盘输入,客户端会将输入内容封装,通过protobuf3序列化后,经过WebSocket发送给Entry应用,Entry应用通过反序列化后,将输入发送给bash的stdin。
以上就是Entry的工做原理。从中咱们能够看出,Entry已经很好地解决了传统ssh-client登陆所遇到的问题:web
- Entry经过调用console的接口完成了身份验证工做,因为全部的权限都被console统一管理,所以Entry不须要本身维护权限信息,即Entry自己是无状态应用。这种应用的优点在于能够低成本扩容,用以应对多并发的场景。
- Entry经过Docker Remote API链接容器,这样只要被链接的容器内能够启动bash进程,用户就能够经过客户端链接到该容器。容器无需启动sshd进程,也就无需再以supervisord等进程做为entrypoint。更多的容器就能够以单进程的形式运行,下降了容器自己的维护成本。
Entry的设计细节
俗话说,细节决定成败。为了提升使用体验,Entry应用在设计与实现时考虑到了不少细节,在这里拿出来与你们分享。docker
- 链接保持:当WebSocket链接在一段时间内没有数据传输后,会自动断开。这给用户的使用带来了极大的不便。Entry在设计时,对每个创建的WebSocket链接,会有一个单独的goroutine每隔10秒发送一个PING类型的Message(不是WebSocket协议中的PingMessage),这样保证了在不主动断开的状况下,用户和容器能够一直保持链接。
- 使用protobuf3制定消息格式并实现序列化与反序列化:使用protobuf3能够方便地定义与扩展本身的消息格式,同时在传输时能减少必定的带宽占用。
Entry的消息格式有两类,RequestMessage和ResponseMessage。客户端发送的请求都属于RequestMessage,服务端返回的数据都封装在ResponseMessage中。其中:bash
- RequestMessage类型包括:PLAIN和WINCH。PLAIN就是用户经过键盘的输入。WINCH则是终端窗口大小改变的消息,内容中会携带新窗口的rows和cols。
- ResponseMessage类型包括:STDOUT, STDERR,PING和CLOSE。STDOUT和STDERR表明了该消息内容是来自于标准输出仍是标准错误。PING则表明是链接保持专用的信息。CLOSE则是链接将要断开前Entry返回的信息,会包含错误缘由或者正常退出的信息。
- 监听终端窗口大小改变:默认的终端窗口大小都是80 * 24,但该标准在当前的平常使用中早已过期。若是在一个全屏的terminal中仍然使用该大小显然是不合理的。所以客户端在成功链接到容器后,客户端会首先根据当前的terminal大小发送一个WINCH类型的RequestMessage,Entry收到后会调用ExecResize接口,这样以后全部的stdout和stderr都会按照新的终端大小显示。客户端还会监听窗口大小改变的事件,若是发生改变,一样还会发送WINCH到Entry。
- UTF-8编码检查:客户端和服务端在发送消息内容时,都会对缓冲区内要发送的数据作UTF-8编码检查。若是发送数据不符合编码规则,则会先发送最长符合的缓冲区前缀,后面剩余的数据则被移到缓冲区的开始,待下次发送。这种设计是为了处理中文等非latin1字符的显示问题。避免由于非法的UTF-8编码形成终端显示乱码。
Entry存在的问题
- 非正常退出时,bash进程不会结束,而是会以sleep的状态残留于容器中。若是一个容器有过多的bash进程,极可能由于cgroup的内存限制致使容器退出。目前官方并无给出相似execKill的API,只能期待在之后版本的docker中能解决这个问题。
- Entry应用依赖特定的LAIN客户端。以前用户只能经过lain enter命令进入容器。可是12月份后咱们升级了console的前端,增长了web terminal功能。用户只须要经过点击容器的ID就能够打开一个含有terminal的web页面,而后经过该web页面与容器进行交互,不须要再安装任何客户端。在这里要十分感谢开源项目xterm.js(https://github.com/sourcelair/xterm.js),该项目基于JavaScript与CSS实现了一个近乎完美模拟xterm终端的插件。目前,console的web terminal能够支持Firefox和Chrome,可是没法支持IE和Safari。
总结
Entry是LAIN中一款设计较为精巧、技术含量较高的应用。其利用了WebSocket全双工传输的特色,在单进程容器的场景下实现了对容器的远程登陆,同时保证了登陆权限的控制。本文但愿经过分享LAIN中Entry的设计与实现,为须要开发远程登陆容器功能的PaaS同行提供技术方案参考。Entry已经开源,地址在(https://github.com/laincloud/entry),欢迎一块儿讨论交流学习。websocket