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:
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 条流水线,空跑耗时不会太长。