Przeglądaj źródła

feat: add Chapter 11 - monitoring and maintenance ops (stats/top/logs/commit/export/prune)

yangyi 2 dni temu
rodzic
commit
f92142a3e2

+ 440 - 0
src/main/java/space/anyi/docker/MaintenanceOpsAPI.java

@@ -0,0 +1,440 @@
+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.CreateImageResponse;
+import com.github.dockerjava.api.command.LogContainerCmd;
+import com.github.dockerjava.api.command.TopContainerResponse;
+import com.github.dockerjava.api.model.ChangeLog;
+import com.github.dockerjava.api.model.Frame;
+import com.github.dockerjava.api.model.PruneResponse;
+import com.github.dockerjava.api.model.PruneType;
+import com.github.dockerjava.api.model.Statistics;
+import com.github.dockerjava.api.model.StreamType;
+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.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Docker 日常运维与维护 API 示例
+ * 演示监控查看(stats/top/logs -f)、文件系统变更(diff)、归档(export/import/commit)、
+ * 生命周期补全(restart/pause/rename/update)、复制文件进容器(cp -进)等操作
+ * 每个操作旁标注了等价的 Docker CLI 命令,方便对照理解
+ */
+public class MaintenanceOpsAPI {
+    private static final Logger log = LoggerFactory.getLogger(MaintenanceOpsAPI.class);
+
+    private final DockerClient dockerClient;
+
+    public MaintenanceOpsAPI() {
+        this.dockerClient = DockerClientFactory.createDockerClient();
+    }
+
+    // ==================== 生命周期补全 ====================
+
+    /**
+     * 重启容器(先停止马上再启动)
+     * 等价命令: docker restart <containerId>
+     * @param containerId 容器 ID
+     */
+    public void restartContainer(String containerId) {
+        // 等价命令: docker restart <containerId>(内部 = stop + start)
+        dockerClient.restartContainerCmd(containerId).exec();
+        log.info("容器 {} 已重启", containerId);
+    }
+
+    /**
+     * 暂停容器(冻结进程,不停止)
+     * 等价命令: docker pause <containerId>
+     * @param containerId 容器 ID
+     */
+    public void pauseContainer(String containerId) {
+        // 等价命令: docker pause <containerId>(基于 cgroup freezer,进程被冻结)
+        dockerClient.pauseContainerCmd(containerId).exec();
+        log.info("容器 {} 已暂停", containerId);
+    }
+
+    /**
+     * 恢复已暂停的容器
+     * 等价命令: docker unpause <containerId>
+     * @param containerId 容器 ID
+     */
+    public void unpauseContainer(String containerId) {
+        // 等价命令: docker unpause <containerId>
+        dockerClient.unpauseContainerCmd(containerId).exec();
+        log.info("容器 {} 已恢复", containerId);
+    }
+
+    /**
+     * 重命名容器(容器在运行中也可改名)
+     * 等价命令: docker rename <containerId> <newName>
+     * @param containerId 容器 ID
+     * @param newName 新名称
+     */
+    public void renameContainer(String containerId, String newName) {
+        // 等价命令: docker rename <containerId> <newName>
+        dockerClient.renameContainerCmd(containerId).withName(newName).exec();
+        log.info("容器 {} 已重命名为 {}", containerId, newName);
+    }
+
+    /**
+     * 动态更新容器资源限制(无需重建容器)
+     * 等价命令: docker update --memory 256m <containerId>
+     * @param containerId 容器 ID
+     * @param memoryMb 新的内存限制(MB)
+     */
+    public void updateContainerResources(String containerId, long memoryMb) {
+        // 等价命令: docker update --memory 256m <containerId>
+        // 注意:Docker 要求 memory-swap >= memory,只改 memory 会返回 409,
+        // 因此这里同步调整 memory-swap(保持为内存的 2 倍,与 docker 默认一致)
+        long memoryBytes = memoryMb * 1024 * 1024;
+        dockerClient.updateContainerCmd(containerId)
+                .withMemory(memoryBytes)
+                .withMemorySwap(memoryBytes * 2)
+                .exec();
+        log.info("容器 {} 资源限制已更新为 {}MB", containerId, memoryMb);
+    }
+
+    // ==================== 监控与查看 ====================
+
+    /**
+     * 查看容器内运行的进程(等价 docker top)
+     * 等价命令: docker top <containerId>
+     * @param containerId 容器 ID
+     * @return 进程列表(每行一个 String[])
+     */
+    public String[][] topContainer(String containerId) {
+        // 等价命令: docker top <containerId>(相当于容器内 ps aux)
+        TopContainerResponse response = dockerClient.topContainerCmd(containerId).exec();
+        var titles = response.getTitles();
+        log.info("容器 {} 进程列表 (字段: {})", containerId, String.join(", ", titles));
+        for (String[] process : response.getProcesses()) {
+            log.info("  {}", String.join(" ", process));
+        }
+        return response.getProcesses();
+    }
+
+    /**
+     * 获取容器资源使用统计(单次快照,等价 docker stats --no-stream)
+     * 等价命令: docker stats --no-stream <containerId>
+     * @param containerId 容器 ID
+     * @return 统计信息
+     */
+    public Statistics getContainerStats(String containerId) {
+        // 单次快照:withNoStream(true) 只取一帧就完成,不持续输出
+        // 等价命令: docker stats --no-stream <containerId>
+        AtomicReference<Statistics> snapshot = new AtomicReference<>();
+        try {
+            dockerClient.statsCmd(containerId)
+                    .withNoStream(true)
+                    .exec(new ResultCallback.Adapter<>() {
+                        @Override
+                        public void onNext(Statistics stats) {
+                            snapshot.set(stats);
+                        }
+                    }).awaitCompletion();
+
+            Statistics stats = snapshot.get();
+            if (stats != null && stats.getMemoryStats() != null) {
+                long usageMb = stats.getMemoryStats().getUsage() / 1024 / 1024;
+                long limitMb = stats.getMemoryStats().getLimit() / 1024 / 1024;
+                log.info("容器 {} 内存使用: {}MB / {}MB, 进程数: {}",
+                        containerId, usageMb, limitMb, stats.getNumProcs());
+            }
+            return stats;
+        } catch (InterruptedException e) {
+            log.error("获取容器统计被中断: {}", e.getMessage());
+            Thread.currentThread().interrupt();
+            return null;
+        }
+    }
+
+    /**
+     * 实时跟随容器日志(等价 docker logs -f)
+     * 与 Ch6 的 getContainerLogs(一次性读取)不同,这里持续输出新日志直到超时
+     * 等价命令: docker logs -f --tail 50 <containerId>
+     * @param containerId 容器 ID
+     * @param followSeconds 跟随时长(秒),超过后自动断开
+     * @return 跟随期间收集到的日志
+     */
+    public String followContainerLogs(String containerId, long followSeconds) {
+        // 等价命令: docker logs -f <containerId>
+        // withFollowStream(true) 进入跟随模式,容器产生新日志会实时推送到回调
+        StringBuilder output = new StringBuilder();
+        try {
+            LogContainerCmd cmd = dockerClient.logContainerCmd(containerId)
+                    .withStdOut(true)
+                    .withStdErr(true)
+                    .withFollowStream(true)
+                    .withTail(50);
+
+            ResultCallback<Frame> callback = cmd.exec(new ResultCallback.Adapter<>() {
+                @Override
+                public void onNext(Frame frame) {
+                    if (frame.getStreamType() == StreamType.STDOUT ||
+                            frame.getStreamType() == StreamType.STDERR ||
+                            frame.getStreamType() == StreamType.RAW) {
+                        output.append(new String(frame.getPayload()));
+                    }
+                }
+            });
+
+            // 跟随指定时长后主动断开(模拟 Ctrl+C 结束 docker logs -f)
+            Thread.sleep(followSeconds * 1000);
+            callback.close();
+
+            log.info("跟随 {} 秒后收集日志 {} 字节", followSeconds, output.length());
+            return output.toString();
+        } catch (InterruptedException e) {
+            log.error("跟随日志被中断: {}", e.getMessage());
+            Thread.currentThread().interrupt();
+            return output.toString();
+        } catch (IOException e) {
+            log.error("关闭日志流失败: {}", e.getMessage());
+            return output.toString();
+        }
+    }
+
+    // ==================== 文件系统 ====================
+
+    /**
+     * 查看容器相对镜像的文件系统变更(新增/修改/删除)
+     * 等价命令: docker diff <containerId>
+     * @param containerId 容器 ID
+     * @return 变更列表
+     */
+    public List<ChangeLog> containerDiff(String containerId) {
+        // 等价命令: docker diff <containerId>
+        // kind: 0=已修改 1=新增 2=删除
+        List<ChangeLog> changes = dockerClient.containerDiffCmd(containerId).exec();
+        for (ChangeLog change : changes) {
+            log.info("  {} {}", change.getKind() == 1 ? "新增" :
+                    change.getKind() == 2 ? "删除" : "修改", change.getPath());
+        }
+        return changes;
+    }
+
+    /**
+     * 复制本地文件/目录到容器中(docker cp 的放入方向)
+     * 等价命令: docker cp ./local.txt <containerId>:/tmp/local.txt
+     * @param containerId 容器 ID
+     * @param hostResource 宿主机文件或目录路径
+     * @param containerPath 容器内目标路径
+     */
+    public void copyToContainer(String containerId, String hostResource, String containerPath) {
+        // 等价命令: docker cp ./local.txt <containerId>:/tmp/local.txt
+        dockerClient.copyArchiveToContainerCmd(containerId)
+                .withHostResource(hostResource)
+                .withRemotePath(containerPath)
+                .exec();
+        log.info("已复制 {} 到容器 {} 的 {}", hostResource, containerId, containerPath);
+    }
+
+    // ==================== 归档与快照 ====================
+
+    /**
+     * 将运行中容器的文件系统导出为 tar 文件(不含镜像层和元数据)
+     * 等价命令: docker export <containerId> -o backup.tar
+     * @param containerId 容器 ID
+     * @param tarFilePath 导出文件路径
+     * @throws IOException 文件写入失败
+     */
+    public void exportContainer(String containerId, String tarFilePath) throws IOException {
+        // 等价命令: docker export <containerId> -o backup.tar
+        // 导出的是容器当前文件系统的快照,可配合 docker import 恢复为镜像
+        try (InputStream tarStream = dockerClient.exportContainerCmd(containerId).exec();
+             FileOutputStream fos = new FileOutputStream(tarFilePath)) {
+            tarStream.transferTo(fos);
+            log.info("容器 {} 文件系统已导出到 {}", containerId, tarFilePath);
+        }
+    }
+
+    /**
+     * 将 tar 文件导入为镜像(docker export 的逆操作)
+     * 等价命令: docker import backup.tar my-app:v2
+     * @param tarFilePath 容器导出的 tar 文件
+     * @param repository 目标仓库名
+     * @param tag 目标标签
+     * @return 是否成功
+     */
+    public boolean importImageFromTar(String tarFilePath, String repository, String tag) {
+        // 等价命令: docker import backup.tar my-app:v2
+        // createImageCmd(name, stream) 用于从 tar 流导入镜像
+        try (InputStream tarStream = Files.newInputStream(Path.of(tarFilePath))) {
+            // createImageCmd 是同步命令,exec() 直接阻塞直到导入完成
+            CreateImageResponse response = dockerClient.createImageCmd(repository, tarStream)
+                    .withTag(tag)
+                    .exec();
+            log.info("镜像 {}:{} 导入成功,ID: {}", repository, tag, response.getId());
+            return true;
+        } catch (IOException e) {
+            log.error("镜像导入失败: {}", e.getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * 将容器的当前状态保存为新镜像(docker commit)
+     * 与 docker export 不同,commit 会保留镜像层历史、配置和元数据
+     * 等价命令: docker commit -m "init app" <containerId> my-app:v3
+     * @param containerId 容器 ID
+     * @param repository 目标仓库名
+     * @param tag 目标标签
+     * @return 新镜像 ID
+     */
+    public String commitContainerToImage(String containerId, String repository, String tag) {
+        // 等价命令: docker commit <containerId> my-app:v3
+        String imageId = dockerClient.commitCmd(containerId)
+                .withRepository(repository)
+                .withTag(tag)
+                .withMessage("committed by docker-java")
+                .exec();
+        log.info("容器 {} 已提交为镜像 {}:{},镜像 ID: {}", containerId, repository, tag, imageId);
+        return imageId;
+    }
+
+    // ==================== 清理(谨慎使用) ====================
+
+    /**
+     * 清理未使用的数据(docker 磁盘清理)
+     * 注意:这是"危险"操作,会删除所有未使用的资源!
+     * 等价命令:
+     *   docker container prune -f   (删除已停止的容器)
+     *   docker image prune -f       (删除悬空镜像)
+     *
+     * <p>教程仅作 API 演示,请勿在测试中执行以免误删环境数据</p>
+     * @return 清理结果摘要
+     */
+    public String pruneUnusedResources() {
+        // 等价命令: docker container prune -f:只清理已停止的容器
+        PruneResponse containers = dockerClient.pruneCmd(PruneType.CONTAINERS).exec();
+        // 等价命令: docker image prune -f:只清理悬空(dangling)镜像
+        PruneResponse images = dockerClient.pruneCmd(PruneType.IMAGES).exec();
+        log.info("容器清理: 回收 {} 字节, 镜像清理: 回收 {} 字节",
+                containers.getSpaceReclaimed(), images.getSpaceReclaimed());
+        return "containerReclaimed=" + containers.getSpaceReclaimed()
+                + ", imageReclaimed=" + images.getSpaceReclaimed();
+    }
+
+    // ==================== 测试辅助 ====================
+
+    /**
+     * 查看容器详情(测试辅助,等价 docker inspect)
+     * @param containerId 容器 ID
+     * @return 容器详情
+     */
+    public com.github.dockerjava.api.command.InspectContainerResponse inspectContainer(String containerId) {
+        return dockerClient.inspectContainerCmd(containerId).exec();
+    }
+
+    /**
+     * 停止容器(测试辅助,导出文件系统前先停止容器以避免快照不一致)
+     * @param containerId 容器 ID
+     */
+    public void stopContainer(String containerId) {
+        dockerClient.stopContainerCmd(containerId).withTimeout(5).exec();
+        log.info("容器 {} 已停止", containerId);
+    }
+
+    /**
+     * 创建可持续运行的临时容器(sleep 保持存活,供监控/复制等操作使用)
+     * @param containerName 容器名称
+     * @return 容器 ID
+     */
+    public String createSleepingContainer(String containerName) {
+        // 等价命令: docker run -d --name tmp-app amazoncorretto:17 sleep 300
+        CreateContainerResponse container = dockerClient.createContainerCmd("amazoncorretto:17")
+                .withName(containerName)
+                .withEntrypoint("sh", "-c")
+                .withCmd("sleep 300")
+                .exec();
+        dockerClient.startContainerCmd(container.getId()).exec();
+        return container.getId();
+    }
+
+    /**
+     * 创建并启动一个执行指定命令的临时容器(测试辅助)
+     * 等价命令: docker run -d --name tmp-app amazoncorretto:17 sh -c "<command>"
+     * @param containerName 容器名称
+     * @param command 由 sh -c 执行的命令
+     * @return 容器 ID
+     */
+    public String createContainer(String containerName, String... command) {
+        CreateContainerResponse container = dockerClient.createContainerCmd("amazoncorretto:17")
+                .withName(containerName)
+                .withEntrypoint("sh", "-c")
+                .withCmd(command)
+                .exec();
+        dockerClient.startContainerCmd(container.getId()).exec();
+        return container.getId();
+    }
+
+    /**
+     * 删除容器(测试清理用)
+     * @param containerId 容器 ID
+     */
+    public void removeContainer(String containerId) {
+        dockerClient.removeContainerCmd(containerId).withForce(true).exec();
+        log.info("容器 {} 已删除", containerId);
+    }
+
+    /**
+     * 删除镜像(测试清理用)
+     * @param image 镜像 ID 或名称
+     */
+    public void removeImage(String image) {
+        dockerClient.removeImageCmd(image).withForce(true).exec();
+        log.info("镜像 {} 已删除", image);
+    }
+
+    /**
+     * 在容器内执行命令并返回输出(测试辅助,等价 docker exec)
+     * @param containerId 容器 ID
+     * @param command 命令参数
+     * @return 输出文本
+     */
+    public String execInContainer(String containerId, String... command) {
+        StringBuilder output = new StringBuilder();
+        try {
+            var exec = dockerClient.execCreateCmd(containerId)
+                    .withCmd(command)
+                    .withAttachStdout(true)
+                    .withAttachStderr(true)
+                    .exec();
+            dockerClient.execStartCmd(exec.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 image 镜像名称(仓库:标签)
+     * @return 是否存在
+     */
+    public boolean checkImageExists(String image) {
+        return dockerClient.listImagesCmd().exec().stream()
+                .anyMatch(img -> img.getRepoTags() != null &&
+                        java.util.Arrays.asList(img.getRepoTags()).contains(image));
+    }
+}

+ 196 - 0
src/test/java/space/anyi/docker/MaintenanceOpsAPITest.java

@@ -0,0 +1,196 @@
+package space.anyi.docker;
+
+import com.github.dockerjava.api.model.ChangeLog;
+import com.github.dockerjava.api.model.Statistics;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 容器日常运维与维护 API 的测试类
+ * 注意:需要本机 Docker daemon 运行中
+ * 使用 amazoncorretto:17 / nginx:latest 镜像,每个测试自行清理
+ * (危险操作 pruneUnusedResources 不在测试中执行,避免误删环境数据)
+ */
+class MaintenanceOpsAPITest {
+
+    private String uniqueName(String prefix) {
+        return prefix + "-" + UUID.randomUUID().toString().substring(0, 8);
+    }
+
+    /**
+     * 生命周期补全:创建 -> 暂停 -> 验证暂停 -> 恢复 -> 验证运行 -> 重启 -> 验证运行
+     */
+    @Test
+    void restartPauseUnpauseLifecycle() {
+        MaintenanceOpsAPI api = new MaintenanceOpsAPI();
+        String id = api.createSleepingContainer(uniqueName("t-pause"));
+
+        // 暂停
+        api.pauseContainer(id);
+        var paused = api.inspectContainer(id);
+        assertTrue(paused.getState().getPaused(), "暂停后的容器应处于 Paused 状态");
+
+        // 恢复
+        api.unpauseContainer(id);
+        var resumed = api.inspectContainer(id);
+        assertFalse(resumed.getState().getPaused(), "恢复后的容器不应处于 Paused 状态");
+
+        // 重启
+        api.restartContainer(id);
+        var restarted = api.inspectContainer(id);
+        assertEquals("running", restarted.getState().getStatus(), "重启后的容器应处于运行状态");
+
+        api.removeContainer(id);
+    }
+
+    /**
+     * 重命名与动态调整资源限制:docker rename + docker update
+     */
+    @Test
+    void renameAndUpdateResources() {
+        MaintenanceOpsAPI api = new MaintenanceOpsAPI();
+        String id = api.createSleepingContainer(uniqueName("t-ren"));
+
+        // 重命名
+        String newName = uniqueName("t-renamed");
+        api.renameContainer(id, newName);
+        var inspected = api.inspectContainer(id);
+        assertTrue(inspected.getName().contains(newName), "容器名称应已更新");
+
+        // 动态更新内存限制
+        api.updateContainerResources(id, 128);
+        var updated = api.inspectContainer(id);
+        assertEquals(128L * 1024 * 1024, updated.getHostConfig().getMemory(), "内存限制应更新为 128MB");
+
+        api.removeContainer(id);
+    }
+
+    /**
+     * 进程查看与文件系统变更:docker top + docker diff
+     */
+    @Test
+    void topAndDiff() {
+        MaintenanceOpsAPI api = new MaintenanceOpsAPI();
+        String id = api.createSleepingContainer(uniqueName("t-top"));
+
+        // 在容器内创建变更文件
+        api.execInContainer(id, "sh", "-c", "echo change-me > /tmp/diff-test.txt");
+
+        // docker top:应能看到容器的 sleep 进程
+        String[][] processes = api.topContainer(id);
+        assertTrue(processes.length > 0, "容器内应有运行中的进程");
+
+        // docker diff:应能列出新创建的文件
+        List<ChangeLog> changes = api.containerDiff(id);
+        boolean found = changes.stream().anyMatch(c -> "/tmp/diff-test.txt".equals(c.getPath()));
+        assertTrue(found, "diff 应列出新增文件 /tmp/diff-test.txt");
+
+        api.removeContainer(id);
+    }
+
+    /**
+     * 监控快照:docker stats --no-stream
+     */
+    @Test
+    void statsSnapshot() {
+        MaintenanceOpsAPI api = new MaintenanceOpsAPI();
+        String id = api.createSleepingContainer(uniqueName("t-stats"));
+
+        Statistics stats = api.getContainerStats(id);
+        assertNotNull(stats, "应能获取到容器统计信息");
+        assertNotNull(stats.getMemoryStats(), "统计应包含内存信息");
+        assertTrue(stats.getMemoryStats().getUsage() > 0, "内存使用量应大于 0");
+
+        api.removeContainer(id);
+    }
+
+    /**
+     * 复制本地文件到容器:docker cp <local> <container>:<path>
+     */
+    @Test
+    void copyToContainer() throws IOException {
+        MaintenanceOpsAPI api = new MaintenanceOpsAPI();
+        String id = api.createSleepingContainer(uniqueName("t-cp"));
+
+        // 本地写一个临时文件
+        Path tempFile = Files.createTempFile("docker-cp-test", ".txt");
+        Files.writeString(tempFile, "hello from host");
+
+        // 复制到容器 /tmp/ 下
+        api.copyToContainer(id, tempFile.toString(), "/tmp");
+        String content = api.execInContainer(id, "sh", "-c", "cat /tmp/" + tempFile.getFileName());
+        assertTrue(content.contains("hello from host"), "容器内应能读到复制进去的文件内容");
+
+        Files.deleteIfExists(tempFile);
+        api.removeContainer(id);
+    }
+
+    /**
+     * docker commit:将运行中容器的当前状态保存为镜像
+     */
+    @Test
+    void commitContainerCreatesImage() {
+        MaintenanceOpsAPI api = new MaintenanceOpsAPI();
+        String containerId = api.createSleepingContainer(uniqueName("t-commit"));
+
+        // docker commit:容器当前状态保存为镜像
+        String commitRepo = uniqueName("commit-demo");
+        String commitTag = "v1";
+        String commitImageId = api.commitContainerToImage(containerId, commitRepo, commitTag);
+        String commitImage = commitRepo + ":" + commitTag;
+        assertNotNull(commitImageId, "commit 应返回新镜像 ID");
+        assertTrue(api.checkImageExists(commitImage), "commit 生成的镜像应存在");
+
+        // 清理:删除测试镜像和容器
+        api.removeImage(commitImage);
+        api.removeContainer(containerId);
+    }
+
+    /**
+     * docker export:将容器文件系统导出为 tar 文件
+     * (docker import 还原导出容器 tar 在 containerd snapshotter 下可能报
+     *  "unexpected EOF",属于环境限制,故自动化测试只验证导出产物非空)
+     */
+    @Test
+    void exportContainerProducesTar() throws IOException {
+        MaintenanceOpsAPI api = new MaintenanceOpsAPI();
+        String containerId = api.createSleepingContainer(uniqueName("t-export"));
+
+        // 先停止容器,避免导出运行中容器文件系统导致快照不一致
+        api.stopContainer(containerId);
+
+        // docker export:容器文件系统导出为 tar
+        Path exportTar = Files.createTempFile("container-export", ".tar");
+        api.exportContainer(containerId, exportTar.toString());
+        assertTrue(Files.size(exportTar) > 0, "导出文件不应为空");
+
+        Files.deleteIfExists(exportTar);
+        api.removeContainer(containerId);
+    }
+
+    /**
+     * 实时跟随日志:docker logs -f(收集新产生的日志)
+     */
+    @Test
+    void followLogsCollectsNewOutput() {
+        MaintenanceOpsAPI api = new MaintenanceOpsAPI();
+        String containerName = uniqueName("t-follow");
+        // createContainer 内部以 `sh -c <command>` 方式执行,这里传入脚本本身
+        String containerId = api.createContainer(containerName,
+                "for i in 1 2 3 4 5; do echo tick-$i; sleep 1; done");
+
+        // 跟随 4 秒,应能收集到前几秒产生的 tick 日志
+        String logs = api.followContainerLogs(containerId, 4);
+        assertTrue(logs.contains("tick-1") && logs.contains("tick-2") && logs.contains("tick-3"),
+                "跟随期间应收集到新产生的日志,实际: " + logs);
+
+        api.removeContainer(containerId);
+    }
+}