开头我就直接说结论:用Docker把这套Spring Boot + Vue项目容器化跑起来,说难不难,但踩过的坑绝对不少。尤其当你从“开发环境能跑”跨越到“一台干净服务器上一条命令拉起来”,中间涉及的镜像构建、网络打通、配置注入、优雅停机这些问题,每一步都有细节。我这边某个模拟项目X就是这么一步步从单机手动部署切到Docker Compose编排的,整个过程的经验沉淀下来,写成这篇东西分享给你。本文适合正在做前后端分离项目、准备引入容器化部署的同学,也适合Docker刚入门、想找一份完整可落地参考的人。
1. 先把部署架构想清楚:三个容器如何协同
很多人一上来就写Dockerfile,结果文件写好了、镜像build出来了,docker run却起不来,或者起来了但前端访问不到后端接口。根子在于架构没先理清。Docker部署Spring Boot + Vue,本质上要解决三个问题:后端服务怎么打包成镜像,前端静态资源怎么伺候,两个服务之间以及它们与数据库之间怎么通消息。
1.1 项目拆分与容器规划
我这边的模拟项目X是典型的前后端分离结构:后端Spring Boot提供RESTful接口,端口8080;前端Vue开发完打包后是一堆静态文件,需要Nginx托管,同时Nginx承担反向代理,把/api开头的请求转发给后端。数据库用的MySQL 8.0,数据目录必须持久化。
基于这个结构,容器规划就很清晰了:
| 容器角色 | 镜像来源 | 内部端口 | 与外部的关系 |
|---|---|---|---|
| springboot-app | 自建,多阶段构建 | 8080 | 仅内网,由Nginx代理访问 |
| nginx-web | 自建,多阶段构建 | 80 | 映射到宿主机的8081,对外唯一入口 |
| mysql-db | Docker Hub官方mysql:8.0 | 3306 | 映射到宿主机3306,供本地运维用 |
关键设计思路是:对外只暴露Nginx一个口子,后端不直接暴露给外部。这样安全策略收敛了,外部请求全部走Nginx的80端口,再由它决定是返回前端静态页面还是代理给后端接口。很多初学者会把Spring Boot的8080也直接映射出去,图省事,但其实会增加暴露面和配置复杂度,不建议这么做。
1.2 网络模式选择:桥接网络比links好用
我当时规划网络时也纠结过,Docker Compose项目会自动创建默认网络,服务之间通过服务名互相访问。后来发现直接用Compose默认的桥接网络就足够了,服务名就是天然的主机名,Spring Boot容器里配置数据库地址直接写mysql-db而不是localhost,Nginx配置里代理上游直接写springboot-app:8080。
这里要刻意提醒一下:Docker容器里没有localhost共享的概念。每个容器有自己独立的网络命名空间,localhost指的是容器自己,你如果在Spring Boot容器里访问localhost:3306,那是在找容器自己,必然失败。跨容器访问必须走网络别名,也就是服务名。刚开始用Docker的人经常栽在这里,查了半天代码也查不出问题。
2. 后端镜像构建:Dockerfile里的瘦身思路与启动细节
Spring Boot后端镜像可能是整个环节里最容易出问题的地方,但也是最容易优化出彩的地方。我从最初的单阶段构建到后来改成多阶段构建,镜像体积从500多MB直接降到180MB左右,部署和传输速度明显改善。
2.1 多阶段构建:编译容器与运行容器分离
后端的构建流程很简单:先拉一个带Maven和JDK的镜像把源码编译成jar包,再拉一个只带JRE的镜像把jar包塞进去运行。两个阶段分开,最终镜像里没有编译工具链,体积自然小。下面是我这边的Dockerfile,踩过几个坑之后稳定使用的版本:
# 第一阶段:编译 FROM maven:3.8-openjdk-11 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests -B # 第二阶段:运行 FROM openjdk:11-jdk-slim WORKDIR /app ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone COPY --from=builder /build/target/app.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app/app.jar"]这里有个看起来不起眼但很重要的操作:用RUN mvn dependency:go-offline -B先把依赖下载好,生成镜像构建缓存。这样之后改代码重新构建,只要pom没变,Maven依赖层就会命中缓存,省掉每次重复下载依赖的时间。实测下来,单纯改一个Java类再build镜像,整个过程能控制在20秒左右,不卡在Maven下载上。
JRE镜像我选jdk-slim而不是alpine版本,原因在于alpine上的glibc和某些Java原生库会有兼容问题,尤其项目里用了JNA或者需要访问系统时间库时,容易出现诡异报错。slim系列基于Debian,包管理成熟、兼容性好,虽然体积比alpine大一点,但换来的是省心,值。
2.2 JVM参数与容器资源限制的适配
容器里跑Java有个特殊问题:JVM默认的堆内存大小是根据宿主机物理内存计算的。如果不加限制,一台64G内存的机器上,容器内的JVM可能把堆开到物理内存的1/4甚至更多,而Docker容器本身可能只配额了1G。结果就是容器OOM被杀,或者机器上多个容器互相抢占内存。
我这边给JVM显式设置了堆参数,同时开启容器感知特性:
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-Xmx512m", "-Xms256m", "-jar", "/app/app.jar"]在实际服务器的docker-compose里,我还加了mem_limit资源限制,让Docker和JVM两层设置匹配。经验是:-XX:MaxRAMPercentage适合JVM版本较新、想自动适应容器内存的场景;-Xmx512m则适合明确知道业务峰值需要多少堆的场景。我最终两者都用了,以-Xmx为硬顶,MaxRAMPercentage兜底。如果你的服务是定时任务类、内存访问不密集,堆给256m就能跑;如果是高并发接口型,建议先压测再定。
另外在JVM参数里我加了-Djava.security.egd=file:/dev/./urandom。很多人忽略这个参数,Spring Boot启动时如果/dev/random熵源不足,会出现启动慢到几十秒甚至卡住的现象。换成urandom后启动时间明显稳定。
2.3 时区问题:两行命令搞定
容器默认时区是UTC,而业务日志、定时任务都需要北京时间。第一次我没处理时区,结果凌晨的定时任务差8小时执行,排查了很久才意识到是容器时区问题。解决办法就在Dockerfile里加两行:
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone第一行设置环境变量,第二行把系统时区文件链接过去。注意Debian系镜像可能没装tzdata,如果执行报错,可以在前面加一句RUN apt-get update && apt-get install -y tzdata。这是我实际遇到的情况,openjdk:11-jdk-slim里tzdata默认就有,但如果换更激进的基础镜像,就要留意。
3. 前端镜像构建:Nginx托管与API反向代理的细节
前端这一侧,难点不再是构建,而是怎么让Nginx既能托管SPA的静态资源,又能把API请求转发到后端容器,同时处理好路由的history模式。
3.1 多阶段构建:从Node到Nginx
前端的Dockerfile思路和后端一致,分两个阶段:
# 第一阶段:构建前端静态文件 FROM node:16-alpine AS build WORKDIR /web COPY package*.json ./ RUN npm ci --registry=https://registry.npmmirror.com COPY . . RUN npm run build # 第二阶段:Nginx托管 FROM nginx:1.25-alpine WORKDIR /usr/share/nginx/html RUN rm -rf ./* COPY --from=build /web/dist . COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]npm ci和npm install的区别值得说一句。npm ci是严格按package-lock.json安装依赖,速度更快,且不会改动lock文件,在CI/Docker构建场景下是更正确的选择。以前我用npm install,时不时出现依赖版本漂移导致的构建结果不一致,换成npm ci后就没再出现。
前端构建阶段我在网络源上走了点弯路,npm默认源在某些网络环境下特别慢,后来在Dockerfile里临时指定了npmmirror源,构建速度提升显著。但要注意,这只适用于构建阶段,不会污染项目的npm配置。
3.2 SPA路由history模式与try_files
Vue默认的hash路由不涉及服务端配置,但大多数正规项目都会改成history模式,去掉URL里的#。问题来了:history模式下,用户直接访问/login、/dashboard这类具体路径时,Nginx找不到对应的物理文件,会返回404。
解决办法是Nginx配置里加try_files指令:
location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }这段配置的含义是:先尝试按请求的路径找文件,找不到就尝试目录,还找不到就统一返回index.html,由前端路由接管。初学者容易漏掉这行,前端自己访问时都从根路径进没问题,一部署上线,刷新子页面就白屏或404。这个坑我帮同事排过好几次。
3.3 API反向代理的几个避坑点
Nginx里把/api前缀的请求转发给后端Spring Boot,常规写法是:
location /api/ { proxy_pass http://springboot-app: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; }这里最容易犯的错是proxy_pass后面有没有斜杠,行为会完全不同。带斜杠的http://springboot-app:8080/会把/api前缀剥掉再转发,也就是前端请求/api/user/list,后端收到的是/user/list;不带斜杠则保持原路径,后端收到/api/user/list。两种方式都能用,关键是前后端约定要一致。我这边是后端接口统一加/api前缀,前端代理走proxy_pass http://springboot-app:8080;(不带斜杠),保持全路径传递,后端Controller也能正常匹配。
另外,proxy_set_header这几行别删。Spring Boot里如果用了request.getRemoteAddr()做IP白名单或访问日志,拿不到真实客户端IP会全是Nginx容器IP。X-Forwarded-For和X-Real-IP就是防止这个问题。
3.4 单页应用缓存策略
静态资源部署后,最尴尬的场景是用户浏览器缓存了旧版本的JS文件,界面还是老的。解决办法是在构建时给JS/CSS文件名加内容哈希。Vue CLI和Vite默认都会做这件事,但Nginx的缓存响应头也得配合:
location /assets/ { expires 30d; add_header Cache-Control "public, immutable"; } location / { expires -1; add_header Cache-Control "no-cache"; }前者告诉浏览器带哈希的静态资源可以长期缓存,后者保证index.html每次重新校验。这套配置能让发版后用户无需强刷就能看到新界面,是我在部署迭代中被逼出来的方案。
4. docker-compose编排:数据库、依赖顺序与数据持久化
镜像准备好之后,容器编排是关键。我用的是Docker Compose,它比一个个docker run命令强太多了:一条docker-compose up -d重启整套环境,依赖顺序自动处理,网络和卷统一管理。
4.1 compose文件的核心配置
我这边生产环境的compose文件结构大致如下:
version: "3.8" services: mysql-db: image: mysql:8.0 container_name: projectx-mysql restart: always environment: MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}" MYSQL_DATABASE: projectx MYSQL_USER: projectx_user MYSQL_PASSWORD: "${MYSQL_USER_PASSWORD}" TZ: Asia/Shanghai command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci - --default-time-zone=+08:00 volumes: - mysql-data:/var/lib/mysql - ./sql/init:/docker-entrypoint-initdb.d healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] interval: 10s timeout: 5s retries: 5 ports: - "3306:3306" springboot-app: build: context: ./backend container_name: projectx-api restart: always depends_on: mysql-db: condition: service_healthy environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql-db DB_PORT: 3306 DB_NAME: projectx DB_USER: projectx_user DB_PASSWORD: "${MYSQL_USER_PASSWORD}" TZ: Asia/Shanghai ports: - "8080:8080" nginx-web: build: context: ./frontend container_name: projectx-web restart: always depends_on: - springboot-app ports: - "8081:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro volumes: mysql-data:MySQL的command里我显式指定了字符集和时区。utf8mb4比utf8强的点是能存四字节的Emoji字符,现代应用最好直接选utf8mb4,不然用户昵称里带个表情符号就会写入报错。这个坑我当年踩过,后来所有新建库都默认这个配置。
4.2 depends_on只解决启动顺序,不解决可用性
Compose的depends_on看起来控制了服务启动顺序,但它的默认行为只是想:先启动MySQL容器,再启动Spring Boot容器。问题在于MySQL容器“启动成功”不等于“可接受连接”,MySQL容器起来了,但初始化还在进行,Spring Boot这时候去连数据库,连接被拒,启动失败。
解决方式是健康检查配合depends_on新语法:
depends_on: mysql-db: condition: service_healthy这样Compose会等MySQL通过健康检查(mysqladmin ping可以成功返回)才启动Spring Boot。这里注意condition: service_healthy是Compose较新版本才支持的写法。如果你的Compose版本不支持这种格式,另一个替代方案是在Spring Boot启动命令里加--spring.datasource.initialization-mode=never,或者在代码里配置数据库连接重试,但都不如健康检查这种方式干净。我在服务器上升级了Docker Compose插件后,这个功能稳定可用。
4.3 环境变量管理:不要硬编码敏感信息
看compose文件你会发现,密码都是${变量名}的形式,而不是写死。我是用一个.env文件在同级目录存放真实值,.env不提交到代码仓库,加入.gitignore。这算是最朴素的密钥管理方案,但好在简单有效。如果环境要求更高,可以接入专门的密钥管理服务,但在大部分项目场景下,.env配合好权限控制就够用了。
Spring Boot端的数据源配置我用环境变量注入:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql-db:3306/projectx?useUnicode=true&characterEncoding=utf8 SPRING_DATASOURCE_USERNAME: projectx_user SPRING_DATASOURCE_PASSWORD: "${MYSQL_USER_PASSWORD}"application.yml里写:
spring: datasource: url: ${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} password: ${SPRING_DATASOURCE_PASSWORD}这样开发和生产的数据库配置彻底分离,代码仓库里永远不会出现真实密码。
4.4 数据卷持久化:最容易被忽略的致命细节
MySQL容器一旦删除重建,如果不做数据持久化,库里的数据就全没了。这个后果在早期阶段不明显,等上线有真实用户数据后就是灾难级事故了。Compose的volumes段里我声明了mysql-data这个命名卷,挂载到MySQL的数据目录:
volumes: mysql-data:匿名卷或命名卷之间我优先选命名卷,因为升级镜像时不会意外丢失数据,备份和迁移也方便,docker volume inspect就能看到挂载点位置。后端生成的日志文件、上传的图片文件如果也是写在容器本地,同样要做数据卷映射,不然容器重建后数据归零。
5. 部署上线流程:构建、迁移、更新与回滚
镜像和编排文件都准备完毕后,实际部署这条链路里还有几个经常翻车的环节:服务器上有没有正确的Docker环境、构建产物怎么传过去、出问题如何快速回滚。我把这几件事按顺序梳理一遍。
5.1 服务器环境检查与准备
在一台干净的Linux服务器上,第一步不是拉代码,而是确认Docker环境:
docker version docker-compose version然后我把项目的后端目录、前端目录、compose文件等结构统一放到一个固定目录,比如/opt/projectx/,所有操作都在这个目录下进行:
/opt/projectx/ ├── docker-compose.yml ├── .env ├── backend/ │ ├── Dockerfile │ └── src/ └── frontend/ ├── Dockerfile ├── nginx.conf └── src/目录结构调整好之后,第一次启动的完整命令序列是:
cd /opt/projectx docker-compose up -d --build mysql-db docker-compose up -d --build docker-compose ps这里建议第一步先把MySQL单独启动起来,等它完成初始化并健康后再一次性拉起所有。特别是全新环境首次建库时,MySQL初始化需要跑初始化脚本,如果和其他服务同时启动,Spring Boot大概率会因为连不上库而启动失败,虽然restart: always会帮它重试,但日志里一堆红色报错,容易让人误判出问题。
5.2 镜像构建策略:用Compose的build还是预构建镜像
Compose的build直接指定context,每次up -d --build都会重新构建镜像。这种方式适合代码直接部署在服务器上的项目。我工作时遇到过场景是代码仓库和服务器分离,甚至在服务器上不拉源码,只拉镜像。这时建议把构建好的镜像推到镜像仓库,服务器上直接用image: myregistry.com/projectx-api:版本号,发布流程变成改版本号、拉镜像、重启容器。
对于小团队或者单机部署场景,直接用Compose的context构建更省心。但对于多环境部署,镜像仓库方案是迟早要走的路。我这边单机场景,直接代码目录构建,改动简单、链路短,目前够用。
5.3 更新与回滚操作
日常发布新版本,我的操作流程是:
cd /opt/projectx # 拉取新代码 git pull # 重新构建受影响的镜像并重启 docker-compose up -d --build springboot-app nginx-web # 查看滚动日志确认启动正常 docker-compose logs --tail=200 springboot-app如果新版本启动后发现问题,需要回滚。Docker Compose原生没有一键回滚命令,我的做法很朴素:在关键的发布节点给当前镜像打标签,比如projectx-api:stable,回滚时把镜像标签指回去:
docker tag projectx-api:latest projectx-api:rollback-2025xxx docker-compose up -d --no-build springboot-app如果改动涉及compose文件,可以用git revert先把配置文件回退,再重新up。