从零给个人项目搭一套 CI/CD

从零给个人项目搭一套 CI/CD:打 tag 即发布,失败自动回滚

这篇是教程------讲清楚"应该怎么做",每一步都给出可以直接抄的配置和验收标准。

配套的另一篇是实录,讲的是我实际做的时候踩了哪些坑、每个坑的现象和根因。 如果你更想先知道"哪里会翻车",可以先看那篇;如果你想直接照着搭,看这篇。


0. 先说清楚适用边界

适合:个人项目或小团队,一台 Linux 服务器跑 Docker,代码托管在 GitHub, 应用有一个(或能加一个)HTTP 健康检查端点。

不适合,先说清楚省得白读:

  • 需要多副本滚动发布或蓝绿部署------这套是单实例替换,发布瞬间有秒级空窗

  • 数据库迁移不能中断------这套不负责迁移,得自己安排

  • 需要审批流、多环境分级(staging / prod)------这套是"一条主干直接上生产"

最终效果:推一个版本 tag,自动完成构建、推镜像、部署、健康检查; 健康检查不过就自动切回上一个能跑的版本,并且这次发布在流水线上显示为失败。


1. 先定五条原则,它们决定后面所有细节

动手之前先把这五条想明白,后面的配置就不是"抄"而是"推导"了。

原则一:构建发生在 CI,服务器只拉现成镜像。 服务器上不需要 Go、Node、甚至不需要源码。好处是攻击面小、换机器时不用重装工具链。 代价是服务器上必须能拉到镜像------这就对镜像仓库有要求(见第 7 节)。

原则二:镜像 tag 用 commit sha,绝不用 latest latest 会被下一次构建覆盖,等于"没有上一个版本",那么"回滚"这个词就没有落点了。 用 sha 的好处是双向可查:从线上版本能定位到源码,从源码能定位到镜像。

原则三:"发布成功"的定义是应用真的能响应,不是容器起来了。 docker ps 显示 Up 而端口根本没监听,是极其常见的情况------尤其当启动命令写错时。 所以判据必须是主动请求健康检查端点。

原则四:回滚落点只在健康检查通过之后写。 如果一部署就记下新版本,那么当新版本是坏的时候,下一次"回滚"会把你送回坏版本。 这是个很容易犯、但后果很隐蔽的错误。

原则五:部署脚本必须能脱离 CI 手动执行。 CI 只是它的调用者。排错时能直接在服务器上跑一次,而不是"改点东西再推一版试试", 体验差别巨大。


2. 整体结构

复制代码
本地打 tag 并推送
        │
        ▼
job 1  build-and-push(在 GitHub 的机器上)
  多阶段构建 → 推镜像到镜像仓库
        │
        ▼
job 2  deploy(在 GitHub 的机器上)
  SSH 到服务器执行 bash scripts/deploy.sh <sha 前 12 位>
        │
        ▼
服务器   scripts/deploy.sh
  拉镜像 → 重建应用容器 → 轮询 /health
        │
   ┌────┴─────┐
 通过        失败
   │          │
   │          └─ 用记录的上一版重建 → exit 1
   │
   └─ 记录当前版本 → (可选)热加载反向代理 → exit 0

链路上有四条网络通道,只有一条是 SSH:

通道 方向 传什么 量级
推镜像 runner → 镜像仓库 镜像 几十 MB
部署指令 runner → 服务器 一条命令字符串 几十字节
取代码 服务器 → GitHub git pull(只为拿部署脚本和编排文件) 几 KB
取镜像 服务器 → 镜像仓库 compose pull 几十 MB

注意第三条:服务器上 clone 仓库不是为了源码 ,源码早就编译进镜像了。 只是为了拿到 scripts/deploy.shdocker/compose.prod.yaml 这几个部署用的文件。


3. 前置准备清单

需要什么 说明
Linux 服务器 装 Docker 与 Compose v2(docker compose version 能跑通即可)
一个专用部署账户 不要用 root。加进 docker 组即可
SSH 密钥对 给 CI 用,单独生成,不要复用你日常登录的那把
镜像仓库 国内服务器建议选国内能直连的(见第 7 节)。要求:能免登录拉取
GitHub 仓库 公开或私有都行

