
把 side project 的 backend 從 AWS EC2 + Docker Compose 搬到 GCP Cloud Run 的完整紀錄, 包含 GCP 與 AWS 的概念對應、Terraform 分層設計、WIF 安全邊界, 以及一路被 review 抓出來的問題

## GCP 與 AWS 的概念對應

兩邊都是 cloud provider, 但命名和分層方式不太一樣。搬家之前先搞清楚對應關係, 後面做設定才不會搞混

### 帳號與組織層級

| 概念 | AWS | GCP |
|------|-----|-----|
| 最上層管理單位 | Organization | Organization |
| 帳單與資源隔離 | Account | Project |
| 區域 | Region (us-east-1) | Region (us-east1) |

AWS 用 Account 隔離環境 (dev account / prod account), GCP 用 Project。GCP 的 Project 比 AWS 的 Account 更輕量, 建一個新的 Project 就像開一個新的隔離空間, 不需要另開帳號

### 運算服務

| 功能 | AWS | GCP |
|------|-----|-----|
| VM | EC2 | Compute Engine |
| Serverless container | App Runner / ECS Fargate | **Cloud Run** |
| Container orchestration | EKS | GKE |

Cloud Run 是這次遷移的核心。它的定位是: 你給它一個 container image, 它幫你跑, 不用管 VM、不用管 scaling。對應到 AWS 最接近 App Runner, 但 Cloud Run 更成熟

### 身分與權限 (IAM)

這是兩邊差異最大的地方:

| 概念 | AWS | GCP |
|------|-----|-----|
| 服務用的身分 | IAM Role + Instance Profile | **Service Account** |
| CI/CD 用的暫時性身分 | OIDC Provider + AssumeRoleWithWebIdentity | **Workload Identity Federation (WIF)** |
| 權限指派方式 | IAM Policy 掛在 Role 上 | IAM Binding 綁在 resource 或 project 上 |
| 權限繼承方向 | Policy → Role → Entity | Role → Member (on resource / project) |

AWS 的做法是: 建一個 IAM Role, 把 Policy (權限) 附加上去, 再把 Role 掛到 EC2 (透過 Instance Profile) 或讓 GitHub Actions assume

GCP 的做法不同: 建一個 Service Account, 然後在各個 resource 上建 IAM Binding, 把 role 綁到這個 SA。權限是散佈在各個 resource 上的, 不是集中在一個 Policy 文件裡

```
# AWS: policy is attached to the role
IAM Role
├── Trust Policy: who can assume this role
└── Permission Policy: what this role can do

# GCP: bindings are on each resource
Service Account (identity only, no embedded permissions)
├── Project IAM Binding: roles/cloudsql.client → SA
├── Secret IAM Binding: roles/secretmanager.secretAccessor → SA
└── Bucket IAM Binding: roles/storage.objectViewer → SA
```

### 資料與儲存

| 功能 | AWS | GCP |
|------|-----|-----|
| 物件儲存 | S3 | Cloud Storage (GCS) |
| 關聯式 DB 託管 | RDS | **Cloud SQL** |
| 密鑰管理 | Secrets Manager | **Secret Manager** |
| Container Registry | ECR / GHCR | **Artifact Registry** |
| Terraform state | S3 + DynamoDB (lock) | GCS (lock built-in) |

GCP 的 Terraform state 比 AWS 簡單: S3 需要額外建一個 DynamoDB table 做 state locking, GCS 直接內建

### CI/CD 身分驗證

兩邊都支援 GitHub Actions 用 OIDC token 換取暫時性的 cloud 身分, 不需要存 static credentials:

```
# AWS OIDC flow
GitHub Actions
  → request OIDC token from GitHub
  → send to AWS STS (AssumeRoleWithWebIdentity)
  → get temporary AWS credentials
  → use credentials to call AWS APIs

# GCP WIF flow
GitHub Actions
  → request OIDC token from GitHub
  → send to GCP STS (token exchange)
  → get federated token
  → impersonate Service Account
  → use SA credentials to call GCP APIs
```

GCP 多了一步 "impersonate Service Account", 因為 GCP 的所有 API 操作都需要一個 SA 身分。AWS 的 OIDC 直接拿到一個 Role 的 credentials 就可以用了

## 舊架構 vs 新架構

### 舊架構 (AWS EC2)

```
GitHub Actions
  ├── build → push to GHCR
  └── deploy → SSH into EC2
                 ├── pull image
                 ├── write .env
                 ├── docker compose up
                 └── certbot + nginx

Internet → EC2 → nginx → API container → Postgres container
```

### 新架構 (GCP Cloud Run)

```
GitHub Actions
  ├── build → push to Artifact Registry → resolve digest
  └── deploy → terraform apply (service stack)

Internet → Cloud Run (domain mapping) → API → Cloud SQL (Auth Proxy)
```

