無人Runnerの障害で厄介なのは、必ずしも明確なerrorが出る場合ではありません。むしろ docker pull や docker build が途中で止まり、logも進まず、Registryが何を返したのか見えない状態のほうが切り分けに時間がかかります。

こういうときは、まずnetwork、DNS、proxy、Docker daemonを疑うことが多いです。私も最初はその方向で考えます。ただ今回、実際のincidentを追っていくと、もう一つ見落としやすい経路がありました。Docker CLIはimageをpullする前にRegistry credentialを解決します。background runnerがdesktop sessionと同じcredential helperをそのまま継承していると、automationが人間のinteractive Keychainに依存してしまうことがあります。

きっかけはFishhawkの issue #4046 でした。このprojectではmacOS上のunattended runnerが、screen lock中にDocker credential helperの経路でsystem Keychainに止まり、unlock後に再開した事例が記録されています。

ただし私はreal Keychainでそのlock-screen挙動自体を再現していません。したがって「macOSなら必ず起きる」とは書きません。

確認したかったのは、その下にあるDocker CLIのcredential-selection mechanismです。

最初は単純に、Runner専用の DOCKER_CONFIG を作って、その中に次だけ置けばいいと思っていました。

{"auths":{}}

これならuserのKeychainには触れないはずだ、と考えたわけです。

ところがDocker CLIの現行sourceと、その後に行ったmacOS実験の両方でこの想定は崩れました。空の auths は「明示的にanonymous」という意味にはなりません。今回固定したDocker CLIでは、platform default credential helperのauto-detectionに進む可能性があります。

ここが今回いちばん意外だった点です。

公開imageでもDocker CLIがcredential helperを触る理由

Dockerの公式documentationでは、client configは現在も ~/.docker/config.json に置かれ、DOCKER_CONFIG または --config で別directoryを指定できます。また、credsStore はdefault credential store、credHelpers はRegistryごとのoverrideとして扱われます。該当箇所は Docker CLI configuration と docker login / credential stores にあります。

問題を理解する決め手になったのはDocker CLI自身のload logicでした。

今回固定して調べたcommitは 3d2650ce76053393872e8389c2b77033146cb356 です。このversionの LoadDefaultConfigFile() には次の分岐があります。

if !configFile.ContainsAuth() {
    configFile.CredentialsStore =
        credentials.DetectDefaultStore(configFile.CredentialsStore)
}

そして ContainsAuth() が見ているのはこの3つです。

return c.CredentialsStore != "" ||
    len(c.CredentialHelpers) > 0 ||
    len(c.AuthConfigs) > 0

つまり {"auths":{}} では AuthConfigs のlengthは0のままです。ContainsAuth() はfalseになり、CLIは DetectDefaultStore() に進みます。

Darwinでは、今回固定したsource上の platform defaultは osxkeychain です。docker-credential-osxkeychain が PATH から見つかれば、CLIはそれをdefault storeとして選べます。

つまり、見た目には「空」のRunner configでも内部では次の流れになり得ます。

credsStore なし
credHelpers なし
auth entry なし
        ↓
ContainsAuth() == false
        ↓
platform default helper を探す
        ↓
macOS: docker-credential-osxkeychain

source inspectionだけでもmechanismは説明できます。ただ、実際のDarwin processが本当にそのpathを取るのかはruntimeで確認したかったです。

4種類のconfigを同じmacOS Runnerで実行した

実験にはreal accountもreal Keychainも入れていません。Fishhawkのcodeも実行せず、docker login も行わず、GitHub runnerのsystem Keychainも変更していません。

GitHub-hosted macOS ARM64 runner上で、Docker CLI sourceを上記commitに固定しました。そのうえで自作のfake helperを2つ、PATH の先頭に置きました。

docker-credential-osxkeychain
docker-credential-probe

これらはreal credentialを読むものではありません。「どのhelperが呼ばれたか」「actionは何か」「どのRegistry keyを問い合わせたか」だけをmarker fileに記録します。

実行commandは次です。

GOFLAGS=-mod=vendor go test \
  -run '^TestAITokenCredentialExperiment$' \
  -count=1 -v ./cli/config

GitHub Actions runは 37620686435。環境はmacOS ARM64、macos26、Go 1.26.8 で、exit codeは 0 でした。raw terminal outputは短いです。

=== 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

重要なのは4つのarmの結果です。

