Build Optimization

之前文章介绍了 CI / CD 相关概念及主流工具,利用此类工具能便捷地进行持续集成及发布,但构建自动化并不意味着构建流程的结束,恰好是构建优化的开始。

缓存

缓存( Cache )是构建流程中必须面对的话题。

在持续集成及发布中,缓存是一把双刃剑,一方面,想尽可能的利用缓存,提升构建速度;一方面,想废弃缓存,确保应用的新鲜度。

以 Node 为例,在 build 之前,需要利用 node_modules 来提升构建速度,而当 package.json 中依赖发生变更时,需要清空缓存,安装新增包或更新变更包,确保应用如期运行。

下面以 Drone CI 为例,演示下构建过程中缓存的处理:

  1. 介于 Drone CI 是容器化的自动化工具,并不能如 Jenkins 直接存储缓存至工作区中,倘若不能存储在工作区,其实很容易想到,可以使用第三方存储容器共享文件夹来实现同样效果。

    Drone CI 是基于插件化的自动化工具,缓存相关的插件如下:

    从上述插件可以看出,缓存插件也是分为第三方存储( S3、GCS、SFTP 等 )和容器共享文件夹 ( volume ) 两大类,当然也有插件对这两项做了融合,比如meltwater/drone-cache插件。

    meltwater/drone-cache插件处理 node_modules 本地存储时,存在node_modules/.bin文件夹无法缓存的问题( 可查阅:Allow to configure skipping symbolic links ),因此采用drillster/drone-volume-cache插件作为本地缓存存储方案。

    本文利用主机下/var/drone-cache文件夹来缓存构建时node_modules文件夹,当应用下次构建时可以复用该文件夹下的缓存,从而跳过包安装过程的漫长等待,提升构建速度。.drone.yml示例配置如下:

    # 详细配置可查阅:https://github.com/Drillster/drone-volume-cache/blob/master/DOCS.md
    steps:
      # 取出存储 cache
      - name: restore-cache
          image: drillster/drone-volume-cache
          settings:
            restore: true
            mount:
              - ./node_modules
          # 加载 cache 数据卷,CI服务器对应仓库需要勾选 "Trusted"
          volumes:
            - name: cache
              path: /cache
      # 构建其他步骤,在此不做显示
      - ...
      # 重新存储 cache
      # 此步骤一般在发布至远程服务器前执行,用来确保 cache 的新鲜度
      - name: rebuild-cache
          image: drillster/drone-volume-cache
          settings:
            rebuild: true
            mount:
              - ./node_modules
          volumes:
            - name: cache
              path: /cache
    volumes:
      - name: cache
        host:
          # 倘若 path 不存在,需要在 CI 服务器上新建相应文件夹
          path: /var/drone-cache
    

    小贴士

    本地存储时,倘若存在多个项目或代码仓库,需要对其缓存做区分,不可如上做简单处理

  2. 完成了缓存的存储后,还需要面对应用新鲜度的问题。当应用的package.json 中的依赖更新时,需要移除缓存并进行更新。

    如何监测依赖更新成为需要解决的问题,方案其实有很多,可以参阅:npm install if package.json was modified

    通常做法可以分为监测文件变化( package.json )和监测依赖变化( package.json 中 dependencies 及 devDependencies )两大类。

    监测依赖变化在实现上复杂点,但缓存的更新会更精确,可以通过比对前后依赖项生成校验文件的 md5 来判定依赖是否发生变更,相关实现可参阅:ninesalt/install-changed

    监测文件变化在实现上简单点,可以利用 Git 来实现,可以通过比对前后两次 package.json 文件是否有变动来判定依赖是否发生变更,但往往不太精确,package.json 中非 dependencies 及 devDependencies 部分的变动也会触发清除缓存的操作。

    能不能把这两种方式结合起来,取长补短?

    在 Node 中,依赖会通过 package-lock.jsonyarn.lock 来锁版本,当依赖变更时,对应的package-lock.jsonyarn.lock 也会更新。可以通过检测 lock 文件变化来判定依赖是否发生变更。

    在发布版本时,通常会利用 npm version 命令进行版本更新,此时 package-lock.json 中 version 字段也会更新,并不能满足预期。而yarn.lock不会因 npm version 命令进行文件更新,只有当 dependencies 及 devDependencies 变动时才会更新。

    通过以上的对比,宜采用 yarn 来管理包,并监测 yarn.lock 文件的变化来更新缓存。

     # pre-install.sh 文件
     #!/bin/bash
     # 判断当前与上次提交中 yarn.lock 是否有变化
     changes=$(git diff HEAD^ HEAD -- yarn.lock)
     if [ -n "$changes" ]; then
       echo ""
       echo "*** CHANGES FOUND ***"
       echo "$changes"
       echo "Yarn.lock has changed"
       # 因 drillster/drone-volume-cache 并不可控,此处删除拉取后的缓存,以保证包的新鲜度
       rm -rf ./node_modules
       yarn install
     else
       echo ""
       echo "*** CHANGES NOT FOUND ***"
       echo "Yarn.lock keep unchanged"
       yarn install
     fi
    










     
     
     
     
     














     steps:
       - name: restore-cache
           image: drillster/drone-volume-cache
           settings:
             restore: true
             mount:
               - ./node_modules
           volumes:
             - name: cache
               path: /cache
       # 执行上述 sh 脚本
       - name: install
         image: node
         commands:
           - bash ./pre-install.sh
       - name: rebuild-cache
           image: drillster/drone-volume-cache
           settings:
             rebuild: true
             mount:
               - ./node_modules
           volumes:
             - name: cache
               path: /cache
     volumes:
       - name: cache
         host:
           path: /var/drone-cache
    

