---
title: "主系統上 GCP：一台 VM 跑既有安裝版、Terraform 建機 — 需求討論稿 (FR-184)"
brand: "Guidant AI · **FR-184** 主系統上 GCP"
eyebrow: "FR-184 · 需求討論稿 v1 · 2026-11-07（D1～D8 已裁，定案在 design.md）"
h1: "主系統上 GCP"
subtitle: "急件：POC 階段先用一台 GCE VM 跑既有安裝版 stack，程式零改動；基礎設施用 Terraform 描述，換雲只換一個 module；K8s 延後到有 SLA 需求"
lede: "決策者 11-07 定調：**急著上雲端、自己營運、POC 階段、一套就好、GCP 台灣機房**；規格本稿建議 4 核 16 GB，決策者裁先 8 GB（D3）。本稿把三件事講清楚：為什麼 VM 不是 K8s、每月要花多少（**全部實查**，不是記憶值）、十個服務在 VM 上能不能全起（**全部能**，已對照 189 POC 實測）。探脈發現一個會改設定方向的事：**`DEPLOYMENT_MODE=saas` 現在不能直接用**（三個設定頁會消失、更新中心不認照），雲端這台第一期要跑 `host` 模式，見 D2。"
chips: [
  {text: "決策者已裁：VM 不是 K8s，POC 一套", kind: ok},
  {text: "決策者已裁：先 8 GB（本稿建議 16）、GCP 台灣", kind: ok},
  {text: "決策者已裁：Cloudflare 前門、Brevo 寄信、LC 鎖 IP", kind: ok},
  {text: "D1～D8 已裁，定案見 design.md", kind: ok},
  {text: "程式零改動；Terraform repo 骨架已建", kind: accent},
  {text: "費用每月約 144 美元（實查）", kind: plain},
]
footer: "FR-184 需求討論稿 v1 · 首腦撰寫 · 2026-11-07 · 費用數字抓自各家官方定價頁（2026-11-07），原始頁面文字留在首腦工作暫存區 · Terraform repo：~/Projects/Billows/Audit-Manager/guidant-ai-infra"
---

## 0. 分工概述（30 秒版） {#overview nav="概述"}

**整案一句話**：在 GCP 台灣機房開一台 VM，用 Terraform 建好網路、磁碟、固定 IP、備份、祕密容器，然後在上面跑主產品**既有的** `install.sh`，等於多了一台住在雲端的 POC；出版管線的「直接部署」多一台目標主機，之後升級跟 189 一樣按一個鈕。

| 階段 | 做什麼 | 產出 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|
| 批 1 環境 | `guidant-ai-infra` repo 登記進工作坊；Terraform 平台 module 寫完，plan 看得到要建的全部資源 | `modules/platform-gcp/*.tf`、`envs/gcp-prod/main.tf` | 首腦跑 `terraform plan`，列出 VM、磁碟、IP、防火牆、bucket、secret 容器，沒有紅字 |
| 批 2 建機 | 首腦 `terraform apply`；Cloudflare 子網域指到固定 IP | GCP 上一台 VM、一個對外 IP、DNS 一筆 | 瀏覽器開 `https://<網域>` 看到 nginx 的 502（服務還沒裝，但 TLS 與 DNS 通） |
| 批 3 裝機 | 在 VM 上跑既有安裝包、填 `.env`、License 線上開通、SMTP 填 Brevo | 可登入的系統 | 決策者登入、建專案、上傳證據、按 AI 分類、匯出 SSP pdf、收到一封系統信 |
| 批 3 管線 | FR-173 直接部署加目標 `gcp-prod` | `.gitlab/ci/deploy.yml` 多一個 job | GitLab Run pipeline 選 `gcp-prod`，約 10 分鐘後雲端版本號變新 |
| 批 4 營運 | 每日快照、pg_dump 推 GCS、記憶體與磁碟告警 | 排程與告警規則 | 隔天 GCS 裡看到一個 dump 檔；手動把記憶體灌滿收到一封告警信 |
| 批 4 驗收 | 全功能跑一輪 | 驗收清單勾完 | 清單見 §7 |

