Skip to content

Latest commit

 

History

History
949 lines (714 loc) · 68.3 KB

README.md

File metadata and controls

949 lines (714 loc) · 68.3 KB

Cloud Run SNS

Cloud RunでデプロイするシンプルなSNSアプリケーション

ハンズオンの概要

Google Cloudを活用したシンプルなWebアプリケーションを開発し、サービスとしてデプロイ・公開するまでの手順をハンズオン形式で体験することができます。

主な内容

このハンズオンでは、主に以下の内容に取り組みます。

  • Dockerコンテナを用いた開発環境の構築やコンテナ間通信のための設定
  • NextAuth・Google OAuth2を用いた、Googleアカウント認証のための設定や実装
  • Firestoreのデータベース作成や情報を取得・編集するためのバックエンドAPIの実装
  • 作成したバックエンドのAPIをフロントエンドから呼び出すための設定や実装
  • JWT(JSON Web Token)を検証することによる、安全性の高い認証付きAPIの実装
  • Cloud Runを利用した、マイクロサービスとしてのWebアプリケーションのデプロイ
  • バックエンドサービスのCloud Runに認証を追加することによる、安全性の向上

一方で、以下の内容はあまり重視しません。サンプルコードで多くの部分を実装してあります。

  • Next.jsを用いた基本的なフロントエンドの実装
  • CSSを用いたUIデザインの作成
  • FastAPIを用いた基本的なバックエンドの実装

大まかな手順

  • GitHub・Dockerを用いた環境構築
  • Webアプリケーションの開発(フロントエンド+バックエンド)
  • フロントエンドとバックエンドを、マイクロサービスとしてそれぞれCloud Runにデプロイ

事前に必要なもの

  • Web開発に十分なスペックのPC(Mac・Linux・WSLを推奨)
  • GitHubアカウント
  • Docker Desktop(Docker)
  • Google Cloudプロジェクト
    • ハンズオンは基本的に無料枠の範囲内で実行可能
    • 発展編の一部は、わずかに従量課金が発生する可能性あり
  • Visual Studio Codeなどのエディタ
  • 基礎的なWeb開発についての知識

技術スタック

  • フロントエンド:Next.js + TypeScript + Sass
    • NextAuth:Next.jsでOAuth認証を行うパッケージ
  • バックエンド:FastAPI(Python)
  • Google Cloud
    • Cloud Run:マイクロサービスのデプロイ
      • Cloud Build:GitHubへのpushをトリガーとする自動ビルド・デプロイ
    • Firestore:データベース(NoSQL)
    • Google OAuth2:Googleアカウント認証
    • API Gateway:JWTを用いた認証付きAPIの実装・APIのロギングなど(発展編)
    • Cloud Storage:画像・動画などメディアファイルのアップロード・保存(発展編)
    • (Container Registry:Dockerイメージをアップロードするデプロイ方法の場合のみ)
  • Docker:開発環境の構築・Cloud Runへのデプロイ
  • GitHub:フロントエンド・バックエンドは1つのリポジトリに統合

サンプルコード

GitHubリポジトリとして、以下のURLで公開しています。

https://github.com/aya-se/cloud-run-sns

また、いくつかのブランチがあり、それぞれ以下のようになっています。

  • main:全てのアプリケーション側の実装を完了した状態のソースコード
  • lab:ハンズオン中に必要な部分を修正することを前提とした、未完成状態のソースコード

ハンズオンでは、リポジトリを自分のGitHubアカウントにForkし、labブランチに切り替えてから開発を進めることをおすすめします。

手順

GitHub・Dockerによる開発環境構築

サンプルソースコードの取得

  • GitHubのリポジトリをForkしてからCloneします。
git clone [email protected]:${GitHubのユーザー名}/cloud-run-sns.git
cd cloud-run-sns

Dockerによる環境開発構築

このハンズオンでは、開発環境を全てDockerコンテナを通して構築します。frontendコンテナとbackendコンテナの2つを同時に起動し、コンテナ間で通信することによってアプリケーションを動かします。

  • Docker Desktopをまだ起動していない場合は起動します。
  • /frontendディレクトリに.env.localファイルを作成し、環境変数API_URLを追加します。通信時のホスト名はlocalhostではなく、コンテナ名になることに注意してください。
API_URL=http://backend:8080
  • /backendディレクトリに空の.env.localファイルを作成します。中身は後で加筆します。
  • frontendコンテナに必要なパッケージを導入します。
    • Sass・NextAuth・ESlint・Prettier・Google Auth Library
docker-compose run --rm app sh
npm install
  • frontendbackendの2つのコンテナを同時に立ち上げます。
docker-compose build
docker-compose up
  • ブラウザでフロントエンド・バックエンドのURLを開きます。
    • フロントエンド:http://localhost:3000
    • バックエンド:http://0.0.0.0:8080/docs
      • /docsはFastAPI標準の機能により、自動生成されたSwaggerを表示
  • フロントエンドは初期状態で次のような画面になっているはずです。サインインボタンを押すことはできますが、まだ必要な環境変数を指定していないため、エラーになってしまいます。

Untitled

  • バックエンドはFastAPIの標準機能で自動生成されるSwaggerが表示されます。「Try it out」ボタンでAPIを試すこともできますが、まだ正常に実行することができません。

Untitled

