小Cの已经记不起来的博客

用 Docker 搭建蓝绿部署的完整流程

先把蓝绿部署说成人话

之前我一直觉得“蓝绿部署”听起来挺玄学,好像非得有 Kubernetes、服务网格、流量染色那一大套东西才能搞。结果真动手以后发现,在 Docker 这种单机或者小规模部署里,它其实就是两个版本的应用容器同时活着,一个接流量,一个空跑或者准备接流量。等新版稳定了,再把 Nginx 这类反向代理的 upstream 一换,流量就过去了。

所以这里说的 blue 和 green,本质上就是两个应用容器,比如 web-blueweb-green。它们可以跑同一个端口,比如内部都是 3000,但是不让用户直接访问容器端口,用户只访问 Nginx。Nginx 再决定把请求转给 blue 还是 green。

这套东西适合单体应用、容器化服务、几台服务器以内的小团队。要是你已经多机房、多节点、还要灰度百分比、链路追踪、自动扩缩容,那 Docker Compose 这套就不太够了,得往编排平台靠一靠。

目录结构先摆好

我自己的做法是一个部署目录单独放,别和项目源码混成一团。比如:

blue-green-demo/
├── app/
│   └── Dockerfile
├── nginx/
│   ├── server.conf
│   └── active_backend.conf
├── .deploy/
│   └── current_target
├── deploy.sh
└── docker-compose.yml

.deploy 是用来记录当前流量在哪个环境的。这个文件看起来不起眼,但是脚本里全靠它判断下一个要部署到 blue 还是 green。要是你手动部署,那也行,就是很容易哪天睡懵了,把 Nginx 切到一个还没健康检查过的容器上面。

一个能用的 Dockerfile

应用镜像这里随便给一个 Node 例子,你换成 Python、Go、Java 都行。重点是:镜像里最好有 /healthz 或者 /health 这样的接口,不然健康检查很烦。

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

EXPOSE 3000

CMD ["node", "src/server.js"]

如果你的项目没有健康检查接口,那就自己加一个。别嫌麻烦,后面 Docker Compose 健康检查、部署脚本等待容器 ready,都得靠这个。我一开始偷懒没加,结果脚本里一直判断不出来容器是否健康,后来才想起来这玩意儿不能少。

Docker Compose 让 blue 和 green 同时活着

docker-compose.yml 可以这样写:

services:
  web-blue:
    image: registry.example.com/blue-green-demo:${APP_VERSION:-latest}
    container_name: blue-green-web-blue
    expose:
      - "3000"
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 5s
      retries: 12
    restart: unless-stopped

  web-green:
    image: registry.example.com/blue-green-demo:${APP_VERSION:-latest}
    container_name: blue-green-web-green
    expose:
      - "3000"
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 5s
      retries: 12
    restart: unless-stopped

  nginx:
    image: nginx:1.27-alpine
    container_name: blue-green-nginx
    ports:
      - "80:80"
    volumes:
      - ./nginx/server.conf:/etc/nginx/conf.d/default.conf:ro
      - ./nginx/active_backend.conf:/etc/nginx/conf.d/active_backend.conf:ro
    depends_on:
      web-blue:
        condition: service_healthy
      web-green:
        condition: service_healthy
    restart: unless-stopped

这里有个地方要注意,web-blueweb-green 都用了 expose: 3000,但是没有映射宿主机端口。这样做是为了不让用户绕过 Nginx 直接访问容器。要是你把它们都映射成 3001:30003002:3000,那本地测试挺方便,线上就别这么干了,迟早会有同事或者监控脚本直接打到旧端口上。

另外,nginx:alpine 里有没有 wget,取决于你用的镜像版本。我这里默认它可用。如果你的镜像里没有,可以换 curl,或者干脆在应用容器里用 node -e 做健康检查。命令写得难看一点没事,跑通最重要。

Nginx 才是真正切流量的开关

nginx/server.conf

server {
    listen 80;

    location / {
        proxy_pass http://active_backend;
        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;
    }

    location /healthz {
        proxy_pass http://active_backend;
    }
}

nginx/active_backend.conf 初始可以指向 blue:

upstream active_backend {
    server web-blue:3000;
    keepalive 32;
}

真正部署新版本的时候,不是重启应用容器来切流量,而是改这个 active_backend.conf,把 web-blue 换成 web-green,然后 reload Nginx。Nginx reload 是很轻的动作,不会断开监听,也不会像重启容器那样粗暴。

这里我踩过一个坑:active_backend.conf 这个文件如果不存在,Nginx 容器起不来。结果你以为应用挂了,其实只是配置文件没准备。所以脚本里最好先判断一下,或者第一次 docker compose up 之前手动创建。

部署脚本别靠手敲

下面是我自己的 deploy.sh,思路很简单:

  1. 读取当前流量在哪个环境。
  2. 新版本部署到另一个环境。
  3. 等新版本健康检查通过。
  4. 改 Nginx upstream。
  5. reload Nginx。
  6. 记录当前流量已经切到新版本。
#!/usr/bin/env bash
set -euo pipefail

mkdir -p .deploy

if [ ! -f .deploy/current_target ]; then
  printf 'blue\n' > .deploy/current_target
