---
title: "主系統上 GCP — 設計文件 v1.0 (FR-184)"
brand: "Guidant AI · **FR-184** 主系統上 GCP"
eyebrow: "FR-184 · 設計文件 v1.0 · 定稿 · 2026-11-07"
h1: "設計 v1.0：一台 GCE VM 跑既有安裝版，Terraform 建機"
subtitle: "e2-standard-2（2 核 8 GB）＋8 GB swap、GCP 台灣、Cloudflare 前門、host 模式、Turnstile 開、升級走更新中心；六張卡五批"
lede: "決策者 2026-11-07 裁完 D1～D8。本版是完整的設計文件，讀者不必回頭看討論稿；討論稿（discussion.md）保留費用實查與 K8s 對照供稽核。主產品只改一處：Turnstile 改成「有填 key 就開」，同一包落地版與雲端版通用；其餘改的是出版管線加一台目標主機與新 repo `guidant-ai-infra` 的 Terraform。"
chips: [
  {text: "D1～D8 已裁 2026-11-07", kind: ok},
  {text: "六張卡五批：T1 → T2 → T3＋P1 → O1 → V1", kind: accent},
  {text: "主產品只改 Turnstile 開關；apply 類兩張首腦親手做", kind: plain},
  {text: "每月約 82 美元（8 GB 隨需）", kind: plain},
]
footer: "FR-184 設計文件 v1.0 · 首腦撰寫 · 2026-11-07 · 拆卡表由 handoff/cards.py 產並驗批 · Terraform repo：~/Projects/Billows/Audit-Manager/guidant-ai-infra（GitLab auditmanager/guidant-ai-infra）"
---

## 變更紀錄

| 日期 | 變更 | 對應 |
|---|---|---|
| 2026-11-07 | v1.0 定稿：D1～D8 落定案表；拆六張卡四批；D3 規格由討論稿建議的 16 GB 改為決策者裁的 8 GB | 討論稿 discussion.md |
| 2026-11-07 | D2 改判：Turnstile 列上線必要條件，加 F1（fe）、T4（infra）兩張卡，V1 移到最後一批；八張卡六批 | 決策者同日裁示 |
| 2026-11-08 | D8 改為升級走更新中心線上更新，CI 直接部署（P1）留作救火 | 決策者裁示 |
| 2026-11-08 | D2 做法改為「有填 key 就開」，主產品改動（BE `a108ab505`、FE `f95a85b8`）；F1、T4 取消，六張卡五批；§5.2 裝機步驟改走 `install.conf` | 決策者裁示；沿革見 handoff/FR-184-LOG.md |

## 1. 這案在做什麼 {#what nav="做什麼"}

在 GCP 台灣機房開一台 VM，用 Terraform 建好它需要的一切（網路、防火牆、磁碟、固定 IP、快照、備份 bucket、祕密容器、告警），然後在上面跑主產品**既有的**安裝包。做完等於多了一台住在雲端的 POC：決策者自己營運、多租戶、客戶用瀏覽器進來試用；升級跟 189 一樣在 GitLab 按一個鈕。

**不做 K8s**。理由在討論稿 §3.3：費用兩倍以上、程式要改八處、上線多三週，而 POC 階段用不到它買的那三樣東西（多副本、獨立擴縮、節點自動替換）。觸發條件：有付費客戶要 SLA、或要零停機升級。

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

| 批 | 卡 | 做什麼 | 誰做 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|---|
| 1 | T1 | Terraform 平台 module 寫完，plan 看得到全部資源 | runner | 首腦貼 plan 摘要：VM、磁碟、IP、3 條防火牆、bucket、6 個 secret 的 IAM 綁定、2 條告警，沒有紅字 |
| 2 | T2 | apply 建機；Cloudflare 子網域指到固定 IP | 首腦親手 | 瀏覽器開 `https://guidant-ai.jedicotech.com` 看到 502（TLS 與 DNS 通，服務還沒裝）；直打 IP 連不上 |
| 3 | T3 | VM 上跑既有安裝包、自帶密碼、License 開通、填 Brevo 與 AI 金鑰 | 首腦親手 | 決策者登入、建租戶、上傳證據、AI 分類、匯出 pdf、收到一封系統信 |
| 3 | P1 | 出版管線直接部署多一台 `gcp-prod`（救火用，平常升級走更新中心） | runner | GitLab Run pipeline 選 `gcp-prod`、按 ▶，約 10 分鐘後雲端版本號變新 |
| 4 | O1 | 每日 pg_dump 推 GCS、告警實測、災難復原演練 | runner 寫腳本，首腦演練 | 隔天 GCS 有 dump；灌記憶體收到告警信；從快照起第二台能登入看到同樣資料 |
| 5 | V1 | §8 十項驗收、文件收口 | 決策者驗，runner 收文件 | 十項全勾 |

