「ELKを導入したのにログがゴミだらけだった件」

프로필

2026年03月18日

11 0

記事としては書いていなかった改善がひとつある。Nginxの設定ファイルの変更だ。
理由はシンプルそのもの。Kibanaで確認するログの40〜50%が /env/git といった実在しないパスを探り回るゴミみたいなスキャナーログで、せっかく導入したELKスタックの意味が薄れていたからだ。

というわけで、Kibanaに繰り返し登場するパスをもとに、444を返すようNginxの設定を修正した。
404ではなく444を返した理由は後述する。

(php|php\d?|phtml|wp-admin|wp-content|wp-includes|wp-json|wp-login\.php|xmlrpc\.php|env|git|config|ht|cgi-bin|alfacgiapi|pl|cgi|asp|aspx|jsp|
?:html?|shtml)$ { return 444; } # ブラックリストの例

もちろん実際の設定は上のような単一ブロックではなく、複数の location ブロックに分けて適用した。
その結果、スキャナーログが約40〜50%から20%台まで減少するという嬉しい成果が得られた。

ところが昨日、たまたまKibanaのログをチェックしていたら、127.0.0.1 からわずか7秒間に60回のPOSTリクエストが飛んでいた痕跡を見つけた。その時間帯、僕はアクセスしていない。そもそも内部から直接リクエストを飛ばすことなんてほぼないので、侵入を疑わざるを得なかった。

パターンも /api/upwload/admin/upload といった典型的なスキャニングパターンだったので、OCIのSecurity Rulesのインバウンド/アウトバウンド設定を確認し、ターミナルからcurlでサーバーの ip:8000 に直接接続してみたが——

curl: (28) Failed to connect to cliche.blog port 8000 after 134528 ms: Couldn't connect to server

ちゃんとタイムアウトした。last コマンドでインスタンスのログイン履歴も確認したが、該当時間帯に僕を含め誰のアクセス記録もなかったため、内部侵入という仮説はハズレだと確認できた。

じゃあ、あの 127.0.0.1 の正体は一体何なのか?

ps -ef でプロセスを確認してみたところ、最も怪しいのはLogstashかFilebeatだったが、よくよく考えてみると、ログ収集ツールが実在しない api/upwload みたいなタイポだらけのパスにPOSTを投げる理由がない。

だんだん頭が痛くなってきたので、AIに意見を聞いてみた。


正体判明

であれば、残る可能性としてはOCI(Oracle Cloud Infrastructure)のVulnerability Scanningサービスである確率が高いです。

僕のインスタンスにはOracleが提供する脆弱性スキャニング(Vulnerability Scanning) サービスが有効になっていた。
このサービスは外部から内部を突く方式ではなく、城の中の管理人(エージェント)が自ら巡回して戸締まりを確認するタイプだった。

まあ考えてみれば、7秒で60回という速度はそもそも人間が手動で送れるスピードではないし、外部侵入にしてはログイン記録もOCIのファイアウォールも堅牢なままだったので、エージェントだと仮定すればパズルのピースがきれいにハマった。

とはいえ、タイポ混じりのパスには少し疑問が残ったが、これもプロのセキュリティスキャナーならではの特徴だった。
スキャン時に開発者のミスで放置されたタイポAPIパスを検出するため、典型的なミスパターン(Fuzzing Wordlist)を自動的にチェックする機能があるのだ。


偶然見つけた「裏口」

ハプニングで終わるはずだったこの一件は、セキュリティインフラ全体を見直すきっかけとなった。外部からの侵入経路を想定して調べていく中で、自分のDocker設定が 0.0.0.0:8000 で開いていたことに気づいたのだ。

今まで問題にはなっていなかったが、万が一OCIの設定が何らかの理由で(設定ミスなど)崩れた瞬間、僕のFastAPIサーバーは丸腰で世界中のスキャナーの餌食になるということを意味していた。

即座にDockerのバインディングを制限した。
ports: - "8000:8000"127.0.0.1:8000:8000 に変更し、外部パケットがNginxを経由せずに通過できないように設定を修正した。


ブロック方式の転換

あの手この手で突破してくる残り20〜30%のボットを遮断するため、ブラックリストからホワイトリストへ方式を切り替えることにした。

簡単に説明すると、ブラックリストはバーでトラブルを起こした客を次回から出禁にする方式で、ホワイトリストは会員制バーのように、元々名簿にある会員以外は入店できない方式だ。つまり、僕が許可したパス以外はすべて拒否するわけだが、この拒否も404(Not Found)ではなく444(No Response)で返す形にした。

標準レスポンスの404ではなく444を選んだ理由はこうだ。404はサーバーがクライアントのリクエストを理解した上で「そのパスにリソースはありません」と通知する方式であり、ヘッダーとボディを含む標準レスポンスパケットを生成して送信するため、サーバーリソースの消費とアウトバウンドトラフィックが発生する

一方の444は、Nginx専用の非標準レスポンスだが、クライアントからリクエストを受けるとNginxがホワイトリストを確認し、許可されていないパスだと判断した瞬間、何も答えずに接続をぶった切る方式だ。HTTPレスポンスパケットの生成も、送信もなく、トラフィックの無駄もFastAPIへの負荷も事実上ゼロ

404は電話が鳴ったら出て、営業電話なら「結構です」と切る方式。444は知らない番号はそもそも着信音すら鳴らないようにする方式だ。相手は意図的に無視されているのか、それとも電波が届いていないのか分からない。つまり、ボット側からすればサーバーが落ちているのかネットワークが不安定なのか判別できず、リトライロジックでリソースを浪費することになる。

