返回

Testcontainers 本地 Registry 推送失败?配置 insecure-registries 解决

java

搞定 Testcontainers:为什么推镜像到本地 Registry 会失败?

搞集成测试的时候,用 Testcontainers 启动一个临时的 Docker Registry 是个挺方便的操作。但有时候,你高高兴兴把 Registry 容器跑起来了,准备用代码把一个镜像推上去,结果 docker push 就给你撂挑子了,报个 “connection refused” 或者更奇怪的错误。这到底是咋回事?

问题来了:眼看 Registry 跑着,就是推不上去

想象一下这个场景:你用 Testcontainers,像下面这样,启动了一个 Docker Registry (registry:2) 容器。

// 测试类片段
class DockerRegistryTest {

    @Rule // 或者 @Container for JUnit 5 with testcontainers-junit5
    public static GenericContainer registry;
    private static String registryAddress;
    private static DockerClient dockerClient;

    @BeforeAll
    static void startRegistry() {
        // 使用官方 registry:2 镜像
        registry = new GenericContainer(DockerImageName.parse("registry:2"))
                .withExposedPorts(5000) // 暴露容器的 5000 端口
                .waitingFor(Wait.forHttp("/v2/").forStatusCode(200)); // 等待 Registry API 可用

        registry.start();
        // 确认容器真的跑起来了
        assertTrue(registry.isRunning(), "Registry 没跑起来?不应该啊。");

        // 获取 Registry 在宿主机的访问地址和映射端口
        registryAddress = registry.getHost() + ":" + registry.getMappedPort(5000);
        System.out.println("Registry 访问地址: " + registryAddress);

        // 初始化 Docker 客户端 (docker-java)
        DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder().build();
        dockerClient = DockerClientImpl.getInstance(config, new ApacheDockerHttpClient.Builder()
                .dockerHost(config.getDockerHost())
                .sslConfig(config.getSSLConfig())
                .build());
    }

    @AfterAll
    static void tearDown() {
        if (registry != null) {
            registry.stop(); // 测试结束,清理容器
        }
        if (dockerClient != null) {
          try {
              dockerClient.close(); // 关闭 docker client
          } catch (IOException e) {
             // 处理关闭异常,或者至少打印出来
             e.printStackTrace();
          }
        }
    }

    @Test
    void testPushImageToRegistry() throws InterruptedException, IOException {
        String localImage = "busybox:latest"; // 本地要推送的镜像
        // 先确保本地有这个镜像,没有就拉取
        try {
            dockerClient.inspectImageCmd(localImage).exec();
        } catch (NotFoundException e) {
            System.out.println("本地没有 " + localImage + ", 拉取中...");
            dockerClient.pullImageCmd(localImage).start().awaitCompletion();
        }


        // 给镜像打上指向本地 Registry 的 tag
        // 格式:<registry_address>/<image_name>:<tag>
        String registryImageTag = registryAddress + "/my-busybox:latest";
        System.out.println("给镜像打标签: " + registryImageTag);
        dockerClient.tagImageCmd(localImage, registryAddress + "/my-busybox", "latest").exec();

        System.out.println("准备推送镜像: " + registryImageTag);
        // 推送镜像!问题往往就出在这里
        dockerClient.pushImageCmd(registryImageTag)
                .withAuthConfig(new AuthConfig()) // 本地 Registry 没配认证,传个空的 AuthConfig
                .start()
                .awaitCompletion(); // 等待推送完成

        System.out.println("成功推送镜像到本地 Registry: " + registryImageTag);
        // 如果代码能执行到这里,说明推送成功了
        assertTrue(true);
    }
}

启动测试,startRegistry 部分顺利通过,日志也打印出了 Registry 的地址,比如 Registry 访问地址: localhost:54321 (端口是动态映射的)。你甚至手动 curl http://localhost:54321/v2/_catalog 也能得到 {"repositories":[]} 的响应,证明 Registry 本身工作正常。

但是,运行到 dockerClient.pushImageCmd(...) 时,坏事了!你可能会先遇到类似这样的错误:

com.github.dockerjava.api.exception.DockerClientException: Could not push image: failed to do request:
Head "https://localhost:54321/v2/my-busybox/blobs/sha256:xxxx":
dial tcp [::1]:54321: connect: connection refused

奇怪,明明 curl 用 HTTP 访问 localhost:54321 好好的,怎么推送的时候就 connection refused 了?而且注意看,错误信息里居然是 https://

