上回通过uid和gid的映射解决了容器内用户权限问题,使之更像一个容器进程。

这次要更进一步,通过理解docker的reexec包解决一种容器中常遇到的一种场景。为什么要单独提到这个reexec,那是因为这是容器场景的基础问题,后面的讲解会基于这种场景,所以这里特别说明下。

reexec是docker实现的一种比较hack的方式,使得程序能重新执行自己的代码。听起来是有点hack的方法,但由于go并没有提供原生的方法,所以才有这个包。说了这么多可能还是不是很清晰,究竟reexec解决的是什么场景,为什么需要它,那就用一个🌰来说明下。

问题的引出

当我们通过UTS namespace 来clone了一个新进程,此时你可能已经发现,新进程的hostname和原宿主的hostname是一毛一样的。如果它能随机设置这个hostname那就是再好不过,毕竟处于安全考虑,我们肯定不希望新的namespace中的进程(程序)感知到真.host的主机名。所以如果我在调用系统clone函数启动/bin/sh这个新进程前如果能多一步设置新hostname那这个问题就解决了。

然鹅,golang并没有提供这样的机制,它所提供的*exec.Cmd只是调用系统clone(xxx),结果就是一个新进程被fork出来,根本来不及初始化之类的设置。

为了验证这个,可以看下之前写过的简单代码:

func main() {
    cmd := exec.Command("echo","it's too late to set the hostname, because process have been started/forked")

    //set identify for this demo
    cmd.Env = []string{"PS1=-[namespace-process]-# "}
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS,
    }

    if err := cmd.Run(); err != nil {
        fmt.Printf("Error running the /bin/echo command - %s\n", err)
        os.Exit(1)
    }
}

然后编译和执行

buntu@VM-0-7-ubuntu:~/workspace/gons$ go build ns-proc.go
ubuntu@VM-0-7-ubuntu:~/workspace/gons$ sudo ./ns-proc
it's too late to set the hostname, because process have been started/forked
ubuntu@VM-0-7-ubuntu:~/workspace/gons$

如果我用strace命令追逐程序的执行情况可以看到

ubuntu@VM-0-7-ubuntu:~/workspace/gons$ sudo strace ./ns-proc
...省略...
pipe2([3, 4], O_CLOEXEC)                = 0
getpid()                                = 1904
rt_sigprocmask(SIG_SETMASK, NULL, [], 8) = 0
rt_sigprocmask(SIG_SETMASK, ~[], NULL, 8) = 0
clone(child_stack=0, flags=CLONE_VM|CLONE_VFORK|CLONE_NEWUTS|SIGCHLD) = 1907
rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
close(4)                                = 0
read(3, "", 8)                          = 0
close(3)                                = 0
waitid(P_PID, 1907, it's too late to set the hostname, because process have been started/forked
{si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=1907, si_uid=0, si_status=0, si_utime=0, si_stime=0}, WEXITED|WNOWAIT, NULL) = 0
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=1907, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
rt_sigreturn({mask=[]})                 = 0
wait4(1907, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, {ru_utime={0, 0}, ru_stime={0, 0}, ...}) = 1907
exit_group(0)                           = ?
+++ exited with 0 +++

当程序开始执行cmd.Run(),可以看到调用了Linux系统的clone函数创建新进程然后程序就启动了再没有其他设置只有读取输出并优雅地退出了。。新进程是直接就启动了,也就是说go并没有提供办法来实现新命名空间创建之后,且进程启动之前进行其他操作。

解析reexec,实现包装自己并再次执行

package reexec

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
)

var registeredInitializers = make(map[string]func())

// Register adds an initialization func under the specified name
func Register(name string, initializer func()) {
	if _, exists := registeredInitializers[name]; exists {
		panic(fmt.Sprintf("reexec func already registered under name %q", name))
	}

	registeredInitializers[name] = initializer
}

// Init is called as the first part of the exec process and returns true if an
// initialization function was called.
func Init() bool {
	initializer, exists := registeredInitializers[os.Args[0]]
	if exists {
		initializer()

		return true
	}
	return false
}

