Skip to content

Docker 基础与镜像容器 ​

S4-025建议学进阶约 90 分钟JDK 21企业开发进阶 · 容器与编排

1. 本节定位 ​

从本节起进入部署与运维。第一个问题是:容器到底省下了什么。

★ 本节的实测把三件事量出来 ✓✓:

text
① 分层复用:10 个镜像共享基础层 → 磁盘从 2000 MB 降到 380 MB(省 81%)
② 写时复制:改一个文件只复制那一个(约 13 字节,而不是 180 MB)
③ 构建缓存:Dockerfile 指令换一下顺序 → 重建从 332 秒降到 30 秒(快 11 倍)✓✓

★ 必须说清的边界 ✓:本机没有 Docker,所以本类实现的是最小镜像模型(层、写时复制、构建缓存)—— 它没有模拟 Linux 命名空间与 cgroups(那才是容器的另一半,见 5.4)✓

2. 学习目标 ​

  • 能说清"镜像分层"带来哪三项收益 ★★★;
  • 能解释写时复制(读 0 拷贝、改只复制一个文件)以及它的坑 ★★★;
  • 能说出 Dockerfile 指令的排序规则(按变化频率从低到高) ★★★;
  • 知道多阶段构建与 .dockerignore 解决什么 ★★★;
  • 能说清容器与虚拟机的本质区别(共享内核),以及由此推出的安全结论 ★★★;
  • 知道容器 = 文件分层 + 命名空间 + cgroups ★★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看一个"镜像为什么这么大"的真实场景 ✗:

text
现象:CI 每次构建要 5 分钟,镜像仓库存储一直在涨
排查:Dockerfile 是这么写的 ——
    COPY . /app                 ← 代码一改,这层缓存就失效
    RUN mvn dependency:resolve  ← 于是重新下载全部依赖(几分钟)✗✗
    RUN mvn package

★ 本节的实测就是这个问题 ✓:

text
  ① 常见写法(先 COPY 全部代码,再装依赖):  约 332 秒 ✗✗
  ② 优化写法(先 COPY pom,再装依赖,最后 COPY src):约 30 秒 ✓✓
  → 快了 11 倍 ✓✓

5. 核心概念 ​

5.1 镜像分层:三项收益 ★★★ ​

实测 ✓✓:

text
  场景:基础镜像(JDK + 常用工具)180 MB,每个服务的应用层 20 MB,共 10 个服务

  ① 不分层(每个镜像各存一份完整文件系统): 2000 MB ✗✗
  ② 分层(基础层共享):                     380 MB ✓✓
  → 省下 81% 的磁盘(层复用命中 9 次)

★ 分层的收益不止磁盘(三项一起才是重点) ✓✓:

收益原因
省磁盘 ★★相同层只存一份 ✓✓
省网络 ★★★pull 时已有的层不下载(CI 机器上尤其明显)✓✓
省构建时间 ★★★没变的层直接复用缓存(见 5.3)✓✓

★ 一条实践推论 ✓✓:

基础镜像要尽量统一 ✓ ——

如果 A 服务用 openjdk:17-slim、B 服务用 eclipse-temurin:21, 它们的基础层完全不同 → 分层的收益就没了 ✗✗

所以「统一基础镜像」应该当成团队规范来管 ✓✓

★ 为什么"层"能共享 ✓:

层是不可变的(immutable) ✓ ——一旦生成就不再改动 ✓

所以同一份内容的层可以被任意多个镜像引用 ✓✓

这一点是后面"写时复制"和"构建缓存"的共同基础 ✓

5.2 写时复制:读 0 拷贝、改只复制一个文件 ★★★ ​

实测 ✓✓:

text
  镜像初始状态:2 层,逻辑大小 200 MB
  容器启动后:新增可写层(初始 0 字节)→ 3 层

  ① 读 /app/config.yaml → 直接读下层
     代价:0 字节(不复制)✓✓

  ② 改 /app/config.yaml → 只把这一个文件复制到可写层
     代价:约 13 字节(不是复制整个 180 MB 的基础层)✓✓
     下层仍是旧值,上层是新值 —— 这就是「写时复制」

★ 三个由此推出的结论 ✓✓:

#结论说明
①容器里的修改不影响镜像 ✓✓所以"删掉容器重跑"就回到干净状态(不可变基础设施的底气)✓
②容器启动几乎是瞬时的 ✓✓因为可写层初始是空的,不用复制任何文件 ✓
③同一镜像可以起 N 个互不干扰的容器 ✓✓各自有独立的可写层 ✓

