Архитектура Loki под капотом: Компоненты и хранение

Глубокое погружение в микросервисную архитектуру Loki и устройство системы хранения. Курс детально разбирает жизненный цикл записи и чтения логов, устройство индексов и механизмы работы с объектными хранилищами.

1. Жизненный цикл записи: Роль Distributor и Ingester в обеспечении консистентности

Жизненный цикл записи: Роль Distributor и Ingester в обеспечении консистентности

Высоконагруженный production-кластер генерирует сотни тысяч строк логов в секунду. Если база данных попытается синхронно записывать каждую строку на диск, она неминуемо откажет из-за исчерпания лимитов операций ввода-вывода (IOPS). Чтобы переваривать такие объемы, система должна принимать данные максимально быстро, держать их в оперативной памяти и сбрасывать в постоянное хранилище крупными блоками, при этом гарантируя, что при внезапном отключении питания ни одна строка не потеряется. В Loki эта задача решается через микросервисную архитектуру пути записи (write path), где ключевые роли играют два компонента: Distributor и Ingester.

!Архитектура пути записи в Loki

Путь записи начинается, когда агент (например, Promtail) собирает пачку логов и отправляет HTTP POST запрос на эндпоинт /loki/api/v1/push. Этот запрос попадает на балансировщик нагрузки, за которым скрывается флот инстансов Distributor.

Distributor: Валидация и защита кластера

Distributor — это первый рубеж обороны кластера Loki. Это абсолютно stateless (не хранящий состояния) микросервис. Его главная задача — принять входящий трафик, проверить его на соответствие правилам, нарезать на правильные порции и перенаправить нужным Ingester-ам. Отсутствие состояния означает, что вы можете масштабировать Distributor горизонтально (добавлять новые поды или виртуальные машины) в любой момент, просто реагируя на рост входящего трафика.

При получении пакета логов Distributor выполняет несколько этапов валидации.

Во-первых, проверяются метки времени (timestamps). Система отбрасывает логи, которые пришли из слишком далекого прошлого или будущего. За это отвечают параметры конфигурации, определяющие границы окна приема. Исторически Loki требовал строгой хронологической последовательности логов внутри одного потока (stream). Если строка приходила с меткой времени старше, чем у уже записанной строки в том же потоке, она отбрасывалась с ошибкой entry out of order. В современных версиях Loki этот механизм смягчен: система умеет принимать логи вне порядка (out-of-order), но в пределах заданного временного окна, что критически важно для распределенных систем, где часы на разных серверах могут быть слегка рассинхронизированы.

Во-вторых, Distributor применяет лимиты (Rate Limiting). Поскольку Loki изначально проектировался как multi-tenant (многоарендная) система, где логи разных команд или клиентов хранятся в одном кластере, необходимо защищать ресурсы от «шумных соседей». Distributor проверяет размер входящего запроса (в байтах и строках) и сравнивает его с квотами конкретного тенанта. Если лимит превышен, Distributor немедленно возвращает ошибку HTTP 429 (Too Many Requests), заставляя агента сбора применить механизм экспоненциальной задержки (exponential backoff) и повторить попытку позже.

Маршрутизация и Hash Ring

После успешной валидации Distributor должен решить, куда отправить данные. В кластере работают десятки Ingester-ов, хранящих потоки в оперативной памяти. Крайне важно, чтобы все строки логов, принадлежащие одному потоку (имеющие идентичный набор лейблов), всегда попадали на одни и те же Ingester-ы. Если распределять логи одного потока случайным образом, при выполнении запроса системе придется опрашивать абсолютно все узлы кластера, что уничтожит производительность.

Для решения этой задачи Distributor использует механизм консистентного хеширования (Consistent Hashing) и структуру данных, называемую Hash Ring (хэш-кольцо).

!Перераспределение потоков в Hash Ring при добавлении узла

