從 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 文件裡

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 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)

1
2
3
4
5
6
7
8
9
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)

1
2
3
4
5
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:

1
2
3
4
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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_idrepository_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 比較安全:

1
2
3
# 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 失敗:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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:

1
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:

1
2
3
4
5
6
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:

1
2
3
4
5
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
2
3
4
5
6
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 行)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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 行)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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:

1
2
3
4
5
6
7
8
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

0%