线上排查一个诡异的业务 Bug,链路追踪查到 HTTP 接口服务就断了。排查了半天,才发现罪魁祸首是接口里丢进 Celery 异步队列的一个后台任务。由于 Trace Context 没有自动透传给 Celery 消费者,分布式链路追踪的 Trace ID 在入队的那一刻直接“石沉大海”。每次遇到这种异步队列报错,只能靠关键词去 Kibana 里大海捞针式地人肉匹配日志,心智负担极重。
Celery 官方的 opentelemetry-instrumentation-celery 插桩库在面对跨服务的复杂链路时,经常因为上下文传播器(Carrier)配置不正确而失效。为了彻底解决这个问题,干脆手动实现 Celery 信号(signals)注入与提取 Trace Context 的逻辑。
解决这个大坑的核心在于:在 Celery 任务发布(before_task_publish)时,将当前的 Trace Context 注入到 Celery 消息的 headers 中;然后在 Celery 任务执行前(task_prerun),从 headers 中提取上下文,并将其作为当前线程的父上下文激活。
下面是经过线上验证的完整初始化与注入代码:
import logging
from celery.signals import before_task_publish, task_prerun, task_postrun
from opentelemetry import trace
from opentelemetry.context import attach, detach
from opentelemetry.propagate import inject, extract
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
logger = logging.getLogger(__name__)
def init_tracer(service_name: str, endpoint: str):
resource = Resource.create(attributes={"service.name": service_name})
provider = TracerProvider(resource=resource)
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint, insecure=True))
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
@before_task_publish.connect
def on_task_publish(headers=None, body=None, sender=None, **kwargs):
if headers is None:
return
# 提取当前活跃的 Trace 上下文,注入到 Celery 消息 headers 中
inject(headers)
logger.debug("Successfully injected trace context into Celery headers: %s", headers)
@task_prerun.connect
def on_task_prerun(task_id=None, task=None, *args, **kwargs):
# Celery 任务执行前,尝试从 request headers 中提取 Trace 上下文
request = task.request
headers = getattr(request, "headers", None) or {}
ctx = extract(headers)
# 激活提取出的上下文,并将 token 保存到任务实例中,以便后续释放
token = attach(ctx)
setattr(task, "__trace_token", token)
# 启动当前任务的 Span
tracer = trace.get_tracer("celery-worker")
span = tracer.start_span(
name=task.name,
context=ctx,
attributes={
"messaging.system": "celery",
"messaging.destination": task.queue or "default",
"messaging.celery.task_id": task_id
}
)
setattr(task, "__trace_span", span)
@task_postrun.connect
def on_task_postrun(task_id=None, task=None, retval=None, state=None, **kwargs):
# 任务执行结束,关闭 Span
span = getattr(task, "__trace_span", None)
if span:
if state == "FAILURE":
span.set_status(trace.StatusCode.ERROR)
if isinstance(retval, Exception):
span.record_exception(retval)
else:
span.set_attribute("error.message", str(retval))
else:
span.set_status(trace.StatusCode.OK)
span.end()
# 恢复原有的上下文
token = getattr(task, "__trace_token", None)
if token:
detach(token)
折腾完这套逻辑后,我们用 Jaeger 观察调用链,发现 HTTP 接口、Celery 生产者和 Celery 消费者的 Span 终于完美连成了一条线。不仅能清晰看到任务在队列里积压了多久(通过对比入队 Span 和执行 Span 的时间戳),还能一眼看清哪个下游 gRPC 服务挂了。
如果你们也遇到类似的跨服务异步追踪丢失问题,建议直接把这套信号监听器贴进项目的 celery.py 初始化代码里。不过需要特别注意:在生产高并发场景下,默认的 OTLP gRPC Exporter 可能会引起网络阻塞,建议把队列大小和批量发送超时调大,或者干脆本地部署 Otelemetry Collector 用 UDP 转发。
大家在用 OpenTelemetry 做异步任务追踪时,还踩过哪些比较恶心的深坑?