PHP应用的可观测性(Observability):集成OpenTelemetry实现全链路追踪

PHP应用的可观测性:集成OpenTelemetry实现全链路追踪

大家好!今天我们来聊聊PHP应用的可观测性,以及如何利用OpenTelemetry来实现全链路追踪。在微服务架构日益普及的今天,一个请求往往需要经过多个服务才能完成,这使得问题排查变得异常困难。传统日志分析虽然有用,但在复杂系统中显得力不从心。可观测性,特别是全链路追踪,为我们提供了更深入的视角,帮助我们理解系统行为,快速定位问题,并优化性能。

为什么需要可观测性?

在传统的监控模式下,我们通常关注的是CPU利用率、内存占用、磁盘IO等指标。然而,这些指标只能告诉我们系统是否“健康”,而无法解释“为什么”。当系统出现问题时,我们往往需要花费大量时间在日志中大海捞针,才能找到问题的根源。

可观测性通过提供三个核心支柱——指标(Metrics)、日志(Logs)和追踪(Traces)——让我们能够深入了解系统内部状态,回答以下问题:

  • 系统发生了什么?(指标)
  • 为什么会发生?(日志)
  • 请求经过了哪些服务?耗时多久?(追踪)

其中,全链路追踪是可观测性的关键组成部分,它能够帮助我们追踪请求在各个服务之间的流转路径,分析请求的性能瓶颈,并快速定位故障点。

OpenTelemetry简介

OpenTelemetry (OTel) 是一个云原生计算基金会(CNCF)的项目,旨在提供一套标准化的API、SDK和工具,用于生成、收集和导出遥测数据(metrics, logs, traces)。它的目标是成为遥测数据的行业标准,解决不同厂商遥测方案互不兼容的问题。

OpenTelemetry 的主要优点包括:

  • 厂商无关性: 应用程序代码无需依赖特定的遥测后端,可以轻松切换不同的后端服务。
  • 标准化: 提供统一的API和数据格式,简化了遥测数据的集成和分析。
  • 可扩展性: 支持自定义的采样策略、数据处理和导出方式。
  • 社区驱动: 拥有庞大而活跃的社区,不断推进项目的发展。

在PHP应用中集成OpenTelemetry

下面我们来演示如何在PHP应用中集成OpenTelemetry,实现全链路追踪。我们将使用一个简单的示例应用,包含两个服务:frontendbackendfrontend 服务接收用户请求,并将请求转发给 backend 服务处理。

1. 安装OpenTelemetry PHP SDK

首先,我们需要安装 OpenTelemetry PHP SDK。推荐使用 Composer 进行安装:

composer require openTelemetry/sdk
composer require openTelemetry/exporter-otlp
composer require openTelemetry/transport-grpc

这里我们安装了以下几个组件:

  • openTelemetry/sdk: OpenTelemetry PHP SDK的核心组件。
  • openTelemetry/exporter-otlp: OpenTelemetry Protocol (OTLP) exporter,用于将遥测数据导出到支持 OTLP 协议的后端,例如 Jaeger、Zipkin、OpenTelemetry Collector等。
  • openTelemetry/transport-grpc: gRPC传输,用于 OTLP 协议与后端通信。

2. 配置OpenTelemetry SDK

接下来,我们需要配置 OpenTelemetry SDK。创建一个 bootstrap.php 文件,用于初始化 OpenTelemetry SDK:

<?php

require __DIR__ . '/vendor/autoload.php';

use OpenTelemetrySDKTraceTracerProvider;
use OpenTelemetrySDKResourceResourceInfo;
use OpenTelemetrySDKResourceResourceAttributes;
use OpenTelemetrySDKTraceSamplerAlwaysOnSampler;
use OpenTelemetryContribOtlpExporter;
use OpenTelemetryContribOtlpProtocolGrpcExporterFactory;
use OpenTelemetrySDKTraceSpanProcessorSimpleSpanProcessor;
use OpenTelemetryAPIGlobals;

// 服务名称
$serviceName = getenv('SERVICE_NAME') ?: 'unknown-service';

