把 Open WebUI 从云端搬回本地 —— FRP 内网穿透 + Docker + 本地模型折腾记录

又是全让 AI 写的,仅做一点修改,当个记录使用。

里面有些话还是挺弱智的,不代表我自己的观点。

说在前面

我原本把 Open WebUI 部署在云服务器上(2 核 4G、东京、30Mbps),多端互通用得挺爽。但升级到新版本之后,体验直线下降:随便打开一个新页面,侧边栏的 folders 和 chats 要转上一分钟才出来,时不时还给你来个 takes too long to respond

我所有能力当时都走 API: Embedding 和 Reranking 用的是 BGE-M3 / BGE-Reranker-V2-M3,聊天模型走第三方中转。所以这个卡顿其实不是模型跑不动,更像是新版前端、数据库或者容器资源的问题。

于是就有了这次折腾,目标其实有三个,而且优先级不一样:

  1. 把卡顿解决掉,顺便把主实例搬到性能强得多的台式机;
  2. 保留多端(手机、Mac、外网)随时访问的能力 —— 这是我最担心搬回家会丢掉的东西;
  3. 后续能玩本地大模型,兼顾隐私。

本次折腾概览

  • 云服务器当 FRP 中继(frps),台式机当客户端(frpc),外网访问全程经云服务器中转
  • 台式机用 Docker Desktop(WSL2 后端)跑 Open WebUI
  • 踩了一堆坑:Docker 端口映射失效、Windows Defender 误杀、FRP token 不匹配、手机 502
  • 域名复用现有 Caddy,先用临时子域名过渡,不动生产
  • frpc 用任务计划程序开机自启,并全程 Portable
  • 干掉 Watchtower 的自动更新,冻结版本
  • 本地模型:Embedding 换本地 BGE-M3,Reranking 继续留在 API,Chat 折腾了 qwen 一些版本

整体架构

最终跑通的链路是这样的:

手机 / Mac / 外网
  ↓ HTTPS :443
云服务器 Caddy
  ↓ 本机 127.0.0.1:13000
云服务器 frps
  ↓ 加密 FRP 隧道
家里 Windows frpc
  ↓ 本机 127.0.0.1:8080
Open WebUI 容器(Docker)

为什么是 FRP,不是 Tailscale / ZeroTier

我之前 Tailscale、ZeroTier 都试过,甚至把自己的服务器设成中继节点,还是各种失败 —— 核心原因是我的网络环境 P2P 打洞极不稳定。Persec 也失败,唯一稳的是向日葵,但向日葵实在太毒瘤。

FRP 的思路不一样:它放弃赌 P2P 直连,直接强制走中继。台式机主动连出去连云服务器,访问流量明确经过云服务器。不需要公网 IP、不需要路由器端口映射、CGNAT 也不影响。代价是绕一圈东京,延迟高一点。

一、云服务器装 frps

下载最新版 FRP,装二进制:

sudo install -m 755 frps /usr/local/bin/frps
frps --version

生成一个强随机 Token(别用 123456 这种):

openssl rand -hex 32

/etc/frp/frps.toml:

bindAddr = "0.0.0.0"
bindPort = 7000

auth.method = "token"
auth.token = "你的随机Token"

transport.tls.force = true

allowPorts = [
  { start = 13000, end = 13010 },
  { start = 13389, end = 13399 }
]

log.to = "/var/log/frps.log"
log.level = "info"
log.maxDays = 7

systemd 服务是干嘛的

简单说,systemd 就是 Linux 里的"大管家",给 frps 配一个 service 只有一个目的:保活 + 自启。不配它,你 SSH 一断、机器一重启,frps 就没了。配了它就是开机自动跑、崩溃自动重启,可以完全忘掉它的存在。

[Unit]
Description=FRP Server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/frps -c /etc/frp/frps.toml
Restart=always
RestartSec=5
LimitNOFILE=1048576

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now frps
sudo systemctl status frps

云服务器安全组放行 7000(FRP 控制)、80443(后面 Caddy 用)。

