要回答标题中的疑问,我们首先要清楚,Pod是什么?
Pod的翻译叫容器组,顾名思义,是一组容器。叫做“组”是因为这些容器:
从效果上看,容器组运行在一个虚拟的“主机”中。这个“主机”基于Linux命名空间、cgroups等机制和宿主机相互隔离。
虽说容器具有隔离性,但是这种隔离程度远远不如虚拟机,容器本质上就是进程。内核也提供了接口,允许你切换命名空间。只需要切换到宿主机的初始(Initial)命名空间,理论上就可以运行宿主机文件系统中的任何程序,并保证程序的行为正常。
在K8S的Pod的Spec中,和命名空间有关的编排配置包括:
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错误:
nsenter -m -t 1 nsenter: cannot open /proc/1/ns/mnt: Permission denied
这说明容器没有足够的权限进行操作。
Kubernets允许容器以特权模式运行,你只需要配置安全上下文即可。安全上下文包括Pod、Container两个级别。
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安全上下文中的对应设置。
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日志,可以用下面这种卷挂载的方式满足:
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函数:
/*
__attribute__((constructor)) void init() {
// 这里的代码会在Go运行时启动前执行
// 它会在单线程的C上下文中运行
}
*/
import "C"
libcontainer提供了基于此技巧的例子。
在Go语言中,你可以这样设置NS:
// 将当前线程的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)
}
// 在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
}
调用上述函数的例子:
err = networkNS.Do(func(ns.NetNS) error {
var err error
// 获取命名空间中的网络接口
lo, err = net.InterfaceByName("lo")
return err
})
func getCurrentThreadNetNSPath() string {
// /proc/self/ns/net 返回的是主线程的命名空间
// 要获得当前goroutine的后备线程的命名空间,使用:
return fmt.Sprintf("/proc/%d/task/%d/ns/net", os.Getpid(), unix.Gettid())
}
下面的例子演示了如何创建一个不依赖于线程,可独立存在的网络命名空间:
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)
}
当不需要后,关闭文件描述符、解除挂载即可:
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