**依賴**：批 1 的前置（GCP 專案、帳單、API、state bucket、infra repo 骨架）已於 11-07 完成並驗過。T2 要決策者給 Cloudflare 權杖與網域；T3 要決策者先把自帶密碼、Brevo 帳密、AI 金鑰放進 Secret Manager。

## 3. 決策定案表 {#decisions nav="決策"}

| # | 題目 | 定案 | 理由 | 被排除的 |
|---|---|---|---|---|
| D1 | 部署形態 | **`DEPLOYMENT_MODE=host`**，與 189 相同 | `saas` 有兩個既有缺口：總部判定讓郵件／LDAP／安全政策三頁消失（`common/authz/tenant_hq.py`）、更新中心只認 host 照（`product_update_flow.py:193`）。host 在 VM 上沒有任何技術問題，機器指紋綁的是自己的 VM | saas：另開 FR 補兩個缺口再切；順便把 `DEPLOYMENT_MODE` 改名成它真正控制的「信任模型」 |
| D2 | Turnstile 人機驗證 | **開，上線必要條件**；裝機時 `install.conf` 填 `GUIDANT_TURNSTILE_SITE_KEY`／`GUIDANT_TURNSTILE_SECRET_KEY` | 主產品的開關是「兩把都填真 key 才開」（`common/util/turnstile_state.py`），後端驗 token、登入頁（讀 `/setup/status`）、前門 CSP（放行 `challenges.cloudflare.com`）三處同一判定。key 在 compose `.env`，升級保留，同一顆 FE image 落地版與雲端版通用 | 關（討論稿建議）：POC 也要有人機驗證；另出雲端版 FE image：升級會被蓋回關閉版，且前門 CSP 會擋 Turnstile |
| D3 | 規格 | **e2-standard-2（2 核 8 GB）＋8 GB swap**；告警門檻記憶體 80%、swap 用掉一半 | 決策者裁先 8 GB。首腦已告知風險：190 乾淨狀態 6 GB、api 容器上限 6 GB、installer 最低值 16 GB，AI 分類或匯出同時跑可能 OOM。swap 當緩衝、告警響了改一個變數升級 | 16 GB（討論稿建議）：費用每月多 57 |
| D4 | 祕密 | **Secret Manager 只建殼，值由決策者手動放；DB／Redis 六組密碼決策者自帶** | Terraform state 與 git 永遠沒有密碼。installer 偵測到既有 `.env` 會沿用不重產（`install.sh` 第 1211 行），所以裝機前先把自帶密碼寫進 `.env` 即可。JWT、加密鑰、S3、classifier 四組 installer 強制產生（內部憑證不開放自帶），產完回寫 Secret Manager 備查 | 全部自動產生：決策者要自己管 |
| D5 | 防火牆 | **80／443 只放行 Cloudflare IP 範圍**；22 只放行 188 runner 與管理者 IP | 繞過 Cloudflare 直打 VM 會被擋，WAF 才有意義。IP 清單從 `https://www.cloudflare.com/ips-v4` 抓 | 開給全世界 |
| D6 | 寄信 | **Brevo SMTP 587** | GCE 封 25 port；公司信箱不是 Google Workspace；Brevo 免費每天 300 封、自家網域當寄件人。在後台郵件設定頁填，不進 `.env` | Gmail 當 SMTP：每天 500 封上限、易被鎖、寄件人是 gmail.com |
| D7 | License Center 放行 | **Cloudflare Access 鎖 VM 固定 IP** | 一台、IP 固定、BE 程式不動。Terraform output 印 IP，決策者貼到 Access 規則 | service token：多台或 K8s 再改 |
| D8 | 升級路徑 | **平常走更新中心線上更新，只收正式發版**；CI 直接部署 `deploy-gcp-prod`（比照 `poc`，`GUARDED=1`、`when: manual`）留作救火 | 雲端版有外部客戶試用，等同正式環境；直接部署可推任何 branch，線上更新只拿得到簽正式章的版本。自己天天用也等於持續驗客戶會走的那條路（下載、驗章、八步升級、失敗退回）。安裝包預設寫入 `UPDATE_SITE_URL`，裝完就接更新站。救火時（線上更新卡住、急修還沒正式發布）才用直接部署 | 只用直接部署：半成品容易誤進正式環境，也測不到更新中心那條路 |

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

