源码追查 Drone 开源系统中的 socket 泄漏问题

背景

微服务的最基本基础,就是 CICD,CI 的交付产物是制品。制品有很多属性,比如环境、版本等。从这个抽象上来看,如果微服务是基于 K8s + docker 的话,那么,docker image 是制品的一种具象化的表现。同样,如果微服务并不基于 docker 和 K8s,那么,制品也可以是其他具体表现,比如 tar 包。

如果是基于 K8s + docker 的话,CI 最终交付的制品,应该是 docker 镜像。所以,如何构建镜像,就有的选了,比如:Jenkins、Gitlab CI、Drone、或者基于 K8s 的 Pod (init containers)的 Knative 等。个人认为,Gitlab CI 与 Gitlab 耦合太强,无法作为一个通用的构建中心独立出来。所以,Jenkins、Drone、Knative 等这几个系统,都可以按照团队喜好自建。但不论哪种方式,一个底线始终不变:必须能从原理层了解选型系统,并且,出了问题,最好可以从源码层定位和捕捉问题。

综合来看,Drone 既有 Pipeline 模型,又有插件化设计、基于 Golang,非常轻量,所以最终将其作为构建系统的核心底层设施。

Drone 是开源的,基于 Golang + Docker 的,也是目前 CNCF 云原生体系下的一个项目。

Drone 这套系统在我们公司,通过服务化交付运行近 3 年,非常稳定。由于使用 Drone 较早,目前使用的是 0.8.10 版本,后来准备使用 1.x 版本时,由于官方代码质量还严重不过关,有一堆问题,因此最终将版本,固定在了 1.x 之前的最后一个版本,也就是 0.8.10 。

然而,最近在排查某个问题的时候,发现 Drone 的 worker 节点上的组件进程 drone-agent 存在 socket 泄露问题。下面就是针对这个问题的一个排查过程。

提前剧透:最后的修复方式,是通过修改源码解决的,但是因为版本较早,截止目前,Drone 已经到了 v1.9.0 版本,之前版本,官方也不再管理和维护了。因此最终是通过直接修改 vendor 包的第三方库来解决。
另外,也顺便吐槽一下,Drone 官方维护者对 Drone 的权限收敛过大,对社区里的外部成员接纳程度不太高,在开源这个事情上的氛围差一些(这个问题也有人在社区论坛吐槽过)。

现存问题

问题

在查看 drone 的 worker 节点的健康状态时,发现其 worker 节点的工作组件 drone-agent 有大量链接,经排查,其数量达到了 18000+ 个,非常多。然而,查看其链接类型时,发现,几乎都是 unix domain socket 链接。

1
2
3
4
5
6
7
8
[root@node002145 xxx]# netstat -apn |grep 9329
tcp 0 0 192.168.x.x:62028 192.168.x.xx:6666 ESTABLISHED 9329/drone-agent
tcp6 0 0 :::3000 :::* LISTEN 9329/drone-agent
unix 3 [ ] STREAM CONNECTED 5004600 9329/drone-agent
unix 3 [ ] STREAM CONNECTED 151467 9329/drone-agent
unix 3 [ ] STREAM CONNECTED 215488 9329/drone-agent
unix 3 [ ] STREAM CONNECTED 234425 9329/drone-agent
...

drone

drone 本身,是 CNCF 一个项目,主要是基于 docker 的 pipeline 引擎。可以替代 Jenkins、GitlabCI 执行构建过程,非常灵活。

而 drone 是 master-slave 架构,slave 节点是执行 pipeline 的节点,执行组件为 drone-agent。由于 drone 是基于 docker 的,所以,drone-agent 组件需要与其节点上的 dockerd 执行通信,告知 docker 要起什么容器、执行上面命令等等。

所以,这 18000+ 个 连接,其实便是 drone-agent 与 dockerd 的链接。那么,如何验证呢?其实很简单,我们上面执行 netstat -apn ,grep 的是 drone-agent 的进程。反过来,我们 grep dockerd 的进程,也是一样的 18000+。

