文章482
标签257
分类63

Docker原理实战-4:容器Container

前三篇分别讲解了Linux内核所以提供的功能:Namespace、Cgroups和UnionFS,本篇使用这些技术,真正的实现一个类似于Docker运行环境下的容器;

系列文章:

源代码:


Docker原理实战-4:容器Container

前言:proc文件系统

在手写我们的Container之前,先简单介绍一下Linux中的/proc

众所周知,在Linux中,一切皆文件;而/proc文件系统是由内核提供的,它并非真正的文件系统,而是存在于内存中的、包含了整个系统运行时的信息(硬件配置、系统mount设备等);

/proc以文件的形式存在,为访问内核数据的操作提供了接口;

实际上,很多系统工具就是简单读取/proc下的某个文件内容实现的;

如:lsmod命令,实际上就是cat /proc/modules

下面是关于/proc下的一些目录的总结:

路径 说明
/proc/N PID为Ν的进程信息
/proc/N/cmdline 进程启动命令
/proc/N/cwd 链接到进程当前工作目录
/proc/N/environ 进程环境变量列表
/proc/N/exe 链接到进程的执行命令文件
/proc/N/fd 包含进程相关的所有文件描述符
/proc/N/maps 与进程相关的内存映射信息
/proc/N/mem 指代进程持有的内存,不可读
/proc/N/root 链接到进程的根目录
/proc/N/stat 进程的状态
/proc/N/statm 进程使用的内存状态
/proc/N/status 进程状态信息,比 stat/statm更具可读性
/proc/self/ 链接到当前正在运行的进程

对于/proc/N下的各个目录,基本上完整的描述了一个进程在操作系统中的内容;

尤其要注意的是:/proc/self/

看似比较鸡肋,但是是后面我们构造容器时具有终于作用!

了解了/proc之后,我们开始真正的构建一个容器吧!

为了简单起见,我们先按照自底向上的顺序,介绍容器内资源限制的组件,然后再自顶向下地构建命令行工具;


一、使用Cgroups限制容器资源组件

本小节从定义Subsystem接口开始,自底向上地介绍如何使用Cgroups限制进程资源;

首先,为了简单起见,我们仅仅对下面三种资源进行限制,其他资源也是完全类似的实现方式:

  • 内存限制;
  • CPU核心数;
  • CPU时间片权重;

1、Subsystem接口抽象

根据上面的资源限制类型,抽象出对应的Subsystem接口:

chapter3_container/3_3_container_with_limit_and_pipe/cgroups/subsystems/subsystems.go

package subsystems

var (
    SubsystemIns = []Subsystem{
        &CpuSetSubSystem{},
        &MemorySubSystem{},
        &CpuSubSystem{},
    }

    CgroupConfigPath = "tasks"
)

// ResourceConfig 用于传递资源限制的结构体
type ResourceConfig struct {
    MemoryLimit string // 内存限制
    CpuShare    string // CPU时间片权重
    CpuSet      string // CPU核心数
}

// Subsystem Subsystem接口
// 这里将 cgroups 抽象为了 path,因为 cgroups 在 hierarchy 的路径就是虚拟文件系统中的路径!
type Subsystem interface {
    // Name 返回subsystem的名称,如:cpu、memory等
    Name() string

    // Set 设置某个cgroup在这个subsystem中的资源限制
    Set(path string, res *ResourceConfig) error

    // Apply 将指定pid对应的进程添加至某个cgroup中
    Apply(path string, pid int) error

    // Remove 移除某个cgroup
    Remove(path string) error
}

Subsystem接口共包含了四个方法:

  • Name():返回对应subsystem的名称,如:memorycpuset等;
  • Set(path string, res *ResourceConfig):用于设置某个cgroup在这个subsystem中的资源限制;
  • Apply(path string, pid int):将指定pid对应的进程添加至某个cgroup中;
  • Remove(path string):移除某个cgroup;

这样,我们就可以使用Set方法设置一个对应的硬件资源,并使用Apply方法让其对某个PID生效!


2、一些辅助函数

在之前第二篇对于Cgroups的讲解中,我们知道:

Cgroups是目录型的结构,而如果我们想要限制进程资源,只需要在Cgroups的根路径下创建新的目录,并修改该目录下的配置,并将进程的PID移至这个新目录下的tasks文件中即可;

为了实现这个目的,我创建了两个工具函数,如下:

chapter3_container/3_3_container_with_limit_and_pipe/cgroups/utils/utils.go

package utils

var (
    DefaultCgroupFilePerm os.FileMode = 0755

    DefaultCgroupConfigFilePerm os.FileMode = 0755
)

// FindCgroupMountPoint 查询对应subsystem在当前进程中的挂载点路径
// /proc/self/mountinfo 文件格式:
// 43 35 0:37 / /sys/fs/cgroup/memory rw,nosuid,nodev,noexec,relatime shared:18 - cgroup cgroup rw,memory
// 查找最后一部分的逗号分隔字段,如:memory
func FindCgroupMountPoint(subsystem string) string {
    // 打开当前进程挂载文件(后面会查询信息)
    f, err := os.Open("/proc/self/mountinfo")
    if err != nil {
        return ""
    }
    defer func(f *os.File) {
        err := f.Close()
        if err != nil {
            log.Error(err)
        }
    }(f)

    scanner := bufio.NewScanner(f)
    for scanner.Scan() {
        content := scanner.Text()
        fields := strings.Split(content, " ")
        for _, opt := range strings.Split(fields[len(fields)-1], ",") {
            if opt == subsystem {
                return fields[4]
            }
        }
    }
    if err := scanner.Err(); err != nil {
        log.Error(err)
        return ""
    }
    return ""
}

// GetCgroupPath 获取当前进程中指定cgroup对应的路径
func GetCgroupPath(subsystem string, cgroupPath string) (string, error) {
    cgroupRoot := FindCgroupMountPoint(subsystem)

    // 查询文件是否已经存在
    _, err := os.Stat(path.Join(cgroupRoot, cgroupPath))
    if err != nil {
        // 非文件不存在错误,返回错误
        if !os.IsNotExist(err) {
            return "", fmt.Errorf("cgroup path error %v", err)
        } else { // 如果是文件不存在错误,创建
            if err := os.Mkdir(path.Join(cgroupRoot, cgroupPath), DefaultCgroupFilePerm); err != nil {
                return "", fmt.Errorf("error create cgroup %v", err)
            }
        }
    }

    return path.Join(cgroupRoot, cgroupPath), nil
}

