🗄️

4000社の財務サイトをDBサーバーなしで動かす — sqlite を Cloudflare R2 + Workers で配信した話

に公開

はじめに

前回の記事 で、日本の有報を10年分 XBRL から正規化して sqlite に詰めた話を書きました。今回はその続きで、sqlite を Cloudflare R2 に投げて4000社規模の財務サイトを DBサーバーなしで動かした 構成の話です。

edinetty.com で動いています。スクリーナー・ランキング・異常決算レポート すべて R2 上の静的 JSON だけで動いていて、DB プロセスは一切立てていません。

全体構成

[VPS]
EDINET API ─▶ fetcher ─▶ raw sqlite
                       ─▶ normalizer ─▶ normalized sqlite (約 690MB)
                                      ─▶ exporter
                                          ├─ 社別 JSON.gz × 約4100ファイル
                                          ├─ screener_latest.json.gz (全社の最新値1ファイル)
                                          ├─ anomaly_reports.json.gz / theme_articles.json.gz など
                                          └─ sitemap.xml

                                                   ▼  (S3互換APIでPUT、差分のみ)
                                              [Cloudflare R2]


                                       [Cloudflare Workers (Hono)]


                                              ユーザー

VPS は EDINET の取り込みと正規化、JSON 生成までしか担当していません。フロントから VPS には一切叩きに来ません。読み込みは全部 Workers 経由で R2 を見ます。

「DBサーバーを置かない」を最初に決めた

EDINET の正規化データはほぼ Read-only なバッチ更新で、ユーザー入力で書き換わる箇所がありません。つまり

  • 個別ユーザーごとの状態を持たない
  • 検索/絞り込みはあるが、結果の母集団は4000社程度で小さい

この性質に対して RDB を別途立てるのは過剰で、VPS 上の sqlite を WebAPI 経由で晒すのも、可用性とインフラ管理が増えるだけで嬉しくないです。

サーバーを立てずに、静的 JSON を CDN から返すだけにしたい」というのが最初に決めた要件でした。

社別 JSON.gz と「集約 JSON.gz」を両方作る

最初は社別 JSON だけで全部やろうとしましたが、これは詰まります。

スクリーナー(条件指定で銘柄を絞り込む画面)で「ROE 15% 以上 × 自己資本比率 50% 以上」のような条件を投げたとき、4000社の社別 JSON を全部 fetch していたらリクエスト数が爆発します。

なので exporter で2系統の JSON を作ります。

用途 ファイル サイズ 中身
個社ページ company/E12345.json.gz × 4000 約 8KB / 社 全期歴の財務・指標・AI分析
一覧・絞込 screener_latest.json.gz 約 1MB 全社の最新FYのみ・絞込に使う指標だけ

スクリーナーは screener_latest.json.gz 1ファイルだけ取って、ブラウザ側で絞り込みます。約 1MB なので CDN キャッシュが効けば一発で返ります。詳細が必要な個社ページに来たときに初めて company/<edinet_code>.json.gz を取りに行きます。

集約ファイルは他にもいくつか作っています。

  • screener_articles.json.gz — スクリーナーの解説記事(テーマ別ガイド)
  • theme_articles.json.gz — テーマ別の銘柄まとめ記事
  • anomaly_reports.json.gz異常決算レポート(売上急増/V字回復/赤字転落 などの自動検出記事)

中身は Markdown 本文 + メタデータ。Workers 側で MD を HTML に変換して返しています。

差分だけアップロードする (SHA256 manifest)

毎回4000ファイルを R2 に PUT し直すと、変わってないファイルもアップロードされて R2 の Class A オペレーション(書き込み)課金が無駄に増えます。

R2 の HEAD でバイト比較してもいいのですが、毎回4000回 HEAD を投げる方が遅いし HEAD も Class B オペレーションでカウントされます。

そこで R2 上に SHA256 マニフェスト を1ファイル置いて、ローカルの hash と突き合わせる方式にしました。

manifest.json (R2に1個):
{
  "company/E00004.json.gz": "ab12cd...",
  "company/E00006.json.gz": "ef34gh...",
  ...
}

exporter は走り出しに manifest を1回 GET、各社の JSON を生成するたびに hash を計算して manifest と比較、変わっていたら PUT、最後に manifest を1回 PUT します。

これで「3月決算の有報が一斉に出る6月末」のように更新が集中する日でも、変わった社だけ PUT されます。普段の差分は数十社〜数百社程度なので、夜間バッチが軽く終わります。

