はじめに
- 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.jp〜04.dnsv.jp)に変更する
3. カスタムドメインの設定
3-1. Google Search Consoleでドメイン所有権を確認
- Google Search Console にアクセス
- 「プロパティを追加」→「ドメイン」→
[YOUR_DOMAIN]を入力 - 表示されるTXTレコードをDNSプロバイダに追加
- 「確認」ボタンを押す
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クライアントを作成(手動)
- GCP Console → 「APIとサービス」→「認証情報」
- 「OAuthクライアントID」を作成 → アプリケーションの種類: ウェブアプリケーション
- 承認済みリダイレクトURIに
https://[YOUR_DOMAIN]/auth/callbackを追加 - 取得したクライアント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がエラーになるため、初回のみ下記の順序で実施する:
- terraform applyを実行(secretコンテナが作成されるが、Cloud Runのデプロイはエラーになる)
- 値を登録する
- 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では管理できないため手動で実施する点に注意