SkyWalking PHP 内核代码剖析

一、总体流程

PHP 重要几个生命周期说明,先后顺序为 PHP_MI、PHP_RI、PHP_EXECUTE、PHP_RS、PHP_MS,每个模块的作用如下

  • PHP_MI,模块初始化阶段,主要进行 PHP 框架、Zend 引擎的初始化工作。重要的几个工作如下:
    • 全局状态信息的初始化,如 SG、CG、EG等
    • 启动 Zend 引擎,内存池启动、注册虚拟机的各项执行句柄
    • 解析 php.ini 配置文件
    • 注册拓展,包括静态编译拓展和动态编译拓展
    • 回调拓展定义的 MI 函数,即 PHP_MINIT_FUNCTION
  • PHP_RI,请求初始化阶段,CLI 模式下,该函数执行一次。如果是 php-fpm 模式下,会在 PHP_RI 和 PHP_RS 之间循环。该阶段需要关注的有如下事宜:
    • 激活 zend 引擎,包括:重置垃圾回收器、初始化编译器、初始化执行器、初始化词法扫描器
    • 回调各拓展定义的 RI 函数,即 PHP_RINIT_FUNCTION
  • PHP_EXECUTE,脚本执行阶段。通过拦截 zend 引擎的 execute 函数,我们可以捕获用户执行的每一条语句,我们可以在此阶段,进行 MySQL、Redis、CURL等代码的捕获,从而生成对应的 span,进而构建 trace。
  • PHP_RS,请求关闭阶段。该阶段主要进行请求资源的释放动作,同时这个是 fpm 请求的最后一个阶段,我们可以在此阶段,把本次请求获取的 segment 信息上报至 sidecar
  • PHP_MS,模块关闭阶段。各项资源的释放

SkyWalking PHP 内核的主要处理流程整理如下:

image-20210118185219149

二、PHP_MI 阶段,自定义函数执行器替换Zend内核执行器

image-20210118191216694

该阶段的入口函数 PHP_MINIT_FUNCTION (skywalking)

2.1 代码命名约定

该阶段主要是进行 Zend 执行器的 assign 动作,变量的命名有如下规则:

  • ori_ 打头的,是 Zend 引擎的原函数,这里做备份,便于 hack 后,恢复原来的执行
  • zend_ 打头的,就是 Zend 引擎的内置函数
  • sky_ 打头的,是我们计划在对应阶段进行的自定义动作。一般在自定义动作的最后,都会使用 ori_ 来交还函数控制权,恢复原来正常流程函数的执行动作

部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
PHP_MINIT_FUNCTION (skywalking) {
// .....

// 用户自定义函数执行器(php脚本定义的类、函数)
ori_execute_ex = zend_execute_ex;
zend_execute_ex = sky_execute_ex;

// 内部函数执行器(c语言定义的类、函数)
ori_execute_internal = zend_execute_internal;
zend_execute_internal = sky_execute_internal;

// ......
}

2.2 拦截的函数分类和意义

其拦截的函数主要分三类:

  • zend_execute_ex,拦截用户态函数,即我们平时写的 .php 文件里面的代码。这里可以捕获到 class name 类名、function name 函数名
  • zend_execute_internal,拦截 PHP 内置的函数和类等,比如 PDO、mysqli 等
  • CURL相关的函数句柄,这样我们就可以捕获函数的上下游 http 调用信息。其拦截的函数包括:curl_exec、curl_setopt、curl_setopt_array、curl_close

三、PHP_RI 阶段,请求初始化,注册 sky-agent,构造原始 segment

入口函数 PHP_RINIT_FUNCTION(skywalking)

3.1 主要流程

PHP_RI 阶段在每一个 fpm 请求时都会触发一次,在此阶段,主要进行以下两件事

  • static int sky_register(),通过 unix sock 通信,注册 agent,同时根据返回的握手信息来确定 app、service、instance信息
  • static void request_init() ,构造 segement 信息,这里包含了以下重要信息
    • 生成 traceId
    • 根据 header 的 sw8 字段来解析上游信息,进而构造 span 信心,refs 信息

3.2 traceId 生成规则,三段格式 instance.pid.second

其核心代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
static void generate_context() {
int sys_pid = getpid();
long second = get_second();
second = second * 10000 + sky_increment_id; //创建traceid的因子
char *makeTraceId;
makeTraceId = (char *) emalloc(sizeof(char) * 180); //分配traceId所需要的内存

bzero(makeTraceId, sizeof(char) * 180);

sprintf(makeTraceId, "%d.%d.%ld", application_instance, sys_pid, second);

// .....
}

其中 sky_increment_id 是 0~9999。MSP 平台中显示的 TraceId 信息,类似如下:

image-20210118194441556

我们来简短分析下,按点「.」分割

  • 1。因为在 sky_register 阶段,application_instance 固定为 1
  • 393,即 pid
  • 16109522148050 这个就是时间戳 + sky_increment_id 构成的了

