把 AgentMall 部署到云服务器:从白屏排查到 GitHub 自动发布
记录 AgentMall 的 Docker 部署、Vue 白屏修复、数据持久化,以及 GitHub 自动发布中的 SSH 密钥格式问题。
博客上线解决的是静态文件托管。轮到 AgentMall,事情就复杂了一层:除了前端页面,还有 Java 后端、MySQL、上传文件,以及之后持续更新的问题。
这次目标很具体:把应用放到阿里云服务器上,让别人能访问;更新版本时保留数据;以后推送 GitHub 后,能够自动发布。
真正花时间的地方,集中在环境差异、页面白屏、服务边界和自动部署的最后一公里。
1. 先检查已有环境,再决定要装什么
这台服务器使用 Alibaba Cloud Linux 3,x86_64 架构,内存约 4GB,磁盘约 40GB。
任务交接时的信息是“尚未安装 Docker”,但实际检查发现服务器已有 Docker 和 Compose,可以直接复用。
这个细节很小,却改变了后面的操作:服务器状态需要以当下检查为准,不能把交接说明当成永远准确的事实。
部署前,我先确认了正在运行的服务、监听端口、磁盘空间,以及是否已经有业务数据,避免覆盖现有环境。已有本地代码改动也先保留,构建工作使用独立副本。
2. Termius 能登录,不代表命令行也能登录
我已经通过 Termius 登录过服务器,但自动化命令最初仍然无法通过 SSH 连接。
原因是两套工具的认证环境并不一定共享。Termius 可能使用保存在应用里的密钥,命令行 SSH 则要根据自己的配置、密钥文件和服务端授权完成认证。
手边有一个 .pem 文件,也不能仅凭文件存在就认定它与这台服务器匹配。
这次我先核对服务器主机指纹,再通过单独的部署公钥获得连接。整个过程没有把私钥或密码贴到聊天里。
早期部署结束后会清理临时授权,但连续维护时,每次都要重新手动添加,确实很打断节奏。后面我把用途分开了:
| 用途 | 最后的安排 |
|---|---|
| 本机协助维护服务器 | 保留独立、可撤销的长期维护授权 |
| GitHub 自动发布 AgentMall | 使用另一把受限部署密钥 |
维护密钥留在本机,GitHub 的部署账号只允许调用发布接收器,不提供普通 shell。两种授权没有混用。
3. 云端聊天里的文件,不会自动出现在本机
另一个容易忽略的边界,是文件到底在哪台机器上。
之前云端环境里已经构建过部署包,但用户无法下载。云端的 /workspace 路径属于那个环境,不是本机目录,也不是目标服务器目录。
这次最终回到本机源码重新构建,并明确指定目标平台:
docker build --platform linux/amd64 \
-f deploy/backend.Dockerfile \
-t agentmall-backend:<版本标签> .
docker build --platform linux/amd64 \
-f deploy/web.Dockerfile \
-t agentmall-web:<版本标签> .
本地能构建,不等于产物默认就适合目标服务器。把架构写清楚,省去了依赖默认行为的猜测。
后端构建还遇到了 Maven 预下载依赖耗时的问题。后面移除了额外的 dependency:go-offline 阶段,保留缓存挂载,直接完成 mvn package。本机复用了已有依赖缓存,GitHub 上也验证了正式构建可以成功。
4. 先让服务在本机跑通,再开放公网
最后的运行结构是:
浏览器
↓
Nginx:前端页面与入口
├── /api/ → Java 21 后端
└── /files/ → Java 21 后端
↓
MySQL 8.4
前端构建使用 VITE_BASE_URL=/api。浏览器向当前站点请求接口,由 Nginx 转发到内部后端,避免前端构建产物里残留本地开发地址。
后端监听容器网络里的 9090,MySQL 使用容器网络里的 3306。这两个端口没有直接开放到公网。
我先让网站只监听服务器的 127.0.0.1:8080,完成登录、商品查询、文件上传下载和重启后的持久化检查,再开放公网 HTTP 入口。
这里还踩到了云服务器的一道门:服务器本机访问成功,不代表云安全组已经允许外部访问。
确认应用正常之后,再放行安全组中的 TCP 80。没有为了图省事,把数据库和后端端口一起暴露出去。
当前站点完成的是 HTTP 上线,HTTPS 和域名仍然是后续要补的工作。
5. 页面返回 200,浏览器却是白屏
这次最直观的一次失败,是公网地址已经能访问,打开后却只有空白页面。
HTML 返回 200,JavaScript 和 CSS 也能拿到,但页面仍然没有正确渲染。因此只用 curl 检查首页状态码,并不能证明前端应用正常。
最后定位到 Vue Router 的组件声明。代码已经静态导入了组件,却又把组件对象包成函数:
import Login from '@/views/Login.vue'
// 本次项目中导致白屏的写法
{ path: '/login', component: () => Login }
这次修复统一改成了直接传入静态组件:
{ path: '/login', component: Login }
需要真正按路由懒加载时,则使用动态导入:
{
path: '/login',
component: () => import('@/views/Login.vue')
}
这三种写法不能只看起来差不多,就混在一起使用。
修复后重新构建前端镜像,只更新前端服务,再在浏览器里实际检查登录页和后台页面。同时复查 API 和之前上传的文件,确认修页面没有影响数据。
我也为 HTML 配置了缓存重新验证,减少更新后继续使用旧入口页面的情况。但缓存只能作为排查方向之一,不能把所有白屏都归因于缓存。
6. 容器可以替换,数据要单独保留
数据库文件和用户上传内容,不能依赖某个应用容器的临时文件系统。
这次分别使用命名卷保存 MySQL 数据和上传文件。服务配置了 restart: unless-stopped,Docker 自身也启用开机自启。命名卷与重启策略解决的是不同问题:前者保存数据,后者管理容器恢复运行。Docker 卷说明 · Docker 重启策略说明
新环境初始化时,只使用表结构、部署检查用的少量数据和随机生成的管理员初始密码,没有复制旧用户、旧订单和旧模型密钥。
随机密码也不是每次登录都会变化,而是在新初始化时生成并保存。后续更新继续使用已有数据库和运行配置。
这里我给自己留了一条明确的边界:正常更新应用,不执行删除数据卷的命令,不重新初始化数据库。
7. 自动部署卡在了一段看不见的 Secret 上
手动发布跑通以后,接下来才是自动化:
main 收到推送
→ GitHub Actions 构建前后端镜像
→ 打包并计算校验和
→ SSH 上传
→ 服务器校验并更新应用
→ 健康检查
第一次真实执行时,构建和打包都成功了,上传却报错:
Load key "...": error in libcrypto
Permission denied (publickey, ...)
这里要分清两个阶段:SSH 客户端先解析私钥,之后才向服务器完成认证。如果私钥本身没有被正确解析,先反复给服务器添加公钥,并不会解决问题。
因为不能把 Secret 的值打印出来排查,我给工作流加了构建前检查:规范化复制带来的换行,识别支持的 OpenSSH 编码形式,再验证解析出来的公钥指纹是否与服务器授权一致。
这次最终通过了验证,但没有为了“兼容格式”而接受任意密钥。能恢复格式之后,仍然要匹配预先确认的公钥指纹。
GitHub Actions Secret 的使用方式参考了官方文档,真实私钥只在私下配置过程中处理,没有进入代码仓库或构建包。GitHub Actions Secrets 说明
8. 自动发布也要能看出自己是否成功
服务器接收发布包时,检查了文件结构、大小、SHA256、应用镜像标签和目标架构,再加载前后端镜像。
更新时只重建前后端,不重建 MySQL。上线检查失败,会尝试恢复之前的应用镜像。
这里有两个不能夸大的地方:
- 回滚行为经过了自动化测试;这不等于我故意在线上制造了一次完整故障演练。
- 回滚应用镜像,不会自动撤销数据库结构变化,数据库迁移必须单独设计。
最终验收没有只看 GitHub 的绿色状态,还检查了服务器上的实际镜像标签、数据库容器是否保持不变,以及浏览器能否正常显示登录页。
管理员登录、认证后的商品查询、此前上传文件的下载和哈希检查都通过了。AgentMall 自身的真实模型业务调用,仍需配置相应的新密钥后再单独验证。
现在日常更新只需要把代码提交并推送到 main,或把 PR 合并到 main。修改本地文件还不算发布,但不用再重新走一遍手工打包和上传。
另外,购物者端和管理端也不是两台独立服务器:普通账号登录后进入购物者页面,管理员账号进入后台。入口不同,背后仍是这一套部署。
这次部署让我对“上线成功”的判断具体了很多:页面能看、接口能用、数据能留下、版本能更新,出了问题还知道该回到哪里检查。

