如何在Pod中执行宿主机上的命令
要回答标题中的疑问,我们首先要清楚,Pod是什么?
Pod的翻译叫容器组,顾名思义,是一组容器。叫做“组”是因为这些容器:
- 总是被同时调度,调度到同一节点
- 共享网络,具有相同的IP地址和端口空间,可以通过localhost相互访问
- 可以基于SystemV信号量、POSIX消息队列等方式,进行进程间通信
- 共享存储卷(需要各自分别挂载)
从效果上看,容器组运行在一个虚拟的“主机”中。这个“主机”基于Linux命名空间、cgroups等机制和宿主机相互隔离。
虽说容器具有隔离性,但是这种隔离程度远远不如虚拟机,容器本质上就是进程。内核也提供了接口,允许你切换命名空间。只需要切换到宿主机的初始(Initial)命名空间,理论上就可以运行宿主机文件系统中的任何程序,并保证程序的行为正常。
在K8S的Pod的Spec中,和命名空间有关的编排配置包括:
1 2 3 4 5 6 7 8 9 10 11 |
type PodSpec struct { // 使用宿主机的网络命名空间 HostNetwork bool `json:"hostNetwork,omitempty" protobuf:"varint,11,opt,name=hostNetwork"` // 使用宿主机的PID命名空间 HostPID bool `json:"hostPID,omitempty" protobuf:"varint,12,opt,name=hostPID"` // 使用宿主机的IPC命名空间 HostIPC bool `json:"hostIPC,omitempty" protobuf:"varint,13,opt,name=hostIPC"` // 让所有容器共享同一个PID命名空间,此选项不能和HostPID同时设置 // 启用该选项后,容器的第一个进程不会赋予PID 1 ShareProcessNamespace *bool `json:"shareProcessNamespace,omitempty" protobuf:"varint,27,opt,name=shareProcessNamespace"` } |
创建一个启用上述配置,和宿主机共享网络、PID、IPC命名空间的Pod后,通过 kubectl exec执行 ps aux,你会看到宿主机上的进程。
其它User、Mount、Cgroup等几种命名空间,没有相应的配置字段。需要强调的是,Mount命名空间无法共享,容器需要独立的文件系统树来挂载镜像。仅仅通过K8S提供的编排配置,无法实现我们的目标 —— 因为看不到和宿主机一样的文件系统树。
上文提到过,内核允许切换到某个命名空间,然后执行应用程序。系统调用setns、unshare、clone等提供了切换命名空间的接口,命令行工具nsenter、unshare也可以实现相同的功能。
如果我们在启用上述配置的容器中执行 nsenter,尝试切换到初始Mount命名空间,会提示Permission denied错误:
1 2 |
nsenter -m -t 1 nsenter: cannot open /proc/1/ns/mnt: Permission denied |
这说明容器没有足够的权限进行操作。
Kubernets允许容器以特权模式运行,你只需要配置安全上下文即可。安全上下文包括Pod、Container两个级别。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
type PodSpec struct { // 提供Pod级别的安全属性,并为容器安全属性提供默认值 SecurityContext *PodSecurityContext `json:"securityContext,omitempty" protobuf:"bytes,14,opt,name=securityContext"` } type PodSecurityContext struct { // 应用到所有容器的SELinux上下文,如果不指定,则容器运行时为每个容器指定随机的SELinux上下文 SELinuxOptions *SELinuxOptions `json:"seLinuxOptions,omitempty" protobuf:"bytes,1,opt,name=seLinuxOptions"` // 运行容器进程入口点使用的UID,默认从镜像元数据中获取UID RunAsUser *int64 `json:"runAsUser,omitempty" protobuf:"varint,2,opt,name=runAsUser"` // 运行容器进程入口点使用的GID,默认从使用容器运行时的默认值 RunAsGroup *int64 `json:"runAsGroup,omitempty" protobuf:"varint,6,opt,name=runAsGroup"` // 提示容器必须以非Root身份运行,如果设置为true,则Kubelet会在运行时校验镜像 // 确保它不以UID 0运行,如果发现镜像以UID 0 进行则导致启动失败 RunAsNonRoot *bool `json:"runAsNonRoot,omitempty" protobuf:"varint,3,opt,name=runAsNonRoot"` // 额外的补充组,赋予容器的第一个进程,作为组GID的补充 SupplementalGroups []int64 `json:"supplementalGroups,omitempty" protobuf:"varint,4,rep,name=supplementalGroups"` // 一个特殊的、应用到所有容器的补充组 // 某些类型的卷,允许Kubelet修改卷的所有者,这可以确保容器有权访问卷的内容 // 该选项导致: // 1. 卷的所有者GID设置为FSGroup // 2. setgid位被启用,这导致卷中新创建的文件的所有者为FSGroup // 3. 卷中文件的模式和rw-rw----进行或操作,也就是启用所有者、所在组的读写权限 // 如果不配置,kubelet不会修改任何卷的所有者和文件模式 FSGroup *int64 `json:"fsGroup,omitempty" protobuf:"varint,5,opt,name=fsGroup"` // 指定一系列命名空间化的Sysctl键值 // 如果容器运行时不支持某个Sysctl则可能导致启动失败 Sysctls []Sysctl `json:"sysctls,omitempty" protobuf:"bytes,7,rep,name=sysctls"` } |
容器安全上下文中,有一部分字段和Pod安全上下文一样,它们会覆盖Pod安全上下文中的对应设置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
type Container struct { SecurityContext *SecurityContext `json:"securityContext,omitempty" protobuf:"bytes,15,opt,name=securityContext"` } type SecurityContext struct { // 需要给容器添加/删除的能力列表,默认能力取决于容器运行时 Capabilities *Capabilities `json:"capabilities,omitempty" protobuf:"bytes,1,opt,name=capabilities"` // 以特权模式运行容器。这种模式下,容器中进程的身份等价于宿主机的root Privileged *bool `json:"privileged,omitempty" protobuf:"varint,2,opt,name=privileged"` // 覆盖Pod上下文设置 SELinuxOptions *SELinuxOptions `json:"seLinuxOptions,omitempty" protobuf:"bytes,3,opt,name=seLinuxOptions"` // 覆盖Pod上下文设置 RunAsUser *int64 `json:"runAsUser,omitempty" protobuf:"varint,4,opt,name=runAsUser"` // 覆盖Pod上下文设置 RunAsGroup *int64 `json:"runAsGroup,omitempty" protobuf:"varint,8,opt,name=runAsGroup"` // 覆盖Pod上下文设置 RunAsNonRoot *bool `json:"runAsNonRoot,omitempty" protobuf:"varint,5,opt,name=runAsNonRoot"` // 容器的根文件系统是否设置为只读 ReadOnlyRootFilesystem *bool `json:"readOnlyRootFilesystem,omitempty" protobuf:"varint,6,opt,name=readOnlyRootFilesystem"` // 是否允许子进程获得比父进程更多的特权,控制容器进程的no_new_privs标记是否被设置 // 如果容器是运行在特权模式,或者具有CAP_SYS_ADMIN能力,则该配置自动为true AllowPrivilegeEscalation *bool `json:"allowPrivilegeEscalation,omitempty" protobuf:"varint,7,opt,name=allowPrivilegeEscalation"` // 指定该容器的proc挂载类型,默认的 ProcMount *ProcMountType `json:"procMount,omitempty" protobuf:"bytes,9,opt,name=procMount"` } |
要满足我们的需求,只需要设置容器安全上下文的Privileged为True就足够了。这样你就可以通过nsenter进入宿主机的Mount命名空间,并且随意的运行命令了,例如通过systemctl判断某些服务是否正常运行。
这个样例允许我们在容器中访问宿主机的日志、控制宿主机的systemd,而不需要切换整个Mount命名空间。我们目前项目的一个需求就是,能够读取节点的内核日志环、Journald日志,可以用下面这种卷挂载的方式满足:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
apiVersion: apps/v1 kind: DaemonSet metadata: name: centos namespace: kube-system spec: selector: matchLabels: name: centos template: metadata: labels: name: centos spec: # 加入宿主机网络命名空间 hostNetwork: true # 加入宿主机PID命名空间 hostPID: true # 加入宿主机IPC命名空间 hostIPC: true containers: - image: docker.gmem.cc/centos:7.6 imagePullPolicy: Always name: centos securityContext: # 设置PID为root runAsUser: 0 # 特权模式 privileged: true volumeMounts: # 这个挂载允许容器中的systemctl和宿主机的systemd通信 - name: dbus mountPath: /var/run/dbus - name: run-systemd mountPath: /run/systemd # 这个挂载允许查看宿主机的systemd配置 - name: etc-systemd mountPath: /etc/systemd # 这个挂载允许容器读取非journald管理的日志 - name: var-log mountPath: /var/log - name: var-run mountPath: /var/run - name: run mountPath: /run - name: usr-lib-systemd mountPath: /usr/lib/systemd volumes: - name: dbus hostPath: path: /var/run/dbus type: Directory - name: run-systemd hostPath: path: /run/systemd type: Directory - name: etc-systemd hostPath: path: /etc/systemd type: Directory - name: var-log hostPath: path: /var/log type: Directory - name: var-run hostPath: path: /var/run type: Directory # /var/run 是 /run的符号链接 - name: run hostPath: path: /run type: Directory - name: usr-lib-systemd hostPath: path: /usr/lib/systemd type: Directory |
这是一个命令行工具,能够在指定的命名空间中执行命令。K8S的utils/nsenter包对该命令进行了封装,可以参考。
要编程式的切换命名空间,可以利用这个系统调用。
在Go语言下,你需要注意的一点是,setns调用可能需要单线程上下文。而Go运行时是多线程的,你必须在Go运行时启动之前,执行setns调用。要实现这种提前调用,可以利用cgo的constructor技巧,该技巧能够在Go运行时启动之前,执行一个任意的C函数:
1 2 3 4 5 6 7 |
/* __attribute__((constructor)) void init() { // 这里的代码会在Go运行时启动前执行 // 它会在单线程的C上下文中运行 } */ import "C" |
libcontainer提供了基于此技巧的例子。
在Go语言中,你可以这样设置NS:
1 2 3 4 |
// 将当前线程的NS设置为ns.Fd()这个文件描述符所指向的网络命名空间 if err := unix.Setns(int(ns.Fd()), unix.CLONE_NEWNET); err != nil { return fmt.Errorf("Error switching to ns %v: %v", ns.file.Name(), err) } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
// 在ns所代表的网络命名空间(以网络命名空间的文件描述符锚定) func (ns *netNS) Do(toRun func(NetNS) error) error { // 如果命名空间(文件描述符)已经关闭 if err := ns.errorIfClosed(); err != nil { return err } // 设置当前调用者goroutine的网络NS为当前对象所代表的网络OS // 然后运行函数,完毕后重置为线程原先的网络命名空间 // 为了防止goroutine底层的OS线程切换,需要锁定、回调执行完毕后解锁 containedCall := func(hostNS NetNS) error { // 得到当前线程的NS threadNS, err := GetCurrentNS() if err != nil { return fmt.Errorf("failed to open current netns: %v", err) } // 关闭文件描述符 defer threadNS.Close() // 设置NS if err = ns.Set(); err != nil { return fmt.Errorf("error switching to ns %v: %v", ns.file.Name(), err) } // 结束回调后,恢复NS,解锁线程 defer func() { err := threadNS.Set() // switch back if err == nil { // 仅当前NS切回成功,才解锁线程,否则 // 保持锁定,这会导致gouroutine结束了销毁OS线程 // 这种做法可能不是最优解,但是安全,不会出现NS混乱 runtime.UnlockOSThread() } }() // 调用真实的回调 return toRun(hostNS) } // 保存当前命名空间的句柄 hostNS, err := GetCurrentNS() if err != nil { return fmt.Errorf("Failed to open current namespace: %v", err) } // 总是关闭(在回调goroutine完毕后) defer hostNS.Close() // 用于等待回调goroutine完毕 var wg sync.WaitGroup wg.Add(1) // 关键之处:启用一个新的goroutine,在其中执行回调 // 这样做的原因是,当前goroutine不切换NS,保证了安全性 // 而新gorouinte会先切NS,再切回去,这过程万一失败,直接抛弃它的底层OS线程即可 var innerError error go func() { defer wg.Done() // 锁定OS线程,重要 runtime.LockOSThread() innerError = containedCall(hostNS) }() wg.Wait() return innerError } |
调用上述函数的例子:
1 2 3 4 5 6 |
err = networkNS.Do(func(ns.NetNS) error { var err error // 获取命名空间中的网络接口 lo, err = net.InterfaceByName("lo") return err }) |
1 2 3 4 5 |
func getCurrentThreadNetNSPath() string { // /proc/self/ns/net 返回的是主线程的命名空间 // 要获得当前goroutine的后备线程的命名空间,使用: return fmt.Sprintf("/proc/%d/task/%d/ns/net", os.Getpid(), unix.Gettid()) } |
下面的例子演示了如何创建一个不依赖于线程,可独立存在的网络命名空间:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
import "golang.org/x/sys/unix" // 获取网络命名空间目录 func getNsRunDir() string { xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR") // 如果XDG_RUNTIME_DIR被设置,检查是否当前用户是/var/run的所有者 // 如果不是,提示当前运行在一个User命名空间中 // 运行时目录应该取:$XDG_RUNTIME_DIR/netns if xdgRuntimeDir != "" { if s, err := os.Stat("/var/run"); err == nil { // 发起系统调用,获取文件信息 st, ok := s.Sys().(*syscall.Stat_t) if ok && int(st.Uid) != os.Geteuid() { return path.Join(xdgRuntimeDir, "netns") } } } return "/var/run/netns" } func NewNS() (ns.NetNS, error) { nsRunDir := getNsRunDir() // 随机目录名 b := make([]byte, 16) _, err := rand.Reader.Read(b) // 创建目录,如果它被挂载到了其它命名空间,则必须改为共享挂载点 err = os.MkdirAll(nsRunDir, 0755) // 重新挂载它,设置为共享挂载,MS_REC表示递归的处理子树中的挂载,都改为共享 // 如果它尚不是一个挂载点,则调用会失败 err = unix.Mount("", nsRunDir, "none", unix.MS_SHARED|unix.MS_REC, "") if err != nil { if err != unix.EINVAL { return nil, fmt.Errorf("mount --make-rshared %s failed: %q", nsRunDir, err) } // 重新Bind挂载到它自身,可以“升级”为挂载点,递归处理子树 err = unix.Mount(nsRunDir, nsRunDir, "none", unix.MS_BIND|unix.MS_REC, "") if err != nil { return nil, fmt.Errorf("mount --rbind %s %s failed: %q", nsRunDir, nsRunDir, err) } // 再次标记为共享挂载点 err = unix.Mount("", nsRunDir, "none", unix.MS_SHARED|unix.MS_REC, "") } nsName := fmt.Sprintf("cnitest-%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) // 在挂载点下创建一个空白文件,获取它的文件描述符 nsPath := path.Join(nsRunDir, nsName) mountPointFd, err := os.Create(nsPath) if err != nil { return nil, err } // 关闭文件描述符 mountPointFd.Close() // 确保在出错时挂载点被清理掉 // 如果命名空间已经成功挂载,则该调用没有任何作用,因为文件正在使用 defer os.RemoveAll(nsPath) var wg sync.WaitGroup wg.Add(1) // 在专门的进程中进行命名空间相关的工作 // 这样我们可以安全的Lock/Unlock OSThread而不会搞乱此函数调用者的lock/unlock状态 go (func() { defer wg.Done() // 将当前协程绑到当前所在的OS线程上,确保它总是在此宿主机线程上执行 // 在UnlockOSThread之前,其它协程不会在此OS线程上运行 // 如果协程退出前没有UnlockOSThread则OS线程被关闭 // 所有init函数在启动线程中运行,在其中调用LockOSThread会导致main函数在启动线程中执行 // 在调用依赖于per-thread状态的OS服务或非Go库之前,应当LockOSThread runtime.LockOSThread() // 这里不去解锁,确保协程结束时OS线程会被杀死(1.10+) var origNS ns.NetNS // 获取当前线程的网络命名空间路径 /proc/2816046/task/2816057/ns/net origNS, err = ns.GetNS(getCurrentThreadNetNSPath()) if err != nil { return } defer origNS.Close() // 在当前线程上创建新的网络命名空间 err = unix.Unshare(unix.CLONE_NEWNET) if err != nil { return } // 恢复原来的网络命名空间 defer origNS.Set() // 将当前线程的网络命名空间(/proc/..)绑定挂载到先前创建的挂载点上 // 这会持久化网络命名空间,即使其中没有线程了(当前Goroutine的底层线程马上就退出了) err = unix.Mount(getCurrentThreadNetNSPath(), nsPath, "none", unix.MS_BIND, "") if err != nil { err = fmt.Errorf("failed to bind mount ns at %s: %v", nsPath, err) } })() wg.Wait() if err != nil { return nil, fmt.Errorf("failed to create namespace: %v", err) } // 打开代表网络命名空间的文件描述符 return ns.GetNS(nsPath) } |
当不需要后,关闭文件描述符、解除挂载即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
networkNS.Close() testutils.UnmountNS(networkNS) func UnmountNS(ns ns.NetNS) error { nsPath := ns.Path() // 仅当它是被bind挂载时才umount,不去触碰/proc中的命名空间 if strings.HasPrefix(nsPath, getNsRunDir()) { if err := unix.Unmount(nsPath, 0); err != nil { return fmt.Errorf("failed to unmount NS: at %s: %v", nsPath, err) } if err := os.Remove(nsPath); err != nil { return fmt.Errorf("failed to remove ns path %s: %v", nsPath, err) } } return nil } |
Leave a Reply