カテゴリー: ガジェット Page 2 of 113

PHP「php2shell」脆弱性への緊急暫定対応:PHP-FPMのSession Upload Progress無効化(追記に恒久的対応策を記載)(更に追記:CVE-2026-115560にも有効)

更に更に追記 2026/10/23 22:21

本記事でのUpload Progress無効化は、CVE-2026-115560にも有効だという点を添えておきます。

更に追記 2026/10/22 4:02

修正版にアップデートしました。

追記2026/10/21 18:25

2026/10/21 18:25現在、筆者vpsでは念には念を入れてphp-fpmを暫定停止しています。

これは付け焼き刃では対処できないと判断。

sudo systemctl stop php8.3-fpm

で止めています。

→ 修正版が出たために再開しています

概要

2026年10月21日、PHPの重大な脆弱性として報じられた「php2shell」(CVE-2026-109618、CVE-2026-109619)への暫定対応として、PHP-FPMのSession Upload Progress機能を無効化します。

本対応では、session.upload_progress.enabled を Off に設定し、PHP-FPMへ反映しますが、これは本当に緩和策でしかないようでしたが……

CVE-2026-115560の暫定措置としては依然として推奨される策のようです。

2026/10/21 20:27 追記

以下、Geminiによる要約

1. 一体どういう攻撃なのか?

「細工したHTTPリクエストをWebサイトに送るだけで、外部からサーバーのOSコマンドを実行されてしまう」攻撃です。

  • 攻撃の手口:
    攻撃者がWebブラウザやツール経由で特殊なパラメータ(セッション管理やマルチパートフォームの処理に関わるリクエスト)を送信します。
  • 発生する問題:
    PHPコア(C言語で書かれたエンジン部分)内部でメモリ破壊が発生し、Webサーバーの権限で任意のプログラム(シェルなど)を遠隔実行されます。
  • 結果:
    サーバーが丸ごと乗っ取られ、ランサムウェアの設置、データ漏洩、他サイトへの踏み台攻撃などに悪用されます。

2. 大騒ぎしている理由

主に以下の4つの理由から、世界中のエンジニアやセキュリティ企業(watchTowr、各国のCERT等)が大騒ぎしています。

  1. 深刻度スコアが最大級(CVSS 9.2 〜 10.0)
    認証(ログイン)不要、事前の権限不要で、外部ネットワークから直接狙えるため、危険度が最高レベルに設定されています。
  2. 影響範囲が広すぎる
    PHP 5系から8系に至るまで非常に幅広いバージョンが影響を受け、WordPress、Laravel、EC-CUBEなど、Web上の膨大なシステムが標的になり得ます。
  3. アプリ側の改修や設定変更では防ぎきれない
    PHPスクリプト(WordPressやPHPコード自体)の書き方で防げる問題ではなく、PHPエンジンそのもののバグです。php.ini で session.upload_progress.enabled を無効化するなどの緩和策も議論されましたが、完全な遮断には至らず「パッチ適用以外に根本解決がない」と判明したためです。
  4. PoC(実証コード)がパッチ前に出回り始めた
    公式パッチの公開直前(ゼロデイ/準ゼロデイ状態)の段階で、すでに悪用を試みる検証コードや解析情報がネット上に出回り始め、攻撃の自動ボット化が危惧されたためです。

上記理由により、本設定変更は暫定的な緩和策であり、脆弱性そのものの修正を保証するものではありません。正式な修正版PHPが提供されたら、対象バージョンと修正状況を確認したうえで更新します。

また、今回の対応前にApacheのModSecurity CRSが不審なリクエストをHTTP 403で遮断したことを確認しています。WAFによる防御とPHP実行環境の対策を重ね、複数の防御層を維持します。

環境

  • OS:Ubuntu 24.04 LTS
  • Webサーバー:Apache
  • PHP:8.3
  • PHP実行方式:PHP-FPM
  • PHP-FPM設定ファイル:/etc/php/8.3/fpm/php.ini
  • 対象設定:session.upload_progress.enabled
  • 対応目的:脆弱性に対する暫定的なリスク低減

