開眼シェルスクリプト2012年11月号

Pocket
LINEで送る

出典: 技術評論社SoftwareDesign

11. 開眼シェルスクリプト 第11回 オンラインストレージもどきを作る(2)

11.1. はじめに

 今回は豆腐ボックス第二回です。
前回はrsyncを使ったオンラインストレージ
(たまっていく一方なのでモドキ)を作りました。
今回はこれを少しずつ改善していきます。
普段あまり使わない機能のオンパレードなので、
筆者も詳しくはないのですが、
一緒に一つずつ確認していきましょう。

 今回は、前回にも増して力技の嵐です。
また、私は変数にクォートをつけるどうのこうのには無頓着なので、
(脚注:必要な局面では頓着します。)
人によっては「こんなコーディングおかしい」と思うかもしれません。
しかし、秀吉の「墨俣一夜城」の例はちょっと言い過ぎかもしれませんが、
人の想像を超えた早さで何かを作れるということには、
組織や自身の行く末を変えるくらいの力があります。

11.2. おさらい

 豆腐ボックスは、サーバを経由して複数のクライアントPCのファイルを
同期するアプリケーションです。
各PCの ~/TOFUBOX/ 内のディレクトリをrsyncで同期します。
クライアントPCは多数台の接続を想定しており、
一つの同時に二台以上のクライアントとサーバが同期処理しないように、
排他制御の仕組みが入っています。

 排他制御は、クライアント側からサーバ側にディレクトリを作りにいき、
その成否を利用して行っています。
mkdir を排他区間の作成に使うという手法です(前回参照)。

 クライアント側は以下の二つのスクリプトで構成されています。
TOFUBOX.SYNC が同期を行うスクリプトです。
また、 TOFUBOX.SUSSTOP は、PCがスリープから復帰したら、
TOFUBOX.SYNC を殺すスクリプトです。
排他制御のために必要なスクリプトです。

1
2
3
4
ueda@X201:~$ tree .tofubox/
.tofubox/
├── TOFUBOX.SUSSTOP
└── TOFUBOX.SYNC

 サーバ側には、以下のようにシェルスクリプトが一つだけあります。

1
2
3
ueda@tofu:~$ tree .tofubox/
.tofubox/
└── REMOVE.LOCK

  REMOVE.LOCK は、
クライアント側がロックをかけた後に通信を中断したかどうかを判断し、
適切な時にロックを外します。

 豆腐ボックスのコードの総量は、クライアント側、
サーバ側のものを全部足してもわずか73行です。
せっかくコードが短いんですから、一番長い
TOFUBOX.SYNC をリスト1に全部掲載しておきます。
このコードには、後から手を入れます。

・リスト1: TOFUBOX.SYNC(前回のもの)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
ueda@X201:~/.tofubox$ cat TOFUBOX.SYNC
#!/bin/bash -xv
#
# TOFUBOX.SYNC
#
# written by R. Ueda (usp-lab.com)
exec 2> /tmp/$(basename $0)

server=tofu.usptomonokai.jp
dir=/home/ueda

MESSAGE () {
        DISPLAY=:0 notify-send "豆腐: $1"
}

ERROR_CHECK(){
        [ "$(echo ${PIPESTATUS[@]} | tr -d ' 0')" = "" ] && return
        DISPLAY=:0 notify-send "豆腐: $1"
        exit 1
}

#ロックがとれなかったらすぐ終了
ssh -o ConnectTimeout=5 $server "mkdir $dir/.tofubox/LOCK" || exit 0

#pull############################
MESSAGE "受信開始"
rsync -auz --timeout=30 $server:$dir/TOFUBOX/ $dir/TOFUBOX/
ERROR_CHECK "受信中断"
MESSAGE "受信完了"

#push############################
MESSAGE "送信開始"
rsync -auz --timeout=30 $dir/TOFUBOX/ $server:$dir/TOFUBOX/
ERROR_CHECK "送信中断"
MESSAGE "送信完了"

ssh -o ConnectTimeout=5 $server "rmdir $dir/.tofubox/LOCK"

exit 0

11.3. serviceコマンドで止めたり動かしたりする

 まずやりたいのは、
