Imágenes GitLab privadas en Kubernetes: la guía

Descarga imágenes de un registro GitLab privado desde Kubernetes: deploy token, imagePullSecret, rotación y depuración de ImagePullBackOff.

Cables de fibra óptica amarillos conectados a un panel de parcheo blanco, con un conector azul

Respuesta corta: para descargar una imagen de un registro GitLab privado desde Kubernetes, crea un deploy token con el scope read_registry en el proyecto o el grupo, conviértelo en un secret de tipo kubernetes.io/dockerconfigjson en el namespace de destino y después referencia ese secret en imagePullSecrets (del pod o, mejor, del ServiceAccount). Bastan tres comandos; el resto de este artículo trata del ámbito que conviene elegir, de la rotación y del diagnóstico de los errores ImagePullBackOff.

Es uno de los bloqueos más habituales cuando un equipo pasa de docker compose en un único servidor a un clúster de Kubernetes: el pipeline sube correctamente la imagen a registry.gitlab.com/mi-grupo/mi-app, pero el clúster no tiene ninguna identidad con la que ir a buscarla. Los pods giran en bucle, los logs están vacíos y kubectl describe muestra un lacónico unauthorized: HTTP Basic: Access denied.

Cables de fibra óptica amarillos conectados a un panel de parcheo blanco, con un conector azul

Por qué Kubernetes no puede leer tu registro de GitLab

El GitLab Container Registry es privado por defecto: su visibilidad sigue la del proyecto. Un proyecto privado implica un registro autenticado, y el kubelet —es él quien descarga las imágenes, no el plano de control— no hereda ningún ~/.docker/config.json de tu equipo. Aunque docker pull funcione en tu máquina después de un docker login, el clúster sigue siendo anónimo.

Kubernetes resuelve este problema con un objeto Secret de tipo kubernetes.io/dockerconfigjson. El kubelet lee ese secret, extrae las credenciales y las presenta al registro durante el pull. Un punto esencial, que a menudo se descubre por las malas: un secret pertenece a un namespace concreto. Un secret creado en default es invisible desde staging o production. Hay que recrearlo, por tanto, en cada namespace que despliegue la imagen.

¿Qué credencial usar?

GitLab ofrece varios tokens, y no todos sirven igual para este caso.

CredencialÁmbitoDuración¿Apta para el pull del clúster?
Deploy token (proyecto)Un proyectoCaducidad opcionalSí — la opción por defecto
Deploy token (grupo)Todos los proyectos del grupoCaducidad opcionalSí, si hay varias apps del mismo grupo
Project access tokenUn proyecto, scopes ampliosCaducidad obligatoriaPosible, pero sobredimensionado
Personal access tokenTodos los proyectos del usuarioCaducidad obligatoriaNo — está ligado a una persona
CI_JOB_TOKENEl job en cursoUnos minutosNo — caduca con el job

El deploy token gana por tres razones: no está vinculado a ningún ser humano (nada se rompe cuando alguien deja el equipo), admite únicamente el scope read_registry y puede crearse a nivel de grupo para cubrir varios microservicios con un solo par usuario/contraseña.

El CI_JOB_TOKEN, en cambio, resulta muy práctico para el docker login dentro del pipeline, pero su vida útil coincide con la del job. Un secret de Kubernetes construido con él funcionará en el primer despliegue y se romperá en el primer reinicio de un pod: una trampa clásica y especialmente traicionera, ya que el síntoma aparece horas después de la puesta en producción.

Crear el deploy token

En GitLab: Settings → Repository → Deploy tokens para un proyecto, o Settings → Repository → Deploy tokens a nivel de grupo. Rellena:

  • Name: k8s-prod-pull (nombra por clúster/entorno, no por aplicación)
  • Expiration date: ponle una. Un año como máximo; seis meses es un buen compromiso si automatizas la rotación.
  • Username: déjalo vacío para obtener un nombre generado (gitlab+deploy-token-123456), o fíjalo para simplificar tus scripts.
  • Scopes: marca únicamente read_registry. Ni read_repository, ni write_registry.

Copia la contraseña de inmediato: GitLab no la vuelve a mostrar nunca.

Consejo: para una imagen almacenada en el Dependency Proxy o en un package registry, el scope necesario es distinto (read_package_registry). Para imágenes Docker clásicas, read_registry basta.

Generar el imagePullSecret

El comando imperativo es el más seguro, porque construye el JSON por ti:

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@ejemplo.es'

Tres detalles que hacen fracasar la mitad de los intentos:

  1. --docker-server debe ser el host del registro, no el de GitLab. En gitlab.com es registry.gitlab.com. En una instancia autoalojada, es el valor de registry_external_url en gitlab.rb, a menudo registry.ejemplo.es — nunca gitlab.ejemplo.es. Compruébalo leyendo el prefijo que aparece en la página Deploy → Container Registry del proyecto.
  2. Sin esquema y sin ruta. https://registry.gitlab.com/mi-grupo produce un secret inservible: el kubelet compara el host de la imagen con la clave del config.json.
  3. Comillas simples alrededor de la contraseña. Los tokens de GitLab pueden contener caracteres que el shell interpreta.

