前面两天我们了解完 docker 原理之后,今天我们动手把项目的结构给搭起来,先总体看一下项目结构

整个文件调用过程如下

我们最终达到的效果实现下面这个命令,该命令会启动一个隔离的容器,并在该容器中运行第一个命令为 top

go-docker run -ti top

main.go

程序的入口,主要是接收命令行参数,接收命令行参数处理使用的第三方工具包为 github.com/urfave/cli,日志打印采用的 github.com/sirupsen/logrus

package main

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

const usage = `go-docker`

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

    app.Commands = []cli.Command{
        runCommand,
        initCommand,
    }
    app.Before = func(context *cli.Context) error {
        logrus.SetFormatter(&logrus.JSONFormatter{})
        logrus.SetOutput(os.Stdout)
        return nil
    }
    if err := app.Run(os.Args); err != nil {
        logrus.Fatal(err)
    }
}

这里主要关注 Commands 数组,我们定义了两个运行命令runCommandinitCommand,这两个命令定义在Command.go 文件中,看一下文件内容

command.go

package main

import (
    "fmt"

    "github.com/sirupsen/logrus"
    "github.com/urfave/cli"

    "go-docker/cgroups/subsystem"
    "go-docker/container"
)

// 创建namespace隔离的容器进程
// 启动容器
var runCommand = cli.Command{
    Name:  "run",
    Usage: "Create a container with namespace and cgroups limit",
    Flags: []cli.Flag{
        cli.BoolFlag{
            Name:  "ti",
            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 {
        if len(context.Args()) < 1 {
            return fmt.Errorf("missing container args")
        }
        tty := context.Bool("ti")

        res := &subsystem.ResourceConfig{
            MemoryLimit: context.String("m"),
            CpuSet:      context.String("cpuset"),
            CpuShare:    context.String("cpushare"),
        }
        // cmdArray 为容器运行后,执行的第一个命令信息
        // cmdArray[0] 为命令内容, 后面的为命令参数
        var cmdArray []string
        for _, arg := range context.Args() {
            cmdArray = append(cmdArray, arg)
        }
        Run(cmdArray, tty, res)
        return nil
    },
}

// 初始化容器内容,挂载proc文件系统,运行用户执行程序
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 {
        logrus.Infof("init come on")
        return container.RunContainerInitProcess()
    },
}

run 命令主要就是启动一个容器,然后对该进程设置隔离,initrun 命令中调用的,不是我们自身通过命令行调用的,这里我们主要关注 Run(cmdArray, tty, res)函数即可,它接收我们传递过来的参数,tty 表示是否前台运行,对应docker-ti命令,Run 函数写在了 run.go文件中

run.go

package main

import (
    "os"
    "strings"

    "github.com/sirupsen/logrus"

    "go-docker/cgroups"
    "go-docker/cgroups/subsystem"
    "go-docker/container"
)

func Run(cmdArray []string, tty bool, res *subsystem.ResourceConfig) {
    parent, writePipe := container.NewParentProcess(tty)
    if parent == nil {
        logrus.Errorf("failed to new parent process")
        return
    }
    if err := parent.Start(); err != nil {
        logrus.Errorf("parent start failed, err: %v", err)
        return
    }
    // 添加资源限制
    cgroupMananger := cgroups.NewCGroupManager("go-docker")
    // 删除资源限制
    defer cgroupMananger.Destroy()
    // 设置资源限制
    cgroupMananger.Set(res)
    // 将容器进程,加入到各个subsystem挂载对应的cgroup中
    cgroupMananger.Apply(parent.Process.Pid)

    sendInitCommand(cmdArray, writePipe)
    parent.Wait()
}

func sendInitCommand(comArray []string, writePipe *os.File) {
    command := strings.Join(comArray, " ")
    logrus.Infof("command all is %s", command)
    _, _ = writePipe.WriteString(command)
    _ = writePipe.Close()
}

基本上对 docker 初始化要做的事情都放在了这个文件中,主要是启动一个容器,然后对该容器做一些资源限制,这里需要关注的是 container.NewParentProcess(tty),它会给我们返回一个被 namesapce 隔离的进程。这个函数在 process.go 文件里

process.go

package container

import (
    "os"
    "os/exec"
    "syscall"
)

// 创建一个会隔离namespace进程的Command
func NewParentProcess(tty bool) (*exec.Cmd, *os.File) {
    readPipe, writePipe, _ := os.Pipe()
    // 调用自身,传入 init 参数,也就是执行 initCommand
    cmd := exec.Command("/proc/self/exe", "init")
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
            syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
    }
    if tty {
        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr
    }
    cmd.ExtraFiles = []*os.File{
        readPipe,
    }
    return cmd, writePipe
}

这个函数会通过 /proc/self/exe init 来调用自身我们定义的 initCommand命令,然后给该进程设置隔离信息。看一下我们的 initCommand 干了什么事,这个命令的内容在 init.go 文件里。

init.go

package container

import (
    "fmt"
    "io/ioutil"
    "os"
    "os/exec"
    "strings"
    "syscall"

    "github.com/sirupsen/logrus"
)

// 本容器执行的第一个进程
// 使用mount挂载proc文件系统
// 以便后面通过`ps`等系统命令查看当前进程资源的情况
func RunContainerInitProcess() error {
    cmdArray := readUserCommand()
    if cmdArray == nil || len(cmdArray) == 0 {
        return fmt.Errorf("get user command in run container")
    }
    // 挂载
    err := setUpMount()
    if err != nil {
        logrus.Errorf("set up mount, err: %v", err)
        return err
    }

    // 在系统环境 PATH中寻找命令的绝对路径
    path, err := exec.LookPath(cmdArray[0])
    if err != nil {
        logrus.Errorf("look %s path, err: %v", cmdArray[0], err)
        return err
    }

    err = syscall.Exec(path, cmdArray[0:], os.Environ())
    if err != nil {
        return err
    }
    return nil
}

