Docker 容器化部署:镜像分层与 Compose 编排

bee2026-10-0896 分钟0 次阅读
为 Spring Boot 应用写一个生产级 Dockerfile:多阶段构建、镜像瘦身、分层缓存优化;再用 Compose 一键拉起应用 + MySQL + Redis 的完整环境。
1 / 201
小节
〇、30 秒看懂
2 / 201

先说人话。容器不是「一台迷你电脑」,而是一个被单独关进小房间的 Java 进程:它有自己的一套文件系统视图、自己的网卡、自己的内存额度,但和宿主机共用同一个 Linux 内核。正因为不虚拟硬件、不装操作系统,它才能秒级启动、几十 MB 起步。而「镜像」就是那套打包好的文件与配置——同一份镜像在任何一台装了 Docker 的机器上,展开出来的东西都一模一样,这就是它治「在我机器上能跑」的办法。

3 / 201

先把六个词一句话解释(全文都会用到):

4 / 201
  • 镜像(Image):只读的文件层叠起来的模板,构建一次、到处复用;它是「交付物」,不是运行中的东西
  • 容器(Container):镜像跑起来的实例,等于在只读层顶上再盖一层可写的临时层;删掉容器,可写层就没了
  • 分层(Layer):Dockerfile 里每条 COPY / RUN 产生一层,层与层之间只认「我和我下面有没有变」
  • 构建缓存(Cache):某一层及其以下完全没变,这一层就不重跑,直接复用——这是 Dockerfile 写法的全部学问
  • cgroup:Linux 内核的资源限额开关,docker run -m 512m 靠它限制内存;JVM 要「感知容器」就是去读它
  • namespace:Linux 的视图隔离机制,让容器里的进程看不到宿主机的其他进程和网络
5 / 201
类比

镜像是精装房,容器是住进去的那户人家。开发商交给你的是同一个户型、同一套装修(镜像),你在哪座城市买到的都一样;但住户在自己屋里贴的海报、堆的外卖盒(容器可写层)退房时全部清空——想让东西留下来,就得另外租一个储藏室,那就是数据卷(Volume)。所以「我把文件写在容器里了」约等于「我把家当放在租来的房子里然后退了房」。

6 / 201
架构图
图 · 本篇地图:一次上线要过的四关
图 · 本篇地图:一次上线要过的四关
7 / 201

上图把整篇拆成四关:分清镜像与容器 → Dockerfile 怎么写才不吃缓存 → 跑起来之后 JVM 与容器的相处规则 → 编排与优雅停机。后面每一节都能在这张树上找到一个位置;卡壳时回来看一眼,就知道自己在第几关。

8 / 201

学完这一篇,你应该能回答三个问题:

9 / 201
  • 为什么改一行业务代码,我的镜像构建要重跑一遍依赖下载?Dockerfile 到底该怎么排指令?
  • 容器日志写着 Exit code (137)、Reason: OOMKilled,但 Java 日志里一行 OutOfMemoryError 都没有——到底谁杀了谁?
  • 我明明配了优雅停机,为什么 docker stop 还是在切请求?信号在哪一步被吞掉了?
10 / 201
小节
一、为什么要容器化:先解决"在我机器上能跑"
11 / 201

每个后端都听过这句话:「在我机器上能跑啊。」背后的本质是环境漂移——开发机是 JDK 21 加 MySQL 8.0,测试环境是 JDK 17 加 MySQL 5.7,生产又是另一套。容器化的第一步,就是把「应用 + 依赖 + 运行时」打包成一个不可变的交付物,让三个环境跑的是同一个东西。

12 / 201
对照表
维度物理机虚拟机容器(Docker)
隔离级别无硬件级虚拟化进程级(namespace + cgroup)
启动速度——分钟级秒级甚至毫秒
资源开销高(每台一套)高(每台一个完整 OS)低(共享宿主内核)
镜像体积——GB 级MB ~ 数百 MB
一致性差,靠人工对齐较好极好,镜像即环境
弹性伸缩慢慢快,拉起即用
13 / 201

容器之所以轻,是因为它不虚拟硬件、不装完整操作系统——所有容器共享宿主机的内核,只是用 namespace 做视图隔离、用 cgroup 做资源限制。这带来启动快、密度高的优点,代价是隔离性弱于虚拟机(内核漏洞可能被跨容器利用)。

14 / 201
说明

容器化不是银弹。它解决的是"环境一致性、依赖隔离、弹性伸缩"三件事;对"应用本身写得对不对"毫无帮助。别指望打个镜像就能解决内存泄漏。

15 / 201
小节
二、Docker 核心概念速通
16 / 201

先把四个最常混的概念分清:

17 / 201
对照表
概念一句话解释关键点
镜像(Image)只读的模板,分层叠加构建产物,不可变
容器(Container)镜像的运行实例在镜像上加一个可写层
仓库(Registry)存放镜像的地方Docker Hub、Harbor、云厂商
数据卷(Volume)绕过联合文件系统的持久化存储容器删除,数据仍在
18 / 201
小节
2.1 镜像分层:缓存为什么能命中
19 / 201

镜像不是一整块,而是一层层叠加的只读层。每一条 COPY、RUN、ADD 指令都会产生新的一层。Docker 构建时会逐层比对缓存:某一层及其所有下层都没变,这一层就直接复用缓存,不再执行。

20 / 201

这就解释了一个经典优化:为什么要把 pom.xml 复制提前。

21 / 201
dockerfile
# 反例:先拷全部源码,改一行代码,依赖安装那一层缓存全失效COPY . .RUN mvn dependency:go-offlineRUN mvn package -DskipTests
22 / 201
代码对照
代码dockerfile
# 正例:先只拷 pom 装依赖,改源码时依赖层依然命中缓存COPY pom.xml .RUN mvn dependency:go-offline     # 依赖不变 → 这一层永远命中COPY src ./src                    # 源码变化 → 只让后面的层失效RUN mvn package -DskipTests
解读
  • 反例里 COPY . . 一旦源码变动,它之后的所有层(依赖下载、打包)全部重新执行
  • 正例把「不常变的依赖声明」和「常变的源码」拆到两层,改代码只重跑打包,构建时间从几分钟降到几十秒
23 / 201
架构图
图 1 · 镜像分层的复用逻辑
图 1 · 镜像分层的复用逻辑
24 / 201
要点

分层的规则是「上下有依赖,上层变化不影响下层」。所以 Dockerfile 里指令的顺序,原则上应该越稳定的越靠上,越常变的越靠下。

25 / 201

这条规则光看文字容易记反,亲手跑一次缓存判定就忘不了。下面这个实验用默认的 layer 参数把「每条指令产生哪一层」「缓存凭什么判定」「构建输出里的 CACHED 是什么意思」逐帧摊开——重点盯第 ③ 帧(为什么 COPY pom.xml 要提前)和第 ⑥ 帧(分层提取省下了多少):

26 / 201
内核实验
TeaVM镜像分层与缓存命中未启动
默认参数 layer 就是这一题:改一行业务代码会波及到哪几层,看第 ②④ 帧的判定过程
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
27 / 201

缓存判定光看图还不够,最容易忘的是「一层的指纹由谁组成」。下面这台单步台把 builder 段的五行构建拆成逐帧执行:每按一步,看它到底重算了哪个指纹——第 ③ 步是整段的灵魂:

28 / 201
单步调试台
单步台改一行代码,缓存炸到哪一层1 / 5
从 ① 走到 ⑤:每一步都在回答「这一层的指纹是谁」,第 ③ 步告诉你为什么 COPY pom.xml 要单独一行
被调试的代码
1# builder 段的五行构建
2COPY pom.xml .
3RUN mvn -B dependency:go-offline
4COPY src ./src
5RUN mvn -B package -DskipTests
此刻的变量
改动无
缓存—
调用栈
—
1第一次构建:缓存里什么都没有,五步全部真实执行。缓存判定从这一层开始向后看。
29 / 201

记住这张指纹表再看 2.2 的 .dockerignore:如果 COPY src ./src 的输入被 target/ 这类垃圾污染,每一层都会变——缓存从此再没命中过。

30 / 201
小节
2.2 `.dockerignore`:被忽略的那部分也是成本
31 / 201

上下文(context)是先整体打包传给守护进程的,所以 target/、.git/、node_modules/ 这些目录哪怕最终没进镜像,也照样要上传一遍。根目录建一个 .dockerignore:

32 / 201
代码对照
代码text
target/.git/.idea/*.imlsrc/test/
解读
  • builder 阶段里的 ~/.m2(本地 Maven 仓库)默认每次重建都重新下载依赖;用 BuildKit 可以挂载复用:RUN --mount=type=cache,target=/root/.m2 mvn -B package
  • .git 留在 builder 层里不只是慢,还可能把历史信息带进交付物
  • 忘了排除 target/ 时最常见的连锁反应是:COPY . . 每次都变 → 所有上层缓存全失效,正好把 2.1 节的优化抵消干净
33 / 201
小节
三、第一个 Dockerfile:逐行读懂
34 / 201

先看一个能跑、但还不够生产级的基础版本:

35 / 201
代码对照
代码dockerfile
# 1. 基础镜像:只带 JRE,体积远小于 JDKFROM eclipse-temurin:21-jre# 2. 工作目录:后续 COPY / RUN / CMD 的相对根WORKDIR /app# 3. 把打好的 jar 拷进镜像COPY target/app.jar app.jar# 4. 时区,避免容器日志比本地少 8 小时ENV TZ=Asia/Shanghai# 5. 声明监听端口(仅文档作用,不发布端口)EXPOSE 8080# 6. 启动命令:exec 形式,PID 1 能收到 SIGTERMENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
解读
  • FROM:一切从基础镜像开始。用 jre 而不是 jdk,只带运行时,体积能省一两百 MB
  • WORKDIR:设定工作目录,等价于 cd,但更可靠——不存在会自动创建
  • COPY:把宿主机的文件复制进镜像,构建上下文内的路径要用相对路径
  • ENV:设置环境变量,TZ 影响日志时间戳,容器里默认 UTC 常导致时间对不上
  • EXPOSE:只是元数据声明,告诉使用者这个容器监听 8080,并不会真的开放端口(发布靠 -p)
  • ENTRYPOINT:用 exec 数组形式 而非 shell 形式,保证 Java 进程是 PID 1,能直接收到 docker stop 发的 SIGTERM,优雅停机才生效
36 / 201
小节
四、多阶段构建:从 700MB 瘦到 200MB
37 / 201

上面那个 Dockerfile 有个前提:宿主机得先 mvn package 打出 jar。但 CI 机器未必装了 Maven,就算装了,也不该把构建环境和运行环境混在一起。解法是多阶段构建——一个 Dockerfile 里写两段 FROM,前一段负责构建,后一段只拿产物。

38 / 201
代码对照
代码dockerfile
# ---------- 阶段一:构建(带 JDK + Maven,用完即弃) ----------FROM maven:3.9-eclipse-temurin-21 AS builderWORKDIR /build# 先只拷 pom.xml,依赖不变时这一层永远命中缓存COPY pom.xml .RUN mvn -B dependency:go-offline# 再拷源码,源码改动只会让下面的层失效COPY src ./srcRUN mvn -B clean package -DskipTests# ---------- 阶段二:运行(只带 JRE,没有 Maven、没有源码) ----------FROM eclipse-temurin:21-jreWORKDIR /appCOPY --from=builder /build/target/*.jar app.jarENV TZ=Asia/ShanghaiEXPOSE 8080ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
解读
  • AS builder 给阶段起名,方便后面 COPY --from=builder 引用
  • 最终镜像只包含第二阶段——Maven、JDK、源码全被丢弃,这是体积骤降的关键
  • COPY --from=builder 只把需要的产物(jar)从构建阶段搬过来
39 / 201
对照表
方案基础镜像大致体积说明
直接打 JDK + 源码镜像maven + jdk~700MB+把构建工具也带进生产,浪费且不安全
多阶段构建仅 jre~200MB只留运行时,推荐
40 / 201
小节
4.1 分层提取:让依赖层也命中缓存
41 / 201

多阶段构建解决了体积,但还有一个缓存问题:每次改一行业务代码,整个 app.jar 变了,COPY app.jar 这一层就失效,依赖层跟着重来。Spring Boot 提供了分层提取,把 fat jar 拆成独立的层分别 COPY:

42 / 201
xml
<plugin>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-maven-plugin</artifactId>    <configuration>        <layers>            <enabled>true</enabled>        </layers>    </configuration></plugin>
43 / 201
代码对照
代码dockerfile
# 构建阶段:把 jar 拆成分层目录RUN java -Djarmode=layertools -jar /build/target/app.jar extract# 运行阶段:逐层 COPY —— 依赖层不变就命中缓存COPY --from=builder /build/dependencies/ ./COPY --from=builder /build/spring-boot-loader/ ./COPY --from=builder /build/snapshot-dependencies/ ./COPY --from=builder /build/application/ ./ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
解读
  • layertools extract 把 jar 拆成 dependencies/(稳定)、application/(常变)等目录
  • 稳定层在上、应用层在下,依赖没变时只有 application/ 那层失效,镜像重建几乎瞬间完成
  • 入口改成 JarLauncher(对应上一篇讲的可执行 jar 启动器),让它从分层目录加载
44 / 201

前面四节讲的其实是一条流水线上的三段:先打出能跑的 jar,再把 jar 切成层搬进镜像,最后起容器发布端口。新手最容易断在「jar 和镜像到底谁装了谁」这一环,所以把整条路按顺序走一遍:

45 / 201
原理动画
动图 · 从一个 jar 到一个能对外服务的容器
动图 · 从一个 jar 到一个能对外服务的容器
46 / 201

第六步特别说明一下:-p 8080:8080 和 /actuator/health/readiness 属于两个不同的责任——前者是 Docker 干的(把宿主端口映射进容器),后者是应用自己得答对(回答「我现在能不能接流量」)。这一篇先把 Docker 这半边做扎实,探针那半边见第 42 篇。

47 / 201

而第一、二步的前提是:你确实知道自己打进镜像的那个 jar 长什么样。上一篇文章拆开过可执行 jar 的内部结构——Main-Class 是 JarLauncher 而不是你的启动类,依赖躺在 BOOT-INF/lib 里由自定义类加载器读取。做容器化之前先用这个实验把这层确认一遍,尤其是 war 那一档:等你换成 war 部署外部 Tomcat 时,COPY target/*.jar 这条指令会直接找不到文件。

48 / 201
内核实验
TeaVM打进镜像的那个 jar 里到底有什么未启动
先看 layout 认清 BOOT-INF 目录,再切 run 看 java -jar 的启动序列;war 分支解释为什么 war 不能这么 COPY
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
49 / 201
小节
五、常用命令速查
50 / 201
对照表
命令作用常用参数
docker build -t app:1.0 .构建镜像-t 打标签,. 是构建上下文
docker run -d app:1.0运行容器-d 后台、-p 端口、-e 环境变量、-v 挂载
docker ps -a列出容器-a 含已停止
docker logs -f <id>看容器日志-f 跟随输出
docker exec -it <id> sh进容器交互-it 分配终端
docker stop <id>停止(发 SIGTERM,优雅停机)docker kill 才是直接杀
docker rm <id>删除容器-f 强制
docker images / docker rmi列出 / 删除镜像——
docker system prune -a清理无用镜像与缓存谨慎使用,会删未使用资源
51 / 201
坑

docker stop 默认只等 10 秒就发 SIGKILL。如果你的应用优雅停机要 30 秒,务必 docker stop -t 30 <id>,否则停机没走完就被强杀,又回到了"腰斩请求"的老问题。

52 / 201
小节
六、容器网络与数据卷
53 / 201

容器默认跑在 bridge 网络里,每个容器有独立 IP,容器内的 localhost 指容器自己,不是宿主机。同一自定义网络内,容器可以直接用服务名互相访问:

54 / 201
代码对照
代码bash
# 创建自定义网络docker network create bee-net# 起一个 MySQL,命名 mysql,加入网络docker run -d --name mysql --network bee-net \  -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=bee \  -v mysql-data:/var/lib/mysql \  mysql:8.0# 起应用,用服务名 mysql 连接(而不是 localhost)docker run -d --name bee-app --network bee-net \  -p 8080:8080 \  -e SPRING_PROFILES_ACTIVE=prod \  -e DB_HOST=mysql \  -v "$(pwd)/logs:/app/logs" \  bee-app:1.0.0
解读
  • --network bee-net 让两个容器进入同一网络,可用名字互访
  • -e DB_HOST=mysql:应用连接字符串里写 mysql,Docker 内置 DNS 会解析到那个容器
  • -v mysql-data:/var/lib/mysql:命名卷持久化数据,容器删了数据还在
  • -v "$(pwd)/logs:/app/logs":绑定挂载,把容器日志目录映射到宿主机,方便查看
55 / 201
小节
七、Docker Compose 编排:一键拉起整套环境
56 / 201

手敲一堆 docker run 很快就会失控。Compose 用一份声明式 YAML 描述整套环境,docker compose up 一键拉起:

57 / 201
yaml
services:  mysql:    image: mysql:8.0    environment:      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root}      MYSQL_DATABASE: bee    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci    volumes:      - mysql-data:/var/lib/mysql      - ./sql:/docker-entrypoint-initdb.d   # 首次启动自动执行初始化脚本    ports:      - "3306:3306"    healthcheck:      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]      interval: 10s      timeout: 5s      retries: 10  redis:    image: redis:7-alpine    command: redis-server --appendonly yes    volumes:      - redis-data:/data    healthcheck:      test: ["CMD", "redis-cli", "ping"]      interval: 10s      retries: 5  app:    build: .                                 # 用当前目录的 Dockerfile 构建    image: bee-app:1.0.0    depends_on:      mysql:        condition: service_healthy           # 等 MySQL 健康检查通过再启动      redis:        condition: service_healthy    environment:      TZ: Asia/Shanghai      SPRING_PROFILES_ACTIVE: prod      DB_HOST: mysql      DB_PORT: 3306      DB_NAME: bee      REDIS_HOST: redis    ports:      - "8080:8080"volumes:  mysql-data:  redis-data:
58 / 201

逐段解读:

59 / 201
  • 服务依赖与启动顺序:depends_on 默认只保证"启动顺序",不保证"就绪顺序"。加上 condition: service_healthy,才会等健康检查通过——这是避免"应用先起来、数据库还没准备好"的关键
  • 健康检查:healthcheck.test 是探活命令,MySQL 用 mysqladmin ping、Redis 用 redis-cli ping。depends_on 的 service_healthy 依赖它的结果
  • 环境变量注入数据库配置:应用不写死连接串,而是读 DB_HOST / DB_NAME,由 Compose 注入。${MYSQL_ROOT_PASSWORD:-root} 表示"取环境变量,缺省用 root"
  • 数据卷:mysql-data、redis-data 是命名卷,声明在顶层 volumes: 下,容器重建数据不丢
  • 构建与镜像:build: . 让 Compose 现场构建,image: 给它起名字便于复用
60 / 201
原理动画
动图 · Compose 一键启动的时序
动图 · Compose 一键启动的时序
61 / 201

Compose 的 healthcheck 让编排系统知道容器「能不能接活」,而容器里跑的 Spring Boot 应用也得自己回答这个问题。第 42 篇讲过 Actuator 的健康端点,这里补上它在容器视角的样子——健康检查不是一次探活,而是多个检查项投票之后的最坏结果:

62 / 201
内核实验
TeaVM容器里的健康检查:谁在往里投票未启动
切到 health 参数:看摘要怎么把数据库、Redis、磁盘三个检查项聚合出一个总状态;第 ③ 帧就是「一个 DOWN 全场 DOWN」的判定现场
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
63 / 201
  • 数据库通、Redis 通 → UP;任何一项不通 → 整条链路 DOWN,K8s 的 readiness 据此把你摘出负载均衡
  • 所以健康检查里别挂业务查询:一个偶发的慢 SQL 会把「进程活着」误报成「实例不可用」
  • 想知道容器里到底是哪一项在拉低状态,打开 management.endpoint.health.show-details=always 逐项看
64 / 201
小节
八、应用改造:让程序适配容器
65 / 201

容器化不只是写 Dockerfile,应用本身也要做两处配合。

66 / 201
小节
8.1 配置读环境变量
67 / 201

不要在 application.yml 里写死 localhost,用带默认值的占位符:

68 / 201
代码对照
代码yaml
spring:  datasource:    url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:bee}?useSSL=false&serverTimezone=Asia/Shanghai    username: ${DB_USER:root}    password: ${DB_PASSWORD:root}  data:    redis:      host: ${REDIS_HOST:localhost}      port: ${REDIS_PORT:6379}
解读
  • ${DB_HOST:localhost} 的语法是「取环境变量 DB_HOST,取不到就用 localhost」
  • 本地开发不设环境变量,走默认值连本机;容器里由 Compose 注入 DB_HOST=mysql
  • 同一份代码、同一个 jar,靠环境变量适配不同环境,这正是十二要素应用(12-Factor)的第三条:配置存于环境
69 / 201
小节
8.2 日志输出到 stdout
70 / 201

容器哲学里有一条:进程只写标准输出,日志的去向交给平台。所以生产配置应该只往控制台打,而不是写文件:

71 / 201
代码对照
代码yaml
logging:  file:    name: ""            # 清空文件输出,只保留 console  pattern:    console: "%d{HH:mm:ss.SSS} %-5level [%X{traceId}] %logger{36} - %msg%n"
解读
  • 容器里写文件有致命缺陷:容器一销毁,文件就没了;写宿主机还得挂卷,多实例时又难聚合
  • 输出到 stdout,docker logs 能直接看,日志驱动或 ELK/Fluentd 能统一采集
  • 这正好衔接上一篇讲的日志体系——JSON 到 stdout 给机器,人类可读留给开发
72 / 201
小节
8.3 让 JVM 认识容器的内存限额
73 / 201

这是容器化里最容易「平时不出事、一扩容就炸」的一处。JVM 判断该开多大堆时,默认看的是整台机器的物理内存,而不是分给它的那份配额。一个跑在 512m 容器里的进程如果按宿主 32GB 去算默认堆,就会一路冲到 cgroup 上限被内核直接抹掉。Java 8u191 之后 UseContainerSupport 默认打开,JVM 会去读 cgroup 的限额文件(v1 是 memory.limit_in_bytes,v2 是 memory.max),但它读的是限额,不是「你的堆」。

74 / 201

正确写法是按百分比声明,而不是写死 -Xmx:

75 / 201
代码对照
代码dockerfile
FROM eclipse-temurin:21-jre-jammyWORKDIR /appCOPY --from=builder /build/target/app.jar app.jar# 堆按容器限额的 75% 走;元空间与线程栈单独封顶ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 \      -XX:InitialRAMPercentage=75.0 \      -XX:MaxMetaspaceSize=256m \      -Xss512k"ENTRYPOINT ["java", "-jar", "app.jar"]
解读
  • JAVA_TOOL_OPTIONS 比写死在 ENTRYPOINT 里更好:运维可以在不改镜像的前提下用 -e 覆盖
  • MaxRAMPercentage 剩下的那 25% 不是浪费,是要留给堆外的:Metaspace(类元数据)、每个线程约 1MB 的栈、CodeCache(JIT 编译出的机器码)、DirectByteBuffer(NIO 与 Netty 用的直接内存)
  • 一条常被忘的加法:Tomcat 默认 200 个工作线程,光线程栈就近 200MB。这也是高并发容器里常把 -Xss 压到 512k 的原因
  • 想知道 JVM 到底看到多少内存,直接在容器里问它:
76 / 201
bash
docker exec bee-app java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|InitialHeapSize'
77 / 201

两种失败模式必须分清,因为它们的排查方向完全相反:

78 / 201
对照表
OutOfMemoryError: Java heap spaceOOMKilled / Exit code (137)
谁发起的JVM 自己(堆装不下了)cgroup / 内核 / K8s(总用量超限额)
Java 侧有没有异常有,日志里能看到完整栈没有,日志戛然而止
能不能留现场能配 -XX:+HeapDumpOnOutOfMemoryError 抓 hprof什么现场都不留
调什么的查泄漏、调堆比例算总账:堆 + 堆外一起看
79 / 201

两种失败的分界,其实就落在「给堆留多少百分比」这一个数字上。512MB 的容器里把 -XX:MaxRAMPercentage 从 25 拖到 100,每一步都能看到堆、非堆、总账三者怎么分摊——注意右端那一档,它等价于写死 -Xmx512m:

80 / 201
参数调节台
调节台512MB 容器里,堆该占多少比例
-XX:MaxRAMPercentage
75%当前 25 – 100
常见落点:四分之三给堆
  • 堆 384MB,Metaspace + 200 条线程栈 + CodeCache 约 130MB,总账余量 40MB 左右
  • 启动正常、GC 频率可接受——这就是上面示例里 75% 的来历
  • 前提:Metaspace 与 -Xss 分别封顶,别让非堆无界生长
堆75%
非堆25%
记法:百分比不是「堆的标准答案」,而是「给非堆留预算」的写法——剩下的那 25% 不是浪费,是 Metaspace、线程栈与直接内存的房租。
81 / 201
类比

行李箱限重。航空公司说「这件箱子最多 23 公斤」,那是整箱的重量——衣服、锁扣、轮子、里面的充电宝全算。你把衣服塞到 23 公斤(把 -Xmx 设成等于容器限额),到柜台一称必然超重,地勤直接开箱都没开就把你拦下(OOMKilled);而「衣服太多了穿不进箱子」(堆内 OutOfMemoryError)是你自己在家里就发现的,还能摊开看看是哪件出问题。前者是外部处决,后者是内部报错——症状、修法、能不能留证据,全都不一样。

82 / 201
架构图
图 · 写死 -Xmx512m vs 按比例跟随限额
图 · 写死 -Xmx512m vs 按比例跟随限额
83 / 201

左边那一列解释了为什么「加大内存限额就好了」经常只是掩盖问题:根因可能在 Netty 的直接内存没释放,而堆外压根不受 -Xmx 管辖。这个实验把「JVM 读到多少 → 默认堆怎么算出来 → 两种失败各自长什么样」一次走完:

84 / 201
内核实验
TeaVM容器给多少内存,JVM 就看到多少未启动
切到 mem 参数:重点看第 ⑤⑥ 两帧——同一个「内存不够」,一种有 Java 异常,一种只有 exit 137
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
85 / 201
小节
九、镜像优化清单
86 / 201
对照表
优化项做法收益
基础镜像选 jre-slim / alpine 变体体积从数百 MB 降到 ~200MB
多阶段构建构建与运行分离丢掉 Maven/JDK/源码
非 root 运行USER app降低容器逃逸风险
.dockerignore排除 target/、.git/、node_modules/减小构建上下文,加速构建
合并 RUN 层RUN a && b && c减少镜像层数
分层 COPYlayertools + 按层复制提升缓存命中
固定基础镜像版本用 :21-jre 而非 :latest构建可复现
87 / 201
提示

alpine 镜像体积小,但它用 musl libc 而非 glibc,某些依赖本地库的组件(如部分 JDK 特性、JNI 库)会不兼容。稳妥起见,Spring Boot 应用优先选 eclipse-temurin:21-jre-jammy 这类 slim 变体,而不是无脑上 alpine。

88 / 201

清单读完了,把它变成你家项目的行为——下面这台生成器把「生产级 Dockerfile 该有哪些块」做成勾选项。先只勾「多阶段 + 分层提取」看最小骨架,再逐项叠加非 root、内存比例、健康检查与停机信号,对照上面表格里各自的收益:

89 / 201
生成器
生成器勾出你的生产级 DockerfileDockerfile3 / 8
先只勾 multistage + layered 得到骨架;nonroot 换成非 root 用户;maxram 写入按比例的内存参数;healthcheck 与 stopsignal 是上线前最后两块——对照本节表格逐项验收,缺哪一项,第十节的速查表里就对应哪一行报错
产物
# ---------- 构建阶段:要完整 JDK 与 Maven ----------
FROM maven:17-eclipse-temurin AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -B dependency:go-offline        # 先只拷 pom,依赖层可被缓存
COPY src ./src
RUN mvn -B -DskipTests package \
    && java -Djarmode=layertools -jar target/*.jar extract --destination /app

# ---------- 运行阶段:只要 JRE ----------
FROM eclipse-temurin:17-jre
WORKDIR /app
# 变化频率从低到高排列,改代码不会让依赖层缓存失效
COPY --from=build /app/dependencies/ ./
COPY --from=build /app/spring-boot-loader/ ./
COPY --from=build /app/snapshot-jar/ ./
COPY --from=build /app/application/ ./

ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
ENV SPRING_PROFILES_ACTIVE="prod"

EXPOSE 8080 9090
# exec 形式:java 就是 PID 1,SIGTERM 能送达(shell 形式做不到)
ENTRYPOINT ["java", "-jar", "app.jar"]

# docker build -t demo-service:0.0.1 .
# docker run --rm -p 8080:8080 -m 512m demo-service:0.0.1
勾了这些,代价与理由在这里
多阶段构建构建阶段要 JDK + Maven,运行阶段只要 JRE,镜像从 700MB 降到 200MB 量级。
分层解包Spring Boot 插件产出 layers.idx,把依赖与业务代码分开,改一行代码不必重推 200MB。
-XX:MaxRAMPercentage 而不是 -Xmx 写死容器内存改成一档,堆就跟着改;写死 Xmx 是 OOMKilled(exit 137)的经典成因。
90 / 201
说明

生成器里的 healthcheck 与 stopsignal 是两块「平时看不出、出事才后悔」的配置——前者决定编排系统能不能发现容器僵死,后者决定 docker stop 时应用有没有机会排空;与第十三节的探针分工一起看。

91 / 201
小节
十、四个高频坑
92 / 201
小节
10.1 容器里的 localhost 不是宿主机
93 / 201

最常见的误解:以为容器里 localhost:3306 能连到宿主机的 MySQL。错——容器的网络 namespace 是独立的,localhost 指容器自己。要么用宿主机 IP(Linux 上是 172.17.0.1),要么干脆把数据库也放进 Compose,用服务名互访(推荐后者)。

94 / 201
小节
10.2 EXPOSE 与 -p 的区别
95 / 201

EXPOSE 8080 只是声明,不会让外部访问到。真正发布端口的是 docker run -p 8080:8080(或 Compose 的 ports)。少写 -p,容器跑得好好的,浏览器却死活连不上。

96 / 201
小节
10.3 时区差 8 小时
97 / 201

容器默认 UTC,日志时间戳和数据库时间都会比北京时间少 8 小时。解决方案:Dockerfile 里加 ENV TZ=Asia/Shanghai,或运行时 -e TZ=Asia/Shanghai,并在连接串里带 serverTimezone=Asia/Shanghai。

98 / 201
小节
10.4 随机数熵不足导致启动慢
99 / 201

某些镜像(尤其精简版)熵池小,SecureRandom 初始化时阻塞读 /dev/random,表现为应用卡在启动、Tomcat 迟迟不监听端口。Linux 内核 5.6+ 后 /dev/random 不再阻塞,老内核可挂载 /dev/urandom 或加 -Djava.security.egd=file:/dev/./urandom 绕过。

100 / 201
小节
10.5 `exec format error`:镜像架构和你的机器对不上
101 / 201

在 Apple Silicon(M 系列)Mac 上跑一个只为 x86 构建的镜像,或者把本地构建的 arm64 镜像推到 amd64 服务器上,就会看到这条:

102 / 201
text
standard_init_linux.go:228: exec user process caused: exec format error
103 / 201

它跟 Java 一点关系都没有——是 Docker 在尝试执行 ENTRYPOINT 那个二进制时失败了:镜像里打包的是 linux/arm64 的 java,宿主内核是 linux/amd64,指令集不同,加载器直接拒绝。三条自救路径:

104 / 201
  • 构建时显式声明平台:docker buildx build --platform linux/amd64 -t bee-app:1.0 .
  • 运行时声明(仅在有 qemu 模拟的机器上有效,慢且不稳):docker run --platform linux/amd64 bee-app:1.0
  • CI 里一次产出两种架构:docker buildx bake 或多条 --platform linux/amd64,linux/arm64 配合 manifest 推送
105 / 201

排查只有一条命令就够,看镜像自己的架构而不是你的机器的:

106 / 201
代码对照
代码bash
docker inspect --format '{{.Os}}/{{.Architecture}}' bee-app:1.0
解读

坑:docker pull 默认会按你本机架构去拉镜像,所以「我本地能跑、CI 报 exec format error」经常不是代码问题,而是同一标签下两种架构的镜像内容并不相同。生产镜像请锁死平台,别依赖默认行为。

107 / 201
小节
10.6 配了优雅停机,`docker stop` 却还在切请求
108 / 201

第 36 篇讲过优雅停机的六个动作,前提是JVM 真的收到了那个信号。容器里最容易断的就是这一环。

109 / 201
原理动画
动图 · docker stop 之后,JVM 到底听见了什么
动图 · docker stop 之后,JVM 到底听见了什么
110 / 201

关键在第 ②③ 两帧的分岔:

111 / 201
  • exec(JSON 数组)形式:ENTRYPOINT ["java","-jar","app.jar"] —— java 自己就是 PID 1,SIGTERM 直达 JVM,关闭钩子照常跑
  • shell 形式:ENTRYPOINT java -jar app.jar —— 容器实际启动的是 /bin/sh -c "java -jar app.jar",站在 1 号位上的是 shell;/bin/sh 默认不转发收到的信号,于是你所有优雅停机配置一句都没执行,10 秒后被 SIGKILL 硬杀
112 / 201

症状非常固定:日志里一行 shutdown 相关的话都没有,但容器确实被正常停了。修法三选一——改用 exec 形式;或保留 shell 形式但写成 ENTRYPOINT exec java -jar app.jar;或用 tini / dumb-init 当 1 号进程代管信号与僵尸回收。

113 / 201

还要两边对齐:Spring 侧的 server.shutdown=graceful + spring.lifecycle.timeout-per-shutdown-phase 若配了 30 秒,而 Docker 侧 docker stop 默认宽限期只有 10 秒,那后 20 秒的请求照样是被硬切的。

114 / 201
yaml
server:  shutdown: gracefulspring:  lifecycle:    timeout-per-shutdown-phase: 20s
115 / 201

验证方法很土但最有效:一边 docker stop -t 30 bee-app,一边 docker logs -f bee-app,看得见 Closing org.springframework... 才算通。这个实验把信号链路完整走一遍,重点看第 ②③ 两帧的分岔:

116 / 201
内核实验
TeaVMSIGTERM 到底有没有走到 JVM未启动
切到 signal 参数:对照 ②③ 两帧看清 exec 与 shell 形式的区别,再看 ⑥ 为什么宽限期必须两边对齐
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
117 / 201
小节
十一、交互演示:条件装配与环境变量
118 / 201

容器环境变量和应用里的条件装配,本质是同一件事:同一份代码,在不同环境装配出不同结果。这个演示把 IoC 容器的条件开关跑一遍——就像切换容器环境变量一样。

119 / 201
内核实验
120 / 201
  • 拨动 @ConditionalOnProperty 这类开关,观察容器里注册的 Bean 如何变化
  • 容器环境变量(如 DB_HOST)通过 Spring 的 @ConfigurationProperties 绑定,最终影响装配
  • 理解这一点,就理解了"一个镜像跑多环境"的底层机制
121 / 201

不过这一篇的主角不是应用内部装配,而是镜像本身。第二节那条「上层变化不影响下层」的规则,只有亲手看一次缓存判定才记得住——上面第 2.1 节已经用 layer 参数走过一遍,这里补一个视角:同一份代码,环境不同、装配结果不同。下面这个实验把条件注解的求值过程摊开,它对应的就是「容器里注入什么环境变量,应用就长成什么样子」:

122 / 201
内核实验
TeaVM配置与 Profile 决定装配结果未启动
依次切 onclass / onbean / onprop / missing,最后看 report——onprop 那一格就是容器 -e 覆盖生效的地方
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
123 / 201

再切到 build 参数看构建阶段的卫生问题(.dockerignore、builder 层的 ~/.m2、基础镜像选型),它对应第四节的取舍:

124 / 201
内核实验
TeaVM多阶段构建与镜像卫生未启动
切到 build 参数:第 ② 帧解释为什么 .dockerignore 要第一个写,第 ④ 帧是 alpine/musl 那个 UnknownHostException 的来源
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
125 / 201

镜像、容器与应用三件事讲完了,验证手段还剩最后一层:命令行。下面这台控制台连着浏览器里的同一个 Java 内核——先 whoami 看当前进程,再逐条 lab 把本节的四个实验敲一遍,回显全部由内核真实执行:

126 / 201
内核控制台
127 / 201
提示

lab dockerimg mem 和 lab dockerimg signal 建议连着敲——第一个回答「JVM 看到多少内存」,第二个回答「SIGTERM 有没有走到它」,8.3 与 10.6 两节的主要结论都在这些回显里。

128 / 201
小节
十二、生产思辨:docker run 还是 K8s
129 / 201
决策
决策应用已经容器化,要上生产。单机几台服务器,团队规模不大,未来可能扩到多集群。该用 `docker compose` 编排还是直接上 K8s?
130 / 201
小节
十三、镜像做对了,还要会「下线」
131 / 201

上面所有功夫最后都要落在一次真实的滚动发布上:新容器顶上、老容器退场,而用户不该感觉到中间那次抖动。老容器退场的完整动作不是「杀掉进程」,而是三件事按顺序发生——先被摘掉流量、再把在途请求处理完、最后才退出。这三步里 Docker 只负责第一步的一半,剩下的都在应用和编排层。

132 / 201

第 42 篇讲过 Actuator 的两个探针端点,语义完全不同,混用会造成「数据库抖一下,全体实例被重启」:

133 / 201
  • /actuator/health/liveness 回答「这个进程还有救吗」——只该判断进程自身,配错了会导致反复重启
  • /actuator/health/readiness 回答「现在能不能给我流量」——依赖不健康时应该由它摘流量,而不是杀进程
134 / 201
内核实验
TeaVM上线与优雅停机:摘流量 → 排空 → 退出未启动
依次切 ready / drain / kill 三个参数:ready 看两个探针的分工,drain 看在途请求怎么被等完,kill 看硬杀时那几条没打完的 SQL 去了哪
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
135 / 201
  • 宽限期要对齐:K8s 的 terminationGracePeriodSeconds(默认 30 秒)必须大于 Spring 侧 spring.lifecycle.timeout-per-shutdown-phase,否则你等到一半就被 SIGKILL
  • preStop 那个 sleep 不是玄学:Endpoint 更新有传播延迟,收到 SIGTERM 的一瞬间旧实例仍可能被打进新请求,所以惯例是先 sleep 2~5 再开始收尾
  • docker stop -t 同理:本地或 Compose 环境下没有 preStop,唯一能调的就是 -t;第五节那条坑说的就是这件事
136 / 201
类比

容器下线像电影院打烊。关灯是「不再卖票」(readiness 变红、停止接新请求),放映厅里正在看的观众要看完这一段才离场(在途请求排空),广播通知得先递到领班手里才有用(信号走到 PID 1)。十分钟还不走,保安直接清人(SIGKILL)——爆米花就打翻了。

137 / 201
小节
十四、常见报错速查
138 / 201

下面每一行的「报错原文」都能整段复制去搜索,别意译、别缩写。新手在这三处最容易卡住:内存被外部处决、端口连不上、构建每次都重来。

139 / 201
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
Exit code (137),且 Java 日志里没有一行 OutOfMemoryError137 = 128 + 9,即被 SIGKILL;是 cgroup 因总用量(堆 + 元空间 + 线程栈 + 直接内存)超限额而杀进程,不是堆不够先确认 JVM 看到多少限额:`docker exec <id> java -XX:+PrintFlagsFinal -version \grep MaxHeapSize;把 -Xmx 换成 -XX:MaxRAMPercentage=75.0,再用 -XX:NativeMemoryTracking=summary + jcmd <pid> VM.native_memory` 看堆外本篇 8.3 节 · #36 JVM 参数
exec /entrypoint.sh: no such file or directory,或容器一起来就退出、docker logs 只有这一行脚本在 Windows 上编辑过,存成 CRLF 换行;Linux 内核读到的解释器是 /bin/sh\r,这个文件确实不存在。ENTRYPOINT ["sh","entrypoint.sh"] 不会暴露它(参数由 sh 解析),但 exec 形式或 RUN chmod +x 直接执行就会炸在宿主机跑 file entrypoint.sh(会显示 with CRLF line terminators)或 `cat -A entrypoint.sh \grep -c '\^M\$';修法是 dos2unix,或加 .gitattributes 里 *.sh text eol=lf,或干脆在 Dockerfile 里 RUN sed -i 's/\r$//' entrypoint.sh`本篇第十节 · #46 交付清单
OCI runtime create failed: ... exec /entrypoint.sh: no such file or directory 或 standard_init_linux.go:228: exec user process caused: ...上一行那个问题的容器版,只是由 runc 报出来;同一批诱因还有:脚本没打上可执行位(COPY 保留源文件权限)、ENTRYPOINT 写成了相对路径而 WORKDIR 又变了、基础镜像换了架构先 docker run --rm --entrypoint sh <image> -c 'ls -l; head -1 entrypoint.sh' 看文件到底在不在、权限是不是 -rwxr-xr-x;再按上一行处理 CRLF;权限问题用 RUN chmod +x 补本篇第十节
Bind for 0.0.0.0:8080 failed: port is already allocated宿主机的 8080 已经被别人占了(另一个容器、上一轮没退干净的进程、本机开发服务)。注意报错说的是宿主机端口,容器内 8080 本身没问题docker ps --format '{{.Names}}\t{{.Ports}}' 看谁占着;开发环境换个宿主端口 -p 8081:8080;生产上正解是让编排系统分配,不要写死 container_port 之外的宿主端口本篇第六、七节
java.lang.OutOfMemoryError: Java heap space堆内真的装不下了:集合无界增长、一次查出百万行、缓存没设上限加 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp 复现一次,抓 hprof 用 MAT 看支配树;这类失败不会留 exit 137本篇 8.3 节 · #28 连接池与内存
日志里一行 Received SIGTERM, JVM shutdown is in progress,请求在停机瞬间被切断信号确实走到了 JVM(第 10.6 节那半段是通的),但排空没排完就被 SIGKILL 打断:Spring 侧阶段超时大于 Docker 宽限期,或还有长查询/长连接占着把两边对齐:docker stop -t 30 <id>,同时把 spring.lifecycle.timeout-per-shutdown-phase 调到比它小(比如 20s);并确认在途请求里没有跑几分钟的 SQL本篇 10.6 节 · 第十三节 · #36 优雅停机
docker stop 之后日志里一句 shutdown 都没有,请求被硬切shell 形式的 ENTRYPOINT 让 /bin/sh 站在 PID 1 上,它默认不转发 SIGTERM改成 exec 数组形式 ["java","-jar","app.jar"],或在 shell 形式里写 exec java -jar …,或用 tini;同时确认 server.shutdown=graceful 已打开本篇 10.6 节 · #36 优雅停机
standard_init_linux.go:228: exec user process caused: exec format error镜像架构与宿主指令集不匹配(arm64 镜像跑在 amd64 机器上,或反之),跟 Java 无关docker inspect --format '{{.Os}}/{{.Architecture}}' <image> 看清镜像身份;构建时锁死 --platform linux/amd64本篇 10.5 节
改一行业务代码,mvn dependency:go-offline 那一层又重跑了一遍COPY . . 放在依赖安装之前,源码一变这层及其以上全部失效拆成两步:COPY pom.xml . → RUN mvn -B dependency:go-offline → COPY src ./src → RUN mvn package;配合 .dockerignore 排除 target/、.git/本篇 2.1 节 · 上方 layer 实验
容器起得好好的,浏览器死活连不上只写了 EXPOSE 8080(纯声明),忘了 -p 8080:8080 或 Compose 的 portsdocker ps 看 PORTS 列有没有 0.0.0.0:8080->8080/tcp;没有就是没发布本篇 10.2 节
容器里连宿主机上的 MySQL 报 Communications link failure容器里的 localhost 指容器自己,不是宿主机Linux 上用 172.17.0.1,Mac/Windows 用 host.docker.internal,正规解法是数据库也进 Compose、用服务名互访本篇 10.1 节 · 第六节
Alpine 基础镜像下偶发 UnknownHostException: redis,但 nslookup redis 正常musl libc 的 getaddrinfo 对 IPv6/短 TTL 的处理与 glibc 不同,精简镜像更容易复现先换回 eclipse-temurin:21-jre-jammy 复现,确认与 libc 有关再决定要不要继续用 alpine本篇第九节提示条 · 上方 build 实验
应用卡在启动、Tomcat 迟迟不监听端口,日志停在随机数相关一行精简镜像熵池小,SecureRandom 阻塞读 /dev/random老内核加 -Djava.security.egd=file:/dev/./urandom;Linux 5.6+ 后 /dev/random 已不阻塞本篇 10.4 节
140 / 201
提示

这一张表里有三条(137、信号被吞、alpine DNS)都不会以「Java 异常」的形式出现,症状是日志戛然而止。排查容器问题时先看 docker inspect <id> --format '{{.State.ExitCode}} {{.State.OOMKilled}}',一眼就能分清「谁杀的」。

141 / 201

上面那张速查表里,java.lang.OutOfMemoryError: Java heap space 和第一行的 137 长得很像,现场却完全不同。下面这段是真实堆栈,先别看答案——点出你认为的凶手行,再对照两种失败的分界:

142 / 201
报错急救
报错急救java.lang.OutOfMemoryError: Java heap space
同样是「内存不够」,这次的现场留下了完整堆栈

报表服务把一个月的订单明细全量导出(SELECT * 查了几十万行),高峰期容器被 OOMKilled 重启过两次;这次日志里留下了完整的 Java 异常——注意它与 Exit Code 137 的区别。

java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.Arrays.copyOf(Arrays.java:3537)
at java.base/java.io.ByteArrayOutputStream.grow(ByteArrayOutputStream.java:123)
at com.bee.order.report.ExcelWriter.writeRows(ExcelWriter.java:52)
at com.bee.order.report.ReportService.exportAll(ReportService.java:88)
at com.bee.order.web.ReportController.export(ReportController.java:31)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
143 / 201
小节
十五、随堂自测
144 / 201

先来一道热身题,考的是第二节的缓存规则:

145 / 201
随堂自测
随堂自测一个多阶段 Dockerfile 里依次写着:`COPY pom.xml .` → `RUN mvn dependency:go-offline` → `COPY src ./src` → `RUN mvn package`。现在只改了 `src/main/java` 里一行业务代码,重新 build,哪些层会重跑?
先自己选一个,选中立刻告诉你对不对
146 / 201

再来一道综合题,把 8.3、10.6 和第十三节串起来:

147 / 201
随堂自测
随堂自测生产上一个 512MB 限额的容器反复重启,`kubectl describe pod` 显示 `Reason: OOMKilled`、Exit Code 137,而应用日志里没有任何 `OutOfMemoryError`,最后一行还是正常的业务 INFO。下列哪种做法**最不能**解决根因?
先自己选一个,选中立刻告诉你对不对
148 / 201
小节
十六、沙盘:这个容器该给多少内存
149 / 201

「把限额调大就好了」是容器排障里最贵的一句安慰——它让症状消失,却让你永远不知道是谁在吃内存。下面这个沙盘把堆比例和容器限额做成两个开关,切一档就能同屏看到默认堆算出多大、总用量落在哪、以及最终以什么姿势失败:

150 / 201
沙盘
沙盘堆比例 × 容器限额:谁先撑不住
运行结果
-Xmx512m → MaxHeapSize = 512MB(等于整个限额)
# Metaspace 180MB + 200 线程栈 ≈ 200MB + CodeCache 64MB
RSS 冲到 519MB,cgroup 上限 512MB
Reason: OOMKilled · Exit Code 137 · Java 日志无 OutOfMemoryError
最典型的翻车姿势:堆按限额写满,堆外没留位置。
151 / 201
说明

沙盘里的数字是示意,结论是真的——堆之外的那部分开销不随限额缩小,却随并发增长。所以「同一个百分比在不同限额下安全与否不同」,而写死 -Xmx 的问题在于它两头都不管:小盒子照样爆,大盒子照样浪费。

152 / 201
小节
十七、动手练习
153 / 201
小节
第一档 · 照做
154 / 201

目标:从空目录起,做出「多阶段构建 + 分层 COPY + 感知限额 + exec 形式入口」的镜像,并用 Compose 把它和 MySQL 一起拉起来。每一步都给预期输出,对不上就停下检查。

155 / 201

第一步,建工程骨架(只需要三个文件):

156 / 201
text
docker-lab/├── pom.xml├── .dockerignore└── src/main/java/com/example/lab/LabApplication.java
157 / 201

第二步,pom.xml(开启分层提取,这是后面 COPY 四层的前提):

158 / 201
xml
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">    <modelVersion>4.0.0</modelVersion>    <parent>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-parent</artifactId>        <version>3.3.0</version>        <relativePath/>    </parent>    <groupId>com.example</groupId>    <artifactId>docker-lab</artifactId>    <version>1.0.0</version>    <properties>        <java.version>21</java.version>    </properties>    <dependencies>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-web</artifactId>        </dependency>    </dependencies>    <build>        <finalName>app</finalName>        <plugins>            <plugin>                <groupId>org.springframework.boot</groupId>                <artifactId>spring-boot-maven-plugin</artifactId>                <configuration>                    <layers>                        <enabled>true</enabled>                    </layers>                </configuration>            </plugin>        </plugins>    </build></project>
159 / 201

第三步,.dockerignore(少这一行,构建上下文会白白上传几十秒):

160 / 201
text
target/.git/.idea/*.imlsrc/test/
161 / 201

第四步,最小应用 LabApplication.java:

162 / 201
java
package com.example.lab;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;@SpringBootApplication@RestControllerpublic class LabApplication {    @GetMapping("/hello")    public String hello() {        long heapMb = Runtime.getRuntime().maxMemory() / 1024 / 1024;        return "heap max = " + heapMb + "MB";   // 第七步用它验证 JVM 看到了多少内存    }    public static void main(String[] args) {        SpringApplication.run(LabApplication.class, args);    }}
163 / 201

第五步,Dockerfile(多阶段 + 分层 + exec 入口 + 按比例设堆):

164 / 201
dockerfile
# ---------- 阶段一:构建 ----------FROM maven:3.9-eclipse-temurin-21 AS builderWORKDIR /buildCOPY pom.xml .RUN mvn -B dependency:go-offlineCOPY src ./srcRUN mvn -B clean package -DskipTestsRUN java -Djarmode=layertools -jar target/app.jar extract# ---------- 阶段二:运行 ----------FROM eclipse-temurin:21-jre-jammyWORKDIR /appCOPY --from=builder /build/dependencies/ ./COPY --from=builder /build/spring-boot-loader/ ./COPY --from=builder /build/snapshot-dependencies/ ./COPY --from=builder /build/application/ ./ENV TZ=Asia/Shanghai \    JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:MaxMetaspaceSize=256m"EXPOSE 8080ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
165 / 201

第六步,构建并观察缓存:

166 / 201
bash
docker build -t docker-lab:1.0 .
167 / 201

预期输出(关键看 CACHED 出现的层数):

168 / 201
text
=> [builder 2/6] WORKDIR /build                          0.0s=> [builder 3/6] COPY pom.xml .                          0.1s=> [builder 4/6] RUN mvn -B dependency:go-offline        0.0s=> CACHED=> [builder 5/6] COPY src ./src                          0.2s=> [builder 6/6] RUN mvn -B clean package -DskipTests   41.8s=> [stage-1 3/6] COPY --from=builder /build/dependencies 1.4s
169 / 201

第七步,带限额起容器,回头对照 8.3 节那张对照图验算比例:

170 / 201
bash
docker run -d --name lab -p 8080:8080 -m 512m docker-lab:1.0curl -s localhost:8080/hello
171 / 201

预期响应:

172 / 201
text
heap max = 384MB
173 / 201

384 正好是 512 的 75%,说明 MaxRAMPercentage 生效了。若这里读到的是宿主物理内存的一个很大比例,说明你的基础镜像 JDK 太老、UseContainerSupport 没开。

174 / 201

第八步,改一行业务代码再构建一次,确认依赖层仍是 CACHED:

175 / 201
bash
sed -i 's/hello = /hello .. /' src/main/java/com/example/lab/LabApplication.javadocker build -t docker-lab:1.1 .
176 / 201

预期:构建阶段那条 RUN mvn -B dependency:go-offline 依然打印 CACHED,只有 COPY src 之后的层重跑。如果你看到的是依赖重新下载,回头检查 2.1 节的指令顺序。

177 / 201

第九步,验证优雅停机真的走到了 JVM:

178 / 201
bash
docker stop -t 30 lab &docker logs -f lab 2>&1 | grep -i "closing\|shutdown"
179 / 201

预期能看到一行类似 Closing org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext...。看不到就回到本篇 10.6 节检查 ENTRYPOINT 的形式。

180 / 201

第十步,落成 Compose(docker-compose.yml,放在 Dockerfile 旁边):

181 / 201
yaml
services:  mysql:    image: mysql:8.0    environment:      MYSQL_ROOT_PASSWORD: root      MYSQL_DATABASE: lab    healthcheck:      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]      interval: 10s      retries: 10    volumes:      - mysql-data:/var/lib/mysql  app:    build: .    depends_on:      mysql:        condition: service_healthy    deploy:      resources:        limits:          memory: 512M    ports:      - "8080:8080"volumes:  mysql-data:
182 / 201
bash
docker compose up -ddocker compose ps
183 / 201

预期 app 状态为 running,mysql 为 healthy;docker compose ps 里 app 的 PORTS 列必须有 0.0.0.0:8080->8080/tcp——没有就是第七节少了 ports。

184 / 201

验收清单:① 说得出第八步为什么依赖层还能命中缓存(对应第二节规则);② curl 到的 384MB 是怎么算出来的;③ docker inspect lab --format '{{.State.ExitCode}} {{.State.OOMKilled}}' 在你手动 docker stop 后返回什么、为什么不是 137 True。

185 / 201
小节
第二档 · 变体
186 / 201

只改一处,观察结论翻转:

187 / 201
  1. 把 ENTRYPOINT 改成 shell 形式 ENTRYPOINT java -jar app.jar(去掉 JSON 数组)。你会观察到:docker stop -t 30 lab 仍然在约 10 秒后就结束,日志里一句 Closing ... 都没有——这就是 10.6 节讲的信号被 shell 吞掉。再用 docker exec lab ps -ef 看 1 号位是谁,答案会是 sh。
  2. 把 .dockerignore 里那行 target/ 删掉,先本地跑一次 mvn package 再 docker build。你会观察到:构建开头的 transferring context 从几百 KB 涨到几十 MB,耗时明显变长;而且 COPY src ./src 之外的内容变化会让上层缓存失效。这就是「上下文也是成本」。
  3. 把 -m 512m 改成 -m 256m 而不动 JAVA_TOOL_OPTIONS。你会观察到:curl 变成连接被拒,docker inspect 给出 OOMKilled true、Exit Code 137,而 docker logs 最后一行还是正常的启动完成日志——完美的「外部处决、不留现场」样本。此时把比例降到 50 再试,症状会从 137 变成能被日志记录的堆内错误。
  4. 把基础镜像换成 eclipse-temurin:21-jre-alpine。你会观察到:镜像体积确实小了约 20MB,但如果连的是服务名而不是 IP,可能开始出现偶发的解析失败——对照第九节提示条与上方 build 实验的第 ④ 帧。
188 / 201

做完第 1 条回头看第十六节沙盘:把 policy 切成 fixed-xmx、limit 切成 512m,两处的结论应该能互相对上。

189 / 201
小节
第三档 · 造一个
190 / 201

给自己做一个「一键可复现环境 + 上线体检脚本」,要求别人拿到仓库后不需要问你任何问题就能跑起来。

191 / 201

需求:

192 / 201
  • 一份 compose.yaml:包含 mysql(带 healthcheck 与初始化 SQL)、redis(命名卷持久化)、app(depends_on: condition: service_healthy)三个服务,全部显式声明内存限额
  • app 的 Dockerfile 必须同时满足:多阶段、.dockerignore、layertools 四层 COPY、exec 形式入口、MaxRAMPercentage 而非 -Xmx、非 root 用户(USER app)
  • 一个 Makefile,至少四个目标:make build(构建)、make up(拉起整套)、make smoke(curl 健康端点 + /hello,任一失败即退出码非 0)、make graceful-test(后台 docker stop -t 30,同时 docker logs -f 抓取关闭日志,断言里必须出现 Closing,否则报「信号没走到 JVM」并以非 0 退出)
  • 一段 README 里的「内存预算表」:写出你选的百分比、堆大小、Metaspace、线程数 × -Xss、直接内存估算,以及它们相加为什么不超过限额
193 / 201

验收清单:① 全新机器上 make up && make smoke 全绿;② 故意把一个 Bean 的构造写成抛异常,make smoke 必须失败而不是挂住;③ make graceful-test 在 exec 形式下通过、把入口改成 shell 形式后必须失败(证明脚本真的能抓到回归);④ docker images 里最终镜像不超过 300MB,且 docker inspect --format '{{.Os}}/{{.Architecture}}' 与部署目标一致;⑤ 说清「为什么 docker stop 之后 ExitCode 是 143 而不是 137」。

194 / 201
小节
十八、要点自查
195 / 201
自检

不看上文,说出镜像分层的失效方向——改一层会波及它上面还是下面的层?为什么 COPY pom.xml 要排在 COPY src 前面?

196 / 201
自检

OOMKilled / Exit Code 137 与 OutOfMemoryError: Java heap space 分别由谁发起?哪一个能留下 heap dump,为什么?

197 / 201
自检

为什么 shell 形式的 ENTRYPOINT 会让优雅停机失效?三种修法各自的适用场景是什么?

198 / 201
自检

容器里的 localhost 指向谁?跨容器互访的正规做法是什么,EXPOSE 在其中起了什么作用?

199 / 201
自检

depends_on 加上 condition: service_healthy 之后,「就绪」这件事是由谁判定的?它还差哪一半(提示:探针语义见第 42 篇)?

200 / 201
口诀

稳定在前、易变在后,改哪里就从哪里往上炸;限额是整箱的账,信号要走到 1 号位才算数。

201 / 201
总结

这一节的三句话——镜像分层是只读叠加,Dockerfile 里越稳定的指令越靠上,pom.xml 提前 COPY 才能吃到缓存;多阶段构建把「构建环境」和「运行环境」分开,镜像能从 700MB 瘦到 200MB;容器里没有 localhost、EXPOSE 不等于发布端口、日志要交 stdout。把 Dockerfile 写对、把 Compose 编排理顺,一套「应用 + MySQL + Redis」的可复现环境就成型了。