Toda la evidencia
KubernetesGitOpsArgo CDGitLab CI

Entornos efímeros por rama con GitOps y cero credenciales estáticas

Resumen

Ahora cada merge request tiene su propio entorno en vivo: un namespace, una URL y un certificado TLS, que se crean al abrir la MR y se destruyen cuando se fusiona o se cierra. Quien revisa hace clic en un enlace en lugar de descargarse una rama, y nada en el flujo de entrega guarda una clave de AWS de larga duración.

Lo interesante no es que existan entornos de previsualización, sino que se reconcilian en lugar de crearse con scripts: el conjunto de entornos en marcha se deriva del conjunto de MR abiertas, así que un webhook perdido o un pipeline fallido no pueden dejar ninguno olvidado.

  • Creación: de la apertura de la MR a un entorno sirviendo HTTPS en unos 4,5 minutos en p50 y 8 minutos en p95.
  • Eliminación: de la fusión o cierre de la MR a la desaparición del namespace, el certificado y los registros DNS en unos 1,5 minutos en p50 y 3 minutos en p95.
  • Volumen: unos 340 entornos creados desde la puesta en marcha en febrero de 2026, aproximadamente 9 a la semana.
  • Fiabilidad de la limpieza: cero namespaces huérfanos y cero registros de Route53 huérfanos en la auditoría de octubre de 2026.
  • Credenciales: cero claves estáticas de AWS. cert-manager intercambia un token de service account de Kubernetes por credenciales STS de corta duración en cada cambio de DNS.

El reto

La revisión era lenta porque solo había un sitio donde ver un cambio funcionando, y todo el mundo lo quería a la vez.

  • Un único entorno de staging compartido. Las ramas hacían cola para usarlo. Desplegar tu MR implicaba sobrescribir la de otra persona, así que a menudo se aprobaba solo mirando el diff.
  • El comportamiento se revisaba al final. Los problemas visuales y de integración aparecían después de fusionar, en dev, donde era más caro deshacerlos y bloqueaban el trabajo de los demás.
  • Los entornos manuales no se limpiaban solos. Cualquier cosa que se levantara a mano sobrevivía a la rama para la que se había creado.
  • Automatizar el DNS implicaba guardar una clave. La forma habitual de dejar que un clúster gestione registros de Route53 es una clave de acceso de un usuario IAM en un Secret de Kubernetes: de larga duración, rara vez rotada y legible por cualquiera que pueda leer ese Secret.

Antes de los entornos de previsualización, la MR mediana tardaba unas 26 horas desde su apertura hasta su aprobación, y aproximadamente un tercio de ese tiempo se iba en esperar turno en staging.

Arquitectura

Dos bucles independientes se encuentran en el tag de la imagen. GitLab CI construye y sube una imagen etiquetada con el commit más reciente de la MR. Argo CD, por su parte, pregunta a GitLab qué MR están abiertas y genera una Application por MR que espera exactamente ese tag.

Ninguno de los dos llama al otro. CI nunca toca el clúster, y Argo CD nunca ejecuta un build. Esa separación es la que permite que el clúster no guarde credenciales de CI y que CI no guarde credenciales del clúster.

El bucle de build y el bucle de despliegue no comparten nada salvo el tag de la imagen
  1. Un desarrollador abre una MR. El pipeline construye la imagen con BuildKit rootless y la sube al GitLab Container Registry como <short-sha>, autenticándose con el propio CI_JOB_TOKEN del job.
  2. El controlador de ApplicationSet detecta la nueva MR en su siguiente consulta o webhook y genera una Application con el número de la MR en el nombre.
  3. Argo CD sincroniza la Application en su propio namespace: Deployment, Service, enrutamiento y un Certificate.
  4. cert-manager resuelve un reto DNS-01 en Route53 con credenciales de corta duración, y el entorno se levanta en mr-<iid>.preview.<domain> con HTTPS.
  5. Cuando la MR se fusiona o se cierra, desaparece del resultado del generador, la Application se borra y su finalizer elimina todo lo que había creado, incluido el namespace.

