FR-064 /app 唯讀化(D4)驗證報告:T-6.1 前置驗證 + T-6.2 實作驗證

FR-064 T-6.1 — runtime 無寫產物目錄行為驗證報告

項目 內容
Notion CM-1226(子任務)/CM-1213(FR-064.6)/CM-1188(母案)
驗證日期 2026-08-15
驗證對象 guidant-ai-be:1.14.0(image id 0dcabc8d289d,FR-063 出貨版)
驗證主機 192.168.50.188(DEV),另起獨立容器,未動 STG/POC 服務
性質 唯讀驗證——只起測試容器觀察,不改任何產物、不改既有容器

§1

結論(先講)

D4 唯讀化可以走,但必須同時設 PYTHONDONTWRITEBYTECODE=1

  1. 🔴 疑慮成真:現行 image 在 runtime 確實會寫 /app 下的 __pycache__——③層原樣附帶的 .py 在 import 時由 Python 產生 .pyc。基線容器啟動後 /app 下新增 90 個 __pycache__ 目錄、622 個 .pyc
  2. 但這不構成阻擋:寫入全部發生在 boot 期(import 階段),冒煙跑完重路徑後零新增(before/after diff 逐行相同)。不存在「業務執行中持續寫產物目錄」的行為。
  3. PYTHONDONTWRITEBYTECODE=1 完全消除:設了之後 /app 寫入歸零,功能冒煙結果與基線逐項一致。
  4. 不設也不炸:以 --read-only rootfs 直接跑(不設該 env),服務照樣 healthy,Read-only file systemEROFS 錯誤、零 Traceback——Python 寫不進 .pyc 時是靜默略過,這是 CPython 既有的 fallback 行為。api/socketio 兩模式皆然。

也就是說:唯讀化本身安全,PYTHONDONTWRITEBYTECODE=1 是「讓行為明確、不靠隱性 fallback」的正規做法,不是續命補丁。


§2

驗證設計

四個獨立容器,同一 image、同一 env(DEV DB、cm_app),只差受測變因:

容器 RUN_MODE 變因 用途
A api 無(現況基線) 量化現況寫入
B api PYTHONDONTWRITEBYTECODE=1 驗解法有效
C api --read-only rootfs+tmpfs /tmp /run 驗寫不進去時不炸
D socketio --read-only(同 C) 另一模式同樣驗

量測手段用 docker diff <容器>——它列出容器可寫層相對 image 的全部差異(A=新增、C=目錄變動、D=刪除),是「產物目錄有無被寫」最直接的證據,比事前事後掃 mtime 更不會漏。

冒煙覆蓋(25 個端點)刻意選會拉起③層重套件的路徑:登入/權限選單/各模組列表(DI 全量 wiring+ORM)/Excel 範本下載(openpyxl)/docx 範本(python-docx+lxml)/YAML 範本/license 狀態/detection profile 列表。


§3

量測結果

最終 docker diff 行數

容器 diff 行數 /app.pyc 檔數
A(基線) 807 622
B(DONTWRITEBYTECODE) 4 0
C(read-only, api) 4 0
D(read-only, socketio) 4 0

B/C/D 那 4 行全是 /opt/compliance-manager-be/scripts/pki 這個唯讀 bind mount 的掛載點目錄(掛載點本身要在可寫層建出來,屬 Docker 機制而非程式寫入),/app 無關

A 容器的寫入來源分佈

/app 下產生 .pyc 的套件(③層原樣附帶的純 Python 套件):

套件 .pyc 數
pandas 253
fontTools 143
numpy 87
rapidfuzz 35
pdfminer 30
grpc 23
PIL 22
Crypto 16
dateutil 12
lxml 1

全部落在 /app/<套件>/<strong>/__pycache__/,沒有一個落在 volume 路徑之外的其他位置**,也沒有任何非 __pycache__/app 寫入。

「boot 期寫完就不再寫」的證據(A 容器)

