ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

drone+docker搭建轻量级CI/CD自动化部署流水线

drone+docker搭建轻量级CI/CD自动化部署流水线

大概半年前,我负责的那个后端服务还在用最原始的方式发版:本地打包、scp 上传、SSH 上去重启进程,一次发版少说十几分钟,赶上网络抖动能折腾半小时。后来我把项目迁到了基于 drone+docker 的自动化部署流程上,代码一推送,镜像自动构建,远程服务器自动更新,整个流程基本不需要人碰。今天把这套方案从架构选型、环境搭建、流水线编写到排障实录完整梳理一遍,想在小团队里落地自动化发布体系的同学,可以直接照抄。

这套方案最核心的价值,是把“发布”这件事从手工操作变成了一次 Git 提交。你要做的只是在仓库根目录放一个.drone.yml,在控制台点一下激活,剩下的 build、push、deploy 全交给系统。drone 负责调度,docker 负责执行环境隔离,两者搭在一起,部署链路非常轻,但又足够真实业务使用。

1. drone+docker 选型:轻量 CI/CD 方案为什么更适合小团队

1.1 跟 Jenkins 和 GitLab CI 比,drone 的取舍到底在哪

先回答一个很多人会问的问题:为什么不用 Jenkins?

Jenkins 确实生态庞大,插件多到数不清,但它的“重”在中小团队里往往是负担。你要单独维护一个服务,装一堆插件,界面配置路径很深,一个 pipeline 写成 Groovy 脚本之后,普通后端同学根本不敢碰。而且 Jenkins 的安装、升级、插件兼容问题,会持续消耗团队的运维精力。

GitLab CI 如果你们本来就在重度使用 GitLab,那它是很自然的选择。但它的一个隐性前提是:你愿意把自己绑在 GitLab 生态里。很多人用的代码托管平台并不是 GitLab,或者团队里 GitHub、Gitea、自建 Git 平台混用,这时候 GitLab CI 就不太好统一。

drone 的做法完全不同。它的 server 只负责接收 webhook、解析.drone.yml、调度任务;执行任务的是配套的 runner。runner 拿到一个 pipeline 后,会把每一步 step 都当作一个 Docker 容器来跑。这意味着什么?意味着构建环境不再依赖某个固定的 Jenkins 节点,你不需要在一台机器上装 Java、Node、Go、Python 全家桶。每个 step 可以在自己的镜像里工作,比如前端步骤用 node 镜像,后端步骤用 golang 镜像,互不干扰,干净利落。

我自己的体会是,drone 的学习成本低到几乎可以忽略。配置文件就是一坨 YAML,每一步做什么写得很直白。新同学看一遍.drone.yml,比看 Jenkins 那种几百行的 Groovy 脚本要轻松太多。

1.2 docker runner 的执行链路:一次构建是如何完整跑起来的

可以把 drone 理解成“调度中心 + 工位”的结构。drone server 是调度中心,负责接单、排期、记录结果;docker runner 是工位,负责真正干活。

一次构建的完整链路是这样的:

  1. 你在 Git 仓库 push 代码,托管平台收到 push 事件后,向 drone server 的/hook地址发一个 webhook。
  2. drone server 根据仓库关联的.drone.yml解析出 pipeline,生成一个构建任务。
  3. server 把这个任务通过 RPC 推给一个空闲的 runner。
  4. runner 按顺序启动一个个 Docker 容器,每个容器对应 pipeline 里的一个 step。容器会挂载同一个 workspace 卷,所以前一步生成的文件,后一步还能看到。
  5. runner 把每个 step 的日志实时回传给 server,你在 drone 网页上能看到滚动输出。
  6. 所有 step 执行完后,server 把最终状态(成功/失败)展示出来,并触发后续通知。

这个设计里最关键的一点,是“每一步都是独立容器”。它带来两个直接好处:一是环境隔离,A 步骤装的依赖不会污染 B 步骤;二是可复现,同样的配置在任何机器上跑,结果几乎一致。你可以把它类比成流水线上的独立工位,每个工位自带工具箱,工人只在自己的工位上操作,做完传给下一个工位。

1.3 什么时候不应该用 drone,先给你泼盆冷水

任何工具都有边界。drone 适合中小团队、项目数量中等、构建任务以 Linux 容器为主的场景。但如果你遇到下面这些情况,建议慎重:

  • 团队非常依赖可视化编排和拖拽式编辑,不接受写 YAML 的协作方式。
  • 构建流程需要 Windows 环境支持,容器化解决不了。
  • 对 pipeline 有非常复杂的审批流和权限隔离要求,比如要按部门、按角色做细粒度审批。
  • 仓库只是静态托管,无法配置 webhook,或者网络策略不允许 server 被外部访问。

