本文へ移動
購読
GUIDE#devtools更新 2026.10.11

grepコマンドの使い方:正規表現・再帰検索とログ調査で使う組み合わせ

GNU grep 3.12をテスト用のログと設定ファイルで検証。-G・-E・-Pで変わる件数、-Fと-wで防ぐIPアドレスの誤検出、set -eで止まる終了コード、ロケールやripgrepとの違いを実行結果で確かめます。

level
中級
time
約45分
stack
Ubuntu 26.04.1 LTS / GNU grep 3.12(grep 3.12-1、-P は PCRE2 10.46) / ripgrep 15.1.0 / bash 5.3.9・dash 0.5.12(/bin/sh) / uutils coreutils 0.10.0(sort・uniq・wc・tail・timeout) / gzip 1.14(zgrep) / mawk 1.3.4 / glibc 2.43(locales 2.43) / git 2.53.0 / procps 4.0.4
author
@ryoma_t
// 前提条件
シェルでcd・cat・パイプが使えること / 再現する場合はDockerでubuntu:26.04のコンテナを1つ起動できること
grepの-G・-E・-Pで件数が変わる例、-Fと-wでIPアドレスの誤検出を絞る例、-Aで例外の前後を読む例を実行したターミナル画面
画像:nagoya.dev(実際の画面・実行結果をもとに作成)

grepコマンドとは、ファイルや標準入力を行単位で読み、指定したパターンに一致した行だけを出力するコマンドです。索引を持つ全文検索の仕組みではないので、実行のたびに対象を先頭から読みます。オプションの意味は一覧で調べられますが、実際に手が止まるのは「-Eを付けたら件数が変わった」「0件のときだけスクリプトが黙って終わる」といった挙動の差のほうです。Ubuntu 26.04.1 の GNU grep 3.12 を使い、ログと設定ファイルの小さなツリーをコマンドで生成して、その差を件数で確かめました。

対象は、サーバーのログや設定ファイルをgrepで調べる情シス・インフラ担当の方と、シェルスクリプトの条件分岐にgrepを使っている方です。読み終えると、正規表現の方言ごとの件数表、IPアドレスのような文字列を誤検出を減らして探す書き方、set -eのスクリプトでも止まらない件数の取り方、grep・ripgrep・awkを選ぶ判断表が手元に残ります。

この記事でわかること

  • 同じパターンが-G・-E・-Pで0件にも3件にも7件にもなる理由と、Ubuntuのgrepで\dが警告なしで外れる例
  • IPアドレスの検索で.が起こす誤検出を、-Fと-wで9件から7件、4件へ絞る手順
  • OR・AND・NOTの書き方、-rと-Rのシンボリックリンクの扱い、--exclude-dirがパスでは効かない点
  • 終了コード0・1・2の意味と、set -eのスクリプトが0件で黙って止まる原因と直し方
  • ロケール・文字コード・圧縮・バッファリングで結果が変わる場面と、grep・ripgrep・awkの判断表

検証環境:コンテナとテスト用のログ・設定ファイルを作る

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

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

--initは、後半でtimeoutを使ってパイプラインを打ち切る実験のために付けました。付けずに同じ実験をした別のコンテナでは、打ち切られたtailやgrepが<defunct>の行としてpsに残りました。--memory 512mは共用のサーバーで動かすための上限で、grepの検証そのものには要りません。ポートの公開も--privilegedも使いません。

コンテナにはdocker exec -it grep-command-usage-regex-lab bashで root として入ります。後半の比較に使う ripgrep と git、日本語ロケールを生成するためのlocalesを入れ、作業ユーザーを作ります。grep・awk(mawk)・gzip(zgrepを含む)・ps(procps)は、このイメージに最初から入っていました。

terminal(コンテナ内・root)
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ripgrep git locales >/dev/null 2>&1; echo "exit=$?"
exit=0
locale-gen ja_JP.UTF-8
Generating locales (this might take a while)...
  ja_JP.UTF-8... done
Generation complete.
useradd -m -s /bin/bash ryoma && id ryoma
uid=1001(ryoma) gid=1001(ryoma) groups=1001(ryoma)

以降はdocker exec -it -u ryoma -w /home/ryoma grep-command-usage-regex-lab bashで作業ユーザーとして入り直し、作業ディレクトリを作って移動します。この先のコマンドは、すべて~/grep-labをカレントディレクトリにして実行したものです。最初に、使うコマンドの版とロケールを確認します。

terminal
mkdir -p ~/grep-lab && cd ~/grep-lab
grep PRETTY_NAME /etc/os-release
PRETTY_NAME="Ubuntu 26.04.1 LTS"
grep --version | head -n 1; grep --version | tail -n 1
grep (GNU grep) 3.12
grep -P uses PCRE2 10.46 2025-08-27
bash --version | head -n 1; rg --version | head -n 1
GNU bash, version 5.3.9(1)-release (x86_64-pc-linux-gnu)
ripgrep 15.1.0
for c in sort uniq wc tail timeout; do $c --version | head -n 1; done
sort (uutils coreutils) 0.10.0
uniq (uutils coreutils) 0.10.0
wc (uutils coreutils) 0.10.0
tail (uutils coreutils) 0.10.0
timeout (uutils coreutils) 0.10.0
dpkg-query -W grep ripgrep git bash dash gzip mawk procps rust-coreutils libc6
bash	5.3-2ubuntu1
dash	0.5.12-12ubuntu3
git	1:2.53.0-1ubuntu1
grep	3.12-1
gzip	1.14-1~exp2ubuntu1.1
libc6:amd64	2.43-2ubuntu2.4
mawk	1.3.4.20260129-1
procps	2:4.0.4-9ubuntu1
ripgrep	15.1.0-1ubuntu1
rust-coreutils	0.10.0-1ubuntu2~26.04.1
locale | head -n 3; locale -a
LANG=
LANGUAGE=
LC_CTYPE="POSIX"
C
C.utf8
POSIX
ja_JP.utf8

grep --versionの最終行grep -P uses PCRE2 10.46は、このgrepが-P(Perl互換の正規表現)を PCRE2 というライブラリで処理することを示しています。-Pを使うスクリプトを別のマシンで動かす前に、同じコマンドでこの行を確かめてください。sort・uniq・wc・tail・timeoutは、このイメージでは uutils coreutils 0.10.0 でした。集計の節でgrepの後ろにつなぐコマンドなので、版を残しておきます。

ロケールは POSIX(LANGが空)です。locale -aはC.utf8・ja_JP.utf8という表記で一覧を出しますが、指定するときはC.UTF-8・ja_JP.UTF-8と書いて通ります。ロケールで結果が変わる例は後半の節にまとめ、そこではLC_ALLをコマンドごとに明示します。

この記事の出力は、端末を割り当てないdocker execで、コマンドを1つずつsh -lcに渡して記録したものです(Ubuntuのshは dash 0.5.12)。bash 5.3.9 の対話シェルで打った場合と見え方が変わる箇所は、その場で断ります。シェルの種類が結果に関わる箇所は、bashでも実行して確かめています。

テスト用のファイルは次のコマンドで作ります。乱数は使っていないので、同じコマンドを流せば、この記事と同じ件数になります。

