kmuto’s blog

はてな社でMackerel CREをやっています。料理と旅行といろんなIT技術

非フレームワークのPHPアプリケーションの計装に向き合っていたら、ゼロコード計装っぽいものを錬成してしまったのだが…?

OpenTelemetryのトレースゼロコード計装は、たいていフレームワークの利用を前提としており、PHPも例外ではない。しかし、フレームワークを使わないアプリケーションはいまも広く存在する。そのような状況で、アプリケーションコードにできるだけ手を入れずに計装できるか、しばらく試行錯誤していた。

当初は素朴にNGINXでの計装や一部PHPコードの改変、ライブラリ計装から始めたが、最終的にリクエストに対する「ゼロコード計装っぽいもの」を錬成した気がする。

題材とするアプリケーション

構成はシンプルで、NGINXがリクエストを受け付け、PHP-FPMを介してPHPコードを実行する。アプリケーションコード内ではMariaDBへのクエリが発生する。

目的は、リクエストやデータベース操作をOpenTelemetryのスパンとして収集し、Mackerelのようなオブザーバビリティサービスに送信してトレース化すること。ただし、既存のPHPコードの修正は最小限にとどめたい

ゼロコード計装っぽいものの仕組み

結論から言うと、要点はprepend.phpにある。PHPauto_prepend_file設定を利用すると、リクエストごとにコード冒頭にこのファイルがあたかも注入れたかのように振る舞うので、アプリケーション側を改変せずに計装できる。

/etc/php/*/fpm/php.iniおよび/etc/php/*/cli/php.iniPHPエンジン設定):

...
auto_prepend_file = <パス>/prepend.php
...
[opentelemetry]
extension=opentelemetry.so

