入坑 Drone CI
如果你不了解 Drone CI 不妨看看下面这个使用场景:
我有一个日记本,之前使用 Typecho 这个 CMS 来写日记,但是书写体验不太好(没有自动保存功能,写完提交时页面卡住等等问题),丢了两次日志后,我打算切换到 Jekyll 来写,它们文档的格式都使用 markdown,所以迁移起来很方便。
由于我之前使用了 Gitea 作为个人的代码仓库。所以我的日记被我放到了 Gitea 上。
jekyll 需要将写好的 markdown 文档来编译出 html 文件,再由 nginx 提供网页服务。那么问题来了:在仓库中只有 markdown 文档,除非每次写完日记手动执行一下 bundle exec jekyll build
然后同步到 nginx 服务器的指定目录下,否则看日记时只能看 markdown ,而且还要先把仓库中所有的日记下载下来才能看。这太不优雅了!
这时引入一个 CI/CD 流程就可以解决这个问题。从上面这个场景可以很明显地看出:Drone CI 就是帮助我们完成编译与部署过程的。
这类技术有一些名词,例如 CI/CD 以及 DevOps 等,它们的详细定义,请参考维基百科,这里就不过多赘述了。
Drone CI Alternate
除了 Drone CI 外,类似的实现还有许多,例如出名的 Github CI,Circle CI,Argon CI 等等,但我的需求是开源、本地部署、能够适配现有的代码仓库以及简单好用。
值得一提的是 Gitea 有自己的 CI,支持 Gitea Runners,可以兼容 Github CI,也就是说可以把现有的 Github CI 的 pipeline 直接抄来用。但是由于其处于 Alpha 阶段,还不稳定,所以我并没有使用它。
Deploy
Drone CI 由两大部分组成:
- Drone Server
- Server 负责与代码仓库通信,获得代码更新状态,并给 runners 下达指令。
- Drone Runners
- Runners 负责编译、部署等等过程。
- 根据需求的不同,有不同的 Runner。例如需要使用 docker 可以使用 docker runner, 需要不同的处理器架构,也需要不同的 runner。
也就是说 Runners 可以有多个,而 Server 只有一个。这一点从单词的单、复数形式也可看出。本文中将说明 docker runner 的使用。
至于详细的部署流程请参考官方文档,对于 Gitea,部署完后,如果你想测试部署是否成功,则可以在仓库的 设置 -> Web钩子 -> 点开指定的钩子-> 最下方的测试推送。就可以了解 Drone Server 与 Gitea 的连接状态。
Drone CI Pipeline
Pipeline 是编译与部署的流程图,Runners 将根据 Pipeline 中的内容执行对应的步骤。你需要在需要使用 CI 的仓库中添加 .drone.yml
文件,并在该文件内编写 pipeline。
由于docker提供了环境隔离,便于测试、部署,你甚至可以把 runner 和 server 部署在同一台服务器上。下文中将以 docker pipeline 为主进行说明,其它类型的 pipeline 也大同小异。如果你还不了解 docker 请先学习 docker 再继续阅读。
docker pipeline 的执行将由如下几步组成:
- clone 当前仓库的所有内容到一个临时Volume中,它将被挂载到每一步的容器中
- 拉取当前步骤需要的容器镜像
- 运行容器,并执行pipline中的指令
- 以此 2和3 的方式执行一个或多个步骤
- 结束运行,停止并删除容器
以上步骤均在 Runner 中执行,Runner 将使用宿主机的docker 环境,所以会修改运行环境,因此不建议在生产服务器上运行 Runner,而是使用专用的服务器。
值得一提的是:如果你的服务器性能不错,或某些步骤对服务器压力不大,且步骤间没有依赖关系,但耗时较长。你可以将多个上述任务并行,以缩短构建时间。
第一个 Pipeline
文中将以构建自己的 debian 镜像为例子来写一个 pipeline:
由于网络原因,如果你经常使用 docker 镜像进行测试,你会发现更新、下载软件的速度十分缓慢,因此我希望在官方提供的镜像上更换更新源,所以我写了下面这个dockerfile:
FROM debian:12
RUN rm -rf /etc/apt/sources.list.d/debian.sources \
&& touch /etc/apt/sources.list \
&& echo "deb http://mirrors.bfsu.edu.cn/debian/ bookworm main contrib non-free non-free-firmware" >> /etc/apt/sources.list \
&& echo "deb http://mirrors.bfsu.edu.cn/debian/ bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list \
&& echo "deb http://mirrors.bfsu.edu.cn/debian/ bookworm-backports main contrib non-free non-free-firmware" >> /etc/apt/sources.list \
&& echo "deb http://mirrors.bfsu.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list \
&& apt -y update \
&& apt -y install apt-transport-https ca-certificates \
&& sed -i 's/http:/https:/g' /etc/apt/sources.list
RUN apt -y update \
&& apt -y upgrade
上面的文件保存在仓库的 debian/Dockerfile
。下面我需要使用 pipeline 来实现 docker image 的自动构建:
---
kind: pipeline
type: docker
name: debian-builder
steps:
- name: build-debian
image: plugins/docker
settings:
build_args:
- HTTP_PROXY=http://192.168.1.10:10801
- HTTPS_PROXY=http://192.168.1.10:10801
registry: example.registry.com
repo: example.registry.com/user/debian
username:
from_secret: docker_username
password:
from_secret: docker_password
tags:
- latest
- 12
- bookworm
dockerfile: ./debian/Dockerfile
可以看到这怎么和前面说的步骤不一样,这个pipeline中并没有什么 linux 命令,它需要干什么呢?其实对于这种常见的操作(制作docker镜像、同步、release),你不需要手动编写 pipeline,在插件市场中可以找到对应的插件,你只需要填写对应的参数即可。
下面是一些需要说明的参数:
step
首先这个 pipeline 只有一个 step,这一 step 包含了制作镜像并 push 到镜像服务器 example.registry.com
proxy
我使用了 build_args
这将会让构建过程的网络通信通过代理服务器,如果你的网络连接受限,不妨加入代理服务器,这个参数也可以用于其它 step 中。
secret
对于密码肯定不能直接写在 .drone.yml
中。这会让你的密码暴漏在所有能访问代码仓库的人。 Drone 提供了两种解决方案:
- 使用 Drone 自己的密码管理功能
- 使用 HashiCorp Vault
这里就不讲 Vault 的集成了,直接使用 Drone 的密码管理:
由于我需要推送到私有源,所以需要用户名和密码,对于这些插件来说,你需要打开 Drone 的管理界面,点开对应的项目,在 Settings 有一个 secret 选项用来存这类信息,例如用户名、密码、token等等。
secret 以键值对的形式存储,Drone 对值基本不做限制,根据需要,值可以是:
- 一个数字、字母密钥
- 一个包含密钥的 json
键则是:
- 在 pipeline 中使用的名字
对于这个示例 pipeline 我有两个密钥,分别是 docker_username
和 docker_password
他们的值对应我在私有源的用户名和密码。
这样我的密码就被安全地存好了,不会被别人看到。即使后续需要修改键值对,也不会直接看到原来的密码。
左侧下方的 Organization 中也有一个 secret,与上面那个不同的是,上方的 secret 仅限于该项目使用,下方组织(团队)里的 secret,则可以在多个项目中共用。如果你有 Vault 那么也是同样的道理,相对与这个简单的密码管理来说, Vault 功能要多得多。
以上就是一个基础的 pipeline 文件,它根据 Dockerfile,构建镜像、标记 tag 并使用用户名和密码完成认证,上传到私有源中。
Docker in Docker
在完成对 pipeline 的初步了解后,首先需要知道这个插件本身就是一个容器,你给它设置好环境变量,它会根据这些变量工作。现在,再猜想一下上方的插件是怎么做到构建镜像的。
很明显,在容器(插件)内部,我们还要依赖一个docker来构建镜像。如果只是一个没有 docker 的 linux 镜像是没办法完成上述操作的。
实现
Docker in Docker 简称 dind,指的是在容器中使用 docker 的概念。我以简单通俗的方式来说一下实现这个目标两种方法:
Nested
嵌套方式,容器以特权模式运行,在容器内部虚拟出来一个docker容器,类似于嵌套虚拟化
- 优点:
- 内部容器完全隔离
- 不污染宿主机环境
- 缺点:
- 运行速度慢,不建议用于编译任务
- 无法挂载主机资源
- 无法使用 docker volume
- 需要特权模式,安全性存在问题
docker.sock
直接将主机的 docker.sock 传递到容器内,容器使用它来创建新的容器,新容器和这个容器之间是平级的关系,你可以在宿主机上看到新容器。
执行任务的仍是宿主及的 docker。实际上这不叫 docker in docker 而是 docker outside of docker。但是它与 dind 的目标是一样的。
- 优点:
- 相对于嵌套方式,有更高的性能
- 可以使用宿主机上的资源
- 缺点:
- 污染宿主机环境
- 共享 sock 使安全性降低
- 错误的挂载和操作会造成系统损坏
Plan B
由上可见,这两个方式各有各的问题我更倾向于使用 docker.sock 的方法。
除此之外,要实现相同的目标,其实可以使用一个虚拟机 runner 或 shell runner,直接在系统上运行docker,这样既不会污染环境,也不会降低速度。但是,你需要更多的设备并且能够对虚拟机、物理及进行生命周期的管理。使用起来更快捷、安全,但管理麻烦且需要更多设备。
本文以 docker runner 为主,这个内容就不在这里进行讨论了。
Nested DinD Pipeline
为了说明更多内容,我们来看看嵌套的 dind 的 pipeline 是什么样子的:
---
kind: pipeline
name: default
steps:
- name: test
image: docker:dind
volumes:
- name: dockersock
path: /var/run
commands:
- sleep 5 # give docker enough time to start
- docker ps -a
services:
- name: docker
image: docker:dind
privileged: true
volumes:
- name: dockersock
path: /var/run
volumes:
- name: dockersock
temp: {}
请在创建一个空的仓库,运行上面这个 pipeline。注意:这个 pipeline 中使用了特权容器,你需要在 drone 的设置中的 Project Settings 打开 Trust 选项,信任这个仓库,这样 drone 才会运行 privilege 容器。
services
这里多出的 services 字段在 Drone 中作为为 step 中的步骤提供服务的容器,每一个 service 都作为一个单独的容器启动,来为 steps 中的步骤提供服务。它的使用场景主要用于测试步骤:如果你的程序需要使用 redis 数据库,或其它容器提供的服务,在这里可以很方便地运行。本处也是使用 service 的例子。
这里使用 privileged: true
来启动特权容器,为 step 提供 dind 环境。
volumes
volumes 是卷组,在 Drone 中有两种卷:
- 共享主机文件的卷组(类似于 docker bind volume)用于访问宿主机的文件
- 临时卷(类似于 docker named volume)临时卷只在执行 Pipeline 时存在,执行完后会销毁
这里有一个 dockersock 卷组,它是一个临时卷,它用来共享 service 中的 dind 容器下的 /var/run 来达到通过 sock 控制 dind 中的 docker 的目的,从而在 dind service 容器中创建容器。
那么我们 step 中的容器和这个 service 容器是平级的关系,你可以在宿主机上看到这两个容器。step 容器通过操作 service 的 sock 来在 service 容器中创建容器,创建的容器属于嵌套的方式,在宿主机上看不到。
该 pipeline 的 docker ps
输出将会是空的,原因是 service 容器中没有任何容器。
commands
commands 用于执行指令,你可以根据需要把用到的指令写在这里,这里的指令不像 Dockerfile 中的 RUN 每一各run会多一层。所以放心地写吧,需要多条命令就换行,不用使用 &&
了。
image
这里是容器要使用的镜像,这也是我喜欢 docker runner 的一点。编译环境不用每次都搭建,只需要搭建一次,做成镜像,或者将该镜像的构建放入 CI/CD 工作流中。对于一些程序,直接使用对应的公共镜像就可以。
Drone CI plugins/docker
Drone 的开发团队在 dockerhub 注册了用户名为 plugins 的用户,所以 image 才是 plugins/docker 不得不说这个想法非常牛,这使得在 .drone.yml
的可读性非常好。
docker 插件使用的是 docker outside of docker,即直接使用 docker.sock。
docker 插件有一个特性:为了安全考虑,禁用了 volume,如果你试图在这个地方挂载 volume 那么这就会造成 docker.sock 无法访问。
但是这给我们带来了很多不变,例如最终容器需要前一个步骤(例如编译)得到的结果,但是你又没办法共享卷组,下面是这类问题的解决方法。
使用外部服务
构建一个简单的暂存文件的服务,例如使用 sftp,http(s) 这种基础的协议来传输文件,将某一步的结果上传至暂存服务器,再在打包最终镜像时下载下来。
Multi-stage
充分利用 Dockerfile 的功能来实现编译、构建镜像,具体参考 Multi-stage builds。在非必要的情况下,我更推荐这种方法。这会使得 pipeline 更加简单、清晰。
多任务并行
如果需要多个 steps 那么来构建多个 docker 镜像,那么这些 steps 会依次执行,构建完一个再构建下一个。对于构建一些不需要编译操作的 docker 镜像我喜欢让多个构建过程并行。
可以在 .drone.yml
中书写多个 pipeline 来实现这个功能即:
---
kind: pipeline
type: docker
name: alpine-builder
steps:
- name: build
...
...
---
kind: pipeline
type: docker
name: debian-builder
steps:
- name: build
...
...
这样就在执行时就会实现并行构建,对于其它需要并行操作的 steps 也可以使用相同方法。
Triggers
Drone 对于所有操作,都是默认执行的,例如:对于上面这些 pipeline,只要仓库有变化,例如发生 release、commit、pull request 等等,也包含定时任务,Drone 会执行 pipeline 流程。
如果你想做一个当发生 pull request 时触发的 CI/CD 流程,你可以在 pipeline 的下方加入:
trigger:
event:
- pull_request
action:
- opened
通过 triggers 可以在同一个 .drone.yml 文件中包含多个 pipeline(类似于多任务并行的写法),但是使用 triggers 限制 pipeline 的执行,对不同行为做不同的操作。
还有一个比较有用的功能就是 cron job, 在 Drone 的 web 页面,点击对应的仓库 settings -> Cron job 即可简单得创建定时任务,填上任务名字,执行分支和间隔,这样你可以实现诸如:Nightly 版本、每月更新等等,但是请注意,不要和 .drone.yml
内的 trigger
产生冲突。
更详细的 triggers 请参考官方文档。
小结
对于构建一个自定义的镜像,和写一些基础的 pipeline 需要掌握的内容差不多就是这样。
还记得文章开头的 jekyll 使用的问题吗?这是我的解决方法:
- 我的服务器每日拉取 ruby 镜像,构建出包含 jekyll 的镜像,并推送镜像到 Gitea
- 当 jekyll 仓库更新,触发 CI 工作流
- 拉取每日构建的镜像
- 使用 jekyll 将 markdown 转换为 html
- CD 部份很简单,使用 rsync 上传到 nginx 服务器,增量更新
我总是使用最新的 Gemfile 和 Gemfile.lock 避免因为 ruby 版本升级造成版本不匹配而不可编译问题。这样就构建出了一个完整的 CI/CD 工作流,我只负责写日记就行。不过,上面这个流程还有值得优化的地方:例如使用 jekyll 的增量编译。
通过使用 CI/CD ,我的仓库里只有 markdown 日记,几个 html 页面,没有 Gemfile 和 Gemfile.lock 等文件。代码仓库只会因为有了新的日志而发生变化。
当然,CI/CD 的目的不仅仅在于使用 jekyll 写日记,这只是本文为说明工作流程的例子而已。你还可以通过各种操作、插件,实现代码仓库的更新,例如使用:git-push 插件对代码库进行更新,ansible 插件实现集群部署。如果插件满足不了你的需求,你也可以自己写 pipeline。
CI/CD 只是 Devops 中的一部分,本文也只是大致说明了 Drone CI 的使用。还有更多的内容需要去学习,请参考其它的文档。不妨写一个 pipeline 练练手吧!