Мониторинг сетевой активности

Я уже немного писал о нашей системе оркестрации и даже делал выступление. В этой статье пойдёт речь об одной из важных частей этой системы -- о мониторинге. Конечно же, я не смогу рассказать всё о мониторинге за один раз, но расскажу про дизайн подсистемы, которая занимается отслеживанием сетевых соединений. Также я опишу какие интерфейсы есть в linux для получения метрик. Это может быть интересно тем, пользовался только высокоуровневыми утилитами и не знает как они работают на самом деле.

Предыстория

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

Вкратце, cantal умеет:

  1. Собирать системные метрики с интервалом в 2 сек
  2. Забирать метрики из приложения по собственному протоколу
  3. Распознавать соседние сервера где тоже есть cantal
  4. Собирать с соседних узлов подмножество метрик и предоставлять к ним доступ
    по HTTP API (без центрального сервера)
  5. На каждой ноде показывать метрики, список процессов, список сетевых
    соединений, и т.д. в виде web-странички
  6. Отправлять подмножество метрик в carbon (graphite) для архивирования

Больше описано в документации. В целом, кроме децентрализации, тут всё довольно скучно. Могу только добавить, что написав его на rust'е мы смогли собирать метрики с маленькими накладными расходами, a web-frontend находящийся на каждом узле не занимает много памяти.

Постановка задачи

В абстрактном описании задача проста:

  1. Видеть какие приложения зарезервировали какие TCP-порты
  2. Отслеживать количество соединений каждого приложения, по типам: активные,
    закрытые, ожидают таймаута...

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

Если вы изолируете сетевые интерфейсы, то скорее всего у вас не будет проблемы с конфликтами портов. Но видеть какой сервис каким адресом торчит в наружу, и какой из них с каким соединён, тоже очень полезно для отладки. Мы планируем добавить поддержку изолированных сетей в cantal в будущем.

Второй пункт важен для production-серверов. Есть огромное количество задач, которое гораздо лучше решается асинхронным подходом. А утечки соединений в асинхронном коде являются частой ситуацией. Будь то утечки goroutine, незакрытые файловые дескрипторы или просто чрезмерно часто открывающиеся соединения хорошо бы про них знать прежде чем это станет проблемой.

И еще одно ограничение: мы хотим чтобы это всё быстро работало на одном милионе соединений на машине. Почему это важное ограничение будет понятно ниже.

Как реализовать?

Многие знают что есть утилита netstat для просмотра соединений на машине. Почему-то не многие знают, что есть более современная утилита ss (расшифровывается, видимо, как sock stat) для этой же задачи. Но cantal не будет их запускать, по-этому мы посмотрим на системные интерфейсы.

Главный системный интерфейс для мониторинга -- это файловая система proc.

Если вы в первый раз об этом слышите -- зайдите в папку /proc и осмотритесь. Там есть много чего интересного, например, утилита ps, которая показывает список процессов, просто обходит эту папку, и показывает её содержимое в виде таблички. Но в /proc есть еще много всего интересного чего в другом виде не найти.

Есть файл /proc/net/tcp как-раз для этого случая. Вот пример его содержимого:

  sl  local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt   uid  timeout inode
   0: 00000000:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 17337 1 0000000000000000 100 0 0 10 0
   1: 0007001F:0277 00000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 18247 1 0000000000000000 100 0 0 10 0
   2: A80C0A30:C4F6 E6D3EAD8:01BB 01 00000000:00000000 02:00000C5D 00000000  1000        0 942906 2 0000000000000000 22 4 19 10 -1
   3: A80C0A30:E37E AE9FEB04:1B58 01 00000000:00000000 02:000003F7 00000000  1000        0 867992 2 0000000000000000 23 4 30 10 7
   4: A80C0A30:E3F2 C6D3EAD8:01BB 01 00000000:00000000 02:00000B37 00000000  1000        0 941769 2 0000000000000000 22 4 25 10 -1
   5: A80C0A30:D940 7DC1CEF0:01BB 01 00000000:00000000 02:00000C11 00000000  1000        0 912543 2 0000000000000000 40 4 23 10 -1
   6: A80C0A30:E790 AF3A7F46:01BB 01 00000000:00000000 02:00000B11 00000000  1000        0 867120 2 0000000000000000 37 4 30 10 -1
   7: A80C0A30:B69E 2F3A4EB6:01BB 01 00000000:00000000 02:00000DB7 00000000  1000        0 867490 2 0000000000000000 39 4 29 4 5
   8: A80C0A30:C4F4 E6D3EAD8:01BB 01 00000000:00000000 02:00000C44 00000000  1000        0 942902 2 0000000000000000 22 4 19 10 -1
   9: A80C0A30:D6FA 00397866:01BB 01 00000000:00000000 02:00000E11 00000000  1000        0 867495 2 0000000000000000 38 4 30 3 2
  10: A80C0A30:C2D6 854E89A0:01BB 01 00000000:00000000 02:00000B1D 00000000  1000        0 945229 2 0000000000000000 24 4 18 10 -1
  11: A80C0A30:C4FC E6D3EAD8:01BB 01 00000000:00000000 02:00000DC4 00000000  1000        0 945341 2 0000000000000000 22 4 17 10 -1
  12: A80C0A30:8C5C 3A413EC8:0050 01 00000000:00000000 00:00000000 00000000  1000        0 860832 1 0000000000000000 41 4 32 10 -1

