Docker 容器化部署:镜像分层与 Compose 编排
先说人话。容器不是「一台迷你电脑」,而是一个被单独关进小房间的 Java 进程:它有自己的一套文件系统视图、自己的网卡、自己的内存额度,但和宿主机共用同一个 Linux 内核。正因为不虚拟硬件、不装操作系统,它才能秒级启动、几十 MB 起步。而「镜像」就是那套打包好的文件与配置——同一份镜像在任何一台装了 Docker 的机器上,展开出来的东西都一模一样,这就是它治「在我机器上能跑」的办法。
先把六个词一句话解释(全文都会用到):
- 镜像(Image):只读的文件层叠起来的模板,构建一次、到处复用;它是「交付物」,不是运行中的东西
- 容器(Container):镜像跑起来的实例,等于在只读层顶上再盖一层可写的临时层;删掉容器,可写层就没了
- 分层(Layer):Dockerfile 里每条
COPY/RUN产生一层,层与层之间只认「我和我下面有没有变」 - 构建缓存(Cache):某一层及其以下完全没变,这一层就不重跑,直接复用——这是 Dockerfile 写法的全部学问
- cgroup:Linux 内核的资源限额开关,
docker run -m 512m靠它限制内存;JVM 要「感知容器」就是去读它 - namespace:Linux 的视图隔离机制,让容器里的进程看不到宿主机的其他进程和网络
镜像是精装房,容器是住进去的那户人家。开发商交给你的是同一个户型、同一套装修(镜像),你在哪座城市买到的都一样;但住户在自己屋里贴的海报、堆的外卖盒(容器可写层)退房时全部清空——想让东西留下来,就得另外租一个储藏室,那就是数据卷(Volume)。所以「我把文件写在容器里了」约等于「我把家当放在租来的房子里然后退了房」。

上图把整篇拆成四关:分清镜像与容器 → Dockerfile 怎么写才不吃缓存 → 跑起来之后 JVM 与容器的相处规则 → 编排与优雅停机。后面每一节都能在这张树上找到一个位置;卡壳时回来看一眼,就知道自己在第几关。
学完这一篇,你应该能回答三个问题:
- 为什么改一行业务代码,我的镜像构建要重跑一遍依赖下载?Dockerfile 到底该怎么排指令?
- 容器日志写着
Exit code (137)、Reason: OOMKilled,但 Java 日志里一行OutOfMemoryError都没有——到底谁杀了谁? - 我明明配了优雅停机,为什么
docker stop还是在切请求?信号在哪一步被吞掉了?
每个后端都听过这句话:「在我机器上能跑啊。」背后的本质是环境漂移——开发机是 JDK 21 加 MySQL 8.0,测试环境是 JDK 17 加 MySQL 5.7,生产又是另一套。容器化的第一步,就是把「应用 + 依赖 + 运行时」打包成一个不可变的交付物,让三个环境跑的是同一个东西。
| 维度 | 物理机 | 虚拟机 | 容器(Docker) |
|---|---|---|---|
| 隔离级别 | 无 | 硬件级虚拟化 | 进程级(namespace + cgroup) |
| 启动速度 | —— | 分钟级 | 秒级甚至毫秒 |
| 资源开销 | 高(每台一套) | 高(每台一个完整 OS) | 低(共享宿主内核) |
| 镜像体积 | —— | GB 级 | MB ~ 数百 MB |
| 一致性 | 差,靠人工对齐 | 较好 | 极好,镜像即环境 |
| 弹性伸缩 | 慢 | 慢 | 快,拉起即用 |
容器之所以轻,是因为它不虚拟硬件、不装完整操作系统——所有容器共享宿主机的内核,只是用 namespace 做视图隔离、用 cgroup 做资源限制。这带来启动快、密度高的优点,代价是隔离性弱于虚拟机(内核漏洞可能被跨容器利用)。
容器化不是银弹。它解决的是"环境一致性、依赖隔离、弹性伸缩"三件事;对"应用本身写得对不对"毫无帮助。别指望打个镜像就能解决内存泄漏。
先把四个最常混的概念分清:
| 概念 | 一句话解释 | 关键点 |
|---|---|---|
| 镜像(Image) | 只读的模板,分层叠加 | 构建产物,不可变 |
| 容器(Container) | 镜像的运行实例 | 在镜像上加一个可写层 |
| 仓库(Registry) | 存放镜像的地方 | Docker Hub、Harbor、云厂商 |
| 数据卷(Volume) | 绕过联合文件系统的持久化存储 | 容器删除,数据仍在 |
镜像不是一整块,而是一层层叠加的只读层。每一条 COPY、RUN、ADD 指令都会产生新的一层。Docker 构建时会逐层比对缓存:某一层及其所有下层都没变,这一层就直接复用缓存,不再执行。
这就解释了一个经典优化:为什么要把 pom.xml 复制提前。
# 反例:先拷全部源码,改一行代码,依赖安装那一层缓存全失效COPY . .RUN mvn dependency:go-offlineRUN mvn package -DskipTests# 正例:先只拷 pom 装依赖,改源码时依赖层依然命中缓存COPY pom.xml .RUN mvn dependency:go-offline # 依赖不变 → 这一层永远命中COPY src ./src # 源码变化 → 只让后面的层失效RUN mvn package -DskipTests- 反例里
COPY . .一旦源码变动,它之后的所有层(依赖下载、打包)全部重新执行 - 正例把「不常变的依赖声明」和「常变的源码」拆到两层,改代码只重跑打包,构建时间从几分钟降到几十秒