💡 **補足:Dockerfile・docker-compose.ymlの詳しい記述内容**
  • フロントエンド
    • Dockerfile:Cloud Runへのデプロイ・ローカル開発環境の構築に使用
      • npm installと本番環境起動時のコマンド(npm run start)を含む
    • docker-compose.yml:ローカル開発環境の構築に使用
      • WATCHPACK_POLLING:ホットリロードのための環境変数
      • command:Next.jsの開発環境起動時のコマンド(npm run dev
  • バックエンド
    • Dockerfile:Cloud Runへのデプロイ・ローカル開発環境の構築に使用
      • requirement.txtに含まれるパッケージをインストール
      • --reload:ホットリロードのためのオプション
    • docker-compose.yml:ローカル開発環境の構築に使用
      • env_file:ローカル開発で必要な環境変数を読み込むファイルの指定
💡 **補足:2つのコンテナはどのように通信しているのか?**

今回、frontendコンテナとbackendコンテナは、同一のdocker-compose.ymlの中で作成しており、どちらもデフォルトで、共通のブリッジネットワークであるcloud-run-sns_defaultに属しています。このため、特別な設定をせずに、コンテナ間での通信が実現しています。一方で、異なるdocker-compose.ymlで2つのコンテナを立ち上げた場合などは、別途、外部ネットワークの作成と接続が必要です。

dockerコマンドにより、Networkの一覧や情報を取得することができます。

docker network ls

NETWORK ID     NAME                    DRIVER    SCOPE
444e54a69485   bridge                  bridge    local
eda870ddfa5a   cloud-run-sns_default   bridge    local
d85ecb5fa5f9   host                    host      local
15a98508ee7a   none                    null      local
docker inspect cloud-run-sns_default

[
	{
		"Containers": {
	    "xxx": {
	      "Name": "frontend",
				...
      },
      "xxx": {
	      "Name": "backend",
				...
      },
  }
]

参考文献

フロントエンドの開発(Next.js・OAuth認証)

ディレクトリ構成

/frontendディレクトリはNext.jsの標準的な構成になっています。

  • /src/components:UIの部品(コンポーネント)
  • /src/pages:ページなど
  • /src/styles:Sassによるページ・コンポーネントのスタイリング
  • /src/types:TypeScriptによる型定義
  • .env.local:Next.jsのローカル開発環境でのみ用いる環境変数
  • next.config.js:Next.jsのさまざまな設定を記述
frontend
├── public
├── src
│   ├── components
│   ├── pages
│   ├── styles
│   └── types
├── .env.local
├── Dockerfile
└── next.config.js
  • next.config.jsにホスト名lh3.googleusercontent.comを追加します。これは、Next.jsでGoogleアカウントのアイコンを表示させるために必要となる設定です。
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  images: {
    domains: ["lh3.googleusercontent.com"],
  },
};

module.exports = nextConfig;

NextAuth・Google OAuth2によるGoogleアカウント認証

NextAuthはNext.jsでOAuth認証を行う上で便利なパッケージです。今回は、このNextAuthを利用して、Google OAuth2によるGoogleアカウント認証を実装します。

  • まずはGoogle Cloudコンソール上で作業します。
  • 「APIとサービス>OAuth同意画面」からアプリケーションを作成します。
    • User Type:外部

Untitled

  • 「作成」をクリックして、次の設定に進みます。
    • アプリ名:Cloud Run SNSなど(任意の名前でOK)
    • ユーザー サポートメール:自分のGoogleアカウントのアドレス
    • アプリのロゴ:/frontend/public/google-cloud.pngなど(任意)
    • デベロッパーの連絡先情報:自分のGoogleアカウントのアドレスなど

Untitled

  • 「保存して次へ」をクリックして次の設定に進みます。スコープの設定は特に行わず、もう一度「保存して次へ」をクリックして次の設定に進みます。
    • 「ADD USERS」をクリックしてテストユーザーを追加します。今後のアプリケーション検証のために、複数のGoogleアカウントを登録するとより良いでしょう。

Untitled

  • 「保存して次へ」をクリックすると、OAuth同意画面の作成が完了し、登録したアプリの情報を閲覧・編集できるようになります。

Untitled

  • 「APIとサービス>認証情報」から「認証情報を作成」でOAuthクライアントIDを発行します。
    • アプリケーションの種類:ウェブアプリケーション
    • 名前:Cloud Run SNS OAuthなど(任意の名前でOK)
    • 承認済みのリダイレクトURI」に、 http://localhost:3000/api/auth/callback/googleを追加
      • ここに登録したURIからしか、Googleアカウントの認証画面にアクセスすることができない

Untitled

  • 「作成」をクリックすると、「クライアントID」と「クライアントシークレット」が発行されるので、記録しておきます。
  • ローカルの開発フォルダに戻って作業します。
  • .env.localに環境変数を追加し、以下のようにします。
    • API_URL:バックエンドのURL(http://backend:8080
    • GOOGLE_CLIENT_ID:OAuthのクライアントID
    • GOOGLE_CLIENT_SECRET:OAuthのクライアントシークレット
    • NEXTAUTH_URL:フロントエンドのURLを指定(http://localhost:3000/
    • NEXTAUTH_SECRET:JWTを暗号化しトークンをハッシュするために使用する鍵
      • OpenSSLコマンドでランダムな鍵を生成する
openssl rand -base64 32
API_URL=http://backend:8080
GOOGLE_CLIENT_ID=${OAuthクライアントID}
GOOGLE_CLIENT_SECRET=${OAuthクライアントシークレット}
NEXTAUTH_URL=http://localhost:3000/
NEXTAUTH_SECRET=${ランダム生成した鍵}
  • ここまで設定すると、フロントエンドの「サインイン」ボタンをクリックして、Googleアカウントでサインインできるようになるはずです。実際にGoogle純正のログイン画面が表示され、サインインが完了して次のような画面になれば成功です!🎉🎉🎉
  • 右上が「サインアウト」ボタンに変わっています。クリックするとサインアウトします。再び「サインイン」ボタンからサインインすることもできます。
  • フォームと「投稿」ボタンが表示されるようになりましたが、まだ正常に投稿を作成することはできません。

Untitled

  • NextAuthによるGoogleアカウント認証の実装について、確認しておきます。NextAuth特有の実装は/pages/api/[…nextauth].tsにあり、以下のようになっています。基本的には、NextAuthの公式ドキュメント通りの実装になっています。
    • process.env.xxxは先ほど.env.localに追加した環境変数を参照しています。GoogleプロバイダーによるOAuth認証にはGOOGLE_CLIENT_IDGOOGLE_CLIENT_SECRETの2つの環境変数が必要です。
    • callbacksにはJWT(JSON Web Token)をセッション情報に含めるような実装を追加しています。JWTのid_tokenを検証することでユーザ情報を取得できるようになっており、後ほどバックエンドAPIの実装で利用します。
import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";

export default NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID ?? "",
      clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      if (account) {
        token.idToken = account.id_token;
      }
      return token;
    },
    async session({ session, token }) {
      session.user.idToken = token.idToken;
      return session;
    },
  },
});
  • これ以外には、全ページ共通の内容を記述する/pages/_app.tsxSessionProviderを追加するほか、使用したい部分でuseSessionsignInsignOut等をimportして呼び出すだけ(/components/Header.tsxなど)となっており、非常に実装が簡単です。詳しくはソースコードやドキュメントを参照してください。

