
开头我就直接说结论用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-dbDocker Hub官方mysql:8.03306映射到宿主机3306供本地运维用关键设计思路是对外只暴露Nginx一个口子后端不直接暴露给外部。这样安全策略收敛了外部请求全部走Nginx的80端口再由它决定是返回前端静态页面还是代理给后端接口。很多初学者会把Spring Boot的8080也直接映射出去图省事但其实会增加暴露面和配置复杂度不建议这么做。1.2 网络模式选择桥接网络比links好用我当时规划网络时也纠结过Docker Compose项目会自动创建默认网络服务之间通过服务名互相访问。后来发现直接用Compose默认的桥接网络就足够了服务名就是天然的主机名Spring Boot容器里配置数据库地址直接写mysql-db而不是localhostNginx配置里代理上游直接写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 TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone COPY --frombuilder /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:MaxRAMPercentage75.0, -Xmx512m, -Xms256m, -jar, /app/app.jar]在实际服务器的docker-compose里我还加了mem_limit资源限制让Docker和JVM两层设置匹配。经验是-XX:MaxRAMPercentage适合JVM版本较新、想自动适应容器内存的场景-Xmx512m则适合明确知道业务峰值需要多少堆的场景。我最终两者都用了以-Xmx为硬顶MaxRAMPercentage兜底。如果你的服务是定时任务类、内存访问不密集堆给256m就能跑如果是高并发接口型建议先压测再定。另外在JVM参数里我加了-Djava.security.egdfile:/dev/./urandom。很多人忽略这个参数Spring Boot启动时如果/dev/random熵源不足会出现启动慢到几十秒甚至卡住的现象。换成urandom后启动时间明显稳定。2.3 时区问题两行命令搞定容器默认时区是UTC而业务日志、定时任务都需要北京时间。第一次我没处理时区结果凌晨的定时任务差8小时执行排查了很久才意识到是容器时区问题。解决办法就在Dockerfile里加两行ENV TZAsia/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 --registryhttps://registry.npmmirror.com COPY . . RUN npm run build # 第二阶段Nginx托管 FROM nginx:1.25-alpine WORKDIR /usr/share/nginx/html RUN rm -rf ./* COPY --frombuild /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_filesVue默认的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-serverutf8mb4 - --collation-serverutf8mb4_unicode_ci - --default-time-zone08: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-modenever或者在代码里配置数据库连接重试但都不如健康检查这种方式干净。我在服务器上升级了Docker Compose插件后这个功能稳定可用。4.3 环境变量管理不要硬编码敏感信息看compose文件你会发现密码都是${变量名}的形式而不是写死。我是用一个.env文件在同级目录存放真实值.env不提交到代码仓库加入.gitignore。这算是最朴素的密钥管理方案但好在简单有效。如果环境要求更高可以接入专门的密钥管理服务但在大部分项目场景下.env配合好权限控制就够用了。Spring Boot端的数据源配置我用环境变量注入SPRING_DATASOURCE_URL: jdbc:mysql://mysql-db:3306/projectx?useUnicodetruecharacterEncodingutf8 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 --tail200 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。