用 Drone CI 替换掉 Jenkins

Jenkins 是一款免费开源的 CI/CD(持续集成、部署)软件,被广泛应用在各种场景中。Jenkins 的主要优势在于其包罗万象的插件,可以完成各种任务,例如自动执行 SCPAnsible 等部署,Cppcheck 等代码分析,Telegram钉钉等状态通知。

我之前也将 Jenkins 用于大量任务的自动化执行,例如我的 Dockerfile 镜像的自动更新,你正在浏览的 Hexo 博客的部署,甚至还有原神自动签到

但是 Jenkins 是一款拥有悠久历史的 CI,其前身 Hudson 早在 2005 年就发布了。因此,Jenkins 执行任务时依然是传统的直接执行命令,而非使用 Docker 容器等现代化的方式。这意味着 CI 执行的成功与否很大程度上依赖 Worker 主机的系统环境。例如,前段时间我租了一台配置更高的服务器,由于重新搭建了环境,导致 CI 执行过程中出现一堆莫名其妙的问题,花了一个星期才全部发现解决。

此外,Jenkins 用 Java 写成,因此它的内存占用相当恐怖,一个 Jenkins 实例的内存占用可以达到 1GB 以上,导致它完全没法用在低配置的主机上,即使是最简单的任务也不行。而且,Jenkins 强大的插件功能却难以用配置文件的方式调用。有很多插件都没有实现通过 Jenkinsfile 配置参数的功能,遇到这种插件只能在 Jenkins 网页上逐个配置,麻烦、容易出错。

相比之下,Drone 这款基于容器的 CI 是一个更现代化的选择。Drone 推荐使用 基于 Docker 容器的 Worker(官方称为 Runner)。由于使用容器作为运行环境,Drone 可以发挥容器的最大优势:运行环境的一致性。只要容器镜像一致,就可以保证 CI 的命令每次都在相同环境下执行,输出的结果也将是稳定的。当然,如果你的脚本无论如何无法在容器中运行,Drone 也提供直接执行命令或是在 DigitalOcean 云服务器中运行的 Runner。

Drone 还有很多其它的优势:Drone 使用 Go 语言写成,其内存占用仅是 Jenkins 的十分之一;Drone 的配置文件语言是 YAML 或者 Jsonnet,不需要像 Jenkins 一样学一种专用语言;Drone 的插件虽然相比 Jenkins 较少,但全部都是 Docker 容器,可以统一在配置文件中调用。

JenkinsDrone
运行环境Worker 系统环境可选 Docker 容器,Worker 系统环境,DigitalOcean 云服务器
配置文件专用语言 Jenkinsfile通用语言 YAML/Jsonnet
插件数量多,1836 个(本文写成日)少,102 个
插件配置主要在网页端,只有少量能通过配置文件统一在配置文件中
开发语言JavaGo
内存占用多,1GB 左右少,100MB 左右

安装 Drone

作为一款容器化的 CI,Drone 本体也是一个 Docker 容器,通过环境变量的方式进行配置。Drone 可以对接 GitHubGitLabGiteaBitBucket,请参照链接中的官方文档进行配置。但是一个 Drone 实例只能同时对接其中一个。如果你像我一样需要同时接 GitHub 和自己的 Gitea,就需要运行两套 Drone。

如果你准备将 Drone 用于部署,你需要一种方法把部署密钥传进 Drone。我用的是 Vault 这款密钥管理软件,Drone 官方提供了 Vault 的支持。当然,你也可以把密钥直接存在 Drone 中,但不能在网页端直接操作,而是必须使用 Drone 的命令行工具

以下是我的配置供参考,部署了 Vault、Drone:

version: '2.4'
services:
    # 密钥管理,Vault 实例,和 Drone 的插件
    vault:
        image: vault
        container_name: vault
        restart: unless-stopped
        command: 'server'
        labels:
            - com.centurylinklabs.watchtower.enable=false
        volumes:
            - './conf/vault:/vault/config:ro'
            - './data/vault:/vault/file'

    drone-vault:
        image: drone/vault
        container_name: drone-vault
        restart: unless-stopped
        environment:
            DRONE_DEBUG: 'true'
            DRONE_SECRET: '***drone-vault 的密钥***'
            VAULT_ADDR: 'https://vault.lantian.pub'
            VAULT_TOKEN: '***Vault 的密钥***'
        depends_on:
            - vault

    # 第一套 Drone,用于我自己的 Gitea
    drone:
        image: drone/drone:2
        container_name: drone
        restart: unless-stopped
        environment:
            DRONE_GITEA_SERVER: 'https://git.lantian.pub'
            DRONE_GITEA_CLIENT_ID: '***Gitea 的 OAuth ID***'
            DRONE_GITEA_CLIENT_SECRET: '***Gitea 的 OAuth 密钥***'
            DRONE_RPC_SECRET: '***Drone Runner 的密钥,用 openssl rand -hex 16 生成***'
            DRONE_SERVER_HOST: ci.lantian.pub
            DRONE_SERVER_PROTO: https
            DRONE_USER_CREATE: username:lantian,admin:true # 配置管理员账号
            DRONE_JSONNET_ENABLED: 'true'
            DRONE_STARLARK_ENABLED: 'true'
        volumes:
            - './data/drone:/data'

    # 第一套 Drone 的 Docker Runner
    drone-runner-docker:
        image: drone/drone-runner-docker:1
        container_name: drone-runner-docker
        restart: unless-stopped
        environment:
            DRONE_RPC_PROTO: https
            DRONE_RPC_HOST: ci.lantian.pub
            DRONE_RPC_SECRET: '***Drone 的密钥,与上面的 DRONE_RPC_SECRET 一致'
            DRONE_RUNNER_CAPACITY: 4 # 并行任务数
            DRONE_RUNNER_NAME: drone-docker
            DRONE_SECRET_PLUGIN_ENDPOINT: http://drone-vault:3000
            DRONE_SECRET_PLUGIN_TOKEN: '***drone-vault 的密钥***'
        volumes:
            - '/var/run:/var/run'
            - '/cache:/cache'
        depends_on:
            - drone
            - drone-vault

    # 第二套 Drone,用于 GitHub
    drone-github:
        image: drone/drone:2
        container_name: drone-github
        restart: unless-stopped
        environment:
            DRONE_GITHUB_CLIENT_ID: '**GitHub 的 OAuth ID**'
            DRONE_GITHUB_CLIENT_SECRET: '***GitHub 的 OAuth 密钥***'
            DRONE_RPC_SECRET: '***Drone Runner 的密钥,用 openssl rand -hex 16 生成***'
            DRONE_SERVER_HOST: ci-github.lantian.pub
            DRONE_SERVER_PROTO: https
            DRONE_USER_CREATE: username:xddxdd,admin:true # 配置管理员账号
            DRONE_REGISTRATION_CLOSED: 'true' # 禁止新用户注册
            DRONE_JSONNET_ENABLED: 'true'
            DRONE_STARLARK_ENABLED: 'true'
        volumes:
            - './data/drone-github:/data'

    # 第二套 Drone 的 Docker Runner
    drone-github-runner-docker:
        image: drone/drone-runner-docker:1
        container_name: drone-github-runner-docker
        restart: unless-stopped
        environment:
            DRONE_RPC_PROTO: https
            DRONE_RPC_HOST: ci-github.lantian.pub
            DRONE_RPC_SECRET: '***Drone 的密钥,与上面的 DRONE_RPC_SECRET 一致'
            DRONE_RUNNER_CAPACITY: 4 # 并行任务数
            DRONE_RUNNER_NAME: drone-docker
            DRONE_SECRET_PLUGIN_ENDPOINT: http://drone-vault:3000
            DRONE_SECRET_PLUGIN_TOKEN: '***drone-vault 的密钥***'
        volumes:
            - '/var/run:/var/run'
            - '/cache:/cache'
        depends_on:
            - drone-github
            - drone-vault

基本的 Drone 自动构建与部署

搭建完 Drone 后,下一步是添加构建任务。这里我以部署我的 Hexo 博客为例。

我的博客本身有一套部署脚本,执行以下任务:

  • 安装 node_modules
  • hexo generate
  • hexo deploy 到 GitHub Pages 上(作为备用)
  • 把所有图片都转一遍 WebP,所有静态资源都提前用 Gzip、Brotli 压缩好
  • 把生成的文件用 Ansible 批量 Rsync 到所有服务器上

