Tota l’evidència
KubernetesGitOpsArgo CDGitLab CI

Entorns efímers per branca amb GitOps i zero credencials estàtiques

Resum

Ara cada merge request té el seu propi entorn en viu: un namespace, una URL i un certificat TLS, que es creen quan s’obre la MR i es destrueixen quan es fusiona o es tanca. Qui revisa fa clic en un enllaç en lloc de baixar-se una branca, i res al flux de lliurament no guarda una clau d’AWS de llarga durada.

El que és interessant no és que existeixin entorns de previsualització, sinó que es reconcilien en lloc de crear-se amb scripts: el conjunt d’entorns en marxa es deriva del conjunt de MR obertes, així que un webhook perdut o un pipeline fallit no en poden deixar cap d’oblidat.

  • Creació: de l’obertura de la MR a un entorn servint HTTPS en uns 4,5 minuts en p50 i 8 minuts en p95.
  • Eliminació: de la fusió o el tancament de la MR a la desaparició del namespace, el certificat i els registres DNS en uns 1,5 minuts en p50 i 3 minuts en p95.
  • Volum: uns 340 entorns creats des de la posada en marxa el febrer del 2026, aproximadament 9 per setmana.
  • Fiabilitat de la neteja: zero namespaces orfes i zero registres de Route53 orfes a l’auditoria d’octubre del 2026.
  • Credencials: zero claus estàtiques d’AWS. cert-manager intercanvia un token de service account de Kubernetes per credencials STS de curta durada a cada canvi de DNS.

El repte

La revisió era lenta perquè només hi havia un lloc on veure un canvi funcionant, i tothom el volia alhora.

  • Un únic entorn de staging compartit. Les branques hi feien cua. Desplegar la teva MR implicava sobreescriure la d’una altra persona, així que sovint s’aprovava només mirant el diff.
  • El comportament es revisava al final. Els problemes visuals i d’integració apareixien després de fusionar, a dev, on era més car desfer-los i bloquejaven la feina dels altres.
  • Els entorns manuals no es netejaven sols. Qualsevol cosa que s’aixequés a mà sobrevivia a la branca per a la qual s’havia creat.
  • Automatitzar el DNS implicava desar una clau. La manera habitual de deixar que un clúster gestioni registres de Route53 és una clau d’accés d’un usuari IAM en un Secret de Kubernetes: de llarga durada, poques vegades rotada i llegible per qualsevol que pugui llegir aquell Secret.

Abans dels entorns de previsualització, la MR mediana trigava unes 26 hores des que s’obria fins que s’aprovava, i aproximadament un terç d’aquest temps se n’anava a esperar torn a staging.

Arquitectura

Dos bucles independents es troben al tag de la imatge. GitLab CI construeix i puja una imatge etiquetada amb el commit més recent de la MR. Argo CD, per la seva banda, pregunta a GitLab quines MR estan obertes i genera una Application per MR que espera exactament aquest tag.

Cap dels dos no crida l’altre. CI no toca mai el clúster, i Argo CD no executa mai un build. Aquesta separació és la que permet que el clúster no guardi credencials de CI i que CI no guardi credencials del clúster.

El bucle de build i el bucle de desplegament no comparteixen res tret del tag de la imatge
  1. Un desenvolupador obre una MR. El pipeline construeix la imatge amb BuildKit rootless i la puja al GitLab Container Registry com a <short-sha>, autenticant-se amb el mateix CI_JOB_TOKEN del job.
  2. El controlador d’ApplicationSet detecta la nova MR a la següent consulta o webhook i genera una Application amb el número de la MR al nom.
  3. Argo CD sincronitza l’Application en el seu propi namespace: Deployment, Service, encaminament i un Certificate.
  4. cert-manager resol un repte DNS-01 a Route53 amb credencials de curta durada, i l’entorn s’aixeca a mr-<iid>.preview.<domain> amb HTTPS.
  5. Quan la MR es fusiona o es tanca, desapareix del resultat del generador, l’Application s’esborra i el seu finalizer elimina tot el que havia creat, inclòs el namespace.

El clúster és autogestionat (kubeadm), no EKS, cosa que importa per entendre com funcionen les credencials d’AWS a la decisió 3.

Decisió 1: ApplicationSets d’Argo CD amb el pull request generator

Vam fer que els entorns fossin una funció de les MR obertes, no un efecte secundari dels jobs del pipeline. El pull request generator consulta l’API de GitLab per obtenir les MR obertes i produeix un conjunt de paràmetres per MR; l’ApplicationSet converteix cada conjunt en una Application.

