Runc 与 Cgroups

Posted by icebergu on 07-07,2020

建议提前阅读 Linux Cgroups V1 介绍与使用

Runc 可以算是启动创建容器的最后一步,其中设置 Cgroups,隔离 namespaces,配置网络,挂载相应的卷 等一系列操作
在这里插入图片描述本文将主要讲 runc 是如何去操作系统中的 Cgroups,实现对资源的限制和管理的

Runc 支持三种方式来限制管理资源,分别是使用 Cgroups V1, Cgroups V2, Systemd
本文将主要讲解 Cgroups V1, 关于 Cgroups V1 相关的基本概念可以参考
Linux Cgroups V1 介绍与使用

Cgroup Manager

Cgroup Manager 是 runc 实现对系统的 cgroup 操作抽象
实现了设置资源的配置,PID 加入到指定控制组控制组的销毁,控制组进程的暂停恢复,获取配置,获取统计信息等操作

type Manager interface {
    Apply(pid int) error                  // 将 pid 加入到控制组中
    Set(container *config.Config) error   // 设置控制组的配置
    GetCgroups() (*configs.Cgroup, error) // 获取控制组的配置

    GetPids() ([]int, error)    // 返回控制组(cgroup) 中的 PID, 不包括子控制组
    GetAllPids() ([]int, error) // 返回控制组和它的子控制组的所有 PID
    GetStats() (*Stats, error)  // 获取控制组统计信息

    Destroy() error // 删除控制组

    GetPaths() map[string]string     // 获取保存 cgroup 状态文件的路径
    GetUnifiedPath() (string, error) // 如果容器组没有挂载任何控制器(子系统), 则返回值同 GetPaths,否则,返回 error

    Freeze(state configs.FreezerState) error // 任务的暂停和恢复
}

GetStats 方法会返回控制组中的统计信息,记录了 CPU, Memroy, Blkio 之类的一些状态

type Stats struct {
    CpuStats    CpuStats    `json:"cpu_stats,omitempty"`
    MemoryStats MemoryStats `json:"memory_stats,omitempty"`
    PidsStats   PidsStats   `json:"pids_stats,omitempty"`
    BlkioStats  BlkioStats  `json:"blkio_stats,omitempty"`

    // the map is in the format "size of hugepage: stats of the hugepage"
    HugetlbStats map[string]HugetlbStats `json:"hugetlb_stats,omitempty"`
}

Set 方法在设置控制组 资源的时候需要传递 config.Config实际上该方法只会使用 Config.Cgroup.Resources 中的数据

// Config 定义容器的配置
type Config struct {
    // ....
    Cgroups *Cgroup `json: "cgroup"`
    // ....
}

