我先把这套系统放到一台全新的 Ubuntu 服务器上跑通了一遍,过程比预想的曲折,但最后用 Docker Compose 一键拉起的那一刻,整个前后端分离物业管理系统从数据库到静态页面全部就绪。这篇把整个部署过程掰开揉碎,从环境准备、项目改造、镜像构建到编排上线,按我实际踩过的顺序来写。
1. 从整体架构看清楚要部署什么
1.1 一套典型的 SpringBoot + Layuimini + MySQL 系统长什么样
先别急着敲命令,得先搞清楚这套物业管理系统内部是怎么组织的。它其实是一个标准的"前后端分离"架构:
- 后端是 SpringBoot 应用,对外暴露的是一组 RESTful API,比如业主信息管理、物业费账单、报修工单、车位管理、公告发布等模块,数据全部落到 MySQL。
- 前端是 Layuimini。它本身是一个基于 Layui 的轻量后台管理模板,典型的特征是左侧菜单 + 右侧页面的布局方式,菜单导航、Tab 切换这些管理后台的基础交互都已经封装好了,开发的业务页面通过 layui 的模块化机制去加载。
- 数据层由 MySQL 承担。整个系统最核心的数据,比如房产档案、业主台账、收费记录,都存储在 MySQL 里。
三者在独立环境里各自跑是一回事,"拎到一台服务器上协同工作"又是另一回事。前端页面要能请求到后端 API,后端要能连上数据库,三者之间的网络链路、配置文件、端口关系都必须提前设计好。
1.2 为什么这套项目特别适合用 Docker 来部署
如果不用 Docker,传统部署流程大概是:服务器上手动装 JDK、装 MySQL、装 Nginx,然后把 jar 包丢进去启动,把前端静态文件拷到 Nginx 目录。问题在于:
- 环境不一致:开发机上是 JDK 8,服务器上是 JDK 11,多字节码版本直接启动失败。
- 依赖残留:以前装过别的项目的 MySQL,端口冲突、配置残留,排查起来极其痛苦。
- 回滚困难:改了几行代码重新部署,结果把服务器环境搞乱了,想回退到上一个版本基本靠重装。
Docker 的思路是"把环境和应用打包成一个标准化的容器镜像"。开发的时候保证镜像里的环境跟本地一致,部署的时候不管服务器底层的系统是什么样,只要跑 Docker,镜像里的应用就是同一个运行环境。尤其是物业管理系统这种典型的"SpringBoot + 静态前端 + MySQL"三件套,天然适合用容器编排来管理。
2. 部署前的准备工作:服务器、项目、环境逐个到位
2.1 Ubuntu 服务器上安装 Docker 与环境初始化
我在一台 Ubuntu 20.04 的服务器上操作,系统版本不同命令会有微小差异,但思路完全相同。安装 Docker 本身不复杂,注意几个环节:
# 更新系统包索引 sudo apt update # 安装 Docker 依赖 sudo apt install -y apt-transport-https ca-certificates curl # 安装 docker-ce、docker-compose-plugin sudo apt install -y docker.io docker-compose-plugin # 启动并设置开机自启 sudo systemctl enable docker sudo systemctl start docker这里有一个我建议你一开始就做的动作:给当前用户加入 docker 组,否则每次执行 docker 命令都要加 sudo,长期操作无比痛:
sudo usermod -aG docker $USER newgrp docker之后需要配置 Docker 的 registry mirror。不配置的话,拉取 MySQL、Nginx 这类公共镜像时可能很慢,尤其在一些网络环境下会频繁超时。在/etc/docker/daemon.json中写入:
{ "registry-mirrors": ["https://docker.mirrors.example.com"] }具体用哪个 mirror 地址,要根据你服务器的实际网络环境来选择,没有统一答案。改完重启 Docker:
sudo systemctl restart docker这一步属于"不配置也能用,但配置了体验天差地别"的操作。实测在未配置 mirror 的情况下拉一个 MySQL 镜像可能要分多次才成功,配置之后一次性完成。
2.2 后端项目的配置改造与打包
SpringBoot 项目开发时,application-dev.yml里的数据库地址通常是localhost:3306。但部署到 Docker 容器后,后端容器跟 MySQL 容器是分开的两个容器,localhost 指向的是容器自己,根本连不到数据库。
所以需要为生产环境单独准备一份配置。我个人的做法是新增一个application-prod.yml:
server: port: 8080 spring: datasource: url: jdbc:mysql://mysql-container:3306/property_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: Root@123456 driver-class-name: com.mysql.cj.jdbc.Driver logging: file: name: /logs/property-system.log这里的mysql-container是后面要创建的 MySQL 容器名。在 Docker 自定义网络中,容器之间可以通过容器名互相访问,这是容器通信的关键机制。
配置激活方式:
java -jar property-system.jar --spring.profiles.active=prod或者通过环境变量SPRING_PROFILES_ACTIVE=prod传入。
然后进行 Maven 打包:
mvn clean package -DskipTests打包完成后,确认target目录下生成了可执行的 jar 包。这里要注意:确保容器里运行的是打出来的 jar,而不是依赖 IDE 启动。
2.3 前端 Layuimini 项目的构建产物
Layuimini 是基于 Layui 的后台模板,多数项目的组织形式是 HTML + JS + CSS 的静态资源,没有复杂的 Webpack 构建流程。所谓"打包",就是把整个前端工程目录整理好,确保index.html、page、lib、assets等目录结构完整。
如果你拿到的项目需要编译,通常是 npm 管理:
npm install npm run build构建完成后,dist/目录里就是纯静态文件。把dist目录拷贝到服务器上,后面直接挂载给 Nginx 容器即可。
这里有 一个很多人会忽略的细节:Layuimini 的前端页面内部请求后端 API 时,通常会定义一个全局变量,比如站点的 base URL。这个地址如果写死成http://localhost:8080,部署到生产环境后浏览器会直接请求用户本机的 8080 端口,属于典型的低级事故。需要在前端代码里找一下config.js或类似的配置文件,把 API 地址改成"与页面同源的相对路径",比如/api,这样后续通过 Nginx 反向代理就能解决跨域和地址问题。
3. 先跑数据层:MySQL 容器部署与初始化
3.1 创建目录结构与数据卷
部署之前,我在服务器上规划了一个清晰的目录结构,避免文件乱扔,后面排查问题时能快速定位:
mkdir -p /opt/property-system cd /opt/property-system mkdir -p mysql/{data,init} mkdir -p backend mkdir -p frontendmysql/data:MySQL 数据目录的宿主机映射,容器删了数据还在。mysql/init:初始化 SQL 脚本,容器首次启动时自动执行。backend:后端 jar 包和 Dockerfile。frontend:前端静态资源。
把项目的property_db.sql放到mysql/init目录下。这个 SQL 文件里应该包含建库建表语句以及初始数据(比如管理员账号、基础字典数据)。
3.2 启动 MySQL 容器并验证初始化
MySQL 官方镜像有一个很实用的机制:容器首次启动时,会自动执行/docker-entrypoint-initdb.d/目录下的.sql文件。这意味着你不需要手动进容器执行 SQL,只要把初始化脚本挂载进去,首次启动就能自动完成建库。
启动命令:
docker run -d \ --name mysql-container \ --restart always \ -p 3306:3306 \ -e TZ=Asia/Shanghai \ -e MYSQL_ROOT_PASSWORD=Root@123456 \ -e MYSQL_DATABASE=property_db \ -v /opt/property-system/mysql/data:/var/lib/mysql \ -v /opt/property-system/mysql/init:/docker-entrypoint-initdb.d \ mysql:5.7 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_general_ci关键点逐一说明:
--restart always:容器意外退出或服务器重启后自动拉起,生产环境必须配置。-e TZ=Asia/Shanghai:设置容器时区,不设置的话 MySQL 默认 UTC 时间,日志时间和国内时间相差 8 个小时,排查问题时候会很别扭。--character-set-server=utf8mb4:字符集必须和初始化 SQL 文件里的表结构保持一致,否则容易出现中文乱码。MYSQL_DATABASE=property_db:会自动创建一个空库。但如果初始化 SQL 里已经有CREATE DATABASE语句,后面的执行会受到影响。我自己更倾向于初始化 SQL 中只包含表结构和数据,不包含建库语句,交给环境变量来处理。
验证初始化是否完成:
# 查看启动日志 docker logs -f mysql-container # 进入容器检查表结构 docker exec -it mysql-container mysql -uroot -pRoot@123456在 MySQL 命令行里执行USE property_db; SHOW TABLES;,能正常列出表就代表初始化成功。
3.3 数据持久化的坑:为什么必须挂载数据目录
MySQL 容器如果不挂载数据目录,容器删除后数据就全没了。这听起来像废话,但实际操作中真的很常见——有人为了升级镜像版本,直接删了老容器重建,结果业主数据、缴费记录全部丢失。
即使挂载了数据目录,也要注意:如果 MySQL 数据目录里已经有了初始化数据,再次挂载新的initSQL 目录时,/docker-entrypoint-initdb.d下的脚本不会重复执行。因为镜像的初始化逻辑只在数据目录为空的情况下运行。所以,初始化 SQL 的执行时机是第一次启动且 data 目录为空时,之后想要改表结构,需要手动进容器执行脚本,或者专门跑一个一次性容器。
4. 后端上线:SpringBoot 容器化全流程
4.1 编写多阶段构建 Dockerfile
后端项目最经典的方案是用多阶段构建:第一阶段用 Maven 镜像完成编译打包,第二阶段用 JRE 镜像作为运行环境。这样做的好处是最终镜像体积小,不包含 Maven、JDK 等构建期工具。
在backend目录下创建Dockerfile:
FROM maven:3.8-openjdk-8 AS builder WORKDIR /build COPY pom.xml . COPY src ./src RUN mvn clean package -DskipTests FROM openjdk:8-jre-alpine WORKDIR /app COPY --from=builder /build/target/property-system.jar /app/app.jar ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app/app.jar", "--spring.profiles.active=prod"]注意几个细节:
- Java 版本必须和项目保持一致。项目基于 JDK 8,就用 openjdk 8;如果是较新的 SpringBoot 3.x,JDK 版本至少 17,镜像要换成
eclipse-temurin:17-jre。 - 时区在镜像构建时就写入,避免在启动命令里再处理。
ENTRYPOINT里直接激活prod配置,不需要额外传参,减少部署时的手动操作。
4.2 构建后端镜像并配置网络
先把 jar 包和相关文件拷贝到服务器/opt/property-system/backend目录,然后构建镜像:
cd /opt/property-system/backend docker build -t property-backend:1.0 .构建完成之后,先创建一个自定义网络,让容器之间可以通过容器名互相访问:
docker network create property-net然后启动后端容器:
docker run -d \ --name backend-container \ --restart always \ --network property-net \ -p 8080:8080 \ -v /opt/property-system/logs:/logs \ -e SPRING_PROFILES_ACTIVE=prod \ property-backend:1.0这里最重要的一行是--network property-net。有了这个自定义网络,容器之间不依赖 IP 地址,直接用容器名就能解析,比如后端容器里访问mysql-container:3306。如果不用同一个网络,后端容器是连不到 MySQL 的。
日志挂载是另一个必要操作:-v /opt/property-system/logs:/logs。SpringBoot 日志文件路径在application-prod.yml中配置为/logs/property-system.log,这个目录被挂载到宿主机,日志不会因为容器重建而丢失,排查问题时也方便直接tail -f宿主机上的日志。
验证后端是否启动了:
docker logs -f backend-container出现 "Started Application in x.xxx seconds" 就代表启动成功。如果日志里报UnknownHostException: mysql-container,说明网络没接对;如果是Connection refused,大概率是 MySQL 还没初始化完成。
4.3 后端容器的启动顺序问题
一个很常见的现象:后端容器启动时 MySQL 还没就绪,SpringBoot 的数据源初始化直接失败,整个应用启动报错退出。
解决这个问题有几个思路:
- 在应用层面配置连接重试:SpringBoot 默认数据源初始化失败就抛异常,可以引入连接重试机制。
- 在容器层面让后端晚点启动:这种方式依赖于脚本化,不够优雅。
- 用 docker-compose 的 healthcheck + depends_on:等 MySQL 健康后再启动后端,这才是比较规范的方案。
我实际采用的方式是 docker-compose 中的健康检查机制,在后面的编排部分会详细展开。
5. 前端接入:Layuimini 静态资源 + Nginx 容器
5.1 编写 Nginx 配置
前端容器用的是 Nginx,它承担两个任务:一是托管 Layuimini 静态资源,二是把/api请求反向代理到后端容器。
在frontend目录下创建nginx.conf:
server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; # 处理 Layuimini 的静态资源 location / { try_files $uri $uri/ /index.html; } # API 反向代理到 SpringBoot 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; } }解释两个关键配置:
try_files $uri $uri/ /index.html;:如果请求的路径在静态目录中找不到对应文件,就回退到index.html。这对 Layuimini 这类多页面应用很管用,刷新子页面时不会返回 404。location /api/配合proxy_pass http://backend-container:8080/;:这里的/很关键,它在转发时会把/api前缀去掉。比如浏览器请求/api/owner/list,Nginx 转发给后端的是/owner/list。所以后端 Controller 的 RequestMapping 不需要带/api前缀。如果你想保留/api/前缀,proxy_pass就不要加末尾的/。
另外,Layuimini 前端页面如果请求的是/api/...相对路径,浏览器访问的是http://服务器IP/api/...,同源情况下不存在跨域问题。如果开发阶段前端直接请求后端 8080 端口,配置了跨域,部署时通过 Nginx 反向代理之后这些跨域配置就不需要了。
5.2 启动 Nginx 容器
确认前端静态资源已经上传到/opt/property-system/frontend/dist目录,然后启动:
docker run -d \ --name frontend-container \ --restart always \ --network property-net \ -p 80:80 \ -v /opt/property-system/frontend/dist:/usr/share/nginx/html \ -v /opt/property-system/frontend/nginx.conf:/etc/nginx/conf.d/default.conf \ nginx:1.20端口映射到宿主机 80。如果你的服务器上已经有 Nginx 或其他服务占用了 80 端口,可以先sudo lsof -i:80查一下占用情况,或者把宿主端口改为 8088(-p 8088:80)。
然后验证整个链路:
# 浏览器访问 http://服务器IP/ # 服务器上验证 API 是否可用 curl http://localhost/api/owner/list curl http://localhost/api/login通过浏览器能看到 Layuimini 的登录页,输入管理员账号能正常进入系统,菜单能展开、列表能加载出来,整套系统就通了。
5.3 前端容器与后端容器协作的网络细节
有一个我在真实项目中踩过的坑:前后端容器都在同一个自定义网络property-net里,但是 Nginx 的proxy_pass第一次配置时我写的是http://127.0.0.1:8080/。结果浏览器请求/api的时候 Nginx 一直返回 502。原因很简单:Nginx 容器内部的 127.0.0.1 指向自己,不是宿主机的 8080 端口,更不是后端的地址。
在 Docker 网络模式下,容器之间要通信,应该使用对方容器的名称作为主机名,也就是http://backend-container:8080/。Docker 内置的 DNS 服务会把容器名解析成对应的容器 IP。
如果确实希望从容器访问宿主机的端口,可以用host.docker.internal这种方式,但这不是跨容器通信的正确姿势。
6. 一键编排:用 docker-compose 替代手动命令
6.1 手动执行 docker run 的问题
前面几步我都是手动执行 docker run,每一步之间需要记住容器名、网络、挂载参数,整体操作非常琐碎。而且有一个前面提到的问题——后端容器和 MySQL 容器的启动顺序没有保证,后端很容易在 MySQL 还没就绪时启动。
docker-compose 把三个容器的定义写在一个 YAML 文件里,一条命令完成创建、启动、网络配置。它还支持健康检查,可以在 MySQL 真正可用之后再启动后端。
6.2 编写 docker-compose.yml
在/opt/property-system下创建docker-compose.yml:
version: "3.8" services: mysql: image: mysql:5.7 container_name: mysql-container restart: always environment: TZ: Asia/Shanghai MYSQL_ROOT_PASSWORD: Root@123456 MYSQL_DATABASE: property_db volumes: - ./mysql/data:/var/lib/mysql - ./mysql/init:/docker-entrypoint-initdb.d ports: - "3306:3306" healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-pRoot@123456"] interval: 10s timeout: 5s retries: 5 start_period: 30s backend: build: ./backend container_name: backend-container restart: always depends_on: mysql: condition: service_healthy environment: TZ: Asia/Shanghai SPRING_PROFILES_ACTIVE: prod volumes: - ./logs:/logs ports: - "8080:8080" frontend: image: nginx:1.20 container_name: frontend-container restart: always depends_on: - backend volumes: - ./frontend/dist:/usr/share/nginx/html - ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf ports: - "80:80"几个编排要点:
healthcheck段是解决启动顺序的关键。MySQL 容器启动后,mysqladmin ping能通了就表示可用了,此时才开始启动后端,基本能避免数据库连接失败的报错。depends_on配合condition: service_healthy表示依赖服务必须健康可用,这是新版 docker-compose 的写法,旧版本可能不支持condition,需要docker compose插件。- 网络不需要手动创建。compose 会为这个项目自动创建一个默认网络,所有 service 都加入其中,容器名就是服务名对应的主机名。
build: ./backend表示后端镜像如果本地不存在,会按照./backend/Dockerfile自动构建。
6.3 启动、查看状态与日常运维
启动整个系统:
cd /opt/property-system docker compose up -d查看容器运行状态:
docker compose ps如果后端启动还是报数据库连接失败,可以看健康检查的细节:
docker inspect --format='{{json .State.Health}}' mysql-container日常运维常用命令:
# 查看所有容器日志 docker compose logs -f # 只查看后端日志 docker compose logs -f backend # 重启某个服务 docker compose restart backend # 停止全部服务(容器会被删除,数据卷里的数据保留) docker compose down # 停止并删除数据卷(慎用,数据会全没) docker compose down -vdocker compose down不会删除./mysql/data挂载的数据,因为这是 bind mount,不是 compose 管理的数据卷。这其实是个好设计:日常更新版本、重建服务时,数据完全不受影响。
7. 实际部署中的常见问题与排查记录
7.1 问题速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
后端日志报UnknownHostException: mysql-container | 后端容器和 MySQL 容器不在同一网络 | docker network inspect property-net检查两个容器是否都在里面 |
后端日志报Communications link failure | MySQL 未就绪或密码错误 | 先docker compose ps看 MySQL 是否 healthy,再确认 MYSQL_ROOT_PASSWORD 与 JDBC 密码一致 |
| 浏览器打开页面是空白或 403 | 前端静态资源挂载路径不对 | 确认dist目录完整,docker exec -it frontend-container ls /usr/share/nginx/html查看目录内容 |
| API 请求 502 | Nginx 代理地址写错 | 检查proxy_pass是http://backend-container:8080而不是 127.0.0.1 |
| API 请求 404 | 代理路径少了一层或后端路由对不上 | 确认location /api/中的proxy_pass末尾是否有/,前后缀被正确处理 |
| 中文乱码 | 数据库字符集不是 utf8mb4 | 检查 MySQL 容器启动参数--character-set-server=utf8mb4,以及建表语句是否指定 charset |
| 时区差 8 小时 | 容器时区未设置 | 检查所有 service 的TZ=Asia/Shanghai环境变量 |
| 镜像拉取超时 | 未配置 registry mirror | 修改/etc/docker/daemon.json后重启 Docker |
7.2 镜像拉取慢与超时问题
新环境第一次docker compose up要拉 mysql、nginx 等镜像,网络不好时会超时。我建议在部署前先把必要的镜像手动拉一遍,确认拉取成功后,再执行 compose:
docker pull mysql:5.7 docker pull nginx:1.20如果反复超时,优先改 Docker 镜像源。改完重启之后重新拉取。
7.3 MySQL 容器一直处于 starting 状态
compose 启动后docker compose ps显示 mysql 容器一直 not healthy,大概率是初始化 SQL 文件里有语法错误。找到问题脚本,修正后需要清理数据目录重新初始化:
docker compose down sudo rm -rf mysql/data/* docker compose up -d因为 MySQL 镜像的初始化脚本只会在数据目录为空时执行一次。如果数据目录里已经有半初始化的数据,脚本不会重新执行,你会一直卡在奇怪的状态。
7.4 前端登录后接口报 404
前面说过,Nginx 配置里location /api/配合proxy_pass http://backend-container:8080/,会把/api前缀去掉。如果后端 Controller 是通过/api/owner/list来定义路由的,这一个/就会导致请求找不到对应接口。
遇到这种情况,把proxy_pass改为http://backend-container:8080(去掉末尾的/),这样 Nginx 会把完整的/api/owner/list转发给后端。到底用哪种,取决于后端路由本身带不带/api前缀。上线前用 curl 测一下实际转发路径最靠谱。
7.5 容器重启后数据还在吗
这个问题在实战中问的人最多。只要数据卷挂载正确,容器删了重建,数据都在。验证方式是看宿主机目录:
ls /opt/property-system/mysql/data如果这个目录下有文件,说明 MySQL 数据是落盘在宿主机上的。反过来,如果目录是空的,就要检查 compose 文件里是不是漏了volumes配置。
整套流程走下来,最大感受是:所谓 Docker 实战部署,难点通常不在 Docker 命令本身,而在于搞清楚应用之间的依赖关系和配置细节。数据库连接地址什么时候用容器名、Nginx 反代保持还是去掉路径、时区和字符集有没有统一,这些才是真正影响系统能否跑起来的关键。
最后留一个小建议:部署完成后,把docker-compose.yml、nginx.conf、Dockerfile、application-prod.yml这四类核心文件整理进项目仓库的docs/deploy目录。后续任何人接手,只要有一条 Linux 命令和这套文件,五分钟内就能复制出完整环境。这比任何部署文档都可靠。