読者です 読者をやめる 読者になる 読者になる

株式会社ネクスト エンジニアBlog

不動産・住宅情報サイト HOME'Sを運営する株式会社ネクストのエンジニアが提供する技術ブログです。エンジニアに役立つ情報の発信や、弊社エンジニアの活動を中心にお届けします。

Zipkinを導入してみた(PHP編)

こんにちは。技術基盤部の磯野です。 先日に引き続きZipkinです。 今回は PHP + Symfony で動いている Webアプリケーションへのトレーサーの導入です。

前回の記事 → Zipkinを導入してみた(サーバー編)

構成

PHPは非同期に処理ができないので直接Zipkinサーバーにに投げるのではなくローカルのfluentdを利用してできるだけ短時間で処理が終わるようにしています。

f:id:nextdeveloper:20160704145103p:plain 画像のアプリケーション・フレームワーク → Zipkin Symfony2 Fluentd Sinatra

処理の流れ

  1. トレースデータの初期化(trace_id, span_idにユニークな値を設定する)
  2. Symfonyのイベントリスナのkernel.requestとkernel.responseをSubscribe
  3. kernel.requestイベントの通知時にリクエストの受付時刻(microtime)やURLなどを保存
  4. WebAPI呼び出し時に処理の流れを追跡するための情報をリクエストヘッダに付与
  5. WebAPI呼び出し処理の直前に送信時刻(microtime)やリクエスト先を保存
  6. WebAPI呼び出し終了時に受信時刻(microtime)を保存し、API呼び出し開始時に保存した内容と一緒にローカルのfluentdにscribeで送信
  7. kernel.responseイベントの通知時にレスポンス送信時刻(microtime)を保存し、kernel.requestで保存した内容と一緒にローカルのfluentdにscribeで送信
  8. fluentdは順次Zipkinサーバーに転送

WebAPIが管理外のサービスの場合にはトレースデータの追跡情報をリクエストヘッダに付与せずに処理を行います。
なお、アプリケーションとWebAPIの呼び出しのトレースデータを別々にfluentdに流すようになっていますが、後述のライブラリの仕様です。 トレースするシステムが多い場合はまとめて送信するように改修した方がよさそうです。

処理の流れを追跡するためのリクエストヘッダ

WebAPIの呼び出しのトレースデータは取得できますが、WebAPIからさらに別のWebAPIを呼ぶ場合には、一連の流れを認識するデータを持ちまわる必要があります。 そこで、HTTPヘッダにその流れを認識するために必要なデータを付与しています。

以下はそのHTTPヘッダとその説明です。

HTTP Header Type 説明
X-B3-TraceId 64 encoded bits *1 リクエストごとに共通のID、これで追跡情報を紐付ける
X-B3-SpanId 64 encoded bits *1 計測ごとに一意に決まるID
X-B3-ParentSpanId 64 encoded bits *1 直前のSpanId
X-B3-Sampled Boolean (either “1” or “0”) *2 サンプリング対象かどうか
X-B3-Flags a Long -

*1 内部データは数値ですが、ヘッダに付与する際は16進数表現した文字列に変換します。
*2 rubygemsにあるzipkin-tracerが「X-B3-Sampled」を受け取る際に'true'(文字列)を要求するので文字列に変換します。

詳しくは以下URLの「HTTP Tracing」を参照してください。 http://zipkin.io/pages/instrumenting.html

fluentdの設定

scribeで受け取りzipkinのscribe用のポートに転送するためのfluentdの設定はこんな感じです。
※ 事前にfluent-plugin-scribeを導入しています。

<source>
  type scribe
  port 1463
</source>

<match zipkin.**>
  type scribe
  host zipkinserver
  port 9410
  field_ref message
</match>

PHP(Symfony)側の実装

続いてPHP(Symfony)側の実装です。

クライアントライブラリ

まずはZipkinにトレースデータを送信するためのライブラリの導入をします。