/etc/php/*/fpm/pool.d/www.conf(OTel Collectorへの送信設定とライブラリ計装パッケージの有効化):

...
env[OTEL_SERVICE_NAME] = "php-tutorial"
env[OTEL_EXPORTER_OTLP_ENDPOINT] = "http://localhost:4318"
env[OTEL_PHP_AUTOLOAD_ENABLED] = "true"

composer.json(OpenTelemetry系とシグナルHTTP送信のライブラリ):

{
    "require": {
        "open-telemetry/api": "^1.5",
        "open-telemetry/exporter-otlp": "^1.3",
        "open-telemetry/sdk": "^1.5",
        "php-http/guzzle7-adapter": "^1.1",
        "open-telemetry/opentelemetry-auto-psr15": "^1.1",
        "open-telemetry/opentelemetry-auto-mysqli": "^0.0.3"
    },
    "config": {
        "allow-plugins": {
            "php-http/discovery": false,
            "tbachert/spi": false
        }
    }
}

prepend.php(ゼロコード計装っぽいものの核心):

<?php

use OpenTelemetry\SDK\Trace\TracerProviderFactory;

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

$factory = new TracerProviderFactory();
$tp = $factory->create();
$tracer = $tp->getTracer('trace-tutorial');

$rootSpan = $tracer->spanBuilder($_SERVER['SCRIPT_NAME'])->setSpanKind(OpenTelemetry\API\Trace\SpanKind::KIND_SERVER)->startSpan();
$scope = $rootSpan->activate();

$rootSpan->setAttribute('http.target', $_SERVER['REQUEST_URI']);
$rootSpan->setAttribute('http.route', $_SERVER['DOCUMENT_URI']);
$rootSpan->setAttribute('http.user_agent', $_SERVER['HTTP_USER_AGENT']);
$rootSpan->setAttribute('http.method', $_SERVER['REQUEST_METHOD']);
$rootSpan->setAttribute('net.sock.peer.addr', $_SERVER['REMOTE_ADDR']);

set_exception_handler(function (Throwable $e) use ($rootSpan) {
  echo "An unexpected error occurred: " . $e->getMessage();
  $rootSpan->setStatus(\OpenTelemetry\API\Trace\StatusCode::STATUS_ERROR, $e->getMessage());
  $rootSpan->recordException($e, ['exception.escaped' => true]);
  throw $e;
});

register_shutdown_function(function() use ($rootSpan, $scope, $tp) {
  $rootSpan->end();
  $scope->detach();

  $tp->shutdown();
});

center.php(サンプルのアプリケーションコード):

<?php

echo "<h1>Hello from PHP</h1>";

sleep(1);

$childSpan = $tracer->spanBuilder('child_process')->startSpan();

// Exceptionを出してみたいなら
// $a = 1/0;

try {

  $mysqli = new mysqli('localhost', 'myuser', 'mypass', 'mydb');
  if ($mysqli->connect_errno) {
    die("MySQL connection failed: " . $mysqli->connect_error);
  }
  $sql = "SELECT id, name FROM fruits";
  $result = $mysqli->query($sql);
  if ($result) {
    echo "<h1>Fruits Table</h1>";
    echo "<ul>";
    while ($row = $result->fetch_assoc()) {
        echo "<li>{$row['id']}: {$row['name']}</li>";
    }
    echo "</ul>";

    $result->free();
  } else {
    echo "Query error: " . $mysqli->error;
  }
  $mysqli->close();

  sleep(2);

} finally {
  $childSpan->end();
  sleep(1);
}

config.yml(OTel Collectorの設定):

receivers:
  otlp:
    protocols:
      http:
      grpc:

processors:
  batch:
    timeout: 5s
    send_batch_size: 5000
    send_batch_max_size: 5000

exporters:
  otlphttp/mackerel:
    endpoint: https://otlp-vaxila.mackerelio.com
    compression: gzip
    headers:
      Mackerel-Api-Key: <APIキー>

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/mackerel]

試行錯誤(1):NGINXの計装チャレンジ

Webフレームワークの場合、フレームワーク自身がHTTPサーバーの機能を備えており、NGINXなどはそこにプロキシ接続するという構成が一般的である。フレームワークのフロントエンド部分が計装されていれば、ルートとなるリクエストのスパンもそこで作られる。

しかし、NGINX・PHP-FPM・PHPコードの構成の場合、PHPコードを加工せずにルートスパンを生み出すということはできない。計装作業内容と「既存のPHPコードの修正は最小限にとどめたい」という制約からすると、ルートスパンをNGINXに持たせたほうがよいのではないかと考えた。

  • 利点
    • モジュールと簡単な設定でHTTPリクエストのスパンを生成できる
    • 属性としてリクエスト(パス、メソッド、リモートアドレスなど)の各種が自動で付加される
  • 欠点

すでに運用されている環境では欠点がだいぶ厳しいが、ドキュメントを見ながらDebian 13環境に入れていくことにする。

sudo apt-get install -y curl gnupg2 ca-certificates lsb-release debian-archive-keyring
curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
    | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
http://nginx.org/packages/debian `lsb_release -cs` nginx" \
    | sudo tee /etc/apt/sources.list.d/nginx.list
echo -e "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" \
    | sudo tee /etc/apt/preferences.d/99nginx
sudo apt-get update
sudo apt-get install -y nginx nginx-module-otel

/etc/nginx/nginx.confを編集し、ngx_otel_module.soの有効化、およびユーザー名をDebianシステム標準のwww-dataに合わせる(さもないとphp-fpmのほうで面倒になる)。

# 先頭に追加
load_module modules/ngx_otel_module.so;

...
# userの値をnginxから変更
user  www-data;
...

/etc/nginx/conf.d/default.confでOpenTelemetry計装を有効化する。プロトコルはHTTP/ProtobufではなくgRPCを使うようだ。

otel_exporter {
  endpoint localhost:4317;
}

otel_trace on;
otel_trace_context propagate;
otel_service_name nginx;
otel_span_name $request_uri;

server {
...

www-dataユーザーへの変更と設定反映を行う。

sudo systemctl stop nginx
sudo chwon www-data /var/log/nginx/*.log
sudo systemctl start nginx

NGINX版では/usr/share/nginx/htmlがコンテンツの場所になる。

次に、OpenTelemetryシグナルを受け取るOpenTelemetry Collectorを用意する。

wget https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v0.132.0/otelcol-contrib_0.132.0_linux_amd64.deb
sudo dpkg -i otelcol-contrib_0.132.0_linux_amd64.deb

本記事範囲ではcontribである必要はないが、将来的にフィルタリングやサンプリングなどをしようとすると結局contribレベルが必要になるだろう(無駄も多いが、かといってocbは誰でも使いやすいというものでもない…)。

/etc/otelcol-contrib/config.yamlが設定ファイルなので、Mackerelに送るよう変更する(設定内容は前掲に同じ)。設定を反映するために再起動する。

sudo systemctl restart otelcol-contrib.service

これでリクエストに応じてスパンを送るようになった。

curl http://localhostを実行し、MackerelのAPM画面のnginxサービスでトレースになっていることを確認する。

試行錯誤(2):PHPコードに直接計装

まずはPHPの環境をOpenTelemetryモジュールのビルドも含めて用意する。

sudo apt-get install -y php-fpm php-pear php-dev
sudo pecl channel-update pecl.php.net
sudo pecl intsall opentelemetry

/etc/php/8.4/fpm/php.ini/etc/php/8.4/cli/php.iniの末尾に拡張モジュールを登録する(cliのほうは実際には使わないのだが、ここに記入しておかないと、あとでcomposerのインストール時に拡張がないと言われて失敗する)。

...
[opentelemetry]
extension=opentelemetry.so

/etc/php/*/fpm/pool.d/www.confでOTel Collectorへの送信設定とライブラリ計装パッケージの有効化をしておく。こちらの送信はHTTP/Protobufにしている。