**依賴**：批 1 前決策者要做完的事已全部完成（GCP 專案 `guidant-ai-cloud-514012`、帳單、API、state bucket，11-07 驗過）。批 2 要決策者給 Cloudflare 的 API 權杖與網域名稱。批 3 要 Brevo 帳號與 AI 金鑰。

## 1. 為什麼要做 {#why nav="為什麼"}

主系統到今天只有落地版：客戶自己的主機裝安裝包。決策者 11-07 要**自己營運一套雲端版**給客戶試用，POC 階段，而且急。

現況對這件事有利的地方比想像多：

- 安裝版 compose 是乾淨的服務契約，十個服務各自一個容器，FR-065 當初就為「客戶隨便一台 Ubuntu」設計，GCE VM 就是那種主機。
- 出版管線（FR-173）已經能對任一台主機 ssh 送安裝包跑 `install.sh --upgrade`，雲端這台只是第四台。
- 儲存已走 S3 相容介面（SeaweedFS），寄信、AI、License 全部是設定不是程式。

不利的只有一件：程式裡有「跑在一台機器上」的假設（機器指紋、主機撿升級單、排程跟著 api），這些在 VM 上**完全不是問題**，只在 K8s 才會撞到。所以 VM 是急件的正解，K8s 留到有 SLA 需求。

## 2. 架構 {#arch nav="架構"}

::: diagram
[![FR-184 架構：Cloudflare 前門、GCP 一台 VM 跑既有 stack、CI ssh 部署](diagrams/architecture.svg)](diagrams/architecture.svg)

[圖 1 — 一台 VM 跑既有十個容器；Cloudflare 擋前面；CI 走既有直接部署那條路（點圖看大圖）]{.diagram-caption}
:::

**兩層切開，換雲的縫在中間**：

| 層 | 內容 | 換雲時 |
|---|---|---|
| 平台層（Terraform，`modules/platform-gcp/`） | VPC、防火牆、VM、資料碟、固定 IP、快照排程、GCS 備份 bucket、Secret Manager 容器、開機腳本（swap、docker、掛碟） | 重寫這個 module |
| 介面（module outputs，固定欄位） | `public_ip`、`instance_name`、`ssh_user`、`backup_bucket`、`data_mount_path` | 不變 |
| 應用層 | 主產品安裝包＋`install.sh`，一行不改 | 不變 |

Terraform repo `guidant-ai-infra` 骨架已建（commit `7ec4f3a`、`78e8255`）：`.gitignore`、`CLAUDE.md`（runner 只做到 plan，apply 由決策者或首腦）、`envs/gcp-prod/` 的 provider 與 GCS 後端、`terraform init` 與 `validate` 已在新專案上通過。

## 3. 費用（全部實查） {#cost nav="費用"}

抓法：Google 定價頁是前端渲染，用無視窗瀏覽器切到台灣區抓；AWS 抓定價頁背後的官方 JSON；DigitalOcean 與 Hetzner 官方頁純文字。抓取日 2026-11-07，美元。

### 3.1 單價

| 項目 | GCP 台灣 | AWS 台北 | AWS 東京 |
|---|---|---|---|
| 4 核 16 GB 每小時 | e2-standard-4 **0.1552** | t3.xlarge 0.1958 | t3.xlarge 0.2176 |
| 4 核 32 GB 每小時 | e2-highmem-4 0.2093 | — | — |
| 磁碟每 GB 月 | pd-balanced 0.10 | gp3 0.0864 | gp3 0.096 |
| 快照每 GB 月 | 0.05 | 0.045 | 0.05 |
| 固定 IP 每小時 | 0.005 | 0.005 | 0.005 |
| 出站流量每 GB | 亞洲 0.12 | 每月 100 GB 免費 | 同左 |
| 一年承諾 | 最多 63 折 | t3.xlarge 0.137 | 同左 |