terminal
mkdir -p logs conf data releases/v1 releases/v2
cat > logs/app.log <<'EOF'
2026-10-01 09:00:01 INFO  app started pid=4120 port=8080
2026-10-01 09:00:02 INFO  db pool ready size=10 host=10.0.0.5
2026-10-01 09:00:15 INFO  health check ok node=ip-10-0-0-5.internal
2026-10-01 09:01:10 WARN  slow query 812ms table=orders
2026-10-01 09:02:44 INFO  request id=10203045 GET /orders 200 95ms
2026-10-01 09:03:05 ERROR db connection timeout after 3000ms host=10.0.0.5
2026-10-01 09:03:06 ERROR retry 1/3 failed: connection refused host=10.0.0.5
2026-10-01 09:03:08 WARN  failover to replica host=10.0.0.50
2026-10-01 09:03:09 INFO  db pool ready size=10 host=10.0.0.50
2026-10-01 09:04:20 INFO  login user="tanaka" from="10.0.0.51" result="ok"
2026-10-01 09:05:12 WARN  3 errors in the last 5 minutes
2026-10-01 09:06:30 INFO  request id=10203046 GET /orders 200 88ms
2026-10-01 09:07:41 ERROR upstream timeout after 5000ms path=/api/stock
2026-10-01 09:08:02 INFO  cache refreshed keys=1532 in 41ms
2026-10-01 09:10:31 ERROR unhandled exception in OrderService.create
java.lang.IllegalStateException: stock is negative: item=A-1042
    at shop.OrderService.create(OrderService.java:88)
    at shop.OrderController.post(OrderController.java:41)
Caused by: java.sql.SQLException: deadlock detected
2026-10-01 09:10:31 INFO  request id=10203047 POST /orders 500 1204ms
2026-10-01 09:12:00 INFO  login user="sato" from="192.168.10.7" result="ok"
2026-10-01 09:13:27 WARN  disk usage 91% on /var/lib/app
2026-10-01 09:15:00 ERROR batch failed: last message was "ERROR: disk quota exceeded"
2026-10-01 09:15:01 INFO  batch retry scheduled in 300s
2026-10-01 09:16:45 INFO  Error page rendered for /orders/9981
2026-10-01 09:18:10 INFO  connection restored host=10.0.0.5
2026-10-01 09:20:00 INFO  app stopping pid=4120
EOF
awk 'BEGIN { split("10.0.0.5 192.168.10.7 10.0.0.50 172.16.3.20 10.0.0.5 10.0.0.51 192.168.10.7", ip, " "); split("/ /orders /login /api/stock /health", path, " "); split("200 200 404 200 500 200 302 200 200 503 200", st, " "); for (i = 0; i < 300; i++) printf "%s - - [01/Oct/2026:09:%02d:%02d +0900] \"GET %s HTTP/1.1\" %s %d %d\n", ip[i % 7 + 1], int(i / 60), i % 60, path[i % 5 + 1], st[i % 11 + 1], 500 + (i * 53) % 4000, 5 + (i * 37) % 1900 }' > logs/access.log
sed 's/2026-10-01/2026-09-30/' logs/app.log | gzip > logs/app.log.1.gz
cat > conf/nginx.conf <<'EOF'
upstream app {
    server 10.0.0.5:8080;
    server 10.0.0.50:8080 backup;
}
server {
    listen 443 ssl;
    server_name shop.example.com;
    location / {
        proxy_pass http://app;
        proxy_connect_timeout 5s;
        proxy_read_timeout 30s;
    }
}
EOF
cat > conf/app.conf <<'EOF'
# application settings
db_host=10.0.0.5
db_timeout=3000
#db_timeout=1000
log_level=INFO
EOF
printf 'db_host=10.0.0.9\ndb_timeout=1000\nlog_level=DEBUG\n' > conf/app.conf.bak
printf 'DB_USER=app\nDB_TIMEOUT=3000\n' > conf/.env
printf 'db_host=10.0.0.9\ndb_timeout=1000\n' > releases/v1/app.conf
printf 'db_host=10.0.0.5\ndb_timeout=3000\n' > releases/v2/app.conf
ln -s releases/v2 current
cat > data/batch.log <<'EOF'
2026-10-01 02:00:01 バッチ開始 ジョブ=売上集計
2026-10-01 02:00:09 エラー発生 コード=E102 顧客=ABC商事
2026-10-01 02:00:10 警告 再試行します
2026-10-01 02:00:15 エラー発生 コード=E205 顧客=abc商事
2026-10-01 02:00:31 バッチ終了 件数=1532
EOF
iconv -f UTF-8 -t SHIFT_JIS data/batch.log > data/batch-sjis.log
printf 'SESS\0\001\002 user=tanaka idle_timeout=900\n' > data/session.dat
  • logs/app.log:手書きのアプリケーションログ。ERRORの行、レベルの付かないスタックトレース、IPアドレスに似た文字列(ip-10-0-0-5、id=10203045)を入れてあります
  • logs/access.log:awkで生成したアクセスログ。末尾の3列はステータスコード・バイト数・応答時間(ミリ秒)です
  • logs/app.log.1.gz:app.logの日付だけを変えてgzipで圧縮した、ローテーション済みログの代役
  • conf/・releases/・current:設定ファイルと、隠しファイル.env、releases/v2を指すシンボリックリンク
  • data/:日本語のログ(UTF-8 と Shift_JIS)と、NULバイトを含むsession.dat

できあがったファイルと行数を確かめます。findの! -type dは、ディレクトリ以外(ファイルとシンボリックリンク)だけを並べる指定です。

terminal
find . ! -type d | sort
./conf/.env
./conf/app.conf
./conf/app.conf.bak
./conf/nginx.conf
./current
./data/batch-sjis.log
./data/batch.log
./data/session.dat
./logs/access.log
./logs/app.log
./logs/app.log.1.gz
./releases/v1/app.conf
./releases/v2/app.conf
wc -l logs/app.log logs/access.log
   27 logs/app.log
  300 logs/access.log
  327 total
head -n 3 logs/access.log
10.0.0.5 - - [01/Oct/2026:09:00:00 +0900] "GET / HTTP/1.1" 200 500 5
192.168.10.7 - - [01/Oct/2026:09:00:01 +0900] "GET /orders HTTP/1.1" 200 553 42
10.0.0.50 - - [01/Oct/2026:09:00:02 +0900] "GET /login HTTP/1.1" 404 606 79

grepコマンドの基本形とよく使うオプション

基本形はgrep [オプション] パターン [ファイル...]です。ファイルを省くと標準入力を読むので、コマンド | grep パターンの形でも使えます。まずERRORを含む行を、行番号つき(-n)と件数だけ(-c)で出します。

terminal
grep -n ERROR logs/app.log
6:2026-10-01 09:03:05 ERROR db connection timeout after 3000ms host=10.0.0.5
7:2026-10-01 09:03:06 ERROR retry 1/3 failed: connection refused host=10.0.0.5
13:2026-10-01 09:07:41 ERROR upstream timeout after 5000ms path=/api/stock
15:2026-10-01 09:10:31 ERROR unhandled exception in OrderService.create
23:2026-10-01 09:15:00 ERROR batch failed: last message was "ERROR: disk quota exceeded"
grep -c ERROR logs/app.log
5
grep -ci error logs/app.log
7
grep -ciw error logs/app.log
6

-cの5に対して、大文字小文字を区別しない-iでは7になりました。増えた2行は3 errors in the last 5 minutesとError page renderedです。-wを足すと6に減ります。-wは「一致した部分の前後が、英字・数字・アンダースコア以外であること」を条件にするので、後ろにsが続くerrorsは外れます。

よく使うオプションを、POSIXの規格(Issue 8)に載っているかどうかと合わせて並べます。

