从零给个人项目搭一套 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.sh 和 docker/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.sh 或 certbot 签发,把续期做成定时任务,并在续期成功后 reload nginx。 (用 Caddy 的话这一步是内建的,代价是配置知识不如 nginx 通用------按你的取舍选。)
14. 收尾清单
搭完之后,还有几件"不做不报错、但迟早会疼"的事:
| 项 | 为什么 | 建议时机 |
|---|---|---|
| 数据库定时备份 | 数据卷通常是唯一副本,误删就是永久丢失 | 尽快 |
| 发布失败告警 | 否则失败只能靠你自己去 Actions 页面看 | 尽快 |
| 备份恢复演练 | 没验证过的备份不算备份 | 备份上线后一次 |
| 健康检查端点分级 | liveness(进程活着)与 readiness(依赖就绪)分开 | 依赖变多之后 |
| 多副本滚动发布 | 消除发布瞬间的空窗 | 有真实用户流量后 |
| 部署脚本的热加载纳入 | 见第 13 节第三点 | 加上反向代理时 |
15. 这套方案的边界
deploy.sh 的回滚覆盖的是:镜像拉下来了,但容器起不来、或者起来了但服务不健康。
它覆盖不了:
-
镜像没拉到 (tag 写错、仓库拒绝)→ 脚本在
pull阶段就退出,线上没被动过, 这时候"不需要回滚"本身就是正确行为 -
容器健康、但业务逻辑是错的 → 健康检查只能证明"能响应",证明不了"响应是对的"。 这类问题要靠监控、灰度、代码评审,自动化解决不了
理解这条边界很重要:自动回滚不是万能的,它只是把一类最常见的失败变成自动处理。
附:为什么值得这么做
这套东西的价值不在于"自动化",而在于它把"发布"从一次有风险的操作, 变成了一个有确定结论的事件------绿就是绿,红就是红,红了服务也还在。