上面定义了两个辅助工具函数:

  • FindCgroupMountPoint:查询对应subsystem在当前进程中的挂载点路径;
  • GetCgroupPath:获取当前进程中指定子cgroup对应的路径(如不存在,则创建);

由于不同的subsystem所在的系统Cgroups配置根路径是不同的,因此:

我们需要一个函数来帮助我们寻找对应Cgroups下的subsystem在/proc中的路径,即GetCgroupPath函数

需要注意的是:对于不同的进程,其所挂载的文件系统也是不同的!

我们可以在当前进程中通过查看/proc/self/mountinfo文件,获取当前进程所对应subsystem的挂载点根路径!

文件格式如下:

43 35 0:37 / /sys/fs/cgroup/memory rw,nosuid,nodev,noexec,relatime shared:18 - cgroup cgroup rw,memory

可以看到最后一部分的逗号分隔字段,如:memory;

则当前进程的cgroup根路径为:/sys/fs/cgroup/memory

FindCgroupMountPoint函数

FindCgroupMountPoint主要是用在GetCgroupPath函数中的,因此我们先来看FindCgroupMountPoint函数;

// FindCgroupMountPoint 查询对应subsystem在当前进程中的挂载点路径
// /proc/self/mountinfo 文件格式:
// 43 35 0:37 / /sys/fs/cgroup/memory rw,nosuid,nodev,noexec,relatime shared:18 - cgroup cgroup rw,memory
// 查找最后一部分的逗号分隔字段,如:memory
func FindCgroupMountPoint(subsystem string) string {
    // 打开当前进程挂载文件(后面会查询信息)
    f, err := os.Open("/proc/self/mountinfo")
    if err != nil {
        return ""
    }
    defer func(f *os.File) {
        err := f.Close()
        if err != nil {
            log.Error(err)
        }
    }(f)

    scanner := bufio.NewScanner(f)
    for scanner.Scan() {
        content := scanner.Text()
        fields := strings.Split(content, " ")
        for _, opt := range strings.Split(fields[len(fields)-1], ",") {
            if opt == subsystem {
                return fields[4]
            }
        }
    }
    if err := scanner.Err(); err != nil {
        log.Error(err)
        return ""
    }
    return ""
}

根据上文所述,FindCgroupMountPoint函数首先打开当前进程挂载文件:/proc/self/mountinfo

并在文件中按行查找函数入参中指定的subsystem名称的配置,如果找到则返回第五项,即:/sys/fs/cgroup/memory


GetCgroupPath函数

再来看函数GetCgroupPath

// GetCgroupPath 获取当前进程中指定cgroup对应的路径
func GetCgroupPath(subsystem string, cgroupPath string) (string, error) {
    cgroupRoot := FindCgroupMountPoint(subsystem)

    // 查询文件是否已经存在
    _, err := os.Stat(path.Join(cgroupRoot, cgroupPath))
    if err != nil {
        // 非文件不存在错误,返回错误
        if !os.IsNotExist(err) {
            return "", fmt.Errorf("cgroup path error %v", err)
        } else { // 如果是文件不存在错误,创建
            if err := os.Mkdir(path.Join(cgroupRoot, cgroupPath), DefaultCgroupFilePerm); err != nil {
                return "", fmt.Errorf("error create cgroup %v", err)
            }
        }
    }

    return path.Join(cgroupRoot, cgroupPath), nil
}

在函数GetCgroupPath中,首先通过FindCgroupMountPoint(subsystem)获取到了对于指定Subsystem对应Cgroup的根路径;

随后,判断在Cgroup的根路径下是否存在子cgroupPath,如果不存在则创建;

最后,返回这个子Cgroup的路径;


3、具体Subsystem接口实现

下面以内存为例,来看一下如何实现Subsystem接口;

chapter3_container/3_3_container_with_limit_and_pipe/cgroups/subsystems/memory.go

package subsystems

import (
    "fmt"
    log "github.com/sirupsen/logrus"
    "io/ioutil"
    "my_docker/cgroups/utils"
    "os"
    "path"
    "strconv"
)

var (
    MemoryName = `memory`

    MemoryNameCgroupConfig = "memory.limit_in_bytes"
)

type MemorySubSystem struct {
}

func (m *MemorySubSystem) Name() string {
    return MemoryName
}

func (m *MemorySubSystem) Set(cgroupPath string, res *ResourceConfig) error {
    subsystemCgroupPath, err := utils.GetCgroupPath(m.Name(), cgroupPath)
    if err != nil {
        return err
    }

    configPath := path.Join(subsystemCgroupPath, MemoryNameCgroupConfig)
    // 如果存在内存限制的配置
    if res.MemoryLimit != "" {
        err = ioutil.WriteFile(configPath,
            []byte(res.MemoryLimit), utils.DefaultCgroupConfigFilePerm)
        if err != nil {
            return fmt.Errorf("set cgroup cpuset fail %v", err)
        }
    }

    log.Infof("set memory success, file: %s, size: %s",
        configPath,
        res.MemoryLimit,
    )
    return nil
}

func (m *MemorySubSystem) Apply(cgroupPath string, pid int) error {
    subsystemCgroupPath, err := utils.GetCgroupPath(m.Name(), cgroupPath)
    if err != nil {
        return fmt.Errorf("get cgroup %s error: %v", cgroupPath, err)
    }

    configPath := path.Join(subsystemCgroupPath, CgroupConfigPath)
    err = ioutil.WriteFile(configPath,
        []byte(strconv.Itoa(pid)), utils.DefaultCgroupConfigFilePerm)
    if err != nil {
        return fmt.Errorf("set cgroup proc fail %v", err)
    }

    log.Infof("apply memory success, file: %s, pid: %d",
        path.Join(subsystemCgroupPath, CgroupConfigPath),
        pid,
    )
    return nil
}

func (m *MemorySubSystem) Remove(cgroupPath string) error {
    subsystemCgroupPath, err := utils.GetCgroupPath(m.Name(), cgroupPath)
    if err != nil {
        return fmt.Errorf("get cgroup %s error: %v", cgroupPath, err)
    }
    return os.RemoveAll(subsystemCgroupPath)
}

① Name方法

首先,Name方法返回了内存限制subsystem的名称:MemoryName = "memory"

② Set方法

Set方法的实现如下:

func (m *MemorySubSystem) Set(cgroupPath string, res *ResourceConfig) error {
    subsystemCgroupPath, err := utils.GetCgroupPath(m.Name(), cgroupPath)
    if err != nil {
        return err
    }

    configPath := path.Join(subsystemCgroupPath, MemoryNameCgroupConfig)
    // 如果存在内存限制的配置
    if res.MemoryLimit != "" {
        err = ioutil.WriteFile(configPath,
            []byte(res.MemoryLimit), utils.DefaultCgroupConfigFilePerm)
        if err != nil {
            return fmt.Errorf("set cgroup cpuset fail %v", err)
        }
    }

    log.Infof("set memory success, file: %s, size: %s",
        configPath,
        res.MemoryLimit,
    )
    return nil
}

