Veille

Comment configurer un proxy Docker Hub avec Harbor ?

  • Date de l’événement 29 Jul. 2023
  • Temps de lecture min.

Aujourd'hui, la conteneurisation est utilisée quotidiennement. La plupart des conteneurs sont basés sur des images provenant d'un registre, généralement le registre Docker Hub : "le service leader mondial pour trouver et partager des images de conteneurs". Depuis le 20 novembre 2020, lorsque utilisé de manière anonyme, le registre Docker Hub a une limite de 100 téléchargements toutes les 6 heures par adresse IP (200 pour les utilisateurs authentifiés, jusqu'à 5 000/jour pour les abonnements payants). Lorsque les développeurs de votre entreprise, vos exécuteurs d’intégration continue et/ou votre cluster Kubernetes ont la même adresse IP publique, cette limite peut être atteinte très rapidement, car chaque téléchargement - déclenché manuellement, par une construction d’intégration continue ou un déploiement - sera soustrait de cette limite de téléchargement.
Lorsque la limite est atteinte, les téléchargements suivants seront rejetés, bloquant ainsi les développeurs, les constructions des exécuteurs d’intégration continue ou les déploiements Kubernetes. Vous devriez alors remarquer l'un des messages d'erreur suivants :

ERROR: toomanyrequests: Too Many Requests.

ou :

You have reached your pull rate limit. You may increase the limit by authenticating and upgrading: https://www.docker.com/increase-rate-limits.

Une solution pourrait être de créer un compte pour bénéficier du double de la limite (200 téléchargements/6 heures au lieu de 100), ou même de payer un abonnement pour augmenter la limite à 5 000 téléchargements par jour. Cette solution pourrait fonctionner et nécessiterait de configurer les clients (sur l'ordinateur des développeurs, l’intégration continue, Kubernetes, ...) pour s'authentifier, mais elle ne ferait que repousser le moment où vous atteindrez la limite.
Si vous disposez d'un registre Docker d'entreprise (Harbor, Nexus, ...), une meilleure solution serait de l'utiliser comme cache proxy. En effet, en utilisant un cache proxy, au lieu d'avoir chaque client (chaque développeur, exécuteur d’intégration continue, nœud Kubernetes, ...) téléchargeant chacun la même image Docker depuis le registre Docker Hub, le cache proxy ne téléchargerait cette image qu'une seule fois depuis le registre Docker Hub et servirait l'image mise en cache à chaque client, réduisant ainsi le nombre de téléchargements depuis le registre Docker Hub.
Dans notre cas, nous voulions utiliser Harbor comme cache proxy car il était déjà utilisé comme registre Docker de notre entreprise. Malheureusement, la documentation et la plupart des tutoriels sur Internet expliquent uniquement comment configurer le cache proxy dans Harbor, mais jamais comment configurer le client Docker pour le faire fonctionner avec le cache proxy. D'autre part, la documentation Docker explique uniquement comment configurer un miroir, mais n'explique pas comment le faire fonctionner avec un cache proxy.
Bien que l'on puisse penser que disposer à la fois de la documentation du serveur et du client serait suffisant, nous verrons qu'il manque un élément.

Le problème

Comme mentionné précédemment, la création d'un cache proxy dans Harbor est assez simple en suivant la documentation :

  • Créer un endpoint de registre ciblant https://hub.docker.com
  • Créer un nouveau projet
    • Dans notre cas, nous avons appelé le projet "hub"
    • Cocher la case "Proxy Cache"
    • Sélectionner l’endpoint de registre précédemment créé

Avec cette configuration, les URLs attendues pour télécharger les images sont de la forme suivante (notez la partie "hub" dans l'URL) :

  • https://harborUrl/hub/library/nginx pour l'image nginx
  • https://harborUrl/hub/bitnami/postgresql pour l'image bitnami/postgresql


    Passons maintenant à la configuration de notre client Docker en suivant la documentation correspondante pour utiliser cette URL en tant que miroir. Configurez /etc/docker/daemon.json comme suit :

    {
        "registry-mirrors": ["https://harborUrl/hub"]
    }

Lorsque vous redémarrez le démon Docker (systemctl restart docker), il échoue avec les erreurs suivantes dans les journaux de log :

failed to start daemon: invalid mirror: path, query, or fragment at end of the URI "https://harborUrl/hub"

En effet, le client Docker n'autorise pas un contexte en tant que partie du miroir : il s'attend exclusivement à un hôte. Cela signifie que nous devons configurer https://harborUrl au lieu de https://harborUrl/hub. Avec cette configuration :

  • Le téléchargement de l'image nginx se ferait à partir de https://harborUrl/library/nginx
  • Le téléchargement de l'image bitnami/postgresql se ferait à partir de https://harborUrl/bitnami/postgresql
    Comme nous pouvons le constater, aucune de ces URLs ne correspond aux URLs attendues pour le projet "hub" d'Harbor. De plus, le téléchargement d'une image "library" et d'une image avec un namespace aboutirait à des téléchargements depuis deux projets Harbor différents. Il n'est donc pas possible de configurer Docker pour utiliser un projet unique de cache proxy Harbor en tant que miroir sans ajouter une pièce supplémentaire au puzzle.
    Notez que, contrairement à Docker, Podman (un moteur de conteneur sans démon et alternatif à Docker) dispose d'une fonctionnalité de prefix/location qui peut fonctionner avec cette configuration Harbor. La configuration registries.conf suivante :
[[registry]]
prefix="docker.io"
location="harborUrl/hub"

permettrait à la commande "podman pull docker.io/nginx" de télécharger l'image depuis le proxy Harbor "hub".
Notez également que le gestionnaire de dépôts Nexus est conscient de cette particularité, car il permet de mapper un dépôt Docker à la racine (sans chemin de contexte) en utilisant un port différent de celui utilisé pour tous les autres dépôts Nexus. Cela signifie qu'il peut mapper https://nexusURL/hub vers https://nexusURL:8443/, ce qui serait une configuration valide pour Docker.
Comme Harbor n'a pas cette fonctionnalité, nous avons décidé de mettre en place un proxy avec une réécriture entre le client Docker et Harbor. Contrairement à la solution Nexus, nous avons décidé d'utiliser un autre domaine dédié au proxy Docker Hub au lieu d'utiliser un port différent, mais cela relève simplement d'un choix ou d'une préférence.

Solution pour harbor

Supposons que notre Harbor soit disponible sur https://harborUrl/ et que nous voulions exposer le cache proxy (le projet "hub" dans Harbor) sur https://dockerhubProxyCache/.
Nous allons d'abord définir quelles sont les requêtes/réponses attendues entre le démon Docker et le registre. Ensuite, nous verrons quelles sont les configurations Nginx requises pour se conformer à ces attentes.

 

Requêtes/réponses attendues

Lorsqu'il est demandé de télécharger l'image "php", le démon Docker est censé effectuer les requêtes suivantes et recevoir les réponses suivantes (merci à cet article pour les détails). Dans notre exemple, nous utilisons le domaine dockerhubProxyCache pour avoir une idée des réponses attendues que notre proxy devrait renvoyer :

 

1.GET sur https://dockerhubProxyCache/v2/

  • le registre devrait répondre avec le code 401
  • l'en-tête de réponse Www-Authenticate devrait être défini comme suit : Bearer realm="https://dockerhubProxyCache/service/token",service="harbor-registry" pour donner au démon Docker des informations sur l'endroit où demander le JWT (voir étape suivante)

 

2. GET sur https://dockerhubProxyCache/service/token?scope=repository%3Alibrary%2Fphp%3Apull&service=harbor-registry

  • le registre devrait répondre avec le code 200
  • le corps de la réponse devrait contenir un jeton JWT donnant un accès en lecture à l'image library/php

 

3.GET sur https://dockerhubProxyCache/v2/library/php/manifests/latest

  • la requête devrait avoir un en-tête Authorization avec le JWT précédemment reçu en tant que Bearer
  • la réponse devrait avoir un corps avec un JSON décrivant les couches de l'image avec leur empreinte sha256 correspondante (ou elle peut avoir un corps avec un "fat manifest" qui déclencherait une autre demande de manifeste pour obtenir celui correspondant à l'architecture/au système d'exploitation de la plateforme)

 

4. GET sur https://dockerhubProxyCache/v2/library/php/blobs/sha256:...

  • la requête devrait avoir un en-tête Authorization avec le JWT précédemment reçu en tant que Bearer
  • une requête pour chaque couche
  • la réponse contient les données binaires du layer correspondant

Configuration étape par étape de Nginx

Configuration de base

Tout d'abord, nous devons nous assurer que le server_name est défini sur "harborUrl" dans la configuration globale (nginx/nginx.conf) sinon la configuration personnalisée que nous allons ajouter pourrait la compromettre.

...
  server {
    listen 8443 ssl;
    server_name harborUrl;
    ...
  }
...

Ensuite, nous allons ajouter notre configuration personnalisée. Créez un fichier nginx/conf.d/proxy.server.conf avec le contenu suivant :

server {
    server_name dockerhubProxyCache; # "dockerhubProxyCache" is the domain dedicated to the Docker Hub proxy 
    listen 8443 ssl;
    ssl_* ...; # Some SSL configuration (certificate, protocols, ciphers, ...), not provided here

    location / {
        proxy_set_header Host harborUrl; # "harborUrl" is the main domain of Harbor
        proxy_pass https://localhost:8443;
    }
}

Réécriture de l'en-tête de réponse www-authenticate

À condition que le domaine dockerhubProxyCache soit configuré pour cibler notre Nginx Harbor, cette configuration simple transfert simplement les requêtes sur dockerhubProxyCache vers l'URL Harbor par défaut (harborUrl).
Si nous essayions de télécharger une image avec cette configuration, la première requête (GET sur https://dockerhubProxyCache/v2/) renverrait une réponse 401, mais avec une valeur incorrecte dans l'en-tête Www-Authenticate. La valeur serait :
Bearer realm="https://harborUrl/service/token",service="harbor-registry"
alors que nous attendons :
Bearer realm="https://dockerhubProxyCache/service/token",service="harbor-registry"
Les requêtes suivantes émises par le démon Docker (pour obtenir un jeton JWT) seraient ainsi effectuées sur le domaine incorrect, car le démon utilise la valeur du champ realm renvoyée. Pour résoudre ce problème, nous devons réécrire l'en-tête de réponse en remplaçant le domaine :

map $upstream_http_www_authenticate $rewritten_www_authenticate_header {
    ~^(?<prefix1>.*https://).*(?<suffix1>/service/token.*)$     $prefix1$host$suffix1;
}

server {
    server_name dockerhubProxyCache; 
    listen 8443 ssl;
    ssl_* ...;

    location ~ /v2/ {
        proxy_set_header Host harborUrl; # "harborUrl" is the main domain of Harbor
        proxy_pass https://localhost:8443;
        proxy_hide_header Www-Authenticate;
        add_header Www-Authenticate $rewritten_www_authenticate_header always;
    }

    location / {
        proxy_set_header Host harborUrl;
        proxy_pass https://localhost:8443;
    }
}

Le bloc "map" définit la variable $rewritten_www_authenticate_header à partir de l'en-tête de réponse Www-Authenticate en remplaçant le nom d'hôte (harborUrl) par le nom d'hôte dédié au cache proxy (dockerhubProxyCache).
Le nouveau bloc "location" correspond aux requêtes commençant par /v2/. Il renvoie les requêtes vers Harbor et remplace (cache + ajoute) l'en-tête de réponse Www-Authenticate par la valeur de la variable $rewritten_www_authenticate_header.
Avec cette configuration, la première requête (GET sur https://dockerhubProxyCache/v2/) recevra la réponse attendue.


Réécriture de la portée du jeton de service

Après cette première requête, le démon Docker effectue une demande d'authentification (GET sur https://dockerhubProxyCache/service/token?scope=repository%3Alibrary%2Fphp%3Apull&service=harbor-registry). Procurer cette demande telle quelle ne ciblerait pas le projet "hub" d'Harbor. Nous devons donc réécrire les arguments de la demande et introduire le projet "hub" dans le chemin du dépôt pour transformer scope=repository%3Alibrary%2Fphp en scope=repository%3Ahub%2Flibrary%2Fphp avant de la transmettre en proxy :

map $upstream_http_www_authenticate $rewritten_www_authenticate_header {
    ...
}
map $args $rewritten_scope_args {
    ~^(?<prefix2>.*scope=repository%3A)(?<suffix2>.*)$     ${prefix2}hub%2F${suffix2}; # "hub" is the Harbor project name used for proxy cache
}

server {
    ...
    location / {
        proxy_set_header Host harborUrl;
        proxy_pass https://localhost:8443;
        if ($args ~* ^scope=repository%3A) {
            set $args $rewritten_scope_args;
        }
    }
}

Le bloc "map" définit la variable $rewritten_scope_args à partir de la variable $args (correspondant aux arguments de la requête) en insérant hub%2F (version encodée de "hub/") après scope=repository%3A (version encodée de "scope=repository:").
La configuration supplémentaire dans le bloc "location /" applique cette transformation sur les arguments des requêtes commençant par scope=repository%3A.
Avec cette configuration, la réponse devrait être un JWT permettant l'accès en lecture de l'image hub/library/php.
Les requêtes suivantes sur les endpoints des manifests et des blobs seront authentifiées avec ce JWT.


Réécriture des chemins des manifestes et des blobs

Après cette authentification, le démon Docker effectuera des requêtes de manifests et de blobs avec des requêtes GET de la forme : https://dockerhubProxyCache/v2/library/php/.... Nous devrons réécrire ces requêtes en ajoutant "hub" dans le chemin (après "v2") avant de les transmettre en proxy afin de cibler le projet hub d'Harbor :

map $upstream_http_www_authenticate $rewritten_www_authenticate_header {
    ...
}
map $args $rewritten_scope_args {
    ...
}
map $uri $rewritten_v2_uri {
    ~^/v2/(.+)$ /v2/hub/$1; # "hub" is the Harbor project name used for proxy cache
}
server {
    ...
    location ~ /v2/ {
        proxy_set_header Host harborUrl;        proxy_pass https://localhost:8443;
        proxy_hide_header Www-Authenticate;
        add_header Www-Authenticate $rewritten_www_authenticate_header always;
        if ($request_uri ~* "^/v2/(.+)$") {
            rewrite ^/v2/(.+)$ $rewritten_v2_uri break;
        }
    }
    location / {
        ...
    }
}

La variable $rewritten_v2_uri est définie via le bloc "map" qui transforme l'URI de la requête en ajoutant "hub" entre /v2/ et le reste du chemin.
La configuration supplémentaire dans le bloc "location ~ /v2/" applique cette réécriture aux requêtes ayant un chemin commençant par "/v2/" et suivi d'un chemin non vide. Elle ne s'appliquera pas à la première requête "/v2/" effectuée par le démon Docker car elle a un chemin vide après "/v2". Mais elle s'appliquera à toutes les requêtes de manifests et de blobs.


Configuration complète de Nginx

Après cette configuration étape par étape, regroupons tout dans une configuration Nginx complète :

map $args $rewritten_scope_args {
    ~^(?<prefix2>.*scope=repository%3A)(?<suffix2>.*)$     ${prefix2}hub%2F${suffix2}; # "hub" is the Harbor project name used for proxy cache
}
map $upstream_http_www_authenticate $rewritten_www_authenticate_header {
    ~^(?<prefix1>.*https://).*(?<suffix1>/service/token.*)$     $prefix1$host$suffix1;
}
map $uri $rewritten_v2_uri {
    ~^/v2/(.+)$ /v2/hub/$1; # "hub" is the Harbor project name used for proxy cache
}

server {
    server_name dockerhubProxyCache; # "dockerhubProxyCache" is the domain dedicated to the Docker Hub proxy 
    listen 8443 ssl;
    ssl_* ...; # Some SSL configuration (certificate, protocols, ciphers, ...), not provided here

    location ~ /v2/ {
        proxy_set_header Host harborUrl; # "harborUrl" is the main domain of Harbor
        proxy_pass https://localhost:8443;
        proxy_hide_header Www-Authenticate;
        add_header Www-Authenticate $rewritten_www_authenticate_header always;
        if ($request_uri ~* "^/v2/(.+)$") {
            rewrite ^/v2/(.+)$ $rewritten_v2_uri break;
        }
    }

    location / {
        proxy_set_header Host harborUrl; # "harborUrl" is the main domain of Harbor
        proxy_pass https://localhost:8443;
        if ($args ~* ^scope=repository%3A) {
            set $args $rewritten_scope_args;
        }
    }
}

N'oubliez pas de redémarrer Nginx et de configurer votre DNS pour que "dockerhubProxyCache" cible votre instance Harbor.

Ensuite, assurez-vous que votre démon Docker est correctement configuré pour utiliser ce nouveau proxy en configurant /etc/docker/daemon.json avec :

{
    "registry-mirrors": ["https://dockerhubProxyCache"]
}

puis redémarrez le démon Docker pour appliquer les modifications.


Conclusion

Dans cet article, nous avons commencé par présenter un problème courant (atteinte de la limite de téléchargement de Docker Hub) avec ce qui semblait être une solution simple (utiliser le cache proxy Harbor). Nous avons ensuite découvert que la mise en œuvre de cette solution n'était pas aussi simple qu'il y paraissait, principalement en raison des limitations de configuration du démon Docker (par exemple, podman dispose d'une configuration simple et fonctionnelle).
Cela nous a conduit à une analyse approfondie des requêtes/réponses entre le démon Docker et un registre Docker pour trouver quelles altérations des échanges réseau étaient nécessaires pour que les choses fonctionnent.
Nous avons finalement mis en œuvre ces modifications à l'aide d'un middleware en réutilisant et en configurant le Nginx dans l'architecture Harbor. Avec cette configuration, notre Harbor peut être utilisé comme un cache proxy pour Docker Hub et résoudre le problème de limite de téléchargement dans l'ensemble de l'entreprise.

Maxime Robert

Maxime Robert

Expert technique