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.0 image 创建一个临时 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:重用 latest tag 使得你难以在生产问题时回滚,因为不清楚具体运行的是哪个 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 大小。

dockerdevopscontainerimagespring-boot