Images GitLab privées sur Kubernetes : le guide

Tirer vos images d'un registre GitLab privé depuis Kubernetes : deploy token, imagePullSecret, rotation, debug d'ImagePullBackOff, commandes incluses.

Câbles de fibre optique jaunes connectés à un panneau de brassage blanc, avec un connecteur bleu

Réponse courte : pour tirer une image d'un registre GitLab privé depuis Kubernetes, créez un deploy token avec le scope read_registry sur le projet ou le groupe, transformez-le en secret de type kubernetes.io/dockerconfigjson dans le namespace cible, puis référencez ce secret dans imagePullSecrets (du pod ou, mieux, du ServiceAccount). Trois commandes suffisent ; le reste de cet article traite du périmètre à choisir, de la rotation et du diagnostic des erreurs ImagePullBackOff.

C'est l'un des blocages les plus fréquents quand une équipe passe de docker compose sur un serveur unique à un cluster Kubernetes : le pipeline pousse bien l'image dans registry.gitlab.com/mon-groupe/mon-app, mais le cluster, lui, n'a aucune identité pour aller la chercher. Les pods tournent en boucle, les logs sont vides, et kubectl describe affiche un laconique unauthorized: HTTP Basic: Access denied.

Câbles de fibre optique jaunes connectés à un panneau de brassage blanc, avec un connecteur bleu

Pourquoi Kubernetes ne peut pas lire votre registre GitLab

Le GitLab Container Registry est privé par défaut : sa visibilité suit celle du projet. Un projet privé implique un registre authentifié, et le kubelet — c'est lui qui tire les images, pas le plan de contrôle — n'hérite d'aucun ~/.docker/config.json de votre poste. Même si docker pull fonctionne chez vous après un docker login, le cluster reste anonyme.

Kubernetes résout ce problème avec un objet Secret de type kubernetes.io/dockerconfigjson. Le kubelet lit ce secret, en extrait les identifiants, et les présente au registre pendant le pull. Point essentiel, souvent découvert dans la douleur : un secret est propre à un namespace. Un secret créé dans default est invisible depuis staging ou production. Il faut donc le recréer dans chaque namespace qui déploie l'image.

Quel identifiant utiliser ?

GitLab propose plusieurs jetons, et tous ne se valent pas pour cet usage.

IdentifiantPérimètreDuréeAdapté au pull cluster ?
Deploy token (projet)Un projetExpiration optionnelleOui — le choix par défaut
Deploy token (groupe)Tous les projets du groupeExpiration optionnelleOui, si plusieurs apps du même groupe
Project access tokenUn projet, scopes largesExpiration obligatoirePossible, mais surdimensionné
Personal access tokenTous les projets de l'utilisateurExpiration obligatoireNon — lié à une personne
CI_JOB_TOKENLe job en coursQuelques minutesNon — expire avec le job

Le deploy token gagne pour trois raisons : il n'est rattaché à aucun être humain (pas de rupture quand quelqu'un quitte l'équipe), il accepte le scope read_registry seul, et il peut être créé au niveau du groupe pour couvrir plusieurs microservices avec une seule paire identifiant/mot de passe.

Le CI_JOB_TOKEN, lui, est très pratique pour le docker login dans le pipeline, mais sa durée de vie correspond à celle du job. Un secret Kubernetes construit avec lui marchera au premier déploiement puis cassera au premier redémarrage de pod — un piège classique et particulièrement vicieux, puisque le symptôme apparaît des heures après la mise en production.

Créer le deploy token

Dans GitLab : Settings → Repository → Deploy tokens pour un projet, ou Settings → Repository → Deploy tokens au niveau du groupe. Remplissez :

  • Name : k8s-prod-pull (nommez par cluster/environnement, pas par application)
  • Expiration date : mettez-en une. Un an au maximum ; six mois est un bon compromis si vous automatisez la rotation.
  • Username : laissez vide pour obtenir un nom généré (gitlab+deploy-token-123456), ou fixez-le pour simplifier vos scripts.
  • Scopes : cochez uniquement read_registry. Pas read_repository, pas write_registry.

Copiez le mot de passe immédiatement : GitLab ne le réaffiche jamais.

Astuce : pour une image stockée dans le Dependency Proxy ou dans un package registry, le scope nécessaire diffère (read_package_registry). Pour des images Docker classiques, read_registry suffit.

Générer l'imagePullSecret

La commande impérative est la plus sûre, parce qu'elle construit le JSON à votre place :

kubectl create secret docker-registry gitlab-registry \
  --namespace=production \
  --docker-server=registry.gitlab.com \
  --docker-username='gitlab+deploy-token-123456' \
  --docker-password='gldt-xxxxxxxxxxxxxxxxxxxx' \
  --docker-email='devops@exemple.fr'

Trois détails qui font échouer la moitié des tentatives :

  1. --docker-server doit être l'hôte du registre, pas celui de GitLab. Sur gitlab.com c'est registry.gitlab.com. Sur une instance auto-hébergée, c'est la valeur de registry_external_url dans gitlab.rb, souvent registry.exemple.fr — jamais gitlab.exemple.fr. Vérifiez-la en lisant le préfixe affiché sur la page Deploy → Container Registry du projet.
  2. Pas de schéma, pas de chemin. https://registry.gitlab.com/mon-groupe produit un secret inopérant : le kubelet compare l'hôte de l'image à la clé du config.json.
  3. Guillemets simples autour du mot de passe. Les jetons GitLab peuvent contenir des caractères interprétés par le shell.

Contrôlez le résultat sans le dévoiler dans l'historique :

kubectl -n production get secret gitlab-registry \
  -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq '.auths | keys'

Vous devez voir exactement ["registry.gitlab.com"].

Rattacher le secret

Deux approches. Au niveau du pod, explicitement :

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: production
spec:
  template:
    spec:
      imagePullSecrets:
        - name: gitlab-registry
      containers:
        - name: api
          image: registry.gitlab.com/mon-groupe/mon-app:1.4.2

Ou, plus élégant, au niveau du ServiceAccount utilisé par le namespace — tous les pods qui l'utilisent en héritent, sans toucher aux manifestes :

kubectl -n production patch serviceaccount default \
  -p '{"imagePullSecrets":[{"name":"gitlab-registry"}]}'

La seconde option évite les oublis quand une nouvelle application arrive, mais elle est moins explicite à la lecture. Sur les clusters que nous exploitons pour nos clients, nous patchons le ServiceAccount et laissons imagePullSecrets dans les charts Helm : la redondance est inoffensive et sert de documentation.

Femme assise avec une tablette devant des baies de serveurs éclairées en bleu dans un centre de données

Une bonne pratique : imagePullPolicy et tags immuables

Tant que vous y êtes, réglez la politique de pull. Kubernetes applique IfNotPresent par défaut, sauf pour un tag :latest où il bascule sur Always. Conséquence : avec :latest, un redéploiement tire une image différente sans que rien ne le signale dans Git, et un secret expiré casse le pod immédiatement.

La règle que nous appliquons systématiquement :

  • taguez par version sémantique ou par SHA court de commit (:1.4.2, :sha-a1b2c3d) ;
  • gardez imagePullPolicy: IfNotPresent ;
  • activez les tags protégés et immuables côté GitLab pour empêcher l'écrasement d'un tag déjà déployé.

Une image taguée par SHA rend aussi le rollback trivial : kubectl rollout undo deployment/api repointe vers une couche déjà présente dans le cache du nœud, sans dépendre du registre.

Automatiser depuis GitLab CI/CD

Créer le secret à la main une fois par namespace fonctionne, jusqu'au jour de l'expiration du token. Deux stratégies tiennent dans le temps.

Stratégie 1 : recréation idempotente dans le pipeline

Stockez le deploy token dans des variables CI/CD masquées et protégées (K8S_PULL_USER, K8S_PULL_TOKEN), puis régénérez le secret à chaque déploiement :

deploy:
  stage: deploy
  image: bitnami/kubectl:1.34
  environment: production
  rules:
    - if: $CI_COMMIT_TAG
  script:
    - kubectl create secret docker-registry gitlab-registry
        --namespace="$KUBE_NAMESPACE"
        --docker-server="$CI_REGISTRY"
        --docker-username="$K8S_PULL_USER"
        --docker-password="$K8S_PULL_TOKEN"
        --dry-run=client -o yaml | kubectl apply -f -
    - helm upgrade --install api ./charts/api
        --namespace "$KUBE_NAMESPACE"
        --set image.tag="$CI_COMMIT_TAG"
        --atomic --timeout 5m