分发

之前博客的自动化部署主要是以 github pages 为载体,但在实际部署过程中需要分发代码至各服务器。

在 Jenkins CI 中,这一操作通常利用 Publish Over SSH 插件来进行完成,大致过程如下:

# 连接 Jenkins CI VPC( Virtual Private Cloud )网络
# VPC 通常称为私有网络或者专有网络,更加安全,自定义度更高
SSH: Connecting from host [vpc-jenkins-ci]
# configuration 中即为分发服务器的配置项
SSH: Connecting with configuration [vpc-web-prod-1-172.36.13.46] ...
# 这里利用管道来传输文件,即压缩文件至 STDOUT 
# 再利用 SSH 连接 Web服务器,拷贝并解压传输文件至对应文件夹,并删除压缩的传输文件
SSH: EXEC: STDOUT/STDERR from command [cd /var/www/web/docs
tar xzf docs.tar.gz -C /var/www/web/docs
rm -f docs.tar.gz] ...
SSH: EXEC: completed after 501 ms
# 完成后断开连接,继续执行分发任务
SSH: Disconnecting configuration [vpc-web-prod-1-172.36.13.46] ...
SSH: Transferred 1 file(s)
SSH: Connecting from host [vpc-jenkins-ci]
SSH: Connecting with configuration [vpc-web-prod-2-172.16.26.115] ...
SSH: EXEC: STDOUT/STDERR from command [cd /var/www/web/docs
tar xzf docs.tar.gz -C /var/www/web/docs
rm -f docs.tar.gz] ...
SSH: EXEC: completed after 600 ms
SSH: Disconnecting configuration [vpc-web-prod-2-172.16.26.115] ...
SSH: Transferred 1 file(s)
Build step 'Send files or execute commands over SSH' changed build result to SUCCESS
Finished: SUCCESS

概括来说,CI 服务器需先通过 SSH 连接 Web 服务器,分发时再传输文件至对应的 Web 服务器。

以 Drone CI 为例,具体步骤如下:

  1. 在 CI 服务器下生成无密码的 Public SSH Keys
# 执行命令,下述结果仅为执行参考
$ ssh-keygen

# 执行结果
Generating public/private rsa key pair.
Enter file in which to save the key (/root/.ssh/id_rsa):
# passphrase 为 key 的密码,默认设置空 ,表示不需要密码
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /root/.ssh/id_rsa.
Your public key has been saved in /root/.ssh/id_rsa.pub.
The key fingerprint is:
SHA256:YwhPC9gxq6OPmVJ7XYiKI257bSPPnfpdBc root@snowball
The key's randomart image is:
+---[RSA 2048]----+
|   o*o           |
|  oo.=.          |
| =o=..          |
|+ o.o=+o.        |
| +o. ++oS.E      |
|..+.. .. . .     |
|.o + .... .      |
| +=.oo+. .       |
|++==+=.          |
+----[SHA256]-----+

# 查看私钥
$ cat /root/.ssh/id_rsa
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAs1rxWQZzK+pOLbXY7vdLMoMfA85QVavrwuR06RksLImlFiXj
lDLhmYZUUijspp1Zw775+9VQxldejiCsL3mhzWSJPJ9wO5TJi1CXLn5QsEjY39dC
s5SEVq1EhqnVN0fjQqHaJn8GOOfy5bvzyTmV8WgO8Pl4CeR5vuuQbRYFDP+rjQnH
zLpeq73FiWASMRT5vIrZ1Rk92JoGN7TtBdI3ipP+O1kMimO0sATB9Rruww+lpuuZ
63jbHjPfmY24czMHbtbkpNjyDZNyvC7Mi2RNuIwcDkz4LQOJuWni
-----END RSA PRIVATE KEY-----

# 查看公钥
$ cat id_rsa.pub
ssh-rsa AAAAB3NzaC1yc2EAA2gQNDCo99NsjZzrkYYRZ4Uohrgt8LPXxTF0Zr3 root@snowball

  1. 复制 Public SSH Keys ,登录 Web 服务器,保存至 ~/.ssh/authorized_keys 文件
$ ssh root@45.88.74.102 'mkdir -p .ssh && cat >> .ssh/authorized_keys' < ~/.ssh/id_rsa.pub
# 成功后退出登录的 Web 服务器
# 在 CI 服务器通过 SSH 连接 Web 服务器,免密可登录则表示设置成功
$ ssh root@45.88.74.102
  1. 配置 Drone CI 仓库设置项

    REMOTE_HOST 为第一台 Web 服务器 IP 地址,REMOTE_HOST2 为第二台 Web 服务器 IP 地址,以此类推;RSYNC_KEY 为 CI 服务器 Private SSH Key;RSYNC_USER 为 Web 服务器用户名,通常为 root

  2. 配置 .drone.yml 文件

    服务器间数据传输存在 rsync、nc、ftp、scp、nfs 等多种方式,本文采用 rsync 来传输数据。

    rsync 是一个远程数据同步工具,可以通过 LAN/WAN 快速同步多台主机间的文件。rsync 使用 rsync 算法来保持本地和远程两个主机间的文件同步,只传送两个主机同一文件的不同部分,而不是每次全量更新,这种增量更新的方式,提升了文件传输的速度。

    Drone CI 中一般使用 drillster/drone-rsync 插件来传输服务器间数据。示例如下:

    - name: rsync
      image: drillster/drone-rsync
      environment:
        RSYNC_KEY:
          from_secret: RSYNC_KEY
        RSYNC_USER:
          from_secret: RSYNC_USER
        REMOTE_HOST:
          from_secret: REMOTE_HOST
        REMOTE_HOST2:
          from_secret: REMOTE_HOST2
      settings:
        hosts:
          - $$REMOTE_HOST
          - $$REMOTE_HOST2
      source: ./dist
      # 需要在 Web 服务器上安装 nginx
      # nginx 默认静态资源文件夹
      target: /usr/share/nginx/html
    

容器化

上面从缓存和分发角度优化了构建流程,使得整个构建更加自动化、速度更快。总体来说,以上的优化已经能满足日常构建的大部分场景。但倘若思考下整个构建流程,优化依然可以继续。

目前来说,利用 Drone 部署远程 CI 服务器,所有的构建都是在容器内进行,已经完成了环境隔离,保障了本地与远程构建结果的一致性。

稍微回望这一流程,拉取远端代码,构建产出 dist 目录的过程,其实是可以采用 Dockerfile 文件生成 dist 静态镜像来解决。Dockerfile 文件如下:

FROM node:alpine as builder
# 注入环境变量
ENV PROJECT_ENV production
ENV NODE_ENV production
# 新建工作目录
WORKDIR /src
WORKDIR /src-build
ADD ./ /src-build/
# 执行构建命令
RUN yarn install && \
    yarn run build && \
    mv  /src-build/dist /src/ && \
    rm -rf /src-build

如上利用 Dockerfile 可以不需引入 CI 服务器,即可完成打包的操作。

