Tutorial

De cero a pipeline: laboratorio de DevOps con IA

🔥 Del prompt a producción: creá una app con IA y llevála hasta deploy con Docker, CI/CD, calidad, seguridad y Blue/Green sin downtime.

🔥 Del prompt a producción: creá una app con IA y llevála hasta deploy con Docker, CI/CD, calidad, seguridad y Blue/Green sin downtime.

Build with fire, Deploy with power · roxs.dev

DevOps Labs

Objetivo del laboratorio

Vas a crear una app mínima con IA y llevarla a producción de forma segura: revisarla, probarla, empaquetarla en Docker, publicarla en un registro y desplegarla sin cortar el servicio. Al terminar tendrás un pipeline que detiene la entrega cuando encuentra un error de código, pruebas, seguridad o rendimiento.

El playground que acompaña esta guía es el oficial docker (una única máquina, docker-01, con Docker ya instalado). A medida que avanzás vas a ver bloques ::simple-task que verifican, sobre esa misma máquina, que el bloque anterior quedó resuelto de verdad — no son decorativos, corren comandos reales.

Cómo usar esta guía

Leé esto antes de tipear nada. Son tres minutos y te ahorra dos horas.

El laboratorio tiene 42 pasos agrupados en once bloques. Todos los comandos se ejecutan dentro de docker-01, en una terminal como el usuario laborant, salvo que un paso indique otra cosa. Cada paso tiene la misma forma: el comando o el archivo, qué deberías ver, y qué hacer si no lo ves. No saltees la parte de «qué deberías ver»: la mitad del aprendizaje está en notar cuando la salida no es la que esperabas.

Al terminar vas a poder mostrar una imagen publicada en un registro local, un pipeline que la valida antes de publicar y un despliegue azul/verde que conserva la versión anterior si la nueva falla.

Vocabulario mínimo

  • Imagen: paquete inmutable con la aplicación y lo necesario para ejecutarla.
  • Contenedor: instancia en ejecución de una imagen.
  • Registro: servidor que guarda y distribuye imágenes, como localhost:5000 en este laboratorio.
  • Pipeline: secuencia automatizada de compuertas que detienen una entrega ante un error.
  • Despliegue azul/verde: dos versiones preparadas para alternar el tráfico solo después de verificar la nueva.

La regla de las dos columnas

La aplicación se genera con un prompt. Todo lo que la rodea — linter, imagen, registro, despliegue, pipeline, pruebas de carga — se escribe a mano. La razón es simple: la app es el pretexto, no el tema. Si el tema fuera la app, el laboratorio se llamaría «Programación», no «Entrega continua y calidad».

Eso no significa que la parte generada sea gratis. Vas a escribir el prompt, vas a revisar lo que devuelve, vas a encontrar dos cosas que le faltan, y vas a escribir un segundo prompt para corregirlas.

Note

Nota. Usar IA no termina al recibir el código. También hay que leerlo, probarlo y corregirlo. Al final vas a poder explicar qué parte generó la IA y qué parte hiciste vos.

El contrato

Para que el Dockerfile, el pipeline y el script de despliegue funcionen igual para todo el curso, la app tiene que cumplir un contrato fijo. La implementación puede variar entre estudiantes; la interfaz no.

Ruta / archivoObligación
src/app.jsExporta createApp(). No llama a listen().
src/server.jsÚnico punto de arranque. Lee PORT del entorno, por defecto 3000.
src/app.test.jsPruebas con node:test. Sin dependencias externas.
GET /200 con JSON que incluya status: "ok", version y color.
GET /healthz200 mientras el proceso viva. Es la señal de «estoy corriendo».
GET /readyz200 si puede atender, 503 mientras se apaga. Es la señal de «mandame tráfico».
Ruta inexistente404.
Señal SIGTERMApagado ordenado en menos de 2 segundos.
Note

Nota. Si no tenés acceso a una IA, usá el código de referencia del final. Podés completar el laboratorio, aunque no vas a practicar la revisión de una respuesta generada.

Bloque A · Preparación del entorno (pasos 1 a 4)

Si algo de acá falla, no sigas: se arrastra hasta el final.

Paso 01 · Verificar herramientas

node --version
docker --version
docker buildx version
git --version
jq --version

Deberías ver: node en v22 o superior y docker en 24 o superior. buildx tiene que responder algo: sin él no funcionan las cachés de build del paso 21.

Si falla

Node viejo: instalalo con nvm en lugar de pelearte con el del sistema. Docker sin permisos: sudo usermod -aG docker $USER y volvé a iniciar sesión — no uses sudo docker durante todo el laboratorio, porque después los archivos generados quedan de root.

Si necesitás Node 22:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
exec $SHELL
nvm install 22 && nvm use 22 && nvm alias default 22

Paso 02 · Crear el proyecto

En docker-01, creá el directorio de trabajo. Desde este punto, quedate en ~/devops-labs salvo que el paso indique otra ruta.

mkdir -p ~/devops-labs/src && cd ~/devops-labs
pwd

Deberías ver: La ruta absoluta terminada en /devops-labs. Todos los comandos de la guía asumen que estás parado ahí.

Paso 03 · Inicializar git

El pipeline va a etiquetar cada imagen con el hash del commit. Sin git no hay versionado, y sin versionado no hay forma de responder «qué código exacto hay adentro de esta imagen».

El entorno ya trae una identidad de Git configurada para el usuario laborant; no necesitás usar git config --global en este proyecto.

git init -b main
printf 'node_modules\ncoverage\njunit.xml\nsbom.json\n*.log\n.env\n' > .gitignore
git add .gitignore && git commit -m "estructura inicial"
git rev-parse --short HEAD

Deberías ver: Siete caracteres hexadecimales. Ese es el identificador que va a viajar hasta la etiqueta de la imagen.

Paso 04 · Reservar los puertos

ss -tlnp 2>/dev/null | grep -E ':(3000|5000|8080)\b' || echo "puertos libres"

Deberías ver: puertos libres. El laboratorio usa 3000 para el router y 5000 para el registro.

Si falla

Si ss muestra algo escuchando en el 3000, 5000 u 8080, es casi siempre un contenedor de una corrida anterior que quedó vivo en docker-01. Buscalo con docker ps y sacalo con docker rm -f <nombre> antes de seguir.

Bloque B · La aplicación con IA (pasos 5 a 11)

Escribir el prompt, revisar la respuesta, encontrar lo que falta, corregir. Vas a hacer esto en dos vueltas. La primera genera una app que funciona y que, cuando la metas en un contenedor, va a tener dos problemas que todavía no podés ver. La segunda los corrige. Hacé las dos: el objetivo del ejercicio es el hallazgo, no el archivo final.

Note

Qué modelo usar. Los prompts de este bloque son agnósticos de herramienta: funcionan igual con DeepSeek, ChatGPT, Claude, GitHub Copilot, Kiro o cualquier otro asistente de IA al que tengas acceso. Usá el que tengas disponible — lo que se evalúa es cómo revisás y corregís la respuesta, no con qué modelo la generaste.

Paso 05 · Prompt A — la app que creés que necesitás

