Вы замечали, как простые вопросы иногда приводят к сложным вопросам? Сегодня мы попытаемся подступиться к одному из таких вопросов. Категория — наша любимая: сетевые аспекты Linux.  Если я перейду по ссылке https://blog.cloudflare.com/, мой браузер подключится к удалённому TCP-адресу, в данном случае это может быть 104.16.132.229:443. Подключение пойдёт с локального IP-адреса, присвоенного моей машине с Linux, через выбранный случайным образом локальный TCP-порт, скажем, 192.0.2.42:54321. Что произойдёт, если я затем решу отправиться на другой сайт? Возможно ли установить другое TCP-соединение с того же локального IP-адреса и порта? Чтобы найти ответ на этот вопрос, давайте выясним его опытным путём. Мы подготовили для вас восемь вопросов-задачек. Ответив на каждый из них, вы откроете для себя один из аспектов тех правил, что регулируют разделение локальных адресов между TCP-сокетами под Linux. Честно предупреждаем: от этого материала может зайти ум за разум. Вопросы разделены на две группы в зависимости от тестового сценария: В первом сценарии два сокета подключаются с одного и того же локального порта на одни и те же удалённые IP и порт. Однако локальный IP для обоих сокетов отличается. В свою очередь, во втором сценарии локальный IP и порт одинаковы для всех сокетов, но отличается удалённый адрес или, в сущности, только IP-адрес. Отвечая на поставленные вопросы, мы будем делать одно из двух: 1.      Позволять ОС автоматически выбирать локальный IP и/или порт для сокета или  2.      Явно присваивать локальный адрес при помощи bind(), прежде чем подключиться (connect()) к сокету; такой метод также называется bind-before-connect. Поскольку мы будем исследовать пограничные случаи, связанные с логикой bind(), нам нужно каким-то способом исчерпать все локальные адреса, то есть пары (IP, порт). Можно было бы банально создать много сокетов, но проще было бы подкрутить конфигурацию системы и предположить, что на машине есть всего один локальный эфемерный порт, который ОС может присваивать сокетам:   В каждом вопросе-загадке вас ждёт краткий листинг на Python. Ваша задача — спрогнозировать, каков будет итог выполнения кода. Выполнится ли код успешно? Или нет? Спрашивать ChatGPT не разрешается 😉. Всегда есть процедура настройки кода, о которой просто нужно помнить. Для краткости мы исключим её из наших фрагментов кода: Избавившись от этих формальностей, начнём. На старт, внимание, марш!    В сценарии #1 мы подключаем два сокета к одному и тому же удалённому адресу: 127.9.9.9:1234. Эти сокеты будут использовать отличающиеся локальные IP-адреса, но достаточно ли этого для разделения локального порта?    Локальный IP Локальный порт Удалённый IP Удалённый порт уникальный одинаковый одинаковый Одинаковый 127.0.0.1127.1.1.1127.2.2.2 60_000 127.9.9.9 1234 На стороне локальной машины привязываем два сокета к разным, явно указанным IP-адресам. Позволяем ОС выбирать локальный порт. Помните: в диапазоне локальных эфемерных портов на нашей машине содержится всего один порт (60 000).    А ВОТ Ответ #1    Здесь постановка такая же, как и в предыдущей загадке. Однако мы запрашиваем ОС выбрать локальный IP-адрес и порт для первого сокета. Как вы думаете, результат будет отличаться от полученного в предыдущей загадке?    А ВОТ Ответ #2. Загадка сформулирована точно, как и предыдущая. Мы просто поменяли порядок. Сначала мы подключаемся к сокету с явно указанного локального адреса. Затем приказываем системе выбрать для нас локальный адрес. Очевидно, при таком изменении порядка действий никаких изменений произойти не должно, верно?    А ВОТ Ответ #3.   В сценарии #2 рассмотрим обратную постановку. Теперь у нас будет не множество локальных IP и один удалённый адрес, а один локальный адрес 127.0.0.1:60000 и два разных удалённых адреса. Всё тот же вопрос: могут ли два сокета совместно использовать локальный порт? Напоминаю: диапазон эфемерных портов у нас по-прежнему равен единице.    Локальный IP Локальный порт Удалённый IP Удалённый порт одинаковый одинаковый уникальный одинаковый 127.0.0.1 60_000 127.8.8.8127.9.9.9 1234 Начнём с азов. Мы подключаемся (connect()) к двум разным удалённым адресам. Это для затравки 🙂    А ВОТ Ответ #4. Что если явно связаться (bind()) с локальным IP, но позволить ОС самой выбрать порт — изменится ли от этого что-либо?    А ВОТ Ответ #5.    На этот раз мы явно укажем локальный адрес и порт. Иногда необходимо указывать локальный порт.    А ВОТ Ответ #6. На случай, если вы полагали, что страннее быть уже не может — давайте добавим в эту смесь SO_REUSEADDR. Сперва запрашиваем ОС, чтобы она выделила для нас локальный адрес. Затем явно свяжемся с тем же локальным адресом, который, как мы уже знаем, ОС обязана была присвоить первому сокету. Мы включим возможность переиспользовать локальный адрес для обоих сокетов. Это разрешено? А ВОТ Ответ #7. Наконец, вишенка на торте. Эта загадка — точно как и #7, но наоборот. Здравый смысл не оставляет сомнений, что ответ должен быть точно как в #7, но так ли это?    А ВОТ Ответ #8.   Тайная жизнь локального TCP-порта в трёх состояниях  Сейчас всё понятно? Мы как будто занимаемся обратной разработкой чёрного ящика. Пожалуй, ничего не понятно, как и должно быть при обратной разработке чёрного ящика. Так что же происходит за кулисами? Давайте туда заглянем. Linux отслеживает все находящиеся в использовании TCP-порты в хеш-таблице, которая называется bhash. Не перепутайте её с таблицей ehash; в ней отслеживаются сокеты, которым уже присвоены как локальные, так и удалённые адреса. Каждая запись в хеш-таблице указывает на цепочку так называемых «корзин привязки» (bind bucket). В такой корзине группируются сокеты, совместно использующие один локальный порт. Точнее говоря, сокеты группируются в корзины по следующим признакам:  Сетевое пространство имён, к которым они относятся и  Устройство VRF, с которым они связаны, и  Номер локального порта, с которым они связаны. Но простейшая возможная конфигурация — всего одно сетевое пространство имён, никаких VRF; можно сказать, что сокеты сгруппированы в корзине привязки по соответствующему им локальному номеру порта. Набор сокетов в каждой корзине привязки, совместно использующих локальный порт, подкрепляется связным списком именованных обладателей. Когда мы приказываем ядру присвоить локальный адрес сокету, наша задача — проверить, не возникает ли конфликт с каким-либо существующим сокетом. Дело в том, что разделять номер локального порта можно лишь при соблюдении определённых условий: Как следует из вышеприведённого комментария, ядро оптимизирует код в расчёте на оптимистичный случай: конфликта нет. По окончании манипуляций корзина привязки содержит дополнительное состояние, в котором агрегированы свойства сокетов, содержащихся в этой корзине:    Давайте обратим внимание только на первое совокупное свойство — fastreuse. Оно существует в Linux со времён ныне доисторической версии 2.1.90pre1. Как указано в комментарии, исходно в форме флагового бита, а в процессе развития он превратился в поле, размер которого определяется в байтах. Остальные шесть полей появились гораздо позже с введением SO_REUSEPORT в Linux 3.9. Ведь они играют роль только при наличии сокетов, у которых установлен флаг SO_REUSEPORT. В рамках этой статьи мы их проигнорируем. Всякий раз, когда ядру Linux требуется привязать сокет к локальному порту, сначала нужно поискать корзину привязки для этого порта. Нам несколько усложняет жизнь следующий аспект: поиск TCP-корзины привязки в ядре осуществляется в двух точках ядра. Поиск корзины привязки может осуществляться заранее, во времяbind() , или поздно, во время connect(). Которая из этих функций будет вызвана — зависит от того, как именно был настроен подключённый сокет: Однако, оказываясь в inet_csk_get_port или __inet_hash_connect, нам всегда приходится обходить цепочку корзин в bhash, подыскивая корзину с совпадающим номером порта. Такая корзина может уже существовать, либо нам предварительно придётся её создать. Но, когда она возникнет, её поле fastreuse окажется в одном из трёх возможных состояний. Это -1, 0 или +1. Создаётся впечатление, будто Linux-разработчики вдохновлялись квантовой механикой. Состояние отражает два аспекта корзины привязки: 1.      Какие сокеты находятся в корзине? 2.      Когда возможно совместное использование локального порта? Поэтому давайте попробуем расшифровать три возможных состояния fastreuse и посмотрим, что они означают в каждом из случаев. Во-первых, что нам говорит свойство fastreuse о владельцах корзины, то есть о сокетах, использующих данный локальный порт? Значение fastreuse  В списке владельцев содержатся -1 Сокеты, подключённые (connect()) из эфемерного   порта 0 Сокеты, привязанные   без SO_REUSEADDR +1 Сокеты, привязанные с SO_REUSEADDR Это, конечно, не вся, но почти вся правда. Вскоре мы доберёмся до сути. Когда же дело доходит до совместного использования портов, ситуация получается гораздо менее прямолинейной: Можно ли, … когда … fastreuse = -1 fastreuse = 0 fastreuse = +1 Связаться (bind()) с тем же самым портом (эфемерным или   указанным) Да, тогда и только тогда,   когда локальный IP уникален ① ← idem ← idem Связаться (bind()) с конкретным портом при помощи SO_REUSEADDR Да, тогда и только тогда, когда   локальный IP уникален ИЛИ конфликтующий сокет   использует SO_REUSEADDR ① ← idem да ② Подсоединиться (connect()) с того же эфемерного   порта к тому же удалённому (IP, порту) Да, тогда и только тогда,   когда локальный IP уникален ③ нет ③ нет ③ Подсоединиться (connect()) с того же эфемерного   порта к уникальному удалённому (IP, порту) да ③ нет ③ нет ③ ① Определяется в зависимости от inet_csk_bind_conflict(), вызываемой из inet_csk_get_port() (привязка к конкретному порту) или inet_csk_get_port() → inet_csk_find_open_port() (привязка к эфемерному порту). ② Поскольку inet_csk_get_port() пропускает проверку конфликтов для fastreuse == 1 buckets. ③ Поскольку inet_hash_connect() → __inet_hash_connect() пропускает корзины с fastreuse != -1. Притом, что на первый взгляд всё это выглядит довольно сложно, вышеприведённая таблица вполне сводима к двум утверждениям, которые должны быть верными — в таком виде она воспринимается немного проще: bind() или раннее выделение локального адреса всегда проходит успешно при отсутствии каких-либо конфликтов между локальным IP-адресом и каким-либо из существующих сокетов, connect() или позднее выделение локального адреса всегда оканчивается неудачей в случае, если корзина TCP-привязки для локального порта находится в любом состоянии, кроме fastreuse = -1, connect() оканчивается успехом только при отсутствии каких-либо конфликтов между локальными и удалёнными адресами, сокетная опция SO_REUSEADDR обеспечивает совместное использование локальных адресов, если эта опция также используется всеми конфликтующими сокетами (и ни один из них не находится в состоянии слушателя). К счастью, вы и не обязаны. При помощи программируемого отладчика drgn можно проверить состояние корзины привязки прямо в действующем ядре:    Давайте испробуем этот скрипт и попробуем подтвердить вещи, подаваемые в таблице 1 как истинные. Учитывайте, что для получения приведённых ниже сеансовых сниппетов ipython --classic я пользовался той же конфигурацией, которая делалась для ответов на загадки.  Два подключённых сокета совместно используют эфемерный порт 60 000: Два привязанных сокета повторно используют порт 60 000:    Смесь привязанных сокетов с активированной и активированной опцией REUSEADDR совместно использует порт 60 000:    Если работать с такими инструментами, то чтобы доказать истинность информации, содержащейся в таблице 2, требуется всего лишь написать комплект исследовательских тестов.    Но что произошло в последнем сниппете? Корзина привязки явно перешла из одного состояния fastreuse в другое. Именно это упускается в таблице 1. И это означает, что полной картины происходящего мы себе по-прежнему не представляем. Нам ещё предстоит выяснить, когда именно может измениться состояние fastreuse у корзины. Здесь явно требуется конечный автомат. Как мы только что могли убедиться, корзине привязки не требуется на протяжении всего жизненного цикла оставаться в исходном состоянии fastreuse. Добавление сокетов в корзину может спровоцировать изменение состояния. Оказывается, возможен переход только в состояние fastreuse = 0, если нам доведётся привязать (bind()) сокет, который:  1.      не конфликтует с уже имеющимися владельцами и 2.      у него не включена опция SO_REUSEADDR. Притом, что мы могли бы выяснить всё это, внимательно прочитав код в inet_csk_get_port → inet_csk_update_fastreuse, нам определённо не повредило бы подкрепить то, что мы уже поняли, и для этого нужно провести ещё несколько тестов. Теперь, когда мы смогли восстановить полную картину, напрашивается вопрос…  Во-первых, на случай, когда в следующий раз системный вызов bind() отклонит ваш запрос с мотивацией EADDRINUSE, или connect() упрётся, выдав ошибку EADDRNOTAVAIL : чтобы вы знали, что происходит, или, как минимум, обладали инструментарием, позволяющим это выяснить.  Во-вторых, поскольку ранее мы рекламировали, как можно открывать соединения из конкретного набора портов. Такая практика сопряжена с привязкой сокетов, у которых выставлена опция SO_REUSEADDR. Тогда мы ещё не осознавали, что существует пограничный случай, в котором порт не поддаётся совместному использованию обычными сокетами, подключёнными при помощи connect(). Притом, что это погоды не делает, важно понимать потенциальные последствия. Чтобы поправить ситуацию, мы совместно с сообществом Linux надстроили в API ядра новую сокетную опцию, позволяющую пользователю указать диапазон локальных портов. Ожидается, что новая опция будет доступна в готовящейся к выходу новой версии Linux 6.3. Имея такую опцию, мы сможем больше не прибегать к уловкам с bind(). Опять же, так открывается возможность разделять локальный порт между сокетами, подключёнными при помощи обычного connect(). В этой статье был сформулирован относительно прямолинейный вопрос — в каких случаях два TCP-сокета могут совместно использовать локальный адрес? — после чего был проработан путь к ответу. Этот ответ настолько сложен, что в одном предложении его не выразить. Более того, это даже не полный ответ. В конце концов, мы решили проигнорировать фичу SO_REUSEPORT, а также не рассматривали конфликтов, в которые вовлечены слушающие TCP-сокеты. Если из этого исследования и есть простой вывод, то вот он: привязывая сокет при помощи bind(), можно нарваться на коварные последствия. При использовании bind() для выбора исходящего IP-адреса лучше всего сочетать этот выбор с сокетной опцией IP_BIND_ADDRESS_NO_PORT, а присваивание портов оставить ядру. В противном случае можно непреднамеренно перекрыть возможность переиспользования локальных TCP-портов. Прискорбно, что этот совет неприменим к UDP, где IP_BIND_ADDRESS_NO_PORT на настоящий момент, в сущности, не работает. Но это уже другая история.  До новых встреч 🖖.