ディレクトリに入った大量のファイルへの操作

Linux マシンの 1 ディレクトリに、うっかり数十万・数百万ファイル作ってしまうことないですか? 自分は今日もやってしまいました。

ls rm * も帰ってこなくて/エラーが出て身動きがとれなくなってしまいます。 そんな困った時のオペレーションのやり方をまとめておきます。

下準備

まずは空の 100 万ファイルを生成します。

# time seq -w 1 1000000 | xargs touch

real	0m25.994s
user	0m3.118s
sys	0m17.361s

time を図る前に、メモリに乗った slab 等を以下のようなコマンドで drop すると正確な値が読み取れるんじゃないかと思います。 ただ今回は面倒なので、Cache/Slab をたっぷり 2GB 弱メモリに載せて drop させずに検証しています。

# echo 3 > /proc/sys/vm/drop_caches

ファイルを数える

まず、どんなファイルあるか知りたいですよね。 ただ、軽率に ls を叩くとコンソールが文字で埋まってしまいます。 慎重に数を数える所から行いましょう。

# time ls | wc -l
1000000

real	0m3.944s
user	0m3.410s
sys	0m0.534s

10 万ファイルあるようです。 数えるだけで、4 秒ほどかかってしまいました。 ls はデフォルトで結果をソートして表示するので、ソートを行わない -U オプションを渡すと早くなります。

# time ls -U | wc -l
1000000

real	0m0.706s
user	0m0.275s
sys	0m0.440s

一部のファイル名を見てみる

先程使ったソートを行わないことで高速化する ls -U を使用します。

# time ls -U | head
0095772
0571231
0043505
0708529
0119538
0247028
0258524
0876395
0721112
0217545

real	0m0.006s
user	0m0.000s
sys	0m0.008s

非常に高速に先頭 10 件のファイル名を出力しました。

# time ls -U > /dev/null

real	0m0.690s
user	0m0.292s
sys	0m0.398s

| head が無いと、出力に時間がかかっていることも分かります。

一部のファイルの属性を見てみる

こういった大量にファイルがあるケースというのは、典型的な例で言うとログ等の機械生成されたファイルだと思います。 一部のファイルは退避させたかったりするので、調査のためにファイルサイズ・作成日時・容量などを知りたい場合が多いです。

普段は ls -lh コマンドで詳細を出して | less 等で追うのですが、ファイル数が多いと大変時間がかかってしまいます。 以下の実行結果を見ると、最初のオプションを指定しなかった ls | wc -l の倍ほどの実行時間がかかっていることが分かります。

root@kb:~/many-files# time ls -lh | head
total 0
-rw-r--r-- 1 root root 0 Mar 19 19:46 0000001
-rw-r--r-- 1 root root 0 Mar 19 19:46 0000002
-rw-r--r-- 1 root root 0 Mar 19 19:46 0000003
-rw-r--r-- 1 root root 0 Mar 19 19:46 0000004
-rw-r--r-- 1 root root 0 Mar 19 19:46 0000005
-rw-r--r-- 1 root root 0 Mar 19 19:46 0000006
-rw-r--r-- 1 root root 0 Mar 19 19:46 0000007
-rw-r--r-- 1 root root 0 Mar 19 19:46 0000008
-rw-r--r-- 1 root root 0 Mar 19 19:46 0000009

real	0m7.660s
user	0m4.791s
sys	0m2.867s

これはソートをやめても、実行時間は半分程度にしか減りません。

root@kb:~/many-files# time ls -lhU | head
total 0
-rw-r--r-- 1 root root 0 Mar 19 19:46 0095772
-rw-r--r-- 1 root root 0 Mar 19 19:47 0571231
-rw-r--r-- 1 root root 0 Mar 19 19:46 0043505
-rw-r--r-- 1 root root 0 Mar 19 19:47 0708529
-rw-r--r-- 1 root root 0 Mar 19 19:46 0119538
-rw-r--r-- 1 root root 0 Mar 19 19:46 0247028
-rw-r--r-- 1 root root 0 Mar 19 19:46 0258524
-rw-r--r-- 1 root root 0 Mar 19 19:47 0876395
-rw-r--r-- 1 root root 0 Mar 19 19:47 0721112

real	0m4.492s
user	0m1.615s
sys	0m2.879s

ファイルの一覧の取得に 1 秒・詳細情報を取得するのに 3 秒・ソートするのに 3 秒というような内訳でしょうか。 ls コマンドは -l を指定すると、全ての結果を作ってから標準出力へと結果を出力するようです。 一部のファイルの情報だけ見たいのに、ほとんどのファイルの情報は捨てられてしまっています。

