第 10 章:收尾 — 完整 pipeline 全貌 + 安全拆除

第 10 章:收尾 — 完整 pipeline 全貌 + 安全拆除

這一章在做什麼? 我們把前面所有環節串成一條 gated pipeline:push 到 main → 自動跑測試 → 通過才 build 並推 image → 再自動部署到 Cloud Run。重點是用 Workload Identity 讓 GitHub Actions 免金鑰安全地部署到 GCP。做完這章,你改一行 code、git push,它就自己一路跑到上線——這就是 DevOps 的終點風景。最後附上一份完整拆除清單,確保不留下任何偷偷計費的東西。

前置:第 3–9 章。本章新增 code/terraform/cicd.tfcode/.github/workflows/deploy.yml

💸 本章會短暫產生費用(要把 Cloud Run + Cloud SQL 開起來示範自動部署)。示範完立刻照章末清單拆除。


步驟 1:回顧全貌(更新後的架構)

我們要把這些零件串成一條線:

flowchart TB dev["開發者"] -->|"git push main"| gh["GitHub"] gh --> t subgraph pipe["GitHub Actions:一條 gated pipeline"] direction LR t["① test<br/>go vet + go test"] --> b["② build-push<br/>image → GHCR"] b --> d["③ deploy<br/>部署到 Cloud Run"] end b -->|"push image"| ghcr["GHCR"] d -->|"gcloud run deploy"| run["Cloud Run"] ghcr -->|"pull image"| run run -->|"連線"| sql["Cloud SQL"] run -->|"/metrics"| prom["Prometheus → Grafana"] wif["Workload Identity<br/>(免金鑰信任)"] -.->|"認證"| d tf["Terraform"] -.->|"佈建"| run tf -.->|"佈建"| sql tf -.->|"佈建"| wif

關鍵是「gated(有閘門)」:三個 job 用 needs 串成依賴——測試沒過,就不會 build;沒 build,就不會部署。壞 code 永遠到不了正式環境。


步驟 2:讓 GitHub Actions 能安全部署到 GCP(Workload Identity)

要讓 GitHub Actions 去 GCP 部署,它得「證明自己是誰」。最差的做法是產生一把服務帳號金鑰塞進 GitHub Secret——金鑰會外洩、難輪替。更好的是 Workload Identity Federation(WIF)

WIF 是什麼?(白話) 讓 GCP 信任 GitHub 簽發的短期身分 token(OIDC),GitHub Actions 跑的時候拿這個 token 換取「臨時的 GCP 權限」——全程沒有長期金鑰。而且可限定「只有你這個 repo」能換。安全又免維護。

用 Terraform 建立這套信任。建立 code/terraform/cicd.tf

# code/terraform/cicd.tf
variable "github_repo" {
  description = "GitHub repo(owner/name),限制誰能部署"
  type        = string
  # 例如 "yourname/no-logic-trade"
}

# 部署用的服務帳號(GitHub Actions 會「臨時假冒」它)
resource "google_service_account" "deployer" {
  project      = var.project_id
  account_id   = "nlt-deployer"
  display_name = "no-logic-trade CI/CD deployer"
}

# 給它部署 Cloud Run 的權限
resource "google_project_iam_member" "deployer_run_admin" {
  project = var.project_id
  role    = "roles/run.admin"
  member  = "serviceAccount:${google_service_account.deployer.email}"
}

# 允許 deployer 以「app 執行用 SA(第 6 章的 nlt-app)」身分部署
resource "google_service_account_iam_member" "deployer_act_as_app" {
  service_account_id = google_service_account.app.name
  role               = "roles/iam.serviceAccountUser"
  member             = "serviceAccount:${google_service_account.deployer.email}"
}

# Workload Identity Pool:容納外部身分的「信任池」
resource "google_iam_workload_identity_pool" "github" {
  project                   = var.project_id
  workload_identity_pool_id = "github-pool"
  display_name              = "GitHub Actions pool"
}

# Provider:信任 GitHub 的 OIDC,且只接受你的 repo
resource "google_iam_workload_identity_pool_provider" "github" {
  project                            = var.project_id
  workload_identity_pool_id          = google_iam_workload_identity_pool.github.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-provider"
  display_name                       = "GitHub provider"

  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.repository" = "assertion.repository"
  }
  # 只允許這個 repo 的 token(避免任何人都能換權限)
  attribute_condition = "assertion.repository == '${var.github_repo}'"

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

# 允許「來自該 repo 的 Actions」假冒 deployer SA
resource "google_service_account_iam_member" "wif_impersonate" {
  service_account_id = google_service_account.deployer.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/${var.github_repo}"
}

output "wif_provider" {
  description = "填到 GitHub Actions 的 workload_identity_provider"
  value       = google_iam_workload_identity_pool_provider.github.name
}

output "deployer_sa_email" {
  description = "填到 GitHub Actions 的部署 SA email"
  value       = google_service_account.deployer.email
}