★★ 但写时复制有一个"反直觉"的坑 ✗✗:

上层改大量文件时,每个文件都要复制一次 → 可写层迅速膨胀 ✗

更常见的坑:日志直接写在容器里 ✗✗ —— 可写层越来越大,直到磁盘满(而镜像本身还是小的)✗

正确做法:日志输出到 stdout,数据写到挂载的卷 ✓✓

(这也是为什么 docker logs 能看到日志 —— 它读的是容器外的日志文件 ✓)

★ 一条判断标准 ✓✓:

数据放哪
日志 ★★★stdout(由容器运行时接管)✓✓
业务数据(数据库文件、上传文件)★★★挂载卷 ✓✓
临时文件 ✓可写层(随容器删除)✓

5.3 构建缓存:Dockerfile 指令的排序规则 ★★★ ​

实测 ✓(改一行代码后重新构建):

text
  ① 常见写法:
       COPY . /app                ← 代码一改,这层缓存失效 ✗✗
       RUN mvn dependency:resolve ← 缓存失效 → 重新下载全部依赖 ✗✗
       RUN mvn package            ← 重新编译
       约 332 秒 ✗✗

  ② 优化写法:
       COPY pom.xml /app          ← pom 没变 → 缓存命中 ✓✓
       RUN mvn dependency:resolve ← 缓存命中(0 秒)✓✓
       COPY src /app              ← 只有这层失效
       RUN mvn package            ← 只重新编译
       约 30 秒 ✓✓(快了 11 倍)

★★★ 一条规则(记住它就能省下大量 CI 时间) ✓✓:

Dockerfile 里的指令要按「变化频率」从低到高排列 ✓✓

text
基础镜像 → 系统依赖 → 语言依赖(pom / package.json)→ 源码 → 构建产物

改动越频繁的东西越靠后,缓存命中率就越高 ✓✓

★ 另外三个常用优化 ✓✓:

手段省下什么
多阶段构建(multi-stage) ★★★构建工具链不进最终镜像(省几百 MB)✓✓
.dockerignore ★★★不把 target/、.git 打进构建上下文 ✓✓
合并相关 RUN ★减少层数(但别硬合并,会牺牲缓存)⚠️

★ 多阶段构建的样子 ✓:

dockerfile
# 阶段一:构建(用完整的 JDK 镜像)
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline          # ★ 依赖层可缓存
COPY src ./src
RUN mvn package -DskipTests

# 阶段二:运行(只用 JRE,镜像小得多)
FROM eclipse-temurin:21-jre-alpine
COPY --from=builder /build/target/app.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

★★ 收益 ✓✓:最终镜像从"JDK + Maven + 源码 + 依赖"(几百 MB) 变成"JRE + 一个 jar"(几十 MB)✓✓

★★★ 一条权衡 ✓✓:层数不是越少越好 —— 把 10 条 RUN 合并成 1 条确实少了层,但任何一处变化都会让这层全部重跑 ✗✗ 所以:相关操作用 && 合并,不相关的分开 ✓

5.4 容器的另一半:隔离(命名空间 + cgroups)★★★ ​

★★ 必须说清 ✓✓:

容器 ≠ 虚拟机,也 ≠ 只有文件分层 ✓

它 = 文件系统分层(本节实测) + 命名空间隔离 + cgroups 资源限制 ✓✓

★ 隔离的六个维度 ✓:

隔离维度靠什么实现效果
进程视图 ★★PID namespace容器里看到自己是 PID 1 ✓
网络 ★★★Network namespace独立的网卡、IP、端口空间 ✓✓
主机名 ★UTS namespace容器有自己的 hostname ✓
挂载点 ★★★Mount namespace看到自己的文件系统 ✓✓
用户 ★★User namespace容器内 root ≠ 宿主 root ✓
CPU / 内存 ★★★cgroups限制用量,超了被杀 ✓✓

★★ 与虚拟机的本质区别(一句话) ✓✓:

容器与宿主共享内核(所以启动是毫秒级、开销极小)✓✓

虚拟机各自跑一个内核(所以启动是分钟级、开销大)✓

★★★ 由此推出一条安全结论 ✓✓:

共享内核意味着「内核漏洞会穿透隔离」 ✗✗