如果你像提问者那样,尝试把地址改成 0.0.0.0:<mapped_port>,错误就更明确了:

com.github.dockerjava.api.exception.DockerClientException: Could not push image: failed to do request:
Head "https://0.0.0.0:60075/v2/my-busybox/blobs/sha256:xxxx":
http: server gave HTTP response to HTTPS client

这个 http: server gave HTTP response to HTTPS client 直接把问题点出来了:客户端(Docker Daemon)想用 HTTPS,但服务器(你的本地 Registry)只提供了 HTTP 服务。

刨根问底:为啥 Docker Daemon 非要用 HTTPS?

这其实是 Docker 的安全机制在起作用。默认情况下,Docker Daemon 在和 Docker Registry 通信时,会优先尝试使用 HTTPS。

  1. 谁在推镜像? 很重要的一点:执行 docker push 命令的,或者 docker-java 库里 pushImageCmd 最终调用的,是 Docker Daemon (Docker 守护进程),而不是你的 Java 测试代码直接去连接 Registry 的端口。你的代码只是给 Docker Daemon 发送指令。
  2. Daemon 的规矩: 当 Docker Daemon 收到推送指令,看到目标 Registry 地址(比如 localhost:54321)不是它已知的默认安全 Registry (像 Docker Hub),它会默认认为这是一个需要安全连接(HTTPS)的 Registry。
  3. HTTP vs HTTPS: Testcontainers 启动的 registry:2 容器,默认情况下在暴露的端口上提供的是 HTTP 服务。
  4. 矛盾爆发: Docker Daemon 尝试用 HTTPS 去连接 localhost:54321,但 Registry 只响应 HTTP。这就导致了协议不匹配,连接失败,最终表现为 connection refused (因为 HTTPS 握手失败) 或者更明确的 http: server gave HTTP response to HTTPS client 错误。

所以,curl http://localhost:<port> 能通,是因为你明确指定了用 HTTP。而 docker push 默认行为导致它尝试了 HTTPS,于是就挂了。

解决方案:让 Docker Daemon 信任你的本地 HTTP Registry

既然问题出在 Docker Daemon 不信任这个 HTTP 的本地 Registry,解决办法就是告诉它:“嘿,老兄,localhost:<port> 这个地址是自己人,用 HTTP 就行,别搞那么严肃!”

具体操作就是配置 Docker Daemon 的 insecure-registries 选项。

核心操作:修改 Docker 配置文件 daemon.json

你需要找到 Docker Daemon 的配置文件 daemon.json,并添加你的本地 Registry 地址。

  • 文件位置:

    • Linux: /etc/docker/daemon.json
    • Windows: C:\ProgramData\docker\config\daemon.json (注意 ProgramData 是隐藏文件夹)
    • macOS: 点击 Docker Desktop 菜单栏图标 -> Preferences (或 Settings) -> Docker Engine。在这里直接编辑 JSON 配置。
  • 配置内容:
    你需要在这个 JSON 文件里添加 insecure-registries 数组,把你打算 用作 Registry 的地址加进去。

    关键点来了: Testcontainers 默认会动态映射端口。这意味着你每次运行测试,registry.getMappedPort(5000) 返回的端口号都可能不一样。这给 daemon.json 的静态配置带来了麻烦。你有几种选择:

    1. (不推荐) 动态修改 daemon.json 并重启 Docker: 理论上可以在测试代码里获取到映射端口后,去修改 daemon.json,然后重启 Docker Daemon。但这太麻烦了,还会影响你正在运行的其他容器,非常不适合自动化测试。
    2. (常用方案) 使用固定端口 + 预配置 daemon.json 在 Testcontainers 里指定一个固定的宿主机端口,然后在 daemon.json 里提前把这个地址(localhost:<fixed_port>127.0.0.1:<fixed_port>)加到 insecure-registries

    我们选择方案 2 来演示。假设我们决定把 Registry 的 5000 端口固定映射到宿主机的 5001 端口。

    • 修改 daemon.json:
      打开或创建 daemon.json 文件,添加如下内容:

      {
        "insecure-registries": [
          "localhost:5001",
          "127.0.0.1:5001"
        ]
        // 你可能还有其他配置,比如 builder, experimental 等,保留它们
        // "builder": { ... },
        // "experimental": true
      }
      
      • 注意: 添加 localhost:5001127.0.0.1:5001 两个是为了更保险,因为不同的 Docker 环境或客户端库有时解析 localhost 的行为可能略有差异。
      • 如果 daemon.json 已存在并且有内容,确保这是个有效的 JSON,把 insecure-registries 作为一个顶级 key 加进去,或者合并到已有的 JSON 结构里。
    • 重启 Docker Daemon:

      • Linux: sudo systemctl restart docker
      • Windows/macOS: 退出 Docker Desktop,然后重新启动它。或者在 Docker Desktop 的 Settings/Preferences -> Troubleshoot -> Restart Docker Desktop。
  • 安全提示:

    • insecure-registries 意味着 Docker Daemon 和这个 Registry 之间的通信是 不加密 的 (HTTP),并且不会验证 Registry 的 TLS 证书(即使它提供了)。
    • 只对你完全信任的本地或内部网络 Registry 使用此选项。 绝对不要把公共的、需要安全连接的 Registry 地址加到这里面。
    • 修改完 daemon.json 必须重启 Docker Daemon 才能生效。