倘若不引入 CI 服务器,利用 Dockerfile 来进行打包同样要处理缓存和分发问题:每次构建时,yarn install 都需要重新执行一遍,安装导致的时间和安全成本巨大;分发的话,需要通过上传镜像至私有 Docker 镜像仓库,再进入 Web 服务器执行拉取镜像并运行容器的操作。

  • 回顾前端构建的流程,只需要保障 Node版本 、yarn版本 及 node_modules 文件夹一致,即可保证生成 dist 文件的一致。那么,是不是可以把这三者做成 Node 基础镜像来为 Web 服务器端的构建服务。

    因需要区分 Node 基础镜像和其他构建镜像,新建 node.dockerfile,如下:

    # 拉取 node 镜像
    FROM node:alpine
    # alpine 版本不包含 git 和 docker
    # 但后续在此镜像中需要使用 git 和 docker,安装后体积会增加一倍
    # 也可以不安装,在运行镜像时安装,主要还是在于镜像体积的取舍
    RUN apk update && apk add --no-cache git && \
        apk add docker
    # 新建并进入工作区
    RUN mkdir /src
    WORKDIR /src
    # 复制当前 package.json 相关文件至工作区
    COPY ./package*.json /src/
    COPY ./yarn.lock /src/
    # 安装包
    RUN yarn install --production
    

    在当前目录下执行构建镜像,如下:

    # 构建 Node 基础镜像
    # -t 镜像的 tag
    # -f Dockerfile 的名字 (默认为 ‘Dockerfile’)
    # .  代表当前文件夹的所有文件,并发送至 Docker daemon
    # 具体可查阅:https://docs.docker.com/v17.12/engine/reference/commandline/build/
    $ docker build -t node-base -f node.dockerfile .
    # 构建过程
    Sending build context to Docker daemon  133.4MB
    Step 1/6 : FROM node:alpine
    alpine: Pulling from library/node
    e7c96db7181b: Pull complete 
    773afe93be0d: Pull complete 
    223db68b3560: Pull complete 
    609526630549: Pull complete 
    Digest: sha256:25d56cf8f21a33f61415bcde0dd7fb1e1d46ecdb9b3b6d39e4846570cc235a81
    Status: Downloaded newer image for node:alpine
    ---> d4edda39fb81
    Step 2/6 : RUN mkdir /src
    ---> Running in 6c79b449d11f
    Removing intermediate container 6c79b449d11f
    ---> 7b232a36912b
    Step 3/6 : WORKDIR /src
    ---> Running in 2c91d05c5d08
    Removing intermediate container 2c91d05c5d08
    ---> e031c4db6733
    Step 4/6 : COPY ./package*.json /src/
    ---> b24da7a66597
    Step 5/6 : COPY ./yarn.lock /src/
    ---> 967926c6ebaf
    Step 6/6 : RUN yarn install --production
    ---> Running in 8d75cadf2142
    yarn install v1.16.0
    [1/4] Resolving packages...
    [2/4] Fetching packages...
    info fsevents@1.2.9: The platform "linux" is incompatible with this module.
    info "fsevents@1.2.9" is an optional dependency and failed compatibility check. Excluding it from installation.
    [3/4] Linking dependencies...
    [4/4] Building fresh packages...
    Done in 120.07s.
    Removing intermediate container 8d75cadf2142
    ---> defa3914799b
    Successfully built defa3914799b
    Successfully tagged node-base:latest
    

    这样便完成了 Node 基础镜像的制作,但本地镜像并不能共享,而各环境倘若需要保证一致性的话,需要引用同一基础镜像,此时可以上传至 Docker hub 公有或私有仓库,或利用 Harbor 搭建公司镜像管理平台,或利用阿里云等云服务商提供的镜像仓库服务。比较推荐 Harbor 自建镜像管理平台,保障镜像服务的安全性和稳定性。

    基础镜像线上化后,不需要安装任何环境和包,只需执行构建即可,缓存问题迎刃而解,而且方便追踪及复现线上故障,可谓一箭双雕。

  • 回望分发 Web 服务器时,直接传输 dist 到 nginx 默认文件夹,需要预先在服务器上配置 nginx 环境及配置文件,当 Web 服务器增多后,这一阶段比较繁琐,也容易出错。

    倘如部署时,只需运行 Docker 镜像便能访问 Web 服务,就能轻松地进行服务器扩展及收缩,也方便在本地追踪和复现线上问题。

    之前的 Dockerfile 其实可以一拆为三,分为 Node 基础镜像、Dist 静态镜像及 Nginx 部署镜像。因 Nginx 部署镜像仅需 Dist 静态镜像提供配置文件及静态资源,可以利用 Docker 的多阶段构建将两镜像进行合并。

    小贴士

    Docker 的多阶段构建只会保留最后阶段的文件和命令,可查阅 docker | multistage-build

    在当前目录下,新建 web.dockerfile,如下:

    # 拉取 node 基础镜像
    # 这里以本地镜像为例
    FROM node-base as builder
    # 进入 node 基础镜像下 src 目录
    WORKDIR /src
    # 复制当前目录文件至 src 目录下
    COPY . /src/
    # 打包静态资源
    RUN yarn build
    
    # 拉取 nginx 基础镜像
    FROM nginx:alpine
    # 新建工作区
    WORKDIR /root 
    # 复制 builder 阶段 /src/dist 文件至当前 nginx 目录 docs 下
    COPY --from=builder /src/dist /usr/share/nginx/html/docs
    # 暴露端口( nginx 基础镜像默认 80 端口,可忽略 )
    EXPOSE 80 
    

    在当前目录下执行构建镜像,如下:

    $ docker build -t web-nginx -f web.dockerfile .
    # 构建过程
    Sending build context to Docker daemon  133.4MB
    Step 1/8 : FROM node-base as builder
    ---> defa3914799b
    Step 2/8 : WORKDIR /src
    ---> Using cache
    ---> 7bec27b99d51
    Step 3/8 : COPY . /src/
    ---> b90d93e4aa54
    Step 4/8 : RUN yarn build
    ---> Running in 0e50b49dda4d
    yarn run v1.16.0
    $ vuepress build docs
    wait Extracting site metadata...
    tip Apply theme @vuepress/theme-default ...
    tip Apply plugin container (i.e. "vuepress-plugin-container") ...
    tip Apply plugin @vuepress/register-components (i.e. "@vuepress/plugin-register-components") ...
    tip Apply plugin @vuepress/active-header-links (i.e. "@vuepress/plugin-active-header-links") ...
    tip Apply plugin @vuepress/search (i.e. "@vuepress/plugin-search") ...
    tip Apply plugin @vuepress/nprogress (i.e. "@vuepress/plugin-nprogress") ...
    tip Apply plugin @vuepress/pwa (i.e. "@vuepress/plugin-pwa") ...
    tip Apply plugin @vuepress/last-updated (i.e. "@vuepress/plugin-last-updated") ...
    tip Apply plugin @vuepress/back-to-top (i.e. "@vuepress/plugin-back-to-top") ...
    tip Apply plugin @vuepress/medium-zoom (i.e. "@vuepress/plugin-medium-zoom") ...
    ℹ Compiling Client
    ℹ Compiling Server
    ✔ Server: Compiled successfully in 6.89s
    ✔ Client: Compiled successfully in 11.04s
    wait Rendering static HTML...
    wait Generating service worker...
    success Generated static files in dist.
    
    Done in 13.70s.
    Removing intermediate container 0e50b49dda4d
    ---> c8270562d518
    Step 5/8 : FROM nginx:alpine
    ---> bfba26ca350c
    Step 6/8 : WORKDIR /root
    ---> Using cache
    ---> bbea1e258a05
    Step 7/8 : COPY --from=builder /src/dist /usr/share/nginx/html
    ---> 600974ff3c47
    Step 8/8 : EXPOSE 80
    ---> Running in 387166dcd7c4
    Removing intermediate container 387166dcd7c4
    ---> 75e21d8027cc
    Successfully built 75e21d8027cc
    Successfully tagged web-nginx:latest
    

    接下来把该镜像上传到镜像仓库,在各预装 docker 的 Web 服务器上直接拉取并运行就行了。

