Вы замечали, как простые вопросы иногда приводят к сложным вопросам? Сегодня мы попытаемся подступиться к одному из таких вопросов. Категория — наша любимая: сетевые аспекты Linux.
Когда два TCP-сокета могут разделять локальный адрес?
Если я перейду по ссылке 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, порт). Можно было бы банально создать много сокетов, но проще было бы подкрутить конфигурацию системы и предположить, что на машине есть всего один локальный эфемерный порт, который ОС может присваивать сокетам:
sysctl -w net.ipv4.ip_local_port_range='60000 60000'
В каждом вопросе-загадке вас ждёт краткий листинг на Python. Ваша задача — спрогнозировать, каков будет итог выполнения кода. Выполнится ли код успешно? Или нет? Спрашивать ChatGPT не разрешается ????.
Всегда есть процедура настройки кода, о которой просто нужно помнить. Для краткости мы исключим её из наших фрагментов кода:
from os import system
from socket import *
# Недостающие константы
IP_BIND_ADDRESS_NO_PORT = 24
# В пространстве имён нашей сети всего *один* эфемерный порт
system("sysctl -w net.ipv4.ip_local_port_range='60000 60000'")
# Открываем слушающий сокет по *:1234. Именно к нему будем подключаться.
ln = socket(AF_INET, SOCK_STREAM)
ln.bind(("", 1234))
ln.listen(SOMAXCONN)
Избавившись от этих формальностей, начнём. На старт, внимание, марш!
Сценарий #1: Когда локальный IP уникален, но локальный порт не меняется
В сценарии #1 мы подключаем два сокета к одному и тому же удалённому адресу: 127.9.9.9:1234. Эти сокеты будут использовать отличающиеся локальные IP-адреса, но достаточно ли этого для разделения локального порта?
Локальный IP |
Локальный порт |
Удалённый IP |
Удалённый порт |
уникальный |
одинаковый |
одинаковый |
Одинаковый |
127.0.0.1 |
60_000 |
127.9.9.9 |
1234 |
Загадка #1
На стороне локальной машины привязываем два сокета к разным, явно указанным IP-адресам. Позволяем ОС выбирать локальный порт. Помните: в диапазоне локальных эфемерных портов на нашей машине содержится всего один порт (60 000).
s1 = socket(AF_INET, SOCK_STREAM)
s1.bind(('127.1.1.1', 0))
s1.connect(('127.9.9.9', 1234))
s1.getsockname(), s1.getpeername()
s2 = socket(AF_INET, SOCK_STREAM)
s2.bind(('127.2.2.2', 0))
s2.connect(('127.9.9.9', 1234))
s2.getsockname(), s2.getpeername()
А ВОТ Ответ #1
#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python
"""
Quiz #1
-------
>>> s1 = socket(AF_INET, SOCK_STREAM)
>>> s1.bind(('127.1.1.1', 0))
>>> s1.connect(('127.9.9.9', 1234))
>>> s1.getsockname(), s1.getpeername()
(('127.1.1.1', 60000), ('127.9.9.9', 1234))
>>>
>>> s2 = socket(AF_INET, SOCK_STREAM)
>>> s2.bind(('127.2.2.2', 0))
>>> s2.connect(('127.9.9.9', 1234))
>>> s2.getsockname(), s2.getpeername()
(('127.2.2.2', 60000), ('127.9.9.9', 1234))
>>>
Outcome: SUCCESS. Local port is shared.
Cleanup:
>>> s1.close()
>>> s2.close()
"""
from quiz_common import run_doctest
from socket import *
if __name__ == "__main__":
run_doctest(__name__)
Загадка #2
Здесь постановка такая же, как и в предыдущей загадке. Однако мы запрашиваем ОС выбрать локальный IP-адрес и порт для первого сокета. Как вы думаете, результат будет отличаться от полученного в предыдущей загадке?
s1 = socket(AF_INET, SOCK_STREAM)
s1.connect(('127.9.9.9', 1234))
s1.getsockname(), s1.getpeername()
s2 = socket(AF_INET, SOCK_STREAM)
s2.bind(('127.2.2.2', 0))
s2.connect(('127.9.9.9', 1234))
s2.getsockname(), s2.getpeername()
А ВОТ Ответ #2.
#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python
"""
Quiz #2
-------
>>> s1 = socket(AF_INET, SOCK_STREAM)
>>> s1.connect(('127.9.9.9', 1234))
>>> s1.getsockname(), s1.getpeername()
(('127.0.0.1', 60000), ('127.9.9.9', 1234))
>>>
>>> s2 = socket(AF_INET, SOCK_STREAM)
>>> s2.bind(('127.2.2.2', 0))
>>> s2.connect(('127.9.9.9', 1234))
>>> s2.getsockname(), s2.getpeername()
(('127.2.2.2', 60000), ('127.9.9.9', 1234))
>>>
Outcome: SUCCESS. Local port is shared.
Cleanup:
>>> s1.close()
>>> s2.close()
"""
from quiz_common import run_doctest
from socket import *
if __name__ == "__main__":
run_doctest(__name__)
Загадка #3
Загадка сформулирована точно, как и предыдущая. Мы просто поменяли порядок. Сначала мы подключаемся к сокету с явно указанного локального адреса. Затем приказываем системе выбрать для нас локальный адрес. Очевидно, при таком изменении порядка действий никаких изменений произойти не должно, верно?
s1 = socket(AF_INET, SOCK_STREAM)
s1.bind(('127.1.1.1', 0))
s1.connect(('127.9.9.9', 1234))
s1.getsockname(), s1.getpeername()
s2 = socket(AF_INET, SOCK_STREAM)
s2.connect(('127.9.9.9', 1234))
s2.getsockname(), s2.getpeername()
А ВОТ Ответ #3.
#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python
"""
Quiz #3
-------
>>> s1 = socket(AF_INET, SOCK_STREAM)
>>> s1.bind(('127.1.1.1', 0))
>>> s1.connect(('127.9.9.9', 1234))
>>> s1.getsockname(), s1.getpeername()
(('127.1.1.1', 60000), ('127.9.9.9', 1234))
>>> s2 = socket(AF_INET, SOCK_STREAM)
>>> s2.connect(('127.9.9.9', 1234))
Traceback (most recent call last):
...
OSError: [Errno 99] Cannot assign requested address
Outcome: FAILURE. Local port can't be shared.
Cleanup:
>>> p1, _ = ln.accept()
>>> p1.close() # TIME-WAIT on server side
>>> s1.close()
>>> s2.close()
Solution #3
-----------
Use `IP_BIND_ADDRESS_NO_PORT` socket option.
>>> s1 = socket(AF_INET, SOCK_STREAM)
>>> s1.setsockopt(SOL_IP, IP_BIND_ADDRESS_NO_PORT, 1)
>>> s1.bind(('127.1.1.1', 0))
>>> s1.connect(('127.9.9.9', 1234))
>>> s1.getsockname(), s1.getpeername()
(('127.1.1.1', 60000), ('127.9.9.9', 1234))
>>>
>>> s2 = socket(AF_INET, SOCK_STREAM)
>>> s2.connect(('127.9.9.9', 1234))
>>> s2.getsockname(), s2.getpeername()
(('127.0.0.1', 60000), ('127.9.9.9', 1234))
>>>
Outcome: SUCCESS. Local port is shared when using `IP_BIND_ADDRESS_NO_PORT` option.
Cleanup:
>>> s1.close()
>>> s2.close()
"""
from quiz_common import run_doctest
from socket import *
if __name__ == "__main__":
run_doctest(__name__)
Сценарий #2: когда локальный IP и порт одинаковы, но удалённый IP отличается
В сценарии #2 рассмотрим обратную постановку. Теперь у нас будет не множество локальных IP и один удалённый адрес, а один локальный адрес 127.0.0.1:60000
и два разных удалённых адреса. Всё тот же вопрос: могут ли два сокета совместно использовать локальный порт? Напоминаю: диапазон эфемерных портов у нас по-прежнему равен единице.
Локальный IP |
Локальный порт |
Удалённый IP |
Удалённый порт |
одинаковый |
одинаковый |
уникальный |
одинаковый |
127.0.0.1 |
60_000 |
127.8.8.8 |
1234 |
Загадка #4
Начнём с азов. Мы подключаемся (connect()) к двум разным удалённым адресам. Это для затравки ????
s1 = socket(AF_INET, SOCK_STREAM)
s1.connect(('127.8.8.8', 1234))
s1.getsockname(), s1.getpeername()
s2 = socket(AF_INET, SOCK_STREAM)
s2.connect(('127.9.9.9', 1234))
s2.getsockname(), s2.getpeername()
А ВОТ Ответ #4.
#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python
"""
Quiz #4
-------
>>> s1 = socket(AF_INET, SOCK_STREAM)
>>> s1.connect(('127.8.8.8', 1234))
>>> s1.getsockname(), s1.getpeername()
(('127.0.0.1', 60000), ('127.8.8.8', 1234))
>>>
>>> s2 = socket(AF_INET, SOCK_STREAM)
>>> s2.connect(('127.9.9.9', 1234))
>>> s2.getsockname(), s2.getpeername()
(('127.0.0.1', 60000), ('127.9.9.9', 1234))
>>>
Outcome: SUCCESS. Local (IP, port) is shared.
Cleanup:
>>> s1.close()
>>> s2.close()
"""
from quiz_common import run_doctest
from socket import *
if __name__ == "__main__":
run_doctest(__name__)
Загадка #5
Что если явно связаться (bind()
) с локальным IP, но позволить ОС самой выбрать порт — изменится ли от этого что-либо?
s1 = socket(AF_INET, SOCK_STREAM)
s1.bind(('127.0.0.1', 0))
s1.connect(('127.8.8.8', 1234))
s1.getsockname(), s1.getpeername()
s2 = socket(AF_INET, SOCK_STREAM)
s2.bind(('127.0.0.1', 0))
s2.connect(('127.9.9.9', 1234))
s2.getsockname(), s2.getpeername()
А ВОТ Ответ #5.
#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python
"""
Quiz #5
-------
>>> s1 = socket(AF_INET, SOCK_STREAM)
>>> s1.bind(('127.0.0.1', 0))
>>> s1.connect(('127.8.8.8', 1234))
>>> s1.getsockname(), s1.getpeername()
(('127.0.0.1', 60000), ('127.8.8.8', 1234))
>>>
>>> s2 = socket(AF_INET, SOCK_STREAM)
>>> s2.bind(('127.0.0.1', 0))
Traceback (most recent call last):
...
OSError: [Errno 98] Address already in use
>>>
Outcome: FAILURE. Local (IP, port) can't be shared.
Cleanup:
>>> p1, _ = ln.accept()
>>> p1.close() # TIME-WAIT on server side
>>> s1.close()
>>> s2.close()
Solution #5
-----------
Use `IP_BIND_ADDRESS_NO_PORT` socket option.
>>> s1 = socket(AF_INET, SOCK_STREAM)
>>> s1.setsockopt(SOL_IP, IP_BIND_ADDRESS_NO_PORT, 1)
>>> s1.bind(('127.0.0.1', 0))
>>> s1.connect(('127.8.8.8', 1234))
>>> s1.getsockname(), s1.getpeername()
(('127.0.0.1', 60000), ('127.8.8.8', 1234))
>>>
>>> s2 = socket(AF_INET, SOCK_STREAM)
>>> s2.setsockopt(SOL_IP, IP_BIND_ADDRESS_NO_PORT, 1)
>>> s2.bind(('127.0.0.1', 0))
>>> s2.connect(('127.9.9.9', 1234))
>>> s2.getsockname(), s2.getpeername()
(('127.0.0.1', 60000), ('127.9.9.9', 1234))
>>>
Outcome: SUCCESS. Local (IP, port) is shared when using `IP_BIND_ADDRESS_NO_PORT`.
Cleanup:
>>> s1.close()
>>> s2.close()
"""
from quiz_common import run_doctest
from socket import *
if __name__ == "__main__":
run_doctest(__name__)
Загадка #6
На этот раз мы явно укажем локальный адрес и порт. Иногда необходимо указывать локальный порт.
s1 = socket(AF_INET, SOCK_STREAM)
s1.bind(('127.0.0.1', 60_000))
s1.connect(('127.8.8.8', 1234))
s1.getsockname(), s1.getpeername()
s2 = socket(AF_INET, SOCK_STREAM)
s2.bind(('127.0.0.1', 60_000))
s2.connect(('127.9.9.9', 1234))
s2.getsockname(), s2.getpeername()
А ВОТ Ответ #6.
#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python
"""
Quiz #6
-------
>>> s1 = socket(AF_INET, SOCK_STREAM)
>>> s1.bind(('127.0.0.1', 60_000))
>>> s1.connect(('127.8.8.8', 1234))
>>> s1.getsockname(), s1.getpeername()
(('127.0.0.1', 60000), ('127.8.8.8', 1234))
>>>
>>> s2 = socket(AF_INET, SOCK_STREAM)
>>> s2.bind(('127.0.0.1', 60_000))
Traceback (most recent call last):
...
OSError: [Errno 98] Address already in use
>>>
Outcome: FAILURE. Local (IP, port) can't be shared.
Cleanup:
>>> p1, _ = ln.accept()
>>> p1.close() # TIME-WAIT on server side
>>> s1.close()
>>> s2.close()
>>>
Solution #6
-----------
Use `SO_REUSEADDR` socket option.
>>> s1 = socket(AF_INET, SOCK_STREAM)
>>> s1.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
>>> s1.bind(('127.0.0.1', 60_000))
>>> s1.connect(('127.8.8.8', 1234))
>>> s1.getsockname(), s1.getpeername()
(('127.0.0.1', 60000), ('127.8.8.8', 1234))
>>>
>>> s2 = socket(AF_INET, SOCK_STREAM)
>>> s2.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
>>> s2.bind(('127.0.0.1', 60_000))
>>> s2.connect(('127.9.9.9', 1234))
>>> s2.getsockname(), s2.getpeername()
(('127.0.0.1', 60000), ('127.9.9.9', 1234))
>>>
Outcome: SUCCESS. Local (IP, port) is shared when using `SO_REUSEADDR`.
Cleanup:
>>> s1.close()
>>> s2.close()
"""
from quiz_common import run_doctest
from socket import *
if __name__ == "__main__":
run_doctest(__name__)
Загадка #7
На случай, если вы полагали, что страннее быть уже не может — давайте добавим в эту смесь SO_REUSEADDR.
Сперва запрашиваем ОС, чтобы она выделила для нас локальный адрес. Затем явно свяжемся с тем же локальным адресом, который, как мы уже знаем, ОС обязана была присвоить первому сокету. Мы включим возможность переиспользовать локальный адрес для обоих сокетов. Это разрешено?
s1 = socket(AF_INET, SOCK_STREAM)
s1.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
s1.connect(('127.8.8.8', 1234))
s1.getsockname(), s1.getpeername()
s2 = socket(AF_INET, SOCK_STREAM)
s2.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
s2.bind(('127.0.0.1', 60_000))
s2.connect(('127.9.9.9', 1234))
s2.getsockname(), s2.getpeername()
А ВОТ Ответ #7.
#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python
"""
Quiz #7
-------
>>> s1 = socket(AF_INET, SOCK_STREAM)
>>> s1.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
>>> s1.connect(('127.8.8.8', 1234))
>>> s1.getsockname(), s1.getpeername()
(('127.0.0.1', 60000), ('127.8.8.8', 1234))
>>>
>>> s2 = socket(AF_INET, SOCK_STREAM)
>>> s2.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
>>> s2.bind(('127.0.0.1', 60_000))
>>> s2.connect(('127.9.9.9', 1234))
>>> s2.getsockname(), s2.getpeername()
(('127.0.0.1', 60000), ('127.9.9.9', 1234))
>>>
Outcome: SUCCESS. Local (IP, port) is shared.
Cleanup:
>>> s1.close()
>>> s2.close()
"""
from quiz_common import run_doctest
from socket import *
if __name__ == "__main__":
run_doctest(__name__)
Загадка #8
Наконец, вишенка на торте. Эта загадка — точно как и #7, но наоборот. Здравый смысл не оставляет сомнений, что ответ должен быть точно как в #7, но так ли это?
s1 = socket(AF_INET, SOCK_STREAM)
s1.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
s1.bind(('127.0.0.1', 60_000))
s1.connect(('127.9.9.9', 1234))
s1.getsockname(), s1.getpeername()
s2 = socket(AF_INET, SOCK_STREAM)
s2.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
s2.connect(('127.8.8.8', 1234))
s2.getsockname(), s2.getpeername()
А ВОТ Ответ #8.
#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python
"""
Quiz #8
-------
>>> s1 = socket(AF_INET, SOCK_STREAM)
>>> s1.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
>>> s1.bind(('127.0.0.1', 60_000))
>>> s1.connect(('127.9.9.9', 1234))
>>> s1.getsockname(), s1.getpeername()
(('127.0.0.1', 60000), ('127.9.9.9', 1234))
>>>
>>> s2 = socket(AF_INET, SOCK_STREAM)
>>> s2.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
>>> s2.connect(('127.8.8.8', 1234))
Traceback (most recent call last):
...
OSError: [Errno 99] Cannot assign requested address
>>>
Outcome: FAILURE. Local (IP, port) can't be shared.
Cleanup:
>>> s1.close()
>>> s2.close()
Solution #8
-----------
There is NONE.
"""
from quiz_common import run_doctest
from socket import *
if __name__ == "__main__":
run_doctest(__name__)
Тайная жизнь локального TCP-порта в трёх состояниях
Сейчас всё понятно? Мы как будто занимаемся обратной разработкой чёрного ящика. Пожалуй, ничего не понятно, как и должно быть при обратной разработке чёрного ящика. Так что же происходит за кулисами? Давайте туда заглянем.
Linux отслеживает все находящиеся в использовании TCP-порты в хеш-таблице, которая называется bhash. Не перепутайте её с таблицей ehash; в ней отслеживаются сокеты, которым уже присвоены как локальные, так и удалённые адреса.
Каждая запись в хеш-таблице указывает на цепочку так называемых «корзин привязки» (bind bucket). В такой корзине группируются сокеты, совместно использующие один локальный порт. Точнее говоря, сокеты группируются в корзины по следующим признакам:
Сетевое пространство имён, к которым они относятся и
Устройство VRF, с которым они связаны, и
Номер локального порта, с которым они связаны.
Но простейшая возможная конфигурация — всего одно сетевое пространство имён, никаких VRF; можно сказать, что сокеты сгруппированы в корзине привязки по соответствующему им локальному номеру порта.
Набор сокетов в каждой корзине привязки, совместно использующих локальный порт, подкрепляется связным списком именованных обладателей.
Когда мы приказываем ядру присвоить локальный адрес сокету, наша задача
— проверить, не возникает ли конфликт с каким-либо существующим сокетом. Дело в
том, что разделять номер локального порта можно лишь при соблюдении определённых условий:
/* Есть несколько простых правил, следуя которым можно переиспользовать локальный порт
* в приложении. В сущности:
*
* 1) Сокеты, связанные с разными интерфейсами, могут совместно использовать локальный порт
* Если это условие не выполняется – переход к тесту 2.
* 2) Если у всех сокетов есть набор sk->sk_reuse, и ни один из них не находится в состоянии
* TCP_LISTEN, допускается совместное использование порта.
* Если это условие не выполняется – переход к тесту 3.
* 3) Если все сокеты связаны с конкретным локальным адресом inet_sk(sk)->rcv_saddr, и
* одинаковых среди них нет, то допускается совместное использование порта
* Если это условие не выполняется, то порт совместно использовать нельзя.
*
* Здесь наиболее интересен тест #2. Именно этим весь день и занимается FTP-сервер.
* Чтобы оптимизировать этот случай, мы используем конкретный флаговый бит, определённый ниже.
* Добавляя сокеты в список корзины привязки, мы проверяем:
* (newsk->sk_reuse && (newsk->sk_state != TCP_LISTEN))
* В случае, если все сокеты из корзины привязки пройдут этот тест,
* будет установлен флаговый бит.
* ...
*/
Как следует из вышеприведённого комментария, ядро оптимизирует код в расчёте на оптимистичный случай: конфликта нет. По окончании манипуляций корзина привязки содержит дополнительное состояние, в котором агрегированы свойства сокетов, содержащихся в этой корзине:
struct inet_bind_bucket {
/* ... */
signed char fastreuse;
signed char fastreuseport;
kuid_t fastuid;
#if IS_ENABLED(CONFIG_IPV6)
struct in6_addr fast_v6_rcv_saddr;
#endif
__be32 fast_rcv_saddr;
unsigned short fast_sk_family;
bool fast_ipv6_only;
/* ... */
};
Давайте обратим внимание только на первое совокупное свойство — 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 |
Сокеты, подключённые ( |
0 |
Сокеты, привязанные без |
+1 |
Сокеты, привязанные с |
Это, конечно, не вся, но почти вся правда. Вскоре мы доберёмся до сути.
Когда же дело доходит до совместного использования портов, ситуация получается гораздо менее прямолинейной:
Можно ли, … когда … |
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 можно проверить состояние корзины привязки прямо в действующем ядре:
#!/usr/bin/env drgn
"""
dump_bhash.py - List all TCP bind buckets in the current netns.
Script is not aware of VRF.
"""
import os
from drgn.helpers.linux.list import hlist_for_each, hlist_for_each_entry
from drgn.helpers.linux.net import get_net_ns_by_fd
from drgn.helpers.linux.pid import find_task
def dump_bind_bucket(head, net):
for tb in hlist_for_each_entry("struct inet_bind_bucket", head, "node"):
# Пропустить корзины не из этой сети
if tb.ib_net.net != net:
continue
port = tb.port.value_()
fastreuse = tb.fastreuse.value_()
owners_len = len(list(hlist_for_each(tb.owners)))
print(
"{:8d} {:{sign}9d} {:7d}".format(
port,
fastreuse,
owners_len,
sign="+" if fastreuse != 0 else " ",
)
)
def get_netns():
pid = os.getpid()
task = find_task(prog, pid)
with open(f"/proc/{pid}/ns/net") as f:
return get_net_ns_by_fd(task, f.fileno())
def main():
print("{:8} {:9} {:7}".format("TCP-PORT", "FASTREUSE", "#OWNERS"))
tcp_hashinfo = prog.object("tcp_hashinfo")
net = get_netns()
# Перебрать все слоты bhash
for i in range(0, tcp_hashinfo.bhash_size):
head = tcp_hashinfo.bhash[i].chain
# Перебрать корзины привязки в слоте
dump_bind_bucket(head, net)
main()
Давайте испробуем этот скрипт и попробуем подтвердить вещи, подаваемые в таблице 1 как истинные. Учитывайте, что для получения приведённых ниже сеансовых сниппетов ipython --classic я пользовался той же конфигурацией, которая делалась для ответов на загадки.
Два подключённых сокета совместно используют эфемерный порт 60 000:
>>> s1 = socket(AF_INET, SOCK_STREAM)
>>> s1.connect(('127.1.1.1', 1234))
>>> s2 = socket(AF_INET, SOCK_STREAM)
>>> s2.connect(('127.2.2.2', 1234))
>>> !./dump_bhash.py
TCP-PORT FASTREUSE #OWNERS
1234 0 3
60000 -1 2
>>>
Два привязанных сокета повторно используют порт 60 000:
>>> s1 = socket(AF_INET, SOCK_STREAM)
>>> s1.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
>>> s1.bind(('127.1.1.1', 60_000))
>>> s2 = socket(AF_INET, SOCK_STREAM)
>>> s2.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
>>> s2.bind(('127.1.1.1', 60_000))
>>> !./dump_bhash.py
TCP-PORT FASTREUSE #OWNERS
1234 0 1
60000 +1 2
>>>
Смесь привязанных сокетов с активированной и активированной опцией REUSEADDR совместно использует порт 60 000:
>>> s1 = socket(AF_INET, SOCK_STREAM)
>>> s1.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
>>> s1.bind(('127.1.1.1', 60_000))
>>> !./dump_bhash.py
TCP-PORT FASTREUSE #OWNERS
1234 0 1
60000 +1 1
>>> s2 = socket(AF_INET, SOCK_STREAM)
>>> s2.bind(('127.2.2.2', 60_000))
>>> !./dump_bhash.py
TCP-PORT FASTREUSE #OWNERS
1234 0 1
60000 0 2
>>>
Если работать с такими инструментами, то чтобы доказать истинность информации, содержащейся в таблице 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
на настоящий момент, в сущности, не работает. Но это уже другая история.
До новых встреч ????.
Комментарии (6)
easyman
00.00.0000 00:00+3Напоминает мне вот это:
https://blog.cloudflare.com/the-quantum-state-of-a-tcp-port/
Sivchenko_translate Автор
00.00.0000 00:00+2Да, это перевод, добавил ссылку и автора. Поскольку в этом блоге только переводы, сразу забыл отметить.
aamonster
00.00.0000 00:00+1Ник явно показывает, что не по злому умыслу :-)
Sivchenko_translate Автор
00.00.0000 00:00+1https://habr.com/ru/users/olegsivchenko/posts/ заходите при случае, тут гораздо интереснее
atd
00.00.0000 00:00+7На самом деле, статья и яйца выеденного не стоит, если вспомнить, что TCP-соединение задаётся четвёркой (src ip, src port, dst ip, dst port). На что, собственно, и указали в комментариях к оригинальной статье
Apoheliy
Только непонятно, где тут C?