0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Cloud Run + Google OAuth2でWebアプリを公開する

0
Posted at

はじめに

  • GCPのCloud Runで自作WebアプリをデプロイしてURLで公開したい
  • ただし誰でも見れるのは困るので、特定のGoogleアカウントのみアクセスできるようにしたい
  • LB + IAPが正攻法だが、個人用途ではコストが高い(月$27〜)ため、アプリ側でGoogle OAuth2を実装して代替する

本記事で扱う内容

  • FastAPI製WebアプリをCloud Runにデプロイし、Google OAuth2でアクセス制限をかけるまでの流れ
    • ハンズオンで実行することを想定しています
  • ※インフラは全くの門外漢なので、技術的な詳細には言及が難しい点はご理解ください

前提

  • GCPプロジェクト・Workload Identity Federation・terraform CI/CDが構築済みであること
  • カスタムドメインを取得済みであること(Google OAuth2のコールバックURLにHTTPSが必要)

アーキテクチャ

ブラウザ → カスタムドメイン → Cloud Run(FastAPI)→ Google OAuth2
                                      ↑                    ↑
                             Artifact Registry    Secret Manager
                             (Dockerイメージ)  (OAuthクライアント情報)

ディレクトリ構成

|-- [APP_NAME]
    |-- terraform
    |   |-- main.tf              # terraform/provider設定
    |   |-- cloud_run.tf         # Cloud Run・Artifact Registry
    |   |-- iam.tf               # サービスアカウント・権限
    |   |-- secrets.tf           # Secret Manager
    |   |-- workload_identity.tf # Workload Identity Federation
    |   |-- variables.tf
    |   |-- outputs.tf
    |-- web
        |-- main.py
        |-- pyproject.toml
        |-- Dockerfile
        |-- frontend/
            |-- index.html

流れ

1. 必要なAPIを有効化する

gcloud services enable run.googleapis.com
gcloud services enable artifactregistry.googleapis.com
gcloud services enable secretmanager.googleapis.com

2. カスタムドメインの取得

Google OAuth2のコールバックURLにはHTTPSが必要なため、カスタムドメインが必要。
お名前.comなどのドメインレジストラで取得する(年間1,000〜2,000円程度)。

  • お名前.com で希望のドメインを検索・購入する
  • 取得後、ネームサーバーをDNSレコード設定用(お名前.comの場合は 01.dnsv.jp04.dnsv.jp)に変更する

3. カスタムドメインの設定

3-1. Google Search Consoleでドメイン所有権を確認

  1. Google Search Console にアクセス
  2. 「プロパティを追加」→「ドメイン」→ [YOUR_DOMAIN] を入力
  3. 表示されるTXTレコードをDNSプロバイダに追加
  4. 「確認」ボタンを押す

3-2. Cloud RunにカスタムドメインをMapping

gcloud beta run domain-mappings create \
  --service=[APP_NAME] \
  --domain=[YOUR_DOMAIN] \
  --project=[PROJECT_ID] \
  --region=asia-northeast1

出力されるAレコードをDNSプロバイダに追加する。反映後(数時間程度)、HTTPS証明書が自動発行される。

4. GCP ConsoleでOAuth2クライアントを作成(手動)

  1. GCP Console → 「APIとサービス」→「認証情報」
  2. 「OAuthクライアントID」を作成 → アプリケーションの種類: ウェブアプリケーション
  3. 承認済みリダイレクトURIに https://[YOUR_DOMAIN]/auth/callback を追加
  4. 取得したクライアントID・シークレットをメモする

5. TerraformでGCPリソースを定義する

cloud_run.tf

resource "google_artifact_registry_repository" "app" {
  repository_id = "[APP_NAME]"
  format        = "DOCKER"
  location      = var.region
}

resource "google_cloud_run_v2_service" "app" {
  name     = "[APP_NAME]"
  location = var.region

  template {
    service_account = google_service_account.cloud_run.email
    containers {
      image = "${var.region}-docker.pkg.dev/${var.project_id}/[APP_NAME]/web:latest"

      env {
        name  = "ALLOWED_EMAILS"
        value = var.allowed_emails
      }

      env {
        name  = "BASE_URL"
        value = "https://[YOUR_DOMAIN]"
      }

      env {
        name = "GOOGLE_CLIENT_ID"
        value_source {
          secret_key_ref {
            secret  = google_secret_manager_secret.google_client_id.secret_id
            version = "latest"
          }
        }
      }

      env {
        name = "GOOGLE_CLIENT_SECRET"
        value_source {
          secret_key_ref {
            secret  = google_secret_manager_secret.google_client_secret.secret_id
            version = "latest"
          }
        }
      }

      env {
        name = "SECRET_KEY"
        value_source {
          secret_key_ref {
            secret  = google_secret_manager_secret.session_secret_key.secret_id
            version = "latest"
          }
        }
      }

      ports {
        container_port = 8080
      }
    }
  }
}

