Сокетный API операционной системы дает приложению точку доступа к сетевому обмену. TCP предоставляет упорядоченный поток байтов с подтверждением доставки, UDP передает отдельные датаграммы без гарантии порядка и повторной доставки. Выбор протокола зависит от требований к задержке, надежности и контролю потерь.
Блокирующий вызов останавливает текущий поток или горутину, пока операция не завершится, не истечет тайм-аут или не произойдет ошибка. В сокетах так работают accept(), recv(), send() и их аналоги. Неблокирующий режим возвращает управление сразу: Python получает BlockingIOError, а Go скрывает ожидание ОС за планировщиком горутин.
Практическая схема выглядит так: приложение создает сокет, привязывает его к адресу, подключается или начинает слушать порт, затем обрабатывает байты в выбранной конкурентной модели. Ниже разобраны точки, где можно проверить или ограничить действие: внутри приложения, в прокси, на хосте и на сетевом шлюзе.
Сначала договоримся, что считать блокировкой
Блокировка относится к ожиданию результата системного вызова, а не к самому факту наличия сетевого соединения. TCP-сервер может держать тысячи установленных соединений, но блокировать обработку каждого из них по-разному: отдельным процессом, потоком, событием в цикле или горутиной.
| Модель | Поведение | Практический сценарий |
|---|---|---|
| Процесс на соединение | Изоляция памяти, высокая цена создания процессов | Небольшое число клиентов и строгая изоляция |
| Поток на соединение | Простой блокирующий код, общая память | Сетевые сервисы с умеренным числом подключений |
selectors или asyncio | Один цикл событий следит за множеством дескрипторов | Высокое число соединений с короткими операциями |
| Горутина Go | Блокирующий стиль кода паркует горутину, а не весь поток | TCP- и UDP-серверы с большим числом клиентов |
Базовый TCP-клиент на Python блокируется в connect() и recv(), пока не сработает тайм-аут:
import socket
with socket.create_connection(('127.0.0.1', 9000), timeout=3) as sock:
sock.sendall(b'PING\n')
response = sock.recv(4096)
print(response.decode())
Вызов setblocking(False) меняет контракт:
sock.setblocking(False)
try:
sock.connect(('10.0.0.20', 9000))
except BlockingIOError:
pass
После этого приложение должно следить за готовностью сокета через selectors, poll или epoll. Простая замена режима без цикла событий приводит к активному опросу, лишней нагрузке на CPU и потере контроля над тайм-аутами.
В стандартной сборке CPython с GIL ожидание сети освобождает интерпретатор, поэтому потоковая модель подходит для I/O-нагрузки. CPU-затратный разбор данных внутри Python-потока требует отдельного процесса, очереди задач или сборки Python без GIL с проверкой совместимости библиотек. В Go рантайм отслеживает сетевые события и может выполнять тысячи горутин на небольшом числе системных потоков.
Карта: приложение, хост и два сетевых маршрута
У TCP-соединения есть два направления передачи: клиент отправляет байты серверу, сервер возвращает байты клиенту. На уровне IP каждое направление проходит через таблицу маршрутизации, интерфейсы, NAT и фильтры. У UDP нет рукопожатия TCP, поэтому отправитель может передать датаграмму без предварительного подтверждения доступности получателя.
Серверный сокет обычно проходит четыре шага:
socket()создает дескриптор.bind()связывает его с IP-адресом и портом.listen()переводит TCP-сокет в режим ожидания подключений.accept()возвращает новый сокет для конкретного клиента, а слушающий дескриптор продолжает принимать подключения.
Привязка к 127.0.0.1 разрешает доступ только с этого хоста. Адрес 0.0.0.0 прослушивает IPv4-интерфейсы, но доступ все равно может блокировать firewall. Для IPv6 нужен отдельный учет адреса :: и параметров dual-stack.
import socket
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(('0.0.0.0', 9000))
listener.listen(128)
while True:
conn, address = listener.accept()
conn.sendall(b'connected\n')
conn.close()
Проверяйте путь отдельно от приложения: ss -ltnp показывает слушающие TCP-порты и процессы, ss -uanp помогает найти UDP-сокеты, а ip route get 10.0.0.20 показывает выбранный интерфейс и исходный адрес. Для полной проверки периметра полезна пошаговая методика аудита сетевой инфраструктуры с анализом открытых портов и правил firewall.
Для лаборатории удобно поднять два небольших сервера на VDS или облачном хосте. Например, Timeweb Cloud предоставляет VDS, сети и Kubernetes, поэтому можно проверить bind-адрес, правила входящего трафика, задержку и поведение сервиса при нескольких клиентах.
Hook: приложение проверяет действие до выполнения
Сокет передает байты и не знает, что сообщение означает удаление записи, запуск команды или запрос метрик. Проверка должна выполняться после разбора протокола и до вызова обработчика, который меняет состояние.
TCP не сохраняет границы сообщений. Один вызов send() на клиенте может соответствовать нескольким вызовам recv() на сервере. Поэтому протоколу нужен фрейминг: фиксированный заголовок с длиной, разделитель строк или формат с самостоятельным определением конца сообщения. Лимит кадра задайте заранее, например 1 MiB, чтобы поврежденный заголовок не заставил сервер ждать гигабайты данных.
ALLOWED_ACTIONS = {'PING', 'GET_STATUS'}
MAX_FRAME = 1024 * 1024
def dispatch(request, user):
action = request.get('action')
if action not in ALLOWED_ACTIONS:
raise ValueError('action is not allowed')
if not user.is_authenticated:
raise PermissionError('authentication required')
return handlers[action](request)
В этом hook проверяет имя действия, аутентификацию и затем передает запрос обработчику. Для write-операций добавьте проверку прав на объект, идемпотентный идентификатор запроса и запись результата в журнал. Ошибка валидации должна закрывать кадр или соединение по правилам протокола, но не оставлять сокет в неопределенном состоянии.
Для UDP граница сообщения сохраняется: recvfrom() получает одну датаграмму. Это упрощает фрейминг, но размер датаграммы ограничен MTU и настройками ОС. Большие UDP-сообщения могут фрагментироваться или теряться, поэтому прикладной протокол должен выдерживать повтор, переупорядочивание и пропуск пакетов.
MCP-wrapper: проверка между клиентом и сервером инструментов
Прокси между клиентом и сервером инструментов строится как два соединения: клиент подключается к wrapper, wrapper устанавливает отдельное соединение с сервером и пересылает разрешенные сообщения. Такой слой полезен, когда клиент нельзя быстро изменить, а правила доступа должны быть едиными для нескольких приложений.
Прокси проверяет имя инструмента, размер сообщения, идентификатор клиента, тайм-аут и частоту запросов. Если транспорт зашифрован между клиентом и сервером, промежуточный wrapper видит только зашифрованный поток. Для проверки содержимого TLS завершают на контролируемом прокси, после чего нужно заново шифровать соединение до сервера.
ALLOWED_TOOLS = {'health', 'read_metrics'}
def allow_request(message, identity):
if identity not in {'monitoring', 'operator'}:
return False
if message.get('tool') not in ALLOWED_TOOLS:
return False
if len(message.get('arguments', {})) > 32:
return False
return True
В TCP-wrapper нельзя бездумно копировать поток порциями. Сначала прочитайте заголовок кадра, получите заявленную длину, проверьте ее лимит, дочитайте ровно это число байтов и только потом разбирайте сообщение. Тайм-ауты должны действовать на чтение, запись и установку соединения. Иначе медленный клиент займет дескриптор на неопределенный срок.
Браузер: кнопка «Удалить» тоже выполняет действие
Браузер отправляет HTTP-запрос через TCP, а при HTTPS шифрует его до точки завершения TLS. Сетевой firewall видит IP-адреса, порты и состояние соединения. Он не различает кнопку «Удалить» и кнопку «Обновить», пока трафик не попадет на HTTP-прокси или сервер после расшифровки.
DELETE /api/items/42 HTTP/1.1
Host: app.internal
Authorization: Bearer token
X-Request-ID: 8f31
Приложение должно проверить метод, маршрут, токен, право на объект и защиту от повторного запроса. Для браузерных сессий добавьте CSRF-защиту и проверку происхождения запроса. Запись в журнале должна содержать пользователя, объект, результат, IP-адрес и идентификатор запроса.
При HTTP/2 несколько запросов используют одно TCP-соединение, поэтому количество сокетов не совпадает с количеством действий пользователя. При WebSocket после рукопожатия сервер получает сообщения в длительном соединении. В обоих случаях смысл действия появляется на прикладном уровне, а не в recv().
Контроль на хосте: что делают программы и их потомки
Хост видит процесс, который держит дескриптор сокета, локальный порт и состояние соединения. В Linux для первичной проверки используйте ss -tpn, lsof -nP -iTCP:9000 -sTCP:LISTEN и просмотр лимита ulimit -n. Превышение лимита файловых дескрипторов часто выглядит как сетевой сбой, хотя удаленный узел доступен.
Потомок процесса может унаследовать открытый дескриптор после fork(). Из-за этого старый worker продолжает удерживать порт, а graceful restart не завершает соединение ожидаемым образом. Закрывайте лишние дескрипторы, используйте явный жизненный цикл workers и проверяйте процессное дерево.
В Go конкурентный TCP-сервер часто запускает отдельную горутину для каждого принятого соединения:
package main
import (
`log`
`net`
)
func main() {
listener, err := net.Listen(`tcp`, `:9000`)
if err != nil {
log.Fatal(err)
}
defer listener.Close()
for {
conn, err := listener.Accept()
if err != nil {
log.Print(err)
continue
}
go serve(conn)
}
}
Горутина потребляет меньше памяти, чем отдельный поток, но бесконечное создание горутин все равно исчерпает память при атаке или ошибке клиента. Ограничивайте число активных соединений семафором, закрывайте idle-сессии и задавайте дедлайны через SetReadDeadline и SetWriteDeadline.
Linux: что даёт eBPF
eBPF позволяет наблюдать сетевые события на уровне ядра: вход в connect, accept, sendmsg, recvmsg, сетевые пакеты и события cgroup. Это помогает связать сетевую попытку с PID, командой и контейнером, когда журналы приложения неполны.
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_connect { printf("%d %s", pid, comm); }'
Эта трассировка показывает факт входа в системный вызов, но сама по себе не доказывает успешное соединение. Для результата нужен контроль выхода из вызова, а для адреса назначения требуется разобрать структуру sockaddr. Проверяйте корреляцию с ss, журналом сервиса и правилами firewall.
Для ограничений исходящих соединений используют политики cgroup и подходящие BPF-программы, но доступные точки подключения зависят от версии ядра, типа hook и загрузчика. eBPF не заменяет обычный firewall и не дает прикладному коду сведения о данных, скрытых TLS. На рабочем хосте сначала включите наблюдение, соберите список легитимных направлений, затем добавляйте блокирующие правила с аварийным способом отката.
Для контейнеров фиксируйте не только IP и порт, но и имя workload, namespace, UID процесса и время события. IP контейнера может измениться после пересоздания, поэтому правило по одному адресу быстро устаревает.
Windows: ETW наблюдает, блокировка требует своего механизма
ETW собирает события Windows и сетевых компонентов, но запись события не прекращает соединение. Для воспроизводимой диагностики можно снять трассу:
netsh trace start capture=yes report=no tracefile=C:\Temp\net.etl
netsh trace stop
Файл ETL помогает сопоставить время подключения, процесс и сетевую ошибку. Команда Test-NetConnection 10.0.0.20 -Port 9000 проверяет TCP-доступность порта, а Get-NetTCPConnection -LocalPort 9000 показывает состояние локальных соединений.
Блокирование выполняет Windows Filtering Platform через правила Windows Defender Firewall или корпоративный агент. В правиле указывают профиль, направление, приложение или службу, протокол, локальный и удаленный порт, адреса и действие. При проблеме с политикой полезен пошаговый алгоритм поиска блокировки сетевого доступа в Windows: он разделяет DNS, маршрут, прокси, VPN, firewall и доменную политику.
Проверяйте правило на тестовом порту и отдельной учетной записи. Слишком широкое разрешение для svchost.exe или интерпретатора может открыть доступ множеству сервисов, поэтому для production лучше привязывать правило к конкретной программе, службе или группе узлов.
Сетевой шлюз: что проходит через проверку
Шлюз обычно принимает решение по пяти параметрам: исходный IP, исходный порт, целевой IP, целевой порт и протокол. Stateful firewall хранит состояние TCP: SYN, подтверждение рукопожатия, закрытие и тайм-аут. Для UDP он чаще создает временную запись по первой датаграмме и удаляет ее после периода бездействия.
Правило на TCP-порт 9000 не разрешает автоматически UDP-порт 9000. Правило для IPv4 не обязательно покрывает IPv6. NAT меняет адрес или порт, поэтому проверяйте правила до и после трансляции. При asymmetrical routing пакет может прийти через один интерфейс, а ответ уйти через другой, из-за чего stateful firewall отбросит поток.
table inet filter {
chain input {
type filter hook input priority filter;
policy drop;
ct state established,related accept
iifname `lo` accept
ip saddr 10.20.0.0/16 tcp dport 9000 accept
}
}
Проверяйте конфигурацию осторожно: правило с политикой drop может оборвать SSH-сеанс. Сначала разрешите административный адрес, добавьте временный таймер отката и проверьте входящий и исходящий трафик с двух независимых узлов.
Шлюз не видит PID и имя функции, которая вызвала connect(). TLS скрывает HTTP-метод, путь и тело запроса. Для контроля прикладных действий нужен reverse proxy с завершением TLS, API gateway или проверка внутри сервиса. Для сетевого уровня пригодится практический разбор блокировки IP и подсетей с примерами для iptables, nftables, Nginx и fail2ban.
Почему одного base_url недостаточно
base_url задает предполагаемый адрес сервиса, но не описывает весь сетевой путь. DNS может вернуть несколько A и AAAA-записей, клиент может выбрать IPv6, прокси может изменить маршрут, а библиотека может повторить запрос на другом адресе. Сервис может использовать отдельный endpoint для метрик, DNS, очереди, базы данных или health-check.
Проверяйте фактические адреса непосредственно из среды запуска:
import socket
for item in socket.getaddrinfo('api.internal', 443, type=socket.SOCK_STREAM):
family, socktype, proto, canonname, address = item
print(family, address)
В Go аналогичный поиск выполняет net.LookupIP, а фактический сетевой клиент может использовать собственный DialContext, прокси и пул соединений. Поэтому запись в конфигурации приложения нужно сопоставлять с DNS-ответом, маршрутами, переменными прокси и логами библиотеки.
Для диагностики используйте три слоя:
- Приложение: фрейминг, аутентификация, список разрешенных действий, лимит размера, тайм-аут и журнал.
- Хост: слушающий процесс, лимит дескрипторов, исходящие правила, process tree и наблюдение eBPF или ETW.
- Сеть: DNS, маршрут, NAT, firewall, балансировщик, TLS-прокси и доступность каждой пары адресов и портов.
Логи нужно сопоставлять по времени, IP, порту и идентификатору запроса. Для Nginx и Apache пригодится практический анализ логов с командами grep и awk: сначала найдите код ответа и задержку, затем сравните запись proxy с журналом приложения и состоянием сокета.
Короткий рабочий алгоритм выглядит так:
- Проверить, слушает ли процесс нужный IP, порт и семейство адресов.
- Проверить маршрут и DNS именно на том хосте, где работает клиент.
- Разделить TCP-сбой, UDP-потерю, ошибку TLS и отказ прикладной авторизации.
- Снять трассировку на хосте или шлюзе и сопоставить ее с логом приложения.
- Задать тайм-ауты, лимиты соединений и правила повторов до запуска под нагрузкой.
Такой подход показывает, где возник сбой: в системном вызове, конкурентной модели Python или Go, прикладном протоколе, процессе на хосте или сетевом фильтре.