Deep Engineering

ЗАМЕР

bench/nameres/connect_timeout.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/sre/name-resolution
Как запустить
sudo python3 bench/nameres/resolve.py  > bench/nameres/runs/resolve.txt
sudo python3 bench/nameres/practice.py > bench/nameres/runs/practice.txt
sudo python3 bench/nameres/connect_timeout.py > bench/nameres/runs/connect_timeout.txt

Запись прогона

Замеры для урока «Разрешение имени»

Файл Что делает
resolve.py пять наблюдений: откуда приходит ответ (файл или сеть); есть ли кеш в процессе; цена имени, которое не разрешается; сколько запросов уходит при AF_UNSPEC и при AF_INET; что делают поисковые области и точка на конце имени
connect_timeout.py кто покрывает резолв: таймаут сокета у socket.create_connection в CPython его не ограничивает (3,00 с при бюджете 0,3 с), дедлайн вокруг всего вызова (asyncio.wait_for) возвращает вызывающего за 0,30 с, а net.DialTimeout в Go (dialtimeout.go, резолверы go и cgo) включает резолв в свои 300 мс
practice.py ответы к задачам урока: сколько имён спрошено с поисковой областью и без неё, сколько запросов уходит на неуказанное семейство и во что обходится ненайденное имя при настройках по умолчанию

Запуск из корня репозитория:

sudo python3 bench/nameres/resolve.py  > bench/nameres/runs/resolve.txt
sudo python3 bench/nameres/practice.py > bench/nameres/runs/practice.txt
sudo python3 bench/nameres/connect_timeout.py > bench/nameres/runs/connect_timeout.txt

connect_timeout.py (02.11.2026) снят в отдельном пространстве имён монтирования — unshare -m с mount --bind копии на /etc/resolv.conf, — чтобы подмена файла не задела соседние процессы; молчащий сервер слушает 127.0.0.77:53. Интерпретатор — CPython 3.11.17 (3.11.15 из шапок остальных записей в среде уже нет), Go — go1.24.7 linux/amd64.

Как устроен замер

Настоящий сервер имён для блоков 3–5 не нужен и вреден: его времена — это чужая сеть, а не поведение библиотеки. Вместо него скрипт поднимает свой сервер на 127.0.0.1:53, который принимает запросы и не отвечает ни на один, и на время замера подменяет /etc/resolv.conf. Всё, что при этом видно, — расписание повторов самой libc.

Исходный /etc/resolv.conf копируется в /tmp до первой подмены и возвращается в finally. Если процесс всё же убить между блоками, файл останется подменённым — копия лежит в /tmp/resolv.conf.bench-backup.

Что воспроизводимо

Цена ненайденного имени: timeout × attempts × число серверов, секунда в секунду — для блокирующего getaddrinfo glibc и молчащего сервера.

Что из этого следует для таймаутов клиента, зависит от клиента, а не от DNS. Замер resolve.py и practice.py — голый socket.getaddrinfo из CPython; клиента с таймаутом 300 мс в них нет, и «клиент ждал 12 секунд» — вывод для клиента, у которого таймаут стоит на сокете после резолва. connect_timeout.py проверяет это прямо: у socket.create_connection(timeout=0.3) резолв занял 3,00 с мимо бюджета, а net.DialTimeout в Go вернулся за 0,30 с, потому что его таймаут включает разрешение имени. Число запросов: два при AF_UNSPEC, один при AF_INET; два имени при поисковой области и одно при точке на конце. Порядок источников — из nsswitch.conf.

Не воспроизводимы миллисекунды настоящих запросов: в этой среде они гуляют от 2,2 до 29,3 мс, поэтому ни одно утверждение урока на них не опирается. Не воспроизводится и то, какой именно адрес вернётся для example.com, — важно лишь, что за шесть вызовов подряд он менялся.

Требования к среде