Verifica el resultado sin dejarlo al descubierto en el historial:

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

Debes ver exactamente ["registry.gitlab.com"].

Asociar el secret

Hay dos enfoques. A nivel de pod, de forma explícita:

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

O, más elegante, a nivel del ServiceAccount que usa el namespace: todos los pods que lo utilicen lo heredan, sin tocar los manifiestos:

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

La segunda opción evita olvidos cuando llega una nueva aplicación, pero es menos explícita al leerla. En los clústeres que explotamos para nuestros clientes, parcheamos el ServiceAccount y dejamos imagePullSecrets en los charts de Helm: la redundancia es inofensiva y sirve de documentación.

Mujer sentada con una tableta frente a racks de servidores iluminados en azul en un centro de datos

Una buena práctica: imagePullPolicy y tags inmutables

Ya que estás, ajusta la política de pull. Kubernetes aplica IfNotPresent por defecto, salvo con un tag :latest, donde cambia a Always. Consecuencia: con :latest, un redespliegue descarga una imagen distinta sin que nada lo refleje en Git, y un secret caducado rompe el pod de inmediato.

La regla que aplicamos sistemáticamente:

  • etiqueta por versión semántica o por SHA corto de commit (:1.4.2, :sha-a1b2c3d);
  • mantén imagePullPolicy: IfNotPresent;
  • activa los tags protegidos e inmutables en GitLab para impedir que se sobrescriba un tag ya desplegado.

Una imagen etiquetada por SHA también vuelve trivial el rollback: kubectl rollout undo deployment/api reapunta a una capa ya presente en la caché del nodo, sin depender del registro.

Automatizar desde GitLab CI/CD

Crear el secret a mano una vez por namespace funciona, hasta el día en que el token caduca. Hay dos estrategias que aguantan con el tiempo.

Estrategia 1: recreación idempotente en el pipeline

Guarda el deploy token en variables CI/CD enmascaradas y protegidas (K8S_PULL_USER, K8S_PULL_TOKEN) y después regenera el secret en cada despliegue:

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

La combinación --dry-run=client -o yaml | kubectl apply -f - es la forma canónica de volver idempotente kubectl create secret: actualiza el secret si existe, lo crea si no, y nunca devuelve AlreadyExists. $CI_REGISTRY lo proporciona GitLab automáticamente y ya contiene el host correcto, lo que elimina el error n.º 1 descrito más arriba.

El --atomic de Helm evita que te quedes con un Deployment desplegado a medias si el pull falla a pesar de todo: el chart vuelve a su estado anterior.

Estrategia 2: secret gestionado fuera del pipeline

En un clúster multiequipo, es mejor sacar el token del CI. Dos herramientas cumplen este papel sin esfuerzo:

  • External Secrets Operator: el token vive en AWS Secrets Manager, Vault o Scaleway Secret Manager, y un objeto ExternalSecret lo materializa como dockerconfigjson en cada namespace. La rotación se hace en la bóveda y el clúster se actualiza automáticamente en el siguiente ciclo de refresco.
  • Reflector o kubernetes-replicator: mantienes un único secret de origen en un namespace de administración y una anotación lo replica en todos los namespaces autorizados. Sencillo y muy eficaz cuando el único dolor es la multiplicación de namespaces.

En ambos casos, anota la fecha de caducidad del deploy token en tu runbook. GitLab envía un correo de aviso antes del vencimiento de los tokens que tienen una, pero ese mensaje suele llegar al buzón de un único propietario del proyecto.

Diagnosticar un ImagePullBackOff

Cuando algo se rompe, sigue este orden. Aísla la causa en menos de cinco minutos.

# 1. Leer el mensaje exacto del evento
kubectl -n production describe pod api-7d9c8b5f4-abcde | tail -30

# 2. Comprobar que el secret existe EN este namespace
kubectl -n production get secret gitlab-registry

# 3. Comprobar que el pod lo referencia realmente
kubectl -n production get pod api-7d9c8b5f4-abcde \
  -o jsonpath='{.spec.imagePullSecrets}'

Después lee el mensaje:

MensajeCausa probableSolución
unauthorized: HTTP Basic: Access deniedToken caducado, revocado o con un scope incorrectoRecrear el deploy token con read_registry
pull access denied ... may require authenticationSecret ausente del namespace o no referenciadoCrear el secret en el namespace correcto, parchear el ServiceAccount
manifest unknown / not foundEl tag no existe (errata, pipeline de build fallido)Comprobar el tag en el Container Registry
x509: certificate signed by unknown authorityRegistro autoalojado con certificado internoAñadir la autoridad de certificación en los nodos
toomanyrequestsLímites de tasa (registro upstream, imagen base)Dependency Proxy o espejo de caché