参考文献(NextAuth)

💡 **補足:Next.jsのAPI呼び出し方法について**

どちらの方法も一長一短ですが、今回のサンプルコードではサーバーサイドから呼び出す方法で統一しています。

  • クライアントサイドから呼び出す場合
    • NEXT_PUBLIC_API_URLを環境変数に登録して、fetchで呼び出し
    • Cloud Runへのデプロイ時に、Dockerfileへの環境変数の追記が必要
    • FastAPI側で、CORSを回避するための実装が必要
    • APIのURLがブラウザの開発者ツールなどから直接閲覧できてしまう
  • サーバーサイドから呼び出す場合
    • API_URLを環境変数に登録して、fetchで呼び出し
    • Dockerでのローカル開発環境構築時に共有Networkの登録が必要
    • getServerSidePropsはこちらに相当
    • クライアント側からPOSTリクエストなどを呼びたい場合は、/page/apiに転送用のAPIルートを用意し、その中から外部APIを呼び出すように実装することが考えられる(サーバーサイドからの呼び出しになる)
      • 二重にAPIを挟むような実装になるので、冗長感はあるが、サーバーサイドからの呼び出しに統一できる

バックエンドの開発(FastAPI・Firestore・API Gateway)

ディレクトリ構成

/backendディレクトリはFastAPIの標準的な構成になっています。

  • /api/routers:APIのルートごとの処理
  • /api/schemas:APIのリクエストやレスポンスにおける型クラスの定義
  • /api/main.py:バックエンドサーバー起動時に実行するプログラム
  • .env.local:ローカル開発環境でのみ用いる環境変数
  • requirements.txt:必要なPythonモジュールの一覧
backend
├── api
│   ├── routers
│   ├── schemas
│   └── main.py
├── .env.local
├── .gitignore
├── (application_default_credential.json)
├── Dockerfile
└── requirements.txt

API構成

  • GET:/posts:投稿の一覧を取得する

  • POST:/posts:新しいメッセージを投稿する

  • DELETE:/posts:投稿を削除する

  • 必要なパッケージがrequirements.txtに追加されていることを確認します。不足していた場合は追記し、backendコンテナを再ビルド・再起動します。

fastapi
uvicorn
pydantic
google-cloud-firestore
google-auth

Firestoreのデータベース作成と接続

アプリケーションの投稿(post)を保存するためのデータベースをGoogle CloudのFirestoreで作成し、バックエンドから接続できるようにします。FirestoreはGoogle Cloudが提供するデータベースのサービスの1つですが、NoSQL型のデータベースであり、無料枠が充実しています。(※SQL型のデータベースとしてCloud SQLなどもありますが、やや高機能で課金が発生しやすくなっているため、今回は利用しません。)

  • Google Cloudコンソールの「Firestore」を開き、ネイティブモードasia-northeast1にデータベースを作成します。
    • 注:Filestore(ストレージ)ではなくFirestore(データベース)

Untitled

Untitled

  • データベースの作成が完了すると、次のような画面になります。まだ何もデータを作成していないので、空の状態です。

Untitled

  • Firestoreにアクセス・編集するためのサービスアカウント(GSA)を作成します。Google Cloudコンソールから「IAMと管理>サービスアカウント」を開き、「サービスアカウントを作成」をクリックします。
    • サービスアカウント名:firestore-user
    • サービスアカウントID:firestore-user
    • サービスアカウントの説明:Service account for Firestoreなど(任意)
    • 「ロールを追加」から「Datastore>Cloud Datastore ユーザー」を選択

Untitled

Untitled

  • サービスアカウントの一覧にfirestore-user@${プロジェクト名}.iam.gserviceaccount.comが追加されていることを確認し、クリックします。
  • 「キー」タブに移動し、「鍵を追加」「新しい鍵を作成」の順にクリックします。

Untitled

  • キーのタイプを「JSON」とし、「作成」をクリックすると、ローカルマシンにJSONファイルがダウンロードされます。このファイルに認証情報が含まれています。
  • ダウンロードしたJSONファイル名をfirestore-user.jsonに変更し、/backendディレクトリにコピーします。
  • /backendディレクトリの.env.localファイルに、必要な環境変数を追加します。この2つは、認証情報が自動で読み込まれないローカル開発環境の構築にのみ必要です。
GOOGLE_APPLICATION_CREDENTIALS=firestore-user.json
GOOGLE_CLOUD_PROJECT=${Google CloudのプロジェクトID}
  • .gitignoreファイルに、.env.localfirestore-user.jsonが追加されていることを確認します。この2つのファイルには外部に公開するべきではない情報が含まれているので、誤ってGitHubのリモートリポジトリにpushしてしまわないように注意してください。
  • ここまでの時点でGET:/postsAPIを動かすことが可能になりました。/api/routers/post.pyを開き、以下の部分のコメントアウトを削除します。
    • os.getenvで環境変数を取得しています。先ほど.env.localに追加したGOOGLE_CLOUD_PROJECTにあるFirestoreを、GOOGLE_APPLICATION_CREDENTIALS(つまりfirestore-user.json)にあるサービスアカウントの認証情報を使って取得します。
    • firestore-userというサービスアカウントには、「Cloud Datastore ユーザー」というIAM(権限)を付与していたため、Firestoreの情報を取得・編集することができます。IAMを付与していない場合は、権限不足でエラーになってしまいます。
    • Firestoreはコレクションの中にドキュメントを格納するという形式になっています。ここでは、postsというコレクションに1つ1つの投稿を格納するという想定です。デフォルトでは順序がバラバラになってしまうので、timestampフィールドの降順に並び替えて、新しい順に取得するようにしています。
