Red – cr0x.net https://cr0x.net Sat, 07 Feb 2026 13:08:45 +0000 es hourly 1 https://wordpress.org/?v=6.9.4 https://cr0x.net/wp-content/uploads/2026/02/logo-150x150.png Red – cr0x.net https://cr0x.net 32 32 Redes en Docker: la única interpretación errónea de NAT/firewall que expone todo https://cr0x.net/es/docker-nat-firewall-exposicion-total/ https://cr0x.net/es/docker-nat-firewall-exposicion-total/#respond Sat, 07 Feb 2026 13:08:45 +0000 https://cr0x.net/?p=33750 Bloqueas un host con una política de firewall ordenada. Ejecutas un contenedor con “solo un puerto de prueba”. Minutos más tarde, un escáner en otra zona horaria ya está hablando con él. No “abriste” nada—al menos no en tu cabeza.

Esta es la trampa de las redes Docker: una interpretación errónea de cómo interactúan NAT y las cadenas del firewall, y tu modelo mental deja de coincidir con la realidad del kernel. El kernel siempre gana. Asegurémonos de que gane a tu favor.

La interpretación errónea: “Mi firewall decide qué es alcanzable”

El malentendido más caro en redes Docker es pensar que las reglas de tu firewall del host se evalúan “normalmente” para los puertos publicados por contenedores.

En un host Linux que utiliza la red por defecto de puente de Docker, Docker instala reglas de iptables que reescriben el tráfico (DNAT) y lo encamnan hacia los contenedores. Si tu política de firewall asumía un simple “INPUT decide, luego ACCEPT/DROP”, puedes estar equivocado de dos maneras a la vez:

  • Puede que estés filtrando la cadena equivocada. Los puertos publicados por contenedores suelen ser tráfico reencaminado, no entrega local. Eso significa que la decisión suele estar en FORWARD, no en INPUT.
  • Puede que estés filtrando demasiado tarde. Las reescrituras NAT ocurren antes del filtrado de formas que cambian lo que ven tus reglas. Piensas que estás bloqueando el puerto 8080 en el host, pero tras el DNAT es “puerto 80 a 172.17.0.2”, y tu regla ya no coincide.

El trabajo de Docker es hacer que los contenedores sean alcanzables. Tu trabajo es decidir desde dónde. Si no afirmas explícitamente ese límite en el lugar correcto, Docker hará felizmente la parte de alcanzar para todo Internet.

Verdad seca: la postura por defecto de “publicar un puerto” es “hacerlo alcanzable”. La postura por defecto de “ejecutar en una VM en la nube” es “asumir que alguien te escanea”. Combina ambas y obtienes un ticket que nadie disfruta.

El verdadero recorrido del paquete: conntrack, NAT, filter y dónde se engancha Docker

Un modelo mental mínimo que no te miente

Cuando un paquete llega a un host Linux, el kernel no le pregunta a tu firewall cortésmente si puede existir. Clasifica el paquete, consulta conntrack, lo pasa por los hooks de NAT, luego por los hooks de filtrado, y luego lo enruta. El orden importa.

Para una conexión TCP entrante típica a un puerto publicado por Docker en el host (por ejemplo, 203.0.113.10:443), las paradas importantes son:

  1. PREROUTING (nat): La regla DNAT de Docker puede reescribir el destino desde la IP:puerto del host a la IP:puerto del contenedor.
  2. Decisión de enrutamiento: Tras el DNAT, el kernel puede decidir que este tráfico no está destinado al propio host sino que debe reenviarse a una interfaz de puente (docker0).
  3. FORWARD (filter): Aquí es donde muchos firewalls del host olvidan mirar. Docker añade reglas de accept para flujos establecidos y para puertos publicados.
  4. POSTROUTING (nat): Para el tráfico saliente desde contenedores, Docker típicamente aplica SNAT/MASQUERADE para que las respuestas parezcan venir del host.

Las cadenas importantes de Docker (backend iptables)

En sistemas que usan el backend iptables, Docker suele crear y usar estas cadenas:

  • DOCKER (en nat y filter): contiene reglas DNAT y algunas reglas de filtrado para redes de contenedores.
  • DOCKER-USER (en filter): tu punto de inserción soportado. Docker salta aquí temprano para que puedas aplicar tu política.
  • DOCKER-ISOLATION-STAGE-1/2: usado para aislar redes Docker unas de otras.

Por qué las “reglas” de UFW/firewalld pueden parecer correctas y aun así fallar

Muchos gestores de firewall a nivel de host son envoltorios. Generan reglas iptables/nftables en ciertas cadenas con ciertas prioridades. Si se centran en INPUT pero tu tráfico de contenedor se está reenviando, tu “deny” nunca tiene voto.

Además, Docker puede insertar reglas antes que tus reglas gestionadas por la distro, dependiendo de cómo esté construido tu firewall. El paquete coincidirá con la primera regla aceptable y jamás llegará a tu DROP cuidadosamente curado.

Una idea parafraseada de Werner Vogels (CTO de Amazon): “Todo falla todo el tiempo; diseña y opera como si la falla fuera normal.” Aplica eso también a la seguridad: asume que la mala configuración es normal y construye salvaguardas.

Hechos interesantes y contexto histórico

  • Linux netfilter es anterior a Docker por una década. iptables se hizo popular a principios de los 2000, construido sobre hooks de netfilter en el kernel.
  • Conntrack es estado, no magia. El seguimiento de conexiones permite reglas “ESTABLISHED,RELATED” que hacen los firewalls utilizables, pero también significa que un paquete permitido puede crear un flujo permitido de larga duración.
  • El bridge por defecto de Docker es un bridge clásico de Linux. No es un “switch Docker” especial; es el mismo primitivo usado por VMs y namespaces de red durante años.
  • “Publicar” significa “ligar a todas las interfaces” salvo que se indique lo contrario. -p 8080:80 normalmente se liga a 0.0.0.0 (y a menudo a ::) a menos que especifiques una IP.
  • Docker introdujo DOCKER-USER como concesión a la realidad. La gente necesitaba un punto estable para aplicar política que sobreviva a reinicios de Docker y que no sea reescrito.
  • nftables no reemplazó instantáneamente a iptables en la práctica. Muchas distribuciones migraron a nftables internamente, pero las herramientas, expectativas e integración con Docker tardaron años en ponerse al día.
  • Hairpin NAT es anterior a tu incidente actual. El problema de “el host se habla a sí mismo vía su IP pública” existe en muchos dispositivos NAT; Docker puede desencadenar rarezas similares en una sola máquina.
  • El networking ingress de Swarm usa su propia tubería. El routing mesh puede publicar puertos en todos los nodos, lo que sorprende a equipos que esperan “solo el nodo con la tarea está expuesto”.
  • Los security groups en la nube no son sustituto de la política del host. Son una capa, no una garantía—reglas mal aplicadas o cambios posteriores pueden seguir exponerte.

Cómo ocurre la “exposición” realmente (cuatro modos comunes)

Modo 1: Publicaste un puerto y olvidaste que se enlaza al mundo

docker run -p 8080:80 … es conveniente. También es una exposición explícita. Si querías “solo localhost”, necesitas decirlo: -p 127.0.0.1:8080:80.

Este es el bug de “no pensé que sería público”. Docker pensó que sí. Docker tenía razón.

Modo 2: Tu firewall filtra INPUT, pero el tráfico de Docker pasa por FORWARD

Si el paquete es DNAT’d a una IP de contenedor, ya no está destinado al host. Eso lo empuja a la lógica de reenvío. Si tu postura de seguridad ignora FORWARD, has dejado una puerta lateral abierta—de par en par.

Modo 3: Confías en los valores por defecto de UFW/firewalld que no contemplan las cadenas de Docker

Algunos gestores de firewall establecen políticas por defecto de forward en ACCEPT, o no gestionan las cadenas de Docker en absoluto. Puedes acabar con un firewall que parece restrictivo para el host, mientras los contenedores van por un carril rápido separado.

Modo 4: Optimizaste por rendimiento y accidentalmente eliminaste un punto de estrangulamiento

Deshabilitar conntrack, cambiar bridge-nf-call-iptables, cambiar backends de iptables o activar funciones de “ruta rápida” puede cambiar qué reglas se ejecutan y cuándo. Aquí es donde “funcionó en staging” muere.

Broma #1: NAT es como un organigrama corporativo—todo pasa por ahí y nadie admite que lo posee.

Tareas prácticas: comandos, salidas y la decisión que tomas

Estos no son “ejecuta esto porque lo dijo un blog”. Cada uno te dice algo concreto. Cada uno tiene una decisión adjunta.

Tarea 1: Lista los puertos publicados y sus direcciones de enlace

cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
NAMES          IMAGE             PORTS
web            nginx:1.25        0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
metrics        prom/prometheus   127.0.0.1:9090->9090/tcp
db             postgres:16       5432/tcp

Qué significa: web es alcanzable en todas las interfaces IPv4 e IPv6. metrics es solo localhost. db no está publicado.

Decisión: Si no debería ser alcanzable desde Internet, para y vuelve a ejecutar con una IP de enlace explícita o elimina la publicación. No “lo arregles luego en el firewall” a menos que también controles DOCKER-USER.

Tarea 2: Confirma qué está realmente escuchando en el host

cr0x@server:~$ sudo ss -lntp | sed -n '1,8p'
State  Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
LISTEN 0      4096   0.0.0.0:8080       0.0.0.0:*       users:(("docker-proxy",pid=2211,fd=4))
LISTEN 0      4096   127.0.0.1:9090     0.0.0.0:*       users:(("docker-proxy",pid=2332,fd=4))
LISTEN 0      4096   0.0.0.0:22         0.0.0.0:*       users:(("sshd",pid=1023,fd=3))

Qué significa: Docker está exponiendo 8080 en todas las interfaces. Incluso si existen reglas iptables, un socket en escucha es el primer paso de la alcanzabilidad.

Decisión: Si ves 0.0.0.0 o :: y no lo pretendías, primero corrige las flags de publicación o la definición del servicio.

Tarea 3: Inspecciona el uso de docker-proxy (y no asumas que desapareció)

cr0x@server:~$ ps -ef | grep -E 'docker-proxy|dockerd' | head
root      1190     1  0 08:11 ?        00:00:12 /usr/bin/dockerd -H fd://
root      2211  1190  0 09:02 ?        00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 80
root      2332  1190  0 09:03 ?        00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 127.0.0.1 -host-port 9090 -container-ip 172.17.0.3 -container-port 9090

Qué significa: Algunas configuraciones aún usan userland proxying para puertos publicados. Eso puede cambiar cómo los paquetes atraviesan el firewall del host y afectar el logging.

Decisión: Si estás solucionando “por qué mi regla INPUT no coincidió”, anota si el tráfico se está proxyando localmente o se reenvía al contenedor.

Tarea 4: Ver las reglas iptables de Docker en la tabla nat (donde ocurre el DNAT)

cr0x@server:~$ sudo iptables -t nat -S | sed -n '1,60p'
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
-N DOCKER
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

Qué significa: Cualquier paquete destinado a direcciones locales en el puerto 8080 puede ser DNAT’d al contenedor. Eso incluye IPs públicas en el host.

Decisión: Si necesitas restringir fuentes, hazlo en DOCKER-USER (filter) o ajusta la publicación/enlace; no pelees contra DNAT con reglas INPUT tardías.

Tarea 5: Comprueba el orden de la tabla filter, especialmente DOCKER-USER

cr0x@server:~$ sudo iptables -S FORWARD
-P FORWARD DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -o docker0 -j DOCKER
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
-A FORWARD -i docker0 -o docker0 -j ACCEPT

Qué significa: Docker está saltando a DOCKER-USER temprano. Eso es bueno: tienes un punto de estrangulamiento. La política por defecto de FORWARD es DROP aquí, lo cual también es bueno.

Decisión: Pon tus listas de permisos/denegaciones en DOCKER-USER. Si tu sistema no tiene ese salto, debes arreglar ese modelo de política inmediatamente.

Tarea 6: Inspecciona la cadena DOCKER-USER (tu punto de aplicación)

cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -j RETURN

Qué significa: No se está aplicando ninguna política para el tráfico reenviado a contenedores. Todo pasa sin cambios.

Decisión: Añade reglas explícitas: denegar por defecto y permitir solo lo que deba ser alcanzable.

Tarea 7: Añadir una regla “denegar por defecto inbound a contenedores” de forma segura

cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -i eth0 -o docker0 -m conntrack --ctstate NEW -j DROP
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -i eth0 -o docker0 -m conntrack --ctstate NEW -j DROP
-A DOCKER-USER -j RETURN

Qué significa: Las nuevas conexiones entrantes que lleguen por eth0 y se reenvíen a contenedores vía docker0 serán descartadas. Los flujos ya establecidos siguen permitidos mediante otras reglas.

Decisión: Este es tu freno de emergencia. Después de aplicarlo, permite selectivamente los puertos publicados requeridos desde las fuentes necesarias.

Tarea 8: Permitir un servicio publicado específico desde un rango de origen específico

cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -p tcp -s 198.51.100.0/24 -d 172.17.0.2 --dport 80 -j ACCEPT
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -p tcp -s 198.51.100.0/24 -d 172.17.0.2 --dport 80 -j ACCEPT
-A DOCKER-USER -i eth0 -o docker0 -m conntrack --ctstate NEW -j DROP
-A DOCKER-USER -j RETURN

Qué significa: Estás permitiendo solo el rango de origen previsto para alcanzar ese puerto del contenedor, por delante del drop por defecto.

Decisión: Prefiere listas de permitidos por origen y destino cuando sea posible. Si no puedes, al menos restringe por interfaz y puerto.

Tarea 9: Verifica desde la perspectiva del host qué ruta e interfaz usa el reenvío

cr0x@server:~$ ip route get 172.17.0.2
172.17.0.2 dev docker0 src 172.17.0.1 uid 1000
    cache

Qué significa: El tráfico hacia la IP del contenedor va por docker0. Eso valida que tu interfaz coincide con las reglas del firewall.

Decisión: Si usas redes personalizadas (por ejemplo, br-*), actualiza las reglas para que coincidan con las interfaces de salida correctas.

Tarea 10: Comprueba los sysctls de bridge netfilter que cambian si el tráfico en puente pasa por iptables

cr0x@server:~$ sudo sysctl net.bridge.bridge-nf-call-iptables net.bridge.bridge-nf-call-ip6tables
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1

Qué significa: El tráfico en puente recorrerá las reglas de iptables. Si estos valores son 0, algunas expectativas de filtrado se rompen y puedes acabar con la confusión de “las reglas del firewall no funcionan”.

Decisión: Mantén estos valores habilitados a menos que comprendas profundamente las compensaciones y tengas un mecanismo alternativo de aplicación.

Tarea 11: Determina si iptables está usando el backend nft (importa para depurar)

cr0x@server:~$ sudo iptables -V
iptables v1.8.9 (nf_tables)

Qué significa: Los comandos iptables están manipulando nftables por debajo. El orden de las reglas y la coexistencia con reglas nft nativas pueden ser sorprendentes.

Decisión: Al depurar, usa también nft list ruleset, no solo la salida de iptables.

Tarea 12: Inspecciona el ruleset de nftables para la interacción con Docker

cr0x@server:~$ sudo nft list ruleset | sed -n '1,80p'
table inet filter {
  chain forward {
    type filter hook forward priority 0; policy drop;
    jump DOCKER-USER
    jump DOCKER-ISOLATION-STAGE-1
    ct state related,established accept
  }
  chain DOCKER-USER {
    iif "eth0" oif "docker0" ct state new drop
    return
  }
}
table ip nat {
  chain PREROUTING {
    type nat hook prerouting priority -100; policy accept;
    fib daddr type local jump DOCKER
  }
  chain DOCKER {
    tcp dport 8080 dnat to 172.17.0.2:80
  }
}

Qué significa: Puedes ver la misma estructura lógica en términos de nftables: NAT en PREROUTING, filtrado en forward y tu política DOCKER-USER.

Decisión: Si tu distro usa nftables de forma nativa, considera gestionar la política de Docker en nft directamente para coherencia—pero asegúrate de que Docker siga manteniendo sus cadenas estables.

Tarea 13: Confirma si un puerto es alcanzable desde un punto externo