ConfigContainsAuth()default load後のstore実際のhelper call
{"auths":{}}falseosxkeychaindocker-credential-osxkeychain get https://index.docker.io/v1/
{"auths":{"fishhawk.invalid":{}}}truefile storehelper callなし
{"credsStore":"probe","auths":{}}trueprobedocker-credential-probe get https://index.docker.io/v1/
{"credHelpers":{"example.invalid":"probe"}}truefile storeexample.invalid にだけ probe

一番重要なのは最初の行です。裸の {"auths":{}} は「helperを使わない状態」にはなりませんでした。fake docker-credential-osxkeychain が PATH に存在すると、このDocker CLIはそれをauto-detectし、Docker Hubのauth解決で実際に呼びました。

2行目はFishhawkのmerged implementationにある、少し不思議なconfigの理由を説明します。NewAnonymousDockerConfig() は、空の auths ではなく次のno-secret placeholderを使っています。

{
  "auths": {
    "fishhawk.invalid": {}
  }
}

ここにはusernameもpasswordもtokenもありません。役割はcontrol flowだけです。auth mapをnon-emptyにして ContainsAuth() をtrueにし、platform default helper discoveryのbranchに入らないようにしています。

「認証しないために空のauth entryを入れる」という形は最初かなり直感に反します。ただsourceとruntime resultを並べると理由は明確です。

なお、ここにはversion boundaryがあります。このbehaviorは固定したDocker CLI commitで第一者検証したものです。永久的なDocker protocol contractとして扱うべきではありません。これに依存するautomationなら、Docker CLI upgrade時にregression testで守るほうが安全です。

Runner用Docker configはdesktop userのconfigと分離する

この結果を見ても、私はunattended jobを直すためにuserの ~/.docker/config.json から credsStore を消すことは勧めません。

Credential store自体は安全性のために存在します。Docker documentationがexternal storeを勧めるのも、real Registry credentialを平文で config.json に置くよりsystem Keychainや専用helperに渡すほうが通常は安全だからです。

問題はhelperそのものではなく、background automationがinteractive userのcredential stateを暗黙に継承していることです。

public imageしかpullしないRunnerなら、私は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

placeholder keyにはreal credentialは入っていません。名前が必ず runner.invalid である必要もありません。重要なのはRunnerがuserの ~/.docker/config.json を読まないこと、そして今回検証したCLI versionではplatform helper auto-detectionも避けられることです。

private imageが必要なら話は別です。anonymous configではprivate Registryにauthenticateできません。Runner側に明示的なautomation credential pathが必要です。

可能なら、すべてのRegistryに効くdefault credsStore より、Registry単位の credHelpers を優先したいです。credentialを使う範囲がconfig上で見えるからです。

{
  "credHelpers": {
    "registry.example.com": "your-noninteractive-helper"
  }
}

今回のfixtureでは example.invalid と other.invalid の両方をlookupしましたが、fake helperが呼ばれたのは example.invalid だけでした。Dockerがdocumentしているper-Registry behaviorとも一致します。

実運用で your-noninteractive-helper に何を使うかはRegistry providerとcredential lifetime次第です。特定のhelperを万能解として勧めるつもりはありません。unattended automationで重要なのは、machine-oriented credential、狭いRegistry scope、可能ならshort-lived access、そしてinteractive desktopがなくても安定して応答することです。

もう一つ見落としやすいのがpluginです。DOCKER_CONFIG はauth fileだけではなくDocker CLI全体のconfig directoryです。Docker Desktopのuser configに置かれた buildx、Compose、その他CLI pluginに依存している場合、まったく新しいconfig directoryへ切り替えるとplugin discoveryまで変わる可能性があります。

Fishhawkのmerged implementationはoperator側のplugin directoriesをRunner configへ引き継ぐ処理も持っています。考え方は参考になりますが、pathをそのままコピーする話ではありません。Runnerが docker pull だけなら単純ですが、docker buildx、Compose、custom pluginも使うなら、credential isolationと一緒にplugin discoveryも確認すべきです。

これは以前書いた CLIProxyAPIをDockerで動かしてCodexのproxyにする構成 と似ています。人間のstateとbackground serviceのstateを混ぜると、最初は設定が少なくて楽です。ただ長期間unattendedで動かすようになると、そのhidden couplingのほうが維持しにくくなります。

helperが止まったら、docker pull全体のtimeoutまで待たない

もう一つ、より直接的な実験もしました。

fake docker-credential-probe get を意図的に永遠にreturnしない実装にして、Docker CLIのcredential lookupをそのhelperに通しました。

outer test harnessのdeadlineは 900 ms です。

markerには先に次が記録されました。

docker-credential-probe|get|https://index.docker.io/v1/

