Docker Compose + Nginx 实现零停机蓝绿部署:从入门到生产级实践

88 阅读4分钟

在单机 Docker 环境下,如何实现像 Kubernetes 那样的“滚动更新”或“蓝绿部署”?很多开发者习惯于直接 docker compose restart,但这会导致服务在重启期间出现短暂的 502 错误。

本文将总结一套基于 Shell 脚本 + Docker Compose + Nginx 的轻量级蓝绿部署方案。该方案重点解决了两个核心问题:如何准确判断新服务已就绪,以及如何优雅地处理旧服务的剩余流量


1. 架构设计

我们使用 蓝绿部署 (Blue/Green Deployment) 策略。

  • 状态文件 (AorB) :记录当前在线的是 A 环境还是 B 环境。
  • 双容器槽位backend (Blue) 和 backend1 (Green)。
  • Nginx 负载均衡:通过切换 upstream 配置文件并重载 (reload),将流量瞬间切换到新容器。

2. 核心挑战与解决方案

在脚本演进过程中,我们解决了以下三个生产级痛点:

痛点一:服务“假启动”导致 502

旧方案:通过 grep "Worker ready" 检查日志。

问题:容器启动且打印了日志,并不代表 Web Server 已经绑定端口并准备好接收 TCP 连接。且日志缓冲会导致判断延迟。

新方案:主动 HTTP 探测 (Active Probing)

我们在脚本中引入了 wait_for_health 函数,通过 curl 持续请求应用的 /api/user/me 接口。

  • 判定逻辑:只要返回 HTTP 200 或 401 Unauthorized,即视为服务可用。
  • 为什么接受 401?/api/user/me 需要登录。如果我们收到 401 错误,说明 Nginx/Gunicorn/Spring Boot 等应用层已经完全启动并拦截了请求,这正是我们需要的“健康”信号。

痛点二:暴力停机切断用户连接

旧方案:Nginx reload 后立即 docker stop 旧容器。

问题:Nginx 的 reload 是异步的,且旧容器上可能还有正在处理的长连接(如下载、WebSocket)。直接停止会导致用户端连接重置。

新方案:连接耗尽 (Connection Draining)

  1. Nginx 层面:设置 worker_shutdown_timeout 60s;,给旧 Worker 进程 60 秒时间处理剩余请求。
  2. 脚本层面:引入 wait_for_draining 函数。使用 Linux 原生命令 ss (Socket Statistics) 监控指向旧容器 IP 的 TCP 连接。只有当连接数归零或超时,才执行停机。

痛点三:Nginx 配置指令作用域错误

问题worker_shutdown_timeout 被错误放置在 http 块中。

修正:该指令属于全局配置,必须放在 nginx.conf最外层 (Main Context)


3. 最终实施方案

3.1 Nginx 全局配置 (nginx.conf)

Nginx

user  nginx;
worker_processes  auto;

# 【关键配置】放在最外层,控制旧进程最长存活时间
worker_shutdown_timeout 60s;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    include       /etc/nginx/conf.d/*.conf;
    # 你的 upstream 配置文件被包含在这里
    include       /etc/nginx/upstream.conf; 
}

3.2 自动化部署脚本 (deploy.sh)

这是整合了所有优化逻辑的完整脚本:

Bash

#!/usr/bin/env bash

# === 配置区 ===
ABFILE=nginx/AorB
CONTAINER_PORT=8000           # 容器内部应用端口
HEALTH_PATH="/api/user/me"    # 健康检查接口
MAX_RETRIES=30                # 健康检查最大重试次数
DRAIN_TIMEOUT=60              # 流量耗尽最大等待秒数
# =============

# 颜色定义
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'

# 初始化状态文件
if [ ! -f "$ABFILE" ]; then echo "A" > "$ABFILE"; fi
AorB=$(cat ${ABFILE})

# 获取容器内部 IP
get_container_ip() {
  local id=$(docker compose ps -q $1 2>/dev/null)
  if [ -n "$id" ]; then
    docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $id
  fi
}

# 1. 健康检查:等待 API 返回有效响应 (包括 401)
wait_for_health() {
  local service=$1
  local count=0
  echo -e "${YELLOW}🔍 Checking health for $service...${NC}"

  while [ $count -lt $MAX_RETRIES ]; do
    local ip=$(get_container_ip $service)
    if [ -n "$ip" ]; then
      # 获取响应内容
      local resp=$(curl -s --max-time 2 "http://${ip}:${CONTAINER_PORT}${HEALTH_PATH}")
      # 只要包含 Unauthorized,说明服务活着
      if echo "$resp" | grep -q "Unauthorized"; then
        echo -e "${GREEN}$service is READY!${NC}"
        return 0
      fi
    fi
    sleep 2
    count=$((count + 1))
  done
  echo -e "${RED}❌ Health check failed for $service${NC}"; return 1
}

# 2. 流量耗尽:等待旧连接断开
wait_for_draining() {
  local service=$1
  local count=0
  local ip=$(get_container_ip $service)

  [ -z "$ip" ] && return 0
  echo -e "${YELLOW}🛑 Draining connections for $service ($ip)...${NC}"

  while [ $count -lt $DRAIN_TIMEOUT ]; do
    # 检查目标为旧容器 IP 的 ESTABLISHED 连接
    local conn=$(ss -tn state established dst $ip | grep -v Recv-Q | wc -l)
    if [ "$conn" -eq "0" ]; then
      echo -e "${GREEN}✅ No active connections. Safe to stop.${NC}"
      return 0
    fi
    echo "   Active connections: $conn. Waiting..."
    sleep 1
    count=$((count + 1))
  done
  echo -e "${RED}⚠️ Timeout! Force stopping.${NC}"
}

# 3. 启动并切换
deploy() {
  local new=$1
  local old=$2
  local env=$3

  echo "=== Deploying $new (Replace $old) ==="
  cp envs/prod-common.env "$env"
  
  # A. 启动新容器
  docker compose build $new
  docker compose up -d $new
  
  # B. 健康检查
  wait_for_health $new || exit 1
  
  # C. Nginx 切换流量
  cp nginx/$new.conf.tmpl nginx/upstream.conf
  sudo nginx -t && sudo nginx -s reload
  echo -e "${GREEN}🚀 Traffic switched to $new${NC}"

  # D. 旧节点处理 (流量耗尽 -> 停止)
  wait_for_draining $old
  docker compose stop $old
}

# 主流程
if [ "$AorB" == "A" ]; then
  deploy "backend" "backend1" "envs/prod.env"
  echo B > ${ABFILE}
else
  deploy "backend1" "backend" "envs/prod1.env"
  echo A > ${ABFILE}
fi

4. 总结

这套脚本实现了一个闭环的自动化发布流程:

  1. Start: 启动新容器。
  2. Verify: 通过 HTTP 语义(401 Unauthorized)确认业务逻辑已加载。
  3. Switch: Nginx 热加载,流量切换。
  4. Drain: 监控 TCP 连接,等待旧请求处理完毕。
  5. Stop: 安全关闭旧容器。

这种方案不需要复杂的 Kubernetes 运维知识,非常适合中小规模的 Docker Compose 部署场景,能显著提升发布的稳定性和用户体验。