db = firestore.Client(os.getenv("GOOGLE_CLOUD_PROJECT"))

@router.get("/posts", response_model=List[post_schema.Post])
async def get_root():
    # 投稿を取得
    docs = db.collection(u"posts").order_by(
        u"timestamp", direction=firestore.Query.DESCENDING).get()
    # 投稿データを整形
    posts = []
    for doc in docs:
        data = doc.to_dict()
        data["id"] = doc.id
        posts.append(data)
    return posts
  • google.auth.exceptions.DefaultCredentialsErrorとなるので、環境変数を読み込むために一度Dockerコンテナを停止し、再起動します。
  • /docsの「Try it out」ボタンからGET:/postsAPIを実行してみましょう。「Execute」ボタンをクリックして、以下のように、Statusが200で空の配列がレスポンスとして返ってくれば成功です!🎉🎉🎉

Untitled

  • 続いて、フロントエンドからもAPIを呼び出してみましょう。/pages/index.tsxgetServerSideProps()のコメントアウトを編集し、以下のようにします。
    • ここでは、JavaScript標準のfetch()関数を用いてAPIを呼び出します。
export async function getServerSideProps() {
  const API_URL = process.env.API_URL;
  /*
  const auth = new GoogleAuth();
  const client = await auth.getIdTokenClient(API_URL ?? "");
  const res = await client.request({ url: `${API_URL}/posts` });
  const data = (await res.data) as Array<Post>;
  */
  const res = await fetch(`${API_URL}/posts`);
  const data = (await res.json()) as Array<Post>;
  // const data = new Array<Post>();
  const props: Props = {
    data: data,
  };
  return {
    props: props,
  };
}
  • ブラウザからフロントエンドをリロードしてみましょう。まだ投稿が何も無いので、表示上は何も変わりませんが、Dockerコンテナを起動しているターミナルを確認すると、リロードの度にbackendコンテナでGET:/postsAPIが呼び出され、Status200でレスポンスが返ってきていることがわかります。これにより、フロントエンドからバックエンドのAPIを正常に呼び出せていることが確認できました!🎉🎉🎉

Untitled

ここまでの時点でGET:/postsAPIを実装できました。続いて、投稿を作成・削除するAPI(POST:/postsDELETE:/posts)についても実装していきます。

JWTトークンによる認証付きAPIの実装

もし、POSTDELETEのAPIに適切な認証を付けなかった場合、非サインイン状態だとしても、何らかの方法でAPIを直接リクエストすることで、不正に投稿を作成・削除できてしまう可能性があります。そこで、バックエンドサーバー側で、Firestoreへのアクセス前にGoogleのプロバイダーから取得済みのJWT(JSON Web Token)を検証することで、APIを保護し、よりセキュアなシステムを目指します。

  • JWTにはaccess_tokenid_tokenの2種類のトークンがありますが、JWT自体の検証にはid_tokenを使用します。
    • access_token:リソースへのアクセスを認可するためのトークン
    • id_token:ユーザーが認証されたことを証明するトークン、デコードすることで認証されたユーザーの情報を取得することが可能
  • X-Id-Token: ${id_token}の形でヘッダにJWTのid_tokenを追加し、JWTが有効なものであると検証できた場合のみ、その後の処理を実行します。
    • ただし、HTTP通信だとトークンを傍受される可能性があるため、セキュリティ的にはHTTPS通信であることが前提になります。
  • /backendディレクトリの.env.localファイルに、以下の環境変数を追加します。
GOOGLE_CLIENT_ID=${OAuthクライアントID}
  • /api/routers/post.pyを開き、以下の部分のコメントアウトを削除します。
    • POST:/postsAPIが呼び出された時の処理を実装しています。一見、投稿データを加工し、Firestoreにドキュメントを追加しているだけのように思えますが、post_root()の引数にid_info = Depends(verify_token)が含まれていることに注目してください。verify_token()という関数は/api/routers/auth.pyに実装してありますが、実はこの部分でJWTトークンの認証を行っています。
@router.post("/posts")
async def post_root(post_body: post_schema.PostCreate, id_info = Depends(verify_token)):
    # 投稿データ
    data = {
        u"timestamp": firestore.SERVER_TIMESTAMP,
        u"user_name": id_info["name"],
        u"user_email": id_info["email"],
        u"user_image": id_info["picture"],
        u"text": post_body.text
    }
    # 投稿を作成
    db.collection(u"posts").document().set(data)
    return {"message": "Post created successfully"}
  • /api/routers/auth.pyverify_token()の実装を確認します。
    • まず、verify_token()にはx_id_token: str = Header(None)という引数があります。ここでHTTPリクエストのHeaderに含まれるX_Id_Tokenフィールドを受け取ります。 x_id_tokenがない場合はその時点でエラーを返すようになっています。
    • x_id_tokenがある場合は、google-authパッケージを用いてJWTのid_tokenを検証します。x_id_tokenが実際にGoogleプロバイダーが発行したJWTであると確認できた場合は、id_infoを返します。id_infoにはGoogleのアカウント情報が含まれており、名前・Email・アイコン画像などを取得できます。
    • 今回のPOST:/postsAPIでは、リクエストのbodyはtextのみとし、ユーザー情報はヘッダーのX_Id_Tokenを検証して得られたid_infoから取得するようにしています。これにより、ユーザー本人ではない何者かが、不正にその人になりすまして投稿できてしまうリスクや、不正なフィールド値の投稿を作成してしまうことによるエラーのリスクを下げることができます。
      • セキュリティ上は、id_tokenを盗まれないことが重要になります。HTTP通信だとヘッダから情報を盗まれてしまう可能性があるので、HTTPS通信にするべきでしょう。
