從 AWS EC2 遷移到 GCP Cloud Run: 架構決策與踩坑紀錄
把 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 | 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:
|
|
GCP 多了一步 “impersonate Service Account”, 因為 GCP 的所有 API 操作都需要一個 SA 身分。AWS 的 OIDC 直接拿到一個 Role 的 credentials 就可以用了
舊架構 vs 新架構
舊架構 (AWS EC2)
|
|
新架構 (GCP Cloud Run)
|
|
少了 nginx、certbot、SSH、SCP、.env, deploy workflow 從 ~150 行砍到 ~60 行
Terraform 三層分割
把 Terraform 拆成三個獨立的 root stack:
|
|
為什麼要拆
| 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:
|
|
deploy workflow 只需要傳 api_image 一個變數, 其他都從 remote state 讀
WIF (Workload Identity Federation) 安全邊界
基本設定
GCP 的 WIF 透過 attribute mapping 把 GitHub OIDC token 的 claim 對應到 GCP 的 attribute:
|
|
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 比較安全:
|
|
Cloud SQL 設定
踩坑: Enterprise vs Enterprise Plus
PostgreSQL 17 以上, Cloud SQL 預設 edition 是 Enterprise Plus。但最便宜的 db-f1-micro 只有 Enterprise 才能用。沒有明確指定 edition 會拿到比預期貴的 instance 或直接 apply 失敗:
|
|
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:
|
|
Database URL 用 Unix socket path, 由 Terraform 自動組合後存進 Secret Manager:
|
|
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:
|
|
踩坑: serviceAccountUser scope
CI/CD 的 Terraform SA 需要 roles/iam.serviceAccountUser 才能讓 Cloud Run “act as” runtime SA。第一版放在 project level — 代表它能 impersonate project 裡任何 SA。改成只綁到特定 runtime SA:
|
|
這是 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 夠了, 但要注意:
- 不是 GA, 正式環境不建議用
- Terraform SA 必須是 domain 的 verified owner (下面會說明)
- DNS 設定是 CNAME, 不是 A record
- 未來升級 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 帳號。這代表:
- 你的個人 Google 帳號驗證了 domain, 不代表 Terraform SA 也有權限。SA 是獨立的 identity, 必須在 Search Console 裡被加為 domain 的 verified owner
- 這是一個手動步驟, 無法用 Terraform 或 API 完成。你必須去 Search Console UI 裡手動加 SA 的 email
- 如果忘了這步,
terraform apply會在google_cloud_run_domain_mapping直接報錯, 錯誤訊息是 domain verification failure, 不容易第一時間聯想到 Search Console
操作步驟:
|
|
這個設計的原因可能跟 Google 的產品歷史有關 — Search Console 本來就是 Google 管理 domain ownership 的地方, Cloud Run 直接沿用了這個驗證機制, 而不是像 AWS/Azure 一樣在 cloud 服務內自建驗證流程
Deploy Workflow 對比
舊版 (AWS EC2, ~150 行)
|
|
新版 (GCP Cloud Run, ~60 行)
|
|
差異:
- 不用管 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:
|
|
uniform_bucket_level_access: 統一用 IAM 管理, 不用 ACLpublic_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
經驗總結
- 先搞清楚概念對應: GCP 和 AWS 的 IAM 模型差很多, Service Account vs IAM Role 的思維方式不同, 搬家前先理清
- Terraform 拆 stack: 依變動頻率拆, deploy 要快, 不要每次都 plan 整個世界
- WIF trust boundary 一開始就要嚴: 鎖到 repo + branch, 用 immutable ID 不要用 name
- Least privilege 不是口號: project-level IAM 很方便但遲早會出事, per-resource binding 比較安全
- Cloud SQL edition 要明確指定: PG 17+ 預設 Enterprise Plus, 最低成本要設
ENTERPRISE - 不要假設東西存在: 每一個 “這個功能應該有” 的假設都要去 repo 裡驗證
References
- Cloud Run Overview
- Cloud Run Domain Mapping
- Cloud SQL Connect from Cloud Run
- Cloud SQL Editions
- Cloud SQL Configure SSL
- Workload Identity Federation with Deployment Pipelines
- Secret Manager Access Control
- Artifact Registry Overview
- Terraform GCS Backend
- Terraform Google Provider
- Cloud Run Domain Mapping Troubleshooting
- Verify Site Ownership (Search Console)
- GitHub Actions OIDC Token Claims