// OTLP exporter endpoint
$otlpEndpoint = getenv('OTLP_ENDPOINT') ?: 'localhost:4317';

// 创建 Resource
$resource = ResourceInfo::create(ResourceAttributes::create([
    'service.name' => $serviceName,
    'telemetry.sdk.language' => 'php',
    'telemetry.sdk.name' => 'opentelemetry',
    'telemetry.sdk.version' => '1.0.0', // Replace with actual version
]));

// 创建 OTLP Exporter
$exporterFactory = new ExporterFactory();
$exporter = $exporterFactory->create('otlp', 'grpc', $otlpEndpoint);

// 创建 Span Processor
$spanProcessor = new SimpleSpanProcessor($exporter);

// 创建 Tracer Provider
$tracerProvider = new TracerProvider(
    $spanProcessor,
    new AlwaysOnSampler(), // 采样器,AlwaysOnSampler 表示始终采样
    $resource
);

// 设置全局 Tracer Provider
Globals::setTracerProvider($tracerProvider);

// 注册 shutdown 函数,确保在脚本结束时导出数据
register_shutdown_function(function () use ($tracerProvider) {
    $tracerProvider->shutdown();
});

return Globals::tracerProvider()->getTracer('my-app-tracer', '1.0.0');

这个文件主要做了以下几件事:

  • 加载 Composer 自动加载器。
  • 定义服务名称,可以从环境变量中获取,默认值为 unknown-service
  • 定义 OTLP exporter 的 endpoint,可以从环境变量中获取,默认值为 localhost:4317
  • 创建 Resource,用于描述服务的信息。
  • 创建 OTLP Exporter,用于将遥测数据导出到 OTLP 后端。
  • 创建 Span Processor,用于处理 Span 数据。
  • 创建 Tracer Provider,用于创建 Tracer。
  • 设置全局 Tracer Provider,方便在应用中使用。
  • 注册 shutdown 函数,确保在脚本结束时导出数据。

3. 修改应用代码

现在,我们需要修改应用代码,添加 OpenTelemetry 的追踪代码。

frontend 服务 (index.php):

<?php

require_once __DIR__ . '/bootstrap.php';

use OpenTelemetryAPIGlobals;
use OpenTelemetryAPITraceSpanKind;
use OpenTelemetryAPITraceStatusCode;

$tracer = Globals::tracerProvider()->getTracer('frontend-tracer', '1.0.0');

$rootSpan = $tracer->spanBuilder('frontend-request')
    ->setSpanKind(SpanKind::KIND_SERVER)
    ->startSpan();

$context = $rootSpan->getContext();
$scope = $rootSpan->activate();

echo "Frontend service processing request...n";

// 调用 backend 服务
$backendUrl = 'http://backend:8081'; // 假设 backend 服务运行在 backend:8081
$backendResponse = callBackendService($backendUrl, $tracer, $context);

echo "Backend service response: " . $backendResponse . "n";

$rootSpan->setStatus(StatusCode::STATUS_OK, 'Request processed successfully');
$rootSpan->end();
$scope->detach();

function callBackendService(string $url, OpenTelemetryAPITraceTracerInterface $tracer, OpenTelemetryContextContextInterface $parentContext): string
{
    $span = $tracer->spanBuilder('call-backend')
        ->setSpanKind(SpanKind::KIND_CLIENT)
        ->setParent($parentContext) // 设置父Span
        ->startSpan();

    $scope = $span->activate();

    try {
        $client = new GuzzleHttpClient();
        $response = $client->get($url, [
            'headers' => [
                'X-Trace-Id' => $span->getContext()->getTraceId(), // 传递 TraceId (可选)
                'X-Span-Id' => $span->getContext()->getSpanId() // 传递 SpanId (可选)
            ]
        ]);

        $body = $response->getBody()->getContents();
        $span->setStatus(StatusCode::STATUS_OK, 'Backend service called successfully');
        $span->end();
        $scope->detach();
        return $body;
    } catch (Exception $e) {
        $span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
        $span->recordException($e);
        $span->end();
        $scope->detach();
        return 'Error calling backend service: ' . $e->getMessage();
    }
}