Из интересного нам, это вторая и третья колонки, которые показывают адреса соединений. Кто не узнал адреса -- это то к чему вы привыкли, только в шестнадцатеричном виде. Например, rem_adress (удалённый адрес) в последней строке это 3A413EC8:0050 что более привычно выглядит 58.65.62.200:80, т.е. HTTP соединение на удалённую машину.

Что еще важно что тут есть uid -- идентификатор пользователя, который открыл socket.

В linux'е у каждого процесса есть 4 идентификатора пользователя:

  • Настоящий (real)
  • Действующий (effective)
  • Сохранённый (saved set)
  • Для файловой системы (filesystem uid)

Для socket'а сохраняется filesystem uid. Вы можете их посмотреть в /proc/PID/status где (PID -- идентификатор процесса). Чаще всего они одинаковые, по-этому не буду останавливаться на этом здесь.

Обратите внимание что здесь нет pid'а процесса. И это наш первый камень преткновения.

Точнее по факту их два: узнать pid процесса и быстро вычитать этот файл (помните, тут будет миллион строк). Но обо всём по порядку.

Связь соединений с процессами

Для тех кто с этим не сталкивался, это не очевидно, но ядро linux не отслеживает какому процессу принадлежит файловый дескриптор (socket). В общем случае это даже не возможно, поскольку каждый socket может быть представлен во многих процессах. Например, вот что показывает sudo ss -nl4p | grep nginx:

tcp    LISTEN     0      128                    *:80                    *:*
users:(("nginx",1568,12),("nginx",1567,12),("nginx",1566,12),("nginx",1565,12),("nginx",1550,12))

Кратко поясню что делает команда ss -nlp4p | grep nginx:

  • -n -- порты показывать цифрами (:80 вместо :http), это быстрее и часто удобнее
  • -l -- только "слушающие" socket'ы, то есть ожидающие соединений из сети
  • -4 -- только TCP/IP v4
  • -p -- показывать процессы, которым принадлежит сокет
  • | grep nginx -- отфильтровать по слову nginx

Из строки выше можно видеть следующее:

  • *:80 -- socket слушает на порту 80
  • users:(("nginx", X, Y)) -- этот порт слушает nginx у которого идентификатор X и номер файлового дескриптора Y

Т.е. в нашем случае есть 5 процессов c номерами 1568, 1567, 1566, 1565, 1550. Если быть более точным, то мастер-процесс nginx'а и все его рабочие процессы слушают один и тот же socket.

Если посмотреть на дерево процессов то увидим:

$ pstree 1550
-+= 01550 root nginx: master process /usr/local/nginx/sbin/nginx
 |--- 01568 nobody nginx: worker process
 |--- 01567 nobody nginx: worker process
 |--- 01566 nobody nginx: worker process
 \--- 01565 nobody nginx: worker process

Если посмотреть конфигурацию nginx'а то там будет worker_processes: 4;.

Как же ss и lsof показывают данные?

Если кратко: полным перебором всех файловых дескрипторов всех процессов. Именно по-этому на миллионе соединений это долго. Но я всё-же расскажу подробности.

Файловые дескрипторы любого процесса можно посмотреть в папке /proc/PID/fd, например, вот наш главный nginx:

$ ls -la /proc/1550/fd
total 0
dr-x------ 2 root root  0 Jun 21 23:36 .
dr-xr-xr-x 9 root root  0 Apr  8 16:38 ..
lrwx------ 1 root root 64 Jun 21 23:36 0 -> /dev/null
lrwx------ 1 root root 64 Jun 21 23:36 1 -> /dev/null
l-wx------ 1 root root 64 Jun 21 23:36 10 -> /usr/local/nginx/logs/access.log
l-wx------ 1 root root 64 Jun 21 23:36 11 -> /usr/local/nginx/logs/error.log
lrwx------ 1 root root 64 Jun 21 23:36 12 -> socket:[16111]

Обратите внимание на номер 12, который представляет из себя символическую ссылку на объект типа socket:[16111].

Стрелочка -> в ls означает символическую ссылку. Файлы в /proc/PID/fd как бы являются символическими ссылками на то, что они открывают, на сколько это возможно. В частности для логов и /dev/null это может быть ссылка непосредственно на файл, а вот для socket'ов такой возможности нет.

Если теперь посмотреть в /proc/net/tcp то мы можем увидеть этот номер в колонке inode:

  sl  local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt   uid  timeout inode
...
   8: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 16111 1 0000000000000000 100 0 0 10 0
...

Вот так полным перебором всех файлов в /proc/*/fd/* мы можем связать открытые соединения с процессами.

Т.е. вы правильно поняли: нужно прочесть миллион строк в /proc/net/tcp и еще вычитать миллион файловых дескрипторов в /proc/PID/fd. При этом последнее вы должны вычитать для всех процессов и всех файлов (это не обязательно сетевые соединения, например база данных может открывать много просто файлов).

Так ли это плохо?

В моих экспериментах ss на процессоре класса i5, с миллионом открытых соединений, отрабатывает около 8-ми секунд. При этом съедая 100% одного ядра. Это значит что мы уже не можем получать эти метрики раз в 2 секунды как все остальные.

И даже если мы будем собирать их раз в минуту -- мы не можем распределить нагрузку равномерно, нам нужно выполнить задачу как можно быстрее, потому что приложение "живёт своей жизнью". Т.е. если мы будем минуту делать один обход файловых дескрипторов, то к тому времени как мы будем обходить последние из них, первые уже будут содержать совсем не то, что раннее. То же касается и соединений. И еще более важно, что когда мы будем связывать таблицу соединений с таблицей файловых дескрипторов -- мы будем иметь много "промахов".

Т.е. мы раз в минуту будем вытеснять полностью одно ядро процессора на 8 секунд. При этом при большой нагрузке эти 8 секунд тоже будут растягиваться
во времени.

Есть ли решение?

Можно сделать две вещи:

  1. Помните в списке соединений у нас фигурировал uid? Мы выдали каждому
    приложению собственный uid. Теперь легко отследить кому принадлежит socket.
  2. Использовать netlink для вытаскивания списка socket'ов.

На этом этапе первый пункт наводит на вопрос "неужели вы этого не делали раньше?", а второй выглядит обманом: "зачем было так долго описывать неправильный интерфейс если есть лучше?".

Про пользователей есть три простых аспекта вопроса:

  1. Главное, что я хочу сказать в статье про мониторинг, это что мы
    web-страничке теперь показываем список соединений и список процессов по каждому пользователю, и этого достаточно. И таким образом мы избавились от сканирования /proc/*/fd*.
  2. Фактически пользователи с не нулевым uid-ом (т.е. не root) имеют равные
    права. Когда в контейнере только один пользователь -- какой номер ему не дай -- ничего не изменится. По-этому мы использовали один фиксированный uid=1 для всех приложений.
  3. Мы и оставили uid=1 внутри контейнера, но добавили user namespaces, но
    это, впрочем, неважные детали контейнеризации.

Про netlink это тоже не такое очевидное решение как кажется. Начнём с цифр:

Для 1000000 соединений я могу получить информацию о соединениях примерно за 0.7 сек на i5. При том что скопировать /proc/net/tcp не удается меньше
чем за 1.8 сек.

Почему я употребил слово "cкопировать" в прошлом предложении? Потому что обработка /proc/net/tcp ограничена не скоростью разбора текста, а способом чтения его из ядра. По некоторым неизвестным мне причинам ядро отдает около 4Кбайт данных за раз. Соответственно для 1 млн. сокетов мне нужно сделать около 300тыс. операций чтения. И оказывается около 70% времени проведено в ядре (system time), а не в моём коде (user time).

Недостатки же у netlink тоже есть:

  1. Более сложный протокол
  2. Вместо документации предлагают почитать исходные коды ядра

Примерно по этой причине мы это не реализовали, но сделаем в будущем.

