DevOps · 03

Proyecto completo: chanchito de punta a punta

Una API Node + MongoDB llevada por las tres capas, en tu PC: Docker construye la imagen y sirve para desarrollar, Terraform prepara la plataforma en el cluster y Kubernetes corre la app.

Antes de empezar

Este proyecto combina las tres herramientas. Si alguna te resulta nueva, conviene hacer antes su guía de inicio (Docker: “Empezar desde cero”; Kubernetes: “Cluster local”; Terraform: “Práctica con Docker”).

Requisitos
Docker Desktop con Kubernetes activado, kubectl (viene con Docker Desktop) y Terraform instalado. Todo corre en tu PC.
Plataforma
Lo que la app necesita y cambia poco (acá: el namespace, el Secret con la contraseña y el ConfigMap). La crea Terraform.
Namespace
Una “carpeta” dentro del cluster. Todo el proyecto vive en el namespace chanchito.
Secret / ConfigMap
Objetos de Kubernetes con la contraseña (Secret) y la configuración no secreta (ConfigMap) que los Pods leen como variables de entorno.
Provider kubernetes
El plugin de Terraform que crea objetos dentro de un cluster de Kubernetes (en vez de en una nube).
Probe /health
Una ruta de la app que responde “ok”; Kubernetes la consulta para saber si el Pod está vivo y listo.
NodePort
Un tipo de Service que abre un puerto (acá el 30080) para entrar a la app desde tu navegador.
Rolling update / rollback
Actualizar a una versión nueva sin cortar el servicio, y volver a la anterior si falla.
0

Quién crea qué

Cada carpeta del repo la maneja una herramienta distinta, y cada una deja algo en el cluster.

infra/ terraform apply app/ docker build k8s/ kubectl apply Imagen local chanchito:1.0.0 namespace chanchito cluster de Docker Desktop ConfigMap chanchito-config Secret mongo usuario · pass · URL ← Terraform Deployment chanchito 2 Pods · chanchito:1.0.0 probes /health Deployment monguito 1 Pod · mongo:7 strategy: Recreate Service chanchito NodePort 30080 → 3000 Service monguito · PVC :27017 · mongo-data 1Gi image
i

¿Por qué este reparto? Terraform maneja lo que cambia poco y es “de plataforma” (namespaces, secretos, permisos). La app cambia seguido y se despliega con kubectl (o con CI).

i

Docker Desktop comparte las imágenes con su Kubernetes en el modo predeterminado (kubeadm): no hace falta registry en local. Si en Settings elegiste el modo kind, no se comparten y tenés que usar un registry, igual que en la nube.

✓

La contraseña entra una sola vez (TF_VAR_mongo_password) y llega a Mongo y a la app a través del Secret.

1

Estructura del repositorio

Si ya tenés una app con Dockerfile y docker-compose, podés reorganizarla así.

Árbol

chanchito/
├── app/                      # Docker
│   ├── app.js
│   ├── package.json
│   ├── package-lock.json
│   ├── Dockerfile
│   └── .dockerignore
├── docker-compose.yml        # desarrollo local
├── .env                      # secretos de desarrollo (fuera de git)
├── infra/                    # Terraform: la plataforma
│   ├── versions.tf
│   ├── providers.tf
│   ├── variables.tf
│   ├── main.tf
│   └── outputs.tf
├── k8s/                      # Kubernetes: la app
│   ├── mongo.yaml
│   └── chanchito.yaml
└── .gitignore

.gitignore del proyecto

# Node
node_modules/

# secretos locales
.env

