主题
运维手册
本页面向已经跑起来的一套部署。当前的交付形态是 1Panel 应用包 + docker compose + 外部中间件,所以要运维的不是「一个 jar 进程」,而是「一个实例目录 + 两三个容器 + 一堆不在包里的服务」。本页只回答四件事:东西在宿主机的哪里、日志怎么看、每天该盯哪几项、哪些行为是设计如此。
| 你要解决的问题 | 去哪一页 |
|---|---|
| 还没装 / 要重装 / 安装表单怎么填 | 部署总览 |
| 装不上、起不来、按症状查原因 | 故障排查 |
| 这次安装/升级/卸载做了什么、卡在哪一步 | 任务中心与日志 |
| 升级平台、升级应用、存量库补脚本 | 升级与补丁 |
| 备什么、密钥纪律、上线前脱敏 | 备份与安全 |
| 运行期怎么盯、怎么重启、怎么清 | 本页 |
三条纪律
- 应用生命周期的问题先看任务中心(「我的应用 → 应用任务」):步骤与日志是边跑边落库的,比翻服务端日志快。
- 不要
DROP DATABASE。 安装表单建的库只授了库级权限(SHOW GRANTS只有一行GRANT ALL PRIVILEGES ON ...),应用账号没有全局CREATE,库删了就建不回来。要重来请清空表,做法见 升级与补丁。 - 应用装的菜单受保护:
path/component/perms/menuType/parentId的修改与删除都会被服务端拒绝(只放行名称 / 图标 / 排序 / 显隐)。想隐藏某个应用,用「停用」,不要删菜单。原理见 菜单归属与保护。
一、这台部署由什么组成
主包只有两个容器,定义在 deploy/1panel/apps/wujies-platform-server/5.6.3/docker-compose.yml(用户版是 wujies-platform-client/5.6.3/docker-compose.yml):
| compose 服务 | 容器名 | 端口 | 做什么 |
|---|---|---|---|
frontend | ${CONTAINER_NAME}(= 1Panel 实例名,属主服务) | ${PANEL_APP_PORT_HTTP} → 80 | nginx:1.27-alpine:托管前端 dist、反代 /prod-api/ 到后端、按 /remote-apps/ 提供远程应用产物、/minio/ 反代对象存储、/upload/ 提供本地存储 |
backend | ${CONTAINER_NAME}-backend | 容器内 8080;官方版再映射 ${PANEL_APP_PORT_API}(默认 18080) | 后端 jar(镜像内 /app/app.jar)。启动前先解析中间件地址,首次启动自己建库建表 |
两个服务都是 restart: always。用户版的后端没有 ports: —— 它不对公暴露后端端口,浏览器请求一律经前端的 nginx。
中间件一个都不在包里,每个都在安装表单里「选面板已装的服务」或「填外部地址」:
| 依赖 | 必填? | 怎么给 | 不给会怎样 |
|---|---|---|---|
| MySQL 8.x | 必填 | 「数据库服务」下拉选面板里的 MySQL(面板自动建库 + 授权),或填 5 个「外部」字段 | 容器启动失败:[entry] !! 没有数据库地址 |
| Redis | 必填 | 「Redis 服务」,或外部字段 | 容器启动失败:[entry] !! 没有 Redis 地址 |
| RabbitMQ | 选填 | 服务 / 外部字段 | 应用照常启动,只有「消息中心 / 站内信」不可用(入口脚本会明确写出来) |
| 对象存储 | 不用填 | 装完在「系统工具 → 系统配置 → 文件存储配置」里启用一种 | 默认所有配置都是停用状态 → 上传应用包、图片会失败 |
调度中心(独立包 wujies-snailjob-server) | 选填 | 「调度中心地址」填 snailjob-server(该包的固定网络别名) | 留空 = 不启用:订单自动取消 / 自动收货、优惠券状态与过期、促销启停、统计刷新、会员积分过期等任务都不跑 |
监控中心(独立包 wujies-monitor-admin) | 选填 | 「监控中心地址」填 http://monitor-admin:9090/admin | 留空 = 不注册,功能本身不受影响 |
地址解析规则写在 deploy/docker/backend/entrypoint.sh 的 pick() 里:外部字段非空 → 用外部;否则用面板选的服务;都空 → fail() 并打印该填哪一栏。这条逻辑必须在 shell 里做,compose 与 Spring 都表达不了这种条件。后端镜像里额外装了 default-mysql-client(deploy/docker/backend/Dockerfile),首次初始化就是用它连库。
容器路线没有 -Dloader.path
entrypoint.sh 最后一行只传了 -Dapp-market.deploy.plugin-dir,没有 -Dloader.path —— 插件由运行期加载器 PluginRuntimeManager 在启动期 + 运行期加载,所以「装应用 / 升应用不需要重启 JVM」在这条路线上成立。-Dloader.path 只存在于 jar 直跑那条路线(script/prune/start-host.ps1)。两套机制的差别见 插件运行时。
二、宿主机上的东西在哪
实例目录形如 /opt/1panel/apps/local/wujies-platform-server/<实例名>/(<实例名> 就是面板「已安装」里显示的名字)。安装表单的值写在实例目录的 .env 里,docker-compose.yml、data/、sql/ 也都在这一层 —— 下文所有 docker compose 命令都要先 cd 到这里。
| 宿主(相对实例目录) | 容器内 | 内容 |
|---|---|---|
./data/plugins-dist | /app/plugins-dist | 插件 jar 落位目录(= app-market.deploy.plugin-dir)+ active-plugins.txt |
./data/plugins-dist/migrations/<appCode>/ | /app/plugins-dist/migrations/<appCode>/ | 应用包里的迁移脚本(由后端读取执行);落位前会先清空该目录 |
./data/remote-apps | /app/remote-apps(后端读写)、/var/www/remote-apps:ro(nginx 只读) | 远程应用前端产物(= app-market.deploy.frontend-dir) |
./data/logs | /app/logs | 四个日志文件,见下一节 |
./data/upload | /app/upload | 本地文件存储(sys_oss_config 选 local 时用) |
./data/temp | /app/temp | 上传临时目录(compose 里 SPRING_SERVLET_MULTIPART_LOCATION=/app/temp) |
./sql | /app/sql:ro | 初始化 SQL 的 files/*.sql(后端首次启动时导入) |
落位产物分三处放(AppPackageDeployService):
- 后端插件 jar →
./data/plugins-dist/根下,同目录的active-plugins.txt记「每个应用当前生效哪个 jar」,格式是appCode=jarFileName; - 迁移脚本 →
./data/plugins-dist/migrations/<appCode>/(落位前会先清空该目录,所以包里删掉或改名的旧脚本不会留在磁盘上被再次执行); - 前端产物 →
./data/remote-apps/<appCode>/,对外 URL =/remote-apps/<appCode>/+ 清单里assets.frontend的文件名,并带?v=<版本号>破缓存(AppRuntimeController)。
一个目录两处挂,缺一处就是「安装成功但点进去 404」
./data/remote-apps 同时挂给后端(读写,落位用)和前端 nginx(只读,提供 /remote-apps/**)。两者必须是同一个宿主目录;不一致的表现是菜单在、接口在,页面报「远程视图不可用」或 /remote-apps/<code>/index.js 404。细节见 前端部署与 nginx。
三、日志在哪
日志由 wujies-admin/src/main/resources/logback-plus.xml 定义:log.path = ./logs,而后端容器的 WORKDIR 是 /app(deploy/docker/backend/Dockerfile),所以容器内是 /app/logs,即宿主 ./data/logs。
| 文件 | 内容 | 级别 | 滚动与保留 |
|---|---|---|---|
sys-console.log | 控制台同一份输出,全量 | INFO 及以上 | 按天 sys-console.%d{yyyy-MM-dd}.log,maxHistory=1(只留 1 天) |
sys-info.log | 只收恰好 INFO(LevelFilter 精确匹配,DEBUG/WARN/ERROR 都不进) | 恰好 INFO | 按天 gzip,maxHistory=60 |
sys-error.log | 只收 ERROR | 恰好 ERROR | 按天 gzip,maxHistory=60 |
| 容器标准输出 | docker compose logs 看到的那份 | 同 console | 由容器运行时决定 |
bash
cd /opt/1panel/apps/local/wujies-platform-server/<实例名>
# 容器状态;反复重启说明起不来,看日志
docker compose ps
# 入口脚本的结论都在 [entry] 前缀的行里
docker compose logs --tail=200 backend | grep '\[entry\]'
# 落盘日志在宿主目录上,直接 tail 就行
tail -n 200 data/logs/sys-console.log
tail -n 200 data/logs/sys-error.log
# 容器内的同一份(容器里是 /app/logs)
docker compose exec backend sh -c 'tail -n 100 /app/logs/sys-console.log'
# 看插件到底加载了哪几个
docker compose logs backend | grep -E '插件加载完成|插件目录下有'[entry] 行会依次给出:中间件地址解析结果(MySQL=… Redis=… RabbitMQ=…)、等待 MySQL 就绪、首次初始化或「已初始化过,跳过导入」、调度中心与监控中心是否启用、启动用的 profile。初始化的判据是 sys_config 里有没有 platform.init.version 这一行。
sys-console.log 只留一天
它是排障时最有用的那份(唯一含 WARN 的落盘文件:插件加载告警、目录远程调用失败、路由墓碑提示都只在这里),但 maxHistory=1。需要留证就当场复制走。
Actuator:能在线看健康与日志,但需要 HTTP Basic
application.yml 里 management.endpoints.web.exposure.include: '*'、management.endpoint.health.show-details: ALWAYS、management.endpoint.logfile.external-file: ./logs/sys-console.log:
| 端点 | 用途 |
|---|---|
GET /actuator/health | 健康检查,含各组件明细(show-details: ALWAYS) |
GET /actuator/logfile | 在线读 ./logs/sys-console.log(容器内 /app/logs/sys-console.log) |
GET /actuator/loggers | 查看当前各级别;POST /actuator/loggers/<name> 可临时改级别 |
| 其余端点 | 由 include: '*' 一并暴露(含环境、bean 拓扑等敏感信息) |
bash
# 注意端口:这是**后端**端口(面板里的「后端接口端口」,默认 18080),不是前端端口
curl -s -u wujies:<密码> http://127.0.0.1:18080/actuator/health
curl -s -u wujies:<密码> 'http://127.0.0.1:18080/actuator/loggers/cn.wujies'/actuator/** 的两件事必须记住
- 它走 HTTP Basic 鉴权,凭据是
spring.boot.admin.client.username/.password(SecurityConfig.getSaServletFilter()用SaHttpBasicUtil.check)。1Panel 路线下:填了「监控中心地址 + 账号 + 密码」时,entrypoint.sh会把表单值导出成这两个属性;留空时用的是构建期写进 jar 的默认值(根pom.xml的monitor.username/monitor.password,仓库里是wujies/123456)——上线前必须换掉(填监控中心表单,或给 backend 容器补SPRING_BOOT_ADMIN_CLIENT_USERNAME/_PASSWORD两个环境变量,或改 pom 默认值后重建镜像)。 - 别把后端端口暴露到公网。 官方版为了给用户版读目录,会把
${PANEL_APP_PORT_API}对公映射;而include: '*'+show-details: ALWAYS等于把配置项与 bean 拓扑一起送出去。公网只暴露前端端口,后端端口留给内网 / 反代白名单。
从前端端口打 /actuator/health 会「成功」,但那是假象
deploy/docker/frontend/nginx.conf 只反代了 /prod-api/、/remote-apps/、/minio/、/upload/,没有 /actuator。这条请求会落到 location / 的 SPA 回退,返回 HTTP 200 + index.html —— 看着像通了,其实一个字都不是监控数据。要么用后端端口,要么 docker compose exec backend 在容器里打。
四、日常检查清单
| # | 检查项 | 怎么看 | 异常信号 |
|---|---|---|---|
| 1 | 容器与健康 | docker compose ps;curl -u <监控账号>:<密码> http://127.0.0.1:<后端接口端口>/actuator/health | 容器反复重启;health 里某组件非 UP |
| 2 | 插件落位与生效版本 | 宿主 ls data/plugins-dist、cat data/plugins-dist/active-plugins.txt;日志里搜「插件加载完成」 | 清单里的 jar 在磁盘上不存在;同一 appCode 堆了多个版本(可清,见第五节) |
| 3 | 应用入口清单 | GET /app/runtime/remote-apps(不加权限注解,返回本租户已启用且有前端产物的应用与 entry) | 少了某个已启用应用;纯后端应用不出现是正常的 |
| 4 | 应用可用性 | GET /app/runtime/enabled-apps(本租户已启用应用编码,不要求有前端产物) | 与「我的应用」里的状态不一致 |
| 5 | 目录服务连通(用户版 / 目录集中) | 调一次市场页,然后去对端的访问日志里找 GET /app/catalog/... | 报「目录接口令牌校验失败」;对端日志里根本没有对应请求 |
| 6 | 签名就绪度 | GET /app/console/signature-readiness(权限 app:console:list) | ready=false,或 unsigned / invalid / unverifiable 不为 0 |
| 7 | 磁盘与卷 | 宿主 df -h、docker system df;重点看 data/logs、data/plugins-dist、data/remote-apps | 日志盘满;plugins-dist 下同一应用堆积多个版本 jar |
| 8 | 卡死任务 | 「我的应用 → 应用任务」里是否还有长期 进行中 | 有就等超时清扫机制收尾,不要手改库 |
| 9 | 备份项是否都在 | 见 备份与安全 | 签名私钥不在仓库之外留有备份 —— 最高优先级的一项 |
第 6 项不要做成定时任务
signature-readiness 会真实下载市场上所有已发布版本(上限 100,超出会报 truncated)逐个验签。按需调用即可,定时跑等于每天白拉一遍对象存储。
第 5 项有反直觉的纪律
验证目录远程调用时不要自己 curl 对端:否则对端日志里那几条 GET /app/catalog/... 是你自己打的,你会把「我打成功了」当成「调用方链路通了」。另外 HTTP 200 不等于成功 —— 远程目录出现过「200 但 body 是错误信封」。
第 8 项的机制:进程在任务执行中途被杀(重启、OOM、容器被驱逐)时,那条记录会永远停在 running,由 AppTaskTimeoutSweeper 收尾 —— 判据是 update_time(AppTaskRecorder 每推进一步就会刷新它),阈值是 app-market.task.timeout-minutes(默认 10 分钟),启动后 app-market.task.sweep-interval-ms(默认 60000)周期清扫,收敛成 status=failed + current_step=超时失败。看到「超时失败」时原始 log_text 还在,看它最后停在哪一步即可。
五、常用命令
bash
# ---- 定位实例目录(面板「已安装」里能看到实际路径)----
cd /opt/1panel/apps/local/wujies-platform-server/<实例名>
# ---- 启停 ----
docker compose up -d # 应用 compose 文件的改动
docker compose restart backend # 普通重启(**不会**重读 .env)
docker compose up -d --force-recreate backend # 改过 .env / 面板参数之后用这条
docker compose down # 停掉本实例(数据在宿主目录与数据库里,不丢)
# ---- 看产物 ----
docker compose exec backend sh -c 'ls -l /app/plugins-dist && cat /app/plugins-dist/active-plugins.txt'
ls data/plugins-dist/migrations/ # 每个应用一个子目录
# ---- 连库看一眼(镜像里装了 default-mysql-client;库名/账号是安装表单随机生成的那组)----
docker compose exec backend sh
# 下面是**容器内**的交互命令:选外部库时把 PANEL_DB_* 换成 EXTERNAL_DB_*
mysql -h"$PANEL_DB_HOST" -P"${PANEL_DB_PORT:-3306}" -u"$PANEL_DB_USER" -p"$PANEL_DB_USER_PASSWORD" "$PANEL_DB_NAME"
# 进去之后:select config_value from sys_config where config_key='platform.init.version';面板里有等价入口:应用商店 → 已安装 → 该实例的「日志」「终端」「参数」。在「参数」里改值并保存会重建容器,效果等同于 --force-recreate。
清理残留的旧插件 jar
宿主目录是 bind mount,所以在宿主机上删就等于在容器里删。平台仓库自带脚本(script/prune/prune-plugins.sh):
bash
# 先 dry-run,只会打印将删哪些
PLUGIN_DIR=./data/plugins-dist DRY_RUN=1 bash /path/to/wujies-platform/script/prune/prune-plugins.sh
# 确认后真删(只删「同一个 appCode 名下、不是 active-plugins.txt 记录的那一个」)
PLUGIN_DIR=./data/plugins-dist bash /path/to/wujies-platform/script/prune/prune-plugins.sh必须显式传 PLUGIN_DIR
脚本用「脚本目录的上一级」当工程根来推断 plugins-dist,而它住在 script/prune/,算出来是 script/plugins-dist —— 不传参数会对着一个不存在的目录空跑。prune-plugins.ps1 与 start-host.ps1 有同样的推断缺陷(start-host.ps1 的 $root 也会算成 script/),这条已记在 wujies-platform/docs/开发问题与解决方案.md 的遗留清单里。
残留 jar 现在不致命,但清单是硬前提
运行期 PluginRuntimeManager(容器路线的唯一加载者)与启动期加载器(仅 jar 直跑路线)都按 active-plugins.txt 选版本,不在清单里的 jar 会被刻意跳过,日志里会打印「插件目录下有 N 个 jar,按 active-plugins.txt 只加载 M 个(其余是旧版本残留,正常)」。⚠️ 但 active-plugins.txt 缺失或为空时不过滤,会退回「目录下所有 jar 全挂」的旧行为 —— 那一版才真的会因为两份同样的 mapper XML 起不来。清理仍然值得做:残留占磁盘,且能避免清单缺失时踩坑。
六、改一个东西,怎么让它生效
「改了没反应」绝大多数是生效方式用错了。按改的对象分四类(完整覆盖关系见 配置项):
| 改的是 | 生效动作 | 要重建镜像吗 |
|---|---|---|
| 安装表单里的值(端口、外部中间件地址、目录服务令牌、检查模式、调度 / 监控地址) | 面板「已安装 → 参数」保存(会重建容器),或 docker compose up -d --force-recreate | 不用。compose 把它们映射成环境变量,环境变量优先级高于 jar 内 yml |
jar 内 yml 的值(例如 app-market.security.public-key) | 改源码 → 重新打包平台包 → docker build backend 镜像 → 换镜像重建容器 | 要 |
前端 dist/(宿主前端源码) | 同上,重建 frontend 镜像 | 要 |
实例目录里的 sql/files/*.sql | 换文件 + 删掉 sys_config.platform.init.version 标记行 + 重启容器 | 不用(SQL 是 bind mount)。改表结构要另想办法,见 升级与补丁 |
| 平台表结构(存量库加列 / 加索引) | 手工执行 script/sql/update/ 里对应的幂等脚本 | 不用 |
| 对象存储凭据 | 后台「文件存储配置」直接改(落在 sys_oss_config 表) | 不用 |
签名公钥没有安装表单入口,换钥必须重建 backend 镜像
data.yml 里与安全相关的只有「应用包检查模式」,compose 也只传了 APP_MARKET_SECURITY_MODE —— 公钥没有走环境变量,它仍然来自 jar 内 application.yml 的 app-market.security.public-key(仓库里是开发验证阶段生成的那把)。所以要换成自己的钥:keygen 生成 → 用 -SyncPublicKey 或手工改源码 yml → 重新打包 + 重建 backend 镜像。 只改宿主上的文件没有用:yml 是打进 jar 的。顺序错了的表现是 enforce 把你刚签的包全部拒掉、报错却长得像「签名无效」。
1Panel 路线下「检查模式」以表单为准
APP_MARKET_SECURITY_MODE: ${SECURITY_MODE} 会覆盖 jar 内 yml 的值,而 data.yml 里这一栏的默认值是 warn(不是 yml 里的 enforce)。切 enforce 之前先跑一次 /app/console/signature-readiness,理由见 包安全与签名。
七、已知限制(设计如此,不是 bug)
下面每一条都有人当成缺陷报过。它们都是刻意的取舍。
| 限制 | 为什么这样设计 | 想绕过的话怎么做 |
|---|---|---|
| 迁移只做 up,不回滚 | MySQL 的 DDL 隐式提交,包一层事务是假的安全感;设计上要求脚本自身幂等 | 自己写一个「反向」的新迁移脚本(新文件名),当作一次正常变更发布 |
| 迁移脚本内容不可修改 | 已执行过的脚本按 (app_code, script) 记 SHA-256 摘要,内容变了直接拒绝执行 | 新增一个脚本(02-xxx.sql),让全新安装走 01、存量部署走 02 |
| 应用装的菜单不能改路由 / 权限,也不能删 | 这些字段与插件代码、前端路由、权限标识绑死,改了下次升级会被清单覆盖回去 | 改名称 / 图标 / 排序 / 显隐是放行的;要换页面就改 app.json 并发布新版本 |
| 签名是单公钥模型 | 一把私钥签所有包,服务端只配一把 app-market.security.public-key | 开放第三方上架需要换多钥注册表或平台审后重签 |
| 目录远程读取不做缓存 | 读取频率极低(安装/升级时几次);加缓存会遇到「刚上架却装到旧版本」 | 什么都不用做;超时已配(connect-timeout-ms: 3000 / read-timeout-ms: 15000),宁可快速失败 |
| 卸载默认不删数据表 | 默认那条路是「保留数据」(可恢复);只有显式选「彻底删除」才删表,且只删应用包迁移脚本里声明过的表 | 走「彻底删除」;对象存储里的图片两种模式都不删,要清自己清 |
| 卸载应用不会删数据库 | 库与账号是安装表单时面板建的,卸载不该顺手删数据 | 要清就在面板「数据库 → MySQL」里手工删库删用户(库名按 wujies_ 前缀找) |
| 初始化 SQL 只在没有标记时导入 | 判据是 sys_config.platform.init.version 这一行,避免每次重启重跑一遍 | 只改数据:删标记行 + 重启;改表结构:清表或手工执行补丁脚本(别 DROP DATABASE) |
| 「部署」按钮不跑迁移脚本 | POST /app/version/{versionId}/deploy 只做「落位 + 热加载」,迁移只在 install / upgrade 里跑 | 要改表结构就发布更高版本后升级,或「彻底删除」后重装 |
| 用户版看不到上架端 | 服务端 MarketEditionGuardFilter 对 /app/console、/app/version、/app/package 一律返回 403(不是 404、也不是静默失效),菜单整棵隐藏 | 这是同一个 jar 的形态开关,不是阉割版;安装 / 升级 / 任务中心 / 菜单保护 / 签名校验全都在 |
assets.signature 字段未使用 | 签名走的是包根 signature.txt(分离式),字段留着是为了兼容已上架的清单 | 不要以为填了它就等于签了名 —— 校验只看包内有没有 signature.txt |
jar 直跑那条路线的两个坑(1Panel 路线不涉及)
只在开发机 / 排障时用 script/prune/start-host.ps1 起 jar 的话:① 启动参数里的 -D 必须加引号("-Dloader.path=plugins-dist"),否则 PowerShell 会把它拆开;② 该脚本与 prune-plugins.ps1 的默认目录推断都是错的(见第五节),要显式传目录。交付与生产请走 1Panel 应用包。