# allUsersにrun.invokerを付与(認証はアプリ側のOAuth2で実施)
resource "google_cloud_run_v2_service_iam_member" "public" {
  project  = var.project_id
  location = var.region
  name     = google_cloud_run_v2_service.app.name
  role     = "roles/run.invoker"
  member   = "allUsers"
}

iam.tf

resource "google_service_account" "github_actions" {
  account_id   = "github-actions-sa"
  display_name = "GitHub Actions Service Account"
}

resource "google_project_iam_member" "github_actions_editor" {
  project = var.project_id
  role    = "roles/editor"
  member  = "serviceAccount:${google_service_account.github_actions.email}"
}

resource "google_project_iam_member" "github_actions_run_admin" {
  project = var.project_id
  role    = "roles/run.admin"
  member  = "serviceAccount:${google_service_account.github_actions.email}"
}

resource "google_service_account" "cloud_run" {
  account_id   = "[APP_NAME]-sa"
  display_name = "[APP_NAME] Cloud Run SA"
}

secrets.tf

resource "google_secret_manager_secret" "google_client_id" {
  secret_id = "google-client-id"
  replication { auto {} }
}

resource "google_secret_manager_secret" "google_client_secret" {
  secret_id = "google-client-secret"
  replication { auto {} }
}

resource "google_secret_manager_secret" "session_secret_key" {
  secret_id = "session-secret-key"
  replication { auto {} }
}

resource "google_secret_manager_secret_iam_member" "google_client_id_access" {
  secret_id = google_secret_manager_secret.google_client_id.secret_id
  role      = "roles/secretmanager.secretAccessor"
  member    = "serviceAccount:${google_service_account.cloud_run.email}"
}

resource "google_secret_manager_secret_iam_member" "google_client_secret_access" {
  secret_id = google_secret_manager_secret.google_client_secret.secret_id
  role      = "roles/secretmanager.secretAccessor"
  member    = "serviceAccount:${google_service_account.cloud_run.email}"
}

resource "google_secret_manager_secret_iam_member" "session_secret_key_access" {
  secret_id = google_secret_manager_secret.session_secret_key.secret_id
  role      = "roles/secretmanager.secretAccessor"
  member    = "serviceAccount:${google_service_account.cloud_run.email}"
}

variables.tf

variable "project_id" {
  type    = string
  default = "[PROJECT_ID]"
}

variable "region" {
  type    = string
  default = "asia-northeast1"
}

variable "allowed_emails" {
  type    = string
  default = "[YOUR_EMAIL]"
}

6. GitHub ActionsでterraformのCI/CDを設定する

前提記事で構築したWorkload Identity FederationによるCI/CDを使い、terraform plan/applyを自動化する。

name: Terraform Deploy

on:
  push:
    branches:
      - main
    paths:
      - '[APP_NAME]/terraform/**'
  pull_request:
    branches:
      - main
    paths:
      - '[APP_NAME]/terraform/**'
  workflow_dispatch:
    inputs:
      apply:
        description: 'Run terraform apply'
        type: boolean
        default: false

permissions:
  id-token: write
  contents: read

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Workload Identity Federationを使ってGCPに認証
      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: projects/[PROJECT_NUMBER]/locations/global/workloadIdentityPools/github-pool/providers/github-provider
          service_account: github-actions-sa@[PROJECT_ID].iam.gserviceaccount.com

      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Init
        working-directory: [APP_NAME]/terraform
        run: terraform init

      - name: Terraform Plan
        working-directory: [APP_NAME]/terraform
        run: terraform plan

      # mainへのpush、またはworkflow_dispatchでapplyチェックを入れた場合のみapply
      - name: Terraform Apply
        working-directory: [APP_NAME]/terraform
        if: (github.ref == 'refs/heads/main' && github.event_name == 'push') || inputs.apply
        run: terraform apply -auto-approve
  • PRを作成するとterraform planが走り、差分を確認できる
  • mainにmergeするとterraform applyが自動実行される
  • workflow_dispatch で手動実行する場合は「Run terraform apply」にチェックを入れるとapplyまで実行される

7. Secret Managerに値を登録する

terraform applyでsecretコンテナが作成された後、値を登録する。登録前はterraform applyがエラーになるため、初回のみ下記の順序で実施する:

  1. terraform applyを実行(secretコンテナが作成されるが、Cloud Runのデプロイはエラーになる)
  2. 値を登録する
  3. terraform applyを再実行(Cloud Runのデプロイが成功する)