Set方法首先通过工具函数GetCgroupPath获取到了子Cgroup的路径;

随后,通过path.Join拼接出了具体内存限制所对应的子Cgroup的配置文件路径,如:

/sys/fs/cgroup/memory/testmemlimit/memory.limit_in_bytes

可以看到:

  • 内存限制的根Cgroup配置路径为:/sys/fs/cgroup/memory;
  • 子Cgroup为:testmemlimit
  • 内存限制文件为:MemoryNameCgroupConfig = "memory.limit_in_bytes",是一个固定的值!

然后,如果传入的配置中存在关于内存限制的配置,则将配置大小写入文件中,如:1000m

同时,如果不存在此文件,则创建文件后再写入;

③ Apply方法

Apply方法的实现也是非常简单,代码如下:

func (m *MemorySubSystem) Apply(cgroupPath string, pid int) error {
    subsystemCgroupPath, err := utils.GetCgroupPath(m.Name(), cgroupPath)
    if err != nil {
        return fmt.Errorf("get cgroup %s error: %v", cgroupPath, err)
    }

    configPath := path.Join(subsystemCgroupPath, CgroupConfigPath)
    err = ioutil.WriteFile(configPath,
        []byte(strconv.Itoa(pid)), utils.DefaultCgroupConfigFilePerm)
    if err != nil {
        return fmt.Errorf("set cgroup proc fail %v", err)
    }

    log.Infof("apply memory success, file: %s, pid: %d",
        path.Join(subsystemCgroupPath, CgroupConfigPath),
        pid,
    )
    return nil
}

首先也是通过utils.GetCgroupPath(m.Name(), cgroupPath)找到内存对应的子Cgroup路径(和Set方法相同);

随后,向路径下的tasks文件写入指定的PID号即可!

在Linux中对于资源的限制就是这么简单!

④ Remove方法

Remove方法就更简单了,代码如下:

func (m *MemorySubSystem) Remove(cgroupPath string) error {
    subsystemCgroupPath, err := utils.GetCgroupPath(m.Name(), cgroupPath)
    if err != nil {
        return fmt.Errorf("get cgroup %s error: %v", cgroupPath, err)
    }
    return os.RemoveAll(subsystemCgroupPath)
}

直接获取当前配置路径,并删除整个配置目录即可!


4、其他Subsystem实现

下面是CPU核心数和CPU时间片限制对应Subsystem接口实现的代码,和内存限制完全类似,这里不再赘述!

① CPU核心数

chapter3_container/3_3_container_with_limit_and_pipe/cgroups/subsystems/cpuset.go

package subsystems

import (
    "fmt"
    log "github.com/sirupsen/logrus"
    "io/ioutil"
    "my_docker/cgroups/utils"
    "os"
    "path"
    "strconv"
)

var (
    CpuSetName = `cpuset`

    CpuSetCgroupConfig = "cpuset.cpus"
)

type CpuSetSubSystem struct {
}

func (c *CpuSetSubSystem) Name() string {
    return CpuSetName
}

func (c *CpuSetSubSystem) Set(cgroupPath string, res *ResourceConfig) error {
    subsystemCgroupPath, err := utils.GetCgroupPath(c.Name(), cgroupPath)
    if err != nil {
        return err
    }

    // 如果存在CPU核心数的配置
    configPath := path.Join(subsystemCgroupPath, CpuSetCgroupConfig)
    if res.CpuSet != "" {
        err = ioutil.WriteFile(configPath,
            []byte(res.CpuSet), utils.DefaultCgroupConfigFilePerm)
        if err != nil {
            return fmt.Errorf("set cgroup cpuset fail %v", err)
        }
    }

    log.Infof("set cpu-set success, file: %s, cpu-set num: %s",
        configPath,
        res.CpuSet,
    )
    return nil
}

func (c *CpuSetSubSystem) Apply(cgroupPath string, pid int) error {
    subsystemCgroupPath, err := utils.GetCgroupPath(c.Name(), cgroupPath)
    if err != nil {
        return fmt.Errorf("get cgroup %s error: %v", cgroupPath, err)
    }

    configPath := path.Join(subsystemCgroupPath, CgroupConfigPath)
    err = ioutil.WriteFile(configPath,
        []byte(strconv.Itoa(pid)), utils.DefaultCgroupConfigFilePerm)
    if err != nil {
        return fmt.Errorf("set cpuset cgroup proc fail %v", err)
    }

    log.Infof("apply cpu-set success, file: %s, pid: %d",
        path.Join(subsystemCgroupPath, CgroupConfigPath),
        pid,
    )
    return nil
}

func (c *CpuSetSubSystem) Remove(cgroupPath string) error {
    subsystemCgroupPath, err := utils.GetCgroupPath(c.Name(), cgroupPath)
    if err != nil {
        return fmt.Errorf("get cgroup %s error: %v", cgroupPath, err)
    }
    return os.RemoveAll(subsystemCgroupPath)
}

② CPU时间片

chapter3_container/3_3_container_with_limit_and_pipe/cgroups/subsystems/cpu.go

package subsystems

import (
    "fmt"
    log "github.com/sirupsen/logrus"
    "io/ioutil"
    "my_docker/cgroups/utils"
    "os"
    "path"
    "strconv"
)

var (
    CpuName = `cpu`

    CpuCgroupConfig = "cpu.shares"
)

type CpuSubSystem struct {
}

func (c *CpuSubSystem) Name() string {
    return CpuName
}

func (c *CpuSubSystem) Set(cgroupPath string, res *ResourceConfig) error {
    subsystemCgroupPath, err := utils.GetCgroupPath(c.Name(), cgroupPath)
    if err != nil {
        return err
    }

    configPath := path.Join(subsystemCgroupPath, CpuCgroupConfig)
    // 如果存在CPU时间片的配置
    if res.CpuShare != "" {
        err = ioutil.WriteFile(configPath,
            []byte(res.CpuShare), utils.DefaultCgroupConfigFilePerm)
        if err != nil {
            return fmt.Errorf("set cgroup cpu share fail %v", err)
        }
    }

    log.Infof("set cpu-share success, file: %s, cpu-share: %s",
        configPath,
        res.CpuShare,
    )
    return nil
}