L’alternativa òbvia eren les review apps del mateix GitLab: un job de desplegament als pipelines de MR i un job on_stop per desmuntar-lo. La vam descartar per dos motius.

  • L’eliminació depèn que s’executi un job. Si el job d’aturada falla, es cancel·la o el runner no està disponible quan s’esborra la branca, l’entorn queda orfe i ningú no se n’assabenta.
  • CI necessitaria credencials del clúster. Un desplegament basat en push implica que cada pipeline guarda un kubeconfig, que és just el tipus de credencial estàtica que volíem eliminar.

Amb el generador, l’esborrat és reconciliació. Una MR tancada simplement deixa d’aparèixer al resultat, i el controlador n’elimina l’Application a la passada següent.

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 detalls sostenen la major part de la fiabilitat:

  • El namespace el genera el chart, no CreateNamespace=true. Un namespace creat amb aquesta opció de sincronització no el rastreja l’Application, així que l’esborrat en cascada el deixa enrere. Generar l’objecte Namespace fa que Argo CD en sigui el propietari, i s’esborra juntament amb tota la resta.
  • El finalizer fa que l’esborrat sigui en cascada. Sense resources-finalizer.argocd.argoproj.io, esborrar l’Application deixaria orfes les seves càrregues de treball.
  • El contracte del tag és exacte. El head_short_sha d’Argo CD té 8 caràcters, igual que el CI_COMMIT_SHORT_SHA de GitLab, així que CI i Argo CD coincideixen en el tag sense parlar entre ells. Un nou push a la MR canvia el tag, i l’entorn el segueix.

Els entorns de previsualització viuen en un projecte d’Argo CD dedicat que només permet destinacions preview-mr-*, de manera que una plantilla errònia no pot arribar als namespaces de dev ni de prod. Un webhook de merge requests de GitLab apunta al controlador d’ApplicationSet, així que les MR noves i les tancades es detecten en segons; la consulta cada 60 segons és només una alternativa per si es perden esdeveniments.

Decisió 2: GitLab CI amb BuildKit rootless

Construïm les imatges amb BuildKit rootless perquè la resposta per defecte, Kaniko, ja no té upstream. Google va arxivar el repositori GoogleContainerTools/kaniko el juny del 2025, i les seves imatges gcr.io/kaniko-project van deixar de rebre actualitzacions. Existeix un fork mantingut per Chainguard, però començar un pipeline nou sobre un fork d’un projecte arxivat implica heretar les decisions de manteniment d’altres.

OpcióPod privilegiatEstat el 2025–26Veredicte
Docker-in-DockerSíMantingutDescartat: pods de runner privilegiats
Kaniko (upstream)NoArxivat el juny del 2025Descartat: sense correccions de seguretat upstream
Kaniko (fork de Chainguard)NoFork mantingutDescartat: dependència del fork d’un únic proveïdor
BuildahNo (amb user namespaces)MantingutViable; menys eines de memòria cau de build
BuildKit rootlessNoMantingut, projecte MobyTriat

BuildKit és el motor que hi ha darrere de docker build, així que el comportament dels Dockerfile coincideix amb el que els desenvolupadors veuen en local. També ens dona memòria cau de capes recolzada pel registry, cosa que importa quan 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 cal reconèixer:

  • --oci-worker-no-process-sandbox permet que BuildKit s’executi sense crear el seu propi PID namespace, cosa que el perfil de seguretat per defecte del pod del runner no permet. Els passos RUN comparteixen llavors el namespace de processos del contenidor de build. Per a un pod de build d’un sol ús és acceptable; per a runners compartits entre diversos equips mereix una anàlisi més detinguda.
  • La credencial del registry és el token del job. CI_JOB_TOKEN només existeix mentre dura el job, així que la pujada de la imatge no afegeix cap secret emmagatzemat.

Decisió 3: DNS-01 de cert-manager contra Route53, sense claus d’AWS emmagatzemades

Cada entorn obté un certificat real mitjançant un repte ACME DNS-01, i cert-manager s’autentica a AWS amb un token de service account de Kubernetes en lloc d’una clau d’accés.

Per què DNS-01. HTTP-01 necessita que el nou hostname sigui encaminable abans que existeixi el certificat, i lliga l’emissió a l’ingress controller que estigui servint el repte. DNS-01 només necessita un registre TXT, així que l’emissió no depèn de l’encaminament, funciona amb noms comodí i va sobreviure intacta al canvi d’ingress controller que descrivim més avall.

