Info

  • Docker
  • CI/CD
  • Umair Abbas
Repo, CI und Container-Registry vom ersten Tag an besitzen: eine Gründer-Checkliste für ausgelagerte EntwicklungRepo, CI und Container-Registry vom ersten Tag an besitzen: eine Gründer-Checkliste für ausgelagerte Entwicklung

Repo, CI und Container-Registry vom ersten Tag an besitzen: eine Gründer-Checkliste für ausgelagerte Entwicklung

Die meisten ausgelagerten Projekte scheitern nicht am Code. Sie scheitern an dem Tag, an dem der Dienstleister nicht mehr antwortet.

Ich führe eine kleine Software-Agentur, und ein guter Teil unserer Arbeit bestand über die Jahre darin, Produkte aufzusammeln, die ein anderes Team zurückgelassen hat. Das Muster wiederholt sich mit langweiliger Regelmäßigkeit. Der Code liegt in einem Repository, das der Gründer lesen kann, aber das Image wird aus einer Basis gebaut, die in der privaten Registry des alten Dienstleisters liegt. Die CI läuft unter dem persönlichen Konto von irgendjemandem. Die Produktion zieht :latest aus einem Namespace, in dem sich auf Kundenseite niemand anmelden kann. Alles funktioniert, bis es nicht mehr funktioniert — und dann kann niemand mehr etwas neu bauen.

Dieser Artikel ist die Checkliste, die ich Gründern heute vor dem ersten Commit in die Hand gebe. Das ist keine Theorie. Jeder Befehl und jede Ausgabe weiter unten wurde auf einer gewöhnlichen Debian-13-Maschine mit Docker 26.1 ausgeführt (die genaue Umgebung steht am Ende), gegen ein kleines Demo-Repository. Sie können alles davon in etwa zehn Minuten nachvollziehen.

Die Regel: Ein Fremder mit Ihren Zugangsdaten kann ausliefern

Der Test, den ich verwende, ist einfach. Geben Sie einer kompetenten Entwicklerin oder einem kompetenten Entwickler, die oder der den Dienstleister nie getroffen hat:

  • Lesezugriff auf das Repository,
  • Schreibzugriff auf Ihre Container-Registry,
  • sonst nichts.

Können sie den genau in Produktion laufenden Dienst klonen, bauen, starten und veröffentlichen? Lautet die Antwort «dafür müssten sie die Agentur um etwas bitten», dann gehört Ihnen das Produkt noch nicht. Alles Folgende existiert, um aus dieser Antwort ein Ja zu machen — und um es mit einem Skript zu beweisen statt mit einem Versprechen.

1. Das Repository liegt in Ihrer Organisation

Das klingt selbstverständlich. Es ist zugleich der Punkt, den ich am häufigsten verletzt sehe.

Das Repository muss unter einer Organisation liegen, die das Unternehmen kontrolliert, mit mindestens zwei Personen aus dem Unternehmen als Eigentümer. Die Entwickler des Dienstleisters werden als Mitglieder oder externe Mitarbeiter eingeladen. Sie legen das Repository nicht in ihrer eigenen Organisation an, um es «später zu übertragen». Später kommt nie — oder es kommt mitten in einem Streit.

Legen Sie vom ersten Tag an eine CODEOWNERS-Datei an, damit die Eigentümerschaft im Repository steht und nicht in einem Vertragsanhang:

# Client-side engineers own every path. The vendor team is added as reviewers, not owners.
* @acme-corp/platform-owners
/.github/ @acme-corp/platform-owners
/Dockerfile @acme-corp/platform-owners

CODEOWNERS greift nur, wenn der Branch-Schutz die Review durch den Code Owner verlangt — schalten Sie das also auch für main ein. Wenn Ihr Git-Host GitHub ist, können Sie mit der CLI prüfen, wem die Dinge tatsächlich gehören (führen Sie das gegen Ihre eigene Organisation aus; die Ausgabe hängt von Ihrem Konto ab):

Terminal window
gh api repos/acme-corp/handoff-demo --jq '.owner.login, .owner.type'
gh api "orgs/acme-corp/members?role=admin" --jq '.[].login'

Der erste Befehl soll den Namen Ihrer Organisation und Organization ausgeben, der zweite soll Personen auf Ihrer Gehaltsliste auflisten.

2. Basis-Images per Digest festnageln, nicht per Tag

Tags wandern. python:3.12-slim heute und python:3.12-slim in acht Monaten sind unterschiedliche Bytes. Für ein Wochenendprojekt ist das in Ordnung, für eine Übergabe ist es furchtbar: Das neue Team kann nicht unterscheiden, ob ein Fehler ihm gehört oder dem Basis-Image.