Para repetir el pull manualmente con las mismas credenciales, fuera de Kubernetes:

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

Si este comando funciona desde tu equipo pero falla en el clúster, el problema ya no es la autenticación: mira hacia la red de salida de los nodos (proxy corporativo, reglas de egress, política de red) o hacia el espacio en disco del nodo — un /var/lib/containerd saturado produce fallos de pull engañosos, visibles a través de la condición DiskPressure de kubectl describe node.

Panel de parcheo con cables de fibra óptica turquesa y amarillos en un rack de centro de datos

El coste del registro, a menudo olvidado

El almacenamiento del GitLab Container Registry se factura sobre la cuota de almacenamiento del namespace de GitLab, y crece rápido: cada pipeline sube una imagen completa. Con una imagen Node.js de 350 MB y cuarenta pipelines por semana, añades más de 50 GB al mes si nada se encarga de limpiar.

Dos palancas concretas:

  • Las reglas de caducidad de tags (Settings → Packages and registries → Clean up image tags): conserva los 10 últimos tags, guarda los que correspondan a versiones v* y elimina el resto a partir de 30 días. Ojo: la purga solo libera espacio de verdad tras el paso del garbage collector del registro.
  • Imágenes más pequeñas: una base node:22-alpine o un build multietapa con npm ci --omit=dev reduce la imagen en varios cientos de megabytes y acelera en la misma medida el pull en cada nodo nuevo.

La segunda palanca tiene un efecto directo en tu tiempo de arranque cuando hay picos de carga: un pod que descarga 80 MB en lugar de 350 MB arranca mucho más rápido cuando el autoscaler añade un nodo virgen. Es exactamente el tipo de optimización que integramos en los servicios de infraestructura y despliegue que llevamos a cabo para nuestros clientes, junto al desarrollo de aplicaciones en sí.

Preguntas frecuentes

¿Hace falta un secret por namespace?

Sí. Los secrets de Kubernetes no cruzan los namespaces. O recreas el secret en cada uno (mediante el pipeline, de forma idempotente), o usas un replicador como Reflector o External Secrets Operator para propagarlo desde una única fuente.

¿Se puede usar CI_JOB_TOKEN para el imagePullSecret?

No, no para un secret duradero. Ese token caduca al terminar el job. Es perfecto para el docker login durante el build, pero un pod reiniciado unas horas después ya no podrá descargar la imagen. Usa un deploy token.

¿Deploy token de grupo o de proyecto?

De proyecto si solo una aplicación usa el registro; de grupo en cuanto despliegues varios servicios del mismo grupo en el mismo clúster. El token de grupo reduce el número de secrets que hay que rotar; a cambio, amplía el alcance en caso de compromiso. En todos los casos: únicamente el scope read_registry.

¿Cómo evitar el corte el día de la caducidad?

Crea el nuevo token antes de eliminar el antiguo, aplica el nuevo secret en todos los namespaces, comprueba un kubectl rollout restart en una aplicación no crítica y después revoca el antiguo. Y anota el vencimiento en tu calendario de operaciones en lugar de confiar en el correo de alerta de GitLab.

¿Y con un registro autoalojado detrás de un certificado interno?

El secret es idéntico, pero el kubelet debe confiar en la autoridad de certificación. Instálala en el almacén del sistema de los nodos (/usr/local/share/ca-certificates/ y después update-ca-certificates, con reinicio de containerd) o, según el runtime, configura /etc/containerd/certs.d/<host>/. Modificar el pod no cambiará nada: el pull se hace a nivel del nodo.

En resumen

  • El registro de GitLab es privado por defecto; el kubelet necesita un secret kubernetes.io/dockerconfigjson para autenticarse en él.
  • Usa un deploy token (de proyecto o de grupo) con el único scope read_registry, nunca un token personal ni CI_JOB_TOKEN.
  • --docker-server es el host del registro (registry.gitlab.com o $CI_REGISTRY), sin https:// ni ruta de proyecto.
  • Un secret por namespace: vuelve idempotente su creación con --dry-run=client -o yaml | kubectl apply -f -, o propágalo con External Secrets Operator.
  • Asócialo al ServiceAccount para cubrir todos los pods, y mantén imagePullSecrets en tus charts a modo de documentación.
  • Etiqueta por versión o por SHA, no con :latest, con imagePullPolicy: IfNotPresent y tags inmutables.
  • Ante un ImagePullBackOff, lee el mensaje de kubectl describe: distingue un token inválido (Access denied) de un tag inexistente (manifest unknown).
  • Limpia el registro (reglas de caducidad de tags) y reduce el tamaño de las imágenes: menos almacenamiento facturado y arranques más rápidos.

¿Un despliegue atascado en el pull de una imagen, un clúster que hay que industrializar o una cadena GitLab CI/CD que retomar? Cuéntanos tu contexto — nuestro equipo trabaja en estos temas a diario desde Pontault-Combault.