Привет!
Если у вас уже есть некоторый опыт работы с веб-серверами, то вам наверняка доводилось попадать в классическую ситуацию «адрес уже используется» (EADDRINUSE).
В этой статье будут подробно разобраны не только предпосылки, позволяющие судить, случится ли в ближайшей перспективе такая ситуация (для этого достаточно просмотреть список открытых сокетов), но и будет рассказано, как можно прослеживать конкретные пути кода в ядре (где происходит такая проверка).
Если вам просто интересно, как именно работает системный вызов socket(2), где именно хранятся все эти сокеты, то обязательно дочитайте эту статью до конца!
❯ В чём суть сокетов?
Сокеты – это конструкции, через которые обеспечивается коммуникация между процессами, работающими на разных машинах, и эта коммуникация происходит по сети, которая для всех этих процессов является базовой. Бывает и так, что сокеты применяются для коммуникации между процессами, работающими на одном и том же хосте (в таком случае речь идёт о сокетах Unix).
Очень точная аналогия, иллюстрирующая суть сокетов и по-настоящему меня впечатлившая, приводится в книге Computer Networking: A top-down approach.
В самом общем виде можно представить компьютер как «дом», в котором есть множество дверей.
Здесь каждая дверь — это сокет, и, как только к ней подойдёт клиент, он может «постучать» в неё.
Сразу после стука в дверь (отправка пакета SYN
) дом автоматически реагирует на это, выдавая ответ (SYN+ACK
), который затем сам заверяет (да, вот такой умный дом с «умной дверью»).
Тем временем, пока сам процесс просто сидит там в доме, сам «умный дом» координирует работу клиентов и выстраивает две очереди: одну для тех, которые всё ещё обмениваются приветствиями с домом, а другую для тех, кто уже справился с этапом приветствия.
Как только те или клиенты оказываются во второй очереди, процесс может впустить их.
Когда соединение считается принятым (клиенту сказано входить), сервер может коммуницировать с клиентом, передавая и получая данные в зависимости от того, что именно требуется.
Здесь стоит отметить, что фактически клиент «не впускают» в дом. Сервер создаёт в доме «приватную дверь» (клиентский сокет) и затем коммуникация с клиентом идёт именно через неё.
Эта статья будет понятнее, если вы пошагово представляете, как реализуется 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()
.
❯ Задача этого метода – выделить две сущности: новый индексный дескриптор 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/BcaVCAOczY
— Ciro 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
. В итоге я нашёл ответы, помогающие понять поведение конкретных функций, с которыми мне приходится сталкиваться при повседневной работе.
❯ Источники
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩
❯ Рекомендуемые статьи
Если из этой статьи вы извлекли для себя что-то новое, то посмотрите и следующие — вероятно, они также пойдут вам на пользу!
ссылка на оригинал статьи https://habr.com/ru/articles/841462/
Добавить комментарий