cacti のデータを変換して zabbix にインポートする

cacti がグラフを作成するための蓄積データは rrd ファイルになっています。このファイルを zabbix_sender が読める型式に変換して zabbix に取り込んでしまおう……というツールを書きました。本家にもその方法があったりするんですが、ここに乗ってる shell スクリプトはマトモに動かないので……

なお、この記事にあるスクリプトや手順によって発生したいかなる損害についても筆者が責任を負うことはできません。もし試してみる場合は自己責任でお願いしますね。できるなら検証環境を作って十分にテストしてください。バックアップも忘れずに!

……あとスクリプトがおかしな動きをするようなら、直して使ってくださいw

ものすごく雑ですが perl でコンバーターを書きました。ちょっと長いです。

cacti2zabbix
#!/usr/bin/perl
################################################################################
# cacti2zabbix converter
############################################# vim: set tabstop=4 filetype=perl #
use strict;
use warnings;
 
my $KEY_CF_AVERAGE = 'AVERAGE';
 
my $FILENAME_PROHIBITED_CHARS = '\s\'"#|;:@`\[\]\$\%\&\~\?\{\}\+\*\/\.\\\\';
 
 
 
################################################################################
# Prototypes
################################################################################
 
sub walk_rrd($);
sub walk_ds($);
sub walk_rra($);
sub walk_database($);
 
sub prompt($);
sub trim($);
 
# Print information message.
sub info($);
 
# Print warning message.
sub warning($);
 
# Print error message.
sub error($);
 
# Print Usage.
sub usage($);
 
################################################################################
# Main
################################################################################
 
##
# Check args.
#
if ($#ARGV != 0) {
	usage('Invalid number of arguments.');
	exit 1;
}
 
my $infile = shift(@ARGV);
 
if (!(-f $infile)) {
	error('"${infile}"??');
	exit 2;
}
 
##
# Load rrd-xml (rrdtool dump xxx.rrd > xxx.xml)
#
my $fp_in;
 
if (!open($fp_in, '<', $infile)) {
	error("Cannot open file: '${infile}'.");
	exit 3;
}
 
info("Start load '${infile}'.");
 
my $rrd;
 
while (my $line = <$fp_in>) {
	if ($line =~ m|^\s*<rrd>|o) {
		$rrd = walk_rrd($fp_in);
	}
}
 
 
close($fp_in);
 
if (! $rrd) {
	error('Conversion failed.');
	exit 4;
}
 
 
##
# Generate file for zabbix-sender.
#
my $hostname;
 
until ($hostname = prompt('hostname ?')) {
	warning('Hostname is mandatory parameter.');
}
 
my $idx = 0;
foreach my $ds (@{$rrd->{'dss'}}) {
 
	my $itemkey = prompt("itemkey of ds:${ds} ?");
 
	if (! $itemkey) {
		$idx++;
		next;
	}
 
	$itemkey =~ s/"/\\"/g;
 
	my $data_format = prompt(
		"data format string for 'sprintf' (eg : %s, %d, %f ...) ?");
 
	# numeric(16,4) on postgres.
	$data_format = '%.4f' if ($data_format eq '%f');
 
	eval {
		my $test = sprintf($data_format, 1);
	};
 
	if ($@) {
		error($@);
		next;
	}
 
	my $outfile = "${hostname}_${itemkey}";
	$outfile =~ s/[${FILENAME_PROHIBITED_CHARS}]/_/og;
	$outfile =~ s/_{2,}/_/g;
	$outfile .= '.zbx';
 
	info("Set file name to '${outfile}'.");
	info("Start process to collect data.");
 
	my %datas = ();
	my $min_pdp_per_row = 999999;
 
	foreach my $rra (@{$rrd->{'rras'}}) {
 
		next if ($rra->{'cf'} ne $KEY_CF_AVERAGE);
 
		my $pdp_per_row = $rra->{'pdp_per_row'};
		my $overwrite = 0;
 
		if ($pdp_per_row < $min_pdp_per_row) {
			$min_pdp_per_row = $pdp_per_row;
			$overwrite = 1;
		}
 
		foreach my $data (@{$rra->{'database'}}) {
 
			next if (
				exists($datas{$data->{'unix_ts'}}) &&
				!($overwrite)
			);
			$datas{$data->{'unix_ts'}} = ${$data->{'values'}}[$idx];
		}
	}
 
	info("Start sort and output zabbix-data to '${outfile}'.");
 
	my $fp_out;
 
	if (!open($fp_out, '>', $outfile)) {
		error("Cannot open '${outfile}' for write.");
		next;
	}
 
	foreach my $unix_ts (sort keys %datas) {
		print $fp_out
			"\"${hostname}\" ",
			"\"${itemkey}\" ",
			"${unix_ts} ",
			sprintf($data_format, $datas{$unix_ts}), "\n";
	}
 
	info("Finished to convert '${ds}' to '${itemkey}'.");
}
 
 
exit 0;
 
