Docker: Contenedores en redes independientes y como conectarlos

Introducción

En Docker existen distintos tipos de redes, las cuales se interconectan usando lo se denominan Drivers. Existen distintos tipos de Drivers que podemos utilizar para proveer de funcionalidades de red a nuestros Contenedores:

  • Bridge: es el driver por defecto si no se especifica ninguno a la hora de crear el Contenedor. Está destinado a aplicaciones que corren en Contenedores independientes que precisan comunicarse entre ellas. Cada Contenedor tendrá su propia IP, pudiendo segmentar el direccionamiento en subredes distintas, permitiendo controlar las comunicaciones entre dispositivos y servicios dentro del mismo Docker Host.
Docker Bridge
  • Host: para Contenedores independientes, eliminando el aislamiento de red entre el Contenedor y el Docker Host, usando el mismo direccionamiento en ambos equipos. Es decir, ambos comparten la misma IP.
Docker Host
  • Overlay: conecta múltiples demonios de Docker entre sí y habilita los servicios de Swarm para comunicarse unos entre otros. Es el mejor cuando necesitamos conectar Contenedores ubicados en distintos Docker Hosts, o cuando múltiples aplicaciones trabajan juntas usando servicios Swarm.
Docker Overlay
  • Macvlan: permite asignar una dirección MAC a un Contendor, simulando ser un host físico en la red. Destinado para aplicaciones legacy que precisan estar conectadas físicamente a la red en lugar de a un Docker Host.
  • None: cuando un Contenedor tiene deshabilitadas todas las redes. No disponible para servicios Swarm.
  • Plugin de terceros: para integrar Docker con redes especializadas de terceros.

En esta entrada nos vamos a centrar en cómo crear Contenedores ubicados en redes independientes y aisladas entre sí, y en cómo permitir la comunicación entre ellos, usando redes con Drivers de tipo Bridge.


Supuesto

Supongamos que ofrecemos microservicios con la tecnología Docker desde un mismo Docker Host y precisamos independizar la comunicación entre estos y permitirla entre nodos concretos si fuera preciso. También podemos imaginarnos un entorno en el que ofrecemos servidores Web como microservicios y con una base de datos MySQL unificada para todos ellos y en una red segura sin ningún puerto expuesto hacía Internet. O simplemente disponer de un entorno de preproducción y otro de producción independientes. Existen muchos supuestos posibles para abordar este tema.

El entorno en el que nos vamos a centrar cuenta con:

  • Dos subredes independientes.
  • Dos Contenedores creados a partir de la imagen oficial ubuntu:latest modificada únicamente con el servicio ping instalado.
  • Docker Host corriendo en Debian 9.

Vamos al lío!!!


Redes

Por defecto disponemos de las siguienets redes en una instalación limpia de Docker:

# docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
00195108f37e        bridge              bridge              local
d41425e0dad0        host                host                local
515fa5624551        none                null                local

Crear nuevas redes

Vamos a crear dos subredes indpenedientes, bajo el direccionamiento 192.169.0.0/16 y de tipo Bridge, que llamaremos net_A (192.169.5.0/24) y net_B (192.169.6.0/24):

# docker network create --driver bridge --subnet=192.169.5.0/24 --ip-range=192.169.5.0/24 net_A
e3ea14fdce1f4b59690e753eed767e69fe0206ec855cdc175983d3dc29dfb3cb

# docker network create --driver bridge --subnet=192.169.6.0/24 --ip-range=192.169.6.0/24 net_B
4bc6f7e9bccc7f639ce43bf4ba838dfce6f143e083955cd682f94af1eebb8559

En estos momentos el lisado de redes sería el siguiente:

# docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
00195108f37e        bridge              bridge              local
d41425e0dad0        host                host                local
e3ea14fdce1f        net_A               bridge              local
4bc6f7e9bccc        net_B               bridge              local
515fa5624551        none                null                local

Cada una con su direccionamiento propio, pero aún sin Contenedores asignados:

# docker network inspect net_A
[
    {
        "Name": "net_A",
        "Id": "e3ea14fdce1f4b59690e753eed767e69fe0206ec855cdc175983d3dc29dfb3cb",
        "Created": "2020-04-29T11:40:34.083305502+02:00",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": {},
            "Config": [
                {
                    "Subnet": "192.169.5.0/24",
                    "IPRange": "192.169.5.0/24"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {}
        },
        "Options": {},
        "Labels": {}
    }
]

# docker network inspect net_B
[
    {
        "Name": "net_B",
        "Id": "4bc6f7e9bccc7f639ce43bf4ba838dfce6f143e083955cd682f94af1eebb8559",
        "Created": "2020-04-29T11:40:50.303744508+02:00",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": {},
            "Config": [
                {
                    "Subnet": "192.169.6.0/24",
                    "IPRange": "192.169.6.0/24"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {}
        },
        "Options": {},
        "Labels": {}
    }
]

Incidar que la primera IP de cada rango está destinado al Gateway de cada red, como veremos más adelante cuando creemos los Contenedores.


Contenedores

Creamos dos contenedores a partir de la imagen ubuntu:latest a la que le hemso instalado el servicio Ping, la cual llamamos ubuntu_ping:latest, aunque podemos instalar este en el Contenedor cuando lo creemos. Esta entrada no se basa en explicar como crear imágenes personalizadas, por lo que dejamos a elección del lector investigar para saber cómo se hace o bien instalar el servicio en cada Contenedor una vez arrancado.

Crear nuevos Contenedores

En el comando de creación indicaremos el nombre del Contenedor, ubuntu_A y ubuntu_B, e indicaremos para cada uno una red distinta de las creadas en el apartado anterior, net_A y net_B respectivamente. Estos serán creados en modo interactivo (-it) y en segundo plano (-d):

# docker container run -itd --name ubuntu_A --network net_A  ubuntu_ping
f531122b80e35f1d9f8202c2d7b7df686520788836101026ddb26a20ae87fe3b

# docker container run -itd --name ubuntu_B --network net_B  ubuntu_ping
718c3cf82a4e5bbe956bd2cefa452d95b0dc13eb6a9b1a25efab660b35441941

Estarían ambos arrancados si no se mostró error alguno durante su creación:

# docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                    NAMES
718c3cf82a4e        ubuntu_ping         "bash"                   8 seconds ago       Up 2 seconds                                 ubuntu_B
f531122b80e3        ubuntu_ping         "bash"                   16 seconds ago      Up 14 seconds                                ubuntu_A

Redes asignadas en los Contenedores

Si inspeccionamos ambos Contenedores podemos ver que cada uno está asignado a una red distinta, con su direccionamiento y el de su Gateway correspondiente:

# docker container inspect f531122b80e3
...
            "Networks": {
                "net_A": {
                    "IPAMConfig": null,
                    "Links": null,
                    "Aliases": [
                        "f531122b80e3"
                    ],
                    "NetworkID": "e3ea14fdce1f4b59690e753eed767e69fe0206ec855cdc175983d3dc29dfb3cb",
                    "EndpointID": "732b715c1beff252db77d3e90c38a84ea86e494903eac199b41a486f5e14afa5",
                    "Gateway": "192.169.5.1",
                    "IPAddress": "192.169.5.2",
                    "IPPrefixLen": 24,
                    "IPv6Gateway": "",
                    "GlobalIPv6Address": "",
                    "GlobalIPv6PrefixLen": 0,
                    "MacAddress": "02:42:c0:a9:05:02",
                    "DriverOpts": null
                }
            }

# docker container inspect 718c3cf82a4e
...
            "Networks": {
                "net_B": {
                    "IPAMConfig": null,
                    "Links": null,
                    "Aliases": [
                        "718c3cf82a4e"
                    ],
                    "NetworkID": "4bc6f7e9bccc7f639ce43bf4ba838dfce6f143e083955cd682f94af1eebb8559",
                    "EndpointID": "f522fb6265f2a4bcc360f89b17f5907dcf2d0a5017f31b3b117ef47f688e2ffc",
                    "Gateway": "192.169.6.1",
                    "IPAddress": "192.169.6.2",
                    "IPPrefixLen": 24,
                    "IPv6Gateway": "",
                    "GlobalIPv6Address": "",
                    "GlobalIPv6PrefixLen": 0,
                    "MacAddress": "02:42:c0:a9:06:02",
                    "DriverOpts": null
                }
            }

Tal y como se indicó antes, la primera IP de cada rango está destinada al Gateway de cada red, siendo las IPs sucesivas las que Docker va destinando a los Contenedores que se van agregando a la red. En este caso Docker asignará la siguiente primera IP libre dentro de cada rango, siendo en este caso las IPs 192.169.5.2 y 192.169.6.2 respectivamente.

Si inspeccionamos de nuevo las redes podemos ver como ya tienen Contenedores asignados, en donde se muestra información de cada uno, como la IP asignada:

# docker network inspect net_A
...
        "Containers": {
            "f531122b80e35f1d9f8202c2d7b7df686520788836101026ddb26a20ae87fe3b": {
                "Name": "ubuntu_A",
                "EndpointID": "732b715c1beff252db77d3e90c38a84ea86e494903eac199b41a486f5e14afa5",
                "MacAddress": "02:42:c0:a9:05:02",
                "IPv4Address": "192.169.5.2/24",
                "IPv6Address": ""
            }
        },
...

# docker network inspect net_B
...
        "Containers": {
            "718c3cf82a4e5bbe956bd2cefa452d95b0dc13eb6a9b1a25efab660b35441941": {
                "Name": "ubuntu_B",
                "EndpointID": "f522fb6265f2a4bcc360f89b17f5907dcf2d0a5017f31b3b117ef47f688e2ffc",
                "MacAddress": "02:42:c0:a9:06:02",
                "IPv4Address": "192.169.6.2/24",
                "IPv6Address": ""
            }
        },
...

Conectividad entre Contenedores

Si accedemos a cada Contenedor podemos comprobar la conectividad entre el resto de Contenedor y los Gateways de cada subred.

En ete caso sí podemos hacer ping al Gateway de todas las subredes, pero no a la IP de los Contenedores en redes distintas. Por ejemplo:

  • Accedemos a la consola del Contenedor ubuntu_A:
# docker exec -it f531122b80e3 bash
  • Hacemos ping hacia ambos Gateways creados (192.169.5.1 y 192.169.6.1), el cual funciona en todos los casos:
# ping 192.169.5.1
PING 192.169.5.1 (192.169.5.1) 56(84) bytes of data.
64 bytes from 192.169.5.1: icmp_seq=1 ttl=64 time=0.425 ms
64 bytes from 192.169.5.1: icmp_seq=2 ttl=64 time=0.264 ms
64 bytes from 192.169.5.1: icmp_seq=3 ttl=64 time=0.259 ms
^C
--- 192.169.5.1 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2079ms
rtt min/avg/max/mdev = 0.259/0.316/0.425/0.077 ms
#
# ping 192.169.6.1
PING 192.169.6.1 (192.169.6.1) 56(84) bytes of data.
64 bytes from 192.169.6.1: icmp_seq=1 ttl=64 time=0.332 ms
64 bytes from 192.169.6.1: icmp_seq=2 ttl=64 time=0.259 ms
^C
--- 192.169.6.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1012ms
rtt min/avg/max/mdev = 0.259/0.295/0.332/0.040 ms
  • Hacemos ping a la IP del Contenedor de la subred distinta (192.169.6.2), el cual falla:
root@f531122b80e3:/# ping 192.169.6.2
PING 192.169.6.2 (192.169.6.2) 56(84) bytes of data.
^C
--- 192.169.6.2 ping statistics ---
8 packets transmitted, 0 received, 100% packet loss, time 7262ms

Confirmamos que los Contenedores de ambas subredes no son accesibles entre ellos. A continuación veremos como solucionar este punto, llegado al caso de necesitar conectividad entre ambos Contenedores.

Permitir conectividad entre Contenedores

Para permitir la conectividad entre ambos Contenedores tenemos dos métodos distintos:

Asignar el Contenedor a la red destino

El método recomendado por Docker es asignar el Contenedor a la otra red, de tal forma que este tendría una nueva interfaz de red con una IP en el otro segmento de red, permitiendo conectarse con el resto de Contenedores de esta. Esto genera dos inconveneintes:

  • Conectividad con el resto de Contenedores de la red agregada.
  • Conectividad con todos los puertos de los Contenedores de la red agregada.
  • Disponer de una IP libre en la red que vamos a agregar.

De todas formas, si ninguno de los inconvenientes anterioers fueran un problema podemos proceder a establecer la comunicación usando este método.

Agregamos la nueva red a cada Contenedor:

# docker network connect net_A ubuntu_B

Si inspeccionamos el Contenedor ubuntu_A vemos la nueva red agregada:

# docker container inspect ubuntu_A
...
            "Networks": {
                "net_A": {
                    "IPAMConfig": null,
                    "Links": null,
                    "Aliases": [
                        "f531122b80e3"
                    ],
                    "NetworkID": "e3ea14fdce1f4b59690e753eed767e69fe0206ec855cdc175983d3dc29dfb3cb",
                    "EndpointID": "732b715c1beff252db77d3e90c38a84ea86e494903eac199b41a486f5e14afa5",
                    "Gateway": "192.169.5.1",
                    "IPAddress": "192.169.5.2",
                    "IPPrefixLen": 24,
                    "IPv6Gateway": "",
                    "GlobalIPv6Address": "",
                    "GlobalIPv6PrefixLen": 0,
                    "MacAddress": "02:42:c0:a9:05:02",
                    "DriverOpts": null
                },
                "net_B": {
                    "IPAMConfig": {},
                    "Links": null,
                    "Aliases": [
                        "f531122b80e3"
                    ],
                    "NetworkID": "4bc6f7e9bccc7f639ce43bf4ba838dfce6f143e083955cd682f94af1eebb8559",
                    "EndpointID": "80b793958c42e81789f3308c0cfdf904377bcbb080e2e522b427770837a22225",
                    "Gateway": "192.169.6.1",
                    "IPAddress": "192.169.6.3",
                    "IPPrefixLen": 24,
                    "IPv6Gateway": "",
                    "GlobalIPv6Address": "",
                    "GlobalIPv6PrefixLen": 0,
                    "MacAddress": "02:42:c0:a9:06:03",
                    "DriverOpts": {}
                }
            }
...

Si nos conectamos a un Contenedor vemos la nueva interfaz de red agregada (192.169.6.3):

# docker exec -it f531122b80e3 bash

root@f531122b80e3:/# ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.169.5.2  netmask 255.255.255.0  broadcast 192.169.5.255
        ether 02:42:c0:a9:05:02  txqueuelen 0  (Ethernet)
        RX packets 4600  bytes 1840120 (1.8 MB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 329  bytes 28019 (28.0 KB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

eth2: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.169.6.3  netmask 255.255.255.0  broadcast 192.169.6.255
        ether 02:42:c0:a9:06:03  txqueuelen 0  (Ethernet)
        RX packets 30  bytes 4348 (4.3 KB)
        RX errors 0  dropped 2  overruns 0  frame 0
        TX packets 2  bytes 493 (493.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 6  bytes 476 (476.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 6  bytes 476 (476.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

Ya funcionaría el PING entre equipos hacia las mismas redes, por ejemplo desde ubuntu_A hacia ubuntu_B:

# ping 192.169.6.2
PING 192.169.6.2 (192.169.6.2) 56(84) bytes of data.
64 bytes from 192.169.6.2: icmp_seq=1 ttl=64 time=0.762 ms
64 bytes from 192.169.6.2: icmp_seq=2 ttl=64 time=0.350 ms
64 bytes from 192.169.6.2: icmp_seq=3 ttl=64 time=0.357 ms
^C
--- 192.169.6.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2052ms
rtt min/avg/max/mdev = 0.350/0.489/0.762/0.194 ms

Desconectamos la red para explicar el siguiente apartado:

root@f531122b80e3:/# exit
exit
# docker network disconnect net_B ubuntu_A

FW (IPTABLES) en Docker Host

Por defecto Docker usa IPTABLES para comunicar los elementos de su tecnología en el Docker Host que lo contiene. Entre las reglas creadas podemos encontrar dos reglas personalizadas:

  • DOCKER: es donde Docker agrega todas sus cadenas de reglas, la cual se recomienda no manipular manualmente.
  • DOCKER-USER: es donde cada usuario puede agregar, borrar o modificar sus reglas personalizadas, las cuales son aplicadas antes que las creadas automáticamente por Docker.
  • FORWARD: pueden ser creadas manualmente o por otro firewall basado en IPTABLES, y serán evaluadas después que las anteriores. Esto significa que si exponemos un puerto a través de Docker este qeuda expuesto sin importar las reglas que el Firewall haya configurado, debiendo ser agregadas estas reglas a la cadena DOCKER-USER.

En nuestro caso vamos a agregar una regla en la cadena DOCKER-USER que permita el ICMP o Ping entre ambos Contenedores:

# iptables -I DOCKER-USER -s 192.169.5.2 -d 192.169.6.2 -p icmp -j ACCEPT
# iptables -I DOCKER-USER -s 192.169.6.2 -d 192.169.5.2 -p icmp -j ACCEPT

La regla en el Firewall quedaría de la sigiuente forma:

# iptables -L -n -v
...
Chain DOCKER-USER (1 references)
 pkts bytes target     prot opt in     out     source               destination
    2   168 ACCEPT     icmp --  *      *       192.169.6.2          192.169.5.2
   11   924 ACCEPT     icmp --  *      *       192.169.5.2          192.169.6.2
 4669 4016K RETURN     all  --  *      *       0.0.0.0/0            0.0.0.0/0
# Warning: iptables-legacy tables present, use iptables-legacy to see them

Ahora comprobamos si funciona la conectividad conectándonos de nuevo al Contenedor ubuntu_A y lanzando un Ping hacia el Contenedor ubuntu_B:

# docker exec -it f531122b80e3 bash
root@f531122b80e3:/# ping 192.169.6.2
PING 192.169.6.2 (192.169.6.2) 56(84) bytes of data.
64 bytes from 192.169.6.2: icmp_seq=1 ttl=63 time=0.490 ms
64 bytes from 192.169.6.2: icmp_seq=2 ttl=63 time=0.365 ms
^C
--- 192.169.6.2 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1080ms
rtt min/avg/max/mdev = 0.365/0.427/0.490/0.065 ms

Borramos las regla en el FW si no las necesitamos:

# iptables -D DOCKER-USER 2
# iptables -D DOCKER-USER 1

Conclusión

Trabajar con Contenedores de Docker en redes distintas dentro de un mismo Docker Host es posible gracias al tipo de redes Bridge que esta tecnología nos ofrece y la segmentación de redes privadas dentro del mismo host. Esto nos permite controlar la conectividad entre estos de varias formas, como se ha explciado en esta entrada, ofreciendo una gran flexibilidad tanto a empresas que ofrezcan este tipo de microservicios como para usuarios individuales que usen esta tecnología, asegurando las comunicaciones y manteniendo nuestro entorno siempre seguro ante, por ejemplo, movimientos laterales dentro de nuestro ecosistema Docker.


Enlaces de interés

Docker oficial sobre redes https://docs.docker.com/network/

Docker oficial sobre IPTABLES https://docs.docker.com/network/iptables/