Kevin Cui's Blog

Podman 基本原理及通信机制

Podman 是 daemonless 的开源容器引擎,相比 Docker 而言,Podman 不需要运行守护进程,而是直接通过 libpod 库来管理容器,这样就可以避免 Docker 的一些安全问题,比如 Docker 的守护进程需要 root 权限,而 Podman 可以通过普通用户来管理容器。

简要架构

podman 分为两部分,一部分是 podman client,另一部分是 libpod 库,可以简单理解为前端和后端,所有和容器相关的操作都是通过 libpod 库来实现的。podman client 所做的事情就是将用户的命令通过请求发送给 libpod 来进行操作。

值得注意的是,libpod 只有 Linux 架构的版本,所以 libpod 只能在 linux 系统下运行。至于如何在 Windows 和 MacOS 上运行可以见下一节。

Machine

在 podman 中有一个 machine 的命令,用于启动一个 Linux 虚拟机,来运行容器,这样就可以避免在 podman 中直接运行容器时,需要 root 权限的问题。

目前 podman 支持4种虚拟机类型:

  1. qemu
  2. applehv
  3. hyperv
  4. wsl2

在 Linux 系统下,machine 命令是可选的,但是在 Windows 和 MacOS 系统下,machine 命令是必须的。

值得一提的是,由 machine 启动的虚拟机系统是 Fedora Linux。 并且 machine 命令会自动下载 Fedora Linux 的镜像,所以第一次运行 podman machine init 时,会比较慢(可以事先手动下载,并通过 --image-path 参数进行指定)。 如果在使用 init 命令,并且没有指定 --image-path 时,podman 每次都会去尝试下载最新的 Fedora 镜像,关于这一点,规则如下:

  1. 如果本地有缓存,并且和最新的镜像一致,则不会下载
  2. 缓存只保留一个版本,并且会在两周后删除。

Fedora Linux 中内置了 podman,所以其实在 Windows 和 MacOS 系统里,你所运行的 podman 命令其实都是通过 gvp 或者 ssh 连接到虚拟机里的 socket file,通过 socket file 把请求转发给 libpod 来进行处理。如下图:

podman

图中的 podman remote 就是宿主机中的 podman 命令,之所以这么写,是因为在 Windows 和 MacOS 下所运行的 podman 都是 remote 模式。(在 Linux 中,可以通过加入 --remote 参数手动进入此模式)

通信

在上一节中的例图中,有一块是 GVP / SSH,这是用来让宿主机和虚拟机互相通信的技术方案。在介绍 GVP 之前,需要先了解 SSH 通信原理,这会有助于理解 GVP。

SSH

当通过 podman machine initpodman machine start 来初始化并启动虚拟机时,podman 会在虚拟机中启动一个 ssh 服务。

这时,可以通过 podman system connection ls --format=json 命令查看连接信息,如下:

[
    {
        "Name": "podman-machine-default",
        "URI": "ssh://[email protected]:65489/run/user/502/podman/podman.sock",
        "Identity": "/Users/black-hole/.ssh/podman-machine-default",
        "IsMachine": true,
        "Default": true
    },
    {
        "Name": "podman-machine-default-root",
        "URI": "ssh://[email protected]:65489/run/podman/podman.sock",
        "Identity": "/Users/black-hole/.ssh/podman-machine-default",
        "IsMachine": true,
        "Default": false
    }
]

其中 podman-machine-default-root 用于以 root 权限访问虚拟机,但一般而言,我们都是以普通用户的身份访问虚拟机,所以这里只关注 podman-machine-default 即可。

从上面的信息中可以看到,URI 是:ssh://[email protected]:65489/run/user/502/podman/podman.sock。这时我们就可以通过这个 socket file 去连接到虚拟机中的 libpod 服务。 podman 发送请求时,会先建立 ssh 连接,然后将请求通过 ssh 通道发送到虚拟机中。其实现简要代码如下:

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"net/url"

	"github.com/containers/common/pkg/ssh"
)

func main() {
	uri := "ssh://[email protected]:65489/run/user/502/podman/podman.sock"
	_url, _ := url.Parse(uri)
	conn, _ := ssh.Dial(&ssh.ConnectionDialOptions{
		Host:                        uri,
		Identity:                    "/Users/black-hole/.ssh/podman-machine-default",
		User:                        _url.User,
		Port:                        65489,
		InsecureIsMachineConnection: false,
	}, "golang")

	client := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
		return ssh.DialNet(conn, "unix", _url)
	}}}

	resp, _ := client.Get("http://d/v4.6.0/libpod/_ping")
	fmt.Println(resp.StatusCode)
}

这种方式是 Windows 系统下的行为,而在 MacOS 系统下,默认行为是通过 GVP 来实现的。

