Эх сурвалжийг харах

feat: add Chapter 6 - container ops APIs (logs/exec/wait/copy)

yangyi 2 өдөр өмнө
parent
commit
ccd6809150

+ 172 - 1
docker-java-sdk-tutorial.md

@@ -1050,4 +1050,175 @@ Docker 的设计中,一个容器可以有多个名称(别名),通常是
 
 ***
 
-下一章,我们将讲解容器运维相关 API,包括查看日志、在容器中执行命令、文件复制等操作。
+## 容器运维相关API
+
+前面我们学习了容器的基础管理(创建、启动、停止、删除),本章将深入讲解容器的**运维操作**:查看日志、在容器中执行命令、文件复制、等待容器退出等。这些操作是日常运维中最常用的功能。
+
+### logsCmd - 获取容器日志
+
+获取容器日志对应 `docker logs` 命令。日志以**帧(Frame)**的方式流式返回,每帧包含数据类型和载荷:
+
+```java
+/**
+ * 获取容器日志(同步方式)
+ * @param containerId 容器 ID
+ * @param tailLines 只显示末尾的行数
+ * @return 日志文本
+ */
+public String getContainerLogs(String containerId, int tailLines) {
+    StringBuilder logBuilder = new StringBuilder();
+    try {
+        // 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 {
+        // 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 {
+        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. 清理容器
+        dockerClient.removeContainerCmd(id).withForce(true).exec();
+        log.info("4. 临时容器已清理");
+    } catch (Exception e) {
+        log.error("运维演示失败: {}", e.getMessage());
+    }
+}
+```
+
+**思考:** 结合前面学习的 exec 与 logs 场景,在"容器内执行命令"获取到的输出与"查看容器日志"获取到的是否相同?它们有什么区别?
+
+`exec` 获取的是**在运行中的容器里新执行命令**的输出,相当于 `docker exec <container> <cmd>`;而 `logs` 获取的是**容器主进程**(PID 1)启动以来产生的日志,相当于 `docker logs <container>`。两者针对的是不同数据源:exec 是临时命令的输出,logs 是容器自身的运行日志。
+
+***
+
+下一章,我们将总结 DockerClient 核心对象与全部 API 的对照关系,形成一份速查表。

+ 1 - 1
docker-java-sdk.md

@@ -8,7 +8,7 @@
 - 创建DockerClient
 - 连接到Docker
 - 拉取镜像
-- 创建容器并启动4
+- 创建容器并启动
 ## 镜像管理相关API
 ## 镜像构建相关API
 ## 容器管理相关API

+ 199 - 0
src/main/java/space/anyi/docker/ContainerOpsAPI.java

