本文へ移動
購読
GUIDE#infra更新 2026.10.31

findコマンドの使い方:条件の組み合わせと-execで安全に一括処理する

GNU findutils 4.10.0のfindを、癖のある名前と日時・サイズを決めたツリーで検証。-oと括弧、-prune、-mtimeと-sizeの丸め、-execの\;と+、-deleteの罠を確かめます。

level
中級
time
約40分
stack
Ubuntu 26.04.1 LTS(ubuntu:26.04 コンテナ) / GNU findutils 4.10.0(find・xargs、findutils 4.10.0-3build2) / tar 1.35(1.35+dfsg-4ubuntu0.4) / fd 10.3.0(fd-find 10.3.0-2ubuntu1) / plocate 1.1.23 / git 2.53.0 / uutils coreutils 0.10.0(sort・wc・seq・touch) / dash 0.5.12(/bin/sh)
author
@ryoma_t
// 前提条件
シェルでcd・パイプ・リダイレクトが使えること / 再現する場合はDockerでubuntu:26.04のコンテナを1つ起動できること
findコマンドで-oと括弧の有無による結果の違い、-mtimeの日数の数え方、-exec {} +の引数の分け方、-print0とxargs -0の出力を確かめた端末画面
画像:nagoya.dev(実際の画面・実行結果をもとに作成)

findコマンドとは、指定したディレクトリから下を順にたどり、名前・種類・更新日時・サイズなどの条件に合うファイルを一覧にしたり、見つけたファイルにコマンドを実行したりするコマンドです。ファイルの中身を調べるgrepとも、事前に作った索引を引くlocateとも違い、実行するたびにディレクトリを実際に読みます。Linux(Ubuntu 26.04.1)のGNU findutils 4.10.0で、空白・改行・引用符を含む名前や、更新日時とサイズを決めて作ったファイルのツリーを用意し、オプション一覧だけでは読み取れない挙動を1つずつ実行して確かめました。

対象は、ログの掃除やファイルの棚卸しをfindで書いているインフラ担当・情シスの方と、cronに入れたfindが想定外のファイルまで消さないか気になっている方です。読み終えると、-oと括弧で結果が変わる理由、-pruneでの除外の書き方、-mtime +1が「48時間以上前」になる数え方、-exec {} \;と{} +の起動回数の差、名前が壊れないxargs -0の使い方、件数を確かめてから-deleteする掃除スクリプトが手元に残ります。

この記事でわかること

  • -oを括弧なしで書くと-printが片側にしか付かず、*.pyが1件も出ない理由と\( \)での直し方
  • -pruneと! -pathの除外の違い(読めないディレクトリで権限エラーと終了コード1が残るかどうか)
  • -mtime +1は48時間以上前、-size -1Mは空のファイルだけ、という丸めの規則を実ファイルで確認した結果
  • -exec {} \;と{} +の起動回数(10000ファイルで10000回と2回)、+でtarのアーカイブが最後の1810件だけになった例、sh -cの文字列に{}を書くとファイル名がコマンドとして実行される例
  • -print0 | xargs -0、-deleteを先に書くと全部消える罠、件数を確かめてから消す掃除スクリプトとcronの例

検証環境:コンテナとテスト用のツリーを作る

検証はDockerのubuntu:26.04コンテナ1つで行いました。コンテナ名のfind-command-usage-exec-lab、ホスト名のnagoyadevlab01、作業ユーザーのryoma、作業ディレクトリの~/find-lab(/home/ryoma/find-lab)は、どれもこの記事で付けた例の名前です。コマンドや出力に出てくるこれらの名前とパスは、自分の環境のものに読み替えてください。最初にDockerホストのシェルで次のコマンドを実行し、コンテナを起動します(表示されるコンテナIDは省いています)。

terminal(Dockerホスト)
docker run -d --memory 512m --name find-command-usage-exec-lab --hostname nagoyadevlab01 ubuntu:26.04 sleep infinity

--memory 512mは共用のマシンで動かすための上限で、findの検証そのものには要りません。ポートの公開も--privilegedも使いません。後半で扱う権限エラーは実行するユーザーで出方が変わるので、検索は一般ユーザーで行います。

次にdocker exec -it find-command-usage-exec-lab bashでコンテナにrootとして入り、後半の比較で使うfd(Ubuntuのパッケージ名はfd-find、コマンド名はfdfind)、plocate、gitを入れて、作業ユーザーを作ります。findとxargsはfindutilsパッケージに含まれていて、このイメージには最初から入っていました。

terminal(コンテナ内・root)
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends fd-find plocate git >/dev/null 2>&1; echo "exit=$?"
exit=0
useradd -m -s /bin/bash ryoma && id ryoma
uid=1001(ryoma) gid=1001(ryoma) groups=1001(ryoma)

ここからはDockerホストで別の端末を開き、docker exec -it -u ryoma -w /home/ryoma find-command-usage-exec-lab bashで作業ユーザーとして入り直します。作業ディレクトリを作って移動し、使うコマンドの版を確かめます。後半でrootの操作が要る箇所は、Dockerホストからdocker execで実行する形で書いてあり、それ以外のコマンドはすべて~/find-labをカレントディレクトリにしてryomaで実行したものです。

terminal(コンテナ内・ryoma)
mkdir -p ~/find-lab && cd ~/find-lab
grep PRETTY_NAME /etc/os-release
PRETTY_NAME="Ubuntu 26.04.1 LTS"
find --version | head -n 1
find (GNU findutils) 4.10.0
xargs --version | head -n 1
xargs (GNU findutils) 4.10.0
dpkg-query -W findutils fd-find plocate git tar
fd-find	10.3.0-2ubuntu1
findutils	4.10.0-3build2
git	1:2.53.0-1ubuntu1.1
plocate	1.1.23-1ubuntu3
tar	1.35+dfsg-4ubuntu0.4
fdfind --version; plocate --version | head -n 1
fdfind 10.3.0
plocate 1.1.23
for c in sort wc seq touch; do $c --version | head -n 1; done
sort (uutils coreutils) 0.10.0
wc (uutils coreutils) 0.10.0
seq (uutils coreutils) 0.10.0
touch (uutils coreutils) 0.10.0

