Найти в Дзене
Linux | Network | DevOps

Как Linux создает сокеты и подсчитывает их

Автор оригинала: Ciro S. Costa Привет! Если у вас уже есть некоторый опыт работы с веб-серверами, то вам наверняка доводилось попадать в классическую ситуацию «адрес уже используется»‬ (EADDRINUSE). В этой статье будут подробно разобраны не только предпосылки, позволяющие судить, случится ли в ближайшей перспективе такая ситуация (для этого достаточно просмотреть список открытых сокетов), но и будет рассказано, как можно прослеживать конкретные пути кода в ядре (где происходит такая проверка). Если вам просто интересно, как именно работает системный вызов socket(2), где именно хранятся все эти сокеты, то обязательно дочитайте эту статью до конца! Сокеты – это конструкции, через которые обеспечивается коммуникация между процессами, работающими на разных машинах, и эта коммуникация происходит по сети, которая для всех этих процессов является базовой. Бывает и так, что сокеты применяются для коммуникации между процессами,
работающими на одном и том же хосте (в таком случае речь идёт о со
Оглавление

Автор оригинала: Ciro S. Costa

Привет!

Если у вас уже есть некоторый опыт работы с веб-серверами, то вам наверняка доводилось попадать в классическую ситуацию «адрес уже используется»‬ (EADDRINUSE).

В этой статье будут подробно разобраны не только предпосылки, позволяющие судить, случится ли в ближайшей перспективе такая ситуация (для этого достаточно просмотреть список открытых сокетов), но и будет рассказано, как можно прослеживать конкретные пути кода в ядре (где происходит такая проверка).

Если вам просто интересно, как именно работает системный вызов socket(2), где именно хранятся все эти сокеты, то обязательно дочитайте эту статью до конца!

❯ В чём суть сокетов?

Сокеты – это конструкции, через которые обеспечивается коммуникация между процессами, работающими на разных машинах, и эта коммуникация происходит по сети, которая для всех этих процессов является базовой. Бывает и так, что сокеты применяются для коммуникации между процессами,
работающими на одном и том же хосте (в таком случае речь идёт о сокетах
Unix).

Очень точная аналогия, иллюстрирующая суть сокетов и по-настоящему меня впечатлившая, приводится в книге Computer Networking: A top-down approach.

В самом общем виде можно представить компьютер как «дом», в котором есть множество дверей.

-2

Здесь каждая дверь — это сокет, и, как только к ней подойдёт клиент, он может «постучать» в неё.

Сразу после стука в дверь (отправка пакета SYN) дом автоматически реагирует на это, выдавая ответ (SYN+ACK), который затем сам заверяет (да, вот такой умный дом с «умной дверью»).

-3

Тем
временем, пока сам процесс просто сидит там в доме, сам «умный дом»
координирует работу клиентов и выстраивает две очереди: одну для тех,
которые всё ещё обмениваются приветствиями с домом, а другую для тех,
кто уже справился с этапом приветствия.

Как только те или клиенты оказываются во второй очереди, процесс может впустить их.

-4

Когда
соединение считается принятым (клиенту сказано входить), сервер может
коммуницировать с клиентом, передавая и получая данные в зависимости от
того, что именно требуется.

Здесь стоит отметить, что фактически
клиент «не впускают» в дом. Сервер создаёт в доме «приватную дверь»
(клиентский сокет) и затем коммуникация с клиентом идёт именно через
неё.

Эта статья будет понятнее, если вы пошагово
представляете, как реализуется TCP-сервер на C. Если пока эта тема вам
не слишком хорошо знакома, то обязательно изучите статью «
Реализация TCP-сервера».

❯ Где мне искать список сокетов, имеющихся в моей системе?

Как
только у вас сложится представление о том, как именно устанавливается
соединение по протоколу TCP, мы сможем «зайти в дом» и исследовать, как
машина создаёт эти «двери» (сокеты). Также мы узнаем, сколько дверей у
нас в доме, и в каком состоянии каждая из них (закрыта она или открыта).

Для этого давайте возьмём для примера сервер, который просто создаёт сокет (дверь!) и ничего с ним не делает.

// socket.c –создаёт сокет и затем засыпает.
#include <stdio.h>
#include <sys/socket.h>


/**
* Создаёт сокет для работы по TCP IPv4, после чего переходит в
* режим ожидания.
*/
int
main(int argc, char** argv)
{
// Системный вызов `socket(2)` создаёт конечную точку для дальнейшей
// коммуникации, а затем возвращает дескриптор файла, ссылающийся на
// эту конечную точку
// Он принимает три аргумента (последний из них предоставляется лишь
// для большей конкретики):
// - домен (в пределах которого происходит коммуникация)
// AF_INET Интернет-протоколы IPv4
//
// - тип (семантика коммуникации)
// SOCK_STREAM Предоставляет правильно упорядоченные
// надёжные двунаправленные потоки байт,
// основанные на типе соединения
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (err == -1) {
perror("socket");
return err;
}


// Просто ждём ...
sleep(3600);

return 0;
}

Под капотом такой простой системный вызов запускает
целую кучу внутренних методов (подробнее о них в следующем разделе),
которые в какой-то момент позволят нам искать информацию об активных
сокетах, записываемую в трёх разных файлах: /proc/<pid>/net/tcp, /proc/<pid>/fd и /proc/<pid>/net/sockstat.

Тогда как в каталоге fd представлен список файлов, открытых процессом, в самом файле /proc/<pid>/net/tcp сообщается,
какие в данный момент есть активные TCP-соединения (в различных
состояниях), относящиеся к сетевому пространству имён данного процесса. С
другой стороны, файл sockstat можно считать своеобразным резюме.

Начиная с каталога fd и далее становится заметно, что после вызова socket(2) дескриптор сокетного файла фигурирует в списке аналогичных дескрипторов:

# Запустить socket.out (gcc -Wall -o socket.out socket.c)
# и оставить его работать в фоновом режиме
./socket.out &
[2] 21113

# Убедиться, что это открытые файлы, используемые процессом.
ls -lah /proc/21113/fd
dr-x------ 2 ubuntu ubuntu 0 Oct 16 12:27 .
dr-xr-xr-x 9 ubuntu ubuntu 0 Oct 16 12:27 ..
lrwx------ 1 ubuntu ubuntu 64 Oct 16 12:27 0 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct 16 12:27 1 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct 16 12:27 2 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct 16 12:27 3 -> 'socket:[301666]'

Учитывая, что при простом вызове socket(2) никакое TCP-соединение не устанавливается, мы не найдём для себя и не соберём никакой важной информации из /proc/<pid>/net/tcp.

По резюме (sockstat) можно догадаться, что количество выделенных TCP-сокетов постепенно увеличивается:

# Ознакомимся с файлом, в котором содержится информация о сокете.
cat /proc/21424/net/sockstat
sockets: used 296
TCP: inuse 3 orphan 0 tw 4 alloc 106 mem 1
UDP: inuse 1 mem 0
UDPLITE: inuse 0
RAW: inuse 0
FRAG: inuse 0 memory 0

Чтобы убедиться, что в процессе нашей работы число alloc действительно увеличивается, давайте изменим вышеприведённый код и попробуем выделить сразу 100 сокетов:

+ for (int i = 0; i < 100; i++) {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (err == -1) {
perror("socket");
return err;
}
+ }

Теперь, вновь проверив этот параметр, убедимся, что число alloc действительно увеличилось:

cat /proc/21456/net/sockstat

bigger than before!
|
sockets: used 296 .----------.
TCP: inuse 3 orphan 0 tw 4 | alloc 207| mem 1
UDP: inuse 1 mem 0 *----------*
UDPLITE: inuse 0
RAW: inuse 0
FRAG: inuse 0 memory 0

❯ Что именно происходит под капотом, когда выполняется системный вызов socket?

socket(2) подобен фабрике, производящей базовые структуры, предназначенные для обработки операций над таким сокетом.

Воспользовавшись iovisor/bcc,
можно на максимальную глубину отследить все вызовы, происходящие в
стеке sys_socket, и, исходя из этой информации, понять каждый шаг.

| socket()
|--------------- (kernel boundary)
| sys_socket
| (socket, type, protocol)
| sock_create
| (family, type, protocol, res)
| __sock_create
| (net, family, type, protocol, res, kern)
| sock_alloc
| ()
˘

Начиная с sys_socket как
такового, именно эта обёртка системного вызова — первый слой,
затрагиваемый в пространстве ядра. Именно на этом уровне выполняются
различные проверки и подготавливаются некоторые флаги, передаваемые для
использования при последующих вызовах.

Как только будут выполнены все предварительные проверки, вызов выделяет в собственном стеке указатель на struct socket — структуру, в которой содержится непротокольная конкретика о сокете:

/**
* Сокет определяется как системный вызов
* со следующими аргументами:
* - int family; - домен, в котором происходит коммуникация
* - int type; and - семантика коммуникации
* - int protocol. – конкретный протокол в рамках
* определённого домена и семантики.
*
*/
SYSCALL_DEFINE3(socket,
int, family,
int, type,
int, protocol)
{
// Указатель, который должен быть направлен на
// `struct sock`, структуру, в которой содержится полное определение
// сокета после того, как он будет должным образом выделен из
// семейства сокетов.
struct socket *sock;
int retval, flags;


// ... проверяется информация, готовятся флаги ...
// Создаются базовые структуры для работы с сокетами.
retval = sock_create(family, type, protocol, &sock);
if (retval < 0)
return retval;


// Для данного процесса выделяется дескриптор файла, так, чтобы
// он мог потреблять конкретный интересующий нас сокет из
// пользовательского пространства
return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
}


/**
* Высокоуровневая обёртка сокетных структур
*/
struct socket {
socket_state state;
short type;
unsigned long flags;
struct sock* sk;
const struct proto_ops* ops;
struct file* file;
// ...
};

Учитывая, что в данный момент мы как раз создаём
сокет, и мы можем сами выбирать из различных типов и семейств протоколов
(например, UDP, UNIX и TCP), именно для этого в struct socket содержится интерфейс (struct proto_ops*),
определяющий базовые конструкции, реализуемые сокетом. Эти конструкции
не зависят ни от типа, ни от семейства протоколов, и данная операция
инициируется при вызове метода, который идёт следующим: sock_create.

/**
* Инициализирует `struct socket`, выделяя необходимую
* для этого память, а также заполняя
* всю необходимую информацию, связанную с
* сокетом
*
* Метод:
* - Проверяет некоторые детали, связанные с аргументами;
* - Выполняет запланированную проверку безопасности для `socket_create`
* - Инициализирует саму операцию выделения памяти для `struct socket`
* (так, чтобы `family` выполняла её в соответствии с теми правилами, что в ней действуют)
*/
int __sock_create(struct net *net,
int family, int type, int protocol,
struct socket **res, int kern)
{
int err;
struct socket *sock;
const struct net_proto_family *pf;

// Проверяет диапазон протокола
if (family < 0 || family >= NPROTO)
return -EAFNOSUPPORT;
if (type < 0 || type >= SOCK_MAX)
return -EINVAL;


// Инициирует собственные проверки безопасности для socket_create.
err = security_socket_create(family, type, protocol, kern);
if (err)
return err;


// Выделяет объект `struct socket` и привязывает его к файлу,
// расположенному в файловой системе `sockfs`.
sock = sock_alloc();
if (!sock) {
net_warn_ratelimited("socket: no more sockets\n");
return -ENFILE; /* Не вполне точное совпадение, но это самый
близкий аналог, имеющийся в posix */
}

sock->type = type;

// Пытается извлечь методы семейства протоколов, чтобы
// создавать сокет по правилам, специфичным для данного семейства.
pf = rcu_dereference(net_families[family]);
err = -EAFNOSUPPORT;
if (!pf)
goto out_release;


// Выполняет метод создания сокетов, специфичный для
// данного семейства протоколов.
//
// Например, если мы работаем с семейством AF_INET (ipv4)
// и при этом мы создаём TCP-сокет (SOCK_STREAM),
// то вызывается конкретный метод для обработки сокета
// именно такого типа.
//
// Если бы мы указывали локальный сокет (UNIX),
// то вызывали бы другой метод (с учётом, что
// такой метод реализовывал бы интерфейс `proto_ops`
// и такой метод был бы загружен).
err = pf->create(net, sock, protocol, kern);
if (err < 0)
goto out_module_put;
// ...
}

Продолжая это подробное исследование, давайте внимательно рассмотрим, как именно выделяется структура struct socket с использованием метода sock_alloc().

-5

❯ Задача этого метода – выделить две сущности: новый индексный дескриптор inode и объект socket.

Они связаны на уровне файловой системы sockfs,
которая не только отвечает за отслеживание информации о сокете в
системе, но и предоставляет уровень трансляции, через который
взаимодействуют обычные вызовы из файловой системы (например, write(2)) и сетевой стек (независимо от того, в каком именно базовом домене происходит такая коммуникация).

Отслеживая работу метода sock_alloc_inode, отвечающего за выделение индексного дескриптора в sockfs, мы можем наблюдать, как именно организуется весь этот процесс:

trace -K sock_alloc_inode
22384 22384 socket-create.out sock_alloc_inode
sock_alloc_inode+0x1 [kernel]
new_inode_pseudo+0x11 [kernel]
sock_alloc+0x1c [kernel]
__sock_create+0x80 [kernel]
sys_socket+0x55 [kernel]
do_syscall_64+0x73 [kernel]
entry_SYSCALL_64_after_hwframe+0x3d [kernel]


/**
* sock_alloc - выделение сокета
*
* Выделить новые объекты индексного дескриптора и сокета. Система сначала связывает их вместе,
* а затем инициализирует. После этого выделяется сокет. Если мы израсходуем весь запас индексных дескрипторов,
* то возвращается NULL.
*/
struct socket *sock_alloc(void)
{
struct inode *inode;
struct socket *sock;

// При условии, что файловая система находится в памяти,
// выделяем объекты, используя для этого
// память ядра.
inode = new_inode_pseudo(sock_mnt->mnt_sb);
if (!inode)
return NULL;


// Извлекает структуру `socket` из
// `inode`, находящегося в `sockfs`
sock = SOCKET_I(inode);


// Задаёт некоторые аспекты файловой системы, такие, что
inode->i_ino = get_next_ino();
inode->i_mode = S_IFSOCK | S_IRWXUGO;
inode->i_uid = current_fsuid();
inode->i_gid = current_fsgid();
inode->i_op = &sockfs_inode_ops;


// Обновляет счётчик, учитывающий отдельно каждое ядро ЦП,
// который затем может использоваться `sockstat` и другими системами,
// если нужно быстро подсчитать количество сокетов).
this_cpu_add(sockets_in_use, 1);
return sock;
}


static struct inode *sock_alloc_inode(
struct super_block *sb)
{
struct socket_alloc *ei;
struct socket_wq *wq;

// Создаётся запись в кэше ядра и
// берётся необходимая для этого память.
ei = kmem_cache_alloc(sock_inode_cachep, GFP_KERNEL);
if (!ei)
return NULL;

wq = kmalloc(sizeof(*wq), GFP_KERNEL);
if (!wq) {
kmem_cache_free(sock_inode_cachep, ei);
return NULL;
}


// Выполняет простейший возможный
// вариант инициализации
ei->socket.state = SS_UNCONNECTED;
ei->socket.flags = 0;
ei->socket.ops = NULL;
ei->socket.sk = NULL;
ei->socket.file = NULL;

// Возвращает базовый индексный дескриптор vfs.
return &ei->vfs_inode;
}

❯ Сокеты и лимитирование ресурсов

Учитывая, что на индексный дескриптор файловой системы можно ссылаться из пользовательского пространства, используя для этого файловый дескриптор, складывается такая ситуация: после того, как мы настроим все базовые структуры ядра, в дело вступает sys_socket. Он генерирует файловый дескриптор за пользователя (выполняет все шаги валидации лимитов для ресурсов, как описано в документе Process resource limits under the hood).

Если вы когда-нибудь задумывались, почему при работе с socket(2) может возникать ошибка «слишком много открытых файлов», то всё дело именно в этих проверках лимитов для ресурсов:

static int
sock_map_fd(struct socket* sock, int flags)
{
struct file* newfile;

// Помните его? Это тот самый метод,
// при помощи которого ядро проверяет
// лимит доступных ресурсов и помогает убедиться,
// что мы этот лимит не превысили!
int fd = get_unused_fd_flags(flags);
if (unlikely(fd < 0)) {
sock_release(sock);
return fd;
}

newfile = sock_alloc_file(sock, flags, NULL);
if (likely(!IS_ERR(newfile))) {
fd_install(fd, newfile);
return fd;
}

put_unused_fd(fd);
return PTR_ERR(newfile);
}

❯ Подсчёт сокетов в системе

Если вы внимательно следили, что делает вызов sock_alloc,
то обращу ваше внимание вот на что: именно он увеличивает количество
сокетов, которые в настоящий момент находятся «в использовании».

struct socket *sock_alloc(void)
{
struct inode *inode;
struct socket *sock;

// ....

// Обновляет значение счётчика, работающего на каждом ядре процессора
// и после этого используется `sockstat`, чтобы другие системы также
// могли быстро узнавать количество сокетов.
this_cpu_add(sockets_in_use, 1);
return sock;
}

Поскольку this_cpu_add является макросом, можно заглянуть в его определение и выяснить о нём дополнительную информацию:

/*
* this_cpu operations (C) 2008-2013 Christoph Lameter <cl@linux.com>
*
* Оптимизированы манипуляции, связанные с выделением памяти на конкретные ядра процессора,
* или на конкретные ядреса, или на переменные ЦП.
*
* Эти операции гарантируют исключительность доступа для всех других операций
* при работе на *одном и том же* процессоре. При этом предполагается, что в пересчёте на каждое ядро к любым данным одновременно обращается только один экземпляр
* процессора(текущий).
*
* [...]
*/

Теперь, при условии, что мы постоянно прибавляем сокеты к sockets_in_use, можно, как минимум, предположить, что метод, зарегистрированный для /proc/net/sockstat собирается
использовать это значение — в самом деле, именно так и происходит. Это
также означает, что мы будем складывать все значения, зарегистрированные
на каждом ядре ЦП:

/*
* Сообщить статистику о выделении сокетов [mea@utu.fi]
*/
static int sockstat_seq_show(struct seq_file *seq, void *v)
{
struct net *net = seq->private;
unsigned int frag_mem;
int orphans, sockets;

// Извлечь счётчики, относящиеся к TCP-сокетам.
orphans = percpu_counter_sum_positive(&tcp_orphan_count);
sockets = proto_sockets_allocated_sum_positive(&tcp_prot);

// Показать статистику!
// Как мы уже видели в самом начале статьи,
// `alloc` показывает все те сокеты, которые уже были выделены,
// но в данный момент ещё могут не находиться в состоянии "используется".
socket_seq_show(seq);
seq_printf(seq, "TCP: inuse %d orphan %d tw %d alloc %d mem %ld\n",
sock_prot_inuse_get(net, &tcp_prot), orphans,
atomic_read(&net->ipv4.tcp_death_row.tw_count), sockets,
proto_memory_allocated(&tcp_prot));
// ...
seq_printf(seq, "FRAG: inuse %u memory %u\n", !!frag_mem, frag_mem);
return 0;
}

❯ Что насчёт пространств имён?

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

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

интересно
- `/proc/<pid>/net/tcp` с пространствами имён, а
`/proc/<pid>/net/sockstat` — нет (до сих пор так, патч не
приняли) 
pic.twitter.com/BcaVCAOczYCiro S. Costa (@cirowrc) October 16, 2018

Если хотите сами разобраться в этом вопросе, рекомендую вам сначала изучить статью Using network namespaces and a virtual switch to isolate servers.

Суть в данном случае такова: можно создать набор сокетов, посмотреть sockstat, затем создать сетевое пространство имён, зайти в него, а затем выясняется: хотя мы и не видим TCP-сокетов сразу из всей системы (именно так действует разграничение по пространствам имён!), мы всё-таки видим общее количество сокетов, выделенных в системе (как будто пространств имён нет).

# Создать набор сокетов, воспользовавшись нашим
# примером на C
./sockets.out


# Убедиться, что у нас есть набор сокетов
cat /proc/net/sockstat
sockets: used 296
TCP: inuse 5 orphan 0 tw 2 alloc 108 mem 3
UDP: inuse 1 mem 0
UDPLITE: inuse 0
RAW: inuse 0
FRAG: inuse 0 memory 0


# Создать сетевое пространство имён
ip netns add namespace1


# Зайти в него
ip netns exec namespace1 /bin/bash


# Убедиться, что `/proc/net/sockstat` показывает столько же
# выделенных сокетов.
TCP: inuse 0 orphan 0 tw 0 alloc 108 mem 3
UDP: inuse 0 mem 0
UDPLITE: inuse 0
RAW: inuse 0
FRAG: inuse 0 memory 0

❯ В качестве заключения

Интересно, оглянуться на то, что у меня получилось. Я углубился в исследование внутреннего устройства ядра, так как мне просто стало любопытно, как работает /proc. В итоге я нашёл ответы, помогающие понять
поведение конкретных функций, с которыми мне приходится сталкиваться при повседневной работе.

❯ Источники