Когда машин в хозяйстве больше двух, рутина чтения логов по цепочке ssh-сессий перестаёт укладываться в рабочее время: ротация стирает историю раньше, чем её успевают разобрать, а при сбое собрать картину по десятку каталогов за отведённые минуты не выходит. Пара Loki и Promtail закрывает задачу иначе, чем тяжёлые хранилища с полнотекстовым индексом: Loki индексирует только метки записей, а текст хранит сжатыми блоками и фильтрует после выборки. Ниже разобраны устройство пары, конфигурация агента, дисциплина меток и сценарий от системных журналов до запроса при разборе инцидента.

Устройство Loki, индексация меток вместо полного текста и дешёвые блоки хранения

Loki это система агрегации логов: приём записей, хранение и запросы в одном продукте. Агенты отправляют записи на приёмный интерфейс, стандартно порт 3100, путь приёма /loki/api/v1/push. Вместе с каждой строкой клиент передаёт набор меток, пар вида "имя - значение". Ключевое проектное решение в том, что индексируется именно набор меток, а не содержимое строк. Записи с одинаковым набором меток образуют поток, строки потока сжимаются в блоки и складываются в объектное хранилище или на файловую систему. Индекс хранит только связку "метки - блоки", поэтому он на порядки меньше полнотекстового.

Аналогия с почтой объясняет выгоду: сортировочный центр смотрит на адрес на конверте и не читает письмо. Метки в Loki играют роль адреса, текст записи остаётся "в конверте", сжатым и неиндексированным. Запрос сначала выбирает потоки по меткам, и только потом текстовый фильтр проходит по отобранным строкам. Плата за дешевизну: произвольный поиск слова без меток требует перебора больших объёмов, поэтому качество меток важнее мощности сервера.

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

Практическая сторона невелика: в конфигурации Loki задаются место хранения, срок хранения записей и параметры приёма, проверка готовности доступна по пути /ready, счётчики в формате Prometheus по пути /metrics.

Назначение Promtail и механика агента, позиции чтения и отправка записей

Promtail это агент, который ставится на каждую машину и работает как служба. Работа укладывается в четыре шага: найти файлы по шаблонам, читать новые строки, прогнать их через конвейер и отправить пакетом в Loki. Файлы агент не читает с начала, он следит за концом каждого файла, как tail -f.

Память позиций делает агента устойчивым к перезапускам: в файле /var/lib/promtail/positions.yaml хранится смещение последней прочитанной строки каждого источника, и после рестарта службы чтение продолжается с сохранённого места. Позиция привязана к файлу с учётом его внутреннего идентификатора, поэтому ротация не путает агента: переименованный файл опознаётся, строки не теряются и не дублируются.

Отправка идёт пакетами: агент копит строки, сжимает их и отправляет пачкой на приёмный путь Loki, параметры накопления задаются в секции clients. При недоступности Loki строки ждут в очереди, свежие выпуски агента ведут локальный журнал неотправленных записей, так что короткий отказ приёмной стороны обходится без потерь. Перед перезапуском службы конфигурацию проверяют ключом синтаксиса:

promtail -config.file=/etc/promtail/config.yml -check-syntax
systemctl restart promtail
journalctl -u promtail -n 50 --no-pager

После рестарта стоит убедиться, что агент поднялся и не пишет ошибок: сообщение "connection refused" означает либо неверный адрес приёмного интерфейса, либо ещё не запущенный Loki.

Статические задания в конфигурации, шаблоны путей и метки источника

Конфигурация агента описывается в YAML: секция server задаёт порты агента, positions место файла позиций, clients параметры отправки, а основная работа живёт в scrape_configs, списке заданий. Простейшее задание статическое, путь к файлам в нём задаётся служебной меткой __path__:

server:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /var/lib/promtail/positions.yaml

clients:
  # перед адресом приёмного интерфейса в рабочей конфигурации
  # указывают схему http
  - url: 127.0.0.1:3100/loki/api/v1/push
    batchwait: 1s
    batchsize: 102400

scrape_configs:
  - job_name: system
    static_configs:
      - targets: [localhost]
        labels:
          job: system
          host: web-01
          __path__: /var/log/*.log

Поле targets обязательно по наследию формата, достаточно localhost. Шаблон в __path__ понимает звёздочку и классы символов в квадратных скобках, двойная звёздочка спускается по подкаталогам, так что путь /var/log/**/*.log накроет и вложенные каталоги. Метки из секции labels прикрепляются к каждой строке источника, и уже здесь закладывается кардинальность: job, host и ещё пара полей, не больше.

Заданий может быть сколько угодно, по одному на источник: system на системные журналы, nginx на журналы веб-сервера, app на файлы приложения в /var/log/app/. Каждому свои пути и метки, например job: app и service: payments. Метка host дублируется во все задания и становится главным адресом при поиске проблемной машины.

Конвейер парсинга строк, регулярные выражения с именованными группами и перенос полей в метки

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

2025-10-12 06:25:44 level=warning service=payments rid=8f3a22 msg="payment retried" code=E732

Конвейер для неё выглядит так:

pipeline_stages:
  - regex:
      expression: '^(?P<ts>\S+ \S+) level=(?P<level>\w+) service=(?P<service>\w+) rid=(?P<rid>\w+) msg="(?P<msg>[^"]*)" code=(?P<code>\w+)$'
  - template:
      source: level
      template: '{{ ToLower .Value }}'
  - labels:
      level:
      service:
      code:
  - timestamp:
      source: ts
      format: "2006-01-02 15:04:05"

