共计 4626 个字符,预计需要花费 12 分钟才能阅读完成。
前言
在公司外部应用 Jenkins 做 CI/CD 时,常常会碰到我的项目构建失败的状况,个别状况下通过 Jenkins 的构建控制台输入都能够理解到大略产生的问题,然而有些非凡状况开发须要在 Jenkins 服务器上排查问题,这个时候就只能找运维去调试了,为了开发人员的体验就调研了下 web terminal,可能在构建失败时提供容器终端给开发进行问题的排查。
成果展现
反对色彩高亮,反对 tab 键补全,反对复制粘贴,体验基本上与平时的 terminal 统一。
基于 docker 的 web terminal 实现
docker exec 调用
首先想到的就是通过 docker exec -it ubuntu /bin/bash
命令来开启一个终端,而后将规范输出和输入通过 websocket
与前端进行交互。
而后发现 docker 有提供 API 和 SDK 进行开发的,通过 Go SDK
能够很不便的在 docker 里创立一个终端过程:
- 装置 sdk
go get -u github.com/docker/docker/client@8c8457b0f2f8
这个我的项目新打的 tag 没有遵循 go mod server 语义,所以如果间接 go get -u github.com/docker/docker/client
默认装置的是 2017 年的打的一个 tag 版本,这里我间接在 master 分支上找了一个 commit ID,具体起因参考 issue
- 调用 exec
package main | |
import ( | |
"bufio" | |
"context" | |
"fmt" | |
"github.com/docker/docker/api/types" | |
"github.com/docker/docker/client" | |
) | |
func main() { | |
// 初始化 go sdk | |
ctx := context.Background() | |
cli, err := client.NewClientWithOpts(client.FromEnv) | |
if err != nil {panic(err) | |
} | |
cli.NegotiateAPIVersion(ctx) | |
// 在指定容器中执行 /bin/bash 命令 | |
ir, err := cli.ContainerExecCreate(ctx, "test", types.ExecConfig{ | |
AttachStdin: true, | |
AttachStdout: true, | |
AttachStderr: true, | |
Cmd: []string{"/bin/bash"}, | |
Tty: true, | |
}) | |
if err != nil {panic(err) | |
} | |
// 附加到下面创立的 /bin/bash 过程中 | |
hr, err := cli.ContainerExecAttach(ctx, ir.ID, types.ExecStartCheck{Detach: false, Tty: true}) | |
if err != nil {panic(err) | |
} | |
// 敞开 I /O | |
defer hr.Close() | |
// 输出 | |
hr.Conn.Write([]byte("ls\r")) | |
// 输入 | |
scanner := bufio.NewScanner(hr.Conn) | |
for scanner.Scan() {fmt.Println(scanner.Text()) | |
} | |
} |
这个时候 docker 的终端的输入输出曾经能够拿到了,接下来要通过 websocket 来和前端进行交互。
前端页面
当咱们在 linux terminal 上敲下 ls
命令时,看到的是:
root@a09f2e7ded0d:/# ls | |
bin dev home lib64 mnt proc run srv tmp var | |
boot etc lib media opt root sbin sys usr |
实际上从规范输入里返回的字符串却是:
[0m[01;34mbin[0m [01;34mdev[0m [01;34mhome[0m [01;34mlib64[0m [01;34mmnt[0m [01;34mproc[0m [01;34mrun[0m [01;34msrv[0m [30;42mtmp[0m [01;34mvar[0m | |
[01;34mboot[0m [01;34metc[0m [01;34mlib[0m [01;34mmedia[0m [01;34mopt[0m [01;34mroot[0m [01;34msbin[0m [01;34msys[0m [01;34musr[0m |
对于这种状况,曾经有了一个叫 xterm.js
的库,专门用来模仿 Terminal 的,咱们须要通过这个库来做终端的显示。
var term = new Terminal(); | |
term.open(document.getElementById("terminal")); | |
term.write("Hello from \x1B[1;3;31mxterm.js\x1B[0m $"); |
通过官网的例子,能够看到它会将特殊字符做对应的显示:
这样的话只须要在 websocket 连上服务器时,将获取到的终端输入应用 term.write()
写进去,再把前端的输出作为终端的输出就能够实现咱们须要的性能了。
思路是没错的,然而没必要手写,xterm.js
曾经提供了一个 websocket 插件就是来做这个事的,咱们只须要把规范输出和输入的内容通过 websocket 传输就能够了。
- 装置 xterm.js
npm install xterm
- 基于 vue 写的前端页面
<template> | |
<div ref="terminal"></div> | |
</template> | |
<script> | |
// 引入 css | |
import "xterm/dist/xterm.css"; | |
import "xterm/dist/addons/fullscreen/fullscreen.css"; | |
import {Terminal} from "xterm"; | |
// 自适应插件 | |
import * as fit from "xterm/lib/addons/fit/fit"; | |
// 全屏插件 | |
import * as fullscreen from "xterm/lib/addons/fullscreen/fullscreen"; | |
// web 链接插件 | |
import * as webLinks from "xterm/lib/addons/webLinks/webLinks"; | |
// websocket 插件 | |
import * as attach from "xterm/lib/addons/attach/attach"; | |
export default { | |
name: "Index", | |
created() { | |
// 装置插件 | |
Terminal.applyAddon(attach); | |
Terminal.applyAddon(fit); | |
Terminal.applyAddon(fullscreen); | |
Terminal.applyAddon(webLinks); | |
// 初始化终端 | |
const terminal = new Terminal(); | |
// 关上 websocket | |
const ws = new WebSocket("ws://127.0.0.1:8000/terminal?container=test"); | |
// 绑定到 dom 上 | |
terminal.open(this.$refs.terminal); | |
// 加载插件 | |
terminal.fit(); | |
terminal.toggleFullScreen(); | |
terminal.webLinksInit(); | |
terminal.attach(ws); | |
} | |
}; | |
</script> |
后端 websocket 反对
在 go 的规范库中是没有提供 websocket 模块的,这里咱们应用官网钦点的 websocket 库。
go get -u github.com/gorilla/websocket
外围代码如下:
// websocket 握手配置,疏忽 Origin 检测 | |
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool {return true}, | |
} | |
func terminal(w http.ResponseWriter, r *http.Request) { | |
// websocket 握手 | |
conn, err := upgrader.Upgrade(w, r, nil) | |
if err != nil {log.Error(err) | |
return | |
} | |
defer conn.Close() | |
r.ParseForm() | |
// 获取容器 ID 或 name | |
container := r.Form.Get("container") | |
// 执行 exec,获取到容器终端的连贯 | |
hr, err := exec(container) | |
if err != nil {log.Error(err) | |
return | |
} | |
// 敞开 I / O 流 | |
defer hr.Close() | |
// 退出过程 | |
defer func() {hr.Conn.Write([]byte("exit\r")) | |
}() | |
// 转发输出 / 输入至 websocket | |
go func() {wsWriterCopy(hr.Conn, conn) | |
}() | |
wsReaderCopy(conn, hr.Conn) | |
} | |
func exec(container string) (hr types.HijackedResponse, err error) { | |
// 执行 /bin/bash 命令 | |
ir, err := cli.ContainerExecCreate(ctx, container, types.ExecConfig{ | |
AttachStdin: true, | |
AttachStdout: true, | |
AttachStderr: true, | |
Cmd: []string{"/bin/bash"}, | |
Tty: true, | |
}) | |
if err != nil {return} | |
// 附加到下面创立的 /bin/bash 过程中 | |
hr, err = cli.ContainerExecAttach(ctx, ir.ID, types.ExecStartCheck{Detach: false, Tty: true}) | |
if err != nil {return} | |
return | |
} | |
// 将终端的输入转发到前端 | |
func wsWriterCopy(reader io.Reader, writer *websocket.Conn) {buf := make([]byte, 8192) | |
for {nr, err := reader.Read(buf) | |
if nr > 0 {err := writer.WriteMessage(websocket.BinaryMessage, buf[0:nr]) | |
if err != nil {return} | |
} | |
if err != nil {return} | |
} | |
} | |
// 将前端的输出转发到终端 | |
func wsReaderCopy(reader *websocket.Conn, writer io.Writer) { | |
for {messageType, p, err := reader.ReadMessage() | |
if err != nil {return} | |
if messageType == websocket.TextMessage {writer.Write(p) | |
} | |
} | |
} |
总结
以上就实现了一个简略的 docker web terminal 性能,之后只须要通过前端传递 container ID
或container name
就能够关上指定的容器进行交互了。
残缺代码:https://github.com/monkeyWie/…
我是 MonkeyWie,欢送扫码???????? 关注!不定期在公众号中分享
JAVA
、Golang
、前端
、docker
、k8s
等干货常识。