GitLab CI/CD 在 Node.js 项目中的实践

近期在按照业务划分项目时,咱们组被分了好多的项目过来,大量的是基于 Node.js 的,也是咱们组持续在使用的语言。

现有流程中的一些问题

在维护多个项目的时候,会暴露出一些问题:javascript

  1. 如何有效的使用 测试用例
  2. 如何有效的使用 ESLint
  3. 部署上线还能再快一些吗html

    1. 使用了 TypeScript 之后带来的额外成本

测试用例

首先是测试用例,最初咱们设计在了 git hooks 里边,在执行 git commit 以前会进行检查,在本地运行测试用例。
这会带来一个时间上的问题,若是是平常开发,这么操做仍是没什么问题的,但若是是线上 bug 修复,执行测试用例的时间依据项目大小可能会持续几分钟。
而为了修复 bug,可能会采用 commit 的时候添加 -n 选项来跳过 hooks ,在修复 bug 时这么作无可厚非,可是即便你们在平常开发中都采用commit -n 的方式来跳过繁琐的测试过程,这个也是没有办法管控的,毕竟是在本地作的这个校验,是否遵循这个规则,全靠你们自觉。 java

因此一段时间后发现,经过这种方式执行测试用例来规避一些风险的做用可能并非颇有效。node

ESLint

而后就是 ESLint,咱们团队基于airbnbESLint 规则自定义了一套更符合团队习惯的规则,咱们会在编辑器中引入插件用来帮助高亮一些错误,以及进行一些自动格式化的操做。
同时咱们也在 git hooks 中添加了对应的处理,也是在 git commit 的时候进行检查,若是不符合规范则不容许提交。
不过这个与测试用例是相同的问题:git

  1. 编辑器是否安装 ESLint 插件无从得知,即便安装插件、是否人肉忽略错误提示也无从得知。
  2. git hooks 能够被绕过

部署上线的方式

以前团队的部署上线是使用shipit周边套件进行部署的。
部署环境强依赖本地,由于须要在本地创建仓库的临时目录,并通过屡次ssh XXX "command"的方式完成 部署 + 上线 的操做。
shipit提供了一个有效的回滚方案,就是在部署后的路径添加多个历史部署版本的记录,回滚时将当前运行的项目目录指向以前的某个版本便可。_不过有一点儿坑的是,很难去选择我要回滚到那个节点,以及保存历史记录须要占用额外的磁盘空间_
不过正由于如此,shipit在部署多台服务器时会遇到一些使人不太舒服的地方。 github

若是是多台新增的服务器,那么能够经过在shipit配置文件中传入多个目标服务器地址来进行批量部署。
可是假设某天须要上线一些小流量(好比四台机器中的一台),由于前边提到的shipit回滚策略,这会致使单台机器与其余三台机器的历史版本时间戳不一致(由于这几台机器不是同一时间上线的)
提到了这个时间戳就另外提一嘴,这个时间戳的生成是基于执行上线操做的那台机器的本地时间,以前有遇到过同事在本地测试代码,将时间调整为了几天前的时间,后时间没有改回正确的时间时进行了一次部署操做,代码出现问题后却发现回滚失败了,缘由是该同事部署的版本时间戳过小,shipit 找不到以前的版本(shipit 能够设置保留历史版本的数量,当时最先的一次时间戳也是大于本次出问题的时间戳的) docker

也就是说,哪怕有一次进行太小流量上线,那么之后就用不了批量上线的功能了 (没有去仔细研究shipit官方文档,不知道会不会有相似--force之类的忽略历史版本的操做) shell

基于上述的状况,咱们的部署上线耗时变为了: (__机器数量__)X(__基于本地网速的仓库克隆、屡次 ssh 操做的耗时总和__)。 P.S. 为了保证仓库的有效性,每次执行 shipit 部署,它都会删除以前的副本,从新克隆 npm

