早上 9:30,线上环境挂了一个小时
周一早会还没开完,运维就过来敲桌子:线上环境更新失败。我打开 Jenkins,流水线还卡在第五步,队列里排了 3 个构建。这仓库只有 3 个提交,但一次完整构建要跑 10 分钟。
更麻烦的是,这台 Jenkins 是 3 年前装的,插件版本乱到没人敢升级。git 插件连不上 Gitee,ssh-agent 插件又和 JDK 版本冲突。谁动谁背锅。
我用两天时间把 CI/CD 迁到了 Drone。这篇是这次迁移的完整记录,代码可直接改改就用。
先看方案:修 Jenkins 还是换 Drone
评估过两条路:
方案 A:保留 Jenkins,升级插件,重写 Pipeline。评估结果:至少花 3 人天,还要解决 JVM 内存(现在峰值 4.2G)、插件兼容、主从同步三个问题。风险高。
方案 B:换成 Drone。评估结果:Drone 是 Go 写的,Server + Runner 都跑 Docker,内存占用小;流水线文件是仓库里的 .drone.yml,天然走 code review;插件直接是 Docker 镜像,没有插件市场依赖。
选 B。
| 对比项 | Jenkins 2.375 | Drone 2.16 |
|---|---|---|
| 配置方式 | Groovy / UI | YAML(.drone.yml) |
| 插件管理 | 插件市场,需手动升级兼容 | 每个插件是 Docker 镜像,随用随拉 |
| 构建隔离 | 同一工作区,环境易污染 | 每个 Step 一个容器,环境干净 |
| 资源占用 | JVM 峰值 4.2G | Server 约 480M + Runner 约 1.3G |
| 构建速度(实测) | 8m35s(串行) | 4m52s(可并行) |
Drone 核心原理:为什么它吃得少跑得快
Drone 由两部分组成:
- drone-server:负责 Webhook、鉴权、数据库(默认 SQLite),只做 API 调度。
- drone-runner-docker:轮询 Server 拿任务,启动一个个 Docker 容器执行 Step。
每个 Step 都是独立的 docker run,但共享一个 workspace volume。所以上一步产出的 vendor/、dist/ 下一步能直接用。
关键点:Step 之间用 depends_on 声名依赖,没有依赖的 Step 会并行跑。这是构建提速的根本。
完整代码实现:搭一套可用的 Drone
环境版本
- Docker 20.10.17
- Docker Compose v2.3.4
- drone/drone:2.16
- drone/drone-runner-docker:1.8
- Gitee(配置为 Git provider)
第一步:部署 Server 和 Runner
先创建网络,再启动两个容器。因为要持久化配置和数据,我用 volume 挂载了 /data。
# 生成随机 RPC 秘钥,当作密码保存
openssl rand -hex 32
# 创建网络
docker network create drone-net
# 启动 Server(Drone 2.16)
docker run --detach --name=drone \
--publish=8080:80 \
--volume=/var/lib/drone:/data \
--env=DRONE_GITEE_ENABLED=true \
--env=DRONE_GITEE_CLIENT_ID=你的Gitee应用ID \
--env=DRONE_GITEE_CLIENT_SECRET=你的Gitee应用Secret \
--env=DRONE_RPC_SECRET=你的随机秘钥 \
--env=DRONE_SERVER_HOST=ci.example.com \
--env=DRONE_SERVER_PROTO=http \
--net=drone-net \
--restart=always \
drone/drone:2.16
# 启动 Runner(drone-runner-docker 1.8)
docker run --detach --name=runner \
--volume=/var/run/docker.sock:/var/run/docker.sock \
--env=DRONE_RPC_PROTO=http \
--env=DRONE_RPC_HOST=drone \
--env=DRONE_RPC_SECRET=你的随机秘钥 \
--net=drone-net \
--restart=always \
drone/drone-runner-docker:1.8
如果用 Docker Compose 管理更省心。写一个 docker-compose.yml:
# docker-compose.yml
services:
drone:
image: drone/drone:2.16
ports:
- "8080:80"
volumes:
- /var/lib/drone:/data
environment:
- DRONE_GITEE_ENABLED=true
- DRONE_GITEE_CLIENT_ID=你的Gitee应用ID
- DRONE_GITEE_CLIENT_SECRET=你的Gitee应用Secret
- DRONE_RPC_SECRET=${DRONE_RPC_SECRET}
- DRONE_SERVER_HOST=ci.example.com
- DRONE_SERVER_PROTO=http
networks:
- drone-net
restart: always
runner:
image: drone/drone-runner-docker:1.8
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- DRONE_RPC_PROTO=http
- DRONE_RPC_HOST=drone
- DRONE_RPC_SECRET=${DRONE_RPC_SECRET}
networks:
- drone-net
restart: always
networks:
drone-net:
driver: bridge
然后在 .env 里放 DRONE_RPC_SECRET=xxxx,执行 docker compose up -d。
第二步:写 .drone.yml
我们的项目是 Laravel 11(PHP 8.3),流程是:composer 装依赖 → 跑 PHPUnit → 构建并推送镜像 → 通知钉钉。
# .drone.yml
kind: pipeline
type: docker
name: backend-api
volumes:
- name: cache
host:
path: /var/lib/drone/cache
- name: docker_sock
host:
path: /var/run/docker.sock
steps:
- name: composer-install
image: composer:2.7
volumes:
- name: cache
path: /cache
commands:
- composer config -g cache-dir /cache/composer
- composer install --no-interaction --prefer-dist --no-progress
environment:
COMPOSER_ALLOW_SUPERUSER: "1"
- name: run-tests
image: thecodingmachine/php:8.3-v4-cli
commands:
- php ./vendor/bin/phpunit --testsuite Tests --testdox
depends_on: [ composer-install ]
- name: build-image
image: docker:24.0.7
volumes:
- name: docker_sock
path: /var/run/docker.sock
commands:
- export IMAGE_TAG=${DRONE_BRANCH//\//_}-${DRONE_COMMIT_SHA:0:8}
- docker build -t registry.example.com/backend/api:${IMAGE_TAG} .
- docker push registry.example.com/backend/api:${IMAGE_TAG}
depends_on: [ run-tests ]
- name: notify
image: node:20-alpine
commands:
- node scripts/notify.js
environment:
DINGTALK_WEBHOOK:
from_secret: dingtalk_webhook
depends_on: [ build-image ]
when:
status: [ success, failure ]
说明:
- 工作区默认在容器内的
/drone/src,每个 Step 之间共享。 composer-install把缓存写到宿主机/var/lib/drone/cache,避免每次重新下载。run-tests用的镜像自带 pdo_mysql 扩展,不用现场编译。build-image挂载了 Docker socket,直接在容器里 build 镜像。notify里的DINGTALK_WEBHOOK从 secret 读取,不写死在文件里。
第三步:添加 Secret
用 drone CLI 添加钉钉机器人 webhook 地址。
# 下载 drone CLI(v1.6.2)
curl -L https://github.com/drone/drone-cli/releases/download/v1.6.2/drone_linux_amd64.tar.gz | tar xz
# 登录
# 这里的地址和 token 在 Drone 后台「Account」里找
./drone server add ci.example.com YOUR_API_TOKEN
# 添加 secret
./drone secret add --repo=your/project --name=dingtalk_webhook \
--data='https://oapi.dingtalk.com/robot/send?access_token=xxxx'
有了 secret,.drone.yml 里就能用 from_secret 引用了。这样密钥不会出现在代码仓库。
第四步:一个带部署的完整 Pipeline
如果要把构建产物同步到目标服务器,我用了一个独立脚本 deploy.sh。脚本里临时写入 SSH 私钥,用后即焚。
#!/usr/bin/env bash
# scripts/deploy.sh
set -euo pipefail
SSH_KEY_PATH=$(mktemp)
echo "$DEPLOY_KEY" > "$SSH_KEY_PATH"
chmod 600 "$SSH_KEY_PATH"
ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no \
"$DEPLOY_HOST" \
"cd /opt/api && docker compose pull api && docker compose up -d api"
rm -f "$SSH_KEY_PATH"
Pipeline 里加一步执行这个脚本:
- name: deploy
image: client:alpine
environment:
DEPLOY_KEY:
from_secret: deploy_key
DEPLOY_HOST: root@10.0.0.8
commands:
- apk add --no-cache openssh-client bash
- bash scripts/deploy.sh
when:
status: success
branch: main
用来传版本号的 PHP 小工具
镜像打完后,我要往产物里写一个 version.json,供前端展示构建信息。
<?php
// scripts/version.php
$build = (int) getenv('DRONE_BUILD_NUMBER');
$file = __DIR__ . '/../dist/version.json';
$data = [
'build' => $build,
'branch' => getenv('DRONE_BRANCH'),
'commit' => getenv('DRONE_COMMIT_SHA'),
'built_at' => date('c'),
];
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
echo "version file written: {$file}" . PHP_EOL;
在 run-tests 之后插入一步 php scripts/version.php 就行。
测试用 SQL 初始化
PHPUnit 集成测试需要独立数据库,我整理了一个初始化的 SQL 文件:
-- tests/sql/init.sql (MySQL 8.0.35)
CREATE DATABASE IF NOT EXISTS drone_demo
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE drone_demo;
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Pipeline 里跑测试前先导入这个文件,保证每次环境一致。
通知脚本 notify.js
Node 20 自带 fetch,不需要 npm install。
// scripts/notify.js
const webhook = process.env.DINGTALK_WEBHOOK;
const data = {
msgtype: 'markdown',
markdown: {
title: '构建通知',
text: '### ' + process.env.DRONE_REPO_NAME + ' 构建 #' + process.env.DRONE_BUILD_NUMBER + '\n' +
'状态:' + process.env.DRONE_BUILD_STATUS + '\n' +
'提交:' + process.env.DRONE_COMMIT_SHA.slice(0, 8) + '\n' +
'任务:' + process.env.DRONE_JOB_NAME
}
};
fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(res => {
if (!res.ok) process.exit(1);
}).catch(err => {
console.error(err);
process.exit(1);
});
效果数据
切换后的两周,我统计了同一仓库、同一分支、相同提交数量的构建数据。拿构建时间中位数对比:
| 指标 | Jenkins | Drone | 变化 |
|---|---|---|---|
| 平均总耗时(中位数) | 8m35s | 4m52s | -43% |
| 任务排队时间 | 平均 46s,最高 12m | 平均 3s | -93% |
| Server 内存 | 4.2G(JVM 峰值) | 468M | -89% |
| 总计磁盘占用 | 10G+ | 3.8G(含缓存) | -62% |
| 环境相关失败率 | 15% | 2.6% | -83% |
数据是真实的,但有一定背景:项目原来是串行构建,Drone 里 run-tests 和 composer-install 有依赖关系,所以实际并行度只开到了 2 层。如果不同测试套件互相独立,还能继续并行拆分。
资源占用下降的原因:Jenkins 的 JVM 是常驻进程,还要跑一堆插件,Drone Server 只做 API 调度,把构建负载全丢给 Docker 容器,用完即销毁。
避坑:这些坑我全踩过
1. DRONE_RPC_SECRET 泄露等于送钥匙
RPC 秘钥是 runner 和 server 通信的凭证,一定要用 openssl rand -hex 32 生成,放到 .env 里,别写进仓库。有同学把它直接写在 docker-compose.yml 然后推到 Git,结果被陌生账号扫描到,被迫换秘钥。
2. Docker socket 挂载权限
/var/run/docker.sock 权限默认是 root:root,runner 容器内用户默认是 root,所以能正常用。但如果你为了安全给 runner 容器指定了非 root 用户,不要直接挂 socket,否则起不了容器。我踩过一次:在 runner 里加了 user: 1000,然后构建一直报 "permission denied while trying to connect to the Docker daemon socket"。解决方案:单独跑一个特权的 docker 完成 build,别在 runner 里直接操作。
3. 缓存不能放在容器内
Drone 的每个 Step 都是独立容器,默认写入数据不会保留。第一次迁移时我把 composer 缓存写在 /tmp/composer-cache,结果每次构建都重新拉依赖,慢得和 Jenkins 一样。后来用 volumes 把宿主机目录挂进去,才算真正的缓存。
4. when 条件写反
notify 步骤我一开始写的是:
when:
status: failure
结果构建成功时从不发通知。看了文档才知道,success 和 failure 要写成数组:
when:
status: [ success, failure ]
5. 国内拉 Docker Hub 镜像慢
drone-runner-docker 每步都要从 Docker Hub 拉镜像,没配加速时,单是拉 node:20-alpine 就花了 2 分钟。解决:给 Docker daemon 配镜像加速,在 /etc/docker/daemon.json 里写:
{
"registry-mirrors": [
"https://docker.m.daocloud.io"
]
}
6. 仓库过大时 git clone 超时
如果仓库历史很深,Drone 默认 clone 全部分支。我们仓库有 2.4G 历史,第一次构建直接超时。处理办法:在 .drone.yml 里加 clone 限制:
clone:
depth: 1
disable: false
这样每个构建只拉最新一个提交,时间从 50s 降到 9s。
结尾
如果你是 5-20 人的后端团队,正在为 CI 耗时和 Jenkins 维护头疼,我建议试一下 Drone。配置写在仓库里,谁改都逃不过 code review。多个步骤能并行跑,资源占用是 Jenkins 的零头。
这篇的所有代码都是直接能跑的。按上面的步骤操作,最多一天就能把核心流程跑通。