坑 1:命令粘到提示符里了。 我复制粘贴的时候,把 sudo systemctl enable --now frps 后面粘上了一大串终端提示符字符,结果这条启动命令根本没执行成功,查状态一直是 inactive (dead)、日志 No entries。看着邪门,其实就是命令没跑起来。老老实实单独敲一遍就好。

二、Windows 上的 frpc

FRP 客户端是纯 Portable 软件,就俩文件 frpc.exe + frpc.toml,不写注册表、不装运行库,删文件夹就跟没来过一样。所以我一开始就把它放进我的 C:\Portable\frp(我 C 盘专门有个 Portable 文件夹放绿色软件),而不是教程默认的 C:\frp

注意:一开始就定好最终路径。 因为后面要用任务计划程序做开机自启,路径变了自启就失效。别配好了再挪。

坑 2:Windows Defender 把 frpc.exe 当病毒删了。 一解压就报威胁(HackTool:Win32/FRP 之类),连原文件都被干掉。这是 Defender 对内网穿透工具的预防性误杀,不是你下到了假货。解法是先把目录加进 Defender 排除项,再重新下载,在被排除的文件夹里面解压:

Add-MpPreference -ExclusionPath "C:\Portable\frp"

坑 3:压缩包里的文件别全塞进去。 包里有 frps(服务端,家里用不到)、frpc(客户端,要的)、LICENSE(不用)。只留 frpc.exefrpc.toml

三、大坑:我压根还没在本机部署 Open WebUI

配到"确认本机 Open WebUI 端口"这一步,在两个终端 docker ps 都失败:服务器上是 permission denied(要加 sudo),Windows 上直接 docker 不是命令

原因很蠢但很关键:FRP 只是把台式机上已存在的服务转发出去,它不会自动把云端的 Open WebUI 搬到本机。 此前所有东西都还在云服务器上,本机根本没有 Docker、也没有 Open WebUI。这一整段被gpt漏掉了。

于是补课:装 Docker Desktop(WSL2 后端)。这里得诚实说一句:Docker Desktop 不是 Portable,它会装 Windows 程序和服务,不像 FRP 删文件夹就干净。但它是 Win + WSL2 场景下端口、GPU、自启最省心的方案,所以我认了。它和我 WSL 里的开发环境只通过 localhost 端口通信,不共享文件系统,算是最干净的隔离方式。

部署本机 Open WebUI:

docker volume create open-webui-local-data
docker run -d --name open-webui-local --restart unless-stopped `
  -p 127.0.0.1:3000:8080 `
  -v open-webui-local-data:/app/backend/data `
  ghcr.io/open-webui/open-webui:main

坑 4:拉镜像 EOF。 中途报 failed to fetch ... EOF,是代理网络抽风。Docker 分层下载,已完成的层会缓存,直接重跑同一条命令即可,拉一半的碎片它自己会校验丢弃,不留垃圾。

真正的大坑:端口映射死活不生效

容器 healthy 了,但 docker ps 的 PORTS 只显示 8080/tcp,而不是期望的 127.0.0.1:3000->8080/tcphttp://127.0.0.1:3000 打不开。查底层配置:

docker inspect open-webui-local --format '{{json .NetworkSettings.Ports}}'
# {"8080/tcp":[]}

一个空数组。也就是 Docker 收到了 -p 配置(HostConfig.PortBindings 里有),但根本没真正把端口发布出来

排查过程记录一下,避免以后又走弯路:

  • 不是命令写错:-p 127.0.0.1:3000:8080-p 3000:8080 都试了,都是空数组。
  • 不是 VPN :127.0.0.1 是回环,流量根本不出机器,关不关 VPN 无关。
  • 不是设置乱改:NetworkMode=bridge 正常、Host networking 没开、Port binding 是默认。
  • 重启 Docker Desktop 无效。

结论:当前 Docker Desktop 的端口发布功能整体失效,原因没深究。

解法:干脆绕过端口映射,启用 Host networking。

docker rm -f open-webui-local
docker run -d --name open-webui-local --restart unless-stopped `
  --network host `
  -v open-webui-local-data:/app/backend/data `
  ghcr.io/open-webui/open-webui:main

