* notes / microservicios

Microservicios con Docker para un blog: auth, posts y autores por separado

Desmonté un blog en tres servicios independientes orquestados con docker-compose. Funciona; ahora cuento si valió la pena partir algo que cabía en un monolito.

March 27, 20256 min read#microservicios#docker#arquitectura#react

Hay decisiones técnicas que uno toma sabiendo que probablemente son desproporcionadas para el problema, pero que persigue por aprender. La mía fue partir un blog (tres modelos de datos, cuatro endpoints relevantes y una audiencia modesta) en una arquitectura de microservicios con su propio puerta de enlace, su autenticación por servicio y su despliegue independiente. Esto es lo que salió de ese experimento: lo que funcionó, lo que sobraba y lo que aprendí incluso cuando todo gritaba "hazlo monolítico".

Contexto

El sistema se compone de tres servicios y un frontal:

  • auth-service: gestiona usuarios, sesiones y emisión de tokens JWT firmados con una clave asimétrica.
  • posts-service: alberga los artículos, el versionado y la búsqueda por etiquetas.
  • author-service: mantiene los perfiles de los autores, sus enlaces y sus métricas públicas.
  • frontend: una aplicación React que consume todo a través de una puerta de enlace.

Cada servicio tiene su propia base de datos PostgreSQL, regla casi sagrada en microservicios, y se comunica con los demás por HTTP o mediante eventos publicados en un broker ligero. Para orquestar el conjunto en local utilizo docker-compose, y para producción una variante con Traefik y certificados gestionados automáticamente.

React SPAclienteAPI GatewayTraefik / Kongauth-serviceNode / Fastifyposts-serviceGo / Echoauthor-servicePython / FastAPIpg-authpg-postspg-authorevento

Las flechas azules son llamadas HTTP/JSON sincrónicas; las verdes, eventos asíncronos publicados en NATS cuando un post se publica o se borra. Esa distinción importa: el frontal nunca debe quedar bloqueado esperando una cascada de llamadas, y los servicios que sólo necesitan enterarse de un cambio deben suscribirse, no consultar.

Fronteras de servicio y autenticación entre ellos

La regla que me autoimpuse fue clara: cada servicio es dueño absoluto de su esquema. posts-service no puede leer la tabla de usuarios de auth-service; si necesita el nombre del autor para una respuesta, lo solicita por HTTP o, mejor, lo cachea desde un evento author.updated. La tentación de hacer un JOIN entre bases distintas se desvanece en cuanto uno entiende que esa es exactamente la deuda que el monolito disfraza de productividad.

Para autenticación entre servicios opté por tokens de servicio firmados con una clave compartida, validados en cada salto por el destinatario. Existen alternativas más sofisticadas (mTLS con un service mesh, SPIFFE), pero para tres servicios eran innecesarias. El frontal habla con la puerta de enlace usando un JWT de usuario; la puerta de enlace lo intercambia internamente por un token corto firmado para el servicio destino.

Implementación: docker-compose orquestando todo

