ZEROMAKE | keep codeing and thinking!
2019-03-22 | docker

docker 容器调试新姿势

一、前言

  1. 我在平时工作中经常使用 docker 来创建自己的开发环境,比如 mysql, redis 之类的。
  2. 有些时候需要把现有的容器里的服务配置进行变更,docker exec 进入容器后发现很多基础命令工具(vim, nano)都没有,这让我很苦恼。
  3. 再后来看到 @github/ayleikubectl-debug 发现了容器之间可以共享各种资源。
  4. 但是 kubectl-debug 只能够提供给 kubernetes 进行使用,所以我这边模仿了 kubectl-debug 写了一个 docker-debug

二、原理和方案

docker 内置的资源共享
ContainerCreate api 文档中我们可以找到
HostConfig 下有 NetworkMode, UsernsMode, IpcMode, PidMode

只要根据文档格式设置即可在新的容器中共享 network, user, ipc, pid 这些资源。

文件系统共享
docker 从宿主机挂载的目录文件可以直接继承设置到新的容器创建配置中即可使用。

Docker 核心技术与实现原理 这篇博文中我了解到对于最终运行中的容器是有一个通过 UnionFS 在文件系统提供一个合并的目录,然后再挂载到容器中的。

在这个基础上我去 /var/lib/docker/overlay2 下找到了很多 hash 目录经过检查发现就是我想要的合成目录,但是这个时候却发现不知道如何找到对应容器的最终合成目录,找了一下发现 docker inspect 能够打出容器的各种信息。

1
{
2
"GraphDriver": {
3
"Data": {
4
"LowerDir": "...",
5
"MergedDir": "/var/lib/docker/overlay2/dd1a974cf3c1c43fe43598987664e6c9fb17f5872afd280254132bd036051ea7/merged",
6
"UpperDir": "/var/lib/docker/overlay2/dd1a974cf3c1c43fe43598987664e6c9fb17f5872afd280254132bd036051ea7/diff",
7
"WorkDir": "/var/lib/docker/overlay2/dd1a974cf3c1c43fe43598987664e6c9fb17f5872afd280254132bd036051ea7/work"
8
}
9
}
10
}

发现了 GraphDriver.Data.MergedDir 正好指向最终的合成目录,直接像挂载宿主机目录一样即可挂载到新的容器当中。

三、代码

这边考虑只介绍 创建容器拉取镜像创建exec运行exec

拉取镜像