尤为是服务端项目,有时紧急的 bug 修复多是在非工做时间,这意味着可能当时你所处的网络环境并非很稳定。
我曾经晚上接到过同事的微信,让我帮他上线项目,他家的 Wi-Fi 是某博士的,下载项目依赖的时候出了些问题。
还有过使用移动设备开热点的方式进行上线操做,有一次非先后分离的项目上线后,直接就收到了联通的短信:「您本月流量已超出XXX」(当时还在用合约套餐,一月就800M流量)。json

TypeScript

在去年下半年开始,咱们团队就一直在推进 TypeScript 的应用,由于在大型项目中,拥有明确类型的 TypeScript 显然维护性会更高一些。
可是你们都知道的, TypeScript 最终须要编译转换为 JavaScript(也有 tsc 那种的不生成 JS 文件,直接运行,不过这个更多的是在本地开发时使用,线上代码的运行咱们仍是但愿变量越少越好)。

因此以前的上线流程还须要额外的增长一步,编译 TS
并且由于shipit是在本地克隆的仓库并完成部署的,因此这就意味着咱们必需要把生成后的 JS 文件也放入到仓库中,最直观的,从仓库的概览上看着就很丑(50% TS、50% JS),同时这进一步增长了上线的成本。

总结来讲,现有的部署上线流程过于依赖本地环境,由于每一个人的环境不一样,这至关于给部署流程增长了不少不可控因素。

如何解决这些问题

上边咱们所遇到的一些问题,其实能够分为两块:

  1. 有效的约束代码质量
  2. 快速的部署上线

因此咱们就开始寻找解决方案,由于咱们的源码是使用自建的 GitLab 仓库来进行管理的,首先就找到了 GitLab CI/CD
在研究了一番文档之后发现,它可以很好的解决咱们如今遇到的这些问题。

要使用 GitLab CI/CD 是很是简单的,只须要额外的使用一台服务器安装 gitlab-runner,并将要使用 CI/CD 的项目注册到该服务上就能够了。
GitLab 官方文档中有很是详细的安装注册流程:

install | runner
register | runner
group register | repo 注册 Group 项目时的一些操做

上边的注册选择的是注册 group ,也就是整个 GitLab 某个分组下全部的项目。
主要目的是由于咱们这边项目数量太多,单个注册太过繁琐(还要登陆到 runner 服务器去执行命令才可以注册)

安装时须要注意的地方

官网的流程已经很详细了,不过仍是有一些地方能够作一些小提示,避免踩坑
sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner

这是 Linux 版本的安装命令,安装须要 root (管理员) 权限,后边跟的两个参数:

  • --userCI/CD 执行 job (后续全部的流程都是基于 job 的)时所使用的用户名
  • --working-directoryCI/CD 执行时的根目录路径 我的的踩坑经验是将目录设置为一个空间大的磁盘上,由于 CI/CD 会生成大量的文件,尤为是若是使用 CI/CD 进行编译 TS 文件而且将其生成后的 JS 文件缓存;这样的操做会致使 innode 不足产生一些问题
--user 的意思就是 CI/CD 执行使用该用户进行执行,因此若是要编写脚本之类的,建议在该用户登陆的状态下编写,避免出现无权限执行 sudo su gitlab-runner

注册时须要注意的地方

