我在使用go语言编写一个沙箱项目,使用cgroup进行资源限制。在沙箱启动时,会创建一个cgroup子节点对资源进行限制,当程序结束后,会移除这个沙箱。但是发现在移除的时候报错,发现删不掉。

详细报错内容如下:

time="2025-05-17T10:09:32+08:00" level=warning msg="remove cgroup fail device or resource busy"
time="2025-05-17T10:09:32+08:00" level=warning msg="remove cgroup fail unlinkat /sys/fs/cgroup/memory/sandbox-go/c6e0eaaa-cbcf-426b-a241-6942f844b973/memory.kmem.tcp.max_usage_in_bytes: operation not permitted"

但是我之前已经测试过这个功能,但是并没有发现删除cgroup的时候会出问题。

接着查了下解决方法:

  • 可以用rmdir这个命令删除cgroup下中的目录,或者说子节点。

  • 检查tasks中是否还存在进程id。

  • 该子节点下是否有其他子 cgroup。 --- 可以确认没有

我检查了下tasks中的内容发现是空的,已经没有任何进程id了,所以就使用rmdir方法实验一下。

验证了一下确实可行。

cd /sys/fs/cgroup/cpu,cpuacct
rmdir sandbox-go/febc0ff1-2dfd-4a40-9e90-9c81687766a3/
rmdir sandbox-go/c735af4f-9de9-43a2-8503-0425560a4845/
rmdir sandbox-go/

但是不对,我跟着看了下os.RemoveAll方法的时候, 它底层实际也是用了rmdir这个系统调用。

removeAll方法中,调用Remove方法,在Remove方法中,使用了syscall.Rmdir这个系统调用

func RemoveAll(path string) error {
	return removeAll(path)
}

func removeAll(path string) error {
     // ... 省略
	err := Remove(path)
    // ... 省略
	return nil
}
func Remove(name string) error {
    // .... 省略
	e1 := ignoringEINTR(func() error {
		return syscall.Rmdir(name)
	})
    // .... 省略
	return &PathError{Op: "remove", Path: name, Err: e}
}

既然都是用到了rmdir这个系统调用,我就专门验证了一下单独运行RemoveAll方法,能不能正常删除cgroup子节点。

结果可以正常删除了。

package main

import (
	"fmt"
	"os"
)

func main() {

	err := os.RemoveAll("/sys/fs/cgroup/memory/sandbox-go/a71c0f5f-be44-4873-a567-4b6ba0b8f951")
	if err != nil {
		panic(err)
	} else {
		fmt.Println("remove success")
	}
}

所以导致删除报没有操作权限的问题应该不是使用的删除文件方法不对导致。

对比了一下这次的改动,加了一个超时判断,当超过一定时间,会主动杀死子进程。代码如下

	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(runConfig.MaxRealTime)*time.Millisecond)
	defer cancel()
	done := make(chan interface{}, 1)
	go func() {
		if err := parent.Wait(); err != nil {
			log.Errorf("Run parent.Wait err: %v", err)
		}
		// 结束
		done <- struct{}{}
	}()

	select {
	case <-ctx.Done():
		log.Infof("Run timeout")
		// kill process
		err = parent.Process.Kill()
		if err != nil {
			log.Errorf("Run parent.Process.Kill err: %v", err)
		}
	case <-done:
		log.Info("Run done")
	}

我怀疑是不是parent.Process.Kill()进程没有完全执行完成导致的,我试着在这个后面加了个sleep,等了个一秒之后才放行。

	select {
	case <-ctx.Done():
		log.Infof("Run timeout")
		// kill process
		err = parent.Process.Kill()
		if err != nil {
			log.Errorf("Run parent.Process.Kill err: %v", err)
		}
	case <-done:
		log.Info("Run done")
	}

这次发现也不报错了。

这时候我才意识到,调用parent.Process.Kill()不会立刻把进程杀掉,它只是发送了kill -9这个信号就结束了。所以发送信息号,到真的执行删除cgroup子节点时,这个进程可能还没有真正被kill掉。导致删除的时候删除失败了。

最终把代码改成下面这部分就行了,发送Kill信号后,等待进程结束了再退出。

	select {
	case <-ctx.Done():
		log.Infof("Run timeout")
		// kill process
		err = parent.Process.Kill()
		if err != nil {
			log.Errorf("Run parent.Process.Kill err: %v", err)
		}
		<-done
	case <-done:
		log.Info("Run done")
	}

总结

bug原因

删除cgroup子节点时,该子节点还存在管理的进程(tasks内容不为空),所以无法被删除。

引起bug的原因

引起这个bug的原因有以下几个:

  • 对cgroup机制认识不熟,不熟悉cgroup子节点无法被删除的几种情况。

  • 忘了process.Kill()方法只是发送kill -9信号,不是直接删除进程。

知识汇总

删除cgroup子节点需要满足以下条件:

  • 无子cgroup:该cgroup节点下面没有其他子节点。

  • tasks没有管理的进程:简单说tasks内容要为空,如果不为空表示该cgroup还在管理tasks中的进程资源。无论这些进程是正常运行中的,还是僵尸进程。