ARTICLE — POSTS

My Docker 2

今日目标

基于 Cgroup 实现资源控制

如何实现

参考 k8s 官方文档, 有以下两种驱动

  • cgroupfs
  • systemd

cgroupfs 直接操作 /sys/fs/cgroup 下的虚拟文件接口, 而 systemd 则通过 D-Bus 总线与 systemd 通信,将操作 /sys/fs/cgroup 的任务委托给它

为了降低项目复杂度,目前只计划实现 cgroupfs 驱动且不考虑适配 Cgroup v1

将 Cgroup 控制的资源抽象为接口

type Resource interface {
	// CgroupFile 返回 cgroup v2 控制器文件名(如 "cpu.weight")。
	CgroupFile() string
	// CgroupValue 返回要写入文件的格式化字符串。
	CgroupValue() string
	// Validate 做边界检查,失败返回 ErrInvalidResource。
	Validate() error
}

每一种资源最终都会变为字符流(CgroupValue())写入 cgroup 叶子节点对应的文件(CgroupFile())

  1. 内存
// MemoryLimit 单位是字节,对应 cgroup v2 的 memory.max。
// 不允许 0(v2 中 0 = "max" 即无限制),用户想解除限制就别传 -m。
type MemoryLimit int64

func (m MemoryLimit) CgroupFile() string  { return "memory.max" }
func (m MemoryLimit) CgroupValue() string { return strconv.FormatInt(int64(m), 10) }
func (m MemoryLimit) Validate() error {
	if m <= 0 {
		return fmt.Errorf("%w: --memory must be > 0 bytes, got %d",
			ErrInvalidResource, m)
	}
	return nil
}
  1. CPU占用率
// CPUMax 描述 cpu.max 的一行:每周期的 CPU 时间配额和周期长度。
// 对应 cgroup v2 cpu.max 文件格式 "$MAX $PERIOD"(单位均为微秒)。
type CPUMax struct {
	Quota  uint64 // 0 表示 "max"(不限制)
	Period uint64 // 周期长度(微秒)
}

func (c CPUMax) CgroupFile() string { return "cpu.max" }
func (c CPUMax) CgroupValue() string {
	if c.Quota == 0 {
		return fmt.Sprintf("max %d", c.Period)
	}
	return fmt.Sprintf("%d %d", c.Quota, c.Period)
}
func (c CPUMax) Validate() error {
	if c.Period == 0 {
		return fmt.Errorf("%w: cpu-max period must be > 0", ErrInvalidResource)
	}
	return nil
}

驱动接口

// Driver 是 cgroup 后端抽象。cgroupfs 和 systemd 各自实现一份。
//
// 接口维度(不是底层路径):
//   - Create: 申请容器所属的 cgroup(scope 对 systemd 来说是 lazy:在 AddPID 时才创建)
//   - Apply: 写入 Resource 指定的资源限额
//   - AddPID: 把进程加进 cgroup;host PID 而不是容器内 PID
//   - Remove: 销毁 cgroup;可恢复错误(残留进程)记 warning 不 fatal
type Driver interface {
	Create(id, parent string) error
	Apply(id, parent string, resources ...Resource) error
	AddPID(id, parent string, pid int) error
	Remove(id, parent string) error
	Name() string
}

cgroupfs 实现

// cgroupRootFS 是 cgroup v2 统一层级的默认挂载点。
const cgroupRootFS = "/sys/fs/cgroup"

// cgroupParent 是 cgroupfs 驱动创建的父 cgroup 名(位于 cgroupRootFS 之下)。
// 类似 Docker 把所有容器 cgroup 放在 docker/ 之下。
const cgroupParent = "mydocker"

// cgroupfsDriver 直接读写 /sys/fs/cgroup 的实现。
// 所有方法都是 host-namespace 视角;调用者负责传宿主机 PID(不是容器内 PID)。
type cgroupfsDriver struct {
	root string // "/sys/fs/cgroup"
	log  *slog.Logger
}

// NewCgroupfsDriver 在 v2 不可用时返回 ErrCgroupV2Unavailable,让上层 fail-fast。
func NewCgroupfsDriver() (Driver, error) {
	if !CgroupV2Available() {
		return nil, ErrCgroupV2Unavailable
	}
	return &cgroupfsDriver{root: cgroupRootFS, log: slog.Default()}, nil
}

// leafPath 拼出 id 对应的 cgroup 目录绝对路径。
// 我们不暴露 parent 参数——所有 cgroupfs 容器都挂在 cgroupParent 之下,
// 避免用户传任意路径把已存在的 cgroup 当成"父"用。
// cgroup v2 规定资源必须在叶子节点写入
func (d *cgroupfsDriver) leafPath(id string) string {
	return filepath.Join(d.root, cgroupParent, id)
}

// Create 建出 cgroup 目录,并在父 cgroup 中开启 cpu/memory/pids 三个控制器。
//
// 关键:v2 要求父 cgroup 的 cgroup.subtree_control 显式 enable 对应 controller,
// 子 cgroup 才能拿到对应的文件。Create 每次都做幂等检查——只补缺失的。
func (d *cgroupfsDriver) Create(id, parent string) error {
	if err := d.ensureParentCgroup(); err != nil {
		return fmt.Errorf("ensure parent cgroup: %w", err)
	}
	leaf := d.leafPath(id)
	if err := os.Mkdir(leaf, 0o755); err != nil && !errors.Is(err, os.ErrExist) {
		return fmt.Errorf("mkdir %s: %w", leaf, err)
	}
	d.log.Info("cgroup created", "path", leaf)
	return nil
}