::: diagram
[![FR-184 架構](diagrams/architecture.svg)](diagrams/architecture.svg)

[圖 1 — 一台 VM 跑既有十個容器；Cloudflare 擋前面；升級走更新中心，CI 直接部署留作救火（點圖看大圖）]{.diagram-caption}
:::

::: diagram
[![FR-184 雲端架構（GCP 官方圖示）](diagrams/cloud-architecture.png)](diagrams/cloud-architecture.png)

[圖 2 — 雲端版架構，GCP 官方圖示，對齊 2026-11-08 實際建出來的資源（點圖看大圖）；圖源 `diagrams/cloud_architecture.py`]{.diagram-caption}
:::

兩層切開，換雲的縫在 module outputs：

| 層 | 內容 | 換雲時 |
|---|---|---|
| 平台層 `modules/platform-gcp/` | VPC、防火牆、VM、資料碟、固定 IP、快照政策、GCS 備份 bucket、Secret Manager 殼、告警、開機腳本 | 新增 `modules/platform-<雲>/` 輸出同一組欄位 |
| 介面（outputs） | `public_ip`、`instance_name`、`ssh_user`、`backup_bucket`、`data_mount_path` | 不變 |
| 應用層 | 主產品安裝包＋`install.sh` | 不變 |

## 5. 詳細設計 {#detail nav="詳細設計"}

### 5.1 Terraform 資源（T1）

| 資源 | 設定 | 為什麼 |
|---|---|---|
| VPC＋子網 | `10.10.0.0/24`，asia-east1 | 自己一個網路，不用 default |
| 防火牆 `allow-cloudflare-web` | tcp 80／443，source＝Cloudflare ips-v4＋ips-v6（`data "http"` 抓） | D5 |
| 防火牆 `allow-ssh-admin` | tcp 22，source＝`var.ssh_allowed_cidrs` | 188 runner 與管理者 |
| 防火牆 `deny-all-ingress` | 最低優先權兜底 | GCP default 網路的 allow 規則不會帶進來，但明寫保險 |
| 固定 IP | `google_compute_address` | DNS、LC Access 規則、CI 變數都指它 |
| 資料碟 | pd-balanced `var.data_disk_gb`（預設 100） | 掛 `/srv/guidant-ai`，與系統碟分開，快照只拍它 |
| 快照政策 | 每日、保留 7 天 | 災難復原的基礎 |
| VM | `var.machine_type`（預設 `e2-standard-2`）、Ubuntu 24.04 LTS、系統碟 30 GB、ssh user `jedi` | user 名對齊 `deploy_e2e.sh` 的 `jedi@` |
| VM service account | 只給 Secret Manager accessor、logging writer、monitoring writer | 最小權限；裝機腳本用它讀 secret |
| 備份 bucket | asia-east1、版本控管、30 天生命週期 | pg_dump 落點 |
| Secret Manager | **不建**：六個 secret 決策者 11-07 已親手建好（`guidant-db-admin`／`guidant-db-app`／`guidant-redis`／`guidant-redis-api`／`guidant-redis-socketio`／`guidant-redis-worker`，各一版）；Terraform 用 `data` 讀名稱、把 accessor 綁給 VM service account，不讀值 | D4；SMTP 與 AI 金鑰走後台填（`guidant-smtp`／`guidant-anthropic` 兩個 secret 是備查，裝機不用） |
| 告警 × 2 | 記憶體 > 80%、磁碟 > 85%，通知 `var.alert_email` | D3 的保險絲；記憶體指標要裝 Ops Agent |

開機腳本 `scripts/startup.sh`：建 swap（GCE 映像預設沒有）、裝 docker-ce 與 compose plugin、空碟才格式化並掛資料碟、裝 Ops Agent。**不做安裝流程**，那是 `install.sh` 的事。

### 5.2 裝機（T3）