这样 Open WebUI 直接用宿主机网络,少了一层映射,访问地址从此变成 http://127.0.0.1:8080(不是 3000 了!后面 FRP 的 localPort 也得跟着改成 8080)。终于打开了。

代价:Host networking 下 8080 不再被 127.0.0.1 限制,理论上局域网设备也能访问。后续可以用 Windows 防火墙挡掉局域网入站,只留本机和 frpc。

四、FRP 打通

frpc.toml 关键段(注意 localPort = 8080):

serverAddr = "<你的服务器公网IP>"
serverPort = 7000
auth.method = "token"
auth.token = "和服务器一模一样的Token"
transport.tls.enable = true
loginFailExit = false
log.to = "C:/Portable/frp/frpc.log"

[[proxies]]
name = "open-webui"
type = "tcp"
localIP = "127.0.0.1"
localPort = 8080
remotePort = 13000

坑 5:token in login doesn’t match。 frpc 日志一直刷这句。别用肉眼对几十位字符串,最稳的是重新生成一枚 Token,服务器和 Windows 两边同时替换,frps 记得 systemctl restart。另外提醒自己:Token 千万别贴到聊天/截图/笔记里,贴了就当泄露,立刻轮换。

frpc 日志出现 login to server success + start proxy success,服务器上 sudo ss -lntp | grep 13000 能看到监听,就说明隧道通了。日志被我重定向到文件了,所以前台窗口"没反应"是正常的,Get-Content ...frpc.log 去看。

坑 6:手机 502,折腾半天发现是 VPN。 服务器本机 curl -I http://127.0.0.1:13000 返回 200,说明整条链路是通的。但手机开着 VPN 访问 http://<IP>:13000 就一直转、最后 502。关掉 VPN、切移动数据,秒开。 教训:HTTP + 裸 IP + 非标准端口 这种组合,很多 VPN 节点会抽风。别拿"YouTube 能开"推断"访问自己服务器也没问题",两者路径完全不同。这也是后面必须上 HTTPS 域名的理由 —— 标准 HTTPS + 域名 + 443 的兼容性好太多。

五、域名 + Caddy + HTTPS

我服务器上早就有 Caddy 在给 域名A 服务(反代到云端旧 Open WebUI 的 localhost:8080)。Caddyfile 极简:

域名A {
    reverse_proxy localhost:8080
}

这里的策略很重要:不动生产,用临时子域名过渡。 因为本机是全新空实例,直接把 域名A 切过去,我打开熟悉的域名会看到一个空白 Open WebUI。所以新开一个 local-域名A 指向 FRP,验证 + 迁数据都稳了,再考虑切正式域名,随时能回退。

先备份再改:

sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.backup-$(date +%F-%H%M%S)
域名A {
    reverse_proxy localhost:8080
}

local-域名A {
    reverse_proxy 127.0.0.1:13000
}
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Cloudflare 这里选 DNS Only(灰云)。 我原来的 域名A 也是灰云。对 Open WebUI 我建议保持灰云:聊天内容、上传、流式响应不经过 Cloudflare,少一层影响 WebSocket / 长连接的变量,Caddy 也能直接向 Let’s Encrypt 自动签证书。橙云代理反而要处理 SSL 模式(至少 Full strict)、超时、流式兼容等一堆事,对我没必要。

HTTPS 通了之后,记得删掉安全组里 13000 的公网放行。 因为 Caddy 和 frps 在同一台机器,Caddy 走 127.0.0.1:13000 不经过安全组,外面就再也访问不到裸的 IP:13000 了。最终对外只留 80 / 443 / 7000

六、frpc 开机自启(任务计划程序)

