← プロジェクト一覧

2026-08-28

プロ野球志望届のデータを集めるサーバーレスアプリ

目次

概要

毎年秋のドラフト前に、プロ入りを目指す高校生と大学生が「プロ野球志望届」を出します。提出した選手の名前は公開されるのですが、年度とカテゴリでページが分かれていて、今年は何人出したのか、都道府県ごとの分布はどうなっているのかを横断で見ようとすると、手作業で数えるしかありません。

そこを自動化したのがこのシステムです。公開されている情報を定期的に取得してS3に貯め、Webの画面から年度や種別で切り替えて見られるようにしています。個人開発で、月額のコストは無料枠に収まる範囲を前提に組みました。

公開範囲には線を引いています。選手の実名を含むデータは認証の内側に置き、ログインなしで見られるのは人数や都道府県分布といった、個人を識別できない集計値だけです。相手は未成年を含む個人なので、ここは機能性より優先しました。削除請求を受けたときに掲載を止める仕組みも先に入れてあります(後述)。

アーキテクチャ構成

全体像は別ページに図を置きました。

アーキテクチャ図を見る

構成はよくあるAWSサーバーレスの形です。

  • 画面: React + TypeScript + Vite。ビルド成果物をS3に置き、CloudFrontから配信するSPA
  • API: API Gateway + Lambda (Node.js 22.x)
  • 認証: Cognito User Pool。フロントはAmplify経由でトークンを取得し、API呼び出しに付ける
  • データ: S3上のJSONファイル
  • 収集: スクレイピング用のLambdaをEventBridgeの定期実行で起動。結果はS3に書き、実行結果はSESでメール通知
  • 監視: CloudWatchのメトリクスとアラーム、通知はSNS経由
  • IaC: AWS CDK v2 (TypeScript)。リージョンはap-northeast-1

Lambdaは用途ごとに4本あります。取得したHTMLをまとめてパースするスクレイピング用だけ512MBで、データ処理・API・ウォームアップは128MBです。もとはどれも256MBでした。コードには「処理速度49%向上」「50%コスト削減」というコメントを添えているのですが、その根拠にした計測結果はリポジトリのどこにも残していません。数字だけが残って測り方が残っていないので、次に見直すときはまた同じ計測からやり直しになります。用途が違う関数を同じサイズで揃えたままにしない、という方針自体は今も妥当だと思っています。

CDKのコードは、スタック1ファイルに全部書くのをやめて、機能ごとのconstructに割りました。今あるのはS3、Lambda、Cognito、API Gateway、フロントエンド、CloudFront、監視、予算、コスト最適化、ウォームアップの10個です。分けたのは設計思想というより実務上の都合です。1ファイルに詰めると、cdk diffの差分がどの機能を触った結果なのか読み取れません。

API設計

認証を二重にしている理由

実名を含むデータを返すルートは、API GatewayのCognito authorizerで守っています。それに加えて、Lambdaのハンドラ入口でもcognito:groupsadminが入っているかを見て、入っていなければ403を返します。

ただし、Lambda側がやっているのはトークンの検証ではありません。API Gatewayのオーソライザがリクエストコンテキストに書き込んだクレームを読むだけで、署名は確かめていません。だからこの二重化が効くのは、CDKのメソッド設定を書き換えてauthorizerが外れたケースです。オーソライザが無くなればクレームも来なくなるので、Lambdaは判定に失敗して403で止まります。判定できないときに通すのではなく止める、いわゆるfail closedです。

逆に、Lambdaを直接invokeされるケースは今の実装では守れません。呼び出し側がペイロードを組み立てられる以上、クレームは好きに詰められるからです。ここを本気で塞ぐならLambda側でJWTの署名検証まで持つ必要があって、そこまではやっていません。関数のinvoke権限をIAMで絞ることに頼っている状態です。

判定そのものは1つの関数に寄せ、そこだけをテストで固めました。二重に持ったまま片方の更新を忘れると、守っているつもりの穴が残ります。

ルートの役割分担

エンドポイントの分け方は2層です。

個人を識別しない集計・補助のGETだけが認証不要で、/statistics/schools/years/available、あとはヘルスチェックが該当します。それ以外、つまり選手の実名を含むデータも、収集の実行や実行履歴といった運用系も、まとめてCognito認証かつadminグループ必須です。ログインしただけのユーザーとゲストの見えるものは変わりません。実名を見るには管理者である必要があります。

定義していないパスに来たリクエストも、キャッチオールのルートごと認証必須側に倒しています。ここを公開側に倒すと、ルートを1本追加し忘れた瞬間に穴になるので。

Lambdaが1本しかない話

APIのルートは複数ありますが、統合先のLambdaは実質1本です。API Gateway側でパスとメソッドごとに認証設定を変えて、処理そのものは同じ関数がパスを見て振り分けています。

ルートごとに関数を分けるほうが教科書的には正しいはずです。分けなかったのは、この規模だと1本のほうが得だと判断したからでした。関数が増えれば、そのぶんコールドスタートする箱が増えます。デプロイ対象も増えるし、共通のS3読み込み処理をどう共有するかという話も出てくる。アクセスがごく少ないシステムで、そこに手間をかける理由が見つかりませんでした。

代償はハンドラが太ったことです。ファイルが1,032行、うちハンドラ本体だけで約817行あります。切り出せているのは年度一覧の取得、既定年度の決定、ページング値の解決といった補助関数だけで、ルートごとの処理はifを並べてハンドラの中にそのまま書いてあります。読みづらい自覚はあります。トラフィックが増えるか、ルートがもう5本増えたら分割する、というのが今の線引きです。

データ構造

DynamoDBをやめてS3に置いた

最初はDynamoDBを想定していました。実際、設定ファイルにはテーブル名を持つ項目が今も残っています。ただ実体としてのテーブルリソースは作っておらず、選手データはすべてS3上のJSONです。API LambdaがS3を直接読みます。

やめた理由は、データの形とアクセスの仕方がDynamoDB向きではなかったからです。

保存されるのは年度とカテゴリの組み合わせごとに1ファイル、中身は選手の配列です。更新は年に1シーズンだけ、まとめて全件を書き換えます。読み出しは「2024年の高校生を全部ください」がほとんどで、1件だけ引くケースは画面上ほとんど発生しません。

この形をDynamoDBに載せると、パーティションキーの設計を考え、年度横断で見るためにGSIを足し、全件取得のためにページングを回すことになります。対してS3なら、キーを組み立ててGetObjectを1回叩けば終わりです。1ファイルの中身もLambdaのメモリに丸ごと乗る大きさに収まります。安く、単純に済むほうを取りました。

そのぶん、DynamoDBならインデックスやクエリが引き受けてくれる部分を、素朴な方法で埋めています。集計を返す/statisticsは、リクエストが来るたびに高校生と大学生の年度ファイルを両方GetObjectして、その場で配列を回して都道府県別・ポジション別を数え直しています。キャッシュ用のオブジェクトは置いていません。年度の一覧も、ListObjectsV2でプレフィックス配下を並べてキー名から正規表現で4桁を抜き出しています。要するにファイル名がインデックス代わりで、命名規約が崩れた瞬間に年度が見えなくなる作りです。

インデックスを持とうとした痕跡はあります。収集側が種別ごとにインデックス用のJSONを書いているのですが、これはAPIから一度も読まれていません。しかも中身はavailableYearsが現在年、totalRecordsが0、filesが空配列という、書式だけ作って値を埋めていないスタブです。誰も読まないので誰も壊れていることに気づかない、という典型的な置き土産になっています。

引き受けたトレードオフ

素直に困っている点もあります。

部分更新ができません。1人分の情報を直したいときも、ファイル全体を読んで書き戻します。

検索の置き場所も歪です。氏名や学校名での絞り込みはブラウザ側でやっていて、フロントは一覧APIを500件ずつページングしながら最大40ページまで取り切り、全部メモリに載せてからincludesでフィルタしています。件数が増えるほど、絞り込みの前に転送するデータが増える。API Gatewayには/players/searchというルートが定義してあるのですが、ハンドラ側に対応する処理が無いので、ここに来たリクエストは認証を通ったうえで404になります。ルートだけ先に生やして中身を作らなかったぶんが、そのまま残っている状態です。

そして同時書き込みに弱い。今は書き込み経路が定期実行と管理者の手動実行しかなく、実質的に競合しないので通していますが、書き手が増えたら壊れます。

このあたりは全部、今のデータ量なら問題にならないという理由で許容しているだけで、正しさで選んだわけではありません。件数が二桁増えたらDynamoDBに移す、という前提で置いています。

設定ファイルにテーブル名が残っているのは単なる消し忘れです。ただ、消すときに使わなかった理由を書き添えておかないと、次に触る人が同じ検討を最初からやり直すことになります。設定だけ消して判断の理由を残さないのは、負債を減らしたように見えて減っていません。

設計上の判断と学び

定期実行を本番だけに置いた

データ収集はEventBridgeの定期実行ルールで起動します。このルールは本番環境にしか作っていません。

開発環境にも同じルールを置くと、取得元のサイトへ開発と本番の両方からアクセスすることになります。相手にとっては負荷が二重になるだけで、こちらが得るものは何もない。開発環境で定期実行の挙動を確かめたいときは、テスト用のルールを手で有効化して、終わったら戻す運用にしました。

CDKで環境ごとに構成を変えるのは、パラメータで分岐が増えるぶん少し気持ち悪くはあります。それでも、相手のサーバーに払うコストのほうを優先しました。

稼働期間をコードの外に出した

取得対象が更新される時期は限られていて、年間を通して取りに行く意味がありません。

かといって、稼働期間をコードに書き込むと、期間が変わるたびにデプロイが必要になります。そこで期間の開始日と終了日をパラメータストアに置き、EventBridgeのルールは通年で発火させて、Lambdaが起動直後に「今日が期間内か」を判定して、期間外なら何もせず終了する形にしました。期間を変えるときはパラメータを書き換えるだけで済みます。

判定は安全側に倒してあります。パラメータが未設定でも、値が壊れていても、取得に失敗しても、すべて「期間外」として実行しません。設定ミスで意図せず通年アクセスが走るほうが、走らないより害が大きいからです。

日付の判定は日本時間の暦日で行っています。Lambdaの実行環境はUTCなので、そのまま日付を取ると期間の端の扱いを取り違えやすい。明示的に日本時間へ寄せてから比較しています。

削除リストを2箇所で通す

選手本人や保護者から掲載停止の請求が来たときのために、除外リストをS3に置いています。ここで少し考えたのが、除外をどこでかけるかでした。

収集時に弾くだけだと、請求を受け付ける前に保存済みだった過年度のファイルはそのまま配信され続けます。逆に配信時だけだと、次の収集でまた書き戻ってきます。結局、保存する前と、S3から読んで返す前の両方で通すことにしました。

このリストは読み込みに失敗したときも安全側です。ファイルが存在しなければ「除外なし」として通常どおり動きますが、存在するのに読めない場合はエラーを投げて処理を止めます。収集側なら保存せずに中止し、配信側なら503を返します。読めないまま続行すると、削除請求済みの人を配信してしまうので。

除外リスト自体が個人情報なので、ログには件数しか出していません。障害調査のときに氏名が見えないのは正直やりにくいのですが、そこは諦めました。

同期invokeという負債

管理画面から収集を手で実行できるようにしてあります。この経路が、API Lambdaからスクレイピング用LambdaをRequestResponseで直接呼び出す形になっています。

そうしたのは完了検知が楽だったからです。同期で呼べば結果がそのまま返ってくるので、実行が終わったかどうかを別途ポーリングする画面を作らずに済みました。ただしAPI Gateway経由の呼び出しには上限があるので、収集対象が増えて処理が長引けば、その手前で応答が切れます。

素直に直すなら、キューを1本挟んで、実行状況はS3に書いた履歴を見に行く形にすべきです。すでに実行履歴のファイルは持っているので、材料は揃っています。優先度を上げていないだけで、これが正しい形だと思ってこうしているわけではありません。忘れる前にここに書いておきます。

無料枠とアラームの上限

CloudWatchのアラームには無料で使える個数の上限があります。監視したい対象を関数ごとに素直に並べると、この数では足りません。

そこで、関数ごとにエラーアラームを立てるのをやめて、スクレイピング・データ処理・APIの3関数のエラー件数を足し合わせた1つのメトリクスにまとめ、それにアラームを1本かける形にしました。どの関数で起きたかはアラームでは分かりません。

ここで手を抜いたところがもう1つあって、ダッシュボードのウィジェットも関数名のディメンションを指定していません。AWS/Lambdaの名前空間をそのまま集計しているだけなので、ダッシュボードを開いても内訳は出ません。結局、通知が来たらCloudWatch LogsとLambdaのコンソールを関数ごとに開く、という一番原始的な切り分けをしています。

個人開発で無料枠に収めようとすると、こういう選択が普通に出てきます。仕事なら真っ先に却下されるでしょう。ただ、上限があるおかげで何を捨てるかを真面目に考えることにはなりました。

使っている技術

  • フロントエンド: React 19 / TypeScript / Vite / Material-UI / Redux Toolkit (RTK Query) / Chart.js
  • バックエンド: AWS Lambda (Node.js 22.x) / API Gateway / Amazon S3 / Amazon Cognito / Amazon SES
  • 配信: CloudFront + S3
  • 定期実行と監視: EventBridge / CloudWatch / SNS / AWS Budgets
  • IaC: AWS CDK v2 (TypeScript)
  • テスト: Vitest / Jest / Playwright
  • CI/CD: GitHub Actions(開発環境はdevelopへのpush、本番はGitHubリリースの公開が起点。どちらも手動実行のトリガーを併設)