Guidant AI · FR-167 · 2026-11-01

線上更新機制問答

回答 2026-10-31 PM 會議提出的機制面問題。每題先給結論,再給查到的事實與路徑。涉及規劃或方案取捨的另列在文末。

安裝與設定

Q6

安裝部署做哪些事、路徑在哪、用哪個帳號、權限多大

全程以 root 執行(sudo ./install.sh)。理由:要建系統目錄、寫 600 權限的設定檔、把容器要讀的檔 chown 給容器內的 uid、操作 docker daemon,這些都需要 root;安裝完後日常維運指令 sudo guidantai 同樣以 root 跑。

安裝八步與落點:

步做什麼動到哪裡
①環境檢查:docker、磁碟、埠、核心只讀
②載入 9 顆映像並逐檔核對 bundle-manifest.sha256docker image store
③產生密碼與金鑰(見 Q7)記憶體,下一步才落檔
④寫設定檔、產 HTTPS 自簽憑證、產 Agent 對接 CA/srv/guidant-ai/.env、guidant.env(600 root)、certs/、pki/
⑤建資料庫:init 容器跑全部 schema 與 seeddocker volume guidant_guidant-pgdata(第一次升級後改為 /srv/guidant-ai/pgdata)
⑥設定內建物件儲存,灌隨包系統檔(框架 PDF、檢測基準)SeaweedFS bucket guidant-ai
⑦啟動服務,裝維運指令與 systemd 單元/usr/local/bin/guidantai、/usr/local/lib/guidant/、/etc/systemd/system/guidant-upgrade-*、/etc/guidant-ai/install.conf、/etc/guidant/release-keys/
⑧印安裝參數與下一步(開通授權)安裝紀錄 /srv/guidant-ai/log/install-*.log

資料目錄 /srv/guidant-ai 是唯一要備份的地方:設定、憑證、資料庫、物件儲存、升級進度全在裡面。/etc/guidant-ai/install.conf 只記「資料目錄在哪、compose 檔在哪」,給維運指令定位用。

Q6
補問

升級用的公鑰為什麼放 /etc/guidant/?什麼時候放進去的?