4. 第 1 步:给应用加一个健康检查端点

这是整套方案的前提。没有它,第 5 节的原则三就落不了地。

在路由里加一条(Gin 示例):

复制代码
r.GET("/health", func(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{
        "code":    0,
        "data":    gin.H{"status": "ok"},
        "message": "success",
    })
})

健康检查该探多深

这是设计上唯一需要权衡的地方:

深度 做法 问题
太浅 只要进程活着就返回 200 数据库断了也报成功,等于没检查
太深 每次访问都去查数据库、Redis、外部 API 依赖抖动会让发布被误判为失败
折中 检查"应用自身"以及最关键的那个依赖,且带短超时 需要想清楚哪个依赖才算关键

对个人项目,我建议第一版就做最浅的那层(进程活着 + HTTP 能响应), 它已经能挡住绝大多数"启动命令写错、端口没监听、配置缺失"这类致命问题。 等真的遇到"进程活着但功能全挂"的情况,再往里加。

验收

复制代码
curl -fsS http://127.0.0.1:8080/health
# 返回 200 且是你预期的 JSON

5. 第 2 步:把编排文件拆成「开发版」和「生产版」

一份编排同时服务本地开发和生产,迟早会拧巴。拆成两个文件,各自只有一处差异: 开发版用 build:,生产版用 image:

复制代码
docker/compose.yaml         # 本地开发:build 本机源码
docker/compose.prod.yaml    # 服务器:拉现成镜像,不构建
docker/.env.example         # 入库,作模板
docker/.env                 # 不入库,权限 600

生产版必须加的几件事(每一件都对应一个真实的生产事故):

复制代码
services:
  app:
    # 故意不给默认值:忘了传 IMAGE_TAG 就直接报错,
    # 好过悄悄部署一个"上一个碰巧留下的"版本。
    image: <镜像仓库>/<命名空间>/<仓库名>:${IMAGE_TAG:?IMAGE_TAG 未设置}
​
    restart: unless-stopped
​
    ports:
      # 只绑回环地址。对外由反向代理提供,数据库端口和后台端口不直接暴露。
      - "127.0.0.1:8080:8080"
      - "127.0.0.1:8081:8081"
​
    healthcheck:
      # 探应用自己的 /health,不依赖宿主机装 curl
      test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:8080/health >/dev/null 2>&1 || exit 1"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 15s
​
    logging:
      # 不加这个,日志会慢慢把磁盘吃满
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"
​
    deploy:
      resources:
        # 小内存机器必须设上限,否则一个内存泄漏会把整台机器拖垮
        limits:
          memory: 256M
          cpus: "1.0"

数据库那一步还有个细节:如果应用连不上数据库会直接退出(很多框架的"连接池初始化失败即 panic"), 那么必须让应用等数据库就绪再启动

复制代码
    depends_on:
      db:
        condition: service_healthy

这要求 db 服务也得有 healthcheck------MySQL 的写法是 mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD --silent

验收(不需要 Docker 守护进程)

复制代码
# 1) 只校验语法,不需要启动容器
docker compose -f docker/compose.prod.yaml config --quiet
​
# 2) 看渲染后的结果,确认挂载路径、端口、环境变量都对
IMAGE_TAG=test docker compose -f docker/compose.prod.yaml config

这一步很值得养成习惯:编排文件的错误往往在"启动容器"时才暴露, 而 config 能在推代码之前就发现绝大多数问题。


6. 第 3 步:写部署脚本(整套方案的核心)

放在 scripts/deploy.sh。完整版本如下,可以直接用:

复制代码
#!/usr/bin/env bash
#
# 服务器侧部署脚本。由 GitHub Actions 通过 SSH 调用,也可以手动执行。
#
# 用法:
#   bash scripts/deploy.sh <image_tag>   部署指定版本(tag 是 commit sha 前 12 位)
#   bash scripts/deploy.sh --status      查看当前版本与容器状态
​
set -euo pipefail
​
# 用脚本自身的位置推导仓库根目录 —— 这样从任何目录调用都正确
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_DIR"
​
COMPOSE_FILE="docker/compose.prod.yaml"
ENV_FILE="docker/.env"
STATE_FILE=".deploy_state"
​
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; }
​
compose() {
  docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
}
​
# 从 .env 读一个变量,读不到用兜底值。避免把端口写死在两处。
read_env() {
  local key="$1" fallback="$2" value=""
  if [ -f "$ENV_FILE" ]; then
    value="$(grep -E "^${key}=" "$ENV_FILE" | tail -n1 | cut -d= -f2- | tr -d '\r' || true)"
  fi
  printf '%s' "${value:-$fallback}"
}
​
HEALTH_URL="http://127.0.0.1:$(read_env PUBLIC_PORT 8080)/health"
​
# 最多探 30 次、每次间隔 2 秒,即最长等 60 秒。
# 这个等待时间的意义:把「发布成功」定义为应用真的能响应,而不是容器启动了。
wait_health() {
  local attempt
  for attempt in $(seq 1 30); do
    if curl -fsS --max-time 3 "$HEALTH_URL" >/dev/null 2>&1; then
      log "健康检查通过(第 ${attempt} 次探测)"
      return 0
    fi
    sleep 2
  done
  return 1
}
​
if [ "${1:-}" = "--status" ]; then
  log "当前版本:$(cat "$STATE_FILE" 2>/dev/null || echo '(无记录)')"
  compose ps
  exit 0
fi
​
NEW_TAG="${1:-}"
if [ -z "$NEW_TAG" ]; then
  log "用法:bash scripts/deploy.sh <image_tag>"
  exit 2
fi
​
if [ ! -f "$ENV_FILE" ]; then
  log "缺少 ${ENV_FILE},请先从 docker/.env.example 复制并填写"
  exit 2
fi
​
# 上一版就是回滚落点。读不到说明是第一次部署,那时没有可回滚的目标。
PREV_TAG="$(cat "$STATE_FILE" 2>/dev/null || true)"
log "当前版本:${PREV_TAG:-(无记录)}"
log "开始部署:${NEW_TAG}"
​
export IMAGE_TAG="$NEW_TAG"
​
log "拉取新镜像(数据库容器不动)"
compose pull app
​
log "重建 app 容器"
compose up -d app
​
if wait_health; then
  # 只有健康检查通过才把新版本记为「当前版本」,
  # 这样下一次部署的回滚目标一定是最后一个真正跑起来的版本。
  printf '%s\n' "$NEW_TAG" > "$STATE_FILE"
  log "部署成功,当前版本:${NEW_TAG}"
  exit 0
fi
​
log "健康检查连续 30 次未通过,判定本次发布失败"
compose logs --tail=80 app || true
​
if [ -z "$PREV_TAG" ]; then
  log "没有可回滚的历史版本,需要人工介入"
  exit 1
fi
​
log "回滚到上一个版本:${PREV_TAG}"
export IMAGE_TAG="$PREV_TAG"
compose up -d app
​
if wait_health; then
  log "已回滚到 ${PREV_TAG},服务已恢复"
else
  log "回滚后健康检查仍未通过,需要人工介入"
fi
​
# 无论回滚是否成功,都以失败退出 —— 新版本确实是坏的,不该显示绿色。
exit 1

三个值得单独说的设计点

一、为什么回滚成功了还是要 exit 1 因为退出码是给人看的信号。这次发布确实失败了,流水线就该是红的; 回滚只是止损,不是成功。把红色美化掉,等于把"新版本有问题"这件事藏起来了。

二、为什么状态文件必须在健康检查之后写? 见第 1 节的原则四。这里再补一句实现细节:只有成功分支 会写这个文件, 所以失败时它的内容和修改时间都不会变------这恰好也是一个很有用的排查证据 (见第 12 节)。

三、为什么用 ${BASH_SOURCE[0]} 推导路径? $(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) 让脚本无论从哪个目录被调用, 都能正确定位仓库根目录。如果写成相对路径,从 CI 里调用和手动调用会得到不同结果。

验收

手动跑一次(需要镜像已经在仓库里,或本地有):

复制代码
cd /srv/<你的目录> && bash scripts/deploy.sh --status

7. 第 4 步:准备镜像仓库

这一步的唯一硬性要求是:服务器在国内时,必须能直连、且速度快。 否则你会遇到两个连锁问题------部署慢,以及因为慢而撞上 SSH 步骤的超时。

先说一个反直觉的事实:

Docker 的 registry-mirrors 配置只对 docker.io 生效。ghcr.io 配国内镜像地址是无效的,这不是"没配好",而是机制上不支持。

所以如果你用 GHCR 且服务器在国内,实测可能只有几十 KB/s。这时候只能换仓库。 国内可选:阿里云 ACR、腾讯云 TCR、华为云 SWR。以下以阿里云 ACR 个人版为例:

事项 做法
实例地域 尽量选离服务器近的(个人版可能只开放部分地域,以控制台为准)
仓库类型 公开------这样服务器拉取免登录,不用在服务器上存任何凭据
推送凭据 推送无论公开私有都要登录。用固定密码,因为临时密码只有 1 小时
凭据存放 登录名通常是"云账号名"而不是邮箱,在控制台"访问凭证"页可以看到

还有一件事必须做:关掉 buildx 默认附加的证明清单 。 它会在推镜像时额外推一个 platform=unknown/unknown 的 manifest, 某些仓库会因此拒绝整个推送

复制代码
- uses: docker/build-push-action@v6
  with:
    provenance: false
    sbom: false

验收

复制代码
# 在服务器上(不需要 docker login)
docker pull <镜像仓库>/<命名空间>/<仓库名>:<某个 tag>

8. 第 5 步:准备部署账户与密钥

复制代码
# 1) 建专用账户,加进 docker 组(能跑 docker 就够,不需要 root)
#    Debian / Ubuntu:
sudo adduser --disabled-password --gecos "" deploy
#    RHEL 系(含阿里云 Linux)用:
# sudo useradd --create-home --shell /bin/bash deploy
sudo usermod -aG docker deploy

# 2) 生成一把专供 CI 使用的密钥(不要复用你日常登录的那把)
ssh-keygen -t ed25519 -C "ci-deploy" -f ./ci_deploy -N ""

# 3) 把公钥放进去
sudo mkdir -p /home/deploy/.ssh
sudo tee -a /home/deploy/.ssh/authorized_keys < ./ci_deploy.pub
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys

两个容易忽略的点:

  • 私钥绝对不能进仓库 。把 ci_deploy 所在目录加进 .gitignore, 并且真的确认一遍 git status 是干净的。

  • 服务器上要 clone 一份仓库git clone 到比如 /srv/<项目名>,属主设为 deploy)。 不是要源码,是要那几个部署用的文件。

验收

复制代码
ssh -i ./ci_deploy deploy@<服务器IP> 'cd /srv/<项目名> && docker compose version'

9. 第 6 步:写流水线

.github/workflows/release.yml

复制代码
name: Release

# 日常 push 走另一个 workflow 只跑测试;只有版本 tag 才触发发布。
on:
  push:
    tags:
      - "v*.*.*"
  workflow_dispatch:
    inputs:
      deploy:
        description: "手动触发时是否在构建完成后执行部署"
        type: boolean
        default: false

permissions:
  contents: read

env:
  REGISTRY: <镜像仓库域名,例如 crpi-<实例ID>.<地域>.personal.cr.aliyuncs.com>
  IMAGE_NAME: <命名空间>/<仓库名>

