为什么要分三阶段上线

PipeMonitor 的后端由三个独立服务组成:pipe-monitor-api(Node.js 业务层)、pipe-monitor-mqtt(Mosquitto 私有 broker)、pipe-monitor-mysql(持久化存储)。最早一版想一次 docker compose up -d 全拉起来,结果暴露出几个问题:

  • API 还没验证通就启 Mosquitto,DR154 切到私有 broker 后第一时间分不清是 API 订阅错了还是 broker 鉴权错了
  • MySQL 和 API 同时起,createPool 拿到 ECONNREFUSED 时没法判断是镜像没拉下来还是密码写错
  • 任何一步出问题都要把整套栈停掉重头来,回退成本高

所以最后拆成三阶段,每阶段只引入一个新变量

阶段引入的新组件复用情况
manual-first-stagepipe-monitor-api复用公共 EMQX broker.emqx.io:1883
private-mqtt-stagepipe-monitor-mqtt(Mosquitto)API 切到自建 broker
mysql-stagepipe-monitor-mysqlAPI 加 storage profile

这套切分不是凭感觉拍的,而是按”出问题时能定位到哪一层”反推出来的。下面逐阶段拆解。

Docker Compose profiles:按需启用服务

三阶段能干净切分的前提是 docker-compose.yml 用了 profiles 把可选服务隔离开。核心片段:

services:
  pipe-monitor-mysql:
    image: mysql:8.4
    profiles:
      - storage            # 仅在 --profile storage 启用时拉起
    # ... 小内存实例参数

  pipe-monitor-mqtt:
    image: eclipse-mosquitto:2
    profiles:
      - private-mqtt       # 仅在 --profile private-mqtt 启用时拉起
    ports:
      - "1883:1883"

  pipe-monitor-api:
    build:
      context: ./services/pipe-monitor-api
    # 无 profiles,始终启用——第一阶段就只有它

关键点:

  • pipe-monitor-api 不挂 profile:任何时候 docker compose up -d 都会拉起它,对应第一阶段”只上 API”
  • pipe-monitor-mqttprivate-mqtt:第二阶段才显式 --profile private-mqtt 启用
  • pipe-monitor-mysqlstorage:第三阶段叠加 --profile storage
  • Compose 的 profiles 是累加的,第三阶段命令是 --profile private-mqtt --profile storage,两个 profile 一起激活

这样每个阶段对应的启动命令就是一条线:

# 第一阶段:只有 API
docker compose up -d --build

# 第二阶段:API + 私有 MQTT
docker compose --profile private-mqtt up -d --build

# 第三阶段:API + 私有 MQTT + MySQL
docker compose --profile private-mqtt --profile storage up -d --build

profiles 的好处是未启用的服务完全不参与编排,不会因为缺 .env 里的 DB 密码就启动失败。这比用多个 compose 文件管理简单得多。

第一阶段:manual-first-stage

阶段目标

只把 pipe-monitor-api 推到 VPS,设备仍连公共 EMQX broker.emqx.io:1883。这样能先验证:镜像能 build、Nginx 反代能通、HTTPS 证书能签、API 能订阅公共 broker——把网络与部署链路全部跑通,不引入任何自建基础设施

上传前本机校验

本地 PowerShell 跑一遍 check + 容器起 + health:

cd D:\Project\PipeMonitor\Platform\services\pipe-monitor-api
npm run check

cd D:\Project\PipeMonitor\Platform
docker compose up -d --build
curl http://127.0.0.1:3000/health

期望返回:

{"ok":true,"service":"pipe-monitor-api","mqttConnected":true}

mqttConnected: true 说明 API 已经能连上默认的 broker.emqx.io:1883 并完成订阅。这一步在本机过了,上服务器后才不会在镜像构建层浪费时间。

服务器侧启动

VPS 目标目录 /opt/varka,SSH 端口 50022,部署用户 mydei。拉代码后直接起:

cd /opt/varka
docker compose up -d --build
docker compose ps
docker compose logs --tail 80 pipe-monitor-api
curl http://127.0.0.1:3000/health

然后配 Nginx 反代 + Certbot 签 HTTPS:

sudo cp /opt/varka/nginx/pipe-monitor.varka.cn.conf /etc/nginx/conf.d/
sudo nginx -t && sudo systemctl reload nginx
curl http://pipe-monitor.varka.cn/health

# HTTP 通了再注入 HTTPS
sudo certbot --nginx -d pipe-monitor.varka.cn
sudo nginx -t && sudo systemctl reload nginx
curl https://pipe-monitor.varka.cn/health

验收清单

  • docker compose ps 显示 pipe-monitor-apiUp
  • curl https://pipe-monitor.varka.cn/health 从服务器外可达
  • 日志显示 MQTT 已连 mqtt://broker.emqx.io:1883 并订阅 device/FM001/up
  • device/FM001/up 发一条 tele 测试帧,/api/latest?dev=FM001 返回该 payload

Out Of Scope(本阶段明确不做)

  • 不上 pipe-monitor-mqtt(Mosquitto 私有 broker)
  • 不上 MySQL 持久化、不实现 /api/history/api/alarms/ws/live
  • 不接 GitHub Actions 自动部署
  • 不切 DR154 设备的 broker 地址

Out Of Scope 写在文档里是关键:它把”这阶段该做什么”和”以后再做”划清边界,避免边部署边加功能导致回归测试失焦。

回退路径

第一阶段本身就是最小集,回退就是把容器停掉、Nginx conf 删掉:

cd /opt/varka
docker compose down
sudo rm /etc/nginx/conf.d/pipe-monitor.varka.cn.conf
sudo nginx -t && sudo systemctl reload nginx

设备无需任何改动,因为它还在连公共 EMQX。

第二阶段:private-mqtt-stage

阶段目标

pipe-monitor-mqtt(Mosquitto),DR154 和 API 都切到自建 broker。第一阶段已经验证了 API 本身工作正常,这一阶段引入的唯一新变量就是 broker 鉴权与网络可达性。

安全组与端口

VPS 安全组新增:

1883 TCP    # MQTT

保留:

50022 SSH
80    HTTP / 证书续期
443  HTTPS API

生成 Mosquitto 凭据

pipe-monitor-mqttpasswordfile + aclfile 做用户级鉴权。两个用户:dr154-fm001(设备侧发布账号)和 pipe-monitor-api(API 侧订阅账号)。首次创建用 -c,之后追加用户不能带 -c,否则会覆盖整个文件:

cd /opt/varka
test -f pipe-monitor-mqtt/aclfile || cp pipe-monitor-mqtt/aclfile.template pipe-monitor-mqtt/aclfile

# 仅首次创建 passwordfile 用 -c
if [ ! -f pipe-monitor-mqtt/passwordfile ]; then
  docker run --rm -it -v "$PWD/pipe-monitor-mqtt:/mosquitto/config" eclipse-mosquitto:2 \
    mosquitto_passwd -c /mosquitto/config/passwordfile dr154-fm001
else
  docker run --rm -it -v "$PWD/pipe-monitor-mqtt:/mosquitto/config" eclipse-mosquitto:2 \
    mosquitto_passwd /mosquitto/config/passwordfile dr154-fm001
fi

# 追加 API 用户
docker run --rm -it -v "$PWD/pipe-monitor-mqtt:/mosquitto/config" eclipse-mosquitto:2 \
  mosquitto_passwd /mosquitto/config/passwordfile pipe-monitor-api
sudo docker exec pipe-monitor-mqtt kill -HUP 1

两个账号用不同的强密码,密码文件不进 Git。

切换 API 到私有 broker

.env 写私有 broker 连接信息:

cat > .env <<'EOF'
PIPE_MONITOR_MQTT_URL=mqtt://pipe-monitor-mqtt:1883
PIPE_MONITOR_MQTT_USERNAME=pipe-monitor-api
PIPE_MONITOR_MQTT_PASSWORD=<pipe-monitor-api-password>
PIPE_MONITOR_MQTT_UP_TOPIC=device/FM001/up
EOF
chmod 600 .env

启动带 private-mqtt profile:

cd /opt/varka
docker compose --profile private-mqtt up -d --build
docker compose --profile private-mqtt up -d --force-recreate pipe-monitor-api
docker compose --profile private-mqtt ps

API 日志期望:

[mqtt] connected mqtt://pipe-monitor-mqtt:1883
[mqtt] subscribed device/FM001/up(q0)

用容器内 mosquitto_pub 自测

不依赖外部设备,直接在 Docker 网络内发测试帧验证订阅链路:

docker run --rm --network varka_default eclipse-mosquitto:2 \
  mosquitto_pub -h pipe-monitor-mqtt -p 1883 \
  -u dr154-fm001 -P '<dr154-fm001-password>' \
  -t device/FM001/up \
  -m '{"t":"tele","ts":1777019000,"seq":1,"dev":"FM001","flow":0,"total":0,"v":0,"pres":0,"temp":[20.5,null,null,null,null,21.0,21.3],"heart_count":10,"valid":63}'

然后从 VPS 外验证:

curl https://pipe-monitor.varka.cn/api/status
curl "https://pipe-monitor.varka.cn/api/latest?dev=FM001"

容器内自测通过后,再用 MQTTX 从公网验证 1883 可达性,最后才切真实 DR154。

验收清单

  • API 日志显示连到 mqtt://pipe-monitor-mqtt:1883
  • 容器内 mosquitto_pub 发帧后 /api/latest?dev=FM001 返回新值
  • MQTTX 公网连接 1883 不出现 not authorised
  • DR154 切到私有 broker 后日志显示真实 tele FM001
  • /api/statusteleTotal 持续递增

回退路径

把 API 切回公共 broker,DR154 临时也切回:

cd /opt/varka
mv .env .env.private-mqtt
docker compose up -d --build pipe-monitor-api

注意这里没有 --profile private-mqtt,所以 pipe-monitor-mqtt 不会被拉起,API 走 docker-compose.yml 里的默认 MQTT_URL 回退逻辑(如果配的话)或显式指向 broker.emqx.io。DR154 端单独改回去即可。

第三阶段:mysql-stage

阶段目标

叠加 storage profile,启 pipe-monitor-mysql,API 加上持久化。API 形状和 MQTT 流不变,只是 /api/history/api/alarms 这类依赖存储的接口开始可用。

前置条件

  • 第二阶段已稳定运行
  • pipe-monitor-api 通过 https://pipe-monitor.varka.cn/health 健康
  • /opt/varka/.env 已存在(第二阶段建立的私有 broker 配置)

补全 .env

在现有 .env 上追加 DB 配置:

PIPE_MONITOR_DB_HOST=pipe-monitor-mysql
PIPE_MONITOR_DB_PORT=3306
PIPE_MONITOR_DB_NAME=pipe_monitor
PIPE_MONITOR_DB_USER=pipe_monitor
PIPE_MONITOR_DB_PASSWORD=<pipe-monitor-db-password>
PIPE_MONITOR_MYSQL_ROOT_PASSWORD=<pipe-monitor-mysql-root-password>

docker-compose.yml 里 MySQL 容器对密码缺失是显式 fail而不是默默用默认值:

MYSQL_PASSWORD: "${PIPE_MONITOR_DB_PASSWORD:-${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set in .env}}"
MYSQL_ROOT_PASSWORD: "${PIPE_MONITOR_MYSQL_ROOT_PASSWORD:-${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set in .env}}"

? 语法让 Compose 在变量未设置时直接报错退出,避免 MySQL 用空密码起来变成裸奔服务。

启动 storage profile

profile 是累加的,第三阶段必须同时带 private-mqttstorage

cd /opt/varka
docker compose --profile private-mqtt --profile storage up -d --build
docker compose --profile private-mqtt --profile storage ps
docker compose logs --tail 80 pipe-monitor-mysql
docker compose logs --tail 80 pipe-monitor-api

API 日志期望:

[db] connected mysql://pipe-monitor-mysql:3306/pipe_monitor

.env 变更后必须 recreate 而非 restart

docker compose restart 不会重新读 environment 段,只重启进程。改了 .env 后必须用 up --force-recreate

docker compose --profile private-mqtt --profile storage up -d --force-recreate pipe-monitor-api

这是 Docker Compose 一个容易踩的坑:restart 看起来像是”重启服务”,但环境变量是容器创建时注入的,只有 recreate 才会重新走一遍 environment 解析。

验收清单