type Cgroup struct {
    Path        string            `json:"path"`         // cgroup 路径,相对于`层级`的地址
    ScopePrefix string            `json:"scope_prefix"` // ScopePrefix describes prefix for the scope name
    Paths       map[string]string // 所属各个控制器的 cgroup 路径,需要注意该路径是绝对路径

    // 包含了各个子系统资源的设置
    *Resources
}
type Resources struct {
    AllowAllDevices              *bool             `json:"allow_all_devices,omitempty"`
    AllowedDevices               []*Device         `json:"allowed_devices,omitempty"`
    DeniedDevices                []*Device         `json:"denied_devices,omitempty"`
    Devices                      []*Device         `json:"devices"`
    Memory                       int64             `json:"memory"`
    MemoryReservation            int64             `json:"memory_reservation"`
    MemorySwap                   int64             `json:"memory_swap"`
    KernelMemory                 int64             `json:"kernel_memory"`
    KernelMemoryTCP              int64             `json:"kernel_memory_tcp"`
    CpuShares                    uint64            `json:"cpu_shares"`
    CpuQuota                     int64             `json:"cpu_quota"`
    CpuPeriod                    uint64            `json:"cpu_period"`
    CpuRtRuntime                 int64             `json:"cpu_rt_quota"`
    CpuRtPeriod                  uint64            `json:"cpu_rt_period"`
    CpusetCpus                   string            `json:"cpuset_cpus"`
    CpusetMems                   string            `json:"cpuset_mems"`
    PidsLimit                    int64             `json:"pids_limit"`
    BlkioWeight                  uint16            `json:"blkio_weight"`
    BlkioLeafWeight              uint16            `json:"blkio_leaf_weight"`
    BlkioWeightDevice            []*WeightDevice   `json:"blkio_weight_device"`
    BlkioThrottleReadBpsDevice   []*ThrottleDevice `json:"blkio_throttle_read_bps_device"`
    BlkioThrottleWriteBpsDevice  []*ThrottleDevice `json:"blkio_throttle_write_bps_device"`
    BlkioThrottleReadIOPSDevice  []*ThrottleDevice `json:"blkio_throttle_read_iops_device"`
    BlkioThrottleWriteIOPSDevice []*ThrottleDevice `json:"blkio_throttle_write_iops_device"`
    Freezer                      FreezerState      `json:"freezer"`
    HugetlbLimit                 []*HugepageLimit  `json:"hugetlb_limit"`
    OomKillDisable               bool              `json:"oom_kill_disable"`
    MemorySwappiness             *uint64           `json:"memory_swappiness"`
    NetPrioIfpriomap             []*IfPrioMap      `json:"net_prio_ifpriomap"`
    NetClsClassid                uint32            `json:"net_cls_classid_u"`
    CpuWeight                    uint64            `json:"cpu_weight"`
    CpuMax                       string            `json:"cpu_max"`
}

config.Cgroup 需要注意一下,其中 Path 字段是相对于层级挂载路径的控制器路径,层级的概念在Cgroups V1 中已经解释

比如,我的控制组系统中路径是 /sys/fs/cgroup/cpu/iceber/cgroup1
其中/sys/fs/cgroup/cpu 是 Cgroups 的一个层级,他绑定了 cpu 这个子系统/controller
config.Cgroup.Path 就应该是 /iceber/cgroup1

confi.Cgroup.Paths 这个是直接提供每个子系统控制组的系统路径,他会屏蔽掉 Path 字段的作用

Cgroups V1 Manager

// libcontainer/cgroups/fs/apply_raw.go

type Manager struct {
    mu       sync.Mutex
    Cgroups  *configs.Cgroup
    Rootless bool // ignore permission-related errors
    Paths    map[string]string // 记录各个子系统下控制组的路径
}

我们使用 Manager 直接初始化相应字段就可以了

manger := &Manager{
    Cgroups: configCgroup,
}

manager.Cgroups 实际会使用到的字段是 configs.Cgroup.Path,configs.Cgroups.Paths,通过这两个字段来找到子系统控制组路径

manager.Cgroups.Resources 字段可能也会使用到
比如 Freeze 方法就会利用到 manager.Cgroups.Resources.Freezer 字段

apply 方法 将 pid 加入到控制组

apply 的作用是根据 manager.Cgroups.Path/Paths 将 pid 参数加入子系统控制组

getCgroupData 函数会返回 cgroupData,而 subsystem 接口的 Apply 方法便是通过该结构来执行具体的逻辑
cgroupDatapath 方法可以获取子系统的系统路径

