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文件并解析出其中的任务:

  1. 获得一个步骤:test
  2. 获取test步骤下的一个任务:test-job
  3. 在tags指定的标签为docker的Gitlab Runner机器上,拉取node:8.1.2-slim和mongo:3.2两个镜像。
  4. 运行mongo:3.2,运行node:8.1.2-slim并将项目文件挂载成工作目录,接着将mongo:3.2服务以mongo这个名称链接到node:8.1.2-slim容器内以供访问。
  5. 在node:8.1.2-slim容器内执行script中的命令。
  6. 以分支名为key,缓存node_modules文件,下次提交时自动加载。

总体来说,使用还是相对简单的。

但也存在一些场景可能会希望在本地模拟Gitlab CI的运行,比如我们在学习阶段,不确定写法是否可行又不想推到Gitlab形成记录时,或者,需要在本地验证单元测试代码,但希望可以像Gitlab CI一样挂载一个干净的数据库容器时,这些特殊的情况下,本地如果有一套类似的机制就会非常有帮助。

实际上,实现一个没那么复杂的Gitlab CI确实不难,只要我们理解了Gitlab CI的运行机制问题就清晰多了,大致需要实现下面4个功能:

  1. 至少能运行脚本命令。
  2. 可以将脚本命令放入指定的容器中执行。
  3. 可以将依赖的服务链接到指定的容器中。
  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 "---------------------------------------------------------"

    return ${exit_code}
}

clear() {
    # 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
}

# 捕捉退出信号
trap 'clear; exit' SIGINT SIGQUIT

if [[ -z ${image} ]]; then
    run_script_only
else
    run_image
fi

script_exit=$?

if [[ ${script_exit} == "0" ]]; then
    success "Job succeeded"
else
    error "Job failed"
fi

clear

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. 命名规则1:将所有左斜线“/”更换为双下划线“__”
  3. 命名规则2:将所有左斜线“/”更换为中划线“-”

也就是说,mongo:3.2在node容器内访问时,名称是mongo,如果要连接该mongo服务应该使用的URI是:

mongodb://mongo:27017/test-db

执行效果:
Gitlab CI持续集成机制以及在本地模拟Gitlab CI的方案_第1张图片
ps. 看了脚本逻辑的朋友应该了解到,事实上在执行时,也是将本地的项目目录挂载到容器内的,由于本地的项目目录通常会带有依赖(不会提交到代码仓库),所以npm i安装依赖的步骤也是可以省略的。

当然,windows就没办法使用了,后期有空可以将这块逻辑使用golang实现,编译成各个平台的二进制文件自然就都支持了。

感兴趣的朋友可以支持一下个人的github:local-ci。

你可能感兴趣的:(shell,gitlab,ci)