14:00の時点で実施した、さっくりとした手順

  1. PHP-FPMの現在の設定値を確認します。
  2. PHP-FPMの設定ファイルをバックアップします。
  3. session.upload_progress.enabled を Off に変更します。
  4. PHP-FPMの設定を検査します。
  5. PHP-FPMを再読み込みします。
  6. 設定値とサービスの状態を確認します。
  7. Web経由での設定反映とアップロード機能を確認します。
  8. 正式な修正版PHPの公開状況を確認し、更新します。

詳細手順

1. 現在の設定値を確認します

PHP-FPMの設定値を確認します。

sudo php-fpm8.3 -i | grep -i 'session.upload_progress.enabled'

今回の確認結果は以下のとおりでした。

session.upload_progress.enabled => On => On

設定ファイル上では該当行がコメントアウトされていました。

;session.upload_progress.enabled = On

先頭の ; はコメントを示すため、この行自体は設定として適用されません。PHPのデフォルト値が On であるため、コメントアウトされていても機能が無効化されているとは限りません。

実際の設定値を確認し、無効化が必要であることを判断します。

2. 設定ファイルをバックアップします

変更前に、PHP-FPMの設定ファイルをバックアップします。

sudo cp -a /etc/php/8.3/fpm/php.ini /etc/php/8.3/fpm/php.ini.bak-20261021

バックアップファイルが作成されたことを確認します。

ls -l /etc/php/8.3/fpm/php.ini.bak-20261021

設定変更により問題が発生した場合は、このバックアップから元の設定へ切り戻します。

3. Session Upload Progressを無効化します

設定ファイルを編集します。

sudoedit /etc/php/8.3/fpm/php.ini

以下の設定項目を探します。

;session.upload_progress.enabled = On

コメントアウトを解除するだけではなく、値を Off に変更します。

session.upload_progress.enabled = Off

同じ設定項目を重複して追加しないようにします。

変更後、差分を確認します。

sudo diff -u /etc/php/8.3/fpm/php.ini.bak-20261021 /etc/php/8.3/fpm/php.ini

意図した設定項目だけが変更されていることを確認します。

4. PHP-FPMの設定を検査します

設定ファイルの構文を検査します。

sudo php-fpm8.3 -t

正常な場合は、設定ファイルのテストに成功した旨が表示されます。

エラーが表示された場合は、PHP-FPMを再読み込みせず、設定ファイルを修正します。

5. PHP-FPMへ設定を反映します

設定検査に成功したら、PHP-FPMを再読み込みします。

sudo systemctl reload php8.3-fpm

続いてサービスの状態を確認します。

systemctl status php8.3-fpm --no-pager

サービスが正常に稼働していることを確認します。

6. 設定値を再確認します

PHP-FPMの設定値を再確認します。

sudo php-fpm8.3 -i | grep -i 'session.upload_progress.enabled'

期待する結果は以下のとおりです。

session.upload_progress.enabled => Off => Off

ただし、このコマンドは新しく起動したPHP-FPMプロセスの設定を確認するものであり、稼働中のWebリクエストが使用する設定値を直接保証するものではありません。

必要に応じて、Web経由でPHPの設定値を確認します。確認用スクリプトを公開する場合は、外部からアクセスできないように制限し、確認後は必ず削除します。

7. Webアプリケーションの動作を確認します

設定反映後、対象のWebアプリケーションが正常に動作することを確認します。

  • Webサイトの表示
  • ログインとセッション維持
  • ファイルアップロード
  • アップロード後のファイル処理
  • ApacheとPHP-FPMのエラーログ

通常のファイルアップロード自体は、この設定変更によって原則として無効になりません。ただし、PHPのSession Upload Progress機能に依存した進捗表示などには影響する可能性があります。

作業上の注意

影響範囲

session.upload_progress.enabled = Off にすると、PHPがセッションにアップロード進捗情報を記録する機能が無効になります。

通常のファイルアップロード、ログイン、セッション管理などは、この設定変更だけで通常は影響を受けません。ただし、進捗情報を利用するアプリケーションについては動作確認が必要です。

切り戻し

変更後にアプリケーションの動作不良などが発生した場合は、バックアップから設定を復元します。

sudo cp -a /etc/php/8.3/fpm/php.ini.bak-20261021 /etc/php/8.3/fpm/php.ini

復元後、設定を検査します。

sudo php-fpm8.3 -t

検査に成功したら、PHP-FPMを再読み込みします。

sudo systemctl reload php8.3-fpm

最後にサービスの状態とWebアプリケーションの動作を確認します。

なお、切り戻しによって設定値が On に戻る場合、暫定緩和策も解除されます。切り戻しは必要な場合に限り、影響とリスクを確認して実施します。

根本対策

本対応は暫定的な緩和策です。正式な修正版PHPが公開されたら、PHP公式およびUbuntuのセキュリティ情報を確認します。

Ubuntuでは修正がバックポートされる場合があるため、バージョン番号だけで判断せず、使用中のリリースに対する修正状況を確認します。

修正版が提供されたら、実際にWebアプリケーションが使用しているPHP実行環境を更新し、動作確認を実施します。

まとめ

PHPの設定ファイルで該当項目がコメントアウトされていても、機能が無効になっているとは限りません。PHPのデフォルト値が On であれば、コメントアウトされた状態でも機能は有効です。

今回の対応では、設定ファイルの記述だけで判断せず、実際の設定値を確認したうえで Off を明示し、PHP-FPMへの反映と動作確認を行います。

WAFによるリクエスト遮断、PHP実行環境の暫定対策、修正版への更新を組み合わせ、単一の防御機構に依存しない構成を維持します。

To Buy, or Not to Buy ――シェイクスピアの悲劇を経て、自宅サーバ用の小型PCを買った話

はじめに

人間は、なぜ決断に苦しむのでしょうか。

シェイクスピアの『ハムレット』と『マクベス』には、決断を前にして葛藤する二人の人物が描かれています。

考え続けることで行動できなくなるハムレット。行動の代償を理解しながらも、野心に突き動かされるマクベス。

この二人の独白を思い返していた私は、電車の中でひとつの決断を迫られていました。

自宅サーバ用の小型PCを買うか、買わざるか。

という、前置きは壮大なのに結論がしょぼい、いつもの筆者の与太話が始まります。

1. ハムレットとマクベスに学ぶ、決断のコスト

To be, or not to be

『ハムレット』第3幕第1場。あまりにも有名な独白は、こう始まります。

To be, or not to be: that is the question.

「生きるべきか、死ぬべきか。それが問題だ。」

ハムレットが考えているのは、生き続ける苦痛と、すべてを終わらせることの間にある葛藤です。

何かを選ぶことは、別の可能性を捨てることでもあります。しかも、選択した先に何があるのか、必ずしもわかっているわけではありません。

ハムレットは、苦難に耐えることと、苦難に立ち向かうことを比較します。

Whether ’tis nobler in the mind to suffer
The slings and arrows of outrageous fortune,
Or to take arms against a sea of troubles

「不条理な運命の投げる石や矢に耐えることが気高いのか。それとも、苦難の海に武器を取って立ち向かうことなのか。」

ここで重要なのは、行動することにも、行動しないことにも、それぞれ代償があるという点です。

何もしなければ現状が続く。行動すれば、今まで存在しなかった問題まで引き受けることになる。

これは心理学でいう接近・回避葛藤に通じます。ある選択肢に望ましい結果と望ましくない結果が同居していると、人はその選択に引き寄せられながら、同時にそこから逃れようともします。

さらにハムレットは、死後の世界について考えます。

The undiscover’d country, from whose bourn
No traveller returns

「誰ひとり帰ってきたことのない、未知の国。」

未知の国へ踏み出せば、元の場所には戻れないかもしれない。そもそも、その先に何があるのかさえわからない。

ここには、決断の不可逆性と不確実性が凝縮されています。そして独白は、思考が行動を妨げるという結論へ向かいます。

Thus conscience does make cowards of us all

「こうして思慮は、われわれを皆臆病者にしてしまう。」

考えること自体が悪いのではありません。問題は、考えるほど不安が増え、検討すべき可能性が際限なく広がり、いつまでも行動に移れなくなることです。

いわゆる分析麻痺。判断材料を集めるための思考が、判断そのものを先送りするための思考へと変わってしまうのです。

If it were done when ’tis done

一方、『マクベス』第1幕第7場の独白は、別の方向から決断の苦しみを描きます。

If it were done when ’tis done, then ’twere well
It were done quickly.

「もし、事を成し遂げた瞬間にすべてが終わるのなら、さっさとやってしまうのがよい。」

マクベスが考えているのは、ダンカン王の暗殺です。

王を殺害すれば、王位を手に入れられるかもしれない。しかし、殺害という行為が終わったからといって、その結果まで終わるわけではありません。

マクベスは、暴力が自分自身に返ってくる可能性を認識しています。

Bloody instructions, which, being taught, return
To plague th’inventor

「血塗られた教訓は、それを世に教えた者自身を苦しめるために戻ってくる。」

自ら暴力を行使すれば、その暴力が自分を脅かす世界を作り出すことにもなる。決断の結果は、実行した瞬間だけでなく、その後の人間関係や社会のあり方にまで及びます。

さらにマクベスは、自分を行動へ駆り立てるものの正体を理解しています。

I have no spur
To prick the sides of my intent, but only
Vaulting ambition

「私の決意を駆り立てる拍車はない。ただ、身の程を越えて跳ね上がる野心があるだけだ。」

彼には、王を殺さなければならない正当な理由がありません。それでも王になりたいという欲求を捨てられない。

ハムレットは、考え続けることで行動できなくなります。一方のマクベスは、行動の代償を理解しながらも、欲望によって一線を越えようとします。

両者に共通するのは、決断が単なる選択ではないという認識です。

何かを選べば、別の可能性を手放す。そして、選択によって変わった世界を完全に元へ戻せるとは限らない。

だからこそ、決断は苦しいのです。

2. それでも、私は小型PCを買うか悩んでいた

さて、ここからが本題です。

今まで自宅サーバとして使っていたのは、Let's note CF-XZ。

  • CPU:Core i5-7300
  • メモリ:8GB
  • SSD:256GB

これを使って、自室のサーバ環境を構築していました。

試しにNextcloudとAdGuard Homeを導入してみたところ、思いのほか快適だったのです。

何しろローカル運用。外部公開するサーバと比べれば、外部からの攻撃に備えるためのセキュリティ対策や運用上の手間は段違いに少なくなります。もちろん、ローカルだからといってセキュリティ対策が不要になるわけではありませんが、用途を絞った自室の環境としては、かなり具合がよかった。

ところが、使い続けるうちに、ひとつの問題が気になり始めました。

「いや、いい加減古い」

そして、もうひとつ。

「自室の単一障害点になりつつある」

Nextcloudが止まれば、ファイルへのアクセスが不便になる。AdGuard Homeが止まれば、DNSの名前解決に影響が出る。

つまり、DNSが死んだら自室環境が死ぬという恐怖。

便利な環境を作ったつもりが、その環境を支える機器が、今度は自分にとっての弱点になっていたのです。

もちろん、CF-XZはまだ動きます。使えるものをわざわざ買い替える必要があるのか。サーバ用途なら、今の性能でも十分なのではないか。

「それでもまだ動くし」

この言葉が、頭の中で繰り返されます。

一方で、古い機器を使い続けることにもリスクがあります。故障してから慌てて移行するより、余裕があるうちに次の環境を用意しておいた方がよい。

つまり、買い替えないことにもコストがある。

ハムレットなら、ここからさらに数幕分は悩んでくれそうです。

3. そして、セールがやってきた

そんな折、タイミングよく開催されたAmazonプライム感謝セール。

そこで見つけてしまったのです。

ほぼ4万円という、お手頃価格の小型PCを。

今回購入したのはこちら。

BOSGAME E5

項目仕様
OSWindows 11 Pro
CPUAMD Ryzen 3 5300U
メモリ16GB DDR4 RAM
ストレージ512GB M.2 NVMe PCIe 4.0 SSD
購入価格ほぼ4万円

CF-XZと比較すれば、メモリ容量は8GBから16GBへ、SSDは256GBから512GBへと増加。自室のサーバ用途として使うには、なかなか手頃な構成です。

もちろん、購入しただけで単一障害点の問題が解決するわけではありません。移行方法を考え、サービスを構築し、必要なら切り戻せるようにしておく必要があります。

それでも、約4万円で新しい環境を用意できるのであれば、検討する価値は十分にあります。

しかし、ここで再び、あの二人が頭の中に現れました。

ハムレットが問いかけます。

「To buy, or not to buy. 買うべきか、買わざるべきか」

マクベスが答えます。

「If it were done when ’tis done, then ’twere well it were done quickly. やってしまえば片がつくのなら、さっさとやってしまえばよい」

……いや、マクベスの場合は王を殺す話なので、PCの購入と同列に並べるのはさすがに問題がありますが。

それでも、両者の独白は頭の中で続きます。

まだ使える。けれど、古い。買い替えれば費用がかかる。買い替えなければ、故障時のリスクが残る。

買うか、買わざるか。

4. 決断を下したのは、時間だった

そんな逡巡を続けていたのは、電車の中でした。

ThinkPadを広げ、Amazonをブラウザで開き、小型PCを物色し、価格を確認し、仕様を比較する。

そして、ふと気づきました。

あと少しで、自宅の最寄り駅に着く。

このまま悩み続けるのか。それとも、もう決めてしまうのか。

ここで、ひとつの事実に思い至りました。

「こんな逡巡を繰り返すんだったら、サパッと決めるべき」

価格も、用途も、必要なスペックも、おおよその条件はそろっています。今ここでさらに悩んだところで、劇的に判断材料が増えるわけではありません。

  • ハムレットは、なおも考え続けるでしょう。
  • マクベスは、やってしまえば片がつくと言うでしょう。

そして私は、電車が最寄り駅に到着する前に決断しました。

購入。

かくして、注文を終えた私は、いつもの自宅へと帰っていったのでした。

おわりに

そして、現物が届きました。

BOSGAME E5。自宅サーバ用の小型PCです。

シェイクスピアの悲劇から、決断の不確実性、機会損失、不可逆性について考察し、最後に得られた結論は、ほぼ4万円の買い物でした。

しかし、今回の決断には、それなりの理由があります。古くなった機器を使い続けるリスクがあり、ローカル環境の利便性を実感しており、手頃な価格で次の環境を用意できる機会があった。

あとは、実際に移行して、安定して動く環境を作るだけです。

考え続けて何も始められないのも困る。勢いだけで進んで後始末に追われるのも困る。

必要な条件を確認し、許容できるコストを見積もり、決断する。そして、決断した後の問題に備える。

これが、今回の小型PC購入から得られた教訓です。

もっとも、シェイクスピアの悲劇を経て自宅サーバを増強するという結論が、果たして文学的に正しいのかはわかりません。

少なくとも、私の自宅サーバ環境は、またひとつ拡張されることになりました。

To buy, or not to buy.

今回は、買いました。しかも、こんな写真と共に

「買っちった」

ええ、決断の重さと得られたリターンなんて、しょせんはこの程度という、いつもの前置きは壮大、結論は単純というお話でした。

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でページ保存・複製まで確認。

この手の復旧では、

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

ではなく、

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

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

ピボット旅行2026-07『Come Sail Away!(後編)』

はじめに

この、安定した船旅がもたらした時間は非常に有意義でした。

逆説的に「普段と全く変わらない部屋での過ごし方」をしただけで目的地寸前まで到着するという形です。

最初のトラブルとバックアッププラン

とはいえ、最初から「どーすんだこれ」のトラブルがいきなり降りかかります。

「ThinkPadのACアダプターがない」

詰め忘れです。出発寸前までこのノートPCを開いていたため、鞄に持ち込んだ後、それをサプライバッグに入れてませんでした。ですが脳内シャアの

「ええい、打ちどころが悪いとこんなものか!」

とはならず。というのも、スマートフォンやiPadなどの充電に備えて「USBマルチアダプター」があったからです。PD対応。後はケーブルが…… ありました。それも、体のすぐそばに。

「Type-C給電可能なケーブル」が備わっているネックストラップ。この「見せてもらおうか……」をまさか本番でぶち込むとは思わず。

結果は成功。

でなければ、この船旅はかなりの制限が加わっていたと思います。「当初の予定を外しながらもバックアッププランでどうにかする」を象徴する出来事。

お風呂→頭の悪い夕飯

お風呂という性質上、テキストのみの描写になりますが…… 艦内大浴場は感動そのものでした。キチッと座って汗を流し、湯量たっぷりの浴槽に浸かる。そして眼前に広がる川﨑の夜景。

で、部屋着に着替えて自室に戻り、楽しいお弁当タイムです。正直、ピボット(コメダで夕食)という手じゃあなくてよかったと思いました。でなければ、この頭の悪い弁当に巡り会うことはできませんでした。

こちらです。

そぼろと牛タン焼き肉。それ以上に目を引くのが辛子明太子一腹。「欲望をその場に詰め込んだ」という。これを、先の「広い船室」でいただく。最高にも程があります。

フェリー飯・船内オペレーション

テンションが上がると胃袋の容量が上がるもの。頭の悪いお弁当を食べてなお「もう少しに胃に入る」ということで、オーシャン東九フェリーの名物「冷食販売機」を利用します。

とても簡単な仕組みです。

  1. 気に入った商品を自販機で購入する。
  2. パックを袋から開ける。
  3. ラウンジに置かれている業務用電子レンジで「買ったものと番号が一致する」ボタンを押す。
  4. それを食べる

見かけとか更に別守とかそんなお行儀のいいものはありません。冷食のトレイでそのままいただく。これは船客も提供側も満足いく仕掛けだと思いました。

船客のメリット

  • 船上価格が若干入っていても「熱々の食べ物が即手に入る」
  • 調理器具も食べる場所も用意されている。
  • 個室利用者なら船室でも食べられる。
  • 航行中であればどのタイミングでも食べられる。

提供側のメリット

  • 人件費の最小化。
  • 船客用の厨房を廃することで船室面積を増やせる。
  • 輸送と保管費用も最低限。

はいいとして「海を見ながら、身近な冷食を食べる」というテーマとテンションは、背徳感がマシマシです。

船室のベッド

これは、豪華なダブルベッドとかではないですが、筆者としては満足いく広さです。何せ4人部屋を1人で利用。床にそのまま寝ることも可能です。かなり深く眠ることができました。

船内アクティビティ

食事と就寝以外は本当に普段やっていることでした。即ち、

  • PCを広げる
  • ブログの記事を書く
  • オフラインで日記を書く
  • ゲームをする

です。このゲームをするというのはスマートフォンゲームではなくSteamでの『Blue Reflection Quartet』をプレイしてました。ThinkPadがしっかりと軽めの3Dゲームも動くということで。

朝風呂と食事

紀伊半島を見ながらの朝風呂も最高でした。朝ご飯は、夕飯と合わせて買っていた『キムパとチャプチェ』です。これも、「ある程度、味がしっかりしているし旅情も味わえる」パーツ。

夜明け→接岸

そこからは以下の通り。起きたのが紀伊半島の東側。

そこから朝風呂入ったりデッキでねんどろライザと記念撮影をしたり。

そして、「潮岬」で電波が安定してきたのでThinkPadで自分のサーバにSSH接続をしたりfirefly-iiiの記帳をしたり。

もちろん、着替えとパッキングも済ませています。

ゆっくりしながらいよいよ、船は徳島港に入港。瀬戸内の青い海が出迎えてくれました。(この日だけ)

ピボット旅行2026-05『そうだリフィル、買おう。(My “Favourite” Things)』

「お土産」は旅の途中や最後に買うというのが通例ではありますが、私の場合は「0日目」に始まっていました。

はじめに

道具論のお話ではあるのですが、これもまた旅行記なのでそのカテゴリーに入れます。

拙稿にあるように

筆者はアナログの筆記具「も」信頼しています。

なぜなら、

  • iPhone
  • iPad mini
  • Thinkpad

といったデジタル筆記具と

  • ほぼ日手帳
  • トラベラーズノート

を併用。というのも、『MASTERキートン』に曰く

「やめておけ」
「…………」
「拳銃の方が、ナイフよりも速いと思っているんだろう。
 だが、拳銃はデリケートな道具だ。
 弾が出ないかもしれないし、
思い通り的に当たるとは限らん。
おまけに拳銃は、
抜き、構え、引き金を引くまでに三動作(スリーアクション)……
 その点ナイフは、一動作(ワンアクション)で終わる。
この距離なら、絶対に俺が勝つ!!
どうする? それでもやってみるかね?」

の通り、それぞれ「記録する手段」は同じなのですが「得意とする射程距離」と「初速」が違うため、どうしても併用する。

(↑建前↑)
(↓本音↓)
「カッコいいから」

です。

また、昨年の谷川岳・湯檜曽温泉にて筆者は「トラベラーズノート」という極めてシンプルな作りなのにカスタマイズ性と利便性を兼ね備えた手帳を手に入れています。

このトラベラーズノートを使って分かったこと

シンプルなのに強い

これが一番の印象です。最初は縦長すぎないかと思ったあの独特のサイズ感。『ほぼ日手帳』になれている筆者としては、公式リフィルの厚い紙は慣れるのかどうかと不安だったのにスラスラ書けます。特に万年筆のインクの吸い付きが再考です。

割と雑に使える

旅に携行するだけあって

  • 重い荷物の隙間
  • 少々の水分

は耐えられます。使えば使うほど味が出てくる革の風合いも、その割と雑に使っていいんだという安心感をもたらしてくれます。

豊富なカスタマイズ

手帳本体が止め紐をつけた革。なのに、

  • 追加のリフィル
    • 無地
    • 罫線
    • スタンプ
    • スケジュール……
  • クリアファイル
  • リフィル自体を増やすアタッチメントバンド

等、用途に合わせて成長します。

ここまで旅行記に書く理由

「この旅行記群を書くにあたって活躍したから」に他なりません。「トラベラーズ」の名は伊達では無いです。

  • 旅先のメモ帳
  • ちょっとした気づき
  • 使ったお金

何よりも「レシートの台紙」として。

旅行0日目での「そうだリフィル、買おう。」

今回は東京からのスタートが19時と、サンライズ瀬戸よりも2時間ほど早い出立となります。なので、自宅からの出発も早く済みます。

最初の予定では

  1. 夢の島熱帯植物館で時間を潰す
  2. 閉館と出航の間をコメダで時間つぶし
  3. 同時に早めの夕食
  4. 船内の朝食をそこで包んでもらい
  5. フェリーターミナルに着く

予定でしたが「時は9/24」が立ちはだかります。今年の大連休がある意味災いし、「夢の島熱帯植物館、休館日」と、いきなり0日目の時間を潰すプランが無くなりました。

そうなると、「出航までどこで時間を潰すか?」の問いが生まれたときの結論が「そうだリフィル、買おう。」という、脳内に『My "Favourite" Things(英国英語で)』のあの歌い出しが聞こえてきた次第です。

この時いる場所は新木場駅。東京駅まで10数分です。そこで、こういう回答がありました。

以前購入しているこの、トラベラーズファクトリー、東京駅。ここなら夕飯・翌朝の朝食を買うことも可能です。オマケに、このタイミングで気づいた点として、「買ったばかりのノートに備え付けのスタンプを押してもらえる」おあつらえ向きのカスタマイズです。

そうして出航

乗船手続き前、その戦利品がこちら。この、表紙に押印された

  • 香川
  • 広島
  • 岡山

が旅で出回ったところ、という辺りが「ええぃ、ままよ!」のアドリブで行動しつつ「天候という変数」のピボットが成立できたわけで。

「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キャッシュのサイズやメモリ消費、レスポンスへの影響なども継続して検証していく予定です。

Page 2 of 113

Powered by WordPress & Theme by Anders Norén