Nageln Sie den Digest fest und hinterlassen Sie einen Kommentar, der erklärt, warum:

# Base image pinned by digest: a rebuild next year uses the same bytes.
FROM python:3.12-slim@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f

So finden Sie den Digest dessen, was Sie bereits geladen haben:

Terminal window
docker images --digests python
REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE
python 3.12-slim sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f df18428df8a0 17 hours ago 119MB

Die Basis zu aktualisieren wird damit zu einem geprüften Einzeiler-Pull-Request statt zu einem unsichtbaren Nebeneffekt des Neubauens. Werkzeuge wie Dependabot oder Renovate können diese PRs für Sie öffnen.

3. Keine Basis-Images aus der privaten Registry des Dienstleisters

Dieser Punkt verursacht den größten Schmerz, deshalb hier, wie es aussieht, wenn es schiefgeht. Ein typisches Dockerfile «aus der Dienstleister-Ära»:

FROM ghcr.io/example-vendor-agency/python-base:latest
COPY app/ ./app/
CMD ["python", "app/server.py"]

Auf den Laptops des Dienstleisters baut das, weil sie angemeldet sind. Auf einer sauberen Maschine ohne ihre Zugangsdaten ist das die echte Ausgabe (auf die relevanten Zeilen gekürzt):

$ docker build -t acme/handoff-demo:vendor-era .
#2 [internal] load metadata for ghcr.io/example-vendor-agency/python-base:latest
#2 ERROR: failed to authorize: failed to fetch anonymous token: unexpected status from GET request to https://ghcr.io/token?scope=repository%3Aexample-vendor-agency%2Fpython-base%3Apull&service=ghcr.io: 403 Forbidden
------
Dockerfile:1
--------------------
1 | >>> FROM ghcr.io/example-vendor-agency/python-base:latest
2 | COPY app/ ./app/
3 | CMD ["python", "app/server.py"]
--------------------
ERROR: failed to solve: ghcr.io/example-vendor-agency/python-base:latest: failed to resolve source metadata for ghcr.io/example-vendor-agency/python-base:latest: failed to authorize: failed to fetch anonymous token: unexpected status from GET request to https://ghcr.io/token?scope=repository%3Aexample-vendor-agency%2Fpython-base%3Apull&service=ghcr.io: 403 Forbidden
exit code: 1

(example-vendor-agency ist ein Platzhalter-Namespace, der 403 oben ist also echt — es ist aber derselbe Fehler, den Sie bei einem privaten Namespace ohne Zugriff bekämen.)

Wenn der Dienstleister ein gehärtetes Basis-Image hat, das er mag: gerne. Es wird in Ihre Registry kopiert, sein Dockerfile kommt in Ihr Repository, und Ihre Images bauen darauf auf.

Eine saubere Maschine kann das private Basis-Image des Dienstleisters nicht laden

4. Jedes Image sagt, woher es kommt

Wenn um zwei Uhr nachts etwas kaputtgeht, lautet die erste Frage: «Welcher Commit läuft gerade?» OCI-Labels beantworten sie, ohne dass jemand den Chatverlauf durchsuchen muss:

ARG GIT_SHA=unknown
ARG SOURCE_URL=unknown
LABEL org.opencontainers.image.source=$SOURCE_URL \
org.opencontainers.image.revision=$GIT_SHA \
org.opencontainers.image.title="handoff-demo"

Dann kann jede Person mit dem Image Folgendes tun:

Terminal window
docker image inspect localhost:5000/acme/handoff-demo:203d8f1 \
--format '{{ index .Config.Labels "org.opencontainers.image.source" }} @ {{ index .Config.Labels "org.opencontainers.image.revision" }}'
file:///tmp/client-git/acme/handoff-demo.git @ 203d8f1

In einer echten CI ist die Quelle die URL Ihres Repositorys und die Revision der vollständige Commit-SHA. Auch GHCR nutzt org.opencontainers.image.source, um ein Paket mit seinem Repository zu verknüpfen.

Wenn Sie schon im Dockerfile sind: Lassen Sie den Prozess als Nicht-Root-Benutzer laufen. Das kostet zwei Zeilen und ist ein Befund weniger in der ersten Sicherheitsprüfung nach der Übergabe:

RUN useradd --system --uid 10001 --no-create-home app
USER 10001

5. Die Registry liegt in Ihrem Konto

Die Produktion sollte Images aus einer Registry ziehen, die dem Unternehmen gehört: GHCR unter Ihrer Organisation, ECR in Ihrem AWS-Konto, Artifact Registry in Ihrem GCP-Projekt oder eine selbst gehostete Registry. Die CI des Dienstleisters schreibt dorthin mit einem eng geschnittenen Zugangsdatum, das Sie ausgestellt haben und widerrufen können.

