Pinpoint 是一个开源的 APM (Application Performance Management/应用性能管理)工具,用于监控和追踪大规模分布式系统。本文对 Pinpoint 的 PHP 客户端做了一次较深入的原理分析,后续我们将分享更多性能监控和诊断领域的技术文章。
- 正文开始 -
🔍
Pinpoint PHP Agent 利用 PHP/Zend 虚拟机提供的 Hook 机制实现,实现不修改代码就能监控 PHP 应用。该 Agent 使用 C++ 开发,各种插件除了直接写在Agent(使用C++)之外,同时也支持加载某个目录下 PHP 开发的插件,地址如下:
https://github.com/naver/pinpoint-c-agent
编译构建
首先讲一下这个 Agent 怎么使用。Clone 代码到本地后,安装必要的编译依赖,Ubuntu 环境下:
sudo apt install automake bison flex g++ git libtool make pkg-config openssl libssl-dev php-devcd pinpoint-c-agent/pinpoint_php./Build.shexport LD_LIBRARY_PATH=$PWD/../thirdlibray/var/:$LD_LIBRARY_PATHsudo make install
注意:需要安装 php-dev,提供了 phpize 用于编译 PHP 拓展。
GCC版本较高时,编译会出现问题,这是由于其默认开启了c++11标准,编译thrift使用 std::shared_ptr 而非 boost::shared_ptr。
需要修改 pinpoint_common/Makefile ,给 CFLAGS 和 CPPFLAGS 添加-DFORCE_BOOST_SMART_PTR。
接着部署 Collector,HBase 和 Web 后启动。本地部署直接使用默认配置即可,注意,单机模式的 HBase 等使用默认端口。需要 pinpoint 版本 1.8.0-RC1 及以上。
接下来,安装 PHP 环境用于测试:
sudo apt install apache2 php libapache2-mod-php
修改 PHP.ini ( /etc/php/7.0/apache2/php.ini),添加以下内容:
[pinpoint]extension=</path/to/php-7.1.0-pinpoint.so>pinpoint_agent.trace_exception=truepinpoint_agent.config_full_name=</path/to/pinpoint_agent.conf>
pinpoint动态链接库的安装地址,从上面的make install可以看到,默认是 /usr/lib/php/20151012/pinpoint.so。
修改 pinpoint_agent.conf 配置,设置collector地址,plugin目录以及日志文件等。pinpoint-c-agent/quickstart/config/pinpoint_agent.conf.example 有一个模版。配置好后,重启 apache2 服务器,访问 PHP 应用就,可以从 Pinpoint 看到监控数据了。
原理分析
Pinpiont PHP Agent和 Java Agent 一样使用 thrift 传递相同格式的 Trace 数据,本篇原理主要涉及:
PHP 方法拦截原理;
插件实现原理;
Agent 与 Zend 虚拟机交互。
从 PHP/Zend 虚拟机谈起
PHP 分为接口层、语言层、虚拟机三个部分,分别对应 SAPI、PHP语言、Zend虚拟机。
SAPI (Server Application Programming Interface) 是各个服务器抽象层之间的约定,提供PHP的入口,脚本的执行都是从这里开始的。比如CLI是命令行入口,通常我们执行php main.php就是调 CLI 模块的 main 方法,同时它提供函数指针的方式提供了一些基本的功能,比如对于无缓存的写调用的是 CLI 提供的 fwrite。
apache2handler 是 Apache 服务器上运行的入口,它提供了和 Apache 服务器交互的功能,比如对于无缓存的写调用的 Apache 提供的 rwrite,而 PHP 的全局变量如 GET,GET,_SERVER 都是在该模块初始化的时候设置的。SAPI相当于一扇大门。

语言层,PHP 利用 re2c 和 bison 完成对源文件的词法分析和预发分析,生成 opcode 数组提供给 Zend 虚拟机执行,为了提高效率还有对应的缓存。每个 opcode 都有对应的 handler 函数供虚拟机调用,执行完成的结果通过 SAPI 返回给终端或者服务器。
PHP 的整体生命周期如下:

当 PHP 应用开始执行时,调用各个拓展模块的模块初始化函数(MINIT),当有请求过来时,调用每一个模块的请求初始化函数(RINIT),之后执行请求的PHP文件,执行完成后,调用请求清理函数(RSHUTDOWN)后执行清理,随后调用模块清理函数(MSHUTDOWN)之后结束执行。
这里只是大致描述,想要深入了解PHP内核从下方链接获取更多信息。由于 PHP Agent 与 PHP 内核密不可分,涉及到 Agent 原理的将在后面提及。
http://www.php-internals.com/book/?p=index
Agent插桩原理
http://www.php-internals.com/book/?p=index
该文件是 PHP pinpoint 拓展的接口,里面定义了 zend_module_entry 结构体:

PHP 扩展通过 zend_module_entry 这个结构来表示,此结构定义了扩展的全部信息:扩展名、扩展版本、扩展提供的函数列表以及 PHP 四个执行阶段的 Hook 函数等,每一个扩展都需要定义一个此结构的变量,而且这个变量的名称格式必须是:{mudule_name}_module_entry,内核正是通过这个结构获取到扩展提供的功能的。其中 PHP_MINT 等是 C 语言宏定义这里不再展开。
分析 PHP_MINIT_FUNCTION(pinpoint) 函数:
1. 首先完成一些全局变量初始化以及模块ini配置文件读取相关的工作。之后读取在php.ini文件中设置的pinpiont-agent.config文件。此时Agent状态为 AGENT_CREATED
2. 创建 Agent 并preInit(),完成诸如日志等初始化、Context 初始化、设置 ApiDataManager 和 StringDataManager,这两者用于 Trace 信息记录。此时Agent状态为 AGENT_PRE_INITED
3. 初始化 AOP 拦截功能,init_aop()完成 InterceptorManagerPtr 初始化,并生成 PhpAop 对象。此时AOP拦截函数并未运行
4. 打开 AOP 功能,turn_on_aop()是重点,它将 Zend 虚拟机中执行PHP函数的函数替换成自定义的函数,hacker 了 Zend 虚拟机。