逐段重點:

  • google_service_account.deployer:CI/CD 專用的部署身分,只給「部署 Cloud Run」需要的權限(最小權限原則)。
  • deployer_act_as_app:部署 Cloud Run 服務時要「以 app 的執行 SA 身分」設定,需要 serviceAccountUser
  • google_iam_workload_identity_pool + ..._provider:建立對 GitHub OIDC 的信任,attribute_condition 把信任限縮到你這個 repo(重要的安全護欄)。
  • wif_impersonate:授權「來自該 repo 的 Actions」能臨時假冒 deployer。
  • 兩個 output:等下要填進 GitHub。

套用(會把第 6 章的 Cloud Run/Cloud SQL 與這裡的 WIF 一起建好):

cd code/terraform
# 在 terraform.tfvars 補上 github_repo = "yourname/no-logic-trade"
terraform apply
terraform output wif_provider        # 複製這個值
terraform output deployer_sa_email   # 複製這個值

💡 讓 Terraform 與 CD 不打架:CD 會用 gcloud run deploy 更新 image,這會和 Terraform 記的 image 不一致(drift)。在第 6 章 cloudrun.tfgoogle_cloud_run_v2_service.app 裡加上 lifecycle { ignore_changes = [template[0].containers[0].image] },讓 Terraform 不去管 image,交給 CD 更新。

把兩個 output 填到 GitHub repo 的 Settings → Secrets and variables → Actions → Variables(這些不是密碼、用 Variables 即可):

  • GCP_WIF_PROVIDER = terraform output wif_provider 的值
  • GCP_DEPLOYER_SA = terraform output deployer_sa_email 的值
  • GCP_PROJECT_ID = 你的專案 ID

步驟 3:把 CI/CD/Deploy 串成一條 gated pipeline

建立 code/.github/workflows/deploy.yml(這條取代先前 cd.yml 的角色;ci.yml 可留著做 PR 檢查):

