Docker pull 卡住怎麼辦:排查 macOS credential helper 與 DOCKER_CONFIG
無人值守 Runner 最難查的一種故障,不一定會直接報錯。更麻煩的是它停在 docker pull、docker build 這類命令上,日誌沒有繼續,也看不出 Registry 到底回了什麼。
這種時候第一反應通常是查網路、DNS、代理或 Docker daemon。我以前也會先往這些方向看。但這次順著一個真實故障追下去,我發現還有一條比較隱蔽的路徑:Docker CLI 在真正拉鏡像之前,可能先去找 Registry 憑據;如果背景 Runner 繼承的是桌面工作階段裡的 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。
換句話說,一份看起來「什麼都沒有」的 Runner 設定,實際流程可能變成:
沒有 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 最前面:
docker-credential-osxkeychain
docker-credential-probe
它們完全不碰真實憑據,只記錄「誰呼叫了我、執行什麼 action、查哪個 Registry key」。這樣就能直接看到 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":{}} 確實沒有形成「明確不使用 helper」的邊界。只要 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。
Credential store 本來就有它的價值。Docker 官方文件之所以建議外部 store,是因為把真實 Registry 憑據交給系統 Keychain 或專門的 helper,通常比直接寫進 config.json 更安全。真正有問題的是「無人值守自動化默默重用了人的互動式 credential state」。
如果 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 範圍、最好是短期存取,以及離開互動式桌面後仍能穩定回應。
還有一個很容易漏掉的細節: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 反向代理 時碰到的問題其實很像:把「人的狀態」和「背景服務的狀態」混在一起,前期很省設定,但一旦要長期無人值守,隱藏耦合會比多寫一點明確設定更難維護。
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 終止。
這裡能證明的事情很窄,但很實用:credential lookup 一旦進入外部 helper,而 helper 不回應,這條呼叫鏈就可以一直等。現在 docker-credential-helpers client 的 Get() 本身就是執行外部 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。
但我的第一方實驗沒有鎖定真實 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 時正常,鎖屏、登出桌面或夜間任務就莫名卡住」,我會按這個順序查:
- 先印出 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 則拿自己能解釋、能測試、能 timeout 的設定。兩套狀態分開後,排障路徑會短很多。
如果你準備把這類 Runner 長期放在伺服器或開發機上,我之前那篇 Docker 資料卷自動備份到 GitHub 講的是另一種故障,但原則其實一樣:不要只管「今天能跑」,還要先知道狀態在哪裡、壞了怎麼定位、換機器後怎麼還原。