とある場所のネットワーク速度が著しく低下することがあったので監視目的で PING を使ってみることにした(iperf 使いたいけど相手が居ない)。とりあえず PING の出力をパースして SQLite に格納。GNUPLOT で可視化するけど、そのうち Zabbix とかに移して動的監視する予定。
今回は「お兄ちゃん、GREP と AWK の使用を禁止します!」縛りでやります。
PING からの文字列の取り出し
ping
は -c {count}
で回数を指定すると最後に統計を出してくれるのでそこから min、avg、max、mdev を取得します。
$ ping -c1 localhost
PING localhost (127.0.0.1) 56(84) bytes of data. 64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.092 ms --- localhost ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.092/0.092/0.092/0.000 ms
文字列の切り出しは grep
や sed
、awk
なんかを使うのが定番ですが、冒頭の宣言に従い、ここではシェルの機能のみでやっていきます。
まず、IFS
を改行のみに変更して ping
の出力を行ごとに位置パラメータに格納します。
IFS=$'\n' set -- $(ping -c1 localhost)
set -x
(xtrace)を使ってシェルの動きを見てみます。
+ IFS=' ' ++ ping -c1 localhost + set -- 'PING localhost (127.0.0.1) 56(84) bytes of data.' '64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.090 ms' '--- localhost ping statistics ---' '1 packets transmitted, 1 received, 0% packet loss, time 0ms' 'rtt min/avg/max/mdev = 0.090/0.090/0.090/0.000 ms'
eval
で遅延展開を使って位置パラメータに入っている文字列を出力してみます。
for ((i = 1; i <= ${#}; i++)) do eval echo LINE ${i}: \${${i}} done
行ごとに位置パラメータに格納されているのがわかります。この中で必要になるのは最後の行だけです。
LINE 1: PING localhost (127.0.0.1) 56(84) bytes of data. LINE 2: 64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.090 ms LINE 3: --- localhost ping statistics --- LINE 4: 1 packets transmitted, 1 received, 0% packet loss, time 0ms LINE 5: rtt min/avg/max/mdev = 0.090/0.090/0.090/0.000 ms
なお、遅延展開には以下のような方法があります。1番目と2番目は書式が違うだけで同じ動きをしますが、(自分の備忘録によると)3番目は ZSH では使えないようなので注意が必要です。位置パラメータが二桁を超えると {}
が必要となりますので常につけておく方が無難だと思います。
eval echo \${${#}} eval echo '$'{${#}} echo ${!#}
今度は最後の行だけを取り出して再度位置パラメータにセットします。現在の IFS
は改行のみになっているので、予め IFS
を変更して単語の分割も一緒にやってしまいます。最後の行で使われている区切り文字は space
と /
、=
なのでこの3つを IFS
にセットします。
IFS=' /='
そして、先程と同じように eval
を使って遅延展開で最終行を展開します。
eval set -- \${${#}}
for
で位置パラメータを見てみます。
for ((i = 1; i <= ${#}; i++)) do eval echo ITEM ${i}: \${${i}} done
これで最終行の分割ができました。
ITEM 1: rtt ITEM 2: min ITEM 3: avg ITEM 4: max ITEM 5: mdev ITEM 6: 0.090 ITEM 7: 0.090 ITEM 8: 0.090 ITEM 9: 0.000 ITEM 10: ms
通常は ITEM 6〜ITEM 9までを取り出して変数に代入するのでしょうが、ITEM 2〜ITEM 5までの文字列が変数名に使えるのでそのまま使います。
${2}=${6} # -> min=0.090 ${3}=${7} # -> avg=0.090 ${4}=${8} # -> max=0.090 ${5}=${9} # -> mdev=0.000
これを eval
で実行します。
eval ${2}=${6} ${3}=${7} ${4}=${8} ${5}=${9} echo MIN : ${min} echo AVG : ${avg} echo MAX : ${max} echo MDEV: ${mdev}
MIN : 0.090 AVG : 0.090 MAX : 0.090 MDEV: 0.000
for
文を消して set -vx
で見てみると位置パラメータが eval
によってどのように展開されているかわかると思います。
IFS=$'\n' + IFS=' ' set -- $(ping -c1 localhost) ++ ping -c1 localhost + set -- 'PING localhost (127.0.0.1) 56(84) bytes of data.' '64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.046 ms' '--- localhost ping statistics ---' '1 packets transmitted, 1 received, 0% packet loss, time 0ms' 'rtt min/avg/max/mdev = 0.046/0.046/0.046/0.000 ms' IFS=' /=' + IFS=' /=' eval set -- \${${#}} + eval set -- '${5}' set -- ${5} ++ set -- rtt min avg max mdev 0.046 0.046 0.046 0.000 ms eval ${2}=${6} ${3}=${7} ${4}=${8} ${5}=${9} + eval min=0.046 avg=0.046 max=0.046 mdev=0.000 min=0.046 avg=0.046 max=0.046 mdev=0.000 ++ min=0.046 ++ avg=0.046 ++ max=0.046 ++ mdev=0.000 echo MIN : ${min} + echo MIN : 0.046 MIN : 0.046 echo AVG : ${avg} + echo AVG : 0.046 AVG : 0.046 echo MAX : ${max} + echo MAX : 0.046 MAX : 0.046 echo MDEV: ${mdev} + echo MDEV: 0.000 MDEV: 0.000
とりあえずまとめると以下の数行で ping
コマンドの出力から変数の代入までができます。
save_IFS=${IFS} IFS=$'\n' set -- $(ping -c1 localhost) IFS=' /=' eval set -- \${${#}} eval ${2}=${6} ${3}=${7} ${4}=${8} ${5}=${9} IFS=${save_IFS}
IFS
をバックアップしたりするのが面倒な場合はサブシェルで実行してもいいと思います。
eval $( IFS=$'\n' set -- $(ping -c1 localhost) IFS=' /=' eval set -- \${${#}} echo ${2}=${6} ${3}=${7} ${4}=${8} ${5}=${9} )
なお、IFS
は以下のコマンドで初期状態になります。
IFS=$' \t\n'
PING タイムスタンプの取得
ping
は -D
でタイムスタンプが拾えます。これは2行目から拾えばよさそうです。
$ ping -c1 -D localhost PING localhost (127.0.0.1) 56(84) bytes of data. [1511621731.988689] 64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.063 ms --- localhost ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.063/0.063/0.063/0.000 ms
文字列の取り出しの前にタイムスタンプ部分の説明をしておきます。-D
オプションで表示される書式は UNIX 時間なので、読みやすくするためには変換が必要になります。これは date
コマンドを使います。
$ LC_TIME=C date -d@1511621731.988689 Sat Nov 25 23:55:31 JST 2017
SQLite の日付型の書式は yyyy-mm-dd HH:MM:SS
のようになっているので以下のように変換をします。ただ、SQLite に UNIX時間を整数型か浮動小数点数型で追加してもあとから変換はできるので、ここで無理して日付型に変換する必要はないかもしれません。
$ LC_TIME=C date -d@1511621731.988689 +'%F %T' 2017-11-25 23:55:31
さて、ping
コマンドの出力からタイムスタンプ部分を抜き出す必要がありますが、少々余計なものがいくつかあります。
PING localhost (127.0.0.1) 56(84) bytes of data. [1511621731.988689] 64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.063 ms (以下略)
まずは2行目を取り出します。ping
の -D
オプションを忘れずに。
IFS=$'\n' set -- $(ping -c1 -D localhost) echo ${2}
2行目が取り出せました。ここからタイムスタンプ部分だけを取り出したいのですが、[]
が邪魔ですね。
[1511622657.836489] 64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.042 ms
[
と ]
を区切り文字として IFS
にセットします。位置パラメータがどうなってるかわかりやすくするために途中に set -x
を入れます。
IFS=' []' set -x set -- ${2} echo ${2}
先頭の [
の左には何もないので空になっています。タイムスタンプの右側にあった ]
は区切り文字として扱われたので無くなりました。
+ set -- '' 1511622852.338428 64 bytes from localhost '(127.0.0.1):' + echo 1511622852.338428
date
コマンドに渡せる書式になったので変換します。
date -d@${2} +$'%F %T'
これで yyyy-mm-dd HH:MM:SS
形式で取り出せました。
2017-11-26 00:15:33
ここまでを続けて書くと以下のようになります。
IFS=$'\n' set -- $(ping -c1 -D localhost) IFS=' []' set -- ${2} # ここの ${2} は「2"行"目」 date -d@${2} +'%F %T' # ここの ${2} は「2"列"目」
PING コマンドの2行目からタイムスタンプを取得して最終行から結果を取得する
ここまでで最終行の取り出しとタイムスタンプの取り出しができましたが、実は問題があります。最終行の取り出し、もタイムスタンプの取り出しも、2回目の set
で最初に取得した ping
コマンドの結果を上書きしてしまっているためどちらかしか取り出せません。
最初に取得した ping
コマンドの結果を使い回す方法はいくつかあると思いますが、
ping
コマンドの結果を変数に格納しておく- 関数を作って位置パラメータを渡す
- サブシェルで処理する
の3つくらいでしょうか。(シェルに詳しい人なら他にも思いつきそうですが)
ここまで位置パラメータだけでやってきて今更 ping
コマンドの結果を変数に入れるのはスマートな方法ではないような気がするので関数かサブシェルにします。
まずは関数版から。カレントシェルの IFS
が書き換わるのが嫌なのですべてサブシェル内で実行しています。カレントシェルでやる場合は適当な変数に最初の IFS
を保存しておくとよいです。
# as = Assignment Statements time_as(){ ( IFS=' []' set -- ${2} date -d@${2} +'time="%F %T"' ) } stat_as(){ ( IFS=' /=' eval set -- \${${#}} echo ${2}=${6} ${3}=${7} ${4}=${8} ${5}=${9} ) } ## IF CURRENT SHELL #save_IFS=${IFS} IFS=$'\n' #set -- $(ping -c1 -D localhost) #IFS=${save_IFS} eval $( IFS=$'\n' set -- $(ping -c1 -D localhost) time_as "${@}" stat_as "${@}" ) echo ${time} ${min} ${avg} ${max} ${mdev}
それぞれの関数に "${@}"
で一番最初に取得した位置パラメータ群を渡すと代入分を返してくれるので eval
で実行させて代入まで済ませます。
set -x return_time_as(){ ( IFS=' []' set -- ${2} date -d@${2} +'time="%F %T"' ) } return_stat_as(){ ( IFS=' /=' eval set -- \${${#}} echo ${2}=${6} ${3}=${7} ${4}=${8} ${5}=${9} ) } eval $( IFS=$'\n' set -- $(ping -c1 -D localhost) return_time_as "${@}" return_stat_as "${@}" ) ++ IFS=' ' +++ ping -c1 -D localhost ++ set -- 'PING localhost (127.0.0.1) 56(84) bytes of data.' '[1511627944.923406] 64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.043 ms' '--- localhost ping statistics ---' '1 packets transmitted, 1 received, 0% packet loss, time 0ms' 'rtt min/avg/max/mdev = 0.043/0.043/0.043/0.000 ms' ++ return_time_as 'PING localhost (127.0.0.1) 56(84) bytes of data.' '[1511627944.923406] 64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.043 ms' '--- localhost ping statistics ---' '1 packets transmitted, 1 received, 0% packet loss, time 0ms' 'rtt min/avg/max/mdev = 0.043/0.043/0.043/0.000 ms' ++ IFS=' []' ++ set -- '' 1511627944.923406 64 bytes from localhost '(127.0.0.1):' icmp_seq=1 ttl=64 time=0.043 ms ++ date -d@1511627944.923406 '+time="%F %T"' ++ return_stat_as 'PING localhost (127.0.0.1) 56(84) bytes of data.' '[1511627944.923406] 64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.043 ms' '--- localhost ping statistics ---' '1 packets transmitted, 1 received, 0% packet loss, time 0ms' 'rtt min/avg/max/mdev = 0.043/0.043/0.043/0.000 ms' ++ IFS=' /=' ++ eval set -- '${5}' +++ set -- rtt min avg max mdev 0.043 0.043 0.043 0.000 ms ++ echo min=0.043 avg=0.043 max=0.043 mdev=0.000 + eval 'time="2017-11-26' '01:39:04"' min=0.043 avg=0.043 max=0.043 mdev=0.000 time="2017-11-26 01:39:04" min=0.043 avg=0.043 max=0.043 mdev=0.000 ++ time='2017-11-26 01:39:04' ++ min=0.043 ++ avg=0.043 ++ max=0.043 ++ mdev=0.000 echo ${time} ${min} ${avg} ${max} ${mdev} + echo 2017-11-26 01:39:04 0.043 0.043 0.043 0.000 2017-11-26 01:39:04 0.043 0.043 0.043 0.000
長いですね。
続いてサブシェル版。
## IF CURRENT SHELL #save_IFS=${IFS} IFS=$'\n' #set -- $(ping -c1 -D localhost) #IFS=${save_IFS} eval $( IFS=$'\n' set -- $(ping -c1 -D localhost) ( IFS=' []' set -- ${2} date -d@${2} +'time="%F %T"' ) ( IFS=' /=' eval set -- \${${#}} echo ${2}=${6} ${3}=${7} ${4}=${8} ${5}=${9} ) ) echo ${time} ${min} ${avg} ${max} ${mdev}
set -vx
で実行してみます。
eval $( IFS=$'\n' set -- $(ping -c1 -D localhost) ( IFS=' []' set -- ${2} date -d@${2} +'time="%F %T"' ) ( IFS=' /=' eval set -- \${${#}} echo ${2}=${6} ${3}=${7} ${4}=${8} ${5}=${9} ) ) ++ IFS=' ' +++ ping -c1 -D localhost ++ set -- 'PING localhost (127.0.0.1) 56(84) bytes of data.' '[1511627682.894487] 64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.048 ms' '--- localhost ping statistics ---' '1 packets transmitted, 1 received, 0% packet loss, time 0ms' 'rtt min/avg/max/mdev = 0.048/0.048/0.048/0.000 ms' ++ IFS=' []' ++ set -- '' 1511627682.894487 64 bytes from localhost '(127.0.0.1):' icmp_seq=1 ttl=64 time=0.048 ms ++ date -d@1511627682.894487 '+time="%F %T"' ++ IFS=' /=' ++ eval set -- '${5}' +++ set -- rtt min avg max mdev 0.048 0.048 0.048 0.000 ms ++ echo min=0.048 avg=0.048 max=0.048 mdev=0.000 + eval 'time="2017-11-26' '01:34:42"' min=0.048 avg=0.048 max=0.048 mdev=0.000 time="2017-11-26 01:34:42" min=0.048 avg=0.048 max=0.048 mdev=0.000 ++ time='2017-11-26 01:34:42' ++ min=0.048 ++ avg=0.048 ++ max=0.048 ++ mdev=0.000 echo ${time} ${min} ${avg} ${max} ${mdev} + echo 2017-11-26 01:34:42 0.048 0.048 0.048 0.000 2017-11-26 01:34:42 0.048 0.048 0.048 0.000
わざわざ関数作るまでもないのでサブシェルでよさそうです。
PING が失敗したときの例外処理
まずは PING の終了ステータスを確認します。テストは下記の4種類としました。
- デフォルトゲートウェイ[0]
- 宛先が存在しない[1]
- 宛先がネットワークアドレス[2]
- 宛先がブロードキャストアドレス[2] ※Linux では
-b
オプションが必要 - 宛先が範囲外[2]
$ for i in 1 254 0 255 256; do ping -c1 -W1 192.168.1.$i >/dev/null 2>&1; echo $?; done 0 1 2 2 2
サブシェル内の ping
がエラーで終了してもカレントシェルでは set
の終了コードで上書きされてしまいます。サブシェル内の ping
が異常終了した場合のみ位置パラメータの最後に終了コードを入れるようにして、あとから参照するようにしてみます。
save_IFS=${IFS} IFS=$'\n' set -- $(ping -c${ping_count} -D ${host} || echo $?) case ${_} in 0) : ;; *) exit 1 ;; esac IFS=${save_IFS}
(あ、なんかめんどくさい…)
もう無難に PING の出力を変数に入れることにします…。
ping_result=$(ping -c${ping_count} -D ${host}) || exit $? save_IFS=${IFS} IFS=$'\n' set -- ${ping_result} IFS=${save_IFS}
データベースの作成(SQLite)
データを格納するデータベースを作成します。ここでは SQLite を使用しますが、特に貯めておく必要がないのであれば揮発性 KVS を使うのもアリだと思います。
データベースファイル名とテーブル名は下記の通りです。
テーブルの構成です。今回は複数ホストのデータを格納するため HOST
列を用意しています。TIMESTAMP
と HOST
は同時に存在しないはずなので Primary key を組みます。
TIMESTAMP | HOST | MIN | AVG | MAX | MDEV |
---|---|---|---|---|---|
2017-11-26 13:00:28 | 192.168.1.103 | 59.789 | 95.716 | 128.351 | 24.985 |
2017-11-26 13:00:30 | 192.168.1.1 | 1.367 | 3.428 | 8.866 | 3.145 |
2017-11-26 13:00:33 | 192.168.1.103 | 44.499 | 87.418 | 122.071 | 29.737 |
2017-11-26 13:00:35 | 192.168.1.1 | 1.403 | 1.49 | 1.537 | 0.059 |
2017-11-26 13:00:38 | 192.168.1.103 | 5.982 | 106.414 | 218.366 | 80.11 |
2017-11-26 13:00:40 | 192.168.1.1 | 1.438 | 1.521 | 1.671 | 0.098 |
SQLite3 でテーブルを作成する際、DEFAULT CURRENT_TIMESTAMP
で現在時刻を自動的に挿入するようにもできるのでテーブルにレコードを追加したときの時間がよいという場合は ping
コマンドでタイムスタンプを取得する必要はありません(ただし SQLite は UTC なので日本時間に合わせる場合は datetime()
関数などを使う必要があります)。値を格納する列はミリ秒なので REAL
にしておきます。
データベースファイルを作成します。
$ sqlite3 ping.sqlite
テーブルを作成します。SQLite の Primary key は null
が許可されているので NOT NULL
にしておきます。
create table PING ( TIMESTAMP DATE NOT NULL , HOST TEXT NOT NULL , MIN REAL , AVG REAL , MAX REAL , MDEV REAL , primary key ( TIMESTAMP , HOST ) );
下記はデータを挿入する場合の例です。
insert into PING values ( '2017-11-25 18:18:00' , '192.168.1.1' , 0.2 , 0.2 , 0.2 , 0.2 )
DEFAULT CURRENT_TIMESTAMP
を使う場合は TIMESTAMP
列に何も入れないため、列を指定してデータを挿入します。
create table PING ( TIMESTAMP DATE PRIMARY KEY DEFAULT CURRENT_TIMESTAMP , HOST TEXT , MIN REAL , AVG REAL , MAX REAL , MDEV REAL );
insert into PING ( HOST , MIN , AVG , MAX , MDEV ) values ( '192.168.1.1' , 0.2 , 0.2 , 0.2 , 0.2 );
これでデータベースの作成は完了です。
PING 実行から結果をデータベースに追加するまでをスクリプト化する
一通り準備ができたのでスクリプト化していきます。
CREATE TABLE
や INSERT
文は予めシェルの変数に入れておきます。INSERT
文は値をあとから eval
で更新するようにしています。(SQLite に Oracle の引数みたいなのあったかどうか覚えてません)
#!/bin/bash set -e set -u #set -x ### ENVIRONMENT ### host=${1:?} ping_count=4 sqlite_db_file="ping.sqlite" sqlite_table_name="PING" ### SQL Query ### ## CREATE TABLE sqlite_sql_create_table="\ create table ${sqlite_table_name} ( TIMESTAMP DATE NOT NULL , HOST TEXT NOT NULL , MIN REAL , AVG REAL , MAX REAL , MDEV REAL , primary key ( TIMESTAMP , HOST ) ); " ## INSERT (Shell's delayed expansion) sqlite_sql_insert="\ insert into ${sqlite_table_name} values ( '\${timestamp}' , '\${host}' , \${min} , \${avg} , \${max} , \${mdev} ); " if ! test -e "${sqlite_db_file}" then sqlite3 "${sqlite_db_file}" <<! ${sqlite_sql_create_table} ! fi ping_result=$(ping -c${ping_count} -D ${host}) || exit $? save_IFS=${IFS} IFS=$'\n' set -- ${ping_result} IFS=${save_IFS} eval $( ( IFS=' []' set -- ${2} date -d@${2} +'timestamp="%F %T"' ) ( IFS=' /=' eval set -- \${${#}} echo ${2}=${6} ${3}=${7} ${4}=${8} ${5}=${9} ) ) echo "\ ${host} (${timestamp}) MIN : ${min} AVG : ${avg} MAX : ${max} MDEV: ${mdev} " eval sqlite_sql_insert=\""${sqlite_sql_insert}"\" sqlite3 "${sqlite_db_file}" <<! ${sqlite_sql_insert} !
ルータ宛と Raspberry Pi Zero 宛で試してみます。
$ watch -pn 10 './ping.sh 192.168.1.1; ./ping.sh 192.168.1.103' Every 10.0s: ./ping.sh 192.168.1.1; ./ping.sh 192.168.1.103 192.168.1.1 (2017-11-26 21:57:11) MIN : 1.493 AVG : 1.607 MAX : 1.904 MDEV: 0.176 192.168.1.103 (2017-11-26 21:57:14) MIN : 47.586 AVG : 114.384 MAX : 207.937 MDEV: 61.446
データの可視化
GNUPLOT を使います。が、なんか色々忘れたのですごく簡素なグラフです。(一時期は毎日やってたのにすっかり忘れてしまいました)
$ sqlite3 -separator , ping.sqlite 'select * from PING where HOST = "192.168.1.103" order by TIMESTAMP;" > data.csv $ gnuplot graph.gnu
途中、データが無い部分は PING を止めていたところになりますが、死活監視としても使えそうです。
SQLite は -html
オプションで HTML 出力もしてくれるので表はすぐに作成できます。
$ sqlite3 -html -header ping.sqlite 'select * from (select * from PING where HOST = "192.168.1.103" order by TIMESTAMP desc LIMIT 10) order by TIMESTAMP;'
TIMESTAMP | HOST | MIN | AVG | MAX | MDEV |
---|---|---|---|---|---|
2017-11-27 00:21:36 | 192.168.1.103 | 5.973 | 123.851 | 230.968 | 97.036 |
2017-11-27 00:21:46 | 192.168.1.103 | 7.398 | 75.579 | 211.868 | 80.311 |
2017-11-27 00:21:56 | 192.168.1.103 | 6.885 | 41.134 | 142.876 | 58.741 |
2017-11-27 00:22:06 | 192.168.1.103 | 79.394 | 112.944 | 145.748 | 24.741 |
2017-11-27 00:22:16 | 192.168.1.103 | 3.778 | 78.472 | 137.308 | 51.999 |
2017-11-27 00:22:26 | 192.168.1.103 | 5.991 | 158.897 | 358.325 | 134.201 |
2017-11-27 00:22:36 | 192.168.1.103 | 33.446 | 123.946 | 215.256 | 68.784 |
2017-11-27 00:22:46 | 192.168.1.103 | 5.815 | 69.027 | 137.482 | 59.065 |
2017-11-27 00:22:56 | 192.168.1.103 | 33.585 | 99.668 | 212.551 | 67.971 |
2017-11-27 00:23:06 | 192.168.1.103 | 35.1 | 126.315 | 217.04 | 68.714 |
というわけで今回はここまで。