为什么要分三阶段上线
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-stage | pipe-monitor-api | 复用公共 EMQX broker.emqx.io:1883 |
| private-mqtt-stage | pipe-monitor-mqtt(Mosquitto) | API 切到自建 broker |
| mysql-stage | pipe-monitor-mysql | API 加 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-mqtt挂private-mqtt:第二阶段才显式--profile private-mqtt启用pipe-monitor-mysql挂storage:第三阶段叠加--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-api为Upcurl 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-mqtt 用 passwordfile + 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/status的teleTotal持续递增
回退路径
把 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-mqtt 和 storage:
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=true且db.connected=true/api/latest?dev=FM001仍返回当前遥测值(不因切库而丢最新值)
回退路径
第三阶段回退有两种粒度:
- 只停 MySQL、保留 MQTT:
docker 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-recreate,restart不会重新注入环境变量- MySQL 密码用
${VAR:?error}语法让缺失时显式 fail,避免空密码裸奔 - 小项目分阶段上线的方法论可复用:复用公共基础设施、最小可行部署、文档驱动回退
后续阅读
- Docker 容器化部署与 Node.js 后端——
docker-compose.yml整体设计与 API 容器构建细节 - 自建 MQTT Broker:Mosquitto 鉴权与 ACL 配置——第二阶段 Mosquitto 凭据与 ACL 的深度拆解
- MySQL 存储层:自动建表、历史幂等性与游标分页——第三阶段启用 storage profile 后的数据库层设计