# Docker Java SDK 使用教程
> Solomon Hykes — Docker 创始人
>
> Docker 提供了一个核心的抽象层,让你可以在任何基础设施上运行任何应用。这种抽象层的本质是:将应用的运行环境打包成一个标准化的单元,这个单元可以在任何地方运行。
那么,从这章开始,就让我们来感受一下,Docker Java SDK 为我们带来了什么。
***
## 前置Docker配置
在使用 Docker Java SDK 之前,我们需要确保本机的 Docker 环境已经正确配置。SDK 通过 Unix socket 或 TCP 协议与 Docker daemon 通信,因此需要根据实际情况选择合适的连接方式并完成相应配置。
### 用户组配置
默认情况下,Docker daemon 的 Unix socket 文件 `/var/run/docker.sock` 的所有者是 `root` 用户,所属组是 `docker`。如果当前用户没有 `docker` 用户组的权限,在连接 Docker 时会遇到权限拒绝的问题:
```
permission denied while trying to connect to the Docker daemon socket
```
解决方法是将当前用户添加到 `docker` 用户组中:
```bash
# 将当前用户添加到 docker 用户组
sudo usermod -aG docker $USER
# 查看当前用户所属 groups
groups
# 如果 usermod 不生效,可以尝试 newgrp 刷新组信息
newgrp docker
```
添加完成后,需要**重新登录系统**或**重启系统**让修改生效。我们可以通过以下命令验证是否配置成功:
```bash
# 查看 docker.sock 文件的权限
ls -la /var/run/docker.sock
# 输出示例:srw-rw---- 1 root docker 0 Sep 15 10:00 /var/run/docker.sock
# 注意权限中的 's' 表示 socket 文件,所属组为 docker
```
**思考:** 为什么 Docker 选择使用 Unix socket 而不是普通的文件来进行通信?
Unix socket 是一种进程间通信(IPC)机制,相比 TCP 连接,它在同一台机器上的通信效率更高,且不需要经过网络协议栈,安全性也更好。因此 Docker daemon 默认监听 Unix socket 作为主要的通信方式。
### 网络协议配置
Docker Java SDK 支持两种连接协议:**Unix socket** 和 **TCP**。
#### Unix socket 协议
Unix socket 是默认的连接方式,适用于 Docker daemon 运行在本机的场景。SDK 通过以下地址连接:
```
unix:///var/run/docker.sock
```
这种方式的优点是无需额外配置,安全性高(通过文件权限控制访问),但缺点是只能连接本机的 Docker daemon。
#### TCP 协议
当需要远程连接 Docker daemon 时,需要使用 TCP 协议。这要求 Docker daemon 启动时开启 TCP 端口监听。
**方法一:修改 Docker daemon 启动参数**
在 `/etc/docker/daemon.json` 中添加配置:
```json
{
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2375"]
}
```
**方法二:修改 systemd service 配置**
创建或编辑 systemd 配置文件:
```bash
sudo mkdir -p /etc/systemd/system/docker.service.d
sudo vi /etc/systemd/system/docker.service.d/override.conf
```
添加以下内容:
```ini
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2375 -H unix://var/run/docker.sock
```
然后重启 Docker 服务:
```bash
sudo systemctl daemon-reload
sudo systemctl restart docker
```
验证 TCP 监听是否生效:
```bash
# 查看 Docker daemon 监听的端口
ss -tlnp | grep 2375
# 或使用 curl 测试
curl http://localhost:2375/version
```
**注意:** 开启 TCP 监听后,任何能够访问该端口的客户端都可以操作 Docker daemon,存在严重的安全风险。在生产环境中,建议配合 TLS 证书进行加密通信。
#### TLS 安全通信配置
在生产环境中使用 TCP 协议时,应该启用 TLS 加密:
```bash
# 创建 TLS 证书(示例)
mkdir -p /etc/docker/certs
openssl req -x509 -newkey rsa:4096 -sha256 -days 365 \
-keyout /etc/docker/certs/server-key.pem \
-out /etc/docker/certs/server-cert.pem \
-nodes -subj "/CN=localhost"
# Docker daemon 启动参数
ExecStart=/usr/bin/dockerd \
--tlsverify \
--tlscacert=/etc/docker/ca.pem \
--tlscert=/etc/docker/certs/server-cert.pem \
--tlskey=/etc/docker/certs/server-key.pem \
-H tcp://0.0.0.0:2376 \
-H unix://var/run/docker.sock
```
### 连接方式对比
| 特性 | Unix socket | TCP | TCP + TLS |
|------|-------------|-----|-----------|
| 适用场景 | 本机连接 | 远程连接 | 远程连接(生产) |
| 安全性 | 高(文件权限) | 低(明文传输) | 高(证书加密) |
| 性能 | 高 | 中等 | 中等(加密开销) |
| 配置复杂度 | 低 | 中 | 高 |
| 默认端口 | 无 | 2375 | 2376 |
**思考:** 在微服务架构中,如果需要让应用通过 Docker Java SDK 管理容器化的服务,你会选择哪种连接方式?为什么?
***
## 快速入门
前面我们完成了 Docker 环境的配置,现在让我们正式开始使用 Docker Java SDK。本章将介绍如何创建 DockerClient、连接到 Docker daemon、拉取镜像,以及创建并启动一个容器。
### Maven 依赖
首先,我们需要在项目中引入 Docker Java SDK 的依赖。在 `pom.xml` 中添加以下配置:
```xml
com.github.docker-java
docker-java
3.7.1
com.github.docker-java
docker-java-transport-httpclient5
3.7.1
ch.qos.logback
logback-classic
1.5.38
```
这里我们需要两个核心依赖:`docker-java` 是 SDK 的核心库,定义了所有 API 接口和模型;`docker-java-transport-httpclient5` 是传输层实现,负责与 Docker daemon 进行 HTTP 通信。
### 创建 DockerClient
DockerClient 是 Docker Java SDK 的核心入口类,所有的 Docker 操作都通过它来执行。SDK 提供了两种创建方式:使用默认配置和自定义配置。
#### 使用默认配置
最简单的方式是使用 `DockerClientBuilder` 的默认配置,它会自动检测本机的 Docker 环境:
```java
package space.anyi.docker;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.core.DockerClientBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Docker Java SDK 快速入门示例
* 演示如何使用默认配置创建 DockerClient
*/
public class QuickStart {
private static final Logger log = LoggerFactory.getLogger(QuickStart.class);
/**
* 使用默认配置连接本地 Docker
* 默认配置会自动使用 Unix socket 连接本机 Docker daemon
*/
public void quickStart() {
// 使用默认配置创建 DockerClient 实例
// 内部会自动检测 Docker 环境并使用 unix:///var/run/docker.sock 连接
DockerClient dockerClient = DockerClientBuilder.getInstance().build();
// 使用 ping 命令测试连接是否成功
// 等价命令: docker version(连接自检,底层请求 HTTP GET /_ping)
dockerClient.pingCmd().exec();
log.info("Docker client ping 成功!");
// 列出所有本地镜像,验证连接正常
// 等价命令: docker images
dockerClient.listImagesCmd().exec().forEach(image -> {
log.info("Docker 镜像信息: {}", image);
});
}
}
```
这段代码展示了最基本的使用方式:通过 `DockerClientBuilder.getInstance().build()` 创建客户端,然后使用 `pingCmd()` 测试连接,最后列出所有镜像验证功能正常。
**思考:** `DockerClientBuilder.getInstance()` 内部做了什么?它是如何知道 Docker daemon 的地址的?
实际上,`DockerClientBuilder.getInstance()` 会读取环境变量 `DOCKER_HOST` 来确定 Docker daemon 的地址。如果该环境变量未设置,则默认使用 `unix:///var/run/docker.sock`。
#### 使用自定义配置
在实际项目中,我们通常需要自定义连接参数,比如指定 Docker daemon 地址、设置超时时间等:
```java
package space.anyi.docker;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.core.DockerClientImpl;
import com.github.dockerjava.core.DefaultDockerClientConfig;
import com.github.dockerjava.core.DockerClientConfig;
import com.github.dockerjava.httpclient5.ApacheDockerHttpClient;
import com.github.dockerjava.transport.DockerHttpClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
/**
* Docker Java SDK 自定义配置示例
* 演示如何使用自定义参数创建 DockerClient
*/
public class QuickStart {
private static final Logger log = LoggerFactory.getLogger(QuickStart.class);
// 连接地址配置
private static final String UNIX_HOST = "unix://var/run/docker.sock";
private static final String TCP_HOST = "tcp://0.0.0.0:2375";
/**
* 使用自定义配置连接 Docker
* 可以指定连接地址、超时时间、最大连接数等参数
*/
public void customStart() {
// 步骤1:构建 DockerClientConfig 配置对象
DockerClientConfig dockerClientConfig = DefaultDockerClientConfig
.createDefaultConfigBuilder()
.withDockerHost(UNIX_HOST) // 指定 Docker daemon 地址
// 以下为可选的 TLS 配置
//.withDockerTlsVerify(true)
//.withDockerCertPath("/home/user/.docker")
// 以下为可选的 Registry 认证配置
//.withRegistryUsername(registryUser)
//.withRegistryPassword(registryPass)
//.withRegistryEmail(registryMail)
//.withRegistryUrl(registryUrl)
.build();
log.info("Docker 客户端配置: {}", dockerClientConfig);
// 步骤2:构建 HTTP 传输层实例
DockerHttpClient httpClient = new ApacheDockerHttpClient.Builder()
.dockerHost(dockerClientConfig.getDockerHost()) // 使用配置中的地址
//.sslConfig(dockerClientConfig.getSSLConfig()) // SSL 配置
.maxConnections(100) // 最大连接数
.connectionTimeout(Duration.ofSeconds(30)) // 连接超时时间
.responseTimeout(Duration.ofSeconds(45)) // 响应超时时间
.build();
log.info("HTTP 客户端配置: {}", httpClient);
// 步骤3:根据自定义配置创建 DockerClient
DockerClient dockerClient = DockerClientImpl.getInstance(dockerClientConfig, httpClient);
// 测试连接
// 等价命令: docker version(连接自检)
dockerClient.pingCmd().exec();
log.info("自定义配置连接成功!");
// 列出所有镜像
// 等价命令: docker images
dockerClient.listImagesCmd().exec().forEach(image -> {
log.info("Docker 镜像信息: {}", image);
});
}
}
```
自定义配置的创建分为三个步骤:
1. 构建 `DockerClientConfig`:配置 Docker daemon 地址、TLS、Registry 认证等
2. 构建 `DockerHttpClient`:配置传输层参数,如连接数、超时时间等
3. 使用 `DockerClientImpl.getInstance()` 创建客户端实例
### 连接测试
创建 DockerClient 后,我们可以使用 `pingCmd()` 方法测试连接是否正常。如果连接失败,SDK 会抛出 `DockerException` 异常:
```java
try {
// 等价命令: docker version(连接自检)
dockerClient.pingCmd().exec();
log.info("Docker 连接成功!");
} catch (Exception e) {
log.error("Docker 连接失败: {}", e.getMessage());
}
```
我们还可以获取 Docker daemon 的版本信息,进一步验证连接:
```java
// 获取 Docker 版本信息
// 等价命令: docker version
String version = dockerClient.versionCmd().exec().getVersion();
log.info("Docker 版本: {}", version);
// 获取 Docker 系统信息
// 等价命令: docker info
Info info = dockerClient.infoCmd().exec();
log.info("Docker 系统信息: {}", info);
```
### 拉取镜像
拉取镜像是使用 Docker 的基本操作之一。SDK 提供了 `pullImageCmd()` 方法来拉取镜像:
```java
import com.github.dockerjava.api.command.PullImageCmd;
import com.github.dockerjava.api.command.PullImageResultCallback;
import com.github.dockerjava.api.model.PullResponseItem;
/**
* 拉取 Docker 镜像
* @param imageName 镜像名称,如 "hello-world" 或 "nginx:latest"
*/
public void pullImage(String imageName) {
try {
// 构建并执行拉取命令
// 等价命令: docker pull nginx:latest
// exec() 方法会阻塞直到镜像拉取完成
dockerClient.pullImageCmd(imageName)
.exec(new PullImageResultCallback())
.awaitCompletion();
log.info("镜像 {} 拉取成功!", imageName);
} catch (InterruptedException e) {
log.error("镜像拉取被中断: {}", e.getMessage());
Thread.currentThread().interrupt();
}
}
```
**注意:** `PullImageResultCallback` 是一个异步回调类,我们需要调用 `awaitCompletion()` 方法来等待拉取完成。如果不调用此方法,拉取操作会在后台异步执行,主线程可能无法获取到结果。
### 创建容器并启动
拉取镜像后,我们可以使用该镜像创建并启动一个容器。这个过程分为两步:先创建容器(create),再启动容器(start):
```java
import com.github.dockerjava.api.command.CreateContainerResponse;
import com.github.dockerjava.api.model.ExposedPort;
import com.github.dockerjava.api.model.Ports;
/**
* 创建并启动一个容器
* @param imageName 镜像名称
* @param containerName 容器名称
*/
public void createAndStartContainer(String imageName, String containerName) {
// 步骤1:创建容器
// 创建容器只是分配资源和配置,并不会真正运行
// 等价命令: docker create --name demo-container hello-world
CreateContainerResponse container = dockerClient.createContainerCmd(imageName)
.withName(containerName) // 设置容器名称
.exec();
log.info("容器创建成功,ID: {}", container.getId());
// 步骤2:启动容器
// 等价命令: docker start demo-container
// 上面两步合起来相当于: docker run --name demo-container hello-world
dockerClient.startContainerCmd(container.getId()).exec();
log.info("容器 {} 启动成功!", containerName);
}
```
创建容器时,我们可以通过链式调用设置各种参数:
```java
// 创建一个带有端口映射和环境变量的容器
CreateContainerResponse container = dockerClient.createContainerCmd("nginx:latest")
.withName("my-nginx")
.withEnv("NGINX_HOST=localhost", "NGINX_PORT=80") // 设置环境变量
.withExposedPorts(ExposedPort.tcp(80)) // 暴露端口
.withPortBindings(new Ports.Binding(8080, 80)) // 端口映射
.exec();
```
### 完整示例
下面是一个完整的示例,展示了从创建 DockerClient 到运行容器的整个流程:
```java
package space.anyi.docker;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.command.CreateContainerResponse;
import com.github.dockerjava.core.DefaultDockerClientConfig;
import com.github.dockerjava.core.DockerClientImpl;
import com.github.dockerjava.httpclient5.ApacheDockerHttpClient;
import com.github.dockerjava.transport.DockerHttpClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
/**
* Docker Java SDK 快速入门完整示例
*/
public class QuickStart {
private static final Logger log = LoggerFactory.getLogger(QuickStart.class);
private final DockerClient dockerClient;
public QuickStart() {
// 创建 DockerClient 实例
DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder().build();
DockerHttpClient httpClient = new ApacheDockerHttpClient.Builder()
.dockerHost(config.getDockerHost())
.maxConnections(100)
.connectionTimeout(Duration.ofSeconds(30))
.responseTimeout(Duration.ofSeconds(45))
.build();
this.dockerClient = DockerClientImpl.getInstance(config, httpClient);
}
/**
* 完整流程演示:连接 -> 拉取镜像 -> 创建容器 -> 启动容器
*/
public void runDemo() {
// 1. 测试连接
// 等价命令: docker version
dockerClient.pingCmd().exec();
log.info("1. Docker 连接成功");
// 2. 拉取 hello-world 镜像
try {
// 等价命令: docker pull nginx:latest
dockerClient.pullImageCmd("hello-world")
.exec(new com.github.dockerjava.api.command.PullImageResultCallback())
.awaitCompletion();
log.info("2. 镜像拉取成功");
} catch (InterruptedException e) {
log.error("镜像拉取被中断");
Thread.currentThread().interrupt();
return;
}
// 3. 创建并启动容器
// 等价命令: docker create --name demo-container hello-world
CreateContainerResponse container = dockerClient.createContainerCmd("hello-world")
.withName("demo-container")
.exec();
log.info("3. 容器创建成功,ID: {}", container.getId());
// 等价命令: docker start demo-container
// 上面两步合起来相当于: docker run --name demo-container hello-world
dockerClient.startContainerCmd(container.getId()).exec();
log.info("4. 容器启动成功");
}
public static void main(String[] args) {
new QuickStart().runDemo();
}
}
```
运行这段代码,你会看到 Docker 客户端成功连接、拉取镜像、创建并启动容器的完整过程。
**思考:** 为什么创建容器和启动容器要分成两步?直接一步完成不是更简单吗?
将创建和启动分开的设计允许我们在容器启动前进行更多配置,比如设置网络、挂载卷、配置资源限制等。这种设计也符合 Docker 的命令行操作习惯(`docker create` + `docker start`)。
***
## 镜像管理相关API
镜像(Image)是 Docker 的核心概念之一,它是一个只读的模板,包含了运行应用所需的所有内容:代码、运行时、系统工具、库和配置。Docker Java SDK 提供了一系列镜像管理 API,本章我们来逐一学习。
### listImagesCmd - 列出所有镜像
`listImagesCmd()` 方法用于列出 Docker daemon 上的镜像列表,对应 Docker CLI 的 `docker images` 命令。
```java
/**
* 列出自定义节点上所有存在的镜像
* @return 镜像列表
*/
public List listImages() {
// 等价命令: docker images -a(列出所有镜像,包括中间层缩影)
List images = dockerClient.listImagesCmd()
.withShowAll(true) // 展示所有镜像(包括中间层)
.exec();
log.info("本机共有 {} 个镜像", images.size());
return images;
}
```
`Image` 对象包含了镜像的核心信息:
| 字段 | 说明 |
|------|------|
| `getId()` | 镜像的完整 ID(sha256 哈希值) |
| `getRepoTags()` | 镜像的仓库和标签列表,如 `["nginx:latest", "nginx:1.25"]` |
| `getRepoDigests()` | 镜像摘要 |
| `getSize()` | 镜像大小(字节) |
| `getCreated()` | 创建时间戳 |
| `getLabels()` | 镜像标签(Label) |
| `getContainers()` | 使用该镜像的容器数量 |
**思考:** 为什么 `listImagesCmd()` 默认不包含中间层镜像?`withShowAll(true)` 开启了什么?
Docker 的镜像采用分层存储架构,一个镜像由多个 Layer 叠加而成。中间层镜像(dangling image)通常没有标签,它们只是构建过程中的临时产物。`withShowAll(true)` 会将这些中间层镜像也展示出来,方便排查磁盘占用问题。
### inspectImageCmd - 获取镜像详细信息
当我们需要深入了解一个镜像的完整信息时,可以使用 `inspectImageCmd()` 方法:
```java
/**
* 获取镜像的详细信息
* 包括镜像的 ID、仓库、标签、创建时间、大小、Layer 等
* @param imageId 镜像 ID 或名称
* @return 镜像详情
*/
public InspectImageResponse inspectImage(String imageId) {
// 等价命令: docker inspect
InspectImageResponse response = dockerClient.inspectImageCmd(imageId).exec();
log.info("镜像 {} 详情: {}", imageId, response);
return response;
}
```
`InspectImageResponse` 返回的信息比 `Image` 更为详细,主要包括:
| 字段 | 说明 |
|------|------|
| `getId()` | 镜像 ID |
| `getArch()` | 支持的 CPU 架构 |
| `getOs()` | 操作系统类型 |
| `getConfig()` | 镜像的配置信息(包括环境变量、入口命令等) |
| `getContainerConfig()` | 构建镜像时的容器配置 |
| `getRootFs()` | 镜像的文件系统层次 |
| `getDockerVersion()` | 构建镜像时使用的 Docker 版本 |
**思考:** `inspectImageCmd()` 和 `listImagesCmd()` 的返回信息有什么区别?什么场景下需要用 `inspect`?
`listImagesCmd()` 返回的是简要信息,适合批量展示;`inspectImageCmd()` 返回的是完整 JSON 详情,适合获取镜像的配置细节,比如环境变量、入口命令、暴露端口等。在需要根据镜像配置来决定如何启动容器时,`inspect` 非常有用。
### pullImageCmd - 拉取镜像(异步回调机制)
`pullImageCmd()` 用于从远程仓库拉取镜像,对应 `docker pull` 命令。与前面几个方法不同,拉取镜像是一个**异步操作**,需要借助回调机制来处理结果:
```java
/**
* 拉取指定名称的镜像
* @param imageName 镜像名称
* @return 拉取是否成功
*/
public boolean pullImage(String imageName) {
try {
// 等价命令: docker pull ubuntu:latest
// exec() 执行命令,返回异步回调
// awaitCompletion() 阻塞等待镜像拉取完成
dockerClient.pullImageCmd(imageName)
.exec(new PullImageResultCallback())
.awaitCompletion();
log.info("镜像 {} 拉取成功!", imageName);
return true;
} catch (InterruptedException e) {
log.error("镜像拉取被中断: {}", e.getMessage());
Thread.currentThread().interrupt();
return false;
}
}
```
`PullImageResultCallback` 继承自 `ResultCallback.Adapter`,它会持续接收 Docker daemon 推送过来的拉取进度。`awaitCompletion()` 方法会阻塞当前线程,直到拉取完成或发生异常。
**思考:** 为什么要设计成异步回调模式?如果直接返回结果不是更简单吗?
镜像拉取可能是一个漫长的过程(大型镜像可能需要数分钟),如果将这个方法设计为同步阻塞,那么调用线程会被长时间占用。异步回调模式允许我们在等待拉取的同时执行其他操作,比如拉取多个镜像时,可以并发执行。
### removeImageCmd - 删除镜像
删除镜像使用 `removeImageCmd()` 方法,对应 `docker rmi` 命令:
```java
/**
* 删除本地镜像
* @param imageId 镜像 ID 或名称
* @param force 是否强制删除(即使镜像被容器使用)
*/
public void removeImage(String imageId, boolean force) {
// 等价命令: docker rmi
// 等价命令: docker rmi -f (force=true 时强制删除)
dockerClient.removeImageCmd(imageId)
.withForce(force) // 强制删除
.exec();
log.info("镜像 {} 已删除", imageId);
}
```
**注意:** 如果镜像正在被某个容器使用,普通删除会失败。此时需要设置 `force=true` 强制删除,或者先删除容器再删除镜像。
### tagImageCmd - 标记镜像
`tagImageCmd()` 用于给镜像添加标签,对应 `docker tag` 命令,常用于给镜像重命名或在本地建立镜像副本:
```java
/**
* 为镜像添加标签(相当于 docker tag)
* @param imageId 源镜像 ID 或名称
* @param repository 新的仓库名
* @param tag 新的标签名
*/
public void tagImage(String imageId, String repository, String tag) {
// 等价命令: docker tag :
dockerClient.tagImageCmd(imageId, repository, tag).exec();
log.info("镜像 {} 已标记为 {}:{}", imageId, repository, tag);
}
```
例如,当我们拉取了 `nginx:latest` 镜像后,想将其标记为 `my-nginx:v1.0`:
```java
api.tagImage("nginx:latest", "my-nginx", "v1.0");
```
### saveImageCmd / loadImageCmd - 导入导出镜像
Docker 还支持将镜像导出为 tar 文件,或者从 tar 文件导入镜像。这在离线部署场景中非常有用:
```java
// 将镜像导出为 tar 文件
public void saveImage(String imageId, String filePath) {
try (OutputStream outputStream = new FileOutputStream(filePath)) {
// 等价命令: docker save -o nginx-backup.tar
dockerClient.saveImageCmd(imageId).exec(outputStream);
log.info("镜像 {} 已导出到 {}", imageId, filePath);
} catch (IOException e) {
log.error("导出镜像失败: {}", e.getMessage());
}
}
// 从 tar 文件加载镜像
public void loadImage(String filePath) {
try (InputStream inputStream = new FileInputStream(filePath)) {
// 等价命令: docker load -i nginx-backup.tar
dockerClient.loadImageCmd(inputStream)
.exec(new LoadImageResultCallback())
.awaitCompletion();
log.info("镜像已从 {} 加载成功", filePath);
} catch (IOException | InterruptedException e) {
log.error("加载镜像失败: {}", e.getMessage());
}
}
```
**思考:** 镜像导出和导入在什么场景下会用到?与直接在网络上拉取镜像相比有什么优势?
镜像导出/导入适用于**离线环境**或**内网环境**,比如生产环境无法直接访问 Docker Hub 时,可以在有网络的机器上导出镜像,然后通过 U 盘或内网传输到目标机器上加载。这种方式不依赖网络连接,但文件传输本身需要额外的存储空间。
### 镜像管理完整示例
```java
// 列出所有镜像
List images = api.listImages();
images.forEach(img -> log.info("镜像: {}, 大小: {}MB",
Arrays.toString(img.getRepoTags()), img.getSize() / 1024 / 1024));
// 检查镜像是否存在
boolean exists = api.checkImageExists("nginx:latest");
if (!exists) {
api.pullImage("nginx:latest"); // 拉取
}
// 查看镜像详情
InspectImageResponse info = api.inspectImage("nginx:latest");
log.info("镜像架构: {}, 操作系统: {}", info.getArch(), info.getOs());
// 给镜像打标签
api.tagImage("nginx:latest", "my-nginx", "v1.0");
// 删除镜像
api.removeImage("my-nginx:v1.0", false);
```
***
## 镜像构建相关API
前面我们学习了镜像的基本管理操作,本章将介绍如何使用 Docker Java SDK **构建自定义镜像**。镜像构建是 Docker 最强大的功能之一,它允许我们将应用打包成标准化的镜像,实现"一次构建,到处运行"。
### buildImageCmd - 从 Dockerfile 构建镜像
`buildImageCmd()` 对应 `docker build` 命令,它需要指定构建上下文(包含 Dockerfile 和构建所需的文件)。
```java
/**
* 从 Dockerfile 构建镜像
* @param dockerfilePath Dockerfile 文件路径
* @param imageName 构建目标镜像名称(repository:tag)
* @return 构建成功返回镜像 ID,失败返回 null
*/
public String buildImage(String dockerfilePath, String imageName) {
File dockerfile = new File(dockerfilePath);
if (!dockerfile.exists()) {
log.error("Dockerfile 不存在: {}", dockerfilePath);
return null;
}
try {
// 等价命令: docker build -f Dockerfile -t sdk-demo/hello:latest .
// withDockerfile 指定 Dockerfile 文件位置
// withBaseDirectory 设置构建上下文(当前目录)
// withTag 指定构建后的镜像名称和标签
// exec() 返回异步回调,awaitImageId() 阻塞等待构建完成并返回镜像 ID
String imageId = dockerClient.buildImageCmd()
.withDockerfile(dockerfile)
.withBaseDirectory(dockerfile.getParentFile())
.withTag(imageName)
.exec(new BuildImageResultCallback())
.awaitImageId();
log.info("镜像 {} 构建成功,镜像 ID: {}", imageName, imageId);
return imageId;
} catch (Exception e) {
log.error("镜像构建失败: {}", e.getMessage());
return null;
}
}
```
构建过程的关键参数:
| 方法 | 说明 |
|------|------|
| `withDockerfile(File)` | 指定 Dockerfile 文件路径 |
| `withBaseDirectory(File)` | 指定构建上下文目录(Dockerfile 中 COPY 指令从此目录读取文件) |
| `withTag(String)` | 指定构建出的镜像名称和标签(如 `sdk-demo/hello:latest`) |
| `withRemove(boolean)` | 构建完成后是否移除中间层容器 |
| `withForcerm(boolean)` | 是否强制移除中间层容器(当构建失败时) |
| `withBuildArg(String, String)` | 传递构建参数(对应 ARG 指令) |
`BuildImageResultCallback` 是构建过程的异步回调,各个方法的含义:
| 方法 | 说明 |
|------|------|
| `onNext(BuildResponseItem)` | 每收到一个构建事件时回调(如缓存命中、步骤执行等) |
| `onError(Throwable)` | 构建出错时回调 |
| `awaitImageId()` | 阻塞等待构建完成,返回镜像 ID(超时则抛出异常) |
| `awaitCompletion()` | 阻塞等待构建完成,无返回值 |
**思考:** 构建上下文(BaseDirectory)的作用是什么?为什么需要单独指定 Dockerfile 的位置?
构建上下文是 Docker 构建时能"看到"的所有文件所在的目录。当 Dockerfile 中使用 `COPY app.jar /app/` 时,这个 `app.jar` 就是从构建上下文中寻找的。将 Dockerfile 放在构建上下文之外(通过 `withDockerfile` 指定)是允许的,但 COPY 指令仍然只能访问构建上下文内的文件。
### 构建流程详解
构建一个镜像的过程大致如下:
```
Dockerfile(构建指令)
|
v
构建上下文 (BaseDirectory) ──► Docker daemon 执行每个指令
|
| 1. FROM alist666/alist:latest ← 拉取/复用基础镜像
| 2. RUN echo "..." > /info.txt ← 执行命令,创建新 Layer
|
v
构建完成的镜像 (imageName:tag)
```
每次 `RUN`、`COPY` 等指令都会创建一个新的镜像 Layer,所有 Layer 叠加在一起就是最终的镜像。这个分层机制使得镜像可以被高效地缓存和复用。
### 示例:构建一个带标识文件的自定义镜像
假设我们有如下 Dockerfile:
```dockerfile
FROM registry.fit2cloud.com/halo/halo-pro:2.24
# 构建时添加一个标识文件,演示 Dockerfile 的 RUN 指令
RUN echo "Built by Docker Java SDK" > /build-info.txt
# 容器启动时输出构建信息
CMD ["sh", "-c", "cat /build-info.txt"]
```
使用我们封装好的 `ImageBuildAPI` 构建并验证:
```java
ImageBuildAPI api = new ImageBuildAPI();
String imageId = api.buildImage("src/test/resources/Dockerfile", "sdk-demo/hello:latest");
log.info("构建结果: {}", imageId);
// 验证镜像已存在
List images = api.findImageByName("sdk-demo/hello:latest");
log.info("找到 {} 个匹配镜像", images.size());
```
运行测试可以看到构建的输出:
```
镜像 sdk-demo/hello:latest 构建成功,镜像 ID: sha256:xxxx
镜像 sdk-demo/hello:latest 是否存在: true
```
**思考:** 为什么 `withRemove(true)` 和 `withForcerm(true)` 的组合在 CI/CD 流水线中很重要?
在自动化构建过程中,如果每次构建都留下大量的中间层容器,会逐渐耗尽磁盘空间。`withRemove(true)` 确保正常完成后清理中间层,`withForcerm(true)` 则在构建失败时也能强制清理,避免残留。
***
## 容器管理相关API
容器(Container)是 Docker 的另一核心概念,它是镜像的**运行实例**。如果说镜像是"类",那容器就是"对象"。本章将系统学习容器管理的各项 API。
### 容器的完整生命周期
容器的生命周期可以分为几个阶段:
```
创建 (create) → 启动 (start) → 运行中 (running)
↓
停止 (stop) / 强制终止 (kill)
↓
删除 (remove)
```
Docker 的设计哲学是:**创建**和**启动**是分离的操作。创建容器只分配资源和配置,容器处于 `created` 状态;启动后进入 `running` 状态。这种分离设计让我们可以在启动前完成网络、卷、资源限制等配置。
### createContainerCmd - 创建容器
```java
/**
* 创建容器(不启动)
* @param imageName 镜像名称
* @param containerName 容器名称
* @return 创建的容器响应(包含容器 ID)
*/
public CreateContainerResponse createContainer(String imageName, String containerName) {
// 创建容器:分配资源配置,但不会真正运行
// 等价命令: docker create --name my-container nginx:latest
// withName 指定容器名称(可选,不指定时 Docker 自动生成)
CreateContainerResponse response = dockerClient.createContainerCmd(imageName)
.withName(containerName)
.exec();
log.info("容器创建成功,ID: {}, 名称: {}", response.getId(), containerName);
return response;
}
```
创建容器时常用的配置项:
| 方法 | 说明 |
|------|------|
| `withName(String)` | 指定容器名称 |
| `withEnv(String...)` | 设置环境变量 |
| `withExposedPorts(ExposedPort...)` | 声明容器内暴露的端口 |
| `withPortBindings(Ports)` | 配置端口映射(宿主机端口 -> 容器内端口) |
| `withCmd(String...)` | 覆盖镜像中定义的启动命令 |
| `withWorkingDir(String)` | 设置容器内工作目录 |
| `withHostConfig(HostConfig)` | 设置资源限制、挂载卷、网络模式等 |
创建带环境变量和端口映射的容器:
```java
// 创建带环境变量的容器
// 等价命令: docker create --name my-container -e MYSQL_ROOT_PASSWORD=123456 nginx:latest
dockerClient.createContainerCmd("nginx:latest")
.withName("my-nginx")
.withEnv("NGINX_HOST=localhost", "NGINX_PORT=80")
.exec();
// 创建带端口映射的容器
// 等价命令: docker create --name my-container -p 8080:80 nginx:latest
dockerClient.createContainerCmd("nginx:latest")
.withName("my-nginx")
.withExposedPorts(ExposedPort.tcp(80))
.withPortBindings(new Ports.Binding(8080, 80))
.exec();
```
### startContainerCmd - 启动容器
创建容器后,使用 `startContainerCmd()` 启动它:
```java
/**
* 启动容器
* @param containerId 容器 ID
*/
public void startContainer(String containerId) {
// 等价命令: docker start
dockerClient.startContainerCmd(containerId).exec();
log.info("容器 {} 启动成功", containerId);
}
```
**思考:** 在快速入门章节我们看到了 `CreateContainerResponse.getId()`,它返回完整的容器 ID。为什么大多数 API 都要求传容器 ID 而不是名称?
容器 ID 是容器的唯一标识,不会重复。而容器名称虽然也可以用于操作(Docker daemon 会解析),但在某些场景下名称可能不存在或重复。使用 ID 是最稳妥的方式。SDK 也支持传入短 ID 或名称,Docker daemon 会自动解析。
### stopContainerCmd - 停止容器
停止容器采用"优雅退出"机制:首先向容器进程发送 `SIGTERM` 信号,等待一段时间后仍未退出则发送 `SIGKILL` 强制终止:
```java
/**
* 停止容器(等待容器优雅退出)
* @param containerId 容器 ID
* @param timeoutSeconds 等待时长(秒),超过则强制终止
*/
public void stopContainer(String containerId, Integer timeoutSeconds) {
// 等价命令: docker stop -t 10 (先发 SIGTERM,超时后 SIGKILL)
dockerClient.stopContainerCmd(containerId)
.withTimeout(timeoutSeconds) // SIGTERM 后等待时间
.exec();
log.info("容器 {} 已停止(等待 {} 秒)", containerId, timeoutSeconds);
}
```
### killContainerCmd - 强制停止容器
与 `stopContainerCmd` 不同,`killContainerCmd` 直接发送 `SIGKILL` 信号,进程立即终止,不给优雅退出的机会:
```java
/**
* 强制停止容器(立即发送 SIGKILL)
* @param containerId 容器 ID
*/
public void killContainer(String containerId) {
// 等价命令: docker kill (立即发送 SIGKILL,不给优雅退出机会)
dockerClient.killContainerCmd(containerId).exec();
log.info("容器 {} 已被强制停止", containerId);
}
```
**思考:** 什么场景下应该用 stop,什么场景下应该用 kill?
`stop` 会先给进程发 `SIGTERM`,让应用有机会保存数据、释放资源(比如数据库需要执行清理操作);`kill` 则是立即杀死进程,可能导致数据丢失。生产环境中应该优先使用 `stop`,只有在进程无法响应或严格限时的情况下才使用 `kill`。
### removeContainerCmd - 删除容器
删除容器时需要考虑两个选项:
```java
/**
* 删除容器
* @param containerId 容器 ID
* @param force 是否强制删除(容器运行中也删除)
* @param removeVolumes 是否同时删除关联的卷
*/
public void removeContainer(String containerId, boolean force, boolean removeVolumes) {
// 等价命令: docker rm
// 等价命令: docker rm -f -v (同时强制停止并删除数据卷)
dockerClient.removeContainerCmd(containerId)
.withForce(force) // 强制删除运行中的容器
.withRemoveVolumes(removeVolumes) // 删除关联数据卷
.exec();
log.info("容器 {} 已删除", containerId);
}
```
### listContainersCmd - 列出容器
列出容器对应 `docker ps` 命令。默认只显示运行中的容器,`withShowAll(true)` 则显示所有容器(包括已停止的):
```java
/**
* 列出所有容器(包括停止的)
* @return 容器列表
*/
public List listContainers() {
// 等价命令: docker ps -a(列出所有容器,包括已停止的)
// withShowAll(true) 展示所有容器(不只运行中的)
List containers = dockerClient.listContainersCmd()
.withShowAll(true)
.exec();
log.info("本机共有 {} 个容器", containers.size());
return containers;
}
```
`Container` 对象包含的信息:
| 字段 | 说明 |
|------|------|
| `getId()` | 容器 ID |
| `getNames()` | 容器名称列表(以 `/` 开头) |
| `getImage()` | 使用的镜像名称 |
| `getState()` | 容器状态(running / exited 等) |
| `getStatus()` | 状态描述(如 "Up 2 hours") |
| `getPorts()` | 端口映射信息 |
| `getLabels()` | 容器标签 |
### inspectContainerCmd - 获取容器详细信息
与镜像类似,容器也有详查方法。它返回容器的完整配置、网络、挂载信息:
```java
/**
* 获取容器详细信息
* @param containerId 容器 ID
* @return 容器详情
*/
public InspectContainerResponse inspectContainer(String containerId) {
// 等价命令: docker inspect (查看容器完整 JSON 详情)
InspectContainerResponse response = dockerClient.inspectContainerCmd(containerId).exec();
log.info("容器 {} 状态: {}, 名称: {}",
containerId, response.getState().getStatus(), response.getName());
return response;
}
```
`InspectContainerResponse` 的关键信息:
| 字段 | 说明 |
|------|------|
| `getId()` | 容器 ID |
| `getName()` | 容器名称 |
| `getState()` | 容器状态(含 Running、Status、StartedAt 等) |
| `getConfig()` | 容器配置(镜像、环境变量、命令等) |
| `getNetworkSettings()` | 网络配置(端口映射、IP 地址等) |
| `getMounts()` | 挂载的卷 |
| `getHostConfig()` | 宿主机相关配置 |
| `getCreated()` | 创建时间 |
### 完整生命周期示例
```java
public void containerLifecycleDemo(String imageName, String containerName) {
// 1. 创建容器
CreateContainerResponse container = createContainer(imageName, containerName);
// 2. 启动容器
startContainer(container.getId());
// 3. 查询状态
inspectContainer(container.getId());
// 4. 停止容器
stopContainer(container.getId(), 10);
// 5. 删除容器
removeContainer(container.getId(), false, false);
log.info("容器 {} 生命周期演示完成", containerName);
}
```
**思考:** 查看 `Container` 的 `getNames()` 返回的数组——为什么是一个数组而不是单个字符串?
Docker 的设计中,一个容器可以有多个名称(别名),通常是 `/容器名`。当使用 `--link` 方式连接容器时,会为被连接的容器创建额外的名称。虽然现代 Docker 更推荐使用自定义网络,但 `getNames()` 的设计保留了这种能力。
***
## 容器运维相关API
前面我们学习了容器的基础管理(创建、启动、停止、删除),本章将深入讲解容器的**运维操作**:查看日志、在容器中执行命令、文件复制、等待容器退出等。这些操作是日常运维中最常用的功能。
### logsCmd - 获取容器日志
获取容器日志对应 `docker logs` 命令。日志以**帧(Frame)**的方式流式返回,每帧包含数据类型和载荷:
```java
/**
* 获取容器日志(同步方式)
* @param containerId 容器 ID
* @param tailLines 只显示末尾的行数
* @return 日志文本
*/
public String getContainerLogs(String containerId, int tailLines) {
StringBuilder logBuilder = new StringBuilder();
try {
// 等价命令: docker logs --tail 10
// withTail 只获取末尾 N 行
LogContainerCmd cmd = dockerClient.logContainerCmd(containerId)
.withStdOut(true) // 获取标准输出
.withStdErr(true) // 获取错误输出
.withTail(tailLines); // 只取末尾几行
cmd.exec(new ResultCallback.Adapter<>() {
@Override
public void onNext(Frame frame) {
// 日志以帧(Frame)形式返回,每帧包含不同类型的数据
if (frame.getStreamType() == FrameType.STDOUT ||
frame.getStreamType() == FrameType.STDERR ||
frame.getStreamType() == FrameType.RAW) {
logBuilder.append(new String(frame.getPayload()));
}
}
}).awaitCompletion();
return logBuilder.toString();
} catch (InterruptedException e) {
log.error("获取容器日志被中断: {}", e.getMessage());
Thread.currentThread().interrupt();
return logBuilder.toString();
}
}
```
常用的日志选项:
| 方法 | 说明 |
|------|------|
| `withStdOut(true)` | 获取标准输出流 |
| `withStdErr(true)` | 获取标准错误流 |
| `withTail(int)` | 只获取末尾 N 行(类似 `tail -n`) |
| `withSince(long)` | 获取某个时间戳之后的日志 |
| `withTimestamps(true)` | 在日志前添加时间戳 |
| `withFollow(boolean)` | 是否持续跟随(类似 `-f`) |
**思考:** 为什么日志要使用 Frame(帧)而不是纯文本?FrameType 的作用是什么?
Docker 容器的日志实际上包含两个数据流:标准输出(STDOUT)和标准错误(STDERR)。如果只用纯文本,无法区分一行日志到底来自哪个流。Frame 的 `streamType` 字段标记了数据来源,这样上层可以根据需求选择性处理(比如只收集错误日志用于告警)。
### execCreateCmd + execStartCmd - 在容器中执行命令
在运行中的容器内部执行命令,对应 `docker exec` 命令。它分为**两步**:
1. `execCreateCmd`:创建 exec 实例,描述要执行什么命令
2. `execStartCmd`:真正执行命令并接收输出
```java
/**
* 在容器中执行命令(同步方式)
* @param containerId 容器 ID
* @param command 要执行的命令参数,如 ["ls", "-la"] 或 ["echo", "hello"]
* @return 命令输出的文本
*/
public String execCommandInContainer(String containerId, String... command) {
StringBuilder output = new StringBuilder();
try {
// 等价命令: docker exec ls -la /
// 1. 创建 exec 实例(描述要在容器中执行什么命令)
ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId)
.withCmd(command) // 要执行的命令
.withAttachStdout(true) // 挂接标准输出
.withAttachStderr(true) // 挂接错误输出
.exec();
// 2. 启动 exec(真正执行命令,并接收输出)
dockerClient.execStartCmd(execCreateCmdResponse.getId())
.exec(new ResultCallback.Adapter<>() {
@Override
public void onNext(Frame frame) {
if (frame.getStreamType() == FrameType.STDOUT ||
frame.getStreamType() == FrameType.STDERR) {
output.append(new String(frame.getPayload()));
}
}
}).awaitCompletion();
return output.toString();
} catch (InterruptedException e) {
log.error("执行容器命令被中断: {}", e.getMessage());
Thread.currentThread().interrupt();
return output.toString();
}
}
```
**思考:** 为什么 `exec` 要分为 create 和 start 两步?为什么不一步到位?
这个设计参考了 fork/exec 的 POSIX 模型:create 阶段创建新的进程上下文(包括环境变量、工作目录、附加流),start 阶段真正在目标容器内启动这个进程。分离的好处是可以在真正执行前检查命令是否合法、权限是否足够,失败时可以提前阻止而不产生任何影响。
### waitContainerCmd - 等待容器退出
`waitContainerCmd` 用于阻塞当前线程,直到容器退出并返回退出码:
```java
/**
* 等待容器退出
* @param containerId 容器 ID
* @param timeoutSeconds 超时时间(秒)
* @return 容器退出码(-1 表示超时)
*/
public int waitContainer(String containerId, int timeoutSeconds) {
try {
// 等价命令: docker wait (阻塞直到容器退出,返回退出码)
WaitContainerResultCallback callback = dockerClient.waitContainerCmd(containerId)
.exec(new WaitContainerResultCallback());
// 阻塞等待容器退出,设置超时
int exitCode = callback.awaitStatusCode(timeoutSeconds, TimeUnit.SECONDS);
log.info("容器 {} 已退出,退出码: {}", containerId, exitCode);
return exitCode;
} catch (InterruptedException e) {
log.error("等待容器退出被中断: {}", e.getMessage());
Thread.currentThread().interrupt();
return -1;
}
}
```
这个 API 在 CI/CD 场景中非常有用:我们可以启动一个容器执行测试任务,然后等待它退出并检查退出码来判断测试是否通过。
### 运维完整示例
一个完整的运维操作流程如下:
```java
public void opsDemo() {
String containerName = "ops-demo-" + System.currentTimeMillis();
try {
// 1. 创建一个执行 echo 命令的容器
String id = createTemporaryContainer(containerName, "sh", "-c", "echo HelloOps && sleep 999");
log.info("1. 临时容器已创建并启动: {}", id);
// 2. 在容器中执行命令
String result = execCommandInContainer(id, "ls", "-la", "/");
log.info("2. 容器内 ls -la / 输出:\n{}", result);
// 3. 获取容器日志
String logs = getContainerLogs(id, 10);
log.info("3. 容器日志:\n{}", logs);
// 4. 清理容器
// 等价命令: docker rm -f (测试清理用)
dockerClient.removeContainerCmd(id).withForce(true).exec();
log.info("4. 临时容器已清理");
} catch (Exception e) {
log.error("运维演示失败: {}", e.getMessage());
}
}
```
**思考:** 结合前面学习的 exec 与 logs 场景,在"容器内执行命令"获取到的输出与"查看容器日志"获取到的是否相同?它们有什么区别?
`exec` 获取的是**在运行中的容器里新执行命令**的输出,相当于 `docker exec `;而 `logs` 获取的是**容器主进程**(PID 1)启动以来产生的日志,相当于 `docker logs `。两者针对的是不同数据源:exec 是临时命令的输出,logs 是容器自身的运行日志。
***
## DockerClient 核心对象及 API 对照表
经过前面章节的学习,我们已经掌握了 Docker Java SDK 的主要使用方法。本章将对 `DockerClient` 接口的全部方法进行归类整理,形成一份**速查表**,方便日常开发时快速查找。
### DockerClient 核心架构
`DockerClient` 是整个 SDK 的统一入口,所有 Docker 操作都通过它来分发。它的方法返回值是各种 `*Cmd` 接口,通过链式调用设置参数后,调用 `.exec()` 真正执行。
```java
// DockerClient 方法的调用模式
dockerClient.<命令方法>(<参数>)
.<参数方法>(<值>) // 链式调用
.<参数方法>(<值>)
.exec(); // 真正执行,返回响应或回调
```
### API 全局概览
以下表格列出了 `DockerClient` 接口的全部方法,按功能分类:
#### 一、Docker Daemon 基本信息
| 方法 | 对应命令 | 说明 |
|------|----------|------|
| `pingCmd()` | 无对应 CLI(HTTP `/_ping`) | 检测 Docker daemon 连接是否正常 |
| `infoCmd()` | `docker info` | 获取 Docker daemon 系统信息 |
| `versionCmd()` | `docker version` | 获取 Docker 版本信息 |
| `authCmd()` | — | 进行 Registry 认证 |
| `eventsCmd()` | `docker events` | 实时监听 Docker 事件 |
#### 二、镜像管理
| 方法 | 对应命令 | 说明 |
|------|----------|------|
| `listImagesCmd()` | `docker images` | 列出本地所有镜像 |
| `inspectImageCmd(id)` | `docker inspect` (镜像) | 获取镜像完整详情 |
| `pullImageCmd(name)` | `docker pull` | 拉取镜像(异步回调) |
| `pushImageCmd(name)` | `docker push` | 推送镜像到 Registry |
| `removeImageCmd(id)` | `docker rmi` | 删除本地镜像 |
| `tagImageCmd(id, repo, tag)` | `docker tag` | 为镜像添加标签 |
| `saveImageCmd(id)` | `docker save` | 将镜像导出为 tar 流 |
| `saveImagesCmd()` | `docker save` (多个) | 批量导出多个镜像 |
| `loadImageCmd(inputStream)` | `docker load` | 从 tar 流加载镜像 |
| `searchImagesCmd(keyword)` | `docker search` | 搜索 Registry 中的镜像 |
| `imageHistoryCmd(id)` | `docker history` | 查看镜像构建历史 |
| `createImageCmd(name, stream)` | `docker import` | 从 tar 流创建镜像 |
#### 三、镜像构建
| 方法 | 对应命令 | 说明 |
|------|----------|------|
| `buildImageCmd()` | `docker build` | 从 Dockerfile 构建镜像 |
| `buildImageCmd(file)` | `docker build` | 指定 Dockerfile 构建 |
| `buildImageCmd(inputStream)` | `docker build` | 从 tar 包构建镜像 |
#### 四、容器生命周期管理
| 方法 | 对应命令 | 说明 |
|------|----------|------|
| `createContainerCmd(image)` | `docker create` | 创建容器(不启动) |
| `startContainerCmd(id)` | `docker start` | 启动容器 |
| `stopContainerCmd(id)` | `docker stop` | 优雅停止容器(先 SIGTERM,再 SIGKILL) |
| `killContainerCmd(id)` | `docker kill` | 强制杀死容器(立即 SIGKILL) |
| `restartContainerCmd(id)` | `docker restart` | 重启容器 |
| `pauseContainerCmd(id)` | `docker pause` | 暂停容器(使用 cgroup freezer) |
| `unpauseContainerCmd(id)` | `docker unpause` | 恢复已暂停容器 |
| `removeContainerCmd(id)` | `docker rm` | 删除容器 |
| `waitContainerCmd(id)` | `docker wait` | 阻塞等待容器退出(返回退出码) |
| `renameContainerCmd(id)` | `docker rename` | 重命名容器 |
| `updateContainerCmd(id)` | `docker update` | 更新容器运行时配置 |
| `commitCmd(id)` | `docker commit` | 将容器状态保存为镜像 |
| `topContainerCmd(id)` | `docker top` | 查看容器内运行的进程 |
| `exportContainerCmd(id)` | `docker export` | 将容器文件系统导出为 tar |
#### 五、容器查询
| 方法 | 对应命令 | 说明 |
|------|----------|------|
| `listContainersCmd()` | `docker ps` | 列出容器 |
| `inspectContainerCmd(id)` | `docker inspect` (容器) | 获取容器完整详情 |
| `containerDiffCmd(id)` | `docker diff` | 查看容器文件系统变更 |
| `statsCmd(id)` | `docker stats` | 获取容器资源统计 |
#### 六、容器运维
| 方法 | 对应命令 | 说明 |
|------|----------|------|
| `logContainerCmd(id)` | `docker logs` | 获取容器日志 |
| `attachContainerCmd(id)` | `docker attach` | 连接到容器的 I/O 流 |
| `execCreateCmd(id)` | `docker exec` (创建) | 在容器内创建命令会话 |
| `execStartCmd(id)` | `docker exec` (启动) | 执行创建好的命令会话 |
| `inspectExecCmd(id)` | — | 获取 exec 会话详情 |
| `resizeExecCmd(id)` | — | 调整 exec TTY 大小 |
| `copyArchiveFromContainerCmd(id, path)` | `docker cp` (取出) | 从容器复制文件到本地 |
| `copyArchiveToContainerCmd(id)` | `docker cp` (放入) | 将本地文件复制到容器 |
| `copyFileFromContainerCmd(id, path)` | `docker cp` (取出) | 从容器复制单个文件 |
| `resizeContainerCmd(id)` | — | 调整容器 TTY 大小 |
#### 七、数据卷(Volume)
| 方法 | 对应命令 | 说明 |
|------|----------|------|
| `listVolumesCmd()` | `docker volume ls` | 列出所有数据卷 |
| `inspectVolumeCmd(name)` | `docker volume inspect` | 查看卷详情 |
| `createVolumeCmd()` | `docker volume create` | 创建数据卷 |
| `removeVolumeCmd(name)` | `docker volume rm` | 删除数据卷 |
#### 八、清理(Prune)
| 方法 | 对应命令 | 说明 |
|------|----------|------|
| `pruneCmd(PruneType.CONTAINERS)` | `docker container prune -f` | 清理已停止的容器 |
| `pruneCmd(PruneType.IMAGES)` | `docker image prune -f` | 清理悬空(dangling)镜像 |
#### 九、网络(Network)
| 方法 | 对应命令 | 说明 |
|------|----------|------|
| `listNetworksCmd()` | `docker network ls` | 列出所有网络 |
| `inspectNetworkCmd()` | `docker network inspect` | 查看网络详情 |
| `createNetworkCmd()` | `docker network create` | 创建自定义网络 |
| `removeNetworkCmd(id)` | `docker network rm` | 删除网络 |
| `connectToNetworkCmd()` | `docker network connect` | 将容器接入网络 |
| `disconnectFromNetworkCmd()` | `docker network disconnect` | 将容器移出网络 |
### 核心模型类对照
SDK 中的模型类与 Docker JSON API 的返回数据一一对应。以下是常用的模型类:
#### Image 相关
| 模型类 | 用途 | 常用字段 |
|--------|------|----------|
| `Image` | 镜像简要信息 | `id`, `repoTags`, `size`, `created`, `labels` |
| `InspectImageResponse` | 镜像完整详情 | `id`, `arch`, `os`, `config`, `rootFs` |
| `ImageHistory` | 镜像构建历史 | `created`, `createdBy`, `size` |
#### Container 相关
| 模型类 | 用途 | 常用字段 |
|--------|------|----------|
| `Container` | 容器简要信息 | `id`, `names`, `state`, `status`, `ports` |
| `InspectContainerResponse` | 容器完整详情 | `id`, `name`, `state`, `config`, `networkSettings`, `mounts` |
| `ContainerState` | 容器状态 | `running`, `status`, `exitCode`, `pid` |
| `ContainerPort` | 端口映射 | `privatePort`, `publicPort`, `type` |
| `Mount` | 挂载点 | `source`, `destination`, `mode`, `type` |
#### 命令响应
| 模型类 | 用途 | 常用字段 |
|--------|------|----------|
| `CreateContainerResponse` | 创建容器响应 | `id`, `warnings` |
| `ExecCreateCmdResponse` | 创建 exec 响应 | `id` |
| `Version` | 版本信息 | `version`, `apiVersion`, `buildTime` |
| `Info` | 系统信息 | `containers`, `images`, `serverVersion` |
### 回调类与异步机制
Docker SDK 中的 I/O 密集操作(拉取镜像、构建镜像、执行命令等)采用异步回调模式。核心回调类:
| 回调类 | 用途 | 常用方法 |
|--------|------|----------|
| `ResultCallback.Adapter` | 通用异步回调适配器 | `onNext(T)`, `onComplete()`, `onError(Throwable)` |
| `PullImageResultCallback` | 拉取镜像回调 | `awaitCompletion()` |
| `BuildImageResultCallback` | 构建镜像回调 | `awaitImageId()` |
| `WaitContainerResultCallback` | 等待容器回调 | `awaitStatusCode(timeout, unit)` |
| `LogContainerResultCallback` | 获取日志回调 | 继承自 `ResultCallback`,逐帧回调 |
| `ExecStartResultCallback` | 执行命令回调 | 继承自 `ResultCallback`,逐帧回调 |
| `LoadImageResultCallback` | 加载镜像回调 | `awaitCompletion()` |
### 错误处理机制
SDK 中的所有 Docker 操作失败时,统一抛出 `DockerException` 的子类:
| 异常类 | HTTP 状态码 | 说明 |
|--------|-------------|------|
| `NotFoundException` | 404 | 资源不存在(镜像/容器未找到) |
| `ConflictException` | 409 | 资源冲突(如删除运行中的容器) |
| `InternalServerErrorException` | 500 | Docker daemon 内部错误 |
| `BadRequestException` | 400 | 请求参数不正确 |
| `ForbiddenException` | 403 | 权限不足(Docker socket 权限或 Registry 认证失败) |
最佳实践:在调用 Docker API 时,始终用 try-catch 捕获异常并进行适当的错误处理:
```java
try {
dockerClient.pingCmd().exec();
log.info("Docker 连接正常");
} catch (DockerException e) {
// 根据异常类型进行针对性处理
if (e instanceof NotFoundException) {
log.error("请求的资源不存在");
} else if (e instanceof InternalServerErrorException) {
log.error("Docker daemon 内部错误: {}", e.getMessage());
} else {
log.error("Docker 操作失败: {}", e.getMessage());
}
} catch (Exception e) {
log.error("网络连接失败: {}", e.getMessage());
}
```
### 本教程代码结构
| 类名 | 所在章节 | 说明 |
|------|----------|------|
| `QuickStart` | Chapter 2 | DockerClient 创建、连接、拉取镜像、创建启动容器 |
| `DockerClientFactory` | Chapter 2 | DockerClient 工厂类,提供统一的实例创建方法 |
| `ImageManageAPI` | Chapter 3 | 镜像管理(列出、查看详情、拉取、删除、标记、检查存在) |
| `ImageBuildAPI` | Chapter 4 | 镜像构建(从 Dockerfile 构建、清理中间层) |
| `ContainerManageAPI` | Chapter 5 | 容器管理(创建、启动、停止、删除、列出、查看详情) |
| `ContainerOpsAPI` | Chapter 6 | 容器运维(日志、执行命令、复制文件、等待退出) |
| `ContainerAdvancedAPI` | Chapter 8 | 容器高级创建(HostConfig:重启策略、资源限制、端口映射) |
| `VolumeNetworkAPI` | Chapter 9 | 数据卷与网络管理(卷的生命周期、自定义网络、DNS 互联) |
| `RegistryPushAPI` | Chapter 10 | 私有镜像仓库(本地 registry、认证、推送拉取闭环) |
| `MaintenanceOpsAPI` | Chapter 11 | 运行监控与日常维护(stats/top/logs -f、diff、commit/export、update) |
***
## 容器高级配置与生产化
前面的章节里,我们创建容器时只用了最基本的参数。真实生产环境中,容器需要设置重启策略、内存/CPU 限制、非 root 用户运行、端口发布——这些都属于 `HostConfig` 的配置范围。本章围绕 `ContainerAdvancedAPI` 演示如何用一个 `createContainerCmd` 一步到位完成 `docker run` 的常用生产级参数。
### HostConfig 核心字段
`HostConfig` 是 docker-java 中承载"容器运行时配置"的核心模型类。它对应 `docker run` 的 `-m`、`--cpus`、`--restart`、`--read-only`、`-p` 等参数,通过 Builder 模式链式调用,最后通过 `withHostConfig(hostConfig)` 传入容器创建命令。
```java
// 等价命令:
// docker run -d --name web-app \
// --restart=unless-stopped -m 128m --cpus 0.5 \
// --user 1000:1000 --read-only \
// -p 8080:80 nginx:latest
HostConfig hostConfig = HostConfig.newHostConfig()
.withRestartPolicy(RestartPolicy.unlessStoppedRestart()) // --restart=unless-stopped
.withMemory(128L * 1024 * 1024) // -m 128m(字节)
.withNanoCPUs(500_000_000L) // --cpus 0.5(纳核)
.withReadonlyRootfs(true) // --read-only
.withPortBindings(portBindings); // -p 8080:80
dockerClient.createContainerCmd("nginx:latest")
.withName("web-app")
.withUser("1000:1000") // --user
.withExposedPorts(exposedPort)
.withHostConfig(hostConfig)
.exec();
```
docker-java 中有两个常见的"单位陷阱"需要注意:
- `withMemory()` 接受 `long` 类型的**字节数**,而不是 MB——直接传 `128` 会限制在 128 字节,而不是 128MB。
- `withNanoCPUs()` 接受纳核数(1核 = 10⁹),而 `--cpus 0.5` = 5×10⁸ 纳核。
### 运行结果
```log
00:11:14.428 ContainerAdvancedAPI -- 生产风格容器 test-prod-xxx 启动成功,访问地址: http://localhost:8080/
00:11:14.451 ContainerAdvancedAPI -- 容器 xxx 主机配置 -> 重启策略: unless-stopped, 内存限制: 128MB, CPU限制: 0.5核
00:11:14.898 ContainerAdvancedAPI -- 带资源限制的容器创建成功,ID: xxx, 内存: 256MB, CPU: 1.0核
```
通过 `inspectContainerCmd` 返回的 `HostConfig` 对象,可以清晰地看到重启策略、内存、CPU 限制均已生效。
### 端口映射:ExposedPort 与 Ports
`docker run -p 8080:80` 的 SDK 对应实现由 `ExposedPort`(声明容器端口)和 `Ports`(绑定宿主机端口)两步完成:
```java
ExposedPort exposedPort = ExposedPort.tcp(80); // 容器内 80 端口
Ports portBindings = new Ports();
portBindings.bind(exposedPort, Ports.Binding.bindPort(8080)); // 绑定到宿主机 8080
HostConfig hostConfig = HostConfig.newHostConfig().withPortBindings(portBindings);
// ...随后传入 createContainerCmd
```
> 注意:`ExposedPort` 必须同时传给 `withExposedPorts()` 和 `withPortBindings()`——前者是元数据声明,后者是实际绑定。
**思考:** `withRestartPolicy(RestartPolicy.unlessStoppedRestart())` 与 `withRestartPolicy(RestartPolicy.alwaysRestart())` 有什么区别?生产环境中应该选哪个?
两者都在容器退出后自动重启,区别在于:`always` 在 Docker daemon 重启后也会拉起容器,而 `unless-stopped` 在 daemon 重启后**不会**拉起被手动 `docker stop` 过的容器。生产环境通常选 `unless-stopped`——既能自动恢复崩溃,又尊重运维的手动停止操作。
***
## 数据卷与网络管理
容器是短暂的——一旦删除,内部数据就随之消失。在真实应用中,数据库文件、用户上传等需要持久化存储;多个容器之间也需要相互通信。本章围绕 `VolumeNetworkAPI` 演示 Docker 数据卷(Volume)的创建、挂载、数据持久化,以及自定义 bridge 网络的容器互联与 DNS 发现。
### 数据卷的基本操作
数据卷是 Docker 推荐的持久化存储方式,由 Docker 管理,独立于容器生命周期。
```java
// 创建数据卷
// 等价命令: docker volume create my-volume
dockerClient.createVolumeCmd().withName("my-volume").exec();
// 列出所有数据卷
// 等价命令: docker volume ls
List volumes = dockerClient.listVolumesCmd().exec().getVolumes();
// 查看详情(获取宿主机上的实际挂载路径)
// 等价命令: docker volume inspect my-volume
InspectVolumeResponse vol = dockerClient.inspectVolumeCmd("my-volume").exec();
// vol.getMountpoint() -> /var/lib/docker/volumes/my-volume/_data
```
运行结果:
```log
00:11:09.162 VolumeNetworkAPI -- 数据卷 test-vol-xxx 创建成功
00:11:09.182 VolumeNetworkAPI -- 本机共有 41 个数据卷
00:11:09.186 VolumeNetworkAPI -- 数据卷 test-vol-xxx 挂载点: /var/lib/docker/volumes/test-vol-xxx/_data, 驱动: local
```
### 挂载卷到容器(Bind)
`docker run -v my-volume:/data` 在 SDK 中通过 `Bind` 对象实现:
```java
// 等价命令: docker run --name app -v my-volume:/data app:latest
Bind bind = new Bind("my-volume", new Volume("/data"));
HostConfig hostConfig = HostConfig.newHostConfig().withBinds(bind);
CreateContainerResponse container = dockerClient.createContainerCmd("amazoncorretto:17")
.withName("app")
.withEntrypoint("sh", "-c")
.withCmd("sleep 300")
.withHostConfig(hostConfig)
.exec();
dockerClient.startContainerCmd(container.getId()).exec();
```
在容器 A 中写入 `/data/hello.txt`,删除容器 A 后启动容器 B 并挂载同一个卷,数据依然存在——这就是数据持久化的核心价值。测试日志印证了这一特性:
```log
00:11:11.268 VolumeNetworkAPI -- 挂载数据卷的容器创建成功,ID: xxx (容器A)
00:11:11.482 VolumeNetworkAPITest -- 容器A写入结果: (写入 /data/hello.txt)
00:11:11.679 VolumeNetworkAPI -- 容器 xxx 已删除 (删除容器A)
00:11:11.720 VolumeNetworkAPI -- 挂载数据卷的容器创建成功,ID: xxx (容器B,同卷)
→ 读取到容器A写入的文件
```
### 自定义网络与容器 DNS
Docker 默认的 `bridge` 网络不支持容器名 DNS 解析;自定义 bridge 网络会自动启用内嵌 DNS,使容器名可直接作为主机名使用。
```java
// 创建自定义 bridge 网络
// 等价命令: docker network create my-network
CreateNetworkResponse response = dockerClient.createNetworkCmd()
.withName("my-network")
.withDriver("bridge")
.exec();
// 在自定义网络中启动容器(容器名自动注册为 DNS)
// 等价命令: docker run -d --name web-a --network my-network ...
HostConfig hostConfig = HostConfig.newHostConfig().withNetworkMode("my-network");
```
验证 DNS:在同一网络中,容器 B 可通过 `getent hosts dns-a` 解析容器 A 的名称:
```log
00:11:10.463 VolumeNetworkAPITest -- 容器B解析容器A的结果:
172.18.0.2 dns-a-66813360 ← 容器B能通过容器名访问容器A
```
**思考:** 为什么在默认 bridge 网络下 `ping container-a` 会失败,而在自定义网络下可以成功?
默认 bridge 网络不启用 DNS 解析,只能通过 IP 通信;自定义网络启用 Docker 内嵌 DNS,容器名会自动注册为 A 记录。这是生产环境中几乎总是推荐使用自定义网络的主要原因。
***
## 私有镜像仓库与推送
将镜像推送到 Registry 是 CI/CD 流程的关键环节。本章以本地 `registry:2` 为对象,用 `RegistryPushAPI` 完整演示 `docker login` → `docker tag` → `docker push` → `docker pull` 的闭环过程。
### 启动本地私有仓库
`registry:2` 是 Docker 官方提供的镜像仓库实现,默认监听 5000 端口:
```java
// 等价命令: docker run -d --name my-registry -p 5000:5000 registry:2
ExposedPort registryPort = ExposedPort.tcp(5000);
Ports portBindings = new Ports();
portBindings.bind(registryPort, Ports.Binding.bindPort(hostPort));
dockerClient.createContainerCmd("registry:2")
.withName(containerName)
.withExposedPorts(registryPort)
.withHostConfig(HostConfig.newHostConfig().withPortBindings(portBindings))
.exec();
```
### 认证、打标签、推送
docker-java 中的 `docker login` 对应 `authCmd()`,`docker push` 对应 `pushImageCmd()`:
```java
// 等价命令: docker login localhost:5000
AuthConfig authConfig = new AuthConfig()
.withUsername("docker-java-demo")
.withPassword("demo-password")
.withRegistryAddress("localhost:5000");
AuthResponse response = dockerClient.authCmd().withAuthConfig(authConfig).exec();
// response.getStatus() -> "Login Succeeded"
// 等价命令:
// docker tag nginx:latest localhost:5000/myapp:v1
// docker push localhost:5000/myapp:v1
dockerClient.tagImageCmd("nginx:latest", "localhost:5000/myapp", "v1").exec();
dockerClient.pushImageCmd("localhost:5000/myapp")
.withTag("v1")
.exec(new ResultCallback.Adapter<>()) // docker-java 3.7.1 使用通用回调
.awaitCompletion(); // 阻塞等待推送完成
```
推送完成后删除本地副本,再从仓库拉取,验证闭环:
```java
// 等价命令: docker pull localhost:5000/myapp:v1
dockerClient.pullImageCmd("localhost:5000/myapp:v1")
.exec(new PullImageResultCallback())
.awaitCompletion();
```
### 运行结果
```log
00:11:08.150 RegistryPushAPI -- 本地私有仓库已启动: http://localhost:41385/
00:11:08.162 RegistryPushAPI -- Registry localhost:41385 认证结果: Login Succeeded
00:11:08.489 RegistryPushAPI -- 镜像 localhost:41385/myapp-xxx:v1 推送成功
00:11:08.769 RegistryPushAPI -- 镜像 localhost:41385/myapp-xxx:v1 从仓库拉取成功
```
> 注意:docker-java 3.7.1 中 `pushImageCmd()` 没有专用的 `PushImageResultCallback`,需使用 `ResultCallback.Adapter` 通用回调配合 `awaitCompletion()` 等待推送完成。这是版本限制,升级到更高版本后可使用专用回调类。
**思考:** `docker export` 导出的容器文件系统 tar 与 `docker save` 导出的镜像 tar 有什么区别?各自适合什么场景?
`docker export` 导出的是**容器运行后的合并文件系统**,不含镜像层历史和元数据,适用于"把运行环境打包成单层镜像"的场景(配合 `docker import`)。`docker save` 导出的是**完整镜像**(含所有层、配置和标签),适用于镜像迁移和备份。本教程 `MaintenanceOpsAPI` 中演示了 `exportContainer`,`ImageManageAPI` 中演示了 `saveImage`,两者可对比使用。
***
## 运行监控与日常维护
容器上线后,运维人员需要随时查看进程状态、资源占用、日志流、文件系统变更,以及动态调整配置。本章围绕 `MaintenanceOpsAPI` 演示 Docker 运行时监控和日常维护操作,包括 `docker stats --no-stream`、`docker top`、`docker logs -f`、`docker diff`、`docker commit`、`docker export`、`docker cp`(放入方向)和 `docker update`。
### 实时监控:stats 与 top
`docker stats --no-stream` 对应 `statsCmd`,需要通过回调接收 `Statistics` 对象:
```java
// 等价命令: docker stats --no-stream
AtomicReference snapshot = new AtomicReference<>();
dockerClient.statsCmd(containerId)
.withNoStream(true) // 只取一次,不持续输出
.exec(new ResultCallback.Adapter<>() {
@Override
public void onNext(Statistics stats) {
snapshot.set(stats);
}
}).awaitCompletion();
Statistics stats = snapshot.get();
long usageMb = stats.getMemoryStats().getUsage() / 1024 / 1024;
long limitMb = stats.getMemoryStats().getLimit() / 1024 / 1024;
```
`docker top` 对应 `topContainerCmd`,返回容器内进程列表(类似 `ps aux`):
```java
// 等价命令: docker top
TopContainerResponse response = dockerClient.topContainerCmd(containerId).exec();
// response.getTitles() -> ["UID","PID","PPID","C","STIME","TTY","TIME","CMD"]
// response.getProcesses() -> [["root","3749932","3749909","12","00:11","?","00:00:00","sleep 300"]]
```
### 实时日志跟随(logs -f)
Chapter 6 中的 `getContainerLogs` 是一次性读取;这里演示的是**持续跟随模式**,对应 `docker logs -f`:
```java
// 等价命令: docker logs -f --tail 50
LogContainerCmd cmd = dockerClient.logContainerCmd(containerId)
.withStdOut(true)
.withStdErr(true)
.withFollowStream(true) // 关键:进入跟随模式
.withTail(50);
ResultCallback callback = cmd.exec(new ResultCallback.Adapter<>() {
@Override
public void onNext(Frame frame) {
output.append(new String(frame.getPayload()));
}
});
// 跟随指定时长后主动关闭(模拟 Ctrl+C)
Thread.sleep(followSeconds * 1000);
callback.close();
```
### 文件系统变更查看(docker diff)
`docker diff` 列出容器相对原始镜像的新增、修改和删除文件:
```java
// 等价命令: docker diff
List changes = dockerClient.containerDiffCmd(containerId).exec();
// kind: 0=已修改 1=新增 2=删除
```
测试在容器内创建一个新文件后,diff 输出:
```log
00:11:15.555 MaintenanceOpsAPI -- 修改 /tmp
00:11:15.555 MaintenanceOpsAPI -- 新增 /tmp/diff-test.txt
```
### 归档操作:commit 与 export
`docker commit` 将容器当前状态保存为新镜像,保留镜像层历史:
```java
// 等价命令: docker commit my-app:v3
String imageId = dockerClient.commitCmd(containerId)
.withRepository("my-app")
.withTag("v3")
.withMessage("committed by docker-java")
.exec();
```
`docker export` 将容器文件系统导出为 tar(不含层历史,只保留当前快照):
```java
// 等价命令: docker export -o backup.tar
try (InputStream tarStream = dockerClient.exportContainerCmd(containerId).exec();
FileOutputStream fos = new FileOutputStream("backup.tar")) {
tarStream.transferTo(fos);
}
```
> **重要提示:** `exportContainerCmd` 对**运行中**容器导出可能产生不完整的文件系统快照。建议在导出前先 `stopContainer`,以避免 containerd 解压时出现 `unexpected EOF`。`docker import` 在 containerd snapshotter 下对某些基础镜像的导出 tar 解包不稳定,实际生产中更推荐 `docker save`/`docker load`。
### 动态更新容器(docker update)
`docker update` 允许在不重建容器的情况下调整资源限制:
```java
// 等价命令: docker update --memory 256m
// 注意:Docker 要求 memory-swap >= memory,必须同时设置
long memoryBytes = 256 * 1024 * 1024;
dockerClient.updateContainerCmd(containerId)
.withMemory(memoryBytes)
.withMemorySwap(memoryBytes * 2) // 必须同时设置,否则返回 409
.exec();
```
**思考:** `docker commit` 和 `docker build` 都能产生镜像,为什么生产环境中总是推荐使用 `docker build`?
`docker commit` 把容器的完整文件系统合并成一个不透明的镜像层,无法追溯修改历史、无法复现构建过程、镜像体积无法优化。`docker build` 通过 Dockerfile 定义构建步骤,每条指令产生一个可缓存的层,支持版本控制、增量构建和安全审查。`docker commit` 适用于紧急现场保存,而 `docker build` 是唯一推荐的生产构建方式。
***
> Docker Java SDK 的 API 设计遵循"命令对象模式":每个操作对应一个 `*Cmd` 接口,通过 Builder 模式链式设置参数,最后通过 `.exec()` 统一执行。掌握这个模式后,即使遇到本教程未覆盖的 API,也能快速上手。