################################################################################
# Sub routines
################################################################################
 
sub walk_rrd($) {
 
	my $fp = shift(@_);
	my @dss;
	my @rras;
	my $step = 300;
 
	while (my $line = <$fp>) {
 
		if ($line =~ m|^\s*</rrd>|o) {
			return {
				'dss' => \@dss,
				'rras' => \@rras,
			};
		}
 
 
 
		if ($line =~ m|^\s*<step>(\d+)</step>|) {
			$step = int($1);
		}
 
		if ($line =~ m|^\s*<ds>|o) {
 
			my $child = walk_ds($fp);
 
			if (! $child) {
				error('Syntax error: <ds> not closed.');
				return undef;
			}
 
			if ($child->{'name'}) {
				push(@dss, trim($child->{'name'}));
				info("Found <ds> named '${dss[$#dss]}'.");
			} else {
				error('<ds> not contains <name>.');
				return undef;
			}
		} 
 
		if ($line =~ m|^\s*<rra>|) {
 
			my $rra = walk_rra($fp);
 
			if (! $rra) {
				error('<rra> not closed.');
				return undef;
			}
 
			my $cf = $rra->{'cf'};
			my $sec_per_row = int($rra->{'pdp_per_row'}) * $step;
 
			push(@rras, $rra);
			info("Found <rra> [cf:${cf}, sec_per_row:${sec_per_row}].");
		}
	}
 
	error('<rrd> not closed.');
	return undef;
}
 
sub walk_ds($) {
 
	my $fp = shift(@_);
	my %elements = ();
 
	while (my $line = <$fp>) {
 
		return \%elements if ($line =~ m|^\s*</ds>|o);
 
		if ($line =~ m|^\s*<([a-z_]+)>(.+)</\1>|o) {
			my $element = $1;
			my $value = $2;
 
			chomp($value);
			$elements{$element} = $value;
 
		}
	}
 
	error('<ds> not closed.');
	return undef;
}
 
sub walk_rra($) {
 
	my $fp = shift(@_);
	my $cf = undef;
	my $pdp_per_row = undef;
	my @database;
 
	while (my $line = <$fp>) {
 
		if ($line =~ m|^\s*</rra>|o) {
			error('<cf> not found.') unless(defined($cf));
			error('<pdp_per_row> not found.') unless(defined($pdp_per_row));
			error('<database> not found.') unless(@database);
 
			if (defined($cf) && defined($pdp_per_row) && @database) {
				return {
					'cf' => $cf,
					'pdp_per_row' => $pdp_per_row,
					'database' => \@database,
				};
			} else {
				return undef;
			}
		}
 
 
 
		if ($line =~ m|^\s*<cf>(.+)</cf>|o) {
			$cf = trim($1);
		} 
 
		if ($line =~ m|^\s*<pdp_per_row>(\d+)</pdp_per_row>|o) {
			$pdp_per_row = trim($1);
		}
 
		if ($line =~ m|^\s*<database>|o) {
			@database = walk_database($fp);
		}
	}
 
	error('<rra> not closed');
	return undef;
}
 
sub walk_database($) {
 
	my $fp = shift(@_);
	my @database;
 
	while (my $line = <$fp>) {
 
		return @database if ($line =~ m|^\s*</database>|);
 
		my ($ts, $unix_ts) =
			$line =~ m|^\s*<!--\s*([0-9A-Z\s:-]+)\s+/\s+(\d+)\s+-->|o;
 
##
# hmm...
#		my @values =
#			$line =~ m|<row>\s*(?:<v>([0-9eE\+\.\-]+)</v>)+\s*</row>\s*$|go;
#
		my ($values_part) =
			$line =~ m|<row>((?:<v>[0-9eE\+\.\-]+</v>)+)</row>|o;
 
		next unless ($values_part);
 
		my @values =
			$values_part =~ m|<v>([0-9eE\+\.\-]+)</v>|og;
		next unless (@values);
 
 
		push (
			@database, 
			{
				'unix_ts' => $unix_ts,
				'values' => \@values,
			}
		);
	}
 
	error('<database> not closed.');
	return undef;
}
 
