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

Aiohttp met aangepaste DNS-servers, Unbound en Docker

Ontlast je Python aoihttp applicatie door caching DNS resolvers toe te voegen aan je lokale systeem.

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

Het gebruik van aiohttp lijkt zo eenvoudig, maar dat is het niet. Het is verwarrend. De 'Client Quickstart' documentatie begint met het volgende:

Opmerking

Maak geen sessie aan per aanvraag. Waarschijnlijk heb je een sessie per aanvraag nodig die alle aanvragen samen uitvoert.

Complexere gevallen kunnen een sessie per site vereisen, bijvoorbeeld één voor Github en één voor Facebook APIs. Hoe dan ook, een sessie maken voor elk verzoek is een heel slecht idee.

Een sessie bevat een verbindingspool. Hergebruik van verbindingen en keep-alives (beide staan standaard aan) kunnen de totale prestaties versnellen.

Hmmm ... ok ... herhaal alsjeblieft ...

Hoe dan ook, het probleem: ik moet veel verschillende sites controleren en ik wil ook aangepaste DNS-servers gebruiken. Dit betekent een sessie per site. Ik weet niet welke sites, ik krijg gewoon een lijst met Urls. Dus gaan we voor een sessie per Url. En alles gebruikt Docker.

In deze post voeden we de aiohttp AsyncResolver met de IP addresses van twee Unbound caching DNS resolvers, Cloudflare of Quad9, die op ons lokale systeem draaien. Zoals altijd is mijn ontwikkelsysteem Ubuntu 22.04.

De applicatie Python

Hieronder staat onze (incomplete) Python applicatie. Het gebruikt aiohttp om sites (Urls) te controleren. Het script draait in een Python Docker container. Ik ga je hier niet vervelen met het opzetten van een Docker Python image en container.

Merk op dat we een AsyncResolver aanmaken voor elke TCPConnector voor elke sessie die de IP addresses van Cloudflare of Quad9 gebruikt.

# 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())

Probleem: rechtstreeks verbonden met externe DNS-servers

Hoewel het bovenstaande werkt, heeft het enkele problemen. Als we veel url's controleren, sturen we veel (afzonderlijke) verzoeken naar de DNS-servers van Cloudflare of Quad9.

We kunnen de TCPConnector hergebruiken, bijvoorbeeld door een pool van TCPConnector's aan te maken, en de DNS-caching van de connectors gebruiken. Dit is een grote verbetering, maar het is nog steeds verre van perfect omdat onze connectors 'direct' verbonden blijven met de buitenwereld (via de resolver).

Oplossing: Lokale caching DNS-servers

We kunnen het beter doen door een of meer caching DNS-servers op ons lokale systeem te draaien en de AsyncResolver's te voeden met de IP address's van onze caching DNS-servers.

Caching DNS-server: Niet-gebonden

Er zijn veel Docker DNS-server images en ik heb 'Unbound DNS Server Docker Image' geselecteerd, zie onderstaande koppelingen. Waarom? Het is eenvoudig te gebruiken en stuurt standaard queries door naar een externe DNS-server, Cloudflare. Een leuke functie is dat we DNS over TLS (DoT) kunnen gebruiken. Dit betekent dat we de verzoeken afschermen van (ISP) tracking.

Omdat we meer dan één lokale DNS-server willen, kopiëren we eerst wat configuratiebestanden buiten de container. In de directory waar we de DNS-server starten, maken we een nieuwe directory aan:

my_conf

Vervolgens starten we de DNS-server:

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

En in een andere terminal kopiëren we enkele bestanden van binnen de container naar ons systeem:

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

Stop de DNS-server door op 'CTRL-C' te drukken.

Ik heb het volgende docker-compose.yml bestand gemaakt:

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:

We maken verbinding van de Python applicatie Docker container naar de DNS server container met behulp van Docker netwerk. Dit betekent dat er geen poorten gespecificeerd hoeven te worden in docker-compose.yml bestand. Geen poorten publiceren betekent een betere beveiliging. Om het Docker netwerk 'unbound_dns_network' aan te maken:

docker network create unbound_dns_network

Om de DNS-server te starten:

docker-compose up

Controleren of de DNS-server werkt

Hiervoor gebruik ik 'netshoot: a Docker + Kubernetes network trouble-shooting swiss-army container', zie onderstaande links. Wanneer we het starten, maken we ook verbinding met het 'unbound_dns_network':

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

Vervolgens gebruiken we 'dig' om te controleren of onze DNS-server werkt.

Merk op dat we hier verwijzen naar de Docker-compose servicenaam, 'unbound_cloudflare_service':

dig @unbound_cloudflare_service -p 53 google.com

Resultaat:

---
; <<>> 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

De 'ANTWOORD SECTIE' geeft de IP address. De querytijd is 488 milliseconden. Als we het commando opnieuw uitvoeren, krijgen we hetzelfde resultaat, maar de querytijd zal (bijna) 0 zijn. Merk op dat de IP address van onze lokale DNS-server service ook wordt weergegeven:

172.17.10.3

Hoe dan ook, onze lokale DNS-server werkt!

We kunnen deze stappen herhalen voor Quad9. Maak een nieuwe directory en kopieer de bestanden van de Cloudflare setup.

Bewerk het bestand docker-compose.yml en vervang 'cloudflare' door 'quad9'.

Bewerk het bestand 'forward-records.conf':

  • Commentarieer de regels voor Cloudflare
  • Decommenteer de regels voor Quad9

En neem het op!

Onze lokale DNS-servers gebruiken in ons Python script

Dit is de laatste stap. We moeten twee dingen doen:

  • Voeg het 'unbound_dns_network' toe aan onze Python Docker container.
  • Vertaal de namen 'unbound_cloudflare_service' en 'unbound_quad9_service' naar IP addresses.

Het toevoegen van 'unbound_dns_network' aan onze Python Docker container is eenvoudig. We doen dit op dezelfde manier als in het Unbound docker-compose.yml bestand.

We kennen de IP addresses van onze lokale DSN server services al, maar ze kunnen veranderen. In plaats van de IP addresses hard te coderen, vertalen we de servicenamen naar IP addresses in ons Python script, door de volgende code in ons script, zie hierboven, te veranderen van:

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

naar:

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

Dit werkt natuurlijk alleen als de lokale DNS-serverservices up-and-running zijn.

Nu worden alle DNS-verzoeken van onze Python applicatie omgeleid naar onze lokale DNS-servers!

Samenvatting

We wilden ons vriendelijk gedragen en de externe DNS-servers niet overbelasten met te veel verbindingen. We wilden ook de directe verbinding van onze Python toepassing met externe DNS-servers verwijderen. We hebben dit bereikt door lokale DNS-serverservices op te starten en ons Python script hierop aan te sluiten, met behulp van Docker network.
We hebben een extra dependance gemaakt, lokale DNS-servers, maar ook een dependance verwijderd. Als een DNS-server op afstand down is (voor enige tijd), blijft onze Python applicatie werken.

Links / credits

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

Laat een reactie achter

Reageer anoniem of log in om commentaar te geven.

Opmerkingen

Laat een antwoord achter

Antwoord anoniem of log in om te antwoorden.