
認証を作らないまま、URLだけ公開していた話 #3 前編「Lambda編」
はじめに
前回、S3に置いたページをCloudFront経由で公開しました。URLを開けば、誰でも見られる状態です。
今回はその裏側、サーバー側を作ります。 いまは画面に出ている数字が全部ハードコードなので、それをAPIから取ってくる形にしていきます。
ただ、最初に手を動かしたのは実装ではありませんでした。公開したURLを眺めていて、まずいことに気づいたからです。
今回やったこと
1. 認証を作らないまま、URLだけ公開していた
いま公開しているページは、表示しているデータが全部ハードコードです。だから誰が見ても同じ画面が出るし、それで問題ありません。
問題は次の段階です。データベースを繋いだ瞬間に、こうなります。
URLを知っている人なら、誰でも同じデータを読み書きできる
記録を書き換えられますし、リクエストが増えれば課金にも直結します。
設計メモには「初期版は単一ユーザー前提でよいか」と書いて、ずっと保留にしていました。改めて見ると、「単一ユーザー前提」と「公開URL」は両立しません。 自分しか使わない前提のものを、全世界に開いていたわけです。
作りながら決めればいい、と思っていた項目でした。でも実際には、データベースの設計より前に決めないといけないものでした。誰のデータかを表す値が、そのままテーブルのキーになるからです。
2. いまは認証を作らないと決めた
選択肢は3つありました。
A. 認証なし・単一ユーザー — 内容: いまのまま / 判断: 不可。 公開URLがある以上、成立しない
B. ブラウザにIDを持たせる — 内容: 初回にUUIDを発行して保存。サインアップ画面が不要 / 判断: これにした
C. 本格的な認証 — 内容: メール登録・パスワード / 判断: 本格公開の条件。いまは過剰
Bにしたのは、いまの段階にCが重すぎるからです。
次にやろうとしているのは、友人に何人か試してもらうことです。その規模のために、メール登録もパスワードの再発行も要りません。試してもらう段階のデータは、最悪消えてもいいと割り切っています。
Cをやらないと決めたわけではありません。 本格的に公開するなら認証は要ります。順番の話であって、いまそこにコストをかける段階ではない、というだけです。
やることは単純です。初回アクセス時にブラウザ側でランダムなIDを1つ作り、保存しておく。以降のリクエストにそれを付けて送る。サーバーはそれを見て「この人のデータ」を返す。
ただしこれは「認証」ではありません
IDは自己申告なので、他人のIDを知っていればそのデータを読み書きできます。 ランダムな値なので推測はできませんが、「守られている」状態ではありません。
いまは友人に試してもらう段階なので、これで進めます。本格的に公開する前には、ちゃんとした認証に移行する——これを「いつかやる」ではなく「公開の条件」として書き残しました。
移行を軽くするために、2つだけ守った
あとで本格的な認証に移すとき、いちばん重くなるのはデータの作り直しです。利用者を表す値がキーになっているので、その形が変わると全レコードを書き換えることになります。
避けるために、2つ決めました。
利用者のIDは、意味を持たない文字列にする。 メールアドレスや連番をキーにしない
「誰のリクエストか」を判定する処理を、1か所に閉じる
2つ目が効きます。いまはリクエストのヘッダを読むだけの関数ですが、あとで中身を差し替えるだけで済みます。呼び出す側は「IDという文字列を受け取る」だけなので、他のコードは触りません。
// これ1つだけを、あとで書き換える
export const resolveUserId = (event) => { ... }こうしておけば、移行のときは「新しいアカウント → 既存のID」という対応表を1つ足すだけで済みます。データは1件も書き換えません。
3. APIの形を先に決める
作るのは3つです。
GET — パス: /me / いつ: ページを開いたとき。1回で全部返す
POST — パス: /records / いつ: 達成ボタンを押したとき
PUT — パス: /me/skills / いつ: スキルを変更したとき
パスを /me にした理由
/users/{id} にもできますが、認証を入れたときにパス設計をやり直すことになります。
/me は「自分の情報」を指すので、利用者の特定方法が変わってもパスは変わりません。 今はヘッダから、あとでトークンから読むだけの違いです。
呼び出し回数を先に決めた
課金の上限をかける前提として、こう決めました。
ページを開いたら 1回
達成ボタンで 1回
ポーリングしない
「今日のミッションを取りに行くのは、ページを開いたときだけ」と決めておけば、常時通信が発生しません。無限ループのバグが入っても気づきやすくなります。
同じ日に2回押されても、1件にする
達成の記録は「スキル + 日付」で一意にしました。同じ日に同じスキルで2回送られても、2件にしません。
理由は、経験値を保存していないからです。このアプリは経験値もレベルも保存せず、達成記録から毎回計算します。つまり記録が重複すると、そのまま数字が狂います。
ボタンの連打も、通信のリトライも、普通に起きます。サーバー側で弾く形にしました。
日付と時刻はサーバーが決める
記録を送るとき、日付をクライアントから受け取っていません。端末の時計を変えれば、経験値を操作できてしまうからです。
称号に「朝型」「夜型」があり、達成した時刻を見ています。ここが信用できないと成立しません。
4. 計算をどこでやるか
ここがいちばん悩みました。
第2回の時点では、画面側で計算していました。 達成記録の配列を持っていて、そこからレベルもランクも称号も計算する。ロジックは progress.js という1ファイルにまとまっています。
APIを作るにあたって、そのまま「サーバーは記録を返すだけ、計算は画面側」でもよさそうに見えました。既存のコードがそのまま使えます。
ただ、ミッションの難易度を「いまの連続日数」で決める仕様にしたので、サーバー側でも連続日数を計算する必要が出てきます。 同じロジックが2か所に必要になります。
サーバー側に寄せることにしました。理由は3つです。
どのみちサーバーが計算する。 二重に持ちたくない
記録が増えても転送量が増えない。 画面側で計算するなら全記録を毎回ダウンロードする必要がある。2年続ければ数千件になる
表示される数字が1か所で決まる。 ずれる余地がなくなる
書き直しは、ほとんど発生しませんでした
「サーバーに寄せる」と言うと作り直しに聞こえますが、ロジックはそのまま使えました。
変えたのは置き場所です。
frontend/src/lib/progress.js → shared/progress.js
frontend/src/lib/badges.js → shared/badges.jsshared/ という場所を作って、フロントとバックエンドの両方から読むようにしました。フロント側はビルドツールの設定を1行足すだけ、バックエンドは相対パスで読むだけです。
第1回で決めたことが、ここで効きました
第1回で「フロントもバックエンドもJavaScriptで統一する」と決めていました。理由は「学ぶ言語を1つに絞って、頭の切り替えコストを下げる」でした。
言語が違っていたら、同じ計算を2回書くしかありませんでした。 レベルの計算式も、連続日数の数え方も、称号の条件も、全部です。そして片方を直したときに、もう片方を直し忘れます。
学習コストを下げるための判断が、設計上の利点として返ってきたのは予想していませんでした。
5. Lambda関数を作る
ようやくAWSの画面です。