Copiá el prompt tal cual en el modelo que uses (DeepSeek, ChatGPT, Claude, Copilot, Kiro o el asistente de IA que tengas a mano). Fijate en tres decisiones antes de mandarlo: fija los nombres de archivo, prohíbe explícitamente generar el resto, y pide un formato de salida concreto.

Actuá como desarrollador Node.js senior. Generá ÚNICAMENTE el código de una
aplicación HTTP mínima que después voy a contenedorizar y desplegar yo.

NO generes: Dockerfile, .dockerignore, configuración de linter, scripts de
pipeline, archivos de CI, docker-compose ni README. Esos los escribo yo.
Si te dan ganas de agregarlos, no lo hagas.

Stack obligatorio:
- Node.js 22, módulos ESM ("type": "module")
- Express 5 como única dependencia de producción
- Sin ninguna dependencia de desarrollo para pruebas: usá el corredor
  incorporado de Node (node:test) y node:assert/strict

Archivos exactos a entregar, con estas rutas:
1. package.json
2. src/app.js
3. src/server.js
4. src/app.test.js

Contrato que debe cumplir la app:
- src/app.js exporta una función createApp() que devuelve la app de Express.
  Ese archivo NO debe llamar a listen().
- src/server.js importa createApp(), lee el puerto de process.env.PORT
  (por defecto 3000) y levanta el servidor.
- GET / responde 200 con JSON: { status: "ok", message: <texto libre>,
  version: <valor de process.env.APP_VERSION o "dev">,
  color: <valor de process.env.APP_COLOR o "unknown"> }
- GET /healthz responde 200 con JSON { status: "healthy", uptime: <segundos> }
- Cualquier ruta no definida responde 404
- Los logs salen por consola como JSON de una sola línea, no como texto libre

En package.json definí los scripts:
- start  -> node src/server.js
- test   -> node --test --experimental-test-coverage
- lint   -> eslint .          (no generes la configuración del linter)

Las pruebas en src/app.test.js deben levantar la app en un puerto efímero
(listen(0)), consultarla con el fetch global y cerrar el servidor al final.
Cubrí las tres rutas del contrato.

Formato de la respuesta: un bloque de código por archivo, precedido por su
ruta en una línea. Sin explicaciones, sin comentarios de relleno, sin texto
antes ni después.
Note

Criterio. El prompt fija condiciones que vas a necesitar después:

  • app.js no inicia el servidor: así las pruebas pueden levantar la app en un puerto libre, sin usar el 3000.
  • Un bloque por archivo y con su ruta: así podés copiar cada respuesta a su archivo sin adivinar dónde va.
  • Sin paquetes extra para pruebas: usás las herramientas incluidas en Node y mantenés menos dependencias para instalar, actualizar y revisar.

Un buen prompt no pide solo «una app». Define qué archivos entregar, cómo deben comportarse y qué no debe agregarse. Eso permite comprobar la respuesta en los pasos siguientes.

Paso 06 · Guardar los archivos

Estás en una terminal, sin editor gráfico: el código que te devuelve el modelo lo tenés que meter a mano en cada archivo. La forma más confiable es un heredoc, uno por archivo:

cat > package.json << 'EOF'
(pegá acá el contenido completo que te devolvió el modelo)
EOF

Repetí con src/app.js, src/server.js y src/app.test.js. Las comillas simples en 'EOF' son importantes: evitan que la terminal interprete $, backticks u otros caracteres especiales dentro del código pegado.

ls -R src package.json
cat package.json | jq .scripts

Deberías ver: Los cuatro archivos en las rutas exactas del contrato, y los tres scripts. Si el modelo puso todo en un solo archivo o cambió src/ por la raíz, corregilo a mano ahora: el resto de la guía depende de esas rutas.

Si el modelo devolvió todo junto, separalo siguiendo las rutas que encabezaban sus bloques: creá package.json con su bloque y un archivo por cada bloque src/.... La estructura final debe ser exactamente esta:

package.json
src/app.js
src/server.js
src/app.test.js

Paso 07 · Revisar antes de confiar

Antes de instalar nada, comprobá que la respuesta respete el contrato. Este paso existe para detectar errores de diseño y dependencias innecesarias antes de que se propaguen al Dockerfile, al escaneo de seguridad y al pipeline. Abrí los cuatro archivos y marcá cada punto:

  • src/app.js no llama a listen() en ningún lado
  • Las pruebas usan listen(0) y cierran el servidor en el after
  • package.json tiene "type": "module" y ninguna dependencia de test
  • La versión de Express es 5.x, no 4.x
  • Los logs son JSON de una línea, no console.log('Servidor en puerto ' + port)
  • No aparecieron archivos que el prompt prohibía
  • No hay dependencias que no pediste (dotenv, morgan, helmet, nodemon)

Para revisar las dependencias sin leer todo package.json, corré:

npm pkg get dependencies devDependencies

Si algún punto falla, corregilo ahora en el archivo correspondiente o pedile al modelo que lo corrija. Cuando los siete puntos estén resueltos, seguí al paso 08.

Note

Nota. La IA suele agregar paquetes “por las dudas”. Cada paquete extra hay que instalarlo, actualizarlo y revisarlo. Si no lo pediste, sacalo o anotá por qué lo dejás.

Paso 08 · Instalar y probar que arranca

npm install
npm start &
sleep 1
curl -s localhost:3000/ | jq
curl -s localhost:3000/healthz | jq
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/no-existe

Deberías ver: Dos objetos JSON y un 404. La app cumple el contrato visible.

Paso 09 · El primer hallazgo

Ahora medí cuánto tarda en apagarse cuando le mandás la misma señal que le manda Docker.

PID=$(pgrep -f "node src/server.js" | head -1)
time (kill -TERM $PID; while kill -0 $PID 2>/dev/null; do sleep 0.1; done)

Deberías ver: Un tiempo casi instantáneo — pero mirá qué pasó: el proceso murió de golpe, sin cerrar las conexiones abiertas. Si en ese momento había pedidos en vuelo, se cortaron.

Cuando esto corre dentro de un contenedor, docker stop manda SIGTERM y espera diez segundos antes de mandar SIGKILL. Un proceso Node que no registra un manejador de esa señal se comporta de dos maneras según cómo esté arrancado, y ninguna de las dos es la que querés: o muere de golpe cortando pedidos, o queda debajo de un shell que no le reenvía la señal y tarda los diez segundos completos.

El segundo hallazgo es más sutil. Preguntate: cuando el pipeline reemplace esta app por una versión nueva, ¿cómo sabe el router que la nueva ya puede recibir tráfico? /healthz responde 200 desde que el proceso arranca, incluso antes de estar lista, e incluso mientras se está apagando. Necesitás una señal distinta.

Paso 10 · Prompt B — la app que necesitabas

Escribí vos el prompt de corrección antes de mirar el que sigue. Después comparalo. Si el tuyo es más preciso, usá el tuyo.

Sobre el código anterior, aplicá exactamente estos tres cambios. Devolvé los
archivos completos que se modifiquen, con su ruta. No agregues dependencias.

