30 Dec 2010

中規模データ処理で学んだ tips

数十 ~ 数百GB くらいのデータを変換したり転送したりといった作業を最近をしていました. このくらいの規模だと, そこそこ量があるのでさっと片付けることはできないけども, 大規模なデータセットという訳ではないので腰をすえて取り組む感じでもないので, なかなか面倒な作業になります. こういう "dirty" だけど必ず誰かがやらないといけない仕事は, できるだけ手早くスマートに片付けてしまいたいものです. ここでは自分の脳内 dump も兼ねて, 作業をするときに役に立った tips や考えたことなどを列挙していきたいと思います.

心構え

自動化しよう. でもやりすぎないように

言うまでもありませんが, 怠惰は美徳です. この手の作業はバッチ化しやすいので, どんどん自動化していきましょう. ただしバッチ処理に凝りすぎてしまうのも考えものです. だいたい 8, 9 割うまく動くスクリプトをさっと書いて, イレギュラーなケースには手で対応するのが最終的に一番早いように思えました.

小さくテストする

データの量が大きいので処理を途中で失敗するとやり直しがかなり面倒です. すぐにスクリプトを走らせるよりも, 小さなテストデータを準備し, 事前によく動作確認しておくほうが, やり直しが減り早く仕事を片付けられます.

見積もり重要

このような単純作業でも見積もりはやはり重要です. 例えば処理時間の見積もりだと, 処理の1ステップあたりにどのくらい時間がかかるかを計測し, それに必要なステップ数を掛ける程度の計算で十分です. 作業はいつ終わるのかだけでなく, バッチ処理の改善が必要なのか, ディスク容量が足りるのかなど, 作業の作戦をたてる時の大事な情報になります.

進捗を可視化する

何がどこまで終わっているかを可視化すると, 作業抜け, 報告時に有用です. また複数人で作業する場合は必ず必要ですね.

リソースに注意する

top や df などで CPU やメモリ, ディスク使用量をチェックするようにして, リソースの使い過ぎに注意しましょう. 早く終わらせようと思って並列で処理を走らせたりすると CPU のリソースを食ってしまったり, あるいは出力する中間ファイルが大きすぎてディスクがフルになってしまったりします. サーバに監視設定がされていたりしたら, 不用意なアラートが飛んでしまうかもしれません.

bash でのループ

次の書式で bash でループを書くことができます.

# 1 から 10 を echo する
for i in {1..10}; do echo ${i}; done;

連番のファイルやサーバに対して処理を行うときに便利です. 2桁の0補完で表示するなど, フォーマット出力をしたい場合は, 少し面倒ですが printf コマンドと組み合わせるしかなさそうです. [2010-12-31] id:shiumachi さんに教えていただいた seq コマンドを使う方法について追記しました. 記事下部にも追記があるのであわせて御覧ください

# img_001.jpg - img_128.jpg を ping に変換
for i in {1..128}; do num=`printf %03d ${i}`; convert img_${num}.jpg img_${num}.png; done;

# seq を使った方法
for i in `seq -w 1 10`; do echo ${i}; done 

また in 演算子の右辺にはグロブも使えます. その場合はマッチするファイルに対してループすることができます.

# *.tmp ファイルを chmod
for f in *.tmp; do sudo chmod 644 ${f}; done;

tar コマンドの tips

標準入出力との組み合わせ

"-" (ハイフン) を使うことで圧縮元/展開先を標準入出力にすることができます.

# tar に固めた内容を標準出力へ (端末の表示が崩れることがあるので注意)
tar -cf - test.txt

# 標準入力の内容を展開
cat foo.tar.gz | tar -zxf -

この挙動がうれしいのは ssh と組み合わせた時です. 以下のようにパイプでリモートホストにデータを送信すると, ローカルに一時ファイルを作る必要がなくなります. 作業しているサーバのディスク容量が不足している時などに重宝します.

# test/ ディレクトリの中身を tar + gzip 圧縮し, パイプでリモートへ送信.
# リモートではパイプからの入力を展開している
tar -zcf - test/ | ssh remote.example.com 'tar -zxf -'

