如何使用 OpenTelemetry 修复 OpenTelemetry 中的一个 bug

OpenTelemetry 可以帮助我们快速找到软件中的问题根源。最近我们遇到了一个问题,通过使用 OpenTelemetry 的一个功能,我们成功解决了另一个功能中的 bug。

在本博客文章中,我们想要与您分享这个有趣的经历。通过这个经历,您将了解到语言特定实现中的细微差异可能会产生有趣的影响,以及如何使用 Java 和 Python 的功能来帮助您调试上下文传播问题。

问题

描述该 bug

在博客文章 学习如何使用 OpenTelemetry 为 NGINX 加入监控 中,我们创建了一个小型示例应用程序,其中有一个基于 Node.js 的前端应用程序,调用了充当反向代理的 NGINX,用于后端的 Python 应用程序。

我们的目标是创建一个可重用的 docker-compose,不仅展示人们如何使用 OpenTelemetry 为 NGINX 加入监控,还展示跨网络服务器的分布式跟踪的情况。

Jaeger 在前端应用程序到 NGINX 的跟踪中显示出了一条流转迹线,但 NGINX 到 Python 应用程序的连接却不可见:我们有两个不相连的跟踪。

这让人感到惊讶,因为我们先前测试时,在将 Java 应用程序作为后端时,可以看到从 NGINX 到下游应用程序的跟踪。

复现步骤

按照将 NGINX 放置在两个服务之间的说明进行操作。将基于 Java 的应用程序替换为一个 Python 应用程序,例如将以下三个文件放置在 backend 文件夹中:

  • app.py:

    import time
    
    import redis
    from flask import Flask
    
    app = Flask(__name__)
    cache = redis.Redis(host='redis', port=6379)
    
    def get_hit_count():
      retries = 5
      while True:
          try:
              return cache.incr('hits')
          except redis.exceptions.ConnectionError as exc:
              if retries == 0:
                  raise exc
              retries -= 1
              time.sleep(0.5)
    
    @app.route('/')
    def hello():
      count = get_hit_count()
      return 'Hello World! I have been seen {} times.\n'.format(count)
    
  • Dockerfile:

    FROM python:3.10-alpine
    WORKDIR /code
    ENV FLASK_APP=app.py
    ENV FLASK_RUN_HOST=0.0.0.0
    RUN apk add --no-cache gcc musl-dev linux-headers
    COPY requirements.txt requirements.txt
    RUN pip install -r requirements.txt
    RUN opentelemetry-bootstrap -a install
    EXPOSE 5000
    COPY . .
    CMD ["opentelemetry-instrument", "--traces_exporter", "otlp_proto_http", "--metrics_exporter", "console", "flask", "run"]
    
  • requirements.txt:

    flask
    redis
    opentelemetry-distro
    opentelemetry-exporter-otlp-proto-http
    

通过以下内容更新 docker-compose.yml 文件:

version: '2'
services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - '16686:16686'
  collector:
    image: otel/opentelemetry-collector:latest
    command: ['--config=/etc/otel-collector-config.yaml']
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
  nginx:
    image: nginx-otel
    volumes:
      - ./opentelemetry_module.conf:/etc/nginx/conf.d/opentelemetry_module.conf
      - ./default.conf:/etc/nginx/conf.d/default.conf
  backend:
    build: ./backend
    image: backend-with-otel
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318/v1/traces
      - OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
      - OTEL_SERVICE_NAME=python-app
  redis:
    image: 'redis:alpine'
  frontend:
    build: ./frontend
    image: frontend-with-otel
    ports:
      - '8000:8000'
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318/
      - OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
      - OTEL_SERVICE_NAME=frontend

通过运行 docker compose up1 来启动该环境,然后使用 curl localhost:8000 发送一些请求给前端应用程序。

您期望看到什么?

localhost:16686 的 Jaeger UI 中,您期望看到从 frontend 经过 NGINX 到 python-app 的跟踪。

您实际上看到了什么?

localhost:16686 的 Jaeger UI 中,您将看到两条跟踪,一条从 frontend 经过 NGINX 到达,另一条仅针对 python-app