DigitalOcean 與 Hetzner 實查後退出候選：前者沒有 4 核 16 GB 的 Basic 方案（要 General Purpose 月費 126，不便宜）；後者新加坡節點沒有 4 核 16 GB、多個方案標缺貨。

### 3.2 整套月費（4 核 16 GB、100 GB 碟、快照、IP、備份、50 GB 流量）

::: statgrid
::: stat
[144]{.v}[GCP 台灣，隨需]{.k}
:::
::: stat
[100]{.v}[GCP 台灣，一年承諾後]{.k}
:::
::: stat
[166]{.v}[AWS 台北，隨需]{.k}
:::
::: stat
[184]{.v}[GCP 台灣 32 GB，隨需]{.k}
:::
:::

16 GB 對 32 GB 差 **40 美元**（承諾後差 25）。決策者已選 16 GB。

### 3.3 K8s 對照（決策者看過，留紀錄）

| | VM 16 GB | GKE Autopilot 正統（受管 DB／Redis） | GKE 高可用 |
|---|---|---|---|
| 每月隨需 | 144 | 約 290（pod 申請量縮到實測峰值後） | 約 770 |
| 程式改動 | 零 | 八處拿掉單機假設 | 同左 |
| 上線 | 3 到 5 天 | 3 到 4 週 | 3 到 4 週 |

K8s 貴在兩件結構性的事：Autopilot 每個 pod 按申請量預留、不能像單機那樣共用記憶體；DB、Redis、負載平衡器在單機裡是免費搭便車，K8s 各自一張帳單。決策者 11-07 裁「POC 走 VM」。

## 4. 十個服務在 VM 上能不能起（對照 189 POC 實測） {#services nav="服務盤點"}

結論：**全部能，不需要 GPU、不需要巢狀 docker、不需要特權容器**。

| 服務 | 怎麼跑 | 189 現值 | 190 現值 | compose 上限 |
|---|---|---|---|---|
| api | BE image，`RUN_MODE=api` | 4.0 GB | 1.9 GB | 6 GB |
| worker | 同 image | 2.2 GB | 1.6 GB | 3 GB |
| socketio | 同 image | 1.8 GB | 1.6 GB | 3 GB |
| sandbox AI 分類器 | evidence-classifier image，Prompt Guard ONNX 模型 1.1 GB 跑 CPU；真正的 LLM 分類打 api.anthropic.com | 0.1 GB 閒置 | 同左 | 4 GB |
| 弱點掃描、SBOM、CINC | 在 api 容器內 | 含在 api | | |
| LibreOffice 轉檔、PDF 匯出 | api 容器內建 | 含在 api | | |
| db、redis、seaweedfs、bugsink | 各自容器 | 0.9＋0.01＋0.7＋0.4 GB | | bugsink 1 GB |
| fe nginx | 對外 80／443 | 0.01 GB | | |
| 合計 | | 約 10 GB | 約 6 GB | 硬上限加總 17 GB |

189 是跑六天、有舊資料的狀態；4 核 Xeon 平均負載 0.68，CPU 很閒。磁碟方面 189 用 135 GB，其中 30 GB 是歷史 image、6.6 GB 升級包暫存、9 GB 證據檔，資料庫只有 631 MB；雲端從乾淨安裝起算，100 GB 夠 POC。

**16 GB 的風險講清楚**：compose 硬上限加總 17 GB，只有「AI 分類批次、弱點掃描、LibreOffice 匯出同時發生」才碰到。對策是 8 GB swap 當保險絲（189 也有 4 GB swap）加「記憶體超過 85% 或 swap 動用就告警」，告警響了 Terraform 改一個變數升 32 GB，關機重開兩分鐘。

## 5. 待決策 {#decisions nav="待決策"}

::: {.callout .pending}
**D1 — 雲端這台的部署形態：第一期跑 `host`，不跑 `saas`**

探脈發現 `DEPLOYMENT_MODE=saas` 現在有兩個洞：①全公司共用設定（SMTP／LDAP／登入政策）讀寫都卡「總部」（`common/authz/tenant_hq.py`），saas 下沒有任何租戶算總部，三頁整個消失（DEV 2026-10-04 就是因此改回 host，見 memory `project_dev_deployment_mode_host`）；②更新中心只認落地版的照（`product_update_flow.py:193`），saas 照會讓「立即檢查」回尚未開通。

