---
title: FR-193 Docker rootless — 設計 v1.1（Agent 先行）
brand: Guidant AI · **FR-193** Docker rootless
eyebrow: FR-193 · design v1.1 · 2026-11-10
h1: Agent 主機改 rootless Docker
subtitle: 主機準備層、docker 呼叫切身分、uid 換算、升級重驗；主系統後續另拆
lede: "Agent 安裝包改成以專用帳號 `guidant` 跑 rootless Docker。程式碼與 image 不動，改動全在 `deploy/install.sh` 與 `scripts/install/lib/`。十張卡六批，第一棒乾淨機手動踩坑，第二批起改安裝程式。"
chips: [{text: v1.1 已被 v1.2 取代, kind: plain}, {text: Agent 先行, kind: accent}, {text: 10 卡 6 批, kind: plain}]
footer: 討論與決策沿革見 discussion.md 與 git log；相對 v1.0 的差異見末節
---

> **本版已被 [design-v1.2.md](design-v1.2.md) 取代**（新增 D8 發行版自帶 Docker 處理）。此檔凍結不改。

# FR-193 設計 v1.1（已被 v1.2 取代）

## 0. 分工概述（30 秒版） {#overview nav="概述"}

**整案一句話**：Agent 被掃主機上的 Docker 引擎改成普通帳號 `guidant` 執行，三顆容器全由它開；客戶照常 `sudo ./install.sh`，多出來的主機設定全由安裝程式做。

| 階段 | 做什麼 | 產出 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|
| P0 探路 | 乾淨機手動裝 rootless，列壞掉清單、量吞吐 | 證據頁 `evidence/agent-rootless-probe.md` | 打開證據頁，看得到十二項驗證清單每項的實際輸出、上傳 100 MB 的秒數、traceroute 兩種模式的結果 |
| P1 改安裝程式 | 主機準備層、Docker 三段式安裝、docker 呼叫切身分、uid 換算、升級回滾重驗、收尾小項 | `install.sh` 與 `lib/` 四支新檔 | 乾淨機 `sudo ./install.sh` 一路到底不手動介入；裝完 `ps -eo user,comm \| grep dockerd` 沒有 root；重開機 Agent 自起；主系統發更新後 Agent 換版成功 |
| P2 收口 | 手冊、資安報告、出貨包總驗 | README 兩節、SR-09.5 改寫、兩個 OS 的驗收證據頁 | 拿出貨包在 Ubuntu 24.04 與 Debian 12 各裝一次，`audit_nonroot_stack.sh --agent` 全 ✔，主系統派掃描收結果正常 |

**依賴**：決策者功課只有一件——A0 需要一台乾淨 Ubuntu 24.04 VM（有 TPM 2.0、能連 DEV 主系統）。主系統那套之後開 FR-193.1，照這份的做法抄。

## 1. 決策定案表 {#decisions nav="決策"}

| 編號 | 定案 | 理由 | 被排除 |
|---|---|---|---|
| D1 | 對外一律 8443，不貼能力、不改 sysctl；80 的 http 轉址拿掉 | PM 2026-11-10 裁：還沒出給客戶過，改預設沒有相容成本；省掉 capability 維護與主機掃描解釋 | 預設 443 貼 `rootlesskit` 能力（v1.0 路三）；全機 sysctl。**只影響主系統，本份不做，列入 FR-193.1** |
| D2 | ping sysctl 全開 `0 2147483647` | 與 Ubuntu／RHEL 預設相同；放寬的只有核心代填的 ICMP echo | 只開 `guidant` 子 gid 區段（每台數字不同、客戶會蓋掉） |
| D3 | 網路驅動用預設 `slirp4netns` | Agent 流量單向上傳、對吞吐不敏感；A0 留數字 | `pasta`（實驗性，吞吐不夠才換）；`bypass4netns`（要自訂 seccomp） |
| D4 | 出貨包含 Docker Engine 完整一組，install.sh 三段式 | 客戶拿到包就能裝比省 500 MB 重要 | 只包 rootless 附加三支 |
| D5 | 帳號名預設 `guidant`，設定檔 `GUIDANT_RUN_USER` 可改；已存在就沿用並檢查三項 | 有些客戶帳號命名有規範；撞名不能死 | 固定名稱直接 `useradd` |
| D6 | 三支升級 systemd 單元仍以 root 跑，只有 docker 操作切 `guidant` | staging 目錄只有 root 進得去、簽章驗證用的是 root 持有的現行版執行檔，整支改 user unit 會失去這層隔離 | 整支改成 `guidant` 的 user unit |
| D7 | 不留 root Docker 相容路徑 | 沒有舊客戶（FR-180 同一判準） | 偵測模式雙路徑 |

