DEV Community

jesus manrique
jesus manrique

Posted on • Originally published at guayoyo.tech

De Cero a Kubernetes Parte 4: Backend FastAPI + PostgreSQL en Producción

K8s Terraform ArgoCD — Header

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            │             │
│    └───────────────────────┘             │
└─────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

gitops/apps/database/kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: production
resources:
  - postgresql-cluster.yaml
  - db-credentials-secret.yaml
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

⚠️ En producción real: Nunca pongas passwords en texto plano en git. Usa una de estas opciones:

  1. SealedSecrets — encripta el Secret completo; solo el controlador en el cluster sabe descifrarlo. El YAML encriptado sí puede committearse.
  2. ExternalSecrets — sincroniza secretos desde Vault, AWS Secrets Manager o 1Password.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Build y push:

docker build -t ghcr.io/tu-org/backend:v1.0.0 .
docker push ghcr.io/tu-org/backend:v1.0.0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
  • secretKeyRef inyecta username/password del Secret db-credentials sin exponerlos en el Deployment
  • livenessProbe y readinessProbe — Kubernetes usa estos para saber si el pod está vivo y si debe recibir tráfico. Si /health falla, 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

gitops/apps/backend/kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: production
resources:
  - deployment.yaml
  - service.yaml
  - ingress.yaml
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)