Go的CICD、Module、构建、测试

前言

最近在做一些项目的重构,将一些服务和功能逻辑逐渐迁移到Go,或者通过来来重写一遍。一是优化相关逻辑,删减部分代码和逻辑功能,二是之前的架构和服务的部署方式,已经远远落后于时代,不论是开发效率、代码检查、部署与发布上线等等,都消耗了大量不必要的时间,严重影响开发效率。

CICD有很多方案,如Drone、Jenkins、Gitlab等,过往的经历告诉我,不能单纯为了Go而使用Go,除非Go编写的服务确实好用,足够成果。从简单实现的角度,通过Gitlab来完成,公司的部署环境有些特殊,有安全层面的考虑,是不能直接访问外网的,包括但不限于docker pull、go get、go mod download等等。如何加快CI的执行速度,如何使得构建后的镜像最小,减少传输带宽,如何复用Go相关的package,减少多次构建需要重新下载等等,就这些问题分享一下我的思路和做法。

镜像构建

docker对大家来讲,已经非常常见了,并不是什么新潮的东西。但经常能看到大家构建出来的镜像,并不是最佳实践,构建出来的镜像包含了很多不必要的内容,尤其是如果这个镜像作为基础镜像需要给其他人使用的,那么这就很不负责任了。我只是跑一个简单的服务,你却需要我docker pull一个4GB的镜像,要花大量时间等待,尤其是网络不好的时候,编译发布也不方便。
如何构建出一个体积最小的镜像,这是我们需要对自己的基本要求,也是一个基本良心。

合并多个RUN语句

看过大量的Dockerfile都会发现,很多Dockerfile都用了一些奇怪的技巧,用了大量的&&,来替代多个RUN,因为根据docker镜像构建的原理,其实就和git commit一样,是逐层叠加的,层数越多,占用的空间也就会越大。

多阶段构建

若多个阶段使用同个镜像,那么多阶段构建等于压缩了中间多个层,将他们合并到一个层。如我们在git中add了一个较大的文件,然后下一次commit的时候把他删除了,虽然我们拉取这个git代码时,这个文件不在了,但是他一直存在在这个仓库,还是会占用较大的体积。所以我们做的ADD data ./RUN rm ./data,其实就是个障眼法,掩耳盗铃,这个data还是存在于镜像中。所以可以在移除掉之后,通过多阶段构建,将结果COPY一份,不需要中间过程产生的文件等。

1
2
3
4
5
6
7
FROM golang:1.12 as build-env
WORKDIR /go/src/y.cn/some
ADD . ./
RUN CGO_ENABLED=0 GO111MODULE=on go build -ldflags -o some y.cn/some
FROM golang:1.12
WORKDIR /go
COPY --from=build-env /go/src/y.cn/some/some ./

上述脚本已经将所编译的some,copy到一个新的镜像,这就是一个简单合并各个镜像层。

distroless

这是Google提供的一个容器库,支持多个主流语言,仅包含该语言所需的环境、二进制文件和脚本,他甚至shell都没有,所以你想attach进去容器都是不行的,当docker相关的日志和监控比较完善的时候,才有条件使用。

Alpine

Alpine是各类镜像使用最多的基础镜像,Alpine是一个轻量级、面向安全的linux发行版,也是比较实用的镜像中最小的一个,如最新的3.10版本,仅有5MB大小。如Go开发的脚本、服务,可以将二进制文件构建好后,copy到一个干净的Alpine镜像中,这样得到的镜像大小就特别小,约等于Alpine镜像大小+Go二进制包大小。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FROM golang:1.12 as build-env
WORKDIR /go/src/y.cn
RUN git clone git@y.cn:some.git --depth 1 \
&& cd some \
&& CGO_ENABLED=0 GO111MODULE=on go build -ldflags -o some y.cn/some

FROM alpine:3.10
RUN apk update \
&& apk upgrade \
&& apk add --no-cache \
ca-certificates \
&& update-ca-certificates 2>/dev/null || true
WORKDIR /data
COPY --from=build-env /go/src/y.cn/some/some ./
ADD conf.yml ./etc
ENTRYPOINT ["./SERVICE_NAME"]

这样就是得到了一个非常小的镜像,通常10~30MB的一个镜像就能将服务容器跑起来。因为Alpine是非常小的,所以并不支持gcc环境等,需要CGO_ENABLED=0关闭CGO,不然你会发现编译后的二进制包无法运行。

Go Module

