使用 Tornado 实现 Docker 容器的 exec

因为工作项目的需求,研究了一下 WebSSH

实现原理

由于某些时候,需要通过某种方式进入到容器内部里面去执行一些操作,一般容器并没有提供 SSH 服务,但是有些容器提供了 bash,可以通过执行 bash 进入到一个交互式的 shell 中,执行一些操作。

通过 Docker cli 运行 bash

首先,是可以通过 docker run -it image-name bash 或者 docker exec -it container-id bash的方式在容器里执行一个交互式的 bash,类似于 SSH,但是不是基于 SSH。这里有个问题就是有的镜像并没有提供 bash 或者其他 shell 的时候,是没有办法通过此方法进入到容器里面去执行一些操作。

通过 Docker API 运行 bash

Docker 有提供 API 来实现 Docker cli 的 exec 命令

  • POST /containers/(id or name)/exec
  • POST /exec/(id)/start

首先是在一个容器里创一个 exec 的实例,然后是执行这个 exec 的实例。

但是这里的 /exec/(id)/start,Docker 用到了一个 tcp stream 来交互 stdinstdoutstderr。其实官方的 Python SDK 里是有这个的实现,但是我用的是 Tornado,并没有找到相关的库,于是乎又只能撸轮子了。

下面是一步一步分析,这里假设容器 ID 为 'XXXX'

创建一个 exec 实例

这个就是一个简单的 POST 请求。

http_client = tornado.httpclient.AsyncHTTPClient()  
res = yield http_client.fetch(  
    'http://{host}:{port}/containers/{container}/exec'.format(host=host, port=port, container=container),
    method='POST',
    headers={
        'Content-Type': 'application/json',
    },
    body=json.dumps({
        'AttachStdin': True,
        'AttachStdout': True,
        'AttachStderr': True,
        'DetachKeys': 'ctrl-p,ctrl-q',
        'Tty': True,
        'Cmd': [
            '/bin/bash'
        ]
    })
)
data = res.body  
print data  
exec_id = json.loads(data)['Id']  

通过 /containers/(id or name)/exec 请求,得到一个 exec id

执行 exec 实例

拿到 exec id 后,就是执行这个 exec 的实例了。

在 Docker 这个 API 实现这里,跟 WebSocket 的实现类似,首先是一个 POST 请求过来(WebSocketGET 请求),然后服务器不会中断这个连接,而是返回给客户端下面这样的 HTTP 报文

HTTP/1.1 200 OK  
Content-Type: application/vnd.docker.raw-stream  

如果客户端的请求头里有下面两个头的话(其实只需要一个 Upgrade

Upgrade: tcp  
Connection: Upgrade  

Docker API 服务器会返回下面这样的 HTTP 报文

HTTP/1.1 200 OK  
Content-Type: application/vnd.docker.raw-stream  
Connection: Upgrade  
Upgrade: tcp  

然后 Docker API 服务器就会往这个 tcp stream 里写 stdoutstderr(如果请求的时候有这个的需求)的内容,然后也可以往这个 tcp stream 里发送数据到容器里执行的命令的 stdin(如果请求的时候有这个需求)里面。

下面是这个操作的代码

tcp_client = tornado.tcpclient.TCPClient()  
conn = yield tcp_client.connect(host, port)  
data = json.dumps({  
    'Detach': False,
    'Tty': True
})
yield conn.write(b'POST /exec/{}/start HTTP/1.1\r\n'.format(exec_id))  
yield conn.write(b'Host: 11.11.11.3:2375\r\n')  
yield conn.write(b'Connection: Upgrade\r\n')  
yield conn.write(b'Content-Type: application/json\r\n')  
yield conn.write(b'Upgrade: tcp\r\n')  
yield conn.write(b'Content-Length: {}\r\n'.format(len(data)))  
yield conn.write(b"\r\n")  
yield conn.write(data.encode('utf-8'))  
res = yield conn.read_until(b'\r\n\r\n')  

这里为什么要手动构建 HTTP 请求呢?因为没找到 Tornado 的 HTTPClient 有这种协议的实现,反正我是没找到。

然后就可以通过 conn 来读写数据了,conn 是一个 IOStream 的实例。

与前端结合实现 WebSSH

前端我选用的是 xterm.js,这个是一个前后端的实现都有,后端是用 node.js 实现的,这里我替换掉了使用 Tornado 提供服务。

具体的实现原理是打开前端页面,与后端建立一个 WebSocket 的连接,然后后端在打开 WebSocket 的连接的同时,通过上述的通过 Docker API 运行 bash 建立一个 tcp 连接,并且把前端所有的输入都转发到 tcp 去,然后 tcp 所有的输出都转发到 WebSocket 去。

具体后端代码如下

class BashHandler(tornado.websocket.WebSocketHandler):

    @tornado.gen.coroutine
    def open(self):
        print "WebSocket opened"
        container = '0e9bfdfe4639'
        container = '4798e46ea3c'
        host = '11.11.11.3'
        port = 2375
        host = '172.24.6.171'
        port = 4000
        http_client = tornado.httpclient.AsyncHTTPClient()
        res = yield http_client.fetch(
            'http://{host}:{port}/containers/{container}/exec'.format(host=host, port=port, container=container),
            method='POST',
            headers={
                'Content-Type': 'application/json',
            },
            body=json.dumps({
                'AttachStdin': True,
                'AttachStdout': True,
                'AttachStderr': True,
                'DetachKeys': 'ctrl-p,ctrl-q',
                'Tty': True,
                'Cmd': [
                    '/bin/bash'
                ]
            })
        )
        data = res.body
        print data
        exec_id = json.loads(data)['Id']
        print exec_id
        tcp_client = tornado.tcpclient.TCPClient()
        conn = yield tcp_client.connect(host, port)
        data = json.dumps({
            'Detach': False,
            'Tty': True
        })
        yield conn.write(b'POST /exec/{}/start HTTP/1.1\r\n'.format(exec_id))
        yield conn.write(b'Host: 11.11.11.3:2375\r\n')
        yield conn.write(b'Connection: Upgrade\r\n')
        yield conn.write(b'Content-Type: application/json\r\n')
        yield conn.write(b'Upgrade: tcp\r\n')
        yield conn.write(b'Content-Length: {}\r\n'.format(len(data)))
        yield conn.write(b"\r\n")
        yield conn.write(data.encode('utf-8'))
        res = yield conn.read_until(b'\r\n\r\n')
        print res

        self.termin_conn = conn

        @tornado.gen.coroutine
        def test():
            while True:
                try:
                    data = yield conn.read_bytes(1024, partial=True)
                    self.write_message(data)
                except tornado.iostream.StreamClosedError:
                    self.close()
                    break

        yield test()

    @tornado.gen.coroutine
    def on_message(self, message):
        try:
            yield self.termin_conn.write(message.encode('utf-8'))
        except tornado.iostream.StreamClosedError:
            self.write_message('Terminal has disconnected.')
            self.close()

    def on_close(self):
        try:
            # 这里可能有暗坑,比如进入 vim 后,页面被关闭(WebSocket 关闭)
            # 如果发送 exit 到容器里的话,是没法退出的
            self.termin_conn.write('\nexit\n') # exit bash when ws close
            self.termin_conn.close()
        except tornado.iostream.StreamClosedError:
            pass
        print "close"

    def check_origin(self, origin):
        # just for test
        return True

参考