Les quatre lliçons anteriors comparaven eines de CI/CD entre elles. Aquesta no, perquè Docker i Kubernetes no són eines de CI/CD: en són el substrat. Docker apareix dues vegades en qualsevol pipeline modern i amb papers completament diferents —és l'entorn on corre el pipeline i és el format de l'artefacte que es desplega—, i confondre aquests dos papers produeix imatges de producció d'1,2 GB que contenen el compilador, els tests i les credencials del build. Kubernetes, per la seva banda, és el destí més comú avui i canvia la forma del CD: el model de push que Reservalia fa servir contra ECS té un equivalent natural a Kubernetes, però la comunitat es va moure majoritàriament cap a GitOps, que inverteix qui inicia el desplegament. El curs ha fet servir tots dos des del mòdul 2 sense baixar al detall; aquesta lliçó hi baixa. I acaba amb la pregunta que gairebé mai no es fa en veu alta: quan NO necessites Kubernetes.

Contingut

  1. Els dos papers de Docker en un pipeline
  2. BuildKit: memòries cau de muntatge, multietapa i secrets
  3. Memòria cau de capes al registre i builds multiplataforma
  4. Imatges mínimes, no-root, HEALTHCHECK i etiquetes OCI
  5. Per què el digest mana sobre el tag
  6. Construir imatges dins del pipeline: DinD, socket i constructors sense dimoni
  7. Kubernetes com a destí: els objectes mínims
  8. Sondes i el seu paper al rolling update
  9. Rollback: kubectl rollout status i undo
  10. Parametritzar per entorn: Helm i Kustomize
  11. Push davant de pull: per què a Kubernetes triomfa GitOps
  12. Reservalia a Kubernetes, contrastat amb ECS
  13. Kubernetes com a plataforma per als propis executors
  14. Quan NO necessites Kubernetes
  15. Errors Comuns i Consells
  16. Exercicis
  17. Conclusió

  1. Els dos papers de Docker en un pipeline

flowchart LR
    subgraph P["Paper A · entorn del pipeline"]
      R1["Job qualitat<br/>contenidor node:22"]
      R2["Job test<br/>contenidor node:22 + postgres"]
      R3["Job build<br/>contenidor amb buildx"]
    end
    subgraph A["Paper B · format de lartefacte"]
      IM["Imatge de runtime<br/>distroless, no-root, 90 MB"]
      REG["Registre ECR<br/>identificada per digest"]
      DEST["ECS / Kubernetes"]
    end
    R3 -->|"docker build --push"| IM --> REG --> DEST
Paper A: entorn del pipeline Paper B: artefacte desplegable
Què és La imatge on s'executen els steps La imatge que es desplega a producció
Qui la tria L'image: del job, el container: o l'executor El teu Dockerfile
Què conté Compilador, gestor de paquets, eines, CLIs Només el necessari per executar
Mida raonable 500 MB - 1,5 GB, tant li fa Com més petita, millor
Cicle de vida Mor amb el job Viu mesos a producció
Criteri de seguretat Aïllament del job Superfície d'atac mínima
Objectiu Reproductibilitat del build Reproductibilitat del desplegament + seguretat

El paper A és el que dona reproductibilitat al pipeline: un job que corre a node:22.11-bookworm es comporta igual avui que d'aquí a sis mesos, independentment de què tingui instal·lat la màquina que l'executa. És la resposta a l'agent contaminat de la 06-01, i per això totes les eines modernes el fan servir per defecte.

El paper B és l'artefacte immutable de la 02-06. La confusió entre tots dos produeix l'antipatró més comú de tot el tema: fer servir la mateixa imatge per construir i per executar. El resultat és una imatge de producció que inclou el compilador de TypeScript, devDependencies, l'historial de Git i, amb sort, cap fitxer de credencials del build. La separació es fa amb multietapa, que el curs va introduir a la 02-03 i que aquí portem al detall.

  1. BuildKit: memòries cau de muntatge, multietapa i secrets

BuildKit és el motor de construcció modern (per defecte en versions recents de Docker i a buildx). Aporta tres coses que canvien de veritat un pipeline: construcció paral·lela del graf d'etapes, memòries cau de muntatge i secrets que no queden a la imatge.

# syntax=docker/dockerfile:1.7                        # 1 · habilita la sintaxi de BuildKit
# apps/api/Dockerfile — Reservalia

# ---------- etapa de dependències ----------
FROM node:22.11-bookworm AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/
COPY packages/compartit/package.json packages/compartit/
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
    npm ci --prefer-offline                            # 2 · memòria cau persistent entre builds

# ---------- etapa de construcció ----------
FROM deps AS build
COPY . .
RUN --mount=type=cache,target=/app/.tsbuildinfo \
    npm run build --workspace apps/api
RUN npm ci --omit=dev --prefer-offline                 # 3 · deixa només dependències de producció

# ---------- etapa de runtime ----------
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime   # 4
WORKDIR /app
COPY --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/apps/api/dist ./dist

USER nonroot                                            # 5
EXPOSE 3000
ENV NODE_ENV=production

LABEL org.opencontainers.image.source="https://github.com/reservalia/reservalia" \
      org.opencontainers.image.revision="${GIT_SHA}" \
      org.opencontainers.image.licenses="UNLICENSED"    # 6

ENTRYPOINT ["/nodejs/bin/node", "dist/servidor.js"]
  1. La línia # syntax= no és un comentari: li diu a BuildKit quina versió del frontend ha de fer servir, i és el que habilita --mount. Sense ella, aquestes línies fallen.
  2. --mount=type=cache és la millora més rendible i la menys coneguda. La memòria cau d'npm es munta durant el RUN i no queda a la imatge, però persisteix entre builds al constructor. Diferència respecte del truc clàssic de "copiar només package.json primer per aprofitar la memòria cau de capes": aquest funciona encara que el lockfile canviï, perquè no depèn d'invalidar una capa sinó de tenir els tarballs ja descarregats. sharing=locked evita corrupció amb builds concurrents.
  3. npm ci --omit=dev després de construir és el que evita portar devDependencies al runtime. És un estalvi habitual de centenars de megabytes.
  4. Imatge de runtime diferent i mínima: apartat 4.
  5. USER nonroot: apartat 4.
  6. Etiquetes OCI: apartat 4.

