カテゴリー: ガジェット Page 1 of 112

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

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のような最低保存期間を気にする必要はありません。短時間だけ存在したファイルは、その存在していた時間だけが課金対象となります。

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

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

ワイヤレスイヤホン・新調。(Anker Liberty Pro4選定)

2024年以来のワイヤレスイヤホン、導入です。

Bluetoothイヤホンを買い換えました。筆者が最終的に選択したのはAnkerのSoundcore Liberty 4 Proです。

それまで使用していたモデルは、同社のSport X20。イヤーフックがあるため落としにくいという利点がありましたが、アクティブノイズキャンセリングの効き具合に関してはやや物足りなくなっていったというのも事実です。特に移動時や周囲の雑音が気になる環境では、より強力な遮音性能がほしいというのも購入のきっかけである。

選択肢Pro 4 かPro 5か

SportはX20が未だ現役ということから、イヤーフックなしでも使える機種を選定していきます。選択肢として検討したのは、

  • 昨年登場したLiberty Pro 4
  • 今年新たに発表された後継機のLibetty Pro 5です。

最新モデルであるPro 5は性能面を見れば非常に魅力的ではありましたが、ここに一つの問題が立ちはだかります。

落としても授業料で済むか?

筆者は過去に、当時のSONYのハイエンドモデルを側溝に落として紛失してしまった経験があります。いくら2年使っていたとはいえ、高価格なイヤホンを失った際の精神的・金銭的ダメージは小さくなく、だからこそこれまである程度割り切って使える価格帯(そして落ちにくい形状)の製品を選んできました。

なので、Pro 5の約2.5万円強という価格設定は、万が一の紛失を想像した際にやや心理的ハードルが高いというものがあります。

一方でPro 4は、実売1万円台半ばから後半という価格帯でありながら、十分強力なノイズキャンセリング性能を確認。万が一の紛失時のリスクを許容範囲に抑えつつ、日常の静寂を手に入れるという点も協力でした。

確定、Pro 4

また、Pro 4のスマート充電ケースにディスプレイが搭載されている点もポイントでした。ケース側でノイズキャンセリングの切り替えやモード設定を直接行えるため、歩行中などに耳元のイヤホン本体を直接触る機会が減る。これは耳元での操作ミスによるポロリと落とす事故をある程度防げます。

それ以上に、実機を装着して「自分を選んだ」感が強いのものPro 4でした。

使用感、気になったことなどは改めてご報告します。

Page 1 of 112

次

Powered by WordPress & Theme by Anders Norén