Lean Baseball

No Engineering, No Baseball.

認証機能を(ほぼ)作らずに認証付きバックエンドを3日で作っちゃった話 - Google CloudとFastAPIのエコシステムに全力で乗っかろう

心身のコンディション維持と体調管理のため, 毎日運動と血圧・体重・脈拍の計測をしているマンです*1.

新たな個人開発かつ, 趣味と実益(&学習)を兼ねて,

  • プロダクトオーナーやりたい宣言したので, 個人開発としてプロダクト作りたい
  • ネタどうしようかな...そうだ! 毎日運動と血圧・体重・脈拍の計測 に役立つ何かを作るぞ!
  • 自分でSaaSを開発する練習もしたいので, スタートアップがやりそうなアーキテクチャでちゃんと作ろう

と, 昨年末に思いつき「自分専用のヘルスケア領域SaaS」としてモノを作っているのですが,

  • データの入力元となるフロントエンドと, 記録したデータの可視化&リコメンドに注力したい
  • ↑の理由で, バックエンドの開発は正直な話思いっきり手を抜きたい
  • 一番面倒くさいと言っても過言ではない認証機能(ユーザー管理)を開発ゼロでやりたい!!!

...と思いつき, 知識と余暇の時間を総動員して色々創意工夫した結果,

認証機能を(ほぼ)作らずに認証付きバックエンドを3日で作っちゃいました.

今年初の技術ブログ記事となるこのエントリーでは,

  • ユーザー認証をGoogle Cloudの作法に従って「ほぼ」開発すること無く
  • スケールアウト・スケールインも配慮した構成で
  • 2, 3日でバックエンドのAPIを立てちゃうぞ!

という話を綴りたいと思います.

TL;DR

Firebase AuthenticationをプロバイダとしてCloud Endpointsを使い, いい感じにApp Engineで繋ぐことにより認証機能をAPIのコードして書かなくてもいい感じに用意できますよ!

あと, FastAPI便利すぎ.

想定読者

主にバックエンド系のエンジニア(サーバーサイドとかSRE)向けのコンテンツです.

何かしらのプログラミング言語でWebアプリ作ってGoogle App EngineやFirebaseでホスティングしました

ぐらいの経験があるとサクッと読めるんじゃないかなと思います.

なお, 基本的な用語は解説しないので中級者〜上級者向け記事かもしれません.*2

おしながき

作ったもの

ひと言で言うと「毎日の血圧・脈拍・体重および飲酒量を記録するWebアプリ」になります.

プロトタイピングのイメージはこんな感じです.

f:id:shinyorke:20220214215215p:plain

どんな感じで作ったかというと,

  • フロントエンド, バックエンド, 認証をそれぞれ別に開発・構築
  • フロントエンドはNext.js + TypeScriptで開発し, Firebase Hostingで運用
  • バックエンドはFastAPI + Firestoreで開発, Google App Engineでホスティング
  • 認証認可はFirebase Authenticationを使う
    • フロントエンドで認証(gmail認証)して画面にログイン & バックエンド認証で使うJWT tokenを取得
    • バックエンドへのアクセスはJWT tokenを使う
  • バックエンドのアクセスはJWT tokenを持ってるリクエストのみ許可
    • Cloud Endpoints + Cloud RunでAPI Gatewayを構築
    • バックエンドに対するすべてのリクエストはAPI Gatewayを通す(下記のAdmin User accessを除く)
  • バックエンドのみ, 管理者(Admin User)がそのままアクセスできるようにIdentity-Aware-Proxy(IAP)で許可したユーザーのみアクセス可能に

色々書きましたが, 絵にするとこういう感じです.

f:id:shinyorke:20220214220148p:plain

なお, 現時点(2022/2/14時点)ではFirebase上のフロントエンド⇔API Gateway経由のバックエンドアクセスはまだ検証中だったりしますが*3, JWT tokenを使ったアクセス制御は実現できているのでその話を中心に紹介します.

やったこと

前述の通り, フロントエンドとのつなぎ込みがまだ途中なため, 下図のレイヤーに絞ってやったことを紹介します.

f:id:shinyorke:20220214220648p:plain
今回紹介する範囲

FastAPIで開発

「リッチなSPA + 薄いバックエンド」的な今風のスタイルで構築しています.

かつ, バックエンドでは「データ保存」「データ保存前のValidation」ぐらいしかやることがなかった(認証はバックエンドの前段にあるFirebase Authentication + API Gatewayで実現), FastAPI(Python)でサクッと実装しました.

fastapi.tiangolo.com

FastAPIはpydanticでIFの型定義とValidationをちょこっと書くだけで基本的なWeb APIができちゃうかつ, 今回は作るものの仕様上, Firestoreで十分事足りる(RDBMS的なのはいらない)というのもあり, 開発は比較的順調でした.

例えば先程スクショで上げた入力画面へのPOST機能はこんな感じです.

# Application本体

