1 points par GN⁺ 2024-07-13 | 1 commentaires | Partager sur WhatsApp
  • Les images de conteneurs sont généralement publiées dans un registre dédié, mais si l’on expose un bucket S3 en HTTP et que l’on place les fichiers dans les chemins attendus, il peut être utilisé comme cible pour docker pull
  • Dans un builder d’images personnalisé, le processus permettant de produire en quelques secondes une image pouvant être pullée était devenu un goulot d’étranglement, le temps de push des couches représentant une part importante
  • Dans un benchmark d’upload d’une couche de 198 Mio, ECR a été mesuré à 24–28 Mio/s, contre 115–190 Mio/s pour S3, soit jusqu’à 8 fois plus rapide pour S3
  • L’écart vient du fait que S3 peut uploader en parallèle les chunks d’une même couche, tandis qu’ECR, fondé sur l’OCI Distribution Spec, doit traiter les chunks séquentiellement
  • Cette approche ne remplace pas les fonctionnalités de registre comme docker push, la validation des images, l’analyse de sécurité ou le contrôle d’accès aux dépôts privés ; elle doit donc être vue comme une optimisation expérimentale

Conditions pour que docker pull fonctionne depuis un bucket S3

  • Pour utiliser S3 comme un registre de conteneurs, il faut exposer le bucket en HTTP et uploader les fichiers de l’image aux chemins attendus par Docker
  • Si les conditions sont réunies, l’image peut être récupérée avec docker pull comme depuis un registre classique
  • L’image de démonstration exécute cowsay, et fonctionne avec docker run --rm .../cowsay en utilisant l’URL du bucket comme nom d’image
  • La démonstration utilise Cloudflare R2
    • L’egress gratuit est la raison de ce choix
    • R2 et S3 étant compatibles au niveau API, l’image est uploadée vers R2 avec l’AWS SDK

Pourquoi envisager S3 au lieu d’un registre dédié

  • Les images de conteneurs sont généralement hébergées dans des registres dédiés comme DockerHub, GitHub Container Registry ou ECR
  • L’objectif du builder d’images personnalisé est de passer des exigences à une image pouvant être pullée en quelques secondes
  • Dans un environnement AWS, ECR est le choix le plus simple, mais la différence entre S3 et ECR est apparue nettement dans les vitesses d’upload réelles
  • En ajoutant du traçage d’exécution au code pour optimiser les performances, le temps passé à pousser les couches vers le registre de conteneurs s’est révélé être un goulot d’étranglement majeur

Résultats d’upload d’une couche de 198 Mio

  • Un petit benchmark a comparé le temps nécessaire et le débit pour uploader une couche de 198 Mio vers ECR et vers S3
  • Les vitesses observées sont les suivantes
    • ECR : minimum 24 Mio/s, 8,2 s
    • ECR : maximum 28 Mio/s, 7,0 s
    • S3 : minimum 115 Mio/s, 1,7 s
    • S3 : maximum 190 Mio/s, 1,0 s
  • D’après ces résultats, S3 atteint un niveau jusqu’à 8 fois plus rapide qu’ECR
  • Le code expérimental a été exécuté sur AWS, et S3 comme ECR étaient connectés en interne via un VPC, sans passer par l’Internet public
    • Cette configuration offre une latence et une bande passante aussi favorables que possible

L’écart de vitesse créé par l’upload parallèle de chunks

  • Avec S3, les chunks d’une même couche peuvent être uploadés en parallèle
  • Si la bande passante est suffisante, l’upload parallèle de chunks augmente fortement le débit
  • La documentation AWS recommande également l’upload parallèle de chunks afin de maximiser l’utilisation de la bande passante
  • ECR implémente l’OCI Distribution Spec
    • Cette spécification est le standard qui permet à docker pull et docker push de fonctionner avec plusieurs registres
    • Le push des couches doit se dérouler séquentiellement : même avec un upload par chunks, il faut attendre la fin du chunk précédent avant de passer au suivant
  • Lorsqu’on teste aussi un upload séquentiel sur S3, le débit retombe à un niveau similaire à celui d’ECR

