Quantcast
Channel: mattintosh note
Viewing all articles
Browse latest Browse all 892

Shell と PING でネットワークの速度を監視する

$
0
0

とある場所のネットワーク速度が著しく低下することがあったので監視目的で PINGを使ってみることにした(iperf 使いたいけど相手が居ない)。とりあえず PINGの出力をパースして SQLiteに格納。GNUPLOTで可視化するけど、そのうち Zabbix とかに移して動的監視する予定。

今回は「お兄ちゃん、GREPAWKの使用を禁止します!」縛りでやります。

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

文字列の切り出しは grepsedawkなんかを使うのが定番ですが、冒頭の宣言に従い、ここではシェルの機能のみでやっていきます。

まず、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++))doevalecho 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では使えないようなので注意が必要です。位置パラメータが二桁を超えると {}が必要となりますので常につけておく方が無難だと思います。

evalecho\${${#}}evalecho'$'{${#}}echo${!#}

今度は最後の行だけを取り出して再度位置パラメータにセットします。現在の IFSは改行のみになっているので、予め IFSを変更して単語の分割も一緒にやってしまいます。最後の行で使われている区切り文字は space/=なのでこの3つを IFSにセットします。

IFS=' /='

そして、先程と同じように evalを使って遅延展開で最終行を展開します。

evalset -- \${${#}}

forで位置パラメータを見てみます。

for((i = 1; i <= ${#}; i++))doevalecho 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=' /='evalset -- \${${#}}
+ evalset -- '${5}'set -- ${5}
++ set -- rtt min avg max mdev 0.046 0.046 0.046 0.000 mseval${2}=${6}${3}=${7}${4}=${8}${5}=${9}
+ evalmin=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=' /='evalset -- \${${#}}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のようになっているので以下のように変換をします。ただ、SQLiteUNIX時間を整数型か浮動小数点数型で追加してもあとから変換はできるので、ここで無理して日付型に変換する必要はないかもしれません。

$ 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-xset -- ${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 Statementstime_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-xreturn_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=' /='
++ evalset -- '${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.0002017-11-2601: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=' /='
++ evalset -- '${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.0002017-11-2601:34:42 0.048 0.048 0.048 0.000

わざわざ関数作るまでもないのでサブシェルでよさそうです。

PINGが失敗したときの例外処理

まずは PINGの終了ステータスを確認します。テストは下記の4種類としました。

  1. デフォルトゲートウェイ[0]
  2. 宛先が存在しない[1]
  3. 宛先がネットワークアドレス[2]
  4. 宛先がブロードキャストアドレス[2] ※Linuxでは -bオプションが必要
  5. 宛先が範囲外[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):;;
*)exit1;;esacIFS=${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 を使うのもアリだと思います。

データベースファイル名とテーブル名は下記の通りです。

  • SQLiteファイル(データベース)名:ping.sqlite
  • SQLiteテーブル名:PING

テーブルの構成です。今回は複数ホストのデータを格納するため HOST列を用意しています。TIMESTAMPHOSTは同時に存在しないはずなので Primary key を組みます。

TIMESTAMPHOSTMINAVGMAXMDEV
2017-11-26 13:00:28192.168.1.10359.78995.716128.35124.985
2017-11-26 13:00:30192.168.1.11.3673.4288.8663.145
2017-11-26 13:00:33192.168.1.10344.49987.418122.07129.737
2017-11-26 13:00:35192.168.1.11.4031.491.5370.059
2017-11-26 13:00:38192.168.1.1035.982106.414218.36680.11
2017-11-26 13:00:40192.168.1.11.4381.5211.6710.098

SQLite3 でテーブルを作成する際、DEFAULT CURRENT_TIMESTAMPで現在時刻を自動的に挿入するようにもできるのでテーブルにレコードを追加したときの時間がよいという場合は pingコマンドでタイムスタンプを取得する必要はありません(ただし SQLiteUTCなので日本時間に合わせる場合は datetime()関数などを使う必要があります)。値を格納する列はミリ秒なので REALにしておきます。

データベースファイルを作成します。

$ sqlite3 ping.sqlite

テーブルを作成します。SQLiteの Primary key は nullが許可されているので NOT NULLにしておきます。

createtable
PING
(
    TIMESTAMP   DATENOTNULL
,   HOST        TEXT NOTNULL
,   MIN         REAL
,   AVG         REAL
,   MAX         REAL
,   MDEV        REAL
,   primary key (
        TIMESTAMP
    ,   HOST
    )
);

下記はデータを挿入する場合の例です。

insertinto
PING
values (
    '2017-11-25 18:18:00'
,   '192.168.1.1'
,   0.2
,   0.2
,   0.2
,   0.2
)

DEFAULT CURRENT_TIMESTAMPを使う場合は TIMESTAMP列に何も入れないため、列を指定してデータを挿入します。

createtable
PING
(
    TIMESTAMP   DATE PRIMARY KEY DEFAULT CURRENT_TIMESTAMP
,   HOST        TEXT
,   MIN         REAL
,   AVG         REAL
,   MAX         REAL
,   MDEV        REAL
);
insertinto PING (
    HOST
,   MIN
,   AVG
,   MAX
,   MDEV
) values (
    '192.168.1.1'
,   0.2
,   0.2
,   0.2
,   0.2
);

これでデータベースの作成は完了です。

PING実行から結果をデータベースに追加するまでをスクリプト化する

一通り準備ができたのでスクリプト化していきます。

CREATE TABLEINSERT文は予めシェルの変数に入れておきます。INSERT文は値をあとから evalで更新するようにしています。(SQLiteOracleの引数みたいなのあったかどうか覚えてません)

#!/bin/bashset-eset-u#set -x### ENVIRONMENT ###host=${1:?}ping_count=4sqlite_db_file="ping.sqlite"sqlite_table_name="PING"### SQL Query ##### CREATE TABLEsqlite_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}!fiping_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}"evalsqlite_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を止めていたところになりますが、死活監視としても使えそうです。

f:id:mattintosh4:20171127001613p:plain

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;'
TIMESTAMPHOSTMINAVGMAXMDEV
2017-11-27 00:21:36192.168.1.1035.973123.851230.96897.036
2017-11-27 00:21:46192.168.1.1037.39875.579211.86880.311
2017-11-27 00:21:56192.168.1.1036.88541.134142.87658.741
2017-11-27 00:22:06192.168.1.10379.394112.944145.74824.741
2017-11-27 00:22:16192.168.1.1033.77878.472137.30851.999
2017-11-27 00:22:26192.168.1.1035.991158.897358.325134.201
2017-11-27 00:22:36192.168.1.10333.446123.946215.25668.784
2017-11-27 00:22:46192.168.1.1035.81569.027137.48259.065
2017-11-27 00:22:56192.168.1.10333.58599.668212.55167.971
2017-11-27 00:23:06192.168.1.10335.1126.315217.0468.714

というわけで今回はここまで。

前に書いた GNUPLOTスクリプト整理しないと…。


Viewing all articles
Browse latest Browse all 892

Trending Articles