その後child processは待ち続け、933 ms でouter deadlineにより終了しました。

ここで証明できることは限定的ですが、運用上は重要です。credential lookupがexternal helperの中に入り、そのhelperが応答しなければ、lookup path自体が待ち続けることがあります。現在の docker-credential-helpers client Get() はexternal helperを実行してoutputを待つ実装であり、Runnerのbusiness-level timeoutをこのlayerが代わりに決めてくれるわけではありません。

unattended jobでは、timeoutはfailure reasonを説明できる場所に置きたいです。

job全体に30分timeoutを設定して、最後に「docker pull failed」だけ残るより、private Registry pullの前にbounded probeを置くか、少なくともDocker stepだけに明示的timeoutを設定し、「credential helperが応答しなかった」と分かるほうがはるかに追いやすいです。

Fishhawkのmerged implementationも同じ方向です。operatorが明示的なDocker configを渡した場合、対象imageが実際に使うhelperを先に求め、bounded probeをしてからpullへ進みます。現在のboundは20秒ですが、この20秒はFishhawk固有のengineering policyです。Dockerのdefaultでも、この記事のuniversal recommendationでもありません。

local machine identityだけを見るhelperなら数秒で十分かもしれません。network-backed vaultならもっと長く必要でしょう。再利用すべきなのは数字ではなく、「credential lookupに独立したobservable boundを持たせる」という考え方です。

「macOSをlockすると止まる」という証拠の境界

ここが最もoverclaimしやすい部分です。

Fishhawk #4046が記録しているreal incidentでは、macOS Docker Desktop hostのinteractive configに credsStore: desktop があり、helper chainが docker-credential-osxkeychain まで進み、screen lock中にKeychain側で止まり、unlock後に再開しました。そのprojectは後に PR #4059 でhardeningをmergeしています。

一方、私のfirst-party experimentではreal macOS sessionをlockしていません。real docker-credential-osxkeychain にsystem Keychainを読ませてもいません。同名のfake helperで「Docker CLIがそれを選ぶか」を確認し、別のblocking fake helperで「external helperが返らないとcredential lookupが待つか」を確認しただけです。

したがって、今のevidenceから言えるのは次までです。

  • DOCKER_CONFIG、credsStore、credHelpers はunattended Docker CLIのcredential pathを直接決める。
  • 今回固定したCLI versionでは、macOS上の {"auths":{}} はhelperがdiscoverableなら osxkeychain をauto-selectし得る。
  • no-secret placeholder auth entryは同じfixtureでdefault-helper discoveryを止めた。
  • external credential helperはcallerがouter boundを設定しない限りcredential lookupを待たせ続けることがある。
  • Fishhawkのreal lock-screen Keychain incidentはこのmechanismと整合するが、実際の SecItemCopyMatching のlock-screen behaviorはFishhawk側のthird-party field evidenceである。

もしRunnerが「誰かがMacを使っている間は正常なのに、lock、logout、overnight jobで突然止まる」という症状なら、私は次の順で確認します。

  1. Runnerが実際に受け取っている DOCKER_CONFIG、HOME、config pathを表示する。想像しているfileを読んでいるとは限りません。
  2. config.json の credsStore と、対象Registryの credHelpers を確認する。
  3. PATH からどの docker-credential-* executableが見えるか確認する。
  4. userのglobal ~/.docker/config.json を変更する前に、dedicated runner configで比較する。
  5. helperが必要なら、helper lookupとfull image operationを分けて観測し、前者に説明可能なtimeoutを置く。
  6. DOCKER_CONFIG を切り替えた後、buildx、Compose、必要なCLI pluginが引き続きresolveできるか確認する。

こうしたfailureはDocker networkingの問題に見えやすいです。logの最後に見えるcommandが docker pull だからです。しかし実際に止まっているのは、その前の「このRegistryにcredentialがあるのか、どこから取るのか」というlayerかもしれません。

今はDocker credential configを、background processがuser preferenceとしてそのまま継承するものではなく、Runnerのruntime stateとして扱うほうが安全だと考えています。desktop userはKeychainを使い続けてよい。一方、unattended runnerには自分専用で、説明でき、testでき、timeoutできるconfigを渡す。この2つを分けると、troubleshooting pathはかなり短くなります。

このRunnerをserverやdevelopment machineで長期運用するなら、以前の Docker volumeをGitHubへ自動backupする方法 で扱ったrecoveryの考え方も同じです。「今日動く」で終わらせず、stateがどこにあり、壊れたらどう診断し、別machineへどう戻すかまで決めておくと後が楽です。