上一篇 下一篇 分享链接 返回 返回顶部

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

发布人:Minchunlin 发布时间:2025-09-07 11:21 阅读量:725


凌晨 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')
目录结构
全文