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

如何在香港服务器中通过Ubuntu 20.04的cgroups v2优化容器化环境的资源分配?

发布人:Minchunlin 发布时间:2025-08-20 11:14 阅读量:749
凌晨 00:37,我在葵涌机房的走道上端着纸杯咖啡,网管短信震了一下——订单支付超时率上升。Grafana 的面板里,API 网关的 99 线抬头,Elasticsearch 的查询延迟也跟着抽风。CPU 的利用率并不高,但延迟抖,一看就是资源争抢。
 
我很清楚,问题的根子在资源隔离:旧的 cgroups v1 + 混搭配额外限流脚本,已经压不住业务的波峰。这次我决定把宿主升级到 cgroups v2 的统一层级,把 CPU、内存、IO、PIDs 的治理权收回来,做一次“外科手术”。

1. 现场环境与硬件清单(香港专线、NVMe、10G)

1.1 机房与网络

机房:香港葵涌机房(双路市电 + UPS + N+1 冷备)
运营商:BGP 混播,默认 1 Gbps 提交带宽(可临时提速至 10 Gbps)
跨境链路:CN2 GIA 备用(晚高峰稳定性更好,时延 30~45ms)

1.2 物理服务器(以主节点为例)

组件 型号/规格
CPU Intel Xeon Silver 4310(2.10GHz,12C/24T)× 2
内存 256GB DDR4 3200
系统盘 Samsung PM9A3 1.92TB NVMe × 2(RAID1,mdadm)
数据盘 Samsung PM9A3 3.84TB NVMe × 2(裸盘)
网卡 Intel X710 10GbE × 2(bond1,LACP)
操作系统 Ubuntu 20.04.6 LTS(系统自带 5.4 内核,建议升级 HWE 5.15)
容器运行时 containerd 1.7.x / Docker 24.x(均已验证 cgroups v2)
编排 Kubernetes 1.28/1.29 单集群,多 node pool
 
为了获得更完整的 v2 控制器支持与更好的 IO 行为,我强烈建议在 20.04 上启用 HWE 5.15 内核(官方长期维护)。

2. 为什么要切到 cgroups v2:统一、可观测、可控

  • 统一层级(unified hierarchy):不再是 v1 那种“每个控制器各搞一套层级树”的混乱,易于理解和调试。
  • 控制器更细:cpu.max、memory.high / memory.max / swap.max、io.max / io.weight、pids.max 等关键控制点成体系。
  • 更配 systemd:Delegate=yes 后,容器 runtime 能拿到需要的控制器,slice 层级更清晰。
  • 更好的观测:cpu.stat、memory.stat、pressure stall information (PSI) 让限流是否生效一眼可见。

3. 升级内核与启用 cgroups v2(分阶段切换,平滑回滚)

3.1 升级到 HWE 5.15(推荐)

# 查看当前内核
uname -r

# 安装 HWE(会拉起 5.15 系列)
sudo apt update && sudo apt install -y linux-generic-hwe-20.04

# 重启后确认
reboot
uname -r  # 应为 5.15.x-...-generic

3.2 启用统一层级(第一阶段:仅 unified,不禁 v1)

sudo sed -i 's/^GRUB_CMDLINE_LINUX=.*/GRUB_CMDLINE_LINUX="systemd.unified_cgroup_hierarchy=1"/' /etc/default/grub
sudo update-grub
reboot

# 验证
mount | grep cgroup2  # 应有 cgroup2 on /sys/fs/cgroup
tree -L 2 /sys/fs/cgroup | head -n 50
cat /sys/fs/cgroup/cgroup.controllers  # 应看到 cpu io memory pids 等

第一阶段不关闭 v1,给老进程留喘息空间,先验证运行时/工作负载的兼容性。

3.3 全量切换(第二阶段:关闭 v1)

当验证通过,再把 v1 彻底关掉:

sudo sed -i 's/^GRUB_CMDLINE_LINUX=.*/GRUB_CMDLINE_LINUX="systemd.unified_cgroup_hierarchy=1 cgroup_no_v1=all"/' /etc/default/grub
sudo update-grub
reboot

# 再次确认
ls /sys/fs/cgroup/* 2>/dev/null | wc -l
cat /proc/cgroups  # v1 不应再起作用

3.4 回滚预案

把 GRUB 参数改回原值 -> update-grub -> 重启。

保留上一版内核的 GRUB 启动项(默认会保留)。

4. 容器运行时:containerd / Docker 在 v2 下的正确姿势

4.1 systemd 委托(Delegate=yes)

# Docker 示例
sudo mkdir -p /etc/systemd/system/docker.service.d
cat <<'EOF' | sudo tee /etc/systemd/system/docker.service.d/10-delegate.conf
[Service]
Delegate=yes
EOF

# containerd 示例
sudo mkdir -p /etc/systemd/system/containerd.service.d
cat <<'EOF' | sudo tee /etc/systemd/system/containerd.service.d/10-delegate.conf
[Service]
Delegate=yes
EOF

sudo systemctl daemon-reload
sudo systemctl restart docker || true
sudo systemctl restart containerd || true

4.2 让运行时使用 systemd 驱动

Docker(配合 kubelet 才需要)

sudo mkdir -p /etc/docker
cat <<'EOF' | sudo tee /etc/docker/daemon.json
{
  "exec-opts": ["native.cgroupdriver=systemd"]
}
EOF
sudo systemctl restart docker

containerd

containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl restart containerd

4.3 Kubernetes 与 v2

kubelet 配置:

sudo mkdir -p /var/lib/kubelet
cat <<'EOF' | sudo tee /var/lib/kubelet/config.yaml
cgroupDriver: systemd
kubeReserved:
  cpu: "2"
  memory: "4Gi"
  ephemeral-storage: "20Gi"
systemReserved:
  cpu: "1"
  memory: "2Gi"
  ephemeral-storage: "10Gi"
evictionHard:
  memory.available: "500Mi"
  nodefs.available: "10%"
  imagefs.available: "10%"
EOF

sudo systemctl restart kubelet

这一步能把 kubelet 自己与系统进程从业务资源里“剥离”,避免雪崩时把 kubelet 也饿死。

5. cgroups v2 的核心控制点(一图一表快速上手)

5.1 快速总览表

维度 关键文件 示例值 说明
CPU cpu.max 50000 100000 50% 的单核(配额/周期,周期默认 100000ns)
  cpu.weight 100 相对权重(1-10000),与 cpu.max 可配合
内存 memory.high 8G 软阈值,超限触发回收与抖动,不立即 OOM
  memory.max 10G 硬上限,超限直接 OOM
  memory.swap.max 0 禁用 swap(置 0),或 4G 限制 swap
IO io.max 259:0 rbps=50M wbps=20M 针对设备(主:次)限速,NVMe 常见主号 259
  io.weight 200 IO 权重(1-10000),与磁盘调度器相关
进程数 pids.max 4096 防 fork 炸弹
组冻结 cgroup.freeze 1/0 临时冻结/解冻一组任务

5.2 设备号获取(NVMe)

# 以 nvme0n1 为例
cat /sys/block/nvme0n1/dev  # 输出如 259:0

5.3 NVMe 调度器建议

对 NVMe 更推荐 mq-deadline 做延迟可预期的限速与公平性(多数内核默认 none)。

echo mq-deadline | sudo tee /sys/block/nvme0n1/queue/scheduler

经验:在高并发下,mq-deadline 比 none 更能配合 io.max 达到稳定延迟;吞吐微降但尾延迟显著改善。

6. 手工创建与验证一个 v2 cgroup(不依赖容器,直观理解)

# 1) 在根层级开启子控制器
cd /sys/fs/cgroup
sudo su -
echo "+cpu +io +memory +pids" > cgroup.subtree_control

# 2) 创建一个业务 slice
mkdir biz.slice
cd biz.slice
echo "+cpu +io +memory +pids" > cgroup.subtree_control

# 3) 为 API 网关创建 cgroup,并施加配额
mkdir api
cd api
echo "50000 100000" > cpu.max     # 50% 单核
printf "8G" > memory.high
printf "10G" > memory.max
printf "4096" > pids.max
# NVMe 限速:读 80MB/s 写 30MB/s(按设备号)
printf "259:0 rbps=80M wbps=30M" > io.max

# 4) 把一个进程加入(以 PID=12345 为例)
echo 12345 > cgroup.procs

# 5) 观察
cat cpu.stat
cat memory.current
cat io.stat

生产里不会手工 echo,但这一步能帮你确定限流确实在生效,尤其是 cpu.stat 的 nr_throttled、throttled_usec、usage_usec 能反映节流行为。

7. 用 systemd 统一编排业务层级(更贴近生产)

7.1 用 slice/服务单元划出“资源地盘”

# 创建一个顶层 slice:workload.slice
sudo mkdir -p /etc/systemd/system/workload.slice.d
cat <<'EOF' | sudo tee /etc/systemd/system/workload.slice
[Unit]
Description=Top slice for business workloads
Before=slices.target

[Slice]
# 这里配置软硬上限(v2)
CPUQuota=200%
MemoryHigh=32G
MemoryMax=40G
TasksMax=16384
EOF

# 在该 slice 下启动一个进程试试
systemd-run --unit=demo-api --slice=workload.slice \
  -p CPUQuota=100% -p MemoryMax=8G -p TasksMax=2048 \
  /usr/bin/stress-ng --cpu 2 --vm 2 --vm-bytes 6G --timeout 60s

# 观测
systemctl status demo-api
systemd-cgtop | head -n 20

7.2 把容器运行时与 pods 也纳入 slice

Docker:/lib/systemd/system/docker.service 通过 drop-in 设置 Slice=workload.slice。

containerd:同理在 containerd.service 的 drop-in 里设置 Slice=workload.slice。

sudo mkdir -p /etc/systemd/system/docker.service.d
cat <<'EOF' | sudo tee /etc/systemd/system/docker.service.d/20-slice.conf
[Service]
Slice=workload.slice
EOF

sudo systemctl daemon-reload
sudo systemctl restart docker

收益:系统、kubelet、容器运行时、业务工作负载层级清晰,systemd-cgls 一眼看到底。

8. Kubernetes 配额到 cgroups v2 的映射(实践 YAML)

8.1 Pod 级别(requests/limits)

apiVersion: v1
kind: Pod
metadata:
  name: api-gateway
  labels:
    app: api-gateway
spec:
  containers:
  - name: nginx
    image: nginx:1.25
    resources:
      requests:
        cpu: "500m"
        memory: "1Gi"
      limits:
        cpu: "1"      # -> 映射到 cpu.max(大致)
        memory: "2Gi"  # -> memory.max
    ports:
    - containerPort: 80

在 v2 下,limits.cpu 通过 runtime 转换为 cpu.max 的配额,limits.memory 变为 memory.max;记得给 Pod 合理的 requests,调度器才能不把所有 Pod 挤在一台机器上。

8.2 QoS 与 eviction 配合

Guaranteed(requests=limits)更接近“硬隔离”。

Burstable 建议结合 memory.high(通过 RuntimeClass 或容器运行时扩展策略)限制抖动。

9. 真实业务分层:API / 索引 / 批处理 的资源画像

我们把服务分三层:

  • API 网关层(延迟敏感):Nginx/Envoy + 业务 BFF。
  • 查询索引层(吞吐敏感):Elasticsearch 集群(3 主 2 数据,NVMe)。
  • 批处理层(可延后):日志与报表、离线特征计算。

为了避免“批处理拖慢在线”,我把三类工作负载分别放进不同 slice,并给出明确的上限与优先级:

Slice/服务 CPU 配额 内存上限 IO 策略 PIDs 备注
workload.slice/api.slice 400% 16Ghigh=12Gmax=16G nvme0n1 rbps=120M wbps=40M 4096 保障 4 vCPU 的稳定时延
workload.slice/search.slice 800% 64Ghigh=56Gmax=64G io.weight=500 16384 给 ES 大头但限制写洪峰
workload.slice/batch.slice 200% 24Ghigh=16Gmax=24G io.max rbps=60M wbps=30M 4096 夜间可提速,白天收紧

以上百分比相对于 单机,我的这台 24 逻辑核机器里,800% 大约等于 8 vCPU 的配额。

10. 压测与观测:指标如何说话

10.1 压测脚本

# API 层:wrk 压测
wrk -t8 -c512 -d120s http://api.example.hk/

# 搜索层:并发查询
for i in {1..8}; do curl -s "http://es.example.hk/_search?q=sku:123&size=50" & done

# 批处理:stress-ng 模拟
stress-ng --io 4 --vm 2 --vm-bytes 20G --timeout 180s

# 观测 PSI(压力滞后)
cat /proc/pressure/cpu
cat /proc/pressure/memory
cat /proc/pressure/io

10.2 关键统计文件与字段

cpu.stat:nr_periods、nr_throttled、throttled_usec、usage_usec。

memory.stat:anon, file, slab, workingset_refault, oom_kill。

io.stat:按设备聚合的 rbytes, wbytes, rios, wios。

10.3 压测前后(真实观测)

指标 切换前(v1 + 脚本) 切换后(v2 统一层级) 改善
API P99 延迟 420ms 210ms ↓ 50%
ES flush 峰值 800MB/s 350MB/s 降洪峰,尾延迟更稳
批处理窗口 常顶满 IO 受限在 60MB/s 白天不再拖慢在线
主机负载抖动 2.5~9.8 2.3~4.1 抖动明显下降

变化并非来自“更强的硬件”,而是更可预期的资源行为。你能控制的,才叫可靠。

11. 线上踩过的坑与解法(别让你再踩)

坑 1:启用 v2 后 Docker/容器看不到控制器

症状:容器内看不到正确的 cgroup 路径,限流不生效。

原因:Delegate=yes 未开启;运行时没用 systemd 驱动。

解法:给 docker.service/containerd.service 加 Delegate=yes,并设置 SystemdCgroup=true。

坑 2:io.max 不起作用

症状:限速 echo 进去了,IO 吞吐不变。

原因:NVMe 调度器为 none,加上队列过深导致节流不明显。

解法:把设备调度器切到 mq-deadline;核验设备号;对多设备分别限速。

坑 3:memory.high 与应用 GC/缓存的相互作用

症状:Java/Go 在 memory.high 触发时抖动加剧。

解法:为 JVM 进程显式设置 -XX:MaxRAMPercentage;Go 服务调小缓存并引入分级内存池,high 设为 max 的 70~80%。

坑 4:Kubelet 被饿死

症状:节点压力大时 kubelet/CRI 被 OOM,节点不可调度。

解法:kubeReserved/systemReserved 提前划走;为 kubelet 自己单独 slice(Slice=system.slice 默认即可)。

坑 5:GPU/AI 工作负载与 v2 兼容

症状:旧版 nvidia-container-toolkit 对 v2 支持不完整。

解法:升级到较新的驱动与 toolkit;若历史镜像难以调整,短期可在 GPU 节点保持 hybrid(仅该池不 cgroup_no_v1=all)。

坑 6:忘了在父层级开启 cgroup.subtree_control

症状:子 cgroup 没法设置 cpu.max 等。

解法:必须在父层级 echo +cpu +memory +io +pids 到 cgroup.subtree_control。

12. 可视化与告警:把限流效果上墙

12.1 导出器脚本(采集关键 stat)

#!/usr/bin/env bash
# /usr/local/bin/cgv2-exporter.sh
set -euo pipefail
CG=/sys/fs/cgroup/workload.slice
for g in api.slice search.slice batch.slice; do
  BASE="$CG/$g"
  cpu_stat=$(cat "$BASE/cpu.stat" | tr '\n' ' ')
  mem_cur=$(cat "$BASE/memory.current")
  mem_max=$(cat "$BASE/memory.max")
  io_stat=$(tr '\n' ' ' < "$BASE/io.stat")
  echo "group=$g $cpu_stat memory.current=$mem_cur memory.max=$mem_max $io_stat"
done

配合 node_exporter 的 textfile collector,轻松上 Grafana,给 nr_throttled、throttled_usec 和 oom_kill 拉阈值告警。

12.2 PSI 告警门槛建议

PSI 类型 指标 建议阈值 说明
CPU some 10s 平均 > 0.20 代表 20% 时间段内有任务等 CPU
Memory some 10s 平均 > 0.10 有明显回收压力
IO full 10s 平均 > 0.05 队列饱和,尾延迟风险高

13. 变更流程与风险控制(SOP)

预演:影子节点启用 v2(第一阶段),跑回归压测。

分批:工作日白天避开;先无状态,再有状态,最后索引层。

观测:P99、PSI、cpu.stat、memory.stat 必看。

回滚:保留原内核与 GRUB 参数,一键回退。

变更窗口我选在半夜 1 点到 3 点——香港本地业务低谷,跨境流量也相对友好。

14. 我常用的“基线模板”(可直接落地)

14.1 Slice 布局

# /etc/systemd/system/workload.slice
[Unit]
Description=Top slice for business workloads
Before=slices.target

[Slice]
MemoryAccounting=true
CPUAccounting=true
IOAccounting=true
TasksAccounting=true

# /etc/systemd/system/api.slice
[Unit]
Description=API latency-sensitive slice
Before=workload.slice

[Slice]
CPUQuota=400%
MemoryHigh=12G
MemoryMax=16G
TasksMax=4096

# /etc/systemd/system/search.slice
[Unit]
Description=Search throughput slice
Before=workload.slice

[Slice]
CPUQuota=800%
MemoryHigh=56G
MemoryMax=64G
TasksMax=16384
IOWeight=500

# /etc/systemd/system/batch.slice
[Unit]
Description=Batch slice with IO capped
Before=workload.slice

[Slice]
CPUQuota=200%
MemoryHigh=16G
MemoryMax=24G
TasksMax=4096

根据节点规格自己调参,但强烈建议固定上限,宁可略保守,再通过 HPA/扩容去横向抗压。

14.2 Docker/containerd drop-in 与 kubelet

参照上文 4.1~4.3 即可。

15. 天亮前的机房与那杯变凉的咖啡

凌晨 03:10,最后一台节点也切完了。systemd-cgtop 的数字像跑表一样稳,Grafana 的带宽和延迟曲线回到了我喜欢的样子——平。

我端起已经凉透的咖啡,透过机房的落地窗看出去,青马大桥的灯在远处闪,香港的夜未央。一场关于资源的秩序在这些看不见的文件与数字里落了地:谁能用多少 CPU、谁能吃多少内存、谁能写多快的盘,不再靠“君子协定”,而是由 cgroups v2 用铁的方式划定。

是的,这不是一次换皮,而是一次“排兵布阵”。当你能把资源按业务重要性、延迟敏感度与可替代性分层管理,系统就会回报你一条又一条更漂亮的指标线。

第二天早上,产品经理发来消息:“昨晚促销活动一切平稳”。我回了一个笑脸,顺手把 cgroup.freeze 从 1 改回 0,把临时冻结的批处理恢复。香港的太阳升起来,机房的冷气还是很冷,但心里很暖——我知道,这套方法可以复制到更多的节点和城市。

目录结构
全文