1. 项目概述:为什么“创建 memory cgroup 失败”是运维和开发绕不开的硬骨头
“创建 memory cgroup 失败”这八个字,听上去像一句系统报错日志里的冷冰冰提示,但对任何在 Linux 容器、Kubernetes 或资源隔离场景下摸爬滚打过的工程师来说,它背后往往是一连串真实而紧迫的问题:服务启动卡在 init 阶段、Pod 一直 Pending、容器内存限制不生效、甚至整个节点资源调度突然失灵。这不是理论问题,而是你凌晨三点收到告警时,SSH 连上服务器第一眼就可能看到的拦路虎。核心关键词——memory cgroup、cgroup v1/v2、kernel 参数、OOM killer、systemd、cgroup.procs 写入失败——每一个都直指 Linux 资源管理的底层神经。它解决的不是“能不能用”,而是“能不能稳、能不能准、能不能信”。适合谁?Kubernetes 集群管理员、容器平台开发者、嵌入式系统资源优化人员、CI/CD 流水线稳定性保障工程师,以及所有需要对进程内存行为做确定性约束的技术角色。它不挑人,但极其挑细节:一个内核配置开关没开、一行 systemd 服务配置写错、一次 mount 命令挂载顺序颠倒,都足以让整个 memory cgroup 层级树在初始化阶段就崩塌。我经历过三次典型故障:第一次是某边缘计算节点升级内核后,cgroup v2 默认启用但容器运行时未适配,所有 memory 子系统创建全返回 ENOENT;第二次是某金融后台服务在 systemd 启动脚本里漏加 MemoryMax=,导致 cgroup 目录虽存在却无法写入限制值,OOM 时直接杀掉主进程而非子进程;第三次最隐蔽——某定制发行版裁剪了 CONFIG_MEMCG_SWAP=y,结果 swap accounting 关闭,memory.max 文件根本不会生成,debugfs 下连路径都找不到。这些都不是文档里会重点标红的坑,而是实操中必须亲手踩过、记牢、形成肌肉记忆的经验。
2. 核心机制拆解:memory cgroup 不是“建个目录”那么简单
2.1 从 cgroup v1 到 v2:两套完全不同的资源治理哲学
很多人以为“创建 memory cgroup”就是mkdir /sys/fs/cgroup/memory/myapp,然后往里面写点数字完事。这种理解在 cgroup v1 下勉强能跑通,但在 v2 下会直接撞墙。根本原因在于:v1 是“按控制器分治”,v2 是“统一层级统管”。v1 时代,memory、cpu、pids 各自独立挂载,你可以只挂 memory,不挂 cpu,互不影响。但 v2 强制要求所有控制器(包括 memory)必须挂载在同一棵 cgroup 树上,且只能挂一次。这意味着,如果你执行了mount -t cgroup2 none /sys/fs/cgroup,那 memory 就不再是单独的子系统,而是这棵统一树上的一个可配置属性。此时再试图mkdir /sys/fs/cgroup/memory/xxx,系统会直接返回No such file or directory——因为/sys/fs/cgroup/memory/这个路径压根不存在,v2 下只有/sys/fs/cgroup/xxx这种扁平结构。验证方法极简单:stat /sys/fs/cgroup/ | grep Type,如果输出cgroup2,你就已经身处 v2 世界;如果输出cgroup(无数字),才是 v1。这个判断必须放在所有操作之前,否则后续所有命令都是南辕北辙。我见过太多人对着 v2 环境死磕 v1 的文档,反复尝试echo 100M > /sys/fs/cgroup/memory/myapp/memory.limit_in_bytes,结果永远报错,直到发现目录根本不存在。v2 下等效操作是mkdir /sys/fs/cgroup/myapp && echo 100000000 > /sys/fs/cgroup/myapp/memory.max。注意单位:v1 用limit_in_bytes,v2 用memory.max,且 v2 的值支持max(不限制)、0(禁止使用内存)、具体字节数,还新增了high、low、min等精细化阈值,这是 v1 完全不具备的弹性控制能力。
2.2 kernel 编译选项:那些藏在 .config 里的“隐形开关”
即使你确认了 cgroup 版本,创建失败仍可能源于内核本身就没编译进 memory cgroup 支持。这不是运行时配置问题,而是出厂设置缺失。关键编译选项有三个,缺一不可:
CONFIG_CGROUPS=y:cgroup 框架总开关,必须为 y(内置)或 m(模块)。若为 n,整个 cgroup 系统都不存在。CONFIG_MEMCG=y:memory cgroup 子系统核心开关。若为 n,/sys/fs/cgroup/memory/(v1)或memory.max(v2)文件根本不会生成。CONFIG_MEMCG_SWAP=y:控制是否启用 swap 内存统计。若为 n,v1 下memory.memsw.limit_in_bytes不可用,v2 下memory.swap.max文件不会出现。很多生产环境为简化管理会关闭 swap,但若业务逻辑依赖 swap accounting(如某些 JVM GC 策略),关闭它就会导致预期外的 OOM 行为。
如何快速验证?zcat /proc/config.gz | grep -E "CONFIG_CGROUPS|CONFIG_MEMCG|CONFIG_MEMCG_SWAP"(需内核启用 IKCONFIG_PROC)。若无此文件,则查/boot/config-$(uname -r)。若发现CONFIG_MEMCG=n,恭喜,你遇到的是最底层的硬伤——必须重新编译内核或更换已启用该选项的内核版本。这不是改个配置就能解决的,而是架构级前提。我曾协助某物联网设备厂商排查,其定制内核因追求精简,将CONFIG_MEMCG设为n,导致所有基于 cgroup 的内存隔离方案全部失效,最终不得不回退到用户态内存监控+信号干预的粗糙方案,稳定性大打折扣。
2.3 systemd 的深度耦合:你以为在管 cgroup,其实是在和 systemd 打交道
在现代 Linux 发行版(尤其是 RHEL/CentOS 8+、Ubuntu 20.04+、Debian 11+)中,systemd已成为 cgroup 的事实管理者。它不再只是个 init 系统,更是 cgroup 的“中央调度局”。当你执行systemctl start myapp.service,systemd 会自动为该服务创建对应的 cgroup 路径(如/sys/fs/cgroup/myapp.service),并应用你在 service 文件中定义的资源限制。此时,如果你手动mkdir /sys/fs/cgroup/myapp,systemd 会认为这是非法冲突,可能拒绝启动服务,或在清理时误删你的手动目录。更关键的是,systemd 对 cgroup 的管理有严格生命周期:服务停止时,它会自动递归删除整个 cgroup 目录。这意味着,手动创建的 cgroup 在 systemd 环境下是“临时户口”,而通过 systemd unit 定义的才是“正式编制”。验证方式:systemctl show myapp.service | grep -i cgroup,查看ControlGroup字段是否指向有效路径。若为空或报错,说明 systemd 未成功为其创建 cgroup。常见陷阱是:服务文件中写了MemoryMax=512M,但忘记设置Delegate=yes。Delegate选项决定该服务是否有权在其子 cgroup 中进一步创建和管理子控制器。若为 no(默认),则服务进程无法向myapp.service/child/这类子路径写入限制,导致嵌套容器或子进程内存控制失效。这是 Kubernetes kubelet 启动时必须显式设置Delegate=yes的根本原因。
2.4 权限与命名空间:root 不等于无所不能
即使 root 用户,在容器或非初始 PID 命名空间中,创建 memory cgroup 也会失败。这是因为 cgroup 的创建受CAP_SYS_ADMIN能力约束,而该能力在非初始命名空间中默认被丢弃。docker run --cap-add=SYS_ADMIN可以临时赋予,但这违背最小权限原则,且在 Kubernetes Pod Security Policy(或新版 Pod Security Admission)中会被拦截。更安全的做法是:在宿主机上预先创建好所需 cgroup 目录,并通过--cgroup-parent参数指定容器使用该父目录,让容器进程仅继承已有 cgroup,无需创建新层级。另一个常被忽视的权限点是cgroup.procs文件的写入。创建 cgroup 目录只是第一步,真正生效需将进程 PID 写入cgroup.procs。但该文件有严格规则:只能由进程自身或其父进程写入,且目标进程必须与当前进程处于同一 cgroup 层级或子层级。这意味着,你不能在一个 shell 中echo $$ > /sys/fs/cgroup/myapp/cgroup.procs把自己移进去,因为 shell 进程通常在/sys/fs/cgroup/unified/下,而myapp是同级目录,违反层级约束。正确做法是:先mkdir /sys/fs/cgroup/myapp,再sh -c 'echo $$ > /sys/fs/cgroup/myapp/cgroup.procs; exec "$@"' -- your_command,利用子 shell 的父子关系完成迁移。这个细节决定了“创建”和“生效”之间隔着一道看不见的墙。
3. 实操排障全流程:从报错信息反推根因的七步法
3.1 第一步:精准捕获错误码——别只看“failed”,要看 errno
所有“创建失败”的表象背后,都是具体的 errno(错误号)。这是诊断的起点,绝不能跳过。常见错误码及其含义如下表所示:
| 错误码 | 数值 | 字符串表示 | 典型触发场景 | 关键检查点 |
|---|---|---|---|---|
| ENOENT | 2 | No such file or directory | cgroup v2 环境下尝试访问/sys/fs/cgroup/memory/;内核未启用CONFIG_MEMCG;挂载点未创建 | stat /sys/fs/cgroup/确认 v1/v2;grep CONFIG_MEMCG /boot/config-* |
| EPERM | 1 | Operation not permitted | 非 root 用户操作;进程在非初始 PID 命名空间;cgroup.procs写入违反父子层级规则 | id确认 uid;lsns -t pid查看命名空间;检查cgroup.procs写入逻辑 |
| EACCES | 13 | Permission denied | 目录权限不足(如chmod 700 /sys/fs/cgroup);SELinux/AppArmor 策略阻止 | ls -ld /sys/fs/cgroup/;ausearch -m avc -ts recent(SELinux) |
| ENOSPC | 28 | No space left on device | cgroup v1 下memory.use_hierarchy=1时,子 cgroup 数量超过memory.kmem.limit_in_bytes限制;v2 下memory.max设为 0 | cat /sys/fs/cgroup/memory/memory.use_hierarchy;检查memory.kmem.limit_in_bytes值 |
| EBUSY | 16 | Device or resource busy | cgroup 目录已被其他进程占用;尝试重复挂载同一类型 cgroup | lsof +D /sys/fs/cgroup/;`findmnt |
提示:获取精确 errno 的最佳方式是使用
strace。例如strace -e trace=mkdir,mount,write -f systemctl start myapp.service 2>&1 | grep -A5 -B5 "failed"。它能清晰显示哪个系统调用失败及返回的具体数值,比单纯看 journal 日志可靠十倍。
3.2 第二步:验证 cgroup 挂载状态——mount 命令是你的第一面镜子
mount | grep cgroup输出是诊断的黄金快照。一个健康的 cgroup v2 环境应类似:
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,seclabel)而 cgroup v1 环境则会有多行,如:
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd) cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)关键观察点有三:
- 挂载类型:
cgroup2vscgroup,直接定版本; - 挂载选项:v2 必须有
cgroup2类型;v1 下每行末尾括号内的memory、cpu等即为启用的控制器,若memory缺失,说明该控制器未挂载; - 挂载点路径:v1 下
/sys/fs/cgroup/memory必须存在且可读;v2 下/sys/fs/cgroup/必须存在且类型为cgroup2。
若发现memory控制器在 v1 下未挂载,手动挂载命令为:mount -t cgroup -o memory none /sys/fs/cgroup/memory。但注意:此操作在 systemd 管理的系统中可能被覆盖,永久生效需修改/etc/fstab或 systemd mount unit。
3.3 第三步:检查内核参数与启动配置——/proc/cmdline 是启动时的“遗嘱”
内核启动参数能覆盖编译选项的默认行为。cat /proc/cmdline输出中,以下参数至关重要:
cgroup_enable=memory:强制启用 memory cgroup,即使CONFIG_MEMCG为m(模块),此参数也能确保模块被加载。若缺失,且CONFIG_MEMCG=m,则 memory cgroup 不可用。systemd.unified_cgroup_hierarchy=1:强制启用 cgroup v2。若为0或缺失,则回退到 v1。systemd.legacy_systemd_cgroup_controller=1:在 v2 下启用 v1 兼容模式(不推荐,仅用于过渡)。
修改方法:编辑/etc/default/grub,在GRUB_CMDLINE_LINUX行添加所需参数,如GRUB_CMDLINE_LINUX="cgroup_enable=memory systemd.unified_cgroup_hierarchy=1",然后update-grub && reboot。这是解决“内核编译了但没启用”的终极手段。我曾处理一个案例:客户内核CONFIG_MEMCG=y,但启动参数遗漏cgroup_enable=memory,导致ls /sys/fs/cgroup/下始终没有memory目录,折腾两天才发现启动参数这道门没开。
3.4 第四步:systemd 服务单元深度审计——Unit 文件里的“暗礁”
当故障发生在systemctl start时,必须逐行审计 service 文件。一个健壮的、支持 memory cgroup 的 unit 示例及关键注释如下:
[Unit] Description=My Memory-Constrained App After=network.target [Service] Type=simple ExecStart=/usr/local/bin/myapp # 必须开启委托,否则子进程无法创建自己的 cgroup Delegate=yes # 内存硬限制,v2 下生效 MemoryMax=512M # 内存软限制,v2 下生效,允许短暂超限 MemoryHigh=400M # 防止 OOM killer 杀掉本进程,优先杀子进程 OOMScoreAdjust=-500 # 确保 cgroup 目录在服务启动前创建 RuntimeDirectory=myapp # 若需 swap 限制,且内核启用了 CONFIG_MEMCG_SWAP MemorySwapMax=1G [Install] WantedBy=multi-user.target审计清单:
Delegate=yes是否存在?缺失则嵌套控制失效;MemoryMax值是否为合法格式(支持M,G,K后缀,或纯数字)?MemoryMax=512(无单位)会被解释为 512 字节,瞬间 OOM;OOMScoreAdjust是否设为负值?正值会提高被 OOM killer 选中的概率;RuntimeDirectory是否声明?它确保/run/myapp/目录存在,避免因路径不存在导致启动失败。
3.5 第五步:实时验证 cgroup 状态——不要相信“应该”,要看见“正在”
创建操作完成后,必须立即验证状态,而非假设成功。核心验证命令:
cat /sys/fs/cgroup/myapp/memory.max(v2)或cat /sys/fs/cgroup/memory/myapp/memory.limit_in_bytes(v1):确认限制值已写入且未被重置为max或-1;cat /sys/fs/cgroup/myapp/cgroup.procs:确认目标进程 PID 已列出;cat /sys/fs/cgroup/myapp/memory.current(v2)或memory.usage_in_bytes(v1):确认计数器开始增长,证明统计已激活;ps -o pid,cgroup $(pgrep myapp):确认进程确实位于预期 cgroup 路径下。
一个经典误判是:mkdir成功、echo写入memory.max成功,但cat memory.current始终为 0。这通常意味着进程并未真正运行在该 cgroup 下,而是仍在父 cgroup(如/sys/fs/cgroup/)中。此时需检查cgroup.procs是否为空,或ps命令输出是否匹配。
3.6 第六步:日志交叉分析——journalctl 与 dmesg 的双盲验证
journalctl -u myapp.service -n 50 --no-pager是服务级日志,但关键的 cgroup 底层错误往往出现在内核日志中。dmesg -T | grep -i "cgroup\|memory\|oom"能捕获 kernel panic 级别的 cgroup 初始化失败,例如:
[Mon Jan 1 10:00:00 2024] cgroup: cgroup2: unknown option "memory" [Mon Jan 1 10:00:00 2024] Memory cgroup stats for /myapp: cache:0KB rss:0KB rss_huge:0KB mapped_file:0KB dirty:0KB writeback:0KB inactive_anon:0KB active_anon:0KB inactive_file:0KB active_file:0KB unevictable:0KB第一行明确指出挂载选项错误;第二行则证明 cgroup 已创建且统计正常。两者结合,能快速定位是“创建失败”还是“创建成功但统计异常”。我习惯同时打开两个终端窗口:一个journalctl -f -u myapp,一个dmesg -w | grep -i cgroup,启动服务时同步观察,错误源头几乎秒现。
3.7 第七步:最小化复现——剥离所有干扰,回归本质
当以上步骤均未定位根因,必须构建最小化环境。步骤如下:
- 启动一个干净的
bash(非 systemd 管理的 shell):unshare --user --pid --fork --mount-proc /bin/bash; - 手动挂载 cgroup v2:
mkdir -p /tmp/cgroup && mount -t cgroup2 none /tmp/cgroup; - 创建子目录:
mkdir /tmp/cgroup/mytest; - 尝试写入限制:
echo 100000000 > /tmp/cgroup/mytest/memory.max; - 启动测试进程并迁移:
sleep 1000 & echo $! > /tmp/cgroup/mytest/cgroup.procs; - 观察
cat /tmp/cgroup/mytest/memory.current是否增长。
若此最小环境成功,则问题必在 systemd、SELinux、或上游容器运行时(如 containerd、cri-o)的配置中;若失败,则问题锁定在内核或硬件层面(如某些 ARM 平台对 cgroup v2 支持不完整)。这个方法曾帮我快速排除一个棘手问题:某 ARM64 服务器上,memory.max写入后立即被内核重置为max,最终发现是内核补丁缺失,升级到 5.10.100+ 后解决。
4. 解决方案库:针对不同场景的“抄作业”式配置
4.1 场景一:Kubernetes Node 上 kubelet 启动失败,报错 “failed to create memory cgroup”
这是生产环境最高频的故障。kubelet 作为 Kubernetes 的节点代理,重度依赖 cgroup 进行 Pod 资源隔离。失败原因通常是 cgroup v2 与旧版 kubelet 不兼容,或 systemd 配置冲突。解决方案分三步:
第一步:确认 kubelet 版本与 cgroup 兼容性
- kubelet < 1.21:仅支持 cgroup v1;
- kubelet 1.21-1.24:支持 v1/v2,但需显式配置;
- kubelet >= 1.25:默认要求 cgroup v2。
验证命令:kubelet --version。若版本低于 1.21 但系统为 v2,必须切换到 v1。
第二步:强制 kubelet 使用 v1(适用于旧版 kubelet)编辑/var/lib/kubelet/config.yaml,添加:
cgroupDriver: cgroupfs并确保/etc/systemd/system/kubelet.service.d/10-kubeadm.conf中包含:
Environment="KUBELET_EXTRA_ARGS=--cgroup-driver=cgroupfs"然后重启:systemctl daemon-reload && systemctl restart kubelet。
第三步:为 v2 环境配置 kubelet(推荐,面向未来)
- 确保内核启动参数含
systemd.unified_cgroup_hierarchy=1; /var/lib/kubelet/config.yaml中设置:
cgroupDriver: systemd- 关键!在
/etc/systemd/system/kubelet.service.d/10-kubeadm.conf中,必须添加:
[Service] Delegate=yes这是 kubelet 能在 v2 下创建子 cgroup 的生命线。缺少它,kubelet 会报failed to create cgroup path "/kubepods.slice"。
注意:
Delegate=yes必须写在[Service]段内,且不能被其他文件覆盖。我曾因/etc/systemd/system/kubelet.service.d/20-custom.conf中错误地写了Delegate=no,导致所有配置失效,排查耗时半天。
4.2 场景二:Docker 容器内存限制不生效,docker run -m 512m后top显示内存远超
Docker 本身不直接管理 cgroup,它依赖底层的 containerd 和 runc。问题根源常在 containerd 配置。解决方案:
检查 containerd 配置cat /etc/containerd/config.toml,确认以下配置存在且正确:
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc] runtime_type = "io.containerd.runc.v2" [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options] SystemdCgroup = true # 此项为关键!必须为 true 才能使用 systemd cgroup 驱动若SystemdCgroup = false(默认值),则 runc 使用cgroupfs驱动,与 systemd 管理的 cgroup 冲突,导致限制无效。
验证与修复
- 修改配置后,
systemctl restart containerd; - 重启 Docker:
systemctl restart docker; - 启动测试容器:
docker run -it --rm -m 512m ubuntu:20.04 sh -c 'dd if=/dev/zero of=/dev/null bs=1M count=1000'; - 在宿主机上
cat /sys/fs/cgroup/docker/*/memory.max(v2)或memory.limit_in_bytes(v1),确认值为536870912。
实操心得:
SystemdCgroup = true是 Docker 在 systemd 环境下的“最佳实践开关”。它让容器 cgroup 完全融入 systemd 的统一管理,避免双头管理带来的不一致。但需注意,此设置要求 containerd 1.4+ 和较新内核。
4.3 场景三:自定义 systemd service 中,MemoryMax设置后进程仍被 OOM killer 杀死
这通常不是 cgroup 创建失败,而是 OOM killer 的决策逻辑被误解。MemoryMax是硬限制,一旦突破,内核会立即触发 OOM killer。但 killer 选择杀谁,取决于oom_score_adj值。解决方案:
精细调整 OOM 评分在 service 文件[Service]段中添加:
# 降低本服务被 kill 的优先级(范围 -1000 到 1000,-1000 为永不 kill) OOMScoreAdjust=-500 # 同时,确保子进程继承更低的评分(可选) OOMScoreAdjProcess=yes重启服务后,cat /proc/$(pgrep myapp)/oom_score_adj应返回-500。
添加内存压力缓冲仅靠MemoryMax太刚性。v2 提供更柔性的MemoryHigh:
# 当内存使用接近 400M 时,内核开始积极回收页面,避免冲到 512M 硬限 MemoryHigh=400M # 设置最低保障,确保关键缓存不被回收 MemoryMin=100MMemoryHigh触发的是内存回收(reclaim),而非 OOM killer,用户体验更平滑。
验证 OOM 行为启动服务后,手动制造内存压力:stress-ng --vm 1 --vm-bytes 600m --timeout 60s,观察dmesg是否出现Out of memory: Killed process及被杀进程名。若myapp未被杀,而是stress-ng被杀,说明配置生效。
4.4 场景四:嵌入式设备(ARM)上 cgroup v2 初始化失败,mount返回Invalid argument
部分 ARM SoC(如早期 Rockchip、Allwinner)的内核对 cgroup v2 支持不完整,尤其在memory控制器的 swap accounting 或 kmem accounting 上。解决方案是降级到 v1 并精简功能:
禁用高风险子功能编辑/etc/default/grub,在GRUB_CMDLINE_LINUX中添加:
cgroup_enable=memory swapaccount=0 cgroup.memory=nokmemswapaccount=0:禁用 swap accounting,避免CONFIG_MEMCG_SWAP相关错误;cgroup.memory=nokmem:禁用内核内存(kmem) accounting,解决kmem.limit_in_bytes不可用问题。
强制使用 v1添加systemd.unified_cgroup_hierarchy=0,并确保/etc/fstab中有:
cgroup /sys/fs/cgroup/memory cgroup memory,defaults 0 0然后update-grub && reboot。
经验之谈:嵌入式场景下,“够用就好”是铁律。v2 的先进特性在资源受限设备上可能是负担。我为某工业网关定制的方案,就是彻底关闭 kmem 和 swap accounting,仅保留基础 memory limit,稳定运行三年无 cgroup 相关故障。
5. 高阶避坑指南:那些文档里不会写的实战血泪
5.1 “/sys/fs/cgroup/” 目录被清空?别急着 mkdir,先查 systemd 的“自动清理”
在 systemd 管理的系统中,/sys/fs/cgroup/下的空目录并非“损坏”,而是 systemd 的主动清理策略。systemd 默认启用DefaultControllers=选项,若未显式声明,它会定期扫描并删除空的 cgroup 目录。因此,你手动mkdir /sys/fs/cgroup/myapp后,几秒钟就消失,不是 bug,是 feature。解决方案有两个:
- 永久方案:在
/etc/systemd/system.conf中设置DefaultControllers=memory cpu pids(列出你需要的控制器),并systemctl daemon-reload; - 临时方案:创建一个“占位”进程,如
sleep infinity > /sys/fs/cgroup/myapp/cgroup.procs,只要该进程存活,目录就不会被删。
我的教训:曾为一个监控脚本创建临时 cgroup,每次运行都手动 mkdir,结果脚本在夜间被 systemd 清理,导致监控中断。后来改为
systemd-run --scope --scope-prefix=myapp-monitor --property=MemoryMax=100M bash -c 'your_monitor_script',让 systemd 全权托管生命周期,一劳永逸。
5.2memory.max设为0的真实含义:不是“不限”,而是“禁止”
这是最反直觉的坑。在 cgroup v2 中,echo 0 > memory.max并非设置无限内存,而是完全禁止该 cgroup 使用任何内存。任何尝试分配内存的操作(如malloc)都会立即失败,返回ENOMEM。这常被误用作“禁止进程运行”的手段,但实际效果是进程在main()开始前就因libc初始化失败而退出。正确做法是:
- 若想“不限制”,写
max(字符串):echo max > memory.max; - 若想“严格限制为 0”,仅用于调试或极端安全场景,需确保进程本身不申请内存。
验证:cat memory.max,输出0表示禁止,max表示不限。
5.3 容器内free -h显示内存与 cgroup 限制不符?那是free的“善意谎言”
free命令读取的是/proc/meminfo,而该文件反映的是整个系统的物理内存状态,不受 cgroup 限制影响。容器内看到的total是宿主机内存,而非容器限额。要查看容器实际可用内存,必须读取 cgroup 接口:
- v2:
cat /sys/fs/cgroup/memory.max(限额),cat /sys/fs/cgroup/memory.current(已用); - v1:
cat /sys/fs/cgroup/memory/memory.limit_in_bytes,memory.usage_in_bytes。
free的误导性曾导致我们团队误判一个容器的内存泄漏——free显示用了 2G,而memory.current只有 300M,最终发现是free把宿主机的 buffer/cache 算进去了。记住:cgroup 的真相,永远在/sys/fs/cgroup/下,不在/proc/meminfo里。
5.4cgroup.procs与cgroup.tasks的生死之别:写错一个字母,控制权全失
cgroup.procs和cgroup.tasks都在 cgroup 目录下,但语义天壤之别:
cgroup.procs:写入线程组 leader 的 PID(即getpid()返回的值)。写入后,该线程组的所有线程(包括后续 fork 的)都进入此 cgroup。cgroup.tasks:写入任意线程的 TID(线程 ID)。写入后,仅该线程进入,其子线程不继承。
绝大多数场景应使用cgroup.procs。若误写cgroup.tasks,则 fork 出的新线程会回到父 cgroup,导致内存控制“漏气”。验证方法:ps -o pid,tid,cgroup $(pgrep myapp),观察所有线程的cgroup列是否一致。
最后分享一个小技巧:在调试时,用
echo $$ > /sys/fs/cgroup/myapp/cgroup.procs将当前 shell 移入 cgroup,然后exec your_program,这样整个程序及其所有子进程都受控,比在程序内调用setgid更可靠。这是我写自动化部署脚本时的标准操作,百试百灵。