1
import (
2
"context"
3
"strings"
4
5
"github.com/pkg/errors"
6
"github.com/docker/docker/client"
7
"github.com/docker/docker/api/types"
8
"github.com/docker/docker/pkg/jsonmessage"
9
"github.com/docker/docker/api/types"
10
"github.com/docker/docker/api/types/container"
11
"github.com/docker/docker/api/types/mount"
12
"github.com/docker/docker/api/types/strslice"
13
14
"github.com/zeromake/docker-debug/pkg/stream"
15
"github.com/zeromake/docker-debug/pkg/tty"
16
)
17
18
const (
19
legacyDefaultDomain = "index.docker.io"
20
defaultDomain = "docker.io"
21
fficialRepoName = "library"
22
)
23
24
type Cli struct {
25
client client.APIClient
26
in *stream.InStream
27
out *stream.OutStream
28
err io.Writer
29
}
30
// splitDockerDomain 分割镜像名和domain
31
func splitDockerDomain(name string) (domain, remainder string) {
32
i := strings.IndexRune(name, '/')
33
if i == -1 || (!strings.ContainsAny(name[:i], ".:") && name[:i] != "localhost") {
34
domain, remainder = defaultDomain, name
35
} else {
36
domain, remainder = name[:i], name[i+1:]
37
}
38
if domain == legacyDefaultDomain {
39
domain = defaultDomain
40
}
41
if domain == defaultDomain && !strings.ContainsRune(remainder, '/') {
42
// 处理docker hub 镜像名映射
43
remainder = officialRepoName + "/" + remainder
44
}
45
return
46
}
47
48
// ImagePull 拉取镜像
49
func (c *Cli) ImagePull(imageName string) error {
50
domain, remainder := splitDockerDomain(imageName);
51
body, err := c.client.ImagePull(
52
context.Background(),
53
domain + '/' + remainder,
54
types.ImagePullOptions{},
55
)
56
if err != nil {
57
return errors.WithStack(err)
58
}
59
defer body.Close()
60
// docker 包里自带处理 pull 的 http 输出到 tty。
61
return jsonmessage.DisplayJSONMessagesToStream(responseBody, cli.out, nil)
62
}
63
64
// CreateContainer 创建一个自定义镜像的新容器并把目标容器的各种资源挂载到新容器上
65
func (c *Cli) CreateContainer(targetContainerID string) error {
66
ctx := context.Background()
67
info, err := cli.client.ContainerInspect(ctx, targetContainerID)
68
if err != nil {
69
return errors.WithStack(err)
70
}
71
mountDir, ok := info.GraphDriver.Data["MergedDir"]
72
mounts = []mount.Mount{}
73
targetMountDir = '/mnt/container';
74
// 通过 Inspect 查找出 MergedDir 位置并挂载到新容器的 /mnt/container
75
if ok {
76
mounts = append(mounts, mount.Mount{
77
Type: "bind",
78
Source: mountDir,
79
Target: targetMountDir,
80
})
81
}
82
// 继承目标容器的挂载目录
83
for _, i := range info.Mounts {
84
var mountType = i.Type
85
if i.Type == "volume" {
86
// 虚拟目录在 docker 处理后也是一个在宿主机上的普通目录改为 bind
87
mountType = "bind"
88
}
89
mounts = append(mounts, mount.Mount{
90
Type: mountType,
91
Source: i.Source,
92
Target: targetMountDir + i.Destination,
93
ReadOnly: !i.RW,
94
})
95
}
96
targetName := fmt.Sprintf("container:%s", targetContainerID)
97
containerConfig := &container.Config{
98
// 直接使用 sh 命令作为统一的容器后台进程
99
Entrypoint: strslice.StrSlice([]string{"/usr/bin/env", "sh"}),
100
// 默认镜像,真实项目中应该来自配置
101
Image: 'nicolaka/netshoot:latest',
102
Tty: true,
103
OpenStdin: true,
104
StdinOnce: true,
105
}
106
hostConfig := &container.HostConfig{
107
// network 共享
108
NetworkMode: container.NetworkMode(targetName),
109
// 用户共享
110
UsernsMode: container.UsernsMode(targetName),
111
// ipc 共享
112
IpcMode: container.IpcMode(targetName),
113
// pid 共享
114
PidMode: container.PidMode(targetName),
115
// 文件共享
116
Mounts: mounts,
117
}
118
}
119
120
// ExecCreate 创建exec
121
func ExecCreate(containerID string) error {
122
ctx := context.Background()
123
execConfig := types.ExecConfig{
124
// User: options.user,
125
// Privileged: options.privileged,
126
// DetachKeys: options.detachKeys,
127
// 是否分配一个 tty vim 之类的 cli 交互类工具需要
128
Tty: true,
129
// 是否附加各种标准流
130
AttachStderr: true,
131
AttachStdin: true,
132
AttachStdout: true,
133
// exec 需要执行的命令也就是目标命令
134
Cmd: [
135
'bash',
136
'-l',
137
],
138
}
139
resp, err := cli.client.ContainerExecCreate(ctx, container, opt)
140
return resp, errors.WithStack(err)
141
}
142
143
func (cli *DebugCli) ExecStart(execID string) error {
144
ctx := context.Background()
145
execConfig := types.ExecStartCheck{
146
Tty: true,
147
}
148
response, err := cli.client.ContainerExecAttach(ctx, execID, execConfig)
149
if err != nil {
150
return errors.WithStack(err)
151
}
152
// 把 docker cli 包的 tty 移植了可以直接处理 HijackedIO。
153
streamer := tty.HijackedIOStreamer{
154
Streams: cli,
155
InputStream: cli.in,
156
OutputStream: cli.out,
157
ErrorStream: cli.err,
158
Resp: response,
159
TTY: true,
160
}
161
return streamer.Stream(ctx)
162
}

四、一些边角处理

4.1 通过环境变量获取 docker 配置

在各种系统环境下 dockercli 获取连接的配置都是用环境变量和固定值来做的。

  • DOCKER_HOST 对应 docker 服务端 api 地址。
  • DOCKER_TLS_VERIFY api 的连接是否为 tls
  • DOCKER_CERT_PATH 使用的证书目录。

4.2 docker/client 的 opts 包引入报错

在直接使用 docker/clientopts 包发现有很多奇怪的引入照成了各种错误,经过研究发现我的项目只需要一部分提取后放到了项目中。

4.3 使用 git-chglog 来生成 changelog

在开发过程中 changelog 如果使用手动维护会十分麻烦,所以考虑寻找一个 cli 通过 git log 自动生成。

后面找到了 git-chglog 这个工具效果还不错就是比较麻烦的是 changelog 自己也在 git 管理下每次的生成都会错过这次生成的提及。

4.4 go 编译二进制的一些问题

golang 的程序编译后只有单个执行文件,但是大小有 10MB - 11MB 左右,后来考虑使用 upx 进行压缩,但是却发现 upx 的压缩虽然能够在 linux 上压缩 mac 的二进制但是会出现压缩后二进制文件无法执行了,直接在 mac 上压缩是没问题的。

五、下一步计划

  • 抽取 cli 的操作,开放到 pkg 里。
  • 构建一个 http rpc api 支持在网页上操作,通过 websocketsocket.io 支持 tty 映射。
  • 单独构建一个前端操作界面,可支持静态部署类似 aria2ui 之类的。

六、参考和感谢