Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада)

в 6:40, , рубрики: deckhouse, devops, devopsconf, Lables, mimirtool, monitoring, prometheus, tsdb, метрики, потребление ресурсов

Привет! На связи Владимир Гурьянов, технический директор Deckhouse Observability Platform в компании «Флант». В своём докладе на DevOpsConf 2024 я провёл небольшое расследование и выяснил, кто виноват в том, что Prometheus «съел» 64 ГБ оперативной памяти на сервере. А главное — я разобрался, что нужно делать, чтобы избегать этого в будущем. В этой статье приведу основные размышления и выводы из доклада.

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 1

«Флант» в целом начинался с DevOps-as-a-Service, и сейчас мы обслуживаем в этом направлении более 300 клиентов с несколькими тысячами серверов и десятками тысяч приложений. Как можно догадаться, здесь не обошлось без мониторинга.

Я работаю в команде Deckhouse Observability, которая создаёт собственную платформу мониторинга «Фланта». В SaaS-версии система обрабатывает более 100 миллионов метрик в минуту. За основу в разработке мы берём Prometheus.

За последние годы у нас появился обширный опыт работы с Prometheus — могу с уверенностью сказать, что это отличная система мониторинга с массой возможностей. Однако у Prometheus есть одно свойство, которое меня сильно огорчает, — потребление ресурсов. Если это расстраивает вас не меньше, чем меня, давайте разберёмся, чем вызвана эта проблема и как её решить.

Сначала кратко поговорим про сам Prometheus: обсудим его устройство, то, как его компоненты расходуют ресурсы, и начнём искать «главного подозреваемого» в их растрате.

Содержание:

Начинаем расследование: архитектура Prometheus и расход ресурсов

В 2012 году SoundCloud вдохновились Google Borgmon и решили создать нечто подобное, но в формате Open Source — так появился Prometheus. Его первый публичный релиз состоялся в 2015 году. Система написана на языке Go.

Prometheus — это один бинарный файл. Однако внутри него работают несколько взаимосвязанных процессов, которые можно чётко разделить по решаемым задачам. Давайте посмотрим, что у Prometheus «под капотом»:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 2

Прежде всего система мониторинга должна понимать, откуда получать метрики. За это отвечает Service Discovering. Он взаимодействует с внешними API, получает из них метаданные и строит список источников данных — таргеты.

Далее запускается процесс получения данных — Scraping. Он собирает данные с источников по pull-модели и протоколу HTTP. Scraping может получать данные от экспортеров, которые умеют извлекать метрики и преобразовывать их в понятный для Prometheus вид. Второй источник — сами приложения, в которые можно встроить метрики с помощью стандартных библиотек.

После сбора метрик их необходимо куда-то сохранять. В качестве базы данных (Storage) в Prometheus используется TSDB (Time Series Database).

Чтобы считывать сохранённые метрики, используется UI. У Prometheus есть собственный UI, однако он малофункционален, поэтому чаще используют Grafana.

Кроме того, в Prometheus есть два типа Rules: recording и alerting. Recording rules позволяют выполнить запрос и сохранить его результат в виде новой метрики. Alerting rules проверяют выполнение определённых условий и, как понятно из названия, отправляют уведомления во внешние системы, например в Alertmanager.

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

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 3

Поскольку Prometheus написан на Go, с помощью встроенного инструмента pprof можно узнать, сколько памяти потребляет каждый компонент. Я проанализировал около десятка инсталляций и получил следующее примерное распределение:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 4

Service Discovering — малозатратная операция и не расходует практически ничего. Rules обычно выполняются на часовом окне, поэтому не требуют большого объёма данных и ресурсов. Удивительно, но запросы от UI тоже потребляют всего около 10% ресурсов. В случае со Scraping 30% оправданы тем, что он получает данные в виде plain-текста, который нужно преобразовать из строк во внутренние структуры, а операции со строками в Go всегда дорогие. На первое место однозначно выходит Storage (TSDB) — посмотрим на него подробнее.

Ищем подозреваемого: особенности работы TSDB

На DevOpsConf 2023 я рассказывал о концепции TSDB, в том числе о TSDB в Prometheus. Чтобы создать общий контекст, приведу основные результаты из того доклада.

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

Поскольку градусников много, нужно как-то их идентифицировать. Для этого используются наборы лейблов (далее — labelsets), которые представляют собой пары «ключ — значение». Ниже — пример labelset:

{
  name:  cpu_usage,
  node:  curiosity,
  core:  0
}

В Prometheus, как и в большинстве систем мониторинга, labelset, как правило, указывают в фигурных скобках. Кроме того, поскольку это длинные строки, напрямую с ними не работают — каждому labelset’у назначается ID. Дальше уже хранятся ID labelset’а и его значения (они же временные ряды или серии):

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 5

Вернёмся к происходящему в Prometheus. Допустим, у нас есть миллион источников данных, жёсткий диск и область памяти.

Важный момент

В TSDB есть активный блок — в случае с Prometheus обычно двухчасовой. Prometheus сначала хранит его в памяти и только потом записывает на диск.

Схема работы в этом случае будет такой:

  1. Получаем значение от какого-то источника и сохраняем в память mapping labelset’а в ID.

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

  3. Записываем в журнал значение и время его получения.

  4. Записываем в память значение и время его получения.

  5. Повторяем операции 1–4 для следующих источников.

В итоге накапливается некоторый объём данных. По истечении определённого времени они записываются на диск в виде отдельного блока, который включает как значения метрик, так и полный набор labelsets. По сути, блок — это такая мини-база данных, которая содержит всю необходимую информацию, чтобы определить, какое значение было у конкретной метрики в период времени, к которому относится этот блок:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 6

Теперь ту часть данных, которая находится в памяти, можно условно разделить на две части: в одной находятся данные, а вторая работает с labelsets. Сначала рассмотрим первую и определим, что в ней может приводить к избыточному потреблению памяти.

Ищем подозреваемого: работа с данными

Вернёмся к исходному примеру с градусником. Мы всё так же снимаем с него значения и записываем timestamps. Данные в памяти уже находятся в закодированном, сжатом виде.

Для значений используется Gorilla-кодирование, которое позволяет максимально эффективно хранить в памяти временные ряды. Важная особенность этого алгоритма: чтобы прочитать любую точку, сначала нужно декодировать весь объём данных, то есть в нашем случае — весь двухчасовой блок.

Timestamps кодируются с помощью Delta-Delta-кодирования. Если мы снимаем значения через регулярные промежутки, например каждые 30 или 60 секунд, для хранения одного timestamp нужен всего один бит.

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 7

Последовательность timestamps и values называется chunk (далее — чанк). По умолчанию в Prometheus в чанк помещаются данные за два часа. По истечении двух часов чанк записывается на диск, и начинается новый сбор данных.

Если источников станет больше, например миллион, сформируется миллион чанков (один источник — один чанк). Через два часа мы получим миллион файлов на диске — в целом это не так уж много. Однако через 24 часа их станет 12 миллионов — а вот это уже многовато. Если бы источников было больше и/или мы хранили данные не за день, а за месяц, то данные хранились бы непоследовательно и запросы на чтение были бы очень тяжёлыми.

В Prometheus эта проблема решена так: чанки записываются на диск последовательно в один файл. Благодаря этому уменьшается число файлов, данные хранятся последовательно, а чтение быстрое и удобное. Кроме того, известно смещение внутри файла, а значит, можно легко найти нужный чанк, не читая файл целиком. Максимальный размер файла — 128 МБ. Механизм записи такой: сначала в файл записываются первые чанки от всех источников, затем, если осталось место, записываются вторые и т. д. Когда место в одном файле заканчивается, открывается другой и запись продолжается в него.

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 8

Потенциальный минус здесь в том, что чанки ограничены только по времени. Например, если чанк двухчасовой и Scrape Interval равен 60 секундам, то через два часа будет 120 точек. Однако если снимать данные каждые 15 секунд, то через два часа будет уже 480 точек. Из-за этого становится трудно предсказать, сколько места займут данные в чанке, и очевидно, что это влияет на потребление памяти. Кроме того, чтобы декодировать данные в чанке, нужно прочитать его целиком — это тоже расходует память.

В Prometheus с этим борются, ограничивая размер чанка: в него можно записать только 120 точек. После этого он сохраняется на диск. При этом важно сохранить скорость работы с ним, чтобы можно было оперативно читать из него данные. Поэтому чанки m-map’ятся в память. Примерно так же работает swap: в памяти создаются структуры данных, при обращении к такой структуре операционная система быстро считывает данные с диска и подкладывает их в память. Со стороны приложения всё выглядит так, будто данные всегда были в памяти. Однако на самом деле их там нет, и это позволяет существенно экономить объём занятого места в оперативной памяти.