1. 首腦在 Mac 從 Secret Manager 讀值組 `install.conf`（600）送上 VM：主機名稱、資料目錄、DB 管理／DB 應用／Redis 主密碼、root admin 密碼、Anthropic 金鑰、Turnstile 兩把 key。
2. 從 188 scp 安裝包與 `.sha256`，驗完解開，`sudo ./install.sh --config install.conf`。host 模式是 installer 預設。
3. 裝完換 Cloudflare Origin 憑證：覆蓋 `certs/server.crt`、`server.key`，`chown 101:101 server.key`，`docker compose up -d guidant-fe`。**不能裝前先放**：`certs/server.key` 存在時 installer 判定已有安裝而停下（`install.sh:933`）。
4. 刪 VM 上的 `install.conf`；installer 自產的 JWT、加密鑰、S3、classifier、Redis 三組行程帳號回寫 Secret Manager 備查。Redis 那三組全新安裝一律自產，不讀 `install.conf`。
5. 決策者登入、License 線上開通（貼序號）、後台填 Brevo 587。

**機器指紋**：host 模式綁 `/etc/machine-id`。GCE 從映像開機會產生新的；**從快照還原的 VM 沿用舊的**，災難復原後 License 不用重開。從頭重建 VM 指紋會變，要重新開通，寫進 runbook。

### 5.3 升級與出版管線（P1）

**平常升級**：管理員登入 → 系統設定 → 更新中心 →「立即檢查」→「更新到 X」。只看得到正式發版：雲端版的機器指紋**不登記**進更新站「內部主機清單」，`guidant.env` **不加** `PRODUCT_UPDATE_ALLOW_SAME_VERSION`，測試版發布的草稿就到不了這台。Turnstile 的 key 在 compose `.env`，升級保留，升完不必補任何步驟。

**救火用直接部署（P1）**：照既有三台的樣子加第四台：`.gitlab-ci.yml` 的 `target` 選項加 `gcp-prod`；`deploy.yml` 加 `deploy-gcp-prod` job 比照 `poc`；`deploy_e2e.sh` 的 `case` 加一行，主機從 CI 變數讀。188 runner 的公鑰（`~jedi/.ssh/id_ed25519.pub`，註解 `guidant-build-188`，11-07 查過存在）進 T1 的 `ssh_public_keys`。

### 5.4 備份與復原（O1）

每日 cron：`pg_dump -Fc` → `gcloud storage cp` 到備份 bucket。資料碟每日快照。復原步驟：從快照起新 VM → 裝同版 → restore dump → License 不用重開（machine-id 沿用）。演練一次並 destroy 臨時 VM。

## 6. 現況接入點盤點 {#touchpoints nav="接入點"}

| 元件 | 現況 | 本案動作 |
|---|---|---|
| `docker/production/docker-compose.yml`、`install.sh` | 落地版安裝流程 | **不動** |
| `scripts/build/deploy_e2e.sh` | 三台 case | 加 `gcp-prod` case（P1） |
| `.gitlab/ci/deploy.yml`、`.gitlab-ci.yml` | 三個 deploy job | 加 `deploy-gcp-prod`、target 選項（P1） |
| `nginx.onprem.conf`（FE repo） | 只轉 `X-Forwarded-For`，沒處理 `CF-Connecting-IP` | **不動**；稽核紀錄要真實 IP 另開 FE 卡（§9） |
| `common/authz/tenant_hq.py`、`product_update_flow.py` | saas 兩個缺口 | **不動**；另開 FR（§9） |
| `guidant-ai-infra`（新 repo） | 骨架：`.gitignore`、`CLAUDE.md`、`envs/gcp-prod/{versions,variables}.tf`、`.terraform.lock.hcl` | T1 填 module 與 main.tf |
| `.claude/workshop.toml` `[repos]` | 六個 repo | 加 `infra`（開卡前由首腦登記） |

## 7. 拆分表 {#cards nav="拆分表"}

由 `handoff/cards.py` 產，已驗：依賴都在更早的批、同批同 repo 零撞檔。

