Serie: De Cero a Kubernetes — Parte 1 · Parte 2 · Parte 3 · Parte 4 · Parte 5
Ya tenemos un cluster con ingress, TLS y GitOps. Ahora le ponemos carga de trabajo real: una base de datos PostgreSQL gestionada por un operador y un backend en FastAPI que se conecta a ella, ejecuta migraciones al arrancar, y expone una API REST con health checks.
Esta es la parte donde el tutorial deja de ser "miren qué bonito el cluster vacío" y empieza a ser "esto ya funciona en producción".
Componentes de esta parte
┌─────────────────────────────────────────┐
│ api.tudominio.com │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ Ingress │ │
│ │ (nginx + TLS) │ │
│ └───────────┬───────────┘ │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ Backend Service │ │
│ │ (ClusterIP) │ │
│ └───────────┬───────────┘ │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ Backend Deployment │ │
│ │ 2 réplicas │ │
│ │ FastAPI + Uvicorn │ │
│ │ Init container: │ │
│ │ migraciones │ │
│ └───────────┬───────────┘ │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ PostgreSQL │ │
│ │ (CloudNativePG) │ │
│ │ 1 instancia │ │
│ │ PVC 10Gi │ │
│ └───────────────────────┘ │
└─────────────────────────────────────────┘
Paso 1: CloudNativePG — PostgreSQL como recurso nativo de Kubernetes
CloudNativePG (CNPG) es un operador que gestiona PostgreSQL en Kubernetes sin StatefulSets manuales. Lo instalas y creas un recurso Cluster. El operador maneja replicación, backups, failover y actualizaciones. Es la forma moderna de correr PostgreSQL en Kubernetes.
Instalamos el operador con Terraform (Parte 2, pero lo documentamos aquí):
resource "helm_release" "cnpg" {
name = "cloudnative-pg"
namespace = kubernetes_namespace.infrastructure.metadata[0].name
repository = "https://cloudnative-pg.github.io/charts"
chart = "cloudnative-pg"
version = "0.22.0"
}
Luego definimos la base de datos como código en nuestro repo GitOps:
gitops/apps/database/postgresql-cluster.yaml:
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: guayoyo-db
namespace: production
spec:
instances: 1
imageName: ghcr.io/cloudnative-pg/postgresql:16.4
bootstrap:
initdb:
database: guayoyo
owner: app
secret:
name: db-credentials
storage:
size: 10Gi
storageClass: local-path
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
monitoring:
enablePodMonitor: false
gitops/apps/database/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: production
resources:
- postgresql-cluster.yaml
- db-credentials-secret.yaml
Paso 2: Secretos — sin texto plano en git
gitops/apps/database/db-credentials-secret.yaml:
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
namespace: production
type: kubernetes.io/basic-auth
stringData:
username: app
password: "CambiaEstoEnProduccionConSealedSecrets"
⚠️ En producción real: Nunca pongas passwords en texto plano en git. Usa una de estas opciones:
- SealedSecrets — encripta el Secret completo; solo el controlador en el cluster sabe descifrarlo. El YAML encriptado sí puede committearse.
- ExternalSecrets — sincroniza secretos desde Vault, AWS Secrets Manager o 1Password.
- SOPS + Age — encripta archivos completos con llaves age.
Para este tutorial, el Secret va en texto plano PORQUE es un cluster de laboratorio. Pero quiero que quede claro: en tu empresa, esto jamás va así.
Paso 3: Código del backend (FastAPI)
Creamos el backend en un repo separado (o subdirectorio del monorepo). Estructura:
backend/
├── Dockerfile
├── requirements.txt
├── main.py
├── models.py
├── database.py
└── migrations/
└── 001_init.sql
main.py:
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import asyncpg
import os
import time
from contextlib import asynccontextmanager
DB_HOST = os.getenv("DB_HOST", "guayoyo-db-rw")
DB_PORT = os.getenv("DB_PORT", "5432")
DB_NAME = os.getenv("DB_NAME", "guayoyo")
DB_USER = os.getenv("DB_USER", "app")
DB_PASSWORD = os.getenv("DB_PASSWORD", "")
pool = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global pool
max_retries = 10
for attempt in range(max_retries):
try:
pool = await asyncpg.create_pool(
host=DB_HOST,
port=DB_PORT,
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
min_size=2,
max_size=10
)
# Inicializar tablas
async with pool.acquire() as conn:
await conn.execute("""
CREATE TABLE IF NOT EXISTS items (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
break
except Exception as e:
if attempt == max_retries - 1:
raise
time.sleep(3)
yield
await pool.close()
app = FastAPI(title="Guayoyo API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://admin.tudominio.com",
"https://app.tudominio.com",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class ItemCreate(BaseModel):
name: str
description: str = ""
class Item(ItemCreate):
id: int
created_at: str
@app.get("/health")
async def health():
try:
async with pool.acquire() as conn:
await conn.fetchval("SELECT 1")
return {"status": "ok", "db": "connected", "timestamp": time.time()}
except Exception as e:
return {"status": "degraded", "db": str(e)}
@app.post("/items", response_model=Item)
async def create_item(item: ItemCreate):
async with pool.acquire() as conn:
row = await conn.fetchrow(
"INSERT INTO items (name, description) VALUES ($1, $2) RETURNING *",
item.name, item.description
)
return Item(id=row["id"], name=row["name"],
description=row["description"] or "",
created_at=str(row["created_at"]))
@app.get("/items")
async def list_items():
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM items ORDER BY created_at DESC")
return [{"id": r["id"], "name": r["name"],
"description": r["description"] or "",
"created_at": str(r["created_at"])} for r in rows]
@app.get("/items/{item_id}")
async def get_item(item_id: int):
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM items WHERE id = $1", item_id)
if not row:
raise HTTPException(status_code=404, detail="Item not found")
return {"id": row["id"], "name": row["name"],
"description": row["description"] or "",
"created_at": str(row["created_at"])}
@app.delete("/items/{item_id}")
async def delete_item(item_id: int):
async with pool.acquire() as conn:
result = await conn.execute("DELETE FROM items WHERE id = $1", item_id)
if result == "DELETE 0":
raise HTTPException(status_code=404, detail="Item not found")
return {"status": "deleted"}
Dockerfile:
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false && poetry install --no-dev
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY . .
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Build y push:
docker build -t ghcr.io/tu-org/backend:v1.0.0 .
docker push ghcr.io/tu-org/backend:v1.0.0
Paso 4: Desplegar en Kubernetes vía ArgoCD
gitops/apps/backend/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: production
spec:
replicas: 2
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
serviceAccountName: apps-sa
containers:
- name: backend
image: ghcr.io/tu-org/backend:v1.0.0
ports:
- containerPort: 8000
env:
- name: DB_HOST
value: guayoyo-db-rw.production.svc.cluster.local
- name: DB_PORT
value: "5432"
- name: DB_NAME
value: guayoyo
- name: DB_USER
valueFrom:
secretKeyRef:
name: db-credentials
key: username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
Puntos importantes aquí:
-
guayoyo-db-rw.production.svc.cluster.local— este es el DNS interno que CloudNativePG crea automáticamente.-rw= read-write (la primaria). Si tuvieras réplicas de lectura, estarían en-ro. -
secretKeyRefinyecta username/password del Secretdb-credentialssin exponerlos en el Deployment -
livenessProbeyreadinessProbe— Kubernetes usa estos para saber si el pod está vivo y si debe recibir tráfico. Si/healthfalla, Kubernetes reinicia el pod (liveness) o lo saca del balanceador (readiness)
gitops/apps/backend/service.yaml:
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: production
spec:
selector:
app: backend
ports:
- port: 80
targetPort: 8000
protocol: TCP
type: ClusterIP
gitops/apps/backend/ingress.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: backend
namespace: production
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- api.tudominio.com
secretName: api-tls
rules:
- host: api.tudominio.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: backend
port:
number: 80
gitops/apps/backend/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: production
resources:
- deployment.yaml
- service.yaml
- ingress.yaml
Paso 5: Verificación end-to-end
# 1. Verificar que ArgoCD sincronizó todo
argocd app list
# backend Healthy Synced
# database Healthy Synced
# 2. Probar la API
curl https://api.tudominio.com:30443/health
# {"status":"ok","db":"connected","timestamp":1716163200.123}
# 3. Crear un item
curl -X POST https://api.tudominio.com:30443/items \
-H "Content-Type: application/json" \
-d '{"name":"Test Item","description":"Desde curl"}'
# {"id":1,"name":"Test Item","description":"Desde curl","created_at":"..."}
# 4. Listar items
curl https://api.tudominio.com:30443/items
# [{"id":1,"name":"Test Item",...}]
# 5. Borrar un item
curl -X DELETE https://api.tudominio.com:30443/items/1
# {"status":"deleted"}
# 6. Verificar que las 2 réplicas están corriendo
kubectl get pods -n production -l app=backend
# NAME READY STATUS RESTARTS AGE
# backend-xxxxx-xxxxx 1/1 Running 0 1m
# backend-xxxxx-yyyyy 1/1 Running 0 1m
Qué aprendiste en esta parte
- CloudNativePG como operador de PostgreSQL en Kubernetes
- DNS interno de Kubernetes:
guayoyo-db-rw.production.svc.cluster.local - Secretos para credenciales (y la nota seria sobre SealedSecrets/ExternalSecrets)
- FastAPI con connection pooling a PostgreSQL usando asyncpg
- Init container pattern para migraciones al arranque
- Liveness y readiness probes — la diferencia y por qué ambas importan
- CORS configurado para aceptar tráfico de los frontends
- Construcción de imagen Docker multi-stage optimizada
En la Parte 5, desplegaremos los dos frontends (React + Vue), los conectaremos al backend, y haremos una prueba de integración completa donde una app crea datos y la otra los consume en tiempo real.
En Guayoyo Tech diseñamos backends que no se caen cuando hay tráfico real. FastAPI, Go, Node.js — lo que tu proyecto necesite, con PostgreSQL, health checks, métricas y deployment automatizado en Kubernetes. Hablemos gratis 15 minutos y te mostramos cómo.

Top comments (0)