Спустя три часа (один полный блок + ½ блока) можно выделить чанки, которые входят в блок, в отдельную часть. При Scrape Interval в 15 секунд это будет четыре чанка по 120 точек. Добавляем к чанкам индекс, чтобы получилась законченная БД. Дальше процесс повторяется.

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 9

Подведём промежуточный итог.

Все данные от каждого источника записываются в отдельные чанки. Размер чанка — 120 точек. При этом чанки последовательно записываются в один файл, а при достижении 128 МБ открывается новый файл, в который продолжается последовательная запись. По истечении трех часов часть чанков «отрезается» и сохраняется в блок.

Данные хранятся в памяти в закодированном, сжатом виде. Для values используется Gorilla-кодирование. Его особенность в том, что для чтения любой точки нужно декодировать чанк целиком. Для timestamps используется Delta-Delta-кодирование, и это позволяет тратить всего один бит на хранение одного timestamp.

Кажется, работа с данными хорошо оптимизирована и эффективна. Тогда, возможно, в излишнем потреблении памяти виноваты не данные, а та область, где хранятся labelsets. Давайте посмотрим.

Ищем подозреваемого: работа с labelsets

Ниже — пример labelset:

{_name_="http_requests_total", job="ingress", method="GET"}

Здесь три лейбла: name, job и method.

Labelsets хранятся в Index. Index нужен, чтобы обслуживать запросы на чтение, примерно как в реляционных БД. В случае с Prometheus мы, как правило, не ищем по полному labelset, а берем один или несколько лейблов и составляем запрос, возвращающий все серии, в которых есть эти лейблы.

Важный момент 

Если вы зайдёте в исходный код, чтобы узнать, как устроен Index, то увидите более сложную картину. Здесь я в несколько упрощённой форме расскажу, как это работает.

Для реализации Index в Prometheus предусмотрена отдельная область памяти, в которой есть несколько сущностей — о них ниже.

Symbols. В эту сущность мы записываем все ключи и значения в виде отдельных строк и назначаем им ID:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 10

Это позволяет максимально эффективно работать с labelset’ами, так как в них часто повторяются ключи и значения.

Postings. Благодаря этой структуре можно быстро вести поиск по парам «ключ — значение». Для каждой пары мы указываем список серий, в которых есть эта пара, но используем уже ID и массив серий:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 11

Например, чтобы найти все серии, в которых есть лейбл job и его значение “ingress”, нужно сначала преобразовать пару «ключ — значение» с помощью символов. В нашем примере это символы 3 и 4. Затем необходимо найти пару 3,4 и прочитать массив серий.

Series. Эта сущность позволяет связать labelsets с данными. Здесь есть ID серий и массив, в котором с помощью ID описан весь labelset. Чтобы восстановить labelset, нужно с помощью символов превратить ID в массиве в реальные значения и вернуть их. Кроме того, в Series есть ссылка на данные — фактически ссылка на смещение в файле с чанками, которое позволяет получить доступ к чанку. Поскольку чанков, как правило, несколько, таких ссылок тоже несколько:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 12

Мы рассмотрели работу с labelsets на примере одной серии. Посмотрим, что будет, если добавить ещё одну.

Возьмём серию, у которой labelset отличается только значением для лейбла method: в исходной был GET, в этой — POST:

{_name_="http_requests_total", job="ingress", method="POST"}

Разберём шаги добавления серии в Index:

Шаг 1. Добавить запись POST в Symbols:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 13

Шаг 2. Добавить в Postings новую пару «ключ — значение» method=“POST” и серию, в которой она встречается. Также необходимо обновить записи для уже имеющихся пар «ключ — значение», так как пары _name_="http_requests_total" и job=“ingress” уже встречались:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 14

Шаг 3. Добавить ещё одну запись в Series, так как это новая серия и у неё свои ссылки на чанки, свой labelset:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 15

Итак, Symbols, Postings и Series позволяют оптимально хранить labelsets: избавиться от дублирования строк, хранить ID в виде цифр и смещение чанков и при этом сохранять скорость поиска. Таким образом, работа с labelsets тоже вряд ли подходит на роль «главного подозреваемого» в растрате ресурсов.

Тут, как бывает в детективах, приходит время взглянуть на тех, кому до этого удавалось оставаться в тени и избегать подозрений. В нашем случае это cardinality и churn. Рассмотрим их по очереди.

