カテゴリー: Linux Page 2 of 67

GROWI 8.0のAI AgentにGeminiを組み込む:月10ドルの特典を活用してみる(初回課金あり。結果は要観察)

Google One AI Proに付帯する、月10ドル相当のGoogle Cloudクレジット。

これを何かに使えないかと考えていたところ、自前でホスティングしているWiki「GROWI」のAI Agentと組み合わせることを思いつきました。

GROWIのAI Agentは、Wiki内の複数ページを横断して検索し、得られた情報をもとに回答を生成してくれる機能です。これが実用になるなら、過去に蓄積したナレッジを探し出す手間を減らせます。

そして、手元には毎月付与されるGoogle Cloudクレジットがある。

ならば、これを活用しない手はありません。

実際に設定してみたところ、GROWIからGemini APIを呼び出し、Wiki内の情報を横断検索して回答を生成するところまでは確認できました。

ただし、ここで一つ注意点があります。

AI Agentが動いたことと、月10ドルの特典だけで運用できていることは、別の話です。

今回は、そこに至るまでの設定手順と、実際に課金される金額について、今後も確認していく必要がある点をまとめます。

例によって前置きは長いです。

1. 取り組もうとしたきっかけ:WikiのナレッジをAIから引き出したい

GROWIのAI Agentは、Wikiに蓄積された情報を横断して調べる用途に向いています。

例えば、過去の旅行記や技術メモを複数ページにわたって検索し、その内容をまとめさせる、といった使い方です。

人間がページ名を思い出し、検索語を考え、一つずつ開いていく手間を考えると、かなり魅力的な機能です。

一方、Gemini APIを無料枠のまま利用しようとすると、レート制限に引っかかることがあります。

特に、複数ページの検索や要約など、一つの質問に対して複数回のAPI呼び出しが発生する処理では、無料枠の制限がボトルネックになる可能性があります。

そこで、従量課金側の利用設定を行い、Google Cloudのクレジットを活用できないか試してみることにしました。

月10ドル相当のクレジットが利用でき、実際のAPI利用料がその範囲に収まるのであれば、個人運用のWikiとしては十分に魅力的です。

ただし、これはあくまで期待値の話。

利用料が月10ドル以内に収まることと、その利用料に特典クレジットが適用されることは、それぞれ確認しなければなりません。

この二つを一緒にしてしまうと、後から請求額を見て慌てることになります。

2. 最初の関門:APIは呼び出せるのに、前払い残高がない

Google Cloud側で請求先アカウントを用意し、Google AI StudioでAPIキーを発行。GROWI側にも設定を入れて、いざ動作確認。

ところが、次のエラーが返ってきました。

Your prepayment credits are depleted. Please go to AI Studio at ... to manage your project and billing.

メッセージの内容は、事前入金したクレジットが枯渇している、というものです。

ここで問題になったのが、Google Cloud側にクレジットがあることと、Google AI Studio側でGemini APIを利用できる状態になっていることが、必ずしも同じではないという点でした。

今回の環境では、AI Studio側で前払い残高を購入する操作が必要になりました。

ただし、ここで注意したいのは、前払い残高を購入すれば、その金額がそのまま保証金として残り、APIの利用料は必ずGoogle Cloudの特典から優先的に引き落とされる、とまでは確認できていないことです。

請求先アカウントにクレジットが表示されていることだけでは、実際のAPI利用料にどのクレジットが適用されるかまでは判断できません。

このあたりは、設定画面の表示だけで安心せず、実際の利用明細と残高の推移を追う必要があります。

3. 作業前に確認しておくこと

今回の作業では、次の点に注意しました。

  • Google Cloudの請求先アカウントと、支払い方法の設定が必要になる。
  • Google AI Studio側で、前払い残高の購入を求められる場合がある。
  • Google Cloudのプロモーションクレジットと、AI Studio側の前払い残高は、同じものとして扱わない。
  • クレジットが付与されていることと、そのクレジットが実際のAPI利用料に適用されることは別途確認する。
  • Google側の料金体系、クレジットの適用条件、画面表示は変更される可能性がある。

なお、今回の手順は、筆者の環境で動作確認できた構成をもとにしたものです。

APIが正常に動くところまでは確認していますが、最終的な請求額とクレジットの消費順序については、引き続き経過観察が必要です。

「月10ドルの特典があるから大丈夫」と考えて作業を進めるのではなく、課金の仕組みを確認しながら利用することを前提にしています。

4. 今回の環境

項目設定
WikiGROWI v8.0.7
実行環境Node.js 24 / pnpm / Docker不使用
AI基盤Google AI Studio / Gemini API
クレジットGoogle Developer Programの月10ドル相当の特典
モデルGemini APIで利用可能なFlash系モデル

モデル名については、実際にAI Studioのモデル一覧に表示され、利用可能なものを選択してください。モデルの提供状況や名称は変更されるため、特定のモデル名を固定して手順を進めるのは避けたほうがよさそうです。

5. さっくりとしているがすんなりはいかない設定手順

Step 1. Google Cloudの請求先アカウントを用意する

まず、Google Cloud側の請求先アカウントを用意します。

  1. Google Cloud Console にアクセスする。
  2. 「お支払い」から請求先アカウントを作成、または既存のアカウントを確認する。
  3. 必要に応じて支払い方法を登録する。
  4. Google Developer Programの特典ページ にアクセスする。
  5. 月10ドル相当の生成AI・Cloudクレジットの特典が利用可能であれば、案内に従って請求先アカウントに適用する。
  6. Google Cloud Consoleのクレジット画面で、特典の適用状況と有効期限を確認する。

ここで確認したいのは、単に特典の申し込みが完了していることではありません。

実際にどの請求先アカウントに、どの種類のクレジットが、いつまで有効なものとして付与されているかです。

後から利用明細を確認するときにも必要になるため、この時点で記録しておくとよいでしょう。

Step 2. Google AI StudioでAPIキーを発行する

続いて、Gemini APIを利用するためのAPIキーを用意します。

  1. Google AI StudioのAPIキー管理画面 を開く。
  2. 必要に応じてプロジェクトを作成する。
  3. APIキーを発行する。
  4. 対象プロジェクトの請求設定と利用可能な課金階層を確認する。
  5. APIキーを安全な場所に保管する。

課金設定については、画面上の案内に従ってください。

なお、APIキーを発行できたことだけでは、課金設定が完了していることや、特典クレジットが利用料に適用されることまでは確認できません。

Step 3. AI Studio側の前払い設定を確認する

今回、実際にエラーの解消に関係したのがこの部分です。

Google AI StudioのBilling画面を開き、前払い残高と購入に関する表示を確認します。

Google AI Studio Billing

今回の環境では、前払い残高の購入が必要でした。

ただし、購入金額や購入が必要となる条件は、画面上の案内と利用環境に従ってください。最低購入額を含め、すべての環境で同じ条件になるとは限りません。

そして、ここが今回の重要なポイントです。

前払い残高を購入した場合は、その金額がどのように扱われるのかを確認してから利用を続けてください。

前払い残高が表示されていることだけを根拠に、追加の持ち出しが発生しないとは判断できません。

Google Cloud側の特典残高、AI Studio側の前払い残高、APIの利用明細をそれぞれ確認し、実際の利用料がどこに計上されるのかを追う必要があります。

Step 4. GROWIのAI設定を行う

ここからはGROWI側の設定です。

  1. GROWIに管理者アカウントでログインする。
  2. /admin/ai を開く。
  3. AI機能を有効にする。
  4. プロバイダーとしてGoogleを選択する。
  5. 発行したGemini APIキーを設定して保存する。
  6. モデルカタログを更新する。
  7. 利用可能なモデルから、使用するFlash系モデルを選択して保存する。

画面の項目名や利用できるモデルは、GROWIのバージョンやGemini API側の変更によって異なる場合があります。

モデル一覧が更新できない場合は、APIキーや課金設定、利用可能なモデルを順番に確認してください。

Step 5. AI AgentからWiki内の情報を検索する

設定が完了したら、GROWIの通常画面に戻り、AI Agentに質問してみます。

例えば、次のような質問です。

「ピボット旅行」と名付けられた記事一覧から、
なぜそう名づけたのかを要約してください。

今回の環境では、AI AgentがWiki内の複数ページを参照し、それらの内容を踏まえた回答を生成するところまで確認できました。

これは、今回の設定で得られた成果です。

単にGemini APIに接続できただけではなく、GROWIに蓄積された情報を検索し、その結果を使って回答を生成するという、本来やりたかった動作が成立しました。

一方で、この動作確認によって分かるのは、あくまでAI Agentが機能しているということです。

利用料が特典クレジットから差し引かれているのか、前払い残高がどのように消費されるのか、最終的な請求額がいくらになるのかは、別途確認する必要があります。

6. ここからが本当の経過観察

今回の設定で、GROWI AI AgentとGemini APIの連携自体は成立しました。

しかし、これで「月10ドルの特典を使って、実質無料で運用できる環境が完成した」と結論づけるのは、まだ早いと考えています。

今後、確認したいのは次の点です。

  • Google Cloud側の特典クレジットが、実際のAPI利用料に適用されているか。
  • AI Studio側の前払い残高が、どのタイミングで、どのように変化するか。
  • API利用料がどの請求項目に計上されるか。
  • 一定期間利用した後、実際に追加の支払いが発生していないか。
  • 月10ドル相当の特典の有効期限や適用条件に変更がないか。

特に重要なのは、設定直後の残高表示だけで判断しないことです。

利用直後には明細への反映に時間がかかる場合もあるため、利用記録と請求情報を継続して確認する必要があります。

また、今回のように前払い残高を購入する仕組みでは、購入した金額と、最終的に消費された金額を分けて考えなければなりません。

「残高がある」「特典が付与されている」「APIが動いている」。

この三つがそろっていても、最終的な支払いがゼロになるとは限りません。

実際にいくら課金されるかは、別問題です。

ここは、しばらく利用実績を追ってから判断することにします。

