Логи вы уже сделали полезными — структурными, с trace_id. Остаётся приземлённый, но дорогой вопрос: где их хранить и как по ним искать. И тут выясняется, что Loki, OpenSearch и VictoriaLogs — это не три похожих «хранилища логов», из которых берут любое, а три разные философии индексации. Именно она определяет, сколько вы заплатите за хранение, каким языком будете искать, где напоретесь на кардинальность и насколько тяжело всё это эксплуатировать.
Всё, что ниже, снято со стенда, где один поток логов веером уходит во все три бэкенда: один генератор, один Vector, три синка из одного источника. Это условие сравнения — во все три приезжает буквально одно и то же событие, а не три похожих потока.
Главное, что дал стенд, я вынес сюда сразу. Объёмы различаются больше чем в двадцать раз, память в покое — в 167 раз, а при неверно положенном поле Loki не «замедляется», а отказывается принимать данные: 13 523 записи из 100 000 и 45 ответов «429 Too Many Requests».
В статье
- Три философии индексации
- Один запрос на трёх языках — и почему он не один
- Объём: больше чем в двадцать раз
- Память: 167 раз ещё до первой записи
- Кардинальность: Loki отказывается принимать
- Отказ одного синка тормозит весь конвейер
- А если настроить?
- Когда что выбирать
- Итог
- Документация и первоисточники
Три философии индексации
Всё остальное — следствие одного решения: что индексировать.
- Loki — индексирует только метки. Лог — это поток (stream), определённый набором меток (
{service="checkout", level="error"}). Индекс строится только по меткам; само тело лога не индексируется — оно лежит сжатыми чанками, а при запросе фильтруется перебором. Индекс крошечный и дешёвый; расплата — тело ищется сканом, и всё держится на дисциплине меток. - OpenSearch — индексирует всё. Полнотекстовый инвертированный индекс на поля по маппингу. Любое слово в логе ищется мгновенно, плюс агрегации и дашборды. Расплата — индекс большой, нужен диск и heap, эксплуатация тяжелее.
- VictoriaLogs — потоки плюс колоночное хранение остальных полей. Модель «поток на комбинацию полей» здесь та же, что у Loki: поля из
_stream_fieldsопределяют поток, и ниже видно, как высококардинальное поле в этом списке плодит потоки. Отличие в остальных полях: они не обязаны быть метками, хранятся по колонкам и индексируются экономно, поэтому искать можно по любому полю. Середина: поиск шире, чем у Loki, а ресурсов и места уходит кратно меньше, чем у OpenSearch.
| Loki | OpenSearch | VictoriaLogs | |
|---|---|---|---|
| Что индексируется | только метки | все поля (full-text) | поля, экономно (колоночно) |
| Тело лога | скан перебором | инвертированный индекс | колоночный скан + индекс |
| Язык запросов | LogQL | Query DSL / Lucene / PPL / SQL | LogsQL |
| Full-text-поиск | слабый (скан) | сильный | средний |
| Зрелость | зрелая (Grafana) | очень зрелая | молодая, растёт |
Три оставшиеся строки такой таблицы — стоимость хранения, ресурсы и поведение при кардинальности — обычно заполняют словами «низкая / высокая». Ниже они заполнены числами.
Один запрос на трёх языках — и почему он не один
Задача: сколько записей уровня error у сервиса checkout. Ответ известен заранее — генератор посчитал его до отправки: 1334.
LogQL: sum(count_over_time({service="checkout", level="error"}[2h]))
DSL: {"query":{"bool":{"filter":[
{"term":{"service.keyword":"checkout"}},
{"term":{"level.keyword":"error"}}]}}}
LogsQL: service:="checkout" AND level:="error" | count()Все три ответили 1334 — то есть данные приехали одинаково, и остальные замеры на этом стенде можно считать сопоставимыми. Именно поэтому проверка сделана первой: пока не доказано, что во все три попал один и тот же набор, сравнивать объёмы бессмысленно.
А теперь неприятное. Эти запросы отличаются не только синтаксисом. Одна и та же отправленная запись приезжает в три бэкенда с разными именами полей:
OpenSearch level, message, service, timestamp <- как отправили
VictoriaLogs _msg, _stream, _stream_id, _time, level, service <- переименовал и добавил
Loki service, level, service_name <- ДОБАВИЛ своёVictoriaLogs переименовал message в _msg, а timestamp в _time, и добавил свои _stream и _stream_id — идентификатор потока, в который попала запись. Loki добавил метку service_name, которой в записи не было, — он вывел её из service. Плюс у OpenSearch точное совпадение по строке ищется через подполе .keyword, которое создаёт динамический маппинг.
И тут же — оговорка, которая мне самому стоила ошибки в первой редакции. Набор меток Loki зависит не только от его конфигурации, но и от способа доставки. При прямом push в Loki к этому набору добавляется ещё и detected_level — уровень, который Loki определяет из содержимого сам. Через синк Vector этой метки нет. Я перенёс наблюдение с одного конвейера на другой и получил в тексте лишнюю метку, которой в замерах не было; набор выше снят с живого стенда, а не восстановлен по памяти.
Отсюда вывод сильнее, чем «у каждого свой синтаксис»: переносимости запросов между бэкендами логов нет на уровне модели данных. Переезд означает переписывание не только грамматики, но и имён полей — включая те, которых вы не создавали.
Объём: больше чем в двадцать раз
Сто тысяч записей, исходный файл 10,5 МиБ. Во все три доехало ровно по 100 000 — это предусловие проверялось до замера, иначе меньший объём у бэкенда с потерями читался бы как компактность.
| Бэкенд | данные (прогон 1 / прогон 2) | к исходному файлу | весь том |
|---|---|---|---|
| VictoriaLogs | 187,7 / 177,8 КиБ | 0,02× | 181,8 КиБ |
| Loki | 570,8 / 587,0 КиБ | 0,05× | 628,3 КиБ |
| OpenSearch | 4,1 / 4,2 МиБ | 0,40× | 4,2 МиБ |
Колонки разные, и путать их не надо. Данные — каталоги данных без журнала упреждающей записи (у Loki это chunks и tsdb-shipper-active, но не wal); это ответ на вопрос «сколько занимает хранение». Весь том — то же плюс журналы и служебные файлы, то есть сколько места нужно работающему тому. Планировать диск надо по второй колонке: WAL лежит рядом с данными, а не вместо них. Заметно это только у Loki — у него журнал добавляет к объёму десятую часть.
Прогонов два, и это не педантизм. Внутри одного прогона числа стоят намертво — разброс серии 0–2,2 %, — но между прогонами VictoriaLogs расходится на 5 %, а кратность между крайними гуляет: 22,4× и 23,9×. Причина не в бэкендах, а в том, что каждый прогон — своя история фоновых слияний: сколько сегментов успело слиться, в какой момент срезался чанк. Внутрисерийный разброс меряет одно такое состояние, а не разные, и потому воспроизводимость им не описывается.
Отсюда осторожная формулировка: разница больше чем в двадцать раз, а не «ровно 22,4». Устойчив порядок величины, и он подтверждает устройство: полнотекстовый индекс на все поля дороже индекса по меткам и колоночного хранения. Сама кратность привязана к потоку — короткие однотипные сообщения сжимаются лучше разнородных, и на ваших логах число будет другим.
Что я мерил не то
Первая редакция замера показала у Loki 8,7 МиБ. Через несколько минут том занимал 2,2 МиБ — вчетверо меньше. Дальше — 2,30, потом 2,31, потом 3,56 МБ: размер гулял в обе стороны, потому что фоновые слияния и компакция идут паузами.
Разбор состава тома объяснил и это, и кое-что похуже:
/data/wal 2 240 512 <- журнал упреждающей записи
/data/tsdb-shipper-active 32 800 <- индекс
каталога chunks НЕТ ВОВСЕЯ мерил журнал, а данных на диске не было вообще: Loki держал чанки в памяти. По умолчанию chunk_idle_period — 30 минут, max_chunk_age — 2 часа, и на коротком прогоне записи физически не доходят до хранилища. Перезапуск не помогал: WAL реплеился обратно в память.
Лечится это заданием коротких интервалов сброса — как условие замера, а не как оптимизация. И с обратной стороной, которую надо назвать: частый сброс даёт мелкие чанки, а они сжимаются хуже крупных, так что объём Loki в таблице выше скорее завышен относительно продового режима.
Отсюда практическое правило, полезное далеко за пределами этого стенда: прежде чем мерить «сколько занимает», проверьте, что измеряемое уже лежит там, где вы смотрите.
Память: 167 раз ещё до первой записи
| Бэкенд | в покое | под нагрузкой |
|---|---|---|
| VictoriaLogs | 7,3–9,4 МиБ | 62–141 МиБ |
| Loki | 45,8–53,6 МиБ | 114–126 МиБ |
| OpenSearch | 1559,6–1651,7 МиБ | 1718–1789 МиБ |
Два разных отношения, и путать их нельзя: 167–212× в покое и 13–25× под нагрузкой.
В покое разрыв максимален, потому что JVM забирает заданный -Xms сразу и не отдаёт: полтора гигабайта заняты ещё до первой записи. Под нагрузкой разрыв сокращается — у Loki и VictoriaLogs память растёт с нагрузкой в разы, а OpenSearch прибавляет около 10 %. В этой конфигурации (-Xms1g -Xmx1g) основную часть его памяти задаёт heap, и потому она почти не зависит от нагрузки. Это наблюдение о стенде, а не правило «память OpenSearch — константа»: вне heap у JVM и Lucene есть своя память — метаспейс, direct-буферы, отображённые в память сегменты индекса, — и на больших индексах она растёт.
Практический смысл зависит от того, сколько у вас инсталляций. Для одной большой важнее поведение под нагрузкой. Для десятка мелких — число в покое: столько памяти нужно просто чтобы держать сервис запущенным.
А вот про CPU я сказать ничего не могу, и это честнее, чем сказать неправду. Сначала я записал вывод «пик у OpenSearch на порядки выше, шумом не объясняется». Три прогона одной конфигурации дали пик 662,6 %, 70,5 %, 513,9 % и 636,4 % — между вторым и остальными разница на порядок. Причина в способе снятия: docker stats даёт мгновенное значение раз в три секунды, а поток проглатывается за секунды, так что попадание на всплеск — вопрос везения. Единственное, что данные выдерживают: у OpenSearch всплески бывают выше 100 %, то есть индексация распараллеливается на несколько ядер.
Кардинальность: Loki отказывается принимать
Здесь главный опыт статьи, и он устроен из трёх прогонов, различающихся одной вещью — куда попадает поле request_id, уникальное на каждую запись:
plain— поля нет вовсе;body— поле есть, лежит в теле записи;label— то же поле объявлено меткой Loki и частью потока VictoriaLogs.
# vector/agent-highcard.yaml, синк Loki
labels:
service: "{{ service }}"
level: "{{ level }}"
request_id: "{{ request_id }}" # <- вот она
# синк VictoriaLogs
query:
_stream_fields: service,level,request_id # <- и вот онаРезультат:
| plain | body | label | |
|---|---|---|---|
| Loki, серий | 9 | 9 | 13 726 |
| VictoriaLogs, потоков | 9 | 9 | 22 640 |
| OpenSearch, полей в маппинге | 7 | 9 | 9 |
| Loki, объём | 533 КиБ | 905 КиБ | 59,9 МиБ |
| VictoriaLogs, объём | 179 КиБ | 646 КиБ | 4,4 МиБ |
Но самое важное — не в этой таблице, а в следующей:
| режим | доставка завершена | время | Loki принял | отказов 429 |
|---|---|---|---|---|
| plain | да | 18 с | 100 000 | 0 |
| body | да | 19 с | 100 000 | 0 |
| label | нет | 368 с | 13 523 | 45 |
maximum active stream limit exceeded when trying to create stream
{level="info", request_id="req-card-label-00017978", service="checkout",
service_name="checkout"}, reduce the number of active streams
(reduce labels or reduce label values)Это принципиально иное поведение, чем «стало дороже». Система не принимает данные, Vector уходит в бесконечные повторы, и за 368 секунд доехало 13,5 % потока. В отдельном продолжении за ещё восемь минут прибавилось около сотни записей.
Цифры объёма в строке label поэтому относятся к части данных — сравнивать их с plain как равные нельзя, и в отчёте стенда это оговорено отдельно.
Вывод, ради которого всё это мерилось: беда не в уникальном идентификаторе, а в том, куда его положили. В теле записи он стоит объёма — Loki 1,7×, VictoriaLogs 3,6×, и это предсказуемая цена данных. В метке он множит сущности индекса в полторы-две с половиной тысячи раз и упирается в лимит.
Управляет этим настройка конвейера, а не сам факт наличия идентификатора в логе:
- Loki — какие поля перечислены в
labelsу синка; - VictoriaLogs — что указано в
_stream_fieldsпри приёме; - OpenSearch — динамический маппинг, где новый ключ становится полем.
Про OpenSearch отдельно: уникальные значения его схему не расширяют (7 → 9 полей, потому что добавился один ключ плюс его .keyword). Взрыв маппинга у него бывает от уникальных ключей — когда в лог кладут объект с именами полей вида attr_<uuid>. Это другая ошибка, и здесь она не мерилась.
Отказ одного синка тормозит весь конвейер
Побочный результат того же опыта, который стоит отдельного раздела.
В режиме label OpenSearch и VictoriaLogs приняли по 22 640 записей — ровно столько, сколько успел отдать общий Vector. Сами они ни в какие лимиты не упёрлись: их тормозил соседний синк.
Причина в устройстве конвейера: один источник, одна трансформация, три синка. Когда один синк не принимает и копит повторы, встают все. Точное число зависит от прогона — отдельный замер, поставленный ради этого раздела, в трёх прогонах дал соседям 12 509, 18 308 и 11 605 вместо 22 640 (в таблицах ниже — последний), — но картина одна: соседи получают ровно столько же, сколько отказавший.
Первое объяснение, которое напрашивается, — «у них общий буфер, нужны раздельные». Оно неверно, и я проверил это отдельным замером. Буфер у каждого синка Vector и так свой. Дело не в раздельности, а в том, что буфер конечен, а политика переполнения по умолчанию — when_full: block: заполнившись, буфер отказавшего синка останавливает отдачу вверх по топологии, и соседи, читающие тот же источник, встают вместе с ним.
Проверяется это конфигурацией, отличающейся ровно одним параметром:
buffer:
type: memory
max_events: 500
when_full: drop_newest # вместо block по умолчаниюДва прогона, отличающиеся только этими четырьмя строками:
block (по умолчанию) |
drop_newest |
|
|---|---|---|
| OpenSearch принял | 11 605 | 100 000 |
| VictoriaLogs принял | 11 605 | 100 000 |
| Loki принял (по API) | 5 000 | 5 000 |
Читается это так. При block соседи получают ровно столько же, сколько успел проглотить отказавший синк, — потому что остановка поднимается выше точки ветвления. При drop_newest соседи получают весь поток целиком.
Дальше — то, на чём я сам ошибся в первой редакции этого замера. Сказать «у Loki потерялось столько-то» можно, только если сходится баланс событий: всё, что буфер принял, обязано разложиться на выброшенное, доставленное, лежащее в буфере и крутящееся в повторах.
| судьба события на синке Loki | block |
drop_newest |
|---|---|---|
| буфер принял | 11 605 | 100 000 |
| — выброшено при переполнении | 0 | 89 750 |
| — доставлено (успешными запросами) | 2 857 | 2 874 |
| — в повторах | 8 143 | 6 878 |
| — лежит в буфере | 605 | 498 |
прямой счётчик vector_buffer_discarded_events_total |
0 | 89 750 |
Теперь видно и главное различие, и то, что за ним стоит. block не выбрасывает ничего — он ждёт, и ожиданием заражает соседей. drop_newest выбрасывает 89 750 записей из 100 000 — молча для обычного лога: намеренный выброс Vector пишет только на уровне debug, — и этой ценой освобождает соседей. Увидеть потери можно по счётчику vector_buffer_discarded_events_total (метки buffer_id, stage, intentional): он совпал с балансом до события. Его серия появляется только после первого выброса, поэтому при block её нет — это ноль, а не отсутствие метрики.
Отдельная ловушка учёта, на которой легко построить неверный вывод: «доставлено» меньше, чем «Loki принял» — 2 874 против 5 000. Оба счётчика считают события, так что «разными единицами» это не объясняется. Объяснение в другом: Loki принимает запрос частично.
Это стоило отдельного опыта, потому что иначе оставалось бы догадкой. Занимаем предел активных потоков, затем шлём один запрос из двух потоков — существующего и нового:
ответ на смешанный запрос: 429
записей в существующем потоке до: 1
записей в существующем потоке после: 2 <- строка сохранена
записей в новом потоке: 0 <- строка отвергнутаЗапрос завершён ошибкой, но строка в поток, который ещё помещался в лимит, записана. Vector считает такой запрос неуспешным целиком, и события из него в счётчик доставленных не попадают — хотя часть их Loki хранит. Отсюда практическое правило: по счётчику успешной отправки нельзя судить о том, что лежит в бэкенде — ни в большую сторону, ни в меньшую.
И отдельно то, чего настройка не меняет: Loki в обоих случаях принял ровно 5 000 — это max_global_streams_per_user, предел активных потоков, который у Loki по умолчанию равен пяти тысячам. Каждая запись здесь создаёт свой поток, поэтому предел потоков и число записей совпали.
Почему тогда в замере выше у Loki 13 523, а не 5 000? Потому что предел считается по активным потокам: за длинный прогон часть успевает устареть и освободить место, и суммарно проходит больше, чем помещается одновременно. Столько же — не значит столько за раз. Сколько именно просочится до отказа, зависит от длительности потока, а не только от предела; устойчиво тут другое — приём прекращается, и никакая настройка доставки этого не лечит.
Политика буфера решает судьбу соседей, а не отказавшего. Вылечить отказ настройкой доставки нельзя; можно только перестать заражать им остальных.
Формулировать вывод как «сделайте раздельные буферы» — значит советовать то, что уже сделано. Правильная постановка вопроса другая: какова политика переполнения и допустимы ли потери. Ответов ровно три, и все с ценой: block — сохранность ценой общего торможения; drop_newest — независимость ценой потерь у отказавшего синка, молчаливых для обычного лога (видны они по vector_buffer_discarded_events_total — алерт на рост этого счётчика обязателен); отдельный процесс Vector на бэкенд — независимость без потерь ценой второго чтения источника и второго процесса.
А если настроить?
Вопрос, который иначе повисает после таблицы объёмов. Тот же поток, две конфигурации приёма.
| Бэкенд | дефолт | настроено | отношение |
|---|---|---|---|
| Loki | 570,8 КиБ | 569,0 КиБ | 1,00× |
| OpenSearch | 4,2 МиБ | 3,9 МиБ | 0,93× |
| VictoriaLogs | 187,6 КиБ | 187,7 КиБ | 1,00× |
VictoriaLogs здесь контрольная группа: его конфигурацию не меняли, и отношение 1,00× показывает разброс между прогонами вообще — почти нулевой. Значит 7 % у OpenSearch — эффект настройки, а не шум.
Настраивался строгий маппинг вместо динамического:
"mappings": {
"dynamic": "strict",
"properties": {
"message": { "type": "keyword" },
"request_id": { "type": "keyword", "index": false, "doc_values": false },
"service": { "type": "keyword" },
"level": { "type": "keyword" },
"timestamp": { "type": "date" }
}
}Семь процентов, а не разы — и причина в составе данных: генератор пишет пять коротких однотипных сообщений, инвертный индекс по такому набору мал сам по себе, резать почти нечего. На разнородных логах разница была бы больше; насколько — этот стенд не показывает.
Главная оговорка, без которой раздел был бы вредным. message как keyword дешевле в хранении, но полнотекстовый поиск по нему пропадает: искать можно только точное совпадение целой строки, а не слово внутри сообщения. Ровно за этот поиск OpenSearch обычно и берут. Так что «настроил и стало лучше» здесь неверно — стало дешевле и беднее одновременно, и размен выгоден только если логи ищут по полям, а не по тексту.
Настройка Loki не дала ничего, и это тоже результат. Отключение discover_log_levels не изменило набор меток — в обоих прогонах это level, service, service_name. Отключать было нечего: detected_level в этом конвейере не появляется вовсе, как разобрано выше. Прежде чем резать «лишнюю» метку в конфиге, посмотрите, приходит ли она у вас: настройка, которая ничего не отключает, выглядит в отчёте точно так же, как настройка, которая не применилась.
Когда что выбирать
- Loki — когда логов много, ищут по меткам и коротким периодам, а бюджет на хранение ограничен. Условие: дисциплина меток. Нарушение стоит не денег, а отказа приёма — как в замере выше.
- OpenSearch — когда нужен полнотекстовый поиск, агрегации и готовая аналитика, и вы согласны платить памятью (полтора гигабайта на пустой инстанс) и объёмом (больше чем в двадцать раз против самого компактного). Настройкой это уменьшается на проценты, а не на порядки — и ценой того самого поиска.
- VictoriaLogs — когда хочется искать по любому полю, не выбирая заранее, что проиндексировать, и при этом платить мало. Дисциплина всё равно нужна, но в одном месте:
_stream_fieldsзадают потоки ровно так же, как метки у Loki, и высококардинальное поле там создало 22 640 потоков. Самый компактный и самый экономный по памяти в этом замере. Расплата — молодость: экосистема и интеграции беднее, а предел его устойчивости к кардинальности стенд так и не нащупал.
Отдельно: лимит потоков есть у всех трёх, просто у Loki он ближе. VictoriaLogs в режиме label создал 22 640 потоков и продолжал принимать; где его предел — этот замер не выяснил.
Итог
- Во все три приезжает одно и то же, но называется по-разному.
messageстановится_msg,timestamp—_time, а Loki добавляет меткуservice_name, которую вы не отправляли (а при прямомpush— ещё иdetected_level). Переносимости запросов нет на уровне модели данных. - Объём различается больше чем в двадцать раз на одинаковом потоке: VictoriaLogs 178–188 КиБ, Loki 571–587 КиБ, OpenSearch 4,1–4,2 МиБ при исходных 10,5 МиБ. Точная кратность — величина прогона: два прогона одной конфигурации дали 22,4× и 23,9×.
- Память в покое различается в 167 раз — и это не про нагрузку, а про то, сколько нужно, чтобы просто держать сервис запущенным.
- Память у Loki и VictoriaLogs растёт с нагрузкой в разы, у OpenSearch в этой конфигурации её почти целиком задаёт heap. Поэтому под нагрузкой разрыв сокращается до 13–25 раз. Постоянства памяти OpenSearch вообще это не доказывает.
- Уникальное поле в теле стоит объёма, в метке — отказа приёма. Одно и то же
request_id: 1,7× к объёму в теле против 13 726 серий и 45 ответов «429» в метке. - Loki не деградирует плавно, он перестаёт принимать. 13 523 записи из 100 000 за 368 секунд, дальше прирост почти нулевой.
- Отказ одного синка тормозит весь конвейер — и «раздельные буферы» тут не помогают. В Vector они и так раздельные; связывает соседей политика
when_full: block, поднимающая backpressure выше точки ветвления. Выбор не между «раздельно и вместе», а между сохранностью и независимостью. - У OpenSearch кардинальность значений не расширяет схему — только уникальные ключи. Его беда другая и лечится иначе.
- Настройка OpenSearch дала 7 %, а не разы, и отняла полнотекстовый поиск по сообщению. Дешевле и беднее одновременно.
- Настройка Loki не дала ничего, потому что отключать было нечего: метка, которую собирались убрать, в этом конвейере и не появлялась.
- Размер на диске у этих систем не стабилизируется. Фоновые слияния меняют его в обе стороны минутами позже; мерить надо серией, а не снимком.
- CPU этими средствами не меряется. Четыре прогона одной конфигурации дали пик 662 %, 70 %, 514 % и 636 % — величина не определяется, определяется только факт распараллеливания.
На этом серия «Наблюдаемость на практике» закончена: от первого спана до счёта за хранение.
Документация и первоисточники
- Loki: labels best practices — почему метки должны быть низкокардинальными.
- Loki: limits —
max_global_streams_per_userи соседние ограничения. - LogQL — селекторы потоков и фильтры.
- OpenSearch: mappings — динамический и строгий маппинг,
keywordпротивtext. - OpenSearch: index templates — как задать маппинг до создания индекса.
- VictoriaLogs: LogsQL — синтаксис запросов.
- VictoriaLogs: data model —
_msg,_time,_stream_fieldsи что считается потоком. - Vector: sinks — синки
lokiиelasticsearch, буферы и повторы. - Стенд, конфиги и сырые выводы замеров —
digital-cookbook/observability/log-backends.
Комментарии