至于为什么是 uninx domain socket ,而不是 TCP socket,是因为 drone-agent 与 dockerd 通信,走的就是 dockerd 服务默认的 unix domain socket server 。

这是 drone-agent 的启动方式:

1
usr/bin/docker run --net=host --memory=10G --name=drone-agent -v /var/run/docker.sock:/var/run/docker.sock hub.xxx.com/paas/xxx/drone-agent:v1.0.2

可以看到,drone-agent 的启动,是要把主机上的 /var/run/docker.sock,挂载到 drone-agent 的容器内的。

这个问题,有什么影响?

由于操作系统中,进程能打开的文件句柄数量有限,而 TCP 链接本质还是一个 socket,也是要占用句柄数量的,虽然连接数有数量上限,且可通过 ulimit 配置,但是,只要有泄露问题,就还是有可能触发 “too many open files” 错误的。

排查

drone-agent 有 dockerd 之间的 socket 未释放,肯定是 drone-agent 未合理的关闭链接导致。

复现问题

先看一下,当前的 docker 数量,以及 drone-agent 的 unix domain socket 数量

1
2
3
4
5
Every 0.3s: docker ps && netstat -apn |grep drone-agent|wc -l                                                                                                                                                                                           Mon Aug 10 14:52:11 2020

CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a58ad55546a7 hub.xxx.com/paas/xxx/drone-agent:v1.0.2 "/bin/sh -c '/usr/bi…" 3 days ago Up 3 days drone-agent
126

触发一次构建过程,再观察

1
2
3
4
5
6
Every 0.3s: docker ps && netstat -apn |grep drone-agent|wc -l                                                                                                                                                                                           Mon Aug 10 14:55:11 2020

CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
5483dd82b3f5 hub.xxx.com/paas/golang:1.14.4 "/bin/sh -c 'echo $C…" 12 seconds ago Up 11 seconds 0_3359634489227731582_step_0
a58ad55546a7 hub.xxx.com/paas/xxx/drone-agent:v1.0.2 "/bin/sh -c '/usr/bi…" 3 days ago Up 3 days drone-agent
128

可以看到,一次构建触发,涨了 2 个 socket 链接,且不会释放

创建独立的 drone-agent 节点做测试

现在,构建系统是 1 个 drone-server + 2 个 drone-agent ,也就是 2 个worker 节点。之所以要创建一个独立的 drone-agent 节点,其目的是,作为我们的测试节点,且不影响现有的构建系统的征程工作。换句话说,我们要测试一些构建,这些构建,只在特定的 drone-agent 上跑。

这就要用到 Drone 的特性:节点选择特性:platform(可以理解为 K8s 体系的 NodeSelector 能力)。

①:首先,找一台机器,部署 drone-agent ,并给这个 drone-agent 指定 platform,由于 drone-agent 我们是 docker 启动的,故而,可参考如下 Dockerfile,构建一个新的 drone-agent 镜像

1
2
3
4
FROM hub.xxx.com/paas/centos:7.5.1804
COPY release/drone-agent /usr/bin
ENV TZ='Asia/Shanghai'
ENTRYPOINT /usr/bin/drone-agent --server=192.168.2.146:6666 --password=22222 --debug=true --max-procs=10 --platform=kvm/amd64

构建完之后,启动此镜像,这个 drone 的 agent 就成了一个特殊的 agent 了,其他构建,漂移不过来的。

之所以漂移不到这个 agent 节点,是因为,这个 agent 的节点,platform 指定的是 “kvm/amd64”,其他 agent 节点,默认的 platform 是 “linux/amd64” ,而最关键的是,一般的构建,默认其实指定了 platform 为 “linux/amd64”。

②:找一个需要构建的项目,配置 .drone.yml ,及 Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
workspace:
base: /go/

clone:
git:
image: hub.xxx.com/paas/drone-plugin-git:latest
network_mode: host

pipeline:
go-build:
image: hub.xxx.com/paas/golang:1.14.4
network_mode: host
environment:
GOPROXY: https://goproxy.cn
GOPRIVATE: gitlab.xxx.com
commands:
- make
volumes:
- /DATA/golang-mod/mod:/go/pkg/mod
build-image:
image: hub.xxx.com/paas/drone-docker:v0.5.7
purge: false
repo: hub.xxx.com/mtest/gowebsocketdemo
volumes:
- /var/run/docker.sock:/var/run/docker.sock
network_mode: host
auth_config:
innerid: hub.xxx.com
auth_config_innerid: hub.xxx.com

platform: kvm/amd64

③:测试构建

为这个 gowebsocketdemo 配置了如上 .drone.yml 后,提交 tag 触发自动构建,总会漂移到这个 agent 节点上,不会漂移到其他 agent 节点。

代码层初步排查

万事具备,后边,我们调试 drone-agent ,只需要在之前的特殊节点起停 drone-agent 服务测试即可,不会影响到主业务。

从之前的分析看,drone-agent 在执行 pipeline 的时候,很短的时间,就起了 2 个 unix domain socket,它后边分步执行 pipeline 的时候,socket 数量并没有什么变化。这可能说明,unix domain socket 的建立,可能是在执行 pipeline 之前就已经开始了。为了验证这个问题,先梳理一下 drone-agent 执行 pipeline 的基本代码结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// drone-agent 执行任务的逻辑

