1 Punkte von GN⁺ 2024-07-13 | 1 Kommentare | Auf WhatsApp teilen
  • Container-Images werden normalerweise in einer dedizierten Registry abgelegt, aber wenn ein S3-Bucket per HTTP verfügbar gemacht und Dateien an den vorgegebenen Pfaden abgelegt werden, kann er wie ein Ziel für docker pull verwendet werden
  • Bei einem benutzerdefinierten Image-Builder wurde der Prozess, ein pullbares Image innerhalb weniger Sekunden zu erzeugen, zum Flaschenhals, wobei das Pushen der Layer einen großen Anteil der Zeit ausmachte
  • Beim Upload-Benchmark für einen 198-MiB-Layer wurde ECR mit 24–28 MiB/s und S3 mit 115–190 MiB/s gemessen, womit S3 bis zu 8-mal schneller war
  • Der Unterschied entsteht dadurch, dass S3 Chunks eines einzelnen Layers parallel hochladen kann, während ECR auf Basis der OCI Distribution Spec Chunks sequenziell verarbeiten muss
  • Dieser Ansatz kann Registry-Funktionen wie docker push, Validierung von Images, Security-Scans oder Zugriffskontrolle für private Repositories nicht ersetzen und sollte daher als experimentelle Optimierung betrachtet werden

Bedingungen, unter denen docker pull aus einem S3-Bucket funktioniert

  • Um S3 wie eine Container-Registry zu verwenden, muss der Bucket per HTTP verfügbar gemacht werden, und die Image-Dateien müssen an den von Docker erwarteten Pfaden hochgeladen werden
  • Wenn diese Bedingungen erfüllt sind, lässt sich das Image wie aus einer normalen Registry per docker pull abrufen
  • Das Demo-Image führt cowsay aus und verwendet die Bucket-URL als Image-Namen, sodass es mit docker run --rm .../cowsay funktioniert
  • Im Demo wird Cloudflare R2 verwendet
    • Ausschlaggebend war der kostenlose Egress
    • R2 und S3 sind API-kompatibel, daher wird das Image mit dem AWS SDK zu R2 hochgeladen

Warum S3 statt einer dedizierten Registry betrachtet wurde

  • Container-Images werden üblicherweise in dedizierten Registries wie DockerHub, GitHub Container Registry oder ECR gehostet
  • Das Ziel des benutzerdefinierten Image-Builders ist es, innerhalb weniger Sekunden von einer Anforderung zu einem pullbaren Image zu kommen
  • In einer AWS-Umgebung ist ECR die einfachste Wahl, aber bei der tatsächlichen Upload-Geschwindigkeit zeigte sich ein deutlicher Unterschied zwischen S3 und ECR
  • Als zur Performance-Optimierung Traces in den Code eingebaut wurden, stellte sich heraus, dass das Pushen der Layer in die Container-Registry ein wesentlicher Flaschenhals war

Ergebnisse beim Upload eines 198-MiB-Layers

  • Ein kleiner Benchmark vergleicht Upload-Zeit und Durchsatz, indem ein 198-MiB-Layer jeweils zu ECR und S3 hochgeladen wird
  • Die beobachteten Geschwindigkeiten waren wie folgt
    • ECR: mindestens 24 MiB/s, 8,2 Sekunden
    • ECR: maximal 28 MiB/s, 7,0 Sekunden
    • S3: mindestens 115 MiB/s, 1,7 Sekunden
    • S3: maximal 190 MiB/s, 1,0 Sekunden
  • Den Ergebnissen zufolge war S3 gegenüber ECR bis zu 8-mal schneller
  • Der Versuchscode lief in AWS, und S3 sowie ECR waren intern über eine VPC verbunden, ohne das öffentliche Internet zu durchqueren
    • Diese Bedingungen sorgen für möglichst gute Latenz und Bandbreite

Der Geschwindigkeitsunterschied durch parallelen Chunk-Upload

  • Bei S3 können Chunks eines einzelnen Layers parallel hochgeladen werden
  • Bei ausreichender Bandbreite steigert paralleler Chunk-Upload den Durchsatz erheblich
  • Auch die AWS-Dokumentation empfiehlt parallelen Chunk-Upload, um die Bandbreitennutzung zu maximieren
  • ECR implementiert die OCI Distribution Spec
    • Diese Spezifikation ist der Standard, der docker pull und docker push über verschiedene Registries hinweg ermöglicht
    • Das Pushen von Layern muss sequenziell erfolgen; selbst bei Chunk-Uploads kann der nächste Chunk erst beginnen, wenn der vorherige abgeschlossen ist
  • Wenn in S3 ebenfalls ein sequenzieller Upload getestet wird, fällt der Durchsatz auf ein ähnliches Niveau wie bei ECR zurück

Die tatsächliche Anfragestruktur von docker pull

  • Die internen Anfragen von docker pull bestehen aus mehreren HEAD- und GET-Requests
  • Ein beispielhafter Ablauf sieht so aus
    • Prüfen, ob ein Image-Manifest existiert: HEAD /v2/my-image/manifests/latest
    • Image-Manifest herunterladen: GET /v2/my-image/manifests/latest
    • Erneut per Manifest-Hash herunterladen: GET /v2/my-image/manifests/sha256:...
    • Blob mit Image-Metadaten herunterladen: GET /v2/my-image/blobs/sha256:...
    • Blob mit dem Image-Layer herunterladen: GET /v2/my-image/blobs/sha256:...
  • Im Kern ist docker pull damit ein Vorgang, bei dem die benötigten Dateien per HTTP heruntergeladen werden
  • Wenn ein statischer Fileserver die benötigten Dateien an den erwarteten Pfaden bereitstellt und für jede Anfrage die passenden Content-Type-Header setzt, können Container-Images gepullt werden
  • Ein S3-Bucket kann diese beiden Bedingungen erfüllen und mit vorsichtiger Konfiguration wie eine Container-Registry funktionieren

Grenzen dieses experimentellen Ansatzes

  • Dieser Ansatz ist weiterhin experimentell, und vor weiteren Untersuchungen lassen sich nur schwer belastbare Schlussfolgerungen ziehen
  • S3 ist im strengen Sinn keine Container-Registry
    • docker push ist nicht möglich
    • Dass docker pull funktioniert, ist das Ergebnis einer passenden HTTP-Anfragestruktur und statischen Dateiauslieferung
  • Bestehende Container-Registries bieten deutlich mehr Funktionen als das einfache Ablegen von Dateien in einem Bucket
    • Man kann darauf vertrauen, dass per Standard-Push hochgeladene Images tatsächlich gültig sind
    • Sie können automatische Security-Scans und Warnungen für Layer bereitstellen
    • Zugriffsrechte für private Repositories lassen sich nativ festlegen
  • Wenn es wie erwartet funktioniert, könnte es auch möglich werden, öffentliche Container-Images in Cloudflare R2 zu hosten

