Представь типичную прод-картину. Клиент жмёт кнопку, ловит 502, в чате саппорта горит "у меня всё сломалось". Ты открываешь логи. На фронт-балансировщике запрос есть. На API-шлюзе вроде тоже есть, но как понять, что это тот самый запрос, а не соседний? Время чуть разное, IP за CDN одинаковый, URI у половины запросов один и тот же. Дальше backend, очередь, ещё один внутренний nginx. Пять логов, пять разных форматов, и ни одной нитки, которая связала бы их в одну историю. Ты сидишь и глазами сопоставляешь миллисекунды. Это не отладка, это гадание.
Решается это одним принципом: у каждого запроса должен быть уникальный идентификатор, который рождается на первом узле и едет через всю систему - в заголовках на бэкенд и в каждой строчке лога каждого хопа. Тогда ты грепаешь по одному id и видишь весь путь: кто принял, куда проксировал, сколько думал апстрим, где именно отвалилось. Это и есть nginx трассировка. В этом уроке разберём два уровня: ручную корреляцию логов через переменные nginx (паттерн нашего эталона) и автоматический distributed tracing через ngx_otel_module с экспортом в Jaeger/Tempo. Первое - дёшево и работает везди. Второе - даёт визуальный таймлайн спанов без единой строки кода в приложении.