Structure réelle des requêtes de docker pull

  • Les requêtes internes de docker pull se composent de plusieurs requêtes HEAD et GET
  • Un flux typique ressemble à ceci
    • Vérifier l’existence du manifeste d’image : HEAD /v2/my-image/manifests/latest
    • Télécharger le manifeste d’image : GET /v2/my-image/manifests/latest
    • Télécharger à nouveau via le hash du manifeste : GET /v2/my-image/manifests/sha256:...
    • Télécharger le blob de métadonnées de l’image : GET /v2/my-image/blobs/sha256:...
    • Télécharger le blob de couche de l’image : GET /v2/my-image/blobs/sha256:...
  • Au final, docker pull ressemble surtout à un processus de téléchargement HTTP des fichiers nécessaires
  • Si un serveur de fichiers statiques place les fichiers nécessaires aux chemins attendus et définit l’en-tête Content-Type approprié pour chaque requête, il est possible de puller une image de conteneur
  • Un bucket S3 peut remplir ces deux conditions ; configuré avec prudence, il peut donc se comporter comme un registre de conteneurs

Limites de cette approche expérimentale

  • Cette approche reste expérimentale, et il est difficile de tirer des conclusions fortes sans enquête plus approfondie
  • S3 n’est pas, au sens strict, un registre de conteneurs
    • Il n’est pas possible de faire un docker push
    • Le fait que docker pull fonctionne est le résultat de l’adéquation entre la structure des requêtes HTTP et la fourniture de fichiers statiques
  • Les registres de conteneurs existants offrent davantage de fonctionnalités qu’un simple dépôt de fichiers dans un bucket
    • On peut avoir confiance dans le fait qu’une image uploadée via la méthode standard de push est réellement valide
    • Ils peuvent fournir des analyses de sécurité automatiques des couches et des alertes
    • Ils permettent de définir nativement les droits d’accès aux dépôts privés
  • Si cela fonctionne comme prévu, il pourrait aussi devenir possible d’héberger des images de conteneurs publiques sur Cloudflare R2

