一句话结论
五台机器(micro / soft / arm / max2 / v100)已全部收编为单栈 rootful。收编后落盘属主的基线是「容器 user: = 登录 uid」,所以 业务数据天然属于登录用户,sync 直接可读;真正需要单独处理的是三类散落物:必须 root 才能跑的容器写出的 600 文件(tailscale state、mihomo ruleset、certbot 备份)、rootless 时代残留的 subuid 目录、以及0 字节的下载锁。处置分工是:容器能 pin user 的 pin(A)→ 机密/易变 state 直接排出同步面(C)→ 只有「必须 root 写、内容又不机密」的静态目录才用读端 ACL 放行(B)。永远不用 root 跑 sync。
初衷(为什么从双模回退单栈)
| 动机 | 双栈下的具体问题 | 收编后 |
|---|
| 数据归属不分裂 | 两个 daemon 两套 store、两套 uid 映射:rootless 容器内 root = 宿主登录 uid,rootful 容器内 root = 真 root。同一批数据在两边的属主语义完全不同,rootless 写出的文件在宿主上表现为 subuid(实测 100998 / 100999 / 100032) | 只剩一套映射,落盘属主 = 容器 user:,业务容器统一登录 uid |
| 运维只有一条真相 | d/dc 到底打哪个 daemon 要看 DOCKER_HOST 与环境;探针(cAdvisor)也得跟着改 socket;镜像/卷/网络两边各一份 | 一个 dockerd,默认 socket,探针和备份都只有一条路径 |
| 能力缺口消失 | rootless 无 device cgroup(GPU 只能 bind /dev/nvidia*)、无 privileged、host 网络与 TUN 受限、绑 <1024 端口要 rootlesskit 代理 | 回 devices: 正规写法,privileged/host 网络随意 |
| 备份可读性 | rclone/sftp 以登录用户身份读:subuid 属主的文件读不到,只能 ACL 或 chown 兜 | 属主就是登录用户,默认可读 |
| 磁盘 | 双栈时同一份基础镜像在两个 store 各存一份(实测 v100 上 base 13 层 7.01GB 双向复制),迁到 rootless 后 / 净增 +7.4GB | 单份;本次收编后 v100 / 回到 175G 用 / 33G 余 |
| 性能不是理由 | 实测 v100 同一镜像 rootful 46.5/48.8/46.5 tok/s,rootless 45.7/48.2/46.9 tok/s —— 端口转发没有可测损耗 | 收编后同机复测 51.9/53.1/50.9 tok/s |
前置事实(实测)
| 机器 | 规格 | 容器(rootful) | 登录用户 / uid | 特权方式 |
|---|
| micro | 2 核 / 974964 kB | 1(noise) | ghost / 1001 | ghost 在 docker 组 |
| soft | 2 核 / 974964 kB | 8(nginx / mariadb / git / trilium / ghost / webdav / mindwtr / noise) | ghost / 1001 | 同上 |
| arm | 4 核 / 24548032 kB | 7(nginx / open-webui / searxng / oauth / hermes-agent-gx / frps / noise) | ghost / 1001 | 同上 |
| max2 | 16 核 / 56043 MiB | 6(clash / clash-ui / tailscale / mariadb / hermes-cron / rclone) | cat / 1000 | cat 在 docker 组(gid 131) |
| v100 | 28 核 / 39G,/ 218G | 13(llama / llama-frpc / mariadb / git + clash / clash-ui / tailscale + grafana ×6) | x99 / 1000 | x99 在 docker 组;root 通道 = opc(NOPASSWD) |
GPU 相关:v100 双卡 Tesla V100-SXM2-16GB,驱动 580.126.20,容器里靠 6 个 /dev/nvidia* 节点 + 三个驱动库(libcuda / libnvidia-ml / libnvidia-ptxjitcompiler)指向 *.so.580.126.20,没有 nvidia-container-runtime。
实现过程
统一口径
- B 模式特权:登录用户进 docker 组,
d/dc 免 sudo(代价是宿主 root 等价,已知并接受)。 - 容器
user: = 登录 uid(max2/v100 是 1000,VPS 三台是 1001),落盘属主天然等于备份账号。 - 业务容器删掉
cpus,只留 memory limit;cpus 是绝对核数不是百分比,CFS quota 一触发就是硬停,在小机上实测有害(soft mariadb 有 51% 周期被 throttle)。只有 noise 保留 cpus。
micro / soft / arm
- 本来就是 rootful,做的是收尾三件:确认 ghost 在 docker 组、把容器
user: 对齐 1001、清掉业务容器 cpus。 - 边角权属:searxng 的 3 个文件由 entrypoint 自己 chown 成 977,属正常。
max2(NixOS,声明式)
modules/system/service/docker.nix 删掉 rootless.enable / setSocketVariable 整块与 environment.sessionVariables.DOCKER_HOST,nixos-rebuild switch --description ... 落地(同时停掉 user unit docker.service)。- 镜像先用
docker save | docker load 全部搬到 rootful(19 个镜像,不重新下载),再 chown -R 1000:1000 mariadb/hermes-cron/rclone 的 data 与 config,最后 rootful up -d。 - 回收
~/.local/share/docker 30GB;cat 由 configuration.nix 加入 docker 组(该列表不能在两个文件里重复定义)。 - bashrc 保留
rd/rc/dc 与自动适配逻辑(用户明确要求不删)。
v100(07:00 定时 runbook,无人值守)
- 镜像:
docker save rootless 侧 3 个镜像管道进 rootful,15.4GB 用时 9.3 分钟;空间系数实测 1.07×(15.4GB 镜像占 store 16G),不是先前估计的 2×。 - 卷:
strata-cache / strata-state 用 tar --numeric-owner 灌进 rootful 卷(先清空迁移前的陈旧同名卷),校验属主 1000 且 strata-state 为 700。 - compose:
user: "0:0" 回 "1000:1000"、tmpfs 两个挂载点 uid=0,gid=0 回 uid=1000,gid=1000(这一对必须同时翻,只翻一个会在引擎内部报错而不是挂载时报错);删 limits.cpus 与整个 reservations 段,保留 memory: 36G、shm_size: 4g、start_period: 600s。 - 设备用
devices: 不用 bind:S4 探针在切换前就暴露了 —— rootful 有 device cgroup,沿用 rootless 的 -v /dev/nvidia0 写法会 Failed to initialize NVML: Unknown Error;改成 devices: 后两卡正常。生产当时未动。 - 删 edge(全站 compose 无 80/443 发布,公网走 frpc →
llama:8080)。 - 拆 rootless:
systemctl --user disable --now docker、删 unit、docker context use default、rm -rf ~/.local/share/docker(16G,需 root)。 - bashrc 换成统一版(md5
69ced44d…,DOCKER_HOST 为空,d/rd/dc 都是 function)。 - 结果:停机窗口 2.6 分钟(07:27:57 → 07:30:27),重启后
/health 约 2 分钟、权重装载约 6 分钟;验收 healthy、restarts=0、公网 200×3、双卡显存 15572/15670 MiB。
权限归位(一次性,不反复做)
| 对象 | 改造前属主 | 改造后 |
|---|
v100 /www/mariadb/data/mariadb | 100998:100998(subuid) | 1000:1000 |
v100 /www/forgejo/data | 100999:100999(subuid) | 1000:1000 |
| max2 mariadb / hermes-cron / rclone 的 data 与 config | subuid(rootless userns) | 1000:1000 |
sync 与散落权限:现状、成因、方案
机制事实(决定方案边界)
- 通道:max2 本地
/s500data/docker/<实例> ↔ VPS :/www,rclone bisync,worker 是容器内的 rclone,以登录用户身份跑(不是 root);VPS 侧走 SSH 登录用户。 - bisync 不传属主:远端新建的文件属主永远是远端 SSH 登录用户,本地新建的永远是本地登录用户。所以「权限」在这里只决定一件事 —— 读得到、还是读不到;不会把 root 属主搬到对面去。
- 推论(重要):用 ACL 放行一个 root 600 文件后,它同步过去会变成登录用户可读的普通文件 —— 等于把「只有 root 能看」的东西交给备份账号。机密 state(
tailscaled.state、netmap 缓存)必须排除,不能靠 ACL 放行。 - 读端才是瓶颈:容器写的目录如果是
700/600 且属主不是登录用户,rclone 直接 SKIP。放行必须发生在读的那一端。
现状实测(属主分布)
| 镜像树 | 文件数(按属主) | 异常项 |
|---|
| micro | 8 × 1000 | 无 |
| soft | 2520 × 1000 | 2 × 100032:webdav/data/dav/ghost、webdav/data/dav/mindwtr(rootless 时代 subuid 目录,755 尚可读) |
| max2 | 3586 × 1000 | 11 × root:全在 tailscale/data/tailscale/(tailscaled.state 600、netmap-cache/* 600 ×6、derpmap.cached.json 600、dns-cache/*.json 600、files/<host>-uid-… 750 目录 ×2) |
| arm | 1416 × 1000 | 无(VPS 侧另有 3 × 977 searxng) |
| v100(本地镜像) | 5556 × 1000 | 1 × root:llama/data/models/…/huggingface/download/chat_template.jinja.lock(0 字节下载锁) |
v100(VPS /www) | 3396 × 1000 | 14 × root:mihomo/data/{ruleset,ruleset/*.yaml,cache.db,ASN.mmdb,sub-x99-*.yaml}、grafana/data/grafana/dashboards、llama/build/packages/strata-dsv41…、tailscale/data/tailscale/{tailscaled.state,files,log*.txt,derpmap.cached.json} |
三类散落物与来源
| 类别 | 典型文件 | 来源(为什么必须是 root) |
|---|
| root 600 运行时 state | tailscaled.state、netmap/dns 缓存、derpmap.cached.json、mihomo/data/cache.db | tailscale 容器 user: 为空(= root),需要 NET_ADMIN/TUN;mihomo 跑 host 网络 + TUN 改宿主路由 |
| root 静态文件/目录 | mihomo/data/ruleset/*、grafana/data/grafana/dashboards、certbot 备份 add.yml.bak-* | 由 root 运行的一次性任务(grafana provisioning、certbot)创建,之后容器仍以 root 读写 |
| subuid 残留 | 100032 的 webdav dav 目录、历史上的 100998/100999 | rootless 时代 userns 映射;chown 过的主体已归位,零星目录漏网 |
filter 现状:已排 vs 未排(实测 grep vps-docker-filter.txt)
| 对象 | 是否已在 filter | 现在实际行为 |
|---|
**/tailscaled.state、**/tailscaled.log*、tailscale/**/files/** | 已排 | 不同步(正解:节点钥匙属机密) |
certbot/data/**/privkey*.pem | 已排 | 不同步(丢了重签) |
tailscale/**/derpmap.cached.json、netmap-cache/**、dns-cache/** | 未排 | root 600 读不到 → 靠 SKIP 静默跳过(无日志噪音,但也无告警) |
mihomo/data/cache.db | 未排 | 同上,root 644 可读 → 实际会被同步(属普通缓存,可接受) |
mihomo/data/ruleset/**、grafana/data/grafana/dashboards | 未排 | root 但 755/644 可读 → 能同步;需要写回时才会撞权限 |
结论:真正卡住同步的只有一组 —— tailscale/data/tailscale/ 下的 600 缓存(derpmap / netmap-cache / dns-cache)。它们该走方案 C(补进 filter 明确排除,让行为从"静默 SKIP"变成"显式规则"),不该走 ACL。
可能方案与判据
| 方案 | 做法 | 代价 / 风险 | 适用 |
|---|
| A 容器侧 pin user | 能给 user: 的容器一律填登录 uid(现状主体已做);连 tmpfs 的 uid/gid 一起改 | 需要容器真能非 root 跑(GPU 引擎已验证可以) | 首选,覆盖绝大多数业务数据 |
| B 读端 ACL | plan.conf 的 SETFACL 段列父目录,执行前 sudo setfacl -R -m u:<syncuser>:rX -m d:u:<syncuser>:rX;新建子目录靠默认 ACL 继承 | 要维护清单;只加读权限(不改属主),失败只警告、靠 SKIP 兜底 | 必须 root 写、内容不机密的静态目录(dashboards、ruleset、certbot 备份) |
| C 排出同步面 | 写进 vps-docker-filter.txt 的 - 规则(现已有 mariadb 热文件、prometheus WAL、grafana.db、ghost.db、tailscaled.state 等) | 不同步 = 不能靠文件备份恢复;要靠应用层备份(dump / snapshot API) | 机密或在线易变的 state:tailscaled.state、derpmap.cached.json、netmap/dns 缓存、mihomo cache.db |
| D 一次性 chown 归位 | 对确认不再由 root 写的静态文件 chown -R 1000:1000 | root 容器重启会重造 600 root 文件 → 回潮;不可作为长期机制 | certbot 备份、确认已停用的 root 组件 |
| E root 跑 sync | root ssh / sudo rclone | 已裁决禁止:sync 一律用登录用户,放行只加 ACL | 不采用 |
推荐组合:A 打底(业务数据天然属登录用户)→ C 排掉机密/易变 state(tailscale 缓存、mihomo 缓存)→ B 只给「必须 root 写且不机密」的静态目录放行 → D 仅用于确认不再回潮的个别文件。不要用 B 去救 C 类文件:ACL 放行会让机密 state 变成备份账号可读并同步出去。
实测证据
| 判据 | 实测值 |
|---|
| v100 收编后进程/端口 | 1 个 dockerd;rootless store 已删;/ 175G 用 / 33G 余(85%) |
| v100 镜像空间 | 15.4GB 镜像 → rootful store 约 16G(1.07×),管道传输 9.3 分钟 |
| v100 停机窗口 | 2.6 分钟;/health 约 2 分钟可用,权重装载约 6 分钟 |
| v100 吞吐 | 收编前 rootful 46.5/48.8/46.5,rootless 45.7/48.2/46.9;收编后 51.9/53.1/50.9 tok/s |
| v100 卷属主 | strata-state = drwx------ 1000:1000 |
| cAdvisor 指向 | --docker=unix:///run/docker.sock、--docker_root=/var/lib/docker,target up,llama 内存/CPU 各 1 条序列 |
| max2 rootless 回收 | ~/.local/share/docker 30GB 删除;镜像 29 个仍在 rootful |
管理命令
| 用途 | 命令 |
|---|
| 确认在跟哪个 daemon 说话 | docker info --format '{{.DockerRootDir}}'(/var/lib/docker = rootful) |
| 属主审计(每台,找散落) | find /www -xdev -printf '%U\n' | sort | uniq -c | sort -rn |
| 列出非登录用户持有的文件 | find /www -xdev ! -user x99 -printf '%u:%U %m %p\n' |
| 读端 ACL 放行(在 sync 读的那一端跑) | sudo setfacl -R -m u:cat:rX -m d:u:cat:rX <目录> |
| 一次性归位 | sudo chown -R 1000:1000 <目录>(先确认不再由 root 写) |
| 卷搬运(保属主) | tar --numeric-owner -C src -cf - . | tar --numeric-owner -C dst -xf - |
| 镜像搬运(不重下) | docker --host <src> save IMG | docker --host <dst> load |
| sync 跑一次 + 看结果 | cd /s500data/docker/max2/rclone/sync && ./sync.sh <plan>,日志在 <plan>/logs/run_*.log |
踩坑记录
| 现象(原文) | 成因 | 改法 |
|---|
Failed to initialize NVML: Unknown Error | rootful 有 device cgroup,沿用 rootless 的 -v /dev/nvidia0 bind 写法 | 改 devices: 列表;驱动库与 nvidia-smi 仍用 bind |
探针里 nvidia-smi 报缺 libllama-server-impl.so | 镜像 ENTRYPOINT 把 nvidia-smi 当参数吞了 | --entrypoint nvidia-smi |
cAdvisor 容器长期 unhealthy,日志 wget: server returned error: HTTP/1.0 404 Not Found | dockerd 把自身 HTTP_PROXY 注入每个容器,busybox wget 不认 no_proxy,镜像自带 healthcheck 被代理到 clash | compose 里把 HTTP_PROXY/HTTPS_PROXY/http_proxy/https_proxy 置空值(保留 NO_PROXY) |
引擎报 runtime directory must be owner-only (mode 0700) | 卷搬运时属主变成 subuid,或 strata-state 权限不是 700 | tar --numeric-owner + 校验 ls -ldn 属主 1000、权限 700 |
sync 日志 forgejo/data/queues/common/MANIFEST-…: Update Create failed: permission denied | 该目录当时仍属 subuid 100999,SSH 登录用户 x99(1000) 写不进去 | chown 归位 1000:1000 后消失(本次收编已做) |
| Grafana 上 llama CPU/内存出现多条「残线」 | cAdvisor 的 container_label_com_docker_compose_config_hash 等标签随每次 compose up / 镜像重建而变,每次劈出新序列 | relabel 里 labeldrop 掉易变标签 + 把 id 固定为常量;再 delete_series 清历史 |
| 预期「load 大镜像要 2 倍空间」 | 未实测的估计 | 实测 1.07×;空间按 df -B1 前后差值判,不按倍数猜 |
改了 bashrc 删掉 rd/rc | 误判「单栈后不再需要」 | 用户裁定:脚本本就是自动适配的,保留,恢复原版(md5 69ced44d…) |
不做的事(用户裁定)
- 不用 root 跑 sync(root ssh /
sudo rclone 都不行);放行只加 ACL,放行位置在读的那一端。 - 不为了腾空间动热模型盘:
models_sata 是故意放在 SATA SSD 上给引擎提速的,挪到机械盘就掉速。 - mariadb 的
memory: 256M 保留:刻意设的低内存,与宿主内存大小无关,禁止顺手放宽。 - 业务容器不再用
cpus:cpus 是绝对核数,不做百分比映射;只 noise 保留。 - 备份与回滚点用完即清:
.bak 长期留在活的 compose 旁边视为缺陷。 - git 提交/合并一律等用户明确下令,不自动 commit/push/merge。
边界
- 本档不覆盖 cAdvisor 的
container_fs_usage_bytes:containerd 镜像库模式下该指标为空,属已知限制,不是配置错。 - bisync 的冲突策略按各
plan.conf(infra 树 STOP),本档不展开。 - 属主审计是逐台跑的采样(
find -printf '%U'),数字随容器运行会变;结论以「非登录用户条目」为准,不以绝对计数为准。 - VPS 侧
/www 的 root 文件清单是 2026-11-11 的一次实测快照,新增 root 组件后需重跑审计。
相关笔记
- 「服务:docker的rootful和rootless困惑」
915vFpZhWb2s —— 两个 daemon 的差异对照与判据(本档是同一条线的「收编结局」) - 「服务:docker镜像源」
FZn3M2pZNNfi