メインコンテンツへスキップ
見出し画像

認証を作らないまま、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つ決めました。

    1. 利用者のIDは、意味を持たない文字列にする。 メールアドレスや連番をキーにしない

    2. 「誰のリクエストか」を判定する処理を、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つです。

    1. どのみちサーバーが計算する。 二重に持ちたくない

    2. 記録が増えても転送量が増えない。 画面側で計算するなら全記録を毎回ダウンロードする必要がある。2年続ければ数千件になる

    3. 表示される数字が1か所で決まる。 ずれる余地がなくなる

    書き直しは、ほとんど発生しませんでした

    「サーバーに寄せる」と言うと作り直しに聞こえますが、ロジックはそのまま使えました。

    変えたのは置き場所です。

    frontend/src/lib/progress.js   →   shared/progress.js
    frontend/src/lib/badges.js     →   shared/badges.js

    shared/ という場所を作って、フロントとバックエンドの両方から読むようにしました。フロント側はビルドツールの設定を1行足すだけ、バックエンドは相対パスで読むだけです。

    第1回で決めたことが、ここで効きました

    第1回で「フロントもバックエンドもJavaScriptで統一する」と決めていました。理由は「学ぶ言語を1つに絞って、頭の切り替えコストを下げる」でした。

    言語が違っていたら、同じ計算を2回書くしかありませんでした。 レベルの計算式も、連続日数の数え方も、称号の条件も、全部です。そして片方を直したときに、もう片方を直し忘れます。

    学習コストを下げるための判断が、設計上の利点として返ってきたのは予想していませんでした。

    5. Lambda関数を作る

    ようやくAWSの画面です。

    画像
    関数の作成。ランタイムはNode.js、アーキテクチャはARM64

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

    画像
    カスタム設定。ONにしたのはARM64だけ

    注意
    「関数URL」をONにしてはいけません。
    ONにするとAPI Gatewayを経由しない公開URLがもう1つできます。 あとで設定する制限をすり抜けるので、今回は不要です。

    VPCもOFFです。ONにすると起動が遅くなりますし、今回はプライベートなリソースに繋ぎません。


    画像
    作成完了。この時点ではサンプルコードが入っている

    6. zipを上げて、ハンドラで詰まる

    コードは複数のファイルに分かれているので、zipにまとめてアップロードします。

    画像
    コードタブ → 更新 → .zip ファイルから更新
    画像
    10.25 KB。ライブラリを使っていないので小さい

    このとき 「AWS KMS カスタマーマネージドキーによる暗号化」というチェックボックスが出ます。入れませんでした。

    紛らわしいのですが、これは「暗号化するか」ではなく「どの鍵で暗号化するか」の選択です。チェックを入れなくても、AWSの鍵で暗号化されます。自分の鍵を使うと有料になります。

    中身は公開しているコードそのもので、秘密は入っていません。選ぶ理由がありませんでした。

    画像
    アップロード後。zipの中身がそのまま載っている

    エクスプローラに backend/src と shared と package.json が並んでいます。zipの中身がそのままLambdaに置かれることが分かります。

    ただ、これではまだ動きません

    このまま実行すると、「モジュールが見つからない」で落ちます。

    画像
    ハンドラが index.handler のまま

    ハンドラという設定が、既定値のままだからです。

    ハンドラは「どのファイルの、どの関数を呼べばいいか」をLambdaに教える設定です。書き方はこうなっています。

    backend/src/handler . handler
    └──────────────┘   └─────┘
       ファイルのパス      関数の名前
       (拡張子は書かない)

    既定の index.handler は「zipの直下にある index.js の handler 関数」という意味です。私のzipには index.js がありません。

    画像
    backend/src/handler.handler に変更

    なぜこんな深い場所に置いたのか

    zipの直下に置けば index.handler のままで動きます。そうしなかったのは、共有ファイルへのパスを揃えたかったからです。

    コードは下記のような書き方をしています。

    import { summarizeSkill } from '../../shared/progress.js'

    この相対パスが、手元でもLambda上でも同じになるように、zipの中をリポジトリと同じ配置にしました。直下に置くとパスが変わり、手元で動くコードとLambdaに載せるコードが別物になります。「ローカルでは動くのに本番で落ちる」の温床です。

    7. 環境変数を1つ設定する

    画像
    ALLOWED_ORIGIN に、第2回で作ったCloudFrontのURLを入れる

    ブラウザから別のドメインのAPIを呼ぶと、既定では拒否されます。「このドメインからなら許可する」とサーバー側が明示する必要があり、その値を環境変数で渡しています。

    コードに直接書かないのは、開発中と本番でURLが変わっても、コードを触らずに済ませたいからです。

    8. 守ろうとしたら、すでに守られていた

    暴走したときに備えて、同時に動ける数を2個までに制限しようとしました。無限ループのバグが入っても、被害額に天井ができます。

    画像
    同時実行の設定画面
    画像
    エラー。「予約されていないアカウントの同時実行数が 100 を下回ることはできません」

    設定できませんでした。

    AWSには「予約したあとも、他の関数のために100以上を残すこと」というルールがあります。ところがこのアカウントの上限は 10 でした。2を予約すると残り8で、100を大きく下回ります。1すら予約できません。

    以前は上限1000が普通だったようですが、新しく作ったアカウントの初期値は10になっていました。

    でも、目的はすでに達成されていました

    • やりたかったこと — この関数は同時に2個までしか動かない

    • 実際の状態 — アカウント全体で同時に10個までしか動かない

    上限10が、そのまま天井になっています。 しかも関数単位ではなくアカウント全体にかかるので、より強い制限です。

    「制限できないなら上限を上げよう」と考えたくなりますが、逆効果です。上限を上げれば天井が高くなります。将来ユーザーが増えて足りなくなったとき、引き上げの申請と同時に制限を設定する、という順序になります。

    もう1つ、既定で守られていました

    画像
    再帰ループ検出。既定で有効、しかも無料

    心配していたリスクの1つに「Lambdaが自分自身を呼び続けるループ」がありました。これも既定で検出して止めてくれる設定になっていました。

    警戒していた3つのうち、2つは最初から守られていたことになります。

    9. 動いた

    画像
    テストイベント。手元で書いたテストとまったく同じJSON
    画像
    成功。42.87ms、使用メモリ78MB

    返ってきた中身は、手元で動かしたときとまったく同じでした。

    英語        Lv.7 A exp: 630 通算 61 連続2日 称号7
    筋トレ       Lv.4 C exp: 250 通算 25 連続2日 称号4
    プログラミング Lv.7 A exp: 730 通算 70 連続10日 称号7

    shared/ に置いた計算ロジックが、手元でもLambda上でも同じ結果を出していることの確認になりました。

    つまずいた点

    • 認証を「あとで決める」にしていたら、公開URLと矛盾していた。 データベースより前に決めるべき項目だった

    • ハンドラの既定値では動かない。 zipの中の配置と、設定の書き方が対応していることを知らないと直せない

    • 同時実行数を制限しようとして、制限できなかった。 ただし調べたら、制限したかった目的はアカウントの上限で達成されていた

    • 警戒していたことの半分は、既定で守られていた。 手を動かす前に「たぶん有料だろう」「たぶん自分でやるんだろう」と決めつけていた

    後編について

    Lambdaは動きましたが、まだ外から呼べません。 テスト画面から実行しただけで、インターネットからは届きません。

    後編ではAPI Gatewayを繋いで、URLで叩ける状態にします。あわせて、今回できなかったリクエスト数の制限もそちらで設定します。

    あなたへのおすすめ