Recotem 2.0: 設定ファイル駆動のCLI/ライブラリへ全面刷新

推薦システム構築ツールRecotemを 2.0 として全面的に書き直しました。これまでの Django + Vue + Celery + PostgreSQL + Redis という7サービス構成の Web アプリケーションから、YAML レシピを書いて recotem train / recotem serve を実行するだけのシングルパッケージ Python CLI/ライブラリに刷新しています。

なぜ書き直したのか

旧 Recotem は Web UI を中心に据えていたため、利用者は管理画面から学習ジョブを登録し、結果を確認し、モデルを配信する仕組みでした。便利な一方で、「ちょっと推薦モデルを動かしたい」「既存のデータパイプラインに組み込みたい」というケースでは複雑な構成です。PostgreSQL / Redis / Channels / Daphne / nginx といったサービスを立ち上げる必要があり、コンテナイメージも複数に分かれていました。

Recotem 2.0 では発想を逆転させ、「Web UI をやめて、設定ファイル(YAML レシピ)で完結させる」方針にしました。1 つの YAML が 1 つのモデル、1 つの /predict/{name} エンドポイントに対応します。インストールは pip install recotem の一発、配布物も単一の Docker イメージです。

レシピ駆動の利用フロー

1. レシピを書く

データソース、アルゴリズム、チューニング設定を 1 つの YAML にまとめます。

name: purchase-recommender
data:
  source: csv
  path: s3://my-bucket/purchases.csv
  user_column: user_id
  item_column: item_id
training:
  algorithms: [IALS, BPR, RP3beta]
  trials: 30
  timeout_per_trial: 300

2. 学習

recotem train recipe.yaml

irspack + Optuna でアルゴリズム選択とハイパーパラメータ探索を行い、HMAC 署名付きのバイナリアーティファクトを出力します。アルゴリズム別の試行回数や試行ごとのタイムアウトを指定でき、Optuna のストレージは in-memory / SQLite / PostgreSQL から選択して並列・再開可能なチューニングが実行できます。

3. サービング

recotem serve --recipes ./recipes/

ディレクトリ内のレシピを監視し、アーティファクトが更新されたら無停止でホットスワップします。FastAPI ベースで、/predict/{name} エンドポイントが自動的に生やされる仕組みです。

廃止したコンポーネント

シンプル化のために、旧バージョンで使っていた以下のスタックは全て削除しました。

  • バックエンド: Django, Django REST Framework, Django Channels, Daphne, Celery
  • データストア: PostgreSQL, Redis
  • フロントエンド: Vue, Vite, PrimeVue, Tailwind
  • インフラ: nginx プロキシ, 推論専用サブサービス

代わりに recotem パッケージひとつで完結します。配布は PyPI と単一 Docker イメージ、Helm チャートはサービング専用に学習用 CronJob オプション付きという構成です。

データソースのプラグイン化

