Linux/Apache/アクセスログの数や国を見る

アクセスログの確認につかう、awk、perlのサンプル(2017-02-23、更新:2023-07-09)


いつかは見ようと思ったアクセスログにようやく着手できました。
Apacheのアクセスログと、Analytics の分析結果の違いも気になっていたので、
これを機に見てみました。


日ごとのアクセス件数の確認

まず、前提となる Apache のアクセスログの書式ですが
デフォルトの LogFormat はこうなっていると思います。

"%h %l %u %t \"%r\" %>s(以下省略)"

私の環境では以下のように、自分が加工しやすいように変えていますので
後述のスクリプト内で、
日付(%t)を egrep で拾うところや、
レスポンスコード(%>s)を awkで拾うところ($4)は読み替えてください。

"%{%y%m%d/%H:%M:%S}t %h %u %>s (以下省略)" 

そんなわけで、
ログから 日付で grep して、awkでカウントします。

# cat ./aclog_count.sh
#!/bin/sh

# アクセスログの場所を指定し、
# 今月の今日までのログを読み込む
ACLOG=/var/log/httpd/access_log*
min=1
ym=`date +%y%m`
max=`date +%d`

# 年月日指定する場合は以下をいじる
#ym=`date +%y11`
#min=1
#max=30

while [ $min -le $max ]; do
    ii=`printf '%02d' $min`

    echo "$ym$ii -----------------"

    egrep "^$ym$ii" $ACLOG | \
    grep -v "Googlebot" |\
    awk '
       BIGIN{
           cnt200 = 0
           cnt300 = 0
           cnt400 = 0
           cnt500 = 0
       }
       {
           if($4 ~ /2[0-9][0-9]/){
               cnt200++
           }
           else if($4 ~ /3[0-9][0-9]/){
               cnt300++
           }
           else if($4 ~ /4[0-9][0-9]/){
               cnt400++
           }
           else if($4 ~ /5[0-9][0-9]/){
               cnt500++
           }

       }
       END{
           printf("    200: %d\n", cnt200)
           printf("    300: %d\n", cnt300)
           printf("    400: %d\n", cnt400)
           printf("    500: %d\n", cnt500)
     }'
  min=`expr $min + 1 `
done

HTTPレスポンスについてざっくり説明すると、

-200番台が正常終了、

-300番台がリダイレクトなど、

-400番台がクライアントエラー(ページが見つからない、認証NGとか)

-500番台がサーバエラーです。

上の例では「grep -v Googlebot」で無視するログを指定していますが、
実際はここを調整して取捨選択します。例えば
「egrep -vi "robot|Googlebot|bingbot|siteexplorer"」等を除外するとか、
ブラウザ名を指定するとか、Refererで絞るとか。

~~検索ボットの分析スクリプトの例は後述

こんなイメージで、
大雑把にアクセス数を見てみたのですが、
それにしても 404 が多い・・・
どこぞのロボットの過去に消した記事へのアクセスとか
WordPressやDBの脆弱性を漁るためのアクセスとか

~~この辺が気になって、fail2ban を導入する ことになったのですが。

アクセスログから国の特定

流れとしては、各国のIP割り当てを調べて、
アクセス元のIPを調べて、
アクセス元がどの国かを調べる、という話です。

まずは、各国へのサブネット割り当て表を取ってきます。
表の中身については APNICのRIR書式説明(英語) を参照ください。

# wget ftp://ftp.apnic.net/pub/stats/apnic/delegated-apnic-extended-latest
# wget ftp://ftp.afrinic.net/pub/stats/afrinic/delegated-afrinic-extended-latest
# wget ftp://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest
# wget ftp://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest
# wget ftp://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest

これをIP/サブネット/国名の書式に加工します。
(19.03.20更新)これ、よくよく考えると、サブネットに加工する必要ないんですよね...
上記ファイルは、rir_list/ というディレクトリを作って、放り込んでおいて下さい。
これを使って、次のようなスクリプトを作ります。

#!/usr/bin/perl -w

# 現状はIPv4だけで足りそうなので、
# v6は必要になったら考えます

my %ip_range;
my %ip_cntry;

# まず、割り当て一覧を読み込む
# このツールと同じ場所に rir_listディレクトリを作って
# 以下のファイルを一式置いておく
# ftp://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest
# ftp://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest
# ftp://ftp.apnic.net/pub/stats/apnic/delegated-apnic-extended-latest
# ftp://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest
# ftp://ftp.afrinic.net/pub/stats/afrinic/delegated-afrinic-extended-latest
use File::Basename;
my $base_dir = dirname(__FILE__) .  "/rir_list/";
@rir_lists = (
$base_dir . "delegated-afrinic-extended-latest",
$base_dir . "delegated-apnic-extended-latest",
$base_dir . "delegated-arin-extended-latest",
$base_dir . "delegated-lacnic-extended-latest",
$base_dir . "delegated-ripencc-extended-latest"
);
 
