如何在香港服务器的 RHEL 系统中,用 cgroup 限制“外挂进程”的 CPU/内存占用,提升游戏服务器公平性

凌晨 2:17,我在香港葵涌机房值班。监控屏上某区服突然出现“鬼畜帧”:服务器 tick 抖到 25ms——这在我们平时稳定的 12ms 水平里,像心电图的尖刺。CPU 总占用只有 58%,可进程级别的 %CPU 峰值莫名冲上 900%(64 线程机器里 9 核被一把抱死)。
我用 pidstat 一扫,发现不是我们主进程(gameserver),也不是常驻的 matchmaker/redis,而是一串临时拉起的脚本和工具:python3 pathfinder.py、lua bot.lua、phantom-proxy。这些不是我们官方部署的服务,多半是某些“外挂/加速/代练工具”被塞到了同机用户里,靠就地计算抢占 CPU 做路线规划和模拟决策,再走本地回环连进游戏端口——延迟小、优势大,但它吞的不是它该吞的资源。
那一晚我把它们统统装进了“笼子”(cgroup),给每个“可疑用户组”设了严格的 CPU/内存/IO 配额,并把 gameserver 本体升到独立的高优先级 slice。十分钟后 tick 回到 11.8ms,在线投诉归零。
这篇就是把我那晚的复盘和之后沉淀下来的做法,完整写给你:不管你是刚上手 RHEL 的新人,还是要在高并发商用环境里兜底的老运维,都能照着一步步落地。
1. 现场环境与目标边界
硬件(香港节点,实配示例)
| 项 | 型号/参数 |
|---|---|
| 机型 | Dell PowerEdge R7525 |
| CPU | AMD EPYC 7443P,24C/48T,2.85GHz |
| 内存 | 256GB DDR4 |
| 系统盘 | 2 × 1.92TB NVMe(RAID1) |
| 数据盘 | 2 × 3.84TB NVMe(RAID1) |
| 网卡 | 2 × 10GbE(Bond LACP) |
| 典型 RTT | 香港机房 ↔ 华南 18~28ms |
系统
- 节点 A:RHEL 9.3(默认 cgroup v2,systemd v252)
- 节点 B:RHEL 8.6(可混合/统一,systemd v239)
- 节点 C:RHEL 7.9(cgroup v1,systemd v219)
目标
让 gameserver、matchmaker、redis 等官方服务拥有稳定资源与更高优先级。
可疑/外挂类进程(非白名单用户、非白名单服务)被统一装进低权重 cgroup,限定:
- CPU:不超过 20% 总核(或限定固定核集)
- 内存:2~4GB 上限,超过直接 OOM/Kill
- IO:NVMe 单盘读 ≤100MB/s,写 ≤50MB/s
- PIDs:≤ 512,防止 fork 风暴
动态发现和“迁移”已在跑的可疑进程——不中断 gameserver。
2. 先判定你在 v1 还是 v2(非常关键)
# 任一节点执行
stat -fc %T /sys/fs/cgroup
# 输出 "cgroup2fs" => v2;输出 "tmpfs" 多半是 v1 或混合
# v2 特征文件
test -f /sys/fs/cgroup/cgroup.controllers && echo "cgroup v2"
# 单进程查看
grep -E 'cgroup(:|2):' /proc/$$/cgroup
- RHEL 9:默认 cgroup v2(统一层级)。
- RHEL 8:早期默认混合,可加内核参数启用 v2:systemd.unified_cgroup_hierarchy=1(需重启)。
- RHEL 7:cgroup v1(libcgroup 工具链最顺手)。
后文给出 v2 路线(RHEL8/9) 与 v1 路线(RHEL7) 两套做法,按你的实际环境选。
3. 白名单与账户分层(组织好“谁进哪个笼子”)
3.1 白名单(官方服务)
- gameserver.service(主进程)
- matchmaker.service
- redis.service
- nginx.service
3.2 可疑用户与组
- 单独建一组:players(或你的多租户业务组)
- 非白名单的登录用户统一加入 players
- 临时登录、脚本 Runner、作业节点一律纳入
# 示例:把 user01/user02 加进 players 组
groupadd -f players
usermod -aG players user01
usermod -aG players user02
4. RHEL 9/8(cgroup v2)最佳实践:基于 systemd 的“切片化”管控
4.1 把官方服务抬到高优先级 slice
我们给官方服务一个独立的 game.slice,提高权重、限定 IO、可选绑核:
# /etc/systemd/system/game.slice
[Unit]
Description=Slice for Official Game Services
[Slice]
CPUWeight=900
IOWeight=900
# 可选:预留核给游戏主进程(示例预留 0-15 号核)
AllowedCPUs=0-15
# 读写带宽保护(对 /dev/nvme0n1)
IOReadBandwidthMax=/dev/nvme0n1 400M
IOWriteBandwidthMax=/dev/nvme0n1 200M
把服务归属到 game.slice(以 gameserver 为例):
# /etc/systemd/system/gameserver.service(节选)
[Unit]
Description=Game Server
Slice=game.slice
After=network-online.target
[Service]
User=game
WorkingDirectory=/opt/game/bin
ExecStart=/opt/game/bin/gameserver --conf /etc/game/server.yaml
# 稳定性保障
CPUQuota=1600% # 允许最多 16 线程满核
CPUWeight=900
MemoryMax=64G
TasksMax=4096
Restart=always
CPUQuota=1600% 表示最多用到 16 个 CPU 的时间片;如果你更喜欢软性调度,也可只用 CPUWeight,把绝对 quota 留空。
4.2 给“外挂/可疑进程”做一个低优先级 slice
# /etc/systemd/system/sandbox.slice
[Unit]
Description=Slice for Non-official or Suspicious Processes
[Slice]
CPUWeight=10
CPUQuota=20% # 总体不超过 20% CPU
MemoryHigh=2G # 软限,超过开始受压
MemoryMax=4096M # 硬限
IOReadBandwidthMax=/dev/nvme0n1 100M
IOWriteBandwidthMax=/dev/nvme0n1 50M
TasksMax=512
4.3 一次性把“用户会话”绑进 sandbox.slice(强力而干净)
做法 A(更推荐):给 players 组用户的 user.slice 设定资源属性(持久)
# 找到某用户 UID(示例 user01 => 1002)
id -u user01
# 为 user-1002.slice 写 drop-in
systemctl edit user-1002.slice
粘贴:
[Slice]
Slice=sandbox.slice
上述表示:用户会话切片挂到 sandbox.slice 下,天然继承 CPU/内存/IO 限制。对整组用户可批量生成 drop-in;或者直接对父级 user.slice 做限制,但注意别波及 root 和系统服务。
做法 B(快速兜底):对当前活跃会话临时设置(无重启)
# 临时给 user-1002 的切片加资源限制
systemctl set-property --runtime user-1002.slice \
CPUQuota=20% CPUWeight=10 MemoryHigh=2G MemoryMax=4G \
IOReadBandwidthMax=/dev/nvme0n1 100M IOWriteBandwidthMax=/dev/nvme0n1 50M TasksMax=512
4.4 把已经在跑的可疑进程“搬”进 sandbox(不中断业务)
当晚我就是这样做的:用 systemd-cgls 找到 PID 所在 scope,再 迁移到 slice。
# 找 PID
pidof -x pathfinder.py phantom-proxy || ps -eo pid,cmd | grep -E 'pathfinder|phantom|lua bot'
# 把 PID 放进一个新的 scope(挂在 sandbox.slice 下)
systemd-run --scope --slice=sandbox.slice --unit sandbox-move-$$ -- /bin/true
for p in 1234 5678 9012; do
echo $p > /sys/fs/cgroup/system.slice/sandbox.slice/sandbox-move-$$.scope/cgroup.procs
done
更简法:
systemctl set-property --runtime PID-1234.scope Slice=sandbox.slice
但不同系统里 PID scope 名称并不总是一致,我实战里更常用前一种“新 scope + cgroup.procs 移动”的通吃法。
4.5 纯 cgroupfs(v2)硬核写法(备选)
如果你不依赖 systemd 属性,直接用 v2 文件系统也行:
# 开启控制器(在根或父目录)
echo "+cpu +io +memory +pids" > /sys/fs/cgroup/cgroup.subtree_control
# 建沙箱组
CG=/sys/fs/cgroup/sandbox-cheat
mkdir -p $CG
# 配额:CPU 20% => 配额/周期(微秒);100000 周期里给 20000 配额
echo "20000 100000" > $CG/cpu.max
echo $((4*1024*1024*1024)) > $CG/memory.max
echo 512 > $CG/pids.max
# IO(需要设备主次号)
lsblk -no MAJ:MIN,NAME /dev/nvme0n1 # 假设 259:0
echo "259:0 rbps=104857600 wbps=52428800" > $CG/io.max
# 移动进程
for p in $(pgrep -f 'pathfinder|phantom|bot.lua'); do echo $p > $CG/cgroup.procs; done
5. RHEL 7(cgroup v1)稳定套路:libcgroup + cgrules
RHEL7 的 systemd 版本较老(v219),对 MemoryMax 等新属性支持不全;我在 7 系上通常用 libcgroup。
5.1 安装 & 启动
yum install -y libcgroup
systemctl enable --now cgconfig
systemctl enable --now cgred
5.2 定义 cgroup 组(/etc/cgconfig.conf)
group sandbox_weak {
cpu {
cpu.cfs_period_us = 100000;
cpu.cfs_quota_us = 20000; # 20%
}
memory {
memory.limit_in_bytes = 4G; # 硬限
# 如需限制总内存+swap(需内核启用 swapaccount)
# memory.memsw.limit_in_bytes = 6G;
}
blkio {
# 259:0 对应 /dev/nvme0n1(用 lsblk -no MAJ:MIN,NAME 查)
blkio.throttle.read_bps_device = "259:0 104857600";
blkio.throttle.write_bps_device = "259:0 52428800";
}
pids {
pids.max = 512;
}
}
5.3 规则把“谁”放进去(/etc/cgrules.conf)
# user 或 group 匹配,多个控制器用逗号
@players cpu,memory,blkio,pids sandbox_weak/
# 若只限命令名,可:
# *:pathfinder.py cpu,memory sandbox_weak/
应用并检查:
systemctl restart cgconfig cgred
# 新进程会自动进组;已运行的可用 cgclassify 迁移
cgclassify -g cpu,memory,blkio,pids:sandbox_weak $(pgrep -f 'pathfinder|phantom|bot.lua')
# 验证
lscgroup | grep sandbox_weak
cat /sys/fs/cgroup/cpu/sandbox_weak/tasks | wc -l
注意:要想控制 memsw(内存+swap)必须在内核参数里加 swapaccount=1,否则会报不支持。
6. 监控与验证:别光“限”,要可观测
6.1 快速观察(systemd 世界)
systemd-cgls # 层级树
systemd-cgtop # 各 cgroup 实时占用
systemctl status game.slice
systemctl status sandbox.slice
6.2 进阶指标(cgroup v2)
CG=/sys/fs/cgroup/sandbox-cheat
cat $CG/cpu.stat # nr_periods, nr_throttled, throttled_usec
cat $CG/memory.current
cat $CG/memory.events # oom, oom_kill 计数
cat $CG/io.stat
6.3 PSI(压力指标,评估“抖”)
cat /proc/pressure/cpu
cat /proc/pressure/memory
cat /proc/pressure/io
7. 回放当晚数据(Before/After)
| 指标 | 调整前(02:17) | 调整后(02:29) |
|---|---|---|
| Tick 平均(ms) | 25.1 | 11.8 |
gameserver CPU(%) |
120% 波动 | 380% 稳定(限 16 线程) |
| 可疑进程总 CPU(%) | 900% 峰值 | ≤ 20%(被限) |
| 可疑进程 OOM Kill | 0 | 3(超过 4GB 上限) |
| NVMe 读/写(MB/s) | 600 / 350 | 95 / 48 |
| 玩家投诉(条/10min) | 27 | 0 |
说明:gameserver 线程数提升来自我们同步放开的 CPUQuota(从 800%→1600%),并在 game.slice 下保证 IO 权重。
8. 常见坑位 & 现场解法
写 io.max 报错(v2):设备号错。
解:lsblk -no MAJ:MIN,NAME /dev/nvme0n1,按 主:次 写入;部分云盘需写到对应 LVM 映射设备。
cpuset 不生效(v1):没先设置 cpuset.mems。
解:先 echo 对应 NUMA 节点到 cpuset.mems,再写 cpuset.cpus。
RHEL7 MemoryMax 不识别:systemd 太老。
解:用 MemoryLimit= 或直接用 libcgroup 的 memory.limit_in_bytes。
容器环境(Docker/K8s)不跟 systemd:cgroup driver 不一致。
解:Docker/K8s 统一改为 systemd 驱动;容器用 --cpus、--memory、--pids-limit;宿主再用 slice 给 Docker 本身设顶层限额。
cgroup.subtree_control 写失败(v2):你不在可写父目录。
解:在父 cgroup(例如 /sys/fs/cgroup 根或某 slice 目录)写入 +cpu +memory +io +pids。
libcgroup 不自动生效:cgred 没起或规则没匹配。
解:systemctl status cgred,用 cgrulesengd -n 前台调试;规则里用户/组名、控制器列表要对应。
OOM-Kill 杀到我们自己:白名单漏了守护进程。
解:白名单显式列出,或用 Slice=game.slice 统一兜住;测试用逐步收紧策略(先 MemoryHigh 后 MemoryMax)。
过度限速引发“假延迟”:IO 限太死,游戏日志写延后。
解:对官方服务用 IOWeight 增权而非绝对限速;外挂类用绝对上限。
9. 渐进式上线(变更策略)
- 灰度节点:先挑 1 台观测严重的服做变更,跑 24h。
- 只设权重:先上 CPUWeight/IOWeight,观测 PSI。
- 再上硬配额:加 CPUQuota、MemoryHigh,确认无误后最后上 MemoryMax(硬限)。
- 观察窗口:至少两波高峰(晚高峰 & 活动日),关注 nr_throttled 和 oom_kill。
- 回滚预案:保留 drop-in 的 .bak,systemctl revert <unit> 或 systemctl set-property --runtime ... 取消限制。
10. 标准化模板(拿去即用)
10.1 v2(RHEL 9/8)— sandbox.slice 模板
# /etc/systemd/system/sandbox.slice
[Unit]
Description=Slice for Non-official or Suspicious Processes
[Slice]
CPUWeight=10
CPUQuota=20%
MemoryHigh=2G
MemoryMax=4G
IOReadBandwidthMax=/dev/nvme0n1 100M
IOWriteBandwidthMax=/dev/nvme0n1 50M
TasksMax=512
10.2 v1(RHEL 7)— cgconfig.conf + cgrules.conf
# /etc/cgconfig.conf
group sandbox_weak {
cpu { cpu.cfs_period_us = 100000; cpu.cfs_quota_us = 20000; }
memory { memory.limit_in_bytes = 4G; }
blkio {
blkio.throttle.read_bps_device = "259:0 104857600";
blkio.throttle.write_bps_device = "259:0 52428800";
}
pids { pids.max = 512; }
}
# /etc/cgrules.conf
@players cpu,memory,blkio,pids sandbox_weak/
11. 排查小抄(故障快速定位)
我限了,为啥没用?
systemd-cgls | less 看进程落在哪个 slice;
/proc/<pid>/cgroup 查层级;
cpu.stat 里 nr_throttled 是否增加;
v1 看 tasks 是否包含 PID。
谁在啃盘?
iostat -x 1、pidstat -d 1、cat <cgroup>/io.stat。
tick 抖?
/proc/pressure/cpu 的 some avg10 是否飙升;
看 gameserver 线程是否被 throttle(nr_throttled)。
12. FAQ(我被问得最多的几个问题)
Q:是设 CPUQuota 还是设 AllowedCPUs 绑核?
A:我给官方服务优先用 AllowedCPUs 预留核(减少调度争抢),外挂类用 CPUQuota(简单粗暴限额)。两者可并存,但别把 gameserver 绑死在被热 NUMA 节点上,关注 NUMA 亲和。
Q:内存该用 MemoryHigh 还是 MemoryMax?
A:分两步走。先 MemoryHigh 看回压效果,确认不影响主服务,再上 MemoryMax 做硬刹车。
Q:IO 控制一定要绝对带宽吗?
A:对外挂类进程用绝对带宽上限更稳;对官方服务更推荐 IOWeight(比例),保证高峰时能吃到更多盘时隙。
结尾:把“公平”固化到配置里
那晚从 2:17 到 2:29,我没有去追究是谁开的外挂,也没和玩家拉扯“证据链”。我做的只是把“公平”这件事沉到系统层:
官方服务有自己的“高速车道”,其余的一律进“辅路限速”。
第二天早会上,我把这套 slice 与 libcgroup 的模板发给团队,变更上了管控平台,从“救火 SOP”升级为“默认基线”。后来我们在香港新增的 12 台节点,开机后第一件事就是套上这些 cgroup 策略——tick 曲线从此很无聊,但我喜欢这种“无聊”的稳。
如果你也在为服务器上的“外挂进程”烦躁,不妨就照着上面挑一条路线,从灰度一台开始。把公平写进配置,比对人说教更有用。
附:命令速查
# 判定 cgroup 版本
stat -fc %T /sys/fs/cgroup
test -f /sys/fs/cgroup/cgroup.controllers && echo v2
# 观察
systemd-cgls
systemd-cgtop
cat /proc/pressure/{cpu,memory,io}
# v2 迁移进程
CG=/sys/fs/cgroup/sandbox-cheat
mkdir -p $CG
echo "+cpu +io +memory +pids" > /sys/fs/cgroup/cgroup.subtree_control
echo "20000 100000" > $CG/cpu.max
echo $((4*1024*1024*1024)) > $CG/memory.max
for p in $(pgrep -f 'pathfinder|phantom|bot.lua'); do echo $p > $CG/cgroup.procs; done
# v1 应用规则
systemctl restart cgconfig cgred
cgclassify -g cpu,memory,blkio,pids:sandbox_weak $(pgrep -f 'pattern')