本文中的域名、IP 地址、路径、Token 等信息均已脱敏处理。
前段时间我使用 docker 部署了 Sun-Panel 导航页(1.3.0 版本)。
部署完立马发现了一个问题:系统状态模块只能看到容器里的数据,看不到宿主机真实数据
没用几天又觉得登录会话太短,隔一阵子就得重新登录,很是麻烦,毕竟我都配置了 mTLS 了就没必要这么..嗯…严谨了。(详见: 《使用客户端证书保护 Sun-Panel》)
这两个问题并不算严重,但都很烦。
前者会让系统监控失去它原本的作用,而官方把 docker 部署查看宿主机系统状态的能力放在了后面的闭源付费版本里,而我当时用的是 1.3.0,也就是最后一个开源版本。
后者则是纯粹的使用体验问题。
于是我就把源码拉下来,自己改了改。
先解决宿主机监控的问题
最开始发现问题的时候,我使用的还是 docker 部署,后面换成了源码编译运行。
为什么放弃 Docker
继续用 docker 也不是不行,但想了想,我后面本来就准备自己维护源码,如果继续坚持 Docker,那么后面的开发流程就会变成:
修改源码↓重新构建镜像↓重新部署容器↓重新测试整个过程会变得非常繁琐。,而且宿主机监控本身就是一个天然会涉及容器边界的问题。
既然迟早都要编译源码,那不如一步到位。
于是我开始研究本地构建流程。
最终部署方式也从 Docker 变成了源码编译后的原生运行。
从源码开始分析监控逻辑
部署方式调整完成以后,宿主机监控的问题才真正开始解决。
最先处理的是磁盘监控。
顺着代码一路看下去,问题其实藏得很浅:
后端有一段 GetDiskMountpoints():
调用 gopsutil 读取系统所有分区↓原样返回给前端中间没有任何过滤。
也就是说,操作系统报告多少个挂载点,前端就会收到多少个。
而 Linux 和 macOS 都会报告大量虚拟文件系统:
tmpfsdevfsoverlayprocsysfsautofsnullfs...这些本身不是磁盘,但会原样出现在磁盘下拉框里,把真实磁盘淹没掉。
增加文件系统过滤
原因清楚以后,处理方式就比较直接。
我在 GetDiskMountpoints() 和 GetDiskInfo() 里加了同一层过滤,整体逻辑变成:
扫描全部分区↓识别文件系统类型↓命中黑名单就跳过↓只返回真实挂载点黑名单覆盖了 Linux 和 macOS 两边的常见虚拟 FS,大概三十项。
处理完以后,磁盘列表终于只剩下真正有意义的设备。
前端看起来却卡住了
后端改完,构建部署。
结果发现磁盘监控根本添加不进去…
CPU 和内存都添加正常,唯独磁盘,选完挂载点,点确定,毫无反应。
这个问题折腾了挺久的,最后是通过浏览器开发者工具定位到的。
打开 Network 面板,导出 HAR,搜索保存请求,扔给ai检索,结果发现点确定的时候,根本就没有发出保存请求,也就是说问题不在后端拒绝,而是在前端就被拦住了。
继续查看 Console,发现一行 {warnings: undefined},点击跳转进 Naive UI 内部代码,是一段:
return w ? (console.log(w), Promise.resolve(!1)) : Promise.resolve(!0)意思是验证通过返回 true,验证失败返回 false 并打印 warnings。
但 w 是 {warnings: undefined},对象始终 truthy,所以被判定为”验证失败”。
这是 Naive UI 版本变化带来的存量 bug
继续往上追,发现问题出在 DiskEditor/index.vue 的 verification() 方法,原版代码是:
const errors = await formRef.value?.validate()if (!errors) { return Promise.resolve(true)}这段代码在旧版 Naive UI 上能正常工作,因为 validate() 验证通过时返回 undefined,!undefined 是 true。
但 Naive UI 升到 2.44 以后,validate() 验证通过时返回的是 {warnings: undefined} 对象。
对象始终 truthy,!{...} 永远是 false。
于是验证明明通过了,却被判定为失败。
CPU 和内存监控不走这个 verification(),所以没事,只有磁盘监控被卡住。
解决方式:跳过这层验证
Naive UI 跨版本的返回值不稳定,写兼容判断维护成本太高 (<—— 纯懒的)
而真正需要防的只有”空值”和”非法路径”两种情况:
- NSelect 已经加了
filterable,只能从后端返回的列表里选,不会乱填 - 后端
GetDiskInfoByPath对无效路径有错误兜底
前端这层 form validate 其实是多余的。
于是直接把 verification() 简化成无条件返回 true:
请求验证↓直接返回 true↓交给 NSelect 和后端去兜底处理完以后,三种监控都能正常添加了。
顺手把构建流程整理了一遍
既然已经开始维护源码,构建流程自然也要一起整理。
因为后面:
修改源码↓编译↓测试↓部署会不断重复。
如果每次都重新这样整一遍,那我大概率会疯掉的。
第一次跑 build.sh 就翻车
项目自带的 build.sh 原本是给 Linux CI 写的。
我第一次在 macOS 上跑 ./build.sh -r,一连暴露了四个问题:
pnpm 11 拒绝运行依赖的 postinstall 脚本↓esbuild 和 vue-demi 安装失败↓pnpm install 退出码 1
go install 装到 ~/go/bin 不在 PATH↓go-bindata-assetfs: command not found
release 目录不存在↓go build 找不到输出路径
bindata.go 没生成↓Go 编译报 package not in std每一个单拎出来可能没什么,但全部一起来就炸了。
顺手把 build.sh 也改了
既然都要改,干脆把 build.sh 一起修了:
pnpm install 加 --config.dangerously-allow-all-builds↓解决 postinstall 审批失败
buildBackEndAssets 加 export PATH=<span data-math-id="math-0"></span>(go env GOPATH)/bin↓go-bindata-assetfs 能被找到
_build 函数在 go build 前加 mkdir -p↓release 目录自动创建
SUPPORTED_OSARCH 追加 darwin/arm64/clang↓支持 macOS arm64 交叉编译
musl 静态编译加 uname -s 守卫↓非 Linux 自动跳过顺手还修了一个 build.sh 第 9 行的原作者遗留老 bug,VERSION 的命令替换语法写错了,${cut ...} 应该是 $(cut ...),导致版本号一直是空。
从 Windows 同步到 Mac 还有一道坎
build.sh 改完以后,本地构建基本能跑通了。
但脚本从 Windows 同步到 Mac 还会丢两样东西:
换行符:Windows 是 CRLF,Mac 上 bash 报 bad interpreter: /bin/bash^M↓执行权限:.command 文件没 +x,双击报 permission denied于是又写了一个 setup.command,首次同步后跑一次,自动修复这两个:
sed -i '' 's/\r$//' *.command↓chmod +x *.command还有几个隐藏的小坑
构建跑通以后,部署阶段又遇到两个问题。
第一个:.env 文件不在 git 里
前端构建脚本 add-frontend-version.js 强依赖 .env,但 .env 被 gitignore 忽略,从 Windows 同步过来时漏了(我居然能忘了这茬)。
解决方式是构建脚本检测到 .env 不存在时自动创建一份默认配置。
第二个:cp -r 合并拷贝不删旧文件
部署前端用 cp -r dist web,是合并拷贝,不会删掉 web/assets/ 里旧的 JS。
旧 hash 的 JS 残留,新 index.html 如果引用的是新 hash 还好,但偶尔会因为某些原因引用旧 hash,导致浏览器加载旧前端,改动”看起来没生效”。
解决方式是部署时先 rm -rf web 再 cp -r dist web,彻底替换。
最后整理成一套脚本
把这些处理都固化下来以后,最终保留下来的是这几个:
setup.command 首次同步后跑一次,修 CRLF 和权限build.command 一键构建 + 部署start.command 一键启动之后更新源码基本只需要:
./build.command剩下的环境检查、.env 创建、bindata 生成、二进制编译、前端部署,全部交给脚本处理。
后面继续维护就轻松很多。
登录状态为什么总会失效
宿主机监控解决以后,还有另一个一直存在的问题:登录状态总会莫名其妙失效。
最开始我以为是前端 Token 过期,后来顺着登录流程一路往下追,才发现 Sun-Panel 用的是双层 token 缓存:
前端 localStorage 里的 cToken↓后端 CUserToken 缓存映射到 bToken↓数据库 users.token 字段里的 bToken↓后端 UserToken 缓存映射到完整用户信息请求时前端只发 cToken,后端靠这两层缓存拿到用户。
cToken 这一层是会话寿命的真正瓶颈。
初始化代码如下:
return global.NewCache[string]( 72*time.Hour, 48*time.Hour, "CUserToken",)也就是说:
cToken 有效期:72 小时清理间隔:48 小时而且用的是绝对过期时间。
问题不只是这个 72 小时
刚看到这里的时候。
最容易想到的办法其实是:
72*time.Hour↓30*24*time.Hour直接把时间改长。
但继续往下看以后发现并没有这么简单,因为绝对过期时间是缓存创建时就开始计时的,不会因为用户持续访问而刷新。
简单来说就是:
周一登录↓一直正常使用↓周四到达 72 小时↓掉登录即使这三天里每天都在使用,结果也不会改变。
增加滑动续期
真正的问题其实是缺少续期机制,所以登录中间件那一步要加一次缓存刷新。
原来的流程:
请求进来↓读取 CUserToken↓命中就放行↓返回数据修改以后:
请求进来↓读取 CUserToken↓命中就刷新过期时间↓再放行↓返回数据对应代码其实只有一行:
global.CUserToken.SetDefault(cToken, token)用的是 SetDefault 而不是带时长参数的 Set。
这样续期时长会跟着初始化配置走。
以后想改 TTL 只动一个地方,不会出现”初始化改了 60 天,中间件还在续 30 天”这种常量散落问题。
同时把有效期调整到 30 天
既然已经改了续期逻辑。
顺手把默认有效期也一起调整。
从:
72 小时改成:
30 天清理间隔顺手从 48 小时改成 24 小时。
最终得到的行为变成:
30 天内有任何访问↓自动续期↓保持登录
连续 30 天没有访问↓登录失效↓重新登录对于导航页这种长期驻留应用来说,这才是更符合我实际使用的习惯。
为什么不动 UserToken 的 1 分钟过期
其实我改的时候差点顺手把 UserToken 也一起延长,因为这个缓存的默认过期只有 1 分钟,看起来比 cToken 还短。
但 LoginInterceptor 里有一段 DB 兜底逻辑:
UserToken miss↓查数据库 users.token↓查到就回填缓存↓继续放行数据库里的 bToken 是持久存的。,所以这 1 分钟只影响性能,不影响登录寿命。
如果改长,反而可能会引入新问题:
我改头像↓DB 已更新↓UserToken 缓存里还是旧的↓最多 10 分钟界面不刷新所以这一层保持不动。
改密码后老 token 还是会失效
滑动续期加上以后,自然会担心一个问题:
如果改了密码,老 cToken 还在缓存里,那是不是还能继续用?
那必须是不能啊,因为改密码的接口会把数据库里的 users.token 字段清空:
我改密码↓DB users.token = ""↓UserToken 缓存清掉↓老 cToken 还在 CUserToken 里↓但下次请求会走 DB 兜底↓DB 查不到 token↓返回 1001 踢回登录页滑动续期只刷新 cToken 的 TTL,它管不了已经失效的 DB token,所以安全性不受影响。
退出登录也正常生效
退出登录的接口是挂在 LoginInterceptor 下面的,也就是说:
请求 /logout↓LoginInterceptor 先续期 cToken↓Logout 函数里 Delete cToken↓续期被 Delete 覆盖↓cToken 被清掉时序上续期在前,Delete 在后,最终结果就是 cToken 正确清理,退出登录不受影响。
重启进程还是要重登一次
这两层缓存都是 go-cache 内存驱动,数据全在进程内存里。
所以重启 sun-panel 的时候:
进程退出↓内存清零↓所有 cToken 丢失↓所有账号都需要重新登录这个是这次没解决的,但也是我能接受的。
彻底解决要么装 Redis,要么换成不走缓存的 LoginInterceptorDev。
但我觉得没啥必要,不想折腾了。
改完需要重新登录一次
对了,还有一个容易忽略的点。
旧 cToken 是按 72 小时老配置生成的。
改完代码部署以后,哪怕 cToken 还没过期,它也只会按老配置的 TTL 继续走,必须主动退出重登一次,才能拿到按新 30 天 TTL 生成的新 cToken。
最终效果
这几轮修改完成以后。
Sun-Panel 基本已经变成了我真正想要的样子。
监控部分:
能够看到宿主机真实状态构建部分:
能够快速重新编译和部署登录部分:
30 天滑动续期这些改动看起来都不算大,但实际使用体验好了不少,比直接增加几个新功能带来的体验明显得多。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时




























