/ Tornado

使用 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` 请求。 ```python 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](https://github.com/sourcelair/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
#参考 * https://github.com/docker/docker/blob/master/api/server/router/container/exec.go * https://github.com/docker/docker-py/blob/master/docker/api/exec_api.py * https://docs.docker.com/engine/reference/api/docker\_remote\_api