线上排查一个诡异的业务 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 loggingfrom celery.signals import before_task_publish, task_prerun, task_postrunfrom opentelemetry import tracefrom opentelemetry.context import attach, detachfrom opentelemetry.propagate import inject, extractfrom opentelemetry.sdk.trace import TracerProviderfrom opentelemetry.sdk.trace.export import BatchSpanProcessorfrom opentelemetry.sdk.resources import Resourcefrom 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.connectdef 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.connectdef 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.connectdef 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 做异步任务追踪时,还踩过哪些比较恶心的深坑?