fi

if [ ! -f nginx/active_backend.conf ]; then
  printf 'upstream active_backend {\n    server web-blue:3000;\n    keepalive 32;\n}\n' > nginx/active_backend.conf
fi

current="$(tr -d '[:space:]' < .deploy/current_target)"

if [ "$current" = "blue" ]; then
  new="green"
else
  new="blue"
fi

image_tag="${1:-$(git rev-parse --short HEAD 2>/dev/null || echo latest)}"

echo "当前流量在 $current"
echo "准备部署到 $new,镜像 tag: $image_tag"

# 本地构建就改成 docker compose build,远程镜像就 pull
APP_VERSION="$image_tag" docker compose pull "web-$new" || true
APP_VERSION="$image_tag" docker compose up -d --no-deps "web-$new"

cid="$(docker compose ps -q "web-$new")"

until [ "$(docker inspect --format='{{.State.Health.Status}}' "$cid")" = "healthy" ]; do
  echo "等待 web-$new 健康检查通过..."
  sleep 2
done

# 切流量
printf 'upstream active_backend {\n    server web-%s:3000;\n    keepalive 32;\n}\n' "$new" > nginx/active_backend.conf

docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload

printf '%s\n' "$current" > .deploy/previous_target
printf '%s\n' "$new" > .deploy/current_target

echo "流量已切换到 $new,镜像 tag: $image_tag"

脚本里最关键的几行其实就是生成新的 active_backend.conf,然后 nginx -s reload。别小看这个,前面部署、健康检查、等待、镜像拉取,都是为了这几行命令别出事。

如果你用的是本地镜像,没有 registry,那把 docker compose pull 那段换成:

APP_VERSION="$image_tag" docker compose build "web-$new"
APP_VERSION="$image_tag" docker compose up -d --no-deps "web-$new"

不过本地 build 的话,blue 和 green 很容易指向同一个镜像 tag,测试切换不太直观。最好给每个版本打个 tag,比如 v20260912-abc123

回滚脚本也别省

蓝绿部署最大的好处就是回滚快。发现新版本不行,只要把 Nginx 再切回旧环境就行了。旧环境别急着 stop,先留着观察几分钟。

rollback.sh 可以这样:

#!/usr/bin/env bash
set -euo pipefail

target="$(tr -d '[:space:]' < .deploy/previous_target)"

if [ -z "$target" ]; then
  echo "没有 previous_target,不能回滚"
  exit 1
fi

printf 'upstream active_backend {\n    server web-%s:3000;\n    keepalive 32;\n}\n' "$target" > nginx/active_backend.conf

docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload

printf '%s\n' "$target" > .deploy/current_target

echo "流量已回滚到 $target"

这个脚本只是切流量,不会立刻删除刚部署失败的新版本。我个人建议先别删,留着看看日志,等确认问题定位完了再 docker compose stop 或者 rm

第一次跑起来的命令

假设你把应用镜像推到 registry 了,或者本地已经 build 出 latest,那先起来一组:

cd blue-green-demo
docker compose up -d
docker compose ps
curl -i http://localhost/healthz

如果你能看到 200,说明 blue 活着,Nginx 也在正常转发。然后部署新版本:

./deploy.sh v20260912-fix-login

切过去以后再看:

curl -i http://localhost/healthz
docker compose logs --tail 100 web-green
docker compose logs --tail 100 nginx

不出问题的话,基本就算部署成功了。

几个真会踩到的坑

第一,别把 blue 和 green 的端口直接暴露给用户。蓝绿部署的前提是流量入口只有一个。你要是同时开放多个应用端口,那切换就没什么意义了。

第二,健康检查别写得太松。我见过有人 healthz 只返回 {},但是数据库已经挂了,页面照样 200。这种健康检查等于没做。至少检查依赖,比如数据库、Redis、外部接口能不能通。

第三,切换 Nginx 后不要马上杀旧容器。最好留 5 到 30 分钟,具体时间看你业务。要是用户有长连接、WebSocket、大文件上传,切流量那一瞬间可能会有点影响。这个要提前知道,不要等用户反馈了才想起来。

第四,数据库迁移不能想当然。蓝绿部署只是解决应用容器切换,不会替你解决 schema 不兼容的问题。如果新版本要改表结构,最好做向后兼容:先加新字段,旧版本还能跑;切换完成以后,再清理旧字段。要是迁移脚本会把旧版本打挂,那就不叫回滚了,叫现场火化。

第五,镜像 tag 别滥用 latest。演示用一下没什么,线上真的会出事故。你这次部署 latest,下次还是 latest,回滚的时候发现两边镜像一模一样,那才尴尬。

这套流程适合什么情况

中小团队、单体 Docker 应用、一两台服务器、不需要特别复杂的发布策略,用这套就挺舒服。它比直接重启容器安全一点,也比一上来就搞平台简单一点。

我现在的习惯是:本地开发随便 restart,测试环境用 Docker Compose 起 blue/green,预发和生产再考虑更严格的镜像签名、流水线、健康门禁和监控。蓝绿部署本身没那么神秘,关键就是别把“切换”想得太高级,它很多时候就是换一个 upstream,然后 reload Nginx。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放