1. 一套 Vue+SpringBoot 前后端分离项目,为什么非得用 Docker 跑
先说结论:如果你手上有 Vue 做前端、SpringBoot 做后端的前后端分离项目,并且你已经受够了"在我电脑上明明是好的"这句话,那 Docker 基本是你绕不过去的一站。我前后用 Docker 部署过七八套这类项目,从个人小工具到公司内部中台,踩过的坑能装满一个回收站文件夹。这篇就把我从零到一键启停的完整过程摊开讲——镜像怎么切、Compose 怎么写、Nginx 怎么配、接口 404 和跨域怎么排,全部给你落到能直接抄的粒度。
这篇文章适合三类人:刚学完 Vue 和 SpringBoot、准备第一次把项目部署到服务器的朋友;被环境依赖折磨过、想用容器统一运行环境的开发者;以及手里有一堆微服务、想用编排工具把启动流程压成一条命令的工程师。我不打算讲太多 Docker 的理论八股,重点放在"这套前后端分离项目落到容器里,每一步为什么这么选"。
先交代一下背景。前端 Vue 一般用 Vite 或 Vue CLI 构建,产物是一堆静态文件;后端 SpringBoot 打包出来是一个可执行 JAR,内部塞了 Tomcat。传统部署方式无非是:服务器上装个 Nginx 托管前端静态文件,再把 JAR 丢到服务器上用nohup java -jar跑起来。听起来简单,但真做起来,JDK 版本、Nginx 路径、字符集、时区、端口占用、开机自启,每一项都能耗掉你半天。而容器化的价值就在于:把这些环境变量和依赖,全部锁进镜像里,让"能跑"这件事变得可复制。
2. 动手前的整体设计:镜像怎么切、服务怎么连
在敲第一行 Dockerfile 之前,我建议你先花二十分钟把架构想清楚。很多新手上来就docker run一个 JAR,跑通了就以为万事大吉,结果前端往哪放、接口怎么代理、数据库怎么连,全都打成一团。这一步的规划质量,直接决定后面你要返工几次。
2.1 前后端分离项目在传统部署下的三个老大难
第一个难题是环境漂移。你本地是 JDK 17,服务器上是 JDK 8,SpringBoot 3.x 直接起不来,报的错还特别隐晦,往往是一串 class 版本号不匹配。前端也类似,Node 版本不同,npm install出来的依赖树能差出一整个 node_modules 地狱。
第二个难题是代理与静态资源的耦合。前后端分离项目里,前端页面是静态的,但发请求时要打到后端 API。开发阶段我们靠vue.config.js或 Vite 的server.proxy做代理,可一旦打包上线,这个代理就失效了,必须靠 Nginx 的location反向代理来接管。这一步配错,页面能打开但接口全红,非常常见。
第三个难题是启停与依赖顺序。后端要连数据库,数据库还没起来,SpringBoot 就疯狂重连失败然后退出。以前靠写一堆 shell 脚本sleep 30硬等,容器编排里如果不好好处理依赖,你会看到后端容器反复重启,日志刷屏。
这三个问题,Docker 加 Compose 组合起来恰好都能治:镜像锁环境,Nginx 容器管代理,Compose 管顺序和网络。这就是我坚持容器化的原因。
2.2 镜像拆分的三种思路与取舍对比
怎么把项目装进镜像,业内其实有几种流派,我列个表对比一下,你再决定用哪种。
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 单镜像全塞 | 一个镜像里同时放 Nginx、静态文件、JAR、JDK | 只有一个容器,部署简单 | 镜像巨大(500MB+),前后端耦合,改前端要重新构建整个镜像 |
| 双镜像分离 | 前端一个 Nginx 镜像,后端一个 JRE 镜像 | 职责清晰,各自独立构建,镜像小 | 需要额外配 Nginx 反向代理和网络 |
| 后端镜像内托管前端 | 把 Vue 产物拷进 SpringBoot 的 static 目录 | 只需要一个容器 | 前后端构建耦合,前端改一点就要重打后端包,热更新全无 |
我试过第一种和第三种。第一种镜像动辄六七百兆,推送镜像到仓库时等到怀疑人生;第三种确实是"一个 JAR 解决所有",但前端每次改动都要重新走一遍 Maven 打包,团队协作时非常难受。最后我固定用第二种:双镜像分离。前端交给 Nginx,后端只跑 JAR,两边通过 Docker 内部网络通信,Nginx 把/api前缀的请求转发到后端容器。
2.3 我最终落地的方案:双镜像 + Compose 编排
具体长这样:整台机器上跑两个容器加一个可选的数据库容器。web容器基于nginx:alpine,里面只有编译好的前端静态文件和一份 Nginx 配置;api容器基于eclipse-temurin或openjdk的 JRE 镜像,里面只有 JAR 和 JVM 参数。两个容器加入同一个自定义 bridge 网络,web通过服务名api就能访问到后端,不需要暴露后端的端口到宿主机。
这个设计的妙处在于网络隔离。后端端口完全不对外暴露,外部只能通过 Nginx 的 80 端口进来,安全性提升一截,也避免了端口冲突。你本地开发时后端跑 8080,服务器上根本不用管,容器内部自己通。下面几章我就按"先做后端镜像、再做前端镜像、最后 Compose 串起来"的顺序,逐块拆给你看。
3. 后端 SpringBoot 镜像:从 Maven 打包到容器启动
后端这块相对单纯,核心就是"怎么把 JAR 用最小体积跑起来"。但里面有几个细节,做不做,直接影响到镜像大小和启动速度。
3.1 打包方式与 JAR 分层
SpringBoot 项目用 Maven 或 Gradle 打出来的可执行 JAR,内部结构其实是"应用代码 + 依赖库 + 启动器"三层。SpringBoot 2.3 之后支持了layered JAR,也就是把依赖按变动频率拆成dependencies、spring-boot-loader、snapshot-dependencies、application四层。这么做的好处是:当你只改了业务代码,Docker 构建时可以复用前面几层缓存,镜像重建从几分钟压到几十秒。
如果你嫌配置分层麻烦,还有一个更省事的做法——多阶段构建,在容器里直接跑 Maven。我早期图省事就这么干,缺点是每次构建都要重新下载依赖,网速慢的时候能等到睡着。所以我现在推荐本地或 CI 先打 JAR,再进 Dockerfile 只负责运行,构建速度快得多。命令很普通:
mvn clean package -DskipTests打完之后在target目录下会生成类似demo-0.0.1-SNAPSHOT.jar的文件。注意-DskipTests不是让你永远不跑测试,而是构建镜像时跳过,测试应该放在 CI 的独立阶段跑,别混在一起。
3.2 Dockerfile 逐行拆解
后端的 Dockerfile 我习惯放在项目根目录,内容就这么几行:
FROM eclipse-temurin:17-jre-jammy WORKDIR /app # 时区设置为东八区,避免日志时间差八小时 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone COPY target/demo-0.0.1-SNAPSHOT.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app/app.jar"]这里有几个点值得说。基础镜像我选eclipse-temurin的 JRE 版本而不是 JDK,因为运行阶段根本不需要编译器,JRE 能省下两百多兆。如果你项目用的是 SpringBoot 2.x 配 JDK 8,换成eclipse-temurin:8-jre就行;用 3.x 就上 17。时区那两行是血的教训,不加的话容器里默认 UTC,日志时间比北京时间早八小时,排查问题时能把人绕晕。
ENTRYPOINT用 exec 格式(中括号数组写法)而不是 shell 格式,是因为 shell 格式会把启动命令包一层/bin/sh -c,导致 JVM 收不到 SIGTERM 信号,docker stop时无法优雅停机,只能等超时被强杀,容易丢数据。这个细节很多教程不讲,但生产环境里很关键。
3.3 JVM、时区与健康检查的实战参数
镜像能跑之后,我通常会再补三样东西。第一是JVM 参数,容器内存有限,JVM 默认会按宿主机内存去分配堆,容易被 OOMKiller 干掉。现在推荐用容器感知的写法:
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:+UseG1GC", "-jar", "/app/app.jar"]MaxRAMPercentage=75.0表示堆最大用到容器内存的 75%,留 25% 给元空间、线程栈和直接内存。比写死-Xmx512m灵活,换台机器不用改。
第二是健康检查,让容器自己汇报状态。SpringBoot 接了 Actuator 之后有/actuator/health端点,可以在 Compose 里配healthcheck。第三是环境变量注入,数据库地址、密码这些东西绝不能写死在镜像里,一律通过 Compose 的environment或.env文件传进去,SpringBoot 用${DB_HOST:localhost}这种占位符读取。这样同一份镜像,测试环境和生产环境可以跑出不同配置。
4. 前端 Vue 镜像:构建产物与 Nginx 托管
前端这一步比后端费脑子,因为坑大多藏在"打包配置"和"Nginx 路由"之间的接缝处。我见过太多人本地npm run dev一切正常,打成镜像后页面白屏或者刷新就 404。
4.1 Vue 打包阶段最容易踩的 base 与路由
第一个坑是publicPath / base。Vue CLI 里叫publicPath,Vite 里叫base。如果你部署在域名根路径下,保持默认/就行;但如果你挂在子路径比如/admin/下,就必须显式配成/admin/,否则打包出来的资源引用路径会错,页面直接白屏,控制台一堆 404。这是新手最高频的白屏原因。
第二个坑是路由模式。Vue Router 有 hash 和 history 两种模式。hash 模式(URL 带#)刷新不会 404,因为#后面的内容不发给服务器;history 模式好看,但刷新时浏览器会把整条路径发给 Nginx,Nginx 找不到这个文件就报 404。解决办法是在 Nginx 配置里加try_files回退到index.html,后面会写。
第三个坑是环境变量注入的时机。Vite 的import.meta.env.VITE_API_BASE是在构建时就写死进产物的,不是运行时。也就是说,你编译镜像时如果 API 地址写的是http://localhost:8080,那这个地址就永远固化在里面了,换个环境就得重新构建。想要运行时可变,得改成用window变量或者干脆让前端只发相对路径/api,由 Nginx 去代理。我强烈推荐后者,前端只认/api,所有环境差异交给 Nginx,一劳永逸。
4.2 多阶段 Dockerfile 实测写法
前端的 Dockerfile 我固定用多阶段:第一阶段用 Node 编译,第二阶段只把产物拷进 Nginx。这样最终镜像里没有 Node、没有源码、没有 node_modules,干净又小。
# 阶段一:构建 FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # 阶段二:运行 FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]注意几个细节。npm ci比npm install更适合 CI 和构建环境,它会严格按package-lock.json装,保证每次依赖版本一致。把COPY package*.json和COPY . .分开写,是为了利用缓存——只要依赖没变,npm ci这层就命中缓存,改业务代码不会重新装依赖,构建时间能砍掉一大半。
COPY --from=builder是从上一个阶段把dist目录搬过来,最终镜像里看不到任何源码。如果你用的是 Vue CLI,产物目录是dist一般没错;Vite 也是dist;个别老项目可能是build,改一下路径即可。
4.3 Nginx 配置:history 回退、反向代理、缓存与 gzip
这份nginx.conf是整套方案里我认为最值得反复打磨的文件,它同时承担静态托管和接口代理两个职责:
server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; # history 模式回退,解决刷新 404 location / { try_files $uri $uri/ /index.html; } # 静态资源长缓存 location ~* \.(js|css|png|jpg|svg|woff2)$ { expires 30d; add_header Cache-Control "public, immutable"; } # 接口代理到后端容器 location /api/ { proxy_pass http://api: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; } }逐条说。try_files $uri $uri/ /index.html是 history 模式的救命配置,请求的文件不存在就交给index.html,由前端路由自己处理。静态资源那段按扩展名匹配,给 JS、CSS 加 30 天强缓存,但前提是你的构建工具会给文件加 hash 指纹(Vite 和 Vue CLI 默认都会),否则缓存会导致用户看不到更新。
proxy_pass http://api:8080/这里有个极其容易配错的斜杠问题。proxy_pass结尾带斜杠,意思是把/api/这个前缀剥掉再转发;不带斜杠则原样转发。也就是说,如果前端请求/api/user/list,带斜杠时后端收到的是/user/list,不带斜杠后端收到的是/api/user/list。你的后端 Controller 映射路径是哪个,就得配哪个,配反了就是 404。这个斜杠我至少栽过三次。
5. Docker Compose 编排:一个命令把整套服务拉起来
有了两个镜像,最后一步是用 Compose 把它们串起来,让docker compose up -d一句话搞定全部。这一步的重点是网络、依赖和配置注入。
5.1 网络、依赖顺序与启动竞态
Compose 默认会创建一个 bridge 网络,同一个 compose 文件里的服务可以直接用服务名互访,这也是前面 Nginx 里能写http://api:8080的原因。你不用管容器 IP,Compose 帮你做了 DNS 解析。
依赖顺序靠depends_on,但要提醒一句:depends_on只保证容器"启动了",不保证里面的服务"就绪了"。比如你的后端要连 MySQL,depends_on只能保证 MySQL 容器先被拉起来,但 MySQL 初始化可能要十几秒,这期间后端连不上照样报错。正确的做法是配合healthcheck加condition: service_healthy,让 Compose 等数据库真正健康了再启动后端。
services: api: build: ./backend depends_on: mysql: condition: service_healthy environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai SPRING_DATASOURCE_USERNAME: root SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD} networks: - app_net web: build: ./frontend ports: - "80:80" depends_on: - api networks: - app_net mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} MYSQL_DATABASE: demo volumes: - mysql_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s retries: 5 networks: - app_net volumes: mysql_data: networks: app_net: driver: bridge5.2 数据卷、环境变量与配置注入
数据库这种有状态服务,一定要用 volume 持久化,否则docker compose down一执行,数据全没。上面mysql_data这个具名卷会把数据保存在宿主机上,删容器不删卷,重建后数据还在。要清库时才手动docker volume rm,危险操作,别手滑。
环境变量我放在同目录的.env文件里,比如DB_PASSWORD=xxxx,Compose 会自动读取。千万别把密码写进 docker-compose.yml 然后提交到 git,这属于经典事故。.env记得加进.gitignore。镜像里也一样,后端镜像本身不含任何密码,全靠运行时注入。
5.3 打包与启动的完整命令链
从零到跑起来,命令链大概是这样:
# 1. 打后端 JAR cd backend && mvn clean package -DskipTests && cd .. # 2. 前端不用手动构建,Dockerfile 里做了 # 3. 启动整套服务 docker compose up -d --build # 4. 看日志 docker compose logs -f api # 5. 下线 docker compose down--build强制重新构建镜像,改了代码后加这个参数。日常只是重启就用docker compose restart。想看某个服务状态用docker compose ps。这一套命令下来,前端、后端、数据库就全起来了,浏览器访问宿主机 80 端口即可。
6. 联调排错实录:那些让你怀疑人生的坑
前面讲的是顺利路径,但真实世界里第一次跑通前的排错过程才是精华。这一章我把最常见的问题和排查思路整理出来,你可以当成一个速查手册。
6.1 跨域、404 与代理路径错位
页面能打开但接口全红,八成是代理没生效或者路径错位。排查顺序是这样的:先看浏览器 Network 里请求的实际 URL 是什么,是打到了http://localhost/api/...还是打到了别的域名。如果是后者,说明前端打包时 API 地址写死了,回到 4.1 节改成相对路径。如果 URL 对了但返回 404,就进web容器里看 Nginx 的错误日志:
docker compose exec web cat /var/log/nginx/error.log如果日志里显示connect() failed (111: Connection refused),说明 Nginx 转发到api:8080连不上,重点查后端容器是否真的在监听 8080、是否在同一网络里。用docker compose exec web ping api能通就说明网络没问题。
跨域(CORS)这块,用 Nginx 反向代理之后其实是同源的,因为前端和接口都从同一个域名和端口进来,浏览器根本不会触发跨域检查。这也是我推荐代理方案而不是前端直连后端的原因之一。如果你还是遇到了跨域报错,多半是前端某个请求没走/api前缀,绕过了 Nginx 直连了后端,回头查那个请求的 URL。
6.2 Windows 上 Docker Desktop 起不来怎么办
我身边用 Windows 的朋友,十个有六个遇到过 Docker Desktop 启动失败,报错大意是"未检测到虚拟化支持"。这个问题跟项目本身无关,但会卡住你第一步。排查思路:先在 BIOS 里确认 CPU 虚拟化(VT-x / AMD-V)开着;然后在 Windows"启用或关闭功能"里确认 WSL2 或者 Hyper-V 已经启用;再检查是否装了和 Hyper-V 冲突的旧版虚拟机软件。用wsl --update更新一下 WSL 内核往往也能解决。顺带一提,如果你用的是新版 Docker Desktop,记得在设置里勾选"使用 WSL2 引擎",比老的 Hyper-V 模式快不少,文件挂载性能也好很多。
6.3 常见问题速查表
| 现象 | 可能原因 | 排查/解决 |
|---|---|---|
| 前端白屏,控制台资源 404 | base/publicPath 配错 | 改成部署路径,重新构建 |
| 刷新页面 404 | history 模式无回退 | Nginx 加try_files ... /index.html |
| 接口 404 | proxy_pass 斜杠问题 | 对齐后端映射路径,注意尾部斜杠 |
| 接口 502 | 后端容器未就绪或崩了 | 看docker compose logs api |
| 日志时间差 8 小时 | 容器时区为 UTC | 镜像设TZ=Asia/Shanghai |
| 容器莫名被 kill | 内存超限被 OOM | 限制堆内存MaxRAMPercentage |
docker stop很慢 | ENTRYPOINT 用了 shell 格式 | 改成 exec 数组格式 |
| 数据库数据丢了 | 没挂 volume | 加具名卷持久化 |
这张表我基本每次部署新项目都会对照一遍,能省掉大量重复排错的时间。
7. 上线之后还能怎么优化
跑通只是起点,真要长期维护,还有几件事值得做。镜像瘦身是第一件,后端用 JRE 不用 JDK,前端用 alpine 基础镜像,构建多阶段,一套下来能把镜像从 800MB 压到 200MB 以内,推拉速度天差地别。第二件是CI 集成,把docker build和docker compose up写进流水线,提交代码自动构建部署,人不用登服务器。
第三件是多架构镜像。如果你手里有 ARM 架构的服务器,或者在做国产化平台的适配,直接构建的 x86 镜像在上面跑不起来,需要用docker buildx构建linux/amd64和linux/arm64双平台镜像。命令大概是这样:
docker buildx build --platform linux/amd64,linux/arm64 -t yourname/demo:latest --push .第四件是日志与监控。容器日志默认存在宿主机上,越攒越多,记得在 Compose 里给每个服务配logging驱动限制单个日志文件大小和数量,不然磁盘迟早被撑爆。
最后分享一个我个人踩过最深的小坑:有次前端页面在容器里布局异常,本地却好好的,折腾半天才发现是 Nginx 把 CSS 当成了静态资源强缓存,而那次发版恰好没改文件名,用户拉到的还是旧样式。所以强缓存一定要配合文件 hash,构建工具开了 hash 之后再放心加 30 天过期。这类问题不看 Network 面板的响应头根本查不出来,多看一手原始响应,比瞎猜强太多。整套流程我用了两三年,从单机部署到多环境切换都很稳,你也照着搭一遍,基本就能把"环境不一致"这个老毛病彻底甩掉了。