La lección anterior terminó con un roadmap y una promesa: el primer paso para partir el monolito de Kilómetro Cero es conseguir que dos procesos hablen entre sí de forma fiable. Antes de elegir un mecanismo de alto nivel (RPC, gRPC, colas de mensajes), hay que entender sobre qué se apoyan todos ellos: los protocolos de red. Todo lo que veremos en este módulo (y buena parte de lo que veremos en el resto del curso) son bytes que viajan por TCP o UDP, casi siempre envueltos en HTTP. Si no se entiende qué garantiza cada capa y qué no, es imposible razonar sobre timeouts, duplicados, orden de mensajes o rendimiento.
En esta lección construiremos la primera versión, deliberadamente primitiva, del servicio inventario como un servidor TCP que responde a un comando de texto; enviaremos posiciones de furgoneta-3 por UDP y observaremos qué se pierde; y haremos una petición HTTP al monolito para ver lo que hay debajo de un curl. Con ese material práctico compararemos TCP, UDP, HTTP (en sus versiones 1.1, 2 y 3) y MQTT, y fijaremos criterios para elegir entre ellos.
Contenido
- Qué es un protocolo y por qué se organiza en capas
- La pila TCP/IP
- TCP: la conexión fiable y su precio
- UDP: rapidez sin garantías
- HTTP/1.1, HTTP/2 y HTTP/3
- Protocolos ligeros: MQTT y WebSockets
- Práctica:
inventariosobre TCP, telemetría por UDP y una petición HTTP al monolito - Cómo elegir: tabla comparativa
- Errores comunes y consejos
- Ejercicios
- Conclusión
- Qué es un protocolo y por qué se organiza en capas
Un protocolo es un acuerdo entre dos partes sobre tres cosas: el formato de los mensajes (qué bytes significan qué), las reglas de intercambio (quién habla primero, cómo se responde, qué ocurre ante un error) y el significado de cada mensaje. Sin acuerdo previo no hay comunicación posible: cuando pedidos envía bytes a inventario, ambos deben haber pactado de antemano que STOCK queso-curado\n es una consulta y que OK 5\n es su respuesta.
Los protocolos de red se organizan en capas porque el problema es demasiado grande para resolverlo de una vez. Cada capa resuelve un problema concreto y ofrece un servicio a la capa superior, ocultándole los detalles de las inferiores:
- La capa de enlace mueve tramas entre dos máquinas conectadas físicamente (Ethernet, Wi-Fi).
- La capa de red (IP) mueve paquetes entre máquinas de redes distintas, saltando por routers. No garantiza nada: los paquetes pueden perderse, duplicarse o llegar desordenados.
- La capa de transporte (TCP, UDP) entrega datos a un proceso concreto (identificado por un puerto) y, en el caso de TCP, añade fiabilidad y orden.
- La capa de aplicación (HTTP, MQTT, DNS, el protocolo de gRPC...) define el significado de los mensajes para un uso concreto.
Esta separación es la razón por la que puedes escribir un servidor HTTP sin saber nada de Ethernet, y también la razón por la que las falacias de la lección 01-04 son tan peligrosas: cada capa oculta los problemas de la inferior, pero no los elimina. TCP oculta que IP pierde paquetes reintentando, y ese reintento se traduce en latencia impredecible (falacia 2).
- La pila TCP/IP
El modelo TCP/IP tiene cuatro capas (el modelo OSI, más académico, tiene siete; en la práctica se habla con la terminología de ambos, y conviene conocer la correspondencia).
| Capa TCP/IP | Capa OSI equivalente | Unidad de datos | Protocolos habituales | Qué identifica al destino | Dónde vive en Kilómetro Cero |
|---|---|---|---|---|---|
| Aplicación | Aplicación, Presentación, Sesión (5-7) | Mensaje | HTTP, gRPC, MQTT, AMQP, DNS, TLS | URL, tópico, método | El código de pedidos, inventario, la app móvil de los repartidores |
| Transporte | Transporte (4) | Segmento (TCP) / Datagrama (UDP) | TCP, UDP, QUIC | Puerto (p. ej. 5001) | La biblioteca socket de Python, el runtime de gRPC |
| Red (Internet) | Red (3) | Paquete | IP (v4/v6), ICMP | Dirección IP | El kernel del sistema operativo, la red virtual de Docker, Kubernetes |
| Enlace | Enlace y Física (1-2) | Trama | Ethernet, Wi-Fi, 4G/5G | Dirección MAC | Las tarjetas de red del servidor; el módem 4G de furgoneta-3 |
Cuando pedidos envía "STOCK queso-curado" a inventario, el mensaje baja por la pila en el emisor (se le añaden cabeceras TCP, luego IP, luego Ethernet: encapsulación) y sube por la pila en el receptor, donde cada capa quita su cabecera. Es útil visualizarlo:
flowchart LR
subgraph pedidos["Nodo de pedidos"]
A1[Aplicación: STOCK queso-curado] --> T1[TCP: puerto 5001, nº secuencia]
T1 --> R1[IP: 10.0.0.12 → 10.0.0.31]
R1 --> E1[Ethernet: trama]
end
E1 -. red física, routers .-> E2
subgraph inventario["Nodo de inventario"]
E2[Ethernet: trama] --> R2[IP]
R2 --> T2[TCP: reensambla y ordena]
T2 --> A2[Aplicación: STOCK queso-curado]
end
Un detalle práctico que se olvida a menudo: un puerto identifica un proceso, no un servicio lógico. Cuando inventario tenga varias réplicas (inv-bcn, inv-vlc), cada una escuchará en su propia IP y puerto, y hará falta algo por encima (DNS, un balanceador, el descubrimiento de servicios de Kubernetes, lección 07-05) para que pedidos sepa a cuál dirigirse.
- TCP: la conexión fiable y su precio
TCP (Transmission Control Protocol) es el protocolo de transporte que usan HTTP, gRPC, AMQP, el protocolo de PostgreSQL y casi todo lo demás. Ofrece cuatro garantías sobre IP, que no ofrece ninguna:
- Conexión. Antes de enviar datos se establece un canal bidireccional entre dos extremos (IP:puerto ↔ IP:puerto). Los datos pertenecen a esa conexión y se cierra explícitamente.
- Fiabilidad. Cada byte enviado tiene un número de secuencia; el receptor confirma (ACK) lo recibido; lo no confirmado se retransmite. Desde el punto de vista de la aplicación, o el byte llega, o la conexión termina con error.
- Orden. Los bytes se entregan a la aplicación en el mismo orden en que se enviaron, aunque los paquetes IP lleguen desordenados.
- Control de flujo y de congestión. El receptor anuncia cuánto puede aceptar (ventana de recepción), y el emisor reduce su ritmo cuando detecta pérdidas, interpretándolas como congestión de la red.
Lo que TCP no es: un protocolo de mensajes
TCP entrega un flujo de bytes, no mensajes. Si pedidos hace dos send() seguidos de "STOCK queso-curado\n" y "STOCK tomate-rosa\n", el receptor puede obtenerlos en un solo recv(), o en tres trozos de tamaño arbitrario. La aplicación es responsable de delimitar los mensajes (con un terminador como \n, con un prefijo de longitud, o con un formato autodescriptivo). Es la fuente de errores más frecuente al programar con sockets y la razón por la que existen protocolos de aplicación: HTTP usa cabeceras y Content-Length; gRPC usa un prefijo de longitud de 5 bytes por mensaje.
El coste del handshake
Abrir una conexión TCP requiere un intercambio de tres mensajes (SYN, SYN-ACK, ACK): un viaje de ida y vuelta completo (RTT) antes de poder enviar el primer byte útil. Si además hay TLS (lección 06-02), se añaden uno o dos RTT más. Entre dos contenedores del mismo centro de datos el RTT es de ~0,5 ms y el coste es despreciable; entre la app de un repartidor con 4G en Lleida y el servidor, el RTT puede ser de 80-100 ms, y abrir una conexión por cada mensaje es un desperdicio enorme. De aquí nace la importancia de las conexiones persistentes (keep-alive) y de los pools de conexiones: abrir pocas conexiones y reutilizarlas.
Head-of-line blocking
La garantía de orden tiene un precio. Si en una conexión TCP se pierde el paquete número 7, TCP retiene los paquetes 8, 9 y 10 (que sí llegaron) hasta que la retransmisión del 7 llega, porque debe entregar los bytes en orden. Este fenómeno se llama bloqueo de cabeza de línea (head-of-line blocking, HOL): una pérdida retrasa todo lo que viene detrás, aunque sea independiente. Cuando varias peticiones lógicas comparten una conexión (como hace HTTP/2), una pérdida en la petición de Ana retrasa la petición de Marc. Lo retomaremos al hablar de HTTP/3.
Cuándo se entera TCP de que el otro extremo ha muerto
Nunca de inmediato. Si el proceso de inventario muere limpiamente, el sistema operativo cierra la conexión y el cliente recibe un error rápido. Pero si el servidor entero se apaga o un router deja de encaminar, no llega ningún paquete de cierre: el cliente sigue esperando hasta que sus retransmisiones agotan un límite (minutos, por defecto) o hasta que el mecanismo keepalive de TCP (desactivado por defecto, y con intervalos de horas) lo detecta. Esta es la razón técnica por la que la lección 01-04 insistía en que ninguna llamada remota debe hacerse sin timeout de aplicación: TCP no lo va a hacer por ti.
- UDP: rapidez sin garantías
UDP (User Datagram Protocol) es lo mínimo que se puede poner sobre IP: añade los puertos de origen y destino y una suma de verificación, y nada más. No hay conexión, no hay confirmaciones, no hay retransmisión, no hay orden, no hay control de flujo. Cada sendto() es un datagrama independiente que llega entero o no llega.
¿Para qué sirve entonces? Para los casos en los que las garantías de TCP son un estorbo:
- Cuando un dato viejo no vale nada. La app de
furgoneta-3envía su posición GPS cada segundo. Si se pierde la posición de las 10:00:03, retransmitirla a las 10:00:05 no aporta nada: ya habrá llegado la de las 10:00:04. Con TCP, esa retransmisión además bloquearía las posiciones posteriores (HOL). Con UDP, simplemente se pierde y la siguiente la reemplaza. - Cuando la latencia importa más que la completitud. Voz, vídeo en directo, juegos, telemetría.
- Cuando hay muchísimos mensajes pequeños y el coste de mantener conexiones sería prohibitivo (DNS usa UDP por esto).
- Cuando se quiere construir un protocolo de transporte propio encima, como hace QUIC.
En Kilómetro Cero, la telemetría de posiciones es el caso claro: miles de repartidores en campaña enviando un datagrama por segundo, donde perder uno de cada cien es irrelevante. Lo que no se enviaría jamás por UDP es un pedido o un pago: ahí cada mensaje importa. Un matiz importante: la aplicación que usa UDP suele acabar añadiendo algunas de las garantías de TCP a medida (un número de secuencia para detectar desorden, por ejemplo), y lo veremos en el código.
- HTTP/1.1, HTTP/2 y HTTP/3
HTTP es el protocolo de aplicación dominante, no solo para páginas web, sino como transporte de APIs REST, de gRPC y, cada vez más, de casi cualquier cosa (porque atraviesa cortafuegos y proxies sin problemas). Conviene entender sus tres versiones porque las diferencias tienen consecuencias arquitectónicas directas.
HTTP/1.1
Protocolo de texto sobre TCP. Una petición es una línea de método y ruta, unas cabeceras, una línea en blanco y un cuerpo opcional; la respuesta, una línea de estado, cabeceras y cuerpo. Es lo que verás en el apartado 7 al hacer la petición al monolito. Sus limitaciones:
- Una petición en vuelo por conexión. El cliente envía una petición y debe esperar la respuesta completa antes de enviar la siguiente por esa conexión (el pipelining existe en la especificación, pero nunca funcionó bien en la práctica). Los navegadores lo compensan abriendo 6 conexiones por dominio; los clientes de API, con pools.
- Cabeceras repetidas y en texto. Cada petición envía
Host,User-Agent,Accept, cookies... sin comprimir. En una API que hace miles de llamadas pequeñas, las cabeceras pueden pesar más que los cuerpos. - Keep-alive (conexiones persistentes) es el comportamiento por defecto y mitiga el coste del handshake, pero no el problema de la serialización de peticiones.
HTTP/2
Mismo significado (métodos, códigos, cabeceras), distinto formato: binario y con multiplexación. Una única conexión TCP transporta múltiples flujos (streams) simultáneos e independientes: la petición de stock de Ana y la de Marc viajan intercaladas en la misma conexión, sin esperarse. Además, comprime las cabeceras (HPACK) y permite priorizar flujos. Es la base de gRPC (lección 02-03) precisamente por la multiplexación y porque los flujos permiten streaming en ambas direcciones.
Su punto débil es el que ya conocemos: como todos los flujos comparten una conexión TCP, el bloqueo de cabeza de línea de TCP afecta a todos. En redes con pérdidas (móviles), HTTP/2 puede rendir peor que HTTP/1.1 con varias conexiones.
HTTP/3 y QUIC
HTTP/3 resuelve ese problema cambiando el transporte: en lugar de TCP usa QUIC, un protocolo construido sobre UDP que reimplementa fiabilidad, orden y control de congestión, pero por flujo, no por conexión. Si se pierde un paquete del flujo de Ana, solo se retrasa el flujo de Ana. QUIC además integra TLS 1.3 en el handshake (una conexión segura en 1 RTT, o 0 RTT si se reanuda) y sobrevive a cambios de IP (la furgoneta que pasa de Wi-Fi a 4G mantiene la conexión). Está desplegado en los grandes navegadores y CDN; en comunicación entre servicios dentro de un centro de datos su ventaja es menor, porque allí apenas hay pérdidas.
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| Formato | Texto | Binario (tramas) | Binario (tramas) |
| Transporte | TCP | TCP | QUIC sobre UDP |
| Peticiones simultáneas por conexión | 1 | Muchas (multiplexación) | Muchas (multiplexación) |
| Compresión de cabeceras | No | HPACK | QPACK |
| Bloqueo de cabeza de línea | A nivel HTTP (una petición bloquea la siguiente) | Solo a nivel TCP | Eliminado (por flujo) |
| Handshake con TLS | 2-3 RTT | 2-3 RTT | 1 RTT (0 RTT si se reanuda) |
| Uso típico en Kilómetro Cero | API REST pública, monolito actual | gRPC entre servicios | Apps móviles vía CDN |
- Protocolos ligeros: MQTT y WebSockets
Hay escenarios en los que HTTP es demasiado pesado o su modelo petición-respuesta no encaja.
MQTT es un protocolo de publicación/suscripción diseñado para dispositivos con recursos limitados y redes poco fiables (su origen está en la telemetría de oleoductos por satélite). Un broker central recibe mensajes publicados en tópicos jerárquicos (km0/reparto/furgoneta-3/posicion) y los reenvía a quien se haya suscrito (con comodines: km0/reparto/+/posicion). Sus cabeceras ocupan 2 bytes, mantiene una única conexión TCP persistente (así que un mensaje no paga handshake), define tres niveles de calidad de servicio (QoS 0: como mucho una vez; QoS 1: al menos una vez; QoS 2: exactamente una vez, con un intercambio de cuatro mensajes) e incluye el concepto de last will, un mensaje que el broker publica si el dispositivo se desconecta bruscamente. Para la app de los repartidores es una alternativa seria a UDP crudo: paga algo más de bytes a cambio de atravesar cualquier red móvil y de tener un broker que reparte las posiciones. Aquí solo lo presentamos; el diseño de un sistema de mensajería en tiempo real con MQTT y la propagación hasta el mapa de la clienta se desarrolla en la lección 08-02.
WebSockets convierte una conexión HTTP en un canal bidireccional persistente entre navegador y servidor, para que el servidor pueda enviar sin que el cliente pregunte. Es lo que usará la pantalla de seguimiento de Ana para ver moverse a furgoneta-3 sin recargar. También se desarrolla en la lección 08-02; aquí basta con saber que existe, que va sobre TCP y que empieza como una petición HTTP normal con la cabecera Upgrade: websocket.
- Práctica:
inventario sobre TCP, telemetría por UDP y una petición HTTP al monolito
inventario sobre TCP, telemetría por UDP y una petición HTTP al monolitoVamos a tocar las tres cosas con la biblioteca estándar de Python. El objetivo no es escribir código de producción (para eso están las lecciones siguientes), sino ver con las manos qué hace cada protocolo. Todo el código va en km0/servicios/, que hasta ahora estaba vacía.
7.1 Servidor TCP de inventario
Primera versión del servicio inventario extraído del monolito: un servidor que acepta conexiones y responde al comando STOCK <producto>. Definimos un protocolo de aplicación mínimo: mensajes de texto UTF-8 terminados en \n; respuestas OK <unidades> o ERR <motivo>.
# km0/servicios/inventario/tcp_servidor.py
import socket
import threading
HOST = "0.0.0.0" # escuchar en todas las interfaces del contenedor
PORT = 5001
# Stock inicial: los cinco productos del esquema 001_esquema_inicial.sql,
# identificados por un slug URL-safe. Más adelante vendrá de PostgreSQL.
STOCK = {
"tomate-rosa": 120,
"calabacin": 80,
"queso-curado": 5,
"queso-fresco": 30,
"vino-crianza": 200,
}
candado = threading.Lock() # varios hilos leerán STOCK a la vez
def leer_linea(conn):
"""Lee bytes de la conexión hasta encontrar '\\n'.
TCP entrega un flujo de bytes, no mensajes: un recv() puede devolver
medio comando o dos comandos juntos. Delimitar es tarea nuestra.
"""
datos = b""
while not datos.endswith(b"\n"):
trozo = conn.recv(1024)
if not trozo: # b"" significa que el cliente cerró
return None
datos += trozo
return datos.decode("utf-8").strip()
def procesar(linea):
partes = linea.split()
if len(partes) == 2 and partes[0] == "STOCK":
with candado:
unidades = STOCK.get(partes[1])
if unidades is None:
return "ERR producto desconocido"
return f"OK {unidades}"
return "ERR comando no reconocido"
def atender(conn, direccion):
"""Bucle de una conexión: lee comando, responde, repite hasta el cierre."""
print(f"[inventario] conexión de {direccion}")
with conn:
while True:
linea = leer_linea(conn)
if linea is None:
break
respuesta = procesar(linea)
conn.sendall((respuesta + "\n").encode("utf-8"))
print(f"[inventario] {direccion} desconectado")
def main():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as servidor:
# Permite reiniciar el servidor sin esperar a que el SO libere el puerto
servidor.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
servidor.bind((HOST, PORT))
servidor.listen()
print(f"[inventario] escuchando en {HOST}:{PORT}")
while True:
conn, direccion = servidor.accept() # bloquea hasta que llega un cliente
threading.Thread(target=atender, args=(conn, direccion), daemon=True).start()
if __name__ == "__main__":
main()Explicación de las partes clave:
socket.AF_INET, socket.SOCK_STREAMsignifica "IPv4, TCP". ConSOCK_DGRAMsería UDP.bindasocia el socket a una dirección y puerto;listenlo pone en modo servidor;acceptbloquea hasta que un cliente completa el handshake y devuelve un socket nuevo dedicado a esa conexión. El socket original sigue escuchando.- Cada conexión se atiende en un hilo, porque
recvbloquea: si atendiéramos a los clientes en serie, el segundo esperaría a que el primero se desconectara. En la lección 01-02 vimos la alternativa conasyncio; aquí usamos hilos por simplicidad. leer_lineaes la parte más importante de todo el fichero. Acumula bytes hasta ver el terminador. Si la eliminas y confías en que "unrecv= un mensaje", el servidor funcionará en tus pruebas locales y fallará en producción cuando la red fragmente un mensaje o junte dos.sendall(y nosend) garantiza que se envían todos los bytes;sendpuede enviar solo una parte y devolver cuántos envió.
7.2 Cliente TCP en pedidos
# km0/servicios/pedidos/tcp_cliente.py
import socket
class ClienteInventario:
"""Cliente del protocolo de texto de inventario. Reutiliza una conexión."""
def __init__(self, host="localhost", puerto=5001, timeout=2.0):
# create_connection hace el handshake TCP; timeout acota tanto la
# conexión como cada recv/send posteriores (lección 01-04).
self.sock = socket.create_connection((host, puerto), timeout=timeout)
self.buffer = b""
def consultar_stock(self, producto):
self.sock.sendall(f"STOCK {producto}\n".encode("utf-8"))
while b"\n" not in self.buffer:
trozo = self.sock.recv(1024)
if not trozo:
raise ConnectionError("inventario cerró la conexión")
self.buffer += trozo
linea, _, self.buffer = self.buffer.partition(b"\n")
respuesta = linea.decode("utf-8")
if respuesta.startswith("OK "):
return int(respuesta[3:])
raise ValueError(f"inventario respondió: {respuesta}")
def cerrar(self):
self.sock.close()
if __name__ == "__main__":
cliente = ClienteInventario()
for producto in ["queso-curado", "tomate-rosa", "jamon"]:
try:
print(producto, "->", cliente.consultar_stock(producto))
except (ValueError, ConnectionError, socket.timeout) as e:
print(producto, "-> error:", e)
cliente.cerrar()Salida esperada:
Observa dos cosas. Primera: la conexión se abre una vez y se usa para las tres consultas (keep-alive hecho a mano); abrirla en cada consulta triplicaría los handshakes. Segunda: el buffer del cliente conserva los bytes sobrantes de un recv, por si llegó parte de la siguiente respuesta. Prueba a parar el servidor con Ctrl+C mientras el cliente espera: verás un ConnectionError inmediato porque el sistema operativo cerró la conexión. Ahora prueba a bloquear el puerto con un cortafuegos o a desconectar la red: el cliente no verá nada hasta que salte el timeout de 2 segundos. Es la diferencia entre un fallo crash limpio y una omisión (lección 01-02).
7.3 Telemetría de furgoneta-3 por UDP
Ahora la app del repartidor. Envía un datagrama JSON por segundo con su posición y un número de secuencia. Para simular una red móvil, el emisor "pierde" un 10 % de los datagramas antes de enviarlos.
# km0/servicios/reparto/furgoneta_udp.py
import json
import random
import socket
import time
DESTINO = ("localhost", 5002)
REPARTIDOR = "furgoneta-3"
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP: sin connect ni handshake
lat, lon = 41.9794, 2.8214 # salida del mercado de Girona
for seq in range(1, 31):
lat += random.uniform(-0.0005, 0.0005)
lon += random.uniform(-0.0005, 0.0005)
posicion = {"repartidor": REPARTIDOR, "seq": seq,
"lat": round(lat, 5), "lon": round(lon, 5), "ts": time.time()}
if random.random() < 0.10:
print(f"(simulando pérdida del datagrama {seq})")
else:
# Cada sendto es un datagrama independiente: llega entero o no llega.
sock.sendto(json.dumps(posicion).encode("utf-8"), DESTINO)
time.sleep(1)Y el receptor en reparto, que detecta huecos en la secuencia y descarta posiciones que llegan tarde:
# km0/servicios/reparto/receptor_posiciones.py
import json
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", 5002))
print("[reparto] esperando posiciones en UDP 5002")
ultimo_seq = {} # repartidor -> último número de secuencia aceptado
while True:
datos, origen = sock.recvfrom(4096) # un datagrama completo por llamada
pos = json.loads(datos)
rep, seq = pos["repartidor"], pos["seq"]
anterior = ultimo_seq.get(rep, 0)
if seq <= anterior:
print(f"[reparto] {rep}: datagrama {seq} llegó tarde, descartado")
continue
if seq > anterior + 1:
print(f"[reparto] {rep}: perdidos {seq - anterior - 1} datagramas")
ultimo_seq[rep] = seq
print(f"[reparto] {rep} en ({pos['lat']}, {pos['lon']}) seq={seq}")Puntos importantes:
- No hay
listen,acceptniconnect.recvfromdevuelve un datagrama completo y la dirección de quien lo envió. A diferencia de TCP, aquí sí hay mensajes con límites: no hace faltaleer_linea. - El número de secuencia lo añadimos nosotros: UDP no lo da. Con él detectamos pérdidas y desorden, y decidimos qué hacer (ignorar la pérdida y descartar lo tardío, que es la política correcta para posiciones). Estamos reconstruyendo parte de TCP, la parte que nos interesa, sin pagar por la que no.
- El datagrama de
furgoneta-3mide unos 100 bytes. Por TCP+HTTP/1.1, con cabeceras, pasaría de 300, y cada envío nuevo desde una conexión cerrada añadiría un handshake. Por MQTT (apartado 6) serían unos 110 bytes sobre una conexión ya abierta: un compromiso intermedio que veremos en 08-02.
7.4 Una petición HTTP al monolito
Por último, hablemos con el monolito que dejamos arrancado en 01-06, pero mirando lo que curl esconde.
# km0/servicios/pedidos/http_salud.py
import http.client
import json
conn = http.client.HTTPConnection("localhost", 8000, timeout=3)
conn.request("GET", "/salud", headers={"Accept": "application/json",
"User-Agent": "km0-pedidos/0.1"})
resp = conn.getresponse()
print("Estado:", resp.status, resp.reason) # 200 OK
print("Cabeceras:", dict(resp.getheaders()))
cuerpo = json.loads(resp.read())
print("Cuerpo:", cuerpo) # {'estado': 'ok', 'productos': 5}
conn.close()Lo que viaja por la conexión TCP hacia el puerto 8000 es exactamente este texto (puedes verlo con curl -v http://localhost:8000/salud o con nc localhost 8000 escribiéndolo a mano):
Y la respuesta:
Fíjate en Content-Length: es la forma en que HTTP/1.1 resuelve el problema de delimitar mensajes sobre el flujo de bytes de TCP (el mismo que resolvimos con \n en 7.1). Y en que, comparado con nuestro protocolo de texto, HTTP añade cabeceras que aquí ocupan más que el dato útil: es el precio de la interoperabilidad universal. Para una llamada interna entre pedidos e inventario que se repita 1.200 veces por segundo (lección 01-03), ese precio pesa, y es una de las razones por las que la lección 02-03 usará gRPC sobre HTTP/2 con cuerpos binarios.
- Cómo elegir: tabla comparativa
| Criterio | TCP (socket crudo) | UDP | HTTP (1.1/2) | MQTT |
|---|---|---|---|---|
| Garantía de entrega | Sí (o error) | No | Sí (hereda de TCP) | Configurable (QoS 0/1/2) |
| Orden | Sí | No | Sí | Sí por tópico y QoS>0 |
| Conexión | Sí, persistente | No | Sí (keep-alive) | Sí, persistente y ligera |
| Sobrecarga por mensaje | Muy baja (20 B cabecera TCP) | Mínima (8 B) | Alta en 1.1, media en 2 | Muy baja (2 B) |
| Modelo | Flujo bidireccional | Datagramas | Petición-respuesta | Publicación/suscripción |
| Delimitación de mensajes | Tarea de la aplicación | Nativa | Nativa (cabeceras) | Nativa |
| Atraviesa proxies/cortafuegos | Depende del puerto | A menudo bloqueado | Siempre | Normalmente (o sobre WebSockets) |
| Interoperabilidad | Requiere protocolo propio | Requiere protocolo propio | Universal | Amplia (IoT) |
| Cuándo usarlo en Kilómetro Cero | Nunca directamente: siempre con un protocolo encima | Telemetría de posiciones si se controla la red | APIs públicas (REST) y gRPC entre servicios | Telemetría de repartidores en redes móviles (08-02) |
Criterios de elección, en orden de importancia:
- ¿Cada mensaje importa? Si sí, descarta UDP y MQTT QoS 0.
- ¿Es petición-respuesta o es un flujo de eventos? Petición-respuesta → HTTP/gRPC (02-02, 02-03). Flujo hacia muchos interesados → mensajería (02-04) o MQTT.
- ¿Quién está al otro lado? Un navegador o un tercero → HTTP. Un servicio propio → gRPC. Un dispositivo con batería y 4G → MQTT.
- ¿Cuántos mensajes por segundo y de qué tamaño? A partir de miles por segundo con cuerpos pequeños, la sobrecarga de HTTP/1.1 en texto se nota; HTTP/2 binario o MQTT la reducen.
- Solo entonces mira el rendimiento bruto. Casi nunca es el factor decisivo y casi siempre es el primero que se discute.
Errores Comunes y Consejos
- Suponer que un
recvdevuelve un mensaje. Es el error número uno con TCP. Funciona en local y falla en producción. Delimita siempre (terminador, prefijo de longitud o formato autodescriptivo) o usa un protocolo de aplicación que lo haga por ti. - Confiar en que TCP detectará que el servidor ha muerto. Solo lo detecta si el proceso cierra la conexión. Ante un servidor apagado o una red cortada, tu cliente esperará minutos sin timeout de aplicación.
- Abrir una conexión por petición. Cada handshake cuesta un RTT (más con TLS). Reutiliza conexiones: pools en clientes HTTP, canales persistentes en gRPC, una conexión por proceso en MQTT.
- Usar UDP "porque es más rápido" para datos que importan. Un pedido perdido no es más rápido, es un pedido perdido. UDP es para datos que caducan.
- Añadir a UDP toda la fiabilidad de TCP a mano. Si acabas implementando retransmisiones, orden y control de flujo, usa TCP (o QUIC). UDP solo compensa si necesitas menos garantías.
- Ignorar el tamaño de las cabeceras HTTP. En llamadas internas muy frecuentes, medir bytes por petición revela sorpresas. HTTP/2 y los cuerpos binarios existen por esto.
- Consejo: aprende a usar
curl -v,nc(netcat) ytcpdump/Wireshark. Ver los bytes reales que viajan resuelve en minutos discusiones que en abstracto duran horas. - Consejo: documenta el protocolo de aplicación aunque sea de texto y de tres líneas, como hicimos con
STOCK/OK/ERR. El día que otro equipo escriba un cliente, lo agradecerá; y el día que quieras cambiarlo, sabrás qué rompe.
Ejercicios
Ejercicio 1: Ampliar el protocolo de inventario
Añade al servidor TCP el comando RESERVAR <producto> <cantidad>, que descuente stock si hay suficiente y responda OK <restante>, o ERR stock insuficiente en caso contrario. Ten en cuenta que varios hilos pueden reservar a la vez el mismo producto (recuerda el último queso de Ana y Marc, lección 01-05). Después, desde el cliente, lanza 4 reservas de 2 unidades de queso-curado desde 4 hilos simultáneos y comprueba que el stock nunca queda negativo.
Ejercicio 2: Fragmentación de mensajes
Modifica tcp_cliente.py para que envíe los tres comandos STOCK de golpe con un único sendall (concatenados con \n) y luego lea las tres respuestas. Comprueba si el servidor los procesa correctamente cuando llegan juntos y explica por qué, y razona qué pasaría si leer_linea hubiera sido escrito como return conn.recv(1024).decode().strip().
Ejercicio 3: Elegir protocolo
Para cada flujo de Kilómetro Cero indica qué protocolo de transporte/aplicación usarías (TCP crudo, UDP, HTTP/1.1, HTTP/2, MQTT) y justifícalo con los criterios del apartado 8:
- La app de Lucía consulta el catálogo de la Quesería Montblanc.
pedidosconsulta el stock ainventario1.200 veces por segundo en campaña.furgoneta-3envía su posición cada segundo mientras cruza zonas con cobertura intermitente entre Lleida y Tarragona.- Un productor sube 40 fotos de sus productos desde su oficina.
- Un sensor de temperatura de la cámara frigorífica de la Bodega Roble Alto informa cada minuto, con batería.
Soluciones
Solución 1:
def procesar(linea):
partes = linea.split()
if len(partes) == 2 and partes[0] == "STOCK":
with candado:
unidades = STOCK.get(partes[1])
return "ERR producto desconocido" if unidades is None else f"OK {unidades}"
if len(partes) == 3 and partes[0] == "RESERVAR":
producto = partes[1]
try:
cantidad = int(partes[2])
except ValueError:
return "ERR cantidad no numérica"
if cantidad <= 0:
return "ERR cantidad debe ser positiva"
with candado: # comprobar y descontar de forma atómica
unidades = STOCK.get(producto)
if unidades is None:
return "ERR producto desconocido"
if unidades < cantidad:
return "ERR stock insuficiente"
STOCK[producto] = unidades - cantidad
return f"OK {STOCK[producto]}"
return "ERR comando no reconocido"La clave es que la comprobación y el descuento ocurren dentro del mismo with candado. Si comprobaras fuera del candado y descontaras dentro, dos hilos podrían ver "quedan 5", y ambos descontar 2... y luego un tercero y un cuarto también: el stock quedaría en -3. Con 4 reservas concurrentes de 2 unidades sobre 5, exactamente dos tendrán éxito (restante 3, luego 1) y dos recibirán ERR stock insuficiente. Cliente de prueba:
import threading
def reservar():
c = ClienteInventario()
c.sock.sendall(b"RESERVAR queso-curado 2\n")
print(c.sock.recv(1024).decode().strip())
c.cerrar()
hilos = [threading.Thread(target=reservar) for _ in range(4)]
for h in hilos: h.start()
for h in hilos: h.join()Nota que, en un solo proceso, el candado resuelve el problema; cuando haya réplicas inv-bcn e inv-vlc, ningún candado en memoria servirá: ese es el problema del Módulo 3.
Solución 2:
self.sock.sendall(b"STOCK queso-curado\nSTOCK tomate-rosa\nSTOCK jamon\n")
for _ in range(3):
while b"\n" not in self.buffer:
self.buffer += self.sock.recv(1024)
linea, _, self.buffer = self.buffer.partition(b"\n")
print(linea.decode())Funciona porque leer_linea del servidor acumula hasta el primer \n y... aquí hay una trampa: tal como está escrita, leer_linea comprueba datos.endswith(b"\n"), y si los tres comandos llegan en un solo recv, datos termina en \n y contiene tres líneas; procesar verá "STOCK queso-curado\nSTOCK tomate-rosa\nSTOCK jamon" y responderá ERR comando no reconocido una sola vez, dejando al cliente esperando dos respuestas que nunca llegarán (hasta el timeout). La versión correcta del servidor debe conservar un buffer por conexión y extraer líneas con partition, exactamente como hace el cliente. Es un ejemplo perfecto de que "funciona en mis pruebas" no significa "gestiona bien el flujo de bytes". Con la versión ingenua conn.recv(1024).decode().strip(), además, un comando que llegara partido en dos recv produciría dos errores ERR comando no reconocido y ningún resultado.
Solución 3:
- HTTP (1.1 o 2, según el cliente): petición-respuesta, cliente externo (app móvil o navegador), interoperabilidad y caché de HTTP. Con una CDN delante, HTTP/3 mejora la experiencia móvil.
- HTTP/2 (gRPC): cada mensaje importa (descarta UDP), petición-respuesta síncrona (descarta mensajería), servicio propio, volumen alto con mensajes pequeños (la multiplexación y el formato binario reducen sobrecarga). Lección 02-03.
- MQTT (o UDP si la red fuera propia): los datos caducan (una posición vieja no vale), red móvil con pérdidas, dispositivo con batería. MQTT con QoS 0 da prácticamente la eficiencia de UDP, pero mantiene una conexión que atraviesa las redes de los operadores móviles y reconecta sola; y el broker reparte las posiciones a quien las necesite (08-02).
- HTTP: cada foto importa (nada de UDP), es una transferencia petición-respuesta, va desde un navegador; HTTP/2 permite subir las 40 en paralelo por una sola conexión.
- MQTT con QoS 1: dispositivo restringido, red posiblemente inestable, mensajes pequeños e infrecuentes en los que sí importa que lleguen (una temperatura fuera de rango es una alerta). El last will avisaría si el sensor se desconecta.
Conclusión
Esta lección ha bajado hasta los cimientos sobre los que se apoya todo lo demás. Hemos visto que un protocolo es un acuerdo de formato, reglas y significado, y que la pila TCP/IP reparte el problema en capas que ocultan, pero no eliminan, los fallos de la capa inferior. TCP nos da conexión, fiabilidad, orden y control de flujo a cambio del handshake, del bloqueo de cabeza de línea y de una detección de fallos que nunca es inmediata; UDP nos quita todo eso y por ello resulta ideal para las posiciones de furgoneta-3, donde un dato viejo no vale nada. HTTP/1.1 es texto y una petición por conexión; HTTP/2 multiplexa flujos sobre una conexión binaria y es la base de gRPC; HTTP/3 se apoya en QUIC sobre UDP para eliminar el bloqueo de cabeza de línea. MQTT y WebSockets quedan presentados y remitidos a la lección 08-02. Con el código hemos comprobado que TCP es un flujo de bytes que exige delimitar mensajes, que un cliente sin timeout se cuelga ante un servidor que desaparece sin cerrar, y que las cabeceras HTTP pueden pesar más que el dato.
El servidor TCP de inventario con su protocolo STOCK/OK/ERR funciona, pero es incómodo: cada nueva operación exige inventar un formato, escribir un parser en ambos extremos y traducir errores a mano. Lo que querríamos es escribir inventario.consultar_stock("queso-curado") en el código de pedidos y que alguien se encargara de convertirlo en bytes, enviarlo, esperar la respuesta y devolverla como un valor de Python. Esa idea, con sus ventajas y sus trampas, es el tema de la siguiente lección: RPC y RMI.
Curso de Arquitecturas Distribuidas
Módulo 1: Introducción a los Sistemas Distribuidos
- Conceptos Básicos de Sistemas Distribuidos
- Modelos de Sistemas Distribuidos
- Ventajas y Desafíos de los Sistemas Distribuidos
- Las Falacias de la Computación Distribuida
- Tiempo, Relojes y Ordenación de Eventos
- Del Monolito a la Plataforma Distribuida: el Caso Kilómetro Cero
Módulo 2: Comunicación en Sistemas Distribuidos
- Protocolos de Comunicación
- RPC y RMI
- gRPC y Serialización de Datos
- Mensajería y Colas de Mensajes
- Patrones de Comunicación Asíncrona
Módulo 3: Consistencia y Replicación
- Modelos de Consistencia
- El Teorema CAP y PACELC
- Algoritmos de Consenso
- Replicación de Datos
- Transacciones Distribuidas y Sagas
Módulo 4: Almacenamiento Distribuido
- Particionado de Datos y Hashing Consistente
- Sistemas de Archivos Distribuidos
- Almacenamiento de Objetos
- Bases de Datos Distribuidas
- Cachés Distribuidos
Módulo 5: Computación Distribuida
- Modelos de Computación Distribuida
- MapReduce y Hadoop
- Spark y Computación en Memoria
- Procesamiento de Flujos de Datos
- Planificación de Trabajos y Pipelines de Datos
Módulo 6: Seguridad en Sistemas Distribuidos
- Autenticación y Autorización
- Cifrado y Protección de Datos
- Gestión de Identidades
- Seguridad entre Servicios: mTLS y Gestión de Secretos
- Puertas de Enlace, Limitación de Tasa y Auditoría
Módulo 7: Monitoreo y Mantenimiento
- Monitoreo de Sistemas Distribuidos
- Logs Centralizados y Trazabilidad Distribuida
- Gestión de Fallos y Recuperación
- Patrones de Resiliencia: Timeouts, Reintentos y Circuit Breaker
- Automatización y Orquestación
- Pruebas en Sistemas Distribuidos e Ingeniería del Caos