在 Windows 系统中,所有 cli 涉及到容器/镜像的操作都是基于这种方式去做的。

GVP (gvisor-tap-vsock)

引入 GVP 的目的之一是为了解决每次都要通过 ssh 去连接虚拟机以及负责 API 转发的功能。

需要注意的是,GVP 是常驻进程。

简要描述就是把宿主机中的 socket file 流量转发到虚拟机中的 socket file

首先 podman 会先在宿主机中创建一个 socket file,然后在启动 qemu 时,加入 -qmp 参数,暴露 qemusocket file,然后再通过 GVP 技术将宿主机中的 socket fileqemu 中的 socket file 进行绑定,这样就可以实现宿主机中的 socket file 和虚拟机中的 socket file 之间的通信。其命令简化如下:

gvproxy -listen-qemu unix:///var/folders/zm/g19w916x2x36bt16htrwtsh80000gp/T/podman/qmp_podman-machine-default.sock -forward-sock /Users/black-hole/.local/share/containers/podman/machine/qemu/podman.sock -forward-dest /run/user/502/podman/podman.sock

qemu-system-x86_64 -qmp unix:/var/folders/zm/g19w916x2x36bt16htrwtsh80000gp/T/podman/qmp_podman-machine-default.sock,server=on,wait=off -virtfs local,path=/var/folders,mount_tag=vol2,security_model=none

podman 在 MacOS 系统中还提供了 podman-mac-helper 的可执行文件,并和 podman 一起打包在了 pkg 安装包里。 当执行 podman machine start 时,podman 会先检测是否有 podman-mac-helper,如果存在,则会通过 podman-mac-helper 来创建一个软链接: /var/run/docker.sock,这个软连接指向的就是宿主机中的 socket file

API 转发

在开始之前,可以先思考一下,如何在你的程序里调用 podman api?

MacOS

在 MacOS 中可以执行: export DOCKER_HOST='unix:///Users/black-hole/.local/share/containers/podman/machine/qemu/podman.sock' 来让后续的 podman 命令都通过本地的 socket file 通信。

以及也可以使用这个 socket file 进行 API 调用,如下:

curl --unix-socket /Users/black-hole/.local/share/containers/podman/machine/qemu/podman.sock http://d/v4.6.0/libpod/_ping

Windows

首先,在 Windows 系统下,是没有 socket file 的概念的。但是 API 调用又非常重要,为了解决这一问题,podman 团队提供了一个 win-sshproxy.exe 的可执行文件,这个文件是用来转发 API 请求的。

win-sshproxy 的基本原理是通过 Windows 的 Named Pipe(命名管道)创建一个双工管道(duplex pipe)来实现的,可以将其简单的理解为 socket file

为了让其可以正常工作,此进程是需要常驻。

win-sshproxy 创建 Named Pipe 源码十分简单,如下:

// Allow built-in admins and system/kernel components
const SddlDevObjSysAllAdmAll = "D:P(A;;GA;;;SY)(A;;GA;;;BA)"

func ListenNpipe(socketURI *url.URL) (net.Listener, error) {
	user, _ := user.Current()

	// Also allow current user
	sddl := fmt.Sprintf("%s(A;;GA;;;%s)", SddlDevObjSysAllAdmAll, user.Uid)
	config := winio.PipeConfig{
		SecurityDescriptor: sddl,
		MessageMode:        true,
		InputBufferSize:    65536,
		OutputBufferSize:   65536,
	}
	path := strings.Replace(socketURI.Path, "/", "\\", -1)
    return winio.ListenPipe(path, &config)
}

创建完 Named Pipe 后,win-sshproxy 将会启动一个 ssh 连接,并和 Named Pipe 进行绑定,绑定的逻辑也十分简单,如下:

func main() {
    complete := new(sync.WaitGroup)
	complete.Add(2)
    go forward(ssh, namedPipe, complete)
	go forward(namedPipe, ssh, complete)

	go func() {
		complete.Wait()
		ssh.Close()
		namedPipe.Close()
	}()
}

func forward(src io.ReadCloser, dest CloseWriteStream, complete *sync.WaitGroup) {
	defer complete.Done()
	_, _ = io.Copy(dest, src)

	// 让另一端的 io.Copy() 退出
	_ = dest.CloseWrite()
}

自此,宿主机就可以通过 Named Pipe 来转发 API 请求。如下方的 js 代码:

require('http').get({
    hostname: 'd',
    path: '/v4.6.0/libpod/_ping',
    socketPath: '//./pipe/docker_engine',
}, res => {
    console.log(`statusCode: ${res.statusCode}`)
    res.destroy();
}).end();

#podman #linux #gvp #container

Reply to this post by email ↪