记一次基于Github Actions+Docker Compose的CI/CD改造

本文介绍了作者为个人博客后端项目搭建CI/CD自动化部署流程的实践。通过GitHub Actions实现代码推送后自动构建Docker镜像并推送到GHCR,再结合GitHub Webhook和服务器上的Webhook服务触发自动拉取镜像和重新部署。文章详细阐述了技术选型、架构设计、Dockerfile优化、工作流配置以及遇到的时区和健康检查等问题的解决方案,实现了从代码提交到服务更新的全自动化。

文章目录

为什么要折腾这个?

随着步入社会开始工作之后,我在自己服务器上折腾的频率也越来越低,目前在服务器上还活跃使用的大概也就这个博客(真的活跃吗?)和 RustDesk Server(用于远程桌面)。而 RustDesk 是通过官方的 Docker Compose 模板部署的,无需自行编译、没有复杂的配置,只要开放好对应的端口,就能做到开箱即用,让我第一次尝到了容器部署的甜头。

最近,我对博客后端做了一些小修小补,如以往一样,通过 WSL 环境编译为 linux x86 二进制(因为是 Golang 写的),再上传到服务器,最后手动重启服务。在做完一系列操作后,我突然在想,这些操作是不是都应该交给自动化流水线去完成,以节省生命中宝贵的5分钟(笑)?

我是个讨厌重复性工作的人,Let’s Encrypt 的免费 SSL 证书每三个月就需要续签一次,虽然有自动续签脚本,但还是需要手动将新证书部署到腾讯云 EdgeOne、阿里云 CDN 等服务,以保证网站的正常访问。所以我后面专门花时间写了个脚本,当证书有更新时,自动推送到各个云平台。而平时工作时,更是只要将代码合并到仓库后,手动启动一下流水线,从编译到部署等一系列操作都交由 Jenkins 等 CI/CD 平台自动完成。

所以,我决定在博客后端这个项目上实践一下 CI/CD!说干就干,我的设想是当新的提交推送到远程仓库后,触发构建,生成 Docker 镜像并推送到镜像仓库,然后通过某种方式通知到服务器上,服务器拉取最新镜像,并重新部署。

服务器镜像仓库流水线远程仓库用户服务器镜像仓库流水线远程仓库用户完成开发,推送启动流水线构建,打包镜像推送镜像推送完成事件通知拉取最新镜像拉取完成重新部署

技术选型

自动集成

既然代码托管在 Github 上,那么第一时间想到的就是Github Actions,免费、集成度高、模板丰富,使其自然而然成为了我的首选。

Github Actions 有自己的一套术语概念,包括 workflow、job、step、action 等,我们还可以引用他人创建好的 action,减少造轮子的过程。网上的教程十分完善,这里便不再赘述。

镜像仓库

除了自动集成外,Github 也提供了公共的镜像仓库服务Github Container Registry(简称GHCR),它可以直接与 GitHub 账号互通,且对于个人项目来说,可以直接利用 GITHUB_TOKEN 进行鉴权,省去了在 CI 环境中配置第三方 Docker Hub 凭证的繁琐。

需要注意的是,在没有妙妙小工具的服务器上,貌似无法直接访问 GHCR,需要通过镜像站中转。

Webhook

镜像构建完成后,服务器如何感知并更新呢?轮询显得有点傻,而定时任务又不够实时。最优雅的方式当然是事件驱动。

怎么接收消息?我选择在服务器上部署一个轻量级的 Webhook 服务(推荐使用adnanh/webhook),通过简单的配置文件,就能定义“当接收到某个 HTTP 请求时,执行某个 Shell 脚本”。

那么怎么实现事件源呢?最开始我考虑在Github Actions工作流中添加一个步骤,实现构建完成后请求 Webhook;但随后发现 Github 贴心地为我们提供了仓库粒度的Webhooks功能,借助该功能,我们可以很方便地定义 Webhook 地址,订阅我们需要的事件(这里我订阅了Workflow runs事件,当工作流开始/结束后会触发该事件),还可以添加用于消息验证的 secret,以便我们在自己的 Webhook 服务中验证事件的真伪。

仓库级别的Webhook

这样就完美打通了从构建打包到服务器重新部署的“最后一公里”。

总体架构设计

