無人值守 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":{}}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":{}} 確實沒有形成「明確不使用 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 時正常,鎖屏、登出桌面或夜間任務就莫名卡住」,我會按這個順序查:

  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 講的是另一種故障,但原則其實一樣:不要只管「今天能跑」,還要先知道狀態在哪裡、壞了怎麼定位、換機器後怎麼還原。