sub prompt($) {
 
	print STDOUT "\n", $_[0], ' ';
 
	my $in = <STDIN>;
 
	print STDOUT "\n";
	return trim($in);
}
 
sub trim($) {
	my $trimed = $_[0];
	$trimed =~ s/^\s+//o;
	$trimed =~ s/\s+$//o;
	return $trimed;
}
 
sub info($) {
	_printMessage('II', $_[0]);
}
 
sub warning($) {
	_printMessage('WW', $_[0]);
}
 
sub error($) {
	_printMessage('EE', $_[0]);
}
 
sub _printMessage($$) {
	my ($type, $message) = @_;
	print STDERR localtime(time()), " [${type}] ${message}\n";
}
 
sub usage($) {
	print STDERR "\n", $_[0], "\n\n" if ($_[0]);
	print STDERR 
<<EOF;
Usage:
 
    # cacti2zabbix <rrd file>
 
EOF
 
 
}

このスクリプトは rrd に保存されている MAX の値は無視して AVERAGE のみを拾います。雑です。

うちのルーターの In/Out bits の rrd を変換してみます。rrd ファイルは、特に変更してなければ /path/to/cacti/rra/ とかにあるかと思います。

rrd から xml に変換

まず rrdtool を使って rrd ファイルから xml に変換します。

# rrdtool dump port1_traffic_in_137.rrd > port1_traffic_in_137.xml

xml から zabbix-sender 型式に変換

cacti2zabbix を使って、xml ファイルを zabbix-sender に渡せる型式に変換します。

# cacti2zabbix port1_traffic_in_137.xml
204414831176970 [II] Start load 'port1_traffic_in_137.xml'.
204414831176970 [II] Found <ds> named 'traffic_in'.
204414831176970 [II] Found <ds> named 'traffic_out'.
204414831176970 [II] Found <rra> [cf:AVERAGE, sec_per_row:300].
204414831176970 [II] Found <rra> [cf:AVERAGE, sec_per_row:300].
204414831176970 [II] Found <rra> [cf:AVERAGE, sec_per_row:1800].
204414831176970 [II] Found <rra> [cf:AVERAGE, sec_per_row:7200].
204414831176970 [II] Found <rra> [cf:AVERAGE, sec_per_row:86400].
204414831176970 [II] Found <rra> [cf:MAX, sec_per_row:300].
204414831176970 [II] Found <rra> [cf:MAX, sec_per_row:300].
204414831176970 [II] Found <rra> [cf:MAX, sec_per_row:1800].
204414831176970 [II] Found <rra> [cf:MAX, sec_per_row:7200].
204414831176970 [II] Found <rra> [cf:MAX, sec_per_row:86400].

hostname ? router

itemkey of ds:traffic_in ? ifInOctets[VLAN1]


data format string for 'sprintf' (eg : %s, %d, %f ...) ? %f

34714831176970 [II] Set file name to 'router_ifInOctets_VLAN1_.zbx'.
34714831176970 [II] Start process to collect data.
34714831176970 [II] Start sort and output zabbix-data to 'router_ifInOctets_VLAN1_.zbx'.
34714831176970 [II] Finished to convert 'traffic_in' to 'ifInOctets[VLAN1]'.

itemkey of ds:traffic_out ? ifOutOctets[VLAN1]


data format string for 'sprintf' (eg : %s, %d, %f ...) ? %f

464714831176970 [II] Set file name to 'router_ifOutOctets_VLAN1_.zbx'.
464714831176970 [II] Start process to collect data.
464714831176970 [II] Start sort and output zabbix-data to 'router_ifOutOctets_VLAN1_.zbx'.
464714831176970 [II] Finished to convert 'traffic_out' to 'ifOutOctets[VLAN1]'.
#