foreach my $r_list (@rir_lists){
  open($list, "< $r_list") or die "$! [r_list]\n";
  while(<$list>){
    # 上記の、こんな感じのファイルを読みたい
    #  apnic|JP|ipv4|1.0.16.0|4096|20110412|allocated|A92D9378
    if(/^[^\|]+\|(\w+)\|ipv4\|([\d\.]+)\|(\d+)\|/){
      $ip=$2; $num=$3; $cnt=$1;
      if($ip =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/){
          $bin=$1*256*256*256
          +$2*256*256
          +$3*256
          +$4; # IP表記から数値に戻す
          $ip_range{$bin} = ($bin + $num);
          $ip_cntry{$bin} = $cnt;
      }
    }
  }
}

# 引数にアドレスを渡した場合は、国判定してすぐ終了
if($ARGV[0] =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/){
    $bin=$1*256*256*256
    +$2*256*256
    +$3*256
    +$4; # IP表記から数値に戻す
    while(my ($start, $end) = each(%ip_range)){
        if($start <= $bin && $bin <= $end){
            printf("%s is %s\n", $ARGV[0], $ip_cntry{$start});
            exit;
        }
    }
    printf("%s is not found.\n", $ARGV[0]);
    exit;
}

# 次にアクセスログを読み込みつつ
# IPv4 アドレス部 を 国名に変換する
my $file;
if($ARGV[0] eq '-') { $file = 'STDIN';}
else{ open($file, "< $ARGV[0]") or die "$! [$ARGV[0]]\n";}

$i=0;
while(<$file>){
    my $hit = 0;
     if(/^(.*[ \[\(:])(\d\d?\d?\.\d\d?\d?\.\d\d?\d?+\.\d\d?\d?+)([ \]\)].*)$/){
        # アクセスログの書式に合わせて if 文内の正規表現を調整
        # 上記の例だと、11.22.33.44 とか [11.22.33.44] が出てきたら無節操に変換を試みる
        $pre=$1; $ip=$2; $suf=$3;
        if($ip =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/){
            $bin=$1*256*256*256
            +$2*256*256
            +$3*256
            +$4; # IP表記から数値に戻す

            foreach my $start (keys(%ip_range)){
                if($start <= $bin && $bin <= $ip_range{$start}){
                    printf("%s%s[%s]%s\n",
                            $pre,
                            $ip,
                            $ip_cntry{$start},
                            $suf );
                    $hit++;
                    last;
                }
                $i++;
            }
        }
    }
    if(! $hit){
        # 変換できない場合はそのまま出力
        print $_;
    }
}

これ(convert.pl)を使って、

# ./convert.pl  /var/log/httpd/access_log | head 

あるいは標準入力から読込

# grep keyword /var/log/httpd/access_log | ./convert.pl -
# lastb | head -100 | ./convert.pl -

国が英語二文字(JPとか)なので、それを調べるのがめんどくさい方は
こちらが強化版 です。※長くなるので分けました

これで念願の、国別アクセス状況を知ることができました。
それにしても、むかし掲示板荒らしを見たときに分かっていたものの、
想定外に多国籍であることが判明・・・そうだったのか・・・うーん、ぐろーばる

おまけ:URLの日本語デコード

Wikiで日本語のURLクエリが「%~」になりますが、nkf コマンドで日本語で戻せます。
が、いま手元に nkf が入って無い(が、perl はある)ので
戻し方をメモっておきます。 (echo は「の」をエンコードした文字、それをパイプで拾って日本語へデコードする例。)

echo "%E3%81%AE" |\
perl -e 'while(<STDIN>){ s/%([0-9a-fA-F]{2})/pack("H2",$1)/ge; print $_; }'

おまけ:検索ボットを数える

さきほど、grep -v で検索エンジンのアクセスは除外する、
なんて簡単に言いましたが、ログ見始めて分かりましたが、これが多い!
次々に新しいbotがくるのですが、主要なものだけでもどの程度のアクセスがあるのか、
参考までに調べてみることにしました。

以下、bot_count.sh
ACLOGにアクセスログを指定し、mm・min・maxに月日指定、
正規表現 regex にパイプ(|)区切りで検索単語を羅列して、
「egrep "^17$mm$ii" $ACLOG」はお手元のアクセスログの日付書式に合わせてください。

# cat bot_count.sh
#!/bin/sh

# アクセスログの場所を指定し、
# 今月の今日までのログを読み込む
ACLOG=/var/log/httpd/access_log*
mm=`date +%m`
min=`date +%-d --date "1 day ago"`
max=`date +%-d`

# 月日指定する場合は以下をいじる
# 今回は 2月の統計をとりたいので、変数を上書き
mm="02"
min=1
max=28

regex="Googlebot|webmeup-crawler|bingbot|siteexplorer|msnbot|MJ12bot|Baiduspider|Yahoo! Slurp|Uptimebot|SemrushBot"

