2026年5月25日

2026年5月25日

シェルスクリプトのパフォーマンス最適化 実践編:プロセス生成・I/O・バックアップ処理を軽くする

cron で動かすバックアップやログ集計のスクリプトが遅い、あるいは動いている間だけ WordPress の表示が重くなる場合は、「プロセスを作る回数」「同じデータを読む回数」「一時ファイルとディスクへの書き込み」の3つを減らし、最後に優先度を下げて動かすのが効果的です。この記事では、それぞれをどう計測し、どう書き換えるかを、サーバー運用でよくある処理を例に説明します。

ループ内の外部コマンドを組み込み機能に置き換える基本的な書き換えや、time による計測の基本は、同じテーマの別記事で扱っています。この記事はその続きとして、実際に運用しているスクリプトをサーバーに優しくする方法に絞っています。

遅さの種類を見分ける

同じ「遅い」でも、CPU を使い切っているのか、ディスクやネットワークを待っているのか、プロセスを作りすぎているのかで、効く対策が違います。bash 組み込みの time ではなく GNU time(/usr/bin/time)の -v を使うと、詳しい内訳が見られます。

/usr/bin/time -v ./backup.sh 2> time.txt
grep -E 'Elapsed|Maximum resident|context switches|File system' time.txt

見るべき点は次のとおりです。

  • Elapsed(経過時間)に比べて User と System の時間が短い: 待ち時間が大半です。ディスク I/O やネットワークを減らす対策が効きます。
  • Voluntary context switches が多い: I/O などを待って自分から CPU を手放した回数です。待ちが多いことの目安になります。
  • Maximum resident set size: 最も多く使ったメモリ量です。大きなファイルを一度に読み込んでいないかの確認に使います。

プロセスを何回起動しているかは、strace で数えられます。

strace -f -e trace=execve -o exec.log ./report.sh
grep -c execve exec.log

この数がデータの行数と同じくらいなら、1行ごとにコマンドを起動している書き方です。strace を付けると処理はかなり遅くなるため、ここでは回数を数えるだけにして、時間の計測とは分けてください。strace が入っていない場合はパッケージから追加します。

サブシェルを作らない書き方

外部コマンドを呼ばなくても、bash はコマンド置換 $( )、パイプ |、丸括弧 ( ) のたびにサブシェル(自分自身の複製のプロセス)を作ります。1回あたりは短くても、ループの中で何万回も繰り返すと無視できない時間になります。

よくあるのが、関数の戻り値をコマンド置換で受け取る書き方です。

# 呼ぶたびにサブシェルが作られる
to_upper() { printf '%s' "${1^^}"; }
for s in "${items[@]}"; do
  u=$(to_upper "$s")
done
# 結果を変数に直接書き込む(bash 4.3 以降の nameref)
to_upper() { local -n _out=$1; _out=${2^^}; }
for s in "${items[@]}"; do
  to_upper u "$s"
done

local -n は、渡された名前の変数を別名で扱う機能です。古い bash で動かす必要があるなら、「関数は結果をグローバル変数 REPLY に入れる」といった決まりにしても同じ効果があります。なお bash 5.3 では、サブシェルを作らないコマンド置換 ${ コマンド; } が加わりましたが(2026年10月現在、まだ入っていない環境も多い)、動かすサーバーの版を確かめてから使ってください。

複数のコマンドの出力をまとめてリダイレクトしたいときも、( ) ではなく { ...; } を使えばサブシェルを作りません。cd の影響を外に出したくない場合など、本当に分離が必要なときだけ ( ) を使います。

大きなファイルは読み方と回数を見直す

パイプから read で読むと遅い

cat access.log | while read ... と while read ...; done < access.log は同じ結果になりますが、速さが違います。bash の read は、パイプのように読み戻しができない入力からは1バイトずつ読み込みます。通常のファイルをリダイレクトで渡した場合はまとめて読めるため、システムコールの回数が大幅に減ります。

# 遅い: パイプ経由で1バイトずつ読む
cat access.log | while IFS= read -r line; do ...; done

