
我先把这套系统放到一台全新的 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 地址要根据你服务器的实际网络环境来选择没有统一答案。改完重启 Dockersudo systemctl restart docker这一步属于不配置也能用但配置了体验天差地别的操作。实测在未配置 mirror 的情况下拉一个 MySQL 镜像可能要分多次才成功配置之后一次性完成。2.2 后端项目的配置改造与打包SpringBoot 项目开发时application-dev.yml里的数据库地址通常是localhost:3306。但部署到 Docker 容器后后端容器跟 MySQL 容器是分开的两个容器localhost 指向的是容器自己根本连不到数据库。所以需要为生产环境单独准备一份配置。我个人的做法是新增一个application-prod.ymlserver: port: 8080 spring: datasource: url: jdbc:mysql://mysql-container:3306/property_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: Root123456 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.activeprod或者通过环境变量SPRING_PROFILES_ACTIVEprod传入。然后进行 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/dataMySQL 数据目录的宿主机映射容器删了数据还在。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 TZAsia/Shanghai \ -e MYSQL_ROOT_PASSWORDRoot123456 \ -e MYSQL_DATABASEproperty_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-serverutf8mb4 \ --collation-serverutf8mb4_general_ci关键点逐一说明--restart always容器意外退出或服务器重启后自动拉起生产环境必须配置。-e TZAsia/Shanghai设置容器时区不设置的话 MySQL 默认 UTC 时间日志时间和国内时间相差 8 个小时排查问题时候会很别扭。--character-set-serverutf8mb4字符集必须和初始化 SQL 文件里的表结构保持一致否则容易出现中文乱码。MYSQL_DATABASEproperty_db会自动创建一个空库。但如果初始化 SQL 里已经有CREATE DATABASE语句后面的执行会受到影响。我自己更倾向于初始化 SQL 中只包含表结构和数据不包含建库语句交给环境变量来处理。验证初始化是否完成# 查看启动日志 docker logs -f mysql-container # 进入容器检查表结构 docker exec -it mysql-container mysql -uroot -pRoot123456在 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目录下创建DockerfileFROM 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 --frombuilder /build/target/property-system.jar /app/app.jar ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone EXPOSE 8080 ENTRYPOINT [java, -jar, /app/app.jar, --spring.profiles.activeprod]注意几个细节Java 版本必须和项目保持一致。项目基于 JDK 8就用 openjdk 8如果是较新的 SpringBoot 3.xJDK 版本至少 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_ACTIVEprod \ 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.confserver { 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/listNginx 转发给后端的是/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.ymlversion: 3.8 services: mysql: image: mysql:5.7 container_name: mysql-container restart: always environment: TZ: Asia/Shanghai MYSQL_ROOT_PASSWORD: Root123456 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, -pRoot123456] 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 failureMySQL 未就绪或密码错误先docker compose ps看 MySQL 是否 healthy再确认 MYSQL_ROOT_PASSWORD 与 JDBC 密码一致浏览器打开页面是空白或 403前端静态资源挂载路径不对确认dist目录完整docker exec -it frontend-container ls /usr/share/nginx/html查看目录内容API 请求 502Nginx 代理地址写错检查proxy_pass是http://backend-container:8080而不是 127.0.0.1API 请求 404代理路径少了一层或后端路由对不上确认location /api/中的proxy_pass末尾是否有/前后缀被正确处理中文乱码数据库字符集不是 utf8mb4检查 MySQL 容器启动参数--character-set-serverutf8mb4以及建表语句是否指定 charset时区差 8 小时容器时区未设置检查所有 service 的TZAsia/Shanghai环境变量镜像拉取超时未配置 registry mirror修改/etc/docker/daemon.json后重启 Docker7.2 镜像拉取慢与超时问题新环境第一次docker compose up要拉 mysql、nginx 等镜像网络不好时会超时。我建议在部署前先把必要的镜像手动拉一遍确认拉取成功后再执行 composedocker 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 命令和这套文件五分钟内就能复制出完整环境。这比任何部署文档都可靠。