Блокам 3–5 нужны права root: bind на порт 53 и запись в /etc/resolv.conf. Без них скрипт печатает not permitted и выполняет первые два блока. Нужен также рабочий выход в сеть для блоков 1 и 2 — без него example.com не разрешится и строки покажут gaierror.

Числа сняты на CPython 3.11.15, Linux 6.18.44, glibc с hosts: files dns.

Скрипт

201 строк
"""Таймаут соединения и дедлайн всего вызова: кто из них покрывает резолв.

ЗАЧЕМ ЭТОТ ФАЙЛ (аудит 01.11.2026, Q-04). Урок говорил безусловно: шаг
разрешения имени «не ограничен таймаутом запроса: таймаут ставится на
соединение, а соединять ещё не с чем». Для одного класса клиентов это верно,
для другого — нет:

  - у `socket.create_connection(addr, timeout=...)` в CPython таймаут ставится
    на сокет ПОСЛЕ `getaddrinfo` (Lib/socket.py: сначала цикл по
    getaddrinfo, внутри него settimeout), и блокирующий вызов резолвера им не
    ограничен;
  - у `net.DialTimeout` в Go документация прямо говорит «The timeout includes
    name resolution, if required», а Dialer.DialContext (net/dial.go,
    go1.24.7) превращает Timeout в дедлайн контекста ДО resolveAddrList.

Урок до этого мерил только первый класс — голый `socket.getaddrinfo` из
CPython — и выдавал его за общее правило. Здесь оба класса стоят рядом на
одном и том же молчащем сервере имён.

КАК ИЗМЕРЯЕТСЯ. Как в resolve.py: свой сервер имён, который принимает запросы
и не отвечает ни на один, и подменённый /etc/resolv.conf. Отличие одно: сервер
слушает 127.0.0.77:53, а не 127.0.0.1:53, чтобы не столкнуться с resolve.py,
если тот запущен рядом. Исходный resolv.conf копируется и возвращается в
finally.

ПОЧЕМУ ПОДПИСИ ПО-АНГЛИЙСКИ. Как у соседей: урок цитирует запись прогона
дословно обеими версиями.

ЗАПУСК: sudo python3 bench/nameres/connect_timeout.py
Вывод: runs/connect_timeout.txt

Запись прогона снята в отдельном пространстве имён монтирования, чтобы
подмена resolv.conf не задела соседние процессы машины:

  cp /etc/resolv.conf /tmp/rc && unshare -m sh -c \\
    'mount --bind /tmp/rc /etc/resolv.conf && python3.11 bench/nameres/connect_timeout.py'
"""

import asyncio
import os
import shutil
import socket
import subprocess
import sys
import threading
import time

RESOLV = "/etc/resolv.conf"
BACKUP = "/tmp/resolv.conf.connect-timeout-backup"
SERVER = "127.0.0.77"
PROBE = "zz-probe-9931"
TIMEOUT_S = 0.3
HERE = os.path.dirname(os.path.abspath(__file__))


def show(title: str) -> None:
    print()
    print(title)
    print("-" * len(title))


def row(label: str, value: object) -> None:
    print(f"  {label:<46} {value}")


class SilentNameServer:
    """Принимает запросы на SERVER:53 и не отвечает ни на один."""

    def __init__(self) -> None:
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.sock.bind((SERVER, 53))
        self.queries = 0
        threading.Thread(target=self._loop, daemon=True).start()

    def _loop(self) -> None:
        while True:
            try:
                self.sock.recvfrom(512)
            except OSError:
                return
            self.queries += 1

    def close(self) -> None:
        self.sock.close()


def timed(fn) -> tuple[float, str]:
    started = time.perf_counter()
    try:
        fn()
        outcome = "connected"
    except OSError as exc:
        outcome = f"{type(exc).__name__}: {exc}"
    return time.perf_counter() - started, outcome