Le couple --dry-run=client -o yaml | kubectl apply -f - est la façon canonique de rendre kubectl create secret idempotent : il met à jour le secret s'il existe, le crée sinon, et ne renvoie jamais AlreadyExists. $CI_REGISTRY est fourni automatiquement par GitLab et contient déjà le bon hôte, ce qui élimine l'erreur n° 1 décrite plus haut.

Le --atomic de Helm vous évite de laisser un Deployment à moitié déployé si le pull échoue malgré tout : le chart revient à son état précédent.

Stratégie 2 : secret géré hors pipeline

Sur un cluster multi-équipes, mieux vaut sortir le jeton du CI. Deux outils remplissent ce rôle sans effort :

  • External Secrets Operator : le token vit dans AWS Secrets Manager, Vault ou Scaleway Secret Manager, et un objet ExternalSecret le matérialise en dockerconfigjson dans chaque namespace. La rotation se fait dans le coffre, le cluster suit automatiquement au prochain cycle de rafraîchissement.
  • Reflector ou kubernetes-replicator : vous maintenez un seul secret source dans un namespace d'administration et une annotation le réplique dans tous les namespaces autorisés. Simple, et très efficace quand la seule douleur est la multiplication des namespaces.

Dans les deux cas, notez la date d'expiration du deploy token dans votre runbook. GitLab envoie un courriel d'avertissement avant l'échéance des tokens qui en ont une, mais ce message arrive souvent dans la boîte d'un seul propriétaire de projet.

Diagnostiquer un ImagePullBackOff

Quand ça casse, suivez cet ordre. Il isole la cause en moins de cinq minutes.

# 1. Lire le message exact de l'événement
kubectl -n production describe pod api-7d9c8b5f4-abcde | tail -30

# 2. Vérifier que le secret existe bien DANS ce namespace
kubectl -n production get secret gitlab-registry

# 3. Vérifier que le pod le référence réellement
kubectl -n production get pod api-7d9c8b5f4-abcde \
  -o jsonpath='{.spec.imagePullSecrets}'

Lisez ensuite le message :

MessageCause probableCorrectif
unauthorized: HTTP Basic: Access deniedToken expiré, révoqué ou mauvais scopeRecréer le deploy token avec read_registry
pull access denied ... may require authenticationSecret absent du namespace ou non référencéCréer le secret dans le bon namespace, patcher le ServiceAccount
manifest unknown / not foundLe tag n'existe pas (typo, pipeline de build échoué)Vérifier le tag dans Container Registry
x509: certificate signed by unknown authorityRegistre auto-hébergé avec certificat interneAjouter l'autorité de certification sur les nœuds
toomanyrequestsLimites de taux (registre amont, image de base)Dependency Proxy ou miroir de cache

Pour rejouer le pull manuellement avec les mêmes identifiants, hors de Kubernetes :

echo "$K8S_PULL_TOKEN" | docker login registry.gitlab.com \
  -u 'gitlab+deploy-token-123456' --password-stdin
docker pull registry.gitlab.com/mon-groupe/mon-app:1.4.2

