目标与现状
把一台全新安装的 Oracle Ubuntu 24.04 minimal 实例改造成纯 docker 宿主机:不做别的事,不装监控,rootful 与 rootless 双栈并存,并由它接管原站点的全部容器与固定公网 IP。全过程脚本化为 optimize-ubuntu.sh,三种模式(--check 只读预检 / 无参数实跑 / --verify 只断言),幂等可反复执行。
终态:一个 optimize-<机名>.ini 描述目标机与两把钥匙,一条命令完成「精简 → docker 全栈 → rootless 前置 → 家目录命令集 → 13 项验收」,任何一步失败即 FATAL 退出,不做兜底。
前置事实(实测)
| 项 | 实测值 |
| 发行版 | Ubuntu 24.04.5 LTS minimal(Oracle 官方镜像) |
| 机型 | VM.Standard.E2.1.Micro,1 核 / 952M 内存 / 50G 引导卷 |
| 内核级限制 | kernel.apparmor_restrict_unprivileged_userns = 1(Ubuntu 23.10+ 默认,rootless 命门) |
| 用户命名空间上限 | user.max_user_namespaces = 1212 |
| 镜像自带防火墙 | /etc/iptables/rules.v4:INPUT 仅放行 22,其余 -j REJECT |
| 自带 snap | 3 个:core18 / snapd / OCI 云代理,其中 core18 被云代理占用 |
| 运维账号 | 两个:特权账号(免密 sudo,只干 root 活)+ 日常账号(sudo 要密码,rootless 跑它名下) |
| 安装后占用 | docker 全栈约 3.5G;迁入整站数据后 13G / 48G |
实现过程
实例创建
- 镜像选 Ubuntu 24.04 Minimal,引导卷 50G,与既有实例同一子网——安全列表随子网继承,不另建规则。
- cloud-init
user-data 建两个账号:特权账号 sudo: ALL=(ALL) NOPASSWD:ALL;日常账号 sudo: ALL=(ALL) ALL(要密码)。两账号各写自己的 ssh_authorized_keys,不给日常账号免密提权。
- 公网 IP 先分配临时 IP 供安装期使用,站点验证通过后再把保留 IP 转过来。
系统精简
- snap 整体清除(含云代理快照):先按轮次删应用快照、再删 base 快照,最后
apt-get purge snapd + 删 /var/lib/snapd、/snap。
- 关服务:
unattended-upgrades、apport、ModemManager、udisks2、rpcbind(含 socket)、getty@tty1。
- 关计时器:
apt-daily*、motd-news、fwupd-refresh、e2scrub_all、dpkg-db-backup。
- 保留:
iscsid(云引导卷)、serial-getty(串口救援)、fstrim(SSD)、cloud-init 网络。
journald SystemMaxUse=100M;swapfile 2G(已有活动 swap 则整段跳过);收尾 autoremove --purge + clean。
内核参数与宿主防火墙
| 键 / 端口 | 值 | 为什么 |
kernel.apparmor_restrict_unprivileged_userns | 0 | 不关掉 AppArmor 对非特权用户命名空间的限制,rootless daemon 直接起不来 |
vm.swappiness | 30 | 小内存机型别急着换出 |
net.ipv4.ip_forward | 1 | 容器网络转发 |
fs.inotify.max_user_watches / max_user_instances | 524288 / 8192 | 多容器同时监听文件不撞上限 |
| INPUT 放行 TCP | 80 / 443 / 62222 / 63306 | 边缘转发与端口直通;云安全列表放行 ≠ 能访问,宿主这一层必须同步放行 |
sysctl 落 /etc/sysctl.d/99-docker-host.conf;端口写进镜像自带的 /etc/iptables/rules.v4(iptables-persistent 开机复原),并同时用 iptables -I INPUT 1 立即生效,不 flush 现网规则。
docker 宿主化
- 官方源 + 全栈:
docker-ce / docker-ce-cli / containerd.io / buildx / compose-plugin / docker-ce-rootless-extras / slirp4netns / fuse-overlayfs / uidmap / dbus-user-session。
daemon.json:json-file 日志 10m×3 + live-restore + overlay2(与既有机型一致,日志不会吃满小盘)。
- apt 全部带
-o DPkg::Lock::Timeout=600:新机首启 cloud-init 会占 dpkg 锁。
rootless 改造(四个硬前提)
| 前提 | 做法 | 失败表现 |
| uid 映射 | uidmap 包 + 日常账号在 /etc/subuid 有行(useradd 建的才有) | setuptool 报缺 newuidmap |
| 常驻 | loginctl enable-linger <账号> | 无人登录时 rootless daemon 不起 |
| cgroup 下放 | /etc/systemd/system/user@<UID>.service.d/delegate.conf 写 Delegate=yes | 带 cpus: 的容器报 NanoCPUs can not be set |
| 安装 | dockerd-rootless-setuptool.sh install --force | rootful 在跑时 setuptool 默认拒绝 |
之后断言 /sys/fs/cgroup/user.slice/user-<UID>.slice/cgroup.controllers 含 cpu;不含就 systemctl restart user@<UID> 一次(linger 会抢在 drop-in 之前把 user manager 拉起,导致 Delegate 不生效——这是竞态,不是偶发)。再建 rootless 网络、把 CLI context 复位回 default(setuptool 会偷偷切成 rootless,裸 docker 行为因此漂移)。
bashrc 改造
- 把跨机命令集整篇写入日常账号的
~/.bashrc(原文件备份为 ~/.bashrc.bak-optimize):d/dc 走 rootless socket,rd/rdc 走 rootful socket,严格不互相回落。
- 文件顶部先
unalias d dc rd rdc reload:旧 alias 残留会在函数定义解析时把 d() { 展开成语法错误——unalias 必须在任何复合命令之前,写在 if 体内没用(bash 先整块解析后执行)。
- 配套脚本落家目录并加执行位:
mem.sh(单文件双模式:source 得函数 / 执行跑主流程)、dockersave.sh。
- 脚本用
ssh ... 'bash -s' < 本地文件 送远端执行,不走多层引号拼接(本地 set -u 会把远端 $VAR 提前吞掉)。
站点迁移与固定 IP 交接
- 数据:同 VCN 私网直传
tar(--acls --xattrs --same-owner 保留 subuid 属主),实测 38MB/s,5.4G 用 2 分 49 秒;文件数与源机逐一相等。跨公网中转实测不到 0.7MB/s,不要用。
- 镜像:不重新 pull,
docker save | ssh | docker load(用户裁定:ghcr 等源太慢)。
- 固定 IP 转移三步(顺序保证每步后仍能远控):① 删新机临时公网 IP → ② 把保留 IP 从旧机 private-ip 改绑新机 → ③ 给旧机补一个新临时 IP。同子网安全列表自动继承。
- 边缘转发:纯端口转发容器改
network_mode: host 直连 127.0.0.1,不挂在网桥上——bridge 形态下 host.docker.internal 指向 docker0(NO-CARRIER),在新系统上会 EHOSTUNREACH。
- DNS 切换、六域名验收通过后才销毁旧机(
/www 先删,再连引导卷一起销毁,释放额度)。
实测证据
| 判据 | 实测 |
| rootful daemon | 29.9.3(server 版本与 CLI 一致) |
| rootless daemon | 29.9.3 |
| rootless 功能 | docker run --rm hello-world 通过(userns 真跑通,不是只看 socket) |
| apparmor userns | 0 |
| cgroup 控制器 | cpuset cpu io memory pids(cpu 已下放) |
| snap | 0 个,snap 命令不存在 |
| 内存 | used 384M / available 567M(952M 机器,双 daemon 常驻) |
| 磁盘 | 安装完成 3.5G/48G;整站迁入后 13G/48G |
| 站点验收 | 笔记 302 / 代码库 200 / 任务 404 / 文件 401 / 静态 200 / 首页 200 |
| 端口直通 | 62222 / 63306 均为 OPEN |
| 重启韧性 | 整机重启后 13 项验收全自动恢复(rootless 由 linger 自起,不依赖登录) |
管理命令
| 用途 | 命令 |
| 只读预检(两账号连通性 + 远端现状) | ./optimize-ubuntu.sh --check optimize-<机名>.ini |
| 实跑(幂等,可反复) | ./optimize-ubuntu.sh optimize-<机名>.ini |
| 只断言 13 项验收 | ./optimize-ubuntu.sh --verify optimize-<机名>.ini |
| 看实跑日志 | tail -30 logs/optimize-<机名>-<时间>.log |
| 扩新机 | cp optimize-<机名>.ini optimize-<新机>.ini 改五键(host / user / user_key / sudo_user / sudo_key) |
| 换命令集来源目录 | BASHRC_DIR=<目录> ./optimize-ubuntu.sh <ini> |
| rootless daemon 状态 | systemctl --user is-active docker.service |
| 区分两条栈 | ss -tlnp | grep -E ':80 |:443 |:8080 |:8443 ';rootful 的 docker-proxy 与 rootless 的 rootlesskit 会同时出现 |
| 宿主放行端口是否在 | iptables -S INPUT | grep dport |
命令为什么这样写:脚本只认 sudo 账号干 root 活、日常账号干 rootless,两条身份不混;所有远端执行走 ssh ... bash -s 的 stdin,避免引号层数与变量提前展开;验收一律断言而不是打印「完成」。
踩坑记录
| 现象(原文) | 成因 | 处置 |
| rootless daemon 起不来 / userns 相关报错 | Ubuntu 23.10+ 默认 apparmor_restrict_unprivileged_userns=1 | 置 0 并落 sysctl.d |
cannot remove "core18": snap is being used by ... | base 快照被应用快照依赖,按字母序删必失败 | 多轮重试到删不动,再 apt purge snapd |
Aborting because rootful Docker is running and accessible | setuptool 检测到 rootful | install --force |
docker.socket 不存在导致 enable 失败 | rootless setuptool 只建 docker.service | socket 有才 enable,不硬启 |
| rootful 探测结果为空 | 特权账号不在 docker 组 | 凡探 rootful 一律 sudo docker ... |
NanoCPUs can not be set, as your kernel does not support CPU CFS scheduler | user@UID 只拿到 memory/pids 控制器 | Delegate=yes drop-in + 断言 cgroup.controllers 含 cpu |
| 外部 443 表现为 TLS 握手 EOF,宿主自己 curl 正常 | 镜像自带 rules.v4 只放行 22,INPUT REJECT | rules.v4 补端口 + 立即 iptables -I |
connect(...): Host is unreachable(边缘容器 → 宿主) | bridge 形态下 host-gateway 指向 NO-CARRIER 的 docker0 | 边缘容器改 network_mode: host |
VERSION_CODENAME: 未绑定的变量(本地脚本报) | 本地 set -u 提前展开远端变量 | 远端脚本走 stdin 传输,不在命令行里拼变量 |
TMP_USER: 未绑定的变量(trap 里) | EXIT trap 引用尚未赋值的变量 | 先声明为空再 trap,用时写 ${X:-} |
重复 source 报 syntax error near unexpected token '(' | 旧 alias 在函数定义解析时被展开 | unalias 放文件顶部、复合命令之前;验证必须用真实旧 alias + shopt -s expand_aliases 复现 |
不做的事(用户裁定)
- 不定 alias:
d/dc/rd/rdc 由用户自己写进 .bashrc,脚本只负责把命令集文件送到位。
- 不装监控:不装任何资源监控、探针、采集代理;只保留 systemd 自身的 journald 配额。
- 不动 cloud-init 网络:
systemd-networkd / resolved 原样保留,避免重启丢网。
- 镜像不重新 pull:迁移统一
docker save | ssh | docker load。
- 终端命令不进 nix 包装:命令收敛在 bashrc 一套文件里,不新造系统级 wrapper。
边界
- 只针对 Ubuntu(Oracle 云镜像);换成 alpine 等发行版时只替换 SYS 段(包管理器与 init 系统),USER / BASHRC / 验收三段原样复用。
- rootless 数据与 rootful 数据不共享(data-root 不同),迁移后必须重新拉起容器,不能只拷镜像。
- 数据属主依赖各机
/etc/subuid 起始值一致;换机若 subuid 区间不同,容器内 1001 → 宿主 UID 会变,需重新 chown。
- 本笔记不含任何密码、私钥内容与具体公网 IP;主机名、域名、路径一律占位符(笔记树带公开分享属性)。
- 性能预期仅为「1 核 952M 小机可跑双栈 + 六容器」,不是跑分;双 daemon 常驻本身即固定开销。