豆腐ボックスを簡単に止めたり動かしたりする機能を作ることです。
例えばapacheなどは以下のようにスマートに止めたり動かしたりできるわけで、
豆腐ボックスもこれくらいスマートにしたいものです。

1
2
# service apache start
# service apache stop

 現時点では、
豆腐ボックスの起動には次のようにcrontabを使っています。
しかしこれだと止めるにはわざわざ crontab -e
などでコメントアウトしに行かなくてはなりません。
下手すると crontab -r などと打ってえらいことになります。

1
2
3
ueda@X201:~/.tofubox$ crontab -l | grep -v "#"

*/4 * * * * /home/ueda/.tofubox/TOFUBOX.SYNC

 また、 TOFUBOX.SUSSTOP も、
現状では単に端末からバックグラウンド起動しているだけです。
止めるときはkillしてやらなければなりません。

 ということで、 service から豆腐ボックスを制御できるようにしましょう。
ここらへんはOSやディストリビューションによっていろいろ違いますが、
ここでは Ubuntu Linux 12.04 に絞っています。

11.3.1. 起動スクリプトを書く

 まず、豆腐ボックスに関わるシェルスクリプトを一斉に起動したり、
止めたりするスクリプトをリスト2のように書きます。
スクリプト中の TOFUBOX.LOOPTOFUBOX.WATCH は、
まだ書いてないスクリプトです。
特に凝ったことはしていません。
startが引数にあったらシェルスクリプトを立ち上げて、
stopがあったら全部殺すだけです。

・リスト2: TOFUBOX.INIT

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
ueda@X201:~/.tofubox$ cat TOFUBOX.INIT
#!/bin/bash
#
# TOFUBOX.INIT 豆腐ボックスの起動・終了
#
# written by R. Ueda (r-ueda@usp-lab.com)
exec 2> /dev/null

sys=/home/ueda/.tofubox

case "$1" in
start)
        ps cax | grep -q TOFUBOX.SUSSTOP && exit 1
        ps cax | grep -q TOFUBOX.LOOP && exit 1
        ps cax | grep -q TOFUBOX.WATCH && exit 1

        $sys/TOFUBOX.SUSSTOP &
        $sys/TOFUBOX.LOOP &
        $sys/TOFUBOX.WATCH &
;;
stop)
        killall TOFUBOX.SUSSTOP
        killall TOFUBOX.LOOP
        killall TOFUBOX.WATCH
;;
*)
        echo "Usage: TOFUBOX {start|stop}" >&2
        exit 1
;;
esac

exit 0

TOFUBOX.LOOP をリスト3に示します。
単に3分ごとに TOFUBOX.SYNC を立ち上げるだけの、
crontabの代わりのスクリプトです。

・リスト3: TOFUBOX.LOOP

1
2
3
4
5
6
7
ueda@X201:~/.tofubox$ cat TOFUBOX.LOOP
#!/bin/bash -xv

while : ; do
        /home/ueda/.tofubox/TOFUBOX.SYNC
        sleep 60
done

  TOFUBOX.INIT を動かしてみましょう。
TOFUBOX.WATCH については、
なにもしないスクリプトを置いて、実行できるようにしておきます。
リスト4に動作例を示します。

・リスト4:TOFUBOX.INITの動作確認

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#起動
ueda@X201:~/.tofubox$ ./TOFUBOX.INIT start
#プロセスを確認。
ueda@X201:~/.tofubox$ ps cax | grep TOFU
26072 pts/5    S      0:00 TOFUBOX.SUSSTOP
26073 pts/5    S      0:00 TOFUBOX.LOOP
26075 pts/5    S      0:00 TOFUBOX.SYNC
#二回目のstartは失敗する。
ueda@X201:~/.tofubox$ ./TOFUBOX.INIT start
ueda@X201:~/.tofubox$ echo $?
1
#止める。
ueda@X201:~/.tofubox$ ./TOFUBOX.INIT stop
ueda@X201:~/.tofubox$ ps cax | grep TOFU

 次に、これを service で叩けるようにします。
リスト5のように /etc/init.d/ 下にリンクを貼ることでできるようになります。

