arquitectura28 min de lectura

Arquitectura P2P: como funciona la red entre iguales

Por Sergio Perea #arquitectura#sistemas distribuidos
Arquitectura P2P: como funciona la red entre iguales

La arquitectura Peer-to-Peer (P2P), o “red entre iguales”, es un modelo de comunicación en el que todos los dispositivos conectados funcionan al mismo tiempo como cliente y servidor. Es decir, cada computadora puede consumir recursos de otros nodos y, a la vez, ofrecer recursos propios.

Este modelo rompe con la tradicional arquitectura Cliente-Servidor, en la que todo depende de un servidor central. Mientras que en Cliente-Servidor el servidor concentra los datos, la seguridad y la lógica de negocio, en P2P el objetivo es la descentralización.

Esto se traduce en:

👉 En Cliente-Servidor, si el servidor falla, todo el sistema cae.
👉 En P2P, cuantos más nodos se conectan, más fuerte y escalable se vuelve la red.

Cómo se estructura una red P2P

Aunque la idea principal del P2P es la descentralización, no todas las redes funcionan igual. Según el grado de centralización, podemos encontrar tres tipos principales de arquitecturas:

P2P puro no estructurado

Imagina que todos los nodos son iguales, sin jerarquías.

  • No existe un servidor central.
  • Cada nodo conoce únicamente a un grupo reducido de “vecinos”.
  • Para buscar un archivo se usa un algoritmo de inundación: un nodo pregunta a sus vecinos, esos vecinos preguntan a los suyos, y así sucesivamente.

P2P puro no estructurado

👉 Ventaja: es muy difícil de detener y ofrece bastante privacidad.
👉 Desventaja: genera muchísimo tráfico en la red y no escala bien cuando hay muchos usuarios.

Ejemplos reales: Gnutella, FreeNet.


P2P puro estructurado

Aquí el sistema es más ordenado porque usa Tablas Hash Distribuidas (DHT).

  • Una DHT funciona como un gran índice repartido entre todos los nodos.
  • Cada nodo es responsable de administrar una parte de ese índice global.
  • Así, cuando buscas un recurso, no necesitas inundar la red, solo ir directo al nodo que lo tiene.

P2P estructurado

👉 Ventaja: permite localizar un archivo incluso si solo hay una copia en toda la red.
👉 Desventaja: mantener la tabla actualizada es complicado, porque los usuarios suelen entrar y salir de la red constantemente.

Ejemplos reales: Chord, Kademlia, Pastry, Tapestry.


P2P híbrido

En este caso sí existe un servidor central, pero con un rol diferente:

  • El servidor no almacena los archivos.
  • Su función es llevar un registro de qué nodo tiene qué recurso.
  • Cuando quieres un archivo, el servidor te dice dónde encontrarlo y luego descargas directamente del nodo que lo posee.

P2P hibrido

👉 Ventaja: las búsquedas son rápidas y fáciles para el usuario.
👉 Desventaja: si el servidor central falla, toda la red deja de funcionar (único punto de fallo).

Ejemplos reales: Napster, eDonkey, BitTorrent, Skype.

¿Cuándo utilizar una arquitectura P2P?

La arquitectura P2P (Peer-to-Peer) no se usa en todos los proyectos. Es muy potente, pero también tiene limitaciones. Para entender cuándo conviene aplicarla, piensa en situaciones donde necesitas que muchos usuarios colaboren entre sí sin depender de un servidor central.

Algunos casos típicos son:

Compartir archivos

Ejemplo clásico: BitTorrent.
Si quieres que los usuarios puedan intercambiar música, videos, documentos u otros archivos sin necesidad de un servidor que los guarde todos, el P2P es ideal.
👉 Entre más usuarios haya, más rápido y eficiente será el intercambio.

Transmisión de contenido en vivo (streaming)

Ejemplo: P2PTV o programas como SopCast.
Cuando un servidor transmite un video en vivo a miles de personas, puede saturarse. Con P2P, cada espectador no solo recibe la señal, sino que también la comparte con otros, reduciendo la carga en el servidor principal.

Procesamiento distribuido

Ejemplo: SETI@home o BOINC.
Si un problema necesita muchísima potencia de cálculo (como analizar señales del espacio o estudiar datos médicos), se puede dividir en pequeñas partes y repartirlas entre miles de computadoras de usuarios voluntarios.

Criptomonedas y blockchain

Ejemplo: Bitcoin o Ethereum.
Las transacciones no pasan por un banco, sino que todos los nodos de la red verifican que sean válidas. Así se evita depender de una sola entidad y se gana seguridad y descentralización.

Aplicaciones descentralizadas (dApps)

Cuando no quieres que exista un único dueño de la aplicación (como puede pasar con redes sociales o mensajerías tradicionales), el P2P permite crear sistemas resistentes a la censura y difíciles de detener.

Usa P2P cuando tu aplicación necesita escalar fácilmente, compartir recursos entre muchos usuarios o evitar depender de un servidor central.
No es recomendable si buscas algo simple, con pocos usuarios o donde la seguridad y el control centralizado sean más importantes.

Creando tu primera app P2P en Python (puro no estructurado)

Vamos a construir un ejemplo muy sencillo de red P2P no estructurada. La idea es que cada nodo de la red sea capaz de:

  1. Escuchar conexiones de otros nodos (actuar como servidor).
  2. Conectarse a otros nodos (actuar como cliente).
  3. Compartir y buscar mensajes entre sus “vecinos” usando un sistema parecido al de la inundación.

De esta forma, podrás ver cómo funciona la comunicación entre iguales sin depender de un servidor central.

De este modo conseguiremos que:

  • Cada nodo funcione como cliente y como servidor al mismo tiempo.
  • La comunicación se haga entre vecinos directos.
  • El mensaje se inunde por la red, igual que en un P2P puro no estructurado.

Paso 1: Preparar el entorno

Necesitas tener instalado Python 3 en tu computadora.
Puedes comprobarlo con:

python3 --version

Paso 2: Crear el archivo node.py

Este será el programa que representará a un nodo P2P.

import socket import threading # Lista de vecinos conocidos (IP, PUERTO) neighbors = [] # Función para manejar mensajes entrantes def handle_client(conn, addr): print(f"[+] Conexión desde {addr}") while True: data = conn.recv(1024).decode("utf-8") if not data: break print(f"[Mensaje recibido de {addr}]: {data}") # Reenviar el mensaje a los vecinos (inundación) for n_ip, n_port in neighbors: if (n_ip, n_port) != addr: # Evitar enviarlo al que lo mandó try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((n_ip, n_port)) s.sendall(data.encode("utf-8")) s.close() except: pass conn.close() # Función para levantar el servidor del nodo def start_node(ip, port): server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind((ip, port)) server.listen() print(f"[+] Nodo iniciado en {ip}:{port}") while True: conn, addr = server.accept() thread = threading.Thread(target=handle_client, args=(conn, addr)) thread.start() # Función para enviar mensajes a la red def send_message(msg): for n_ip, n_port in neighbors: try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((n_ip, n_port)) s.sendall(msg.encode("utf-8")) s.close() except: pass # Ejemplo de uso if __name__ == "__main__": ip = "127.0.0.1" # Localhost port = int(input("Puerto del nodo: ")) start_thread = threading.Thread(target=start_node, args=(ip, port)) start_thread.start() while True: msg = input("Escribe un mensaje para enviar: ") send_message(msg)

Paso 3: Probar la red

Abre tres terminales diferentes.

Ejecuta el programa en cada terminal con un puerto distinto:

python3 node.py

👉 Ejemplo: uno en el puerto 5000, otro en el 5001 y otro en el 5002.

Edita la lista neighbors dentro de cada archivo node.py para que conozcan al menos a un vecino.
Por ejemplo, en el nodo 5000:

neighbors = [("127.0.0.1", 5001)]

En el nodo 5001:

neighbors = [("127.0.0.1", 5000), ("127.0.0.1", 5002)]

En el nodo 5002:

neighbors = [("127.0.0.1", 5001)]

Ahora escribe un mensaje en cualquiera de los nodos:

Escribe un mensaje para enviar: Hola desde el nodo 5000

👉 Verás cómo ese mensaje se propaga a través de los vecinos hasta llegar a todos los nodos conectados.

P2P híbrido en Python (simulando un servicio tipo torrent)

