kmuto’s blog

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

PHP 7系アプリケーションへのOBI(OpenTelemetry eBPF Instrumentation)によるゼロコード計装を試していた

o11y界隈でOBIが熱いらしい。OBI、OpenTelemetry eBPF Instrumentation。実行しているプロセスや通信に対し、カーネルが提供するeBPF機能で割り込み、OpenTelemetryのシグナルとして送信できるというもの。

opentelemetry.io

Goのゼロコード計装でもeBPFを使っていたね。

kmuto.hatenablog.com

ということで、こういうケースではOBIが活躍できるかもしれないというサンプルを作って試していく。

お題のPHP 7アプリケーション

ここでは例として「PHP 7.4でLaravelを動かしている」という状況を考える。データベースとしてはMySQLを想定しつつMariaDBを使っている。

PHP 7.4は2022年にセキュリティサポートが切れているバージョンであり、OpenTelemetryのゼロコード計装の対象でもない。かろうじて手動計装向けのSDKは存在するものの、トレーサー設定に始まりHTTPもクエリも全部自前で計装しなければならず、気軽に着手できるものではないだろう。

まずはDockerイメージでMariaDB/MySQLを利用可能なPHP 7.4の環境を用意する。

FROM php:7.4
RUN apt-get update && apt-get install -y --no-install-recommends git-core \
  && php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
  && php composer-setup.php \
  && mv composer.phar /usr/local/bin/composer
RUN docker-php-ext-install mysqli pdo pdo_mysql \
  && docker-php-ext-enable mysqli pdo pdo_mysql

前回PHPのゼロコード計装をしたときとおおむね同じようにアプリケーションを作っていく。最初のcompose.ymlは以下。

services:
  php74-laravel:
    build:
      context: .
    ports:
      - "8000:8000"
    networks:
      - php-network
    volumes:
      - .:/var/www/html
    working_dir: /var/www/html/myprj
    command: php artisan serve --host=0.0.0.0
  db:
    image: mariadb:10.6
    ports:
      - "3306:3306"
    environment:
      MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
      MARIADB_DATABASE: laravel
    volumes:
      - ./db:/var/lib/mysql:Z
    networks:
      - php-network

networks:
  php-network:

Laravelアプリケーションを作成。

$ docker compose start db
$ docker compose run --rm php74-laravel /bin/bash
# apt-get install -y vim-tiny mariadb-client
# cd /var/www/html
# composer create-project laravel/laravel myprj
# cd myprj
# vi .env
...
DB_HOST=db (127.0.0.1→dbに)
...
# mysql -h db (雑にセッションデータベースと同じものにサンプルテーブルを突っ込んでいる)
> CREATE DATABASE laravel CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
> CREATE USER 'myuser'@'%' IDENTIFIED BY 'mypass';
> GRANT ALL PRIVILEGES ON laravel.* TO 'myuser'@'%';
> FLUSH PRIVILEGES;
> USE laravel;
> CREATE TABLE fruits (
>   id INT AUTO_INCREMENT PRIMARY KEY,
>   name VARCHAR(50) NOT NULL
> );
> INSERT INTO fruits (name) VALUES
>  ('Apple'),
>  ('Orange'),
>  ('Grape'),
>  ('Banana'),
>  ('Strawberry');
> \q
# php artisan migrate

適当なMySQLクエリと、エラーを発生させるエンドポイントも含めておこう(vi routes/web.php)。

<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    $mysqli = new mysqli('db', 'myuser', 'mypass', 'laravel');
    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();

    return 'Hello World';
});

Route::get('/error', function () {
    1 / 0;
});

docker run環境をexitで抜け、改めてdocker compose upを実行して起動する。アクセスしてみよう。

curl http://localhost:8000
<h1>Fruits Table</h1><ul><li>1: Apple</li><li>2: Orange</li><li>3: Grape</li><li>4: Banana</li><li>5: Strawberry</li><li>6: Apple</li><li>7: Orange</li><li>8: Grape</li><li>9: Banana</li><li>10: Strawberry</li></ul>Hello World

雑だが動いている。

OBIとOpenTelemetry Collectorの設定

OBIはDockerイメージが提供されているので、これをそのまま利用してみよう。設定はYAMLでもできるようだが、環境変数のほうがはるかに簡単。compose.ymlに設定を追加する。

  obi:
    image: docker.io/otel/ebpf-instrument:main
    pid: 'host'
    privileged: true
    environment:
      OTEL_EBPF_TRACE_PRINTER: text
      OTEL_EBPF_OPEN_PORT: 8000
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
    networks:
      - php-network
  otel-collector:
    image: mackerel/otelcol-mackerel:latest
    environment:
      MACKEREL_APIKEY: 《MackerelのAPIキー》
    volumes:
      - ./otel-config.yml:/etc/otelcol-mackerel/config.yaml
    command: "--config /etc/otelcol-mackerel/config.yaml"
    networks:
      - php-network

Mackerelに送るOpenTelemetry Collectorまわりは、最近Mackerel OpenTelemetry Collectorがリリースされたことで設定がだいぶ身軽になっている。ただHTTP 4318ポート待ち受けがローカルバインドになっているので、docker compose向けには結局configを上書きする必要があった。設定のotel-config.ymlは以下のとおり。

receivers:
  otlp:
    protocols:
      grpc:
      http:
        endpoint: 0.0.0.0:4318

exporters:
  mackerelotlp:

service:
  pipelines:
    metrics:
      receivers: [otlp]
      exporters: [mackerelotlp]
    traces:
      receivers: [otlp]
      exporters: [mackerelotlp]

