Go的CICD、Module、构建、测试

前言

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

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

基本概念

CI的全称是Continuous Integration,表示持续集成。
在常见的开发场景中,开发人员可能会频繁地提交自己的代码,这些提交的代码,先通过提交Merge Request,再经过Code Review合并到主分支,最终经过测试、灰度,然后上线。
CI主要作用是保障Code Review之前的代码,通过一系列自动化测试验证代码的质量和Bug,提升代码质量,并根据测试结果,向开发人员或团队进行预警。
CI同时也会检查Merge之后的代码是否正常,符合一切预期。

CI的好处

减少Review代码同学的压力与工作量

假若有一个同学经常写bug,他的代码每次都能测出一大堆问题,他自己也没有意识主动去检查自己的代码能不能跑起来,全靠你帮他发现,有些变量名写错了又不好发现。你每次提醒他,他都能快速意识到自己的问题,但下次还是可能会遇到。你作为负责Review代码的同事,面对大量的改动,是否有些担心?

制定并维护团队代码规范

你是一个技术管理,你为团队制定了一系列规范,如上线前自测、各个模块要有相应的设计与说明文档、数据库的DDL变更要及时维护在代码库中、代码的测试用例覆盖率要达到50%。经过一段时间,你发现这些问题并没有很好的改善,如何能够让一起定下规范严格执行呢?

快速迭代,小步快跑

在开发过程中,当你需要改到一些你不是非常熟悉的模块,总担心自己会不会改错一些逻辑,当初写这个逻辑的同学可能也不在了,那么如何确保自己修改的内容有效呢?产品经理又催得紧要求今晚上线?

持续改进与提升

在开发中,你写了一个模块,当时是有较高的QPS,有正常的结果返回,结果随着项目的迭代,你发现该模块变得越来越慢。你发现当初大家约定的Code Style,慢慢也开始变样了,一个方法里面有多种实现,怎么办?

怎么做

CI可以代码库设置了一个较高的标准,所有不符合规范,有语法错误的代码,都可以直接拒绝合并至主分支。
根据不同语言,可以检查语法错误、变量定义但未使用、重复引入package、未格式化、scheme定义不正确、测试用例等等。

  • 代码提交之后,将会根据分支进行不同的自动化处理:
    • 代码常规检查(Code Style、代码底层实现Code Lint等,检测所有语法错误、告警、未使用代码等)
    • 自动化单元测试(输出结果与测试覆盖率,对代码逻辑检测)
    • 复杂用例覆盖测试(构造MySQL、Redis等数据,对业务逻辑检测)
  • 代码Review阶段,由于上一阶段已经对代码质量做了基本检查,相关reviewer只需要review逻辑和实现方案即可。
  • 合并待发布阶段,会自动检查是否冲突,没有冲突自动合并,有冲突则通知相关同学人工介入进行git rebase等处理。
  • 构建阶段,CI可以根据Dockerfile自动构建并将分支推送至镜像仓库。
  • 部署阶段,发布到测试环境、预发环境、线上环境,灰度上线等。

根据上述的过程,我们其实已经将各类简单的脚本变成了一系列自动化执行的流程,也带来了标准化,可以直接一键应用到所有的项目。这就是持续集成,在我们背后无声无息的进行着,减少了大量极其浪费资源的人工执行,对研发团队来说,解决了大量的资源,对研发同学来说,可以减少review代码、测试的时间,也可以更加自信上线,快速迭代,更加专注产品、业务打磨。

在研发流程中,每个人只需要关注自己开发的功能,并编写测试用例来确保自己的功能(逻辑)不受其他人提交的代码影响,同时确保自己没有影响到别人功能(逻辑),其他代码质量、逻辑检验由CI完成。

Gitlab Runner

Gitlab其实是能够关联一些很好的工具,比如一些需求管理、Wiki等等。其中Gitlab CI可以做一些代码检查、跑测试用例、自动构建等等工作。我们使用的代码仓库也是Gitlab,那么使用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,根据项目的特性来编写一些定制化的检查。如if-not-present等配置可以在docker不存在的时候,才拉取相关分支。

镜像构建

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
FROM golang:1.13 as build-env
WORKDIR /go/src/y.cn/some
COPY go.mod ./
RUN GO111MODULE=on go mod download
FROM golang:1.13
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/ ./