# ------------------------------------------------------------------ 1
def block1() -> None:
    show("1. A SOCKET TIMEOUT DOES NOT BOUND THE LOOKUP BEFORE IT")
    row("client", f"CPython {sys.version.split()[0]}, socket module")
    row("resolv.conf", "timeout:1 attempts:3, one silent server")

    s, outcome = timed(lambda: socket.getaddrinfo(PROBE, 80, socket.AF_INET, socket.SOCK_STREAM))
    row("getaddrinfo alone, s", f"{s:.2f}  -> {outcome}")

    s, outcome = timed(
        lambda: socket.create_connection((PROBE, 80), timeout=TIMEOUT_S).close()
    )
    row(f"create_connection(timeout={TIMEOUT_S}), s", f"{s:.2f}  -> {outcome}")

    print()
    print("  create_connection resolves first and sets the timeout on each")
    print("  socket afterwards, so the 0.3 s budget never applies to the lookup.")


# ------------------------------------------------------------------ 2
def block2() -> None:
    show("2. A DEADLINE AROUND THE WHOLE CALL RETURNS ON TIME")
    row("client", f"CPython {sys.version.split()[0]}, asyncio.wait_for")

    async def run() -> tuple[float, str, float]:
        loop = asyncio.get_running_loop()
        lookup_done = loop.create_future()
        started = time.perf_counter()

        def blocking() -> None:
            try:
                socket.getaddrinfo(PROBE, 80, socket.AF_INET, socket.SOCK_STREAM)
            except OSError:
                pass
            loop.call_soon_threadsafe(lookup_done.set_result, time.perf_counter() - started)

        task = loop.run_in_executor(None, blocking)
        try:
            await asyncio.wait_for(asyncio.shield(task), TIMEOUT_S)
            outcome = "resolved"
        except TimeoutError:
            outcome = "TimeoutError"
        caller = time.perf_counter() - started
        background = await lookup_done
        return caller, outcome, background

    caller, outcome, background = asyncio.run(run())
    row(f"caller waited (deadline {TIMEOUT_S} s), s", f"{caller:.2f}  -> {outcome}")
    row("the lookup thread itself finished after, s", f"{background:.2f}")

    print()
    print("  The deadline frees the caller, not the resolver: the blocking")
    print("  getaddrinfo keeps its thread busy for the full retry schedule.")


# ------------------------------------------------------------------ 3
def block3() -> None:
    show("3. GO: DIALTIMEOUT INCLUDES NAME RESOLUTION")
    go = shutil.which("go") or "/usr/local/go/bin/go"
    version = subprocess.run([go, "version"], capture_output=True, text=True).stdout.strip()
    row("client", version)
    row("resolv.conf", "timeout:1 attempts:3, one silent server")
    for resolver in ("go", "cgo"):
        env = dict(os.environ, GODEBUG=f"netdns={resolver}")
        out = subprocess.run(
            [go, "run", os.path.join(HERE, "dialtimeout.go"), PROBE],
            capture_output=True,
            text=True,
            env=env,
        ).stdout.strip()
        seconds, _, err = out.partition("\t")
        row(f"net.DialTimeout 300ms, netdns={resolver}, s", f"{seconds}  -> {err}")

    print()
    print("  Dialer turns Timeout into a context deadline before it resolves")
    print("  the name, so the same 0.3 s covers the lookup and the connect.")


def main() -> None:
    print(f"Python {sys.version.split()[0]} · Linux {os.uname().release}")
    print(f"a silent name server on {SERVER}:53, an unresolvable name {PROBE!r}")
    try:
        shutil.copyfile(RESOLV, BACKUP)
        server = SilentNameServer()
    except OSError as exc:
        show("1-3. NOT PERMITTED")
        row("cannot bind :53 or write resolv.conf", type(exc).__name__)
        return
    try:
        with open(RESOLV, "w") as handle:
            handle.write(f"nameserver {SERVER}\noptions timeout:1 attempts:3\n")
        block1()
        block2()
        block3()
        row("queries the silent server received", server.queries)
    finally:
        shutil.copyfile(BACKUP, RESOLV)
        server.close()
        print()
        print("  resolv.conf restored from the backup.")


if __name__ == "__main__":
    main()