Задача выглядит просто: на хостах пишутся файлы логов — nginx в combined-формате и приложение JSON-строками, — и всё это должно оказаться в OpenSearch разобранным на поля. По дороге надо отсеять шум (обращения к /health не нужны никому), не отправлять один и тот же лог дважды и не потерять его, если сборщик перезапустится. Ни один из этих пунктов не решается фразой «настроить агент»: каждый — отдельное решение со своей ценой, и цену видно только на живом потоке.
Классический ответ — Filebeat читает файлы, Logstash парсит и маршрутизирует, между ними брокер. Vector от Datadog делает всё это одним процессом: один бинарник, один YAML, один язык трансформаций на всю обработку. Практическая разница не в том, что он «написан на Rust и потому быстрее», а в том, что весь контур описывается парой конфигов, которые читаются за десять минут, а трансформации прогоняются unit-тестами, не поднимая ни брокера, ни хранилища.
Статья не про выбор бэкенда логов: OpenSearch здесь — приёмник, а не предмет разговора, на его месте могли быть Loki или ClickHouse — сам выбор между ними разобран в статье про Loki, OpenSearch и VictoriaLogsготовится, с 21 октября. Не про трейсы и метрики приложений — только логи. И не про то, Vector или OpenTelemetry Collector: граница между этими двумя инструментами — тема отдельной статьи, Vector или OpenTelemetry Collector: где чей инструментготовится, с 25 сентября. Всё, что ниже, снято на Vector 0.57 на стенде, который поднимается одной командой.
В статье
- Что мы строим
- Модель Vector: источники, трансформации, синки
- Агент: чтение файлов и checkpoints
- VRL: парсинг, фильтрация, дедупликация
- Транспорт: NATS и Kafka между агентом и агрегатором
- Агрегатор: доставка в OpenSearch
- Надёжность: буферы и подтверждения
- Наблюдаемость самого Vector
- Vector всё ещё 0.x
- Типичные ошибки
- Итог
- Документация и первоисточники
Что мы строим
nginx access + app JSON"] --> A["Vector agent
file source + VRL"] A --> T["Транспорт
NATS или Kafka"] T --> G["Vector aggregator"] G --> O["OpenSearch"] G --> P["Prometheus"] G --> F["Файловый архив"]
flowchart LR
L["Файлы логов
nginx access + app JSON"] --> A["Vector agent
file source + VRL"]
A --> T["Транспорт
NATS или Kafka"]
T --> G["Vector aggregator"]
G --> O["OpenSearch"]
G --> P["Prometheus"]
G --> F["Файловый архив"]
Слева — генератор синтетических логов на Go: он пишет два файла, nginx-access в combined-формате (включая обращения к /health) и app.log с JSON-строками, среди которых намеренно много повторов. Агент читает оба файла, парсит их в VRL, отбрасывает health-запросы, дедуплицирует app-ветку и публикует результат в транспорт. Агрегатор подписан на транспорт, помечает поток и раскладывает его в два места: файловый архив и индекс OpenSearch. Prometheus стоит в стороне от потока логов — он снимает собственные внутренние метрики обоих процессов Vector, сами события в него не попадают.
Живой стенд — digital-cookbook/observability/vector-pipeline: поднимается одной командой, самопроверяется скриптом verify.sh, все числа в статье сняты на нём. Версии зафиксированы: Vector 0.57.0-debian, OpenSearch 3.5.0. Часть описанного ниже поведения специфична именно для Vector 0.57.
Сколько это стоит
Три прогона генератора по минуте, транспорт NATS, конфигурация агента — та, которую собираем ниже. CPU здесь в единицах docker stats, где 100% означает одно полностью занятое ядро:
| Поток генератора | Дошло до индекса | Недоставлено | CPU агента | CPU агрегатора | RAM агента / агрегатора |
|---|---|---|---|---|---|
| 1 000 событий/с | 27 362 из 60 000 | 0 | ~5% | ~6% | 219 / 187 МиБ |
| 5 000 событий/с | 127 541 из 300 000 | 0 | ~23–24% | ~34–35% | ~290 / ~240 МиБ |
| 20 000 событий/с | 502 230 из 1 199 200 | 0 | ~98% | ~145% | 326 / 287 МиБ |
На холостом ходу в этих же прогонах оба процесса держат около 130–165 МиБ при CPU около 0.2%.
Три оговорки, без которых эти числа читаются неправильно.
CPU — порядок величины, а не точное значение. Два повтора одного и того же прогона расходятся между собой на 2.8–8.2% относительных. Отдельно от этого: поток docker stats отдаёт по два кадра на одно реальное обновление, и усреднение по сырым кадрам против усреднения по дедуплицированным даёт расхождение до 8.8% относительных на тех же самых данных. Ни один из двух источников шума не меняет порядок и направление выводов — какой вариант дороже, какой транспорт легче, — но вторую значащую цифру меняет. Поэтому дальше в статье «около 23%», а не «23.0%».
Часть этого CPU — не обработка логов. Source internal_metrics в обоих процессах непрерывно производит собственные метрики Vector и гонит их в экспортёр Prometheus: по vector top это около 156 событий в секунду независимо от того, что происходит на основном конвейере. Холостые 0.2% — это в основном он, и в загруженных числах он тоже присутствует.
«Дошло до индекса» — не характеристика конвейера и тем более не константа. Разрыв между сгенерированным и доехавшим создают два механизма в агенте, оба стоят выше транспорта. Фильтр health-запросов срезает ровно один путь из шести равновероятных у генератора, то есть константную одну шестую nginx-ветки, на любом объёме. Дедупликация app-ветки работает иначе: у генератора всего 2400 различимых комбинаций полей, по которым идёт сравнение, а кэша на 5000 записей хватает, чтобы удержать их все. Как только партия становится заметно больше 2400 app-событий, почти каждая следующая app-строка оказывается повтором уже виденной комбинации. Вклад app-ветки поэтому стремится к константе около 2400 событий, тогда как nginx-ветка растёт линейно вместе с объёмом — и суммарная доля «доехавших от сгенерированного» падает с размером партии, асимптотически к 41.7%. В таблице выше партии крупные, от 60 тысяч событий, поэтому доля везде близка к асимптоте; на маленькой партии в 20 тысяч событий тот же самый конвейер даёт 10 545 доехавших, больше половины. Это одно и то же поведение на всех объёмах, а не деградация под нагрузкой, и сравнивать конфигурации между собой имеет смысл только на одинаковом объёме генерации.
А вот столбец «недоставлено» — как раз про конвейер, и он важнее доли. Считается он так: генератор по построению знает, сколько событий должно пережить фильтр и дедуп, и пишет это число в манифест прогона; замер сверяет манифест с числом документов в индексе. Ноль на всех прогонах означает, что в индексе оказалось ровно столько, сколько генератор посчитал должным доехать, — и столько же в файловом архиве. Не «почти столько» и не «в пределах процента»: ровно, на всех трёх уровнях нагрузки.
Модель Vector: источники, трансформации, синки
У Vector три вида компонентов и больше ничего:
- sources — откуда данные приходят: файлы, сокеты, HTTP, брокеры, собственные метрики процесса;
- transforms — что с ними происходит по дороге:
remap(код на VRL),filter,dedupe, маршрутизация, агрегации; - sinks — куда уходят: файл, брокер, Elasticsearch/OpenSearch, экспортёр Prometheus, stdout.
Топология не описывается отдельной секцией — она выводится из поля inputs. Каждая трансформация и каждый синк перечисляют, чьи выходы читают, и из этих ссылок Vector собирает граф. Это заметное отличие от OpenTelemetry Collector, где компоненты сначала объявляются, а потом отдельно связываются в service.pipelines: у Vector объявление и связывание — одно и то же место, и разводка потоков читается по конфигу снизу вверх, без второй карты, которую надо держать в синхроне с первой. Обратная сторона того же свойства всплывёт ниже, в разделе про VRL: ошибку именно в разводке заметить труднее, чем ошибку в коде трансформации.
Само деление на агент рядом с приложением и центральный агрегатор — не особенность Vector, а общий паттерн слоя сбора телеметрии; он подробно разобран в статье про OpenTelemetry Collector. У Vector этот паттерн ничем не выделен и никак не назван: агент и агрегатор — один и тот же бинарник с разными конфигами. Вся разница в том, что у первого источник — файлы, а у второго — брокер.
В контур стенда вошёл минимум компонентов: file, internal_metrics, remap, filter, dedupe, nats/kafka, elasticsearch, prometheus_exporter. За кадром осталось многое, о чём полезно знать, что оно вообще есть: http_server — приём событий по HTTP, когда логи приходят вебхуком, а не файлом; host_metrics — CPU, память, диск и сеть самого хоста, замена node_exporter, если Vector уже стоит агентом; aggregate — свёртка метрик по окну до отправки; console — вывод потока в stdout, самый быстрый способ увидеть, что реально течёт по конвейеру, и первое, что стоит подключить при отладке. Полный список — в справочнике конфигурации Vector.
Агент: чтение файлов и checkpoints
data_dir: /var/lib/vector
sources:
nginx_file:
type: file
include:
- /logs/nginx-access.log
read_from: beginning
fingerprint:
strategy: checksum
app_file:
type: file
include:
- /logs/app.log
read_from: beginning
fingerprint:
strategy: checksum
internal:
type: internal_metricsread_from: beginning — при первой встрече с файлом прочитать его целиком, а не только то, что допишется после старта агента. Для стенда это обязательно: генератор успевает записать файлы до того, как поднимется агент.
fingerprint отвечает на вопрос «тот ли это файл, что я читал минуту назад». checksum считает отпечаток по началу содержимого, альтернативный device_and_inode — по номеру inode. Второй дешевле, но ломается там, где inode нестабилен: сетевые и контейнерные файловые системы, некоторые схемы ротации. В стенде взят checksum.
data_dir — то, где Vector хранит своё состояние, и в первую очередь checkpoints: до какого смещения в каждом файле он уже дочитал. Без сохранённого состояния перезапущенный агент видит файл впервые — и читает его с начала, потому что read_from: beginning именно это и просит.
Замер: агент останавливается и поднимается снова, генератор при этом молчит; смотрим, сколько документов в индексе до и после.
| Перезапуск агента | Документов до | После | Прирост |
|---|---|---|---|
data_dir в томе Docker |
44 077 | 44 077 | 0 |
data_dir теряется вместе с контейнером |
44 058 | 88 116 | 44 058 |
Вторая строка — ровное удвоение: агент перечитал оба файла с начала и отправил весь поток второй раз. И здесь есть деталь, которую легко списать на одно только перечитывание файлов: удвоение прошло целиком, ни одно app-событие не было отброшено как дубликат. Кэш дедупликации живёт в памяти процесса и в data_dir не сохраняется — свежий агент не помнит ни одной из уже виденных комбинаций. То есть потеря data_dir стоит дважды: заново читаются файлы и заново набивается кэш дедупа, так что дублями оказывается и то, что дедуп при непрерывной работе срезал бы.
Правило отсюда простое и не зависящее от Vector: data_dir должен переживать перезапуск контейнера — том, а не слой образа. Иначе каждый рестарт агента даёт дубли в объёме всего, что ещё лежит в файлах логов.
VRL: парсинг, фильтрация, дедупликация
VRL (Vector Remap Language) — язык трансформаций, на котором пишется всё, что происходит с событием между источником и синком. Он ближе к обычному языку программирования, чем Grok-шаблоны, и, в отличие от них, компилируется и проверяется по типам до старта конвейера — ошибка в трансформации обнаруживается не на первом событии в продакшне, а сразу.
transforms:
parse_nginx:
type: remap
inputs: [nginx_file]
source: |
parsed, err = parse_nginx_log(.message, "combined")
if err != null {
.service = "unparsed"
.parse_error = err
} else {
. = merge(., parsed)
.service = "nginx"
del(.message)
}
.host_role = "agent"
parse_app:
type: remap
inputs: [app_file]
source: |
parsed, err = parse_json(.message)
if err != null {
.service = "unparsed"
.parse_error = err
} else {
. = merge(., object!(parsed))
del(.message)
}
.host_role = "agent"Что здесь важнее синтаксиса:
parse_nginx_log(.message, "combined")— встроенный парсер combined-формата, отдельного regex писать не нужно. Возвращает пару: результат и ошибку.- Ветка ошибки не теряет событие. Строка, которую разобрать не удалось, помечается
.service = "unparsed", причина складывается в.parse_error, и событие едет дальше. Это осознанный выбор: непарсящаяся строка — обычно самое интересное, что случилось с сервисом, и уронить её молча значит потерять именно сигнал. С такой разметкой она доезжает до индекса и находится одним запросом. merge(., parsed)раскладывает поля разбора в корень события, а не вкладывает объектом.del(.message)убирает исходную строку. После успешного разбора она дублирует уже разложенные поля — хранить её значит платить за одни и те же данные дважды.object!(parsed)в app-ветке:parse_jsonформально может вернуть не объект — JSON-документ бывает числом или массивом, — аmergeпринимает только объект. Восклицательный знак здесь утверждение типа.
transforms:
drop_health:
type: filter
inputs: [parse_nginx]
condition:
type: vrl
source: |
req = to_string(.request) ?? ""
!contains(req, "/health")
dedupe_app:
type: dedupe
inputs: [parse_app]
cache:
num_events: 5000
fields:
match: ["service", "level", "msg", "duration_ms"]Обратите внимание на входы: drop_health читает только nginx-ветку, dedupe_app — только app-ветку. Потоки сходятся уже в синке. Это не косметика — почему именно так, будет видно чуть ниже.
Типизация VRL строже, чем кажется
Естественная запись условия фильтра выглядит так: !contains(string!(.request ?? ""), "/health") — взять поле, подстраховаться пустой строкой, привести к строке. В Vector 0.57 она не компилируется, и следующие две попытки её починить — тоже:
string!(.request ?? "")— ошибка E651,unnecessary error coalescing operation: доступ к полю.requestне «фейлится», он просто даёт значение илиnull, поэтому??после него бессмыслен независимо от данных;string!(.request) ?? ""— снова E651: компилятор статически видит, что в схеме этого фильтраstring!упасть не может;is_string(.request) && contains(.request, "/health")— ошибка E110,invalid argument type: сужения типа по&&в VRL 0.57 нет, внутриcontains()поле по-прежнемуany.
Рабочая форма — та, которую подсказывает сам компилятор в тексте ошибки:
req = to_string(.request) ?? ""
!contains(req, "/health")to_string() — фейлящаяся функция, после неё ?? осмыслен и ловит app-события, у которых .request нет вовсе. Результат ложится в локальную переменную, а не в поле события: иначе у app-логов появилось бы пустое .request, которого там быть не должно. Ни один из трёх отказов не пришлось искать в документации — их выдал сам компилятор, он же в подсказке try: предложил рабочую форму. Цикл «поправил — прогнал» стоит секунды: VRL проверяется до старта конвейера, поднимать для этого ничего не нужно.
Трансформации можно тестировать без стенда
У Vector есть встроенный прогон unit-тестов трансформаций. Это, пожалуй, самая недооценённая его часть: тесты живут рядом с конфигом, не требуют ни брокера, ни хранилища, ни Docker Compose — только сам бинарник и два YAML.
tests:
- name: nginx_combined_parsed
inputs:
- insert_at: parse_nginx
type: log
log_fields:
message: '10.0.0.1 - alice [27/Jul/2026:10:15:01 +0000] "GET /api/items HTTP/1.1" 200 1234 "-" "curl/8.5.0"'
outputs:
- extract_from: parse_nginx
conditions:
- type: vrl
source: |
assert_eq!(.service, "nginx")
assert_eq!(.status, 200)
assert_eq!(.client, "10.0.0.1")
assert!(!exists(.message), "message должен быть удалён после парсинга")
- name: nginx_broken_line_is_marked_not_dropped
inputs:
- insert_at: parse_nginx
type: log
log_fields:
message: 'это не access-лог'
outputs:
- extract_from: parse_nginx
conditions:
- type: vrl
source: |
assert_eq!(.service, "unparsed")
assert!(exists(.parse_error), "непарсящаяся строка должна нести причину")
- name: health_requests_dropped
inputs:
- insert_at: drop_health
type: log
log_fields:
service: nginx
request: "GET /health HTTP/1.1"
no_outputs_from:
- drop_healthinsert_at кладёт событие прямо в названную трансформацию, extract_from снимает результат с её выхода, no_outputs_from проверяет, что события на выходе не оказалось вовсе — так тестируется фильтр. Запуск:
MSYS_NO_PATHCONV=1 docker run --rm -v "$PWD/vector:/etc/vector:ro" timberio/vector:0.57.0-debian \
test /etc/vector/agent.yaml /etc/vector/tests/agent-tests.yamlПять тестов конвейера со стенда проходят за секунды и покрывают ровно то, что обычно ломается при правке VRL: успешный разбор, помеченная непарсящаяся строка, обогащение app-события, отсечение health-запроса, пропуск обычного запроса. Переменная MSYS_NO_PATHCONV=1 нужна только в Git Bash на Windows — MSYS иначе переписывает /etc/vector в путь Windows, и Docker получает мусор.
А теперь про слепое пятно, из-за которого зелёные тесты дают ложное спокойствие. insert_at внедряет событие в трансформацию, минуя её inputs. Значит, тесты проверяют логику каждого узла и не говорят ничего о том, верно ли узлы соединены между собой.
На стенде это и случилось. dedupe_app изначально стоял после общего drop_health и получал сразу оба потока. У nginx-событий нет полей level, msg, duration_ms, по которым идёт сравнение, а отсутствующие поля равны друг другу — так что для дедупа весь nginx-поток выглядел одним и тем же событием и схлопывался в считанные единицы. Все пять тестов были зелёными и до исправления разводки, и после: разводка в них просто не участвует, insert_at её обходит. Дефект нашёл живой прогон с подсчётом событий на выходе агента, а не unit-тесты. Отсюда вывод, который стоит держать в голове с любым конфигурируемым конвейером: unit-тесты трансформаций покрывают код узлов, а корректность графа проверяется только сквозным прогоном с ожидаемым числом событий на выходе.
Сколько стоят трансформации
Вариант конфигурации агента меняется, поток генератора — нет: 5000 событий в секунду, транспорт NATS, партия 300 000 событий.
| Вариант агента | Что делает | CPU агента | CPU агрегатора |
|---|---|---|---|
passthrough |
ничего: файл → транспорт | ~47% | ~93% |
nofilter |
парсинг VRL, без фильтра и дедупа | ~44% | ~76% |
agent |
парсинг + фильтр health + дедуп | ~23–24% | ~34–35% |
Первые две строки — главный результат этой таблицы, и он контринтуитивный: парсинг обошёлся не дороже, чем его отсутствие. Разница между ~47% и ~44% лежит внутри шума измерения — напомню, разброс повторов до 8.2%, способ усреднения даёт ещё до 8.8%. Поэтому честная формулировка не «VRL бесплатен», а «на потоке 5000 событий в секунду разбор combined-строки и JSON не выделяется из шума и не является тем, за что вы платите». Оптимизировать VRL до того, как замерена цена всего остального, — занятие бессмысленное.
У прогона passthrough есть особенность, из-за которой его ~93% нельзя ставить рядом с остальными строками. Измерено вот что: в индексе этого прогона не оказалось ни одного документа — шаблон имени индекса ссылается на поле, которого неразобранный поток не несёт (почему так и что из этого следует — в разделе про ограничения 0.x). И второе: passthrough не делает del(.message), так что до агрегатора доходят события с исходной строкой внутри, объёмнее остальных. На что при этом ушёл процессор агрегатора, замер не показывает — поэтому число в таблице стоит, но сопоставимым не является. CPU агента это не затрагивает: он стоит выше по потоку.
Третья строка — про другое. agent дешевле не потому, что делает меньше работы, а потому, что делает её над меньшим потоком: фильтр и дедуп отсекают события в агенте, до транспорта, и агрегатор получает 127 541 событие вместо 300 000. Его CPU падает соответственно — примерно с 76% до 35%, более чем вдвое. Это и есть практический вывод про место фильтрации: она платит не за себя. Она платит за всё, что стоит ниже — сеть, брокер, агрегатор, bulk-запросы, объём хранения и его стоимость.
Транспорт: NATS и Kafka между агентом и агрегатором
Агент и агрегатор можно соединить напрямую — у Vector для этого есть парный source/sink vector. Для двух хостов так и проще. Брокер между ними появляется, когда агентов много, а агрегатор один; когда агрегатор должен переживать перезапуск, не роняя поток; когда тот же поток нужен ещё кому-то, кроме агрегатора. И ещё одно, менее очевидное: брокер разводит темпы. Агент пишет тогда, когда есть строки, агрегатор читает так быстро, как позволяет OpenSearch, и всплеск на входе не превращается сразу в отказ на выходе.
# vector/agent.yaml
sinks:
to_nats:
type: nats
inputs: [drop_health, dedupe_app]
url: nats://nats:4222
subject: logs.pipeline
connection_name: vp-agent
encoding:
codec: json
# vector/aggregator.yaml
sources:
from_nats:
type: nats
url: nats://nats:4222
subject: logs.pipeline
connection_name: vp-aggregator
decoding:
codec: json# vector/agent-kafka.yaml
sinks:
to_kafka:
type: kafka
inputs: [drop_health, dedupe_app]
bootstrap_servers: kafka:9092
topic: logs-pipeline
encoding:
codec: json
# vector/aggregator-kafka.yaml
sources:
from_kafka:
type: kafka
bootstrap_servers: kafka:9092
topics: ["logs-pipeline"]
group_id: vp-aggregator
auto_offset_reset: earliest
decoding:
codec: jsonКонфиги почти симметричны, и по ним не видно, что поведение у этих двух транспортов различается принципиально.
Что происходит, когда подписчика нет
Процедура одна и та же для обоих транспортов, иначе сравнение было бы некорректным: сквозной прогон, затем агрегатор останавливается, при остановленном агрегаторе генерируется ещё 10 000 событий, затем агрегатор поднимается обратно.
| Транспорт | В архиве до остановки | Сгенерировано без подписчика | В архиве после подъёма | Доехало |
|---|---|---|---|---|
| NATS core | 10 545 | 10 000 | 10 545 | 0 |
| Kafka | 10 545 | 10 000 | 14 678 | 4 133 |
Ноль у NATS — не сбой стенда и не дефект Vector, а свойство транспорта в той конфигурации, которую собрали. В конфигах стенда используется core NATS: параметр jetstream у источника не задан. А core NATS — это подписка «здесь и сейчас», а не очередь: сервер не хранит сообщения, у которых нет подписчика, и не пытается удержать их для будущего. Хуже того, агент проблемы не видит: публикация в core NATS — fire-and-forget, продюсер получает успех независимо от того, слушает ли его кто-нибудь.
Это не приговор NATS как транспорту и не ограничение Vector. У источника nats есть режим JetStream — отдельная секция jetstream в конфиге, требующая имени стрима и консьюмера, то есть работа с персистентным стримом вместо эфемерной подписки. Стенд его не включает сознательно: сравнивать хотелось два разных подхода — эфемерную подписку и лог с удержанием, — а не две конфигурации одного и того же.
Что из этого следует, а что нет. Цифра ноль выше описывает core-режим, а не продукт целиком: удержание сообщений на стороне NATS решается JetStream, и если вам нужна устойчивость к падению подписчика, смотреть надо туда. Но JetStream-профиль на этом стенде не проверялся, поэтому ставить его в один ряд с измеренной колонкой Kafka я не стану: у него своя семантика подтверждений и свои гарантии, и превращаются ли они в такие же числа — вопрос отдельного замера, а не логического вывода из этого.
Число 4133 у Kafka требует разбора, иначе читается неправильно. Это не «доехало 4133 из 10 000». Это весь nginx-поток второй партии — ровно столько, сколько агент физически отправил в топик, 4133 из 4133 ожидаемых по манифесту генератора. App-ветки во второй партии в топике не было вовсе, и не из-за Kafka: дедуп стоит в агенте, выше транспорта, агент за оба прогона не перезапускался, и его кэш с первой партии уже содержал все различимые комбинации app-полей — новые app-строки были отброшены до публикации. То есть ноль по app-ветке здесь про дедуп, а транспортный сигнал такой: всё, что агент успел опубликовать при отсутствующем консьюмере, Kafka удержала в топике, и consumer group с auto_offset_reset: earliest дочитала это с отставания при повторном подключении.
Из свойства core NATS следует неочевидное операционное требование: агрегатор обязан подняться раньше агента. Сообщения, опубликованные в промежутке между стартом агента и появлением подписчика, теряются безвозвратно, и никакой ретрай их не вернёт. Скрипт up.sh на стенде поэтому ждёт строку Vector has started в логах агрегатора, прежде чем поднимать агента. С Kafka такого требования нет.
Источники ведут себя по-разному, когда брокера нет
Ещё одна асимметрия, из-за которой на стенде четыре конфига вместо двух. Source nats на этапе сборки топологии вызывает connect() синхронно и без повторных попыток: если брокер недоступен, падает вся сборка конфигурации — Configuration error, процесс завершается кодом 78, — и вместе с ней умирают все остальные источники в том же файле. Source kafka в тех же условиях остаётся жив, пишет ошибки подключения в лог и переподключается.
Поэтому у агента и агрегатора по два раздельных файла — agent.yaml/agent-kafka.yaml и aggregator.yaml/aggregator-kafka.yaml, — а не один конфиг с двумя источниками, из которого нужный выбирался бы флагом. Общий файл выглядел бы аккуратнее, но на профиле Kafka падал бы целиком из-за холостого источника NATS, который в этом прогоне никому не нужен.
Цена транспорта
На том же потоке 5000 событий в секунду агент на NATS стоит примерно вдвое дороже, чем на Kafka:
| Транспорт | CPU агента, два повтора | CPU агрегатора, два повтора |
|---|---|---|
| NATS | ~23% и ~24% | ~35% и ~34% |
| Kafka | ~12% и ~13% | ~28% и ~30% |
Этот разрыв лежит далеко за пределами разброса между повторами (2.8–8.2%), так что это не шум. Причина — батчинг, и здесь важно точно разделить, что измерено, а что взято из документации.
Измерено: NATS-синк отправляет по одному сообщению на событие. У NATS-сервера на стенде включён монитор-порт, и его счётчик принятых сообщений дал 10 545 на 10 545 доехавших до архива событий — ровно 1:1, при среднем размере сообщения 269 байт. Это прямое измерение счётчиком сервера, а не вывод из документации. Заодно оно согласуется со структурой конфигурации: у синка nats в Vector 0.57 секции batch нет вообще, есть только управление на уровне запроса.
Из документации: у синка kafka батчинг есть и работает по умолчанию — batch.timeout_secs равен одной секунде, даже если batch не настроен явно.
Не измерено: сколько именно produce-запросов сделала Kafka. По offset’ам этого не видно — каждое событие остаётся отдельной записью в топике, группировка происходит на уровне запроса, — и для точного числа понадобилось бы включать статистику librdkafka.
Итого корректная формулировка звучит так: разница в CPU объясняется отсутствием батчинга у NATS-синка (измерено) в сочетании с наличием батчинга у Kafka-синка (документировано). Утверждать конкретный коэффициент сокращения числа запросов на этих данных нельзя.
Оба брокера разобраны отдельно, со своими стендами: digital-cookbook/messaging/nats и digital-cookbook/messaging/kafka.
Агрегатор: доставка в OpenSearch
Агрегатор — тот же бинарник с другим конфигом: источник у него брокер, а не файлы. Работы внутри немного — одна трансформация, которая помечает поток, и два синка, читающие её выход.
transforms:
mark_aggregator:
type: remap
inputs: [from_nats]
source: |
.pipeline_stage = "aggregated"
sinks:
archive:
type: file
inputs: [mark_aggregator]
path: /archive/events.log
encoding:
codec: json
opensearch:
type: elasticsearch
inputs: [mark_aggregator]
endpoints: ["https://opensearch:9200"]
api_version: v7
auth:
strategy: basic
user: admin
password: "VectorDemo#2026"
tls:
verify_certificate: false
bulk:
index: "logs-app-{{ service }}"
batch:
max_events: 1000
timeout_secs: 2Пароль литералом и verify_certificate: false — свойства стенда, а не образец для копирования: сертификат у OpenSearch self-signed, и проверять его нечем. В продакшне и то, и другое устроено иначе.
Остальное разбирается по строкам.
type: elasticsearch, а не opensearch. Отдельного синка под OpenSearch у Vector нет и не нужно: OpenSearch — форк Elasticsearch 7.10 и говорит тем же bulk-протоколом.
api_version: v7 задан явно. Vector умеет определять версию API сам, но здесь на автоопределение полагаться нельзя. OpenSearch 3.5.0 API восьмой версии не реализует, а его корневой ответ на GET / не содержит поля build_flavor, по которому Vector как раз и различает диалекты. В такой ситуации автоопределение способно выбрать не тот диалект bulk-протокола, и проявиться это может уже не на старте, а на первой партии документов. Явный v7 снимает неопределённость целиком.
batch — про то, чем именно синк говорит с хранилищем. Документы уходят не по одному, а bulk-запросами: до 1000 событий или раз в две секунды, что случится раньше. Это единственное место, где стоит осознанно крутить числа под конкретное хранилище: слишком маленькая партия — лишние round-trip’ы и лишний CPU на обеих сторонах, слишком большая — растущая задержка появления лога в поиске и риск словить лимит размера запроса.
bulk.index: "logs-app-{{ service }}" — имя индекса как шаблон по полю события. Поток раскладывается по индексам сам: logs-app-nginx для веб-логов, logs-app-api, logs-app-worker, logs-app-auth — по значению service из JSON приложения, logs-app-unparsed для строк, которые не разобрались. Это удобно ровно тем, чем удобны отдельные индексы: у них независимые retention и размер, и снести месячные веб-логи можно, не задев логи приложения.
А вот префикс logs-app- здесь не косметика и не соглашение об именовании. Естественная запись bulk.index: "{{ service }}" — только поле, без статического начала — в Vector 0.57 не работает вовсе, и не работает она интересным образом: полная vector validate и реальный старт ошибку находят, а вот под флагом --no-environment тот же конфиг выглядит совершенно здоровым. Разбор — в разделе про ограничения 0.x.
Синка archive шаблонное ограничение не касается: у файлового синка путь литеральный, /archive/events.log, шаблонов в нём нет. Это разделение оказалось полезным не только как «вторая копия на всякий случай» — именно оно позволяет отличить потерю в транспорте от потери в маршрутизации индекса. Если в архиве поток есть, а в индексе нет, значит до агрегатора всё доехало, и проблема ниже, в синке. Ровно этот случай встретится дальше дважды.
Сквозная сверка по внутренним метрикам обоих процессов на одном прогоне: агент отправил в транспорт 10 545 событий, агрегатор столько же принял, и оба его синка получили те же 10 545 — и файловый архив, и OpenSearch. Ни одного расхождения между четырьмя точками замера.
Надёжность: буферы и подтверждения
Хранилище логов недоступно чаще, чем хотелось бы: перезапуск узла, полный диск, rolling upgrade, просто пауза в несколько минут. Вопрос не в том, случится ли это, а в том, что произойдёт с потоком, пока это длится. У Vector ответ на этот вопрос — три настройки у синка, и они определяют исход целиком.
sinks:
opensearch:
type: elasticsearch
inputs: [mark_aggregator]
# ... endpoints, auth, tls, bulk, batch ...
buffer:
type: disk
max_size: 268435488 # 256 MiB — минимум для дискового буфера
when_full: block
acknowledgements:
enabled: truetype: disk против memory. По умолчанию буфер синка — в памяти, и это разумный дефолт для быстрых синков: он дешёвый и не трогает диск. Но память не переживает перезапуск процесса, и её нельзя сделать большой, не рискуя OOM. Дисковый буфер переживает рестарт Vector и измеряется сотнями мегабайт. Плата — запись на диск в горячем пути и вполне ощутимое замедление синка на потоках, где память справлялась.
when_full — что делать, когда буфер полон. Два поведения. block останавливает подачу в синк, и остановка распространяется вверх по конвейеру: агрегатор перестаёт вычитывать транспорт. drop_newest выбрасывает новые события на входе в переполненный буфер и продолжает работать как будто ничего не произошло.
acknowledgements.enabled: true — источник считает событие обработанным не когда отдал его дальше, а когда синк подтвердил доставку. Без этого обратное давление не имеет смысла: подтверждения — тот механизм, по которому «синк не принимает» доходит до источника.
Оговорка, без которой настройка обманывает: цепочка замыкается только там, где источник сам умеет сквозные подтверждения, и это свойство конкретного источника, а не общее правило. У Kafka оно есть, и в замере ниже работает именно поэтому. У источника nats в core-режиме его нет — подтверждать там нечего, публикация fire-and-forget. Включить acknowledgements на синке недостаточно: если источник подтверждений не поддерживает, обратное давление упрётся в него и дальше вверх не пойдёт.
Замер устроен так: OpenSearch останавливается, при остановленном синке генерируется поток 8000 событий в секунду в течение 240 секунд, затем OpenSearch поднимается обратно и ждём, пока буфер сольётся. Транспорт в обоих прогонах Kafka, меняется единственная настройка — when_full.
| Поведение при заполнении буфера | Должно было доехать | В индексе | Потеряно |
|---|---|---|---|
block (обратное давление) |
802 661 | 802 661 | 0 |
drop_newest |
802 696 | 276 722 | 525 974 (65.5%) |
Разница между строками — одно слово в конфиге и две трети потока.
Числа здесь заслуживают доверия не потому, что их получилось два одинаковых, а потому, что причина изолирована двумя независимыми измерениями. Внешний счётчик: _count в OpenSearch против ожидания, которое генератор посчитал по построению. Внутренний: метрики самого буфера. В прогоне drop_newest buffer_received_events_total у синка opensearch дал 276 722 события против 802 696 на входе в агрегатор — то есть буфер отказался принять ровно 525 974 события, и это в точности то расхождение, которое независимо посчитал OpenSearch. Не «в пределах погрешности», а до последнего события. Заодно buffer_sent_events_total равен buffer_received_events_total: всё, что буфер принял, он в итоге и отдал — потеря произошла на входе в буфер, а не при сливе.
Обратное давление при этом не бесплатное, и понимать его цену важнее, чем радоваться нулю в таблице. block не создаёт ёмкость из воздуха — он передаёт проблему вверх по потоку. В этом стенде выше стоит брокер, который поглотил задержку, и всё сошлось. В контуре, где выше стоит агент, читающий файлы, тот же block в конце концов остановит чтение файлов — и дальше вопрос в том, переживёт ли ротация логов паузу. То есть block перекладывает удержание потока на предыдущий слой, а не отменяет необходимость его где-то удерживать.
Сколько на самом деле помещается в дисковый буфер
max_size: 268435488 — это 256 MiB, номинально место на два сегмента. Столько в буфер не поместилось: ёмкость до начала потерь стабилизировалась на 128 MiB — ровно половине настроенного.
Дальше важно аккуратно разделить, что здесь установлено, а что нет.
Факт из документации Vector: дисковый буфер пишется append-only файлами-сегментами с потолком 128 MiB на файл, и файл удаляется только после того, как обработаны все события из него. Там же задан и минимум размера буфера — около 256 MiB. Первоисточник — модель буферизации Vector, и оба этих числа стоит прочитать там глазами, прежде чем планировать ёмкость.
Что сошлось с этим фактом, двумя независимыми измерениями. Потолок сегмента 128 MiB — это 134 217 728 байт. Прямой замер du -sk /var/lib/vector во время простоя синка дал 131 096 КиБ в прогоне block, то есть 134 242 304 байта — 128,02 MiB. Внутренняя метрика vector_buffer_received_bytes_total{buffer_id="opensearch"} в прогоне drop_newest дала 134 217 320 байт — 128,0 MiB, расхождение с документированной границей 408 байт. Снаружи по файловой системе и изнутри по счётчику Vector получается одно и то же число, и это число — ровно документированный потолок одного сегмента.
Открытый вопрос: почему при настроенном месте на два сегмента задействуется только один. Документация Vector этого не объясняет, в исходники Vector здесь никто не заглядывал, и выдумывать механизм не стоит.
Отсюда и практический вывод — ровно настолько сильный, насколько позволяет один замер: на Vector 0.57 в этом стенде полезная ёмкость оказалась около одного сегмента, а не всего max_size — настроенные 256 MiB означали 128 MiB реальных. Обобщать это в правило Vector я не берусь: замер один, версия одна, профиль нагрузки один. Но планировать ёмкость по номиналу max_size, не проверив поведение на своей версии и своём потоке, после такого результата не стоит — заложите запас и убедитесь сами, тем более что проверка дешёвая: остановить приёмник, погнать поток и посмотреть на du каталога data_dir вместе с vector_buffer_received_bytes_total.
И отдельная деталь для того, кто будет считать этот объём под свой поток: 268435488 байт — не произвольное число из конфига стенда, а минимум, который Vector 0.57 вообще принимает для дискового буфера. Меньше поставить нельзя, причём заметит это не всякая проверка конфига — об этом ниже.
Наблюдаемость самого Vector
Конвейер, который переносит логи, сам логами не наблюдается: если он молча теряет события, в логах об этом не будет ничего. Смотреть надо на счётчики, и у Vector они есть из коробки — как источник, а не как отдельная подсистема.
sources:
internal:
type: internal_metrics
sinks:
metrics_out:
type: prometheus_exporter
inputs: [internal]
address: 0.0.0.0:9599Собственные метрики Vector — обычный источник, который течёт по обычному конвейеру в обычный синк. У этого есть приятное следствие: их можно фильтровать, переименовывать и агрегировать теми же трансформациями, что и всё остальное. И неприятное, уже упомянутое выше: этот поток реально существует и реально стоит процессорного времени — по vector top около 156 событий в секунду фонового расхода, независимо от того, что происходит на основном конвейере.
Дальше — обычный scrape:
global:
scrape_interval: 5s
scrape_configs:
- job_name: vector-agent
static_configs:
- targets: ["agent:9599"]
- job_name: vector-aggregator
static_configs:
- targets: ["aggregator:9598"]Метрик Vector отдаёт много, но для «конвейер цел или нет» хватает трёх семейств, и все три размечены по компонентам — component_id, component_kind, component_type:
vector_component_received_events_total— сколько компонент принял;vector_component_sent_events_total— сколько отдал;vector_buffer_events— сколько событий сейчас лежит в буфере синка.
Первые два интересны не сами по себе, а разностью. Прогон генератора на 20 000 событий (по 10 000 в каждый файл), метрики сняты с обоих процессов и сведены здесь в таблицу по компонентам — в сыром виде /metrics отдаёт по строке на компонент со всеми лейблами:
| Компонент агента | Kind | Type | sent_events_total |
|---|---|---|---|
nginx_file |
source | file |
10 000 |
app_file |
source | file |
10 000 |
parse_nginx |
transform | remap |
10 000 |
parse_app |
transform | remap |
10 000 |
drop_health |
transform | filter |
8 267 |
dedupe_app |
transform | dedupe |
2 278 |
to_nats |
sink | nats |
10 545 |
Последний столбец читается как схема конвейера, у которой подписаны рёбра. Файлы отдали по 10 000, парсинг ничего не потерял (remap не отбрасывает события — этому есть отдельная причина, ветка ошибки помечает строку, а не роняет её), фильтр срезал health-запросы и отдал 8267, дедуп из 10 000 app-событий оставил 2278. И дальше главное: 8267 + 2278 = 10545 — ровно то, что синк отправил в транспорт. Сумма сходится, значит потоки соединены так, как задумано, и ничего не потерялось между узлами.
На агрегаторе та же арифметика продолжается: from_nats отдал 10 545, mark_aggregator — 10 545, файловый архив — 10 545, синк OpenSearch принял 10 545. Четыре точки замера в двух процессах и одно число. verify.sh на том же прогоне подтвердил это снаружи: ожидалось 10 545, в архиве 10 545, в индексе 10 545.
vector_buffer_events в этом прогоне равен нулю у всех синков обоих процессов — под такой нагрузкой ни один синк не подпирает. Метрика становится содержательной ровно в сценарии предыдущего раздела, и именно она — тот сигнал, по которому в продакшне стоит держать алерт: растущий буфер синка означает, что хранилище не успевает, и до потерь остаётся столько времени, сколько буфера ещё свободно.
vector top и почему его не стоит закладывать в процедуру
У Vector есть встроенный TUI-дашборд, vector top, который подключается к GraphQL API процесса и показывает ту же таблицу компонентов живьём, со скоростями. Вещь полезная — при отладке конфига она отвечает на «что вообще течёт» быстрее, чем curl по метрикам.
У неё есть жёсткое требование, о котором стоит знать заранее:
Terminal must be a teletype (TTY) to display a Vector dashboard.Это не ошибка и не сбой — штатное поведение. vector top рисует интерфейс и без настоящего терминала работать не может: ни в docker compose exec -T, ни в CI-шаге, ни в скрипте замера. В любую автоматизацию закладывать надо HTTP-эндпоинт метрик, а не vector top. На стенде дашборд удалось снять только обходным путём, подделкой TTY внутри контейнера, — и как рабочий способ этот обход предлагать нельзя, он годится разве что однократно заглянуть.
Разделение источников здесь стоит держать в голове: числа в таблице выше сняты запросом к /metrics, а вот рейт фонового расхода internal_metrics (те самые ~156 событий в секунду) виден именно в vector top — из накопительных счётчиков /metrics скорость без временной базы не выводится. Абсолютные значения при этом совпали в обоих источниках (dedupe_app 2.28 k, drop_health 8.27 k, to_nats 10.54 k), так что доверия к дашборду это не убавляет.
Дашборды по самому потоку логов в статье не рисуем — это отдельная тема, и она разобрана в статье про OpenSearch Dashboards.
Vector всё ещё 0.x
Vector 0.57 — это 0.x, и это не формальность в номере. Ломающие изменения приезжают в минорных версиях, а часть поведения, на которое опирается рабочий конфиг, между версиями меняется. Ниже три конкретных случая со стенда — не полный список, а иллюстрация того, чего ожидать.
${VAR} в конфигах больше не разворачивается по умолчанию. Из-за этого в конфигах стенда переменных нет вовсе — все значения литеральные. Здесь легко запутаться на ровном месте: ${AGENT_CONFIG} и ${AGG_CONFIG}, которые видны в docker-compose.yml, — это переменные docker compose, а не Vector. Compose подставляет их при разворачивании команды контейнера, до старта процесса; к моменту, когда Vector читает свой YAML, никаких ${...} там уже нет, только готовый путь в аргументе --config. Нотация одна и та же в двух разных местах одного проекта, и различие между ними — чисто вопрос того, кто читает файл.
Отключённая интерполяция не значит «секретов в конфиг не положить вообще» — у неё два штатных обхода. Первый — препроцессинг конфига до того, как его увидит Vector: envsubst или аналогичный шаблонизатор разворачивает ${VAR} в файле ещё на этапе сборки образа или entrypoint-скрипта, Vector получает уже готовый YAML без единой переменной. Второй — встроенный в Vector секрет-бэкенд (раздел Secrets в документации): по механизму подстановки он не отличается от ${VAR} — значение так же подставляется текстом в конфиг ДО разбора, то есть источнику секрета нужно доверять так же осторожно (значение с YAML-синтаксисом внутри способно исказить разобранный конфиг). Разница в другом: секрет из бэкенда никогда не попадает в окружение процесса Vector и поэтому не виден через /proc/<PID>/environ или аналоги, а обычная переменная окружения — видна. На стенде — третий путь, ни один из двух: пароль OpenSearch лежит литералом прямо в aggregator*.yaml, потому что стенд demo-only и одноразовый. В продакшне так делать нельзя — секрет литералом в конфиге, лежащем в Git, это утечка по определению.
Шаблон синка требует литерального префикса. Тот самый bulk.index: "{{ service }}" из раздела про агрегатор. Без статического начала Vector не может вывести «базу confinement» — множество индексов, которые синк потенциально способен задеть, — а значит и ограничить его чем-либо. Опция dangerously_allow_unconfined_template_resolution существует и названа честно.
Само требование разумно, а вот интересна тут не оно, а то, какая проверка его находит, а какая молчит. Полная vector validate находит и завершается кодом 78:
√ Loaded ["/etc/vector/variants/unconfined-index.yaml"]
√ Transforms configuration
Component errors
----------------
x Sink "opensearch": template references event fields (["service"]) but has no
literal string prefix to derive a confinement base from. Add a static prefix to
your template, or set `dangerously_allow_unconfined_template_resolution: true`
to opt out.А тот же файл с флагом --no-environment — тем самым, который удобно поставить в CI, чтобы проверка не ходила по сети к брокерам и хранилищам, — объявляется корректным:
√ Loaded ["/etc/vector/variants/unconfined-index.yaml"]
√ Transforms configuration
-------------------------------------------------------
ValidatedТо же самое, второй раз. Минимальный размер дискового буфера — тот, что задан в документации как ~256 MiB, — ведёт себя одинаково: конфиг с max_size: 1048576 под --no-environment проходит, а полная валидация сообщает
Component errors
----------------
x Sink "out": error occurred when building buffer: failed to build individual
stage 0: parameter 'max_buffer_size' was invalid: must be greater than or
equal to 268435488 bytesРазница между двумя режимами не косметическая: --no-environment отключает ту часть проверки, которая собирает компоненты — буферы синков, базу confinement для шаблонов, подключения. Всё, что определяется на этом этапе, под флагом не проверяется вовсе, и конфиг выглядит здоровым. Видно это только по коду возврата: 78 против 0.
Отсюда вывод, который дороже обоих ограничений по отдельности: vector validate — рабочий гейт для CI, а validate --no-environment единственным гейтом быть не должен. Флаг ставят из разумных соображений: чтобы проверка конфига не зависела от доступности Kafka и OpenSearch и не падала из-за сетевых причин. Цена — целый класс ошибок конфигурации, который она перестаёт видеть. Если сеть в CI недоступна, честнее держать два шага: быстрый --no-environment на каждый коммит и полную валидацию перед выкаткой, в окружении, где зависимости подняты. Ещё надёжнее — реальный старт с проверяемым конфигом до строки Vector has started в логах, но это уже не обязательное условие, а запас поверх.
Асимметрия источников. Источник nats при недоступном брокере роняет весь процесс кодом 78, а kafka в тех же условиях остаётся жив и переподключается — об этом уже шла речь в разделе про транспорт. Здесь стоит обратить внимание на то, каким этот код является: код семейства ошибок конфигурации, тот же, что у провала confinement. То есть недоступность брокера — обстоятельство сугубо временное и внешнее — Vector 0.57 классифицирует так же, как невалидный конфиг, и обращается с ней так же: без единой попытки повтора, с падением всего процесса. Полная валидация, к слову, сообщает и об этом — недостижимый брокер попадает в те же Component errors. Понимать это надо не как дефект, который вот-вот починят, а как то, во что упирается порядок старта в вашем docker-compose или манифесте.
Из всего этого следуют три правила, скучные и работающие: версию образа пиновать (стенд стоит на timberio/vector:0.57.0-debian, и часть описанного специфична именно для 0.57); upgrade guide читать перед сменой минорной версии, а не после; обновляться отдельным изменением, не смешивая его с правкой конфига, — иначе непонятно, что именно сломалось.
Типичные ошибки
Каждый пункт — то, на что наткнулся стенд, с числом или наблюдением из прогона.
Пять из них разобраны выше по ходу статьи — здесь только формулировка и ссылка:
- Оптимизировать VRL раньше, чем замерено всё остальное. Парсинг не выделился из шума измерения: ~44% против ~47% CPU. См. цену трансформаций.
- Фильтровать не там, где надо. Фильтр платит не за себя, а за всё, что стоит ниже: агрегатор упал с ~76% до ~35% CPU, более чем вдвое. См. там же.
- Дедуплицировать по полям, которых нет в части потока. Отсутствующие поля равны друг другу, поэтому чужой поток схлопывается целиком — тихо, без ошибок в логе. См. разводку дедупа.
- Считать, что зелёные unit-тесты означают целый конвейер.
insert_atминуетinputs, поэтому разводка в тестах не участвует. См. слепое пятно тестов. - Держать
data_dirв слое контейнера, а не в томе. Перезапуск агента дал ровное удвоение: 44 058 документов до, 88 116 после. См. checkpoints.
Два оставшихся стоит развернуть — они добавляют то, чего выше не было.
Оставить буфер и when_full как получилось. Одно слово в конфиге — 0 потерянных событий против 525 974 (65.5% потока) на одном и том же четырёхминутном отказе синка. При этом drop_newest не выбор по невнимательности: он законен там, где свежие данные важнее полноты. И block не бесплатен — он передаёт удержание потока на слой выше, а чем это обернётся, зависит от того, что там стоит: брокер поглотит паузу, а агент на файлах в конце концов остановит чтение. Ошибка не в том, какое значение выбрано, а в том, что оно не выбрано осознанно.
Шаблон синка, ссылающийся на поле, которого в потоке может не быть. Ломается он избирательно, и этим опасен. Событие без поля service роняется в синке OpenSearch — и доезжает во все остальные:
ERROR sink{component_kind="sink" component_id=opensearch component_type=elasticsearch}:
vector::internal_events::template: Failed to render template for "index".
error=Missing fields on event: ["service"] error_type="template_failed"
stage="processing"
ERROR sink{component_kind="sink" component_id=opensearch component_type=elasticsearch}:
vector_common::internal_event::component_events_dropped:
Events dropped intentional=false count=1 reason="Failed to render template."Живой прогон: 3000 событий, в файловом архиве все 3000, в OpenSearch ни одного — индекс logs-app-* не создался вообще. И ошибка в логе одна: после первого попадания Vector подавляет её строкой Internal log [Failed to render template for "index".] is being suppressed to avoid flooding, чтобы не спамить на каждое из трёх тысяч событий. То есть по объёму логов масштаб проблемы не виден совсем — одна строка ERROR означает и одно потерянное событие, и три тысячи. Настоящий сигнал здесь component_events_dropped с intentional=false, и алерт стоит навешивать на него.
Итог
Vector закрывает контур логов одним процессом: один бинарник на агенте и на агрегаторе, один язык на всю обработку, unit-тесты трансформаций, которые прогоняются за секунды и не требуют ни брокера, ни хранилища. По сравнению с парой Filebeat плюс Logstash это заметно меньше подвижных частей, и весь контур действительно читается по двум YAML.
Платится за это не производительностью. Платится дисциплиной и собственным операционным контуром. Дисциплиной — потому что 0.x означает ломающие изменения в минорных версиях, пинованные образы и upgrade guide перед каждым обновлением. Операционным контуром — потому что ноль потерянных событий на всех прогонах этой статьи не получается по умолчанию: он складывается из дискового буфера вместо буфера в памяти, when_full: block вместо дефолта, включённых подтверждений, data_dir в томе, разводки, при которой дедуп видит только свой поток, порядка старта, при котором подписчик поднимается раньше публикующего, и алертов по vector_buffer_events и component_events_dropped. Каждое из этих решений — одна строка в конфиге, и каждое из них по отдельности выглядит необязательным.
Оговорка, без которой этот список читается как гарантия: он описывает измеренную конфигурацию, а не свойство Vector вообще. Прогон с нулём потерь при отказе приёмника снят на Kafka — транспорте, который удерживает сообщения и умеет сквозные подтверждения. На core NATS та же цепочка не замкнётся: подтверждать там нечего, и при остановленном подписчике теряется всё отправленное, что и показал отдельный замер. Список выше — набор условий, при которых ноль достижим, а не обещание, что он получится на любом транспорте.
И одно наблюдение поверх всего: почти всё, что стенд поймал, поймано не логами и не ошибками, а подсчётом событий. Тихая потеря — норма для конвейера телеметрии, а не исключение: дедуп, съевший поток, шаблон, уронивший события в одном синке, буфер, отказавшийся принимать, — ни одно из этих трёх не выглядело сбоем. Поэтому сквозная проверка «сколько сгенерировано, сколько должно было доехать, сколько доехало» стоит дороже любого дашборда, а число, которое стоит держать на виду, — не пропускная способность, а расхождение.
Что дальше
- Vector или OpenTelemetry Collector: где чей инструментготовится, с 25 сентября — граница ответственности между двумя слоями сбора: где начинается один и заканчивается другой, и почему их часто ставят вместе
- Наблюдаемость на практикеготовится, с 7 октября — серия про догфудинг-стек: инструментирование Go и Java, трейсы, метрики, логи
- OpenSearch Dashboards — что делать с логами после того, как они доехали
- OpenTelemetry Collector — тот же паттерн «агент рядом с приложением плюс центральный агрегатор» с другой стороны
Стенд целиком, со скриптами всех замеров и results/, где лежат сырые числа этой статьи: digital-cookbook/observability/vector-pipeline.
Документация и первоисточники
- Документация Vector — общая точка входа
- Справочник конфигурации — все источники, трансформации и синки с полным списком опций
- Справочник VRL — функции, типы и коды ошибок компилятора
- Upgrade guide 0.57 — ломающие изменения этой версии, включая отключённую интерполяцию
${VAR} - Модель буферизации — сегменты по 128 MiB, минимальный размер дискового буфера, семантика
when_fullи подтверждений - Синк
elasticsearch— опции буфера, батчинг,bulk.indexиapi_version - Синк
kafka—batch.timeout_secs(по умолчанию 1 секунда) и остальные опции батчинга - Unit-тесты конфигов —
insert_at,extract_from,no_outputs_fromи запуск черезvector test
Комментарии