В метки Loki добавили request_id, чтобы удобнее искать запрос. Искать стало действительно можно: вставил идентификатор из жалобы клиента, получил нужные строки. Но у этого удобства есть побочный эффект. Каждый новый запрос начал дробить хранение на ещё один крошечный поток, и система, которая задумывалась как дешёвая и неприхотливая, стала вести себя как база данных, в которую по записи на каждое событие кладут отдельную папку.
Разберём, почему так происходит, как отличить эту причину от других, куда лучше перенести идентификатор и как честно сравнить результат до и после, не обещая одинаковой экономии на любых данных.
Одна и та же строка: идентификатор в метках и в содержимом
Возьмём условную запись веб-сервиса. Первый вариант: при отправке в Loki мы навешиваем метки service="checkout", env="prod", level="error" и request_id="9f3c1a7e", а в тело кладём текст «payment gateway timeout». Второй вариант: метки только service, env и level, а идентификатор живёт внутри строки, например request_id=9f3c1a7e payment gateway timeout.
Человеку обе записи кажутся почти одинаковыми. Для Loki это принципиально разные вещи. Метки образуют индекс: по ним система быстро находит нужные потоки. Содержимое строки в индекс не превращается, его сканируют уже внутри выбранных потоков. Поэтому вопрос «куда положить идентификатор» на деле звучит так: хотим ли мы, чтобы он определял структуру хранения.
Как комбинации меток превращаются в потоки
По документации Loki, уникальные сочетания меток образуют streams, а высокая кардинальность, то есть большое число различных значений, увеличивает число потоков. Поток в этой модели — последовательность записей с одним и тем же набором меток. Записи складываются в него по времени и сжимаются кусками (chunks).
Посчитаем на условном примере; цифры придуманы только для иллюстрации. Пусть у нас 4 сервиса, 2 окружения и 3 уровня логирования. Это 4 × 2 × 3 = 24 комбинации, то есть не более 24 потоков. В каждом из них за час накапливается много строк, они хорошо сжимаются, куски получаются плотными.
Теперь добавим request_id. Если за час проходит 100 000 запросов и каждый оставляет в логах хотя бы одну строку, потоков становится уже порядка 100 000 вместо 24. В каждом потоке лежит одна-две строки. Вместо крупных плотных кусков получается россыпь мелочи, у каждой песчинки свои метаданные, своя запись в индексе и своя нагрузка на память при приёме данных. Объём полезного текста тот же, а накладные расходы выросли на порядки.
Чтобы увидеть последствие в одной строке, возьмём тот же условный час. С меткой request_id каждая запись, например «payment gateway timeout» с идентификатором 9f3c1a7e, открывает собственный поток: Loki заводит для него отдельный набор метаданных, отдельный буфер в памяти и отдельный кусок, который закроется, почти ничего не накопив. Без этой метки та же запись попадает в поток {service="checkout", env="prod", level="error"} и ложится рядом с сотнями соседей. Текст тот же, но в первом случае система обслуживает сто тысяч мелких единиц, а во втором одну крупную последовательность.
Есть и вторая сторона: сжатие. Соседние строки одного сервиса похожи друг на друга, поэтому в большом куске они сжимаются хорошо. В потоке из одной строки сжимать нечего, и каждая запись тянет за собой почти полный набор служебных данных. Какую долю в итоге составят накладные расходы, зависит от длины ваших строк: у длинных стектрейсов она меньше, у коротких сообщений вроде «timeout» заметно больше.
Заметьте: растёт не количество строк, а количество уникальных комбинаций. Именно поэтому проблему не видно по привычному счётчику «сколько логов в секунду». Поток данных может выглядеть одинаково, а внутри устройство хранения будет совершенно другим.
Почему короткие потоки плохи именно на небольшом VPS
На виртуальном сервере с ограниченными vCPU, памятью и NVMe-диском эффект ощущается раньше, чем на большом кластере. Оперативная память расходуется на удержание открытых потоков и их буферов, процессор тратит время на обслуживание индекса и множества мелких кусков, диск получает много мелких записей вместо немногих крупных. Ресурсы тарифов, например 2 ГБ RAM у Ba или 4 ГБ у Uab, не стоит воспринимать как гарантированную ёмкость под определённое число потоков: реальный предел зависит от версии Loki, настроек и самой нагрузки, и его определяют замером. Но направление ясно: чем меньше потоков при том же объёме текста, тем больше запаса остаётся на сам поиск и приём.
Стабильные признаки и идентификаторы: где проходит граница
Полезное правило для выбора меток: метка должна описывать, откуда пришли данные, а не какое именно событие произошло. Документация прямо относит динамические идентификаторы вроде идентификатора клиента или транзакции к плохим кандидатам для индексируемых меток. Сопоставим типичные поля.
| Поле | Сколько значений | Как использовать |
|---|---|---|
| service, app | единицы или десятки, меняются редко | метка |
| env (prod, staging) | несколько | метка |
| host, instance | по числу серверов | метка, если серверов немного и они долгоживущие |
| level | 3–6 | метка или поле в строке, в зависимости от того, как вы фильтруете |
| http_status | десятки, но часть значений редка | скорее содержимое строки; метка допустима при осторожном подходе |
| user_id | растёт вместе с аудиторией | не метка |
| request_id, trace_id | уникален для каждого запроса | не метка |
Граница не абсолютна. У host значений может быть мало, а если вы каждый день пересоздаёте контейнеры с новыми именами, значения копятся и кардинальность растёт незаметно. Поэтому смотрите не на название поля, а на то, как быстро в нём появляются новые значения и сколько их накопится за срок хранения логов.
Отдельно о user_id. Он коварнее request_id: пользователей не миллионы в минуту, значений вроде бы «умеренно». Но каждый пользователь умножается на остальные метки, а активные дни дают всплески. Комбинация service × env × level × user_id разрастается быстрее, чем ожидаешь, а ценность метки сомнительна: вряд ли вы смотрите в панели логи по каждому пользователю как по постоянному разрезу.
Сравним два идентификатора на условных числах. Допустим, у сервиса 5 000 активных пользователей за неделю, а метки env и level дают 2 × 3 = 6 комбинаций. Если пользователь пишет ошибки каждого уровня в каждом окружении, потоков может стать до 5 000 × 6 = 30 000 на один сервис, и это только верхняя граница при одном сервисе. Добавьте ещё несколько сервисов, и множитель применится к каждому. Для request_id граница вообще не ограничена аудиторией: она растёт вместе с числом запросов.
Практическое различие такое: user_id разрастается постепенно и «по неделям», а request_id ломает структуру сразу. Поэтому проблему с пользовательским идентификатором часто замечают позже, когда метку уже встроили в дашборды. Если вам нужно смотреть логи конкретного пользователя, это обычный запрос с фильтром по строке в узком окне времени, а не причина строить отдельный поток на каждого человека. Документация Loki относит динамические идентификаторы вроде клиентских или транзакционных к плохим кандидатам для индексируемых меток, и пользовательский идентификатор здесь не исключение.
Как убедиться, что причина именно в кардинальности
Медленный Loki и растущий диск бывают по разным причинам: слишком длинное хранение, подробный debug-уровень, огромные многострочные стектрейсы, неудачные запросы по большим диапазонам времени. Чтобы не лечить не то, проверьте симптомы, характерные именно для высокой кардинальности:
- число активных потоков растёт гораздо быстрее, чем объём принимаемых данных;
- средний размер потока крошечный: записей мало, а потоков много;
- в списке значений какой-то метки сотни тысяч вариантов, и они похожи на идентификаторы;
- память процесса растёт при приёме, хотя входящий поток строк не изменился;
- запросы на выборку по меткам возвращают огромные списки потоков.
Прямой способ увидеть проблему: посмотреть, какие метки есть у данных и сколько у них значений. Если вы работаете с logcli, подойдут команды вида logcli labels и logcli labels request_id; точный синтаксис и доступность зависят от версии клиента, поэтому сверяйтесь с его справкой. Если у метки список значений бесконечно длинный и выглядит как перечень случайных строк, диагноз почти поставлен. Метрики самого Loki тоже показывают число потоков; названия и состав метрик различаются по версиям, так что ориентируйтесь на документацию вашей версии.
Если установка у вас ещё впереди или вы собираете тестовый стенд, можно начать с материала о том, как поставить Grafana Loki на Ubuntu 24.04, а потом уже играть с метками на копии данных.
Что делать с идентификатором: поиск после переноса
Главный страх при чистке меток простой: «если уберу request_id, как я найду нужный запрос?». Это справедливый вопрос, и от него зависит, пострадает ли удобство. Механическое удаление поля ради красивой статистики — плохая идея. Нужно сохранить возможность найти событие, но перестать использовать идентификатор как часть ключа хранения.
Есть два пути, и они отличаются тем, что требуется от вашей версии Loki.
Путь первый: идентификатор внутри строки
Самый универсальный вариант. Приложение пишет request_id прямо в строку лога, лучше в стабильном формате: key=value или JSON. Поиск превращается в запрос по стабильным меткам и фильтр по тексту:
{service="checkout", env="prod"} |= "9f3c1a7e"Если логи в JSON, можно разобрать строку на лету и отфильтровать по полю:
{service="checkout", env="prod"} | json | request_id="9f3c1a7e"Здесь индекс сужает круг до потоков сервиса и окружения, а дальше Loki просматривает содержимое выбранных потоков за указанный период. Чем уже диапазон времени, тем дешевле просмотр. На практике идентификатор обычно приходит из жалобы вместе с примерным временем, и это отличное подспорье: вы задаёте окно в несколько минут, а не сутки.
Путь второй: structured metadata
Документация сообщает, что structured metadata предназначены в том числе для часто искомых полей высокой кардинальности, при этом такие поля не попадают в индекс меток. Это как раз случай request_id, trace_id и подобных идентификаторов: они остаются привязанными к записи и доступны запросам, но не плодят потоки.
Оговорка важная: функция зависит от версии Loki и настроек, и включается не везде одинаково. Прежде чем строить на ней схему, проверьте по документации вашей версии, поддерживается ли она и что нужно включить в конфигурации и в агенте, который доставляет логи. Если не поддерживается, путь первый остаётся рабочим и ничем не хуже в смысле структуры хранения.
Выбор между путями можно свести к простому правилу: если версия и агент поддерживают structured metadata, это более аккуратное место для идентификаторов, которые вы ищете часто; если нет или вы не готовы менять конфигурацию, пишите их в строку и ищите через фильтр.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Прослеживаем один запрос от жалобы до строки
Представим ситуацию на условном примере. Поддержка присылает: клиент не смог оплатить заказ около 14:20, в сообщении об ошибке виден код 9f3c1a7e.
До переноса вы бы выполнили что-то вроде {service="checkout", request_id="9f3c1a7e"} и сразу получили поток из одной-двух строк. Быстро, но ценой тысяч мелких потоков.
После переноса последовательность такая:
- Сужаете диапазон времени в Grafana до интервала 14:10–14:30.
- Выбираете стабильные метки:
service="checkout",env="prod". - Добавляете фильтр по тексту
|= "9f3c1a7e"или, для JSON, разбор и условие по полю. - Находите строку с ошибкой, а по соседним строкам того же потока и времени восстанавливаете контекст: что вызывало шлюз, сколько длился таймаут.
Заметьте, что контекст стал даже доступнее. Раньше строки одного запроса были разбросаны по отдельным потокам (по одному на идентификатор, если идентификатор одновременно менялся), а теперь все записи сервиса лежат в общем потоке в порядке времени, и соседние сообщения видны рядом.
Цена такого поиска — просмотр содержимого в выбранных потоках. Для узкого окна времени она невелика. Для запроса «найди этот идентификатор за последние 30 дней» она будет заметной, и здесь удаление метки действительно может сделать конкретный поиск медленнее. Это честный компромисс: вы платите временем на редкий широкий поиск, чтобы не платить хранением и памятью на каждую запись. Если широкие поиски по идентификатору у вас частые, это довод в пользу structured metadata или в пользу трассировки, где идентификатор — первоклассный объект.
Разберём цену широкого поиска на условном примере. Пусть за сутки сервис checkout пишет 2 000 000 строк. Запрос с фильтром |= "9f3c1a7e" на окно в двадцать минут просматривает примерно сотую долю суток, то есть порядка 28 000 строк при ровной нагрузке. Тот же запрос за 30 дней просматривает уже около 60 000 000 строк: объём работы вырос в 2 100 раз вместе с окном. Расчёт условный, он показывает пропорцию, а не измеренное время.
Отсюда практическое правило: не вводить идентификатор в метки ради редких поисков по месяцу, а сузить окно с помощью другого признака. Время жалобы, сервис и окружение обычно уже известны. Если же идентификатор приходит без времени, полезно просить его у поддержки или записывать время рядом с кодом ошибки, который видит клиент. А для идентификаторов, которые вы ищете часто и по длинным периодам, подходят structured metadata: они остаются привязанными к записи, но не попадают в индекс меток, если ваша версия Loki и агент их поддерживают.
Сравнение до и после на одинаковом наборе логов
Самая частая ошибка при оптимизации — сравнивать несравнимое: «до» измерено в понедельник под нагрузкой, «после» в воскресенье ночью. Чтобы сравнение было честным, зафиксируйте условия.
- Один набор данных. Возьмите фиксированный отрезок логов, например один час прошлого рабочего дня, и загрузите его в два экземпляра Loki или в один экземпляр по очереди с очисткой между прогонами. Первый вариант с
request_idв метках, второй с идентификатором в содержимом или в structured metadata. - Одинаковая конфигурация. Те же лимиты, то же хранилище, тот же срок хранения, та же версия Loki.
- Одинаковые запросы. Заранее составьте короткий список: поиск по сервису за 15 минут, поиск идентификатора в окне 10 минут, поиск идентификатора за сутки, подсчёт ошибок по уровням за час. Выполняйте каждый несколько раз и смотрите на разброс, а не на лучший результат.
Что сравнивать:
| Показатель | Что покажет |
|---|---|
| Число активных потоков | прямое следствие кардинальности |
| Размер хранилища после приёма | влияние мелких кусков и индекса |
| Память процесса при приёме | цена удержания множества потоков |
| Время запроса по стабильным меткам | не ухудшилась ли базовая навигация |
| Время поиска идентификатора в узком и широком окне | цена переноса поля из меток |
Не ждите от этой таблицы универсальных коэффициентов. Насколько уменьшится хранилище, зависит от того, какая доля потоков была «одиночками», как хорошо сжимаются ваши строки, какой у вас размер кусков и сколько других меток участвует в комбинациях. На одних данных разница будет резкой, на других скромной. Любое число из чужого примера — это чужой набор данных, а не ваш прогноз.
Чтобы результат можно было прочитать, записывайте его в одну таблицу с двумя колонками, «с идентификатором в метках» и «с идентификатором в содержимом», и отдельной строкой для каждого показателя. Рядом пометьте версию Loki, размер тестового часа и число строк в нём. Тогда через месяц вы поймёте, на каких данных сделан вывод, а не станете спорить с памятью.
Обратите внимание на два признака, которые легко пропустить. Первый: время запроса по стабильным меткам не должно ухудшиться, иначе вы случайно убрали метку, которая нужна для навигации. Второй: широкий поиск идентификатора после переноса, скорее всего, станет медленнее, и это нормально, если он редкий. Если же после переноса приём стал легче, а типичный поиск в окне десяти минут остался приемлемым, схема сработала для вашего набора. Для чужого набора, с другой долей одиночных потоков и другой длиной строк, результат может быть иным, поэтому цифры стоит собирать самим.
Что происходит со старыми данными
Удаление метки из конфигурации агента влияет на новые записи. Уже сохранённые потоки с request_id не исчезают сами: они лежат до истечения срока хранения или до удаления по вашей политике. Поэтому после переноса картина улучшается постепенно, по мере того как старые потоки покидают окно хранения. Если вы сравниваете показатели на следующий день после правки, не удивляйтесь, что общее число потоков в системе ещё велико: смотрите на прирост новых потоков за интервал, а не на суммарную цифру.
Есть и обратная сторона. Если вы переносите идентификатор в содержимое, старые запросы вида {request_id="..."} перестанут находить новые записи. Дашборды, алерты и закладки, которые используют эту метку, нужно обновить заранее, иначе сломается привычная работа дежурных. Составьте список мест, где метка встречается, прежде чем менять конфигурацию.
Практика на VPS: порядок действий без лишней спешки
Небольшому серверу особенно полезна осторожность. Выделенный тестовый экземпляр Loki на отдельной машине избавляет от риска повлиять на рабочие логи. VPS с NVMe, root-доступом и несколькими локациями (в публичном предложении MAATRIX указаны UK, US, RU и FR) подойдёт для такого стенда, а сам сервер создаётся после оплаты за пару минут. Точный выбор тарифа определяется замером: начните с небольшого варианта, например Ba или Uab, посмотрите на потребление памяти и диска при приёме тестового часа логов, и поднимайтесь выше, только если видите упор в ресурс, а не просто потому что «страшно».
Дальнейший порядок:
- Составьте перечень меток и посчитайте число значений каждой за выбранный период.
- Отметьте подозрительные: те, значения которых похожи на идентификаторы или быстро растут.
- Найдите, где эти метки используются в запросах, дашбордах и правилах.
- Решите для каждого поля: остаётся меткой, переезжает в строку или в structured metadata, если версия это поддерживает.
- Проверьте на стенде схему на одинаковом наборе логов и сравните показатели из таблицы выше.
- Переключайте боевой агент поэтапно: сначала один сервис, потом остальные.
Не торопитесь также убирать полезные поля вместе с проблемными. level или status, которые вы действительно используете в разрезах, могут оставаться метками, пока их значений единицы. Задача не в том, чтобы выбросить как можно больше меток, а в том, чтобы каждая оставшаяся честно отвечала на вопрос «по какому признаку я выбираю поток».
Когда метка с идентификатором всё же оправдана
Редкие случаи есть. Если у вас очень маленькая система, несколько тысяч событий в сутки, и срок хранения короткий, лишние потоки ничего не стоят в реальном смысле, и вы можете сознательно оставить метку ради простоты. Но такая схема плохо масштабируется: рост трафика превратит терпимую особенность в источник проблем, а исправлять придётся уже под нагрузкой. Если вы всё-таки идёте на этот компромисс, запишите его в документации к стенду и задайте порог, при котором вернётесь к вопросу.
Найденный запрос
Вернёмся к жалобе клиента. Идентификатор 9f3c1a7e вставлен в фильтр, окно времени сужено до двадцати минут, метки сервиса и окружения выбраны. Среди строк появляется нужная: таймаут платёжного шлюза, а рядом, в том же потоке, предыдущие и следующие сообщения сервиса. Идентификатор по-прежнему находит свой запрос, но перестал решать, на сколько частей разделить хранилище. Он остался подсказкой для человека, а не ключом для базы, и именно эта роль ему и подходит.