我当时把现有项目迁移到 drone 之前,特意先选了一个边缘服务做试点。第一个服务跑通后,才逐步把其他项目接进来。这条路比较稳,因为 drone 虽然简单,但“从零到一”的坑并不会因为你项目多就少踩。

2. 从零搭建 drone 服务:server 和 runner 的安装配置要点

2.1 用 Compose 一键拉起 server 与 runner

安装 drone 其实就是在 Docker 里跑两个容器。我用一个docker-compose.yml把 server 和 runner 直接编排起来。

先看一个最小的配置示例:

services: drone-server: image: drone/drone:2 ports: - "3080:80" volumes: - ./drone-data:/var/lib/drone environment: - DRONE_SERVER_HOST=drone.example.com - DRONE_SERVER_PROTO=https - DRONE_RPC_SECRET=your-shared-secret - DRONE_USER_CREATE=admin:your-git-username,admin # 对接代码托管平台的 OAuth 配置,不同平台前缀不同 - DRONE_<PROVIDER>_CLIENT_ID=your-client-id - DRONE_<PROVIDER>_CLIENT_SECRET=your-client-secret # 如果用自建 Git 服务,还需要指定服务器地址 - DRONE_<PROVIDER>_SERVER=https://git.example.com restart: always drone-runner: image: drone/drone-runner-docker:1 ports: - "3000:3000" volumes: - /var/run/docker.sock:/var/run/docker.sock environment: - DRONE_RPC_SERVER=drone-server:80 - DRONE_RPC_SECRET=your-shared-secret - DRONE_RUNNER_CAPACITY=2 restart: always

几个环境变量的作用需要理解到位。

DRONE_RPC_SECRET是 server 和 runner 之间的共享密钥,相当于两边对暗号。必须完全一致,否则 runner 连不上 server。这个值建议用随机字符串生成,别用弱密码。

DRONE_SERVER_HOST和DRONE_SERVER_PROTO决定了 server 对外暴露的地址和协议。如果 drone 控制台要通过 HTTPS 访问,这里就写https://drone.example.com,并且端口要映射正确。很多同学在这块踩坑:webhook 回调地址用的是内网 IP,Git 平台在外面访问不到,导致 push 后什么都不触发。

DRONE_USER_CREATE的作用是把某个 Git 账号初始化为 admin。比如你在 Git 平台上的用户名是 alice,这里配成alice:alice,admin,启动后这个账号登录 drone 就是管理员。不配这一项,第一个登录的人往往权限不够,没法激活仓库,会卡在很尴尬的位置。

runner 挂载了宿主机的/var/run/docker.sock,这是它创建构建容器的方式。挂上这个 socket,runner 就等于拿到了宿主机 Docker 的“驾驶权”,可以动态拉起和销毁容器。这是 docker runner 默认的工作模式,后面我会单独讲它的安全问题。

2.2 对接代码库:OAuth 回调、webhook 激活一个都不能少

drone 和 Git 平台的对接分两步:OAuth 授权和 webhook 推送。

OAuth 这一步,每个平台的具体入口不一样,但逻辑是一样的。你要在代码托管平台创建一个应用,拿到 client id 和 client secret,然后把这两个值填到 server 环境变量里。创建应用时填写的回调地址通常是:

https://drone.example.com/login

因为你登录 drone 控制台时,它会把登录请求重定向到 Git 平台;用户授权后,平台回调到这个地址,drone 才能拿到身份信息完成登录。

第二步是激活仓库。仓库第一次进入 drone 控制台列表时,状态是未激活的。在界面里点“Activate”,drone 会自动去 Git 平台注册一个 webhook,地址一般是:

https://drone.example.com/hook

之后只要有人往这个仓库 push,托管平台就会把这个事件 POST 到这个地址,drone 拿到事件就开始跑流水线。

如果激活后 webhook 没创建成功,通常是 OAuth 授权权限不够。比如账号对那个仓库没有管理员权限,drone 就没法替它注册 webhook。这种情况需要换一个有权限的账号,或者去仓库设置里手动补一条 webhook。

2.3 密钥管理:不要在 YAML 里裸奔

自动化部署免不了要接触各种密钥:镜像仓库的账号密码、SSH key、云厂商 token。这些绝不能直接写在.drone.yml里,因为配置文件通常会提交进代码仓库,等于把密钥公开了。

