做小项目最怕什么?不是需求改来改去,而是每次发布都要手动打包、传服务器、重启服务,一套流程走下来十几分钟,稍微有点疏漏就得再加半小时。我自己的一个业余项目本来一直这么干,直到有一次凌晨发版本漏传了一个配置文件,线上直接挂了半小时,才下决心把整个流程自动化。
那套方案我用下来几个月,稳定、免费、基本不用维护,也踩了不少坑。今天这篇文章就把完整实现拆开讲清楚,主要是四块内容:GitHub Actions 负责跑流水线,Docker 解决环境一致性和部署隔离,自动版本号解决“回滚时不知道回哪个版本”的问题,自动回滚则保证发布失败时服务不中断。适合正在用 GitHub 管理代码、手头有云服务器的同学参考,从头搭完大概两三个小时。
1. 整体设计与方案选型:为什么是 GitHub Actions + Docker
1.1 方案背景与选型对比
先说背景。我手头的小项目是一个 Web 服务,部署在一台云服务器上,代码放在 GitHub 私有仓库里。以前发布流程是这样的:本地跑测试、构建镜像、推到 Docker Hub、SSH 上服务器拉镜像、停旧容器、起新容器。看起来链路不长,但每一步都是手工操作,最大的问题是版本管理靠记忆。
后来我对比了几套常见的 CI/CD 方案:
- Jenkins:功能强大,插件生态丰富,但要在服务器上单独部署一套 Jenkins 环境,还要维护插件版本、构建节点,对小项目来说太重了。Teamcity、Drone 这类自建 CI 产品也类似,前期搭建成本都不低。
- GitLab CI:如果你的代码已经托管在自建 GitLab 上,用 GitLab CI 是顺理成章的。但纯 GitHub 用户为了 CI 去搭一套 GitLab,属于给自己找事。
- GitHub Actions:仓库本身在 GitHub 上,Actions 是原生集成,不用额外部署任何服务。免费额度对个人项目完全够用——公共仓库完全免费,私有仓库每月也有 2000 分钟的免费额度,跑一个小项目的镜像构建和部署绰绰有余。
最后我选了 GitHub Actions,核心原因就三个:零额外服务器成本、与代码仓库天然集成、工作流配置以 YAML 文件形式存在仓库里本身就属于“配置即代码”。
1.2 Docker 在这里承担什么角色
Docker 在这套方案里解决的是交付物标准化的问题。没有 Docker 的时候,代码传到服务器上要装依赖、配环境,服务器上装了什么和本地不一样,跑出问题来很难排查。有了 Docker,构建阶段就把运行环境和应用程序一起打包成镜像,服务器上只需要一个 Docker 运行时。
镜像还有一个好处是天然具备版本属性。我构建出v1.0.3、v1.0.4这样的镜像标签,部署的时候指定跑哪个标签,回滚的时候只需要把容器切换到上一个标签的镜像,本质上就是“跑哪个镜像”的问题。
服务器上不需要装 Node、Python、Java 这类运行时,只需要 Docker。镜像内部有完整的运行环境,部署时一次拉取、一次切换,环境一致性问题就消失了。
1.3 自动版本号与自动回滚的设计逻辑
这两个功能是整个方案里的灵魂。自动版本号解决的是“这个镜像对应哪次提交”的问题,自动回滚解决的是“发布出问题怎么办”的问题,两者配合,实际上是给发布流程加了安全带。
自动版本号的几种常见方案:
| 方案 | 生成逻辑 | 优点 | 缺点 |
|---|---|---|---|
| 语义化版本 + 手动 Tag | 开发者发布时打v1.2.3的 git tag | 版本语义清晰,可读性好 | 依赖开发者操作,容易忘 |
| 构建号自增 | 用github.run_number作为递增序号 | 无需人工干预,保证唯一 | 无法直观表达版本语义 |
| 日期 + Commit SHA | 如20250603-1a2b3c4 | 同时包含时间与代码来源 | 稍长,但可接受 |
我实际采用的是组合策略:优先读 git tag 作为语义化版本,没打 tag 时用日期加 Commit SHA 兜底。这样日常发布时能清楚知道版本号,而每次 push 触发构建时也能保证镜像标签不冲突。
自动回滚的实现思路:在部署脚本里做一个发布检查点。先启动新容器,等几秒做健康检查,如果检查失败,就自动把容器切换回旧镜像。这个逻辑不是 GitHub Actions 的某个功能,而是部署脚本的一部分——CI 触发部署,服务器上的脚本负责任何失败的兜底处理。
2. 基础准备:仓库结构、镜像构建与访问凭据
2.1 项目目录结构安排
开工之前先把目录结构理清楚。一个标准项目仓库大概长这样:
my-app/ ├── .github/ │ └── workflows/ │ └── deploy.yml # CI/CD 主流程 ├── src/ # 项目源码 ├── app/ # 应用主目录(视语言而定) ├── Dockerfile # 镜像构建文件 ├── .dockerignore # Docker 构建时排除的文件 ├── scripts/ │ └── deploy.sh # 服务器端部署脚本(会被 CI 推送到服务器) └── requirements.txt / package.json # 项目依赖文件.github/workflows/deploy.yml是整个 CI/CD 的入口,仓库里所有自动化的逻辑都在这里定义。scripts/deploy.sh部署脚本虽然存在代码仓库里,但实际执行是在你的服务器上,这一点要注意。
2.2 Dockerfile 的写法与优化点
Dockerfile 是整个构建的核心。一个标准的 Node.js 项目 Dockerfile 可以这样写:
FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-alpine WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY package*.json ./ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD wget -qO- http://localhost:3000/health || exit 1 USER node CMD ["node", "dist/main.js"]几个要点:
多阶段构建。第一个阶段负责编译打包,第二个阶段只拷贝编译产物和运行依赖,最终镜像体积能小一大截。Node.js 的node_modules从 builder 阶段直接拷贝,避免在最终镜像里重新npm install。
HEALTHCHECK 指令是回滚机制的重要基础。Docker 会根据这个健康检查命令返回的状态判断容器是否正常。我的 Web 服务需要提供一个/health接口,返回 HTTP 200,然后 Docker 每隔 30 秒探测一次,连续 3 次失败就判定容器不健康。
注意 USER 指令。默认容器以 root 运行非常不安全,我踩过坑——容器里被写入了不该写的文件,排查了半天才发现是权限问题。创建普通用户或者直接用USER node,能让容器更安全。
.dockerignore 也不能省。否则本地的.git目录、node_modules、测试文件都会被复制进构建上下文,不仅构建慢,还可能把敏感信息带进去。以下是一个基础模板:
.git node_modules dist *.log .env Dockerfile .dockerignore2.3 GitHub Secrets 与权限配置
GitHub Actions 在流水线执行时,需要访问三类敏感信息:服务器 SSH 私钥、服务器主机信息和 Docker 镜像仓库的访问凭证。这些绝不能写在代码里,要放进仓库的 Settings -> Secrets and variables -> Actions 里。
我实际用到的 Secrets 大概这些:
| Secret 名称 | 用途 | 示例 |
|---|---|---|
SERVER_HOST | 服务器 IP 地址 | 123.45.67.89 |
SERVER_USER | SSH 登录用户名 | root或ubuntu |
SERVER_PORT | SSH 端口,默认 22 | 22 |
SSH_PRIVATE_KEY | 用于登录服务器的私钥 | -----BEGIN OPENSSH PRIVATE KEY-----... |
关于镜像仓库,我建议直接用GHCR(GitHub Container Registry)而不是 Docker Hub,原因有两点:一是 GitHub Actions 内置了GITHUB_TOKEN,权限自动够用,不需要额外申请 Docker Hub 的 Access Token;二是镜像跟代码放在同一个平台,推送拉取不需要绕第三方。
第一次推送 GHCR 前,需要在仓库 Settings 里打开写入权限。路径是 Settings -> Actions -> General -> Workflow permissions,勾选Read and write permissions。
另外注意 GHCR 镜像名的命名规则:ghcr.io/用户名/仓库名。对于私有仓库,服务器拉取镜像时同样需要凭据,需要配置一个GHCR_TOKEN让服务器登录 GHCR。创建方法是在 GitHub 头像 -> Settings -> Developer settings -> Personal access tokens -> Tokens (classic),勾选read:packages权限,生成的 token 存到GHCR_TOKEN这个 Secret 里。
3. 核心工作流:构建、推送、部署、回滚的完整实现
3.1 触发条件与版本号生成
部署流程要控制触发时机,不是每次随便一推都部署。我的deploy.yml里触发条件是这样设计的:
on: push: branches: - main paths: - 'src/**' - 'app/**' - 'Dockerfile' - 'package.json' - '.github/workflows/deploy.yml' workflow_dispatch:触发分支限定main,同时通过paths过滤,只有核心目录或 Dockerfile 变化时才触发部署。这样做的好处是 README 更新、文档调整这类提交不会白白触发一次完整构建。
然后就是版本号的生成逻辑,我用了一个组合方案:
env: IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/my-app SEMVER_TAG: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', github.run_number, github.sha) }}这个表达式看起来有点绕,解释一下:如果是 tag 触发的构建(比如你手动打了v1.2.0的 tag),版本号直接用v1.2.0;如果是普通 push 触发,版本号用run_number(构建序号)加 Commit SHA 前几位,比如123-4f7a2b9。
采用这种方案的好处是:日常 push 产生的镜像有唯一标识,不会互相覆盖;而在真正发版的时候打 tag,版本号语义清晰,方便后续回溯。最核心的诉求是,回滚时你看到的版本号能对应到明确的代码提交,而不是latest这种无法追溯的模糊标签。
3.2 构建镜像并推送到 GHCR
完整的工作流分为构建和部署两个 Job。构建 Job 负责编译镜像并推送,部署 Job 负责在服务器上拉取镜像并切换容器。下面这行是主流程的核心片段:
jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v6 with: context: . push: true tags: | ${{ env.IMAGE_NAME }}:${{ env.SEMVER_TAG }} ${{ env.IMAGE_NAME }}:latest cache-from: type=gha cache-to: type=gha,mode=max这里有个细节值得展开说:docker/build-push-action是官方维护的 Action,把docker build和docker push封装成一步,并且天然支持跨平台构建和多阶段缓存。它默认使用 BuildKit,配合setup-buildx-action可以启用 GitHub Actions 专用缓存(type=gha),第二次构建时会快很多,因为大多数层的缓存都能直接命中。
推送时会打两个标签:一个带版本号、一个latest。带版本号的镜像用于部署和回滚,latest方便在服务器上排查问题时知道当前最新构建长什么样。
3.3 部署与健康检查脚本
部署 Job 需要在服务器上执行远端脚本。我的做法是先用scp把scripts/deploy.sh推送到服务器,再通过 SSH 执行,保证服务器上的部署脚本和代码仓库始终同步。
deploy: needs: build runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Upload deploy script to server uses: appleboy/scp-action@v0.1.7 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ secrets.SERVER_PORT }} source: "scripts/deploy.sh" target: "~/deploy" strip_components: 1 - name: Execute deploy script on server uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ secrets.SERVER_PORT }} script: | chmod +x ~/deploy/deploy.sh ~/deploy/deploy.sh \ --image "${{ env.IMAGE_NAME }}" \ --tag "${{ env.SEMVER_TAG }}" \ --port 3000 \ --container my-app为什么用scp-action+ssh-action而不是直接在 SSH 命令里内联整个部署逻辑?因为部署脚本比较长,直接嵌在 YAML 里既不容易维护,也不方便本地调试。把脚本独立成文件,在本地也能手动执行,排查问题的时候灵活得多。
服务器端的deploy.sh是这个方案里的核心文件,完整内容如下:
#!/usr/bin/env bash set -euo pipefail # 解析参数 IMAGE="" TAG="" PORT="3000" CONTAINER="my-app" HEALTH_URL="http://localhost:${PORT}/health" while [[ $# -gt 0 ]]; do case $1 in --image) IMAGE="$2"; shift 2 ;; --tag) TAG="$2"; shift 2 ;; --port) PORT="$2"; shift 2 ;; --container) CONTAINER="$2"; shift 2 ;; *) echo "Unknown arg: $1"; exit 1 ;; esac done if [[ -z "$IMAGE" || -z "$TAG" ]]; then echo "Error: --image and --tag are required" exit 1 fi OLD_TAG="" OLD_IMAGE="" # 如果容器已存在,记录当前镜像 tag 作为回滚备份 if docker inspect "$CONTAINER" >/dev/null 2>&1; then OLD_IMAGE="$(docker inspect --format='{{.Config.Image}}' "$CONTAINER")" OLD_TAG="$(echo "$OLD_IMAGE" | sed 's/.*://')" || true echo "Existing container found. Current tag: ${OLD_TAG:-none}" fi # 登录 GHCR echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin # 拉取新镜像 echo "Pulling new image: ${IMAGE}:${TAG}" docker pull "${IMAGE}:${TAG}" # 记录当前容器启动时间,作为快速回滚判断依据 START_TIME="$(date +%s)" OLD_CONTAINER_ID="$(docker inspect --format='{{.Id}}' "$CONTAINER" 2>/dev/null || true)" # 启动新容器 echo "Starting new container..." docker stop "$CONTAINER" 2>/dev/null || true docker rm "$CONTAINER" 2>/dev/null || true docker run -d \ --name "$CONTAINER" \ --restart unless-stopped \ -p "${PORT}:3000" \ -e "TAG=$TAG" \ -e "DEPLOYED_AT=$(date -Iseconds)" \ --label "app.version=$TAG" \ "$IMAGE:$TAG" # 健康检查:轮询最多 30 秒 RETRY_COUNT=0 MAX_RETRY=10 for i in $(seq 1 "$MAX_RETRY"); do sleep 3 if curl -sf "$HEALTH_URL" >/dev/null 2>&1; then echo "Health check passed on attempt ${i}" break fi RETRY_COUNT=$i echo "Health check attempt ${i} failed" done if [[ "$RETRY_COUNT" -eq "$MAX_RETRY" ]]; then echo "Health check failed after ${MAX_RETRY} attempts. Rolling back..." # 回滚逻辑 if [[ -n "$OLD_TAG" && "$OLD_TAG" != "none" ]]; then docker stop "$CONTAINER" || true docker rm "$CONTAINER" || true docker run -d \ --name "$CONTAINER" \ --restart unless-stopped \ -p "${PORT}:3000" \ -e "TAG=$OLD_TAG" \ --label "app.version=$OLD_TAG" \ "$IMAGE:$OLD_TAG" echo "Rolled back to ${OLD_TAG}" else echo "No previous container found, cannot rollback." exit 1 fi fi # 清理 7 天前的旧镜像(保留最近版本以便回滚) docker image prune -f --filter "until=168h" echo "Deploy finished."脚本里每个步骤都有意图。记录旧容器镜像的 tag,是为了回滚时有据可依;用--label "app.version=$TAG"给容器打上版本标签,方便后续排查时一眼看出当前跑的是哪个版本;健康检查循环最多 30 秒,是因为我的应用启动大约需要 10 到 15 秒,一些服务首次启动还要做数据库迁移,时间短了会产生误判。
3.4 自动回滚机制的完整拆解
看完上面的脚本,你可能会想:所谓的自动回滚,不就是把旧镜像重新跑一遍吗?对,本质就是这样,但实际落地有几个关键点值得展开。
第一,回滚的判断标准要独立于业务逻辑。不能拿业务接口如/api/getUserInfo来做健康检查,因为业务接口依赖数据库、缓存等外部设施,数据库临时抖动就会误判。最好单独提供一个/health接口,里面只检查进程是否存活、基础依赖是否可就绪,不参与业务计算。
第二,回滚时旧版本数据不能丢。数据通常存在数据库或者宿主机挂载的卷里,容器本身是无状态的,所以回滚容器不需要迁移数据。但如果你把业务数据写在了容器可写层里——比如文件上传到了容器内部路径——回滚之后这些数据就丢了。我启动容器时用-v把数据目录挂载到宿主机,这是上线前就必须考虑清楚的。
第三,回滚不等于无限重试。我在脚本里只做一次回滚,新容器健康检查失败后切回旧容器。但要注意,如果新版本是因为数据库结构变更导致启动失败,回滚到旧版本后,旧版本连数据库也会失败。这种场景下健康检查依然会标记旧容器失败,但至少比新版本暴露问题的时间短。真正处理这类不兼容变更,需要向前兼容的迁移策略,而不是依赖回滚。
第四,回滚的触发条件不能只看一次失败。脚本里的健康检查做了 10 次轮询,每次间隔 3 秒,连续多次失败才判定发布失败,避免因为单次网络抖动或者启动慢导致误回滚。
关于回滚后的状态确认,建议此时再看一眼容器标签:
docker inspect --format='{{.Config.Image}} {{index .Config.Labels "app.version"}}' my-app输出的镜像和标签能确认当前运行的确实是回滚后的旧版本。这是上线后排查问题时最常用的检查命令。
4. 真实踩坑记录与问题排查速查
4.1 高频问题速查表
这套方案从搭建到稳定运行,前前后后踩了不少坑,我把高频问题整理成了下面的速查表,供你排查时对照。
| 现象 | 大概率原因 | 解决办法 |
|---|---|---|
Actions 运行提示permission denied无法推送镜像 | Actions 工作流权限默认为只读 | Settings -> Actions -> General -> Workflow permissions 改为 Read and write |
构建阶段报Cannot connect to the Docker daemon | runner 环境没有启动 Docker 或 Buildx 未安装 | 加上setup-buildx-action步骤,并确保使用ubuntu-latest |
服务器拉取 GHCR 镜像提示unauthorized | 服务器未登录 GHCR 或 token 权限不足 | 重新配置GHCR_TOKEN,确认订阅项勾选read:packages |
| SSH 部署步骤连接超时 | 服务器安全组/防火墙未放行 SSH 端口 | 检查服务器防火墙规则,确认 22 端口对 GitHub Actions 出口 IP 可达 |
| 健康检查一直失败但服务其实正常 | 健康检查接口地址不对或端口映射错误 | 确认/health接口是否存在,确认-p映射的宿主机端口 |
脚本执行报docker: command not found | SSH 执行时环境变量未加载 | 用which docker查路径,在脚本中硬编码 Docker 路径或用sudo -E |
docker image prune把旧镜像删了导致无法回滚 | prune 条件设置太激进 | 保留至少 24 小时的镜像,或者用--filter "until=168h"这样的较长窗口 |
4.2 实际排查过程记录:Cassandra 阶段挂了
有一次部署完以后,服务始终 502,客户端全部超时。排查的完整思路是这样的:先登录服务器看容器状态,发现容器显示Up但没有响应。再手动 curl/health,发现超时。接着看容器日志,发现应用在连接数据库阶段不断重试——数据库是另一个容器,因为某种原因重启了,导致应用连不上。
这时候回滚脚本已经执行过了,但旧版本同样连不上数据库,所以回滚“成功”了,服务依然是坏的。这个案例说明一个道理:回滚解决的是“新代码本身有问题”的情况,不解决“依赖环境有问题”的情况。只有当新版本启动失败且旧版本可以正常启动时,自动回滚才有意义。遇到依赖问题,需要先把依赖恢复,再重新部署。
这个坑也让我给部署脚本增加了一个前置检查:部署前先检测数据库等关键依赖是否可用,不可用就直接告警,不进入部署流程。
4.3 几条独家经验总结
最后分享几条在实际操作中沉淀下来的经验,这些不太容易在文档里找到,但对稳定性很有帮助:
版本号用run_number虽方便但要意识到它的重置问题。GitHub 的run_number是仓库维度的累计值,但如果你把仓库删了重建,或者 fork 之后再跑 Actions,编号会从头开始。好在镜像标签里同时带了 commit SHA,所以即使run_number重置,SHA 也能保证标识唯一。如果需要稳定的发布语义,给发布 commit 打 tag 才是正解。
部署脚本里一定要记录部署日志。最初几个版本我的脚本几乎没有输出日志,出了问题只能重新跑一遍。后来我改成了往宿主机/var/log/my-app-deploy.log里追加部署记录,部署时间、版本号、健康检查结果都写进去。排查问题效率高很多。
开启 GitHub Actions 的并发控制。如果有多个 push 几乎同时触发部署,可能出现两个部署 Job 同时 SSH 到服务器、互相抢容器的情况。在 workflow 里加一段:
concurrency: group: deploy-${{ github.ref }} cancel-in-progress: true这样同一分支同一时间只允许一个部署任务执行,新的任务会取消正在运行的任务,避免部署互相打架。
docker image prune的窗口要留够。我一开始为了省钱把旧镜像清理得很勤快,结果有一次提前回滚发现旧镜像已经被删了,只能重新构建。实际上镜像占的磁盘空间不大,保留一周完全没问题。在生产环境,磁盘空间比一个旧镜像贵多了。
5. 扩展方向:这套架构还能怎么玩
这套 GitHub Actions + Docker + 自动回滚的骨架搭好之后,后面加功能就很方便了。我目前在上线前的流程里补了两个自动化环节:一个是 CI 阶段自动跑 lint 和单元测试,测试不通过就不执行镜像构建;另一个是发布后自动把版本号和变更内容推送到企业微信/钉钉机器人,发布结果大家第一时间能看到。
如果你有 staging 和 production 两套环境,可以在 workflow 里定义两个部署 Job 或者用 Environment 做审批控制,GitHub Actions 的 Environment 支持手动审批,push 到 main 后先部署到 staging,人工验证没问题再点按钮部署到 production。这套机制对团队协作特别有用。
数据库变更自动迁移也是可以扩展的方向,但单独依赖回滚机制处理不了——需要将迁移脚本和回滚脚本设计成可逆式操作,这一步要做很多防备,建议在充分测试并设计好迁移反转逻辑后再引入,不要一开始就上,否则数据库变更造成的故障会让自动回滚变成“自动增加复杂度”。
我还在持续调整这套方案。目前最满意的是不用再操心“服务器上跑的到底是哪个版本”了——每次检查标签,明确又清晰。如果你也还在手动发布,不妨照着这套流程搭一个,最开始只跑一个简单的构建和部署,跑顺了再加版本号和回滚,路会越走越顺。