使用 CircleCI 实现持续集成和持续部署

最近把一个项目从 Daocloud 迁出,原因是 Daocloud 的企业组需要收费,我们认为没必要付出这笔费用,并且自建的话实际难度不大。

关于 Daocloud,其实我个人很早就不使用了,Daocloud 的 CI/CD 功能我自己使用 CircleCI 来替代,而他的容器管理我则使用了功能更全,对 Swarm Mode 支持更好的 Portainer 替代。

不过基于 CircleCI 我最早也只是实现了持续集成的环节,持续部署这一块并没有实现。但这次迁出的过程中,总算把它给搞定了。

最早的时候也有几种方案,包括使用 jenkins 和 drone,但两者的问题是需要自建相应的设施,并且我们现在业务都只在单机上,如果同样机子还要部署这些服务,加上 CI 的时候还需要创建 MySQL 以及 Redis 来,可能会影响业务稳定。而如果开多一台服务器,意味着我们还要解决怎么跨节点部署的问题,我们可能需要搭建一个集群。

我个人一开始关于 CI 这一块想法是很明确的,那就是使用 CircleCI,但是 CD 这一块怎么处理我也没搞定。最开始我的想法是实现一个 Webhook,部署在服务器上面,用来更新容器,为此我尝试了用 Go 语言和 Node.js 来实现这样一个 Webhook,但是实现的过程我发现本质上还是调用 Docker Engine API,更新也就是一个 HTTP 请求就可以搞定的事情,所以就琢磨着怎么去实现这个。

但是很快问题就出来了,Docker Container 是不支持变更 Image 的,实际上像 Daocloud 更新容器也是容器销毁和重新创建。这意味这一个 Update 的 HTTP 请求是不够的,而且重新创建的时候,还需要把相应的环境变量,Volume 等也进行配置。

解决容器更新的问题,最简单的一个方式就是使用 Docker Service,它支持更新的时候指定 Image,支持 forceUpdate。当然这意味这需要初始化一个 Docker Swarm Mode。这样就可以一个 HTTP 请求实现持续部署了。

不过最终我并没有使用 HTTP 来实现持续部署,转而使用了 SSH 来执行 service update 操作。当然这并不是说 HTTP 的方式不可行或者不好,只是我认为 SSH 的方式比较简单粗暴。HTTP 的方式,还需要将 docker.sock 套接字用 socat 转发出来,并且还要做好安全(比如加上 basic auth)。而 SSH 的话,需要把私钥交出去给 CircleCI。

两种方式都可以,不过我最后使用的是 SSH 的方式。简单说下最终 CircleCI 的配置:

image_config: &image_config	# 配置

  IMAGE_NAME: xxx	# 镜像名
  DEV_SERVICE_NAME: xxx-dev	# develop 分支对应的 service 名
  PROD_SERVICE_NAME: xxx	# master 分支对应的 service 名

version: 2
jobs:
  test:
    working_directory: ~/app
    docker:
      - image: circleci/node:8.9
      - image: mysql:5.7	# 测试需要依赖 MySQL
        environment:
          - MYSQL_DATABASE: xxx
          - MYSQL_ALLOW_EMPTY_PASSWORD: yes
        command:
          - --character-set-server=utf8mb4 # 以下 command 是为了正确创建出 utf8mb4 的数据库
          - --collation-server=utf8mb4_unicode_ci
          - --skip-character-set-client-handshake
      - image: redis:3.0.7	# 测试需要依赖 Redis

    environment: 
      - NODE_ENV: test

    steps:
      - checkout

      - restore_cache:
          keys:
          - v1-dependencies-{{ checksum "yarn.lock" }}	# 复用缓存
      
      - run: yarn
      
      - save_cache:
          key: v1-dependencies-{{ checksum "yarn.lock" }}
          paths:
            - node_modules
      
      - run: yarn run lint	# Lint 检查

      - run: yarn run task:initdb	# 数据库初始化测试数据

      - run: yarn run test:ci	# 集成测试

      - run: yarn run report-coverage	# 上传测试覆盖率报告

      - store_artifacts:
          path: coverage
          prefix: coverage

  build:
    machine:
      docker_layer_caching: true	# 尽可能利用缓存

    environment:
      <<: *image_config
    
    steps:	# 登陆私有 dockerhub 并构建和上传镜像
      - checkout

      - run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD hkccr.ccs.tencentyun.com

      - run: docker build -t hkccr.ccs.tencentyun.com/xxx/$IMAGE_NAME:$CIRCLE_BRANCH-${CIRCLE_SHA1:0:7} .

      - run: docker push hkccr.ccs.tencentyun.com/xxx/$IMAGE_NAME:$CIRCLE_BRANCH-${CIRCLE_SHA1:0:7}

      - store_artifacts:
          path: Dockerfile

  deploy:
    machine: true

    environment:
      <<: *image_config
    
    steps:
      - add_ssh_keys:	# 需要在 CircleCI 里面进行 SSH 密钥配置,在此处指定 fingerprints
          fingerprints:
            - "09:2f:cb:df:27:bd:c6:5a:e4:f4:21:06:9a:d8:ed:g1"

      - run: 
          name: Deploying
          command: |	# 根据当前分支名更新相应的服务
            if [ $CIRCLE_BRANCH == "master" ]; then
              ssh root@11.11.11.11 docker service update $PROD_SERVICE_NAME --image hkccr.ccs.tencentyun.com/xxx/$IMAGE_NAME:$CIRCLE_BRANCH-${CIRCLE_SHA1:0:7}
            else
              ssh root@11.11.11.11 docker service update $DEV_SERVICE_NAME --image hkccr.ccs.tencentyun.com/xxx/$IMAGE_NAME:$CIRCLE_BRANCH-${CIRCLE_SHA1:0:7}
            fi

workflows:
  version: 2
  test_build_deploy:
    jobs:
      - test
      - build:
          filters:
            branches:
              only:	# 只对 develop 和 master 分支进行 build
                - master
                - develop
          requires:
            - test	# 需要 test 通过才能执行
      - deploy:
          requires:
            - build

当 develop 分支有新的提交时,CircleCI 会自动的依次运行 testbuilddeploy 三个任务,分别是集成测试,镜像构建,更新服务器上相应的服务,从而实现了持续集成和持续部署。