func (c *CpuSubSystem) Apply(cgroupPath string, pid int) error {
    subsystemCgroupPath, err := utils.GetCgroupPath(c.Name(), cgroupPath)
    if err != nil {
        return fmt.Errorf("get cgroup %s error: %v", cgroupPath, err)
    }

    configPath := path.Join(subsystemCgroupPath, CgroupConfigPath)
    err = ioutil.WriteFile(configPath,
        []byte(strconv.Itoa(pid)), utils.DefaultCgroupConfigFilePerm)
    if err != nil {
        return fmt.Errorf("set cgroup proc fail %v", err)
    }

    log.Infof("apply cpu-share success, file: %s, pid: %d",
        path.Join(subsystemCgroupPath, CgroupConfigPath),
        pid,
    )
    return nil
}

func (c *CpuSubSystem) Remove(cgroupPath string) error {
    subsystemCgroupPath, err := utils.GetCgroupPath(c.Name(), cgroupPath)
    if err != nil {
        return fmt.Errorf("get cgroup %s error: %v", cgroupPath, err)
    }
    return os.RemoveAll(subsystemCgroupPath)
}

5、Cgroup整体管理者:CgroupManager

为了方便使用,在所有的Subsystem之上封装一个CgroupManager,通过传入ResourceConfig配置直接对资源进行限制;

代码如下:

chapter3_container/3_3_container_with_limit_and_pipe/cgroups/cgroups_manager.go

package cgroups

import (
    "github.com/sirupsen/logrus"
    "my_docker/cgroups/subsystems"
)

// CgroupManager cgroups的整体管理者(同时管理多种类型的cgroup)
type CgroupManager struct {
    // cgroup在hierarchy中的路径 相当于创建的cgroup目录相对于root cgroup目录的路径
    Path string
    // 资源配置
    Resource *subsystems.ResourceConfig
}

func NewCgroupManager(path string) *CgroupManager {
    return &CgroupManager{
        Path: path,
    }
}

// Apply 将进程pid加入到这个cgroup中
func (c *CgroupManager) Apply(pid int) error {
    for _, subSysIns := range subsystems.SubsystemIns {
        err := subSysIns.Apply(c.Path, pid)
        if err != nil {
            return err
        }
    }
    return nil
}

// Set 设置cgroup资源限制
func (c *CgroupManager) Set(res *subsystems.ResourceConfig) error {
    for _, subSysIns := range subsystems.SubsystemIns {
        err := subSysIns.Set(c.Path, res)
        if err != nil {
            return err
        }
    }
    return nil
}

// Destroy 释放cgroup
func (c *CgroupManager) Destroy() error {
    for _, subSysIns := range subsystems.SubsystemIns {
        if err := subSysIns.Remove(c.Path); err != nil {
            logrus.Warnf("remove cgroup fail %v", err)
        }
    }
    return nil
}

CgroupManager包括两个配置信息:

  • Path:子cgroup在hierarchy中的路径,即所创建的子cgroup目录相对于根Cgroup目录的路径;
  • Resource:即各个Subsystem所对应的资源限制;

同时,CgroupManager包括了三个方法:Set、Apply和Destroy;

实现非常简单,就是通过调用具体Subsystem数组中各个Subsystem的方法实现;


二、构建命令行工具

有了资源限制器之后,接下来我们开始创建我们的命令行工具(众所周知,Docker就是一个命令行工具);

构建命令行工具时我们使用了著名的cli工具库:

  • github.com/urfave/cli

1、命令行预期功能实现

在本篇实现的命令行中,我们会实现:

  • 使用类似于docker run -it [command]的命令创建一个容器;
  • 使用-m 100m -cpuset 1 -cpushare 512等Flag对容器内资源进行限制;

2、命令行入口

在构建命令行工具时,我将采用自上而下的方式进行构建:从函数的入口开始,然后是每一个命令行Flag的实现;

首先来看main函数:

chapter3_container/3_3_container_with_limit_and_pipe/main.go

package main

import (
    log "github.com/sirupsen/logrus"
    "github.com/urfave/cli"
    "my_docker/cmd"
    "os"
)

const usage = `my-docker is a simple container runtime implementation.
               The purpose of this project is to learn how docker works and how to write a docker by ourselves
               Enjoy it, just for fun.`

func main() {
    app := cli.NewApp()
    app.Name = "my-docker"
    app.Usage = usage

    app.Commands = []cli.Command{
        cmd.InitCommand,
        cmd.RunCommand,
    }

    app.Before = func(context *cli.Context) error {
        // Log as JSON instead of the default ASCII formatter.
        log.SetFormatter(&log.JSONFormatter{})
        log.SetOutput(os.Stdout)
        return nil
    }

    if err := app.Run(os.Args); err != nil {
        log.Fatal(err)
    }
}

代码非常简单:

首先,使用cli.NewApp()创建了一个命令行应用,并指定了应用的名称和说明;

随后,加入了两个命令:

  • InitCommand
  • RunCommand

注:上面这两个命令非常的重要,是实现整个容器的核心!

最后,在app.Before中,配置了日志输出格式和地址(标准输出流),并使用app.Run(os.Args)启动了应用;

在Run方法中会匹配用户指定的命令,进入到对应的XxxCommand函数中!


三、具体命令行参数实现

1、进程间通信方式管道:Pipe概述

上面我们实现了命令行工具的入口方法,在接下来继续进行命令行命令的具体实现前,还需要补充一些关于Linix进程间通信管道(Pipe)的知识;

因为我们所创建的容器是在一个新的进程中,因此需要通过管道进行进程间通信!

进程间通信包括了很多种方式,管道只是其中一种;

所谓管道(Pipe),就是一个连接两个进程的通道,他是Linux支持IPC的一种方式;

通常,管道都是半双工的:一端进行写操作,而另一端进行读操作!

同时,管道可以分为两种类型:

  • 无名管道:通常用于具有亲缘关系的进程之间直接进行通信;
  • 有名管道(FIFO管道):它是一种存在于文件系统中的管道,可以被两个没有任何亲缘关系的进程进行访问;有名管道可以通过mkfifo()函数来创建;

本质上来说:管道也是文件的一种!

但是它和文件通信的区别在于:

管道有一个固定大小的缓冲区(大小一般是4KB):

  • 当管道被写满时,写进程就会被阻塞,直到有读进程把管道的内容读出来;
  • 同样地,当读进程从管道内拿数据的时候,如果这时管道的内容是空的,那么读进程同样会被阻塞,一直等到有写进程向管道内写数据!

在Go中,可以使用os.Pipe()创建一个匿名管道:

  • func Pipe() (r *File, w *File, err error)

