Pull to refresh

UDP и проблема доставки ответа

Reading time 6 min
Views 45K
Original author: Matt Palmer
image
Ниже — перевод статьи о проблеме работы с udp в сетевых приложениях. Переводчик позволил себе сменить примеры: в исходном тексте другие сетевые адреса и код на ruby. В переводе использован простенький скрипт на перле. Суть проблемы и решение от этого не меняются.
Кроме того, местами добавлены мои комментарии (в скобках, выделены курсивом).
Картинка для привлечения внимания взята из текста замечательной книги «learnyousomeerlang.com»

Тяжкая работа лёгких протоколов



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

Для примера разберём ситуацию с получением ответа, когда UDP датаграмма с начальным запросом посылается на дополнительный IP адрес на интерфейсе (alias или secondary IP).
Есть интерфейс eth1:
$ ip a add 192.168.1.235/24 dev eth1 && ip a ls dev eth1       
2: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP qlen 1000
    link/ether 00:30:84:9e:95:60 brd ff:ff:ff:ff:ff:ff
    inet 192.168.1.47/24 brd 192.168.1.255 scope global eth1
    inet 192.168.1.235/24 scope global secondary eth1
    inet6 fe80::230:84ff:fe9e:9560/64 scope link 
       valid_lft forever preferred_lft forever


Как обычно выглядит код для получения пакета по udp? Ну, echo сервер может выглядеть как-то очень похоже на то, что под катом:
echo_server.pl
#!/usr/bin/perl
use IO::Socket::INET;

# flush after every write
$| = 1;

my ($socket,$received_data);
my ($peeraddress,$peerport);

$socket = new IO::Socket::INET ( 
    MultiHomed => '1',
    LocalAddr => $ARGV[0],
    LocalPort => defined ($ARGV[1])?$ARGV[1]:'5000',
    Proto => 'udp'
) or die "ERROR in Socket Creation : $! \n";
print "Waiting for data...";
while(1)
{
$socket->recv($recieved_data,1024);
$peer_address = $socket->peerhost();
$peer_port = $socket->peerport();
chomp($recieved_data);
print "\n($peer_address , $peer_port) said : $recieved_data";

#send the data to the client at which the read/write operations done recently.
$data = "echo: $recieved_data\n";
$socket->send("$data");

}

$socket->close();



Это достаточно простой скрипт на перле, который покажет от кого пришёл udp пакет, содержимое пакета и отправит этот пакет обратно отправителю. Проще некуда. Теперь запустим наш сервер:
$ ./echo_server.pl
Waiting for data...

Посмотрим, что он слушает:
$ netstat -unpl | grep perl
udp        0      0 0.0.0.0:5000            0.0.0.0:*                           9509/perl

И после этого подключимся с удалённой машины к нашему серверу по основному IP:
-bash-3.2$ nc -u 192.168.1.47 5000
test1
echo: test1
test2
echo: test2

Как это выглядит в tcpdump'е на нашей машине (ну, или должно выглядеть):
-bash-3.2$ tcpdump -i eth1 -nn  port 5000
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth1, link-type EN10MB (Ethernet), capture size 96 bytes
17:41:00.517186 IP 192.168.3.11.44199 > 192.168.1.47.5000: UDP, length 6
17:41:00.517351 IP 192.168.1.47.5000 > 192.168.3.11.44199: UDP, length 12
17:41:02.307634 IP 192.168.3.11.44199 > 192.168.1.47.5000: UDP, length 6
17:41:02.307773 IP 192.168.1.47.5000 > 192.168.3.11.44199: UDP, length 12

Просто фантастика — я отправляю пакет и получаю пакет обратно. В netcat'е мы получаем обратно что бы мы не напечатали (смешной эффект, если печатать «стрелочки»).

А теперь тоже самое на вторичный адрес на том же интерфейсе:
-bash-3.2$ nc -u 192.168.1.235 5000
test1
test2