· 多租户场景(跑别人的代码)→ 要用虚拟机,或 gVisor / Kata 这类沙箱 ✓

· 跑自己的服务 → 容器就够了 ✓✓

(这也是为什么 K8s 集群里不跑不可信镜像)✓

★ 常用命令(本模型做的事 vs 真实 docker) ✓:

本模型做的事真实命令
LayerStore.acquire()(复用层)docker pull(已存在的层会跳过)✓
Image 的层列表docker history <image>(看每层)✓✓
writableLayerdocker diff <container>(看改动了啥)✓
buildCache()docker build(--no-cache 可禁用)✓

★ 四条最常用的排障命令 ✓✓:

bash
docker logs -f <container>      # 看日志(读的是 stdout 重定向的文件)✓✓
docker exec -it <container> sh  # 进容器看现场 ✓✓
docker stats                    # 看 CPU/内存(读的是 cgroups)✓✓
docker inspect <container>      # 看配置与网络 ✓

5.5 Dockerfile 的最佳实践清单 ★★★ ​

#做法原因
①按变化频率排序指令 ★★★缓存命中率(5.3)✓✓
②用多阶段构建 ★★★最终镜像小、不含构建工具 ✓✓
③配 .dockerignore ★★★构建上下文小、避免把密钥打进去 ✓✓
④用固定版本的基础镜像(别用 latest)★★★可重复构建(《CI/CD 流水线设计》 的原则)✓
⑤不用 root 运行(USER app)★★★降低容器逃逸后的影响 ✓
⑥只 COPY 需要的文件 ★★少一层、少泄漏 ✓
⑦HEALTHCHECK 或由编排层配探针 ★★★见 《Docker Compose 与多容器编排》、《Kubernetes 入门》 ✓

★★ 第 ④ 条容易被忽视 ✓✓:

FROM openjdk:latest 意味着"今天构建和明天构建可能得到不同的镜像" ✗

这直接违反"可重复"原则 ✓ —— 于是会出现"昨天还好好的,今天重新构建就挂了" ✗✗

6. 递进示例 ​

6.1 最小示例:把 Spring Boot 应用容器化 ​

dockerfile
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY target/app.jar app.jar
EXPOSE 8080
USER 1001                                # ★ 不用 root
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

6.2 业务示例:完整的多阶段 + 缓存优化 ​

见 5.3 ✓

6.3 反例:把日志和数据写在容器里 ​

dockerfile
# ✗ 日志写文件(可写层只增不减)→ 磁盘满
ENTRYPOINT ["java", "-jar", "/app/app.jar", "--logging.file.name=/app/logs/app.log"]

# ✓ 输出到 stdout(由运行时接管),数据写挂载卷
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

7. 完整可运行代码与实测 ​

代码:labs/distributed-lab/src/main/java/cn/kb/learn/dist/container/ContainerModelLesson.java

运行:

powershell
cd labs
.\mvnw.cmd -pl distributed-lab compile
java "-Dstdout.encoding=UTF-8" -cp distributed-lab/target/classes cn.kb.learn.dist.container.ContainerModelLesson

实测输出(关键部分) ✓:

text
===== 1. 分层复用 =====
  ① 不分层(每个镜像各存一份完整文件系统): 2000 MB ✗✗
  ② 分层(基础层共享):  380 MB ✓✓(物理占用 19 个层)
  → 省下 81% 的磁盘(层复用命中 9 次)✓✓

===== 2. 写时复制 =====
  容器启动后:新增可写层(初始 0 字节)✓✓ → 3 层
  ① 读 /app/config.yaml → 代价:0 字节 ✓✓
  ② 改 /app/config.yaml → 代价:约 13 字节(不是 180 MB)✓✓

===== 3. 构建缓存 =====
  ① 常见写法:改动一行代码后重建约 332 秒 ✗✗
  ② 优化写法:改动一行代码后重建约 30 秒 ✓✓
  → 快了 11 倍 ✓✓

★ 三条可落地的结论 ✓:

#结论依据
①统一基础镜像,让分层复用最大化省 81% ✓
②日志出 stdout,数据挂卷可写层会膨胀 ✗
③Dockerfile 按变化频率从低到高排快 11 倍 ✓✓

