← プロジェクト一覧

2026-08-28 更新日:

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

目次

概要

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

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

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

2026年のシーズンは、収集の稼働期間を9月1日から10月13日までにしました。公開している集計値では、10月5日時点の累計が263名(高校生121名・大学生142名)です。

アーキテクチャ構成

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

アーキテクチャ図を見る

構成はよくある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の差分がどの機能を触った結果なのか読み取れません。

フロント用のバケットを非公開にした

最初の構成では、フロントの成果物を置いたS3バケットにWebサイトホスティングと公開読み取りを付け、CloudFrontの前段にはOAI(Origin Access Identity)を使っていました。9月5日に見直したところ、CloudFrontを通さずにバケットのWebサイト用アドレスを直接開くと、平文のHTTPで画面が返ってくる状態でした。セキュリティの方針には「HTTPSだけで配信する」と書いてあったので、文書と実態が食い違っていたことになります。

v1.16.0(9月6日リリース)で、バケットのWebサイトホスティングと公開読み取りを外し、パブリックアクセスを全面的にブロックしました。CloudFrontからの読み取りは、非推奨になったOAIからOAC(Origin Access Control)に切り替えています。本番のバケットはCDKの外で作ったものを取り込んで使っていて、CDKのgrant系メソッドがポリシーを書き換えてくれません。そこでバケットポリシーの設定、パブリックアクセスのブロック、Webサイト設定の削除の3つを、カスタムリソースで順番に実行する形にしました。順番を固定したのは、切り替えの途中で配信が止まらないようにするためです。デプロイ後のヘルスチェックには、バケットのWebサイト用アドレスが200を返したら失敗とみなす確認も足しています。

API設計

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

実名を含むデータを返すルートは、API GatewayのCognito authorizerで守っています。それに加えて、Lambdaのハンドラ入口でもIDトークンのクレームに管理者グループが入っているかを見て、入っていなければ403で返す作りです。

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

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

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

ルートの役割分担

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

認証不要なのは、個人を識別しない集計と補助情報を返すGETだけです。統計を返すエンドポイント、学校の一覧、データのある年度の一覧、ヘルスチェックの4つが該当します。それ以外、つまり選手の実名を含むデータも、収集の実行や実行履歴といった運用系も、まとめてCognito認証と管理者グループの両方を必須にしています。ログインしただけのユーザーとゲストの見えるものは変わりません。実名を見るには管理者である必要があります。

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

Lambdaが1本しかない話

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

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

代償はハンドラが太ったことです。8月末に1,032行だったファイルは、10月6日時点で1,297行あります。うちハンドラ本体が約940行です。読み取り失敗の扱いを分けたり、新規の数え方を変えたり、監査ログを足したりした分が、そのままハンドラに積み増されました。v1.17.8(10月5日)では、監査ログを1か所で出すためにハンドラ本体を別の関数へ移しましたが、ルートごとの分割はしていません。切り出せているのは年度一覧の取得、既定年度の決定、ページング値の解決といった補助関数だけで、ルートごとの処理はifを並べてハンドラの中にそのまま書いてあります。読みづらい自覚はあります。トラフィックが増えるか、ルートがもう5本増えたら分割する、というのが今の線引きです。

データ構造

DynamoDBをやめてS3に置いた

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

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

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

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

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

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

引き受けたトレードオフ

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

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

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

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

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

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

「本日の新規」の数え方

画面とXへの日次サマリーには「本日の新規」という数字を出しています。最初は、届出の受付日が今日の選手を数えていました。シーズンが始まってみると、この数え方では漏れが出ました。公示の一覧には、過去の受付日の行があとから追加されます。土日に受け付けた分も、平日の公示で初めて一覧に載ります。どちらも受付日が「今日」にならないので、新しく載ったのに「本日の新規」に入りません。

v1.16.0で、選手データに初めて一覧で見かけた日を持たせ、その日付で数えるように変えました。表示も高校生と大学生に分け、日本の祝日を集計期間とスキップの判定に入れています。連盟の公示は営業日にしか出ないので、土日と祝日は取りに行きません。

同じ選手かどうかの判定にも穴がありました。通知メールの新規リストだけが、取得したページ上の行番号から作ったIDで新規かどうかを見ていました。行番号は取得のたびに変わりうるので、同じ選手を別人として扱うおそれがあります。v1.17.2(10月1日)で、氏名・学校名・種別の組み合わせで比べる形に揃えました。初めて見かけた日を付けるときと同じ基準です。v1.17.3(10月2日)では、メールの新規も前日との比較をやめ、画面やXと同じく初めて見かけた日が当日の選手を数える形にしました。

大学の都道府県は学校名から引いている

都道府県別の分布は、高校生と大学生で出どころが違います。高校生は、公示の一覧にある都道府県の列の値をそのまま使っています。大学の一覧には所在地の列がありません。その位置に入っているのは、所属する連盟の名前です。連盟名は都道府県ではないので、大学名から所在地を引く対応表を自前で持っています。

この対応表を8月23日から更新していませんでした。今年の公示には、立教大学・日本大学・筑波大学など、表に無い大学が32校載っていました。表で引けなかった選手は「地域不明」として数えるので、10月6日の本番の統計では、総数266名のうち45名が地域不明になっていて、全員が大学生でした。

v1.17.10(10月6日)で、32校を本部の所在地で表に足し、地域不明を0名にしました。日本大学国際関係学部だけは、前から登録してあった東海大学の静岡キャンパスと同じく、キャンパスのある静岡で登録しています。分析画面の学校別統計にも別の穴がありました。対応表を通さずに選手データの都道府県の項目を直接読んでいたので、大学生は全員「不明」と表示されていました。これもダッシュボードと同じ引き方に揃えています。

v1.17.11(10月11日)で、今年の公示に載っていて表に無かった9校をさらに足しました。10月11日時点の大学の公示に載っている84校は、すべて表で引けます。

これまでは、表で引けなかった学校名がどこにも残らず、地域不明が増えても、どの学校が原因かをログから追えませんでした。いまは統計を返すときに、引けなかった学校名を運用向けのログに1リクエスト1行で出しています。本番では警告レベルのログを出さない設定なので、本番でも残るログの種類をロガーに足しました。ログに載せるのは学校名だけで、選手の氏名は載せません。公開している統計の応答にも学校名は入れていません。

ドラフト対象外者は別枠で数える

大学の公示には、「NPBドラフト対象者」の表のほかに「NPBドラフト対象外者」の表もあります。v1.17.11までの収集は、対象者の表だけを読んでいました。v1.17.12(10月13日)で対象外者の表も取り込むようにしましたが、既存の数字には混ぜていません。総提出者数、大学生の人数、都道府県の分布、新規の数え方、Xに投稿する集計は、これまでどおり対象者だけで数えます。対象外者の人数は統計の別の項目で返し、公開の画面には「NPBドラフト対象外者N名は総提出者数・大学生に含めていない」という1行を出すだけです。氏名を見られるのは、ほかの選手と同じく管理者向けの一覧だけです。

既存のデータを崩さないことにも気を配りました。対象外者は対象者のうしろに並べて保存するので、対象者のIDは変わりません。初めて見かけた日を引き継ぐための照合も、対象者は従来どおりの組み合わせで比べ、対象外者の行にだけ区分を足して見分けます。本番に対象外者が入るのは、リリース後に管理画面から手で取得してからです。定期実行はもう止めてあります(後述)。

設計上の判断と学び

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

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

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

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

ウォームアップが本物の収集を走らせていた

その「相手のサーバーに払うコスト」を、自分で大きく踏み越えていたことがあります。

コールドスタート対策として、ウォームアップ用のLambdaが一定間隔で各関数を呼んでいます。呼ぶときはウォームアップだと分かる目印をイベントに付けるのですが、スクレイピング側のハンドラはこの目印を一度も見ていませんでした。つまりウォームアップが来るたびに、本物の収集が走っていた。取得元の公式サイトには、方針として約束していた「1日1回程度」を大きく超える回数のアクセスが届いていました。気づいたのは9月1日で、実際のログで確かめています。

直したのは、スクレイピングとAPIの両方のハンドラで、冒頭に目印を見て即座に返す処理です。S3にもパラメータストアにも取得元にも触らずに終わります。ついでに、ウォームアップ経由の呼び出しは種別を渡さないため、既定の「高校生」だけが走って大学生のデータが0件のままだったことも分かりました。種別を省略したときの既定は「両方」に変えています。