# 速い: ファイルを直接渡す
while IFS= read -r line; do ...; done < access.log

それでも、数十万行を超えるファイルを bash のループで処理するのは時間がかかります。行ごとの処理は awk に任せ、シェルは結果を受け取るだけにするのが基本です。

同じファイルを何度も読まない

調べたい項目の一覧があり、それぞれについて大きなファイルを検索する処理は、項目の数だけファイルを読み直すことになります。

# 遅い: スラッグの数だけ access.log 全体を読み直す
while IFS= read -r slug; do
  printf '%s %s\n' "$(grep -c " /$slug/ " access.log)" "$slug"
done < slugs.txt

一覧を先にメモリへ読み込み、大きなファイルは1回だけ読むように変えます。awk なら、1つ目のファイルを読んでいる間だけ成り立つ NR == FNR の条件で、2つのファイルを順に処理できます。

# 速い: slugs.txt を連想配列に入れ、access.log は1回だけ読む
awk 'NR == FNR { want["/" $1 "/"]; next }
     ($7 in want) { n[$7]++ }
     END { for (p in n) print n[p], p }' slugs.txt access.log | sort -rn

この例は、Combined 形式のログで7番目の項目がリクエストのパスであること、パスが /スラッグ/ の形であることを前提にしています。クエリ文字列が付いたアクセスは一致しないので、必要なら sub(/\?.*/, "", $7) で取り除いてから比べます。bash だけで書く場合も、declare -A の連想配列(bash 4 以降)に一覧を入れて引けば、同じ考え方で読み直しを避けられます。

一時ファイルを減らし、パイプでつなぐ

バックアップスクリプトでは、「ダンプを書き出す → 圧縮する → 元のファイルを消す」という手順がよく見られます。この形はディスクに2回書き込むうえ、圧縮前の大きなファイルぶんの空き容量も必要です。パイプでつなげば、書き込みは圧縮後の1回で済みます。

#!/usr/bin/env bash
set -euo pipefail

cd /var/www/example.com
wp db export - --single-transaction | zstd -T0 -q > "/backup/db-$(date +%F).sql.zst"
tar -cf - wp-content/uploads | zstd -T0 -q > "/backup/uploads-$(date +%F).tar.zst"

ここで重要なのが set -o pipefail です。これが無いと、パイプラインの終了コードは最後のコマンド(zstd)のものになるため、wp db export が失敗しても成功したように見え、中身の壊れたバックアップが残ります。wp db export - はダンプを標準出力に出し、後ろの --single-transaction は mysqldump に渡されて、InnoDB のテーブルをロックせずに整合性のあるダンプを取るためのものです。WP-CLI は root での実行を既定で拒否するため、このスクリプトはサイトのファイルを所有するユーザーの cron で動かします。本番で試す前に、復元までできるかを検証環境で確かめてください。

圧縮方式で時間が大きく変わる

  • gzip: どこでも使えますが、1つの CPU コアしか使いません。
  • pigz: gzip と互換の形式で、複数のコアを使って圧縮します。展開は通常の gzip でもできます。
  • zstd: 同程度の圧縮率なら gzip より速いことが多く、-T0 で利用できるコアに合わせて並列に圧縮します。展開側にも zstd が必要です。
  • xz: ファイルは小さくなりやすい一方、圧縮にかなり時間がかかります。

どれが良いかは、データの種類、CPU のコア数、保存先の容量、復元する環境で使えるツールによって変わります。同じデータで時間と大きさを比べてから決めてください。また、毎回すべてを圧縮し直すより、rsync で変更のあったファイルだけを転送するほうが、アップロード画像のように増えていくデータには向いています。

変わったものだけを処理する

ログやファイルを毎回すべて処理しているなら、前回からの差分だけに絞れないかを考えます。find の -newer を使えば、目印のファイルより新しいものだけを選べます。

