一、背景
"核心力量少儿跑酷"教练助手是一个基于 WebSocket 长连接的 AI 智能问答应用。教练在课堂上提问时,前端与后端通过 WebSocket 维持实时连接,流式接收大模型生成的专业回答。
这意味着:服务更新时,不能简单粗暴地停止旧服务再启动新服务。 一旦这么做,所有正在进行的 AI 对话瞬间断开,教练课堂现场被打断,体验极差。
我们需要一套方案,在服务更新的同时,让存量用户不受影响。
二、核心目标
- 零停机发布:服务更新期间,用户的请求和 WebSocket 长连接不能中断
- 快速回滚:新版本出问题时,能在秒级切回旧版本
- 优雅退出:旧服务停机时,不能粗暴断开连接,要给现有请求一个体面的收尾
三、整体架构
┌──────────┐
│ Nginx │
│ :443 │
└────┬─────┘
│
┌──────────┴──────────┐
│ upstream.conf │
│ │
│ app-blue:8083 │
│ app-green:8083 down│
│ │
└──────────┬──────────┘
│
┌──────────┴──────────┐
│ │
┌────┴────┐ ┌────┴────┐
│ Blue │ │ Green │
│ :8083 │ │ :8084 │
│ (活跃) │ │ (待命) │
└─────────┘ └─────────┘
核心思路:同时运行两套完全相同的容器(Blue 和 Green),一套对外服务,一套待命。发布新版本时先启动待命环境做健康检查,确认无误后 Nginx 切换流量。
四、蓝绿发布流程
4.1 两套完全对等的容器
docker-compose.yaml 中定义了两套应用服务,结构完全一致:
yaml
services:
app-blue:
image: core-coach-back:${BLUE_VERSION:-latest}
container_name: core-coach-app-blue
ports:
- "${BLUE_PORT:-8083}:8083"
stop_grace_period: 30s
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8083/api/v1/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
app-green:
image: core-coach-back:${GREEN_VERSION:-latest}
container_name: core-coach-app-green
ports:
- "${GREEN_PORT:-8084}:8083"
stop_grace_period: 30s
# healthcheck 配置相同
两个容器配置完全对等,仅端口不同(Blue 映射 8083,Green 映射 8084,内部均监听 8083)。依赖同一个 MySQL、Redis、MinIO 实例,保证数据一致性。
4.2 Nginx 动态路由
Nginx 的 upstream 配置放在独立文件中,由部署脚本动态写入:
nginx
# upstream.conf
upstream app_backend {
server app-blue:8083 max_fails=3 fail_timeout=10s;
server app-green:8083 max_fails=3 fail_timeout=10s down;
}
upstream app_backend_ws {
server app-blue:8083 max_fails=3 fail_timeout=10s;
server app-green:8083 max_fails=3 fail_timeout=10s down;
}
关键设计点:
down标志 :将待命环境的 server 标记为down,Nginx 不会将请求路由到它max_fails=3+fail_timeout=10s:连续 3 次失败后熔断 10 秒,防止将流量打到异常节点- API 和 WebSocket 各自独立 upstream:虽然当前指向同一组容器,但预留了独立扩缩的空间
Nginx 的 WebSocket 代理配置设置了 7 天超时,确保长连接不会被 nginx 主动切断:
nginx
location /api/v1/ws/ {
proxy_pass http://app_backend_ws//api/v1/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
4.3 发布脚本执行步骤
部署脚本 deploy.sh 的核心流程:
第一步:确定当前活跃环境
bash
if grep "server app-blue" "$UPSTREAM_CONF" | grep -qv "down"; then
ACTIVE="blue"
else
ACTIVE="green"
fi
通过检查 upstream.conf 中哪个 server 不带 down 标记,确定当前对外服务的是 blue 还是 green。
第二步:构建新版本镜像并启动待命环境
bash
NEXT="green" # 假设当前 active 为 blue
docker compose build "app-${NEXT}"
docker compose stop "app-${NEXT}" 2>/dev/null || true
docker compose rm -f "app-${NEXT}" 2>/dev/null || true
docker compose up -d "app-${NEXT}"
先停掉旧的待命容器,再构建新镜像并启动。新容器起来后通过健康检查验证。
第三步:健康检查轮询
bash
for i in $(seq 1 30); do
if curl -sf --max-time 2 "http://localhost:${NEXT_PORT}/api/v1/health" >/dev/null 2>&1; then
break
fi
sleep 2
if [ "$i" -eq 30 ]; then
echo "超时!旧环境不受影响"
exit 1
fi
done
最多等待 60 秒(30 次 × 2 秒间隔),健康检查通过才继续。超时则直接退出,旧环境不受任何影响。
第四步:切换流量
bash
cat > "$UPSTREAM_CONF" <<EOF
upstream app_backend {
server app-${NEXT}:8083 max_fails=3 fail_timeout=10s;
server app-${ACTIVE}:8083 max_fails=3 fail_timeout=10s down;
}
upstream app_backend_ws {
server app-${NEXT}:8083 max_fails=3 fail_timeout=10s;
server app-${ACTIVE}:8083 max_fails=3 fail_timeout=10s down;
}
EOF
docker compose exec -T nginx nginx -t
docker compose exec -T nginx nginx -s reload
重写 upstream.conf,将新环境设为活跃、旧环境标记为 down,然后先 nginx -t 校验配置,再 nginx -s reload 平滑重载。
第五步:验证并停止旧环境
bash
curl -sf --max-time 3 "http://localhost:85/api/v1/health" >/dev/null || {
bash scripts/rollback.sh
exit 1
}
docker compose stop "app-${ACTIVE}"
切换后立即发起一次健康检查请求验证新环境可用。验证失败则自动触发回滚,验证通过则停止旧容器,完成发布。
4.4 回滚机制
回滚脚本 rollback.sh 的核心逻辑:
bash
ACTIVE=$(cat ".active-color")
PREV="green" # 或 blue
docker compose start "app-${PREV}" # 启动旧环境
# 重写 upstream.conf,切回旧环境
docker compose exec -T nginx nginx -s reload
docker compose stop "app-${ACTIVE}"
由于旧容器只是 docker compose stop 而不是 docker compose down,容器文件系统仍在,可以直接 start 快速启动,回滚时间在秒级。
4.5 部署流水线
Jenkins 流水线的四个阶段:
| 阶段 | 操作 | 说明 |
|---|---|---|
| Checkout | 拉取代码 | 记录 commit hash |
| Package | tar -czf app.tar.gz |
仅打包源码(cmd/internal/pkg/go.mod/go.sum),排除 Dockerfile、nginx、测试文件等 |
| Upload | scp 到服务器 |
制品体积小,传输速度快 |
| Deploy | SSH 执行 deploy.sh |
在服务器上解压源码 → 构建镜像 → 健康检查 → 切换流量 |
关键设计:在目标服务器上构建,而不是在 CI 环境构建好镜像再分发。好处是:
- 不需要搭建私有镜像仓库
- 服务器本地构建利用 Docker 层缓存,后续构建更快
- CI 环境只负责代码拉取和打包传输,链路简单
五、优雅停机
5.1 问题
Docker 默认 docker stop 的行为是:先发 SIGTERM,等待 10 秒,如果进程还没退出就发 SIGKILL 强杀。
对于 WebSocket 长连接服务,这 10 秒远远不够。正在进行的 AI 对话、未完成的写库操作都可能被强行中断。
5.2 延长停机窗口
docker-compose.yaml 中配置:
yaml
stop_grace_period: 30s
将 Docker 等待时间延长到 30 秒,给服务充足的收尾时间。
5.3 Go 服务优雅退出
服务端 gracefulShutdown() 的实现:
go
func (a *App) gracefulShutdown() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("正在关闭服务器...")
// 第一步:取消所有后台 Worker(游戏生成、音乐转存等)
if a.workerCancel != nil {
a.workerCancel()
}
// 第二步:关闭 HTTP Server,等待现有请求处理完成
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := a.server.Shutdown(ctx); err != nil {
logger.Error("服务器关闭失败", zap.Error(err))
}
// 第三步:关闭数据库和 Redis 连接
_ = database.CloseMySQL()
_ = database.CloseRedis()
// 第四步:刷写日志缓冲区
_ = logger.Sync()
logger.Info("服务器已关闭")
}
退出顺序经过精心设计:
- 先停 Worker:取消游戏生成、音乐转存等后台任务,不再接收新的异步工作
- 再停 HTTP :
server.Shutdown()会关闭空闲连接,并等待正在处理的请求完成(最长等 5 秒)。WebSocket 连接收到关闭帧后,客户端自动重连到新容器 - 最后关数据库:确保所有数据库操作已提交,再断开连接
5.4 客户端重连
旧容器 WebSocket 断开后,客户端自动重连到 Nginx。此时 Nginx 已将 upstream 指向新容器,连接无缝恢复。用户端感知到的只是一次短暂的 "重新连接中...",而非报错或白屏。
六、Docker 镜像优化
采用多阶段构建,减小镜像体积:
dockerfile
# 构建阶段
FROM golang:1.25.5-alpine3.22 AS builder
ENV GOPROXY=https://goproxy.cn,direct
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o server ./cmd/server/main.go
# 运行阶段
FROM alpine:latest
COPY --from=builder /app/server .
COPY config.yaml.docker /app/configs/config.yaml
CMD ["./server"]
关键优化:
CGO_ENABLED=0:编译纯静态二进制,不依赖系统 C 库,可以在 Alpine 的精简镜像中直接运行-ldflags="-w -s":去除调试信息和符号表,减小二进制体积- 分层缓存 :先复制
go.mod/go.sum下载依赖,再复制源码编译。依赖不变时,Docker 会复用依赖下载层的缓存 - 多阶段构建:最终运行镜像只包含二进制和配置文件,不含 Go 编译工具链
七、总结
这套部署方案的核心原则:
- 新旧并存:任何时候都有一套待命环境,发布就是切换 Nginx upstream,回滚就是再切回去
- 先验证再切:健康检查通过后才切换流量,检查不通过旧环境完全不受影响
- 给时间收尾:延长 Docker 停机窗口 + Go 优雅退出,让旧服务体面退场
- 构建在服务器:CI 只传输源码,在目标机器上构建,省去镜像仓库和分发环节
最终效果:一次完整的蓝绿发布,用户侧无感知。旧连接通过客户端重连平滑过渡到新容器,新请求直接打到新版本服务。