少了 nginx、certbot、SSH、SCP、.env, deploy workflow 從 ~150 行砍到 ~60 行

## Terraform 三層分割

把 Terraform 拆成三個獨立的 root stack:

```
infra/terraform/
  bootstrap/   # one-time, local apply
  platform/    # infra.yml workflow
  service/     # deploy.yml workflow
```

### 為什麼要拆

| Stack | 變動頻率 | 管理方式 | 內容 |
|-------|---------|---------|------|
| bootstrap | 幾乎不動 | 本地手動 | state bucket, WIF, CI/CD SA |
| platform | 偶爾改 | infra.yml workflow | DB, registry, secrets, runtime SA |
| service | 每次 deploy | deploy.yml workflow | Cloud Run service, IAM, domain mapping |

拆開的好處: deploy 只 plan/apply Cloud Run, 不碰 DB 和 registry, 速度快、爆炸半徑小

### 跨 stack 資料傳遞

service stack 需要知道 DB connection name 和 runtime SA email, 這些在 platform stack 裡。一開始用 CLI `-var` 手動傳, 被 review 指出來: 每次都要手動帶值, 容易出錯

改用 `terraform_remote_state`:

```hcl
data "terraform_remote_state" "platform" {
  backend = "gcs"
  config = {
    bucket = "my-project-tfstate"
    prefix = "platform"
  }
}

locals {
  db_connection_name = data.terraform_remote_state.platform.outputs.db_connection_name
  cloud_run_sa       = data.terraform_remote_state.platform.outputs.cloud_run_service_account_email
}
```

deploy workflow 只需要傳 `api_image` 一個變數, 其他都從 remote state 讀

## WIF (Workload Identity Federation) 安全邊界

### 基本設定

GCP 的 WIF 透過 attribute mapping 把 GitHub OIDC token 的 claim 對應到 GCP 的 attribute:

```hcl
resource "google_iam_workload_identity_pool_provider" "github" {
  attribute_mapping = {
    "google.subject"                = "assertion.sub"
    "attribute.repository_id"       = "assertion.repository_id"
    "attribute.repository_owner_id" = "assertion.repository_owner_id"
    "attribute.repository"          = "assertion.repository"
    "attribute.ref"                 = "assertion.ref"
  }

  attribute_condition = join(" && ", [
    "assertion.repository_id == \"<REPO_ID>\"",
    "assertion.repository_owner_id == \"<OWNER_ID>\"",
    "assertion.ref == \"refs/heads/main\"",
  ])

  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}
```

`attribute_condition` 是 provider 層級的閘門: 不符合條件的 token 直接被拒絕, 連 exchange 都做不了

### 踩坑: Trust boundary 不夠嚴

第一版只鎖了 `repository_id` 和 `repository_owner_id`, 沒有鎖 branch。被指出: repo 裡任何 branch 的 workflow 只要有 `id-token: write`, 都能交換成 GCP 身分。加上 `assertion.ref == "refs/heads/main"` 才收到 main branch only

### 踩坑: mutable name vs immutable ID

GitHub OIDC token 裡有 `repository` (name, 可改) 和 `repository_id` (數字, 不可改)。改 repo 名字會讓用 name 的 condition 斷掉, 用 ID 比較安全:

```bash
# get immutable IDs
gh api repos/<owner>/<repo> --jq '.id'
gh api users/<owner> --jq '.id'
```

## Cloud SQL 設定

### 踩坑: Enterprise vs Enterprise Plus

PostgreSQL 17 以上, Cloud SQL 預設 edition 是 Enterprise Plus。但最便宜的 `db-f1-micro` 只有 Enterprise 才能用。沒有明確指定 edition 會拿到比預期貴的 instance 或直接 apply 失敗:

```hcl
resource "google_sql_database_instance" "main" {
  database_version = "POSTGRES_17"

  settings {
    edition = "ENTERPRISE"
    tier    = "db-f1-micro"

    ip_configuration {
      ipv4_enabled = true
      ssl_mode     = "ENCRYPTED_ONLY"
    }
  }
}
```

`ssl_mode = "ENCRYPTED_ONLY"` 拒絕所有未加密連線。加上沒有設定 `authorized_networks`, 直接用 public IP 連 TCP 也連不進去, 只有 Cloud SQL Auth Proxy 能通

### Cloud Run 連 Cloud SQL

Cloud Run v2 內建 Cloud SQL Auth Proxy, 用 volume mount 把 Unix socket 掛進 container:

```hcl
template {
  volumes {
    name = "cloudsql"
    cloud_sql_instance {
      instances = ["project:region:instance-name"]
    }
  }

  containers {
    volume_mounts {
      name       = "cloudsql"
      mount_path = "/cloudsql"
    }
  }
}
```

