前端微服务通过Gitlab-CI实现检查、构建并自动部署虚拟机

1,972 阅读9分钟

一、背景

最近开发了个大前端项目,用前端微服务来实现,由于涉及到十几个子项目,如果每个项目都手动或者脚本部署,人力成本和出错的概率就会很高,所以需要接入Gitlab-CI/CD,方便统一部署和后续灰度系统的接入。

实现的过程中需要考虑的问题:

  • 涉及到十几个子项目,后面会持续扩展,所以需要统一的发布方式
  • 需要考虑后续接入灰度系统的问题
  • 项目支持中英文,所以除了语言文件和一些特殊的文件中,其他代码中不允许有中文
  • 部署的虚拟机需要经过跳板机,Gitlab Runner需要可以直接将文件发布到虚拟机
  • 一些变量不适合共享给所有用户,需要隐藏

这里以前端服务为例子,但实现方案也适用于后端服务

二、实践概述

1、原理图

image.png

  1. 统一的gitlab-config配置文件,前端服务通过include引入
  2. 当提交代码触发CI,则会在Gitlab Runner中执行CI配置文件
  3. 如果涉及到定制的镜像,会将镜像推送到镜像仓库
  4. Runner执行多个任务,在最后部署的任务中,将代码推送到虚拟机
  5. 如果存在跳板机,要提前处理密钥,获取访问权限

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,命名AppApp下有多个项目包括:smsaccountservices

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字段来引入
  • .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检测

image.png

我们可以通过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出现错误会返回12,则终止当前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

distversion是我们要发布的代码,在这一步我们可以接入灰度系统,通过管理配置文件,根据用户灰度范围来确定用户当前的版本。后续在补充这一个章节

# 打包
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

image.png

六、部署服务器

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是否正常

七、注意点

  1. 变量的使用:CI过程中需要用到很多变量,比如SSHKey、镜像仓库账号密码、上传的虚拟机IP等,可以配置在Gitlab-CI页面中,防止泄露
  2. gitlab-config仓库需要设置为public模式,否则通过include remote不一定可以获取
  3. Gitlab、镜像仓库、Runner、部署服务器网络一定要通,否则无法关联起来。
  4. artifacts的使用,artifacts 只能是 $CI_PROJECT_DIR 目录下的内容
  5. 在自动部署阶段,如果没有灰度系统,要提前终止,配置参数when: manual通过手动触发最后一步的部署任务
  6. 该方案不仅可以支持微服务的发布,还适用于统一管理几乎所有通过ci发布的项目,即使项目的发布方式差异比较大,也可以通过增加配置来处理

image.png