notes

最近因为公司业务需要,部门所有人都要转型做前端(还不知道以后做不做native相关的东西…),所以大家都开始学习react,原因是react的写法更像native app的写法,比如组件的生命周期方法,class等等…

既然做了react项目,CI是一定要配好的,之前做ios app开发的时候,由于没有多余的mac电脑,所以没法做CI,CI可以把开发流程规范很多,还可以自动做语法检查,但是配置CI需要一定的docker知识,不了解的同学最好还是先去了解一下。

在项目中启用CI很简单,只要在项目根目录加上文件.gitlab-ci.yml就行了。然后在.gitlab-ci.yml文件里面写配置。

想要知道怎么写配置,需要先了解CI中的一些概念。

pipeline

在CI中,执行一次CI的流程或步骤称为pipeline。

比如,我们可以配置项目的CI流程为: 第一步,安装项目依赖。 第二步,执行语法检查。 第三步,构建项目。 第四部,部署在测试服务器。

以上的四步称为一次pipeline,每一步称为一个stage(阶段)。

stage

在CI中可以自定义stage,比如我可以在.gitlab-ci.yml文件中写这么一段

stages:
    - install_deps
    - eslint
    - build
    - deploy

这就代表着把一次pipeline分为四个stage。每个stage只有在前一个stage执行成功后才会执行。

job

接下来,那怎么告诉CI具体每个stage怎么执行?

这就需要定义job,job是stage的执行单元,一个stage可以定义多个job。

比如一个job可以这样定义:

prepare:
  stage: install_deps
  script:
    - npm install

prepare是job的名字,可以自定义。 stage: install_deps表示这个job属于install_deps这个stage。 script定义这个job具体执行的脚本。就是执行这个job的时候就会执行npm install这个命令。

总结一下就是:在.gitlab-ci.yml文件里面定义pipeline,pipeline中有多个stage,stage是按顺序执行的,如果某个stage执行出错,那么之后的stage不会执行。每个stage又对应多个job,相同的stage中的多个job是并行执行的,只有所有的job都返回成功才算成功。

1

runner

假设我们已经写好了一个.gitlab-ci.yml文件,是这样的

image: zacksleo/node

stages:
  - install_deps
  - build

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

install_deps:
  stage: install_deps
  when: manual
  script:
    - npm install

testing-server:
  stage: build
  script:
    - if [ ! -d "node_modules" ]; then
    - npm install
    - fi
    - yarn eslint ./
    - npm run build

那么,由谁来具体执行job里面的script脚本呢,就是runnerrunner是需要配置的,但是我所在的公司已经配好了一些runner,有国内和国外的。所以我也没配过。这里就不细讲了,简单就说一下原理。

runner是用docker来跑script脚本的,看到第一行的image: zacksleo/node了么,这句话的意思是使用zacksleo/node镜像来构建容器,执行script脚本,镜像是预装了node的,所以可以执行node脚本,docker的相关知识这里就不赘述了。

还有一个比较重要的点是每次运行job或者stage之前,runner都会把gitignore里面的文件全部删掉,这就导致了在前一个阶段装的npm install依赖不能给后面的stage使用,所以这里就用到了cache,把相关的依赖文件夹node_modules放到缓存里,这样就能给后面的stage使用了。

还有一点值得注意的是when: manual这行,这表示此stage会直接跳过,需要手动执行。因为cache是一直保存着的,比如之前的pipeline安装过npm install的话,是可以在下次的pipeline直接使用的,所以install_deps这个stage是不需要每次都执行的,只有在package.json里面添加了新的依赖,才需要手动去执行。注意:install_deps这个stage可能需要执行多遍,执行一遍的话后面的stage可能还会报找不到对应模块的错误,那是因为每个stage是跑在不同的runner上的,只执行一遍的话可能只更新了部分runner里面的cache,有些runnercache还是老的,所以最好多执行几次。

实战

好,我们已经了解了CI的一些基础知识。说了那么多,我们直接来解读公司后端大神写的CI文件

image: zacksleo/node

before_script:
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" > ~/deploy.key
    - chmod 0600 ~/deploy.key
    - ssh-add ~/deploy.key
    - mkdir -p ~/.ssh
    - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
    - export APP_ENV=testing
    - yarn config set registry 'https://registry.npm.taobao.org'
stages:
    - prepare
    - test
    - build
    - deploy

variables:
    COMPOSER_CACHE_DIR: "/cache/composer"
    DOCKER_DRIVER: overlay2