在按照官网的流程执行时,咱们的 tag 是留空的,暂时没有找到什么用途。。
以及 executor 这个比较重要了,由于咱们是从手动部署上线仍是往这边靠拢的,因此稳妥的方式是一步步来,也就是说咱们选择的是 shell ,最常规的一种执行方式,对项目的影响也是比较小的(官网示例给的是 docker

.gitlab-ci.yml 配置文件

上边的环境已经所有装好了,接下来就是须要让 CI/CD 真正的跑起来
runner 以哪一种方式运行,就靠这个配置文件来描述了,按照约定须要将文件放置到 repo 仓库的根路径下。
当该文件存在于仓库中,执行 git push 命令后就会自动按照配置文件中所描述的动做进行执行了。

上边的两个连接里边信息很是完整,包含各类能够配置的选项。

通常来说,配置文件的结构是这样的:

stages:
  - stage1
  - stage2
  - stage3

job 1:
  stage: stage1
  script: echo job1

job 2:
  stage: stage2
  script: echo job2

job 3:
  stage: stage2
  script:
    - echo job3-1
    - echo job3-2

job 4:
  stage: stage3
  script: echo job4

stages 用来声明有效的可被执行的 stage,按照声明的顺序执行。
下边的那些 job XXX 名字不重要,这个名字是在 GitLab CI/CD Pipeline 界面上展现时使用的,重要的是那个 stage 属性,他用来指定当前的这一块 job 隶属于哪一个 stage
script 则是具体执行的脚本内容,若是要执行多行命令,就像job 3那种写法就行了。

若是咱们将上述的 stagejob 之类的换成咱们项目中的一些操做install_dependenciestesteslint之类的,而后将script字段中的值换成相似npx eslint之类的,当你把这个文件推送到远端服务器后,你的项目就已经开始自动运行这些脚本了。
而且能够在Pipelines界面看到每一步执行的状态。

P.S. 默认状况下,上一个 stage 没有执行完时不会执行下一个 stage 的,不过也能够经过额外的配置来修改:
allow failure
when

设置仅在特定的状况下触发 CI/CD

上边的配置文件存在一个问题,由于在配置文件中并无指定哪些分支的提交会触发 CI/CD 流程,因此默认的全部分支上的提交都会触发,这必然不是咱们想要的结果。
CI/CD 的执行会占用系统的资源,若是由于一些开发分支的执行影响到了主干分支的执行,这是一件得不偿失的事情。

因此咱们须要限定哪些分支才会触发这些流程,也就是要用到了配置中的 only 属性。

使用only能够用来设置哪些状况才会触发 CI/CD,通常咱们这边经常使用的就是用来指定分支,这个是要写在具体的 job 上的,也就是大体是这样的操做:

具体的配置文档
job 1:
  stage: stage1
  script: echo job1
  only:
    - master
    - dev

单个的配置是能够这样写的,不过若是 job 的数量变多,这么写就意味着咱们须要在配置文件中大量的重复这几行代码,也不是一个很好看的事情。
因此这里可能会用到一个yaml的语法:

这是一步可选的操做,只是想在配置文件中减小一些重复代码的出现
.access_branch_template: &access_branch
  only:
    - master
    - dev

job 1:
  <<: *access_branch
  stage: stage1
  script: echo job1

job 2:
  <<: *access_branch
  stage: stage2
  script: echo job2

一个相似模版继承的操做,官方文档中也没有提到,这个只是一个减小冗余代码的方式,无关紧要。

缓存必要的文件

由于默认状况下,CI/CD在执行每一步(job)时都会清理一下当前的工做目录,保证工做目录是干净的、不包含一些以前任务留下的数据、文件。
不过这在咱们的 Node.js 项目中就会带来一个问题。
由于咱们的 ESLint、单元测试 都是基于 node_modules 下边的各类依赖来执行的。
而目前的状况就至关于咱们每一步都须要执行npm install,这显然是一个没必要要的浪费。

因此就提到了另外一个配置文件中的选项:cache

用来指定某些文件、文件夹是须要被缓存的,而不能清除:

cache:
  key: ${CI_BUILD_REF_NAME}
  paths:
    - node_modules/

大体是这样的一个操做,CI_BUILD_REF_NAME是一个 CI/CD 提供的环境变量,该变量的内容为执行 CI/CD 时所使用的分支名,经过这种方式让两个分支之间的缓存互不影响。

部署项目

若是基于上边的一些配置,咱们将 单元测试、ESLint 对应的脚本放进去,他就已经可以完成咱们想要的结果了,若是某一步执行出错,那么任务就会停在那里不会继续向后执行。
不过目前来看,后边已经没有多余的任务供咱们执行了,因此是时候将 部署 这一步操做接过来了。

部署的话,咱们目前选择的是经过 rsync 来进行同步多台服务器上的数据,一个比较简单高效的部署方式。

P.S. 部署须要额外的作一件事情,就是创建从 gitlab runner所在机器 gitlab-runner用户到目标部署服务器对应用户下的机器信任关系。
有 N 多种方法能够实现,最简单的就是在 runner机器上执行 ssh-copy-id 将公钥写入到目标机器。
或者能够像我同样,提早将 runner 机器的公钥拿出来,须要与机器创建信任关系时就将这个字符串写入到目标机器的配置文件中。
相似这样的操做: ssh 10.0.0.1 "echo \"XXX\" >> ~/.ssh/authorized_keys"

大体的配置以下:

variables:
  DEPLOY_TO: /home/XXX/repo # 要部署的目标服务器项目路径
deploy:
  stage: deploy
  script:
    - rsync -e "ssh -o StrictHostKeyChecking=no" -arc --exclude-from="./exclude.list" --delete . 10.0.0.1:$DEPLOY_TO
    - ssh 10.0.0.1 "cd $DEPLOY_TO; npm i --only=production"
    - ssh 10.0.0.1 "pm2 start $DEPLOY_TO/pm2/$CI_ENVIRONMENT_NAME.json;"
同时用到的还有 variables,用来提出一些变量,在下边使用。

ssh 10.0.0.1 "pm2 start $DEPLOY_TO/pm2/$CI_ENVIRONMENT_NAME.json;",这行脚本的用途就是重启服务了,咱们使用pm2来管理进程,默认的约定项目路径下的pm2文件夹存放着个个环境启动时所需的参数。

固然了,目前咱们在用的没有这么简单,下边会统一提到

而且在部署的这一步,咱们会有一些额外的处理

这是比较重要的一点,由于咱们可能会更想要对上线的时机有主动权,因此 deploy 的任务并非自动执行的,咱们会将其修改成手动操做还会触发,这用到了另外一个配置参数:

deploy:
  stage: deploy
  script: XXX
  when: manual  # 设置该任务只能经过手动触发的方式运行

固然了,若是不须要,这个移除就行了,好比说咱们在测试环境就没有配置这个选项,仅在线上环境使用了这样的操做

更方便的管理 CI/CD 流程

若是按照上述的配置文件进行编写,实际上已经有了一个可用的、包含完整流程的 CI/CD 操做了。

不过它的维护性并非很高,尤为是若是 CI/CD 被应用在多个项目中,想作出某项改动则意味着全部的项目都须要从新修改配置文件并上传到仓库中才能生效。

因此咱们选择了一个更灵活的方式,最终咱们的 CI/CD 配置文件是大体这样子的(省略了部分不相干的配置):

variables:
  SCRIPTS_STORAGE: /home/gitlab-runner/runner-scripts
  DEPLOY_TO: /home/XXX/repo # 要部署的目标服务器项目路径

stages:
  - install
  - test
  - build
  - deploy_development
  - deploy_production

install_dependencies:
  stage: install
  script: bash $SCRIPTS_STORAGE/install.sh

unit_test:
  stage: test
  script: bash $SCRIPTS_STORAGE/test.sh

eslint:
  stage: test
  script: bash $SCRIPTS_STORAGE/eslint.sh

# 编译 TS 文件
build:
  stage: build
  script: bash $SCRIPTS_STORAGE/build.sh

deploy_development:
  stage: deploy_development
  script: bash $SCRIPTS_STORAGE/deploy.sh 10.0.0.1
  only: dev     # 单独指定生效分支

deploy_production:
  stage: deploy_production
  script: bash $SCRIPTS_STORAGE/deploy.sh 10.0.0.2
  only: master  # 单独指定生效分支

咱们将每一步 CI/CD 所须要执行的脚本都放到了 runner 那台服务器上,在配置文件中只是执行了那个脚本文件。
这样当咱们有什么策略上的调整,好比说 ESLint 规则的变动、部署方式之类的。
这些都彻底与项目之间进行解耦,后续的操做基本都不会让正在使用 CI/CD 的项目从新修改才可以支持(部分须要新增环境变量的导入之类的确实须要项目的支持)。

接入钉钉通知

实际上,当 CI/CD 执行成功或者失败,咱们能够在 Pipeline 页面中看到,也能够设置一些邮件通知,但这些都不是时效性很强的。
鉴于咱们目前在使用钉钉进行工做沟通,因此就研究了一波钉钉机器人。
发现有支持 GitLab 机器人,不过功能并不适用,只能处理一些 issues 之类的, CI/CD 的一些通知是缺失的,因此只好本身基于钉钉的消息模版实现一下了。

由于上边咱们已经将各个步骤的操做封装了起来,因此这个修改对同事们是无感知的,咱们只须要修改对应的脚本文件,添加钉钉的相关操做便可完成,封装了一个简单的函数:

function sendDingText() {
  local text="$1"

  curl -X POST "$DINGTALK_HOOKS_URL" \
  -H 'Content-Type: application/json' \
  -d '{
    "msgtype": "text",
    "text": {
        "content": "'"$text"'"
    }
  }'
}

