自建服务公网链路复盘:DSH 断连、带宽优化与 RustDesk 端口收敛
本次排查从 DSH 公网页面反复显示“连接成功 / 立即重连”开始,逐步检查了腾讯云 Caddy、frps、本机 frpc 和软路由上的其他隧道,并完成心跳容忍度调整、HTTP 压缩优化、RustDesk API 端口收敛及失效站点清理。本文记录实际改动、验证结果和仍未证实的部分,避免把临时缓解误写成已经定位底层根因。
1. 环境与链路
诊断时间为 2026 年 9 月 19 日。以下配置只代表当时部署,不代表后续版本的默认行为。示例以 example.com 替代真实域名,不包含认证口令、令牌、私钥或用户会话数据。
DSH 的隧道已从软路由移到 DSH 主机,frpc 使用 host 网络连接本机回环地址。DSH 保持监听 127.0.0.1:3080,通过 trustedHosts 接受公网域名。旧的 13080 → 3080 Node 转发方案不再承载访问。
RustDesk 原生客户端使用独立的 ID、打洞和中继端口,不经过上图中的 frp HTTP 隧道;Web Client 另有 Caddy 代理的 WebSocket 入口。
2. DSH 反复断连:证据与边界
2.1 现象
- 本地地址访问稳定,多台电脑通过公网域名访问都会反复重连。
- 问题出现时 DSH 主进程并非持续崩溃重启。
- 公网链路能够建立 WebSocket,并非完全不支持 Upgrade。
- Caddy 和 frps 日志出现过
unexpected EOF、context canceled和EOF。
日志里的 SSE /plugins/events 与 WebSocket /api/remote.mux 是不同连接。不能把所有 EOF 都算作 WebSocket 故障,刷新页面和服务重启也会关闭请求。
2.2 当时实现的心跳机制
检查已安装的 @deepseek-ai/dsh-api-gateway 运行代码,发现:
默认 websocketHeartbeatIntervalMs = 2000
MAX_MISSED_HEARTBEATS = 2
收到 Pong 后将未应答计数清零
达到阈值后执行 socket.terminate()在定时器正常运行的情况下,短暂的几秒停顿就可能触发关闭。不能只用连接寿命是否接近 2 秒的整数倍来判定原因。
2.3 已观察到什么
| 证据 | 可以得出的结论 | 不能得出的结论 |
|---|---|---|
| 本地正常,多台电脑公网复现 | 公共访问路径是优先排查对象 | 已定位为 frp 的某个缺陷 |
| 本机 3080 侧若干 mux 请求约 5 秒后由服务端先发 FIN | 这些连接的本机服务侧先发起关闭 | 一定是心跳函数执行了 terminate |
| 云端某条连接 40 秒内观察到 20 次 Ping/Pong | 该连接能转发心跳控制帧 | 所有域名、所有连接都正常 |
| 心跳改成 30 秒并重启后,用户反馈暂未再复现 | 调整在观察期内缓解了问题 | 已证明具体哪一跳发生了丢包 |
抓包脚本只是按 TCP 包载荷识别独立的小 Ping/Pong,没有做完整 TCP 重组和 WebSocket 解码;控制帧与其他数据合并时会漏计。云端 8080 也被多个服务共用,未建立域名关联的健康连接不能当作 DSH 的专属证据。两处采样没有形成严格同步的端到端证据。
因此本次最稳妥的结论是:DSH 默认心跳容忍度与这条公网访问路径不匹配的可能性很高,放宽后实际症状消失;但 Ping/Pong 究竟在哪一段延迟,仍未完成定位。 不能写成“Caddy 吞掉 Pong”或“腾讯云防火墙禁止 WebSocket”。
2.4 实际修改
在 DSH 的 Web profile 补丁中增加:
- id: typert-gateway
config:
websocketHeartbeatIntervalMs: 30000本次文件位置是 ~/.dsh/profiles/web/cordis.patch.yml。修改前需检查目标插件原有配置以及补丁合并规则,避免覆盖其他配置。
此调整把名义失联检测窗口提高到约 60~90 秒,代价是真正断网后发现失联也可能变慢。没有关闭心跳,也没有修改 DSH 源码。用户自行重启唯一的 DSH 实例后报告恢复稳定。
不要启动第二个 dsh web 来做验证:多个实例可能共用同一份用户状态。
3. 小带宽场景的优化记录
3.1 已落地配置
| 位置 | 修改 | 预期作用与验证范围 |
|---|---|---|
| DSH HTTP | gzip 等级 1 → 3,阈值仍为 1024 字节 | 增加少量 CPU 换取可能更小的文本响应;未测整站节省比例 |
| DSH 主机 frpc | poolCount: 20 → 8 | 减少预备工作连接资源,不能视为大量节流措施 |
| Gitea、DS2 API 的 Caddy 站点 | 增加 encode zstd gzip | 首页 GET 实测 200 且返回 gzip;未覆盖 Git LFS 和 API 流式接口 |
| RustDesk Web | 增加 encode zstd gzip | /webclient/ HTML 从 6659 字节降至 2406 字节,约减少 64% |
DSH 最终相关参数:
compression: gzip
compressionLevel: 3
compressionThresholdBytes: 1024DSH 主机 frpc 最终相关参数:
transport.poolCount = 8
transport.heartbeatInterval = 30
transport.heartbeatTimeout = 90这些是现有配置中的局部字段,不是可直接覆盖整份文件的完整配置。
3.2 压缩发生在哪一段很重要
如果只在腾讯云 Caddy 压缩,主要节省“腾讯云 → 浏览器”的响应流量,不代表“家中 frpc → 腾讯云 frps”同样节省。若瓶颈是家庭上行,优先看源站是否已压缩以及传输内容本身是否可压缩。
需要区分三件事:
- HTTP gzip/zstd:适用于 HTML、JS、CSS、JSON 等响应。
- WebSocket permessage-deflate:由 WebSocket 两端协商,普通 HTTP
encode不会自动开启它。 - frp 传输压缩:可另行评估,但对已经压缩或加密的内容收益通常很低,本次没有启用。
当时 DSH mux 使用 new WebSocketServer({ noServer: true }),没有启用服务端 perMessageDeflate,也未提供对应的 gateway 配置项。普通 HTTP gzip 不覆盖 mux 数据帧。因不修改源码,本次未改 WebSocket 压缩。
也要纠正一个容易过度保守的判断:给 Caddy 配置 encode 并不意味着它一定把图片、视频或上游已编码的响应再压缩一次。 需要检查内容类型、协商头、现有 Content-Encoding 和实际响应;流式接口还要验证首包与逐段到达时间。不能笼统地把“有 SSE/WebSocket”当作整站禁止压缩的理由。
4. RustDesk API 端口收敛
本次使用的是 lejianwen/rustdesk-api:full-s6 部署。它在 21114 提供 API/Web 服务,这不是所有 RustDesk 部署都完全相同的端口安排。
原 Compose 映射:
ports:
- "21114:21114"修改为只绑定云主机回环:
ports:
- "127.0.0.1:31114:21114"Caddy 同步更新:
rd.example.com {
encode zstd gzip
redir /webclient /webclient/ 301
reverse_proxy 127.0.0.1:31114
}原站点的其他安全头及兼容跳转保留。修改端口映射需要重建容器,单纯 docker restart 不会采用新的 Compose 端口映射。
| 公网端口 | 当时用途 | 处理 |
|---|---|---|
| 21114/TCP | API/Web 直连入口 | 移除公网映射,安全组删除放行 |
| 21115/TCP | NAT 检测 | 保留 |
| 21116/TCP、UDP | ID 服务与打洞相关通信 | 保留 |
| 21117/TCP | 原生客户端中继 | 保留 |
| 21118、21119/TCP | 当前 Web Client 的 Caddy HTTPS/WebSocket 入口 | 保留 |
| 443/TCP | API/Web 的 HTTPS 入口 | 保留 |
客户端 API Server 配置为 https://rd.example.com 才能继续通过 443 使用 API。若某台客户端仍填写 http://服务器:21114,必须先改地址。 原生 ID/中继端口没变不代表所有客户端 API 配置都自动兼容。
重建时短暂出现一次 502,随后 /webclient/ 恢复 200。带 Accept-Encoding: gzip 的 GET 返回 Content-Encoding: gzip,证明测试页面压缩生效。远程桌面画面已有自身编码,这次网页压缩不会同等幅度减少中继画面流量。
5. Caddy 失效站点与 RPC 误判
经确认弃用,清理了 AI Hub、Clash Dashboard、TERP 三个站点的 Caddy 配置。清理前对应上游有拒绝连接记录,删除后不再转发到这些失效端口。
DNS 与 Caddy 是两层配置:删掉 Caddy 站点不等于删掉 DNS。遗留解析应单独处理;域名移除后的结果还取决于 TLS 证书缓存和兜底路由,不能保证总是返回某个固定 HTTP 状态。
Aria2 RPC 则保留:根路径 404,但 /jsonrpc 对测试请求返回 400 和 application/json-rpc。这说明路径上有 RPC 服务响应,不能因为没有首页就当作废弃。它仍不等于已经验证鉴权、下载控制等全部功能。下载站同域 /jsonrpc 路由还通过 Host 指向这条 frp 代理,删除前必须一起检查依赖。
6. 软路由 frpc 审计
软路由上只有一个主 frpc 容器,版本为 0.69.0,共配置 11 条代理:
blog-www、gitea、gitea-ssh、MTPhotos、navidrome
uptime-kuma、ariang、aria2-rpc、caigou
web-resource-manager、vaultwarden本次没有修改它的配置:
transport.poolCount = 20
transport.heartbeatInterval = 30
transport.heartbeatTimeout = 90这里有几条需要记住的诊断边界:
- 代理注册成功只证明控制面接受了代理,不证明局域网后端健康。
poolCount是预备工作连接池参数,不是业务并发上限。长连接数量多不能直接推出必须配置 20;TCP 多路复用下也不能把它等同于 20 条独立公网 TCP。- 当时
ss、stat不可用,nc -z的探测 stderr 又被丢弃,所有结果都显示 CLOSED。这组结果没有区分工具不兼容与真正拒绝连接,不能用于删隧道。 - Gitea 后端曾报拒绝连接,但没有完整故障时间线,不能据此认定当前仍不可用。
- 日志显示 Gitea SSH 收到同一外部来源的密集连接,值得结合 SSH 日志判断是否扫描,不应仅凭 frps 的连接记录认定入侵。
后续优化应先明确瓶颈在哪:家庭上行、腾讯云出口,还是 Wi-Fi/路由排队。软路由 SQM 只能控制它能看到和整形的出口;多个 HTTP 服务复用在 frp 加密连接中,也不容易直接按业务精细分类。若瓶颈是腾讯云出口,需要在云端或应用侧配合限速。
优先减少照片原图、无损音频、大文件和远程桌面高画质的流量,通常比减少空闲心跳更有收益。版本升级、frp 压缩、SQM 和应用限速均属于后续候选,不在本次已完成清单中。
7. 可复用的变更与验收步骤
7.1 修改前确认现场
date -Is
docker inspect --format '{{.State.StartedAt}} {{.RestartCount}}' frpc
docker exec frpc frpc --version
docker compose version
# 旧环境可能只有 docker-compose;先检查,不要等改完才发现。记录容器挂载路径和配置修改时间。RestartCount=0 不是“从未重启”的证明,人工重启或容器重建要结合启动时间、容器 ID 和日志看。文件写入且进程晚于写入,只是必要检查;关键参数还应以运行时行为验证。
7.2 Caddy 单文件挂载的注意事项
当 Caddyfile 是 Docker 单文件 bind mount 时,使用原子替换文件的编辑方式可能更换 inode,导致容器仍读旧文件。应保留备份,并采用保留原 inode 的写入方式,之后核对容器内文件。
docker exec caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile先校验再重载。重载不重启容器,但 WebSocket 等长连接仍可能受路由重载影响,不能承诺完全无感。回滚时恢复备份,重新校验并加载。
7.3 验证真实业务路径
# 云主机本地验证 HTTPS 路由;保留证书校验。
curl --max-time 10 \
--resolve rd.example.com:443:127.0.0.1 \
-H 'Accept-Encoding: gzip' \
-D - -o /dev/null \
https://rd.example.com/webclient/example.com 要替换为实际域名。压缩验证使用 GET 并显式协商编码,不能只凭无 Accept-Encoding 的 HEAD 请求判断“没压缩”。云主机本机回环检查不经过公网安全组,安全组收敛还需要外部网络验证。
不确定应用是否有 /health 时,不要随便请求一个 404 就判断不健康。RPC 返回错误要结合方法、认证和响应体解释,不能简单用 200/非 200 二分。
8. 本次最终状态与后续观察
| 项目 | 状态 |
|---|---|
| DSH 心跳 30 秒 | 已配置,用户重启后反馈未再复现;长期稳定性待观察 |
| DSH gzip 等级 3、本机 frpc 池 8 | 已写入,用户已重启;整站流量收益未量化 |
| RustDesk API 回环绑定、Web gzip | 已部署并验证页面响应 |
| Gitea、DS2 API 压缩 | 首页已验证,其他业务接口需按实际使用验证 |
| 三个弃用 Caddy 站点 | 已清理,DNS 单独处理 |
| Aria2 RPC | 保留,有 RPC 响应,不按根路径 404 删除 |
| 软路由 11 条代理 | 保留,本次只读审计 |
| 旧 dsh-forward.service | 曾发现脚本删除后仍反复失败重启;未在本次停用,留作独立清理项 |
这次最有效的改动是放宽 DSH 心跳容忍窗口。带宽优化则需要按真实传输内容和瓶颈位置评估,不能把一组参数调整称为“最佳状态”。
相关阅读
上面的外部链接用于后续核对;本文的现场结果依据本次日志、已安装代码、配置变更与请求验证,不把未完成的验证写作既成结论。