1 commentaires

 
GN⁺ 2024-07-13
Avis sur Hacker News
  • Il est dommage que l’OCI Distribution Spec ne se lise pas comme une spécification bien conçue
    D’après la spécification, le push des couches doit se faire séquentiellement : même en uploadant par morceaux, il faut attendre la fin de chaque morceau avant de passer au suivant. D’après des tests sur DockerHub et GHCR, l’upload par morceaux lui-même est de toute façon cassé, et les clients ont plutôt tendance à envoyer chaque blob/couche en entier. La spécification recommande aussi un format de valeur Content-Range qui ne correspond pas au format RFC7233
    Bien sûr, il y a du parallélisme au niveau des blobs, mais pas à l’intérieur d’un blob. Je regrette aussi l’occasion manquée de standardiser la pagination de la liste des tags. Une formulation à ce sujet a été supprimée par erreur de la norme [1], ce qui a conduit chaque registre à l’implémenter à sa manière
    [1] https://github.com/opencontainers/distribution-spec/issues/4...

    • Docker et les technologies autour des conteneurs sont globalement comme ça. Docker en tant qu’expérience utilisateur est excellent, mais en tant que technologie, c’est presque le bazar
      Je ne veux pas pour autant le dénigrer complètement. Cela a réellement été révolutionnaire, a rendu l’usage des namespaces Linux bien plus simple qu’avant, et a changé le monde dans le bon sens. Mais Docker a toujours privilégié l’expérience utilisateur à la perfection technique, et ce n’est pas forcément mauvais en soi. Comme il existe beaucoup d’entreprises ennuyeuses qui résolvent des problèmes coûteux avec Perl ou des CSV échangés par FTP, une technologie ennuyeuse, voire mauvaise, peut avoir beaucoup de valeur si elle est livrée dans un bon package
      Malgré tout, c’est parfois un peu amer de se dire que cela aurait pu être bien meilleur qu’aujourd’hui
    • À cela s’ajoute que je ne sais pas si le problème vient de la spécification OCI ou si AWS est particulier, mais contrairement à GitLab ou Nexus, AWS ECR ne prend pas en charge la création automatique de dossiers
      Par exemple, une forme comme .dkr.ecr..amazonaws.com/foo/bar/baz:tag ne fonctionne pas ; seul un stockage à plat est possible, ce qui rend les noms d’images ou les tags excessivement longs. En théorie, on peut créer des objets de dépôt ECR dans Terraform pour imiter quelque chose de similaire, mais ce n’est pas idéal dans des pipelines où le chemin final de l’image est dynamique. Il faut accorder au rôle IAM du pipeline CI un nombre de permissions inconfortablement élevé, et je n’aime pas non plus que des ressources AWS soient gérées en dehors du dépôt Terraform central
      [1] https://stackoverflow.com/questions/64232268/storing-images-...
  • Cloudflare a déjà publié en open source un serveur de registre de conteneurs utilisant R2
    Je me demande si quelqu’un l’a essayé
    [1]: https://github.com/cloudflare/serverless-registry

    • Ça a l’air bien. Cela dit, il est indiqué qu’il y a une limite de 500 Mo par couche
      Dans certains cas d’usage, ce n’est peut-être pas un gros problème, mais dans d’autres, cela peut être un critère d’exclusion immédiat
  • Je suis l’auteur de l’article. Si quelqu’un sait pourquoi la spécification OCI impose que le push des couches soit séquentiel, j’aimerais bien le savoir
    Je me demande si c’est simplement un accident historique ou s’il y a une raison cachée. Pour être clair, plusieurs couches peuvent évidemment être poussées en parallèle ; ici, je parle de la partie qui impose de pousser le contenu d’une seule couche de manière séquentielle

    • C’est peut-être parce que cela simplifie le nettoyage. Si l’on n’a pas atteint le “dernier” chunk, il est clair qu’après N+Timeout, il s’agit d’un upload non terminé, et qu’on peut donc le supprimer
      Cela simplifie le détail d’implémentation consistant à décider quoi faire des uploads partiels. Sinon, à la fin de chaque chunk, il faudrait vérifier que tous les autres chunks sont présents et marquer l’upload comme terminé. Cela dit, c’est un détail d’implémentation, et je doute que ce soit une conception significative ou intentionnelle. L’approche S3 devrait bien fonctionner, et j’ai déjà fait quelque chose de similaire dans une entreprise qui déployait de grosses images. Les 0,10 dollar par Go et par mois finissaient par représenter une somme importante
      On perd les fonctionnalités supplémentaires d’ECR, mais personnellement je les trouve assez limitées
    • Je n’ai jamais eu affaire au push, mais cette approche me plaît. Aux débuts de Docker, il n’y avait pas de conteneur de registre privé vraiment utilisable, donc on mettait les images derrière nginx pour les pull, et c’est pour ça que j’ai trouvé l’article intéressant
    • J’ai implémenté un registre compatible OCI [1], et comme la spécification est complexe, nous avons surtout suivi le comportement de l’implémentation de référence [2] plutôt que la spécification
      Lorsque le client termine l’upload d’un blob, il doit fournir le digest du blob complet. Cette exigence semble destinée à permettre au serveur de vérifier l’intégrité des octets reçus. Si le serveur ne commence à vérifier le digest qu’au moment de la dernière requête HTTP, il doit relire tout le contenu du blob déjà écrit dans le stockage lors des requêtes HTTP précédentes. Pour de grosses couches, cette latence peut devenir difficilement acceptable. Pour répondre aux besoins d’un client particulier, nous avons vérifié que cela fonctionnait jusqu’à des blobs de 150 Gio
      Dans notre implémentation, en revanche, nous poursuivons le calcul du digest tout au long de la séquence de requêtes. À mesure que nous recevons les données du blob par chunks, nous calculons simultanément le digest et les envoyons en streaming vers le stockage de blobs. Entre chaque requête, nous sérialisons l’état du calcul du digest dans l’URL d’upload renvoyée au client via l’en-tête Location. C’est à peu près géré dans ce code : https://github.com/sapcc/keppel/blob/7e43d1f6e77ca72f0020645...
      À ma connaissance, l’implémentation de référence utilise la même approche. Comme le calcul du digest ne peut se faire que séquentiellement, l’upload doit lui aussi se dérouler de manière séquentielle
      [1] https://github.com/sapcc/keppel
      [2] https://github.com/distribution/distribution
    • Merci pour l’article de blog. Vous dites avoir « travaillé avec Outerbounds ces 4 derniers mois pour développer un builder d’images de conteneur personnalisé » ; vous mentionnez que cela mériterait un article à part, mais je serais curieux d’en savoir un peu plus
      Même un lien vers le dépôt GitHub serait appréciable. Le contexte, c’est que je cherche un moyen de créer des images OCI de manière programmatique depuis $PROGRAMMING_LANGUAGE, ou que j’envisage de l’implémenter moi-même. Quelque chose comme Buildah, mais sous forme d’API pour un vrai langage de programmation, pas d’interface en ligne de commande. Bien sûr, on peut appeler Buildah comme sous-processus, mais c’est assez pénible, il faut aussi gérer l’interaction avec l’état interne de Buildah ou son nettoyage, et Buildah ne prend actuellement pas en charge le Mac
    • Aucune raison évidente ne me vient à l’esprit ; cela pourrait être lié à la charge
      Il me semble avoir ajouté autrefois le push parallèle à Docker, mais je confonds peut-être avec le pull et le push. En y repensant, mon travail portait sur la parallélisation des vérifications, pas sur le push final. Si l’on précise sur quelle couche une couche vient se placer, il est possible que l’ID référencé doive déjà exister
  • C’est un cas d’usage plutôt chouette
    Personnellement, j’utilise simplement Nexus. Ça marche suffisamment bien et ça prend en charge aussi bien les images OCI que les paquets apt, les dépôts Maven personnalisés, NuGet, npm, etc. En revanche, la configuration et l’utilisation des ressources sont un peu pénibles, surtout du côté des politiques de nettoyage : https://www.sonatype.com/products/sonatype-nexus-repository
    Cela dit, j’aime vraiment le fait que docker pull ne soit “qu’un” ensemble de requêtes HEAD et GET. J’aimerais voir plus de technologies prendre ce genre de décision de bon sens : réutiliser ce qui fonctionne bien depuis longtemps, sans compliquer inutilement les choses. Je suis surpris qu’il n’existe pas davantage de stockages de conteneurs simples avec authentification et nettoyage. Nexus et Harbor sont tous deux assez complexes à utiliser en pratique

    • J’utilise Gitea uniquement pour les packages. Il gère Docker, npm, Python, etc.
      Je suis surpris que personne ne l’ait mentionné dans ce fil
  • Distribution de la CNCF, l’ancien Docker Registry, inclut une fonctionnalité permettant d’adosser le registre à des URL signées CloudFront qui récupèrent les données depuis S3 [1]
    https://distribution.github.io/distribution/storage-drivers/...

  • Je me demande quel est le problème avec https://github.com/distribution/distribution

    • Je ne l’avais pas vu auparavant, et il prend effectivement en charge S3. Mais je me demande s’il sert les téléchargements directement depuis S3 aux clients, ou s’il utilise seulement S3 comme backend de stockage interne et se comporte en pratique comme un proxy lors des pulls
  • Cette approche semble très coûteuse, et j’aurais aimé que l’article aborde aussi les coûts. Je suis curieux pour S3 comme pour R2.

    • Le tier S3 Standard coûte, par Go stocké, un cinquième du prix d’ECR.
      Le coût du trafic sortant vers Internet gratuit est le même, mais les dépôts ECR publics ont une exception : le trafic sortant pour une utilisation interne à AWS est gratuit.
    • Au final, le coût est celui de S3. Il varie selon la région et le tier de stockage, mais le coût de stockage par Go, les coûts de GET/PUT et les coûts de bande passante sont consultables sur le site d’AWS : https://aws.amazon.com/s3/pricing/
  • En dehors des outils de développement, je n’utilise pas beaucoup Docker, mais je n’ai jamais compris pourquoi des registres de conteneurs privés devaient exister.
    Ça ressemble juste à de la rente. Je me demande quel avantage concret cela apporte par rapport à créer soi-même une sorte de fichier image géré directement et à l’utiliser comme on veut.

    • Rien n’oblige à en utiliser un. On peut utiliser docker save et docker import.
      docker save alpine:3.19 > alpine.tar
      docker load < alpine.tar
      Mais il faut alors gérer ce fichier tar, et tous les systèmes doivent savoir où il se trouve et comment y accéder. Sinon, on peut éviter de réinventer la roue et utiliser le mécanisme que Docker fournit déjà.
    • Il est très probable d’avoir des images qu’on ne veut pas rendre publiques. Ces images doivent généralement être accessibles par une infrastructure comme un cluster k8s ou des runners CI/CD.
      Il faut donc mettre en place son propre registre, ou payer quelqu’un pour le faire. Bien sûr, si vous n’utilisez des images que pour le développement, tout cela n’a pas d’importance : il suffit de les stocker sur la machine de développement.
    • C’est la même raison pour laquelle on n’échange pas des fichiers par e-mail au lieu d’utiliser un dépôt de code.
      On a besoin d’un dépôt central qui conserve toutes les versions précédentes et auquel plusieurs consommateurs peuvent accéder facilement. On n’a pas envie, après avoir buildé une app, de la pousser manuellement partout où elle pourrait s’exécuter. Il suffit de builder une fois, de pousser vers un dépôt central, puis de faire en sorte que tous les environnements y fassent référence.
      Il n’est pas non plus nécessaire de payer pour l’hébergement d’un dépôt privé. Il existe beaucoup d’outils qu’on peut héberger soi-même.
    • Les registres cloud privés sont très utiles pour les projets qui ont des exigences indispensables en matière d’authentification/autorisation autour des images Docker.
      On peut tout configurer par environnement avec Terraform, Bicep ou Pulumi.
    • Reste aussi la question de savoir comment gérer cela. Si l’on veut utiliser les mêmes outils que pour les images publiques, il suffit d’exploiter un registre de conteneurs.
  • ECR semble en réalité conçu pour permettre de téléverser les couches d’image en plusieurs parties.
    Parmi les API ECR concernées, il y a InitiateLayerUpload API, appelée au début du téléversement de chaque couche d’image, UploadLayerPart API, appelée pour chaque fragment de couche (jusqu’à 20 Mo), et PutImage API, qui pousse le manifeste d’image contenant les références aux couches après le téléversement. Ce qui est étrange, c’est que les fragments de couche doivent être téléversés en encodage base64, ce qui augmente les données d’environ 33 %.

    • J’ai utilisé cette API directement, et malheureusement elle exigeait là aussi un téléversement ordonné.
  • L’idée d’utiliser l’agencement des chemins de fichiers comme moyen de contrôle des endpoints est intéressante.
    Je me demande toutefois comment gérer l’en-tête Docker-Content-Digest. Il n’est pas obligatoire, mais il est recommandé de l’inclure dans la réponse, et de nombreux clients s’y attendent et peuvent refuser une couche sans cet en-tête. On risque aussi de passer à côté de fonctionnalités comme la referrers API de la spécification OCI 1.1. L’implémentation semble assez délicate.