ARM64を選びました。 x86_64より安く、今回のコードはCPUに依存しないので問題ありません。

注意
「関数URL」をONにしてはいけません。
ONにするとAPI Gatewayを経由しない公開URLがもう1つできます。 あとで設定する制限をすり抜けるので、今回は不要です。
VPCもOFFです。ONにすると起動が遅くなりますし、今回はプライベートなリソースに繋ぎません。

6. zipを上げて、ハンドラで詰まる
コードは複数のファイルに分かれているので、zipにまとめてアップロードします。


このとき 「AWS KMS カスタマーマネージドキーによる暗号化」というチェックボックスが出ます。入れませんでした。
紛らわしいのですが、これは「暗号化するか」ではなく「どの鍵で暗号化するか」の選択です。チェックを入れなくても、AWSの鍵で暗号化されます。自分の鍵を使うと有料になります。
中身は公開しているコードそのもので、秘密は入っていません。選ぶ理由がありませんでした。

エクスプローラに backend/src と shared と package.json が並んでいます。zipの中身がそのままLambdaに置かれることが分かります。
ただ、これではまだ動きません
このまま実行すると、「モジュールが見つからない」で落ちます。

ハンドラという設定が、既定値のままだからです。
ハンドラは「どのファイルの、どの関数を呼べばいいか」をLambdaに教える設定です。書き方はこうなっています。
backend/src/handler . handler
└──────────────┘ └─────┘
ファイルのパス 関数の名前
(拡張子は書かない)既定の index.handler は「zipの直下にある index.js の handler 関数」という意味です。私のzipには index.js がありません。

