技术指南

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 和只读基线

把 example.service 和 findmnt 的路径换成真实目标;记录内核、systemd、可用 controller 和同一时间窗。iostat 的第一份开机累计报告由 -y 排除。
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

从目标 cgroup 读到根,避免只检查直接父级;inactive unit 没有已实现的 cgroup,应在受控启动后再采样。根 cgroup 没有 io.max/io.weight 属正常边界。
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 是停顿时间比例,不是吞吐或剩余带宽。

把路径映射到真实块设备

将 /var/lib/example、/dev/vda 和服务名换成目标值。dm、LVM、overlay 或网络盘可能需要沿 lsblk/findmnt 继续向下核对。
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 提交候选变更

三种策略不是一个模板:一次只启用一项。50 只是低于默认 100 的相对权重示例;20M 在 systemd 中是十进制 20,000,000 字节/秒,不是 20 MiB/s。
# sudo systemctl edit backup.service
[Service]
IOAccounting=yes
# 每个候选窗口只取消下面一项的注释;数值均为占位符
# IOWeight=50
# IOWriteBandwidthMax=/dev/disk/by-id/example 20M
# IOWriteIOPSMax=/dev/disk/by-id/example 200

systemd 接受块设备路径,也能从普通文件或目录尝试发现后端设备,但复杂 RAID、LVM 与多层映射不能依赖自动发现。使用 `/dev/disk/by-id/` 是为了减少设备名漂移,仍须在启动和恢复环境验证它解析到预期 major:minor。对供应商 unit 使用 drop-in,不改 `/usr/lib/systemd/system/`。若 timer、容器或其他 supervisor 拥有子 cgroup,先写清每一层的 owner;多层限制会叠加。

单变量验证:从静态检查到真实业务窗口

verify 检查 unit 文件但不能证明限制已作用于目标设备。daemon-reload 后配置可能不会立即影响既有进程;是否启动或重启必须按状态一致性与恢复流程决定,之后以实时 cgroup 和固定业务窗口验收。
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` 当阈值。撤销条件必须预先写明。

  1. 先暂停或降级批处理摄入,保留可恢复任务和管理通道;不要为了追求吞吐删除队列或备份临时文件。
  2. 保存失败窗口采样、unit 有效配置、父级 cgroup、设备映射和应用日志,再恢复已审阅的旧 drop-in。
  3. 回滚后重复相同健康检查,确认数据库落盘、日志写入、备份校验和交互 SLO 均恢复;只看到 unit active 不算验收。
  4. 若回滚后设备压力仍在,根因可能是云盘/宿主或应用放大;转查供应商指标、文件系统和应用并发,不要继续修改 systemd 限额。

失败分类与最小回退

症状较可能原因处置
unit 启动失败或出现 unknown lvaluesystemd 版本不支持属性或 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 回滚
恢复磁盘配置并 daemon-reload 还不等于活跃进程已回滚。数据一致性流程决定是否重启;实时 cgroup、相同健康检查和业务 SLO 都恢复后,回滚才完成。
# 回滚前保存失败配置和证据
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、设备和数值均为占位符;首次变更应在可暂停窗口和独立管理通道完成。

返回知识库