El clúster es autogestionado (kubeadm), no EKS, lo que importa para entender cómo funcionan las credenciales de AWS en la decisión 3.

Decisión 1: ApplicationSets de Argo CD con el pull request generator

Hicimos que los entornos fueran una función de las MR abiertas, no un efecto secundario de los jobs del pipeline. El pull request generator consulta la API de GitLab para obtener las MR abiertas y produce un conjunto de parámetros por MR; el ApplicationSet convierte cada conjunto en una Application.

La alternativa obvia eran las review apps del propio GitLab: un job de despliegue en los pipelines de MR y un job on_stop para desmontarlo. La descartamos por dos motivos.

  • La eliminación depende de que se ejecute un job. Si el job de parada falla, se cancela o el runner no está disponible cuando se borra la rama, el entorno queda huérfano y nadie se entera.
  • CI necesitaría credenciales del clúster. Un despliegue basado en push implica que cada pipeline guarda un kubeconfig, que es justo el tipo de credencial estática que queríamos eliminar.

Con el generador, el borrado es reconciliación. Una MR cerrada simplemente deja de aparecer en el resultado, y el controlador elimina su Application en la siguiente pasada.

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: previews
  namespace: argocd
spec:
  goTemplate: true
  generators:
    - pullRequest:
        requeueAfterSeconds: 60
        gitlab:
          project: "<project-id>"
          pullRequestState: opened
          tokenRef:
            secretName: gitlab-mr-reader
            key: token
  template:
    metadata:
      name: "preview-mr-{{ .number }}"
      finalizers:
        - resources-finalizer.argocd.argoproj.io
    spec:
      project: previews
      source:
        repoURL: <gitops-repo>
        targetRevision: main
        path: charts/app
        helm:
          parameters:
            - name: image.tag
              value: "{{ .head_short_sha }}"
            - name: host
              value: "mr-{{ .number }}.preview.<domain>"
      destination:
        server: https://kubernetes.default.svc
        namespace: "preview-mr-{{ .number }}"
      syncPolicy:
        automated:
          prune: true
          selfHeal: true

Tres detalles sostienen la mayor parte de la fiabilidad:

  • El namespace lo genera el chart, no CreateNamespace=true. Un namespace creado con esa opción de sincronización no lo rastrea la Application, así que el borrado en cascada lo deja atrás. Generar el objeto Namespace hace que Argo CD sea su propietario, y se borra junto con todo lo demás.
  • El finalizer hace que el borrado sea en cascada. Sin resources-finalizer.argocd.argoproj.io, borrar la Application dejaría huérfanas sus cargas de trabajo.
  • El contrato del tag es exacto. El head_short_sha de Argo CD tiene 8 caracteres, igual que el CI_COMMIT_SHORT_SHA de GitLab, así que CI y Argo CD coinciden en el tag sin hablar entre ellos. Un nuevo push a la MR cambia el tag, y el entorno lo sigue.

Los entornos de previsualización viven en un proyecto de Argo CD dedicado que solo permite destinos preview-mr-*, de modo que una plantilla errónea no puede llegar a los namespaces de dev ni de prod. Un webhook de merge requests de GitLab apunta al controlador de ApplicationSet, así que las MR nuevas y las cerradas se detectan en segundos; la consulta cada 60 segundos es solo un respaldo por si se pierden eventos.

Decisión 2: GitLab CI con BuildKit rootless

Construimos las imágenes con BuildKit rootless porque la respuesta por defecto, Kaniko, ya no tiene upstream. Google archivó el repositorio GoogleContainerTools/kaniko en junio de 2025, y sus imágenes gcr.io/kaniko-project dejaron de recibir actualizaciones. Existe un fork mantenido por Chainguard, pero empezar un pipeline nuevo sobre un fork de un proyecto archivado supone heredar las decisiones de mantenimiento de otros.