I els secrets en temps de construcció, que és on més gent s'equivoca:

# MALAMENT: el token queda a l'historial de la imatge per sempre
ARG NPM_TOKEN
RUN echo "//registre.example/:_authToken=${NPM_TOKEN}" > .npmrc && npm ci && rm .npmrc
#   ↑ esborrar el fitxer després NO l'elimina: la capa anterior el conté

# BÉ: muntatge de secret, no persisteix a cap capa
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --prefer-offline
docker buildx build --secret id=npmrc,src=$HOME/.npmrc -t reservalia/api:$SHA .

La regla és la de la 04-03 amb un mecanisme concret: ARG i ENV amb secrets són visibles amb docker history per a qualsevol que tingui la imatge. Esborrar el fitxer en un RUN posterior no serveix: les capes són immutables i acumulatives. --mount=type=secret és l'única forma correcta.

  1. Memòria cau de capes al registre i builds multiplataforma

En un executor efímer no hi ha memòria cau local de capes: cada build comença de zero. La solució és guardar la memòria cau al registre, que és el que el ci.yml de Reservalia ja feia i aquí expliquem.

docker buildx build \
  --file apps/api/Dockerfile \
  --cache-from type=registry,ref=$ECR/reservalia/api:cache \
  --cache-to   type=registry,ref=$ECR/reservalia/api:cache,mode=max \
  --tag $ECR/reservalia/api:$GIT_SHA \
  --push .
Mode de memòria cau Què guarda Quan
mode=min (per defecte) Només les capes de la imatge final Poc útil amb multietapa: perd les etapes intermèdies
mode=max Capes de totes les etapes, incloses deps i build El que vols gairebé sempre; ocupa més al registre
type=gha El magatzem de memòria cau de GitHub Actions Còmode a Actions; subjecte als seus límits de mida i desallotjament
type=inline La memòria cau va dins de la mateixa imatge Simple; només mode=min a la pràctica

Dos avisos. La memòria cau al registre també cal netejar-la: és una etiqueta més que creix, i amb mode=max creix bastant. I una memòria cau de capes enverinada és un vector real: si algú amb accés d'escriptura al registre substitueix la memòria cau, els teus builds poden incorporar capes alienes. Els permisos d'escriptura sobre el repositori de memòria cau han de ser els mateixos que sobre les imatges (04-03).

Multiplataforma amb buildx, rellevant des que els executors ARM són més barats i els portàtils de desenvolupament són ARM:

docker buildx create --name multi --driver docker-container --use
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag $ECR/reservalia/api:$GIT_SHA \
  --push .

Això produeix una llista de manifestos: una única etiqueta que apunta a dues imatges, i el destí es descarrega la que correspon a la seva arquitectura. Contrapartides honestes: si l'executor és amd64, la variant arm64 es construeix per emulació amb QEMU i pot trigar entre tres i deu vegades més; l'alternativa és construir cada arquitectura en un executor natiu i unir els manifestos amb docker buildx imagetools create, que és més ràpid i més complex. I si la teva aplicació té dependències amb binaris natius, cal provar de veritat a les dues arquitectures: compilar no és funcionar.

  1. Imatges mínimes, no-root, HEALTHCHECK i etiquetes OCI

Base Mida típica Té shell Superfície Quan
node:22 (Debian completa) ~1,1 GB Alta Només per construir
node:22-slim ~200 MB Mitjana Runtime acceptable, fàcil de depurar
node:22-alpine ~130 MB Sí (ash) Baixa Runtime petit; compte amb musl davant de glibc
gcr.io/distroless/nodejs22 ~110 MB No Molt baixa Runtime de producció
scratch 0 No Mínima Binaris estàtics (Go, Rust)

La diferència important no és la mida, és què hi ha a dins. Una imatge distroless no té shell, ni gestor de paquets, ni curl, ni utilitats: si algú aconsegueix execució dins del contenidor, no té amb què treballar. També redueix dràsticament el soroll dels escàners de vulnerabilitats: la majoria dels CVE que apareixen en una imatge Debian completa són en paquets que la teva aplicació no fa servir mai, i aquest soroll és el que fa que els equips deixin de mirar els informes de la 04-03.

Contrapartida real i cal dir-la: depurar en distroless és incòmode. No pots fer kubectl exec -it ... -- sh perquè no hi ha sh. Les respostes: contenidors efímers de depuració (kubectl debug --image=busybox --target=api) que s'adjunten al Pod sense modificar la imatge, i bona observabilitat (03-06) per no necessitar entrar-hi. Molts equips fan servir -slim a staging i distroless a producció, cosa que és un compromís defensable encara que trenca lleugerament la regla del "mateix artefacte a tots els entorns".

Usuari no-root: per defecte un contenidor corre com a root, i encara que l'aïllament del contenidor limita el dany, un procés root que s'escapi té molt més marge. A Kubernetes això es reforça amb la política del Pod:

securityContext:
  runAsNonRoot: true                # el Pod no arrenca si la imatge corre com a root
  runAsUser: 65532
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true      # obliga a declarar volums per al que sigui escrivible
  capabilities: { drop: ["ALL"] }

HEALTHCHECK al Dockerfile és útil amb Docker o Compose; a Kubernetes l'orquestrador l'ignora, perquè fa servir les seves pròpies sondes (apartat 8). Posar-lo no sobra —documenta com es comprova que el procés està sa— però no hi confiïs com a mecanisme en un clúster.

Etiquetes OCI: són metadades estàndard que fan traçable la imatge. org.opencontainers.image.source connecta la imatge amb el seu repositori i revision amb el commit exacte. La pregunta "de quin commit va sortir el que hi ha a producció?" —primera pregunta de qualsevol incident, segons la 03-05— es respon amb docker inspect en comptes d'amb arqueologia.

  1. Per què el digest mana sobre el tag

Ja es va establir a la 02-06; aquí el mecanisme:

# Una etiqueta és un punter MUTABLE
docker push reservalia/api:v1.4.0          # apunta a sha256:aaa...
docker push reservalia/api:v1.4.0          # ara apunta a sha256:bbb... i ningú no se n'assabenta