Si cette commande réussit depuis votre poste mais échoue dans le cluster, le problème n'est plus l'authentification : regardez du côté du réseau sortant des nœuds (proxy d'entreprise, règles de sortie, politique réseau) ou de l'espace disque du nœud — un /var/lib/containerd saturé produit des échecs de pull trompeurs, visibles via la condition DiskPressure de kubectl describe node.

Panneau de brassage avec câbles de fibre optique turquoise et jaunes dans une baie de centre de données

Le coût du registre, souvent oublié

Le stockage du GitLab Container Registry est facturé sur le quota de stockage du namespace GitLab, et il grossit vite : chaque pipeline pousse une image complète. Avec une image Node.js de 350 Mo et quarante pipelines par semaine, vous ajoutez plus de 50 Go par mois si rien ne nettoie.

Deux leviers concrets :

  • Les règles d'expiration des tags (Settings → Packages and registries → Clean up image tags) : conservez les 10 derniers tags, gardez ceux correspondant à des versions v*, supprimez le reste au-delà de 30 jours. Attention, la purge ne libère réellement l'espace qu'après le passage du garbage collector du registre.
  • Des images plus petites : une base node:22-alpine ou un build multi-étapes avec npm ci --omit=dev fait fondre l'image de plusieurs centaines de mégaoctets, et accélère d'autant le pull sur chaque nouveau nœud.

Le second levier a un effet direct sur votre temps de démarrage en cas de montée en charge : un pod qui tire 80 Mo au lieu de 350 Mo démarre nettement plus vite quand l'autoscaler ajoute un nœud vierge. C'est exactement le genre d'optimisation que nous intégrons aux prestations d'infrastructure et de déploiement que nous menons pour nos clients, aux côtés du développement applicatif lui-même.

Questions fréquentes

Faut-il un secret par namespace ?

Oui. Les secrets Kubernetes ne traversent pas les namespaces. Soit vous recréez le secret dans chacun (via le pipeline, de façon idempotente), soit vous utilisez un réplicateur comme Reflector ou External Secrets Operator pour le propager depuis une source unique.

Peut-on utiliser CI_JOB_TOKEN pour l'imagePullSecret ?

Non, pas pour un secret durable. Ce jeton expire à la fin du job. Il est parfait pour docker login pendant le build, mais un pod redémarré quelques heures plus tard ne pourra plus tirer l'image. Utilisez un deploy token.

Un deploy token de groupe ou de projet ?

Projet si une seule application utilise le registre, groupe dès que vous déployez plusieurs services du même groupe sur le même cluster. Le token de groupe réduit le nombre de secrets à faire tourner ; en contrepartie, il élargit le périmètre de compromission. Dans tous les cas : scope read_registry seulement.

Comment éviter la coupure le jour de l'expiration ?

Créez le nouveau token avant de supprimer l'ancien, appliquez le nouveau secret dans tous les namespaces, vérifiez un kubectl rollout restart sur une application non critique, puis révoquez l'ancien. Et notez l'échéance dans votre calendrier d'exploitation plutôt que de compter sur le courriel d'alerte de GitLab.

Et avec un registre auto-hébergé derrière un certificat interne ?

Le secret est identique, mais le kubelet doit faire confiance à l'autorité de certification. Installez-la dans le magasin système des nœuds (/usr/local/share/ca-certificates/ puis update-ca-certificates, redémarrage de containerd) ou, selon le runtime, configurez /etc/containerd/certs.d/<hôte>/. Modifier le pod n'y changera rien : le pull se fait au niveau du nœud.

En résumé

  • Le registre GitLab est privé par défaut ; le kubelet a besoin d'un secret kubernetes.io/dockerconfigjson pour s'y authentifier.
  • Utilisez un deploy token (projet ou groupe) avec le seul scope read_registry, jamais un jeton personnel ni CI_JOB_TOKEN.
  • --docker-server vaut l'hôte du registre (registry.gitlab.com ou $CI_REGISTRY), sans https:// ni chemin de projet.
  • Un secret par namespace : rendez sa création idempotente avec --dry-run=client -o yaml | kubectl apply -f -, ou propagez-le via External Secrets Operator.
  • Rattachez-le au ServiceAccount pour couvrir tous les pods, et gardez imagePullSecrets dans vos charts à titre documentaire.
  • Taguez par version ou par SHA, pas en :latest, avec imagePullPolicy: IfNotPresent et des tags immuables.
  • En cas d'ImagePullBackOff, lisez le message de kubectl describe : il distingue un token invalide (Access denied) d'un tag inexistant (manifest unknown).
  • Nettoyez le registre (règles d'expiration des tags) et réduisez la taille des images : moins de stockage facturé, démarrages plus rapides.

Un déploiement qui coince sur un pull d'image, un cluster à industrialiser ou une chaîne GitLab CI/CD à reprendre ? Parlez-nous de votre contexte — notre équipe travaille sur ces sujets au quotidien depuis Pontault-Combault.