第 6 章:雲平台部署 — 把容器部署到 Cloud Run(free tier 優先)

第 6 章:雲平台部署 — 把容器部署到 Cloud Run(free tier 優先)

這一章在做什麼? 我們把第 4 章推到 GHCR 的 image,真正部署到 Cloud Run(GCP 的 serverless 容器服務),並用 Terraform 一併佈建一個最小的 Cloud SQL(雲端 PostgreSQL),讓 app 連上它。做完這章,你的後端會有一個公開網址,任何人都能 curl 到——它真的上線了。

為什麼選 Cloud Run? 因為它對新手最友善、最省錢:你只給它一顆 image,它就幫你跑起來、自動配網址與 HTTPS、沒流量時自動縮到零(不計費)、免費額度也大。你完全不用管伺服器。

前置:第 5 章的 Terraform 基礎(API、服務帳號)已 apply、image 已在 GHCR 且設為公開

💸 本章開始會產生費用! Cloud Run 在免費額度內+縮到零,幾乎是 $0;但 Cloud SQL 會按小時計費(最小規格約每月個位數美元),即使沒人用也照算。所以本章結尾有明確的 terraform destroy——學完就刪掉


步驟 1:先搞懂 Cloud Run

Cloud Run

GCP 的「serverless 容器」服務。你給它一顆容器 image,它負責跑、自動擴縮、配 HTTPS 網址。有請求才啟動、沒請求縮到零,按實際用量計費。

🔁 前端類比:很像把後端容器丟上 Vercel/Netlify 的感覺——push 一個產物,它給你一個會自動擴縮的網址。差別是 Cloud Run 跑的是你的容器,什麼語言都行。

它和「資料庫」的分工:Cloud Run 是無狀態的(縮到零、隨時被重建),所以資料庫不能跑在它裡面,要用獨立的 Cloud SQL(代管的 PostgreSQL)。app 透過 GCP 的 Cloud SQL 連接器(一條安全通道)連到它。


步驟 2:補上要部署的 image 與 random provider

  1. code/terraform/variables.tf 追加一個變數:
# code/terraform/variables.tf(追加)
variable "image" {
  description = "要部署的容器 image(GHCR,需為公開)"
  type        = string
}
  1. code/terraform/terraform.tfvars 填入你的 image(把 <你的帳號> 換掉):
# code/terraform/terraform.tfvars(追加)
image = "ghcr.io/<你的帳號>/no-logic-trade:latest"
  1. code/terraform/versions.tfrequired_providers 裡加上 random(我們會用它產生資料庫密碼):
# code/terraform/versions.tf(在 required_providers 內追加)
    random = {
      source  = "hashicorp/random"
      version = "~> 3.0"
    }

步驟 3:Terraform — 佈建 Cloud SQL(PostgreSQL)

建立 code/terraform/database.tf

# code/terraform/database.tf

# 給 Cloud SQL 實例名稱加一段隨機後綴:
# 因為「刪除後的實例名,GCP 會保留約一週不讓你重用」,加後綴可避免重建撞名。
resource "random_id" "suffix" {
  byte_length = 2
}

# 隨機產生資料庫密碼(存在 state 裡,不寫進版控)
resource "random_password" "db" {
  length  = 24
  special = false
}

resource "google_sql_database_instance" "pg" {
  name                = "nlt-pg-${random_id.suffix.hex}"
  database_version    = "POSTGRES_16"
  region              = var.region
  deletion_protection = false # 關鍵:讓 terraform destroy 能刪掉它

  settings {
    tier = "db-f1-micro" # 最小、最便宜的共享核心規格

    ip_configuration {
      ipv4_enabled = true # 給公開 IP(仍透過連接器安全連線)
    }
  }
}

resource "google_sql_database" "db" {
  name     = "nolologic"
  instance = google_sql_database_instance.pg.name
}

resource "google_sql_user" "user" {
  name     = "nolologic"
  instance = google_sql_database_instance.pg.name
  password = random_password.db.result
}

逐段解釋:

  • random_id.suffix:產生隨機後綴,避免 Cloud SQL 實例撞名(GCP 對「剛刪掉的名字」會保留約一週,是新手很常踩的雷)。
  • random_password.db:隨機產生 24 字密碼,存在 state(已 gitignore)。
  • google_sql_database_instance.pg:Cloud SQL 實例本體。
    • database_version = "POSTGRES_16"tier = "db-f1-micro"(最便宜)。
    • deletion_protection = false:很重要——預設是 true,會讓 terraform destroy 失敗。設 false 才能順利刪除。
    • ipv4_enabled = true:給它公開 IP(我們仍用連接器連,不直接暴露密碼)。
  • google_sql_database.db:在實例裡建立名為 nolologic 的資料庫。
  • google_sql_user.user:建立帳號 nolologic,密碼用上面隨機產生的。