# Un digest és el hash del manifest: IMMUTABLE per construcció
docker pull reservalia/api@sha256:aaa1b2c3...

Conseqüències pràctiques que cal interioritzar:

  • FROM node:22 al teu Dockerfile no és reproduïble. Aquesta etiqueta apunta a imatges diferents amb el temps. Per a builds veritablement reproduïbles: FROM node:22.11-bookworm@sha256:.... Contrapartida: cal actualitzar-ho, i aquí entra l'automatització de dependències de la 04-02, que també renova digests d'imatges base.
  • Desplegar per etiqueta introdueix una cursa: entre que el pipeline construeix :v1.4.0 i l'orquestrador se la descarrega, aquesta etiqueta pot apuntar a una altra cosa. Per això el cd.yml de Reservalia desplega per digest.
  • La promoció entre entorns és moure un digest, no reconstruir. Staging i producció executen el mateix sha256: exacte, i això és el que fa que "funcionava a staging" signifiqui alguna cosa.
  • Els registres permeten etiquetes immutables (ECR: tag immutability), cosa que impedeix sobreescriure una etiqueta existent. Activa-ho: tanca la porta a un error humà amb conseqüències greus.

  1. Construir imatges dins del pipeline: DinD, socket i constructors sense dimoni

Construir una imatge des d'un job que ja corre en un contenidor és un problema real amb tres solucions i tres perfils de risc diferents. És una de les decisions de seguretat més importants d'un pipeline i gairebé sempre es pren per inèrcia.

Enfocament Com funciona Risc Rendiment
Docker-in-Docker (DinD) Un dimoni Docker complet dins del contenidor del job, en mode privilegiat Alt: --privileged equival a accés al kernel de l'amfitrió; escapar-se del contenidor és factible Memòria cau freda cada vegada excepte muntatge de volum
Socket de l'amfitrió muntat Es munta /var/run/docker.sock de l'amfitrió Molt alt: accés al dimoni de l'amfitrió = root a l'amfitrió, i visibilitat dels contenidors d'altres jobs Bo: memòria cau compartida
Kaniko Construeix en espai d'usuari, sense dimoni Baix Acceptable; memòria cau al registre
Buildah Constructor sense dimoni, pot córrer rootless Baix Bo
BuildKit rootless Dimoni de BuildKit sense privilegis Baix Molt bo; admet --mount=type=cache

El raonament sobre per què això importa: en un executor compartit, un job amb el socket de l'amfitrió muntat pot llistar i manipular els contenidors d'altres jobs, inclosos els que tenen credencials de producció al seu entorn. No cal un atacant extern: n'hi ha prou amb un .gitlab-ci.yml o un flux de treball modificat en un PR. És l'escalada de privilegis més accessible en un CI mal configurat.

# Kaniko en un job de GitLab CI: sense dimoni, sense privilegis
build:
  image:
    name: gcr.io/kaniko-project/executor:v1.23.2-debug
    entrypoint: [""]
  script:
    - /kaniko/executor
        --context "${CI_PROJECT_DIR}"
        --dockerfile "${CI_PROJECT_DIR}/apps/api/Dockerfile"
        --destination "${CI_REGISTRY_IMAGE}/api:${CI_COMMIT_SHA}"
        --cache=true
        --cache-repo "${CI_REGISTRY_IMAGE}/cache"
        --image-name-with-digest-file /tmp/digest.txt   # el digest, per promocionar
  artifacts:
    paths: [ /tmp/digest.txt ]
# BuildKit rootless com a contenidor secundari en un Pod d'agent
containers:
  - name: buildkit
    image: moby/buildkit:v0.16.0-rootless
    args: ["--addr", "unix:///run/user/1000/buildkit/buildkitd.sock", "--oci-worker-no-process-sandbox"]
    securityContext:
      runAsUser: 1000
      seccompProfile: { type: Unconfined }     # requisit de rootless, no privilegi total

Recomanació pràctica: si l'executor és compartit, no facis servir DinD privilegiat ni muntis el socket de l'amfitrió. Kaniko o BuildKit rootless cobreixen el cas normal. DinD és acceptable en executors efímers d'un sol ús i d'un sol equip, on el radi del compromís és el mateix job. I a GitHub Actions amb executors allotjats el problema no es planteja igual, perquè la màquina és efímera i d'un sol job —cosa que és, per cert, un avantatge de disseny dels executors allotjats que no sempre s'aprecia—.

  1. Kubernetes com a destí: els objectes mínims

No cal saber Kubernetes sencer per desplegar-hi. Aquests són els objectes que sí que cal entendre:

flowchart TD
    D["Deployment<br/>estat desitjat: 4 repliques, imatge X"] --> RS["ReplicaSet<br/>un per versio"]
    RS --> P1["Pod"]
    RS --> P2["Pod"]
    SVC["Service<br/>IP estable + balanceig"] --> P1
    SVC --> P2
    ING["Ingress<br/>encaminament HTTP extern"] --> SVC
    CM["ConfigMap<br/>configuracio no sensible"] -.-> P1
    SEC["Secret<br/>credencials"] -.-> P1
