Problema

En entornos Docker Compose donde un contenedor actúa como gateway entre dos redes (por ejemplo, una red privada y una pública), es habitual usar iptables con la regla MASQUERADE para que el tráfico saliente parezca provenir de la interfaz externa del gateway. Cuando la regla no se aplica, los paquetes siguen mostrando la IP interna del contenedor origen, lo que provoca fallos de conectividad y respuestas rechazadas por los servidores remotos. El síntoma típico es observar, con tcpdump, la IP del contenedor interno en la capa IP del tráfico que sale por la interfaz pública del gateway.

Este comportamiento no es exclusivo de un caso concreto; aparece en cualquier arquitectura que combine:

  • varios bridges definidos en docker-compose.yml,
  • un contenedor con network_mode: "service:<gateway>",
  • una regla iptables -t nat -A POSTROUTING -o <iface> -j MASQUERADE.

El problema se manifiesta tanto en entornos de desarrollo como en despliegues de producción ligera (homelab, CI/CD pipelines, etc.).

Causa

Varios factores pueden impedir que la regla MASQUERADE surta efecto:

  1. Interfaz equivocada
    Docker renombra las interfaces dentro del contenedor (eth0, eth1, …). Si la regla usa -o eth0 pero el tráfico sale por eth1, la coincidencia falla y el paquete no se traduce.

  2. Falta de net.ipv4.ip_forward
    El kernel necesita el flag ip_forward=1 para reenviar paquetes entre interfaces. En contenedores sin privilegios o sin la sysctl adecuada, el reenvío se bloquea antes de que iptables actúe.

  3. Privilegios insuficientes
    iptables requiere CAP_NET_ADMIN. Ejecutar el contenedor sin --cap-add=NET_ADMIN (o sin privileged: true) impide crear o modificar reglas.

  4. Reglas de Docker que sobrescriben la cadena
    Docker inserta sus propias reglas en DOCKER, DOCKER-USER y POSTROUTING. Si la regla MASQUERADE se coloca antes de la regla -j MASQUERADE de Docker, puede ser anulada o nunca alcanzada.

  5. Orden de inserción
    En la tabla nat, las reglas se evalúan secuencialmente. Una regla anterior que haga -j RETURN o que limite el rango de origen puede impedir que la regla MASQUERADE se vea.

  6. Falta del paquete iptables
    Algunas imágenes minimalistas (Alpine, BusyBox) no incluyen iptables por defecto. Intentar ejecutar la regla sin el binario disponible genera un error silencioso si el script no verifica el código de salida.

  7. Uso de network_mode: "service:<gateway>" sin exponer la interfaz externa
    Cuando el contenedor cliente comparte la pila de red del gateway, ambos comparten la misma tabla de rutas. Si la interfaz externa no está conectada al bridge público, no hay salida real para la regla.

  8. Política de firewall del host
    El host puede tener ufw o firewalld que bloquean el tráfico NAT antes de que llegue al contenedor, creando la ilusión de que la regla no funciona.

Solución

Una solución robusta debe abordar cada punto anterior y quedar reutilizable para cualquier composición que requiera NAT. El flujo recomendado es:

1. Preparar el contenedor gateway

  • Usa una imagen que incluya iptables o instálalo en tiempo de arranque.
  • Concede NET_ADMIN (o privileged: true si el entorno lo permite).
  • Habilita el reenvío IP mediante sysctl.

Ejemplo de fragmento docker-compose.yml:

services:
  nat-gateway:
    image: alpine:3.23.5
    privileged: true               # o cap_add: ["NET_ADMIN"]
    sysctls:
      - net.ipv4.ip_forward=1
    volumes:
      - ./nat-init.sh:/usr/local/bin/nat-init.sh:ro
    command: /usr/local/bin/nat-init.sh
    networks:
      - private-vpc
      - public-vpc
  api:
    image: alpine:3.23.5
    command: sleep infinity
    network_mode: "service:nat-gateway"
    depends_on:
      - nat-gateway
    networks:
      - private-vpc

