利用kubernetes exec接口实现任意容器的web-terminal
一、 Kubectl exec命令登录指定容器
如果你用过k8s,那么kubectl exec 命令一定不要错过。简单的敲上:
kubectl exec -it pod名 -- /bin/sh
就可以登录到任意节点的指定的容器里面,效果和使用ssh登录到一台机器进行操作一模一样,非常的方便。
那有没有想过:
- 这个功能是怎么实现的呢?
- 能不能在Web网页上面,直接拥有这个功能呢?
接下来,我们一一解读。
二、 Kubectl exec实现
1. 最底层实现:Docker容器的exec命令
K8s实现的“进入某个容器”的功能,底层本质是Docker容器通过exec进入容器的扩展。即从本机容器,扩展为任意节点的容器。
所以咱们先看看Docker怎么通过exec进入容器的呢?
docker exec -it 容器id /bin/sh
通过上面的命令,就可以进入到容内部。 本质是新建了一个“与目标容器,共享namespace的”新的shell进程。所以该shell进程,看到的世界,就是容器内的世界了。
那么K8s要做的就是,跨节点利用Docker的这个功能。
2. Kubectl到容器的超长路径
从kubectl命令行工具,到容器内部,这里经过的网络路径其实是很长的。如下:
因为exec命令行,是实时交互的。即输入和输出,实时发生。
所以K8s选择了使用 类似Websocket 这种双向实时通信的协议,来传递输入/输出内容。
Kubectl <---(双向实时协议)---> Kube-Api-Server <---(双向实时协议)--->节点kubelet
3. Kubectl实现exec代码简析
通过简单查询 kubectl 的源码:
import "k8s.io/client-go/tools/remotecommand"
//这里初始化了一个 remote-cmd 的对象
exec, err := remotecommand.NewSPDYExecutor(config, method, url)
if err != nil {
return err
}
//这里开始,将输入输出,进行实时传递(Stream)
return exec.Stream(remotecommand.StreamOptions{
Stdin: stdin,
Stdout: stdout,
Stderr: stderr,
Tty: tty,
TerminalSizeQueue: terminalSizeQueue,
})
这里可以看到,kubectl使用了一个叫 SPDY 的协议去连K8s的API-server。 这里的SPDY协议是Google公司搞的,基本类似Websocket可以进行双向实时传输,但是这个协议已经被淘汰了,被HTTP2所替代。见k8s的issue:SPDY is deprecated. Switch to HTTP/2。
(https://github.com/kubernetes/kubernetes/issues/7452)
好在K8s的API-server除了支持 SPDY协议,也支持Websocket协议。
(https://github.com/kubernetes/kubernetes/issues/89163)
三、 网页web-terminal直连容器
有了前面的背景知识,那么如果想在web网页中,实现exec实时登录到容器里面。那么可以有以下思路:
首先,SPDY是个淘汰的协议,所以前端JS代码可供参考的很少。而前端对Websocket的支持则很广泛。所以咱们Web侧选择使用Websocket。
于是,实现的方案有如下几种:
1. web网页使用Websocket直连K8s
这种场景,虽然看着最直接,但是适用场景反而有限。 因为权限隔离问题,一般情况你不可能让前端获得最大的k8s权限,允许进入任意容器中。
2. Web网页经过一个后端,中转至K8s
在前端和K8s的中间,增加一个自研的Server,可以很好的控制权限隔离,封装K8s到业务的转换。
那么对于自研Server来说,它就是一个类似Proxy的程序。其中,[Web<---->自研Server]这一段肯定是使用Websocket协议。但是[自研Server<---->K8s]这一段,则有2种实现:1种是直接使用Websocket协议。第2种是使用SPDY协议(即利用 remotecommand代码实现)。
下面分2种场景分析。
3. 中转Server通过SPDY与K8s相连
因为kubectl代码中有exec的实现(通过SPDY),所以中转Server直接借鉴,也是很方便。
这种实现方案,可以参考:https://github.com/jcops/k8-web-terminal
整体Server使用GO语言的beego框架,简单好用。前段连接使用Websocket,后段连接使用了SPDY协议。
不过经过代码分析,感觉后段连接的实现不如纯Websocket转发简洁。所以这里更推荐下一种实现方式。
4. 中转Server通过Websocket与K8S相连
因为SPDY协议已经被淘汰了,所以直接使用Websocket实现,显得更高大上,并且代码也更简洁。
这里没有找到参考实现的仓库,直接贴一点我们自己的代码实现。使用的包是:
import " github.com/gorilla/websocket"
后段连接主要代码逻辑:
// Server去连接K8s,得到websocket的连接
ws, _, err := websocket.DefaultDialer.Dial(addr, h)
// 与前端的websocket,进行proxy桥接
go k8stoweb(connFrontEnd, connBackEnd, errFrontEnd)
go webtok8s(connBackEnd, connFrontEnd, errBackEnd)
// 其中,桥接函数如下
func replicateWebSocket (dst, src *websocket.Conn, errc chan error) {
for {
msgType, msg, err1 := src.ReadMessage()
if err1 != nil {
m := websocket.FormatCloseMessage(websocket.CloseNormalClosure, fmt.Sprintf("%v", err1))
if e, ok := err1.(*websocket.CloseError); ok {
if e.Code != websocket.CloseNoStatusReceived {
m = websocket.FormatCloseMessage(e.Code, e.Text)
}
}
errc <- err1
_ = dst.WriteMessage(websocket.CloseMessage, m)
break
}
err1 = dst.WriteMessage(msgType, msg)
if err1 != nil {
errc <- err1
break
}
}
}
这种实现,后段转发比SPDY那个参考仓库更简洁,我们自己选用了此方式。
四、 K8s的Websocket协议,是有扩展的!
中转Proxy说完,我们要好好说道一下这个前端。因为前端是使用Websocket,经过proxy中转,直接到达K8s。所以相当于直接与K8s的Websocket协议互连。
所以这里就要引出实现中,遇到的最大的坑。即:K8s的exec在使用Websocket协议时,是有扩展的,并且扩展规则是K8s自己设置的规则。 我们以 用户敲下“ls”命令到容器,然后容器list文件列表为例来说明。
如果你直接发送”ls”内容,那么肯定是不通的。因为K8s根本不认这种“输入”。
K8s认为websocket的报文内容,有“频道”的。不然一条cmd命令行执行后,用户无法判断响应的内容是 stdout,还是 stderr。所以k8s这么约定:
1. websocket报文内容的第一个字节,用来表示“频道”:
第一字节值 |
其余内容含义 |
0 |
标准输入 |
1 |
标准输出 |
2 |
标准错误 |
3 |
服务端异常信息 |
4 |
terminal窗口大小调整resize |
参考:https://www.cnblogs.com/a00ium/p/10905279.html
不过文章中,调整窗口的行为,没有提。最终是在
看到的。前端调整窗口时,需要这么发送信息给K8s。
2. 频道的内容,需要使用Base64进行编码!
即要发送“ls”命令,需要向K8s发送的内容为:
sendMsg := "0" + base64.Encoding("ls")
这样发送才行,K8s才认为是收到”ls”命令。
收到响应,要先去掉第一个字节,然后再进行base64解码。
Ps:这里推荐一个websocket调试网站:http://coolaf.com/tool/chattest
用来连自己的中转Proxy,比较方便。
3. 前端JS的实现
因为中间的“自研Server”主要是进行中转Proxy,所以刚才提到的K8s接口内容中,首字节频道,以及响应的编码,其实都是交由前端来处理的。
这里可以直接参考k8s的web-ui的实现:https://github.com/kubernetes-ui/container-terminal
其中的container-terminal.js文件中,主要实现如下:
//发送内容
ws.send("0" + utf8_to_b64(data));
//接收内容
switch(ev.data[0]) {
case '1':
case '2':
case '3':
term.write(b64_to_utf8(data));
break;
}
4. 避免bash泄漏
每次连接后就容易在pod中残留一个bash的进程。
这里需要前端在任意中断连接前,发送了一个特殊的message: 0ZXhpdA0K
其中“0”表示标准输入,前面提过。
“ZXhpdA0K”经过base64反编码后,其实就是 “exit”命令。
在点击页面close时,或者任何关闭web-terminal 行为,前端都记得发送该信息,避免bash进程泄漏。
五、 总结
到此,如果你想自己实现K8s的web-terminal,并且增加各种权限控制之类的业务逻辑,应该是没有障碍了。还有哪里需要补充的也欢迎交流。
- 点赞
- 收藏
- 关注作者
评论(0)