・リスト5: /etc/init.d にリンクを張る

1
2
3
root@X201:/etc/init.d# ln -s ~/.tofubox/TOFUBOX.INIT tofubox
root@X201:/etc/init.d# ls -l tofubox
lrwxrwxrwx 1 root root 32  8月 17 10:08 tofubox -> /home/ueda/.tofubox/TOFUBOX.INIT

 使ってみましょう。ユーザはrootでなくても大丈夫です。
動作確認した例をリスト6に示します。

・リスト6: service を使う

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
ueda@X201:~$ service tofubox start
ueda@X201:~$ ps cax | grep TOFU
26433 pts/3    S      0:00 TOFUBOX.SUSSTOP
26434 pts/3    S      0:00 TOFUBOX.LOOP
26435 pts/3    S      0:00 TOFUBOX.SYNC
ueda@X201:~$ service tofubox start
ueda@X201:~$ echo $?
1
ueda@X201:~$ service tofubox stop
ueda@X201:~$ ps cax | grep TOFU

 ところで、例えばUbuntuなどdebian系のディストリビューションでは
/etc/init.d/skeleton をコピーして起動スクリプトを書くなど、
ディストリビューション、OSによっていろいろ流儀があるようです。
が、個人で使うものを作るうちは、
なにかまずい情報をインターネットにばらまく恐れがない限り、
とにかく拙速にやることをおすすめします。
「許可を取るより謝る方がずっと簡単だ。」
です。考えすぎはいけません。
また、私のようにいちいち変数のクォートをしない人は、
バックアップを欠かさずに・・・。

11.3.2. PCが起動したときに走らせる

 次に、PCが起動したときに、
TOFUBOX.INIT も起動するようにします。
まあ、あまり難しく考えず、 /etc/rc.local
ファイルに TOFUBOX.INIT を仕掛けることにします。
ただ単に書くだけだと root で起動するので、
ueda で起動させるために su コマンドを使います。
rootで起動すると、例えばsshのための鍵を ueda
の鍵でなくてrootのものを読みに行ってしまうなど、
うまく動きません。リスト7のように記述します。

・リスト7: /etc/rc.local への追記

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
ueda@X201:~$ cat /etc/rc.local
#!/bin/sh -e
#
# rc.local
#
(略)

su - ueda -c '/home/ueda/.tofubox/TOFUBOX.INIT start'

exit 0

 これで、再起動のときにこのスクリプト( rc.local )が実行され、
その中に書いてある TOFUBOX.INIT が実行されます。
下のように、 psu オプションをつけて、
スクリプトが指定のユーザで実行されていたら成功です。

・リスト8:再起動時の動作確認

1
2
3
4
5
ueda@X201:~# reboot
...再起動...
ueda@X201:~$ ps caxu | grep TOFU
ueda      1364  0.0  0.0  17472  1460 ?        S    10:46   0:00 TOFUBOX.SUSSTOP
ueda      1366  0.0  0.0   4392   608 ?        S    10:46   0:00 TOFUBOX.LOOP

11.4. もっとタイミングにこだわる

 さて、今度は同期のタイミングをもっと合理的にします。
とにかく現状では3分ごとに読み書きしており、
右上に「豆腐:~~~」とメッセージが出て非常に煩わしい。
自分で作ってて煩わしいのですから、他人にはもっと煩わしいことでしょう。
(脚注:ここ数ヶ月、画面をのぞきこんだ人に「豆腐って何ですか?」と聞かれます。
「Software Design読め」と答えています。)

11.4.1. ファイルを更新したときだけ同期しにいく

 クライアントからサーバへの同期は、クライアントの
~/TOFUBOX ディレクトリが変更されたときだけでよいので、
変更されたタイミングでサーバへ同期しにいくのがよいでしょう。
inotifywait というコマンドを使うと、ファイルの変更等の検知ができます。

 例えば、 ~/TOFUBOX/ 下のディレクトリを監視するにはリスト9のように打ちます。

・リスト9: inotifywait の立ち上げ

1
2
3
ueda@X201:~$ inotifywait -mr ~/TOFUBOX/
Setting up watches.  Beware: since -r was given, this may take a while!
Watches established.

