因为工作项目的需求,研究了一下 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
来交互 stdin
、stdout
和 stderr
。其实官方的 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
。
在 Docker 这个 API 实现这里,跟 WebSocket
的实现类似,首先是一个 POST
请求过来(WebSocket
是 GET
请求),然后服务器不会中断这个连接,而是返回给客户端下面这样的 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
里写 stdout
和 stderr
(如果请求的时候有这个的需求)的内容,然后也可以往这个 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
的实例。
具体的实现原理是打开前端页面,与后端建立一个 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