技术栈:Spring Boot 3.5 + Vue 3 + MySQL + Redis + Docker + Nginx 关键词:多环境发布、蓝绿零停机、SSH 远程执行、RBAC 数据权限、实时监控、审计日志
一、项目背景
团队里服务器、项目、环境、发布、监控、权限、审计分散在多个工具里:服务器靠 Excel 记录,发布靠手工 scp + docker run,出问题靠人肉登机器看日志。目标很明确——用一个平台把这些动作收敛起来:
服务器管理:统一登记、SSH 连通性检测、Web 终端
项目与环境管理:一个项目多套环境(DEV/TEST/PROD),每套环境绑定服务器、端口、实例数
发布中心:从 Git 拉代码 → 构建镜像 → 多实例部署 → 负载均衡 → 一键回滚
监控大盘:实例状态、CPU/内存、告警、容器日志
权限与审计:角色 + 数据范围,所有敏感操作留痕
下面按「技术选型 → 架构 → 核心实现 → 踩坑」的顺序展开。
二、技术选型
三、整体架构
┌────────────────────────────┐ ┌─────────────────────────────┐
│ Vue 3 SPA (Vite) │ REST │ Spring Boot 3 (pl-platform)│
│ Element Plus / Pinia │ <────> │ Controller → Service → Mapper
│ xterm.js 终端 │ WS │ Security / JWT / 审计切面 │
└────────────────────────────┘ └───────────┬─────────────────┘
│ SSH(JSch) / WS
┌─────────────▼─────────────┐
│ MySQL 3307 Redis 6380 │
└───────────────────────────┘
│
┌─────────────────────────▼────────────────────────┐
│ 目标服务器 node1 / node2(Docker + Nginx) │
│ 应用容器 ×N + 负载均衡容器 ×1 │
└──────────────────────────────────────────────────┘后端按 controller / service / service.impl / mapper / entity / dto / config / security 分层,共 9 个 Controller、15 个核心 Service 实现;数据库通过 10 个 Flyway 脚本从零建到 V10。
四、核心实现
4.1 RBAC + 数据范围
权限模型是「用户-角色-权限点」三层,角色额外带一个 数据范围:
ALL:全部数据PROJECT:本人负责的项目及其发布单SELF:仅本人创建的数据
落地方式是 DataScopeService 统一给出 Scope 与可见 projectIds,各业务 Service 在拼查询条件时统一调用,避免每个模块各写一套判断:
DataScopeService.Scope scope = dataScopeService.scope(userId);
if (scope == Scope.SELF) {
query.eq(SysRelease::getOperatorId, userId);
} else if (scope == Scope.PROJECT) {
query.in(SysRelease::getProjectId, dataScopeService.projectIds(userId));
}同时做了几条硬约束:
超级管理员不可编辑、不可删除、不可停用、不可重置密码、不可移除超管角色;
非超管给他人授权时,只能授予自己已拥有的权限点,否则 403;
登录失败 5 次锁定 15 分钟,用 Redis 计数,Redis 异常时降级放行,避免缓存故障导致全员登不上。
4.2 SSH 凭据加密与 Web 终端
服务器的 SSH 密码/私钥不能明文入库,落地为 AES-GCM 加密列(CryptoService),并遵循两条规则:
列表接口只返回「是否已配置」,不回显密文;
编辑弹窗需要回显时,走单独接口
GET /api/servers/{id}/credentials,且要求server:update权限并写审计日志。
前端用统一的 SensitiveField 组件:默认显示 *,点「查看」后切换明文并可编辑,避免敏感信息长期暴露在屏幕上。
Web 终端基于 WebSocket + JSch:前端 xterm.js 负责渲染与尺寸同步(addon-fit),后端 SshTerminalHandler 把 PTY 输出流式推回,输入直接写进 channel。
4.3 发布引擎:蓝绿切换做到零停机
这是整个项目最复杂的一块。最初版本是「删旧容器 → 起新容器」,实测会有几秒 502;改造后流程如下:
阶段1 逐台服务器:git 拉取 → docker build
阶段2 起新实例:换一组端口(基础端口 +100/+1000/+2000 动态选空闲槽),
容器打标签 pl.project / pl.env / pl.release,等端口就绪
阶段3 切负载均衡:重写 nginx upstream → nginx -t → nginx -s reload(复用 LB 容器)
阶段4 排空 10 秒后:按标签清理非当前 release 的旧容器几个关键设计点:
① 端口选择不能写死。 新旧实例要同时存活,必须让新实例先起在另一组端口。用 docker ps --format '{{.Ports}}' 采集实际占用,再选 0 / +1000 / +2000 里第一个在所有目标服务器上都空闲的槽位:
for (int offset : new int[]{0, PORT_SLOT_OFFSET, 2 * PORT_SLOT_OFFSET}) {
// 逐个端口检查是否被 ":{port}->" 占用
}② 就绪判定用 TCP 探测而非 sleep。 端口能连上才进入下一阶段,起不来就直接失败并打日志,旧实例继续服务:
for i in $(seq 1 90); do (echo > /dev/tcp/127.0.0.1/$PORT) && { READY=1; break; }; sleep 2; done③ 容器用标签做代次识别。 实例名带上发布单号(pl-<code>-<env>-<releaseId>-<n>),清理时按 pl.release 标签保留当前代次,避免靠名字正则误删(早期用子串匹配 name=pl-xxx-dev-,把 -lb 负载均衡容器一起删了)。
④ 失败要能收拾残局。 切换 LB 之前任何一步失败,只删「本次发布单」启动的容器(按 pl.release 标签),旧实例不受影响;切换成功后才允许进清理阶段。
⑤ 并发发布互斥。 两个人同时点发布,两套流程会互相清理对方的容器,最后可能把 LB 指向的实例删掉。因此在「查是否有进行中发布 + 落库」外面套了 Redis SETNX 环境锁(TTL 30s),Redis 不可用时只告警不阻塞。
⑥ 旧实例留 10 秒排空。 nginx -s reload 后旧 worker 还在处理在途请求,立刻 rm -f 会有瞬时 5xx,所以统一等待 10 秒再清理。
发布日志按秒落库,前端 1 秒轮询,抽屉打开即自动滚到底部。
4.4 监控采集:从「假在线」到真实探测
监控大盘最初两个指标是“糊”的:
「在线服务器」其实是
status=1的启用数量,SSH 连不上也照样算在线;实例状态按「环境实例数 + 命名规则」推导,发布改造后实例名带了发布单号,导致真实容器被判成
MISSING。
修正方案:
sys_server增加online、last_check_at(Flyway V10),每轮采集先对启用服务器执行docker info做真实连通探测;实例发现改为按 Docker 标签查询(
pl.project+pl.env),LB 容器用-lb后缀识别;采集结果写
sys_monitor_instance,状态从 RUNNING 变为异常时生成告警;不再存在的实例记录自动清理;页面展示「实例数据采集于 xx · 服务器连通探测于 xx」,把 30 秒采集周期摆到明面上,避免用户误判为实时。
前端侧还有两个体验问题顺手修了:
轮询请求全部带上
hideLoading,否则全局 loading 遮罩每 2 秒闪一次,看起来像“一直加载中”;日志抽屉只在首次加载显示局部 loading,之后静默刷新并自动滚到底部。
4.5 审计日志
所有敏感操作(改密码、查看凭据、发布、回滚、导出)统一走审计切面,记录操作人、IP、参数、耗时、结果。两个细节:
参数中的密码/令牌统一替换为
***;IPv6 回环地址统一展示为
127.0.0.1,便于检索。
查询支持多条件组合与 CSV 导出(导出单独走 audit:export 权限点)。
五、踩坑实录
5.1 菜单点击后自动收起
原因:侧边栏用了嵌套 el-menu,子菜单被重新创建导致展开态丢失。改法是只保留一个 el-menu,用递归组件 MenuTree.vue 渲染层级。
5.2 切换菜单整页刷新
一开始怀疑 Vite 依赖预构建,实际根因是 App.vue 顶层的 <router-view :key="route.path">——key 变化导致整个布局销毁重建。把 key 下移到布局内部的 <router-view>,只重建内容区;同时在 vite.config.js 的 optimizeDeps 里预声明所有 Element Plus 组件,避免开发态依赖重载。
5.3 切换主题后表格斑马纹出现白底残影
Element Plus 的斑马纹用的是半透明背景色,Chromium 切主题时不重绘单元格背景。方案是新增不透明主题变量 --app-table-stripe,在 element-overrides.scss 统一覆盖。
5.4 服务器是 CentOS 7 + git 1.8.3,私有仓库拉不下来
1.8.3 不支持 http.extraHeader 认证,git clone 直接报 could not read Username。改用 GIT_ASKPASS 临时脚本(mktemp + chmod 700 + trap 退出删除),令牌不出现在命令行和日志里。
5.5 Docker Hub 与 Maven 中央仓库太慢
Docker:写
/etc/docker/daemon.json配置国内 registry mirror 并重启;Maven:构建镜像时注入阿里云
settings.xml,依赖下载从 ~10KB/s 提到 ~1MB/s。
5.6 代码明明推了,发布却一直是旧版本(最隐蔽的一个)
发布日志里 git rev-parse HEAD 始终停在仓库最初提交,但 git ls-remote origin master 能看到新提交。根因是服务器上缓存的仓库缺少 remote.origin.fetch refspec,git fetch --all 只写 FETCH_HEAD,origin/master 永远不更新,checkout -B master origin/master 每次都回到旧提交。
修复:
git fetch origin --tags --prune '+refs/heads/*:refs/remotes/origin/*'并在发布日志里常驻打印「当前 HEAD / 远端 master」,以后代码没更新能一眼看出。
5.7 编译通过却运行报 Unresolved compilation problems
mvn compile | tail -8 拿到的退出码是 tail 的而不是 mvn 的,导致误判成功,实际用 IDE 生成的错误 class 在跑,抛 java.lang.Error,catch (Exception) 又抓不到,发布单卡在 RUNNING。教训是校验构建结果必须重定向到文件并显式取 $?。
六、部署与验证
本地/测试环境用 Docker 跑 MySQL(3307)、Redis(6380),后端 8080,前端 Vite dev 5173 或构建为静态资源由 Nginx 托管。Flyway 在启动时自动把库迁到最新版本,无需人工干预。
线上验证方式值得一提:单独做了一个 pl-demo 项目作为“被发布对象”,提供 5 个开放接口:
GET /api/demo/ping:连通性探测GET /api/demo/info:返回instanceId、hostIp、容器 IP,用来验证多实例与负载均衡GET /api/demo/time、POST /api/demo/echo、GET /api/demo/divide
平台侧再把 PL_HOST_IP、PL_INSTANCE_ID 注入容器,配合 /api/demo/info 的调用日志,就能直观验证「请求落到哪台服务器哪个实例」以及监控日志的实时刷新。
一次 1.0.12 发布实测:两实例(node1/node2)、LB 轮询正常,切换期间以 0.3 秒间隔连续探测 278 次全部返回 200,旧容器按标签清理干净。
七、总结
这个平台的价值不在“功能堆了多少”,而在于把几件容易出错的事做成了确定性流程:
发布可回滚:镜像按版本打 tag,回滚复用已有镜像,不做重复构建;
切换无中断:端口槽位 + 就绪探测 + upstream reload + 排空等待;
状态可自证:实例名、Docker 标签、
instanceId、hostIp都能反查请求落在哪;权限收得住:数据范围 + 敏感字段加密 + 审计留痕。
后续可以继续做的方向:把发布流水线拆成可编排阶段(支持自定义构建命令)、监控指标落历史表做趋势图、以及把单机 SSH 执行替换为 Agent 模式,减少对 SSH 稳定性的依赖。