立ち上がりっぱなしになるので、
別の端末で ~/TOFUBOX/ の中をリスト10のように操作すると、

・リスト10: ~/TOFUBOX/ にちょっかいを出す。

1
2
3
ueda@X201:~/TOFUBOX$ touch hoge
ueda@X201:~/TOFUBOX$ rm hoge
ueda@X201:~/TOFUBOX$ cat ~/TESTDATA | head -n 1000 > hoge

inotifywait を立ち上げた画面には、
ファイル操作のログのようなものが出てきます。

・リスト11: inotifywait の出力

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/home/ueda/TOFUBOX/ OPEN hoge
/home/ueda/TOFUBOX/ ATTRIB hoge
/home/ueda/TOFUBOX/ CLOSE_WRITE,CLOSE hoge
/home/ueda/TOFUBOX/ DELETE hoge
/home/ueda/TOFUBOX/ CLOSE_WRITE,CLOSE hoge
/home/ueda/TOFUBOX/ MODIFY hoge
/home/ueda/TOFUBOX/ OPEN hoge
/home/ueda/TOFUBOX/ MODIFY hoge
...
/home/ueda/TOFUBOX/ MODIFY hoge
/home/ueda/TOFUBOX/ MODIFY hoge
/home/ueda/TOFUBOX/ CLOSE_WRITE,CLOSE hoge

 ということは、これを立ち上げておいて、
ファイルに変更があったときだけ、
クライアントからサーバへの同期を行えばよいということになります。
また、 inotifywait
はリスト11のようにファイルに関する様々なイベントに反応しますが、
-e というオプションで
同期するファイルができるときのイベントだけ引っ掛けることもできます。
(リスト12内で使用しています。)

 さっき作った空のスクリプト TOFUBOX.WATCH には、
この役目をさせるつもりでした。
リスト12のように TOFUBOX.WATCH を実装します。

・リスト12: TOFUBOX.WATCH

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
ueda@X201:~/.tofubox$ cat TOFUBOX.WATCH
#!/bin/bash

dir=/home/usp/TOFUBOX
sys=/home/usp/.tofubox

touch $sys/PUSH.REQUEST

inotifywait -e moved_to -e close_write -mr $dir |
while read str ; do
        [ -e $sys/PUSH.REQUEST ] && touch $sys/PUSH.WAIT
        touch $sys/PUSH.REQUEST
done

TOFUBOX.WATCH は、

  • ファイルが ~/TOFUBOX/ に移動してきた
  • ~/TOFUBOX/ 内でなにかファイルの書き込みが終わってファイルが閉じられた

の二つの事象を監視し、これらが起こったら、
~/.tofubox/ の下に PUSH.REQUEST
PUSH.WAIT いうファイルを置きます。
PUSH.WAIT は、 PUSH.REQUEST がすでにあるときに置きます。

 そして、 TOFUBOX.SYNC 内の、
クライアントのディレクトリをサーバに同期しにいく部分
(リスト1の31~35行目)
を次のように書き換えます。

・リスト13:クライアント->サーバ同期のコード変更

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#push############################
while [ -e "$sys/PUSH.REQUEST" ] ; do
        MESSAGE "送信開始"

        rsync -auz --timeout=30 $dir/TOFUBOX/ $server:$dir/TOFUBOX/
        ERROR_CHECK "送信中断"

        rm $sys/PUSH.REQUEST
        [ -e $sys/PUSH.WAIT ] && mv $sys/PUSH.WAIT $sys/PUSH.REQUEST

        MESSAGE "送信完了"
done

 rsync がうまくいったら、 PUSH.REQUEST を消します。
この間に inotifywait が反応していたら、
PUSH.WAIT ができているのでこれを
PUSH.REQUEST に名前を変えてもう一回 rsync します。
通信が途切れなければという条件はつきますが、
クライアント側でファイルを書き換えている時はずっとロックを持ったままで
rsync が続きます。

 この実装には一つ問題があって、これだとサーバ側にデータ変更があり、
クライアント側の ~/TOFUBOX/ に変更があったら
PUSH.REQUEST ができるので、
一度無駄な書き込みが起こります。これはご愛嬌ということで。