func (m *Manager) Apply(pid int) (err error) {
    if m.Cgroups == nil {
        return nil
    }
    m.mu.Lock()
    defer m.mu.Unlock()

    var c = m.Cgroups
    d, err := getCgroupData(m.Cgroups, pid)
    if err != nil {
        return err
    }
    // m.Paths 记录了各个 subsystem 下控制组的路径
    m.Paths = make(map[string]string)

    // 判断 cgroups 配置中是否指定子系统下控制组的路径
    if c.Paths != nil {
        for name, path := range c.Paths {
            // 检查子系统是否挂载正常
            _, err := d.path(name)
            if err != nil {
                if cgroups.IsNotFound(err) {
                    continue
                }
                return err
            }
            m.Paths[name] = path
        }
        // 将 pid 加入到指定控制组中
        return cgroups.EnterPid(m.Paths, pid)
    }
    // 根据cgroups.Path 来获取相应子系统下控制组路径
    for _, sys := range m.getSubsystems() {
        // 获取子系统下控制组路径
        p, err := d.path(sys.Name())
        if err != nil {
            // 由于安全原因,devices 必须存在
            if cgroups.IsNotFound(err) && sys.Name() != "devices" {
                continue
            }
            return err
        }
        m.Paths[sys.Name()] = p
        // 执行具体子系统的 Apply 逻辑
        if err := sys.Apply(d); err != nil {
            // handle error
        }
    }
    return nil
}

我们现在来看一下 getCgroupData 具体做了什么
注意:cgroupData 其实只针对 manager.Cgroups.Path 有效,因为manager.Cgroups.Paths 已经指定了完整系统路径

/*
eg: cgroupData 中的数据是这样的
{
  root: "sys/fs/cgroup"
  innerPath: "/parentCgroup/cgroup1"
  pid: 10000
  config: *config.Cgroup
}
*/
type cgroupData struct {
    root      string          // Cgroups 的挂载目录
    innerPath string          // 子系统下控制组的相对目录
    config    *configs.Cgroup // cgroups 的配置,包含了各个子系统的资源配置,会在子系统设置相关资源时使用
    pid       int             // 加入到控制组的 PID
}

func getCgroupData(c *configs.Cgroup, pid int) (*cgroupData, error) {
    // getCgroupRoot 会通过从 /proc/self/mountinfo 查询 Cgroups 挂载点的目录
    root, err := getCgroupRoot()
    if err != nil {
        return nil, err
    }
    // 要求配置中提供配置组的路径,Path 是相对于 root
    if (c.Name != "" || c.Parent != "") && c.Path != "" {
        return nil, fmt.Errorf("cgroup: either Path or Name and Parent should be used")
    }

    // 对路径进行安全处理
    cgPath := libcontainerUtils.CleanPath(c.Path)
    cgParent := libcontainerUtils.CleanPath(c.Parent)
    cgName := libcontainerUtils.CleanPath(c.Name)

    // 默认使用 Path, 而 Parent 和 Name 其实已经废除
    innerPath := cgPath
    if innerPath == "" {
        innerPath = filepath.Join(cgParent, cgName)
    }
    return &cgroupData{
        root:      root,
        innerPath: innerPath,
        config:    c,
        pid:       pid,
    }, nil
}

Apply 方法最终会调用各个子系统Apply 方法,那我们现在来看一下子系统的接口定义,以及以 cpu 子系统 为例的 Apply 逻辑

subsystem(子系统)/controller(控制器)

Cgroups 中使用 子系统/控制器 来管理和限制具体的资源

runc 提供了 subsystem 接口,定义了控制器需要提供给 Manager 使用的方法


type subsystem interface {
    Name() string // 返回子系统的名字
    // 创建 cgroupData 指定的控制组,并将 cgroupData.pid 加入到该控制组
    Apply(*cgroupData) error
    // 设置控制组路径的资源配置
    Set(path string, cgroup *configs.Cgroup) error

    // 获取指定控制组路径下的资源统计信息
    GetStats(path string, stats *cgroups.Stats) error 
    // 移除 cgroupData 中指定的控制组
    Remove(*cgroupData) error                         
}

// 声明 Manger 会使用的所有子系统
var subsystemsLegacy = subsystemSet{
    &CpusetGroup{},
    &DevicesGroup{},
    &MemoryGroup{},
    &CpuGroup{},
    &CpuacctGroup{},
    &PidsGroup{},
    &BlkioGroup{},
    &HugetlbGroup{},
    &NetClsGroup{},
    &NetPrioGroup{},
    &PerfEventGroup{},
    &FreezerGroup{},
    &NameGroup{GroupName: "name=systemd", Join: true},
}