Database URL 用 Unix socket path, 由 Terraform 自動組合後存進 Secret Manager:

```
postgresql+psycopg://user:password@/dbname?host=/cloudsql/project:region:instance
```

App code 不用改 — 它本來就支援 `DATABASE_URL` 環境變數

### 踩坑: enable_private_path 只適用 Private IP

一開始加了 `enable_private_path_for_google_cloud_services = true`, 想讓 Cloud Run 走 Google 內部網路。但 Google 文件明確說這個設定只在 Private IP 模式下有效, 對 Public IP instance 無意義。拿掉, 不加沒用的東西

## Secret Manager 最小權限

### 踩坑: project-level secretAccessor

第一版給 runtime SA `roles/secretmanager.secretAccessor` 在 project level, 代表它能讀 project 裡所有 secret。改成 per-secret IAM:

```hcl
resource "google_secret_manager_secret_iam_member" "runtime_access" {
  for_each  = toset(local.secrets)
  secret_id = google_secret_manager_secret.secrets[each.value].secret_id
  role      = "roles/secretmanager.secretAccessor"
  member    = "serviceAccount:${runtime_sa_email}"
}
```

### 踩坑: serviceAccountUser scope

CI/CD 的 Terraform SA 需要 `roles/iam.serviceAccountUser` 才能讓 Cloud Run "act as" runtime SA。第一版放在 project level — 代表它能 impersonate project 裡任何 SA。改成只綁到特定 runtime SA:

```hcl
resource "google_service_account_iam_member" "terraform_acts_as_runtime" {
  service_account_id = google_service_account.cloud_run.name
  role               = "roles/iam.serviceAccountUser"
  member             = "serviceAccount:${terraform_sa_email}"
}
```

這是 AWS 的 `iam:PassRole` 的 GCP 對應版。AWS 用 Condition 限制 PassRole 能交給哪個 service, GCP 用 resource-level IAM binding 限制能 impersonate 哪個 SA

## Custom Domain: Domain Mapping vs ALB

Cloud Run 綁 custom domain 有兩種方式:

| 方式 | 成本 | 狀態 |
|------|------|------|
| Cloud Run domain mapping | 免費 | Preview / Limited Availability |
| Global External ALB + serverless NEG | ~$18/月 | GA (General Availability) |

dev 環境用 domain mapping 夠了, 但要注意:

1. 不是 GA, 正式環境不建議用
2. Terraform SA 必須是 domain 的 verified owner (下面會說明)
3. DNS 設定是 CNAME, 不是 A record
4. 未來升級 ALB 時, app code 和 domain 都不用動, 只改 Terraform resource

### 踩坑: Domain Verification 綁 Google Search Console

Cloud Run domain mapping 要求 domain 的 ownership 必須在 Google Search Console 裡驗證過。這是 GCP 獨有的機制, AWS 和 Azure 都沒有這個要求:

| Cloud Provider | Custom Domain 驗證方式 |
|----------------|----------------------|
| **GCP Cloud Run** | Google Search Console domain verification |
| AWS App Runner / ALB | ACM (Certificate Manager) DNS validation |
| Azure Container Apps | 直接在 resource 上設定, 加 DNS record 驗證 |

AWS 和 Azure 的做法是: 你在 cloud 服務上設定 custom domain, 它給你一筆 DNS record (CNAME 或 TXT), 你加到 DNS provider, 驗證完成。整個流程都在 cloud 平台內完成

GCP 不一樣: domain ownership 是綁在 Google Search Console (原本是給 SEO / webmaster 用的工具) 上的, 而且是綁到 Google 帳號。這代表:

1. **你的個人 Google 帳號驗證了 domain, 不代表 Terraform SA 也有權限**。SA 是獨立的 identity, 必須在 Search Console 裡被加為 domain 的 verified owner
2. **這是一個手動步驟**, 無法用 Terraform 或 API 完成。你必須去 Search Console UI 裡手動加 SA 的 email
3. **如果忘了這步, `terraform apply` 會在 `google_cloud_run_domain_mapping` 直接報錯**, 錯誤訊息是 domain verification failure, 不容易第一時間聯想到 Search Console

操作步驟:

```
1. Go to Google Search Console
2. Select the verified property for your domain
3. Settings → Users and permissions → Add user
4. Enter the Terraform SA email (e.g. github-actions-terraform@project.iam.gserviceaccount.com)
5. Set permission to Owner
6. Save
```

這個設計的原因可能跟 Google 的產品歷史有關 — Search Console 本來就是 Google 管理 domain ownership 的地方, Cloud Run 直接沿用了這個驗證機制, 而不是像 AWS/Azure 一樣在 cloud 服務內自建驗證流程