# 普通にこのようにした場合は test.tar.gz 分のディスク容量が必要になる
tar -zcvf test.tar.gz test/
scp test.tar.gz remote.example.com:.

あるいは, ホストAからホストBへデータを送りたいが, A -> B へ直接送信できず, ホストCを経由する必要がある場合を考えます. このようなときは, ホストCから両方のホストへ ssh しパイプを使うと1コマンドでファイルの転送が可能です.

# ホスト C で作業
# host_A から host_B へ test/ ディレクトリの内容を転送.
ssh host_A.example.com 'tar -zcf - test/' | ssh host_B.example.com 'tar -zxf -'
アーカイブされている内容の確認

"-t" オプションで tar アーカイブの内容を実際には展開せずチェックすることができます.

tar -tvf foo.tar.gz
展開先の指定

"-C" オプションで展開時の出力ディレクトリを指定できます.

# foo.tar.gz を /tmp に展開する
tra -C /tmp -zxvf foo.tar.gz
アーカイブの一部を展開

具体的にファイル・ディレクトリ名を与えることで, アーカイブ全てではなく一部だけを展開できます.

# bar.txt のみを展開
tar -zxvf foo.tar.gz foo/bar.txt

# foo/bar ディレクトリを展開
tra -zxvf foo.tar.gz foo/bar

ただし展開時にすべてのファイルを走査しているようなので, 展開にかかる時間は通常と同じです. 展開にかかる時間の短縮にはなりませんでした.

複数ファイルの展開

tar コマンドは複数のアーカイブファイルを指定して展開することはできないようです. 次のように複数ファイルを展開時に指定しても, 二番目以降のファイルは, 上記のように一番目のアーカイブファイルの中の特定のファイルと認識されてしまいエラーになります.

% tar -zxvf foo.tar.gz bar.tar.gz
tar: bar.tar.gz: Not found in archive
tar: Error exit delayed from previous errors.

複数ファイルを展開したい場合は, 面倒ですがシェルの for 文や find コマンドと組み合わせる必要があります.

# bash の for で
for f in *.tar.gz; do tar -zxvf ${f}; done

# find と -exec で
find . -name "*.tar.gz" -exec tar -zxvf {} \; 

# find と xargs で
find . -name "*.tar.gz" -print0 | xargs -0 -n1 tar -zxvf

3つ目の例はこちらの記事 (xargs を使って tar で複数ファイルを解凍する - With skill and creativeness) を参考にさせていただきました.

sort コマンド

ソート時の条件指定

ソート時にどのフィールドで並べ替えるか (ソートのキー) を -k オプションで指定できます. デリミタは -t オプションで指定します (デフォルトは空白).

例えば /etc/passwd は値が ":" で区切られています. 以下のように書くと, まず /etc/passwd の3番目のフィールドでソートし, それが同じ場合は4番目のフィールドでソートするという意味になります.

% sort -t : -k 3,3 -k 4,4 /etc/passwd > ~/mypasswd

詳しくはこちらを参照: no title

-c オプションでソート済みかどうかをチェック

sort コマンドに -c オプションをつけると, そのファイルがソート済みかどうかを確認できます. ソートされていない場合は, 最初に見つかった未ソートの行が表示されるようです. 処理後の簡易チェックに使いました.

% cat test.txt
a
b
c
d
a
% sort -c test.txt
sort: test.txt:5: disorder: a
ソート済みファイルのマージ

すでにソートされているファイルが複数あり, それらを1つのファイルにマージしたい場合は -m オプションを使います. ファイルを cat でひとまとめにしてから普通にソートするよりもこちらの方が高速です.

% sort -c foo.txt
% sort -c bar.txt
% sort -c baz.txt
% sort -m foo.txt bar.txt baz.txt > foobarbaz.txt

split コマンド

split コマンドでファイルを分割できます. -b オプションで分割後の1ファイルあたりのバイト数, あるいは -l オプションで行数, -p オプションで区切り文字を指定します.

