七天用 Go 寫個 docker(第三天)

pibigstar發表於2020-03-21

專案原始碼:點選檢視專案原始碼

前面兩天我們瞭解完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命令主要就是啟動一個容器,然後對該程式設定隔離,init是run命令中呼叫的,不是我們自身通過命令列呼叫的,這裡我們主要關注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
}

文章會首發於我微信公眾號上,掃碼關注,及時獲取最新內容

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章