本文为从零开始写 Docker 系列第二篇,主要在 mydocker run 命令基础上优化参数传递方式,改为使用 runC 同款的匿名管道传递参数。
完整代码见:https://github.com/lixd/mydocker欢迎 Star
推荐阅读以下文章对 docker 基本实现有一个大致认识:
核心原理:基于 namespace 的视图隔离:探索 Linux Namespace:Docker 隔离的神奇背后 基于 cgroups 的资源限制 初探 Linux Cgroups:资源控制的奇妙世界 深入剖析 Linux Cgroups 子系统:资源精细管理 Docker 与 Linux Cgroups:资源隔离的魔法之旅 基于 overlayfs 的文件系统:Docker 魔法解密:探索 UnionFS 与 OverlayFS 基于 veth pAIr、bridge、iptables 等等技术的 Docker 网络:揭秘 Docker 网络:手动实现 Docker 桥接网络开发环境如下:
root@mydocker:~# lsb_release -aNo LSB modules are available. Distributor ID: Ubuntu Description: Ubuntu 20.04.2 LTS Release: 20.04 Codename: focal root@mydocker:~# uname -r 5.4.0-74-generic注意:需要使用 root 用户
1. 当前方式存在的问题
在之前实现 run 命令时,参数传递方式比较简单直接。
就像这样:
cmd := exec.Command(“/proc/self/exe”, “init”,args)在 fork 子进程时,把参数全部跟在 init 后面,作为 init 命令的参数,然后在 init 进程中解析参数。
var initCommand = cli.Command{ Name: “init”, Usage: “Init container process run users process in container. Do not call it outside”, /* 1.获取传递过来的 command 参数 2.执行容器初始化操作 */ Action: func(context *cli.Context) error { log.Infof(“init come on”) cmd := context.Args().Get(0) log.Infof(“command: %s”, cmd) err := container.RunContainerInitProcess(cmd, nil)return err }, }这种方式最大的问题是,如果用户输入参数特别长,或者里面有一些特殊字符时该方案就会失效。
因此,我们对这部分逻辑进行优化,使用管道来实现父进程和子进程之间的参数传递。
这部分参考 runC 中也是用的这种方案。
2. 什么是匿名管道?
匿名管道是一种特殊的文件描述符,用于在父进程和子进程之间创建通信通道。
有以下特点:
管道有一个固定大小的缓冲区,一般是4KB。
这种通道是单向的,即数据只能在一个方向上流动。
当管道被写满时,写进程就会被阻塞,直到有读进程把管道的内容读出来。
同样地,当读进程从管道内拿数据的时候,如果这时管道的内容是空的,那么读进程同样会被阻塞,一直等到有写进程向管道内写数据。
是不是和 Go 中的 Channel 很像
因此,匿名管道在进程间通信中很有用,可以使一个进程的输出成为另一个进程的输入,从而实现进程之间的数据传递。
为什么选择匿名管道?
我们这个场景正好也是父进程和子进程之间传递数据,而且也是单向的,只会从父进程传递给子进程,因此正好使用匿名管道来实现。
管道使用很简单:
readPipe, writePipe, err := os.Pipe()返回的两个 FD 一个代表管道的读端,另一个代表写端。
我们只需要把 readPipe FD 告知子进程,writePipe FD 告知父进程即可完成通讯。父进程将参数写入到 writePipe 后,子进程即可从 readPipe 中读取到。
3. 具体实现
整个实现分为两个部分:
1)FD 传递 2)数据读写FD 传递
首先在父进程中创建一个匿名管道,这样父进程自然就可以拿到 writePipe 的 FD。
我们要做的就是将 readPipe FD 告知子进程。
具体实现是这样的:
func NewParentProcess(tty bool) (*exec.Cmd, *os.File) { // 创建匿名管道用于传递参数,将readPipe作为子进程的ExtraFiles,子进程从readPipe中读取参数 // 父进程中则通过writePipe将参数写入管道 readPipe, writePipe, err := os.Pipe() if err != nil { log.Errorf(“New pipe error %v”, err)return nil, nil } 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, }iftty { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr } cmd.ExtraFiles = []*os.File{readPipe}return cmd, writePipe }主要是这句:
cmd.ExtraFiles = []*os.File{readPipe}将 readPipe 作为 ExtraFiles,这样 cmd 执行时就会外带着这个文件句柄去创建子进程。
数据读写
父进程写数据由于父进程天然就能拿到 writePipe FD,因此只需要在合适的时候讲数据写入管道即可。
何为合适的时候?
虽然匿名管道自带 4K 缓冲,但是如果写满之后就会阻塞,因此最好是等子进程启动后,再往里面写,尽量避免意外情况。
因此,合适的时候就是指子进程启动之后。
如果未启动子进程就往管道中写,写完了再启动子进程,大部分情况下也可以,但是如果 cmd 大于 4k 就会导致永久阻塞。
因为子进程未启动,管道中的数据永远不会被读取,因此会一直阻塞。
对应到代码中,也就是 parent.Start() 之后,等子进程启动后就通过 writePipe FD 将命令写入到管道中。
具体实现如下:
func Run(tty bool, comArray []string) { parent, writePipe := container.NewParentProcess(tty)if parent == nil { log.Errorf(“New parent process error”) return } if err := parent.Start(); err != nil { log.Errorf(“Run parent.Start err:%v”, err) } // 在子进程创建后通过管道来发送参数 sendInitCommand(comArray, writePipe) _ = parent.Wait() } // sendInitCommand 通过writePipe将指令发送给子进程 func sendInitCommand(comArray []string, writePipe *os.File) { command := strings.Join(comArray,” “) log.Infof(“command all is %s”, command) _, _ = writePipe.WriteString(command) _ = writePipe.Close() } 子进程读数据子进程这边就麻烦一点,包含以下两步:
1)获取 readPipe FD 2)读取数据子进程启动后,首先要找到前面通过ExtraFiles 传递过来的 readPipe FD,然后才是数据读取,具体实现如下:
如果不清楚这部分代码在做什么,可以仔细阅读一下代码中的注释,对这部分逻辑有详细解释。
const fdIndex = 3 func readUserCommand() []string { // uintptr(3 )就是指 index 为3的文件描述符,也就是传递进来的管道的另一端,至于为什么是3,具体解释如下: /* 因为每个进程默认都会有3个文件描述符,分别是标准输入、标准输出、标准错误。这3个是子进程一创建的时候就会默认带着的, 前面通过ExtraFiles方式带过来的 readPipe 理所当然地就成为了第4个。 在进程中可以通过index方式读取对应的文件,比如 index0:标准输入 index1:标准输出 index2:标准错误 index3:带过来的第一个FD,也就是readPipe 由于可以带多个FD过来,所以这里的3就不是固定的了。 比如像这样:cmd.ExtraFiles = []*os.File{a,b,c,readPipe} 这里带了4个文件过来,分别的index就是3,4,5,6 那么我们的 readPipe 就是 index6,读取时就要像这样:pipe := os.NewFile(uintptr(6), “pipe”) */ pipe := os.NewFile(uintptr(fdIndex), “pipe”) msg, err := io.ReadAll(pipe)if err != nil { log.Errorf(“init read pipe error %v”, err) returnnil } msgStr :=string(msg) return strings.Split(msgStr, ” “) }子进程 fork 出来后,执行到readUserCommand函数就会开始读取参数,此时如果父进程还没有开始发送参数,根据管道的特性,子进程会阻塞在这里,一直到父进程发送数据过来后子进程才继续执行下去。
子进程拿到数据之后,就可以运行命令了:
func RunContainerInitProcess() error { // mount /proc 文件系统 mountProc() // 从 pipe 中读取命令 cmdArray := readUserCommand() iflen(cmdArray) ==0 { return errors.New(“run container get user command error, cmdArray is nil”) } path, err := exec.LookPath(cmdArray[0]) if err != nil { log.Errorf(“Exec loop path error %v”, err) returnerr }log.Infof(“Find path %s”, path) if err = syscall.Exec(path, cmdArray[0:], os.Environ()); err != nil {log.Errorf(“RunContainerInitProcess exec :” + err.Error()) } return nil }这部分倒是没什么变化,就是使用syscall.Exec 执行命令。
流程图
整个参数传递流程如下图所示:
至此,传参方式就优化完成了。
4. 测试
虽然,功能上没有改动,只优化了传参方式,不过还是测试一下。
交互式命令
root@mydocker:~/mydocker# go build . root@mydocker:~/mydocker# ./mydocker run -it /bin/sh {“level”:“info”,“msg”:“init come on”,“time”:“2024-01-03T14:44:35+08:00”} {“level”:“info”,“msg”:“command: /bin/sh”,“time”:“2024-01-03T14:44:35+08:00”} {“level”:“info”,“msg”:“command:/bin/sh”,“time”:“2024-01-03T14:44:35+08:00”} # ps -efUID PID PPID C STIME TTY TIME CMD root 1 0 0 09:47 pts/1 00:00:00 /bin/sh root 5 1 0 09:47 pts/1 00:00:00 ps -ef非交互式命令
root@mydocker:~/mydocker# ./mydocker run -it /bin/ls {“level”:“info”,“msg”:“init come on”,“time”:“2024-01-03T14:51:48+08:00”} {“level”:“info”,“msg”:“command: /bin/ls”,“time”:“2024-01-03T14:51:48+08:00”} {“level”:“info”,“msg”:“command:/bin/ls”,“time”:“2024-01-03T14:51:48+08:00”} LICENSE Makefile README.md container example go.mod go.sum main.go main_command.go mydocker run.go至此,一切正常。
5. 小结
主要使用匿名管道来替换了默认的传参方式,以避免特殊情况下可能出现的问题。
整个流程如下图所示:
父进程创建匿名管道,得到 readPiep FD 和 writePipe FD; 父进程中构造 cmd 对象时通过ExtraFiles 将 readPiep FD 传递给子进程 父进程启动子进程后将命令通过 writePipe FD 写入子进程 子进程中根据 index 拿到对应的 readPipe FD 子进程中 readPipe FD 中读取命令并执行如果你对云原生技术充满好奇,想要深入了解更多相关的文章和资讯,欢迎关注微信公众号。
扫描下方二维码或搜索公众号【探索云原生
】即可订阅
完整代码见:https://github.com/lixd/mydocker欢迎 Star
相关代码见 opt-passing-param-by-pipe 分支,测试脚本如下:
# 克隆代码 git clone -b opt-passing-param-by-pipe https://github.com/lixd/mydocker.git cd mydocker # 拉取依赖并编译go mod tidy go build .# 测试 ./mydocker run -it /bin/ls
暂无评论内容