耗时三天,我将Gitlab CI由shell executor平滑迁移Docker环境

本文原载于 https://blog.shifudao.com, 原文链接

前言

在前一篇文章《浅谈如何打造对容器友好的应用》中,我谈到了对于容器化交付的软件,应该如何让软件本身适配容器环境。今天写一篇文章,谈谈我为何要将Gitlab CI迁移到Docker环境,以及我做了哪些努力。

我为什么将CI迁移到Docker环境?

之前我们的Gitlab已经在线上运行了多年,CI流程已经相对成熟。由于CI使用的较早,当时的Gitlab CI也只支持shell executor,所以就一直使用shell executor用到现在,并且工作的很好。

随着项目越来越多,开发节奏逐渐顺畅,shell executor的一些问题也逐步暴露出来了:

  • 难以并行化,资源冲突比较严重。如测试都需要用到PostgreSQL,为了保证测试能正常运行,不得不限定runner并发数只能是1
  • 资源利用不充分。比如某个集成测试需要跑10分钟左右,但是整体对服务器的资源消耗并不大,由于runner并发数是1,其他的CI任务只能排队等待。在提交密集的时候,整个CI流水线的效率成了项目交付的瓶颈
  • 隔离性很差。shell runner直接使用系统的shell,使用系统的运行环境,为了不影响其他正在运行的系统,基本上都得为CI单独部署一台甚至若干台服务器
  • 部署麻烦。如果为了迁移CI服务器,那就是一件折磨人的工作了。我需要在一台干净的服务器上安装上JDK, NodeJS, PostgreSQL, Android SDK, Hugo, …… 并且需要对目录、数据库等做初始化,配置合理的权限等等。这些工作少说也得一天的工作量。如果能将集成环境一起打包带走,那无疑是一件非常棒的体验。

随着Docker的出现,这一类的环境隔离的问题被解决了,因此并行化流水线的问题其实也就迎刃而解了。并且由于环境的隔离性,因此我可以放心的在一些资源利用并不充分的服务器上部署docker executor来进行CI流程,不会干扰正在运行中的系统和应用。这样整个CI的部署工作量少了很多,并且大大提高了CI的效率。

在简单的体验Docker executor之后,我下定决心将CI环境逐步迁移到Docker环境。

我做了哪些工作

在Docker环境中搭建CI环境不是一件轻松的事情,和shell环境直接运行CI相比:

  • 调试麻烦。shell环境可以直接访问操作系统的文件系统,拿到构建日志,甚至很容易重现构建错误并加以调整。但是Docker环境就完全是另一片天地。容器运行完毕即销毁,不做特殊的处理难以拿到运行日志,故障重现也不如shell那么方便。如果运行的是docker in docker环境,调试起来就更麻烦。
  • 集成麻烦。Docker等于每次启动都是一个完全干净的环境,对于测试来说这是最理想的环境,但是对于搭建这样的环境来说就比较繁琐了。我需要事先在脑海中过一遍这个stage需要用到哪些环境,然后对应准备这些环境的Docker镜像,然后在脑海中想清楚它们是怎么组装起来的,以及在容器中我应该如何初始化对应的运行环境。相比之下shell环境虽然第一次部署特别麻烦,工作量很大,但是毕竟是一次性工作,以后运行起来就非常轻松。
  • 相对耗时较长。在调试CI环境中,随时会遇到问题,甚至牵扯到需要更新镜像等问题。Docker环境运行CI往往需要花费比shell中更长的时间,因此加大了调试的时间

添加一个docker executor并不难,不过为了能在docker executor中构建docker镜像,需要用到docker in docker环境,因此在添加runner的时候就需要注意runner参数。

为了有别于shell runner(需要考虑到旧的兼容性,下面会叙述),需要添加一个tagdocker,这个可以通过runner register参数或在gitlab页面直接添加。

