OpenTelemetry на AWS: единые сигналы и ловушка кардинальности
OpenTelemetry инструментирует трассы, метрики и логи один раз и экспортирует куда угодно; ADOT — это Collector на AWS. Главная ловушка — кардинальность: user_id в метке метрики — это один временной ряд на пользователя, что расплавляет бэкенд и счёт.
Кто-то в платформенной команде хочет дашборды латентности по каждому пользователю, поэтому добавляет одну строку в обработчик запроса: измерение user_id на существующей метрике http.server.duration. Это уезжает в пятницу. К понедельнику инстанс Prometheus/AMP, питающий дашборды, OOM-убивает себя в цикле падений, запросы отваливаются по таймауту, а дежурный, который сперва объявил аварию метрик, постепенно понимает, что аварии нет — бэкенд делает ровно то, что ему велели. Каждое уникальное значение user_id создало собственный временной ряд; при 1,4 миллиона активных пользователей одна метка превратила горстку рядов примерно в полтора миллиона, а число активных рядов — это то, что база временных рядов держит в памяти. Команда, у которой это крутится на кастомных метриках CloudWatch, а не на Prometheus, не падает — они получают тот же урок в виде пятизначного счёта, потому что каждая отдельная комбинация измерений — это отдельная биллингуемая метрика. Одна и та же ошибка, два счёта: один оплачен ОЗУ, другой — деньгами. Никто не хранил слишком много данных по объёму. Они хранили слишком много различных форм данных — а это и есть кардинальность, единственное число observability, которое растёт вместе с базой пользователей, а не с трафиком.
OpenTelemetry и ADOT: инструментируй один раз, экспортируй куда угодно
Прошлый урок был про X-Ray — собственный бэкенд трассировки AWS и его агента. Проблема опоры на агента любого одного вендора — это lock-in: в день, когда ты захочешь слать трассы в Datadog, метрики в самохостимый Prometheus, а логи держать в CloudWatch, тебе придётся переинструментировать весь флот, потому что вендорский агент говорит только с вендором. OpenTelemetry (OTel) — это вендор-нейтральный ответ: один открытый стандарт для инструментирования трёх сигналов — трасс, метрик и логов — единым SDK в твоём коде, плюс отдельный процесс под названием OTel Collector, который принимает, обрабатывает и экспортирует эту телеметрию в любой выбранный тобой бэкенд. Код приложения эмитит OTLP (OpenTelemetry Protocol — проводной протокол для передачи телеметрии, поддерживает gRPC и HTTP); Collector решает, куда это приземлится. Смена бэкенда становится изменением конфига Collector, а не изменением кода во всех сервисах.
На AWS поддерживаемая дистрибуция Collector — это ADOT, AWS Distro for OpenTelemetry: апстримный Collector плюс поддерживаемые AWS экспортёры (X-Ray, CloudWatch/EMF, Amazon Managed Prometheus) и патчи безопасности, с поддержкой AWS. Collector работает в одной из трёх топологий, и выбор — это настоящий компромисс. Как агент/сайдкар (один Collector на хост или на под) он близок к приложению, не добавляет сетевого хопа и изолирует радиус поражения — но ты запускаешь N Collector’ов и платишь эти накладные N раз. Как центральный шлюз (балансируемый флот Collector’ов, в который шлют все сервисы) ты централизуешь политику сэмплирования, батчинга и редактирования в одном месте — но это теперь общая зависимость, которую надо масштабировать и держать высокодоступной, и это естественное место для tail-сэмплирования, потому что он видит целые трассы. Большинство серьёзных установок запускают оба: тонкий агент для сбора, шлюз для политики.
# ADOT / OTel Collector: принять OTLP, сбатчить, затем РАЗВЕСТИ на три бэкенда.
receivers:
otlp:
protocols:
grpc: # приложения пушат OTLP по gRPC в локальный агент
http:
processors:
batch: {} # амортизируй вызовы экспорта; никогда не экспортируй по спану
exporters:
awsxray: {} # трассы -> AWS X-Ray
awsemf: # метрики -> CloudWatch через Embedded Metric Format
namespace: Checkout
dimension_rollup_option: NoDimensionRollup
prometheusremotewrite: # метрики -> Amazon Managed Prometheus (AMP)
endpoint: "https://aps-workspaces.../api/v1/remote_write"
service:
pipelines:
traces: { receivers: [otlp], processors: [batch], exporters: [awsxray] }
metrics: { receivers: [otlp], processors: [batch], exporters: [awsemf, prometheusremotewrite] }Компромисс, который надо проговорить вслух: OTel покупает тебе портативность и одну инструментацию на все бэкенды ценой эксплуатации Collector (stateful, ограниченный памятью процесс, который надо сайзить и мониторить) и жизни со спецификацией, где часть кусков — особенно логи — созрели позже трасс. Turnkey-агент вендора пропускает Collector целиком; за это удобство ты платишь lock-in’ом. Режим отказа при ошибке здесь коварен: недонастроенный шлюзовой Collector молча роняет спаны под нагрузкой (очередь batch заполняется и экспортёр сбрасывает), так что твои трассы выглядят полными в dev и зияют дырами в проде ровно тогда, когда они нужны.
Корреляция сигналов: trace_id — это ключ соединения
Причина, по которой стоит объединять три сигнала под одним стандартом, — это корреляция: возможность за секунды перейти от скачка метрики к примеру трассы и к точным строкам лога того самого запроса, вместо грепа по трём несвязанным системам. Механизм — единый идентификатор, trace_id, который течёт через все три. Трасса распространяется через границы сервисов заголовком W3C traceparent, так что каждый хоп делит один trace_id:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ └─ trace_id (32 hex) ──────────┘ └ span_id ────┘ └ флаги
└ версияТот же trace_id штампуется в каждый структурированный лог, который эмитит запрос, так что фильтрация логов по одному trace_id восстанавливает ровно то, что произошло на том запросе. А со стороны метрик экземпляры (exemplars) прикрепляют образцовый trace_id к точке данных метрики — поэтому, когда бакет гистограммы латентности скачет, ты кликаешь по скачку и попадаешь на реальную медленную трассу, а не на догадку. Метрика → трасса → лог, по одному клику. Клей, который вообще делает сигналы соединяемыми, — это согласованные resource-атрибуты: каждый сигнал, который эмитит приложение, помечен одним и тем же service.name, deployment.environment, service.version. Сделай их несогласованными — один сервис зовёт себя checkout, другой checkout-svc — и бэкенд считает их разными сервисами, карта сервисов рассыпается, а корреляция тихо ломается без ошибки. Режим отказа здесь — не падение; это дашборды, которые выглядят нормально и врут.
Head против tail сэмплирования: где ты решаешь, что хранить
Ты не можешь хранить каждую трассу на масштабе — при 5000 запросов/секунду это ~13 миллиардов трасс в месяц, и как счёт за хранение, так и накладные SDK/сети делают 100% удержание абсурдом. Поэтому ты сэмплируешь, обычно оставляя 1–10% трасс. Сеньорное решение — где происходит выбор «хранить/дропнуть». Head-сэмплирование решает в самом начале трассы, на первом сервисе, до того как сделана любая работа: дёшево, без состояния, и решение распространяется вниз через флаги traceparent, так что вся трасса согласованно хранится или дропается. Его фатальная слабость в том, что оно решает вслепую — оно не может знать, что эта трасса закончится 500-кой или займёт 9 секунд, поэтому ровный 5% head-сэмпл по определению выбросит 95% твоих редких трасс с ошибками, ровно тех, что были нужны.
Tail-сэмплирование решает после завершения трассы, когда исход известен: оставить 100% трасс, которые упали с ошибкой или превысили порог латентности, и сэмплировать скучные быстрые успехи до 1%. Сигнал кардинально лучше — ты оставляешь важное и дропаешь только шум. Цена структурна: Collector должен буферизовать каждый спан каждой трассы в полёте в памяти, пока трасса не завершится (окно буферизации, скажем, 10–30 секунд), затем оценить политику на собранной трассе. Это делает tail-сэмплирование stateful, прожорливым к памяти и чувствительным к окну буферизации — слишком короткое, и ты решаешь по неполным трассам; слишком длинное, и ты взрываешь память Collector’а. Оно также вынуждает все спаны одной трассы попадать в один и тот же инстанс Collector, что усложняет горизонтально масштабируемый шлюз.
▸Почему это работает
Почему head-сэмплирование дёшево, но слепо, а tail умно, но дорого? Потому что ценность трассы познаваема только в конце — была ли она медленной, упала ли? — а стоимость её хранения платится всю дорогу. Head-сэмплирование платит решение о стоимости заранее, в самый дешёвый момент (один булев флаг, проброшенный во флагах traceparent, ноль буферизации), и в обмен получает ноль информации: оно бросает взвешенную монету ещё до того, как запрос вообще выполнился. Tail-сэмплирование инвертирует оба: оно ждёт, пока исход не известен, поэтому может оставить 0,1% трасс с ошибками и дропнуть 99% скучных — куда лучший сигнал на хранимый байт — но чтобы ждать, Collector должен держать каждый спан каждой открытой трассы в ОЗУ всё окно буферизации. Поэтому правило большого пальца — гибрид: head-сэмплируй до управляемой ставки, чтобы ограничить шланг, затем tail-сэмплируй внутри, чтобы предпочтительно оставлять ошибки и медленные трассы. Ты обмениваешь память на релевантность, намеренно.
Ловушка кардинальности: стоимость метрики — это число её уникальных комбинаций меток
Прежде чем добавить метку к любой метрике, спроси себя: сколько различных значений может принимать это поле по всей базе пользователей? Если ответ «одно на пользователя» или «одно на запрос» — остановись. Вот почему.
Это режим отказа, который сжигает репутацию ценой карьеры, и он чисто про арифметику. Метрика — это не одна штука, это один временной ряд на каждую уникальную комбинацию значений меток (измерений). Метрика http.server.duration{route, status_class, region} с, скажем, 20 маршрутами × 5 классами статуса × 4 регионами — это 400 временных рядов, совершенно нормально. Стоимость — это произведение кардинальностей всех меток. Ловушка — поместить высококардинальный идентификатор в метку: user_id, request_id, session_id, email или сырой URL со встроенными ID (/orders/4821). Метка user_id добавляет не «несколько» рядов — она добавляет один ряд на пользователя. Десятки тысяч пользователей — это десятки тысяч рядов; 1,4 миллиона пользователей — это 1,4 миллиона рядов от одной метки, а если она умножается с существующими метками, то десятки миллионов. Бэкенд Prometheus/AMP держит активные ряды в памяти, поэтому это OOM-убивает его; CloudWatch тарифицирует каждую отдельную комбинацию измерений как отдельную кастомную метрику (иллюстративно ~$0,30/метрика/месяц, зависит от региона), так что миллион комбинаций — это пятизначная строка счёта — те самые два счёта из Hook.
Фикс — это дисциплина, а не фича. Держи метки метрик низкокардинальными и ограниченными — значения из маленького конечного набора: шаблон маршрута (/orders/{id}), а не сырой путь, status_class (2xx, 5xx), а не точный код, если надо ограничить, region/az, service.version. Высококардинальные идентификаторы принадлежат трассам и логам, где им положено жить — трасса и так записывает user_id и request_id как атрибуты спана бесплатно, а структурированный лог несёт их как поля — и ты добираешься до них через соединение по trace_id, а не через метку метрики. Конкретно с EMF: поле в EMF-JSON тарифицируется как измерение метрики только если ты перечислил его в массиве Dimensions, поэтому логируй user_id обычным полем (бесплатно, запрашиваемо) и никогда не клади его в Dimensions. Правило, которое сеньор усваивает: метки — для того, по чему ты группируешь и алармишь; идентификаторы — для того, по чему ты ищешь конкретный экземпляр, — и они идут в трассы и логи, никогда в метку метрики.
# АНТИПАТТЕРН — взрывается до одного ряда на пользователя/запрос:
attributes:
- key: user_id # миллионы значений -> миллионы рядов
- key: http.target # сырой "/orders/4821" -> один ряд на id заказа
# ФИКС — ограниченные метки на метрике; идентификаторы уходят в спаны/логи:
attributes:
- key: http.route # шаблон "/orders/{id}" -> ~десятки рядов
- key: status_class # "2xx"/"4xx"/"5xx" -> горстка
- key: region # конечный набор
# user_id / request_id: держи их атрибутами СПАНА + полями лога, не метками метрики.
# добирайся до них соединением по trace_id, никогда не добавляя измерение метрики.Тебе нужна отладка по каждому запросу и пользователю И дешёвая, масштабируемая метрика латентности. Куда положить user_id, чтобы сохранить оба?
Команда добавляет метку user_id к одной метрике Prometheus при 1,4 миллиона активных пользователей, и бэкенд начинает OOM-падать. Что на самом деле гонит стоимость и каков фикс?
- 01Что OpenTelemetry + ADOT дают сверх агента одного вендора и чего это стоит?
- 02Объясни ловушку кардинальности и компромисс head-против-tail сэмплирования с правилом, куда какой идентификатор принадлежит.
OpenTelemetry — это вендор-нейтральный способ инструментировать все три сигнала observability — трассы, метрики и логи — один раз, единым SDK, и слать их через OTel Collector в любой бэкенд; на AWS этот Collector — ADOT, экспортирующий в X-Ray, CloudWatch/EMF или Amazon Managed Prometheus, так что смена или развод бэкендов — это изменение конфига, а не переинструментация, ценой эксплуатации stateful, ограниченного памятью Collector (как сайдкар-агент, центральный шлюз или оба). Выигрыш объединения сигналов — корреляция: один trace_id, проброшенный через сервисы заголовком W3C traceparent и заштампованный в каждый структурированный лог, плюс exemplars, связывающие скачок метрики с примером трассы, позволяют переходить метрика → трасса → лог для одного запроса — но лишь если согласованные resource-атрибуты (service.name, deployment.environment) делают сигналы соединяемыми, иначе корреляция тихо ломается. Поскольку ты не можешь хранить каждую трассу на масштабе, ты сэмплируешь 1–10%: head-сэмплирование решает дёшево в начале, но вслепую, дропая редкие трассы с ошибками; tail-сэмплирование решает после завершения трассы, так что оставляет каждую ошибку и медленную трассу ценой буферизации целых трасс в памяти Collector — обычный ответ гибрид. И сеньорная ловушка, что связывает всё, — это кардинальность: метрика — один временной ряд на уникальную комбинацию меток, поэтому высококардинальный идентификатор вроде user_id меткой метрики становится одним рядом на пользователя — миллионами — что OOM-убивает бэкенд Prometheus/AMP и тарифицируется как миллион кастомных метрик CloudWatch. Держи метки метрик низкокардинальными и ограниченными (шаблон маршрута, status_class, region), а user_id, request_id и сырые пути клади туда, где им место: в трассы и логи, достижимые соединением по trace_id, никогда в метку метрики. Теперь, когда видишь бэкенд временных рядов под давлением OOM или счёт CloudWatch, заваленный кастомными метриками, твой первый вопрос — какая метка имеет неограниченную кардинальность; и ответ почти всегда — идентификатор пользователя или запроса, которому место в атрибуте спана, а не в метке метрики.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.