2026.08.14#ops8分

サーバーを薄く保ち、計算をブラウザに任せた構成

録音、エンコード、ピッチ分析、カードの描画をすべてブラウザで動かし、サーバーは記録と通知だけを担当する構成にしました。 半年間運営してみて、この構成により何を節約でき、どこで手間が増えたのかを整理した記録です。

サーバーの仕事を減らした決定

発音矯正サービスは2日で作ったPoCから始まりました。一人で作り一人で運営するので、サーバーでやることを最小にするのが最初に決めた設計方針でした。音声をサーバーへ送って分析する構成とした場合、CPUと帯域がユーザー数に比例して増えます。その時点では、その費用を負担できるという根拠が見出せていない状況でした。

そこで重い計算はすべてブラウザへ移しました。録音、MP3エンコード、ピッチ(声の高低)抽出、フィードバックカードの描画がクライアントで動き、サーバーは結果を保存して通知を送ります。サーバーのコードが薄くなったので、Worker 2つでサービス全体が動いています。

発音チェックの構成図

画面とAPIはWorker 1つが担当し、毎日動くバッチと通知は別のWorkerとDurable Object(リクエストの間も状態を保持するCloudflareの実行単位)が処理します。データはD1とR2に、添削動画はStreamに置いています。

コストと応答速度

この構成で得たものは2つです。

1つ目は、サーバー費用がユーザー数に比例して増えないことです。ピッチ抽出はユーザーの端末で動くので、サーバーは完成した結果だけを受け取ります。運営メンバーには、実演と一緒にこの費用構造を見せました。

2つ目は、ユーザー側での応答が速いことです。録音を止めると曲線がすぐ描かれます。サーバーへの往復がないので、ネットワークが遅い環境でも分析そのものは同じ速さで終わります。

動画も同じ原則に従いました。先生がアップロードする添削動画は、Workerを経由せずブラウザからCloudflare Streamへ直接上がります。Workerがファイルを中継すればリクエスト時間とメモリをその分使うことになり、そうする理由がありませんでした。データベースには動画の識別子だけを保存します。

画面のカードとPNGの不一致

ブラウザがカードを描くということは、同じカードを描くコードが2セット存在するということでもあります。先生が画面で見るカードと、学習者に届くPNGを、別のコードが描いています。

2つの結果がずれても例外は出ません。学習者には違うカードがそのまま届きます。そのため、2つの結果が座標単位で一致するかをテストで検証しています。

提出1件に付くリクエスト数

提出のフローはクライアントが主導します。文の数だけ音声を作ってアップロードし、その後にサーバーのWorkflowと通知が続きます。

課題提出1件が生むリクエスト

提出1件には、R2書き込み12回にD1書き込み、Workflowの実行、Durable Objectの呼び出し、Discord APIの呼び出しが付きます。登校前の時間帯に数人が同時に提出すると、数十から数百のリクエストになります。運営メンバーから「使う人がいない時間にトラフィックが出る」と聞かれたとき、この形を描いてようやく説明できました。

リクエスト数だけを見ても原因は見つけにくいです。ユーザーの行動1回がリクエスト何個に広がるのかを知ってはじめて、グラフを解釈できます。

2つのWorkerがD1 1つを共有

構成の中で一番気にしているのは、Worker 2つがD1 1つを共有している点です。画面を担当するWorkerとバッチを担当するWorkerが同じテーブルを読み書きします。スキーマを変えると2つのWorkerを一緒にデプロイする必要があり、片方が入れたデータをもう片方が前提にします。

D1は複数の文を1つのトランザクションにまとめることをサポートしていないため、batchでまとめて送っています。batchは順序を保証しますが、トランザクションとは違います。途中で失敗すると前の書き込みが残ります。そのため、失敗しても再実行すれば同じ結果になるように各ステップを組む必要があります。

一人で作る規模では、この結合はむしろ楽です。サービスは1つだけでデプロイも自分がするので、調整する相手がいません。人が増えたりバッチが重くなれば、ここから問題になると見ています。

外したセッションキャッシュ

速く作るときに入れて、あとで外したものもあります。セッションデータの読み込みを速くするためKV(キーバリューストア)にキャッシュを載せたのですが、運営に入ってからの「ログインが遅い」という報告の原因が、そのキャッシュでした。

キャッシュを外したセッションデータの読み込み経路

wallTime(リクエストが始まって終わるまでの実時間)とcpuTime(そのうち計算に使った時間)を突き合わせると、計算は短く待ちが長いことがわかりました。キャッシュを確認する往復が、D1を直接読むより遅かったのです。キャッシュを外すと、セッションデータの読み込みは573msから20msになりました。

速くなるはずだと思って入れたキャッシュが、遅い原因でした。サーバーを薄く保つと決めておきながら、サーバーの経路に層を1つ足していました。

環境を分離せずに重なったcron

運営で起きたもう1つの問題は、環境の分離に関わるものです。Discord通知が二重に届く障害があり、原因は同じcronが2つの環境に重複登録されていたことでした。

cronが2つの環境に重複登録された通知の経路

見つけにくかった理由は、2つの実行がどちらも「正常完了」だった点です。エラーログが残らないため、実行記録の時刻を突き合わせてようやく重複を確認できました。今はcronを1つの環境だけに登録し、同じミスを繰り返さないようルールとして残しています。

次に変える2つ

キャッシュは、遅いことを確認してから入れます。最初から入れてあるキャッシュは効果を証明する基準線がないので、後でボトルネックになっても疑わなくなります。

環境の分離は、通知のように外へ出ていく機能を付ける前に整えます。重複して送られた通知は、ユーザーにはそのまま見えて、ログには残りません。

4つの決定と結果
決定得たもの増えた手間
計算をブラウザへユーザー数に比例しないサーバー費用カード描画のコードが2セット、突合テストが必要
動画の直接アップロードWorkerのリクエスト時間・メモリの節約アップロード失敗の処理をクライアントが担当
DB 1つを共有調整コストなし、デプロイが単純スキーマ変更が2つのWorkerに同時に影響
セッションキャッシュ (巻き戻し)なしセッションデータ読み込み 573ms
©2026 Imjurney · All Rights Reserved8 Posts | 4 Projects