Docker 学习篇(六)| 实战 — 用 Docker 构建 SpringBoot + Vue 全栈项目
- 1. 前置准备
- 1.1 确认 Docker 装好了
- 1.2 配置镜像加速器
- 2. 拉取中间件镜像
- 3. 后端:blog-server 的 Dockerfile
- 3.1 分析项目
- 3.2 在项目根目录创建文件
- 3.3 构建镜像
- 4. 前端:blog-ui 的 Dockerfile
- 4.1 分析项目
- 4.2 创建文件
- 4.3 构建镜像
- 5. docker-compose.yml(全家桶一键启动)
- 6. 启动与验证
- 6.1 如果你本机已装了 MySQL / Redis
- 6.2 构建并启动
- 6.3 验证
- 6.4 最终效果:四个容器各司其职
- 7. 部署到服务器
- 7.1 导出镜像
- 7.2 上传到服务器
- 7.3 服务器上导入并启动
- 8. 命令速查
- 9. 常见问题
1. 前置准备
1.1 确认 Docker 装好了
dockerversion输出中有 Client 和 Server 两段,Server 有版本号就是 OK。
1.2 配置镜像加速器
国内直连 Docker Hub 几乎不可用,必须配镜像加速。
Docker Desktop → 设置 → Docker Engine → 修改registry-mirrors:
{"registry-mirrors":["https://docker.1ms.run","https://docker.m.daocloud.io"]}点击Apply & Restart重启 Docker 后生效。
验证:
dockerpull hello-world能拉下来就说明配好了。拉完删掉:docker rmi hello-world。
⚠️
docker.xuanyuan.me在 Docker Desktop 29.x 上不兼容(报content size of zero),不要加。
2. 拉取中间件镜像
项目需要的中间件只有 MySQL 和 Redis。Nginx 不需要单独拉——它会在构建前端镜像时从 Docker Hub 自动拉取(FROM nginx:alpine)。
dockerpull mysql:8.0dockerpull redis:7-alpine确认:
dockerimages看到mysql:8.0和redis:7-alpine即可。
3. 后端:blog-server 的 Dockerfile
3.1 分析项目
blog-server 是一个 Spring Boot 3.2.5 + Java 21 + Maven 多模块项目:
blog-server/ ├── pom.xml ← 父 POM ├── blog-bootstrap/ ← 启动模块(有 main 方法) ├── blog-module-common/ ├── blog-module-article/ ├── blog-module-comment/ ├── blog-module-media/ ├── blog-module-auth/ ├── blog-module-site/ └── blog-module-message/关键配置(application.yml):
| 配置项 | 环境变量 | application.yml 默认值 | docker-compose 中覆盖为 |
|---|---|---|---|
| 数据库地址 | DB_HOST | localhost | mysql(容器名) |
| 数据库端口 | DB_PORT | 3307 | 3306(容器内端口) |
| 数据库名 | — | blog | — |
| 用户名 | DB_USERNAME | root | root |
| 密码 | DB_PASSWORD | root | root |
| Redis 地址 | REDIS_HOST | localhost | redis(容器名) |
| Redis 端口 | REDIS_PORT | 6380 | 6379(容器内端口) |
| Redis 密码 | — | 123456 | — |
| 服务端口 | SERVER_PORT | 8080 | 8080 |
| 上传目录 | UPLOAD_PATH | 本地 Windows 路径 | /app/upload(容器内路径) |
application.yml 默认值已设为连 Docker 容器(宿主机端口 3307/6380),方便 IDEA 直接跑。docker-compose 会用自己的环境变量覆盖为容器内端口(3306/6379)。
3.2 在项目根目录创建文件
在blog-server/下创建.dockerignore:
target/ .git/ .idea/ *.md *.log upload/ logs/在blog-server/下创建Dockerfile:
# ===== 第一阶段:编译 ===== FROM maven:3.9-eclipse-temurin-21 AS builder WORKDIR /app # 直接复制所有源码,单步编译 # 注意:多模块 Maven 项目不建议用 mvn dependency:go-offline 分层—— # 内部模块间依赖无法从本地仓库解析,会导致构建失败 COPY . . RUN mvn clean package -DskipTests -pl blog-bootstrap -am # ===== 第二阶段:运行 ===== FROM eclipse-temurin:21-jre-alpine WORKDIR /app # 从编译阶段只拿 jar 包 COPY --from=builder /app/blog-bootstrap/target/*.jar app.jar # 创建上传目录 RUN mkdir -p /app/upload EXPOSE 8080 CMD ["java", "-jar", "app.jar"]多阶段构建的关键认知:第一阶段用
maven:3.9-eclipse-temurin-21(含 JDK + Maven + 源码 → ~700MB),编译完后整个第一阶段丢弃。最终镜像基于eclipse-temurin:21-jre-alpine(只含 JRE → ~180MB),加上我们的 jar 约 250MB。如果不用多阶段,直接把 JDK 打进去,镜像至少 500MB+。blog-server 最终镜像里的东西: ✅ JRE 21(运行 Java 需要) ✅ app.jar(我们的代码) ✅ /app/upload 目录 ❌ Maven(不需要了) ❌ JDK 编译器(不需要了) ❌ 源代码(不需要了)
三种 Docker 构建方式的对比(基础/多阶段/Buildpacks)详见第四篇第 3 节。这里直接用最推荐的多阶段构建。
3.3 构建镜像
cdblog-serverdockerbuild-tblog-server:latest.4. 前端:blog-ui 的 Dockerfile
4.1 分析项目
blog-ui 是 Vue 3 + Vite + Element Plus,构建后生成dist/静态文件。
4.2 创建文件
在blog-ui/下创建.dockerignore:
node_modules/ dist/ .git/ *.md在blog-ui/下创建nginx.conf:
server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; # ========== 关键:API 请求代理到后端 ========== location /api/ { proxy_pass http://blog-server: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; } # Swagger / Knife4j API 文档 location /swagger-ui.html { proxy_pass http://blog-server:8080; } location /v3/api-docs { proxy_pass http://blog-server:8080; } location /webjars/ { proxy_pass http://blog-server:8080; } # 上传文件 location /upload/ { proxy_pass http://blog-server:8080; } # Vue Router history 模式:找不到文件就回退到 index.html location / { try_files $uri $uri/ /index.html; } }为什么需要 nginx 代理
/api/?Vue 项目里的 Axios 发请求是从用户浏览器发出的,不是从 Docker 容器里发出的。如果你在
.env里写VITE_API_BASE_URL=http://localhost:8080,那浏览器就真的去访问用户自己电脑的localhost:8080——这在部署到服务器上时完全不对。正确做法:前端请求全部发到同源(同一个域名/端口),由 nginx 根据路径前缀把
/api/转发给后端容器:浏览器 → http://服务器/api/v1/articles ↓ nginx (blog-ui 容器) ↓ proxy_pass http://blog-server:8080 ↓ blog-server 容器处理请求
在blog-ui/下创建Dockerfile:
# ===== 第一阶段:构建 ===== FROM node:20-alpine AS builder WORKDIR /app # 先复制依赖描述文件(package.json + package-lock.json) # 这样改源码不改依赖时,npm install 这层走缓存 COPY package*.json . RUN npm config set registry https://registry.npmmirror.com # 国内加速,海外可删 RUN npm install # VITE_API_BASE_URL 设为空:浏览器发请求到同源,由 nginx 代理到后端 ENV VITE_API_BASE_URL="" COPY . . # build-only 是该项目跳过了类型检查(vue-tsc),普通项目用 npm run build 即可 RUN npm run build-only # ===== 第二阶段:托管 ===== FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80VITE_API_BASE_URL 为什么设空?Vite 在构建时会把
import.meta.env.VITE_API_BASE_URL替换为实际值,写进 JS 文件里。设为空字符串后,Axios 的baseURL为空,所有请求变成相对路径(如/api/v1/articles),浏览器自动发到当前页面的域名。然后 nginx 根据/api/前缀转发给后端。blog-ui 最终镜像里的东西: ✅ Nginx(Web 服务器) ✅ dist/ 静态文件(HTML + JS + CSS) ✅ nginx.conf(代理规则) ❌ Node.js(不需要了) ❌ node_modules(不需要了) ❌ 源代码(不需要了) ❌ npm(不需要了)
4.3 构建镜像
cdblog-uidockerbuild-tblog-ui:latest.5. docker-compose.yml(全家桶一键启动)
在项目根目录创建docker-compose.yml:
services:mysql:image:mysql:8.0container_name:blog-mysqlports:-"3307:3306"volumes:-D:/Develop/DockerData/Personal/docker-mysql:/var/lib/mysqlenvironment:MYSQL_ROOT_PASSWORD:rootMYSQL_DATABASE:blogrestart:unless-stoppedhealthcheck:test:["CMD","mysqladmin","ping","-h","localhost"]interval:10stimeout:5sretries:5networks:-blog-netredis:image:redis:7-alpinecontainer_name:blog-redisports:-"6380:6379"volumes:-D:/Develop/DockerData/Personal/docker-redis:/datacommand:redis-server--requirepass 123456restart:unless-stoppednetworks:-blog-netblog-server:image:blog-server:latest# 本地 docker build,服务器 docker load,同一份 compose 两边通用container_name:blog-serverports:-"8081:8080"# 8081 避免和 IDEA 里跑的冲突volumes:-blog-upload:/app/upload# 上传的文件持久化environment:-SPRING_PROFILES_ACTIVE=docker-DB_HOST=mysql# 用容器名当域名!-DB_PORT=3306# 容器内端口,不是映射端口-DB_USERNAME=root-DB_PASSWORD=root-REDIS_HOST=redis-REDIS_PORT=6379-REDIS_PASSWORD=123456-UPLOAD_PATH=/app/uploaddepends_on:mysql:condition:service_healthyredis:condition:service_startedrestart:unless-stoppednetworks:-blog-netblog-ui:image:blog-ui:latestcontainer_name:blog-uiports:-"80:80"depends_on:-blog-serverrestart:unless-stoppednetworks:-blog-netvolumes:blog-upload:# 命名卷:Docker 管理,不用关心路径networks:blog-net:driver:bridge# 自定义桥接网络:容器间用容器名互访关键知识点:
1. 为什么统一用
image:不用build:
- docker-compose.yml 没有
build:,不管本地还是服务器,同一份文件直接用- 本地改代码后:
docker build -t blog-server:latest . && docker compose up -d- 服务器部署时:
docker load -i blog-server.tar && docker compose up -d- 不用记"本地用 build 服务器用 image"这种容易忘的规则
2. 容器间通信用容器名
- blog-server 的环境变量里
DB_HOST=mysql,用的是compose 服务名- 原理:同一个自定义网络(
blog-net)里的容器,Docker 内置 DNS 会把服务名解析为容器 IP- 注意端口用容器内端口(
3306),不是宿主机映射端口(3307)3. 四个镜像来源
远程拉取(Docker Hub) 本地构建(我们的 Dockerfile) ┌─────────────────┐ ┌──────────────────┐ │ mysql:8.0 │ │ blog-server │ │ redis:7-alpine │ │ blog-ui │ └─────────────────┘ └──────────────────┘ ↓ ↓ 4 个镜像 → docker compose up → 4 个容器
6. 启动与验证
6.1 如果你本机已装了 MySQL / Redis
两种方案,挑一个:
方案 A:停掉本机服务(简单省事)
# 管理员 PowerShellnet stop MySQL80# 服务名可能不同,去 services.msc 确认net stop Redis# 服务名也可能是 RedisService 或其他,去 services.msc 确认此时 3306 和 6379 空闲,但 compose 仍然走 3307/6380,IDEA 连接也不用变。
方案 B:本机服务留着,Docker 换端口(推荐,已配好)
本文的docker-compose.yml已经用了岔开端口的方案:
Windows 本机 Docker 容器 ├── MySQL → localhost:3306 ├── MySQL → localhost:3307 ← compose 默认 ├── Redis → localhost:6379 ├── Redis → localhost:6380 ← compose 默认不需要改任何配置,直接docker compose up -d就能两边同时跑。
⚠️数据目录绝对不能共用!两个 MySQL 实例指向同一份数据文件会锁表甚至损坏数据。Docker 的数据目录
D:/Develop/DockerData/Personal/docker-mysql必须是空的独立目录。
IDEA 里切着连:
- 连 Windows MySQL →
localhost:3306,root / root - 连 Docker MySQL →
localhost:3307,root / root
6.2 构建并启动
# 1. 先构建镜像(把代码打成镜像)dockerbuild-tblog-server:latest ./blog-serverdockerbuild-tblog-ui:latest ./blog-ui# 2. 一键启动dockercompose up-d# 之后改代码,只需重建镜像并重启对应服务dockerbuild-tblog-server:latest ./blog-server&&dockercompose up-d6.3 验证
# 看四个容器是否都在跑dockercomposeps# 看后端日志dockerlogs-fblog-server# 测试 API(通过 nginx 代理)curlhttp://localhost/api/v1/site/config# 浏览器访问# 前端: http://localhost# API 文档: http://localhost/swagger-ui.html(通过 nginx 代理)# 后端直连: http://localhost:8081(绕过 nginx,调试用)所有访问都走前端 80 端口,nginx 根据路径自动转发
/api/到后端。后端 8081 只给开发者本地调试用。
6.4 最终效果:四个容器各司其职
浏览器访问 http://localhost │ ▼ ┌──────────────┐ │ blog-ui │ Nginx 容器 │ :80 │ / → 静态文件 (Vue) └──────┬───────┘ /api/* → 代理给 blog-server:8080 │ /upload/* → 代理给 blog-server:8080 ▼ ┌──────────────┐ │ blog-server │ JRE 容器 │ :8080 │ Spring Boot 应用 └──┬─────┬─────┘ │ │ ▼ ▼ ┌────┐ ┌────┐ │mysql│ │redis│ 中间件容器 │:3306│ │:6379│ └────┘ └────┘用代码验证 Redis 连接(可选):
在 blog-server 里加一个测试接口,确认 Redis 容器能正常读写:
@RestControllerpublicclassRedisTestController{@AutowiredprivateStringRedisTemplatestringRedisTemplate;@GetMapping("/redis/set")publicStringsetRedisData(){stringRedisTemplate.opsForValue().set("docker-test","Docker Redis 连接成功!");return"已存入 Redis";}@GetMapping("/redis/get")publicStringgetRedisData(){returnstringRedisTemplate.opsForValue().get("docker-test");}}访问/redis/set存数据,/redis/get取数据,能取到说明 Redis 通了。
7. 部署到服务器
7.1 导出镜像
dockersave-oblog-server.tar blog-server:latestdockersave-oblog-ui.tar blog-ui:latest7.2 上传到服务器
通过 FinalShell 或宝塔面板,把blog-server.tar、blog-ui.tar、docker-compose.yml传到服务器上。
7.3 服务器上导入并启动
服务器上需要拉取中间件镜像(MySQL、Redis):
# 拉取中间件镜像(服务器也要配镜像加速器)dockerpull mysql:8.0dockerpull redis:7-alpine# 导入你自己的镜像dockerload-iblog-server.tardockerload-iblog-ui.tar# 修改 docker-compose.yml 中卷路径为 Linux 路径(如 /data/docker-mysql)# docker-compose.yml 用的是 image: 不是 build:,本地服务器同一份,不用改dockercompose up-d服务器部署时,建议删掉 docker-compose.yml 中 MySQL 和 Redis 的
ports映射——生产环境只暴露前端 80/443 就够了。
注意.tarvs.tar.gz:如果镜像文件后缀是.tar.gz,两种方式导入:
# 方式一:管道解压导入(最可靠)gunzip-cblog-server.tar.gz|dockerload# 方式二:直接用 load(部分新版 Docker 支持)dockerload-iblog-server.tar.gz有人会把
.tar文件直接改后缀成.tar.gz,导致gunzip -c失败。用file 文件名命令可以查看真实类型——显示POSIX tar archive就是.tar,显示gzip compressed data才是真正的.tar.gz。
docker cp:容器和宿主机互传文件
# 从宿主机复制到容器dockercpapp.jar blog-server:/app/app.jar# 从容器复制到宿主机(比如导出日志)dockercpblog-server:/app/logs ./logs# 容器内目录结构参考:# 后端应用:取决于 Dockerfile 的 WORKDIR(本例为 /app)# 前端静态文件:/usr/share/nginx/html(Nginx 默认)8. 命令速查
完整命令手册见 第五篇:常用命令速查。这里只列本篇用到的关键命令:
# 构建dockerbuild-tblog-server:latest ./blog-server# 构建后端镜像dockerbuild-tblog-ui:latest ./blog-ui# 构建前端镜像# 启动dockercompose up-d# 启动所有服务dockercompose up-d--force-recreate# 重建容器(镜像更新后用这个)dockercompose down# 停止并清理dockercomposeps# 看所有容器状态dockercompose logs-fblog-server# 看后端日志9. 常见问题
| 问题 | 解决 |
|---|---|
| 端口被占用 | 停掉 Windows 本机的 MySQL/Redis 服务,或岔开端口映射(如 3307) |
| 容器启动后立即退出 | docker logs 容器名看错误日志,通常缺环境变量或端口冲突 |
| 后端连不上数据库 | 检查DB_HOST=mysql(容器名)且DB_PORT=3306(容器内端口),不是宿主机端口 3307 |
| 构建慢 | 检查.dockerignore有没有排除node_modules/target |
| 镜像拉不下来 | 检查镜像加速器配了没有,重启 Docker |
| 改代码不生效 | docker compose up -d --build(重建镜像并重启) |
| Redis 客户端连不上 | 填localhost,端口填宿主机映射端口(如 6380),不是容器 IP |
| 容器名重复 | 容器名同一机器唯一,先docker rm 容器名再重建 |
容器卡死,stop/restart/kill 都不响应:
这种情况通常是 runc 进程异常,传统docker stop或docker kill都无效。
# 1. 获取容器主进程 PIDPID=$(dockerinspect-f'{{.State.Pid}}'容器名或ID)# 2. 强杀该 PID(在同一 shell 会话执行,变量才保留)kill$PID# 如果 kill 无响应,升级为强杀:kill -9 $PID# 3. 确认容器已停dockerps-a# 4. 强制删除并重建dockerrm-f容器名dockercompose up-d
kill $PID后容器状态从 running 变成 stopped,再docker rm -f清理干净,然后 compose 重部署。