Vector или OpenTelemetry Collector: где чей инструмент

Vector, OpenTelemetry Collector и Fluent Bit на одном потоке логов: ресурсы, цена трансформации, потери при отказе бэкенда, приём OTLP-трейсов — и во что обходится связка Vector → Collector по OTLP

Vector и OpenTelemetry Collector постоянно оказываются в одной клетке архитектурной схемы, подписанной «слой сбора телеметрии». Из-за этого выбор между ними обсуждают как соревнование: кто быстрее, кто меньше ест, кто моднее. Соревнование это ложное — у инструментов разные сильные стороны, и они спокойно живут в одной системе.

Но и разговор о «разных сильных сторонах» обычно остаётся разговором. Утверждения вроде «Vector эффективнее» или «у Collector трейсы первым классом» кочуют из статьи в статью без единого числа, а те числа, что встречаются, сняты на разных стендах, разной нагрузке и разной трансформации — то есть сравнивать их между собой нельзя.

Поэтому здесь один и тот же поток логов проходит через три конвейера на одной машине: Vector, OpenTelemetry Collector и Fluent Bit третьим для масштаба. У всех одинаковый вход, одинаковая трансформация и одинаковый набор полей на выходе, причём последнее не обещание, а проверяемое условие: если наборы полей разошлись, замеры не публикуются.

Четвёртым на стенде стоит связка Vector → Collector — та самая «поставьте оба», которую советуют чаще всего. Она в равноправном сравнении не участвует: у неё другая задача — проверить, собирается ли стык вообще, и чего это стоит. Ни ресурсы, ни поля на её выходе с остальными тремя не сопоставлялись, и ниже это оговорено отдельно.

Статья не про выбор бэкенда логов — OpenSearch здесь приёмник, а не предмет разговора; выбор между хранилищами будет разобран в статье про Loki, OpenSearch и VictoriaLogsготовится, с 21 октября. Не про то, зачем вообще нужен слой сбора и как его разворачивать — это в статье про OpenTelemetry Collector. И не про устройство самого Vector: сквозной контур на нём с VRL, буферами и брокером между агентом и агрегатором собран в статье про pipeline логов на Vector.

Vector, OpenTelemetry Collector и Fluent Bit на одном потоке логов

В статье

Что сравниваем и на чём