首先我们来梳理一下完整的数据流向,整个自动化流程可以分为两个主要阶段:CI(持续集成)CD(持续部署)

  1. 本地开发: 我在本地编写代码,测试无误后,打上 tag(例如 v1.0.1)并推送到 GitHub。
  2. GitHub Actions (CI):
  • 监听 tag 推送事件;
  • 拉取源码,搭建 Go 编译环境;
  • 构建 Docker 镜像,并打上对应的 tag 以及 latest 标签;
  • 将镜像推送到 GHCR。
  1. Webhook 通知: 流水线运行结束后,通过 Github 仓库的 Webhooks 机制,往服务器发送一个事件通知。
  2. 服务器 (CD):
  • Webhook 服务收到请求,通过签名头验证消息真实性;
  • 验证通过后,执行预定义的 deploy.sh 脚本;
  • 脚本执行 docker compose pull 拉取最新镜像;
  • 执行 docker compose up -d 重建容器。

核心实现

项目的容器化改造

Dockerfile 层缓存机制

首先,我们需要对博客后端进行容器化的适配改造,首先是构建镜像的 Dockerfile 脚本,这里引入了多阶段构建,将源码和编译器限定在构建阶段,以减少最终镜像的大小:

# =========构建阶段=========
FROM golang:1.25.4-alpine AS builder
# 设置工作目录
WORKDIR /build
# 安装必要的构建工具
RUN apk add --no-cache git
# 复制 go.mod 和 go.sum 文件
COPY go.mod go.sum ./
# 下载依赖(先不复制源代码,以利用build cache)
RUN go mod download
# 复制源代码
COPY . .
# 构建应用程序
RUN go build -o blackhole-blog
# =========运行阶段=========
# 使用alpine轻量级运行环境,减少镜像大小
FROM alpine:latest
# 添加时区支持
RUN apk --no-cache add tzdata
# 设置工作目录
WORKDIR /app
# 从构建阶段复制二进制文件
COPY --from=builder /build/blackhole-blog .
# 暴露端口
EXPOSE ${APP_PORT}
# 启动应用
CMD ["./blackhole-blog"]

由于 Docker 镜像的分层存储缓存机制,在构建时我们可以先复制依赖文件并下载依赖,再去复制完整源代码并构建。这样当源代码变动而依赖没有变化时,构建阶段就可以直接复用之前的构建缓存,而无需重新下载依赖。

Docker Compose 编排

Dockerfile 解决的是构建的问题,而为了更方便地管理和部署服务,我们使用 Docker Compose 来进行编排。

docker-compose.yml
version: "3.8"
services:
backend:
image: ghcr.io/username/project:latest # 指向镜像的地址
container_name: backend
restart: unless-stopped
# 端口映射
ports:
- "${HOST_IP}:${HOST_PORT}:80"
# 挂载宿主机目录,可以将配置文件和日志目录挂载到容器中
volumes:
- ${DATA_PATH}:/data
# 环境变量 - 指定配置文件路径
environment:
- BH_BLOG_CONFIG_PATH=${BH_BLOG_CONFIG_PATH}
- GIN_MODE=${GIN_MODE}
- TZ=${TZ}
# 访问宿主机上的mysql和redis
extra_hosts:
- "host.docker.internal:host-gateway"
# 健康检查
healthcheck:
test:
[
"CMD-SHELL",
"wget --quiet --tries=1 --spider http://127.0.0.1/health || exit 1",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
.env
DATA_PATH=/path/to/data
GIN_MODE=release
BH_BLOG_CONFIG_PATH=/data/conf/config.toml
HOST_IP=0.0.0.0
HOST_PORT=8080
TZ=Asia/Shanghai

利用 docker-compose 脚本 + 环境变量文件,我们便能通过docker compose up -d命令快速在服务器上部署博客后端服务,并且后续更新镜像可以用相同命令重新部署,相当于规范了整个部署流程。

Github Actions 工作流配置

在项目根目录下创建 .github/workflows/docker-build.yml。我们在工作流里用到的 action 都来自官方和社区中创建好的,这就是 Github Actions 的优势之一:依托开源社区的强大生态,提供了大量现成 action,满足开发者各种场景下的需求而无需额外开发。

name: Build and Push Docker Image
# 配置工作流触发规则
on:
push:
tags:
# 当推送了符合semver规则的tag时触发
- "v[0-9]+.[0-9]+.[0-9]+"
# 也允许手动触发
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # 添加该权限允许将镜像上传到ghcr
steps:
# 拉取仓库源代码
- name: Checkout repository
uses: actions/checkout@v4
# 登录到ghcr
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 导出版本号等元数据用于docker镜像
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest,enable=true
# 设置docker构建环境
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# 构建并推送镜像
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }} # 从导出元数据步骤的结果中获取标签
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
provenance: false