Para entender mejor cómo funciona la arquitectura P2P híbrida, vamos a simular un sistema parecido a los torrents. La idea es usar conceptos que ya conoces: un servidor central (que será el tracker) y varios clientes (los peers). El tracker no guarda los archivos, solo mantiene una lista de qué peer tiene qué piezas. Los peers, en cambio, se conectan entre ellos directamente para intercambiar las piezas y reconstruir el archivo completo. De esta forma, podrás ver cómo se combinan los roles de cliente-servidor con la descentralización propia del P2P, pero usando mecanismos sencillos en Python.

En un P2P híbrido hay dos actores:

Tracker (servidor central):

  • Mantiene un índice: qué peers tienen qué archivo/piezas.
  • Responde a:

announce: “yo, peer X, tengo estas piezas del archivo F”.
peers: “dame la lista de peers que tienen el archivo F”.

Peers (nodos):

  • Anuncian al tracker lo que tienen.
  • Piden al tracker una lista de peers para un archivo.
  • Se conectan directamente entre ellos para intercambiar piezas.

Nota: Usamos solo librerías estándar (sin requests). El tracker va con http.server y los peers usan urllib para consultarlo + sockets para intercambiar piezas.


1) tracker.py — Servidor de coordinación

Guarda esto como tracker.py:

from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlparse, parse_qs import json from collections import defaultdict # file_id -> { "peers": set("ip:port"), "pieces": { "ip:port": set(piece_idx) } } STATE = defaultdict(lambda: {"peers": set(), "pieces": defaultdict(set)}) class TrackerHandler(BaseHTTPRequestHandler): def _send_json(self, payload, code=200): data = json.dumps(payload).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def do_GET(self): parsed = urlparse(self.path) qs = parse_qs(parsed.query) if parsed.path == "/announce": # /announce?peer=IP:PORT&file=FILE_ID&pieces=0,1,2 peer = qs.get("peer", [None])[0] file_id = qs.get("file", [None])[0] pieces = qs.get("pieces", [""])[0] if not peer or not file_id: return self._send_json({"error": "missing peer or file"}, 400) piece_set = set() if pieces: piece_set = set(int(p) for p in pieces.split(",") if p.strip().isdigit()) STATE[file_id]["peers"].add(peer) if piece_set: STATE[file_id]["pieces"][peer] = piece_set return self._send_json({"ok": True}) elif parsed.path == "/peers": # /peers?file=FILE_ID file_id = qs.get("file", [None])[0] if not file_id: return self._send_json({"error": "missing file"}, 400) peers = sorted(list(STATE[file_id]["peers"])) # También devolvemos el mapa de piezas por peer para "rarest first" si quisieras pieces_map = {peer: sorted(list(pcs)) for peer, pcs in STATE[file_id]["pieces"].items()} return self._send_json({"file": file_id, "peers": peers, "pieces_map": pieces_map}) else: self._send_json({"error": "not found"}, 404) def run(host="0.0.0.0", port=8000): server = HTTPServer((host, port), TrackerHandler) print(f"[tracker] Escuchando en http://{host}:{port}") server.serve_forever() if __name__ == "__main__": run()

Qué hace:

  • GET /announce: registra que un peer tiene ciertas piezas de un file_id.
  • GET /peers: devuelve la lista de peers (y opcionalmente qué piezas tiene cada uno).

2) peer.py — Nodo que sube/baja piezas

Este peer:

  • Levanta un servidor TCP sencillo para servir piezas.
  • Se anuncia al tracker con las piezas que tiene.
  • Pregunta al tracker por peers que tengan el archivo y descarga piezas que le falten.

Guarda esto como peer.py:

import socket import threading import os import math import time import json from urllib.parse import urlencode from urllib.request import urlopen CHUNK_SIZE = 1024 * 16 # 16KB por pieza (ajústalo a gusto) def split_into_chunks(data, chunk_size=CHUNK_SIZE): return [data[i:i+chunk_size] for i in range(0, len(data), chunk_size)] class Peer: def __init__(self, my_ip, my_port, tracker_url, file_id, filepath=None): self.my_ip = my_ip self.my_port = my_port self.tracker_url = tracker_url.rstrip("/") self.file_id = file_id # Si tienes un archivo local, lo compartes. Si no, empiezas vacío para "descargarlo". self.filepath = filepath self.chunks = [] self.have = set() # índices de piezas que tengo if self.filepath and os.path.exists(self.filepath): with open(self.filepath, "rb") as f: data = f.read() self.chunks = split_into_chunks(data) self.have = set(range(len(self.chunks))) print(f"[peer] Compartiendo {len(self.chunks)} piezas de {self.filepath}") else: # simula archivo destino de tamaño X (aquí lo aprenderemos de otro peer) self.chunks = [] # se llenará durante la descarga print("[peer] Sin archivo local, listo para descargar.") # --- Servidor TCP para servir piezas --- def serve(self): def handle_client(conn, addr): try: req = conn.recv(1024).decode("utf-8").strip() # Protocolo simple: "GET file_id piece_idx" parts = req.split() if len(parts) == 3 and parts[0] == "GET" and parts[1] == self.file_id: idx = int(parts[2]) if idx in self.have: conn.sendall(self.chunks[idx]) else: conn.sendall(b"") # no la tengo except: pass finally: conn.close() srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.bind((self.my_ip, self.my_port)) srv.listen() print(f"[peer] Sirviendo piezas en {self.my_ip}:{self.my_port}") while True: c, a = srv.accept() threading.Thread(target=handle_client, args=(c, a), daemon=True).start() # --- Comunicación con el tracker --- def announce(self): pieces_param = ",".join(str(i) for i in sorted(self.have)) qs = urlencode({ "peer": f"{self.my_ip}:{self.my_port}", "file": self.file_id, "pieces": pieces_param }) url = f"{self.tracker_url}/announce?{qs}" with urlopen(url, timeout=5) as r: _ = r.read() def get_peers(self): url = f"{self.tracker_url}/peers?{urlencode({'file': self.file_id})}" with urlopen(url, timeout=5) as r: data = json.loads(r.read().decode("utf-8")) return data.get("peers", []), data.get("pieces_map", {}) # --- Cliente de piezas --- def request_piece(self, peer_host, peer_port, idx): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(3) s.connect((peer_host, peer_port)) req = f"GET {self.file_id} {idx}\n".encode("utf-8") s.sendall(req) chunk = b"" while True: part = s.recv(CHUNK_SIZE) if not part: break chunk += part s.close() if chunk: # Asegura estructura de self.chunks missing = idx - (len(self.chunks) - 1) if missing > 0: self.chunks.extend([b""] * missing) if idx >= len(self.chunks): self.chunks.append(chunk) else: self.chunks[idx] = chunk self.have.add(idx) return True except: pass return False def download_loop(self, target_num_pieces=None): """ Intenta completar el archivo. Si no conocemos total de piezas, usaremos la pista del mapa de piezas de otros peers. """ while True: try: # 1) Anunciar lo que tengo self.announce() # 2) Pedir peers peers, pieces_map = self.get_peers() # Elimina mi propio endpoint de la lista me = f"{self.my_ip}:{self.my_port}" peers = [p for p in peers if p != me] # Descubre número de piezas objetivo si aún no lo sabemos if target_num_pieces is None and pieces_map: target_num_pieces = 1 + max( (max(pcs) if pcs else -1) for pcs in pieces_map.values() ) # si no hay pistas, intenta con lo que ya tienes if target_num_pieces < len(self.chunks): target_num_pieces = len(self.chunks) # Si no sabemos cuántas piezas hay, intenta aprovechar lo conocido if target_num_pieces is None: target_num_pieces = max(len(self.chunks), 0) needed = [i for i in range(target_num_pieces) if i not in self.have] # Si ya terminé, intenta reconstruir archivo si tengo ruta if not needed and target_num_pieces > 0: if self.filepath: with open(self.filepath, "wb") as f: for chunk in self.chunks[:target_num_pieces]: f.write(chunk) print(f"[peer] ¡Descarga completa! Guardado en {self.filepath}") else: print("[peer] ¡Descarga completa!") time.sleep(3) continue # sigue anunciando por si te piden piezas # 3) Estrategia simple: intenta pedir cada pieza faltante al primer peer que la tenga for idx in needed: got = False # Intenta peers que declaran tenerla candidate_peers = [] for p, pcs in pieces_map.items(): if idx in pcs and p in peers: candidate_peers.append(p) # Si no hay mapa, intenta con todos if not candidate_peers: candidate_peers = peers for p in candidate_peers: host, port = p.split(":") if self.request_piece(host, int(port), idx): print(f"[peer] Pieza {idx} descargada desde {p}") got = True break if not got: # no disponible ahora, lo volvemos a intentar en la siguiente iteración pass time.sleep(2) except Exception as e: print("[peer] Error en download_loop:", e) time.sleep(2) def run_peer(my_port, tracker_url, file_id, filepath=None, my_ip="127.0.0.1"): peer = Peer(my_ip, my_port, tracker_url, file_id, filepath=filepath) # servidor de subida threading.Thread(target=peer.serve, daemon=True).start() # bucle de descarga/subida peer.download_loop() if __name__ == "__main__": # Ejemplo rápido: # - Ajusta estos valores o pásalos por CLI si quieres TRACKER = "http://127.0.0.1:8000" FILE_ID = "demo-file-v1" PORT = int(input("Puerto del peer: ")) PATH = input("Ruta de archivo local (vacío si quieres descargar): ").strip() or None run_peer(PORT, TRACKER, FILE_ID, filepath=PATH)