cacti2zabbix は rrd ファイルの内容を解析して、いくつかの質問を投げてきます。

hostname ? には、zabbix 側でこのデータを受け取るホストの名前を正しく入力してください。上の例では 'router' です。

itemkey of ds:traffic_in ? には、cacti側で “traffic_in” という名前で定義されていたデータを zabbix 側でどんな名前のキーで受け取るかを正しく指定してください。要は zabbix でアイテム一覧を表示した時に「キー」のカラムに設定されている文字列です。 zabbix アイテム一覧

上の例では、“ifInOctets[VLAN1]” を指定しています。

data format string for...? には、元の rrd が保持しているデータタイプ(整数、小数、文字列……等)を sprintf 形式で指定してください。rrd が保持しているデータタイプは xml を見ればわかるかと思います。

# tail -n 5 port1_traffic_in_137.xml
            <!-- 2017-02-15 09:00:00 JST / 1487116800 --> <row><v>3.5974569886e+04</v><v>1.0010369773e+05</v></row>
            <!-- 2017-02-16 09:00:00 JST / 1487203200 --> <row><v>4.1631290100e+03</v><v>7.9451339122e+04</v></row>
        </database>
    </rra>
</rrd>

<v> と </v> で挟まれた部分が整数なのか、小数なのか、はたまた文字列なのか……を指定します。この例では小数なので %f を指定します。

ここまでの手順で、cacti2zabbix は In トラフィックのデータを変換して “<ホスト名>_<アイテムキー>.zbx” というファイルを出力します。

ただ、この rrd は In と Out のデータを2列で持っているちょっと特殊な rrd なので、2列目のアイテムについて itemkey of ds:traffic_out ? data format string for...? と聞いてきます。1列目のデータ “traffic_in” について指定した時と同じように zabbix 側のアイテムキーとデータタイプを指定してください。

以降は、rrd の列の数だけ同じことを繰り返します。大抵のデータは1列しか持っていないと思いますが……

zabbix に送る

zabbix_sender コマンドを使ってデータを zabbix に流し込みます。

zabbix のアイテムの設定

流し込み先(この例の場合は、ホスト “router” の “ifInOctets[VLAN1]” とか)のアイテムタイプを “Zabbixトラッパー” に変更します。また、データ型、単位、乗数、保存時の計算等の各設定にも注意してください。rrd のデータは、既に色々と計算処理を施された結果の値です。 たとえば、インターフェイスのトラフィックデータは snmp では積算バイト数なのでデータ型は「整数」、保存時の計算で「差分/時間」を設定していると思います。単位が bps であれば、乗数は「8」を指定しているかもしれません。ですが、これから送るデータは cacti で処理されて既に単位時間あたりの平均になっています。なので、データ型は「小数」になり、保存時の計算も「なし」です。同じように cpu 使用率なんかも snmp で取得した場合は積算 tick 値ですが、rrd には cacti で処理された使用率の値が保存されています。

この辺に注意して zabbix 側のアイテムの設定を変更してください。

データ送信

zabbix_sender の man ページあたりを参考にして、データを送信します。

# zabbix_sender -v -z 127.0.0.1 -T -i router_ifInOctets_VLAN1_.zbx

rrd に保存されていたデータが zabbix でグラフになると思います。

ただ、当然ですがそもそも rrd で保持していた以上のデータは取り込めません。 cacti の rrd は一定以前のデータは1日平均でまとめられてしまうため、zabbix のヒストリデータも1日1件しか入りません。そういうギャップを無視して荒っぽくインポートしているため、たとえば zabbix で表示する時に、1日よりも少ない時間範囲を指定するとグラフが出ないとか、まぁ色々と問題はあります……

でも例えば1年単位の変動を数年分並べて比較するとか、そういう用途ならどんなに大雑把なデータでもインポートしておけば、無いよりはずっとマシかな……とか個人的には思ってます。

ちなみにうちの環境では lm_sensors の温度データとかを移行してみましたw 数年分並べてみると、毎年だいたい同じカタチのグラフになるんですが、毎年少しずつグラフ全体が上側にずれていくんですよね……ホコリとかそういうのが影響して、温度が下がりづらくなってるんだと思います。

https://manimani.cc/lib/plugins/linkback/exe/trackback.php/wiki:cacti:cacti2zabbix