Gitlab CI持续集成机制以及在本地模拟Gitlab CI的方案
听过持续集成的人应该都知道Jenkins的鼎鼎大名,如果我们代码仓库选择了Gitlab,那可能还会听说另一种相对小巧的持续集成方案:Gitlab CI,这个从Gitlab 8.0开始就已经集成的工具正在变得越来越强大,如今已经可以在大多数场景下取代Jenkins了。
使用Gitlab CI非常简单,在项目的根目录下新建一个".gitlab-ci.yml"文件,并将规则写入即可,例如一个执行nodeJs单元测试的步骤可以这么写:
# 启用的步骤
# 每个stage表现为gitlab/pipeline页面对应构建任务stages列的一个按钮
stages:
- test
# 定义指定步骤的一个子任务,test-job为该子任务的名称,可以随意起名
test-job:
# 指明当前子任务属于哪个步骤
stage: test
# 当前子任务的脚本将在指定docker容器中运行
# 实际上Gitlab CI会运行这个镜像,并将项目文件整个挂载到容器的工作目录下
image: node:8.1.2-slim
# 需要依赖的服务,Gitlab CI会运行这些服务
# 并以一定的规则将其链接到image指定的容器中
# 也就是说,image内的容器可以通过一个名称访问services中的容器
services:
- mongo:3.2
# 指明执行这个任务的机器
# Gitlab CI需要将任务运行在Gitlab Runner中
tags:
- docker
# 将要执行的脚本
# 如果指明了image,则脚本会在image对应的容器中执行
# 否则会直接在Gitlab Runner中运行
script:
- npm i
- npm test
# 文件缓存策略
cache:
key: "$CI_BUILD_REF_NAME"
paths:
- node_modules/
当我们提交代码到Gitlab时,Gitlab首先检测到.gitlab-ci.yml文件并解析出其中的任务:
- 获得一个步骤:test
- 获取test步骤下的一个任务:test-job
- 在tags指定的标签为docker的Gitlab Runner机器上,拉取node:8.1.2-slim和mongo:3.2两个镜像。
- 运行mongo:3.2,运行node:8.1.2-slim并将项目文件挂载成工作目录,接着将mongo:3.2服务以mongo这个名称链接到node:8.1.2-slim容器内以供访问。
- 在node:8.1.2-slim容器内执行script中的命令。
- 以分支名为key,缓存node_modules文件,下次提交时自动加载。
总体来说,使用还是相对简单的。
但也存在一些场景可能会希望在本地模拟Gitlab CI的运行,比如我们在学习阶段,不确定写法是否可行又不想推到Gitlab形成记录时,或者,需要在本地验证单元测试代码,但希望可以像Gitlab CI一样挂载一个干净的数据库容器时,这些特殊的情况下,本地如果有一套类似的机制就会非常有帮助。
实际上,实现一个没那么复杂的Gitlab CI确实不难,只要我们理解了Gitlab CI的运行机制问题就清晰多了,大致需要实现下面4个功能:
- 至少能运行脚本命令。
- 可以将脚本命令放入指定的容器中执行。
- 可以将依赖的服务链接到指定的容器中。
- 最好在任务结束时能自行清理。
在本地实现的话,优选方案可以是shell脚本(所以windows用户请飘过 ~):
#!/bin/sh
network="local_ci"
services=()
image=""
script=""
# The container started by this script automatically closes over time
# to prevent the last cleanup from becoming system garbage when it is not executed
timeout=600
# -S specifies the script to run, required
# -i specify mirror name, optional
# -s add a service, optional
# -n specify the docker network name to create, optional
# -t specifies the duration of the image timeout cleanup, optional
while getopts 's:i:n:S:t' OPT; do
case $OPT in
s)
services=(${services[@]} $OPTARG)
;;
i)
image=$OPTARG
;;
n)
network=$OPTARG
;;
S)
script=$OPTARG
;;
t)
timeout=$OPTARG
;;
esac
done
if [ -z $script ]; then
echo "script is required"
exit 1
fi
info() {
echo "\033[32m$1\033[0m \033[35m$2\033[0m" $3 ...
}
success() {
echo "\033[32m\n$1\n\033[0m"
}
error() {
echo "\033[31m\n$1\n\033[0m"
}
# runs in a local shell
run_script_only() {
info "\nrunning" "script" $script
echo "\n---------------------------------------------------------"
bash -v $script
exit_code=$?
echo "---------------------------------------------------------"
return $exit_code
}
# runs in the specified container
run_image() {
info "using" "executor with image" $image
stop_ids=()
# create docker network
if ! docker network inspect $network &>/dev/null; then
info "creating" "docker network" $network
docker network create $network &>/dev/null
fi
success "created docker network success"
# start the services
for service in ${services[@]}; do
# remove the content after the colon
service_nv=${service%\:*}
# remove the port
service_np=${service_nv/\:*\//\/}
# name it after rule 1
name1=${service_np//\//__}
# name it after rule 2
name2=${service_np//\//-}
info "pulling" "docker image" $service
docker pull $service &>/dev/null
info "staring" "service image" $service
cid=$(docker run -d --rm --stop-timeout $timeout $service)
# ddd to the list of container ids to be closed
stop_ids=(${stop_ids[@]} $cid)
docker network connect --alias $name1 --alias $name2 $network $cid
done
info "pulling" "docker image" $image
docker pull $image &>/dev/null
info "staring" "image" $image
image_id=$(docker run -itd --rm --stop-timeout $timeout -v `pwd`:`pwd` -w `pwd` $image top)
stop_ids=(${stop_ids[@]} $image_id)
docker network connect $network $image_id
info "\nrunning" "script" $script
echo "\n---------------------------------------------------------"
docker exec -it $image_id bash -v $script
exit_code=$?
echo "---------------------------------------------------------"
# clean up the container
docker stop ${stop_ids[@]} &>/dev/null
if docker network inspect $network | grep '"Containers": {}' &>/dev/null; then
docker network rm $network &>/dev/null
fi
return $exit_code
}
if [ -z $image ]; then
run_script_only
else
run_image
fi
script_exit=$?
if [ $script_exit == "0" ]; then
success "Job succeeded"
exit 0
fi
error "Job failed"
exit $script_exit
遵循国际开源惯例,代码中的注释统一借用google tanslate英文化,感兴趣的可以拷贝出来反翻译一下,正好测测谷歌翻译捞不捞。
将上面的代码保存成local-ci.sh文件,并赋予执行权限:
chmod +x local-ci.sh
然后复制到项目根目录下,同时新建一个脚本文件:local-script.sh,在此脚本中编写命令,例如将上面Gitlab CI任务的例子转换下来就是:
npm i
npm test
整体执行是这样的:
./local-ci.sh -i node:8.1.2-slim -s mongo:3.2 -S ./local-script.sh
使用i指定image,s指定service,允许多个,S则指定脚本文件。
服务链接到容器中的命名规则同Gitlab CI:
- 剥离冒号“:”之后的内容
- 命名规则1:将所有左斜线“/”更换为双下划线“__”
- 命名规则2:将所有左斜线“/”更换为中划线“-”
也就是说,mongo:3.2在node容器内访问时,名称是mongo,如果要连接该mongo服务应该使用的URI是:
mongodb://mongo:27017/test-db
执行效果:
ps. 看了脚本逻辑的朋友应该了解到,事实上在执行时,也是将本地的项目目录挂载到容器内的,由于本地的项目目录通常会带有依赖(不会提交到代码仓库),所以npm i安装依赖的步骤也是可以省略的。
当然,windows就没办法使用了,后期有空可以将这块逻辑使用golang实现,编译成各个平台的二进制文件自然就都支持了。
感兴趣的朋友可以支持一下个人的github:local-ci。