find (GNU findutils) 4.10.0と表示されれば、この記事と同じGNU版です。macOSやFreeBSDのfindはBSD系の別の実装で、後述のとおり-mtimeの数え方まで違います。このイメージではsort・wc・seq・touchがGNU coreutilsではなくuutils coreutils 0.10.0だったので、版を残しておきます。

この記事の出力は、端末を割り当てないdocker execで、コマンドを1つずつsh -lcに渡して記録したものです(Ubuntuの/bin/shはdash)。端末かどうかで結果が変わる箇所は、その場で断ります。

テスト用のツリーは次のスクリプトで作ります。乱数は使っていません。logs/の5ファイルだけは、スクリプトを実行した時刻から12・36・60・84・240時間前の更新日時になります。この記事で試す-mtimeと-mminの条件は、作ってから12時間以内に実行すれば、この記事と同じ結果になる時刻の組み合わせにしてあります。

terminal(テスト用ツリーの作成)
cat > make-tree.sh <<'EOF'
# find-lab のテスト用ツリーを作る(空のディレクトリで1回だけ実行する)
set -eu
now=$(date +%s)

# app: -o と括弧、-prune、深さの確認用
mkdir -p app/src app/node_modules/left-pad app/certs app/.cache
printf 'print("main")  # TODO: validate args\n' > app/src/main.py
printf 'print("util")\n' > app/src/util.py
printf 'console.log("view")\n' > app/src/view.js
printf '# app\n' > app/README.md
printf 'module.exports = 1\n' > app/node_modules/left-pad/index.js
printf '# left-pad\n' > app/node_modules/left-pad/README.md
printf 'build\n' > app/.cache/build.js
printf 'node_modules/\n' > app/.gitignore
printf 'dummy\n' > app/certs/dev.key

# logs: 作成した時刻から 12・36・60・84・240 時間前の更新日時
mkdir -p logs
for h in 12 36 60 84 240; do
  touch -d "@$((now - h * 3600))" "logs/app-$(printf '%03d' "$h")h.log"
done

# audit: 更新日時を日付で固定した3ファイル(-newermt 用)
mkdir -p audit
for d in 05 15 25; do
  touch -d "2026-10-$d 12:00" "audit/report-2026-10-$d.csv"
done

# size: 0 バイトから 3 MiB まで(truncate なのでディスクはほとんど使わない)
mkdir -p size
truncate -s 0 size/s0.bin
truncate -s 1 size/s1.bin
truncate -s 512000 size/s500k.bin
truncate -s 1048576 size/s1m.bin
truncate -s 1048577 size/s1m1.bin
truncate -s 3145728 size/s3m.bin

# inbox: 空白・改行・先頭のハイフン・引用符・$( ) を含む名前
mkdir -p inbox
touch 'inbox/report 2026.txt' "$(printf 'inbox/line1\nline2.txt')" inbox/-n.txt "inbox/it's.txt" 'inbox/$(id).txt'

# secret: 作業ユーザー自身も読めないディレクトリ
mkdir -p secret
printf 'dummy\n' > secret/server.key
chmod 000 secret

# tmp: -delete の練習用
mkdir -p tmp/cache
touch tmp/a.tmp tmp/b.tmp tmp/cache/c.tmp tmp/keep.txt

# bulk: -exec の起動回数を数えるための 10000 ファイル(名前に空白なし)
mkdir -p bulk
seq -f 'bulk/f%05g.txt' 1 10000 | xargs touch
EOF
sh make-tree.sh; echo "exit=$?"
exit=0
find . -mindepth 1 -maxdepth 1 | sort
./app
./audit
./bulk
./inbox
./logs
./make-tree.sh
./secret
./size
./tmp