from datetime import datetime
from dataclasses import asdict
from fastapi import APIRouter, Depends
from stats.model import (
    StatsFormRequestModel,
    StatsFormResponceModel,
    StatsData,
    generate_unique_key,
    SCHEMA_BODY_STATS,
    form2save_data
)
from environment import GC_CREDENTIALS, GC_PROJECT_ID
from interfases.google.firestore import Firestore

api = APIRouter()
firebase = Firestore(project_id=GC_PROJECT_ID, credential=GC_CREDENTIALS)  # type: ignore


def database() -> Firestore:
    """
    Database client
    :return: Database client object
    """
    return firebase


@api.get("/")
@api.options("/")
def index() -> StatsFormResponceModel:
    """
    Healthcheck point
    :return: StatsFormResponceModel
    """
    now = datetime.now()
    responce = StatsFormResponceModel()
    responce.datetime = now.isoformat()
    return responce


@api.post("/")
def create(body: StatsFormRequestModel, client: Firestore = Depends(database)) -> StatsData:
    """
    create stats
    :param body: StatsFormRequestModel
    :param client: Database client
    :return: StatsFormResponceModel
    """
    now = datetime.now()
    data: StatsData = form2save_data(form=body, created_at=now, updated_at=now)
    client.add(document=SCHEMA_BODY_STATS, key=generate_unique_key(), value=asdict(data))
    return data

メイン関数はこれぐらいで, 殆どの実装はクラスの入力値チェックでした.

# データモデル
from datetime import datetime
from typing import Dict
from dataclasses import dataclass
import uuid

from pydantic import BaseModel, Field, root_validator, validator

SCHEMA_BODY_STATS = 'body_stats'


@dataclass
class StatsData:
    statsDate: datetime
    bpUp: int
    bpDown: int
    pulse: int
    weight: float
    bfp: float
    bmi: float
    alcBeer: int
    alcSake: int
    alcSpirit: int
    alcWine: int
    alcOther: int
    uid: str
    createdAt: datetime = None  # type: ignore
    updatedAt: datetime = None  # type: ignore


class StatsFormRequestModel(BaseModel):
    """
    Request Form
    """
    statsDate: datetime = Field()
    bpUp: int = Field()
    bpDown: int = Field()
    pulse: int = Field()
    weight: float = Field()
    bfp: float = Field()
    bmi: float = Field()
    alcBeer: int = Field(default=0)
    alcOther: int = Field(default=0)
    uid: str = Field()

    @classmethod
    def check_alcohol_int(cls, value: int, column: str) -> bool:
        """
        validate alcohol check
        :param value: stats
        :param column: column name
        :return: boolean(True == ok)
        :exception: ValueError
        """
        if (value >= 0 and value <= 2000):
            return True
        raise ValueError(f"invalid {column}: {value}")

    @classmethod
    def check_int(cls, value: int, column: str) -> bool:
        """
        validate integer check
        :param value: stats
        :param column: column name
        :return: boolean(True == ok)
        :exception: ValueError
        """
        if (value > 0 and value <= 300):
            return True
        raise ValueError(f"invalid {column}: {value}")

    @classmethod
    def check_float(cls, value: float, column: str) -> bool:
        """
        validate float check
        :param value: stats
        :param column: column name
        :return: boolean(True == ok)
        :exception: ValueError
        """
        if (value > 0 and value <= 100):
            return True
        raise ValueError(f"invalid {column}: {value}")

    @validator('pulse')
    def check_pulse(cls, value: int) -> int:  # type: ignore
        if cls.check_int(value, 'pulse'):
            return value
    # MEMO 長いので省略

    @validator('alcOther')
    def check_other_alchol(cls, value: int) -> int:  # type: ignore
        if cls.check_alcohol_int(value, 'alcOther'):
            return value

    @root_validator
    def check_bp(cls, values: Dict) -> Dict:
        if cls.check_int(values['bpUp'], 'bpUp') and cls.check_int(values['bpDown'], 'bpDown') and (
                values['bpUp'] > values['bpDown']):
            return values
        raise ValueError(f"invalid bp: up: {values['bpUp']} down: {values['bpDown']}")

    class Meta:
        pass


class StatsFormResponceModel(BaseModel):
    """
    Response Body
    """
    status: str = 'ok'
    datetime: str = ''

    class Meta:
        pass


def generate_unique_key() -> str:
    """
    Generate unique key(use database)
    :return: uuid
    """
    _key = uuid.uuid1()
    return str(_key)


def form2save_data(form: StatsFormRequestModel, created_at: datetime, updated_at: datetime) -> StatsData:
    """
    Form data to save data
    :param form: Form data
    :param created_at: created at
    :param updated_at: updated at
    :return: Save stats
    """
    data: StatsData = StatsData(**form.dict())
    data.createdAt = created_at
    data.updatedAt = updated_at
    return data


認証をGatewayに任せたので, バックエンドの実装はホントに薄かったです.*4

App Engineで運用

バックエンドのホスティングは最初Cloud Runで考えていたのですが, Cloud Runの仕様上Load Balancerを挟まないとIAPが使えないことがわかったため, Load Balancerを挟む必要がないかつ, 管理も楽なApp Engineでホスティングしました.