データソースは recotem.datasources の entry point で拡張可能なプラグイン方式に変更しました。標準で以下に対応しています。

  • CSV / Parquet: ローカルファイルシステム, オブジェクトストア (s3://, gs://, az://, abfs://, abfss://), HTTPS
  • BigQuery: GA4 のBQ エクスポート構成を想定したパターン

HTTPS データソースを使う場合、sha256 による整合性ピンが必須で、RECOTEM_MAX_DOWNLOAD_BYTES(デフォルト 256 MiB)でダウンロードサイズの上限を設定できます。リダイレクト時のスキーム変更拒否や、プライベート IP への接続拒否(SSRF対策)も組み込まれており、安心して外部 URL を指定できます。

セキュリティ設計

CLI/ライブラリ化に伴い、アーティファクトの取り回しが運用上の中心になるため、署名と検証を強化しています。

  • HMAC 署名付きアーティファクト: マジックバイト + バージョン + リザーブ + kid + ヘッダ JSON + ペイロードという構造。複数 kid を持つ KeyRing でゼロダウンタイムの鍵ローテーションが可能
  • FQCN allow-list: 復元時に読み込めるクラスを手動で列挙し、numpy.* / scipy.sparse.* の限定的なモジュールプレフィックス許可と高リスクサブモジュール拒否を組み合わせる二重防御
  • API キー認証: X-API-Key ヘッダで認証。digest は hashlib.scrypt + ドメイン分離ソルト recotem.api-key.v1 で保存
  • TrustedHost / CORS デフォルト拒否: 余計なホスト/オリジンからのアクセスを排除
  • /health/health/details の分離: 匿名アクセス可能なヘルスチェックは件数のみ、kidbest_class といったメタデータは認証付きの /health/details のみで開示
  • OpenAPI のフェイルセキュア: /docs/openapi.jsonRECOTEM_ENVdevelopment / dev / test のときのみ有効
  • 構造化ログのリダクション: structlog のチェーン先頭にリダクションプロセッサを置き、シークレットや認証情報の混入を防止

--insecure-no-auth--dev-allow-unsigned といった開発用フラグは RECOTEM_ENV でガードされ、本番環境では使えない設計です。

オブザーバビリティ

/metrics エンドポイントは RECOTEM_METRICS_ENABLED=true のオプトイン方式で、12 種類の Prometheus メトリクスを出します。

  • 推論系: recotem_predict_total, recotem_predict_latency_seconds
  • モデル状態: recotem_model_loaded, recotem_active_recipes
  • 障害系: recotem_artifact_load_failures_total, recotem_artifact_stat_failures_total, recotem_watcher_unhandled_errors_total, recotem_metadata_lookup_errors_total, recotem_recipe_rescan_errors_total, recotem_recipes_dir_scan_failures_total
  • 運用系: recotem_swap_total, recotem_bigquery_storage_fallback_total

構造化ログには train_done / train_error / tuning_aborted といった正規化済みイベントを出力し、run_id / exit_code / artifact / best_class / best_score / trials / trained_at / kid などのフィールドを統一的に持たせています。

ホットスワップとフェイルセーフ

サービング側では、アーティファクトファイルの更新を検知して無停止で読み替えます。ここに以下の堅牢性が組み込まれています。

  • Read-once プロトコル: stat → read の TOCTOU を回避
  • Stale-on-swap-fail: ホットスワップ時の復元失敗で 503 にせず、直前のモデルを継続提供
  • Lenient startup: 壊れた YAML があっても起動を中断せず、/health/detailsloaded=false / last_load_error=... で開示
  • X-Recotem-Metadata-Degraded: 1 ヘッダ: メタデータ参照に失敗した推論レスポンスを明示的にマーク
  • OOM のフォールスルー: ファイル読み込みや学習パイプラインでの OOM は意図的にラップせず伝播

チュートリアル: 1 コマンドで動かす

examples/tutorial-purchase-log/ を新設し、HTTPS で取得できる小さな公開 CSV(sha256 ピン済み)を使ったゼロセットアップのクイックスタートを用意しました。compose.yaml の tutorial フローに組み込まれており、docker compose で学習からサービングまで通せます。

テスト

ユニット / 統合 / fuzz の 3 階層で 1,420 ケースをカバーしました。

  • hypothesis によるアーティファクトローダーのバイトミューテーション fuzz
  • レシピローダーの YAML ミューテーション fuzz
  • pytest-httpserver を使った HTTPS CSV ソースの統合テスト
  • 実ファイルウォッチャーでの並列ホットスワップテスト
  • 起動時の壊れた YAML 耐性テスト

CI では pytest / ruff lint / ruff format / シークレットログ検査 / Docker ビルド / e2e (train → serve → predict) / CodeQL / Trivy を回しています。

まとめ

Recotem 2.0 は、Web UI ベースの推薦システム管理ツールから、YAML レシピ駆動の CLI/ライブラリへと舵を切りました。pip install recotem でインストールして、recotem train recipe.yamlrecotem serve --recipes ./recipes/ を打つだけでモデル学習から API 配信まで完結します。Docker, Kubernetes (Helm), オブジェクトストア, BigQuery, Prometheus といったクラウドネイティブな環境とそのまま接続でき、HMAC 署名・scrypt API キー・SSRF 対策など運用に必要なセキュリティも揃っています。

Fessのパスワードハッシュ化をBCryptに強化

Fessではユーザーパスワードの保存形式を、これまでのソルトなしSHA-256(1ラウンド)から、Spring Security v5.8互換のBCryptベースの形式へと刷新しました。既存の環境を止めることなく、ログインをきっかけに新形式へ段階的に移行できるようになっています。

変更の背景

これまでのFessはパスワードを sha256(plain) の16進文字列としてそのまま保存していました。ソルトがなくラウンド数も1回のため、万が一パスワードハッシュが漏洩した場合にレインボーテーブルやGPUによる総当たりで平文を復元されるリスクがありました。今回の対応では、保存形式を {bcrypt}$2a$10$... に切り替え、ログインごとに古い形式を新形式へ置き換える仕組みを導入しています。

パスワードハッシュ化の刷新

BCryptへの移行

新たに PasswordHashHelperorg.codelibs.fess.helper.PasswordHashHelper)を追加しました。ComponentUtil.getPasswordHashHelper() 経由で取得し、encode / matches / upgradeEncoding という公開APIを通して利用します。