Кто виноват: cardinality

Cardinality — это количество уникальных labelsets для метрики.

Рассмотрим пример. Допустим, есть метрика с именем http_requests_total. У нее есть два лейбла: instance — с тремя значениями и job —  с одним:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 16

Чтобы вычислить cardinality этой метрики, нужно перемножить числа 1, 3 и 1 — получится 3. Таким образом, для этой метрики есть три уникальных набора лейблов. Чанков тоже будет три.

Добавим еще один лейбл (method) уже с пятью значениями:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 17

В этом случае cardinality равно 15. Число чанков тоже увеличится, но кажется, что разница между 3 и 15 не такая уж большая.

Добавим еще один лейбл — пусть это будет эндпоинт, на который приходит запрос. Предположим, у нас 1000 таких эндпоинтов:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 18

В результате значение cardinality и число чанков увеличатся до 15 000. Очевидно, чтобы хранить такое количество данных в чанках, нужен большой объём памяти. Также требуется больше ресурсов на вставку данных в эти чанки и на чтение. Например, если мы хотим посчитать, сколько всего пришло запросов, нужно декодировать 15 000 чанков.

Значение 15 000 в cardinality — это столько же новых серий и labelsets в Index. Их нужно описать в Symbols и Postings, причем последние увеличиваются как по количеству записей, так и «в ширину». Массивы в Series также растут, так как явно есть повторяющиеся пары «ключ — значение». Серии тоже прибывают. Всё это приводит к росту потребления памяти. Кроме того, требуется больше ресурсов, чтобы обновить Index, а также чтобы прочитать и зарезолвить запрос.

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 19

Кажется, мы нашли одного «виновного». Теперь посмотрим на churn.

Кто виноват: churn

Churn — это мера скорости, с которой временные ряды добавляются в систему мониторинга или удаляются из неё. Churn отражает динамику изменений в метриках.

Начнём с чанков. Допустим, у нас есть четыре градусника, из которых стабильно присылает значения только четвёртый. Спустя некоторое время накапливается 120 точек, чанк записывается на диск, и сбор данных продолжается:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 20

Через три часа данные можно записывать на диск. Однако стоит помнить, что в течение этих трех часов сохраняются в том числе и чанки от источников, которые перестали присылать данные. Поскольку есть чанки, в которых мало значений, сжатие работает не так эффективно, как на больших данных. Это приводит к увеличению потребления ресурсов.

Размеры Symbols, Postings и Series также увеличиваются. Это требует больше ресурсов на обновление Index и на чтение.

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 21

Поскольку могут потребоваться исторические данные, нельзя удалять из Index серии — то есть в нём нужно хранить данные по всем сериям, которые когда-либо появлялись.

Несмотря на то что работа с чанками и Index достаточно эффективна, как правило, в излишнем расходовании ресурсов виноваты именно cardinality и churn. При этом у churn есть ещё одна неприятная особенность: он не отслеживает, какие метрики появляются, а какие — исчезают, поэтому изначальная 1000 уникальных метрик в блоке через несколько часов может превратиться в 3000. Это бывает нелегко обнаружить.

Кто виноват: remote_write (бонус)

Prometheus remote_write — это механизм, который позволяет отправлять данные в какой-то внешний storage.

Если remote_write один, это особо не отражается на ресурсах. Если их несколько, а источники, которые принимают данные, недоступны, потребление памяти на каждый remote_write может составлять 500–700 МБ в зависимости от количества метрик в кластере.

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

Что делать: анализируем потребление ресурсов

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

Начнем с churn, так как он более капризный и не всегда очевидный.

В Prometheus есть утилита ./tsdb с ключом analyze. Для завершённых блоков она позволяет найти те пары «ключ — значение», которые приводят к максимальному churn:

./tsdb analyze

Label pairs most involved in churning:
17 job=node
11 __name__=node_systemd_unit_state
6 instance=foo:9100
4 instance=bar:9100
3 instance=baz:9100

Начиная с версии 2.14 появилась специализированная метрика — с помощью запроса ниже можно посмотреть серии, которые чаще всего появлялись и исчезали за последний час:

topk(10, sum without(instance)(sum_over_time(scrape_series_added[1h])))

Для cardinality инструментов побольше. Помимо утилиты ./tsdb analyze, есть Prometheus Status — страница в Prometheus, которая показывает лейблы с наибольшим значением cardinality. Ещё одна полезная утилита — mimirtool. Остановимся на ней подробнее.