drone 提供了 Secret 机制,可以在控制台里给某个仓库设置密钥,也可以在命令行里设置:

# 安装 drone 命令行工具后 drone secret add --repository owner/repo --name registry_username --value your-username drone secret add --repository owner/repo --name registry_password --value your-password

在流水线里引用时,用from_secret读取:

steps: - name: push image: plugins/docker settings: username: from_secret: registry_username password: from_secret: registry_password

需要特别提醒的是:secret 只是对最终日志做了隐藏,它本身仍然是明文存在 server 的数据库里。所以 server 的数据库和备份文件本身也要当作敏感资产对待,别扔在裸磁盘上不做任何保护。

注意:Secret 的可见范围按仓库隔离。如果你有多个仓库要共用同一套部署密钥,最好提升到 organization 级别去配置,否则每个仓库都得重新填一遍,后面改密码时非常痛苦。

3. 手写一份可复用的 drone.yml:从代码提交到线上发布

3.1 流水线骨架与 workspace 挂载逻辑

drone 流水线的核心就是.drone.yml。先看一个最简骨架:

kind: pipeline type: docker name: build workspace: base: /workspace path: src/app steps: - name: test image: golang:1.22 commands: - go test ./...

顶层kind: pipeline表示这是一个流水线,type: docker表示使用 docker runner 执行,name是这条流水线的名字。workspace是所有 step 共享的工作目录。假设你的仓库是某公司的 app 仓库,drone 会把代码 clone 到/workspace/src/app下面。前面步骤生成的文件,后面步骤在这个路径下还能看到。

这里有个细节:drone 默认会帮你 clone 代码,每个 step 启动时当前目录就是 workspace 中的仓库根目录。所以 commands 里写相对路径就行,不用反复 cd。

3.2 多阶段构建:从单元测试到镜像推送

实际项目里,流水线至少会拆成测试、编译、镜像、部署四个阶段。阶段之间用depends_on显式声明依赖,drone 才会按顺序执行。

下面是我当时在 Go 服务上用的完整配置,你可以参考改造:

kind: pipeline type: docker name: build workspace: base: /go path: src/app steps: - name: test image: golang:1.22 commands: - go mod download - go test -race -cover ./... depends_on: [] - name: compile image: golang:1.22 commands: - CGO_ENABLED=0 GOOS=linux go build -o app ./cmd/server depends_on: - test - name: push image: plugins/docker settings: repo: registry.internal/app tags: ${DRONE_BRANCH} registry: registry.internal username: from_secret: docker_username password: from_secret: docker_password depends_on: - compile when: branch: - main - dev - name: deploy-dev image: appleboy/drone-ssh settings: host: 192.168.1.20 username: deploy key: from_secret: ssh_key script: - docker pull registry.internal/app:dev - cd /srv/app && docker compose up -d app-dev depends_on: - push when: branch: dev - name: deploy-prod image: appleboy/drone-ssh settings: host: 192.168.1.10 username: deploy key: from_secret: ssh_key script: - docker pull registry.internal/app:main - cd /srv/app && docker compose up -d app-prod depends_on: - push when: branch: main

这段配置里值得展开讲几个点。

tags: ${DRONE_BRANCH}是 drone 的内置变量。每次构建时,它会自动替换成当前分支的名字。比如 dev 分支 push 后,镜像 tag 就是 dev;main 分支 push 后,镜像 tag 就是 main。你不需要在 YAML 里硬编码死 tag,发布环境自然就按分支区分了。

when块是条件触发。deploy-dev 只有当分支是 dev 时执行,deploy-prod 只有在分支是 main 时执行。这样一条流水线就可以覆盖测试环境和生产环境,不需要为每个环境单独维护一份配置文件。

depends_on比step 顺序更可靠。drone 默认每个 step 在 Docker 容器里独立运行,如果某个 step 写错了依赖关系,它可能提前并行执行。比如 compile 依赖 test,就应该显式写出来,否则两个步骤可能同时跑,编译时依赖的测试产物可能还没生成。

经验:不要把 deploy 步骤直接写在 push 后面,除非你有十足的把握镜像一定 push 成功。分开写的好处是,如果 push 失败,deploy 根本不会被执行,避免线上拉到一个不存在的镜像然后服务全部挂掉。

3.3 部署环节:远程服务器如何拿到新镜像

部署这一步很多人会纠结:到底是让 drone 直接 ssh 到服务器执行命令,还是在服务器上装一个 agent?

我试下来最简单的做法是:drone 通过 SSH 插件远程登录目标服务器,在服务器上执行docker pull和docker compose up -d。这样服务器上只需要有 docker 和 docker compose,不需要安装任何额外 agent。