解决方案

线索

由于在后端使用 Java 应用程序时,设置正常工作,我们知道问题要么是由 Python 应用程序引起的,要么是由 NGINX 工具库和 Python 应用程序的组合引起的。

我们可以迅速排除 Python 应用程序本身是问题的原因:我们尝试使用一个简单的 Node.js 应用程序作为后端,结果得到了相同的结果:两条跟踪,一条从前端到 NGINX,另一条仅针对 Node.js 应用程序本身。

因此,我们知道存在传播问题:跟踪上下文没有成功从 NGINX 传递到 Python 和 Node.js 应用程序。

分析

知道问题在 Java 中没有出现,并且很可能是传播机制不完整,我们知道我们需要做什么:我们需要查看跟踪头。

值得庆幸的是,针对 JavaPython 的工具库具有一个功能,可以轻松地捕获 HTTP 请求和响应头作为跟踪的属性。

通过通过环境变量 OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUESTOTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE 提供一个逗号分隔的 HTTP 头名称列表,我们可以定义要捕获的 HTTP 头。在我们的情况下,我们将包含所有可能的传播头:

OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST=tracestate,traceparent,baggage,X-B3-TraceId

在我们基于 docker-compose 的示例中,我们只需将其添加到后端服务的定义中即可:

backend:
  build: ./backend
  image: backend-with-otel
  environment:
    - OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318/v1/traces
    - OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
    - OTEL_SERVICE_NAME=python-app
    - OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST=tracestate,traceparent,baggage,X-B3-TraceId

再次运行 docker compose up1 启动我们的示例应用程序,并使用 curl localhost:8080 向前端应用程序发送一些请求。

在 Jaeger 中,我们仍然看到跟踪是不连续的。然而,在查看其中一条跟踪时,我们可以看到从 NGINX 到后端收集到的请求头:

Jaeger UI 的截图显示 http.request.header.traceparent 有多个条目。

看到了吧!跟踪头 (baggage, traceparent, tracestate) 作为多个报头字段发送:NGINX 模块多次添加了每个报头的值,因为多值报头由 RFC7230 支持,所以这并没有立即引起问题。

我们通过测试了从 NGINX 到下游服务的关联能力,并且不需要深入研究 OTel Java SDK 的源代码,看起来 Java 可以灵活地接收具有多个值的 traceparent,尽管这种格式在 W3C Trace Context 规范中是无效的。所以从 NGINX 到 Java 服务的传播是有效的,而相比之下,Python(和其他语言)没有提供这种灵活性,因此从 NGINX 到下游服务的传播会默默失败。

请注意,我们并不是建议其他语言应该和 Java 具有相同的灵活性,即可以读取多个值的 traceparent 或者反过来:该 bug 存在于 NGINX 模块中,我们需要修复它。

修复

为了解决问题,我们向 NGINX 模块添加了一些检查, 确保跟踪头仅设置一次。

这个修复已包含在 otel-webserver-module 的 v1.0.1 版本中. 这意味着您可以更新 Dockerfile 来安装 NGINX 模块,例如:

FROM nginx:1.18
ADD https://github.com/open-telemetry/opentelemetry-cpp-contrib/releases/download/webserver%2Fv1.0.1/opentelemetry-webserver-sdk-x64-linux.tgz /opt
RUN cd /opt ; tar xvfz opentelemetry-webserver-sdk-x64-linux.tgz
RUN cd /opt/opentelemetry-webserver-sdk; ./install.sh
ENV LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/opt/opentelemetry-webserver-sdk/sdk_lib/lib
RUN echo "load_module /opt/opentelemetry-webserver-sdk/WebServerModule/Nginx/ngx_http_opentelemetry_module.so;\n$(cat /etc/nginx/nginx.conf)" > /etc/nginx/nginx.conf
COPY default.conf /etc/nginx/conf.d
COPY opentelemetry_module.conf /etc/nginx/conf.d

  1. docker-compose is deprecated. For details, see Migrate to Compose V2↩︎ ↩︎