函数返回两个变量,一个是读、一个是写,其类型都是文件类型;

在后文中,我们会使用这个函数创建宿主进程和容器进程之间的通道,将初始化命令通过宿主进程传递给容器进程中!

接下来进行命令的具体实现!


2、具体命令实现

对于InitCommand而言,是在容器中进行初始化时,由我们的命令行工具调用的,而非用户调用的;

因此我们直接来看RunCommand命令的实现:

chapter3_container/3_3_container_with_limit_and_pipe/cmd/run_command.go

package cmd

import (
    "fmt"
    log "github.com/sirupsen/logrus"
    "github.com/urfave/cli"
    "my_docker/cgroups"
    "my_docker/cgroups/subsystems"
    "my_docker/container"
    "os"
    "strings"
)

var (
    defaultCgroupPath = "mydocker-cgroup"
)

var RunCommand = cli.Command{
    Name: "run",
    Usage: `Create a container with namespace and cgroups limit
            my-docker run -ti [command]`,
    Flags: []cli.Flag{
        cli.BoolFlag{
            Name:  "ti",
            Usage: "enable tty",
        },
        cli.BoolFlag{
            Name:  "it",
            Usage: "enable tty",
        },
        cli.StringFlag{
            Name:  "m",
            Usage: "memory limit",
        },
        cli.StringFlag{
            Name:  "cpushare",
            Usage: "cpushare limit",
        },
        cli.StringFlag{
            Name:  "cpuset",
            Usage: "cpuset limit",
        },
    },
    Action: func(context *cli.Context) error {
        // Step 1:用户初始化命令校验
        if len(context.Args()) < 1 {
            return fmt.Errorf("missing container command")
        }

        // Step 2:获取用户命令行命令
        // Step 2.1:从Context中获取容器内初始化命令
        var cmdArray []string
        for _, arg := range context.Args() {
            cmdArray = append(cmdArray, arg)
        }

        // Step 2.2:从Context中获取容器配置相关命令
        tty := context.Bool("ti") || context.Bool("it") // tty参数
        resourceConfig := getResourceConfig(context)    // 容器资源限制参数

        run(tty, cmdArray, resourceConfig)
        return nil
    },
}

func run(tty bool, comArray []string, res *subsystems.ResourceConfig) {
    parent, writePipe := container.NewParentProcess(tty)
    if parent == nil {
        log.Errorf("New parent process error")
        return
    }
    err := parent.Start()
    if err != nil {
        log.Error(err)
    }

    cgroupManager := cgroups.NewCgroupManager(defaultCgroupPath)
    defer func(cgroupManager *cgroups.CgroupManager) {
        err := cgroupManager.Destroy()
        if err != nil {
            log.Error(err)
        }
    }(cgroupManager)

    err = cgroupManager.Set(res)
    if err != nil {
        goto FASTEND
    }
    err = cgroupManager.Apply(parent.Process.Pid)
    if err != nil {
        goto FASTEND
    }

    sendInitCommand(comArray, writePipe)
    err = parent.Wait()

FASTEND:
    if err != nil {
        log.Error(err)
    }

    os.Exit(0)
}

func getResourceConfig(context *cli.Context) *subsystems.ResourceConfig {
    var (
        memoryLimit = `256m`
        cpuset      = `1`
        cpuShare    = `512`
    )

    if context.String("m") != "" {
        memoryLimit = context.String("m")
    }
    if context.String("cpuset") != "" {
        cpuset = context.String("cpuset")
    }
    if context.String("cpushare") != "" {
        cpuShare = context.String("cpushare")
    }

    return &subsystems.ResourceConfig{
        MemoryLimit: memoryLimit,
        CpuSet:      cpuset,
        CpuShare:    cpuShare,
    }
}

// sendInitCommand 向管道中写入用户自定义初始化命令参数
func sendInitCommand(comArray []string, writePipe *os.File) {
    defer func(writePipe *os.File) {
        err := writePipe.Close()
        if err != nil {
            log.Errorf("close write pipe err: %v", err)
        }
    }(writePipe)

    command := strings.Join(comArray, " ")
    log.Infof("command all is %s", command)
    _, err := writePipe.WriteString(command)
    if err != nil {
        log.Errorf("write pipe err: %v", err)
        return
    }
}

① 声明命令变量

首先,我们声明了一个命令变量:RunCommand

var RunCommand = cli.Command{
    Name: "run",
    Usage: `Create a container with namespace and cgroups limit
            my-docker run -ti [command]`,
    Flags: []cli.Flag{
        cli.BoolFlag{
            Name:  "ti",
            Usage: "enable tty",
        },
        cli.BoolFlag{
            Name:  "it",
            Usage: "enable tty",
        },
        cli.StringFlag{
            Name:  "m",
            Usage: "memory limit",
        },
        cli.StringFlag{
            Name:  "cpushare",
            Usage: "cpushare limit",
        },
        cli.StringFlag{
            Name:  "cpuset",
            Usage: "cpuset limit",
        },
    },
    Action: func(context *cli.Context) error {
        // Step 1:用户初始化命令校验
        if len(context.Args()) < 1 {
            return fmt.Errorf("missing container command")
        }

        // Step 2:获取用户命令行命令
        // Step 2.1:从Context中获取容器内初始化命令
        var cmdArray []string
        for _, arg := range context.Args() {
            cmdArray = append(cmdArray, arg)
        }

        // Step 2.2:从Context中获取容器配置相关命令
        tty := context.Bool("ti") || context.Bool("it") // tty参数
        resourceConfig := getResourceConfig(context)    // 容器资源限制参数

        run(tty, cmdArray, resourceConfig)
        return nil
    },
}

在变量中,我们声明了子命令的一些内容:

  • Name:命令名称;
  • Usage:命令说明;
  • Flags:命令占位符:
    • ti & it:启用tty交互;
    • m & cpushare & cpuset:内存、CPU时间片以及CPU核心数资源限制;
  • Action:命令具体执行的函数,其中:Action函数的入参context中包含了用户所指定的上述所声明的Flags参数以及其他参数(用于指定容器创建后的初始化命令)

这里补充说明一点:

在Action函数的入参context中:

  • Flags对应的参数使用例如:context.Bool("it")进行取值;

  • 用户定义的其他参数通过context.Args()进行取值;

    例如命令:./my_docker run -ti -m 100m -- stress --vm-bytes 200m --vm-keep -m 1

  • context.String("m")会取到:100m

  • context.Args()会取到:stress --vm-bytes 200m --vm-keep -m 1

