angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

Aiohttp con servidores DNS personalizados, Unbound y Docker

Descargue su aplicación Python aoihttp añadiendo resolvedores DNS de caché a su sistema local.

13 julio 2023
En Async
post main image
https://www.pexels.com/nl-nl/@cihankahraman

Utilizar aiohttp parece muy fácil, pero no lo es. Es confuso. La documentación 'Client Quickstart' comienza con lo siguiente:

Nota

No cree una sesión por solicitud. Lo más probable es que necesite una sesión por solicitud que realice todas las solicitudes juntas.

Casos más complejos pueden requerir una sesión por sitio, por ejemplo, una para Github y otra para Facebook APIs. De todos modos hacer una sesión para cada petición es una muy mala idea.

Una sesión contiene un pool de conexiones en su interior. La reutilización de la conexión y los keep-alives (ambos están activados por defecto) pueden acelerar el rendimiento total.

Hmmm ... ok ... repite por favor ...

De todos modos, el problema: Tengo que comprobar muchos sitios diferentes, y también quiero usar servidores DNS personalizados. Esto significa una sesión por sitio. No sé qué sitios, acabo de obtener una lista de Urls. Así que vamos para una sesión por Url. Y todo usando Docker.

En este post alimentamos el aiohttp AsyncResolver con los IP addresses de dos resolvers DNS de caché Unbound, Cloudflare o Quad9, corriendo en nuestro sistema local. Como siempre mi sistema de desarrollo es Ubuntu 22.04.

La aplicación Python

A continuación se muestra nuestra aplicación (incompleta) Python . Utiliza aiohttp para comprobar sitios (Urls). El script se ejecuta utilizando un contenedor Python Docker . No voy a aburrirte aquí con la configuración de una imagen y un contenedor Docker Python .

Tenga en cuenta que creamos un AsyncResolver para cada TCPConnector para cada sesión utilizando los IP addresses de Cloudflare o Quad9.

# check_urls.py
import asyncio
import aiodns
import aiohttp
import logging
import os
import socket
import sys

def get_logger(
    console_log_level=logging.DEBUG,
    file_log_level=logging.DEBUG,
    log_file=os.path.splitext(__file__)[0] + '.log',
):
    logger_format = '%(asctime)s %(levelname)s [%(filename)-30s%(funcName)30s():%(lineno)03s] %(message)s'
    logger = logging.getLogger(__name__)
    logger.setLevel(logging.DEBUG)
    if console_log_level:
        # console
        console_handler = logging.StreamHandler(sys.stdout)
        console_handler.setLevel(console_log_level)
        console_handler.setFormatter(logging.Formatter(logger_format))
        logger.addHandler(console_handler)
    if file_log_level:
        # file
        file_handler = logging.FileHandler(log_file)
        file_handler.setLevel(file_log_level)
        file_handler.setFormatter(logging.Formatter(logger_format))
        logger.addHandler(file_handler)
    return logger

logger = get_logger(file_log_level=None)


async def check_url(task_number, url, nameservers=None):
    logger.debug(f'[{task_number}] url = {url}, nameservers = {nameservers}')

    resolver = None
    if nameservers:
        resolver = aiohttp.resolver.AsyncResolver(nameservers=nameservers)

    connector=aiohttp.TCPConnector(
        limit=1,
        use_dns_cache=True,
        ttl_dns_cache=300,
        family=socket.AF_INET,
        resolver=resolver
    )

    async with aiohttp.ClientSession(
        connector=connector,
        # if we want to reuse the connector with other sessions, 
        # we must not close it: connector_owner=False
        connector_owner=True,
    ) as session:
        async with session.get(
            url,
        ) as client_response:
            logger.debug(f'[{task_number}] status = {client_response.status}')
            logger.debug(f'[{task_number}] url = {client_response.url}')
            logger.debug(f'[{task_number}] content_type = {client_response.headers.get("Content-Type", None)}')
            logger.debug(f'[{task_number}] charset = {client_response.charset}')