import os
from fastapi import HTTPException, Header
from google.oauth2 import id_token
from google.auth.transport import requests
  
def verify_token(x_id_token: str = Header(None)):
    if not x_id_token:
        raise HTTPException(status_code=401, detail="X-Id-Token header required")
    try:
        # IDトークンの検証
        id_info = id_token.verify_oauth2_token(x_id_token, requests.Request(), os.getenv("GOOGLE_CLIENT_ID"))
        if id_info['iss'] not in ['accounts.google.com', 'https://accounts.google.com']:
            raise ValueError('Wrong issuer.')
        return id_info
    except ValueError:
        raise HTTPException(status_code=401, detail="Invalid authentication")
  • 動作検証として、以下のようなcurlコマンドでPOSTが行えないことを確認します。
curl "http://0.0.0.0:8080/posts" -X POST -d'{"text":"hoge"}' -H "content-type: application/json"
  • /docsでも試してみましょう。「Try it out」ボタンを押して、x-id-tokenやbodyのtextに適当な値を入力して、「Execute」ボタンをクリックしても、Statusが401となり、投稿が作成できないことが確認できます。

Untitled

  • 続いて、/api/routers/post.pyを開き、以下の部分のコメントアウトを削除します。
    • DELETE:/postsAPIが呼び出された時の処理を実装しています。基本的にPOSTと変わりませんが、こちらは投稿した本人であるかどうかの確認も追加しています。
@router.delete("/posts")
async def delete_root(post_delete_body: post_schema.PostDelete, id_info = Depends(verify_token)):
    doc = db.collection(u"posts").document(post_delete_body.id).get()
    # 存在しない投稿を削除しようとした場合
    if not doc.exists:
        raise HTTPException(status_code=404, detail="Post not found")
    doc_email = doc.to_dict()["user_email"]
    # 他人の投稿を削除しようとした場合
    if doc_email != id_info["email"]:
        raise HTTPException(status_code=403, detail="Forbidden")
    # 投稿を削除
    db.collection(u"posts").document(post_delete_body.id).delete()
    return {"message": "Post deleted successfully"}

APIの動作検証

ここまでの時点で、フロントエンド・バックエンドで最低限必要な設定が完了したため、アプリケーション全体を動かすことが可能です。

  • ブラウザでhttp://localhost:3000にアクセスし、画面が表示されることを確認します。
    • Dockerのbackendコンテナを開いているターミナルを確認します。GET:/postsリクエストが届いており、Status200で正常にレスポンスが返されていればOKです。
backend  | INFO:     xxx.xx.x.x:xxxxx - "GET /posts HTTP/1.1" 200 OK
  • 右上の「サインイン」ボタンをクリックします。Google純正のアカウント認証が画面されるので、自分のGoogleアカウントでサインインします。
  • ログインしたら元のページにリダイレクトされ、自分のアカウントの情報が取得できていることを確認します。
  • 「新しい投稿を開始」のフォームに何か文字を入力し、「投稿」ボタンをクリックします。画面がリロードされ、投稿した自分のメッセージが表示されることを確認します。

Untitled

Untitled

  • Dockerのbackendコンテナを開いているターミナルを確認します。POST:/postsリクエストおよびGET:/postsリクエストが届いており、status:200で正常にレスポンスが返されていればOKです。
backend  | INFO:     xxx.xx.x.x:xxxxx - "POST /posts HTTP/1.1" 200 OK
backend  | INFO:     xxx.xx.x.x:xxxxx - "GET /posts HTTP/1.1" 200 OK
  • Google Cloudコンソールで「Firestore」を開き、データベースに実際にデータが追加されていることを確認します。postsというコレクションが新しく作成され、その中に投稿した内容が追加されていればOKです。なお、ドキュメントの名前はFirestore側でユニークな値として自動生成され、GET:/postsAPIはこれをidとして受け取ります。

Untitled

  • サインイン状態の場合、自分の投稿には「削除」ボタンが表示されています。右上の「サインアウト」ボタンをクリックしてサインアウトすると、「削除」ボタンが表示されなくなることを確認します。

Untitled

  • 右上の「サインイン」ボタンをクリックし、再度サインインします。
  • 自分の投稿に表示されている「削除」ボタンをクリックします。画面がリロードされ、投稿した自分のメッセージが削除されたことを確認します。
  • Dockerのbackendコンテナを開いているターミナルを確認します。DELETE:/postsリクエストおよびGET:/postsリクエストが届いており、status:200で正常にレスポンスが返されていればOKです。
backend  | INFO:     xxx.xx.x.x:xxxxx - "DELETE /posts HTTP/1.1" 200 OK
backend  | INFO:     xxx.xx.x.x:xxxxx - "GET /posts HTTP/1.1" 200 OK
  • Google Cloudコンソールで「Firestore」を開き、データベースから実際にデータが削除されていることを確認します。postsコレクションのドキュメントが削除されていればOKです。

ここまでの時点で、基本的なアプリケーションの開発が完了しました!次はいよいよ、Cloud Runを用いて、Google Cloud上にサービスをデプロイし、公開していきます。

参考文献(FastAPI・Firestore)

Cloud Runによるマイクロサービスのデプロイ

ローカル上でアプリケーションの機能を一通り実装することができたので、アプリケーションをGoogle Cloud上にデプロイしていきます。今回は、Cloud Runを用いてデプロイしますが、その中でも、Cloud Buildの機能を活用し、GitHubリポジトリのpushをトリガーとしてビルドする方法でデプロイします。指定したGitHubリポジトリが更新されると自動的に再ビルド・デプロイを行うことができるため、大変便利なデプロイ方法です。以下では、フロントエンド・バックエンドをマイクロサービスとしてそれぞれ個別にデプロイします。

