mattintosh note

どこかのエンジニアモドキの備忘録

ApacheでELB-HealthCheckerのログ振り分けについて考える

User-Agent による振り分けの問題点

AWS で Application Load Balancer(以下 ALB)を使っていると Apache ログに下記のアクセスが記録される。デフォルトではチェック間隔が 30 秒になっているので 86400/30 で一日あたり約 2880 レコードが記録される。

10.0.0.128 - - [13/Jan/2020:00:00:00 +0900] "GET /healthcheck.html HTTP/1.1" 200 - "-" "ELB-HealthChecker/2.0"

で、このログを振り分けるための方法として下記のようなものを見かけた。(正規表現の部分については多少違いはある)

SetEnvIf User-Agent "ELB-HealthChecker.*" nolog
CustomLog "logs/access_log" combined env=!nolog

上記の設定は次のように動く。

  1. User-AgentSetEnvIf正規表現 ELB-HealthChecker.* にマッチした場合、nolog 環境変数を設定する。
  2. CustomLognologが設定されていなければ(つまり !nolog ならば) access_log に記録する。

どこかからコピペしてきたのだと思ったら案の定、同様の情報が次々と出てきた。

しかしこれには欠点があって、User-Agent を ELB-HealthChecker 等に偽装してしまえば隠れて活動が可能となる。例えば、下記のようにアクセスすれば Apache のログには記録されない。(env=nolog を別のログに出力していればそちらに出力される)

$ curl --user-agent "ELB-HealthChecker" localhost

この設定が横行してしまうと AWS で運用しているシステムは軒並み不正アクセスされてしまうのではないかなと思う(そもそもログに残らないので不正アクセスされたことすら気づかないかもしれない)。例えば「ログは動いてないのに接続数がものすごく多い」などの状態になるかもしれない。

Qiita はコピペ記事もあったりするんだろうけど、Classmethod さんはもう少し危険性について説明や補足しておいた方がいいのではないかと思う。

Apache の公式ドキュメントを読んでいる人ならわかると思うが、SetEnvIf の第3引数は regex なので ELB-HealthChecker.* と書く必要は無い。そう書いている人はコピペなのだろう。

SetEnvIf User-Agent ELB-HealthChecker nolog

ベストプラクティスを考える

とりあえずどんな状況でも安易に User-Agent だけで判別するのはよろしく無いと思う(iOSAndroid の振り分けとかならいいと思うが)。なので User-Agent 以外の項目を条件に加える。

SetEnvIf User-AgentBrowserMatch、および SetEnvIfNoCase User-AgentBrowserMatchNoCase はそれぞれ同じ動作なのでここではすべて SetEnvIf User-AgentSetEnvIfNoCase User-Agent とする。

