通过浏览器连接docker容器

前言

在公司内部使用 Jenkins 做 CI/CD 时,经常会碰到项目构建失败的情况,一般情况下通过 Jenkins 的构建控制台输出都可以了解到大概发生的问题,但是有些特殊情况开发需要在 Jenkins 服务器上排查问题,这个时候就只能找运维去调试了,为了开发人员的体验就调研了下 web terminal,能够在构建失败时提供容器终端给开发进行问题的排查。

效果展示

支持颜色高亮,支持<kbd>tab</kbd>键补全,支持复制粘贴,体验基本上与平常的 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 IDcontainer name就可以打开指定的容器进行交互了。

完整代码:https://github.com/monkeyWie/docker-web-terminal

本文首发于我的博客:https://monkeywie.cn,欢迎收藏!不定期分享JAVAGolang前端dockerk8s等干货知识。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,240评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,328评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,182评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,121评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,135评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,093评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,013评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,854评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,295评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,513评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,398评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,989评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,636评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,657评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352

推荐阅读更多精彩内容

  • 用两张图告诉你,为什么你的 App 会卡顿? - Android - 掘金 Cover 有什么料? 从这篇文章中你...
    hw1212阅读 12,711评论 2 59
  • 一. 前言 在微服务大行其道的今天,容器恰巧又是微服务的主要载体,所以我们操作的对象也由最开始的「物理机」到「虚拟...
    猴子精h阅读 10,143评论 2 10
  • 黑色的海岛上悬着一轮又大又圆的明月,毫不嫌弃地把温柔的月色照在这寸草不生的小岛上。一个少年白衣白发,悠闲自如地倚坐...
    小水Vivian阅读 3,105评论 1 5
  • 渐变的面目拼图要我怎么拼? 我是疲乏了还是投降了? 不是不允许自己坠落, 我没有滴水不进的保护膜。 就是害怕变得面...
    闷热当乘凉阅读 4,241评论 0 13
  • 感觉自己有点神经衰弱,总是觉得手机响了;屋外有人走过;每次妈妈不声不响的进房间突然跟我说话,我都会被吓得半死!一整...
    章鱼的拥抱阅读 2,172评论 4 5