func naiveSelf() string {
	name := os.Args[0]
	if filepath.Base(name) == name {
		if lp, err := exec.LookPath(name); err == nil {
			return lp
		}
	}
	// handle conversion of relative paths to absolute
	if absName, err := filepath.Abs(name); err == nil {
		return absName
	}
	// if we couldn't get absolute name, return original
	// (NOTE: Go only errors on Abs() if os.Getwd fails)
	return name
}

看着代码也不多那就全放上来好了。可以看到,reexec包提供了一种机制,通过Register(funName,func()) 把要再次执行的代码func(),用funName这个名字注册到内存中,然后包初始化函数Init()会检查内存中是否有注册过函数,有就取出来执行一次。

内存中的自己

/proc/self/exe 这是个有趣的文件 请看下面命令执行结果:

ubuntu@VM-0-7-ubuntu:~/workspace/gons$ file /proc/self/exe
/proc/self/exe: symbolic link to /usr/bin/file
ubuntu@VM-0-7-ubuntu:~/workspace/gons$ stat /proc/self/exe
  File: '/proc/self/exe' -> '/usr/bin/stat'
  Size: 0         	Blocks: 0          IO Block: 1024   symbolic link
Device: 4h/4d	Inode: 122807854   Links: 1
Access: (0777/lrwxrwxrwx)  Uid: (  500/  ubuntu)   Gid: (  500/  ubuntu)
Access: 2019-04-27 20:23:54.814457133 +0800
Modify: 2019-04-27 20:23:54.814457133 +0800
Change: 2019-04-27 20:23:54.814457133 +0800
 Birth: -
ubuntu@VM-0-7-ubuntu:~/workspace/gons$

我通过不同的命令(file,stat)去读/proc/self/exe这个文件,结果都表明,这个文件就是指向当前执行的程序。

带着这个知识点继续揭开reexec包的入口

// +build linux

package reexec

import (
	"os/exec"
	"syscall"
)

// Self returns the path to the current process's binary.
// Returns "/proc/self/exe".
func Self() string {
	return "/proc/self/exe"
}

// Command returns *exec.Cmd which has Path as current binary. Also it setting
// SysProcAttr.Pdeathsig to SIGTERM.
// This will use the in-memory version (/proc/self/exe) of the current binary,
// it is thus safe to delete or replace the on-disk binary (os.Args[0]).
func Command(args ...string) *exec.Cmd {
	return &exec.Cmd{
		Path: Self(),
		Args: args,
		SysProcAttr: &syscall.SysProcAttr{
			Pdeathsig: syscall.SIGTERM,
		},
	}
}

你是个思考者🤔

这代表什么? reexec.Command(), 刚才说过,程序执行后会产生一个临时的二文件/proc/self/exe,这个也代表当前执行的程序(自己的一个副本),结果代码表示,它会调用运行中的自己。

回顾我们提出的问题,这就是重新调用程序自己重新执行另一段代码的办法,通过args[0]把需要重新执行的代码保存起来,然后调用自己,再把代码取出来执行。

具体怎么做,请看下面

撸起袖子

回到我们的demo

func init() {
	fmt.Printf("arg0=%s,\n",os.Args[0])
	reexec.Register("initFuncName", func() {
		fmt.Printf("\n>> namespace setup code goes here <<\n\n")
		nsRun()
	})
	if reexec.Init() {
		os.Exit(0)
	}
}

>注意:在golang中,init函数通常是在包加载的时候最先执行,甚至在main函数执行前他就执行了。

这里首先把一个 匿名函数 用initFuncName这个名字注册到内存,然后打印一句初始化语句假装真的在初始化。 然后检查Init()是否已经执行过(Init检查程序第一个参数这个名字的函数是否有注册,有就执行并返回true). 如果有执行就会退出,否则就就是个死循环了. 在我们注册的函数还调用nsRun(),这才是我要启动的函数,但是现在只是注册,reexec.Init()这里会决定它是否需要执行这个注册的匿名函数。稍后看程序输出就知道什么时候执行

下面是注册的函数里面要用到的nsRun()

func nsRun() {
	cmd := exec.Command("sh")

	//set identify for this demo
	cmd.Env = []string{"PS1=-[namespace-process]-# "}
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS,
	}
	//todo uid and gid mapping

	if err := cmd.Run(); err != nil {
		fmt.Printf("Error running the /bin/sh command - %s\n", err)
		os.Exit(1)
	}
}