1 Kommentare

 
GN⁺ 2024-07-13
Meinungen auf Hacker News
  • OCI Distribution Spec liest sich leider nicht wie eine gut entworfene Spezifikation.
    Laut Spezifikation müssen Layer sequenziell gepusht werden, sodass man selbst beim Upload in Chunks erst zum nächsten weitergehen kann, wenn der jeweilige Chunk abgeschlossen ist. Nach meinen Tests mit DockerHub und GHCR ist der Chunk-Upload selbst ohnehin kaputt, und Clients laden eher jeden Blob/Layer am Stück hoch. Die Spezifikation empfiehlt außerdem ein Format für Content-Range-Werte, das nicht zum RFC7233-Format passt.
    Zwar gibt es Parallelität auf Blob-Ebene, aber keine innerhalb eines Blobs. Außerdem ärgert mich, dass die Gelegenheit verpasst wurde, die Paginierung der Tag-Liste zu standardisieren. Durch das versehentliche Entfernen der entsprechenden Formulierung aus dem Standard [1] implementiert das nun jede Registry anders.
    [1] https://github.com/opencontainers/distribution-spec/issues/4...

    • Docker und die Technologien rund um Container sind im Großen und Ganzen genau so. Docker als Nutzererlebnis ist großartig, als Technologie ist es aber nahezu ein Durcheinander.
      Damit will ich es nicht völlig verteufeln. Es war tatsächlich revolutionär, hat die Nutzung von Linux-Namespaces viel einfacher gemacht als zuvor und die Welt zum Besseren verändert. Es hat nur immer die User Experience über technische Vollständigkeit gestellt, und das ist an sich nicht unbedingt schlecht. So wie es viele langweilige Unternehmen gibt, die teure Probleme mit Perl oder per FTP ausgetauschten CSV-Dateien lösen, kann auch langweilige oder sogar schlechte Technik großen Wert haben, wenn sie gut verpackt ist.
      Trotzdem ist es manchmal bitter, weil es so viel besser hätte sein können als heute.
    • Dazu kommt: Ich weiß nicht, ob das an der OCI-Spezifikation liegt oder AWS hier speziell ist, aber anders als GitLab oder Nexus unterstützt AWS ECR keine automatische Erstellung von Ordnern.
      Eine Form wie .dkr.ecr..amazonaws.com/foo/bar/baz:tag funktioniert zum Beispiel nicht; es ist nur flache Speicherung möglich, wodurch Image-Namen oder Tags übermäßig lang werden. Theoretisch könnte man in Terraform ECR-Repository-Objekte anlegen und etwas Ähnliches nachbilden, aber bei Pipelines mit dynamischen Zielpfaden für Images ist das nicht besonders gut. Man müsste der IAM-Rolle der CI-Pipeline unangenehm viele Berechtigungen geben, und mir gefällt auch nicht, dass AWS-Ressourcen außerhalb des zentralen Terraform-Repositorys verwaltet werden.
      [1] https://stackoverflow.com/questions/64232268/storing-images-...
  • Cloudflare hat einmal einen Open-Source-Container-Registry-Server veröffentlicht, der R2 verwendet.
    Ich frage mich, ob ihn schon jemand ausprobiert hat.
    [1]: https://github.com/cloudflare/serverless-registry

    • Sieht gut aus. Allerdings steht dort, dass es ein Limit von 500 MB pro Layer gibt.
      Für manche Einsatzzwecke ist das vielleicht kein großes Problem, für andere kann es sofort ein Ausschlusskriterium sein.
  • Ich bin der Autor. Falls jemand weiß, warum Layer-Pushes in der OCI-Spezifikation sequenziell sein müssen, würde ich mich über Hinweise freuen.
    Ich frage mich, ob das einfach ein historischer Zufall ist oder ob es einen versteckten Grund gibt. Zur Klarstellung: Mehrere Layer kann man natürlich parallel pushen; hier geht es um den Teil, dass der Inhalt eines einzelnen Layers sequenziell gepusht werden muss.

    • Es könnte daran liegen, dass es das Aufräumen vereinfacht. Wenn man den „letzten“ Chunk nicht erreicht hat, ist nach N+Timeout klar, dass es sich um einen nicht abgeschlossenen Upload handelt, den man löschen kann.
      Damit werden Implementierungsdetails dazu, wie partielle Uploads behandelt werden, vereinfacht. Andernfalls müsste man am Ende jedes Chunks prüfen, ob alle anderen Chunks vorhanden sind, und dann den Abschluss durchführen. Allerdings ist das ein Implementierungsdetail, und ich bezweifle, ob das eine sinnvolle oder beabsichtigte Designentscheidung war. Der S3-Ansatz dürfte gut funktionieren; bei einer Firma, die früher große Images ausgerollt hat, habe ich einmal etwas Ähnliches gemacht. 0,10 US-Dollar pro GB und Monat summierte sich ziemlich.
      Die Zusatzfunktionen von ECR verliert man, aber persönlich halte ich diese Funktionen für ziemlich begrenzt.
    • Mit Pushes habe ich mich nie beschäftigt, aber so ein Ansatz freut mich. In den frühen Docker-Tagen gab es keinen brauchbaren privaten Registry-Container, also haben wir Images hinter nginx gelegt und per Pull bezogen; deshalb fand ich den Artikel interessant.
    • Ich habe eine OCI-kompatible Registry [1] implementiert, und weil die Spezifikation komplex ist, haben wir uns größtenteils eher am Verhalten der Referenzimplementierung [2] orientiert als an der Spezifikation.
      Wenn ein Client einen Blob-Upload abschließt, muss er den Digest des gesamten Blobs angeben. Diese Anforderung scheint dazu zu dienen, dass der Server die Integrität der empfangenen Bytes prüfen kann. Wenn der Server erst beim letzten HTTP-Request mit der Digest-Prüfung beginnen würde, müsste er den gesamten Blob-Inhalt, den er bei früheren HTTP-Requests bereits in den Storage geschrieben hat, erneut lesen. Bei großen Layern kann diese Verzögerung untragbar sein. Wegen einer konkreten Client-Anforderung haben wir verifiziert, dass es bis zu 150-GiB-Blobs funktioniert.
      Stattdessen führt unsere Implementierung die Digest-Berechnung über die gesamte Request-Sequenz hinweg fort. Während Blob-Daten chunkweise empfangen werden, werden gleichzeitig der Digest berechnet und die Daten in den Blob-Storage gestreamt. Zwischen den einzelnen Requests serialisieren wir den Zustand der Digest-Berechnung in der Upload-URL, die im Location-Header an den Client zurückgegeben wird. Grob wird das in diesem Code behandelt: https://github.com/sapcc/keppel/blob/7e43d1f6e77ca72f0020645...
      Soweit ich weiß, verwendet die Referenzimplementierung denselben Ansatz. Da die Digest-Berechnung nur sequenziell möglich ist, muss auch der Upload sequenziell ablaufen.
      [1] https://github.com/sapcc/keppel
      [2] https://github.com/distribution/distribution
    • Danke für den Blogbeitrag. Du schreibst, dass du „in den letzten 4 Monaten mit Outerbounds zusammengearbeitet und einen Custom-Container-Image-Builder entwickelt“ hast; du sagtest zwar, das sei Stoff für einen eigenen Beitrag, aber ich frage mich, ob du wenigstens ein paar Details nennen kannst.
      Ein Link zum GitHub-Repository wäre auch schön. Hintergrund ist, dass ich nach einer Möglichkeit suche, OCI-Images programmatisch aus $PROGRAMMING_LANGUAGE heraus zu erstellen, oder überlege, so etwas selbst zu implementieren. Also wie Buildah, aber nicht als Kommandozeileninterface, sondern als echte API für eine Programmiersprache. Natürlich könnte man Buildah als Subprozess aufrufen, aber das ist etwas umständlich; außerdem muss man sich um die Interaktion mit dem internen Zustand von Buildah und dessen Bereinigung kümmern, und derzeit unterstützt Buildah auch keinen Mac.
    • Ein eindeutiger Grund fällt mir nicht sofort ein; es könnte lastbedingt sein.
      Ich glaube, ich hatte früher einmal parallele Pushes zu Docker hinzugefügt, aber vielleicht verwechsle ich das mit Pull und Push. Später stellte sich heraus, dass meine Arbeit nicht der finale Push war, sondern eher die Parallelisierung von Prüfungen. Wenn man angibt, auf welchem Layer ein Layer „obenauf“ liegt, könnte es daran liegen, dass die referenzierte ID bereits existieren muss.
  • Ein ziemlich cooler Use Case.
    Persönlich benutze ich einfach Nexus. Es funktioniert gut genug und unterstützt alles von OCI-Images über apt-Pakete bis zu eigenen Maven-, NuGet- und npm-Repositories. Allerdings sind Konfiguration und Ressourcenverbrauch etwas lästig, besonders bei den Bereinigungsrichtlinien: https://www.sonatype.com/products/sonatype-nexus-repository
    Trotzdem ist es wirklich schön, dass docker pull „einfach“ ein Bündel aus HEAD- und GET-Requests ist. Ich würde gerne bei mehr Technologien solche vernünftigen Entscheidungen sehen: Dinge weiterverwenden, die seit Langem gut funktionieren, und sie nicht unnötig verkomplizieren. Es überrascht mich, dass es nicht mehr einfache Container-Storages mit Authentifizierung und Bereinigung gibt. Nexus und Harbor sind beide ziemlich komplex für den praktischen Einsatz.

    • Ich nutze Gitea nur für Pakete. Es handhabt Docker, npm, Python usw.
      Ich bin überrascht, dass es in diesem Thread niemand erwähnt hat.
  • CNCF Distribution, früher Docker Registry, enthält eine Funktion, um die Registry mit CloudFront Signed URLs zu hinterlegen, die von S3 abrufen [1].
    https://distribution.github.io/distribution/storage-drivers/...

  • Ich frage mich, was an https://github.com/distribution/distribution das Problem ist.

    • Das hatte ich vorher nicht gesehen, und es unterstützt tatsächlich S3. Ich frage mich aber, ob es dem Client Downloads direkt von S3 bereitstellt oder S3 nur als eigenes Storage-Backend nutzt und beim Pull faktisch wie ein Proxy funktioniert.
  • Dieser Ansatz wirkt sehr teuer, und es wäre gut gewesen, wenn der Artikel auch die Kosten behandelt hätte. Mich würden sowohl S3 als auch R2 interessieren.

    • Die S3-Standardklasse kostet pro gespeichertem GB ein Fünftel von ECR.
      Die Kosten für ausgehenden Traffic ins freie Internet sind gleich, aber bei öffentlichen ECR-Repositories gibt es die Ausnahme, dass ausgehender Traffic für die Nutzung innerhalb von AWS kostenlos ist.
    • Am Ende sind es S3-Kosten. Je nach Region und Storage-Klasse variieren sie, aber die Kosten pro gespeichertem GB, für GET/PUT und für Bandbreite findet man auf der AWS-Website: https://aws.amazon.com/s3/pricing/.
  • Außerhalb von Entwicklungstools nutze ich Docker nicht viel, aber ich habe nie verstanden, warum es private Container-Registries geben muss.
    Für mich wirkt das einfach wie Rent-Seeking. Mich würde interessieren, welche konkreten Vorteile das gegenüber einer selbst verwalteten Image-Datei hat, die man nach Belieben verwendet.

    • Man muss sie nicht zwingend nutzen. Man kann docker save und docker import verwenden.
      docker save alpine:3.19 > alpine.tar
      docker load < alpine.tar
      Aber dann muss man diese tar-Datei verwalten, und alle Systeme müssen wissen, wo sie liegt und wie sie darauf zugreifen. Oder man nutzt einfach den Weg, den Docker bereits bereitstellt, statt das Rad neu zu erfinden.
    • Es ist sehr wahrscheinlich, dass es Images gibt, die nicht öffentlich zugänglich sein sollen. Solche Images müssen normalerweise für Infrastruktur wie k8s-Cluster oder CI/CD-Runner erreichbar sein.
      Deshalb muss man entweder selbst eine Registry betreiben oder jemanden dafür bezahlen. Wenn man Images natürlich nur für die Entwicklung nutzt, ist all das irrelevant; dann kann man sie einfach auf der Entwicklungsmaschine speichern.
    • Aus demselben Grund, aus dem man keine Dateien per E-Mail hin- und herschickt, statt ein Code-Repository zu verwenden.
      Man braucht ein zentrales Repository, in dem alle früheren Versionen vorhanden sind und auf das mehrere Konsumenten einfach zugreifen können. Man möchte eine App nach dem Build nicht einzeln an jeden Ort schieben, an dem sie ausgeführt werden könnte. Stattdessen baut man einmal, pusht in ein zentrales Repository und lässt alle Orte auf dieses Repository verweisen.
      Für das Hosting eines privaten Repositorys muss man auch nicht unbedingt bezahlen. Es gibt viele Tools, mit denen man es selbst hosten kann.
    • Private Cloud-Registries sind sehr nützlich in Projekten, die verbindliche Anforderungen an Authentifizierung/Autorisierung im Zusammenhang mit Docker-Images haben.
      Man kann alles pro Umgebung mit Terraform, Bicep oder Pulumi konfigurieren.
    • Auch die Frage, wie man das verwaltet, ist ein Problem. Wenn man dieselben Tools wie für öffentliche Images verwenden will, betreibt man eben eine Container-Registry.
  • ECR wirkt tatsächlich so, als sei es dafür ausgelegt, Image-Layer in mehreren Teilen hochladen zu können.
    Zu den relevanten ECR-APIs gehören die InitiateLayerUpload API, die beim Start des Uploads jedes Image-Layers aufgerufen wird, die UploadLayerPart API, die für jeden Layer-Chunk aufgerufen wird (maximal 20 MB), sowie die PutImage API, mit der nach dem Layer-Upload ein Image-Manifest mit Referenzen auf die Image-Layer gepusht wird. Merkwürdig ist, dass die Layer-Chunks per base64-Encoding hochgeladen werden müssen, wodurch die Daten um etwa 33 % größer werden.

    • Ich habe diese API direkt verwendet, und leider verlangte sie auch dort sequenzielle Uploads.
  • Die Idee, die Anordnung von Dateipfaden als Mittel zur Endpoint-Steuerung zu nutzen, ist interessant.
    Allerdings frage ich mich, wie der Docker-Content-Digest-Header behandelt wird. Er ist zwar nicht verpflichtend, wird aber empfohlen in Antworten aufzunehmen, und viele Clients erwarten ihn und könnten Layer ohne diesen Header ablehnen. Außerdem könnte man Funktionen wie die referrers API der OCI-1.1-Spezifikation verpassen. Die Implementierung dürfte ziemlich knifflig sein.