## 2. 現況與目標 {#now nav="現況"}

### 2.1 三層身分

| 層 | 現況 | 目標 |
|---|---|---|
| 容器裡的程式 | uid 1000（FR-180 做完） | 不動 |
| Docker 引擎 | 主機 root | 主機帳號 `guidant` |
| 容器 uid 在主機上的實際身分 | 1000 就是 1000 | 子 uid 起點 + 1000（如 101000），每台不同 |

### 2.2 現況接入點盤點

| 元件 | 現況 | 本案動作 |
|---|---|---|
| `deploy/install.sh` `check_environment()` | 只查 docker 在不在、版本、compose plugin | 加 cgroup v2／systemd／核心版本；Docker 三段式（沒有→從包裝；夠新→沿用補附加；太舊→停） |
| `deploy/install.sh` `require_root()` | 全程 root | 不變（安裝程式整支仍 sudo 跑），但 docker 操作內部切身分 |
| `deploy/install.sh` `dc()` | `docker compose --env-file … -f …` | 加 `sudo -u guidant` 與 `DOCKER_HOST`；另補 `dk()` 包 77 處 bare docker |
| `deploy/install.sh:2333` `chown 1000:1000` | seaweedfs 私鑰 | 走換算 |
| `lib/upgrade_order.sh` `_UO_APP_ID=1000`、`:91` chown | 升級單目錄 | 走換算 |
| `lib/local_admin.sh:28` `AGENT_UID:-1000` | volume 修 owner | 走換算 |
| `lib/secrets.sh` `SEC_RUN_DIR` 700 | `/run/guidant-agent` root 700 | 750 `root:guidant`、`.env` 640 |
| `lib/systemd/*.service` 三支 | root、`After=docker.service` | 仍 root；`After` 改等 `user@<uid>.service`；內部 docker 走 `dk()` |
| `deploy/docker-compose.yml:162` `cap_add: [NET_RAW]` | 給 ping | 拿掉（rootless 下無效）；ping 靠 sysctl |
| `netdiag/adapters/probes/traceroute.py:30` `-I` | ICMP 模式 | 依 A0 實測：不通就不加 `-I` |
| `scripts/build/build_agent_bundle.sh` | 帶 tpm2 debs | 多帶 Docker 一組（`fetch_docker_debs.sh`） |
| 主機 OS | 無 | 六項設定（§3.1） |

## 3. 詳細設計 {#design nav="設計"}

### 3.1 主機準備層 `lib/rootless_host.sh`（A1）

install.sh 第①步環境檢查之後、寫設定之前呼叫 `rootless_host_prepare`，冪等，重跑無害。

