ファイル処理を自動化するシェルスクリプトを書いていると、通常のファイルなら問題なく動いていたコードが、スペースや改行を含むファイル名に出会った途端に壊れることがあります。複数人が使う共有サーバーや、外部からアップロードされたファイルを処理する実務の場面では、ファイル名の形式を制御できないことも多いです。
この記事では、RHEL 9系~Ubuntu 24.04 LTSを想定環境として、ファイル名に空白・改行・先頭ハイフン・グロブ文字が含まれていてもシェルスクリプトが正しく動作する設計パターンを解説します。NUL区切りによる安全な受け渡し、
IFS= read -r -d ''を使ったループ、mapfileによる配列への格納、--によるオプション解釈停止の4パターンを、壊れる書き方と安全な書き方のペアで示します。この記事のポイント
・forループ+コマンド置換はスペースでフィールド分割され壊れる
・find -print0 | xargs -0 でNUL区切りに統一するのが基本設計
・IFS= read -r -d '' でNULストリームを安全にループできる
・先頭ハイフンのファイル名は -- でオプション解釈を止めてから渡す
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜシェルスクリプトでファイル名の空白・改行が問題になるのか
シェルには「フィールド分割(Word Splitting)」という仕組みがあります。コマンド置換($())や変数展開の結果を、環境変数 IFS(Internal Field Separator)の値で区切って複数の引数に分割する動作です。IFSのデフォルト値はスペース・タブ・改行の3文字です。そのため、コマンド置換の結果にスペースや改行が含まれると、それが引数の区切りとして解釈されます。たとえば、
my file.logというスペース入りのファイル名があったとします。# 問題が起きる例:コマンド置換でのフィールド分割 $ touch "my file.log" # スペース入りのファイルを作成 $ for f in $(find . -name "*.log"); do > echo "$f" > done # 出力例(環境により異なります) ./my file.log # "my file.log" が2つの引数に分割されてしまう
for f in "$(find ...)"と書いても、ループのin部分ではフィールド分割が先に行われるため根本的な解決にはなりません。この問題を根本から解決するには、ファイル名の区切り文字をスペースや改行ではなく、ファイル名には絶対に含まれないNUL文字(\0)に変更する設計が必要です。壊れる書き方と安全なシェルスクリプト設計|4つのパターン
1. forループ+コマンド置換がスペースで壊れる問題
空白を含むファイル名で代表的な壊れ方のパターンを確認します。# 壊れる例(1):for + コマンド置換 for f in $(find /backup -name "*.tar.gz"); do echo "処理: $f" # ファイル名のスペースで引数が分割される done # 壊れる例(2):ls の出力をパイプで渡す ls /backup | xargs gzip # ls はスペース区切りで出力、xargs のデフォルト区切りもスペース # 壊れる例(3):cat でファイルリストを読む for f in $(cat file_list.txt); do cp "$f" /dest/ # file_list.txt に空白入り行があると壊れる done
lsの出力をパイプで扱う方法も、改行をIFSとした分割の問題が残るため、処理用のデータソースとしてlsを使うのは避けましょう。2. NUL区切りを使ったシェルスクリプトの安全設計
findの-print0オプションは、ファイル名の末尾をNUL文字(\0)で区切って出力します。対応するxargsの-0オプションと組み合わせることで、スペースや改行を含むファイル名でも安全に処理できます。# 安全な例(1):find -print0 と xargs -0 の組み合わせ find /backup -name "*.tar.gz" -print0 | xargs -0 -I{} gzip -d {} # 安全な例(2):-exec を使う場合(xargs 不要・1ファイルずつ処理) find /backup -name "*.tar.gz" -exec gzip -d {} \; # 安全な例(3):-exec {} + で複数ファイルをまとめて渡すバッチ処理 find /backup -name "*.tar.gz" -exec gzip -d {} + # 出力例(環境により異なります) # /backup/file with space.tar.gz なども正しく処理される
-exec {} \;は1ファイルずつコマンドを実行し、-exec {} +は複数ファイルをまとめて渡します。1ファイル単位での処理が重要な場面では\;を選びます。3. IFS= read -r -d '' によるNULストリームのループ設計
find -print0の出力をシェルのwhileループで受け取りたい場合は、IFS= read -r -d ''を使います。-d ''は読み取りの区切り文字をNULに指定するオプションです。# 安全な例:IFS= read -r -d '' でNULストリームをループ while IFS= read -r -d '' f; do echo "処理中: $f" cp -- "$f" /dest/ done < <(find /backup -name "*.tar.gz" -print0) # 出力例(環境により異なります) # 処理中: /backup/my file.log # 処理中: /backup/report 2024-01.tar.gz
・IFS=:IFSを空文字に設定してフィールド分割を完全に無効化
・-r:バックスラッシュをエスケープ文字として解釈せずリテラルに扱う
・-d '':行の区切り文字をNUL(
\0)に設定プロセス置換
< <()を使っているのは、パイプ(|)でループを書くとサブシェルで実行されるためです。サブシェル内で設定した変数はループの外から参照できません。< <()を使えば現在のシェルのコンテキストでループが実行されます。シェルスクリプトの設計を体系的に学びたい方は、Linuxシェルスクリプト習得コースも参考にしてください。
4. 配列に格納してからループするシェルスクリプトのパターン
ファイルリストをループの前に一度配列に格納したい場合は、mapfile(bash 4.4以上。readarrayとも書ける)が使えます。# 安全な例:mapfile -d '' でNUL区切りの結果を配列に格納 mapfile -d '' -t files < <(find /backup -name "*.tar.gz" -print0) # 配列の要素数を確認 echo "対象ファイル数: ${#files[@]}" # 対象ファイル数: 3(出力例。環境により異なります) # ループで処理 for f in "${files[@]}"; do echo "処理: $f" done
mapfile -d ''の-d ''は区切り文字をNULに設定するオプション(bash 4.4以上)で、-tは末尾のNULを取り除きます。配列に格納する設計の利点は、ループ前にファイル数の確認・ソート・フィルタリングといった前処理ができることです。一方、ファイル数が非常に多い場合はメモリを多く使うため、大量ファイルを扱うケースでは
IFS= read -r -d ''のストリーム処理の方が適しています。先頭ハイフンやグロブ文字を含むファイル名への追加対策
1. -- によるオプション解釈停止の設計
ファイル名が-rや--verboseのようにハイフンで始まる場合、コマンドにそのまま渡すとオプションとして解釈されることがあります。# 危険な例:先頭ハイフンがオプションとして解釈される f="-rf" rm "$f" # rm -rf として実行される(意図しない削除の危険) # 安全な例:-- でオプション解釈を停止する rm -- "$f" # -- 以降はすべてファイル名として解釈される cp -- "$f" /dest/ grep -- "$pattern" "$f"
--(ダブルダッシュ)は「ここからはオプションではなく引数として扱う」を意味するPOSIX規約の区切り文字です。rm・cp・grep・catなど、ほぼすべての標準コマンドで有効です。ファイルパスを変数から渡す場合は常に--を付ける習慣にすると安全です。2. ダブルクォートによるグロブ展開の防止
*・?・[・]を含むファイル名は、シェルのパス名展開(グロブ展開)で意図しない展開が起きる可能性があります。# 問題になる例:クォートなしの変数展開でグロブが起きる f="report[2024].log" echo $f # カレントディレクトリに report2024.log があると展開される # 安全な例:ダブルクォートで囲めばグロブ展開されない echo "$f" # "report[2024].log" がそのまま表示される(出力例) # リテラルとして書く場合はシングルクォートが確実 ls 'report[2024].log' # グロブ展開なし
"$f"のようにダブルクォートで囲む限りグロブ展開は起きません。クォートなしで変数を使う(echo $fなど)のが問題の原因となります。よくあるエラーと対処法|シェルスクリプトのトラブルシュート
実務でNUL区切りの設計を導入するときに遭遇しやすいエラーと対処法をまとめます。1. mapfile -d '' が使えない(bash 4.3以前)
mapfileの-dオプションはbash 4.4以上で追加されました。RHEL 7系など古い環境ではbash 4.2が入っており、-d ''が使えません。# bash バージョン確認 bash --version # GNU bash, version 4.2.46 → -d '' は使えない # 対処:while IFS= read -r -d '' でループする方式に切り替える while IFS= read -r -d '' f; do process "$f" done < <(find . -name "*.log" -print0)
IFS= read -r -d ''を使うwhileループはbash 4.0以上で動作し、古い実務環境でも安全に使えます。2. パイプ後のループで変数が反映されない
パイプ(|)でループを書くとサブシェルで実行されるため、ループ内で変数を更新しても外側から参照できません。実務でよくある落とし穴です。# 問題のある例:パイプ後はサブシェルになる count=0 find . -name "*.log" -print0 | while IFS= read -r -d '' f; do count=$((count + 1)) done echo "count=$count" # 0 が表示される(サブシェルの変数は外に出ない) # 対処:プロセス置換で現在のシェルでループを実行 count=0 while IFS= read -r -d '' f; do count=$((count + 1)) done < <(find . -name "*.log" -print0) echo "count=$count" # 正しいカウントが表示される(出力例。環境により異なります)
3. xargs -0 で「コマンドが長すぎる」エラーが出る
ファイル数が非常に多いとxargsが複数回コマンドを分割実行します。1回のコマンド実行にかけるファイル数を制限するには-nオプションを使います。# -n 1 で1ファイルずつ処理(確実だが遅い) find . -name "*.log" -print0 | xargs -0 -n 1 gzip # -n 100 で100ファイルずつバッチ処理 find . -name "*.log" -print0 | xargs -0 -n 100 gzip
本記事のまとめ
ファイル名に空白・改行・特殊文字が含まれていても壊れないシェルスクリプトを書くためのパターンをまとめます。| 問題のある書き方 | 安全な設計パターン |
|---|---|
| for f in $(find ...) | while IFS= read -r -d '' f; do ... done < <(find ... -print0) |
| ls | xargs cmd | find ... -print0 | xargs -0 cmd |
| for f in $(cat list) | while IFS= read -r f; do ... done < list |
| mapfile(NUL非対応) | mapfile -d '' -t arr < <(find ... -print0) |
| rm "$f"(先頭ハイフンリスク) | rm -- "$f" |
・ファイルリストの受け渡しはNUL区切り(
-print0/xargs -0)を使う・ループでの読み込みは
IFS= read -r -d ''を使う・コマンドへのファイル名渡しは
--と"$f"で必ず保護するこれらの設計習慣を身につけるだけで、スペースや特殊文字を含むファイル名に起因するスクリプトのバグを大幅に減らせます。
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 前のページへ:GNU parallelコマンドでシェルスクリプトの並列処理を設計する方法|xargsより高機能な並列実行とジョブ制御の実践
- この記事の属するカテゴリ:シェルスクリプトへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。
登録無料・いつでも解除できます