我们现在来看一看 cpu 子系统的 Apply 方法做了哪些事情

type CpuGroup struct{}

func (s *CpuGroup) Apply(d *cgroupData) error {
    // 获取 cpu 子系统的控制组地址
    path, err := d.path("cpu")
    if err != nil && !cgroups.IsNotFound(err) {
        return err
    }
    return s.ApplyDir(path, d.config, d.pid)
}

func (s *CpuGroup) ApplyDir(path string, cgroup *configs.Cgroup, pid int) error {
    if path == "" {
        return nil
    }
    // 确保路径存在
    if err := os.MkdirAll(path, 0755); err != nil {
        return err
    }
    // 在添加进程加入控制组之前,设置实时资源配置
    // 因为如果进程已经进入 SCHED_RR 模式并且没有设置 RT 带宽,再次添加会失败
    if err := s.SetRtSched(path, cgroup); err != nil {
        return err
    }
    // 将 pid 加入到控制组中
    return cgroups.WriteCgroupProc(path, pid)
}

可以看到,实际 Apply 做了两件事,第一件是创建控制组的目录,第二件事就是把 PID 加入到控制组

我们顺势看一下资源配置是如何设置的,其实就是简单地对控制组下相应配置文件进行修改,并没有什么黑魔法

func (s *CpuGroup) SetRtSched(path string, cgroup *configs.Cgroup) error {
    if cgroup.Resources.CpuRtPeriod != 0 {
        if err := fscommon.WriteFile(path, "cpu.rt_period_us", strconv.FormatUint(cgroup.Resources.CpuRtPeriod, 10)); err != nil {
            return err
        }
    }
    if cgroup.Resources.CpuRtRuntime != 0 {
        if err := fscommon.WriteFile(path, "cpu.rt_runtime_us", strconv.FormatInt(cgroup.Resources.CpuRtRuntime, 10)); err != nil {
            return err
        }
    }
    return nil
}

func (s *CpuGroup) Set(path string, cgroup *configs.Cgroup) error {
    if cgroup.Resources.CpuShares != 0 {
        if err := fscommon.WriteFile(path, "cpu.shares", strconv.FormatUint(cgroup.Resources.CpuShares, 10)); err != nil {
            return err
        }
    }
    if cgroup.Resources.CpuPeriod != 0 {
        if err := fscommon.WriteFile(path, "cpu.cfs_period_us", strconv.FormatUint(cgroup.Resources.CpuPeriod, 10)); err != nil {
            return err
        }
    }
    if cgroup.Resources.CpuQuota != 0 {
        if err := fscommon.WriteFile(path, "cpu.cfs_quota_us", strconv.FormatInt(cgroup.Resources.CpuQuota, 10)); err != nil {
            return err
        }
    }
    return s.SetRtSched(path, cgroup)
}

Manager 再分析

Freeze 暂停/恢复 控制组 中任务

实际是操作 freezer 子系统,修改控制组中 freezer.state 文件

func (m *Manager) Freeze(state configs.FreezerState) error {
    if m.Cgroups == nil {
        return errors.New("cannot toggle freezer: cgroups not configured for container")
    }
    // 获取 Freeze subsystem 的控制组地址
    paths := m.GetPaths()
    dir := paths["freezer"]
    prevState := m.Cgroups.Resources.Freezer
    m.Cgroups.Resources.Freezer = state
    // 获取 subsystem,修改 freezer.state 文件
    freezer, err := m.getSubsystems().Get("freezer")
    if err != nil {
        return err
    }
    err = freezer.Set(dir, m.Cgroups)
    if err != nil {
        m.Cgroups.Resources.Freezer = prevState
        return err
    }
    return nil
}

总结

实际上 runc 相当于使用了一个方便操作系统 Cgroups 的包,这个包并没有什么黑魔法,只是对子系统下的配置文件进行查询和修改