1. 现场还原:一条 systemd 命令让两个容器在同一个核上硬碰硬
干这行久了,你会遇到一种特别有意思的故障:systemd 命令一执行,容器里的 CPU 使用率开始“打架”,进程明明之前绑核正常,重启之后就错位了。我遇到的具体场景是 32 核物理机上跑多个容器服务,其中一个容器由宿主机 systemd 的app-a.serviceunit 管理,业务主进程之前一直用taskset -pc 4固定在物理核 4 上;另一个容器app-b用 cpuset 钉在物理核 5 上。两个服务各自跑了一个星期都好好的,结果同事为了“统一管理启动参数”,往app-a.service里加了一行CPUAffinity=4-5,然后执行了systemctl daemon-reload && systemctl restart app-a.service,噩梦就从这里开始。
1.1 故障现象:CPU 总量没变,业务延迟却明显变糟
故障出现后的第一反应是看top。奇怪的是整机 CPU 使用率并没有明显上涨,依然是 60% 多,但容器的 RT 和 P99 延迟直线上升,日志里开始出现线程阻塞、超时重试。用mpstat -P ALL一看才发现问题:原本独占核 5 的app-b所在 CPU 5 几乎被打满,而原本应该是高负载的核 4 使用率反而掉了一半。也就是说,负载“漂”到了相邻核心上,造成了两组进程在同一颗物理核上争抢。
核对进程绑核状态时,现象非常直接:
taskset -pc <app-a主进程PID> # 重启前输出:pid <PID> current affinity list: 4 # 重启后输出:pid <PID> current affinity list: 4-5这个4-5就是罪魁祸首。systemd 并没有把原来的4保留,而是直接把可运行核集合换成了4-5,于是app-a的 worker 线程有机会跑到核 5 上。核 5 本来就是app-b独占的 cpuset,两边的线程在同一颗物理核上抢 ALU、抢 L2/L3、争 runqueue,表现就是 CPU “打架”。
1.2 触发命令:你越觉得它只是“重启”,越容易踩坑
这里最容易被忽略的是:很多人把systemctl restart理解成“重新启动进程而已”,不会去动绑核。但它不仅仅是杀掉旧进程、拉起新进程。systemd 在启动 unit 里的 ExecStart 进程时,会完整应用 unit 中[Service]段定义的各种执行属性,CPUAffinity就在其中。
在我那个案例里,之前的taskset -pc 4是运维同学对“旧 PID”手动敲的,这个绑定只存在于当时的进程上。一旦 systemd 重启服务,旧进程被 kill,新进程由 systemd fork 出来,继承的是 systemd 写入的新掩码4-5。你之前手动绑的那颗核,随着旧进程一起消失了。听起来像废话,但现场很多人都会忘记这一点——绑核不是写在磁盘上的配置,而是进程状态的一部分,进程没了,状态也没了。
提示:本文所有命令都只需要 root 或相应 capability。生产环境操作前先备份 unit 文件,并确认当前各容器的 CPU 分配表。
2. systemd、cpuset、taskset:三层绑核分别管什么
要搞懂为什么 systemd 能“篡改”容器绑核,得先把 Linux 里控制一个进程“能在哪些 CPU 上跑”的机制分清楚。常见的至少有三层,很多人把它们混为一谈,出事往往就是这几层互相覆盖。
2.1 cpuset cgroup 是“圈地”,它管住一个进程集合能去哪
cpuset 是 cgroup 的一个 controller,它可以给一组进程划定可用的物理 CPU 集合和内存节点集合。Docker/Podman 里的--cpuset-cpus,底层写的就是 cgroup v1 的/sys/fs/cgroup/cpuset/docker/<id>/cpuset.cpus,或者 cgroup v2 的cpuset.cpus。
你可以把它理解成“圈地”:圈里能跑,圈外不能跑。它面向的是“一组进程”,不是单个线程。比如app-b被放进cpuset.cpus=5的 cgroup 后,这个 cgroup 里的所有任务无论怎么设置亲和性,内核调度器都不会把它们放到核 5 以外的地方。
注意它的语义是“允许”,不是“平均分配”。cpuset.cpus=0-3不等于四个核一定都被用上,也不等于核 0 必须被某个进程独占。它是边界条件,不是调度算法。
2.2 taskset 掩码是“个人意愿”,调度器尊重但不扩展
taskset操作的是sched_setaffinity(),设置的是当前任务(线程)自己的亲和性掩码。这个掩码决定了调度器允许给这个线程选择哪些 CPU。它的关键特点是:只能在一个方向上收紧,或者在边界内移动,不能凭空调出边界。
实际调度时,内核会把 cpuset 允许集合和任务亲和性掩码取交集,再决定把任务放到哪颗核:
有效可调度集合 = cpuset 允许集合 ∩ 任务亲和性掩码如果 cpuset 是0-7,任务掩码是4,那么这个任务只会出现在核 4 上。如果 cpuset 是4-7,任务掩码是0-3,两者交集为空,任务实际上处于一个不可调度的状态,sched_setaffinity会直接报错或者被内核裁掉。所以 taskset 本身很“老实”,它不会让进程跑出圈地范围,只是在线程粒度上告诉内核“我想重点用哪几颗核”。
2.3 systemd 的 CPUAffinity 是在 fork 之后、exec 之前那次“夹塞”
systemd 在[Service]段里提供的CPUAffinity=,本质上是让 systemd 在启动子进程的过程中调用一次sched_setaffinity()。具体时序是:systemd fork 出子进程,在 exec 真正的程序之前设置亲和性。这段窗口非常短,看起来像是“程序一开始就是带绑核运行的”。
systemd 还有另一个属性AllowedCPUs=,它对应的是 cgroup 的 cpuset 层。和CPUAffinity=不一样,AllowedCPUs=改的是当前 unit 所在 cgroup 的cpuset.cpus,它是“圈地”层的动态写法。很多教程把这两个混着用,但实际上一个写的是任务掩码,一个写的是 cgroup 边界,效果完全不同:
| systemd 属性 | 作用层 | 影响效果 |
|---|---|---|
CPUAffinity= | 任务/线程亲和性掩码 | 类似 taskset,负责进程最终在哪颗核跑 |
AllowedCPUs= | cgroup cpuset | 负责 group 边界,进程不能跑出这个集合 |
这个区分特别重要。如果只调CPUAffinity,cpuset 边界不会变;如果只调AllowedCPUs,那么原任务的掩码并不会被 systemd 自动重写。故障往往发生在“你以为 systemd 在做 A,实际上它做了 B,或者 B 和 A 一起做”的时候。
3. 根因拆解:systemd 为什么能覆盖容器进程的绑核
光知道三层机制还不够,我们要回答标题里的“为什么”。原因不复杂,但很反直觉:systemd 的CPUAffinity是一个覆盖型操作,不是“跟现有绑核求并集”。
3.1 重启 unit 时 systemd 会拿着 CPUAffinity 重新写一次掩码
当你在 unit 文件里写了CPUAffinity=4-5,然后执行systemctl daemon-reload && systemctl restart app-a.service时,systemd 会做下面几件事:
- 读取解析后的 unit 配置,拿到
CPUAffinity=4-5。 - 在 cgroup 中创建新的进程组,并设置好各类资源属性。
- fork 出新的 ExecStart 子进程。
- 在 exec 前,把这子进程的亲和性掩码设置成
4-5。 - 子进程 exec 成真正的业务程序,后续所有线程默认继承
4-5这个掩码。
所以,不是 systemd “主动发现了你此前 taskset 过核 4,然后来覆盖”,而是重启后新进程本来就会带上这层掩码。你之前对旧 PID 调用的taskset已经随旧进程消亡,根本没有机会参与新一轮启动。如果要怪,只能怪“手工 taskset 的方式没有把状态固化到 unit 文件里”。
3.2 CPUAffinity 的语义是“替换掩码”,不是“追加”
这是理念上最容易出错的一点。sched_setaffinity()的语义是:把指定任务的 CPU 允许集合设置为调用者传入的掩码。也就是说,传进去0x30(核 4-5),结果就是4-5,绝不会是“原来 4,再加一个 5”这种事。假如 systemd 内部或应用再调用一次,最终掩码永远等于最后一次调用的结果。
这一点在排障时要特别留意。你可能会看到/proc/<PID>/status里的Cpus_allowed_list写的是4-5,但如果你记得进程启动脚本里明明执行过taskset -pc 4,第一反应是“脚本没生效”。实际上脚本很可能执行了,但顺序是先执行了脚本里的taskset,后执行了 systemd 的亲和性设置,或者 systemd 在服务重启时覆盖了它。由于CPUAffinity是整体替换,不是逐位叠加,就会出现覆盖后的“绑核错乱”。
3.3 真正让两个容器“打架”的那个比特位是怎么出现的
回到我的例子:app-b的 cpuset 是5,app-a没有 cpuset 限制但原先 task 掩码是4,两者不重叠,所以之前相安无事。加了CPUAffinity=4-5后,app-a的有效可调度集合变成了:
cpuset(无限制)∩ task mask(4-5) = 4-5于是app-a的线程跑到核 5。核 5 上既有app-a的线程,又有app-b的线程。对内核调度器来说,这是两个完全合法的任务,调度器会把它们都放到核 5,于是一颗物理核上出现两个高负载线程来回抢占。到这里,“CPU 打架”就不是比喻了,而是真实的 runqueue 争抢、缓存抖动和上下文切换开销。
4. 定位与验证:别靠猜,把 allow list 摊开看
遇到类似问题,别急着top看谁 CPU 高,先把“绑核状态”完整摊开。三步走基本能定位。
4.1 看 /proc/PID/status 里的 Cpus_allowed_list
每个线程都在/proc/<tid>/status里暴露了亲和性掩码,看Cpus_allowed_list最直观。它能告诉你当前内核眼里这个线程“允许”在哪些 CPU 上调度。
grep Cpus_allowed_list /proc/<PID>/status # 输出示例:Cpus_allowed_list: 4-5注意亲和性掩码是线程粒度的,主进程的 PID 和 worker 线程的 TID 要分别看。可以循环看:
for tid in /proc/<PID>/task/*; do grep Cpus_allowed_list "$tid"/status donetaskset -pc <PID>也可以看主进程,但如果程序起了一堆线程,最好还是依赖/proc,把每个线程的掩码都拉出来跟预期比对。
4.2 看 cgroup 的 cpuset.cpus,确认边界
第二步是确认进程所在的 cgroup 边界。先用/proc/<PID>/cgroup找到路径:
cat /proc/<PID>/cgroup # 0::/system.slice/app-a.service然后读对应 cpuset 配置。cgroup v1 下:
cat /sys/fs/cgroup/cpuset/system.slice/app-a.service/cpuset.cpuscgroup v2 下通常是:
cat /sys/fs/cgroup/app-a.service/cpuset.cpus cat /sys/fs/cgroup/app-a.service/cpuset.cpus.effectivecpuset.cpus是本层配置,cpuset.cpus.effective是考虑了父 cgroup 之后实际生效的集合。如果发现 cpuset 边界没变,而Cpus_allowed_list变了,说明问题出在任务掩码层;如果两个都变了,说明有人动了 systemd 的AllowedCPUs或 cgroup 配置。
4.3 用 systemctl show 判断 systemd 在这层写了什么
如果进程是 systemd unit 管理的,直接问 systemd 比猜快得多:
systemctl show app-a.service -p CPUAffinity -p AllowedCPUs systemctl cat app-a.serviceCPUAffinity会显示 systemd 准备应用给 ExecStart 进程的掩码,AllowedCPUs会显示 cpuset 层的目标集合。两者和/proc里的真实状态一对,就能知道到底是谁在最后一步覆盖了谁。还有一个细节:如果 unit 文件里没写CPUAffinity,但/proc里掩码变了,那要去看/etc/systemd/system.conf里的全局CPUAffinity=,它同样会被 systemd 继承到子进程上。
5. 怎么救:从临时止血到永久改对
定位之后就是救火。救火要分两步,第一步先把错乱的掩码改回去,第二步是改配置,避免下一次 restart 又复发。
5.1 临时恢复:taskset 命令直接改回目标核
如果只是想让业务先恢复正常,最直接的方法是给当前进程重新设置亲和性:
taskset -pc 4 <主进程PID>如果主进程有大量子线程,建议循环处理,否则可能只有主线程被改:
for tid in /proc/<主进程PID>/task/*; do taskset -pc 4 "${tid##*/}" done这个操作对运行中的进程是即时的,不会重启服务。但请注意,这是临时的。一旦 systemd 再次 restart 这个 unit,新的进程又会带上 unit 文件里的CPUAffinity=4-5,同样的坑会再踩一次。
5.2 从 unit 层根治:别让两种绑核方式同存
临时恢复之后,最重要的是避免“手工 taskset + systemd CPUAffinity”长期共存。你只能在两条路线里选一条:
- 如果按线程粒度精细绑核是业务需求,那就去掉 unit 文件里的
CPUAffinity,让 ExecStart 自己通过 taskset 或程序内部的sched_setaffinity控制每个线程的绑定。systemd 只负责拉起进程,不插手亲和性。 - 如果只要求“这个服务整体用哪几个核”,那就把绑核配置收敛到 unit 文件,写成单独的
CPUAffinity=4,把启动脚本里的 taskset 删掉。让 systemd 作为唯一权威来源,避免两处配置互相打架。
改完 unit 后记得:
systemctl daemon-reload systemctl restart app-a.service taskset -pc <新PID> # 确认结果确实是 4,而不是 4-5我最推荐的是用 systemd 自身来管“服务级绑核”,因为它会在每次重启时自洽地应用同一套配置。按线程精细绑核这种高级玩法,才需要落到业务进程内部去控制,不要放在外围脚本里和 systemd 抢。
5.3 systemd-run 临时跑任务时同样要注意掩码语义
除了 unit 文件,systemd-run也是常见的“执行个 systemd 命令”的场景。很多人会用下面这种命令临时限制某个任务的 CPU:
systemd-run --scope -p CPUAffinity=4-5 /opt/app/batch这个命令的语义和 unit 文件里的CPUAffinity完全一样:它会让 systemd 把这个 scope 中的进程亲和性设为4-5。如果你之前已经在容器或者脚本里用taskset -c 6给任务绑过核,那么这条systemd-run很有可能会覆盖你的设置。临时调试时没问题,要是把它写进运维脚本,就相当于埋了一个和本文一模一样的雷。
6. 这类故障最容易被忽略的复盘点
这次故障给我留下的教训,比单纯修好服务要多得多。做运维和 SRE,最怕的不是引入复杂系统,而是自以为懂了某个简单机制。
6.1 监控要同时看位置和负载,不能只看 CPU%
整机 CPU 总量不变,不代表没有故障。容器 CPU 从核 4 漂到核 4-5,总量甚至可能下降,但延迟和争抢反而会上升。所以监控不能只看%CPU,至少要加一层 CPU placement 视图:mpstat -P ALL看单核,pidstat -t看线程的 CPU 使用和调度情况,必要时直接采集/proc/*/status里的Cpus_allowed_list和cpuset.effective。
我把这个习惯总结成一句话:CPU 监控要回答两个问题——“用得多吗?”和“在哪个核上用的?”。缺了后者,绑核错乱这类问题会隐藏得很深。
6.2 容器内 PID 1 是不是 systemd,决定了绑核的话语权
排查容器问题前,先确认容器里 PID 1 是什么。如果 PID 1 是 systemd,那么容器内的服务同样会受 systemdCPUAffinity影响;如果 PID 1 是个普通脚本或者tini,systemd 相关属性就不会生效,只会继承宿主机或 cgroup 层的掩码。用pstree -p看一下父子关系,往往比翻业务代码更快。
6.3 配置变更评审里应该加一条“CPUAffinity 是否覆盖现有 taskset”
最后一条是流程层面的建议。凡是涉及 systemd unit 变更的评审,都应加一个问题:“这个 unit 的 CPUAffinity/AllowedCPUs 会不会覆盖现有进程的 taskset/cpuset 绑核?”问题很小,但能拦住很多次线上故障。我自己现在每次改 unit 之前都会执行一条对比命令:
grep -E "CPUAffinity|AllowedCPUs" /etc/systemd/system/*.service taskset -pc <受影响PID>把配置里的掩码和当前运行中的掩码摊开放在同一屏,再看一眼目标核上有没有别的容器,确认没有重叠之后才敢restart。这套动作用不了两分钟,但足以避免再出现一次“systemd 命令导致容器 CPU 打架”的复盘会。