Article

从 Docker 容器化到 GitHub Actions 自动化 CI/CD 工作流

前言

在开发多服务的前后端分离项目时,本地能跑通往往只是万里长征第一步。一旦项目里同时包含用户端前端、管理端前端、多模块 Java 业务后端,外加 MySQL、Redis 和 Nginx,传统的“手动装环境、手动打 JAR 包、手动传服务器重启”就会变得非常脆弱——漏改一个端口、JDK 小版本不一致或者依赖没更新,都会让部署卡上半天。

为了把团队从重复的手工部署里解放出来,整套工程的交付方式可以顺着一条很清晰的路线演进:

text
编写 Dockerfile(固化单个服务的编译与运行环境)
       ↓
引入 Docker Compose(编排多容器网络、端口与数据卷)
       ↓
接入 GitHub Actions CI(代码提交后自动构建镜像并做启动探测)
       ↓
延伸至持续交付 CD(推送版本化镜像、缓存加速与服务端拉取更新)

这篇笔记结合实际项目中的配置与排错经历,把从零开始容器化到搭建 GitHub Actions 自动化 CI/CD 工作流的关键技术点总结一遍。

单服务容器化:编写多阶段 Dockerfile

想要自动化运行,第一步是把每个服务“怎么编译、怎么启动”写进 Dockerfile,消除不同机器之间的环境差异。

1. 分离编译环境与运行环境

前端需要 Node.js 和 npm 打包,后端需要 JDK 和 Maven 编译,但服务真正跑起来的时候根本用不着这些庞大的编译工具链。因此前后端统一采用 Docker 多阶段构建(Multi-stage builds):

  • 前端镜像:第一阶段(builder)基于 Node 镜像安装依赖并执行 npm run build;第二阶段只把打包生成的纯静态文件拷入轻量运行镜像中。
  • 后端镜像:第一阶段在 Maven + JDK 镜像中编译出可执行 .jar;第二阶段换用精简的 JRE 镜像,仅拷贝最终产物运行。

在写后端 Maven 构建阶段时,还有一个很实用的分层技巧:先单独 COPY 父工程和各子模块的 pom.xml 预拉取依赖,再 COPY src 源码目录执行编译。这样只要没改依赖项,平时提交业务代码时就能直接命中前面的依赖缓存层。

2. 对齐前端打包目录与构建上下文

多阶段构建里最容易顺手写错的一行是 COPY --from=builder。很多模板默认写死拷贝 /app/dist,但实际前端工程未必把产物输出到 dist。

例如项目里的两个前端子工程,在各自的 vite.config.ts 中分别将 build.outDir 设为了 ueit-user-web 和 ueit-admin。写 Dockerfile 前必须先核对打包配置里的真实输出路径:

dockerfile
# 拷贝 Vite 实际配置的 outDir 目录,而非默认的 dist
COPY --from=builder /app/ueit-user-web ./dist

同时还要留意 Docker 构建上下文(Build Context) 的范围:COPY 只能读取上下文目录以内的文件。多模块仓库在规划目录时,需要先定好是以仓库根目录还是子模块目录作为构建上下文,确保源码和配置文件路径能对得上。

3. 统一基础镜像版本与文件名规范

把本地工程搬进容器并在不同系统间构建时,有两处细节很容易踩坑:

  1. 基础镜像与编译目标版本对齐:当父 pom.xml 中的 java.version 和 maven.compiler.target 仍为 1.8 时,直接套用 JDK 17 基础镜像容易遇到模块移除或兼容报错。容器化前应先核对工程配置,让构建阶段 JDK 与运行阶段 JRE 版本保持一致(如统一使用 Temurin 8)。
  2. Dockerfile 文件名大小写敏感:macOS 默认文件系统不区分大小写,把文件名写成小写的 dockerfile 在本地往往不会报错;但推到 Linux 环境(包括 GitHub Actions 的 Ubuntu Runner)后,默认查找首字母大写的 Dockerfile 就会直接失败。统一使用标准大写命名能省掉不少跨平台麻烦。

多服务本地编排:使用 Docker Compose 串联全栈

单个镜像能打出来之后,下一步是用 docker-compose.yml 把分散的容器组装成一套能协同工作的完整系统。

1. 服务拓扑与内部网络规划

在项目的 Compose 配置中,整套业务栈由六类服务组成:

服务名角色说明与端口规划
user用户端前端服务,容器内监听 5001 端口
admin管理端前端服务,容器内监听 5002 端口
backendJava 多模块业务后端,映射 16000、16002 端口
mysql业务数据库,挂载独立命名数据卷持久化存储
redis缓存服务,挂载独立命名数据卷持久化存储
nginx统一入口反向代理,对外暴露 6001(用户端)与 6003(管理端)端口

