一个无人值守 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":{}}falseosxkeychaindocker-credential-osxkeychain get https://index.docker.io/v1/
{"auths":{"fishhawk.invalid":{}}}truefile store无 helper 调用
{"credsStore":"probe","auths":{}}trueprobedocker-credential-probe get https://index.docker.io/v1/
{"credHelpers":{"example.invalid":"probe"}}truefile 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 问题,我会按这个顺序查:

  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 里讲的另一个原则也一样适用:真正需要长期运行的东西,别只管“今天能跑”,还要提前想清楚状态在哪里、坏了怎么定位、换机器以后怎么恢复。

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 没有一起丢失。
$ 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 故障。