Go Mod是Go新引入的一个包管理工具,实际上谈不上成熟,官方也没有推荐使用,但go mod毕竟还是未来,提前做好准备还是十分必要的。原先的GoVendor/GoDep的包管理,并未在部署上尝试特别大的问题。但由于go mod下载的package其实是放在你的$GOPATH/pkg/mod中,并没有加载到你的项目中,go mod vendor我执行之后也并未成果,暂时没找到什么原因。
所以使用go mod也带来一个挑战,事实上很多服务器的环境,并没有一个放开的公网网络,若 package之前未层使用过,go build或者go mod download的时候,都需要下载对应的package。意味着给Go项目的CICD、测试带来了一些影响。
在如何解决这类问题方面,分享一下我的看法,无非就是线上环境的$GOPATH/pkg/mod需要提前准备好相关package的问题。想到了可以通过golang:latest作为基础镜像,把项目git仓库ADD进去,go mod download下载所有所需的package,然后删掉git库,commit这个镜像到线上,那线上就有pkg/mod了。然后这个镜像就可以作为线上构建的基础镜像,包含所需的库,并且提供一些必备的工具脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
FROM golang:1.12 as build-env
WORKDIR /go/src/y.cn/some
ADD . ./
RUN GO111MODULE=on go mod download \
&& cd ../ \
&& rm -rf some
FROM golang:1.12
WORKDIR /go
RUN apt-get update && apt-get install -y mariadb-client > /dev/null
RUN go get golang.org/x/tools/cmd/goimports \
&& go get honnef.co/go/tools/cmd/staticcheck \
&& go get github.com/golangci/golangci-lint/cmd/golangci-lint
COPY --from=build-env /go/ ./

Gitlab Runner

通常很多公司的内部仓库都是用的Gitlab来管理,那么其实Gitlab是能够关联一些很好的工具,比如一些需求管理、Wiki等。其中Gitlab CI可以做一些代码检查、跑测试用例、自动构建等等工作。使得每个人的代码提交后,都能检测本次提交改动内容的正确性,合并到主线分支之后,是否所有测试用例都能通过,从而提前避免一些代码错误、上线故障等。
尤其是如果团队人数很多,一个大项目下面很多微服务,各个服务之间存在交叉影响,能够完善测试各个数据库、缓存之间的交互,模拟流量和数据,在一个小型环境中跑通全部测试用例,这对开发效率是非常有意义的。每个人只需要关注自己修改的功能,并编写测试用例来保证自己的功能(逻辑)不受其他人提交的代码影响,而不需要花太多精力来留意别人写的模块。
Gitlab CI依靠Gitlab Runner来运行,Gitlab Runner可以通过docker容器来运行,可以配置最大任务数限制,当有git commit的时候,他就会拉取最新的代码,对这批代码做一些CICD操作,这些配置命令,可以写在.gitlab-ci.yml,Gitlab Runner会按这个配置来执行这些计划任务。当测试不通过的时候,会发送邮件到对应的邮箱。
对应的Dockerfile其实也可以很简单:

1
2
3
FROM gitlab/gitlab-runner:latest
WORKDIR /
COPY ./config.toml /etc/gitlab-runner

Gitlab Runner的注册其实也很简单,可以先起一个空镜像容器,运行sudo gitlab-ci-multi-runner register,输入gitlab的链接、Token、Runner的名字、类型等等,就会生成一个如下的配置,这个配置可以保存下来,将其提交到镜像中,这个容器就可以发布到线上了。其中concurrent表示同时运行的任务数,pull_policy等拉取规则可以定制化修改一下。
需要特别留意的是,docker的sock文件是需要挂载进镜像的,以便Gitlab Runner能够运行各类docker镜像来跑Gitlab的CI。
配置参考:

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
concurrent = 6
check_interval = 0

[session_server]
session_timeout = 1800

[[runners]]
name = "CI-name"
url = "https://git.y.cn/"
token = "xxxx"
executor = "docker"
[runners.custom_build_dir]
[runners.docker]
tls_verify = false
image = "golang:lastest"
memory = "4g"
cpus = "4"
privileged = false
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
pull_policy = "if-not-present"
shm_size = 0
[runners.cache]
[runners.cache.s3]
[runners.cache.gcs]
[runners.custom]
run_exec = ""

通过docker命令可以将gitlab runner启动docker run --net=host --rm -v /var/run/docker.sock:/var/run/docker.sock gitlab/gitlab-runner:latest,这时候任何满足规则的commit都会触发Gitlab CI。可以根据不同的语言来配置不同的.gitlab-ci.yml,根据项目的特性来编写一些定制化的检查。