Compose 会自动为同一编排文件下的服务创建共享网络,Nginx 转发和后端连库都可以直接使用 backend、mysql、redis 等服务名作为主机名通信,不再依赖写死的机器 IP。

2. 容器粒度取舍:单容器多进程的隐患

在早期版本里,后端曾采用过一种“省事”的打包方式:把管理端、用户端和移动端三个 Spring Boot JAR 包打进同一个镜像,再用下面的 start.sh 脚本一起后台启动:

bash
JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"

java $JAVA_OPTS -jar wdd-admin.jar & admin=$!
java $JAVA_OPTS -jar wdd-user-web.jar & user=$!
java $JAVA_OPTS -jar wdd-user-mobile.jar & mobile=$!

wait $admin $user $mobile

虽然这样少写了几个容器定义,但在实际运维和 CI 检查中隐患很大:

注意

单容器运行多后端进程的隐患

  1. 容器内存超卖引发 OOM:三个 JVM 都带上了 -XX:MaxRAMPercentage=75.0,意味着每个进程都认为自己最多能占容器总内存限制的 75%,并发一上来极易撑爆容器内存上限。
  2. 缺乏独立生命周期与信号处理:后台 & 加 wait 无法像独立容器那样对单个崩溃的子服务做精准重启,也缺少对 SIGTERM 停止信号的规范转发,三个服务的日志还会混杂在一起难以排查。

同样地,早期前端容器里先用 Node 的 serve 起静态服务、外层再挂 Nginx 反代也略显冗余。遵循“一个容器只跑一个核心进程”,或者直接把前端编译产物交给 Nginx 容器托管,整套编排会稳健得多。

3. 容器启动顺序与健康检查

多容器一起 up 的时候,最常见的问题是“后端启动太快,MySQL 还没初始化完,结果后端连库失败直接退出”。

需要注意,Compose 里普通的 depends_on 只管容器进程启动的先后,不管容器里的数据库是否已经准备好接受连接。可靠的做法是给 mysql 和 redis 配上 healthcheck 探针,并在后端服务里使用 condition: service_healthy 等待依赖真正就绪后再启动(详见 Compose 启动顺序控制文档)。

接入 GitHub Actions:实现自动化 CI 构建与集成验证

有了 Dockerfile 和 docker-compose.yml 之后,就可以把过去靠人手敲的验证步骤搬上 GitHub Actions,让每次 push 和 pull_request 都自动跑一遍“从零构建 + 启动联调”。

1. 从独立打包演进到整栈启动测试

最初配置 Actions 时,很容易只写几个互不相干的 docker build 任务。但单看镜像构建成功,并不能保证各容器拼在一起时端口映射、环境变量和 Nginx 转发配置也是对的。

因此在后续完善的工作流(监听主分支与开发分支的推送及 PR 事件,见 GitHub Actions 触发事件说明)中,我们把验证升级成了整套 Compose 编排的端到端启动检查:

text
Checkout 检出代码 → 配置 Docker Buildx
          ↓
执行 docker compose build 构建全量服务镜像
          ↓
执行 docker compose up -d 在 Runner 后台拉起整套服务
          ↓
轮询执行 curl -f 探测 Nginx 对外端口(6001 / 6003)
          ↓
若探测失败:自动导出 docker compose logs 定位故障容器
          ↓
无论成功失败(always):清理临时容器与测试数据卷

2. 轮询探测、失败日志收集与环境清理

因为容器内应用启动需要时间,up -d 之后马上跑 curl 肯定会报连接拒绝。工作流里采用了一个最多尝试 30 次、每次间隔 5 秒的轮询脚本去探测 http://localhost:6001 和 6003 端口:一旦在时限内拿到正常的 HTTP 响应就放行通过;如果 30 次全部超时失败,则立刻在失败分支里执行 docker compose logs,把所有容器的报错现场直接留在 Actions 日志面板上。

警告

区分 CI 清理命令与线上运维命令 在自托管 Runner 或临时测试环境里,任务收尾会用 docker compose down -v 把容器连同临时卷清理干净;但向线上部署脚本迁移时务必去掉 -v,防止误删真实的业务数据库持久化卷。

3. 区分页面连通测试与后端接口验收

在搭建这类集成 CI 时,也要清楚当前探针覆盖到了哪一层:通过 Nginx 探测前端端口返回 200,能够证明镜像构建、容器网络和反向代理入口正常;但如果数据库还没有自动导入初始表结构,后端业务接口测试就无法直接跑通。

要想让 Actions 进一步覆盖到后端 API 冒烟测试,需要把干净的初始化 SQL 挂载进 CI 环境的 /docker-entrypoint-initdb.d/,等数据库健康检查通过、后端完成连接后,再去请求核心只读接口或健康检查端点。