1. Estado de disponibilidad
   Exportá desde src/app.js un objeto mutable `state` con la propiedad
   `ready` en true. Agregá GET /readyz que responda 200 con
   { status: "ready" } cuando state.ready sea true, y 503 con
   { status: "draining" } cuando sea false.
   No toques /healthz: tiene que seguir devolviendo 200 siempre que el
   proceso esté vivo. Son dos señales distintas y no deben coincidir.

2. Apagado ordenado en src/server.js
   Al recibir SIGTERM o SIGINT:
   a) poné state.ready en false y logueá el evento
   b) esperá 500 ms para que el router deje de mandar tráfico
   c) cerrá el servidor con server.close() y salí con código 0
   d) dejá un temporizador de seguridad de 10 s que salga con código 1
      si el cierre se traba; usá .unref() para que no impida el cierre normal

3. Prueba nueva en src/app.test.js
   Agregá un caso que ponga state.ready en false, verifique que /readyz
   devuelve 503, y lo restaure en true al terminar.
Note

Nota. El prompt B no dice solo “arreglá el apagado”. Dice qué debe pasar y en qué orden. Cuanto más claro sea el pedido, más fácil será revisar si el código cumple.

Guardá los archivos que te devuelva el modelo sobrescribiendo src/app.js, src/server.js y src/app.test.js en esas mismas rutas dentro de ~/devops-labs — no los renombres ni los muevas. Usá el mismo truco del heredoc del paso 06 (cat > src/app.js << 'EOF' ... EOF) para pegar cada archivo completo. Si el modelo te devolvió el archivo completo, reemplazá el contenido entero; si te devolvió solo el fragmento cambiado, integralo vos a mano antes de seguir.

Paso 11 · Verificar el contrato completo

npm start &
sleep 1
curl -s localhost:3000/readyz | jq          # { "status": "ready" }
PID=$(pgrep -f "node src/server.js" | head -1)
kill -TERM $PID
sleep 0.2
curl -s localhost:3000/readyz | jq          # { "status": "draining" }  503
sleep 1
curl -s localhost:3000/readyz               # ya no responde: cerró

Deberías ver: La secuencia completa: listo, drenando, cerrado. Esa ventana de medio segundo entre «dejo de estar listo» y «me apago» es la que permite reemplazar una versión sin cortar peticiones.

git add -A && git commit -m "app generada y endurecida"

Bloque C · Instalación de dependencias (pasos 12 y 13)

Corto, pero con una distinción que después define si el build es reproducible.

Paso 12 · Entender el archivo de bloqueo

ls -la package-lock.json
jq '.packages | length' package-lock.json
jq -r '.packages["node_modules/express"].version' package-lock.json

Deberías ver: La cantidad total de paquetes del árbol y la versión exacta de Express. package.json dice ^5.1.0, que es un rango; package-lock.json dice la versión puntual que se instaló. El rango es la intención, el bloqueo es el hecho.

Si falla

Si no existe package-lock.json, corré npm install otra vez. Sin ese archivo, dos builds del mismo commit pueden traer dependencias distintas, y ahí se termina la reproducibilidad.

Paso 13 · Instalación limpia

npm ci instala de forma reproducible: usa el archivo de bloqueo sin cambiarlo. npm install, en cambio, puede resolver una discrepancia y reescribir package-lock.json; por eso sirve para desarrollar, pero no como compuerta del pipeline.

rm -rf node_modules
time npm ci
du -sh node_modules

Deberías ver: La instalación completa en pocos segundos. npm ci borra node_modules, instala exactamente lo que dice el bloqueo, y falla si package.json y package-lock.json no coinciden.

Note

Nota. Anotá el tamaño de node_modules. Lo vas a necesitar en el paso 20, cuando veas cuánto de eso se está enviando al daemon de Docker en cada build.

Bloque D · Validación del código (pasos 14 a 16)

Primer archivo de la columna izquierda: lo escribís vos.

Paso 14 · Crear el eslint.config.js

ESLint 9 abandonó el formato .eslintrc.js; si copiás la configuración de un tutorial viejo, el linter directamente no arranca. Creá eslint.config.js en la raíz de ~/devops-labs con el contenido de abajo. Es una configuración plana: un archivo JavaScript que exporta un arreglo, donde cada elemento aplica a un conjunto de archivos y los últimos pisan a los primeros.

// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';

export default [
  js.configs.recommended,
  {
    files: ['**/*.js'],
    languageOptions: {
      ecmaVersion: 2024,
      sourceType: 'module',
      globals: { ...globals.node }
    },
    rules: {
      'indent': ['error', 2],
      'quotes': ['error', 'single'],
      'semi': ['error', 'always'],
      'eqeqeq': ['error', 'always'],
      'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      'no-console': 'off'
    }
  },
  {
    files: ['loadtest.js'],
    languageOptions: {
      globals: { __ENV: 'readonly', __ITER: 'readonly', __VU: 'readonly' }
    }
  },
  { ignores: ['node_modules/', 'coverage/'] }
];
npm install -D eslint@^9 @eslint/js@^9 globals@^15

Paso 15 · Ejecutar el linter

npm run lint
echo "código de salida: $?"

Deberías ver: Sin salida y código 0, o una lista de errores de formato del código generado. Si hay errores, es normal: el modelo no conocía tus reglas.

npx eslint . --fix
npm run lint
Si falla

«ESLint couldn't find an eslint.config.js file» (el archivo no existe): estás corriendo npm run lint antes de completar el paso 14, o guardaste el archivo en otra carpeta. Confirmá con ls eslint.config.js parado en ~/devops-labs.

«ESLint couldn't find a configuration file» con el archivo presente: quedó un .eslintrc.js del formato viejo, o el archivo nuevo tiene module.exports en vez de export default. Con "type": "module" tiene que ser sintaxis ESM.

Paso 16 · Distinguir las reglas que importan

Rompé una regla de formato y una regla semántica, y compará los mensajes.

// agregá a src/app.js
const sinUsar = 42;
if (process.env.NODE_ENV == 'production') { /* == en lugar de === */ }

Deberías ver: Dos errores: no-unused-vars y eqeqeq. Ninguno de los dos es de estilo. El primero suele indicar código muerto o un olvido; el segundo, una comparación que hace conversión de tipos y produce resultados sorprendentes.

Note

Nota. Las comillas y los espacios se pueden arreglar solos con una herramienta. Errores como una variable sin usar o == pueden ocultar un problema. En este laboratorio vas a ver ambos; en un proyecto real conviene separarlos.

Revertí los dos cambios antes de seguir.

Bloque E · Pruebas unitarias (pasos 17 a 19)

Sin Jest, sin supertest, sin archivo de configuración.

Paso 17 · Ejecutar las pruebas

npm test

Deberías ver: Cuatro pruebas en verde y una tabla de cobertura al final. Node 22 trae el corredor, las aserciones y la cobertura incorporados: no instalaste nada para esto.

Si falla

ERR_MODULE_NOT_FOUND: en ESM los imports relativos llevan la extensión, tiene que ser './app.js' y no './app'. Si el proceso queda colgado al terminar, falta el server.close() en el after.

