共计 5934 个字符,预计需要花费 15 分钟才能阅读完成。
本文为从零开始写 Docker 系列第二篇,次要在 mydocker run 命令根底上优化参数传递形式,改为应用 runC 同款的匿名管道传递参数。
<!–more–>
如果你对云原生技术充斥好奇,想要深刻理解更多相干的文章和资讯,欢送关注微信公众号。
扫描下方二维码或搜寻公众号【摸索云原生】即可订阅
残缺代码见:https://github.com/lixd/mydocker
欢送 Star
举荐浏览以下文章对 docker 根本实现有一个大抵意识:
- 外围原理:深刻了解 Docker 外围原理:Namespace、Cgroups 和 Rootfs
- 基于 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 -a | |
No 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 user's 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,} | |
if tty { | |
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) | |
return nil | |
} | |
msgStr := string(msg) | |
return strings.Split(msgStr, " ") | |
} |
子过程 fork 进去后,执行到 readUserCommand
函数就会开始读取参数,此时如果父过程还没有开始发送参数,依据管道的个性,子过程会阻塞在这里,始终到父过程发送数据过去后子过程才继续执行上来。
子过程拿到数据之后,就能够运行命令了:
func RunContainerInitProcess() error { | |
// mount /proc 文件系统 | |
mountProc() | |
// 从 pipe 中读取命令 | |
cmdArray := readUserCommand() | |
if len(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) | |
return err | |
} | |
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 -ef | |
UID 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 |