⚠️ **注意:請求先アカウントの登録について**

Cloud Runでサービスを作成する際には、請求先アカウントの登録が必要になる場合があります。この場合は指示に従い、必要事項を登録して課金を有効にしてください。なお、本ハンズオンの基本編は、基本的に無料枠の範囲内で実行できるため、課金は発生しません。

Untitled

フロントエンドサービスのデプロイ

  • Google Cloudのコンソールで「Cloud Run」を開き、「サービスの作成」をクリックします。
    • 「ソース リポジトリから新しいリビジョンを継続的にデプロイする」を選択し、「Cloud Buildの設定」をクリック
      • Source repository:リポジトリプロバイダを「GitHub」とし、リポジトリを指定
      • Build Configuration:ブランチを指定し、Build TypeをDockerfile、ソースの場所は/frontend/Dockerfileを指定
    • サービス名:cloud-run-sns-frontendなど(任意の名前でOK)
    • リージョン:asia-northeast1(東京)を推奨
    • CPUの割り当てと料金:リクエストの処理中にのみCPUを割り当てる
    • 自動スケーリング:最小数 0 / 最大数 1
    • 「すべて」を選択(インターネットからサービスに直接アクセスできるようにします)
    • 認証:「未認証の呼び出しを許可」

Untitled

Untitled

  • 「作成」をクリックし、デプロイが完了すれば成功です!🎉🎉🎉
    • ただしこの時点では、必要な環境変数を設定していないので、まだアプリケーションは正常に動作しません。

Untitled

  • フロントエンドサービスのURLが表示されるので、コピーしておきます。

バックエンドサービスのデプロイ

  • Google Cloudのコンソールで「Cloud Run」を開き、「サービスの作成」をクリックします。
    • 「ソース リポジトリから新しいリビジョンを継続的にデプロイする」を選択し、「Cloud Buildの設定」をクリック
      • Source repository:リポジトリプロバイダを「GitHub」とし、リポジトリを指定
      • Build Configuration:ブランチを指定し、Build TypeをDockerfile、ソースの場所は/backend/Dockerfileを指定
    • サービス名:cloud-run-sns-backendなど(任意の名前でOK)
    • リージョン:asia-northeast1(東京)を推奨
    • CPUの割り当てと料金:リクエストの処理中にのみCPUを割り当てる
    • 自動スケーリング:最小数 0 / 最大数 1
    • 「すべて」を選択(インターネットからサービスに直接アクセスできるようにします)
    • 認証:「未認証の呼び出しを許可」
  • 「作成」をクリックし、デプロイが完了すれば成功です!🎉🎉🎉
    • ただしこの時点では、必要な環境変数を設定していないので、まだアプリケーションは正常に動作しません。
  • バックエンドサービスのURLが表示されるので、コピーしておきます。
  • ここまでの時点で、Google Cloudのコンソールで「Cloud Run」を開き、以下のように2つのサービスが登録されていることを確認してください。

Untitled

環境変数の追加・リダイレクトURIの設定

.env.localに追加した環境変数は、あくまでもローカル環境での開発のためのものであり、Google Cloudにデプロイした際は、別途、環境変数の登録が必要です。また、OAuth同意画面におけるリダイレクトURIの設定も必要になります。

  • Google Cloudのコンソールで「Cloud Run」を開き、フロントエンドサービスcloud-run-sns-frontend)をクリックします。
  • 「新しいリビジョンの編集とデプロイ」をクリックし、以下の環境変数を追加します。
    • API_URL:バックエンドサービスのURLを指定(注:末尾に/を含めない)
    • GOOGLE_CLIENT_ID:OAuthのクライアントID
    • GOOGLE_CLIENT_SECRET:OAuthのクライアントシークレット
    • NEXTAUTH_URL:フロントエンドサービスのURLを指定
    • NEXTAUTH_SECRET:JWTを暗号化しトークンをハッシュするために使用する鍵
      • OpenSSLコマンドでランダムな鍵を生成する
        • OpenSSLコマンドが使えない場合はインストールする
openssl rand -base64 32

Untitled

  • 「デプロイ」をクリックし、リビジョンが100%移行したことを確認します。
  • Google Cloudのコンソールで「Cloud Run」を開き、バックエンドサービスcloud-run-sns-backend)をクリックします。
  • 「新しいリビジョンの編集とデプロイ」をクリックし、以下の環境変数を追加します。
    • GOOGLE_CLIENT_ID:OAuthのクライアントID
  • 「デプロイ」をクリックし、リビジョンが100%移行したことを確認します。
💡 **補足:バックエンドサービスへの一部の環境変数追加は不要**
  • GOOGLE_APPLICATION_CREDENTIALS
    • Google Cloudにデプロイした場合は、自動で認証情報を取得するため不要
  • GOOGLE_CLOUD_PROJECT
    • Google Cloud上では、初めからこの環境変数が登録されているため不要
  • Google Cloudのコンソールで「APIとサービス>認証情報」を開きます。
  • 「OAuth2.0クライアントID」から登録したクライアントIDを開き、「承認済みのリダイレクトURI」に[フロントエンドサービスのURL]/api/auth/callback/googleを追加します。

Untitled

デプロイしたサービスの動作確認

ここまでの時点で、マイクロサービスのデプロイと必要な設定が完了しました。フロントエンドサービスのURLにアクセスし、サービスが正しく動作していれば成功です!🎉🎉🎉

Untitled

試しに、アプリケーションを書き換えて、GitHubにpushしてみましょう。pushをトリガーとして、マイクロサービスが自動で再ビルド・再デプロイされることが確認できます。

💡 **補足:サービスが正しく動作しないときは**