nginx request_id: встроенный уникальный идентификатор
Базовый кирпич - встроенная переменная $request_id. Она появилась ещё в nginx 1.11.0 и есть везде, ничего подключать не надо. Это 32 шестнадцатеричных символа (16 случайных байт), генерируется на каждый запрос независимо. Не путай с $connection (номер соединения) - на одном keepalive-соединении проходит много запросов, у каждого свой $request_id.
Код: Выделить всё
log_format trace '$remote_addr [$time_local] id=$request_id '
'"$request" $status rt=$request_time '
'urt=$upstream_response_time';
access_log /var/log/nginx/access.log trace;
Паттерн эталона: цепочка map для nginx x-request-id и хопов
В эталонном проекте ngx-trace-gateway вся логика трассировки вынесена в два include-файла: global_trace.conf и global_hop_name.conf. Идея простая, но красивая: доверяй входящему id, если он есть, иначе роди свой. Реализуется это через map, потому что map - ленивый, считается только при первом обращении к переменной, и не тратит CPU, если строку лога никто не пишет.
Сердце - первый map. Если клиент (или вышестоящий прокси) прислал заголовок X-Request-ID, мы его уважаем и используем как сквозной trace-идентификатор. Если заголовок пустой - подставляем локально сгенерированный $request_id. Так первый nginx в цепочке становится "источником истины", а все последующие наследуют его id.
Код: Выделить всё
# global_trace.conf
# сквозной id всей цепочки: входящий X-Request-ID или, если пусто, наш $request_id
map $http_x_request_id $trace_request_id {
default $http_x_request_id;
"" $request_id;
}
# локальный id именно этого хопа (всегда свой, всегда уникальный)
map $request_id $local_request_id {
default $request_id;
}
# "роль-узла:локальный-id" - отметка текущего хопа
map "$hop_name:$local_request_id" $current_hop {
default "$hop_name:$local_request_id";
}
# цепочка хопов: дописываем себя к тому, что пришло
map $http_x_request_chain $request_hops_chain {
default "$http_x_request_chain,$current_hop";
"" "$current_hop";
}
Откуда берётся $hop_name? Это второй файл. Хостнеймы в проде уродливые и нестабильные (ip-10-0-3-44.eu-central-1.compute.internal), а в логах хочется читаемую логическую роль. Поэтому маппим $hostname в человекочитаемую метку:
Код: Выделить всё
# global_hop_name.conf
map $hostname $hop_name {
default "unknown-node";
"~^api-(\d+)" "api-nginx-$1"; # api-01.prod -> api-nginx-01
"~^edge-(\d+)" "edge-nginx-$1";
"~^int-(\d+)" "internal-nginx-$1";
}
# физический хостнейм оставляем отдельно, для точечной диагностики
map $hostname $hop_physical {
default $hostname;
}
Проброс заголовков на бэкенд и в лог - замыкаем корреляцию
Переменные посчитали - теперь их надо передать дальше и записать у себя. Без этого вся схема мёртвая: бэкенд не узнает trace-id, а в логе нечего грепать. Проброс живёт в global_proxy.conf:
Код: Выделить всё
# global_proxy.conf (фрагмент трассировки)
proxy_set_header X-Request-ID $trace_request_id;
proxy_set_header X-Request-Chain $request_hops_chain;
proxy_set_header X-HOP-Name $hop_name;
proxy_set_header X-Prev-Request-ID $local_request_id;
Вторая половина - записать всё в свой структурированный лог. В эталоне log_format с escape=json и полями trace:
Код: Выделить всё
log_format structured_log escape=json
'{'
'"timestamp":"$time_iso8601",'
'"trace_request_id":"$trace_request_id",'
'"local_request_id":"$local_request_id",'
'"hop_name":"$hop_name",'
'"hops_chain":"$request_hops_chain",'
'"client_ip":"$remote_addr",'
'"method":"$request_method","uri":"$uri",'
'"status":$status,'
'"request_time":$request_time,'
'"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"upstream_response_time":"$upstream_response_time"'
'}';
W3C traceparent: стандарт, а не отсебятина
X-Request-ID хорош для корреляции логов, но это самопальная схема. Индустрия договорилась о стандарте - W3C Trace Context. Это два заголовка: traceparent и tracestate. Формат traceparent жёсткий:
Код: Выделить всё
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
^ ^--------- trace-id ---------^ ^-- span-id --^ ^flags
версия (32 hex) (16 hex)
Код: Выделить всё
proxy_set_header traceparent $http_traceparent;
proxy_set_header tracestate $http_tracestate;
nginx opentelemetry: ngx_otel_module без единой строки кода
Главная новость 2026 года, которую многие до сих пор не усвоили: ngx_otel_module - официальный и OPEN-SOURCE модуль. Не Plus-only, не сторонний. Его пишет команда nginx (репозиторий nginx/nginx-otel), он раздаётся пакетом nginx-module-otel начиная с nginx 1.25.3, входит в официальные docker-образы с суффиксом -otel, а с 1.27.5 включён в них по умолчанию. Раньше для OTel в nginx брали сторонний модуль на C++ от OpenTelemetry с накладными расходами до ~50% на запрос. Нативный ngx_otel_module написан на Rust и держит оверхед в районе ~10-15% - это переворачивает экономику трассировки в проде.
Что он умеет: генерит OTel-спан на каждый запрос, поддерживает W3C trace context propagation (читает входящий traceparent, создаёт дочерний спан, пробрасывает дальше) и экспортирует спаны в коллектор по OTLP/gRPC. Важно: только gRPC, HTTP-экспортёра у него нет - целишься в порт 4317 коллектора, не 4318.
Подключение - динамический модуль, грузится в самом верху nginx.conf:
Код: Выделить всё
# nginx.conf, в главном контексте
load_module modules/ngx_otel_module.so;
Код: Выделить всё
http {
otel_exporter {
endpoint otel-collector:4317; # OTLP/gRPC, НЕ 4318
interval 5s; # как часто слать батчи
batch_size 512;
batch_count 4;
}
otel_service_name api-gateway; # service.name в трейсах
otel_trace on; # включить трассировку
otel_trace_context propagate; # читать и пробрасывать W3C
server {
listen 443 ssl;
http2 on;
location /api/ {
# имя спана: по умолчанию имя location, переопределяем явно
otel_span_name "api_proxy";
# свои атрибуты в спан - тут пригождается $request_id
otel_span_attr "request.id" $request_id;
otel_span_attr "http.route" $uri;
otel_trace_context propagate;
proxy_pass http://backend_upstream;
}
}
}
- ignore - входящий traceparent игнорируется, новый не вставляется. Трассировки фактически нет.
- extract - читаем входящий контекст (привязываемся к чужому трейсу), но дальше НЕ пробрасываем. Read-only.
- inject - игнорируем входящее, генерим новый корень трассы и вставляем его дальше. Так настраивают самый первый (edge) узел, на который приходят клиенты без traceparent.
- propagate - читаем входящий контекст И пробрасываем дочерний дальше. Это для внутренних прокси посреди цепочки. Самый частый режим.
Заметь красивую деталь: otel_span_attr "request.id" $request_id связывает два мира. В визуальном трейсе у каждого спана теперь есть атрибут с твоим nginx request_id - и ты можешь прыгнуть из Jaeger прямо в логи по этому id. Distributed tracing и log correlation замкнуты.
Jaeger, Tempo, Grafana и Angie OTel
Куда это всё течёт. ngx_otel_module шлёт OTLP/gRPC. На приёме обычно OpenTelemetry Collector - он принимает спаны, батчит, при желании сэмплирует и роутит дальше. За ним стоит хранилище: Jaeger (классика, своя UI с поиском по trace-id и атрибутам) или Grafana Tempo (дешёвое хранилище в объектном сторадже, бесшовно дружит с Grafana и Loki - кликаешь по trace-id в логе и проваливаешься в трейс). Минимальный коллектор для лабы:
Код: Выделить всё
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
exporters: [otlp/jaeger]
Типичные грабли
- Доверять X-Request-ID откуда попало. Если edge-узел слепо берёт $http_x_request_id, любой клиент пришлёт свой и подделает/схлопнет трассировку (одинаковый id у всех -> логи слипнутся). На самом внешнем периметре X-Request-ID от клиента лучше зачищать (proxy_set_header X-Request-ID $request_id безусловно), а наследовать входящий только от доверенных внутренних прокси.
- Порт 4318 в otel_exporter. Модуль умеет только OTLP/gRPC (4317). 4318 - это HTTP-протокол, спаны просто не дойдут, и без ошибок в логе. Молчаливая потеря данных.
- otel_trace_context ignore вместо propagate. Включил otel_trace on, а трассы рваные - почти всегда контекст стоит в ignore/extract там, где нужен propagate. Дерево спанов распадается на отдельные корни.
- Логировать $upstream_response_time без кавычек как число. При нескольких апстримах или ретраях переменная становится списком вида "0.012, 0.300" - и невалидный JSON ломает парсер. В эталоне это поле всегда строка.
- Думать, что $request_id и trace-id из traceparent - одно и то же. Это разные сущности: $request_id локален для узла, W3C trace-id сквозной. Связывай их через otel_span_attr, не подменяй.
- map с регэкспом без ~. map $hostname $hop_name без тильды сравнивает строку буквально, регэксп не сработает, все хосты упадут в default. Префикс ~ обязателен.
Поднимем цепочку и проверим сквозной id. Понадобится docker.
- Шаг 1. Создай global_trace.conf и global_hop_name.conf по образцам выше, подключи их через include в http-контекст.
- Шаг 2. В http_server добавь log_format structured_log (с полем trace_request_id) и access_log с ним. Проверь конфиг:
Код: Выделить всё
nginx -t - Шаг 3. Дёрни шлюз без заголовка и убедись, что id родился сам: Посмотри в access.log - trace_request_id должен быть непустым 32-символьным.
Код: Выделить всё
curl -s -D- http://localhost/api/ping -o /dev/null - Шаг 4. Теперь пришли свой id и проверь, что он наследуется: В логе trace_request_id обязан быть ровно deadbeef-test-0001, а local_request_id - своим сгенерённым.
Код: Выделить всё
curl -s -H "X-Request-ID: deadbeef-test-0001" http://localhost/api/ping - Шаг 5. Подключи ngx_otel_module: load_module наверху, блок otel_exporter с endpoint на otel-collector:4317, otel_trace on, otel_trace_context propagate. Подними OpenTelemetry Collector + Jaeger (конфиг выше).
- Шаг 6. Сделай несколько запросов и открой Jaeger UI (порт 16686). Найди сервис api-gateway, открой трейс, разверни спан - проверь, что в атрибутах виден request.id, равный твоему $request_id.
- Шаг 7. Финал корреляции: возьми trace_request_id из JSON-лога, найди по нему запрос, сопоставь с атрибутом request.id в Jaeger. Лог и трейс должны сойтись на одном запросе.
- Зачем в эталоне разделены $trace_request_id и $local_request_id, и какой из них уходит бэкенду в X-Request-ID?
- Чем режим otel_trace_context inject отличается от propagate, и на каком узле цепочки какой ставить?
- Почему otel_exporter endpoint указывает на порт 4317, а не 4318, и что произойдёт при ошибке?
- Как связать визуальный трейс в Jaeger с конкретной строкой структурированного лога nginx?
Сквозная трассировка стоит на двух уровнях. Дешёвый и универсальный - паттерн эталона: цепочка map рождает или наследует $trace_request_id, дописывает хоп в X-Request-Chain, $hostname маппится в логическую роль $hop_name, и всё это пробрасывается бэкенду и пишется в JSON-лог - корреляция логов по одному грепу. Богатый уровень - nginx opentelemetry через официальный open-source ngx_otel_module (пакет nginx-module-otel с 1.25.3, OTLP/gRPC, W3C propagation, ~10-15% оверхеда): спаны в Jaeger/Tempo без кода в приложении. Свяжи их через otel_span_attr с $request_id - и у тебя метрики, логи и трейсы сходятся на одном идентификаторе. Запрос больше нигде не пропадёт.