build-cache:
    stage: prepare
    script:
        - yarn install --cache-folder /cache/yarn
    cache:
      key: "$CI_COMMIT_REF_NAME"
      paths:
        - node_modules
    except:
        - docs
        - tags
    when: manual
    tags:
        - mainland
eslint:
    stage: test
    dependencies: []
    cache:
      key: "$CI_COMMIT_REF_NAME"
      policy: pull
      paths:
        - node_modules
    script:
        - if [ ! -d "node_modules" ]; then
        - yarn install --cache-folder /cache/yarn
        - fi
        - yarn eslint ./
    except:
        - docs
        - develop
        - master
        - tags
    tags:
        - mainland
build-check:
    stage: test
    dependencies: []
    cache:
      key: "$CI_COMMIT_REF_NAME"
      policy: pull
      paths:
        - node_modules
    script:
        - if [ ! -d "node_modules" ]; then
        - yarn install --cache-folder /cache/yarn
        - fi
        - yarn build
    except:
        - docs
        - develop
        - master
        - tags
    tags:
        - mainland
build-package:
    stage: test
    script:
        - if [ ! -d "node_modules" ]; then
        - yarn install --cache-folder /cache/yarn
        - fi
        - yarn build
    dependencies: []
    cache:
      key: "$CI_COMMIT_REF_NAME"
      policy: pull
      paths:
        - node_modules
    artifacts:
        name: "build"
        untracked: false
        expire_in: 60 mins
        paths:
            - build
    except:
        - docs
    only:
        - develop
        - master
        - tags
    tags:
        - mainland
testing-image:
    stage: build
    image: docker:latest
    dependencies:
        - build-package
    cache: {}
    before_script: []
    script:
        - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
        - docker build -t $CI_REGISTRY_IMAGE:latest .
        - docker push $CI_REGISTRY_IMAGE:latest
        - docker rmi $CI_REGISTRY_IMAGE:latest
    only:
        - develop
    tags:
        - mainland
staging-image:
    stage: build
    image: docker:latest
    dependencies:
        - build-package
    cache: {}
    before_script: []
    script:
        - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
        - docker build -t $CI_REGISTRY_IMAGE:stable .
        - docker push $CI_REGISTRY_IMAGE:stable
        - docker rmi $CI_REGISTRY_IMAGE:stable
    only:
        - master
    tags:
        - mainland
production-image:
    stage: build
    image: docker:latest
    dependencies:
        - build-package
    before_script: []
    script:
        - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
        - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG .
        - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
        - docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
    only:
        - tags
    tags:
        - mainland
testing-server:
    stage: deploy
    variables:
        DEPLOY_SERVER: "xxx.xxx.xxx.xxx"
    dependencies: []
    cache: {}
    script:
        - cd deploy/testing
        - rsync -rtvhze ssh . root@$DEPLOY_SERVER:/data/$CI_PROJECT_NAME --stats
        - ssh root@$DEPLOY_SERVER "docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY"
        - ssh root@$DEPLOY_SERVER "export COMPOSE_HTTP_TIMEOUT=120 && export DOCKER_CLIENT_TIMEOUT=120 && cd /data/$CI_PROJECT_NAME && docker-compose pull web && docker-compose stop && docker-compose rm -f && docker-compose up -d --build"
    only:
        - develop
    environment:
        name: testing
        url: https://testing.example.com:1004
    tags:
        - mainland
staging-server:
    stage: deploy
    variables:
        DEPLOY_SERVER: "xxx.xxx.xxx.xxx"
    dependencies: []
    cache: {}
    script:
        - cd deploy/staging
        - rsync -rtvhze ssh . root@$DEPLOY_SERVER:/data/$CI_PROJECT_NAME --stats
        - ssh root@$DEPLOY_SERVER "docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY"
        - ssh root@$DEPLOY_SERVER "export COMPOSE_HTTP_TIMEOUT=120 && export DOCKER_CLIENT_TIMEOUT=120 && cd /data/$CI_PROJECT_NAME && docker-compose pull web && docker-compose stop && docker-compose rm -f && docker-compose up -d --build"
    only:
        - master
    environment:
        name: staging
        url: https://preview.example.com:1004
    tags:
        - mainland
