你有没有遇到过这种场景本地开发好好的Spring Boot接口一调就通Vue页面刷新就出数据结果一到部署环节就鸡飞狗跳。要么是服务器上环境变量配得跟本地不一致要么是前端打包后的路由刷新404要么是后端一升级就得连代码带环境重新折腾一遍。我早年刚接触容器化的时候也是被这类问题磨掉不少耐心后来把整套前后端项目完整迁移到Docker上之后才真正体会到“一次构建到处运行”这句话的分量。这篇文章我就围绕“Docker部署Spring Boot Vue项目”这件事把从镜像构建、容器编排到Nginx反向代理、数据库容器化的整个流程梳理一遍。这不是一篇纯理论讲解更多是我在实际项目里反复踩坑之后沉淀下来的操作记录。适合谁看如果你已经能写Spring Boot接口、能用Vue搭页面但还没系统搞过Docker部署或者想在团队里把前后端交付流程统一起来这篇文章应该能帮你省不少时间。1. 整体部署方案的核心设计思路前后端分离项目的部署本质上要解决三个问题后端Java进程怎么跑、前端静态资源怎么托管、前后端之间的请求怎么打通。传统做法是在服务器上手动装JDK、装Nginx、上传jar包和dist目录环境一旦多了就容易“水土不服”。Docker的思路则是把这三件事全部固化到镜像和编排文件里服务器上只需要有Docker环境就行。我常用的拓扑是三个容器一个跑Spring Boot后端一个跑Nginx托管Vue构建产物一个跑MySQL存数据。Nginx除了托管静态文件还承担反向代理的角色把/api开头的请求转发给后端的容器。这相当于把Nginx放在前端和后端的中间层既解决了跨域问题又减少了前后端联调时的环境差异。选择Docker Compose而不是裸docker run主要原因是有依赖关系需要管理。后端要等数据库就绪前端又要等后端起来才能联调Compose可以通过depends_on控制启动顺序配合健康检查还能进一步保证服务可用性。另外所有配置集中在一个yml文件里新同事拉下来docker compose up -d就能跑起来不需要看一堆部署文档。1.1 为什么把Nginx放进容器而不是用宿主机有人会问前端资源能不能直接让Spring Boot以静态文件方式返回技术上可以但拆分出来更合理。Vue构建后的产物是纯静态文件用Nginx托管不仅性能更好还能充分利用它的gzip压缩和缓存策略。更关键的是前端路由如果是history模式刷新页面时Nginx需要做try_files回退到index.html这个配置写在容器内的Nginx配置里随镜像一起分发比在每台服务器上单独改配置要可控得多。后端镜像则直接基于JDK运行时把Spring Boot打好的jar包放进去。Spring Boot内嵌了Tomcat不需要单独跑一个Tomcat容器这样镜像更小线程模型也更简单。整个后端对外暴露的端口只有一个内嵌容器监听8080Docker再映射到宿主机。1.2 镜像分层与构建缓存的权衡Docker镜像的分层机制让我在构建时能利用缓存提升速度。后端Dockerfile里我先把依赖的jar包拷进去执行mvn dependency:go-offline类似的逻辑或者直接把pom.xml和源码分步COPY这样只要依赖没变后续构建就会命中缓存层不会每次全量拉取依赖。前端构建也有同样的套路先COPY package.json和package-lock.json执行npm ci或yarn install再COPY源码源码变更不会导致依赖重新安装。实际执行时的取舍是开发环境用多阶段构建生产环境则提前构建好产物再打镜像。多阶段构建的好处是最终镜像只包含运行所需的最小文件。比如前端阶段用Node镜像编译产出dist目录后再拷贝到Nginx镜像阶段最终镜像里没有node_modules体积小很多。2. 后端镜像制作与关键参数配置后端是整个系统的核心镜像制作相对直接但有几个细节如果不注意到了生产环境才会暴露问题。2.1 Dockerfile通用模板与逐行解析先给出一份我常用的后端Dockerfile模板# 基础镜像选用带JDK的Linux发行版 FROM openjdk:8-jre-alpine # 维护者信息与标签 LABEL maintainerdevopsexample.com # 设置时区容器默认是UTC差8小时很容易坑到日志和定时任务 RUN apk add --no-cache tzdata \ cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ echo Asia/Shanghai /etc/timezone \ apk del tzdata # 创建应用目录并切换到非root用户提升安全性 RUN addgroup -S app adduser -S app -G app RUN mkdir -p /app/logs chown -R app:app /app USER app # 将宿主机上构建好的jar包拷贝进镜像 COPY target/demo-0.0.1-SNAPSHOT.jar /app/app.jar # 暴露应用端口 EXPOSE 8080 # Java启动参数与容器内存限制配合 ENV JAVA_OPTS-XX:UnlockExperimentalVMOptions -XX:UseCGroupMemoryLimitForHeap ENTRYPOINT [sh, -c, java $JAVA_OPTS -jar /app/app.jar]这里有几个关键点。第一是时区我遇到过几次日志时间比北京时间慢8小时的情况排查到最后发现是容器默认UTC。在生产环境定时任务和日志分析都会受影响干脆在镜像构建时就固定下来。第二是JVM参数。容器里的Java进程默认能看到的CPU和内存数量是宿主机的不加限制容易导致堆内存超出容器配额。-XX:UseCGroupMemoryLimitForHeap让JVM感知到CGroup的内存限制避免无脑拿宿主机内存作为堆上限。Java 8 update 191以上版本这类参数行为有所调整建议根据基础镜像版本确认是否需要显式设置。第三是非root用户运行。容器内以root身份运行应用一旦应用被入侵攻击者直接就有容器内的最高权限。我切换到一个专用用户后即使jar包被破解也不能随便读写宿主机挂载的目录权限。2.2 Spring Boot配置如何适应容器环境Spring Boot的配置文件里数据库地址不能写死成localhost因为容器内的localhost是容器自己。在Compose编排下后端容器通过服务名访问MySQL容器所以配置要改成这样spring: datasource: url: jdbc:mysql://mysql-container:3306/demo_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai这种方式最大的好处是环境无关性。本地开发时用application-dev.yml连本地数据库生产Docker环境里则通过环境变量覆盖配置。Compose里用environment注入Spring Boot的占位符语法${DB_HOST:localhost}可以设置默认值既灵活又不会因为漏配环境变量直接启动失败。连接池参数也要留个心眼。容器内存有限HikariCP默认的最大连接数可能偏大在低配服务器上会拖垮数据库。我一般设置maximum-pool-size: 20具体值根据服务的QPS和数据库规格来定不要一味追求大。2.3 健康检查让Compose真正感知后端状态Spring Boot Actuator提供了健康检查端点Docker层面也能配合做探活。我通常在Compose里配置healthcheck命令长这样healthcheck: test: [CMD, wget, -q, --spider, http://localhost:8080/actuator/health] interval: 30s timeout: 10s retries: 3这里要确认镜像里有没有wget如果没有就换curl。healthcheck的意义不只是监控它还让depends_on的条件判断真正有效。否则Compose只保证容器启动了不保证应用就绪了。数据库容器起来到真正能接受连接往往有几十秒的初始化窗口靠睡眠等待纯属碰运气。3. 前端构建、镜像制作与Nginx配置前端环节的坑比后端更多主要集中在这几个地方构建阶段依赖版本不一致、SPA路由刷新404、静态资源缓存策略不合适、构建产物包过大。3.1 多阶段构建从Node到Nginx的精简方案前端Dockerfile我坚持用多阶段构建写出来是这个样子# 构建阶段 FROM node:16-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --registryhttps://registry.npmjs.org COPY . . RUN npm run build # 运行阶段 FROM nginx:1.25-alpine COPY --frombuild /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因为npm ci严格按照package-lock.json安装不会自动更新依赖版本。开发环境中可能某个传递依赖被升级了构建出来行为不一样等部署到生产环境再暴露排查成本很高。npm ci在CI/CD场景下更快也更可复现。依赖镜像选node:16-alpine还是node:16-slim我偏向alpine体积小很多但偶尔会遇到node-sass这类需要编译原生模块的库在alpine上缺musl工具链这种时候就得换slim镜像或额外安装构建工具。这类问题在基础镜像选择时就要预判。3.2 Nginx配置要点SPA回退与API代理下面这份nginx.conf是我在实际项目中精简出来的针对Vue的history路由做完适配server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xmlrss image/svgxml; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend-container: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; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { expires 7d; add_header Cache-Control public, immutable; } }try_files $uri $uri/ /index.html这行是history路由的核心。如果不加用户访问https://xxx.com/dashboard然后刷新Nginx找不到对应的物理文件直接返回404。加上这行后所有匹配不到的路由都会回退到index.html由前端路由接管。proxy_pass http://backend-container:8080这种写法要特别注意backend-container必须与Compose里后端服务名一致。在容器网络中服务名就是DNS名前端容器通过它访问后端容器链路清晰也不用维护IP映射。静态资源缓存我放到最后一段正则location里。js、css这类带hash的文件可以放心长缓存因为文件名变了自然请求新文件。这里用immutable表示文件内容不会变化浏览器可以直接用缓存连重新验证都不做。3.3 环境变量注入构建期与运行期的选择前端项目的API地址通常写在.env.production里但这样有个问题镜像一旦构建API地址就焊死在代码里。如果换了环境比如从测试环境迁移到生产环境就得重新构建镜像。更优的做法是让Nginx在容器启动时注入环境变量或者在前端运行时读取全局配置。我一般用nginx的envsubst模板能力。做法是在nginx.conf里写占位符location /api/ { proxy_pass http://${BACKEND_HOST}:8080; }在Nginx镜像启动前用envsubst替换变量envsubst ${BACKEND_HOST} /etc/nginx/conf.d/default.conf.template /etc/nginx/conf.d/default.conf这样同一个前端镜像在不同环境都能复用只是环境变量不同。不过实际项目中我更常把API请求设计成相对路径/api然后统一走Nginx代理前端代码不需要知道具体后端地址也就省去了这层动态替换。4. 数据库容器化与持久化方案很多后端项目示例里数据库直接用宿主机安装的MySQL容器只跑应用。但为了整套环境一致性和快速拉起把数据库也容器化是更彻底的做法。数据库容器化最需要重视的是数据持久化否则容器一删数据全没。4.1 MySQL容器与数据卷的规划Compose里MySQL服务的配置长这样mysql: image: mysql:8.0 container_name: mysql-container restart: always environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: demo_db MYSQL_USER: demo_user MYSQL_PASSWORD: demo_pass123 TZ: Asia/Shanghai ports: - 3306:3306 volumes: - mysql-data:/var/lib/mysql - ./sql/init:/docker-entrypoint-initdb.d command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_unicode_ciMYSQL_DATABASE、MYSQL_USER、MYSQL_PASSWORD这三个环境变量在首次启动时会自动创建数据库和用户。/docker-entrypoint-initdb.d目录下的SQL脚本会在数据库初始化时自动执行非常适合放表结构和初始数据。这个机制我经常拿来在测试环境自动建表省去手工导入的麻烦。冒号映射的端口暴露是为了方便开发环境用Navicat等工具直连。生产环境可以去掉这个端口映射让数据库只在内网网络中暴露减少攻击面。注意字符集参数。MySQL 8.0默认字符集已经不是latin1了但为了保险还是显式指定utf8mb4。emoji表情和生僻字在utf8mb4下才能正常存储以前用utf8时遇到过“插入成功但读出来是问号”的情况折腾半天才发现是字符集问题。4.2 数据卷的备份与恢复思路数据卷命名在Compose里默认是项目名_数据卷名全量备份时可以直接对数据卷做tar归档docker run --rm -v demo_mysql-data:/var/lib/mysql -v /backup:/backup alpine tar czf /backup/mysql-$(date %Y%m%d).tar.gz -C /var/lib/mysql .恢复则是把tar包解压回数据卷。这种方式简单直接适合中小项目。不过我建议生产环境还是启用MySQL自身的binlog或定期定时逻辑备份毕竟物理文件级备份的恢复粒度比较粗误删数据时可能找不回来到秒级。4.3 数据库容器内存与连接数限制数据库容器如果不对内存做限制MySQL会默认分配较大的缓冲池在小内存服务器上直接拖垮系统。我在Compose里给MySQL服务加了部署限制deploy: resources: limits: memory: 1G cpus: 1.0内存1G时MySQL的innodb_buffer_pool_size最好调整到512M左右。这个参数可以在command里或配置文件里设置。不设置的话MySQL 8.0默认的buffer pool可能占用很大比例的内存在容器里容易被OOM killer杀掉。MySQL初始化启动后会有持续一段时间的日志刷写和缓冲预热刚启动时不要立刻压测或者大量并发写入至少给它几十秒缓冲时间。5. Docker Compose编排与多容器协同Docker Compose是整个部署的“总指挥”一份yml文件把后端、前端、数据库全部串起来。这是从“手动敲一堆docker run命令”到“一条命令拉起环境”的关键一步。5.1 完整Compose编排服务依赖与启动顺序我的docker-compose.yml基础结构如下version: 3.8 services: mysql: image: mysql:8.0 container_name: mysql-container restart: always environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: demo_db volumes: - mysql-data:/var/lib/mysql networks: - app-network healthcheck: test: [CMD, mysqladmin, ping, -h, localhost, -uroot, -proot123456] interval: 10s timeout: 5s retries: 5 backend: build: ./backend image: demo-backend:latest container_name: backend-container restart: always depends_on: mysql: condition: service_healthy environment: DB_HOST: mysql DB_PORT: 3306 DB_NAME: demo_db DB_USER: root DB_PASSWORD: root123456 ports: - 8080:8080 networks: - app-network frontend: build: ./frontend image: demo-frontend:latest container_name: frontend-container restart: always depends_on: - backend ports: - 80:80 networks: - app-network volumes: mysql-data: networks: app-network: driver: bridgedepends_on配合condition: service_healthy是关键点。MySQL容器启动后并不能马上接受连接直接让后端紧跟启动大概率会遇到“Access denied”或者“Communications link failure”。用healthcheck的mysqladmin ping判断就绪再启动后端基本能做到稳扎稳打。容器网络这块三个服务都接在同一个自定义bridge网络上互相之间通过服务名互通。自定义网络比默认bridge网络多了一个好处自动DNS解析服务名就是域名服务重建IP变了也没关系。5.2 容器重启策略与日志管理restart: always是生产环境的底线策略。服务器重启后Docker守护进程会自动拉起这些容器省去手工操作。但要注意如果应用本身的启动依赖数据库且数据库还没就绪容器启动失败后Docker会不断重试这期间日志会刷得比较快。日志管理我建议在Compose里配一下logging: driver: json-file options: max-size: 10m max-file: 3默认情况下Docker会无限积累容器日志时间长了会占用大量磁盘。配置后单容器日志超过10M自动轮转最多保留3个文件。这个配置在服务器上部署时几乎是必须的否则半年不管磁盘真的会被日志塞满。5.3 环境隔离多环境Compose文件拆分开发环境、测试环境、生产环境的配置差别很大硬改一份Compose文件很痛苦。我通常拆成三份docker-compose.yml公共配置docker-compose.dev.yml开发环境覆盖docker-compose.prod.yml生产环境覆盖启动时叠加使用docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d后面文件里的配置会覆盖前面的同名项。比如开发环境映射数据库端口方便本地调试生产环境则不映射开发环境前端挂载源码目录热更新生产环境则直接使用构建镜像。这种拆分方式让不同环境之间逻辑清晰也不会出现改一个环境把另一个环境弄坏的情况。6. 部署实操过程中的常见问题与排查无论准备多充分部署时总会遇到各种状况。我把自己做过项目里反复出现的几类问题集中整理一下权当一份速查表。6.1 容器启动失败与退出状态排查如果容器启动后立刻退出先看日志docker logs -f backend-container日志能直接告诉我们80%的问题。其中常见的是端口被占用特别是宿主机上已经装了Nginx或MySQL时容器端口映射会冲突。Java进程内存超限也常见。Spring Boot默认堆内存会按物理内存比例分配容器限制小但JVM不感知时OOM会直接杀掉容器。这种情况往往观察日志看不到异常输出但docker inspect里的OOMKilled字段是true。解决办法就是设置JVM堆参数或者在Docker层面给内存限制前先给JVM留出余量。6.2 服务间无法通信与网络排查前端容器访问不了后端接口但后端接口在宿主机curl是通的。第一反应应该是看容器网络。前端容器里执行docker exec -it frontend-container sh curl http://backend-container:8080/actuator/health如果ping不通或连接超时检查一下两个容器是否在同一个网络。容器不在同一个自定义网络通过服务名访问就会失败。很多时候是Compose文件里某个服务忘了写networks字段或者手滑拼错了网络名。还有一种“假通”情况就是DNS解析成功但端口不对。比如后端容器内部监听的是8080但Nginx代理里写成了9090这种错误经常被网络因素掩盖排查时先在容器内确认服务监听端口。6.3 前端页面白屏与静态资源加载异常前端容器跑起来了访问首页却是白屏。按F12打开控制台通常有两种情况第一种是资源加载404。检查dist目录下资源路径是不是绝对路径。Vue CLI构建时如果publicPath配置的是/资源路径就是根路径部署在子目录下就会404。这种情况把publicPath改成./相对路径能解决但需要注意路由的base也要对应调整。第二种是JS渲染报错常见的是浏览器不支持某些ES6语法。检查browserslist配置确保生产构建的target足够兼容。还有一个容易被忽略的点是Nginx的default.conf覆盖配置不生效。Nginx镜像官方自带了一个默认的配置文件如果不挂载或者覆盖正确路径自定义配置就会失效此时页面能打开但API代理和路由回退都不对。确认挂载路径是/etc/nginx/conf.d/default.conf而不是nginx.conf。6.4 数据库连接失败与字符集乱码后端日志里报数据库连接失败先确认MySQL容器是否健康。生产环境下密码通过环境变量注入时要检查特殊字符是否被shell或Compose转义。密码里有$、这类字符时Compose里的写法要加引号或者在变量文件里做好转义。字符集乱码这个问题不仅要看MySQL的server字符集还要看表和字段的字符集。我曾经在一个老项目里把表的字符集设成了latin1MySQL全局怎么改都没用最后还是逐表转换才解决。所以初始化SQL脚本里建表语句也要显式写上DEFAULT CHARSETutf8mb4。6.5 镜像构建缓慢与缓存失效每次构建前端镜像都要重新npm install一遍下来几分钟就没了。检查Dockerfile中COPY的顺序。先COPYpackage.json执行安装依赖的命令再COPY其他源码这样依赖层就会被缓存源码变动不会触发重装依赖。后端构建同理用Maven构建镜像时尽量利用本地.m2仓库或者在CI机配套构建缓存机制。如果只是改了一个Java类却花几分钟重新拉全部依赖那一定是COPY顺序写得不合理。7. 上线之后的运维观察与收尾建议部署完成只是开始后面持续观察容器运行状态才是正经事。7.1 日常运维命令速查# 查看所有容器运行状态 docker compose ps # 查看后端实时日志 docker logs -f backend-container # 进入前端容器交互排查 docker exec -it frontend-container sh # 重启某个服务 docker compose restart backend # 重新构建并启动 docker compose up -d --builddocker compose up -d --build是上线发布最常用的命令它会比对镜像代码变了就重建配置变了就重新创建容器。发布流程如果再接上CI的自动化构建基本就解放人力了。7.2 基于实际项目的一点体会这套部署方案我在多个项目里复用从单机小应用到稍微复杂一点的微服务拆分底层思路都一致。容器化最大的收益不是“省了装环境的时间”而是让部署行为本身变得可重复、可审查。同一个镜像在测试环境验过到生产环境跑出来的行为一致这才是能睡安稳觉的关键。如果你刚开始接触这套东西建议不要一上来就追求复杂的Kubernetes编排先把Docker Compose玩明白。Compose覆盖单机多容器场景已经非常够用等确实遇到跨机扩容、滚动更新、服务发现的需求再往Kubernetes迁移也不迟。基础打牢了上层架构再怎么变核心的镜像构建、网络通信、数据持久化这些底层逻辑不会变。最后说一个容易被忽略的细节Docker相关的命令最好固化到一个部署脚本里比如deploy.sh中包含镜像构建、标签打版、Compose拉起的完整流程这样即使半年后再回来发布新版本照着脚本走一遍也不会遗漏步骤。脚本本身不复杂但记录了完整的操作路径和当时的决策逻辑这是文档之外最有价值的沉淀。