Docker pull이 멈출 때: macOS credential helper와 DOCKER_CONFIG 점검하기
무인 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에서 화면이 잠긴 동안 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 experiment가 그 가정을 뒤집었습니다. 빈 auths는 “명시적인 anonymous 상태”가 아닙니다. 제가 고정해서 테스트한 Docker CLI에서는 platform default credential helper auto-detection으로 이어질 수 있습니다.
이번 조사에서 가장 놓치기 쉬운 부분이 바로 이것입니다.
공개 image를 당겨도 Docker CLI가 credential helper를 건드리는 이유
Docker 공식 문서는 현재도 client config를 ~/.docker/config.json에 두고, DOCKER_CONFIG 또는 --config로 다른 config directory를 지정할 수 있다고 설명합니다. 또한 credsStore는 default credential store, credHelpers는 Registry별 override입니다. 관련 문서는 Docker CLI configuration과 docker login / credential stores입니다.
이 문제를 이해하는 데 결정적이었던 것은 Docker CLI 자체의 load logic입니다.
이번에 고정해 본 Docker CLI commit은 3d2650ce76053393872e8389c2b77033146cb356입니다. 이 version의 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에서는 제가 고정한 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만으로도 이 경로를 설명할 수 있습니다. 그래도 실제 Darwin process가 정말 그 방향으로 움직이는지는 runtime에서 확인하고 싶었습니다.
같은 macOS Runner에서 4가지 config를 직접 비교했다
실험에는 real account와 real Keychain을 넣지 않았습니다. Fishhawk code도 실행하지 않았고, docker login도 하지 않았으며, GitHub runner의 system Keychain을 수정하지도 않았습니다.
GitHub-hosted macOS ARM64 runner에서 Docker CLI source를 위 commit으로 고정한 뒤, 제가 직접 만든 fake helper 두 개를 PATH 앞쪽에 배치했습니다.
docker-credential-osxkeychain
docker-credential-probe
이 helper들은 실제 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의 marker 결과입니다.
| Config | ContainsAuth() | default load 뒤 store | 실제 helper call |
|---|---|---|---|
{"auths":{}} | false | osxkeychain | docker-credential-osxkeychain get https://index.docker.io/v1/ |
{"auths":{"fishhawk.invalid":{}}} | true | file store | helper call 없음 |
{"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는 그것을 auto-detect했고, Docker Hub auth를 조회할 때 실제로 실행했습니다.
두 번째 행은 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를 건너뛰게 합니다.
“인증하지 않으려고 빈 auth entry를 넣는다”는 형태는 처음 보면 역설적으로 보입니다. 하지만 source와 runtime result를 같이 보면 이유가 명확합니다.
여기에는 반드시 version boundary를 붙여야 합니다. 이 동작은 고정된 Docker CLI commit에서 1차 검증한 결과입니다. 영구적인 Docker protocol contract로 보면 안 됩니다. 이런 isolation에 의존한다면 Docker CLI upgrade 때 regression test로 확인하는 편이 안전합니다.
Runner의 Docker config는 desktop user config와 분리하는 편이 낫다
이 결과를 봤다고 해서 unattended job을 고치기 위해 user의 ~/.docker/config.json에서 credsStore를 삭제하는 것은 권하지 않습니다.
Credential store는 보안을 위해 존재합니다. Docker 문서가 external store를 권하는 이유도 실제 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에 적용되는 broad credsStore보다 Registry별 credHelpers를 먼저 고려하겠습니다. 어떤 Registry가 credential을 쓰는지 config에서 바로 보이기 때문입니다.
{
"credHelpers": {
"registry.example.com": "your-noninteractive-helper"
}
}
이번 fixture에서는 example.invalid와 other.invalid를 모두 조회했지만 fake helper는 example.invalid에 대해서만 한 번 호출됐습니다. Docker가 문서화한 per-Registry helper 동작과도 맞습니다.
실제 운영에서 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은 기존 config의 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이 process를 종료했습니다.
이 실험이 증명하는 범위는 좁지만 실무적으로 중요합니다. credential lookup이 external helper 안으로 들어간 뒤 helper가 응답하지 않으면 lookup path 자체가 계속 대기할 수 있습니다. 현재 docker-credential-helpers client의 Get()는 external helper를 실행하고 output을 기다립니다. 이 layer가 Runner의 business-level timeout을 대신 정해 주지는 않습니다.
unattended job에서는 timeout을 “무엇이 실패했는지 설명할 수 있는 위치”에 두는 편이 좋습니다.
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을 진행합니다. 현재 Fishhawk는 20초를 사용하지만, 이 20초는 Fishhawk의 engineering policy일 뿐 Docker default도 아니고 이 글의 보편적인 권장값도 아닙니다.
local machine identity helper라면 몇 초로 충분할 수 있습니다. network-backed vault라면 더 길어야 할 수 있습니다. 재사용할 만한 핵심은 숫자가 아니라 “credential lookup 자체에 observable bound를 둔다”는 설계입니다.
“macOS를 잠그면 멈춘다”는 증거는 어디까지인가
여기가 가장 과장하기 쉬운 부분입니다.
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과 일치하지만, 실제
SecItemCopyMatchinglock-screen behavior는 여전히 Fishhawk의 third-party field evidence입니다.
Runner가 “사람이 Mac을 쓰는 동안에는 정상인데 lock, logout, overnight job에서는 멈춘다”는 증상을 보인다면 저는 다음 순서로 확인하겠습니다.
- Runner가 실제로 받은
DOCKER_CONFIG,HOME, config path를 출력합니다. 예상한 file을 읽고 있다고 가정하지 않습니다. config.json의credsStore와 대상 Registry의credHelpers를 확인합니다.PATH에서 실제로 보이는docker-credential-*executable을 확인합니다.- user의 global
~/.docker/config.json를 바꾸기 전에 dedicated runner config로 비교합니다. - helper가 필요하다면 helper lookup을 full image operation과 분리해 관측하고, 앞단에 설명 가능한 timeout을 둡니다.
DOCKER_CONFIG전환 후buildx, Compose, 필요한 CLI plugin이 계속 resolve되는지도 확인합니다.
이런 장애는 Docker networking 문제처럼 보이기 쉽습니다. log에서 마지막으로 보이는 command가 보통 docker pull이기 때문입니다. 하지만 실제 stall은 그보다 앞선 “이 Registry의 credential이 있는가, 어디서 가져올 것인가” 단계일 수 있습니다.
이제는 Docker credential config를 background automation이 user preference처럼 자연스럽게 상속하는 값이 아니라 Runner의 runtime state로 보는 편이 낫다고 생각합니다. desktop user는 Keychain을 계속 써도 됩니다. unattended runner는 자기 전용의, 설명 가능하고, test할 수 있고, timeout할 수 있는 config를 가지면 됩니다. 두 상태를 분리하면 troubleshooting path도 훨씬 짧아집니다.
이런 Runner를 server나 development machine에서 오래 돌릴 계획이라면 Docker volume을 GitHub에 자동 백업하는 방법에서 다룬 recovery 원칙도 함께 생각해 볼 만합니다. “오늘 동작한다”에서 끝내지 말고 state가 어디에 있고, 고장 나면 어떻게 진단하며, 다른 machine에서 어떻게 복구할지를 미리 정해 두는 편이 낫습니다.