echo -n "[YOUR_CLIENT_ID]" | gcloud secrets versions add google-client-id \
  --data-file=- --project=[PROJECT_ID]

echo -n "[YOUR_CLIENT_SECRET]" | gcloud secrets versions add google-client-secret \
  --data-file=- --project=[PROJECT_ID]

echo -n "$(openssl rand -hex 32)" | gcloud secrets versions add session-secret-key \
  --data-file=- --project=[PROJECT_ID]

8. FastAPIにGoogle OAuth2を実装する

pyproject.toml — 依存追加

dependencies = [
    "authlib>=1.3.0",
    "fastapi>=0.136.1",
    "httpx>=0.27.0",
    "itsdangerous>=2.2.0",
    "python-dotenv>=1.2.2",
    "starlette>=0.46.0",
    "uvicorn>=0.46.0",
]

main.py

import os
from authlib.integrations.starlette_client import OAuth
from dotenv import load_dotenv
from fastapi import FastAPI, Response
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from starlette.requests import Request

load_dotenv()

app = FastAPI()

app.add_middleware(SessionMiddleware, secret_key=os.getenv("SECRET_KEY"))

FRONTEND_DIR = os.getenv("FRONTEND_DIR", "frontend")
ALLOWED_EMAILS = os.getenv("ALLOWED_EMAILS", "").split(",")

oauth = OAuth()
oauth.register(
    name="google",
    client_id=os.getenv("GOOGLE_CLIENT_ID"),
    client_secret=os.getenv("GOOGLE_CLIENT_SECRET"),
    server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
    client_kwargs={"scope": "openid email"},
)

app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")


@app.get("/auth/login")
async def login(request: Request):
    redirect_uri = os.getenv("BASE_URL") + "/auth/callback"
    return await oauth.google.authorize_redirect(request, redirect_uri)


@app.get("/auth/callback")
async def callback(request: Request):
    token = await oauth.google.authorize_access_token(request)
    email = token["userinfo"]["email"]
    if email not in ALLOWED_EMAILS:
        return Response("Forbidden", status_code=403)
    request.session["user"] = email
    return RedirectResponse("/")


@app.get("/")
def index(request: Request):
    if not request.session.get("user"):
        return RedirectResponse("/auth/login")
    return FileResponse(f"{FRONTEND_DIR}/index.html")


# 各APIエンドポイントにも同様の認証チェックを追加する
@app.get("/api/example")
def example(request: Request):
    if not request.session.get("user"):
        return Response("Unauthorized", status_code=401)
    return {"message": "ok"}

9. DockerイメージをビルドしてCloud Runにデプロイする

dockerワークフローにビルド・push・デプロイをまとめて定義する。

name: Docker Build and Push

on:
  push:
    branches:
      - main
    paths:
      - '[APP_NAME]/web/**'
  pull_request:
    branches:
      - main
    paths:
      - '[APP_NAME]/web/**'
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: projects/[PROJECT_NUMBER]/locations/global/workloadIdentityPools/github-pool/providers/github-provider
          service_account: github-actions-sa@[PROJECT_ID].iam.gserviceaccount.com

      - name: Configure Docker for Artifact Registry
        run: gcloud auth configure-docker asia-northeast1-docker.pkg.dev

      # mainへのpushまたはworkflow_dispatchのときのみArtifact Registryにpush
      # PRのときはビルドのみ(pushなし)
      - name: Build and Push Docker Image
        uses: docker/build-push-action@v6
        with:
          context: [APP_NAME]/web
          push: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' }}
          tags: asia-northeast1-docker.pkg.dev/[PROJECT_ID]/[APP_NAME]/web:latest

      # terraform applyはCloud Runのリソース定義に変更がない場合は何もしないため
      # イメージ更新のみではCloud Runに反映されない。gcloud run services updateで明示的にデプロイする
      - name: Deploy to Cloud Run
        if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
        run: |
          gcloud run services update [APP_NAME] \
            --region=asia-northeast1 \
            --project=[PROJECT_ID] \
            --image=asia-northeast1-docker.pkg.dev/[PROJECT_ID]/[APP_NAME]/web:latest
  • PRを作成するとDockerビルドのみ実行され、イメージが正常にビルドできるか確認できる
  • mainにmergeするとArtifact Registryへのpush + Cloud Runへのデプロイが自動実行される
  • workflow_dispatch で手動実行も可能

備忘

  • Secret Managerの注意点として、terraformはsecretのコンテナ(箱)を作るだけで、値の登録は別途手動で行う必要がある
  • カスタムドメインのCloud RunへのMappingはTerraformでは管理できないため手動で実施する点に注意
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?