Action函数也是非常简单:

  • 使用context.Args()获取用户初始化命令;
  • 使用getResourceConfig(context)获取用户所定义的容器限制命令;
  • 调用run(tty, cmdArray, resourceConfig)函数,开启新的进程,启动容器!

run(tty, cmdArray, resourceConfig)函数中实现了整个容器的核心内容,下面我们来看这个函数;


② 开启新的容器进程:NewParentProcess

run函数的整体实现如下:

chapter3_container/3_3_container_with_limit_and_pipe/cmd/run_command.go

func run(tty bool, comArray []string, res *subsystems.ResourceConfig) {
    parent, writePipe := container.NewParentProcess(tty)
    if parent == nil {
        log.Errorf("New parent process error")
        return
    }
    err := parent.Start()
    if err != nil {
        log.Error(err)
    }

    cgroupManager := cgroups.NewCgroupManager(defaultCgroupPath)
    defer func(cgroupManager *cgroups.CgroupManager) {
        err := cgroupManager.Destroy()
        if err != nil {
            log.Error(err)
        }
    }(cgroupManager)

    err = cgroupManager.Set(res)
    if err != nil {
        goto FASTEND
    }
    err = cgroupManager.Apply(parent.Process.Pid)
    if err != nil {
        goto FASTEND
    }

    sendInitCommand(comArray, writePipe)
    err = parent.Wait()

FASTEND:
    if err != nil {
        log.Error(err)
    }
    os.Exit(0)
}

在run函数中,首先我们调用了container包中的NewParentProcess(tty)函数创建了一个子进程:

chapter3_container/3_3_container_with_limit_and_pipe/container/container.go

func NewParentProcess(tty bool) (*exec.Cmd, *os.File) {
    readPipe, writePipe, err := utils.NewPipe()
    if err != nil {
        log.Errorf("New pipe error %v", err)
        return nil, nil
    }

    // 子进程中调用自己,并发送init参数,在子进程中初始化容器资源(自己的init命令!)
    cmd := exec.Command("/proc/self/exe", "init")
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC |
            syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET,
    }
    // 如果支持tty
    if tty {
        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr
    }

    cmd.ExtraFiles = []*os.File{readPipe}
    return cmd, writePipe
}

NewParentProcess函数中,首先创建了一个前文中所提到的管道;

随后,创建了一个命令行:

  • /proc/self/exe init

注:上面的这个命令行可以说是整个容器实现中最为巧妙的地方!(Docker也是使用这个方式进行容器内资源初始化的!)

这里重点介绍一下上面创建的命令的含义:

由本篇最开始的/proc介绍可知:/proc/self/exe链接到了当前进程的执行命令文件;

因此:/proc/self/exe init命令将会再次调用docker命令本身,即:docker init,从而执行到我们所声明的InitCommand函数中(注:此时已经进入到了容器进程中!)并完成初始化!

随后,对命令进行了Namespace绑定:

cmd.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC |
        syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET,
}

同时,如果支持tty,还会将子进程中的标准流和当前进程中的流进行绑定:

if tty {
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
}

最后,将所创建的管道对应的readPipe传递给命令(之后会传入容器进程中,用于接收宿主进程发送的初始化消息),并返回创建的命令和writePipe(用于宿主进程向容器进程发送消息);


接着,我们回到run函数中:

err := parent.Start()
if err != nil {
    log.Error(err)
}

run函数调用返回的命令的Start函数parent.Start()创建新的进程,即:容器进程!

这时就会调用我们的InitCommand函数;

让我们看一下init命令的实现;


③ 初始化容器:InitCommand

下面是InitCommand的声明:

chapter3_container/3_3_container_with_limit_and_pipe/cmd/init_command.go

var InitCommand = cli.Command{
    Name:  "init",
    Usage: "Init container process run user's process in container. Do not call it outside",
    Action: func(context *cli.Context) error {
        log.Infof("init come on")
        err := container.RunContainerInitProcess()
        return err
    },
}

可以看到,InitCommand调用了container包中的RunContainerInitProcess函数:

chapter3_container/3_3_container_with_limit_and_pipe/container/container.go

// RunContainerInitProcess 在容器中创建初始化进程!(本函数在容器内部,作为第一个进程被执行)
func RunContainerInitProcess() error {
    // systemd 加入linux之后, mount namespace 就变成 shared by default, 所以你必须显式声明你要这个新的mount namespace独立!
    // Issue:https://github.com/xianlubird/mydocker/issues/41
    err := syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, "")
    if err != nil {
        return err
    }

    // 从无名管道中获取用户的参数(从WritePipe过来!)
    cmdArray := readUserCommand()
    if cmdArray == nil || len(cmdArray) == 0 {
        return fmt.Errorf("run container get user command error, cmdArray is nil")
    }

    // 使用 mount 挂载 proc 文件系统(以便后面通过 ps 命令查看当前进程资源)
    // MS_NOEXEC:本文件系统不允许运行其他程序
    // MS_NOSUID:本系统运行程序时,不允许 set-user-id, set-group-id
    // MS_NODEV:mount系统的默认参数
    defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
    err = syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
    if err != nil {
        return err
    }

    // 查询命令的绝对路径,此时用户可以不再输入绝对路径!
    absPath, err := exec.LookPath(cmdArray[0])
    if err != nil {
        log.Errorf("Exec loop path error %v", err)
        return err
    }
    log.Infof("find cmd absolute path %s", absPath)

    // 完成容器内初始化,并将用户进程运行起来!
    // syscall.Exec 最终调用 execve 系统函数,执行当前 filename 对应程序
    // 并”覆盖“当前进程的镜像、数据和堆栈等信息,包括PID,因此将容器最开始的 init 进程替换掉!
    if err := syscall.Exec(absPath, cmdArray, os.Environ()); err != nil {
        log.Errorf("init container err: %v", err.Error())
    }
    return nil
}

RunContainerInitProcess函数中:

首先,我们重新挂载了/,这是为了解决在 systemd 加入linux之后, mount namespace 默认就变成了 shared by default,因此需要显式声明新的mount namespace独立!

随后,调用了readUserCommand()从无名管道中获取用户的容器初始化参数,代码如下:

// 从无名管道中获取输入
func readUserCommand() []string {
    // 这里用3号文件描述符是因为,我们只创建了一个管道流,而默认是0、1、2(标准输入+输出,错误输出)
    pipe := os.NewFile(uintptr(3), "pipe")
    msg, err := ioutil.ReadAll(pipe)
    if err != nil {
        log.Errorf("init read pipe error %v", err)
        return nil
    }
    msgStr := string(msg)
    return strings.Split(msgStr, " ")
}