コストとリクエスト数の感覚

R2 の課金は ストレージ + Class A (書き込み) + Class B (読み込み) で、egress は無料です。

  • ストレージ: 全 JSON 合計で約 50MB 程度(圧縮後)。10GB まで無料枠なのでストレージ料金は0円
  • 書き込み (Class A): 1日2回のバッチで差分のみ。普段は数百 PUT 程度。月100万回まで無料枠。
  • 読み込み (Class B): ユーザーアクセス分。Workers から R2 を読むときも Class B カウント。月1000万回まで無料枠。

検索流入が月数百〜数千程度の現状では、Cloudflare 側は全部無料枠に収まっています。Workers も無料枠(10万リクエスト/日)に収まっていて、Cloudflare からの請求は0円です。

ただし「サーバー代0円」ではありません。EDINET の取り込み・正規化・JSON 生成のためのバッチ処理は VPS 上で動かしているので、その VPS 代は別途発生しています。配信側だけ Cloudflare の無料枠で賄えている、という構成です。

ちなみに以前はパイプラインも自宅のラズパイで回していて、その頃はドメイン代だけで済んでいました。データ量と処理時間が増えてきたタイミングで VPS に移しています。「個人開発で配信を Cloudflare に寄せる構成」は、バッチ側をどこで動かすかで月コストの体感が変わります。

将来 PV が10万/月を超えたら Cloudflare の有料枠に入りますが、それは贅沢な悩みです。

Workers (Hono) 側

Workers ランタイムから R2 バケットへは R2 Bindings で直接アクセスできます。S3 SDK を使う必要はなく、env.BUCKET.get(key) のような形で取れます。

個社ページのコードはだいたいこんな雰囲気です。

app.get("/company/:code", async (c) => {
  const obj = await c.env.BUCKET.get(`company/${c.req.param("code")}.json.gz`);
  if (!obj) return c.notFound();
  const data = JSON.parse(await obj.text());  // gzipはR2側で展開
  return c.html(renderCompanyPage(data));
});

R2 オブジェクトを Content-Encoding: gzip 付きでアップしておくと、Workers 側では透過的に展開された body が読めます。エッジで HTML を組み立てて返しているので、レスポンスは早いです(edinetty.com で実測してもらうのが早いです)。

落とし穴

実装中に踏んだ細かいところを残します。

1. R2 の Content-Encoding: gzip と SDK の自動展開(manifest 限定)

社別 JSON など読むだけのファイルは Content-Encoding: gzip を付けて上げると Workers 側で透過的に展開された body が読めて便利です。ただし前述の SHA256 manifest だけは話が別で、S3 互換 SDK で GET すると Content-Encoding: gzip の値を見て勝手に展開して返してくる実装があり、展開後の body で hash を取ると保存時と一致しなくなります。manifest だけは ContentEncoding を付けずにアップロードして、常に生 gzip として読む形に揃えました。

2. INSERT OR IGNORE と差分アップロードの組み合わせ

normalizer 側で先勝ち (INSERT OR IGNORE) にしていると、訂正有報の値が原本を上書きできません。差分アップロード側は「sqlite から JSON 生成 → hash 比較」なので、sqlite が古いままだと R2 も古いままです。差分アップロードは正しさを保証しないので、上流が正しく更新される前提が必要です。

3. sitemap.xml は明示的に作る

R2 にオブジェクトを置いただけでは検索エンジンには見つけてもらえません。sitemap.xml を別途生成して R2 に置き、robots.txt から参照させる必要があります。新規ドメインの場合これをサボると、いつまで経ってもインデックスされません(しました)。

まとめ

EDINET のような Read-only でバッチ更新が中心のデータ は、DB サーバーを立てずに sqlite + R2 + Workers で十分配信できる、というのが今回の結論です。

  • ストレージ・読み書きとも Cloudflare の無料枠に収まる規模感
  • 差分アップロードで毎日のバッチ更新も軽い
  • 個社の詳細は社別 JSON、一覧・絞込は集約 JSON、と用途で分ける

この構成は EDINET 以外でも、官公庁オープンデータや書籍メタデータなど「公開データのアグリゲータ」系には素直にハマる気がします。

edinetty.com はこの構成で動いています。スクリーナーや異常決算レポート を触っていただいて、もし表示が遅いとか挙動が変なところがあれば教えてください。

Discussion