
年初我们把一套老资源管控组件从别的发行版迁到浪潮信息 KeyarchOSKOS上组件本身点名要 libcgroup-2.0-3我当时心想“不就编个 cgroup 工具库么configure 一把梭就完事了”。结果从进入编译环境到 cgconfig 服务真正跑起来前前后后折腾了四天中间踩过的坑基本都能写成一串连续剧。最后理顺之后我意识到这套 libcgroup 在新内核、新 systemd、新 GCC 面前已经不只是“老版本”三个字那么简单——它是 Cgroup v1 时代的一整套行事方式而 KOS 默认面对的是 Cgroup v2 的世界。这篇文章就专门记录我在 KOS 上适配 libcgroup-2.0-3 的完整过程不省略报错不跳过排查思路。如果你也要在 KeyarchOS、或其他 RHEL 系发行版上把一个依赖老 libcgroup 的模块盘活这篇文章应该能帮你少踩至少两天坑。文章覆盖了编译依赖、configure 探测、Makefile 告警、库文件链接、cgroup 挂载形态、SELinux 策略、systemd 单元冲突、最后 RPM 化整条链路适合有一定 Linux 基础但没怎么碰过老 cgroup 工具链的运维、交付和内核适配工程师。1. 为什么点名要 libcgroup-2.0-3业务依赖与版本约束1.1 那套退不掉的调度系统先交代背景。这套资源管控组件负责把数据库实例和几个离线计算进程按项目组划分成不同的 CPU 和内存分组实现在线业务与离线业务之间的资源隔离。它早年是基于 libcgroup 的 cgexec、cgcreate、cgset 这些命令写出来的启动脚本里定义一组 cgroup 目录应用启动前用 cgexec 把进程丢进对应组再按业务时间窗动态调整 cpu.shares 和 memory.limit_in_bytes。这套东西在旧系统上跑了五六年稳定得让人懒得碰它。但迁移到 KOS 之后问题立刻暴露KOS 默认仓库里带的 libcgroup 工具包版本对不上而且系统默认的 cgroup 管理方式早就从“用户自己挂控制器”变成了“systemd 统一管理”。老组件的启动脚本第一步就是 mount cgroup 控制器这一步在新系统上很容易炸。我没法把组件整体重写一是业务方不允许长时间停机二是这套脚本里的 cgroup 参数命名、层级关系都是按 libcgroup 2.0 时代的行为固化下来的换新管理方式等于重新梳理一遍业务规则。于是适配目标非常朴素在 KOS 上把 libcgroup-2.0-3 完完整整编译出来让老脚本跑起来之后行为和以前一致。1.2 不升级的底气2.0-3 在 Cgroup v1 时代是稳定标杆很多人会劝我既然迁移直接换 systemd-run 或者用 cgroup v2 新接口不行吗。技术上当然可行但工程上不现实。libcgroup 2.0-3 对应的是 Cgroup v1 时代非常经典的工具集它提供的不只是命令还有 cgconfig.conf 配置解析、cgrulesengd 规则引擎守护进程、PAM 模块。这套东西把配置—规则—执行完整串起来了老业务脚本全是按这个链路写的。另外 2.0-3 这个 RPM 版本号本身就是一套成熟的发行版打包产物做过源码编译的人都知道源码 tar 包经过发行版维护者打补丁之后跟裸源码的差异可能很大。直接拿 GitHub 上的 libcgroup master 分支编译编译也许能过但很多命令行参数和配置文件格式已经变了。所以我当时就锁定了 2.0-3 这一档尽量找对应的发行版源码包而不是追新版本。这里也提醒一下如果你们的业务依赖的是 cgconfig.conf 里那些 controller 配置块升级大版本前一定要先用 cgsnapshot 把现有 cgroup 配置导出再对比新旧 cgconfigparser 的解析差异。不要想当然认为小版本升级是透明的。2. 开工前把底账摸清KOS 发行版与内核形态核对2.1 最小化安装缺了哪些编译链第一次踩坑发生在最没技术含量的环节依赖缺失。KOS 支持最小化安装很多编译套件默认不装上。我当时敲./configure系统直接提示configure: error: no acceptable C compiler found in $PATH。查了之后发现连 gcc 都没有。先补齐基础工具链sudo dnf install -y gcc gcc-c make autoconf automake libtool但这只是第一轮。libcgroup 2.0-3 编译还要 pam、libxml2 相关的开发包sudo dnf install -y pam-devel libxml2-devel这里要注意KOS 的仓库里 pam-devel 和 libpam 的版本比较新但头文件路径还是标准的/usr/include/security/pam_appl.h一般不会有问题。真正容易翻车的是 libxml2-devel 没装全configure 脚本会跳过 xml 相关的配置项导致后面 cgconfigparser 解析配置时部分能力被静默裁剪。判断依赖缺不缺我建议多留一个心眼编译完成后逐个检查生成出来的二进制依赖了哪些动态库。ldd /usr/local/sbin/cgconfigparser如果输出里出现not found说明对应运行库没装或路径不对。这一条后面还会用到。2.2 先搞清楚 /sys/fs/cgroup 是 v1 还是 v2在动手编译之前有一个问题必须提前确认KOS 默认的 cgroup 挂载形态是什么。检查方法很简单mount | grep cgroup stat -fc %T /sys/fs/cgroup第二条命令会输出文件系统类型。如果是cgroup2fs说明系统默认运行在 Cgroup v2 统一模式如果是tmpfs基本可以判断 /sys/fs/cgroup 下面还有按控制器分开的 v1 挂载点。我在这台 KOS 上看到的是 cgroup2fs。也就是说系统默认交给 systemd 做统一资源控制老的 libcgroup 工具在这一形态下几乎没法正常工作因为 v2 模式下控制器不再分散挂载到/sys/fs/cgroup/cpu、/sys/fs/cgroup/memory这些目录而是统一在一个层级里用cgroup.controllers文件控制。这一步确认完了我心里大概有数后面编译可以正常做但运行时大概率要处理系统 cgroup 形态的问题。这个决定最好在编译前就和业务方同步因为修改内核引导参数会影响整个节点不只是你这一套组件。2.3 确认 glibc 与内核接口的兼容水位libcgroup 2.0-3 编译期还会和 glibc 头文件产生一些摩擦。我用的是 KOS 自带的 GCC版本比较新。老代码里常见的memset、strncpy、snprintf用法在新 glibc 头文件的检查下会变成告警如果 Makefile 里开了-Werror告警直接变报错。建议开工前先记录一下基线版本方便后面排查cat /etc/os-release uname -r rpm -q glibc gcc不要小看这一步。我后面遇到的一个编译报错就是因为老代码里用strncpy拷贝固定长度字符串新 GCC 认为可能存在截断风险直接甩出一个-Werrorstringop-truncation。没有版本信息你很难判断到底是代码问题还是工具链问题。3. 编译期的三场硬仗configure、Makefile、链接器3.1 configure 把 PAM 偷偷降级了libcgroup 2.0-3 的 configure 脚本对 PAM 的探测逻辑比较诡异如果系统缺少 PAM 开发库它不会直接报错而是把--with-pam自动降级成 no最后编译出来的包没有任何 PAM 模块。这种静默失败比直接报错更讨厌因为组件部分功能要依赖 PAM 会话级 cgroup 规则少了模块之后规则引擎完全不生效而表面上看所有命令都是正常的。我第一次编译时没注意 configure 输出直接扫到末尾看到 configuration completed successfully 就走了。等到 cgred/cgrulesengd 起不来才回过头看 config.log发现 PAM 相关检查全部显示 no。处理办法分两步。先确认头文件确实存在ls /usr/include/security/pam_appl.h然后就比较直接了显式指定编译参数./configure --prefix/usr --sysconfdir/etc --libdir/usr/lib64 \ --with-pam --enable-pam --enable-cgred这里要特别留意--enable-cgred。如果你需要 cgrulesengd 这个守护进程必须显式打开否则默认不编译。还有一个小坑configure 脚本如果发现libpam.so不是链接脚本而是纯动态库可能探测失败。RHEL 系发行版有时候为了节省空间只装了libpam.so.0没有生成供链接器使用的libpam.so软链。解决办法是装pam-devel后确认ls -l /usr/lib64/libpam.so如果这个文件不存在就用 dnf 重新安装 pam-devel不要手工去ln -s否则下次升级又断掉。3.2 老代码撞上新 GCC 的 -Werror 墙PAM 那关过了之后真正的编译报错才开始。libcgroup 2.0-3 的代码很多地方仍然保留着 C99 之前的书写习惯比如用strncpy做定长拷贝但没考虑源字符串超长snprintf返回值没有检查截断枚举类型和整数类型隐式转换。KOS 自带的 GCC 默认开启-Wformat-truncation、-Wstringop-truncation这类告警而老 Makefile 里又留着-Werror结果一编译就停../libcgroup-2.0-3/src/libcg.c: In function cg_create_control_group: error: strncpy output truncated before terminating nul copying 16 bytes from a string of the same length [-Werrorstringop-truncation]遇到这种情况不要去逐行改老代码工程量大且容易引入新问题。我当时给 configure 配置了额外的 CFLAGS绕过 -Werror./configure --prefix/usr --sysconfdir/etc --libdir/usr/lib64 \ --with-pam --enable-pam --enable-cgred \ CFLAGS-O2 -g -Wno-errorstringop-truncation -Wno-errorformat-truncation这个处理方式适合先跑起来的适配阶段。如果后面要长期维护建议造一个补丁目录把这些需要忽略的告警项整理成文档附在 RPM spec 里否则下一个编译的人会一脸懵。编译命令也建议直接开多线程make -j$(nproc)第一次建议先单线程编译一次把告警完整记录下来再用多线程重新编这样日志可读性更好。3.3 libcg.so 装完找不到rpath 与 ldconfig 的收尾编译通过之后我以为胜利在望结果运行cgget直接打脸cgget: error while loading shared libraries: libcg.so.0: cannot open shared object file: No such file or directory原因很直白我 configure 时指定--libdir/usr/lib64但make install完之后 ldconfig 缓存没有刷新。正常做法是sudo make install sudo ldconfig但还有第二种可能你如果没指定--libdir默认会装到/usr/local/lib而 KOS 的 linker 搜索路径里默认没有/usr/local/lib。这时候要么改 ld.so.conf要么干脆重新 configure 时固定/usr/lib64。我最终选择了固定在/usr/lib64因为老业务脚本里写死了LD_LIBRARY_PATH如果库不在系统默认搜索路径里会导致脚本执行环境不一致。如果你们有自定义安装路径的需求务必在链接阶段加 rpath否则换一台机器部署总会丢库make LDFLAGS-Wl,-rpath,/opt/libcgroup-2.0-3/lib64把库路径写进二进制的 RUNPATH比到处设LD_LIBRARY_PATH要可靠得多。4. cgconfig 起不来的完整排查链路从 EBUSY 到 systemd 之争4.1 现场现象服务双失败与 journalctl 日志编译安装这关过了真刀真枪跑服务时问题来了。我配置好/etc/cgconfig.conf启动 cgconfig 服务sudo systemctl start cgconfig结果状态是 failed。再看日志journalctl -u cgconfig -n 50输出的关键行是这样的cgconfigparser[1234]: Cannot mount cgroup filesystem (errno16 Device or resource busy) cgconfigparser[1234]: Cannot mount cpu,cpuacct to /sys/fs/cgroup/cpu,cpuacct: Device or resource busy第一反应是挂载点被占用。但被谁占用为什么之前的系统上同样的配置没问题这需要往下挖。4.2 顺着 errno16 往下挖挂载冲突的机制errno16 是 EBUSY表示设备或资源忙。在 cgroup 语境下它通常意味着目标挂载点已经被同一个控制器挂载过或者同一个层级已经存在无法再以不同参数重复挂载。KOS 默认是 Cgroup v2 统一模式此时/sys/fs/cgroup整体是一个 cgroup2 文件系统cpu、memory 这些控制器都归 systemd 管路径上并没有独立的/sys/fs/cgroup/cpu目录。libcgroup 工具启动时会尝试创建独立的 v1 控制器挂载点比如把 cpu 控制器挂到/sys/fs/cgroup/cpu内核发现这些控制器已经加入 cgroup2 的 unified hierarchy就会拒绝再次挂载返回 EBUSY。说白了这不是 libcgroup 本身的 bug而是它和 systemd 在谁来管理控制器这个问题上打起来了。Cgroup v2 为了统一层级不允许同一个控制器同时出现在 v1 和 v2 两个层级里。让 libcgroup 正常工作必须保证这些控制器处于 v1 形态。4.3 最终决策内核切回 Cgroup v1 混合模式我当时评估了三条路保持系统默认 v2想办法只给老组件单独创建一个 v1 子层级不切内核手动用 systemd 的 delegate 方式把部分控制器委托给 cgconfig直接在内核引导参数里把系统全局切到 Cgroup v1 混合模式。第一条路走不通因为 cgroup v2 统一层级下控制器一旦启用就不允许以 v1 方式重新挂载。第二条路只适合systemd-run这种新工具老 libcgroup 直接操作/sys/fs/cgroup的方式完全不在 systemd 的委托机制里。所以最后选了第三条路虽然影响面大但最稳。修改/etc/default/grub在GRUB_CMDLINE_LINUX末尾追加systemd.unified_cgroup_hierarchy0然后重新生成引导配置sudo grub2-mkconfig -o /boot/grub2/grub.cfg sudo reboot重启后验证stat -fc %T /sys/fs/cgroup mount | grep cgroup这次输出里可以看到 tmpfs 挂载在/sys/fs/cgroup下并且/sys/fs/cgroup/cpu、/sys/fs/cgroup/memory等独立目录都已经出现。这一步是整场适配最关键的分水岭后面所有功能都建立在 v1 混合模式之上。这里要给一个非常明确的提示如果你所在的集群里有依赖 Cgroup v2 的容器运行时比如新版 runc 或者 Kubernetes 比较新的节点全局切 v1 之前一定要先做兼容性测试。我在测试环境只跑了几个旧组件看着没问题就切了结果有一台机器上的容器任务出了资源统计不准的现象后来才意识到是 cgroup 形态变化导致 runc 拿不到 v2 接口。最稳妥的做法是先挑一台闲置节点切换观察一两天再铺开。4.4 切完之后的验证与 systemd 单元顺位调整切回 v1 混合模式之后cgconfig 服务不再是必挂失败的状态但启动时机又出现新问题。我配的/etc/cgconfig.conf里定义了 cpu 和 memory 两组配置group production/db { cpu { cpu.shares 2048; } memory { memory.limit_in_bytes 8G; memory.memsw.limit_in_bytes 10G; } }手动执行解析器测试cgconfigparser -l /etc/cgconfig.conf这条命令能过说明配置语法没问题。但一开机启动 cgconfig.service 还是会偶发失败日志依然有 EBUSY。原因在于 systemd 服务启动的顺序和 cgroup 控制器挂载时机冲突。解决办法是在 systemd 单元里显式声明依赖顺序。KOS 安装 libcgroup 之后生成的 unit 文件默认有Afterlocal-fs.target但没有强制要求sysinit.target完整走完。我当时在/etc/systemd/system/multi-user.target.wants/cgconfig.service的基础上又写了 drop-in 配置sudo mkdir -p /etc/systemd/system/cgconfig.service.d cat /etc/systemd/system/cgconfig.service.d/override.conf EOF [Unit] Aftersysinit.target systemd-modules-load.service Beforemulti-user.target [Service] Typeoneshot RemainAfterExityes ExecStart/sbin/cgconfigparser -l /etc/cgconfig.conf ExecStop/sbin/cgconfigparser -l /etc/cgconfig.conf -s EOF这里强调RemainAfterExityes因为 cgconfig 只是加载配置到内核本身不需要常驻进程。配好之后sudo systemctl daemon-reload sudo systemctl enable --now cgconfig sudo systemctl status cgconfig这次才真正稳定下来。5. 规则引擎与命令层踩坑SELinux、套接字和目录不存在5.1 cgexec 在普通用户手里翻车cgconfig 服务正常后我按老脚本执行cgexec -g cpu,memory:production/db /usr/local/bin/important-approot 下运行一切正常切到普通用户马上报cgexec: cgroup of group production/db is not created这个报错有迷惑性它不是说你的 cgroup 没建出来而是普通用户没有权限进入那个 cgroup。libcgroup 的cg_create逻辑会尝试在/sys/fs/cgroup/{cpu,memory}/production/db目录下做操作而 KOS 安装后这些目录默认 root 所有权限是 0755普通用户没法写。老系统的做法是编译后把 cgroup 目录权限统一放开或者用 setuid 辅助工具。我在 KOS 上没继续沿用那套因为权限开太大会引入新风险。最终按需给指定用户授权sudo chown root:appgroup /sys/fs/cgroup/cpu/production /sys/fs/cgroup/memory/production sudo chmod 0775 /sys/fs/cgroup/cpu/production /sys/fs/cgroup/memory/production sudo chown root:appgroup /sys/fs/cgroup/cpu/production/db /sys/fs/cgroup/memory/production/db注意授权不能只做一次因为 cgconfig 服务在每次重启时会重新创建 cgroup 目录权限会被重置。我最后写了个小的 systemd oneshot 服务在 cgconfig.service 之后执行 chown/chmod这样重启后权限不会丢。工程上这比改 libcgroup 源码要稳。5.2 cgrulesengd 的套接字路径与 SELinux 策略另一个隐蔽问题是 cgrulesengd。libcgroup 2.0-3 需要它把/etc/cgrules.conf里的用户/组规则实时翻译成 cgroup 写入操作。我启动它时sudo systemctl start cgrulesengd进程起来了但规则完全不生效。ef/etc/cgrules.conf 里写的是appuser cpu,memory production/db用户切进去跑进程cgget查production/db里的进程 PID并没有新增。查journalctl -u cgrulesengd也没看到明显报错。再查进程状态发现 cgrulesengd 连了/var/run/cgred.socket但 KOS 的/var/run是/run的软链接。理论上这不该有问题但 libcgroup 2.0-3 里有个历史问题它启动时会尝试删除旧的 socket 文件再重新监听如果/var/run/cgred.socket和/run/cgred.socket不是同一个 inode删除和重新绑定之间可能出现路径错乱。最终我通过重新 configure 指定 socket 路径比如用--with-cgred-socket/run/cgred.socket然后重新编译安装问题消失。更隐蔽的是 SELinux。KOS 默认 enforcing 模式cgrulesengd 进程要往/sys/fs/cgroup里写文件还要在内核 netlink socket 上监听用户进程事件。SELinux 策略如果没放行会被静默拦截进程不报错但规则不生效。排查 SELinux 的通用办法sudo ausearch -m avc -ts recent日志里如果出现avc: denied { write } for pid... commcgrulesengd namedb之类的条目就要处理策略。临时验证可以先sudo setenforce 0确认规则生效后再决定是写 SELinux 模块还是给特定目录调整上下文。我这里最终没有关闭 SELinux而是对/sys/fs/cgroup相关路径设了允许策略生产环境不建议直接 setenforce 0。5.3 真实业务压测锁份额与锁定内存的验证规则引擎和权限问题都解决之后一定要做一次端到端验证别只看服务起来了。我的验证方法是第一步确认配置里的 cgroup 层级真实存在cgget -g cpu,memory:production/db输出里应该能看到cpu.shares等于 2048、memory.limit_in_bytes接近 8G。第二步用cgexec启动一个压测进程cgexec -g cpu,memory:production/db stress-ng --cpu 4 --vm 2 --vm-bytes 2G --timeout 60然后从另一个终端用 top 观察进程的 cgroup 归属ps -o pid,comm,cgroup -p PID这里能直接看到进程被放进production/db路径。第三步触发 cgrulesengd 的规则匹配让普通用户直接启动同款压测进程确认规则引擎把新进程自动归入 group不需要手动加 cgexec。这一步如果失败多半还是 SELinux 或权限问题按 5.2 的方法处理。整个验证做下来之后老组件的核心链路才算真正在 KOS 上跑通。从这个节点往回看libcgroup 本身编译不是难点难的是把 cgroup 管理权和 systemd 捋顺。6. 把适配成果固化RPM 打包与升级路径的长期备忘6.1 不要让手工编译成为一次性的秘密适配成功后如果只留一份源码目录在 /tmp三个月后必然有人问这环境当初怎么搭的。我把自己改过的源码连同编译参数整理成了 RPM spec用 rpmbuild 重打包这样以后任何一台 KOS 机器都可以直接安装。spec 文件里的关键段落参考如下%configure \ --prefix/usr \ --sysconfdir/etc \ --libdir%{_libdir} \ --with-pam \ --enable-pam \ --enable-cgred \ --with-cgred-socket/run/cgred.socket \ CFLAGS-O2 -g -Wno-errorstringop-truncation -Wno-errorformat-truncation make %{?_smp_mflags} make install DESTDIR%{buildroot}安装后脚本注意刷新 ldconfig%post /sbin/ldconfig %postun /sbin/ldconfig打包之前强烈建议把源码目录里生成的.o文件和configure缓存清掉否则 rpmbuild 可能会把本机路径带进二进制里换机器安装后出现诡异问题。另外把 cgroup 切 v1 的内核参数写成文档放到服务器上/etc/kos-libcgroup-README避免后来的人以为这个参数是多余的给删了。6.2 后续内核升级会再来找你提前留好兼容接口适配完成后我给业务方提了个醒KOS 如果做内核小版本升级引导参数一般还会保留Cgroup v1 混合模式不至于被悄悄清掉。但如果哪天有安全加固或性能调优要求要把系统切回 Cgroup v2libcgroup 这条路就必须重走一遍。我的建议是在业务脚本里不要直接用mount -t cgroup这种硬编码方式而是加一个检测函数启动时先判断/sys/fs/cgroup是 cgroup2fs 还是 tmpfs再决定是否走 libcgroup 的命令。这个函数的逻辑就是这次适配排查中最值钱的沉淀if [ $(stat -fc %T /sys/fs/cgroup) cgroup2fs ]; then # 这里只警告不自动切 cgroup v1 echo WARNING: cgroup v2 detected, libcgroup compatibility not granted fi不需要自动修复能提前报警就够了。真正的兼容切换动作应该由维护人员评估后手动完成脚本千万不要自作主张去改内核参数。这次适配到最后服务器上留下的不是一堆手工命令记录而是一个可复现的 RPM、一份内核参数说明、一组权限初始化 service以及最关键的一套让老组件在新系统里继续干活的运行环境。以后再有别的机器要接入这套调度系统我不会再慌按顺序装包、改 grub、起服务、验规则四步走完。如果你也在做类似的老组件迁新系统工作建议从一开始就把每个环境的 cgroup 形态、内核参数、SELinux 状态记录成表格。这种问题的难点永远不在单点技术而在环境差异的排列组合。把环境差异表格化踩坑速度至少快一倍。