代码适配:使用固定端口

现在,修改你的 Testcontainers 初始化代码,使用固定的端口映射:

    @BeforeAll
    static void startRegistry() {
        int fixedHostPort = 5001; // 定义你想固定的宿主机端口

        registry = new GenericContainer(DockerImageName.parse("registry:2"))
                // 使用 addFixedExposedPort 替代 withExposedPorts
                .withFixedExposedPort(fixedHostPort, 5000) // 将容器的 5000 端口映射到宿主机的 fixedHostPort (5001)
                .waitingFor(Wait.forHttp("/v2/").forStatusCode(200));

        registry.start();
        assertTrue(registry.isRunning(), "Registry 没跑起来?");

        // 现在 registryAddress 会是固定的 localhost:5001 或 127.0.0.1:5001 (取决于 getHost() 的返回值)
        // 但 getHost() 通常返回 localhost,所以直接用固定的地址更稳妥
        // registryAddress = registry.getHost() + ":" + fixedHostPort; // 或者 registry.getMappedPort(5000) 也能拿到 5001
        registryAddress = "localhost:" + fixedHostPort; // 直接用我们配置好的地址
        System.out.println("Registry 访问地址: " + registryAddress);

        // Docker Client 初始化代码不变
        DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder().build();
        dockerClient = DockerClientImpl.getInstance(config, new ApacheDockerHttpClient.Builder()
                .dockerHost(config.getDockerHost())
                .sslConfig(config.getSSLConfig())
                .build());
    }

修改解释:

  • 我们用 withFixedExposedPort(5001, 5000) 替代了之前的 withExposedPorts(5000)。这告诉 Testcontainers 把容器内的 5000 端口明确映射到宿主机的 5001 端口。
  • 在构造 registryAddress 时,我们现在可以直接使用 localhost:5001,因为端口是固定的。
  • daemon.json 中,你已经将 "localhost:5001""127.0.0.1:5001" 加入了 insecure-registries 列表。

完成以上两步(修改 daemon.json 并重启 Docker,修改测试代码使用固定端口)后,再次运行你的 DockerRegistryTestdockerClient.pushImageCmd 应该就能成功将镜像推送到你的本地 Testcontainers Registry 了。

进阶技巧:关于 Registry 地址

  • 在某些环境(尤其是 Docker Desktop on Mac/Windows 或特定 Linux 网络配置)下,容器可能无法直接通过 localhost127.0.0.1 访问宿主机或其他容器。Testcontainers 提供了一个特殊的主机名 host.testcontainers.internal,它会解析为宿主机上一个能被容器访问到的 IP 地址。如果你的测试场景是容器内部需要访问宿主机上的服务,这个特性很有用。但在我们这个问题里,是 Docker Daemon (运行在宿主机环境) 需要访问 Registry 容器,所以 localhost127.0.0.1 通常是正确的选择,关键在于配置 insecure-registries
  • 如果你执意要用动态端口,并且不想(或不能)重启 Docker Daemon,可能需要探索更复杂的方案,比如使用一个支持在运行时动态添加 insecure registries 的本地代理,或者修改测试逻辑,避免需要从 Docker Daemon 推送(例如,直接在测试中通过 Registry API 操作,但这复杂得多且不常用)。对于大多数场景,固定端口配合 daemon.json 是最实用、最简单的解决方案。

现在,你应该能顺利地在 Testcontainers 启动的本地 Registry 里推送和拉取镜像,让你的集成测试跑得更顺畅了。记住关键:让 Docker Daemon 信任你的这个本地 HTTP 小兄弟!