Docker pull 卡住怎么办:排查 macOS credential helper 与 DOCKER_CONFIG
一个无人值守 Runner 最让人难排查的故障,不一定是直接报错。更麻烦的是它停在 docker pull、docker build 这类命令上,日志没有继续,也看不出 Registry 到底返回了什么。
这种时候第一反应通常是查网络、DNS、代理、Docker daemon。我前面也会这样想。但这次顺着一个真实故障往下追,我发现还有一条更隐蔽的路径:Docker CLI 可能在真正拉镜像之前,先去找 Registry 凭据;如果它继承的是你桌面会话里的 credential helper,那么后台任务实际上已经和人的交互式 Keychain 绑在一起了。
触发我继续查的是 Fishhawk 的 issue #4046。这个项目记录了一次 macOS 上的无人值守 Runner 故障:屏幕锁定后,Docker 的 credential helper 链路卡在系统 Keychain,解锁后才继续。这个现象本身我没有在真实 Keychain 上独立复现,所以后面不会把它写成“所有 macOS 都会这样”。
真正值得验证的是它背后的机制。
我一开始以为解决办法很简单:给 Runner 单独建一个 DOCKER_CONFIG,里面只放一个空的:
{"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 和 docker login / credential stores。
真正让我把问题想明白的是 Docker CLI 自己的加载逻辑。
在我固定检查的 Docker CLI commit 3d2650ce76053393872e8389c2b77033146cb356 里,LoadDefaultConfigFile() 有这一段:
if !configFile.ContainsAuth() {
configFile.CredentialsStore =
credentials.DetectDefaultStore(configFile.CredentialsStore)
}
而 ContainsAuth() 判断的是三件事:
return c.CredentialsStore != "" ||
len(c.CredentialHelpers) > 0 ||
len(c.AuthConfigs) > 0
这就解释了为什么 {"auths":{}} 会出问题。AuthConfigs 的长度还是 0,ContainsAuth() 仍然是 false,于是 CLI 会进入 DetectDefaultStore()。
在 Darwin 上,当前源码里的 平台默认值就是 osxkeychain。只要 docker-credential-osxkeychain 在 PATH 里,CLI 就可能把它选成默认 store。
也就是说,你明明给后台任务准备了一个“空配置”,Docker CLI 看到的却可能是:
没有 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:
docker-credential-osxkeychain
docker-credential-probe
它们不访问任何真实凭据,只把“谁调用了我、执行了什么 action、查哪个 Registry”写进 marker 文件。这样可以直接观察 Docker CLI 到底选了谁。
实际执行命令是:
GOFLAGS=-mod=vendor go test \
-run '^TestAITokenCredentialExperiment$' \
-count=1 -v ./cli/config
这次 GitHub Actions run 是 37620686435,环境为 macOS ARM64、macos26、Go 1.26.8,退出码是 0。原始终端输出只有很短几行:
=== 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 的占位项:
{
"auths": {
"fishhawk.invalid": {}
}
}
我检查的 Fishhawk 合并版本里,NewAnonymousDockerConfig() 正是在做这件事。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:
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,因为它把需要凭据的范围写得更清楚:
{
"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 反代 时遇到的思路其实类似:把机器上“人的状态”和“后台服务的状态”混在一起,前期很省配置,后面一旦要长期无人值守,边界就会越来越难解释。
helper 真卡住时,不要等整个 docker pull 超时
我还做了一个更直接的实验:把 fake docker-credential-probe 的 get 故意写成永远不返回,然后让 Docker CLI 的 credential lookup 走进它。
外层测试 harness 只给 900 ms。
最后 marker 先记录到了:
docker-credential-probe|get|https://index.docker.io/v1/
随后子进程一直等,到 933 ms 时被外层 deadline 终止。
这里能证明的事情很窄,但很实用:Docker 的 credential lookup 一旦进入外部 helper,而 helper 不回答,调用链可以一直等在那里。当前 docker-credential-helpers 的 client Get() 本身就是执行外部命令再等待输出,这一层没有替你的 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。
但我的第一方实验没有锁定真实 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 问题,我会按这个顺序查:
- 先打印 Runner 实际拿到的
DOCKER_CONFIG、HOME和配置文件路径,别默认它一定在用你以为的那份配置。 - 看
config.json里有没有credsStore或对应 Registry 的credHelpers。 - 检查
PATH上实际存在什么docker-credential-*helper。 - 用独立的 Runner config 做对照,不要先修改人的全局
~/.docker/config.json。 - 如果必须走 helper,把 helper lookup 和整个镜像任务拆开观察,给前者一个可解释的 timeout。
- 切换
DOCKER_CONFIG后,再检查buildx/ Compose / CLI plugin 有没有被一起切丢。
这类问题很容易被误判成 Docker 网络故障,是因为日志最后停在的地方往往只是 docker pull。真正卡住的位置可能更早,在“这个 Registry 有没有凭据、该去哪里拿”这一层。
我现在反而会把 Docker credential config 当成 Runner 的运行时状态,而不是一份可以顺便继承的用户偏好。人的桌面 Docker 可以继续用 Keychain;无人值守 Runner 则拿自己可解释、可测试、可超时的配置。两套东西分开以后,排障路径会短很多。
如果你准备把这类 Runner 长期挂在服务器或开发机上,那篇 Docker 数据卷自动备份到 GitHub 里讲的另一个原则也一样适用:真正需要长期运行的东西,别只管“今天能跑”,还要提前想清楚状态在哪里、坏了怎么定位、换机器以后怎么恢复。
Runbook:Runner 卡在 docker pull 时先做这 6 步
现场救火时,我不会先改全局 Docker 配置,顺序会尽量固定:
- 确认 Runner 实际拿到的
DOCKER_CONFIG和HOME。 - 检查当前 config 里有没有
credsStore/credHelpers/ auth entry。 - 确认
PATH上到底有哪些docker-credential-*helper。 - 用一个独立、无秘密的 Runner config 做对照,不要先破坏人的桌面配置。
- 如果必须走 helper,把 credential lookup 单独做成有界 probe,再执行真正的 pull/build。
- 切换
DOCKER_CONFIG后,顺手验证 buildx / Compose / CLI plugin 没有一起丢失。
$ printf 'DOCKER_CONFIG=%s\nHOME=%s\n' "$DOCKER_CONFIG" "$HOME"
$ command -v docker-credential-osxkeychain || true
查看本次 macOS Runner 的原始测试输出
$ 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 故障。