ls -U | head はメチャクチャに早かったので、これの結果を ls -l コマンドの後に指定することで早くなるのか試してみます。

# time ls -l `ls -U | head`
-rw-r--r-- 1 root root 0 Mar 19 19:46 0043505
-rw-r--r-- 1 root root 0 Mar 19 19:46 0095772
-rw-r--r-- 1 root root 0 Mar 19 19:46 0119538
-rw-r--r-- 1 root root 0 Mar 19 19:46 0217545
-rw-r--r-- 1 root root 0 Mar 19 19:46 0247028
-rw-r--r-- 1 root root 0 Mar 19 19:46 0258524
-rw-r--r-- 1 root root 0 Mar 19 19:47 0571231
-rw-r--r-- 1 root root 0 Mar 19 19:47 0708529
-rw-r--r-- 1 root root 0 Mar 19 19:47 0721112
-rw-r--r-- 1 root root 0 Mar 19 19:47 0876395

real	0m0.013s
user	0m0.007s
sys	0m0.009s

十分に早そうです。

名前で検索した上で、ファイルの属性を見てみる

ログなどの場合は、ファイル名に日付・タイムスタンプが利用されていることが多いと思います。 先月のログを削除する前に情報を見ておきたいと思って ls -l 2020-01-* 等と指定する事も多いと思います。

ですが、大量にファイルがある環境ではこの方法が使えません。 ダミーファイルは日付の名前が付いていないので、日付の範囲を指定することを模して 10 万~ 40 万番のファイルの詳細を確認してみようと思います。

# ls -l 0{1..4}*
bash: /bin/ls: Argument list too long

bash などのシェルでは、連番を生成する {1..4} ・ファイル名のワイルドカードを指す * 等は、シェルにて全て評価された後にコマンドの引数へと渡ります。 上記のコードでは、30 万の引数が ls コマンドに渡ったということになります。 環境・コマンドによって引数の数の制限は異なりますが、典型的には 2MB・数千個程度等の制限が掛かっていることが多いようです。

こういった際は、find コマンドを利用すると良いです。find コマンドは、パイプを正しく扱い、ファイル名を素早く返します。 -name フラグはシェル展開とは異なるパターンを受け付けますが、分かりにくいようであれば、 -iregex -regex で正規表現を利用することも出来ます。 パイプで一部のファイル名を返す場合・全てを /dev/null に捨てる場合で実行速度にかなりの差があることが分かると思います。

# time find -type f -name '0[1234]*' | head
./0100001
./0100006
./0100010
./0100016
./0100022
./0100032
./0100034
./0100040
./0100062
./0100064

real	0m0.152s
user	0m0.076s
sys	0m0.081s

# time find -type f -name '0[1234]*' > /dev/null

real	0m1.423s
user	0m1.032s
sys	0m0.391s

ただ、これだけではファイルの詳細情報が分かりません。 find コマンドで検索した結果を head で 10 件取り出し、それを ls -l の引数へと渡してみます。

# time ls -l `find -type f -name '0[1234]*' | head`
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100001
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100006
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100010
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100016
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100022
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100032
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100034
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100040
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100062
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100064

real	0m0.167s
user	0m0.076s
sys	0m0.102s

十分に高速になりました。 ただし、ファイル名にスペースが含まれていると正しく動きません。 これは xargs コマンドと、ファイル名の区切りをスペースではなく NULL バイトで表すオプションを付与することで解決できます。 find コマンドは -print0head コマンドは -zxargs コマンドは -0 フラグで、区切り文字を NULL バイトとして表現が可能となります。

# time find -type f -name '0[1234]*' -print0 | head -z | xargs -0 ls -l
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100001
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100006
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100010
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100016
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100022
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100032
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100034
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100040
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100062
-rw-r--r-- 1 root root 0 Mar 19 19:46 ./0100064

real	0m0.159s
user	0m0.083s
sys	0m0.097s

大量のファイルを削除する

ls と同じく、rm コマンドも引数の制限があるので、いつものようにシェル展開・ワイルドカード等を使うと削除が出来ません。

# time rm *
bash: /bin/rm: Argument list too long

これも、find と xargs を組み合わせることで解決できます。

# time find -type f -print0 | xargs -0 rm

real	1m0.537s
user	0m3.457s
sys	0m14.028s

スペースが含まれないことが分かっていれば、単にこのように書くことも出来ます。

# time find -type f | xargs rm

find -name find -regex フラグ等を利用し、ファイル名を検索した上で大量のファイルを削除することも容易でしょう。 今回は削除に時間がかかるので、他の例は載せません。