7. まとめ:便利な環境はできた。あとは請求を確認する

今回、GROWI 8.0.7のAI AgentにGemini APIを組み込み、Wiki内の複数ページを横断検索して回答を生成するところまで確認できました。

蓄積したナレッジを、ページ名や検索語を思い出すことなく引き出せるようになるのは、個人運用のWikiとしても大きな利点です。

毎月付与されるGoogle Cloudクレジットを活用できるなら、なおさら魅力的な構成になります。

ただし、現時点で確認できているのは、連携と動作の成功までです。

月10ドル相当の特典が実際の利用料にどのように適用されるのか、前払い残高がどう扱われるのか、最終的に追加の請求が発生するのか。

このあたりは、実際の利用明細を見ながら確認していく必要があります。

便利だからといって、課金の仕組みまで都合よく解釈してはいけません。

ひとまず、GROWI AI Agentを使える環境は整いました。

あとは、実際に使いながら請求の推移を観察していきます。

GROWI v8.0.1→v8.0.6移行時に発生したMongoDB transactionエラーの復旧手順

はじめに

旅行記を書くところではありますが、「その旅行記の下書きを書くのに必要な」GROWIのメモなのでここに残します。いわゆる「キャッシュイン」です。

GROWIをv8.0.1からv8.0.6へアップグレードしました。

すると、ページの保存や複製を行ったところ、MongoDBのtransactionに関するエラーが発生。GROWI側では、

prisma.revisions.create()

あたりの処理で問題が起きていました。こういうときにまず疑うのはGROWIのアップデートそのものなのですが、調べてみると原因はもう少し下。

MongoDBがstandalone構成だった。

というものでした。以下、復旧手順のメモです。

筆者環境

itemversion
OSUbuntu 24.04
GROWI8.0.6
node.js24.14.1
npm11.13.0
pnpm11.1.1

何が起きていたのか

  • ページの保存ができない
  • 複製したらエラーになる

と言う割とひどい状況。

まずMongoDBの状態を確認します。

rs.status()
MongoServerError[NoReplicationEnabled]: no replset config has been received

MongoDB自体は普通に動いているものの、Replica Setとしては動いていません。

v8.0.3以降のGROWIではtransactionを利用する処理が必要になったためにこの構成では対応できなかった、ということになります。

ここで必要なのは、MongoDBを何台も用意することではありません。現在の構成をそのまま維持しつつ、「1台だけのReplica Set」として動かす方針で対応しました。

さっくりとした手順

  1. GrowiとMongoDBを停止します。
  2. mongod.confをバックアップ
  3. MongoDBにReplica Set rs0を設定
  4. MongoDBを再起動
  5. rs.initiate()でReplica Setを初期化
  6. rs.status()でPRIMARYになったことを確認
  7. helloコマンドでも確認
  8. GROWIのMONGO_URIにreplicaSet=rs0を追加
  9. GROWIを再起動
  10. ページの保存・複製を実際に行って確認

0. GrowiとMongoDBの停止

  • Growi停止(Systemd賭して登録済み)
sudo systemctl stop growi
systemctl status growi

inactiveを確認します。

  • MongoDB停止
sudo systemctl stop mongod
systemctl status mongod

inactiveを確認します。

1. mongod.confをバックアップ

まずは設定変更前の状態を残します。筆者の環境では、設定ファイルを日付付きで/etc/conf_backupに保存しています。

sudo cp -pi /etc/mongod.conf /etc/conf_backup/mongod.conf.$(date +%Y%m%d)

変更前後を比較できるようにしておけば、あとで、

「……で、何を変更したんだっけ?」

となっても確認できます。設定変更では、この「戻れる状態を作ってから触る」が大事です。

2. MongoDBをReplica Setとして起動する

/etc/mongod.confを編集します。

もともとは、

#replication:

となっていました。ここを、

replication:
  replSetName: rs0

に変更します。今回の設定変更はこれだけですが、「だからこそ」変更後の差分確認を行います。

diff -u /etc/conf_backup/mongod.conf.$(date +%Y%m%d) /etc/mongod.conf
+replication:
+  replSetName: rs0
+

変更したらMongoDBを再起動。

sudo systemctl restart mongod

念のため、

sudo systemctl status mongod

で正常に起動していることも確認しておきます。

3. Replica Setを初期化する

続いてMongoDBへ接続。

mongosh

ここで、

rs.status()

を実行します。筆者の環境では、

MongoServerError[NotYetInitialized]: no replset config has been received

となりました。mongod.confで

replication:
  replSetName: rs0

としただけでは、Replica Setとしての構成情報まで作られるわけではありません。そこで、

rs.initiate({
  _id: "rs0",
  members: [
    { _id: 0, host: "localhost:27017" }
  ]
})

を初期化します。正常に実行できれば、

{ ok: 1 }

が返ります。これで、1台だけのReplica Setが作られました。

4. PRIMARYになったことを確認する

もう一度、

rs.status()

を実行します。今回の環境では、

set: 'rs0'
myState: 1

となりました。メンバーについても、

name: 'localhost:27017'
health: 1
state: 1
stateStr: 'PRIMARY'

となっています。

つまり、

localhost:27017

で動いているMongoDBが、

rs0

というReplica SetのPRIMARYになっています。今回の構成は1台だけなので、

votingMembersCount: 1
writableVotingMembersCount: 1

となります。今回はこれで問題ありません。「Replica Set」という名前からすると、何台もMongoDBを並べる必要がありそうに見えますが、今回必要なのはそこではありません。

transactionを利用できるReplica Set構成になっていること。

これが目的です。

5. helloでも確認する

念のため、helloコマンドでも確認します。

db.adminCommand({ hello: 1 })

ここで、

setName: 'rs0'
isWritablePrimary: true
primary: 'localhost:27017'

とsetNameがrs0になっていること。そして、現在の接続先が書き込み可能なPRIMARYとして認識されていることを確認します。

6. GROWIのMONGO_URIを変更する

MongoDB側が終わったので、今度はGROWI側。Growiのスタートスクリプトであるgrowi-start.shをバックアップを取ってから修正します。

変更前の箇所を

export MONGO_URI=mongodb://localhost:27017/growi?maxPoolSize=10

この部分を編集。

export MONGO_URI='mongodb://localhost:27017/growi?maxPoolSize=10&replicaSet=rs0'

に変更します。追加したのは、

&replicaSet=rs0

MongoDBをReplica Setとして起動しただけではなく、GROWIからMongoDBへ接続するときにも、そのReplica Setを指定する必要があります。

筆者の環境では最終的に、

export MONGO_URI='mongodb://localhost:27017/growi?maxPoolSize=10&replicaSet=rs0'

となりました。また、'(シングルクォーテーション)で囲まないとGrowiが503となり起動しても即停止するという地味に嫌なハマりどころがありました。

7. GROWIを再起動

MONGO_URIを変更したらGROWIを再起動します。ここは環境ごとの起動方法に合わせてください。再起動後、GROWIへアクセスします。

8. 実際にエラーが出ていた操作をやる

そして、ここが個人的には一番大事なところ。

実際にGROWIを操作します。今回エラーが出ていた、

  • ページの保存
  • ページの複製

を実行。結果、どちらも正常に動作しました。

さらに既存ページについても確認し、アップグレード前と同じように利用できる状態へ戻っていることを確認。

これで復旧完了です。

今回のポイント

今回、MongoDBそのものが壊れていたわけではありません。

GROWIをv8.0.1からv8.0.6へアップグレードしたことで、これまで問題にならなかったMongoDBの構成では対応できない処理が表面化した、というものでした。

最終的に変更したのは、

MongoDB standalone
        ↓
MongoDB Single Node Replica Set
        ↓
GROWIのMONGO_URIにも replicaSet=rs0 を指定

です。MongoDBを複数台構成にしたわけではありません。現在の1台構成はそのまま。その1台をReplica Setとして動かしています。

そして、最後に実際のGROWIでページ保存・複製まで確認。

この手の復旧では、

「設定ファイルを書き換いた」
↓
「サービスが起動した」
↓
「たぶん直った」

ではなく、

実際にエラーが発生していた操作をもう一度やってみるところまでが復旧です。

今回もそこまで確認して、ようやく作業完了としました。

「Jailhouse Lock」の運用・トラブルシューティング(mod_rewriteで詰まったところ)

この記事の修正となります。

Webサーバーを公開していると、日常的に /wp-admin や /.git、生IPアドレスへのスキャン(Probe)が大量に飛んできます。

単に 404 や 403 を返すだけでも防御にはなりますが、今回は「悪意あるスキャナーやボットを検知し、専用のトラップ(檻)ページへ自動転送・隔離する」という多層防御システム(通称: Jailhouse Lock)を Apache + ModSecurity で構築しました。

構築の過程でハマった「設定の競合」と「ModSecurityのフェーズ問題」の解決策を記録として残します。

1. 目的

  1. 不正スキャンの隔離:
    隠しファイル(/.git)、WordPress探索(/wp-admin)、CGI探索(/cgi-bin)、生IP直撃などの不審なリクエストを検知し、アプリケーション(Rails/Node/PHP等)へ届く前に専用のトラップページ(檻)へ 302 Redirect する。
  2. 正常通信の保護:
    通常のブラウザからのアクセスや Let's Encrypt(ACME チャレンジ)などの正常な通信に一切影響を与えない。
  3. 無駄なログ・負荷の削減:
    ボットによる無駄なリクエスト処理コストを最小限に抑える。

2. 最初の設定と準備

(1) トラップページ(檻)ディレクトリとファイルの準備

トラップ先のコンテンツを配置するディレクトリとダミーファイルを作成します。

# 檻となるディレクトリの作成
sudo mkdir -p /var/www/jailhouse_trap

# トラップページの作成(例: Git用、WordPress用、CGI用、生IP/ボット用)
sudo touch /var/www/jailhouse_trap/git.html
sudo touch /var/www/jailhouse_trap/login.html
sudo touch /var/www/jailhouse_trap/cgi.html
sudo touch /var/www/jailhouse_trap/topgear.html