1. **前提**：`stat -fc %T /sys/fs/cgroup` 要 `cgroup2fs`；`systemctl --version` 有輸出；`uname -r` ≥ 5.11。任一不符 `check_fail`。
2. **Docker 三段式**：沒有 docker → 從包 `debs/docker/<目標>/` 裝（deb 用 `dpkg -i` 加 `apt-get -f`，el-9 把目錄當本機 repo 給 dnf）；有且 `Server.Version` ≥ `MIN_DOCKER_MAJOR` → 只補 `docker-ce-rootless-extras`、`uidmap`、`slirp4netns`、`dbus-user-session`；太舊 → `fail` 印「請自行升級或移除後重跑」。裝完 `systemctl disable --now docker.service docker.socket`。
3. **帳號**（D5）：`GUIDANT_RUN_USER` 預設 `guidant`。不存在 → `useradd --system --create-home --shell nologin`。存在 → 查 subuid 有無（無則 `usermod --add-subuids`）、shell 是否 nologin（否則 warn 不改）、是否在 sudo／wheel 群組（是則 `fail`）。
4. **linger**：`loginctl enable-linger`。
5. **sysctl**：寫 `/etc/sysctl.d/90-guidant-rootless.conf` 一行 `net.ipv4.ping_group_range = 0 2147483647`，`sysctl --system`。
6. **cgroup 委派**：寫 `/etc/systemd/system/user@.service.d/delegate.conf`（`Delegate=cpu cpuset io memory pids`），`daemon-reload`；若 `guidant` 的 user manager 已在跑，`loginctl terminate-user` 讓它重起。
7. **啟用 rootless**：`machinectl shell guidant@ /bin/bash -c 'XDG_RUNTIME_DIR=/run/user/$(id -u) dockerd-rootless-setuptool.sh install && systemctl --user enable docker'`。沒有 `machinectl` 就裝 `systemd-container`。
8. **驗**：以 `dk info --format '{{.SecurityOptions}} {{.CgroupDriver}}'` 要含 `rootless` 與 `systemd`，否則 `fail`。

### 3.2 docker 呼叫切身分（A3）

新增兩個共用函式，所有 docker 操作只准走它們：

```bash
RUN_UID="$(id -u "$GUIDANT_RUN_USER")"
dk()    { sudo -u "$GUIDANT_RUN_USER" XDG_RUNTIME_DIR="/run/user/$RUN_UID" DOCKER_HOST="unix:///run/user/$RUN_UID/docker.sock" docker "$@"; }
dc()    { dk compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"; }
```

陷阱：`sudo -u` 不帶起 user manager，但 daemon 已由 linger 常駐，所以這裡用 `sudo -u` 足夠；只有 `dockerd-rootless-setuptool.sh install` 那一次要 `machinectl shell`。`ENV_FILE` 在 `/run/guidant-agent`，`guidant` 要讀得到（A4）。守衛：`scripts/install/lib/README.md` 加一行規則，並補一支 `test/check_no_bare_docker.sh` grep 整個 install 樹。

### 3.3 uid 換算 `lib/subuid_map.sh`（A4）

```bash
subuid_base() { awk -F: -v u="$GUIDANT_RUN_USER" '$1==u{print $2; exit}' /etc/subuid; }
host_uid_for() { echo $(( $(subuid_base) + $1 )); }   # host_uid_for 1000 → 101000
```

三處 chown 改成 `chown "$(host_uid_for 1000):$(host_uid_for 1000)"`。`local_admin.sh` 走 `docker run -u 0` 在容器內 chown 1000 的那條不用改（容器內看到的仍是 1000，對映由引擎做）。

`/run/guidant-agent`：`secrets.sh load` 結尾 `chown root:$GUIDANT_RUN_USER`、`chmod 750`、`.env` 640。這是 tmpfs 每次開機重建，所以放在 load 裡不放 install。

### 3.4 升級單元（A5、D6）

三支單元維持 `User=root`。`After=docker.service` 改 `After=user@$RUN_UID.service`（install 時代入）。`upgrade_order.sh` 第 0 步的 `docker load`／`docker tag`／`_uo_tags` 與 `upgrade_runner.sh` 的 `dc_up`／`docker images` 全走 `dk()`。staging 目錄仍 root 700，解包、驗簽在 root 做完才交給 `dk load`。

### 3.5 出貨包（A2）

