この言葉をご存知だろうか。
これはGancarzのUNIX哲学[Gancarz2001]※1と言われるものだ。UNIX系OSの使い方や世界観をまとめたもので、大雑把に言い切ってしまえば「ターミナルやシェルスクリプトでコマンドを使い倒して早く仕事しよう」 ということになる。
ここで言う仕事というのは、エンジニアがコンピュータのために仕事をするということではなく、顧客データの管理や表計算、原稿書きなどの作業のことだと考えてもらえればよいだろう。UNIXの使い方がコマンドライン中心だった時代の考え方のひとつだ。
テキストデータには変換が容易という特徴がある。GancarzのUNIX哲学で「単純なテキストファイルにデータを格納せよ」と言っているのはまさにこのことだ。データをテキストの形式で保持しておけば、特定のソフトウェアの仕様に束縛されることはない。[Raymond2007]※2にもUNIXはテキスト文化だから気軽にプログラムに入門できる効用があるという説明がある。
「短いシェルスクリプト」に開眼するにはある一定の訓練期間が必要だ。たとえば、なにかしらのプログラミング言語を知っている方がシェルスクリプトを記述するとインデントとfor構文だらけになりがちだ。ここでは「シェルスクリプトの便利な利用例」と同じ重要度で、「短いシェルスクリプトを書く手練手管」も紹介していく。シェルスクリプトを使うことにピンと来ない人でも「深いインデント回避のためのロジック」を楽しんでいただけたらと思う。
この文章を書いているのはシェルスクリプトで業務システムを構築する企業ユニバーサル・シェル・プログラミング研究所に勤務し、毎日シェルスクリプトばかり書いている男だ。短く書いて早く仕事を終わらせることにのみ心血を注いでいる。
業務外ではUSP友の会というシェルスクリプトの会の会長として、主に技術以外のところで会の舵取りを行ってる(@usptomo, facebook)。シェルスクリプトに興味をそそられるようであれば、ぜひUSP友の会が参加するイベントを訪ねていただければと思う。
前職では大学でロボットのプログラムばかり書き、学生にもそれを強要していた。映像処理にはじまり、カメラの制御、その他人工知能的なものをひたすらプログラムしており、主にC++を使っていた。現在もロボカップ(ロボットサッカーの大会)の日本大会の手伝いをしている。
今回はシェルスクリプトに慣れるという目的で、バックアップを実施するプログラムを組む方法を紹介する。まず、次の状況を想定する。
古いバックアップファイルを間引く際には日付の計算をすることになるので、バックアップ自体よりも日付の計算をどのようにシェルスクリプトで書くかということが今回のケースでは鍵になる。
まずは/var/www/をバックアップするシェルスクリプトを書いてみよう。tar(1)コマンドで/var/www/ディレクトリのファイルをまとめ、/home/ueda/WWW.BACKUP/というディレクトリに置くことにする。
シェルスクリプトは次の通りだ。シェルスクリプト名は/home/ueda/SYS/WWW.BACKUPとした。バックアップファイルの置き場所は前述したように/home/ueda/WWW.BACKUP/、ファイル名はwww.日付.tar.gzとした。ディレクトリ名やファイル名の命名規則は、ユニケージ開発手法※3の「作法」に従っているが、ここではあまり気にしなくてもよい。
#!/bin/sh -vx dest=/home/ueda/WWW.BACKUP tmp=/tmp/$$ today=$(date +%Y%m%d) #/tmpに/var/www/の内容を固めて圧縮 tar zcvf $tmp.tar.gz /var/www/ #バックアップファイルの置き場所に移動 mv $tmp.tar.gz ${dest}/www.${today}.tar.gz
1行目はスクリプトを読み込むインタプリタを指定するための行となっている。この#!のことを「シバン(shebang)」と呼ぶ。
#!/bin/shの後ろの-xvは、シェルスクリプト実行時にログが表示されるようにするオプションだ。3~5行目は、変数を指定している。シェルの変数は単に文字列を格納するだけだ。=で変数と文字列を結ぶように記述する。
変数名=値となる文字列
3行目でdestという変数に/home/ueda/WWW.BACKUPという文字列が格納されることになる。=の両側には空白を入れてはいけない。空白を入れるとシェルが、1つ目の文字列をコマンドだと解釈してしまう。変数destは、シェルスクリプト中で$destや${dest}と書くと値に置き換わる。
4行目は、tmpという変数に/tmp/という文字列と$$という変数の値をくっつけた文字列を格納している。次のように確認できる。
$ tmp=/tmp/$$ $ echo $tmp /tmp/8389 $
$$は予約変数で、このシェルスクリプトのプロセス番号が格納されている。プロセス番号はグローバル名前空間においてユニークなので、プロセス番号を入れることでファイル名の衝突を防ぐことができる。
変数todayには、dateコマンドから出力される文字列が格納される。これは言葉で説明するより、次のように実行した方がわかりやすいだろう。
$ date +%Y%m%d 20120612 $ today=$(date +%Y%m%d) $ echo $today 20120612 $
変数を定義したら、あとはバックアップするコマンドを実行すればよい。9行目で$tmp.tar.gzというファイルに/var/www/の内容が圧縮保存される。このスクリプトでは/tmp/以下に作成したバックアップファイルを10行目のmv(1)で/home/ueda/WWW.BACKUP/ディレクトリに移動させている。これは、途中でスクリプトが止まったときに、中途半端なバックアップファイルが/home/ueda/WWW.BACKUP/ディレクトリにできないようにする配慮だ。
実行すると次のようにログが出力されることを確認できる。+印の行に実行されたコマンドが表示されている。
$ /home/ueda/SYS/WWW.BACKUP #!/bin/sh -vx dest=/home/ueda/WWW.BACKUP + dest=/home/ueda/WWW.BACKUP tmp=/tmp/$$ + tmp=/tmp/9174 today=$(date +%Y%m%d) date +%Y%m%d) date +%Y%m%d ++ date +%Y%m%d + today=20111022 #/tmpに/var/www/の内容を固めて圧縮 tar zcvf $tmp.tar.gz /var/www/ + tar zcvf /tmp/9174.tar.gz /var/www/ tar: Removing leading `/' from member names /var/www/ /var/www/html/ (中略。保存対象のファイルやディレクトリが羅列される) #バックアップファイルの置き場所に移動 mv $tmp.tar.gz ${dest}/www.${today}.tar.gz + mv /tmp/9227.tar.gz /home/ueda/WWW.BACKUP/www.20111022.tar.gz $
/home/ueda/WWW.BACKUP/ディレクトリにバックアップファイルが作成されていれば成功だ。解凍できるか試してみてほしい。
/home/ueda/SYS/WWW.BACKUPをcron(8)などを使って毎日実行したケースを想定する。これらのファイルを適切に間引くという処理を/home/ueda/SYS/WWW.BACKUPに追加する。具体的には「直近一週間のバックアップファイルを残し、あとは毎週日曜のバックアップファイルだけを残す」という処理を記述する。
プログラミングに慣れた方であれば「for構文中で一つずつバックアップファイルの日付を調べ、if構文で処理を場合分けし・・・」というコードを想定するだろう。しかしシェルスクリプトでそれをやってしまうと、読みやすいコードにはならない。シェルが得意なのはファイル入出力とパイプライン処理であり、これらを駆使して入れ子の少ない平坦なコードを書いたほうがよい。
まず、上で書いたシェルスクリプトについて、tar(1)コマンドを使う前の部分をリスト3のように書き加える。初めて見た方のために補足すると、パイプ|はコマンドの出力を次のコマンドに渡すための記号、リダイレクト>は、コマンドの出力をファイルに保存するための記述である。
14行目から18行目はデバッグのためのコードで、「昔のバックアップ」のダミーファイルを作っている。この部分は最後には削除する。もしwhileがうまく動かなければ、ターミナルで手打ちでダミーファイルを作っても構わない。17行目のdate(1)コマンドの使い方はあまりなじみが無いかもしれないが、-dというオプションを使うと日付の演算ができる※4。20行目以降は古いファイルを間引くパートだ。記述はまだ途中で、この段階ではファイルの日付を取得して表示しているだけである。
#!/bin/sh -vx #ログをlogというファイルに保存する exec 2> ./log dest=/home/ueda/WWW.BACKUP tmp=/tmp/$$ today=$(date +%Y%m%d) ############################################################### #デバッグのため、ダミーファイルを作る #稼動時には消す。 d=20100101 while [ $d -lt $today ] ; do touch $dest/www.$d.tar.gz d=$(date -d "${d} 1 day" +%Y%m%d) done ############################################################### #古いファイルの削除 #移動 cd $dest #ファイル列挙 ls | #ドットを区切り文字にして第二フィールド(=日付)を取り出す。 cut -d. -f2 | #日付ではないものを除去 egrep "[0-9]{8}" | #念のためソート sort > $tmp-days #デバッグのために出力 cat $tmp-days rm -f $tmp-* exit 0 (以下略。tar(1)コマンドの処理が書いてある。)
上記シェルスクリプトではGNU Core Utilitiesのdate(1)コマンドを使うことを想定している。FreeBSDでBSD dateを使う場合には、date(1)コマンドの実行部分を次のように書き換えればよい。
d=$(date -v+1d -j -f "%Y%m%d" "${d}" "+%Y%m%d")
$tmp-daysに日付の一覧ができたので、この中からファイルを消すべき日付を抽出する。シェルスクリプトを書いたことのある方は、読み進む前にぜひコードを考えてみてほしい。コードにはif構文はひとつも必要ない。
$tmp-daysを求めた後の部分は次のようになる。2行目から13行目で、直近7日分の日付を書いたファイルと、日曜日を書いたファイルを作成している。その後、17, 18行目で「直近7日分でも日曜日でもない日付」を抽出している。
6~9行目のwhile構文で日付に曜日を付加している。6行目で$tmp-daysの内容がパイプから1行ずつ読み込まれて変数dにセットされている。7行目のdateで、変数dの日付に曜日が付けられる。date(1)コマンドの出力は、doneの後のパイプからgrep(1)に渡っている。
#直近7日分の日付 tail -n 7 $tmp-days > $tmp-lastdays #日曜日 cat $tmp-days | while read d ; do date -d "${d}" +"%Y%m%d %w" #1:日付 2:曜日(ゼロが日曜) done | #第二フィールドが0のものだけ残す grep "0$" | #曜日を消す cut -d" " -f1 > $tmp-sundays #days,lastdays,sundaysをマージして、 #一つしかない日付が削除対象 sort -m $tmp-{days,lastdays,sundays} | uniq -u > $tmp-remove #デバッグのため出力 cat $tmp-remove rm -f $tmp-* exit 0
FreeBSDを使っている場合にはdate(1)コマンドのところは次のようにBSD dateの形式へ変更する。
date -v+1d -j -f "%Y%m%d" "${d}" "+%Y%m%d %w"
18行目のuniq -uは、一個だけしかない日付だけ出力するという動きをする。これで、$tmp-daysにあって、$tmp-lastdaysや$tmp-sundaysに無い日付だけが出力されるので、「直近7日でも日曜でもない日付=ファイルを消す日付」が得られる。sort(1)とuniq(1)だけでこのような演算ができるということに気づくにはちょっと経験が必要だが、2行で済んでしまう破壊力は抜群だ。
最後に消去する処理を実装する。xargs(1)コマンドを使えば1行で実装できる。次のように、uniq -uの後にパイプで接続すればよい。
#days,lastdays,sundaysをマージして、 #一つしかない日付が削除対象 sort -m $tmp-{days,lastdays,sundays} | uniq -u | #消去対象日付がパイプを通って来る。 xargs -I '{}' rm www.'{}'.tar.gz
xargs(1)はパイプから受けた文字を指定に従って実行するコマンドだ。この例ではwww.と.tar.gzの間に日付を一つずつ入れてからrm(1)コマンドを実行している。
最後に、体裁を整えたシェルスクリプトを次に示す。本文で触れていない小細工も盛り込んであるので、解析してみてほしい。
#!/bin/sh -vx # # /var/wwwのバックアップ # # written by R. UEDA (USP研究所) Oct. 10, 2011 # exec 2> /home/ueda/LOG/LOG.$(basename $0).$(date +%Y%m%d) dest=/home/ueda/WWW.BACKUP tmp=/tmp/$$ today=$(date +%Y%m%d) ############################################################### #古いファイルの削除 #移動 cd $dest #ファイル列挙 ls | #ドットを区切り文字にして第二フィールド(=日付)を取り出す。 cut -d. -f2 | #日付ではないものを除去 egrep "[0-9]{8}" | #念のためソート sort > $tmp-days #直近7日分の日付のリスト tail -n 7 $tmp-days > $tmp-lastdays #日曜日のリスト cat $tmp-days | while read d ; do date -d "${d}" +"%Y%m%d %w" #1:日付 2:曜日(ゼロが日曜) done | #第二フィールドが0のものだけ残す grep "0$" | #曜日を消す cut -d" " -f1 > $tmp-sundays #days,lastdays,sundaysをマージして、 #レコードが一つしかない日付が削除対象 sort -m $tmp-{days,lastdays,sundays} | uniq -u | xargs -I '{}' rm www.'{}'.tar.gz ############################################################### #バックアップ #/tmpに/var/www/の内容を固めて圧縮 tar zcvf $tmp.tar.gz /var/www/ >&2 #バックアップファイルの置き場所に移動 mv $tmp.tar.gz ${dest}/www.${today}.tar.gz rm -f $tmp-* exit 0
FreeBSDで実行する場合、date(1)コマンドの指定をBSD date形式に変更するのを忘れずに実施しておきたい。
シェルスクリプトではコメントは豊富に書くことを推奨しておきたい。使った意図を書いておかないと後から読んだ時に意味不明になることが多い。
今回の例で気づいた方もいらっしゃると思うが、短いシェルスクリプトを書けるようになる第一歩はファイルを配列の代わりに使う癖を付けることにある。grep(1)やuniq(1)などのコマンドの多くもそうした使い方を前提に作られている。
今回は、シェルスクリプトを書く動機について説明し、バックアップというお題に対するシェルスクリプト/home/ueda/SYS/WWW.BACKUPを作成した。/home/ueda/SYS/WWW.BACKUPは57行のスクリプトで、そのうちコードが23行、コメントと空白が34行だ。制御構文はwhile構文がひとつあるのみでif構文は使っていない。
以下が今回の重要な点といえる。
※1 [Gancarz2011] Mike Gancarz (著), 芳尾 桂 (翻訳): UNIXという考え方 ーその設計思想と哲学, オーム社, 2001.
※2 [Raymond2007] Eric S.Raymond (著), 長尾 高弘 (翻訳): The Art of UNIX Programming, アスキー, 2007.
※3 ユニケージはユニバーサル・シェル・プログラミング研究所の登録商標。
※4 date(1)の-dオプションはGNU Core Utilities dateに限定されたオプション。FreeBSDなどを使っている場合にはBSD dateの方法で記述する。
Software Design 2012年1月号 上田隆一著、「テキストデータならお手のもの 開眼シェルスクリプト 【1】短いスクリプトを書くコツ―ファイルを配列代わりに使う」より加筆修正後転載