Docker 基础:镜像、容器和层
1. 总览
很多初学 Docker 的人容易混淆 image 和 container,或者不明白为什么第二次 docker build 会这么快。本文解释三个基础概念:image、container 和 layer——附带一个真实的 Spring Boot 应用构建 image 的例子。读完这篇,你就能写出合理的 Dockerfile,懂得利用 layer 缓存加快构建,还能避免 Java 应用容器化时的几个常见问题。
2. Image 是什么
Image 是一个只读快照,包含运行应用所需的一切:基础操作系统、运行时(比如 JVM)、库、环境变量和你的代码。Image 不会自己运行——它就像一个“模板”,你从它创建 container。
你用 Dockerfile 来定义 image:
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
每一行的解释:
FROM:选择基础 image(Temurin 上的 JRE 21)。所有后续构建都建立在这个基础之上。WORKDIR:在 container 中设置工作目录,后续命令相对于这个目录运行。COPY:将预构建的 jar 文件(来自mvn package)复制到 image 中。ENTRYPOINT:container 启动时运行的命令。
用下面的命令构建 image:
docker build -t order-service:1.0 .
结果:
[+] Building 3.2s (8/8) FINISHED
=> [1/3] FROM eclipse-temurin:21-jre
=> [2/3] WORKDIR /app
=> [3/3] COPY target/order-service.jar app.jar
=> exporting to image
=> => naming to docker.io/library/order-service:1.0
现在 image 被保存在本地,你可以用 docker images 查看列表。
注意: Image 只是一个静态文件。用相同的 Dockerfile 和不变的代码运行多次
docker build不会 创建一个正在运行的应用——要运行它,你需要从 image 创建一个 container。
3. Container 是什么
Container 是从 image 创建的一个正在运行(或已停止)的实例。如果 image 是“类”,那么 container 就是“对象”——你可以从同一个 image 创建多个 container,每个 container 在进程、网络、可写文件系统层上都是独立的。
创建并运行一个 container:
docker run -d --name order-svc -p 8080:8080 order-service:1.0
-d:以分离模式(后台)运行。--name:给 container 命名以便管理,而不是使用随机 ID。-p 8080:8080:将主机的 8080 端口映射到 container 内的 8080 端口。
检查正在运行的 container:
docker ps
CONTAINER ID IMAGE STATUS PORTS NAMES
3f2a9c1b8d4e order-service:1.0 Up 5 seconds 0.0.0.0:8080->8080/tcp order-svc
你可以从同一个 image 运行第二个 container,只需更改名称和端口:
docker run -d --name order-svc-2 -p 8081:8080 order-service:1.0
这两个 container 完全独立:如果一个 container 崩溃或你向其文件系统写入文件,另一个 container 不会受到影响。原始 image(order-service:1.0)保持不变——这就是 image 只读特性的本质。
不需要 container 时停止并删除它:
docker stop order-svc && docker rm order-svc
3.1. Container 和虚拟机(VM)有什么区别
Container 与主机共享内核,不像虚拟机那样虚拟化整个操作系统。因此 container 在几秒内启动(如上面的构建日志所示),而不是像虚拟机那样需要几十秒,而且消耗的资源也少得多。
4. Layer 是什么以及为什么重要
Dockerfile 中的每一行命令(FROM、COPY、RUN、ADD 等)都会创建一个单独的 layer。这些 layer 相互堆叠(union filesystem),Docker 会缓存每一个 layer——如果某个 layer 与上一次构建相比没有改变,Docker 会复用缓存而不是重新构建。
考虑一个写法不优化的 Dockerfile:
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
ENTRYPOINT ["java", "-jar", "target/order-service.jar"]
问题在于:COPY . . 复制了所有源代码,即使你只改了一行注释。因为 COPY layer 改变了,Docker 被迫重新构建后面的 RUN mvn package layer——每次都要花几分钟,即使逻辑代码没变。
优化的方式是将依赖部分(较少改变)分离出来放在前面,将代码部分(经常改变)放在后面:
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
解释:
COPY pom.xml .然后RUN mvn dependency:go-offline:这个 layer 只在你添加/删除pom.xml中的依赖时改变。大多数时候,这个 layer 会被缓存——跳过下载库的步骤(通常最耗时)。COPY src ./src放在后面:只有这个 layer 及后续 layer 会重新构建,依赖 layer 仍保持缓存。- 多阶段构建(
AS build然后FROM ... jre):最后阶段只取已构建的 jar 文件,不携带 JDK 和 Maven 缓存——生产 image 会小得多。
查看 image 的各个 layer:
docker history order-service:1.0
IMAGE CREATED CREATED BY SIZE
a1b2c3d4e5f6 3 minutes ago ENTRYPOINT ["java" "-jar"...] 0B
7f8e9d0c1b2a 3 minutes ago COPY /app/target/order-ser… 45.2MB
...
每一行对应一个 layer,带上它添加的大小。COPY jar 的 layer 占大部分空间——这很正常,因为它包含应用的实际代码。
4.1. 重新构建时 layer 缓存如何工作
假设你只修改了一个 Java 类然后重新构建:
docker build -t order-service:1.1 .
=> [build 1/5] FROM eclipse-temurin:21-jdk
=> CACHED [build 2/5] COPY pom.xml .
=> CACHED [build 3/5] RUN mvn dependency:go-offline
=> [build 4/5] COPY src ./src
=> [build 5/5] RUN mvn package -DskipTests
=> [stage-1 2/2] COPY --from=build /app/target/order-service.jar app.jar
两行 CACHED 表示 Docker 跳过了下载依赖——比最初那个 COPY . . 的写法构建快得多。
注意: Docker 根据文件内容和命令顺序比较缓存,而不是基于时间戳。如果你改变
COPY或RUN的顺序,从那个点开始的所有 layer 都会失效,即使实际内容与上次相同。
5. 用自动化测试验证
当你容器化一个服务时,通常想确保构建的 image 能正确运行并通过健康检查。使用 Testcontainers 库来编写测试,确保 container 启动并返回正确的响应。
添加依赖:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.1</version>
<scope>test</scope>
</dependency>
编写测试:
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import static org.assertj.core.api.Assertions.assertThat;
class OrderServiceContainerTest {
@Test
void containerShouldRespondOnHealthEndpoint() {
try (GenericContainer<?> container =
new GenericContainer<>("order-service:1.0")
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/actuator/health"))) {
container.start();
assertThat(container.isRunning()).isTrue();
assertThat(container.getMappedPort(8080)).isPositive();
}
}
}
GenericContainer从order-service:1.0image 创建一个临时 container,当 try 块结束时自动清理。waitingFor(Wait.forHttp(...)):测试等待健康检查端点返回 2xx 状态后才认为 container 准备就绪,避免竞态条件。getMappedPort(8080):Docker 在主机上映射一个随机端口,测试获取实际端口号以便后续调用(如需要)。
运行这个测试确认你在第 2 节构建的 image 不仅构建成功,而且确实能运行——这是许多人忽视的验证步骤。
6. 常见错误
- 在清理时混淆 image 和 container:运行
docker rm删除 container,但 image 仍然存在并占用空间。要删除 image,使用docker rmi。 - 在安装依赖前复制整个源码:如第 4 节所述,这样做会损失 layer 缓存的好处,导致不必要的缓慢构建。
- 使用过重的基础 image(如完整的
ubuntu)用于 JRE 运行时:image 会变得很大,增加拉取/部署时间。应该使用精简的 JRE image,如eclipse-temurin:21-jre或兼容的-alpine版本。 - 构建新 image 却不改变 tag:重用
latesttag 使得你难以在生产问题时回滚,因为不清楚具体运行的是哪个 image。
什么时候不需要多阶段构建: 如果你在 CI 中预先构建 jar(比如 GitHub Actions),然后才
docker build,你不需要 Dockerfile 中的build阶段——只需像第 2 节示例那样COPY现成的 jar 文件。多阶段构建只在你想让 Docker 从源代码自动编译时才需要。
7. 总结
Image 是静态模板,container 是从 image 运行的实例,layer 是相互堆叠的层,帮助 Docker 智能缓存以加快构建。掌握 Dockerfile 中的 layer 顺序——将较少改变的部分(依赖)放在前面,经常改变的部分(源代码)放在后面——是优化构建时间的最简单方式。有了这个基础,你可以进一步学习如何用 Docker Compose 编排多个服务,或通过 distroless base 优化 image 大小。