Hoopak

PHP用のライブラリは公式には存在しなかったので。GitHubで探しました。 1つしか見つからなかったのでこちらを利用しています。 https://github.com/Jimdo/hoopak

This implementation might be incomplete and very naive

とのことでちょっとドキドキですが、期待通りに動かなければ直せばいいだけなのでそのまま使います。

Apache Thrift

HoopakはApache Thriftに依存しています。 https://thrift.apache.org/

上記URLのページからダウンロードしてきたファイルを展開すると各言語用のライブラリが入っているのでPHP用を利用します。

ソースコード

Symfonyをあまり理解せずに作っているので、使い方とか用語とか説明とか違ったらご指摘ください。

また、以下の3ファイルのrequireが必要なようなので事前にどこかでrequireしておいた方がいいです。

  • hoopak/gen-php/Scribe/Types.php
  • hoopak/gen-php/Scribe/scribe.php
  • hoopak/gen-php/Zipkin/Types.php

DI用設定ファイル(抜粋)

ExampleApp/Resources/config/services.yml

parameters:
  zipkintracer.samplerate: 0.1
  zipkintracer.service_name: webserver

services:
  zipkin_tracer:
    scope: request
    class: ExampleApp\TracerService
    arguments:
      - @request
      - %zipkintracer.service_name%
      - %zipkintracer.samplerate%
  zipkin_event_listener:
    scope: request
    class: ExampleApp\TraceEventListener
    tags:
      - { name: kernel.event_subscriber }
    arguments:
      - @zipkin_tracer
      - @kernel
      - @request

イベントリスナクラス

<?php
namespace ExampleApp;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;

/**
 * Zipkinでのトレース用のイベントリスナ
 * リクエスト開始〜レスポンス直前の時間の計測を登録する。
 **/
class TraceEventListener implements EventSubscriberInterface
{
    /**
     * コンストラクタ
     *
     * @param ExampleApp\TraceService $trace
     * @param AppKernel $kernel
     * @param Request $request
     */
    public function __construct($tracer, $kernel, $request)
    {
        $this->tracer = $tracer;
        $this->kernel = $kernel;
        $this->request = $request;
    }

    public static function getSubscribedEvents()
    {
        return array(
            'kernel.request' => 'onKernelRequest',
            'kernel.response' => 'onKernelResponse',
        );
    }

    public function onKernelRequest(GetResponseEvent $event)
    {
        $trace = $this->tracer->getTrace();
        $trace->record(\Hoopak\Annotation::serverReceive());
        $trace->record(\Hoopak\Annotation::string('server.env', $this->kernel->getEnvironment()));
        $trace->record(\Hoopak\Annotation::string('http.uri', $this->request->server->get('SCRIPT_URL')));
        $trace->record(\Hoopak\Annotation::string('http.query', json_encode($this->request->query->all())));
    }

    public function onKernelResponse(FilterResponseEvent $event)
    {
        $trace = $this->tracer->getTrace();
        $trace->record(\Hoopak\Annotation::serverSend());
    }
}

サービスコンテナクラス(イベントリスナにDIする用)

<?php
namespace ExampleApp;

require_once 'hoopak/gen-php/Scribe/Types.php';
require_once 'hoopak/gen-php/Scribe/scribe.php';
require_once 'hoopak/gen-php/Zipkin/Types.php';

/**
 * Zipkinによるトレース用クラスの初期化処理&保持する
 *
 **/
class TracerService
{
    private $_tracer;
    /**
     * コンストラクタ
     *
     * @param Request $request
     * @param float $samplerate Zipkinによるトレースをサンプリングするレートの指定
     */
    public function __construct($request, $service_name, $samplerate = 1.0)
    {
        $method = strtolower($request->server->get('REQUEST_METHOD', 'GET'));
        $tracer = new \Hoopak\ZipkinTracer(new \Hoopak\ScribeClient('localhost', 1463));
        $this->_trace = new TraceWrapper($method, null, null, null, $samplerate, array($tracer));

        $ipaddress = $request->server->get('SERVER_ADDR');
        $port = $request->server->get('SERVER_PORT');
        $this->_trace->setEndpoint(new \Hoopak\Endpoint($ipaddress, $port, $service_name));
    }

