Saltar al contenido

Capítulo 12 de 13

Proyecto · conectar y comprobar servicios

Añadir proxy y Compose, arrancar el catálogo y verificar permisos y operaciones a través de HTTP.

#Qué conectamos ahora

Esta es la segunda parte del proyecto del catálogo. Continúa en la misma carpeta, con app.py, requirements.txt, Dockerfile, .dockerignore, init-db.sh, .env y los tres secretos ya explicados. Ahora añadirás solo dos archivos: Caddyfile y compose.yaml. Después verificarás operaciones reales antes de ensayar fallos.

#Fase 5 · Poner el proxy delante

Guarda este Caddyfile local:

:80 {
    request_body {
        max_size 8KB
    }
    reverse_proxy api:8000 {
        transport http {
            dial_timeout 3s
            response_header_timeout 10s
        }
    }
    log {
        output stdout
        format json
    }
}

:80 sirve HTTP para el laboratorio, sin intentar emitir un certificado público. Caddy recibe la petición y conecta a api:8000 por la red interna compartida. Limita cuerpos a 8 KB; la API aplica un límite de 4 KB y valida su significado. Un límite en el proxy protege antes de consumir trabajo de aplicación, y la validación interna sigue siendo necesaria si otra ruta llega directamente a la API.

En esta fase la publicación estará limitada a 127.0.0.1, así que no se enviarán tokens por HTTP a través de Internet. Para un dominio público aplica la fase de HTTPS: cambia el sitio por tu dominio, publica 80/443 y conserva el volumen de certificados.

#Fase 6 · Conectar el sistema con Compose

Guarda compose.yaml:

Ver compose.yaml completo · servicios, redes, volúmenes y secretos
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_DB: catalogo
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password, app_password]
    volumes:
      - db_data:/var/lib/postgresql/data
      - ./init-db.sh:/docker-entrypoint-initdb.d/10-init.sh:ro
    networks: [datos]
    healthcheck:
      test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "postgres", "-d", "catalogo"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 10s
    restart: unless-stopped
    mem_limit: 512m
    cpus: 1.0
    logging:
      driver: local
      options:
        max-size: "10m"
        max-file: "3"
  api:
    image: "${APP_IMAGE:-catalogo:1}"
    build: .
    environment:
      DB_HOST: db
      DB_NAME: catalogo
      DB_USER: catalogo
      DB_PASSWORD_FILE: /run/secrets/app_password
      WRITE_TOKEN_FILE: /run/secrets/write_token
    secrets: [app_password, write_token]
    depends_on:
      db:
        condition: service_healthy
    networks: [entrada, datos]
    dns_search: ["."]
    dns_opt: ["timeout:1", "attempts:1"]
    read_only: true
    tmpfs:
      - /tmp:size=64m,mode=1777
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    init: true
    stop_grace_period: 30s
    healthcheck:
      test:
        - CMD
        - python
        - -c
        - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=4)"
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s
    restart: unless-stopped
    mem_limit: 256m
    cpus: 1.0
    pids_limit: 64
    logging:
      driver: local
      options:
        max-size: "10m"
        max-file: "3"
  proxy:
    image: caddy:2-alpine
    ports:
      - "127.0.0.1:${HTTP_PORT:-8080}:80"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks: [entrada]
    depends_on:
      api:
        condition: service_healthy
    restart: unless-stopped
    mem_limit: 128m
    logging:
      driver: local
      options:
        max-size: "10m"
        max-file: "3"
networks:
  entrada:
  datos:
    internal: true
volumes:
  db_data:
  caddy_data:
  caddy_config:
secrets:
  db_password:
    file: ./secrets/db_password
  app_password:
    file: ./secrets/app_password
  write_token:
    file: ./secrets/write_token

Lee el archivo siguiendo una petición: proxy → api por entrada → db por datos. La base no pertenece a entrada, y no tiene ports. La API tiene acceso a ambas redes pero no publica el 8000. Los secretos se asignan por servicio; el proxy no recibe ninguno de los secretos de base o escritura.

