Runbook: SIT 環境部署與 CD
SIT (System Integration Test) 跑在獨立的 SIT MacBook Pro(runner 名
linjianguodeMacBook-Pro)上。該機是帶有專用sitlabel 的持久化 self-hosted runner;Backend PR CI(test.yml)使用獨立的 Linuxcortex-cirunner,CI job 只允許同 repository 的OWNERPR,且不注入 deployment secrets。 正式/dev stack 在另一台(MacBook-Pro-2),兩者不同機。日常 SIT 發版:push 到
SITbranch →deploy-sit.yml在 SIT 機本地 build + migrate + upSIT DB Rebase:只有需要以dev DB重建SIT資料時,才手動執行
rebase-sit-db.yml。它不會由push自動觸發。 (不經 registry、不經 SSH)。SIT stack 用docker-compose.sit.yml完全隔離 (獨立 project name / 容器名 / port / volume / network)。
拓樸一覽
SIT 預設停用自動 strategy 計算(SCHEDULER_STRATEGY_ENABLED=false),避免
低記憶體主機在策略計算階段反覆 OOM。停用涵蓋每日/補跑的策略命中與匹配、
夜間籌碼鏈的策略步驟、Story Strategy 發布與重試、forward-return labels、
每週策略發掘;行情、ETF 持股、技術/籌碼快照與其他排程繼續運行。
既有策略結果保留並可查詢,但不再更新,不應用來驗收新日期的策略結果。
此開關由 scheduler 的計算入口共同檢查,也涵蓋啟動恢復;僅移除 cron 不足以
停用策略。API 容器(agent-api)也讀同一個變數:單檔即時更新遇到 OHLC 修訂
時會在 API 程序內觸發 Story Strategy 重算,同樣受此開關控制。CD 的歷史 Story
replay+策略發布配對步驟會暫緩,且不寫入完成 watermark。一般事件補齊與每日
資料工作不受此配對步驟暫緩影響。夜間籌碼鏈在策略步驟前的規則目錄(rule catalog)匯入不屬於策略計算,
停用期間照常執行。
需恢復計算時,將 GitHub Actions Variable SCHEDULER_STRATEGY_ENABLED 設為
true 並沿用既有 SIT 部署流程;手動 compose 可匯出同名變數後重建 scheduler
與 agent-api。容器的環境變數要重建容器才會改變,而且策略 job 是在 scheduler
啟動時才決定要不要註冊,所以改值後兩個容器都要重建才生效。
重新啟用前應確認記憶體容量與資料日期。其他環境未提供此變數時維持原有啟用行為。
重新啟用後的第一次部署,若 SES catch-up 的 replay watermark 尚未記錄,且 Story 已有
歷史資料,CD 會在部署 job 內一次執行選擇性(selective)Story replay(範圍為既有 Story
覆蓋的整段期間)與 full-story-set 策略刷新,耗時且吃記憶體,需先確認容量並預留時間。夜間籌碼鏈在停用期間已把各交易日記為完成,這些日子的
策略命中與匹配不會自動補算(#1964),需另外核對缺漏日期與補算範圍。
| 機器 | 角色 | 服務 |
|---|---|---|
MacBook-Pro-2(dev/prod) |
開發 + 正式 | cortex-*(API 8000、Web 3000、DB 5432)、正式 DB |
linjianguodeMacBook-Pro(SIT) |
專用 sit self-hosted runner + SIT |
cortex-sit-*(API 18000;DB 僅內網) |
| SIT 服務 | 容器 | host port |
|---|---|---|
| postgres | cortex-sit-postgres | 未發布(內部網路限定 #567;SIT_DB_PORT 僅供手動 loopback GUI) |
| api | cortex-sit-api | 127.0.0.1:${SIT_API_PORT:-18000}(僅 SIT host) |
| scheduler | cortex-sit-scheduler | — |
| realtime-quote-scheduler | cortex-sit-realtime-quote-scheduler | — |
port 預設值:CD 走 GitHub Actions Variables(
SIT_API_PORT;SIT_DB_PORT不吃,見 §1 說明——postgres 不對外發布 host port);僅手動操作時可用該機.env.sit覆寫。 請依 SIT 機實際佔用情況設定,避免與該機其他服務衝突。所有 compose 指令一律從 repo root 執行(context: ..才解析得到)。
一、SIT 機一次性前置(在 linjianguodeMacBook-Pro 上做一次)
CD 會自動 checkout + build + up,但兩件事 CD 不會幫你做,需先在 SIT 機手動完成:
1. 設定 secrets / config(GitHub Actions Secrets & Variables)
CD(deploy-sit.yml)從 GitHub Actions Secrets & Variables 注入 env,
不再讀主機上的 .env.sit——SIT 主機硬碟上不會留常駐明文秘密檔,秘密也不綁定
單一機器(換機/搬 VM 較快)。在 repo Settings → Secrets and variables → Actions
建立:
Secrets(加密,必填): JWT_SECRET_KEY、DB_PASSWORD、FINMIND_TOKEN、FRED_API_KEY
Secrets(建議): GEMINI_API_KEY、OPENAI_API_KEY、GOOGLE_CLIENT_ID、GOOGLE_CLIENT_SECRET、
APPLE_CLIENT_ID、APPLE_TEAM_ID、APPLE_KEY_ID、APPLE_PRIVATE_KEY(PEM 內容,亦接受 base64 或 .p8 路徑;申請流程見 apple-sign-in-setup.zh-TW.md)、
SMTP_USERNAME、SMTP_PASSWORD、CLOUDFLARE_EMAIL_API_TOKEN、TELEGRAM_BOT_TOKEN
Variables(非秘密): CORS_ALLOW_ORIGINS(如 https://sit-cortexpro.aoshiken.com,管理後台對外網域)、
APPLE_APP_BUNDLE_ID(原生 iOS/macOS App 的 Bundle ID;無原生 App 可留空)、
OAUTH_CALLBACK_URL、FRONTEND_URL、FRONTEND_URL_WEB(對外 Cloudflare 網域)、
SIT_API_PORT(視 SIT 機佔用調整)、SCHEDULE_SUMMARY_MAIL_ENABLED、SCHEDULE_SUMMARY_MAIL_TRANSPORT、
SCHEDULE_SUMMARY_MAIL_TO、SMTP_HOST、SMTP_PORT、SMTP_SECURITY、SMTP_FROM、
SMTP_TIMEOUT_SECONDS、CLOUDFLARE_EMAIL_ACCOUNT_ID、CLOUDFLARE_EMAIL_FROM、
CLOUDFLARE_EMAIL_TIMEOUT_SECONDS、SCHEDULE_TELEGRAM_ENABLED、TELEGRAM_CHAT_ID、
TELEGRAM_TIMEOUT_SECONDS(可留空)、SCHEDULER_STRATEGY_ENABLED(未設時 SIT 預設 false,見上方說明)、
SIT_API_WORKERS(API 的 uvicorn worker 數,未設=1)、THEME_JOB_RECOVER_STALE_ON_STARTUP
(主題任務啟動回收,未設=true;SIT_API_WORKERS 大於 1 時設 false),兩者見〈八、API 多 worker〉。
v0.24.0 首次套用 migration 134 前,操作者須停止全部 writers,使用既有工具完成一次
fresh 全庫加密備份並驗證;金鑰留在 SIT 主機的 0600 私有檔。這是該次部署前的一次性
操作,不是 workflow gate 或 PR merge 條件,也不要求新增備份 Variables;後續例行部署
沿用既有備份制度。完整 release 說明見
v0.24.0-sit-release.zh-TW.md。每日排程信的完整設定與安全啟用流程見
schedule-summary-mail.md。排程執行結果 Telegram 通知的設定與
啟用流程見 schedule-telegram-notify.md。
⚠️
SIT_DB_PORT不是 CD Variable:compose 的 postgres 不對外發布 host port(內部 網路限定,#567),故 CD 不吃此值;它只影響「手動」在本機開 loopback DB-GUI 綁定時的 port (見下方 §五 網路暴露、.env.sit)。
JWT_SECRET_KEY用openssl rand -hex 32且【與正式不同】;DB_PASSWORD用openssl rand -hex 24。 ⚠️DB_PASSWORD必須是 alphanumeric/hex(勿含@ : / ? #等特殊字元、勿 URL-encode): compose 以字串插值組DATABASE_URL,特殊字元會破壞連線字串;而 URL-encode 也救不了——postgres 收的是 rawPOSTGRES_PASSWORD、app 端會 decodeDATABASE_URL,兩者不符會靜默認證失敗。openssl rand -hex 24產生的即為安全的純 hex。深層修法(app 端動態組建)追蹤於 #824。- runner trust boundary:deployment/maintenance jobs 使用完整
[self-hosted, macOS, X64, sit]labels;Backend PR CI 使用獨立 Linuxcortex-cirunner,維持同 repository/OWNER限制,且不得注入 deployment secrets。 - 注意:secrets 仍會進到執行中容器的 env(app 需要),這不防主機被完全攻陷—— 它的價值是集中管理/輪換 + 主機不留明文檔。被攻陷的正確反應是「在 GitHub 一次全換、重跑 CD」。
2. 一次性 seed SIT DB(legacy bootstrap only)
此段只供全新SIT volume首次bootstrap。既有SIT若需重新帶入dev DB,禁止再使用
sit_db_seed.sh restore的pg_restore --clean流程;請使用下方「SIT DB Rebase」標準流程, 才會保留會員帳號、持股與觀察清單並提供candidate/rollback保護。
SIT DB volume (sit_postgres_data) 跨 CD 持久,所以 seed 只需做一次。第一次 CD 之前
先把 SIT postgres 起來並灌入資料,否則首次 CD 的 api health check 會等不到(空庫)。
# 先在 SIT 機 clone repo(這份 checkout 與 runner 的 _work 工作目錄不同,互不干擾,
# 純供首次手動 seed 用):
git clone git@github.com:rdaoshiken/Cortex.git && cd Cortex && git checkout SIT
git submodule update --init --recursive
# 手動操作(如首次 seed)時 env 由你自己 export(值就是你在 GitHub 建的 Secrets):
# export JWT_SECRET_KEY=... DB_PASSWORD=... FINMIND_TOKEN=... FRED_API_KEY=...
# export SCHEDULE_SUMMARY_MAIL_ENABLED=false # 完整 provider 設定見 mail runbook
# export CORS_ALLOW_ORIGINS=https://sit-cortexpro.aoshiken.com SIT_API_PORT=18000
# CD(deploy-sit.yml)則自動從 GitHub Secrets/Variables 注入,不需這步。
# (或把這些值放進 gitignored investment-agent/.env.sit,下面的 sitc() 會自動載入。)
# 定義一個 helper function(比 eval 安全好用):
sitc() {
# Manual convenience: auto-load a gitignored investment-agent/.env.sit if you
# keep one, else rely on the vars exported above. CD never calls this helper.
if [ -f investment-agent/.env.sit ]; then
docker compose -p cortex-sit -f investment-agent/docker-compose.sit.yml --env-file investment-agent/.env.sit "$@"
else
docker compose -p cortex-sit -f investment-agent/docker-compose.sit.yml "$@"
fi
}
# 讓 image 內的 non-root runtime user 對齊這台 SIT 主機的使用者,
# 並先建好所有 writable bind-mount 目錄。
export APP_UID="$(id -u)"
export RUNTIME_STATE_PATH="${HOME}/.cortex/sit/runtime-state"
export SIT_LOGS_PATH="${HOME}/.cortex/sit/logs"
mkdir -p \
investment-agent/data/sit/quarterly_xbrl \
"$SIT_LOGS_PATH" \
"$RUNTIME_STATE_PATH"
# 兩個 log 路徑都故意放在 checkout 外,理由有兩層:
# (1) GitHub Actions checkout clean 會刪除 workspace 內被 gitignore 的檔案,
# 但不會刪除長時間 backfill checkpoints;
# (2) /app/logs 與 /app/logs/runtime-state 是「巢狀」bind mount,Docker 會在
# 主機上把該巢狀掛載點具體化成真實目錄。外層來源只要在 workspace 內,
# 每次 stack 啟動就會在 checkout 裡留下一個目錄,而它存在期間
# actions/checkout 清不掉 workspace(rmdir 得到 EACCES)——曾連續讓
# v0.19.0~v0.21.0 四次 tag 的文件站部署失敗。這兩個 export 即使手動操作
# 也不可省略(compose 的預設值同樣指向 $HOME/.cortex,省略只是拿到同樣
# 的值)。
#
# ⚠️ 為什麼 runner 刪不掉它,目前**未確定**(#1114)。本文件早期版本寫
# 「由 daemon 以 root 建立」,主機實測推翻該說法:三層皆
# aoshiken.dev:staff、drwxr-xr-x、flags 欄 '-'、無 ACL,而 runner 就是
# 該使用者。ownership、permission、macOS flags、ACL 皆已排除。停掉容器
# 後即可刪除,但那是相關性不是機制。不要從這段文字推導成因。
# (a) 在 SIT 機:先只起 postgres(空庫)
sitc up -d postgres
# (b) 在正式機 MacBook-Pro-2:dump source 資料(排除衍生表,約數百 MB)
investment-agent/scripts/sit_db_seed.sh dump /tmp/cortex_sit_seed.dump
# (c) 傳到 SIT 機
scp /tmp/cortex_sit_seed.dump <user>@linjianguodeMacBook-Pro.local:/tmp/
# (d) 在 SIT 機:restore
investment-agent/scripts/sit_db_seed.sh restore /tmp/cortex_sit_seed.dump
⚠️ 安全:SIT 的
JWT_SECRET_KEY(GitHub Secret)必須與正式不同,否則正式 DB dump 內含的 有效 session JWT 可同時打 SIT 與正式。dump 也會帶入 auth/subscription 個資(皆你本人)。衍生表(snapshots/indicators/messages)restore 後為空,由 SIT scheduler 重算 (每日 15:30 TW)。需要立即有衍生資料時,在
cortex-sit-api內手動跑對應 backfill (見investment-agent/backend/scripts/)。
替代:path B — 乾淨 schema(不跨機搬資料)
不想搬 ~800M 時,在 SIT 機建空庫但 schema 正確:
SB=investment-agent/backend/database/schema_baseline.sql
docker exec -i cortex-sit-postgres psql -U cortex -d cortex_investment < "$SB"
# 再依 schema_baseline 的 -- MIGRATION: manifest 套用比 baseline 新的 migrations
二、SIT DB Rebase(手動、需核准)
適用時機與資料契約
- 日常程式發版不搬DB;只有明確需要讓SIT採用dev全庫資料時才執行本流程。
- dev dump保留完整schema與一般資料,但不帶入:
refresh_tokens、oauth_login_states、oauth_exchange_grants、email_otp_challenges、member_social_accounts與member_email_credentials的table data。 - SIT會回灌會員帳號、OAuth provider、Email credential、tier/admin/status/profile。
- SIT持股以完整交易ledger保留:
brokers、transactions、transaction_closures,包含day-trade pair與FIFO closure關係。 - SIT觀察清單保留
watchlist_groups與watchlist_items;若引用ticker不在dev, 只補入必要stocksrow,不覆蓋dev既有stock metadata。 - 同一會員在dev與SIT都有資料時採SIT-wins:candidate內該會員的dev持股與觀察清單 會被SIT版本取代,不做union,避免交易重複計入;dev-only會員資料不變。
- 所有refresh/session/token失效,切換後使用者必須重新登入;原SIT Email密碼hash與 OAuth identity仍保留。
- subscription history、user groups與audit/access logs不回灌,但仍存在原封不動的 rollback DB與完整disaster-recovery dump。
一次性GitHub設定
在repository Settings → Environments建立sit-db-rebase,設定required reviewer,並將
deployment branch規則限制為SIT。
只有可核准SIT資料破壞操作的人員能approve此environment。workflow固定checkout SIT、
與日常deploy共用deploy-sit concurrency,兩者不會併發。
DB rebase會重新啟動realtime quote scheduler,因此沿用日常部署的GitHub Actions設定:
REALTIME_QUOTE_SOURCE預設為data_hub,此模式須有HUB_API_URL Variable與
HUB_SERVICE_TOKEN Secret;切為finmind時不需要Hub credential。workflow會在停止
任何writer或切換DB前,以worker共用的驗證器檢查來源、HTTP(S) URL與空白credential;
設定錯誤時直接結束。
Step 1:dev主機準備dump
umask 077
mkdir -p "$HOME/.cortex/sit-exports"
chmod 700 "$HOME/.cortex/sit-exports"
dump="$HOME/.cortex/sit-exports/cortex_dev_for_sit_$(date +%Y%m%d_%H%M%S).dump"
investment-agent/scripts/prepare_sit_rebase_dump.sh "$dump"
指令會建立$dump與$dump.manifest,以0600保存,並印出SHA-256。producer會用
pg_restore --list確認六張認證/暫態表沒有TABLE DATA,且會員、交易持股與觀察清單
TABLE DATA都存在。請把畫面上的SHA-256放入變更紀錄,傳檔時不要從log重新拼湊。
Step 2:operator以SSH加密傳到SIT staging
先在SIT主機建立受限目錄:
install -d -m 700 "$HOME/.cortex/sit/imports"
再從dev主機傳送兩個檔案:
scp "$dump" "$dump.manifest" \
<sit-user>@<sit-host>:.cortex/sit/imports/
不得把dump放進Git workspace、GitHub Actions artifact、release asset或公開object storage。
workflow只接受staging目錄內的basename,不接受URL、/、..、symlink或任意路徑。
Step 3:手動dispatch並核准
gh workflow run rebase-sit-db.yml \
--ref SIT \
-f dump_basename="$(basename "$dump")" \
-f manifest_basename="$(basename "$dump.manifest")" \
-f dump_sha256='<prepare指令印出的64字元SHA-256>' \
-f confirmation='REBASE_SIT_DB_KEEP_ACCOUNTS_PORTFOLIOS_WATCHLISTS'
在rebase-sit-db environment approval畫面再次核對dump basename、SHA-256與本次變更單。
核准前先確認SIT主機磁碟能同時容納原DB、candidate DB、輸入dump與完整DR dump;腳本也會
按來源DB bytes加25%安全餘裕fail closed,空間不足不會退回直接drop舊DB。
自動執行順序
- 驗證basename、symlink、SHA-256、TOC、PostgreSQL major、磁碟、container identity。
驗證後會複製成run-private
0600輸入檔,後續TOC與restore不再讀取operator staging檔。 - 驗證dump來源commit已被目前
SITcommit納入,並比對dev dump、現行SIT DB與SITcheckout三方migration ledger及完整migration檔案內容;不一致即停止。 - 建立run-scoped candidate DB,寫入database COMMENT與host ownership marker,再restore dev dump。
- candidate migration、collation與token exclusion檢查通過後,在現行SIT仍在線時建立完整DR dump。
- 停止API、daily scheduler及realtime quote scheduler writers,匯出最終account-domain bundle及checksums。
- 在candidate單一transaction內完成identity mapping、SIT-wins持股/觀察清單replace、ID remap與FK/invariant驗證。
- 原
cortex_investmentrename成run-scoped rollback DB;candidate rename成cortex_investment。 - 啟動API與兩個scheduler,並要求API health及兩個scheduler running連續三次成功。
candidate restore/merge失敗時不切換,舊SIT直接重啟。rename後health失敗時,workflow會把
failed candidate移出live名稱、將原DB rename回cortex_investment並重新驗證health。
切換期間live與candidate會先設為ALLOW_CONNECTIONS false再終止既有連線;成功後只重新
開放新live,保留的rollback DB維持禁止連線,避免誤用。
證據、rollback與清理
每次run的restricted evidence位於$HOME/.cortex/sit/rebase-runs/<run-key>/,包含:
- 輸入dump TOC與SHA驗證結果;
- 完整SIT DR dump、TOC與SHA-256;
- account-domain bundle(含credential hash與投資資料,皆
0600); - identity/domain mapping counts與merge summary;
- candidate、rollback、failed DB名稱與ownership marker。
不得上傳上述檔案為Actions artifact,也不得把完整email、password hash、provider payload或
交易明細貼到issue/PR。rollback DB與DR dump不自動刪除。確認新SIT穩定後,先列出精確DB名稱、
pg_database_size、dump SHA與保留期限,另取得破壞性操作核准,才能執行DROP DATABASE或刪檔。
禁止用glob或「最新一個」推測清理目標。
三、CD(push 到 SIT 即自動部署)
- 觸發:merge / push 到
SITbranch →deploy-sit.yml在 SIT 機執行。 經審查的dev版本 merge 至SIT後即由正常 CI/CD 自動部署;保留 SIT 既有 hotfix, 不以 force push 覆蓋部署分支,也不另設 rehearsal SHA、人工 timeout 或 receipt gate。 - v0.24.0 首次含 134 的部署須先安排並完成上述 fresh backup,再觸發會自動部署的
dev → SIT merge/push;不得先觸發部署再補備份。此一次性操作不阻擋程式碼合入
dev, 也不成為 GitHub PR approval/merge check。人工 full-size rehearsal 仍只是可選維運證據。 - workflow 順序:deployment holds → checkout → config preflight → 保存原服務 running+immutable image IDs → build → origin binding → DB ready → pending 數量 +持久 DB ledger 狀態 → 所有app writers停機與 read-back → migration marker → migrations → schema gates → 新 stack recreate+穩定 health → 記錄成功啟用點 → SES catch-up。本版保留 40 檔 pending 上限。
- runner 目標固定
[self-hosted, macOS, X64, sit],以 restricted runner group 限制 host-bound workflows。缺 label 時保持 queued,不退回通用 self-hosted labels。 - migration 使用
LOCK_TIMEOUT=5s;只有 migration 134 的 statement timeout 固定使用0(不設 statement timeout),同批及其他部署的其餘 migration 檔各使用600s。 workflow 不設定 job timeout override,沿用 Actions 既有 6 小時預設上限,也不依賴 額外 Actions Variables。 失敗檔案雖未記入 ledger,前面 DDL 仍可能已 commit;重跑前須先稽核實際 schema/partial state。 - quiesce 前核對
public.schema_migrations與 host 上的最後成功啟用點$HOME/.cortex/sit/activated-migration,以COLLATE "C"比較完整檔名;超過啟用點 視為已開始 migration,包含未成功啟用的 partial/零 pending 跨 run 重試。 檔案不存在僅首次回退 baseline127;壞檔/不可讀/擁有者或0600權限不符/不在 checkout 或 DB ledger 的啟用點會在 quiesce 前拒絕部署。原服務只有在 capture 時 API/daily scheduler都running且image IDs完整有效才可fallback;若既有quote worker存在, 它也必須running且image ID有效。首次部署時只有Docker inventory成功證明quote容器不存在, 才可把舊兩服務stack視為完整rollback來源;inspect或inventory失敗不能當成不存在。 - 正式 quiesce 尚未開始的失敗/取消不額外停止原 stack;既有 legacy runtime-state 搬移分支若已停止服務,仍須人工讀回/復原。marker 為 false、quiesce 已開始且 完整 fallback 可用時,可以恢復先前 image;成功啟用後的純程式碼部署若為零 pending 且 ledger 未超過啟用點,marker 保持 false,quiesce 或新 stack recreate/health 失敗可自動回退,deploy 仍失敗/取消。 fallback unavailable 或恢復失敗時會再停所有app writers。回復經證明沒有舊quote worker的 legacy stack時,只重啟API與舊daily scheduler;恢復健康後會移除本次失敗前滾所建立的 stopped quote container,讓下次部署仍能證明舊stack的quote worker真正不存在。清理或讀回失敗時 rollback視為失敗並再停全部writers;不會移除已證明屬於舊stack的quote worker。 quiesce 以 Docker inventory 判定 writer:API、daily scheduler、quote worker 經 inventory 成功證明不存在時視為已停止;inventory 或 inspect 失敗仍不能當成已停止。主機在 recreate 中途斷電可能舊容器已刪、新容器未建,重跑可通過 quiesce;原服務不在,該次沒有可回退的舊 stack。
- marker 為 true、新 stack 尚未通過穩定 health 驗證前,任何 migration、 schema、recreate 或 health 失敗都保持所有app writers停止。禁止只 retag 舊 image, 禁止自動 DROP、rebase 或還原 live DB;依 release runbook 以同次 cutover 的 DB dump+舊 image/config 配對回復。 可處理的 TERM/INT 會先停止 writers,再讀 logs;runner 強制終止或主機故障仍須讀回 實際狀態,不能把 cleanup step 的存在當成服務已停止的證明。
- 新 stack 通過穩定 health 後才標記
SIT_CUTOVER_ACTIVATED=true。下一個獨立步驟讀取 ledger 的實際MAX(filename COLLATE "C"),以同目錄 tmp+原子替換、0600權限 更新啟用點。寫入失敗保留健康新 stack 並令 deploy 失敗;不可提前寫入/刪除啟用點 來繞過回退閘門。人工 DB recovery 也須核對啟用點與配對 images。 後續 SES catch-up 失敗或取消仍保留健康的新API與兩個scheduler,deploy保持失敗或取消,catch-up未完成的 watermark 不寫入;SIT_MIGRATION_STARTED保持原值,啟用後的 cleanup 不切回舊 image。 - OAuth schema gate 仍核對 public 的兩張表:完整欄位型別/長度/nullability、
PK/unique/CHECK/FK、
ON DELETE CASCADE、expiry/member indexes 與正負 probe; 同名但殘缺的漂移表不能通過。 - migration 106 新增
oauth_login_states、oauth_exchange_grants,讓 OAuth state 與 exchange grant 可跨 Uvicorn workers 原子消耗。部署或 rollback 會使尚未完成的短期登入 流程失效,使用者只需重新登入;既有 access/refresh token 不受影響。 - rollback 到舊 image 時不要 drop migration 106 的表:舊程式會忽略它們,保留空表是 安全且可再次前滾的做法。
- Backend PR CI 在獨立 Linux runner 執行,不共用 SIT 的 Docker runtime。
查看部署結果:gh run list --workflow deploy-sit.yml。
四、Scheduler
SIT 啟用兩個獨立排程程序:
cortex-sit-scheduler執行每日、補齊與story工作;cortex-sit-realtime-quote-scheduler只在設定的台股盤中窗口輪詢即時報價。
兩者可獨立啟停;DB migration/rebase仍必須同時停止API與兩個scheduler。
⚠️ Rate limit:SIT 與正式 scheduler 雖在不同機,但共用同一組 FinMind/FRED 額度 → 每日與盤中呼叫都會增加。若盤中報價觸發供應商限流,只停quote worker:
sitc stop realtime-quote-scheduler;daily pipeline則使用sitc stop scheduler。
五、常用運維(在 SIT 機)
# 同前面定義的 helper(放進 ~/.zshrc 更方便):
sitc() {
# Manual convenience: auto-load a gitignored investment-agent/.env.sit if you
# keep one, else rely on the vars exported above. CD never calls this helper.
if [ -f investment-agent/.env.sit ]; then
docker compose -p cortex-sit -f investment-agent/docker-compose.sit.yml --env-file investment-agent/.env.sit "$@"
else
docker compose -p cortex-sit -f investment-agent/docker-compose.sit.yml "$@"
fi
}
sitc logs -f agent-api # SIT API log
sitc logs -f realtime-quote-scheduler # 盤中報價worker log
sitc restart agent-api # 重啟單一服務
sitc down # 停 SIT(保留 DB volume)
sitc down -v # 停 SIT 並刪 DB(慎用;清掉 sit_postgres_data)
docker exec -it cortex-sit-postgres psql -U cortex cortex_investment
六、已知限制
- Chroma 向量庫(
knowledge.chroma.persist_directory: data/chroma)在 SIT 為 container-local、每次 up 重建(baked image、無 mount)。日後若要持久化,請用 獨立 host 路徑,不要與任何其他 stack 共用。 data/my-tw-coveragesubmodule 以:ro掛載(只讀輸入),SIT 不會回寫。- SIT logs 落在
$HOME/.cortex/sit/logs/(SIT_LOGS_PATH)、runtime state 落在$HOME/.cortex/sit/runtime-state/(RUNTIME_STATE_PATH),兩者都刻意在 git checkout 之外(見上方 §三的說明);scheduler data 落在investment-agent/data/sit/,與其他環境分開。舊版把 logs 放在
investment-agent/logs/sit/。既有主機上那個目錄會留下 歷史 log,可自行保留或封存;新的寫入一律進$HOME/.cortex/sit/logs/。 SIT為長壽部署分支,由 CD 監看;正式環境日後用 tag-driven CD(不與此共用)。
疑難排解:屬性正常卻 Permission denied 的目錄
症狀:某個目錄 ls -l 看起來完全正常(你自己擁有、drwxr-xr-x、無 ACL、無
flags),但 mv / rmdir / rm -rf 一律回 Permission denied。CI 上的典型長相是
actions/checkout 失敗:
EACCES: permission denied, rmdir '.../investment-agent/logs/sit/runtime-state'
原因:某個運行中的容器把該路徑當作 bind mount 的目標。Docker Desktop 在
macOS 透過檔案分享層(virtiofs / gRPC-FUSE)把 host 目錄投射進 Linux VM,掛載期間
會釘住對應的 host 目錄,並對 rename(2) / rmdir(2) 回 EACCES 而非 EBUSY。
最誤導人的有兩點,這是它連續擋掉 v0.19.0~v0.21.0 四次 tag 部署、且排查方向 錯誤數小時的原因:
- 回的是
EACCES不是EBUSY。EBUSY會直接指向「有東西持有它」;EACCES把人導向權限方向——而權限完全正常,於是怎麼查都查不出所以然。 mount表裡查不到它。它不是 host 層級的掛載,mount、lsof都看不到有 東西持有它。「mount | grep沒命中」不代表沒有東西持有它。
判斷方法:查它是不是某個運行中容器的 bind mount 目標。
docker ps -q | xargs -I{} docker inspect -f '{{.Name}} {{range .Mounts}}{{.Source}} {{end}}' {} | grep <路徑片段>
解法:停掉持有它的容器再操作。
docker stop cortex-sit-api cortex-sit-scheduler cortex-sit-realtime-quote-scheduler
mv <路徑> <封存位置>
deploy-sit.yml 的 Preserve legacy runtime state before checkout 已內建這個
fallback(mv 失敗時自動停容器重試,容器由同一個 job 稍後的 Recreate SIT stack
帶回),日常不需人工介入。
根本修正已完成:兩個 log mount 的來源都已移出 git checkout(見 §一的 export 說明),Docker 不會再在 workspace 內建立掛載點,此情況不應再發生。上述保留作為 辨識特徵——同樣的陷阱會出現在任何 Docker Desktop on macOS 的機器上,不限於這 一個路徑。
實證:2026-07-30 於 SIT 主機(macOS 15.7.7 / Docker 29.5.2)以受控實驗重現—— 同形狀的巢狀 mount 產生的目錄,容器運行中
mv與rmdir皆Permission denied、 停掉容器後mv成功;xattr -l只有com.apple.provenance,mount | grep命中 數 0。完整過程見 issue #1114。
七、只同步 SIT 題材成員
題材 Markdown 更新已部署到 SIT 後,使用 Sync SIT Theme Memberships
workflow;不要為了題材資料使用整庫 Rebase SIT Database。此 workflow 固定在
具備專用 sit label 的 self-hosted SIT runner 上執行,並與部署/整庫 rebase 共用 deploy-sit
concurrency lock;sit-theme-sync Environment 另限制只有 dev branch 可執行。現行
private-repo GitHub 方案不支援 required reviewers,因此執行授權必須同時留在變更單/issue,
並由操作者輸入與日期完全一致的 confirmation phrase 與已核准筆數。
執行前先在相同 SIT commit 對來源做 dry-run,確認預期的題材、成員與關聯筆數。 以 2026-07-26 snapshot 為例:
gh workflow run sync-sit-theme-memberships.yml \
--ref dev \
-f knowledge_as_of=2026-07-26 \
-f expected_memberships=629 \
-f expected_relations=69 \
-f expected_themes=41 \
-f confirmation=SYNC_SIT_THEME_MEMBERSHIPS_2026-07-26
workflow 在寫入前會確認:
- 先解析最新
origin/SITSHA,再於每次 run 專屬目錄 checkout 該精確 commit 與 recursive coverage submodule;不把 volatile 的 live bind mount 當作資料來源; - 隔離 checkout 與預期 SIT SHA 相同、coverage submodule 與 SIT gitlink 一致,且
themes/沒有額外未追蹤/ignored Markdown; - 從 coverage submodule 的精確 commit 建立 archive,送入 API 容器內由非 root runtime user 建立的 run-scoped 暫存目錄,並以逐檔 SHA-256 manifest 驗證內容相同;
cortex-sit-api/cortex-sit-postgres健康,既有 live 題材 mount 存在且唯讀;- 容器內同步程式 SHA-256 與 SIT checkout 相同;
- dry-run 筆數與人工核准的三個 expected inputs 相同;
- 完整兩張題材表 dump、同日 CSV rollback 檔與 SHA-256 均已產生。
寫入只會替換指定 knowledge_as_of 的 coverage_theme_membership 與
coverage_theme_relation,並使用應用程式的單一 transaction。寫入或後驗證失敗時,
runner 會以同日 CSV 自動還原並逐檔比對;成功時則驗證來源/DB 完整列集合、歷史 snapshot、
非題材 coverage 表與 SIT API,最後移除容器暫存來源。為確保 API 確實讀到本次資料,
target date 不得早於 DB 目前最新題材日期。每次 run 的備份、來源 manifest、
sync-success.marker 與驗證證據會保存為 30 天 artifact。
八、API 多 worker(#2105)
SIT 的 agent-api 預設跑 1 個 uvicorn worker。worker 數由 GitHub Actions Variable
SIT_API_WORKERS 決定:compose 把它轉成 WEB_CONCURRENCY,uvicorn 在啟動指令沒有
--workers 時讀它,程式也讀同一個變數決定下面兩件事:
- 連線池均分:
config/core.yaml的database.api(pool_size10、max_overflow20)是整個 API 的總額,每個 worker 拿 floor(總額 ÷ worker 數)。2 個 worker 時每個 5+10,API 合計仍是 30 條, 與單 worker 相同。 - 限流共用 Redis:worker 數大於 1 且
REDIS_URL有值時,default/strict/theme_analysis 三層 限流的計數改放 Redis,每個 IP/會員在所有 worker 間共用一份額度。單 worker 維持進程內計數。 每個請求因此多一次同步 Redis 來回(2026-11-03 本機以limits的 fixed-windowhit各量 5×1,000 次:Redis 每次 wall 0.0475 ms/CPU 0.0285 ms,in-process 0.0011 ms;以 11-02 壓測每請求 17.22 ms loop CPU 換算,多出的 CPU 約占 0.16%;SIT CPU 較慢,實際值以 SIT 重測為準)。Redis 停掉或卡住超過api.rate_limit.redis_socket_timeout_seconds(0.1 秒)時,該 worker 退回進程內計數, 之後以 1、2、4…秒的間隔重試 Redis。退回期間各 worker 各自計數,額度暫時變成 worker 數倍。 Redis 的allkeys-lru在記憶體不足時也可能淘汰限流 key,結果只會更寬鬆。
開啟步驟
- 先確認 SIT 上跑的已是含 #2105 的版本(單 worker):
docker exec cortex-sit-api printenv WEB_CONCURRENCY印出1才是;舊版沒有這個變數,指令沒有輸出且 exit 1。不要在第一次部署這個版本時就同時切 2 worker: 沒有 pending migration 的部署失敗時,自動回滾(sit_stack_control.sh restore-previous)會用當下的 Variable 重建前一版 image,舊版沒有連線池均分與 Redis 限流,會變成 2 個 worker 各開 10+20 條連線、 限流額度翻倍。(有 migration 的部署失敗時不會自動回滾,writers 維持停止。) - repo Settings → Secrets and variables → Actions → Variables 設
SIT_API_WORKERS=2與THEME_JOB_RECOVER_STALE_ON_STARTUP=false。 多 worker 時,任一 worker 當掉重啟的啟動回收,會把另一個 worker 跑超過 60 分鐘的主題任務誤標 failed, 所以要一起關。關閉後,當掉時遺留的任務會維持running,需要在管理端手動取消。 - 重跑 Deploy SIT。部署會在 build 前檢查這兩個值:
SIT_API_WORKERS必須是 1 到database.api.pool_size(目前 10)之間的整數, 回收開關只接受 true/false/1/0/yes/no/on/off;worker 大於 1 而回收仍開時只發警告(::warning::),不擋部署。 - 驗證兩個 worker 都在。
/health是綠的不代表兩個都活著,因為 supervisor 重啟其中一個時, 另一個仍會回應。
docker logs cortex-sit-api 2>&1 | grep -c "Started server process"
docker logs --since 1m cortex-sit-api 2>&1 | grep -o '"pid":[0-9]*' | sort -u
第一行應為 2(重啟後會再增加),第二行應有 2 個不同 pid,且同一行的 db_pool 顯示 pool_size 5/max_overflow 10。
限制與注意
- worker 數只用
SIT_API_WORKERS設定,不要在 compose 的 command 加--workers,否則連線池不會均分、限流也不會改走 Redis。 SIT_API_WORKERS不可大於database.api.pool_size(目前 10,以config/core.yaml為準):每個 worker 至少要分到 1 條連線。這只是連線池切得開的上限,主機記憶體能撐幾個 worker 部署前不會檢查。部署前驗證會擋下超過的值;uvicorn 會先把所有 worker 程序開起來、app 才會拒絕,所以不能靠 app 啟動時的檢查。- DB rebase workflow(〈二〉)也會重建
agent-api,並沿用同兩個 Variable。它沒有格式檢查,改 Variable 後先跑一次 Deploy SIT 再跑 rebase。 - 記憶體:2026-11-01 單 worker 300 VU 壓測期間(壓測 harness
resources.csv),cortex-sit-api373.9–416.7 MiB、VM 可用 1,413–1,662 MiB、主機 swap 已用 2,280–3,051 MiB。 2026-11-03 04:43 閒置時(SSH 唯讀查詢),cortex-sit-api384 MiB、VM MemAvailable 1,387,700 kB、主機 swap 已用 2,752 MiB/4,096 MiB。 第二個 worker 預估再吃約一個 worker 的量,會加重 swap。 - 仍是每個 worker 各一份的進程內狀態:單檔即時更新去重(
api/routes/stocks.py,同一檔新股票可能被兩個 worker 同時更新, 只多耗 FinMind 次數,OHLC 寫入本身有 DB advisory lock)、空窗更新節流、即時報價串流計數、各類唯讀快取。 Gemini 配額已是 Postgres 共享、response cache 回填已有 Redis 租約,不受影響。 - 示範用的 admin job API(
POST /api/admin/update-data、GET /api/admin/jobs*,api/routes/analyze.py)把 job 狀態存在 進程內 dict,多 worker 時查詢可能落到另一個 worker 而回 404 或漏列。它只模擬進度、不更新資料,且只有管理者使用, 2026-11-03 裁決接受此限制、不改。 - runtime_metrics 每個 worker 各寫一行 10 秒窗,以
pid區分。判讀方式見api-runtime-metrics.md。
回滾
把 SIT_API_WORKERS 改回 1(或刪除)、THEME_JOB_RECOVER_STALE_ON_STARTUP 改回 true,再重跑 Deploy SIT。
程式不需回滾:單 worker 時連線池、限流與舊版行為相同。
網路暴露(pre-public hardening #567、#878)
- Postgres 不對外:compose 已移除 postgres 的 host
ports映射;DB 只在內部 network(postgresservice DNS)可達。若要在 SIT 本機用 GUI 連 DB,改綁 loopback:ports: ["127.0.0.1:${SIT_DB_PORT:-15433}:5432"],切勿綁0.0.0.0。 - API origin 僅限 SIT host:compose 將 API 發布為
127.0.0.1:${SIT_API_PORT:-18000}:8000;host 上的 cloudflared origin 必須填實際 port, 例如預設值使用http://127.0.0.1:18000。不要填localhost(可能解析到未綁定的 IPv6::1),也不要填 SIT LAN IP。路由器不得轉發 TCP 18000。 - Streamlit 已移除:無認證、不能帶 OAuth token,不得暴露。前端用 web/mobile OAuth app。
- 對外開放前:deployment env 需設
CORS_ALLOW_ORIGINS=https://<公開域名>,否則瀏覽器前端被 CORS 擋。 - 速率限制:每 IP 限流已開(
config/core.yamlapi.rate_limit);超限回 429。
API origin 封閉驗證
每次修改 compose、Tunnel ingress 或 SIT_API_PORT 後,都要完成三方向驗證;任一結果不符
就保留 Cloudflare Access,不得公開 SIT:
在 SIT MacBook Pro 本機執行(自訂 SIT_API_PORT 時替換 18000):
PORT=18000
curl -fsS "http://127.0.0.1:${PORT}/health"
先在 SIT MacBook Pro 確認實際連到測試 LAN 的 Wi-Fi/Ethernet IP,將該值帶到另一台
LAN 機器。若有啟用 WARP/VPN,不得使用 utun* 或 VPN IP;另以 macOS
「系統設定 → 網路」交叉確認使用中的實體介面與 IP:
networksetup -getinfo "Wi-Fi"
# 使用有線網路時,將 Wi-Fi 換成系統顯示的 Ethernet service 名稱。
在另一台 LAN 機器執行;先替換保留的範例 IP。命令必須印出 PASS,若 TCP 連線成功
就是 origin 仍可繞過:
PORT=18000
SIT_LAN_IP=192.0.2.1
case "$SIT_LAN_IP" in
""|127.*|192.0.2.1)
echo "ERROR: replace SIT_LAN_IP with the value read from the SIT host" >&2
exit 2
;;
esac
command -v nc >/dev/null || {
echo "ERROR: netcat (nc) is required for the TCP reachability probe" >&2
exit 2
}
if nc -z -w 5 "$SIT_LAN_IP" "$PORT"; then
echo "FAIL: SIT API TCP origin is reachable from LAN"
exit 1
else
echo "PASS: SIT API TCP origin is not reachable from LAN"
fi
Cloudflare Access Email policy 尚未移除時,使用已登入 Access 的瀏覽器 DevTools 確認
GET https://<SIT_API_HOSTNAME>/health 回傳 HTTP 200,且 JSON 包含 status: ok 與
database: connected。若 Access 已配置 Service Token policy,也可從外部網路執行:
: "${CF_ACCESS_CLIENT_ID:?set Cloudflare Access service-token client ID}"
: "${CF_ACCESS_CLIENT_SECRET:?set Cloudflare Access service-token secret}"
command -v jq >/dev/null || {
echo "ERROR: jq is required to validate the health JSON" >&2
exit 2
}
HEALTH_BODY="$(mktemp)"
trap 'rm -f "$HEALTH_BODY"' EXIT
if ! HTTP_STATUS="$(curl -sS -o "$HEALTH_BODY" -w '%{http_code}' \
-H "CF-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}" \
-H "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}" \
"https://<SIT_API_HOSTNAME>/health")"; then
echo "FAIL: Tunnel health request did not complete" >&2
exit 1
fi
if [ "$HTTP_STATUS" != 200 ] ||
! jq -e '.status == "ok" and .database == "connected"' "$HEALTH_BODY" >/dev/null; then
echo "FAIL: Tunnel did not return HTTP 200 with status=ok and database=connected" >&2
exit 1
fi
rm -f "$HEALTH_BODY"
trap - EXIT
echo "PASS: Cloudflare Tunnel reached the healthy SIT API origin"
解除 Email policy 後,立即從外部網路執行獨立的無 credential smoke;302 登入 redirect、 Access HTML、401/403 或非預期 JSON 都算失敗,必須立即恢復 Access policy:
command -v jq >/dev/null || {
echo "ERROR: jq is required to validate the health JSON" >&2
exit 2
}
HEALTH_BODY="$(mktemp)"
trap 'rm -f "$HEALTH_BODY"' EXIT
if ! HTTP_STATUS="$(curl -sS -o "$HEALTH_BODY" -w '%{http_code}' \
"https://<SIT_API_HOSTNAME>/health")"; then
echo "FAIL: restore Access policy; public health request did not complete" >&2
exit 1
fi
if [ "$HTTP_STATUS" != 200 ] ||
! jq -e '.status == "ok" and .database == "connected"' "$HEALTH_BODY" >/dev/null; then
echo "FAIL: restore the Cloudflare Access policy immediately" >&2
exit 1
fi
rm -f "$HEALTH_BODY"
trap - EXIT
echo "PASS: public Tunnel health check reached the SIT API origin"
在 SIT host 另確認 Docker 只列出 loopback binding:
docker compose -p cortex-sit -f investment-agent/docker-compose.sit.yml port agent-api 8000
# 預期:127.0.0.1:18000(或設定的 SIT_API_PORT)
若 Tunnel 驗證失敗,先把 cloudflared origin 改回同一個 loopback port;不要為了恢復服務
把 Compose 改回 wildcard binding。Rollback 到舊 image 不會還原 Compose port 設定,重新
up -d 前仍須確認 resolved config 維持 host_ip: 127.0.0.1。
v0.24.0 初次切版容量紀錄
SIT Docker VM 上限已擴至 768 GiB;最近一次實際讀回 PGDATA guest filesystem 可用 528,032,485,376 bytes、Docker.raw backing filesystem 可用 1,797,346,500,608 bytes, 原服務目前健康。這些數值是該次維運紀錄,不是 CD threshold 或 workflow gate;CD 不執行 capacity helper。額外 owned rehearsal 已停止,且其全庫 restore(含 post-data) 仍為 INCOMPLETE,不可稱為 PASS。詳見 v0.24.0 runbook。