    public function getTrace()
    {
        return $this->_trace;
    }
}

足りない機能を補充するためのラッパークラス

あとで整理してhoopak側に取り込んでプルリク出したいと思います。

<?php
namespace ExampleApp;

/**
 * Hoopak\TraceのWrapクラス
 *
 **/
class TraceWrapper
{
    /**
     * @var Hoopak\Trace
     **/
    private $_trace;

    /**
     * @var boolean
     * Hoopak\Traceエラー時に次回以降処理を転送しないためのフラグ
     *
     **/
    private $_error = false;

    /**
     * @var array
     * \Hoopak\ScribeClientなどのインスタンスを持つ配列
     **/
    private $_tracer = array();

    /**
     * @var \Hoopak\Endpoint
     **/
    private $_endpoint = null;


    /**
     * トレースデータを取得するかどうか
     **/
    public $sampled = true;

    /**
     * @var array APIサーバーに送信するZipkinヘッダの対応表
     **/
    private $_headers = array(
        'traceId' => 'X-B3-TraceId',
        'parentSpanId' => 'X-B3-ParentSpanId',
        'spanId' => 'X-B3-SpanId',
        'sampled' => 'X-B3-Sampled',
        #'flags' => 'X-B3-Flags'
    );

    /**
     * コンストラクタ
     *
     * @param Hoopak\Trace
     */
    public function __construct($method, $traceId = null, $spanId = null, $parentSpanId = null, $samplerate = 1.0 , $tracers = array())
    {
        if (!$traceId) {
            $traceId = $this->_id();
        }
        if (!$spanId) {
            $spanId = $this->_id();
        }
        $this->_tracers = $tracers;
        $this->_trace = new \Hoopak\Trace($method, $traceId, $spanId, $parentSpanId, $tracers);
        if ($samplerate < 1.0) {
            $this->sampled = ($samplerate == 0) ? false : ($samplerate > (mt_rand()/mt_getrandmax()));
        }
    }


    /**
     * @see \Hoopak\Trace::record()
     * sampled=falseの時は何もしない
     **/
    public function record($annotation)
    {
        if (!$this->sampled) {
            return;
        }
        $this->__call('record', array($annotation));
    }

    /**
     * @see \Hoopak\Trace::child()
     * \Hoopak\Trace::_id()が同一IDを返却する確率が高すぎるので、使わないように処理を上書き
     **/
    public function child($name)
    {
        $samplerate = $this->sampled ? 1.0 : 0.0;
        $trace = new self($name, $this->traceId, $this->_id(), $this->spanId, $samplerate, $this->_tracers);
        $trace->setEndpoint($this->_endpoint);
        return $trace;
    }

    /**
     * @see \Hoopak\Trace::setEndpoint()
     * このクラスでもendpointを変数として保持する
     **/
    public function setEndpoint($endpoint)
    {
        $this->_endpoint = $endpoint;
        $this->__call('setEndpoint', array($endpoint));
    }


    /**
     * Hoopak\Traceに処理を移譲するためのメソッド
     * 移譲先でエラーが発生した場合は、握りつぶす
     *
     * 個別のメソッドに関しては移譲先を参照
     * @see hoopak/src/Hoopak/Trace.php
     *
     **/
    public function __call($name, $arguments)
    {
        if ($this->_error) {
            return;
        }
        try {
            return call_user_func_array(array($this->_trace, $name), $arguments);
        } catch (\Exception $e) {
            $message = "exception '" . get_class($e) . "' with message '" . $e->getMessage() ."' in " . $e->getFile() . ":" . $e->getLine();
            error_log($message);
            $this->_error = true;
        }
    }

