Testcontainers 本地 Registry 推送失败?配置 insecure-registries 解决
2025-04-28 06:10:56
搞定 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。
- 谁在推镜像? 很重要的一点:执行
docker push
命令的,或者docker-java
库里pushImageCmd
最终调用的,是 Docker Daemon (Docker 守护进程),而不是你的 Java 测试代码直接去连接 Registry 的端口。你的代码只是给 Docker Daemon 发送指令。 - Daemon 的规矩: 当 Docker Daemon 收到推送指令,看到目标 Registry 地址(比如
localhost:54321
)不是它已知的默认安全 Registry (像 Docker Hub),它会默认认为这是一个需要安全连接(HTTPS)的 Registry。 - HTTP vs HTTPS: Testcontainers 启动的
registry:2
容器,默认情况下在暴露的端口上提供的是 HTTP 服务。 - 矛盾爆发: 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 配置。
- Linux:
-
配置内容:
你需要在这个 JSON 文件里添加insecure-registries
数组,把你打算 用作 Registry 的地址加进去。关键点来了: Testcontainers 默认会动态映射端口。这意味着你每次运行测试,
registry.getMappedPort(5000)
返回的端口号都可能不一样。这给daemon.json
的静态配置带来了麻烦。你有几种选择:- (不推荐) 动态修改
daemon.json
并重启 Docker: 理论上可以在测试代码里获取到映射端口后,去修改daemon.json
,然后重启 Docker Daemon。但这太麻烦了,还会影响你正在运行的其他容器,非常不适合自动化测试。 - (常用方案) 使用固定端口 + 预配置
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:5001
和127.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。
- Linux:
- (不推荐) 动态修改
-
安全提示:
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,修改测试代码使用固定端口)后,再次运行你的 DockerRegistryTest
,dockerClient.pushImageCmd
应该就能成功将镜像推送到你的本地 Testcontainers Registry 了。
进阶技巧:关于 Registry 地址
- 在某些环境(尤其是 Docker Desktop on Mac/Windows 或特定 Linux 网络配置)下,容器可能无法直接通过
localhost
或127.0.0.1
访问宿主机或其他容器。Testcontainers 提供了一个特殊的主机名host.testcontainers.internal
,它会解析为宿主机上一个能被容器访问到的 IP 地址。如果你的测试场景是容器内部需要访问宿主机上的服务,这个特性很有用。但在我们这个问题里,是 Docker Daemon (运行在宿主机环境) 需要访问 Registry 容器,所以localhost
或127.0.0.1
通常是正确的选择,关键在于配置insecure-registries
。 - 如果你执意要用动态端口,并且不想(或不能)重启 Docker Daemon,可能需要探索更复杂的方案,比如使用一个支持在运行时动态添加 insecure registries 的本地代理,或者修改测试逻辑,避免需要从 Docker Daemon 推送(例如,直接在测试中通过 Registry API 操作,但这复杂得多且不常用)。对于大多数场景,固定端口配合
daemon.json
是最实用、最简单的解决方案。
现在,你应该能顺利地在 Testcontainers 启动的本地 Registry 里推送和拉取镜像,让你的集成测试跑得更顺畅了。记住关键:让 Docker Daemon 信任你的这个本地 HTTP 小兄弟!