Note

Nota. Con Node 22 no hace falta instalar Supertest. Las pruebas usan fetch, que ya viene con Node. Así evitás el error TextEncoder is not defined de versiones viejas.

Paso 18 · Agregar el umbral de cobertura

Que las pruebas pasen no dice cuánto del código se ejecutó. Agregá a package.json un script pensado para el pipeline:

"test:ci": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=junit.xml"
npm run test:ci
echo "código de salida: $?"
head -3 junit.xml

Deberías ver: Código 0 y un junit.xml con el resultado en formato estándar. Ese archivo es lo que después el servidor de CI convierte en un reporte navegable.

Important

Compuerta · Pruebas. Pasa si todas las pruebas terminan bien y la cobertura de líneas alcanza 80%. La bandera --test-coverage-lines=80 hace que Node devuelva código 1 si no se llega, sin herramientas adicionales.

Paso 19 · Ver caer la cobertura

// agregá a src/app.js
export function formatearFecha(d) {
  if (!d) return null;
  if (typeof d === 'string') return new Date(d).toISOString();
  return d.toISOString();
}
npm run test:ci; echo "código de salida: $?"

Deberías ver: Todas las pruebas en verde y el comando fallando igual. Es el caso más incómodo de explicar y el más importante de ver: agregaste código que nadie ejecuta nunca, y ninguna prueba se puso roja.

Note

Nota. La cobertura muestra qué líneas se ejecutaron, no si la prueba fue buena. Usala como una señal: si entra código nuevo sin pruebas, el número baja. No escribas pruebas solo para subir el número.

Sacá la función antes de seguir, o escribile una prueba.

Bloque F · Docker multi-stage (pasos 20 a 24)

El bloque más largo, y el que más se nota en el resultado.

Paso 20 · Escribir el .dockerignore

Este es el archivo con mejor relación entre esfuerzo y efecto de todo el laboratorio. Sin él, el cliente de Docker empaqueta el directorio completo — incluido el node_modules que mediste en el paso 13 — y se lo manda al daemon en cada build.

# .dockerignore
node_modules
coverage
junit.xml
sbom.json
.git
.gitignore
*.md
.env*
Dockerfile
.dockerignore
docker-compose*.yml
loadtest.js
# Los scripts se usan desde el host; no deben entrar en la imagen.
*.sh

Deberías ver: En el próximo build, una línea de transferring context de menos de 100 kB en lugar de decenas de megas.

Paso 21 · Escribir el Dockerfile

# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=22-alpine

# --- deps: solo lo que corre en produccion ---
FROM node:${NODE_VERSION} AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

# --- test: linter y pruebas, etapa separada ---
FROM node:${NODE_VERSION} AS test
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run lint
RUN npm run test:ci

# --- runtime: lo minimo para correr ---
FROM node:${NODE_VERSION} AS runtime
ARG APP_VERSION=dev
ARG GIT_SHA=unknown
LABEL org.opencontainers.image.title="devops-labs-app" \
      org.opencontainers.image.version="${APP_VERSION}" \
      org.opencontainers.image.revision="${GIT_SHA}"
ENV NODE_ENV=production APP_VERSION=${APP_VERSION}
WORKDIR /app
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
COPY --chown=node:node src ./src
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "src/server.js"]

Cinco decisiones que conviene poder defender:

  • Copiar primero los manifiestos y después el código. Docker cachea por capa: si copiás todo junto, cualquier cambio en el código invalida la instalación de dependencias y el build vuelve a tardar minutos.
  • --mount=type=cache sobre el caché de npm. Reutiliza los paquetes descargados entre builds sin dejarlos dentro de la imagen.
  • USER node. Las imágenes oficiales de Node traen ese usuario sin privilegios. Correr como root dentro del contenedor no te da nada y amplía lo que un atacante puede hacer si entra.
  • CMD en forma de lista. En forma de cadena, el proceso queda debajo de un shell que no reenvía SIGTERM, y todo el trabajo del paso 10 se pierde.
  • 127.0.0.1 en el healthcheck, no localhost. En algunos entornos localhost resuelve primero a IPv6 y la comprobación falla contra una app que escucha en IPv4.
Note

Nota. La etapa test sirve para comprobar el código. La imagen final sirve para ejecutar la app y no incluye pruebas. Tenerlas separadas hace que la imagen final sea más chica y que Docker reutilice mejor lo que ya construyó.

En el pipeline las pruebas dejan un reporte y pueden correr junto con otras tareas. La etapa test del Dockerfile queda disponible para probar la misma imagen si la necesitás.

Paso 22 · Revisar el Dockerfile con hadolint

docker run --rm -i hadolint/hadolint hadolint - < Dockerfile

Deberías ver: Posiblemente algún aviso sobre fijar la versión de la imagen base con más precisión. Leé cada uno y decidí: algunos se corrigen, otros se documentan. Un linter que se ignora en bloque no aporta nada.

Paso 23 · Construir

export GIT_SHA=$(git rev-parse --short HEAD)

docker build --target test -t devops-labs-app:test .

docker build \
  --build-arg APP_VERSION=1.0.0 \
  --build-arg GIT_SHA=$GIT_SHA \
  -t devops-labs-app:$GIT_SHA .

Deberías ver: El primer build corre linter y pruebas adentro del contenedor. El segundo produce la imagen final. Mirá la línea de transferring context: tiene que ser mucho más chica que en el paso 20.

GIT_SHA vive solo en esta terminal. Si abrís otra o la cerrás antes de usar los pasos siguientes, recuperalo con export GIT_SHA=$(git rev-parse --short HEAD).

Si falla

--mount no reconocido: te falta BuildKit. Poné DOCKER_BUILDKIT=1 delante del comando, o instalá el componente buildx.

Paso 24 · Inspeccionar el resultado

docker images devops-labs-app
docker history devops-labs-app:$GIT_SHA --format '{{.Size}}\t{{.CreatedBy}}' | head -10
docker inspect devops-labs-app:$GIT_SHA --format '{{json .Config.Labels}}' | jq

Deberías ver: El tamaño de la imagen final, las capas ordenadas por peso, y las etiquetas OCI con la versión y el hash del commit. Esas etiquetas son las que te van a permitir, dentro de seis meses, mirar un contenedor en ejecución y saber de qué commit salió.

Bloque G · Pruebas de funcionalidad (pasos 25 a 28)

La imagen construye; ahora hay que ver si se comporta.

Paso 25 · Ejecutar el contenedor

Antes de publicar el puerto 3000 desde Docker, verificá que no siga corriendo el servidor Node de los pasos anteriores. Si aparece una línea, detenelo; si no aparece nada, continuá.

ss -tlnp | grep :3000 || true
pkill -f "node src/server.js" || true
docker run -d --name devops-labs-tmp -p 3000:3000 \
  -e APP_COLOR=prueba devops-labs-app:$GIT_SHA
sleep 8
docker ps --filter name=devops-labs-tmp --format '{{.Names}}\t{{.Status}}'

