| 項目 | 內容 |
|---|---|
| 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 服務 |
| 性質 | 唯讀驗證——只起測試容器觀察,不改任何產物、不改既有容器 |
D4 唯讀化可以走,但必須同時設 PYTHONDONTWRITEBYTECODE=1。
/app 下的 __pycache__——③層原樣附帶的 .py 在 import 時由 Python 產生 .pyc。基線容器啟動後 /app 下新增 90 個 __pycache__ 目錄、622 個 .pyc 檔。PYTHONDONTWRITEBYTECODE=1 完全消除:設了之後 /app 寫入歸零,功能冒煙結果與基線逐項一致。--read-only rootfs 直接跑(不設該 env),服務照樣 healthy,零 Read-only file system/EROFS 錯誤、零 Traceback——Python 寫不進 .pyc 時是靜默略過,這是 CPython 既有的 fallback 行為。api/socketio 兩模式皆然。也就是說:唯讀化本身安全,PYTHONDONTWRITEBYTECODE=1 是「讓行為明確、不靠隱性 fallback」的正規做法,不是續命補丁。
四個獨立容器,同一 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 列表。
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 無關。
/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 後 docker diff : 807 行
冒煙後 docker diff : 807 行
comm -13 (before, after) : 0 筆新增
25 個端點打完(含 Excel/docx 產出)零新增——.pyc 是 import 當下一次性產生,之後命中快取不再寫。
狀態 : Up (healthy) ← 兩模式皆是
Read-only file system / EROFS 出現次數 : 0
Traceback 出現次數 : 0
冒煙結果 : 與基線 A 逐項相同(200/400/404/405 皆一致)
log 中僅有 SQLAlchemy 的 SAWarning(relationship overlaps),與唯讀化無關,是既有存量警告。
ENV PYTHONDONTWRITEBYTECODE=1——放 image 而非部署 compose,理由同 D8「參數寫死在產物內」:這不該是部署現場可調的開關。--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)照舊掛。thirdparty 層要排除 __pycache__——build 出貨已排除,但若日後有人拿掉 PYTHONDONTWRITEBYTECODE,.pyc 會在 boot 期長出來;manifest 逐檔比對若把 __pycache__ 納入清單將產生系統性誤報。此排除規則屬 FR-064.2(manifest 產生)的實作約束,已在此註記。/app/static、/tmp、$HOME/.cm-jobs),設計上不觸及產物目錄;但 LibreOffice 首次轉檔會寫 $HOME/.config/libreoffice——$HOME 是 volume,唯讀化不影響,唯獨要確認部署時 /home/guidant 確實有掛(沒掛時容器內該路徑落在唯讀層會轉檔失敗)。這一項建議在 T-6.2 收口時實機補測一次 PDF 匯出。四個測試容器(fr064-smoke-a/b/c/d)驗證完畢後移除,測試用 volume 目錄 /srv/guidant-fr064{c,d}-* 一併清理。既有 guidant-api / guidant-socketio(STG DB)全程未動。
T-6.1 三項建議全數落地並實機驗證。受測 image guidant-ai-be:fr064-t62(同一顆 1.14.0 dist + 本次改動的 Dockerfile/entrypoint),在 188 另起獨立容器測,STG 兩服務全程未動。
| 檔案 | 改動 |
|---|---|
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 |
以 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 個)。
--read-only 下)/api/1.0/version 200、/healthz 200。probe_container.sh 七項探針:登入 200(DI 全量 wiring+DB+JWT)、/users/menu 200 五筆、i18n 兩個 .mo 生效、socketio 模式健康且 REST 未載、client 級收發過。%PDF 檔頭,內嵌字型為 Noto-Sans-CJK-TC / -Bold → WeasyPrint+中文字型在唯讀 rootfs 下完好。$HOME/.config/libreoffice(volume 內)。前提是 /home/guidant 有掛——沒掛會落在唯讀層而轉檔失敗,已寫進部署文件。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 也是安全的」這種錯誤結論。
PROBE_SSP_UID)。風險低:它與已驗過的 PDF 匯出共用 LibreOffice/$HOME 路徑,且 docx 範本是唯讀讀取。測試容器 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 連續)。