単発小ネタ bashスクリプト:ファイル数、ディレクトリ数調査ツール例
ファイルあるか確認してるんだけど、何か遅い時がある
1. 背景・目的
Linux / NAS 環境で、単一ディレクトリ配下同一階層に大量のファイルが存在すると、探索・一覧・検索処理が極端に遅くなることがあります。
-
lsやfindが返ってこない - アプリケーション側でファイル探索に時間がかかる
- バックアップ処理が想定以上に長時間化する
調査と再現試験のために、以下を作成しました。
- 実際にどのディレクトリにどれくらいファイルが存在するのかを可視化する調査ツール
- そのツールの動作検証用として、大量ファイルを持つテスト用ディレクトリ構成、ファイルを生成するツール
2. ツール構成
| ツール名 | 役割 |
|---|---|
file_count.sh |
指定パス配下を再帰走査し、各ディレクトリ直下のファイル数・ディレクトリ数を集計 |
make_test_nas_tree.sh |
年月日階層+大量ファイルを持つテスト用ディレクトリを生成(動作検証用) |
3. file_count.sh(調査ツール)
3.1 できること
- 指定ディレクトリ配下を 再帰的に 1 回の
findで走査 - 各ディレクトリについて以下を集計
- 直下のファイル数
- 直下のディレクトリ数
- CSV 形式で結果を出力
- 実行中は標準出力に進捗(現在位置・件数・経過時間)を表示
3.2 使い方
chmod +x file_count.sh
./file_count.sh /nas
出力例(CSV):
path,file_count,dir_count
/nas/2024,0,12
/nas/2024/01,0,20
/nas/2024/01/05,12345,0
CSV はスクリプトと同じディレクトリに、以下の形式で出力されます。
<hostname>_yyyymmdd_hhmmss_file_count.csv
3.3 実装上のポイント(簡単に)
- ディレクトリごとに
lsやfindを回さない - ファイルについて
findを1回だけ実行し、親ディレクトリ単位で集計 - NAS への負荷を最小限に抑える設計
4. make_test_nas_tree.sh(検証用データ生成ツール)
4.1 できること
- 年/月/日 形式のディレクトリ構成を作成
- 日付ディレクトリは「存在する日/存在しない日」を混在
- 存在する日は
- 数千〜数十万のテキストファイルを生成
- 内容は固定文字列
test
- 実行中は
- 現在処理中の日付
- 全体進捗
- 作成済ファイル数
を標準出力に可視化
4.2 使い方
chmod +x make_test_nas_tree.sh
./make_test_nas_tree.sh
生成先:
./nas_testdata/2024/MM/DD/
4.3 実装上のポイント
-
/dev/urandomを利用して日付有無・ファイル数をランダム化 -
mkdir -pを使用し、月ディレクトリは「必要な日だけ」生成 - 実 NAS での実行は重いため、まずはローカルディスクで検証推奨
5. 一般的な注意点、目安
5.1 ディレクトリあたりのファイル数目安
性能的な意味での明確な「閾値、上限」ではありませんが、良く言われる数字や経験則として:
| ファイル数 | 影響の出やすさ |
|---|---|
| 数千 | ほぼ問題なし |
| 数万 |
ls や探索で体感遅延が出始める |
| 数十万 | 明確に遅い。アプリ影響が指摘されやすい |
| 数百万 | 運用上恐らくNG(設計見直し推奨) |
#ファイルシステム(ext4 / xfs)、inode、ファイルディスクリプタ、キャッシュ状況、HW、NAS実装により差あり
5.2 調査の進め方
- まず 実態を数値で把握(今回の
file_count.sh) - 問題ディレクトリを特定
- 以下を検討
- 日付・ハッシュなどによる分散
- アーカイブ化
- アプリ側の探索ロジック見直し
5.3 運用の注意点
- 「気付いたら増えていた」が一番危険
- 定期的に集計して 増加傾向を見る
- NAS 上での
find/duの多用は慎重に - 調査用ツールは「速く・軽く・一度で」
- どのくらいが自分の環境にとっての「大量」なのかを事前に実験してみよう
6. スクリプト
調査ツール
#!/usr/bin/env bash
set -euo pipefail
# --------------------------------------------
# file_count.sh
# 指定パス配下を再帰走査し、
# 「各ディレクトリ直下の ファイル数 / ディレクトリ数」を集計してCSV出力。
#
# 使い方:
# ./file_count.sh /nas
#
# 出力:
# <script_dir>/<hostname>_yyyymmdd_hhmmss_file_count.csv
# カラム: path,file_count,dir_count
# --------------------------------------------
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <target_dir>"
exit 1
fi
TARGET="$1"
if [[ ! -d "$TARGET" ]]; then
echo "ERROR: target is not a directory: $TARGET"
exit 1
fi
# スクリプトが置かれているディレクトリにログ出力
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOST="$(hostname -s 2>/dev/null || hostname)"
TS="$(date +%Y%m%d_%H%M%S)"
OUT="${SCRIPT_DIR}/${HOST}_${TS}_file_count.csv"
START_EPOCH="$(date +%s)"
# 進捗表示用: 全ディレクトリ一覧(空ディレクトリも出したいので)
DIRLIST_TMP="$(mktemp)"
trap 'rm -f "$DIRLIST_TMP"' EXIT
echo "Target: $TARGET"
echo "Preparing directory list (for progress/output) ..."
# 走査1回目(ディレクトリだけ一覧化、NUL区切り)
find "$TARGET" -type d -print0 > "$DIRLIST_TMP"
# ディレクトリ総数(NULの個数)
DIR_TOTAL="$(tr -cd '\0' < "$DIRLIST_TMP" | wc -c | tr -d ' ')"
if [[ "$DIR_TOTAL" -eq 0 ]]; then
echo "No directories found under: $TARGET"
exit 0
fi
echo "Total directories: $DIR_TOTAL"
echo "Scanning entries and aggregating (single traversal) ..."
# 集計用(bash 4系の連想配列)
declare -A FILE_CNT
declare -A DIR_CNT
# 走査2回目(調査本番、ディレクトリ以外)
# 各エントリの「親ディレクトリ(%h)」と「種別(%y)」をNUL区切りで吐かせて集計
# %y: f=file, d=dir, l=symlink, etc...
# 「d 以外」をファイル扱いにする(要件に合わせて調整可)
while IFS=$'\t' read -r parent typ; do
if [[ "$typ" == "d" ]]; then
DIR_CNT["$parent"]=$(( ${DIR_CNT["$parent"]:-0} + 1 ))
else
FILE_CNT["$parent"]=$(( ${FILE_CNT["$parent"]:-0} + 1 ))
fi
done < <(find "$TARGET" -mindepth 1 -printf '%h\t%y\0' | tr '\0' '\n')
echo "Writing CSV: $OUT"
echo "path,file_count,dir_count" > "$OUT"
# DIRLIST_TMP から順番に出力しつつ進捗表示
i=0
# NUL区切りを読み出す
while IFS= read -r -d '' dir; do
i=$((i+1))
f="${FILE_CNT["$dir"]:-0}"
d="${DIR_CNT["$dir"]:-0}"
# CSV出力(カンマを含むパス対策で念のためダブルクォート)
printf '"%s",%s,%s\n' "$dir" "$f" "$d" >> "$OUT"
# 進捗表示(1ディレクトリごと)
now="$(date +%s)"
elapsed=$((now - START_EPOCH))
pct=$(( i * 100 / DIR_TOTAL ))
clear
echo "Target: $TARGET"
echo "Output: $OUT"
echo
echo "Progress: ${i}/${DIR_TOTAL} (${pct}%)"
echo "Elapsed: ${elapsed}s"
echo
echo "Current: $dir"
done < "$DIRLIST_TMP"
# 最後に結果サマリ
END_EPOCH="$(date +%s)"
TOTAL_ELAPSED=$((END_EPOCH - START_EPOCH))
clear
echo "Done."
echo "Target: $TARGET"
echo "Output: $OUT"
echo "Directories: $DIR_TOTAL"
echo "Elapsed: ${TOTAL_ELAPSED}s"
検証用データ生成ツール
#!/usr/bin/env bash
set -euo pipefail
# ------------------------------------------------------------
# make_test_nas_tree.sh
# このスクリプトの配置場所を起点に、テスト用の年月日ディレクトリを作成し、
# 日付ディレクトリが「ある/ない」を混在させ、ある日は大量のテキストファイルを生成する。
#
# 期間: 2024/01/01 ~ 2024/03/31(3か月分)
# 出力先: ./nas_testdata/2024/MM/DD/
#
# 使い方:
# chmod +x make_test_nas_tree.sh
# ./make_test_nas_tree.sh
#
# 調整:
# - 作る日付ディレクトリの出現率: CREATE_DAY_RATE
# - 1日あたりの最大ファイル数: MAX_FILES_PER_DAY
# - 1日あたりの最小ファイル数: MIN_FILES_PER_DAY
# ------------------------------------------------------------
# ===== 調整パラメータ =====
CREATE_DAY_RATE=70 # 0-100: 日付ディレクトリを作る確率(70=7割)
MIN_FILES_PER_DAY=2000 # 数千
MAX_FILES_PER_DAY=120000 # 数十万(やりすぎ注意。必要なら上げる)
# =========================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BASE="${SCRIPT_DIR}/nas_testdata"
START_DATE="2024-01-01"
END_DATE="2024-03-31"
# GNU date 前提(RHEL系はOK、macOS(BSD date)はそのままだと動きません)
if ! date -d "$START_DATE" +%F >/dev/null 2>&1; then
echo "ERROR: GNU date が必要です(date -d が使えない環境です)"
exit 1
fi
start_epoch="$(date -d "$START_DATE" +%s)"
end_epoch="$(date -d "$END_DATE" +%s)"
# 日数計算(両端含む)
total_days=$(( (end_epoch - start_epoch) / 86400 + 1 ))
# 乱数(/dev/urandom を使い、同じ分布で毎回バラつくように)
rand_u32() {
od -An -N4 -tu4 /dev/urandom | tr -d ' '
}
rand_range() { # rand_range MIN MAX (両端含む)
local min="$1" max="$2"
local span=$((max - min + 1))
local r
r="$(rand_u32)"
echo $(( min + (r % span) ))
}
rand_percent() { # 0-99
local r
r="$(rand_u32)"
echo $(( r % 100 ))
}
mkdir -p "$BASE/2024"
created_days=0
skipped_days=0
created_files_total=0
t0="$(date +%s)"
for ((i=0; i<total_days; i++)); do
day_epoch=$(( start_epoch + i*86400 ))
ymd="$(date -d "@$day_epoch" +%Y/%m/%d)"
ymd_dash="$(date -d "@$day_epoch" +%F)"
# 進捗表示
now="$(date +%s)"
elapsed=$((now - t0))
pct=$(( (i+1) * 100 / total_days ))
clear
echo "Test data generator"
echo "Base: $BASE"
echo "Period: $START_DATE .. $END_DATE"
echo
echo "Progress: $((i+1))/$total_days (${pct}%) Elapsed: ${elapsed}s"
echo "Now: $ymd (processing)"
echo
echo "Created days: $created_days"
echo "Skipped days: $skipped_days"
echo "Total files: $created_files_total"
echo
echo "Tip: MAX_FILES_PER_DAY=${MAX_FILES_PER_DAY} (adjust if too heavy)"
# 日付ディレクトリを作るか判定
p="$(rand_percent)"
if (( p >= CREATE_DAY_RATE )); then
skipped_days=$((skipped_days+1))
continue
fi
# 作成するファイル数を決定(数千~数十万でばらつき)
# たまに大きくしてみる:
# base=一様、boost=小さい確率で上乗せ
files="$(rand_range "$MIN_FILES_PER_DAY" "$MAX_FILES_PER_DAY")"
boost_p="$(rand_percent)"
if (( boost_p < 10 )); then
# 10%の確率でさらに増やす(上限は MAX_FILES_PER_DAY のまま)
extra="$(rand_range 0 $((MAX_FILES_PER_DAY/2)) )"
files=$((files + extra))
if (( files > MAX_FILES_PER_DAY )); then
files="$MAX_FILES_PER_DAY"
fi
fi
dir="${BASE}/${ymd}"
mkdir -p "$dir"
created_days=$((created_days+1))
# ファイル生成
# ※大量ファイルは時間がかかります。NASテストの雰囲気作りなので内容は "test" 固定。
# 連番で file_000001.txt のように作成
for ((n=1; n<=files; n++)); do
printf "test\n" > "${dir}/file_$(printf '%06d' "$n").txt"
# 進捗はほどほどに更新(取り敢えず5000ファイルごと)
if (( n % 5000 == 0 )); then
now2="$(date +%s)"
elapsed2=$((now2 - t0))
clear
echo "Test data generator"
echo "Base: $BASE"
echo "Period: $START_DATE .. $END_DATE"
echo
echo "Progress: $((i+1))/$total_days (${pct}%) Elapsed: ${elapsed2}s"
echo "Now: $ymd (creating files)"
echo
echo "Directory: $dir"
echo "Files: ${n}/${files}"
echo
echo "Created days: $created_days"
echo "Skipped days: $skipped_days"
echo "Total files: $created_files_total"
fi
done
created_files_total=$((created_files_total + files))
done
t1="$(date +%s)"
elapsed_all=$((t1 - t0))
clear
echo "Done."
echo "Base: $BASE"
echo "Period: $START_DATE .. $END_DATE"
echo "Created days: $created_days"
echo "Skipped days: $skipped_days"
echo "Total files: $created_files_total"
echo "Elapsed: ${elapsed_all}s"
echo
echo "Example:"
echo " ls -l ${BASE}/2024/*/* | head"
7. おまけ
入れ子探索で問題になるワンライナー例
find /tmp/nas_testdata/ -type d | xargs -I{} sh -c 'echo -n "{} : " && find {} -type f | wc -l'
仮に:
- ディレクトリ数:10,000
- ファイル総数:5,000,000
だとすると:
- 外側 find:1回
- 内側 find:10,000回
NAS 上で 5,000,000 × 10,000 相当の探索が発生
↓まだマシなワンライナー例
find /tmp/nas_testdata -mindepth 1 -type f -printf '%h\n' | sort | uniq -c
どうせ調査は時間かかるので状況可視化したいし、
同じ形式で何度もログファイルに記録を残したいということもあり、
ワンライナーは諦めてbashスクリプトにしています。