jobs:
  # ---------- 构建并推送镜像 ----------
  build-and-push:
    runs-on: ubuntu-latest
    outputs:
      image_tag: ${{ steps.meta.outputs.sha_tag }}
    steps:
      - uses: actions/checkout@v4

      - name: 计算镜像名与 tag
        id: meta
        run: |
          IMAGE="$(echo "${REGISTRY}/${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')"
          # 核心规则:tag 用 commit sha 前 12 位。刻意不推 latest。
          SHA_TAG="${GITHUB_SHA::12}"
          {
            echo "image=${IMAGE}"
            echo "sha_tag=${SHA_TAG}"
            echo "tags<<EOF"
            echo "${IMAGE}:${SHA_TAG}"
            if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
              echo "${IMAGE}:${GITHUB_REF_NAME}"
            fi
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - uses: docker/setup-buildx-action@v3

      - name: 登录镜像仓库
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - name: 构建并推送
        uses: docker/build-push-action@v6
        with:
          context: .
          file: docker/Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          # 关掉 buildx 默认附加的证明清单,否则某些仓库会拒绝整个 push
          provenance: false
          sbom: false
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # ---------- SSH 到服务器执行部署 ----------
  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    # 注意:GitHub 不允许在 if 里读 secrets,所以要另建一个 variable 当开关。
    if: ${{ vars.DEPLOY_ENABLED == 'true' && (github.event_name == 'push' || inputs.deploy) }}
    steps:
      - name: 通过 SSH 执行远程部署
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          port: ${{ vars.DEPLOY_PORT || 22 }}
          # 默认只有 10 分钟。要按"正常耗时的若干倍"来设,
          # 不是越大越好 —— 见下面第二个注意点。
          command_timeout: 15m
          script: |
            set -e
            cd "${{ vars.DEPLOY_PATH }}"
            git fetch --tags --prune
            git checkout main
            git pull --ff-only
            bash scripts/deploy.sh "${{ needs.build-and-push.outputs.image_tag }}"

三个必须知道的注意点

一、command_timeout 要放宽,但别放宽到离谱。 默认只有 10 分钟,首部署很容易撞线。它失败时的现象极具欺骗性: SSH 会话被杀 → 远端脚本收到 SIGHUP 静默中断 → 服务器上既没有版本记录也没有新容器, 看起来像"部署根本没执行"。所以设它是为了不误伤; 但也不该设成 1 小时------超时给得越宽,故障暴露得越晚。

二、不要在 SSH 步骤里写 script_stop: true appleboy/ssh-action v1 已经删掉了这个输入,写了会报 Unexpected input(s)。 它是用来"遇到错误就停"的,而这个能力由脚本自己的 set -e 提供,效果等价。

三、部署步骤里先 git fetch + checkout + pull 再执行脚本。 这样"部署脚本本身"也走同一套版本管理。注意用 --ff-only: 如果服务器上有未提交的本地改动,它会明确报错而不是悄悄合并------ 这是好事,因为服务器上出现本地改动本身就是个需要处理的意外。


10. 第 7 步:配置 Secrets 与 Variables

仓库 Settings → Secrets and variables → Actions

Secrets(加密,日志里不会显示):

名字
REGISTRY_USERNAME 镜像仓库登录名
REGISTRY_PASSWORD 镜像仓库固定密码
DEPLOY_HOST 服务器 IP 或域名
DEPLOY_USER 部署账户名
DEPLOY_SSH_KEY CI 私钥的完整内容(含首尾的 BEGIN/END 行)

Variables (明文,可以在 if 里引用):

名字 示例 说明
DEPLOY_ENABLED true 部署开关。没配服务器时设成 false,构建部分照样能单独练通
DEPLOY_PORT 22 SSH 端口
DEPLOY_PATH /srv/<项目名> 服务器上的仓库路径
SITE_URL 留空则跳过"部署后从公网复验"那一步

关于 SITE_URL 有个坑:不要 图省事填 http://127.0.0.1:8080。 在 runner 上 127.0.0.1 指的是 runner 自己,那一步会真的去 curl 然后失败。留空就好。


11. 第 8 步:第一次发布

复制代码
git tag v0.1.0
git push origin v0.1.0

然后到 GitHub 的 Actions 页面看两个 job 依次变绿。接着在服务器上做四步验收:

# 命令 期望
1 cat .deploy_state 等于这次的 commit sha 前 12 位
2 docker ps --format '{``{.Names}} {``{.Image}} {``{.Status}}' 应用容器镜像 tag = 该 sha,状态 healthy
3 curl -fsS http://127.0.0.1:8080/health 返回预期 JSON
4 bash scripts/deploy.sh --status 版本号与容器状态一致

注意"容器起来了"不算验收通过------第 3 步才是真正的判据。


12. 第 9 步:演练一次回滚(这一步别跳过)

一个没被验证过的回滚机制,等于没有。造一个"容器能起来但服务不可用"的版本:

复制代码
# 正常版:ENTRYPOINT ["./app"]
# 演练版:让容器活着,但永远不监听端口 —— 健康检查必然失败
ENTRYPOINT ["sleep", "infinity"]

打个 tag 推上去(临时用个 v0.1.99 之类,演练完删掉),然后在服务器上取证 (不依赖任何日志):

观察 发布成功 失败后自动回滚
.deploy_state 内容和修改时间都被改写 两者都不变
应用容器 镜像 = 新 tag,创建时间在本次窗口内 创建时间被刷新,但镜像是旧 tag
本地镜像列表 出现新 tag 出现坏 tag(证明拉取用的是新 tag)

判据:状态文件没动 + 容器是旧 tag + 容器创建时间落在部署窗口内 ⇒ 只可能是"先起坏版本 → 健康检查失败 → 重建回旧版本"这一条路径。

这次流水线显示红色是正确的 ,不要以为哪里坏了------脚本最后故意 exit 1

演练完把坏 tag 和坏镜像都清掉,避免哪天被人重跑又红一次:

复制代码
git push origin --delete v0.1.x
git tag -d v0.1.x
# 镜像仓库控制台里也把这个 tag 删掉

13. 第 10 步:反向代理(要对外访问就必需)

应用端口只绑在 127.0.0.1,所以对外必须有反向代理。用 nginx 的话,一个能用的最小站点:

复制代码
server {
    listen      80;
    server_name _;

    # 127.0.0.11 是 Docker 内嵌 DNS,只在容器网络内可用,
    # 所以 nginx 也必须跑在同一个 compose 网络里。
    resolver 127.0.0.11 valid=10s ipv6=off;

    client_max_body_size 10m;

    location / {
        # 关键:用变量而不是写死的 upstream
        set $app_upstream app:8080;
        proxy_pass http://$app_upstream;

        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Connection        "";
    }

    gzip             on;
    gzip_min_length  1024;
    gzip_proxied     any;
    gzip_types       text/plain text/css text/xml text/javascript application/javascript
                     application/json application/xml image/svg+xml;
}

这里有三个点,每一个都对应一个真实故障:

一、set $app_upstream + resolver 不能简化成 upstream 块。 应用容器每次部署都会被重建,容器 IP 会变 。而 upstream 块只在 nginx 启动时解析一次, 之后一直连那个已经不存在的地址------表现是"每次发版后 502,过一会儿又莫名好了", 而且极容易被误判成应用崩了。用变量则改为运行时解析,按 valid 周期自动刷新。

二、gzip_types 里必须有 text/javascript Go 1.20 起 .js 的 MIME 从 application/javascript 改成了 text/javascript, 而 nginx 的 gzip_types 是按响应头精确匹配的。只写前者,一个 JS 都不会被压缩, 而且不报任何错 。判断方法:响应头没有 Content-Encoding: gzip, 且 Content-Length 等于原始文件大小。

(如果你的静态资源是 nginx 直接托管的而不是应用托管的,同样要检查这一条。)

三、改了配置文件不会自动生效。 配置是挂载进容器的文件,运行中的进程不会重读;而发布流程只重建应用容器, 不重建代理容器。所以在部署脚本的成功分支里补一次热加载:

复制代码
# 放在 deploy.sh 里「写状态文件」之后、「exit 0」之前
PROXY_CID="$(compose ps -q nginx 2>/dev/null | head -n1 || true)"

if [ -n "$PROXY_CID" ]; then
  if docker exec "$PROXY_CID" nginx -t; then
    docker exec "$PROXY_CID" nginx -s reload
    log "已热加载反向代理配置"
  else
    # 校验不通过时不要动它:正在运行的配置会继续服务,站点不受影响。
    log "警告:nginx 配置校验未通过,代理继续使用当前配置"
  fi
fi

两个细节:

  • 用容器 ID(compose ps -q)而不是容器名:容器名由 compose 项目名决定,换目录就变了。

  • || true 不能省set -e 下,VAR="$(某命令)" 这种"只含命令替换的赋值" 如果命令失败,会无条件终止整个脚本 ,而且日志停在半句、看起来像莫名其妙中断。 compose ps -q nginx 在编排文件里没有这个服务时就会失败------所以必须兜住。

关于 HTTPS :有域名且能正常签发证书时,加上 443 站点即可。 证书可以用 acme.shcertbot 签发,把续期做成定时任务,并在续期成功后 reload nginx。 (用 Caddy 的话这一步是内建的,代价是配置知识不如 nginx 通用------按你的取舍选。)


14. 收尾清单

搭完之后,还有几件"不做不报错、但迟早会疼"的事:

为什么 建议时机
数据库定时备份 数据卷通常是唯一副本,误删就是永久丢失 尽快
发布失败告警 否则失败只能靠你自己去 Actions 页面看 尽快
备份恢复演练 没验证过的备份不算备份 备份上线后一次
健康检查端点分级 liveness(进程活着)与 readiness(依赖就绪)分开 依赖变多之后
多副本滚动发布 消除发布瞬间的空窗 有真实用户流量后
部署脚本的热加载纳入 见第 13 节第三点 加上反向代理时

15. 这套方案的边界

deploy.sh 的回滚覆盖的是:镜像拉下来了,但容器起不来、或者起来了但服务不健康。

它覆盖不了

  • 镜像没拉到 (tag 写错、仓库拒绝)→ 脚本在 pull 阶段就退出,线上没被动过, 这时候"不需要回滚"本身就是正确行为

  • 容器健康、但业务逻辑是错的 → 健康检查只能证明"能响应",证明不了"响应是对的"。 这类问题要靠监控、灰度、代码评审,自动化解决不了

理解这条边界很重要:自动回滚不是万能的,它只是把一类最常见的失败变成自动处理。


附:为什么值得这么做

这套东西的价值不在于"自动化",而在于它把"发布"从一次有风险的操作, 变成了一个有确定结论的事件------绿就是绿,红就是红,红了服务也还在。

相关推荐
yi碗汤园15 小时前
OSS对象存储服务
网络·阿里云
边境悍匪1 天前
蜗牛学苑 Java 智能体学习 Day44|Vue 前端项目搭建 思维导图复盘
java·开发语言·spring boot·学习·阿里云
阿里云大数据AI技术1 天前
千问 APP × 阿里云:MaxCompute 向量检索重构 RAG 数据收录评估链路,一条SQL将性能提速 11 倍
人工智能·阿里云·maxcompute·rag·千问
干就完事了2 天前
机器学习-音乐艺人流行趋势预测
人工智能·机器学习·阿里云
晴空蓝天2 天前
零工系统企业认证:营业执照 OCR 识别,阿里云这个接口我调了一下午
阿里云·ocr
晨曦_子画3 天前
Qwen 3.8 27B 与 Qwen 3.6 27B:架构相同,相隔4个月,升级方式不同
阿里云·ai·千问
szephyr4 天前
Docker + Compose 实战:把本地项目一键搬到云服务器,附完整配置
运维·docker·容器·部署·云服务器
szephyr4 天前
Nginx 反向代理实战:从 502、跨域到 HTTPS,一次打通
运维·nginx·https·部署·反向代理
聚搜云——JuSouClouD4 天前
在阿里云代理商渠道买轻量服务器,带宽套餐支持自选吗?
服务器·阿里云·云计算