从“夜里升级翻车”到“白天也能安心切”:Ubuntu下Docker双实例平滑升级RagFlow
先说一个我踩过的坑:某次给公司知识库升级RagFlow,按官方最常规的流程操作——拉最新代码、改配置文件、docker compose up -d,结果我这边命令刚执行完,同事就在群里说知识库连不上了,紧接着又有人反馈“正在解析的那份合同文档白搞了”。我当场意识到,这种部署方式天生带有一个空窗期:旧容器停止、新容器创建的间隙,服务是断的,而且RagFlow启动并不是秒级,实际上要等好几分钟。后来我把升级方案改成“双实例加Nginx平滑切换”,才真正做到了白天也能升级、用户无感知。
这篇文章就是针对Ubuntu服务器上Docker部署的RagFlow,完整记录我验证过的不停机升级方案。适合负责RagFlow运维、企业知识库/Agent应用部署的同学参考,尤其是那些“知识库不能断、文档解析不能丢”的生产环境。
1. 为什么RagFlow的常规升级一定会打断服务
1.1 官方默认升级流程的“隐藏停机窗口”
RagFlow官方文档里推荐的升级方式说白了就是两步:先git pull拉最新代码,再docker compose up -d让服务按新配置重建。听起来没什么问题,但如果你深挖Docker Compose的执行逻辑就会发现,up -d并不是“原地热替换”,它的实际动作是:检测到镜像或配置变化后,先停止旧的容器,再用新配置创建并启动一个新容器。这里的关键矛盾是端口,RagFlow默认把宿主机的9380端口映射到容器内的HTTP服务上,同一时间只能有一个进程监听这个端口,所以新容器必须等旧容器完全停止并释放端口,才有办法启动。
这个“先停后起”的间隙就是停机窗口。窗口本身可能只有几秒,但那只是“容器状态”层面的几秒。真正的不可用时间远比这个长,因为Docker把容器标记为“Up”不意味着RagFlow应用已经就绪。我刚接手RagFlow部署的时候,就因为只看了docker compose ps里状态变成 Up 就宣布升级完成,结果前端一直转圈,查了半天才发现应用内部还没初始化完。
1.2 RagFlow应用启动链路比你想的慢得多
RagFlow服务端启动不是一个简单的Python进程监听端口。它启动时要依次完成这些事:连接MySQL并校验元数据、连接Redis、连接Elasticsearch并校验/初始化索引、连MinIO对象存储、加载配置的Embedding模型(本地模型或在线模型),最后才是HTTP服务开始监听。任何一个依赖出问题,容器都会进入CrashLoopBackOff。
我实测过一组数据:在一台4C16G的Ubuntu服务器上,RagFlow容器从创建到健康检查通过,最快也要1分钟,最慢的一次接近4分钟——那次是因为Embedding模型要从远端拉取,网络抖动导致模型下载重试。也就是说,官方默认升级方式的真实不可用时长,不是命令执行的那几秒,而是“建容器+初始化依赖+加载模型”的完整链路。对外提供的知识库一旦断这么久,基本就是事故。
1.3 端口独占让“原地重启”这条路走不通
很多人会想:那我不重建容器,直接docker restart行不行?不行。restart只是重启同一个容器的进程,镜像和版本没变,谈不上“升级”。想要换版本,就必然要经历“旧容器退出-新容器绑定端口”的过程。除非你能接受换端口对外提供服务,否则只要入口还指向9380,就必须有一个瞬间是没人监听的。
所以结论很清晰:想做到“不停机”,不是把重启动作变快,而是要把版本切换动作从用户请求链路上摘除,让请求始终有一个可用的后端在响应,用户感知不到后端在换人。
2. 不靠运气的不停机方案:双实例加反向代理
2.1 核心思路:让新旧两个版本同时存在,用网关切流量
我的做法是在RagFlow前面加一层Nginx作为统一入口,宿主机上同时跑两套RagFlow实例:旧实例继续监听9380端口处理生产流量,新实例监听9390端口处于“待命”状态。日常Nginx把请求全部转发给旧实例;等新实例验证完毕,我修改Nginx的upstream配置把流量平滑切到新实例。
可能有人会问:这不就是蓝绿部署吗?对,就是蓝绿部署,只不过是在单机Docker Compose环境下的轻量实现。蓝绿部署看起来“土”,但对RagFlow这种单体Web应用反而是最合适的。它不依赖Kubernetes的滚动更新能力,也不需要额外搭建服务发现组件,一台Ubuntu服务器加上Nginx就能完成。
整个链路可以描述为:
用户请求 -> Nginx(:80/443) -> 旧实例 127.0.0.1:9380 -> 新实例 127.0.0.1:9390Nginx的reload是平滑重载,执行瞬间不中断现有TCP连接,已经进入旧Worker的请求会在旧Worker上正常走完,新请求则由新Worker按新配置处理。对RagFlow这种“网页+HTTP API”的短连接形态来说,平滑程度足够。
2.2 数据层怎么处理:共享还是隔离
双实例方案里最核心的技术决策不是Nginx配置,而是“新实例的数据层怎么接”。我在这上面纠结过很久,实践下来可以给出一个决策表:
| 升级场景 | 数据层策略 | 理由 |
|---|---|---|
| 小版本升级,官方release notes未提及数据库结构变更 | 共享现有MySQL/Redis/MinIO/ES | 成本最低,数据实时一致,切换后无数据同步问题 |
| 大版本升级,官方说明涉及数据库迁移或索引变更 | 完全隔离,新实例独立数据卷 | 避免新版迁移逻辑污染生产数据,切换失败可直接切回 |
| 无法确认兼容性,或生产环境数据极其重要 | 优先隔离,先导数据验证再切 | 用资源换安全,回滚容错率最高 |
共享数据层的连接方式其实不复杂。RagFlow官方docker compose里的服务名是mysql、redis、minio、elasticsearch,这些名字在Docker自定义网络里就是DNS主机名。要让新实例的server容器直接用这些名字连上生产依赖,只需要把新实例的compose网络指向生产环境的默认网络,网络名通常是<项目目录名>_default,比如/opt/ragflow/docker这个目录启动的项目网络就叫ragflow_default。
隔离数据层则是新起一套完整的compose,用-p ragflow-green指定独立项目名,这样数据卷、网络都和生产分开,切换前做备份导入。缺点是要准备两套资源,而且数据导入也需要时间,我这里给一个真实的成本参考:我迁移过约60GB的MinIO对象存储、几百MB的MySQL库,大概用了20多分钟。这个时间窗口对于升级来说可以接受,但不适合频繁切换。
2.3 资源开销到底要多少
单机跑两套RagFlow,很多人第一反应是“服务器扛得住吗”。我梳理一下各组件的大致内存占用:ragflow-server容器本身吃2-4G,Elasticsearch吃1-2G,MySQL加Redis加MinIO加起来2G左右,一套全栈下来大概6-8G。如果采用共享数据层,多跑一个新server容器,额外增加2-4G内存就够了;如果采用隔离方案,那就是两份全栈,约12-16G起步。
所以我的经验是:16G内存的Ubuntu服务器是“共享数据层升级”的舒适线,8G会有点紧张但也不是不能用,只是新实例启动和文档解析并发时要注意观察。磁盘方面,新实例的数据卷需要预留空间,建议至少留出当前数据量1.5倍的空闲容量,这样MinIO、ES快照备份和迁移都有地方放。
坦诚说,不停机升级不是免费的,本质是用富余资源换取业务连续性。如果服务器内存只有8G,我建议优先考虑隔离数据层方案里的“停旧起新”变体,把停机时间压缩到分钟级,而不是硬撑双实例导致整机OOM。
3. 在Ubuntu上完整执行不停机升级的实操过程
3.1 第零步:升级前把备份做扎实
无论采取共享还是隔离方案,备份都是必经步骤。这不是求心安,而是给回滚埋锚点。我在升级前固定执行三件事:备份MySQL、备份关键数据卷、保存当前compose配置快照。
MySQL备份用mysqldump,注意不要影响线上写入,加--single-transaction参数即可:
cd /opt/ragflow/docker docker compose exec -T mysql sh -c \ 'exec mysqldump -uroot -p"$MYSQL_PASSWORD" --single-transaction ragflow' \ > /backup/ragflow_mysql_$(date +%F).sql数据卷备份需要先确认卷名。不同版本的RagFlow compose对数据卷的命名略有差异,执行docker volume ls | grep ragflow看一下实际的卷名,然后逐个打包:
docker run --rm -v ragflow_minio-data:/data -v /backup:/backup \ alpine tar czf /backup/minio-data_$(date +%F).tgz -C /data . docker run --rm -v ragflow_esdata:/data -v /backup:/backup \ alpine tar czf /backup/esdata_$(date +%F).tgz -C /data .MinIO里的原始文件和ES索引在备份时可能有少量写入,这一点不用太纠结,我们回滚真正依赖的是MySQL dump加MinIO文件,ES索引如果真丢了,最坏情况下重新解析一遍文档也能重建,虽然代价不小,但至少有退路。
3.2 搭第二套实例:共享数据层的最小化操作
如果你决定采用共享数据层方案,操作重点是把新版server独立拉起来,同时避免和生产服务的端口、容器名冲突。先把官方docker目录复制一份:
cp -a /opt/ragflow/docker /opt/ragflow-green cd /opt/ragflow-green在生产目录里拉取新版本镜像。这里注意,不要用latest这种浮动标签,必须锁死版本号:
docker pull infiniflow/ragflow:v0.18.0以你实际要升级的release tag为准,我这里只是举例。拉完镜像后,编辑/opt/ragflow-green/docker-compose.yml,做三处修改:
- 把
ragflow-server的镜像tag改成目标版本,比如v0.18.0; - 把端口
9380:80改为9390:80,避免与生产冲突; - 删除
mysql、redis、minio、elasticsearch这些服务定义,只保留ragflow-server,因为我们共享生产的数据层。
然后修改网络部分,让新server加入生产网络:
services: ragflow-server: image: infiniflow/ragflow:v0.18.0 container_name: ragflow-server-green ports: - "9390:80" networks: - default networks: default: external: name: ragflow_default里面的ragflow_default要换成你生产项目实际的网络名,不确定就执行docker network ls查。启动新实例:
docker compose -p ragflow-green up -d-p指定独立项目名,即使你在/opt/ragflow-green目录下操作,数据卷和容器名也会和生产的ragflow项目区分开。
3.3 启动后等健康检查,别看着Up就切
新实例启动后不能马上切流量,因为容器状态Up不代表RagFlow可用。正确做法是盯着日志看启动进度:
docker compose -p ragflow-green logs -f ragflow-server等日志里出现HTTP服务监听的标志(通常是Running on或Uvicorn running一类),再验证健康检查接口。RagFlow的健康检查路径是/v1/health,如果拿不准,直接看官方compose里healthcheck用的路径,照抄过来:
curl -s http://127.0.0.1:9390/v1/health返回的内容里包含healthy类似字样才算通过。但通过健康检查只代表服务活着,功能层面还需要进一步验证:打开新实例的Web界面,确认知识库列表、文档列表和你生产环境一致,上传一个小文件触发解析,跑一次对话测试。这一步本质是“用生产数据做冒烟测试”,非常关键,它能在切换前把大部分兼容性问题暴露出来。
我个人习惯让新实例带压运行10到30分钟再切。这段时间里反复看docker compose -p ragflow-green logs有没有ES索引告警、Redis连接告警、MinIO权限告警。这些日志虽然不致命,但往往是升级后被用户吐槽的隐患。
3.4 Nginx上线与灰度切换
Nginx这里我建议单独跑一个容器,或者用宿主机Nginx,关键是配置必须清晰。下面是我生产环境验证过的配置模板:
upstream ragflow_backend { server 127.0.0.1:9380 weight=100; server 127.0.0.1:9390 weight=0; } server { listen 80; server_name kb.example.com; client_max_body_size 200m; location / { proxy_pass http://ragflow_backend; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_buffering off; proxy_read_timeout 300s; } }有四个点值得展开说:client_max_body_size默认只有1m,知识库上传的PDF、Word文档随便就几十MB,不调大必然上传失败;proxy_buffering off是给RagFlow的流式对话响应准备的,不关闭缓存的话,用户看到的回答会一截一截地卡顿;proxy_read_timeout要调长,RAG场景下一步检索加生成往往要几十秒,默认60秒很容易超时;proxy_set_header里的Upgrade和Connection是给WebSocket能力预留的,RagFlow有些交互需要用到。
切换流程我分两步走。第一步先放一小部分流量到新实例试水:
upstream ragflow_backend { server 127.0.0.1:9380 weight=99; server 127.0.0.1:9390 weight=1; }保存配置后执行docker exec nginx nginx -s reload,Nginx会平滑重载。在1%流量下观察新实例的日志和Nginx access log,如果没有异常,第二步再全量切换。全量切换时有个细节:把旧实例标记为down,而不是单纯把weight改成0,这样已建立的连接会自然结束,新连接全部走新实例:
upstream ragflow_backend { server 127.0.0.1:9380 down; server 127.0.0.1:9390; }再reload一次,这时候对外入口已经完全在新版本上了。
3.5 切换后不要急着“庆祝”,先观察真实请求
全量切换之后,我一般会让流量跑5到10分钟再下结论。观察三个地方:Nginx access log里9390的响应码有没有大量4xx/5xx;新实例日志有没有异常堆栈;MySQL和ES的慢查询/连接数有没有异常上升。如果这十分钟一切平稳,升级才算完成一半,接下来是回滚预案的保留和旧实例的善后。
4. 验证、灰度与回滚:最容易翻车的地方全在这
4.1 别只看健康检查,业务路径要逐个过
切换后最容易犯的错就是“看到health接口正常就宣布成功”。RagFlow的业务链路长,health只是门槛。我整理过一个验证清单,每条基本就是点几下页面的事:
- 上传一个测试文档,确认能触发解析,并能在文档列表看到解析状态;
- 跑一次知识库对话,确认能命中文档内容并流式返回答案;
- 打开包含图片/附件的文档记录,确认MinIO的访问URL正常,图片能预览;
- 如果你们用API对接外部系统,调一次
/v1/chats之类的接口,确认鉴权和响应体格式没变化。
特别是MinIO这条,新实例的环境变量如果和生产不一致(比如MINIO_ENDPOINT指向了错误的地址,或者bucket权限配置有出入),前端文档预览会出现图片裂开、附件无法下载的问题,这种问题健康检查根本看不出来。
4.2 回滚不是删旧重来,而是切回去
共享数据层方案最大的优势是回滚动作非常轻:只要旧实例的容器和数据卷还在,回滚就是改Nginx的upstream,把流量从9390切回9380,再reload一次。
但这里有一个必须提前确认的前提:新版RagFlow启动时会不会对MySQL schema做自动迁移。如果官方release notes明确说了数据库结构变更,或者你启动新实例时发现日志里有DDL操作,那回滚到旧版本就可能失败——旧代码连不上新schema,报字段不存在或类型不匹配的错。这种情况就不能用共享数据层,必须走隔离数据层方案,在隔离环境里先把迁移脚本验证清楚再切。
我在这上面吃过亏。有一次升级后续版本,旧实例上还有定时任务在写数据,新实例启动时迁移了表结构,导致两边并发写同一张表,最后表数据出了问题。后来恢复的方式是拿升级前的MySQL dump直接整库还原,再手动切回旧实例,这个过程中服务是真停了一段时间。所以请大家记住:共享数据层的“优雅回滚”是有前提的,前提不满足就不要硬上。
4.3 灰度比例怎么定才合理
有朋友问我,Nginx按5%、10%这样渐进地切流量行不行。我的实际体验是:对RagFlow这种应用,“5%的流量试新版本”听起来稳妥,实际意义没有想象中大,因为5%的流量未必会打到你没验证过的功能路径上。用户可能5%的请求都集中在登录和聊天,而你最担心的文档解析恰好没被覆盖到。
所以我更推荐“先19%跑半小时,再100%切换”的两段式,而不是多次小比例切换。19%的流量足够触发一次真实的文档上传和对话请求,再加上我在切换前已经用生产数据做过业务冒烟测试,两个动作叠加,覆盖度是够的。反复多次切换反而会增加状态不一致的风险,尤其是在共享数据层下,新旧两个server同时处理写请求,对数据库的压力虽然不大,但没必要去制造这种不必要的并发窗口。
4.4 旧实例的清理时机与数据卷保留
新实例稳定运行24小时之后,可以清理旧容器了。我的建议是先停后删,不要立刻删数据卷:
docker stop ragflow-server-old docker rm ragflow-server-old数据卷继续保留一周再决定是否释放。这一周里如果用户反馈“我上周传的文档打开有问题”“这个解析结果和之前不一样”,你还来得及把旧实例重新拉起来对照。保留旧数据卷的成本只是磁盘占用,相比数据丢失后的代价,这点成本非常划算。
最后分享一个实战细节
这套流程跑顺之后,我最大的体会是“不停机”不是靠某一条命令实现的,而是靠流程设计。Nginx前置、双实例、灰度切换、回滚预案,每一步都是提前设计好的,升级执行反而变成了一件没有惊喜的例行操作。
最后再分享一个我踩过的坑:自动化脚本里千万不要把docker compose up -d和nginx -s reload写成同一行直接执行。中间必须留出健康检查的等待逻辑,等/v1/health返回正常再执行reload。我之前图省事写过一个一键脚本,结果新实例数据库初始化没完成,Nginx已经切过去了,用户那边白屏了好几分钟。从那以后我把升级拆成两段:第一阶段“起新实例并验证”,第二阶段“切流量”,中间必须由人确认,慢即是快。