Где хранить логи: Loki vs OpenSearch vs VictoriaLogs

Три бэкенда логов — три разные философии индексации, а не три похожих продукта. Loki индексирует только метки и сканирует тело brute-force; OpenSearch строит полнотекстовый инвертированный индекс на всё; VictoriaLogs хранит колоночно и индексирует поля экономно. Отсюда всё расходится: стоимость хранения, язык запросов (LogQL/DSL/LogsQL), ловушки кардинальности, операционная сложность. Разбираем один и тот же запрос на трёх языках, цену каждого подхода и когда что выбирать

Логи вы уже сделали полезными — структурными, с trace_id. Остаётся приземлённый, но дорогой вопрос: где их хранить и как по ним искать. И тут выясняется, что Loki, OpenSearch и VictoriaLogs — это не три похожих «хранилища логов», из которых берут любое, а три разные философии индексации. Именно она определяет, сколько вы заплатите за хранение, каким языком будете искать, где напоретесь на кардинальность и насколько тяжело всё это эксплуатировать.

Всё, что ниже, снято со стенда, где один поток логов веером уходит во все три бэкенда: один генератор, один Vector, три синка из одного источника. Это условие сравнения — во все три приезжает буквально одно и то же событие, а не три похожих потока.

Главное, что дал стенд, я вынес сюда сразу. Объёмы различаются больше чем в двадцать раз, память в покое — в 167 раз, а при неверно положенном поле Loki не «замедляется», а отказывается принимать данные: 13 523 записи из 100 000 и 45 ответов «429 Too Many Requests».

Сверху горизонтальная линия, от неё вниз идут три одинаковые стрелки к трём вертикальным контейнерам, стоящим на общей нижней линии. Левый узкий и почти пустой — внутри лишь три коротких штриха. Средний втрое шире и целиком заполнен плотной тонкой штриховкой. Правый узкий и заполнен ровными сплошными полосами

В статье

Три философии индексации

Всё остальное — следствие одного решения: что индексировать.

  • 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 потоков и продолжал принимать; где его предел — этот замер не выяснил.

Итог

  1. Во все три приезжает одно и то же, но называется по-разному. message становится _msg, timestamp — _time, а Loki добавляет метку service_name, которую вы не отправляли (а при прямом push — ещё и detected_level). Переносимости запросов нет на уровне модели данных.
  2. Объём различается больше чем в двадцать раз на одинаковом потоке: VictoriaLogs 178–188 КиБ, Loki 571–587 КиБ, OpenSearch 4,1–4,2 МиБ при исходных 10,5 МиБ. Точная кратность — величина прогона: два прогона одной конфигурации дали 22,4× и 23,9×.
  3. Память в покое различается в 167 раз — и это не про нагрузку, а про то, сколько нужно, чтобы просто держать сервис запущенным.
  4. Память у Loki и VictoriaLogs растёт с нагрузкой в разы, у OpenSearch в этой конфигурации её почти целиком задаёт heap. Поэтому под нагрузкой разрыв сокращается до 13–25 раз. Постоянства памяти OpenSearch вообще это не доказывает.
  5. Уникальное поле в теле стоит объёма, в метке — отказа приёма. Одно и то же request_id: 1,7× к объёму в теле против 13 726 серий и 45 ответов «429» в метке.
  6. Loki не деградирует плавно, он перестаёт принимать. 13 523 записи из 100 000 за 368 секунд, дальше прирост почти нулевой.
  7. Отказ одного синка тормозит весь конвейер — и «раздельные буферы» тут не помогают. В Vector они и так раздельные; связывает соседей политика when_full: block, поднимающая backpressure выше точки ветвления. Выбор не между «раздельно и вместе», а между сохранностью и независимостью.
  8. У OpenSearch кардинальность значений не расширяет схему — только уникальные ключи. Его беда другая и лечится иначе.
  9. Настройка OpenSearch дала 7 %, а не разы, и отняла полнотекстовый поиск по сообщению. Дешевле и беднее одновременно.
  10. Настройка Loki не дала ничего, потому что отключать было нечего: метка, которую собирались убрать, в этом конвейере и не появлялась.
  11. Размер на диске у этих систем не стабилизируется. Фоновые слияния меняют его в обе стороны минутами позже; мерить надо серией, а не снимком.
  12. CPU этими средствами не меряется. Четыре прогона одной конфигурации дали пик 662 %, 70 %, 514 % и 636 % — величина не определяется, определяется только факт распараллеливания.

На этом серия «Наблюдаемость на практике» закончена: от первого спана до счёта за хранение.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии