利用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 的协议去连K8sAPI-server。 这里的SPDY协议是Google公司搞的,基本类似Websocket可以进行双向实时传输,但是这个协议已经被淘汰了,被HTTP2所替代。见k8sissueSPDY is deprecated. Switch to HTTP/2

https://github.com/kubernetes/kubernetes/issues/7452

 

好在K8sAPI-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。所以相当于直接与K8sWebsocket协议互连。

 

所以这里就要引出实现中,遇到的最大的坑。即:K8sexec在使用Websocket协议时,是有扩展的,并且扩展规则是K8s自己设置的规则。 我们以 用户敲下“ls”命令到容器,然后容器list文件列表为例来说明。


如果你直接发送”ls”内容,那么肯定是不通的。因为K8s根本不认这种“输入”。

 

K8s认为websocket的报文内容,有“频道”的。不然一条cmd命令行执行后,用户无法判断响应的内容是 stdout,还是 stderr。所以k8s这么约定:

 

1.     websocket报文内容的第一个字节,用来表示“频道”:

第一字节值

其余内容含义

0

标准输入

1

标准输出

2

标准错误

3

服务端异常信息

 参考:https://www.cnblogs.com/a00ium/p/10905279.html

 

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接口内容中,首字节频道,以及响应的编码,其实都是交由前端来处理的。

 

这里可以直接参考k8sweb-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;
}

 

五、   总结

到处,如果你想自己实现K8sweb-terminal,并且增加各种权限控制之类的业务逻辑,应该是没有障碍了。还有哪里需要补充的也欢迎交流。

(完)