Nginx OpenTelemetry 链路追踪:上下文透传、ngx_otel_module 与采样

把 Nginx 接入 OpenTelemetry 链路追踪,覆盖 trace 上下文透传规范、ngx_otel_module 配置、采样策略与后端服务对接要点。

一个请求变慢时,最痛苦的问题不是「哪个服务慢」,而是「这个请求到底经过了哪些服务」。当系统里有网关、多个微服务、消息队列和数据库时,日志是分散的,指标是聚合的,只有链路追踪能把一次请求的完整路径拼出来。而这条链路的起点,恰恰是最容易被漏掉的一跳——Nginx。如果接入层不生成 span、不传播上下文,整条链路就会断在门口,后端服务只能各自为战。本文讲清如何让 Nginx 成为链路的正确起点:上下文怎么传、模块怎么配、采样怎么定、后端怎么接。

1. 为什么入口需要链路追踪

一句话总结: 接入层是链路的根节点,缺少它的 span,后端所有追踪都只能看到局部,无法回答端到端延迟来自哪一段。

一次请求在 Nginx 侧消耗的时间由几部分组成:等待客户端发送请求体、排队等待 worker、与上游建连、等待上游响应、向客户端发送响应。这些时间在后端服务的 span 里是看不到的,但它们往往是延迟的主要来源。

时间构成对应变量是否被后端感知
客户端到 Nginx$request_time 与上游时间之差否
Nginx 到上游建连$upstream_connect_time否
上游处理$upstream_response_time部分
Nginx 回传客户端$request_time 减去上游时间否

没有入口 span,这些分段就只能靠日志近似推断;有了入口 span,它们会作为属性挂在同一个 trace 上,直接和下游的 span 对齐。

# 先把这些变量记进日志,作为链路追踪的对照基准
log_format trace_ready '$remote_addr "$request" $status '
                       'rt=$request_time '
                       'urt=$upstream_response_time '
                       'uct=$upstream_connect_time '
                       'trace=$otel_trace_id '
                       'span=$otel_span_id';
access_log /var/log/nginx/access.log trace_ready;

$otel_trace_id 与 $otel_span_id 由 ngx_otel_module 提供。把它们写进访问日志,就等于给每条日志打上了 trace 标识,排查时可以用 trace ID 直接关联日志与链路。

2. Trace 上下文传播规范

一句话总结: W3C Trace Context 用 traceparent 头承载 trace-id、span-id 与采样标志,接入层必须生成或透传它,并保证大小写与格式正确。

OpenTelemetry 默认使用 W3C Trace Context 规范,核心是 traceparent 请求头,格式为四段用连字符分隔:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  │                                │                │
             │  │                                │                └─ flags(01 表示已采样)
             │  │                                └─ parent-id(16 位十六进制,即当前 span)
             │  └─ trace-id(32 位十六进制,全链路唯一)
             └─ version(当前固定 00)

接入层的行为取决于上游是否已经生成了上下文:

  • 外部用户直接访问:没有 traceparent,Nginx 应生成新的 trace-id 与 span-id
  • 上游服务调用:已有 traceparent,Nginx 应继承 trace-id 并生成新的 span-id
  • 网关转发给后端:Nginx 把 traceparent 透传给上游,让下游继续这条链路
# ngx_otel_module 会自动处理上述三种情况:
#   - 请求头存在 traceparent 时继承 trace-id
#   - 不存在时生成新的 trace-id
#   - 转发给上游时自动注入新的 traceparent
location /api/ {
    otel_trace on;
    otel_trace_context inject;     # 向上游注入上下文
    proxy_pass http://backend;
}

除 traceparent 外,还有两个常被忽略的头:tracestate(厂商自定义状态,必须原样透传)与 baggage(业务自定义的键值对,如租户 ID、灰度标记)。

# 透传 baggage 需要显式允许,默认不转发
location /api/ {
    otel_trace on;
    otel_trace_context inject;
    proxy_set_header traceparent $otel_traceparent;
    proxy_set_header tracestate  $http_tracestate;
    proxy_set_header baggage     $http_baggage;
    proxy_pass http://backend;
}