# 具体发送时传入的参数
sendDingText "proj: $CI_PROJECT_NAME[$CI_JOB_NAME]\nenv: $CI_ENVIRONMENT_NAME\ndeploy success\n$CI_PIPELINE_URL\ncreated by: $GITLAB_USER_NAME\nmessage: $CI_COMMIT_MESSAGE"

# 某些 case 失败的状况下 是否须要更多的信息就看本身自定义咯
sendDingText "error: $CI_PROJECT_NAME[$CI_JOB_NAME]\nenv: $CI_ENVIRONMENT_NAME"

上述用到的环境变量,除了DINGTALK_HOOKS_URL是咱们自定义的机器人通知地址之外,其余的变量都是有 GitLab runenr所提供的。

各类变量能够从这里找到:predefined variables

回滚处理

聊完了正常的流程,那么也该提一下出问题时候的操做了。
人非圣贤孰能无过,颇有可能某次上线一些没有考虑到的地方就会致使服务出现异常,这时候首要任务就是让用户还能够照常访问,因此咱们会选择回滚到上一个有效的版本去。
在项目中的 Pipeline 页面 或者 Enviroment 页面(这个须要在配置文件中某些 job 中手动添加这个属性,通常会写在 deploy 的那一步去),能够在页面上选择想要回滚的节点,而后从新执行 CI/CD 任务,便可完成回滚。