# 100バイトずつ分割
split -b 100 test.txt

# 100キロバイトずつ分割
# -b オプションは k(キロバイト), m(メガバイト)を認識する
split -b 100k test.txt

# 100行ずつ分割
split -l 100 test.txt

# "--" 区切りで分割
split -p "--" test.txt

分割後のファイルは xaa, xbb といったように, "x" + "[aa-zz] の2文字の suffix" という命名規則で保存されます.

% split -b 100k test.txt
% ls -lh
total 4304
-rw-r--r--  1 kosei  staff   1.0M 12 29 23:01 test.txt
-rw-r--r--  1 kosei  staff   100K 12 29 23:05 xaa
-rw-r--r--  1 kosei  staff   100K 12 29 23:05 xab
-rw-r--r--  1 kosei  staff   100K 12 29 23:05 xac
-rw-r--r--  1 kosei  staff   100K 12 29 23:05 xad
-rw-r--r--  1 kosei  staff   100K 12 29 23:05 xae
-rw-r--r--  1 kosei  staff   100K 12 29 23:05 xaf
-rw-r--r--  1 kosei  staff   100K 12 29 23:05 xag
-rw-r--r--  1 kosei  staff   100K 12 29 23:05 xah
-rw-r--r--  1 kosei  staff   100K 12 29 23:05 xai
-rw-r--r--  1 kosei  staff   100K 12 29 23:05 xaj
-rw-r--r--  1 kosei  staff    74K 12 29 23:05 xak

ファイル名のあとに文字列を渡すと, "x" の代わりに prefix として使用されます. また -a オプションで suffix の長さを指定できます.

% split -b 100k -a 3 test.txt test.splitted.
% ls -lh
total 4304
-rw-r--r--  1 kosei  staff   100K 12 29 23:19 test.splitted.aaa
-rw-r--r--  1 kosei  staff   100K 12 29 23:19 test.splitted.aab
-rw-r--r--  1 kosei  staff   100K 12 29 23:19 test.splitted.aac
-rw-r--r--  1 kosei  staff   100K 12 29 23:19 test.splitted.aad
-rw-r--r--  1 kosei  staff   100K 12 29 23:19 test.splitted.aae
-rw-r--r--  1 kosei  staff   100K 12 29 23:19 test.splitted.aaf
-rw-r--r--  1 kosei  staff   100K 12 29 23:19 test.splitted.aag
-rw-r--r--  1 kosei  staff   100K 12 29 23:19 test.splitted.aah
-rw-r--r--  1 kosei  staff   100K 12 29 23:19 test.splitted.aai
-rw-r--r--  1 kosei  staff   100K 12 29 23:19 test.splitted.aaj
-rw-r--r--  1 kosei  staff    74K 12 29 23:19 test.splitted.aak
-rw-r--r--  1 kosei  staff   1.0M 12 29 23:01 test.txt

また当然ですが, 分割後のファイルは cat するともとに戻ります.

% cat test.splitted.* > test2.txt
% diff test.txt test2.txt
% 

個人的にははじめこのコマンドを知らず, head/tail で頑張っていたので非効率でした.

tr コマンド

tr コマンドはファイル中の文字を置換します. 使える場面も限定的だし, また後述する perl のワンライナーや sed などでも同様のことはできますが, 簡単なものならばこちらのほうがタイプ数が少ないです.

# 文字列が制御文字 (^A) で区切られている test.txt の 制御文字をタブに置換

% less test.txt
foo^Abar^Abaz
% tr '^A' '    ' < test.txt
foo     bar     baz

また -d オプションで文字の削除もできます.

# 制御文字 (^A) を削除
% tr -d '^A' < test.txt

grep コマンド

grep コマンドに -A <行数>, -B <行数> というオプションをつけると, マッチした行の前後の行を表示してくれます. その場合はマッチ結果の区切りとして "--" を入れてくれるので便利です.

