Rocket.Chat 从 7.x 升级到 8.x,看起来像一次普通的容器镜像升级,但在实际环境中,真正需要处理的不只是 Rocket.Chat 本身。
这次升级同时涉及:
- Rocket.Chat 跨主版本升级
- MongoDB 从 7.0 系列迁移到 8.2 系列
- MongoDB 容器镜像从旧的第三方镜像切换到官方 Community Server 镜像
- 数据卷布局变化
- Replica Set 重新初始化
- 数据逻辑迁移与完整性验证
- Rocket.Chat 数据库 migration
- Apps Engine / Moleculer 启动异常排查
- 最终清理旧数据卷、临时备份和迁移镜像
整个过程最重要的并不是“把新镜像跑起来”,而是保证:
任何一个阶段出现问题,都能够明确知道数据是否安全、问题出在哪里,以及能否回退。
一、为什么不能只修改镜像版本然后 docker compose up
旧环境采用的是典型的 Docker Compose 部署:
Rocket.Chat 7.x
│
▼
MongoDB 7.0
MongoDB 使用的是较早时期常见的第三方容器镜像。
升级到 Rocket.Chat 8.x 后,数据库环境也需要同步调整。新的目标结构变成:
Rocket.Chat 8.5.x
│
▼
MongoDB Community Server 8.2
这带来了一个非常重要的问题:
旧 MongoDB 数据目录不能简单地直接挂载到新的 MongoDB 8.2 容器里。
MongoDB 的数据目录内部包含:
- WiredTiger 数据文件
- journal
- catalog
- collection metadata
- index metadata
- replica set 状态
跨多个 MongoDB 大版本直接复用底层数据目录,风险远高于普通容器镜像升级。
因此这次采用的不是“原地替换数据库镜像”,而是:
旧 MongoDB 7
│
│ mongodump
▼
逻辑备份
│
▼
新 MongoDB 8.2
│
│ mongorestore
▼
新数据库
也就是说,迁移的是逻辑数据,而不是直接让 MongoDB 8.2 接管 MongoDB 7 的物理文件。
二、升级前先建立可验证的数据库基线
在修改任何东西之前,先记录数据库当前状态。
例如检查:
MongoDB 版本
FCV
Replica Set 状态
数据库集合数量
用户数量
消息数量
上传文件数量
文件块数量
这里最重要的不是某一个具体数字,而是建立一个可以在迁移结束后重新比对的基线。
例如:
Before migration
Collections N
Users N
Messages N
Uploads N
Upload files N
Upload chunks N
迁移后重新执行相同统计。
如果:
before == after
至少能够确认核心数据数量没有因为迁移出现明显丢失。
这比单纯看到:
mongorestore completed successfully
更有价值。
因为“restore 命令返回成功”并不等于“应用数据一定正确”。
三、使用两层备份,而不是只保留一个 dump
迁移过程中采用了两种不同用途的保护措施。
第一层:逻辑备份
在旧数据库运行期间先执行一次:
mongodump --archive --gzip
然后检查:
- gzip 是否完整
- archive 是否能够被
mongorestore解析 - 文件哈希是否稳定
这是一份预备备份。
正式迁移前停止 Rocket.Chat,使数据库不再接收应用写入,再生成最终 dump:
Rocket.Chat stopped
MongoDB still running
│
▼
final mongodump
这样得到的是一个应用静止状态下的一致性备份。
第二层:物理数据卷副本
仅有 dump 仍然不够。
因为逻辑备份适合数据恢复,但如果需要快速恢复整个旧 MongoDB 环境,物理数据卷更加直接。
因此在旧 MongoDB 正常关闭以后,将整个旧数据卷做一次冷复制:
Old MongoDB volume
│
▼
Rollback volume
复制完成后不是只比较目录大小,而是进一步检查:
文件数量
SHA256
逐文件一致性
最终确认:
all regular files are byte-identical
这一点很重要。
du 显示的空间大小可能因为文件系统 block allocation 存在少量差异,不能单独用来判断复制失败。
逐文件哈希一致,才是更可靠的判断依据。
四、保留原数据卷名称,但让它承载全新的 MongoDB 8 数据
Docker volume 本身没有真正的“rename”操作。
如果希望升级结束后仍然保持原有的正式卷名称,可以使用下面的方式:
旧正式卷
│
├── 冷复制 → rollback volume
│
└── 删除旧正式卷名称
│
▼
Docker Compose 创建新的同名卷
│
▼
MongoDB 8.2
这样最终生产环境仍然只有:
mongodb_data
uploads
而不会永久留下类似:
mongodb_new
mongodb_v2
mongodb_82
mongodb_migrated
这样的历史命名。
从长期维护角度看,这种方式更干净。
迁移期间,旧物理数据则放在单独的 rollback volume 中。
五、MongoDB 8.2 使用全新的数据目录启动
新的 MongoDB 容器使用官方 Community Server 镜像。
启动顺序被拆成了几个明确阶段:
Permission initialization
│
▼
MongoDB start
│
▼
Health check
│
▼
Replica Set initialization
│
▼
Rocket.Chat
这种设计的好处是:
Rocket.Chat 不会在 MongoDB 尚未完成基础初始化时立即启动。
数据库首先必须通过:
db.adminCommand("ping")
然后初始化 Replica Set:
rs.initiate(...)
最后确认:
PRIMARY
health = 1
这时才进入数据恢复阶段。
六、不要把 Rocket.Chat 和数据库迁移同时进行
新的 MongoDB 启动后,Rocket.Chat 并没有立即启动。
先恢复数据库:
mongorestore
恢复完成后,再检查关键数据:
Collections
Users
Messages
Uploads
Upload files
Upload chunks
结果全部和迁移前一致。
然后进一步执行 MongoDB collection validation。
不仅验证普通 metadata,还重点检查:
users
messages
settings
uploads
upload metadata
upload chunks
最终要求:
valid: true
errors: []
warnings: []
做到这里以后,才可以说:
MongoDB 数据迁移基本完成。
而不是在 mongorestore 返回 0 的那一刻就认为数据库迁移已经成功。
七、再让 Rocket.Chat 自己执行数据库 migration
MongoDB 数据恢复完成后,才启动新的 Rocket.Chat 8.5.x。
Rocket.Chat 启动后会执行自己的应用层 migration。
升级前 migration version 位于旧版本状态。
启动新版本以后,migration version 自动向前推进。
需要关注几个字段:
version
locked
buildAt
updatedAt
其中尤其重要的是:
locked: false
如果 migration 长时间保持:
locked: true
就意味着升级过程可能没有正常结束。
本次 migration 最终完成,并恢复到:
locked: false
Rocket.Chat API 同时也能够正常返回版本信息。
到这里,数据库升级和 Rocket.Chat schema 升级实际上都已经完成。
八、真正的问题出现在 Apps Engine
Rocket.Chat 本体成功启动后,日志却持续出现:
EADDRINUSE
address already in use
错误来自:
Moleculer TCP Transporter
而且并不是一次性的启动 warning。
日志表现为:
Reconnecting...
EADDRINUSE
Reconnecting...
EADDRINUSE
Reconnecting...
EADDRINUSE
不断循环。
与此同时:
Rocket.Chat HTTP API 正常
容器没有重启
MongoDB 正常
数据库 migration 已完成
这使问题变得非常有意思。
它不是传统意义上的:
端口已经被另一个 Docker container 占用
因为冲突发生在 Rocket.Chat 容器内部的 Moleculer transporter。
九、从进程层面定位内部端口冲突
进一步检查容器内部进程后发现:
node main.js
deno ... apps runtime
除了 Rocket.Chat 主 Node.js 进程之外,还有一个 Deno 子进程。
这个 Deno runtime 对应的是已经安装的 Jitsi App。
同时检查内部 TCP listener,可以看到:
Rocket.Chat node process
│
└── already listening on an internal TCP port
但 Moleculer transporter 随后又尝试监听同一个端口,于是产生:
EADDRINUSE
这说明:
冲突发生在 Rocket.Chat 自身内部服务生命周期,而不是主机 Docker 端口映射。
十、一个容易误判的地方:App 在 UI 中已经是 Disabled
最开始很容易怀疑:
Jitsi 是不是没有真正关闭?
但管理界面明确显示:
Disabled
数据库进一步检查以后发现:
top-level status: initialized
Apps Engine 日志则显示:
app:construct
app:initialize
app:setStatus -> initialized
没有:
app:onEnable
也没有进入:
manually_enabled
这说明 App 确实没有真正处于启用状态。
但 Rocket.Chat Apps Engine 仍然会:
construct
initialize
spawn runtime
也就是说:
Disabled 不一定意味着这个 App 完全不会参与 Apps Engine 的初始化过程。
这是这次排查里很容易被忽略的一点。
十一、确认不是 Deno 版本错误
由于 Rocket.Chat Apps Engine 现在依赖 Deno runtime,因此另一个怀疑方向是:
Rocket.Chat 镜像里的 Deno 版本是否不兼容?
检查后发现:
Deno 版本符合当前 Rocket.Chat 镜像要求
Node.js 版本也正常
因此:
Deno binary version mismatch
这一方向被排除。
问题继续指向:
App runtime
+
Moleculer transporter initialization
十二、最终通过卸载 Jitsi 做因果验证
仅仅“怀疑某个 App”还不够。
最直接的验证方法是:
卸载 App
→
重启 Rocket.Chat
→
重新观察进程和日志
卸载以后再次检查数据库:
App record: []
检查容器进程:
node main.js
之前的 Deno Apps runtime 已经完全消失。
然后观察新的 Rocket.Chat 启动过程。
新的 Moleculer broker:
Transporter: TcpTransporter
Connecting to the transporter
TCP server is listening
TCP Transporter started
全部正常。
更关键的是:
EADDRINUSE
不再出现。
再次过滤:
WARN
ERROR
FATAL
EADDRINUSE
MongoServerError
MongoNetworkError
结果为空。
这构成了一条很完整的排查链:
Jitsi installed
│
▼
Deno runtime exists
│
▼
Moleculer EADDRINUSE loops
│
▼
Uninstall Jitsi
│
▼
Deno runtime disappears
│
▼
Rocket.Chat restart
│
▼
TCP transporter starts normally
│
▼
EADDRINUSE disappears
这已经能够非常有力地说明:
这次异常与 Jitsi App / Apps runtime 初始化过程高度相关。
但仍然不能仅凭这一点下结论说:
某个 Jitsi 版本本身一定存在缺陷。
因为还存在 Rocket.Chat、Apps Engine、Moleculer transporter 与 App runtime 多层交互。
更准确的描述应该是:
当前版本组合下,安装该 App 会触发 Rocket.Chat Apps runtime 与 Moleculer TCP transporter 的异常行为;卸载后问题不再复现。
十三、还有一个没有实际造成故障的索引警告
第一次启动 Rocket.Chat 8.x 时还出现过一个 MongoDB index warning。
Rocket.Chat 希望创建:
{ rid: 1, sparse: true }
但数据库里已经存在同名索引:
{ rid: 1 }
MongoDB 因为:
index name is the same
options are different
而拒绝重新创建。
这是一个值得记录的问题,但当时没有立即删除或重建索引。
原因很简单:
数据库正常
migration 正常
API 正常
应用正常
数据验证正常
在没有确认 Rocket.Chat 官方预期之前,直接:
dropIndex(...)
属于不必要的数据结构修改。
后续干净重启以后,这个 warning 也没有再次出现。
因此没有为了“让日志看起来漂亮”而主动修改数据库。
这是一个很重要的运维原则:
Warning 不等于必须立刻修复。
先确认它是否真的造成实际故障。
十四、不要在问题解决后长期保留迁移垃圾
确认系统完全正常后,迁移期间的临时资产被立即清理。
包括:
旧 MongoDB rollback volume
临时 mongodump archive
最终迁移 archive
临时复制工具镜像
最终 Docker volume 只保留:
MongoDB production volume
Rocket.Chat uploads volume
而正式的 Compose 历史配置备份继续保留。
这里需要区分两类备份。
长期配置历史
例如:
compose.yml.<timestamp>
属于配置变更记录,可以长期保存。
临时迁移资产
例如:
migration dump
rollback volume
temporary copy image
只用于一次迁移过程。
确认升级已经成功以后,应当及时删除。
否则几个月之后,很容易出现:
这个 10 GB volume 是什么?
这个 dump 还能不能删?
这是生产数据还是旧数据?
迁移完成以后马上清理,反而更加安全。
十五、这次升级真正重要的不是命令,而是顺序
整个过程最终可以压缩成下面这条链路:
旧环境验证
│
▼
建立数据基线
│
▼
在线逻辑备份
│
▼
停止 Rocket.Chat
│
▼
最终一致性 dump
│
▼
停止 MongoDB
│
▼
冷复制旧数据卷
│
▼
创建全新 MongoDB 8 数据卷
│
▼
启动 MongoDB
│
▼
初始化 Replica Set
│
▼
mongorestore
│
▼
数据数量比对
│
▼
collection validation
│
▼
启动 Rocket.Chat 8
│
▼
Rocket.Chat migration
│
▼
API 验证
│
▼
日志审计
│
▼
Apps Engine 异常排查
│
▼
卸载问题 App
│
▼
再次干净启动
│
▼
最终日志审计
│
▼
删除临时回滚资产
这个顺序最大的价值是:
每一步只有一个主要变量发生变化。
如果 MongoDB restore 以后数据不对,问题就在数据库迁移阶段。
如果数据库完全正常,但 Rocket.Chat migration 失败,问题就在应用升级阶段。
如果 Rocket.Chat 和数据库都正常,但日志出现 runtime 错误,那么再去查 Apps Engine。
这样就不会把:
MongoDB
Rocket.Chat
Docker
Replica Set
Apps Engine
Jitsi
Moleculer
六七个完全不同的变量混在一起猜。
十六、几个值得长期保留的经验
1. 跨数据库大版本迁移,优先逻辑迁移
如果底层存储格式已经跨越多个主版本:
mongodump
mongorestore
通常比直接复用物理数据目录更容易验证。
2. “能启动”远远不等于“升级完成”
至少应该确认:
container running
database PRIMARY
migration unlocked
API available
data counts match
collection validation passes
logs clean
3. 数据迁移要同时拥有逻辑回滚和物理回滚
逻辑 dump 适合恢复数据。
物理 volume 副本适合快速恢复整个旧环境。
两者用途不同。
4. 不要看到 warning 就立即修改数据库
例如索引 warning。
如果:
数据正常
migration 正常
业务正常
先调查原因,再决定是否修改。
数据库 schema 不应该成为“清理日志”的牺牲品。
5. Disabled App 仍然可能参与 runtime 初始化
这是这次最值得注意的现象之一。
Apps Engine 中:
Disabled
不一定等价于:
完全不会 construct / initialize
完全不会启动 runtime
因此排查 Apps Engine 问题时不能只看管理界面的开关状态。
6. 排查容器端口冲突时,要区分三层
Host port
Docker network port
Application internal port
本次冲突属于第三种:
Rocket.Chat container internal TCP transporter
因此修改 Docker ports: 映射完全没有意义。
7. 最好的升级日志应该能够证明“没有问题”
迁移完成以后,不只检查:
有没有明显 ERROR
还应主动过滤:
WARN
ERROR
FATAL
EADDRINUSE
exception
failed
MongoServerError
MongoNetworkError
最终得到空输出,比“页面似乎能打开”更有说服力。
结语
一次 Rocket.Chat 跨主版本升级,表面上只是更换两个 Docker image,实际涉及的却是:
应用
数据库
数据格式
Replica Set
数据卷
数据库 migration
Apps runtime
内部 service broker
回滚策略
真正可靠的升级方式,不是追求最少的命令,而是让每一个阶段都能够回答三个问题:
现在的数据是否完整?
当前异常属于哪一层?
如果失败,能否明确回到上一个稳定状态?
最终系统能够正常运行固然重要。
但更重要的是,在整个升级过程中,始终知道:
哪些数据已经验证,哪些组件已经排除,哪些异常是真正的问题,以及为什么可以安全地进入下一步。