Docker部署Spring Boot+Vue前后端分离项目的完整指南

📅 发布时间:2026/10/10 9:38:49
Docker部署Spring Boot+Vue前后端分离项目的完整指南
上两周刚把一个前后端分离的项目完整搬到 Docker 上Spring Boot 做后端接口Vue 做管理端页面从本地开发环境到服务器一键部署整个过程折腾了差不多三天。这中间踩了不少坑有的坑网上资料说得含糊有的坑是版本更新之后才出现的趁着记忆还热乎我把整套思路和操作细节整理出来需要的可以直接照着抄。1. 部署前需要理清的设计思路1.1 为什么把项目拆成三个容器而不是直接扔进一台机器很多人第一次用 Docker 部署前后端项目容易陷入一个误区把所有东西塞进一个容器里或者干脆只把 Jar 包放进去前端文件还是手动拷贝到 Nginx 里。这样做短期能跑但后续维护会发现很难受——前端改一行代码要重新登服务器替换静态文件后端升级版本还要担心影响前端环境。我用的方案是拆成三个容器后端容器跑 Java 服务前端容器用 Nginx 承载 Vue 打包后的静态文件再加上 MySQL 数据库容器。三者的关系是前端通过 HTTP 请求访问后端接口后端连接数据库读写数据对外只暴露前端和数据库的端口。这样做的好处有几个。第一是环境隔离Java 的 JDK 版本、Nginx 的配置、MySQL 的数据存储互不干扰不会出现“升级 Node 导致 Nginx 配置不见了”这种破事。第二是迁移方便整套环境写在 docker-compose 文件里换一台服务器只需要装好 Docker一条命令就能拉起全部服务。第三是回滚容易镜像就是存档版本出问题直接把镜像 tag 切回上一个验证过的历史版本随时能用。1.2 项目目录结构与镜像规划在写 Dockerfile 之前我先规划了目录结构建议你也先做这一步别急着写配置。project-root/ ├── backend/ # Spring Boot 项目 │ ├── Dockerfile │ └── target/xxx.jar ├── frontend/ # Vue 项目 │ ├── Dockerfile │ ├── nginx.conf │ ├── dist/ # npm run build 产物 │ └── src/ └── docker-compose.yml后端和前端各自维护一套 Dockerfile最外层根据数据库配置额外添加 MySQL 服务。需要注意一点Jar 文件是通过 Maven 构建出来的前端 dist 是通过 npm 构建出来的但我在写镜像的时候用了多阶段构建也就是直接在 Dockerfile 里完成编译打包不用先在宿主机上装一遍 Maven 和 Node。后面的 Dockerfile 部分我会展开细说。1.3 部署前的自检清单动手写配置之前先在本地或者测试环境确认这几件事Spring Boot 项目是否用了 Maven 或 Gradle 构建能在命令行里完整打包生成可执行 Jar。Vue 项目执行npm run build能正常产出 dist 目录并且开发环境下的 API 请求地址是相对路径或者可以通过环境变量覆盖。后端数据库连接信息没有硬编码在代码里而是通过环境变量读取方便容器启动时传入。确认服务器的端口规划前端端口、后端端口、数据库端口不能和已有服务冲突。前两项是打包的基础后两项是容器化改造的重头戏。如果你的项目目前是直接写死数据库地址的建议先改成配置项容器里再通过 Environment 传入。2. 后端容器化Spring Boot 的 Dockerfile 编写2.1 基础镜像选择别只用 openjdk 不带版本Spring Boot 的 Dockerfile 第一行就是选择基础镜像这一步很容易翻车。很多人习惯写FROM openjdk:latest图省事。但在生产环境里latest是个坑——它指向的 JDK 版本可能跟你本地开发环境不一致导致编译出的 class 文件无法运行。我现在用的是结合了 Maven 和 Java 运行时的多阶段方案。第一阶段用带 Maven 的镜像编译打包第二阶段用精简的 JRE 镜像运行。这样最终镜像体积小里面也不含编译工具安全性和启动速度都好得多。一个实际例子我的后端项目基于 Java 17 和 Spring Boot 3.xDockerfile 长这样# 第一阶段编译打包 FROM maven:3.9.4-eclipse-temurin-17 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 第二阶段运行 FROM eclipse-temurin:17-jre WORKDIR /app COPY --frombuilder /app/target/*.jar app.jar EXPOSE 8080 ENV TZAsia/Shanghai ENTRYPOINT [java, -jar, app.jar]2.2 多阶段构建解决了什么问题多阶段构建最直接的价值是控制镜像大小。如果只用单个 Maven 镜像跑完所有步骤最终镜像里会残留 maven 仓库、源码、编译工具链体积轻则几百 MB重则一个多 G。而上面的写法里最终运行的镜像只包含 JRE 和打好的 Jar体积通常在 200 MB 左右。还有一个隐藏好处是依赖下载缓存。mvn dependency:go-offline会在构建阶段先拉取所有依赖存放到镜像层里。只要 pom.xml 没变这一层的缓存就不会失效后续构建几分钟内就能完成。2.3 构建与运行细节时区、参数、健康检查Spring Boot 项目打包好之后运行阶段有几个经常被忽略的细节。时区问题。如果不设置环境变量容器的默认时区是 UTC和国内的时间差八个小时。数据库里存的时间和日志时间都会错乱。我在 Dockerfile 里加了ENV TZAsia/Shanghai但更稳妥的办法是在启动时挂载宿主机时区文件或者直接依赖基础镜像的 tzdata 包。上面的写法在常用基础镜像里实测没问题如果你的镜像里没有 tzdata需要先apt-get install -y tzdata。JVM 参数。容器环境下 JVM 的默认堆内存行为可能会让进程吃满宿主机内存。建议在启动命令里显式限制内存ENTRYPOINT [java, -Xms256m, -Xmx512m, -jar, app.jar]如果服务器内存紧张这个配置能避免容器耗尽宿主机资源导致 Docker 守护进程被拉死。当然具体的堆内存大小要根据你项目的实际负载来调。还有一个容易被忽略的点是健康检查。Spring Boot Actuator 自带health端点可以在 Dockerfile 里配置 HEALTHCHECKHEALTHCHECK --interval30s --timeout3s --retries3 \ CMD curl -fs http://localhost:8080/actuator/health || exit 1但要注意基础镜像里不一定带 curl有些精简 JRE 镜像里没有。这时候需要在构建阶段把 curl 装进去或者直接用 Java 写一个简单检查。我一般选择在基础镜像里补装 curl代价是体积略增但可排查性和可运维性都上来了。2.4 构建镜像的命令与测试写好后端 Dockerfile执行构建docker build -t backend:1.0.0 ./backend构建完成后先不急着编排用单独试跑的模式验证容器能不能正常启动docker run -d --name backend-test -p 8080:8080 backend:1.0.0然后检查容器日志是否出现了 Spring Boot 的启动成功标志再访问一下http://localhost:8080/actuator/health确认状态 UP。这一步在开发机上验证完后面编排时心里就踏实了。3. 前端容器化Vue 项目如何构建镜像3.1 前端构建的两种思路对比前端和纯后端服务不太一样Vue 项目本身没有运行时的服务进程它是一堆静态文件通过 Nginx 对外提供 HTTP 服务。构建思路无非两种。一种是先在宿主机上执行npm run build把 dist 目录拷进镜像里。这种方式适合本机已经装了 Node但换到其他环境时不够通用。另一种是我推荐的方式用 Node 镜像在构建阶段执行打包然后只把 dist 产物拷贝到 Nginx 镜像里。这样宿主机完全不需要安装 Node任何一台装了 Docker 的机器都能完成构建。前端 Dockerfile 如下# 第一阶段构建 Vue 静态资源 FROM node:18-alpine AS builder WORKDIR /app COPY package.json . RUN npm install COPY . . RUN npm run build # 第二阶段运行 Nginx FROM nginx:1.24-alpine COPY --frombuilder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 803.2 前端路由 history 模式与 Nginx 配置这地方的坑是我认为整个项目里最容易踩的得单独说。Vue Router 如果用的是createWebHistory模式页面地址长这样http://xxx.com/system/user不带#。这种模式下当你直接访问一个子路由比如刷新页面或者直接输入 URL浏览器会向服务器请求/system/user这个路径但服务器上根本没有这个文件Nginx 会返回 404。解决方法是给 Nginx 配置try_files让所有找不到的路径都回退到index.html由前端路由接管接下来的路由解析。server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里location /api/是反向代理规则。前端代码里请求接口的地址写成/api/...Nginx 接收到这个路径后会转发给后端容器。proxy_pass http://backend:8080/api/;里的backend是 docker-compose 里的服务名容器网络内可以直接通过服务名访问后面编排部分会解释为什么这么写。3.3 运行时环境变量处理别把打包当唯一途径如果你的前端代码里有VUE_APP_API_BASE_URL之类的环境变量并且这个变量在开发环境和测试环境指向不同的接口地址那你需要注意npm run build的过程会把环境变量编译进 JavaScript 文件里。换句话说一旦构建完成镜像里的接口地址就固定了无法通过容器运行时的环境变量覆盖。这种“构建时注入”的方式在开发环境没问题但到了部署阶段就很麻烦。比如同一个镜像要同时部署到测试环境和生产环境测试环境的接口是http://test-api.xxx.com生产环境是http://api.xxx.com你就必须分别构建两个镜像。我用的方案是运行时动态注入。在 Nginx 镜像的启动阶段执行一个脚本把环境变量写到/usr/share/nginx/html/config.js里然后在 HTML 里引入这个文件前端代码从window.SITE_CONFIG.apiBaseUrl读取接口地址。这样同一个镜像通过传入不同的环境变量就能适配不同环境。具体做法是加一个 entrypoint.sh#!/bin/sh cat /usr/share/nginx/html/config.js EOF window.SITE_CONFIG { apiBaseUrl: ${API_BASE_URL} } EOF nginx -g daemon off;在 Dockerfile 里替换 Nginx 默认启动命令COPY entrypoint.sh /entrypoint.sh RUN chmod x /entrypoint.sh ENTRYPOINT [/entrypoint.sh]前端代码里这样读取const apiBaseUrl window.SITE_CONFIG?.apiBaseUrl || /api/;这样构建出的镜像可以一套打天下每次部署只是改环境变量不用重新构建。对于需要频繁发布测试版和正式版的情况这个方案能省不少时间。4. 使用 docker-compose 一键编排整套服务4.1 docker-compose 文件的基础结构前端和后端镜像都构建好了接下来要把数据库也拉进来一起受 docker-compose 的编排管理。先放一个实际能用的 docker-compose.ymlversion: 3.8 services: mysql: image: mysql:8.0 container_name: project-mysql restart: always environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: project_db MYSQL_USER: appuser MYSQL_PASSWORD: app123456 volumes: - ./mysql-data:/var/lib/mysql ports: - 3306:3306 networks: - app-net backend: build: ./backend image: backend:1.0.0 container_name: project-backend restart: always depends_on: - mysql environment: SPRING_PROFILES_ACTIVE: docker DB_HOST: mysql DB_PORT: 3306 DB_NAME: project_db DB_USER: appuser DB_PASSWORD: app123456 ports: - 8080:8080 networks: - app-net frontend: build: ./frontend image: frontend:1.0.0 container_name: project-frontend restart: always depends_on: - backend environment: API_BASE_URL: /api/ ports: - 80:80 networks: - app-net networks: app-net: driver: bridge4.2 服务名与容器网络的关键作用docker-compose 会给每个服务创建一个可通过服务名访问的网络别名。比如 backend 容器在 app-net 网络里它的主机名就是backend。所以前端 Nginx 配置里写proxy_pass http://backend:8080/api/;就能直接访问到后端服务不需要关心 IP 地址。这一点在生产环境特别重要。容器每次启动分配到的 IP 是动态的直接写死 IP 会在重启后失效。通过服务名通讯容器怎么重启、IP 怎么变化都不用修改配置。depends_on控制启动顺序。需要注意它只保证先启动数据库容器不代表数据库已经初始化完成。实际部署中我遇到过一个情况MySQL 还在初始化时Spring Boot 已经启动连接数据库失败导致整个启动流程崩溃。这个问题有两种处理方式。一是给后端容器加restart: alwaysMySQL 初始化完成后后端会自动重启连接简单粗暴但有效。二是写一个等待脚本在容器启动前循环检查数据库端口是否就绪。两种我都试过等待脚本更可控但脚本本身的健壮性也要测试所以我一般先用restart: always等系统平稳运行后再考虑要不要加脚本。4.3 数据库数据持久化容器能删数据不能丢MySQL 容器有一个大坑如果容器被删除而数据没有挂载到宿主机那么整个数据库就没了。docker-compose 里我用了一个数据卷挂载volumes: - ./mysql-data:/var/lib/mysql这样 MySQL 的数据文件会存储到宿主机项目的mysql-data目录下。即使执行docker-compose down把容器都删掉下次重新启动docker-compose up -d时数据仍然在。对于生产环境这个挂载目录最好放到独立的数据盘或者使用 Docker Volume 卷。4.4 镜像管理给镜像打 tag 并推送到仓库本地构建的镜像只存在于当前机器如果要把项目部署到远程服务器有几种方式。最简单的做法是直接在服务器上放源码并执行docker-compose up -d --build让服务器自己构建。这种方式适合测试环境但每次发布都要把源码传上去项目大时很耗时。更规范的做法是构建完成后把镜像推送到镜像仓库服务器只负责拉取。构建时打好标签docker tag backend:1.0.0 registry.example.com/project/backend:1.0.0 docker push registry.example.com/project/backend:1.0.0在服务器上执行docker pull registry.example.com/project/backend:1.0.0 docker-compose up -d这在多人协作或需要多台服务器部署时尤其重要。镜像仓库就是存档每一个版本都是可回滚的中转点比每次临时打 tar 包靠谱得多。4.5 常用编排命令速查这里整理几个我每次部署都会用到的命令# 构建镜像并后台启动全部服务 docker-compose up -d --build # 查看服务状态 docker-compose ps # 查看某个服务的日志 docker-compose logs -f backend # 重新启动某个服务 docker-compose restart frontend # 停止并移除容器不会删数据卷 docker-compose down # 彻底停止并移除容器、网络和匿名卷 docker-compose down -vdown -v要谨慎使用它会删除挂载到容器匿名卷的数据。我因为手误执行过一次差点把数据库测试数据清空好在那只是开发环境。生产环境务必确保所有数据都挂载到了宿主机路径或者命名卷否则千万别加-v。5. 踩坑记录与排查技巧实录5.1 镜像拉取超时与构建慢的问题使用默认源拉取基础镜像时常会遇到超时或速度极慢这个问题在国内环境尤其明显。解决办法是配置镜像加速地址在 Docker 守护进程的配置文件中加入 registry-mirrors 配置。如果你所在的网络环境无法直接访问公共仓库也可以在构建阶段配置镜像源。前端 Node 镜像的npm install速度慢时可以把 npm 源切到国内镜像在 Dockerfile 里加一行RUN npm config set registry https://registry.npmmirror.example.comMaven 同理在 settings.xml 或构建命令里指定镜像地址。这一步能有效缩短构建时间尤其是首次构建需要拉取大量依赖时差距非常明显。5.2 容器里访问不到宿主机服务有一种场景是后端需要连接数据库但数据库不在容器里而在宿主机上。这时候 Spring Boot 配置里如果写localhost:3306连接的一定是容器自身的 3306 端口肯定连不上宿主机。解决方法是把数据库主机地址写成宿主机在 Docker 网络里的 IP。Docker 的 bridge 网络模式下宿主机地址通常是172.17.0.1。更推荐的做法是使用host.docker.internal这个特殊域名它在 Linux 上需要额外参数支持在 Docker Desktop for Mac/Windows 上是内置的。我实际验证下来最省心的方案还是把所有依赖服务都纳入 docker-compose 统一管理这样直接用服务名即可完成互通不需要关心 IP 和网络模式。5.3 前端刷新页面返回 404刚才在 Nginx 配置里提过这个问题但在实际部署时还是会有人漏掉。如果你已经配了try_files $uri $uri/ /index.html;但仍然 404排查几个点。第一确认 Nginx 的root路径是否正确。如果镜像里静态文件在/usr/share/nginx/html而配置里写成/var/www/html那所有文件都会 404。第二确认配置生效了。有的基础镜像已经在/etc/nginx/nginx.conf或/etc/nginx/conf.d/default.conf里有一套默认配置你新拷贝的配置可能被覆盖或冲突。执行docker exec进入容器查看当前生效的配置内容逐行确认。第三检查防火墙或安全组规则。如果是在云服务器上部署80 端口需要放行否则外部访问不了。5.4 后端容器启动失败反复重启这类问题一般集中在依赖资源和资源耗尽两个方面。数据库连接不上的原因可能是数据库服务还没就绪或者连接地址写错。JVM 内存设置过大但容器可用内存不足则会导致启动崩溃。使用docker-compose logs查看日志是最直接的排查途径日志里会明确显示启动失败的原因。有一次我遇到 Spring Boot 不断重启循环日志里没有特别明显的报错最后发现是 healthcheck 配置的 curl 命令在容器里不存在健康检查永远失败而触发重启机制。精简的基础镜像往往缺少工具排查问题前先确认基础镜像包含哪些命令避免把排查工具本身做成新的故障源。5.5 常用排查命令速查表目标命令查看容器状态docker ps -a跟踪日志docker logs -f 容器名进入容器docker exec -it 容器名 /bin/sh查看网络docker network inspect app-net查看资源占用docker stats查看挂载docker inspect 容器名查看Volumes和Mounts字段清理悬空镜像docker image prune5.6 镜像体积优化经验部署完成后我还会看一遍镜像体积。Spring Boot 的多阶段构建已经让后端镜像控制在 250 MB 以内但 Nginx 镜像如果配置不当也可能变大。最大的体积杀手是把源码 copy 到运行镜像里。有些人是直接把整个前端源码目录COPY . .到 Nginx 里这样镜像里全是 .vue 文件和 node_modules最后体积奔着 1 GB 去了。正确做法是只拷贝dist目录。其次是一个隐藏体积杀手如果你用单阶段构建Jar 文件可能包含一堆冗余依赖。Spring Boot 3.x 自带 Spring Boot Maven Plugin 的分层工具可以把 Jar 包里的依赖模块、应用模块拆开分层配合 Docker 构建缓存让依赖层只在 pom 变更时重新打包。6. 我的自动化部署经验整套方案稳定运行之后我加了一点点自动化脚本让发布流程更顺手。先是在项目里写了一个简单的部署脚本deploy.sh#!/bin/bash set -e echo 构建后端镜像 docker build -t backend:1.0.0 ./backend echo 构建前端镜像 docker build -t frontend:1.0.0 ./frontend echo 启动服务 docker-compose up -d echo 清理悬空镜像 docker image prune -f在服务器上执行这个脚本它会自动完成从源码到服务启动的全过程。如果代码有内容更新只需要在服务器上拉取最新代码git pull然后重新执行脚本前端的 npm 构建、后端的 Maven 打包都会在容器内完成宿主机完全不需要安装 Java 和 Node 环境。这种方式对于中小型项目来说性价比已经很高了。后续如果团队规模变大、发布频率变高可以考虑接入更完善的自动化流水线但基础原理都是一样的仍然是构建镜像、推送仓库、服务器拉取启动这三个环节。我这里分享的只是常规做法不同项目细节会有些出入。比如如果你的前端没有用 Vue Router 的 history 模式Nginx 的配置就不需要try_files回退如果 Spring Boot 项目还在用 Java 8基础镜像的标签要对应改成temurin-8。部署本身不是难点难的是把每个环节的依赖关系理清楚然后选择合适的编排方式。按上面这一套流程走下来前后端项目从一个无从下手的“一堆本地进程”变成一个完整的、可迁移的容器集合整个开发体验会顺畅很多。