async def main():
    logger.debug(f'()')
    dns_cloudflare = ['1.1.1.1', '1.0.0.1']
    dns_quad9 = ['9.9.9.9', '149.112.112.112']

    sites = [
        ('http://www.example.com', dns_cloudflare),
        ('http://www.example.org', dns_quad9)
    ]

    tasks = []
    for task_number, site in enumerate(sites):
        url, nameservers = site
        task = asyncio.create_task(check_url(task_number, url, nameservers))
        tasks.append(task)

    for task in tasks:
        await task
    logger.debug(f'ready')

asyncio.run(main())

Problema: Conexión directa a servidores DNS remotos

Aunque lo anterior funciona tiene algunos problemas. Si comprobamos muchas Urls entonces estamos disparando muchas peticiones (separadas) a los servidores DNS de Cloudflare o Quad9.

Podemos reutilizar el TCPConnector, por ejemplo, mediante la creación de un grupo de TCPConnectors, y utilizar el almacenamiento en caché de DNS de los conectores. Esto es una gran mejora, pero aún está lejos de ser perfecto porque nuestros conectores siguen conectados "directamente" al mundo exterior (a través del resolver).

Solución: Servidores DNS de caché locales

Podemos hacerlo mejor ejecutando uno o más servidores DNS de caché en nuestro sistema local, y alimentando los AsyncResolvers con los IP addresses de nuestros servidores DNS de caché.

Servidor DNS de caché: Sin consolidar

Hay un montón de Docker servidor DNS images y he seleccionado 'Sin consolidar servidor DNS Docker imagen', ver enlaces más abajo. ¿Por qué? Es fácil de usar, y por defecto reenvía las consultas a un servidor DNS remoto, Cloudflare. Una buena característica es que podemos usar DNS sobre TLS (DoT). Esto significa que protegemos las peticiones del rastreo (ISP).

Como queremos más de un servidor DNS local, primero copiamos algunos archivos de configuración fuera del contenedor. En el directorio donde iniciamos el servidor DNS, creamos un nuevo directorio:

my_conf

Luego iniciamos el servidor DNS:

docker run --name=my-unbound mvance/unbound:1.17.0

Y en otro terminal, copiamos algunos ficheros de dentro del contenedor a nuestro sistema:

mkdir my_conf
docker cp my-unbound:/opt/unbound/etc/unbound/forward-records.conf my_conf
docker cp my-unbound:/opt/unbound/etc/unbound/a-records.conf my_conf

Detenemos el servidor DNS pulsando 'CTRL-C'.

He creado el siguiente archivo docker-compose.yml:

version: '3'

services:
  unbound_cloudflare_service:
    image: "mvance/unbound:1.17.0"
    container_name: unbound_cloudflare_container
    networks:
     - dns
    volumes:
      - type: bind
        read_only: true
        source: ./my_conf/forward-records.conf
        target: /opt/unbound/etc/unbound/forward-records.conf
      - type: bind
        read_only: true
        source: ./my_conf/a-records.conf
        target: /opt/unbound/etc/unbound/a-records.conf
    restart: unless-stopped

networks:
  dns:
    external: true
    name: unbound_dns_network

volumes:
  mydata:

Nos estamos conectando desde la aplicación Python contenedor Docker al contenedor del servidor DNS utilizando Docker red. Esto significa que no hay necesidad de especificar los puertos en el archivo docker-compose.yml. No publicar puertos significa mayor seguridad. Para crear la red Docker 'unbound_dns_network':

docker network create unbound_dns_network

Para iniciar el servidor DNS:

docker-compose up

Comprobar si el servidor DNS está funcionando

Para ello utilizo 'netshoot: a Docker + Kubernetes network trouble-shooting swiss-army container', ver enlaces más abajo. Cuando lo iniciamos, también nos conectamos a 'unbound_dns_network':

docker run --rm -it --net=unbound_dns_network nicolaka/netshoot