3.3 header 里的 HTTP_SW8 数据含义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static void generate_context() {
// .......
// 获取 header HTTP_SW8 信息
sw = zend_hash_str_find(Z_ARRVAL_P(carrier), "HTTP_SW8", sizeof("HTTP_SW8") - 1); //$SERVER['HTTP_SW8'];

// .......

// 按中横线(-)分割为 sw8_N 数组
php_explode(zend_string_init(ZEND_STRL("-"), 0), Z_STR_P(sw), &temp, 10);

// ......
// 对分割后的数组,进行解码,其 index 为 1,2,4,5,6,7
zval_b64_decode(&sw8_1decode, Z_STRVAL_P(sw8_1));
zval_b64_decode(&sw8_2decode, Z_STRVAL_P(sw8_2));
zval_b64_decode(&sw8_4decode, Z_STRVAL_P(sw8_4));
zval_b64_decode(&sw8_5decode, Z_STRVAL_P(sw8_5));
zval_b64_decode(&sw8_6decode, Z_STRVAL_P(sw8_6));
zval_b64_decode(&sw8_7decode, Z_STRVAL_P(sw8_7));

// .......
}

我们去网关日志里,取一个样本分析,得到的原始 header 信息如下

1
1-QzBBODNDMzEtMTYxMDk1MzUyODM0MC0xMTc3OTctQS0xMjcw-QzBBODNDMzEtMTYxMDk1MzUyODM0MC0xMTc3OTctQS0xMjcw-1-xxxxxxxxxxxx-YjcyOWU0MzUtNjA5Zi00YzMwLWI4MjctNjZmMmUyYWZjNmM2-xxxxxxxxxx==-xxxxx.service

我们按中横线(-)分割,得到

1
2
3
4
5
6
7
8
1
QzBBODNDMzEtMTYxMDk1MzUyODM0MC0xMTc3OTctQS0xMjcw // Trace Id base64
QzBBODNDMzEtMTYxMDk1MzUyODM0MC0xMTc3OTctQS0xMjcw // Parent trace segment Id
1 // Parent span Id
xxxxxxxxxxxx // Parent service
YjcyOWU0MzUtNjA5Zi00YzMwLWI4MjctNjZmMmUyYWZjNmM2 // Parent service instance
xxxxxxxxxx== // Parent endpoint
xxxxx.service // Target address used at client side of this request

我们对其中 1,2,4,5,6,7 进行 base64 解码,得到

1
2
3
4
5
6
7
8
1
C0A83C31-1610953528340-117797-A-1270 // Trace Id base64
C0A83C31-1610953528340-117797-A-1270 // Parent trace segment Id
1 // Parent span Id
xxxxxxxxxxxxx // Parent service
b729e435-609f-4c30-b827-66f2e2afc6c6 // Parent service instance
xxxxxxxxxxxxxxxxxxxxxxxxx // Parent endpoint
xxxxxxxxxx.service // Target address used at client side of this request

3.4 RI 周期主要流程如下

lifecycle

四、PHP_EXECUTE 阶段,拦截代码执行语句,分析后恢复执行

image-20210118193135548

在 MI 阶段,我们替换了 zend 引擎的函数执行指向,所以所有语句的执行会被我们接管。我们在执行完自己需要的动作后,还原原来的执行即可。

需要注意的是,我们接管的函数会被多次触发,每执行一条 opline,就会被触发一次(存疑,待指正)。

4.1 ZEND_API void sky_execute_ex(zend_execute_data *execute_data)

核心的流程如下

  1. 获取当前代码执行的信息,包括:类名、函数名
  2. 对类名和函数名进行判断,看是否需要拦截。目前拦截的是 Predis SDK
  3. 如果需要拦截,根据拦截信息构造 span
  4. 把构造好的 span 插入到当前 segment 的 spans 数组里
  5. 恢复函数原来的执行,调用 ori_ 即可

image-20210118200055531

4.2 ZEND_API void sky_execute_internal(zend_execute_data execute_data, zval return_value)

其核心流程和上面 4.1 分析的类似,其不同就在于这里拦截的是 PHP 内置的一些类,即编译时就安装的类。流程主要包括

  1. 获取类名和函数名
  2. 根据类名判断是否需要拦截,目前需要拦截:PDO、PDOStatement、mysqli、Yar_Client、Reids、Memecached
  3. 构造 span,插入到当前 segment 的 spans 数组里
  4. 恢复函数执行

image-20210118200231152

4.3 CURL HOOK

这里主要是调用链的传递,如果发现有 curl 请求,则根据规则生成当前的 sw8 信息,塞到 http header 的 sw8 字段里,传递给下游。

更多的流程,以后补充。

image-20210118200521291

五、PHP_RS 请求结束阶段,发送 segment 信息至 sidecar

PHP_RS 阶段主要做两件事儿:

  1. 通过 unix sock 发送本请求构建的 segment 信息至 sidecar

  2. 释放当前请求里的全局状态存储。由于其他的 int、char、boolean 类型的不涉及到内存管理,所以就是四个 zval 的释放

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    ZEND_BEGIN_MODULE_GLOBALS(skywalking)
    char *sock_path;
    char *app_code; //app_name eg:skywalking.app_code = MyProjectName
    char *app_code_env_key; //app_name 环境变量地址:环境变量->默认KEY:APM_APP_CODE
    zend_bool enable;
    zval UpstreamSegment; //全局上报数据段
    zval context;
    zval curl_header; //curl header数据
    zval curl_header_send; //记录当前R周期 是否已经send过curl_header
    int version;
    ZEND_END_MODULE_GLOBALS(skywalking)

image-20210118200709516

六、PHP_MS 模块结束阶段

这个阶段没啥好说的,没啥特殊操作

七、全景,整个大脑图

lifecycle2