Für die Demo habe ich das offizielle registry:2-Image als Platzhalter für «die Registry des Kunden» verwendet:

$ docker run -d --name client-registry -p 5000:5000 registry:2
be33ebd056ea7ac90d49ebd0a348e568c62fbc51739ad42ee1dd159665b591b5

Nach einem Push zeigt die Registry-API genau das, was Ihnen gehört:

$ curl -s http://localhost:5000/v2/_catalog
{"repositories":["acme/handoff-demo"]}
$ curl -s http://localhost:5000/v2/acme/handoff-demo/tags/list
{"name":"acme/handoff-demo","tags":["12d12c4","203d8f1"]}

Beachten Sie, dass die Tags Commit-SHAs sind und nicht latest. Deployen Sie per Digest oder per SHA-Tag — dann ist ein Rollback eine Änderung von einem Wort.

6. CI ist eine Datei im Repository und läuft unter der Organisation

Die CI-Konfiguration gehört ins Repository, und die Pipeline läuft unter dem Konto der Organisation, nicht unter dem persönlichen Konto einer Entwicklerin des Dienstleisters. Geheimnisse liegen im Secret Store der Organisation. Hier ist der Workflow aus dem Demo-Repository, für GitHub Actions:

name: build-and-push
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
packages: write
jobs:
image:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Log in to the client's registry
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build (and push on main)
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' }}
build-args: |
GIT_SHA=${{ github.sha }}
SOURCE_URL=${{ github.server_url }}/${{ github.repository }}
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

Zwei Details sind für die Eigentümerschaft entscheidend. Der Workflow nutzt das eingebaute GITHUB_TOKEN, es gibt also kein langlebiges persönliches Token zu erben. Und er pusht nach ghcr.io/${{ github.repository }}, das Image landet also bei dem, dem das Repository gehört — bei Ihnen.

Bevor ich einem Workflow traue, lasse ich einen Linter darüber laufen:

$ actionlint .github/workflows/build.yml && echo "actionlint: no issues"
actionlint: no issues

7. Beweisen Sie es: der Übergabe-Check

Checklisten verrotten. Ein Skript nicht, solange Sie es ausführen. Das ist die Fassung, die ich für kleine Dienste verwende. Sie klont in ein temporäres Verzeichnis, richtet Docker auf eine leere Konfiguration aus, damit keine gespeicherte Anmeldung helfen kann, und baut, startet, prüft, inspiziert und schickt das Image einmal im Kreis durch Ihre Registry.

#!/usr/bin/env bash
# handoff-check.sh: can someone who is NOT the vendor rebuild, run and ship this repo?
# Usage: ./handoff-check.sh <git-url-or-path> <registry/namespace>
set -euo pipefail
REPO="$1"
REGISTRY="$2"
WORK="$(mktemp -d)"
export DOCKER_CONFIG="$WORK/docker-config" # empty config: no saved vendor logins
mkdir -p "$DOCKER_CONFIG"
echo "== 1. Fresh clone"
git clone --quiet "$REPO" "$WORK/src"
cd "$WORK/src"
SHA="$(git rev-parse --short HEAD)"
echo "HEAD is $SHA"
echo "== 2. Secrets that should never be in git"
if git ls-files | grep -E '(\.env$|\.pem$|\.key$|id_rsa)'; then
echo "FAIL: secret-looking files are tracked"; exit 1
else
echo "OK: no tracked .env/.pem/.key files"
fi
echo "== 3. Unpinned base images"
if grep -E '^FROM ' Dockerfile | grep -v '@sha256:'; then
echo "WARN: base image not pinned by digest"
else
echo "OK: every FROM is pinned by digest"
fi
echo "== 4. Build with no registry credentials"
docker build --quiet \
--build-arg GIT_SHA="$SHA" \
--build-arg SOURCE_URL="$REPO" \
-t "$REGISTRY/handoff-demo:$SHA" .
echo "== 5. Run and probe"
CID="$(docker run -d -p 18080:8080 "$REGISTRY/handoff-demo:$SHA")"
for i in $(seq 1 20); do
curl -fsS http://127.0.0.1:18080/healthz 2>/dev/null && break
sleep 0.5
done
echo
echo "runs as uid: $(docker exec "$CID" id -u)"
docker rm -f "$CID" >/dev/null
echo "== 6. Provenance labels"
docker image inspect "$REGISTRY/handoff-demo:$SHA" \
--format '{{ index .Config.Labels "org.opencontainers.image.source" }} @ {{ index .Config.Labels "org.opencontainers.image.revision" }}'
echo "== 7. Push to the client's registry and pull back by digest"
docker push --quiet "$REGISTRY/handoff-demo:$SHA"
DIGEST="$(docker image inspect "$REGISTRY/handoff-demo:$SHA" --format '{{ index .RepoDigests 0 }}')"
docker rmi --force "$REGISTRY/handoff-demo:$SHA" >/dev/null
docker pull --quiet "$DIGEST"
echo "pulled $DIGEST"
rm -rf "$WORK"
echo "== handoff-check passed"