...
env[OTEL_SERVICE_NAME] = "php-tutorial"
env[OTEL_EXPORTER_OTLP_ENDPOINT] = "http://localhost:4318"
env[OTEL_PHP_AUTOLOAD_ENABLED] = "true"

sudo systemctl restart php8.4-fpm.serviceで設定を反映する。

データベースとcomposerをインストールし、コンテンツフォルダ下(本来はセキュリティなどで場所を考えるべきだろうが)にOpenTelemetryのSDKなどを展開する。php-http/guzzle7-adapterはHTTPでシグナルを送るために必要だった。

sudo apt-get install -y mariadb-server mariadb-client php8.4-mysql composer
cd /usr/share/nginx/html
sudo mkdir phptest
sudo chown <自分アカウント> phptest
cd phptest
composer require open-telemetry/api open-telemetry/exporter-otlp open-telemetry/sdk php-http/guzzle7-adapter

データベースはsudo mysqlして適当に作っておく(適当にしてもひどい権限ではある…)。

sudo mysql

CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'myuser'@'localhost' IDENTIFIED BY 'mypass';
GRANT ALL PRIVILEGES ON mydb.* TO 'myuser'@'localhost';
FLUSH PRIVILEGES;

USE mydb;
CREATE TABLE fruits (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL
);
INSERT INTO fruits (name) VALUES
('Apple'),
('Orange'),
('Grape'),
('Banana'),
('Strawberry');

元のPHPアプリケーションコードapp.phpは以下のとおり。いろいろひどいが、あくまでサンプルなので目をつむってほしい。

<?php

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

echo "<h1>Hello from PHP</h1>";

sleep(1);

// ここから子スパンにしたい処理
try {

  $mysqli = new mysqli('localhost', 'myuser', 'mypass', 'mydb');
  if ($mysqli->connect_errno) {
    die("MySQL connection failed: " . $mysqli->connect_error);
  }
  $sql = "SELECT id, name FROM fruits";
  $result = $mysqli->query($sql);
  if ($result) {
    echo "<h1>Fruits Table</h1>";
    echo "<ul>";
    while ($row = $result->fetch_assoc()) {
        echo "<li>{$row['id']}: {$row['name']}</li>";
    }
    echo "</ul>";

    $result->free();
  } else {
    echo "Query error: " . $mysqli->error;
  }
  $mysqli->close();

  sleep(2);
  // ここまで子スパンにしたい処理

  sleep(1);
}

既存のPHPコードに手を入れたくはないが、いったん計装をコード内に追加してみる。

<?php

use OpenTelemetry\SDK\Trace\TracerProviderFactory;
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter;

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

$factory = new TracerProviderFactory();
$tp = $factory->create();
$tracer = $tp->getTracer('trace-tutorial');

$headers = getallheaders();
$propagator = TraceContextPropagator::getInstance();
$getter = ArrayAccessGetterSetter::getInstance();
$ctx = $propagator->extract($headers, $getter);

$rootSpan = $tracer->spanBuilder('Hello Mackerel!')->setParent($ctx)->startSpan();
$scope = $rootSpan->activate();

echo "<h1>Hello from PHP</h1>";

sleep(1);

$childSpan = $tracer->spanBuilder('child_process')->startSpan();

