新闻

把 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。修改本地文件还不算发布,但不用再重新走一遍手工打包和上传。

另外,购物者端和管理端也不是两台独立服务器:普通账号登录后进入购物者页面,管理员账号进入后台。入口不同,背后仍是这一套部署。

这次部署让我对“上线成功”的判断具体了很多:页面能看、接口能用、数据能留下、版本能更新,出了问题还知道该回到哪里检查。