Objecte Què és Equivalent mental a ECS
Pod Un o diversos contenidors que comparteixen xarxa i cicle de vida Una tasca
ReplicaSet Manté N Pods idèntics vius — (intern)
Deployment Declara l'estat desitjat i gestiona el reemplaçament creant ReplicaSets Servei ECS
Service IP i DNS estables amb balanceig cap als Pods sans Target group
Ingress Encaminament HTTP extern, TLS, hosts i rutes ALB amb regles
ConfigMap Configuració no sensible Variables d'entorn de la definició de tasca
Secret Dades sensibles (codificades en base64, no xifrades per defecte) Secrets Manager / SSM
# k8s/deployment.yaml — Reservalia API
apiVersion: apps/v1
kind: Deployment
metadata:
  name: reservalia-api
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0          # 1 · no baixar mai de la capacitat actual
      maxSurge: 1                # 1 Pod extra durant el reemplaçament
  selector:
    matchLabels: { app: reservalia-api }
  template:
    metadata:
      labels: { app: reservalia-api }
    spec:
      securityContext:
        runAsNonRoot: true
        seccompProfile: { type: RuntimeDefault }
      containers:
        - name: api
          # 2 · per digest, no per etiqueta
          image: 123456789012.dkr.ecr.eu-west-1.amazonaws.com/reservalia/api@sha256:aaa1b2c3...
          ports: [ { containerPort: 3000 } ]
          envFrom:
            - configMapRef: { name: reservalia-api-config }
            - secretRef:    { name: reservalia-api-secrets }
          resources:
            requests: { cpu: "250m", memory: "256Mi" }   # 3
            limits:   { cpu: "1",    memory: "512Mi" }
          startupProbe:                                   # 4
            httpGet: { path: /salut/viu, port: 3000 }
            failureThreshold: 30
            periodSeconds: 2
          readinessProbe:
            httpGet: { path: /salut/llest, port: 3000 }
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet: { path: /salut/viu, port: 3000 }
            periodSeconds: 10
            failureThreshold: 3
          lifecycle:
            preStop:                                      # 5
              exec: { command: ["sleep", "5"] }
      terminationGracePeriodSeconds: 30
  1. maxUnavailable: 0 amb maxSurge: 1 és l'ajust correcte per no perdre capacitat durant el desplegament: primer es crea el Pod nou, s'espera que estigui llest i només llavors se'n retira un de vell. Amb maxUnavailable: 1 (el valor per defecte reparteix d'una altra manera) pots quedar-te per sota de la capacitat necessària en hora punta.
  2. Imatge per digest, pel que s'ha dit a l'apartat 5. A més, amb digest no cal imagePullPolicy: Always: l'identificador ja és únic.
  3. requests i limits no són opcionals. Sense requests, el planificador no sap què reservar i col·loca malament; sense limits, un Pod amb una fuita de memòria pot tombar el node. I un detall que mossega: superar el límit de memòria mata el contenidor (OOMKilled) sense avís, mentre que superar el de CPU només l'alenteix.
  4. Sondes: apartat 8.
  5. preStop amb una pausa curta resol una cursa real: quan un Pod entra en terminació, Kubernetes li envia SIGTERM alhora que el treu dels endpoints del Service, i aquestes dues coses no estan sincronitzades. Sense la pausa, algunes peticions arriben a un Pod que ja s'està tancant. Cinc segons eliminen la majoria dels 502 durant els desplegaments.

  1. Sondes i el seu paper al rolling update

Les tres sondes es confonen constantment i els seus efectes són molt diferents:

Sonda Pregunta que respon Si falla Efecte al rolling update
startupProbe Ha acabat d'arrencar? Reinicia el contenidor Suspèn les altres dues mentre corre: protegeix arrencades lentes
readinessProbe Pot atendre trànsit ara? El treu del Service, sense reiniciar Decisiva: el rollout no avança fins que el Pod nou està llest
livenessProbe Continua viu o està penjat? Reinicia el contenidor Pot provocar reinicis en bucle si està mal configurada

La sonda de disponibilitat és la que governa el desplegament i és la que fa que el rolling update de la 03-04 sigui segur: mentre el Pod nou no respongui 200 a /salut/llest, Kubernetes no retira cap Pod vell. Si l'aplicació arrenca trencada, el rollout es queda aturat amb la versió anterior servint: la fallada es conté sola.

Dos errors de configuració amb conseqüències greus:

La sonda de vida que comprova dependències. Si /salut/viu consulta la base de dades, una caiguda de la base de dades fa fallar la sonda a tots els Pods, Kubernetes els reinicia tots alhora, i a un incident de base de dades li sumes una caiguda total de l'aplicació amb reinicis en bucle. Regla: vida = "el procés respon"; disponibilitat = "puc atendre peticions, incloses les meves dependències".

Sonda de vida sense sonda d'arrencada en aplicacions lentes. Si l'aplicació triga 40 s a arrencar i la sonda de vida comença als 10 s amb llindar de 3 fallades, el contenidor es reinicia abans d'acabar d'arrencar, per sempre. La startupProbe existeix exactament per a això.

  1. Rollback: kubectl rollout status i undo

# Desplegar: canviar la imatge del Deployment (per digest)
kubectl set image deployment/reservalia-api \
  api=$ECR/reservalia/api@sha256:aaa1b2c3... --record

# Esperar i FALLAR si no convergeix: això és el que fa que el job de CD sigui honest
kubectl rollout status deployment/reservalia-api --timeout=5m

# Historial i tornada enrere
kubectl rollout history deployment/reservalia-api
kubectl rollout undo deployment/reservalia-api               # a la revisió anterior
kubectl rollout undo deployment/reservalia-api --to-revision=7

kubectl rollout status amb --timeout és la línia que converteix un desplegament en una verificació: si els Pods nous no arriben a estar llestos, l'ordre retorna error i el job de CD falla, en comptes de donar verd i deixar un rollout encallat que ningú no mira. És exactament el que la 03-02 demanava del desplegament idempotent i verificat.

I el rollback aquí és més ràpid que a ECS: kubectl rollout undo restaura el ReplicaSet anterior, les imatges del qual ja estan descarregades als nodes, així que en segons hi ha Pods vells servint. Els 4 minuts de rollback per digest de Reservalia (03-05) baixarien bastant.

Dos matisos honestos. undo reverteix la plantilla del Pod, no l'estat del món: si el desplegament va incloure una migració de base de dades, revertir la imatge no reverteix la migració, i allà continua manant expand and contract (04-06). I --record està en desús; en un flux GitOps l'historial real és a Git, que és millor lloc que les anotacions de l'objecte.

  1. Parametritzar per entorn: Helm i Kustomize

El mateix Deployment ha d'existir a dev, staging i producció amb diferent nombre de rèpliques, diferents recursos i diferent configuració. Dos enfocaments dominants.

Helm — plantilles amb variables:

# helm/reservalia-api/templates/deployment.yaml
spec:
  replicas: {{ .Values.repliques }}
  template:
    spec:
      containers:
        - name: api
          image: "{{ .Values.imatge.repositori }}@{{ .Values.imatge.digest }}"
          resources:
            {{- toYaml .Values.recursos | nindent 12 }}
# helm/reservalia-api/values-produccio.yaml
repliques: 6
recursos:
  requests: { cpu: "500m", memory: "512Mi" }
  limits:   { cpu: "2",    memory: "1Gi" }