set -euo pipefail
stamp=/var/lib/myjob/last-run
[ -e "$stamp" ] || touch -d @0 "$stamp"   # 初回は1970年の日付で作る
next="$(mktemp /var/lib/myjob/run.XXXXXX)"   # 開始時刻の目印を先に作る

# 前回より後に追加・変更されたアップロードファイルだけチェックサムを記録する
find /var/www/example.com/wp-content/uploads -type f -newer "$stamp" -print0 \
  | xargs -0 -r sha256sum >> /backup/uploads.sha256

mv "$next" "$stamp"

目印の扱いには2つ注意点があります。1つは、処理の途中で失敗したときに目印だけが更新されると取りこぼしが出ることです。set -e と pipefail を付けておけば、失敗した時点で止まり、目印は更新されません。もう1つは、処理の最後に touch で目印を作ると、処理中に追加されたファイルが次回の対象から漏れることです。開始時刻の目印を先に作っておき、成功したら置き換える形にすると防げます。なお、この方法は処理の対象ファイル自体を書き換えない場合に向いています。画像の最適化のようにファイルを書き換える処理では、更新日時が変わって毎回対象になってしまうため、処理済みの一覧を別に持つなどの工夫が必要です。

bash の起動回数を減らす

find -exec bash -c '...' \; や、xargs から1件ずつ bash -c を呼ぶ書き方は、ファイルの数だけシェルを起動します。シェルの起動は外部コマンドの中でも重いほうです。ファイルをまとめて渡し、シェルの中でループさせると、起動は数回で済みます。

# ファイルごとに bash を起動する
find . -name '*.log' -exec bash -c 'gzip -9 "$1"' _ {} \;

# まとめて渡し、1つの bash の中でループする
find . -name '*.log' -exec bash -c 'for f; do gzip -9 "$f"; done' _ {} +

for f; do は、引数を順に回すループの省略形です。先頭の _ は $0 に入るダミーの値で、これを書かないと最初のファイルが $0 に取られて処理されません。

サーバーへの負荷を抑えて動かす

速くすることと同じくらい大切なのが、同じサーバーで動いている Web サーバーやデータベースの邪魔をしないことです。nice で CPU の優先度を、ionice でディスク I/O の優先度を下げられます。

nice -n 19 ionice -c3 /usr/local/bin/backup.sh

ionice -c3 は、他の処理がディスクを使っていないときだけ I/O を行う「idle」クラスです。ただし、優先度が反映されるかどうかは I/O スケジューラーに依存し、BFQ などでは効きますが、それ以外では効果が見られないことがあります。cat /sys/block/デバイス名/queue/scheduler で使われているスケジューラーを確認できます。

systemd のタイマーで動かしているなら、サービスのユニットファイルに同じ設定を書けます。CPU の使用率の上限も指定できます。

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
Nice=19
IOSchedulingClass=idle
CPUQuota=50%

また、前回の実行が終わらないうちに次の実行が始まると、同じ処理が重なって負荷が倍になります。cron で動かすスクリプトは、flock -n などで二重起動を防いでおくと安心です。

変更の前後で結果を比べる

最適化した後は、同じ入力で前後の出力を diff で比べ、処理時間も複数回測って比較します。特にバックアップでは、速くなったかどうかより、正しく復元できるかのほうが重要です。圧縮方式やダンプの方法を変えたら、検証環境に復元して WordPress が動くことまで確認してください。

まとめ

  • /usr/bin/time -v で「CPU を使っているのか、待っているのか」を見分け、strace -e trace=execve でプロセスの起動回数を数える。
  • コマンド置換による関数の戻り値など、ループ内のサブシェルを減らす。結果は nameref やグローバル変数で受け取る。
  • read はパイプより直接のリダイレクトで読む。大きなファイルを何度も読まず、一覧はメモリに入れて1回で突き合わせる。
  • ダンプと圧縮はパイプでつなぎ、set -o pipefail で途中の失敗を見逃さない。圧縮方式は実データで比べて選ぶ。
  • nice・ionice や systemd の設定で優先度を下げ、二重起動を防ぐ。変更後は出力の比較と復元の確認を行う。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