本地跑得好好的,一部署就翻车,这应该是很多人接触前后端分离项目后最头疼的事。尤其当项目涉及Spring Boot后端、Vue前端、外加MySQL数据库的时候,环境问题简直能把人逼疯:JDK版本不一致、Node版本对不上、数据库初始化脚本没跑、端口被占用……每一条都能让你在“部署”这一步卡上半天。后来我把这套组合整体迁到Docker上,用容器把后端、前端、数据库各自的运行环境固化下来,才算真正解决了“我这能跑、你那跑不起来”的老大难。这篇文章就围绕“Docker部署Spring Boot + Vue项目”这套组合,把我实际操作的完整过程、镜像构建思路、编排细节、以及几个坑的排查链路全部记录下来,适合那些已经把项目在本地跑通、正准备往服务器上部署的开发者参考。
我默认你已经了解Docker的基本命令(build、run、ps、logs这些),也知道Spring Boot和Vue项目的基本结构。如果你只是刚把环境装好,建议先花半小时把官方快速入门过一遍,再回来看这篇,会顺畅很多。下面我直接从整套方案的价值判断开始讲,然后是镜像构建、编排启动、生产细节,最后附上我真实踩过的一个排查案例。
1. 为什么推荐用Docker部署前后端分离项目:不只是“省事”这么简单
先说点实际的。以前部署Spring Boot + Vue项目,最典型的流程是这样的:服务器上手动装JDK(还得纠结是8还是11还是17)、装Maven或Gradle、装Node.js(版本还得和本地开发时一致)、装Nginx、装MySQL,然后手动打包、上传、改配置、重启。听起来每一步都不难,但合在一起就是灾难——尤其是当你需要维护多台服务器,或者隔了几个月要重新部署一次的时候,你甚至记不清当初到底装的哪个版本。
用Docker之后,整个思路就变了:你把运行环境直接“写”进镜像里,服务器上只需要有一个Docker引擎,剩下的全是复制粘贴。
我最初从传统部署切到Docker部署,核心动机有三个,都不是为了赶时髦:
- 环境一致性问题从根本上消除。本地开发用的JDK 17、Node 18、MySQL 8,镜像里是什么样,服务器上就是什么样。不会再出现“本地好好的,服务器上报ClassNotFoundException”这种问题。
- 部署动作变得可重复、可脚本化。传统部署里,你可能维护一份手写的部署文档,但实际操作时总有人为偏差——漏了一步、路径写错。Docker把整个部署过程固化成了Dockerfile和compose文件,版本管理进Git,任何人拉下来执行一遍就得到完全一样的结果。
- 迁移和扩容的成本极低。换服务器?把镜像导出或推到镜像仓库,新机器上拉下来启动就行。要横向扩展?后端容器多起几个副本,前面用负载均衡接着即可。
当然,这不代表Docker是万能的。如果你只有一个极简单的单体项目、服务器也就一台,那手写脚本部署也许更直接。但只要项目包含两个以上组件,或者你有“换机器重来”的预期,容器化的收益就非常明显了。
我这套方案最终的结构是这样拆的:
| 组件 | 容器化方式 | 对外暴露 |
|---|---|---|
| 后端(Spring Boot) | 独立镜像,多阶段构建 | 仅内网端口8080,不直接暴露给外部 |
| 前端(Vue) | 构建产物打入Nginx镜像 | 宿主机80端口,由Nginx统一接管 |
| 数据库(MySQL) | 官方镜像,数据目录挂载到宿主机 | 宿主机3306端口,供后端连接 |
这个拆分逻辑是:外部流量只经过Nginx(前端容器),后端API通过Docker内部网络访问,不直接暴露公网。这样在安全性和请求转发层面都更清晰。如果你没有独立的Nginx容器,也可以理解成前端镜像里的那个Nginx同时承担了静态文件服务和API反向代理两个角色。
2. 后端Spring Boot镜像构建:多阶段构建才是关键
2.1 基础镜像选型:不要盲目追求“小而美”
后端镜像的核心内容是:JDK运行环境 + 打好的Jar包。最简单粗暴的写法是直接用openjdk:17-jdk这种完整镜像,把Jar包COPY进去,然后java -jar启动。但这样做出来的镜像体积很大,而且运行时根本用不到编译器、调试工具这些内容。
更科学的做法是多阶段构建(multi-stage build):第一阶段用带Maven和JDK的镜像完成编译打包,第二阶段只拷贝Jar包到一个精简的运行时镜像里。我当前用的Dockerfile大致长这样:
# 第一阶段:编译打包 FROM maven:3.8-openjdk-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 第二阶段:运行 FROM eclipse-temurin:17-jre WORKDIR /app COPY --from=builder /build/target/demo-backend.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]这里有几个值得展开讲一下的点:
第一,mvn dependency:go-offline这一步很重要。它的作用是先把所有依赖下载到本地Maven仓库,后续真正的打包阶段就不会因为网络波动反复拉依赖了。配合Docker的层缓存机制,只要pom.xml没变,这一层缓存就一直有效,重新构建镜像时速度会快非常多。
第二,运行时基础镜像我选的是eclipse-temurin:17-jre而不是某一个固定的厂商镜像。Temurin是目前比较活跃的OpenJDK发行版,镜像长期维护,安全性有保障。选JRE版本而不是JDK版本,是因为我们运行阶段只要执行Java代码,不需要再编译任何东西,体积能省一部分。
第三,注意我用的是maven:3.8-openjdk-17作为构建阶段的镜像。如果你本地用的是Gradle,就换成对应的Gradle镜像,思路完全一致。
2.2 实际构建时最容易踩的坑:Jar包名字写死
这个坑我至少见过三个人踩过。Spring Boot的Maven插件默认生成的Jar包名通常是项目名-版本号.jar,比如demo-backend-0.0.1-SNAPSHOT.jar。如果你在Dockerfile里写死了这个文件名,等哪天版本号升级了,重新构建镜像时就会报“文件不存在”。
我个人的习惯是在pom.xml里显式指定finalName,让Jar包名固定:
<build> <finalName>demo-backend</finalName> </build>这样Dockerfile里始终写COPY --from=builder /build/target/demo-backend.jar app.jar,不管版本号怎么变,构建都不会断。这个小习惯能让你的Dockerfile更健壮,强烈建议养成本能反应。
2.3 后端镜像要不要集成配置文件?
很多人会把application-prod.yml打进镜像,但我更推荐的做法是:**配置文件通过环境变量或挂载目录注入,而不是打进镜像。**原因很简单:镜像应该是“一份代码打包后的产物”,而配置是“部署环境相关的内容”。你把配置打进镜像,就失去了在不同环境间复用同一个镜像的能力。
我在后面的编排章节里会详细讲怎么通过环境变量动态切换数据库地址、账号密码这些内容,这里先记住一个原则:尽量不要把任何和环境相关的东西写死进镜像。
3. 前端Vue项目的镜像打包思路:从构建产物到Nginx托管
3.1 为什么要用“构建阶段 + Nginx运行阶段”的结构
Vue项目在开发时是源码形态,有node_modules、有各种.vue文件,但它并不能直接被浏览器执行。你需要先经过构建(npm run build)生成dist目录,里面是一堆静态资源文件(HTML、JS、CSS、图片等)。这些静态文件需要一个Web服务器来托管,所以前端镜像的典型做法就是:
- 第一阶段:用Node镜像执行
npm install和npm run build,产出dist - 第二阶段:用Nginx镜像,把
dist目录拷贝进去,配置Nginx来处理静态资源和API代理
对应的Dockerfile也贴在下面:
# 第一阶段:构建Vue应用 FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm install --registry=https://registry.npmjs.org COPY . . RUN npm run build # 第二阶段:Nginx托管 FROM nginx:1.25-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;"]3.2 前端Nginx配置:不只是托管静态文件
这里有一个新手特别容易忽略的点:Vue项目如果用了Vue Router的历史模式(history模式),那么用户直接访问/detail/123这种地址时,Nginx会拿着这个路径去找对应的文件,结果当然是404。解决方式是用Nginx的try_files指令,把所有请求先尝试匹配静态文件,匹配不到就回退到入口页面index.html,交给前端路由去处理。
我的nginx.conf大概长这样:
server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; client_max_body_size 20M; # 前端路由回退 location / { try_files $uri $uri/ /index.html; } # API请求反向代理到后端容器 location /api/ { proxy_pass http://demo-backend: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; } }这里面的重点有两个。
第一个是try_files $uri $uri/ /index.html;这行。它的逻辑是:先按当前路径找真实文件,找不到就按目录找,再找不到就全部丢给index.html。这样前端路由在刷新页面时就不会白屏。
第二个是location /api/这一段反向代理。注意我代理的目标地址写的是http://demo-backend:8080,这里的demo-backend是后端容器在Docker内部网络里的主机名,不是localhost。如果你写成localhost,Nginx容器会尝试连接它自己,结果必然是连接失败。这个点在后续排查章节我会再带你走一遍完整的认知链路。
顺带说一下client_max_body_size这个配置。如果你的项目里有文件上传功能,默认的Nginx上传大小限制是1M,超过就会报413。我习惯在一开始就调大到20M左右,免得后面上线了被产品经理追着问“为什么文件传不上去”。
3.3 前端镜像构建的缓存策略
Vue项目构建慢,最大的瓶颈往往在npm install这一步,因为依赖包数量动辄几百个,而且网络不稳定的话会特别折磨人。Docker的层缓存在这里能帮上大忙:只要package.json和package-lock.json没有变化,npm install那一层就不会重新执行。
这也是为什么我强烈建议你把package*.json单独COPY进来先执行安装,再COPY源码。一开始偷懒把所有文件一次性COPY进去,等于每次改一行代码都要重新npm install,构建一次十分钟,脾气再好也会被磨没。
如果项目用的是pnpm或yarn,思路完全一致,把包管理命令替换成对应的就行。
4. 编排启动:docker-compose如何让三套组件协同工作
4.1 网络和依赖关系是编排的核心
前后端和数据三个容器不能各跑各的,它们需要彼此通信,而且要保证启动顺序合理——后端启动时需要连接数据库,如果数据库还没就绪,后端会反复重试或直接退出。这些需求用单纯的docker run命令去管理会很痛苦,所以使用docker-compose.yml做统一编排是我推荐的方案。
我的compose配置大体如下:
version: "3.8" services: db: image: mysql:8.0 container_name: demo-db restart: always environment: MYSQL_ROOT_PASSWORD: demo_root_pwd MYSQL_DATABASE: demo_db MYSQL_USER: demo_user MYSQL_PASSWORD: demo_pwd ports: - "3306:3306" volumes: - ./mysql-data:/var/lib/mysql - ./init:/docker-entrypoint-initdb.d command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci backend: build: ./backend container_name: demo-backend restart: always depends_on: db: condition: service_healthy environment: SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/demo_db?useSSL=false&serverTimezone=Asia/Shanghai SPRING_DATASOURCE_USERNAME: demo_user SPRING_DATASOURCE_PASSWORD: demo_pwd ports: - "8080:8080" frontend: build: ./frontend container_name: demo-frontend restart: always depends_on: - backend ports: - "80:80"4.2 数据库的初始化脚本放在哪里
MySQL官方镜像有个很贴心的机制:首次启动时,它会自动执行/docker-entrypoint-initdb.d目录下的所有.sql脚本,按文件名顺序执行。这意味着你不需要手动进入容器执行初始化SQL,把建表语句、初始数据脚本放到宿主机目录./init下并挂载进去,数据库容器第一次启动时就会自动完成初始化。
但有一个必须强调的点:这个机制只在数据目录为空(也就是首次初始化)时生效。如果你的mysql-data目录已经有数据了,再怎么改init里的脚本都不会重新执行。你需要手动进入容器执行,或者把数据目录清空后再重新初始化。我在实际项目里就吃过这个亏,改了初始化脚本,重启无数次都没效果,排查了半天才发现是数据卷里的老数据在“作祟”。
4.3depends_on和健康检查:别让顺序变成竞态
depends_on控制的是容器的启动顺序,但它不保证依赖的服务已经“可用”。比如depends_on: [db]只能保证db容器先启动,不代表MySQL已经完成初始化、可以接受连接了。如果你的后端一旦连不上数据库就退出,那么在db还没就绪时,后端会直接启动失败。
这里有两个解决思路。
第一个思路是给后端容器加restart策略,比如restart: always,配合后端侧代码里的重连机制,MySQL没起来就反复重启后端,直到能连上。这个方案简单粗暴,但说实话体验不好,日志里会刷一堆连接失败的错误。
第二个思路更优雅:给db服务配置健康检查,然后让后端依赖“健康状态”。上面配置里的condition: service_healthy就是这个意思。配合在db里加一段健康检查:
healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-udemo_user", "-pdemo_pwd"] interval: 5s timeout: 3s retries: 12这样compose会先启动db,然后持续执行健康检查,直到MySQL真正能接受连接后才启动backend。顺序问题从“猜”变成了“确定”,明显更可靠。
4.4 宿主机端口冲突:一个很隐蔽的坑
你还记得我上面把数据库的3306端口映射到了宿主机对吧?如果你的服务器上本身也装了MySQL,这个映射会直接失败,报端口占用。所以上线前一定要确认:宿主机上有没有其他进程占用了你要映射的端口。
另外还有一个更微妙的问题:你映射了3306到宿主机,如果服务器有公网IP且安全组放行了3306,相当于你的数据库直接暴露在公网上了。这非常危险。我的建议是,如果不需要在宿主机上直接连数据库(比如你只在容器内部连),就把ports那一段去掉或者只映射到127.0.0.1,写法是:
ports: - "127.0.0.1:3306:3306"这样只有本机可以访问,外部网络完全碰不到,安全性提升一个档次。
5. 生产环境不能忽略的几件事:数据卷、日志和健康检查
5.1 数据卷必须挂载:否则容器一删数据就没了
容器是“有状态”的克星。MySQL里的数据如果不挂载到宿主机,一旦容器被删除(比如升级镜像、重启Docker服务),数据就跟着容器一起消失了。这是Docker部署里最闻名的“删库跑路”事故源泉,但很多人第一次部署时根本不会意识到。
挂载的方式我上面已经写在compose里了:./mysql-data:/var/lib/mysql。这里我建议用相对路径(相对于compose文件所在目录)来挂载,因为用绝对路径的话,换一台服务器就要改配置,可迁移性差。如果用的是云服务器,最好同时考虑把数据目录放到独立的数据盘上,避免系统盘出现问题导致数据丢失。
5.2 容器日志的增长问题:别再让日志吃满磁盘
Docker默认会把容器的标准输出和标准错误都收进日志文件,如果你不管它,日志文件会一直增长。一个流量稍大的后端,几天时间日志就能吃掉几个GB的磁盘空间。而磁盘满了之后,最直接的表现就是容器写文件失败、服务无响应。
我在生产环境里一般会在compose里对日志做滚动限制:
logging: driver: "json-file" options: max-size: "20m" max-file: "5"意思就是单个日志文件最多20MB,最多保留5个文件,超过就自动轮转删除最旧的。如果项目需要做集中式日志分析(比如扩容到多副本的场景),可以引入EFK这类方案,但那是后话了,单机部署阶段先把磁盘安全保住。
5.3 给每个服务加健康检查:监控的最后一公里
编排文件里给db配置了健康检查,但生产环境下,我更建议给后端和前端也加上。后端的健康检查可以直接利用Spring Boot Actuator的/actuator/health端点:
healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] interval: 10s timeout: 5s retries: 3注意一个问题:eclipse-temurin:17-jre这个镜像里默认没有curl。你需要在Dockerfile里先安装它:
RUN apt-get update && apt-get install -y curl \ && rm -rf /var/lib/apt/lists/*或者用wget替代,取决于你已经装了什么工具。健康检查的意义在于:它不仅能让docker ps里显示容器是否健康,更重要的是能被其它依赖它的服务感知状态变化,也能被外部的监控系统定期探活。
6. 一次真实踩坑:容器能启动但前端“死活”调不通后端,完整排查链路
6.1 现象描述:看似正常,实则全断
有次我把这套环境搭好后,用docker compose up -d一次性启动所有容器,三个容器都显示Up状态。本地访问前端页面也能打开,但点击登录按钮后,接口请求全部超时。打开浏览器F12,Network面板里所有/api/...请求都是net::ERR_CONNECTION_REFUSED。
6.2 排查第一步:先确认后端容器内的服务是否正常
我首先执行:
docker exec -it demo-backend bash curl http://localhost:8080/actuator/health结果返回了{"status":"UP"},说明后端进程本身是健康的。这一下就排除了后端自身故障,问题大概率出在“前端请求后端”这条链路上。
6.3 排查第二步:确认Nginx配置是否生效
我进入前端容器检查配置:
docker exec -it demo-frontend bash cat /etc/nginx/conf.d/default.conf发现try_files和/api/代理配置都在。这时我开始怀疑是不是Nginx配置压根没被加载,于是重新执行了nginx -t,提示配置语法正常。问题依然存在,说明问题不在这里。
6.4 排查第三步:验证容器间网络通信,问题浮出水面
我改用curl从前端容器内部直接请求后端:
docker exec -it demo-frontend curl http://demo-backend:8080/actuator/health结果也是一样,超时。到这一步基本可以确定:两个容器之间根本不通。
接着我查看网络情况,发现一个被我忽略的事实——两个容器不在同一个自定义网络里。原因是我最开始是分两次用docker run启动的,后来才迁移到compose,但旧的容器还在使用默认网络,或者网络配置有冲突。compose默认会为项目创建一个独立的网络,所有在compose里定义的服务都会加入这个网络,但如果你手动创建过容器,或者有其它历史容器,网络隔离问题就会很隐蔽地出现。
解决办法是在compose里显式声明自定义网络,并让所有服务使用同一个网络:
networks: app-network: driver: bridge services: db: ... networks: - app-network backend: ... networks: - app-network frontend: ... networks: - app-network重新执行:
docker compose down docker compose up -d再从前端容器内部curl后端,通了。
6.5 排查第四步:问题重新出现,这次指向Nginx配置解析
网络修好后,我再次刷新页面,发现请求不再报连接拒绝,但所有API返回502 Bad Gateway。这说明Nginx确实把请求转发到了后端,但后端没有正确响应。
我又开始怀疑是不是demo-backend这个主机名解析不到,于是从前端容器里ping了一下:
docker exec -it demo-frontend ping demo-backend结果能ping通。那问题就只剩一个:Nginx的proxy_pass写法。我当时的配置是:
location /api/ { proxy_pass http://demo-backend:8080/; }注意这个坑在于proxy_passURL末尾的斜杠。当时我写的代理地址带上了/,Nginx在转发时会去掉匹配前缀/api/,然后把剩下的路径拼到代理地址后面。也就是说请求/api/user/login会变成http://demo-backend:8080/user/login,而后端Controller的路由一般还是挂在/api下,自然就匹配不到路由了。
两种写法的区别我整理成了一张表,方便你对照:
| proxy_pass写法 | 代理结果(请求 /api/user/login) | 是否保留/api前缀 |
|---|---|---|
http://demo-backend:8080 | /api/user/login | 保留 |
http://demo-backend:8080/ | /user/login | 去掉 |
我改回不带斜杠的写法,重新加载Nginx配置,请求立刻恢复正常。
6.6 这次踩坑留下的三条经验
第一条,容器状态Up不等于服务已经通了。排查前后端联调问题,永远先走“容器内curl”这条链路,从后端自身健康、容器间连通性、Nginx转发规则三个层面逐个验证,比直接刷新页面看浏览器报错要高效得多。
第二条,Docker网络永远用自定义网络,不要靠默认行为。如果你有过手动docker run和compose混用的历史,网络配置很容易变得难以追踪。显式声明网络不仅让容器间通信可预期,也让整个配置表意更清晰。
第三条,Nginx的proxy_pass写法差异是高频坑。转发时带不带结尾斜杠,行为截然不同。如果你发现前端页面能打开但接口全是502或404,优先检查这里。
7. 让部署过程更顺滑的几条实战建议
到这一步,一套能跑的Docker化前后端分离项目已经成型了。我再补几个实际操作中总结出来的习惯,未必所有人都会提到,但确实让我的部署体验提升了不少。
第一条:给镜像加版本标签,不要永远用latest。正式部署时,我习惯打上日期或构建号,比如demo-backend:20250601,这样镜像有变更时,可以随时回滚到上一个版本。latest在开发方便,但在生产环境里它会让“回滚”变得不可控。
第二条:把.env文件纳入.gitignore,但保留.env.example。compose里的数据库密码、环境变量这些敏感信息,不要直接写死在docker-compose.yml里提交到代码仓库。Docker Compose会默认读取同目录下的.env文件并自动替换变量,所以可以把配置改成${MYSQL_ROOT_PASSWORD}这种形式,然后维护一个不含真实密码的.env.example模板供团队成员参考,真实环境各自填写各自的.env。
第三条:每次变更部署前,先本地用同样的镜像跑一遍。我见过很多人在服务器上直接改配置、直接重新build,一旦失败,排错成本极高。先在本地或测试环境用同一套compose文件拉起来验证一遍,确认没问题再推到服务器,能省掉一大半故障时间。
第四条:尽量保持Dockerfile和compose文件的简洁。有些项目喜欢在Dockerfile里堆很多层操作,每多一个RUN就会多出一层镜像、多一分缓存失效的风险。能用一条命令完成的事情,尽量合并成一条。同时把容易变化的指令(比如COPY源码)放在后面,把不容易变化的指令(比如安装依赖)放在前面,充分发挥层缓存。
根据我个人的实践体验,Docker部署这套Spring Boot + Vue项目,最大的价值不在于“不用装环境”这一个点,而在于它把整个部署过程从“一门手艺”变成了“一份可复现的配置”。团队新成员拿到仓库,只要装了Docker,一条命令就能把整个项目跑起来,不再需要“求教老员工部署步骤”这种环节。不同的项目结构可能还需要你在Nginx配置、容器网络、数据备份上做更多调整,但基本框架和排查思路是通用的。希望这篇能帮你少走几步弯路,把更多精力留到业务本身。