一个请求变慢时,最痛苦的问题不是「哪个服务慢」,而是「这个请求到底经过了哪些服务」。当系统里有网关、多个微服务、消息队列和数据库时,日志是分散的,指标是聚合的,只有链路追踪能把一次请求的完整路径拼出来。而这条链路的起点,恰恰是最容易被漏掉的一跳——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 落到日志里,整条链路就能真正串起来。至此,从认证、路由、上游治理、灰度、地域策略到可观测性,接入层的六个关键能力已经完整覆盖。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。