ウォームアップにはもう1つ穴がありました。呼び出す関数名を開発環境の名前で直書きしていたので、本番のウォームアップが開発環境の関数を叩いていたのです。v1.17.2で、呼び出し先を環境変数から読む形に直しました。

同じ種類の穴は、デプロイ後の検証にもありました。関数が起動できるかを確かめる手順が、スクレイピング用のLambdaを2024年度の実際の収集指示で呼んでいたのです。呼び出し元を示す印が無い呼び出しは、稼働期間の判定も1日1回の制限も素通りします。そのため開発と本番のデプロイのたびに取得元のサイトへアクセスが飛び、2024年度の実行履歴が積み上がっていました。v1.17.4(10月2日)で、検証の呼び出しをウォームアップの目印付きに置き換えています。起動できたかどうかは、応答に関数のエラーが含まれていないかで判定します。あわせてスクレイピング側でも、呼び出し元が分からないまま過去の年度を指定された呼び出しは、設定を読む前に拒否するようにしました。開発環境のE2Eが手動実行の経路で起こす収集も、その回だけ履歴に記録しない指定を付けて走らせます。

稼働期間はコードの外からCDKの管理下へ戻した

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

最初の設計では、稼働期間をコードの外に出していました。期間の開始日と終了日をパラメータストアに置き、Lambdaが起動直後に「今日が期間内か」を判定して、期間外なら何もせず終了する。期間を変えるときはパラメータを書き換えるだけで、デプロイは要らない、という狙いです。

v1.15.0(9月1日)で、この判断を逆にしました。いまは期間の値をCDKの設定ファイルに書き、デプロイのたびにCDKがパラメータストアへ投入します。9月2日に終了日を9月30日から10月8日へ延ばしたときも、設定ファイルを直してv1.15.1としてデプロイしました。

逆にした理由は、手で書き込む運用の弱さが表に出たからです。それまでCDKが作るのは空の値を持つパラメータだけで、実際の期間は手作業で書き込む前提でした。この形だと、パラメータの定義が変わるスタック更新のたびに値が空に戻ります。空の期間は「期間外」と判定されるので、定期実行は誰にも知らせずに止まります。デプロイなしで期間を変えられる利点より、デプロイのたびに収集が黙って止まりうる危険のほうが大きい、と判断しました。期間を変えるのにデプロイが要るのは、年に数回のことなので受け入れています。

期間内かどうかの判定は、Lambda側にそのまま残しています。パラメータが未設定でも、値が壊れていても、取得に失敗しても、すべて「期間外」として実行しません。設定ミスで意図せず通年アクセスが走るほうが、走らないより害が大きいからです。EventBridgeのルールは平日に発火させ、手動実行やリトライで休日に走ったときのために、土日と祝日もLambda側で判定して実行しません。

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

ただ、終了日にしていた10月8日は高校の届出の締切です。大学の締切は大学の連盟が別に決めていて、公示にはそのあとも選手が載ります。定期実行が10月8日で止まっていたので、10月11日の時点で、大学の公示の「ドラフト対象者」の表にある149名のうち3名が本番のデータに入っていませんでした。v1.17.11(10月11日)で終了日を10月22日まで延ばし、10月13日の定期実行で3名が入って、本番の大学生は149名になりました。収集は毎回その年の全件を保存し直すので、取りこぼした分も1回で揃います。高校は受付が締め切られているので、高校生の提出者はもう増えません。

3名が入ったのを確かめてから、v1.17.12(10月13日)で終了日を10月13日に前倒ししました。10月14日からの定期実行は期間外として何もせずに終わり、取得したいときは管理画面から手で実行します。手動の実行は稼働期間の判定を受けません。シーズンの終わりは定期実行を止め、残りは手動で拾う形にしました。

読めないときは0と言わない

9月5日に、自分たちで敵対的な検証をかけて、失敗を握りつぶしている箇所を洗い出しました。目立ったのは「読めなかった」を「0件だった」に丸めているところです。

統計のエンドポイントは、S3の読み取りに失敗すると、その種別を0名として返していました。これだと障害が「今年はまだ誰も出していない」という集計値として公開されてしまいます。いまは、まだファイルが作られていない場合と読み取りに失敗した場合を分けています。前者はその種別が無いことを明示して返し、後者は503で応答して、0名という数字は出さない形です。

収集側も同じです。ページは取れたのにパースの結果が0件だったとき、以前は成功として記録していました。取得元のページの構造が変わったときにこの形になるので、いまは失敗として履歴と監視に残します。

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

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

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

このリストは読み込みに失敗したときも止める作りです。ファイルが存在しなければ「除外なし」として通常どおり動きますが、存在するのに読めない場合はエラーを投げて処理を止めます。収集側なら保存せずに中止し、配信側なら503を返します。読めないまま続行すると、削除請求済みの人を配信してしまうので。9月5日からは、ファイルは読めても中身の形が壊れている場合(空のオブジェクトや、配列のはずの項目が配列でない場合)も同じく止めています。除外ゼロとして扱うのは、明示的に空の配列が書かれているときだけです。

9月5日には、請求を受けたら保存済みのデータから該当者を物理的に消す処理と、履歴系のデータに保存期限を付けるS3のライフサイクル設定も実装しました。どちらもリリース前にオーナーの指示で取り消し、v1.16.0には入れていません。削除請求は、今も配信時に除外リストで止める方式で対応しています。

除外リスト自体が個人情報なので、ログには件数しか出していません。収集の実行履歴も、9月から氏名を持たず件数だけを保存する形に変えました。ただし実行履歴とは別に、収集のたびに氏名と学校の一覧を日付ごとに保存する処理が残っていたのです。v1.17.3(10月2日)でこの保存をやめ、保存先への権限も外しました。すでに保存された分はまだ消しておらず、これから削除します。障害調査のときに氏名が見えないのは正直やりにくいのですが、そこは諦めました。

同期invokeという負債

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

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

この上限は、エッジ最適化のREST APIだと統合タイムアウトの29秒で、引き上げられません。API Lambdaのタイムアウトは30秒にしていたので、設定値がそもそも効いていませんでした。v1.17.2で28秒に直しています。

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

無料枠とアラームの上限

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

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

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

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

本番のエラーがログに残っていなかった

10月2日、本番のLambdaで例外が起きても、CloudWatch Logsに何も残っていないことが分かりました。ロガーは本番で出力を絞る設定にしていたのですが、その絞り方を誤っていて、エラーと致命的なエラーまで含めて全部止めていたのです。本番で障害が起きても、調べる材料が最初から無い状態です。

v1.17.5(10月2日)で、本番でもエラーと致命的なエラーだけはJSON 1行で出すように直しました。リクエストID、メソッドとパス、ステータス、所要時間、エラーの中身を、取れる範囲で載せます。デバッグ用の出力と正常な応答の記録は、これまでどおり本番では出しません。あわせて、API Gatewayが5xxを返したら通知するアラームを開発と本番に足しました。APIのLambdaは例外を握って500を返すので、Lambdaのエラー件数を見る既存のアラームには現れなかったためです。

v1.17.6(同日)では、どの版のコードが出したログかを見分けられるよう、Lambdaにリリースの版を環境変数で渡し、本番のログに載せるようにしました。例外を握って500を返す経路のいくつかが、応答の記録を通らずに返っていたのもここで直しています。

誰が読んだかを後から追えるようにした

10月5日に、APIの認可とログを点検しました。きっかけは同じ日に読んだ外部の記事で、正規の認証を通った読み取りは普段の利用に紛れるので、盗まれた資格情報で全件を読まれても気づけない、という論点でした。そこで、不正なアクセスを防げるかに加えて、正規の資格で全件を読まれたときに誰が何を読んだかを後から追えるかを見ています。

他人のデータを読めてしまう穴は見つかりませんでした。そもそも所有者という概念が無く、認可は公開と管理者の2段階で、IDを受け取るルートはまだ実装していません。足りなかったのはログです。API Gatewayのアクセスログに残していた利用者の項目はIAM認証用のもので、Cognitoの認証では常に空でした。本番のLambdaは、拒否も成功も記録していませんでした。管理者のトークンを盗まれて選手の一覧を全ページ取られても、ログにはIPアドレスと200の行が並ぶだけです。

v1.17.8(10月5日)で、アクセスログに呼び出し主体のID(Cognitoのsub)、実際のパス、User-Agent、オーソライザのエラーを足しました。Lambdaには、ログの出力レベルに関係なく1リクエスト1行を出す監査ログを加えています。載せるのはルート、呼び出し主体、絞り込みの条件、ステータス、返した件数、リクエストIDで、選手の氏名と学校名は載せません。除外リストや実行履歴と同じ扱いです。さらに、401と403が短時間に急増したら通知するアラームを足しました。401はCognitoのオーソライザが返してLambdaまで届かないので、数えるのはアクセスログの側です。

1つ見込み違いがありました。アクセスログにはsubと一緒にユーザー名のクレームも載せたのですが、本番の行を見ると値が入っていません。クレーム名に含まれるコロンのところで、API Gatewayの変数の展開が切れていました。この項目は実環境で確かめないままリリースしていたものです。v1.17.9(10月6日)でアクセスログから外し、ユーザー名が要るときは監査ログの側をリクエストIDで引く形にしています。

401と403の急増を見るアラームには、もう1つ弱点がありました。正規の資格で認可を通り、200で成功し続ける大量の読み取りは、このアラームに引っかかりません。v1.17.12(10月13日)で、監査ログに載せている「返した件数」を時間ごとに合計し、普段の利用ではまず出ない量に達したら通知するアラームを開発と本番に足しました。閾値は直近7日間の実測から決めています。その期間に一覧の読み取りが出ていたのはすべてE2Eの実行で、E2Eが何回か重なっても鳴らない値にしました。

点検ではログ以外の指摘もいくつか出ていて、そちらの進め方はまだ決めていません。

静的なセキュリティ監査の指摘を直した

10月7日に、コードと設定を読むだけの静的なセキュリティ監査をかけました。出た指摘にはそれぞれ反証を試み、残ったもののうちコードで直せるものをv1.17.11(10月11日)で直しています。

中でも影響が大きかったのはLambdaのinvoke権限です。前に書いたとおり、API Lambdaは直接invokeされるとペイロードのクレームをそのまま信じるので、守りはIAMのinvoke権限だけです。その権限を関数名の前方一致で書いていたため、開発環境の権限が本番の関数まで届く範囲になっていました。いまは自分の環境のスクレイピング用Lambdaだけを呼べるように絞りました。同じロールのメール送信の権限も、どの送信元でも送れる形から、通知に使う送信元だけに絞り、コードから使っていなかった生のメール送信の権限は外しています。

入力の検証にも抜けがありました。収集を手で実行するAPIは、年度の値に数字以外が混じっていても受け付けていて、範囲の比較を素通りした値が取得先のURLの組み立てまで届いていました。いまは整数でなければ400で返し、スクレイピング用のLambdaを呼びません。スクレイピング側でも、年度を整数に直してから範囲を見ています。

残りは細かいものです。収集結果の通知メールはHTMLで、選手名や学校名、エラー文をそのまま差し込んでいたので、差し込む値はすべてエスケープするようにしました。開発環境のログには、パースの途中経過として先頭5名の氏名と学校名が出ていたので、件数だけに減らしています。Cognitoの招待メールと確認メールの返信先に、このシステムと関係のない第三者のドメインのアドレスが入っていたのも外しました。

ライトテーマをデジタル庁のデザインシステムに合わせた

v1.17.7(10月5日)で、画面のライトテーマをデジタル庁デザインシステム(DADS)に準拠させました。配色、書体、本文の大きさと行間、見出しの大きさ、ボタン・入力欄・表の見出し・リンク・タブの形と色、キーボード操作で出るフォーカスの枠まで、DADSの値に合わせています。値の出どころは、デジタル庁がMITライセンスで公開しているデザイントークンのパッケージです。

ダークテーマとグラフの系列色は変えていません。DADSにダークテーマの公式な定義が無いためです。チップの角丸やアウトラインボタンのホバー時の色のように、トークンに値が無く、コンポーネントの見た目に近づけて決めた箇所もあります。デジタル庁のロゴやシンボルマークは使っていません。

CI/CDと運用

GitHub Actionsは、自分で用意したEC2上のセルフホストランナー2本で回しています。課金の都合でGitHubのホストランナーが使えなくなったときの回避策として始めたもので、EC2が止まったときだけ手動実行でGitHubのホストランナーへ逃がす入力を全ワークフローに付けました。

developブランチへのpushで動くのは、9月5日に作った開発用パイプライン1本だけです。品質確認、必要ならインフラの配備、必要ならフロントの配備、開発環境へのE2Eを、同じコミットに対して1回の実行の中で順番に流します。配備するかどうかは、最後に配備が成功したコミットからの累積差分で決めています。

本番のデプロイは、GitHubのリリースの公開が起点です。v1.17.0(9月7日)からは、配備かE2Eが失敗したら、直前にE2Eまで通ったリリースへ自動で戻すようにしました。戻し先の候補になるのは、E2Eの成功を記録したリリースだけです。配備が終わっただけのリリースを候補にすると、E2Eで落ちたリリースへ戻してしまうからです。本番のE2Eが通ると、リリースの告知文の下書きを作って運営者の確認を待ちます。告知は、運営者が承認したものだけをXに投稿します。

10月2日には、配備の前後の検証にも抜けが見つかりました。開発環境への配備では、同じ実行の中で先に品質確認が通っている前提で、配備直前の単体テストを省いていました。ところが品質確認は変更箇所に応じてフロントかバックエンドの検証を省くので、組み合わせによってはテストを一度も通らないコードが開発環境に入ります。v1.17.5で、配備の直前にも単体テストを必ず走らせるようにしました。配備後の検証にも、引数の渡し方を取り違えて毎回失敗し、その失敗を握りつぶして先へ進んでいた確認が1つありました。何も確かめていない検証が、成功の印を付けたまま並んでいたわけです。これも同じv1.17.5で直し、v1.17.6では、スクレイピングとデータ処理のLambdaを呼んで起動を確かめる手順も、失敗したら警告で済ませずに配備の失敗として扱う形にしました。本番ならそのまま自動の切り戻しにつながります。

10月7日の監査では、CIの側にも指摘が2つありました。1つはPlaywrightのトレースです。トレースは入力欄に打った値も記録するので、ログインの手順でリトライが起きると、ログインに使う値まで記録され、CIの成果物に残りうる設定でした。v1.17.11からは、ログインの手順では全環境で、本番ではすべての手順でトレースを取りません。もう1つは依存パッケージの取り込みです。Dependabotのminorとpatchの更新は、CIが通れば自動でマージされます。乗っ取られたパッケージの悪意のある版が取り下げられる前にdevelopへ入らないよう、Dependabotとpnpmの両方で、公開から7日経っていない版は取り込まない設定にしました。セキュリティ修正の更新には、この待ち期間が効きません。

トレースを止めたあとにも、ログインの値が残る場所がもう1つありました。PlaywrightのHTMLレポートは、入力欄に値を打つ手順をその値ごとステップ名として記録します。そのため、E2Eで使う管理者の資格情報が、CIの成果物のレポートに平文で入っていました。v1.17.12で、ログインの入力をレポートに値が残らない書き方に置き換え、標準出力からもユーザー名を外しています。修正前に作られたレポートは消しました。CIとテストだけの変更で、配信物は変わりません。

同じv1.17.12では、開いていたDependabotのアラート11件(critical 1件・high 7件・moderate 3件)も片付けました。11件ともロックファイルに載っている推移的な依存で、Lambdaにも画面の配信物にも入っていません。修正版への引き上げに加えて、どこからも使っていなかった開発用の依存を外し、依存全体の監査結果を0件にしています。

テストはルートがVitestで500件、フロントがVitestで159件、E2EがPlaywrightで39件です(v1.17.12の本番配備時点)。以前はJestも併用していましたが、いまはVitestに揃えています。9月19日にテストを棚卸しし、その結果を10月1日のv1.17.2で反映しました。実装のリテラルを読み返すだけのテストは削り、フォールバックで常に合格になっていたテストは失敗しうる形に書き直しています。ルートの件数はこのときいったん357件まで減り、その後、ログと監査ログ、セキュリティ監査の修正、ドラフト対象外者の取り込みに合わせてテストを足したぶん増えました。

運用面では、シーズン中の公示がある日に、その日の新規と累計をXに投稿しています。数字は公開している統計のエンドポイントから取るので、画面に出ている集計値と同じものです。

使っている技術

  • フロントエンド: 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(OAC) + 非公開のS3
  • 定期実行と監視: EventBridge / CloudWatch / SNS / AWS Budgets
  • IaC: AWS CDK v2 (TypeScript)
  • テスト: Vitest / Playwright
  • CI/CD: GitHub Actions(セルフホストランナー。開発環境はdevelopへのpushで動く開発用パイプライン、本番はGitHubリリースの公開が起点で、失敗時は自動ロールバック)