Алгоритм маршрутизации работает следующим образом:

  • Distributor берет полный набор лейблов потока (например, {app="frontend", env="prod", cluster="us-east-1"}).
  • На основе этих лейблов вычисляется 32-битный хеш.
  • Полученное число накладывается на кольцо, на котором уже размещены токены (идентификаторы) всех доступных Ingester-ов.
  • Distributor движется по кольцу по часовой стрелке от полученного хеша потока и находит первый встречный токен Ingester-а. Этот узел и становится основным приемником данных.
  • Математически позиция потока на кольце вычисляется по формуле:

    Где — позиция на кольце (целое число от 0 до ), — криптографическая хеш-функция (в Loki используется алгоритм fnv32a), — отсортированная строка, представляющая все лейблы потока, а — операция взятия остатка от деления, замыкающая пространство значений в кольцо.

    Консистентное хеширование решает проблему масштабирования. Если мы добавим новый Ingester в кластер, он займет свое место на кольце и «заберет» на себя только часть потоков от соседнего узла. Большинство потоков останутся на своих старых местах. Это кардинально отличается от простого деления по модулю на количество серверов, при котором изменение числа серверов привело бы к перераспределению 100% данных.

    Репликация и кворум записи

    Хранение данных исключительно в оперативной памяти одного Ingester-а — это прямой путь к потере логов при сбое оборудования (например, при Kernel Panic или отключении питания сервера). Чтобы обеспечить отказоустойчивость, Loki использует концепцию фактора репликации (Replication Factor, RF), заимствованную из архитектуры Dynamo.

    По умолчанию в production-инсталляциях фактор репликации равен 3 (). Это означает, что Distributor, определив по хэш-кольцу первый Ingester для потока, продолжает движение по кольцу и выбирает еще два следующих уникальных Ingester-а. Входящая порция логов отправляется на все три узла параллельно.

    Однако Distributor не ждет ответа от всех трех узлов. Для подтверждения успешной записи агенту (Promtail) достаточно получить ответы от кворума узлов. Размер кворума записи вычисляется по формуле:

    Где — минимальное количество успешных ответов от Ingester-ов (Write Quorum), — фактор репликации, а — математическая операция округления вниз до ближайшего целого числа.

    Для стандартного :

    !Динамика кворума записи: параллельная отправка и ожидание ответов

    Это означает, что Distributor отправляет данные на три Ingester-а и ждет. Как только два из них ответят «Успешно» (HTTP 200 OK), Distributor немедленно возвращает успешный ответ (HTTP 204 No Content) агенту Promtail. Третий Ingester может ответить позже, или его ответ может потеряться в сети, или сам узел может быть в этот момент перезагружен — это не повлияет на успешность операции с точки зрения клиента.

    Такой подход обеспечивает идеальный баланс между надежностью и задержкой (latency). Кластер с способен пережить полное падение одного любого Ingester-а без деградации приема данных.

    Рассмотрим сценарии отказов при и :

  • Сценарий 1: Все узлы здоровы. Distributor шлет запросы на Ingester A, B, C. Узлы A и B отвечают за 10 мс, узел C тормозит из-за сборки мусора (GC) и отвечает за 150 мс. Distributor вернет успех клиенту через 10 мс.
  • Сценарий 2: Один узел мертв. Ingester C отключился. Distributor шлет запросы на A, B, C. Запрос к C отваливается по таймауту, но A и B успешно сохраняют данные. Кворум () собран. Клиент получает успех. Данные не потеряны, так как они есть в памяти двух узлов.
  • Сценарий 3: Два узла мертвы (или сетевое разделение). Ingester B и C недоступны. Distributor получает успешный ответ только от A. Кворум не собран (). Distributor возвращает агенту Promtail ошибку HTTP 500. Promtail не сдвигает свой курсор в файле positions.yaml и через некоторое время повторяет попытку отправки этой же пачки логов.
  • Ingester: Прием и буферизация

    Пока Distributor занимается маршрутизацией и кворумами, Ingester выполняет тяжелую физическую работу. В отличие от Distributor, Ingester — это stateful (хранящий состояние) компонент. Его состояние — это гигабайты логов, находящиеся в оперативной памяти и еще не записанные в постоянное объектное хранилище (например, AWS S3).

    Когда Ingester получает запрос на запись от Distributor, внутри него происходит следующий процесс:

  • Ingester извлекает лейблы из запроса и ищет соответствующий поток (stream) в своей внутренней хеш-таблице.
  • Если поток с такой комбинацией лейблов встречается впервые, Ingester создает для него новую структуру в памяти.
  • Новые строки логов добавляются в активный чанк (chunk) этого потока. Чанк — это сжатый блок данных. Логи сжимаются алгоритмами gzip или snappy прямо в памяти, что позволяет Ingester-у хранить огромные объемы информации.
  • После добавления строк в память, Ingester возвращает успешный ответ Distributor-у.
  • Здесь кроется важный архитектурный компромисс. Ingester подтверждает запись до того, как данные физически окажутся на жестком диске или в S3. Скорость работы памяти позволяет кластеру Loki принимать миллионы строк в секунду, но создает риск потери данных. Если Ingester внезапно будет обесточен, все активные чанки в его оперативной памяти исчезнут.

    Именно поэтому кворум записи () критически важен. Если один Ingester сгорел вместе со своей оперативной памятью, те же самые строки логов гарантированно находятся в оперативной памяти как минимум одного другого Ingester-а (а в идеале — двух). Когда придет время выполнять поиск по логам, компонент Querier (отвечающий за чтение) опросит все Ingester-ы, соберет дублирующиеся данные, выполнит дедупликацию и вернет пользователю полный результат.

    Согласованность данных и CAP-теорема

    В контексте CAP-теоремы (Consistency, Availability, Partition tolerance), архитектура пути записи Loki делает явный перекос в сторону доступности (Availability) и устойчивости к разделению (Partition tolerance). Это классическая система с конечной согласованностью (Eventual Consistency).

    В традиционных реляционных базах данных с транзакционной семантикой (ACID), запись считается успешной только тогда, когда данные надежно зафиксированы на диске (часто с использованием Write-Ahead Log и fsync). Это гарантирует строгую консистентность, но сильно ограничивает пропускную способность.

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

    Жизненный цикл записи на этом этапе завершается с точки зрения клиента (агента сбора логов) — он получил подтверждение и перешел к чтению следующих файлов. Однако для самого кластера работа только начинается. Данные безопасно лежат в оперативной памяти распределенных Ingester-ов, но память не бесконечна. Потоки буферизируются, сжимаются в чанки, и при достижении определенных лимитов по времени или размеру запускается процесс фиксации (flushing). Кроме того, для дополнительной защиты от одновременного падения нескольких узлов, современные версии Loki используют механизм локального журналирования на диск внутри самого Ingester-а.

    2. Механика Ingester: Нарезка чанков, WAL и репликация данных

    Представьте высоконагруженный кластер, который принимает 100 000 строк логов в секунду. Distributor успешно валидирует эти данные и распределяет их по узлам Ingester. Ingester — это компонент, который хранит данные в оперативной памяти (RAM) до двух часов, чтобы максимально эффективно их сжать перед отправкой в постоянное хранилище. Но оперативная память энергозависима. Если сервер Ingester внезапно потеряет питание или ядро Linux убьет процесс из-за нехватки памяти (OOM Killer), два часа логов сотен сервисов исчезнут бесследно. Тем не менее, в правильно настроенном кластере Loki потеря данных при падении узла практически невозможна.

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

    Анатомия Ingester: От строки к чанку

    В отличие от Distributor, который является stateless-сервисом (не хранит состояния и может быть перезапущен в любую секунду без последствий), Ingester — это stateful-компонент. Его главная задача — накапливать входящие логи, группировать их и сжимать.

    Когда Ingester получает порцию логов от Distributor, он выполняет следующий алгоритм:

  • Извлекает лейблы из входящей записи.
  • Ищет в своей оперативной памяти существующий поток (stream), соответствующий этому точному набору лейблов.
  • Если поток существует, лог добавляется в его активный буфер.
  • Если потока нет, Ingester создает новую структуру в памяти.
  • Структура, в которой накапливаются логи внутри потока, называется чанком (chunk). Пока чанк находится в оперативной памяти и принимает новые строки, он считается открытым (active).

    !Процесс накопления и закрытия чанка в памяти Ingester

    Ingester не может держать чанки в памяти вечно. Существуют строгие триггеры, при срабатывании которых активный чанк закрывается (становится доступным только для чтения) и помещается в очередь на отправку в объектное хранилище (S3, GCS). Этот процесс называется сбросом (flushing).

    Триггеры закрытия чанка:

  • По размеру (chunk_target_size): По умолчанию Loki стремится создавать чанки размером около 1.5 МБ в сжатом виде. Поскольку логи сжимаются очень хорошо (часто в 10–15 раз), Ingester будет накапливать примерно 15–20 МБ сырых текстовых данных, прежде чем закроет чанк.
  • По времени жизни (max_chunk_age): Даже если чанк не достиг целевого размера, он будет принудительно закрыт по истечении заданного времени (по умолчанию 2 часа). Это гарантирует, что данные не зависнут в памяти навсегда.
  • По неактивности (chunk_idle_period): Если в поток не поступало новых логов в течение определенного времени (например, 30 минут), чанк закрывается. Это типично для cron-джобов или редко используемых микросервисов.
  • Почему Loki просто не пишет каждую строку лога сразу в S3? Ответ кроется в экономике и физике сжатия. Алгоритмы компрессии (в Loki обычно используется Snappy, LZ4 или Gzip) работают тем эффективнее, чем больше данных им предоставлено для поиска повторяющихся паттернов. Пакет из 10 000 строк логов одного формата сожмется до крошечного бинарного файла, тогда как поштучное сжатие каждой строки даст колоссальный оверхед. Кроме того, объектные хранилища тарифицируют каждый PUT-запрос. Запись миллионов мелких файлов разорит бюджет инфраструктуры, тогда как запись одного крупного чанка раз в два часа стоит копейки.

    Write-Ahead Log (WAL): Страховка от катастроф

    Накопление данных в RAM — отличный способ добиться высокой степени сжатия и снизить количество запросов к S3. Но это создает окно уязвимости длиной до двух часов. Если Ingester падает, все открытые чанки исчезают.

    Чтобы решить эту проблему без потери производительности, Loki использует механизм Write-Ahead Log (WAL) — журнал упреждающей записи.

    !Архитектура взаимодействия RAM и WAL на диске

    Механика работы WAL заключается в следующем:

  • Ingester получает пакет логов от Distributor.
  • До того как добавить логи в чанк в оперативной памяти и до того как ответить Distributor'у HTTP-статусом 200 (Success), Ingester записывает этот пакет в файл WAL на локальном диске.
  • Только после успешной записи на диск (fsync) данные добавляются в RAM, и клиент получает подтверждение.
  • Кажется, что запись на диск должна замедлить систему, ведь мы пытались уйти от дисковых операций, характерных для Elasticsearch. Разница в характере записи. WAL использует исключительно последовательную запись (sequential I/O). Жесткие диски (и даже SSD) тратят больше всего времени на поиск нужного сектора (random I/O). WAL ничего не ищет и не индексирует. Он работает в режиме "append-only" — просто дописывает сырые байты в самый конец файла. Современные диски способны поглощать гигабайты данных в секунду при последовательной записи, поэтому WAL практически не создает узкого места (bottleneck) для производительности Ingester.

    Восстановление после сбоя (Recovery)

    Если процесс Ingester завершается аварийно (например, из-за отключения питания сервера), происходит следующее:

  • При перезапуске Ingester первым делом сканирует директорию WAL на диске.
  • Он читает файлы журнала последовательно, от старых к новым.
  • Каждая запись из WAL "проигрывается" (replayed) заново: Ingester воссоздает потоки в оперативной памяти и заново наполняет чанки теми же самыми логами.
  • После полного проигрывания WAL, Ingester восстанавливает свое состояние в RAM с точностью до миллисекунды перед падением и возобновляет прием новых данных.
  • Очистка WAL (Checkpointing)

    Журнал не может расти бесконечно, иначе локальный диск Ingester-а быстро переполнится. WAL очищается синхронно с процессом сброса чанков (flushing).

    Когда закрытый чанк успешно загружен в объектное хранилище (получен HTTP 200 от S3) и информация о нем зафиксирована в индексе, Ingester понимает, что эти данные теперь в безопасности на удаленном сервере. Локальная копия в WAL больше не нужна. Ingester удаляет (или обрезает) те сегменты WAL-файла, которые содержали сырые логи для этого конкретного чанка. Таким образом, размер WAL на диске всегда примерно равен объему не сохраненных в S3 данных в оперативной памяти.

    Жизненный цикл сброса данных (Flushing)

    Процесс перемещения закрытого чанка из Ingester в объектное хранилище состоит из нескольких строгих шагов, гарантирующих консистентность.

  • Закрытие: Чанк помечается как read-only. В него больше не пишутся новые логи. Если для этого потока приходят новые данные, создается новый активный чанк.
  • Очередь: Чанк помещается во внутреннюю очередь на сброс (flush queue).
  • Загрузка в Object Storage: Ingester инициирует PUT-запрос в S3/GCS, отправляя сжатый бинарный файл чанка.
  • Обновление индекса: Только после успешного сохранения чанка, Ingester обновляет индекс (TSDB). В индекс записывается метаинформация: "Чанк с идентификатором X, содержащий логи потока Y за временной интервал от до , находится по такому-то пути в S3".
  • Очистка памяти: Чанк удаляется из оперативной памяти Ingester.
  • Очистка WAL: Соответствующие записи удаляются с локального диска.
  • Важно понимать, что на протяжении всего этого процесса (пока чанк находится в памяти, закрывается или летит по сети в S3), он доступен для поиска. Компонент Querier (отвечающий за чтение) при выполнении запроса опрашивает не только хранилище, но и все Ingester-ы. Ingester способен выполнять поиск по еще не сброшенным чанкам прямо в своей оперативной памяти. Это обеспечивает возможность анализировать логи в реальном времени (Real-time tailing), не дожидаясь их выгрузки в S3.

    Изящная остановка и механизм Handoff

    Аварийные падения — это исключение. Гораздо чаще Ingester-ы перезапускаются штатно, например, при обновлении версии Loki, изменении конфигурации или при автомасштабировании кластера Kubernetes (Rolling Update).

    Если при штатном сигнале завершения работы (SIGTERM) Ingester начнет сбрасывать все свои открытые чанки в S3, возникнут две проблемы:

  • Задержка развертывания: Выгрузка десятков гигабайт данных из памяти в S3 может занять 10-20 минут. Kubernetes будет ждать, пока под не завершит работу, что сильно замедлит процесс деплоя.
  • Фрагментация: Большинство чанков в памяти еще не достигли целевого размера. Их принудительный сброс приведет к созданию тысяч мелких файлов в S3, что ухудшит сжатие и замедлит будущие запросы на чтение.
  • Для решения этой проблемы в Loki реализован механизм Handoff (передача).

    При получении сигнала на остановку, Ingester переходит в режим "Leaving". Он перестает принимать новые данные от Distributor (Distributor исключает его из Hash Ring), но не начинает сбрасывать данные в S3. Вместо этого он ищет в кластере другой здоровый Ingester и открывает с ним прямое gRPC-соединение. Уходящий Ingester передает все свои открытые чанки из оперативной памяти прямо в оперативную память принимающего Ingester-а.

    Принимающий узел просто "приклеивает" полученные чанки к своим собственным потокам. Таким образом, чанки продолжают накапливаться и достигнут оптимального размера перед нормальным сбросом, а процесс обновления узла занимает секунды, а не минуты. Если по какой-то причине Handoff не удается (например, нет доступных узлов), Ingester откатывается к запасному плану — принудительному сбросу (flush) всех данных в S3.

    Проблема кардинальности и микро-чанки

    Мы подошли к одной из самых частых причин деградации производительности кластеров Loki — проблеме микро-чанков (micro-chunks). Эта проблема напрямую связана с правилами формирования потоков (streams) и триггерами сброса, которые мы обсудили выше.

    Вспомним, что поток определяется уникальной комбинацией лейблов. Если вы добавили динамический лейбл (например, user_id или trace_id), кардинальность системы взлетает. Вместо 100 потоков у вас образуется 100 000 потоков.

    Как на это реагирует Ingester? Для каждого из 100 000 потоков он обязан создать отдельный чанк в оперативной памяти.

    !Влияние высокой кардинальности на размер чанков

    Допустим, сервис генерирует 10 000 строк логов в минуту.

  • Сценарий А (Низкая кардинальность): Логи пишутся в 10 потоков. Каждый поток получает 1000 строк в минуту. За 2 часа чанк накопит 120 000 строк, достигнет размера в 1.5 МБ, отлично сожмется и будет сброшен в S3 как один файл.
  • Сценарий Б (Высокая кардинальность): Из-за лейбла user_id логи размазываются по 100 000 потоков. Каждый поток получает 1 строку лога раз в 10 минут.
  • В Сценарии Б чанки никогда не достигнут целевого размера по объему. Через 2 часа сработает триггер max_chunk_age. Ingester будет вынужден принудительно закрыть 100 000 чанков. В каждом из них будет всего по 12 строк логов. Размер такого чанка составит несколько килобайт. Это и есть микро-чанк.

    Последствия микро-чанков катастрофичны для кластера:

  • OOM (Out of Memory): Каждый открытый чанк требует накладных расходов в оперативной памяти (метаданные, указатели). 100 000 открытых чанков могут исчерпать RAM Ingester-а, даже если самих логов мало.
  • Смерть объектного хранилища (Rate Limits): Ingester попытается отправить 100 000 PUT-запросов в S3 одновременно. S3 ответит ошибками 429 Too Many Requests, очередь сброса заблокируется, Ingester перестанет принимать новые данные.
  • Финансовые затраты: Если S3 тарифицирует каждые 1000 PUT-запросов, счет за облако вырастет в сотни раз.
  • Медленное чтение: Когда вы попытаетесь выполнить запрос за этот период, Querier-у придется скачать не 10 крупных файлов, а 100 000 крошечных. Запрос будет выполняться минуты вместо миллисекунд.
  • Чтобы диагностировать эту проблему, администраторы Loki следят за метрикой loki_ingester_chunk_size_bytes. Если средний размер сбрасываемого чанка стабильно держится на уровне нескольких десятков килобайт — это верный признак взрыва кардинальности и необходимости пересмотра лейблов.

    Понимание механики Ingester-а — ключей к стабильности Loki. Именно здесь сходятся воедино компромиссы между потреблением памяти, безопасностью данных (WAL) и эффективностью хранения (нарезка чанков). Защитив данные при записи, система должна уметь так же эффективно их извлекать. О том, как Loki распараллеливает чтение тысяч чанков и почему запросы выполняются так быстро, несмотря на отсутствие полнотекстового индекса, мы поговорим при разборе компонента Querier.