Как это сумасшествие выглядит в tcpdump'е на этот раз:
-bash-3.2$ tcpdump -i eth1 -nn  port 5000
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth1, link-type EN10MB (Ethernet), capture size 96 bytes
17:48:32.467167 IP 192.168.3.11.34509 > 192.168.1.235.5000: UDP, length 6
17:48:32.467292 IP 192.168.1.47.5000 > 192.168.3.11.34509: UDP, length 12
17:48:33.667182 IP 192.168.3.11.34509 > 192.168.1.235.5000: UDP, length 6
17:48:33.667332 IP 192.168.1.47.5000 > 192.168.3.11.34509: UDP, length 12


Ну и естественно, никакой уважающий себя сетевой стек не собирается принимать пакеты с совершенно незнакомого адреса, даже если порты стоят правильные. Таким образом, клиент никогда не получит обратные пакеты и будет думать, что сервер просто отбрасывает его запросы.

То что происходит, на первый взгляд кажется полным бредом. Но на самом деле, это обычный дефект для протокола без установки сессии, такого как UDP. Видите ли, наш сокет слушает любой адрес, (пустой параметр LocalAddr при создании сокета передаётся системе как адрес вида «0.0.0.0», любой доступный, что заставляет сокет слушать на всех доступных адресах. И нет, я тоже не знаю, почему это так. Это не особенно интуитивное действие). Когда мы получаем пакет в нашем приложении с помощью socket->recv(), мы не получаем информации о том, на какой конкретно адрес был послан пакет. Мы лишь знаем, что операционная система решила, что пакет был для нас (вот вам и инкапсуляция). Всё что мы знаем, это откуда пакет пришёл к нам. И из-за того, что ядро не хранит никакой информации о соединениях для сокета (ядро штука логичная, просили без соединений — будет без соединений), когда приходит время отправлять пакет обратно, всё что мы можем сообщить это «куда» отправить пакет. (В перле это делается в неявном виде. С объектом $socket связаны адрес и порт отправителя датаграммы, так что в вызове send его указывать не надо).
Но настоящая мозголомка начинается, когда мы пытаемся проставить адрес отправителя в ответной датаграмме. Ещё раз: ядро не хранит никакой информации об отправителе или получателе, так как мы работаем без соединений. И посколько мы слушаем «любой» интерфейс, операционная система думает, что у неё есть карт-бланш на отправку пакета с того адреса, который ей «по душе». В линуксе, похоже, выбирается основной адрес того интерфейса, с которого пакет будет отправлен. (А на самом деле, адрес определяется в соответсвии с RFC1122, 3.3.4.2 «Multihoming Requirements», по таблице маршрутизации — примечание переводчика). Так что для распространнёного случая «один адрес — один интерфейс» — всё работает. Но как только дело доходит до менее распространённых ситуаций, начинают проявляться нюансы.
Решение в том, чтобы создавать сокеты слушающие конкретные адреса. И отправлять пакет с этих сокетов: ядро будет знать, с какого адреса вы хотите отправлять пакеты и всё будет отлично. Выглядит достаточно просто, ага? Ну и естественно, любое вменяемое сетевое приложение уже так делает, ага? Так что очевидно, что реализация UDP в Ruby просто дерьмо(в оригинале исходники на руби, — примечание переводчика;). Именно так я подумал в начале, и не виню вас, если вы подумали так же. Но пока РУБИкон войны с авторами Ruby'ового UDPSocket'а не перейдён, давайте проведём небольшой эксперимент с другими часто используемыми приложениями. Например, SNMPd. Демон из пакета net-snmpd в убунте подвержен той же проблеме, что и наше тестовое приложение выше. Не похоже, что это какие-то новые грабли, на которые только наступили и рассыпались кучей патчей для испрвалений.
Так что в целом, все страдают одной и той же «болезнью». Под «всеми» подразумевается «некоторые UDP сервера». Есть некоторое количество ПО, которое не подвержено подобной проблеме с алиасами на интерфейсах. На ум сразу приходит Bind и NTPd работает нормально, если запущен после того, как вы сконфигурировали все интерфейсы. В чём же разница? Разница в том, что эти сервисы несколько «умнее» и биндятся на все адреса в системе по отдельности. На примере bind'а:
$ netstat -lun |grep :53
 udp        0      0 192.168.1.47:53          0.0.0.0:*                          
 udp        0      0 192.168.1.47:53          0.0.0.0:*                          
 udp        0      0 127.0.0.1:53             0.0.0.0:*  

Это очень круто и решает проблему. Исключение составляет ситуация, когда вы добавляете лишний алиас уже после того, как демон стартовал. Bind не подцепит новый адрес и вам придётся рестартовать сервер. Кроме того, это несколько усложняет код, так как вам приходится как ладить с кучей сокетов внутри программы (например, использовать select() вместо простого блокирования на попытке приёма.) В общем-то, никто не любит лишних сложностей, но с этой можно справится. Однако, настоящая проблема это правило «не добавляйте адресов после старта демона». Необходимость проверять, не добавилось ли в системе ip-адресов, и рестартовать службу после добавляения адреса станет настоящей проблемой.
Однако, есть некоторый workaround и для этой проблемы. Здесь мы вспомним про ntpd. Порты, которые слушает он, выглядят следующим образом:
$ netstat -nlup | grep 123
udp        0      0 192.168.1.235:123       0.0.0.0:*                                    
udp        0      0 192.168.1.47:123        0.0.0.0:*                                          
udp        0      0 127.0.0.3:123           0.0.0.0:*                                         
udp        0      0 127.0.0.1:123           0.0.0.0:*                                          
udp        0      0 0.0.0.0:123             0.0.0.0:* 
udp6       0      0 fe80::230:84ff:fe9e:123 :::*                                              
udp6       0      0 ::1:123  


NTPd слушает каждый адрес в отдельности и дополнительно слушает на любом адресе, доступном системе. Я не знаю точно, зачем это нужно. Если просто слушать на каждом адресе в отдельности, то всё будет хорошо, как в случае с байндом. Но если вы добавляете ещё один адрес на интерфейс после старта ntpd, то начинает проявляться та же самая проблема, что и в случае с udp-echo-сервером. Так что я не думаю, что слушание на «любом» интерфейсе даёт какой либо плюс. Однако, это заставляет ntpd вести себя несколько отлично от Bind'а: когда вы посылаете пакет на интерфейс, добавляенный после старта Bind'а, то он просто игнорирует вас (у него нет сокета, который бы слушал ваши запросы). Ntpd же пытается отправить ответ и страдает от проблемы неправильного адреса в ответах. (Зато можно менять primary адреса на интерфейсах и создавать новые интерфейсы, примечание переводчика).

На текущий момент, лучшим решением кажется следовать пути Bind'а и ntpd и слушать на всех адреса в отдельности с «фокусом» от ntpd: слушать дополнительно и на 0.0.0.0. При этом, если я получил пакет на 0.0.0.0, то надо запускать сканирования доступных в системе адресов и биндится дополнительно и на них. Это должно решить проблему.
Осталось только заставить это работать (и решить кучу проблем, которые наверняка вылезут на пути). Пожелайте мне удачи. Крики боли и мучений которые вы слышите (не имеет значение, где вы находитесь) наверняка мои.

UPD: в комментариях появилось интересное пояснение от Quasar_ru. Всё таки реализация UDP в скриптовых языках неоднозначна: на чистом С можно написать такое клиентское приложение, которое сможет принять ответ от сервера с другого адреса. Польза от такой реализации — спорная, но всё же реализация возможна.
Tags:
Hubs:
+36
Comments 37
Comments Comments 37

Articles