CI/CD 即持续集成/持续部署,是一种软件开发实践,通过自动化的软件流程来构建、测试、部署软件。通过使用 CI/CD,开发团队可以更快地构建和交付出高质量的软件。
项目开始之初,我计划使用 Gitea 自建 Git,作为我们项目的代码仓,使用 Drone 作为 CI 工具。考虑到大部分组员还不会熟练使用 Git,我便改用了 GitHub 作为代码仓,因为其提供了 GitHub Desktop 这一桌面客户端,比较照顾敲不来命令的同学(项目刚开始不就大家就能较为熟练地使用 Git,非常欣慰)。
正好 GitHub 向公开仓库提供了免费的 Actions 工具,我便决定使用 GitHub Actions 作为 CI 工具。GitHub Actions 与 Drone 相比,有以下几个优点:
想要使用 GitHub Actions 作为 CI 工具很简单,只需先考虑清楚要对代码进行哪些测试,然后编写对应的脚本就可以了。
下面展示 YACW 项目前端 CI 测试的配置,将在配置的注释中解释如何构建这个脚本:
# 测试名称
name: ESLint
# 测试的触发器,即什么时间点执行测试
on:
# 时间点1:在 main 和 dev 分支接收 push 时
push:
branches: [ "main", "dev" ]
# 时间点2:在 main 和 dev 分支接收 pull request 时
pull_request:
branches: [ "main", "dev" ]
# 测试的任务
jobs:
# 任务1:eslint
eslint:
# 任务名称
name: Run eslint scanning
# 任务运行环境,一般设置为 ubuntu-latest,即最新 TLS 版本的 Ubuntu
runs-on: ubuntu-latest
# 配置 Actions 执行权限,这里设置为只读取代码,写入安全事件,读取 Actions 运行状态
permissions:
contents: read
security-events: write
actions: read
# 该任务的步骤
steps:
# 步骤1:检出代码
- name: Checkout code
uses: actions/checkout@v3
# 步骤2:安装前端程序所需要的依赖,还额外安装了向 GitHub 上传扫描结果的依赖(eslint-formatter-sarif)
- name: Install Dependencies
run: |
npm install
npm install @microsoft/[email protected]
# 步骤3:运行 eslint 扫描,将扫描结果输出到 eslint-results.sarif 文件中
- name: Run ESLint
run: npx eslint src/
--config .eslintrc.js
--format @microsoft/eslint-formatter-sarif
--output-file eslint-results.sarif
continue-on-error: true
# 步骤4:上传扫描结果到 GitHub
- name: Upload analysis results to GitHub
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: eslint-results.sarif
wait-for-processing: true
# 步骤5:测试编译,这一步可以检查代码是否能够正常编译(有时候,代码可以通过 eslint 扫描,但是无法正常编译,通过这一步检查出错误)
- name: Test build
run: |
npm run build
CI 相较于 CD 更多的执行 Lint 工具,配置文件较为简单。
按照上面的配置敲敲就是一份前端代码的 CI 检查脚本;稍作修改,就可以得到后端代码的 CI 检查脚本。
若使用 Jekins 之类的工具,可以实现代码仓、CI、CD 一套搞定。但 Jekins 实在过于沉重(还是用 Java 写的),不考虑用这类工具。
自动部署的方式有很多:
考虑到使用的简便性,我选择了使用 Docker 部署前后端程序。
In case you don’t know:
Docker 是一个开源的应用容器引擎,基于 Go 语言并遵从 Apache2.0 协议开源。Docker 可以让开发者打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。容器是完全使用沙箱机制,相互之间不会有任何接口。
这样,CD 的流程便确定了下来:
编译部分,使用与 CI 相同的 GitHub Actions 工具实现。
下面展示 YACW 项目前端 Docker Image 编译的 GitHub Actions 配置文件:
# 流程名称
name: Build Docker Image And Publish
# 流程触发器,即什么时间点执行流程
on:
# 比较特殊的触发器,用于手动执行 Action
workflow_dispatch:
# 时间点1:在发布 release 时编译新的 Docker Image
release:
types: ["published"]
# 流程环境变量
env:
REGISTRY_IMAGE: XXX/yacw-frontend
jobs:
# 流程1:编译 Docker Image
build:
runs-on: ubuntu-latest
# 使用矩阵策略,同时构建多个平台的 Docker Image(用于加速 Docker Image 编译)
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm/v6
- linux/arm/v7
- linux/arm64
steps:
-
name: Checkout
uses: actions/checkout@v3
# 获取 Docker Image 的元数据,用于后续的 Docker Image 编译
-
name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
# 使用了 Actions 环境变量,用于获取上方定义的环境变量
images: ${{ env.REGISTRY_IMAGE }}
# 设置 QEMU,用于在非 x86_64 平台上运行 Docker Image
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
# 设置 Docker Buildx。buildx 是 Docker Image 的编译工具
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# 登录 Docker Hub,方便后续推送 Docker Image
-
name: Login to Docker Hub
uses: docker/login-action@v2
with:
# 使用了 Actions Secrets,将不希望展示的数据放在 Secrets 内,可以在 Actions 脚本中安全地使用
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# 安装 Node.js 依赖,准备编译前端代码
-
name: Install Node Dependencies
run: |
npm install
# 编译前端代码
-
name: Build Node App
run: |
npm run build
# P.S. 后续的代码为 Docker 官方提供的示例代码,详见 https://docs.docker.com/build/ci/github-actions/multi-platform/
# 编译 Docker Image,会使用上方对 buildx 的配置
-
name: Build and push by digest
id: build
uses: docker/build-push-action@v4
with:
# 使用仓库根目录的 Dockerfile 进行编译
context: .
# 从 Matrix 配置获取当前编译的平台
platforms: ${{ matrix.platform }}
# 从上方获取的元数据中获取 Docker Image 的标签
labels: ${{ steps.meta.outputs.labels }}
# 定义编译产物的输出
outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
# 将编译产物的 digest 保存到临时文件夹,用于后续的上传
-
name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
# 上传编译产物的 digest,用于后续的合并
-
name: Upload digest
uses: actions/upload-artifact@v3
with:
name: digests
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# 流程2:合并编译产物
# P.S. 下述代码也是来自 Docker 官方的示例代码,详见 https://docs.docker.com/build/ci/github-actions/multi-platform/
merge:
runs-on: ubuntu-latest
needs:
- build
steps:
-
name: Download digests
uses: actions/download-artifact@v3
with:
name: digests
path: /tmp/digests
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ env.REGISTRY_IMAGE }}
tags: |
type=raw,value=${{ github.ref_name }}
type=raw,value=latest
-
name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
-
name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
前端程序的 Dockerfile 则是:
# 使用 nginx 作为基础镜像
FROM nginx:1.23-alpine
# 将编译产物复制到 nginx 的默认静态文件目录
COPY ./dist /usr/share/nginx/html
# 将 nginx 配置文件复制到 nginx 的配置文件目录(这里的 Nginx 配置为 Vue-Router 做了调整,不然使用默认的配置就可以了)
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
# 暴露 80 端口
EXPOSE 80
# 启动 nginx
CMD ["nginx", "-g", "daemon off;"]
前端没有使用 Docker Image 构建中的 builder 和 runner。后端有使用,这边也展示一下吧。
后端程序的 Dockerfile:
# 定义 builder,使用 golang 作为基础镜像
FROM golang:1.20 as builder
# 将项目文件复制到 builder
COPY . /server
# 设置 builder 的工作目录
WORKDIR /server
# 安装 go 依赖
RUN go get -d -v ./...
# 编译服务器端程序
RUN make build
####################
# 定义 runner,使用 alpine 作为基础镜像(体积小,资源占用少)
FROM alpine:latest
# 创建一个目录用于存放服务器端程序
RUN mkdir /go
# 设置 runner 的工作目录
WORKDIR /go
# 从 builder 中复制编译好的服务器端程序到 runner
COPY --from=builder /server/server /go
# 暴露 8080 端口
EXPOSE 8080
# 启动服务器端程序
CMD ["./server"]
这样前后端程序的 Docker Image 都编译好,并推送至 Docker Hub 了。
部署方面,我使用了 Portainer 作为容器管理工具;使用 WatchTower 检查 Docker Image 是否是当前最新版本,若有更新的 Image 则拉取并部署;使用了 Traefik 作为反向代理工具。
下面给出 docker-compose 配置文件:
# docker compose 配置文件版本
version: '3'
# 定义运行的服务
services:
# 服务1: yacw 前端程序
yacw-frontend:
# 定义使用的 Docker Image
image: XXX/yacw-frontend:latest
# 定义容器重启策略,这里是除非手动停止,否则一直重启
restart: unless-stopped
# 这里没有定义暴露的端口,交给 Traefik 自动映射
# 定义容器运行的 labels,Traefik 会根据这些 labels 自动配置反向代理
labels:
- "traefik.enable=true"
# 定义前端程序的域名
- "traefik.http.routers.yacw_frontend.rule=Host(`your-domain`)"
- "traefik.http.routers.yacw_frontend.tls=true"
- "traefik.http.routers.yacw_frontend.tls.certresolver=myresolver"
# 后端程序的配置与前端类似,这里不再赘述
yacw-backend:
image: XXX/yacw-backend:latest
restart: unless-stopped
# 定义容器的环境变量
environment:
SALT: ""
API_PATH: "/api"
# 定义映射的文件,这里将本地的 database.db 映射到容器内的 /go/database.db
volumes:
- "./database.db:/go/database.db"
labels:
- "traefik.enable=true"
- "traefik.http.routers.yacw_backend.rule=Host(`your-domain`) && PathPrefix(`/api`)"
- "traefik.http.routers.yacw_backend.tls=true"
- "traefik.http.routers.yacw_backend.tls.certresolver=myresolver"
将上述配置输入 Portainer,即可完成前后端程序的部署。
将上述的 CI 与 CD 部分结合起来,就是一个完整的 CI + CD 流程了。
流程图如下:
因为我曾经用过 GitHub Actions,Docker 使用也较为熟练,没有遇到很多问题。
唯一难住我的就是如何使用 Matrix 来构建多个 Docker Image,最后是在 Docker 官方文档里看到了解决方案。
GitHub Actions 作为 CI/CD 的工具非常方便,文档也很丰富,使用起来也很简单。
有时候,不用拘泥于例如 Jekins 这样庞大的工具,也可以尝试将平常使用的一个个工具串在一起,不仅可以实现目标的效果,有时还能做到更轻量、更灵活。