一、背景
最近开发了个大前端项目,用前端微服务来实现,由于涉及到十几个子项目,如果每个项目都手动或者脚本部署,人力成本和出错的概率就会很高,所以需要接入Gitlab-CI/CD,方便统一部署和后续灰度系统的接入。
实现的过程中需要考虑的问题:
- 涉及到十几个子项目,后面会持续扩展,所以需要统一的发布方式
- 需要考虑后续接入灰度系统的问题
- 项目支持中英文,所以除了语言文件和一些特殊的文件中,其他代码中不允许有中文
- 部署的虚拟机需要经过跳板机,Gitlab Runner需要可以直接将文件发布到虚拟机
- 一些变量不适合共享给所有用户,需要隐藏
这里以前端服务为例子,但实现方案也适用于后端服务
二、实践概述
1、原理图
- 统一的gitlab-config配置文件,前端服务通过include引入
- 当提交代码触发CI,则会在Gitlab Runner中执行CI配置文件
- 如果涉及到定制的镜像,会将镜像推送到镜像仓库
- Runner执行多个任务,在最后部署的任务中,将代码推送到虚拟机
- 如果存在跳板机,要提前处理密钥,获取访问权限
2、前期准备
- 1、需要Gitlab版本>=8.x,支持
CI/CD - 2、最好用私有仓库来串联和部署,也可以使用公开的镜像
- 3、Gitlab Runner默认已经注册好了,可以供这些服务使用,不明白可以看我之前的文章Gitlab CI/CD执行流程和Gitlab Runner安装和注册
- 4、Gitlab Runner必须和虚拟机、跳板机网络相通
- 5、准备一个Public权限的Gitlab仓库
gitlab-config,用来存放公用的CI配置 - 6、在Gitlab中新建Group,命名
App,App下有多个项目包括:sms、account、services
3、一些必要的CI配置说明
| 字段 | 说明 |
|---|---|
| include | 通过remote引入远程仓库的配置文件 |
| variables | 定义全局变量或局部变量供脚本使用 |
| extends | 引用公用的属性和方法 |
| dependencies | 执行当前Job依赖的任务 |
| tags | 指定执行Jobs的Runner |
| only | 指定CI触发条件 |
| cache | 缓存文件,比如node_modules |
| artifacts | 上传指定文件到Gitlab,供所有的Job使用 |
三、gitlab-config项目说明
该项目用来统一管理其他项目使用的CI配置文件、提前构建CI要使用的镜像、CI要执行的逻辑等,供其他项目引入
1、配置文件
新建gitlab仓库gitlab-config,项目结构为:
├── README.md
├── console_ci_Dockerfile
├── .gitlab-ci-app-sshkey-add.yml
├── .gitlab-ci-app.yml
├── .gitlab-ci.yml
├── console_deploy_Dockerfile
└── scripts
├── build.sh
├── deploy.sh
├── npm_install.js
├── check-prettier.sh
├── check-unittest.sh
├── check-eslint.sh
└── run_cmd.js
- scripts目录用来存放执行的脚本,例如:
- 需要接入灰度系统,就可以将打包后的文件信息写入灰度系统;
- 单元测试,代码检查,eslint插件等检查逻辑
- 代码统计和分析
- .gitlab-ci-app.yml 用来保存子应用需要引入的ci配置文件
- 在子项目或需要CI部署的项目中,通过
include字段来引入
- 在子项目或需要CI部署的项目中,通过
- .gitlab-ci-sshkey.yml 用来引入一些其他yml文件中使用的公共部分,这里为写入SSHKey
- 主要通过
extends字段引用 - 如果你的生产环境像我一样要通过跳板机间接登录,统一在Runner中写入SSHKey作为一个公共部分
- 主要通过
- .gitlab-ci.yml 用来为当前项目构建镜像
- Runner中需要不同的镜像,那么我们就可以在提交该仓库代码时,构建镜像,上传镜像仓库
- app_ci_Dockerfile 用来构建代码检测阶段的镜像
- app_deploy_Dockerfile 用来构建代码部署阶段的镜像
2、构建Runer中使用的镜像
上面我们通过Dockerfile构建了两个在Runner中使用的镜像,一个用来做代码检测,一个用来做部署
app_ci_Dockerfile
FROM node:16-alpine
WORKDIR /data
ENV NODE_ENV development
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
apk add \
git \
bash \
openssh-client
RUN npm install axios yargs@16.2.0 -g
COPY scripts ./scripts
RUN chmod +x ./scripts/*
CMD /bin/bash
其中一个Dockerfile,基于Node镜像,我们要安装一些库将scripts脚本打包到镜像中,方便后面使用,具体可以根据自己的需求定制,另一个也大同小异。
.gitlab-ci.yml作为本项目提交触发的CI配置文件,实现了构建镜像,同步到私有镜像仓库,这里我们构建的镜像命名app/app-cli,拉取方式为:
docker pull hub.xxx.com/app/app-cli:latest
四、代码检查
.gitlab-ci-app.yml,如下我们定义了一个通用的ci配置文件
- 镜像是我们上一步构建的
- 定义了全局的工作路径
/data - 定义了CI要跑的几个流程:代码检查、源代码的打包、部署
include:
- remote: https://git.xxx.com/gitlab-config/.gitlab-ci-app-sshkey-add.yml
image: hub.xxx.com/app/app-cli:latest
variables:
ROOT_DIR: /data
stages:
- check
- build
- deploy
## 代码检查
code-check:
...
# 代码构建
node-build:
...
# 部署预发
pre-node-deploy:
...
code-check 阶段,我们可以做的有很多,包括:
- 检查代码是否Prettier
- 执行单元测试
- 检查代码是否通过ESLint检测
- 检查代码的特殊要求,比如除了某个文件,其他的文件中不允许出现中文
在这里进行ESLint检测
我们可以通过Shell实现这些能力,但Node来实现更灵活,我们在scripts目录下通过Node实现各个模块。
下面我们来实际实现下这些能力,所有实现代码放在scripts目录下:
1、检查代码是否Prettier
check-prettier.sh
#!/bin/bash
# set -e bash如果任何语句的执行结果不是true,就退出
set -e
ci_project_dir=$1
# 进入检测项目所在的目录
cd ci_project_dir
yarn add --exact global prettier
# 检测规则:可以自己定义、读取当前项目下的规则、根据项目名称配置不同的规则
mkdir .tmp && touch .tmp/.prettierrc.json
echo '{"trailingComma":"es5","tabWidth":4,"semi":false,"singleQuote":true}}' >> .tmp/.prettierrc.json
# 指定检测的规范和检测的文件路径
prettier --config ./.tmp/.prettierrc.json --check "./src/**/*.js"
如果检测成功,那么将会输出,并且返回code=0,ci继续执行:
Checking formatting...
All matched files use Prettier code style!
如果pretter出现错误会返回1或2,则终止当前Job
2、执行单元测试
check-unittest.sh
#!/bin/bash
set -e
ci_project_dir=$1
# 进入检测项目所在的目录
cd ci_project_dir
# 执行项目中配置好的单元测试指令
# 这里要提前判断下该项目是否配置了yarn test单元测试
yarn install && yarn test
# 执行完后可以收集单元测试的结果,并上传后端服务
3、检查代码是否通过ESLint检测
check-eslint.sh
#!/bin/bash
set -e
ci_project_dir=$1
# 进入检测项目所在的目录
cd ci_project_dir
yarn config set registry http://registry.npm.taobao.org/
yarn add --dev eslint typescript @babel/preset-env @babel/preset-react @babel/preset-typescript @babel/core @babel/plugin-transform-runtime @typescript-eslint/parser @babel/plugin-proposal-class-properties @babel/plugin-proposal-optional-chaining @babel/plugin-syntax-dynamic-import
mkdir .tmp && touch .tmp/.eslintrc
echo '{"parser":"@typescript-eslint/parser","parserOptions":{"ecmaVersion":7,"sourceType":"module"}}' >> .tmp/.eslintrc
./node_modules/.bin/eslint "src/**/*.tsx" "src/**/*.js" -c .tmp/.eslintrc --no-error-on-unmatched-pattern --no-eslintrc --fix-dry-run
4、检查代码不允许出现中文
check-chinese.sh
#!/bin/bash
root_dir=$1
ci_project_name=$2
ci_project_dir=$3
node -v
echo "CI_PROJECT_NAME: $ci_project_name"
export NODE_ENV=development
echo '---------- check locale hard code ------------'
ls $ci_project_dir
node $root_dir/scripts/checkCode/index.js $ci_project_dir $ci_project_name
if [ $? -ne 0 ]; then
echo "check locale hard code error"
exit 1
fi
scripts/checkCode/index.js,检查硬编码的脚本
const glob = require('glob');
const dir = process.argv[2];
const product = process.argv[3];
// 扫描文件
const getFiles = (path, ignore) => {
return new Promise((resolve, reject) => {
glob(
path,
{
nodir: true,
ignore
},
(err, files) => {
if (err) {
reject(err);
} else {
resolve(files);
}
}
);
});
};
// 检查文件
function checkCode(dir, product) {
const zhGen = /[\u4e00-\u9fa5]+/g; //匹配中文
let dirPath = path.resolve(dir, `**/**`);
return new Promise((resolve, reject) => {
try {
// 一些需要忽略的检查文件
if (rules.ignoreProducts.indexOf(product) >= 0) {
console.log('ignored.')
resolve();
return;
}
// 检查通过的文件,在检查之前,要根据文件类型,删除代码中的注视文件,这里忽略了
getFiles(dirPath, rules.ignoreFiles).then(()=>{
resolve();
}, (e)=>{
reject(e);
});
} catch (e) {
reject(e);
}
});
};
checkCode(dir, product).then(()=>{
console.log('done.');
}).catch((e)=>{
console.error(e);
process.exit(1);
});
5、检查代码Jobs
该阶段执行的内容也可以拆分成多个任务,像Prettier的任务可以允许失败,只需要配置下
allow_failure: true,不会阻塞CI的执行
# 代码检查,
code-check-prettier:
stage: check
script:
- $ROOT_DIR/scripts/check-prettier.sh $CI_PROJECT_DIR
tags:
- runner-tag
allow_failure: true
only:
refs:
- feature/dev
## 必须要检查的内容
code-check-required:
stage: check
script:
- echo "----------testunit-----------"
- $ROOT_DIR/scripts/check-unittest.sh $CI_PROJECT_DIR
- echo "----------eslint-----------"
- $ROOT_DIR/scripts/check-eslint.sh $CI_PROJECT_DIR
- echo "----------check code-----------"
- $ROOT_DIR/scripts/check-chinese.sh $ROOT_DIR $CI_PROJECT_NAME $CI_PROJECT_DIR
tags:
- runner-tag
only:
refs:
- feature/dev
五、构建源代码
1、构建脚本
build.sh
#!/bin/bash
root_dir=$1
ci_project_dir=$2
echo '--------------- install ----------------'
if [ ! -d "./node_modules" ] || [ ! "$(ls -A './node_modules')" ]; then
cd $ci_project_dir
node $root_dir/scripts/npm_install.js
if [ $? -ne 0 ]; then
echo "npm_install error"
exit 1
fi
fi
npm_install.js
这里我们使用了runCmd来执行命令,其实在shell里面就可以处理,这里只是举个例子,一些复杂的逻辑,需要用Node.js实现,执行命令需要通过脚本实现
const runCmd = require('./run_cmd.js')
async function run(){
try {
let cmd = `yarn config set registry https://registry.npmmirror.com && yarn install && yarn build:prod`
await runCmd(cmd)
}catch(e){
console.error(e)
process.exit(1)
}
}
run();
runCmd.js,在node实现的过程中,需要执行一些指令,统一用该函数执行
const { exec } = require('child_process');
let execOptions = {
maxBuffer: 1024 * 1024 * 1024,
timeout: 1000 * 60 * 10
};
module.exports = function runCmd(cmd){
return new Promise(async (resolve, reject)=> {
let childProcess = exec(cmd, execOptions, (error)=>{
if (error) {
reject(error);
return;
}
resolve();
})
childProcess.on('exit', (code)=>{
if(code !== 0){
reject('final exit code is ' + code);
}
});
childProcess.stdout.on('data', function(data) {
console.log("stdout: ", data + '');
});
childProcess.stderr.on('data', function(data) {
console.log("stderr: " + data + '');
});
})
}
2、构建Job
dist、version是我们要发布的代码,在这一步我们可以接入灰度系统,通过管理配置文件,根据用户灰度范围来确定用户当前的版本。后续在补充这一个章节
# 打包
node-build:
stage: build
script:
- >
TZ='Asia/Shanghai'; export TZ;
export VERSION=`date +%Y%m%d%H%M%S`
- $ROOT_DIR/scripts/build.sh $ROOT_DIR $CI_PROJECT_DIR || exit "$?";
- cd $CI_PROJECT_DIR && pwd && ls -al
- echo $VERSION >> $CI_PROJECT_DIR/VERSION
# 缓存依赖
cache:
key:
files:
- package-lock.json
- package.json
prefix: $CI_PROJECT_NAME-$CI_JOB_NAME
paths:
- node_modules/
tags:
- tag-runner
only:
refs:
- feature/test03
# 上传打包后的文件和版本文件,供所有的job使用,CI目前只能缓存$CI_PROJECT_DIR下的文件,如果要在两个 job 之间传递 artifacts,你必须设置dependencies。
artifacts:
paths:
- dist/
- VERSION
六、部署服务器
1、获取机器权限
一般情况下访问生产环境,需要经过跳板机,自动发布就要让Gitlab-Runner直接可以访问虚拟机。
实现方法:
- 将跳板机的私钥写入Gitlab-Runner,然后将跳板机的公钥放在要部署的虚拟机。
- 如果没有跳板机,可以直接将Runner的公钥放入目标主机
- 注意:不能直接将跳板机的密钥写在gitlab-config,容易泄露。要将密钥作为变量,配置在Gitlab的后台中,在Gitlab中通过变量读取
- 这里我们设置Key的变量为
SSH_PRIVATE_KEY_133,默认机器读取id_ras,这里我们更换了名字,,并指定使用id_rsa_133
gitlab-ci-app-sshkey-add.yml
.sshkey-add:
before_script:
- echo '---------- sshkey add ------------'
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- echo -e "Host *\n" >> ~/.ssh/config
- echo -e "\tStrictHostKeyChecking no\n" >> ~/.ssh/config
- echo -e "Host *\n" >> ~/.ssh/config
- echo -e "\tIdentityFile ~/.ssh/id_rsa_133\n" >> ~/.ssh/config
- echo -e "\tPubkeyAcceptedKeyTypes=+ssh-rsa\n" >> ~/.ssh/config
- echo -e "\tHostKeyAlgorithms +ssh-rsa\n" >> ~/.ssh/config
- echo "$SSH_PRIVATE_KEY_133" | tr -d '\r' >> ~/.ssh/id_rsa_133
- chmod 600 ~/.ssh/id_rsa_133
tags:
- tag-runner
2、部署
$PRE变量为CI后台配置的变量,为要发布的虚拟机IP,这样可以不用暴露when: manual是因为部署发布需要格外注意,手动发布能及时提醒发布人注意回归extends可以将公用的.sshkey-add引入Job
# 部署预发
pre-node-deploy:
extends:
- .sshkey-add
stage: deploy
variables:
DEPLOY_ENV: $PRE
STARK_ENV: 'pre'
scripts:
- pwd && ls -al
- cd $CI_PROJECT_DIR && ls -al
- export VERSION=`cat VERSION` || exit "$?";
- echo '---------- deploy start ------------'
- scp -r $CI_PROJECT_DIR/dist/* root@$DEPLOY_ENV:/data/web/$CI_PROJECT_NAME
- echo '---------- deploy success ------------'
# 手动执行
when: manual
only:
refs:
- feature/dev
3、CI触发的方法
这里选择通过提交feature/dev分支来触发CI,还有很多触发的方法
- 完全匹配的分支、符合正则的分支提交
- 变量判断、排除触发条件
- changes,监控文件改变触发
- except:feature-pre,和refs指定的分支进行过滤,feature-pre分支不触发
only:
refs:
- feature/dev
- /^feature-.*$/
variables:
- $CI_PROJECT_NAME != "styles"
changes:
- Dockerfile
- .gitlab-ci.yml
except:
- feature-pre
4、引入方式
在需要CI/CD的项目根目录下新建.gilab-ci.yml
include:
- remote: http://git.xxx.com:/gitlab-config/gitlab-ci-app.yml?raw=true
引入之前可以先验证下yml是否正常
七、注意点
- 变量的使用:CI过程中需要用到很多变量,比如SSHKey、镜像仓库账号密码、上传的虚拟机IP等,可以配置在Gitlab-CI页面中,防止泄露
- gitlab-config仓库需要设置为public模式,否则通过
include remote不一定可以获取 - Gitlab、镜像仓库、Runner、部署服务器网络一定要通,否则无法关联起来。
- artifacts的使用,artifacts 只能是 $CI_PROJECT_DIR 目录下的内容
- 在自动部署阶段,如果没有灰度系统,要提前终止,配置参数
when: manual通过手动触发最后一步的部署任务 - 该方案不仅可以支持微服务的发布,还适用于统一管理几乎所有通过ci发布的项目,即使项目的发布方式差异比较大,也可以通过增加配置来处理