分层的规则是「上下有依赖,上层变化不影响下层」。所以 Dockerfile 里指令的顺序,原则上应该越稳定的越靠上,越常变的越靠下。
这条规则光看文字容易记反,亲手跑一次缓存判定就忘不了。下面这个实验用默认的 layer 参数把「每条指令产生哪一层」「缓存凭什么判定」「构建输出里的 CACHED 是什么意思」逐帧摊开——重点盯第 ③ 帧(为什么 COPY pom.xml 要提前)和第 ⑥ 帧(分层提取省下了多少):
缓存判定光看图还不够,最容易忘的是「一层的指纹由谁组成」。下面这台单步台把 builder 段的五行构建拆成逐帧执行:每按一步,看它到底重算了哪个指纹——第 ③ 步是整段的灵魂:
# builder 段的五行构建COPY pom.xml .RUN mvn -B dependency:go-offlineCOPY src ./srcRUN mvn -B package -DskipTests| 改动 | 无 |
| 缓存 | — |
记住这张指纹表再看 2.2 的 .dockerignore:如果 COPY src ./src 的输入被 target/ 这类垃圾污染,每一层都会变——缓存从此再没命中过。
上下文(context)是先整体打包传给守护进程的,所以 target/、.git/、node_modules/ 这些目录哪怕最终没进镜像,也照样要上传一遍。根目录建一个 .dockerignore:
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 节的优化抵消干净
先看一个能跑、但还不够生产级的基础版本:
# 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,只带运行时,体积能省一两百 MBWORKDIR:设定工作目录,等价于cd,但更可靠——不存在会自动创建COPY:把宿主机的文件复制进镜像,构建上下文内的路径要用相对路径ENV:设置环境变量,TZ影响日志时间戳,容器里默认 UTC 常导致时间对不上EXPOSE:只是元数据声明,告诉使用者这个容器监听 8080,并不会真的开放端口(发布靠-p)ENTRYPOINT:用 exec 数组形式 而非 shell 形式,保证 Java 进程是 PID 1,能直接收到docker stop发的 SIGTERM,优雅停机才生效
上面那个 Dockerfile 有个前提:宿主机得先 mvn package 打出 jar。但 CI 机器未必装了 Maven,就算装了,也不该把构建环境和运行环境混在一起。解法是多阶段构建——一个 Dockerfile 里写两段 FROM,前一段负责构建,后一段只拿产物。
# ---------- 阶段一:构建(带 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)从构建阶段搬过来
| 方案 | 基础镜像 | 大致体积 | 说明 |
|---|---|---|---|
| 直接打 JDK + 源码镜像 | maven + jdk | ~700MB+ | 把构建工具也带进生产,浪费且不安全 |
| 多阶段构建 | 仅 jre | ~200MB | 只留运行时,推荐 |
多阶段构建解决了体积,但还有一个缓存问题:每次改一行业务代码,整个 app.jar 变了,COPY app.jar 这一层就失效,依赖层跟着重来。Spring Boot 提供了分层提取,把 fat jar 拆成独立的层分别 COPY:
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <layers> <enabled>true</enabled> </layers> </configuration></plugin># 构建阶段:把 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 启动器),让它从分层目录加载
前面四节讲的其实是一条流水线上的三段:先打出能跑的 jar,再把 jar 切成层搬进镜像,最后起容器发布端口。新手最容易断在「jar 和镜像到底谁装了谁」这一环,所以把整条路按顺序走一遍:

第六步特别说明一下:-p 8080:8080 和 /actuator/health/readiness 属于两个不同的责任——前者是 Docker 干的(把宿主端口映射进容器),后者是应用自己得答对(回答「我现在能不能接流量」)。这一篇先把 Docker 这半边做扎实,探针那半边见第 42 篇。
而第一、二步的前提是:你确实知道自己打进镜像的那个 jar 长什么样。上一篇文章拆开过可执行 jar 的内部结构——Main-Class 是 JarLauncher 而不是你的启动类,依赖躺在 BOOT-INF/lib 里由自定义类加载器读取。做容器化之前先用这个实验把这层确认一遍,尤其是 war 那一档:等你换成 war 部署外部 Tomcat 时,COPY target/*.jar 这条指令会直接找不到文件。
| 命令 | 作用 | 常用参数 |
|---|---|---|
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 | 清理无用镜像与缓存 | 谨慎使用,会删未使用资源 |
docker stop 默认只等 10 秒就发 SIGKILL。如果你的应用优雅停机要 30 秒,务必 docker stop -t 30 <id>,否则停机没走完就被强杀,又回到了"腰斩请求"的老问题。
容器默认跑在 bridge 网络里,每个容器有独立 IP,容器内的 localhost 指容器自己,不是宿主机。同一自定义网络内,容器可以直接用服务名互相访问:
# 创建自定义网络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":绑定挂载,把容器日志目录映射到宿主机,方便查看
手敲一堆 docker run 很快就会失控。Compose 用一份声明式 YAML 描述整套环境,docker compose up 一键拉起:
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:逐段解读:
- 服务依赖与启动顺序:
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:给它起名字便于复用

Compose 的 healthcheck 让编排系统知道容器「能不能接活」,而容器里跑的 Spring Boot 应用也得自己回答这个问题。第 42 篇讲过 Actuator 的健康端点,这里补上它在容器视角的样子——健康检查不是一次探活,而是多个检查项投票之后的最坏结果:
- 数据库通、Redis 通 →
UP;任何一项不通 → 整条链路DOWN,K8s 的 readiness 据此把你摘出负载均衡 - 所以健康检查里别挂业务查询:一个偶发的慢 SQL 会把「进程活着」误报成「实例不可用」
- 想知道容器里到底是哪一项在拉低状态,打开
management.endpoint.health.show-details=always逐项看
容器化不只是写 Dockerfile,应用本身也要做两处配合。
不要在 application.yml 里写死 localhost,用带默认值的占位符:
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)的第三条:配置存于环境
容器哲学里有一条:进程只写标准输出,日志的去向交给平台。所以生产配置应该只往控制台打,而不是写文件:
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 给机器,人类可读留给开发
这是容器化里最容易「平时不出事、一扩容就炸」的一处。JVM 判断该开多大堆时,默认看的是整台机器的物理内存,而不是分给它的那份配额。一个跑在 512m 容器里的进程如果按宿主 32GB 去算默认堆,就会一路冲到 cgroup 上限被内核直接抹掉。Java 8u191 之后 UseContainerSupport 默认打开,JVM 会去读 cgroup 的限额文件(v1 是 memory.limit_in_bytes,v2 是 memory.max),但它读的是限额,不是「你的堆」。
正确写法是按百分比声明,而不是写死 -Xmx:
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 到底看到多少内存,直接在容器里问它:
docker exec bee-app java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|InitialHeapSize'两种失败模式必须分清,因为它们的排查方向完全相反:
OutOfMemoryError: Java heap space | OOMKilled / Exit code (137) | |
|---|---|---|
| 谁发起的 | JVM 自己(堆装不下了) | cgroup / 内核 / K8s(总用量超限额) |
| Java 侧有没有异常 | 有,日志里能看到完整栈 | 没有,日志戛然而止 |
| 能不能留现场 | 能配 -XX:+HeapDumpOnOutOfMemoryError 抓 hprof | 什么现场都不留 |
| 调什么的 | 查泄漏、调堆比例 | 算总账:堆 + 堆外一起看 |
两种失败的分界,其实就落在「给堆留多少百分比」这一个数字上。512MB 的容器里把 -XX:MaxRAMPercentage 从 25 拖到 100,每一步都能看到堆、非堆、总账三者怎么分摊——注意右端那一档,它等价于写死 -Xmx512m:
- 堆 384MB,Metaspace + 200 条线程栈 + CodeCache 约 130MB,总账余量 40MB 左右
- 启动正常、GC 频率可接受——这就是上面示例里 75% 的来历
- 前提:Metaspace 与 -Xss 分别封顶,别让非堆无界生长
行李箱限重。航空公司说「这件箱子最多 23 公斤」,那是整箱的重量——衣服、锁扣、轮子、里面的充电宝全算。你把衣服塞到 23 公斤(把 -Xmx 设成等于容器限额),到柜台一称必然超重,地勤直接开箱都没开就把你拦下(OOMKilled);而「衣服太多了穿不进箱子」(堆内 OutOfMemoryError)是你自己在家里就发现的,还能摊开看看是哪件出问题。前者是外部处决,后者是内部报错——症状、修法、能不能留证据,全都不一样。

左边那一列解释了为什么「加大内存限额就好了」经常只是掩盖问题:根因可能在 Netty 的直接内存没释放,而堆外压根不受 -Xmx 管辖。这个实验把「JVM 读到多少 → 默认堆怎么算出来 → 两种失败各自长什么样」一次走完:
| 优化项 | 做法 | 收益 |
|---|---|---|
| 基础镜像 | 选 jre-slim / alpine 变体 | 体积从数百 MB 降到 ~200MB |
| 多阶段构建 | 构建与运行分离 | 丢掉 Maven/JDK/源码 |
| 非 root 运行 | USER app | 降低容器逃逸风险 |
.dockerignore | 排除 target/、.git/、node_modules/ | 减小构建上下文,加速构建 |
| 合并 RUN 层 | RUN a && b && c | 减少镜像层数 |
| 分层 COPY | layertools + 按层复制 | 提升缓存命中 |
| 固定基础镜像版本 | 用 :21-jre 而非 :latest | 构建可复现 |
alpine 镜像体积小,但它用 musl libc 而非 glibc,某些依赖本地库的组件(如部分 JDK 特性、JNI 库)会不兼容。稳妥起见,Spring Boot 应用优先选 eclipse-temurin:21-jre-jammy 这类 slim 变体,而不是无脑上 alpine。
清单读完了,把它变成你家项目的行为——下面这台生成器把「生产级 Dockerfile 该有哪些块」做成勾选项。先只勾「多阶段 + 分层提取」看最小骨架,再逐项叠加非 root、内存比例、健康检查与停机信号,对照上面表格里各自的收益:
# ---------- 构建阶段:要完整 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生成器里的 healthcheck 与 stopsignal 是两块「平时看不出、出事才后悔」的配置——前者决定编排系统能不能发现容器僵死,后者决定 docker stop 时应用有没有机会排空;与第十三节的探针分工一起看。
最常见的误解:以为容器里 localhost:3306 能连到宿主机的 MySQL。错——容器的网络 namespace 是独立的,localhost 指容器自己。要么用宿主机 IP(Linux 上是 172.17.0.1),要么干脆把数据库也放进 Compose,用服务名互访(推荐后者)。
EXPOSE 8080 只是声明,不会让外部访问到。真正发布端口的是 docker run -p 8080:8080(或 Compose 的 ports)。少写 -p,容器跑得好好的,浏览器却死活连不上。
容器默认 UTC,日志时间戳和数据库时间都会比北京时间少 8 小时。解决方案:Dockerfile 里加 ENV TZ=Asia/Shanghai,或运行时 -e TZ=Asia/Shanghai,并在连接串里带 serverTimezone=Asia/Shanghai。
某些镜像(尤其精简版)熵池小,SecureRandom 初始化时阻塞读 /dev/random,表现为应用卡在启动、Tomcat 迟迟不监听端口。Linux 内核 5.6+ 后 /dev/random 不再阻塞,老内核可挂载 /dev/urandom 或加 -Djava.security.egd=file:/dev/./urandom 绕过。
在 Apple Silicon(M 系列)Mac 上跑一个只为 x86 构建的镜像,或者把本地构建的 arm64 镜像推到 amd64 服务器上,就会看到这条:
standard_init_linux.go:228: exec user process caused: exec format error它跟 Java 一点关系都没有——是 Docker 在尝试执行 ENTRYPOINT 那个二进制时失败了:镜像里打包的是 linux/arm64 的 java,宿主内核是 linux/amd64,指令集不同,加载器直接拒绝。三条自救路径:
- 构建时显式声明平台:
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 推送
排查只有一条命令就够,看镜像自己的架构而不是你的机器的:
docker inspect --format '{{.Os}}/{{.Architecture}}' bee-app:1.0坑:docker pull 默认会按你本机架构去拉镜像,所以「我本地能跑、CI 报 exec format error」经常不是代码问题,而是同一标签下两种架构的镜像内容并不相同。生产镜像请锁死平台,别依赖默认行为。
第 36 篇讲过优雅停机的六个动作,前提是JVM 真的收到了那个信号。容器里最容易断的就是这一环。

关键在第 ②③ 两帧的分岔:
- 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 硬杀
症状非常固定:日志里一行 shutdown 相关的话都没有,但容器确实被正常停了。修法三选一——改用 exec 形式;或保留 shell 形式但写成 ENTRYPOINT exec java -jar app.jar;或用 tini / dumb-init 当 1 号进程代管信号与僵尸回收。
还要两边对齐:Spring 侧的 server.shutdown=graceful + spring.lifecycle.timeout-per-shutdown-phase 若配了 30 秒,而 Docker 侧 docker stop 默认宽限期只有 10 秒,那后 20 秒的请求照样是被硬切的。
server: shutdown: gracefulspring: lifecycle: timeout-per-shutdown-phase: 20s验证方法很土但最有效:一边 docker stop -t 30 bee-app,一边 docker logs -f bee-app,看得见 Closing org.springframework... 才算通。这个实验把信号链路完整走一遍,重点看第 ②③ 两帧的分岔:
容器环境变量和应用里的条件装配,本质是同一件事:同一份代码,在不同环境装配出不同结果。这个演示把 IoC 容器的条件开关跑一遍——就像切换容器环境变量一样。
- 拨动
@ConditionalOnProperty这类开关,观察容器里注册的 Bean 如何变化 - 容器环境变量(如
DB_HOST)通过 Spring 的@ConfigurationProperties绑定,最终影响装配 - 理解这一点,就理解了"一个镜像跑多环境"的底层机制
不过这一篇的主角不是应用内部装配,而是镜像本身。第二节那条「上层变化不影响下层」的规则,只有亲手看一次缓存判定才记得住——上面第 2.1 节已经用 layer 参数走过一遍,这里补一个视角:同一份代码,环境不同、装配结果不同。下面这个实验把条件注解的求值过程摊开,它对应的就是「容器里注入什么环境变量,应用就长成什么样子」:
再切到 build 参数看构建阶段的卫生问题(.dockerignore、builder 层的 ~/.m2、基础镜像选型),它对应第四节的取舍:
镜像、容器与应用三件事讲完了,验证手段还剩最后一层:命令行。下面这台控制台连着浏览器里的同一个 Java 内核——先 whoami 看当前进程,再逐条 lab 把本节的四个实验敲一遍,回显全部由内核真实执行:
lab dockerimg mem 和 lab dockerimg signal 建议连着敲——第一个回答「JVM 看到多少内存」,第二个回答「SIGTERM 有没有走到它」,8.3 与 10.6 两节的主要结论都在这些回显里。
上面所有功夫最后都要落在一次真实的滚动发布上:新容器顶上、老容器退场,而用户不该感觉到中间那次抖动。老容器退场的完整动作不是「杀掉进程」,而是三件事按顺序发生——先被摘掉流量、再把在途请求处理完、最后才退出。这三步里 Docker 只负责第一步的一半,剩下的都在应用和编排层。
第 42 篇讲过 Actuator 的两个探针端点,语义完全不同,混用会造成「数据库抖一下,全体实例被重启」:
/actuator/health/liveness回答「这个进程还有救吗」——只该判断进程自身,配错了会导致反复重启/actuator/health/readiness回答「现在能不能给我流量」——依赖不健康时应该由它摘流量,而不是杀进程
- 宽限期要对齐:K8s 的
terminationGracePeriodSeconds(默认 30 秒)必须大于 Spring 侧spring.lifecycle.timeout-per-shutdown-phase,否则你等到一半就被 SIGKILL - preStop 那个 sleep 不是玄学:Endpoint 更新有传播延迟,收到 SIGTERM 的一瞬间旧实例仍可能被打进新请求,所以惯例是先
sleep 2~5再开始收尾 docker stop -t同理:本地或 Compose 环境下没有 preStop,唯一能调的就是-t;第五节那条坑说的就是这件事
容器下线像电影院打烊。关灯是「不再卖票」(readiness 变红、停止接新请求),放映厅里正在看的观众要看完这一段才离场(在途请求排空),广播通知得先递到领班手里才有用(信号走到 PID 1)。十分钟还不走,保安直接清人(SIGKILL)——爆米花就打翻了。
下面每一行的「报错原文」都能整段复制去搜索,别意译、别缩写。新手在这三处最容易卡住:内存被外部处决、端口连不上、构建每次都重来。
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 | |
|---|---|---|---|---|
Exit code (137),且 Java 日志里没有一行 OutOfMemoryError | 137 = 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 的 ports | docker 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 节 |
这一张表里有三条(137、信号被吞、alpine DNS)都不会以「Java 异常」的形式出现,症状是日志戛然而止。排查容器问题时先看 docker inspect <id> --format '{{.State.ExitCode}} {{.State.OOMKilled}}',一眼就能分清「谁杀的」。
上面那张速查表里,java.lang.OutOfMemoryError: Java heap space 和第一行的 137 长得很像,现场却完全不同。下面这段是真实堆栈,先别看答案——点出你认为的凶手行,再对照两种失败的分界:
报表服务把一个月的订单明细全量导出(SELECT * 查了几十万行),高峰期容器被 OOMKilled 重启过两次;这次日志里留下了完整的 Java 异常——注意它与 Exit Code 137 的区别。
先来一道热身题,考的是第二节的缓存规则:
再来一道综合题,把 8.3、10.6 和第十三节串起来:
「把限额调大就好了」是容器排障里最贵的一句安慰——它让症状消失,却让你永远不知道是谁在吃内存。下面这个沙盘把堆比例和容器限额做成两个开关,切一档就能同屏看到默认堆算出多大、总用量落在哪、以及最终以什么姿势失败:
-Xmx512m → MaxHeapSize = 512MB(等于整个限额)# Metaspace 180MB + 200 线程栈 ≈ 200MB + CodeCache 64MBRSS 冲到 519MB,cgroup 上限 512MBReason: OOMKilled · Exit Code 137 · Java 日志无 OutOfMemoryError
沙盘里的数字是示意,结论是真的——堆之外的那部分开销不随限额缩小,却随并发增长。所以「同一个百分比在不同限额下安全与否不同」,而写死 -Xmx 的问题在于它两头都不管:小盒子照样爆,大盒子照样浪费。
目标:从空目录起,做出「多阶段构建 + 分层 COPY + 感知限额 + exec 形式入口」的镜像,并用 Compose 把它和 MySQL 一起拉起来。每一步都给预期输出,对不上就停下检查。
第一步,建工程骨架(只需要三个文件):
docker-lab/├── pom.xml├── .dockerignore└── src/main/java/com/example/lab/LabApplication.java第二步,pom.xml(开启分层提取,这是后面 COPY 四层的前提):
<?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>第三步,.dockerignore(少这一行,构建上下文会白白上传几十秒):
target/.git/.idea/*.imlsrc/test/第四步,最小应用 LabApplication.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); }}第五步,Dockerfile(多阶段 + 分层 + exec 入口 + 按比例设堆):
# ---------- 阶段一:构建 ----------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"]第六步,构建并观察缓存:
docker build -t docker-lab:1.0 .预期输出(关键看 CACHED 出现的层数):
=> [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第七步,带限额起容器,回头对照 8.3 节那张对照图验算比例:
docker run -d --name lab -p 8080:8080 -m 512m docker-lab:1.0curl -s localhost:8080/hello预期响应:
heap max = 384MB384 正好是 512 的 75%,说明 MaxRAMPercentage 生效了。若这里读到的是宿主物理内存的一个很大比例,说明你的基础镜像 JDK 太老、UseContainerSupport 没开。
第八步,改一行业务代码再构建一次,确认依赖层仍是 CACHED:
sed -i 's/hello = /hello .. /' src/main/java/com/example/lab/LabApplication.javadocker build -t docker-lab:1.1 .预期:构建阶段那条 RUN mvn -B dependency:go-offline 依然打印 CACHED,只有 COPY src 之后的层重跑。如果你看到的是依赖重新下载,回头检查 2.1 节的指令顺序。
第九步,验证优雅停机真的走到了 JVM:
docker stop -t 30 lab &docker logs -f lab 2>&1 | grep -i "closing\|shutdown"预期能看到一行类似 Closing org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext...。看不到就回到本篇 10.6 节检查 ENTRYPOINT 的形式。
第十步,落成 Compose(docker-compose.yml,放在 Dockerfile 旁边):
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:docker compose up -ddocker compose ps预期 app 状态为 running,mysql 为 healthy;docker compose ps 里 app 的 PORTS 列必须有 0.0.0.0:8080->8080/tcp——没有就是第七节少了 ports。
验收清单:① 说得出第八步为什么依赖层还能命中缓存(对应第二节规则);② curl 到的 384MB 是怎么算出来的;③ docker inspect lab --format '{{.State.ExitCode}} {{.State.OOMKilled}}' 在你手动 docker stop 后返回什么、为什么不是 137 True。
只改一处,观察结论翻转:
- 把
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。 - 把
.dockerignore里那行target/删掉,先本地跑一次mvn package再docker build。你会观察到:构建开头的transferring context从几百 KB 涨到几十 MB,耗时明显变长;而且COPY src ./src之外的内容变化会让上层缓存失效。这就是「上下文也是成本」。 - 把
-m 512m改成-m 256m而不动JAVA_TOOL_OPTIONS。你会观察到:curl变成连接被拒,docker inspect给出OOMKilled true、Exit Code 137,而docker logs最后一行还是正常的启动完成日志——完美的「外部处决、不留现场」样本。此时把比例降到 50 再试,症状会从 137 变成能被日志记录的堆内错误。 - 把基础镜像换成
eclipse-temurin:21-jre-alpine。你会观察到:镜像体积确实小了约 20MB,但如果连的是服务名而不是 IP,可能开始出现偶发的解析失败——对照第九节提示条与上方build实验的第 ④ 帧。
做完第 1 条回头看第十六节沙盘:把 policy 切成 fixed-xmx、limit 切成 512m,两处的结论应该能互相对上。
给自己做一个「一键可复现环境 + 上线体检脚本」,要求别人拿到仓库后不需要问你任何问题就能跑起来。
需求:
- 一份
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、直接内存估算,以及它们相加为什么不超过限额
验收清单:① 全新机器上 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」。
不看上文,说出镜像分层的失效方向——改一层会波及它上面还是下面的层?为什么 COPY pom.xml 要排在 COPY src 前面?
OOMKilled / Exit Code 137 与 OutOfMemoryError: Java heap space 分别由谁发起?哪一个能留下 heap dump,为什么?
为什么 shell 形式的 ENTRYPOINT 会让优雅停机失效?三种修法各自的适用场景是什么?
容器里的 localhost 指向谁?跨容器互访的正规做法是什么,EXPOSE 在其中起了什么作用?
depends_on 加上 condition: service_healthy 之后,「就绪」这件事是由谁判定的?它还差哪一半(提示:探针语义见第 42 篇)?
稳定在前、易变在后,改哪里就从哪里往上炸;限额是整箱的账,信号要走到 1 号位才算数。
这一节的三句话——镜像分层是只读叠加,Dockerfile 里越稳定的指令越靠上,pom.xml 提前 COPY 才能吃到缓存;多阶段构建把「构建环境」和「运行环境」分开,镜像能从 700MB 瘦到 200MB;容器里没有 localhost、EXPOSE 不等于发布端口、日志要交 stdout。把 Dockerfile 写对、把 Compose 编排理顺,一套「应用 + MySQL + Redis」的可复现环境就成型了。