@@ -0,0 +1,199 @@
+package space.anyi.docker;
+
+import com.github.dockerjava.api.DockerClient;
+import com.github.dockerjava.api.async.ResultCallback;
+import com.github.dockerjava.api.command.CreateContainerResponse;
+import com.github.dockerjava.api.command.ExecCreateCmdResponse;
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Docker 容器运维 API 示例
+ * 演示获取日志、执行命令、复制文件、等待容器等功能
+ */
+public class ContainerOpsAPI {
+    private static final Logger log = LoggerFactory.getLogger(ContainerOpsAPI.class);
+
+    private final DockerClient dockerClient;
+
+    public ContainerOpsAPI() {
+        this.dockerClient = DockerClientFactory.createDockerClient();
+    }
+
+    /**
+     * 获取容器日志(同步方式)
+     * 只获取标准输出,stdout=true, stderr=false
+     * @param containerId 容器 ID
+     * @param tailLines 只显示末尾的行数
+     * @return 日志文本
+     */
+    public String getContainerLogs(String containerId, int tailLines) {
+        StringBuilder logBuilder = new StringBuilder();
+        try {
+            // 使用日志容器回调收集日志
+            // 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() == StreamType.STDOUT ||
+                            frame.getStreamType() == StreamType.STDERR ||
+                            frame.getStreamType() == StreamType.RAW) {
+                        logBuilder.append(new String(frame.getPayload()));
+                    }
+                }
+            }).awaitCompletion();
+            return logBuilder.toString();
+        } catch (InterruptedException e) {
+            log.error("获取容器日志被中断: {}", e.getMessage());
+            Thread.currentThread().interrupt();
+            return logBuilder.toString();
+        }
+    }
+
+    /**
+     * 在容器中执行命令(同步方式)
+     * @param containerId 容器 ID
+     * @param command 要执行的命令参数,如 ["ls", "-la"] 或 ["echo", "hello"]
+     * @return 命令输出的文本
+     */
+    public String execCommandInContainer(String containerId, String... command) {
+        StringBuilder output = new StringBuilder();
+        try {
+            // 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() == StreamType.STDOUT ||
+                                frame.getStreamType() == StreamType.STDERR) {
+                            output.append(new String(frame.getPayload()));
+                        }
+                    }
+                    }).awaitCompletion();
+            return output.toString();
+        } catch (InterruptedException e) {
+            log.error("执行容器命令被中断: {}", e.getMessage());
+            Thread.currentThread().interrupt();
+            return output.toString();
+        }
+    }
+
+    /**
+     * 复制容器内文件到宿主机
+     * @param containerId 容器 ID
+     * @param containerPath 容器内文件路径
+     * @param localDest 宿主机目标目录
+     */
+    public void copyFromContainer(String containerId, String containerPath, String localDest) {
+        try {
+            // 从容器复制文件(tar 流形式返回)
+            try (var stream = dockerClient.copyArchiveFromContainerCmd(containerId, containerPath)
+                    .withHostPath(localDest)
+                    .exec()) {
+                // 文件将通过 tar 流复制到目标路径
+            }
+            log.info("已从容器 {} 复制 {} 到 {}", containerId, containerPath, localDest);
+        } catch (Exception e) {
+            log.error("复制文件失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 等待容器退出
+     * @param containerId 容器 ID
+     * @param timeoutSeconds 超时时间(秒)
+     * @return 容器退出码(-1 表示超时)
+     */
+    public int waitContainer(String containerId, int timeoutSeconds) {
+        try {
+            WaitContainerResultCallback callback = dockerClient.waitContainerCmd(containerId)
+                    .exec(new WaitContainerResultCallback());
+            // 阻塞等待容器退出,设置超时
+            int exitCode = callback.awaitStatusCode(timeoutSeconds, TimeUnit.SECONDS);
+            log.info("容器 {} 已退出,退出码: {}", containerId, exitCode);
+            return exitCode;
+        } catch (Exception e) {
+            log.error("等待容器退出异常: {}", e.getMessage());
+            return -1;
+        }
+    }
+
+    /**
+     * 为运维演示创建临时容器
+     * 注意:halo 镜像自带 ENTRYPOINT,需要覆盖 entrypoint 才能让自定义命令生效
+     * @param containerName 容器名称
+     * @param shellCommand 要执行的 shell 命令(单个字符串,将被 "sh -c" 包装执行)
+     * @return 容器 ID
+     */
+    public String createTemporaryContainer(String containerName, String shellCommand) {
+        // withEntrypoint("sh", "-c") 覆盖镜像自带的 ENTRYPOINT
+        // withCmd(shellCommand) 作为 sh -c 的唯一参数(即要执行的脚本内容)
+        CreateContainerResponse container = dockerClient.createContainerCmd("registry.fit2cloud.com/halo/halo-pro:2.24")
+                .withName(containerName)
+                .withEntrypoint("sh", "-c")
+                .withCmd(shellCommand)
+                .exec();
+        dockerClient.startContainerCmd(container.getId()).exec();
+        return container.getId();
+    }
+
+    /**
+     * 删除容器(测试清理用)
+     * @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);
+    }
+
+    /**
+     * 获取最新运行的容器的示例日志(演示用)
+     * 简化版:创建容器、执行命令、删除容器
+     */
+    public void opsDemo() {
+        String containerName = "ops-demo-" + System.currentTimeMillis();
+        try {
+            // 1. 创建一个执行 echo 命令的容器
+            String id = createTemporaryContainer(containerName, "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. 清理容器
+            removeContainer(id, true, false);
+            log.info("4. 临时容器已清理");
+        } catch (Exception e) {
+            log.error("运维演示失败: {}", e.getMessage());
+        }
+    }
+}

+ 72 - 0
src/test/java/space/anyi/docker/ContainerOpsAPITest.java

@@ -0,0 +1,72 @@
+package space.anyi.docker;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 容器运维 API 的测试类
+ * 注意:需要本机 Docker daemon 运行中
+ * 测试会创建临时容器并自动清理,不影响现有环境
+ */
+class ContainerOpsAPITest {
+
+    private static final String LOCAL_IMAGE = "registry.fit2cloud.com/halo/halo-pro:2.24";
+
+    private String uniqueName(String prefix) {
+        return prefix + "-" + UUID.randomUUID().toString().substring(0, 8);
+    }
+
+    /**
+     * 测试在容器中执行命令
+     */
+    @Test
+    void execCommandInContainer() {
+        ContainerOpsAPI api = new ContainerOpsAPI();
+        String name = uniqueName("test-exec");
+        // 创建并启动一个持续运行的容器
+        String containerId = api.createTemporaryContainer(name, "sleep 999");
+
+        try {
+            // 在容器中执行 ls 命令
+            String result = api.execCommandInContainer(containerId, "ls", "-la", "/");
+            assertNotNull(result, "命令输出不应为 null");
+            assertFalse(result.isEmpty(), "ls -la / 应返回输出内容");
+            assertTrue(result.contains("bin"), "根目录应包含 bin");
+        } finally {
+            // 清理容器
+            api.removeContainer(containerId, true, false);
+        }
+    }
+
+    /**
+     * 测试获取容器日志
+     */
+    @Test
+    void getContainerLogs() {
+        ContainerOpsAPI api = new ContainerOpsAPI();
+        String name = uniqueName("test-log");
+        // 启动时会输出 started
+        String containerId = api.createTemporaryContainer(name, "echo started && sleep 999");
+
+        try {
+            // 获取日志,应包含容器主进程的输出
+            String logs = api.getContainerLogs(containerId, 10);
+            assertNotNull(logs);
+            assertTrue(logs.contains("started"), "日志应包含 started");
+        } finally {
+            api.removeContainer(containerId, true, false);
+        }
+    }
+
+    /**
+     * 测试 opsDemo 完整流程
+     */
+    @Test
+    void opsDemo() {
+        ContainerOpsAPI api = new ContainerOpsAPI();
+        assertDoesNotThrow(api::opsDemo, "运维演示流程应正常执行");
+    }
+}