eBPFに乗っているOBIの性質上、強いケイパビリティーを必要とする。ここではpidでホストOS上のすべてのプロセス、privilegedでeBPFに必要なケイパビリティーをまとめて設定している。悪用されると怖いね! テスト用とはいえ、eBPFでの計装には慎重さが求められることがわかる。とはいえ「ローカルに侵入されていたらそもそも詰みじゃね?」という説もある。

OTEL_EBPF_TRACE_PRINTERデバッグ出力の表示形式、OTEL_EXPORTER_OTLP_ENDPOINTはシグナルの送り先。

OTEL_EBPF_OPEN_PORTが肝で、このポートを開いているプロセスを対象にする。このときプロセス名がサービス名になるようだ。複数を対象にするのであれば,で区切る。対象を探す(ディスカバリー)方法としてはポートのほか、実行パスに基づく方法も用意されている(Goでの計装で使ったのはこの方式)。

計装結果の確認

curl http://localhost:8000curl http://localhost:8000/errorにリクエストし、Mackerelにトレースが送信されるのを待つと、phpサービスにそれっぽいのが出てくる。

正常なトレースでは「GET /」のトレースを大元として、「in queue」「processing」が並行で走り、最後のほうには「SELECT fruits」というクエリスパンも走っている。

「GET /」スパンは、クライアントアドレス、リクエストメソッド、ステータスコード、ルート、パスといったHTTP系の基本的な属性は入っている。試しにクエリパラメータを入れてみたがそれは属性化されなかった。

in queueとprocessingのスパンはぱっと見ではあまり有用な感じはしない(本番で使うならフィルタリングしてしまっていいものかもしれない)。

「SELECT fruits」スパンはオペレーション名とテーブル名が属性として入っているが、SQLクエリは含まれていなかった。

エラーリクエストのほうではhttp.response.status_codeを見ているのか、status.codeがERRORとして記録されている。

メトリックも出力されている。

サーバー・クライアント双方のレイテンシーとサイズまわりが主。

  • db.client.operation.duration
  • dns.lookup.duration
  • http.client.request.body.size
  • http.client.request.duration
  • http.client.response.body.size
  • http.server.request.body.size
  • http.server.request.duration
  • http.server.response.body.size
  • target_info(メタデータ類。host.id、host.name、instanceなど)

デフォルトのままだとメトリックがけっこうな数(サンプルで54本)になるので、Mackerelで有償のオーガニゼーションで利用している場合はご注意されたい(otel-config.ymlのmetricsセクションを消せば投稿されなくなる)。

データベースクエリの表示

前述のとおり、データベースクエリについてはテーブルとオペレーション名まで出ているものの、クエリ全体は出ていない。

テーブルやオペレーション名がわかっているので、OBIはクエリを認識している。コードのほうを見るとPostgreSQLMySQLを対象にかなりマメな解析さえしている。

つまり、今はわざわざ落としているのでdb.query.textなどを取得対象にすればよい、のだがこれが難儀した。

この隠蔽は環境変数の対象にはなっておらず、configのYAMLを読ませる必要がある。しかし公式ドキュメントを見ながらYAMLを書いて実行してもPHPのプロセスを掴むところまではいくものの、リクエスト/レスポンスに計装が全然効いてくれない。ドキュメント自体情報が錯綜しているようだし、世にこの設定で動くという情報がほぼない状態で厳しい(CoPilotでコードからも作らせてみたがやはりうまくいかず。情報共有会をしたい!)。

ともあれ、要はここのフラグを変えたいわけなので、安直な手段としてtrueに変更してイメージを作り直してみた。

$ git clone https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation.git
$ cd opentelemetry-ebpf-instrumentation
$ vi pkg/export/attributes/attr_defs.go
$ docker build -t kmuto/ebpf-instrument:latest .

イメージを利用するようにして、obiコンテナをdown/upする。

obi:
    image: kmuto/ebpf-instrument:latest

やったぜ。

デフォルトがfalseなのは機微が露出する可能性があるからという議論も目にしたので、注意深く使う必要はあるものの、ここまででアプリケーションコードをいじらずともゼロコード計装に近い感覚でHTTPとクエリをざっと観察できるようになった。

制約と可能性

トランザクションを横から覗くという性質上、普通のゼロコード計装や手動計装とはつながらない(はず)。コンテキスト伝播のOTEL_EBPF_CONTEXT_PROPAGATION: allは効かなかった。AIに聞いたらミドルウェア書いてうんぬん…と出てきたのでやってみたけれども、想像どおりtraceparentは飛んできていないようだった(逆にヘッダを書き換えられていたらそのほうが怖い)。

また、手動計装したことでOBI対象外になったり(OTEL_EBPF_EXCLUDE_OTEL_INSTRUMENTED_SERVICES: falseが必要になる)、Collectorにシグナルを送るHTTP通信をOBIが拾ってしまったりと、混ぜるといろいろ厄介になることも発生した。

そのほか、サービス間での通信がTLS暗号化されるとeBPFで中身を見られないかも、とのこと。これも「それはそう」という感じではある。

OBIは銀の弾丸ではなく、言語・環境でゼロコード計装ができるのであれば、まずはそれを選ぶのが妥当だろう。

逆に言語のゼロコード計装も手動計装も困難な状況、たとえば今回作ってみたPHP 7環境のように、コードもインフラも変更せずにHTTPリクエストとデータベースクエリの状況をトレースやメトリックで可視化したいといった用途には活用できそうだなと思った。

issue化もされているとおり、現状配布形態がDockerイメージしかないのは少々不便(Dockerfileを参考にGoのビルドツールを入れてmake compileで作成できるとはいえ)なので、リリースビルドが今後出てくると期待したい。