try {

  $mysqli = new mysqli('localhost', 'myuser', 'mypass', 'mydb');
  if ($mysqli->connect_errno) {
    die("MySQL connection failed: " . $mysqli->connect_error);
  }
  $sql = "SELECT id, name FROM fruits";
  $result = $mysqli->query($sql);
  if ($result) {
    echo "<h1>Fruits Table</h1>";
    echo "<ul>";
    while ($row = $result->fetch_assoc()) {
        echo "<li>{$row['id']}: {$row['name']}</li>";
    }
    echo "</ul>";

    $result->free();
  } else {
    echo "Query error: " . $mysqli->error;
  }
  $mysqli->close();

  sleep(2);

} finally {
  $childSpan->end();
  sleep(1);
  $rootSpan->end();
  $scope->detach();

  $tp->shutdown();
}
  • NGINXのスパンを親とするためにヘッダを展開してスパン($rootSpan)の親コンテキスト(setParent($ctx))としている。
  • スパンのスコープを有効にしておくことで($rootSpan->activate())、その後に$tracerから作ったスパンは自動的に子スパン($childSpan)になる。
  • 例外などが起きたときでもスパンの処理は完了するよう、tryfinallyで囲んでいる。

子スパン部分は仕方がないとはいえ、前後に多めに計装のためのコードが入るので、1つのファイルならまだしも、ほかにもページごとのファイルがあったら辛くなりそうだ。

ともあれ、どうなったか見てみよう。

NGINXのスパンの子スパンとなり、それらしいトレースになった。昨日β版がリリースされたばかりのサービスマップでも見てみよう。

試行錯誤(3):データベースアクセスの自動計装

データベースの箇所の計装をしていなかったので、この段階ではPHPコードのところにメインルーチンと子処理の2つのスパンしかない。実際の運用では、データベースのやりとりがどう発生しているかは、パフォーマンスや障害を確認する際に重要な役割になるだろう。

データベースのやりとりの計装であれば、ようやくゼロコード計装の恩恵を受けられる。open-telemetry/opentelemetry-auto-mysqliライブラリをインストールすると、MariaDBMySQL)データベースとのやりとりに関する命令に対して、自動で計装が施される。

composer require open-telemetry/opentelemetry-auto-mysqli

www.confではenv[OTEL_PHP_AUTOLOAD_ENABLED] = "true"を設定済みなので、そのまま有効になるはずだ。

計画どおり。データベースのクエリのスパンが自動で作られ、操作に関する情報が属性に含まれている。PHPコードでSQL文字列を雑に叩いているためにクエリもだいぶ大胆に出てしまっているが、まっとうな方法で書けばおそらくまっとうに「?」などでマスクされるだろう。さらに先日言及したRedaction Processorを使ってみるのもよいかもしれない。

kmuto.hatenablog.com

同様にライブラリをインストールするだけで計装できるものについては、一覧を確認されたい(とはいえ、データベース、I/O、HTTPアクセスまわり程度にはなってしまうだろうが)。

試行錯誤(4):auto_prepend_fileとauto_append_fileによる計装の隠蔽

PHPコードの計装ができてめでたしめでたし…ではなく、「既存のPHPコードの修正は最小限にとどめたい」が達成できていない。

AIと会話していたところ、auto_prepend_fileauto_append_fileでそれぞれコードの前・後にその内容を注入できるらしい。

auto_prepend_file = <パス>/prepend.php
auto_append_file = <パス>/append.php