tracestate 必须原样透传,不能重写。 它记录了上游采样器的决策上下文,丢失它会导致下游重复采样或采样不一致。同理,baggage 若被丢弃,下游就无法拿到租户、灰度等业务维度,追踪数据会失去分析价值。

3. ngx_otel_module 配置

一句话总结: ngx_otel_module 是官方 OpenTelemetry 模块,需编译加载后配置 exporter 与采样,并在需要的 location 中开启 otel_trace。

该模块由 Nginx 官方维护(nginx/nginx-opentelemetry-module),需要 OpenTelemetry C SDK 作为依赖。

# 编译安装 ngx_otel_module
git clone --depth=1 https://github.com/nginx/nginx-opentelemetry-module.git
git clone --depth=1 --recursive https://github.com/open-telemetry/opentelemetry-cpp.git

cd opentelemetry-cpp && mkdir build && cd build
cmake .. -DBUILD_SHARED_LIBS=ON -DWITH_OTLP_GRPC=ON -DWITH_OTLP_HTTP=ON
make -j"$(nproc)" && make install
# 重新编译 Nginx 时加入该模块
./configure --add-dynamic-module=/path/to/nginx-opentelemetry-module \
            --with-compat
make modules

配置分三层:全局 exporter、server 级采样、location 级开关。

load_module modules/ngx_otel_module.so;
http {
    # 全局:OTLP 导出端点与导出间隔
    otel_exporter {
        endpoint otel-collector.internal:4317;
        interval 5s;
        batch_size 512;
        batch_count 4;
    }
    # 全局默认采样率
    otel_trace on;
    otel_trace_context propagate;
    otel_service_name "nginx-edge";
    otel_resource_attr "deployment.environment" "production";
    otel_resource_attr "service.version" "1.25.4";
    server {
        listen 443 ssl;
        server_name api.example.com;

        # 高频健康检查不追踪,避免污染数据
        location = /healthz {
            otel_trace off;
            access_log off;
            return 200 "ok\n";
        }

        location /api/ {
            otel_trace on;
            otel_trace_context inject;
            # 为该 location 单独设置采样率
            otel_trace_sample_ratio 0.1;
            proxy_pass http://backend;
        }
    }
}

otel_trace_context 有三个取值,语义必须分清:

取值行为适用场景
inject生成或继承上下文并注入上游入口网关,需要把链路传给后端
extract只从请求头提取,不注入中间层,只读取不修改
propagate同时提取与注入通用网关
ignore完全忽略上下文无需追踪的路径

otel_trace_sample_ratio 是概率采样,不是精确比例。 设为 0.1 表示约 10% 的请求被采样,实际比例会随流量波动。需要精确比例时应当用尾部采样或基于 trace-id 的一致性采样。

4. 采样策略

一句话总结: 采样必须在链路入口统一决定,头部采样简单但会漏掉错误请求,尾部采样能保错误但需要额外组件。

采样决定了「哪些请求被完整记录」。采太多成本高,采太少丢失关键信息。三种策略各有取舍。

策略一:头部概率采样。 在 Nginx 按比例决定,实现简单,但可能漏掉所有错误请求。

# 按比例采样:10% 的请求被完整记录
location /api/ {
    otel_trace on;
    otel_trace_context inject;
    otel_trace_sample_ratio 0.1;
    proxy_pass http://backend;
}

策略二:按路径差异化采样。 核心接口全采,非核心接口低采。

# 支付等核心链路全量采样,浏览类接口低采样
location /api/payment/ {
    otel_trace on;
    otel_trace_context inject;
    otel_trace_sample_ratio 1.0;        # 全采
    proxy_pass http://payment_backend;
}

location /api/catalog/ {
    otel_trace on;
    otel_trace_context inject;
    otel_trace_sample_ratio 0.01;       # 低采
    proxy_pass http://catalog_backend;
}

策略三:尾部采样。 先全量收集,在 Collector 侧根据结果决定是否保留,能保证「错误请求必留」。这是生产环境最推荐的方式,但需要 Collector 承担缓冲压力。