Per què sense claus, i com en un clúster que no és EKS. EKS et dona IRSA. Un clúster kubeadm no, però el mecanisme de fons és OIDC estàndard, així que el vam reconstruir:

  1. L’issuer de service accounts de l’API server es configura amb una URL HTTPS pública, i s’hi publiquen el seu document de descobriment i les seves claus de signatura (/.well-known/openid-configuration i el JWKS).
  2. Aquesta URL es registra a IAM com a proveïdor d’identitat OIDC.
  3. Un rol IAM confia en aquest proveïdor, amb condicions que fixen l’audiència a sts.amazonaws.com i el subjecte a la service account de cert-manager.
  4. El ClusterIssuer fa referència a aquesta service account. cert-manager hi sol·licita un token de curta durada lligat a l’audiència i crida ell mateix sts:AssumeRoleWithWebIdentity (documentació de Route53 de cert-manager).
cert-manager canvia un token de service account de curta durada per credencials STS temporals
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

Els permisos del rol estan tan acotats com permet Route53. ChangeResourceRecordSets es limita a la zona allotjada de previsualització, i les claus de condició d’IAM el restringeixen a registres TXT el nom dels quals comença per _acme-challenge.. Un token robat, si mai es filtrés, només podria escriure registres de repte en una zona, i només fins que caduqui la seva sessió d’STS.

Un límit que cal preveure. Let's Encrypt permet 50 certificats nous per domini registrat cada 7 dies. S’emet un certificat per MR, no per push, així que la nostra setmana amb més activitat, amb 19 MR noves, va fer servir menys del 40 % d’aquest marge. Un equip que superi unes 40 MR per setmana hauria de passar a un únic certificat comodí per a *.preview.<domain>, cosa que DNS-01 ja permet.

Decisió 4: dev segueix main, prod està fixat a tags

Els entorns de previsualització responen a «funciona aquest canvi?», dev respon a «funciona main?», i prod només executa una versió que algú ha triat deliberadament. Cada nivell es diferencia en què segueix i en qui el pot moure.

Les previsualitzacions segueixen el cap de les MR, dev segueix main i prod només es mou amb una MR de release
EntornSegueixS’actualitza ambControl
PrevisualitzacióL’últim commit de la MRCada push a la MRCap; és d’un sol ús
DevEl cap de mainCada fusió a mainLa revisió de la MR que es va fusionar
ProdUn tag de Git, per exemple v1.8.2Una MR que canvia el tag fixatBranca protegida; només els maintainers poden fusionar

Una release a prod és un diff d’una línia al repositori de GitOps: targetRevision passa d’un tag al següent. Això dona a cada release un autor, un revisor, una marca de temps i un botó per revertir-la, i la descripció de la MR és el lloc natural per a les notes de la versió.

A GitLab Free, les regles d’aprovació obligatòria són una funció de pagament, així que el control s’aplica amb les opcions de protecció que Free sí que inclou. Al repositori de GitOps, main està protegida amb «Allowed to push» en ningú i «Allowed to merge» en Maintainers. Al repositori de l’aplicació, els tags v* estan protegits perquè només els Maintainers puguin crear un tag de release. És un control més feble que les aprovacions obligatòries, i l’acceptem amb coneixement de causa.

Com gestionar els canvis de l’ecosistema

Tres dels components que hauria recomanat un tutorial del 2024 havien desaparegut o no estaven disponibles quan vam construir això. Dissenyar tenint en compte aquests canvis va acabar sent tanta feina com dissenyar el sistema mateix.

QuanQuè va canviarImpacte per a nosaltresResposta
Març 2026ingress-nginx retirat; sense cap mena de correcció mésCada ruta de previsualització era un Ingress amb anotacions de nginxL’encaminament passa a Gateway API
Juny 2025Kaniko arxivat a upstreamEl builder rootless per defecte es va quedar sense upstreamBuildKit rootless (decisió 2)
ContinuEls Project Access Tokens requereixen Premium a GitLab.comCap token de projecte perquè Argo CD llegeixi les MRUn token d’abast reduït en un usuari bot dedicat

La retirada d’ingress-nginx, i Gateway API com a camí a seguir

L’SIG Network de Kubernetes i el Security Response Committee van anunciar que el manteniment d’ingress-nginx s’aturaria el març del 2026, i més tard l’Steering Committee va advertir que continuar fent-lo servir després de la retirada deixa els clústers exposats. Canviar a un altre Ingress controller hauria funcionat, però les nostres plantilles depenien d’anotacions específiques de nginx que caldria traduir igualment.

Gateway API encaixa millor que Ingress amb els entorns per MR. Un Gateway propietat de la plataforma conté el listener i el certificat; cada namespace de previsualització només aporta un HTTPRoute per al seu hostname, i els allowedRoutes del Gateway decideixen quins namespaces s’hi poden enganxar. La separació reflecteix qui és responsable de què: la plataforma és propietària de la porta d’entrada i la MR és propietària de la seva ruta.

