Когда машин в хозяйстве больше двух, рутина чтения логов по цепочке 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 не должно быть миллионов потоков. Безопасный стартовый набор меток для типичного сервера выглядит скромно:
- job, имя задания, общее для всех строк одного источника;
- host, машина, с которой приехали записи;
- level, уровень записи после парсинга, например warning или error;
- 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 с тем же языком запросов.
Начинать стоит с малого: один агент, три задания, четыре метки, один конвейер с регулярным выражением и ступенью времени. После недели без сюрпризов в счётчиках набор меток расширяют осторожно, по одному полю. Скупые метки, честный парсинг строк и фильтры до отправки дают систему, которая хранит месяцы истории за копейки и отвечает на вопросы о вчерашней ночи за минуты.