# otel-collector 的尾部采样配置
processors:
  tail_sampling:
    decision_wait: 10s
    num_traces: 100000
    policies:
      - name: keep-all-errors
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: keep-slow-requests
        type: latency
        latency: {threshold_ms: 500}
      - name: sample-the-rest
        type: probabilistic
        probabilistic: {sampling_percentage: 5}

采样标志必须沿链路传递。 W3C 规范用 traceparent 最后一段的 01 表示已采样。Nginx 决定采样后,这个标志会随 traceparent 传给后端,后端必须遵守它,不能自行再采一次。否则会出现「Nginx 决定记录、后端丢弃」的断裂链路。

# 在日志中记录采样决策,便于核对采样比例是否符合预期
map $otel_trace_id $sampled {
    default   1;
    ""        0;          # 未生成 trace 说明未被采样
}

5. 与后端服务对接

一句话总结: 后端必须从 traceparent 续接链路而不是另起一条,同时把 Nginx 注入的业务头与 trace 关联起来。

后端服务接入 OpenTelemetry 时,最容易犯的错误是「自己生成 trace-id」而不是「续接传入的上下文」。标准 SDK 默认会自动提取 traceparent,但前提是框架的 HTTP 中间件被正确注册。

# Python 示例:自动提取 traceparent 并续接链路
from opentelemetry import trace
from opentelemetry.propagate import extract
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(
    OTLPSpanExporter(endpoint="http://otel-collector.internal:4317", insecure=True)
))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("order-service")
def handle_request(headers, path):
    # 关键:从请求头提取上下文,续接 Nginx 的 trace
    ctx = extract(headers)
    with tracer.start_as_current_span(f"handle {path}", context=ctx) as span:
        span.set_attribute("http.route", path)
        return process()
// Go 示例:用 otelhttp 中间件自动续接
import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

func main() {
    handler := otelhttp.NewHandler(http.HandlerFunc(orderHandler), "order-api")
    // otelhttp 自动 extract traceparent,并在响应时 inject
    http.Handle("/api/orders/", handler)
    http.ListenAndServe(":8080", nil)
}

把 Nginx 注入的身份与地域信息也挂到 span 上,可以让追踪数据具备业务维度:

# 把 Nginx 注入的头变成 span 属性
span.set_attribute("enduser.id", headers.get("X-Auth-User", "anonymous"))
span.set_attribute("geo.country", headers.get("X-Geo-Country", "UNKNOWN"))
span.set_attribute("http.request_id", headers.get("X-Request-Id", ""))

需要注意不要把敏感信息写进 span 属性。身份 ID 可以记录,令牌、密码、身份证号绝不能进追踪数据,因为追踪后端通常有更宽的访问权限与更长的保留期。

6. 日志与指标关联

一句话总结: 把 trace_id 同时写入访问日志与应用日志,就能在日志系统里按 trace 聚合出完整链路,指标则用来发现异常再下钻到 trace。

三者的分工是:指标发现异常、链路定位慢点、日志看清细节。把它们串起来的钥匙就是 trace_id。

# Nginx 访问日志带上 trace_id 与 span_id
log_format otel '$remote_addr - $remote_user [$time_local] '
                '"$request" $status $body_bytes_sent '
                'rt=$request_time urt=$upstream_response_time '
                'trace_id=$otel_trace_id span_id=$otel_span_id '
                'upstream=$upstream_addr';
access_log /var/log/nginx/access.log otel;

后端在日志中输出同一个 trace_id(OpenTelemetry 的日志桥接会自动注入):

{
  "timestamp": "2026-10-01T21:03:11.482Z",
  "level": "ERROR",
  "message": "payment gateway timeout",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "service": "order-service"
}
# 用 trace_id 把 Nginx 日志与后端日志串起来
TRACE=4bf92f3577b34da6a3ce929d0e0e4736
grep "$TRACE" /var/log/nginx/access.log
grep "$TRACE" /var/log/app/order-service.log
# 两份日志的时间线拼起来,就是这次请求的完整经过

指标侧则用于发现「哪一类请求变慢了」:

# 从访问日志统计各路径的 P95 延迟,定位需要下钻的接口
awk '{print $7, $NF}' /var/log/nginx/access.log \
  | grep 'rt=' | sort | head
# 更完整的做法是接入 Prometheus,用 histogram 统计分位延迟

7. 性能与排错

一句话总结: 追踪的开销主要来自上下文注入与批量导出,采样率是最有效的控制手段;排错时先确认上下文是否真的传到了下游。

ngx_otel_module 的开销集中在三处:解析与生成上下文、在共享内存中维护导出队列、定期批量上报。实测中,开启追踪后单请求延迟增加通常在几十微秒量级,远小于上游处理时间。

# 用批量导出降低上报频率,避免每请求一次网络往返
otel_exporter {
    endpoint otel-collector.internal:4317;
    interval 5s;          # 每 5 秒批量上报一次
    batch_size 512;       # 每批最多 512 个 span
    batch_count 4;        # 最多缓冲 4 批,超出后丢弃
}

batch_count 决定了内存上限。 当 Collector 不可用时,队列会堆满,之后新的 span 被丢弃——这是有意的保护,避免追踪拖垮主服务。生产环境应监控丢弃计数。

排错的四步法:

第一步:确认 Nginx 是否生成了 trace。

# 检查响应头与日志中的 trace 标识
curl -sI https://api.example.com/api/ping | grep -i trace
grep -o 'trace_id=[0-9a-f]*' /var/log/nginx/access.log | tail -3

第二步:确认上下文是否传给了后端。 在后端打印收到的 traceparent:

# 用 tcpdump 抓包确认 traceparent 头确实发出去了
sudo tcpdump -A -s 0 'tcp port 8080' -c 1 | grep -i traceparent

第三步:确认后端是否续接而非另起链路。 若后端 span 的 trace_id 与 Nginx 不一致,说明后端没有 extract 上下文,检查中间件注册顺序。

第四步:确认 Collector 是否收到。 查看 Collector 的接收与导出指标:

# 查看 collector 是否收到 span
curl -s http://otel-collector.internal:8888/metrics \
  | grep -E 'otelcol_receiver_accepted_spans|otelcol_exporter_sent_spans'

常见陷阱清单:

陷阱一:健康检查污染数据。 探针每 5 秒一次,采样后仍然占据大量 trace 名额。必须 otel_trace off。

陷阱二:采样标志丢失。 中间件重写了 traceparent 但没保留 flags,导致下游以为未采样而丢弃。

陷阱三:tracestate 被丢弃。 反向代理默认不透传自定义头,需要显式 proxy_set_header。

陷阱四:OTLP 端点不可达导致阻塞。 使用 gRPC 且未设置超时时,导出失败可能拖慢 worker。务必设置合理的 interval 与 batch_count。

陷阱五:把追踪当成审计日志。 采样意味着数据不完整,不能用于计费、审计等要求完整的场景。

8. 总结

环节要点
入口价值接入层是链路根节点,缺它则端到端延迟无法归因
上下文规范W3C traceparent 四段结构,tracestate 与 baggage 必须透传
模块配置三层配置:全局 exporter、server 采样、location 开关
采样策略头部采样简单、尾部采样保错误,采样标志必须沿链路传递
后端对接必须 extract 续接而非新建链路,业务维度挂到 span 属性
日志关联trace_id 同时写进访问日志与应用日志,按 trace 聚合
性能开销在注入与导出,batch_count 决定内存上限与丢弃行为
排错四步确认:生成、传递、续接、Collector 收到

链路追踪不是加一个模块就完事,它要求从接入层到最底层服务都遵守同一套上下文规范。Nginx 作为链路起点,只要正确生成与传播上下文、合理采样、并把 trace_id 落到日志里,整条链路就能真正串起来。至此,从认证、路由、上游治理、灰度、地域策略到可观测性,接入层的六个关键能力已经完整覆盖。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. Nginx 在服务网格中的角色:边车代理、mTLS 与 Envoy 取舍
  2. Nginx 大文件上传与请求体处理:缓冲、临时文件与断点续传
  3. Nginx 证书自动化与 ACME:certbot、DNS-01 通配符与自动续期