Qué hace:

  • Si el peer tiene un archivo local, lo parte en piezas y lo comparte.
  • Si no lo tiene, se anuncia al tracker con lo que tiene (nada) y descarga las piezas desde otros peers que sí las tengan.
  • El tracker nunca ve los datos, solo quién tiene qué.

3) Cómo probarlo en local

Lanza el tracker en una terminal:

python3 tracker.py

Peer seeder (el que ya tiene el archivo):

  • Prepara un archivo de prueba, por ejemplo: archivo_demo.bin (puede ser cualquier fichero).
  • Lanza un peer que lo comparta, por ejemplo en el puerto 5000:
python3 peer.py # Puerto del peer: 5000 # Ruta de archivo local: archivo_demo.bin

Peer leecher (descargador):

  • Lanza uno o más peers sin archivo, por ejemplo 5001 y 5002:
python3 peer.py # Puerto del peer: 5001 # Ruta de archivo local: (deja vacío)
python3 peer.py # Puerto del peer: 5002 # Ruta de archivo local: (deja vacío)

Verás cómo:

  • Los peers se anuncian al tracker.
  • Los leechers piden lista de peers y van solicitando piezas por TCP.
  • Cuando completan todas las piezas, reconstruyen el archivo en disco (si diste una ruta).

Topología general

P2P diagrama de objetos

Lectura rápida:

  • Los peers se anuncian al tracker con qué piezas tienen.
  • Cuando un peer quiere descargar un archivo, pide al tracker una lista de peers para ese file_id.
  • La transferencia real de piezas ocurre directamente entre peers por TCP (el tracker no toca datos).

Flujo de mensajes (secuencia)

P2P diagrama de secuencia

Qué muestra:

  1. Announce: el peer informa al tracker qué piezas tiene (o ninguna si empieza de cero).
  2. Peers: el peer solicita al tracker la lista de candidatos para ese archivo.
  3. Transferencia: el peer pide piezas específicas por TCP a otros peers.
  4. Reanuncio: al obtener nuevas piezas, vuelve a anunciarse para que otros sepan que ahora también puede compartirlas.

Leyenda rápida

  • Tracker: índice central (no almacena archivos), único punto de fallo del modelo híbrido.
  • Peers: pares simétricos; suben y bajan piezas.
  • ANNOUNCE/PEERS (HTTP): control y coordinación.
  • GET pieza (TCP): transferencia de datos real, P2P puro entre nodos.

Si quieres, puedo añadir hash por pieza, rarest-first y paralelismo en otro diagrama para acercarlo aún más a un cliente torrent real.

Qué has aprendido (y siguientes mejoras)

  • El tracker solo coordina (índice de piezas/peers) ⇒ no transfiere datos.
  • Los peers hacen el intercambio directo punto a punto.
  • Simulas el flujo básico de un torrent (announce, peers, piezas).

Mejoras que puedes implementar:

  • TTL / IDs de mensajes y caché para evitar duplicados en otros escenarios.
  • Rarest-first: priorizar piezas más escasas según pieces_map.
  • Verificación por hash por pieza (integridad).
  • Intercambio simultáneo (pedir distintas piezas a distintos peers en paralelo).
  • Reanudar descargas (persistir mapa de piezas en disco).
  • NAT traversal (más avanzado: UDP hole punching).

Si quieres, te preparo una versión con verificación de integridad por hash y descarga en paralelo para que veas cómo se acercaría más a un cliente torrent real.