產出於 2026-08-15(CM-1192 / FR-063.3)。適用對象:把
guidant-ai-be:<version>image 裝到客戶主機的人。前提:正式 image 不帶任何設定檔。編譯進 binary 的只有產品常數,所有部署差異 一律由外部注入(
--env-file或 composeenvironment:)。缺關鍵項不會拖到執行期 才出錯,啟動期就會一次列出全部缺項後中止。
正式部署一律走 repo 內的 docker/production/docker-compose.yml——所有掛載/ 環境/重啟策略都已釘成定式(含 read_only 唯讀化與 tamper 標記 volume), 照抄即可,不必再手打長指令。
# ① 準備設定檔(照 §2 必填項填)
cp env.sample /srv/guidant-ai/guidant.env && vi /srv/guidant-ai/guidant.env
# ② 準備掛載目錄(owner 不必先喬,容器啟動時會自動 chown,見 §4)
mkdir -p /srv/guidant-ai/{static,log,home,pki}
# ③ 起兩個服務(api + socketio,同一個 image)
GUIDANT_VERSION=<version> docker compose -f docker/production/docker-compose.yml up -d
# 只動單一服務時必指定 service 名:... up -d guidant-api
# ④ 驗
curl -s localhost:8000/api/1.0/version # {"data":{"commit":"...","version":"..."}}
curl -s localhost:8002/healthz # {"mode":"socketio","status":"ok"}可調變數(都有預設,詳見 compose 檔頭註解):GUIDANT_VERSION(image tag)、 GUIDANT_ENV_FILE(預設 /srv/guidant-ai/guidant.env)、GUIDANT_DATA_DIR (預設 /srv/guidant-ai)、AGENT_CERT_DIR(agent 憑證主機端目錄,須與 env 內 AGENT_* 路徑一致,見 §4)。
換版:改 GUIDANT_VERSION(或 pull 新 image)後重跑同一句 up -d, compose 會自動 recreate,資料都在 volume 不受影響。
docker run 寫法(點開)# ③a 起 API 服務
docker run -d --name guidant-api --restart unless-stopped \
--read-only --tmpfs /tmp:rw,size=2g --tmpfs /run:rw \
--env-file /srv/guidant-ai/guidant.env \
-p 8000:8000 \
-v /srv/guidant-ai/static:/app/static \
-v /srv/guidant-ai/log:/app/log \
-v /srv/guidant-ai/home:/home/guidant \
-v /srv/guidant-ai/pki:/opt/guidant/pki \
-v /etc/machine-id:/etc/machine-id:ro \
-v <憑證目錄>:<憑證目錄>:ro \
guidant-ai-be:<version>
# ⚠️ <憑證目錄> 必須與 .env 內 AGENT_* 指的路徑一致(原因與驗證方式見 §4)
# 🔴 `/opt/guidant/pki` 這行不可省:那是 FR-064 tamper 標記落點,漏掛會落成
# 匿名 volume,`docker rm` 容器就把鎖定標記洗掉、防竄改鎖定失效(見 §4)
# ③b 起通知服務(**同一個 image、同一個 command**,只多一個環境變數)
docker run -d --name guidant-socketio --restart unless-stopped \
--read-only --tmpfs /tmp:rw,size=2g --tmpfs /run:rw \
--env-file /srv/guidant-ai/guidant.env \
-e RUN_MODE=socketio \
-p 8002:8002 \
-v /srv/guidant-ai/static:/app/static \
-v /srv/guidant-ai/log:/app/log \
-v /srv/guidant-ai/home:/home/guidant \
-v /srv/guidant-ai/pki:/opt/guidant/pki \
-v /etc/machine-id:/etc/machine-id:ro \
-v <憑證目錄>:<憑證目錄>:ro \
guidant-ai-be:<version>docker --env-file 的格式與 shell 的 .env 慣例不相容:
| 寫法 | 結果 |
|---|---|
export FOO=bar |
❌ docker 把 export FOO 整串當變數名,報 variable 'export FOO' contains whitespaces 並拒絕啟動整個容器 |
FOO="bar" |
⚠️ docker 不剝引號,值變成 "bar"(含引號),密碼會因此對不上 |
FOO=bar |
✅ 正確 |
export 前綴是給 source 用的寫法,人不會覺得有錯——188 上的 .env 就有 7 行是這樣 (2026-08-15 實測撞到,容器直接起不來)。交付前務必檢查:
grep -nE '^[[:space:]]*export ' <你的 env 檔> # 應無輸出
grep -nE '=".*"$' <你的 env 檔> # 應無輸出(或確認該值真的要含引號)啟動期由 config/config_loader.py:validate_required_env() 檢查,缺項會一次列全 (不是缺一個報一個),訊息形如 缺少:DB_HOST, JWT_SECRET_KEY(或 JWT_SECRET)。
| 變數 | 啟動期檢查 | 說明 |
|---|---|---|
DB_HOST |
✅ 強制 | PostgreSQL 主機 |
DB_USER |
✅ 強制 | DB 帳號 |
DB_PASSWORD |
✅ 強制 | DB 密碼 |
JWT_SECRET_KEY |
✅ 強制 | JWT 簽章金鑰。每個客戶獨立生成,不可跨部署點共用 |
REDIS_HOST |
✅ 強制 | Redis 主機 |
REDIS_USER |
⚠️ 未強制 | Redis 帳號。啟動期不檢查,但 Redis 有啟用帳密時漏填會在連線當下才失敗(症狀比缺項難查,故仍視為必填) |
REDIS_PASSWORD |
⚠️ 未強制 | Redis 密碼,同上 |
舊 JSON 包裝格式仍完全相容且優先(既有部署零改動):填了 DB_SECRET / REDIS_SECRET / JWT_SECRET 就以它們為準。新裝機建議用上表的平鋪格式。
| 舊格式 | 新平鋪格式 |
|---|---|
DB_SECRET={"rds_master_username":"u","rds_master_password":"p"} |
DB_USER=u + DB_PASSWORD=p |
REDIS_SECRET={"redis_user_name":"u","redis_user_password":"p"} |
REDIS_USER=u + REDIS_PASSWORD=p |
JWT_SECRET={"jwt_secret":"s"} |
JWT_SECRET_KEY=s |
| 變數 | 預設 | 落地版建議 |
|---|---|---|
RUN_MODE |
api |
api 容器不設;通知容器設 socketio |
PORT / SOCKET_PORT |
8000 / 8002 |
容器各有獨立網路命名空間,通常不用改,只改對外 map 的 port |
DB_PORT |
5432 |
依客戶 DB |
DB_NAME |
compliance_manager |
依客戶命名 |
ENV |
DEV |
純顯示標籤(只在啟動 log 印一行,不驅動任何行為)。落地版填 PRD |
DEBUG |
false |
落地版必須 false。true 會開 SQL 全量 log(洩漏查詢內容且拖慢) |
CORS_ALLOWED_ORIGINS |
* |
落地版必填實際站台網址(逗號分隔)。留 * 等於任何站台都能打這組 API |
LOG_DIR |
./log |
維持預設即可(容器內 cwd 固定 /app,故落在 /app/log 這個掛載點) |
USE_GUNICORN |
未設=not DEBUG |
落地版走 gunicorn(不設即可) |
GUNICORN_WORKERS |
4 |
依客戶機器核心數調整 |
GUNICORN_TIMEOUT |
120 |
匯出/大檔解析比一般請求久,機器慢時調大 |
SYSTEM_NAME / SYSTEM_URL / FRONTEND_BASE_URL |
— | 通知信內容與連結會用到 |
TZ |
image 內已設 Asia/Taipei |
🔴 必須與該部署的主機/DB/其他服務一致。跨時區客戶用 -e TZ=<該地時區> 覆寫,但不可各自為政——程式內有 naive datetime 比較(如 agent 在線判定),時區不一致會讓「東西是活的、判定卻壞掉」(見 §4 末的時區說明) |
ENABLE_MULTI_TENANT |
true |
單一客戶落地版通常仍維持 true |
MFA_REQUIRED |
false |
DB RUNTIME_CONFIG 有值時以 DB 為準,本項是預設來源 |
LICENSE_ENFORCEMENT_ENABLED |
true |
顯式寫出(曾因靠隱式預設而誤判) |
LICENSE_READONLY_GATE_ENABLED |
true |
同上 |
DEPLOYMENT_MODE |
saas |
落地版填 host(啟用機器指紋核對) |
TURNSTILE_SECRET_KEY |
— | 登入 captcha。落地版(內網無外網)需評估是否停用,否則登入會因連不到 Cloudflare 而失敗 |
DB_READ_HOST |
— | 目前未生效(讀寫分離從未實際運作,無消費者),保留定義供未來使用 |
| 容器內路徑 | 用途 | 不掛的後果 |
|---|---|---|
/app/static |
使用者上傳檔落地處 | 重啟即全丟。問卷 Excel 上傳實測落點:/app/static/file/answer/upload/<user>/ |
/app/log |
應用 log | 重啟即丟,出事無從查起 |
/home/guidant |
$HOME:內含 .cm-jobs(背景 job 狀態)與 .config/libreoffice(LibreOffice profile,首次轉檔自建) |
job 狀態遺失;LibreOffice 每次重建 profile |
/tmp |
LibreOffice 轉檔中繼(TMPDIR) |
用容器層,大檔轉檔可能吃爆容器可寫層。建議掛實體 volume,容量數 GB 以上 |
/opt/guidant/pki |
🔴 FR-064 tamper 標記落點(common/integrity/tamper_marker.py 預設 /opt/guidant/pki/.integrity-tamper) |
Dockerfile 有 VOLUME 宣告,不顯式掛載會落成匿名 volume——docker rm 容器時鎖定標記跟著消失,防竄改鎖定形同虛設(重建容器即解鎖)。2026-08-16 實際踩到,CM-1238 補洞 |
/etc/machine-id(唯讀,掛「檔」不是目錄) |
🔴 機器指紋錨點(common/license/machine_fingerprint.py 第一級來源)——unlock token 綁機(FR-064.5)與 license 綁機(FR-062 D6)都以它為準 |
image 內的 /etc/machine-id 是 0 bytes 空檔(Debian base image 標準行為,容器不跑 systemd 永遠填不上)→ 指紋退回 uuid.getnode()(容器 MAC)→ 每次 docker restart 就換一個指紋:原廠簽的解鎖憑證重啟即失效(永遠解不了鎖)、license 綁機漂移。2026-08-16 實測撞到,CM-1242 補洞 |
| 憑證目錄(路徑見下方 🔴) | 檢測 agent 的 mTLS/JWT 憑證檔 | 不用檢測 agent 功能可不掛;要用時憑證只能外部掛入(不打進 image) |
/etc/machine-id 掛載的前置條件:宿主機必須有這個檔-v /etc/machine-id:/etc/machine-id:ro 掛的是檔案。宿主機若沒有這個檔, docker 會自動把它建成目錄——容器內讀檔失敗、指紋悄悄退回 MAC,症狀與沒掛 完全一樣(服務全綠、只有解鎖與綁機壞掉)。裝機前先驗:
test -s /etc/machine-id && echo OK || echo "缺 machine-id,見下方補法"
# 缺的話(極精簡系統才會缺,systemd 系開機安裝即有):
sudo systemd-machine-id-setup # 或 sudo sh -c 'dbus-uuidgen > /etc/machine-id'⚠️ machine-id 一旦被重新生成,該機所有既有綁機憑證(unlock token/host 版 license)都會失效,需向原廠重新申請。故只在「本來就沒有」時補,不要為了整齊 重新生成。
AGENT_* 的值逐字一致這是實際踩過的坑(2026-08-15,188 上容器化後 agent 全數連不上)。
AGENT_CA_CERT 等變數存的是檔案的絕對路徑字串,程式直接拿去開檔 (common/util/agent_auth/settings.py)。它不會去別的地方找、也沒有預設位置—— 所以容器內必須在那個路徑上真的有檔案,掛在別處等於沒掛。
失敗症狀:BE 起得正常、healthcheck 綠、API 都通,只有 agent 連不上 (讀不到 CA 憑證就無法完成 mTLS 驗證)。又是一個「服務看起來活著」的靜默失效。
兩種做法,擇一:
做法 A(推薦,.env 零改動) —— 把主機憑證目錄按原路徑掛進容器:
# .env 內若是 AGENT_CA_CERT=/opt/guidant-ai-be/scripts/pki/agent_dev/ca.crt
# 就照這個路徑掛,容器內外路徑相同:
-v /opt/guidant-ai-be/scripts/pki:/opt/guidant-ai-be/scripts/pki:ro掛 :ro 唯讀 —— BE 只需要讀憑證,唯讀可防止容器意外寫壞 CA 私鑰。
做法 B(路徑收斂,需同步改 .env) —— 統一掛到 /opt/guidant/pki, 並把 .env 內六個 AGENT_* 路徑一起改成該目錄下的位置。新客戶裝機建議走這條, 既有部署轉換時兩邊要一起改,改一半就是連不上。
光是「掛對路徑」還不夠——私鑰通常是 0600 root(ca.key / cloud_client.key / jwt_private.pem),而容器內業務進程跑的是 uid 1000,讀不到。原生部署時 BE 以 root 或檔案擁有者執行,從來不會遇到;容器化刻意降權後就撞上。
主機端修正(只改 owner、不放寬權限模式,私鑰維持 0600):
sudo chown 1000:1000 <憑證目錄>/*.key <憑證目錄>/*.pem失敗症狀極具誤導性(2026-08-15 實際踩到,繞了很久):
last_seen_at 持續更新 ← 這條只驗 JWT 公鑰,而公鑰是 0644 讀得到{"reachable": false, "detail": "[Errno 13] Permission denied"})裝機驗證(別等 agent 心跳才發現):
# ⚠️ 必須帶 -u 1000:docker exec 預設以 root 進入,root 讀得到 0600 私鑰,
# 用 root 驗會全部 ✅ 卻漏掉這個坑(本案初次驗證即因此漏掉)。
# 要驗的是「業務進程的身分讀不讀得到」,不是「有沒有這個檔」。
docker exec -u 1000 <容器> sh -c 'for p in $AGENT_CA_CERT $AGENT_CA_KEY \
$AGENT_CLOUD_CLIENT_CERT $AGENT_CLOUD_CLIENT_KEY \
$AGENT_JWT_PRIVATE_KEY $AGENT_JWT_PUBLIC_KEY; do
[ -r "$p" ] && echo "✅ $p" || echo "❌ $p"; done'六個全 ✅ 才算好。有 ❌ 的兩種可能:掛載路徑與 AGENT_* 對不上(檔案根本不在), 或 owner 不是 1000(檔案在但讀不到)——ls -ln 一看便知。
端到端確認(最終判準,光看檔案可讀還不夠):
curl -sk -X POST -H "Authorization: Bearer <token>" -H 'Content-Type: application/json' \
-d '{"base_url":"<agent 位址>"}' https://<主機>/api/1.0/remote-agents/health-check
# 期望 {"reachable": true, "status_code": 200}確認 agent 真的通了(心跳間隔預設 AGENT_HEARTBEAT_INTERVAL_SEC,等一輪):
docker logs --since 5m <容器> | grep 'agents/heartbeat' # 應見 HTTP 200
AGENT_AUTH_MODE不設=預設none=agent 認證整條關閉,此時以上憑證不會被讀。 設為full才會走 mTLS + JWT。
image 內已釘死 TZ=Asia/Taipei。不要把它改成與該部署其他元件不同的值。
原因:程式內有 naive datetime 比較的既有寫法,最典型的是 agent 在線判定 (app/remote_agent/service/agent_enrollment_service.py:is_online()):
return (datetime.now() - last_seen_at).total_seconds() < offline_threshold_sec兩邊都是不帶時區的時間值,一旦容器時區與寫入端(DB/其他服務)不同,差幾小時就 永遠算成「很久沒心跳」。
實際症狀(2026-08-15 於 188 踩到,容器初版未設 TZ 落到 UTC、主機是 CST):
last_seen_at 持續更新也就是說東西全都是活的,只有那個判定壞掉——查的時候很容易往「網路不通/憑證錯」 的方向鑽。先比對時間再說:
date # 主機
docker exec <容器> date # 容器,兩者應完全一致(含時區縮寫)跨時區客戶部署時以 -e TZ=<該地時區> 覆寫,但主機、容器、DB 三者要一致。
owner 不必事先喬:容器 entrypoint 會在啟動時把上述掛載點(且僅限這幾個) chown 成 1000:1000(已實測:root 擁有的 bind mount 掛進去後自動修正,上傳正常)。 若以 docker run --user 自行指定非 root 身分啟動,則 chown 不會執行,owner 需自備。
產物本身(
/app下除static/log外的內容)是root:root,業務進程(uid 1000) 只讀不寫——這是刻意的防竄改設計,見下一段。
STORAGE_CONFIG 是 DB 設定不是環境變數:儲存後端(local / minio / remote_agent) 存在 public.system_configs(group=STORAGE_CONFIG)。⚠️ 選 local 時 base_dir 必須明確設定——未設會 fallback 到 /tmp/upload/,重啟即遺失。
--read-only(防竄改,FR-064 D4)image 內產物已歸 root:root、業務進程跑 uid 1000,檔案系統層就寫不進產物。 再加 --read-only 把整個 rootfs 掛成唯讀,連 root 身分(例如有人 docker exec -u 0 進來)也改不動——這是成本近乎為零的一道防線,正式交付建議都加。
加了之後必須補兩個 tmpfs,否則啟動失敗:
docker run -d --name guidant-api --restart unless-stopped \
--read-only \
--tmpfs /tmp:rw,size=2g \
--tmpfs /run:rw \
--env-file /srv/guidant-ai/guidant.env \
-p 8000:8000 \
-v /srv/guidant-ai/static:/app/static \
-v /srv/guidant-ai/log:/app/log \
-v /srv/guidant-ai/home:/home/guidant \
-v /srv/guidant-ai/pki:/opt/guidant/pki \
-v /etc/machine-id:/etc/machine-id:ro \
-v <憑證目錄>:<憑證目錄>:ro \
guidant-ai-be:<version>| 項目 | 為什麼 |
|---|---|
--tmpfs /tmp:rw,size=2g |
TMPDIR=/tmp,LibreOffice 轉檔中繼要寫。容量要給足——大檔轉檔吃掉 tmpfs 會轉檔失敗。若轉檔量大,改掛實體 volume(-v /srv/guidant-ai/tmp:/tmp)而非 tmpfs |
--tmpfs /run:rw |
gunicorn 等常見的 pid/socket 落點 |
既有 volume(含 /opt/guidant/pki) |
照舊掛,唯讀化不影響它們(bind mount / volume 不受 --read-only 管) |
/home/guidant 一定要掛 |
LibreOffice 首次轉檔會寫 $HOME/.config/libreoffice。沒掛時該路徑落在唯讀 rootfs 上 → 轉檔失敗 |
compose 寫法:直接用 repo 內 docker/production/docker-compose.yml(§0), read_only + tmpfs + 全部掛載(含 tamper 標記的 /opt/guidant/pki)都已內建。
socketio 容器同樣適用(RUN_MODE=socketio,實測兩模式皆 healthy)。
不加
--read-only也能跑,不會有任何錯誤訊息——這正是要在文件寫明的原因: 它是「加了才有、不加也不吭聲」的防護,不會有人在排錯時發現自己漏了。
| 功能 | 變數 |
|---|---|
| AI(Dashboard/翻譯/bot) | OPENAI_API_KEY、ANTHROPIC_API_KEY、GOOGLE_API_KEY |
| Google Drive 整合 | GOOGLE_DRIVE_OAUTH_CLIENT_ID、..._SECRET、..._REDIRECT_URI、DRIVE_TOKEN_ENCRYPTION_KEY(⚠️ 每環境獨立生成,換值會導致既有密文解不開需重新授權)、DRIVE_FILE_SIZE_LIMIT_MB、DRIVE_SYNC_WORKER_POOL_SIZE |
| 檢測 Agent | AGENT_AUTH_MODE、DETECTION_TOOL_ENCRYPTION_KEY、AGENT_CA_CERT/_KEY、AGENT_CLOUD_CLIENT_CERT/_KEY、AGENT_JWT_PRIVATE_KEY/_PUBLIC_KEY(值是憑證檔的絕對路徑,掛載點必須與這些值逐字一致,見 §4)、AGENT_CLOUD_ENDPOINT(agent 回連 BE 的位址,每部署點必改)、AGENT_HEARTBEAT_INTERVAL_SEC、AGENT_CERT_VALID_DAYS(預設 365)、AGENT_JWT_TTL_SEC(預設 60)、DETECTION_UPLOAD_* 五項與 DETECTION_PROFILE_* 四項上傳限制(都有預設,見 .env.sample) |
| License Center 對接 | LICENSE_ACTIVATION_SERVER_URL(非本機部署必填,預設 http://127.0.0.1:5062 僅 DEV 可用)、LICENSE_CENTER_API_TOKEN |
| 排程報表 | SCHEDULE_REPORT_DIR(若使用,該路徑需可寫且應在掛載點內) |
| SSP 匯出轉檔 | LIBREOFFICE_CMD(LibreOffice 裝在非標準路徑才需填;容器版 image 內已裝於標準路徑) |
這 16 個由內部共用套件直接讀取,不經主專案 config,因此在主專案原始碼裡搜不到—— 歷來漏設的常客(design 盤點#14):
| 類別 | 變數 |
|---|---|
| 執行環境 | ENABLE_MULTI_TENANT、RUN_ENV、TZ |
| DB | DB_USERNAME、DB_PASSWORD、DB_SCHEMA、DEFAULT_SCHEMA |
| Redis | REDIS_DB、REDIS_SSL、REDIS_SECRET(見下) |
| i18n | DEFAULT_LOCALE |
| 外部整合 | GITLAB_*、GITHUB_PRIVATE_TOKEN |
| Auth | OTP_COOLDOWN_SECONDS、AUTH_CHANGE_SECRET_COOLING_HOURS |
⚠️ DB_USERNAME(jedi 讀)與 DB_USER(主專案讀)是兩個不同的變數名。 兩邊都用到 DB 帳號時,兩個都要填成同一個值。
⚠️ jedi-mfa(Email OTP)只認舊 JSON 格式 REDIS_SECRET (jedi_mfa/common/util/redis_client_util.py:18 直接 json.loads(os.getenv("REDIS_SECRET"))), 不吃主專案的平鋪 REDIS_USER / REDIS_PASSWORD。有啟用 Email OTP 的部署, REDIS_SECRET 舊格式必須保留;且主專案「兩種都填時以舊 JSON 為準」, 所以填了就要填真值。
scripts/build/build_release.sh 產完 dist 會產 integrity manifest 並送 License Center 簽章。用的環境變數與產品 BE 既有參數同名(config/config.py,FR-062),不另創新名:
| 變數 | 用途 | 預設 |
|---|---|---|
LICENSE_ACTIVATION_SERVER_URL |
LC 簽發站 base URL(送簽打 /api/internal/sign-manifest) |
http://127.0.0.1:5062(build 機 188 上 LC 就在本機) |
LICENSE_CENTER_API_TOKEN |
LC api_tokens 白名單內的明文 token,至 LC 後台「API Token 管理」頁建立;不入版控、不進 build log(腳本只印前 4 碼) |
—(必填才會送簽) |
build_release.sh 印明確警告後繼續,但沒簽的 dist 打不出 image(build_image.sh 會擋),所以「忘了簽」不會靜靜流到出貨。LICENSE_CENTER_API_TOKEN=… scripts/build/sign_manifest.sh \
--manifest <dist>/integrity-manifest.json \
--output <dist>/integrity-manifest.siglicense-center sign-manifest <manifest 檔> --output <sig 檔>。 兩條路徑走同一個 sign_payload 與同一道結構守門,產物等價。| 功能 | 原因 | 影響面 |
|---|---|---|
| 證據自動分類(AI 分類器) | 需以 docker run 起分類器容器;BE 自己容器化後變成 DinD 問題(要掛 docker.sock,等同給容器宿主機 root 權限)。D4 定案降級停用 |
該模組端點仍在,觸發時止於 EC_500001「分類容器執行失敗」。其他模組不受影響 |
| Swagger UI | 從未啟用(docs.init_app 一直被註解),且落地版不應對客戶暴露 API 文件 |
無 |
產品的防竄改機制(FR-064)能偵測到「檔案 hash 對不上」並留下 machine_fingerprint/ tamper_event_id/不符檔案的 mtime/ctime/owner/size 等檔案證據,但**「是誰在主機上 動的手」不在產品應用層能力範圍內**——竄改動作(SSH 登入改檔、docker exec 進容器、 直接改 volume 掛載來源)完全不經過產品的業務邏輯,產品拿不到操作者身分。
要溯源到「哪個帳號、哪個 session 做的」,需要客戶主機層自行建立審計機制, 這是客戶責任範疇,不是本產品的責任範疇。以下是建議客戶部署時一併規劃的項目:
auditd 監控產物目錄Linux 主機上用 auditd 對容器產物與掛載路徑加寫入監控規則,範例:
# 監控 /app 底下(產物目錄)的寫入/屬性變更(含 chmod/chown)
auditctl -w /app -p wa -k guidant_integrity
# 監控憑證與 tamper 落點目錄(見 §4)
auditctl -w /opt/guidant/pki -p wa -k guidant_pki_write
# 若採 bind mount,監控主機端實際來源路徑(容器內 /app 對應到的主機路徑)
auditctl -w /srv/guidant-ai/app-image-root -p wa -k guidant_host_write-p wa 表示監控 write/attribute-change 事件;-k 是自訂 tag,方便後續用 ausearch -k guidant_integrity 篩選。規則要設為開機常駐(寫入 /etc/audit/rules.d/),否則重開機即失效。
查詢範例(配合 tamper 事件的 detected_at 時間窗,往回抓對應的稽核紀錄):
ausearch -k guidant_integrity -ts <detected_at 前後時間窗>主機層審計紀錄(auditd log、docker exec 使用紀錄、SSH 登入 log)建議外送到 客戶自有的集中式 log 平台(SIEM/syslog server),而非只留在被懷疑遭竄改的 同一台主機上——本地留存的稽核紀錄與本地 tamper 標記檔面臨同一個弱點: 主機若被完全控制,本地留存的一切都可能被清除或竄改,外送才能保留不受單機 狀態影響的獨立證據鏈。
產品應用層無法識別檔案竄改行為人,主機層審計是客戶責任範疇。 產品偵測與存證止於「檔案證據+(若有業務 session)旁證」,不含操作者身分溯源; 若客戶場景需要「查明是誰做的」這一層,須自行部署並維運上述主機層審計機制。