# Docker pull 卡住怎么办：排查 macOS credential helper 与 DOCKER_CONFIG

> Canonical: https://vxi.me/posts/docker-pull-credential-helper-docker-config/
> Locale: zh-cn
> Published: 2026-10-07
> Updated: 2026-10-07
> Markdown: https://vxi.me/posts/docker-pull-credential-helper-docker-config.md

一个无人值守 Runner 最让人难排查的故障，不一定是直接报错。更麻烦的是它停在 `docker pull`、`docker build` 这类命令上，日志没有继续，也看不出 Registry 到底返回了什么。

这种时候第一反应通常是查网络、DNS、代理、Docker daemon。我前面也会这样想。但这次顺着一个真实故障往下追，我发现还有一条更隐蔽的路径：Docker CLI 可能在真正拉镜像之前，先去找 Registry 凭据；如果它继承的是你桌面会话里的 credential helper，那么后台任务实际上已经和人的交互式 Keychain 绑在一起了。

触发我继续查的是 Fishhawk 的 [issue #4046](https://github.com/kuhlman-labs/fishhawk/issues/4046)。这个项目记录了一次 macOS 上的无人值守 Runner 故障：屏幕锁定后，Docker 的 credential helper 链路卡在系统 Keychain，解锁后才继续。这个现象本身我没有在真实 Keychain 上独立复现，所以后面不会把它写成“所有 macOS 都会这样”。

真正值得验证的是它背后的机制。

我一开始以为解决办法很简单：给 Runner 单独建一个 `DOCKER_CONFIG`，里面只放一个空的：

```json
{"auths":{}}
```

这样应该就不会再碰人的 Keychain 了。

结果 Docker CLI 当前源码和我后面的 macOS 实验都把这个判断推翻了。**空的 `auths` 并不等于“明确匿名”；在当前 Docker CLI 里，它仍可能触发平台默认 credential helper 的自动探测。**

这也是这次排查里最容易踩的坑。

## Docker CLI 为什么会在拉公共镜像时碰 credential helper

Docker 官方文档现在仍然把客户端配置放在 `~/.docker/config.json`，也明确说明可以用 `DOCKER_CONFIG` 或 `--config` 切换配置目录；同时，`credsStore` 是默认 credential store，`credHelpers` 可以针对单个 Registry 覆盖默认 store。对应说明可以看 [Docker CLI configuration](https://docs.docker.com/reference/cli/docker/) 和 [docker login / credential stores](https://docs.docker.com/reference/cli/docker/login/)。

真正让我把问题想明白的是 Docker CLI 自己的加载逻辑。

在我固定检查的 Docker CLI commit `3d2650ce76053393872e8389c2b77033146cb356` 里，[`LoadDefaultConfigFile()`](https://github.com/docker/cli/blob/3d2650ce76053393872e8389c2b77033146cb356/cli/config/config.go) 有这一段：

```go
if !configFile.ContainsAuth() {
    configFile.CredentialsStore =
        credentials.DetectDefaultStore(configFile.CredentialsStore)
}
```

而 [`ContainsAuth()`](https://github.com/docker/cli/blob/3d2650ce76053393872e8389c2b77033146cb356/cli/config/configfile/file.go) 判断的是三件事：

```go
return c.CredentialsStore != "" ||
    len(c.CredentialHelpers) > 0 ||
    len(c.AuthConfigs) > 0
```

这就解释了为什么 `{"auths":{}}` 会出问题。`AuthConfigs` 的长度还是 0，`ContainsAuth()` 仍然是 false，于是 CLI 会进入 `DetectDefaultStore()`。

在 Darwin 上，当前源码里的 [平台默认值就是 `osxkeychain`](https://github.com/docker/cli/blob/3d2650ce76053393872e8389c2b77033146cb356/cli/config/credentials/default_store_darwin.go)。只要 `docker-credential-osxkeychain` 在 `PATH` 里，CLI 就可能把它选成默认 store。

也就是说，你明明给后台任务准备了一个“空配置”，Docker CLI 看到的却可能是：

```text
没有 credsStore
没有 credHelpers
没有任何 auth entry
        ↓
ContainsAuth() == false
        ↓
探测当前平台默认 helper
        ↓
macOS: docker-credential-osxkeychain
```

这不是我根据 Fishhawk Issue 猜出来的。源码能解释这条路径，但源码 inspection 还不够，我更想知道它在真实 Darwin 进程里会不会按这个方向走。

## 我把四种 config 放到同一台 macOS Runner 上跑了一遍

为了避免把真实账号、Keychain 和第三方项目代码搅进实验，我没有运行 Fishhawk，也没有执行 `docker login`，更没有修改 GitHub Runner 的系统 Keychain。

实验做得很小：GitHub 托管 macOS ARM64 Runner，Docker CLI 源码固定在上面的 commit，然后在 `PATH` 前面放两个我自己写的 fake helper：

```text
docker-credential-osxkeychain
docker-credential-probe
```

它们不访问任何真实凭据，只把“谁调用了我、执行了什么 action、查哪个 Registry”写进 marker 文件。这样可以直接观察 Docker CLI 到底选了谁。

实际执行命令是：

```bash
GOFLAGS=-mod=vendor go test \
  -run '^TestAITokenCredentialExperiment$' \
  -count=1 -v ./cli/config
```

这次 GitHub Actions run 是 `37620686435`，环境为 macOS ARM64、`macos26`、Go `1.26.8`，退出码是 **0**。原始终端输出只有很短几行：

```text
=== RUN   TestAITokenCredentialExperiment
AITOKEN_RESULT .../data/experiments/docker-credentials/raw-go-test.json
--- PASS: TestAITokenCredentialExperiment (0.95s)
PASS
ok   github.com/docker/cli/cli/config   0.956s
```

真正有信息量的是四组 marker 结果：

| 配置 | `ContainsAuth()` | 默认加载后的 store | 实际 helper 调用 |
| --- | --- | --- | --- |
| `{"auths":{}}` | false | `osxkeychain` | `docker-credential-osxkeychain get https://index.docker.io/v1/` |
| `{"auths":{"fishhawk.invalid":{}}}` | true | file store | **无 helper 调用** |
| `{"credsStore":"probe","auths":{}}` | true | `probe` | `docker-credential-probe get https://index.docker.io/v1/` |
| `{"credHelpers":{"example.invalid":"probe"}}` | true | file store | 只对 `example.invalid` 调用 `probe` |

第一组就是我最想确认的地方：裸的 `{"auths":{}}` 确实没有形成一个“明确匿名”的边界。fake `docker-credential-osxkeychain` 只要出现在 `PATH`，这版 Docker CLI 就会自动发现它，并且在查询 Docker Hub auth 时真正执行它。

第二组则解释了 Fishhawk 最终为什么没有停在空 `auths`，而是用了一个没有任何 secret 的占位项：

```json
{
  "auths": {
    "fishhawk.invalid": {}
  }
}
```

我检查的 Fishhawk 合并版本里，[`NewAnonymousDockerConfig()`](https://github.com/kuhlman-labs/fishhawk/blob/de153d47a86a839822c656c0308b58fe97729c06/runner/internal/gateiso/dockerconfig.go) 正是在做这件事。`fishhawk.invalid` 没有用户名、密码、token，它的作用只是让 auth map 不再为空，令 `ContainsAuth()` 成为 true，跳过平台默认 helper 的自动发现。

这个设计第一次看到确实有点怪：为了“不要认证”，反而要放一个空的 auth entry。可把源码和运行结果放在一起以后，它的目的就很清楚了。

这里必须加一个版本边界。这个行为是我对 **固定 Docker CLI commit** 做的第一方验证，不应该写成永远不变的 Docker 协议。Docker CLI 升级以后，如果你依赖这种隔离方式，最好把这个行为做成自己的回归测试，而不是把今天的源码细节永久当契约。

## Runner 的 Docker 配置，最好和人的桌面配置彻底分开

跑完实验以后，我不建议为了修后台任务，直接去删 `~/.docker/config.json` 里的 `credsStore`。

Docker 官方文档之所以推荐 credential store，本来就是因为把真实 Registry 凭据交给系统 Keychain 或专门 helper，通常比把它们直接写进 `config.json` 更安全。问题出在“无人值守自动化偷偷复用了人的交互式配置”，不是 credential helper 这个机制本身有错。

对只拉公共镜像的 Runner，我更愿意给它一个自己拥有的 `DOCKER_CONFIG`：

```bash
runner_docker_config="$(mktemp -d)"

cat > "$runner_docker_config/config.json" <<'JSON'
{
  "auths": {
    "runner.invalid": {}
  }
}
JSON

DOCKER_CONFIG="$runner_docker_config" docker pull alpine:latest
```

这里的占位 key 不包含真实凭据。重点也不是必须叫 `runner.invalid`，而是让这个 Runner 的 Docker CLI 不再回头读取人的 `~/.docker/config.json`，同时避免当前版本的默认 helper 自动探测。

如果自动化确实要拉私有镜像，就不要继续假装“匿名配置能解决一切”。这时应该明确给 Runner 一条自己的认证路径。

相比一个覆盖所有 Registry 的默认 `credsStore`，我会优先考虑 Registry 级 `credHelpers`，因为它把需要凭据的范围写得更清楚：

```json
{
  "credHelpers": {
    "registry.example.com": "your-noninteractive-helper"
  }
}
```

这次实验里，我同时查询了 `example.invalid` 和 `other.invalid`，fake helper 只收到前者的一次 `get`。这和 Docker 官方关于 `credHelpers`“针对指定 Registry 优先使用”的说明一致。

至于实际生产里 `your-noninteractive-helper` 应该是什么，要看你的 Registry、云厂商和凭据生命周期。我不会在这里把某一种 helper 写成通用答案。后台任务更值得追求的是短期凭据、机器身份、明确的 Registry 范围，以及 helper 在无人值守会话里能不能稳定返回。

还有一个细节很容易在真正落地时踩到：`DOCKER_CONFIG` 不只影响 auth，它还是 Docker CLI 的整个配置目录。如果你平时依赖 Docker Desktop 放在用户目录里的 `buildx`、Compose 或其他 CLI plugin，直接切到一个全新的空目录以后，插件发现也可能跟着变化。

Fishhawk 的合并实现专门把原来配置里的 plugin directories 带进新的 Runner config。我觉得这个处理很值得抄思路，但不要机械抄路径。你的 Runner 如果只做 `docker pull`，问题很小；如果还跑 `docker buildx`、Compose 或自定义 plugin，隔离 credential config 时要一起检查插件发现。

这和我之前写 [Codex 通过 CLIProxyAPI 做 Docker 反代](/posts/cliproxyapi-codex-docker-proxy/) 时遇到的思路其实类似：把机器上“人的状态”和“后台服务的状态”混在一起，前期很省配置，后面一旦要长期无人值守，边界就会越来越难解释。

## helper 真卡住时，不要等整个 docker pull 超时

我还做了一个更直接的实验：把 fake `docker-credential-probe` 的 `get` 故意写成永远不返回，然后让 Docker CLI 的 credential lookup 走进它。

外层测试 harness 只给 **900 ms**。

最后 marker 先记录到了：

```text
docker-credential-probe|get|https://index.docker.io/v1/
```

随后子进程一直等，到 **933 ms** 时被外层 deadline 终止。

这里能证明的事情很窄，但很实用：Docker 的 credential lookup 一旦进入外部 helper，而 helper 不回答，调用链可以一直等在那里。当前 [`docker-credential-helpers` 的 client `Get()`](https://github.com/docker/docker-credential-helpers/blob/df2fce1a0dad8cec1a85ead31f268ab416b7a6cb/client/client.go) 本身就是执行外部命令再等待输出，这一层没有替你的 Runner 决定一个业务级 timeout。

所以无人值守环境里，超时最好放在你能解释的位置。

不是给整个 Job 一个 30 分钟大超时，然后出事时只看到“docker pull failed”。更有用的是在真正依赖私有 Registry helper 之前做一个有界 probe，或者至少给对应的 Docker 步骤一个明确 timeout，并把失败原因记录成“credential helper 没回答”。

Fishhawk 合并后的实现也走了这个方向：如果使用操作员显式提供的 Docker config，它会先找出这次镜像访问实际会命中的 helper，再在一个有界时间里探测。它目前用 20 秒，但这个 **20 秒是 Fishhawk 自己的工程选择，不是 Docker 官方默认，也不是我建议所有项目照搬的数字**。

你的 helper 如果只是本地机器身份，几秒可能就够；如果背后还要访问网络型 vault，阈值又会完全不同。重要的是“credential lookup 有自己的可观测边界”，而不是具体写 5 秒还是 20 秒。

## 关于“macOS 锁屏以后卡死”，证据目前只能说到这里

这篇文章最容易写过头的地方，其实就在标题之外的那一步推断。

Fishhawk #4046 记录的是一台真实 macOS Docker Desktop 主机：交互式配置里用了 `credsStore: desktop`，helper 链继续进入 `docker-credential-osxkeychain`，屏幕锁定期间卡在 Keychain 调用，解锁后继续。原项目后来把这个问题修进了 [PR #4059](https://github.com/kuhlman-labs/fishhawk/pull/4059)。

但我的第一方实验没有锁定真实 macOS 会话，也没有让真正的 `docker-credential-osxkeychain` 去访问系统 Keychain。我只是放了一个同名 fake helper 来确认“当前 Docker CLI 会不会选它”，再用一个故意阻塞的 fake helper 确认“外部 helper 不返回时，credential lookup 会不会跟着等”。

所以现在可以比较确定地说：

- `DOCKER_CONFIG`、`credsStore` 和 `credHelpers` 会直接决定无人值守 Docker CLI 的 credential 路径；
- 当前固定版本下，`{"auths":{}}` 在 macOS 上仍可能自动选中 `osxkeychain`；
- 一个无秘密的占位 auth entry 可以在这版 CLI 上阻止这次默认 helper 探测；
- 外部 credential helper 可以把 credential lookup 挂住，除非调用方自己设置边界；
- Fishhawk 的真实锁屏 Keychain 故障与这条机制吻合，但那次 `SecItemCopyMatching` 行为仍然属于 Fishhawk 的第三方现场证据。

如果你的 Runner 现在就有“人用时正常，锁屏、退出桌面或夜间任务就莫名卡住”的 Docker 问题，我会按这个顺序查：

1. 先打印 Runner 实际拿到的 `DOCKER_CONFIG`、`HOME` 和配置文件路径，别默认它一定在用你以为的那份配置。
2. 看 `config.json` 里有没有 `credsStore` 或对应 Registry 的 `credHelpers`。
3. 检查 `PATH` 上实际存在什么 `docker-credential-*` helper。
4. 用独立的 Runner config 做对照，不要先修改人的全局 `~/.docker/config.json`。
5. 如果必须走 helper，把 helper lookup 和整个镜像任务拆开观察，给前者一个可解释的 timeout。
6. 切换 `DOCKER_CONFIG` 后，再检查 `buildx` / Compose / CLI plugin 有没有被一起切丢。

这类问题很容易被误判成 Docker 网络故障，是因为日志最后停在的地方往往只是 `docker pull`。真正卡住的位置可能更早，在“这个 Registry 有没有凭据、该去哪里拿”这一层。

我现在反而会把 Docker credential config 当成 Runner 的运行时状态，而不是一份可以顺便继承的用户偏好。人的桌面 Docker 可以继续用 Keychain；无人值守 Runner 则拿自己可解释、可测试、可超时的配置。两套东西分开以后，排障路径会短很多。

如果你准备把这类 Runner 长期挂在服务器或开发机上，那篇 [Docker 数据卷自动备份到 GitHub](/posts/docker-volume-backup-github-sync/) 里讲的另一个原则也一样适用：真正需要长期运行的东西，别只管“今天能跑”，还要提前想清楚状态在哪里、坏了怎么定位、换机器以后怎么恢复。


## Runbook：Runner 卡在 docker pull 时先做这 6 步

现场救火时，我不会先改全局 Docker 配置，顺序会尽量固定：

1. 确认 Runner 实际拿到的 `DOCKER_CONFIG` 和 `HOME`。
2. 检查当前 config 里有没有 `credsStore` / `credHelpers` / auth entry。
3. 确认 `PATH` 上到底有哪些 `docker-credential-*` helper。
4. 用一个独立、无秘密的 Runner config 做对照，不要先破坏人的桌面配置。
5. 如果必须走 helper，把 credential lookup 单独做成有界 probe，再执行真正的 pull/build。
6. 切换 `DOCKER_CONFIG` 后，顺手验证 buildx / Compose / CLI plugin 没有一起丢失。

```console
$ printf 'DOCKER_CONFIG=%s\nHOME=%s\n' "$DOCKER_CONFIG" "$HOME"
$ command -v docker-credential-osxkeychain || true
```

<details>
<summary>查看本次 macOS Runner 的原始测试输出</summary>

```console
$ GOFLAGS=-mod=vendor go test -run '^TestAITokenCredentialExperiment$' -count=1 -v ./cli/config
=== RUN   TestAITokenCredentialExperiment
AITOKEN_RESULT .../data/experiments/docker-credentials/raw-go-test.json
--- PASS: TestAITokenCredentialExperiment (0.95s)
PASS
ok   github.com/docker/cli/cli/config   0.956s
```

这段输出来自本文对应的一方实验。它证明固定 Docker CLI 版本下的 helper 选择与阻塞链路，不等于独立复现了真实 macOS 锁屏时的 Keychain 故障。

</details>

## Verification context

- Level: first-party
- Verified: 2026-10-07T00:00:00.000Z
- Environment: macOS ARM64 / macos26
- Environment: Go 1.26.8
- Environment: docker/cli 3d2650c
- Evidence mode: mixed
- Public repository: https://github.com/docker/cli
- Evidence commit: 3d2650ce76053393872e8389c2b77033146cb356
- Evidence receipt: https://vxi.me/evidence/docker-pull-credential-helper-docker-config.json
