docker-java-sdk-tutorial.md 39 KB

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 用户组中:

# 将当前用户添加到 docker 用户组
sudo usermod -aG docker $USER

# 查看当前用户所属 groups
groups

# 如果 usermod 不生效,可以尝试 newgrp 刷新组信息
newgrp docker

添加完成后,需要重新登录系统重启系统让修改生效。我们可以通过以下命令验证是否配置成功:

# 查看 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 socketTCP

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 中添加配置:

{
  "hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2375"]
}

方法二:修改 systemd service 配置

创建或编辑 systemd 配置文件:

sudo mkdir -p /etc/systemd/system/docker.service.d
sudo vi /etc/systemd/system/docker.service.d/override.conf

添加以下内容:

[Service]
ExecStart=
ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2375 -H unix://var/run/docker.sock

然后重启 Docker 服务:

sudo systemctl daemon-reload
sudo systemctl restart docker

验证 TCP 监听是否生效:

# 查看 Docker daemon 监听的端口
ss -tlnp | grep 2375

# 或使用 curl 测试
curl http://localhost:2375/version

注意: 开启 TCP 监听后,任何能够访问该端口的客户端都可以操作 Docker daemon,存在严重的安全风险。在生产环境中,建议配合 TLS 证书进行加密通信。

TLS 安全通信配置

在生产环境中使用 TCP 协议时,应该启用 TLS 加密:

# 创建 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 中添加以下配置:

<dependencies>
    <!-- Docker Java SDK 核心库 -->
    <dependency>
        <groupId>com.github.docker-java</groupId>
        <artifactId>docker-java</artifactId>
        <version>3.7.1</version>
    </dependency>
    <!-- HTTP Client 传输层实现 -->
    <dependency>
        <groupId>com.github.docker-java</groupId>
        <artifactId>docker-java-transport-httpclient5</artifactId>
        <version>3.7.1</version>
    </dependency>
    <!-- 日志实现 -->
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>1.5.38</version>
    </dependency>
</dependencies>

这里我们需要两个核心依赖:docker-java 是 SDK 的核心库,定义了所有 API 接口和模型;docker-java-transport-httpclient5 是传输层实现,负责与 Docker daemon 进行 HTTP 通信。

创建 DockerClient

DockerClient 是 Docker Java SDK 的核心入口类,所有的 Docker 操作都通过它来执行。SDK 提供了两种创建方式:使用默认配置和自定义配置。

使用默认配置

最简单的方式是使用 DockerClientBuilder 的默认配置,它会自动检测本机的 Docker 环境:

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 命令测试连接是否成功
        dockerClient.pingCmd().exec();
        log.info("Docker client ping 成功!");

        // 列出所有本地镜像,验证连接正常
        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 地址、设置超时时间等:

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);

        // 测试连接
        dockerClient.pingCmd().exec();
        log.info("自定义配置连接成功!");

        // 列出所有镜像
        dockerClient.listImagesCmd().exec().forEach(image -> {
            log.info("Docker 镜像信息: {}", image);
        });
    }
}

自定义配置的创建分为三个步骤:

  1. 构建 DockerClientConfig:配置 Docker daemon 地址、TLS、Registry 认证等
  2. 构建 DockerHttpClient:配置传输层参数,如连接数、超时时间等
  3. 使用 DockerClientImpl.getInstance() 创建客户端实例

连接测试

创建 DockerClient 后,我们可以使用 pingCmd() 方法测试连接是否正常。如果连接失败,SDK 会抛出 DockerException 异常:

try {
    dockerClient.pingCmd().exec();
    log.info("Docker 连接成功!");
} catch (Exception e) {
    log.error("Docker 连接失败: {}", e.getMessage());
}

我们还可以获取 Docker daemon 的版本信息,进一步验证连接:

// 获取 Docker 版本信息
String version = dockerClient.versionCmd().exec().getVersion();
log.info("Docker 版本: {}", version);

// 获取 Docker 系统信息
Info info = dockerClient.infoCmd().exec();
log.info("Docker 系统信息: {}", info);

拉取镜像

拉取镜像是使用 Docker 的基本操作之一。SDK 提供了 pullImageCmd() 方法来拉取镜像:

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 {
        // 构建并执行拉取命令
        // 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):

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:创建容器
    // 创建容器只是分配资源和配置,并不会真正运行
    CreateContainerResponse container = dockerClient.createContainerCmd(imageName)
            .withName(containerName)  // 设置容器名称
            .exec();

    log.info("容器创建成功,ID: {}", container.getId());

    // 步骤2:启动容器
    dockerClient.startContainerCmd(container.getId()).exec();
    log.info("容器 {} 启动成功!", containerName);
}

