Сквозная трассировка запросов и OpenTelemetry

Рейтинг: 79.6% · 15 голосов
Самый подробный курс по Nginx - от первого конфига до production API-gateway. Архитектура и event loop, контексты и location, виртуальные хосты, статика, reverse proxy и upstream-балансировка, кэширование, HTTPS и TLS (post-quantum), HTTP/2 и HTTP/3 QUIC, безопасность и rate limiting, realip, JSON-логи и сквозная трассировка, метрики, тюнинг и траблшутинг. Плюс OpenResty (Lua, фазы, JWT, динамика) и Angie (нативные Prometheus, ACME, dynamic upstream). На основе эталонного gateway и лучших практик. Для новичков и SRE/DevOps. Актуально на 2026 (nginx 1.30/1.31, Angie, ingress-nginx EOL -> Gateway API).
Ответить
Аватара пользователя
Igor_NgINX
Сообщения: 44
Зарегистрирован: 11 май 2026, 05:31

Сквозная трассировка запросов и OpenTelemetry

Сообщение Igor_NgINX »

Оглавление курса (44)
  1. Что такое Nginx и почему он захватил веб
  2. Установка Nginx, Angie и OpenResty: пакеты, версии, Docker
  3. Первый конфиг с нуля: минимальный сервер за 5 минут
  4. Архитектура процессов: master, worker и event loop
  5. Анатомия конфигурации: контексты, наследование и модульность
  6. Виртуальные хосты: server и server_name
  7. Директива location: матчинг и приоритеты по шагам
  8. Раздача статики: root, alias, try_files, sendfile
  9. Переменные, map и блок if
  10. rewrite, return и канонизация URL
  11. Работа с заголовками: add_header и proxy_set_header
  12. Модель доверия прокси-заголовков: realip и защита от спуфинга
  13. Сжатие: gzip, brotli и zstd
  14. Reverse proxy: proxy_pass и проброс запроса
  15. upstream и балансировка нагрузки
  16. Таймауты, повторы и устойчивость прокси
  17. FastCGI и PHP: nginx + PHP-FPM (и uwsgi/scgi)
  18. WebSocket, gRPC и стриминг через Nginx
  19. Кэширование ответов: proxy_cache и микрокэш
  20. HTTPS и TLS: сертификаты, протоколы, шифры (2026)
  21. Let's Encrypt и ACME: certbot, acme.sh, нативный ACME Angie
  22. HTTP/2 и HTTP/3 (QUIC) в 2026
  23. Безопасность: заголовки, скрытие версии, ограничения
  24. Rate limiting и защита от перегрузки
  25. Контроль доступа: allow/deny, auth_basic, auth_request и mTLS
  26. Логирование: access_log, форматы и структурированный JSON
  27. Сквозная трассировка запросов и OpenTelemetry (вы здесь)
  28. Метрики и мониторинг Nginx
  29. Производительность и тюнинг под нагрузку
  30. Траблшутинг: 502, 504, 403, 404 и debug-лог
  31. OpenResty: Nginx как платформа на LuaJIT
  32. Фазы обработки запроса и Lua-хуки
  33. Lua API: ngx.*, shared dict, cosocket и lua-resty-core
  34. Практика OpenResty: авторизация, JWT, кэш, Redis
  35. Angie: современный форк Nginx 2026
  36. Динамическая конфигурация и service discovery
  37. Nginx как API-gateway и Kubernetes Gateway API
  38. Динамические модули, njs и WAF
  39. Stream-модуль: проксирование TCP и UDP
  40. Деплой, перезагрузка и конфиг как код
  41. Сценарий: статика, SPA и кэширование за CDN
  42. Сценарий: микросервисный gateway по образцу ngx-trace-gateway
  43. Капстоун: собираем production-gateway с нуля
  44. Чек-лист безопасности, CVE 2026 и аудит конфигурации
Боль: запрос пропал где-то между пятью сервисами

Представь типичную прод-картину. Клиент жмёт кнопку, ловит 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;
Теперь в логе у каждой строки есть id вида 4b2e8c1a9f.... Но сам по себе nginx request_id решает только полдела: он уникален на этом узле. Если запрос прошёл через три nginx, у тебя три разных $request_id и связи между ними нет. Нужна сквозная схема, где первый узел диктует идентификатор всем остальным. Здесь на сцену выходит nginx x-request-id и цепочка map из эталонного проекта.

Паттерн эталона: цепочка 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";
}
Разберём, что тут происходит, по шагам. $trace_request_id - это та самая сквозная нитка, едет неизменной через все узлы. $local_request_id - локальный отпечаток конкретно этого nginx (по сути копия $request_id, выделен отдельной переменной ради читаемости логов и единообразия). $current_hop склеивает логическую роль узла с его локальным id. А $request_hops_chain - самое вкусное: берёт пришедший заголовок X-Request-Chain и дописывает к нему текущий хоп через запятую. Если заголовка не было (мы первый узел) - чейн начинается с нас. В итоге на бэкенде в одном заголовке лежит весь маршрут: edge-nginx-01:abc,api-nginx-01:def,internal-nginx-02:ghi. Видно физический путь запроса по инфраструктуре.

Откуда берётся $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;
}
Регэксп в map работает как ты ожидаешь: ~^api-(\d+) ловит хост api-01.prod и через $1 подставляет номер. Так из api-01.prod получается логическая роль api-nginx-01. В логе теперь видно не мусорный 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;
Логика проброса: бэкенду уходит сквозной $trace_request_id (а не локальный!) - чтобы приложение писало в свои логи тот же id, что и весь периметр. X-Request-Chain несёт накопленный маршрут. X-Prev-Request-ID отдаёт локальный id именно этого прокси - если следующий узел тоже nginx, для него мы становимся "предыдущим хопом". Так корреляция логов прокси и приложения становится тривиальной: в приложении логируешь входящий X-Request-ID, и он совпадает с $trace_request_id в access-логе nginx.