なぜこんな深い場所に置いたのか
zipの直下に置けば index.handler のままで動きます。そうしなかったのは、共有ファイルへのパスを揃えたかったからです。
コードは下記のような書き方をしています。
import { summarizeSkill } from '../../shared/progress.js'この相対パスが、手元でもLambda上でも同じになるように、zipの中をリポジトリと同じ配置にしました。直下に置くとパスが変わり、手元で動くコードとLambdaに載せるコードが別物になります。「ローカルでは動くのに本番で落ちる」の温床です。
7. 環境変数を1つ設定する

ブラウザから別のドメインのAPIを呼ぶと、既定では拒否されます。「このドメインからなら許可する」とサーバー側が明示する必要があり、その値を環境変数で渡しています。
コードに直接書かないのは、開発中と本番でURLが変わっても、コードを触らずに済ませたいからです。
8. 守ろうとしたら、すでに守られていた
暴走したときに備えて、同時に動ける数を2個までに制限しようとしました。無限ループのバグが入っても、被害額に天井ができます。


設定できませんでした。
AWSには「予約したあとも、他の関数のために100以上を残すこと」というルールがあります。ところがこのアカウントの上限は 10 でした。2を予約すると残り8で、100を大きく下回ります。1すら予約できません。
以前は上限1000が普通だったようですが、新しく作ったアカウントの初期値は10になっていました。
でも、目的はすでに達成されていました
やりたかったこと — この関数は同時に2個までしか動かない
実際の状態 — アカウント全体で同時に10個までしか動かない
上限10が、そのまま天井になっています。 しかも関数単位ではなくアカウント全体にかかるので、より強い制限です。
「制限できないなら上限を上げよう」と考えたくなりますが、逆効果です。上限を上げれば天井が高くなります。将来ユーザーが増えて足りなくなったとき、引き上げの申請と同時に制限を設定する、という順序になります。
もう1つ、既定で守られていました

心配していたリスクの1つに「Lambdaが自分自身を呼び続けるループ」がありました。これも既定で検出して止めてくれる設定になっていました。
警戒していた3つのうち、2つは最初から守られていたことになります。
9. 動いた


返ってきた中身は、手元で動かしたときとまったく同じでした。
英語 Lv.7 A exp: 630 通算 61 連続2日 称号7
筋トレ Lv.4 C exp: 250 通算 25 連続2日 称号4
プログラミング Lv.7 A exp: 730 通算 70 連続10日 称号7shared/ に置いた計算ロジックが、手元でもLambda上でも同じ結果を出していることの確認になりました。
つまずいた点
認証を「あとで決める」にしていたら、公開URLと矛盾していた。 データベースより前に決めるべき項目だった
ハンドラの既定値では動かない。 zipの中の配置と、設定の書き方が対応していることを知らないと直せない
同時実行数を制限しようとして、制限できなかった。 ただし調べたら、制限したかった目的はアカウントの上限で達成されていた
警戒していたことの半分は、既定で守られていた。 手を動かす前に「たぶん有料だろう」「たぶん自分でやるんだろう」と決めつけていた
後編について
Lambdaは動きましたが、まだ外から呼べません。 テスト画面から実行しただけで、インターネットからは届きません。
後編ではAPI Gatewayを繋いで、URLで叩ける状態にします。あわせて、今回できなかったリクエスト数の制限もそちらで設定します。