while [ $min -le $max ]; do
    ii=`printf '%02d' $min`

    echo "$mm$ii -----------------"

    # 一行目の mm ii の部分はアクセスログの書式に合わせて整形する
    egrep "^17$mm$ii" $ACLOG | \
    egrep -i "$regex" |\
    awk -v keyword="$regex" '
    {
        # 各キーワードごとの件数をカウントする
        # (変数keyword で、正規表現指定。今回はロボットですね)
        if(match($0, keyword) ){
            keyword_list[substr($0,RSTART,RLENGTH)]++
        }
    }
    END{
        # 配列 keyword_list の
        # 添え字i は キーワード、中身はヒット数(アクセス数) になる
        for (i in keyword_list) {
            printf("  %16s : %d\n", i, keyword_list[i]);
        }
    }' | tee robot_list$mm$ii.txt

    min=`expr $min + 1 `
done

# 過去何回アクセスしてきているかを調べる
echo "--- total days ----------------"
awk -F ":" '{
     a[$1]++;
  } END{
     for (i in a){
         printf("%16s :%02ddays\n", i, a[i]);
     }
  }' robot_list$mm* |\
 sort -t: -k2 |\
 tee robot_list_days.txt

実行イメージはこちら

# ./bot_count.sh
0201 -----------------
           bingbot : 12
       Baiduspider : 1
         Uptimebot : 2
   webmeup-crawler : 63
         Googlebot : 6
           MJ12bot : 2
0202 -----------------
(中略)
0228 -----------------
          bingbot : 2
      Baiduspider : 2
        Uptimebot : 3
     siteexplorer : 9
        Googlebot : 70
          MJ12bot : 23
--- total days ----------------
      siteexplorer  :06days
        SemrushBot  :07days
            msnbot  :08days
   webmeup-crawler  :10days
      Yahoo! Slurp  :13days
           MJ12bot  :21days
         Uptimebot  :26days
       Baiduspider  :27days
           bingbot  :28days
         Googlebot  :28days

他にも botっぽいのは要るのですが、ざっと見ただけでもこれだけあります。
数は大したことないにしても、結局毎日アクセスしてきているのですね。

あとは、botだと分かるものは良心的で、
User-Agentは自己申告だから、
どこぞのブラウザを名乗っていても実はbotかもしれません。

Google Analytics と比べて

Analytics は JavaScript や Cookie やらを使っているはずなので
それらをオフにしているWeb閲覧者や閲覧ツール・ロボは統計に乗らない筈です。
それらを踏まえると、以下の内容も納得かと。

まず目につくのは、検索エンジン・ロボットの類。
アクセスログだと大量に見つかるので、これらを排除して分析する必要があります。
逆に、ロボットやツールからの負荷・攻撃とかは
Analyticsよりもアクセスログから探す方が良いでしょう。
・・・随分むかしに消したはずのページにず~っとアクセスがあって、
どうやら robots.txt で Disallow するまでしても、永遠に続く模様。

セッション数、つまり
1ユーザが複数ページをうろついて、出ていくまでが1セッション?というカウントは
Analystics を使うと便利で見やすいですね。
これをアクセスログから追うならセッションを可視化するネタを仕込む必要があります。
(URLクエリやらCookieやらにセッションを識別する目印を仕込むとか)
Analystics の「行動フロー」画面とか、ちょっと感動しました。


Analytics だと国の他に地域(東京の港区)まで判定されます。
ただ、前述のIPアドレスを元にした国別判定と比較すると異なる結果の時もあります。
(海外からのアクセスが日本国内からと判定されることもあった)
とはいえ、たとえば踏み台(プロキシ)経由でアクセスすれば、アクセス元はその踏み台になるので、
どっちが正しいとかではなくて、どっちも参考程度にした方が良い、という事だと思います。

それと、botやツールの場合も通常アクセスとカウントされるケースも、無くは無いと思います、たぶん。

さすがに「画像ファイル(.pngなど)」にアクセスしたかまでは Analyticsでは追えないはずなので、これも全て読み込んだユーザだけをカウントすると、Analyticsとの数に差異が出ます。
(通常、htmlテキストだけをクロールするのがbot、ホームページのロゴ画像みたいなのも含めて全部にアクセスするのがWebブラウザ、例外としてWebブラウザだけどテキストモードにして画像は読まない、など)
まぁ、どういう基準で1アクセスと数えるかはどれも一長一短で、誤差ですが。


余談ですが、
これが性能調査のためのログ解析とかだと、
もう少し細かく統計取って、グラフ化までを自動化します。
例えば
時分秒単位の平均応答時間、200/300/400/500番台の応答数を
CVSファイル化して、
Excelに読み込ませてグラフ化すると見やすいです(読込、グラフ化までもVBAで自動化する)

  • 秒単位も必要なのは、アクセス数には波があって、波が高い瞬間のデータが要るからです
  • 自動化が重要なのは、結局何回も計測&分析を繰り返すことになるからです

さいごに、
最近(2023.07 現在)、特にEU圏とかから個人情報保護との兼ね合いでGoogle Analyticsも、かなりやりだまに上がっているので、海外からのアクセスを想定するサイトならば、Analyticsをどう使うかはちゃんと調べた方が良いと思います。


色々書いて長くなってしまいましたが・・・

結論:

Analysticsは便利、だけど

私はシェル & Perl大好き、CUI 万歳な人なので、

アクセスログを自分で直接見た方が、なんかこう、楽しいです。