自制镜像。实际上最花时间的也是这部分。有些构建的环境比较繁琐,需要多个技术栈,而Docker的镜像大多是单一运行环境的镜像,因此需要自己定制镜像(如ionic,为了构建Android的apk安装包,需要环境有nodejs, java, gradle, android_sdk这么多种运行环境才能构建)

比如我自己就构建了以下几个镜像:

表面上看比shell环境工作量大了很多,但是这些工作量的付出是值得的。因为对于一个团队来说,大部分情况下技术栈都会保持相对的一个稳定,所以构建一次的docker镜像可以在多个项目进行复用,并且很方便的带着构建环境一并迁移,只要有docker环境就可以打包整个CI环境。

此外,为了保证镜像的一些必要更新,还添加了相应的github action定时任务,检测到FROM镜像更新的时候自动build(因为docker hub的的auto rebuild不会因为官方镜像的更新而触发)

事后的一些注意事项

shell executor 兼容性

由于之前大量的项目都跑在shell executor环境中,因此首先需要保证这些旧项目依旧能正常运行,gitlab会同时运行两套CI环境。为了让shell executor成为默认的runner,需要勾选runner配置中的Run untagged jobs,同时,docker runner则不能勾选这个选项。

对于期望使用docker的项目,在.gitlab-ci.yml配置中强制指定tag选择:

job:
  stage: build
  image: myimage
  scripts:
  - echo hello
  tags:
  - docker

这样只需要对新迁移到docker环境构建的项目修改ci脚本即可,而对旧的项目保持兼容,做到平滑迁移。

容量考虑

docker环境构建往往需要更大的磁盘空间,镜像、容器、存储卷、缓存等等,会比shell环境下占用多几倍的存储空间。对于磁盘空间不那么宽裕的服务器来说,需要充分考虑如何利用有限的存储空间。比如:

  • 尽可能复用镜像layer(使用同一版本的alpine做基础镜像等)
  • 通过定时任务清理掉不活跃的容器和存储卷

缓存考虑

为了加快构建速度,通常都需要使用构建缓存(通常是构建工具缓存的依赖包等等)。在shell环境下通常不需要为缓存特殊考虑,因为它直接用的系统文件系统,缓存直接存储在磁盘上。但是对于docker环境则必须考虑。gitlab ci对于缓存有个cache指令控制,这边我放几个供参考:

gradle的缓存

variables:
  GRADLE_USER_HOME: build/gradle_user_home

cache:
  key: my-project
  paths:
  - ${GRADLE_USER_HOME}/caches/
  - ${GRADLE_USER_HOME}/wrapper/

nodejs缓存

variables:
  NPM_CONFIG_CACHE: npm_cache
  NPM_CONFIG_REGISTRY: https://registry.npm.taobao.org
  NPM_CONFIG_ELECTRON_MIRROR: https://npm.taobao.org/mirrors/electron/
  SASS_BINARY_SITE: https://npm.taobao.org/mirrors/node-sass
  NPM_CONFIG_PHANTOMJS_CDNURL: https://npm.taobao.org/mirrors/phantomjs

cache:
  key: my-another-project
  paths:
  - ${NPM_CONFIG_CACHE}

由于gitlab ci的cache只能支持相对路径,不能用绝对路径,因此需要将构建工具产生的缓存改到当前目录下。加上key,并且标定为项目名,是为了尽可能复用缓存。因为一个项目通常依赖相对稳定,指定项目名为key可以尽可能复用同一个缓存(比如fork项目也可以享受到上游缓存的好处)。

后记

通过这次对Gitlab CI的迁移改造,服务器的资源利用率大大提升,也为了未来可以方便的迁移到k8s集群做了铺垫。未来期望能使用阿里云的serverless k8s集群环境做CI,一些细节还在研究中。

而且也充分考虑到了旧的CI环境兼容,做到平滑切换。对于一些旧项目或者不怎么维护的项目保持旧的环境就好,对于一些实在依赖太重,难以迁移到docker中构建的项目,保持旧的环境就好,不需要为了技术而技术,那就失去了意义。

推荐阅读更多精彩内容