整理完思路,但单靠上述容器化的实践并不能实现自动化部署的工作,将容器化理念融入进 Drone CI 工作流中才是务实之举。

之前利用 Drone Plugin 的功能,完成了缓存和分发的工作。其实容器化之后也大同小异,但需要对 .drone.yml 文件进行相应变更,细节如下:

  • 取消 CI 服务器本地缓存 node_modules 文件的策略,使用远程 docker image 来取代

    # .drone.yml
    - name: build-web-image
      # 私有镜像,这里采用 harbor 来进行存储和管理
      # 细节可参阅:https://github.com/goharbor/harbor
      # 必须配置 image_pull_secrets,否则会出错
      image: harbor.snowball.site/web/node-base
      # 挂载主机 daemon 用来 tag
      volumes:
        - name: dockersock
          path: /var/run/docker.sock
      environment:
        HARBOR_USERNAME:
          from_secret: HARBOR_USERNAME
        HARBOR_PWD:
          from_secret: HARBOR_PWD
      commands: 
        # 倘若 node 基础镜像没内置 git 和 docker,需要进行安装
        # - apk add --no-cache git
        # - apk add docker
        # 登陆私有镜像仓库( 倘若是公有仓库则可以忽略 )
        - docker login harbor.snowball.site -u $$HARBOR_USERNAME -p $$HARBOR_PWD
        # 执行 auto-check-install.sh 脚本来检测缓存变更
        # web.dockerfile 宜采用本地镜像,如 node-base ,保证缓存的新鲜度
        - sh ./build/auto-check-install.sh
        # 构建 web-nginx 镜像并推送至远程
        - docker build -t web-nginx -f web.dockerfile .
        - docker tag web-nginx harbor.snowball.site/web/web-nginx
        - docker push harbor.snowball.site/web/web-nginx
        # 清除 Web 服务器 untagged images
        - docker rmi $(docker images --filter "dangling=true" -q --no-trunc)
      # 容许容器访问主机服务
      privileged: true
    # 配置私有镜像仓库授权信息
    # 通过 docker login [SERVER] 后,可通过 ~/.docker/config.json 获取授权信息
    # 注意密码采用 -p 登陆,使用 --password-stdin 登陆将无法获取授权信息
    # 具体配置可参阅:https://discourse.drone.io/t/1-0-0-rc1-how-to-pull-image-from-private-registry-and-execute-commands-in-it/3057/12
    image_pull_secrets:
      - dockerconfigjson
    
    # auto-check-install.sh
    #!/bin/bash
    echo ========== CHECKING FOR CHANGES ========
    changes=$(git diff HEAD^ HEAD -- yarn.lock)
    if [ -n "$changes" ]; then
        echo ""
        echo "*** CHANGES FOUND ***"
        echo "$changes"
        echo "Yarn.lock has changed"
        # 依赖变更时,需要构建新 node-base 镜像并推送至远程私有仓库
        docker build -t node-base -f node.dockerfile .
        docker tag node-base harbor.snowball.site/web/node-base
        docker push harbor.snowball.site/web/node-base
    else
        echo ""
        echo "*** CHANGES NOT FOUND ***"
        echo "Yarn.lock has not changed"
    fi
    
  • 取消 CI 服务器传输文件至远程 Web 服务器的策略,使用拉取远程 docker image 来取代

    # .drone.yml
    - name: publish-web-server
      #  使用 drone-ssh 插件连接远程服务器
      image: appleboy/drone-ssh
      environment:
        HARBOR_USERNAME:
          from_secret: HARBOR_USERNAME
        HARBOR_PWD:
          from_secret: HARBOR_PWD
      settings:
        # 目前不支持 environment 存取
        host:
          - 45.77.119.141
          - 144.202.103.102
        username: 
          from_secret: REMOTE_USER
        key: 
          from_secret: REMOTE_KEY
        # 暴露至 script 的环境变量
        envs: [ HARBOR_USERNAME,HARBOR_PWD ]
        script:
          - docker -v
          # 判断是否存在 web-server 容器,存在则停止并删除原有 container
          - docker ps -q --filter "name=web-server" | grep -q . && (echo "Docker container web-server is existed" && docker container stop web-server && docker rm -f web-server) || echo "Docker container web-server is not existed"
          # 拉取最新镜像并运行
          - docker login harbor.snowball.site -u $$HARBOR_USERNAME -p $$HARBOR_PWD
          - docker pull harbor.snowball.site/web/web-nginx
          - docker run -d -p 3080:80 --name web-server harbor.snowball.site/web/web-nginx
          # 清除 Web 服务器 untagged images
          - docker rmi $(docker images --filter "dangling=true" -q --no-trunc)
    