// ensureParentCgroup 幂等创建父 cgroup 并补齐 subtree_control。
// 多次创建同名父 cgroup 是预期的——Docker daemon 每次起也只创建一次,
// 但我们这里做成幂等便于 mydocker 多次 run。
func (d *cgroupfsDriver) ensureParentCgroup() error {
	parentDir := filepath.Join(d.root, cgroupParent)
	if err := os.Mkdir(parentDir, 0o755); err != nil && !errors.Is(err, os.ErrExist) {
		return fmt.Errorf("mkdir %s: %w", parentDir, err)
	}

	stPath := filepath.Join(parentDir, "cgroup.subtree_control")
	current, err := os.ReadFile(stPath)
	if err != nil {
		// 第一次创建时文件可能还没刷出来;等一下再读。
		return fmt.Errorf("read subtree_control: %w", err)
	}

	want := []string{"cpu", "memory", "pids"}
	var toEnable []string
	for _, w := range want {
		if !strings.Contains(string(current), w) {
			toEnable = append(toEnable, "+"+w)
		}
	}
	if len(toEnable) == 0 {
		return nil
	}
	// v2 的 subtree_control 一次接受空格分隔的 +list;不能重复 + 同名。
	if err := os.WriteFile(stPath, []byte(strings.Join(toEnable, " ")), 0o644); err != nil {
		return fmt.Errorf("enable controllers %v: %w", toEnable, err)
	}
	d.log.Info("cgroup subtree_control updated", "enabled", toEnable)
	return nil
}

// Apply 把各项 Resource 落到 cgroup 控制器文件上。
// 每个 Resource 自行提供文件名和值,driver 只负责写入。
func (d *cgroupfsDriver) Apply(id, parent string, resources ...Resource) error {
	if len(resources) == 0 {
		return nil
	}
	leaf := d.leafPath(id)

	for _, r := range resources {
		if err := writeCgroupFile(leaf, r.CgroupFile(), r.CgroupValue()); err != nil {
			return fmt.Errorf("set %s=%s: %w", r.CgroupFile(), r.CgroupValue(), err)
		}
	}
	return nil
}

// AddPID 把宿主机视角的 pid 写入 cgroup.procs。
// 必须是 host PID——子进程在新 PID namespace 里的 1 在 cgroup 文件看来是同一个 task_struct。
func (d *cgroupfsDriver) AddPID(id, parent string, pid int) error {
	leaf := d.leafPath(id)
	if err := writeCgroupFile(leaf, "cgroup.procs", strconv.Itoa(pid)); err != nil {
		return fmt.Errorf("add pid %d to cgroup.procs: %w", pid, err)
	}
	return nil
}

// Remove 删除 cgroup 目录。v2 要求目录为空才能 rmdir;
// 我们最多等 1 秒让 cgroup.events 显示 populated=0,再尝试。
// 真的还有残留进程(罕见,例如子进程 fork 了一个不归我们的进程且还活着),
// 打 warning 不报错——cgroup 会泄漏但下一次 mydocker run 还能用。
func (d *cgroupfsDriver) Remove(id, parent string) error {
	leaf := d.leafPath(id)

	// 等 1 秒:覆盖 Wait() 返回后 cgroup.procs 还没刷掉的窗口。
	deadline := time.Now().Add(1 * time.Second)
	for time.Now().Before(deadline) {
		if isCgroupEmpty(leaf) {
			break
		}
		time.Sleep(20 * time.Millisecond)
	}

	if err := os.Remove(leaf); err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil
		}
		// EBUSY 说明还有进程:尝试 cgroup.kill(kernel 5.14+),再 rmdir。
		if errors.Is(err, syscall.EBUSY) {
			d.tryCgroupKill(leaf)
			if err2 := os.Remove(leaf); err2 == nil {
				return nil
			}
		}
		d.log.Warn("cgroup remove failed; leaking", "path", leaf, "err", err)
		return nil
	}
	return nil
}

// tryCgroupKill 写 1 到 cgroup.kill 让内核 SIGKILL 掉残留进程。
// 内核 < 5.14 不存在该文件,直接忽略。
func (d *cgroupfsDriver) tryCgroupKill(leaf string) {
	f, err := os.OpenFile(filepath.Join(leaf, "cgroup.kill"), os.O_WRONLY, 0)
	if err != nil {
		return
	}
	defer f.Close()
	_, _ = f.WriteString("1")
}

// isCgroupEmpty 读 cgroup.events 的 populated 字段判断目录是否还有进程。
// v2 标准接口,kernel 5.7+ 强制存在。
func isCgroupEmpty(leaf string) bool {
	data, err := os.ReadFile(filepath.Join(leaf, "cgroup.events"))
	if err != nil {
		// 读不到就保守认为非空,避免 rmdir 撞 EBUSY。
		return false
	}
	// 格式: "populated 0\n" 或 "populated 1\n"
	for line := range strings.SplitSeq(string(data), "\n") {
		if strings.HasPrefix(line, "populated ") {
			return strings.HasSuffix(line, " 0")
		}
	}
	return false
}

// writeCgroupFile 是 cgroup 文件写入的统一入口。
// 内部走 internal.WriteCgroupValue:单次写入 + read-back 校验。
// 读不到写入值时重试 maxRetries 次(见 internal/atomic.go)。
func writeCgroupFile(leaf, name, value string) error {
	return internal.WriteCgroupValue(leaf, name, value)
}

// Name 满足 Driver 接口。
func (d *cgroupfsDriver) Name() string { return "cgroupfs" }

var _ = Driver(&cgroupfsDriver{})
系列

本系列文章

Docker
  1. My Docker 0
  2. My Docker 1
  3. My Docker 2
  4. My Docker 3