上面配置里用的appleboy/drone-ssh插件,支持 host、username、key 这些参数。SSH key 通过from_secret读取,确保密钥不落到仓库里。

为什么推荐在服务器本地 pull,而不是在构建节点构建完直接把镜像推到服务器?因为 docker 的 image 是分层结构,本机 pull 过之后,如果新镜像只有一层变了,那一次传输的数据量小很多;如果你每次都把整个镜像包传到服务器,带宽和耗时都会很难看。在服务器本地 pull 另一个好处是回滚方便,旧 tag 还在本地,切回去基本是秒级。

如果目标服务器需要登录私有镜像仓库,记得提前在服务器上执行docker login,或者用docker compose的credentials配置,否则 pull 时会报认证错误。

3.4 缓存与并发:怎么让流水线更快,少踩“每次全量装依赖”的坑

drone 的每个 step 都是全新容器,这意味着默认情况下,go mod download、npm install这类操作每次构建都会全量执行一遍。项目小的时候还能忍,依赖一多,你会发现构建时间被依赖下载吃掉了大半。

解决办法是挂缓存卷。单节点场景下,我用宿主机目录做缓存最简单:

volumes: - name: gocache host: path: /var/cache/drone/go steps: - name: deps image: golang:1.22 volumes: - name: gocache path: /go/pkg/mod commands: - go mod download

这样go mod download下载的依赖模块会落在宿主机/var/cache/drone/go目录里,下一次构建直接复用,构建时间能从五分钟压到一分钟以内。

多节点 runner 的场景下,每个节点本地挂一个缓存目录可能不一致,更稳妥的方案是挂对象存储,用专门的 cache 插件把缓存上传到 S3 兼容服务,构建前再下载回来。这块配置会比单节点复杂,团队规模不大时可以先不上。

3.5 按标签发布:生产环境还是用 tag 更安全

上面例子是按分支部署到不同环境,但生产环境我更建议配合 tag 发布。原因很简单:分支是一个持续变动的引用,main 分支今天这个提交,明天可能就在别的提交上了。线上代码必须能追溯到精确版本。

做法是在流水线里追加一段生产发布流程:

- name: deploy-prod image: appleboy/drone-ssh settings: host: 192.168.1.10 username: deploy key: from_secret: ssh_key script: - docker pull registry.internal/app:${DRONE_TAG} - cd /srv/app && docker compose up -d app when: event: tag

${DRONE_TAG}是 drone 内置的 tag 变量。触发条件event: tag表示只有推送 tag 时才执行这一步。你在本地打 tag 并 push 后,drone 会构建一个 tag 版本,并且只对生产环境发布。日常 dev 分支怎么推都不会影响线上,只有你主动发 tag 才意味着真正发版。

这套模式结合镜像 tag 的不可变性,相当于给发布加了一个闸门。万一新版本上线出问题,回滚到上一个 tag 也非常直接。

4. 自动化部署排障实录:docker 权限、镜像缓存、回滚方案

4.1 一上来就踩的 docker socket 权限坑

用 docker runner 最常见的问题,就是构建步骤报权限错误:

dial unix /var/run/docker.sock: connect: permission denied

原因很简单:runner 容器里要访问宿主机的 docker socket,但容器内用户权限不够,宿主的 socket 又不允许非 root 用户访问。

我当时的处理方案有两个,根据情况选择。

方案一:直接给 runner 容器加 root。runner 镜像默认可能就是 root 用户,但如果你的运行环境限制了容器用户,需要显式加user: root或者在宿主机上调整/var/run/docker.sock的属组。这个方案最简单,但要把 runner 容器本身当作高权限服务对待,避免攻击面扩大。

方案二:用 Docker-in-Docker(DinD)。在流水线里挂一个 dind service,构建步骤通过 TCP 访问这个 service 来操作 docker。配置大概长这样:

services: - name: docker image: docker:dind environment: - DOCKER_TLS_CERTDIR= steps: - name: build-image image: docker environment: - DOCKER_HOST=tcp://docker:2375 commands: - docker build -t app .

这个方案的好处是构建过程不再依赖宿主机的 docker daemon,隔离性更好。缺点是多了一层 dind 容器,性能有一点损耗,且两个容器网络通信需要配置正确,排障起来更绕。

小团队只有一台构建机的场景,我还是推荐方案一,简单直接,少绕路。等规模大了、安全要求高了,再切 dind 也不迟。

4.2 私有镜像仓库登录失败,镜像推不上去