迈向完整 CD:镜像版本托管与服务器自动更新

前面的 GitHub Actions 工作流已经完成了 CI(持续集成验证)。而要真正走完 CD(持续交付/部署) 的最后一公里——让远端服务器不用拉源码、不用装全套编译工具就能平滑更新版本——还需要把工作流从“原地构建测试”扩展为“构建推送镜像 + 触发服务端更新”。

text
代码合并主分支
     ↓
Actions 构建并通过启动检查
     ↓
推送双标签镜像(latest + Commit SHA)至镜像仓库(GHCR / 自建 Registry)
     ↓
目标服务器拉取新版本镜像并重启服务 → 健康检查验收(异常则切回旧 SHA 标签)

1. 在流水线中推送双标签镜像与配置缓存

在 CI 验证通过后,紧接着就可以把构建好的业务镜像推送到 GitHub Container Registry(GHCR)或团队自建的私有镜像仓库(如 Harbor、Gitea Container Registry 等,参考 容器镜像仓库推送说明)。推送凭据统一存放在仓库的 Actions Secrets 中,通过标准输入安全登录:

bash
printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io \
  --username "$REGISTRY_USER" --password-stdin

docker build \
  -t ghcr.io/owner/app-backend:latest \
  -t "ghcr.io/owner/app-backend:${GITHUB_SHA}" .

docker push ghcr.io/owner/app-backend:latest
docker push "ghcr.io/owner/app-backend:${GITHUB_SHA}"

这里强烈建议每次发布同时打上 latest + ${GITHUB_SHA} 双标签:latest 作为测试或默认跟踪的浮动入口,而不可变的提交哈希标签则让线上运行的每一个容器都能精准追溯到某一次 Git 提交。一旦新版本上线后发现异常,只需把服务器 Compose 配置里的镜像标签指回上一个正常的 SHA 就能快速回滚。

此外,由于云端托管 Runner 每次都是全新的虚拟机环境,单靠 Dockerfile 分层还留不住上次构建的缓存。在 GitHub Actions 中配合 docker/build-push-action 开启 type=gha(GitHub Actions Cache)或 Registry 缓存后端(详见 Docker Buildx 缓存后端文档),再结合语言级包管理器缓存(如 Maven 仓库缓存或 Python 服务的 uv 缓存挂载),才能在云端流水线里真正省下重复编译时间。

2. 部署端更新策略与自托管 Runner 注意事项

镜像推入仓库后,完成最后一跳的服务器更新通常有两种落地方式:

  • Actions 主动触发更新(Push 模式):在 CD 阶段通过受限的 SSH 密钥连接目标服务器,在部署目录下执行 docker compose pull && docker compose up -d 拉取新镜像并重建变更的服务容器。
  • 宿主机定时巡检更新(Pull 模式):如果服务器使用 Podman 配合 systemd Quadlet 托管容器,也可以在单元文件中配置 AutoUpdate=registry 并开启 podman-auto-update.timer 定时巡检镜像仓库完成拉取重启(详见 用 Podman 和 Quadlet 管理家庭服务器容器)。

如果因为内网穿透或构建速度考虑,选择在自己的 Linux 服务器上部署自托管 Runner,则要重点留意 Runner 的执行隔离:如果 Runner 自身就跑在容器里,给 Runner 挂载宿主机 socket 并不代表它拉起的子任务容器里一定装有 docker CLI 或拥有相同的 socket 权限,上线前要先核对 runs-on 标签与任务实际所在的运行层。

同时,自动化 CD 只应该去无缝替换无状态的前端与 API 容器。一旦某次迭代涉及 MySQL 表结构变更,数据库的备份与迁移脚本应当作为独立受控的前置步骤执行,不能把“数据变更”简单等同于一次 compose up -d。

总结

回顾从手工部署一路演进到自动化工作流的过程,每一层工具其实都在解决一个很具体的问题:Dockerfile 消灭了机器间的编译与运行环境差异,Docker Compose 理清了多服务之间的网络和启动依赖,而 GitHub Actions 则把“每次改完代码都要手动打包验证、推镜像、上服务器更新”的繁琐流程变成了可自动重复、失败留痕、随时能按版本号回退的标准流水线。把这套骨架在项目早期搭好,越往后迭代省下来的精力就越多。

从 Docker 容器化到 GitHub Actions 自动化 CI/CD 工作流

https://www.renpengyu.com/articles/xingyun-docker-actions/
作者
Penry
发布于
2026.02.23
许可协议
CC BY-NC-SA 4.0
站内搜索

找到你感兴趣的内容

文章分类
选择文章分类,匹配任意一个所选分类

多选时显示属于任一所选分类的文章。

文章标签
选择文章标签,多个标签需同时包含

多选时只显示同时包含所选标签的文章。

输入关键词开始搜索