## Deploy Workflow 對比

### 舊版 (AWS EC2, ~150 行)

```yaml
deploy:
  steps:
    - Configure AWS credentials (OIDC)
    - Get EC2 public IP
    - Validate 12+ secrets and variables
    - SCP compose files to EC2
    - SSH into EC2 and run deploy script:
        - write .env
        - install certbot + cronie
        - docker login to GHCR
        - docker compose pull / down / up
        - certbot + cron
    - Health check (HTTPS with HTTP fallback)
```

### 新版 (GCP Cloud Run, ~60 行)

```yaml
build:
  steps:
    - Authenticate to GCP (WIF)
    - Configure Docker for Artifact Registry
    - docker build + push
    - Resolve immutable digest (sha256)

deploy:
  steps:
    - Authenticate to GCP (WIF)
    - terraform init / plan / apply (service stack)
    - Health check (Cloud Run URL)
    - Health check (custom domain)
```

差異:
- 不用管 SSH key、.env、certbot、nginx
- image digest 是 immutable 的 (sha256), 不是 mutable tag
- Cloud Run 設定由 Terraform 管理, 沒有 drift
- secrets 由 Secret Manager 管理, 不用在 deploy 時傳進去

## State Bucket 安全

Terraform state 會包含 DB 密碼等敏感資訊。state bucket 需要 hardening:

```hcl
resource "google_storage_bucket" "terraform_state" {
  versioning {
    enabled = true
  }

  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"
}
```

- `uniform_bucket_level_access`: 統一用 IAM 管理, 不用 ACL
- `public_access_prevention = "enforced"`: 即使 IAM 設錯也不會公開
- `versioning`: state 被覆蓋或損壞時可以恢復

GCS 做 Terraform backend 的一個好處: state locking 內建, 不需要像 AWS 一樣另外建 DynamoDB table

## 成本

| 項目 | AWS EC2 | GCP Cloud Run |
|------|---------|---------------|
| Compute | ~$8-15/月 (t3.micro 24/7) | 幾乎免費 (低流量在 free tier) |
| Database | 免費 (container) | ~$7-10/月 (Cloud SQL db-f1-micro) |
| SSL | 免費 (certbot) | 免費 (自動管理) |
| Registry | 免費 (GHCR) | 低 (Artifact Registry) |
| 總計 | ~$8-15/月 | ~$8-12/月 |

成本差不多, 但營運複雜度大幅降低: 不用管 VM patching、SSH key rotation、certbot renewal、nginx config

## 經驗總結

1. **先搞清楚概念對應**: GCP 和 AWS 的 IAM 模型差很多, Service Account vs IAM Role 的思維方式不同, 搬家前先理清
2. **Terraform 拆 stack**: 依變動頻率拆, deploy 要快, 不要每次都 plan 整個世界
3. **WIF trust boundary 一開始就要嚴**: 鎖到 repo + branch, 用 immutable ID 不要用 name
4. **Least privilege 不是口號**: project-level IAM 很方便但遲早會出事, per-resource binding 比較安全
5. **Cloud SQL edition 要明確指定**: PG 17+ 預設 Enterprise Plus, 最低成本要設 `ENTERPRISE`
6. **不要假設東西存在**: 每一個 "這個功能應該有" 的假設都要去 repo 裡驗證

## References

- [Cloud Run Overview](https://cloud.google.com/run/docs/overview/what-is-cloud-run)
- [Cloud Run Domain Mapping](https://cloud.google.com/run/docs/mapping-custom-domains)
- [Cloud SQL Connect from Cloud Run](https://cloud.google.com/sql/docs/postgres/connect-run)
- [Cloud SQL Editions](https://cloud.google.com/sql/docs/postgres/editions-intro)
- [Cloud SQL Configure SSL](https://cloud.google.com/sql/docs/postgres/configure-ssl-instance)
- [Workload Identity Federation with Deployment Pipelines](https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines)
- [Secret Manager Access Control](https://cloud.google.com/secret-manager/docs/access-control)
- [Artifact Registry Overview](https://cloud.google.com/artifact-registry/docs/overview)
- [Terraform GCS Backend](https://developer.hashicorp.com/terraform/language/backend/gcs)
- [Terraform Google Provider](https://registry.terraform.io/providers/hashicorp/google/latest/docs)
- [Cloud Run Domain Mapping Troubleshooting](https://cloud.google.com/run/docs/troubleshooting#domain-mapping)
- [Verify Site Ownership (Search Console)](https://support.google.com/webmasters/answer/9008080)
- [GitHub Actions OIDC Token Claims](https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect#understanding-the-oidc-token)
