Deep Engineering

MEASUREMENT

bench/timewait/practice.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/sre/time-wait-ports
How to run it
sudo python3 bench/timewait/ports.py    > bench/timewait/runs/ports.txt
sudo python3 bench/timewait/practice.py > bench/timewait/runs/practice.txt

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для урока «TIME_WAIT и эфемерные порты»

Файл Что делает
ports.py пять наблюдений: на чьей стороне возникает TIME_WAIT; сколько он длится и какие настройки на это не влияют; чем кончается попытка открыть больше соединений, чем есть номеров портов; потолок новых соединений в секунду; и что меняет переиспользование одного соединения
practice.py ответы к задачам урока: сторона, на которой остаются сокеты, ошибка при исчерпании портов и посчитанный потолок

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

sudo python3 bench/timewait/ports.py    > bench/timewait/runs/ports.txt
sudo python3 bench/timewait/practice.py > bench/timewait/runs/practice.txt

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

Обе стороны соединения живут в одном процессе на loopback, поэтому по выводу ss видно, на какой из них возник TIME_WAIT: у одной стороны порт сервера стоит локальным адресом, у другой — адресом собеседника.

Длительность состояния не берётся из документации, а измеряется: скрипт закрывает соединение и опрашивает ss, пока сокет не исчезнет. Поэтому прогон идёт около полутора минут.

Исчерпание портов делается не нагрузкой, а сужением диапазона: на время блока /proc/sys/net/ipv4/ip_local_port_range уменьшается до полусотни номеров и возвращается в finally. Так отказ наступает за секунды и не зависит от того, сколько соединений открыто в системе помимо замера.

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

Сторона, на которой остаётся TIME_WAIT: вся двадцатка у того, кто закрыл первым. Точное исчерпание: сколько номеров в диапазоне, столько и соединений, дальше EADDRNOTAVAIL. Один порт на переиспользуемое соединение независимо от числа запросов.

Не воспроизводится точное значение потолка новых соединений в секунду: он считается делением прочитанного диапазона на измеренную длительность, а длительность между запусками гуляет на доли секунды. В ports.txt и practice.txt она снята дважды, и числа поэтому чуть разные — это два разных замера, а не расхождение.

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

ss из iproute2 и права записи в /proc/sys/net/ipv4/ip_local_port_range для третьего блока; без прав он печатает not permitted, а диапазон возвращается в finally. Если процесс убить между блоками, диапазон останется суженным — исходное значение видно в начале блока.

Числа сняты на CPython 3.11.15, Linux 6.18.44, loopback.

Script

122 lines
"""Практика к уроку про TIME_WAIT: три ответа и один потолок.

ЗАЧЕМ ОТДЕЛЬНЫЙ ФАЙЛ. Задачи урока проверяются сборкой
(scripts/validate-practice.mjs): показанный читателю код обязан построчно быть
в скрипте, верный вариант — дословно встречаться в записи прогона, а
заявленное число — печататься самой программой.

ЧТО ЗДЕСЬ ПРОВЕРЯЕТСЯ. Два места, где ошибаются чаще всего. Первое: на чьей
стороне оказывается `TIME_WAIT` и чем кончается попытка открыть больше
соединений, чем есть номеров портов. Второе: сколько новых соединений в секунду
к одному адресу выдерживает клиент — не «примерно много», а число, посчитанное
из двух измеренных величин.

Сервер и вспомогательные функции берутся из `ports.py`, чтобы задача и разбор
урока стояли на одном и том же коде.

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

Прогон идёт около минуты: часть 2 дожидается, пока состояние исчезнет само.
"""

import errno
import os
import subprocess
import sys
import time

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from ports import (  # noqa: E402
    PORT_RANGE,
    Echo,
    exchange,
    in_time_wait,
    time_wait_sides,
)

SMALL_RANGE = "50100 50119"  # двадцать номеров


def who_holds(closes_first: bool) -> tuple[int, int]:
    """Двадцать обменов подряд: на чьей стороне остаётся TIME_WAIT."""
    echo = Echo(closes_first)
    for _ in range(20):
        exchange(echo.port, wait_for_close=closes_first)
    time.sleep(0.4)
    sides = time_wait_sides(echo.port)
    echo.close()
    return sides


def until_ports_run_out() -> tuple[int, str]:
    """Сколько соединений уместится в двадцать номеров портов."""
    with open(PORT_RANGE) as handle:
        original = handle.read().strip()
    echo = Echo(closes_first=False)
    made = 0
    stopped = "nothing"
    try:
        with open(PORT_RANGE, "w") as handle:
            handle.write(SMALL_RANGE + "\n")
        for _ in range(200):
            try:
                exchange(echo.port, wait_for_close=False)
                made += 1
            except OSError as exc:
                stopped = errno.errorcode.get(exc.errno, str(exc.errno))
                break
    finally:
        with open(PORT_RANGE, "w") as handle:
            handle.write(original + "\n")
        echo.close()
    return made, stopped


def time_wait_seconds() -> float:
    """Сколько живёт одно состояние TIME_WAIT — по часам, а не по документации."""
    echo = Echo(closes_first=False)
    local = exchange(echo.port, wait_for_close=False)
    started = time.perf_counter()
    elapsed = -1.0
    while time.perf_counter() - started < 150:
        time.sleep(0.5)
        if not in_time_wait(local):
            elapsed = time.perf_counter() - started
            break
    echo.close()
    return elapsed


def main() -> None:
    print(f"Python {sys.version.split()[0]} · Linux {os.uname().release} · loopback")
    print()

    # --- Часть 1: три ответа о поведении --------------------------------
    server_side, client_side = who_holds(closes_first=False)
    made, stopped = until_ports_run_out()
    print(server_side)
    print(client_side)
    print(stopped)

    # --- Часть 2: потолок новых соединений в секунду ---------------------
    #
    # ПОЧЕМУ ЭТО ДЕЛЕНИЕ ПЕЧАТАЕТ ПРОГРАММА. Обе величины измерены здесь же:
    # диапазон прочитан из /proc, длительность снята по часам. Разделить их в
    # уме на стороне редакции — ровно тот шаг, где вкрадывается ошибка,
    # которую потом нечем поймать.
    seconds = time_wait_seconds()
    with open(PORT_RANGE) as handle:
        low, high = (int(x) for x in handle.read().split())
    span = high - low + 1
    print()
    print(f"connections made with 20 port numbers  {made}")
    print(f"port numbers in ip_local_port_range    {span}")
    print(f"seconds one port stays in TIME_WAIT    {seconds:.1f}")
    print(f"new connections per second, ceiling    {span / seconds:.1f}")


if __name__ == "__main__":
    main()