11.4.2. 本当に受信したときだけ通知する

 読み込みの方は定期的に rsync をかけておいてもよいのですが、
rsync が実際にファイルを読み込んでいないのに通知が出るのはかっこ悪い。
実際に読み込んだら通知を出さないと、有用な情報になりません。
余談ですが、弊社ではこういう通知を出すことは厳重な作法違反とされています。
これもなんとかしましょう。

 今度は、 inotifywait とは別のアプローチをとってみましょう。
(実は書き込みでも同じ方法が使えますが。)
ややこしいので、先にコードを見せます。
リスト1の23行目、ロックを取りに行った後のコードにリスト14のコードを加えます。

・リスト14:サーバ->クライアント同期のコード変更

1
2
3
4
5
6
7
#同期の必要がなければすぐ終了
NUM=$(rsync -auzin --timeout=30 $s $c | wc -c)
#通信に失敗した、あるいは同期済みなら終了
if [ "$NUM" = "" -o "$NUM" -eq 0 ] ; then
        ssh -o ConnectTimeout=5 $server "rmdir $sys/LOCK"
        exit 0
fi

 何をやっているのかというと、二行目で rsync を空実行して同期の必要を探り、
同期の必要があれば、そのまま下に書いてある読み込み処理(と書き込み処理)を実行します。
必要がなければif文内の処理でロックを返上します。

 二行目の rsync には、 in というオプションがついています。
rsynci を指定すると、以下のように更新のリストが表示されます。

#iで更新のリストを表示
ueda@uedaubuntu:~$ rsync -auzi tofu.usptomonokai.jp:~/hoge ./
cd+++++++++ hoge/
>f+++++++++ hoge/file1
>f+++++++++ hoge/file2
>f+++++++++ hoge/file3
#もう一度実行すると、すでに同期済みなのでなにも表示されない
ueda@uedaubuntu:~$ rsync -auzi tofu.usptomonokai.jp:~/hoge ./
ueda@uedaubuntu:~$

また、 n を指定すると、 rsync は同期処理をしません。
ドライランというやつです。

 したがって、二行目の rsync では、実際に同期は行わず、
同期に必要なファイルのリストがあれば、そのリストを出力します。
その出力を wc -c に通して、 $NUM という変数に代入しています。
同期の必要がなければなにもリストが出ないので、
$NUM はゼロになり、あれば非ゼロになります。

 これで不必要な通知は画面に出なくなります。

11.5. 完成!

 余計な通知が出なくなったところで、完成としましょう。
整理した TOFUBOX.SYNC をGitHub
(ryuichiueda/SoftwareDesign/201211の下,
https://github.com/ryuichiueda/SoftwareDesign/blob/master/201211/client/TOFUBOX.SYNC
に掲載しておきました。
整理と言っても、記号類がごちゃっとして綺麗ではありませんが・・・。
これはコードの短さに免じて許してやってください。
結局、クライアント側のコードは118行、サーバ側のコードは20行となりました。
たった138行です。

11.6. おわりに

 前回と今回で、オンラインストレージもどき「豆腐ボックス」
を作りました。出てきたテクニックをまとめると次のようになります。

  • sshとrsyncのタイムアウト
  • rsyncの使い方あれこれ
  • notify-send
  • inotify(inotifywait)
  • mkdir を使った排他制御
  • service
  • sshを使ったリモートからのコマンド実行

 筆者はこれらの何一つエキスパートということはないのですが、
manを読んで、webで調べて、シェルスクリプトで組み合わせるだけで、
なんとか豆腐ボックスを作りました。
ユーザが使えるOSの機能はほとんどコマンドで準備されます。
ですから、シェルスクリプトを書けると機能を総動員することができます。
これが、シェルスクリプトでアプリケーション
(あるいはアプリケーションのプロトタイプ)
を書く一番の利点でしょう。

 もしかしたら、他にもっと便利な機能があって、
もっとコードを短くすることができるかもしれません。
また、今回はやらなかったファイル消去の同期も可能かもしれません。

 次回からは、Maildirに蓄えたメールをさばくというお題に取り組みます。

Pocket
LINEで送る