Как это выглядит?

Disclaimer: Тут на скорую руку сделанный интерфейс чтобы побыстрее проверить описанные возможности. Конечно же он будет улучшаться и полироваться.

Примерно так выглядят наши redis-серверы, на одной из машин:

Скриншот списка редисов и их соединений

Тут видно следующее:

  1. Редис запущен под пользователем c номером 999
  2. Внизу в списке процессов мы видим что их на самом деле четыре
  3. В таблице Passive видно что они слушают 4 сокета (в итоге каждый по одному)
  4. В таблице Active видно что у них есть и исходящие соединения

При детальном рассмотрении: исходящие соединения устанавливает только один из процессов, который на самом деле Redis Sentinel. Но мы этого не видим здесь. Можно даже сказать что в данном случае наша ошибка, что мы их все запускаем из под одного пользователя.

На следующей картинке видна детализация соединений питоньего процесса:

Скриншот отображения соединений к редису для питоньего сервиса

Тут видно что наш процесс (который является каким-то сервисом написанном на python'е) установил 10 соединений к redis'у. На мой вкус 10 соединений к
redis'у избыточны почти для всех ситуаций; кроме использования WATCH и
блокирующих операций. Cantal позволяет это легко увидеть и, возможно, отреагировать.

Мы показываем детализацию соединений для портов где < 1000 соединений. Для того, чтобы можно было понять что это за соединение. Если соединений больше 1000 то список соединений бессмысленный, и мы его не храним в памяти cantal'а. Только общее количество соединений c детализацией по состояниям. Т.е. утечку незакрытых сокетов обычно можно заметить.

А на следующем скриншоте видно как библиотека requests оставляет незакрытые сокеты:

Скриншот с двумя соединениями в состоянии CloseWait

Собственно это проблема всех синхронных библиотек по работе с сетью: мы пытаемся держать keep-alive соединения для производительности. Но когда мы выполнили запрос и через некоторое время после этого соединение закрывается со стороны удалённого узла -- у приложения нет способа об этом узнать и закрыть сокет.

Собственно соединение закрывается тогда, когда приложению понадобился сокет из этого же pool'а соединений. Т.е. когда управление передается коду который управляет этим соединением.

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

Состояние CloseWait означает что операционная система ждёт пока приложение вызовет close(), т.е. закроет сокет, потому что больше ничего полезного он не делает. Когда их два это не сильно страшно. Но будет ли их количество расти?

На картинках выше -- отображение сгруппированое по пользователям. Есть, конечно, и обратная табличка:

Скриншот таблички где порт связывается с пользователем и процессом

Первый опыт

Из интересных моментов, которые мне удалось выяснить пока я отлаживал мониторинг:

  1. Нам моём ноутбуке cups-browsed (отвечает за принтеры и печать) периодически
    присоединяется к webpack'у (точнее webpack-dev-server, или к чему угодно что отвечает на порту 8080)
  2. Как описано выше, библиотека requests не закрывает соединения пока не
    потребуется сделать еще один запрос на тот же сайт. По-этому они торчат в состоянии CLOSE_WAIT
  3. У нас обнаружился memcache который никто не использует: к нему нет
    соединений, и он занимает 488Кб памяти

Планы

Кратко о недостатках которые мы хотим исправить:

  1. Для миллиона и больше соединений (а столько может поддерживать современное
    оборудование) лучше уменьшать период сканирования. Например, с 2х секунд до одной минуты.
  2. Конечно же реализовать netlink о котором говорилось выше
  3. В статистике передаваемой по netlink есть очень интересные метрики: для
    каждого соединения есть round-trip time (RTT), и размеры буферов. RTT очень интересно отслеживать на соединениях с базами данных и backend'ами.
  4. И также хотим сделать возможность сканирования всех network namespaces
    присутствующих на машине

Выводы

Эта история довольно многогранна. Первое что она показывает, что не всегда задачу нужно решать "в лоб". Мы хотели узнавать с каким сокетом связан процесс. Но в итоге начали привязывать сокеты к пользователем. Что гораздо производительнее. Хоть это и не лишено недостатков, они не являются критическими для нас.

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

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

Также нам в некотором смысле повезло, что мы сделали cantal как процесс имеющий самостоятельный web-интерфейс. Мы бы не смогли повторить это достаточно хорошо, если бы просто отправляли метрики в carbon/graphite/influxdb.

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

Paul Colomiets

Read more posts by this author.