创建容器时,我们可以通过链式调用设置各种参数:

// 创建一个带有端口映射和环境变量的容器
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 到运行容器的整个流程:

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. 测试连接
        dockerClient.pingCmd().exec();
        log.info("1. Docker 连接成功");

        // 2. 拉取 hello-world 镜像
        try {
            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. 创建并启动容器
        CreateContainerResponse container = dockerClient.createContainerCmd("hello-world")
                .withName("demo-container")
                .exec();
        log.info("3. 容器创建成功,ID: {}", container.getId());

        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 命令。

/**
 * 列出自定义节点上所有存在的镜像
 * @return 镜像列表
 */
public List<Image> listImages() {
    List<Image> 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() 方法:

/**
 * 获取镜像的详细信息
 * 包括镜像的 ID、仓库、标签、创建时间、大小、Layer 等
 * @param imageId 镜像 ID 或名称
 * @return 镜像详情
 */
public InspectImageResponse inspectImage(String imageId) {
    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 命令。与前面几个方法不同,拉取镜像是一个异步操作,需要借助回调机制来处理结果:

/**
 * 拉取指定名称的镜像
 * @param imageName 镜像名称
 * @return 拉取是否成功
 */
public boolean pullImage(String imageName) {
    try {
        // 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 命令:

/**
 * 删除本地镜像
 * @param imageId 镜像 ID 或名称
 * @param force 是否强制删除(即使镜像被容器使用)
 */
public void removeImage(String imageId, boolean force) {
    dockerClient.removeImageCmd(imageId)
            .withForce(force)   // 强制删除
            .exec();
    log.info("镜像 {} 已删除", imageId);
}

注意: 如果镜像正在被某个容器使用,普通删除会失败。此时需要设置 force=true 强制删除,或者先删除容器再删除镜像。

tagImageCmd - 标记镜像

tagImageCmd() 用于给镜像添加标签,对应 docker tag 命令,常用于给镜像重命名或在本地建立镜像副本:

/**
 * 为镜像添加标签(相当于 docker tag)
 * @param imageId 源镜像 ID 或名称
 * @param repository 新的仓库名
 * @param tag 新的标签名
 */
public void tagImage(String imageId, String repository, String tag) {
    dockerClient.tagImageCmd(imageId, repository, tag).exec();
    log.info("镜像 {} 已标记为 {}:{}", imageId, repository, tag);
}

例如,当我们拉取了 nginx:latest 镜像后,想将其标记为 my-nginx:v1.0

api.tagImage("nginx:latest", "my-nginx", "v1.0");

saveImageCmd / loadImageCmd - 导入导出镜像

Docker 还支持将镜像导出为 tar 文件,或者从 tar 文件导入镜像。这在离线部署场景中非常有用:

// 将镜像导出为 tar 文件
public void saveImage(String imageId, String filePath) {
    try (OutputStream outputStream = new FileOutputStream(filePath)) {
        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)) {
        dockerClient.loadImageCmd(inputStream)
                .exec(new LoadImageResultCallback())
                .awaitCompletion();
        log.info("镜像已从 {} 加载成功", filePath);
    } catch (IOException | InterruptedException e) {
        log.error("加载镜像失败: {}", e.getMessage());
    }
}

思考: 镜像导出和导入在什么场景下会用到?与直接在网络上拉取镜像相比有什么优势?

镜像导出/导入适用于离线环境内网环境,比如生产环境无法直接访问 Docker Hub 时,可以在有网络的机器上导出镜像,然后通过 U 盘或内网传输到目标机器上加载。这种方式不依赖网络连接,但文件传输本身需要额外的存储空间。

镜像管理完整示例

// 列出所有镜像
List<Image> 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 和构建所需的文件)。

/**
 * 从 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 {
        // 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)          

每次 RUNCOPY 等指令都会创建一个新的镜像 Layer,所有 Layer 叠加在一起就是最终的镜像。这个分层机制使得镜像可以被高效地缓存和复用。

示例:构建一个带标识文件的自定义镜像

假设我们有如下 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 构建并验证:

ImageBuildAPI api = new ImageBuildAPI();
String imageId = api.buildImage("src/test/resources/Dockerfile", "sdk-demo/hello:latest");
log.info("构建结果: {}", imageId);

// 验证镜像已存在
List<Image> 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 - 创建容器

/**
 * 创建容器(不启动)
 * @param imageName 镜像名称
 * @param containerName 容器名称
 * @return 创建的容器响应(包含容器 ID)
 */
public CreateContainerResponse createContainer(String imageName, String containerName) {
    // 创建容器:分配资源配置,但不会真正运行
    // 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) 设置资源限制、挂载卷、网络模式等

创建带环境变量和端口映射的容器:

// 创建带环境变量的容器
dockerClient.createContainerCmd("nginx:latest")
        .withName("my-nginx")
        .withEnv("NGINX_HOST=localhost", "NGINX_PORT=80")
        .exec();

// 创建带端口映射的容器
dockerClient.createContainerCmd("nginx:latest")
        .withName("my-nginx")
        .withExposedPorts(ExposedPort.tcp(80))
        .withPortBindings(new Ports.Binding(8080, 80))
        .exec();

startContainerCmd - 启动容器

创建容器后,使用 startContainerCmd() 启动它:

/**
 * 启动容器
 * @param containerId 容器 ID
 */
public void startContainer(String containerId) {
    dockerClient.startContainerCmd(containerId).exec();
    log.info("容器 {} 启动成功", containerId);
}

思考: 在快速入门章节我们看到了 CreateContainerResponse.getId(),它返回完整的容器 ID。为什么大多数 API 都要求传容器 ID 而不是名称?

容器 ID 是容器的唯一标识,不会重复。而容器名称虽然也可以用于操作(Docker daemon 会解析),但在某些场景下名称可能不存在或重复。使用 ID 是最稳妥的方式。SDK 也支持传入短 ID 或名称,Docker daemon 会自动解析。

stopContainerCmd - 停止容器

停止容器采用"优雅退出"机制:首先向容器进程发送 SIGTERM 信号,等待一段时间后仍未退出则发送 SIGKILL 强制终止:

/**
 * 停止容器(等待容器优雅退出)
 * @param containerId 容器 ID
 * @param timeoutSeconds 等待时长(秒),超过则强制终止
 */
public void stopContainer(String containerId, Integer timeoutSeconds) {
    dockerClient.stopContainerCmd(containerId)
            .withTimeout(timeoutSeconds)  // SIGTERM 后等待时间
            .exec();
    log.info("容器 {} 已停止(等待 {} 秒)", containerId, timeoutSeconds);
}

killContainerCmd - 强制停止容器

stopContainerCmd 不同,killContainerCmd 直接发送 SIGKILL 信号,进程立即终止,不给优雅退出的机会:

/**
 * 强制停止容器(立即发送 SIGKILL)
 * @param containerId 容器 ID
 */
public void killContainer(String containerId) {
    dockerClient.killContainerCmd(containerId).exec();
    log.info("容器 {} 已被强制停止", containerId);
}

思考: 什么场景下应该用 stop,什么场景下应该用 kill?

stop 会先给进程发 SIGTERM,让应用有机会保存数据、释放资源(比如数据库需要执行清理操作);kill 则是立即杀死进程,可能导致数据丢失。生产环境中应该优先使用 stop,只有在进程无法响应或严格限时的情况下才使用 kill

removeContainerCmd - 删除容器

删除容器时需要考虑两个选项:

/**
 * 删除容器
 * @param containerId 容器 ID
 * @param force 是否强制删除(容器运行中也删除)
 * @param removeVolumes 是否同时删除关联的卷
 */
public void removeContainer(String containerId, boolean force, boolean removeVolumes) {
    dockerClient.removeContainerCmd(containerId)
            .withForce(force)            // 强制删除运行中的容器
            .withRemoveVolumes(removeVolumes)  // 删除关联数据卷
            .exec();
    log.info("容器 {} 已删除", containerId);
}

listContainersCmd - 列出容器

列出容器对应 docker ps 命令。默认只显示运行中的容器,withShowAll(true) 则显示所有容器(包括已停止的):

/**
 * 列出所有容器(包括停止的)
 * @return 容器列表
 */
public List<Container> listContainers() {
    // withShowAll(true) 展示所有容器(不只运行中的)
    List<Container> 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 - 获取容器详细信息

与镜像类似,容器也有详查方法。它返回容器的完整配置、网络、挂载信息:

/**
 * 获取容器详细信息
 * @param containerId 容器 ID
 * @return 容器详情
 */
public InspectContainerResponse inspectContainer(String containerId) {
    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() 创建时间

完整生命周期示例

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);
}

思考: 查看 ContainergetNames() 返回的数组——为什么是一个数组而不是单个字符串?

Docker 的设计中,一个容器可以有多个名称(别名),通常是 /容器名。当使用 --link 方式连接容器时,会为被连接的容器创建额外的名称。虽然现代 Docker 更推荐使用自定义网络,但 getNames() 的设计保留了这种能力。


下一章,我们将讲解容器运维相关 API,包括查看日志、在容器中执行命令、文件复制等操作。