zend_execute_ex 是虚拟机执行用户定义函数的opcode处理函数,zend_execute_internal是Zend处理内部函数的 opcode handler function。实际上PHP区分方法和函数,这里统称函数。
如果需要捕获异常,则替换掉Zend的zend_throw_exception_hook函数。接下来替换掉的zend_error_cb函数是Zend 的默认 error handler function。
替换完之后,
"RunOriginExecute::isRunning=1",沿用之前 Zend 的 opcode handler 逻辑,此时is_aop_turn_on为false。
接下来看PHP_RINIT_FUNCTION(pinpoint)函数:
递增请求计数器,设置全局变量,获得 Agent 指针;
如果 AOP 功能为 false,并且 Agent 状态为 AGENT_STARTED 则打开 AOP。
如果 Agent 状态为 AGENT_PRE_INITED,则加载 Plugins 目录下的 PHP 插件(通过zend_file_handle)并且开启另外的线程启动 Agent。主要完成 C++ Plugins 和 PHP Plugins 的注册。

接下来处理 HTTP 请求 Headers ,给每一个请求分配 CallId ,接下来调用 PhpRequestInterceptor 的 before 方法。这个是请求级别的拦截器,还有一个是 PhpInternalFunctionsPlugin ,这俩插件是 C++ 实现的,其余是 PHP 实现的。
再看PHP_RSHUTDOWN_FUNCTION(pinpoint),实际上它的处理和 RINIT 类似,但是调用的是 end 方法,记录一次请求结束相关的信息。这里也处理了 HTTP Headers ,关于具体透传的细节在这里不深入。
PHP_MSHUTDOWN_FUNCTION(pinpoint)函数比较简单,首先等异步启动Agent的线程执行完毕,接下来agentPtr->stop()停止 Agent,关闭AOP功能。turn_off_aop()代码如下:

可以看到,它将 pinpiont 修改的 opcode handler 替换成 Zend 默认的handler。
接下来看 pinpoint 修改的 opcode handler,以pp_execute_ex为例,它首先判断是否执行旧的 handler。

如果不执行旧的 handler,则调用 pp_execute_plugin_core,这个函数的定义也在 aop_hook.cpp文件中,这个函数比价长,并且对 PHP 的版本做了适配。核心部分如下:
通过frame_build函数获得调用的栈帧,从中获取调用参数等。接下来调用 aop->getInterceptorPtr(frame.fullname)获取该方法的拦截器,其中 frame.fullname是方法的全名。

通过调用关系 :
this->getInterceptorPtr(funcName.c_str()); -> this->interceptorManagerPtr->find(funcName);(class PhpAop)
可以看出,插件最终在下面类定义的map中保存。

接下了来的事情就是分析调用栈,记录当前方法调用信息,调用 Interceptor 的before 方法。然后使用 Zend 默认的 handler 完成方法调用后,获取返回值,调用 Interceptor 的 end 方法。释放 frame 等。这样就完成了一次调用的记录。
基本原理总结:
插桩通过 PHP/Zend 拓展,通过 Hook 拦截其生命周期,修改Zend虚拟机的 opcode handler function,将所有待执行函数的 handler 封装一层,如果拦截器存在(方法名+拦截器组成一个map进行find)则让 PHP 函数执行前调用 before 方法,执行后调用 end 方法。 实际上和 Java Agent 原理基本类似,只不过是 Java 使用了 JVM 提供的 Instrumentation ,PHP 使用了 Zend 提供的 Hook 。
使用PHP编写插件原理
前面提到过,除了使用 C++ 实现插件(拦截器)外,还可以使用PHP实现。Pinpoint 在 Zend 虚拟机定义了 \Pinpoint\Interceptor 类。例如:
pinpiont-c-agent/quickstart/php/web/plugins/curl_plugin.php:

通过继承这个类,并且在plugin的入口PHP文件(pinpoint_agent.config中定义)中:
$p = new CurlPlugin();pinpoint_add_plugin($p, "curl_plugin.php");
即可(参考plugins_create.php)。
实际上这个类定义在 php_interfaces.cpp中,pinpoint_add_plugin也定义在这里。两者都是通过

这个 zend_function_entry结构体注册给 Zend 虚拟机。它们的实现中调用了许多Zend 的函数,这里不做过多解释。总结起来,就是通过 Zend 虚拟机的接口,将 Pinpoint CPP 功能暴露给 PHP ,使得后者能够拦截方法调用。
本文作者:
垆皓,阿里云开发工程师,专注于应用性能监控、诊断等领域,负责 ARMS 云产品Java 和 PHP 等语言探针的开发。
本文缩略图:icon by 宅小达
/ 点击下方图片,报名参加 /
©每周一推
第一时间获得下期分享
☟☟☟

Tips:
# 点下“在看”❤️
# 然后,公众号对话框内发送“大蒜”,试试手气?😆
# 本期奖品是来自淘宝心选的锌合金家用压蒜器。


