本文为从零开始写 Docker 系列第二篇,主要在 mydocker run 命令基础上优化参数传递方式,改为使用 runC 同款的匿名管道传递参数。


如果你对云原生技术充满好奇,想要深入了解更多相关的文章和资讯,欢迎关注微信公众号。

扫描下方二维码或搜索公众号【探索云原生】即可订阅



完整代码见:https://github.com/lixd/mydocker

欢迎 Star


推荐阅读以下文章对 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

从零开始写 Docker(二)---优化:使用匿名管道传递参数的更多相关文章

  1. Unix/Linux进程间通信(二):匿名管道、有名管道 pipe()、mkfifo()

    1. 管道概述及相关API应用 1.1 管道相关的关键概念 管道是Linux支持的最初Unix IPC形式之一,具有以下特点: 管道是半双工的,数据只能向一个方向流动:需要双方通信时,需要建立起两个管 ...

  2. 一起学习造轮子(二):从零开始写一个Redux

    本文是一起学习造轮子系列的第二篇,本篇我们将从零开始写一个小巧完整的Redux,本系列文章将会选取一些前端比较经典的轮子进行源码分析,并且从零开始逐步实现,本系列将会学习Promises/A+,Red ...

  3. 从零开始学习 Docker

      这篇文章是我学习 Docker 的记录,大部分内容摘抄自 <<Docker - 从入门到实践>> 一书,并非本人原创.学习过程中整理成适合我自己的笔记,其中也包含了我自己的 ...

  4. Linux进程间通信(三):匿名管道 popen()、pclose()、pipe()、close()、dup()、dup2()

    在前面,介绍了一种进程间的通信方式:使用信号,我们创建通知事件,并通过它引起响应,但传递的信息只是一个信号值.这里将介绍另一种进程间通信的方式——匿名管道,通过它进程间可以交换更多有用的数据. 一.什 ...

  5. IPC——匿名管道

    Linux进程间通信——使用匿名管道 在前面,介绍了一种进程间的通信方式:使用信号,我们创建通知事件,并通过它引起响应,但传递的信息只是一个信号值.这里将介绍另一种进程间通信的方式——匿名管道,通过它 ...

  6. Linux进程间通信——使用匿名管道

    在前面,介绍了一种进程间的通信方式:使用信号,我们创建通知事件,并通过它引起响应,但传递的信息只是一个信号值.这里将介绍另一种进程间通信的方式——匿名管道,通过它进程间可以交换更多有用的数据.   一 ...

  7. Linux - 进程间通信 - 匿名管道

    一.概念:进程间通信( IPC,InterProcess Communication) 每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进城之间要交换数据必须通过内 ...

  8. 一起学习造轮子(一):从零开始写一个符合Promises/A+规范的promise

    本文是一起学习造轮子系列的第一篇,本篇我们将从零开始写一个符合Promises/A+规范的promise,本系列文章将会选取一些前端比较经典的轮子进行源码分析,并且从零开始逐步实现,本系列将会学习Pr ...

  9. 2018-10-20-C#-从零开始写-SharpDx-应用-初始化dx修改颜色

    title author date CreateTime categories C# 从零开始写 SharpDx 应用 初始化dx修改颜色 lindexi 2018-10-20 17:34:37 +0 ...

  10. 从零开始学习docker之在docker中搭建redis(集群)

    docker搭建redis集群 docker-compose是以多容器的方式启动,非常适合用来启动集群 一.环境准备 云环境:CentOS 7.6 64位 二.安装docker-compose #需要 ...

随机推荐

  1. [转帖]TiDB 热点问题处理

    TiDB 热点问题处理 本文介绍如何定位和解决读写热点问题. TiDB 作为分布式数据库,内建负载均衡机制,尽可能将业务负载均匀地分布到不同计算或存储节点上,更好地利用上整体系统资源.然而,机制不是万 ...

  2. [转帖]并发控制- sched_yield 函数

    函数说明 函数原型 #include <sched.h> int sched_yield(void); 1 2 sched_yield的作用是让出处理器,调用时会导致当前线程放弃CPU,进 ...

  3. CentOS firewall简单总结

    CentOS firewall简单总结 简介 防火墙是安全的重要道防线. 硬件防火墙一般部署再内网的边界区域.作为最外层的防护. 但是一般硬件的防火墙会比较宽松. 不然会导致很多业务不可用 软件防火墙 ...

  4. UnixBench的简单测试与验证

    UnixBench的简单测试与验证 目标 飞腾2000+ (物理机和虚拟机) Intel Golden 6170 物理机 Intel Golden 5218 虚拟机 Gold 5218 CPU @ 2 ...

  5. 文盘Rust -- 安全连接 TiDB/Mysql

    作者:京东科技 贾世闻 最近在折腾rust与数据库集成,为了偷懒,选了Tidb Cloud Serverless Tier 作为数据源.Tidb 无疑是近五年来最优秀的国产开源分布式数据库,Tidb ...

  6. 洛谷P3101 题解

    输入格式 第 \(1\) 行,三个整数 \(m,n,t\). 第 \(2\) 到 \(m+1\) 行,\(m\) 个整数,表示海拔高度. 第 \(2+m\) 到 \(2m+1\) 行,\(m\) 个整 ...

  7. linux虚拟机固定ip

    1.查看宿主机IP信息 在windows宿主机上,键盘输入win+r,输出cmd,打开终端命令行: 输入ipconfig /all,查看宿主机IP信息: 2.修改Linux虚拟机的配置文件 Linux ...

  8. Redis启用认证

    要在Redis中启用认证,您需要在Redis配置文件中设置requirepass指令.以下是步骤: 找到Redis配置文件.这通常是redis.conf,可能位于/etc/redis/或/etc/目录 ...

  9. 手撕Vue-数据驱动界面改变中

    经过上一篇的介绍,已经实现了观察者模式的基本内容,接下来要完成的就是将上一篇的发布订阅模式运用到 Nue 中,实现数据驱动界面改变. 在监听数据变化的章节当中,根据指定的区域和数据去编译渲染界面 这个 ...

  10. Python 使用sigthief签发证书

    Windows 系统中的一些非常重要文件通常会被添加数字签名,其目的是用来防止被篡改,能确保用户通过互联网下载时能确信此代码没有被非法篡改和来源可信,从而保护了代码的完整性.保护了用户不会被病毒.恶意 ...