FastAPIをApp Engineで動かすのは昔チャレンジして失敗*5したのでどうかなって思ったのですが,

medium.com

この記事に記載の通り, 「uvicornをminimalではなく, standardでインストール」してrequirements.txtを整理したら無事動きました.

また, API Gatewayを作って運用する関係上IAP(Identity-Aware-Proxy)を有効化し, 許可したユーザーとAPI Gateway以外のアクセスは弾くようにしています.

ネットワークの設定を気にすること無くゼロトラストに実現できると同時に,

  • ホストしたアプリのバグ調査の時などは直接ブラウザで見て開ける
  • 今回は要件に無いので作ってないが, 管理者用の管理画面アクセスの制御をアプリの外で実現できる

といったメリットがあるので, IAPはちゃんと使えるといろいろ便利です.

Firebase Authenticationで認証

認証はFirebase Authenticationを使いました.

firebase.google.com

自分で実装する煩わしさを回避(これが一番の理由)しつつ, 「フロントエンドでログインしたユーザーがtokenを使ってバックエンドにアクセス」という綺麗な構造が描きやすいのも採用した理由の一つです.

  1. ユーザーはフロントエンドにアクセスしてログイン
  2. ログインに成功すると, バックエンド認証に必要なJWT tokenが発行される
  3. バックエンドに用事があるときはJWT tokenを付与してアクセス

使い方は難しくなくて,

  • 画面をポチポチしてAuthenticationを有効化
  • ユーザーを作成
  • ログイン & token取得をフロントエンドで実装

Firebaseでの認証はフロントエンド周りだと結構たくさん記事があるのでそこを参考にして作りました.

API GatewayをCloud Endpoints + Cloud Runで用意

Firebase Authenticationのtokenを検証する機能を実装する...なんてことは, 時間的リソースが限られる個人開発ではやりたくないので,

  • Cloud Endpointsで認証機能を定義
  • 定義して生成したイメージをCloud Runでホスティング

することで回避しました.

API Gatewayの定義をopenapiのお作法で作り, ちょっとビルドしてデプロイして完成です.

これはすでに去年検証済みで上手く行ってたので, この方式をそのまま使いました.

shinyorke.hatenablog.com

ちょっとボリュームある記事ですがこちらに詳しく書いてる&参考文献もあるのでやりたい方は参考にどうぞ.

スケーリングできちゃうよ

今回はお一人様SaaSとして(半ば趣味, 半ば実験的に)作っていますが, スケーラビリティを配慮して作っているのでそのまま商用のSaaSのアーキテクチャとしても応用可能です.

  • 実はすべてサーバレス前提のクラウドサービスで構築
  • Firebase, Cloud Run, App Engineすべてスケーリングが楽にできる
  • RDBMS的なボトルネックが無い

トラフィックが334万倍ぐらい増えたら流石に考慮しないといけない気もしなくもないですが(主にコスト面で), この構成でもちゃんと運用したらTV砲くらいは打ち返せると思います(理屈上)

結び

というわけで, ひっそりと昨年末からやってる個人開発の第1段階に終わりが見えてきたので言語化する意味でもブログとして書いてみました.

正直な話, 現時点ではいい感じのアーキテクチャだと思いますが,

  • 確かに認証機能は実装してないけど, 構築とか設定とかでやること・考えることがめちゃ多い(知ってる・知らないの問題が大半だが)
  • いい感じにマイクロサービス化できてるが, 業務でやるときはもうちょっとガバナンス効かせたいな...とか(放置すると難解なピタゴラスイッチになる)
  • 最初に断ったとおり, Firebaseのフロントエンドから導通してないんだよね...理屈上できるけどちょっと厄介

などなど, いくつかの課題がありますし, 3年経てばこのパターンも多分過去のものになりそうな気もしています.

確かなのは, 勝ち筋とか筋がいいアーキテクチャって難しいので, 勉強とプロダクト開発しながらこの辺の知識と感性は引き続き磨きたいなって思います.

なお, これの開発に一段落ついたらそろそろ野球データサイエンスやろうと思います, こっちもこっちでネタが山ほどある...

最後までお読みいただきありがとうございました.

*1:血圧・体重・脈拍は2014年はじめから計測してるので8年分はデータがあります笑

*2:言い方を変えると, 「ある程度Webアプリの開発経験ある人がいい感じにショートカットするための話」なので, 初心者がそのまま真似するのはちょっと危ういかもって思ってます.

*3:アプリケーションのバグおよび, CORS周りでちょっと苦戦しています, Gatewayの設定に問題があるかも...

*4:なお, 仮に仕様が変わってユーザーごとに権限をつける(認可する)ようなフローが発生すると, 流石にバックエンドの実装なしでは難しくなります, 今回は作ったものがシンプルだったのできれいに別れた説あります.

*5:詳細はこの辺にあります, 上手く行かなくて調査しようとしたのですが時間が無かったのでFlaskに書き換えて動かしました(書き換えのほうがすぐイメージできたので躊躇せず実行)