# visionA — Stage 部署手冊 > 版本:Phase 0.7(2026-05-01) > 對應任務:`.autoflow/progress.md` Phase 0.7 任務 B-1 ~ B-7 > 對應決策:S1-S8(HTTPS 處理位置、container 多 process、image 來源、OIDC public client 等) --- ## 1. 部署架構(一張圖) ``` [ Browser ] │ │ HTTPS :9527 (Let's Encrypt 證書,公司自動續簽) ▼ ┌─────────────────────────────────────────────────────────┐ │ 公司 host nginx(host: 192.168.0.130,stage-9527 域名) │ │ - HTTPS termination │ │ - reverse_proxy → http://localhost:9527 │ └─────────────────────────────────────────────────────────┘ │ │ HTTP :9527 (內網) ▼ ┌─────────────────────────────────────────────────────────┐ │ Docker container: visiona (host:9527 → container:80) │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ nginx :80 │ │ │ │ /healthz → 200 OK │ │ │ │ /api/* → api-server :3721 │ │ │ │ /storage/* → api-server :3721 │ │ │ │ /tunnel/connect→ remote-proxy :3800 (WS) │ │ │ │ /_next/static/ → next :3000 (1y cache) │ │ │ │ / → next :3000 (Next.js standalone) │ │ │ └─────────────────────────────────────────────────────┘ │ │ ↓ ↓ ↓ │ │ next:3000 api-server:3721 remote-proxy:3800/3801 │ │ (node) (Go binary) (Go binary) │ └─────────────────────────────────────────────────────────┘ ▲ │ wss://stage-9527.innovedus.com:9527/tunnel/connect │ [ visionA Agent on user PC ] ``` OIDC 流程: ``` Browser ──→ /api/auth/login (visionA-backend) ──→ 302 to MC :7850/connect/authorize?... (PKCE) ──→ 使用者登入 MC ──→ 302 back to /api/auth/callback?code=... (visionA-backend) ──→ token exchange + 建 cookie session ──→ 302 to / (frontend) ``` --- ## 2. 為什麼是這個架構(關鍵設計) ### 2.1 為什麼 HTTPS 由公司 host nginx 處理 公司 stage host 已有 host-level nginx + Let's Encrypt 自動續簽(`stage-9527.innovedus.com` 證書)。 我們重複實作 HTTPS 在 container 內反而: - 無法續簽(container 內沒 LE client、無 ACME challenge 路徑) - 與公司其他 service(`portainer`、`fanfan-mysql` 等共用 nginx)不一致 → container 內 nginx 純 HTTP listen :80,由公司 host nginx 反代。 ### 2.2 為什麼 container 多 process(不是 docker-compose 多 service) 仿 `edge-ai-platform/docker/Dockerfile`: - POC 是這個模式,stage 階段「先讓它跑起來」優先於完美架構 - 公司 host nginx 只反代到 host:9527 一個 port → 容器內必須再有一層 nginx 分流 - 雛形階段不需要獨立擴容 frontend / api-server / remote-proxy → 一個 container 內 4 process(nginx + node + api-server + remote-proxy),由 `entrypoint.stage.sh` 起。 **權衡**:任一 process 死 → container 整個重啟(不像 docker-compose 個別 restart)。 但這也代表問題會被快速發現(healthcheck 立刻失敗),符合 stage 階段需求。 ### 2.3 為什麼帶 node runtime(不是純 nginx serve static) `visionA-frontend/next.config.ts` 設 `output: "standalone"`(不是 `output: "export"`)。 差別: | Config | 產出 | 部署 | |--------|------|------| | `export` | `out/` 全靜態 HTML | nginx serve 即可(edge-ai-platform 用這個) | | `standalone` | `.next/standalone/server.js` + `.next/static/` | 需要 node 跑 server.js | Frontend 動態 routes(`/devices/[id]`、`/models/[id]`、`/workspace/[deviceId]`)目前是 「server component → unwrap params → 給 client component」pattern。改成 export 模式需要: - 加 `generateStaticParams()` 列出所有可能 ID(動態資源不可能列舉) - 或改用 placeholder `[_]` 路徑 + nginx fallback 重寫 → stage 階段不動 frontend code(對齊產品線原則:「Phase 0 不發明新 UI」), container 多帶 node runtime 跑 standalone server。Image 大 ~70MB 可接受。 未來若改 S3 storage / 更嚴格的安全 audit,可考慮: - frontend 走 CDN 靜態 export(需要先補 `generateStaticParams` 或重整 routes) - 或 frontend 獨立 service(k8s deployment) ### 2.4 為什麼 image push internal registry(不是 SCP tar) 公司 `192.168.0.130:5000` 已有 `registry:2` 容器,與其他 service 共用 image registry。 Stage host 的 docker daemon 已配置 insecure-registries 信任。 → 開發機 buildx push 到 internal registry → stage host docker compose pull 拉下來。 timestamp tag 留在 registry 給 rollback 用。 --- ## 3. 環境前置(一次性) ### 3.1 開發機(執行 `deploy-stage.sh` 的機器) ```bash # 必裝 docker --version # 任何 ≥ 20 版本 docker buildx version # 內建(Docker Desktop)或裝 docker-buildx-plugin pnpm --version # ≥ 9(不是必需,scripts 在 container 內裝) git --version # 首次 push 到 internal registry 前要把它加進 daemon insecure-registries: # macOS: Docker Desktop → Settings → Docker Engine # 加進 daemon.json: # { # "insecure-registries": ["192.168.0.130:5000"] # } # 然後 Restart Docker Desktop ``` ### 3.2 Stage host (`192.168.0.130`) 由公司 IT / DevOps 一次性設定(**你不需要做,這裡列出供參考**): ```bash # 1. Docker daemon 已開 :2375 給內網 + insecure-registry 加 192.168.0.130:5000 # 2. 公司 host nginx 已配 server block: # server { # listen 9527 ssl; # server_name stage-9527.innovedus.com; # ssl_certificate /etc/letsencrypt/live/stage-9527.innovedus.com/fullchain.pem; # ssl_certificate_key /etc/letsencrypt/live/stage-9527.innovedus.com/privkey.pem; # location / { # proxy_pass http://localhost:9527; # proxy_http_version 1.1; # proxy_set_header Host $host; # proxy_set_header Upgrade $http_upgrade; # proxy_set_header Connection "upgrade"; # proxy_read_timeout 86400s; # tunnel WS 用 # client_max_body_size 100M; # 模型上傳 # } # } # (注意:listen 9527 ssl 是公司 host nginx; # proxy_pass http://localhost:9527 才是反代到我們 container 的 host:9527) # 3. /opt/visiona/ 目錄存在且 docker daemon 可寫 # 4. /opt/visiona/data/ 存在(local-fs storage 用) ``` 如果以上有缺,先請 IT 補上。**第一次部署前請與 IT 確認 nginx 反代規則 + tunnel WS timeout 拉到 86400s**(少了這個 visionA Agent 連線 1 小時就會被切)。 --- ## 4. 第一次部署 step-by-step ### 4.1 開發機:產 .env.stage 並上傳到 stage host ```bash cd /Users/jimchen/visionA # 從範本複製 cp .env.stage.example .env.stage # 產 secrets SESSION_SECRET=$(openssl rand -hex 32) SIGNING_SECRET=$(openssl rand -hex 32) # 編輯,把以下兩個值換成上面產的 nano .env.stage # VISIONA_SESSION_SECRET= # VISIONA_STORAGE_SIGNING_SECRET= # .env.stage 已在 .gitignore,**不要 commit** git status # 確認看不到 .env.stage ``` ### 4.2 上傳 .env.stage + docker-compose.stage.yml 到 stage host 兩條路徑任選: **選項 A — SCP(如果有 SSH 權限)** ```bash # 假設 stage host 走 SSH(如有) ssh stage-user@192.168.0.130 "mkdir -p /opt/visiona/data" scp .env.stage docker-compose.stage.yml stage-user@192.168.0.130:/opt/visiona/ ``` **選項 B — Portainer / 手動** 如果你只有 Docker daemon 存取(沒 SSH),請公司 IT 幫忙 mkdir + 把這兩個檔放到 `/opt/visiona/`: ```bash # 把檔案內容複製到 stage host cat .env.stage # 印出來給 IT 貼 cat docker-compose.stage.yml # 印出來給 IT 貼 ``` ### 4.3 確認 :9527 端口可用 ```bash # 從你的開發機,先確認 :9527 已釋放 DOCKER_HOST=tcp://192.168.0.130:2375 docker ps --filter publish=9527 # 應該空(edge-ai-platform 已 stop) # 如果還有東西占著: DOCKER_HOST=tcp://192.168.0.130:2375 docker stop ``` ### 4.4 跑部署 ```bash cd /Users/jimchen/visionA bash scripts/deploy-stage.sh ``` 預期輸出(每步驟有 `=== ===` 區隔): ``` === 0/5 Pre-flight checks === [INFO] Project root : /Users/jimchen/visionA [INFO] Docker remote : tcp://192.168.0.130:2375 [INFO] Registry : 192.168.0.130:5000 ... === 1/5 Build (buildx, linux/amd64) === [+] Building 180.5s ... [+] Pushing manifest for 192.168.0.130:5000/visiona:stage === 2/5 Verify image in registry === [INFO] registry 確認有 visiona:stage === 3/5 Remote pull image === === 4/5 Deploy via docker compose === === 5/5 Verify === [INFO] container healthy ✓ [INFO] 對外 healthz ✓ (https://stage-9527.innovedus.com:9527/healthz) ``` ### 4.5 瀏覽器驗證 打開 `https://stage-9527.innovedus.com:9527/`: - [ ] 看到 visionA 首頁(不是 nginx default 或 404) - [ ] 點右上「登入」→ 跳到 MC OIDC 登入頁(`stage-9527.innovedus.com:7850`) - [ ] 登入後 302 回 visionA `/`,狀態列顯示已登入 - [ ] DevTools → Application → Cookies 看到 `visiona_session`(HttpOnly + Secure) - [ ] `https://stage-9527.innovedus.com:9527/healthz` 回 `ok` --- ## 5. 日常更新部署 ```bash cd /Users/jimchen/visionA git pull bash scripts/deploy-stage.sh ``` 腳本會: 1. buildx build 新 image,push 到 registry(含 timestamp tag 給 rollback) 2. stage host pull `:stage` tag 3. `docker compose up -d` 重啟 container(restart unless-stopped) 4. 等 healthcheck 平均 wall-clock:約 3-5 分鐘(取決於有沒有改 dependency)。 --- ## 6. Rollback ### 6.1 找前版 tag ```bash # 列 registry 上的 stage tags curl -s http://192.168.0.130:5000/v2/visiona/tags/list | python3 -m json.tool # 或 docker remote 端 DOCKER_HOST=tcp://192.168.0.130:2375 docker images 192.168.0.130:5000/visiona --format "{{.Tag}}\t{{.CreatedSince}}" ``` Tag 格式:`stage-YYYYMMDD-HHMMSS-`,例:`stage-20260501-153045-a1b2c3d` ### 6.2 Rollback ```bash bash scripts/deploy-stage.sh --rollback stage-20260501-153045-a1b2c3d ``` 腳本會: 1. 把 `:stage-xxx` retag 為 `:stage` 2. push 回 registry 3. stage host docker compose pull + up -d 如果 retag 推回失敗(registry 設 immutable),改成手動: ```bash ssh stage-host cd /opt/visiona sed -i 's|visiona:stage|visiona:stage-20260501-153045-a1b2c3d|' docker-compose.stage.yml docker compose -f docker-compose.stage.yml up -d # 部署成功後改回來 sed -i 's|visiona:stage-.*|visiona:stage|' docker-compose.stage.yml ``` --- ## 7. 故障排除 ### 7.1 對外 https://stage-9527.innovedus.com:9527 不通 排除順序(由外而內): ```bash # 0. 你網路本身能不能解到 stage-9527.innovedus.com? nslookup stage-9527.innovedus.com # 1. 公司 host nginx 有沒有監聽 :9527? curl -k -v https://stage-9527.innovedus.com:9527/healthz # 200 ok → 全鏈路 OK # 502/504 → host nginx 有接到,但反代到 container 失敗(往下查 2) # 連線拒絕 → host nginx 沒監聽 :9527(請 IT) # 2. container 在不在? DOCKER_HOST=tcp://192.168.0.130:2375 docker ps --filter name=visiona # 看 STATUS:Up X minutes (healthy) 才正常 # 3. container 內部能不能打通? DOCKER_HOST=tcp://192.168.0.130:2375 docker exec visiona curl -fsS http://127.0.0.1:80/healthz # 應回 ok # 4. host 能打到 container? DOCKER_HOST=tcp://192.168.0.130:2375 docker exec visiona wget -qO- http://127.0.0.1:80/healthz # 應回 ok ``` 如果 step 1 是「連線拒絕」→ 公司 host nginx 沒接到 :9527,請 IT 加 server block(見 §3.2)。 ### 7.2 OIDC callback 失敗 症狀:登入完跳回 `/api/auth/callback` 顯示 error。 檢查清單: ```bash # 1. callback URL 三邊一致? # .env.stage: VISIONA_OIDC_REDIRECT_URL # MC client 設定: redirect_uris # 瀏覽器網址列實際看到的: /api/auth/callback?code=... # 三個必須完全一樣(含結尾斜線、含 :9527) # 2. issuer URL 帶結尾斜線? grep VISIONA_OIDC_ISSUER_URL /opt/visiona/.env.stage # 必須是 https://stage-9527.innovedus.com:7850/ ← 有結尾斜線 # 3. client_secret 配對? # Public PKCE-only client 必須 VISIONA_OIDC_CLIENT_SECRET 留空 # Confidential client 必須 VISIONA_OIDC_CLIENT_SECRET 有值 # 兩邊不對就會 invalid_client # 4. 看 backend log DOCKER_HOST=tcp://192.168.0.130:2375 docker logs visiona 2>&1 | grep -i oidc | tail -20 ``` ### 7.3 Cookie 沒被前端帶上 症狀:登入後 reload 又變未登入。 ```bash # DevTools → Network → /api/auth/me request → 看有沒有 Cookie: visiona_session=... # 沒有 → cookie domain / path 設錯 # stage 應該 VISIONA_SESSION_COOKIE_DOMAIN=(留空,host-only) # VISIONA_SESSION_COOKIE_SECURE=true(HTTPS only) ``` ### 7.4 模型上傳卡在中段 症狀:100MB 內可以,超過會 413 或 timeout。 兩處 limit 必須對齊: ```bash # 1. nginx.stage.conf(在 image 內) grep client_max_body_size docker/nginx.stage.conf # client_max_body_size 100M; # 2. 公司 host nginx # 請 IT 確認 server block 內也有 client_max_body_size 100M; # 沒設 = 預設 1M,會 413 ``` ### 7.5 visionA Agent 連 tunnel 1 小時就斷 ```bash # 公司 host nginx 必須有: # proxy_read_timeout 86400s; # proxy_send_timeout 86400s; # /tunnel/connect 同層 location 內也要有 Upgrade / Connection upgrade headers # 請 IT 加上 ``` ### 7.6 Container 啟動就立刻 Exit ```bash DOCKER_HOST=tcp://192.168.0.130:2375 docker logs visiona # 常見原因: # - Validate() fail:缺 OIDC env var → 看 stderr 列出哪個變數 # - VISIONA_SESSION_SECRET < 32 chars → backend panic # - port 衝突:unable to bind :3721 → 看是不是別的 service 占了 ``` ### 7.7 緊急救回 POC(visionA 部 fail) 之前 stop 掉沒 rm,可救: ```bash DOCKER_HOST=tcp://192.168.0.130:2375 docker stop visiona DOCKER_HOST=tcp://192.168.0.130:2375 docker start edge-ai-platform # :9527 流量回到 POC,先讓使用者繼續用 ``` --- ## 8. 已知限制(Phase 0.7) 1. **公司 host nginx 是黑盒** — 不在我們 git 控管,更動需要請 IT,文件無法 100% 同步真實狀態 2. **單 container 多 process** — 4 個 process 共命運(任一掛 → 全重啟)。stage 階段可接受,production 應拆 service 3. **LocalFS storage** — 上傳的模型存在 stage host `/opt/visiona/data/`,container 重建不會丟(有 volume),但跨 host 移轉會丟 4. **OIDC 只接 login client** — service-to-service `client_credentials` 已留 config 鉤子但未啟用 5. **Health check 只看 nginx,不看 backend** — `/healthz` 是 nginx 直接回 200,不打 backend;backend 死了 nginx 還活著 healthy 仍是 healthy(但 entrypoint `wait -n` 會抓到 backend 死亡 → container 整個 die → docker restart) 6. **Internal registry 沒設 push 認證** — 任何能 reach `192.168.0.130:5000` 的人都能 push 任何 image。stage 內網 OK,prod 必須加 auth 7. **rollback retag** — `deploy-stage.sh --rollback` 會把 timestamp tag retag 成 `:stage` 推回 registry。若 registry 啟用 immutable tag policy 會失敗,需手動改 compose file(§6.2 結尾有教) --- ## 9. 第一次部署 checklist(給使用者) 執行 `deploy-stage.sh` 之前,以下事項你需要做: - [ ] 在開發機 Docker Desktop daemon.json 加 `"insecure-registries": ["192.168.0.130:5000"]` - [ ] 確認可以 reach `tcp://192.168.0.130:2375`(`docker -H tcp://192.168.0.130:2375 ps` 不拒連) - [ ] 確認可以 reach `192.168.0.130:5000`(`curl http://192.168.0.130:5000/v2/_catalog` 回 JSON) - [ ] 跟公司 IT 確認 stage host nginx 已配 `:9527 → localhost:9527` 反代(含 WS upgrade + 86400s timeout + 100M body size) - [ ] 跟公司 IT 確認 stage host 上 `/opt/visiona/` 目錄存在 - [ ] 在開發機產 .env.stage(`cp .env.stage.example .env.stage` + 編輯填 secrets) - [ ] 把 `.env.stage` + `docker-compose.stage.yml` 上傳到 stage host `/opt/visiona/` - [ ] 確認 :9527 已釋放(`edge-ai-platform` 已 stop) - [ ] MC 端 OIDC client `b8093fea1a504a5d8f0e04bee9f78f2e` 的 redirect_uri 是 `https://stage-9527.innovedus.com:9527/api/auth/callback` 跑: ```bash bash scripts/deploy-stage.sh ``` 成功標準: - 腳本 step 5/5 顯示 `[INFO] container healthy ✓` + `[INFO] 對外 healthz ✓` - 瀏覽器打 `https://stage-9527.innovedus.com:9527/` 看到 visionA 登入頁 - OIDC 流程跑得通 --- ## 10. 相關檔案 | 檔案 | 角色 | |------|------| | `docker/Dockerfile.stage` | 多 stage build:node + go + nginx 三 builder → nginx-alpine runtime | | `docker/nginx.stage.conf` | container 內 nginx 反代規則 | | `docker/entrypoint.stage.sh` | container 啟動腳本(4 process + signal handling) | | `docker-compose.stage.yml` | stage host 上的 compose(單 service from registry) | | `.env.stage.example` | 環境變數範本(在 git 裡,可 commit) | | `.env.stage` | 真實 env 檔(**不在 git 裡**,由人手動放上 stage host) | | `scripts/deploy-stage.sh` | 一鍵部署腳本(buildx + push + remote compose) | 對照 dev 環境(不在本文件範圍): | 檔案 | 角色 | |------|------| | `docker-compose.dev.yml` | local 開發(postgres + member-center + visiona-backend + frontend)| | `.env.dev.example` | dev 環境範本 | | `Makefile` | local make targets | --- > 文件責任人:DevOps Agent > 最後更新:2026-05-01 > 變更請同步更新 `.autoflow/progress.md` Phase 0.7 任務狀態