helm upgrade --install reservalia-api ./helm/reservalia-api \
  --namespace produccio \
  --values helm/reservalia-api/values-produccio.yaml \
  --set imatge.digest="sha256:aaa1b2c3..." \
  --atomic \            # si falla, reverteix automàticament a l'estat anterior
  --wait \              # espera que els recursos estiguin llestos
  --timeout 10m

--atomic --wait és la combinació que converteix helm upgrade en un desplegament amb rollback automàtic incorporat: si els Pods no arriben a estar llestos dins del termini, Helm desfà el canvi. És la línia més valuosa de l'ordre.

Kustomize — superposicions sobre una base, sense plantilles:

# k8s/overlays/produccio/kustomization.yaml
resources: [ ../../base ]
namespace: produccio
replicas:
  - name: reservalia-api
    count: 6
images:
  - name: reservalia/api
    digest: sha256:aaa1b2c3...
patches:
  - path: recursos.yaml
    target: { kind: Deployment, name: reservalia-api }
configMapGenerator:
  - name: reservalia-api-config
    literals: [ "LOG_LEVEL=info", "ENTORN=produccio" ]
kubectl apply -k k8s/overlays/produccio
Helm Kustomize
Mecanisme Plantilles Go + valors Pedaçat declaratiu de YAML
Els fitxers base són… Plantilles: no són YAML vàlid YAML vàlid i aplicable tal com està
Corba d'aprenentatge Mitjana-alta (funcions, nindent, condicionals) Baixa
Distribuir a tercers Excel·lent: repositoris de charts, versionat Pobra
Instal·lar programari de tercers Estàndard de facto Poc pràctic
Gestió d'estat Guarda releases i historial; helm rollback Cap: l'estat és el que hi ha al clúster
Llegibilitat en créixer Empitjora: lògica a les plantilles Bona, fins que hi ha overlays d'overlays
Integrat a kubectl No Sí (-k)

A la pràctica, molts equips fan servir tots dos: Helm per instal·lar programari de tercers (ingress, monitoratge, operadors) i Kustomize per a les seves pròpies aplicacions. És una combinació defensable i molt comuna. L'antipatró que cal evitar és un chart propi amb tantes condicionals que cal llegir plantilles Go per saber què es desplega.

  1. Push davant de pull: per què a Kubernetes triomfa GitOps

El cd.yml de Reservalia fa servir push: el pipeline té credencials de l'entorn i executa el desplegament.

flowchart LR
    subgraph PUSH["Model push · el cd.yml de Reservalia"]
      CI1["Pipeline"] -->|"credencials de prod"| K1["Cluster / ECS"]
    end
    subgraph PULL["Model pull · GitOps"]
      CI2["Pipeline"] -->|"commit: nou digest"| G["Repositori de desplegaments"]
      AG["Agent dins del cluster<br/>Argo CD / Flux"] -->|"llegeix cada 3 min"| G
      AG -->|"aplica des de dins"| K2["Cluster"]
    end
Push Pull (GitOps)
Qui inicia El pipeline Un agent dins del clúster
Credencials del clúster Al CI No surten mai del clúster
Estat desitjat Implícit a l'últim desplegament Explícit a Git
Deriva de configuració Invisible Detectada i corregida
Rollback Tornar a executar amb el digest anterior git revert
Auditoria Logs del pipeline Historial de Git
Multiclúster Un job per clúster Un agent per clúster, mateixa font
Complexitat Baixa Mitjana: un altre component per operar
Latència del desplegament Immediata Segons a minuts (o immediata amb webhook)

Les dues raons per les quals GitOps va guanyar a Kubernetes, i cap no és moda:

1. Les credencials d'administrador d'un clúster són massa poderoses per deixar-les al CI. kubectl apply requereix permisos amplis; si el CI els té, comprometre el CI és comprometre el clúster. Amb GitOps el pipeline només necessita permís per fer commit en un repositori, que és un privilegi molt menor.

2. Kubernetes és declaratiu, i això encaixa de manera natural amb Git. L'estat desitjat és un conjunt de manifestos; posar-los a Git i que un agent els reconciliï contínuament és l'extensió òbvia. I porta una capacitat que el push no té: detecció de deriva. Si algú fa kubectl edit a les tres de la matinada durant un incident, l'agent ho detecta i ho reverteix o ho assenyala. El "per què producció no és el que diu el repositori?" deixa d'existir.

# El pipeline a GitOps: no desplega, escriu l'estat desitjat
actualitzar-manifestos:
  needs: [publicar]
  script:
    - git clone https://github.com/reservalia/desplegaments.git && cd desplegaments
    - |
      cd overlays/produccio
      kustomize edit set image reservalia/api@${DIGEST}
    - git commit -am "api: ${DIGEST} (des de ${CI_COMMIT_SHA})"
    - git push
    # Aquí acaba el pipeline. Argo CD detecta el commit i aplica.
