蓝绿发布实战

一、背景

"核心力量少儿跑酷"教练助手是一个基于 WebSocket 长连接的 AI 智能问答应用。教练在课堂上提问时,前端与后端通过 WebSocket 维持实时连接,流式接收大模型生成的专业回答。

这意味着:服务更新时,不能简单粗暴地停止旧服务再启动新服务。 一旦这么做,所有正在进行的 AI 对话瞬间断开,教练课堂现场被打断,体验极差。

我们需要一套方案,在服务更新的同时,让存量用户不受影响。


二、核心目标

  1. 零停机发布:服务更新期间,用户的请求和 WebSocket 长连接不能中断
  2. 快速回滚:新版本出问题时,能在秒级切回旧版本
  3. 优雅退出:旧服务停机时,不能粗暴断开连接,要给现有请求一个体面的收尾

三、整体架构

复制代码
                    ┌──────────┐
                    │  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("服务器已关闭")
}

退出顺序经过精心设计:

  1. 先停 Worker:取消游戏生成、音乐转存等后台任务,不再接收新的异步工作
  2. 再停 HTTPserver.Shutdown() 会关闭空闲连接,并等待正在处理的请求完成(最长等 5 秒)。WebSocket 连接收到关闭帧后,客户端自动重连到新容器
  3. 最后关数据库:确保所有数据库操作已提交,再断开连接

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 编译工具链

七、总结

这套部署方案的核心原则:

  1. 新旧并存:任何时候都有一套待命环境,发布就是切换 Nginx upstream,回滚就是再切回去
  2. 先验证再切:健康检查通过后才切换流量,检查不通过旧环境完全不受影响
  3. 给时间收尾:延长 Docker 停机窗口 + Go 优雅退出,让旧服务体面退场
  4. 构建在服务器:CI 只传输源码,在目标机器上构建,省去镜像仓库和分发环节

最终效果:一次完整的蓝绿发布,用户侧无感知。旧连接通过客户端重连平滑过渡到新容器,新请求直接打到新版本服务。