用任务计划程序,不是"基本任务"。

  • 常规:名字随意;勾"不管用户是否登录都要运行"+“最高权限”。Configure for没有 Windows 11 选项,选 Windows 10 完全没问题,它只是兼容目标。
  • 触发器:启动时,延迟 30 秒(等网络和 Docker 就绪)。
  • 操作:程序 C:\Portable\frp\frpc.exe,参数 -c "C:\Portable\frp\frpc.toml",起始于 C:\Portable\frp(起始于和程序路径不要加引号)。
  • 条件:取消"仅在交流电源时启动"、取消网络条件判断(frpc 自己会重连)。
  • 设置:失败每 1 分钟重启、重试次数给大点;“如果已在运行则不启动新实例”;取消"运行超过 X 时间就停止"。

配完先把手动跑的 frpc 用 Ctrl+C 停掉,再右键任务"运行",Get-Process frpc 验证。

七、台式机睡眠与 WOL(思路,还没完全落地)

我不想让台式机 7×24 醒着费电。真正需要的是:平时睡眠,外出时用手机/Mac 发个唤醒指令把它叫醒,等 1-3 分钟 Docker/Open WebUI/frpc 自动恢复后再访问。这其实就是当年向日葵开机棒干的事 —— Wake-on-LAN。

关键前提(还得慢慢调):

  • 台式机必须有线连路由器(Wi-Fi 唤醒别想);
  • 主板 BIOS 开启网络唤醒、关掉 ErP / Deep Sleep 这类会切断网卡待机电的省电项;
  • Windows 网卡电源管理里允许 Magic Packet 唤醒;
  • 需要一个 24 小时在线、且同局域网的设备来发魔术包(路由器自带 WOL 最省事,或者用 NAS 自建一个带认证的唤醒中继);
  • 优先"睡眠唤醒",成功率比"关机唤醒"高。

补一句现实:睡眠后 Docker/frpc 都停了,所以不可能"访问隧道再把它叫醒" —— 唤醒必须靠另一个常在线的设备。

八、数据迁移 + 干掉 Watchtower

盘点云端:数据在 /var/lib/docker/volumes/open-webui/_data,镜像是 open-webui:main,而且还挂着一个 watchtower --cleanup open-webui

这个 Watchtower 很可能就是"新版之后越来越卡"的元凶。 它盯着 open-webui 容器,配合浮动的 :main 标签,会在我不知情时自动拉新版重建。Open WebUI 迭代极快、每次更新多少带点 bug,这种"无感自动追 main"对生产环境是灾难。

处理:sudo docker stop watchtower,以后冻结明确版本号 + 手动更新。而且 --cleanup 会清旧镜像,翻车了想回退都难,更不该留着。

迁移遵循原则:先备份、迁到本机、验证,全程保留云端可回退。备份时先停 Watchtower、停容器做一致性快照、打 tar 包、马上把云端容器起回来,尽量少中断 域名A

数据搬到本机、用匹配版本起容器、确认 Chats/Folders/知识库都在之后,就进入过渡状态:

local-域名A  → 本机新实例(日常主用)
域名A        → 云端旧实例(原样保留,紧急回退)

这段时间日常全切 local,云端只用于查看和备用 —— 别在两边同时写新聊天,否则两份数据库就从此分叉了。

九、本地模型(Ollama)

Ollama 装 Windows 原生版(GPU 调用最省事),Open WebUI 容器通过 host.docker.internal:11434 或(Host networking 下)127.0.0.1:11434 调它。

坑 7:装完 ollama 不是命令。 PATH 没刷新,关掉旧 PowerShell 重开一个,或者注销/重启一次就好。

Embedding:换本地 BGE-M3

ollama pull bge-m3

坑 8:ollama embed 命令不存在。 我这版 Ollama 的 CLI 没有 embed 子命令,别以为模型坏了。直接打 HTTP:

Invoke-RestMethod -Uri "http://127.0.0.1:11434/api/embed" -Method Post `
  -ContentType "application/json" `
  -Body (@{ model="bge-m3"; input="测试" } | ConvertTo-Json)