Deberías ver: Up 8 seconds (healthy). El estado healthy viene del HEALTHCHECK del Dockerfile: Docker está consultando /healthz cada diez segundos por su cuenta.

Si falla

address already in use al bindear el puerto 3000: te quedó vivo el npm start del Bloque B (pasos 08/09/11) — el kill -TERM de esos pasos no llegó a matarlo. Buscalo y cerralo antes de reintentar:

ss -tlnp | grep :3000
pkill -f "node src/server.js"

the container name "/devops-labs-tmp" is already in use: un intento anterior fallido (por ejemplo el error de puerto de arriba) dejó el contenedor creado aunque no haya llegado a arrancar. Sacalo y volvé a correr el docker run:

docker rm -f devops-labs-tmp

Si dice unhealthy, ejecutá docker inspect devops-labs-tmp --format '{{json .State.Health}}' | jq para ver la salida de la última comprobación. La causa más común es localhost resolviendo a IPv6 en el healthcheck.

Paso 26 · Verificar el contrato desde afuera

curl -s localhost:3000/ | jq
curl -s localhost:3000/healthz | jq
curl -s localhost:3000/readyz | jq
curl -s -o /dev/null -w 'ruta inexistente: %{http_code}\n' localhost:3000/nada

Deberías ver: En la primera respuesta, "color": "prueba" y "version": "1.0.0": las variables de entorno y los argumentos de build llegaron hasta la respuesta HTTP. Esa cadena es la que después te va a decir qué versión está atendiendo.

Paso 27 · Comprobar el usuario y el proceso

docker exec devops-labs-tmp whoami
docker exec devops-labs-tmp ps -o pid,user,comm

Deberías ver: node como usuario, y node como el proceso con PID 1. Si el PID 1 fuera sh, la señal de apagado no le llegaría al proceso correcto.

Paso 28 · Medir el apagado

time docker stop devops-labs-tmp
docker logs devops-labs-tmp --tail 3
docker rm devops-labs-tmp

Deberías ver: Menos de dos segundos, y en los logs el evento de shutdown con la señal recibida. Este es el resultado directo del prompt B: sin ese cambio, el comando tardaría los diez segundos completos.

Note

Nota. Ocho segundos parecen poco en una app. Con muchos servicios y muchos despliegues, se convierten en horas. Un apagado rápido reduce espera y evita cortar pedidos.

Bloque H · Registro local (pasos 29 a 31)

Además del registro levantamos el router que vamos a necesitar en el bloque K.

Paso 29 · Escribir la infraestructura

Guardá ambos archivos en la raíz de ~/devops-labs: docker-compose.infra.yml y nginx.conf. El montaje ./nginx.conf:... es relativo al directorio desde el que ejecutás docker compose.

# docker-compose.infra.yml
services:
  registry:
    image: registry:2
    container_name: devops-labs-registry
    ports: ["5000:5000"]
    volumes: ["registry-data:/var/lib/registry"]
    restart: unless-stopped

  router:
    image: nginx:1.27-alpine
    container_name: devops-labs-router
    ports: ["3000:80"]
    volumes: ["./nginx.conf:/etc/nginx/conf.d/default.conf:ro"]
    networks: [devops-labs-net]
    restart: unless-stopped

volumes:
  registry-data:

networks:
  devops-labs-net:
    name: devops-labs-net
# nginx.conf
server {
  listen 80;
  resolver 127.0.0.11 valid=1s ipv6=off;

  location / {
    set $upstream http://devops-labs-active:3000;
    proxy_pass $upstream;
    proxy_connect_timeout 2s;
    proxy_next_upstream error timeout http_502 http_503;
  }
}

La línea que hace todo el trabajo es resolver 127.0.0.11 valid=1s. Nginx normalmente resuelve el nombre del destino una sola vez, al arrancar, y se queda con esa dirección para siempre. Con el resolver explícito — 127.0.0.11 es el DNS interno de Docker — y con la dirección puesta en una variable, vuelve a consultar cada segundo. Eso es lo que va a permitir cambiar de versión sin reiniciar el router.

docker compose -f docker-compose.infra.yml up -d
docker compose -f docker-compose.infra.yml ps

Deberías ver: Los dos contenedores arriba. El router va a devolver 502 por ahora: todavía no hay ninguna app con el alias devops-labs-active. Es el comportamiento correcto.

Paso 30 · Etiquetar y publicar

docker tag devops-labs-app:$GIT_SHA localhost:5000/devops-labs-app:$GIT_SHA
docker push localhost:5000/devops-labs-app:$GIT_SHA

Deberías ver: Las capas subiendo y, al final, el digest sha256:.... Ese digest identifica el contenido exacto de la imagen: dos imágenes con el mismo digest son bit a bit la misma, sin importar cómo se llamen.

Si falla

server gave HTTP response to HTTPS client: el daemon exige TLS. Agregá {"insecure-registries": ["localhost:5000"]} a /etc/docker/daemon.json y reiniciá Docker.

Paso 31 · Consultar el registro

curl -s localhost:5000/v2/_catalog | jq
curl -s localhost:5000/v2/devops-labs-app/tags/list | jq

Deberías ver: El repositorio y la lista de etiquetas. Es la misma API que exponen los registros comerciales: lo que aprendas acá se traslada tal cual.

Note

Nota. Usamos el hash del commit como etiqueta porque identifica un cambio exacto. latest cambia con el tiempo y no dice qué versión tiene. Un número como 1.4.2 sirve para las personas; el hash sirve para encontrar el código.

Bloque I · Pipeline automatizado (pasos 32 a 34)

Primera versión, todavía sin seguridad ni carga.

Paso 32 · Escribir el script

#!/usr/bin/env bash
set -euo pipefail

APP=devops-labs-app
REGISTRY=localhost:5000
GIT_SHA=$(git rev-parse --short HEAD)
VERSION=${1:-$GIT_SHA}
IMAGE="$APP:$GIT_SHA"
REMOTE="$REGISTRY/$APP:$GIT_SHA"
START=$(date +%s)

step() { printf '\n\033[1m> %s\033[0m\n' "$1"; }
fail() { printf '\033[31mCOMPUERTA %s FRENO LA LINEA\033[0m\n' "$1"; exit 1; }

step "01 - Estilo y errores de codigo"
npm run lint || fail "01 (lint)"

step "02 - Pruebas y cobertura"
npm run test:ci || fail "02 (test)"

step "03 - Revision del Dockerfile"
docker run --rm -i hadolint/hadolint hadolint --failure-threshold error - < Dockerfile \
  || fail "03 (hadolint)"

step "04 - Construccion de la imagen"
docker build --build-arg APP_VERSION="$VERSION" --build-arg GIT_SHA="$GIT_SHA" \
  -t "$IMAGE" . || fail "04 (build)"

step "05 - Publicacion en el registro"
docker tag "$IMAGE" "$REMOTE"
docker push "$REMOTE" >/dev/null || fail "05 (push)"

printf '\n\033[32mPipeline OK en %ss - %s\033[0m\n' "$(( $(date +%s) - START ))" "$REMOTE"