何らかの設定ミス・実装ミスの可能性があります。Cloud Runのサービスの詳細から「ログ」タブに移動すると、エラーの原因を解明できるかもしれません。

上図の例では、NextAuthのSECRETが無いというエラーログが残されている。環境変数NEXTAUTH_SECRETの登録にミスがあるとわかったので、修正することで問題を解決できる。

上図の例では、NextAuthのSECRETが無いというエラーログが残されている。環境変数NEXTAUTH_SECRETの登録にミスがあるとわかったので、修正することで問題を解決できる。

💡 **補足:Container Registryを用いたデプロイ方法**

Container RegistryにsubmitしたDockerイメージを用いてCloud Runにデプロイする方法もあります。(本家のGoogle Cloud Skill Boostではむしろこちらが紹介されています。)

  • 以下、全てGoogle CloudのCloud Shell上で作業します。
  • GitHubのリポジトリをForkしてからCloneします。
git clone [email protected]:${GitHubのユーザー名}/cloud-run-sns.git
cd cloud-run-sns
  • Cloud Runにフロントエンド・バックエンドのマイクロサービスをデプロイします。
cd frontend
gcloud builds submit --tag [gcr.io/$GOOGLE_CLOUD_PROJECT/cloud-run-sns-](http://gcr.io/$GOOGLE_CLOUD_PROJECT/cloud-run-sns-backend)frontend
gcloud run deploy cloud-run-sns-frontend --image gcr.io/$GOOGLE_CLOUD_PROJECT/cloud-run-sns-frontend --platform managed --region asia-northeast1 --max-instances=1
cd ..
cd backend
gcloud builds submit --tag gcr.io/$GOOGLE_CLOUD_PROJECT/cloud-run-sns-backend
gcloud run deploy cloud-run-sns-backend --image gcr.io/$GOOGLE_CLOUD_PROJECT/cloud-run-sns-backend --platform managed --region asia-northeast1 --max-instances=1
  • GitHubのリポジトリのpushをトリガーとしてビルドする方法と同様、OAuthの「承認済みのリダイレクトURI」および、フロントエンドサービス・バックエンドサービスへの環境変数追加を行います。

以上の手順により、Container Registryを用いたCloud Runへのデプロイが完了します。しかし、この方法の場合、サービスのバージョンを更新する際に、毎回手動でアップデート作業を行う必要があります。今後は、GitHubのリポジトリのpushをトリガーとしてビルドするデプロイ方法が主流になるのではないかと考えられます。

バックエンドサービスのセキュリティ強化

マイクロサービスのデプロイに成功しましたが、セキュリティ面ではまだ問題があります。特に、バックエンドサービスにURL直打ちで直接アクセスできてしまうのは好ましくありません。そこで、バックエンドサービスを認証付きのサービスとし、フロントエンドサービスのみからアクセスできるよう、実装を修正します。

  • Google Cloudのコンソールで「Cloud Run」を開きます。
  • 「推奨事項」の列の「セキュリティ」をクリックすると、「この Cloud Run サービスのセキュリティを強化するには、最小限の権限を持つ専用のサービス アカウントを作成し、そのアカウントを使用して新しいリビジョンをデプロイしてください。」との記述があります。これに従って、「新しいサービスアカウントを作成」をクリックします。
    • サービスアカウント名:cloud-run-sns-frontend
    • サービスアカウントID:cloud-run-sns-frontend
    • 「ロールを追加」から「Cloud Run>Cloud Run 起動元」を選択
      • バックエンドサービスを呼び出すのに必要な権限
  • 「作成」をクリックすると、サービスアカウントがcloud-run-sns-frontendとなった状態のリビジョン設定画面が開くので、そのまま「デプロイ」をクリックします。

Untitled

⚠️ **注意:「推奨事項」の列が表示されない場合**
  • Google Cloudのコンソールで「IAM と管理>サービス アカウント」を開き、サービスアカウント(cloud-run-sns-frontend)を作成します。
  • Google Cloudのコンソールで「Cloud Run」を開き、フロントエンドサービスcloud-run-sns-frontend)をクリックします。
  • 「新しいリビジョンの編集とデプロイ」をクリックし、「セキュリティ」タブに移動します。
  • 「サービスアカウント」にcloud-run-sns-frontendを指定し、「デプロイ」をクリックします。
  • Google Cloudのコンソールで「Cloud Run」を開きます。
  • 「推奨事項」の列の「セキュリティ」をクリックすると、「この Cloud Run サービスのセキュリティを強化するには、最小限の権限を持つ専用のサービス アカウントを作成し、そのアカウントを使用して新しいリビジョンをデプロイしてください。」との記述があります。これに従って、「新しいサービスアカウントを作成」をクリックします。
    • サービスアカウント名:cloud-run-sns-backend
    • サービスアカウントID:cloud-run-sns-backend
    • 「ロールを追加」から「Datastore>Cloud Datastore ユーザー」を選択
  • 「作成」をクリックすると、サービスアカウントがcloud-run-sns-backendとなった状態のリビジョン設定画面が開くので、そのまま「デプロイ」をクリックします。
  • ここまでの時点で、Google Cloudのコンソールで「IAM と管理」を開き、以下のような状態になっていることを確認してください。

Untitled

  • また、ここまでの時点で引き続きサービスが正常に動作することを確認してください。

それではここから、バックエンドサービスに認証を要求するように、変更していきます。

  • Google Cloudのコンソールで「Cloud Run」を開き、バックエンドサービスcloud-run-sns-backend)をクリックします。
  • 「セキュリティ」タブに移動し、「認証」を「認証が必要です」に変更し、「保存」をクリックします。

Untitled

  • 認証を必要とした結果、ブラウザからバックエンドサービスのURLにアクセスしても「Error: Forbidden」となり、内容を閲覧できなくなりました。これにより、バックエンドサービスが外部アクセスから保護されたことがわかります。しかし、フロントエンドサービスからもバックエンドサービスからAPIを取得できなくなるため、「500 Internal Server Error.」と表示されるようになってしまいます。

