服务:docker 收编单栈 rootful —— 初衷、实施与 sync 权限方案

一句话结论

五台机器(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特权方式
micro2 核 / 974964 kB1(noise)ghost / 1001ghost 在 docker 组
soft2 核 / 974964 kB8(nginx / mariadb / git / trilium / ghost / webdav / mindwtr / noise)ghost / 1001同上
arm4 核 / 24548032 kB7(nginx / open-webui / searxng / oauth / hermes-agent-gx / frps / noise)ghost / 1001同上
max216 核 / 56043 MiB6(clash / clash-ui / tailscale / mariadb / hermes-cron / rclone)cat / 1000cat 在 docker 组(gid 131)
v10028 核 / 39G,/ 218G13(llama / llama-frpc / mariadb / git + clash / clash-ui / tailscale + grafana ×6)x99 / 1000x99 在 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/mariadb100998:100998(subuid)1000:1000
v100 /www/forgejo/data100999:100999(subuid)1000:1000
max2 mariadb / hermes-cron / rclone 的 data 与 configsubuid(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。放行必须发生在读的那一端。

现状实测(属主分布)

镜像树文件数(按属主)异常项
micro8 × 1000无
soft2520 × 10002 × 100032:webdav/data/dav/ghost、webdav/data/dav/mindwtr(rootless 时代 subuid 目录,755 尚可读)
max23586 × 100011 × root:全在 tailscale/data/tailscale/(tailscaled.state 600、netmap-cache/* 600 ×6、derpmap.cached.json 600、dns-cache/*.json 600、files/<host>-uid-… 750 目录 ×2)
arm1416 × 1000无(VPS 侧另有 3 × 977 searxng)
v100(本地镜像)5556 × 10001 × root:llama/data/models/…/huggingface/download/chat_template.jinja.lock(0 字节下载锁)
v100(VPS /www)3396 × 100014 × 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 运行时 statetailscaled.state、netmap/dns 缓存、derpmap.cached.json、mihomo/data/cache.dbtailscale 容器 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/100999rootless 时代 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 读端 ACLplan.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:1000root 容器重启会重造 600 root 文件 → 回潮;不可作为长期机制certbot 备份、确认已停用的 root 组件
E root 跑 syncroot 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 Errorrootful 有 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 Founddockerd 把自身 HTTP_PROXY 注入每个容器,busybox wget 不认 no_proxy,镜像自带 healthcheck 被代理到 clashcompose 里把 HTTP_PROXY/HTTPS_PROXY/http_proxy/https_proxy 置空值(保留 NO_PROXY)
引擎报 runtime directory must be owner-only (mode 0700)卷搬运时属主变成 subuid,或 strata-state 权限不是 700tar --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