Guardá este contenido como pipeline.sh en la raíz de ~/devops-labs (con el heredoc del paso 06: cat > pipeline.sh << 'EOF' ... EOF).

Note

Nota. set -euo pipefail hace que el script se detenga si un comando falla, si falta una variable o si falla parte de una tubería. Así el pipeline no puede mostrar éxito después de un error.

El mensaje final aparece solo si todas las etapas terminaron bien. Por eso es una prueba de éxito y no una lista escrita a mano.

Paso 33 · Ejecutar

chmod +x pipeline.sh
git add -A && git commit -m "pipeline base"
./pipeline.sh 1.0.0

Deberías ver: Las cinco etapas en orden y el tiempo total. Anotá ese número: al final del laboratorio vas a tener ocho etapas y vas a poder comparar cuánto cuesta cada control que agregaste.

Paso 34 · Verificar que puede fallar

echo "const x = 1" >> src/app.js     # sin punto y coma, viola una regla
./pipeline.sh 1.0.1
echo "codigo de salida: $?"
git checkout src/app.js

Deberías ver: El pipeline frenando en la compuerta 01 y devolviendo código 1. Nunca llegó a construir la imagen. Ese código distinto de cero es lo único que un servidor de CI mira para decidir si la ejecución fue exitosa.

Bloque J · Seguridad y rendimiento (pasos 35 a 39)

Tres escaneos distintos y una prueba de carga que puede frenar el despliegue.

Paso 35 · Vulnerabilidades de la imagen

«Escanear seguridad» no es una acción, son varias. Vulnerabilidades conocidas en dependencias y sistema base es una. Secretos filtrados en el código es otra. Inventario de componentes es una tercera. Trivy hace las tres, con comandos distintos.

mkdir -p $HOME/.cache/trivy

docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v $HOME/.cache/trivy:/root/.cache/ \
  aquasec/trivy:latest image \
  --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
  devops-labs-app:$GIT_SHA

echo "codigo de salida: $?"

Deberías ver: La descarga de la base de datos la primera vez, después la tabla de resultados, y código 0 si no hay nada grave con corrección disponible.

Important

Compuerta · Seguridad. Frena si aparece alguna vulnerabilidad de severidad alta o crítica que tenga corrección disponible. Eso hace --ignore-unfixed: no tiene sentido bloquear un despliegue por un problema que todavía nadie puede arreglar.

La diferencia con el manual original es --exit-code 1. Sin esa bandera, Trivy imprime la tabla y termina en 0: el pipeline sigue como si nada, y el escaneo pasa de ser un control a ser un adorno.

Si falla

Si vuelve a descargar la base de datos en cada corrida, no estás montando el caché. En un pipeline real eso son varios minutos por ejecución.

Paso 36 · Secretos e inventario

docker run --rm -v "$PWD":/src aquasec/trivy:latest fs \
  --scanners secret --exit-code 1 /src

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  -v "$PWD":/out aquasec/trivy:latest image \
  --format cyclonedx --output /out/sbom.json devops-labs-app:$GIT_SHA

jq '.components | length' sbom.json

Deberías ver: Ningún secreto, y un sbom.json con varios cientos de componentes. Un SBOM es la lista de todo lo que hay adentro de la imagen en formato legible por máquina: cuando mañana salga una vulnerabilidad nueva, la pregunta operativa no es «¿es grave?» sino «¿la tengo yo, y en qué versiones?». Sin inventario, esa pregunta se responde escaneando todo otra vez.

# .trivyignore — para excepciones
# CVE-2024-XXXXX
# Motivo: la ruta afectada no se ejecuta en esta aplicacion.
# Revisar: 2026-12-01 - responsable: equipo de plataforma
CVE-2024-XXXXX
Note

Nota. Si ignorás un problema, anotá por qué y cuándo lo vas a revisar. Sin esa fecha, la excepción puede quedar olvidada para siempre.

Paso 37 · Escribir la prueba de carga

El manual original usa Apache Bench. ab mide, imprime y sale con código 0 haya pasado lo que haya pasado. Además reporta promedios, que pueden ocultar pedidos lentos. Por eso usamos k6 y miramos el percentil 95: el tiempo que no supera el 95% de los pedidos.

k6 es una herramienta que envía muchos pedidos a la app como si fueran usuarios. En este archivo:

  • stages sube a 20 usuarios durante 10 segundos, los mantiene 20 segundos y los baja en 5 segundos.
  • check cuenta cuántas respuestas devuelven HTTP 200.
  • thresholds define los límites: menos de 1% de errores y 95% de los pedidos por debajo de 200 ms. Si no se cumplen, k6 termina con error y el pipeline se detiene.

Guardá el siguiente contenido como loadtest.js en la raíz de ~/devops-labs.

// loadtest.js
import http from 'k6/http';
import { check } from 'k6';

export const options = {
  stages: [
    { duration: '10s', target: 20 },
    { duration: '20s', target: 20 },
    { duration: '5s',  target: 0 }
  ],
  thresholds: {
    http_req_failed:   ['rate<0.01'],
    http_req_duration: ['p(95)<200']
  }
};

export default function () {
  const res = http.get(`${__ENV.TARGET}/`);
  check(res, { 'responde 200': (r) => r.status === 200 });
}

loadtest.js corre en el runtime de k6, no en Node: usa globals propios como __ENV que ESLint no conoce todavía. Si corrés npm run lint (o el pipeline) y ves '__ENV' is not defined no-undef, agregá a eslint.config.js (paso 14) el bloque de globals de k6 que se muestra ahí, antes de { ignores: [...] }.

Paso 38 · Ejecutar la prueba de carga

En docker-01, --network host permite que el contenedor de k6 llegue al puerto 8080 publicado por devops-labs-carga usando localhost. Es propio de este playground Linux.

docker run -d --name devops-labs-carga -p 8080:3000 devops-labs-app:$GIT_SHA
sleep 5

docker run --rm -i --network host \
  -e TARGET=http://localhost:8080 \
  grafana/k6:latest run - < loadtest.js
echo "codigo de salida: $?"

docker rm -f devops-labs-carga

Deberías ver: El resumen de k6 con los umbrales marcados como cumplidos y código de salida 0. Si alguno no se cumple, k6 devuelve 99.

Si aparece connection refused, confirmá primero que la app temporal siga arriba con docker ps --filter name=devops-labs-carga. Si ejecutás este ejemplo fuera de docker-01, Docker Desktop no implementa --network host igual: usá -e TARGET=http://host.docker.internal:8080 en lugar de http://localhost:8080.

Important

Compuerta · Rendimiento. La compuerta la crean los thresholds, no la herramienta. Sin ellos k6 también imprime lindo y sale en 0. Con ellos, el código 99 frena el pipeline.

Note

Nota. Esta prueba corre en la misma máquina que la app. Sus números sirven para comparar una versión con otra, pero no para decir cuántos usuarios soporta producción. Cuando compartas el resultado, decí dónde lo mediste.

Paso 39 · Incorporar las compuertas al pipeline

