El informe del incidente siempre empieza igual: “No se hicieron cambios en la red.” Luego miras el contenedor y está conectado a tres redes,
una de ellas algo pública, y responde felizmente a solicitudes en un puerto que nadie recuerda haber publicado.
Los contenedores con múltiples redes son normales en producción: frontends que se enlazan a backends, agentes que alcanzan tanto un plano de control como uno de datos, monitoreo que abarca entornos. También son una forma perfecta de filtrar tráfico al lugar equivocado si no tratas la red de Docker como un verdadero sistema de enrutamiento y firewall, porque eso es.
Qué falla realmente con los contenedores multi-red
Un contenedor conectado a varias redes Docker está, de hecho, multi-homed. Tiene múltiples interfaces, múltiples rutas y, a veces, múltiples vistas de DNS.
Docker también instalará reglas NAT y de filtrado en el host que deciden qué puede alcanzar a qué. Eso está bien—hasta que asumes mal sobre las redes “internas”,
las rutas por defecto, la publicación de puertos o cómo Compose “aísla” servicios.
La exposición accidental suele ocurrir de una de cuatro maneras:
- Puertos publicados en el host y el host es accesible desde redes que no consideraste (Wi‑Fi, LAN corporativa, VPN, emparejamiento de VPC en la nube, etc.).
-
Bridgeo de redes por diseño: un contenedor conectado tanto a “frontend” como a “backend” se convierte en un punto de pivote. Si el servicio se enlaza a 0.0.0.0,
escucha en todas las interfaces del contenedor. Eso incluye la interfaz “equivocada”. -
Enrutamiento inesperado: la “ruta por defecto” dentro del contenedor apunta a través de la red que Docker decidió que es primaria. Puede no ser
la que pretendías para el egress. -
Deriva del firewall: las reglas iptables/nft de Docker cambiaron, fueron deshabilitadas, fueron parcialmente sobrescritas por herramientas de seguridad del host, o fueron “optimizadas”
por alguien que no le gusta la complejidad (y que además disfruta de los bridges de incidentes).
Aquí está la verdad incómoda: en configuraciones multi-red, “funciona” y “es seguro” son ortogonales. Si no controlas explícitamente enlaces, rutas y políticas,
obtendrás el comportamiento que surge de los valores por defecto. Los valores por defecto no son un modelo de amenaza.
Hechos y contexto que deberías conocer
- La red original de Docker fue un bridge único (docker0) con NAT; las “redes definidas por el usuario” llegaron después para arreglar peculiaridades de DNS/descubrimiento de servicios y aislamiento.
- La comunicación entre contenedores solía ser una sola bandera del demonio (
--icc) que afectaba al bridge por defecto; las redes definidas por el usuario cambiaron el juego. - La publicación de puertos precede a los flujos modernos de Compose y fue diseñada para la comodidad del desarrollador; la seguridad en producción es algo que agregas, no algo que garantice por sí sola.
- Históricamente Docker gestionaba iptables directamente; en sistemas que migraron a nftables, la capa de traducción puede crear sorpresas en el orden de las reglas y en la depuración.
- El networking overlay (Swarm) introdujo opciones VXLAN cifradas, pero el cifrado solo resuelve el sniffing, no la exposición por puertos mal publicados o attachments incorrectos.
- Macvlan/ipvlan se añadieron para satisfacer demandas de “red real”; también eluden las suposiciones cómodas que la gente tiene sobre el aislamiento del bridge de Docker.
- Las redes “internal” en Docker bloquean el enrutamiento externo pero no impiden el acceso de otros contenedores adjuntos a esa red, y no sanitizan los puertos publicados.
- Rootless Docker cambia la tubería; obtienes networking en modo usuario tipo slirp4netns y características diferentes de rendimiento y firewall.
- Los valores por defecto de Compose son convenientes, no defensivos; la red por defecto no es “segura”, es simplemente “está ahí”.
Una cita que vale la pena tener en el monitor, porque explica la mayoría de los outages y las trampas de seguridad al mismo tiempo:
La esperanza no es una estrategia.
— idea parafraseada a menudo atribuida a prácticas de confiabilidad/operaciones
Un modelo mental: Docker te está construyendo routers y firewalls
Deja de pensar en las redes de Docker como “etiquetas”. Son constructos concretos L2/L3 con primitivas de Linux debajo: bridges, pares veth, namespaces, rutas,
estado conntrack, NAT y cadenas de filtrado. Cuando conectas un contenedor a varias redes, Docker crea múltiples interfaces veth en el netns del contenedor,
y típicamente instala rutas para que una de esas redes se convierta en la puerta de enlace por defecto.
Necesitas razonar sobre tres “planos” diferentes:
- Plano de contenedor: qué interfaces existen dentro del contenedor, qué IPs, qué rutas, a qué direcciones se enlazan los servicios.
- Plano del host: reglas iptables/nftables, configuraciones de bridge, rp_filter, forwarding y cualquier otra herramienta de seguridad del host.
- Plano de la red upstream: la alcanzabilidad del host (IP pública, VPNs, LAN corporativa, tablas de enrutamiento de la nube, security groups).
Si algún plano está mal modelado, obtienes “era interno” seguido de una lección pública de humildad.
Broma #1: El networking de Docker es como el Wi‑Fi de oficina—alguien siempre piensa que es “privado”, y luego la impresora les demuestra lo contrario.
Las rutas comunes de exposición (y por qué sorprenden)
1) Los puertos publicados se enlazan más ampliamente de lo que piensas
-p 8080:8080 publica en todas las interfaces del host por defecto. Si el host es accesible por VPN, una VPC emparejada o una subred corporativa, también lo publicaste allí. Publicar no es “local”, es “a todo el host”. El contenedor puede estar conectado a diez redes; la publicación no lo distingue.
La solución es simple y aburrida: enlaza a una IP específica del host cuando publiques, y trata 0.0.0.0 como un olor a problema en producción.
2) Las redes “internal” no son un campo de fuerza
La bandera de red --internal de Docker evita que los contenedores en esa red lleguen al mundo externo mediante el comportamiento de puerta de enlace por defecto de Docker.
No impide que otros contenedores en la misma red los alcancen, y no protege mágicamente los puertos publicados en el host.
3) El contenedor multi-homed escucha en la interfaz equivocada
Los servicios que se enlazan a 0.0.0.0 dentro del contenedor escuchan en todas las interfaces del contenedor. Si adjuntas el contenedor tanto a
frontend como a backend, puede ser accesible desde ambas redes a menos que enlaces explícitamente o pongas un firewall a nivel de contenedor/host.
4) DNS y descubrimiento de servicios apuntan a la IP “equivocada”
El DNS integrado de Docker devuelve registros A dependiendo del alcance de red del contenedor que consulta. En escenarios multi-red puedes acabar con un nombre de servicio
resolviendo hacia una IP en una red que no pretendías usar para ese tráfico. Esto parece timeouts intermitentes, porque a veces tocas la ruta “buena” y otras la “bloqueada”.
5) Selección de ruta y sorpresa de puerta de enlace por defecto
La primera red adjuntada tiende a convertirse en la ruta por defecto. Luego alguien adjunta una red de “monitoreo” después, o Compose adjunta redes en un orden distinto al que esperabas, y de repente el egress sale por el camino equivocado. Esto puede romper supuestos de ACL y hacer que los logs aparezcan con IPs fuente inesperadas.
6) Las herramientas de firewall del host colisionan con las cadenas de Docker
Docker inyecta reglas en iptables. Las herramientas de endurecimiento del host también inyectan reglas. Los equipos de “seguridad” a veces añaden un agente que “gestiona la política de firewall” y
no entiende las necesidades de Docker, así que borra o reordena reglas. Entonces la conectividad se rompe—o peor, la conectividad equivocada funciona.
Tareas prácticas: auditar, probar y decidir (comandos + salidas)
No aseguras el networking de Docker por intuición. Lo aseguras respondiendo repetidamente “¿qué es alcanzable desde dónde?” con evidencia.
A continuación hay tareas prácticas que puedes ejecutar en un host Linux con Docker. Cada una incluye: el comando, qué significa la salida y la decisión que debes tomar.
Task 1: Listar contenedores y sus puertos publicados
cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
NAMES IMAGE PORTS
api myco/api:1.9.2 0.0.0.0:8080->8080/tcp
postgres postgres:16 5432/tcp
nginx nginx:1.25 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
Significado: Cualquier entrada que muestre 0.0.0.0:PORT está expuesta en todas las interfaces del host. Entradas como 5432/tcp sin un mapeo al host no están publicadas; solo son accesibles en redes Docker (a menos que se use network host).
Decisión: Para cada mapeo 0.0.0.0, decide si debe ser accesible desde todas las redes a las que toca el host. Si no, re-enlaza a una IP específica o elimina la publicación.
Task 2: Ver a qué redes está conectado un contenedor
cr0x@server:~$ docker inspect api --format '{{json .NetworkSettings.Networks}}'
{"frontend":{"IPAddress":"172.20.0.10"},"backend":{"IPAddress":"172.21.0.10"}}
Significado: El contenedor está en dos redes. Si el servicio interno se enlaza a 0.0.0.0, escucha en ambas interfaces.
Decisión: Si este contenedor solo necesita aceptar tráfico en una red, o bien desconéctalo de la otra red o enlaza el servicio a la IP de la interfaz prevista.
Task 3: Inspeccionar una red Docker para alcance y endpoints adjuntos
cr0x@server:~$ docker network inspect backend --format '{{.Name}} internal={{.Internal}} driver={{.Driver}} subnet={{(index .IPAM.Config 0).Subnet}}'
backend internal=false driver=bridge subnet=172.21.0.0/16
Significado: Esta es una red bridge definida por el usuario, no internal, con una subred privada enrutable.
Decisión: Si los servicios backend nunca deben hacer egress a Internet, considera recrear esta red con --internal y proporcionar explícitamente cualquier egress necesario vía proxies.
Task 4: Encontrar el PID del contenedor y entrar en su namespace de red
cr0x@server:~$ docker inspect -f '{{.State.Pid}}' api
23147
cr0x@server:~$ sudo nsenter -t 23147 -n ip addr
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
42: eth0@if43: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
inet 172.20.0.10/16 brd 172.20.255.255 scope global eth0
44: eth1@if45: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
inet 172.21.0.10/16 brd 172.21.255.255 scope global eth1
Significado: Dos interfaces, dos subredes. Tu servicio puede ser alcanzable desde ambas.
Decisión: Si solo una interfaz debe aceptar conexiones entrantes, configura la aplicación para enlazarse específicamente (por ejemplo listen 172.20.0.10) o añade reglas de firewall.
Task 5: Comprobar las rutas del contenedor (¿quién es la puerta de enlace por defecto?)
cr0x@server:~$ sudo nsenter -t 23147 -n ip route
default via 172.20.0.1 dev eth0
172.20.0.0/16 dev eth0 proto kernel scope link src 172.20.0.10
172.21.0.0/16 dev eth1 proto kernel scope link src 172.21.0.10
Significado: El egress sale por frontend (eth0). Si esperabas egress solo por backend, ya estás equivocado.
Decisión: Decide qué red debe ser la ruta por defecto. Si debe ser otra, ajusta cómo se conecta el contenedor (orden de adjunto), o usa enrutamiento por políticas dentro del contenedor (avanzado, frágil).
Task 6: Verificar en qué dirección/puerto escucha el proceso realmente
cr0x@server:~$ sudo nsenter -t 23147 -n ss -lntp
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:(("api",pid=1,fd=7))
Significado: El servicio escucha en todas las interfaces. En modo multi-red, eso normalmente no es lo que quieres.
Decisión: Cambia el servicio para que se enlace a una IP específica del contenedor, o protege la interfaz no deseada con firewall. No “confíes” en que nadie pueda alcanzar la red backend.
Task 7: Confirmar qué IPs del host están realmente escuchando (puertos publicados)
cr0x@server:~$ sudo ss -lntp | grep -E '(:80 |:443 |:8080 )'
LISTEN 0 4096 0.0.0.0:80 0.0.0.0:* users:(("docker-proxy",pid=1943,fd=4))
LISTEN 0 4096 0.0.0.0:443 0.0.0.0:* users:(("docker-proxy",pid=1951,fd=4))
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=2022,fd=4))
Significado: Existen listeners a nivel de host. Incluso si “solo querías” acceso interno, el host ahora participa.
Decisión: Si la exposición debe estar limitada, publica como -p 127.0.0.1:8080:8080 o en una dirección LAN/VIP específica. Luego coloca un proxy real delante.
Task 8: Inspeccionar reglas NAT y de filtrado que Docker instaló
cr0x@server:~$ sudo iptables -t nat -S | sed -n '1,120p'
-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.20.0.0/16 ! -o docker0 -j MASQUERADE
-A POSTROUTING -s 172.21.0.0/16 ! -o br-acde1234 -j MASQUERADE
-A DOCKER ! -i br-acde1234 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.20.0.10:8080
Significado: DNAT reenvía host:8080 al contenedor. Existen reglas MASQUERADE para ambas subredes.
Decisión: Si dependes de la política de firewall, verifica que esté implementada en la cadena correcta (a menudo DOCKER-USER) y que coincida con tu intención para cada puerto publicado.
Task 9: Comprobar la cadena DOCKER-USER (donde deberías poner tu política)
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -j RETURN
Significado: Sin política. Todo lo que Docker permite está permitido.
Decisión: Añade reglas explícitas de permitir/denegar aquí para restringir quién puede alcanzar puertos publicados (subredes fuente, interfaces). Hazlo antes de que los incidentes te obliguen a hacerlo bajo presión.
Task 10: Confirmar conectividad inter-contenedor desde la red “equivocada”
cr0x@server:~$ docker exec -it postgres bash -lc 'nc -vz 172.21.0.10 8080; echo exit_code=$?'
Connection to 172.21.0.10 8080 port [tcp/*] succeeded!
exit_code=0
Significado: Un contenedor backend-only puede alcanzar la API en la red backend. Eso puede ser correcto—o puede ser que tu plano de datos ahora toque tu plano de control.
Decisión: Decide si la red backend debe poder iniciar tráfico hacia ese servicio. Si no, hazlo cumplir con reglas de firewall o separando responsabilidades en servicios distintos.
Task 11: Validar que las respuestas DNS difieran entre redes (el sutil)
cr0x@server:~$ docker exec -it nginx sh -lc 'getent hosts api'
172.20.0.10 api
cr0x@server:~$ docker exec -it postgres sh -lc 'getent hosts api'
172.21.0.10 api
Significado: Mismo nombre, IP diferente, dependiendo de la red del llamador. Esto es comportamiento esperado—y una causa común de “funciona desde X pero no desde Y”.
Decisión: Si un servicio debe ser alcanzado solo por una red, no lo adjuntes a la otra red. El “scope” de DNS no es una barrera de seguridad; es una característica de conveniencia.
Task 12: Mostrar el orden de redes y la red “primaria” en Compose
cr0x@server:~$ docker inspect api --format '{{.Name}} {{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
/api frontend backend
Significado: El orden de adjunto es visible, pero no siempre estable entre ediciones si dejas que Compose genere redes automáticamente y refactorizas.
Decisión: En Compose, sé explícito sobre redes y considera fijar explícitamente qué red se usa como ruta por defecto (controlando el orden de adjunto y minimizando el multi-homing).
Task 13: Detectar contenedores que accidentalmente usan network host
cr0x@server:~$ docker inspect -f '{{.Name}} network_mode={{.HostConfig.NetworkMode}}' $(docker ps -q)
/api network_mode=default
/postgres network_mode=default
/node-exporter network_mode=host
Significado: Un contenedor evita el aislamiento de red de Docker y comparte la pila del host. A veces es necesario, a menudo imprudente.
Decisión: Si un contenedor usa network_mode=host, trátalo como un proceso del host. Audita sus direcciones de escucha y reglas de firewall como harías con cualquier demonio.
Task 14: Verificar si Docker está gestionando iptables (o no)
cr0x@server:~$ docker info | grep -i iptables
iptables: true
Significado: Docker está gestionando reglas iptables. Si esto dice false (o faltan reglas), los puertos publicados y la conectividad se comportan de forma diferente y a menudo peligrosa.
Decisión: Si tu entorno deshabilita la gestión de iptables de Docker, debes implementar la política equivalente tú mismo. No ejecutes “iptables: false” a la ligera a menos que te guste depurar hoyos negros.
Task 15: Rastrear un puerto publicado desde el host hasta el contenedor
cr0x@server:~$ sudo conntrack -L -p tcp --dport 8080 2>/dev/null | head
tcp 6 431999 ESTABLISHED src=10.10.5.22 dst=10.10.5.10 sport=51432 dport=8080 src=172.20.0.10 dst=10.10.5.22 sport=8080 dport=51432 [ASSURED] mark=0 use=1
Significado: Puedes ver una conexión real siendo NATeada hacia la IP del contenedor. Esto confirma la ruta del tráfico y las direcciones fuente.
Decisión: Usa esto para validar si las solicitudes vienen de lugares que esperabas. Si las fuentes son “sorprendentes”, corrige la exposición en el binding de publicación o en el firewall.
Task 16: Identificar qué bridges existen y a qué subredes corresponden
cr0x@server:~$ ip -br link | grep -E 'docker0|br-'
docker0 UP 0a:58:0a:f4:00:01 <BROADCAST,MULTICAST,UP,LOWER_UP>
br-acde1234 UP 02:42:8f:11:aa:01 <BROADCAST,MULTICAST,UP,LOWER_UP>
br_bf001122 UP 02:42:6a:77:bb:01 <BROADCAST,MULTICAST,UP,LOWER_UP>
cr0x@server:~$ ip -4 addr show br-acde1234 | sed -n '1,8p'
12: br-acde1234: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
inet 172.21.0.1/16 brd 172.21.255.255 scope global br-acde1234
Significado: Cada bridge definido por el usuario corresponde a un dispositivo bridge de Linux con una IP gateway en el host.
Decisión: Si estás auditando exposición, estas IPs gateway y subredes importan para el firewall y para entender cómo sale el tráfico del namespace del contenedor.
Guía de diagnóstico rápido
Cuando algo “filtra”, “no puede conectar” o “conecta desde el lugar equivocado”, necesitas una secuencia rápida que converge. Esta es esa secuencia.
Trátala como un runbook de on-call: primero/segundo/tercero, sin divagar.
Primero: Demostrar si el puerto está expuesto en el host
- Ejecuta
docker psy busca mapeos0.0.0.0:. - Ejecuta
ss -lntpen el host y confirma que existe un listener (docker-proxy o camino NAT del kernel).
Si está publicado: asume que todas las redes a las que toca el host pueden accederlo hasta que se demuestre lo contrario. Tu historia de “interno” ya es sospechosa.
Segundo: Identificar las redes del contenedor, IPs y ruta por defecto
docker inspect CONTAINERpara redes e IPs.nsenter ... ip routepara ver qué red es la puerta de enlace por defecto.nsenter ... ss -lntppara ver si el servicio escucha en 0.0.0.0 o en una IP específica.
Si escucha en 0.0.0.0: es alcanzable desde todas las redes adjuntas a menos que algo lo bloquee.
Tercero: Verificar la política en el único lugar donde puedes aplicarla con fiabilidad
- Comprueba
iptables -S DOCKER-USER(o su equivalente en nft) para permitir/denegar explícito. - Confirma que Docker gestiona iptables (
docker info). - Prueba desde un contenedor en la red “equivocada” usando
ncocurl.
Si DOCKER-USER está vacío: no tienes una política; tienes esperanza.
Cuarto: Si el comportamiento es inconsistente, sospecha del DNS y nombres de servicio con múltiples IPs
- Ejecuta
getent hosts servicedesde los llamadores en diferentes redes. - Busca respuestas distintas que lleven a resultados ACL diferentes.
Tres mini-historias corporativas desde el terreno
Mini-historia 1: El incidente causado por una suposición equivocada
Una empresa mediana ejecutaba una API de administración interna en Docker. “Interno” significaba “en una red backend en Compose”, así que el equipo se sentía seguro. También publicaron el
puerto en el host por conveniencia, porque algunos scripts de ops corrían desde el host y nadie quería unirse a la red del contenedor solo para usar localhost.
Durante un cambio de red, el host se unió a una subred corporativa más amplia como parte de una consolidación de VPN. Nada en el archivo de Compose cambió. El puerto
seguía publicado en 0.0.0.0. De repente, una herramienta de inventario de otro equipo descubrió la API durante un escaneo rutinario. No fue malicioso.
Fue el tipo de escaneo curioso que ocurre en redes grandes cuando la gente intenta inventariar cosas.
La API administrativa requería autenticación, pero también tenía un endpoint de depuración que devolvía información de versión, metadatos de build y un mapa de nombres internos. Eso fue suficiente para que una campaña de ingeniería social fuera mucho más fácil después. El “incidente” inicial no fue robo de datos; fue divulgación accidental que amplió el radio de impacto de errores futuros.
La causa raíz del postmortem fue aburrida: el equipo equiparó “no en la red frontend de Docker” con “no accesible”. Nunca modelaron el host como un sistema en red. La publicación es una preocupación
del host, no del contenedor. La solución fue igualmente aburrida: publicar solo en 127.0.0.1 y poner un proxy autenticado delante, además de reglas DOCKER-USER para restringir cualquier puerto publicado restante por subred fuente.
Mini-historia 2: La optimización que salió mal
Otra organización tenía problemas de rendimiento con un servicio que hablaba con una base de datos a través de un bridge de Docker. Alguien sugirió macvlan para “networking casi nativo”
y menor overhead. Crearon una red macvlan conectada a la VLAN de producción, dieron a los contenedores IPs de primera clase y celebraron una pequeña ganancia de latencia.
A todos les gusta ahorrar milisegundos. Es la versión adulta de coleccionar pegatinas.
Luego vino el contratiempo: el servicio seguía conectado a una red bridge interna para descubrimiento de servicios y para alcanzar a un sidecar que solo vivía ahí. Ahora el
contenedor tenía dos identidades de red: una en la VLAN de producción y otra en el bridge interno. El servicio se enlazó a 0.0.0.0 como de costumbre. Así que escuchaba en ambas.
Las ACL de la base de datos asumían que solo ciertas subredes podían hablar con ella, pero el servicio ahora podía iniciar conexiones desde un nuevo rango de IPs fuente. Parte del tráfico empezó a tomar un camino distinto al esperado porque la ruta por defecto ahora era vía macvlan. La observabilidad se volvió extraña rápidamente.
El verdadero problema no fue macvlan. Fue la suposición de que “añadir una red más rápida” no cambia la exposición o la identidad. Lo hace. Cambia las IPs fuente, el enrutamiento y qué interfaces reciben tráfico. El incidente que tuvieron no fue “hackeo”, fue “fallos de autenticación misteriosos y comportamiento inconsistente del firewall”, que es como muchos problemas de seguridad se anuncian antes de convertirse en problemas de seguridad reales.
La solución final: dejar de multi-homed ese servicio. Movieron la funcionalidad del sidecar también a la VLAN de producción, o alternativamente movieron la app a una sola red bridge y aceptaron el impacto de rendimiento. La segunda solución fue de gobernanza: las nuevas redes requerían una breve revisión que incluyera “en qué interfaces se enlazará el servicio y cuál será la ruta por defecto después del cambio”.
Mini-historia 3: La práctica aburrida pero correcta que salvó el día
Una plataforma de servicios financieros tenía una regla: cada host tenía una política base DOCKER-USER, y cada puerto publicado debía justificarse con un alcance de origen.
Esto no era sexy. Era una casilla en infraestructura como código. Los ingenieros se quejaban, en silencio, de la manera en que se quejan los ingenieros cuando se les impide hacer algo imprudente rápido.
Un viernes, un equipo desplegó un contenedor de troubleshooting con una UI web rápida. Alguien añadió -p 0.0.0.0:9000:9000 porque necesitaba acceder desde su portátil. Se olvidaron de quitarlo. El contenedor también se adjuntó a una segunda red para alcanzar servicios internos. Era la receta exacta para exposición accidental.
Pero la política base bloqueó inbound a puertos publicados a menos que la fuente proviniera de una pequeña subred de jump-host aprobada. Así que la UI web era accesible desde donde debía ser accesible, y no desde todas partes. La semana siguiente, un escaneo de auditoría vio el puerto abierto en el host pero no pudo alcanzar el servicio desde la red general. El ticket de seguridad fue molesto, pero no fue un incidente.
La lección no fue “las auditorías son útiles”. La lección fue que los valores por defecto aburridos vencen a las limpiezas heroicas. Cuando tu postura de seguridad depende de recordar quitar flags temporales, tu postura está “eventualmente comprometida”. El equipo mantuvo la política base y añadió un guardrail en CI para marcar 0.0.0.0 en publicaciones de Compose para revisión.
Patrones de endurecimiento que realmente funcionan
1) Minimiza el multi-homing. Prefiere una red por servicio.
Los contenedores multi-red a veces son necesarios. También multiplican la complejidad. Si tu servicio necesita hablar con dos cosas distintas, considera:
¿se puede alcanzar una de ellas a través de un proxy en una sola red? ¿puedes dividir el servicio en dos componentes—uno por red—con una API estrecha y auditable entre ellos?
Si debes multi-homer: trata al contenedor como un objeto adyacente a un router. Enlaza explícitamente, registra IPs fuente y aplica firewall.
2) Publica puertos en IPs específicas del host, no en 0.0.0.0
El comportamiento por defecto de publicar es una conveniencia para desarrolladores. En producción, sé explícito:
-p 127.0.0.1:...cuando el servicio es solo para acceso local del host detrás de un reverse proxy.-p 10.10.5.10:...cuando el servicio debe enlazarse a una interfaz/VIP específica.
Esta simple elección elimina categorías enteras de incidentes “ahora alcanzable desde la VPN”.
3) Pon el refuerzo en DOCKER-USER (firewall del host), no en la memoria humana
Docker recomienda DOCKER-USER como un lugar estable para la política personalizada porque se evalúa antes de las propias reglas de Docker.
Una política base podría ser: allow established, permitir fuentes específicas a puertos específicos, drop el resto.
Adáptala por entorno; no hagas cargo-cult.
4) Las redes “internal” son para control de egress, no para segmentación inbound
Usa --internal para evitar egress accidental a Internet desde capas sensibles. Pero no la reclames como un límite inbound. Si un contenedor está adjunto,
puede comunicarse. Tu límite es la membresía más la política del firewall más el binding de la aplicación.
5) Enlaza los servicios a la interfaz/IP prevista dentro del contenedor
Si un servicio debe ser alcanzable solo desde la red frontend, enlázalo a la IP o interfaz de esa red. Esto está subvalorado porque es “configuración de la app”,
no “configuración de Docker”, pero es una de las acciones más efectivas que puedes tomar.
6) Trata macvlan/ipvlan como poner contenedores directamente en la LAN (porque eso haces)
Con macvlan, un contenedor se convierte en un par en la red física con su propia MAC e IP. Eso es potente. También es una forma de eludir las suposiciones de perímetro que la gente accidentalmente hacía cuando todo vivía detrás de docker0.
7) Registrar y alertar sobre attachments de red y puertos publicados
La exposición normalmente ocurre por “cambios pequeños”. Así que vigílalos:
- Nuevos puertos publicados
- Contenedores adjuntados a redes adicionales
- Nuevas redes creadas con drivers como macvlan
Broma #2: Si tu modelo de seguridad depende de “nadie jamás hará -p 0.0.0.0”, tengo una red bridge que venderte.
Docker Compose y multi-redes: hábitos seguros por defecto
Compose facilita las adjunciones multi-red. Eso es genial, hasta que las hace demasiado fáciles. Aquí tienes hábitos que te mantienen fuera de problemas.
Sé explícito sobre redes y su propósito
Nombra redes por función, no por equipo. frontend, service-mesh, db, mgmt vencen a net1 y shared.
Si no puedes nombrarla, probablemente no puedas defenderla.
Prefiere “proxy publica, las apps no”
Un patrón común: solo el reverse proxy publica puertos en el host. Todo lo demás está solo en redes internas. Esto reduce el número de puertos publicados a auditar.
No es perfecto, pero reduce de verdad la superficie.
Usa servicios separados en vez de un servicio con dos redes cuando sea posible
Si conectas un servicio tanto a frontend como a db, se convierte en el puente entre zonas. A veces eso es correcto. A menudo es pereza.
Prefiere una app single-homed y un cliente DB o sidecar single-homed si necesitas funcionalidad entre zonas.
Controla los bindings de publicación en Compose
Los puertos de Compose admiten bindings IP. Úsalos. Si no especificas la IP del host, estás pidiendo “todas las interfaces”.
Errores comunes: síntoma → causa raíz → solución
1) Síntoma: “Servicio es interno, pero alguien lo alcanzó desde la VPN”
Causa raíz: Puerto publicado en 0.0.0.0 en un host alcanzable vía VPN/enrutamiento corporativo.
Solución: Enlazar la publicación a 127.0.0.1 o a una IP de interfaz específica; añadir reglas DOCKER-USER que restrinjan fuentes; poner un proxy autenticado delante.
2) Síntoma: “Contenedores backend pueden alcanzar un endpoint admin que no deberían”
Causa raíz: Contenedor multi-homed escucha en 0.0.0.0 dentro del contenedor, exponiendo el servicio en todas las redes adjuntas.
Solución: Enlazar la app a la interfaz/IP prevista; desconectar de redes innecesarias; añadir políticas de red vía firewall del host cuando corresponda.
3) Síntoma: “Timeouts intermitentes entre servicios”
Causa raíz: DNS resuelve el mismo nombre de servicio a IPs distintas según la red del llamador; algunas rutas están bloqueadas o son asimétricas.
Solución: Reducir adjunciones multi-red; usar hostnames explícitos por capa de red; verificar con getent hosts desde cada llamador.
4) Síntoma: “El tráfico de egress aparece desde un nuevo rango de IPs”
Causa raíz: La ruta por defecto dentro del contenedor cambió debido al orden de adjunto de redes; el egress ahora usa otra interfaz (macvlan, nuevo bridge).
Solución: Controlar el orden de adjunto y minimizar redes; forzar egress vía proxy; si es absolutamente necesario, implementar routing por políticas con cuidado y probar en cada reinicio.
5) Síntoma: “Puertos publicados dejaron de funcionar tras endurecimiento del host”
Causa raíz: La herramienta de firewall del host reordenó o vació cadenas gestionadas por Docker; cambios en la política FORWARD rompieron el reenvío NAT.
Solución: Alinear la gestión del firewall del host con Docker (usar DOCKER-USER para política); asegurar que forwarding esté permitido; validar reglas tras actualizaciones de agentes.
6) Síntoma: “Contenedor alcanzable desde redes que no deberían ver subredes Docker”
Causa raíz: Enrutamiento/upstream peering ahora alcanza rangos RFC1918 usados por los bridges de Docker; “privado” no es “aislado”.
Solución: Elegir subredes no superpuestas para redes Docker; restringir inbound en el perímetro de red y en el host; evitar anunciar subredes Docker upstream.
7) Síntoma: “La red interna aún permite acceso inbound”
Causa raíz: Malentendido: --internal bloquea el enrutamiento externo, no el acceso por contenedores adjuntos; los puertos publicados son independientes.
Solución: Combina --internal con membresía estricta de red y reglas de firewall; no publiques servicios que deben ser solo internos.
Listas de verificación / plan paso a paso
Checklist A: Antes de adjuntar un contenedor a una segunda red
- Lista listeners actuales dentro del contenedor (
ss -lntp). Si se enlaza a 0.0.0.0, asume que será alcanzable en la nueva red. - Decide qué red debe ser la ruta por defecto (
ip route) y qué identidad de egress quieres. - Confirma expectativas de DNS: ¿resolverán los clientes el nombre del servicio a la IP correcta en cada red?
- Documenta la razón del multi-homing en el repositorio junto al archivo de Compose. Si no está escrito, no es real.
Checklist B: Al exponer un servicio vía -p / puertos de Compose
- Nunca publiques sin una IP de host explícita en producción a menos que realmente quieras “todas las interfaces”.
- Decide los rangos de origen permitidos e implémentalos en DOCKER-USER.
- Valida con
ss -lntpy una conexión de prueba desde una fuente permitida y otra no permitida. - Registra solicitudes con IPs fuente; lo querrás luego.
Checklist C: Controles base en el host que previenen exposiciones “ups”
- Crea una política base DOCKER-USER que por defecto deniegue puertos publicados excepto fuentes aprobadas.
- Alerta sobre nuevos puertos publicados y nuevos attachments de red (al menos en revisión de cambios).
- Prevén solapamientos de subredes: elige subredes Docker que no colisionen con rangos corporativos/VPN/nube.
- Estandariza un gestor de firewall (iptables-nft vs nft) y prueba el comportamiento de Docker tras actualizaciones del SO.
Plan paso a paso de remediación para una exposición accidental descubierta
- Confirmar la exposición:
docker ps,ssen el host y una prueba remota desde la red sospechada. - Contención inmediata: quitar la publicación o re-enlazar a
127.0.0.1. - Añadir reglas restrictivas en DOCKER-USER si el puerto debe permanecer publicado.
- Corregir la causa raíz: remover redes innecesarias, enlazar el servicio a la interfaz correcta y añadir checks de regresión en CI/revisión.
- Validación post-cambio: probar desde cada zona de red y confirmar que la resolución DNS y las rutas se comportan como se pretendía.
Preguntas frecuentes
1) Si mi contenedor está en una red Docker “internal”, ¿está seguro?
Más seguro para egress, no automáticamente seguro para inbound. Cualquier contenedor en esa red aún puede alcanzarlo. Y los puertos publicados en el host ignoran “internal”.
2) ¿Por qué -p 8080:8080 expone en más lugares de los que esperaba?
Porque se enlaza a todas las interfaces del host por defecto. Tu host está conectado a más redes de las que recuerdas, especialmente con VPNs y enrutamiento en la nube.
3) ¿Puedo confiar en la separación de redes de Docker como una barrera de seguridad?
Trátala como una capa, no como la capa. Los límites reales necesitan política explícita (reglas DOCKER-USER o equivalente), adjunciones de red mínimas y bindings correctos de la app.
4) ¿Cómo evito que un servicio multi-red escuche en la red “equivocada”?
Configura la aplicación para enlazarse a la IP/interfaz específica del contenedor. Si eso no es posible, bloquea el inbound no deseado en el firewall del host o rediseña para evitar el multi-homing.
5) ¿Por qué el mismo nombre de servicio resuelve a IPs diferentes?
El DNS integrado de Docker devuelve respuestas en función de la red del llamador. Esto es conveniente para descubrimiento de servicios y una causa frecuente de conectividad confusa.
6) ¿Macvlan es inherentemente inseguro?
No. Es simplemente honesto. Pone contenedores en la red real, lo que significa que tu postura de seguridad en la red también debe ser real: VLANs, ACLs y auditoría.
7) ¿Dónde debo poner reglas de firewall para que Docker no las sobrescriba?
Usa la cadena DOCKER-USER para setups basados en iptables. Está diseñada para tu política y se evalúa antes de las propias reglas de Docker.
8) ¿Cuál es la forma más rápida de demostrar una exposición accidental?
Comprueba docker ps por 0.0.0.0:PORT, confirma con ss -lntp en el host y luego prueba desde otro segmento de red (o un contenedor en otra red).
9) ¿Rootless Docker cambia el riesgo de exposición?
Cambia la tubería y algunos valores por defecto, pero no elimina la necesidad de controlar puertos publicados y bindings multi-red. Sigues necesitando un modelo y una política.
Conclusión: próximos pasos prácticos
Si ejecutas contenedores multi-red, acepta que estás haciendo networking real. Entonces actúa como tal.
- Audita puertos publicados y elimina bindings
0.0.0.0donde no sean estrictamente necesarios. - Para cualquier contenedor multi-homed, verifica: interfaces, rutas y direcciones de escucha. Corrige los bindings
0.0.0.0que no corresponden. - Implementa una política base DOCKER-USER y hazla parte del aprovisionamiento del host, no un ritual tribal.
- Reduce el multi-homing por diseño: una red por servicio a menos que puedas justificar el radio de impacto.
- Haz que las adjunciones de red y las publicaciones de puertos sean cambios revisables, no improvisaciones de viernes por la noche.
No necesitas seguridad perfecta para evitar la exposición accidental. Necesitas bindings explícitos, políticas explícitas y menos sorpresas. Eso es simplemente buena operación.