OpciónPod privilegiadoEstado en 2025–26Veredicto
Docker-in-DockerSíMantenidoDescartado: pods de runner privilegiados
Kaniko (upstream)NoArchivado en junio de 2025Descartado: sin correcciones de seguridad upstream
Kaniko (fork de Chainguard)NoFork mantenidoDescartado: dependencia del fork de un único proveedor
BuildahNo (con user namespaces)MantenidoViable; menos herramientas de caché de build
BuildKit rootlessNoMantenido, proyecto MobyElegido

BuildKit es el motor que hay detrás de docker build, así que el comportamiento de los Dockerfile coincide con lo que los desarrolladores ven en local. También nos da caché de capas respaldada por el registry, lo que importa cuando cada push a una MR dispara un build.

build:
  image:
    name: moby/buildkit:rootless
    entrypoint: [""]
  variables:
    BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
  before_script:
    - mkdir -p ~/.docker
    - echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(printf '%s:%s' "$CI_REGISTRY_USER" "$CI_JOB_TOKEN" | base64 | tr -d '\n')\"}}}" > ~/.docker/config.json
  script:
    - buildctl-daemonless.sh build
      --frontend dockerfile.v0
      --local context=. --local dockerfile=.
      --output type=image,name=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA,push=true
      --export-cache type=registry,ref=$CI_REGISTRY_IMAGE:buildcache,mode=max
      --import-cache type=registry,ref=$CI_REGISTRY_IMAGE:buildcache

Dos compromisos que hay que reconocer:

  • --oci-worker-no-process-sandbox permite que BuildKit se ejecute sin crear su propio PID namespace, algo que el perfil de seguridad por defecto del pod del runner no permite. Los pasos RUN comparten entonces el namespace de procesos del contenedor de build. Para un pod de build de un solo uso es aceptable; para runners compartidos entre varios equipos merece un análisis más detenido.
  • La credencial del registry es el token del job. CI_JOB_TOKEN solo existe mientras dura el job, así que la subida de la imagen no añade ningún secreto almacenado.

Decisión 3: DNS-01 de cert-manager contra Route53, sin claves de AWS almacenadas

Cada entorno obtiene un certificado real mediante un reto ACME DNS-01, y cert-manager se autentica en AWS con un token de service account de Kubernetes en lugar de con una clave de acceso.

Por qué DNS-01. HTTP-01 necesita que el nuevo hostname sea enrutable antes de que exista el certificado, y ata la emisión al ingress controller que esté sirviendo el reto. DNS-01 solo necesita un registro TXT, así que la emisión no depende del enrutamiento, funciona con nombres comodín y sobrevivió intacta al cambio de ingress controller que describimos más abajo.

Por qué sin claves, y cómo en un clúster que no es EKS. EKS te da IRSA. Un clúster kubeadm no, pero el mecanismo de fondo es OIDC estándar, así que lo reconstruimos:

  1. El issuer de service accounts del API server se configura con una URL HTTPS pública, y allí se publican su documento de descubrimiento y sus claves de firma (/.well-known/openid-configuration y el JWKS).
  2. Esa URL se registra en IAM como proveedor de identidad OIDC.
  3. Un rol IAM confía en ese proveedor, con condiciones que fijan la audiencia a sts.amazonaws.com y el sujeto a la service account de cert-manager.
  4. El ClusterIssuer hace referencia a esa service account. cert-manager solicita para ella un token de corta duración ligado a la audiencia y llama él mismo a sts:AssumeRoleWithWebIdentity (documentación de Route53 de cert-manager).
cert-manager cambia un token de service account de corta duración por credenciales STS temporales
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-dns01
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: letsencrypt-dns01-account
    solvers:
      - dns01:
          route53:
            region: <region>
            hostedZoneID: <preview-zone-id>
            role: arn:aws:iam::<account-id>:role/cert-manager-dns01
            auth:
              kubernetes:
                serviceAccountRef:
                  name: cert-manager-route53

Los permisos del rol están tan acotados como permite Route53. ChangeResourceRecordSets se limita a la zona alojada de previsualización, y las claves de condición de IAM lo restringen a registros TXT cuyo nombre empieza por _acme-challenge.. Un token robado, si alguna vez se filtrara, solo podría escribir registros de reto en una zona, y solo hasta que caduque su sesión de STS.