Agregá estas etapas a pipeline.sh: la de seguridad entre la construcción y la publicación, y al final la de carga.

step "05 - Vulnerabilidades y secretos"
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  -v "$HOME/.cache/trivy:/root/.cache/" \
  aquasec/trivy:latest image --severity HIGH,CRITICAL --ignore-unfixed \
  --exit-code 1 --quiet "$IMAGE" || fail "05 (vulnerabilidades)"
docker run --rm -v "$PWD":/src aquasec/trivy:latest fs \
  --scanners secret --exit-code 1 --quiet /src || fail "05 (secretos)"

# ... publicacion, y despues del despliegue del bloque K:

step "08 - Prueba de carga"
docker run --rm -i --network host -e TARGET=http://localhost:3000 \
  grafana/k6:latest run --quiet - < loadtest.js || fail "08 (rendimiento)"

Bloque K · Despliegue azul/verde (pasos 40 a 42)

La extensión que el manual original no tiene, y la que más cambia el resultado.

Paso 40 · Escribir el script de despliegue

El despliegue del manual hace docker run sobre el puerto 3000. Si la versión nueva no arranca, no hay a dónde volver: la vieja ya no está. El patrón azul/verde levanta la versión nueva al lado de la que está viva, la verifica mientras todavía no recibe tráfico, y mueve el tráfico solo si pasó.

#!/usr/bin/env bash
set -euo pipefail

IMAGE="localhost:5000/devops-labs-app:${1:?falta el tag}"
NET="devops-labs-net"
CURL="curlimages/curl:8.11.1"

if docker ps --format '{{.Names}}' | grep -q '^devops-labs-blue$'; then
  LIVE=devops-labs-blue; NEXT=devops-labs-green
else
  LIVE=devops-labs-green; NEXT=devops-labs-blue
fi
echo "vive: $LIVE   candidata: $NEXT"

# 1. Levanto la candidata SIN el alias que recibe trafico
docker rm -f "$NEXT" >/dev/null 2>&1 || true
docker run -d --name "$NEXT" --network "$NET" \
  -e APP_COLOR="$NEXT" -e APP_VERSION="$1" "$IMAGE" >/dev/null

# 2. Espero a que se declare lista
for i in $(seq 1 30); do
  if docker run --rm --network "$NET" "$CURL" \
       -sf "http://$NEXT:3000/readyz" >/dev/null 2>&1; then
    echo "candidata lista en ${i}s"; break
  fi
  [ "$i" -eq 30 ] && { echo "la candidata nunca estuvo lista"; \
    docker logs --tail 30 "$NEXT"; docker rm -f "$NEXT"; exit 1; }
  sleep 1
done

# 3. Prueba de humo, todavia sin trafico real
BODY=$(docker run --rm --network "$NET" "$CURL" -sf "http://$NEXT:3000/")
echo "$BODY" | grep -q '"status":"ok"' || {
  echo "la prueba de humo fallo: $BODY"; docker rm -f "$NEXT"; exit 1; }

# 4. Recien ahora muevo el trafico
docker network disconnect "$NET" "$LIVE" >/dev/null 2>&1 || true
docker network disconnect "$NET" "$NEXT" >/dev/null 2>&1 || true
docker network connect --alias devops-labs-active "$NET" "$NEXT"
sleep 2

# 5. Verifico por donde entra el usuario
if ! curl -sf http://localhost:3000/healthz >/dev/null; then
  echo "ROLLBACK: el router no responde con la version nueva"
  docker network disconnect "$NET" "$NEXT" >/dev/null 2>&1 || true
  docker network connect --alias devops-labs-active "$NET" "$LIVE"
  docker rm -f "$NEXT"
  exit 1
fi

# 6. La vieja se apaga ordenadamente
docker stop "$LIVE" >/dev/null 2>&1 || true
docker rm   "$LIVE" >/dev/null 2>&1 || true
echo "desplegada $NEXT con $IMAGE"

Guardá este contenido como deploy.sh en la raíz de ~/devops-labs (mismo truco del heredoc: cat > deploy.sh << 'EOF' ... EOF).

Paso 41 · Desplegar dos veces

chmod +x deploy.sh
./deploy.sh $GIT_SHA
curl -s localhost:3000/ | jq .color

./deploy.sh $GIT_SHA
curl -s localhost:3000/ | jq .color

Deberías ver: "devops-labs-blue" y después "devops-labs-green". El color alterna porque cada despliegue usa el contenedor que no está vivo. El puerto 3000 nunca dejó de responder: quien atiende es el router, y lo único que cambió fue a dónde apunta.

Si falla

502 después del cambio: nginx cacheó la dirección vieja. Verificá que nginx.conf tenga el resolver y que el proxy_pass use una variable, no un nombre literal.

Paso 42 · Probar el rollback

Vas a simular un despliegue que rompe a propósito, para comprobar que deploy.sh no mueve el tráfico si la candidata no pasa la verificación. En src/app.js, buscá el handler real de /readyz (el que devuelve state.ready) y reemplazá solo esa línea del callback, temporalmente, por esta:

// en src/app.js, temporalmente: reemplazá el cuerpo de app.get('/readyz', ...)
app.get('/readyz', (req, res) => res.status(500).json({ status: 'roto' }));

Guardá el archivo y corré:

docker build -t devops-labs-app:roto . && \
  docker tag devops-labs-app:roto localhost:5000/devops-labs-app:roto && \
  docker push localhost:5000/devops-labs-app:roto

./deploy.sh roto
echo "codigo de salida: $?"
curl -s localhost:3000/ | jq .color

Deberías ver: El script esperando 30 segundos, mostrándote los logs del contenedor candidato, borrándolo y saliendo con código 1. Y el curl final devolviendo el color de la versión anterior: el tráfico nunca se movió. Eso es un despliegue que falla sin caerse.

Note

Nota. La versión anterior se apaga apenas entra la nueva. En producción conviene dejarla encendida un rato, sin tráfico, por si aparece un problema y necesitás volver rápido. Ese es el reto avanzado del final.

Revertí el cambio en /readyz y corré ./pipeline.sh completo antes de seguir.

De script a CI

El script de bash es el borrador; la plataforma es la versión final.

Todo lo que hiciste corre en tu máquina, en serie, con tus credenciales. Un servidor de integración continua aporta tres cosas que el script no puede: ejecuta ante cada cambio sin que nadie se acuerde, aísla el entorno entre corridas, y guarda evidencia consultable de cada ejecución. La lógica es la misma; cambia dónde vive.

Note

Nota. Este laboratorio no tiene un servidor GitLab. Por eso .gitlab-ci.yml está como ejemplo en el anexo. Hace lo mismo que pipeline.sh, pero GitLab lo ejecutaría por vos.

Si algo no anda