Com que els certificats s’obtenen per DNS-01, el canvi no afecta com s’emeten. Sí que canvia on viuen: amb un Gateway compartit, els certificats per MR impliquen un listener per hostname, així que l’estat final natural és un únic certificat comodí per a *.preview.<domain> en un sol listener.

Vam triar Envoy Gateway com a implementació. Dev i prod s’hi van migrar el maig del 2026. Els entorns de previsualització són els últims a migrar-se, el novembre del 2026, perquè en el mateix pas passen al certificat comodí.

GitLab Free i el Project Access Token que falta

El pull request generator necessita un token amb read_api per llistar les MR. A GitLab.com, els project access tokens requereixen Premium o Ultimate; a GitLab autogestionat estan disponibles en qualsevol pla.

Els deploy tokens cobreixen la lectura del repositori i del registry, però no l’API, així que només resolen la meitat del problema. Per a l’altra meitat fem servir un personal access token en un usuari bot dedicat que només és membre d’aquest projecte, amb abast read_api i data de caducitat. És l’única credencial estàtica que queda al sistema, i pot llegir metadades de les MR, res més.

Resultats

Els entorns estan en marxa uns 4,5 minuts després d’obrir una MR, i els aproximadament 340 creats des del febrer del 2026 es van netejar tots sense deixar enrere cap namespace ni registre DNS.

Creació, eliminació i la comprovació d’orfes que demostra la neteja
MètricaCom es mesuraResultat
Temps de creacióDel created_at de la MR (API de GitLab) a la primera resposta 200 per HTTPS de la URL de previsualització~4,5 min p50 / ~8 min p95
Temps d’eliminacióDel merged_at o closed_at de la MR a quan el namespace deixa d’existir~1,5 min p50 / ~3 min p95
Entorns creatsNombre d’Applications preview-mr-* diferents en el període~340 des del febrer del 2026
Pic d’entorns simultanisMàxim de namespaces preview-mr-* en un mateix moment14
Namespaces orfesNamespaces preview-mr-* la MR dels quals ja no està oberta0
Registres DNS orfesRegistres TXT _acme-challenge que queden a la zona de previsualització0
Credencials estàtiques d’AWSClaus d’accés IAM que fa servir el clúster0
Cicle de revisióMediana des de l’obertura de la MR fins a l’aprovació, abans i després~26 h abans / ~9 h després

El build de la imatge és la ruta crítica: uns 2,5 minuts amb la memòria cau de capes calenta. Com que el webhook crea el namespace pocs segons després d’obrir-se la MR, el certificat (uns 90 segons, sobretot per la propagació a Route53) s’emet en paral·lel al build i poques vegades suma al total. El que sí que afegeix temps és que el pod es programa abans que existeixi la seva imatge, així que el back-off de descàrrega d’imatges de kubelet pot sumar fins a dos minuts després del push. Els builds amb la memòria cau freda i aquest back-off expliquen la major part del p95.

Val la pena executar les comprovacions d’orfes de manera programada en lloc d’una sola vegada per a un cas d’èxit: llistar les MR obertes des de l’API de GitLab, llistar els namespaces preview-mr-* i els registres de repte, i alertar de la diferència.

Lliçons i propers passos

  • Reconciliar, no programar scripts. Els entorns que existeixen perquè una llista ho diu es netegen sols. Els entorns que existeixen perquè es va executar un job necessiten un altre job per eliminar-los, i aquest job, tard o d’hora, no s’executarà.
  • La propietat decideix la neteja. El namespace era l’únic recurs del qual Argo CD no era propietari per defecte, i era el que hauria quedat orfe. Comprova què rastreja realment el teu controlador abans de refiar-te de l’esborrat en cascada.
  • La federació també funciona fora d’EKS. IRSA no és més que OIDC. Publicar l’issuer i registrar-lo a IAM va eliminar el secret més sensible que, altrament, guardaria el clúster.
  • Cal comptar amb els canvis. Dues eines fonamentals van canviar d’estat durant aquest projecte. Triar components amb un upstream actiu i mantenir al mínim la configuració específica de cada proveïdor va fer que cada canvi fos una substitució acotada.

Propers passos:

  • Acabar la migració a Gateway API i passar els entorns de previsualització a un certificat comodí.
  • Executar la comprovació d’orfes com un job programat que publiqui a Mattermost.
  • Afegir un TTL per a les MR que es queden obertes però inactives, perquè un esborrany oblidat no retingui recursos indefinidament.

Fonts