浏览代码

feat: add inline docker CLI command comparisons across all APIs, fix save/load and copyFromContainer implementations

yangyi 3 天之前
父节点
当前提交
81eac48d2d

+ 14 - 0
src/main/java/space/anyi/docker/ContainerManageAPI.java

@@ -14,6 +14,7 @@ import java.util.List;
 /**
  * Docker 容器管理 API 示例
  * 演示容器的创建、启动、停止、删除、列出、查看等操作
+ * 每个操作旁标注了等价的 Docker CLI 命令,方便对照理解
  */
 public class ContainerManageAPI {
     private static final Logger log = LoggerFactory.getLogger(ContainerManageAPI.class);
@@ -32,6 +33,7 @@ public class ContainerManageAPI {
      */
     public CreateContainerResponse createContainer(String imageName, String containerName) {
         // 创建容器:分配资源配置,但不会真正运行
+        // 等价命令: docker create --name my-container nginx:latest
         // withName 指定容器名称(可选,不指定时 Docker 自动生成)
         CreateContainerResponse response = dockerClient.createContainerCmd(imageName)
                 .withName(containerName)
@@ -48,6 +50,8 @@ public class ContainerManageAPI {
      * @return 容器响应
      */
     public CreateContainerResponse createContainerWithEnv(String imageName, String containerName, String... envVars) {
+        // 等价命令: docker create --name my-container -e MYSQL_ROOT_PASSWORD=123456 nginx:latest
+        //           其中 -e KEY=VALUE 用于注入环境变量
         CreateContainerResponse response = dockerClient.createContainerCmd(imageName)
                 .withName(containerName)
                 .withEnv(envVars)
@@ -72,6 +76,8 @@ public class ContainerManageAPI {
         Ports portBindings = new Ports();
         portBindings.bind(exposedPort, Ports.Binding.bindPort(hostPort));
 
+        // 等价命令: docker create --name my-container -p 8080:80 nginx:latest
+        //           其中 -p 8080:80 表示宿主机 8080 端口映射到容器内 80 端口
         CreateContainerResponse response = dockerClient.createContainerCmd(imageName)
                 .withName(containerName)
                 .withExposedPorts(exposedPort)
@@ -86,6 +92,7 @@ public class ContainerManageAPI {
      * @param containerId 容器 ID
      */
     public void startContainer(String containerId) {
+        // 等价命令: docker start <containerId>
         dockerClient.startContainerCmd(containerId).exec();
         log.info("容器 {} 启动成功", containerId);
     }
@@ -96,6 +103,7 @@ public class ContainerManageAPI {
      * @param timeoutSeconds 等待时长(秒),超过则强制终止
      */
     public void stopContainer(String containerId, Integer timeoutSeconds) {
+        // 等价命令: docker stop -t 10 <containerId>(先发 SIGTERM,超时后 SIGKILL)
         dockerClient.stopContainerCmd(containerId)
                 .withTimeout(timeoutSeconds)  // SIGTERM 后等待时间
                 .exec();
@@ -107,6 +115,7 @@ public class ContainerManageAPI {
      * @param containerId 容器 ID
      */
     public void killContainer(String containerId) {
+        // 等价命令: docker kill <containerId>(立即发送 SIGKILL,不给优雅退出机会)
         dockerClient.killContainerCmd(containerId).exec();
         log.info("容器 {} 已被强制停止", containerId);
     }
@@ -118,6 +127,8 @@ public class ContainerManageAPI {
      * @param removeVolumes 是否同时删除关联的卷
      */
     public void removeContainer(String containerId, boolean force, boolean removeVolumes) {
+        // 等价命令: docker rm <containerId>
+        // 等价命令: docker rm -f -v <containerId>(同时强制停止并删除数据卷)
         dockerClient.removeContainerCmd(containerId)
                 .withForce(force)            // 强制删除运行中的容器
                 .withRemoveVolumes(removeVolumes)  // 删除关联数据卷
@@ -130,6 +141,7 @@ public class ContainerManageAPI {
      * @return 容器列表
      */
     public List<Container> listContainers() {
+        // 等价命令: docker ps -a(列出所有容器,包括已停止的)
         // withShowAll(true) 展示所有容器(不只为运行中的)
         List<Container> containers = dockerClient.listContainersCmd()
                 .withShowAll(true)
@@ -143,6 +155,7 @@ public class ContainerManageAPI {
      * @return 运行中的容器列表
      */
     public List<Container> listRunningContainers() {
+        // 等价命令: docker ps(只列出运行中的容器)
         List<Container> containers = dockerClient.listContainersCmd().exec();
         log.info("运行中的容器数量: {}", containers.size());
         return containers;
@@ -154,6 +167,7 @@ public class ContainerManageAPI {
      * @return 容器详情
      */
     public InspectContainerResponse inspectContainer(String containerId) {
+        // 等价命令: docker inspect <containerId>(查看容器完整 JSON 详情)
         InspectContainerResponse response = dockerClient.inspectContainerCmd(containerId).exec();
         log.info("容器 {} 状态: {}, 名称: {}",
                 containerId, response.getState().getStatus(), response.getName());

+ 44 - 7
src/main/java/space/anyi/docker/ContainerOpsAPI.java

@@ -8,14 +8,22 @@ import com.github.dockerjava.api.command.LogContainerCmd;
 import com.github.dockerjava.api.command.WaitContainerResultCallback;
 import com.github.dockerjava.api.model.Frame;
 import com.github.dockerjava.api.model.StreamType;
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.concurrent.TimeUnit;
 
 /**
  * Docker 容器运维 API 示例
  * 演示获取日志、执行命令、复制文件、等待容器等功能
+ * 每个操作旁标注了等价的 Docker CLI 命令,方便对照理解
  */
 public class ContainerOpsAPI {
     private static final Logger log = LoggerFactory.getLogger(ContainerOpsAPI.class);
@@ -36,6 +44,7 @@ public class ContainerOpsAPI {
     public String getContainerLogs(String containerId, int tailLines) {
         StringBuilder logBuilder = new StringBuilder();
         try {
+            // 等价命令: docker logs --tail 10 <containerId>
             // 使用日志容器回调收集日志
             // withTail 只获取末尾 N 行
             LogContainerCmd cmd = dockerClient.logContainerCmd(containerId)
@@ -71,6 +80,7 @@ public class ContainerOpsAPI {
     public String execCommandInContainer(String containerId, String... command) {
         StringBuilder output = new StringBuilder();
         try {
+            // 等价命令: docker exec <containerId> ls -la /
             // 1. 创建 exec 实例(描述要在容器中执行什么命令)
             ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId)
                     .withCmd(command)              // 要执行的命令
@@ -104,19 +114,42 @@ public void onNext(Frame frame) {
      * @param localDest 宿主机目标目录
      */
     public void copyFromContainer(String containerId, String containerPath, String localDest) {
-        try {
-            // 从容器复制文件(tar 流形式返回)
-            try (var stream = dockerClient.copyArchiveFromContainerCmd(containerId, containerPath)
-                    .withHostPath(localDest)
-                    .exec()) {
-                // 文件将通过 tar 流复制到目标路径
-            }
+        // 等价命令: docker cp <containerId>:<containerPath> <localDest>
+        try (InputStream tarStream = dockerClient.copyArchiveFromContainerCmd(containerId, containerPath).exec()) {
+            // copyArchiveFromContainerCmd 返回 tar 字节流,需要用 TarArchiveInputStream 解压
+            extractTarStream(tarStream, Path.of(localDest));
             log.info("已从容器 {} 复制 {} 到 {}", containerId, containerPath, localDest);
         } catch (Exception e) {
             log.error("复制文件失败: {}", e.getMessage());
         }
     }
 
+    /**
+     * 将 docker cp 返回的 tar 流解压到指定目录
+     * @param tarStream tar 输入流
+     * @param destDir 解压目标目录
+     */
+    private void extractTarStream(InputStream tarStream, Path destDir) throws IOException {
+        try (TarArchiveInputStream tarIn = new TarArchiveInputStream(tarStream)) {
+            TarArchiveEntry entry;
+            while ((entry = tarIn.getNextTarEntry()) != null) {
+                // 逐个解压 tar 包中的条目,防止目录穿越
+                Path target = destDir.resolve(entry.getName()).normalize();
+                if (!target.startsWith(destDir.toAbsolutePath())) {
+                    throw new IOException("非法路径: " + entry.getName());
+                }
+                if (entry.isDirectory()) {
+                    Files.createDirectories(target);
+                } else {
+                    Files.createDirectories(target.getParent());
+                    try (OutputStream out = Files.newOutputStream(target)) {
+                        tarIn.transferTo(out);
+                    }
+                }
+            }
+        }
+    }
+
     /**
      * 等待容器退出
      * @param containerId 容器 ID
@@ -125,6 +158,7 @@ public void onNext(Frame frame) {
      */
     public int waitContainer(String containerId, int timeoutSeconds) {
         try {
+            // 等价命令: docker wait <containerId>(阻塞直到容器退出,返回退出码)
             WaitContainerResultCallback callback = dockerClient.waitContainerCmd(containerId)
                     .exec(new WaitContainerResultCallback());
             // 阻塞等待容器退出,设置超时
@@ -145,6 +179,8 @@ public void onNext(Frame frame) {
      * @return 容器 ID
      */
     public String createTemporaryContainer(String containerName, String shellCommand) {
+        // 等价命令: docker run --name temp-container image sh -c "echo hello"
+        //           --entrypoint 覆盖镜像自带的 ENTRYPOINT
         // withEntrypoint("sh", "-c") 覆盖镜像自带的 ENTRYPOINT
         // withCmd(shellCommand) 作为 sh -c 的唯一参数(即要执行的脚本内容)
         CreateContainerResponse container = dockerClient.createContainerCmd("registry.fit2cloud.com/halo/halo-pro:2.24")
@@ -163,6 +199,7 @@ public void onNext(Frame frame) {
      * @param removeVolumes 是否同时删除数据卷
      */
     public void removeContainer(String containerId, boolean force, boolean removeVolumes) {
+        // 等价命令: docker rm -f <containerId>(测试清理用)
         dockerClient.removeContainerCmd(containerId)
                 .withForce(force)
                 .withRemoveVolumes(removeVolumes)

+ 4 - 0
src/main/java/space/anyi/docker/ImageBuildAPI.java

@@ -12,6 +12,7 @@ import java.util.List;
 /**
  * Docker 镜像构建 API 示例
  * 演示如何从 Dockerfile 构建自定义镜像
+ * 每个操作旁标注了等价的 Docker CLI 命令,方便对照理解
  */
 public class ImageBuildAPI {
     private static final Logger log = LoggerFactory.getLogger(ImageBuildAPI.class);
@@ -36,6 +37,7 @@ public class ImageBuildAPI {
         }
 
         try {
+            // 等价命令: docker build -f Dockerfile -t sdk-demo/hello:latest .
             // withDockerfile 指定 Dockerfile 文件位置
             // withBaseDirectory 设置构建上下文(当前目录)
             // withTag 指定构建后的镜像名称和标签(如 "sdk-demo/hello:latest")
@@ -68,6 +70,7 @@ public class ImageBuildAPI {
         }
 
         try {
+            // 等价命令: docker build --rm --force-rm -t sdk-demo/hello:latest .
             String imageId = dockerClient.buildImageCmd()
                     .withDockerfile(dockerfile)
                     .withBaseDirectory(dockerfile.getParentFile())
@@ -90,6 +93,7 @@ public class ImageBuildAPI {
      * @return 匹配的镜像列表
      */
     public List<Image> findImageByName(String imageName) {
+        // 等价命令: docker images --filter "reference=<imageName>"(按名称过滤)
         List<Image> images = dockerClient.listImagesCmd().exec();
         return images.stream()
                 .filter(img -> img.getRepoTags() != null &&

+ 48 - 0
src/main/java/space/anyi/docker/ImageManageAPI.java

@@ -7,12 +7,18 @@ import com.github.dockerjava.api.model.Image;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.Arrays;
 import java.util.List;
 
 /**
  * Docker 镜像管理 API 示例
  * 演示拉取、列出、查看详情、删除、标记、导入导出等镜像操作
+ * 每个操作旁标注了等价的 Docker CLI 命令,方便对照理解
  */
 public class ImageManageAPI {
     private static final Logger log = LoggerFactory.getLogger(ImageManageAPI.class);
@@ -29,6 +35,7 @@ public class ImageManageAPI {
      * @return 镜像列表
      */
     public List<Image> listImages() {
+        // 等价命令: docker images -a(列出所有镜像,包括中间层缩影)
         List<Image> images = dockerClient.listImagesCmd()
                 .withShowAll(true)      // 展示所有镜像(包括中间层)
                 .exec();
@@ -43,6 +50,7 @@ public class ImageManageAPI {
      * @return 镜像详情
      */
     public InspectImageResponse inspectImage(String imageId) {
+        // 等价命令: docker inspect <imageId>
         InspectImageResponse response = dockerClient.inspectImageCmd(imageId).exec();
         log.info("镜像 {} 详情: {}", imageId, response);
         return response;
@@ -56,6 +64,7 @@ public class ImageManageAPI {
      */
     public boolean pullImage(String imageName) {
         // 先检查本地是否已存在该镜像,避免无谓的网络访问
+        // 等价命令: docker images 查看本地镜像列表
         boolean exists = dockerClient.listImagesCmd().exec().stream()
                 .anyMatch(img -> img.getRepoTags() != null &&
                         Arrays.asList(img.getRepoTags()).contains(imageName));
@@ -64,6 +73,7 @@ public class ImageManageAPI {
             return true;
         }
         try {
+            // 等价命令: docker pull ubuntu:latest
             // exec() 执行命令,awaitCompletion() 阻塞等待镜像拉取完成
             dockerClient.pullImageCmd(imageName)
                     .exec(new PullImageResultCallback())
@@ -83,6 +93,8 @@ public class ImageManageAPI {
      * @param force 是否强制删除(即使镜像被容器使用)
      */
     public void removeImage(String imageId, boolean force) {
+        // 等价命令: docker rmi <imageId>
+        // 等价命令: docker rmi -f <imageId>(force=true 时强制删除)
         dockerClient.removeImageCmd(imageId)
                 .withForce(force)   // 强制删除
                 .exec();
@@ -96,16 +108,52 @@ public class ImageManageAPI {
      * @param tag 新的标签名
      */
     public void tagImage(String imageId, String repository, String tag) {
+        // 等价命令: docker tag <imageId> <repository>:<tag>
         dockerClient.tagImageCmd(imageId, repository, tag).exec();
         log.info("镜像 {} 已标记为 {}:{}", imageId, repository, tag);
     }
 
+    /**
+     * 将镜像导出为 tar 归档文件
+     * @param imageId 镜像 ID 或名称
+     * @param tarFilePath 导出的 tar 文件路径
+     * @throws IOException 文件写入失败时抛出
+     */
+    public void saveImage(String imageId, String tarFilePath) throws IOException {
+        // 等价命令: docker save <imageId> -o nginx-backup.tar
+        try (InputStream tarStream = dockerClient.saveImageCmd(imageId).exec();
+             FileOutputStream fos = new FileOutputStream(tarFilePath)) {
+            // saveImageCmd 返回的是 tar 格式的字节流,写入到本地文件
+            tarStream.transferTo(fos);
+            log.info("镜像 {} 已导出到 {}", imageId, tarFilePath);
+        }
+    }
+
+    /**
+     * 从 tar 归档文件加载镜像
+     * @param tarFilePath 镜像 tar 文件路径
+     * @return 加载是否成功
+     */
+    public boolean loadImage(String tarFilePath) {
+        // 等价命令: docker load -i nginx-backup.tar
+        try (InputStream tarStream = Files.newInputStream(Path.of(tarFilePath))) {
+            // loadImageCmd() 直接接受 InputStream 参数
+            dockerClient.loadImageCmd(tarStream).exec();
+            log.info("镜像已从 {} 加载成功", tarFilePath);
+            return true;
+        } catch (IOException e) {
+            log.error("镜像加载失败: {}", e.getMessage());
+            return false;
+        }
+    }
+
     /**
      * 检查本地是否存在指定镜像
      * @param imageName 镜像名称(仓库名:标签)
      * @return 是否存在
      */
     public boolean checkImageExists(String imageName) {
+        // 等价命令: docker images | grep <imageName>(组合命令判断是否存在)
         boolean exists = dockerClient.listImagesCmd().exec().stream()
                 .anyMatch(img -> img.getRepoTags() != null &&
                         Arrays.asList(img.getRepoTags()).contains(imageName));

+ 51 - 4
src/main/java/space/anyi/docker/QuickStart.java

@@ -4,7 +4,9 @@ import com.github.dockerjava.api.DockerClient;
 import com.github.dockerjava.api.command.CreateContainerResponse;
 import com.github.dockerjava.api.command.PullImageResultCallback;
 import com.github.dockerjava.api.model.ExposedPort;
+import com.github.dockerjava.api.model.Info;
 import com.github.dockerjava.api.model.Ports;
+import com.github.dockerjava.api.model.Version;
 import com.github.dockerjava.core.DefaultDockerClientConfig;
 import com.github.dockerjava.core.DockerClientBuilder;
 import com.github.dockerjava.core.DockerClientConfig;
@@ -19,6 +21,7 @@ import java.time.Duration;
 /**
  * Docker Java SDK 快速入门示例
  * 演示 DockerClient 的创建、连接、拉取镜像、创建并启动容器等基本操作
+ * 每个操作旁标注了等价的 Docker CLI 命令,方便对照理解
  */
 public class QuickStart {
     private static final Logger log = LoggerFactory.getLogger(QuickStart.class);
@@ -43,8 +46,12 @@ public class QuickStart {
      */
     public void quickStart() {
         DockerClient defaultClient = DockerClientBuilder.getInstance().build();
+
+        // 等价命令: docker version(连接自检,底层请求 HTTP GET /_ping)
         defaultClient.pingCmd().exec();
         log.info("Docker client ping 成功!");
+
+        // 等价命令: docker images
         defaultClient.listImagesCmd().exec().forEach(image -> {
             log.info("Docker 镜像信息: {}", image);
         });
@@ -55,7 +62,7 @@ public class QuickStart {
      * 可以自定义连接地址、TLS、超时时间等参数
      */
     public void customStart() {
-        final String UNIX_HOST = "unix://var/run/docker.sock";
+        final String UNIX_HOST = "unix:///var/run/docker.sock";
         final String TCP_HOST = "tcp://0.0.0.0:2375";
         // 构建 DockerClientConfig 配置对象
         DockerClientConfig dockerClientConfig = DefaultDockerClientConfig.createDefaultConfigBuilder()
@@ -79,12 +86,39 @@ public class QuickStart {
         log.info("HTTP 客户端配置: {}", httpClient);
         // 根据自定义配置创建 DockerClient
         DockerClient customClient = DockerClientImpl.getInstance(dockerClientConfig, httpClient);
+
+        // 等价命令: docker version(连接自检)
         customClient.pingCmd().exec();
+
+        // 等价命令: docker images
         customClient.listImagesCmd().exec().forEach(image -> {
             log.info("Docker 镜像信息: {}", image);
         });
     }
 
+    /**
+     * 获取 Docker 客户端与 daemon 的版本信息
+     * 等价命令: docker version
+     */
+    public void getDockerVersion() {
+        // 等价命令: docker version "Docker" 输出客户端与服务端版本
+        // 等价命令: docker version --format '{{.Server.Version}}' 只取服务端版本
+        Version version = dockerClient.versionCmd().exec();
+        log.info("Docker 服务端版本: {}, API 版本: {}, 架构: {}, 运行时间: {}",
+                version.getVersion(), version.getApiVersion(), version.getArch(), version.getBuildTime());
+    }
+
+    /**
+     * 获取 Docker daemon 的系统级信息(容器数、镜像数、存储驱动等)
+     * 等价命令: docker info
+     */
+    public void getDockerInfo() {
+        // 等价命令: docker info
+        Info info = dockerClient.infoCmd().exec();
+        log.info("Docker 系统信息: 容器数: {}, 镜像数: {}, 服务端版本: {}",
+                info.getContainers(), info.getImages(), info.getServerVersion());
+    }
+
     /**
      * 拉取指定名称的镜像
      * 优化:如果本地已存在同名镜像则直接使用,无需访问网络
@@ -92,6 +126,7 @@ public class QuickStart {
      */
     public void pullImage(String imageName) {
         // 先检查本地是否已存在该镜像,避免无谓的网络访问
+        // 等价命令: docker images 查看本地镜像列表
         boolean exists = dockerClient.listImagesCmd().exec().stream()
                 .anyMatch(img -> img.getRepoTags() != null &&
                         java.util.Arrays.asList(img.getRepoTags()).contains(imageName));
@@ -100,6 +135,7 @@ public class QuickStart {
             return;
         }
         try {
+            // 等价命令: docker pull nginx:latest
             // exec() 执行命令,awaitCompletion() 阻塞等待镜像拉取完成
             dockerClient.pullImageCmd(imageName)
                     .exec(new PullImageResultCallback())
@@ -118,12 +154,15 @@ public class QuickStart {
      */
     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);
     }
@@ -143,6 +182,8 @@ public class QuickStart {
         Ports portBindings = new Ports();
         portBindings.bind(exposedPort, Ports.Binding.bindPort(hostPort));
 
+        // 等价命令: docker run -d --name demo-nginx -p 8080:80 nginx:latest
+        //           其中 -p 8080:80 表示"宿主机8080 -> 容器内80"
         CreateContainerResponse container = dockerClient.createContainerCmd(imageName)
                 .withName(containerName)
                 .withExposedPorts(exposedPort)
@@ -150,6 +191,7 @@ public class QuickStart {
                 .exec();
         log.info("容器创建成功,ID: {}, 端口映射: {} -> {}", container.getId(), hostPort, containerPort);
 
+        // 等价命令: docker start demo-nginx
         dockerClient.startContainerCmd(container.getId()).exec();
         log.info("容器 {} 启动成功!", containerName);
     }
@@ -160,31 +202,36 @@ public class QuickStart {
      */
     public void runDemo() {
         // 1. 测试连接
+        // 等价命令: docker version
         dockerClient.pingCmd().exec();
         log.info("1. Docker 连接成功");
 
         // 2. 检查本地是否已有可用的镜像
-        String imageName = "alist666/alist:latest";
+        String imageName = "nginx:latest";
+        // 等价命令: docker images
         boolean hasImage = dockerClient.listImagesCmd().exec().stream()
                 .anyMatch(img -> img.getRepoTags() != null &&
                         java.util.Arrays.asList(img.getRepoTags()).contains(imageName));
         if (!hasImage) {
-            log.warn("本地不存在镜像 {},跳过容器演示", imageName);
-            return;
+            log.warn("本地不存在镜像 {},尝试拉取", imageName);
+            pullImage(imageName);
         }
         log.info("2. 找到本地镜像: {}", imageName);
 
         // 3. 创建容器
+        // 等价命令: docker create --name demo-container nginx:latest
         CreateContainerResponse container = dockerClient.createContainerCmd(imageName)
                 .withName("demo-container-" + System.currentTimeMillis())
                 .exec();
         log.info("3. 容器创建成功,ID: {}", container.getId());
 
         // 4. 启动容器
+        // 等价命令: docker start demo-container
         dockerClient.startContainerCmd(container.getId()).exec();
         log.info("4. 容器启动成功");
 
         // 5. 清理容器(避免污染环境)
+        // 等价命令: docker rm -f demo-container
         dockerClient.removeContainerCmd(container.getId())
                 .withForce(true)
                 .exec();