# 権限の調整
sudo chown -R www-data:www-data /var/www/jailhouse_trap

(2) Apache VirtualHost の基本設定(修正後)

諸々ハマったあとで最終的に決定した内容がこれです。

  • ドメイン名: example.com
  • ドキュメントルート: /var/www/my_app/public
  • トラップディレクトリ Alias: /__jailhouse_lock -> /var/www/jailhouse_trap
<VirtualHost *:443>
    ServerName example.com
    DocumentRoot /var/www/my_app/public

    # ----- 檻(トラップベースディレクトリ)の定義 -----
    <IfModule mod_alias.c>
        Alias /__jailhouse_lock /var/www/jailhouse_trap

        <Directory /var/www/jailhouse_trap>
            Options -Indexes -ExecCGI
            AllowOverride None
            Require all granted
        </Directory>
    </IfModule>

    # ----- Jailhouse Lock (mod_rewrite トラップ群) -----
    <IfModule mod_rewrite.c>
        RewriteEngine On

        # ----- 0. ループ防止および例外通過(最優先判定) -----
        # 檻(トラップページ自身)と ACME チャレンジ(SSL更新)は即座に通過
        RewriteCond %{REQUEST_URI} ^/__jailhouse_lock [NC,OR]
        RewriteCond %{REQUEST_URI} ^/\.well-known/acme-challenge/ [NC]
        RewriteRule ^ - [L]

        # ----- 1. トラップ1: 隠しファイル / ドットディレクトリ探知 -----
        # /.git, /.env, /.htaccess 等
        RewriteRule ^/\. /__jailhouse_lock/git.html [R=302,L,E=dontlog:1]

        # ----- 2. トラップ1.5: WordPress探知 -----
        # /wp-admin, /wp-admin/*, /wordpress配下, /wp-login.php を隔離
        RewriteRule ^/(wp-admin|wordpress)(/.*)?$ /__jailhouse_lock/login.html [R=302,L,NC,E=dontlog:1]
        RewriteRule ^/wp-login\.php$ /__jailhouse_lock/login.html [R=302,L,NC,E=dontlog:1]

        # ----- 3. トラップ2: CGI探索者探知 -----
        # /cgi-bin配下、または .cgi, .pl, .py 拡張子を隔離
        RewriteRule ^/(cgi-bin|cgi-sys|cgi-mod)(/.*)?$ /__jailhouse_lock/cgi.html [R=302,L,NC,E=dontlog:1]
        RewriteRule \.(cgi|pl|py)$ /__jailhouse_lock/cgi.html [R=302,L,NC,E=dontlog:1]

        # ----- 4. トラップ3: 生IP直撃 / Hostヘッダー不正判定 -----
        # HostヘッダーがIPアドレス形式、または空の場合
        RewriteCond %{HTTP_HOST} ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(:[0-9]+)?$ [OR]
        RewriteCond %{HTTP_HOST} ^$
        RewriteRule ^.*$ /__jailhouse_lock/topgear.html [R=302,L,E=dontlog:1]

        # ----- 5. トラップ4: 悪質ボット隔離 -----
        RewriteCond %{ENV:bad_bot} ^1$
        RewriteRule ^.*$ /__jailhouse_lock/topgear.html [R=302,L,E=dontlog:1]

    </IfModule>
</VirtualHost>

3. 想定外の動きと原因(ハマりポイント)

構築中、/wp-login.php は正常に 302 で login.html に転送されるものの、/wp-admin や /wp-admin/admin-ajax.php などのディレクトリ配下へのアクセスが 404 Not Found になる現象が発生しました。

原因の調査で分かった「罠」は以下の2点です。

原因1:ModSecurity(Phase 1)による先行拒否

過去に ModSecurity 側に「WordPress 探索者を 404 で弾く」独自ルール(phase:1)を記述していました。

# [原因となった旧 ModSecurity ルール]
SecRule REQUEST_URI "@rx /(?:wordpress|wp-admin)" \
    "id:10002, phase:1, deny, status:404"

ModSecurity の phase:1(リクエストヘッダー受信直後)は、Apache の mod_rewrite 処理(Phase 2 相当)よりも前に実行されます。
そのため、mod_rewrite でリダイレクト(302)をかける前に ModSecurity が 404 を返してリクエストを終了させていました。

原因2:Alias と mod_rewrite のパス展開の競合

当初 /wp-admin を Alias /wp-admin /var/www/jailhouse_trap/login.html で処理しようとしたところ、/wp-admin/admin-ajax.php へのアクセスが /var/www/jailhouse_trap/login.html/admin-ajax.php という存在しないファイルパスへ展開され、Apache 内部で 404 が発生していました。

4. 修正内容

  1. ModSecurity 側の旧ルールの撤去:
    ModSecurity 側で 404 拒否していた旧ルールを削除し、URI 判定と転送ロジックをすべて mod_rewrite へ統一・一元化しました。
  2. Alias から mod_rewrite(正規表現捕獲)への切り替え:
    サブパスを持つ可能性のあるディレクトリトラップ(wp-admin や cgi-bin)は、Alias ではなく RewriteRule の正規表現 ^/(wp-admin|wordpress)(/.*)?$ を使用して配下ファイルごと一網打尽に捕捉するように変更しました。

5. 修正後の検証テスト

設定反映後、curl を使用して各エンドポイントの挙動をテストしました。

1 WordPress探索のテスト

# 末尾スラッシュなし
curl -i -A "Mozilla/5.0" "https://example.com/wp-admin"

# 配下ファイル
curl -i -A "Mozilla/5.0" "https://example.com/wp-admin/admin-ajax.php"