curl https://pipe-monitor.varka.cn/health
curl https://pipe-monitor.varka.cn/api/status
curl "https://pipe-monitor.varka.cn/api/latest?dev=FM001"
  • /health 返回 ok: true
  • /api/status 显示 db.enabled=truedb.connected=true
  • /api/latest?dev=FM001 仍返回当前遥测值(不因切库而丢最新值)

回退路径

第三阶段回退有两种粒度:

  • 只停 MySQL、保留 MQTTdocker compose --profile private-mqtt up -d --force-recreate pipe-monitor-api(不带 storage profile,MySQL 容器不拉起,API 进入无 DB 模式)
  • 直接回第一阶段:删 .env 里 DB 相关行,重启 API

由于 pipe-monitor-api 设计上支持 DB 不可用的降级运行(/api/status 显示 db.connected=false/api/latest 仍走内存),回退不需要停整个服务。

小项目分阶段上线的工程方法论

三阶段切分看似是为了 PipeMonitor 这个具体项目写的,但背后的方法论可以套到任何”小团队 + 多组件 + 公网部署”的项目上:

1. 每阶段只引入一个新变量

三阶段分别引入”API 镜像”、“私有 broker”、“持久化存储”。出问题时排查范围被天然限定在本阶段新增的组件上。如果一次上三个组件,API 不工作的原因可能是:镜像构建错、Nginx 配错、证书没签、broker 鉴权错、DB 密码错、API 连 DB 超时——六种可能混在一起,定位成本指数级上升。

2. 复用公共基础设施做”最小可行部署”

第一阶段复用 broker.emqx.io 这个公共 EMQX,等于把”broker 是否正常”这个变量先排除掉。公共 broker 不稳是已知的,但它不稳你也能跑通 API → HTTPS → 设备发帧 → API 收到这条链路。链路通了再换自建 broker,问题就被隔离到 broker 这一层。

3. Out Of Scope 写在文档里

每阶段文档末尾都有 Out Of Scope 一节,明确列出”本阶段不做的事”。这不是给读者看的,是给未来部署时的自己看的:避免在第二阶段手痒顺便把 MySQL 也起了,结果出问题又分不清是哪层。

4. 验收清单是可执行的命令

每个验收项都是 curl / docker compose ps / 日志关键字,而不是”看起来正常”。这样验收可重复、可自动化——后续接 GitHub Actions 时,这些断言可以直接变成 CI 步骤。

5. 回退路径在文档里写死

每阶段都有 Rollback 一节,给出具体命令。回退不需要现场想,照着粘贴就行。关键是回退不依赖于”上一次成功的状态”:第一阶段回退就是 docker compose down,第二阶段回退就是 mv .env .env.private-mqtt 然后不带 profile 起 API——每一步都是确定性的。

6. profiles 让”阶段”变成可执行命令而不是文档概念

如果用三份 compose 文件管理三阶段,文件之间会漂移、合并冲突会变多。profiles 把所有服务定义都放在一个 docker-compose.yml 里,阶段切换只是命令行参数变化:

docker compose up -d                                          # 阶段一
docker compose --profile private-mqtt up -d                   # 阶段二
docker compose --profile private-mqtt --profile storage up -d # 阶段三

这样”现在跑在第几阶段”是运行时可观测的,docker compose ps 一看就知道哪些服务起来了。

小结

  • 三阶段切分的核心是每阶段只引入一个新变量:API → 私有 MQTT → MySQL,出问题时排查范围天然收窄
  • Docker Compose profiles 让阶段切换变成命令行参数,未启用的服务完全不参与编排,不因缺 .env 报错
  • 每阶段都写 Out Of Scope 明确边界,避免边部署边加功能导致回归失焦
  • 验收清单必须是可执行命令(curl / 日志关键字),不是”看起来正常”
  • 回退路径写死在文档里,且不依赖”上一次成功状态”,每步都是确定性操作
  • .env 改动后必须 up --force-recreaterestart 不会重新注入环境变量
  • MySQL 密码用 ${VAR:?error} 语法让缺失时显式 fail,避免空密码裸奔
  • 小项目分阶段上线的方法论可复用:复用公共基础设施、最小可行部署、文档驱动回退

后续阅读