func readUserCommand() []string {
    // 指 index 为 3的文件描述符,
    // 也就是 cmd.ExtraFiles 中 我们传递过来的 readPipe
    pipe := os.NewFile(uintptr(3), "pipe")
    bs, err := ioutil.ReadAll(pipe)
    if err != nil {
        logrus.Errorf("read pipe, err: %v", err)
        return nil
    }
    msg := string(bs)
    return strings.Split(msg, " ")
}

func setUpMount() error {
    // systemd 加入linux之后, mount namespace 就变成 shared by default, 所以你必须显示
    //声明你要这个新的mount namespace独立。
    err := syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, "")
    if err != nil {
        return err
    }
    //mount proc
    defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
    err = syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
    if err != nil {
        logrus.Errorf("mount proc, err: %v", err)
        return err
    }

    return nil
}

看着很多,其实没干多少事,就是设置下挂载点,然后运行容器启动后的第一个命令也就是 top 命令,其实真个容器的隔离已经完成了,那我们再拐回去看下资源限制做了那些东西,资源限制全部放在了cgroup文件夹中

subsystem.go

资源限制接口,Apply将进程ID 添加到tasks 中,即将此进程加入 cgroup 中,Set 则对某个资源进行限制,Remove 则为移除该 cgroup,都比较简单,就是创建文件,写文件罢了,理解原理之后,写起来很轻松。

package subsystem

// 资源限制配置
type ResourceConfig struct {
    // 内存限制
    MemoryLimit string
    // CPU时间片权重
    CpuShare string
    // CPU核数
    CpuSet string
}

/**
将cgroup抽象成path, 因为在hierarchy中,cgroup便是虚拟的路径地址
*/
type Subystem interface {
    // 返回subsystem名字,如 cpu,memory
    Name() string
    // 设置cgroup在这个subSystem中的资源限制
    Set(cgroupPath string, res *ResourceConfig) error
    // 移除这个cgroup资源限制
    Remove(cgroupPath string) error
    // 将某个进程添加到cgroup中
    Apply(cgroupPath string, pid int) error
}

var (
    Subsystems = []Subystem{
        &MemorySubSystem{},
        &CpuSubSystem{},
        &CpuSetSubSystem{},
    }
)

manager.go

资源限制管理器

package cgroups

import (
    "github.com/sirupsen/logrus"
    "go-docker/cgroups/subsystem"
)

type CGroupManager struct {
    Path string
}

func NewCGroupManager(path string) *CGroupManager {
    return &CGroupManager{Path: path}
}

func (c *CGroupManager) Set(res *subsystem.ResourceConfig) {
    for _, subsystem := range subsystem.Subsystems {
        err := subsystem.Set(c.Path, res)
        if err != nil {
            logrus.Errorf("set %s err: %v", subsystem.Name(), err)
        }
    }
}

func (c *CGroupManager) Apply(pid int) {
    for _, subsystem := range subsystem.Subsystems {
        err := subsystem.Apply(c.Path, pid)
        if err != nil {
            logrus.Errorf("apply task, err: %v", err)
        }
    }
}

func (c *CGroupManager) Destroy() {
    for _, subsystem := range subsystem.Subsystems {
        err := subsystem.Remove(c.Path)
        if err != nil {
            logrus.Errorf("remove %s err: %v", subsystem.Name(), err)
        }
    }
}

看下具体怎么使用,还是以内存限制来看吧,其他得资源限制和它大同小异,改改文件名罢了。

memory.go

内存限制实例

package subsystem

import (
    "io/ioutil"
    "os"
    "path"
    "strconv"

    "github.com/sirupsen/logrus"
)

type MemorySubSystem struct {
}

func (*MemorySubSystem) Name() string {
    return "memory"
}

func (m *MemorySubSystem) Set(cgroupPath string, res *ResourceConfig) error {
    subsystemCgroupPath, err := GetCgroupPath(m.Name(), cgroupPath, true)
    if err != nil {
        logrus.Errorf("get %s path, err: %v", cgroupPath, err)
        return err
    }
    if res.MemoryLimit != "" {
        // 设置cgroup内存限制,
        // 将这个限制写入到cgroup对应目录的 memory.limit_in_bytes文件中即可
        err := ioutil.WriteFile(path.Join(subsystemCgroupPath, "memory.limit_in_bytes"), []byte(res.MemoryLimit), 0644)
        if err != nil {
            return err
        }
    }
    return nil
}

func (m *MemorySubSystem) Remove(cgroupPath string) error {
    subsystemCgroupPath, err := GetCgroupPath(m.Name(), cgroupPath, true)
    if err != nil {
        return err
    }
    return os.RemoveAll(subsystemCgroupPath)
}

func (m *MemorySubSystem) Apply(cgroupPath string, pid int) error {
    subsystemCgroupPath, err := GetCgroupPath(m.Name(), cgroupPath, true)
    if err != nil {
        return err
    }
    tasksPath := path.Join(subsystemCgroupPath, "tasks")
    err = ioutil.WriteFile(tasksPath, []byte(strconv.Itoa(pid)), 0644)
    if err != nil {
        logrus.Errorf("write pid to tasks, path: %s, pid: %d, err: %v", tasksPath, pid, err)
        return err
    }
    return nil
}

转自:微信号 跟派大星学编程

最后编辑: kuteng  文档更新时间: 2022-04-14 17:15   作者:kuteng