步驟 4:Terraform — 佈建 Cloud Run 服務

建立 code/terraform/cloudrun.tf

# code/terraform/cloudrun.tf

resource "random_password" "jwt" {
  length  = 48
  special = false
}

# 讓 app 的服務帳號(第 5 章建的 nlt-app)能連 Cloud SQL
resource "google_project_iam_member" "sql_client" {
  project = var.project_id
  role    = "roles/cloudsql.client"
  member  = "serviceAccount:${google_service_account.app.email}"
}

resource "google_cloud_run_v2_service" "app" {
  name                = "nlt-api"
  location            = var.region
  deletion_protection = false

  template {
    service_account = google_service_account.app.email

    scaling {
      min_instance_count = 0 # 沒流量縮到零 → 不計費
      max_instance_count = 2
    }

    containers {
      image = var.image

      ports {
        container_port = 8080
      }

      # 環境變數:沿用你 app 既有的設定鍵(DB_HOST 等)
      env {
        name  = "APP_PORT"
        value = "8080"
      }
      env {
        name = "DB_HOST"
        # Cloud SQL 連接器會把 socket 掛在 /cloudsql/<連線名稱>
        value = "/cloudsql/${google_sql_database_instance.pg.connection_name}"
      }
      env {
        name  = "DB_PORT"
        value = "5432"
      }
      env {
        name  = "DB_NAME"
        value = google_sql_database.db.name
      }
      env {
        name  = "DB_USER"
        value = google_sql_user.user.name
      }
      env {
        name  = "DB_SSLMODE"
        value = "disable"
      }
      env {
        name  = "DB_PASSWORD"
        value = random_password.db.result
      }
      env {
        name  = "JWT_SECRET"
        value = random_password.jwt.result
      }

      # 把 Cloud SQL 連接器掛進來
      volume_mounts {
        name       = "cloudsql"
        mount_path = "/cloudsql"
      }

      resources {
        limits = {
          cpu    = "1"
          memory = "512Mi"
        }
      }
    }

    volumes {
      name = "cloudsql"
      cloud_sql_instance {
        instances = [google_sql_database_instance.pg.connection_name]
      }
    }
  }

  depends_on = [google_project_iam_member.sql_client]
}

# 允許「任何人」呼叫這個服務(這樣 curl 才打得到)
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"
}

逐段解釋:

  • random_password.jwt:替正式環境產一把夠長的 JWT 密鑰(取代開發用的 dev-secret)。
  • google_project_iam_member.sql_client:把 roles/cloudsql.client 授予 app 的服務帳號——少了它,app 連不上 Cloud SQL
  • google_cloud_run_v2_service.app:Cloud Run 服務本體。
    • service_account:以第 5 章的 nlt-app 身分執行。
    • scaling { min_instance_count = 0 }縮到零,沒流量時 $0。
    • containers.image = var.image:跑你 GHCR 上的 image。
    • 一堆 env { name value }直接對應你 app 既有的設定鍵(第 1 份教學的 config.go 讀這些),所以 app 一行程式都不用改。關鍵是 DB_HOST = /cloudsql/<連線名稱>——你的 lib/pq 會把開頭是 / 的 host 當成 unix socket 連線,正好接上連接器。
    • volume_mounts + volumes.cloud_sql_instance:把 Cloud SQL 連接器掛在 /cloudsql
    • depends_on:確保先有 IAM 權限,app 啟動才連得上 DB。
  • google_cloud_run_v2_service_iam_member.public:把 roles/run.invokerallUsers,服務才公開可呼叫(否則 curl 會 403)。

💡 關於密碼放在 env:為了讓教學好懂,我們把 DB 密碼/JWT 直接放進 env(它們在 state 裡、已 gitignore)。正式環境應改用 Secret Manager(值不落在 env/state)——第 10 章會提到 Secrets 管理原則。本章的 DB 反正用完即刪,先求跑通。

app 會自己建表嗎? 會。你的 Go app 啟動時會自動跑 migration(第 1 份教學的 migrator),所以 Cloud Run 一啟動、連上 Cloud SQL 就把 5 張表建好,不需要額外步驟。


步驟 5:apply,取得公開網址