【結果】
いずれも HTTP/1.1 302 Found が返り、Location: [https://example.com/__jailhouse_lock/login.html](https://example.com/__jailhouse_lock/login.html) へ正しく誘導されました。

2 CGI探索のテスト

curl -i -A "Mozilla/5.0" "https://example.com/cgi-bin/test.cgi"

【結果】
HTTP/1.1 302 Found で Location: [https://example.com/__jailhouse_lock/cgi.html](https://example.com/__jailhouse_lock/cgi.html) へ誘導。

3 生IPアドレスアクセスのテスト

curl -i -k "https://192.0.2.1/"

【結果】
HTTP/1.1 302 Found で Location: [https://192.0.2.1/__jailhouse_lock/topgear.html](https://192.0.2.1/__jailhouse_lock/topgear.html) へ誘導後、ModSecurity との連携により無効化完了。

4 正常アクセス・ループ防止テスト

# 正常なブラウザ通信
curl -i -A "Mozilla/5.0" "https://example.com/"

# トラップページへの直接アクセス(ループ確認)
curl -i -A "Mozilla/5.0" "https://example.com/__jailhouse_lock/git.html"

【結果】
いずれもリダイレクトループを起こさず HTTP/1.1 200 OK が返り、正常なコンテンツが表示されました。

まとめ

今回のハマりポイントを通して、「WAF (ModSecurity) と Apache (mod_rewrite) の処理フェーズの違い」を理解することの重要性を痛感しました。

多くのクローラーは

  • IPアドレス直打ちである
  • ヘッダが欠損している
  • wp-adminやwp-loginを真っ先に狙ってくる
  • 隠しファイルを執拗に狙う

が私の経験則です。であれば、

  • IPアドレス直打ちやヘッダの欠損はまともにリクエストをさせない。(前段で弾く)
  • 存在しないパスや隠しファイルはダミーページに飛ばす

で、多くのノイズを減らすことができます。

攻撃者は何を探していたのか? ModSecurityのログから読み解く自動スキャンの狙い

Webサーバーを運用していると、ModSecurityやApacheのエラーログには毎日のように不審なアクセスが記録されます。

その多くは機械的なスキャンですが、中には「攻撃者が何を考えてこのURLを叩いているのか」がよく分かるものがあります。

今回は、筆者のサーバーで実際に検知したログをもとに、「攻撃者は何を探していたのか」「WAFはどこを危険と判断したのか」を読み解いてみます。

ログの内容抜粋

※ IPアドレスやドメイン名はダミーに置き換えています。

[Mon Sep 07 02:49:34.840627 2026] [security2:error] [pid 136747:tid 137621397292736] [client 192.0.2.100:55326] [client 192.0.2.100] ModSecurity: Warning. String match within "/content-encoding/ /proxy/ /lock-token/ /content-range/ /if/ /x-http-method-override/ /x-http-method/ /x-method-override/ /x-middleware-subrequest/ /expect/" at TX:header_name_920450_x-middleware-subrequest. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf"] [line "1228"] [id "920450"] [msg "HTTP header is restricted by policy (/x-middleware-subrequest/)"] [data "Restricted header detected: /x-middleware-subrequest/"] [severity "CRITICAL"] [ver "OWASP_CRS/4.28.0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-protocol"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "OWASP_CRS/PROTOCOL-ENFORCEMENT"] [tag "capec/1000/210/272"] [hostname "example.com"] [uri "/_image"] [unique_id "ap2nrj8r0OUiUBbK7B-mlAAAAMo"]
[Mon Sep 07 02:49:34.841346 2026] [security2:error] [pid 136747:tid 137621397292736] [client 192.0.2.100:55326] [client 192.0.2.100] ModSecurity: Warning. Pattern match "(?i)(?:[/\\\\x5c]|%(?:2(?:f|5(?:2f|5c|c(?:1%259c|0%25af))|%46)|5c|c(?:0%(?:[2aq]f|5c|9v)|1%(?:[19p]c|8s|af))|(?:bg%q|(?:e|f(?:8%8)?0%8)0%80%a)f|u(?:221[56]|EFC8|F025|002f)|%3(?:2(?:%(?:%6|4)6|F)|5%%63)|1u)|0x(?:2f|5c))(?:\\\\.(?:%0[01]|\\\\?)?|\\\\?\\\\.?|%(?:2( ..." at REQUEST_URI_RAW. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-930-APPLICATION-ATTACK-LFI.conf"] [line "54"] [id "930100"] [msg "Path Traversal Attack (/../) or (/.../)"] [data "Matched Data: /../ found within REQUEST_URI_RAW: /_image?href=/../../../.env"] [severity "CRITICAL"] [ver "OWASP_CRS/4.28.0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-lfi"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "OWASP_CRS/ATTACK-LFI"] [tag "capec/1000/255/153/126"] [hostname "example.com"] [uri "/_image"] [unique_id "ap2nrj8r0OUiUBbK7B-mlAAAAMo"]
[Mon Sep 07 02:49:34.842572 2026] [security2:error] [pid 136747:tid 137621397292736] [client 192.0.2.100:55326] [client 192.0.2.100] ModSecurity: Access denied with code 403 (phase 2). Operator GE matched 5 at TX:blocking_inbound_anomaly_score. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-949-BLOCKING-EVALUATION.conf"] [line "233"] [id "949110"] [msg "Inbound Anomaly Score Exceeded (Total Score: 35)"] [ver "OWASP_CRS/4.28.0"] [tag "anomaly-evaluation"] [tag "OWASP_CRS"] [hostname "example.com"] [uri "/_image"] [unique_id "ap2nrj8r0OUiUBbK7B-mlAAAAMo"]
[Mon Sep 07 02:49:34.842998 2026] [security2:error] [pid 136747:tid 137621397292736] [client 192.0.2.100:55326] [client 192.0.2.100] ModSecurity: Warning. Unconditional match in SecAction. [file "/usr/share/modsecurity-crs/coreruleset/rules/RESPONSE-980-CORRELATION.conf"] [line "99"] [id "980170"] [msg "Anomaly Scores: (Inbound Scores: blocking=35, detection=35, per_pl=35-0-0-0, threshold=5) - (Outbound Scores: blocking=0, detection=0, per_pl=0-0-0-0, threshold=4) - (SQLI=0, XSS=0, RFI=0, LFI=30, RCE=0, PHPI=0, HTTP=0, SESS=0, COMBINED_SCORE=35)"] [ver "OWASP_CRS/4.28.0"] [tag "reporting"] [tag "OWASP_CRS"] [hostname "example.com"] [uri "/var/www/app/public/404.html"] [unique_id "ap2nrj8r0OUiUBbK7B-mlAAAAMo"]
[Mon Sep 07 02:49:34.849032 2026] [security2:error] [pid 136747:tid 137621380507328] [client 192.0.2.100:55444] [client 192.0.2.100] ModSecurity: Warning. String match within "/content-encoding/ /proxy/ /lock-token/ /content-range/ /if/ /x-http-method-override/ /x-http-method/ /x-method-override/ /x-middleware-subrequest/ /expect/" at TX:header_name_920450_x-middleware-subrequest. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf"] [line "1228"] [id "920450"] [msg "HTTP header is restricted by policy (/x-middleware-subrequest/)"] [data "Restricted header detected: /x-middleware-subrequest/"] [severity "CRITICAL"] [ver "OWASP_CRS/4.28.0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-protocol"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "OWASP_CRS/PROTOCOL-ENFORCEMENT"] [tag "capec/1000/210/272"] [hostname "example.com"] [uri "/google-credentials.json"] [unique_id "ap2nrj8r0OUiUBbK7B-mlQAAAMw"]
[Mon Sep 07 02:49:34.849243 2026] [security2:error] [pid 136747:tid 137621380507328] [client 192.0.2.100:55444] [client 192.0.2.100] ModSecurity: Warning. Matched phrase "credentials.json" at REQUEST_FILENAME. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-930-APPLICATION-ATTACK-LFI.conf"] [line "150"] [id "930130"] [msg "Restricted File Access Attempt"] [data "Matched Data: credentials.json found within REQUEST_FILENAME: /google-credentials.json"] [severity "CRITICAL"] [ver "OWASP_CRS/4.28.0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-lfi"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "OWASP_CRS/ATTACK-LFI"] [tag "capec/1000/255/153/126"] [hostname "example.com"] [uri "/google-credentials.json"] [unique_id "ap2nrj8r0OUiUBbK7B-mlQAAAMw"]
[Mon Sep 07 02:49:34.850394 2026] [security2:error] [pid 136747:tid 137621380507328] [client 192.0.2.100:55444] [client 192.0.2.100] ModSecurity: Access denied with code 403 (phase 2). Operator GE matched 5 at TX:blocking_inbound_anomaly_score. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-949-BLOCKING-EVALUATION.conf"] [line "233"] [id "949110"] [msg "Inbound Anomaly Score Exceeded (Total Score: 10)"] [ver "OWASP_CRS/4.28.0"] [tag "anomaly-evaluation"] [tag "OWASP_CRS"] [hostname "example.com"] [uri "/google-credentials.json"] [unique_id "ap2nrj8r0OUiUBbK7B-mlQAAAMw"]
[Mon Sep07 02:49:34.850677 2026] [security2:error] [pid 136747:tid 137621380507328] [client 192.0.2.100:55444] [client 192.0.2.100] ModSecurity: Warning. Unconditional match in SecAction. [file "/usr/share/modsecurity-crs/coreruleset/rules/RESPONSE-980-CORRELATION.conf"] [line "99"] [id "980170"] [msg "Anomaly Scores: (Inbound Scores: blocking=10, detection=10, per_pl=10-0-0-0, threshold=5) - (Outbound Scores: blocking=0, detection=0, per_pl=0-0-0-0, threshold=4) - (SQLI=0, XSS=0, RFI=0, LFI=5, RCE=0, PHPI=0, HTTP=0, SESS=0, COMBINED_SCORE=10)"] [ver "OWASP_CRS/4.28.0"] [tag "reporting"] [tag "OWASP_CRS"] [hostname "example.com"] [uri "/var/www/app/public/404.html"] [unique_id "ap2nrj8r0OUiUBbK7B-mlQAAAMw"]
[Mon Sep 07 02:49:34.990300 2026] [security2:error] [pid 136775:tid 137621634270912] [client 192.0.2.100:55394] [client 192.0.2.100] ModSecurity: Warning. String match within "/content-encoding/ /proxy/ /lock-token/ /content-range/ /if/ /x-http-method-override/ /x-http-method/ /x-method-override/ /x-middleware-subrequest/ /expect/" at TX:header_name_920450_x-middleware-subrequest. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf"] [line "1228"] [id "920450"] [msg "HTTP header is restricted by policy (/x-middleware-subrequest/)"] [data "Restricted header detected: /x-middleware-subrequest/"] [severity "CRITICAL"] [ver "OWASP_CRS/4.28.0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-protocol"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "OWASP_CRS/PROTOCOL-ENFORCEMENT"] [tag "capec/1000/210/272"] [hostname "example.com"] [uri "/keys/service-account.json"] [unique_id "ap2nrn53LMPC8yKIKKnxogAAAYU"]
[Mon Sep 07 02:49:34.991569 2026] [security2:error] [pid 136775:tid 137621634270912] [client 192.0.2.100:55394] [client 192.0.2.100] ModSecurity: Access denied with code 403 (phase 2). Operator GE matched 5 at TX:blocking_inbound_anomaly_score. [file "/usr/share/modsecurity-crs/coreruleset/rules/REQUEST-949-BLOCKING-EVALUATION.conf"] [line "233"] [id "949110"] [msg "Inbound Anomaly Score Exceeded (Total Score: 5)"] [ver "OWASP_CRS/4.28.0"] [tag "anomaly-evaluation"] [tag "OWASP_CRS"] [hostname "example.com"] [uri "/keys/service-account.json"] [unique_id "ap2nrn53LMPC8yKIKKnxogAAAYU"]

普通のブラウザは送らないHTTPヘッダ

最初にModSecurityが反応したのは次のルールでした。

HTTP header is restricted by policy
Restricted header detected: x-middleware-subrequest

この X-Middleware-Subrequest は、一般的なWebブラウザが送るヘッダではありません。これは Next.js が内部処理で利用する特殊なヘッダとして知られており、近年では

「このヘッダを偽装すれば認証を回避できないか」

という攻撃が数多く試されています。つまり攻撃者は、

「このサーバーは Next.js ではないか?」

という前提でアクセスを始めています。ModSecurityは「通常の利用者が送るはずのないヘッダ」と判断し、この時点で危険なアクセスとして記録しました。

本命は .env ファイル

続いて現れたのがこちらです。

/_image?href=/../../../.env

一見すると画像を要求しているように見えますが、本当の目的は画像ではありません注目すべきなのは

../../../.env

という部分です。

../../../ が意味するもの

../ は

「一つ上のディレクトリへ移動する」

という意味を持ちます。

つまり

../../../.env

とは、

Web公開ディレクトリ
        ↓
      ../
        ↓
      ../
        ↓
      ../
        ↓
       .env

というように、公開ディレクトリの外側にある設定ファイルを読もうとしていることになります。これは Path Traversal(ディレクトリトラバーサル) と呼ばれる典型的な攻撃手法です。

なぜ .env を狙うのか

最近のWebアプリケーションでは、

.env

に重要な設定が保存されています。

例えば

DB_PASSWORD=
API_KEY=
SECRET_KEY=
AWS_SECRET_ACCESS_KEY=
SMTP_PASSWORD=

などです。もし .env が取得できてしまえば、

  • データベースへ接続
  • クラウドサービスへの接続
  • メールサーバーの悪用
  • 他システムへの横展開

など、一気に被害が拡大する可能性があります。攻撃者にとって .env は「宝箱」のような存在なのです。

WAFは何を見ていたのか

今回のログでは

930100

と

930110

という二つのルールが繰り返し反応していました。

Rule 930100

こちらは

パストラバーサルらしい文字列

を検知するルールです。

単純な

../

だけではなく、

URLエンコードやUnicode表現など、回避を試みた表現まで幅広く検査します。

Rule 930110

こちらはもっと単純です。実際に

../

という並びを検出します。

つまり、

  • URI全体
  • パラメータ
  • エンコード表現

それぞれを別々に検査しているため、同じリクエストでも複数回ヒットしています。

次に探し始めたもの

攻撃者は .env の次に、

/google-credentials.json

へアクセスしました。さらに

/keys/service-account.json

というリクエストも続いています。これは Google Cloud や Firebase などで利用される サービスアカウント鍵 を探しています。これらのJSONファイルには

private_key
client_email
project_id

などが含まれており、環境によってはGoogle Cloud APIを操作できる権限が保存されています。そのためOWASP CRSには、

Restricted File Access Attempt

という専用ルールが用意されており、今回も即座にブロックされました。

WAFが35点という高スコアを付けた理由

最後には

Inbound Scores

blocking = 35
LFI = 30

という結果が記録されています。

OWASP CRSは、一つのルールだけでブロックするのではなく、「怪しい行動の積み重ね」を点数化します。

今回であれば、

  • 通常使われないHTTPヘッダ
  • パストラバーサル
  • .env を狙うアクセス
  • 危険なファイル名へのアクセス

といった複数の危険要素が重なった結果、35点 という非常に高い危険度になりました。

既定では5点程度でブロックされるため、このアクセスは「かなり悪質」と判断されたことになります。

このログから見える攻撃者の思考

このログを見ていると、攻撃者は無差別にURLを叩いているようでいて、実際には一定の手順に沿って行動していることが分かります。

  1. まず特殊なHTTPヘッダを送ってNext.js系のアプリケーションではないかを探る。
  2. 次に画像APIを悪用できないか試し、.env を読み出せるか確認する。
  3. 失敗すると今度はGoogle Cloudの認証情報やサービスアカウント鍵を探し始める。

つまり、この一連のアクセスは

「秘密情報(Secrets)がどこかに置きっぱなしになっていないか」

を高速で探し回る自動スキャンなのです。

サーバーそのものを壊すことが目的ではなく、認証情報を盗み出して、その先にあるデータベースやクラウドサービスへ侵入することが本当の狙いと言えるでしょう。

ログを一つひとつ眺めるだけでは「また攻撃が来た」で終わってしまいます。しかし、攻撃者の行動を時系列で追ってみると、「何を探していたのか」「WAFは何を危険だと判断したのか」が見えてきます。

WAFは単に「怪しい文字列」を検出しているのではありません。攻撃者の行動全体を点数化し、その積み重ねから危険度を評価しています。

【Nextcloud】Backblaze B2へデータを移した話:filesだけをbindマウントしたときの手順

以前の記事では、Backblaze B2を rclone でマウントし、systemdサービスで安定運用する方法を紹介しました。

これで「クラウドストレージをローカルディスクのように扱える」環境はできたわけですが、Nextcloudではもう一つ考えなければならない問題があります。

それが 「何をクラウドへ置くのか」 です。最初は筆者も

dataディレクトリごとB2へ持っていけばいいだろう

と思っていました。ところが調べていくと、これは運用上かなり危険な構成でした。

今回は、Nextcloudのデータ領域のうちユーザーの実ファイルだけをBackblaze B2へ移し、それ以外はローカルSSDへ残す構成にした話です。

dataディレクトリを丸ごと移すと何が起きるのか

Nextcloudのデータ領域には、ユーザーが保存したファイル以外にも様々なものが置かれています。

例えば

  • サムネイル
  • プレビュー画像
  • キャッシュ
  • アプリ内部データ
  • ログ

などです。

特に問題になるのが appdata_xxx 以下です。画像を開くだけでもサムネイルが作られ、不要になれば削除されます。

つまり、 大量に作って、大量に消す という処理を何度も繰り返しています。ローカルSSDなら何の問題もありません。

しかし、これをオブジェクトストレージ上で行うと話は変わります。API呼び出しは増えますし、オブジェクトの作成・削除も大量に発生します。

Wasabiで痛い目を見った経験

以前、筆者はWasabiを使っていました。

Wasabiには「90日以内に削除されたオブジェクトでも、90日分の利用料金が発生する」というルールがあります。

つまり、

  • サムネイルを作る
  • 数秒後に削除される

という処理でも、90日分の容量として計算されます。その結果、

「実際には存在しないサムネイルの料金だけが積み上がる」

という状態になりました。見た目の容量はほとんど増えていないのに、請求対象だけが増えていくという、なかなか厄介な運用になります。

Backblaze B2ではWasabiほど厳しい制約はありませんが、それでも大量のAPIアクセスや不要なI/Oをわざわざクラウドへ投げる理由はありません。

そこで残すものと移すものを分ける

最終的に採用した構成はシンプルです。

ローカルSSDへ残すものは、

  • appdata_xxx
  • プレビュー
  • キャッシュ
  • ログ
  • .ncdata

だけです。逆にB2へ置くのは

data/<ユーザー名>/files

だけにしました。ユーザーが保存した実データだけをクラウドへ置く構成です。

今回の環境

今回確認した環境は次の通りです。

  • Ubuntu 24.04 LTS
  • Nextcloud
  • Backblaze B2
  • rclone(FUSE3)
  • マウントポイント:/mnt/b2

ユーザー名などは例としてダミーのものを使用しています。対象となるディレクトリは、

/home/www-data/example/data/demo/files

です。

まずはB2へ同期する

先にローカルのデータをB2へコピーします。まずはドライランで確認します。

rclone sync /home/www-data/example/data/demo/files example-b2:example-bucket/nextcloud/files --dry-run -P -v

問題がなければ本番同期です。

rclone sync /home/www-data/example/data/demo/files example-b2:example-bucket/nextcloud/files -P

同期が終われば、ローカル側を退避します。

cd /home/www-data/example/data/demo

mv files files_backup_local
mkdir files

chown www-data:www-data files
chmod 750 files

シンボリックリンクでは動かない

最初に試したのはシンボリックリンクでした。

ln -sfn /mnt/b2/nextcloud/files files

ところが、

php occ files:scan

を実行すると、

Following symlinks is not allowed

で停止します。最初は権限かと思いました。しかし原因はNextcloud側でした。Nextcloudはデータ領域内のシンボリックリンクを追跡しない仕様になっています。

セキュリティ上は正しい動作なのですが、この用途では少々困ります。

bindマウントなら問題なく動く

そこで使ったのがLinux標準のbindマウントです。

sudo mount --bind \
/mnt/b2/nextcloud/files \
/home/www-data/example/data/demo/files

Nextcloudから見ると、これは普通のディレクトリです。そのため、

sudo -u www-data php /home/www-data/example/occ files:scan demo

も問題なく完走しました。シンボリックリンクでは拒否されても、bindマウントなら普通に扱ってくれます。

fstabだけでは少し危ない

bindマウントが成功したので、そのまま /etc/fstab に書けば終わり…… と思ったのですが、ここにも落とし穴がありました。起動直後は、まだ rclone がB2をマウントしていません。

その状態でbindマウントすると、 空ディレクトリをbindしてしまいます。

そこで、

/mnt/b2/nextcloud/files \
/home/www-data/example/data/demo/files \
none \
bind,nofail,x-systemd.after=rclone-b2.service \
0 0

のように、 x-systemd.after を指定して、rcloneサービスの起動後にbindマウントされるようにしました。これなら再起動しても順序が崩れません。

この構成にして良かったこと

結果として、今回の構成にはかなり満足しています。

まず、サムネイルやキャッシュはローカルSSDで処理されるため、クラウドストレージに不要なI/Oが流れません。

一方で、ユーザーが保存した実データだけはBackblaze B2へ退避されるため、ローカルディスク容量を気にし続ける必要もなくなりました。

さらに、Nextcloudのシンボリックリンク制限にも引っ掛からず、Linux標準のbindマウントだけで自然に動いてくれます。

結果として、「速いものはローカル、保存したいものはクラウド」という、それぞれの長所を活かした構成になりました。

Nextcloudをオブジェクトストレージへ移そうとしている方は、data ディレクトリを丸ごと載せ替える前に、一度「本当にクラウドへ置くべきものは何か」を考えてみることをおすすめします。

その一手間だけで、後々の運用はかなり楽になります。

Backblaze B2をLinuxへ常駐マウントする:/etc/fstabを使わず、systemdサービスで自動起動と自己修復を実現する

前回の記事では、WasabiからBackblaze B2へのデータ移行についてまとめました。これでデータそのものは移動できましたが、それだけではWebアプリケーションから利用することはできません。

Redmineから見れば、これまで通り「files」ディレクトリが存在しているだけであり、その保存先がローカルSSDなのか、Backblaze B2なのかは意識しない構成が理想です。

そのためには、Backblaze B2をLinuxへマウントし、通常のディレクトリとして扱えるようにする必要があります。

今回は、そのための仕組みとしてrclone mountを利用しました。問題は、「どうやって常駐させるか」です。

古くからLinuxでは/etc/fstabへ記述して起動時にマウントする方法が紹介されています。しかし今回は、その方法は採用しませんでした。理由は単純です。クラウドストレージはネットワークの向こう側にあります。

ローカルディスクと同じ感覚でOS起動時にマウントしようとすると、起動順序や通信断など、物理ディスクでは考えなくてよい問題が発生します。

今回はそうしたリスクを避けるため、systemdサービスとしてrclone mountを常駐させる構成にしました。

systemdを選んだ理由

/etc/fstabは非常に便利ですが、クラウドストレージでは少し事情が変わります。

OS起動時、まだネットワークやDNSが利用できないタイミングでマウント処理が実行されると、ホスト名の名前解決に失敗し、そのまま起動処理が止まってしまうことがあります。

設定によってはレスキューモードへ入ってしまうため、「ストレージが利用できない」だけでは済みません。

また、運用中に通信断などでrclone mountが終了してしまった場合も、/etc/fstabには再起動する仕組みがありません。

つまり、一度落ちると手動で復旧するまで、Redmineから添付ファイルへアクセスできない状態が続いてしまいます。systemdであれば、この問題を比較的素直に解決できます。

After=network-online.targetによってネットワークが利用可能になるまで起動を待機でき、さらにRestart=alwaysを指定すれば、万が一プロセスが終了しても自動的に再起動してくれます。

単に「起動時にマウントする」のではなく、「サービスとして運用する」という考え方です。

FUSEの準備を行う

rclone mountはFUSE(Filesystem in Userspace)を利用して動作します。

今回は一般ユーザーでマウントを実行しつつ、Webサーバー(www-data)から読み書きできるようにしたかったため、まずFUSE側の設定を変更しました。

念のためバックアップを取得した上で、/etc/fuse.confのuser_allow_otherを有効にします。

  • バックアップ
sudo cp -p /etc/fuse.conf /etc/fuse.conf.orig

user_allow_other を有効化

sudo sed -i 's/#user_allow_other/user_allow_other/' /etc/fuse.conf

続いてマウントポイントを作成します。

sudo mkdir -p /mnt/app_storage
sudo chown -R $USER:www-data /mnt/app_storage
sudo chmod 775 /mnt/app_storage

今回は実行ユーザーが管理しつつ、Webサーバーからも書き込み可能な権限にしました。

systemdサービスを作成する

続いて、rclone mountをsystemdサービスとして登録します。

sudo tee /etc/systemd/system/rclone-b2-app.service << 'EOF'
[Unit]
Description=Rclone Mount Backblaze B2 for Application Storage
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
User=appuser
Group=appuser
ExecStart=/usr/bin/rclone mount b2-remote:dest-backup/app_data /mnt/app_storage \
  --allow-other \
  --vfs-cache-mode full \
  --vfs-cache-max-size 10G \
  --vfs-cache-max-age 24h \
  --uid 33 --gid 33 \
  --umask 002 \
  --log-level INFO \
  --log-file /var/log/rclone-b2-app.log
ExecStop=/bin/fusermount3 -u -z /mnt/app_storage
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
EOF

今回の設定で特に意識したのは、アプリケーションとの互換性です。

--vfs-cache-mode fullを指定することで、読み書きをローカルキャッシュ経由で処理し、多くのWebアプリケーションが通常のファイルシステムと同じように扱えるようになります。

また、

--uid 33
--gid 33

を指定し、見かけ上の所有者をwww-dataへ変更しています。これにより、Redmineなどから見ても通常の添付ファイルディレクトリとして扱うことができます。

ログについてもsystemd任せにせず、専用ファイルへ出力するよう設定しました。

sudo touch /var/log/rclone-b2-app.log
sudo chown appuser:appuser /var/log/rclone-b2-app.log

問題が起きた際に、原因を追いやすくするためです。

サービスとして起動する

設定が終わったら、systemdへ登録します。

sudo systemctl daemon-reload
sudo systemctl enable --now rclone-b2-app

状態は、

sudo systemctl status rclone-b2-app

で確認できます。Active: active (running)となっていれば正常です。

続いて、

ls -la /mnt/app_storage/files/

などで、Backblaze B2上のデータがマウントされていることを確認します。ここまで来れば、Linuxからは通常のディレクトリとして扱える状態になっています。

Redmineを止めずに切り替える

最後は、Redmineが参照する添付ファイルディレクトリを切り替えます。今回はシンボリックリンクを利用していたため、

cd /var/www/redmine
ln -sfn /mnt/app_storage/files files

だけで切り替えられました。Linuxではシンボリックリンクそのものを置き換える処理は一瞬で完了します。

つまり、Redmineから見ると「files」という入り口が別の場所を指すようになるだけであり、中途半端な状態が発生しません。

このような、一度に切り替える方法は一般にアトミックな切り替えと呼ばれます。アプリケーションを停止することなく保存先だけを差し替えられるため、運用中のシステムでは非常に扱いやすい方法です。

最後は実際に読み書きを確認する

切り替えたら、必ずWebアプリケーションから動作を確認します。

まずは既存チケットを開き、添付ファイルや画像が問題なく表示・ダウンロードできることを確認しました。

続いて、新しいチケットを作成し、テスト用ファイルを添付します。

Redmine上で正常に保存できることに加え、Backblaze B2の管理画面でもファイル数と使用容量が増えていることを確認しました。

読み込みだけでなく、書き込みまで正常に動作することが確認できれば、移行作業は完了です。

おわりに

Backblaze B2への移行は、データをコピーしただけでは終わりではありません。

Webアプリケーションからこれまで通り利用でき、通信断が発生しても自動的に復旧し、運用者が特別なことを意識しなくても済む状態になって初めて、「移行が終わった」と言えるのだと思います。

今回はrclone mountをsystemdサービスとして管理することで、起動順序や通信断といったクラウドストレージ特有の課題にも対応できる構成になりました。

今後は、この構成でRedmineやPiwigoなどを実際に運用しながら、VFSキャッシュのサイズやメモリ消費、レスポンスへの影響なども継続して検証していく予定です。

rcloneを使ったクラウド間データ移行:WasabiからBackblaze B2へ直接ストリーミング同期する

前回の記事では、WasabiからBackblaze B2へ移行する理由と、B2のアカウント作成から接続確認までをまとめました。

今回は実際に、Wasabiへ保存されているデータをBackblaze B2へ移行します。

「クラウドからクラウドへデータを移す」と聞くと、一度ローカルへダウンロードし、それを再びアップロードするイメージを持つ方も多いかもしれません。

筆者も当初はその方法を考えていました。

しかし、この方法ではサーバーのディスク容量を一時的に消費するだけでなく、ダウンロードとアップロードの二重の転送が発生します。データ量が増えるほど、作業時間もストレージ使用量も無視できません。

そこで今回は、rclone が持つリモート間同期機能を利用し、WasabiからBackblaze B2へ直接データを転送しました。

Linuxサーバーはデータを一時的に中継するだけで、ローカルディスクへ保存することはありません。ストレージAPI同士がストリームとしてデータを受け渡すため、容量を気にすることなく移行できます。

本記事では、接続確認からドライラン、本番同期、そして移行後の整合性確認までの流れをまとめます。

今回の移行環境

今回の環境は次の通りです。

  • サーバー:Ubuntu 24.04 LTS
  • ツール:rclone
  • 移行元:Wasabi(S3互換)
  • 移行先:Backblaze B2
  • 移行対象:app_data ディレクトリ

今回はs3fsなどでマウントしたディレクトリは一切利用せず、rclone syncによるクラウド間同期のみで移行を行いました。

まずは接続できることを確認する

同期を始める前に、移行元・移行先の両方へ問題なく接続できることを確認します。今回は、それぞれ次のようなRemoteを登録しました。

Wasabi

[wasabi-remote]
type = s3
provider = Wasabi
access_key_id = <WASABI_ACCESS_KEY>
secret_access_key = <WASABI_SECRET_KEY>
region = ap-northeast-2
endpoint = s3.ap-northeast-2.wasabisys.com

Backblaze B2

[b2-remote]
type = b2
account = <B2_APPLICATION_KEY_ID>
key = <B2_APPLICATION_KEY>
hard_delete = true

設定後は、それぞれのバケット一覧が取得できることを確認します。

# Wasabi
rclone lsd wasabi-remote:

# Backblaze B2
rclone lsd b2-remote:

ここでエラーが出るようであれば、APIキーやアクセス権限を見直しておきます。

最初に移行対象を確認する

同期コマンドを実行する前に、まず対象となるデータ量を確認します。

容量だけではなく、オブジェクト数もこの段階で記録しておくと、移行後の確認が非常に楽になります。

rclone size wasabi-remote:src-storage/app_data

筆者の環境では、

Total objects: 769 (769)
Total size: 454.601 MiB (476683987 Byte)

という結果になりました。

今回は約455MBと比較的小規模ですが、この数字はあとでBackblaze B2側と比較する重要な基準になります。

いきなり同期せず、まずはドライランを行う

rclone syncは非常に便利ですが、その名前の通り「同期」を行うコマンドです。

移行先との差分によっては削除や上書きも実行されるため、筆者はいきなり本番を流すことはほとんどありません。

まずは--dry-runを付け、実際には転送を行わず、どのような処理が実行されるかだけを確認します。

rclone sync \
    wasabi-remote:src-storage/app_data \
    b2-remote:dest-backup/app_data \
    --dry-run \
    -P \
    -v

今回の結果は次の通りでした。

Transferred:      454.601 MiB / 454.601 MiB, 100%, 318.497 MiB/s, ETA 0s
Transferred:          769 / 769, 100%
Elapsed time:          3.0s

全769オブジェクトについて問題なく処理できることを確認できました。

ドライランで異常がなければ、本番へ進みます。

本番同期を実行する

確認が終わったら、--dry-runを外して実際の同期を行います。

rclone sync \
    wasabi-remote:src-storage/app_data \
    b2-remote:dest-backup/app_data \
    -P \
    --transfers 4 \
    --checkers 8

今回は並列転送数を4、差分確認を8として実行しました。この値は回線速度やCPU性能によって最適値が異なりますが、個人サーバー程度であれば十分扱いやすい設定です。

今回のデータ量では、同期自体は数秒程度で完了しました。ローカルディスクを経由していないため、サーバー側の空き容量を気にすることなく作業できたのは大きな利点でした。

移行後は必ず整合性を確認する

同期が終了したら、「終わった」で済ませず、必ず移行先を確認します。まずはBackblaze B2側で同じように容量を集計します。

rclone size b2-remote:dest-backup/app_data

結果は、

Total objects: 769 (769)
Total size: 454.601 MiB (476683987 Byte)

となりました。

移行前に確認した

  • オブジェクト数
  • 総容量

の両方が完全に一致しています。さらにBackblaze B2のWebコンソールでもファイル数と容量を確認し、問題なく反映されていることを確認しました。

ここまで確認できれば、データ移行は完了です。

おわりに

今回の移行作業そのものは数分で終わりました。しかし、本当に時間を掛けたのは「どのストレージへ移るか」を考えることだったように思います。

以前のWasabiでは、Timed Deleted Storageを意識してアプリケーションの設定まで調整する必要がありました。

もちろん、その経験があったからこそ、料金体系だけではなく「普段どのようなファイルが生成・削除されるのか」という視点でストレージを選ぶようになりました。

オブジェクトストレージは、容量や価格だけを比較してしまいがちです。

しかし実際には、アプリケーションの動作と料金体系が噛み合っているかどうかの方が、長く運用していく上では重要なのだと改めて感じています。

次回は、このBackblaze B2をLinuxへマウントし、NextcloudやPiwigoなどのWebアプリケーションからローカルストレージと同じ感覚で利用できるようにする構成をまとめます。

Wasabiの90日削除問題(Timed Deleted Storage)から脱出する。Backblaze B2とrcloneによるオブジェクトストレージ再構築

以前の記事でも触れましたが、筆者はWasabiの「Timed Deleted Storage(90日最低保存期間)」によって、一度約8万円という高額請求を経験しました。

もちろん、Wasabiそのものが悪いサービスというわけではありません。

バックアップ用途のように、一度保存したデータを長期間保持する使い方であれば非常に優秀です。問題だったのは、私が動かしていたアプリケーションとの組み合わせでした。

当時の環境では、MongoDBを利用するサービスが大量の一時ファイルや更新データを短時間で生成・削除しており、その挙動がWasabiの90日最低保存期間と最悪の相性になってしまいました。

さらにNextcloudでも、サムネイル生成やプレビュー画像、一時ファイルなどが絶えず作られては削除されます。

アプリケーションとしてはごく正常な動作です。しかし、Wasabiでは「90日以内に削除されたオブジェクト」であっても、残りの保存期間分がTimed Deleted Storageとして課金対象になります。

結果として、利用容量以上に「削除したデータ」が積み上がり、気付いた頃には非常に大きな請求になっていました。

この経験以降、筆者は運用を大きく見直しました。

NextcloudのデータはローカルSSDへ退避し、一時ファイルの保持期間も短縮。キャッシュやサムネイルの扱いも見直したことで、Timed Deleted Storageは12MB程度まで抑え込めています。

現在では当時のようなペナルティはほぼ発生しておらず、運用自体は十分安定しています。それでも、根本的な問題は残っていました。

実際に保存しているデータは20GB程度しかありません。(というか、Nextcloudを限定的な使い方しかしていないため増やせないという)

さらに、今後導入を考えているPiwigoのようなフォトギャラリーでは、サムネイル生成や画像整理が日常的に行われます。

「この操作をするとTimed Deleted Storageが増えるかもしれない。」

そんなことを気にしながらアプリケーションを導入したり、設定を考えたりするのは、本来あるべき運用ではありません。

サービスに合わせてアプリケーションを制限するのではなく、アプリケーションが本来の動きをしても問題にならないストレージを選びたい。

そう考え、オブジェクトストレージそのものを見直すことにしました。

Backblaze B2を候補に選んだ理由

移行先として最初に候補へ挙がったのは、Cloudflare R2とBackblaze B2でした。

どちらも個人利用では人気の高いサービスですが、筆者が重視したのは「料金」そのものではなく、「運用中に余計なことを考えなくて済むか」という点です。

Wasabiで最も苦労したのは、保存容量ではありません。「削除する」という、ごく普通の操作にコストが発生することでした。Backblaze B2では、料金はByte-Hoursという時間単位で計算されます。

サムネイルや一時ファイルを数分だけ作成して削除した場合、その数分間しか課金されません。アプリケーションが普通に動けば、料金も普通に計算される。

もちろん、小容量利用でのコスト面も魅力です。

最低利用容量はなく、10GBまでは無料。筆者のように20GB程度しか保存していない環境であれば、月額は数十円程度に収まります。

また、以前は「日本から利用するには代理店経由なのでは」と思い込んでいましたが、実際には本家サイトからそのまま個人契約でき、クレジットカードやPayPalにも対応していました。

Backblaze B2のアカウントを作成する

移行先をBackblaze B2に決めたら、まずはアカウントを作成します。

公式サイトから 「B2 Cloud Storage」 を選択し、メールアドレスとパスワードを登録するだけで利用を開始できます。個人利用でも特別な手続きは必要なく、クレジットカードやPayPalでそのまま契約できます。

途中で保存先リージョンを選択しますが、 US West を選択しました。物理的に日本へ比較的近く、レイテンシも十分実用的です。

認証メールで本人確認を済ませると、管理画面へログインできます。

Bucketを作成する

続いて、データを保存するためのBucketを作成します。

左メニューから B2 Cloud Storage → Buckets を開き、「Create a Bucket」を選択します。

設定は次のようにしました。

  • Bucket Name:任意(グローバルで一意)
  • Visibility:Private
  • Default Encryption:今回は無効
  • Object Lock:無効

Object Lockはランサムウェア対策や改ざん防止には非常に有効な機能ですが、通常のファイルサーバー用途では「削除できない」という制約が先に立ってしまいます。

バックアップ専用のバケットであれば検討する価値がありますが、今回は日常的に更新されるストレージなので無効のままとしました。

最初に変更しておきたい設定

Bucketを作成したら、最初に確認しておきたい項目があります。

それが Lifecycle Settings です。

初期設定では Keep all versions となっており、同じファイルを上書きすると以前のバージョンが残り続けます。Backblaze B2はデフォルトでファイルの世代管理を行うため、このままでは古いバージョンも保存容量に含まれます。

筆者の用途では世代管理は不要だったため、

Keep only the last version of the file

へ変更しました。これで最新版のみを保持する運用になります。もちろん、バックアップ用途で利用するのであれば「Keep all versions」の方が適しています。

ここは「どちらが正しい」という話ではなく、用途に合わせて選択する部分です。

アプリケーションキーを発行する

続いて、Linuxから接続するためのAPIキーを作成します。

管理画面の Application Keys から Add a New Application Key を選択します。

今回は次のように設定しました。

  • Key Name:任意
  • Bucket:作成したBucketのみ
  • Access:Read and Write
  • Allow List All Bucket Names:有効

最後の Allow List All Bucket Names は忘れやすい項目です。

rcloneなどがBucket一覧を取得する際に利用するため、有効にしておく方が扱いやすくなります。

キーを作成すると

  • keyID
  • applicationKey

が表示されます。

このうち applicationKeyはこの画面でしか表示されません。 あとから確認できないため、安全な場所へ保管しておきます。

Ubuntu 24.04へrcloneを導入する

Backblaze B2への接続には、今回は rclone を採用しました。

以前はs3fsを利用していましたが、VFSキャッシュや同期機能などを考えると、現在ではrcloneの方が扱いやすい場面が多くなっています。

Ubuntu 24.04であればAPTからそのまま導入できます。筆者は好みでaptitudeを用いています。

sudo aptitude update
sudo aptitude install -y rclone fuse3

導入後以下で確認します。

rclone version

rcloneを設定する

設定は一般ユーザーで行います。

rclone config

を実行し、新しいRemoteを作成します。主な設定は以下の通りです。

  • New remote
  • 名前:任意
  • Storage:Backblaze B2
  • Account:keyID
  • Key:applicationKey

途中で

Permanently delete files on remote removal, otherwise hide files.

という質問が表示されます。筆者はここだけ true を選択しました。Backblaze B2は削除しても「Hidden Version」として残すことができますが、今回の目的は「不要なデータを残さない運用」です。

そのため、削除時は完全削除となるよう hard_delete=true を設定しています。

なお、ファイルを上書きした場合の旧バージョン保持はLifecycle設定の対象になるため、先ほどBucket側で「Keep only the last version」に変更した意味もここで生きてきます。

接続を確認する

設定が終わったら、最後に接続できることを確認します。

rclone lsd 設定した名前:

設定が正しければ、作成したBucket一覧が表示されます。

続いて簡単なファイルをアップロードしてみます。

echo "Hello, Backblaze B2 from Linux!" > test-b2.txt
rclone copyto -P test-b2.txt \
    名前:バケット名/test-b2.txt

アップロード後は、

rclone ls 名前:バケット名

で存在を確認できます。さらに削除も試してみます。

rclone delete \
    名前:バケット名/test-b2.txt

今回は、削除後もTimed Deleted Storageのような最低保存期間を気にする必要はありません。短時間だけ存在したファイルは、その存在していた時間だけが課金対象となります。

これで、ようやく「削除すると料金が増えるかもしれない」という制約から解放されました。

もちろん、料金が安くなったことも嬉しいのですが、それ以上に「アプリケーションが本来の動作をしても気にしなくて良い」という安心感の方が、筆者にとっては大きな収穫だったように思います。

Apache RewriteRuleを書き直して気付いたこと。「正規表現」は短く書くためではなく、保守しやすくするために使う

先日、Redmine の Googlebot 対策として Apache の mod_rewrite を使った RewriteRule を追加しました。

まずは CPU 使用率を下げることが最優先だったため、とにかく確実に動くことを重視して場当たり的に正規表現を追加しました。

なので、今回は、RewriteRule を書き直しながら改めて感じた、「正規表現は短く書くためではなく、保守しやすくするための道具」という話です。

動けばいい。だが、そんな状態では後でメンテしづらい

最初に書いた設定はこんな感じでした。

RewriteRule ^/projects/.*/knowledgebase - [G,L]
RewriteRule ^.*/knowledgebase - [G,L]

RewriteCond %{HTTP_USER_AGENT} (Googlebot|GoogleOther|bingbot|Baiduspider) [NC]
RewriteRule ^/(projects/[^/]+/)?issues\.pdf$ - [G,L]

RewriteCond %{HTTP_USER_AGENT} (Googlebot|GoogleOther|bingbot|Baiduspider) [NC]
RewriteRule ^/(projects/[^/]+/)?issues\.csv$ - [G,L]

RewriteCond %{HTTP_USER_AGENT} (Googlebot|GoogleOther|bingbot|Baiduspider) [NC]
RewriteRule ^/(projects/[^/]+/)?issues\.atom$ - [G,L]

もちろん、これでも動きます。むしろトラブル対応中であれば、このくらい分かりやすく書いた方が安全です。

ただ、冷静になって眺めてみると、

「同じことを何回も書いている」

ことに気付きました。

正規表現とは何か

そこで、さらに正規表現で直していきます。「正規表現」という言葉を聞くと、難しそうな印象を持たれる方も多いかもしれませんが、思ったよりも単純なルールでできます。

例えば、

issues.pdf
issues.csv
issues.atom

この3つは末尾だけが違います。

これを

issues\.(pdf|csv|atom)

と書けば、

「.pdf でも .csv でも .atom でも一致する」

という意味になります。MtGで言うなれば、

  • 日没を遅らせる者
  • 時を解す者
  • ドミナリアの英雄

につく「テフェリー」は

(日没を遅らせる者|時を解す者|ドミナリアの英雄)、テフェリー

と書けます。このように、共通点があるものを一つのルールで表現する。それが正規表現です。

書き直した結果

最終的には次のような形に整理しました。

RewriteCond %{HTTP_USER_AGENT} (Googlebot|GoogleOther|bingbot|Baiduspider) [NC]
RewriteCond %{REQUEST_URI} ^/(projects/[^/]+/)?issues\.(pdf|csv|atom)$ [NC,OR]
RewriteCond %{QUERY_STRING} (^|&)(sort|set_filter|per_page)= [NC]
RewriteRule ^/(projects/[^/]+/)?issues - [G,L]

PDF、CSV、Atom を一つの正規表現へまとめ、さらに「動的クエリ」と「エクスポート要求」を一つの RewriteRule で処理できるようにしています。

行数だけを見ると少し減った程度です。

ですが、保守性はかなり向上しました。

保守性を求めた正規表現

今回の目的は「短く書くこと」ではありません。

例えば将来、

issues.json

も遮断対象にしたくなったとします。以前の書き方なら RewriteRule を一本追加します。この書き方であれば

(pdf|csv|atom|json)

と一か所を書き換えるだけで済みます。変更箇所が一つだけになる。これは保守する上でかなり大きな違いです。

Knowledgebaseも同じ考え方で整理。

Knowledgebase の RewriteRule も整理しました。

以前は

RewriteRule ^/projects/.*/knowledgebase - [G,L]
RewriteRule ^.*/knowledgebase - [G,L]

のように複数行で書いていました。これを

RewriteRule ^/(home/www-data/|(.*/)?knowledgebase) - [G,L]

という一つのルールへまとめています。さらに、このルールでは以前問題になった

/home/www-data/...

のような物理パスへの誤アクセスも同時に処理しています。

「Knowledgebaseだけ」ではなく、「Rails に渡したくないもの」という視点で整理し直した結果です。

「同じ意味のもの」をまとめる

今回 RewriteRule を整理していて感じたのは、正規表現を書くことが目的ではないということです。目的は、「同じ意味を持つものを一つのルールで表現する」ことでした。

  • Knowledgebase は「存在しない旧機能」です。
  • PDF、CSV、Atom は「重いエクスポート」です。
  • sort や set_filter は「重い動的クエリ」です。

そう考えると、設定ファイルも「何を止めたいのか」が分かる構成になっていきます。

未来の自分が読める設定にする

サーバー設定は、一度書いたら終わりではありません。半年後、一年後あるいは障害対応中の深夜に、また自分が読むことになります。

その時に

「この RewriteRule は何を止めているんだっけ?」

と悩むようでは、あまり良い設定とは言えません。もちろん、正規表現は凝ろうと思えばいくらでも複雑にできます。ですが、複雑さは必ずしも保守性につながりません。

今回の書き直しで目指したのは、「短い設定」ではなく、 「意図がまとまっていて、後から見ても理解しやすい設定」 でした。
正規表現は、そのための道具なのだと改めて感じた次第です。

Redmine 6.x移行記(後編)犯人はGooglebotだった。Apacheの410 GoneでPassengerを守るまで

前回の記事では、Redmine 6.1への移行後に Passenger の CPU 使用率が異常に高止まりし、production.log と netstat を追い始めたところまでを書きました。

調べていく中で、

  • 削除した Knowledgebase へのアクセス
  • sort= や set_filter= を含むチケット一覧
  • PDF や CSV、Atom のエクスポート要求

といった、一見すると関連性のないリクエストが大量に飛んできていることが分かってきます。そして最後に、

「もしかしてクローラーなのでは?」

という疑問が残りました。今回は、その続きを書いていこうと思います。

アクセス元を確認してみる

まずは production.log とアクセスログを突き合わせながら、アクセス元を確認してみました。

すると、見えてきたのは見慣れた IP アドレスです。

66.249.xx.xx

Googlebot でした。しかも一度や二度ではありません。HTTPS のセッションを何十本も張りながら、次々とリクエストを投げています。

しかもアクセスされていたのは、削除した Knowledgebase だけではありません。チケット一覧についても、

sort=
page=
set_filter=

といったパラメータ違いを延々と巡回しています。さらに、

issues.pdf
issues.csv
issues.atom

まで取得しようとしていました。普段ブラウザで Redmine を使っていると、これらは「便利な機能」という認識です。

ですが、サーバー側から見ると話は変わります。例えば PDF の生成であれば、データベースからチケットを取得し、テンプレートを組み立て、PDF をレンダリングし、レスポンスを返す。

HTML を返すだけとは比較にならないくらい重い処理になります。

つまり Googlebot は、リンクがあるから辿っているだけなのですが、Passenger からすると重い仕事ばかり持ち込まれている状態でした。

404でもPassengerは起きてしまう

Knowledgebase のアクセスについても同じです。

「存在しないページなんだから404で終わりでは?」

最初はそう考えていました。しかし実際には、Rails が起動し、ルーティングを行い、存在しないことを確認し、404 を返します。404 を返すこと自体は正しい動作です。ですが、その404を返すためにも Passenger は仕事をしています。

これでは、存在しないページへのアクセスであっても CPU を使い続けることになります。

ならばRailsまで届かせなければいい

ここで考え方を変えました。Rails が重いのではありません。Rails に仕事を渡してしまっていることが問題です。

であれば、Rails が起きる前にApacheで止めればいい。Apache を使っている以上、一番軽いのは Apache の段階で処理してしまうことです。

そこで mod_rewrite を使うことにしました。バーチャルホストの.confを以下のように変えていきます。

RewriteRule ^/projects/.*/knowledgebase - [G,L]
RewriteRule ^.*/knowledgebase - [G,L]

まずは削除済みの Knowledgebase。続いて、

RewriteCond %{HTTP_USER_AGENT} (Googlebot|GoogleOther|bingbot|Baiduspider) [NC]
RewriteRule ^/(projects/[^/]+/)?issues\.(pdf|csv|atom)$ - [G,L]

PDF、CSV、Atom のエクスポート。さらに、

RewriteCond %{QUERY_STRING} (sort=|set_filter=|per_page=) [NC]
RewriteRule ^/(projects/[^/]+/)?issues - [G,L]

検索エンジンが総当たりしている動的クエリも Apache 側で受け止めることにしました。

404ではなく410を選んだ理由

404でも目的は達成できます。ですが、今回は410 Goneを選びました。

404エラーは、「今は見つからない」という意味です。検索エンジンから見ると、

「また後で来れば復活しているかもしれない」

という扱いになります。一方、410 Gone は、

「もう永久にありません。」

という宣言です。今回削除した Knowledgebase は、将来復活させる予定はありません。だったら、その意思を HTTP ステータスとして返した方が正確です。

robots.txtも追加した

もちろん、Apache の設定だけではありません。今後の巡回そのものを減らすため、robots.txt にも、

Disallow: /projects/*/knowledgebase*
Disallow: /*?*sort=
Disallow: /*?*set_filter=

などを追加しました。ただし、ここは誤解しやすいところです。robots.txt は即効性のある仕組みではありません。

既に巡回予定へ入っているリクエストは、そのまま飛んできます。まず Apache で止める。その上で robots.txt により今後の巡回を減らす。この二段構えにしました。

効果はすぐに現れた

設定を反映し、Passenger(mod_passengerなのでapacheそのもの)を再起動します。

その後、再び CPU 使用率を確認すると、先ほどまで70〜90%を推移していた Passenger が数%程度で落ち着いていました。

production.log からも

  • Knowledgebase の404、
  • IssuesController の大量アクセス、
  • 500.html

の RoutingError は姿を消しています。試しに curl から Googlebot の User-Agent を付けてアクセスしてもApache が Rails を起動することなく、即座に

HTTP/1.1 410 Gone

を返しました。狙い通りです。Passengerに到達すること無くは何も仕事をしていません。

今回学んだこと

今回改めて感じたのは、「プラグインを削除すること」と、「削除したことを検索エンジンへ伝えること」は全く別の話だということでした。

Redmine 側では綺麗にプラグインをアンインストールできていても、Google は昔の URL を覚えています。そして真面目に巡回を続けます。そのリクエストを Rails まで届けてしまえば、存在しないページであっても Passenger は起き、CPU を使います。

だからこそ、410エラーにより「もうありません。」という判断を Apache が先に行う。

今回のケースでは、この水際防衛が一番効果的でした。

Redmine に限らず、長く運用してきた Web アプリケーションでは、大きな整理やプラグインの削除を行った後、一度アクセスログを眺めてみることをおすすめします。

自分ではとっくに忘れていた URL を、検索エンジンは驚くほど律儀に覚えているものです。そして、それが思わぬサーバー負荷につながっていることも、決して珍しくありません。

余談「Apacheは実は柔軟」

これは私の持論なのですが、超大手プラットフォームや大手配信サイトではapacheは重い。nginxこそ主流だ的な記事を見かけますが筆者のように

多数のサーバを立てられない
それでもWebアプリを多数動かしたい
セキュリティも譲れない
場合はApacheの方が驚くほど柔軟なパターンがあります。その辺の話はまた改めて。

Page 2 of 67

Powered by WordPress & Theme by Anders Norén