find・xargs・sed・awk実践:macOSのBSD版とGNU版の違い

空白入りの名前でも安全なfind+xargs、生成したアクセスログでのsed・awkワンライナー、Linux向けの回答がMacで黙って失敗するBSD版とGNU版の違いを、すべて実際に実行して確認。-execの速度差も実測。

シリーズ:コマンドライン講座 3/6(中級)。 パイプラインを組めるようになった人(講座2)が、フォルダ全体を対象にした作業とテキストのその場編集に進む回です。名前・更新日・サイズでファイルを探し、安全に別のコマンドへ渡し、アクセスログへの質問に sed と awk の1行プログラムで答えます。

本番サーバーは Linux、手元の開発機は Mac、という職場はよくあります。ネットで見つかるシェルの回答の多くは Linux 向けで、Mac に貼ると「派手に失敗する」(illegal option)か、もっと悪いことに「黙って失敗する」(何も変わらない、あるいは違うものが変わる)かのどちらかです。この記事は、そうした回答を macOS のターミナルに貼っている開発者と、フォルダに空白入りの名前がある全員のためのものです。以下のコマンドはすべて macOS の BSD 版ツールで実行し、GNU 版で挙動が違う点は「ここでは未実行」と明記します。

これまでの講座:パスとファイル操作、パイプとリダイレクト。全体像は学習ロードマップへ。

検証環境: macOS 26.6、Apple Silicon Mac(M1 Pro)、zsh 5.9 と bash 3.2.57。BSD 版 find・xargs・sed・awk(awk version 20200816)・grep 2.6.0-FreeBSD。サンプルログの生成だけに Python 3.9.6。出力中のホームのパスは ~ に短縮しています。

準備:小さなプロジェクトとアクセスログ

まず、Web サーバーのアクセスログ(一般的な combined 形式の末尾に、応答時間の秒数を足したもの)を作る生成スクリプトです。IP は文書用のアドレス帯を使い、シードは固定です。

# gen_access.py - fake web access log in "combined" format + response time (seeded)
import random, sys
from datetime import datetime, timedelta

n = int(sys.argv[1]) if len(sys.argv) > 1 else 5000
rng = random.Random(7)
t = datetime(2026, 11, 12, 0, 0, 0)
ips = [f"203.0.113.{i}" for i in range(1, 40)] + [f"198.51.100.{i}" for i in range(1, 20)]
bot = "192.0.2.66"
pages = ["/", "/products/12", "/products/31", "/products/7", "/cart", "/login", "/api/search?q=mug"]
uas = ["Mozilla/5.0 (iPhone)", "Mozilla/5.0 (Windows NT 10.0)", "Mozilla/5.0 (Macintosh)"]
for _ in range(n):
    t += timedelta(seconds=rng.randint(0, 30))
    if rng.random() < 0.08:                          # a scanner probing for WordPress
        ip, path, st, ua = bot, rng.choice(["/wp-login.php", "/xmlrpc.php", "/.env"]), 404, "curl/8.7.1"
    else:
        ip, path, ua = rng.choice(ips), rng.choice(pages), rng.choice(uas)
        st = rng.choices([200, 304, 500], [90, 8, 2])[0]
    size = "-" if st == 304 else str(rng.randint(300, 48000) if st == 200 else rng.randint(150, 600))
    rt = round(rng.lognormvariate(-3.0, 0.9) + (1.5 if st == 500 else 0), 3)
    print(f'{ip} - - [{t:%d/%b/%Y:%H:%M:%S} +0000] "GET {path} HTTP/1.1" {st} {size} "-" "{ua}" {rt}')

次に、よくある問題(空白入りのフォルダとファイル、古いファイル、ゴミ)を含むプロジェクトを作るスクリプトです。