% head test.txt
pmpayqhvud
aiyvauawgd
...
% grep -A 2 -B 2 'abc' test.txt
xvncqjuvmv
rxnddlupxi
bclxxxabcn
gwsodygjzy
kpvaywezck
--
blapryagpl
ezedrjvprh
labcbiecdm
fbxazbfkzy
...

A/B はそれぞれ After/Before の頭文字だと思います.

od コマンド

ファイルの内容を16進数でダンプするのに od -tx1c をよく使いました. データの区切りに使われる制御文字や開業などの確認のためです. 前述の grep -A -B と組み合わせると効果的でした.

% echo -n "foo^Abar^Abaz" > test.txt
% od -tx1c test.txt
0000000    66  6f  6f  01  62  61  72  01  62  61  7a                    
           f   o   o 001   b   a   r 001   b   a   z                    
0000013

ssh コマンド

verbose モード

ssh コマンドに -v オプションを付けると, 接続時の色々な情報が表示されます. なんかうまくつながらないなあという時のデバッグに非常に便利です.

秘密鍵ファイルを指定

ssh コマンドはデフォルトでは ~/.ssh/ 内の id_rsa や identity ファイルを見にいきますが, -i コマンドで秘密鍵ファイルを明示的に指定できます.

pseudo-tty を強制する

ssh 先で sudo するようなコマンドを送信すると, パスワードを求められた際に, 入力が端末に表示されてしまいよくありません. そんな時は -t オプションをつけると解決できます.

% ssh -t remote01.example.com 'sudo ls -la'

あるいは, ssh 先に ssh コマンドを投げるような場合は, -t オプションをつけて擬似端末を割り当てないとエラーが出ます.

% ssh -t remote01.example.com 'ssh remote02.example.com ls -la'

多段 ssh/scp/rsync を行う

踏み台用のプロキシサーバを経由して目的のサーバにログインしないといけないようなケースはよくあると思います. プロキシサーバへ ssh でログインし, そのあと目的のサーバに入る ... ということを毎回やっていては面倒です. ssh_config の "ProxyCommand" という設定項目と nc や connect.c というツールを組み合わせると, 非常に簡単に多段 ssh が実現できます.

詳しい手順は上記の記事を読んで欲しいのですが, 簡単には,

  1. プロキシサーバにツール(nc か connect.c)を準備する.
    • nc は OpenBSD, RedHat 系には標準で入っているらしいです. ない場合は ”connect.c” を使います.
  2. 接続する大元のマシンの ~/.ssh/config に以下を記述
# 目的の接続先
Host remote.example.com
     # remote.example.com (目的の接続先) のユーザ名
     User 
     # proxy.example.com を踏み台用プロキシとする
     ProxyCommand ssh proxy.example.com nc %h %p

以上の設定を行うと, ローカルのマシンで,

% ssh remote.example.com

とするだけでプロキシを経由し目的の remote.example.com に接続できます.

expect でパスワード入力を自動化

expect はパスワード入力など, インタラクティブなプロンプトを伴うコマンド操作を自動化できるツールです. 簡単には以下のように, spawn で実行したいコマンドを指定し, expect ブロックでマッチさせるパターンと, その時の動作を書いていきます. expect の文法は tcl らしいです.

#! /usr/bin/expect

# タイムアウトを無限に設定
set timeout -1

set password ""

# ここにコマンドを記述
spawn your_command.sh

# パスワードを取得する関数
proc get_password {} {
  global password
  stty -echo
  send_user " (for expect) "
  expect_user -re "(.*)\n"
  send_user "\n"
  set password $expect_out(1,string)
  stty echo
}

expect {
  # "Password:" にマッチするとブロックを実行
  "Password:" {
    # まだパスワードを知らなければ聞く
    if {[string length $password] eq 0} {
      get_password
    }
    # 取得したパスワードを送信
    send "$password\r"
    send_user "(sent by expect)\n"
    exp_continue
  } "Sorry, try again." {
    send_user "incorrect password\n"
    exit
  } eof {
    exit
  }
}

