使用 GitHub Actions 与 Docker 构建 CI/CD 系统

文章目录

    • 技术概述
    • 技术详述
      • CI 部分
        • 工具选择
        • 技术实现
      • CD 部分
        • 工具选择
        • 技术实现
          • 编译部分
          • 部署部分
      • CI + CD
    • 问题与解决方案
    • 总结
    • 参考文献

技术概述

CI/CD 即持续集成/持续部署,是一种软件开发实践,通过自动化的软件流程来构建、测试、部署软件。通过使用 CI/CD,开发团队可以更快地构建和交付出高质量的软件。

技术详述

CI 部分

工具选择

项目开始之初,我计划使用 Gitea 自建 Git,作为我们项目的代码仓,使用 Drone 作为 CI 工具。考虑到大部分组员还不会熟练使用 Git,我便改用了 GitHub 作为代码仓,因为其提供了 GitHub Desktop 这一桌面客户端,比较照顾敲不来命令的同学(项目刚开始不就大家就能较为熟练地使用 Git,非常欣慰)。

正好 GitHub 向公开仓库提供了免费的 Actions 工具,我便决定使用 GitHub Actions 作为 CI 工具。GitHub Actions 与 Drone 相比,有以下几个优点:

  • 无需自建服务器,无需自己维护 CI 服务器
  • Actions Markets 提供了大量 CI 工具模板,无需每一个流程都手动创建(例如编译 Docker Image 由自己创建将非常麻烦)
  • 与 GitHub 其他服务(特别是 Pull Request)结合紧密,可以将测试执行结果直接反馈到 Pull Request 中
技术实现

想要使用 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 检查脚本。

CD 部分

工具选择

若使用 Jekins 之类的工具,可以实现代码仓、CI、CD 一套搞定。但 Jekins 实在过于沉重(还是用 Java 写的),不考虑用这类工具。

自动部署的方式有很多:

  • 可以选择直接运行前/后端的代码,前端用 pm3,后端直接运行 go 就可以了;
  • 也可以使用 Docker 进行部署,编写好 docker-compose 配置文件后,一句命令就可以运行整个项目;
  • 其他奇技淫巧…

考虑到使用的简便性,我选择了使用 Docker 部署前后端程序。

In case you don’t know:

Docker 是一个开源的应用容器引擎,基于 Go 语言并遵从 Apache2.0 协议开源。Docker 可以让开发者打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。容器是完全使用沙箱机制,相互之间不会有任何接口。

这样,CD 的流程便确定了下来:

使用 GitHub Actions 与 Docker 构建 CI/CD 系统_第1张图片

技术实现
编译部分

编译部分,使用与 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 部分结合起来,就是一个完整的 CI + CD 流程了。

流程图如下:

使用 GitHub Actions 与 Docker 构建 CI/CD 系统_第2张图片

问题与解决方案

因为我曾经用过 GitHub Actions,Docker 使用也较为熟练,没有遇到很多问题。

唯一难住我的就是如何使用 Matrix 来构建多个 Docker Image,最后是在 Docker 官方文档里看到了解决方案。

总结

GitHub Actions 作为 CI/CD 的工具非常方便,文档也很丰富,使用起来也很简单。

有时候,不用拘泥于例如 Jekins 这样庞大的工具,也可以尝试将平常使用的一个个工具串在一起,不仅可以实现目标的效果,有时还能做到更轻量、更灵活。

参考文献

  • GitHub Actions 官方文档
  • Docker 官方文档

你可能感兴趣的:(github,docker,ci/cd)