此外,由于我的博客用了 Dependabot 来更新依赖包,Dependabot 时不时会发起 Pull Request。显然,处理 Pull Request 时不能执行部署的步骤,只能尝试 generate 一下看会不会失败。

于是我们可以先写一个最基本的配置,保存为 .drone.yaml

kind: pipeline
type: docker
name: default

trigger:
    branch:
        - master

steps:
    - name: hexo generate
      image: node:15-alpine
      commands:
          # 其实没必要装这么多包,只是为了与 deploy 一步统一
          - apk add --no-cache build-base bash git openssh wget python3 gzip brotli zstd parallel imagemagick
          - npm install
          - node_modules/hexo/bin/hexo generate

    - name: hexo deploy
      image: node:15-alpine
      commands:
          # 装包
          - apk add --no-cache build-base bash git openssh wget python3 gzip brotli zstd parallel imagemagick
          - node_modules/hexo/bin/hexo deploy
      # 收到 Dependabot 的 PR 时不要部署
      when:
          event:
              exclude:
                  - pull_request

    # 略过一些后续步骤

这一段配置可以生成出静态网页文件,可以尝试运行 hexo deploy,但是由于缺少 SSH 密钥不会部署成功。由于显而易见的原因,我不会推荐你把 SSH 密钥直接写死在配置文件中。你应该将密钥添加到 Vault(或者 Drone 的密钥存储中),然后在配置文件中调用:

# 从 Vault 获取 SSH 密钥,当前仓库在 Drone 中必须设置为 Trusted 状态
kind: secret
name: id_ed25519
get:
    # 注意这里对应的 Vault 显示的路径是 kv/ssh,data 一项是必须加的
    path: kv/data/ssh
    name: id_ed25519

---
kind: pipeline
type: docker
name: default

# ...

steps:
    # ...
    - name: hexo deploy
      image: node:15-alpine
      environment:
          # 调用上面从 Vault 获取到的 SSH 密钥,设置为环境变量
          SSH_KEY:
              from_secret: id_ed25519
      commands:
          # 安装 SSH 密钥
          - mkdir -p /root/.ssh/
          - echo "$SSH_KEY" > /root/.ssh/id_ed25519
          - chmod 600 /root/.ssh/id_ed25519

          # 配置 SSH,主要是禁用验证 SSH 主机密钥,如果不禁用会登录失败
          - |
              cat <<EOF >/root/.ssh/config
              StrictHostKeyChecking no
              UserKnownHostsFile=/dev/null
              VerifyHostKeyDNS yes
              LogLevel ERROR
              EOF

          # 装包...略

这样 CI 容器中就有 SSH 密钥文件,可以通过 SSH 连接 GitHub 或者其它部署目标了。

但是这里还有一个问题:每次构建启动时,容器里都是一个干净的环境,也就是没有 node_modules,也就意味着每次构建时都要花大量时间下载这个黑洞

好消息是 Drone 提供了一个插件,可以把过程中的文件夹打包缓存,下次部署时解压使用:

# ...
steps:
    # 恢复上次缓存的文件:
    - name: restore cache
      image: meltwater/drone-cache:dev
      settings:
          backend: 'filesystem'
          restore: true
          cache_key: 'volume'
          archive_format: 'gzip'
          filesystem_cache_root: '/cache'
          # 缓存这两个文件夹
          mount:
              - 'node_modules'
              - 'img_cache'
      volumes:
          - name: cache
            path: /cache

    - name: hexo generate
      # ...

    # 把这次的文件缓存:
    - name: rebuild cache
      image: meltwater/drone-cache:dev
      settings:
          backend: 'filesystem'
          rebuild: true
          cache_key: 'volume'
          archive_format: 'gzip'
          filesystem_cache_root: '/cache'
          # 缓存这两个文件夹
          mount:
              - 'node_modules'
              - 'img_cache'
      volumes:
          - name: cache
            path: /cache

# 缓存文件会保存到宿主机的 /cache 文件夹,需要仓库在 Drone 中设置为 Trusted 状态
volumes:
    - name: cache
      host:
          path: /cache