Ausgeführt gegen das Demo-Repository (ein Bare-Repository als Platzhalter für den Git-Host des Kunden) und die lokale Registry:

$ ./handoff-check.sh file:///tmp/client-git/acme/handoff-demo.git localhost:5000/acme
== 1. Fresh clone
HEAD is 203d8f1
== 2. Secrets that should never be in git
OK: no tracked .env/.pem/.key files
== 3. Unpinned base images
OK: every FROM is pinned by digest
== 4. Build with no registry credentials
sha256:60d46686c0cc427edffe8ad32ccc944001ce775a7605a20a90830505c39a6ab1
== 5. Run and probe
{"status": "ok", "version": "203d8f1"}
runs as uid: 10001
== 6. Provenance labels
file:///tmp/client-git/acme/handoff-demo.git @ 203d8f1
== 7. Push to the client's registry and pull back by digest
localhost:5000/acme/handoff-demo:203d8f1
localhost:5000/acme/handoff-demo@sha256:566f323ad28e0c8a27cc5b074b48aba0b0ebb5e35754100eb9417d2397a91546
pulled localhost:5000/acme/handoff-demo@sha256:566f323ad28e0c8a27cc5b074b48aba0b0ebb5e35754100eb9417d2397a91546
== handoff-check passed
handoff-check.sh läuft erfolgreich gegen das Demo-Repository

Ein paar ehrliche Grenzen. Die Geheimnis-Prüfung erwischt nur offensichtliche Dateinamen; für Inhalte nehmen Sie einen echten Scanner wie gitleaks oder trufflehog. Schritt 4 zieht das öffentliche Basis-Image weiterhin anonym, und genau das ist der Punkt: öffentliche Basen und Ihre eigene Registry, sonst nichts. Und gegen eine echte Registry würden Sie den Push-Schritt mit einem CI-Zugangsdatum Ihrer Organisation ausführen, nicht ganz ohne.

Ich bitte Dienstleister darum, aus diesem Skript (oder ihrer Fassung davon) einen wöchentlichen CI-Job zu machen. Der Tag, an dem es rot wird, ist der Tag, an dem Sie von einer Abhängigkeit vom Laptop einer bestimmten Person erfahren — statt des Tages, an dem diese Person geht.

Die Gründer-Checkliste

Übernehmen Sie das in die Leistungsbeschreibung:

  1. Repository am ersten Tag in der Organisation des Unternehmens angelegt; mindestens zwei Eigentümer aus dem Unternehmen; CODEOWNERS committet und durch Branch-Schutz durchgesetzt.
  2. Jedes FROM per Digest festgenagelt; Basis-Images nur aus öffentlichen Registries oder aus der Registry des Unternehmens.
  3. Images mit org.opencontainers.image.source und org.opencontainers.image.revision beschriftet; Container laufen ohne Root-Rechte.
  4. Container-Registry in der Cloud oder im Konto des Unternehmens; Images mit dem Commit-SHA getaggt; Produktions-Deployments per SHA oder Digest.
  5. CI im Repository definiert, unter der Organisation laufend, mit Secrets im Geltungsbereich der Organisation oder mit dem eingebauten Token der Plattform.
  6. Ein Übergabe-Check, der ohne jedes Zugangsdatum des Dienstleisters klont, baut, startet und pusht — regelmäßig in der CI ausgeführt.
  7. Eine schriftliche Liste jedes externen Kontos, von dem das Produkt abhängt (DNS, E-Mail, Zahlungen, Cloud), jeweils mit einer verantwortlichen Person im Unternehmen.

Nichts davon bremst einen guten Dienstleister. Ein Dienstleister, der sich dagegen wehrt, sagt Ihnen damit etwas.

Für diesen Artikel verwendete Umgebung

docker client 26.1.5+dfsg1 / server 26.1.5+dfsg1 (storage driver: vfs)
git version 2.47.3
actionlint 1.7.12
Linux 6.12.94+ x86_64
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"

Umair Abbas ist Gründer von CodeFlamme, einer Software-Agentur in Lahore, Pakistan, die Web-, Mobile- und KI-Produkte baut, bei denen den Kunden Repository, CI und Cloud vom ersten Tag an gehören.

Deutsche Übersetzung des englischen Originalartikels Own the Repo, CI and Container Registry on Day One. Befehle, Ausgaben und Skripte sind unverändert wiedergegeben.


← Back to blog