ELB ヘルスチェッカーからのアクセスでわかっていることは下記の通り。

  • Remote_Host はサブネットで付与される IP アドレス
  • Request_MethodGET
  • Request_URI はコンソールで設定したもの(デフォルトは /
  • Request_ProtocolHTTP/1.1
  • User-AgentELB-HealthChecker/1.0ELB-HealthChecker/2.0

Apache の公式ドキュメントを見ると SetEnvIf の第一引数に指定出来るものには Remote_Host やリクエストヘッダなどがある。(Request-Line も使えるのだろうか?)

mod_setenvif - Apache HTTP サーバ バージョン 2.4

Remote_Addr を使えばより正確に ELB ヘルスチェッカーからのアクセスだと見分けることが出来るだろうが、これは環境によって変わるのでメンテナンス性や移植性に欠ける。となると User-Agent 以外に使えそうなものは Remote_URI である。

では下記のように SetEnvIf に条件を二つ以上設定出来るか?と言うと出来ないシンタックスエラーにはならないが、下記の例で言うと一番最初の attribute regex 以降は env-variable として見られていると思われる。

SetEnvIf User-Agent ^ELB-HealthChecker/\d+\.\d+$ && Remote_URI ^/healthcheck.html$ nolog

ちょっと話が逸れるが ALB に設定するパスの話をしておく。ALB の初期設定では / に設定されているが、チェックの解釈によって手法が分かれると思う。

  • トップページ(//index.html)が 200 を返すことを確認する
  • どのパスでもいいので Apache が 200 を返すことを確認する

私は大抵後者を選択する。トップページが 403 だろうがなんだろうがとりあえずインスタンスに接続してもらわなければ Apache の状態を確認することが出来ない。後者の場合、/index.html 等を返すのはコストの無駄なので空のファイルを置いている。

$ touch /var/www/html/aws/healthcheck.html

というわけで以降で説明するパスは /aws/healthcheck.html というファイルが存在する前提で進める。

このパスを User-Agent と組み合わせることで UA 偽装の判別精度が向上するが、UA 偽装を抽出したいわけではなく、「ELB ヘルスチェッカー」と「それ以外」を切り分けたいだけである。それは下記の理由。

  1. UA 偽装で正規のパスにアクセスされたとしても 0 バイトのファイルを返すだけなので特に問題にはならない。更に ALB を使っている場合はパス一致でブロックしてしまえば正規のパスへのアクセスは ELB の内側からしか行えないように出来る
  2. UA 偽装で正規のパス以外にアクセスされた場合は access_log に記録されるので UA 偽装だと判断出来る。これは CloudWatch Logs で access_log に対して文字列フィルタでメトリクス化しておけば良い

話を戻して User-AgentRequest_URI で ELB ヘルスチェッカーを判別したい場合に Apache で使えそうな機能を集める。

  • <Location> ディレクティブを使う
  • SetEnvIf を複数組み合わせる
  • <IF> ディレクティブを使う(Apache 2.2 では使えない

Location ディレクティブを使う

<Location> ディレクティブを使うことで Request_URI = /healthcheck.html を達成することが出来て、その中に SetEnvIf User-Agent を書けば AND 条件となる。

Apache 2.2 & 2.4

<Location "/aws/healthcheck.html">
    SetEnvIf User-Agent ^ELB-HealthChecker/\d+\.\d+$ nolog access_elb
</Location>

CustomLog "logs/access_log"     combined env=!nolog
CustomLog "logs/access_elb_log" combined env=access_elb

例えば Collectd からのアクセスが増えたとしても <Location> ディレクティブが増えるだけで簡単に設定することが出来る。(Apache を信用するのであれば)Require all deniedRequire local によって SetEnvIf Remote_Host localhost を条件に追加していることと同等と考えられる。

Apache 2.4

<Location "/aws/healthcheck.html">
    SetEnvIf User-Agent ^ELB-HealthChecker/\d+\.\d+$ nolog access_elb
</Location>

<Location "/server-status">
    SetHandler server-status
    Require local
    SetEnvIf User-Agent ^collectd/\d+\.\d+\.\d+$ nolog access_collectd
</Location>

CustomLog "logs/access_log"          combined env=!nolog
CustomLog "logs/access_elb_log"      combined env=access_elb
CustomLog "logs/access_collectd_log" combined env=access_collectd

SetEnvIf で複数条件を組み合わせて AND 条件を作る

SetEnvIf による AND 条件の組み方。別の要件で見たのだけどなんともトリッキーな方法だなと思う。❶ と ❷ は見ればわかるのだけど、❸ がポイント。

❸ で is_elb がセットされていれば Apache のデフォルト設定で 1 が入っているので正規表現 ^$ にマッチせず to_healthcheck はキャンセルされないので ❹ が通る。

❸ で is_elb がセットされていなければ to_healthcheck がキャンセルされるので ❹ は通らない。

Apache 2.2 & 2.4

SetEnvIf User-Agent     ^ELB-HealthChecker/\d+\.\d+$ is_elb
❷ SetEnvIf Request_URI    ^/aws/healthcheck.html$      to_healthcheck
❸ SetEnvIf is_elb         ^$                           !to_healtchcheck
❹ SetEnvIf to_healthcheck 1                            nolog access_elb

CustomLog "logs/access_log"     combined env=!nolog
CustomLog "logs/access_elb_log" combined env=access_elb

考えるのも読み解くのも面倒なので Request_URI を使うなら <Location> ディレクティブを使った方がいいと思う。

IF ディレクティブを使う

Apache 2.4 では <If> ディレクティブが使えるので複雑な条件も作ることが出来る。

Apache 2.4

<If "%{REQUEST_METHOD} == 'GET' && %{REQUEST_URI} == '/aws/healthcheck.html' && %{HTTP_USER_AGENT} =~ m|^ELB-HealthChecker/\d+\.\d+$|">
    SetEnvIf _ .* nolog access_elb
</If>

CustomLog "logs/access_log"     combined env=!nolog
CustomLog "logs/access_elb_log" combined env=access_elb

<If> ディレクティブ内で SetEnvIf する必要は無いので上記例では SetEnv を使用している。regex/(スラッシュ)が含まれる場合はバックスラッシュによるエスケープは無効なようなのでデリミタに代替文字を使う必要がある。

m#^ELB-HealthChecker/\d+\.\d+$#

ALB で UA 偽装を弾いてみる

ALB にはパスや HTTP ヘッダーで任意のレスポンスを返すことが出来る機能が備わっている。ただしパスはワイルドカードが使えるだけで正規表現には対応していないので SetEnvIf のようにはいかない。

ELB-HealthChecker の UA 偽装を弾くには下記のように登録する。レスポンスはお好みだが 403 や 404 よりは 503 を返しておいたほうがクローラーなどに効果がある気がする。文字列については大小文字を区別するため下記の場合 elb-healthchecker には効果が無い。

項目
HTTP ヘッダー User-Agent = ELB-HealthChecker*
レスポンスコード 503
Content-Type text/plain
レスポンス本文 省略

実際に ELB ヘルスチェッカーを装ってくるパターンにどんなものがあるかわからないけど全ブロックしてヘルスチェックファイルだけ許可するのもいいかもしれない。

Apache 2.2

SetEnvIfNoCase User-Agent ELB-HealthChecker like_elb

<Location "/">
    Order allow,deny
    Allow from all
    Deny from env=like_elb
</Location>

<Location "/aws/healthcheck.php">
    Allow from env=like_elb
    SetEnvIf User-Agent ^ELB-HealthChecker/\d+\.\d+$ nolog access_elb
</Location>

CustomLog "logs/access_log"     combined env=!nolog
CustomLog "logs/access_elb_log" combined env=access_elb

Apache 2.4

SetEnvIfNoCase User-Agent ELB-HealthChecker like_elb

<Location "/">
    <RequireAll>
        Require all granted
        Require not env like_elb
    </RequireAll>
</Location>

<Location "/aws/healthcheck.php">
    Require env like_elb
    SetEnvIf User-Agent ^ELB-HealthChecker/\d+\.\d+$ nolog access_elb
</Location>

CustomLog "logs/access_log"     combined env=!nolog
CustomLog "logs/access_elb_log" combined env=access_elb

動作は下記のようになる。アクセス可能な領域に違いは無いが、UA 偽装の場合は ELB 用のログファイルに記録されないないので見分けが付く。

ELB-HealthChecker/2.0 elb-healthchecker/2.0
/aws/healthcheck.html へのアクセス allow allow
/aws/healthcheck.html 以外へのアクセス deny deny
記録されるログファイル access_elb_log access_log