`fetch_docker_debs.sh` 照 `fetch_tpm2_debs.sh`：同一組目標 `ubuntu-22.04 ubuntu-24.04 debian-12 el-9`，在對應版本最精簡容器加 Docker 官方 repo 後 `apt-get install --download-only`／`dnf download --resolve`，輸出 `debs/docker/<目標>/`。釘版寫在腳本頂部一個變數，bump 走 commit。平台固定 `linux/amd64`。

## 4. 主機六項設定一覽 {#host nav="主機設定"}

| # | 設定 | 落在哪 | 全機生效 | uninstall 還原 |
|---|---|---|---|---|
| 1 | `guidant` 帳號＋子 uid 65536 | `/etc/passwd`、`/etc/subuid`、`/etc/subgid` | 多一個帳號 | `userdel -r` |
| 2 | linger | `loginctl` | 否 | `disable-linger` |
| 3 | Docker Engine＋rootless 附加套件 | 套件 | 是 | 不移除（客戶可能另有用途），印提示 |
| 4 | `ping_group_range` | `/etc/sysctl.d/90-guidant-rootless.conf` | 是 | 刪檔、`sysctl --system` |
| 5 | cgroup 委派 | `/etc/systemd/system/user@.service.d/delegate.conf` | 是 | 刪檔、`daemon-reload` |
| 6 | 資料目錄 owner、`/run/guidant-agent` 權限 | `/srv/guidant-ai-agent`、tmpfs | 否 | 隨資料目錄 |

## 5. 不做什麼 {#out nav="不做"}

- 主系統那套（對外改 8443、四支單元、資料庫目錄 owner）→ FR-193.1，等 Agent 這輪踩完坑再開。
- 容器內任何改動、image 重打（FR-180 的成果沿用）。
- 多 Agent 同主機、非 x86_64。
- `pasta` 驅動切換（A0 量出吞吐不夠才開卡）。

## 6. 拆卡表 {#cards nav="拆卡表"}

批＝可同時開 session 的一組；階段＝給 PM 看的分組。由 `handoff/cards.py` 產生並驗過依賴與撞檔。A8 在 BE repo（資安報告），其餘在 Agent repo。