[**建議**：第一期 `DEPLOYMENT_MODE=host`、`TURNSTILE_ENABLED=false`，跟 189 一模一樣，等於多一台 POC 住在雲端。機器指紋綁這台 VM（Terraform 建的 VM 有穩定的 `/etc/machine-id`），License 走線上開通或離線開通都行。**saas 模式的兩個洞另開 FR**（總部判定、更新中心認 saas 照），做完再切。理由：急件不該被 saas 的既有缺口擋住；host 模式在 VM 上沒有任何技術問題。]{.rec}
:::

::: {.callout .pending}
**D2 — Turnstile 人機驗證：第一期關，不開**

FE image 是落地版 build（`.env.onprem` 烙 `VITE_TURNSTILE_ENABLED=false`），要開 Turnstile 得重 build 一顆 FE image 並拿正式的 site key。

[**建議**：第一期關，與 D1 一致。雲端站有 Cloudflare WAF 擋在前面，POC 階段使用者是邀請制，沒有公開註冊入口，暴力登入的風險可接受。要開時另開一張卡：FE 多一個 build mode、Cloudflare 申請 site key。]{.rec}
:::

::: {.callout .pending}
**D3 — 規格與 swap：e2-standard-4、8 GB swap、告警 85%**

[**建議**：照決策者已裁的 16 GB。swap 與告警寫進 Terraform 與開機腳本。升級路徑是改 `machine_type` 變數重 apply。]{.rec}
:::

::: {.callout .pending}
**D4 — 祕密放哪：Secret Manager 只放容器，值由決策者手動放**

Terraform 建 secret 的「殼」（名稱與權限），**不寫值**；值由決策者用 `gcloud secrets versions add` 放進去。VM 開機腳本或裝機步驟從 Secret Manager 讀出來填 `.env`。

[**建議**：這樣 Terraform state 與 git 裡永遠沒有密碼。要放的有：DB 兩組密碼、Redis 四組、SMTP 帳密、AI 金鑰、License Center token、classifier token、bugsink secret。裝機時 `install.sh` 本來會自動產生 DB／Redis 密碼，只有 SMTP、AI、LC token 三組是外部給的，其餘讓 installer 產生後**回寫** Secret Manager 備查。]{.rec}
:::

::: {.callout .pending}
**D5 — 防火牆：80／443 只放行 Cloudflare 的 IP 範圍，不開給全世界**

[**建議**：Terraform 從 Cloudflare 公開的 IP 清單（`https://www.cloudflare.com/ips-v4`）產防火牆規則，繞過 Cloudflare 直打 VM IP 會被擋，WAF 才有意義。SSH 只放行 GitLab CI runner（188 的對外 IP）與決策者的 IP，寫在 `ssh_allowed_cidrs` 變數。]{.rec}
:::

::: {.callout .pending}
**D6 — 寄信：Brevo SMTP 587**

GCE 封鎖 25 port 出站，這是 GCP 鐵則。公司信箱不是 Google Workspace。

[**建議**：Brevo 免費方案每天 300 封，用自家網域當寄件人，Cloudflare 加三筆 DNS 驗證。SMTP 設定在主系統後台「郵件設定」頁填（host 模式下總部看得到），不進 `.env`。]{.rec}
:::

::: {.callout .pending}
**D7 — License Center 放行：Cloudflare Access 鎖 VM 固定 IP**

[**建議**：決策者已選。Terraform output 印出 `public_ip`，決策者貼到 Cloudflare Access 的 IP 規則。BE 程式不動。將來多台或換 K8s 再改 service token。]{.rec}
:::

::: {.callout .pending}
**D8 — 出版管線：`gcp-prod` 比照 `poc`，部署前要按 ▶ 確認**

FR-173 的 `deploy.yml` 一個 job 對應一台，`poc` 是 `when: manual`。

