アゼルバイジャンで車両仲介業を営み、米国から事故車を輸入している場合、直面するソフトウェアの問題はシリコンバレーのスタートアップとは全く異なります。100万人の同時接続ユーザーに向けて最適化する必要はありません。求められるのは、明確さ、稼働率、そして時差が12時間あるオークションハウスと連携しながら、真夜中に自分自身で問題を解決できる能力です。これこそが、私がAutoMaklerを構築した際に置かれていた状況でした。このプラットフォームは、ライブオークションのスクレイピングやCarfaxの照会から、配送見積もり、決済処理に至るまで、あらゆる業務を処理します。これは、実際の顧客にサービスを提供する本番環境のシステムであり、ほとんどの開発者が「徹底的に退屈なスタック」と呼ぶものの上で動作しています。

誰もプレゼンしたがらないスタック

Reactはありません。Vueもありません。RedisもCeleryも、WebSocketサーバーもありません。バックエンドは純粋なPythonを用いたFastAPIです。データベースはPostgreSQL。フロントエンドは、Jinja2テンプレート、Bootstrap、そして少量のバニラJavaScriptを使用したサーバーサイドレンダリングのHTMLです。スクレイピングにはPlaywrightを使用しています。すべては、HTMLを直接配信する単一のPythonプロセスとして動作します。

ビルド工程はありません。監査すべきnode_modulesフォルダも、設定すべきトランスパイラも、追いかけ続ける必要のあるフロントエンドフレームワークの目まぐるしい変化もありません。デプロイする際、私が動かしているのはPythonファイルとテンプレートであり、バンドラーのパイプラインをオーケストレーションしているわけではありません。このシンプルさは妥協ではありません。それこそが目的そのものなのです。

メッセージブローカーなしでジョブをキューイングする方法

ライブオークションのスクレイピングを同期的に行うことはできません。Playwrightがページを読み込み、JavaScriptを実行し、データを抽出するのに、1回のスクレイピングに数秒かかることがあります。この間、ユーザーを待たせる(ブロックする)ことは選択肢にありません。標準的な手法では、Redisをインストールし、Celeryを設定し、ワーカープールを立ち上げることが推奨されます。私はそのすべてをスキップしました。

代わりに、AutoMaklerはPostgres自体をジョブキューとして使用しています。ユーザーがスクレイピングを実行すると、アプリケーションはtasksテーブルにステータスがpendingの新しい行を書き込みます。asyncioのバックグラウンドタスクがその行を拾い上げ、ブラウザによるスクレイピングを開始します。その間、ブラウザは軽量なエンドポイントに対して3秒ごとにポーリングを行い、ステータスを確認します。行がcompletedに更新されると、ページがリフレッシュされて結果が表示されます。

このパターンが機能するのは、ポーリングの間隔が、レスポンスを感じられるほど短く、かつサーバーに負荷をかけないほど十分に長いからです。コンピュータにとって3秒は永遠のようなものですが、外部のオークションサイトを待っている人間にとっては、ほとんど気にならない時間です。データベースはネイティブに並行性を処理します。また、ジョブは単なるPostgresの行であるため、CeleryのログやRedisのキーを掘り返す代わりに、シンプルなSQLクエリでキューを検査することができます。

ワーカープールなしでサーバーを稼働させ続ける方法

ブラウザの自動化はメモリを大量に消費します。一度にあまりに多くのPlaywrightインスタンスを起動すると、サーバーは崩壊します。一般的な解決策は、並行数の制限を備えた管理されたワーカープールであり、多くの場合、先ほど述べたRedisとCeleryの組み合わせによって支えられています。私は、Pythonのたった一行、asyncio.Semaphoreを使用しています。

セマフォは、同時に実行できるブラウザインスタンスの数を制限します。新しいスクレイピングリクエストが来ると、すぐにスロットを確保するか、空きが出るまで待機します。これらすべては同じプロセス内で行われます。失敗する可能性のある外部のオーケストレーターも、静かに死んでしまうワーカープロセスも、監視すべき追加のインフラもありません。メモリ使用量は予測可能な状態に保たれ、サーバーを保護するコードは、デプロイメントマニフェストの中に隠されるのではなく、それを使用するコードのすぐ隣に存在します。

1つのコールバックURLで送金をルーティングする

決済処理において、どうしても変えられない制約がありました。使用している決済ゲートウェイでは、マーチャントアカウントごとにコールバックURLをちょうど1つしか設定できません。しかし、私はその単一のアカウントを通じて、2つの異なるプロジェクトの取引を処理する必要がありました。2つ目のマーチャントプロファイルを作成することは、小規模な仲介業者には割く時間のない、追加の手数料、追加のコンプライアンス、そして追加の事務作業を意味します。

解決策は、顧客をゲートウェイに送る前に、注文IDの文字列にプロジェクト名を直接エンコードすることでした。コールバックが私のサーバーに届くと、AutoMaklerはそのIDをデコードして、どのプロジェクトの支払いであるかを特定し、通知を正しい内部ハンドラーにルーティングします。既存のロジックには一切手を加えていません。これは「加法的な設計(additive design)」です。決済フローを書き換えたのではなく、識別子に少しだけコンテキストを持たせただけなのです。後から振り返れば当たり前のように思えるハックですが、アーキテクチャ上の複雑な調整を何時間も省いてくれるものです。

WebSocketなしで動作するチャット

カスタマーサポートのチャットでは、エンジニアはつい妥協してWebSocketを導入してしまいがちです。私はアプリ内メッセージングを必要としていましたが、同時にインフラのフットプリントを最小限に抑える必要もありました。そこで、オークションのスクレイピングを支えているものと同じポーリング戦略を再利用することにしました。

メッセージはPostgresに保存されます。ユーザーがメッセージを送信すると、テーブルに書き込まれます。クライアントは更新を確認するためにポーリングを行い、UIは新しいメッセージや既読ステータスをニアリアルタイムで反映します。会話テーブルが肥大化しても高速性を維持するため、アクティブな会話の未読メッセージのみを対象とするPostgresの部分インデックス(partial index)を追加しました。これにより、データベースは古い履歴のスキャンにリソースを浪費することなく、クエリプランナーは絞り込まれたインデックスのレンジスキャンによってほとんどのチャット検索を処理できます。

数秒のレイテンシが許容されるサポートチャットであれば、これで全く問題ありません。ユーザーは必要なフィードバックを得られますし、私は期限切れのWebSocket接続のデバッグや、個別のソケットサーバーの管理に追われることもありません。

正直なデメリット

このアーキテクチャには明確なトレードオフがあり、そうでないふりをするのは不誠実です。ポーリングは頻繁に通信が発生します(chatty)。3秒ごとに、すべてのアクティブなクライアントがサーバーにリクエストを送ります。帯域幅とクエリの負荷は、永続的なソケット接続が要求するものよりも高くなります。Pythonプロセスが再起動した場合、タスクを引き継ぐ外部ワーカーが存在しないため、実行中のバックグラウンドタスクは即座に停止してしまいます。しかし、タスクは軽量であり、再試行のコストも低いため、私はこれを許容しています。ブラウザのスクレイピングが失敗しても、ユーザーが単に再実行すればよいだけだからです。

また、このアプローチには限界もあります。もしAutoMaklerが数千件の同時スクレイピングを処理する必要が生じた場合、ポーリングを用いたシングルプロセスモデルでは負荷が限界に達するでしょう。しかし、それは私のビジネスではありません。私が求めているのは、数千人ではなく、数十人の同時接続ユーザーに対する信頼性であり、