Untitled

Untitled

  • そこで、フロントエンドサービスからバックエンドサービスにアクセスできるよう、API呼び出しの実装を変更します。
  • Google Cloudコンソールから「IAM と管理>サービスアカウント」を開き、フロントエンドサービスで使用しているサービスアカウント(cloud-run-sns-frontend)をクリックします。
  • 「キー」タブに移動し、「鍵を追加」「新しい鍵を作成」の順にクリックします。
  • キーのタイプを「JSON」とし、「作成」をクリックすると、ローカルマシンにJSONファイルがダウンロードされます。このファイルに認証情報が含まれています。
  • ダウンロードしたJSONファイル名をcloud-run-invoker.jsonに変更し、/frontendディレクトリにコピーします。
  • /frontendディレクトリの.env.localファイルに、GOOGLE_APPLICATION_CREDENTIALSを追加します。これは、認証情報が自動で読み込まれないローカル開発環境の構築にのみ必要です。さらに、検証のためにAPI_URLを(一時的にCloud RunにデプロイしたバックエンドサービスのURLに変更します。
GOOGLE_APPLICATION_CREDENTIALS=cloud-run-invoker.json
API_URL=${バックエンドサービスのURL}
  • .gitignoreファイルに、cloud-run-invoker.jsonが追加されていることを確認します。このファイルには外部に公開するべきではない情報が含まれているので、誤ってGitHubのリモートリポジトリにpushしてしまわないように注意してください。
  • /pages/index.tsxgetServerSideProps()のコメントアウトを編集し、以下のようにします。
    • これまではJavaScript標準のfetch()関数でHTTPリクエストを行っていましたが、google-auth-libraryパッケージを用いたサービス間認証に実装を変更します。
    • 先ほど.env.localに追加したGOOGLE_APPLICATION_CREDENTIALS(つまりcloud-run-invoker.json)にあるサービスアカウントの認証情報を使って、バックエンドサービス(API_URL)の情報を取得します。この情報を用いて、バックエンドサービスにリクエストを要求しています。
    • cloud-run-sns-frontendというサービスアカウントには、「Cloud Run 起動元」というIAM(権限)を付与していたため、Cloud Runサービスを起動して、リクエストを要求することができます。IAMを付与していない場合は、権限不足でエラーになってしまいます。
export async function getServerSideProps() {
  const API_URL = process.env.API_URL;
  const auth = new GoogleAuth();
  const client = await auth.getIdTokenClient(API_URL ?? "");
  const res = await client.request({ url: `${API_URL}/posts` });
  const data = (await res.data) as Array<Post>;
  /*
  const res = await fetch(`${API_URL}/posts`);
  const data = (await res.json()) as Array<Post>;
	*/
  // const data = new Array<Post>();
  const props: Props = {
    data: data,
  };
  return {
    props: props,
  };
}
  • 同様に、/pages/api/posts.tsの実装を以下のように変更します。
import { NextApiRequest, NextApiResponse } from "next";
import { GoogleAuth } from "google-auth-library";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const API_URL = process.env.API_URL;
  const auth = new GoogleAuth();
  const client = await auth.getIdTokenClient(API_URL ?? "");
  delete req.headers.host;
  const response = await client
    .request({
      url: `${API_URL}/posts`,
      method: req.method as "GET" | "POST" | "DELETE",
      headers: req.headers,
      data: req.body,
    })
    .catch((err) => {
      console.log(err);
      res.status(500).json({ message: "Internal Server Error" });
    });
  const data = await response;
  /*
  delete req.headers.host;
  const response = await fetch(`${API_URL}/posts`, {
    method: req.method,
    headers: req.headers as HeadersInit,
    body: JSON.stringify(req.body),
  });
  const data = await response.json();
  */
  res.status(200).json(data);
}
  • ここまでの時点で、フロントエンドの動作をローカルで確認します。投稿の取得・作成・削除が正常に動作していれば成功です!🎉🎉🎉
    • API_URLをGoogle Cloudにデプロイしたバックエンドサービスに変更しているのにも関わらず動作しています。バックエンドサービスは既に認証を必要とする設定であるため、ブラウザ等から直接アクセスすることはできません。このことから、フロントエンドサービスだけが、認証を経由してバックエンドサービスにアクセスできており、期待通りにセキュリティが向上したことが確認できます。
  • ここまでのソースコードの変更を保存してGitHubにpushします。マイクロサービスが自動で再ビルドされたら、ブラウザでフロントエンドサービスを開きます。投稿の取得・作成・削除が正常に動作していれば成功です!🎉🎉🎉
💡 **補足:サーバーレスVPCアクセスコネクタを利用する方法**

サーバーレスVPCアクセスコネクタを使用して、2つのCloud Runサービスを共通のVPCネットワークに接続し、バックエンドサービスのIngressを内部にすることで、バックエンドサービスを保護する方法もありますが、課金が発生することや、サービス構成が複雑になることから、今回は認証で保護する方法を採用しました。

⚠️ **注意:Cloud Runサービスの削除**
  • ハンズオンを終了する場合は、予期せぬ請求を防ぐため、デプロイしたサービスをすぐに削除することを推奨します。
  • この際、GitHubリポジトリのpushをトリガーとしてビルドする方法でデプロイしたCloud Runのサービスは、Cloud Buildトリガーも削除しないと、GitHubリポジトリのpush時に再度サービスがビルドされ、デプロイされてしまうので注意してください!

Untitled

参考文献

発展編:より高度なシステムの構築

(執筆中)

さらなる発展

今回のWebアプリケーションは必要最低限の機能のみを実装した、非常にシンプルなものです。より良いサービスとするために、以下のような機能の実装が考えられます。

  • 「いいね」「フォロー」「プロフィール閲覧」など、よりSNSらしい機能
  • Cloud Storageを用いて、画像・動画などのメディアをアップロード・投稿できるようにする