cd code/terraform
terraform init      # 重新 init 以下載 random provider
terraform apply     # 輸入 yes 確認
  • Cloud SQL 建立要等幾分鐘(這是正常的,雲端在替你開一台資料庫)。
  • 完成後,輸出會有 Cloud Run 網址。也可單獨取出:

code/terraform/outputs.tf 追加:

# code/terraform/outputs.tf(追加)
output "cloud_run_url" {
  description = "Cloud Run 服務網址"
  value       = google_cloud_run_v2_service.app.uri
}
terraform output -raw cloud_run_url
# 例如 https://nlt-api-xxxxx-de.a.run.app

✅ 如何驗證這一章成功

把網址存起來,打打看:

URL=$(terraform output -raw cloud_run_url)

# 1) 健康檢查
curl $URL/health
# 預期:{"status":"ok"}

第一次請求可能慢個幾秒(冷啟動 + 跑 migration),屬正常。

跑一輪完整流程(全部打到雲端):

# 2) 註冊
curl -s -X POST $URL/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"cloud@example.com","password":"secret123"}'

# 3) 登入拿 token
TOKEN=$(curl -s -X POST $URL/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"cloud@example.com","password":"secret123"}' | sed 's/.*"token":"//;s/".*//')

# 4) 入金
curl -s -X POST $URL/api/account/deposit \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"amount":5000}'

# 5) 下單
curl -s -X POST $URL/api/orders \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"symbol":"AAPL","side":"buy","quantity":3}'
  • [ ] curl $URL/health{"status":"ok"}
  • [ ] 註冊→登入→入金→下單都正常回應(代表 app + Cloud SQL 連線都通)。

查看雲端日誌(debug 用):

gcloud run services logs read nlt-api --region asia-east1 --limit 50

💸 用完即刪(非常重要)

學完就把會計費的東西刪掉。Cloud SQL 只要還在就一直算錢

cd code/terraform
terraform destroy   # 輸入 yes

destroy 會移除 Cloud Run、Cloud SQL、IAM 綁定等(Cloud SQL 刪除要等幾分鐘)。完成後確認真的沒了:

gcloud sql instances list --project 你的-project-id     # 預期:空的 / Listed 0 items
gcloud run services list --region asia-east1            # 預期:看不到 nlt-api

最後到 GCP Console → 帳單 → 報表,確認花費停止累積(可能隔一點時間更新)。

下次要再做時:重新 terraform apply 即可(因為 Cloud SQL 名稱有隨機後綴,不會撞到剛刪的名字)。 想保留 app 但只刪 DB? 進階用法可用 terraform destroy -target=...,但新手建議整份 destroy 最單純、最不會漏掉計費資源。


🧯 常見錯誤 / 踩雷點

症狀 原因 解法
curl 回 403 / 需要登入 沒給 allUsers invoker,或被組織政策「網域限制共用」擋住 確認有 google_cloud_run_v2_service_iam_member.public;個人帳號通常可行,公司帳號可能被政策擋(改用 gcloud run services proxy 本機測試)
服務一直啟動失敗 / Revision 失敗 app 連不上 DB、或沒監聽 8080 gcloud run services logs read;確認 SA 有 cloudsql.clientDB_HOST/cloudsql/<連線名稱>container_port 為 8080
拉不到 image(image pull 失敗) GHCR package 還是 private 回第 4 章把 package 改 Public
terraform destroy 卡住刪不掉 Cloud SQL deletion_protection = true 設為 false 後再 apply 一次、再 destroy
重建時報「instance name 不可用」 重用了剛刪除的 Cloud SQL 名稱(GCP 保留約一週) 已用 random_id 後綴避免;若手動命名請換名
app 連 DB 逾時 Cloud Run 與 Cloud SQL 不同 region、或連接器沒掛 兩者同 var.region;確認 volumes.cloud_sql_instancevolume_mounts 都有
帳單持續增加 忘了 destroy,Cloud SQL 整天在跑 立刻 terraform destroy;並確認第 1 章的預算警示有效

下一步 → 第 7 章:Kubernetes 入門 — 先在本機用 kind 跑起來 Cloud Run 幫我們把「單一服務」跑起來很省事,但當你要管理「很多服務、要自我修復、要更細的控制」時,就需要容器編排。我們會在本機用 kind 建一個 Kubernetes 叢集,理解 Pod / Service / Deployment,把 app 和一個 in-cluster 的 Postgres 部署上去——而且完全免費、跑在你自己電腦