Open WebUI 里 Embedding Engine 选 Ollama、模型填 bge-m3:latest、API Key 留空即可。

我也不太懂,这和我以前用的那个是否确实有区别?我目前是把大部分常用的 RAG 知识库用它重新跑了一遍,然后尝试性的使用,感觉也没什么问题。有问题再说吧。

Reranking:继续留在 API

Reranker 本身不大,但本地部署更麻烦 —— Ollama 的标准接口是 /chat/generate/embed,没把 /rerank 当一等公民,通常得另起一个专门服务(Infinity / TEI 之类),还要看 Open WebUI 版本对本地 rerank endpoint 的支持程度。收益/维护比不划算,所以 BGE-Reranker-V2-M3 我先继续用 API。

Chat 模型:一部血泪史

这是最折腾的部分。先说个关键排查方法:绕过 Open WebUI,直接打 Ollama 的 /api/chat/api/generateprompt_eval_duration / eval_duration,能一刀切开"模型/GPU 慢"还是"Open WebUI 塞了太多东西"。

  • qwen3.5:9b —— 删了。 各种鬼畜:简单问题也拒答(“请提出符合法律法规的问题”);更致命的是正常回答结束、甚至没按 Stop,GPU 利用率也永远卡在 93% 左右;Open WebUI 里首 token 前经常干等一分多钟(GPU 满载但 UI 没反应,像在做超长 prompt prefill / thinking)。裸 API 测下来生成其实很快,所以问题出在它的模板 / thinking / 取消链路与当前 Open WebUI 的兼容性上,不值得救。
  • qwen2.5:7b —— 快而稳。 纯 New Chat 秒出,结束后 GPU 利用率立刻掉回 0%,没有任何残留。缺点是读不到 Folder 绑定的自动 RAG,但在对话里手动把资料丢给它就能正常调用、也不卡首 token。就是版本有点老。
  • qwen3:8b —— 可用。 行为正常,但首字符比 2.5 慢一些,是个 thinking 模型;智力观感更好一点。同样读不到 Folder 自动 RAG。
  • 想追新的 27B/35B 级(如 3.6)对 16G 显存不合适,模型文件就 17G+,大概率 CPU 卸载,慢到违背"本地要快"的初衷,直接放弃。

关于 Folder 自动 RAG 读不到: 小模型能读手动附加的资料,却抓不住 Open WebUI 自动注入的 RAG 上下文 —— 因为后者混在系统提示、角色设定、工具定义里,7B/8B 分不清哪些是"必须遵循的事实"。这是能力边界,不是链路故障。

最终的分工

不追求"全本地",按隐私/体验/复杂度分层才最划算:

环节 方案
Embedding 本地 Ollama BGE-M3(新建/重建的知识库)
Reranking API BGE-Reranker-V2-M3(暂不本地化)
Chat · 简单私密快任务 本地 qwen2.5:7b(改写/摘要/翻译/格式化/手动附资料问答)
Chat · 稍复杂本地任务 本地 qwen3:8b
Chat · 普适版助手/自动RAG/工具/重要判断 闭源强模型 API

一句话:本地模型能做到"私密任务秒出、基础能力可靠"就已经很有价值;复杂的自动 RAG + 工具 + 长期记忆那套,继续交给闭源强模型,不算失败,是正确分工。

还没搞 / 之后想搞

  • WOL 真正落地(BIOS + 网卡 + 常在线设备发包)
  • 本地 Reranker(等确认某个高敏感知识库确实值得全链路本地再说)
  • 稳定跑一阵后,把 域名A 正式切到本机 FRP 上游,云端降级为轻量备用
  • 旧知识库按敏感度分批重建为本地 Embedding
  • qwen2.5:14b 这类更适配 16G 显存的模型,而不是盲目追榜单
  • Windows 防火墙给 Host networking 的 8080 加一条局域网入站阻断

我是差点忘了加这一句的 Wenix(其实就是忘了,现在刚刚补),希望你开心~