これはあまり意味のない例かもしれませんが, このようにすると sudo のパスワードを一度入力すると記憶して補完してくれます.

より詳しくは man EXPECT(1) が参考になりそうです. *1

perl のワンライナー

この手の作業をやるにあたって, perl のワンライナーは本当に強力な武器となります. こちらの 弾さんの記事に必要十分にまとまっているので, 一読をおすすめします.

個人的には -p, -i オプションによるファイル内容の置換をよく使いました.

# test.txt 内の foo を bar に置換し, 結果を上書き, 
# かつ置換前のファイルを test.txt.bak という名前でバックアップ
perl -i.bak -ple 's/foo/bar/g;' test.txt

この例くらいだと sed で十分なのですが, 置換にややこしい正規表現がでてきたり, 置換以外にも複数の処理を行う場合には perl でやったほうが簡単だと思います (個人的に perl の方が書き慣れているということもありますが).

その他の perl の tips

標準出力のバッファリングをしない

perl での処理の進捗やログを標準出力に出力させようとしたとき, デフォルトでは出力がバッファリングされているので, 意図した順で結果が表示されない場合があります. そんな時は特殊変数 "$|" か IO::Handle を使ってバッファリングをオフにすることで解決できます.

# $| に 0 でない値をセットすると, 標準出力がバッファされない
$| = 0;

# あるいは IO::Handle
use IO::Handle;
STDOUT->autoflush(1);

json ファイルのバリデーション・整形

python がある環境限定ですが, コマンドラインで json ファイルのバリデーション・整形を行うには次のようにするのが簡単な気がします.

% echo '{"foo": 1, "bar": 2}' | python -mjson.tool
{
    "bar": 2, 
    "foo": 1
}

書籍

今回のような作業を行うにあたって, この Data munging with Perl は参考になる書籍だと思います. この本では “Data munging” という言葉を, あるところからデータを取得し, それを変換し, あるフォーマットで出力するという処理全般として定義しています.

まだ半分くらいしか読んでないのですが, 実務的な内容が多く有用な本だという感想です. 発行年が古いのでさすがにコードは古臭い書き方がされているのですが, 知らなかったテクニックも紹介されていて勉強になります. *2

[2010-12-31 追記] seq (jot) コマンド

seq コマンドを使うことで 0 をパディングしたシーケンスを生成できます. 上で紹介した bash の for in と printf を組み合わせた方法よりも, seq を使ったほうがスマートです.

# 01-10 の連番. -w で 0 をパディングしてくれる
for i in seq -w </span><span class="synConstant">1</span><span class="synSpecial"> </span><span class="synConstant">10</span><span class="synSpecial">; do echo ${i}; done

# -f オプションでフォーマットの指定が可能 for i in seq -f </span><span class="synStatement">"</span><span class="synConstant">%03d</span><span class="synStatement">"</span><span class="synSpecial"> </span><span class="synConstant">1</span><span class="synSpecial"> </span><span class="synConstant">10</span><span class="synSpecial">; do echo ${i}; done

seq コマンドは BSD 系 (Mac OSX 含む) には入っていません. 代わりに jot コマンドを使うと良いようです.

# 1 から 10 の連番 (パディングなし)
jot 10

# 開始値 5, 長さ 10 の連番 (5 - 14, パディングなし) jot 10 5

# フォーマット出力 (01 - 10) jot -w %02d 10

詳しくは man jot(1) を見てください.

まとめ

自分が中規模なデータを処理したときに役になった tips をまとまりなく列挙してきました. このような作業は決して難しいものではないんですが, 丸腰で臨むと多大な時間がかかってしまうので危険です. 面倒な作業を怠惰に終わらせるには, 細かなテクニックが武器になります. 自分を守るためにもこのような作業はコンピュータに任せ, より本質的なことにリソースを割り当てられるようにしたいものです.

*1:web ではよさげな資料が見つかりませんでした

*2:上のリンクは日本語訳版ですが, 原著の電子書籍版を読んでいます. ちょうど Manning Publications がセールをやっていたので, 10 USD くらいで買えました.