Jenkins 是一款免费开源的 CI/CD(持续集成、部署)软件,被广泛应用在各种场景中。Jenkins 的主要优势在于其包罗万象的插件,可以完成各种任务,例如自动执行 SCP、Ansible 等部署,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 容器,可以统一在配置文件中调用。
| Jenkins | Drone |
---|
运行环境 | Worker 系统环境 | 可选 Docker 容器,Worker 系统环境,DigitalOcean 云服务器 |
配置文件 | 专用语言 Jenkinsfile | 通用语言 YAML/Jsonnet |
插件数量 | 多,1836 个(本文写成日) | 少,102 个 |
插件配置 | 主要在网页端,只有少量能通过配置文件 | 统一在配置文件中 |
开发语言 | Java | Go |
内存占用 | 多,1GB 左右 | 少,100MB 左右 |
安装 Drone
作为一款容器化的 CI,Drone 本体也是一个 Docker 容器,通过环境变量的方式进行配置。Drone 可以对接 GitHub、GitLab、Gitea 和 BitBucket,请参照链接中的官方文档进行配置。但是一个 Drone 实例只能同时对接其中一个。如果你像我一样需要同时接 GitHub 和自己的 Gitea,就需要运行两套 Drone。
如果你准备将 Drone 用于部署,你需要一种方法把部署密钥传进 Drone。我用的是 Vault 这款密钥管理软件,Drone 官方提供了 Vault 的支持。当然,你也可以把密钥直接存在 Drone 中,但不能在网页端直接操作,而是必须使用 Drone 的命令行工具。
以下是我的配置供参考,部署了 Vault、Drone:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101
| version: '2.4' services: 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: 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-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: 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-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
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| kind: pipeline type: docker name: default
trigger: branch: - master
steps: - name: hexo generate image: node:15-alpine commands: - 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 when: event: exclude: - pull_request
|
这一段配置可以生成出静态网页文件,可以尝试运行 hexo deploy
,但是由于缺少 SSH 密钥不会部署成功。由于显而易见的原因,我不会推荐你把 SSH 密钥直接写死在配置文件中。你应该将密钥添加到 Vault(或者 Drone 的密钥存储中),然后在配置文件中调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| kind: secret name: id_ed25519 get: path: kv/data/ssh name: id_ed25519
--- kind: pipeline type: docker name: default
steps: - name: hexo deploy image: node:15-alpine environment: SSH_KEY: from_secret: id_ed25519 commands: - mkdir -p /root/.ssh/ - echo "$SSH_KEY" > /root/.ssh/id_ed25519 - chmod 600 /root/.ssh/id_ed25519
- | cat <<EOF >/root/.ssh/config StrictHostKeyChecking no UserKnownHostsFile=/dev/null VerifyHostKeyDNS yes LogLevel ERROR EOF
|
这样 CI 容器中就有 SSH 密钥文件,可以通过 SSH 连接 GitHub 或者其它部署目标了。
但是这里还有一个问题:每次构建启动时,容器里都是一个干净的环境,也就是没有 node_modules
,也就意味着每次构建时都要花大量时间下载这个黑洞。
好消息是 Drone 提供了一个插件,可以把过程中的文件夹打包缓存,下次部署时解压使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| 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
volumes: - name: cache host: path: /cache
|
此外,部署失败时,可以通过 Telegram 插件发送通知:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| 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 项目为例,此处作为例子简化了部分内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| 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" } } } ] };
[ { "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('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,类似这样:
1 2 3 4 5 6 7
| steps: - name: skip build image: alpine commands: - ./should_build.sh && exit 0 || exit 78
|
实际例子可以在 Dockerfiles 仓库的这个 commit 看到。
不过由于 Drone 使用容器构建,容器本身启动的速度就有点慢,启动 112 条流水线本身就要消耗十几分钟的时间,即使任务立即退出。因此,我后来将 Dockerfiles 仓库调整成了一个架构一条流水线,每条流水线根据 commit 消息判断要构建哪些镜像,这样只需要启动 8 条流水线,空跑耗时不会太长。