Un límite que hay que prever. Let's Encrypt permite 50 certificados nuevos por dominio registrado cada 7 días. Se emite un certificado por MR, no por push, así que nuestra semana con más actividad, con 19 MR nuevas, usó menos del 40 % de ese margen. Un equipo que supere unas 40 MR a la semana debería pasar a un único certificado comodín para *.preview.<domain>, algo que DNS-01 ya permite.

Decisión 4: dev sigue a main, prod está fijado a tags

Los entornos de previsualización responden a «¿funciona este cambio?», dev responde a «¿funciona main?», y prod solo ejecuta una versión que alguien ha elegido de forma deliberada. Cada nivel se diferencia en qué sigue y en quién puede moverlo.

Las previsualizaciones siguen la cabeza de las MR, dev sigue a main y prod solo se mueve con una MR de release
EntornoSigueSe actualiza conControl
PrevisualizaciónEl último commit de la MRCada push a la MRNinguno; es desechable
DevLa cabeza de mainCada fusión en mainLa revisión de la MR que se fusionó
ProdUn tag de Git, por ejemplo v1.8.2Una MR que cambia el tag fijadoRama protegida; solo los maintainers pueden fusionar

Una release a prod es un diff de una línea en el repositorio de GitOps: targetRevision pasa de un tag al siguiente. Eso da a cada release un autor, un revisor, una marca de tiempo y un botón para revertirla, y la descripción de la MR es el lugar natural para las notas de la versión.

En GitLab Free, las reglas de aprobación obligatoria son una función de pago, así que el control se aplica con las opciones de protección que Free sí incluye. En el repositorio de GitOps, main está protegida con «Allowed to push» en nadie y «Allowed to merge» en Maintainers. En el repositorio de la aplicación, los tags v* están protegidos para que solo los Maintainers puedan crear un tag de release. Es un control más débil que las aprobaciones obligatorias, y lo aceptamos con conocimiento de causa.

Cómo lidiar con los cambios del ecosistema

Tres de los componentes que habría recomendado un tutorial de 2024 habían desaparecido o no estaban disponibles cuando construimos esto. Diseñar teniendo en cuenta esos cambios acabó siendo tanto trabajo como diseñar el propio sistema.

CuándoQué cambióImpacto para nosotrosRespuesta
Mar. 2026ingress-nginx retirado; sin ningún tipo de corrección másCada ruta de previsualización era un Ingress con anotaciones de nginxEl enrutamiento pasa a Gateway API
Jun. 2025Kaniko archivado en upstreamEl builder rootless por defecto se quedó sin upstreamBuildKit rootless (decisión 2)
ContinuoLos Project Access Tokens requieren Premium en GitLab.comNingún token de proyecto para que Argo CD lea las MRUn token de alcance reducido en un usuario bot dedicado

La retirada de ingress-nginx, y Gateway API como camino a seguir

El SIG Network de Kubernetes y el Security Response Committee anunciaron que el mantenimiento de ingress-nginx se detendría en marzo de 2026, y más tarde el Steering Committee advirtió de que seguir usándolo después de su retirada deja los clústeres expuestos. Cambiar a otro Ingress controller habría funcionado, pero nuestras plantillas dependían de anotaciones específicas de nginx que habría que traducir de todos modos.

Gateway API encaja mejor que Ingress con los entornos por MR. Un Gateway propiedad de la plataforma contiene el listener y el certificado; cada namespace de previsualización solo aporta un HTTPRoute para su hostname, y los allowedRoutes del Gateway deciden qué namespaces pueden engancharse. La separación refleja quién es responsable de qué: la plataforma es dueña de la puerta de entrada y la MR es dueña de su ruta.

Como los certificados se obtienen por DNS-01, el cambio no afecta a cómo se emiten. Sí cambia dónde viven: con un Gateway compartido, los certificados por MR implican un listener por hostname, así que el estado final natural es un único certificado comodín para *.preview.<domain> en un solo listener.

