# Docker pull 卡住怎麼辦：排查 macOS credential helper 與 DOCKER_CONFIG

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

無人值守 Runner 最難查的一種故障，不一定會直接報錯。更麻煩的是它停在 `docker pull`、`docker build` 這類命令上，日誌沒有繼續，也看不出 Registry 到底回了什麼。

這種時候第一反應通常是查網路、DNS、代理或 Docker daemon。我以前也會先往這些方向看。但這次順著一個真實故障追下去，我發現還有一條比較隱蔽的路徑：Docker CLI 在真正拉鏡像之前，可能先去找 Registry 憑據；如果背景 Runner 繼承的是桌面工作階段裡的 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。

換句話說，一份看起來「什麼都沒有」的 Runner 設定，實際流程可能變成：

```text
沒有 credsStore
沒有 credHelpers
沒有任何 auth entry
        ↓
ContainsAuth() == false
        ↓
探測平台預設 helper
        ↓
macOS: docker-credential-osxkeychain
```

原始碼能解釋這條路徑，但只看原始碼還不夠。我更想確認真實 Darwin 程序會不會照這個方向走。

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

為了不要把真實帳號、Keychain 和第三方專案程式碼混進實驗，我沒有執行 Fishhawk，也沒有跑 `docker login`，更沒有修改 GitHub Runner 的系統 Keychain。

實驗刻意做得很小：GitHub 託管的 macOS ARM64 Runner，Docker CLI 原始碼固定在上面的 commit，然後把兩個自己寫的 fake helper 放到 `PATH` 最前面：

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

它們完全不碰真實憑據，只記錄「誰呼叫了我、執行什麼 action、查哪個 Registry key」。這樣就能直接看到 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":{}}` 確實沒有形成「明確不使用 helper」的邊界。只要 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`。

Credential store 本來就有它的價值。Docker 官方文件之所以建議外部 store，是因為把真實 Registry 憑據交給系統 Keychain 或專門的 helper，通常比直接寫進 `config.json` 更安全。真正有問題的是「無人值守自動化默默重用了人的互動式 credential state」。

如果 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 範圍、最好是短期存取，以及離開互動式桌面後仍能穩定回應。

還有一個很容易漏掉的細節：`DOCKER_CONFIG` 控制的是整個 Docker CLI 設定目錄，不只是 auth。你如果平常依賴 Docker Desktop 放在使用者目錄裡的 `buildx`、Compose 或其他 CLI plugin，直接切到全新的空目錄後，plugin discovery 也可能一起變。

Fishhawk 合併後的實作有特別把原設定裡的 plugin directories 帶到 Runner config。這個思路值得參考，但不要機械照抄路徑。Runner 只做 `docker pull` 時通常很單純；如果還會跑 `docker buildx`、Compose 或自訂 plugin，隔離 credential config 時也要一起驗證 plugin discovery。

這和我之前寫 [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 終止。

這裡能證明的事情很窄，但很實用：credential lookup 一旦進入外部 helper，而 helper 不回應，這條呼叫鏈就可以一直等。現在 [`docker-credential-helpers` client 的 `Get()`](https://github.com/docker/docker-credential-helpers/blob/df2fce1a0dad8cec1a85ead31f268ab416b7a6cb/client/client.go) 本身就是執行外部 helper 並等待輸出；這一層不會替你的 Runner 決定業務級 timeout。

所以無人值守環境裡，timeout 最好放在一個能解釋失敗原因的位置。

如果只給整個 Job 一個 30 分鐘大 timeout，最後只看到「docker pull failed」，排查價值很有限。更實用的是在真正依賴私有 Registry helper 前做有界 probe，或至少替那個 Docker 步驟設定獨立 timeout，並把原因記成「credential helper 沒有回應」。

Fishhawk 合併後的實作也是這個方向：操作員明確提供 Docker config 時，它會先算出這次鏡像存取實際可能命中的 helper，再在一個有界時間內探測。它現在用 20 秒，但這個 **20 秒是 Fishhawk 自己的工程選擇，不是 Docker 官方預設，也不是這篇文章建議所有人照搬的值**。

本機 machine-identity helper 可能幾秒就夠；如果 helper 背後還連網路型 vault，合理上限又會不同。真正可以複用的是「credential lookup 有自己的可觀測邊界」。

## 關於「macOS 鎖屏後卡死」，證據目前只能說到這裡

這篇最容易寫過頭的地方，就是從機制再往前推一步，直接把所有 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，在同一套 fixture 裡確實阻止了這次預設 helper 探測；
- 外部 credential helper 可以讓 credential lookup 持續等待，除非呼叫方自己設邊界；
- Fishhawk 的真實鎖屏 Keychain 故障和這套機制一致，但 `SecItemCopyMatching` 的實際鎖屏行為仍屬於 Fishhawk 的第三方現場證據。

如果你的 Runner 現在就是「有人使用 Mac 時正常，鎖屏、登出桌面或夜間任務就莫名卡住」，我會按這個順序查：

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 則拿自己能解釋、能測試、能 timeout 的設定。兩套狀態分開後，排障路徑會短很多。

如果你準備把這類 Runner 長期放在伺服器或開發機上，我之前那篇 [Docker 資料卷自動備份到 GitHub](/posts/docker-volume-backup-github-sync/) 講的是另一種故障，但原則其實一樣：不要只管「今天能跑」，還要先知道狀態在哪裡、壞了怎麼定位、換機器後怎麼還原。