| 卡 | 階段 | 批 | Repo | 做什麼 | reuse／extend／new＋理由 | 改哪些既有檔 | 新開檔 | 依賴 | 驗收 | Model／Effort |
|---|---|---|---|---|---|---|---|---|---|---|
| A0 | P0 | 1 | agent | 乾淨機手動跑 rootless：列壞掉清單、量吞吐、驗 D2 可行性 | **new**：第一棒不寫程式；後面的卡按實測清單校正，不按推測開 | — | `docs/features/FR-193-2611-docker-rootless/evidence/agent-rootless-probe.md（BE repo，證據）` | — | 乾淨 Ubuntu 24.04 以 guidant 帳號 rootless Docker 手動裝現有 Agent 包，逐步記錄壞在哪、怎麼修；量上傳 100MB 秒數；驗 traceroute -I 與 ping；十二項驗證清單各項結果；產出證據頁 | opus／high |
| A1 | P1 | 2 | agent | rootless 主機準備層：建帳號、子 uid、linger、sysctl、cgroup 委派、附加套件；三段式 Docker 安裝 | **new**：現有 install.sh 完全沒有這層，獨立一支 lib 由 install.sh source；Docker 三段式判斷放這裡 | `deploy/install.sh（check_environment／write_config 段）` | `scripts/install/lib/rootless_host.sh` | A0 | 乾淨機 sudo ./install.sh --check-only 列出六項主機設定狀態；實裝後 guidant 帳號、subuid、Linger=yes、ping_group_range、delegate.conf、docker info 顯示 rootless 全部符合；已有新版 Docker 的主機不被動到；太舊 Docker 停下印訊息 | opus／high |
| A2 | P1 | 2 | agent | 出貨包帶 Docker Engine 完整一組（每個 OS 版本一份） | **extend**：照 fetch_tpm2_debs.sh 先例，目標清單與 tpm2 同一組；build_agent_bundle 多一步呼叫 | `scripts/build/build_agent_bundle.sh` | `scripts/build/fetch_docker_debs.sh` | A0 | 四個目標目錄各有 docker-ce／cli／containerd／compose-plugin／rootless-extras／uidmap／slirp4netns 與相依；el-9 含 repodata；在對應版本乾淨容器內離線 dpkg -i／dnf 裝得起；包體積增量寫進 README | sonnet／medium |
| A3 | P1 | 3 | agent | docker 呼叫全部切 guidant 身分：共用函式、require_root 拆段、77 處 bare docker 收攏 | **extend**：dc() 已是共用入口，補一支 dk() 包 bare docker 並以 guidant socket 執行；不逐行改身分 | `deploy/install.sh`、`scripts/install/lib/upgrade_order.sh`、`scripts/install/lib/upgrade_runner.sh`、`scripts/install/lib/upgrade_guard.sh`、`scripts/install/lib/local_admin.sh`、`scripts/install/lib/image_verify.sh`、`scripts/install/lib/enroll.sh` | — | A1 | grep 整個 install 樹找不到未經 dk()／dc() 的 docker 呼叫（守衛腳本）；以 root 跑 install.sh 起來的容器 docker top 無 root、owner 是 guidant；status／logs／start／stop／restart 五個子命令正常 | opus／high |
| A4 | P1 | 3 | agent | chown 1000 全部換算成子 uid；/run/guidant-agent 讓 guidant 讀得到 | **new**：換算函式獨立一支讓 A3 之外的檔也能用；secrets 服務解完改 750 root:guidant | `scripts/install/lib/secrets.sh`、`scripts/install/lib/secrets_cmds.sh`、`scripts/install/lib/systemd/guidant-agent-secrets.service` | `scripts/install/lib/subuid_map.sh` | A1 | install.sh 2333、upgrade_order.sh 91、local_admin.sh 28 三處 chown 走換算；/run/guidant-agent 開機後 750 root:guidant、.env 640；容器能寫 upload／agent-order；重開機後 Agent 自起 | sonnet／medium |
| A5 | P1 | 4 | agent | 升級與回滾全程 rootless：三支 systemd 單元內的 docker 切身分、從舊版帶資料升級重驗 | **extend**：單元仍以 root 跑（staging 目錄與簽章驗證要 root），裡面 docker load／tag／compose 走 A3 的 dk() | `scripts/install/lib/upgrade_order.sh`、`scripts/install/lib/upgrade_runner.sh`、`scripts/install/lib/systemd/guidant-agent-upgrade-order.service`、`scripts/install/lib/systemd/guidant-agent-upgrade-boot-check.service` | — | A3、A4 | 主系統發更新指令 → Agent 寫單 → 主機單元接手 → 新版起來；中途拔電重開機 boot-check 退回舊版；進度檔與 log.gz 上傳正常 | opus／high |
| A6 | P1 | 4 | agent | compose 拿掉 NET_RAW；traceroute ICMP 模式依 A0 實測結果調整；uninstall 補還原 | **extend**：三件小事同一人做：都是 A0 清單上的收尾項 | `deploy/docker-compose.yml`、`guidant_agent/netdiag/adapters/probes/traceroute.py`、`deploy/install.sh（cmd_uninstall 段）` | — | A3、A4 | compose 無 cap_add；netdiag 的 ping／traceroute／mtu 在 rootless 容器內跑出結果；uninstall 後 guidant 帳號、sysctl 檔、delegate.conf、linger 全清，grep 無殘留 | sonnet／medium |
| A7 | P2 | 5 | agent | deploy/README 與安裝手冊：裝機改了哪些主機設定、IT 怎麼看容器、常見錯誤 | **extend**：以 Claude Docs 那份 Agent 手冊為底稿，拿掉過渡期手動段 | `deploy/README.md` | — | A5、A6 | README §1 前置需求補 cgroup v2／systemd；新增「rootless 之後 docker ps 為何是空的」「裝機改了六項主機設定」兩節；常見錯誤表併入 §10 | sonnet／medium |
| A8 | P2 | 5 | main | 資安報告 SR-09.5 改寫為「Agent 端已採用 rootless」，主系統待續 | **extend**：原段落「不採用」三層表改成 Agent 已做第三層、主系統預計；ping sysctl 與 capability 的資安說明放這 | `docs/security-report/requirements/SR-09-agent.md` | — | A5、A6 | SR-09.5 讀起來是現況不是沿革；grep 原本／改判／第 N 棒 無命中 | sonnet／medium |
| A9 | P2 | 6 | agent | 乾淨機用出貨包全流程總驗：新裝、重開機、升級、回滾、uninstall | **new**：拿 A2 出的真包在乾淨 Ubuntu 24.04 與 Debian 12 各跑一次，十二項驗證清單＋audit_nonroot_stack.sh --agent | — | `docs/features/FR-193-2611-docker-rootless/evidence/agent-rootless-acceptance.md（BE repo，證據）` | A2、A7 | 兩個 OS 各一份證據頁；audit_nonroot_stack.sh --agent 全 ✔ 且主機 ps 無 root dockerd；主系統端收到心跳、派掃描、收結果正常 | opus／high |

