アクセスログをコマンドで集計する方法|ステータスコード別・IP別・時間帯別の件数と404のURLをawk・sort・uniq -cで数える($9がずれる落とし穴も)

大量の小さな紙片を種類ごとに整理し、1枚だけ違う場所に入ったものを見つけて直す場面。ログを分類・集計し、列ずれや異常を確認するイメージ。

こんにちは、おうどんです🍜

おうどん

ななみさん、昨日のアクセスログ、ステータスコード別に数えておいたよ

ななみ

ありがとうございます。……いちばん下の『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クライアントの IP192.0.2.23%h
$2 $3識別子・認証ユーザー(ないときは -)-%l %u
$4日時の前半[30/Oct/2026:09:00:13%t
$5タイムゾーン+0900]%t
$6メソッド(" 付き)"GET%r
$7URL/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"

仕分けラインで、伝票がこう流れています。

  1. awk '{print $9}':伝票から9番目の欄(ステータスコード)だけを抜き出す
  2. sort:同じコードが隣り合うように並べる
  3. uniq -c:続いた行をまとめて、何行あったかを左に書く
  4. 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 は %>sApache 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月時点の内容です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

おうどん|癒しと創作をたのしむ雑食クリエイター
写経アプリやクレイセラピー、優しい和風デザインがすき。
ZARDと刀剣と文字に癒されて、今はアプリ作ってます。

コメント

コメントする

目次