オプション働きPOSIX
-i大文字小文字を区別しない○
-v一致しない行を出す○
-n行番号を付ける○
-c一致した行の数だけを出す○
-l一致したファイルの名前だけを出す○
-q何も出力せず、終了コードだけを返す○
-e パターンパターンを複数指定する○
-E / -F拡張正規表現 / 固定文字列として扱う○
-w単語として一致するものだけに絞る×
-o行全体ではなく、一致した部分だけを出す×
-r / -Rディレクトリを再帰的に探す×
-A / -B / -C一致した行の後ろ・前・前後も出す×
-PPerl互換の正規表現として扱う×
-a / -Iバイナリをテキスト扱いにする / 無視する×
--include / --exclude-dir再帰検索の対象をファイル名・ディレクトリ名で絞る×
--line-buffered出力を1行ごとに書き出す×

×のオプションはPOSIXの規格にはなく、実装ごとの拡張にあたります。今回検証したのは GNU grep だけなので、BusyBox や BSD 系など別の実装で動かすスクリプトに×のオプションを書くときは、その環境で先に試してください。

対話シェルでの見え方について1点補足します。Ubuntuの~/.bashrcには次のエイリアスがあり、対話シェルで打つgrepは実際にはgrep --color=autoです。

terminal
grep -n 'alias grep' ~/.bashrc
82:    alias grep='grep --color=auto'

--color=autoは、標準出力が端末のときだけ一致した部分に色を付けます。この記事の記録は端末なしなので色の制御文字は入っておらず、パイプやファイルに出力した場合も同じです。

クォートしないパターンは、grepに届く前にシェルが書き換える

grepが受け取るのは、シェルが展開を済ませたあとの文字列です。log_levelのようにlogで始まる設定キーを探すつもりで、クォートなしのlog*を渡してみます。

terminal(一致しない例)
grep -n log* conf/nginx.conf conf/app.conf; echo "exit=$?"
exit=1
printf '%s\n' log* app* 10\.0\.0\.5
logs
app*
10.0.0.5

何も出ず、終了コードは1(一致なし)でした。printfで確かめると、log*はカレントディレクトリにあるlogsというディレクトリ名に展開されています。grepはlogsという文字列を探して、見つからなかったわけです。app*のほうは、該当する名前がカレントディレクトリにないので、そのまま渡っています。3つ目の10\.0\.0\.5はバックスラッシュが取り除かれ、10.0.0.5になりました。どれもエラーにはならないので、結果の件数を見るまで気づけません。bash 5.3.9 でも同じ展開になります。

terminal
bash -c 'printf "%s\n" log* app* 10\.0\.0\.5'
logs
app*
10.0.0.5

シングルクォートで囲めば、パターンはそのままgrepに届きます。ただし、届いたあとの*の意味は、ファイル名のワイルドカードとは別物です。

terminal
grep -n 'log*' conf/nginx.conf conf/app.conf
conf/nginx.conf:8:    location / {
conf/app.conf:5:log_level=INFO
grep -n 'log_' conf/nginx.conf conf/app.conf
conf/app.conf:5:log_level=INFO

正規表現の*は「直前の文字の0回以上の繰り返し」です。log*は「loの後ろにgが0個以上」という意味になり、locationのloにも一致しました。「logで始まる何か」を探すならlogだけで足り、区切りまで含めるならlog_と書きます。

筆者の決めごとは「パターンは常にシングルクォート、シェル変数を入れるときだけダブルクォート」です。app*の例のとおり、dashとbashの既定の設定では、一致する名前がカレントディレクトリになければクォートなしでも同じ文字列が届きます(シェルやその設定が違えば、この扱いも変わります)。そのため、今日動いたコマンドが、一致する名前のあるディレクトリで実行した途端に黙って0件になります。

grepの正規表現:-G・-E・-Pで同じパターンの件数が変わる

GNU grep には正規表現の方言が3つあります。何も付けないとき(-Gと同じ)の基本正規表現(BRE)、-Eの拡張正規表現(ERE)、-PのPerl互換正規表現(PCRE)です。同じパターンを3つの方言に渡し、logs/app.logで一致した行数を並べます。

terminal(一致しない組み合わせを含む)
for opt in -G -E -P; do printf '%s ' "$opt"; grep -c "$opt" 'timeout|refused' logs/app.log; done
-G 0
-E 3
-P 3
for opt in -G -E -P; do printf '%s ' "$opt"; grep -c "$opt" 'timeout\|refused' logs/app.log; done
-G 3
-E 0
-P 0
for opt in -G -E -P; do printf '%s ' "$opt"; grep -c "$opt" '[0-9]{4}ms' logs/app.log; done
-G 0
-E 3
-P 3
for opt in -G -E -P; do printf '%s ' "$opt"; grep -c "$opt" '\d+ms' logs/app.log; done
-G 0
-E 0
-P 7
パターン-G(既定)-E-P差が出る理由
timeout|refused033BREでは|はただの文字
timeout\|refused300BREのORは\|。EREとPCREでは文字としての|を指す
[0-9]{4}ms033BREの回数指定は\{4\}と書く
\d+ms007\d(数字)が通じるのはPCREだけ

上の2行は、|の扱いがBREとEREで逆になることを示しています。{4}のほか、+・?・( )も同じ関係で、BREではバックスラッシュを付けた形が特別な意味を持ちます。この4つのパターンでは、どの方言でもエラーは出ず、0が返るだけです。

4行目が、いちばん気づきにくい差です。\dはPCREの書き方で、このgrepの-Eは文字のdとして扱いました。

terminal
echo 'ad 12' | grep -oE '\d+'; echo 'ad 12' | grep -oP '\d+'
d
12

Ubuntu 26.04 の grep 3.12-1 では、grep -E '\d+'はエラーにも警告にもならず、数字ではなく文字のdを探しました。このパッケージでは警告が既定で止められていて、環境変数DEB_GREP_ENABLE_STRAY_BACKSLASH_WARN=1を付けてgrep -cE '\d+ms' logs/app.logを実行すると、grep: warning: stray \ before dが表示されました(件数は0のままです)。ほかの環境のgrepでは、この警告が最初から出ることがあります。-Eで数字を書くときは[0-9]を使い、\dを使うなら-Pに切り替えます。

最短一致の.*?も、-Eでは通じません。-o(一致した部分だけを出す)で、ログのuser="..."の部分を抜き出して比べます。

terminal
grep -oE 'user=".*?"' logs/app.log
user="tanaka" from="10.0.0.51" result="ok"
user="sato" from="192.168.10.7" result="ok"
grep -oP 'user=".*?"' logs/app.log
user="tanaka"
user="sato"
grep -o 'user="[^"]*"' logs/app.log
user="tanaka"
user="sato"

-Eでは?が最短一致の指定にならず、行末の"まで取ってしまいました。-Pでは最初の閉じ引用符で止まります。3つ目の[^"]*(引用符以外の文字の繰り返し)は、オプションなしのBREのままで-Pと同じ結果になります。