## 7. 端到端驗收（結案時首腦操作） {#e2e nav="驗收"}

1. 拿 A2 出的真包，乾淨 Ubuntu 24.04 與 Debian 12 各一台（有 TPM），`sudo ./install.sh` 到底，中間不手動介入。
2. `scripts/build/audit_nonroot_stack.sh --agent` 全 ✔；`ps -eo user,comm | grep -E 'dockerd|containerd'` 無 root。
3. 手冊第 5 節十二項逐項過。
4. 主系統核准 Agent → 派一次掃描 → 收到結果；網路診斷 ping／traceroute／mtu 三項有值。
5. 主系統發 Agent 更新 → 新版起來；中途拔電重開 → boot-check 退回舊版。
6. `uninstall` 後 `grep guidant /etc/passwd /etc/subuid` 空、sysctl 回預設、無 dockerd。

## 8. 相對 v1.0 改了什麼 {#delta nav="v1.1 差異"}

**只改 D1**。PM 2026-11-10 裁示：主系統對外一律 8443，不走「預設 443 貼能力、客戶可切」的路三。理由是產品還沒出給客戶，改預設沒有相容成本；省下 `rootlesskit` capability 的升級重貼與主機掃描報告解釋。

這條只影響主系統（FR-193.1），Agent 這輪十張卡的內容與批次完全不變。FR-193.1 開卡時要涵蓋：

| 要改 | 在哪 | 說明 |
|---|---|---|
| 預設值 | `scripts/installer/install.sh:102-103`、`install.conf.example:35-36`、`docker/production/docker-compose.yml:549-550` | HTTPS 預設 8443；HTTP 80 整條拿掉（80 也是低埠） |
| 網址判斷 | `install.sh:2671` `if GUIDANT_HTTPS_PORT == 443` | 刪掉分支，`SITE_URL` 一律帶 port |
| http 轉址 | FE `nginx.onprem.conf:134-137` 的 8080 server 段 | 拿掉；compose 不再映射 80 |
| Agent 註冊網址 | Agent `install.sh` 問主系統網址那題 | 預設值範例改 `https://<主機>:8443`，驗證接受帶 port |
| 手冊 | `docs/user-manual/` 八份寫 `https://<主機>` 的頁、`docs/spec-site/current/` | 全改帶 `:8443` |
| 測試 | test repo `e2e-env/` | 映射改 8443 |
| 客戶防火牆說明 | 安裝手冊前置需求 | 放行 8443 取代 443 |
