Docker-Push von einem macOS-CI-Runner

Uwe Rittmeyer
Wer eine CI/CD-Pipeline auf macOS betreibt, die ein Docker-Image baut und in eine selbst gehostete Registry pusht, stößt unter Umständen auf eine ärgerliche Hürde:
unauthorized: authentication required
Der Build läuft durch, das Image wird erstellt, docker login sieht sogar nach Erfolg aus – doch der docker push
scheitert an der Authentifizierung. Dieser Beitrag erklärt die Ursache und stellt eine kleine, zustandslose Lösung vor,
die sich direkt in eine Pipeline übernehmen lässt.
TL;DR
Auf macOS legt Docker die Registry-Zugangsdaten standardmäßig über den Credential-Helper docker-credential-osxkeychain
in der Login-Keychain ab. Ein CI-Runner läuft jedoch meist in einer Umgebung ohne GUI-Session, in der diese Keychain
nicht erreichbar ist. Der Login scheint zu funktionieren, der Push schlägt aber mit unauthorized: authentication required
fehl. Die sauberste Lösung besteht darin, pro Job eine eigene, temporäre Docker-Konfiguration über DOCKER_CONFIG zu
setzen, sich per --password-stdin anzumelden und das Verzeichnis anschließend per trap wieder zu entfernen. Damit
wird die Keychain vollständig umgangen, und es bleiben keine Zugangsdaten auf dem Runner zurück.
Ursache
Unter macOS speichert Docker die Registry-Zugangsdaten standardmäßig in der Login-Keychain über einen
Credential-Helper namens docker-credential-osxkeychain. Sichtbar wird das in der Datei ~/.docker/config.json:
{
"credsStore": "osxkeychain"
}
Auf einem interaktiven Desktop funktioniert das einwandfrei. Das Problem: Ein CI-Runner läuft in der Regel nicht in
einer normalen GUI-Session, sondern als LaunchDaemon, als per SSH ausgelöster Job oder unter einem Service-Account,
dessen Login-Keychain nie entsperrt wird. Daher kann der Runner-Prozess die Keychain nicht erreichen.
Das führt zu einem irreführenden Fehlerbild: docker login wird erfolgreich ausgeführt, aber die Zugangsdaten werden
entweder gar nicht geschrieben oder lassen sich zum Zeitpunkt des docker push nicht mehr auslesen. Docker pusht
daraufhin anonym, und die Registry lehnt den Vorgang mit unauthorized: authentication required ab.
Die Lösung: eine temporäre Docker-Konfiguration pro Job
Am saubersten lässt sich das Problem lösen, indem die Keychain vollständig umgangen wird. Dazu erhält jeder Pipeline-Job
ein eigenes, kurzlebiges Konfigurationsverzeichnis für Docker. Ist in diesem Verzeichnis kein credsStore definiert,
schreibt Docker das Auth-Token in eine schlichte config.json innerhalb dieses Verzeichnisses – die Keychain kommt gar
nicht erst ins Spiel.
# Eine frische, isolierte Docker-Konfiguration für diesen Job
export DOCKER_CONFIG="$(mktemp -d)"
# Aufräumen sicherstellen, selbst wenn ein späterer Schritt fehlschlägt
trap 'rm -rf "$DOCKER_CONFIG"' EXIT
# Login mit einem Secret aus der CI – ohne Spuren in Prozessliste und Shell-History
echo "$REGISTRY_PASSWORD" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin
# Wie gewohnt bauen, taggen und pushen
docker push registry.example.com/myapp:latest
-
DOCKER_CONFIG="$(mktemp -d)"verweist Docker auf ein brandneues Verzeichnis ohnecredsStore-Eintrag, sodass die Zugangsdaten in der zugehörigenconfig.jsonstatt in der Keychain landen. Parallele Jobs erhalten somit jeweils ihre eigene, isolierte Konfiguration. -
trap 'rm -rf "$DOCKER_CONFIG"' EXITstellt sicher, dass die temporären Zugangsdaten beim Beenden des Jobs entfernt werden. Auch dann, wenn der Push-Schritt fehlschlägt oder der Job vorzeitig abgebrochen wird.Wichtig: Auf dem Runner bleibt nichts zurück!
-
--password-stdinhält das Passwort aus der Prozessliste (ps) und der Shell-History heraus. Ein Passwort sollte in der CI niemals als einfaches-p-Argument übergeben werden.
Stolperfalle: "User interaction is not allowed."
Dahinter steckt der Security-Framework-Fehler errSecInteractionNotAllowed (-25308). Er bedeutet, dass ein Prozess auf
ein Keychain-Element zugreifen wollte, für das macOS einen interaktiven Dialog anzeigen müsste – etwa zum Entsperren
der Keychain. In einer Session ohne GUI lässt sich dieser Dialog nicht darstellen, weshalb der Vorgang schlicht
abgelehnt wird. Es ist dieselbe Grundursache wie zuvor, nur über einen anderen Weg.
Wer ganz auf Nummer sicher gehen möchte, verzichtet auf docker login und schreibt den Auth-Eintrag direkt in die
temporäre Konfiguration. Ohne docker login wird kein Credential-Helper aufgerufen – die Keychain bleibt damit
garantiert außen vor:
# Eine frische, isolierte Docker-Konfiguration für diesen Job
export DOCKER_CONFIG="$(mktemp -d)"
# Aufräumen sicherstellen, selbst wenn ein späterer Schritt fehlschlägt
trap 'rm -rf "$DOCKER_CONFIG"' EXIT
# Auth-Token selbst erzeugen und direkt in die config.json schreiben
auth=$(printf '%s:%s' "${REGISTRY_USER}" "${REGISTRY_PASSWORD}" | base64)
cat > "$DOCKER_CONFIG/config.json" <<EOF
{
"auths": {
"registry.example.com": { "auth": "$auth" }
}
}
EOF
# Wie gewohnt bauen, taggen und pushen
docker push registry.example.com/myapp:latest
Dieser Ansatz ist die robusteste Variante, da docker login komplett entfällt, gibt es keinen Pfad mehr, über den ein
Credential-Helper (und damit die Keychain) ins Spiel kommen "könnte". Die config.json mit dem base64-kodierten Token
existiert nur im temporären Verzeichnis und wird durch den trap wieder entfernt.
Hinweis zur Sicherheit: base64 ist eine reine Kodierung, keine Verschlüsselung! Der Wert lässt sich jederzeit
ohne Schlüssel zurückwandeln. Die Zugangsdaten liegen in dieser config.json also faktisch im Klartext. Entscheidend
ist daher, dass das temporäre Verzeichnis ausschließlich für den jeweiligen Job zugänglich ist (mktemp -d vergibt
bereits restriktive Rechte) und zuverlässig wieder entfernt wird. Die eigentlichen "Geheimnisse" gehören weiterhin in
den Secret-Store des CI-Systems und sollten niemals im Repository oder in Logs landen.
Warum dieser Ansatz gegenüber den Alternativen vorzuziehen ist
Es gibt zwar weitere Wege, das Problem zu lösen, jedoch bringen alle Nachteile mit sich, die sich für einen Build-Runner nicht eignen:
- Das Entfernen von
credsStoreaus der globalen~/.docker/config.jsonfunktioniert zwar, verändert aber den gemeinsam genutzten Zustand der Maschine und hinterlässt eine base64-kodierte Zugangsinformation in einer dauerhaft vorhandenen Datei. Im Notfall vertretbar, aber unsauberer. - Das Entsperren einer dedizierten Build-Keychain (
security create-keychain/unlock-keychain) ist auch möglich, für eine reine Registry-Authentifizierung ist es jedoch ein unnötiger Mehraufwand.
Der Ansatz mit der temporären DOCKER_CONFIG ist zustandslos, hinterlässt keine dauerhaften Zugangsdaten auf
dem Runner und umgeht das Keychain-Problem vollständig. Aus diesem Grund ist er die Standardempfehlung für
Build-Pipelines.
Konkrete CI-Beispiele
Im Folgenden wird die Lösung für zwei verbreitete CI-Systeme gezeigt. Entscheidend ist in beiden Fällen, dass das
Setzen von DOCKER_CONFIG, der Login und der Push im selben Shell-Aufruf stattfinden – nur dann wirken die
Umgebungsvariable und der trap auch für den Push-Schritt.
GitHub Actions
Das folgende Beispiel geht von einem selbst gehosteten macOS-Runner aus. Benutzername und Passwort der Registry
werden als Repository- oder Organisations-Secrets hinterlegt und über env bereitgestellt:
name: Build and Push
on:
push:
branches: [master]
jobs:
build-and-push:
runs-on: [self-hosted, macOS]
steps:
- uses: actions/checkout@v4
- name: Build Image
run: docker build -t registry.example.com/myapp:latest .
- name: Login and Push
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
export DOCKER_CONFIG="$(mktemp -d)"
trap 'rm -rf "$DOCKER_CONFIG"' EXIT
echo "$REGISTRY_PASSWORD" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin
docker push registry.example.com/myapp:latest
Zu beachten ist, dass jeder run-Schritt in einer eigenen Shell ausgeführt wird. Login und Push gehören deshalb in
denselben Schritt – andernfalls ginge die in mktemp -d erzeugte Konfiguration zwischen den Schritten verloren.
JetBrains TeamCity
In TeamCity wird ein Build-Schritt vom Typ Command Line mit der Ausführungsart Custom Script verwendet. Die Zugangsdaten werden als Parameter hinterlegt und somit als Umgebungsvariablen bereitgestellt:
- Unter Parameters einen Parameter
env.REGISTRY_USER(Typ Environment variable) anlegen. - Einen Parameter
env.REGISTRY_PASSWORDanlegen und über Edit → Type of value auf Password setzen. Dadurch wird der Wert in den Logs maskiert und nicht im Klartext gespeichert.
Im Build-Schritt genügt dann folgendes Skript:
#!/bin/bash
set -euo pipefail
# Eigene, temporäre Docker-Konfiguration für diesen Build
export DOCKER_CONFIG="$(mktemp -d)"
trap 'rm -rf "$DOCKER_CONFIG"' EXIT
# Login mit den als Umgebungsvariablen bereitgestellten Parametern
echo "$REGISTRY_PASSWORD" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin
docker push registry.example.com/myapp:latest
Der Zugriff auf die Parameter erfolgt hier bewusst über echte Umgebungsvariablen ($REGISTRY_PASSWORD) statt über die
TeamCity-Ersetzung %env.REGISTRY_PASSWORD%. So gelangt der geheime Wert nicht in den Skripttext selbst, was im Sinne
der Vertraulichkeit vorzuziehen ist.
Fazit
Der Fehler unauthorized: authentication required auf macOS-Runnern lässt sich fast immer darauf zurückführen, dass
der Keychain-Credential-Helper aus einer Session ohne GUI nicht erreichbar ist. Die Lösung: Jeder Job erhält eine
eigene, temporäre DOCKER_CONFIG, der Login erfolgt per --password-stdin, und das Aufräumen übernimmt ein trap.
Zustandslos, für CI-Zwecke ausreichend sicher und ohne Keychain-Probleme.
Happy pushing ⬆ 🚀️