筆者は、まず-Eと[0-9]や[^"]*のような文字クラスで書けないかを考え、\Kや先読みが必要なときだけ-Pを使います。マニュアルには、Perl互換の正規表現はPCREがそのシステムで使える場合にだけ動くとあるので、ほかのマシンに配るスクリプトほど-Eに寄せておきます。自分のマシンでしか使わないワンライナーなら、最初から-Pで書いても困りません。

固定文字列は-F:IPアドレスのドットが起こす誤検出

IPアドレス10.0.0.5に関係する行を探します。正規表現では.が「任意の1文字」なので、そのまま渡すと余計な行まで拾います。

terminal
grep -c '10.0.0.5' logs/app.log
9
grep -cF '10.0.0.5' logs/app.log
7
grep -cFw '10.0.0.5' logs/app.log
4

9、7、4と、3通りの件数になりました。差の中身は、-v(一致しない行を出す)を後ろにつなぐと取り出せます。

terminal
grep '10.0.0.5' logs/app.log | grep -vF '10.0.0.5'
2026-10-01 09:00:15 INFO  health check ok node=ip-10-0-0-5.internal
2026-10-01 09:02:44 INFO  request id=10203045 GET /orders 200 95ms
grep -F '10.0.0.5' logs/app.log | grep -vFw '10.0.0.5'
2026-10-01 09:03:08 WARN  failover to replica host=10.0.0.50
2026-10-01 09:03:09 INFO  db pool ready size=10 host=10.0.0.50
2026-10-01 09:04:20 INFO  login user="tanaka" from="10.0.0.51" result="ok"

最初の2行は、.が-や数字に一致した誤検出です。ip-10-0-0-5というホスト名と、リクエストIDの10203045(10・任意・0・任意・0・任意・5の並び)が引っかかっています。-Fはパターンを正規表現として解釈せず、書いた文字列そのものを探すので、この2行は消えます。

-Fでも残った3行は10.0.0.50と10.0.0.51で、文字列としては確かに10.0.0.5を含んでいます。こちらは-wで「前後が英数字やアンダースコアでないこと」を条件に足して落とします。ドットをバックスラッシュでエスケープする書き方でも-Fと同じ7になりますが、クォートを忘れると9に戻ります。前の節で見たとおり、シェルがバックスラッシュを取り除くからです。

terminal
grep -c '10\.0\.0\.5' logs/app.log
7
grep -c 10\.0\.0\.5 logs/app.log
9
grep -c '^10\.0\.0\.5 ' logs/access.log
86
needle='10.0.0.5'; grep -cFw -e "$needle" logs/app.log
4

IPアドレス、ファイルのパス、URL、括弧を含むログの断片のように、記号の入った文字列をそのまま探すときは-Fを既定にします。シェル変数の中身を探すなら、最後の例のように-F -e "$変数"の形にしておくと、中身に.や*があっても文字列として扱われます。-Fが使えないのは、行頭の^や行末の$と組み合わせたいときです。その場合は3つ目の例のように、エスケープした正規表現で書きます。アクセスログの先頭の列が10.0.0.5である行は86行でした。

複数条件(OR・AND)と除外(NOT)の書き方

ORは、-eを並べる書き方と、-Eの|の2通りです。

terminal
grep -c -e timeout -e refused logs/app.log
3
grep -cE 'timeout|refused' logs/app.log
3

ANDは、GNU grep のマニュアルでもgrepを2回通す形で案内されています。条件の順序を気にせずに済む書き方です。

terminal
grep ERROR logs/app.log | grep 'host='
2026-10-01 09:03:05 ERROR db connection timeout after 3000ms host=10.0.0.5
2026-10-01 09:03:06 ERROR retry 1/3 failed: connection refused host=10.0.0.5
grep -cE 'ERROR.*host=' logs/app.log
2
grep -cP '^(?=.*host=)(?=.*ERROR)' logs/app.log
2

'ERROR.*host='は「ERRORが先、host=が後」の行にしか一致しません。順序が決まっていないときは、パイプでつなぐか、-Pの先読み(?=...)を2つ並べます。このログではどれも2行でした。パイプでつなぐ場合、1段目に-nを付けると行番号も2段目の検索対象の文字列に入るので、行番号は最後のgrepにだけ付けます。

除外は-vです。絞り込んだ結果から特定の語を含む行を落とすときは、パイプの後ろに置きます。

terminal
grep ERROR logs/app.log | grep -v timeout
2026-10-01 09:03:06 ERROR retry 1/3 failed: connection refused host=10.0.0.5
2026-10-01 09:10:31 ERROR unhandled exception in OrderService.create
2026-10-01 09:15:00 ERROR batch failed: last message was "ERROR: disk quota exceeded"
grep -v -e INFO -e WARN logs/app.log
2026-10-01 09:03:05 ERROR db connection timeout after 3000ms host=10.0.0.5
2026-10-01 09:03:06 ERROR retry 1/3 failed: connection refused host=10.0.0.5
2026-10-01 09:07:41 ERROR upstream timeout after 5000ms path=/api/stock
2026-10-01 09:10:31 ERROR unhandled exception in OrderService.create
java.lang.IllegalStateException: stock is negative: item=A-1042
    at shop.OrderService.create(OrderService.java:88)
    at shop.OrderController.post(OrderController.java:41)
Caused by: java.sql.SQLException: deadlock detected
2026-10-01 09:15:00 ERROR batch failed: last message was "ERROR: disk quota exceeded"

-vに-eを2つ並べると「INFOにもWARNにも一致しない行」になります。出力にはERRORの5行のほかに、レベルの付いていないスタックトレースの4行が残りました。「ERRORを含む行」と「INFOでもWARNでもない行」は同じではなく、後者にはgrep ERRORで落ちてしまう行が含まれます。

psの出力をgrepで絞るとgrep自身が結果に混ざる、という話もここに入ります。バックグラウンドでsleep 600を動かして探します。

terminal
sleep 600 > /dev/null 2>&1 &
ps -eo pid,args | grep 'sleep 600'
   4050 sleep 600
   4051 sh -lc { ps -eo pid,args | grep 'sleep 600' } 2>&1
   4061 grep sleep 600
ps -eo pid,args | grep '[s]leep 600'
   4050 sleep 600
pgrep -a sleep
7 sleep infinity
4050 sleep 600

1つ目の結果には、目的のsleep 600のほかに2行出ています。grep sleep 600はgrep自身で、コマンドラインに検索語を含むので一致します。sh -lc …の行は、この記事の記録のためにdocker execが起動したシェルで、コマンド全体を引数として受け取っているため、同じ理由で一致しました。コマンドをbashの標準入力から渡して、引数に検索語が載らない条件で試すと、この行はなくsleepとgrepの2行でした。対話シェルで打ったときに見えるのはこちらです。PIDは実行のたびに変わります。また、対話シェルで&を付けて起動すると、bashはジョブ番号とPIDの行も表示します。

'[s]leep 600'は、文字クラス[s]がsの1文字に一致するので、探す対象はsleep 600のままです。一方、grepのコマンドラインに載っているのは[s]leep 600という文字列で、これはsleep 600を含みません。grep自身も記録用のシェルも結果から消えるのはそのためです。| grep -v grepを足す方法は、コマンドラインにgrepという文字列を含む別のプロセスまで消すので、筆者は使いません。プロセス名で足りるならpgrep -aのほうが短く、この出力にもpgrep自身は出ていません。sleep infinityは、このコンテナを起動したときのコマンドです。

再帰検索:-rと-Rの違い、--includeと--exclude-dir

ディレクトリ以下をまとめて探すのが-rです。設定キーのdb_timeoutがどこに書かれているかを調べます。grep -rの出力は名前順に並ぶとは限らないので、比べやすいようsortに通しています(並び順は、このコンテナのPOSIXロケールでのものです)。

terminal
grep -rn db_timeout . | sort
./conf/app.conf.bak:2:db_timeout=1000
./conf/app.conf:3:db_timeout=3000
./conf/app.conf:4:#db_timeout=1000
./releases/v1/app.conf:2:db_timeout=1000
./releases/v2/app.conf:2:db_timeout=3000
grep -Rn db_timeout . | sort
./conf/app.conf.bak:2:db_timeout=1000
./conf/app.conf:3:db_timeout=3000
./conf/app.conf:4:#db_timeout=1000
./current/app.conf:2:db_timeout=3000
./releases/v1/app.conf:2:db_timeout=1000
./releases/v2/app.conf:2:db_timeout=3000
grep -rn db_timeout current
current/app.conf:2:db_timeout=3000

-rは5行、-Rは6行です。増えた1行は./current/app.confです。currentはreleases/v2を指すシンボリックリンクなので、./releases/v2/app.confと同じファイルを二重に数えています。GNU grep のマニュアルによると、-rは再帰の途中で見つけたシンボリックリンクをたどらず、コマンドラインに書いたものだけをたどります。3つ目のコマンドでcurrentを引数にすると結果が出るのはそのためです。

件数を数える用途では-rを使い、リンクの先も見たいディレクトリは引数に明示するのが、二重計上を避ける書き方です。-Rを選ぶのは、設定の実体がリンクの先に置かれていて、置き場所を問わず拾いたいときです。

対象を絞るオプションは、ファイル名で選ぶ--include・--excludeと、ディレクトリごと外す--exclude-dirです。

terminal
grep -rn --include='*.conf' db_timeout . | sort
./conf/app.conf:3:db_timeout=3000
./conf/app.conf:4:#db_timeout=1000
./releases/v1/app.conf:2:db_timeout=1000
./releases/v2/app.conf:2:db_timeout=3000
grep -rn --exclude-dir=releases db_timeout . | sort
./conf/app.conf.bak:2:db_timeout=1000
./conf/app.conf:3:db_timeout=3000
./conf/app.conf:4:#db_timeout=1000
grep -rn --exclude-dir=releases/v1 db_timeout . | sort
./conf/app.conf.bak:2:db_timeout=1000
./conf/app.conf:3:db_timeout=3000
./conf/app.conf:4:#db_timeout=1000
./releases/v1/app.conf:2:db_timeout=1000
./releases/v2/app.conf:2:db_timeout=3000
grep -rn --exclude-dir=v1 db_timeout . | sort
./conf/app.conf.bak:2:db_timeout=1000
./conf/app.conf:3:db_timeout=3000
./conf/app.conf:4:#db_timeout=1000
./releases/v2/app.conf:2:db_timeout=3000
grep -rn db_timeout conf releases/v2 | sort
conf/app.conf.bak:2:db_timeout=1000
conf/app.conf:3:db_timeout=3000
conf/app.conf:4:#db_timeout=1000
releases/v2/app.conf:2:db_timeout=3000

--include='*.conf'でapp.conf.bakが外れました。*をシェルに展開させないよう、ここもクォートします。--exclude-dir=releasesはreleases以下を丸ごと飛ばします。

3つ目が落とし穴です。--exclude-dir=releases/v1とパスで書いても何も除外されず、5行のままでした。マニュアルの説明では、再帰の途中で比べられるのはディレクトリのベース名(最後の/より後ろ)です。v1と名前だけを書けば効きますが、今度は別の場所にあるv1という名前のディレクトリもまとめて外れます。特定の1か所だけを外したいなら、最後の例のように、探すディレクトリのほうを引数に並べます。

ログ調査:-A・-B・-Cで前後の行を読む

エラーの行だけを見ても原因がわからないのは、原因が前後の行に書かれているからです。-A 数は一致した行の後ろ(After)、-B 数は前(Before)、-C 数は前後の両方を、指定した行数だけ一緒に出します。

terminal
grep -n -A 4 'unhandled exception' logs/app.log
15:2026-10-01 09:10:31 ERROR unhandled exception in OrderService.create
16-java.lang.IllegalStateException: stock is negative: item=A-1042
17-    at shop.OrderService.create(OrderService.java:88)
18-    at shop.OrderController.post(OrderController.java:41)
19-Caused by: java.sql.SQLException: deadlock detected
grep -n -B 2 'failover' logs/app.log
6-2026-10-01 09:03:05 ERROR db connection timeout after 3000ms host=10.0.0.5
7-2026-10-01 09:03:06 ERROR retry 1/3 failed: connection refused host=10.0.0.5
8:2026-10-01 09:03:08 WARN  failover to replica host=10.0.0.50

-nを付けると、一致した行は行番号の後ろが:、前後の行は-になります。unhandled exceptionの後ろ4行はスタックトレースで、ERRORという語を含まないため、grep ERRORだけでは出てきません。原因のdeadlock detectedは19行目にありました。-B 2のほうは、フェイルオーバーの直前にタイムアウトと接続拒否が続いていたことを示しています。

terminal
grep -n -C 1 ERROR logs/app.log
5-2026-10-01 09:02:44 INFO  request id=10203045 GET /orders 200 95ms
6:2026-10-01 09:03:05 ERROR db connection timeout after 3000ms host=10.0.0.5
7:2026-10-01 09:03:06 ERROR retry 1/3 failed: connection refused host=10.0.0.5
8-2026-10-01 09:03:08 WARN  failover to replica host=10.0.0.50
--
12-2026-10-01 09:06:30 INFO  request id=10203046 GET /orders 200 88ms
13:2026-10-01 09:07:41 ERROR upstream timeout after 5000ms path=/api/stock
14-2026-10-01 09:08:02 INFO  cache refreshed keys=1532 in 41ms
15:2026-10-01 09:10:31 ERROR unhandled exception in OrderService.create
16-java.lang.IllegalStateException: stock is negative: item=A-1042
--
22-2026-10-01 09:13:27 WARN  disk usage 91% on /var/lib/app
23:2026-10-01 09:15:00 ERROR batch failed: last message was "ERROR: disk quota exceeded"
24-2026-10-01 09:15:01 INFO  batch retry scheduled in 300s

一致が離れた場所にあると、かたまりの間に--の行が入ります。6行目と7行目のように隣り合った一致や、13行目と15行目のように前後の範囲が重なる一致は、1つのかたまりにまとめられます。行数の決め方に正解はありませんが、筆者はまず-Cで前後を数行ずつ眺め、スタックトレースのように後ろが長いログでは-Aだけを大きくします。HTTPの応答から入ってサーバー側のログにたどり着くまでの切り分けは、curlコマンドの使い方に書きました。

件数を数える:-cと、-oをsort・uniqにつなぐ集計

-cが数えるのは一致した「行」で、一致した「回数」ではありません。回数が必要なら、-oで一致部分を1つずつ別の行に出してからwc -lで数えます。

terminal
grep -c ERROR logs/app.log
5
grep -o ERROR logs/app.log | wc -l
6
grep -c ERROR logs/*.log
logs/access.log:0
logs/app.log:5
cat logs/*.log | grep -c ERROR
5

5と6の差は、batch failed: last message was "ERROR: disk quota exceeded"の行にERRORが2回あるためです。複数のファイルを渡すと-cはファイルごとの件数を出し、0のファイルも省きません。合計だけが欲しいときは、最後の例のようにcatでつないでから数えます。

-oの出力をsort | uniq -c | sort -rnにつなぐと、値ごとの出現回数の表になります。uniq -cは隣り合った同じ行をまとめて数えるので、先にsortで並べておきます。

terminal
grep -oE 'host=[0-9.]+' logs/app.log | sort | uniq -c | sort -rn
      4 host=10.0.0.5
      2 host=10.0.0.50
grep -oP 'host=\K[0-9.]+' logs/app.log | sort | uniq -c | sort -rn
      4 10.0.0.5
      2 10.0.0.50

host=ごと出したのが-E、値だけにしたのが-Pの\Kです。\Kは「ここまでを一致結果から捨てる」という指定で、-oと組み合わせると目印の後ろだけを抜き出せます。アクセスログでは、5xxを返した行を先に絞ってから、パスごとに数えます。

terminal
grep -cE '" 5[0-9]{2} ' logs/access.log
54
grep -E '" 5[0-9]{2} ' logs/access.log | grep -oE '"GET [^ ]+' | sort | uniq -c | sort -rn
     12 "GET /health
     12 "GET /
     10 "GET /orders
     10 "GET /login
     10 "GET /api/stock

5xxは54行で、パス別では/healthと/が12、残りが10ずつでした。1段目の'" 5[0-9]{2} 'は、リクエスト行を閉じる"と前後の空白を目印にして、バイト数や応答時間の列にある数字を拾わないようにしています。

終了コード0・1・2と-q:set -eで黙って止まる罠

grepは結果を終了コードでも返します。一致する行があれば0、なければ1、ファイルが開けないなどのエラーは2です。-qは何も出力せずに終了コードだけを返すので、シェルスクリプトの条件分岐に使います。

terminal(一致しない場合とエラーを含む)
grep -q ERROR logs/app.log; echo "exit=$?"
exit=0
grep -q FATAL logs/app.log; echo "exit=$?"
exit=1
grep -q ERROR logs/nope.log; echo "exit=$?"
grep: logs/nope.log: No such file or directory
exit=2
grep -q ERROR logs/nope.log logs/app.log; echo "exit=$?"
grep: logs/nope.log: No such file or directory
exit=0
grep -c FATAL logs/app.log; echo "exit=$?"
0
exit=1

4つ目は、存在しないファイルと一致するファイルを一緒に渡した例です。エラーメッセージは出ていますが、終了コードは0でした。-qで一致が見つかった場合はエラーがあっても0を返す、とマニュアルにもPOSIXにも書かれています。

最後の例は-cで、0を表示したうえで終了コード1を返します。「0件」はgrepにとって「一致なし」で、成功(0)ではありません。set -e(コマンドが失敗したらスクリプトを止める設定)を入れたスクリプトでは、これがそのまま停止の原因になります。

terminal(失敗する例)
cat > check-fatal.sh <<'EOF'
#!/bin/bash
set -e
count=$(grep -c FATAL logs/app.log)
echo "FATAL lines: $count"
EOF
bash check-fatal.sh; echo "exit=$?"
exit=1
bash -x check-fatal.sh; echo "exit=$?"
+ set -e
++ grep -c FATAL logs/app.log
+ count=0
exit=1

FATALの行がない正常なログなのに、スクリプトは何も表示せず終了コード1で終わりました。bash -xで追うと、count=0の代入までは進み、その直後で止まっています。コマンド置換の終了コードが代入文の終了コードになり、set -eがそれを失敗と見なすためです。

直し方としてよく見る|| trueは、ファイルがないときの終了コード2まで成功に変えてしまいます。存在しないログを指定して試すと、grepのエラーメッセージが標準エラー出力に流れるだけで、スクリプトは件数が空のまま終了コード0で終わりました。許すのは終了コード1だけにします。

terminal
cat > check-fatal-fixed.sh <<'EOF'
#!/bin/bash
set -e
log=${1:-logs/app.log}
count=$(grep -c FATAL "$log" || [ $? -eq 1 ])
echo "FATAL lines: $count"
if grep -q ERROR "$log"; then
  echo "ERROR found"
fi
EOF
bash check-fatal-fixed.sh; echo "exit=$?"
FATAL lines: 0
ERROR found
exit=0
bash check-fatal-fixed.sh logs/nope.log; echo "exit=$?"
grep: logs/nope.log: No such file or directory
exit=1

grep -c FATAL "$log" || [ $? -eq 1 ]は、grepが1を返したときだけ[ ]が成功して全体が0になります。2のときは[ ]も失敗するので、set -eが働いて止まります。ログがない場合は、grepのエラーメッセージを出して終了コード1で終わりました。if grep -q …; thenの形は、ifの条件部分がset -eの対象外なので、一致しなくても止まりません。

binary file matches と表示されたとき:-aと-I

data/session.datは、先頭近くにNULバイトがあるファイルです。こうしたファイルで一致が見つかると、grepは行を表示する代わりにメッセージだけを出します(この記事では、先頭近くにNULバイトがあるこのファイルだけを試しました)。

terminal
grep timeout data/session.dat; echo "exit=$?"
grep: data/session.dat: binary file matches
exit=0
grep timeout data/session.dat 2>/dev/null; echo "exit=$?"
exit=0
grep -c timeout data/session.dat
1

一致はしているので終了コードは0、-cも1を返します。メッセージは標準エラー出力に出るので、2>/dev/nullを付けると何も表示されないまま「一致あり」になります。GNU grep のマニュアルによると、バイナリと判定されるのは、NULバイトがある場合と、現在のロケールの文字コードとして正しくないバイトがある場合です。

terminal(一致しない場合を含む)
grep -a timeout data/session.dat | cat -v
SESS^@^A^B user=tanaka idle_timeout=900
grep -I timeout data/session.dat; echo "exit=$?"
exit=1

-aはバイナリをテキストとして扱い、行をそのまま出します。制御文字がそのまま端末に流れないよう、cat -vを通して^@のような見える形にしています。-Iは逆に、バイナリを「一致なし」として扱います。ログの途中に壊れたバイトが混ざっていて中身を読みたいなら-a、-rで広く探していて画像やアーカイブを無視したいなら-I、という使い分けです。

ロケールで結果が変わる:日本語の検索と-i

grepが「1文字」をどこで区切るかは、ロケール(LC_ALL・LC_CTYPE・LANG)で決まります。このコンテナは冒頭のlocaleの出力のとおり POSIX(C)ロケールです。LC_ALLを3通りに切り替えて、UTF-8で書かれた日本語のログdata/batch.logを検索します。このコンテナの対話シェルで日本語を含むコマンドを打つと、コマンドラインの日本語は\343\202\250のような8進数の並びで表示されます。POSIXロケールのbashがそう表示しているだけで、検索の結果は変わりませんでした。

terminal
for l in C C.UTF-8 ja_JP.UTF-8; do printf '%s ' "$l"; LC_ALL=$l grep -c 'エラー発生' data/batch.log; done
C 2
C.UTF-8 2
ja_JP.UTF-8 2
for l in C C.UTF-8 ja_JP.UTF-8; do printf '%s ' "$l"; LC_ALL=$l grep -c 'エラ.発生' data/batch.log; done
C 0
C.UTF-8 2
ja_JP.UTF-8 2
for l in C C.UTF-8 ja_JP.UTF-8; do printf '%s ' "$l"; LC_ALL=$l grep -ci 'abc商事' data/batch.log; done
C 1
C.UTF-8 2
ja_JP.UTF-8 2
for l in C C.UTF-8 ja_JP.UTF-8; do printf '%s ' "$l"; LC_ALL=$l grep -ci 'error' logs/app.log; done
C 7
C.UTF-8 7
ja_JP.UTF-8 7
printf 'ー' | wc -c
3

固定の文字列エラー発生は、どのロケールでも2件です。バイト列として同じなら一致するからです。差が出るのは.を入れたときで、Cロケールでは0件になりました。UTF-8のーは3バイトあり、Cロケールの.はそのうちの1つ分にしか一致しません。-iも同じで、全角のabcとABCを同じ文字と見なしたのはUTF-8のロケールだけでした。半角のerrorは、どのロケールでも7で変わりません。

ロケール名を間違えても、生成されていないロケールを指定しても、grepは何も言いません。

terminal(一致しない例)
LC_ALL=xx_XX.UTF-8 grep -c 'エラ.発生' data/batch.log; echo "exit=$?"
0
exit=1

存在しないxx_XX.UTF-8を指定した結果は0件で、Cロケールのときと同じでした。locale -aに出てこない名前は効いていない、と考えてください。cronから動かすスクリプトは、対話シェルとは環境変数が違います(PATHが変わる例はcrontabの書き方と動かない原因の調べ方で再現しています)。ロケールがどう設定されるかも環境によるので、日本語を含むパターンに.や-iを使うスクリプトでは、grepの前にLC_ALLを明示します。

文字コードが違うファイルは、ロケールを合わせても一致しません。data/batch-sjis.logは、同じ内容をShift_JISにしたものです。

terminal(一致しない例を含む)
LC_ALL=C.UTF-8 grep -c 'エラー' data/batch-sjis.log; echo "exit=$?"
0
exit=1
LC_ALL=C.UTF-8 grep E102 data/batch-sjis.log
grep: data/batch-sjis.log: binary file matches
iconv -f SHIFT_JIS -t UTF-8 data/batch-sjis.log | LC_ALL=C.UTF-8 grep 'エラ.発生'
2026-10-01 02:00:09 エラー発生 コード=E102 顧客=ABC商事
2026-10-01 02:00:15 エラー発生 コード=E205 顧客=abc商事

UTF-8で書いたエラーは、Shift_JISのバイト列の中には現れないので0件です。エラーも警告も出ません。ASCIIだけのE102で探すと一致はしますが、UTF-8として正しくないバイトを含むためバイナリ扱いになり、行は表示されません。iconvでUTF-8に変換してからgrepに渡せば、ほかのファイルと同じように検索できます。古いシステムが出力したCSVやログで「あるはずの文字列が0件」になったら、最初に文字コードを確かめます。

圧縮されたログはzgrep、tail -fの後ろでは--line-buffered

ローテーションで.gzになったログをgrepに渡しても、読むのは圧縮されたバイト列なので一致しません。

terminal(一致しない場合とエラーを含む)
grep -c ERROR logs/app.log.1.gz; echo "exit=$?"
0
exit=1
zgrep --version | head -n 1; head -n 3 /usr/bin/zgrep
zgrep (gzip) 1.14
#!/bin/sh

# zgrep -- a wrapper around a grep program that decompresses files as needed
zgrep -c ERROR logs/app.log.1.gz
5
zgrep -c ERROR logs/app.log*
logs/app.log:5
logs/app.log.1.gz:5
zgrep -r ERROR logs; echo "exit=$?"
/usr/bin/zgrep: -r: option not supported
exit=2
find logs -name '*.gz' -exec zgrep -cH ERROR {} +
logs/app.log.1.gz:5

grepは0と終了コード1を返すだけで、圧縮ファイルだとは教えてくれません。zgrepはgzipに付属するシェルスクリプトで、ファイルを展開しながらgrepに渡します。圧縮されていないファイルも一緒に渡せるので、logs/app.log*のように現行のログと過去の分をまとめて指定できます。ただし-rは受け付けません。ディレクトリ以下の.gzをまとめて調べるときは、最後の例のようにfindで列挙して渡します。

tail -fでログを追いながらgrepで絞り、さらに後ろへ渡すと、表示が止まったように見えることがあります。マニュアルの説明では、grepの標準出力は対話的なデバイス(端末)なら行単位、それ以外ではバッファがいっぱいになったときに書き出されます。この記事の記録は端末なしなので、grepの出力をファイルに落とし、timeout 3でパイプラインを打ち切った時点で何行書かれていたかを比べます。tail -fが最初に出すファイル末尾の10行には、ERRORが1行あります。

terminal
tail -n 10 logs/app.log | grep -c ERROR
1
timeout 3 sh -c 'tail -f logs/app.log | grep ERROR > /tmp/hits.txt'; wc -l < /tmp/hits.txt
0
timeout 3 sh -c 'tail -f logs/app.log | grep --line-buffered ERROR > /tmp/hits.txt'; wc -l < /tmp/hits.txt
1

--line-bufferedなしでは、grepは一致した行をバッファに持ったまま打ち切られ、ファイルは空でした。付けると1行ごとに書き出すので、1行が残っています。対話シェルでtail -f app.log | grep ERRORとだけ打つ場合は、grepの出力先が端末なので、このオプションは要りません。必要になるのは、grepの後ろに| cutや| awk、> ファイルが続くときです。

grep・ripgrep・awkの使い分け

ripgrep(コマンド名はrg)は、ディレクトリを再帰的に探すことを前提にした検索コマンドです。同じツリーでgrepと比べる前に、Gitリポジトリにして.gitignoreを置きます。

terminal
git init -q . && printf '*.bak\nlogs/\n' > .gitignore
grep -rli timeout . | sort
./conf/.env
./conf/app.conf
./conf/app.conf.bak
./conf/nginx.conf
./data/session.dat
./logs/app.log
./releases/v1/app.conf
./releases/v2/app.conf
rg -li timeout | sort
conf/app.conf
conf/nginx.conf
releases/v1/app.conf
releases/v2/app.conf
rg -li timeout -uuu | sort
conf/.env
conf/app.conf
conf/app.conf.bak
conf/nginx.conf
data/session.dat
logs/app.log
releases/v1/app.conf
releases/v2/app.conf

timeoutを大文字小文字を区別せずに含むファイルは、grep -rでは8つ、ripgrep 15.1.0 の既定では4つでした。外された4つの理由は、--debugの出力で確かめられます。

terminal
rg --debug -li timeout 2>&1 | grep -o 'ignoring .*' | cut -c1-60 | sort
ignoring ./.git: Ignore(IgnoreMatch(Hidden))
ignoring ./.gitignore: Ignore(IgnoreMatch(Hidden))
ignoring ./conf/.env: Ignore(IgnoreMatch(Hidden))
ignoring ./conf/app.conf.bak: Ignore(IgnoreMatch(Gitignore(G
ignoring ./logs: Ignore(IgnoreMatch(Gitignore(Glob { from: S
ignoring data/session.dat: found binary data at offset 4

conf/.envは隠しファイル、conf/app.conf.bakとlogsは.gitignore、data/session.datはバイナリという理由で外れています。-uuuを付けるとこの3種類の除外がすべて外れ、grep -rと同じ8ファイルになりました。コードを探すときはこの既定がちょうどよく、依存ライブラリやビルド成果物のディレクトリが.gitignoreに書いてあれば、自分で--exclude-dirに並べる手間がなくなります。

rgで「見つからなかった」ことを根拠にするときは、rg -uuuかgrep -rで確かめ直してください。今回のツリーでは、既定のrgは一致する8ファイルのうち4ファイルを検索の対象から外しました。置き忘れたパスワードを探す監査のような用途では、.envを見ないまま0件と答えることになります。

正規表現と出力の既定も違います。

terminal(一致しない場合を含む)
rg -c '\d+ms' logs/app.log; rg -c 'timeout|refused' logs/app.log
7
3
rg -c FATAL logs/app.log; echo "exit=$?"
exit=1
LC_ALL=C rg -c 'エラ.発生' data/batch.log
2

\dや|はオプションなしで使え、grep -P・grep -Eのときと同じ7と3が返りました。一致が0件のときの-cは、0を出さずに何も表示しません(終了コードはgrepと同じ1)。ロケールがCでもエラ.発生は2件でした。スクリプトの中のgrepをrgに置き換えるだけでは同じ動きにならない、ということです。

列の値で条件を付けるならawkの領分です。アクセスログの9列目(ステータスコード)が500以上で、11列目(応答時間)が1000以上の行を数えます。

terminal
grep -cE '" 5[0-9]{2} [0-9]+ [0-9]{4,}$' logs/access.log
24
awk '$9 >= 500 && $11 >= 1000' logs/access.log | wc -l
24
awk '$9 >= 500 && $11 >= 1500 { n++ } END { print n }' logs/access.log
10

grepでも「5で始まる3桁」「4桁以上の数字」と桁数に読み替えれば24行と数えられますが、しきい値を1500に変えると正規表現では書きにくくなります。awkは$11 >= 1500と数値のまま比べられ、10行でした。

場面選ぶもの理由(この記事の実行結果)
サーバーに入ってログを数ファイル調べるgrep(-E・-F)追加のインストールが要らない。今回のイメージにも最初から入っていた
シェルスクリプトの条件分岐grep -qと終了コードPOSIXにあるオプションだけで書ける。0件は終了コード1
Gitリポジトリのコードを横断して探すripgrep.gitignore・隠しファイル・バイナリを既定で外し、\dや|がそのまま通る
漏れがあっては困る検索(監査、設定の洗い出し)grep -rかrg -uuu既定のrgは8ファイル中4ファイルを見なかった
列の数値で絞るawk$9 >= 500 && $11 >= 1500と数値のまま比べられる
.gzになった過去のログzgrepgrepは0を返すだけで、圧縮に気づけない
WindowsのPowerShellSelect-String次の段落を参照(この環境では未実行)

WindowsのPowerShellでは、Select-String(エイリアスはsls)がgrepに当たります。検証環境はLinuxのコンテナだけなので、次のブロックは実行していません。Microsoft Learn のリファレンスに基づく対応関係で、執筆時点の内容です。

PowerShell(Windows・未実行)
Select-String -Path .\logs\app.log -Pattern 'ERROR' -CaseSensitive
Select-String -Path .\logs\app.log -Pattern '10.0.0.5' -SimpleMatch
Select-String -Path .\logs\app.log -Pattern 'INFO' -NotMatch
Select-String -Path .\logs\app.log -Pattern 'timeout' -Context 2,3
Get-ChildItem -Path . -Recurse -Filter *.conf | Select-String -Pattern 'db_timeout'

リファレンスによると、Select-Stringは既定で大文字小文字を区別しないので、grepと同じ結果にしたいときは-CaseSensitiveを付けます。パターンは正規表現として扱われ、-SimpleMatchがgrepの-F、-NotMatchが-v、-Context 2,3が-B 2 -A 3に当たります。サブディレクトリまで探す例は、Get-ChildItem -Recurseの結果をパイプで渡す形で載っています。

つまずきポイント

「grep: Unmatched ( or \(」と出る

-Eを付けたまま、(を含む文字列を探したときのエラーです。

terminal(エラー)
grep -cE 'OrderService.create(' logs/app.log; echo "exit=$?"
grep: Unmatched ( or \(
exit=2
grep -cF 'OrderService.create(' logs/app.log
1
grep -cF '[01/Oct/2026:09:00:0' logs/access.log
10

EREでは(がグループの開始なので、閉じる側がないと正規表現として成立せず、終了コード2で終わります。文字列として探すなら-Fに替えます。3つ目の例のように、[で始まるアクセスログの日時も、-Fならそのまま渡せます。

「grep: logs: Is a directory」と出る

terminal(エラー)
grep ERROR logs; echo "exit=$?"
grep: logs: Is a directory
exit=2
grep -rc ERROR logs | sort
logs/access.log:0
logs/app.log.1.gz:0
logs/app.log:5

ディレクトリを渡すときは-rが要ります。-rなしではディレクトリそのものを読もうとして、終了コード2のエラーになります。-rcの結果を見ると、.gzのファイルも検索の対象になり、0として並んでいます。直下の.logだけでよければ、logs/*.logとシェルに展開させます。

「grep: Invalid collation character」と出る

terminal(エラー)
LC_ALL=C.UTF-8 grep -c '[ァ-ヶ]' data/batch.log; echo "exit=$?"
grep: Invalid collation character
exit=2
LC_ALL=ja_JP.UTF-8 grep -c '[ァ-ヶ]' data/batch.log; LC_ALL=C.UTF-8 grep -cP '[ァ-ヶ]' data/batch.log
4
4
LC_ALL=C grep -c '[ァ-ヶ]' data/batch.log
5

カタカナを表す[ァ-ヶ]のように、日本語の文字で範囲を書いたパターンは、LC_ALL=C.UTF-8ではエラーになりました(GNU grep 3.12、glibc 2.43)。マニュアルによると、範囲指定の解釈に関わるのはロケールのLC_COLLATEです。同じパターンがja_JP.UTF-8では通り、カタカナを含む4行を数えます。C.UTF-8のままにするなら、-Pに替えると同じ4が返りました。Cロケールではエラーにならない代わりに5と数えていて、カタカナのない警告 再試行しますの行まで一致しています。手元のja_JP.UTF-8で動いた範囲指定が、ロケールがC.UTF-8のコンテナやジョブで止まったら、このメッセージを探してください。

動作確認

ここまでの手順を順に実行した状態で、次の出力になれば、テスト用のツリーとgrepの挙動がこの記事と一致しています。値が違うときは、grep --versionとlocaleの出力、ファイルを作ったコマンドを見直します。

terminal
grep -c '' logs/app.log logs/access.log data/batch.log
logs/app.log:27
logs/access.log:300
data/batch.log:5
grep -c ERROR logs/app.log; grep -cF '10.0.0.5' logs/app.log; grep -cFw '10.0.0.5' logs/app.log
5
7
4
grep -rl --include='*.conf' db_timeout . | sort
./conf/app.conf
./releases/v1/app.conf
./releases/v2/app.conf
zgrep -c ERROR logs/app.log*
logs/app.log:5
logs/app.log.1.gz:5
bash check-fatal-fixed.sh; echo "exit=$?"
FATAL lines: 0
ERROR found
exit=0

検証が終わったら、Dockerホストでdocker rm -fv grep-command-usage-regex-labを実行してコンテナを消します。

よくある質問

grepで複数条件(AND・OR)を指定するには?

ORは-eを並べるか、-E 'A|B'と書きます。ANDはgrep A ファイル | grep Bとパイプでつなぐと、AとBの順序を問わずに絞れます。1回で書くなら-E 'A.*B'(Aが先に出る行だけ)か、-P '^(?=.*A)(?=.*B)'です。今回のログでは、ERRORかつhost=の行はどの書き方でも2行でした。

grepで特定の文字列を含む行を除外するには?

-vを付けると、パターンに一致しなかった行だけが出ます。grep ERROR app.log | grep -v timeoutのように、絞り込みの後ろに置く使い方が基本です。-v -e A -e Bは「AにもBにも一致しない行」になります。ファイル単位で外すなら--exclude、ディレクトリごとなら--exclude-dirを使います。

WindowsのPowerShellでgrepに当たるコマンドは?

Select-String(エイリアスはsls)です。Microsoft Learn の説明では、既定で大文字小文字を区別せず、パターンは正規表現として扱われます。固定文字列は-SimpleMatch、除外は-NotMatch、前後の行は-Contextで指定します(執筆時点)。この記事の環境では実行していません。

grepとは何の略ですか?

行エディタの ed で、一致した行を画面に出すときの書き方g/re/p(global / regular expression / print)に由来すると、GNU grep のマニュアルに書かれています。同じ箇所に、-Eは Extended、-Fは Fixed を表すという説明もあります。

まとめ

grepの結果がずれる原因は、シェルの展開、正規表現の方言、ロケールと文字コード、終了コードの4か所に集まっています。パターンはシングルクォートで囲み、記号の入った文字列は-F、正規表現は-Eと[0-9]を基本にして、-Pは\Kや最短一致が要るときだけ使います。件数をスクリプトで受け取る箇所は|| [ $? -eq 1 ]で終了コード1だけを許し、0件とエラーを分けます。「見つからなかった」と報告する前に、対象が.gzでないか、文字コードが合っているか、rgなら除外されたファイルがないかを確かめてください。次の一手は、手元のスクリプトから$(grep -cと|| trueが並んでいる行を探し、この記事のcheck-fatal-fixed.shの形に直せるかを確かめることです。

次に読む

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