不过这在 TypeScript 项目中会有一些问题,由于咱们回滚通常来说是从新执行上一个版本 CI/CD 中的 deploy 任务,在 TS 项目中,咱们在 runner 中缓存了 TS 转换 JS 以后的 dist 文件夹,而且部署的时候也是直接将该文件夹推送到服务器的(TS项目的源码就没有再往服务器上推过了)。

而若是咱们直接点击 retry 就会带来一个问题,由于咱们的 dist 文件夹是缓存的,而 deploy 并不会管这种事儿,他只会把对应的要推送的文件发送到服务器上,并重启服务。

而实际上 dist 仍是最后一次(也就是出错的那次)编译出来的 JS 文件,因此解决这个问题有两种方法:

  1. deploy 以前执行一下 build
  2. deploy 的时候进行判断

第一个方案确定是不可行的,由于严重依赖于操做上线的人是否知道有这个流程。
因此咱们主要是经过第二种方案来解决这个问题。

咱们须要让脚本在执行的时候知道,dist 文件夹里边的内容是否是本身想要的。
因此就须要有一个 __标识__,而作这个标识最简单有效唾手可得的就是,git commit id
每个 commit 都会有一个惟一的标识符号,并且咱们的 CI/CD 执行也是依靠于新代码的提交(也就意味着必定有 commit)。
因此咱们在 build 环节将当前的commit id也缓存了下来:

git rev-parse --short HEAD > git_version

同时在 deploy 脚本中添加额外的判断逻辑:

currentVersion=`git rev-parse --short HEAD`
tagVersion=`touch git_version; cat git_version`

if [ "$currentVersion" = "$tagVersion" ]
then
    echo "git version match"
