接手一个前后端分离项目时,最头疼的往往不是写业务代码,而是把它干净利落地部署到服务器上。环境不一致、依赖缺失、版本冲突,这些老问题反复折腾人。而把 Docker 引入 Spring Boot + Vue 的技术栈之后,整个交付链条变得简单直接:镜像打包的是什么,线上跑的就是什么,环境的差异被彻底抹平了。这篇内容会顺着完整部署流程走一遍,从镜像拆分、容器编排一直到数据持久化和线上排障,把 Docker 部署这个环节真正打通。
1. 为什么这个组合值得用 Docker 编排
先说一个很基础的判断:Spring Boot + Vue 本身不依赖 Docker 也能部署,传统方式无非是后端打成 jar 包配 systemd,前端构建完扔给 Nginx。但这么做有个隐患——两台服务器之间哪怕操作系统小版本不同,都可能让同一个脚本跑出不一样的结果。而 Docker 最核心的价值不是“虚拟化”,是标准化。镜像一旦构建完成,里面包含的运行时、依赖、配置就全部固化下来了,在任何装有 Docker 的机器上跑出来的行为是完全一致的。
前后端分离架构下用 Docker 还有一层额外收益:可以将前端 Nginx、后端 Java 服务、数据库 MySQL 等组件用docker-compose.yml联动编排。以前需要分别部署、分别维护进程、手工处理网络连通性,现在一个文件就能把整套应用的主从关系、网络、端口映射、数据卷一次性定义清楚。真正需要写 Dockerfile 和 Compose 文件的场景往往是多环境交付,比如测试服务器一套、生产服务器一套,而 Docker 保证了环境迁移的成本几乎为零。
部署时建议先明确服务器上的资源规划。常见的分配方案如下:
| 资源项 | 最低推荐配置 | 说明 |
|---|---|---|
| CPU | 2 核 | 后端服务与镜像构建需要一定计算能力 |
| 内存 | 4 GB | Spring Boot 应用与 MySQL 常驻内存较大 |
| 磁盘 | 40 GB | 镜像文件和数据卷会占用不少空间 |
| 操作系统 | Linux(如某主流发行版) | Docker 对 Linux 支持最完整 |
镜像本身也很需要考虑大小,特别是公网带宽有限、推送频繁的场景。基础镜像、依赖层、应用层都要做控制,后文会专门展开。整体思路就是后端一个容器、前端一个容器、数据库一个容器,用 Compose 定义服务关系,真正做到一套配置到处运行。
2. 拆解部署的核心难点:Dockerfile 的设计思路
2.1 前端镜像:多阶段构建的经典用法
前端部分的核心诉求是“构建时依赖 Node,运行时只需要 Nginx”。最直接的做法就是FROM node装完依赖构建后再FROM nginx把产物拷进去,这条路径就是多阶段构建。它的巧妙之处在于最终镜像只包含第二阶段的内容,第一阶段的所有临时文件在镜像里完全不存在。
以前端为例,一个典型的 Dockerfile 长这样:
# 第一阶段:构建 Vue 项目 FROM node:18-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --registry=https://registry.npm.taobao.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;"]npm ci和npm install的区别值得单独说一句。npm ci是严格依照package-lock.json安装,速度更快且不会偶然升级依赖的次要版本,这让构建更可复现。而把package.json和package-lock.json单独先复制,再复制源码,是借用了 Docker 的层缓存机制:依赖文件没变时,RUN npm ci这一层不会被重新执行,构建效率提升非常明显。
别忘了 Nginx 配置这一环。Vue 项目通常是 history 路由模式,如果 Nginx 没做 fallback,直接访问除首页外的页面路径会白屏。在nginx.conf里需要补上:
location / { try_files $uri $uri/ /index.html; }2.2 后端镜像:分层缓存与瘦身手法
Spring Boot 项目在 Docker 化时,一个常见问题是镜像体积会莫名膨胀。以 openjdk 基础镜像直接打出来的包动辄 700MB 以上。瘦身的核心策略有两个:一是换更小的基础镜像,二是利用 Spring Boot 的 fat jar 特性做分层提取。
推荐直接使用eclipse-temurin作为基础镜像,这是社区广泛使用且持续维护的 JRE 发行版。配合 JRE 版本而非 JDK 版本,能再砍掉一部分体积:
FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]这一版很简洁但还能优化。Spring Boot fat jar 内部包含BOOT-INF/lib(依赖库)、BOOT-INF/classes(编译产物)、org/springframework/boot/loader(启动器)等部分,如果把整个 jar 当作一层,只要代码有改动,整个 jar 层都会被重新推送。更好的方案是先解压 jar 再分层拷贝,让依赖层稳定复用:
FROM eclipse-temurin:17-jre-alpine AS builder WORKDIR /app COPY target/*.jar app.jar RUN java -Djarmode=tools -jar app.jar --extract FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY --from=builder /app/app/dependencies/ ./ COPY --from=builder /app/app/spring-boot-loader/ ./ COPY --from=builder /app/app/snapshot-dependencies/ ./ COPY --from=builder /app/app/application/ ./ ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]--extract是较新版本 Spring Boot Maven 插件提供的工具能力,把 fat jar 拆开后每一层的变更频率被分开:依赖层基本不变,应用层才是频繁变动的部分。这样推送镜像时只用传输应用层增量,体验差距很明显。
后端还有一个 Java 自身的老话题——时区。默认时区是 UTC,如果不处理,数据库里存的时间和本地区域对不上,排查起来很难受。在 Dockerfile 里加上 JVM 参数就行:
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone2.3 数据库镜像:数据安全是底线
数据库容器化最容易被轻视,因为很多人觉得 MySQL 装进容器就是docker run mysql这么简单。但容器是被设计成无状态的,一旦容器删除,数据默认跟着丢。解决这个问题一定要用 volume,把 MySQL 的数据目录挂载到宿主机持久路径。
docker-compose.yml里 MySQL 服务可以这么定义:
mysql: image: mysql:8.0 restart: always environment: MYSQL_ROOT_PASSWORD: root_pass MYSQL_DATABASE: app_db MYSQL_USER: app_user MYSQL_PASSWORD: app_password command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci volumes: - mysql_data:/var/lib/mysql ports: - "3306:3306"utf8mb4是 MySQL 8 时代必须显式确认的选项,否则 emoji 等四字节字符存不进去,报错时很多人看不懂。另外务必将mysql_data声明为命名卷,Compose 会自动创建并管理。这个做法意味着即使容器删了重建,数据目录还挂在磁盘上,不会造成重大事故。
初始化数据库结构也有一个妥帖的方案。可以在宿主机准备一个init.sql,通过 volume 挂载到/docker-entrypoint-initdb.d/目录,MySQL 容器首次启动时会自动执行该目录下的脚本。这对不在代码里内置 Flyway 或 Liquibase 的项目特别方便:
volumes: - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql但要注意:该目录只在数据目录为空(首次初始化)时生效。如果数据卷里已经有数据,后续修改 init.sql 不会触发执行。这个细节若不清楚,很容易在校验线上效果时产生困惑。
3. 项目级 docker-compose 编排:把前端、后端、数据库串起来
3.1 Compose 文件的核心结构与网络互通
Docker Compose 是整个部署方案的编排中枢。比起单容器docker run,Compose 最大的优势是可声明、可复用、可版本管理。部署时可以像描述账单一样描述整个应用拓扑:前端服务叫什么、后端服务依赖谁、数据库暴露哪个端口。
一份完整的docker-compose.yml结构如下:
version: "3.8" services: backend: build: ./backend container_name: app_backend restart: always environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql DB_PORT: 3306 DB_NAME: app_db DB_USERNAME: app_user DB_PASSWORD: app_password depends_on: - mysql ports: - "8080:8080" frontend: build: ./frontend container_name: app_frontend restart: always depends_on: - backend ports: - "80:80" mysql: image: mysql:8.0 container_name: app_mysql restart: always environment: MYSQL_ROOT_PASSWORD: root_pass MYSQL_DATABASE: app_db MYSQL_USER: app_user MYSQL_PASSWORD: app_password volumes: - mysql_data:/var/lib/mysql ports: - "3306:3306" volumes: mysql_data:这里的核心知识点是 Compose 默认会创建一个专属 bridge 网络,服务之间可以用服务名(如mysql)互相访问。在 Spring Boot 的application-prod.yml里配置数据源时,主机名直接填mysql而不是某个 IP。这个看似“没有 IP”的写法反而更稳定——容器 IP 在重建后会变化,而服务名在 Compose 网络内是固定的。
depends_on控制的是启动顺序,但不会等待服务完全就绪。比如 MySQL 容器启动了,不代表初始化已经完成。如果后端连接得过早,会报连接拒绝。处理方式有两种:简单的做法是在后端启动命令里加重试机制,或者借助健康检查让 Compose 真正等待 MySQL ready。后者更优雅:
mysql: healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 5s timeout: 3s retries: 20然后在 backend 里把depends_on改写成带 condition 的版本:
backend: depends_on: mysql: condition: service_healthy这样后端容器会等 MySQL 通过健康检查后再启动,基本能规避首次启动因依赖未就绪导致的崩溃。
3.2 Nginx 反向代理与终极访问入口
前端镜像里的 Nginx 除了托管静态文件,还要承担后端 API 的反向代理。前后端各自独立端口暴露在宿主机上(比如前端 80、后端 8080)也可以,但更干净的做法是在 Nginx 层面做一层转发,让前端只暴露一个 80 端口,后端端口对公网不可见。
Nginx 配置里的关键 location 如下:
server { listen 80; server_name _; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://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; } }从体验层面看这样做有不少好处:
- 浏览器只认识 80 端口,部署时无需给用户暴露内部接口地址。
- 后端服务可以平滑重启,前端不受影响。
- Nginx 层面统一管理跨域、SSL 证书和 gzip,不必各自为战。
需要说明的是 Nginx 代理的目标地址http://backend:8080走的是 Compose 网络里的服务名backend,与后端容器内部端口 8080 通信。如果容器名不是backend,这里要相应调整。如果后端正好配置了 context-path,比如/api,则proxy_pass的路径拼接需要小心处理,避免双重叠加/api。
实践中遇到过一种很常见的坑:Nginx 配置改了,但容器里跑的还是旧配置。这时需要重建容器让配置生效:
docker compose up -d --build如果只想重建某个服务而不影响其他容器:
docker compose up -d --build frontend3.3 部署时机的选择:Jenkins、GitLab CI 还是手动执行
Docker 化之后,发布流程已经简化到“构建镜像 + 替换容器”,但这不一定意味着每回都要在服务器上手动敲命令。更顺滑的做法是接入持续集成,比如在代码仓库的 CI 配置里定义流水线:拉代码、跑测试、构建镜像、推送镜像、远程执行docker compose up -d。整个过程从提交代码到发布上线能做到十分钟以内。整体流程可以抽象成下图所示:
| 阶段 | 动作 | 产物 |
|---|---|---|
| 触发 | 代码推送到主干分支或打 tag | 新版本标识 |
| 构建 | Maven 打包 + 前端构建 | 后端 jar、前端 dist |
| 镜像 | 多阶段构建生成新镜像 | Docker 镜像 |
| 推送 | 推送到自有镜像仓库 | 镜像仓库记录 |
| 部署 | 服务器拉取镜像并更新容器 | 新版本服务上线 |
如果团队刚起步,不必一步到位上 K8s,先用 Compose 管理服务器上的容器,后面再平滑演进到集群方案即可。
4. 数据持久化与备份:容器易换,数据难再生
使用 Docker 部署最容易忽视的环节就是数据管理。容器本质上是一个“临时进程”,删除容器很容易,但数据库里的用户数据、订单记录如果跟着被删除,局面就无法收拾。这一块务必备好双重保险:volume 持久化 + 定期备份。
4.1 volume 与 bind mount 的选择
Docker 提供两种主要数据持久化方式:
| 类型 | 特点 | 适用场景 |
|---|---|---|
| 命名卷(volume) | 由 Docker 管理,路径为/var/lib/docker/volumes/,备份需要额外指令 | 数据库数据、应用级数据 |
| 绑定挂载(bind mount) | 直接映射宿主机目录,编辑方便,权限由宿主机控制 | 配置文件、日志目录、初始化脚本 |
数据库推荐用命名卷。绑定的宿主机目录在某些系统上存在权限和性能差异,而命名卷由 Docker 完全接管,所有容器内写入操作都由 Docker 守护进程处理,在大多数人使用的文件系统上表现更稳定。
另外 MySQL 8.0 默认的认证插件细节也值得留个心。部分客户端连不上 MySQL 8,是因为认证插件默认是caching_sha2_password,老客户端只认mysql_native_password。在 Compose 里可以直接追加参数兜底,或者创建专用账号时显式指定插件。部署阶段为了减少不必要的排查时间,可以统一在 command 里指定:
command: - --default-authentication-plugin=mysql_native_password4.2 备份与恢复的一线实践经验
数据库备份不能等到出问题才想方案。容器的备份与传统备份有一个明显差异:要用正确的姿势进入容器内部执行mysqldump。推荐方式:
docker exec app_mysql sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" app_db' > backup.sql也可以设置定时任务,每天凌晨执行备份脚本,并把备份文件轮转保留最近 7 天。恢复时先把备份文件复制进容器,再执行导入:
docker exec -i app_mysql mysql -u root -p app_db < backup.sql备份文件建议再定期同步到对象存储或其他机器上。毕竟服务器磁盘本身也可能损坏,单一节点做备份并不算真正安全。数据备份的关键不是备份本身,而是能证明备份文件可恢复。偶尔做一次演练性恢复,比任何保障流程都来得可靠。
5. 镜像瘦身与启动速度优化的实战技巧
5.1 为什么镜像越小越好
很多人对镜像体积没有直观概念。一个臃肿的镜像意味着推送时间长、拉取时间长、磁盘占用高、攻击面更大。在后端镜像中,如果直接使用完整 JDK 基础镜像,最后体积往往会超过 500MB,而换成 JRE 或精简基础镜像后可以压到 200MB 以下。前端镜像用node全量版本做构建没问题,但运行阶段如果没做多阶段构建,镜像可能保留几百 MB 的 node_modules,体积会非常难看。
一个直观的计算例子:假设镜像体积从 1GB 压到 200MB,在 20Mbps 的公网带宽下,纯拉取时间就从约 400 秒缩短到 80 秒。这种差距在发布窗口紧张的时候会直接决定是否会出现超时。
5.2 .dockerignore 的魔力
COPY指令的行为和.gitignore很像,需要专门用.dockerignore来排除不需要进入上下文的文件。尤其要排除:
target/、node_modules/(依赖目录,体积巨大且没有必要进镜像).git/(历史无法进入镜像)*.log、*.md、IDE 配置文件夹(如.idea/、.vscode/)
否则每次构建时 Docker 都需要把整个项目目录发送给守护进程,目录大了构建会明显变慢。这个文件往往是新手最容易遗漏的细节,但影响很直接。
5.3 启动速度优化与容器生命周期
Java 启动慢是老生常谈的问题。容器化以后启动慢不只是影响“等待的时间”,它还可能引发健康检查失败、服务注册超时等问题。可以从几个方向优化:
- 使用 Spring Boot 3 配合 CDS(Class Data Sharing)或 AOT 技术,启动时间能缩短 30%~50%。
- JVM 参数按环境调整:生产环境用
-Xms和-Xmx设置合理堆大小,避免频繁扩容缩容。 - 容器编排层设置合理的健康检查周期,不要给太短的超时时间。
以 Compose 里的后端健康检查为例:
healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] interval: 15s timeout: 5s retries: 5但注意很多精简镜像里是没有curl的,这时需要确认健康检查命令真实可用,否则会一直处于 unhealthy 状态,排障时白费很多时间。也可以在基础镜像中提前安装 curl,但代价是镜像体积略增。实测下来,这个取舍通常是值得的。
6. 常见问题与排查技巧实录
6.1 容器起不来?先看日志和状态
部署过程中八成的故障都能用“看日志”解决。容器启动失败时第一件事是查状态:
docker compose ps docker compose logs -f backend典型场景包括:
| 现象 | 常见原因 | 解决方向 |
|---|---|---|
| 后端一直重启 | 数据库连接失败或配置错误 | 检查 Compose 网络与服务名,确认 DB_HOST 指向 mysql |
| 前端页面 404 | Nginx 缺少 try_files 或路由模式不匹配 | 补上 fallback 配置并重建前端容器 |
| MySQL 无法初始化 | 数据卷残留旧数据或 init.sql 语法错误 | 检查命名卷是否被复用,临时清掉卷重新初始化 |
| 端口占用 | 宿主机 3306/8080 已被其他进程占用 | 调整宿主机端口映射或停掉占用方 |
6.2 Spring Boot 与 MySQL 容器连接失败
前后端分离项目里最常见的后端启动时报错就是Communications link failure。大多数人第一反应是密码错了,但只要认真看堆栈会发现更深层的原因通常是网络不通。进入后端容器测试连通性:
docker exec -it app_backend ping mysql如果 ping 不通,说明 Compose 网络可能没建立成功,确认两个服务是否在同一网络内。另一种可能是application.yml里写死了 IP,但容器重建后 IP 变了。尽量用服务名而不是写死的 IP,这是容器编排的基本原则。
如果 ping 通了但端口不通,检查 MySQL 是否真的在监听,以及容器里的防火墙策略。生产环境里云平台的防火墙和宿主机防火墙也是被忽略的隐藏因素,端口映射虽然设置正确,但宿主机安全组没放行时,外部仍然无法访问。
6.3 前端 API 请求 502 与跨域问题
访问首页正常,但接口请求报 502,大概率是 Nginx 代理后端失败。排查顺序:
- 确认后端容器在运行且端口正常:
docker compose ps - 进入前端容器测试后端连通性:
docker exec -it app_frontend curl http://backend:8080/actuator/health - 检查
nginx.conf里proxy_pass的 URL 是否用了正确的服务名和端口
如果做了前后端完全分离、后端单独暴露端口,开发环境下 Spring Boot 需要配置跨域:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }线上推荐由 Nginx 做反向代理而不是依赖后端跨域配置,这样会简单很多。API 的请求路径统一以/api开头,避免跨域问题是一方面,也更容易做网关层控制和权限过滤。
6.4 构建缓存失效的问题
明明只改了一行业务代码,但构建时似乎所有层都重新执行了,耗时暴增。这个问题的深层原因多半是COPY . .放在RUN之前,或者没有用.dockerignore导致构建上下文传递了大批无用文件。应遵循一个基本顺序:先复制依赖清单文件,再装依赖,最后复制源码。这个顺序能让依赖层的缓存命中率大幅提高。
如果项目里前后端分别构建镜像,尽量保证只有代码变更引起源文件变化,依赖文件不经常变动。也推荐固定基础镜像的 tag,而不是一直用latest,否则基础镜像更新时整个缓存可能失效。把node:18-alpine固定到具体版本,既稳定又可复现。
7. 一套配置从测试环境平滑迁移到生产环境
开发环境、测试环境、生产环境之间的配置差异,是 Docker 部署绕不开的话题。Compose 文件本身支持多个 override 文件,只需通过-f参数叠加即可:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -ddocker-compose.prod.yml里只覆盖生产特有的配置,比如环境变量、端口、日志限制等。数据库密码这类敏感信息不建议直接写在 Compose 文件里,可以结合环境变量注入的方式处理。例如在宿主机.env文件里维护变量,Compose 会自动读取同目录下的.env:
MYSQL_ROOT_PASSWORD=prod_root_pass MYSQL_PASSWORD=prod_app_pass在docker-compose.yml中引用$MYSQL_ROOT_PASSWORD即可。这样同一份 Compose 在不同环境只需要替换.env,部署配置本身不需要大量改动。
按个人经验来看,一套完整的交付体系还可以增加一处细节:在 CI 流程里对镜像打版本号,比如app-backend:20250111-0912,部署时始终用这个明确的 tag 而非latest。这样出问题时可以迅速回滚到上一版本,而不是在latest的迷雾里反复试错。
8. 部署上线后的日常运维与体验小结
容器化部署不是把项目跑起来就结束了,上线后的维护同样重要。日志归集、监控告警、磁盘清理这些动作建议提前搭建好。
日志管理上,容器默认会把标准输出写到 Docker 的日志驱动里,但容器重建后日志就丢了。更可控的方式是让容器把日志写到挂载目录,再交给采集组件统一处理:
logging: driver: json-file options: max-size: "20m" max-file: "5"max-size和max-file限制单文件大小和保留份数,避免日志无限增长把磁盘打满。数据库容器尤其要注意磁盘告警,因为数据增长速度很容易被低估。磁盘满时 MySQL 可能直接拒绝写入,这种事故恢复成本很高。
我自己在这一路部署踩过许多坑:第一次上线时没有容器健康检查,数据库初始化没完成,后端服务连着重启了十几次,页面白屏了很久;后来加上了service_healthy条件,这个问题就根治了。后来做镜像分层优化时,增量推送从每次至少传输 300MB 降到了 30MB 以内,发布体验直接从“等半天”变成“喝口水就完成”。这套 Docker 部署方案最让人放心的地方在于,它其实不再要求你针对某台特定服务器做手工适配,遇到新环境只要拉镜像、起容器、指配置,整个系统就能一致地跑起来。踩过的坑也算是给后来者的路标,希望这份内容能帮你省下当初那些摸索时间。