    /**
     * \Hoopak\Traceのパブリックなインスタンス変数を取得するためのメソッド
     *
     * 個別の変数に関しては以上先を参照
     * @see hoopak/src/Hoopak/Trace.php
     **/
    public function __get($name)
    {
        return $this->_trace->$name;
    }

    /**
     * Zend\Http\Clientでのリクエストデータ、レスポンスデータを元にZipkinへのトレース登録とを行う。
     * また、リクエストヘッダにZipkinのトレース情報転送用のヘッダを付与する。
     *
     * @param string $name リクエスト送信先のサービス名称
     * @param Zend\Http\Client $client
     * @param callable $block (
     *   @param ExampleApp\TracerWrapper
     *   @return Zend\Http\Response
     * )
     * @return Zend\Http\Response
     *
     **/
    public function traceWithHTTPClient($name, $client, $block)
    {
        $tracer = $this->_createChildHTTPTracer($name, $client);
        $tracer->_prepareHTTPClient($client);
        $tracer->record(\Hoopak\Annotation::clientSend());
        $httpResponse = $block($tracer);
        $tracer->record(\Hoopak\Annotation::string('http.status', $httpResponse->getStatusCode()));
        $tracer->record(\Hoopak\Annotation::clientReceive());
        return $httpResponse;
    }


    /**
     * Zend\Http\Clientのデータを元に子トレースインスタンスを作成し返却する。
     *
     * @param string $name リクエスト先サービス名
     * @param @param Zend\Http\Client $client
     * @return ExampleApp\TracerWrapper
     *
     **/
    private function _createChildHTTPTracer($name, $client)
    {
        $uri = $client->getUri();
        $query = $client->getRequest()->getQuery();
        $method = $client->getRequest()->getMethod();

        $trace = $this->child(strtolower($method));

        $serverAddress = \Hoopak\Annotation::string('sa', '1');
        $serverAddress->endpoint = new \Hoopak\Endpoint($uri->getHost(), $uri->getPort(), $name);
        $trace->record($serverAddress);
        $trace->record(\Hoopak\Annotation::string('http.uri', $uri->getPath()));
        $trace->record(\Hoopak\Annotation::string('http.query', json_encode($query)));
        return $trace;
    }

    /**
     * Zend\Http\Clientにトレースデータ転送に必要なリクエストヘッダを付与する
     * @param Zend\Http\Client $client
     *
     **/
    private function _prepareHTTPClient(\Zend\Http\Client $client)
    {
        foreach ($this->_headers as $key => $value) {
            $headers[$value] = $this->_getStringData($key);
        }
        $client->setHeaders($headers);
    }

    /**
     *
     * @param $key string key of data
     * @return string-
     **/
    private function _getStringData($key)
    {
        $value = $this->$key;
        switch (gettype($value)) {
        case 'integer':
            $ret = sprintf("%016s", dechex($value));
            break;
        case 'boolean':
            $ret = $value ? 'true' : 'false';
            break;
        default:
            $ret = (string)$value;
            break;
        }
        return $ret;
    }

    private function _id()
    {
        return (int)round(microtime(true) * 1000 * 1000);
    }
}

WebAPI呼び出しのロギング追加

このようにWebAPIを呼び出している部分(Zend\Http\Client)があったら

$response = $client->send();

こんな感じに書き換える事でWebAPIへのリクエストのトレースが有効になります。

$tracer = $this->container->get('zipkin_tracer')->getTracer();
$response = $tracer->traceWithHTTPClient("webapi", $client, function($childTracer) use ($client) {
    return $client->send();
});

出力

以下の赤で囲った部分が今回の作業により記録されるようになります。 f:id:nextdeveloper:20160704130308p:plain

次回は、WebAPIサーバーへの導入(Ruby編)の予定です。