Вторая половина - записать всё в свой структурированный лог. В эталоне 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"'
  '}';
escape=json критичен: он экранирует кавычки и спецсимволы внутри значений, иначе один кривой URI с кавычкой ломает тебе всю JSON-строку и парсер в Loki/ELK захлёбывается. Теперь грепаешь по trace_request_id - и собираешь всю историю запроса по всем узлам из их JSON-логов. Дёшево, надёжно, работает на ванильном nginx без единого модуля.

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)
trace-id един на весь распределённый запрос (аналог нашего $trace_request_id), span-id уникален для каждого участка, flags=01 значит "трасса сэмплируется". Любой современный APM (Jaeger, Tempo, Datadog, языковые SDK) понимает этот формат из коробки. Поэтому при проксировании на трассируемый бэкенд правильно пробрасывать стандартные заголовки, а не только свои:

Код: Выделить всё

proxy_set_header traceparent  $http_traceparent;
proxy_set_header tracestate   $http_tracestate;
Но честно: руками склеивать корректный traceparent в nginx (с правильной генерацией span-id на каждый хоп) - мучение. Для этого и существует следующий уровень - ngx_otel_module, который делает W3C-распространение и генерацию спанов автоматически.

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-контексте описываем экспортёр и базовые настройки. Это рабочий блок, который заводится с OpenTelemetry Collector / Jaeger / Tempo:

Код: Выделить всё

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;
        }
    }
}
Ключевая директива для понимания - otel_trace_context, у неё четыре режима, и путать их - классическая ошибка:
  • ignore - входящий traceparent игнорируется, новый не вставляется. Трассировки фактически нет.
  • extract - читаем входящий контекст (привязываемся к чужому трейсу), но дальше НЕ пробрасываем. Read-only.
  • inject - игнорируем входящее, генерим новый корень трассы и вставляем его дальше. Так настраивают самый первый (edge) узел, на который приходят клиенты без traceparent.
  • propagate - читаем входящий контекст И пробрасываем дочерний дальше. Это для внутренних прокси посреди цепочки. Самый частый режим.
Правило большого пальца: edge-узел - inject (он рождает трассу), все внутренние - propagate (продолжают). Тогда в Jaeger ты увидишь единое дерево: span от edge-nginx -> span от api-gateway -> span от приложения -> span от БД, с честными таймингами на каждом уровне.

Заметь красивую деталь: 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]
Если ты на Angie - там OpenTelemetry тоже есть нативно, со схожими директивами семейства otel_*, плюс Angie даёт бесплатный REST API /status и Prometheus-метрики, так что у тебя метрики и трейсы из одного шлюза без Plus-подписки. Связка "структурированные JSON-логи (trace_request_id) + спаны OTel (с тем же id в атрибуте) + метрики" - это и есть три столпа observability, собранные прямо на шлюзе.

Типичные грабли
  • Доверять 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 родился сам:

    Код: Выделить всё

    curl -s -D- http://localhost/api/ping -o /dev/null
    Посмотри в access.log - trace_request_id должен быть непустым 32-символьным.
  • Шаг 4. Теперь пришли свой id и проверь, что он наследуется:

    Код: Выделить всё

    curl -s -H "X-Request-ID: deadbeef-test-0001" http://localhost/api/ping
    В логе trace_request_id обязан быть ровно deadbeef-test-0001, а local_request_id - своим сгенерённым.
  • Шаг 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 - и у тебя метрики, логи и трейсы сходятся на одном идентификаторе. Запрос больше нигде не пропадёт.
👍5 ❤️3 🔥2 😄 🤔
Аватара пользователя
lisa121
Сообщения: 1
Зарегистрирован: 31 май 2026, 11:07

Re: Сквозная трассировка запросов и OpenTelemetry

Сообщение lisa121 »

Дошло наконец зачем два id. $trace_request_id это сквозная нитка через все nginx, а $local_request_id это отпечаток конкретного узла. Бэкенду уходит именно сквозной, поэтому грепом по нему собираешь всё. Раньше тупо слал $request_id и удивлялся что на каждом хопе он разный.
👍2 ❤️1 🔥 😄 🤔1
Аватара пользователя
kamoses
Сообщения: 1
Зарегистрирован: 14 май 2026, 13:43

Re: Сквозная трассировка запросов и OpenTelemetry

Сообщение kamoses »

Подтверждаю про порт - сидел час, otel_trace on, а в Jaeger пусто. Оказалось воткнул 4318 в otel_exporter по привычке от HTTP-экспортёра. Поменял на 4317 и спаны полетели. И да, никакой ошибки в error.log, просто тишина, как и написано в граблях.
👍2 ❤️ 🔥1 😄 🤔
Ответить
← Предыдущая глава
Логирование: access_log, форматы и структурированный JSON
Следующая глава →
Метрики и мониторинг Nginx

Все главы курса «Nginx профессионально: от первого конфига до API-gateway на OpenResty и Angie»

Поделиться темой: ✈ Telegram VK
Похожие запросы: Мониторинг и метрики nginxЛоги nginx: access_log и JSON

Вернуться в «Nginx профессионально: от первого конфига до API-gateway на OpenResty и Angie»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 1 гость