另一个高频问题是 push 私有镜像时报 unauthorized 或者 authentication required。排查思路按下面顺序走:

  1. 确认 secret 有没有配置对。from_secret引用的名字必须在仓库 secret 里存在,拼写不一致是最常见的低级错误。
  2. 确认 registry 参数。plugins/docker插件的registry要填镜像仓库的地址,比如registry.internal。如果镜像地址是registry.internal/app,但 registry 参数漏了,插件会默认推到 Docker Hub,认证当然失败。
  3. 终端先手动登录一次:docker login registry.internal。如果命令行都登不进去,基本可以断定是账号密码错或仓库服务本身有问题,和 drone 无关。

4.3 流水线卡在拉镜像,构建超时怎么定位

drone 流水线卡住,大多数人第一反应是不是哪里死循环了。但我遇到过最多的其实是镜像拉不下来,或者镜像特别大导致超时。

先看 runner 日志。drone server 界面上的日志是流水线 step 的输出,但如果 runner 本身有问题,server 上看不到太多信息。到 runner 机器上执行:

docker logs -f drone-runner

能看到 runner 和 server 的通信状态,以及它在创建容器时拉取镜像的进度。如果镜像是从公共仓库拉取,网络状况又不理想,整个构建可能全卡在这一步。解决办法是给 docker daemon 配置registry-mirrors之类的公共镜像加速服务,改完记得重启 docker。

构建超时本身也有缓解手段。在 step 里可以设置较长的重试和超时策略,但治本的方法还是控制流水线复杂度。如果某个 step 动辄跑二十分钟,别急着加超时时间,先去查瓶颈在哪——是不是依赖下载太慢、是不是测试用例太重、是不是构建产物太大。把大步骤拆小,并行度提上来,比单纯调超时参数健康得多。

4.4 发布失败后的回滚,必须提前想清楚

自动化部署越顺畅,越要提前考虑一件事:新版本出问题了,怎么快速回滚?

我现在的做法是“镜像 tag 即版本,发布脚本支持回滚”。线上 docker compose 里的镜像结果是写死的,比如app:1.2.3。如果 1.2.3 有问题,我在服务器上执行:

docker compose up -d --pull app-1.2.2

或者更简单粗暴一点,手动把 compose 文件里的版本改回去,重新docker compose up -d。

drone 在这里的角色是“提供回滚的弹药”。也就是说,每一个发往生产的 commit 或者 tag,都会构建并且保留镜像。当你需要回滚时,镜像已经在仓库里,不需要重新构建,直接拉旧 tag 就能起来。可以用 drone 再触发一次旧 tag 的构建,也可以直接在服务器上操作,看线上情况紧急程度。

注意:回滚不是把镜像拉回来就完事了。数据库迁移、缓存清空、日志清理这些关联动作,一定要写进回滚 checklist。否则你代码回滚了,数据库字段没回滚,照样在线上一脸懵。

4.5 常见问题速查表

现象可能原因处理方式
push 后不触发构建webhook 没注册,或 server 地址不可达检查仓库 webhook 列表,确认https://drone.example.com/hook存在且可访问
构建一直 pendingrunner 没有连上 server,或 RPC 密钥不匹配看 runner 日志,核对DRONE_RPC_SECRET
step 报 permission denieddocker socket 权限不足runner 以 root 运行,或改用 dind 方案
push 镜像报 unauthorizedsecret 未配置/拼写错误,或 registry 参数缺失检查 secret 名称,命令行手动docker login验证
流水线超时镜像拉取慢,或单步执行时间过长配置镜像加速器,拆大步骤,优化依赖缓存
部署后服务起不来镜像启动命令或环境变量不匹配登录服务器手动docker run复制启动参数,看具体报错日志

最后再分享几点我在实际使用中的体会。drone+docker 这套流程,真正用顺了之后,你会有一种“发布不过是顺带的事”的感觉。但它的顺滑是建立在前期准备上的:密钥先规划好,secret 统一管理,回滚方案提前写好。别把一个不重要的服务做实验,更别在没想好回滚路径的情况下就把生产环境接进来。先小规模跑,再逐步扩展,这套方案带来的收益一定会大于你踩坑付出的成本。

后续如果项目规模上来,还可以继续扩展:加第二台 runner 做横向扩容、用 runner 标签区分构建节点、接入钉钉或企业微信推送构建结果、加定时任务做日常巡检。drone 的插件机制让它能衔接的东西很多,但核心永远是那句:让机器把重复的事干好,人只去处理真正需要判断的事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进