else
    echo "git version not match, rebuild dist"
    bash ~/runner-scripts/build.sh  # 额外的执行 build 脚本
fi

这样一来,就避免了回滚时仍是部署了错误代码的风险。

关于为何不将 build 这一步操做与 deploy 合并的缘由是这样的:
由于咱们会有不少台机器,同时 job 会写不少个,相似 deploy_1deploy_2deploy_all,若是咱们将 build 的这一步放到 deploy
那就意味着咱们每次 deploy,即便是一次部署,但由于咱们选择一台台机器单独操做,它也会从新生成屡次,这也会带来额外的时间成本

hot fix 的处理

CI/CD 运行了一段时间后,咱们发现偶尔解决线上 bug 仍是会比较慢,由于咱们提交代码后要等待完整的 CI/CD 流程走完。
因此在研究后咱们决定,针对某些特定状况hot fix,咱们须要跳过ESLint、单元测试这些流程,快速的修复代码并完成上线。

CI/CD 提供了针对某些 Tag 能够进行不一样的操做,不过我并不想这么搞了,缘由有两点:

  1. 这须要修改配置文件(全部项目)
  2. 这须要开发人员熟悉对应的规则(打 Tag

因此咱们采用了另外一种取巧的方式来实现,由于咱们的分支都是只接收Merge Request那种方式上线的,因此他们的commit title其实是固定的:Merge branch 'XXX'
同时 CI/CD 会有环境变量告诉咱们当前执行 CI/CDcommit message
咱们经过匹配这个字符串来检查是否符合某种规则来决定是否跳过这些job

function checkHotFix() {
  local count=`echo $CI_COMMIT_TITLE | grep -E "^Merge branch '(hot)?fix/\w+" | wc -l`

  if [ $count -eq 0 ]
  then
    return 0
  else
    return 1
  fi
}

# 使用方法

checkHotFix

if [ $? -eq 0 ]
then
  echo "start eslint"
  npx eslint --ext .js,.ts .
else
  # 跳过该步骤
  echo "match hotfix, ignore eslint"
fi

这样可以保证若是咱们的分支名为 hotfix/XXX 或者 fix/XXX 在进行代码合并时, CI/CD 会跳过多余的代码检查,直接进行部署上线。 没有跳过安装依赖的那一步,由于 TS 编译仍是须要这些工具的

小结

目前团队已经有超过一半的项目接入了 CI/CD 流程,为了方便同事接入(主要是编辑 .gitlab-ci.yml 文件),咱们还提供了一个脚手架用于快速生成配置文件(包括自动创建机器之间的信任关系)。

相较以前,部署的速度明显的有提高,而且再也不对本地网络有各类依赖,只要是可以将代码 push 到远程仓库中,后续的事情就和本身没有什么关系了,而且能够方便的进行小流量上线(部署单台验证有效性)。

以及在回滚方面则是更灵活了一些,可在多个版本之间快速切换,而且经过界面的方式,操做起来也更加直观。

最终能够说,若是没有 CI/CD,实际上开发模式也是能够忍受的,不过当使用了 CI/CD 之后,再去使用以前的部署方式,则会明显的感受到不温馨。(没有对比,就没有伤害😂)

完整的流程描述

  1. 安装依赖
  2. 代码质量检查

    1. ESLint 检查

      1. 检查是否为 hotfix 分支,若是是则跳过本流程
    2. 单元测试

      1. 检查是否为 hotfix 分支,若是是则跳过本流程
  3. 编译 TS 文件
  4. 部署、上线

    1. 判断当前缓存 dist 目录是否为有效的文件夹,若是不是则从新执行第三步编译 TS 文件
    2. 上线完毕后发送钉钉通知

后续要作的

接入 CI/CD 只是第一步,将部署上线流程统一后,能够更方便的作一些其余的事情。
好比说在程序上线后能够验证一下接口的有效性,若是发现有错误则自动回滚版本,从新部署。
或者说接入 docker, 这些调整在必定程度上对项目维护者都是透明的。

参考资料

相关文章
相关标签/搜索