Elegimos Envoy Gateway como implementación. Dev y prod se migraron en mayo de 2026. Los entornos de previsualización son los últimos en migrarse, en noviembre de 2026, porque en el mismo paso pasan al certificado comodín.

GitLab Free y el Project Access Token que falta

El pull request generator necesita un token con read_api para listar las MR. En GitLab.com, los project access tokens requieren Premium o Ultimate; en GitLab autogestionado están disponibles en cualquier plan.

Los deploy tokens cubren la lectura del repositorio y del registry, pero no la API, así que solo resuelven la mitad del problema. Para la otra mitad usamos un personal access token en un usuario bot dedicado que solo es miembro de este proyecto, con alcance read_api y fecha de caducidad. Es la única credencial estática que queda en el sistema, y puede leer metadatos de las MR, nada más.

Resultados

Los entornos están en marcha unos 4,5 minutos después de abrir una MR, y los aproximadamente 340 creados desde febrero de 2026 se limpiaron todos sin dejar atrás ningún namespace ni registro DNS.

Creación, eliminación y la comprobación de huérfanos que demuestra la limpieza
MétricaCómo se mideResultado
Tiempo de creaciónDel created_at de la MR (API de GitLab) a la primera respuesta 200 por HTTPS de la URL de previsualización~4,5 min p50 / ~8 min p95
Tiempo de eliminaciónDel merged_at o closed_at de la MR a que el namespace deja de existir~1,5 min p50 / ~3 min p95
Entornos creadosNúmero de Applications preview-mr-* distintas en el periodo~340 desde febrero de 2026
Pico de entornos simultáneosMáximo de namespaces preview-mr-* en un mismo momento14
Namespaces huérfanosNamespaces preview-mr-* cuya MR ya no está abierta0
Registros DNS huérfanosRegistros TXT _acme-challenge que quedan en la zona de previsualización0
Credenciales estáticas de AWSClaves de acceso IAM usadas por el clúster0
Ciclo de revisiónMediana desde la apertura de la MR hasta su aprobación, antes y después~26 h antes / ~9 h después

El build de la imagen es la ruta crítica: unos 2,5 minutos con la caché de capas caliente. Como el webhook crea el namespace a los pocos segundos de abrirse la MR, el certificado (unos 90 segundos, sobre todo por la propagación en Route53) se emite en paralelo al build y rara vez suma al total. Lo que sí añade tiempo es que el pod se programa antes de que exista su imagen, así que el back-off de descarga de imágenes de kubelet puede sumar hasta dos minutos después del push. Los builds con la caché fría y ese back-off explican la mayor parte del p95.

Merece la pena ejecutar las comprobaciones de huérfanos de forma programada en lugar de una sola vez para un caso de éxito: listar las MR abiertas desde la API de GitLab, listar los namespaces preview-mr-* y los registros de reto, y alertar de la diferencia.

Lecciones y próximos pasos

  • Reconciliar, no programar scripts. Los entornos que existen porque una lista lo dice se limpian solos. Los entornos que existen porque se ejecutó un job necesitan otro job para eliminarlos, y ese job, tarde o temprano, no se ejecutará.
  • La propiedad decide la limpieza. El namespace era el único recurso del que Argo CD no era propietario por defecto, y era el que se habría quedado huérfano. Comprueba qué rastrea realmente tu controlador antes de fiarte del borrado en cascada.
  • La federación también funciona fuera de EKS. IRSA no es más que OIDC. Publicar el issuer y registrarlo en IAM eliminó el secreto más sensible que, de otro modo, guardaría el clúster.
  • Hay que contar con los cambios. Dos herramientas fundamentales cambiaron de estado durante este proyecto. Elegir componentes con un upstream activo y mantener al mínimo la configuración específica de cada proveedor hizo que cada cambio fuera una sustitución acotada.

Próximos pasos:

  • Terminar la migración a Gateway API y pasar los entornos de previsualización a un certificado comodín.
  • Ejecutar la comprobación de huérfanos como un job programado que publique en Mattermost.
  • Añadir un TTL para las MR que se quedan abiertas pero inactivas, para que un borrador olvidado no retenga recursos indefinidamente.

Fuentes