我们如何使用 OpenTelemetry 来修复 OpenTelemetry 中的一个 Bug

博客文章在发布后不会更新。这篇文章已经发布一年多了,其内容可能已过时,部分链接可能无效。在依赖任何信息之前,请务必核实。

OpenTelemetry 可以帮助我们快速找到软件问题的根本原因。我们最近遇到一个问题,通过使用 OpenTelemetry 的一个功能来识别另一个功能中的 bug 的根本原因,从而解决了这个问题。

在这篇博文中,我们想与您分享这次有趣的经历。通过它,您将了解到特定语言的实现中微小的差异可能带来有趣的后果,并且您拥有 Java 和 Python 的一个功能,可以帮助您调试上下文传播问题。

问题

描述 bug

为了撰写关于 如何使用 OpenTelemetry instrument NGINX 的博文,我们创建了一个小型示例应用程序,它有一个 Node.js 前端应用程序,该应用程序调用一个 NGINX,NGINX 作为 Python 后端应用程序的反向代理。

我们的目标是创建一个可重用的 docker-compose,它不仅向人们展示如何使用 OpenTelemetry instrument NGINX,还展示跨 Web 服务器的分布式跟踪是什么样的。

虽然 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 请求和响应头 作为 span 属性。

通过环境变量 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 到后端的已收集请求头。

A screenshot of the Jaeger UI showing that http.request.header.traceparent has multiple entries.

就在这里!跟踪头 (baggage, traceparent, tracestate) 以多个头字段的形式发送:NGINX 模块一遍又一遍地添加了每个头的值,并且由于多值头受到 RFC7230 的支持,这并没有立即导致问题。

我们测试了从 NGINX 到下游服务的关联能力,该服务使用的是 Java 应用程序。而且,在不深入研究 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 已弃用。有关详细信息,请参阅 迁移到 Compose V2。 ↩︎ ↩︎