至此,Drone CI 容器化大致完成。但需要注意,刚部署项目时,本地和远程都未暂存基础镜像,需要在 build-web-image 步骤前追加 check-base-image 的步骤:

# .drone.yml
- name: check-base-image
    image: docker:dind
    # 挂载主机 daemon 用来 tag
    volumes:
      - name: dockersock
        path: /var/run/docker.sock
    environment:
      HARBOR_USERNAME:
        from_secret: HARBOR_USERNAME
      HARBOR_PWD:
        from_secret: HARBOR_PWD
    commands: 
      - docker login harbor.snowball.site -u $$HARBOR_USERNAME -p $$HARBOR_PWD
      - sh ./build/auto-check-image.sh
    privileged: true
# auto-check-image.sh
#!/bin/bash
echo ========== CHECKING FOR NODE BASE IMAGE ========

image=$(docker images -q node-base 2> /dev/null)
if [ -n "$image" ]; then
  echo "Docker image node-base is existed"
else
  echo "Docker image node-base is not existed"
  docker build -t node-base -f node.dockerfile .
  docker tag node-base harbor.snowball.site/web/node-base
  docker push harbor.snowball.site/web/node-base
fi

需要说明的是,Dockerfile 构建时每行命令都是 Layer,Docker 构建时使用 Layer Cache 可以加速镜像构建过程,无需担心构建所产生的时间成本。整体构建用时与之前不相伯仲,如下图:

容器化完成之后,基础镜像和服务镜像都存放至 Harbor 私有仓库,如下图:

基于此,可以便捷地复用和管理镜像,方便追踪及复现线上故障,增强了 Web 服务的伸缩性。

小贴士

在实际工作中,Harbor 中的镜像可以通过 tag 来区分版本号,以利于回滚,此处只用作简单演示

体积

应用的体积是构建时需要面对的棘手问题,在前端服务中,主要体现在 npm 包和 Docker 镜像两方面:

  • npm 包

    npm 包分为 devDependencies 和 dependencies 两种依赖,其中 devDependencies 是日常开发环境所依赖的包,在部署时其实并不需要安装。

    可以通过一下命令来优化 node_modules 文件夹的体积:

    # yarn
    # 只安装 dependencies 中的包
    $ yarn install --production
    # npm 
    $ npm prune --production
    
  • Docker 镜像

    Docker 镜像也存在大小之分,比如,Docker Hub 上就存在多个 Node 镜像,不同的镜像具有不同的大小,而在容器时代,Alpine 是最轻量的镜像,因此可以把基础镜像都采用 Alpine 版本,这样生成的镜像足够小,便于推送和拉取。

    # Dockerfile 中拉取 Alpine 版本
    FROM node:alpine
    # or
    FROM nginx:alpine
    

网络

在网络层面,国内访问 npm 镜像源、Docker 镜像源 及 Github 仓库并不稳定,速度堪忧。这可能会导致 npm 包安装失败、Docker 镜像拉取失败、Github 仓库推送代码失败或过慢等问题。

当然,解决方案也有很多,可以采用国外云服务器、引用国内镜像源或利用网络代理来解决。

国内镜像源其实并不能解决 Github 仓库推送问题,而网络代理在 Docker 容器中存在诸多问题,推荐采用国外云服务器来解决。

同等配置下的国外云服务和国内阿里云服务器部署的 Drone CI ,无 node_modules 缓存,同次构建耗时依次如下:

国外云服务构建详细信息:

国内阿里云服务器构建详细信息:

对比可见,主要耗时集中于 yarn install 和 deploy github 这两个阶段。 在拉取 Docker 镜像时,国内阿里云服务器偶尔会抛出无法获取镜像的错误,如下:

Error response from daemon: Get https://registry-1.docker.io/v2/: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)

为解决国内网络问题带来的构建用时成本,倘若条件容许,可以自建代码仓库( GitLab、Gogs、Gitea 等 )、自建 npm 服务器( sinopia、cnpm 等 )及自建 dokcer 镜像仓库( harbor等 )来避免。

倘若条件不容许,针对 npm 官方源 和 Docker 镜像源,可以采用国内镜像源来简单处理:

  • npm 镜像源

    $ yarn config set registry "https://registry.npm.taobao.org"
    
  • Docker 镜像源

    在 Linux 云服务的/etc/docker/daemon.json文件下写入如下信息:

    # daemon.json 倘若不存在则新建
    {
      "registry-mirrors": [
        "https://dockerhub.azk8s.cn",
        "https://reg-mirror.qiniu.com"
      ]
    }
    

    重启 Docker 服务:

    # 以 root 权限运行
    $ systemctl daemon-reload
    $ systemctl restart docker
    

参考链接