至于程序的main函数

func main() {

	cmd := reexec.Command("initFuncName")

	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr


	if err := cmd.Run(); err != nil {
		fmt.Printf("Error running the reexec.Command - %s\n", err)
		os.Exit(1)
	}

}

关键语句cmd := reexec.Command("initFuncName"),此时传递的是希望执行内存中已经注册的initFuncName函数。现在回过头看下init()函数,里面有一句fmt.Printf("arg0=%s,\n",os.Args[0])

编译执行demo程序看到输出:

ubuntu@VM-0-7-ubuntu:~/workspace/gons$ go build ns-proc.go
ubuntu@VM-0-7-ubuntu:~/workspace/gons$ sudo ./ns-proc
arg0=./ns-proc,
arg0=initFuncName,

>> namespace setup code goes here <<

-[namespace-process]-#

从打印结果来看,init被执行两次!

第一次初始化,此时arg0=./ns-proc,显然我们没有注册过ns-proc这个名字的函数在内存中,所以这个时候程序不会退出。 而是开始注册调用reexec.Register()注册initFuncName这个函数。当然了这里仅仅是注册一个函数,这个函数会调用nsRun()这里才是我们要启动的sh进程,但现在它只是注册到内存中还不会执行(因为此时arg0=./ns-proc,所以if reexec.Init()也就不执行)那然后就算注册完成。

init()完成现在控制权交回main()。 此时main有一句cmd := reexec.Command("initFuncName"),刚才我们翻阅过reexec源码,知道这里其实是调用运行中的自己即/proc/self/exe,同时带了一个参数initFuncName,这么巧呢这个参数就等于刚才我们注册在内存中的匿名函数。于是,这调用就开始执行了。而重新执行意味着 init第二次被调用了。 程序输出来了arg0=initFuncName,表明确实是这样的。所以reexec.Command("initFuncName")产生的结果就是 执行/proc/self/exe initFuncName,于是我们就看到了第二次init输出. 而此时回到init函数,可以看到它检查内存中已经注册了initFuncName这个匿名函数。 它就取出这个函数开始执行(这个刚注册的匿名函数里面的nsRun()最终得到执行,新进程sh就通过系统clone()创建出来了)。 init知道已经执行了注册函数,然后他就安优雅的结束了。

附上完整程序,编译并执行看结果就很清晰了

// +build linux



package main
import (
	"fmt"
	"github.com/docker/docker/pkg/reexec"
	"os"
	"os/exec"
	"syscall"
)

func init() {

	fmt.Printf("arg0=%s,\n",os.Args[0])

	reexec.Register("initFuncName", func() {
		fmt.Printf("\n>> namespace setup code goes here <<\n\n")

		nsRun() //calling clone() to create new process goes here
	})

	if reexec.Init() {
		os.Exit(0)
	}
}


func nsRun() {
	cmd := exec.Command("sh")

	//set identify for this demo
	cmd.Env = []string{"PS1=-[namespace-process]-# "}
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWNS |
			syscall.CLONE_NEWUTS |
			syscall.CLONE_NEWIPC |
			syscall.CLONE_NEWPID |
			syscall.CLONE_NEWNET |
			syscall.CLONE_NEWUSER,
		UidMappings: []syscall.SysProcIDMap{
			{
				ContainerID: 0,
				HostID:      os.Getuid(),
				Size:        1,
			},
		},
		GidMappings: []syscall.SysProcIDMap{
			{
				ContainerID: 0,
				HostID:      os.Getgid(),
				Size:        1,
			},
		},
	}

	if err := cmd.Run(); err != nil {
		fmt.Printf("Error running the /bin/sh command - %s\n", err)
		os.Exit(1)
	}
}

func main() {

	cmd := reexec.Command("initFuncName")

	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr


	if err := cmd.Run(); err != nil {
		fmt.Printf("Error running the reexec.Command - %s\n", err)
		os.Exit(1)
	}

}

此外,完整工程代码可以访问git仓库,考虑到网速问题,暂时放国内了,gitee 托管

接下来做什么

现在我们有个比较geek的办法可以让一个程序执行两次,这样就可以在clone新进程之前做些初始化。具体如何初始化,这里仅仅是打印了一句话假装在初始化。具体有哪些操作要执行,我们下回分解。