一个 Web 服务器可以在眨眼间重启,而没有人察觉。一个 AI Agent 服务却做不到——它持有状态:一个正做到一半的 Hermes 会话、一个有三个 Agent 在协商的 im-bot 房间、一个发布到一半的 cron 任务、一个刚写完四行中三行的 PostgreSQL 事务。用一句朴素的 docker compose up --force-recreate 部署新版本,你就会在用户面前把这一切统统拆掉。蓝绿部署就是答案:并排运行两套完全相同的栈,用 sun-port 在它们之间切换流量,如果新版本行为异常,一条命令即可回滚。本教程端到端地搭建这条流水线。
一个无状态 API 通过增加实例来扩展,通过替换实例来重新部署。而 Agent 服务有四个特性,会打破这个模型:
蓝绿部署通过从不拆掉正在运行的系统来解决这四个问题。你在旧版本旁边立起新版本,验证它,然后拨动开关。旧版本一直保持温热,直到你确认无误。
两套环境,一个数据库,一个入口:
让这一切成立的纪律是:绿必须在蓝被动到之前就部署好,并且在切换窗口期内,数据库必须同时接受两个版本。
$ docker --version
Docker version 27.3.1, build ce12230
$ docker compose version
Docker Compose version v2.29.7
$ psql --version
psql (PostgreSQL) 16.4
$ git --version
git version 2.46.0
你还需要一个运行中的 sun-port(见我们的反向代理教程),以及一个两套栈都能访问的 PostgreSQL 实例。如果你还没把 Agent 本身搭起来,先从Ubuntu 上手教程开始。
只有当每个版本都是一个你能启动、停止和回滚的自包含镜像时,这套栈才成立。一个面向 Hermes 或 im-bot 服务的最小 Dockerfile,会在构建时钉死运行时并把代码烧进去:
# Dockerfile
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
CMD ["node", "src/index.js"]
构建绿镜像,并用 Git 发布标签为它打标,这样每一个部署产物都能追溯到一个提交:
$ git tag v1.4.0 && git push origin v1.4.0
$ docker build -t agent-service:v1.4.0 .
$ docker tag agent-service:v1.4.0 agent-service:green
green 标签?你的 Compose 文件引用的是 green,所以你部署时无需编辑 Compose——只需给刚构建的镜像重新打标。回滚同样简单:把 green 重新指向上一个可用的 v1.3.0,再把流量切回去。标签就是你的部署历史。用 Docker Compose 部署两套环境,它们只在名称、端口和镜像标签上不同:
# docker-compose.yml — 两套栈定义在同一个文件里
services:
agent-blue:
image: agent-service:v1.3.0
container_name: agent-blue
restart: unless-stopped
environment:
DATABASE_URL: postgres://agent:${DB_PASSWORD}@postgres:5432/agentdb
RELEASE_COLOR: blue
ports:
- "127.0.0.1:8001:8001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 10s
timeout: 5s
retries: 5
agent-green:
image: agent-service:green
container_name: agent-green
restart: unless-stopped
environment:
DATABASE_URL: postgres://agent:${DB_PASSWORD}@postgres:5432/agentdb
RELEASE_COLOR: green
ports:
- "127.0.0.1:8002:8002"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
interval: 10s
timeout: 5s
retries: 5
postgres:
image: postgres:16
environment:
POSTGRES_DB: agentdb
POSTGRES_USER: agent
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
有三点要注意。端口绑定到 127.0.0.1——两套栈都无法从公网访问,只有 sun-port 可以。一个共享的 postgres 服务意味着两种颜色读写同一份数据,所以切换对状态是透明的。此外,RELEASE_COLOR 被传入应用,让应用自身的日志和健康页能报告自己是哪种颜色——你稍后就会用到它。
$ docker compose up -d
$ docker compose ps
NAME STATUS
agent-blue Up (healthy)
agent-green Up (healthy)
postgres Up (healthy)
sun-port 就是真正发生切换的地方。定义一个指向蓝的 upstream,等准备好后再把它翻到绿——这是一次重载,而不是重新部署:
# sun-port 配置
upstream agent_backend {
server 127.0.0.1:8001; # 蓝
}
server {
listen 443 ssl;
server_name agent.agent-rule.com;
ssl_certificate /etc/letsencrypt/live/agent.agent-rule.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/agent.agent-rule.com/privkey.pem;
location / {
proxy_pass http://agent_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
切换就是一行编辑加一次重载:
# 蓝 -> 绿 切换
upstream agent_backend {
server 127.0.0.1:8002; # 绿
}
$ sun-port -t # 校验配置
configuration file is valid
$ sun-port -s reload # 热重载,不丢连接
reload signal sent
数据库是两种颜色共享的唯一资源,所以 schema 变更必须同时兼容两个版本。规则是扩展-收缩(expand-contract):
一个用 psql 执行的安全加法迁移:
$ psql "$DATABASE_URL" <<'SQL'
ALTER TABLE conversations
ADD COLUMN IF NOT EXISTS model_provider TEXT NOT NULL DEFAULT 'openai';
CREATE INDEX IF NOT EXISTS idx_conversations_model
ON conversations (model_provider);
SQL
ALTER TABLE
CREATE INDEX
ADD COLUMN IF NOT EXISTS 和默认值,是让迁移对任何一种颜色都可运行的两个习惯。一旦你需要删除一列或改一个类型,那就是横跨两个发布的两步舞步——绝不要在切换流量的同一次部署里做这件事。
在切换流量之前,先证明绿是健康的——不只是在运行,而是真正在工作。一次真实的检查直接命中绿容器,确认它能访问数据库并报告自己的颜色:
$ curl -sf http://127.0.0.1:8002/health
{"status":"ok","color":"green","db":"up"}
$ curl -sf http://127.0.0.1:8002/health | grep -q '"db":"up"' \
&& echo "green 已就绪" || echo "green 尚未就绪——不要切换"
green 已就绪
color 字段是你的安全网:如果 green 某天返回了 "color":"blue",说明你的浮动标签被指错了,你差点把旧版本当成新版本部署上去。切换要同时以 status 和 db 为把关条件。
把整个切换封装进一个脚本,让它可复现——也让凌晨两点疲惫的运维人员无法跳过某一步:
#!/usr/bin/env bash
# deploy.sh — 蓝绿切换
set -euo pipefail
COLOR=${1:-green} # 把流量发给哪种颜色
PORT=$([ "$COLOR" = green ] && echo 8002 || echo 8001)
# 1. 对目标做健康把关
curl -sf "http://127.0.0.1:$PORT/health" | grep -q '"db":"up"' \
|| { echo "target $COLOR 不健康"; exit 1; }
# 2. 把 sun-port 指向它
sed -i "s/server 127.0.0.1:800[0-9];/server 127.0.0.1:$PORT;/" \
/etc/sun-port/sun-port.conf
# 3. 校验并重载
sun-port -t
sun-port -s reload
echo "流量现在在 $COLOR 上"
$ ./deploy.sh green
流量现在在 green 上
回滚就是同一个脚本、换成另一种颜色——而且因为旧栈从未被拆掉,回滚是即时的:
$ ./deploy.sh blue # 绿行为异常——一条命令回到蓝
流量现在在 blue 上
蓝绿是一种流程,而流程住在 Git 里。保持简单和线性:
$ git checkout -b release/v1.4.0
$ # ...代码改动、测试...
$ git tag v1.4.0
$ git push origin v1.4.0
三个习惯让这条流水线保持诚实:
agent-service:v1.4.0 应该能从 git checkout v1.4.0 重建出来。否则,回滚就是靠猜。deploy.log,或一个注明「switched blue→green v1.4.0」的打标提交,会给你留下审计轨迹——这与我们可观测性教程里的纪律一致。蓝绿解决了基础设施问题,但 Agent 还带来两个值得提前规划的麻烦:
scheduled_jobs 表上用 SELECT ... FOR UPDATE SKIP LOCKED——这样只有活跃的那一种颜色能拿到租约。最干净的模式:在切换窗口期内(几分钟到一小时)让蓝保持完全存活,让进行中的工作自然排空,等它安静下来后,再 docker compose stop agent-blue。零丢弃工作,零停机。
0.0.0.0。一个绑定到所有网卡的绿栈,公网都能访问,完全绕过 sun-port 的 TLS。绑定 127.0.0.1。green 还指着旧镜像,你就「部署」了个寂寞,然后纳闷 bug 为什么还在。sun-port -s reload 是优雅的;重启 sun-port 会丢连接。永远重载。db:"up" 和正确的 color。蓝绿部署把运行 Agent 基础设施时最吓人的那部分——「新版本会不会把一切都搞垮?」——变成一次可逆的、一条命令的操作。对于一个通过 sun-port 编排 Hermes Agent、im-bot 房间、Docker 工作负载和 PostgreSQL 状态的集群来说,这正是「在周五部署」与「在周五信心满满地部署」之间的差别。