Mimirtool позволяет получить: 

  • из Grafana — список метрик, которые используются в дашбордах; 

  • из Prometheus — общий список метрик и метрики, которые используются в Rules. 

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

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 22

Что делать: избавляемся от лишнего

Приведу небольшой кейс. У коллег был кластер, в котором Prometheus собирал почти 10 миллионов метрик и потреблял почти 64 ГБ памяти:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 23

Когда они избавились от лишних метрик и labelsets, тот же самый Prometheus стал выглядеть так:

Потребление ресурсов в Prometheus: кто виноват и что делать (обзор и видео доклада) - 24

Итого — 877 тысяч метрик и потребление памяти на уровне 5 ГБ. Эта картина уже явно лучше.

Есть несколько способов избавиться от лишнего.

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

В этом могут помочь гибкие настройки некоторых экспортеров и большинства библиотек для инструментирования.

Удалять лишние лейблы или метрики.

Для этого есть механизм релейблинга (relabel_configs):

- source_labels: [requestID]
  action: labeldrop
- source_labels: [__name__]
  regexp: not_important_metric
  action: drop

Выставлять лимиты на количество сэмплов, которые собираются с источника.

Это делается для того, чтобы не «перегреть» Prometheus, если вдруг какое-то приложение начнёт отправлять слишком много метрик.

Выставлять лимиты на количество источников.

Это нужно на случай, если кто-то, например, решит масштабировать свой под с 50 000 метрик до 1000 подов.

Как теперь расследовать инциденты

Итак, мы удалили всё лишнее, и теперь Prometheus расходует гораздо меньше ресурсов.

Предположим, мы получили ошибку 500. Раньше связанная с ней метрика выглядела так:

{__name__="http_requests_total", code="500", user_agent="google Chrome 17", uri="/books/buy/17", method="POST", request_id="XXXX-XX-XX", backend="backend-32", user_id="17"}

Здесь всё предельно понятно: есть бэкенд, ID запроса, эндпоинт, на который пришёл запрос, и даже пользователь, который пришёл с этим запросом.

Вот та же метрика после «генеральной уборки»:

{__name__="http_requests_total", code="500", method="POST"}

Основная задача мониторинга — уведомить нас о том, что что-то пошло не так, позволить быстро разобраться и исправить ошибку. Кажется, что вид метрики выше не решает эту задачу, но это не так. Данные для анализа инцидента можно взять из логов и трейсов. Как правило, в логах есть и user_agent, и backend, и вся остальная необходимая информация.

Связать метрики с логами и трейсами позволяет механизм exemplars.

Обычная метрика в Prometheus выглядит так:

http_requests_total{method="POST", code="500"} 3

Та же метрика с exemplars:

http_requests_total{method="GET", code="500"} 3 # {trace_id="abc123"}

По сути, exemplars — это возможность добавлять метаданные к метрикам. Как правило, это trace_id, через который можно эффективно и удобно связывать метрики с трейсами.

Стоит иметь в виду, что exemplars хранятся (во всяком случае в Prometheus) отдельно. Очевидно, что хранение не бесплатное, поэтому для exemplars действует то же правило, что и для метрик: прежде чем добавлять exemplar, стоит подумать, нужен ли он. Например, если за 30 секунд произошло 100 событий, которые закончились кодом 500 на одном эндпоинте, скорее всего, у этих событий одна причина. Поэтому стоит добавить exemplar только к одной метрике, в крайнем случае — только к каждой десятой или сотой. Таким образом, нужно продумывать добавление exemplars, иначе есть риск вернуться к тому, с чего все начиналось, — к дефициту ресурсов.

Коллеги из кейса выше утверждали, что удаление лишних метрик и labelsets никак не отразилось на продуктивности мониторинга и работы с инцидентами: у них остались те же дашборды и алерты, а exemplars они используют уже давно.

Выводы

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

Решение кроется в грамотном управлении метриками и лейблами: можно удалять или не добавлять лишние, выставлять лимиты на количество сэмплов от источников или количество самих источников. Чтобы найти лишние метрики, можно использовать как встроенные в Prometheus инструменты (./tsdb analyze и Prometheus Status), так и стороннюю утилиту mimirtool.

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

Видео и слайды

Презентация доклада

P. S.

Читайте также в нашем блоге:

Автор: Magvai69

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js