試してみたところ、prependファイル側をtry {で終わらせるという書き方はできないようだ(evalを使えばできるという提案をAIにされたがそれはちょっと…)。try〜catchが使えないため、全体で発生した例外を拾うための処理もprependに書くようにしていくと、結果的にappendでやることはなくなった。

この時点でのprepend.phpは以下のとおりだ。

<?php

use OpenTelemetry\SDK\Trace\TracerProviderFactory;
// use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
// use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter;

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

$factory = new TracerProviderFactory();
$tp = $factory->create();
$tracer = $tp->getTracer('trace-tutorial');

$headers = getallheaders();
$propagator = TraceContextPropagator::getInstance();
$getter = ArrayAccessGetterSetter::getInstance();
$ctx = $propagator->extract($headers, $getter);

$rootSpan = $tracer->spanBuilder($_SERVER['SCRIPT_NAME'])->setParent($ctx)->setSpanKind(OpenTelemetry\API\Trace\SpanKind::KIND_SERVER)->startSpan();

$scope = $rootSpan->activate();

$rootSpan->setAttribute('http.target', $_SERVER['REQUEST_URI']);
$rootSpan->setAttribute('http.route', $_SERVER['DOCUMENT_URI']);
$rootSpan->setAttribute('http.user_agent', $_SERVER['HTTP_USER_AGENT']);
$rootSpan->setAttribute('http.method', $_SERVER['REQUEST_METHOD']);
$rootSpan->setAttribute('net.sock.peer.addr', $_SERVER['REMOTE_ADDR']);

set_exception_handler(function (Throwable $e) use ($rootSpan) {
  echo "An unexpected error occurred: " . $e->getMessage();
  $rootSpan->setStatus(\OpenTelemetry\API\Trace\StatusCode::STATUS_ERROR, $e->getMessage());
  $rootSpan->recordException($e, ['exception.escaped' => true]);
  throw $e;
});

register_shutdown_function(function() use ($rootSpan, $scope, $tp) {
  $rootSpan->end();
  $scope->detach();

  $tp->shutdown();
  echo "Finished.\n";
});
  • app.phpの冒頭の計装設定をおおむねそのまま流用している。
  • 複数のファイルから使われることを想定して、spanBuilderでのスパン名はPHPファイル名を取り込むようにしている。
  • $_SERVER配列に入っている情報をスパン属性にしてみた。PHPのこの手のは昔のイメージでどうにも不安があるのだが…まぁほかの計装ライブラリもそんなに変わらないだろう。
  • setSpanKindでKINDをINTERNALではなくSERVERにしてみる。MackerelのAPM画面のHTTPサーバータブに出そうと思ったのだが、まだ出ておらず、ほかに属性値が必要そうだ。http.status_codeはこの時点では決まっていないので難しい。
  • set_exception_handlerでコード内で発生した例外を集めている。スパンのExceptionにバックトレース込みで入れるようにしてみている。
  • register_shutdown_functionでfinally相当のクロージング作業をさせている。

このprepend.phpを前提としたcenter.phpは以下のようになる。

<?php

echo "<h1>Hello from PHP</h1>";

sleep(1);

$childSpan = $tracer->spanBuilder('child_process')->startSpan();

try {

  $mysqli = new mysqli('localhost', 'myuser', 'mypass', 'mydb');
  if ($mysqli->connect_errno) {
    die("MySQL connection failed: " . $mysqli->connect_error);
  }
  $sql = "SELECT id, name FROM fruits";
  $result = $mysqli->query($sql);
  if ($result) {
    echo "<h1>Fruits Table</h1>";
    echo "<ul>";
    while ($row = $result->fetch_assoc()) {
        echo "<li>{$row['id']}: {$row['name']}</li>";
    }
    echo "</ul>";

    $result->free();
  } else {
    echo "Query error: " . $mysqli->error;
  }
  $mysqli->close();

  sleep(2);

} finally {
  $childSpan->end();
  sleep(1);
}

計装前のapp.phpに子スパンの設定を手動で加えた理想形に近いのではないだろうか。

例外(たとえば$a = 1 / 0をどこかに書いてみる)も良い感じに表現できる。

ここまでで必要な情報を属性に持たせたので、NGINXスパンやそれを親にする必要がなくなった。コンテキストの設定を外すことにする。

$rootSpan = $tracer->spanBuilder($_SERVER['SCRIPT_NAME'])->setSpanKind(OpenTelemetry\API\Trace\SpanKind::KIND_SERVER)->startSpan();

そういえば、$_SERVERを眺めていたら、PHPエンジンに設定したOTEL_EXPORTER_OTLP_ENDPOINTなどの環境変数も(当然ながら)そこに出てきていた。OTel Collectorを介さずMackerelに直接送信したいときにはOTEL_EXPORTER_OTLP_HEADERSAPIキーを含める手順になっているが、$_SERVERがうっかり露出したときにはAPIキー文字列が丸々露出することになるので、やはりOTel Collectorを介するのが安全そうだ。

あとがき

PHP3〜PHP5の頃にはいろいろ濫造していたものじゃ…という経験はあまり役に立たなかったなぁ。