flowchart LR G["Генератор
nginx access + app JSON"] --> V["Vector 0.57"] G --> C["OTel Collector 0.157"] G --> F["Fluent Bit 5.0"] G --> B["Vector → OTLP → Collector"] V --> OS[("OpenSearch 3.5")] C --> OS F --> OS B --> OS

flowchart LR
  G["Генератор
nginx access + app JSON"] --> V["Vector 0.57"] G --> C["OTel Collector 0.157"] G --> F["Fluent Bit 5.0"] G --> B["Vector → OTLP → Collector"] V --> OS[("OpenSearch 3.5")] C --> OS F --> OS B --> OS
Три сопоставимых конвейера и связка, которая проверяет стык, а не участвует в сравнении

Генератор пишет два файла: nginx access-лог в combined-формате, где часть запросов идёт к /health, и app-лог JSON-строками. Задача у трёх сравниваемых конвейеров одна: распарсить обе формы, проставить служебное поле pipeline, отсечь health-запросы и положить результат в OpenSearch. У связки задача другая — дойти до бэкенда вообще, и её результат меряется только счётчиком доставленного.

Стенд — digital-cookbook/observability/vector-vs-collector. Прогоны строго последовательные, по одному конвейеру: одновременный запуск превратил бы замер процессора в конкуренцию за ядра. Версии зафиксированы — Vector 0.57.0, OpenTelemetry Collector contrib 0.157.0, Fluent Bit 5.0.9, OpenSearch 3.5.0.

Одинаковая работа — это проверяемое условие

Сравнивать потребление ресурсов можно только у конвейеров, делающих одно и то же. Формулировка «мы настроили их одинаково» тут не годится: настроить можно с любой добросовестностью, а проверить — только по результату. Всё, что в этом разделе, относится к трём основным конвейерам; связка сюда не входит.

Граница проведена по выходному документу. Скрипт берёт по документу из каждого индекса и сверяет набор прикладных полей — отдельно для nginx-события, отдельно для app-события:

=== прикладные поля документа, service=nginx
  logs-vector:    agent client pipeline referer request service size status
  logs-otelcol:   agent client pipeline referer request service size status
  logs-fluentbit: agent client pipeline referer request service size status

=== прикладные поля документа, service=api
  logs-vector:    duration_ms level msg pipeline service ts
  logs-otelcol:   duration_ms level msg pipeline service ts
  logs-fluentbit: duration_ms level msg pipeline service ts

OK: наборы прикладных полей совпадают, замеры сопоставимы

Служебные поля из сверки исключены сознательно, и это не поблажка. У каждого инструмента своя модель записи, и требовать её совпадения — значит требовать, чтобы инструменты перестали быть собой. Именно этой разницей и открывается сравнение.

Одно уточнение о составе задачи: дедупликации в общей трансформации нет. У Fluent Bit прямого аналога dedupe не существует, и включение её сломало бы эквивалентность. Само это отсутствие — уже характеристика инструмента, к которой вернёмся в разделе про трансформации.

Модель данных: что каждый считает событием

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

Fluent Bit даёт самый компактный документ: только поля и одно служебное @timestamp.

{
  "@timestamp": "2026-07-28T15:51:05.000Z",
  "client": "10.0.0.1",
  "request": "GET / HTTP/1.1",
  "status": 404,
  "size": 3545,
  "referer": "-",
  "agent": "curl/8.5.0",
  "service": "nginx",
  "pipeline": "fluentbit"
}

Vector добавляет четыре плоских служебных поля — file, host, source_type, timestamp. Исходная строка удаляется явным del(.message) в трансформации: если этого не сделать, событие несёт и разобранные поля, и оригинал.

Collector оборачивает всё в структуру OpenTelemetry: прикладные поля лежат внутри attributes, рядом появляются body, severity, instrumentationScope, observedTimestamp и data_stream.

{
  "attributes": {
    "client": "10.0.0.2",
    "request": "GET /api/items/7 HTTP/1.1",
    "status": 500,
    "service": "nginx",
    "pipeline": "otelcol",
    "log.file.name": "nginx-access.log",
    "data_stream": {"dataset": "default", "namespace": "namespace", "type": "record"}
  },
  "body": "10.0.0.2 - carol [28/Jul/2026:15:51:06 +0000] \"GET /api/items/7 HTTP/1.1\" 500 1381 \"-\" \"Mozilla/5.0\"",
  "instrumentationScope": {},
  "observedTimestamp": "2026-07-28T16:05:08.63987961Z",
  "severity": {},
  "@timestamp": "1970-01-01T00:00:00Z"
}

Здесь стоит задержаться на двух вещах.

Первая — body. По спецификации OpenTelemetry это поле необязательное, но в нашем конвейере filelog receiver кладёт в него исходную строку и не убирает её после разбора. Для отладки удобно, для объёма хранения — вдвое дороже: и разобранные поля, и оригинал. Vector и Fluent Bit в тех же конфигурациях исходник отбрасывают, у Vector это явный del(.message).

Вторая — @timestamp, равный эпохе. Collector не выводит время события из строки лога, пока ему не задан отдельный оператор разбора времени. Vector в том же сценарии проставил 2026-07-28T15:51:05Z, потому что parse_nginx_log разбирает время сам, а Fluent Bit — потому что группа времени объявлена как time_key в парсере. У Collector это надо делать руками, и умолчание тихое: документы индексируются, поле есть, значение бессмысленное.

Сигналы: где проходит настоящая граница

Логи — общая территория всех троих. Расходятся они на OTLP.

Collector принимает OTLP в обоих кодированиях, protobuf и JSON, по gRPC и по HTTP. Это его родной протокол, и трейсы для него такой же полноправный сигнал, как логи.

Vector OTLP тоже умеет — утверждение «не умеет» неверно, — но с оговорками, каждую из которых пришлось выяснять живьём.

Первая: OTLP/JSON он не принимает. Тот же самый запрос, который Collector обрабатывает штатно, Vector отвергает:

трейсы: отправлено 100 спанов (31000 байт) → HTTP 500,
        ответ: 1Rejection(InvalidHeader { name: "content-type" })

Спецификация OTLP/HTTP допускает оба кодирования. Vector принимает только protobuf. Чтобы отделить «не умеет OTLP» от «не умеет JSON», сигнал был пропущен через Collector в роли конвертера — тот принял JSON и отдал дальше protobuf. После этого приём заработал, значит дело именно в кодировке.

Вторая оговорка тоньше и обходится дороже. У источника есть переключатель use_otlp_decoding с тремя сигналами, все по умолчанию false. Название подсказывает, что false — это «не разбирать OTLP», и рука тянется включить. Результат обратный ожидаемому:

use_otlp_decoding Что оказалось в выходном файле
false (умолчание) 100 спанов и 100 логов, каждый отдельным событием, поля разложены, span_id читаемым hex
true одна запись на весь пакет, структура OTLP как есть, идентификаторы сырыми байтами

При выключенном декодировании — то есть по умолчанию — запись выглядит так:

{
  "attributes": {"pipeline": "loadgen", "service": "api"},
  "message": "db query slow",
  "severity_text": "ERROR",
  "severity_number": 17,
  "source_type": "opentelemetry",
  "span_id": "af10aa16c45b102d",
  "trace_id": "843edf61a58870d3f96fe7dc8c6d0479",
  "timestamp": "2026-07-28T12:00:00Z"
}

Всё на месте: сигнал разложен по плоской модели Vector, trace_id сохранён у всех ста записей — корреляция лога со спаном не теряется. А вот при use_otlp_decoding: true весь ExportTraceServiceRequest становится одним событием Vector, и ни отфильтровать отдельный спан, ни прочитать его идентификатор в таком виде нельзя: "spanId":"y?[?" вместо hex.

Третья мелочь того же ряда: источник opentelemetry выдаёт три именованных выхода — .logs, .traces, .metrics, — и сослаться на него целиком нельзя. Конфиг с inputs: [otlp] отвергается на старте. При этом встроенный генератор примеров vector generate выдаёт для этого источника ровно такую ссылку, и его вывод валидацию не проходит.

Ни одно из этих ограничений не смертельно. Но вместе они и есть та разница, которая отличает основной сигнал инструмента от второстепенного: на логах Vector не заставляет разбираться в тонкостях, на трейсах — заставляет.

Трансформация: VRL, OTTL и processors

Одна и та же задача — распарсить nginx combined, проставить поле, отсечь /health — тремя языками.

Vector, VRL. Отдельный язык с функциями под конкретные форматы:

transforms:
  parse_nginx:
    type: remap
    inputs: [nginx_file]
    source: |
      parsed, err = parse_nginx_log(.message, "combined")
      if err != null {
        .service = "unparsed"
      } else {
        . = merge(., parsed)
        .service = "nginx"
        del(.message)
      }
      .pipeline = "vector"

  drop_health:
    type: filter
    inputs: [parse_nginx]
    condition:
      type: vrl
      source: |
        req = to_string(.request) ?? ""
        !contains(req, "/health")

parse_nginx_log знает про combined-формат и разбирает строку целиком, включая время. Обработка ошибки обязательна: err придётся либо проверить, либо оборвать выполнение через !.

Collector, OTTL. Разбор и преобразование разнесены: regex живёт в операторе ресивера, изменение полей — в процессоре.

receivers:
  file_log/nginx:
    include: [/logs/nginx-access.log]
    operators:
      - type: regex_parser
        regex: '^(?P<client>\S+) \S+ (?P<user>\S+) \[(?P<ts>[^\]]+)\] "(?P<request>[^"]*)" (?P<status>\d+) (?P<size>\d+) "(?P<referer>[^"]*)" "(?P<agent>[^"]*)"'

processors:
  transform/nginx:
    log_statements:
      - context: log
        statements:
          - set(attributes["service"], "nginx")
          - set(attributes["pipeline"], "otelcol")
          - set(attributes["status"], Int(attributes["status"]))

Готовой функции под nginx нет — regex пишется руками, типы приводятся отдельными выражениями. Зато OTTL один и тот же для логов, метрик и трейсов, а Vector требует разных подходов к разным сигналам.

Fluent Bit. Парсеры объявляются отдельно и подключаются к входу по имени:

parsers:
  - name: nginx_combined
    format: regex
    regex: '^(?<client>[^ ]*) [^ ]* (?<user>[^ ]*) \[(?<time>[^\]]*)\] "(?<request>[^"]*)" (?<status>[^ ]*) (?<size>[^ ]*) "(?<referer>[^"]*)" "(?<agent>[^"]*)"$'
    time_key: time
    time_format: "%d/%b/%Y:%H:%M:%S %z"
    types: "status:integer size:integer"

Декларативно и коротко, приведение типов и разбор времени — параметрами парсера. Ценой гибкости: условная логика сложнее «сматчилось или нет» требует Lua, то есть выхода за пределы конфига.

Во что обходится разбор

Тот же поток, 5000 событий в секунду, с трансформацией и без неё:

Конвейер passthrough с трансформацией отношение
Fluent Bit 2.1–2.3% 4.7–5.7% ×2.4
Vector 5.7% 12.0–12.1% ×2.1
Collector 33.5–33.6% 33.2–34.2% ×1.0

Числа требуют осторожного чтения, и вот в какую сторону. Вариант passthrough отправляет в бэкенд на 9% больше документов, потому что не отсекает /health. То есть полный конвейер вдвое дороже, уже делая меньше работы по отправке; чистая цена разбора ещё выше измеренной.

У Vector и Fluent Bit разбор строк — основная статья расхода, он удваивает потребление процессора. У Collector теряется в шуме, но не потому, что OTTL дешевле VRL: базовая стоимость его конвейера втрое выше, и на этом фоне трансформация просто не видна.

Здесь же место сказать про дедупликацию, которую пришлось исключить из общей задачи. У Vector она встроенная — dedupe с настраиваемым кэшем и списком полей. У Collector есть logdedup processor. У Fluent Bit прямого аналога нет: повторы отсеиваются либо на бэкенде, либо через Lua-скрипт. Для потока, где половина событий — повторяющиеся сообщения приложения, это разница не в удобстве, а в объёме хранилища.

Надёжность: что теряется, когда бэкенд лёг

Самый содержательный замер серии — и единственный, где инструменты разошлись принципиально.

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

Недоступность моделируется двумя способами, и это не педантизм:

  • stop — контейнер останавливается, docker убирает его DNS-запись, клиент получает «no such host». В Kubernetes так выглядит удалённый Service или обращение по DNS-имени отдельного Pod, но не обычная замена пода за Service: там DNS-имя сохраняется, а меняется только EndpointSlice;
  • pause — процессы заморожены, контейнер существует, DNS на месте, соединение упирается в таймаут. Так выглядит зависший или перегруженный бэкенд.
Конвейер stop pause
Vector, disk buffer 256 МиБ 0 потерь 0 потерь
Fluent Bit, filesystem storage 0 потерь 0 потерь
Collector, очередь по умолчанию −218 087 (79%) −126 568 (46%)
Collector, очередь 256 МиБ −218 087 (79%) 0 потерь

Vector и Fluent Bit не потеряли ничего. Vector при этом ретраит и отказ резолвинга тоже:

WARN vector::sinks::util::retries: Retrying after error.
  error=Failed to make HTTP(S) request: error trying to connect:
  dns error: failed to lookup address information: Name or service not known
...
INFO vector::sinks::util::service::health: Endpoint is healthy.

У Collector две разные причины потерь, и их важно не смешивать.

Первая — переполнение очереди, и это умолчание, а не свойство. В режиме pause он теряет 46% потока с сообщением sending queue is full. Ёмкость sending_queue по умолчанию не рассчитана на минуту простоя под такой нагрузкой. Стоит задать её явно — и потери исчезают полностью:

exporters:
  opensearch:
    sending_queue:
      enabled: true
      storage: file_storage
      sizer: bytes
      queue_size: 268435456

Вторая — классификация ошибки, и вот она размером очереди не лечится. В режиме stop обе конфигурации, с умолчанием и с очередью на 256 МиБ, потеряли ровно одинаково — 218 087 событий. Причина в логе:

error: "not retryable error: Permanent error: flush: dial tcp:
        lookup opensearch on 127.0.0.11:53: no such host"
dropped_items: 82

Отказ резолвинга считается постоянной ошибкой, и пачка выбрасывается, не дойдя до очереди, — при включённом retry_on_failure.

Практический смысл стоит очертить точно, потому что соблазн обобщить здесь велик. Пока бэкенд просто не отвечает — настроенный Collector надёжен, потери нулевые. Проблема возникает, только когда имя перестаёт резолвиться, а это более узкий случай, чем кажется: в Kubernetes обычная замена пода за Service DNS-имя не трогает — оно остаётся, обновляется лишь EndpointSlice. Имя исчезает при удалении самого Service, при обращении по DNS-имени отдельного Pod и в части headless-сценариев. Вне Kubernetes это, например, снятая запись у внешнего бэкенда или недоступный резолвер. В таких ситуациях телеметрия за время недоступности теряется безвозвратно, и размер очереди не помогает.

Чем платят за устойчивость

Механизмы разные, и это видно по занятому месту (до отказа → во время недоступности → после доставки, КиБ):

Конвейер до во время после
Vector 17 900 86 128 86 128
Collector 6 508 70 004 70 088
Fluent Bit 108 38 488 3 856

Fluent Bit единственный освобождает место после доставки. У Vector и Collector объём остаётся по верхней отметке всплеска: файлы буфера append-only и удаляются не по подтверждению отдельного события, а по мере обработки сегмента целиком. Для планирования диска это существенно — место занято не текущим объёмом неотправленного, а максимальным за время жизни сегмента.

Устроены механизмы тоже по-разному. У Vector это одна секция buffer на синке. У Collector — две части: sending_queue у экспортёра плюс extension file_storage, который эту очередь хранит. У Fluent Bit устойчивость привязана к входу (storage.type: filesystem у tail), а не к получателю.

Отдельная мелочь, на которой легко споткнуться: минимальный размер дискового буфера Vector — 268435488 байт, то есть 256 МиБ плюс 32 байта. Круглое значение отвергается на старте:

Configuration error. error=Sink "opensearch": 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

Перезапуск: здесь все одинаковы

Поток отработан и доставлен, конвейер перезапускается:

Конвейер том состояния сохранён том удалён
Vector +0 +36 627 (ровное удвоение)
Collector +0 +36 627
Fluent Bit +0 +36 627

Ни одного дубля при сохранённом состоянии и ровное удвоение при потерянном — у всех троих. Механизм у каждого свой (data_dir у Vector, extension file_storage у Collector, параметр db у Fluent Bit), следствие общее: том под состояние — условие корректности, а не пожелание. Причём потеря состояния при живом файле логов даёт не потерю данных, а их дублирование, что для счётчиков и алертов зачастую хуже.

Ресурсы: сколько это стоит

Тот же поток, два уровня нагрузки, по два повтора каждого прогона. Проценты — в единицах docker stats, где 100% означает одно полностью занятое ядро.

Конвейер 5 000 соб/с 20 000 соб/с RAM при 20 000 RAM в простое
Fluent Bit 4.7–5.7% 7.6–8.9% 25–26 МиБ 20–21 МиБ
Vector 12.0–12.1% 44.6–45.7% 470–478 МиБ 146–155 МиБ
Collector 33.2–34.2% 93.4–94.7% 107–112 МиБ 38–42 МиБ

Потерь нет ни у кого: на обоих уровнях все три доставили поток полностью.

Читать эту таблицу как рейтинг не стоит — она про размен, а не про победителя.

Fluent Bit дешевле обоих сразу и по процессору, и по памяти, с большим отрывом. При учетверении потока он прибавил всего в полтора раза. Если задача — «читать файлы и складывать в бэкенд», платить за что-то ещё незачем.

Между Vector и Collector размен, а не превосходство. Vector втрое дешевле по процессору и вчетверо дороже по памяти. При 20 000 событий в секунду Collector подошёл вплотную к полному ядру, Vector занял меньше половины — но потребовал почти полгигабайта.

Память Vector растёт с потоком, у двух других — практически нет: 330 → 475 МиБ против 105 → 110 и 25 → 25. Для агента на каждом хосте важнее не пик, а простой: Vector держит 150 МиБ, ничего не делая, против 40 у Collector и 20 у Fluent Bit. Умножьте на число хостов — это и есть настоящая цена присутствия.

Числа привязаны к этой машине, этому потоку и этим конфигам; сравнивать их между инструментами можно только потому, что работа у всех проверенно одинаковая. Разброс повторов: у Vector 0.8–2.4%, у Collector 1.4–2.9%, у Fluent Bit до 15% — последнее на малых абсолютных значениях, где доли процента дают большой относительный разброс.

Эксплуатация: версии, образы, проверка конфига

То, что не видно в бенчмарках, но чувствуется через полгода работы.

Vector всё ещё 0.x, и это не формальность. Версия 0.57 выключила интерполяцию ${VAR} в конфигах по умолчанию и ввела template confinement: шаблон синка без литерального префикса отвергается на старте. Оба изменения ломающие, оба попадают в типовые конфигурации. Версию надо пиновать и читать upgrade guide перед обновлением — подробнее об этом в статье про pipeline на Vector.

У Collector своя цена — сборка. Contrib-образ содержит всё подряд: 107 ресиверов в версии 0.157. Для прода собирают свой дистрибутив через OCB, включая только нужные компоненты, — это дополнительный шаг в CI, которого у двух других нет.

Fluent Bit ведёт две ветки параллельно. На момент замеров свежие релизы 4.2.x и 5.0.x выходили вперемешку. Выбор ветки — отдельное решение, которое надо принять осознанно.

Образы и пользователь по умолчанию:

Инструмент Размер образа Пользователь
Fluent Bit 5.0.9 48 МБ root
Vector 0.57.0-debian 88 МБ root
Collector contrib 0.157.0 108 МБ 10001:10001

Collector — единственный, кто по умолчанию непривилегированный. На стенде это обернулось падением на named volume, созданном docker от root: пришлось готовить том перед стартом, как это делает initContainer в k8s. Понижать его до root ради удобства неправильно — безопасное умолчание тут достоинство, а не помеха.

Образы Collector и Fluent Bit — distroless, внутрь не зайти: docker run --entrypoint sh отвечает executable file not found. Для отладки это ощутимо, для поверхности атаки — наоборот.

Проверка конфига до запуска. Заведомо сломанный конфиг ловят все трое, но по-разному:

Инструмент Команда Код на сломанном конфиге
Vector vector validate 78
Collector otelcol validate --config 1
Fluent Bit отдельной команды нет, только старт 1

Разница существеннее, чем кажется. У Vector и Collector проверка не ограничивается разбором YAML: обе строят компоненты и трогают окружение. Валидация Collector, например, поймала отсутствие каталога для состояния и устаревший формат секции телеметрии — до всякого запуска. У Fluent Bit единственный способ проверить конфиг — запустить процесс, что в CI означает поднимать контейнер и смотреть, не упал ли он.

Три умолчания, которые тихо портят данные

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

Collector режет строки активно пишущегося файла. Параметр force_flush_period ресивера file_log по умолчанию 500 мс: если строка не завершилась переводом каретки за этот срок, ресивер отправляет прочитанное как самостоятельную запись. На стенде тринадцать строк nginx и четырнадцать строк app уехали пятьюдесятью четырьмя обрывками — одна строка лога превратилась в два события, ни одно из которых не прошло разбор.

Проверяется это арифметикой: разорванная строка даёт +1 документ и +2 нераспарсенных, значит обрывков должно быть ровно вдвое больше расхождения. Так и вышло — 54 против 2 × 27. С force_flush_period: 0 не остаётся ни расхождения, ни обрывков. Vector и Fluent Bit в тех же условиях конца строки дожидаются.

Fluent Bit теряет чанки при исправной сети. Лимит буфера HTTP-ответа у выхода opensearch — 512 КБ по умолчанию. OpenSearch отвечает на bulk статусом по каждому документу, и на реальных пачках ответ в умолчание не влезает:

[warn] [http_client] cannot increase buffer: current=512000 requested=544768 max=512000
[warn] [output:opensearch:opensearch.0] http_do=-1 URI=/_bulk
[error] [engine] chunk cannot be retried: task_id=0, input=tail.0 > output=opensearch.0

Проблема растёт вместе с размером пачки — то есть появляется под нагрузкой и не воспроизводится на маленьких прогонах. Лечится buffer_size: 8M.

Vector на стенде ушёл в формат чужой версии API. У синка elasticsearch параметр api_version по умолчанию auto: Vector опрашивает бэкенд и подстраивает формат bulk. На этом стенде определение иногда не срабатывало, и в метаданные bulk попадал параметр _type, которого в OpenSearch 3.x нет:

400 illegal_argument_exception:
"Action/metadata line [1] contains an unknown parameter [_type]"

Vector считает 400 неретраибельной ошибкой и выбрасывает пачку целиком. Воспроизводилось непостоянно: при неспешном старте определение проходило и запись шла, при быстром перезапуске в скрипте замера — нет. Документация описывает более условную логику отката, завязанную в том числе на suppress_type_name, так что универсальным правилом это наблюдение считать не стоит — но api_version: v8 задать явно дешевле, чем разбираться, почему поток исчез.

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

Развилка выбора

Только логи, файлы, ограниченные ресурсы → Fluent Bit. Если задача — читать файлы, разобрать их парсером и сложить в бэкенд, он дешевле остальных в разы и по процессору, и по памяти, и единственный освобождает место на диске после доставки. Платой будет потолок выразительности: условная логика сложнее «сматчилось или нет» требует Lua, дедупликации нет, проверить конфиг можно только запуском.

Логи как основной сигнал и сложная обработка → Vector. VRL — полноценный язык с функциями под конкретные форматы, дедупликация и буферы встроены, ретраи переживают даже исчезновение бэкенда из DNS. Цена — память: 150 МиБ в простое и полгигабайта под нагрузкой на каждом хосте, плюс жизнь на ветке 0.x с ломающими изменениями между минорными версиями.

OTLP, трейсы, инструментированные приложения → Collector. Здесь у него нет конкурентов среди двоих: оба кодирования OTLP, все три сигнала одинаково полноправны, tail sampling, connectors между конвейерами, экосистема OpenTelemetry целиком. Цена — процессор (втрое дороже Vector на том же потоке), обязательная явная настройка ёмкости очереди и потеря данных при исчезновении бэкенда из DNS.

Оговорка, без которой развилка врёт: это выбор для конкретной задачи, а не навсегда. Ниже — про то, что бывает, когда задач две.

«Оба вместе» на практике

Совет ставить оба звучит в каждом сравнении и выглядит взрослым компромиссом: Vector агентом логов на хосте, Collector хабом для OTLP. Логи идут через сильный на логах инструмент, трейсы — через родной для них, стык между ними по OTLP.

Стык собирается, но не так, как подсказывает схема на салфетке.

Наивная сборка — прочитать файлы, разобрать VRL и отдать синком opentelemetry — не работает. Прогон: 40 000 событий, ожидание 36 627, в индексе ничего, индекса нет вовсе. В логе Vector:

ERROR sink{component_id=to_collector component_type=opentelemetry}:
  Not retriable; dropping the request. reason="Default retry strategy: Bad Request"
ERROR component_events_dropped: Events dropped intentional=false count=18962
ERROR component_events_dropped: Events dropped intentional=false count=17665

Collector при этом не пишет ни строки: с его точки зрения пришёл некорректный запрос, он ответил 400.

Дело не в том, что Collector не понимает JSON — OTLP/HTTP он принимает в обоих кодированиях, и protobuf, и JSON. Дело в том, что codec: json отправляет не OTLP/JSON, а обычный JSON события Vector: без конверта resourceLogs, без scopeLogs, без структуры записи. Для приёмника OTLP это просто посторонний документ.

Нужный кодек существует, и это ключевая деталь: encoding.codec: otlp кодирует события в OTLP-protobuf, то есть ровно в тот формат, который приёмник ждёт. Но сам по себе он ничего не спасает — с ним Vector падает раньше отправки, и ошибка уже своя, а не от Collector:

ERROR codecs::internal_events: Failed serializing frame.
  error=Log event does not contain OTLP top-level fields
        (resourceLogs or resourceMetrics)
  error_code="encoder_serialize" error_type="encoder_failed" stage="sending"
ERROR vector::internal_events::common: Failed to build request.
  error=unable to encode event error_type="encoder_failed"

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

Значит, укладывать событие в proto-модель OTel приходится самому — трансформацией:

to_otlp:
  type: remap
  inputs: [drop_health, parse_app]
  source: |
    svc = to_string(.service) ?? "unknown"
    pipe = to_string(.pipeline) ?? "vector-combo"
    msg = encode_json(.)
    ts = to_string(to_unix_timestamp(now(), unit: "nanoseconds"))

    . = {
      "resourceLogs": [
        {
          "resource": {
            "attributes": [
              {"key": "service.name", "value": {"stringValue": svc}}
            ]
          },
          "scopeLogs": [
            {
              "scope": {"name": "vector"},
              "logRecords": [
                {
                  "timeUnixNano": ts,
                  "observedTimeUnixNano": ts,
                  "severityNumber": 9,
                  "severityText": "INFO",
                  "body": {"stringValue": msg},
                  "attributes": [
                    {"key": "pipeline", "value": {"stringValue": pipe}},
                    {"key": "service", "value": {"stringValue": svc}}
                  ]
                }
              ]
            }
          ]
        }
      ]
    }

С этой трансформацией связка работает полностью: 36 627 документов при ожидании 36 627, потерь нет, ошибок в логе нет.

Так что вывод не «связка невозможна», а «связка стоит дороже, чем кажется по схеме».

Цена видна прямо в этом фрагменте, и стоит проговорить её честно, потому что показанная конфигурация — рабочая, но не образцовая:

  • поля перестают быть полями. Всё событие уложено в body одной JSON-строкой, в attributes вынесены только service и pipeline. Искать по duration_ms в бэкенде после такого не выйдет. Разложить поля по attributes можно, но тогда каждое надо описать парой key/value с типизированным значением, и трансформация вырастает в несколько раз;
  • время события подменяется временем обработки. timeUnixNano берётся из now(), а не из разобранного лога. Для правильного timeUnixNano нужно достать время из события и перевести в наносекунды — ещё несколько строк, свои для каждого формата;
  • это код, не имеющий отношения к предметной области. Двадцать пять строк, которые описывают чужую модель данных и которые придётся сопровождать.

Именно поэтому связка на стенде проверялась только на доставку: счётчик сошёлся, потерь нет. Сравнивать её выходные документы с тремя основными конвейерами бессмысленно — они собраны по-другому и по другой схеме полей.

Есть и ограничение транспорта: синк умеет только HTTP, protocol.type: grpc отвергается как unknown variant. Для OTLP это законно — HTTP+protobuf спецификацией предусмотрен, — но выбора нет.

Практические варианты, если связка нужна:

  • собрать OTLP-структуру в VRL, как выше: работает, но конвейер обрастает кодом, который к вашей предметной области отношения не имеет;
  • поставить Collector первым: он принимает OTLP от приложений и отдаёт Vector, которому остаётся обработка и доставка. Этим путём в статье проверялся приём трейсов Vector, и он не требует ручной сборки вообще;
  • стыковать через брокер — Kafka или NATS понимают оба инструмента, и вопрос кодировки снимается;
  • держать конвейеры раздельными: логи через Vector, трейсы через Collector, без стыка между ними.

Отдельно о том, чем опасен первый, наивный вариант. Оба конфига проходят валидацию. Оба процесса стартуют без ошибок. Метрики показывают, что события читаются и отправляются. Единственный признак неисправности — пустой индекс на другом конце, и обнаруживается он тогда, когда кто-то пойдёт искать логи. С codec: otlp хотя бы видна внятная ошибка сериализации, указывающая на причину.

Когда не нужен ни тот, ни другой

Слой сбора — не бесплатная абстракция: ещё один процесс на каждом хосте, ещё один конфиг, ещё одно место, где теряются данные. Иногда он не нужен.

Одно приложение, один хост, немного логов. docker logs и journalctl решают задачу полностью. Слой сбора появляется, когда хостов становится больше одного.

Приложение уже пишет напрямую в бэкенд. Библиотеки логирования умеют отправлять в Loki, OpenSearch или ClickHouse сами. Это хуже переживает недоступность бэкенда и связывает приложение с хранилищем, но для внутреннего сервиса бывает достаточно.

Kubernetes с готовым решением. Если в кластере уже развёрнут DaemonSet сбора логов, второй слой рядом обычно создаёт больше проблем, чем решает.

Только трейсы и метрики, логов почти нет. SDK OpenTelemetry умеет отправлять напрямую в бэкенд без Collector. Промежуточный слой понадобится, когда нужны сэмплинг, обогащение или буферизация.

Когда слой всё-таки нужен и зачем — разобрано в статье про OpenTelemetry Collector.

Итог

Вопрос «Vector или OpenTelemetry Collector» поставлен неверно, но не потому, что «оба хороши», а потому, что у них разные основные сигналы, и это видно в поведении, а не в маркетинге.

Что показали замеры на одинаковой работе:

  • ресурсы: Fluent Bit дешевле обоих кратно; между Vector и Collector размен — втрое меньше процессора ценой вчетверо большей памяти;
  • трансформация: разбор строк удваивает CPU у Vector и Fluent Bit, у Collector теряется на фоне его собственной стоимости;
  • надёжность: Vector и Fluent Bit не потеряли ничего в обоих режимах отказа; Collector требует явной настройки очереди, а при исчезновении бэкенда из DNS теряет данные независимо от неё;
  • перезапуск: все трое одинаковы — том под состояние обязателен, иначе ровное удвоение;
  • OTLP: Vector принимает трейсы и сохраняет корреляцию, но только в protobuf, и настройка декодирования работает обратно своему названию;
  • связка по OTLP: в направлении Vector → Collector собирается, но требует ручной укладки события в proto-модель OTel — сам по себе ни один кодек этого не делает.

Практический вывод короче: берите Fluent Bit, если задача простая; Vector, если логи основной сигнал и нужна серьёзная обработка; Collector, если основной сигнал OTLP. А если нужны оба — проще ставить Collector первым: обратный порядок работает, но конвейер приходится обвешивать кодом сборки OTLP-структуры.

И общее наблюдение, которое оказалось важнее любого из чисел. Три из найденных дефектов — разрезанные строки у Collector, потерянные чанки у Fluent Bit, чужой формат API у Vector — портят данные молча. Ни один не поднимает ошибку, не роняет процесс и не меняет метрики. Единственное, что их ловит, — сверка точного числа отправленных событий с числом доехавших. Если в вашем конвейере такой сверки нет, вы не знаете, доезжают ли логи целиком; вы знаете только, что процесс жив.

Как устроен сам Vector изнутри — VRL, буферы, брокер между агентом и агрегатором — в статье про pipeline логов на Vector. Зачем нужен слой сбора и как его разворачивать — в статье про OpenTelemetry Collector. Где хранить логи после сбора — будет в сравнении Loki, OpenSearch и VictoriaLogsготовится, с 21 октября.

Стенд со всеми конфигами, скриптами и сырыми выводами замеров — digital-cookbook/observability/vector-vs-collector.

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

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

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

Комментарии