cr0x@server:~$ nc -vz -w 2 203.0.113.10 8080
Connection to 203.0.113.10 8080 port [tcp/*] succeeded!

Qué significa: Es alcanzable. Ninguna cantidad de “pensé que el firewall…” cambia eso.

Decisión: Si esto no debería ser alcanzable, detén el tráfico en DOCKER-USER o elimina la publicación y vuelve a probar. Luego verifica IPv6 por separado.

Tarea 14: Comprueba explícitamente la exposición IPv6

cr0x@server:~$ sudo ss -lnt | grep ':8080 '
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:*
LISTEN 0 4096 [::]:8080    [::]:*

Qué significa: También estás escuchando en IPv6. Si tu security group o firewall solo consideró IPv4, podrías tener exposición IPv6 accidental.

Decisión: Asegura IPv6 de forma equivalente o desactívalo deliberada y cuidadosamente para el servicio. “No usamos IPv6” no es un control.

Tarea 15: Traza la travesía del paquete con contadores (¿tu regla siquiera coincidió?)

cr0x@server:~$ sudo iptables -L DOCKER-USER -v -n
Chain DOCKER-USER (1 references)
 pkts bytes target  prot opt in  out     source          destination
   12   720 ACCEPT  tcp  --  *   *       198.51.100.0/24 172.17.0.2        tcp dpt:80
   55  3300 DROP    all  --  eth0 docker0 0.0.0.0/0       0.0.0.0/0        ctstate NEW
  890 53400 RETURN  all  --  *   *       0.0.0.0/0       0.0.0.0/0

Qué significa: Los contadores cuentan la historia: la regla de permitir coincidió con 12 paquetes; la regla de DROP está bloqueando activamente nuevos intentos.

Decisión: Si los contadores no se mueven, estás mirando la cadena equivocada o la interfaz equivocada. Deja de adivinar; sigue los contadores.

Tarea 16: Verifica las redes Docker y los nombres de interfaces bridge

cr0x@server:~$ docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
c2f6b2a1c3c1   bridge    bridge    local
a7d1f9e2a5b7   appnet    bridge    local
cr0x@server:~$ ip link show | grep -E 'docker0|br-'
4: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
6: br-a7d1f9e2a5b7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default

Qué significa: Las redes bridge definidas por el usuario crean interfaces br-*. Si solo escribiste reglas para docker0, otras redes pueden seguir expuestas.

Decisión: Actualiza la política del firewall para cubrir todas las interfaces bridge de Docker, no solo la por defecto.

Guion de diagnóstico rápido

Si sospechas que un puerto de contenedor está expuesto (o que un firewall “no funciona”), no tomes la ruta escénica. Haz esto en orden.

Primero: identifica la superficie de exposición

  1. Lista los puertos publicados (docker ps con Ports) y busca 0.0.0.0 / ::.
  2. Revisa los sockets en escucha (ss -lntp) para confirmar qué está ligado y por qué proceso.
  3. Prueba la alcanzabilidad desde fuera (o desde un host puente) con nc o curl.

Segundo: localiza el punto de decisión en netfilter

  1. Encuentra reglas DNAT en iptables -t nat -S (o en la tabla nat de nft).
  2. Comprueba el orden de la cadena FORWARD y confirma que se salta a DOCKER-USER temprano.
  3. Usa contadores (iptables -L -v) para ver qué reglas están coincidiendo realmente.

Tercero: arregla con el cambio menos ingenioso

  1. Prefiere restringir el bind (-p 127.0.0.1:…) o quitar -p por completo.
  2. Aplica una política base en DOCKER-USER: drop por defecto para nuevas conexiones entrantes a bridges Docker; allowlist lo que debe ser público.
  3. Vuelve a probar la alcanzabilidad externa y valida que los contadores se movieron como esperabas.

Broma #2: Si vas a “abrir un puerto por cinco minutos”, tus atacantes son puntuales.

Errores comunes: síntoma → causa raíz → solución

1) “UFW dice que está bloqueado, pero el contenedor sigue siendo alcanzable”

Síntoma: UFW niega 8080, pero nc desde Internet se conecta.

Causa raíz: El tráfico se está reenviando a un contenedor (FORWARD), mientras que las reglas de UFW cubren principalmente INPUT. Las reglas iptables de Docker permiten el reenvío.

Solución: Pon la política en DOCKER-USER. O bien drop por defecto nuevas conexiones entrantes a bridges Docker, o permite por lista blanca fuentes/puertos específicos.

2) “Bloqueé el puerto 8080 en INPUT, pero sigue funcionando”

Síntoma: Una regla de DROP en INPUT para tcp/8080 no hace nada.

Causa raíz: El DNAT en PREROUTING cambia el destino a IP:puerto de contenedor; el paquete ya no coincide con INPUT para entrega local.

Solución: Filtra en FORWARD (idealmente en DOCKER-USER). O no publiques en 0.0.0.0 en primer lugar.

3) “Solo está expuesto en IPv6 y nadie lo notó”

Síntoma: IPv4 está bloqueado; un escáner IPv6 reporta un puerto abierto.

Causa raíz: Docker publicó en ::; el firewall IPv6 no es equivalente; el security group ignora IPv6.

Solución: Añade reglas de firewall IPv6; enlaza explícitamente a IPv4 localhost si es necesario; o desactiva IPv6 con cuidado y a conciencia.

4) “Después de habilitar nftables, la red de Docker se volvió rara”

Síntoma: Puertos publicados fallan intermitentemente, o las reglas no aparecen donde esperas.

Causa raíz: Gestión mixta: reglas nft nativas más traducción iptables-nft más cadenas generadas por Docker. La prioridad y el orden de hooks difieren de las suposiciones.

Solución: Estandariza: o gestiona vía iptables consistentemente (con consciencia del backend nft) o adopta una política coherente en nftables que respete las cadenas de Docker.

5) “Los contenedores en una red bridge personalizada están expuestos aunque docker0 esté bloqueado”

Síntoma: Las reglas que referencian docker0 funcionan, pero los contenedores en br-* son alcanzables.

Causa raíz: Las redes definidas por el usuario usan distintas interfaces bridge; tus reglas no las cubren.

Solución: Empareja en oifname "br-*" (nft) o añade reglas de interfaz para cada bridge, o agrúpalas usando ipsets/conjuntos nft.

6) “Swarm publicó un puerto en todas partes”

Síntoma: Un servicio publicado en un nodo aparece alcanzable en todos los nodos.

Causa raíz: El routing mesh de Swarm (ingress) publica el puerto a nivel de clúster; el tráfico se reenvía internamente a una tarea activa.

Solución: Usa publicación en modo host para exposición local al nodo, o aplica restricciones de borde con firewalls/load balancers, y trata el ingress de Swarm como un plano de exposición compartido.

Tres microhistorias corporativas desde el campo

Microhistoria 1: El incidente causado por una suposición equivocada

Una empresa SaaS mediana migró un servicio legado a contenedores en un par de VMs en la nube. El equipo tenía la costumbre bien ganada: bloquear todo en el firewall del host y luego abrir agujeros solo para el reverse proxy y SSH desde la VPN.

Durante la migración, un ingeniero publicó un puerto para una UI de administración interna: -p 8443:8443. La suposición era razonable y de vieja escuela: “El firewall del host lo bloquea a menos que lo abramos”. No lo abrieron.

Dos días después, el equipo de seguridad detectó tráfico saliente a un rango de IPs desconocido. La UI de administración no fue “hackeada” al estilo Hollywood; simplemente era alcanzable. La UI tenía autenticación básica, pero también un endpoint que lanzaba trabajos de fondo costosos. Un desconocido en Internet lo encontró y lo trató como un calefactor crypto gratis.

La discusión post-incidente fue predecible. El equipo de la app culpó al firewall. Infra culpó al equipo de la app por publicar un puerto. Ambos tenían media razón, y así se generan incidentes repetidos.

La corrección real fue aburrida: aplicar un drop por defecto explícito para conexiones NEW entrantes a las interfaces bridge de Docker en DOCKER-USER, y requerir que los servicios se enlacen a 127.0.0.1 salvo que exista un ticket que pruebe la necesidad de acceso externo. La siguiente UI de administración se publicó a localhost y luego se enroutó vía reverse proxy con autentificación y logs de auditoría adecuados.

Microhistoria 2: La optimización que salió mal

Una plataforma cercana a trading buscaba latencia. Alguien notó el overhead en el procesamiento de paquetes y propuso “simplificar el firewall” confiando más en security groups upstream y menos en las reglas del host. En la misma ventana de cambios, voltearon algunos sysctls del kernel y limpiaron lo que consideraron cadenas iptables “redundantes”.

El rendimiento mejoró—lo suficiente como para que los gráficos se vieran bien en una reunión. Luego apareció un problema aparentemente no relacionado: un servicio en contenedor era alcanzable desde un segmento de red que no debía comunicarse con él. No era Internet pública, sino una subred interna amplia con demasiadas laptops y demasiada curiosidad.

La causa raíz no fue un único cambio; fue la combinación. Eliminar el filtrado de forward a nivel de host y cambiar el comportamiento de bridge netfilter hizo que algunos caminos de tráfico dejaran de pasar por los puntos de aplicación previstos. El security group upstream aún “se veía bien”, pero dentro del VPC el radio de impacto se amplió.

No deshicieron todo el trabajo de rendimiento. Reintrodujeron la aplicación en DOCKER-USER con un conjunto de reglas estrecho y mínimos matches, y midieron honestamente el impacto en latencia. Fue pequeño. La reducción de riesgo no lo fue.

Microhistoria 3: La práctica aburrida que salvó el día

Una compañía relacionada con la salud (mucho cumplimiento, muchas auditorías, muchas personas a las que les gustan los checkboxes) tenía una regla: cualquier puerto de contenedor publicado a algo que no sea localhost debe estar justificado, documentado y probado desde un punto externo como parte del cambio.

No era glamuroso. Los ingenieros refunfuñaban. Pero la pipeline incluía una etapa simple: desplegar en un host canario, ejecutar un pequeño conjunto de comprobaciones de conectividad externa y fallar el cambio si cualquier puerto que “debería ser privado” era alcanzable.

Un viernes, un responsable de servicio intentó “temporalmente” publicar un puerto de depuración en todas las interfaces para que un proveedor pudiera probar algo. La solicitud de cambio lo mencionaba, pero la evaluación de riesgos fue vaga. La pipeline lo detectó porque el puerto era alcanzable desde una red no aprobada en el entorno de pruebas.

La solución no fue heroica. Fue un pequeño cambio de configuración: publicar a 127.0.0.1 y acceder mediante un túnel SSH de corta duración desde un bastión controlado. El proveedor aún pudo probar. El puerto nunca se convirtió en un endpoint sorpresa en Internet. Todos se fueron a casa a tiempo, que es el verdadero KPI.

Listas de verificación / plan paso a paso

Lista básica de endurecimiento (host Docker único)

  1. Inventario de exposición: lista puertos publicados (docker ps) y sockets en escucha (ss -lntp).
  2. Decide la intención por puerto: público, solo VPN, interno o localhost-only.
  3. Haz explícito el binding: usa -p 127.0.0.1:HOST:CONTAINER para localhost-only; especifica una IP de interfaz interna si es necesario.
  4. Aplica política por defecto en DOCKER-USER: drop para nuevas conexiones entrantes reenviadas por defecto.
  5. Lista blanca intencionalmente: añade aceptaciones estrechas antes del drop—por rango de origen, IP/puerto destino del contenedor e interfaz cuando sea posible.
  6. Valida IPv6: comprueba listeners y paridad del firewall; no asumas que está desactivado.
  7. Persiste las reglas: asegura que tus reglas DOCKER-USER sobrevivan reinicios (específico del sistema: iptables-persistent, configuración nftables, etc.).
  8. Vuelve a probar desde fuera: verifica que la alcanzabilidad coincide con la intención.

Paso a paso: convertir una “publicación pública” en “privada tras reverse proxy”

  1. Cambia la publicación de pública a localhost:
    • De -p 8080:80 a -p 127.0.0.1:8080:80.
  2. Frénalo con un reverse proxy en el host o en un contenedor edge dedicado que sea el único puerto orientado a Internet.
  3. Aplica el drop base en DOCKER-USER para nuevas conexiones entrantes a interfaces bridge, de modo que “alguien republicó un puerto” no sea exposición instantánea.
  4. Añade autenticación, límites de tasa y logs en la capa del reverse proxy; los servicios en contenedores no deben reinventar la seguridad perimetral individualmente.
  5. Ejecuta validación externa con nc/curl desde una red no aprobada y confirma que falla.

Paso a paso: contención de emergencia cuando sospechas exposición

  1. Aplica un drop de emergencia en DOCKER-USER para nuevas conexiones entrantes a bridges docker (con alcance por interfaz) para detener la hemorragia.
  2. Confirma que la alcanzabilidad externa se detiene mediante una prueba desde fuera.
  3. Identifica los puertos publicados y elimínalos o restrínelos a nivel Docker run/compose.
  4. Reemplaza el drop de emergencia con reglas allowlist para que los servicios requeridos sigan siendo alcanzables.
  5. Audita la exposición IPv6 y aplica la contención correspondiente allí también.

Preguntas frecuentes

1) ¿Es “EXPOSE” en un Dockerfile lo mismo que publicar un puerto?

No. EXPOSE es metadata. La publicación ocurre con -p/--publish o en compose ports:. Solo publicar crea alcanzabilidad desde el host.

2) ¿Por qué mi cadena INPUT no ve el tráfico a puertos publicados de contenedores?

Porque tras el DNAT, el tráfico se enruta a una IP de contenedor y se convierte en tráfico reenviado. Eso se filtra típicamente en FORWARD, no en INPUT.

3) ¿Dónde debería poner mi política a nivel de host para el tráfico Docker?

En DOCKER-USER. Está diseñado como punto de inserción estable que Docker no reescribirá al reiniciarse.

4) Si me enlazo a 127.0.0.1, ¿estoy seguro?

Estás más seguro. El binding a localhost impide el acceso a la red externa a nivel de socket. Aun así, considera amenazas internas, compromisos locales y si otro proceso podría proxyarlo hacia afuera.

5) ¿Docker siempre usa docker-proxy para puertos publicados?

No. El comportamiento varía según la versión de Docker, las características del kernel y la configuración. No asumas un único camino de paquetes—confírmalo con ss y listados de procesos.

6) ¿Cómo cambia la historia IPv6?

Añade un segundo plano de exposición. Puedes tener “IPv4 seguro” y “IPv6 abierto” al mismo tiempo. Valida listeners y reglas de firewall para ambos.

7) Estoy usando nftables. ¿Debería dejar de usar comandos iptables?

Si tu sistema ejecuta iptables con backend nft, los comandos iptables siguen funcionando pero pueden ocultar el orden final de reglas. Para depuración profunda, inspecciona nft list ruleset.

8) ¿El Docker sin root evita estos fallos de firewall?

El modo rootless cambia la red y la mecánica de publicación de puertos, y puede reducir el radio de acción de privilegios a nivel de daemon. No elimina la necesidad de pensar claramente sobre qué es alcanzable y desde dónde.

9) ¿Docker Compose cambia algo de esto?

No hay un cambio fundamental. Compose es una interfaz más cómoda para los mismos primitivos. Si compose indica ports: - "8080:80", lo estás publicando al mundo a menos que especifiques una IP.

10) Solo confío en security groups de la nube. ¿Puedo ignorar el firewall del host?

Puedes hacerlo, hasta que un security group se aplique mal, se copie desde otro entorno o se cambie bajo presión. La defensa en profundidad no es un eslogan; es lo que evita que “un mal día” se convierta en “un mal trimestre”.

Siguientes pasos que puedes hacer hoy

  1. Audita cada host: ejecuta docker ps y ss -lntp. Anota qué está ligado a 0.0.0.0 y ::.
  2. Establece una política por defecto para entradas a contenedores: implementa un drop base para nuevas conexiones reenviadas en DOCKER-USER, y luego lista blanca explícitamente.
  3. Haz que publicar sea intencional: exige IPs de enlace explícitas en definiciones de compose/servicio; por defecto usa localhost y enruta a través de un proxy de borde.
  4. Prueba como un atacante: valida la alcanzabilidad desde fuera de tu segmento de red, incluyendo IPv6.
  5. Operativiza: persiste reglas, añade controles en CI/CD para puertos publicados no deseados y trata los cambios de firewall/Docker como cambios de producción con rollback.

Si recuerdas una cosa: Docker no “está evitando” tu firewall por malicia. Está usando el kernel exactamente como fue diseñado. Tu trabajo es poner la política en el lugar que el kernel realmente consulta.

]]>
https://cr0x.net/es/docker-nat-firewall-exposicion-total/feed/ 0
Docker + Proxy inverso: Misterios del 502 — Dónde está realmente la falla (y soluciones) https://cr0x.net/es/docker-proxy-502-misterios/ https://cr0x.net/es/docker-proxy-502-misterios/#respond Wed, 10 Dec 2025 04:56:37 +0000 https://cr0x.net/2026/01/02/docker-proxy-502-misterios/ Un 502 desde tu proxy inverso es el equivalente operativo de un encogimiento de hombros. Tus usuarios ven “Puerta de enlace incorrecta”.
Tú no ves… nada. La aplicación podría estar bien. El proxy podría estar bien. La red podría estar mintiendo. O un único
contenedor podría estar reiniciándose en bucle en silencio como si le pagaran por el reinicio.

]]>
https://cr0x.net/es/docker-proxy-502-misterios/feed/ 0
Puerto Docker publicado pero inaccesible: la checklist real (Sin conjeturas) https://cr0x.net/es/puerto-docker-publicado-inaccesible-checklist/ https://cr0x.net/es/puerto-docker-publicado-inaccesible-checklist/#respond Mon, 24 Nov 2025 08:41:05 +0000 https://cr0x.net/2026/01/07/puerto-docker-publicado-inaccesible-checklist/ Iniciaste el contenedor. Publicaste el puerto. docker ps muestra con suficiencia 0.0.0.0:8080->80/tcp.
Y, sin embargo, tu navegador se queda esperando como si esperara el autobús bajo la lluvia.

“Puerto publicado pero inaccesible” es una de esas fallas que hace que la gente inteligente haga tonterías: reiniciar Docker, alternar reglas de firewall al azar,
reconstruir imágenes y lanzar amenazas al NAT. Para. Esto es un sistema determinista. Si un puerto publicado no se puede alcanzar,
algo específico está bloqueando el paquete o la aplicación no está escuchando donde crees que lo está.

El modelo mental: qué significa realmente “publicado”

Cuando publicas un puerto con Docker (-p 8080:80), no estás “abriendo un puerto dentro del contenedor.”
Estás pidiendo al motor de Docker que organice el tráfico para que los paquetes que lleguen al puerto del host (8080) se reenvíen al puerto del contenedor (80).
Ese reenvío puede implementarse de varias maneras según la plataforma y el modo:

  • Docker en Linux con privilegios (clásico): reglas NAT de iptables/nftables (DNAT) más un pequeño proxy en espacio de usuario en algunos escenarios.
  • Docker rootless en Linux: con frecuencia usa slirp4netns / reenvío en espacio de usuario, con restricciones y características de rendimiento distintas.
  • Docker Desktop (Mac/Windows): hay una VM, y el reenvío de puertos atraviesa la frontera host ↔ VM, con una capa extra de “diversión”.

“Publicado” significa que Docker creó la intención. No garantiza que el paquete sobreviva:
el firewall del host, los grupos de seguridad en la nube, enlace a la interfaz equivocada, ruta faltante, desajuste con un proxy inverso o un proceso que escucha solo en 127.0.0.1
pueden producir el mismo síntoma: un puerto que parece abierto pero se comporta como una pared de ladrillo.

El truco diagnóstico es dejar de tratarlo como “redes de Docker” y empezar a tratarlo como un problema de ruta:
cliente → red → interfaz del host → firewall → NAT → veth del contenedor → proceso del contenedor.
Encuentra el primer lugar donde la realidad diverge de la expectativa.

Una idea parafraseada de Werner Vogels (CTO de Amazon): todo falla eventualmente; diseña para poder detectar, aislar y recuperar rápidamente.
Los puertos publicados no son diferentes: instrumenta la ruta y la verdad sale a la luz.

Guía rápida de diagnóstico (primero/segundo/tercero)

Primero: confirma que el servicio realmente está escuchando (dentro del contenedor)

Si la app no está escuchando en el puerto que mapeaste, Docker puede reenviar paquetes todo el día y aún así obtendrás timeouts o resets.
No empieces por iptables. Empieza por el proceso.

Segundo: valida que el host esté escuchando y reenviando (en la interfaz correcta)

Comprueba que el puerto del host esté ligado, en qué interfaz está ligado y si Docker insertó las reglas NAT esperadas.
Si el host no está escuchando, o solo escucha en 127.0.0.1, los clientes remotos no podrán conectarse.

Tercero: elimina “bloqueadores externos” (firewalls, grupos de seguridad en la nube, enrutamiento)

Si localhost funciona pero lo remoto no, deja de culpar a Docker. Eso es un problema de perímetro: política de UFW/firewalld/nftables, grupo de seguridad en la nube,
enrutamiento del host o una comprobación de salud del balanceador que golpea al sitio equivocado.

Cuarto: revisa modos especiales y características límite

Docker rootless, IPv6, network host, macvlan, redes overlay, proxies inversos y NAT hairpin traen cada uno sus aristas afiladas.
Si estás en uno de esos, sé explícito y sigue la rama correspondiente en la checklist más abajo.

Chiste #1: NAT es como la política de oficina—todo el mundo dice que lo entiende, y luego los ves culpar a la impresora.

Tareas prácticas: comandos, salidas, decisiones (12+)

Estos son cheques de grado producción. Cada uno incluye un comando, salida típica, qué significa y la decisión que tomas a continuación.
Ejecútalos en orden hasta encontrar la primera cosa “mal”. Ese es el punto donde te detienes y arreglas.

Tarea 1: Confirma el mapeo que Docker cree que creó

cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Ports}}'
NAMES          PORTS
web-1          0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
db-1           5432/tcp

Significado: Docker afirma que publicó 8080 en todas las interfaces IPv4 y también en IPv6.
Si solo ves 127.0.0.1:8080->80/tcp, está ligado a localhost y las conexiones remotas fallarán.

Decisión: Si el mapeo se ve mal, arregla la configuración de run/compose primero. Si se ve bien, continúa.

Tarea 2: Inspecciona los bindings de puertos del contenedor (verdad fundamental)

cr0x@server:~$ docker inspect -f '{{json .NetworkSettings.Ports}}' web-1
{"80/tcp":[{"HostIp":"0.0.0.0","HostPort":"8080"}]}

Significado: La configuración del contenedor de Docker dice: host 0.0.0.0:8080 reenvía a contenedor 80/tcp.
Si aparece null para el puerto, no está publicado.

Decisión: Si el binding no es lo esperado, redepliega con el -p correcto o con ports: en compose.

Tarea 3: Verifica que el servicio esté escuchando dentro del contenedor

cr0x@server:~$ docker exec -it web-1 sh -lc "ss -lntp | sed -n '1,6p'"
State  Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
LISTEN 0      4096   0.0.0.0:80         0.0.0.0:*     users:(("nginx",pid=1,fd=6))

Significado: Algo (nginx) está escuchando en 0.0.0.0:80 dentro del contenedor.
Si ves solo 127.0.0.1:80, generalmente aún funcionará porque el tráfico llega localmente dentro del contenedor,
pero algunas aplicaciones se ligan de forma extraña con solo IPv6 o sockets unix.

Decisión: Si nadie está escuchando, arregla la app/contenedor (comando equivocado, crash loop, configuración).
Si está, continúa.

Tarea 4: Hacer curl al servicio desde dentro del contenedor

cr0x@server:~$ docker exec -it web-1 sh -lc "apk add --no-cache curl >/dev/null 2>&1; curl -sS -D- http://127.0.0.1:80/ | head"
HTTP/1.1 200 OK
Server: nginx/1.25.3
Date: Tue, 02 Jan 2026 14:01:12 GMT
Content-Type: text/html

Significado: La app responde localmente. Si esto falla, detente. Publicar puertos no arreglará una aplicación rota.

Decisión: Si el curl interno falla, diagnostica la aplicación. Si tiene éxito, muévete hacia afuera.

Tarea 5: Hacer curl vía la IP del contenedor desde el host

cr0x@server:~$ docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' web-1
172.17.0.4
cr0x@server:~$ curl -sS -D- http://172.17.0.4:80/ | head
HTTP/1.1 200 OK
Server: nginx/1.25.3
Date: Tue, 02 Jan 2026 14:01:20 GMT
Content-Type: text/html

Significado: El host puede alcanzar el contenedor a través de la red bridge. Si esto falla, el problema está dentro de la red host/contenedor
(bridge caído, enrutamiento por política, cadena DOCKER-USER, módulos de seguridad, o el contenedor adjunto a otra red).

Decisión: Si host → IP del contenedor falla, inspecciona docker network, iptables y políticas del host. Si tiene éxito, verifica la ruta de publicación.

Tarea 6: Hacer curl vía el puerto publicado del host desde el host

cr0x@server:~$ curl -sS -D- http://127.0.0.1:8080/ | head
HTTP/1.1 200 OK
Server: nginx/1.25.3
Date: Tue, 02 Jan 2026 14:01:28 GMT
Content-Type: text/html

Significado: El reenvío de puertos funciona localmente. Si localhost funciona pero lo remoto no, ahora estás en territorio de firewall/interfaz.

Decisión: Si localhost falla, inspecciona la escucha del host y las reglas NAT a continuación. Si localhost funciona, salta a comprobaciones de perímetro.

Tarea 7: Ver qué está realmente escuchando en el puerto del host

cr0x@server:~$ sudo ss -lntp | grep ':8080'
LISTEN 0      4096         0.0.0.0:8080      0.0.0.0:*    users:(("docker-proxy",pid=2314,fd=4))

Significado: El host tiene un listener, a menudo docker-proxy. En configuraciones más nuevas, puede que no veas docker-proxy,
porque DNAT es suficiente; entonces ss puede no mostrar nada aunque funcione.

Decisión: Si ves solo 127.0.0.1:8080, arregla el binding (ports en compose, o IP explícita del host).
Si no ves nada y falla, comprueba reglas NAT y la configuración del daemon de Docker.

Tarea 8: Confirma que Docker insertó reglas NAT (vista legacy de iptables)

cr0x@server:~$ sudo iptables -t nat -S DOCKER | sed -n '1,6p'
-N DOCKER
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.4:80

Significado: Los paquetes que llegan al TCP/8080 del host desde interfaces distintas a docker0 son DNATeados a la IP del contenedor/80.
Si falta la regla, Docker no está programando NAT (común con modo rootless, flags personalizados del daemon o incompatibilidades con nftables).

Decisión: Si las reglas faltan o son incorrectas, arregla el backend de red de Docker o redepliega Docker con integración adecuada de iptables.

Tarea 9: Revisa la cadena DOCKER-USER (la cadena “te bloqueaste a ti mismo”)

cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -j DROP

Significado: Este host descarta el tráfico reenviado antes de las propias reglas de Docker. Esto hará que los puertos publicados sean inaccesibles desde la red
mientras que localhost podría seguir funcionando (dependiendo de la ruta).

Decisión: Reemplaza descartes globales por reglas de permiso explícitas, o mueve la política a una capa de firewall controlada que tenga en cuenta Docker.

Tarea 10: Si usas nftables, inspecciona el ruleset (vista moderna)

cr0x@server:~$ sudo nft list ruleset | sed -n '1,40p'
table ip nat {
  chain PREROUTING {
    type nat hook prerouting priority dstnat; policy accept;
    tcp dport 8080 dnat to 172.17.0.4:80
  }
  chain OUTPUT {
    type nat hook output priority -100; policy accept;
    tcp dport 8080 dnat to 172.17.0.4:80
  }
}

Significado: Existe DNAT en prerouting y output (conexiones locales del host).
Si la regla existe solo en OUTPUT, el tráfico remoto no se reenviará; si solo está en PREROUTING, el comportamiento en localhost puede diferir.

Decisión: Asegura que Docker esté correctamente integrado con nftables y que no estés mezclando backends incompatibles de iptables.

Tarea 11: Verifica el forwarding del kernel y ajustes bridge netfilter

cr0x@server:~$ sysctl net.ipv4.ip_forward net.bridge.bridge-nf-call-iptables 2>/dev/null
net.ipv4.ip_forward = 1
net.bridge.bridge-nf-call-iptables = 1

Significado: El forwarding está habilitado y el tráfico del bridge es visible para iptables.
Algunas baselines endurecidas deshabilitan esto y luego se preguntan por qué los contenedores no se pueden alcanzar.

Decisión: Si ip_forward=0 y esperas enrutamiento/NAT, actívalo (y documenta esto en tu baseline).

Tarea 12: Comprueba el estado de UFW y si está bloqueando Docker silenciosamente

cr0x@server:~$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip

Significado: “deny (routed)” es el clásico asesino de contenedores si te basas en tráfico reenviado.
UFW puede bloquear el reenvío del bridge de Docker incluso cuando permites el puerto en el host.

Decisión: O configura UFW para permitir tráfico enrutado para las redes de Docker, o administra el firewall con reglas explícitas de iptables/nft.

Tarea 13: Revisa zonas de firewalld y masquerade (común en RHEL/CentOS)

cr0x@server:~$ sudo firewall-cmd --state
running
cr0x@server:~$ sudo firewall-cmd --get-active-zones
public
  interfaces: eth0
docker
  interfaces: docker0

Significado: firewalld puede colocar docker0 en su propia zona. Si esa zona no permite forwarding/masquerade, los puertos publicados fallan.

Decisión: Asegura que la zona docker permita forwarding según se requiera, o unifica las zonas intencionalmente.

Tarea 14: Prueba desde un host remoto y compara comportamiento de ruta

cr0x@server:~$ curl -sS -m 2 -D- http://$(hostname -I | awk '{print $1}'):8080/ | head
HTTP/1.1 200 OK
Server: nginx/1.25.3
Date: Tue, 02 Jan 2026 14:01:39 GMT
Content-Type: text/html

Significado: Esto simula “no localhost” usando la IP del host. Si esto falla pero 127.0.0.1 funciona,
tu binding/firewall/ruta difiere entre loopback e interfaz externa.

Decisión: Si falla, inspecciona IP de binding y firewall por interfaz. Si tiene éxito, el problema podría estar fuera del host (SG en la nube, LB, ruta del cliente).

Tarea 15: Captura de paquetes en el host para ver si llega el SYN

cr0x@server:~$ sudo tcpdump -ni eth0 tcp port 8080 -c 5
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
14:02:01.123456 IP 203.0.113.10.51922 > 192.0.2.20.8080: Flags [S], seq 123456789, win 64240, options [mss 1460,sackOK,TS val 1 ecr 0,nop,wscale 7], length 0

Significado: Si ves SYNs llegando, la ruta de red hasta el host está bien. Si no ves nada, el problema está aguas arriba
(grupo de seguridad, NACL, router, balanceador, DNS apuntando a otro lado).

Decisión: No hay SYN: deja de depurar Docker y ve hacia afuera. SYN llega: sigue depurando firewall/NAT/respuesta de la app en el host.

Tarea 16: Captura de paquetes en docker0 para confirmar que ocurre el reenvío

cr0x@server:~$ sudo tcpdump -ni docker0 tcp port 80 -c 5
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on docker0, link-type EN10MB (Ethernet), capture size 262144 bytes
14:02:01.124001 IP 203.0.113.10.51922 > 172.17.0.4.80: Flags [S], seq 123456789, win 64240, options [mss 1460,sackOK,TS val 1 ecr 0,nop,wscale 7], length 0

Significado: El paquete fue DNATeado y llegó a docker0. Si llega en eth0 pero no en docker0,
tus reglas NAT/forwarding son el cuello de botella.

Decisión: Arregla iptables/nftables, ajustes de forwarding o reglas en DOCKER-USER.

Dónde falla: los modos reales de fallo

1) La app escucha en la interfaz o puerto equivocado

Muchos frameworks por defecto usan 127.0.0.1 por “seguridad”. Eso está bien en portátiles. En contenedores es un autosabotaje frecuente.
Node, servidores de desarrollo de Python y algunos microframeworks en Java son infractores habituales.

Qué ves: el contenedor está “saludable”, el puerto está publicado, pero las conexiones cuelgan o se reinician. El curl dentro del contenedor puede funcionar solo vía localhost,
o no funcionar si se liga a un socket UNIX.

Qué hacer: fuerza a la app a ligarse a 0.0.0.0 (o a la interfaz del contenedor explícitamente) y confirma con ss -lntp.
Si usas un servidor de desarrollo, deténlo. Usa un servidor real (gunicorn/uvicorn, nginx, etc.) para cualquier cosa fuera de tu laptop.

2) El puerto está publicado solo a localhost

Compose y docker run permiten bindings como 127.0.0.1:8080:80. Eso significa exactamente lo que dice.
Funciona desde el host, falla desde la red, y consume horas porque “funciona en mi máquina” es un sedante potente.

Solución: liga a 0.0.0.0 o a la IP de la interfaz externa específica, intencionalmente.
Haz el binding explícito en Compose cuando lo desees.

3) El firewall del host bloquea tráfico reenviado (UFW/firewalld) aunque el puerto parezca abierto

Un punto sutil: un “puerto publicado” a menudo se implementa con NAT + forwarding.
Los firewalls pueden permitir INPUT a TCP/8080 pero bloquear FORWARD a docker0, resultando en timeouts.
Localmente puede seguir funcionando porque el tráfico de localhost puede seguir una cadena/hook diferente.

Si ejecutas UFW con “deny routed”, asume que está involucrado hasta demostrar lo contrario.
Si ejecutas firewalld, asume que zonas/mascarade son relevantes.

4) La cadena DOCKER-USER lo bloquea (intencional o accidentalmente)

DOCKER-USER existe específicamente para que puedas insertar políticas antes de las propias cadenas de Docker. Eso es buena ingeniería.
También es donde viven los “drops” temporales por años.

Un solo -j DROP en DOCKER-USER puede convertir tus puertos publicados en agujeros negros. No esparzas descartes globales a menos que también documentes globalmente.

5) Estás mezclando backends de iptables (legacy vs nft) y Docker programa el “equivocado”

En algunas distribuciones, iptables es una capa de compatibilidad sobre nftables, y puedes acabar con reglas insertadas en una vista
mientras que los paquetes son evaluados por la otra, dependiendo de versiones y configuración de kernel/userspace.

Síntoma: Docker afirma que los puertos están publicados; las reglas aparecen en iptables -t nat -S pero no afectan el tráfico,
o las reglas aparecen en nft pero la salida de iptables parece vacía.

Solución: elige un backend consistente y configura Docker en consecuencia. También: deja de tratar la pila de firewall como una novela de elige-tu-propia-aventura.

6) Docker rootless: mecánica de reenvío distinta, sorpresas distintas

Docker rootless evita redes con privilegios. Genial para postura de seguridad, menos genial para “debe comportarse como Docker con privilegios”.
Los puertos publicados se implementan vía reenvío en espacio de usuario; el rendimiento, comportamiento de binding e interacciones con firewalls difieren.

Síntoma: los puertos funcionan solo en localhost, o solo para puertos altos, o fallan al ligar a direcciones específicas.
Las reglas no aparecerán en iptables del sistema porque rootless no las programa.

Solución: confirma que estás en modo rootless, luego sigue la guía específica para rootless (como configuración explícita de reenvío de puertos).
Si necesitas comportamiento NAT clásico, ejecuta Docker con privilegios en hosts endurecidos en vez de una solución a medias.

7) Desajuste con un proxy inverso (upstream equivocado, red equivocada, expectativas TLS)

Publicas un puerto, pero el tráfico en realidad pasa por nginx/HAProxy/Traefik en el host o en otro contenedor.
Tu problema podría ser que el proxy habla con la IP equivocada del contenedor, la red equivocada, o espera TLS en HTTP plano (o viceversa).

Síntoma: el contenedor funciona vía curl directo, pero el proxy devuelve 502/504.
La gente etiqueta esto como “puerto inaccesible” porque el síntoma para el usuario es “sitio caído.”

Solución: prueba la conectividad upstream desde el contexto del proxy, no desde las sensaciones de tu portátil.

8) Hairpin NAT / “conectarme a mi IP pública desde el mismo host”

Estás en el host y haces curl a la IP pública del host:8080 y falla, pero localhost funciona.
Eso suele ser hairpin NAT. Algunas configuraciones de red (especialmente con rp_filter estricto o cierto enrutamiento en la nube)
tratan esta ruta de forma distinta.

Solución: prueba en la interfaz correcta y entiende si estás atravesando un router/LB externo y volviendo.
Si necesitas hairpin, configúralo explícitamente (o no dependas de él).

9) IPv6 está “publicado” pero no es realmente alcanzable

Docker puede mostrar bindings [::]:8080. Eso no significa que tu host tenga conectividad IPv6,
que tu firewall lo permita, o que la ruta al contenedor sea IPv6-lista.

Síntoma: IPv4 funciona; IPv6 hace timeout. O los clientes prefieren IPv6 y fallan aunque IPv4 funcionaría.

Solución: confirma enrutamiento IPv6 y reglas de firewall, y sé explícito sobre si soportas IPv6. IPv6 accidental es un pasatiempo, no una estrategia.

Errores comunes: síntoma → causa raíz → solución

1) “Funciona en localhost pero no desde otra máquina”

Causa raíz: puerto ligado solo a 127.0.0.1, o firewall permite local pero bloquea externo, o grupo de seguridad en la nube bloquea.

Solución: publica a 0.0.0.0 (o la interfaz correcta) y abre el puerto en la capa adecuada (firewall del host + SG en la nube).

2) “docker ps muestra 0.0.0.0:PUERTO, pero ss no muestra nada escuchando”

Causa raíz: confiar en DNAT de iptables sin proxy en espacio de usuario; ss no mostrará un listener aunque NAT funcione.

Solución: prueba con curl 127.0.0.1:PUERTO. Si falla, inspecciona iptables/nft. No trates la salida de ss como la única verdad.

3) “Rechazo de conexión inmediato”

Causa raíz: ningún proceso escuchando dentro del contenedor, puerto del contenedor equivocado, o contenedor se estrelló y fue reemplazado.

Solución: docker exec ss -lntp y docker logs. Confirma que la app liga el puerto esperado.

4) “Timeout (SYN enviado, sin SYN-ACK)”

Causa raíz: paquete descartado por firewall, grupo de seguridad, DOCKER-USER, o enrutamiento/NACL.

Solución: tcpdump en la interfaz externa del host. Si el SYN nunca llega, es upstream. Si llega, inspecciona firewall/NAT en el host.

5) “Funciona del host a la IP del contenedor, pero no vía puerto publicado”

Causa raíz: NAT/forwarding no programado o bloqueado; incompatibilidad de backend de iptables; política en DOCKER-USER.

Solución: inspecciona reglas NAT, DOCKER-USER y sysctls de forwarding. Haz la política de firewall explícita y probada.

6) “Solo algunos clientes pueden alcanzarlo (otros hacen timeout)”

Causa raíz: problemas de MTU (VPNs), enrutamiento asimétrico, preferencia IPv6, o DNS de horizonte dividido apuntando a IPs diferentes.

Solución: captura paquetes; prueba forzando IPv4/IPv6; verifica MTU y rutas; no asumas que la red es una entidad uniforme.

7) “Se rompió tras activar hardening de UFW/firewalld”

Causa raíz: tráfico enrutado/reenvío bloqueado; docker0 puesto en una zona restrictiva.

Solución: permite explícitamente el forwarding para las redes Docker, o implementa reglas conscientes de Docker en DOCKER-USER con cuidado.

8) “El proxy inverso devuelve 502 pero el puerto directo funciona”

Causa raíz: upstream del proxy apunta a la IP/red equivocada del contenedor, protocolo equivocado (HTTP vs HTTPS), o DNS resuelve distinto dentro del proxy.

Solución: prueba la conectividad desde el contenedor/host del proxy, verifica objetivos upstream y asegura que proxy y app compartan la misma red Docker si usas nombres DNS entre contenedores.

Listas de verificación / plan paso a paso (haz esto, no intuición)

Checklist A: Estás en un host Linux y el puerto está muerto desde todas partes

  1. Dentro del contenedor: confirma proceso escuchando en el puerto esperado con ss -lntp.
  2. Dentro del contenedor: curl 127.0.0.1:PORT (o equivalente) para validar la respuesta de la aplicación.
  3. Host a IP del contenedor: curl CONTAINER_IP:PORT. Si falla, arregla la red de Docker o la app.
  4. Host vía puerto publicado: curl 127.0.0.1:PUBLISHED. Si esto falla pero lo anterior funciona, es NAT/forwarding.
  5. Reglas NAT: inspecciona iptables -t nat -S DOCKER o nft list ruleset.
  6. Cadenas de política: inspecciona iptables -S DOCKER-USER y cualquier política global FORWARD.
  7. Ajustes del kernel: comprueba net.ipv4.ip_forward y configuraciones de bridge netfilter.
  8. Sólo entonces: reinicia Docker si cambiaste backends de reglas o configuración del daemon. No reinicies todo como herramienta diagnóstica.

Checklist B: Localhost funciona, remoto falla

  1. Binding: verifica que no esté 127.0.0.1:PUBLISHED en docker ps / inspect.
  2. Prueba “externa” localmente: curl a la IP del host en lugar de localhost.
  3. Firewall: revisa políticas de UFW/firewalld para tráfico enrutado/reenvío.
  4. Presencia de paquetes: ejecuta tcpdump en la interfaz externa durante una prueba remota.
  5. Perímetro en la nube: confirma que grupos de seguridad/NACL/listeners del LB apunten al host/puerto correctos.
  6. Sanidad DNS: asegura que los clientes resuelvan a la IP correcta (sin registros obsoletos o sorpresa de split-horizon).

Checklist C: Es “alcanzable” pero la app está mal (502/bucles de redirección/SSL raro)

  1. Prueba directa: curl al puerto publicado directamente en el host. Obtén un 200 limpio (o la respuesta esperada).
  2. Ruta del proxy: prueba desde el contexto del proxy (contenedor u host) hacia el target upstream.
  3. Protocolo: verifica expectativas HTTP vs HTTPS. No hables TLS a un puerto plano.
  4. Headers: confirma que Host y X-Forwarded-Proto estén correctamente establecidos si la app los usa.
  5. Red: asegura que proxy y app compartan la misma red docker si usas nombres DNS entre contenedores.

Chiste #2: Si tu arreglo es “reinicia todo”, no lo arreglaste—echaste los dados y lo llamaste ingeniería.

Tres mini-historias del mundo corporativo

Mini-historia 1: El incidente causado por una suposición equivocada

Un equipo migró un servicio interno pequeño de VMs a Docker sobre una baseline Linux endurecida. El despliegue fue limpio: el contenedor corría, las comprobaciones de salud pasaban,
y el puerto estaba publicado. El ingeniero on-call verificó curl 127.0.0.1:PORT en el host y vio la respuesta esperada. Se dio por hecho.

Diez minutos después, los usuarios reales no podían alcanzarlo. El error no era un 500; era nada—timeouts. Eso desencadenó el ritual predecible:
redeploy, reiniciar Docker, reconstruir la imagen y finalmente “quizá es la red”. Mientras tanto, el balanceador marcó el servicio como no saludable y lo drenó.

La suposición equivocada fue sutil: “Si localhost funciona, la red debería funcionar”. En ese host, UFW estaba configurado con deny incoming por defecto (bien),
y deny routed por defecto (no bien para el reenvío de puertos de Docker). Las solicitudes a localhost nunca ejercitaron la misma política de forwarding que las solicitudes externas.
Así que el equipo había probado que la aplicación funcionaba, pero no la ruta publicada.

La solución fue aburrida y efectiva: una política de firewall documentada que permitiera tráfico enrutado al subnet específico de contenedores y puertos,
además de un paso en el runbook que exigiera una prueba curl remota (desde una bastión en la misma red) antes de declarar victoria.
Después de eso, este tipo de outage casi desapareció. No porque Docker fuera más amable—porque el equipo dejó de asumir.

Mini-historia 2: La optimización que salió mal

Otro equipo quería “máximo rendimiento” y eliminó todo lo que parecía overhead. Deshabilitaron el proxy en espacio de usuario en la configuración del daemon de Docker,
apretaron reglas de firewall y consolidaron la gestión de iptables bajo un agente de seguridad del host. En pruebas, todo pareció más rápido y limpio.
Los benchmarks lucían bien. Las diapositivas eran impecables.

Luego llegó producción. Un subconjunto de conexiones a puertos publicados empezó a fallar de forma intermitente—principalmente desde subnets específicas.
Las fallas no eran lo bastante consistentes como para ser obvias, pero sí como para arruinar el día de alguien. El incidente botaba entre “red” y “plataforma”
por más tiempo del que nadie quiere admitir.

La raíz fue una interacción entre el ciclo de refresco de reglas del agente de seguridad del host y las reglas NAT dinámicas de Docker.
Cada vez que se reemplazaban contenedores, Docker programaba DNAT; el agente reconciliaba más tarde al “estado deseado” y eliminaba lo que no reconocía.
Como el proxy estaba deshabilitado, no había ruta de fallback en espacio de usuario—solo reglas NAT. Algunas conexiones caían durante ventanas en que las reglas faltaban.

La recuperación fue dejar de tratar iptables como un juguete compartido. El equipo pasó a una política explícita: o el agente de seguridad poseía el estado del firewall con
integración consciente de Docker, o Docker poseía NAT más una política DOCKER-USER controlada. Eligieron lo segundo por simplicidad.
El rendimiento siguió bueno. La fiabilidad mejoró dramáticamente. La optimización no estaba mal; el modelo de propiedad sí.

Mini-historia 3: La práctica aburrida pero correcta que salvó el día

Un grupo de plataforma operaba una flota de hosts Docker detrás de balanceadores internos. Tenían una práctica estricta: cada servicio tenía una “sonda de conectividad” estándar
ejecutada desde tres lugares—dentro del contenedor, desde el host y desde un nodo canario remoto en el mismo segmento de red.
Era mundano. También estaba documentado, automatizado y aplicado durante la respuesta a incidentes.

Una tarde, una actualización rutinaria del kernel fue seguida por una oleada de páginas “servicio inaccesible”. Empezó el pánico habitual en los canales de Slack.
Pero el on-call siguió las sondas. El curl dentro del contenedor funcionó. Host a IP del contenedor funcionó. Host a puerto publicado funcionó. El canario remoto falló.
Eso acotó el problema en minutos: no era Docker, ni la app. Era la alcanzabilidad de entrada.

Un cambio de red había alterado qué subnets podían alcanzar los hosts, y las comprobaciones de salud del balanceador ahora se originaban desde un subnet
que no estaba en la lista de permitidos. El grupo de plataforma tenía las capturas de paquetes que probaban que los SYNs nunca llegaron a la interfaz del host.
La solución fue una actualización limpia de la lista de permitidos del perímetro.

La práctica que salvó el día no fue un ajuste ingenioso. Fue una prueba disciplinada y repetible desde múltiples puntos de vista,
con expectativas documentadas. Evitó una espiral de depuración de contenedores y acortó el incidente.
Lo aburrido es una característica cuando estás on call.

Hechos interesantes y breve historia (para que dejes de adivinar)

  • La publicación de puertos original de Docker en Linux se apoyaba mucho en reglas NAT de iptables porque estaba ampliamente disponible y era rápido en kernel.
  • El “proxy en espacio de usuario” existía para manejar casos límite (como conexiones hairpin y algunos comportamientos de localhost) cuando solo DNAT no era suficiente.
  • La cadena DOCKER-USER se introdujo para que los operadores pudieran imponer política delante de las reglas que Docker gestiona automáticamente sin pelear con las actualizaciones de Docker.
  • iptables tiene dos “mundos” en Linux moderno: legacy xtables y backend nftables. Mezclarlos puede producir reglas que ves pero que el kernel no usa (para tu ruta de tráfico).
  • El “deny routed” por defecto de UFW es razonable para hosts sin contenedores pero con frecuencia rompe el forwarding de contenedores a menos que lo permitas explícitamente.
  • Docker rootless se hizo popular conforme los equipos de seguridad empujaron el principio de menor privilegio, pero cambia intencionalmente cómo se implementa y observa la red.
  • Docker Desktop en Mac/Windows siempre implica una frontera VM, así que los puertos publicados son una función de reenvío a través de virtualización, no solo una regla NAT local.
  • El comportamiento IPv6 suele estar “encendido por accidente” porque los bindings pueden mostrar [::] incluso cuando el entorno no soporta realmente alcanzabilidad IPv6 de extremo a extremo.

La lección de la historia: el comportamiento que ves es producto de decisiones de plataforma, postura de seguridad y evolución de la pila kernel/firewall.
Trata tu entorno como un sistema real con capas, no como una burbuja mágica de Docker.

Preguntas frecuentes

1) ¿Por qué docker ps muestra el puerto publicado si no funciona?

Porque es una vista de configuración, no una prueba de conectividad. Docker registró el binding y probablemente intentó programar el reenvío,
pero firewalls, enrutamiento o la aplicación pueden seguir bloqueando el tráfico real.

2) ¿Cómo sé si el problema es la app o la red?

Usa la prueba de tres saltos: dentro del contenedor (curl localhost), host a IP del contenedor, host al puerto publicado. El primer salto que falle es tu capa.

3) ¿Por qué localhost funciona pero la IP LAN del host no?

Diferentes rutas. Localhost puede golpear reglas OUTPUT o comportamiento local de DNAT; el tráfico externo golpea PREROUTING/FORWARD y está sujeto a política de firewall distinta.
También comprueba si accidentalmente te ligaste a 127.0.0.1.

4) ¿Necesito abrir el puerto en el firewall del contenedor?

Usualmente no. La mayoría de contenedores no ejecutan un firewall. Si lo haces, trátalo como un host real: permite inbound al puerto de la app.
Pero la mayoría de problemas de “puerto publicado inaccesible” son del host/perímetro, no del firewall del contenedor.

5) ¿Por qué a veces ss -lntp no muestra un listener para un puerto publicado?

Porque la publicación basada en NAT no requiere un proceso escuchando en el puerto del host. El kernel reescribe y reenvía paquetes.
Si se usa proxy en espacio de usuario, verás docker-proxy.

6) ¿Puede fallar la publicación de Docker por un desajuste iptables/nftables?

Sí. Si Docker programa reglas en un backend que no se usa efectivamente para tu tráfico, obtienes “las reglas existen” pero no hay reenvío.
Revisa ambas vistas de iptables y nft y estandariza la pila.

7) ¿Qué cambia en Docker rootless?

Rootless no puede programar libremente reglas NAT del sistema. La publicación de puertos suele depender de mecanismos en espacio de usuario.
La observabilidad (inspección de iptables) y el comportamiento (restricciones de binding, rendimiento) difieren. Confirma el modo primero.

8) ¿Cómo depuro si el host está detrás de un balanceador?

Captura paquetes en la interfaz del host mientras el LB hace sondas. Si los SYNs nunca llegan, es configuración del LB, reglas del SG o enrutamiento.
Si los SYNs llegan pero no alcanzan docker0, es firewall/NAT del host.

9) ¿Por qué funciona en IPv4 pero falla en IPv6?

Porque la alcanzabilidad IPv6 requiere enrutamiento de extremo a extremo y reglas de firewall. Docker mostrar [::] no asegura que tu red lo soporte.
Prueba explícitamente con IPv4/IPv6 y configura intencionalmente.

10) ¿Debería usar --network host para “evitar problemas de red de Docker”?

Solo si entiendes los tradeoffs. El networking host elimina la capa NAT pero aumenta riesgo de colisiones de puertos y reduce aislamiento.
Es una herramienta, no un vendaje para problemas desconocidos.

Conclusión: siguientes pasos que realmente evitan repeticiones

Cuando un puerto Docker está publicado pero inaccesible, el sistema te está diciendo exactamente dónde está roto—solo tienes que interrogarlo en el orden correcto.
Empieza dentro del contenedor, muévete hacia afuera y detente tan pronto como encuentres el primer salto fallido. Ese es el cuello de botella. Arregla eso, no tu paciencia.

Haz esto

  1. Codifica la prueba de tres saltos (localhost del contenedor → IP del contenedor desde el host → puerto publicado del host → canario remoto) en tus runbooks.
  2. Estandariza la propiedad del firewall: o Docker administra NAT y usas DOCKER-USER intencionalmente, o tu gestor de firewall se integra con Docker. Nada de misterio compartido.
  3. Haz los bindings explícitos en Compose (0.0.0.0:PORT:PORT vs 127.0.0.1) para no enviar “funciona en mi host”.
  4. Instrumenta la alcanzabilidad: una comprobación blackbox simple desde un nodo remoto detecta la mayoría de estos problemas antes que los usuarios.
  5. Documenta modos especiales (rootless, IPv6, proxies inversos, balanceadores) junto al servicio, no en la cabeza de alguien.

Tu objetivo no es memorizar trivia de redes de Docker. Tu objetivo es reducir el tiempo hasta la verdad. La checklist arriba lo hace—de forma fiable, repetible,
y sin necesitar una ofrenda de reinicio a los dioses de la red.

]]>
https://cr0x.net/es/puerto-docker-publicado-inaccesible-checklist/feed/ 0
Docker «conexión rechazada» entre servicios: arregla redes, no síntomas https://cr0x.net/es/docker-conexion-rechazada-redes/ https://cr0x.net/es/docker-conexion-rechazada-redes/#respond Tue, 18 Nov 2025 16:20:28 +0000 https://cr0x.net/2026/01/02/docker-conexion-rechazada-redes/ “Conexión rechazada” es un mensaje maravillosamente directo. No significa que tu aplicación esté “un poco lenta hoy.”
Significa que algo intentó abrir una conexión TCP y el otro extremo dijo “no” de inmediato.
Sin handshake. Sin espera. Simplemente una puerta cerrada.

En stacks de Docker, esa puerta se cierra por razones previsibles: estás marcando la dirección equivocada, estás en la red equivocada,
estás golpeando el puerto incorrecto, el servidor no escucha donde crees que lo hace, o algo en el medio está filtrando el tráfico.
Este artículo trata de demostrar cuál es el problema, rápido—y arreglar el modelo de red en lugar de esparcir reintentos como agua bendita.

Qué significa realmente “conexión rechazada” (y lo que no significa)

“Conexión rechazada” es la forma que tiene TCP de decir: la IP de destino es alcanzable, pero nadie está escuchando en ese puerto
(o algo rechazó activamente la conexión con un RST TCP). Ese es un modo de fallo muy distinto de:

  • Timeout: los paquetes desaparecen, el enrutamiento está roto, un firewall está descartando, o el servicio está bloqueado y no responde.
  • Fallo de resolución de nombres: ni siquiera obtienes una dirección IP para el nombre objetivo.
  • Connection reset by peer: te conectaste y luego la aplicación te cerró a mitad de la comunicación.

En Docker, “rechazado” a menudo significa que te conectaste al lugar equivocado con éxito. Suena contradictorio hasta que te das cuenta
de la frecuencia con que los desarrolladores apuntan por error a localhost, o al puerto publicado del host desde la misma red,
o a una IP de contenedor que cambió desde el martes pasado.

Aquí tienes la regla que puedes pegar en tu monitor: dentro de un contenedor, “localhost” significa el propio contenedor.
Si te conectas de un contenedor a otro, “localhost” casi siempre está mal, salvo que ejecutes intencionalmente ambos procesos
en el mismo contenedor (lo cual es otra decisión de estilo).

Un modelo mental de la red Docker para operar bajo presión

Las redes de Docker no son “difíciles.” Solo están en capas. La gente se complica cuando adivina qué capa falla.
No vamos a adivinar. Vamos a probarlo.

Layer 1: Proceso y socket

Algo debe estar escuchando en un puerto. Si tu servicio escucha en 127.0.0.1 dentro de su contenedor, otros contenedores no pueden alcanzarlo.
Debe enlazarse a 0.0.0.0 (o a la dirección de la interfaz del contenedor).

Layer 2: Espacio de nombres de red del contenedor

Cada contenedor tiene su propio espacio de nombres de red: sus propias interfaces, rutas y loopback. Los contenedores pueden adjuntarse a una o más redes.
Docker crea un par veth para conectar el espacio de nombres del contenedor a un bridge (para redes bridge) o a un overlay (para Swarm).

Layer 3: Redes Docker (bridge/overlay/macvlan)

La red “bridge” por defecto no es lo mismo que una red bridge definida por el usuario. Las redes definidas por el usuario proporcionan descubrimiento de servicios basado en DNS.
Compose se apoya en eso. Si caes de nuevo en el bridge por defecto y empiezas a hardcodear IPs, estás escribiendo incidentes futuros.

Layer 4: Descubrimiento de servicios (DNS de Docker)

En redes definidas por el usuario, Docker ejecuta un servidor DNS embebido. Los contenedores comúnmente lo ven como 127.0.0.11 en /etc/resolv.conf.
Los nombres de servicio de Compose resuelven a IPs de contenedor en esa red. Si la resolución de nombres falla, todo lo que viene después se vuelve caos.

Layer 5: Publicación de puertos en el host y NAT

ports: en Compose publica puertos del contenedor al host. Eso es para tráfico que viene desde fuera de Docker (tu portátil, el host, otras máquinas).
Dentro de la red Docker, los contenedores normalmente deberían hablar entre sí en el puerto de contenedor vía el nombre del servicio.

Si te encuentras conectándote desde el contenedor A a host.docker.internal:5432 para alcanzar el contenedor B, párate y pregúntate:
“¿Por qué estoy saliendo de la red Docker y reentrando por NAT?” A veces es necesario. La mayoría de las veces no.

Una cita, porque el mundo ops tiene recibos

Idea parafraseada de Werner Vogels (fiabilidad/arquitectura): “Todo falla; diseña asumiendo fallos y recupera mediante automatización.”
Eso aplica aquí: deja de esperar que la red se comporte; diseña para observarla y verificarla.

Guía rápida de diagnóstico (primero/segundo/tercero)

Cuando producción está ardiendo, no necesitas un doctorado en filosofía. Necesitas una secuencia que reduzca el espacio de búsqueda.
Esta guía está pensada para “el servicio A no puede conectar al servicio B” con “conexión rechazada.”

Primero: confirma que marcas el objetivo correcto desde el contenedor llamante

  • Desde dentro del contenedor A: resuelve el nombre que estás usando.
  • Confirma que la IP esté en una red compartida con el contenedor B.
  • Intenta una conexión TCP al puerto previsto.

Si la resolución de nombres falla o apunta a un lugar inesperado, para. Arregla DNS/miembro de red primero.

Segundo: confirma que el contenedor servidor realmente está escuchando en la interfaz y puerto correctos

  • Dentro del contenedor B: lista los sockets en escucha.
  • Comprueba que el servicio enlaza a 0.0.0.0, no a 127.0.0.1.
  • Revisa los logs de la aplicación por “iniciado” versus “crash y reinicio”.

Tercero: inspecciona la plomería de la red Docker y el filtrado del host

  • Inspecciona las conexiones de red y las IPs de los contenedores.
  • Revisa reglas de iptables/nftables si el host está involucrado (puertos publicados, o tráfico cruzando espacios de nombres).
  • Busca aislamiento de red accidental (múltiples proyectos Compose, múltiples redes, alias incorrectos).

Broma #1: Si tu arreglo es “add sleep 10,” no solucionaste la red—solo negociaste con el tiempo, y el tiempo siempre te pasa factura después.

Tareas prácticas: comandos, salida esperada y decisiones

A continuación hay tareas prácticas que puedes ejecutar en un host Linux con Docker y Compose. Cada tarea incluye un comando, una salida realista,
lo que significa esa salida y la decisión que tomas. Esta es la parte que copias en tu canal de incidentes.

Task 1: Identifica los detalles de la conexión fallida (desde logs)

cr0x@server:~$ docker logs --tail=50 api
2026-01-03T09:12:41Z ERROR db: dial tcp 127.0.0.1:5432: connect: connection refused
2026-01-03T09:12:41Z INFO  retrying in 1s

Significado: El contenedor API está intentando alcanzar Postgres en 127.0.0.1 dentro de su propio espacio de nombres de red.
A menos que Postgres se ejecute en el mismo contenedor, eso está mal.

Decisión: Cambia el host de la BD al nombre del servicio de Compose (por ejemplo db) y usa el puerto del contenedor (5432).

Task 2: Confirma que el estado del contenedor no te miente

cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
NAMES     STATUS                        PORTS
api       Up 2 minutes                  0.0.0.0:8080->8080/tcp
db        Restarting (1) 5 seconds ago  5432/tcp

Significado: La BD está reiniciando. “Conexión rechazada” podría ser real (nadie escucha), no un problema de enrutamiento.

Decisión: Deja de afinar el cliente. Arregla el crash loop del contenedor servidor primero: inspecciona logs y configuración de la BD.

Task 3: Inspecciona los logs del servidor buscando problemas de bind/listen

cr0x@server:~$ docker logs --tail=80 db
2026-01-03 09:12:32.177 UTC [1] LOG:  listening on IPv4 address "127.0.0.1", port 5432
2026-01-03 09:12:32.177 UTC [1] LOG:  listening on IPv6 address "::1", port 5432
2026-01-03 09:12:32.180 UTC [1] LOG:  database system is ready to accept connections

Significado: Postgres solo está escuchando en loopback dentro de su contenedor. Otros contenedores recibirán “rechazado.”

Decisión: Configura Postgres para escuchar en 0.0.0.0 (o en la interfaz del contenedor) y ajusta pg_hba.conf en consecuencia.
En contenedores, “seguro por enlazar a localhost” es teatro de seguridad con daños colaterales.

Task 4: Prueba la resolución de nombres dentro del contenedor llamante

cr0x@server:~$ docker exec -it api getent hosts db
172.21.0.3      db

Significado: El DNS de Docker puede resolver db a una IP. Eso es un prerrequisito para el descubrimiento de servicios.

Decisión: Si esto falla, estás en la red equivocada o usando el nombre incorrecto. Arregla redes/alias de Compose, no la aplicación.

Task 5: Prueba el puerto TCP desde el contenedor llamante (señal rápida)

cr0x@server:~$ docker exec -it api bash -lc 'nc -vz -w2 db 5432; echo exit=$?'
nc: connect to db (172.21.0.3) port 5432 (tcp) failed: Connection refused
exit=1

Significado: Alcanzaste la IP del contenedor, pero el puerto la rechazó. Eso casi siempre es “no hay nadie escuchando” o “escucha solo en loopback.”

Decisión: Investiga los sockets en escucha y la dirección de bind del contenedor servidor (siguiente tarea).

Task 6: Comprueba en qué está escuchando el servidor (dentro del contenedor servidor)

cr0x@server:~$ docker exec -it db bash -lc 'ss -lntp | head -n 20'
State  Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0      4096   127.0.0.1:5432      0.0.0.0:*     users:(("postgres",pid=1,fd=6))

Significado: Postgres está ligado a 127.0.0.1:5432 solamente. Los contenedores en la misma red no pueden conectarse.

Decisión: Reconfigura el servicio para enlazar a 0.0.0.0 (o a la IP del contenedor) y reinicia.

Task 7: Confirma que ambos contenedores comparten la misma red Docker

cr0x@server:~$ docker inspect -f '{{json .NetworkSettings.Networks}}' api
{"appnet":{"IPAMConfig":null,"Links":null,"Aliases":["api","api-1"],"NetworkID":"c0b3f6f2e2c1","EndpointID":"8d6d...","Gateway":"172.21.0.1","IPAddress":"172.21.0.2","IPPrefixLen":16,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"MacAddress":"02:42:ac:15:00:02","DriverOpts":null}}
cr0x@server:~$ docker inspect -f '{{json .NetworkSettings.Networks}}' db
{"appnet":{"IPAMConfig":null,"Links":null,"Aliases":["db","db-1"],"NetworkID":"c0b3f6f2e2c1","EndpointID":"a21c...","Gateway":"172.21.0.1","IPAddress":"172.21.0.3","IPPrefixLen":16,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"MacAddress":"02:42:ac:15:00:03","DriverOpts":null}}

Significado: Ambos están en appnet con el mismo NetworkID. Así que el enrutamiento entre ellos debería estar bien.

Decisión: Centra la atención en la configuración de escucha del servidor y la disponibilidad de la aplicación, no en rarezas multi-red.

Task 8: Inspecciona el objeto de red por sorpresas (subnet, contenedores, opciones)

cr0x@server:~$ docker network inspect appnet --format '{{json .IPAM.Config}} {{json .Containers}}'
[{"Subnet":"172.21.0.0/16","Gateway":"172.21.0.1"}] {"1c2f...":{"Name":"api","IPv4Address":"172.21.0.2/16"},"7aa9...":{"Name":"db","IPv4Address":"172.21.0.3/16"}}

Significado: Confirmado subnet y membresía. Si la BD no aparece listada, no está en la red que crees.

Decisión: Si la membresía es incorrecta: arregla Compose para adjuntar servicios a la misma red definida por el usuario y redeploy.

Task 9: Detecta el problema de “confusión por puerto publicado”

cr0x@server:~$ docker port db
5432/tcp -> 0.0.0.0:15432

Significado: El contenedor DB expone 5432 internamente, publicado como 15432 en el host.
Otros contenedores deberían seguir usando db:5432, no db:15432 ni localhost:15432.

Decisión: Si la configuración de la aplicación apunta a 15432 desde dentro de Docker, corrígela. Los puertos publicados son para clientes externos.

Task 10: Valida el enrutamiento desde dentro del contenedor llamante

cr0x@server:~$ docker exec -it api ip route
default via 172.21.0.1 dev eth0
172.21.0.0/16 dev eth0 proto kernel scope link src 172.21.0.2

Significado: El contenedor tiene una ruta a la subred donde vive db. Si la ruta falta, adjuntaste la red equivocada.

Decisión: Ruta faltante significa adjunto de red incorrecto. Arregla redes de Compose, no hackees /etc/hosts.

Task 11: Valida que la DNS del llamante apunte al DNS embebido de Docker

cr0x@server:~$ docker exec -it api cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0

Significado: Se está usando el DNS embebido de Docker. Si ves solo servidores DNS corporativos, la resolución de nombres a nombres de servicio puede fallar.

Decisión: Si el DNS de Docker no se usa, revisa ajustes de DNS sobrescritos en la configuración del daemon o en Compose.

Task 12: Prueba que el servicio es accesible cuando se corrige el bind

cr0x@server:~$ docker exec -it db bash -lc 'grep -E "^(listen_addresses|port)" -n /var/lib/postgresql/data/postgresql.conf | head'
60:listen_addresses = '*'
64:port = 5432
cr0x@server:~$ docker restart db
db
cr0x@server:~$ docker exec -it api bash -lc 'nc -vz -w2 db 5432; echo exit=$?'
Connection to db (172.21.0.3) 5432 port [tcp/postgresql] succeeded!
exit=0

Significado: Convertimos “rechazado” en “exitoso” arreglando el listener.

Decisión: Fija el cambio de configuración, añade una comprobación de readiness y elimina cualquier “retry” cliente sin límites.

Task 13: Atrapa la idea equivocada “depends_on significa listo”

cr0x@server:~$ docker compose ps
NAME           IMAGE            COMMAND                  SERVICE   STATUS          PORTS
stack-api-1    api:latest       "/app/api"               api       Up 20 seconds   0.0.0.0:8080->8080/tcp
stack-db-1     postgres:16      "docker-entrypoint..."   db        Up 22 seconds   5432/tcp

Significado: “Up” no es “listo.” Postgres puede seguir ejecutando migraciones o reproduciendo WAL. Los clientes pueden ver rechazado durante el arranque temprano.

Decisión: Añade un healthcheck a la BD y condiciona el arranque del API a la disponibilidad de la BD (o implementa reintentos robustos con backoff acotado).

Task 14: Verifica el estado de salud (cuando añades healthchecks)

cr0x@server:~$ docker inspect -f '{{.State.Health.Status}}' stack-db-1
healthy

Significado: Los healthchecks te dan una señal de readiness fiable. Puedes usarla para decisiones de orquestación y alertas.

Decisión: Si aparece unhealthy: deja de culpar a la red y arregla la inicialización de la BD, credenciales, disco o configuración.

Task 15: Detecta problemas de firewall/NAT en el host para puertos publicados (host involucrado)

cr0x@server:~$ sudo iptables -S DOCKER | head
-N DOCKER
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.21.0.2:8080

Significado: Docker inyecta reglas para reenviar puertos del host a IPs de contenedor. Si estas reglas faltan, los puertos publicados no funcionarán.

Decisión: Si las reglas faltan o tu entorno usa nftables que anulan Docker: alinea la gestión del firewall con Docker,
o usa un modo de red diferente intencionalmente. No “simplemente vacíes iptables” en producción a menos que te gusten auditorías sorpresa.

Task 16: Detecta proyectos Compose múltiples accidentalmente en redes separadas

cr0x@server:~$ docker network ls --format 'table {{.Name}}\t{{.Driver}}\t{{.Scope}}' | grep -E 'stack|appnet'
NAME                DRIVER    SCOPE
stack_default        bridge    local
billing_default      bridge    local

Significado: Dos proyectos, dos redes por defecto. Un contenedor en stack_default no puede alcanzar servicios en billing_default por nombre.

Decisión: Adjunta ambas stacks a una red definida por el usuario compartida (explícitamente), o ejecútalas como un solo proyecto Compose si están acopladas.

Tres mini-historias corporativas desde el frente

Mini-historia 1: El incidente causado por una suposición equivocada (“localhost es la base de datos”)

Un equipo de producto migró un monolito a “microservicios” durante un trimestre. No fue una conversión religiosa; fue una partida presupuestaria.
El primer paso fue ejecutar el API y Postgres en Docker Compose localmente, y luego promover esa configuración a un entorno de desarrollo compartido.

En los días del monolito, la base de datos vivía en la misma VM. Así que la configuración en todas partes decía DB_HOST=localhost.
Durante la migración, alguien puso Postgres en un contenedor pero mantuvo la variable antigua, pensando que Docker la “mapearía.”
Docker lo mapeó—directamente al lugar equivocado.

El síntoma fue inmediato: los contenedores API lanzaron connect: connection refused. La primera respuesta fue aumentar los reintentos,
porque el equipo había sido quemado recientemente por cold starts en Kubernetes. Los reintentos pasaron de 3 a 30, y los logs se convirtieron en una novela cara.
Seguía fallando, porque no puedes alcanzar eventual consistency con un socket que no existe.

La ruptura ocurrió cuando alguien ejecutó getent hosts dentro del contenedor y notó que el nombre de servicio db resolvía bien.
El API simplemente no lo estaba usando. Un cambio en la configuración después—DB_HOST=db—y el incidente terminó.
La lección no fue “usa nombres de servicio.” La lección fue: las suposiciones son deuda técnica con fecha de vencimiento.

Mini-historia 2: La optimización que salió mal (puertos publicados para “rendimiento”)

Otra organización tenía un entorno de integración basado en Compose. Un ingeniero senior (competente, de verdad) decidió “simplificar la red”
haciendo que los servicios se hablasen entre sí vía puertos publicados en el host. La lógica sonaba limpia:
“Todo apunta a la IP del host, usamos una sola política de firewall y será más fácil depurar.”

Funcionó hasta que dejó de funcionar. Bajo carga, empezaron a ver “conexión rechazada” intermitente del servicio A al servicio B.
El rechazo se agrupaba durante despliegues, pero también al azar en horas pico. Ese es el tipo de comportamiento que hace que la gente culpe al proveedor cloud,
al kernel y a veces a la astrología.

El problema real fue complejidad autoinfligida. El tráfico entrante dio un rodeo: contenedor A → NAT del host → docker-proxy/iptables → contenedor B.
Durante reinicios de contenedores y churn de IPs, las ventanas de tiempo se ampliaron. Algunas conexiones aterrizaron en un mapeo de puerto que momentáneamente apuntaba a ninguna parte.
Además, las llamadas internas quedaron atadas a direcciones dependientes del host, dificultando la escalabilidad horizontal y los failovers.

Revirtieron a tráfico directo servicio-a-servicio sobre la red definida por el usuario, usando nombres de servicio y puertos de contenedor.
Para depuración e ingreso mantuvieron puertos publicados, pero las llamadas internas se quedaron internas. Su “optimización” fue en realidad un rodeo por más piezas móviles.

Mini-historia 3: La práctica aburrida que salvó el día (healthchecks y redes explícitas)

Un equipo de plataforma ejecutaba una stack Compose modesta para herramientas internas: API, cola, Postgres y un worker. Nada sofisticado.
Lo que sí era sofisticado era que lo trataron como producción: redes definidas por el usuario explícitas, healthchecks y nombres de servicio previsibles.
Sin defaults mágicos. Sin “funciona en mi portátil.”

Un viernes, el host se reinició tras un parche de kernel rutinario. Los servicios volvieron, pero el API empezó a fallar de inmediato.
El on-call vio “conexión rechazada” y se preparó para una larga noche. Entonces comprobó la salud de la BD: starting.
Postgres estaba reproduciendo WAL tras un apagado no limpio—normal, pero tarda.

Como había healthchecks, el contenedor API no atacó en estampida la BD con tormentas de conexiones.
Esperó. Los logs fueron aburridos. Las alertas fueron significativas. Diez minutos después, todo estaba healthy y nadie escribió un update en pánico.

Aburrido es un logro. “Funciona tras reinicio” no es una propiedad por defecto; es algo que se ingenia.

Errores comunes: síntoma → causa raíz → solución

Estos son los patrones recurrentes detrás de “conexión rechazada” en sistemas dockerizados. Cada uno incluye una acción correctiva específica.
Si tu equipo repite uno, conviértelo en una regla de lint o ítem de la checklist de revisión.

1) “El API no puede alcanzar la BD, pero la BD está en ejecución” → DB se liga a loopback → ligar a 0.0.0.0

  • Síntoma: connect: connection refused desde otros contenedores; ss muestra 127.0.0.1:5432.
  • Causa raíz: El servicio escucha solo en loopback dentro del contenedor.
  • Solución: Configurar la dirección de bind/listen a 0.0.0.0 (o IP del contenedor), más reglas de autenticación apropiadas (p. ej. pg_hba.conf de Postgres).

2) “Funciona en el host, falla en el contenedor” → uso de localhost → usar nombre de servicio

  • Síntoma: Config de la app usa localhost o 127.0.0.1 para otro servicio.
  • Causa raíz: Malentendido de espacios de nombres de red.
  • Solución: Usar el nombre del servicio de Compose (p. ej. redis, db) y el puerto del contenedor.

3) “Conexión rechazada solo durante el arranque” → readiness no garantizada → añadir healthchecks/backoff

  • Síntoma: Primeros segundos/minutos tras deploy: rechazado; más tarde: OK.
  • Causa raíz: El cliente arranca antes de que el servidor esté escuchando (o listo para aceptar conexiones).
  • Solución: Healthchecks y gating de dependencias; o reintentos cliente con backoff exponencial acotado y jitter.

4) “Puedo hacer ping, pero TCP es rechazado” → la red está bien, el puerto no → deja de depurar L3

  • Síntoma: IP alcanzable, ARP/enrutamiento bien, pero nc falla con refused.
  • Causa raíz: Servicio caído, puerto equivocado, dirección de bind equivocada, o crash de la app.
  • Solución: Revisa ss -lntp y logs del servicio; verifica mapeo de puertos y configuración.

5) “El nombre de servicio no resuelve” → red/proyecto equivocado → adjuntar a la misma red definida por usuario

  • Síntoma: getent hosts db falla dentro de un contenedor.
  • Causa raíz: Contenedores en redes distintas o proyectos Compose distintos sin una red compartida.
  • Solución: Declara una red compartida explícita en Compose y adjunta ambos servicios a ella.

6) “Conectando al puerto publicado desde otro contenedor” → rodeo por NAT → usa puerto del contenedor en la red

  • Síntoma: Contenedor llama a host:15432 o service:15432 porque 15432 está publicado.
  • Causa raíz: Confusión entre puertos de ingreso y puertos internos.
  • Solución: Para tráfico interno: db:5432. Publica puertos solo para clientes externos.

7) “Rechazos intermitentes tras redeploy” → suposiciones de IP obsoletas → deja de usar IPs de contenedor

  • Síntoma: IP hardcodeada funciona hasta un reinicio, luego rechazado/timeout.
  • Causa raíz: Las IPs de contenedor cambian; tu configuración no.
  • Solución: Usa descubrimiento por nombre, no direcciones IP de contenedor.

8) “Puerto publicado muerto desde fuera” → firewall/nftables en conflicto → alinea filtrado del host con Docker

  • Síntoma: Puerto del host mapeado, contenedor escucha, pero clientes externos reciben rechazado.
  • Causa raíz: Reglas de firewall del host que anulan el DNAT/forwarding de Docker, o reglas de Docker que no se instalaron bien.
  • Solución: Arregla la política de firewall para permitir el forwarding; asegúrate de la integración de la cadena Docker; evita gestionar iptables con dos sistemas compitiendo.

Broma #2: La red de Docker no está encantada; solo lo parece cuando te saltas la parte donde verificas en qué universo están tus paquetes.

Listas de verificación / plan paso a paso

Paso a paso: de “rechazado” a causa raíz en 10 minutos

  1. Identifica el objetivo exacto desde el log del cliente: host, puerto, protocolo. Si es localhost, asume que está mal hasta que se demuestre lo contrario.
  2. Revisa salud/estado del contenedor: ¿el servidor está reiniciando o unhealthy?
  3. Desde el contenedor cliente: resuelve el nombre del servidor y captura la IP.
  4. Desde el contenedor cliente: intenta conectar TCP con nc al nombre y puerto del servidor.
  5. Desde el contenedor servidor: confirma que existe un listener con ss -lntp.
  6. Confirma dirección de bind: si es 127.0.0.1, cambia a 0.0.0.0 (y ajusta autenticación).
  7. Confirma membresía de red: ambos contenedores en la misma red definida por el usuario; inspecciona el objeto de red.
  8. Elimina confusión de puertos: llamadas internas usan puerto del contenedor; llamadas externas usan puerto publicado.
  9. Sólo entonces inspecciona reglas de firewall/NAT del host, si el host forma parte del camino.
  10. Tras arreglar: añade healthchecks/readiness, quita sleeps rituales y documenta el contrato de red.

Checklist de despliegue: prevenir “rechazado” antes de que ocurra

  • Usa una red definida por el usuario; no confíes en el bridge por defecto.
  • Usa nombres de servicio para tráfico entre servicios; nunca hardcodees IPs de contenedor.
  • Enlaza servicios de red a 0.0.0.0 dentro de contenedores salvo razón específica.
  • Publica puertos solo para ingreso; no enrutes tráfico interno a través del host.
  • Añade healthchecks para servicios stateful (BD, cache, cola); consume el estado de salud en decisiones de orquestación.
  • Implementa reintentos acotados con backoff y jitter en clientes; trátalo como resiliencia, no como parche.
  • Mantén la política de firewall coherente con Docker; evita gestores de reglas enfrentados.
  • Haz explícitas y nombradas las redes de Compose cuando múltiples proyectos deban comunicarse.

Datos interesantes y contexto histórico (útil, no trivia)

  • Dato 1: Las primeras configuraciones de Docker dependían mucho de bridges Linux e iptables NAT; los puertos publicados todavía se implementan mediante reglas DNAT en muchos sistemas.
  • Dato 2: La red bridge por defecto históricamente se comportaba distinto de los bridges definidos por usuario, especialmente alrededor del DNS/descubrimiento automático de servicios.
  • Dato 3: El DNS embebido de Docker suele aparecer como 127.0.0.11 dentro de contenedores en redes definidas por usuario—un detalle que es oro para diagnóstico.
  • Dato 4: “Conexión rechazada” suele ser un RST TCP inmediato, lo que significa que el camino de red hacia la IP funcionó; el endpoint rechazó el puerto.
  • Dato 5: Los nombres de servicio de Compose se convirtieron en un mecanismo de descubrimiento de facto para dev/test mucho antes de que muchos equipos adoptaran sistemas de descubrimiento “reales”.
  • Dato 6: Las IPs de contenedor son intencionalmente efímeras; el direccionamiento estable se proporciona mediante nombres y descubrimiento, no fijando IPs.
  • Dato 7: Algunas distros migraron de iptables a nftables; herramientas de firewall descoordinadas pueden producir fallos de red Docker confusos si las cadenas no se integran correctamente.
  • Dato 8: depends_on en Compose nunca fue una garantía de readiness; es orden. Trata “listo” como una propiedad a nivel de aplicación.
  • Dato 9: Enlazar a 127.0.0.1 dentro de un contenedor es una trampa clásica porque silenciosamente bloquea todo el tráfico externo de contenedor mientras parece “seguro”.

Preguntas frecuentes

1) ¿Por qué obtengo “conexión rechazada” en lugar de un timeout?

Rechazado suele significar que llegaste a la IP de destino y el kernel respondió con un reset porque nadie escucha en ese puerto
(o un firewall rechazó activamente). Los timeouts tienen más que ver con paquetes descartados y rutas rotas.

2) Si ambos servicios están en Compose, ¿por qué no pueden hablar automáticamente?

Pueden, pero solo si comparten una red y usas nombres de servicio. Los problemas ocurren cuando los servicios caen en redes distintas,
proyectos Compose distintos, o el cliente está configurado para usar localhost o un puerto publicado en el host.

3) ¿Debo usar direcciones IP de contenedor por rendimiento?

No. Las IPs de contenedor cambian. La resolución de nombres no es tu cuello de botella; tu próxima caída sí lo será.
Usa nombres de servicio y deja que Docker DNS maneje el mapeo.

4) ¿Cuál es la diferencia entre expose y ports en Compose?

expose documenta puertos internos y puede influir en el comportamiento de linkage, pero no publica al host.
ports publica puertos a la interfaz del host (normalmente vía NAT). El tráfico container-to-container no necesita puertos publicados.

5) ¿Es depends_on suficiente para prevenir fallos de conexión al arrancar?

No. Lanza contenedores en orden; no garantiza que la dependencia esté lista para aceptar conexiones.
Usa healthchecks y/o reintentos cliente con backoff sensato.

6) ¿Por qué el servidor escucha en 127.0.0.1 dentro del contenedor?

Muchos servicios por defecto se atan a loopback por “seguridad.” En contenedores, eso a menudo bloquea el único tráfico que realmente quieres: otros contenedores.
Enlaza a 0.0.0.0 y protege con autenticación/ACLs en lugar de esconderte detrás de loopback.

7) ¿Pueden los firewalls causar “conexión rechazada” en Docker?

Sí. Las reglas de rechazo pueden generar RST/ICMP que parecen un rechazo. Más comúnmente, los firewalls causan timeouts al descartar paquetes.
Si tu ruta implica puertos publicados o enrutamiento entre hosts, valida la integración del firewall del host con Docker.

8) ¿Deben los contenedores llamarse entre sí vía el puerto publicado del host?

Usualmente no. Añade NAT, modos de fallo extra y acoplamiento al host. Usa networking servicio-a-servicio vía la red Docker compartida.
Publica puertos para clientes fuera de Docker.

9) ¿Por qué funciona localmente pero falla en CI o en un servidor compartido?

Las configuraciones locales suelen tener menos redes, menos proyectos Compose y menos políticas de firewall. En CI/entornos compartidos,
los nombres de red colisionan, los servicios arrancan en distinto orden y las bases de firewall pueden diferir. Haz las redes explícitas y añade healthchecks.

10) ¿Qué pasa si DNS resuelve correctamente pero aún obtengo rechazado?

Entonces DNS no es tu problema. Rechazado apunta a sockets en escucha, direcciones de bind, puertos equivocados o crashes del proceso servidor.
Ejecuta ss -lntp dentro del contenedor servidor y verifica que el puerto esté escuchando en 0.0.0.0.

Conclusión: pasos prácticos siguientes

“Conexión rechazada” no es un misterio; es un diagnóstico esperando a cerrarse. Tu trabajo es dejar de tratarlo como un fenómeno meteorológico.
Prueba el objetivo, prueba la resolución de nombres, prueba la membresía de red, prueba el listener y sólo entonces discute sobre firewalls.

Pasos siguientes que pagan la factura:

  • Audita cada configuración entre servicios buscando localhost, IPs del host y puertos publicados usados internamente. Reemplaza por nombres de servicio y puertos de contenedor.
  • Añade healthchecks para servicios stateful y haz que tus clientes manejen el arranque con backoff acotado.
  • Haz explícitas las redes de Compose, especialmente cuando múltiples proyectos deban comunicarse.
  • Estandariza un runbook corto: getent, nc, ss, docker network inspect. Hazlo memoria muscular.
]]>
https://cr0x.net/es/docker-conexion-rechazada-redes/feed/ 0
Redes Docker: bridge vs host vs macvlan — elige la que no te dará problemas después https://cr0x.net/es/redes-docker-bridge-host-macvlan/ https://cr0x.net/es/redes-docker-bridge-host-macvlan/#respond Thu, 30 Oct 2025 05:01:25 +0000 https://cr0x.net/2026/01/06/redes-docker-bridge-host-macvlan/ Algunos incidentes no empiezan con un fallo evidente. Empiezan con “Solo cambiamos la red”. Y de repente tus contenedores no pueden alcanzar la base de datos, tu monitorización queda ciega y el equipo de seguridad descubre que tu app ahora escucha en todas las interfaces como si fuera 2009.

Docker te ofrece tres palancas tentadoras para el comportamiento L2/L3 local—bridge, host y macvlan. Las tres pueden funcionar. Las tres pueden arruinarte el fin de semana si las eliges por la razón equivocada. Elijamos la que envejece bien en producción.

Decisión primero: qué elegir en 60 segundos

Si gestionas sistemas en producción, tu valor por defecto debería ser aburrido. Las redes aburridas generan menos alertas.

Elige bridge cuando

  • Necesitas publicación de puertos (-p) y aislamiento razonable.
  • Quieres que el host siga siendo el host, no “sopa de contenedores con root arriba”.
  • Tienes múltiples servicios por host y quieres que coexistan sin conflictos de puertos.
  • Quieres un camino hacia diseños más avanzados después (múltiples redes, redes internas, controles de política).

Elige host cuando

  • Necesitas bajo overhead y puedes aceptar el radio de impacto (filtros de paquetes, puertos y namespaces se comparten).
  • Ejecutas un servicio principal orientado a la red por host, y ya enlaza los puertos que necesita.
  • Tienes una buena estrategia para firewall y observabilidad en el host.

Elige macvlan cuando

  • Necesitas que los contenedores aparezcan como ciudadanos L2 de primera clase con su propia MAC/IP en tu LAN física.
  • Te integras con sistemas que esperan IPs únicas por workload (licencias legadas, listas de control basadas en IP, routers upstream, multicast, monitorización antigua, “appliances de seguridad”).
  • Puedes gestionar las realidades de L2: ARP, tablas CAM, seguridad de puertos del switch y disciplina de IPAM.

Mi valor por defecto y con sesgo: comienza con redes bridge definidas por el usuario. Usa host solo cuando puedas defender el riesgo por escrito. Usa macvlan solo cuando debas integrarte con la LAN como un host real y hayas validado que el switch no te castigará por ello.

El modelo mental que evita incidentes tontos

El networking de Docker no es magia. Es namespaces de red de Linux más algo de pegamento: pares de Ethernet virtual (veth), un dispositivo bridge, reglas de enrutamiento y reglas de NAT/iptables/nftables. El “driver” que eliges decide mayormente por dónde viajan esos paquetes y quién posee los puertos.

Tres preguntas que lo deciden todo

  1. ¿Dónde viven los puertos? ¿Mapeas puertos del contenedor a puertos del host (bridge), o el contenedor comparte el espacio de puertos del host (host), o el contenedor obtiene su propia IP (macvlan)?
  2. ¿Quién hace la aplicación de políticas L3/L4? ¿Firewall del host + reglas gestionadas por Docker, o ACLs de la red upstream, o ambos?
  3. ¿Cuál es tu dominio de fallo? ¿Quieres que “un contenedor se volvió raro” se convierta en “el host está raro”?

Además: el rendimiento rara vez es tu primer problema. La capacidad de depuración y la previsibilidad sí lo son. Un driver de red que sea 3% más rápido pero 30% más difícil de diagnosticar no es una optimización. Es un incidente futuro con una invitación de calendario.

Una cita que he visto hacerse realidad en más postmortems de los que quisiera contar: idea parafraseada: “La esperanza no es una estrategia.” — Gene Kranz (idea parafraseada)

Networking bridge: el predeterminado con razón

El modo bridge es la historia de Docker “quiero que los contenedores sean su propio pequeño mundo, pero aún accesibles”. El contenedor obtiene una IP en una subred privada. Docker crea un bridge de Linux (como docker0 o uno definido por el usuario), luego conecta el eth0 del contenedor a él mediante un par veth. El tráfico saliente se enruta/NATea hacia la interfaz del host. El tráfico entrante suele usar puertos publicados.

Por qué bridge es apto para producción

  • La publicación de puertos es explícita. Abres solo lo que quieres abrir. Eso importa cuando la imagen del contenedor cambia y de repente enlaza puertos adicionales.
  • Los nombres importan. Las redes bridge definidas por el usuario ofrecen descubrimiento de servicios basado en DNS integrado. Los contenedores pueden hablar por nombre sin que pegues IPs en configuraciones.
  • El aislamiento es real, más o menos. No es un límite de VM completo, pero es una línea de contención significativa para colisiones accidentales de puertos y algunas clases de mala configuración.
  • La depuración es manejable. Puedes razonar sobre los flujos: contenedor → veth → bridge → host → uplink; y las reglas de NAT son visibles.

Cuando bridge puede causar problemas

  • Desajustes de MTU. Las redes overlay, VPNs o tramas jumbo pueden hacer que PMTUD falle y los paquetes desaparezcan.
  • Sorpresas de NAT. El cambio de IP de origen puede romper ACLs upstream o confundir logs.
  • Comportamiento hairpin. Contenedor→host→contenedor vía puerto publicado puede comportarse diferente que el tráfico directo contenedor→contenedor.
  • Deriva del firewall. Docker manipula iptables/nftables. Si tu firewall base asume control total, habrá una guerra de territorios.

El networking bridge es como un sedán fiable. No es sexy, pero arranca en invierno, las piezas son baratas y todo el mundo sabe cómo arreglarlo.

Networking host: rápido, afilado e inseguro por defecto

--network host deja las apariencias: el contenedor comparte el namespace de red del host. Sin NAT. Sin IP de contenedor. Sin publicación de puertos. Si el proceso enlaza 0.0.0.0:443, está enlazando el puerto 443 del host. Ese es el punto.

Para qué es realmente bueno el modo host

  • Workloads de alto ritmo de paquetes donde NAT y el overhead de conntrack son medibles y problemáticos.
  • Appliances de red (demonios de enrutamiento, speakers BGP, servidores DHCP) donde quieres semánticas de interfaz directas.
  • Hosts simples de un solo inquilino donde un workload posee la máquina.

Qué rompe el modo host (silenciosamente)

  • Las colisiones de puertos se vuelven fallos “aleatorios”. Despliega dos servicios que quieran 8125/udp y lo descubrirás en tiempo de ejecución.
  • Los límites de seguridad se desdibujan. El contenedor puede ver interfaces del host, a veces servicios locales del host, y tu suposición de “está solo dentro de Docker” desaparece.
  • La observabilidad se complica. Herramientas que esperan IPs de contenedor pierden un ancla; la atribución de tráfico puede requerir herramientas conscientes de cgroups.

Broma corta #1: El networking host es como darle a tu contenedor las llaves maestras porque prometió que solo iba a mover el coche para lavarlo.

Gobernanza del modo host que lo hace viable

  • Usa systemd o un orquestador para evitar que dos contenedores enlacen el mismo puerto.
  • Aplica la política de firewall del host explícitamente; no confíes en los “buenos valores por defecto” de Docker.
  • Documenta la propiedad de puertos por host como si fuera un contrato. Porque lo es.
  • Prefiere el modo host solo para workloads que lo justifiquen: captura de paquetes, agentes de métricas, proxies de borde o demonios de red reales.

Macvlan: la opción que “parece un host real” (y sus trampas)

Macvlan asigna a cada contenedor su propia dirección MAC e IP en tu segmento de red físico. Desde el resto de la LAN, el contenedor es un par. Sin mapeo de puertos, sin NAT. Simplemente “aquí hay otra máquina”. Es seductor porque elimina una categoría de incomodidades: los sistemas upstream pueden hablar directamente con los contenedores sin malabarismos de puertos.

Cuando macvlan es la respuesta correcta

  • ACLs legadas y listas de permitidos basadas en IP donde no puedes o no quieres reescribir la política alrededor de NAT.
  • Contenedores tipo appliance que necesitan su propia identidad IP para enrutamiento, monitorización o segmentación de red.
  • Software dependiente de multicast/broadcast donde las semánticas de NAT/bridge son problemáticas (con matices; no todo se vuelve más fácil).

La gran trampa de macvlan: tráfico host→contenedor

Por defecto, con macvlan, el host no puede hablar con sus hijos macvlan en la misma interfaz física. Esto sorprende a la gente cada vez. Los paquetes no hacen hairpin como esperarías.

La solución suele ser crear una subinterfaz macvlan en el host (una interfaz “shim”) en la misma red macvlan y enrutar a través de ella. No es difícil, pero es otra parte móvil que debes mantener a través de reinicios y gestión de configuración.

Dónde macvlan duele después

  • Seguridad de puertos del switch y límites de tabla CAM. Algunos switches no aceptan que un puerto físico emita de repente docenas de MACs. Lo aprenderás a las 2 a.m., durante un incidente, si no lo preguntas antes.
  • Tormentas ARP y churn de tablas de vecinos. Los contenedores aparecen y desaparecen; las caches ARP no siempre se mantienen al día.
  • IPAM se convierte en tu problema. El IPAM de Docker puede asignar desde un rango, pero no va a negociar con tu servidor DHCP o la hoja de cálculo del equipo de red a menos que hagas el trabajo.
  • Pruebas “en una sola máquina” más difíciles. No puedes ejecutar todo en un portátil y esperar que la LAN se comporte igual, especialmente cuando entra Wi‑Fi en la ecuación.

Broma corta #2: Macvlan es genial hasta que tu switch ve treinta nuevas MACs y decide practicar la atención plena dejando caer el tráfico.

Hechos interesantes y un poco de historia (útil, no para trivial)

  • Namespaces de red de Linux (la base del networking de contenedores) llegaron al kernel a finales de los 2000, originalmente para aislar pilas de red para procesos sin virtualización completa.
  • El networking temprano de Docker se apoyaba mucho en reglas iptables de NAT; durante años, “Docker rompió mi firewall” fue un rito de iniciación porque insertaba cadenas automáticamente.
  • Las redes bridge definidas por el usuario añadieron descubrimiento de servicios basado en DNS embebido, que fue un gran avance respecto al antiguo mecanismo --link que incrustaba entradas de host frágiles en los contenedores.
  • Macvlan como característica del kernel es anterior a la adopción por Docker; es un driver de Linux que permite que múltiples MAC virtuales compartan una interfaz física, usado históricamente para segregación de red y entornos de laboratorio.
  • Conntrack (seguimiento de conexiones) es un impuesto oculto en configuraciones con mucho NAT; tasas altas de conexiones pueden agotar las tablas conntrack y parecer pérdida de paquetes aleatoria.
  • Los problemas de MTU empeoraron con la popularidad de overlays/VPNs; “funciona en un host, falla entre sitios” a menudo se reduce a path MTU y fragmentación.
  • El networking host es efectivamente optar por salir de uno de los aislamientos más prácticos de los contenedores: la separación de namespaces de puertos. Por eso muchos orquestadores lo tratan como una opción privilegiada.
  • Bridge vs macvlan suele ser un debate sobre dónde vive la identidad: en el host (bridge/NAT) o en la LAN (macvlan). Ninguna es gratuita.

Tareas prácticas: comandos, salidas y la decisión que tomas

Estas son las tareas que realmente ejecuto cuando alguien dice: “La red está rara”. Cada una tiene un comando, una salida de ejemplo, lo que significa y qué decides después.

Tarea 1: Lista las redes Docker y detecta lo obvio

cr0x@server:~$ docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
a1b2c3d4e5f6   bridge    bridge    local
f6e5d4c3b2a1   host      host      local
112233445566   none      null      local
77aa88bb99cc   app-net   bridge    local

Qué significa: Tienes el bridge por defecto, más un bridge definido por usuario (app-net). Eso es bueno: las redes bridge definidas por usuario dan mejor comportamiento DNS y separación.

Decisión: Si tus servicios siguen usando el bridge predeterminado y patrones legados, muévelos a una red definida por usuario salvo que haya una razón para no hacerlo.

Tarea 2: Inspecciona una red y confirma subred, gateway y opciones

cr0x@server:~$ docker network inspect app-net
[
  {
    "Name": "app-net",
    "Driver": "bridge",
    "IPAM": {
      "Config": [
        {
          "Subnet": "172.22.0.0/16",
          "Gateway": "172.22.0.1"
        }
      ]
    },
    "Options": {
      "com.docker.network.bridge.name": "br-77aa88bb99cc"
    }
  }
]

Qué significa: Este bridge usa un dispositivo bridge de Linux dedicado y una subred definida. Predecible.

Decisión: Si esta subred se solapa con tu VPN corporativa o rangos del centro de datos, cámbiala ahora. El solapamiento causa incidentes “solo roto desde algunos portátiles”.

Tarea 3: Comprueba qué red está usando realmente un contenedor

cr0x@server:~$ docker inspect -f '{{json .NetworkSettings.Networks}}' api-1
{"app-net":{"IPAddress":"172.22.0.10","Gateway":"172.22.0.1","MacAddress":"02:42:ac:16:00:0a"}}

Qué significa: El contenedor está en app-net con una IP interna.

Decisión: Si la app espera ser accesible desde la LAN sin mapeo de puertos, bridge no es suficiente; considera macvlan o un ingreso/proxy adecuado.

Tarea 4: Confirma puertos publicados y dónde enlazan

cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Ports}}'
NAMES     PORTS
api-1     0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp
db-1      5432/tcp

Qué significa: api-1 está expuesto en el puerto 8080 del host. db-1 no está publicado; es solo interno (bien).

Decisión: Si ves enlaces en 0.0.0.0 que no pretendías, bloquéalos (-p 127.0.0.1:... o firewall) antes de que alguien más los encuentre.

Tarea 5: Revisa las reglas NAT de Docker (iptables) y si existen

cr0x@server:~$ sudo iptables -t nat -S | sed -n '1,40p'
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
-N DOCKER
-N DOCKER_OUTPUT
-N DOCKER_POSTROUTING
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER_OUTPUT
-A POSTROUTING -s 172.22.0.0/16 ! -o br-77aa88bb99cc -j MASQUERADE

Qué significa: Docker gestiona NAT para la subred bridge. Esa regla MASQUERADE es tu camino de salida.

Decisión: Si estás en sistemas solo con nftables, verifica que Docker sea compatible con tu stack de firewall. Herramientas mixtas causan confusión de “las reglas existen pero no se aplican”.

Tarea 6: Revisa la presión de conntrack (el dolor del NAT aparece aquí)

cr0x@server:~$ sudo conntrack -S
cpu=0 found=120384 invalid=42 ignore=0 insert=120410 insert_failed=0 drop=0 early_drop=0 error=0 search_restart=0

Qué significa: Los paquetes inválidos son pocos; no hay drops. Conntrack no está en llamas ahora.

Decisión: Si ves drops/insert_failed subiendo bajo carga, aumentar límites de conntrack o reducir churn/NAT de conexiones se vuelve urgente. El modo host a veces “arregla” esto evitando NAT, pero es un intercambio.

Tarea 7: Verifica ruta y MTU desde dentro del contenedor

cr0x@server:~$ docker exec api-1 ip route
default via 172.22.0.1 dev eth0
172.22.0.0/16 dev eth0 proto kernel scope link src 172.22.0.10
cr0x@server:~$ docker exec api-1 ip link show eth0
2: eth0@if21: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
    link/ether 02:42:ac:16:00:0a brd ff:ff:ff:ff:ff:ff link-netnsid 0

Qué significa: La ruta por defecto es el gateway del bridge; MTU es 1500.

Decisión: Si tu underlay es 1450 por VPN/overlay, ajusta el MTU de la red Docker o del host para que el contenedor no envíe paquetes que se pierdan.

Tarea 8: Ver la veth del lado host y la pertenencia al bridge

cr0x@server:~$ ip link show br-77aa88bb99cc
7: br-77aa88bb99cc: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
    link/ether 02:42:11:22:33:44 brd ff:ff:ff:ff:ff:ff
cr0x@server:~$ bridge link | grep br-77aa88bb99cc | head
21: veth6d2b1a2@if20: <BROADCAST,MULTICAST,UP,LOWER_UP> master br-77aa88bb99cc state forwarding priority 32 cost 2

Qué significa: El par veth del contenedor está adjunto al bridge y en forwarding.

Decisión: Si no ves la interfaz o no está en forwarding, probablemente tengas un problema de kernel/bridge o el contenedor está en un estado de namespace de red malo. Reiniciar Docker puede “arreglarlo”, pero primero recoge evidencia.

Tarea 9: Prueba DNS dentro de una red bridge definida por usuario

cr0x@server:~$ docker exec api-1 getent hosts db-1
172.22.0.11    db-1

Qué significa: El DNS embebido de Docker funciona; la resolución contenedor→contenedor está bien.

Decisión: Si el DNS falla aquí, no culpes primero al DNS corporativo. Comprueba que los contenedores compartan una red bridge definida por usuario; el bridge predeterminado se comporta diferente.

Tarea 10: Confirma el comportamiento de host networking comprobando la IP del contenedor (no habrá una)

cr0x@server:~$ docker run --rm --network host alpine ip addr show eth0 | sed -n '1,12p'
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 0c:de:ad:be:ef:01 brd ff:ff:ff:ff:ff:ff
    inet 10.20.30.40/24 brd 10.20.30.255 scope global eth0
       valid_lft forever preferred_lft forever

Qué significa: Estás viendo la dirección de la interfaz del host desde dentro del contenedor. Eso es lo que “network host” significa.

Decisión: Si necesitas reglas de firewall por contenedor o identidades IP distintas, el modo host es la herramienta equivocada.

Tarea 11: Crea una red macvlan con un rango IP controlado

cr0x@server:~$ docker network create -d macvlan \
  --subnet=10.50.10.0/24 --gateway=10.50.10.1 \
  --ip-range=10.50.10.128/25 \
  -o parent=eno1 macvlan-net
8f9e0d1c2b3a4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e

Qué significa: Los contenedores pueden recibir direcciones en 10.50.10.128/25 mientras que el resto de la subred se reserva para otros usos.

Decisión: Si no puedes reservar un rango limpio y documentarlo, no uses macvlan. Los conflictos de IP son desastres en cámara lenta.

Tarea 12: Ejecuta un contenedor en macvlan y confirma que tiene una IP de LAN

cr0x@server:~$ docker run -d --name web-mv --network macvlan-net --ip 10.50.10.140 nginx:alpine
c2b1a0f9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1
cr0x@server:~$ docker exec web-mv ip addr show eth0 | sed -n '1,10p'
2: eth0@if33: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:0a:32:0a:8c brd ff:ff:ff:ff:ff:ff
    inet 10.50.10.140/24 brd 10.50.10.255 scope global eth0

Qué significa: El contenedor es ahora un endpoint de LAN de primera clase.

Decisión: Verifica la política del switch (seguridad de puertos, límites de MAC). Si los paquetes se caen después de unos cuantos contenedores, no es “Docker inestable”. Es tu L2 aplicando reglas.

Tarea 13: Confirma la limitación clásica de macvlan: el host no puede alcanzar al contenedor (por defecto)

cr0x@server:~$ ping -c 2 10.50.10.140
PING 10.50.10.140 (10.50.10.140) 56(84) bytes of data.

--- 10.50.10.140 ping statistics ---
2 packets transmitted, 0 received, 100% packet loss, time 1017ms

Qué significa: El tráfico host→hijo macvlan no funciona. Esto es esperado en muchas configuraciones.

Decisión: Si los servicios del host (agentes de backup, monitores locales, sidecars) deben hablar con esos contenedores, crea un shim macvlan en el host.

Tarea 14: Añade una interfaz shim macvlan en el host para alcanzar contenedores macvlan

cr0x@server:~$ sudo ip link add macvlan-shim link eno1 type macvlan mode bridge
cr0x@server:~$ sudo ip addr add 10.50.10.2/24 dev macvlan-shim
cr0x@server:~$ sudo ip link set macvlan-shim up
cr0x@server:~$ ping -c 2 10.50.10.140
PING 10.50.10.140 (10.50.10.140) 56(84) bytes of data.
64 bytes from 10.50.10.140: icmp_seq=1 ttl=64 time=0.412 ms
64 bytes from 10.50.10.140: icmp_seq=2 ttl=64 time=0.398 ms

--- 10.50.10.140 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1001ms

Qué significa: El host ahora puede alcanzar la red macvlan vía el shim.

Decisión: Hazlo persistente (systemd-networkd/NetworkManager) o desaparecerá en el reinicio y redescubrirás esta limitación en un incidente.

Tarea 15: Valida la salud de ARP/tablas de vecinos en el host

cr0x@server:~$ ip neigh show dev eno1 | head
10.50.10.1 lladdr 00:11:22:33:44:55 REACHABLE
10.50.10.140 lladdr 02:42:0a:32:0a:8c REACHABLE
10.50.10.141 lladdr 02:42:0a:32:0a:8d STALE

Qué significa: El host está aprendiendo vecinos. Si ves muchos FAILED o churn constante, macvlan podría estar estresando L2/L3.

Decisión: Si el churn de vecinos se correlaciona con pérdida de paquetes, reduce el churn de contenedores, ajusta umbrales GC o reconsidera macvlan para ese entorno.

Tarea 16: Confirma qué proceso posee un puerto (modo host y “listeners misteriosos”)

cr0x@server:~$ sudo ss -lntp | grep ':8080'
LISTEN 0      4096         0.0.0.0:8080      0.0.0.0:*    users:(("nginx",pid=21457,fd=6))

Qué significa: Algo (aquí, nginx) posee el puerto 8080 en la pila de red del host.

Decisión: Si esperabas publicación de puertos de Docker pero ves un listener directo, podrías estar en modo host o ejecutar el servicio en el host por accidente. Arregla el modelo de despliegue antes de perseguir problemas fantasma de firewall.

Tarea 17: Traza el camino de paquetes rápidamente con tcpdump (bridge vs host vs macvlan)

cr0x@server:~$ sudo tcpdump -ni br-77aa88bb99cc port 5432 -c 5
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on br-77aa88bb99cc, link-type EN10MB (Ethernet), snapshot length 262144 bytes
12:01:10.112233 IP 172.22.0.10.49822 > 172.22.0.11.5432: Flags [S], seq 123456789, win 64240, options [mss 1460,sackOK,TS val 1 ecr 0,nop,wscale 7], length 0

Qué significa: Estás viendo tráfico contenedor→contenedor en el bridge. Eso confirma que el problema no es “los paquetes no salieron del contenedor”.

Decisión: Si el tráfico aparece en el bridge pero no en el uplink, centra en reglas de enrutamiento/NAT. Si aparece en el uplink pero no llega al destino, es upstream.

Guion rápido de diagnóstico

Este es el orden que ahorra tiempo. No el orden que te hace sentir como un mago de redes.

Primero: identifica el modo de red y la alcanzabilidad prevista

  • Ejecuta docker inspect en el contenedor y confirma si está en bridge/host/macvlan.
  • Aclara: ¿la falla es contenedor → internet, contenedor → contenedor, LAN → contenedor o host → contenedor?

Pista de cuello de botella: La mayoría de los problemas “de redes Docker” son realmente problemas de “tu modelo mental está equivocado”. Arregla el modelo, luego la configuración.

Segundo: comprueba lo obvio de L3 (dentro del contenedor y en el host)

  • Dentro del contenedor: ip addr, ip route, resolución DNS con getent hosts.
  • En el host: estado del bridge, presencia de veth, ruta a la subred, tabla de vecinos (macvlan).

Pista de cuello de botella: Ruta por defecto faltante o DNS malo causa 80% de las quejas “no puede alcanzar X”.

Tercero: valida política y traducción (iptables/nftables, enlaces de puertos, conntrack)

  • Problemas inbound en modo bridge: comprueba puertos publicados y cadenas NAT de iptables.
  • Problemas en modo host: revisa qué proceso posee el puerto y las reglas de firewall del host.
  • Problemas macvlan: comprueba estado ARP/vecinos y restricciones/seguridad del switch.
  • Extraños bajo alta carga: revisa estadísticas de conntrack y logs del kernel.

Pista de cuello de botella: Si el tráfico funciona brevemente y falla bajo carga, asume presión de conntrack o enforcement L2 upstream antes de culpar a Docker.

Cuarto: captura paquetes en la interfaz correcta

  • Bridge: tcpdump en la interfaz del contenedor (dentro) y en el bridge (br-*).
  • Host: tcpdump en la interfaz del host y usa herramientas a nivel de proceso para atribuir tráfico.
  • Macvlan: tcpdump en la interfaz parent y en el shim macvlan (si se usa).

Pista de cuello de botella: Si los paquetes salen del contenedor pero nunca llegan al bridge/uplink, tienes un problema de cableado de namespace. Si alcanzan el uplink, es upstream.

Errores comunes: síntoma → causa raíz → solución

1) “El contenedor no puede alcanzar internet, pero DNS resuelve”

Síntoma: getent hosts example.com funciona; curl se queda colgado o hace timeout.

Causa raíz: Ruta por defecto faltante/incorrecta, regla MASQUERADE rota, o firewall del host bloqueando el reenvío.

Solución: Verifica ip route dentro del contenedor; en el host, confirma iptables -t nat MASQUERADE y que el reenvío IP esté habilitado. Asegura que el firewall del host permita el reenvío desde la subred del bridge.

2) “El servicio está arriba, pero nadie puede conectarse desde afuera” (bridge)

Síntoma: El contenedor escucha en 0.0.0.0:8080 internamente, pero clientes LAN no pueden conectar.

Causa raíz: Puerto no publicado, o publicado solo en localhost, o firewall del host bloquea el puerto publicado.

Solución: Revisa docker ps por 0.0.0.0:hostport->containerport. Si falta, añade -p. Luego verifica que el firewall del host permita inbound.

3) “Dos contenedores están inestables, a veces uno no arranca” (host)

Síntoma: Bucles de reinicio o errores intermitentes al enlazar; logs mencionan “address already in use.”

Causa raíz: El modo host comparte el namespace de puertos; ambos servicios quieren el mismo puerto.

Solución: Deja de usar host networking para ambos, o rediseña puertos. En modo host, trata la asignación de puertos como un recurso global por host.

4) “Los contenedores macvlan funcionan desde la LAN, pero el host no puede alcanzarlos”

Síntoma: Desde otra máquina en la subred puedes conectar; desde el host Docker falla.

Causa raíz: El comportamiento por defecto de macvlan impide la comunicación host→hijo en el mismo parent.

Solución: Añade una interfaz shim macvlan en el host con una IP en la misma subred y enruta a través de ella (o elige ipvlan L3 si corresponde en tu entorno).

5) “Todo funcionó hasta que añadimos más contenedores; luego timeouts aleatorios” (macvlan)

Síntoma: Nuevos contenedores son alcanzables a veces; ARP parece inestable; logs del switch se quejan.

Causa raíz: Seguridad de puerto del switch o límites de direcciones MAC, presión de tabla CAM, o limitación de ARP.

Solución: Coordina con el equipo de red: aumenta límites de MAC en el puerto, desactiva seguridad estricta donde proceda, o evita macvlan en ese segmento.

6) “Algunas peticiones cuelgan, especialmente respuestas grandes” (cualquier modo)

Síntoma: Pings pequeños funcionan; transferencias grandes se atascan; handshake TLS a veces falla.

Causa raíz: Desajuste de MTU y PMTUD roto.

Solución: Mide el MTU efectivo, fija MTU de la red Docker o ajusta MTU de interfaces consistentemente, y valida con pings con bit DF.

7) “Contenedor a contenedor funciona por IP, no por nombre” (bridge)

Síntoma: ping 172.22.0.11 funciona; ping db-1 falla.

Causa raíz: Los contenedores no están en la misma red bridge definida por usuario, o estás usando el bridge por defecto sin el comportamiento DNS correcto.

Solución: Coloca ambos contenedores en la misma red bridge definida por usuario y usa nombres de contenedor (o alias explícitos) allí.

Listas de verificación / plan paso a paso

Paso a paso: elegir el driver con seguridad

  1. Escribe la matriz de alcanzabilidad. Quién necesita hablar con quién (LAN → contenedor, contenedor → LAN, host → contenedor, contenedor → internet).
  2. Decide requisitos de identidad. ¿Los sistemas upstream requieren IPs por workload? Si sí, macvlan podría ser necesario; si no, prefiere bridge.
  3. Decide el modelo de exposición. ¿Quieres puertos publicados explícitos (bridge) o puertos compartidos del host (host)? Por defecto, elige explícito.
  4. Revisa el modelo de propiedad del firewall. Si el firewall del host es gestionado centralmente y los cambios de Docker no son bienvenidos, planifica la integración con cuidado (bridge) o evita patrones con mucho NAT.
  5. Revisa la política del switch si macvlan está en juego (límites de MAC, seguridad de puerto, inspección ARP). Haz esto antes del despliegue.
  6. Elige el modelo más simple que satisfaga los requisitos. Luego documéntalo para que la próxima persona no lo “optimice”.

Lista de verificación para redes bridge en producción

  • Usa redes bridge definidas por usuario, no el bridge por defecto, para apps reales.
  • Elige subredes que no se solapen con rangos VPN/centro de datos.
  • Publica solo puertos necesarios; enlaza a IPs específicas del host cuando sea posible.
  • Decide cómo gestionarás iptables/nftables (y prueba tras actualizaciones del SO).
  • Valida MTU de extremo a extremo y configúralo intencionalmente.

Lista de verificación para networking host en producción

  • Reserva el modo host para workloads que lo justifiquen (ritmo de paquetes, demonios de red, agentes de host).
  • Mantén un mapa de propiedad de puertos por host (o impónlo con automatización).
  • Endurezca reglas de firewall del host; no confíes en los valores por defecto del contenedor.
  • Verifica la atribución en observabilidad: ¿puedes vincular tráfico a un contenedor/cgroup?

Lista de verificación para macvlan en producción

  • Reserva un rango IP documentado; evita mezclar con DHCP a menos que realmente controles el entorno.
  • Confirma que la política del switch soporta múltiples MAC por puerto y no provocará flapping.
  • Planifica el acceso host→contenedor (interfaz shim) si es necesario.
  • Monitorea comportamiento ARP/tabla de vecinos y límites de tasa.
  • Decide quién gestiona DNS: querrás registros directo/inverso si las herramientas corporativas los esperan.

Tres minihistorias corporativas (porque se repetirán si no)

Mini-historia 1: un incidente causado por una suposición equivocada (alcanzabilidad host con macvlan)

Una empresa mediana estaba contenerizando un servicio de reporting legado que necesitaba ser alcanzado por un conjunto de jobs upstream. Eligieron macvlan porque cada job upstream tenía una allowlist con IPs destino codificadas, y reescribir la política habría requerido capital político que no tenían.

En staging todo se veía bien. Desde otras máquinas en la subred podían alcanzar las IPs de los contenedores directamente. El cambio fue a producción un viernes por la tarde, porque claro que sí, y de inmediato sus health checks locales empezaron a fallar. El orquestador marcó instancias como unhealthy y las reinició. Ahora el servicio entró en flapping, lo que hizo que los jobs upstream fallaran más, lo que disparó más reintentos y todo se volvió más ruidoso.

La suposición equivocada: “Si la LAN puede alcanzarlo, el host puede alcanzarlo.” Con macvlan, la ruta host→hijo es una excepción conocida en muchas configuraciones. Sus health checks corrían en el namespace de red del host y no podían alcanzar la IP del contenedor macvlan. El servicio estaba bien; el host simplemente no lo veía.

La solución fue mundana: crear una interfaz shim macvlan en cada host, darle una IP en la subred macvlan y actualizar los health checks para usar ese camino. También documentaron la restricción para que nadie la “simplificara” después.

Mini-historia 2: una optimización que salió mal (host networking para evitar NAT)

Otra organización ejecutaba una canalización de ingestión de métricas de alto rendimiento. Alguien notó contadores de conntrack subiendo bajo carga y decidió que el NAT del bridge “gastaba CPU”. La solución propuesta fue simple: mover los contenedores de ingestión a --network host para que los paquetes evitaran NAT y conntrack por completo.

En un benchmark estrecho funcionó. La CPU bajó, la latencia mejoró y todos se sintieron como si hubieran descubierto un truco. El cambio se desplegó progresivamente en la flota.

Luego empezó la rareza: un subconjunto de hosts tuvo fallos intermitentes de ingestión después de despliegues. No todos los hosts, no todo el tiempo. La causa raíz fueron colisiones de puertos entre el servicio de ingestión y un sidecar de depuración que también enlazaba un puerto UDP. En modo bridge podían coexistir en namespaces diferentes. En modo host, el segundo servicio simplemente no pudo enlazar. A veces el orden de despliegue lo enmascaraba; otras explotaba.

Revirtieron el networking host para la capa de ingestión y en su lugar ajustaron la capacidad de conntrack y redujeron el churn de conexiones con batching. El verdadero cuello de botella no era “NAT es lento”. Era “creamos demasiados flujos de corta vida”. El modo host trató el síntoma e introdujo una nueva clase de fallo más difícil de razonar.

Después añadieron una regla: cualquier cambio que aumente el radio de impacto (como host networking) necesita un modelado de amenazas por escrito y un plan de rollback. Esa política fue menos emocionante que la gráfica del benchmark, pero funcionó.

Mini-historia 3: aburrido pero correcto que salvó el día (bridge definido por usuario + publicación explícita)

Un equipo de servicios financieros ejecutaba múltiples servicios orientados al cliente en hosts compartidos. Estandarizaron en redes bridge definidas por usuario por stack de aplicación y publicaron puertos explícitamente, vinculándolos a interfaces del host específicas. No fue trendy. También fue resiliente.

Una noche, una actualización de imagen de un vendor introdujo un nuevo listener de depuración dentro del contenedor. Nada malicioso; solo uno de esos defaults “oops, quedó activado”. El servicio seguía funcionando normalmente.

Porque el equipo usaba publicación de puertos explícita, el nuevo puerto interno se mantuvo interno. No apareció de repente en el host y no fue accesible desde la LAN. La monitorización no alertó, seguridad no entró en pánico y el incidente nunca fue noticia.

Su postmortem fue corto: “No tuvimos que preocuparnos porque no lo expusimos.” Ese es el tipo de victoria aburrida que solo notas al compararla con la línea temporal alternativa.

Preguntas frecuentes

1) ¿El networking bridge siempre es más lento que host?

No. El modo bridge añade overhead (veth, lookup en bridge, a menudo NAT/conntrack), pero para muchos workloads no es tu cuello de botella. Mide antes de “arreglarlo”. Si no estás saturando CPU en softirq/conntrack, bridge suele estar bien.

2) ¿Por qué debería preferir un bridge definido por usuario sobre el bridge por defecto?

Los bridges definidos por usuario ofrecen mejor comportamiento de DNS/descubrimiento de servicios, separación más clara y setups multi-red más predecibles. El bridge por defecto es compatible con legado, no pensado para producción.

3) ¿Cuándo es buena idea --network host?

Cuando el workload realmente necesita semánticas de red del host: altos ritmos de paquetes donde el overhead de NAT importa, demonios de red o agentes de host. También cuando el host es efectivamente de un solo propósito. Si no, host mode aumenta el radio de impacto y la complejidad de depuración.

4) ¿Por qué el host no puede alcanzar contenedores macvlan por defecto?

Porque macvlan aisla el tráfico entre la interfaz parent y los endpoints macvlan de una forma que impide que la pila del host hable directamente con sus hijos en esa misma interfaz. El workaround común es una interfaz shim macvlan en el host.

5) ¿Puedo ejecutar macvlan en Wi‑Fi?

A veces, pero suele ser doloroso. Muchos drivers Wi‑Fi y puntos de acceso no manejan múltiples MACs fuente por estación como quieres. Si debes intentarlo, hazlo en un laboratorio primero y prepárate para decepciones.

6) ¿Debería usar macvlan solo para evitar conflictos de puertos?

Usualmente no. Los conflictos de puertos se resuelven mejor con networking bridge más publicación de puertos, o colocando un reverse proxy/ingress delante. Macvlan cambia los conflictos de puertos por complejidad de IPAM y L2.

7) ¿Cómo evito que Docker manipule mi firewall?

Puedes restringir el comportamiento de Docker, pero no puedes fingir que no necesita reglas si quieres NAT/puertos publicados. El enfoque práctico es decidir si el firewall del host es “consciente de Docker” y probar el orden de reglas tras actualizaciones. Si tu entorno prohíbe cambios dinámicos de firewall, rediseña en torno a networking enrutado o balanceadores upstream.

8) ¿Cuál es la forma más segura de exponer servicios en modo bridge?

Publica solo los puertos requeridos, enlaza a una IP específica del host cuando proceda (por ejemplo, solo interfaz interna) y aplica reglas de firewall del host. Trata los puertos publicados como superficie de API externa.

9) ¿Cómo manejo logging y visibilidad de IP de cliente con NAT en bridge?

Puedes perder la IP origen original si el tráfico es proxy/NAT dependiendo del camino. Usa reverse proxies que pasen X-Forwarded-For o proxy protocol donde aplique, o evita NAT en ese salto (macvlan/host o un diseño enrutado) si la IP real del cliente es obligatoria.

Siguientes pasos que puedes hacer esta semana

  • Inventario tus hosts: lista contenedores y anota cuáles usan host networking y por qué. Si “porque funcionó” es la razón, no es una razón.
  • Migra una pila a un bridge definido por usuario si aún usas el bridge por defecto. Valida resolución por nombre y disciplina de publicación de puertos.
  • Elige un plan de subredes no solapadas para bridges Docker entre entornos (dev/stage/prod). Los solapamientos son una fuga lenta que se convierte en inundación.
  • Decide tu política de macvlan: o “permitido con validación de switch y reserva de rango IP” u “no permitido”. La ambigüedad es cómo macvlan aparece en producción sin adultos en la sala.
  • Escribe un runbook de una página usando el Guion rápido de diagnóstico arriba e incluye los comandos exactos que tu on-call puede ejecutar sin pensar.

Si quieres una regla que resista bajo estrés: usa bridge hasta que puedas nombrar la limitación específica del bridge que estás encontrando. Luego elige host o macvlan como una excepción deliberada, no por moda.

]]>
https://cr0x.net/es/redes-docker-bridge-host-macvlan/feed/ 0
Docker macvlan: No puedo alcanzar el contenedor — Soluciona la clásica trampa de enrutamiento https://cr0x.net/es/docker-macvlan-trampa-ruteo-host/ https://cr0x.net/es/docker-macvlan-trampa-ruteo-host/#respond Tue, 14 Oct 2025 14:22:29 +0000 https://cr0x.net/2026/01/05/docker-macvlan-trampa-ruteo-host/ Has creado una red macvlan para que tu contenedor viva como una “máquina real” en la LAN. Obtiene su propia IP, aparece en los registros DHCP,
y otros hosts pueden alcanzarlo. Luego intentas curl desde el host Docker y… nada. No hay ping, no hay conexión TCP, no hay alegría.

Esta es la clásica trampa de enrutamiento de macvlan: el host no puede alcanzar a sus hijos macvlan a través de la misma interfaz física por defecto.
Parece un problema de firewall. Huele a problema de ARP. No es ninguno de los dos—hasta que lo conviertes en uno por adivinar.

Lo que ves (y por qué es tan confuso)

El modo de fallo suele ser muy específico:

  • El contenedor tiene una IP en tu LAN (por ejemplo 192.168.10.50).
  • Otros equipos en la LAN pueden hacer ping/curl sin problema.
  • El host Docker no puede hacer ping/curl al contenedor.
  • A veces, el contenedor tampoco puede alcanzar al host en la IP LAN del host.

Si vienes de redes bridge (docker0) esperas que el host pueda hablar con todo.
Macvlan viola esa expectativa a propósito. No es “Docker comportándose raro”; es el driver macvlan de Linux haciendo exactamente
lo que fue diseñado para hacer: crear múltiples interfaces virtuales con direcciones MAC únicas, pero con propiedades específicas de aislamiento de tráfico.

La trampa: pones el contenedor “directamente” en la LAN, pero no pusiste al host en ese mismo segmento L2 de forma que permita comunicación host↔hijo.
La NIC física del host es el padre, y las interfaces macvlan son hijos. En modos comunes de macvlan, Linux no permitirá que el tráfico
vuelva desde el padre a sus propios hijos macvlan.

Broma #1: macvlan es como darle a tus contenedores sus propias puertas de entrada—y luego darte cuenta de que el casero cerró el pasillo interno.

Comportamiento de macvlan en cristiano: el host no “está” en esa red

Cuando creas una red macvlan en Docker, Docker le pide al kernel crear interfaces macvlan vinculadas a una interfaz padre
(por ejemplo, eth0 o enp3s0). Esas interfaces macvlan viven en los namespaces de los contenedores, cada una con su propia dirección MAC.
Para el switch, parecen máquinas separadas conectadas al mismo puerto.

La clave es la entrega local: un host Linux no necesariamente enruta/bridgea paquetes desde su interfaz padre hacia sus propios hijos macvlan.
Así que el host intenta alcanzar 192.168.10.50, hace ARP por eth0 y espera la respuesta. Pero las reglas del kernel para macvlan pueden impedir
que ese tráfico vuelva a entrar en la interfaz macvlan que pertenece al contenedor. Es una limitación deliberada: macvlan está pensado principalmente
para dar identidades L2 a endpoints, no para hacer que el padre les hable.

Por eso ves la extraña asimetría:
otros hosts de la LAN pueden alcanzar el contenedor porque su tráfico llega por el cable y se entrega en la interfaz macvlan.
Pero el tráfico originado por el host se origina “por encima” de la interfaz padre y está sujeto a semánticas de filtrado local.

Modos de macvlan y el que Docker suele implicar

Linux admite varios modos de macvlan: bridge, private, vepa, passthru.
El driver macvlan de Docker por defecto se asemeja al comportamiento bridge para los endpoints macvlan, pero la limitación host↔endpoint sigue siendo la pega clásica.

Si quieres conectividad host↔contenedor, necesitas añadir explícitamente una interfaz en el host en la red macvlan (un macvlan “shim” en el host),
y enrutar la subred de los contenedores a través de ella. O cambiar a ipvlan L3 o usar otro modelo de networking.

Datos e historia interesantes que realmente importan

  1. Macvlan es una función del kernel de Linux, no una invención de Docker; Docker solo lo invoca. Eso importa cuando depuras: piensa en el kernel, no en “magia de Docker”.
  2. Macvlan se popularizó en virtualización y cargas telco donde muchos endpoints necesitan identidades L2 distintas en un uplink compartido.
  3. “Un puerto de switch, muchas MACs” puede activar funciones de seguridad de switch empresarial como límites de MAC en port-security. Por eso macvlan funciona en laboratorio y falla estrepitosamente en un armario corporativo.
  4. La limitación de comunicación host↔hijo macvlan es conocida y antigua; no es una regresión. Es un efecto secundario de cómo macvlan se engancha en la ruta RX/TX.
  5. Ipvlan se añadió más tarde como alternativa que puede reducir la proliferación de MAC: múltiples endpoints comparten la MAC del padre, desplazando la identidad a L3. A veces es la opción madura.
  6. Hairpinning es un concepto separado del aislamiento host de macvlan. Puedes habilitar hairpin en bridges; no “arregla” automáticamente la accesibilidad del host en macvlan.
  7. El modo promiscuo suele ser necesario en hipervisores (VMware, algunas VPCs cloud) para pasar múltiples MACs por una NIC virtual. Sin ello, macvlan descarta paquetes silenciosamente y culpas a Docker.
  8. El ARP flux y el enrutamiento asimétrico aparecen más con macvlan porque ahora tienes múltiples direcciones en un mismo segmento físico y el kernel debe elegir IPs de origen y rutas con cuidado.

Guía de diagnóstico rápido (comprueba primero/segundo/tercero)

El objetivo es responder tres preguntas rápidamente:
(1) ¿Está el contenedor realmente en la LAN? (2) ¿Está el camino host↔contenedor bloqueado por la trampa macvlan?
(3) ¿Hay algo más (VLAN, seguridad del switch, filtrado del hipervisor, firewall) que lo empeore?

Primero: confirma que el síntoma es la trampa clásica

  • Desde otro host de la LAN, haz ping/curl a la IP del contenedor.
  • Desde el host Docker, haz ping/curl a la IP del contenedor.
  • Si funciona desde otros hosts pero no desde el host Docker, probablemente estás en la trampa.

Segundo: valida lo básico L2/L3 (no “arregles” lo que no está roto)

  • Confirma que la IP del contenedor, máscara de subred y gateway sean correctos.
  • Confirma que la tabla de rutas del host no contenga ya una ruta en conflicto.
  • Revisa el comportamiento ARP en el host para la IP del contenedor (¿está INCOMPLETE? ¿MAC errónea?).

Tercero: comprueba las restricciones del entorno

  • ¿Estás en una VM que bloquea múltiples MACs? Si sí, habilita promisc / forged transmits / MAC spoofing según corresponda.
  • ¿Estás en un switch gestionado con port-security o límites de direcciones MAC? Si sí, puede que necesites aumentar el límite o evitar macvlan.
  • ¿Usas trunks VLAN? Verifica que el etiquetado sea correcto y que la interfaz padre sea la subinterfaz VLAN adecuada.

Cuarto: elige un patrón de solución

  • Si necesitas tráfico host↔contenedor: añade una interfaz macvlan en el host y una ruta.
  • Si necesitas menos MACs: considera ipvlan L3 y enruta en su lugar.
  • Si solo necesitas exponer servicios simples: considera bridge networking + publicación de puertos, y sigue con tu vida.

Tareas prácticas: comandos, salidas y decisiones (12+)

Estas son las tareas que realmente ejecuto cuando alguien dice “macvlan está roto.” Cada una incluye una salida representativa y qué decisión toma.
Ajusta nombres de interfaz, subredes e IDs de contenedor a tu entorno.

Tarea 1: Confirma que la red Docker es realmente macvlan y aporta su padre

cr0x@server:~$ docker network ls
NETWORK ID     NAME           DRIVER    SCOPE
a1b2c3d4e5f6   bridge         bridge    local
d4e5f6a1b2c3   host           host      local
e5f6a1b2c3d4   none           null      local
f6a1b2c3d4e5   lan-macvlan    macvlan   local
cr0x@server:~$ docker network inspect lan-macvlan | sed -n '1,120p'
[
  {
    "Name": "lan-macvlan",
    "Id": "f6a1b2c3d4e5...",
    "Driver": "macvlan",
    "Options": {
      "parent": "enp3s0"
    },
    "IPAM": {
      "Config": [
        {
          "Subnet": "192.168.10.0/24",
          "Gateway": "192.168.10.1"
        }
      ]
    }
  }
]

Decisión: si Driver no es macvlan o parent es incorrecto (NIC equivocada, subinterfaz VLAN equivocada),
detente y corrige eso primero. Padre equivocado = teatro de depuración.

Tarea 2: Confirma la IP del contenedor y la interfaz dentro del namespace

cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.ID}}\t{{.Networks}}'
NAMES         ID            NETWORKS
dns01         7c1d2a9f0b11  lan-macvlan
cr0x@server:~$ docker exec -it dns01 ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    inet 127.0.0.1/8 scope host lo
2: eth0@if18: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:c0:a8:0a:32 brd ff:ff:ff:ff:ff:ff
    inet 192.168.10.50/24 brd 192.168.10.255 scope global eth0

Decisión: si el contenedor no tiene IP, o no está en la subred esperada, tu problema es IPAM/DHCP/configuración estática, no la trampa de enrutamiento.

Tarea 3: Prueba la alcanzabilidad desde otro host de la LAN (prueba de control)

cr0x@server:~$ ping -c 2 192.168.10.50
PING 192.168.10.50 (192.168.10.50) 56(84) bytes of data.
From 192.168.10.20 icmp_seq=1 Destination Host Unreachable
From 192.168.10.20 icmp_seq=2 Destination Host Unreachable

--- 192.168.10.50 ping statistics ---
2 packets transmitted, 0 received, +2 errors, 100% packet loss

La salida anterior representa el resultado “malo” desde el propio host. Ejecuta lo mismo desde otra máquina de la LAN si la tienes.

Decisión: si nadie en la LAN puede alcanzarlo, probablemente tienes filtrado de MAC en el switch/hipervisor, VLAN equivocada o firewall del contenedor.
Si otros pueden alcanzarlo pero el host no, continúa: trampa clásica.

Tarea 4: Revisa la tabla de rutas del host para la subred del contenedor

cr0x@server:~$ ip route show
default via 192.168.10.1 dev enp3s0 proto dhcp src 192.168.10.20 metric 100
192.168.10.0/24 dev enp3s0 proto kernel scope link src 192.168.10.20 metric 100

Decisión: si la ruta a 192.168.10.0/24 apunta a enp3s0, el host hará ARP en la interfaz padre.
Eso es normal. También crea la trampa: el host piensa que puede alcanzar directamente al contenedor en la red, pero la entrega local a hijos macvlan está bloqueada.

Tarea 5: Inspecciona el estado ARP/neighbor mientras haces ping al contenedor

cr0x@server:~$ ip neigh show 192.168.10.50
192.168.10.50 dev enp3s0 INCOMPLETE

Decisión: INCOMPLETE sugiere que las solicitudes ARP no están siendo respondidas de una manera que el host acepte.
En la trampa macvlan, la respuesta ARP puede nunca ser entregada al stack del host.

Tarea 6: Captura ARP en la interfaz padre del host mientras haces ping

cr0x@server:~$ sudo tcpdump -ni enp3s0 arp and host 192.168.10.50
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on enp3s0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
12:10:01.123456 ARP, Request who-has 192.168.10.50 tell 192.168.10.20, length 28
12:10:02.125001 ARP, Request who-has 192.168.10.50 tell 192.168.10.20, length 28

Decisión: si ves requests pero no replies, o las replies no aparecen, o las respuestas no son emitidas (contenedor no las ve) o son filtradas
por el host/hipervisor/switch.

Tarea 7: Captura ARP dentro del contenedor mientras el host hace ping

cr0x@server:~$ docker exec -it dns01 tcpdump -ni eth0 arp and host 192.168.10.20
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
12:10:01.123789 ARP, Request who-has 192.168.10.50 tell 192.168.10.20, length 28

Decisión: si el contenedor ve la request ARP, el camino L2 está bien. Si el host aún muestra INCOMPLETE,
estás frente a la limitación host↔macvlan o filtrado local de firewall.

Tarea 8: Verifica que el host no esté bloqueando silenciosamente con nftables/iptables

cr0x@server:~$ sudo nft list ruleset | sed -n '1,120p'
table inet filter {
  chain input {
    type filter hook input priority 0; policy accept;
  }
  chain forward {
    type filter hook forward priority 0; policy drop;
    ct state established,related accept
    iifname "docker0" oifname "docker0" accept
  }
}

Decisión: si tu política forward es drop y dependes del reenvío entre interfaces del host,
necesitarás reglas explícitas. Nota: la clásica trampa macvlan persiste incluso con firewall permisivo, pero un firewall estricto puede añadir fallos adicionales.

Tarea 9: Confirma que Docker no colocó reglas iptables conflictivas (configuraciones legacy)

cr0x@server:~$ sudo iptables -S | sed -n '1,80p'
-P INPUT ACCEPT
-P FORWARD DROP
-P OUTPUT ACCEPT
-N DOCKER
-N DOCKER-USER
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER

Decisión: si FORWARD es DROP, entiende qué debe ser reenviado.
Para host↔macvlan, normalmente resolvemos con una interfaz macvlan en el host y enrutamiento, no reenviando a través de docker0.

Tarea 10: Crea una interfaz macvlan en el host (“shim”) y asigna una IP

Esta es la corrección central para la trampa de enrutamiento. Creamos una interfaz macvlan en el host, atada al mismo padre,
le damos una IP en la misma subred (o un /32 dedicado más una ruta), y luego enrutar la IP del contenedor vía ese shim.

cr0x@server:~$ sudo ip link add macvlan0 link enp3s0 type macvlan mode bridge
cr0x@server:~$ sudo ip addr add 192.168.10.254/24 dev macvlan0
cr0x@server:~$ sudo ip link set macvlan0 up
cr0x@server:~$ ip addr show macvlan0
20: macvlan0@enp3s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 8a:1c:2e:11:22:33 brd ff:ff:ff:ff:ff:ff
    inet 192.168.10.254/24 scope global macvlan0

Decisión: una vez que esta interfaz esté arriba, el host tiene una presencia L2 que puede hablar con hijos macvlan.
Si tu LAN ya usa .254 para algo, elige una IP libre. No te apropies de la dirección del router a menos que disfrutes las noches en vela.

Tarea 11: Añade una ruta específica (o ruta por política) para que el tráfico vaya por el shim

Si las IPs de los contenedores están dentro de la misma subred que el host (común), el host seguirá considerándolas “on-link” vía enp3s0.
Necesitamos empujar al enrutamiento para que las IPs del contenedor se alcancen a través de macvlan0.

cr0x@server:~$ sudo ip route add 192.168.10.50/32 dev macvlan0
cr0x@server:~$ ip route get 192.168.10.50
192.168.10.50 dev macvlan0 src 192.168.10.254 uid 1000
    cache

Decisión: si ip route get muestra dev macvlan0, tu host ahora enviará tráfico al contenedor a través del shim.
Para múltiples contenedores, enruta un rango entero o usa un rango IPAM dedicado a contenedores macvlan para poder enrutar un prefijo, no docenas de /32.

Tarea 12: Valida la conectividad host↔contenedor después del shim

cr0x@server:~$ ping -c 2 192.168.10.50
PING 192.168.10.50 (192.168.10.50) 56(84) bytes of data.
64 bytes from 192.168.10.50: icmp_seq=1 ttl=64 time=0.451 ms
64 bytes from 192.168.10.50: icmp_seq=2 ttl=64 time=0.389 ms

--- 192.168.10.50 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1002ms
rtt min/avg/max/mdev = 0.389/0.420/0.451/0.031 ms

Decisión: si ping funciona, TCP debería funcionar. Si ping funciona pero TCP no, ahora es momento de revisar bind de servicios y firewalls del contenedor.

Tarea 13: Confirma que el contenedor puede alcanzar el host vía la IP del shim

cr0x@server:~$ docker exec -it dns01 ping -c 2 192.168.10.254
PING 192.168.10.254 (192.168.10.254) 56(84) bytes of data.
64 bytes from 192.168.10.254: icmp_seq=1 ttl=64 time=0.312 ms
64 bytes from 192.168.10.254: icmp_seq=2 ttl=64 time=0.298 ms

--- 192.168.10.254 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 0.298/0.305/0.312/0.007 ms

Decisión: indica a las aplicaciones en el host que hablen al contenedor vía la IP del contenedor, y a los contenedores que hablen de vuelta al host vía 192.168.10.254.
No es bonito. Es fiable.

Tarea 14: Comprueba filtrado MAC o problemas de promisc (pista VM/hipervisor)

cr0x@server:~$ ip -d link show enp3s0 | sed -n '1,40p'
2: enp3s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether 3c:52:82:aa:bb:cc brd ff:ff:ff:ff:ff:ff
    promiscuity 0

Decisión: en metal desnudo, promiscuity 0 puede seguir siendo válido porque la NIC recibe tramas para las MAC que se le anuncian.
En una VM, si las tramas macvlan se filtran upstream, verás comportamiento “funciona a veces”. Entonces la solución está fuera de Linux: habilitar MAC spoofing/forged transmits/promisc en el vSwitch/port group.

Tarea 15: Verifica la interfaz padre VLAN si haces trunking

cr0x@server:~$ ip link show | egrep 'enp3s0|enp3s0\.'
2: enp3s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
15: enp3s0.30@enp3s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000

Decisión: si tus contenedores pertenecen a VLAN 30, el parent macvlan de Docker debería ser enp3s0.30, no enp3s0.
VLAN equivocada equivale a “inaccesible”, y perderás tiempo culpando a macvlan.

Patrones de solución: elige la opción menos mala

No hay una solución universal porque macvlan suele elegirse por una razón: quieres adyacencia L2, IPs separadas y que otros dispositivos LAN traten a los contenedores como ciudadanos de primera clase.
Pero también quieres que el host los administre. Esos objetivos están algo en conflicto.

Patrón A (recomendado): Shim macvlan en el host + ruta

Esta es la corrección estándar para la trampa de enrutamiento. Hace al host un par en el segmento macvlan de una forma que el kernel aceptará.
Es explícito. Es observable. Es reversible.

Cómo hacerlo bien:

  • Asigna una IP dedicada para el shim (p. ej., 192.168.10.254).
  • Usa un rango IP dedicado para contenedores (p. ej., 192.168.10.128/25) para poder enrutar un prefijo a macvlan0 en lugar de añadir muchas /32.
  • Persiste la configuración (systemd-networkd, NetworkManager o un script de arranque). Los ip link add ad-hoc desaparecen al reiniciar.

Patrón B: Usa ipvlan L3 en lugar de macvlan

Si tu necesidad principal es “los contenedores tienen IPs en la LAN y son alcanzables”, ipvlan L3 puede ser más limpio.
Reduce la proliferación de direcciones MAC porque los endpoints pueden compartir la MAC del padre, y el enrutamiento es explícito en L3.

La contrapartida: ahora te preocupas más por el enrutamiento y menos por la semántica de broadcast L2. Algunos protocolos de descubrimiento que dependen de broadcast L2
no se comportarán igual. A cambio, evitas drama con port-security del switch y ajustes de MAC spoofing en el hipervisor.

Patrón C: No uses macvlan; usa bridge + puertos publicados

A veces la respuesta correcta es: deja de intentar que los contenedores parezcan máquinas físicas. Si solo expones HTTP, DNS o unos pocos puertos TCP,
el networking bridge con publicación de puertos es más simple y es menos probable que enfurezca a tu equipo de red.

Macvlan es una herramienta. No es una personalidad.

Patrón D: Pon al host en una subinterfaz VLAN y deja los contenedores en otra

En algunas organizaciones, lo más limpio es hacer que el host “viva” en una VLAN de gestión y poner a los contenedores macvlan en una VLAN de servicio vía trunk.
Así puedes enrutar explícitamente entre ellas y tratar al host como un endpoint router más.

A menudo así es como la gente hace funcionar macvlan en sistemas multi-tenant sin crear un host medio-adjuntado raro.

Patrón E: Usa una NIC dedicada para cargas macvlan

Si tienes hardware y puerto, dedicar una NIC física a cargas macvlan puede reducir conflictos con la identidad LAN del host.
También hace los dominios de fallo más limpios: puedes resetear la NIC macvlan sin perder SSH al host.

Broma #2: cuando macvlan se rompe en producción, nunca es “la red,” hasta que lo es—y entonces siempre es tu ticket de cambio.

Errores comunes: síntoma → causa raíz → solución

1) El host no puede alcanzar el contenedor, pero otros hosts sí

Síntoma: la LAN funciona, el host falla (ping/curl desde el host caduca).

Causa raíz: aislamiento clásico host↔hijo macvlan en la interfaz padre.

Solución: crea una interfaz macvlan shim en el host y añade rutas para que las IPs de los contenedores vayan por el shim.

2) Nadie puede alcanzar el contenedor, el contenedor no llega a nada

Síntoma: el contenedor tiene IP, pero está muerto en la red.

Causa raíz: filtrado upstream de múltiples MACs (ajustes del hipervisor, port-security del switch) o parent VLAN equivocada.

Solución: habilita MAC spoofing/promisc/forged transmits en el hipervisor/vSwitch; aumenta el límite de MAC en el switch; asegura que el parent sea eth0.VLAN al trunkear.

3) El contenedor es accesible hasta que despliegas un segundo, luego inestabilidad

Síntoma: el primer contenedor funciona; añadir más causa ARP intermitente o alcance aleatorio.

Causa raíz: límite de MAC en port-security del switch, churn de la tabla CAM, o IPs duplicadas por asignaciones estáticas descuidadas.

Solución: revisa port-security del switch; asigna rangos IP correctamente; usa IPAM de Docker con un rango controlado; considera ipvlan para reducir el conteo de MAC.

4) Alcance desde el host “arreglado”, pero solo para ping

Síntoma: ping funciona tras el shim; conexión TCP falla.

Causa raíz: servicio ligado a localhost, firewall del contenedor o firewall del host bloqueando puertos específicos.

Solución: valida ss -lntp dentro del contenedor; confirma que el servicio liga a 0.0.0.0 o a la IP del contenedor; ajusta reglas nftables/iptables.

5) El contenedor no puede alcanzar servicios del host en la IP LAN del host

Síntoma: el contenedor puede alcanzar internet, pero no 192.168.10.20 (host).

Causa raíz: mismo aislamiento, solo invertido: el tráfico contenedor→host dirigido a la IP del padre llega al stack del host de una forma que puede no devolver bien.

Solución: hace que los contenedores apunten a la IP shim del host (p. ej., 192.168.10.254), o diseña con VLANs separadas y enrutamiento.

6) Las tramas aparecen en tcpdump, pero las aplicaciones aún no conectan

Síntoma: tcpdump ve SYNs llegando; la aplicación caduca.

Causa raíz: enrutamiento asimétrico o rp_filter que descarta respuestas porque el kernel cree que la ruta de retorno es “incorrecta”.

Solución: revisa sysctl net.ipv4.conf.*.rp_filter; considera routing por políticas o asegura que la ruta a las IPs de contenedor sea vía el shim macvlan.

7) Red Docker creada con gateway que en realidad no es alcanzable

Síntoma: el contenedor es alcanzable on-link pero no puede salir de la subred.

Causa raíz: gateway equivocado (error tipográfico, VLAN equivocada) o firewall en el gateway que bloquea ese rango de contenedores.

Solución: valida la ruta por defecto del contenedor; valida ARP del gateway; coordina con el equipo de red para ACLs.

Tres micro-historias del mundo corporativo desde las trincheras de macvlan

Micro-historia 1: El incidente causado por una suposición equivocada

Una empresa mediana quería ejecutar un puñado de servicios de red en contenedores: DNS interno, un relé NTP y un par de appliances de proveedores
que asumían IPs “reales”. Alguien propuso macvlan: “Será limpio. Los contenedores obtienen IPs, sin mapeo de puertos, todo parece un host normal.”
Pasó el piloto con un contenedor. Todo bien.

El incidente comenzó tras un reinicio de mantenimiento. El monitoring gritó que DNS estaba caído—pero solo desde el propio host Docker.
Otros servidores aún resolvían. El ingeniero on-call hizo lo normal: reinició el contenedor, revisó logs y luego revisó el firewall.
Nadie sospechó el problema real porque se asumía que el host siempre puede alcanzar las cargas que ejecuta. Esa suposición suele ser correcta, hasta que eliges macvlan.

Escalaron al equipo de red (claro). Ellos vieron ARP desde el host y ninguna respuesta. El contenedor sí veía las requests ARP, pero el host nunca aprendió la entrada neighbor.
Todos miraron capturas durante una hora, convencidos de un bug de switch. El “bug” era local: la interfaz padre del host no podía hablar con su hijo macvlan.

La solución fue un shim macvlan en el host más una ruta /32 para la IP DNS. Los chequeos DNS desde el host se pusieron verdes de inmediato.
La lección quedó: macvlan no está roto, es opinativo. Si no conoces su opinión, la expresará a las 3 a.m.

Micro-historia 2: La optimización que salió mal

Otra organización ejecutaba una flota de hosts Docker en una plataforma virtualizada. Alcanzaron un límite: demasiadas MACs aprendidas en un puerto de switch top-of-rack.
Alguien propuso “una optimización”: mantener macvlan pero reciclar contenedores e IPs agresivamente para reducir el conteo steady-state de MACs. En papel parece ingenioso.
En realidad, así es como enseñas a la red a odiarte.

A medida que los contenedores churneaban, el switch aprendía y envejecía entradas MAC constantemente. Algunas capas del hipervisor también cacheaban filtros MAC.
El resultado no fue un fallo limpio. Fue lo peor: alcanzabilidad intermitente.
Un contenedor sería alcanzable desde algunas subredes pero no desde otras. Las tablas ARP diferían entre hosts. Handshakes TCP se colgaban a medias.

La revisión del incidente reveló que la “optimización” aumentó el churn justo donde quieres estabilidad: identidad L2 y descubrimiento de vecinos.
El equipo de red no estaba contento, y con razón. L2 está feliz cuando es aburrida.

La corrección fue migrar la carga a ipvlan L3 en un segmento enrutado y mantener vidas útiles de contenedor estables.
También reservaron un rango IP dedicado y dejaron de reciclar direcciones agresivamente. La “optimización” no fue más rápida; solo fue más ruidosa.

Micro-historia 3: La práctica aburrida pero correcta que salvó el día

Una firma financiera tenía una regla estricta: cada red no-bridge usaba un rango de direcciones dedicado, documentado en una hoja interna “IPAM lite”.
No glamoroso. No cutting-edge. Muy efectivo.

Cuando introdujeron macvlan para un servicio legacy que necesitaba adyacencia L2, asignaron un /27 contiguo para IPs de contenedores y reservaron una dirección para el shim del host.
Crearon la interfaz shim vía systemd-networkd, no con un script efímero. También escribieron un runbook de una página: “Si el host no alcanza el contenedor, verifica la ruta al /27 vía macvlan0.”

Meses después, una actualización de kernel y un upgrade de Docker ocurrieron durante el parcheo rutinario. Un ingeniero junior notó que los cheques de monitoring desde el host comenzaron a fallar.
No “intentaron cosas”. Siguieron el runbook: verificaron que existiera la interfaz shim, verificaron la ruta, verificaron ARP. Un reinicio había eliminado la interfaz porque un archivo de configuración no estaba habilitado.
Habilitaron la configuración, recargaron la red y el servicio se recuperó rápido.

No pasó nada heroico. Ese es el punto. La práctica que salvó el día fue aburrida: rangos dedicados, configuración persistente y un runbook que asume que los humanos están cansados.

Listas de comprobación / plan paso a paso

Checklist: antes de elegir macvlan en producción

  • Confirma que realmente necesitas identidades L2. Si solo necesitas TCP/UDP entrante, bridge + puertos publicados suele ser mejor.
  • Haz la pregunta de red temprano: ¿puede este puerto aceptar múltiples MACs? ¿Hay port-security o límites de MAC? ¿Características NAC?
  • Decide de dónde vienen las IPs: rango IPAM estático o DHCP (Docker macvlan a menudo usa asignación estática vía Docker IPAM).
  • Reserva una IP shim del host y documenta esto.
  • Planifica un rango dedicado para contenedores para poder enrutar un prefijo al shim.
  • Confirma diseño VLAN: si trunkeas, crea subinterfaces VLAN y úsalas como padres macvlan.

Paso a paso: implementar macvlan con acceso desde el host (la forma sensata)

  1. Crea la red Docker macvlan con una subred definida y (idealmente) un rango IP restringido.

    cr0x@server:~$ docker network create -d macvlan \
      --subnet=192.168.10.0/24 --gateway=192.168.10.1 \
      -o parent=enp3s0 lan-macvlan
    f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5

    Decisión: si esto falla, probablemente no tienes permisos, el parent no existe o NetworkManager te está peleando.

  2. Ejecuta un contenedor y asigna una IP (o deja que Docker escoja del pool).

    cr0x@server:~$ docker run -d --name dns01 --network lan-macvlan --ip 192.168.10.50 alpine sleep 1d
    7c1d2a9f0b11b7f9a3b6c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2

    Decisión: si Docker rechaza la IP, tienes conflictos o la IP está fuera de la subred.

  3. Crea la interfaz shim en el host y asigna una IP.

    cr0x@server:~$ sudo ip link add macvlan0 link enp3s0 type macvlan mode bridge
    cr0x@server:~$ sudo ip addr add 192.168.10.254/24 dev macvlan0
    cr0x@server:~$ sudo ip link set macvlan0 up
    cr0x@server:~$ ip -br addr show macvlan0
    macvlan0@enp3s0     UP             192.168.10.254/24

    Decisión: si la interfaz no sube, revisa el estado del padre y restricciones del driver.

  4. Añade rutas para las IPs de contenedores (preferiblemente un prefijo).

    cr0x@server:~$ sudo ip route add 192.168.10.128/25 dev macvlan0
    cr0x@server:~$ ip route get 192.168.10.50
    192.168.10.50 dev enp3s0 src 192.168.10.20 uid 1000
        cache

    La salida anterior muestra que la ruta aún va por enp3s0 porque 192.168.10.50 no está dentro de 192.168.10.128/25.
    Decisión: alinea tu rango IP de contenedores con tu ruta. No enrutes la mitad equivocada de la subred y llames a eso “networking.”

  5. Enruta el rango correcto o añade rutas /32 para contenedores específicos.

    cr0x@server:~$ sudo ip route add 192.168.10.50/32 dev macvlan0
    cr0x@server:~$ ip route get 192.168.10.50
    192.168.10.50 dev macvlan0 src 192.168.10.254 uid 1000
        cache

    Decisión: si el enrutamiento es correcto, prueba la conectividad de la aplicación. Si aún falla, ya no estás en la categoría “trampa macvlan”.

  6. Persiste el shim y las rutas usando el sistema de gestión de red del host.

    No lo dejes efímero. Reinicios son inevitables, como reuniones sobre por qué ocurrieron reinicios.

Una cita que deberías tener en tu panel mental

“La esperanza no es una estrategia.” — Vince Lombardi (citada a menudo en círculos de ops/confiabilidad)

Preguntas frecuentes

1) ¿Por qué otros hosts de la LAN pueden alcanzar mi contenedor macvlan, pero el host Docker no?

Porque macvlan típicamente impide que la interfaz padre hable directamente con sus hijos macvlan. El tráfico de otros hosts llega por el cable y se entrega en la interfaz hija.
El tráfico originado por el host no se hairpinea de la misma forma.

2) ¿Es esto un bug de Docker?

No. Docker usa el driver macvlan del kernel. El comportamiento es una propiedad conocida del networking macvlan en Linux.
Trátalo como diseño del kernel de red, no como una regresión de la aplicación.

3) ¿Cuál es la solución más limpia si necesito comunicación host↔contenedor?

Crea una interfaz macvlan en el host atada al mismo parent, asígnale una IP y enruta las IPs de los contenedores por esa interfaz.
Esto hace al host un par de primera clase en ese segmento L2.

4) ¿Debería enrutar una subred completa al shim o usar rutas /32 por contenedor?

Enruta un prefijo dedicado si puedes. Es operacionalmente sensato: menos rutas, menos sorpresas y más fácil de documentar.
Las rutas /32 están bien para pocos contenedores o soluciones tácticas.

5) ¿Ipvlan evitaría este problema?

A menudo sí—especialmente ipvlan L3. Ipvlan cambia el modelo: menos identidad L2, más enrutamiento L3 explícito.
También puede reducir la proliferación de MACs y evitar problemas de port-security.

6) ¿Necesito modo promiscuo?

En metal desnudo, normalmente no. En entornos virtualizados, a menudo sí—porque el hipervisor/vSwitch puede descartar tramas para MACs “desconocidas”.
Si macvlan funciona en un host físico pero falla en una VM, sospecha filtrado MAC del hipervisor inmediatamente.

7) Mi switch tiene port-security. ¿Puedo usar macvlan?

Quizás, pero necesitas conocer el límite de MAC en ese puerto y cómo macvlan cambia el conteo de MACs.
Si no puedes subir el límite o conseguir una excepción, considera ipvlan o bridge networking.

8) ¿Cómo hago que los contenedores alcancen al host?

Haz que los contenedores hablen con el host a través de la IP shim macvlan del host, no a la IP del parent.
Alternativamente, separa la gestión del host y las redes de servicio de contenedores con VLANs y enruta entre ellas.

9) ¿Macvlan es seguro para servicios de almacenamiento stateful?

Puede serlo, pero no confundas “tiene su propia IP” con “está aislado”. Aún compartes la misma NIC física, colas y camino upstream.
Para servicios de almacenamiento, haz explícitos los dominios de fallo: NICs/VLANs dedicadas, MTU predecible y failover probado.

10) ¿Macvlan rompe multicast o descubrimiento broadcast?

Macvlan en sí no “rompe” automáticamente el broadcast en la LAN, pero tu entorno podría: límites de VLAN, IGMP snooping o controles de seguridad pueden cambiar el comportamiento.
Si tu app depende de descubrimiento L2, pruébala con switches reales, no solo con un portátil y optimismo.

Conclusión: próximos pasos prácticos

Si estás atascado en “no puedo alcanzar el contenedor”, deja de mirar logs de Docker. Esto casi siempre es ruteo y semántica L2.
Confirma el síntoma asimétrico (LAN funciona, host falla). Luego implementa la solución que se adapte a tus restricciones:
un shim macvlan en el host con rutas explícitas, o cambiar a ipvlan si la proliferación de MAC te va a despertar por la noche.

Pasos que puedes hacer hoy:

  • Decide un rango IP dedicado para contenedores y reserva una IP shim.
  • Implementa la interfaz shim y una ruta que cubra realmente tus IPs de contenedores.
  • Persiste la configuración para que los reinicios no resuciten el problema.
  • Escribe un runbook de dos minutos: “Si el host no alcanza el contenedor, verifica la ruta al rango vía macvlan0.”
]]>
https://cr0x.net/es/docker-macvlan-trampa-ruteo-host/feed/ 0
Ubuntu 24.04: UFW + Docker — asegurar contenedores sin romper Compose (caso #40) https://cr0x.net/es/ubuntu-ufw-docker-asegurar-contenedores/ https://cr0x.net/es/ubuntu-ufw-docker-asegurar-contenedores/#respond Sun, 12 Oct 2025 00:48:33 +0000 https://cr0x.net/2026/01/04/ubuntu-ufw-docker-asegurar-contenedores/ Activaste UFW. Denegaste todo el tráfico entrante. Te sentiste responsable. Entonces ejecutaste docker compose up
y—sorpresa—tu contenedor es accesible desde Internet de todas formas. No “quizá”. No “solo desde la LAN”.
Accesible. Desde. Todas partes.

Esta es una de esas realidades del networking en Linux que se repiten porque las opciones por defecto están optimizadas para “funciona”
más que para “es seguro”. La buena noticia: puedes asegurarlo en Ubuntu 24.04 sin romper Docker Compose,
sin reemplazar UFW y sin convertir tu host en un experimento de firewall casero.

El modelo mental: por qué UFW y Docker chocan

UFW es una interfaz. Escribe reglas en el firewall del kernel (en Ubuntu 24.04 eso suele ser nftables debajo,
pero muchas herramientas aún hablan la “semántica de iptables”). Docker también es una interfaz. Escribe reglas de firewall para que
la red de contenedores “simplemente funcione”: NAT para salidas, publicación de puertos para entradas e
aislamiento entre bridges.

El conflicto ocurre porque Docker inserta reglas en lugares que UFW no controla, y en prioridades que vencen tu
postura de alto nivel de “denegar entrantes”. Cuando publicas un puerto (-p 8080:80 o en Compose ports:),
Docker programa reglas DNAT y de filtrado para que los paquetes sean reenviados al contenedor. Esos paquetes pueden nunca llegar a la
regla que creías que los bloquearía. UFW dice “denegar”; Docker dice “prometí que ese puerto funcionaría”; Docker gana.

La conclusión práctica: no “arreglas” esto añadiendo más sentencias UFW de permitir y denegar. Lo arreglas
controlando la ruta específica que Docker usa para el tráfico reenviado. Eso significa: entender la ruta de FORWARD,
entender las cadenas de Docker (especialmente DOCKER-USER) y decidir qué debe ser accesible desde dónde.

Una verdad seca: el firewall no es “entrante vs saliente”. Es dirección del tráfico más decisiones de enrutamiento. Los contenedores
no son procesos locales; están detrás de un router virtual. Así que tu política de “entrantes” no siempre se aplica
al tráfico que se reenvía hacia ellos.

Una cita para mantenerte honesto, desde el mundo de la fiabilidad: “La esperanza no es una estrategia.” — Gene Kranz.

Broma #1: La red de Docker es como una tarjeta de acceso de hotel: conveniente hasta que descubres que también abre la puerta de “solo personal”.

Hechos y contexto histórico para usar en discusiones

  • UFW se remonta a la era de iptables y aún piensa en esos términos, incluso cuando nftables es el backend.
  • Docker históricamente dependió de iptables para implementar NAT y puertos publicados; esa suposición de diseño persiste entre distribuciones.
  • Netfilter de Linux tiene múltiples hooks (PREROUTING, INPUT, FORWARD, OUTPUT, POSTROUTING). “Denegar entrantes” de UFW suele apuntar a INPUT, no a FORWARD.
  • Docker publica puertos con DNAT; los paquetes dirigidos a la IP del host pueden reescribirse a la IP del contenedor antes de que la cadena INPUT de UFW sea relevante.
  • La cadena DOCKER-USER existe específicamente para que los operadores puedan insertar reglas de filtrado que se apliquen antes de las reglas de aceptación propias de Docker.
  • Las políticas por defecto de UFW cambiaron con los años, pero el patrón clásico sigue: denegar por defecto en INPUT, permitir established/related, gestionar permisos específicos.
  • Ubuntu se movió hacia nftables como backend preferido; los comandos “iptables” pueden ser envoltorios (iptables-nft) que generan reglas nft.
  • El “iptables=false” de Docker no es gratis; desactivar la gestión de reglas por parte de Docker rompe comportamientos de red comunes a menos que los reemplaces tú mismo.
  • El tráfico entre contenedores en el mismo bridge no es “salida”; es L2/L3 local dentro del host y puede eludir la intención ingenua del firewall a menos que lo filtres.

Guía rápida de diagnóstico

Quieres la ruta más rápida desde “el puerto está expuesto” hasta “sé exactamente qué cadena lo permitió”. Aquí está el orden que
encuentra el cuello de botella rápidamente.

1) Confirma qué está realmente expuesto (no confíes en los archivos Compose)

  • Comprueba los puertos publicados desde la vista de Docker (docker ps).
  • Comprueba los sockets en escucha en el host (ss -ltnp).
  • Haz pruebas desde un sistema remoto o un segundo namespace de red si tienes uno.

2) Identifica la ruta del paquete

  • Si es un puerto publicado, probablemente golpea DNAT en nat PREROUTING y luego filter FORWARD.
  • Si es networking de host (network_mode: host), pasa por filter INPUT como cualquier demonio.

3) Inspecciona la regla que gana

  • Comprueba DOCKER-USER primero (tu punto de control).
  • Luego revisa las cadenas propias de Docker (DOCKER, DOCKER-FORWARD).
  • Después verifica la política de reenvío de UFW y si UFW siquiera ve el paquete.

4) Arregla con el cambio más pequeño que haga verdadera la postura de seguridad

  • Si solo necesitas “alcanzable desde LAN”, filtra por subred de origen en DOCKER-USER.
  • Si solo necesitas “alcanzable desde un contenedor reverse proxy”, deja de publicar el puerto de la app y usa redes internas.
  • Si necesitas “alcanzable solo en localhost”, enlaza los puertos publicados a 127.0.0.1.

Estado objetivo: qué significa realmente “asegurado”

“Contenedores asegurados” es vago. En la práctica, elige uno de estos estados objetivos sensatos e implémentalo explícitamente:

  1. Por defecto: nada está publicado. Los contenedores se comunican por redes privadas de Compose. Solo un reverse proxy
    (o un servicio gateway único) publica 80/443.
  2. Publicación selectiva. Un puñado de puertos está publicado, pero solo a redes origen específicas
    (VPN corporativa, rangos de oficina) y nunca a Internet entero a menos que el servicio deba ser público.
  3. Patrón localhost-only para desarrollo en hosts de producción (sí, a veces hace falta): publica en 127.0.0.1 y
    requiere túnel SSH, VPN o acceso en el propio host.
  4. Aislamiento estricto entre bridges de Docker. El tráfico entre contenedores se permite solo donde tú lo declares.

Lo que debes evitar: “denegar entrantes por defecto” en UFW y luego dejar que Docker publique puertos libremente. Esa es una contradicción
de política con un resultado predecible.

Tareas prácticas (comandos, salidas, decisiones)

Estas son las tareas que realmente ejecuto en Ubuntu 24.04 cuando diagnostico o endurezo UFW + Docker. Cada una incluye
el comando, lo que suele significar la salida y la decisión que tomas.

Task 1: Verificar estado de UFW y políticas por defecto

cr0x@server:~$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

Significado: “routed” controla el reenvío (FORWARD). Si está deshabilitado, UFW puede no estar vigilando el
tráfico reenviado como supones.

Decisión: Si los contenedores están expuestos, debes abordar el comportamiento de reenvío (típicamente vía DOCKER-USER),
no solo reglas INPUT.

Task 2: Confirmar que Docker gestiona iptables/nft

cr0x@server:~$ sudo docker info --format '{{json .SecurityOptions}}'
["name=apparmor","name=seccomp,profile=builtin","name=cgroupns"]

Significado: Esto no muestra iptables directamente, pero confirma un entorno Docker normal. Lo siguiente es revisar
la configuración del daemon.

Decisión: Inspecciona /etc/docker/daemon.json antes de asumir algo sobre el comportamiento del firewall de Docker.

Task 3: Comprobar la opción iptables del daemon de Docker

cr0x@server:~$ sudo cat /etc/docker/daemon.json
{
  "iptables": true,
  "ip-forward": true
}

Significado: Docker programará reglas de firewall. Esa es la situación común/por defecto.

Decisión: Mantenerlo. Desactivarlo es como reinventar NAT a las 2 a.m.

Task 4: Listar puertos publicados según Docker

cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Ports}}'
NAMES          PORTS
web            0.0.0.0:8080->80/tcp
db             5432/tcp
prometheus     127.0.0.1:9090->9090/tcp

Significado: 0.0.0.0:8080 es alcanzable desde todo el mundo (sujeto al firewall). 127.0.0.1:9090
es solo local.

Decisión: Si un servicio no necesita ingreso público, elimina el puerto publicado o enlázalo a localhost.

Task 5: Verificar sockets en escucha en el host (chequeo de realidad)

cr0x@server:~$ sudo ss -ltnp | head -n 12
State  Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
LISTEN 0      4096   0.0.0.0:8080       0.0.0.0:*     users:(("docker-proxy",pid=2214,fd=4))
LISTEN 0      4096   127.0.0.1:9090     0.0.0.0:*     users:(("docker-proxy",pid=2311,fd=4))
LISTEN 0      4096   0.0.0.0:22         0.0.0.0:*     users:(("sshd",pid=1042,fd=3))

Significado: Docker-proxy (o NAT del kernel) acepta conexiones en el host. Si está ligado a 0.0.0.0,
tu firewall debe ser correcto o estás expuesto.

Decisión: Trata las ligaduras a 0.0.0.0 como “públicas hasta que se demuestre lo contrario”.

Task 6: Identificar qué backend de firewall estás usando

cr0x@server:~$ sudo update-alternatives --display iptables | sed -n '1,12p'
iptables - auto mode
  link best version is /usr/sbin/iptables-nft
  link currently points to /usr/sbin/iptables-nft
  link iptables is /usr/sbin/iptables
  slave iptables-restore is /usr/sbin/iptables-restore
  slave iptables-save is /usr/sbin/iptables-save

Significado: Estás usando iptables-nft en modo compatibilidad, lo cual está bien. Lo importante es la consistencia.

Decisión: Evita mezclar reglas nft crudas que compitan con iptables-nft a menos que controles completamente el conjunto de reglas.

Task 7: Inspeccionar la cadena DOCKER-USER (tu gancho de política)

cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -j RETURN

Significado: No se aplican restricciones antes de las reglas de aceptación de Docker.

Decisión: Aquí es donde añades “denegar por defecto, permitir lo necesario” para puertos publicados.

Task 8: Inspeccionar la política FORWARD y cadenas de reenvío de Docker

cr0x@server:~$ sudo iptables -S FORWARD
-P FORWARD DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD

Significado: DROP por defecto es bueno, pero el salto a DOCKER-FORWARD significa que Docker aún puede permitir flujos específicos.

Decisión: Haz cumplir tus restricciones en DOCKER-USER, antes de que DOCKER-FORWARD acepte tráfico.

Task 9: Listar reglas NAT creadas por Docker para puertos publicados

cr0x@server:~$ sudo iptables -t nat -S DOCKER | head -n 20
-N DOCKER
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.18.0.3:80
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 9090 -j DNAT --to-destination 172.18.0.5:9090

Significado: DNAT reescribe el tráfico dirigido al puerto 8080 del host hacia el contenedor. Por eso las reglas INPUT
no lo son todo.

Decisión: Si quieres bloquear acceso público, bloquéalo en filter FORWARD/DOCKER-USER según IP de origen.

Task 10: Añadir una línea base “denegar por defecto para puertos publicados” en DOCKER-USER

cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
cr0x@server:~$ sudo iptables -I DOCKER-USER 2 -i lo -j ACCEPT
cr0x@server:~$ sudo iptables -I DOCKER-USER 3 -s 10.0.0.0/8 -j ACCEPT
cr0x@server:~$ sudo iptables -I DOCKER-USER 4 -s 192.168.0.0/16 -j ACCEPT
cr0x@server:~$ sudo iptables -A DOCKER-USER -j DROP
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-USER -i lo -j ACCEPT
-A DOCKER-USER -s 10.0.0.0/8 -j ACCEPT
-A DOCKER-USER -s 192.168.0.0/16 -j ACCEPT
-A DOCKER-USER -j RETURN
-A DOCKER-USER -j DROP

Significado: La salida de ejemplo muestra una trampa común: Docker (o reglas previas) puede ya incluir RETURN.
Si RETURN aparece antes de DROP, tu drop no se ejecutará.

Decisión: Asegura que DROP esté antes de cualquier RETURN incondicional, o reemplaza el contenido de la cadena limpiamente.
En la práctica, quieres: permitir established, permitir fuentes confiables, luego dropear, luego retornar (o simplemente drop).

Task 11: Reescribir DOCKER-USER limpiamente para evitar sorpresas de orden de reglas

cr0x@server:~$ sudo iptables -F DOCKER-USER
cr0x@server:~$ sudo iptables -A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
cr0x@server:~$ sudo iptables -A DOCKER-USER -s 10.10.0.0/16 -j ACCEPT
cr0x@server:~$ sudo iptables -A DOCKER-USER -s 192.168.50.0/24 -j ACCEPT
cr0x@server:~$ sudo iptables -A DOCKER-USER -j DROP
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-USER -s 10.10.0.0/16 -j ACCEPT
-A DOCKER-USER -s 192.168.50.0/24 -j ACCEPT
-A DOCKER-USER -j DROP

Significado: Determinista: si no provienes de las subredes permitidas y no estás establecido, eres dropeado antes de que Docker acepte nada.

Decisión: Usa esto como base en hosts que no deben publicar servicios arbitrarios a Internet público.

Task 12: Verificar que UFW no esté permitiendo tráfico enrutado en silencio

cr0x@server:~$ sudo grep -nE 'DEFAULT_FORWARD_POLICY|IPV6' /etc/default/ufw
7:DEFAULT_FORWARD_POLICY="DROP"
18:IPV6=yes

Significado: Forward por defecto es DROP. Bien. IPv6 está habilitado; si lo ignoras, puedes “asegurar” IPv4 y aún filtrar por IPv6.

Decisión: Si usas IPv6 (probablemente sí, aunque finjas que no), duplica la política para ip6tables/nft.

Task 13: Revisar las reglas “before” de UFW para interacción con Docker

cr0x@server:~$ sudo sed -n '1,140p' /etc/ufw/before.rules | sed -n '1,40p'
#
# rules.before
#
# Rules that should be run before the ufw command line added rules. Custom
# rules should be added to one of these chains:
#   ufw-before-input
#   ufw-before-output
#   ufw-before-forward
#

Significado: UFW espera que añadas políticas de la ruta de reenvío en ufw-before-forward si hace falta.

Decisión: Prefiere DOCKER-USER para restricciones específicas de Docker. Usa before-forward de UFW para política de enrutamiento más amplia.

Task 14: Confirmar redes de Docker e interfaces bridge

cr0x@server:~$ docker network ls
NETWORK ID     NAME              DRIVER    SCOPE
a1b2c3d4e5f6   bridge            bridge    local
b2c3d4e5f6a1   myapp_default     bridge    local
c3d4e5f6a1b2   host              host      local
d4e5f6a1b2c3   none              null      local

Significado: Cada bridge definido por el usuario (como myapp_default) puede tener su propia interfaz y comportamiento de aislamiento.

Decisión: Si intentas restringir tráfico este-oeste, debes pensar por bridge, no solo en docker0.

Task 15: Mapear un puerto publicado a una IP de contenedor específica (para reglas precisas)

cr0x@server:~$ docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' web
172.18.0.3

Significado: Puedes escribir una regla en DOCKER-USER que permita solo tráfico a este contenedor/puerto (útil para excepciones).

Decisión: Prefiere políticas basadas en subred; las IP por contenedor cambian. Usa IPs estáticas solo cuando realmente las necesites.

Task 16: Probar desde una fuente no confiable y observar contadores

cr0x@server:~$ sudo iptables -L DOCKER-USER -v -n
Chain DOCKER-USER (1 references)
 pkts bytes target     prot opt in     out     source               destination
   40  2400 ACCEPT     all  --  *      *       10.10.0.0/16         0.0.0.0/0
   12   720 DROP       all  --  *      *       0.0.0.0/0            0.0.0.0/0

Significado: Los contadores se incrementan. Tus reglas realmente ven tráfico. Esa es la mejor sensación que puedes tener con firewalls.

Decisión: Si los contadores no se mueven, estás filtrando la cadena/hook equivocado (común cuando se usa network_mode: host).

Task 17: Persistir reglas entre reinicios (no confíes en la memoria)

cr0x@server:~$ sudo apt-get update
Hit:1 http://archive.ubuntu.com/ubuntu noble InRelease
Reading package lists... Done
cr0x@server:~$ sudo apt-get install -y iptables-persistent
Reading package lists... Done
Building dependency tree... Done
Suggested packages:
  firewalld
The following NEW packages will be installed:
  iptables-persistent netfilter-persistent
Setting up iptables-persistent ...
Saving current rules to /etc/iptables/rules.v4...
Saving current rules to /etc/iptables/rules.v6...

Significado: Tus reglas actuales v4/v6 se guardan y restauran al arrancar.

Decisión: Si usas reglas DOCKER-USER, persístelas. De lo contrario el próximo reinicio “arregla” tu firewall volviendo a inseguro.

Task 18: Validar exposición IPv6 (la trampa silenciosa)

cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Ports}}' | sed -n '1,5p'
NAMES          PORTS
web            :::8080->80/tcp
prometheus     127.0.0.1:9090->9090/tcp

Significado: :::8080 es IPv6-any. Si aseguras solo IPv4, todavía eres alcanzable por IPv6.

Decisión: Replica la política DOCKER-USER en ip6tables (o asegúrate de que nft cubra ambas familias).

Broma #2: IPv6 es como esa llave de repuesto bajo el felpudo—todos se olvidan de ella hasta que la persona equivocada la encuentra.

Patrones de Compose que no sabotean tu firewall

1) No publiques lo que no necesitas

La regla de firewall más limpia es la que no necesitas porque el puerto no está expuesto en primer lugar. Compose
facilita publicar todo durante el desarrollo y luego olvidarte de ello.

Prefiere expose: (interno) sobre ports: (publicado). Sí, “expose” es mayormente documentación más comportamiento de red
dentro de Docker, pero la idea clave es: los servicios internos deben ser alcanzables solo por contenedores pares.

2) Enlaza puertos del host a localhost para UIs de administración

Si necesitas Grafana/Prometheus/paneles de administración en un servidor, enlaza a 127.0.0.1 y accede mediante túnel SSH o VPN.
Esto evita jugar al gatito con excepciones del firewall.

En Compose:
ports: ["127.0.0.1:9090:9090"].
Tan simple. No es “seguridad por oscuridad”; es negarse a enrutar el tráfico en absoluto.

3) Usa un reverse proxy como único borde publicado

El mejor patrón de producción: publica 80/443 para un contenedor reverse proxy (o demonio host), y mantén todo lo demás
en redes internas de Docker. Entonces tus reglas de firewall pueden ser aburridas.

Añade una red “internal: true” para backends. Eso le dice a Docker que no proporcione conectividad externa a través de esa red.
No resolverá todos los casos, pero te empuja hacia una topología sensata.

4) Evita network_mode: host a menos que lo necesites

El networking de host evita gran parte de la red virtual de Docker y hace que el contenedor se comporte como un proceso del host.
Eso cambia qué hooks de firewall aplican. También hace que las colisiones de puertos sean divertidas.

Úsalo para agentes de monitorización sensibles al rendimiento o herramientas de red especializadas cuando tengas razón. De lo contrario,
es un atajo que se convierte en responsabilidad durante respuesta a incidentes.

5) Declara redes separadas para “borde público” y “backend privado”

Redes separadas te dan separación razonable. Una red conectada al proxy y la app,
otra conectada solo a backends. También puedes restringir el enrutamiento entre redes a nivel de firewall si hace falta.

La cadena DOCKER-USER: tu punto de apalancamiento

La propia documentación de Docker introdujo DOCKER-USER porque los operadores necesitaban un punto de inserción estable que Docker no reescribiera.
La cadena es saltada desde FORWARD temprano (según la versión), y es el lugar correcto para hacer cumplir “qué orígenes pueden
alcanzar los puertos publicados de contenedores”.

Piénsalo como la “capa de política” sobre la “capa de plomería” de Docker. Docker monta la plomería para que los paquetes lleguen a los contenedores.
Tú defines la política para que solo los paquetes que quieras realmente lleguen.

Qué poner en DOCKER-USER

  • Permitir established/related primero (el tráfico de retorno debe funcionar).
  • Permitir desde subredes de origen confiables (VPN, oficina, rango de bastión).
  • Opcionalmente permitir servicios públicos específicos (p. ej., 80/443 solo al proxy).
  • Dropear todo lo demás que se reenvíe a las redes de Docker.

Qué no poner en DOCKER-USER

  • Reglas por IP de contenedor salvo que hayas fijado IPs y asumido la deuda operativa.
  • Reglas que asuman que solo existe docker0 (Compose crea múltiples bridges).
  • Reglas que dropeen sin permitir established primero (romperás rutas de retorno salientes).

Patrones de integración de UFW que funcionan en Ubuntu 24.04

Hay dos estrategias generales que no terminan mal:

  1. Mantén UFW para servicios del host; aplica el ingreso a contenedores en DOCKER-USER. Esta es mi recomendación por defecto.
    UFW sigue siendo la herramienta amigable para el operador para SSH, node exporters, etc. DOCKER-USER se convierte en el “perímetro de contenedores”.
  2. Empuja más política hacia la cadena de reenvío de UFW (ufw-before-forward y similares). Esto puede funcionar, pero ahora debugueas interacciones entre cadenas gestionadas por UFW y por Docker. Es factible; simplemente no es el camino más rápido.

Mi postura productiva y opinada

Mantén denegar entrantes por defecto en UFW. Permite SSH solo desde fuentes confiables (VPN o bastión). Publica solo 80/443 públicamente.
Todo lo demás o:

  • no está publicado en absoluto (redes internas de Docker), o
  • está publicado a 127.0.0.1, o
  • se permite solo desde subredes privadas vía DOCKER-USER.

IPv6: decide y luego aplica

En Ubuntu 24.04, IPv6 no es un caso marginal. Si tu servidor tiene AAAA, es alcanzable. Si Docker publica en ::,
necesitas una estrategia v6. Esa estrategia puede ser “deshabilitar IPv6 en todas partes” (severo, a veces correcto) o “filtrarlo correctamente”
(más común en entornos modernos).

Tres microhistorias corporativas desde el terreno

Incidente causado por una asunción errónea: “denegar entrantes significa denegar entrantes”

Una empresa SaaS mediana movió un puñado de herramientas internas a un clúster de VM nuevas con Ubuntu 24.04. El equipo de plataforma
tenía un estándar: UFW habilitado, denegar entrantes por defecto, permitir SSH desde la VPN. Desplegaron con Docker Compose un
dashboard interno, una base de datos y una pila de métricas. Parecía ordenado.

La asunción errónea fue silenciosa y clásica: asumieron que “denegar entrantes” de UFW bloquearía cualquier cosa alcanzable vía la
IP del host, incluidos puertos de contenedores. Durante un escaneo externo rutinario (ni siquiera un pentest dedicado), alguien vio
que la página de login del dashboard era alcanzable en un puerto alto. No debía ser pública. Tampoco estaba parcheada recientemente.

La primera respuesta fue “pero UFW está activo; no puede estar expuesto”. La segunda fue mirar docker ps y
ver 0.0.0.0:PORT->CONTAINER. La tercera fue incómoda: habían construido una postura de seguridad a partir de la abstracción de UI en vez de la realidad del flujo de paquetes.

Arreglarlo no fue heroico. Dejaron de publicar el puerto del dashboard, lo pusieron detrás del reverse proxy existente,
y aplicaron una allowlist en DOCKER-USER para los pocos servicios admin que realmente necesitaban acceso directo desde la subnet de la VPN.
La lección que quedó: “entrante” y “reenviado” no son lo mismo, y Docker vive en el terreno reenviado.

Optimización que salió mal: deshabilitar la gestión de iptables de Docker

Otra organización tuvo una revisión de seguridad que no gustó de “aplicaciones que modifican reglas de firewall”. Es un instinto razonable.
El equipo decidió poner "iptables": false en la configuración del daemon de Docker y gestionar todo solo vía UFW.
Lo hicieron en staging, vieron contenedores arrancar y lo llamaron victoria.

El primer problema fue sutil: la conectividad saliente de los contenedores se volvió intermitente de formas difíciles de correlacionar.
Algunas imágenes se descargaban lentamente, algunos webhooks fallaban por timeout, y DNS fallaba intermitentemente según el nodo.
No era “caído”; era “raro”. Lo raro cuesta.

El segundo problema fue operacional: cada proyecto Compose ahora requería NAT y reglas de reenvío personalizadas. Los desarrolladores
no sabían qué puertos se enrutan a dónde porque ya no estaba expresado en el Compose. Las reglas del firewall se convirtieron en conocimiento tribal. Los cambios tardaban más y la respuesta a incidentes se enlenteció.

Finalmente revirtieron el cambio. Docker volvió a gestionar iptables/nft. El equipo de seguridad obtuvo lo que realmente quería—control de política—aplicando restricciones en DOCKER-USER y usando una plantilla estándar para subredes permitidas y puertos de borde públicos. “No dejar que Docker toque iptables” sonaba limpio. En la práctica, sustituyó un mecanismo común por uno a medida. Eso no es seguridad; es deuda con insignia.

Práctica aburrida pero correcta que salvó el día: persistencia de reglas y prueba de reinicio

Una empresa regulada ejecutaba ventanas de mantenimiento trimestrales donde los hosts se reiniciaban, se actualizaban kernels y se aplicaban los cambios habituales.
Un equipo tenía la costumbre: después de cualquier cambio de firewall, (1) persistir reglas, (2) reiniciar un nodo canario, y (3) verificar exposición desde fuera de la subnet. Aburrido. Repetitivo. Molestamente correcto.

Durante una ventana, actualizaron Docker y refrescaron políticas UFW. Todo parecía bien—hasta que el canario reinició.
Su comprobación externa mostró un puerto de servicio abierto que debía ser solo VPN. El equipo no entró en pánico; siguió su checklist y notó que la cadena DOCKER-USER estaba presente pero vacía tras el reinicio. Las reglas no se habían persistido.

Como lo detectaron en un canario, el impacto fue pequeño: reinstalaron la herramienta de persistencia, guardaron reglas v4 y v6,
y validaron de nuevo. El resto de la flota siguió con la línea base corregida. Sin incidente. Sin correo a clientes.
Sin reunión de “cómo pasó esto en la revisión”.

La moraleja no es glamurosa: si no pruebas un reinicio, no tienes una configuración. Tienes un humor.

Errores comunes: síntomas → causa raíz → solución

1) “UFW está habilitado pero el puerto del contenedor sigue siendo alcanzable”

Síntoma: Clientes remotos alcanzan host:8080 incluso con denegar entrantes por defecto.

Causa raíz: El tráfico se DNATea y reenvía; la política INPUT de UFW no aplica. Las reglas de Docker lo aceptan.

Solución: Añade allowlist + drop en DOCKER-USER, o deja de publicar el puerto.

2) “Añadí DOCKER-USER DROP pero no cambió nada”

Síntoma: Los contadores no se mueven; el puerto sigue abierto.

Causa raíz: Estás usando network_mode: host, o tu regla DROP está después de un RETURN incondicional, o estás filtrando solo IPv4.

Solución: Revisa iptables -S DOCKER-USER por orden de reglas; verifica network_mode: host; replica reglas en ip6tables.

3) “Tras reinicio, todo vuelve a exponerse”

Síntoma: La política funciona hasta un reinicio.

Causa raíz: Las reglas DOCKER-USER no se persistieron; solo cambió el estado en tiempo de ejecución.

Solución: Usa iptables-persistent o una unidad systemd que restaure reglas antes de que Docker arranque.

4) “Solo algunos usuarios pueden conectar; otros hacen timeout”

Síntoma: Usuarios de VPN funcionan, usuarios de oficina no (o viceversa).

Causa raíz: La allowlist por origen no incluye todas las subredes reales del cliente; el NAT hace que la fuente parezca distinta.

Solución: Valida la IP de origen del cliente en el servidor (tcpdump/conntrack), luego extiende la allowlist deliberadamente.

5) “El tráfico entre contenedores está bloqueado inesperadamente”

Síntoma: La app no alcanza la BD aunque ambas estén en el mismo proyecto Compose.

Causa raíz: DROP demasiado amplio en DOCKER-USER sin excepciones para tráfico interno del bridge.

Solución: Permite established/related, y si dropeas por defecto, añade permisos explícitos para subredes o interfaces internas antes del drop.

6) “IPv4 está asegurado, pero los escáneres aún ven puertos abiertos”

Síntoma: Un escaneo externo muestra puertos abiertos a pesar de reglas IPv4.

Causa raíz: Exposición IPv6 (:::) con política de la familia ip6tables/nft faltante.

Solución: Implementa política equivalente para IPv6, o deshabilita IPv6 intencionalmente (host + Docker) y verifica.

7) “Las actualizaciones de Docker Compose rompen mi firewall”

Síntoma: Tras compose up, las reglas cambian y el acceso varía.

Causa raíz: Tus restricciones están en cadenas gestionadas por Docker en vez de DOCKER-USER, o confías en nombres de interfaz que cambian.

Solución: Mantén la política en DOCKER-USER y usa criterios de coincidencia estables (subredes de origen, puertos de destino, estado conntrack).

Listas de verificación / plan paso a paso

Plan A (recomendado): publicar solo puertos de borde, restringir todo lo demás

  1. Inventario de puertos expuestos: ejecuta docker ps y ss -ltnp; lista todo lo ligado a 0.0.0.0 o :::.
  2. Eliminar publicaciones accidentales: elimina ports: de servicios internos; usa redes internas.
  3. Enlazar herramientas admin a localhost: usa 127.0.0.1:PORT:PORT en Compose cuando corresponda.
  4. Decidir rangos de origen confiables: subredes VPN, subredes de oficina, IPs de bastión. Escríbelos.
  5. Hacer cumplir en DOCKER-USER: permitir established/related, permitir rangos confiables, dropear el resto.
  6. Replicar para IPv6: añade reglas equivalentes en ip6tables o asegura que nft cubra ambas familias.
  7. Persistir reglas: instala la herramienta de persistencia y valida un reinicio.
  8. Probar externamente: valida desde una red no confiable y desde una confiable.
  9. Monitorear contadores: revisa contadores de DOCKER-USER durante pruebas; confirma que tus reglas están realmente en la ruta.

Plan B: política centrada en UFW (solo si disfrutas trazar cadenas)

  1. Configura la política de reenvío de UFW a DROP (comúnmente ya es así) y asegura el filtrado enrutado según desees.
  2. Añade permisos explícitos de reenvío para bridges de Docker y servicios publicados en las reglas before-forward de UFW.
  3. Verifica que Docker no inserte reglas de aceptación que evadan tu intención (probablemente volverás a DOCKER-USER).

Plan C: “Nada se publica nunca” (para plataformas internas)

  1. Aplica una comprobación en CI que rechace Compose files con ports: salvo aprobación.
  2. Requiere ingreso mediante una capa reverse proxy estandarizada y descubrimiento de servicios interno.
  3. Dropear tráfico reenviado a bridges de Docker desde orígenes no confiables universalmente.

Preguntas frecuentes

1) ¿Por qué UFW no bloquea por defecto los puertos publicados por Docker?

Porque el tráfico de contenedores publicado típicamente se reenvía después de NAT, y la postura “denegar entrantes” de UFW gobierna mayormente
INPUT. Docker instala reglas de reenvío/NAT para garantizar que la publicación de puertos funcione.

2) ¿Debo deshabilitar la gestión de iptables de Docker?

No, no como primer movimiento de seguridad. Sustituir un mecanismo estándar y conocido por reglas NAT y de reenvío manuales que ahora posees para siempre no es ideal. Usa DOCKER-USER para imponer política mientras Docker mantiene la plomería funcionando.

3) ¿Cuál es la mejor solución única que no rompe Compose?

Añadir reglas de allowlist + drop en DOCKER-USER, luego eliminar ports: innecesarios de Compose. Esto preserva la
red de Docker mientras previene la “exposición sorpresa a Internet”.

4) ¿Las reglas DOCKER-USER sobreviven reinicios de Docker?

Normalmente sobreviven reinicios del daemon de Docker porque la cadena está pensada para la política del usuario, pero no necesariamente
sobreviven un reinicio del host a menos que las persistas. Persiste las reglas explícitamente.

5) ¿Cómo restrinjo un puerto publicado a solo la LAN?

Permite la(s) subred(es) LAN en DOCKER-USER y dropea todo lo demás. Alternativamente, enlaza el puerto publicado a una IP de interfaz que exista solo en la LAN, pero filtrar por origen suele ser más claro.

6) ¿Qué pasa con contenedores que deben ser públicos (como una app web)?

Publica solo el reverse proxy (80/443) públicamente. Mantén las apps sin publicar en redes internas. Si debes publicar la app directamente, permite solo esos puertos desde 0.0.0.0/0 y mantiene todo lo demás dropeado.

7) ¿Cambia algo para contenedores con network-mode host?

Sí. Los contenedores en host-network se comportan como procesos del host; el tráfico llega a INPUT, no a FORWARD. Para esos, las reglas de UFW son el punto de control correcto, no DOCKER-USER.

8) ¿Cómo sé si IPv6 está exponiendo mis contenedores?

Busca ::: en el listado de puertos de docker ps o en ss -ltnp. Luego prueba desde un host con IPv6 y confirma que tu política en ip6tables/nft coincida con la intención en IPv4.

9) ¿Puedo hacer todo esto puramente en nftables?

Puedes, pero si Docker usa la compatibilidad iptables-nft, querrás evitar gestión de reglas conflictivas. El enfoque pragmático en Ubuntu es: mantener el comportamiento iptables-nft de Docker y aplicar política en DOCKER-USER (y su equivalente v6).

10) ¿Cuál es la forma más limpia de evitar reglas de firewall por contenedor?

No publiques puertos para servicios internos. Usa redes internas de Docker más un proxy de borde. Entonces tu firewall será mayormente
“permitir 80/443, permitir SSH desde VPN, dropear el resto”, con DOCKER-USER asegurando que los contenedores no lo eludan.

Conclusión: próximos pasos que perduran

La forma fiable de asegurar Docker en Ubuntu 24.04 no es luchar contra la red de Docker. Deja que Docker haga la plomería. Tú haces
la política. Pon tu política donde realmente importa: en la ruta de reenvío, antes de las aceptaciones de Docker, vía DOCKER-USER.

Si quieres una hora práctica:

  1. Ejecuta las tareas de inventario: docker ps, ss -ltnp y revisa vinculaciones IPv6.
  2. Elimina ports: accidentales en Compose; reemplázalos con redes internas o binds a localhost.
  3. Implementa una allowlist DOCKER-USER para rangos de origen confiables y luego un drop por defecto.
  4. Persiste reglas v4 y v6 y verifica con un reinicio en un host canario.
  5. Escribe tu modelo de exposición deseado (borde público vs solo VPN vs interno) para que el próximo ingeniero no reintroduzca la “exposición sorpresa a Internet”.

Terminarás con algo raro en el mundo de contenedores: una postura de firewall que coincide con lo que crees que desplegaste. Eso no es solo seguridad. Es cordura operativa.

]]>
https://cr0x.net/es/ubuntu-ufw-docker-asegurar-contenedores/feed/ 0