inbox/には、空白(report 2026.txt)、改行(line1とline2.txtの間)、先頭のハイフン(-n.txt)、単引用符(it's.txt)、$( )を含む名前のファイルを置きました。どれもLinuxで作れるファイル名で、後半でxargsとシェルを壊す例に使います。secret/はchmod 000にしたディレクトリで、所有者のryoma自身も中を読めません。bulk/の10000ファイルは-execの起動回数を数えるためのもので、名前に空白がないと分かっているので作成には素のxargsを使っています。やり直すときは、コンテナを削除して最初から作り直してください。

findコマンドの式の読み方:-oと括弧、暗黙の-print

findの引数は、起点(appや.)のあとに「式」が続く形です。式は-nameのような「テスト」、-printや-execのような「アクション」、-maxdepthのような「グローバルオプション」を並べたもので、間に演算子を書かなければ-a(AND)でつないだものとして左から評価されます。-o(OR)は-aより優先順位が低く、式に-pruneと-quit以外のアクションが1つもなければ、式全体が真になったファイルに-printが付きます(man findの記述)。findコマンドのオプションを一覧で覚えるより、この3つの規則を先に押さえたほうが式を読み違えません。

この規則が結果を変える例です。app/の.pyと.mdを一覧にするつもりで、3通りに書きました。

terminal
find app -name '*.py' -o -name '*.md' -print | sort
app/README.md
app/node_modules/left-pad/README.md
find app -name '*.py' -o -name '*.md' | sort
app/README.md
app/node_modules/left-pad/README.md
app/src/main.py
app/src/util.py
find app \( -name '*.py' -o -name '*.md' \) -print | sort
app/README.md
app/node_modules/left-pad/README.md
app/src/main.py
app/src/util.py

1つ目は最後に-printを書いただけですが、.pyが1件も出ません。-aが-oより強く結び付くので、この式は「-name '*.py'」または「-name '*.md'かつ-print」と読まれ、-printは.mdの側にしか付いていないからです。2つ目はアクションを書いていないので、式全体に-printが付いて4件出ました。3つ目のように\( \)でORをまとめれば、後ろに付けたアクションは両方に効きます。括弧はシェルにとって特別な文字なので、バックスラッシュか引用符で囲んでfindに渡します。

-oを含む式にアクションを付けるときは、ORを括弧でまとめてから付けると決めておくと安全です。上の1つ目のとおり、括弧がないとアクションは-oの右側の条件にしか付きません。-printなら件数が足りないことに気付けますが、-execや-deleteに置き換えた式では、処理されなかったファイルについて画面には何も出ません。

名前・パス・深さで絞り込む(再帰の深さを制限する)

findは起点から下を、深さの制限なしで再帰的にたどります。直下だけを見たいときは-maxdepth 1、起点そのものを結果から外すときは-mindepth 1を付けます。-type dはディレクトリ、-type fは通常のファイルだけに絞るテストです。

terminal
find app -maxdepth 1 | sort
app
app/.cache
app/.gitignore
app/README.md
app/certs
app/node_modules
app/src
find app -mindepth 1 -maxdepth 1 -type d | sort
app/.cache
app/certs
app/node_modules
app/src

1つ目には起点のapp自身と、ドットで始まる.cache・.gitignoreが含まれています。lsと違い、findはドットで始まる名前を隠しません。2つ目は-mindepth 1で起点を外し、-type dでディレクトリだけにしたものです。-maxdepthと-mindepthはグローバルオプションなので、起点の直後、テストより前に書きます(テストの後ろに書いたときの警告は、つまずきポイントで扱います)。

-nameはファイル名の最後の部分(ベース名)だけをシェルのワイルドカードで比べます。ディレクトリを含めたパスで比べたいときは-pathか-regexを使います。

terminal
find app -path '*/src/*' -name '*.py' | sort
app/src/main.py
app/src/util.py
find app -regex 'main\.py'; echo "exit=$?"
exit=0
find app -regex '.*/src/[a-z]*\.py' | sort
app/src/main.py
app/src/util.py

-pathの*はスラッシュにも一致するので、'*/src/*'はsrcの下なら深さに関係なく一致します。-regexはパス全体に対する一致で、部分一致ではありません。'main\.py'だけではapp/src/main.pyに一致せず、何も表示しないまま終了コード0で終わりました。先頭に.*/を付けると一致します。-regexの正規表現は既定でEmacs形式で、-regextypeで変えられます(man findの記述)。

ディレクトリを除外する:-pruneの書き方と権限エラー

node_modulesや.gitのようなディレクトリを丸ごと除外するには-pruneを使います。-pruneは「このディレクトリの中に入らない」というアクションで、「除外したいディレクトリなら-prune、そうでなければ(-o)本来の条件で表示」という形で書きます。

terminal
find app -path app/node_modules -prune -o -name '*.md' -print
app/README.md
find app -path app/node_modules -prune -o -name '*.md' | sort
app/README.md
app/node_modules
find app -name node_modules -prune -o -name '*.js' -print | sort
app/.cache/build.js
app/src/view.js

1つ目が基本の形で、app/node_modulesの下にあるREADME.mdは出ません。2つ目は最後の-printを省いたもので、除外したはずのapp/node_modules自身が出力に混ざりました。アクションを書かないと式全体に-printが付き、-pruneの側も真になるからです。-pruneを使う式では、右側に-printか-execを明示します。3つ目は-pathの代わりに-nameで指定した書き方で、どの深さにあるnode_modulesもまとめて除外できます。

除外は! -path './secret/*'のように否定でも書けますが、こちらはディレクトリの中に入ったうえで結果から外すだけです。違いは、読めないディレクトリがあるときに出ます。

terminal(読めないディレクトリがある場合)
find . -name '*.key'; echo "exit=$?"
./app/certs/dev.key
find: './secret': Permission denied
exit=1
find . ! -path './secret/*' -name '*.key'; echo "exit=$?"
./app/certs/dev.key
find: './secret': Permission denied
exit=1
find . -path ./secret -prune -o -name '*.key' -print; echo "exit=$?"
./app/certs/dev.key
exit=0

-nameだけの1つ目と、! -pathで外した2つ目は、どちらも./secretを開こうとしてPermission deniedを出し、終了コードが1になりました。-pruneの3つ目はsecretの中に入らないので、エラーが出ず終了コードは0です。出力行とエラー行の並びは、記録した環境(端末なし)での順番です。

エラーを画面から消すだけなら2>/dev/nullですが、終了コードは1のまま残ります。cronやset -eのスクリプトで終了コードを見ているなら、読めないディレクトリは-pruneで飛ばします。GNU findの-readable(実行したユーザーが読めるかどうかのテスト)を使えば、場所を決め打ちせずに「読めないものには入らない」と書けます。

terminal
find . -name '*.key' 2>/dev/null; echo "exit=$?"
./app/certs/dev.key
exit=1
find . ! -readable -prune -o -name '*.key' -print; echo "exit=$?"
./app/certs/dev.key
exit=0

rootで実行すると、chmod 000のディレクトリも読めます。Dockerホストから、rootのまま同じ検索をした結果です。

terminal(Dockerホスト)
docker exec find-command-usage-exec-lab find /home/ryoma/find-lab -name '*.key' | sort
/home/ryoma/find-lab/app/certs/dev.key
/home/ryoma/find-lab/secret/server.key

rootではsecret/server.keyも見つかり、エラーは出ませんでした。rootの対話シェルで試して問題がなかった式でも、一般ユーザーのcronでは終了コード1で終わることがあります。cronに入れる前の試験は、cronと同じユーザーで行ってください。

更新日時で探す:find -mtimeは24時間単位の切り捨てで数える

-mtime nは最終更新からの経過で比べるテストですが、日数は「経過時間を24時間で割り、端数を切り捨てた値」です(man findの-atimeの説明)。カレンダーの日付ではありません。+nはnより大きい、-nはnより小さい、符号なしはちょうどnを意味します。まずlogs/の5ファイルが何時間前に更新されたことになっているかを確かめます。

terminal
for f in logs/*.log; do echo "$f $(( ($(date +%s) - $(stat -c %Y "$f")) / 3600 ))h"; done
logs/app-012h.log 12h
logs/app-036h.log 36h
logs/app-060h.log 60h
logs/app-084h.log 84h
logs/app-240h.log 240h

この5ファイルに、6通りの-mtimeを当てた結果です。

terminal
for n in -1 +0 1 +1 2 +2; do printf '%-10s' "-mtime $n"; find logs -type f -mtime "$n" | sort | tr '\n' ' '; echo; done
-mtime -1 logs/app-012h.log 
-mtime +0 logs/app-036h.log logs/app-060h.log logs/app-084h.log logs/app-240h.log 
-mtime 1  logs/app-036h.log 
-mtime +1 logs/app-060h.log logs/app-084h.log logs/app-240h.log 
-mtime 2  logs/app-060h.log 
-mtime +2 logs/app-084h.log logs/app-240h.log 

36時間前のファイルは、24で割ると1.5なので切り捨てて1日です。そのため-mtime 1に一致し、-mtime +1には入りません。-mtime +1は「切り捨てた日数が1より大きい」、つまり48時間以上前という意味で、60・84・240時間前の3ファイルが該当しました。「1日より古いファイル」のつもりで+1と書くと、24〜48時間前のファイルが漏れます。24時間以上前を指したいなら+0で、こちらは36時間前のファイルから4件が一致しました。

時間で区切りたいときは、分単位の-mminを使うと読み違えません。24時間以内なら-mmin -1440、48時間より前なら-mmin +2880です。

terminal
find logs -type f -mmin -1440 | sort
logs/app-012h.log
find logs -type f -mmin +2880 | sort
logs/app-060h.log
logs/app-084h.log
logs/app-240h.log

「何月何日から何日まで」のように日付で区切るときは、-newermtに日時を直接書きます。-newermt 'D'は「更新日時がDより新しい」というテストで、!を付けると「Dより新しくない」になります。日時はGNU dateの-dと同じ書き方で指定できます(man findの記述)。

terminal
find audit -type f -printf '%TY-%Tm-%Td %TH:%TM %p\n' | sort
2026-10-05 12:00 audit/report-2026-10-05.csv
2026-10-15 12:00 audit/report-2026-10-15.csv
2026-10-25 12:00 audit/report-2026-10-25.csv
find audit -type f -newermt '2026-10-10' ! -newermt '2026-10-20'
audit/report-2026-10-15.csv

audit/の3ファイルは、更新日時を日付で固定して作ったものです(1つ目は-printfの%Tで年・月・日・時・分を表示)。10月10日0時より新しく、10月20日0時より新しくない、という条件で、15日のファイルだけが残りました。監査やインシデント対応で「この期間に変更されたファイル」を出すときは、-mtimeの日数を逆算するより、この形で日時を書いたほうがレビューする人にも条件が伝わります。

-mtimeの数え方は実装によって違います。執筆時点のFreeBSD 15.1のman pageと、Appleが公開しているmacOSのfindのman page(2023年12月22日付)には、単位なしの-mtimeは経過時間を24時間単位に切り上げて比べると書かれていて、GNU版の切り捨てとは境目がずれます。どちらのman pageにも-newermt(ISO 8601形式の日時を受け付ける)が載っているので、LinuxとmacOSの両方で動かすスクリプトは-newermtで書くほうが無難です。BSD系のfindはこの記事では実行しておらず、ここはドキュメントで確認した内容です。

サイズで探す:-sizeは単位に切り上げてから比べる

-sizeも丸めが入るテストです。ファイルの大きさを、指定した単位(kはKiB、MはMiB、cはバイト、何も付けないと512バイトのブロック)に切り上げてから比べます。size/の6ファイルの大きさと、5通りの条件の結果です。

terminal
find size -type f -printf '%s %p\n' | sort -n
0 size/s0.bin
1 size/s1.bin
512000 size/s500k.bin
1048576 size/s1m.bin
1048577 size/s1m1.bin
3145728 size/s3m.bin
for s in -1M 1M +1M -1024k -1048576c; do printf '%-17s' "-size $s"; find size -type f -size "$s" | sort | tr '\n' ' '; echo; done
-size -1M        size/s0.bin 
-size 1M         size/s1.bin size/s1m.bin size/s500k.bin 
-size +1M        size/s1m1.bin size/s3m.bin 
-size -1024k     size/s0.bin size/s1.bin size/s500k.bin 
-size -1048576c  size/s0.bin size/s1.bin size/s500k.bin 

-size -1Mは「1MiB未満」に見えますが、一致したのは0バイトのs0.binだけでした。1バイトのファイルも、MiB単位に切り上げると1になり、「1より小さい」に入らないからです。同じ理由で-size 1Mは「切り上げてちょうど1MiB」、つまり1バイトから1048576バイトまでの3ファイルに一致しています。1MiB未満を指定したいなら、-size -1024kか、バイト単位の-size -1048576cと書きます。このツリーでは2つの結果が同じでした。

整数の単位で切り上げるので、+nは切り上げても「n単位より大きい」と同じ意味になり、-size +1Mは1048577バイトと3145728バイトの2ファイルを正しく選びました。読み違えやすいのは-と符号なしのほうで、-nは実際には「n-1単位以下」です。小さいファイルを探す条件はc(バイト)で書くと迷いません。

terminal
du -k size/s3m.bin; find size -empty
0	size/s3m.bin
size/s0.bin

truncateで作ったファイルは中身のブロックを持たず、du -kでは0でした。それでも-sizeはls -lと同じファイルの大きさ(st_size)で比べるので、s3m.binは3MiBとして扱われます(man findの記述)。ディスクの使用量で探したいときはduを使います。-emptyは空のファイルと空のディレクトリに一致するテストで、ここではs0.binだけが出ました。

find -execで一括処理する:\;と+の違い

-execは、見つけたファイルにコマンドを実行するアクションです。終わりの印が\;か+かで起動の仕方が変わり、\;はファイル1つにつきコマンドを1回、+はファイル名をまとめて引数に並べ、少ない回数で起動します。bulk/の10000ファイルで、起動されたシェルが受け取った引数の数($#)を1回ごとに表示させました。sh -cの後ろのshは$0に入る名前で、ファイル名は$1以降に入ります。

terminal
find bulk -type f | wc -l
10000
find bulk -type f -exec sh -c 'echo "$#"' sh {} \; | sort | uniq -c
  10000 1
find bulk -type f -exec sh -c 'echo "$#"' sh {} +
8190
1810
xargs --show-limits </dev/null 2>&1 | grep 'actually using'
Size of command buffer we are actually using: 131072

\;では引数1個での起動が10000回、+では8190個と1810個の2回でした。+でも1回にまとまるとは限らず、引数が多いと分割されます。man findには、+の形はxargsとほぼ同じ方法でコマンドラインを組み立てると書かれています。この環境のxargsが使うコマンドバッファは131072バイトで、bulk/f00001.txtのような名前は終端のNULを含めて16バイトなので、8190個で131040バイトとほぼ埋まる計算です。

所要時間の差も大きく、同じ10000ファイルに何もしないtrueを実行すると、\;は11秒、+は1秒未満(表示は0s)でした。秒数はマシンや負荷で変わりますが、起動回数の差は変わりません。ファイルごとに別々の処理をする理由がなければ+を使います。

terminal(所要時間)
s=$(date +%s); find bulk -type f -exec true {} \; ; echo "$(( $(date +%s) - s ))s"
11s
s=$(date +%s); find bulk -type f -exec true {} + ; echo "$(( $(date +%s) - s ))s"
0s

+が複数回に分かれることを知らないと、壊れたアーカイブができます。tar -cfを+で呼ぶと、2回目のtarが1回目に作ったアーカイブを上書きします。

terminal(tarが上書きされる場合)
find bulk -type f -exec tar -cf /tmp/bulk.tar {} + ; tar -tf /tmp/bulk.tar | wc -l
1810
find bulk -type f -print0 | tar --null -T - -cf /tmp/bulk.tar; tar -tf /tmp/bulk.tar | wc -l
10000

1つ目のアーカイブに残ったのは、最後に起動された回の1810件だけで、エラーは出ていません。tarのように全部のファイルを1回で受け取る前提のコマンドには、-print0の出力をtar --null -T -(標準入力からNUL区切りのファイル一覧を読む)で渡します。2つ目は10000件そろいました。

-exec … {} +の分割で結果が壊れるのは、tarの-cfや、sh -cの中で>を使って毎回ファイルを作り直す処理のように、1回の起動で全部を受け取る前提のものです。chmodやgzipのようにファイルごとに完結する処理は、何回に分かれても結果は同じです。

-execを条件に使う・複数のコマンドを実行する

\;の-execは、コマンドの終了コードが0なら真になるテストとしても働きます。grep -q(一致したかどうかを終了コードだけで返す)と組み合わせると、中身にTODOを含む.pyだけを表示できます。\;の-execを2つ並べると、1つ目が成功したファイルにだけ2つ目が実行されます。

terminal
find app -name '*.py' -exec grep -q TODO {} \; -print
app/src/main.py
find app -name '*.py' -exec grep -q TODO {} \; -exec wc -l {} \;
1 app/src/main.py

この性質は終了コードにも表れます。\;の-execが失敗しても「条件が偽だった」という扱いで、findの終了コードは0のままでした。+の形では、どれかの起動が0以外で終わるとfindも0以外で終わります(man findの記述どおり)。コマンドの失敗をfindの終了コードで検知したいスクリプトでは+を使います。

terminal
find app -name '*.py' -exec false {} \; ; echo "exit=$?"
exit=0
find app -name '*.py' -exec false {} + ; echo "exit=$?"
exit=1

-execの中でパイプやリダイレクトを使いたいときはsh -cを挟みますが、{}をsh -cの文字列の中に書いてはいけません。GNU findは\;で終わる-execでは{}が引数の一部に含まれていても置き換えるので(man findの記述。次の結果のとおり'echo {}'の中でも置き換わります)、ファイル名がそのままシェルのスクリプトに埋め込まれ、名前の中の$( )や;や改行が構文として解釈されます。inbox/で試した結果です。

terminal(sh -c の文字列にファイル名を埋め込んだ場合)
find inbox -type f -exec sh -c 'echo {}' \;
inbox/-n.txt
inbox/report 2026.txt
inbox/line1
sh: 2: line2.txt: not found
inbox/uid=1001(ryoma) gid=1001(ryoma) groups=1001(ryoma).txt
sh: 1: Syntax error: Unterminated quoted string

$(id).txtという名前のファイルでidコマンドが実際に実行され、ファイル名の代わりにuid=1001(ryoma)…が表示されました。改行を含む名前では2行目が別のコマンドとして実行されてnot foundになり、it's.txtでは引用符が閉じずに構文エラーです。idの代わりにrmを含む名前のファイルが置かれていれば、それも実行されます。

ファイル名はsh -cの引数として渡し、スクリプトの中ではfor f do … done(for f in "$@"の省略形)で受け取ります。

terminal
find inbox -type f -exec sh -c 'for f do printf "[%s]\n" "$f"; done' sh {} +
[inbox/-n.txt]
[inbox/report 2026.txt]
[inbox/line1
line2.txt]
[inbox/$(id).txt]
[inbox/it's.txt]

5つの名前が、改行入りのものも含めてそれぞれ1つの引数として届きました。man findは-execには避けられないセキュリティ上の問題があるとして-execdirを勧めています。-execdirはファイルのあるディレクトリでコマンドを実行し、名前には./が付きます。

terminal
(cd inbox && find * -type f); echo "exit=$?"
find: unknown predicate `-n.txt'
exit=1
find inbox -name '-n*' -execdir printf '[%s]\n' {} \;
[./-n.txt]

1つ目はfind *のように起点をシェルのワイルドカードで渡した例で、-n.txtが式の一部と解釈されてエラーになりました(括弧のサブシェルでcdしているので、実行後もカレントディレクトリは~/find-labのままです)。起点を.やinboxにすれば、出力される名前は./やinbox/で始まり、受け取ったコマンドがオプションと取り違えることはありません。-execdirの出力も./-n.txtでした。

空白や改行を含むファイル名:-print0とxargs -0

findの出力をそのままxargsに渡すと、xargsは空白と改行を区切りとして扱い、引用符とバックスラッシュも特別な文字として解釈します(man xargsの記述)。inbox/の名前を渡した結果です。

terminal(名前が割れる場合)
find inbox -type f | xargs printf '[%s]\n'; echo "exit=$?"
xargs: unmatched single quote; by default quotes are special to xargs unless you use the -0 option
[inbox/-n.txt]
[inbox/report]
[2026.txt]
[inbox/line1]
[line2.txt]
[inbox/$(id).txt]
exit=1

report 2026.txtは2つに、改行入りの名前も2つに割れ、it's.txtで「単引用符が閉じていない」というエラーが出て終了コードは1でした。見落としやすいのは、xargsがエラーで止まる前に、それまでに読んだ名前でprintfを実行している点です。printfがrmだったら、inbox/reportと2026.txtという別の名前で削除を試み、エラーより前の分の処理は済んでしまいます。

-print0で名前をNUL(\0)区切りにし、xargs -0で受け取れば、NULはファイル名に使えない文字なので名前は割れません。

terminal
find inbox -type f -print0 | xargs -0 printf '[%s]\n'
[inbox/-n.txt]
[inbox/report 2026.txt]
[inbox/line1
line2.txt]
[inbox/$(id).txt]
[inbox/it's.txt]

もう1つの落とし穴は、一致が0件のときです。GNU xargsは、入力が空でもコマンドを1回実行します(man xargsの記述)。

terminal(一致が0件のとき)
find inbox -name '*.csv' -print0 | xargs -0 printf '[%s]\n'
[]
find inbox -name '*.csv' -print0 | xargs -0 -r printf '[%s]\n'; echo "exit=$?"
exit=0

1つ目は.csvが1つもないのにprintfが引数なしで実行され、[]が表示されました。引数がないと現在のディレクトリを対象にするlsやduを渡していれば、意図しない場所を処理します。-r(--no-run-if-empty、GNUの拡張)を付けると、入力が空のときは何も実行しません。

find … -exec {} +とfind … -print0 | xargs -0は、どちらも名前を壊さずに渡せます。xargsを選ぶのは、-Pで並列に実行したいとき(man xargsでは-nか-Lと組み合わせるよう書かれています)と、find以外のコマンドの出力を受け取りたいときです。それ以外は、パイプが1つ減り、0件のときに何も起動しない-exec {} +を選びます。

-deleteで消す前に:順番の罠と安全な手順

-deleteは見つけたファイルを消すアクションです。式は左から評価されるので、-deleteをテストより前に書くと、テストを見る前にすべてを消します。tmp/をコピーしたtrap/で試しました。

次の実験は、コンテナの中のテスト用ツリーだけで行ってください。-deleteは確認なしで消し、ゴミ箱もありません。ホストや$HOME、/を起点にした式で順番を試すことは避け、本番の掃除でもいきなり-deleteを付けず、同じ式を-printで実行して対象を確かめてから置き換えます。

terminal(-deleteを先に書いた場合)
cp -a tmp trap && find trap | sort
trap
trap/a.tmp
trap/b.tmp
trap/cache
trap/cache/c.tmp
trap/keep.txt
find trap -delete -name '*.tmp'; echo "exit=$?"
exit=0
ls trap; echo "exit=$?"
ls: cannot access 'trap': No such file or directory
exit=2

-name '*.tmp'に一致しないkeep.txtも、ディレクトリのcacheも、起点のtrap自身も消え、findの終了コードは0でした。-deleteを書くと-depth(ディレクトリの中身を先に処理する順序)が有効になるので、中身が消えて空になったディレクトリも順に消えていきます(man findの記述)。

-depthのもとでは-pruneが効かないため、-pruneと-deleteを組み合わせた式は、GNU find 4.10.0では実行前にエラーで止まり、何も消されませんでした。

terminal(-pruneと-deleteの組み合わせはエラー)
find tmp -path tmp/cache -prune -o -name '*.tmp' -delete; echo "exit=$?"
find: The -delete action automatically turns on -depth, but -prune does nothing when -depth is in effect.  If you want to carry on anyway, just explicitly use the -depth option.
exit=1
find tmp | sort
tmp
tmp/a.tmp
tmp/b.tmp
tmp/cache
tmp/cache/c.tmp
tmp/keep.txt

消すときの手順は、同じ式で-printして一覧を確認し、件数を数え、最後に-print -deleteで消した名前を残しながら消す、の順です。件数は| wc -lだと改行を含む名前を2件と数えてしまうので、-printf '.'で1ファイルにつき1文字だけ出し、wc -cで数えています。

terminal
find tmp -type f -name '*.tmp' -print | sort
tmp/a.tmp
tmp/b.tmp
tmp/cache/c.tmp
find tmp -type f -name '*.tmp' -printf '.' | wc -c
3
find tmp -type f -name '*.tmp' -print -delete | sort
tmp/a.tmp
tmp/b.tmp
tmp/cache/c.tmp
find tmp | sort
tmp
tmp/cache
tmp/keep.txt

keep.txtと、空になったcacheディレクトリが残りました。-type fを付けているので、ディレクトリは対象になりません。

古いログを定期的に消すスクリプトとcron

この手順をスクリプトにしたものです。対象の件数が上限(既定は500件)を超えたら消さずに終了するので、引数のディレクトリや日数を書き間違えたときに大量に消える事故を止められます。

terminal(スクリプトの作成)
cat > clean-old-logs.sh <<'EOF'
#!/bin/sh
# 使い方: sh clean-old-logs.sh <ディレクトリ> <日数> [上限件数]
# <日数> は -mtime +<日数> にそのまま渡す(+13 なら 14 日以上前)
set -u
dir=${1:?directory required}
days=${2:?days required}
max=${3:-500}

count=$(find "$dir" -xdev -type f -name '*.log' -mtime +"$days" -printf '.' | wc -c)
echo "target=$count dir=$dir mtime=+$days"
if [ "$count" -gt "$max" ]; then
  echo "too many files ($count > $max), nothing deleted" >&2
  exit 2
fi
find "$dir" -xdev -type f -name '*.log' -mtime +"$days" -print -delete
EOF
  • -xdev:起点と別のファイルシステム(別ディスクやNFSのマウント)には入りません。ログのディレクトリの下にバックアップ先がマウントされていても巻き込みません。
  • -mtime +"$days":日数は切り捨てで数えるので、13を渡すと14日以上前(336時間以上前)が対象です。
  • -printf '.' | wc -c:改行を含む名前も1件として数えます。
  • [ "$count" -gt "$max" ]:想定より多いときは終了コード2で止まり、メッセージは標準エラーに出ます。cronの出力をログに残していれば後から気付けます。
  • -print -delete:消したファイルの名前を標準出力に残します。
  • 使わなかった書き方:rm $(find …)はシェルの単語分割で空白や改行のところで名前が割れ、find … | xargs rmは前の節の問題をそのまま持ち込むので、find自身の-deleteで消しています。

上限を2にした1回目と、既定の上限で実行した2回目です。

terminal
sh clean-old-logs.sh logs 1 2; echo "exit=$?"
target=3 dir=logs mtime=+1
too many files (3 > 2), nothing deleted
exit=2
sh clean-old-logs.sh logs 1; echo "exit=$?"
target=3 dir=logs mtime=+1
logs/app-084h.log
logs/app-060h.log
logs/app-240h.log
exit=0
find logs -type f | sort
logs/app-012h.log
logs/app-036h.log

1回目は対象が3件で上限の2を超えたので、何も消さずに終了コード2で止まりました。2回目は48時間以上前の3ファイルを消し、その名前を出力しています。消した順番はディレクトリを読んだ順で、並べ替えていません。残ったのは12時間前と36時間前のログです。

cronに登録する場合の例です(毎日 3:30 に、14日以上前の*.logを消す)。/home/ryoma/bin/clean-old-logs.sh、/var/log/myapp、ログの出力先の/home/ryoma/clean-old-logs.logはこの記事の例のパスなので、スクリプトを置いた場所と掃除したいディレクトリに書き換えてください。crontabの書き方と、cronで動かないときの調べ方はcrontabとsystemd timerのガイドで扱っています。cronへの登録と時刻どおりの実行は、この記事のコンテナでは試していません。

crontab(例)
30 3 * * * /bin/sh /home/ryoma/bin/clean-old-logs.sh /var/log/myapp 13 >> /home/ryoma/clean-old-logs.log 2>&1

find・grep -r・locate・fdの使い分け

ファイルを探すコマンドはほかにもあり、何を手がかりに探すかで選びます。fdはUbuntuでは追加のパッケージ(fd-find)です。app/をgit initでgitリポジトリにしてから、findと比べました。

terminal
git init -q app && find app -name '*.js' | sort
app/.cache/build.js
app/node_modules/left-pad/index.js
app/src/view.js
fdfind -e js . app | sort
app/src/view.js
fdfind -HI -e js . app | sort
app/.cache/build.js
app/node_modules/left-pad/index.js
app/src/view.js

findは3つすべてを出しましたが、fdは.gitignoreに書いたnode_modulesと、ドットで始まる.cacheを出しませんでした。-H(隠しファイルも含める)と-I(.gitignoreなどの除外を無視する)を付けると、findと同じ3件です。ソースコードの中で名前を探すならfdの既定が便利ですが、棚卸しや掃除のように全件が必要な処理はfindで書きます。

locate(Ubuntu 26.04で入れたのはplocate)は、updatedbがあらかじめ作った索引を引くので、ディレクトリを読み直しません。updatedbはrootで実行するので、ここはすべてDockerホストからdocker execで実行しました。

terminal(Dockerホスト)
docker exec find-command-usage-exec-lab dpkg -L plocate | grep timer
/usr/lib/systemd/system/plocate-updatedb.timer
docker exec find-command-usage-exec-lab updatedb
docker exec -u ryoma find-command-usage-exec-lab locate '/home/ryoma/find-lab/*.key'
/home/ryoma/find-lab/app/certs/dev.key
docker exec find-command-usage-exec-lab locate '/home/ryoma/find-lab/*.key'
/home/ryoma/find-lab/app/certs/dev.key
/home/ryoma/find-lab/secret/server.key
docker exec -u ryoma -w /home/ryoma/find-lab find-command-usage-exec-lab touch app/src/new.py
docker exec -u ryoma find-command-usage-exec-lab locate '*/app/src/new.py'; echo "exit=$?"
exit=1
docker exec find-command-usage-exec-lab updatedb
docker exec -u ryoma find-command-usage-exec-lab locate '*/app/src/new.py'
/home/ryoma/find-lab/app/src/new.py

plocateパッケージには、updatedbを毎日実行するsystemdのタイマー(plocate-updatedb.timer、OnCalendar=daily)が入っています。このコンテナではsystemdが動いていないので、手でupdatedbを実行しました。ryomaで引くと、chmod 000のsecret/にあるserver.keyは表示されず、rootで引くと両方出ました。索引に入っていても、引いたユーザーが読めない場所のファイルは見せない動きです。touchで作ったnew.pyは、updatedbをもう一度実行するまで見つからず、終了コードは1でした。

grep -rはファイルの中身を探すコマンドで、名前や日時の条件は得意ではありません。場所・名前・日時をfindで絞り、中身はgrepに任せる組み合わせが実務では一番使いやすい形です。grepのオプションと正規表現はgrepコマンドのガイドにまとめています。

terminal
grep -rl --include='*.py' TODO app
app/src/main.py
find app -path app/node_modules -prune -o -name '*.py' -exec grep -l TODO {} +
app/src/main.py
手がかり選ぶコマンドこの検証で見えた挙動向かない場面
名前・種類・更新日時・サイズ・権限find毎回ディレクトリを読むので、作った直後のファイルも出る。-exec・-deleteまで同じコマンドの中に書ける同じ巨大なツリーを何度も検索する(毎回読み直す)
ファイルの中身(文字列・正規表現)grep -r中身を読むのはgrepの仕事。findで対象を絞ってから-exec grep -l {} +で渡せる更新日時やサイズで絞る
名前だけ・索引で速くlocate(plocate)索引を引くので読み直さない。読めない場所の結果は出さないupdatedbより後に作られたファイル
ソースツリーの中を名前でfdgitリポジトリでは.gitignoreの対象とドットで始まる名前を既定で除外全件が必要な棚卸し・cronの掃除、追加パッケージを入れられないサーバー

Windowsのfindは名前が同じでも別物で、ファイルの中から文字列を探すコマンドです(Microsoft Learnの説明)。ファイル名で探すならdir /s /bが近い使い方になります。macOSとFreeBSDのfindはBSD系の実装で、man pageの書式では起点のディレクトリが省略できない形になっています(GNU版は起点を省くとカレントディレクトリを使う)。-printfは執筆時点のFreeBSD 15.1のman pageには載っていますが、Appleが公開しているfindのman pageにはありません。Windowsのコマンドと、BSD系のfindは、この記事では実行していません。

つまずきポイント

find: paths must precede expression

terminal(エラー)
(cd size && find . -name *.bin); echo "exit=$?"
find: paths must precede expression: `s1.bin'
find: possible unquoted pattern after predicate `-name'?
exit=1

-nameの後ろの*.binを引用符で囲まなかったため、シェルが先にs0.bin s1.bin …へ展開し、findには2つ目のs1.binが余分な引数として届きました。2行目のpossible unquoted patternは、その可能性をfindが指摘しているメッセージです。パターンは引用符で囲みます。

terminal(修正後)
(cd size && find . -name '*.bin' | sort)
./s0.bin
./s1.bin
./s1m.bin
./s1m1.bin
./s3m.bin
./s500k.bin

find: missing argument to `-exec'

terminal(エラー)
find tmp -name '*.txt' -exec wc -c {}; echo "exit=$?"
find: missing argument to `-exec'
exit=1

-execの終わりの;をエスケープしないと、シェルがコマンドの区切りとして先に取ってしまい、findには終わりの印が届きません。findは式の解析で止まるので、wcは1回も実行されていません。\;か';'と書きます。+はシェルの特別な文字ではないので、この記事でもエスケープせずに書いています。

terminal(修正後)
find tmp -name '*.txt' -exec wc -c {} \;
0 tmp/keep.txt

find: warning: you have specified the global option -maxdepth after the argument -name

terminal(警告)
find tmp -warn -name '*.txt' -maxdepth 1
find: warning: you have specified the global option -maxdepth after the argument -name, but global options are not positional, i.e., -maxdepth affects tests specified before it as well as those specified after it.  Please specify global options before other arguments.
tmp/keep.txt
find tmp -maxdepth 1 -name '*.txt'
tmp/keep.txt

-maxdepthをテストの後ろに書いたときの警告です。結果はどちらもtmp/keep.txtで、-maxdepthは書いた位置に関係なく式全体に効いています。GNU findは、標準入力が端末のときだけこの種の警告を出す既定になっているので(man findの-warnの説明)、端末のない記録用の環境では-warnを付けて再現しました。Dockerホストからdocker exec -it -u ryoma -w /home/ryoma/find-labで端末を割り当ててfind tmp -name '*.txt' -maxdepth 1を実行したときは、-warnなしでも同じ警告が出ました。警告を消すには、-maxdepthを起点の直後に移します。

find: cannot delete …: Directory not empty

terminal(エラー)
mkdir -p tmp/old/2025 && touch tmp/old/2025/x.log && find tmp -type d -name old -delete; echo "exit=$?"
find: cannot delete 'tmp/old': Directory not empty
exit=1

-deleteは空でないディレクトリを消せません(man findの記述)。-type d -name oldに一致したのはディレクトリのoldだけで、中の2025/x.logは条件に一致しないので消されず、oldは空になりませんでした。ディレクトリごと消したいときは、そのディレクトリを起点にして条件なしで-deleteします。中身から順に消えるので、最後にold自身も消えます。

terminal(修正後)
find tmp/old -delete; echo "exit=$?"; find tmp | sort
exit=0
tmp
tmp/cache
tmp/keep.txt

動作確認

手元の環境が、この記事の結果と同じになるかを確かめるコマンドです。ここまでの手順を順に実行したあと、~/find-labでryomaとして実行します。

terminal
find --version | head -n 1
find (GNU findutils) 4.10.0
find app -path app/node_modules -prune -o -name '*.md' -print
app/README.md
find bulk -type f -exec sh -c 'echo "$#"' sh {} + | wc -l
2
find inbox -type f -print0 | xargs -0 printf '[%s]\n' | wc -l
6
find logs -type f | sort
logs/app-012h.log
logs/app-036h.log

確かめる点は5つです。最初の出力がGNU findutils 4.10.0であること、-pruneの式でapp/README.mdだけが出ること、+の起動が2回であること、xargs -0で5つの名前が6行(改行入りの名前が2行)で表示されること、掃除のあとにapp-012h.logとapp-036h.logだけが残っていることです。確認が終わったら、Dockerホストでコンテナを削除します。

terminal(Dockerホスト)
docker rm -f find-command-usage-exec-lab
find-command-usage-exec-lab

よくある質問

findコマンドで特定のディレクトリを除外するには?

-pruneを使い、find app -path app/node_modules -prune -o -name '*.md' -printのように「除外するディレクトリなら-prune、そうでなければ-oの右側の条件」と書きます。右側の-printを省くと、除外したディレクトリ名が出力に混ざります。! -pathで外す書き方は中に入ってから外すので、読めないディレクトリでは権限エラーと終了コード1が残ります。

findで再帰的に検索しない(深さを制限する)には?

-maxdepth 1を付けると起点の直下だけを調べます。起点そのものも結果から外すなら-mindepth 1を足します。どちらもグローバルオプションなので、find tmp -maxdepth 1 -name '*.txt'のように起点の直後、テストより前に書きます。テストの後ろに書いても結果は同じですが、端末から実行すると警告が出ます。

-execで複数のコマンドを実行するには?

\;で終わる-execを続けて書くと、前の-execが成功したファイルにだけ次が実行されます。パイプやリダイレクトを使うなら-exec sh -c 'for f do …; done' sh {} +の形にして、ファイル名は引数として渡します。\;で終わる-execで{}をsh -cの文字列に埋め込むと、名前に含まれる$( )や;がシェルに実行されます。

Windowsのfindコマンドと同じものですか?

別物です。Windowsのfindはファイルの中から文字列を探すコマンドで、Linuxでいえばgrepに近い役割です(Microsoft Learnの説明)。ファイル名で探すならdir /s /bが近く、Linuxのfindの式は使えません。この記事の結果はLinuxのGNU findutils 4.10.0で確かめたもので、Windowsでは実行していません。

まとめ

findの式は左から評価され、-aが-oより強く結び付き、アクションがなければ式全体に-printが付きます。-mtimeと-sizeは丸めてから比べるので、境目が大事な条件は-mmin・-newermt・バイト単位のcで書きます。名前を別のコマンドに渡すときは-exec {} +か-print0 | xargs -0にし、{}をsh -cの文字列に埋め込まないこと。次の作業として、自分のサーバーのcronやスクリプトにある-delete付きのfindを、-deleteを-printに置き換えて実行し、対象の一覧と件数が想定どおりかを確かめてください。

次に読む

$ subscribe --weekly
毎週月曜、企業ITとホームラボの要点を5分で。