---
title: FR-064 /app 唯讀化（D4）驗證報告：T-6.1 前置驗證 ＋ T-6.2 實作驗證
nav: 唯讀化驗證（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 服務** |
| 性質 | 唯讀驗證——只起測試容器觀察，不改任何產物、不改既有容器 |

---

## 結論（先講）

**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 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` 無關**。

### 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/<套件>/**/__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），與唯讀化無關，是既有存量警告。

---

## 對 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 匯出。

---

## 環境清理

四個測試容器（`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 兩服務全程未動。

## 改了什麼

| 檔案 | 改動 |
|------|------|
| `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 個）。

### 服務與功能（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 也是安全的」這種錯誤結論。

## 未實跑／假設

- **未跑 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）。

## 環境清理

測試容器 `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 連續）。
