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

1. 现场环境与硬件清单(香港专线、NVMe、10G)
1.1 机房与网络
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 |
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% |
16G(high=12G,max=16G) |
nvme0n1 rbps=120M wbps=40M |
4096 | 保障 4 vCPU 的稳定时延 |
workload.slice/search.slice |
800% |
64G(high=56G,max=64G) |
io.weight=500 |
16384 | 给 ES 大头但限制写洪峰 |
workload.slice/batch.slice |
200% |
24G(high=16G,max=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,把临时冻结的批处理恢复。香港的太阳升起来,机房的冷气还是很冷,但心里很暖——我知道,这套方法可以复制到更多的节点和城市。