# code/.github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [ main ]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
  GCP_REGION: asia-east1
  SERVICE: nlt-api

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
          cache: true
      - run: go vet ./...
      - run: go test ./...

  build-push:
    needs: test                # ★ 測試過才 build
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image: ${{ steps.meta.outputs.tags }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: type=sha,format=long
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build-push          # ★ 推好 image 才部署
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write          # ★ 取得 OIDC token 給 WIF(少了會認證失敗)
    steps:
      - name:  Workload Identity 認證 GCP
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
          service_account: ${{ vars.GCP_DEPLOYER_SA }}
      - name: 安裝 gcloud
        uses: google-github-actions/setup-gcloud@v2
      - name: 部署到 Cloud Run(更新成這次的 image)
        run: |
          gcloud run deploy ${{ env.SERVICE }} \
            --image ${{ needs.build-push.outputs.image }} \
            --region ${{ env.GCP_REGION }} \
            --project ${{ vars.GCP_PROJECT_ID }}

逐段重點:

  • 三個 job 用 needs 串成閘門build-push 需要 testdeploy 需要 build-push。任一前置失敗,後面不跑。
  • build-pushoutputs.image:把這次 build 的 image 標籤(...:sha-xxx)往下傳給 deploy
  • deploypermissions: id-token: write:這是 WIF 的命脈——讓這個 job 能向 GitHub 索取 OIDC token。少了它,auth 步驟會失敗。
  • google-github-actions/auth@v2:用步驟 2 的 GCP_WIF_PROVIDER / GCP_DEPLOYER_SA 換到臨時 GCP 權限(全程無金鑰)。
  • gcloud run deploy --image ...:sha-xxx:把 Cloud Run 服務更新成這次的 image——這就是「自動部署」。

✅ 如何驗證:一次 push 跑完整條線

確保 terraform apply(第 6 章 infra + 本章 WIF)已完成、GitHub Variables 已填。然後做一次有意義的 push:

cd code
# 隨便改個會反映在輸出的小地方,或就空提交觸發
git commit --allow-empty -m "deploy: 觸發完整 pipeline"
git push

到 GitHub Actions 分頁看 Deploy 這條 workflow:

  • 你會看到 test → build-push → deploy 三個 job 依序亮綠。
  • deploy 完成後,Cloud Run 會有一個新 revision

確認線上真的更新了:

cd code/terraform
URL=$(terraform output -raw cloud_run_url)
curl $URL/health        # {"status":"ok"}
gcloud run revisions list --service nlt-api --region asia-east1   # 看到剛部署的新 revision
  • [ ] Actions 的 Deploy:test→build-push→deploy 三段依序綠勾。
  • [ ] Cloud Run 出現新 revision。
  • [ ] curl $URL/health 正常。
  • [ ] (可選)故意弄壞測試 push,pipeline 在 test 就停住、不會部署——閘門生效。

步驟 4:兩個收尾概念(知道就好)

GitOps(ArgoCD / Flux)

我們的 deploy 是「push 後主動 gcloud run deploy」。另一種主流做法叫 GitOps:叢集裡有個工具(ArgoCD/Flux)持續盯著 Git repo,一旦你改了 manifest,它就自動把叢集調成跟 Git 一致。差別是「推(push)給叢集」vs「叢集自己拉(pull)」。K8s 環境很常用 GitOps。

Secrets 管理原則

整份教學為了好懂,把一些密碼放在 env / state。正式環境請守住:

  • 永遠不要把密鑰 commit 進 Git(靠 .gitignore)。
  • CI 的密鑰放 GitHub Secrets;認證盡量用 WIF 免金鑰(如本章)。
  • 雲端執行期密鑰用 Secret Manager(Cloud Run 可直接掛載),不要放在純 env。

💸 完整資源拆除清單(務必逐項做完)

DevOps 學完,最重要的一件事:把會花錢的東西全部關掉。逐項執行並複查。

A. 雲端 GCP(會計費,最優先)

cd code/terraform

# 若 GKE 還開著,先刪 K8s 裡的 LoadBalancer(釋放對外 IP/轉發規則)
kubectl delete namespace nlt 2>/dev/null || true

# 一次銷毀 Terraform 建的所有東西(Cloud Run / Cloud SQL / GKE / WIF / SA…)
terraform destroy

逐項複查沒有殘留(這些只要還在就可能計費):

PID=你的-project-id
gcloud run services list --region asia-east1 --project $PID
gcloud sql instances list --project $PID
gcloud container clusters list --project $PID
gcloud compute forwarding-rules list --project $PID
gcloud compute addresses list --project $PID      # 殘留的靜態 IP 也算錢
gcloud compute disks list --project $PID          # 殘留磁碟也算錢
# 以上理想都應是空的

B. 本機(免費,但清乾淨)

helm uninstall monitoring -n monitoring 2>/dev/null || true
kind delete cluster --name nlt

C. GHCR image(公開 image 免費,可留可刪)

到 repo → Packages → 不要的話可刪除。

D. 帳單與防護複查

  • GCP Console → 帳單 → 報表:確認花費停止累積(可能延遲幾小時更新)。
  • 確認第 1 章設的預算警示仍在。

E. 終極保險(最推薦)

如果你確定這個練習做完不再用,刪掉整個 GCP 專案——這是「保證一切停止計費」最乾脆的做法:

gcloud projects delete 你的-project-id

刪除專案會在 30 天後永久移除其中所有資源,期間不再計費。確定不用就刪,最安心。


🧯 常見錯誤 / 踩雷點

症狀 原因 解法
deploy 的 auth 步驟失敗(unable to get token job 少了 permissions: id-token: write 補上該權限(WIF 必需)
auth 成功但部署 permission denied deployer SA 權限不足、或 act-as app SA 沒給 確認 roles/run.admin 與對 app SA 的 serviceAccountUser
WIF failed to generate token / 條件不符 attribute_condition 的 repo 名不對、Variables 填錯 確認 github_repo 與實際 owner/repo 一致;Variables 三個值正確
每次 terraform apply 都想把 image 改回舊的 Terraform 與 CD 都在管 image(drift) 在 Cloud Run 資源加 lifecycle { ignore_changes = [template[0].containers[0].image] }
test 失敗卻還是部署了 沒用 needs 串閘門 確認 build-push.needs: testdeploy.needs: build-push
帳單一直沒歸零 有殘留資源(靜態 IP、磁碟、Cloud SQL) 跑 A 段所有 list 複查並手動刪;或直接刪整個專案(E 段)

🎓 DevOps 全系列完成!

從一個容器化的 Go 後端,你一路長出了完整的交付與運維能力:

你建立的能力
1 看懂 DevOps 全貌與目標
2 優化容器 image(distroless、瘦身)
3 CI:push 自動測試與 build(GitHub Actions)
4 CD:自動把 image 推上 GHCR
5 IaC:用 Terraform 宣告式管理雲端
6 部署上雲:Cloud Run + Cloud SQL(free tier 思維)
7 容器編排:本機 kind 跑 K8s
8 Managed K8s:GKE 上雲
9 可觀測性:Prometheus + Grafana
10 串成 gated pipeline + WIF 免金鑰部署 + 安全拆除

核心心法:一切自動化、一切宣告式、一切可重現、一切能銷毀。改一行 code、git push,剩下交給管線。

想再進階?

  • GitOps:ArgoCD / Flux 做 pull 式部署。
  • 多環境:dev / staging / prod 用 Terraform workspace 或目錄分離。
  • 進階監控:加 Loki(日誌)、Tempo(追蹤)、Alertmanager(警報)、SLO。
  • 安全:容器映像掃描(Trivy)、Secret 掃描、最小權限 IAM、Secret Manager。
  • 更穩的部署:藍綠 / 金絲雀(canary)部署、自動 rollback。

恭喜你從「DevOps 新手」走到「能把一個後端從 code 一路自動化到上線並監控」!🚀