networks:
  private-vpc:
    internal: true
  public-vpc:
    driver: bridge

2. Detectar la interfaz externa

Dentro del script de inicialización, determina cuál es la interfaz conectada al bridge público. En la mayoría de los contenedores será eth0 o eth1. Un método rápido:

#!/bin/sh
set -e

# Instalar iptables si la imagen es mínima
apk add --no-cache iptables

# Detectar la interfaz que tiene una ruta predeterminada
EXTERNAL_IF=$(ip route show default | awk '{print $5}')

echo "Interfaz externa detectada: $EXTERNAL_IF"

# Aplicar la regla MASQUERADE solo si no existe ya
if ! iptables -t nat -C POSTROUTING -s 172.19.0.0/16 -o "$EXTERNAL_IF" -j MASQUERADE 2>/dev/null; then
  iptables -t nat -A POSTROUTING -s 172.19.0.0/16 -o "$EXTERNAL_IF" -j MASQUERADE
  echo "Regla MASQUERADE añadida"
else
  echo "Regla MASQUERADE ya presente"
fi

exec sleep infinity

Puntos clave del script:

  • Detección automática de la interfaz evita errores de coincidencia.
  • Rango de origen (-s 172.19.0.0/16) limita la regla a la subred privada del proyecto, evitando colisiones con otras redes del host.
  • Comprobación de existencia (iptables -C) previene duplicados que pueden saturar la tabla.

3. Ordenar la regla después de las de Docker

Docker inserta sus reglas en la cadena POSTROUTING bajo el nombre MASQUERADE. Para asegurarse de que la regla del gateway se evalúe después, añádela a la cadena POSTROUTING después de la regla -j MASQUERADE de Docker, o inserta la regla en DOCKER-USER que se procesa antes de cualquier regla de Docker:

iptables -t nat -I DOCKER-USER 1 -s 172.19.0.0/16 -o "$EXTERNAL_IF" -j MASQUERADE

4. Persistir la configuración (opcional)

Los contenedores se reinician frecuentemente. Para que la regla sobreviva a un reinicio del contenedor, guarda el estado en un archivo y recárgalo al iniciar:

iptables-save > /etc/iptables.rules
# En el arranque:
iptables-restore < /etc/iptables.rules

5. Verificar que el host no bloquee la NAT

Revisa que el host no tenga reglas que descarten el tráfico NAT:

iptables -L INPUT -n -v
iptables -L FORWARD -n -v

Si encuentras una política DROP en FORWARD, añade una excepción:

iptables -I FORWARD -i "$EXTERNAL_IF" -o eth0 -j ACCEPT
iptables -I FORWARD -i eth0 -o "$EXTERNAL_IF" -j ACCEPT

Cuándo aplicar esta solución

Utiliza este enfoque cuando:

  • Necesites un contenedor que actúe como router/NAT entre dos bridges de Docker.
  • Quieras exponer servicios internos sin usar puertos publicados individualmente.
  • La topología incluya múltiples subredes privadas y una única salida a Internet.
  • No puedas o no quieras depender de la opción --publish de Docker para cada servicio.

No es necesario si:

  • Solo necesitas publicar puertos concretos mediante ports: en docker-compose.yml.
  • La arquitectura ya usa host network o macvlan para exponer directamente los contenedores.
  • El host gestiona NAT a nivel de red y los contenedores se quedan en modo bridge sin requerir reescritura de IP.

Código

#!/bin/sh
set -e

# Instalar iptables en Alpine minimalista
apk add --no-cache iptables

# Detectar la interfaz con ruta por defecto
EXTERNAL_IF=$(ip route show default | awk '{print $5}')
echo "Interfaz externa detectada: $EXTERNAL_IF"

# Subred privada del proyecto (ajustar según sea necesario)
PRIVATE_SUBNET="172.19.0.0/16"

# Insertar regla en DOCKER-USER para que se ejecute antes de las reglas