
1. 现场还原一条 systemd 命令让两个容器在同一个核上硬碰硬干这行久了你会遇到一种特别有意思的故障systemd 命令一执行容器里的 CPU 使用率开始“打架”进程明明之前绑核正常重启之后就错位了。我遇到的具体场景是 32 核物理机上跑多个容器服务其中一个容器由宿主机 systemd 的app-a.serviceunit 管理业务主进程之前一直用taskset -pc 4固定在物理核 4 上另一个容器app-b用 cpuset 钉在物理核 5 上。两个服务各自跑了一个星期都好好的结果同事为了“统一管理启动参数”往app-a.service里加了一行CPUAffinity4-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.cpus5的 cgroup 后这个 cgroup 里的所有任务无论怎么设置亲和性内核调度器都不会把它们放到核 5 以外的地方。注意它的语义是“允许”不是“平均分配”。cpuset.cpus0-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负责进程最终在哪颗核跑AllowedCPUscgroup cpuset负责 group 边界进程不能跑出这个集合这个区分特别重要。如果只调CPUAffinitycpuset 边界不会变如果只调AllowedCPUs那么原任务的掩码并不会被 systemd 自动重写。故障往往发生在“你以为 systemd 在做 A实际上它做了 B或者 B 和 A 一起做”的时候。3. 根因拆解systemd 为什么能覆盖容器进程的绑核光知道三层机制还不够我们要回答标题里的“为什么”。原因不复杂但很反直觉systemd 的CPUAffinity是一个覆盖型操作不是“跟现有绑核求并集”。3.1 重启 unit 时 systemd 会拿着 CPUAffinity 重新写一次掩码当你在 unit 文件里写了CPUAffinity4-5然后执行systemctl daemon-reload systemctl restart app-a.service时systemd 会做下面几件事读取解析后的 unit 配置拿到CPUAffinity4-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 是5app-a没有 cpuset 限制但原先 task 掩码是4两者不重叠所以之前相安无事。加了CPUAffinity4-5后app-a的有效可调度集合变成了cpuset无限制∩ task mask4-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 文件里的CPUAffinity4-5同样的坑会再踩一次。5.2 从 unit 层根治别让两种绑核方式同存临时恢复之后最重要的是避免“手工 taskset systemd CPUAffinity”长期共存。你只能在两条路线里选一条如果按线程粒度精细绑核是业务需求那就去掉 unit 文件里的CPUAffinity让 ExecStart 自己通过 taskset 或程序内部的sched_setaffinity控制每个线程的绑定。systemd 只负责拉起进程不插手亲和性。如果只要求“这个服务整体用哪几个核”那就把绑核配置收敛到 unit 文件写成单独的CPUAffinity4把启动脚本里的 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 命令”的场景。很多人会用下面这种命令临时限制某个任务的 CPUsystemd-run --scope -p CPUAffinity4-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 是个普通脚本或者tinisystemd 相关属性就不会生效只会继承宿主机或 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 打架”的复盘会。