# Terraform
infra/.terraform/
infra/*.tfstate
infra/*.tfstate.*
infra/*.tfvars
infra/tfplan
i

infra/.terraform.lock.hcl sí va a git: fija las versiones de los providers.

Requisitos

  • Docker Desktop con Kubernetes activado (Settings → Kubernetes).
  • kubectl (viene con Docker Desktop) apuntando a docker-desktop.
  • Terraform: winget install Hashicorp.Terraform.
2
Fase 1 · Docker

La app, su imagen y el entorno de desarrollo

Un cambio en app.js: la URL de Mongo llega por variable de entorno, así la misma imagen sirve en Compose y en Kubernetes.

app/app.js

import express from "express";
import mongoose from "mongoose";

const PORT = process.env.PORT ?? 3000;
const MONGO_URL = process.env.MONGO_URL;   // viene de Compose o del Secret

const Animal = mongoose.model("Animal", new mongoose.Schema({
  tipo: String,
  estado: String,
}));

const app = express();

app.get("/", async (_req, res) => res.send(await Animal.find()));

app.get("/crear", async (_req, res) => {
  await Animal.create({ tipo: "Chanchito", estado: "Feliz" });
  res.send("ok");
});

app.get("/health", (_req, res) => res.send("ok"));   // para las probes

await mongoose.connect(MONGO_URL);
app.listen(PORT, () => console.log(`escuchando en ${PORT}`));
i

Ya no hay contraseña escrita en el código. await en el nivel superior funciona porque el package.json tiene "type": "module".

app/Dockerfile y .dockerignore

FROM node:22-alpine
WORKDIR /app

# dependencias primero: capa cacheable
COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

ENV PORT=3000
EXPOSE 3000
USER node
CMD ["node", "app.js"]
# app/.dockerignore
node_modules
npm-debug.log
Dockerfile
.dockerignore

docker-compose.yml (desarrollo)

services:
  chanchito:
    build: ./app
    ports:
      - "3000:3000"
    environment:
      - MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@monguito:27017/miapp?authSource=admin
    depends_on:
      - monguito

  monguito:
    image: mongo:7
    environment:
      - MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}
      - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD}
    volumes:
      - mongo-data:/data/db

volumes:
  mongo-data:
# .env  (fuera de git)
MONGO_USER=mati
MONGO_PASSWORD=<password>
3
Fase 2 · Terraform

La plataforma: namespace, secreto y configuración

Con el provider hashicorp/kubernetes, Terraform crea objetos dentro del cluster de Docker Desktop.

infra/versions.tf · providers.tf · variables.tf

terraform {
  required_version = ">= 1.11"

  required_providers {
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.36"
    }
  }
}
provider "kubernetes" {
  config_path    = "~/.kube/config"
  config_context = "docker-desktop"
}
variable "namespace" {
  type    = string
  default = "chanchito"
}

variable "mongo_user" {
  type    = string
  default = "mati"
}

variable "mongo_password" {
  type      = string
  sensitive = true          # TF_VAR_mongo_password
}

variable "log_level" {
  type    = string
  default = "info"
}

infra/main.tf · outputs.tf

locals {
  ns = kubernetes_namespace_v1.app.metadata[0].name
}

resource "kubernetes_namespace_v1" "app" {
  metadata {
    name = var.namespace
    labels = {
      proyecto = "chanchito"
      gestion  = "terraform"
    }
  }
}

resource "kubernetes_secret_v1" "mongo" {
  metadata {
    name      = "mongo"
    namespace = local.ns
  }
  data = {
    MONGO_INITDB_ROOT_USERNAME = var.mongo_user
    MONGO_INITDB_ROOT_PASSWORD = var.mongo_password
    MONGO_URL = "mongodb://${var.mongo_user}:${urlencode(var.mongo_password)}@monguito:27017/miapp?authSource=admin"
  }
}

resource "kubernetes_config_map_v1" "app" {
  metadata {
    name      = "chanchito-config"
    namespace = local.ns
  }
  data = {
    LOG_LEVEL = var.log_level
  }
}
output "siguiente_paso" {
  value = "kubectl apply -n ${local.ns} -f ../k8s/"
}
i

En data los valores van en texto plano: el provider los codifica en base64. urlencode() evita que una contraseña con @ o : rompa la URL.

4
Fase 3 · Kubernetes

El despliegue de la app

Los manifiestos usan el Secret y el ConfigMap que creó Terraform. Se aplican en el namespace con -n chanchito.

k8s/mongo.yaml

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mongo-data
spec:
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: monguito
spec:
  replicas: 1
  strategy:
    type: Recreate              # un solo Pod usa el disco
  selector:
    matchLabels: { app: monguito }
  template:
    metadata:
      labels: { app: monguito }
    spec:
      containers:
        - name: mongo
          image: mongo:7
          ports:
            - containerPort: 27017
          envFrom:
            - secretRef: { name: mongo }     # creado por Terraform
          volumeMounts:
            - name: datos
              mountPath: /data/db
      volumes:
        - name: datos
          persistentVolumeClaim:
            claimName: mongo-data
---
apiVersion: v1
kind: Service
metadata:
  name: monguito                # host de MONGO_URL
spec:
  selector: { app: monguito }
  ports:
    - port: 27017

k8s/chanchito.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: chanchito
spec:
  replicas: 2
  selector:
    matchLabels: { app: chanchito }
  template:
    metadata:
      labels: { app: chanchito }
    spec:
      containers:
        - name: chanchito
          image: chanchito:1.0.0
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 3000
          env:
            - name: MONGO_URL
              valueFrom:
                secretKeyRef: { name: mongo, key: MONGO_URL }
          envFrom:
            - configMapRef: { name: chanchito-config }
          readinessProbe:
            httpGet: { path: /health, port: 3000 }
            initialDelaySeconds: 3
          livenessProbe:
            httpGet: { path: /health, port: 3000 }
            initialDelaySeconds: 15
          resources:
            requests: { cpu: 50m, memory: 64Mi }
            limits: { memory: 256Mi }
---
apiVersion: v1
kind: Service
metadata:
  name: chanchito
spec:
  type: NodePort
  selector: { app: chanchito }
  ports:
    - port: 80
      targetPort: 3000
      nodePort: 30080           # http://localhost:30080
!

Si chanchito arranca antes que Mongo, falla la conexión y Kubernetes lo reinicia hasta que Mongo responde. Un par de reinicios al principio es normal.

5

Paso a paso

En PowerShell, desde la raíz de chanchito/.

Docker

1 · Desarrollar con Compose

docker compose up --build
Lee .env. Probá localhost:3000/crear y localhost:3000.
docker compose down
Al terminar (los datos quedan en el volumen).

2 · Construir la versión

docker build -t chanchito:1.0.0 ./app
Tag con versión: es la que va a usar Kubernetes.
Terraform

3 · Crear la plataforma

kubectl config use-context docker-desktop
$env:TF_VAR_mongo_password = "<password>"
Cluster correcto y contraseña solo en esta terminal.
terraform -chdir=infra init
terraform -chdir=infra plan
terraform -chdir=infra apply
El plan debe decir 3 to add: namespace, secret y configmap.
Kubernetes

4 · Desplegar la app

kubectl apply -n chanchito -f k8s/
kubectl get pods -n chanchito -w
Esperá 1/1 Running en los tres Pods (Ctrl+C para salir del watch).
# http://localhost:30080/crear
# http://localhost:30080
kubectl logs -n chanchito -l app=chanchito -f
Las dos réplicas comparten la misma base.
Limpieza

5 · Bajar todo

kubectl delete -n chanchito -f k8s/
terraform -chdir=infra destroy
Primero la app, después la plataforma. Borrar el namespace elimina todo lo que quedaba adentro.
!

delete -f k8s/ incluye el PVC: se pierden los datos de Mongo del cluster (los de Compose siguen en su volumen).

6

Día a día con el proyecto

Qué herramienta tocás según qué cambia.

Cambié el código

docker build -t chanchito:1.1.0 ./app
kubectl -n chanchito set image deploy/chanchito chanchito=chanchito:1.1.0
kubectl -n chanchito rollout status deploy/chanchito
Rolling update sin corte. Actualizá también el tag en k8s/chanchito.yaml para que el repo refleje lo desplegado.
kubectl -n chanchito rollout undo deploy/chanchito
Si la versión nueva falla.

Cambié la configuración

terraform -chdir=infra apply -var="log_level=debug"
kubectl -n chanchito rollout restart deploy/chanchito
Terraform actualiza el ConfigMap (~ en el plan); el restart hace que los Pods lean el valor nuevo.
$env:TF_VAR_mongo_password = "<nueva>"
terraform -chdir=infra apply
Cambia el Secret, pero Mongo ya creó su usuario: ver diagnóstico.

Quiero probar algo rápido

docker compose up --build
Para iterar sobre el código, Compose sigue siendo lo más rápido.
kubectl -n chanchito port-forward svc/monguito 27017:27017
Mirar la base del cluster con Compass.
kubectl -n chanchito exec -it deploy/chanchito -- sh
Entrar a un Pod de la app.
7

Siguiente nivel

Lo mismo, más cerca de producción.

Imagen en un registry

docker tag chanchito:1.0.0 ghcr.io/<usuario>/chanchito:1.0.0
docker push ghcr.io/<usuario>/chanchito:1.0.0
Con un cluster en la nube, la imagen tiene que estar en un registry (GHCR, Docker Hub, ECR…). En el YAML usás ese nombre completo.

El cluster también con Terraform

En la nube, un módulo crea el cluster y el provider kubernetes se conecta a él con sus outputs:

module "eks" {
  source = "terraform-aws-modules/eks/aws"
  # versión, VPC, nodos…
}

provider "kubernetes" {
  host = module.eks.cluster_endpoint
  # certificado y token del cluster…
}
!

Un cluster gestionado cuesta plata por hora. Hacé destroy cuando termines de practicar.

Despliegue automático (CI)

# .github/workflows/deploy.yml (esquema)
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: ./app
          push: true
          tags: ghcr.io/<usuario>/chanchito:${{ github.sha }}
      # luego: kubectl set image … con credenciales del cluster