| 批 | 卡 | repo | 做什麼 | reuse／extend／new | 改哪些既有檔 | 新開檔 | 依賴 | model／effort |
|---|---|---|---|---|---|---|---|---|
| 1 | **T1** | infra | Terraform 平台 module 全部資源＋開機腳本，做到 plan | new：`modules/platform-gcp/*.tf`、`scripts/startup.sh`、`envs/gcp-prod/{main,outputs}.tf`；extend：variables、tfvars 範本、module README | `envs/gcp-prod/variables.tf`、`terraform.tfvars.example`、`modules/platform-gcp/README.md` | 六支 | — | opus／high |
| 2 | **T2** | infra | apply 建機、Cloudflare DNS 與 Origin 憑證、LC Access 加 IP（**首腦親手**） | new：`docs/runbook.md` 建機節 | — | `docs/runbook.md` | T1 | opus／medium |
| 3 | **T3** | infra | 裝機與開通、密碼回寫（**首腦親手**） | new：`scripts/env_from_secrets.sh`；extend：runbook 三節 | `docs/runbook.md` | `scripts/env_from_secrets.sh` | T2 | opus／medium |
| 3 | **P1** | main | 直接部署多一台 gcp-prod | extend：三支 CI 檔、FR-173 design 四台表 | `.gitlab-ci.yml`、`.gitlab/ci/deploy.yml`、`scripts/build/deploy_e2e.sh`、FR-173 `design-v2.0.md` | — | T1 | sonnet／medium |
| 4 | **O1** | infra | 備份 cron、告警實測、災難復原演練 | new：`scripts/backup_pg.sh`；extend：startup 裝 cron、runbook | `docs/runbook.md`、`scripts/startup.sh` | `scripts/backup_pg.sh` | T3 | sonnet／medium |
| 5 | **V1** | main | §8 驗收、README／登記表／CLAUDE.md／memory 收口 | extend | FR-184 README、`docs/features/README.md`、`CLAUDE.md` | memory 一支 | T3、P1 | sonnet／low |

T2、T3 是 apply 類，依 infra repo `CLAUDE.md`「runner 只做到 plan」由首腦親手做；開卡是為了留紀錄與驗收落點，Notion 作業人員仍標「小弟」但 Model 欄留空。

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

決策者親手：

1. 瀏覽器開 `https://guidant-ai.jedicotech.com`，憑證是 Cloudflare 簽的、沒有警告。直打 VM IP 連不上。
2. 管理員登入，建一個租戶、一個使用者，用新使用者登入。
3. 建專案、上傳證據檔、按 AI 分類，結果回來。
4. 匯出 SSP docx 與 pdf，中文沒有消失。
5. 觸發密碼重設，收到 Brevo 寄的信，寄件人是自家網域。
6. 更新中心「立即檢查」回正常。
7. 有新正式版時，更新中心「立即檢查」看到新版、按「更新到 X」，升完版本號變新、資料都在、Turnstile 仍開著。
8. 隔天 GCS bucket 有 pg_dump；主控台看到資料碟快照。
9. 從快照起一台新 VM、restore dump，能登入看到同樣資料（首腦做，決策者看）。
10. 登入頁看得到 Turnstile 小工具（DevTools Network 有 `challenges.cloudflare.com`），正確帳密能登入；升級後不必做任何事，小工具仍在。

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

- **K8s**：FR-065 §4.8 五條前提與 Helm chart 全部延後。
- **saas 模式**：另開 FR 補總部判定與更新中心認照，順便把 `DEPLOYMENT_MODE` 改名為它真正控制的事（信任模型）。
- **Cloudflare 真實 IP 進稽核紀錄**：nginx 要加 `set_real_ip_from` Cloudflare 範圍＋`real_ip_header CF-Connecting-IP`，另開 FE 卡。
- **STG 環境**：`envs/` 保留位置，要加是複製變數檔。
- **多租戶自助註冊**、**高可用**：POC 不做。
- **Agent 接雲端**：等 FR-181 合回。

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

- **8 GB 的 OOM 風險**：compose 上限加總 17 GB，api 一個容器上限 6 GB。AI 分類批次、弱點掃描、LibreOffice 匯出同時發生會踩到。對策：8 GB swap、告警 80%、升級只改 `machine_type` 重 apply（關機重開兩分鐘，資料碟不動）。
- **installer 印警告**：最低值 16 GB，低於會印 ⚠ 但不擋裝（README 第 552 行實測 7 GB RC=0）。
- **帳單帳戶風控**：11-07 第一個專案撞過。apply 前先 `gcloud billing projects describe guidant-ai-cloud-514012` 看 `billingEnabled: true`。
- **25 port**：後台 SMTP 填 25 會靜默寄不出去，runbook 明寫 587。
- **Cloudflare IP 清單會變**：Terraform 每次 plan 重抓，清單變了 plan 會顯示防火牆更新，屬正常。
- **swap 在 GCE 映像預設沒有**：startup.sh 建，漏了 8 GB 就是硬上限。
- **從頭重建 VM 指紋會變**：License 要重開通；從快照還原不會。
- **Turnstile 只認 `.env` 的兩把 key**：填在 `guidant.env` 不會生效（前門讀不到 site key）。`guidant.env` 若有 `TURNSTILE_ENABLED=false` 會強制關，要開時一併刪掉。