A continuación, utilizamos 'dig' para comprobar si nuestro servidor DNS está funcionando.

Ten en cuenta que aquí nos referimos al nombre del servicio Docker-compose , 'unbound_cloudflare_service':

dig @unbound_cloudflare_service -p 53 google.com

Resultado:

---
; <<>> DiG 9.18.13 <<>> @unbound_cloudflare_service -p 53 google.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 55895
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;google.com.            IN    A

;; ANSWER SECTION:
google.com.        300    IN    A    142.250.179.174

;; Query time: 488 msec
;; SERVER: 172.17.10.3#53(unbound_cloudflare_service) (UDP)
;; WHEN: Wed Jul 12 16:32:56 UTC 2023
;; MSG SIZE  rcvd: 55

La 'SECCIÓN DE RESPUESTAS' da el IP address. El tiempo de consulta es de 488 milisegundos. Si ejecutamos el comando de nuevo, obtendremos el mismo resultado pero el tiempo de consulta será (cercano a) 0. Observa que también se muestra el IP address de nuestro servicio de servidor DNS local:

172.17.10.3

En cualquier caso, ¡nuestro servidor DNS local funciona!

Podemos repetir estos pasos para Quad9. Crear un nuevo directorio y copiar los archivos de la configuración de Cloudflare.

Edita el archivo docker-compose.yml y sustituye 'cloudflare' por 'quad9'.

Edite el archivo 'forward-records.conf':

  • Comente las líneas para Cloudflare
  • Descomente las líneas para Quad9

Y ¡arriba!

Usando nuestros servidores DNS locales en nuestro script Python

Este es el último paso. Debemos hacer dos cosas:

  • Añadir el 'unbound_dns_network' a nuestro contenedor Python Docker .
  • Traducir los nombres 'unbound_cloudflare_service', y 'unbound_quad9_service' a IP addresses.

Añadir el 'unbound_dns_network' a nuestro contenedor Python Docker es fácil. Lo hacemos de la misma forma que en el archivo Unbound docker-compose.yml.

Ya conocemos los IP addresses de nuestros servicios de servidor DSN locales, pero pueden cambiar. En lugar de codificar los IP addresses, traducimos los nombres de servicio a IP addresses en nuestro script Python , cambiando el siguiente código en nuestro script, ver arriba, de:

    dns_cloudflare = ['1.1.1.1', '1.0.0.1']
    dns_quad9 = ['9.9.9.9', '149.112.112.112']

a:

    dns_cloudflare = [socket.gethostbyname('unbound_cloudflare_service')]
    dns_quad9 = [socket.gethostbyname('unbound_quad9_service')]

Por supuesto, esto sólo funciona si los servicios del servidor DNS local son up-and-running.

Ahora todas las peticiones DNS de nuestra aplicación Python se dirigen a nuestros servidores DNS locales.

Resumen

Queríamos comportarnos de forma amigable y no sobrecargar los servidores DNS remotos con demasiadas conexiones. También queríamos eliminar la conexión directa de nuestra aplicación Python a servidores DNS remotos. Lo hemos conseguido creando servicios de servidores DNS locales y conectando nuestro script Python a ellos, utilizando la red Docker .
Hemos creado una depencia extra, servidores DNS locales, pero también hemos eliminado una depencia. Si un servidor DNS remoto está caído (por algún tiempo), nuestra aplicación Python sigue funcionando.

Enlaces / créditos

Docker Container Published Port Ignoring UFW Rules
https://www.baeldung.com/linux/docker-container-published-port-ignoring-ufw-rules

netshoot: a Docker + Kubernetes network trouble-shooting swiss-army container
https://github.com/nicolaka/netshoot

Unbound
https://nlnetlabs.nl/projects/unbound/about

Unbound DNS Server Docker Image
https://github.com/MatthewVance/unbound-docker

Deje un comentario

Comente de forma anónima o inicie sesión para comentar.

Comentarios

Deje una respuesta.

Responda de forma anónima o inicie sesión para responder.