[**建議**：`deploy-gcp-prod` 比照 `poc`：`GUARDED=1`、`when: manual`。`deploy_e2e.sh` 的 `case` 多一行 `gcp-prod) E2E_HOST=jedi@<public_ip>`。IP 從 Terraform output 來，寫進 GitLab CI 變數 `GCP_PROD_HOST`，不寫死在腳本。]{.rec}
:::

## 6. 不做什麼 {#out-of-scope nav="不做"}

- **K8s**：五條應用層前提（FR-065 §4.8）與 Helm chart 全部延後。觸發條件：有付費客戶要 SLA、或要零停機升級。
- **saas 模式**：D1 說明的兩個洞另開 FR，本案不碰 `tenant_hq.py` 與 `product_update_flow.py`。
- **多租戶自助註冊**：POC 階段租戶由管理員在後台建。
- **STG 環境**：決策者裁一套就好。`envs/` 目錄結構保留 `gcp-stg/` 的位置，要加是複製變數檔。
- **Agent 連線方向**：FR-181 已在做「Agent 只出站」，本案不重複。雲端版接 Agent 等 FR-181 合回。
- **高可用**：單機，掛了從快照起新機。恢復步驟寫進營運手冊。

## 7. 端到端驗收 {#acceptance nav="驗收"}

批 4 驗收卡的清單，決策者親手做：

1. 瀏覽器開 `https://<網域>`，憑證是 Cloudflare 簽的、沒有警告。直打 VM IP 被擋。
2. 用管理員登入，建一個租戶、一個使用者，用新使用者登入。
3. 建專案、上傳一份證據檔（走 SeaweedFS）、按 AI 分類，分類結果回來。
4. 匯出 SSP docx 與 pdf，中文沒有消失（LibreOffice 與字型在 image 內）。
5. 觸發密碼重設，收到 Brevo 寄的信，寄件人是自家網域。
6. 更新中心「立即檢查」回正常（host 照）。
7. GitLab Run pipeline 選 `gcp-prod`，按 ▶，約 10 分鐘後系統版本號變新、資料都在。
8. 隔天 GCS bucket 裡有 pg_dump；GCP 主控台看到資料碟快照。
9. 從快照起一台新 VM、裝同版、restore dump，能登入看到同樣資料（災難復原演練，首腦做）。

## 8. 風險與陷阱 {#risks nav="風險"}

- **帳單帳戶被風控關閉**：11-07 第一個專案就撞到，重建帳單帳戶與專案後才過。Terraform apply 前先 `gcloud billing projects describe` 確認 `billingEnabled: true`。
- **GCE 的 `/etc/machine-id`**：host 模式綁機器指紋。GCE 從映像開機會產生新的 machine-id，但**從快照還原的 VM 會沿用舊的**，災難復原後指紋不變，License 不用重開。從頭重建 VM 指紋會變，要重新開通，寫進營運手冊。
- **25 port**：主系統後台 SMTP 設定若填 25 會靜默寄不出去，手冊明寫 587。
- **Cloudflare 代理後的真實 IP**：nginx 看到的來源 IP 是 Cloudflare 的，稽核紀錄要真實 IP 得讀 `CF-Connecting-IP`。現在 nginx.onprem.conf 沒處理，列為批 3 的小項：加 `real_ip_header` 設定。
- **swap 在 GCE 映像預設沒有**：開機腳本要建，漏了 16 GB 就是硬上限。

## 9. 參考 {#refs nav="參考"}

- FR-065 落地版 installer（compose 服務契約、§4.8 K8s 預留五條）
- FR-173 出版管線 v2.0（直接部署那條路）
- FR-167 線上更新通道（更新中心只認 host 照的那一段）
- memory `project_dev_deployment_mode_host`（saas 模式三頁消失的根因）
- Terraform repo：`~/Projects/Billows/Audit-Manager/guidant-ai-infra`（GitLab `auditmanager/guidant-ai-infra`）
- 費用原始頁面文字：首腦工作暫存區 `scratchpad/pricing/*.txt`（不入版控）