保存形式にはSpring Securityの DelegatingPasswordEncoder と互換性のある {bcrypt} プレフィクス付きの文字列を採用しました。将来的にアルゴリズムを切り替える場合も、プレフィクスを見ることで旧方式と新方式を混在させたまま検証できます。

jBCryptの同梱

新規のMaven依存は追加せず、jBCrypt v0.4(ISCライセンス)を org.codelibs.fess.crypto.bcrypt.BCrypt としてソースに同梱しました。NOTICEファイルおよびlicense-plugin・formatter-pluginの除外リストもあわせて更新しています。jBCrypt 0.4は $2a$ のみをサポートし、他システムで使われる $2b$ / $2y$ は受け付けません。

既存ハッシュの互換性

プレフィクスのないレガシーな値は、app.digest.algorithm の設定(sha256 / sha512 / md5)に従って小文字16進で比較します。比較は MessageDigest.isEqual による定数時間比較で行い、未知の {id} プレフィクスや不正な値は常に false を返します。

認証フローの変更

FessLoginAssist.doAuthenticateLocal では、これまで「ユーザー名+ハッシュ済みパスワード」でDBを引いていた処理を、「ユーザー名のみで引いて PasswordHashHelper.matches で照合する」形に変更しました。BCryptはレコードごとにソルトを持つため、事前にハッシュを計算してWHERE句に入れるやり方は使えないためです。

ユーザー不在・null・レガシーhex・未知プレフィクスなどの失敗経路はすべて、applyTimingPadding でダミーのBCrypt検証を1回走らせ、成功時と同じ処理コストがかかるようにしています。これによりユーザーの存在有無が応答時間の差から推測されるタイミング攻撃を防いでいます。

ログイン時の遅延リハッシュ

既存ユーザーの旧形式パスワードは、ログイン成功のタイミングで自動的にBCryptへ置き換わります。この処理は app.password.upgrade.enabled=true(デフォルト有効)かつ upgradeEncoding が true を返した場合のみ走ります。

書き込みには UserService.updateStoredPasswordHash(username, expectedCurrentHash, newEncodedPassword) を新設し、AuthenticationManager を経由しない専用のリハッシュパスとしました。LDAPやSSOなど外部で管理されているユーザーには影響を与えないためです。

更新はOpenSearchの楽観的並行制御で原子性を担保しており、読み出し時の _seq_no / _primary_termIndexRequestBuilder.setIfSeqNo / setIfPrimaryTerm に伝搬させています。バージョン競合はDEBUGレベルでログ出力するにとどめ、ログインそのものは成功扱いのままとします。

書き込みパスの整理

UserService.changePasswordAdminUserAction.getUser、初期管理ユーザーをブートストラップする SearchEngineClient では、ComponentUtil.getPasswordHashHelper().encode(plain) を直接呼ぶように変更しました。これにより UserService から FessLoginAssist への不自然な依存を取り除いています。

新たなコードからパスワードを書き込む場合も、FessLoginAssist.encryptPassword は使わず PasswordHashHelper.encode を呼ぶようにしてください。

新しい設定項目

fess_config.properties に以下の設定を追加しました。

  • app.password.algorithm=bcrypt : 新規書き込み時のアルゴリズム
  • app.password.bcrypt.cost=10 : BCryptのコストパラメータ
  • app.password.upgrade.enabled=true : 旧形式からの遅延リハッシュの有効化