这里通过3号文件描述符(这里用3号文件描述符是因为,我们只创建了一个管道流,而默认是0、1、2【标准输入+输出,错误输出】)取出了我们的父进程输入流,

注:此时父进程(宿主进程)还没有向管道中写入数据!因此,此时容器进程会在此阻塞(ioutil.ReadAll(pipe))!

直到宿主进程向子进程中发送了初始化命令后,容器进程才会继续执行!

当宿主进程通过管道向子进程发送了初始化命令后,readUserCommand函数获取用户的初始化命令数组,并返回;

随后,容器进程重新挂载 proc 文件系统,并从$PATH中获取用户初始化命令的绝对路径absPath

最终,调用syscall.Exec(absPath, cmdArray, os.Environ()),使用用户传输的命令进行容器的初始化!

注:Go语言中的 syscall.Exec 函数最终会调用 execve 系统函数,执行当前 filename 对应程序,并覆盖当前进程的镜像、数据和堆栈等信息,包括PID,因此会将将容器最开始的 init 进程替换掉!

即:PID为1的进程不再是init,而是用户指定的初始化命令所对应的进程!


④ 挂载Cgroup进行资源限制

让我们把视线再转移回 run函数!

当调用了parent.Start()后,容器进程被创建,并等待宿主进程中的初始化命令,随后完成容器初始化;

此时正是我们对容器进程进行资源限制的最好时机!

因此,在 run 函数中,接下来进行了Cgroup和PID之间的绑定,进行资源的限制:

cgroupManager := cgroups.NewCgroupManager(defaultCgroupPath)
defer func(cgroupManager *cgroups.CgroupManager) {
    err := cgroupManager.Destroy()
    if err != nil {
        log.Error(err)
    }
}(cgroupManager)

err = cgroupManager.Set(res)
if err != nil {
    goto FASTEND
}
err = cgroupManager.Apply(parent.Process.Pid)
if err != nil {
    goto FASTEND
}

注1:如果先初始化,后绑定,则不会对初始化命令的资源进行限制!

注2:直接使用parent.Process.Pid获取容器进程的PID即可!


⑤ 宿主进程向容器进程发送初始化命令:sendInitCommand

在对容进程进行了资源限制之后,宿主进程可以向容器进程发送初始化命令,完成容器的初始化;

发送命令主要是通过sendInitCommand函数完成的:

// sendInitCommand 向管道中写入用户自定义初始化命令参数
func sendInitCommand(comArray []string, writePipe *os.File) {
    defer func(writePipe *os.File) {
        err := writePipe.Close()
        if err != nil {
            log.Errorf("close write pipe err: %v", err)
        }
    }(writePipe)

    command := strings.Join(comArray, " ")
    log.Infof("command all is %s", command)
    _, err := writePipe.WriteString(command)
    if err != nil {
        log.Errorf("write pipe err: %v", err)
        return
    }
}

sendInitCommand函数很简单:就是通过writePipe向容器进程发送了用户初始化命令;

当发送完成后,容器进程便开始进行初始化;

同时,宿主进程等待容器进程完成:

err = parent.Wait()

此时,我们的容器进程初始化完毕,可以使用!

至此,我们的容器创建完毕!


四、命令测试

我们的容器已经写完了,那么效果如何呢?是骡子是马,拉出来溜溜吧!

首先使用go build编译我们的Docker:

root@jasonkay:~/workspace/my_docker/chapter3_container/3_3_container_with_limit_and_pipe# go build
root@jasonkay:~/workspace/my_docker/chapter3_container/3_3_container_with_limit_and_pipe# ll
total 4684
drwxr-xr-x 6 root root    4096 Sep  5 20:04 ./
drwxr-xr-x 5 root root    4096 Aug 28 18:54 ../
drwxr-xr-x 4 root root    4096 Aug 28 18:54 cgroups/
drwxr-xr-x 2 root root    4096 Aug 28 19:16 cmd/
drwxr-xr-x 2 root root    4096 Aug 28 19:18 container/
-rw-r--r-- 1 root root     105 Aug 27 19:53 go.mod
-rw-r--r-- 1 root root    1965 Aug 27 16:19 go.sum
-rw-r--r-- 1 root root     719 Aug 27 19:54 main.go
-rwxr-xr-x 1 root root 4747218 Sep  5 20:04 my_docker*
-rw-r--r-- 1 root root     136 Aug 28 19:25 README.md
-rw-r--r-- 1 root root    1540 Aug 28 19:27 test-ls.sh
-rw-r--r-- 1 root root    2117 Aug 28 19:29 test-memory.sh
drwxr-xr-x 2 root root    4096 Aug 28 18:59 utils/

1、无资源限制测试

① ls命令测试

直接执行下面的命令进行测试:

./my_docker run -ti /bin/ls

输出如下:

{"level":"info","msg":"init come on","time":"2021-09-05T20:05:20Z"}
{"level":"info","msg":"set cpu-set success, file: /sys/fs/cgroup/cpuset/mydocker-cgroup/cpuset.cpus, cpu-set num: 1","time":"2021-09-05T20:05:20Z"}
{"level":"info","msg":"set memory success, file: /sys/fs/cgroup/memory/mydocker-cgroup/memory.limit_in_bytes, size: 256m","time":"2021-09-05T20:05:20Z"}
{"level":"info","msg":"set cpu-share success, file: /sys/fs/cgroup/cpu,cpuacct/mydocker-cgroup/cpu.shares, cpu-share: 512","time":"2021-09-05T20:05:20Z"}
{"level":"info","msg":"apply cpu-set success, file: /sys/fs/cgroup/cpuset/mydocker-cgroup/tasks, pid: 355195","time":"2021-09-05T20:05:20Z"}
{"level":"info","msg":"apply memory success, file: /sys/fs/cgroup/memory/mydocker-cgroup/tasks, pid: 355195","time":"2021-09-05T20:05:20Z"}
{"level":"info","msg":"apply cpu-share success, file: /sys/fs/cgroup/cpu,cpuacct/mydocker-cgroup/tasks, pid: 355195","time":"2021-09-05T20:05:20Z"}
{"level":"info","msg":"command all is /bin/ls","time":"2021-09-05T20:05:20Z"}
{"level":"info","msg":"find cmd absolute path /bin/ls","time":"2021-09-05T20:05:20Z"}

cgroups  cmd  container  go.mod  go.sum  main.go  my_docker  README.md  test-ls.sh  test-memory.sh  utils

容器执行ls命令完成后退出,这和 Docker 中:无前台进程启动时容器自动退出的逻辑完全一致!