services:
  gateway:
    image: traefik:v3.1
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80
    ports:
      - "80:80"
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks: [edge, internal]
 
  auth-service:
    build: ./services/auth
    environment:
      DATABASE_URL: postgres://auth:auth@pg-auth:5432/auth
      JWT_PRIVATE_KEY_FILE: /run/secrets/jwt_key
    secrets: [jwt_key]
    depends_on: [pg-auth]
    labels:
      - traefik.enable=true
      - traefik.http.routers.auth.rule=PathPrefix(`/api/auth`)
      - traefik.http.services.auth.loadbalancer.server.port=3000
    networks: [internal]
 
  posts-service:
    build: ./services/posts
    environment:
      DATABASE_URL: postgres://posts:posts@pg-posts:5432/posts
      AUTHOR_SERVICE_URL: http://author-service:4000
      NATS_URL: nats://broker:4222
    depends_on: [pg-posts, broker]
    labels:
      - traefik.enable=true
      - traefik.http.routers.posts.rule=PathPrefix(`/api/posts`)
      - traefik.http.services.posts.loadbalancer.server.port=4000
    networks: [internal]
 
  author-service:
    build: ./services/author
    environment:
      DATABASE_URL: postgres://author:author@pg-author:5432/author
    depends_on: [pg-author]
    labels:
      - traefik.enable=true
      - traefik.http.routers.author.rule=PathPrefix(`/api/authors`)
      - traefik.http.services.author.loadbalancer.server.port=5000
    networks: [internal]
 
  pg-auth: { image: postgres:16-alpine, environment: { POSTGRES_PASSWORD: auth, POSTGRES_USER: auth, POSTGRES_DB: auth }, networks: [internal], volumes: [auth-data:/var/lib/postgresql/data] }
  pg-posts: { image: postgres:16-alpine, environment: { POSTGRES_PASSWORD: posts, POSTGRES_USER: posts, POSTGRES_DB: posts }, networks: [internal], volumes: [posts-data:/var/lib/postgresql/data] }
  pg-author: { image: postgres:16-alpine, environment: { POSTGRES_PASSWORD: author, POSTGRES_USER: author, POSTGRES_DB: author }, networks: [internal], volumes: [author-data:/var/lib/postgresql/data] }
 
  broker:
    image: nats:2-alpine
    networks: [internal]
 
secrets:
  jwt_key:
    file: ./secrets/jwt_private.pem
 
networks:
  edge:
  internal:
 
volumes:
  auth-data:
  posts-data:
  author-data:

El truco que más me ahorró tiempo fue separar la red edge (la que Traefik expone hacia fuera) de la red internal (por donde los servicios se hablan entre sí). Así, ningún servicio interno queda accesible desde el exterior por descuido, y la puerta de enlace mantiene su privilegio de único punto de entrada.

El precio escondido: observabilidad y transacciones distribuidas

Lo que en un monolito resolvía con un tail -f y un breakpoint, en microservicios exige infraestructura: logs centralizados (probé Loki con Promtail), trazas distribuidas (OpenTelemetry con Tempo) y métricas (Prometheus). Sin ello, depurar un fallo intermitente que cruza tres servicios es un ejercicio frustrante.

El otro gasto silencioso son las transacciones. Cuando un usuario se da de alta debería crearse en auth-service y publicar un perfil en author-service. Si la segunda llamada falla, hay que decidir: ¿se hace rollback del usuario? ¿Se reintenta? ¿Se acepta inconsistencia temporal? Yo opté por el patrón outbox en auth-service: la creación de usuario y la escritura del evento user.created ocurren en la misma transacción de base de datos, y un proceso aparte publica el evento a NATS con garantía at-least-once. author-service procesa el evento idempotentemente.

Hallazgos

  • Para un blog modesto, esta arquitectura es claramente excesiva. Un monolito Next.js con Prisma habría tardado un fin de semana y se mantendría con la mitad de esfuerzo.
  • Sin embargo, me dio una intuición real sobre por qué los equipos grandes adoptan microservicios: no por rendimiento, sino por autonomía organizativa. Cuando tres equipos pueden desplegar sin coordinarse, la velocidad agregada crece.
  • La latencia añadida por el salto extra a la puerta de enlace es despreciable en mi caso (~3 ms), pero se notaría con docenas de servicios.
  • La idempotencia deja de ser una virtud y se convierte en una obligación. Cualquier consumidor de eventos debe asumir que recibirá el mismo mensaje dos veces.
  • Los healthchecks en docker-compose evitaron carreras de arranque que de otro modo se manifestaban como errores de conexión a base de datos en los primeros segundos.

Closure

Si tuviera que rehacerlo, mantendría el frontal y el auth-service separados (la autenticación es lo bastante crítica como para vivir en su propio espacio) y fusionaría posts y autores en un único servicio de contenido. Sería un compromiso razonable: aprovecharía la modularidad donde aporta y evitaría la complejidad donde sólo presume. La lección importante no es "no uses microservicios", sino que la frontera de un servicio debe trazarse donde lo justifica un equipo, un ritmo de despliegue o una garantía de aislamiento, no donde uno cree que quedará bonito en un diagrama.