もちろん、あらゆる技術的決定にはトレードオフが付きまとうという不変の真理に従い、この方式にもデメリットはある。

まず一つ目は運用管理の面倒さ。許可していないパスはすべて444を返す方式のため、ルートを追加するたびにNginxのホワイトリストに新しいルートを設定して再起動するという一連の手順が増える

二つ目はSEO/UXのリスクだ。世の中のすべてのボットが悪いわけではない。悪いボットがいれば、当然いいボットもいる——検索エンジンのクローラー(Google、Naverなど)だ。検索エンジンボットは sitemap.xml に記載されたリンクをひとつずつクロールしようとするが、個別の記事パスや静的ファイル(画像など)のパスのうちひとつでもホワイトリストの正規表現から漏れていると、ボットは444を食らって「このサイトにはリンクはあるけど実際のページはないな」と判断してインデックスを諦める。また、444は標準レスポンスではないのでボットは「一時的なサーバー障害」と誤解して後日再訪問するが、これが繰り返されるとページ全体のスコアが下がらざるを得ない構造になっている。UX面では、静的リソースがホワイトリストから漏れた場合、リソースがブロックされてSNSシェア時のプレビューが表示されず、信頼性が低下する結果を招く。

しかし僕の場合、損よりも得の方が圧倒的に大きかった。運用管理の面倒さはぶっちゃけブラックリスト方式の時と大差ない。なぜなら元の方式もログに繰り返し出現するキーワードをいちいち location ブロックに追加する形だったわけで、新しいルートをホワイトリストに追加するのと工数的にはほぼ変わらない。SEOリスクについても、静的リソース(画像)はインスタンスに直接保存する方式ではなく外部インフラ(S3)からそのまま配信しているのでリソースのブロックは回避できるし、sitemap.xml もFastAPIで db.query を使ってリアルタイム生成するロジックを書いたので、ホワイトリストのルートをすべて公開するように修正済みだ。それに加えて、僕のブログは外部検索からのトラフィックよりも知人やポートフォリオ、GitHub、LinkedInから流入するケースがほとんどなので、404でリソースを消費するよりも444でボットに確実にお引き取り願ってトラフィックを節約する方が合理的だと判断した。

改めてKibanaのログを確認してみると、全期間ベースで正常応答(200)の割合が63%、異常応答(404)の割合が36.4%だった。もしこのプロジェクトがおもちゃではなく、それなりのMAUがあるプロダクトだと仮定すれば、それだけ無駄なレスポンス処理とアウトバウンドトラフィックが発生していたことになる。プロジェクトの規模が大きくなればなるほど、こういう無駄は無視できないコストに化ける


まだ弾は残っている

最後に、fail2banにフィルターを追加した。Nginxで444レスポンスを受けたIPをリアルタイムで検知し、カーネルレベルで恒久的にブロックする仕組みを構築した。

[nginx-444]
enabled = true
port    = http,https
filter  = nginx-444
logpath = /var/log/nginx/access.log

bantime = 600
bantime.increment = true
bantime.multipliers = 1 3 -1
bantime.maxtime = -1

findtime = 60
maxretry = 11

上が最終設定だ。
最初は findtime を600、maxretry を3に設定して、善意の被害者(同業者)やヒューマンエラーを考慮し、韓国伝統の「三振アウト制」を導入しようとしていた。しかしテストしてみると、僕が試したブラウザ(Chrome、Safari)は444を受けると標準レスポンスではないためネットワーク障害と誤認して自動的に再接続を試みるらしき挙動を確認。スマホでテストしていたら、1回のアクセスでログが4〜5件上がって即BANを食らった。unbanip を何度叩いたか分からないが、ブラウザの再試行を考慮した最適な設定を探るという地道な作業を経て、ようやくこの数値に落ち着いた。


おわりに——そして1日目

耳にタコができるほど聞いた。「ポートフォリオの最高峰は実際に運用したサービスだ」と。正直、深く共感したことはなかった。今このブログは、僕が開発を始めた頃とは隔世の感があるほど進化したAIエージェントのコーディング能力のおかげで、今作り直せばもっと短い時間でもっと高いクオリティで仕上げられる自信がある。

でも改めて考えてみると、これまで僕が書いてきた記事の内容は、まさにAIには簡単に教えられない部分なんだと気づいた。なぜなら、AIは結局のところ最も無難で安全な回答をするように設計されているからだ。ホワイトリスト方式を導入すれば必然的に伴う運用/SEOリスクよりも、無難なブラックリスト方式を推奨するだろうし、非標準の444よりも標準レスポンスの404を推奨するだろう。つまり、今日の試行錯誤もかなり前——このブログを作った初期に感じた不便さが僕をここまで連れてきたわけで、もしこのブログを単なるポートフォリオ用に制作だけして実際にデプロイしていなかったら、到底得られなかったインサイトだと思う。

ターミナルでログを見るのが面倒でELKを導入し、ELKを導入してもなお絶えないスキャニングに疲れてFail2Banを導入し、それでもあらゆるパスを突いてくるボットのせいで今日の修正に至った。次はどんなトラブルが僕を困らせて、どんなインサイトをプレゼントしてくれるのか、ちょっと楽しみだったりする。

ちなみに、記事の初稿自体は昨日書き終えたが、細部の調整に時間がかかって今日のアップロードとなった。そのたった1日の間に、444レスポンスは1,696回、BANされたIPは52個だ。

そしてKibanaのログも当然ながら、ステータスコード基準でフィルタリングすると、テスト用の404が1回と405、303を除けば、全レスポンスの97.7%が200という結果だ。

#Nginx #Fail2ban #ELK #Kibana #Whitelist

コメント 0件

コメントを投稿するにはログインが必要です