8. Debug 观察点 ​

  1. docker history <image>:看每一层有多大 ✓✓(往往能发现"某层莫名其妙很大");
  2. docker diff <container>:看容器改了什么(找出写满磁盘的元凶)✓✓;
  3. docker stats:看 CPU/内存(这是 cgroups 的数据)✓;
  4. 把 COPY . /app 换成只 COPY 需要的文件:对比镜像大小与构建时间 ✓✓;
  5. 把基础镜像从 latest 改成固定版本:验证构建可重复 ✓。

9. 常见错误 ​

#错误说明
1COPY . 放在装依赖之前 ✗✗缓存全失效(332 秒 vs 30 秒)✓✓
2没有 .dockerignore ✗✗把 target/、.git、密钥打进镜像 ✓✓
3用 latest 标签 ✗✗构建不可重复 ✓
4日志写容器内文件 ✗✗可写层膨胀到磁盘满 ✓
5数据不挂卷 ✗✗容器删了数据没了 ✓✓
6用 root 跑应用 ✗✗逃逸后影响整个宿主 ✓
7以为容器 = 轻量虚拟机 ✗共享内核,隔离强度不同 ✓✓

10. 动手实验 ​

目标:亲手量出"镜像有多大、构建有多慢"。

步骤:

  1. 跑 ContainerModelLesson,记录省下的磁盘比例 ✓;
  2. 给你的项目写一个多阶段 Dockerfile,对比镜像大小 ★★★;
  3. 加 .dockerignore,看构建上下文变小多少 ✓;
  4. 故意把 COPY . 提前,量一次构建时间的退化 ✓✓;
  5. docker diff 一个跑了一天日志的容器,看可写层涨了多少 ✓。

验收方式:能说出"你的镜像有几层、哪层最大、构建时的瓶颈在哪一步"。

扩展任务:把镜像从 500 MB 优化到 100 MB 以内:

  • 基础镜像换成 alpine 或 distroless ✓✓;
  • 多阶段构建,只 COPY 最终产物 ✓✓;
  • 用 jlink 裁剪 JDK(只保留用到的模块)★★;
  • 说明:为什么"镜像小"不只是存储问题(提示:拉取时间 = 发布速度 = 回滚速度,《CI/CD 流水线设计》)✓✓

11. 练习题 ​

  1. 判断:容器是轻量级虚拟机。(对 / 错)
  2. 填空:容器 = ______ + ______ + ______。
  3. 简答:为什么"层是不可变的"这么重要?
  4. (Dockerfile 阅读)
dockerfile
FROM openjdk:latest
COPY . /app
RUN mvn package
ENTRYPOINT ["java", "-jar", "/app/target/app.jar"]

这个 Dockerfile 有几个问题? 5. (设计)给你的 Spring Boot 应用写一个 Dockerfile:要求最终镜像尽量小、构建尽量快。

参考答案(要点)
  1. 错。容器与宿主共享内核,隔离强度低于虚拟机 ✓★
  2. 文件系统分层;命名空间隔离;cgroups 资源限制 ✓
  3. 因为不可变才能被共享与缓存 ✓ —— 同一内容的层被多个镜像引用(省磁盘、省网络、省构建时间)✓✓★
  4. 四个问题:① latest 不可重复 ✗;② COPY . 在装依赖之前 → 缓存全失效 ✗✗; ③ 没有多阶段构建 → 镜像里带了 Maven 与源码 ✗✗;④ 没有 .dockerignore(会把 target/、.git 打进去)✗
  5. 要点:多阶段(builder 用 maven 镜像 + 运行用 JRE/alpine)✓✓; 先 COPY pom.xml 再 dependency:go-offline,最后 COPY src ✓✓; 固定版本标签、USER 非 root、.dockerignore ✓;日志出 stdout ✓

12. 掌握标准 ​

  • [ ] 能说出分层带来的三项收益 ★★★;
  • [ ] 能解释写时复制,并知道"日志写容器内"的坑 ★★★;
  • [ ] 能背出 Dockerfile 指令的排序规则 ★★★;
  • [ ] 知道多阶段构建与 .dockerignore 解决什么 ★★★;
  • [ ] 能说清容器与虚拟机的本质区别与安全推论 ★★★;
  • [ ] 知道容器 = 文件分层 + 命名空间 + cgroups ★★★;
  • [ ] 知道"用 latest 标签"违反可重复原则 ★★★。

13. 扩展资源 ​

14. 下一步学习 ​

《Docker Compose 与多容器编排》:为什么"本地能跑、CI 里偶发失败"。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引