こんにちは、おうどんです🍜
ななみさん、昨日のアクセスログ、ステータスコード別に数えておいたよ
ありがとうございます。……いちばん下の『HTTP/1.1″』が3件、というのは何のコードですか?
新しいステータスコードかな。たぶん、うどん屋専用の
(会話と、この記事のログは説明用に作ったものです。IP アドレスは文書用のアドレスを使っています)
うどん屋さんの1日の注文伝票を、束ごと渡されたとします。
「かけ」が何杯出たか、どのお客さんが何回来たか、何時が混んだか。
1枚ずつめくって正の字を書いてもいいけれど、伝票が900枚あったら、日が暮れます。
Web サーバーのアクセスログ(Web サーバーが、受けたリクエストを1行ずつ書き残すファイル)も、この伝票の束と同じです。
そして Linux や Git Bash には、伝票を仕分けてくれる係がそろっています。
- awk:伝票から、決まった欄だけを抜き出す係
- sort:抜き出したものを並べる係
- uniq -c:同じものが続いたら、まとめて数える係
- head:上から何枚かだけ見せて、先に帰る係
この記事では、この仕分けラインで、ステータスコード別・IP 別・時間帯別の件数と、404 が多い URL を数えるコマンドをまとめます。
冒頭の「HTTP/1.1″」が3件、の正体もあとで出てきます。うどん屋専用のコードではありません。
検証は、今回の検証環境(Git Bash の bash 5.2.26、GNU Awk 5.0.0、GNU coreutils 8.32 の sort・uniq)で、説明用に作った combined 形式のログ(898行)を使って実行しました。
まず覚えるのは、この3つだけです
- 数える形は
awk '{print $9}' access.log | sort | uniq -c | sort -rn uniq -cの前には、必ずsortを入れる- 件数で並べるときは
sort -rではなくsort -rn
アクセスログの1行は、空白で区切られた伝票の欄
Apache や nginx の combined 形式(よく使われる標準的なログの書き方)では、1行がこう並んでいます。
head -n 3 access.log
192.0.2.23 - - [30/Oct/2026:09:00:13 +0900] "GET /api/stock HTTP/1.1" 304 - "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36"
192.0.2.12 - - [30/Oct/2026:09:01:33 +0900] "GET /menu/kitsune HTTP/1.1" 200 4300 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36"
192.0.2.23 - - [30/Oct/2026:09:02:13 +0900] "GET /img/udon.png HTTP/1.1" 200 48213 "-" "curl/8.5.0"
Apache のドキュメントでは、combined 形式は "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-agent}i\"" という書式です。nginx の combined も、同じ並びになるように定義されています。
awk は、何も指定しなければ空白(スペースやタブ)の並びで1行を欄に分けて、$1、$2……と番号を振ります。
すると、欄の番号はこうなります。
| awk の欄 | 中身 | 例 | Apache の書式 |
|---|---|---|---|
$1 | クライアントの IP | 192.0.2.23 | %h |
$2 $3 | 識別子・認証ユーザー(ないときは -) | - | %l %u |
$4 | 日時の前半 | [30/Oct/2026:09:00:13 | %t |
$5 | タイムゾーン | +0900] | %t |
$6 | メソッド(" 付き) | "GET | %r |
$7 | URL | /api/stock | %r |
$8 | プロトコル(" 付き) | HTTP/1.1" | %r |
$9 | ステータスコード | 304 | %>s |
$10 | レスポンスのサイズ(本文が0バイトなら -) | - | %b |
$11 以降 | リファラー・ユーザーエージェント | "-" "Mozilla/5.0 … | %{Referer}i など |
日時が2つの欄に分かれてるの、ずるくないですか
ずるくはないんです。日時の途中にスペースが1個あるので、awk は素直に2つに切っています。伝票の「日付」欄が、のりしろで2枚に分かれているだけ。
いちばん大事なのは、ステータスコードは9番目の欄ということ。
アクセスログの集計で $9 がやたら出てくるのは、このためです。
読者「
$9って、9ドルのことかと思ってた」
うどん1杯にしては、ちょっと高い。シェルの $ は、お金ではなく「何番目」の印です。
定番の形は「抜き出す → 並べる → 数える → 多い順」
ステータスコード別の件数は、こう数えます。
awk '{print $9}' access.log | sort | uniq -c | sort -rn
489 200
242 404
93 301
58 304
13 500
3 HTTP/1.1"
仕分けラインで、伝票がこう流れています。
awk '{print $9}':伝票から9番目の欄(ステータスコード)だけを抜き出すsort:同じコードが隣り合うように並べるuniq -c:続いた行をまとめて、何行あったかを左に書くsort -rn:左の件数を数として見て(-n)、多い順に(-r)並べる
4人がかりで伝票1枚の欄を数えてる。人件費がすごい
コマンドの人件費は、ほぼタダです。900行なら一瞬で終わります。
sort が2回出てきます。同じ人ですか
同じ人です。1回目は中身の順、2回目は件数の順に並べ直している。この職場でいちばんの働き者です。
いちばん下の HTTP/1.1" が3件。冒頭でおうどんが「新しいコード」と言い張ったのが、これです。
正体は中級者向けの章で見ます。先に言うと、ステータスコードではなく、欄がずれた行です。
コピペで使える集計コマンド集
同じ形で、抜き出す欄を変えれば、たいていの集計ができます。
IP アドレス別の上位5件
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -n 5
95 203.0.113.50
91 192.0.2.45
80 203.0.113.5
79 192.0.2.10
75 198.51.100.8
時間帯別の件数($4 の14文字目から2文字=時)
awk '{print substr($4, 14, 2)}' access.log | sort | uniq -c
120 09
333 10
305 11
140 12
$4 は [30/Oct/2026:10:25:00 という形なので、[ から数えて14文字目と15文字目が時です。日時の書式がこの形(Apache の %t の既定の形)のときだけ使えます。
404 が多い URL
awk '$9 == 404 {print $7}' access.log | sort | uniq -c | sort -rn
95 /wp-login.php
82 /menu/tempura
65 /favicon.ico
うちのうどん屋に WordPress のログイン画面はないのに、95回も来てる
天ぷらうどんも、メニューにないのに82回頼まれてます
天ぷらは、メニューに足してあげてもいいかもしれない。/wp-login.php のほうは、だいたい足さなくていいやつです。
特定の URL に来ている IP
awk '$7 == "/wp-login.php" {print $1}' access.log | sort | uniq -c
95 203.0.113.50
1つの IP から95回。IP 上位の1位も、この IP でした。
同じ人が同じ伝票を95枚出してきたら、さすがに店長を呼びます。
レスポンスのサイズの合計(- の行は除く)
awk '$10 != "-" {sum += $10} END {print sum}' access.log
6016925
この数字、実はちょっとだけ多すぎます。理由は後ろの章で。
🔰 ここまで読めば今日から困らない
アクセスログは空白で区切られた欄の並びで、ステータスコードは $9。数えるときは「awk で抜き出す → sort → uniq -c → sort -rn」の形にして、抜き出す欄を変えれば IP 別・URL 別・時間帯別も同じ形で数えられます。
ここから先は中級者向け:uniq -c は隣の伝票しか見ていない
sort を入れ忘れると、こうなります。
awk '{print $9}' access.log | uniq -c | head -n 5
1 304
6 200
1 404
1 200
1 404
200 が「6」と「1」に分かれています。uniq は、隣り合った同じ行だけをまとめて数えます。間に 404 が1行はさまると、そこで数え直し。
離れた場所にある同じ値は、別のものとして数えられてしまいます。
読者「じゃあ uniq って、何のためにいるの」
隣どうしを数えるのは得意なので、sort のあとならちゃんと働きます。適材適所です。
隣の人としか話さない、人見知りの数え係
じゃあ、uniq の前に sort で、同じ人どうしを隣に座らせてあげればいい
それが sort | uniq -c の意味です。席替えをしてから、点呼を取る。
sort -r と sort -rn は、並べ方の物差しが違う
uniq -c の代わりに、awk の連想配列(文字列を番号にできる配列)で数える書き方もよく見かけます。
このとき -n を付け忘れると、並びがおかしくなります。
awk '{c[$9]++} END {for (k in c) print c[k], k}' access.log | sort -r
93 301
58 304
489 200
3 HTTP/1.1"
242 404
13 500
awk '{c[$9]++} END {for (k in c) print c[k], k}' access.log | sort -rn
489 200
242 404
93 301
58 304
13 500
3 HTTP/1.1"
-n がないと、sort は「文字」として比べます。1文字目どうしで比べるので、9 で始まる 93 が、4 で始まる 489 より上に来る。-n を付けると、先頭の数を数として比べます。GNU coreutils のマニュアルでは、-n の数は、行の先頭の空白(あってもなくてもよい)・マイナス記号・数字の並び、とされています。
93杯のほうが489杯より多いって、算数としてはアウトですよね
辞書としてはセーフ。辞書は算数をしない
ところで、前の章の uniq -c の出力に sort -r を使っても、今回の検証環境では正しい順に並びました。uniq -c が数の前に空白を入れて右にそろえていたので、たまたま文字の比較でも数の順と一致したからです。
たまたまでも合ってるなら、それは sort の実力では
実力ではなく、uniq -c の右寄せのおかげです。手柄を横取りしない。
たまたまに頼らず、件数で並べるときは -n を付けておきましょう。
sort の欄を指定する -k
欄を指定するときは、-k1,1n のように、終わりの欄まで書きます。-k1 だけだと、1欄目から行末までが比べる範囲になります(GNU coreutils のマニュアル)。
printf '10 b\n9 a\n100 c\n' | sort -k1,1n
9 a
10 b
100 c
printf '10 b\n9 a\n100 c\n' | sort -k1,1
10 b
100 c
9 a
$9 がずれる行は、欄の数え方を変えて数える
冒頭の「HTTP/1.1″」の正体を見ます。ステータスが3桁の数字になっていない行を探します。
awk '$9 !~ /^[0-9][0-9][0-9]$/ {print NR": "$9}' access.log
262: HTTP/1.1"
349: HTTP/1.1"
399: HTTP/1.1"
その行を見てみると、こうなっていました。
grep -n 'udon HTTP' access.log
262:198.51.100.99 - - [30/Oct/2026:10:25:00 +0900] "GET /menu/kake udon HTTP/1.1" 400 226 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
349:198.51.100.99 - - [30/Oct/2026:10:40:00 +0900] "GET /menu/kake udon HTTP/1.1" 400 226 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36"
399:198.51.100.99 - - [30/Oct/2026:10:48:20 +0900] "GET /search?q=kitsune udon HTTP/1.1" 400 226 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36"
リクエスト行("GET … HTTP/1.1" の部分)に空白が1つ多く入っています。
awk から見ると欄が1つ増えるので、HTTP/1.1" が9番目に押し出され、ステータスコードの 400 は10番目に行ってしまいました。
かけうどん、の間のスペースで、伝票の欄が全部1つずつずれた。お客さんが悪い
いや、伝票の欄を左から数えていた、こちらの読み方が甘いんです。お客さんは、かけうどんが食べたかっただけ。
リクエスト行は、クライアントが送ってきたものが記録される欄です(Apache のドキュメントでも、ログにはクライアントから来た情報が入ると注意しています)。
nginx のドキュメントでは、既定のエスケープ(記号を \xXX の形に置き換えること)の対象は "・\・文字コードが32未満と126より大きい文字なので、空白(32)は対象に入っていません。
今回のログは説明用に作ったものですが、リクエスト行の空白の数をあてにしないほうが安全です。
ずれた行は、合計にも混ざります。さっきのサイズの合計がちょっと多かったのは、この3行で $10 に 400(ステータスコード)が入り、226 の代わりに足されていたからです(400×3 − 226×3 = 522)。
522バイトなら、誤差では
誤差でも、人によってレポートの数字が違うと、会議が長くなります。会議の長さは誤差では済みません。
" で区切って数える
リクエスト行は " でくくられているので、awk の区切りを " にすると、リクエスト行の空白に左右されなくなります。
head -n 1 access.log | awk -F'"' '{for (i = 1; i <= NF; i++) print i": ["$i"]"}'
1: [192.0.2.23 - - [30/Oct/2026:09:00:13 +0900] ]
2: [GET /api/stock HTTP/1.1]
3: [ 304 - ]
4: [-]
5: [ ]
6: [Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36]
7: []
" で切ると、2番目がリクエスト行、3番目が「ステータスコードとサイズ」になります。3番目をさらに空白で分ければ、ずれません。
awk -F'"' '{split($3, a, " "); print a[1]}' access.log | sort | uniq -c | sort -rn
489 200
242 404
93 301
58 304
13 500
3 400
awk -F'"' '{split($3, a, " "); if (a[2] != "-") sum += a[2]} END {print sum}' access.log
6016403
「HTTP/1.1″」が消えて、400 が3件になりました。合計も 522 少なくなっています。
伝票の欄を左から数えるのをやめて、「ここからここまで」の仕切りで読むようにした、というわけです。
そのまま使える集計スクリプト
ここまでの集計を1本にまとめました。.gz のログもそのまま読めます。
#!/bin/bash
# combined 形式のアクセスログを集計する
# 使い方: bash log_summary.sh <ログファイル(.gz も可)> [上位の件数]
set -euo pipefail
export LC_ALL=C # sort・uniq の並び順をロケールに左右させない
log=${1:?使い方: bash log_summary.sh <ログファイル> [上位の件数]}
top=${2:-5}
read_log() {
case "$log" in
*.gz) zcat -- "$log" ;;
*) cat -- "$log" ;;
esac
}
# 上位 n 件。head と違って入力を最後まで読むので、pipefail でも SIGPIPE で落ちない
top_n() { awk -v n="$top" 'NR <= n'; }
echo "## 行数"
read_log | wc -l
echo "## ステータスコード別"
read_log | awk -F'"' '{split($3, r, " "); print r[1]}' | sort | uniq -c | sort -rn
echo "## IP 上位 $top"
read_log | awk '{print $1}' | sort | uniq -c | sort -rn | top_n
echo "## 時間帯別(時 件数 5xx)"
read_log | awk -F'"' '{
split($1, l, " "); split($3, r, " ")
h = substr(l[4], 14, 2)
total[h]++
if (r[1] ~ /^5/) err[h]++
}
END { for (h in total) printf "%s %6d %5d\n", h, total[h], err[h] + 0 }' | sort
echo "## 404 の URL 上位 $top"
read_log | awk -F'"' '{split($2, q, " "); split($3, r, " "); if (r[1] == "404") print q[2]}' \
| sort | uniq -c | sort -rn | top_n
echo "## 形式がずれている行(ステータスが3桁の数字でない)"
read_log | awk -F'"' '{split($3, r, " "); if (r[1] !~ /^[0-9][0-9][0-9]$/) {bad++; if (bad <= 3) print " " NR ": " $0}}
END {print bad + 0}'
bash log_summary.sh access.log
## 行数
898
## ステータスコード別
489 200
242 404
93 301
58 304
13 500
3 400
## IP 上位 5
95 203.0.113.50
91 192.0.2.45
80 203.0.113.5
79 192.0.2.10
75 198.51.100.8
## 時間帯別(時 件数 5xx)
09 120 0
10 333 13
11 305 0
12 140 0
## 404 の URL 上位 5
95 /wp-login.php
82 /menu/tempura
65 /favicon.ico
## 形式がずれている行(ステータスが3桁の数字でない)
0
10時台にだけ 500 が13件。
犯人は10時ですね
時刻は犯人ではありません。混んだ時間に何かが詰まった、という手がかりです(説明用のログなので、うどんは実際には茹でていません)。
.gz のまま、上位3件で読むこともできます(出力は先頭9行だけ表示)。
bash log_summary.sh access.log.gz 3 | head -n 9
## 行数
898
## ステータスコード別
489 200
242 404
93 301
58 304
13 500
3 400
ファイルを指定し忘れると、使い方を出して終了コード1で止まります。
bash log_summary.sh; echo "exit=$?"
log_summary.sh: line 7: 1: 使い方: bash log_summary.sh <ログファイル> [上位の件数]
exit=1
引数を忘れたら、注文票に『何をお作りしますか』と書き返してくれる
親切な店員さんです。メッセージの頭の 1: は、1番目の引数がない、という意味です。
集計のチェックリスト
uniq -cの前にsortを入れた- 件数で並べるときは
sort -rn(または-k1,1nr)にした - ステータスが3桁の数字でない行がないか、先に数えた
- 時間帯の切り出しは、ログの日時の書式に合わせた
- サイズの
-(本文なし)を合計から除いた
ここから先は上級者向け。読み飛ばしてもOKです:head は先に帰る
完成スクリプトで、head ではなく awk 'NR <= n' を使ったのには理由があります。set -o pipefail(パイプのどこかが失敗したら、全体を失敗にする設定)と head を組み合わせると、こうなることがあります。
set -o pipefail; seq 1 200000 | sort -n | head -n 1; echo "exit=$?"
1
exit=141
set -o pipefail; seq 1 200000 | sort -n | awk 'NR <= 1'; echo "exit=$?"
1
exit=0
1行目は正しく出ているのに、終了コードが141。head は欲しい行数を出したら先に帰ります。そのあとも書き続けようとした sort が、書き込み先がなくなって SIGPIPE(シグナル13)で止められた、と読める数字です(128+13=141)。pipefail がなければ、パイプ全体の終了コードは最後の head の 0 なので、気づきません。
先に帰った head のせいで、まじめに最後まで並べていた sort が怒られる
職場でもありますね、それ
あるかどうかは、ここでは書きません。
じゃあ今日から head は禁止
禁止まではしなくて大丈夫です。画面で見るぶんには便利。気をつけるのは、pipefail を付けたスクリプトの中だけです。set -e も一緒に使っているスクリプトだと、ここでスクリプトごと止まります。少ない行数では起きないこともあるので、テストでは通って本番の大きなログで止まる、という形になりがちです。
終了コードとパイプの話は、Bashで処理が失敗したのに成功扱い? pipefail・PIPESTATUSで終了コードを確かめるにまとめています。
" で区切るのも万能ではない。ログの書き手ごとのエスケープ
" で区切る方法にも弱点があります。リクエスト行の中に " が入っている場合です。
Apache の mod_log_config のドキュメントでは、2.0.46 から、リクエスト行などの文字列の欄で、表示できない文字は \xhh の形に置き換え、" と \ だけは前にバックスラッシュを付ける、とされています。
つまり Apache のログでは、" は \" として残ります。" で区切ると、そこでも切れてしまいます。
cat quote2.log
192.0.2.10 - - [30/Oct/2026:13:00:00 +0900] "GET /?q=\"kake\" HTTP/1.1" 200 5120 "-" "curl/8.5.0"
awk -F'"' '{split($3, r, " "); print "[" r[1] "]"}' quote2.log
[kake\]
ステータスコード、kake。これこそ、うどん屋専用の……
言わせません。" で切ったら、検索語の kake がステータスコードの席に座っただけです。
awk '{print $9}' quote2.log
200
今度は逆に、空白区切りの $9 のほうが正しく、" 区切りのほうがずれました。
完成スクリプトの最後の「形式がずれている行」は、こういう行を拾うためのものです。
bash log_summary.sh quote2.log | tail -n 3
## 形式がずれている行(ステータスが3桁の数字でない)
1: 192.0.2.10 - - [30/Oct/2026:13:00:00 +0900] "GET /?q=\"kake\" HTTP/1.1" 200 5120 "-" "curl/8.5.0"
1
nginx のドキュメントでは、既定のエスケープで " は \x22 の形になるので、この行は起きにくい書き方です。そのかわり、上で見たとおり空白はそのまま残ります。
どちらの区切り方にも弱点があるので、ずれた行を数えて0件か確かめるのを、集計の一部にしておくのが安全です。
完璧な仕分け係はいない。だから、仕分けたあとに検品係を置く
その検品係が、ずれた行を見落としたら?
検品係がするのは、3桁の数字かどうかを見るだけ。仕事が単純なので、まず見落としません。単純な係ほど信用できる、というのはコマンドのいいところです。
ほかにも、資料で確かめた細かい点をまとめておきます。
| 項目 | 内容 | 根拠 |
|---|---|---|
%>s と %s | 内部リダイレクトがあったとき、%s は元のリクエストの、%>s は最後のステータス。combined は %>s | Apache mod_log_config |
%b と %B | 本文が0バイトのとき、%b は -、%B は 0。どちらもヘッダーを含まない本文の大きさで、実際にネットワークに送ったバイト数ではない | Apache mod_log_config |
awk の for (k in c) | 取り出す順番は決まっていない(POSIX で unspecified)。順番が欲しいなら sort を通す | POSIX awk |
awk の -F | 空白以外の1文字を指定すると、その文字が出てくるたびに区切る("" が並ぶと空の欄ができる) | POSIX awk |
| sort とロケール | ロケール(言語や地域の設定)によって並び順が変わることがある。LC_ALL=C にすると、決まった順になる | GNU coreutils sort |
| sort の最後の比較 | キーが同じ行どうしは、行全体で比べて順番を決める(-s で止められる) | GNU coreutils sort |
最後の行のおかげで、件数が同じ行の並びも毎回同じになります。たまたま同じ件数の IP が並んでも、実行のたびに順番が入れ替わることはありません。
伝票の束は、仕分けラインに流してから読む
冒頭のステータスコードの集計に戻ります。
ななみさん、さっきの『HTTP/1.1″』、うどん屋専用のコードじゃなかった。空白でずれた3行で、正体は 400 だった
ちゃんと原因まで確かめてくれたんですね。それなら、この集計は使えます
じゃあ、うどん屋専用のステータスコードは、まだ空き番ってことだね
空いていても、作らなくていいです。
伝票の束は、1枚ずつめくらなくていい。
抜き出す、並べる、数える、多い順。この4人の仕分けラインに流せば、900枚でも一瞬です。おうどんが正の字を書くより、だいぶ速い。
ただ、仕分け係は、伝票の欄が決まった場所にあると信じて働いています。
数えたら、ずれた行が0件かも確かめる。それだけで、集計の数字を人に渡せるようになります🍜
参考資料
- Apache HTTP Server 2.4:Log Files — Common・Combined 形式の書式と各欄の意味、
%bの-、ログにクライアントの情報がそのまま入ることの注意 - Apache HTTP Server 2.4:mod_log_config —
%>sと%s、%bと%B、%tの形、2.0.46 からのエスケープ(\xhh、"と\はバックスラッシュ) - nginx:Module ngx_http_log_module — combined の定義、既定のエスケープの対象(
"・\・32未満と126より大きい文字を\xXX) - POSIX:awk — 既定の欄の区切り(空白の並び)、1文字の区切り、
-F、for (var in array)の順番は決まっていない - GNU Coreutils:sort invocation —
-n・-r・-k・-t、最後の行全体の比較と-s、ロケールとLC_ALL=C
2026年10月時点の内容です。










コメント