同时,可以看到整个Docker执行的日志,以及执行顺序:

  • 初始化;
  • 设置资源限制(这里命令行并未给定资源限制,使用的是资源限制的默认值!);
  • 子进程查找ls命令的绝对路径(这里能够看到子进程的日志是因为我们指定了tty将标准流和当前进程绑定)
  • 执行命令/bin/ls,打印出结果!

注意到:我们并未挂载新的目录,因此容器进程可以打印出宿主进程中当前目录下的文件!


② sh命令测试

除了ls命令外,我们还可以直接执行sh命令进入容器环境!

执行下面的命令:

./my_docker run -ti /bin/sh

此时输出日志,并进入容器进程中:

root@jasonkay:~/workspace/my_docker/chapter3_container/3_3_container_with_limit_and_pipe# ./my_docker run -ti /bin/sh
{"level":"info","msg":"init come on","time":"2021-09-05T20:12:02Z"}
{"level":"info","msg":"set cpu-set success, file: /sys/fs/cgroup/cpuset/mydocker-cgroup/cpuset.cpus, cpu-set num: 1","time":"2021-09-05T20:12:03Z"}
{"level":"info","msg":"set memory success, file: /sys/fs/cgroup/memory/mydocker-cgroup/memory.limit_in_bytes, size: 256m","time":"2021-09-05T20:12:03Z"}
{"level":"info","msg":"set cpu-share success, file: /sys/fs/cgroup/cpu,cpuacct/mydocker-cgroup/cpu.shares, cpu-share: 512","time":"2021-09-05T20:12:03Z"}
{"level":"info","msg":"apply cpu-set success, file: /sys/fs/cgroup/cpuset/mydocker-cgroup/tasks, pid: 355424","time":"2021-09-05T20:12:03Z"}
{"level":"info","msg":"apply memory success, file: /sys/fs/cgroup/memory/mydocker-cgroup/tasks, pid: 355424","time":"2021-09-05T20:12:03Z"}
{"level":"info","msg":"apply cpu-share success, file: /sys/fs/cgroup/cpu,cpuacct/mydocker-cgroup/tasks, pid: 355424","time":"2021-09-05T20:12:03Z"}
{"level":"info","msg":"command all is /bin/sh","time":"2021-09-05T20:12:03Z"}
{"level":"info","msg":"find cmd absolute path /bin/sh","time":"2021-09-05T20:12:03Z"}
# 

查看容器中的进程列表:

# ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 20:12 pts/0    00:00:00 /bin/sh
root           6       1  0 20:12 pts/0    00:00:00 ps -ef

可以看到,容器中PID为1的进程就是我们所指定的进程:sh

接下来对资源限制功能进行测试!


2、资源限制测试

执行下面的命令,在容器中执行stress命令模拟系统负载较高时的场景;

# ./my_docker run -ti -m 100m -- stress --vm-bytes 200m --vm-keep -m 1

{"level":"info","msg":"init come on","time":"2021-09-05T20:15:30Z"}
{"level":"info","msg":"set cpu-set success, file: /sys/fs/cgroup/cpuset/mydocker-cgroup/cpuset.cpus, cpu-set num: 1","time":"2021-09-05T20:15:30Z"}
{"level":"info","msg":"set memory success, file: /sys/fs/cgroup/memory/mydocker-cgroup/memory.limit_in_bytes, size: 100m","time":"2021-09-05T20:15:30Z"}
{"level":"info","msg":"set cpu-share success, file: /sys/fs/cgroup/cpu,cpuacct/mydocker-cgroup/cpu.shares, cpu-share: 512","time":"2021-09-05T20:15:30Z"}
{"level":"info","msg":"apply cpu-set success, file: /sys/fs/cgroup/cpuset/mydocker-cgroup/tasks, pid: 355573","time":"2021-09-05T20:15:30Z"}
{"level":"info","msg":"apply memory success, file: /sys/fs/cgroup/memory/mydocker-cgroup/tasks, pid: 355573","time":"2021-09-05T20:15:30Z"}
{"level":"info","msg":"apply cpu-share success, file: /sys/fs/cgroup/cpu,cpuacct/mydocker-cgroup/tasks, pid: 355573","time":"2021-09-05T20:15:30Z"}
{"level":"info","msg":"command all is stress --vm-bytes 200m --vm-keep -m 1","time":"2021-09-05T20:15:30Z"}
{"level":"info","msg":"find cmd absolute path /usr/bin/stress","time":"2021-09-05T20:15:30Z"}
stress: info: [1] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd

我们stress命令指定的内存占用为200m,而对容器进程的资源占用限制为100m

可以在宿主机中查看资源占用情况:

# top

top - 20:16:37 up 8 days,  4:01,  2 users,  load average: 0.79, 0.27, 0.08
Tasks: 267 total,   2 running, 265 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.4 us,  9.7 sy,  0.0 ni, 87.2 id,  2.6 wa,  0.0 hi,  0.1 si,  0.0 st
MiB Mem :  15985.9 total,   9795.9 free,   2824.4 used,   3365.6 buff/cache
MiB Swap:  12288.0 total,  12184.2 free,    103.8 used.  12832.9 avail Mem 

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND                             
 355578 root      20   0  208660 101456    272 R  73.3   0.6   0:50.78 stress  

可以看到,仅仅占用了 15985.9 * 0.6% = 95.9154 ≈ 100M 内存,我们成功限制了容器进程的内存占用!

其他的资源限制也是可以生效的,这里由于篇幅的原因,不再赘述了!


其他内容

在执行命令时可能会报错:cgroup:no space left on device,即:cpuset中tasks无法加入新的pid

这是由于默认的Cgroups的mems文件是空的,我们只需要写入一个值,分配内存即可,如:

echo 0 > /sys/fs/cgroup/cpuset/mydocker-cgroup/cpuset.mems

对应Issue:https://github.com/xianlubird/mydocker/issues/74

相关文章:https://blog.csdn.net/xftony/article/details/80536562


本篇小结

本篇主要在前面Namespace、Cgroups等概念的基础之上,手动实现了一个能够进行资源占用的类Docker容器!

期间主要穿插讲解了,如:

  • /proc文件系统;
  • 进程间通信管道;
  • ……

等内容;

可以注意到,在执行时容器环境和宿主环境还并未完全隔离;

当然,这也是下一篇文章的内容,下一篇文章将会构建一个资源完全隔离的Docker镜像,尽请期待!


附录

系列文章:

源代码:



本文作者:Jasonkay
本文链接:https://jasonkayzk.github.io/2021/09/05/Docker原理实战-4:容器Container/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可