# setup.sh - a small project tree with the usual problems (spaces, old files, junk)
rm -rf proj
mkdir -p proj/src proj/logs "proj/old logs" proj/notes proj/build
printf 'print("hi")\n' > proj/src/main.py
printf 'def add(a, b):\n    return a + b\n' > proj/src/util.py
printf 'agenda\ndecisions\nnext steps\n' > "proj/notes/Meeting Notes.txt"
printf 'todo\n' > proj/notes/todo.txt
cp access.log proj/logs/access.log
head -100 access.log > "proj/old logs/access 2025.log"
for i in 1 2 3; do echo tmp > "proj/build/cache$i.tmp"; done
touch -t 202610010900 "proj/old logs/access 2025.log" proj/build/*.tmp

python3 gen_access.py > access.log と sh setup.sh を実行します。md5 -q access.log は cc723029cc75f8a657bcfd445eee7818(5,000行)になるはずです。touch -t の行は一部のファイルの日付を 2026年10月1日に戻し、日付による検索で見つかるものを用意しています。

find:パスを先に、パターンは引用符で

find はフォルダを再帰的にたどり、条件を満たしたパスを表示します。

~/demo/find % find proj -type f
proj/logs/access.log
proj/notes/todo.txt
proj/notes/Meeting Notes.txt
proj/old logs/access 2025.log
proj/build/cache1.tmp
proj/build/cache2.tmp
proj/build/cache3.tmp
proj/src/util.py
proj/src/main.py

~/demo/find % find -name '*.py'
find: illegal option -- n
usage: find [-H | -L | -P] [-EXdsx] [-f path] path ... [expression]
       find [-H | -L | -P] [-EXdsx] -f path [path ...] [expression]

GNU 版はパスを省略すると . とみなします(ここでは未実行)が、BSD 版は必須です。. だけでもパスは必ず書きましょう。

2つ目のルールはパターンの引用符です。引用符がないと *.log は find より先にシェルが展開し、結果はカレントフォルダに何があるかで変わります。3通りとも試しました。

~/demo/find % touch a.log b.log

~/demo/find % find proj -name *.log
find: access.log: unknown primary or operator

~/demo/find % rm a.log b.log

~/demo/find % find proj -name *.log
proj/logs/access.log

~/demo/find % cd proj

~/demo/find/proj % find . -name *.log
zsh: no matches found: *.log

一致するファイルが3つあると、コマンドは find proj -name a.log access.log b.log になり find が拒否します。ちょうど1つ(access.log)だと find proj -name access.log になり、エラーは出ないのに access 2025.log が結果から黙って抜け落ちます。一致がなければ zsh はコマンド自体を実行しません。bash 3.2 は一致しない *.log をそのまま渡すので、そちらでは2つとも見つかりました。同じ1行がシェルとフォルダ次第で成功も失敗もするわけです。引用符付きの find proj -name '*.log' は毎回2つとも見つけました。

find の条件:種類・日付・サイズと -o の罠

~/demo/find % find proj -type f -mtime +30
proj/old logs/access 2025.log
proj/build/cache1.tmp
proj/build/cache2.tmp
proj/build/cache3.tmp

~/demo/find % find proj -type f -size +100k
proj/logs/access.log

~/demo/find % find proj -name '*.log' -o -name '*.txt' -print
proj/notes/todo.txt
proj/notes/Meeting Notes.txt

~/demo/find % find proj \( -name '*.log' -o -name '*.txt' \) -print
proj/logs/access.log
proj/notes/todo.txt
proj/notes/Meeting Notes.txt
proj/old logs/access 2025.log

-mtime +30 は「30日より前に更新」、-size +100k は「100 KiB より大きい」です。並べて書いた条件は暗黙の AND で結ばれ、AND は -o(OR)より強く結びつきます。そのため3つ目は「*.log または(*.txt かつ表示)」となり、.log は一致したのに誰も表示しませんでした。シェルに解釈されないようエスケープした括弧で囲めば直ります。

結果を別のコマンドに渡す:xargs・-print0・-exec

xargs は標準入力から「単語」を読み、コマンドの引数として付け足します。問題はその「単語」です。

~/demo/find % find proj -name '*.txt' | xargs wc -l
wc: proj/notes/Meeting: open: No such file or directory
wc: Notes.txt: open: No such file or directory
       1 proj/notes/todo.txt
       1 total

~/demo/find % find proj -name '*.txt' -print0 | xargs -0 wc -l
       1 proj/notes/todo.txt
       3 proj/notes/Meeting Notes.txt
       4 total

~/demo/find % find proj -name '*.txt' -exec wc -l {} +
       1 proj/notes/todo.txt
       3 proj/notes/Meeting Notes.txt
       4 total

-print0 は名前の区切りを改行ではなく NUL バイトにし、xargs -0 は NUL でだけ区切ります。NUL はファイル名に入り得ません。-exec コマンド {} + は find 自身が引数リストを作ります。どちらも安全です。wc の代わりに rm だったら、危険な方は proj/notes/Meeting と Notes.txt を消そうとしたはずで、別のフォルダなら実在するファイルかもしれません。

判断の図。出発点:find がファイルを見つけた。問い:名前に空白・引用符・改行が入りうるか。分からなければ「入る」とみなす。入るなら安全な選択肢は2つ:find -print0 | xargs -0 か、多数のファイルを1プロセスで処理する find -exec コマンド {} +。-0 なしの find | xargs は危険と表示。「Meeting Notes.txt」が2つの引数になるため。-exec コマンド {} \; は安全だが遅い(1ファイル1プロセス)と表示。
find の結果を別のコマンドに渡す方法。そのまま xargs にパイプすると名前が空白で割れる。-print0 と xargs -0、または -exec … {} + なら名前が丸ごと渡る。

xargs について確認したことがさらに3つあります。

~/demo/find % find proj -name '*.bak' | xargs echo removing

~/demo/find % find proj -name '*.py' | xargs -n 1 echo file:
file: proj/src/util.py
file: proj/src/main.py

~/demo/find % find proj -name '*.py' | xargs -I {} cp {} {}.orig

入力が空のとき、BSD 版 xargs は echo を1回も実行しませんでした。GNU 版は -r を付けない限り引数なしで1回実行します(ここでは未実行)。Linux で find … | xargs rm が rm: missing operand を出すのはこのためです。macOS の xargs は -r を受け付けて無視するので、xargs -r と書けば両方で同じ動きになります。-n 1 は引数1つごとに1回実行、-I {} は各行を {} の位置に差し込みます。

1回のコマンドに引数はいくつ入るのか。空ファイル12,000個で xargs -0 sh -c 'echo $#' sh を実行すると 5000、5000、2000 と表示されました。BSD 版 xargs の既定は1回あたり最大5,000引数です(man xargs)。-exec sh -c 'echo $#' sh {} + は 12000 を1回だけ表示しました。find はシステムの引数サイズ上限(ここでは getconf ARG_MAX が 1048576)まで詰め込みます。

よくあるミス:-delete の位置

find は式をファイルごとに左から評価し、-delete はオプションではなく「動作」です。コピーで試しました。

~/demo/find % find proj -name '*.tmp' -print
proj/build/cache1.tmp
proj/build/cache2.tmp
proj/build/cache3.tmp

~/demo/find % find proj -name '*.tmp' -delete

~/demo/find % cp -R proj proj-copy

~/demo/find % find proj-copy -delete -name '*.tmp'

~/demo/find % ls proj-copy
ls: proj-copy: No such file or directory

起きたこと: -delete が先にあると、find はたどったファイルとフォルダを片っ端から消し、消した後で名前を調べました。proj-copy は丸ごと消え、メッセージは1行も出ていません。対策: まず -delete の位置に -print を置いたまったく同じコマンドを実行して一覧を確認し、その1語だけを書き換えます。xargs でも同じで、コマンドの前に echo を付ける(xargs -0 echo rm)と実行内容を確認できます。

sed:流れながら編集する、そして「黙って何もしない」罠

sed は流れてくるテキストを1行ずつ編集します。アクセスログで試します。

~/demo/find % sed -n '/" 500 /p' access.log | head -2
203.0.113.11 - - [12/Nov/2026:00:10:42 +0000] "GET /login HTTP/1.1" 500 356 "-" "Mozilla/5.0 (Windows NT 10.0)" 1.537
203.0.113.33 - - [12/Nov/2026:00:11:08 +0000] "GET / HTTP/1.1" 500 553 "-" "Mozilla/5.0 (iPhone)" 1.521

~/demo/find % sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+)\.[0-9]+/\1.x/' access.log | head -2
203.0.113.x - - [12/Nov/2026:00:00:10 +0000] "GET /login HTTP/1.1" 200 35419 "-" "Mozilla/5.0 (iPhone)" 0.011
203.0.113.x - - [12/Nov/2026:00:00:39 +0000] "GET / HTTP/1.1" 200 16072 "-" "Mozilla/5.0 (Windows NT 10.0)" 0.017

-n と p で選んだ行(行番号や /正規表現/)だけを表示し、s/旧/新/ で置換します。-E は拡張正規表現を有効にし、+ や ( ) をバックスラッシュなしで使えます。2つ目は、ログを社外に共有する前に IP アドレスの末尾を伏せる、よくある依頼への答えです。

ここからが、macOS ではエラーも出さずに何もしない GNU の書き方です。

~/demo/find % sed '/wp-login\|xmlrpc\|\.env/d' access.log | wc -l
    5000

~/demo/find % sed -E '/wp-login|xmlrpc|\.env/d' access.log | wc -l
    4598

~/demo/find % echo 'aaa bbb' | sed 's/a\+/X/'
aaa bbb

~/demo/find % echo 'aaa bbb' | sed -E 's/a+/X/'
X bbb

~/demo/find % echo 'cat category' | sed 's/\bcat\b/dog/g'
cat category

~/demo/find % echo 'cat category' | sed 's/[[:<:]]cat[[:>:]]/dog/g'
dog category

\|・\+・\b は基本正規表現への GNU 拡張です。BSD 版 sed は別の意味に解釈して何にも一致せず、そのまま進みます。「スキャナーの行を消す」はずのコマンドは402行のうち1行も消しませんでした。-E を使えば同じ意図が通ります。うれしい誤算も1つ:このバージョンの macOS では sed 's/,/\n/g' で実際に改行になりました。古い解説には BSD 版 sed ではできないとあるので、同僚の古い Mac で結果が違ったら tr ',' '\n' を使ってください。正規表現の文法そのものは正規表現とはで扱っています。

macOS の sed -i:3通りの結果

ファイルをその場で書き換える場面は、Linux 向けの回答が最も目に見えて壊れるところです。

~/demo/find % printf 'host=old.example.com\nport=8080\n' > app.conf

~/demo/find % sed -i 's/old/new/' app.conf
sed: 1: "app.conf
": command a expects \ followed by text

~/demo/find % sed -i -e 's/old/new/' app.conf

~/demo/find % ls app.conf*
app.conf
app.conf-e

~/demo/find % sed -i '' 's/8080/9090/' app.conf

~/demo/find % sed -i.bak 's/9090/9191/' app.conf && rm app.conf.bak

~/demo/find % cat app.conf
host=new.example.com
port=9191

BSD 版の -i は引数としてバックアップの拡張子を取ります。1つ目では s/old/new/ を拡張子とみなし、app.conf を sed スクリプトとして実行しようとしました(a は「追加」コマンド)。2つ目は「成功」したように見えて -e を拡張子として使い、app.conf-e というゴミを残しました。git にうっかりコミットされがちなファイルです。-i '' は BSD 版だけの「バックアップなし」。拡張子をくっつけた -i.bak は BSD 版でも GNU 版でも動きます(GNU 版の sed -i 's/…/' はここでは未実行)。

awk:欄・条件・合計・グループ

awk は各行を空白の連続で $1、$2… に分け($NF は最後の欄)、条件 { 動作 } を一致した行に実行します。combined 形式では $1 がクライアント IP、$7 がパス、$9 がステータス、$10 がサイズ、このログでは $NF が応答時間です。

~/demo/find % awk '{print $9}' access.log | sort | uniq -c | sort -rn
4126 200
 402 404
 373 304
  99 500

~/demo/find % awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -3
 402 192.0.2.66
  95 203.0.113.12
  92 203.0.113.2

~/demo/find % awk '$9 == 500 {n++; t += $NF} END {printf "%d errors, avg %.3f s\n", n, t/n}' access.log
99 errors, avg 1.566 s

~/demo/find % awk '$9 == 200 {b[$7] += $10} END {for (p in b) printf "%10d %s\n", b[p], p}' access.log | sort -rn | head -3
  14591902 /api/search?q=mug
  14525275 /products/31
  14192831 /products/12

~/demo/find % awk -F'"' '{print $6}' access.log | sort | uniq -c | sort -rn
1535 Mozilla/5.0 (Windows NT 10.0)
1535 Mozilla/5.0 (Macintosh)
1528 Mozilla/5.0 (iPhone)
 402 curl/8.7.1

~/demo/find % awk '$9 == 200 {print $NF}' access.log | sort -n | awk '{v[NR] = $1} END {print "n=" NR, "p50=" v[int(NR*0.50)], "p95=" v[int(NR*0.95)], "max=" v[NR]}'
n=4126 p50=0.05 p95=0.228 max=0.811

順に、ステータス別の件数、一番うるさいクライアント(例のスキャナー)、失敗したリクエストの平均時間、パス別の転送バイト数(b[$7] += $10 はパスをキーにした連想配列)、User-Agent(-F'"' で引用符を区切りにすると6番目の欄になる)、応答時間の中央値と95パーセンタイルです。最後の数字こそ見るべき値で、平均は遅い少数を隠してしまいます。

エラーではなく間違った出力になる、引用符のミスが2つあります。

~/demo/find % awk "{print $1}" access.log | head -1
203.0.113.26 - - [12/Nov/2026:00:00:10 +0000] "GET /login HTTP/1.1" 200 35419 "-" "Mozilla/5.0 (iPhone)" 0.011

~/demo/find % awk '{print $1 $9}' access.log | head -1
203.0.113.26200

ダブルクォートの中では、awk が見る前にシェルが $1(対話シェルでは空)を置き換えるので、awk は {print } を実行して行全体を表示しました。awk のプログラムは必ずシングルクォートで囲みます。また、カンマがないと $1 $9 は連結になります。print $1, $9 なら空白で区切られます。

実測:-exec {} \; と {} + と xargs -0

find の処理を遅くする一番の近道は、1ファイルごとに1プロセス起動することです。50個のフォルダに1行だけのファイルを5,000個(すべて空白入りの名前 file 1.txt …)作り、全部に wc -l を3通りの方法で各5回実行しました。

zmodload zsh/datetime
rm -rf tree; mkdir tree
for i in {1..5000}; do d=tree/d$(( i % 50 )); [[ -d $d ]] || mkdir $d; print "line $i" > "$d/file $i.txt"; done
for c in "find tree -name '*.txt' -exec wc -l {} \;" \
         "find tree -name '*.txt' -exec wc -l {} +" \
         "find tree -name '*.txt' -print0 | xargs -0 wc -l"; do
  for r in 1 2 3 4 5; do
    s=$EPOCHREALTIME; eval "$c" > out.txt; e=$EPOCHREALTIME
    printf '%s: %.0f ms\n' "$c" $(( (e-s)*1000 ))
  done
done
コマンド(5,000ファイル) wc の起動回数 5回の中央値 各回(ms)
-exec wc -l {} \; 5,000 11,958 ms 10,198〜12,386
-exec wc -l {} + 1 136 ms 123〜428
-print0 を xargs -0 wc -l へ 1 137 ms 125〜181
棒グラフ:find -exec wc -l {} \; が 11,958 ms、find -exec wc -l {} + が 136 ms、find -print0 | xargs -0 wc -l が 137 ms。
find で見つけた小さなファイル5,000個に wc -l をかけた時間。5回の中央値、Apple Silicon Mac(M1 Pro)。{} \; は wc を5,000回起動し、{} + と xargs -0 は1回だけ起動する。

出力は同じで、約88倍遅くなりました。3つとも空白入りの名前を正しく扱っています。xargs -P による並列実行の計測は講座5で行います。

BSD 版(macOS)と GNU 版(Linux):実際にぶつかった差

やりたいこと macOS(ここで実行) GNU/Linux の書き方(ここでは未実行)
その場で編集 sed -i '' 's/a/b/' f sed -i 's/a/b/' f
両方で動くその場編集 sed -i.bak 's/a/b/' f && rm f.bak 同じ
sed で1回以上の繰り返し sed -E 's/a+/X/' sed 's/a\+/X/' も可
sed の単語境界 [[:<:]]word[[:>:]] \bword\b
パスなしの find エラー:illegal option -- n . とみなす
サイズと名前の表示 stat -f '%z %N' f find -printf '%s %p\n'、stat -c %s f
入力が空の xargs 何も実行しない、-r は受け付ける -r がなければ1回実行
grep で Perl 正規表現 grep: invalid option -- P grep -P '\d+'
昨日の日付 date -j -v-1d -f %Y-%m-%d 2026-11-12 +%Y-%m-%d → 2026-11-11 date -d yesterday
awk の拡張関数 calling undefined function strftime / asort gawk には strftime・asort・-i inplace

この Mac では stat -c %s が stat: illegal option -- c、find … -printf が find: -printf: unknown primary or operator、date -d yesterday が date: illegal option -- d を表示しました。両方のシステムで動かすスクリプトなら、左の列の移植性のある書き方に絞るか、uname で分岐させます。

正直な限界とバージョンの注意

  • 確認したのは macOS 26.6 だけです(BSD 版 find・xargs・sed、onetrue awk 20200816、grep 2.6.0-FreeBSD)。GNU の列は GNU のドキュメント上の挙動で、実行はしていません。
  • sed 's/,/\n/g' で改行になったのはこのバージョンでの結果です。古い macOS では試していません。
  • xargs の既定5,000引数はこのシステムの man xargs の記述で、観測結果とも一致しました。
  • -mtime +30 は今から24時間単位で数えます。例のファイルは2026年10月1日付けなので一致し続けますが、日付を変えれば結果も変わります。
  • 計測表は Apple Silicon Mac(M1 Pro)1台、ほかの処理も動いている状態です。{} + の 428 ms は初回(キャッシュが冷えた状態)でした。マシンが空いているときに上のスクリプトをそのまま再実行すると、中央値は 8,636 ms・105 ms・112 ms でした。絶対値は動きましたが、約80倍という比率は変わりません。

練習問題

  1. proj 以下で30日より古い .log ファイルを、空白入りの名前でも安全に、1コマンドで gzip 圧縮してください。
  2. awk で access.log の時間帯(0〜23時)別リクエスト数を数えてください。

解答

  1. 引数リストは find に作らせます。
~/demo/find % find proj -name '*.log' -mtime +30 -exec gzip {} +

~/demo/find % find proj -name '*.gz'
proj/old logs/access 2025.log.gz
  1. 日時の欄 $4([12/Nov/2026:00:00:10)を : で分けると、2つ目が「時」です。
~/demo/find % awk '{split($4, t, ":"); print t[2]}' access.log | sort | uniq -c | head -4
 238 00
 244 01
 246 02
 256 03

前回: 講座2:パイプとリダイレクト。次回: 講座4:変数・スクリプト・終了コード。ここで覚えたワンライナーを、黙って失敗せず大きな声で失敗するスクリプトにします。学習ロードマップに戻る。

よくある質問

Mac で sed -i 's/old/new/' file が失敗するのはなぜ?

BSD 版 sed は -i の直後にバックアップの拡張子を必要とします。スクリプトを拡張子、ファイル名をスクリプトと解釈し「command a expects \ followed by text」と出ました。macOS では sed -i '' 's/old/new/' file、BSD と GNU の両方で動かすなら sed -i.bak … && rm file.bak です。

空白入りのファイル名を find から別のコマンドに渡すには?

find … -print0 | xargs -0 コマンド、または find … -exec コマンド {} + を使います。ただの find | xargs は「Meeting Notes.txt」を「Meeting」と「Notes.txt」に割りました。-exec コマンド {} \; も安全ですが1ファイル1プロセスで、5,000ファイルで 11,958 ms、{} + なら 136 ms でした。

macOS の sed で \+ や \| が効かないのはなぜ?

BSD 版 sed の基本正規表現は GNU 拡張の \+・\?・\| に対応しておらず、\b も単語境界になりません。エラーも出ず何も変わらないまま終わります。sed -E で +・?・| を使い、単語境界は [[:<:]] と [[:>:]] で書きます。

  • #シェル
  • #find
  • #xargs
  • #sed
  • #awk
  • #macOS
  • #中級

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