安裝時就放,不是之後補的。190 實查 /etc/guidant/release-keys/b91819f86eb4b4f8.pub 建檔 2026-10-30 19:52:00,與同台安裝紀錄 install-20261030-194622.log 收尾同一分鐘,即 install.sh 第 ⑦ 步「裝維運指令」做的:install.sh:639 呼叫 lib/self_replace.sh --install-from,self_replace.sh:180 再呼叫 release_keys_install(lib/release_keys.sh:99),把出貨包內 release-keys/*.pub 複製到 /etc/guidant/release-keys/。之後每次升級第 0 步走同一支,新鑰隨新包進來、舊的保留。

為什麼放 /etc 不放資料目錄:這把鑰是「用來驗新包能不能信」的。驗章鐵則(release_keys.sh:6):只用這台機器上已經裝好的印樣,不用新包裡帶來的,拿包裡的鑰驗包裡的清單等於沒驗。所以它要與出貨包、資料目錄都脫鉤,放系統設定目錄,root 持有 644 唯讀,與 /usr/local/bin/guidantai、/usr/local/lib/guidant/ 同屬「與包脫鉤的系統層資產」。後端容器以唯讀掛載讀它(docker inspect:/etc/guidant/release-keys (ro)),自己改不了。

項目事實
鑰的種類Ed25519 公鑰(SPKI PEM,113 bytes)。檔名 b91819f86eb4b4f8=公鑰 sha256 前 16 位,即「印樣編號 kid」
這把是誰的10/30 首腦在決策者電腦產的測試章,私鑰在 ~/.guidant-release-test/。正式章等 guidant-release init-keys,公鑰放進 repo scripts/installer/release-keys/ 隨包出去
換鑰順序舊鑰驗過這一版 → 這一版的包才把新鑰裝上。release_keys_install 只加不減、同名不覆蓋(release_keys.sh:100)
出包守門build_bundle.sh:187 沒有正式章就紅燈拒出包(測試期 --skip-prod-key-check 繞)
Q6
補問 2

安裝目錄結構:每個資料夾放什麼、什麼可以清、什麼不能動

依 190(10/30 乾淨重裝)與 189(8 月裝到現在、11 次舊式升級再接新式)實查整理。先看結構圖,再看三級清單,最後是兩台對照。

/srv/guidant-ai/                      資料目錄:唯一要備份的地方
├── pgdata/                           不能動  PostgreSQL 現用資料
├── .env                              不能動  compose 用的全部密碼(Q7)
├── guidant.env                       不能動  後端設定、JWT、三把加密金鑰
├── pki/  certs/  seaweedfs/s3.json   不能動  Agent CA、HTTPS 憑證、物件儲存金鑰
├── docker-compose.yml                不能動  部署定義(已與出貨包脫鉤的副本)
├── bugsink-admin.secret              不能動  錯誤追蹤站管理密碼
│
├── pgdata-prev-<舊版>/              自管    上一版快照,退回用;下次成功第 7 步自動刪
├── .upgrade-config-prev/             自管    升級前設定與憑證備份,每次覆寫
├── ai-gateway-rules/  .default/      自管    AI 閘道規則:客戶改過的 / 隨包預設
├── home/  static/                    自管    容器掛載的工作目錄與靜態檔
├── log/                              自管
│   ├── app.log  app.log.<日期>        後端 log,按日輪替保留 30 天(LOG_RETENTION_DAYS)
│   ├── install-<時間>.log             安裝紀錄
│   ├── upgrade-migrate-<單號>.log     每次升級的 migration 輸出
│   └── upgrade-<時間>.log             舊式 install.sh --upgrade 才寫 可清
│
├── upgrade/
│   ├── upgrade-progress.json         自管    進度檔:更新中心、維護頁、開機檢查都讀(Q18)
│   ├── last-switch.json  progress.d/ 自管    換接紀錄、每步明細
│   ├── maintenance/                  自管    新版維護頁(第 0 步放進來)
│   ├── orders/  .upgrade.lock        自管    更新單落地點(平時空)、防重入鎖
│   ├── incoming/                     可清    下載的更新包 tar.gz+清單+sha256+ops,每包 5 GB
│   │                                           第 7 步只留本次;留著是給「重跑同版」,成功後可手刪
│   ├── bundles/guidant-ai-<版>/      可清    解開的包;成功後只剩 agent/ 1.7 GB(Agent 推送用)
│   └── diag/                         可清    歷次診斷包暫存,寄過就能刪
│
├── backup/  backup-manual/           可清    舊流程每次升級前的 pg_dump;新流程不寫不讀。只有老機器有
└── *.bak-*                           可清    人手改設定前的備份,確認現行設定正常後刪

/etc/guidant/release-keys/<kid>.pub  不能動  發布印樣,丟了升級包驗不過
/etc/guidant-ai/install.conf          不能動  記資料目錄在哪,維運指令靠它定位
/usr/local/bin/guidantai              不能動  維運指令
/usr/local/lib/guidant/               不能動  升級函式庫(八步腳本)
/etc/systemd/system/guidant-upgrade-* 不能動  接單、開機檢查的 systemd 單元

docker volume
├── guidant_guidant-seaweedfs-data    不能動  證據檔
├── guidant_guidant-bugsink-data      不能動  錯誤追蹤資料
├── guidant_guidant-classifier-jobs   不能動  分類工作
├── guidant_guidant-scan-vulndb       不能動  弱點庫
└── guidant_guidant-pgdata            自管    第一次升級前的舊資料庫 volume,下次成功自動刪

docker 映像
├── guidant-ai-be/fe/init           自管    第 7 步留本版+上一版,更早自動刪
├── postgres  redis  seaweedfs  bugsink  evidence-classifier   第三方,升級流程不動
└── guidant-ai-agent:<版>             可清    Agent 容器自己升級留下的,主系統不管(190 五顆 21 GB)
不能動丟了等於資料或系統沒了 自管系統自己產自己清,不用手動 可清磁碟緊時先看這裡

🔴 不能動(丟了等於資料或系統沒了)

位置內容丟了會怎樣
pgdata/PostgreSQL 現用資料目錄全部業務資料沒了
.env、guidant.env全部密碼、JWT、三把加密金鑰(Q7)連不上資料庫;Google Drive 授權與檢測工具憑證永遠解不開
pki/、certs/、seaweedfs/s3.jsonAgent 對接 CA、HTTPS 憑證、物件儲存金鑰全部 Agent 要重接;物件儲存讀不到
docker-compose.yml、bugsink-admin.secret部署定義(已與出貨包脫鉤的副本)、錯誤追蹤站管理密碼維運指令動不了
docker volume guidant_guidant-seaweedfs-data 等四顆證據檔、錯誤追蹤資料、分類工作、弱點庫證據檔沒了
/etc/guidant/release-keys/發布印樣升級包驗不過,再也升不了
/etc/guidant-ai/install.conf、/usr/local/bin/guidantai、/usr/local/lib/guidant/、/etc/systemd/system/guidant-upgrade-*維運指令、升級函式庫、接單的 systemd 單元線上升級接不到單;維運指令找不到資料目錄

🟡 系統自己管(刪了會變難查或退不回,不用手清)

位置內容說明
pgdata-prev-<舊版>/上一版資料庫快照,退回用下次升級成功第 7 步自動刪,只留一代(Q14)
.upgrade-config-prev/升級前設定與憑證備份180K,每次升級覆寫
upgrade/upgrade-progress.json、last-switch.json、progress.d/進度檔更新中心、維護頁、開機檢查、guidantai recover 都讀它(Q18)
upgrade/maintenance/、orders/、.upgrade.lock新版維護頁、更新單落地點、防重入鎖orders/ 平時是空的
ai-gateway-rules/、.default/AI 閘道規則:客戶改過的 / 隨包預設每版升級只換 .default
home/、static/容器掛載的工作目錄與靜態檔
log/app.log 按日輪替保留 30 天(jedi-common log_settings.py:15,LOG_RETENTION_DAYS 可調);install-*.log;每次 migrate 一支 upgrade-migrate-*.log診斷包收最近三支 upgrade/install log。30 天滿會自己刪,不用手動

🟢 可以清(磁碟緊時先看這裡)

位置內容說明
upgrade/incoming/下載的更新包、清單、sha256、ops 小檔,每包 5 GB第 7 步只留本次的,更早版本自動刪。設計上刻意留本次那包給「重跑同版」用;確定升級成功後可手動刪,下次重跑要重下載
upgrade/bundles/guidant-ai-<版>/解開的包,升級成功後只剩 agent/(1.7 GB,Agent 推送用)Agent 都升完或沒 Agent 可刪
upgrade/diag/歷次診斷包暫存已寄原廠的可刪
backup/、backup-manual/舊 install.sh --upgrade 流程每次升級前的 pg_dump(Q5/Q20)新流程不寫也不讀,整個可刪;只有老機器有
*.bak-*(.env.bak-cm1418、guidant.env.bak-fr133-*、guidant.env.bak-fr167-*)人手改設定前的備份確認現行設定正常後可刪
docker 映像自家 be/fe/init 第 7 步自動刪舊版、留本版加上一版;第三方不動Agent 映像主系統清理不管(190 有 1.4.3~1.4.7 五顆各 4.2 GB)。不要跑 docker image prune -a,會連 postgres 等第三方一起清

189 與 190 對照(2026-11-01 12:15 實查)

項目189 POC(8 月裝,11 次舊式升級再接新式)190(10/30 乾淨重裝)可清?
backup/+backup-manual/410 MB,8 支 `.sql.gz`(9/30~10/10)+1 支 8/27 手動 dump沒有✅ 整個刪
*.bak-* 設定備份4 支(8/28~10/31)1 支✅ 確認現行設定後刪
log/531 MB,36 支 app.log(30 天輪替、10/4 一天 78 MB、10/10 95 MB)+12 支舊式 upgrade-*.log30 MB,3 支app.log 30 天自己刪;12 支舊式 upgrade log 可刪
upgrade/incoming/9.9 GB:1.27.9(已裝)+1.27.10(12:13 剛下載未裝)5.1 GB:1.27.81.27.9 那包升級成功後可刪;1.27.10 正在等裝不可動
upgrade/bundles/1.7 GB,只剩 1.27.9 `agent/`1.5 GB,只剩 1.27.8 `agent/`兩台一致,第 7 步清得正確
pgdata-prev-*684 MB(1.27.8)138 MB(1.27.7)兩台一致,各留一代
主機 /home/jedi/16 GB:guidant-ai-1.27.7/ 解開目錄 8.4 GB、1.26.0 與 1.27.7 兩支 tar.gz 7.7 GB沒有兩支 tar.gz ✅;解開目錄先別動(見發現的問題:一顆 redis 容器的 compose 標籤還指著它)
docker 自家映像1.27.8/1.27.9,乾淨1.27.7/1.27.8+Agent 1.4.3~1.4.7 五顆 21 GB190 的 Agent 1.4.3~1.4.6 四顆 17 GB ✅
磁碟195 GB 用 139 GB(75%)293 GB 用 107 GB(39%)189 可安全回收約 8.6 GB,解開目錄查完再加 8.4 GB

189 的清理動作屬「改 POC 狀態」,由首腦執行;本 session 只列清單。

Q7

密碼放在哪、怎麼加密、金鑰在哪、怎麼輪換

密碼與金鑰分兩個檔,都是 600、root 持有、明文存放:

檔內容誰讀
/srv/guidant-ai/.env資料庫管理與業務帳號密碼、Redis 三組帳號密碼、版號、資料庫目錄docker compose 變數
/srv/guidant-ai/guidant.envJWT 簽章金鑰、三支加密金鑰(Google Drive 授權、檢測工具憑證、AI 供應商金鑰)、系統網址、Agent 對接設定、更新站網址後端容器環境變數

為什麼明文:這兩個檔是「開機要用的鑰匙」,加密它們就需要另一把鑰匙放在同一台機器,等於沒加密。業界落地版的常規做法同樣是檔案權限 600 加 root,資料庫裡的敏感欄位才加密。

資料庫裡什麼有加密:Google Drive 授權 token、檢測工具帳密、AI 供應商 API key 三類,用 Fernet(AES-128-CBC 加 HMAC-SHA256,Python cryptography 套件的標準封裝),各自一把 32 bytes 金鑰(openssl rand -base64 32 產生)。密碼本身是 openssl rand -base64 24 產的 24 字元。

輪換:sudo guidantai rotate-credentials 換資料庫管理帳號、業務帳號、Redis 三組密碼,服務短暫重啟。刻意不輪換:JWT 金鑰(換了所有人當場登出)、三支加密金鑰(既有資料用舊鑰加密,換鑰不會回頭重加密,換了就永遠解不開)。要換加密金鑰需要另做「讀舊寫新」的遷移工具,目前沒有。

內部連線:Redis 與 SeaweedFS 走 TLS,憑證由主機上的 Agent CA 簽。SeaweedFS 內的檔案在 Agent 1.4.4 起靜態加密;主系統側的靜態加密狀態我還沒逐一核實,列為待確認。

Q7
補問

.env 裡的資料庫、Redis、「業務帳號」密碼都是明文?業務帳號是 admin 密碼嗎?

是明文,沒有加密;業務帳號不是 admin,是資料庫服務帳號。

/srv/guidant-ai/.env 實際有的密碼(scripts/installer/install.sh:1397 起寫檔那段;原 Q7 少列了後兩類):

鍵給誰用
GUIDANT_DB_ADMIN_PASSWORD資料庫管理帳號 cmmgr,建庫與 migration 用
GUIDANT_DB_APP_PASSWORD資料庫服務帳號 cm_app,後端天天連 DB 用(原文「業務帳號」指這個)
GUIDANT_REDIS_PASSWORD+_API_/_SOCKETIO_/_WORKER_Redis 維運那組,加三個後端行程各自的帳號
GUIDANT_S3_ACCESS_KEY/GUIDANT_S3_SECRET_KEY內建物件儲存 SeaweedFS
GUIDANT_CLASSIFIER_TOKEN後端呼叫證據分類服務的共享密鑰

保護只有一層:600、root 持有(install.sh:1430)。這些全是「機器對機器」的連線密碼。

人登入的密碼不在這裡。root admin 密碼:安裝時若有填,install.sh 以「只傳變數名不傳值」交給 init 容器(install.sh:1791),容器內 init.sh:134 當場轉 bcrypt 雜湊寫進使用者表,跑完 unset;不上命令列、不進 .env、不進安裝紀錄。沒填用 06-admin.sql 預設雜湊,首次登入改。之後所有使用者密碼都是 bcrypt 雜湊在資料庫,無處有明文。

診斷包只收 guidant.env 不收 .env,收的那份由核心統一遮罩(guidantai:728)。

Q7
補問 2

明文太可怕,有辦法加密嗎?

檔案能加密,但在這種落地版主機上幾乎沒有實質保護力,該花力氣的是別的地方。三個查到的事實:

  • 能讀這檔的人就是 root,而 root 可以 docker inspect guidant-api 直接看容器環境變數裡的密碼(後端用環境變數讀,config/config_loader.py:77),也能直接讀 /srv/guidant-ai/pgdata 整個資料庫目錄,根本不需要密碼。.env 加密了,資料還是明文躺在同一顆磁碟。
  • 解密鑰匙沒地方放。190 實查:Ubuntu 22.04、systemd 249、沒有 TPM、沒有全碟加密。能把鑰匙鎖進硬體的 systemd-creds 要 systemd 250 以上加 TPM2,一般 VM 多半沒 TPM。沒硬體可鎖,鑰匙只能放檔案,等於沒加密。
  • 每次開機由人輸入密碼是唯一真正不落地的方式,但服務不能無人值守重啟,升級第 6 步自動起服務也會卡住。

問題:威脅不是「檔案是明文」,是「拿到 root 或拿到磁碟的人看得到一切」,加密 .env 擋不住這兩種人。

建議(三層):

  1. 進裝機手冊給客戶:主機開全碟加密(LUKS),擋磁碟被搬走或 VM 映像被複製;限制誰能 sudo。屬客戶主機設定,進手冊不進資安報告。
  2. 我們自己可改,縮小曝露面:密碼不走環境變數,改掛檔案進容器、程式讀檔。docker inspect、容器 crash dump、子程序都看不到密碼。磁碟上仍明文,但「不小心外洩」的路少一大半。中等大小一張卡,動 install.sh、compose、config_loader.py。
  3. 客戶主機撐得住再做:Ubuntu 24.04 以上且有 TPM2 時,可選用 systemd-creds 把密碼封進 TPM,開機自動解、磁碟上只有密文、搬到別台解不開。真正的加密,但看客戶環境,不能當預設。

理由:資料庫本體 pgdata 明文、證據檔在 SeaweedFS 明文,.env 那幾串只是進門的鑰匙,門後的東西本來沒鎖。先鎖門後(全碟加密),再縮小鑰匙曝露面(檔案掛載),最後才談把鑰匙鎖進保險箱(TPM)。

Q7
補問 3

docker 用什麼身分跑?當初掃描有警告「不能 root」?系統被入侵怎麼處理?

docker 引擎本身是 root(190 實查 dockerd 行程),沒用 rootless 模式;這是業界預設,rootless 會讓綁 80/443、容器內 chown、效能都變麻煩。容器裡的程式分三種狀況(190 實查 docker top、docker inspect):

容器程式實際身分加固
guidant-api/worker/socketiouid 1000 非 root:entrypoint 以 root 起、chown 幾個掛載目錄後立刻降權(entrypoint.sh 第 7、8 行)cap_drop ALL 只加回 4 個、no-new-privileges、唯讀根檔系統、pids 上限
guidant-sandboxclassifier 非 rootcap_drop ALL、唯讀、pids 256
guidant-bugsinkbugsink 非 rootcap_drop ALL、no-new-privileges
guidant-ai-agentuid 1000cap_drop ALL 只加 NET_RAW、no-new-privileges、pids 512
guidant-db(postgres)非 root(降成 postgres)無 cap_drop、no-new-privileges、pids 上限
guidant-fe(nginx)主程式 root、worker 非 root無
guidant-redisroot無
guidant-seaweedfsuid 1000無

「rootless」與「容器內非 root」是兩個層次,決策者問到時釐清:

名詞管的是誰現況
rootless dockerdocker 引擎本身(dockerd)用一般使用者跑。擋「攻破引擎=攻破主機」沒做,三台 dockerd 都是 root。業界預設也不做,會讓綁 80/443、chown、效能變麻煩;暫不建議動
容器內非 root(USER 非 root、cap_drop、no-new-privileges)容器裡那支程式用哪個身分跑。擋「攻破程式=拿到容器 root、再找洞逃出去」自家四顆有做,第三方四顆沒做(上表)。掃描警告的是這一種

當初掃描警告的是 SR-09.5「容器非 root、能力全拿掉、有資源上限」,但只針對 Agent 容器(docs/security-report/requirements/SR-09-agent.md:81),矩陣標「部分符合」。主系統容器當時沒有被列成一條 SR。實況:我們自己寫的四顆都做到了,第三方四顆(db、fe、redis、seaweedfs)沒套同一套加固,redis 還是 root 在跑。列入發現的問題。

系統被入侵怎麼處理:目前沒有寫成文件的處置流程(docs/user-manual/ 與資安報告都沒有事件應變章節)。照現有機制能做的:

  • 圍堵:主機層拔網路或防火牆封鎖;sudo guidantai rotate-credentials 換資料庫與 Redis 全部密碼(JWT 與加密金鑰不在內,見 Q7);授權中心把這張授權加進更新站拒絕名單。
  • 鑑識:sudo guidantai diag 出診斷包(容器 log、system_logs、遮罩後設定檔);主機 journalctl;api_logs 表是每個 HTTP 請求的流水,可追誰在何時打了什麼。
  • 重建:image 有簽章與雜湊、安裝包有清單,做法是乾淨機器重新 install.sh,把 /srv/guidant-ai 的 pgdata 與 seaweedfs 資料接回。但資料目錄可能已被動過,接回前要先與快照或備份比對。

缺的:事件應變手冊、「JWT 金鑰一鍵換掉讓全部 session 失效」的指令、定期備份(Q5/Q20)、檔案完整性監控。屬規劃,列待討論。

發版與簽章

Q8

發版腳本在哪、怎麼處理祕密與簽章、怎麼決定最小版本

三組腳本,各在一個 repo:

做什麼在哪祕密
編譯、掃描、打包、算指紋、產未簽清單主專案 scripts/build/build_all.sh、build_bundle.sh、release_json.sh只有錯誤追蹤站管理員密碼要從 CI 變數帶入烙進包
傳 R2、向更新站登記草稿主專案 scripts/build/publish_draft.sh(CI package 段呼叫)R2 金鑰、更新站 CI token,都在 GitLab CI 變數,不落檔
蓋章、發布、撤回、換章update-center cli/guidant-releaseEd25519 私鑰在決策者電腦 ~/.guidant-release/,passphrase 只在終端機輸入

最小版本(可直升起始版)不是人填的數字,是從 migration 清單推算:scripts/sql/manifest.tsv 每支 migration 有一欄 min_base,寫 migration 的人只在「這支依賴某版才建的東西,從更舊的版直跳會壞」時填那個版號。出包時取全部的最大值寫進清單。目前沒有任何一支填,所以每版都是 1.0.0,任何版都能一跳升上來。

Q9

簡報「一條指令打一次密碼」是什麼意思

決策者在自己電腦執行 guidant-release publish 1.27.8。程式會要求輸入私鑰檔的密碼,輸入一次後:解開私鑰、對清單簽章、把簽好的清單送到更新站、狀態改為「已發布」。密碼不存、不進參數、不進環境變數。簡報那句已改寫。

Q11

15 分鐘下載網址怎麼產生、用什麼服務

更新包放在 Cloudflare R2(S3 相容物件儲存),bucket 不公開。客戶主機要下載時向更新站要網址,更新站用 R2 的存取金鑰對該物件簽一個 AWS SigV4 預簽網址,有效期 X-Amz-Expires=900(15 分鐘)。網址帶著簽名,R2 驗簽放行,過期就 403。主機程式只接受指向固定 R2 主機名的網址,更新站給別的網址一律拒。5 GB 的包下載不經更新站,直接從 R2 拉,更新站沒有流量負擔。

Q11
補問

這是 Cloudflare 的機制嗎?是我們用 API 產一個臨時網址給主機下載?

是。更新站(Cloudflare Worker)替這台主機簽一個 15 分鐘有效的臨時網址,主機拿著它直接向 R2 拉包,更新站本身不經手檔案。嚴格說這是 S3 體系通用的「預簽網址」,R2 相容 S3 API 所以照用,不是 Cloudflare 獨有功能。

環節事實在哪
誰簽Worker 用 R2 存取金鑰對「這個物件的 GET」做 AWS SigV4 簽名,簽在 query string,附 X-Amz-Expires=900;時效常數寫死 15 分鐘update-center src/releases.ts:160(presignDownload)、同檔第 10 行
簽到哪端點 https://<account>.r2.cloudflarestorage.com,bucket guidant-ai-release,bucket 不公開wrangler.toml 第 18、19 行
網址裡有什麼只有簽章,沒有金鑰。金鑰只在 Worker 環境變數;網址流出去也只能在 15 分鐘內下載那一個檔,過期 R2 回 403同上
誰能要到主機先用授權與指紋通過身分驗證,再查「這張授權在 check 看得到這個版」才簽;撤回的、別主版號的一律 404,不給網址src/routes/client.ts:48 起(handleDownloadUrl)
主機側再守一道拿到網址先核主機名必須是那個固定的 R2 主機名,不是就拒,不跟更新站去任何別的地方主專案 app/product_update/service/update_site_client.py:28、:142

補一點:這個網址是主機程式拿去下載用的,不是給人點的。使用者在更新中心按「下載」,是後端去跟更新站要網址再自己拉,使用者看不到網址。Worker 只做「驗身分、查版、簽名」三件小事,5 GB 流量全走 R2。

Q11
補問 2

拿到的網址長什麼樣

以 1.27.9 整包為例(主機名、bucket、物件路徑是查到的真值;金鑰 ID 與簽章用佔位):

https://6b1da966062f9093782aefa99c1e68d1.r2.cloudflarestorage.com/guidant-ai-release/v1/1.27.9/guidant-ai-1.27.9.tar.gz
  ?X-Amz-Expires=900
  &X-Amz-Date=20261101T083000Z
  &X-Amz-Algorithm=AWS4-HMAC-SHA256
  &X-Amz-Credential=<R2 存取金鑰 ID>%2F20261101%2Fauto%2Fs3%2Faws4_request
  &X-Amz-SignedHeaders=host
  &X-Amz-Signature=<64 位十六進位簽章>
段意思來源
主機名+bucket<帳號 ID>.r2.cloudflarestorage.com/guidant-ai-release;主系統核的就是這個主機名update-center wrangler.toml 第 18、19 行
物件路徑v1/<版號>/guidant-ai-<版號>.tar.gz;ops 小檔尾巴換 .ops.tar.gz更新站 D1 product_releases.r2_key(1.27.9 實查:v1/1.27.9/guidant-ai-1.27.9.tar.gz,5,262,333,964 bytes)
X-Amz-Expires=90015 分鐘。是我們程式先放進去的;套件預設 86400 秒(24 小時),所以 15 分鐘是刻意壓短src/releases.ts:169;aws4fetch 1.0.20
X-Amz-Date、Algorithm、Credential、SignedHeaders、Signature標準 AWS SigV4 的簽章參數。Credential 只含金鑰 ID 不含 secret;Signature 是對「這個方法、路徑、時間、時效」算的 HMAC,改任何一段 R2 都回 403aws4fetch signQuery 模式
沒有 X-Amz-Security-Token那是 AWS 臨時憑證才有,R2 長期金鑰沒有同上

更新站回給主系統的整包回應(src/routes/client.ts:76):

{ "version": "1.27.9", "sha256": "6810fdfd…", "size": 5262333964,
  "url": "https://…(上面那串)", "expires_in": 900 }

查不到的地方:真實網址沒有落在任何 log。主系統只在拒絕時 log 主機名(update_site_client.py:143),正常路徑不印網址;更新站也沒記。上面是照程式與套件拼出來的格式,不是從某台機器抓下來的實例。要看真的只能在下載當下抓後端對 R2 的連線或臨時加 debug log,超出問答 session 界線。

Q12

跨主版號(1.x → 2.x)怎麼處理

設計定案 D7:換主版號要重新授權。更新站只回「與客戶授權主版號相同」的版本,1.x 授權的客戶永遠看不到 2.0.0。客戶實際流程:

  1. 原廠在授權中心為該客戶簽發新授權(主版號 2)。
  2. 客戶在產品內匯入新授權。
  3. 下一次「檢查更新」帶新授權問更新站,才看到 2.0.0。
  4. 主機端再驗一次:包的主版號要等於現行主版號,否則拒裝。這一條在 2.0 要放寬成「等於授權主版號」。

資料庫 schema 從 1.x 到 2.x 的 migration 本身與小版相同,照 manifest 順序跑在複本上;差別只在授權門與主版號判斷。

Q12
補問

1.x → 2.x 是不是還沒討論過?如果 2.0.0 只是 UI 大改、底層沒變,應該要允許更新,差別只在授權放不放行?

討論過、定案了、也寫進程式了:設計決策 D7「主版號=授權邊界,換主版號要重新授權」(design.md D7、discussion.md 第 184 行起)。所以「2.0.0 只是大號、想讓客戶免費升」跟現行設計衝突,不是沒規則,是規則說不行。要讓它行是決策問題,不是機制問題。

現在有兩道門,查的東西不一樣:

門查什麼在哪
更新站授權檔 product_major 要等於版本主版號,不等就「看不到」那版(1.x 授權問有沒有新版回 none)update-center src/releases.ts:68(WHERE major = ?1)、src/routes/client.ts:44、:57
主機端更新包主版號要等於目前跑的版本主版號,不等拒裝,錯誤碼 major_mismatch主專案 app/product_update/service/package_verifier.py:214

授權檔那欄在授權中心簽發表單叫「產品主版號」,預設 1(license_center schema.py:183、issue_form.html:77);更早簽的授權沒這欄,更新站一律當 1。

「差別只在授權放行」對一半:那只是第一道。第二道查「現行版」不查「授權」,所以今天就算重簽主版號 2 的授權給客戶,主機仍拒裝 2.0.0,因為他現在跑 1.x。上面 Q12 第 4 點「要放寬成等於授權主版號」就是這個待辦,目前沒做;現在沒有 2.0 不會踩到,列入文末「發現的問題」。

決策者 2026-11-01 確認的理解

  • 跨主版號本身不是限制,限制是授權怎麼判。規則是「主版號要相同」,不是「授權要有效」:授權到期的客戶照樣能升 1.x 最新版(D7 刻意如此),但沒人能跨主版號,不管授權有沒有效。
  • 要開放做得到,技術上沒障礙。例如改成「有買維護就能一直升」:更新站的門從「主版號相同」改成「維護期內」,是改規則不是改架構。
  • 當初討論的做法是出 2.0 再由授權中心簽一張主版號 2 的新授權給客戶(D7 立論是買斷軟體「含同代升級、換代另購」)。維護期模式與它不衝突,只是把「重簽」換成「看授權檔的維護到期日」。兩種都接得住,等商業模式定了再選,本輪只探討做法。
  • 不管選哪種,主機端那道都要改:它現在比「現行版號」不看授權,授權怎麼放行主機都拒,出 2.0 前必須改成「看授權」(見文末發現的問題)。

migration 不受影響:1.x → 2.x 的 schema 升級與小版一樣照 manifest 順序跑在複本上。

升級過程

圖

一次升級的接力:誰在跑、跑哪支、畫面誰接、狀態怎麼傳

整個設計只有一條原則:主系統(後端容器)不碰 docker,只寫一張單;真正動容器的是主機上以 root 跑的 systemd 服務。後端在升級中會被停掉,所以它不能是執行者,也不能是狀態來源。狀態來源是主機寫的一個進度檔,三個讀者(後端、前門、維護頁)都只讀它。

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','lineColor':'#4A5A5C','textColor':'#14201F','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#F6EBD5','labelBoxBorderColor':'#9C6B12','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF','fontSize':'13px'}}}%%
sequenceDiagram
    autonumber
    participant U as 使用者瀏覽器
    participant N as 前門 guidant-fe(nginx)
    participant B as 後端 guidant-api
    participant H as 主機 systemd/guidantai(root)
    participant P as 進度檔 upgrade-progress.json
    rect rgb(246,235,213)
    Note over U,P: 按下更新(服務照常)
    end
    U->>N: 更新中心按「更新到 X」
    N->>B: POST /system/product-update/apply
    B->>B: 重驗包、寫 DB 更新單
    B->>H: 寫檔 upgrade/orders/.json(後端到此為止,不碰 docker)
    B-->>U: 回「已送出」,畫面蓋遮罩、顯示八步,每 3 秒輪詢 status
    rect rgb(246,235,213)
    Note over U,P: 主機接手(服務照常,第 0~2 步)
    end
    H->>H: guidant-upgrade-order.path 看到檔 → guidantai upgrade --from-order
    H->>H: 第 0 步 self_replace.sh:換新版 guidantai、lib、維護頁
    H->>P: 寫第 0 步起訖
    H->>H: 第 1 步 upgrade_preflight.sh 七項檢查
    H->>B: 第 2 步放 draining 旗標
    B->>B: worker 不撿單、心跳不派任務,做完手上的
    H->>P: 寫第 1、2 步
    U->>B: 輪詢 status(經 nginx)
    B->>P: 讀
    B-->>U: 八步走到第 2 步
    rect rgb(236,236,236)
    Note over U,P: 停機中(第 3~5 步):api/worker/socketio/db 都停,前門不停
    end
    H->>B: 第 3 步 compose stop 四個容器
    H->>H: upgrade_db_copy.sh:pgdata → pgdata-next
    U->>N: 輪詢 status
    N--xU: 後端不通回 502;畫面在升級中 → 整頁 reload
    U->>N: GET /
    N->>N: 送首頁前探後端 /api/1.0/version 不通
    N-->>U: 改送 maintenance.html(第 0 步放上去的新版)
    loop 每 3 秒
        U->>N: GET /upgrade-progress.json
        N->>P: 直接讀唯讀掛載的檔(不經後端)
        N-->>U: 維護頁畫八步與進度
    end
    H->>H: 第 4 步 upgrade_apply_on_copy.sh:載映像核雜湊、db 指複本、init 跑 migration
    H->>P: 寫第 3、4 步與 migrate 進度
    H->>H: 第 5 步 upgrade_health.sh:打 127.0.0.1:8000 version/login/三支 API
    H->>P: 寫第 5 步
    rect rgb(246,235,213)
    Note over U,P: 換接與回來(第 6~7 步,第 6 步是不可退回點)
    end
    H->>H: 第 6 步 upgrade_switch.sh:改 .env → 搬目錄 → up db/api → 最後 fe
    H->>B: 新版後端起來
    H->>N: fe 重建成新版(這幾秒看不到維護頁)
    H->>P: 寫 status=succeeded、last-switch.json
    H->>H: 第 7 步 upgrade_cleanup.sh
    U->>N: 維護頁讀到 succeeded,先探後端
    N->>B: /api/1.0/version
    B-->>U: 通了 → 跳回 ?upgraded=1
    U->>B: 更新中心丟快取重讀 status
    B->>P: 每分鐘排程讀進度檔
    B->>B: 同步 DB 單、補寫 system_logs 6239~6248、打更新站 /v1/report、推 Agent
    rect rgb(253,236,236)
    Note over U,P: 任一步失敗/逾時
    end
    H->>H: 第 8 步 upgrade_rollback.sh:.env 寫回舊值、起舊版
    H->>P: 寫 rolled_back(退回也失敗寫 rollback_failed)
    N-->>U: 維護頁顯示「已退回」或「需主機管理員」,不跳回

編號是時間順序。停機那段後端與資料庫都不在,畫面由前門 nginx 的維護頁接手;進度檔只有主機寫,更新中心經後端讀、維護頁經 nginx 直接讀,所以後端停了進度照樣會動。下面的表把每一步展開成「跑哪支、畫面誰給、怎麼知道狀態」。

①~⑫ 是時間順序。⑥ 到 ⑩ 之間後端與資料庫是停的,畫面由前門 nginx 的維護頁接手。狀態的單一來源是主機 runner 每步改寫的 upgrade-progress.json:更新中心經後端 status 讀它、維護頁經 nginx 直接讀它、換接後新版後端的排程也讀它。下面的表把每一格展開成「跑哪支、畫面誰給、怎麼知道狀態」。

後端容器 guidant-api 前門容器 guidant-fe(nginx) 主機 systemd / root shell 停機中 下表:亮格=這一刻在做事;由上往下讀
瀏覽器+前門 guidant-fe
後端容器
主機 root(systemd)
① 按下
「更新到 X」
更新中心頁打 POST …/apply,收到回應就蓋遮罩、顯示八步,每 3 秒輪詢 GET …/status
重驗包 → 寫 DB 更新單 → 寫檔 upgrade/orders/<id>.json
到此為止,不碰 docker
待命
② 單檔落地
照常,畫面顯示「已送出」
照常回 status
guidant-upgrade-order.path 看到 orders/ 有檔 → 啟動 .service → guidantai upgrade --from-order → lib/upgrade_runner.sh
③ 第 0 步
換新版維運指令
照常
照常
self_replace.sh:從 100KB ops 小檔抽新版 guidantai、lib/、新版維護頁放到前門掛載目錄,exec 新版自己續跑
④ 第 1 步 預檢
第 2 步 停接新工作
照常,八步清單走到第 1、2 步
看到 upgrade/draining 旗標:worker 不撿單、心跳不派任務,做完手上的
upgrade_preflight.sh 七項檢查 → upgrade_drain.sh 放旗標、等進行中歸零
⑤ 第 3 步
停服務、複製 DB
畫面換手。輪詢拿到 502 且畫面在升級中 → 整頁 reload → nginx 探後端不通 → 送維護頁(第 ③ 步放上去的新版)。維護頁每 3 秒 GET /upgrade-progress.json,nginx 直接回唯讀掛載的檔,不經後端
停機
api/worker/socketio/db 都停
compose stop 四個容器(fe/redis/seaweedfs 不停)→ upgrade_db_copy.sh rsync pgdata → pgdata-next
⑥ 第 4 步
在複本上升級
維護頁顯示第 4 步與 migrate 進度
停機
upgrade_apply_on_copy.sh:解包 → docker load 並核雜湊 → db 指到複本起來 → init 容器跑 migration → 補設定
⑦ 第 5 步
健康檢查
維護頁顯示第 5 步
停機(另一組新版 api 用複本起著給主機打,前門沒指過去)
upgrade_health.sh:從主機打 127.0.0.1:8000 的 version/login/三支選單與狀態 API,任一不過就退回
⑧ 第 6 步 換接
第 7 步 清理
fe 最後才重建(幾秒看不到維護頁)→ 維護頁看到 status=succeeded 且探後端通了 → 跳回 ?upgraded=1 → 更新中心丟掉快取重讀
新版後端起來
upgrade_switch.sh:改 .env 版號與資料庫目錄 → 搬目錄 → 依序 up db/redis/seaweedfs → api/worker/socketio → fe → 寫 last-switch.json;upgrade_cleanup.sh
⑨ 一分鐘內
顯示現行版與「上次更新完成時間」
排程讀進度檔:同步 DB 單終態、補寫 system_logs 6239~6248、打更新站 /v1/report、若有勾則開始推 Agent
結束
✕ 任一步失敗/逾時
維護頁顯示「已退回」或「退回失敗需主機管理員」,不跳回
舊版服務被起回
第 8 步 upgrade_rollback.sh:停新版、.env 寫回舊值、起舊版;第 0~2 步失敗系統沒動只記 skipped
狀態的單一來源:/srv/guidant-ai/upgrade/upgrade-progress.json,主機 runner 每步起訖改寫;後端 status API 讀它、前門 nginx 直接 serve 它給維護頁。三個畫面讀同一個檔,所以後端停了進度照樣會動。

逐段說明(對應上圖,由左到右)

時點誰在跑做什麼、跑哪支畫面由誰服務、怎麼知道狀態
按下「更新到 X」後端瀏覽器打 POST /api/1.0/system/product-update/apply。後端重驗包(簽章、雜湊)、在 product_update_jobs 寫一張單、再把同一張單寫成主機檔 upgrade/orders/<job_id>.json。後端到此為止,不碰 docker。更新中心頁照常由前門 nginx 送、API 由後端回。畫面每幾秒打 GET …/status,後端讀進度檔與 DB 單回「送出中」。
單檔落地那一秒主機systemd guidant-upgrade-order.path 監看 orders/ 目錄,有檔就啟動 guidant-upgrade-order.service,執行 /usr/local/bin/guidantai upgrade --from-order(root)。它讀單、重驗包、進 lib/upgrade_runner.sh 跑八步。同上。
第 0 步 換成新版維運指令主機self_replace.sh:從 100KB 的 ops.tar.gz 抽出新版 guidantai、lib/、新版維護頁三支,放到 /usr/local/ 與 upgrade/maintenance/,然後 exec 新版自己接著跑。還是舊版服務。但維護頁已經是新版包裡那份,所以升級中看到的維護頁永遠跟目標版走。
第 1 步 預檢主機upgrade_preflight.sh:版號路徑、簽章與整包雜湊、docker、資料庫連得上、核心有沒有待重開、磁碟、停機預估。任一項不過就停,系統沒動。runner 每步起訖都寫 upgrade/upgrade-progress.json;後端 status 讀它,更新中心頁的八步清單從這裡來。
第 2 步 停接新工作主機+後端upgrade_drain.sh 在 upgrade/ 放一個 draining 旗標檔,然後等。後端的 worker 與心跳看到旗標(core/maintenance_gate.py)就不再撿單、不再派任務;進行中的做完,主機數到零就過。服務還在。更新中心頁顯示「停止接新工作,等進行中的做完」。
第 3 步 停服務、複製資料庫主機upgrade_switch.sh 的 sw_stop:docker compose stop guidant-api guidant-worker guidant-socketio guidant-db。不停 guidant-fe、redis、seaweedfs。接著 upgrade_db_copy.sh 把 pgdata rsync 成 pgdata-next。從這一刻畫面換手。更新中心頁的輪詢拿到閘道錯誤(502/504),且畫面正處於「已送出/升級中」狀態,才整頁 reload;reload 時前門 nginx 送 index.html 前先探一次後端 /api/1.0/version(auth_request),探不到就改送 maintenance.html 回 503。平常單支 API 回 502 不會跳頁:nginx 同樣會把它改成 503 回給 XHR,但前端只當錯誤顯示,沒有換頁邏輯;後端活著時的 500 根本不在 error_page 清單裡。只有「後端整個不通」加上使用者開首頁或 F5,才會看到維護頁,且沒有進行中的升級時維護頁只顯示「系統維護中」通用文字,不畫八步。維護頁的 JS 每 3 秒 GET /upgrade-progress.json,nginx 直接從唯讀掛載 /guidant/upgrade/upgrade-progress.json 回檔案,不經後端。所以後端停了,進度照樣會動。
第 4 步 在複本上升級主機upgrade_apply_on_copy.sh:解包、docker load 新映像並逐顆核對雜湊、用環境變數把 guidant-db 指到 pgdata-next 起來、先確認容器掛的真的是複本、再起一個 guidant-ai-init 容器以 migrate 模式跑 migration(輸出到 log/upgrade-migrate-<單號>.log)、補設定檔新增的鍵。現用那份資料庫從頭到尾沒動。維護頁顯示第 4 步進行中與 migrate 進度(runner 把 migrate 輸出轉寫進進度檔)。
第 5 步 健康檢查主機upgrade_health.sh:用複本起新版 api,從主機打 http://127.0.0.1:8000:GET /api/1.0/version 要等於目標版、POST /api/1.0/login 拿 token、帶 token 打 /users/menu、/module-frames/menu、/system/product-update/status 各要 200。任一不過就不換接。維護頁第 5 步。這時有一個新版 api 在跑,但前門沒指過去,使用者碰不到。
第 6 步 換接主機upgrade_switch.sh:停檢查用的那組 → 改 .env 的 GUIDANT_VERSION 與 GUIDANT_PGDATA_DIR → 搬目錄(pgdata 改名 pgdata-prev-<舊版>、pgdata-next 改名 pgdata)→ compose up -d 依序起 db/redis/seaweedfs、api/worker/socketio、最後才 guidant-fe → 寫 last-switch.json。fe 重建那幾秒是唯一看不到維護頁的空檔。fe 起來後 nginx 探後端通了,不再送維護頁。維護頁 JS 看到進度檔 status=succeeded 就跳回 /…/update-center?upgraded=1,更新中心頁帶這個參數會丟掉快取重載狀態與側欄選單。
第 7 步 清理主機upgrade_cleanup.sh:刪舊版自家映像、只留一代資料庫快照、刪更早的解開目錄。失敗只記警告。已是新版畫面。
換接後一分鐘內新版後端排程 core/scheduler_update.py 每分鐘跑:讀進度檔同步 DB 單終態(sync_from_progress)、讀 last-switch.json 清掉換版前那次檢查並重查更新站(after_switch)、把進度檔每一步補寫 system_logs 事件碼 6239~6248 只寫一次(log_steps_once)、打更新站 POST /v1/report 回報結果(report_final_once)、若有勾 Agent 跟進則開始推 Agent。更新中心顯示現行版與「上次更新完成時間」。
任一步失敗或逾時主機runner 進第 8 步 upgrade_rollback.sh:停新版、.env 寫回舊版號與舊資料庫目錄、起舊版服務。第 0~2 步失敗系統還沒動,只記 skipped。退回也失敗就寫 result=rollback_failed 等 sudo guidantai recover。維護頁看進度檔 status 與 result 顯示「已退回」或「退回失敗需要主機管理員」。斷電重開機時 guidant-upgrade-boot-check.service 讀進度檔判斷要不要補退回。

工程師主管最常問的四句

  • 後端停了,是誰在接?沒有人接後端。前門 guidant-fe(nginx)從來沒停,它探到後端不通就把所有頁面請求改送維護頁並回 503。維護頁是純靜態 HTML 加一支 JS,只讀一個檔案。
  • 為什麼只留前端容器?因為它不碰資料庫、不需要升級流程中任何會變動的東西,而且使用者必須有地方看進度。redis 與 seaweedfs 也不停,但它們沒有對外畫面。
  • 維護頁怎麼知道狀態更新了?主機 runner 每一步起訖都改寫 /srv/guidant-ai/upgrade/upgrade-progress.json;這個目錄唯讀掛進 fe 容器 /guidant/upgrade,nginx 把 /upgrade-progress.json 直接 alias 到那個檔。維護頁 JS 每 3 秒抓一次。整條路沒有後端、沒有資料庫。
  • 更新中心頁又怎麼知道?升級前後它打後端 GET …/status,後端讀同一個進度檔加 DB 單回給它。所以兩個畫面的真相來源是同一個檔,只是一個經後端、一個直接讀。
Q13

複製資料庫怎麼做、資料庫名稱會改嗎、設定檔會動嗎

資料庫名稱不變(一直是 guidant_ai),動的是「資料目錄」。流程:

  1. 第 3 步停 api、worker、socketio,再停 db。把現用資料目錄整個複製(rsync)到 /srv/guidant-ai/pgdata-next.partial,完成核對後改名 pgdata-next。「pgdata-next 存在」就代表「複本完整」。
  2. 第 4 步用環境變數把 db 容器指到 pgdata-next 起來,先核對容器掛的真的是複本,再用 init 映像跑 migration。現用那份從頭到尾沒動。
  3. 第 6 步換接:先改 .env 的 GUIDANT_PGDATA_DIR 指向新目錄,再搬目錄(順序刻意:搬完沒寫就斷電,開機會接回舊的,複本等於沒升)。舊的改名 pgdata-prev-<舊版>,pgdata-next 改名 pgdata。

設定檔動了什麼:.env 的 GUIDANT_VERSION 與 GUIDANT_PGDATA_DIR;docker-compose.yml 換成新包那份(複製到資料目錄,與解開的包脫鉤);guidant.env 只補新增的鍵,既有值不動。升級前的設定與憑證另留一份在 .upgrade-config-prev/。

Q14

複本轉正後,舊的快照存在哪裡

兩種情況:

  • 第一次從舊版升級(舊版資料庫在 docker named volume):volume 改不了名,原封留著當快照,進度檔記 prev_kind=volume。188、189、190 都是這種。
  • 之後的升級:現用目錄改名 /srv/guidant-ai/pgdata-prev-<舊版>,進度檔記 prev_kind=dir。三台現在都有 pgdata-prev-1.27.7。

只留一代:下一次升級成功的第 7 步會刪更早的快照(含第一次留下的 volume)。這是「退回用」不是「備份」,見 Q20。

Q15

停服務停什麼、為什麼資料庫還能健康檢查與 migration、資料庫引擎要升版怎麼辦

停的是:api、worker、socketio(第 3 步開頭),接著 db(複製前)。不停的是:前門 fe(切維護頁)、redis、seaweedfs、錯誤追蹤站。

資料庫為什麼還能用:第 4 步把 db 容器用「指向複本」的方式重新起來,migration 用另一個 init 容器連這顆 db 跑。第 5 步再把新版 api 也用複本起來做健康檢查。所以升級中跑著的 db 連的是複本,不是現用資料。

缺口:PostgreSQL 引擎換大版(16 → 17)目前沒有設計。現在的流程只處理 schema migration,資料目錄格式是跟著引擎版本的,換引擎需要 pg_upgrade 或 dump 再 restore 一步,要另外規劃。短期內 postgres:16 不會動。
Q16

清理映像清哪些

第 7 步 upgrade_cleanup.sh,三類:

  • 映像:自家三顆(be、fe、init)凡是 tag 不等於「這次目標版」也不等於「升級前那版」的全部刪。第三方映像(postgres、redis、seaweedfs、bugsink、分類器)不動。
  • 資料庫快照:pgdata-prev-* 只留這次留下的那一份;第一次升級留下的 volume 若已不是現用就刪。
  • 更新包:incoming/ 留本次的 tar.gz、清單、ops 小檔;bundles/ 更早版本整個刪,本次解開的只留 agent/(Agent 推送要用),其餘大檔刪。

清理失敗不影響升級結果,只記警告,下次成功時再清。

Q17

升級 log 放哪、每步內容有沒有存、停機期間的紀錄怎麼回系統

三個地方:

  • 主機逐行輸出:線上更新由 systemd 跑,每一行(停哪個容器、載哪顆映像、健康檢查打了什麼)在 journal:journalctl -u guidant-upgrade-order。migration 另落 /srv/guidant-ai/log/upgrade-migrate-<單號>.log。用 install.sh --upgrade 走的舊路徑才會寫 log/upgrade-<時間>.log。
  • 機器讀的進度:/srv/guidant-ai/upgrade/upgrade-progress.json,每步的狀態、起訖時間、時限、摘要。
  • 系統內的稽核紀錄:換接完成後,新版後端的每分鐘排程讀進度檔,把每一步各寫一筆 system_logs(事件碼 6239~6247,含花了幾秒與起訖時間)加一筆整次結果(6248,從哪版到哪版、誰觸發、成功或退回),只寫一次。停機期間後端沒跑所以寫不進去,這是「事後補登」。

診斷包收:db/system_logs.csv(近 24 小時全部等級,含上面十筆)、log/upgrade-*.log 與 install-*.log 最近三支、last-switch.json。缺口:線上更新的 journal 逐行輸出目前沒落檔、診斷包也沒收,原廠要看停容器到健康檢查那段細節仍要登主機。待開卡:runner 起跑時同步落一支 log/upgrade-<單號>.log。

Q18

進度檔放哪、完成後刪還是留

/srv/guidant-ai/upgrade/upgrade-progress.json,由主機升級程式寫,容器唯讀掛載。留著不刪,下次升級開跑時覆寫。留著的理由:更新中心頁與維護頁都讀它顯示最近一次結果;開機檢查靠它判斷上次有沒有收尾;退回也失敗時 guidantai recover 靠它知道該退到哪。

Q19

健康檢查實打哪些

第 5 步用複本起新版後端後,從主機打 http://127.0.0.1:8000:

  1. GET /api/1.0/version:回 200 且版號等於目標版。
  2. POST /api/1.0/login:用安裝時建的管理員帳號登入拿 token。
  3. 帶 token 打三支:/api/1.0/users/menu、/api/1.0/module-frames/menu、/api/1.0/system/product-update/status,各要 200。

任一不過就不換接、自動退回。選這三支是因為它們分別碰到權限表、資料表、升級子系統三塊。

備份

Q5 · Q20

/srv/guidant-ai/backup 為什麼升級後沒有新檔;備份機制現況本輪不做

檔案沒有不見,是機制換了。backup/ 裡的 .sql.gz 是舊版 install.sh --upgrade(1.14 到 1.19 時期)每次升級前 pg_dump 出來的。三台現況:

主機backup/ 最新一支說明
188 STG1.17.0 前(8 月 29 日)之後的升級都沒再寫
189 POC1.19.0 前(9 月 28 日)同上
190沒有這個目錄10 月才全新安裝,從沒跑過舊流程

FR-167 的升級流程改成「整個資料目錄複製、舊的留一代快照」,不再 dump。這個快照只為退回服務,下次成功就刪,不是備份。所以目前的事實是:

  • 升級前有保護:複本上做、失敗退回、現用資料不動。
  • 沒有定期備份、沒有異地備份、沒有手動備份指令(維運指令說明裡只給一行 pg_dump 範例)。

建議方案(等裁示後開卡):升級第 3 步複製資料庫時順便 pg_dump | gzip 到 backup/,檔名帶版號與時間;加 guidantai backup 手動指令與每日排程;保留份數可設(建議 7);物件儲存(證據檔)也要納入,不只資料庫;手冊補還原步驟。

待討論、未納入本輪

—
  • 私鑰保管與換電腦:主力章在決策者電腦、備用章離線封存是現行設計;換電腦就是把加密的私鑰檔搬過去(密碼另記)。要不要改成硬體金鑰或兩人保管,屬流程決定。
  • 本體與 Agent 分開獨立更新、Agent 自己的更新中心:第二期範圍,牽涉更新站多一類成品、Agent 獨立版本線、Agent 主控台新頁。
  • PostgreSQL 引擎大版升級:見 Q15,要另外設計。
  • 三台主機指紋相同:更新站回報表顯示 188、189、190 指紋一樣,三台是同範本複製的 VM。不影響升級,影響統計與按指紋封鎖,要查指紋算法。
  • 事件應變:見 Q7 補問 3。沒有入侵處置手冊、沒有 JWT 金鑰一鍵換掉的指令、沒有檔案完整性監控;加上 Q5/Q20 的備份一起規劃。
  • 祕密存放三層方案:見 Q7 補問 2。手冊要求客戶全碟加密;我們改成密碼掛檔案不走環境變數(一張卡);有 TPM2 的主機可選 systemd-creds。
  • 2.0 的授權模式:見 Q12 補問。現行 D7 是「換代另購、重簽新授權」;決策者傾向「有買維護就能一直升」。兩種技術上都接得住,差在更新站的門比什麼(主版號相同/維護期內),等商業模式定了再調。不管哪種,主機端主版號檢查都要改成看授權。

發現的問題

!
  • 主機端主版號檢查比的是「現行版」不是「授權主版號」(app/product_update/service/package_verifier.py:214)。後果:出 2.0.0 時就算客戶已重簽主版號 2 的授權,更新站看得到 2.0.0、也下載得到,主機端 verify_manifest 仍以 major_mismatch 拒裝,客戶永遠卡在 1.x 最後一版。現在只有 1.x 不會踩到,出 2.0 前必須改成「等於授權檔 product_major」(Q12 第 4 點已預告,尚未開卡)。修正由線上更新首腦 session 處理。
  • 第三方四顆容器沒套加固(190 實查 docker inspect):guidant-db、guidant-fe、guidant-redis、guidant-seaweedfs 都沒有 cap_drop、no-new-privileges、pids 上限,redis 主程式以 root 跑。我們自己寫的 api/worker/socketio/sandbox 都有。SR-09.5 只寫 Agent 容器,主系統容器漏列。建議 compose 對這四顆補同一套(redis 與 postgres 官方 image 都支援非 root 起、nginx 可改 unprivileged 版),順手把 SR-09.5 範圍擴到主系統。
  • 三台測試機主機層有不屬於產品的服務對整個網路開(188/189/190 實查 ss -ltn):系統套件 redis-server 6.0.16 以 redis 使用者監聽 0.0.0.0:6379,NFS 的 111/2049 也對外,三台 ufw 皆 inactive。這個 Redis 不是產品的(產品 guidant-redis 不綁主機埠、走 TLS 加 ACL),是主機殘留套件,但跟服務住同一台;Ubuntu 套件版預設 protected-mode 擋遠端、無密碼,設定若被改過就是不用密碼進主機的門。測試機由首腦查處置;裝機手冊應要求客戶主機只開 22、80、443,install.sh 第 ① 步環境檢查可加「主機層有其他服務對外」的警告。
  • 189 一顆 guidant-redis 容器的 compose 標籤仍指 /home/jedi/guidant-ai-1.27.7(實查 docker inspect 的 com.docker.compose.project.working_dir);同台 fe 指 bundles/guidant-ai-1.27.9、sandbox 指 bundles/guidant-ai-1.27.8,三顆容器三個 working_dir。原因:第 3 步刻意不停 redis,所以它的 compose 標籤留在最早起它的那次(10/31 手動 install.sh --upgrade)。功能沒壞,但清 /home/jedi/guidant-ai-1.27.7/ 8.4 GB 前要先重建那顆 redis(照 10/31 誤刪 188 目錄的教訓)。建議第 6 步換接時把不停的三顆(redis、seaweedfs、fe)也用 up -d --no-deps 重掛一次 compose 標籤,或至少在手冊寫明。
  • 老機器累的舊流程殘留沒人清:189 有 backup/ 410 MB、12 支舊式 upgrade-*.log、4 支 *.bak-*、/home/jedi/ 兩支舊 tar.gz 7.7 GB;190 有 Agent 舊映像 1.4.3~1.4.6 四顆 17 GB。新流程第 7 步只管自己產的東西。建議 upgrade_cleanup.sh 加一段「舊流程殘留」:backup/、backup-manual/、log/upgrade-<時間>.log 列出並提示可刪(不自動刪,客戶可能還想留 dump);Agent 舊映像由 Agent 升級流程自己清。
  • 190 docker-compose.yml 擁有者是 jedi 不是 root,189 同樣。應是手動操作留下的。功能不受影響,但資料目錄其他檔都是 root,升級第 6 步複製新 compose 時會不會因擁有者不同出問題要核一下。