backend 服务 (index.php):

<?php

require_once __DIR__ . '/bootstrap.php';

use OpenTelemetryAPIGlobals;
use OpenTelemetryAPITraceSpanKind;
use OpenTelemetryAPITraceStatusCode;

$tracer = Globals::tracerProvider()->getTracer('backend-tracer', '1.0.0');

// 从请求头中获取 TraceId 和 SpanId (如果 frontend 服务传递了)
$traceId = $_SERVER['HTTP_X_TRACE_ID'] ?? null;
$spanId = $_SERVER['HTTP_X_SPAN_ID'] ?? null;

$spanBuilder = $tracer->spanBuilder('backend-process');

if ($traceId && $spanId) {
    // 从父 Span 上下文中创建 Span
    $parentContext = OpenTelemetryContextContext::getRoot()->withContextValue(
        new OpenTelemetryAPITracePropagationTraceContextPropagator(),
        new OpenTelemetryContextValueMapContextValue([
            'traceparent' => '00-' . $traceId . '-' . $spanId . '-01' // 构建 traceparent 字符串
        ])
    );
    $spanBuilder->setParent($parentContext);
} else {
    $spanBuilder->setSpanKind(SpanKind::KIND_SERVER);
}

$span = $spanBuilder->startSpan();
$scope = $span->activate();

echo "Backend service processing request...n";

// 模拟一些耗时操作
sleep(1);

$response = "Request processed by backend service.";

$span->setStatus(StatusCode::STATUS_OK, 'Request processed successfully');
$span->end();
$scope->detach();

echo $response;

代码解释:

  • frontendbackend 服务中,我们都初始化了 OpenTelemetry SDK。
  • frontend 服务中,我们创建了一个名为 frontend-request 的 root span,并将其设置为 SERVER 类型的 Span。
  • frontend 服务中,我们调用 backend 服务,并创建了一个名为 call-backend 的 client Span,并将 root Span 的 Context 设置为 call-backend Span 的 Parent。
  • backend 服务中,我们创建了一个名为 backend-process 的 Span,并尝试从请求头中获取 TraceId 和 SpanId,如果存在,则将 backend-process Span 的 Parent 设置为从请求头中获取到的 Context。
  • frontendbackend 服务中,我们都使用 setStatus 方法设置 Span 的状态,并使用 end 方法结束 Span。

4. 运行应用

为了方便演示,我们可以使用 Docker Compose 来运行我们的应用。创建一个 docker-compose.yml 文件:

version: "3.8"

services:
  frontend:
    build:
      context: .
      dockerfile: Dockerfile-frontend
    ports:
      - "8080:80"
    environment:
      SERVICE_NAME: frontend
      OTLP_ENDPOINT: otel-collector:4317
    depends_on:
      - backend
      - otel-collector

  backend:
    build:
      context: .
      dockerfile: Dockerfile-backend
    ports:
      - "8081:80"
    environment:
      SERVICE_NAME: backend
      OTLP_ENDPOINT: otel-collector:4317

  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    ports:
      - "4317:4317" # OTLP gRPC
      - "55680:55680" # zpages
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    depends_on:
      - jaeger

  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686" # Jaeger UI
      - "14268:14268" # Jaeger gRPC

我们需要创建两个 Dockerfile:

Dockerfile-frontend:

FROM php:8.1-apache

WORKDIR /var/www/html

COPY . /var/www/html/

RUN apt-get update && apt-get install -y 
    libzip-dev 
    zip 
    && docker-php-ext-install zip

RUN pecl install grpc
RUN docker-php-ext-enable grpc

RUN apt-get update && apt-get install -y libcurl4-openssl-dev
RUN docker-php-ext-install curl

RUN a2enmod rewrite

RUN composer install

EXPOSE 80

Dockerfile-backend:

FROM php:8.1-apache

WORKDIR /var/www/html

COPY . /var/www/html/

RUN apt-get update && apt-get install -y 
    libzip-dev 
    zip 
    && docker-php-ext-install zip

RUN pecl install grpc
RUN docker-php-ext-enable grpc

