Day53-Docker:Dockerfile最佳实践 + 多阶段构建 + docker-compose编排

为什么你的 Spring Boot 镜像又大、又慢、还跑不起来

把 Spring Boot 应用部署上线的老大难,往往不是代码 bug,而是"在我机器上能跑,到你环境起不来"------JDK 版本不一致、内网依赖拉不到、glibc 差异让 native 库直接崩。Docker 解决的就是环境一致性:把操作系统、JDK、依赖、配置、运行时打包成一个不可变制品,交付的是"确定能跑的镜像"而非一堆源码。

本篇不堆概念,直接上生产级实操,带你解决三个核心问题:Dockerfile 怎么写才不踩安全与体积坑、多阶段构建怎么把镜像从 700MB 压到 180MB、docker-compose 怎么一条命令拉起 Spring Boot + MySQL + Redis + RabbitMQ 开发环境。文中代码均可直接跑,踩过的坑都已标注。


一、先看一个"反面教材"的Dockerfile

很多团队第一次写Spring Boot的Dockerfile,长这样:

bash 复制代码
# ❌ 反面教材:能跑,但全是坑
FROM openjdk:17
COPY target/app.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

能跑吗?能。能上生产吗?不能。问题一堆:

问题 后果
openjdk:17 基础镜像 ~640MB 镜像体积虚胖,拉取/推送慢,浪费存储
用root用户运行 容器逃逸风险,安全审计不过关
没有时区/语言设置 日志时间错8小时,中文乱码
jar包直接COPY 每次代码改动,整个jar层缓存失效,构建巨慢
没有健康检查 K8s/Docker不知道应用是否真就绪
没有JVM参数 OOM了才知道,默认堆太小或太大

下面我们一步步把它改对。


二、生产级Dockerfile:逐行解读

1. 选择合适的基础镜像

openjdk:17 这种"全家桶"镜像是历史包袱。现在官方推荐用 Eclipse Temurin( Adoptium)的 JRE 镜像,体积小一半:

bash 复制代码
# ✅ 基础镜像:Eclipse Temurin 17 JRE,基于Ubuntu,约260MB
FROM eclipse-temurin:17-jre
​
# 如果要极致瘦身,用 alpine 版本(约180MB),但注意musl libc兼容性
# FROM eclipse-temurin:17-jre-alpine

版本说明:JDK 17 是 LTS,Spring Boot 3.x 最低要求就是 JDK 17。如果你还在用 JDK 8,那是另一个故事了------建议尽快升级,Spring Boot 3.2+ 的虚拟线程、GraalVM AOT 都是17+才能享受的红利。

2. 非root用户运行

bash 复制代码
# ✅ 创建非root用户,容器内最小权限原则
RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser

这步看着不起眼,但安全扫描工具(Trivy、Snyk)看到 USER root 就会给你一个高危告警。生产环境的安全合规,这行不能省。

3. 时区与语言环境

bash 复制代码
# ✅ 设置时区为东八区,解决日志时间偏移问题
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
​
# ✅ 语言环境,防止中文日志/文件名乱码
ENV LANG=C.UTF-8

4. 分层COPY:利用构建缓存

这是很多人忽略的优化。jar包里 BOOT-INF/lib 下的第三方依赖很少变,而 BOOT-INF/classes 下的业务代码天天变。如果整个jar一起COPY,每次改一行代码,整个jar层都缓存失效。

Spring Boot从2.3开始支持分层jar,我们利用这一点:

java 复制代码
<!-- pom.xml:开启分层jar(Spring Boot 2.3+,3.x默认开启) -->
<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <layers>
            <enabled>true</enabled>
        </layers>
    </configuration>
</plugin>

然后Dockerfile这样写:

bash 复制代码
# ✅ 分层提取 + 分层COPY,最大化缓存命中率
WORKDIR /app
COPY --from=build target/app.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
​
# 先COPY依赖层(几乎不变,缓存命中率99%+)
COPY --from=extract dependencies/ ./
COPY --from=extract spring-boot-loader/ ./
COPY --from=extract snapshot-dependencies/ ./
# 最后COPY业务代码层(频繁变化)
COPY --from=extract application/ ./

实测:改一行业务代码,Docker构建从"重新传整个70MB的jar"变成"只传2MB的classes层",构建时间从40秒降到8秒。

5. 健康检查 + JVM参数

bash 复制代码
# ✅ 健康检查:Docker原生HEALTHCHECK(K8s有自己的探针,这里二选一)
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD curl -f http://localhost:8080/actuator/health || exit 1
​
# ✅ JVM参数通过环境变量传入,不硬编码
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/heapdump.hprof"
​
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]

关键点-XX:MaxRAMPercentage=75.0 而不是 -Xmx2g。容器里内存是动态的,用百分比让JVM自动适配容器内存上限,比写死数字灵活得多。这是JDK 10+容器感知特性。

完整的生产级Dockerfile

把上面的拼起来,再加上多阶段构建(下一节细讲),完整版如下:

bash 复制代码
# ============ 第一阶段:构建 ============
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
# 先COPY pom.xml,利用缓存下载依赖
COPY pom.xml .
RUN mvn dependency:go-offline -B
# 再COPY源码
COPY src ./src
RUN mvn clean package -DskipTests -B
​
# ============ 第二阶段:运行 ============
FROM eclipse-temurin:17-jre
​
# 非root用户
RUN groupadd -r appuser && useradd -r -g appuser appuser
​
# 时区 + 语言
ENV TZ=Asia/Shanghai
ENV LANG=C.UTF-8
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
​
WORKDIR /app
​
# 从构建阶段COPY jar
COPY --from=builder /build/target/*.jar app.jar
​
# 分层提取
RUN java -Djarmode=layertools -jar app.jar extract && \
    rm app.jar
​
# 分层COPY
COPY --from=extract dependencies/ ./
COPY --from=extract spring-boot-loader/ ./
COPY --from=extract application/ ./
​
USER appuser
​
EXPOSE 8080
​
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
  CMD curl -f http://localhost:8080/actuator/health || exit 1
​
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError"
​
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]

三、多阶段构建:镜像从700MB压到180MB

多阶段构建(Multi-stage build)是Docker 17.05+的核心特性。核心思想:构建环境和生产环境分离

为什么需要它?看这个对比:

Maven、JDK编译器、源代码------这些东西在运行时根本用不到,凭什么让它们占着镜像体积?多阶段构建就是让"builder"阶段干完活后,只把产物(jar)COPY到"runtime"阶段,builder阶段整个丢弃。

再进一步,用 eclipse-temurin:17-jre-alpine(基于Alpine Linux,用musl libc替代glibc),能压到180MB左右:

bash 复制代码
FROM eclipse-temurin:17-jre-alpine
# Alpine 注意事项:
# 1. musl libc 可能在某些native库上有兼容问题(如Netty的native epoll)
# 2. 需要安装 curl 用于健康检查
RUN apk add --no-cache curl tzdata
ENV TZ=Asia/Shanghai
COPY --from=builder /build/target/*.jar app.jar

体积对比实测

方案 镜像体积 拉取时间(首次) 适用场景
openjdk:17 + 单阶段 712MB 28s 仅本地调试
temurin:17-jre + 多阶段 268MB 11s 生产推荐
temurin:17-jre-alpine + 多阶段 182MB 7s 极致瘦身,注意兼容性
GraalVM Native Image 86MB 3s Serverless/冷启动敏感

提醒:Alpine虽好,但musl libc的坑不少。Netty、SQLite-JDBC、某些JNI库在Alpine上可能出问题。如果你不确定依赖里有没有native组件,先用标准jre镜像,别上来就alpine。出了问题排查musl兼容性,比省下的那80MB代价大得多。


四、docker-compose编排开发环境

生产环境用K8s,但开发环境用K8s太重了。docker-compose是开发环境的最佳选择------一条命令拉起Spring Boot + MySQL + Redis + RabbitMQ,新人clone代码5分钟就能跑起来。

java 复制代码
# docker-compose.yml:一键编排开发环境
version: "3.9"
​
services:
  # ============ 应用服务 ============
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: myapp
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=dev
      - SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/myapp?useSSL=false&serverTimezone=Asia/Shanghai
      - SPRING_DATASOURCE_USERNAME=root
      - SPRING_DATASOURCE_PASSWORD=root123
      - SPRING_REDIS_HOST=redis
      - SPRING_REDIS_PORT=6379
      - SPRING_RABBITMQ_HOST=rabbitmq
      - JAVA_OPTS=-XX:MaxRAMPercentage=75.0 -Xms256m -Xmx512m
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_started
      rabbitmq:
        condition: service_healthy
    restart: unless-stopped
    networks:
      - app-network
​
  # ============ MySQL ============
  mysql:
    image: mysql:8.0
    container_name: myapp-mysql
    environment:
      MYSQL_ROOT_PASSWORD: root123
      MYSQL_DATABASE: myapp
      TZ: Asia/Shanghai
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
      - ./sql/init:/docker-entrypoint-initdb.d  # 自动执行初始化SQL
    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot123"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app-network
​
  # ============ Redis ============
  redis:
    image: redis:7-alpine
    container_name: myapp-redis
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    networks:
      - app-network
​
  # ============ RabbitMQ(含管理界面)============
  rabbitmq:
    image: rabbitmq:3.13-management
    container_name: myapp-rabbitmq
    environment:
      RABBITMQ_DEFAULT_USER: admin
      RABBITMQ_DEFAULT_PASS: admin123
    ports:
      - "5672:5672"    # AMQP端口
      - "15672:15672"  # 管理界面
    volumes:
      - rabbitmq-data:/var/lib/rabbitmq
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "ping"]
      interval: 15s
      timeout: 10s
      retries: 5
    networks:
      - app-network
​
# ============ 数据卷 ============
volumes:
  mysql-data:
  redis-data:
  rabbitmq-data:
​
# ============ 网络 ============
networks:
  app-network:
    driver: bridge

几个关键设计点:

  1. depends_on + condition: service_healthy:确保MySQL和RabbitMQ健康检查通过后,app才启动。否则app先起来连不上数据库直接崩溃重启,依赖顺序问题在compose里非常常见。

  2. docker-entrypoint-initdb.d :MySQL官方镜像的约定,容器首次启动时自动执行这个目录下的 .sql/.sh 文件。把建表语句放进去,新人拉起环境就有表结构。

  3. 数据卷持久化mysql-dataredis-data 这些volume保证容器删除重建后数据不丢。开发环境不怕丢数据,但每次重建环境重新造测试数据也烦人。

  4. 网络隔离app-network bridge网络让四个容器在同一个网络里用服务名互相访问(mysql:3306redis:6379),不用暴露所有端口到宿主机。

常用命令:

bash 复制代码
# 启动全部服务(后台运行)
docker-compose up -d
​
# 只启动基础设施,app用本地IDE跑(开发常用)
docker-compose up -d mysql redis rabbitmq
​
# 查看日志
docker-compose logs -f app
​
# 重建app镜像(改了代码后)
docker-compose up -d --build app
​
# 销毁全部(保留数据卷)
docker-compose down
​
# 销毁并删除数据(慎用!开发环境重置用)
docker-compose down -v

五、.dockerignore:90%的人漏掉的配置

.gitignore 一个道理,.dockerignore 决定哪些文件不进构建上下文。没有这个文件,docker build 会把整个项目目录发给Docker daemon,包括 target/.git/node_modules/,构建上下文动辄几百MB。

bash 复制代码
# .dockerignore
# 构建产物
target/
build/
out/
​
# 版本控制
.git/
.gitignore
​
# IDE
.idea/
*.iml
.vscode/
​
# 日志
*.log
logs/
​
# 文档
*.md
docs/
​
# 测试
test-results/
coverage/
​
# 本地配置(敏感信息不入镜像)
application-local.yml
application-prod.yml
​
# Docker自身文件
Dockerfile
docker-compose.yml
.dockerignore

实测:一个中型Spring Boot项目,不加 .dockerignore 构建上下文约280MB,加上后约45MB。构建速度提升明显,尤其是CI/CD环境。


六、建议

1. 镜像版本必须锁定,禁止用 latest

FROM eclipse-temurin:17-jre 是可以的(隐式锁定到17的patch版本),但 FROM eclipse-temurin:latest 是灾难------某天Temurin推了JDK 21的latest标签,你的镜像突然构建出JDK 21的环境,Spring Boot 3.x在JDK 21上虚拟线程行为变化可能导致一堆隐性bug。生产镜像必须用完整版本号:eclipse-temurin:17.0.12_7-jre,或者至少锁定大版本。

2. 开发环境用compose,生产环境别用

docker-compose适合本地开发和单机部署(小项目、内部工具)。一旦你的服务超过3个、需要弹性伸缩、需要滚动更新------直接上K8s。compose的 scale 参数是个半成品,网络模型也不适合多节点。我见过有人拿docker-compose + Swarm上生产,一个节点挂了服务全灭,别走这条路。

3. 把JVM参数做成环境变量,配合K8s ConfigMap动态调整

Dockerfile里 JAVA_OPTS 用环境变量传入,不要硬编码。这样在K8s里可以通过ConfigMap/环境变量动态调整GC参数、堆大小,不用重新构建镜像。线上GC调优、OOM排查时,改个ConfigMap重启Pod就行,而不是改Dockerfile→构建→推镜像→滚动更新,这个链路太长了。


容器化的本质不是"把jar塞进Docker",而是把"环境、依赖、配置、运行时"打包成一个不可变制品。你交付的不再是代码,而是一个确定能跑的制品。这个思维转变,比学会写Dockerfile重要一百倍。

下篇预告:Day 54《企业级CI/CD流水线设计:Jenkins + GitLab + Docker》------代码push到main分支,自动构建→测试→SonarQube质量门禁→Docker镜像推送→部署到测试环境,全流程Pipeline拆解我们明天见。

相关推荐
骇客野人1 小时前
Java SSO 统一认证方案
java·开发语言
START_GAME1 小时前
MSSQL$SQL2016
java·服务器·前端
ydd1001001 小时前
字符串转换整数
java
ly76892 小时前
Docker 从入门到实践:镜像、容器、Dockerfile 与 Docker Compose 详解
运维·docker·容器
会编程的吕洞宾2 小时前
DeepAgents In Action学习(Second)
android·java·学习
梦梦代码精2 小时前
基于UniApp+Vue3+ThinkPHP 8,这套知识付费系统的架构设计有点东西
java·低代码·docker·uni-app·开源·php
-凌凌漆-2 小时前
【freertos】Task创建(v2)
java·开发语言·算法
牛油果子哥q2 小时前
C++内存模型与深浅拷贝万字详解:栈堆静态内存布局、深浅拷贝底层差异、内存泄漏根治、拷贝崩溃踩坑、手写深拷贝实战
java·开发语言·c++
带多刺的玫瑰4 小时前
Leecode#4刷题之寻找两个正序数组的中位数
java·前端·算法
阿无,5 小时前
布隆过滤器
java·算法·哈希算法