production-server:
    stage: deploy
    variables:
        DEPLOY_SERVER: "xxx.xxx.xxx.xxx"
    dependencies: []
    cache: {}
    script:
        - cd deploy/production
        - rsync -rtvhze ssh . root@$DEPLOY_SERVER:/data/$CI_PROJECT_NAME --stats
        - ssh root@$DEPLOY_SERVER "docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY"
        - ssh root@$DEPLOY_SERVER "export COMPOSE_HTTP_TIMEOUT=120 && export DOCKER_CLIENT_TIMEOUT=120 && echo -e '\nTAG=$CI_COMMIT_TAG' >> .env && cd /data/$CI_PROJECT_NAME && docker-compose pull web && docker-compose stop && docker-compose rm -f && docker-compose up -d --build"
    only:
        - tags
    environment:
        name: staging
        url: https://$DEPLOY_SERVER:1004
    tags:
        - mainland

我们一点点来分析,首先,我看可以看到这个pipeline分为四个stage,分别是prepare,test,build,deploy

首先肯定会并行执行stagepreparejob,这里就一个名为build-cachejob,但是这个job是when: manual的,所以会直接跳过,执行下一个stage。

接下来会并行执行stagetestjob,这里有eslintbuild-checkbuild-package三个,eslint很简单,就是执行代码审查。build-checkbuild-package里面的script是相同的,唯一不同的是build-package里面的artifacts字段,artifacts名为工件(可以理解为附件),工件会把文件上传到gitlab上面,供其他job下载,我们也可以在gitlab上下载浏览。

既然artifactscache都可以实现stage和job之间共用数据,那么什么时候用cache,什么时候用artifacts呢,他们又有什么区别呢?

1. cache在runner中生成后,可以给下次相同runner执行ci使用。
2. cache可能不是最新的。
3. 多个runner之间的cache可能是不同的。
3. artifacts在创建后会提交到gitlab上,供本次pipeline后面的stage使用。
4. artifacts在创建后会提交到gitlab上,用户可以下载查看。
5. artifacts必须指定保存时间,不然会一直保存在gitlab上。

每个job开始时会默认去下载artifacts,如果job没有用到artifacts,则加入dependencies: [],表示为禁用artifacts,以加快执行速度。

好,假设这个stage执行成功,这时我们的项目已经构建好了,并可以在gitlab上下载浏览了。

接下来会并行执行stagebuildjob,这里有testing-image,staging-image,production-image三个job,仔细看的话,其实每次commit只会执行其中一个job,因为他们的only字段表示只有commit到only指定的分支时才会触发此job。以testing-image为例,script中,先登录docker私有仓库,在根据根目录中的Dockerfile构建一个新的镜像,在push到私有仓库中,再从runner中删除镜像。

Dockerfile是这样的:

FROM mops-gitlab.lianluo.com:4567/lianluo/nginx:static-spa
COPY ./build /var/www/html
WORKDIR /var/www/html
VOLUME ["/var/www/html"]

这是一个nginx的镜像,会把我们前一步build出来的工件拷贝到镜像中,所以只要启动这个镜像,就能访问到页面了。

我们还注意到,testing-image还有tags一栏,tags指定了此job跑在哪个runner上,runner一般都是打了多个tag的,比如公司内部配了很多个runner,有国内和国外的,国内的会打上mainland标签,所以我们一般在tag中指定为mainland,用国内的runner跑速度更快。

2

点击Expand,可以看到配置好的所有runner。

接下来会并行执行stagedeployjob,也是有3个job,一次commit只会执行其中的一个,以testing-server为例,先看前两句

- cd deploy/testing
- rsync -rtvhze ssh . root@$DEPLOY_SERVER:/data/$CI_PROJECT_NAME --stats

意思为同步deploy/testing目录下的文件到服务器上的某个目录中,deploy/testing目录里面是一些配置文件,比如.envdocker-compose.yml文件。

这里有个问题,就是script是跑在runner上的,ssh连接到服务器的话runner必须知道私钥,然后服务器有公钥,这样ssh才能成功,那么runner怎么知道私钥呢?

这就需要在gitlab上配置。在gitlab上进入具体的项目,找到setting -> CI / CD -> Secret variables,点击Expand,看描述的话可以知道这里是配置runner的环境变量的,私钥就是在这里配置的。

配置好了之后,在before_script中,我们发现这里就是把私钥注入到runner里面,关于ssh的更详细信息可以看这里

连接上服务器之后,接下来就是登陆私有仓库下载前一步构建好的docker镜像,然后就是根据前面传的docker-compose.yml文件执行docker-compose up命令。执行成功后就表示部署好了。