GO 语言基于容器的 CI 实践
什么是 CI,解决什么问题?
CI 即持续集成(Continuous Integration),没有 CI 之前,将新增的代码改动合并到主干是一件危险的事情,通常是定期合并,合并之前进行人工 review & 测试,确认无误后才执行合并。
简单来说 CI 是一个自动化流程,方便我们频繁地合并代码。CI 通常在代码合并之前自动运行,主要步骤包括:构建、代码静态检查、单元测试等。有了 CI,我们合并代码的信心大大增强。
Go 语言的 CI
主要关注以下几点:
-
代码生成:在 CI 流程中重新执行代码生成,有助于发现生成的代码被篡改,或生成代码不一致(生成工具版本差异)诸如此类的问题。重新生成代码后,可以通过
git diff
命令判断代码是否有变动,如果有直接 fail CI。 -
代码格式化:没有经过格式化的代码不允许合入主干。
go fmt
是 Go 工具链内置的代码格式化命令,但是不支持对 import 分类&排序,官方维护的golang.org/x/tools/cmd/goimports
是一个很好的代替。进行代码格式化后,可以通过git diff
命令判断代码是否有变动,如果有直接 fail CI。 -
静态检查:
- BUG 探测:
go vet
是 Go 工具链内置的强力命令,能发现一些常规的 BUG。 - 风格检查:
golang.org/x/lint/golint
可以根据 Go 官方的风格指南《Effective Go》给出“建议”,通常这些“建议”比较苛刻,可以通过 shell 脚本过滤掉不需要的部分。 - goreportcard、golangci-lint、staticcheck 也是可以纳入考虑的选项
- BUG 探测:
-
单元测试:官方的测试框架
go test
已经够用了,要有什么补充的话,考虑 BDD 测试框架 ginkgo。
运行 CI 的环境
通常用 Shell 脚本来实现 CI 流程,但 Shell 脚本的环境依赖是一个深坑,不同操作系统的 Shell 环境差异很大,随手列举几个:
bash
还是sh
(POSIX)bash
的版本差异- BSD Toolchain (MacOS)还是 GNU Toolchain(Linux)
尝试用一套 Shell 脚本去兼容所有的操作系统环境是不可行的,会陷入深不见底的泥潭。
sh + docker 作为 CI 的运行环境
sh (POSIX) 在大多数操作系统下都能找到,docker 也被大多数操作系统支持,使用 sh 作为 docker 容器的启动器,将项目代码挂入到容器内,在容器内运行 bash + GNU Toolchain 执行我们的 CI 脚本,这个方案能解决 99.99% 的环境问题,我们只需要选定容器内 bash + GNU Toolchain 的版本,不再需要考虑 Shell 脚本兼容的问题。
docker volume 的权限问题
将项目代码以 volume 的方式挂入到容器后,在容器内查看文件的 owner (uid:gid) 和容器外是一样的,如果uid对应的 user 或 gid 对应的 group 在容器内不存在,则在容器内直接显示为 uid 或 gid 的数值。也就是说,文件的 owner 与 user 名或 group 名无关,与具体的 uid 和 gid 有关。
docker 容器内默认的用户为 root,如果在 docker 容器内往 volume 写入新文件,那么新文件的 owner 为 (root:root) 对应 uid=0 gid=0,这个 owner(uid=0 gid=0)通常在容器外也是 root,这会造成:如果我们在本地用 sh + docker 执行 CI 脚本,可能会在项目代码目录内产生需要 root 权限才能读写的文件——这是不合理的。可以在 docker run 时通过 --user
选项指定运行 CI 脚本的 uid 和 gid,设置为和容器外一致,就可以避免这个问题。
在 docker 容器内使用 sudo
docker run 时通过 --user
选项指定非 root 用户后,ci 脚本便无法使用需要 root 权限的命令:例如安装软件包。虽然这样的需求在 CI 中并不常见,通常在 CI 的 Docker 镜像构建时已经预装了需要的软件包,但考虑到一套 CI 脚本可能被多个项目复用,可能出现各种各样的需求,如果能在容器内支持 sudo 命令那就更好了。
sudo 的用户白名单本质上是 uid 和 gid 的白名单,在 CI 镜像打包时,我们不能确定未来运行容器时要指定的 uid 和 gid。
这里提供一个变通的方法:
- 构建 CI 镜像时,用命令预先创建 user 和 group 作为占位,例如:ci 用户 ci 组,uid 和 gid 随意。
docker run
执行 sleep 命令 —— 啥也不干。docker exec
(root 用户)修改容器内 ci 用户的 uid 为容器外的 uid(usermod 命令),修改容器内 ci 组的 gid 为容器外的 gid(groupmod命令)。docker exec --user ci:ci
(ci 用户)真正执行 CI 脚本。
CI 脚本和项目代码分离
通用的 CI 脚本应该和项目代码保持松耦合,分开维护,这样通用 CI 脚本也可以被多个项目复用。可以只在项目内放置一个简单的 sh(posix)脚本,调用 curl 获取真正运行的 CI 脚本代码,通过 eval
命令解释执行脚本代码。最终在脚本代码里再运行 docker 容器执行更复杂更高级的 CI 脚本……
Go 缓存
可以通过环境变量 GOMODCACHE 指定 Go Module 的缓存(下载)目录,将其指向 docker volume 中的某个目录,CI 完成时保存这个目录,下次 CI 时装载这个目录(需要配合 CI 系统提供的 cache 机制),这样可以节省每次 CI 下载 Go Module 的时间。
GOCACHE 环境变量对应 Go Build 的缓存目录,可以作类似的缓存处理,加速 go build 命令。