cacti用于监控系统的各项运行指标,提示了操作界面和图表,是一个整合工具集,它完成两个核心任务:
1,指标数据的获取。
2,将数据通过数图进行展示。
在cacti中,图表的绘制、图表数据的存储是通过rrdtool工具实现的,《RRDtool简体中文教程》对rrdtool工具进行了介绍,是很好的资料。
获取数据的途径,视目标数据暴露的方式而定,如:
网卡数据流量、系统负载等数据已通过SNMP标准化,使用SNMP方式获取。
如果要监控nginx(stub_status)数据,该项数据是通过http方式暴露,需使用http获取数据。
如果要监控mysql-server(show status)数据,需要可以连接到mysql服务器,并有权限打印数据。
作为参照比较,nagios提供了check_by_ssh nrpe NSCA几种标准化、易于扩展的机制。如果要整合nagios和cacti的话,在获取数据层面,应该可以实施。如果有net-snmp开发能力,也可以把nginx、mysql-server的数据通过SNMP的方式暴露。
下面开始分析cacti“获取数据”部分的功能实现细节。
通过观察日志,可以对分析提供帮助。
打开日志方式:在cacti的"console" - "General" - "Poller Logging Level",需设置 >= POLLER_VERBOSITY_DEBUG
在我们配置cacti的过程中,会提到要求配置以下的计划任务,我们就从这个脚本开始。
*/5 * * * * /PATH/TO/php /PATH/TO/cacti/poller.php > /dev/null 2>&1
poller.php
---------------
根据cacti界面设置的参数,确定以何种方式获取数据
1,当计划任务启动poller.php进程后,这个进程的生存周期内执行几次数据轮询。
2,一次数据轮询使用几个进程(cmd进程)同时进行数据获取,如何分配任务给cmd进程。
3,cmd进程使用何种实现方式获取数据。
4,将cmd进程获取的数据解析后存入rrd。
1,当计划任务启动poller.php进程后,这个进程的生存周期内执行几次数据轮询。
涉及 $cron_interval,$poller_interval 两项参数
比如cron的周期是5分钟,poller周期是1分钟,则cron触发的poller.php进程,要负责安排5次数据轮询,以满足poller粒度。
$poller_runs = intval($cron_interval / $poller_interval);
poller.php进程的最大运行时间(比计划任务周期少2秒)
define("MAX_POLLER_RUNTIME", $poller_runs * $poller_interval - 2);
2,一次数据轮询使用几个进程(cmd进程)同时进行数据获取,如何分配任务给cmd进程。
这里先解释一下cmd进程,为了提高轮询数据的效率,cacti允许使用多个进程同时进行获取数据的工作,每个进程负责一定数量的host,因为这些进程运行cmd.php(默认是cmd.php,也可以是spine)的上下文,所取这个名称以方便描述。
$concurrent_processes = 2;
系统默认值为1,即只会使用一个进程负责所有主机的数据轮询,我们讨论 >= 2的场景。
cacti设置界面有如下说明,大意是说,当cmd进程使用cmd.php抓取数据时,可以通过增加这个参数,提高性能(多进程模型);当cmd进程使用spine执行数据抓取时,应该通过增加“Maximum Threads per Process”的值,提升性能(多线程模型)。
The number of concurrent processes to execute. Using a higher number when using cmd.php will improve performance. Performance improvements in spine are best resolved with the threads parameter
决定cmd进程数量的另一个因素是,cacti会查询当前有多少个任务(即cacti管理界面中Data Sources页面显示的条目内容),只有每个cmd进程都可以分配到至少一个任务时,才会启用。
cmd进程任务分配。
1,分配是在host粒度上进行的。即只分配host编号,该host上的所有子数据项都在一个进程中完成。
2,poller.php遍历所有的host,并累计任务数,当当前累计任务数 >= $items_per_process值(每个cmd进程平均完成任务数)时,就生成一个cmd进程,负责完成累计范围内的host的数据获取任务。
例如有如下任务
host1 2
host2 3
host3 3
如果$concurrent_processes = 2, $items_per_process = 8 / 2 = 4;
按主机任务进行累计和分配
累计host1 $items_launched = 2;
累计host2 $items_launched = 5; 此时累计任务数 >= $items_per_process; 派生一个cmd.php后台进程,并将开始host(1)和结束host(2)的id传递给这个进程,如下的调用。
exec_background('/PATH/TO/php', "-q /PATH/TO/cacti/cmd.php 1 2");
poller.php的代码如下:
exec_background($command_string, "$extra_args $first_host $last_host");
exec_background的实现,主要由下面这条语句实现。
exec($filename . ' ' . $args . ' > /dev/null &');
关于exec,手册有如下说明,所以' > /dev/null &'很重要。
如果程序使用此函数启动,为了能保持在后台运行,此程序必须将输出重定向到文件或其它输出流。 否则会导致 PHP 挂起,直至程序执行结束。
通过查看php的实现,exec、system、passthru都是基于php_exec的。
流程运行至此,获取数据的任务就交给cmd进程在后台运行了,cmd进程的实现,后面再分析。
3,cmd进程使用何种实现方式获取数据。
cmd进程有两个实现 cmd.php spine。
cmd.php是一个php脚本,通过php解析运行。
spine,不是很了解。猜想它是一个可以访问系统调用、线程模型的可执行文件。它实现与cmd.php相同的功能。
cacti默认使用的是cmd.php,我们只分析这种方式。
cmd进程将指定host的数据项获取后,保存至数据库中(poller_output表),这里存储的数据是暂时性的,用于绘图的数据最终需要存储在rrd文件中。
4,将cmd进程获取的数据解析后存入rrd。
poller.php分配完后台cmd进程后,自已则开始检测后台cmd进程的处理结果。检测方式为:通过循环轮询数据库(poller_output表),如果没有完成的条目,执行usleep(500);如果有完成的条目,将数据通过rrdtool工具,写入到rdd文件,写入过程实现如下:
1)$rrdtool_pipe = rrd_init();
rrd_init()主要使用下面的代码实现:
$command = "/usr/bin/rrdtool - > /dev/null 2>&1";
popen($command, "w");
2)rrdtool_function_update($command_line, $rrdtool_pipe);
rrdtool_function_update()主要使用下面的代码实现:
fwrite($rrdtool_pipe, escape_command(" $command_line") . "\r\n")
fflush($rrdtool_pipe);
3)rrd_close($rrdtool_pipe);
如果成功完成的进程(end_time)数 >= 启动的后台进程数,则视为处理完成。
/* process poller commands */
exec_background($command_string, "$extra_args");
/PATH/TO/php -q /PATH/TO/cacti/poller_commands.php
cmd.php
---------------
接收两个参数 $first_host $last_host,脚本通过$_SERVER["argv"][1],$_SERVER["argv"][2] 获取,取出给定host的poller_items(cacti管理界面中Data Souries)记录。
如果没有提供参数($_SERVER["argc"] == 1),脚本会从数据库中取最近需要轮询数据的host。
下面是数据轮询流程的伪码。
$ping = new Net_Ping; foreach ($polling_items as $item) { if (($new_host) && (!empty($host_id))) { /* perform the appropriate ping check of the host */ /* 如果是一个新host,cmd进程会先检测host是否可到达。 */ if ($ping->ping(... ...) { /* up or down*/ } } /* 根据目标数据暴露的方式,提供了3种获取数据的方式 */ if (!$host_down) { switch ($item["action"]) { case POLLER_ACTION_SNMP: /* snmp */ case POLLER_ACTION_SCRIPT: /* script (popen) */ case POLLER_ACTION_SCRIPT_PHP: /* script (php script server) */ default: } } }
下面来看3种获取数据的实现
POLLER_ACTION_SNMP
即通过snmp协议获取目标数据,需提供commuinty、OID等参数,如果启用了php_snmp扩展,可使用扩展提供了snmp函数获取数据;如果没有php_snmp扩展,可以通过php脚本调用net-snmp包提供的命令行工具来获取数据;或者用php的socket自己实现snmp协议也可以,以下是一些OID。
host信息
snmptranslate -Td -Of 1.3.6.1.2.1.25
.iso.org.dod.internet.mgmt.mib-2.host
网卡流量信息
RFC1213-MIB::ifDescr
RFC1213-MIB::ifInOctets
RFC1213-MIB::ifOutOctets
POLLER_ACTION_SCRIPT,
主要由下面的调用语句实现,是基于popen实现的
$output = trim(exec_poll($command));
exec_poll()主要通过下面的调用实现:
$fp = popen($command, "r"); $output = fgets($fp, 8192);
POLLER_ACTION_SCRIPT_PHP
主要由下面语句调用,是基于proc_open实现的。这种方式通过一个可双向通信的子进程(运行script_server.php上下文,后面均称ss进程),相互配合完成任务。这种方式可以复用子进程,因此会减少一些系统开销。ss进程通过下面的调用初始化。
$cactiphp = proc_open("/PATH/TO/php -q /PATH/TO/cacti/script_server.php cmd", $cactides, $pipes);
关于proc_open的参数,作一些笔记,它表现如下的行为:
它派生(fork)一个子进程,子进程使用$cactides(参数2)指定的方式,初始化自己已打开的文件描述符表。如
cactides = array( 0 => array("pipe", "r"), // stdin is a pipe that the child will read from 1 => array("pipe", "w"), // stdout is a pipe that the child will write to 2 => array("file", "/tmp/error-output.txt", "a"), // stderr is a file to write to 3 => array("file", "/PATH/TO/file", 'rw'), );
该参数(参数2)是一个索引数组,其中0,1,2三个索引位置已经固定分配(0 is stdin, 1 is stdout, while 2 is stderr),其它索引位置可以是任何有效的文件描述符编号。
索引数组的元素也需要是一个数组,其元素有以下约定。
第一个元素是文件描述符的类型,有两种类型可供选择:pipe和file。第二个元素的语义依赖于第一个元素,如果是pipe,第二个元素是文件描述符对应到管道的哪一端,"r"为读端,"w"为写端;管道的另一端将通过$pipes(参数3)返回。管道常用作进程间通信,它是半双工的,数据只能向一个方向流动,即会有只能“写”数据的一端和只能“读”数据的一端。如果是file,第二个元素值是打开文件的路径,第三个元素是打开文件的模式;
在上例中,ss进程的文件描述符0,对应到一个管道(pipe)的读端,描述符1对应到一个管道的写端,描述符2、3都对应到一个文件。$pipes(参数3)是一个数组,$pipes[0]对应描述符0(stdin)的管道的写端,$pipes[1]对应描述符1(stdout)的管道的读端。
文件描述符处理完成后,ss进程运行(exe函数簇)$cmd(第一个参数)程序的上下文,这里是(script_server.php)。进程上下文的替换不影响描述符表。
cmd进程生成ss进程后,在$pipes[1](对应ss进程的stdout)读取子进程的输出,以判定子进程是否已经成功启动。
$output = fgets($pipes[1], 1024); substr_count($output, "Started") != 0
fgets会阻塞,直到从$pipes[1]中读取足够数据、或者EOF、或者cmd进程收到一个signal。然后在输出字串中查找"Started"串。
ss进程启动后,会在stdout输出下面的内容(包含“Started”串),以告知cmd进程,自己已经准备好了。
fputs(STDOUT, "PHP Script Server has Started - Parent is " . $environ . "\n");
然后ss进程阻塞在“读取stdin”上,等待cmd进程的指令
/* process waits for input and then calls functions as required */ while (1) { ... ... $input_string = fgets(STDIN, 1024); ... ... }
至此,cmd进程和ss进程,已完成协调工作的准备工作。当cmd进程需要ss进程配合获取数据时,cmd进程就往ss进程的stdin写命令,ss进程完成后,将结果写入stdout以返回给cmd进程。下面是调用代码:
$output = trim(str_replace("\n", "", exec_poll_php($item["arg1"], $using_proc_function, $pipes, $cactiphp)));
其中exec_poll_php的主要实现语句如下:
function exec_poll_php($command, $using_proc_function, $pipes, $proc_fd) { ... ... fwrite($pipes[0], $command . "\r\n"); $output = fgets($pipes[1], 8192); ... ... return $output; }
cmd进程的分析就到这里,下面我们来看一下ss进程的实现。
script_server.php
---------------
上文提到,ss进程初始完成后,便阻塞于 fgets(STDIN, 1024);以等待cmd进程的命令,下面我们就来看一下,ss进程从stdin获取命令后的流程实现。
$input_string = fgets(STDIN, 1024); /* 如果命令的前4个字符是“quit”,ss进程执行退出 */ if (substr($input_string,0,4) == "quit") { exit(1); } /* pull off the parameters */ $i = 0; while ( true ) { /* 查找命令中的空格,以切分命令参数 */ $pos = strpos($input_string, " "); if ($pos > 0) { switch ($i) { case 0: /* cut off include file as first part of input string and keep rest for further parsing */ $include_file = trim(substr($input_string,0,$pos)); $input_string = trim(strchr($input_string, " ")) . " "; break; case 1: /* cut off function as second part of input string and keep rest for further parsing */ $function = trim(substr($input_string,0,$pos)); $input_string = trim(strchr($input_string, " ")) . " "; break; case 2: /* take the rest as parameter(s) to the function stripped off previously */ $parameters = trim($input_string); break 2; } }else{ break; } $i++; }
上面的代码显示,传递给ss进程的命令串以下面的格式组织:
include_file<空格>function名<空格>parameters串
include_file 的路径为/PATH/TO/cacti/scripts/include_file
function 名字为include_file文件中定义的函数名。
parameters 为function的参数列表。
通过这个机制,我们可以扩展新的获取数据的方法。
至此,获取数据流程实现就分析完了。平常用php写网页比较多一些,基本是与数据库打交道,不会涉及到进程层面的处理,cacti的实现恰好在把php用作shell方面给我一个学习的样例。学习过程中,涉及到了操作系统的一些知识,如系统调用、进程间通信、IO阻塞等;有时候会感叹:“哦,原来php是这样来实现这个功能的”。
感谢cacti、感谢开源。