El healthcheck de PostgreSQL usa -h 127.0.0.1 para comprobar TCP. Durante la inicialización la imagen oficial puede levantar un servidor temporal accesible solo por socket Unix; comprobar ese socket podría declarar disponibilidad antes de terminar los scripts. Aun con TCP correcto, la API comprueba además tabla y permisos con su usuario real.

dns_search: ["."] evita heredar sufijos de búsqueda del host para un servicio que usa nombres internos. dns_opt limita a un segundo cada espera y a un intento del resolutor de esta imagen Linux. Si detienes db, su nombre puede desaparecer del DNS de Docker: sin límites DNS, connect_timeout=3 no impediría esperar mucho más antes de intentar conectar. Un plazo DNS no equivale por sí solo a un plazo total —puede haber varias consultas—; se combina con los límites de conexión, consulta y proxy, y se comprueba mediante la prueba de caída. Si tu entorno necesita dominios de búsqueda corporativos, configúralos deliberadamente y vuelve a medir.

read_only, tmpfs, cap_drop y no-new-privileges se aplican a la API que diseñamos para funcionar así. Los límites de memoria son un punto inicial para este laboratorio, no una garantía de capacidad: observa arranque y carga antes de ajustarlos. Los tres servicios rotan logs mediante el driver local.

#Fase 7 · Arrancar y probar contratos

Desde la misma carpeta, con Docker y Compose en marcha:

docker compose config --quiet
docker compose build api
docker compose up -d --wait --wait-timeout 120
docker compose ps -a
curl --fail http://127.0.0.1:8080/health/ready
curl --fail http://127.0.0.1:8080/libros

Si cambiaste HTTP_PORT, usa ese puerto en todas las peticiones. Esperas {"ready": true} y {"libros": []} en el primer arranque. --wait comprueba salud de los servicios que tienen healthcheck; la petición por el proxy demuestra el camino completo.

Prueba primero el rechazo:

curl -i -X POST http://127.0.0.1:8080/libros \
  -H 'Content-Type: application/json' \
  --data '{"titulo":"El libro sin permiso"}'

Espera 401 Unauthorized. Ahora crea una cabecera en un archivo privado para no escribir el token literal en el historial ni pasarlo como argumento visible de curl:

printf 'Authorization: Bearer %s\n' "$(cat secrets/write_token)" > secrets/write-header
chmod 600 secrets/write-header
curl --fail-with-body -i http://127.0.0.1:8080/libros \
  -H @secrets/write-header \
  -H 'Content-Type: application/json' \
  --data '{"titulo":"Diseñar sistemas que se recuperan"}'
curl --fail http://127.0.0.1:8080/libros

Espera 201 Created, un id numérico y el libro en la lectura posterior. El identificador no tiene por qué ser 1 si ya hiciste pruebas: las secuencias no garantizan números sin huecos. Prueba también un título vacío con token: debe dar 400 y no insertar ninguna fila.

#Comprobar el límite de permisos

docker compose exec api id
docker compose exec api python -c \
  'from app import connect; c=connect(); print(c.execute("SELECT current_user").fetchone()[0]); c.close()'
docker compose exec api python -c \
  'from app import connect; c=connect(); c.execute("DELETE FROM libros"); c.commit()'

Esperas UID 10001, usuario SQL catalogo y un error InsufficientPrivilege al borrar. Ese último fallo es el resultado correcto: la API puede cumplir su función, pero no tiene permisos que no necesita. No lo "arregles" concediendo superusuario.

#Resultado de esta parte

El sistema ya recibe HTTP por el proxy, autentica escrituras, conserva libros en PostgreSQL y limita el rol de la aplicación. Haberlo arrancado una vez no demuestra recuperación: la siguiente página detiene una dependencia, reemplaza la API y restaura una copia por separado.

#Fuentes de esta parte

    ↑ ↓ para recorrer · Intro para abrir · Ctrl K para buscar