到了第7篇,整个HOJ部署链条里就剩前端这一块没落地了。前几篇我们在CentOS上装了宝塔、配好了数据库和中间件、把后端服务容器化跑起来了,但如果前端不发布,整个在线判题系统依然只是“后端API活着”的状态,浏览器里什么都没有。这一篇我会把前端如何构建、如何打成Docker镜像、如何通过宝塔发布容器,以及上线后最常见的白屏、登录失效、路由404这几个坑一次讲明白。
先说结论:HOJ前端虽然可以直接把构建产物丢到宝塔Nginx里用,但在整个项目选择容器化部署的前提下,我更建议把前端也打成镜像。原因倒不是“容器化听起来高级”,而是后续升级、回滚、迁移都要方便得多——一个镜像就是一整套运行环境,不需要在新服务器上重新装Node、装依赖、改Nginx配置。不过,前端镜像的坑也恰恰藏在“把静态文件塞进Nginx”这个看似简单的动作里,比如SPA路由回退、API反向代理、WebSocket升级,少配一行都可能导致上线后页面打不开。
1. 前端镜像在整个HOJ部署里解决什么问题
1.1 HOJ前后端分离后的目录关系
很多第一次部署HOJ的同学,会把整个项目当成一个单体应用来理解。实际上HOJ的前端和后端是彻底分离的:后端由若干个Spring Cloud微服务组成,负责处理业务逻辑、判题调度、数据存储;前端则是独立的Vue项目,负责页面渲染和用户交互。前端构建完之后就是一批纯静态文件(HTML、JS、CSS、图片),它本身不跑业务代码,所有数据都要通过HTTP请求打到后端网关。
这就带来一个很关键的问题:静态文件放在哪里、谁来提供HTTP服务。常见的做法有两种,一种是直接用宝塔面板自带的Nginx来托管dist目录,另一种就是我们现在要做的,把dist目录连同Nginx一起打进Docker镜像。两种方案在功能上没有本质区别,但容器化之后,前端服务的“运行环境”变成了镜像的一部分,换一台机器部署时只需要拉镜像、起容器,不再需要关心目标机器上有没有Nginx、Nginx配置是否合理。
1.2 为什么不用宝塔自带的Nginx直出dist
这不是说宝塔Nginx不好,而是从维护角度考虑,非容器化的方案会引入“配置漂移”的问题。比如你在一台服务器上手动改了Nginx配置,把/api/反向代理到某个地址,三个月后另一台服务器重新部署时,很容易漏掉这个配置项。而把Nginx配置写进镜像,等于把部署文档里的“软性要求”变成了“硬性约束”,任何人拿到同一个镜像,启动出来的前端服务行为都是一致的。
当然,容器化之后的容器端口要映射到宿主机,如果你宝塔面板自身的Nginx占用了80端口,就会冲突。这个我后面会专门讲,实际操作时一般把前端容器映射到8010、8080这类高位端口,再用宝塔的“反向代理”把域名流量转发过去,或者干脆停掉宝塔Nginx,让前端容器直接监听80。
2. 打包前端前的三处预检查:Node版本、仓库子项目、配置地址
2.1 选Node 18还是Node 20更稳妥
HOJ前端源码对Node版本的兼容性不算苛刻,但我建议优先使用Node 18 LTS或Node 20 LTS。为什么强调版本?因为前端依赖里有些锁文件是由特定npm/pnpm版本生成的,Node版本差异过大时,安装依赖可能因为原生模块编译失败而中断。
我的原则是“能在自己电脑上构建成功,就在容器里用同一套Node版本”。比如本地用node -v查出来是v18.20.4,那镜像构建阶段就用node:18-alpine作为基础镜像。这样能最大程度复现本地构建环境,少踩“本地能出包、容器里报错”的坑。
2.2 源码里有哪些前端工程需要分别打包
HOJ整个前端源码由一个多包仓库管理,里面通常包含面向用户的前台站点(frontend)和面向管理员的后台站点(admin)。这两个站点是两套独立工程,构建命令可能不同。我第一次部署时只构建了前台,结果后端管理页面怎么都打不开,后来才发现管理员界面需要单独构建。
建议拉到源码后先看根目录的package.json和README,找到类似build:front、build:admin这种脚本名。如果你拉取的版本没有拆分,也可以通过观察目录结构判断:一般frontend目录对应普通用户端,admin目录对应管理端。两个都构建出来,再分别做成两个镜像或者合并到一个镜像里,HOJ官方倾向于拆成两个容器,这样管理端和用户端可以独立升级。
2.3 后端地址与WebSocket地址不要写localhost
部署前端时最容易犯的错误,就是在环境配置文件里把后端地址写成http://localhost:8080。这个配置在你自己电脑上构建、本地联调时是没问题的,因为浏览器访问的也是localhost。但一旦部署到服务器,页面是用户在浏览器打开的,这时候前端代码里的localhost指向的是用户自己的电脑,而不是你的后端服务器,结果必然是接口全部超时。
正确的做法是区分“客户端可访问的地址”和“服务端内部通信地址”。比如你的服务器公网IP是1.2.3.4,后端网关端口映射为8080,那前端环境配置文件里就应该写http://1.2.3.4:8080,并确保后端网关已处理好跨域。如果配了域名,就直接写https://api.your-domain.com。许多用户反馈“后端都部署好了,前端登录没反应”,八成就是这里写成了localhost。
HOJ还会用WebSocket推送消息和判题结果,所以WebSocket的地址也要一并修改,通常是ws://或wss://协议,不能漏掉。漏配WebSocket的话,前端页面能打开,但判题状态不会实时刷新,表现得很像系统卡死。
3. 前端镜像的Dockerfile长什么样,每一步在干什么
3.1 构建阶段:为什么必须把install和build分成两步
前端镜像的核心是“多阶段构建”,即先在带Node环境的镜像里把项目构建成静态文件,再把静态文件拷入轻量的Nginx镜像。这一步可以显著缩小最终镜像体积——Node镜像动辄几百MB甚至1GB,而Nginx的alpine镜像只有几十MB。
我先给一份我在HOJ部署中实际用到的Dockerfile作为参考,具体项目结构不同,命令会有差异:
# 第一阶段:构建 FROM node:18-alpine AS build-stage WORKDIR /app # 先复制依赖清单,利用Docker缓存加快构建 COPY package.json package-lock.json ./ RUN npm install # 再复制全部源码 COPY . . RUN npm run build:front # 第二阶段:运行 FROM nginx:alpine AS production-stage # 把构建产物复制到Nginx的静态文件目录 COPY --from=build-stage /app/dist /usr/share/nginx/html # 覆盖自定义Nginx配置 COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]拆成两个阶段的好处不只是体积小。你可能发现我把COPY package.json和COPY . .分开了,这是因为Docker构建有缓存机制:只要package.json和锁文件没变,npm install这一步会命中缓存,不用每次重新装依赖。改一行源码重新构建镜像时,只有后续的COPY . .和npm run build会重新执行,构建速度快非常多。
3.2 运行阶段:为什么拷贝dist而不是拷贝源码
运行阶段我选用的是nginx:alpine,而不是继续用Node。因为前端构建完成后的产物是静态文件,不需要Node进程去跑,Nginx这样高性能的HTTP服务器来处理静态资源更合适。有人会问:如果项目里有服务端渲染需求怎么办?HOJ这类Vue SPA(单页应用)不涉及,所以肆无忌惮地走纯静态托管即可。
这里有一个需要注意的点:COPY --from=build-stage /app/dist /usr/share/nginx/html这行命令要求构建结果一定在/app/dist,如果你的HOJ前端构建输出目录不同,比如是build,就需要同步修改。可以在本地执行一次构建,观察生成的目录名再写Dockerfile。
3.3 Nginx配置:SPA回退、API反代、WebSocket升级
静态文件拷进去只是第一步,真正决定前端能不能正常工作的是Nginx配置。HOJ前端走的是Vue Router的history模式,这种模式下浏览器访问/login、/problem/123这类具体的路由时,Nginx如果没找到对应的物理文件,就会直接返回404。所以必须有SPA回退规则:
server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; # 前端路由回退,统一交给index.html location / { try_files $uri $uri/ /index.html; } # 反向代理后端API location /api/ { proxy_pass http://backend-gateway:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # WebSocket代理 location /ws/ { proxy_pass http://backend-gateway:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } }这里backend-gateway是后端服务在Docker网络里的主机名,如果你的后端容器名不是这个,改成实际的容器名或IP。X-Forwarded-Proto $scheme这行尤其重要,它告诉后端“用户实际是通过http还是https访问的”。如果漏掉这一行,即使用了HTTPS域名,后端生成的Cookie可能仍是http类型,导致登录后Session无效。
4. 在宝塔上发布镜像容器,并解决跨容器通信
4.1 打包与推送镜像的常用命令
Dockerfile就绪后,在源码目录执行:
docker build -t hoj-frontend:latest .如果服务器上没有镜像仓库,可以先把镜像导成tar包再拷贝到目标服务器,或者直接在目标服务器上构建。实际操作中,我一般在家目录建一个hoj-build文件夹,把源码放进去,在服务器上一键构建,省去上传镜像的流量。
构建完成后先本地验证一次:
docker run -d --name hoj-frontend-test -p 8010:80 hoj-frontend:latest curl -I http://127.0.0.1:8010curl -I能看到HTTP状态码,200说明Nginx正常返回。如果返回404,多半是静态文件路径不对;返回502则可能是后端反代地址配错。
4.2 容器网络里“后端能ping通但浏览器打不开”的问题
HOJ官方通常会用Docker Compose把多个服务编排起来,自动创建一个网络,所有服务之间可以通过容器名互通。但如果你用宝塔的“Docker管理器”逐个创建容器,默认网络可能是bridge模式,几个容器在逻辑上是可以通信的,不过“能ping通”不代表“HTTP请求能通”,因为后端服务可能只监听了特定端口,或者容器网络策略拒绝了连接。
我建议把同一套服务的容器都放到同一个自定义网络里,这样配置更清晰。先创建网络:
docker network create hoj-network启动后端容器时指定--network hoj-network,启动前端容器时也指定同一个网络:
docker run -d --name hoj-frontend \ --network hoj-network \ -p 8010:80 \ -v /opt/hoj/frontend-config:/usr/share/nginx/html/static:ro \ hoj-frontend:latest这里的挂载我先解释一部分,后面单开一小节详述。总之,容器之间用网络名互访,端口映射用的是宿主机端口,两者不要混淆。如果你启动容器后发现页面能打开但接口报502,先进前端容器里测试一下后端服务名是否解析正确:
docker exec -it hoj-frontend sh wget -q -O- http://backend-gateway:8080/actuator/health能拿到JSON说明容器间网络是通的,问题多半出在Nginx的proxy_pass路径上。
4.3 用数据卷挂载config.js实现改配置不重建
HOJ前端会把一些运行时可变配置放进独立的JavaScript配置文件,常见的是static/config.js或public/config.js,里面包含后端地址、WebSocket地址、CDN开关、站名等等。如果这个配置被打死在镜像里,每次改后端IP或者换域名,都要重新构建镜像,很麻烦。
我的做法是在宿主机准备一个配置目录,比如/opt/hoj/frontend-config/config.js,然后把目录挂载进容器的static目录。容器启动时,宿主机上的config.js会覆盖镜像里的默认配置。这样以后迁移域名或后端地址,只需要改宿主机上的文件,然后重启容器,不需要重新build镜像。上面的docker run命令中-v /opt/hoj/frontend-config:/usr/share/nginx/html/static:ro就是这个目的。
需要注意:挂载目录要提前创建并放入正确的config.js,否则容器会把空目录挂进去,可能顶掉原有的默认配置,造成页面连默认配置都没有,直接白屏。
5. 上线后的故障排查清单:白屏、登录失败、页面404
5.1 白屏:先看浏览器Network,再进容器验证
前端镜像发布后,我见过最多的反馈就是“页面白屏”。白屏的排查顺序很重要,不要一上来就怀疑Nginx配置。先打开浏览器F12,看Console和Network。
- 如果Console里报“Failed to fetch”或“跨域”相关错误,是后端地址配置不对或跨域未处理。
- 如果Custom上加载的JS文件返回404,可能是构建产物路径和Nginx的root路径不匹配。
- 如果页面加载出来但空白,且Console无报错,有可能是Vue运行时异常,常见原因是
config.js里的必填字段缺失。
进容器确认静态文件位置也很简单:
docker exec -it hoj-frontend ls /usr/share/nginx/html如果文件存在,再用curl在容器内访问一下http://127.0.0.1/,看是否返回HTML。如果容器内正常而外部访问白屏,问题往往出在容器端口映射或宿主机防火墙。
5.2 登录失败:重点检查Cookie和X-Forwarded-Proto
登录失败这个坑,我在多个项目里都遇到过。前端把用户名密码提交到后端,后端成功返回,但下一次请求又提示未登录。原因一般是Cookie的属性问题。后端在Set-Cookie时没有标记Secure,但前端实际是HTTPS访问,浏览器就会拒绝保存Cookie。这种情况要检查Nginx是否正确传递了X-Forwarded-Proto,后端才能感知到当前请求是HTTPS,从而生成Secure Cookie。如果后端本身只暴露HTTP地址,而前面还有一层HTTPS网关,那也要确保网关把所有请求转发给容器时,保留X-Forwarded-Proto请求头。
另一类登录失败原因是前端配置了不正确的WebSocket地址,导致登录成功后Socket连接建立失败,前端误判为登录状态不同步。所以登录排查时,不要只看Login接口,还要看/ws/请求是否握手成功。
5.3 404:SPA路由模式与try_files顺序
404有两种情况:一种是刷新某个具体页面时404,比如刷新/problem/1000会404,但点进页面没问题。这是典型的SPA回退没有配置好,try_files没有正确落到index.html。修复方式就是保证有:
location / { try_files $uri $uri/ /index.html; }另一种是访问后端接口返回404,比如请求GET /api/health返回404。这种情况要检查前端请求路径和后端路由是否完全匹配。HOJ后端接口通常以网关路由为前缀,比如/api/或/gateway/,如果Nginx反代时写了location /api/,但proxy_pass http://backend:8080后面没有加路径,那/api/health会原样转发给后端,而后端可能不认/api/health这个路径,就会404。通常需要在proxy_pass里做路径重写:
location /api/ { proxy_pass http://backend-gateway:8080/; proxy_set_header Host $host; }proxy_pass结尾的/会把/api/前缀去掉,比如前端请求/api/health,转发到后端就是/health。至于是去掉前缀还是保留前缀,取决于后端网关的路由规则,这个要看HOJ项目的具体实现。
6. 发布前值得顺手做的镜像瘦身与安全收尾
6.1 用多阶段构建精简镜像
我在第3节已经演示了多阶段构建。如果你拿到的源码里已经有现成Dockerfile,推荐直接使用官方维护的版本,不要自己另搞一套。但如果你想自定义,记住核心原则:构建阶段用全功能镜像,运行阶段用最小镜像。nginx:alpine的镜像体积很小,且自带常用工具,维护成本低。
构建时还可以缓存PNPM或Yarn的依赖目录,进一步加速。比如使用PNPM时,构建阶段先执行:
RUN pnpm config set store-dir /pnpm-store COPY pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile这样依赖会被缓存,后续构建只需要改源码,不需要重新解析锁文件和下载全部依赖。
6.2 避开80端口冲突与可观测性小配置
如果你的服务器上宝塔自身Nginx已经占了80端口,前端容器再映射80端口就会失败。最简单的办法是把前端容器映射到高位端口,比如8010,然后在宝塔“网站”里添加反向代理,把需要对外访问的域名或路径转发到http://127.0.0.1:8010。这样宝塔Nginx负责接收80/443的HTTPS流量,再转到我们的前端容器,容器内部仍然是80端口,互不冲突。
如果不想额外套一层代理,也可以停掉宝塔Nginx,直接让前端容器映射宿主机80端口,但这样宝塔的网页管理功能可能会受影响,不建议新手这么做。我更倾向于“域名入口统一由宝塔Nginx管理,里面转发到各个应用容器”的布局。
容器启动后,记得加上--restart=always,防止服务器重启后前端容器没有自动拉起。这条参数在宝塔Docker管理界面里也有对应选项。
6.3 后端网关和前端location如何保持一致
最后再强调一个细节:前端请求的路径前缀,必须和后端网关配置一致。很多同学独立部署时,前端写/api/,后端网关的路由前缀是service-api,结果自然对不上。在改Nginx之前,先用curl手动验证一条后端接口的地址:
curl http://127.0.0.1:8080/actuator/health然后逐步模拟前端请求路径,比如带前缀访问:
curl http://127.0.0.1:8010/api/actuator/health如果Nginx反代配置正确,能得到和后端一样的JSON。这比盲改配置高效得多。
另外,静态资源的缓存策略也可以顺手优化:带hash的JS/CSS资源可以设置长期缓存,index.html设置为不缓存或短缓存,这样用户更新页面时能及时拿到新版本。Nginx里可以这样区分:
location /static/ { expires 30d; add_header Cache-Control "public, immutable"; } location = /index.html { add_header Cache-Control "no-cache"; }说实话,HOJ部署到这个阶段,整套系统已经算是“能跑”了。前端镜像的构建本身不难,难点全在运行时的路径匹配、网络连通和Cookie这类“看不见的细节”上。我在帮别人排查时发现,90%的问题都出在三个地方:config.js里的地址写错、Nginx缺少SPA回退、反向代理路径不一致。
如果按这套流程做下来还是有问题,建议先把容器日志打开:
docker logs -f hoj-frontend再配合浏览器F12的Network面板,一步步确认静态资源、API请求、WebSocket连接分别卡在哪一环。容器化部署的好处就是“环境和代码一起打包”,排错时可以大胆删掉容器重建,不用担心在服务器上留下什么清理不干净的残留文件。前端这一步跨过去,HOJ就算是真正交付了。