Webhook 配置

在服务器上,我们部署了 adnanh/webhook。它的配置非常简单,通过一个 jsonyaml 配置文件定义规则:

# id即为webhook路径,假设webhook绑定域名为webhook.example.com
# 则完整地址为https://webhook.example.com/hooks/hello-world
- id: hello-world
execute-command: "/path/to/deploy.sh" # 校验通过后要执行的脚本
command-working-directory: "/path/to" # 脚本执行的工作路径
trigger-rule:
and:
# 校验github签名头
- match:
type: payload-hmac-sha256
secret: 设置为在Github Webhooks中配置的secret
parameter:
source: header
name: X-Hub-Signature-256
# 校验是否为工作流开发/结束事件
- match:
type: value
parameter:
source: header
name: X-GitHub-Event
value: workflow_run
# 检查是否为工作流完成事件
- match:
type: value
parameter:
source: payload
name: action
value: completed
# 检查工作流是否成功
- match:
type: value
parameter:
source: payload
name: workflow_run.conclusion
value: success

然后将docker-compose.yml.env文件复制到脚本工作目录,并编写触发重部署的脚本:

#!/bin/bash
echo "开始拉取最新 Docker 镜像..."
if docker compose pull; then
echo "镜像拉取成功。"
else
echo "错误:镜像拉取失败。中止部署。"
exit 1
fi
echo "开始重新部署服务..."
if docker compose up -d --force-recreate; then
echo "服务 blog-backend 重新部署成功。"
exit 0
else
echo "错误:服务重新部署失败。"
exit 1
fi

然后按前面所说的,将 webhook 地址和 secret 配置到 Github Webhooks 中,配置完成后 Github 一般会发一条 demo 事件用来验证 webhook 是否可用。

至此,我们完成了整个 CI/CD 的核心实现,现在可以在 git 树中添加一个 tag,推送到 Github,然后坐下喝杯奶茶等待服务自动更新重启了。有需要的话你甚至可以在重部署脚本中加个通知,来提醒你服务部署成功/失败了。

踩坑实录

时区问题

如果从网上搜索关于容器时区设置的相关教程,大概率都是让你设置环境变量TZ=Asia/Shanghai。但是我们的容器环境使用的是alpine系统,这是一个极其精简的轻量级 Linux 发行版,默认镜像中并不包含时区信息数据,因此直接使用上述方法设置时区并不能生效。

我这边通过安装tzdata包来解决这个问题,alpine使用apk工具来管理软件包,所以只需在 Dockerfile 中添加RUN apk --no-cache add tzdata来安装这个包,即可通过环境变量TZ来设置时区。

执行命令的方式

为了实现健康检查,我在 docker-compose 中添加了healthcheck,通过调用博客后端的一个心跳接口来判断实例是否正常工作。最初通过网络搜索,打算通过以下配置来实现:

version: "3.8"
services:
backend:
# 省略其他配置...
# 健康检查
healthcheck:
test:
[
"CMD",
"wget --quiet --tries=1 --spider http://127.0.0.1/health || exit 1",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s

然后发现启动后没多久实例就被 Docker 标记为unhealthy状态了,且通过 Docker inspect 发现健康检查指令执行失败:no such file or directory: unknown(黑人问号.jpg)。但是当我手动登录容器 shell 执行该命令时,又发现能正常执行。

后来通过查阅资料、对照实验等方式,了解到CMD后接命令这种写法是直接经过内核执行该命令,而不是我们常用的 shell 环境(如shbash),这种情况下内核会把整个wget --quiet --tries=1 --spider http://127.0.0.1:${APP_PORT}/health || exit 1命令当作一个可执行文件名去查找,并且不支持 shell 中的一些特性如逻辑运算符(&&||)。所以,为了实现正确的健康检查,可以用以下任意一种方式:

  • 使用CMD-SHELL + 后续指令的方式,这样指令会通过 shell 运行;

test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://127.0.0.1:80/health || exit 1"]

  • 将可执行文件名和参数拆分开来,并去掉|| exit 1子句。

test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/health"]

这样一来,健康检查就能正常运作了。