此外,部署失败时,可以通过 Telegram 插件发送通知:

# 从 Vault 获取 Telegram 的 token 和目标账号
kind: secret
name: tg_token
get:
    path: kv/data/telegram
    name: token

---
kind: secret
name: tg_target
get:
    path: kv/data/telegram
    name: target

---
# ...
steps:
    # ...

    # 失败时通过这个任务通知
    - name: telegram notification for failure
      image: appleboy/drone-telegram
      settings:
          token:
              from_secret: tg_token
          to:
              from_secret: tg_target
      when:
          status:
              - failure

    # 成功时通过这个任务通知,注意如果是定时任务触发则不会发送成功通知
    - name: telegram notification for success
      image: appleboy/drone-telegram
      settings:
          token:
              from_secret: tg_token
          to:
              from_secret: tg_target
      when:
          branch:
              - master
          status:
              - success
          event:
              exclude:
                  - cron

这样我们就有了一个带部署、带缓存、带 Telegram 通知的 Drone 配置。

矩阵构建(Matrix Build)

有的时候我们需要在多种不同的环境下测试程序,例如 Python 2.7/3.6/3.7/3.8/3.9,GCC/Clang 等等。Drone 支持 Jsonnet 配置文件格式,可以批量定义任务。

以我的 route-chain 项目为例,此处作为例子简化了部分内容:

// 定义一个「函数」,用于创建一条流水线
local DebianCompileJob(image, kernel_headers) = {
  "kind": "pipeline",
  "type": "docker",
  "name": image,
  "steps": [
    {
      "name": "build",
      "image": image,
      "commands": [
        "apt-get update",
        "DEBIAN_FRONTEND=noninteractive apt-get -y --no-install-recommends install build-essential " + kernel_headers,
        "make"
      ]
    },
    {
      "name": "telegram notification",
      "image": "appleboy/drone-telegram",
      "settings": {
        "token": {
          "from_secret": "tg_token"
        },
        "to": {
          "from_secret": "tg_target"
        }
      }
    }
  ]
};

[
  // Telegram 的 token 和目标账号
  {
    "kind": "secret",
    "name": "tg_token",
    "get": {
      "path": "kv/data/telegram",
      "name": "token"
    }
  },
  {
    "kind": "secret",
    "name": "tg_target",
    "get": {
      "path": "kv/data/telegram",
      "name": "target"
    }
  },
  // 批量调用 DebianCompileJob 创建任务,对于不同镜像和头文件包名
  DebianCompileJob('debian:jessie', 'linux-headers-amd64'),
  DebianCompileJob('debian:stretch', 'linux-headers-amd64'),
  DebianCompileJob('debian:buster', 'linux-headers-amd64'),
  DebianCompileJob('debian:bullseye', 'linux-headers-amd64'),
  DebianCompileJob('debian:unstable', 'linux-headers-amd64'),
  DebianCompileJob('ubuntu:xenial', 'linux-headers-generic'),
  DebianCompileJob('ubuntu:bionic', 'linux-headers-generic'),
  DebianCompileJob('ubuntu:focal', 'linux-headers-generic'),
]

保存为 .drone.jsonnet,然后在 Drone 中将配置文件名从 .drone.yaml 改为 .drone.jsonnet 即可。

提前退出构建

在矩阵构建中,有的时候我们不需要运行所有的任务。例如我的 Dockerfiles 仓库,我不需要每次提交都重新构建所有的 14(个镜像)乘以 8(种架构)共 112 种情况。

好在 Drone 支持提前终止构建流水线,只要在某个步骤退出时将返回码设置成 78,类似这样:

# ...
steps:
    # ...
    - name: skip build
      image: alpine
      commands:
          - ./should_build.sh && exit 0 || exit 78

实际例子可以在 Dockerfiles 仓库的这个 commit 看到

不过由于 Drone 使用容器构建,容器本身启动的速度就有点慢,启动 112 条流水线本身就要消耗十几分钟的时间,即使任务立即退出。因此,我后来将 Dockerfiles 仓库调整成了一个架构一条流水线,每条流水线根据 commit 消息判断要构建哪些镜像,这样只需要启动 8 条流水线,空跑耗时不会太长。