Первая ступень, регулярное выражение с именованными группами, разбирает строку на поля: каждая группа вида (?P<имя>...) кладёт свой кусок в карту извлечённых полей ts, level, service, rid, msg, code. Ступень template приводит level к нижнему регистру, чтобы WARNING из старых строк не плодил лишних значений метки. Ступень labels переносит выбранные поля в метки: после неё строка несёт level, service и code. Поле rid намеренно остаётся в тексте, но не в метках, и дальше станет ясно почему.

Регулярное выражение стоит писать с якорями ^ и $, чтобы разбор не срабатывал на частичном совпадении. Если строка не подошла, карта остаётся пустой, ступень labels ничего не добавляет, и строка уезжает в Loki только с метками задания: нестандартные записи остаются доступными фильтрам.

Для приложений, пишущих JSON, есть готовая ступень json, регулярное выражение там не нужно:

  - json:
      expressions:
        level: level
        service: service
        msg: message

Многострочные трассировки исключений решаются ступенью multiline с шаблоном первой строки:

  - multiline:
      firstline: '^\d{4}-\d{2}-\d{2}'
      max_wait_time: 3s

Строки, не начавшиеся с даты, приклеиваются к предыдущей записи, и трассировка приезжает в Loki целиком, а не десятком обрывков.

Ступень времени, фильтры строк и чистка потока до отправки

Ступень timestamp назначает записи её собственное время вместо момента приёма: без неё строки, прочитанные с опозданием, получают время агента, и хронология ломается. Источник берётся из карты извлечённых полей, в примере выше поле ts, формат задаётся строкой-образцом вида "2006-01-02 15:04:05", для стандартных записей есть готовый формат RFC3339. Если в записи нет смещения, время трактуется как UTC, поэтому приложениям стоит писать время со смещением.

Ступень drop отбрасывает строки, совпавшие с регулярным выражением, и это главный способ убрать шум: записи проверок доступности, отладочные строки, скучные информационные повторы. Для каждой причины отбрасывания задаётся метка счётчика:

  - drop:
      expression: "GET /health"
      drop_counter_reason: healthcheck

По значению healthcheck в счётчиках агента видно, сколько строк отброшено. Отбрасывание до отправки экономит канал, объём хранения и внимание человека, а в свежих выпусках есть обратная ступень keep, оставляющая только совпавшие строки.

Кардинальность меток, главный предел системы и правила гигиены индекса

Каждый уникальный набор меток образует новый поток, и у Loki не должно быть миллионов потоков. Безопасный стартовый набор меток для типичного сервера выглядит скромно:

  1. job, имя задания, общее для всех строк одного источника;
  2. host, машина, с которой приехали записи;
  3. level, уровень записи после парсинга, например warning или error;
  4. service, имя приложения, если на машине работает несколько служб.

Четыре метки с десятками значений на парке из двадцати машин дают несколько тысяч потоков, и индекс этого не замечает. Всё, что растёт без предела, в метки класть нельзя: идентификаторы запросов и пользователей, номера сессий, случайные коды. Правило простое: если поле принимает тысячи уникальных значений за час, это не метка. Сто тысяч запросов превратили бы одно задание с меткой rid в сто тысяч потоков.

Цена ошибки проявляется быстро: индекс растёт, служба Loki потребляет память, компакция не успевает за притоком, запросы по меткам замедляются, а тяжёлые выборки завершаются по таймаутам. Возврат метки в строку решает проблему мгновенно: записи остаются в Loki и находятся текстовым фильтром, а индекс худеет.

Дисциплина поддерживается рутиной: показатели числа потоков в счётчиках агента и Loki, доступных по пути /metrics, стоит смотреть после каждого изменения конфигурации, ведь временная метка для отладки живёт в конфигурации год. В новых выпусках Loki появились структурированные поля, которые хранятся рядом с записью и доступны фильтрам, но не попадают в индекс, запасной путь для полей средней кардинальности.

Запросы LogQL при разборе инцидента, от селектора меток до текстового фильтра

Запросы к Loki пишутся на языке LogQL и начинаются с селектора в фигурных скобках, где перечисляются метки:

{job="payments", level="error"}
{job="payments", host="web-01"}
{job="system"} |= "out of memory"
{host="web-02"} != "healthcheck"
{job="nginx"} |~ " 50[23] "

Операторы внутри скобок допускают точное равенство, отрицание и сравнение с регулярным выражением. После селектора работают текстовые фильтры: |= оставляет строки, содержащие подстроку, != отбрасывает содержащие, |~ сопоставляет с регулярным выражением, !~ отбрасывает совпавшие. Порядок всегда один: сначала метки, потом текст, прямое следствие устройства индекса.

Сценарий разбора показывает связку. При жалобе на платежи первым запросом выбирают ошибки по меткам, {job="payments", level="error"} за последние полчаса: по разбросу кодов видно, какая подсистема неспокойна. Дальше из текста одной строки берётся идентификатор неудавшегося запроса, rid, и ищется без всякой метки:

{job="payments"} |= "8f3a22"

Выборка показывает весь путь запроса: предупреждение, повтор, финальная ошибка с кодом E732. Экономия Loki сходится с её дисциплиной: идентификатор лежит в строке, фильтр находит его за секунды, а индекс не раздут одноразовыми метками.

Язык умеет и считать: функции count_over_time и rate превращают строки в числа, агрегаторы sum by и topk группируют по меткам.

sum by (host) (count_over_time({job="system", level="warning"}[5m]))
topk(3, sum by (service) (count_over_time({job="payments"}[1h])))
{job="app"} | json | level="error"

Такие запросы годятся для панелей мониторинга и быстрой оценки, где растёт частота ошибок. Для скриптов существует консольная утилита logcli с тем же языком запросов.

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