app.digest.algorithm はレガシーハッシュの検証用として残していますが、今後は新規書き込みには使われません。

ダウングレード時の注意

本変更以降、保存されるパスワードは {bcrypt}$2a$10$... の形式になります。BCrypt対応前のFessへロールバックするとこれらの値は検証できなくなるため、管理者パスワードのリセットが必要になります。運用でダウングレードを想定する場合はリリースノートでの周知と、手順の準備をおすすめします。

スキーマやAPI、i18nリソースへの変更はありません。

詳細

詳細はPR #3116を参照してください。

Fessのクリックログ・お気に入りログの自動パージに対応する

FessではPurgeLogJobによって古い検索ログが定期的にパージされる仕組みがあるが、クリックログとお気に入りログは対象外で、OpenSearch上に無制限に蓄積され続けていた。今回、これらのログも検索ログと同じ保持日数でパージされるようにした。

背景

Fessは検索体験の分析のために、以下の3種類のログをOpenSearchに記録する。

  • 検索ログ(Search Log) — 検索クエリやヒット件数などの情報
  • クリックログ(Click Log) — 検索結果がクリックされた際の情報
  • お気に入りログ(Favorite Log) — 検索結果がお気に入り登録された際の情報

クリックログとお気に入りログは、検索ログを親データとする子データとして扱われる。つまり、親となる検索ログが削除されても、子であるクリックログ・お気に入りログは残り続けるため、親を参照できない孤立したデータが蓄積していく状態となっていた。

PurgeLogJobはこれまで検索ログ・ジョブログ・ユーザー情報ログ・クロール情報をパージしていたが、クリックログとお気に入りログは対象外だったため、長期運用しているFess環境ではインデックスが無制限に膨らむ問題があった。

変更内容

SearchLogServiceに削除メソッドを追加

SearchLogServiceに、クリックログとお気に入りログを日数指定で削除するメソッドを追加した。

public void deleteClickLogBefore(final int days) {
    clickLogBhv.queryDelete(cb -> {
        cb.query().setRequestedAt_LessEqual(
            systemHelper.getCurrentTimeAsLocalDateTime().minusDays(days));
    });
}

public void deleteFavoriteLogBefore(final int days) {
    favoriteLogBhv.queryDelete(cb -> {
        cb.query().setCreatedAt_LessEqual(
            systemHelper.getCurrentTimeAsLocalDateTime().minusDays(days));
    });
}

クリックログはrequestedAt(検索リクエスト日時)、お気に入りログはcreatedAt(作成日時)を基準に、指定日数より古いドキュメントを削除する。

PurgeLogJobにパージ処理を追加

PurgeLogJob.execute()の検索ログパージの直後に、クリックログとお気に入りログのパージ処理を追加した。

// purge click logs
try {
    final int days = ComponentUtil.getFessConfig().getPurgeSearchLogDay();
    if (days >= 0) {
        searchLogService.deleteClickLogBefore(days);
    } else {
        resultBuf.append("Skipped to purge click logs.\n");
    }
} catch (final Exception e) {
    logger.error("Failed to purge click logs.", e);
    resultBuf.append(e.getMessage()).append("\n");
}

お気に入りログ側もほぼ同じ実装となっている。例外が発生しても他のパージ処理に影響しないよう、それぞれ独立したtry-catchで囲んでいる。

保持日数は検索ログと共有

クリックログ・お気に入りログの保持日数には、専用の設定は新設せず、既存のpurge.searchlog.dayを共用する設計とした。

これは、前述のとおりクリックログとお気に入りログが検索ログの子データだからである。親の検索ログが消えた時点で参照元を失うため、検索ログと同じ保持期間でパージするのが自然な振る舞いとなる。

将来的に保持期間を個別に設定したくなった場合は、専用の設定プロパティを追加し、未指定時はpurge.searchlog.dayにフォールバックする形で拡張できる。

まとめ

Fess 15.6.0から、purge.searchlog.dayで指定した日数に基づいて、検索ログ・クリックログ・お気に入りログが同時にパージされるようになる。これまで長期運用でクリックログやお気に入りログが肥大化していた環境では、アップグレード後に古いデータが自動的に整理されるため、OpenSearchのストレージ負荷軽減が期待できる。