boot 後 docker diff  : 807 行
冒煙後 docker diff   : 807 行
comm -13 (before, after) : 0 筆新增

25 個端點打完(含 Excel/docx 產出)零新增——.pyc 是 import 當下一次性產生,之後命中快取不再寫。

唯讀不炸的證據(C/D 容器)

狀態      : Up (healthy)  ← 兩模式皆是
Read-only file system / EROFS 出現次數 : 0
Traceback 出現次數 : 0
冒煙結果 : 與基線 A 逐項相同(200/400/404/405 皆一致)

log 中僅有 SQLAlchemy 的 SAWarning(relationship overlaps),與唯讀化無關,是既有存量警告。


§4

對 T-6.2(唯讀化實作)的建議

  1. Dockerfile 加 ENV PYTHONDONTWRITEBYTECODE=1——放 image 而非部署 compose,理由同 D8「參數寫死在產物內」:這不該是部署現場可調的開關。
  2. 部署文件補 --read-only + tmpfs 建議:本次實測可用的最小組合是
    --read-only --tmpfs /tmp:rw,size=2g --tmpfs /run:rw
    /tmp 必須是 tmpfs 且給足容量(LibreOffice 轉檔中繼;TMPDIR=/tmp),/run 是 gunicorn 等常見落點。四個既有 volume(/app/static /app/log /home/guidant /opt/guidant/pki)照舊掛。
  3. 完整性 manifest 的 thirdparty 層要排除 __pycache__——build 出貨已排除,但若日後有人拿掉 PYTHONDONTWRITEBYTECODE.pyc 會在 boot 期長出來;manifest 逐檔比對若把 __pycache__ 納入清單將產生系統性誤報。此排除規則屬 FR-064.2(manifest 產生)的實作約束,已在此註記。
  4. 本次未涵蓋:需要真實資料的長路徑(SSP docx 匯入、摘要報告 PDF 匯出、證據分類 job)。這些走的是 volume 落點(/app/static/tmp$HOME/.cm-jobs),設計上不觸及產物目錄;但 LibreOffice 首次轉檔會寫 $HOME/.config/libreoffice——$HOME 是 volume,唯讀化不影響,唯獨要確認部署時 /home/guidant 確實有掛(沒掛時容器內該路徑落在唯讀層會轉檔失敗)。這一項建議在 T-6.2 收口時實機補測一次 PDF 匯出。

§5

環境清理

四個測試容器(fr064-smoke-a/b/c/d)驗證完畢後移除,測試用 volume 目錄 /srv/guidant-fr064{c,d}-* 一併清理。既有 guidant-api / guidant-socketio(STG DB)全程未動。


T-6.2 實作驗證(CM-1227,2026-08-15)

T-6.1 三項建議全數落地並實機驗證。受測 image guidant-ai-be:fr064-t62(同一顆 1.14.0 dist + 本次改動的 Dockerfile/entrypoint),在 188 另起獨立容器測,STG 兩服務全程未動。

§6

改了什麼

檔案 改動
docker/production/Dockerfile COPY --chown=1000:1000 dist/COPY dist/(產物歸 root:root);新增 ENV PYTHONDONTWRITEBYTECODE=1
docker/production/entrypoint.sh chown 迴圈改吃 CHOWN_DIRS 白名單常數(內容不變,四個掛載點),並在該處寫明「擴大成 chown -R /app 會抹掉防線」
docs/.../deployment-env.md §4 新增「🔒 建議加上 --read-only」段(run/compose 兩種寫法+tmpfs 理由表);§8 檢查清單加兩條
scripts/build/probe_container.sh 新增兩項附加探針:產物唯讀、/app.pyc
§7

驗證結果

產物唯讀(本卡核心驗收)

以 uid 1000(=業務進程身分)在容器內嘗試竄改,五種手法全數失敗:

嘗試 --read-only 容器 只靠 owner(無 --read-only
echo x >> /app/guidant-ai Permission denied Permission denied
touch /app/__probe_tamper__ Read-only file system Permission denied
rm -f /app/guidant-ai Read-only file system Permission denied
echo x >> /app/cryptography/exceptions.py Permission denied Permission denied
mkdir /app/evil Permission denied
對照:touch /app/static/x/app/log/x 成功(volume 照舊可寫) 成功

owner 這一層自己就夠——不加 --read-only 五項照樣全擋。--read-only 是額外一層(連 docker exec -u 0 也擋),故列為部署建議而非硬性條件。

docker diff = 0 行/app.pyc = 0 個(基線是 807 行/622 個)。

服務與功能(api/socketio 雙模式,皆在 --read-only 下)

  • 兩容器 Up (healthy)/api/1.0/version 200、/healthz 200。
  • probe_container.sh 七項探針:登入 200(DI 全量 wiring+DB+JWT)、/users/menu 200 五筆、i18n 兩個 .mo 生效、socketio 模式健康且 REST 未載、client 級收發過。
  • 摘要報告 PDF 匯出(T-6.1 建議④點名要補測的項目):HTTP 200、262KB、%PDF 檔頭,內嵌字型為 Noto-Sans-CJK-TC / -Bold → WeasyPrint+中文字型在唯讀 rootfs 下完好。
  • LibreOffice headless 轉檔:txt→pdf 成功,profile 寫到 $HOME/.config/libreoffice(volume 內)。前提是 /home/guidant 有掛——沒掛會落在唯讀層而轉檔失敗,已寫進部署文件。
  • 容器 log 掃 Read-only file system / EROFS / Traceback各 0 次(兩模式)。

探針⑥ 失敗屬既有問題,非本卡造成

probe_container.sh 探針⑥(問卷上傳落點)在新 image 報 FAIL。拿基線 guidant-ai-be:1.14.0 跑同一支探針,同樣 FAIL、訊息逐字相同HTTP 200 但 /app/static 內檔案數沒增加),故確認為既有存量問題,與唯讀化無關。未在本卡處理——建議另開卡查(可能是探針判準寫錯,也可能是 STORAGE_CONFIG.base_dir 指到別處,兩者都不該在防竄改的卡裡順手改)。

新探針有牙齒(突變驗證)

新增的兩項探針拿舊 image(owner 1000、無 PYTHONDONTWRITEBYTECODE)跑,如期轉紅: 可寫:echo x >> /app/PIL/PdfImagePlugin.py/app 下有 622 個 .pyc

⚠️ 過程中發現一個會讓人誤判的細節,已寫進探針註解:舊 image 的 echo >> /app/guidant-ai 也會失敗,但錯誤是 Text file busy(binary 正在執行)而非 Permission denied/app 根目錄在舊 image 也早已是 root:root。也就是說三個落點裡,只有「改一支既存的 ③層 .py」真正落在 owner 判定上——只看前兩項會得出「舊 image 也是安全的」這種錯誤結論。

§8

未實跑/假設

  • 未跑 SSP docx 匯出探針④(缺 PROBE_SSP_UID)。風險低:它與已驗過的 PDF 匯出共用 LibreOffice/$HOME 路徑,且 docx 範本是唯讀讀取。
  • 未在 STG/POC 驗(依環境異動鐵律,開發期只碰 DEV 起的獨立測試容器)。改動只在 image build 層,正式套用要等 T-6.x 收口後重打 image、走放行流程。
  • 未重打正式 image:本卡只驗改動正確,未產出新的出貨 image(版號歸屬與出貨時機屬收口決策)。
  • 證據自動分類未測:落地版該功能本就停用(design D4/deployment-env §7)。
§9

環境清理

測試容器 fr064t62-api / fr064t62-sock / fr064t62-noro 與所有 fr063-probe-* 探針容器已移除,測試 image guidant-ai-be:fr064-t62 已刪,/tmp/fr064-t62-ctx/tmp/fr064t62{,b} 已清。既有 guidant-api / guidant-socketio / postgres / camunda 全程未動(前後 uptime 連續)。