# L'Application d'Argo CD, que viu al clúster
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: { name: reservalia-api-produccio }
spec:
  source:
    repoURL: https://github.com/reservalia/desplegaments.git
    path: overlays/produccio
    targetRevision: main
  destination: { server: https://kubernetes.default.svc, namespace: produccio }
  syncPolicy:
    automated: { prune: true, selfHeal: true }   # selfHeal: corregeix la deriva
    retry: { limit: 3 }

Contrapartides honestes de GitOps: hi ha un component més per operar i actualitzar; el desplegament deixa de ser immediat llevat que configuris un webhook; el rastre d'un canvi travessa dos repositoris, cosa que complica respondre "quin commit de l'aplicació hi ha a producció?" si no s'automatitza bé; i no resol les estratègies de desplegament per si sol —per a canari o blue-green calen Argo Rollouts o Flagger, que és una altra peça més—. La 05-03 ja ho va introduir en el context de microserveis; aquí en queda el perquè.

  1. Reservalia a Kubernetes, contrastat amb ECS

Exercici de contrast, no de migració: Reservalia funciona bé a ECS i la 06-07 dirà per què això importa.

Aspecte ECS Fargate (avui) Kubernetes (hipotètic)
Definició del servei Task definition (JSON) + Service Deployment + Service + Ingress
Qui opera el pla de control AWS, invisible AWS (EKS) o tu; visible en qualsevol cas
Rolling update Natiu del servei ECS Natiu del Deployment
Canari al 10 % Pesos al listener de l'ALB Argo Rollouts / Flagger / malles de serveis
Rollback Tornar a desplegar el digest anterior: ~4 min kubectl rollout undo: segons
Configuració SSM Parameter Store a la task definition ConfigMap + Secret (o operador de secrets externs)
Autoescalat Application Auto Scaling HPA (+ Karpenter o Cluster Autoscaler per als nodes)
Cost d'operació Baix Mitjà-alt: actualitzacions del clúster, complements, CRDs
Coneixement necessari a l'equip Baix Alt
Portabilitat entre núvols Nul·la Alta (amb matisos: els complements solen ser específics)

El que Reservalia guanyaria: rollback gairebé instantani, portabilitat, un ecosistema enorme d'operadors, millors eines de desplegament progressiu, i GitOps amb la reducció de privilegis del CI que comporta.

El que pagaria: un clúster que cal actualitzar diverses vegades l'any, complements que cal mantenir (ingress, gestor de certificats, escalat, observabilitat), l'operació d'Argo CD, i una corba d'aprenentatge real per a un equip de tres persones. En Diego preguntaria quant costa i tindria raó: amb 340 negocis i ~9.000 cites al mes, la resposta honesta és que el rollback de 4 minuts que ja tenen no és el coll d'ampolla de res.

Quan canviaria la resposta: si apareguessin els cinc serveis de la 05-03 amb equips independents, si hi hagués un requisit multinúvol, o si l'equip de plataforma creixés prou perquè operar el clúster no surti del temps de producte.

  1. Kubernetes com a plataforma per als propis executors

Un ús que de vegades s'oblida: el clúster pot allotjar els agents de CI, no només l'aplicació. Ja va aparèixer amb el plugin de Kubernetes de Jenkins (06-01) i amb l'executor kubernetes de GitLab (06-02); a GitHub Actions és ARC (Actions Runner Controller), que la 06-06 detalla.

# Un agent efímer: es crea en començar el job, es destrueix en acabar
apiVersion: v1
kind: Pod
spec:
  restartPolicy: Never
  containers:
    - name: runner
      image: node:22-bookworm
      resources:
        requests: { cpu: "1", memory: "2Gi" }
        limits:   { cpu: "2", memory: "4Gi" }
    - name: buildkit
      image: moby/buildkit:v0.16.0-rootless
      securityContext: { runAsUser: 1000 }

Què aporta: entorn net per job —adeu a l'agent contaminat—, autoescalat de franc (ho fa el clúster), aprofitament de capacitat ociosa, i aïllament per espai de noms entre equips.

Què costa, amb honestedat: un Pod triga desenes de segons a arrencar i descarregar imatges, cosa que empitjora el temps de cua si no es cuida (imatges precarregades als nodes, o un petit grup de Pods calents); la memòria cau ja no persisteix per definició, així que cal recolzar-se en memòries cau remotes; i continua sent un clúster per operar. No executis mai agents de CI al mateix clúster que producció sense aïllament fort: un job és codi arbitrari, i compartir nodes amb producció converteix qualsevol escapada en un incident greu.

  1. Quan NO necessites Kubernetes

Aquest apartat és tan important com els anteriors i s'omet massa vegades.

Probablement no el necessites si:

  • Tens menys de cinc serveis. Kubernetes resol la coordinació de molts serveis; amb pocs, aporta sobretot complexitat. Reservalia n'és l'exemple.
  • El teu equip no té algú que sàpiga operar-lo. Un clúster mal operat és pitjor que ECS o que màquines virtuals ben operades. I "sap fer servir kubectl" no és el mateix que "sap operar un clúster": actualitzar versions, gestionar CRDs, diagnosticar xarxa i emmagatzematge, revisar polítiques de seguretat.
  • La teva càrrega és previsible i modesta. L'autoescalat fi de Kubernetes brilla amb pics; amb càrrega estable, un servei gestionat fa el mateix amb menys peces.
  • Ja tens un servei gestionat que funciona (ECS, Cloud Run, App Runner, App Service). Migrar costa mesos i cal poder anomenar quin problema concret resol.
  • La raó principal és "portabilitat entre núvols". És real però sol ser teòrica: el clúster és portable, però la base de dades gestionada, el balancejador, l'emmagatzematge d'objectes, la gestió d'identitats i els complements específics no ho són. La portabilitat que s'aconsegueix és parcial i es paga cara.

Sí que el necessites quan: tens molts serveis i diversos equips que necessiten autonomia (05-03); necessites desplegament progressiu sofisticat i capacitats que només existeixen en aquest ecosistema; la teva càrrega és molt variable i l'empaquetatge eficient de contenidors estalvia diners de veritat; tens requisits d'on-premise o multinúvol reals i pressupostats; o ja tens la plataforma i l'equip, cas en què el cost marginal d'una aplicació més és baix.

La regla, que és la mateixa que va tancar el mòdul 5: la complexitat es justifica amb un problema concret que resol, no amb el fet que altres la facin servir.

Errors Comuns i Consells

Fer servir la mateixa imatge per construir i per executar. Produeix imatges enormes amb compiladors i credencials. Multietapa sempre.

Secrets amb ARG o ENV. Queden a l'historial de la imatge encara que esborris el fitxer després. --mount=type=secret.

FROM node:22 sense fixar. El build no és reproduïble. Fixa la versió i, en entorns sensibles, el digest, amb automatització que ho actualitzi.

mode=min a la memòria cau de registre amb multietapa. Perd les etapes intermèdies, que són justament les cares. mode=max.

Muntar el socket de Docker de l'amfitrió en jobs de CI. Equival a donar root de l'amfitrió a qualsevol PR. Kaniko o BuildKit rootless.

Sonda de vida que comprova la base de dades. Converteix un incident de base de dades en una caiguda total amb reinicis en bucle. Vida = procés; disponibilitat = dependències.

Sense startupProbe en aplicacions lentes. Bucle de reinicis permanent que sembla un bug de l'aplicació.

Sense requests ni limits. Planificació dolenta, i una fuita de memòria tomba el node. Recorda que superar el límit de memòria mata el contenidor sense avís.

Desplegar per etiqueta en comptes de per digest. Introdueix una cursa i trenca la garantia del "mateix artefacte a tots els entorns".

kubectl apply sense kubectl rollout status --timeout. El job dona verd amb el rollout encallat. Verd fals (04-04) en la seva versió més cara.

Secrets de Kubernetes tractats com a xifrats. Estan en base64, no xifrats. Activa el xifratge en repòs d'etcd i fes servir un operador de secrets externs.

Adoptar Kubernetes per defecte. És la decisió d'arquitectura més cara que es pren amb menys anàlisi.

Exercicis

Exercici 1. La imatge d'apps/api pesa 1,3 GB, triga 6 minuts a construir-se al CI, l'escàner reporta 180 CVE i l'equip va descobrir un token d'npm visible amb docker history. Reescriu el Dockerfile explicant què corregeix cada canvi i quina millora esperes en mida, temps i troballes.

Exercici 2. Reservalia desplega a Kubernetes amb kubectl set image des del cd.yml, amb un kubeconfig d'administrador guardat com a secret del CI. Dissenya la migració a GitOps amb Argo CD: estructura de repositoris, què fa el pipeline després, quins permisos queden al CI, com es fa el rollback, i què es perd respecte del model actual.

Exercici 3. Una startup de quatre persones amb un monòlit Node i una base de dades PostgreSQL, ~200 usuaris i creixement lent, proposa migrar a Kubernetes "per estar preparats per escalar". Escriu la resposta tècnica: quines preguntes cal fer abans, quins costos reals té, quines alternatives hi ha i sota quines condicions canviaria la recomanació.

Solucions

Solució 1.

# syntax=docker/dockerfile:1.7

FROM node:22.11-bookworm AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/
COPY packages/compartit/package.json packages/compartit/
# El token va muntat: NO queda a cap capa
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    --mount=type=cache,target=/root/.npm,sharing=locked \
    npm ci --prefer-offline

FROM deps AS build
COPY . .
RUN npm run build --workspace apps/api
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
    npm ci --omit=dev --prefer-offline

FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/apps/api/dist ./dist
USER nonroot
ENV NODE_ENV=production
EXPOSE 3000
LABEL org.opencontainers.image.source="https://github.com/reservalia/reservalia" \
      org.opencontainers.image.revision="${GIT_SHA}"
ENTRYPOINT ["/nodejs/bin/node", "dist/servidor.js"]
Canvi Què corregeix Efecte esperat
Etapa runtime distroless separada La imatge final portava compilador, devDependencies i fonts 1,3 GB → ~120 MB
npm ci --omit=dev abans de copiar devDependencies a producció Part de l'estalvi anterior
--mount=type=secret per al .npmrc Token visible a docker history Desapareix de la imatge
--mount=type=cache per a ~/.npm Descàrrega completa a cada build 6 min → 2-3 min amb memòria cau calenta
COPY dels manifestos abans que el codi Qualsevol canvi invalidava la instal·lació Capa de dependències reutilitzada entre commits
Base distroless + USER nonroot 180 CVE, la majoria en paquets del sistema sense fer servir ~180 → 5-15, i aquests sí que són accionables
Etiquetes OCI Sense traçabilitat imatge → commit docker inspect respon "d'on ha sortit això?"

Acció imprescindible que no és al Dockerfile: rotar el token d'npm. Va estar a l'historial de totes les imatges publicades i qualsevol amb accés al registre el va poder llegir. Canviar el Dockerfile evita repetir-ho; no repara el que ha passat. És exactament la lliçó de la 06-04, i la resposta de fons és la mateixa: si el token pot substituir-se per publicació federada, millor que rotar-lo.

Advertiment sobre distroless: no hi ha shell, així que l'equip necessita kubectl debug i bona observabilitat abans d'adoptar-lo, o el primer incident serà desagradable.

Solució 2.

Estructura de repositoris — dos, deliberadament separats:

reservalia/reservalia          → codi de l'aplicació, CI, Dockerfile
reservalia/desplegaments       → estat desitjat del clúster
  base/
    deployment.yaml  service.yaml  ingress.yaml  kustomization.yaml
  overlays/
    staging/    kustomization.yaml  recursos.yaml
    produccio/  kustomization.yaml  recursos.yaml

Se separen perquè tenen cicles de vida i permisos diferents: a desplegaments es poden exigir revisors de l'equip de plataforma per a overlays/produccio sense bloquejar el desenvolupament diari.

Què fa el pipeline després:

promocionar:
  needs: [publicar]
  if: github.ref == 'refs/heads/main'
  steps:
    - uses: actions/checkout@v4
      with:
        repository: reservalia/desplegaments
        token: ${{ secrets.TOKEN_DESPLEGAMENTS }}  # únic permís que queda
    - run: |
        cd overlays/staging
        kustomize edit set image reservalia/api=$ECR/reservalia/api@${{ needs.publicar.outputs.digest }}
        git commit -am "staging: api ${{ github.sha }}" && git push

Producció es promociona amb un PR des d'overlays/staging a overlays/produccio —revisat i aprovat—, que substitueix l'aprovació de l'Environment i deixa el mateix rastre d'auditoria, però a Git.

Permisos que queden al CI: només un token amb permís d'escriptura sobre el repositori desplegaments. El kubeconfig d'administrador desapareix del CI, que és el guany principal: comprometre el CI ja no equival a comprometre el clúster, només a poder proposar un canvi que a més passa per revisió a producció.

Rollback: git revert del commit a desplegaments i Argo CD reconcilia en l'interval de sondeig (o immediatament amb webhook). Avantatge afegit: el rollback queda registrat com un commit amb autor i motiu, en comptes de com un job rellançat que ningú no recorda. Per a emergències es conserva kubectl rollout undo com a sortida manual, amb selfHeal desactivat temporalment si cal —i amb la disciplina que després cal arreglar Git, o l'agent revertirà el teu arranjament—.

Què es perd: la immediatesa (segons a minuts, llevat de webhook); la simplicitat d'un sol repositori, i amb ella la resposta directa a "quin commit hi ha a producció?", que ara exigeix mirar dos historials i per tant convé automatitzar amb una anotació al manifest; un component més per operar i actualitzar; i una corba d'aprenentatge per a l'equip. El compte surt a favor sobretot pel punt de les credencials, no per elegància.

Solució 3.

Preguntes abans de respondre res:

  1. Quin problema concret teniu avui? Si la resposta és "cap, és per si de cas", no hi ha cas. Si és "els desplegaments ens fan por" o "no sabem escalar en pics", aquests problemes tenen solucions més barates.
  2. Quina és la càrrega real i la seva variabilitat? Amb 200 usuaris i creixement lent, l'autoescalat no aporta res.
  3. Qui opera el clúster i què deixa de fer mentrestant? Amb quatre persones, és entre mitja i una persona equivalent.
  4. Quin és l'objectiu de disponibilitat? Si no hi ha cap SLO escrit, no hi ha manera de justificar complexitat per disponibilitat.
  5. Què hi ha avui i què falla exactament?

Costos reals, que gairebé mai no es comptabilitzen:

Cost Detall
Pla de control Cost fix mensual (EKS/GKE/AKS) encara que no desplegueu res
Nodes infrautilitzats Un clúster mínim amb redundància són diverses màquines sempre enceses
Complements Ingress, certificats, monitoratge, escalat: tots s'han d'instal·lar i actualitzar
Actualitzacions Diverses versions l'any, amb APIs que es retiren i manifestos que cal tocar
Aprenentatge Setmanes fins a operar amb soltesa; mesos fins a diagnosticar bé
Cost d'oportunitat El més gran: el que aquelles setmanes no es van dedicar al producte

Alternatives, ordenades pel que resolen:

Si el problema és… Solució proporcionada
Desplegaments manuals i amb por CI/CD sobre el que ja hi ha: el mòdul 3 sencer
Entorns no reproduïbles Contenidors + IaC (03-03), sense orquestrador complex
Necessitat d'escalar de tant en tant Servei gestionat de contenidors: ECS Fargate, Cloud Run, App Runner
Caigudes en desplegar Rolling update amb health checks, que aquests serveis ja porten
"Volem aprendre Kubernetes" Formació deliberada, no la producció de l'empresa

Recomanació: no migrar ara. Conteniritzar l'aplicació —això sí, perquè dona reproductibilitat i manté la porta oberta: una aplicació en contenidor amb IaC es mou a Kubernetes més endavant en setmanes, no en mesos— i desplegar en un servei gestionat. Escriure la decisió amb la data i amb les condicions que la revisarien, perquè no es replantegi cada trimestre.

Condicions sota les quals canviaria: arribar a cinc o més serveis amb equips diferents; un requisit contractual d'on-premise o multinúvol; una càrrega amb pics de més d'un ordre de magnitud on l'empaquetatge estalviï diners mesurables; contractar algú amb experiència real d'operació; o necessitar capacitats de l'ecosistema (operadors, desplegament progressiu avançat) que el servei gestionat no ofereixi.

I l'argument que sol tancar la conversa: "estar preparats per escalar" és la justificació més cara de l'enginyeria. Amb 200 usuaris, el risc real no és no poder escalar —això s'arregla quan passa, i amb diners—, és gastar tres mesos de l'equip en infraestructura mentre el producte no avança. La preparació efectiva és conteniritzar i tenir infraestructura com a codi; l'orquestrador es tria quan el problema existeixi.

Conclusió

Docker i Kubernetes no competeixen amb les eines de les lliçons anteriors: hi són a sota i al davant. Docker apareix en dos papers que cal mantenir separats —entorn reproduïble del pipeline i artefacte immutable que es desplega—, i la separació es materialitza amb multietapa, imatges de runtime mínimes, usuari no-root i secrets muntats que no deixen rastre a l'historial. BuildKit hi afegeix el que de veritat accelera un pipeline en executors efímers: memòries cau de muntatge que sobreviuen als canvis de lockfile i memòria cau de capes al registre amb mode=max. I la decisió de com construir imatges dins d'un contenidor —DinD privilegiat, socket de l'amfitrió, o constructors sense dimoni— és una decisió de seguretat de primer ordre que se sol prendre per inèrcia.

A Kubernetes, el que cal dominar per desplegar és un conjunt petit: Deployment, Service, Ingress, ConfigMap, Secret, i sobretot les sondes, perquè la de disponibilitat és la que fa que el rolling update de la 03-04 sigui segur i la de vida mal configurada és la que converteix un incident en una caiguda total. kubectl rollout status --timeout és la línia que impedeix el verd fals, i undo dona el rollback més ràpid del curs. Per parametritzar per entorn, Helm i Kustomize resolen el mateix de manera diferent i conviuen bé. I GitOps va guanyar per una raó concreta i no per moda: treu les credencials del clúster fora del CI i converteix l'estat desitjat en una cosa explícita, versionada i amb detecció de deriva.

L'apartat que convé recordar més temps és l'últim: Kubernetes és la decisió d'arquitectura més cara que es pren amb menys anàlisi. Reservalia està millor a ECS avui, i saber justificar això és tan valuós com saber escriure un Deployment.

L'última eina del recorregut és la que l'alumne fa cinc mòduls que fa servir. GitHub Actions a fons tanca els buits que van quedar: contextos i expressions, tots els disparadors que faltaven i la diferència crítica entre pull_request i pull_request_target, els permisos del GITHUB_TOKEN i OIDC, els tres tipus d'action amb el codi complet d'una escrita en JavaScript, matrius dinàmiques, executors autoallotjats amb autoescalat, els límits reals del servei i com depurar-lo. Després, la 06-07 posarà les sis eines a la mateixa taula i donarà els criteris per triar.

Curs de CI/CD: Integració i Desplegament Continu

Mòdul 1: Introducció al CI/CD

Mòdul 2: Integració Contínua (CI)

Mòdul 3: Desplegament Continu (CD)

Mòdul 4: Pràctiques Avançades de CI/CD

Mòdul 5: Implementació de CI/CD en Projectes Reals

Mòdul 6: Eines i Tecnologies

Mòdul 7: Exercicis Pràctics

Mòdul 8: Recursos Addicionals

© Copyright 2026. Tots els drets reservats