func (r *runner) run(ctx context.Context) error {

// 从队列取一个任务
work, err := r.client.Next(ctx, r.filter)
// 记录各种日志

// 创建一个 docker engine
engine, err := docker.NewEnv()
if err != nil {
...
}

// ...

// 初始化 pipeline 并执行:此处为重点!!
err = pipeline.New(work.Config,
pipeline.WithContext(ctx),
pipeline.WithLogger(defaultLogger),
pipeline.WithTracer(defaultTracer),
pipeline.WithEngine(engine),
).Run()

// ...

return nil
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Run 方法逻辑,准备再正式执行前,打一个 120 sleep 的断点

// Run starts the runtime and waits for it to complete.
func (r *Runtime) Run() error {
defer func() {
r.engine.Destroy(r.ctx, r.spec)
}()

r.started = time.Now().Unix()
if err := r.engine.Setup(r.ctx, r.spec); err != nil {
return err
}

// sleep 断点,观察连接数的增加,是在此断点前,还是断点后
fmt.Println("sleeping 120s")
time.Sleep(time.Second * 120)

for _, stage := range r.spec.Stages {
select {
case <-r.ctx.Done():
return ErrCancel
case err := <-r.execAll(stage.Steps):
if err != nil {
r.err = err
}
}
}

return r.err
}

改动之后,重新构建,部署 drone-agent。观察发现,在 sleep 动作发生时,drone-agent 的 unix socket 增加了 1 个,sleep 结束执行 pipeline ,unix socket 又增加了一个。

  1. sleep 之前,只有 r.engine.Setup 逻辑,所以,可以认为,执行 pipeline 前对 r.engine.Setup 的调用,一定有一个 unix socket 创建了但未释放。
  2. sleep 之后增加的 socket ,只能去 r.execAll 跟踪。

①:r.engine.Setup 里的 unix socket 追查

Setup 的逻辑其实很简单,就是创建 volume、network,所以,关于连接建立的逻辑,其实是在 e.client.VolumeCreate 之类的函数内

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// vendor/github.com/cncd/pipeline/pipeline/backend/docker/docker.go

func (e *engine) Setup(_ context.Context, conf *backend.Config) error {

// 如果配置了 volumes 的话,创建 volume
for _, vol := range conf.Volumes {
_, err := e.client.VolumeCreate(noContext, volume.VolumesCreateBody{
Name: vol.Name,
Driver: vol.Driver,
DriverOpts: vol.DriverOpts,
// Labels: defaultLabels,
})
if err != nil {
return err
}
}

// 如果配置了 Networks 的话,创建 Networks
for _, network := range conf.Networks {
_, err := e.client.NetworkCreate(noContext, network.Name, types.NetworkCreate{
Driver: network.Driver,
Options: network.DriverOpts,
// Labels: defaultLabels,
})
if err != nil {
return err
}
}
return nil
}

下面是 VolumeCreate 函数,这个函数,是在 docker 源码包中(vendor目录下)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// vendor/github.com/docker/docker/client/volume_create.go

// VolumeCreate creates a volume in the docker host.

func (cli *Client) VolumeCreate(ctx context.Context, options volumetypes.VolumesCreateBody) (types.Volume, error) {
var volume types.Volume
resp, err := cli.post(ctx, "/volumes/create", nil, options, nil)
if err != nil {
return volume, err
}
err = json.NewDecoder(resp.body).Decode(&volume)
ensureReaderClosed(resp)
return volume, err
}

初步来看,docker client 源码中,提供了 ensureReaderClosed 方法,此方法内部如下:

1
2
3
4
5
6
7
8
9
// vendor/github.com/docker/docker/client/request.go

func ensureReaderClosed(response serverResponse) {
if body := response.body; body != nil {
// Drain up to 512 bytes and close the body to let the Transport reuse the connection
io.CopyN(ioutil.Discard, body, 512)
response.body.Close()
}
}

从这个层面来看,docker client 源码提供的 API,是处理了 resp.Body.Close 释放链接的方法了。如此一来,应该生效才对。

陷入两难境地:

  1. 为什么 docker client 的源码包的 ensureReaderClosed 不生效?
  2. 既然不生效,应该是使用姿势不对,一定有释放链接的方法?

个人认为,docker client 自己设计缺陷造成 socket 泄漏的可能性应该比较小(要不社区早炸了),所以问题的重心,应该放在 docker 源码包的上层调用上,也就是:github.com/cncd/pipeline 。也就是说,从可能性上来说:github.com/cncd/pipeline 使用了 github.com/docker/docker/client,但是其使用姿势有问题,所以链接未释放——这种可能性比较大。

缩小排查范围,修改源码做验证

我们排查问题,肯定要有一个基线,也就是排查 问题,先基本判定,哪种可能性大,哪种可能性小,否则,茫然排查,会花费大量精力,还不一定有结果。

目前来看,Drone 使用的源码包,可能出现 socket 泄漏问题的有 2 个:

  1. github.com/cncd/pipeline
  2. github.com/docker/docker/client

先基本判定,docker 的源码包出问题的可能性小,毕竟 docker 社区庞大,如果 docker/client 包存在问题,社区应该有大量帖子讨论这个问题,或者有相关的 issue 在 github repo 中。然而,看了一下,只有一个 dockerd socket 泄漏问题,浏览了一下具体描述,与我们的问题不太相符。

排查重心,先放到 github.com/cncd/pipeline 源码包。

走读 pipeline 源码包,其提供了一个 Engine API,我们需要对此 API 改造,让其提供 Close 方法,关闭与 dockerd 的链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// vendor/github.com/cncd/pipeline/pipeline/backend/backend.go

// Engine defines a container orchestration backend and is used
// to create and manage container resources.
type Engine interface {
// Setup the pipeline environment.
Setup(context.Context, *Config) error
// Start the pipeline step.
Exec(context.Context, *Step) error
// Kill the pipeline step.
Kill(context.Context, *Step) error
// Wait for the pipeline step to complete and returns
// the completion results.
Wait(context.Context, *Step) (*State, error)
// Tail the pipeline step logs.
Tail(context.Context, *Step) (io.ReadCloser, error)
// Destroy the pipeline environment.
Destroy(context.Context, *Config) error
}

①:我们为 pipeline Engine Interface,补充一个 Close 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// vendor/github.com/cncd/pipeline/pipeline/backend/backend.go

// Engine defines a container orchestration backend and is used
// to create and manage container resources.
type Engine interface {
// Setup the pipeline environment.
Setup(context.Context, *Config) error
// Start the pipeline step.
Exec(context.Context, *Step) error
// Kill the pipeline step.
Kill(context.Context, *Step) error
// Wait for the pipeline step to complete and returns
// the completion results.
Wait(context.Context, *Step) (*State, error)
// Tail the pipeline step logs.
Tail(context.Context, *Step) (io.ReadCloser, error)
// Destroy the pipeline environment.
Destroy(context.Context, *Config) error

// Close connections
Close() error
}

②:完善 pipeline Engine Interface 的实现

修改前:

1
2
3
4
5
6
7
8
9
10
11
12
// vendor/github.com/cncd/pipeline/pipeline/backend/docker/docker.go

type engine struct {
client client.APIClient
}

// New returns a new Docker Engine using the given client.
func New(cli client.APIClient) backend.Engine {
return &engine{
client: cli,
}
}

修改后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// vendor/github.com/cncd/pipeline/pipeline/backend/docker/docker.go

type engine struct {
client *client.Client
}

// New returns a new Docker Engine using the given client.
func New(cli *client.Client) backend.Engine {
return &engine{
client: cli,
}
}

// 这才是核心!!!!
// 增加 Close 方法,直接调用 docker Client 的 Close
func (e *engine) Close() error {
return e.client.Close()
}

④:pipeline 源码包毕竟是底包,我们还得修改一下上层调用,所谓的上层,也就是 Drone 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Run starts the runtime and waits for it to complete.
func (r *Runtime) Run() error {
defer func() {
r.engine.Destroy(r.ctx, r.spec)

// 补充一个 Close 调用
r.engine.Close()
}()

r.started = time.Now().Unix()
if err := r.engine.Setup(r.ctx, r.spec); err != nil {
return err
}

for _, stage := range r.spec.Stages {
select {
case <-r.ctx.Done():
return ErrCancel
case err := <-r.execAll(stage.Steps):
if err != nil {
r.err = err
}
}
}

return r.err
}

重新打包编译,部署测试。通过多次测试,发现 socket 泄漏问题解决了。每次构建结束,socket 都会释放。

考虑提PR

1、搜索相关 PR 是已存在

既然问题出在 github.com/cncd/pipeline 的源码包上,那么,可以考虑修复之后,提交一个 PR 过去。

但是,提 PR 之前,得先做搜索相关的 issue 和 PR,关键词:”leak”、”socket”、”close”

搜到一个还在 Open 的 PR:https://github.com/cncd/pipeline/pull/20

这个 PR 的大意是:作者 @cedk 提交了一个 PR,作者认为,docker client 应该主动关闭它使用的 socket ,但作者没有提到,为什么要关,以及它的负面影响是什么。然而,可惜的是,这个 PR 在 17 年提出来,一直没有被官方采纳。

2、重提 issue

由于 pipeline 未正常关闭 docker client socket,存在 socket 泄漏问题比较严重,可能触发进程打开最大连接数上限,导致后续 drone-agent 停止服务。因此,我打算提交一个 issue ,问一下 pipeline 包的官方维护者,为什么之前的这个 PR 没过。

参见:https://github.com/cncd/pipeline/issues/50

可惜的是,后来项目维护者说这个项目已经归档,不再维护了。

总结

其实,总结来说,Drone 存在的 unix domain socket 泄露问题,普遍存在于 v0.8.10 及以下版本。由于官方已不再维护 cncd/pipeline 项目,因此可以在项目里,修改 vendor 里的包来解决,不过这种方式并不优雅,也可以将 cncd/pipeline 这个包,提到项目级目录下,而不是放到 vendor 下。

至于如何修改,前面已经给了示例,也可以参考之前别人提的 PR:https://github.com/cncd/pipeline/pull/20

Donate comment here