SíntomaCausa habitualQué hacer
docker stop tarda 10 sEl proceso no recibe SIGTERM: el CMD está en forma de cadenaUsá CMD ["node","src/server.js"]. Verificá con docker exec CONT ps -o pid,comm que node sea PID 1
Contenedor en unhealthyEl healthcheck apunta a localhost y resuelve a IPv6Usá 127.0.0.1 explícito. Mirá docker inspect CONT --format '{{json .State.Health}}'
nginx devuelve 502 tras el cambioCacheó la dirección vieja del destinoConfirmá resolver 127.0.0.11 valid=1s y que proxy_pass use variable
Trivy rebaja la base cada vezEl caché no está montadoAgregá -v $HOME/.cache/trivy:/root/.cache/
ESLint no encuentra configuraciónQuedó un .eslintrc.js, o el archivo usa module.exportsBorralo. Con "type":"module" tiene que ser export default
ERR_MODULE_NOT_FOUND en las pruebasImport sin extensiónEn ESM va './app.js', no './app'
k6 no llega a la appEl contenedor de k6 está en otra red--network host, o poné k6 en devops-labs-net y apuntá a http://devops-labs-active:3000
docker push rechazadoEl daemon exige TLS{"insecure-registries":["localhost:5000"]} en /etc/docker/daemon.json
--mount no reconocidoFalta BuildKitDOCKER_BUILDKIT=1 docker build ...
El build no usa caché nuncaSe copia todo antes de instalarCopiá primero package*.json, instalá, después el código

Anexo · Código de referencia

Solo si no tenés acceso a un modelo — usalo como red, no como atajo

Este es el resultado esperado después del prompt B. Si llegaste acá sin haber intentado los pasos 5 a 11, volvé: la parte de revisar y corregir es la que se evalúa.

// src/app.js
import express from 'express';

export const state = { ready: true };

export function createApp() {
  const app = express();
  app.use(express.json());

  app.get('/', (req, res) => {
    res.json({
      status: 'ok',
      message: 'DevOps Labs DevOps App funcionando',
      version: process.env.APP_VERSION ?? 'dev',
      color: process.env.APP_COLOR ?? 'unknown'
    });
  });

  app.get('/healthz', (req, res) => {
    res.json({ status: 'healthy', uptime: process.uptime() });
  });

  app.get('/readyz', (req, res) => {
    if (!state.ready) return res.status(503).json({ status: 'draining' });
    res.json({ status: 'ready' });
  });

  return app;
}
// src/server.js
import { createApp, state } from './app.js';

const PORT = Number(process.env.PORT ?? 3000);
const server = createApp().listen(PORT, () => {
  console.log(JSON.stringify({ level: 'info', msg: 'listening', port: PORT }));
});

function shutdown(signal) {
  console.log(JSON.stringify({ level: 'info', msg: 'shutdown', signal }));
  state.ready = false;
  setTimeout(() => {
    server.close(() => process.exit(0));
  }, 500);
  setTimeout(() => process.exit(1), 10000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// src/app.test.js
import { test, before, after, describe } from 'node:test';
import assert from 'node:assert/strict';
import { createApp, state } from './app.js';

let server, base;

before(async () => {
  server = createApp().listen(0);
  await new Promise(r => server.once('listening', r));
  base = `http://127.0.0.1:${server.address().port}`;
});

after(() => server.close());

describe('API', () => {
  test('GET / devuelve ok', async () => {
    const res = await fetch(`${base}/`);
    assert.equal(res.status, 200);
    assert.equal((await res.json()).status, 'ok');
  });

  test('GET /healthz devuelve healthy', async () => {
    const res = await fetch(`${base}/healthz`);
    assert.equal(res.status, 200);
    assert.equal((await res.json()).status, 'healthy');
  });

  test('GET /readyz devuelve 503 mientras drena', async () => {
    state.ready = false;
    const res = await fetch(`${base}/readyz`);
    assert.equal(res.status, 503);
    state.ready = true;
  });

  test('ruta inexistente devuelve 404', async () => {
    const res = await fetch(`${base}/no-existe`);
    assert.equal(res.status, 404);
  });
});
// package.json
{
  "name": "devops-labs-app",
  "version": "1.0.0",
  "type": "module",
  "engines": { "node": ">=22" },
  "scripts": {
    "start": "node src/server.js",
    "test": "node --test --experimental-test-coverage",
    "test:ci": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=junit.xml",
    "lint": "eslint .",
    "lint:fix": "eslint . --fix"
  },
  "dependencies": { "express": "^5.1.0" },
  "devDependencies": {
    "@eslint/js": "^9.18.0",
    "eslint": "^9.18.0",
    "globals": "^15.14.0"
  },
  "license": "MIT"
}
.gitlab-ci.yml de referencia — no se ejecuta en este laboratorio

Traducción de pipeline.sh a un pipeline de GitLab CI real. La lógica de etapas, cachés y compuertas es la misma; lo que cambia es dónde vive.

# .gitlab-ci.yml
stages: [verificar, construir, escanear, publicar, desplegar]

variables:
  IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

.node: &node
  image: node:22-alpine
  cache:
    key:
      files: [package-lock.json]
    paths: [.npm/]
  before_script: [npm ci --cache .npm --prefer-offline]

lint:
  <<: *node
  stage: verificar
  script: [npm run lint]

test:
  <<: *node
  stage: verificar
  script: [npm run test:ci]
  artifacts:
    when: always
    reports:
      junit: junit.xml

construir:
  stage: construir
  image: docker:27
  services: [docker:27-dind]
  script:
    - docker build --build-arg GIT_SHA=$CI_COMMIT_SHORT_SHA -t $IMAGE .
    - docker push $IMAGE

escanear:
  stage: escanear
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  script:
    - trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 $IMAGE
    - trivy image --format cyclonedx --output sbom.json $IMAGE
  artifacts:
    paths: [sbom.json]

desplegar:
  stage: desplegar
  script: [./deploy.sh $CI_COMMIT_SHORT_SHA]
  environment:
    name: produccion
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

Nota. lint y test corren al mismo tiempo porque no dependen uno del otro. El despliegue a producción es manual: el pipeline deja todo listo, pero una persona decide cuándo publicar. Cada equipo puede elegir otra regla.

La pregunta con la que conviene cerrar: si mañana entra alguien nuevo al equipo y sube un cambio, ¿qué lo separa de romper producción? Si la respuesta es «la revisión de código», el pipeline todavía no está terminado.


By Roxs · roxs.dev

About the Author

rossana suarez

rossana suarez

Roxs🔥| NaranjaX | ROXS FEST 🤩 | AWS Hero⭐| Docker Captain🐳 | DevOps Ambassador⭐|DevOps🔥| DevSecOps🔥|Content Creator roxs.dev | Developer👩‍💻|Mentor👩‍🏫 🇻🇪🇦🇷

Find this author online

More tutorials you might like

Secure Machine-to-Machine Access with mTLS and Pomerium (cover image)

Secure Machine-to-Machine Access with mTLS and Pomerium

Run a GitHub Actions-compatible continuous integration (CI) job on a private runner and protect its internal API call with mutual TLS (mTLS) and Pomerium. Build separate server and client trust chains, authorize one machine certificate by fingerprint, then revoke, restore, and rotate its credentials through live policy changes.

Learn by doing, not just by reading or watching

Sign up for a free account to start a VM playground right on this page, track your progress, and get notified about new learning materials.

Sign up for free