Răsfoiți Sursa

feat: add Chapter 4 - image build APIs from Dockerfile

yangyi 3 zile în urmă
părinte
comite
937019b7bf

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

@@ -695,4 +695,126 @@ api.removeImage("my-nginx:v1.0", false);
 
 ***
 
-下一章,我们将学习如何使用 Docker Java SDK 从 Dockerfile 构建自定义镜像。
+## 镜像构建相关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 {
+        // 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<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,这是 Docker 使用中最频繁的操作。

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

@@ -0,0 +1,99 @@
+package space.anyi.docker;
+
+import com.github.dockerjava.api.DockerClient;
+import com.github.dockerjava.api.command.BuildImageResultCallback;
+import com.github.dockerjava.api.model.Image;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.util.List;
+
+/**
+ * Docker 镜像构建 API 示例
+ * 演示如何从 Dockerfile 构建自定义镜像
+ */
+public class ImageBuildAPI {
+    private static final Logger log = LoggerFactory.getLogger(ImageBuildAPI.class);
+
+    private final DockerClient dockerClient;
+
+    public ImageBuildAPI() {
+        this.dockerClient = DockerClientFactory.createDockerClient();
+    }
+
+    /**
+     * 从 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 指定构建后的镜像名称和标签(如 "sdk-demo/hello:latest")
+            // 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;
+        }
+    }
+
+    /**
+     * 构建镜像并强制移除中间层容器
+     * @param dockerfilePath Dockerfile 路径
+     * @param imageName 目标镜像名称
+     * @return 镜像 ID
+     */
+    public String buildImageWithCleanup(String dockerfilePath, String imageName) {
+        File dockerfile = new File(dockerfilePath);
+        if (!dockerfile.exists()) {
+            log.error("Dockerfile 不存在: {}", dockerfilePath);
+            return null;
+        }
+
+        try {
+            String imageId = dockerClient.buildImageCmd()
+                    .withDockerfile(dockerfile)
+                    .withBaseDirectory(dockerfile.getParentFile())
+                    .withTag(imageName)
+                    .withRemove(true)       // 构建完成后移除中间层容器
+                    .withForcerm(true)      // 强制移除
+                    .exec(new BuildImageResultCallback())
+                    .awaitImageId();
+            log.info("镜像 {} 构建成功(已清理中间层),镜像 ID: {}", imageName, imageId);
+            return imageId;
+        } catch (Exception e) {
+            log.error("镜像构建失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 列出由 build 命令创建的镜像(可通过标签过滤)
+     * @param imageName 镜像名称
+     * @return 匹配的镜像列表
+     */
+    public List<Image> findImageByName(String imageName) {
+        List<Image> images = dockerClient.listImagesCmd().exec();
+        return images.stream()
+                .filter(img -> img.getRepoTags() != null &&
+                        java.util.Arrays.asList(img.getRepoTags()).contains(imageName))
+                .toList();
+    }
+}

+ 52 - 0
src/test/java/space/anyi/docker/ImageBuildAPITest.java

@@ -0,0 +1,52 @@
+package space.anyi.docker;
+
+import com.github.dockerjava.api.model.Image;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 镜像构建 API 的测试类
+ * 注意:
+ * 1. 需要本机 Docker daemon 运行中
+ * 2. 构建使用本地已有镜像 alist666/alist:latest 作为基础镜像,避免网络依赖
+ * 3. 测试完成后会清理构建产物,避免污染本地环境
+ */
+class ImageBuildAPITest {
+
+    // 测试构建的目标镜像名称(使用随机后缀避免与已有镜像冲突)
+    private static final String BUILD_IMAGE = "sdk-demo/hello:latest";
+
+    /**
+     * 测试从 Dockerfile 构建镜像
+     */
+    @Test
+    void buildImage() {
+        ImageBuildAPI api = new ImageBuildAPI();
+        // Maven 运行测试时工作目录为项目根目录,所以在前面不加 "src/test/resources/"
+        String imageId = api.buildImage("src/test/resources/Dockerfile", BUILD_IMAGE);
+
+        assertNotNull(imageId, "使用本地基础镜像构建应该成功");
+    }
+
+    /**
+     * 测试构建后镜像确实存在于本地
+     */
+    @Test
+    void findImageByName() {
+        ImageBuildAPI api = new ImageBuildAPI();
+        String imageId = api.buildImage("src/test/resources/Dockerfile", BUILD_IMAGE);
+        assertNotNull(imageId, "构建应该成功");
+
+        List<Image> images = api.findImageByName(BUILD_IMAGE);
+        assertFalse(images.isEmpty(), "构建后的镜像应存在于镜像列表中");
+        assertEquals(1, images.size(), "应只有一个匹配的镜像");
+
+        // 清理:删除构建的测试镜像
+        new ImageManageAPI().removeImage(BUILD_IMAGE, true);
+        assertTrue(api.findImageByName(BUILD_IMAGE).isEmpty(),
+                "镜像清理后应消失");
+    }
+}

+ 5 - 0
src/test/resources/Dockerfile

@@ -0,0 +1,5 @@
+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"]