RUN apt-get update && apt-get install -y libcurl4-openssl-dev
RUN docker-php-ext-install curl

RUN a2enmod rewrite

RUN composer install

EXPOSE 80

还需要创建一个 otel-collector-config.yaml 文件,用于配置 OpenTelemetry Collector:

receivers:
  otlp:
    protocols:
      grpc:

processors:
  batch:

exporters:
  jaeger:
    endpoint: jaeger:14268
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [jaeger]

然后,运行 docker-compose up -d 命令启动应用。

5. 查看追踪数据

启动应用后,我们可以访问 http://localhost:8080 来触发请求。然后,我们可以访问 http://localhost:16686 打开 Jaeger UI,查看追踪数据。

我们应该可以看到一个完整的链路,包含 frontend-requestbackend-process 两个 Span,以及 call-backend Span。通过分析这些 Span 的信息,我们可以了解请求在各个服务之间的流转路径,以及每个服务的耗时。

扩展:使用中间件自动注入Span

手动地在每个函数中创建和结束 Span 比较繁琐。我们可以使用中间件来自动注入 Span,简化代码。

例如,我们可以创建一个名为 TraceMiddleware 的中间件:

<?php

namespace AppMiddleware;

use PsrHttpMessageServerRequestInterface as Request;
use PsrHttpServerRequestHandlerInterface as Handler;
use OpenTelemetryAPIGlobals;
use OpenTelemetryAPITraceSpanKind;

class TraceMiddleware
{
    private string $serviceName;
    private string $version;

    public function __construct(string $serviceName, string $version)
    {
        $this->serviceName = $serviceName;
        $this->version = $version;
    }

    public function __invoke(Request $request, Handler $handler)
    {
        $tracer = Globals::tracerProvider()->getTracer($this->serviceName, $this->version);
        $route = $request->getUri()->getPath();
        $span = $tracer->spanBuilder($route)
            ->setSpanKind(SpanKind::KIND_SERVER)
            ->startSpan();
        $scope = $span->activate();

        try {
            $response = $handler->handle($request);
            $span->setStatus(OpenTelemetryAPITraceStatusCode::STATUS_OK);
        } catch (Exception $e) {
            $span->setStatus(OpenTelemetryAPITraceStatusCode::STATUS_ERROR, $e->getMessage());
            throw $e;
        } finally {
            $span->end();
            $scope->detach();
        }

        return $response;
    }
}

然后在你的应用中注册这个中间件。 不同的框架注册方式不一样,以Slim为例:

use AppMiddlewareTraceMiddleware;

$app = new SlimApp();

$app->add(new TraceMiddleware('my-app', '1.0.0'));

$app->get('/hello/{name}', function ($request, $response, array $args) {
    $name = $args['name'];
    $response->getBody()->write("Hello, $name");
    return $response;
});

$app->run();

一些建议

  • 合理命名 Span: Span 的名称应该清晰地反映 Span 的功能,方便理解。
  • 添加 Span 属性: 可以使用 setAttribute 方法添加 Span 属性,例如 HTTP 请求的方法、URL、状态码等。这些属性可以帮助我们更好地分析追踪数据。
  • 使用事件记录重要信息: 可以使用 addEvent 方法记录 Span 的事件,例如数据库查询语句、缓存命中情况等。
  • 设置 Span 状态: 使用 setStatus 方法设置 Span 的状态,例如 STATUS_OK 表示成功,STATUS_ERROR 表示失败。
  • 注意采样率: 在高并发场景下,为了避免遥测数据量过大,可以适当降低采样率。
  • 保护敏感数据: 避免在 Span 属性和事件中记录敏感数据,例如用户密码、信用卡号等。

总结:全链路追踪是高效问题排查的利器

通过集成OpenTelemetry,我们能够轻松地在PHP应用中实现全链路追踪,深入了解系统内部状态,快速定位问题,并优化性能。 全链路追踪为我们提供了更深入的视角,帮助我们理解系统行为,快速定位问题,并优化性能。希望今天的分享能够帮助大家更好地理解和应用可观测性技术。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注