技术指南
systemd/cgroup v2 I/O 限额:从设备证据到可回滚的服务节流
面向运行 systemd 与 cgroup v2 的 Linux VPS 管理者,解决“磁盘慢”究竟是云盘/宿主问题还是服务被 cgroup 限速的问题。文章只做只读盘点和静态配置审阅,不在生产机执行限额、重启或破坏性 I/O 实验。
先做决定:你要保护的是哪一类邻居和哪一种故障预算?如果备份或构建把共享云盘拖到高延迟,优先用 cgroup 的 I/O 权重或上限约束这个批处理 unit;如果服务本身已被 io.max 限住,解除限额前要确认父 slice 和宿主设备还有容量。不要从“磁盘 100% 忙”直接跳到“提高配额”:设备争用、云盘突发额度耗尽、文件系统锁、网络块存储和 cgroup 节流会产生相似症状。可靠结论必须同时看到设备级增量、目标 cgroup 的统计、压力和真实业务结果。
先确认 cgroup、设备和回滚边界
本文讨论 cgroup v2 的 block I/O controller,不讨论把某个目录写进“磁盘限速”就一定生效。控制器按设备 major:minor 工作;同一文件系统可能跨多个设备,LVM、dm-crypt、overlay、网络块设备和容器 namespace 会让路径与底层设备不一一对应。先记录发行版、内核、systemd、挂载表和父 slice。对于数据库、journal、备份仓库等状态型服务,任何限额都必须有独立恢复通道和已知的旧 unit 文件。
证据模型:四条线必须对齐
| 证据线 | 回答的问题 | 常见误读 |
|---|---|---|
| unit/cgroup | 服务是否有 IOWeight 或 io.max,父 slice 是否也有限制? | systemctl 的空值被当成无限性能 |
| 设备统计 | 底层设备的读写、队列和 await 是否随事件升高? | %util 高就等于设备已饱和 |
| 压力与应用 | 服务是否在 I/O 上停顿,延迟/队列/错误是否同步? | iowait 或 PSI 单独证明 cgroup 节流 |
| 拓扑与文件系统 | 实际路径落在哪些 major:minor,是否有 dm、overlay 或远端层? | 把 /var/lib/app 当作可直接限速的设备 |
保存有效 unit 和只读基线
unit=example.service
stamp=$(date --utc --iso-8601=seconds)
printf 'incident_utc=%s\nunit=%s\n' "$stamp" "$unit"
uname -r
systemctl --version | sed -n '1p'
stat -fc '%T' /sys/fs/cgroup
cat /sys/fs/cgroup/cgroup.controllers
systemctl show "$unit" --no-pager \
-p ControlGroup -p Slice -p IOAccounting -p IOWeight \
-p IODeviceWeight -p IOReadBandwidthMax -p IOWriteBandwidthMax \
-p IOReadIOPSMax -p IOWriteIOPSMax -p MainPID
systemctl cat "$unit"
findmnt -T /var/lib -o TARGET,SOURCE,FSTYPE,OPTIONS --noheadings
cat /proc/pressure/io
iostat -x -y 1 3`stat -fc '%T' /sys/fs/cgroup` 返回 cgroup2fs 才能按本文路径继续;根层 `cgroup.controllers` 还必须包含 `io`,目标与祖先目录中也要出现相应 `io.*` 文件。`systemctl show` 是 PID 1 当前加载的属性,`systemctl cat` 只展示主 unit 与 drop-in 来源;二者都不能单独证明实时 cgroup 已执行新限制。路径、服务账号、设备名和 journal 可能暴露租户或内部拓扑,证据目录应最小权限。
读取 io.stat、io.max 和 io.pressure
unit=example.service
cg=$(systemctl show "$unit" -p ControlGroup --value)
test -n "$cg" || { echo 'unit has no realized cgroup'; exit 1; }
while :; do
base="/sys/fs/cgroup${cg}"
printf '\n[cgroup %s]\n' "$cg"
for name in cgroup.controllers cgroup.subtree_control \
io.stat io.max io.weight io.pressure; do
path="$base/$name"
test -r "$path" && { printf '[%s]\n' "$path"; cat "$path"; }
done
test "$cg" = / && break
cg=${cg%/*}
test -n "$cg" || cg=/
done`io.stat` 的基础键是累计的 rbytes/wbytes、rios/wios、dbytes/dios;先保存 t0,在同一固定业务窗口后保存 t1,再按 major:minor 计算增量。额外的延迟或调试键依赖内核、设备与 controller 配置,不能假定存在或按累计量解释。`io.max` 每行以 major:minor 开头,后面可有 `rbps`、`wbps`、`riops`、`wiops`;没有本层设备行只表示没有本层显式上限,祖先上限仍然有效。`io.pressure` 的 some/full 是停顿时间比例,不是吞吐或剩余带宽。
把路径映射到真实块设备
findmnt -T /var/lib/example -o TARGET,SOURCE,FSTYPE,MAJ:MIN,OPTIONS
lsblk -o NAME,MAJ:MIN,TYPE,FSTYPE,MOUNTPOINTS
udevadm info --query=property --name=/dev/vda 2>/dev/null | grep -E '^(DEVNAME|MAJOR|MINOR|ID_)' || true
# 只读观察进程当前打开的文件;不要读写 /proc fd
pid=$(systemctl show example.service -p MainPID --value)
test "$pid" != 0
find /proc/$pid/fd -mindepth 1 -maxdepth 1 -type l -printf '%l\n' 2>/dev/null | sort -u内核 `io.max` 最终使用设备号;systemd 的 IO* 指令既可接受块设备节点,也可接受普通文件或目录并尝试解析其后端设备。但上游明确把自动发现限制在直接文件系统、分区/物理设备或简单 1:1 dm-crypt 等场景,不覆盖复杂 RAID 与卷管理。应把 `findmnt` 的 SOURCE、`lsblk` 的 MAJ:MIN、systemd 加载的属性和实时 `io.max` 交叉核对;映射不能稳定复现时,不发布该上限。
选择 weight、带宽还是 IOPS
| 目标 | 候选边界 | 适用与限制 |
|---|---|---|
| 让交互服务优先,批处理让路 | 验证后使用低于默认 100 的 IOWeight,例如 50;或提高竞争同级的权重 | 仅为同级竞争者的相对份额;块调度器/controller 必须实际支持,空闲时不形成硬上限 |
| 给备份设吞吐上限 | IOWriteBandwidthMax=/dev/xxx 20M | 以字节/秒限制写入;云盘突发、压缩和多设备写入会使端到端速度不同 |
| 防止随机 I/O 放大 | IOReadIOPSMax/IOWriteIOPSMax | 限制操作次数,不等于块大小或延迟保证;数据库工作负载必须实测 |
| 同时保护读写邻居 | 分别设置设备和方向的 rbps/wbps 或 riops/wiops | 遗漏设备、父级 slice 或 sidecar 会让限制不完整 |
权重只表达同一父级下活动兄弟之间的相对份额,`IOWeight=100` 是默认值,不是降权;以 BFQ 为例,只有目标设备实际使用 BFQ 且启用组调度时才有其文档承诺的比例效果,其他后端也必须按目标内核验证。带宽和 IOPS 是 `io.max` 的非 work-conserving 硬上限,会允许短暂突发,并不能承诺端到端延迟。还要核对写回归属:内核文档列出的 cgroup writeback 文件系统为 ext2、ext4、btrfs、f2fs、xfs;其他文件系统把 writeback 记到根 cgroup,多 cgroup 同写一个 inode 也可能误归属。
用 systemd drop-in 提交候选变更
# sudo systemctl edit backup.service
[Service]
IOAccounting=yes
# 每个候选窗口只取消下面一项的注释;数值均为占位符
# IOWeight=50
# IOWriteBandwidthMax=/dev/disk/by-id/example 20M
# IOWriteIOPSMax=/dev/disk/by-id/example 200systemd 接受块设备路径,也能从普通文件或目录尝试发现后端设备,但复杂 RAID、LVM 与多层映射不能依赖自动发现。使用 `/dev/disk/by-id/` 是为了减少设备名漂移,仍须在启动和恢复环境验证它解析到预期 major:minor。对供应商 unit 使用 drop-in,不改 `/usr/lib/systemd/system/`。若 timer、容器或其他 supervisor 拥有子 cgroup,先写清每一层的 owner;多层限制会叠加。
单变量验证:从静态检查到真实业务窗口
sudo systemd-analyze verify backup.service
sudo systemctl daemon-reload
systemctl show backup.service -p IOAccounting -p IOWeight \
-p IOReadBandwidthMax -p IOWriteBandwidthMax \
-p IOReadIOPSMax -p IOWriteIOPSMax -p ControlGroup
# 对受控启动/重启后的实时 cgroup 再核对;空 ControlGroup 表示尚未实现
cg=$(systemctl show backup.service -p ControlGroup --value)
if test -n "$cg"; then
for name in io.max io.weight io.stat io.pressure; do
path="/sys/fs/cgroup${cg}/${name}"
test -r "$path" && { printf '[%s]\n' "$path"; cat "$path"; }
done
fi
systemctl status backup.service --no-pager --full
journalctl -u backup.service --since '-10 min' --output=short-precise --no-pager验证固定输入、并发、压缩级别、目标仓库和观察时长,保存开始/结束的 io.stat、io.pressure、iostat 与应用 SLO。目标 cgroup 没有预期增量并不能直接定为路径错误:还可能是页面缓存尚未回写、文件系统不支持 cgroup writeback、共享 inode 归属变化、helper/sidecar 在别的 cgroup,或任务根本未触及该设备。先排除这些分支,再判断限额是否失配;每个窗口只改 weight、带宽或 IOPS 中的一类。
按工作负载决定观察指标
| 工作负载 | 最容易伤害谁 | 优先观察 | 候选策略 |
|---|---|---|---|
| 异地备份、压缩、校验 | 同盘数据库、日志和交互 API | 写入字节、读写比例、完成时间、数据库 fsync 延迟 | 先用低 IOWeight;需要确定窗口时,再增加仅对备份设备的 wbps |
| 数据库迁移或索引 | 线上请求和 WAL/日志 | 随机 IOPS、await、队列深度、p95 查询与 checkpoint | 先降低迁移并发;只有实测稳定后才设 IOPS 上限 |
| 编译、依赖安装、镜像构建 | 同机缓存和容器写层 | 小块写入、临时目录设备、构建时长和空间增长 | 放入独立 slice,用权重让路;不要直接限制整个 /var |
| 日志压缩与轮转 | 日志可追溯性和磁盘空间 | 读写字节、rename/unlink、journal 延迟和保留策略 | 错峰并限制写带宽;确认失败不会删除原日志 |
同一个 `20M` 对顺序备份和数据库随机写入不是同一种保护。备份更关心单位时间写多少字节,数据库更关心小块请求能否及时完成;对后者,IOPS 上限过低会让 fsync 延迟呈阶梯式上升,即使总吞吐不高。构建通常可以牺牲完成时间换取交互服务稳定,适合权重而非硬带宽。把场景、保护对象和验收指标写进变更单,才能解释为什么某个值在此处合理。
用增量而不是单点数字下结论
对 `io.stat`,至少计算每个 major:minor 的 `rbytes`、`wbytes`、`rios`、`wios` 差值,并除以观察秒数得到近似速率。对 `io.pressure`,比较 avg10/60/300 趋势和 total 增量;短任务可以只留下 total 增量,因为平均值尚未稳定。对 iostat,保存设备名、采样间隔和请求模式,避免把启动以来的平均值和本次任务混在一起。若观察期间没有竞争,权重不会显现差异;应记录“未触发竞争”而不是把配置判为无效。
沿 systemd slice 找到实际限制者
systemd 属性与 cgroup 文件不是两套独立配额。祖先 slice 的硬上限继续约束整棵子树;权重则在每一级活动兄弟之间分配该父级获得的份额,不是一个可简单继承的绝对速度。沿目标到根保存 io.max、io.weight、io.stat 与 controller 可用性,并记录容器运行时等委派边界。只删除叶子 drop-in 而祖先或另一 owner 仍设限,不构成完整回滚。
发布后监控和撤销条件
限额不是一次性调参。为受保护交互 unit 记录设备 await、aqu-sz、io.pressure、请求 p95、错误率和队列;为受限批处理记录 io.stat 增量计算出的速率、完成时长、失败重试和积压。告警关注“压力上升且用户 SLO 变坏”“同一设备增量速率持续接近配置上限”“批处理超过恢复窗口”等组合条件,不把原始累计计数或单独 `%util` 当阈值。撤销条件必须预先写明。
- 先暂停或降级批处理摄入,保留可恢复任务和管理通道;不要为了追求吞吐删除队列或备份临时文件。
- 保存失败窗口采样、unit 有效配置、父级 cgroup、设备映射和应用日志,再恢复已审阅的旧 drop-in。
- 回滚后重复相同健康检查,确认数据库落盘、日志写入、备份校验和交互 SLO 均恢复;只看到 unit active 不算验收。
- 若回滚后设备压力仍在,根因可能是云盘/宿主或应用放大;转查供应商指标、文件系统和应用并发,不要继续修改 systemd 限额。
失败分类与最小回退
| 症状 | 较可能原因 | 处置 |
|---|---|---|
| unit 启动失败或出现 unknown lvalue | systemd 版本不支持属性或 drop-in 语法/路径错误 | 恢复旧 drop-in,保留 verify 输出,核对目标版本手册 |
| 配置显示生效但 io.stat/吞吐没有变化 | 设备号不匹配、父级限制、设备空闲或统计层级不同 | 重新做 findmnt/lsblk 映射,检查父 slice,再用固定竞争场景验证 |
| 备份变慢且数据库延迟也上升 | 带宽/IOPS 过低、同一设备共享或 fsync 受影响 | 先只回滚批处理 unit 的新增项,保留 t0/t1 证据,不要全局解除限制 |
| await 与 io.pressure 同时升高但 cgroup 未限流 | 云盘/宿主争用、文件系统或远端存储瓶颈 | 回到云盘指标、设备拓扑和应用锁;不要把配额调大当作证明 |
| 恢复后仍有拒绝/错误 | 父级 slice 或另一个 supervisor 仍设限 | 保存当前 unit/cgroup 树,逐层找出实际 owner,再按 owner 回滚 |
# 回滚前保存失败配置和证据
sudo cp -a /etc/systemd/system/backup.service.d/override.conf \
/root/backup.override.failed.$(date -u +%Y%m%dT%H%M%SZ)
# 用配置管理或已审阅的 known-good 文件恢复
sudo cp -a /root/backup.override.known-good.conf \
/etc/systemd/system/backup.service.d/override.conf
sudo systemd-analyze verify backup.service
sudo systemctl daemon-reload
systemctl show backup.service -p ControlGroup -p IOWeight \
-p IOReadBandwidthMax -p IOWriteBandwidthMax
# 经批准的停止/启动或重启完成后,再读实时 io.max/io.weight
cg=$(systemctl show backup.service -p ControlGroup --value)
if test -n "$cg"; then
for name in io.max io.weight; do
path="/sys/fs/cgroup${cg}/${name}"
test -r "$path" && { printf '[%s]\n' "$path"; cat "$path"; }
done
fi验收:限制可解释,业务无回归
- 内核/systemd 版本、可用 controller、ControlGroup、全部祖先和设备 major:minor 已记录;unit 加载属性、实时 io.max/io.weight 与版本库意图一致。
- `io.stat` 的设备级增量与预期路径相符;页面缓存、writeback 文件系统支持、共享 inode、helper/sidecar 和委派子树的归属已核对。
- `io.pressure`、iostat await/aqu-sz、应用吞吐、p95 延迟、错误率和批任务完成时间都满足预先写下的门槛;不能只验一个百分比。
- 交互服务在批任务运行时仍满足 SLO,批任务在限额下有可接受的完成时间;超出预算时有暂停、回滚和恢复负责人。
- 保留旧 drop-in、t0/t1 原始采样、journal 和回滚记录;升级内核、systemd、云盘类型、容器运行时或文件系统后重新验收。
诚实的未知项
内核、systemd、发行版补丁、cgroup 层级、块调度器、设备映射器、文件系统、overlay、网络块存储和云厂商突发策略都会改变结果。本文没有通用 MB/s、IOPS、await、%util、PSI 阈值或安全观察时长;值必须由读写比例、块大小、fsync、并发和恢复预算测得。权重属性可写不等于后端兑现比例,daemon-reload 后属性可见也不等于活跃 cgroup 已应用;没有稳定 major:minor、实时 cgroup 和同窗 SLO,不能宣称限额生效。
本文命令和 unit 键在 2026-08-19 按 Linux cgroup v2、PSI、systemd.resource-control、iostat 与 proc_stat 当前一手文档做语义和语法审阅,没有在读者的 VPS、生产服务或真实存储设备上执行。示例中的 unit、设备和数值均为占位符;首次变更应在可暂停窗口和独立管理通道完成。