runc cgroup CreateLibcontainerConfig & linuxContainer
// needsSetupDev returns true if /dev needs to be set up. func needsSetupDev(config *configs.Config) bool { for _, m := range config.Mounts { if m.Device == "bind" && libcontainerUtils.CleanPath(m.Destination) == "/dev" { return false } } return true } // prepareRootfs sets up the devices, mount points, and filesystems for use // inside a new mount namespace. It doesn't set anything as ro. You must call // finalizeRootfs after this function to finish setting up the rootfs. func prepareRootfs(pipe io.ReadWriter, iConfig *initConfig) (err error) { config := iConfig.Config if err := prepareRoot(config); err != nil { return newSystemErrorWithCause(err, "preparing rootfs") } hasCgroupns := config.Namespaces.Contains(configs.NEWCGROUP) setupDev := needsSetupDev(config) for _, m := range config.Mounts { for _, precmd := range m.PremountCmds { if err := mountCmd(precmd); err != nil { return newSystemErrorWithCause(err, "running premount command") } } if err := mountToRootfs(m, config.Rootfs, config.MountLabel, hasCgroupns); err != nil { return newSystemErrorWithCausef(err, "mounting %q to rootfs at %q", m.Source, m.Destination) } for _, postcmd := range m.PostmountCmds { if err := mountCmd(postcmd); err != nil { return newSystemErrorWithCause(err, "running postmount command") } }
kata agent
func (a *agentGRPC) CreateContainer(ctx context.Context, req *pb.CreateContainerRequest) (resp *gpb.Empty, err error) { if err := a.createContainerChecks(req); err != nil { return emptyResp, err } // Convert the OCI specification into a libcontainer configuration. config, err := specconv.CreateLibcontainerConfig(&specconv.CreateOpts{ CgroupName: req.ContainerId, NoNewKeyring: true, Spec: ociSpec, NoPivotRoot: a.sandbox.noPivotRoot, }) if err != nil { return emptyResp, err } // apply rlimits config.Rlimits = posixRlimitsToRlimits(ociSpec.Process.Rlimits) // Update libcontainer configuration for specific cases not handled // by the specconv converter. if err = a.updateContainerConfig(ociSpec, config, ctr); err != nil { return emptyResp, err } return a.finishCreateContainer(ctr, req, config) }
首先调用container, err := createContainer(context, id, spec)创建容器, 之后填充runner结构r。
func createContainer(context *cli.Context, id string, spec *specs.Spec) (libcontainer.Container, error) { rootless, err := isRootless(context) if err != nil { return nil, err } config, err := specconv.CreateLibcontainerConfig(&specconv.CreateOpts{ CgroupName: id, UseSystemdCgroup: context.GlobalBool("systemd-cgroup"), NoPivotRoot: context.Bool("no-pivot"), NoNewKeyring: context.Bool("no-new-keyring"), Spec: spec, Rootless: rootless, }) if err != nil { return nil, err } factory, err := loadFactory(context) if err != nil { return nil, err } return factory.Create(id, config) }
注意factory, err := loadFactory(context)和factory.Create(id, config),这两个就是我们上面提到的factory.go。由工厂来根据配置config创建具体容器。
package main import ( "fmt" "io/ioutil" "os" "os/exec" "path" "strconv" "syscall" ) // 挂载了memory subsystem的hierarchy的根目录位置 const cgroupMemoryHierarchyMount = "/sys/fs/cgroup/memory" func main() { if os.Args[0] == "/proc/self/exe" { // 容器进程 fmt.Printf("current pid %d\n", syscall.Getpid()) cmd := exec.Command("sh", "-c", `stress --vm-bytes 200m --vm-keep -m 1`) cmd.SysProcAttr = &syscall.SysProcAttr{} cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { fmt.Println(err) os.Exit(1) } } cmd := exec.Command("/proc/self/exe") cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS, } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { fmt.Println("Error", err) os.Exit(1) } else { // 得到fork出来进程映射在外部命名空间的pid fmt.Printf("%v\n", cmd.Process.Pid) // 在系统默认创建挂载了 memory subsystem 的hierarchy上创建cgroup os.Mkdir(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit"), 0755) // 将容器进程加入到这个cgroup中 ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit", "tasks"), []byte(strconv.Itoa(cmd.Process.Pid)), 0644) // 限制cgroup进程使用 ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit", "memory.limit_in_bytes"), []byte("100m"), 0644) cmd.Process.Wait() } }
func (m *Manager) Apply(pid int) (err error) { if m.Cgroups == nil { // 全局 cgroup 配置是否存在检测 return nil } //... var c = m.Cgroups d, err := getCgroupData(m.Cgroups, pid) // +获取与构建 cgroupData 对象 //... m.Paths = make(map[string]string) // 如果全局配置存在 cgroup paths 配置, if c.Paths != nil { for name, path := range c.Paths { _, err := d.path(name) // 查找子系统的 cgroup path 是否存在 if err != nil { if cgroups.IsNotFound(err) { continue } return err } m.Paths[name] = path } return cgroups.EnterPid(m.Paths, pid) // 将 pid 写入子系统的 cgroup.procs 文件 } // 遍历所有 cgroup 子系统,将配置应用 cgroup 资源限制 for _, sys := range subsystems { p, err := d.path(sys.Name()) // 查找子系统的 cgroup path if err != nil { //... return err } m.Paths[sys.Name()] = p if err := sys.Apply(d); err != nil { // 各子系统 apply() 方法调用 //... } return nil }
Namespaces
Linux内核实现了namespace,进而实现了轻量级虚拟化服务,在同一个namespace下的进程可以感知彼此的变化,但是不能看到其他的进程,从而达到了环境隔离的目的。namespace有6项隔离,分别是UTS(Unix Time-sharing System, 主机和域名), IPC(InterProcess Comms, 信号量、消息队列和共享内存), PID(Process IDs, 进程编号), Network(网络设备,网络栈,端口等), Mount(挂载点[文件系统]), User(用户和用户组)。
C语言中可以通过clone()
指定flags
参数,在创建进程的同时创建namespace。Linux内核版本3.8之后的用户可以通过ls -l /proc/?/ns
查看当前进程指向的namespace编号。(?
表示当前运行的进程ID号)
UTS
先创建一个UTS隔离的新进程,这里使用了 Sirupsen的logrus库,可以通过go get github.com/sirupsen/logrus
获取
package main
import (
"os"
"os/exec"
"syscall"
"github.com/sirupsen/logrus"
)
func main() {
if len(os.Args) < 2 {
logrus.Errorf("missing commands")
return
}
switch os.Args[1] {
case "run":
run()
default:
logrus.Errorf("wrong command")
return
}
}
func run() {
logrus.Infof("Running %v", os.Args[2:])
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
check(cmd.Run())
}
func check(err error) {
if err != nil {
logrus.Errorln(err)
}
}
在 Linux 环境下执行
$ go run main.go run sh
INFO[0000] Running [sh]
root@ubuntu-14:~/shared#
此时在一个新的进程中执行了sh
命令,由于指定了flag syscall.CLONE_NEWUTS
, 此时已经与之前的进程不在同一个UTS namespace中了。在新sh和原sh中分别执行ls -l /proc/?/ns
进行验证
原sh:
$ ls -l /proc/?/ns
total 0
lrwxrwxrwx 1 root root 0 Sep 2 16:26 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Sep 2 16:26 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 root root 0 Sep 2 16:26 mnt -> mnt:[4026531840]
lrwxrwxrwx 1 root root 0 Sep 2 16:26 net -> net:[4026531957]
lrwxrwxrwx 1 root root 0 Sep 2 16:26 pid -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 Sep 2 16:26 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Sep 2 16:26 uts -> uts:[4026531838]
新sh:
root@ubuntu-14:~/shared# ls -l /proc/?/ns
total 0
lrwxrwxrwx 1 root root 0 Sep 2 16:26 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Sep 2 16:26 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 root root 0 Sep 2 16:26 mnt -> mnt:[4026531840]
lrwxrwxrwx 1 root root 0 Sep 2 16:26 net -> net:[4026531957]
lrwxrwxrwx 1 root root 0 Sep 2 16:26 pid -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 Sep 2 16:26 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Sep 2 16:26 uts -> uts:[4026532197]
可以看到这里两个只有uts所指向的ID不同,因为之前只指定UTS的隔离。在新sh中执行hostname newhost
更改当前的hostname, 可以看到这里的hostname已经被改成了newhost, 但是原来的sh中依然是ubuntu-14, 同样证明UTS隔离成功了。
为了在启动sh的同时就能够将其hostname修改为新的hostname,下面将run()
函数拆分成run()
和child()
。将这个过程分成创建新的namespace和修改hostname两步,这样就可以保证修改namespace的时候已经在新的namespace中了,避免修改主机的hostname。这里的/proc/self/exe
就是当前正在执行的命令,在这里就是go run main.go
func run() {
logrus.Info("Setting up...")
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
check(cmd.Run())
}
func child() {
logrus.Infof("Running %v", os.Args[2:])
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
check(syscall.Sethostname([]byte("newhost")))
check(cmd.Run())
}
然后对main()
函数进行相应的修改
func main() {
if len(os.Args) < 2 {
logrus.Errorf("missing commands")
return
}
switch os.Args[1] {
case "run":
run()
case "child":
child()
default:
logrus.Errorf("wrong command")
return
}
}
再次执行命令可以看到进入时hostname已经是newhost了
$ go run main.go run sh
INFO[0000] Setting up...
INFO[0000] Running [sh]
root@newhost:~/shared#
PID
为了进行PID的隔离将run()
函数中cmd.SysProcAttr
修改为
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID,
此时再次运行,并执行ps
查看当前进程,发现和主机上一样,并没有被隔离。这是因为ps
总是查看/proc
,如果要进行隔离,则需要修改根目录root。
下面获取一个unix文件系统,可以选择docker的busybox镜像,并将其导出。
docker pull busybox
docker run -d busybox top -b
此时获得刚刚的容器的containerID,然后执行
docekr export -o busybox.tar <刚才容器的ID>
即可在当前目录下得到一个busybox的压缩包,用
mkdir busybox
tar -xf busybox.tar -C busybox/
解压即可得到我们需要的文件系统
查看一下busybox目录
$ ls busybox
bin dev etc home proc root sys tmp usr var
接下来通过syscall.Chroot()
将root修改为busybox的目录,然后在进入shell之后通过os.Chdir()
切换到新的根目录下,然后通过syscall.Mount("proc", "proc", "proc", 0, "")
挂载虚拟文件系统proc
(proc
是一个伪文件系统,只存在于内存中,以文件系统的方式为访问系统内核数据的操作提供接口,/proc
目录下的文件记录了正在运行的进程的相关信息), 运行结束之后还要卸载刚才挂载的proc
修改之后的代码
func child() {
...
check(syscall.Sethostname([]byte("newhost")))
check(syscall.Chroot("/root/busybox"))
check(os.Chdir("/"))
// func Mount(source string, target string, fstype string, flags uintptr, data string) (err error)
// 前三个参数分别是文件系统的名字,挂载到的路径,文件系统的类型
check(syscall.Mount("proc", "proc", "proc", 0, ""))
check(cmd.Run())
check(syscall.Unmount("proc", 0))
}
修改之后再次执行,并使用ps
查看当前namespace下进程的情况,得到了期望的状态
go run test.go run sh
INFO[0000] Setting up...
INFO[0000] Running [sh]
/ # ps
PID USER TIME COMMAND
1 root 0:00 /proc/self/exe child sh
4 root 0:00 sh
5 root 0:00 ps
/ #
在child()
中再挂载一个tmpfs
,将代码改为
...
check(syscall.Mount("proc", "proc", "proc", 0, ""))
check(syscall.Mount("tempdir", "temp", "tmpfs", 0, ""))
check(cmd.Run())
check(syscall.Unmount("proc", 0))
check(syscall.Unmount("temp", 0))
执行go run main.go run sh
后使用mount
查看已挂载的文件系统
/ # mount
proc on /proc type proc (rw,relatime)
tempdir on /temp type tmpfs (rw,relatime)
继续执行touch /temp/HELLO
在temp
目录下创建一个文件。然后在主机中执行ls /root/busybox/temp
可以看到刚刚创建的文件。这是因为现在还没有添加挂载点的隔离。
将Cloneflags
更新为Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
再次重复上面的步骤,主机中将不能再看到容器内创建的文件。这里mount point的隔离所使用的flag是CLONE_NEWNS
,因为它是Linux实现的第一个namespace, 人们也没有意识到将来会有更多的namespace。
此时在主机上再调用mount
也不能看到容器中的挂载情况,但是可以通过/proc/<pid>/mounts
这个文件查看。
在容器中执行sleep 1000
创建一个耗时1000秒的进程。然后在主机上通过pidof sleep
获取这个进程的pid,接下来查看这个进程的挂载情况。
$ pidof sleep
4286
$ cat /proc/4286/mounts
proc /proc proc rw,relatime 0 0
tempdir /temp tmpfs rw,relatime 0 0
/proc/<pid>/
下的文件还记录了这个进程的其他信息,比如/proc/<pid>/environ
记录了它的环境变量,可以通过cat /proc/<pid>/environ | tr '\n' '\0'
查看,tr '\n' '\0'
去掉字符间多余的空格。
Cgroups
cgroups可以用于限制namespace隔离起来的资源,为资源设置权重,计算使用量,操控任务启停
Cgroups组件
- cgroup: cgroup是对进程分组管理的一种机制,一个cgroup包含一组进程,并可以在这个cgroup上增加Subsystem的配置
- Subsystem: 资源控制的模块,包括
- blkio: 块设备io控制
- cpu:CPU调度策略
- cpuacct: 进程的CPU占用
- cpuset: 进程可使用的CPU和内存
- devices: 控制进程对内存的访问
- freezer: 挂起和恢复进程
- memory: 控制进程的内存占用
- net_cls: 将网络包分类,使traffic controller可以区分出网络包来自哪个cgroup并做限流和监控
- net_prio: 设置进程产生的网络流量的优先级
- ns:使cgroup中的进程在新的namespace中fork新进程时创建出一个新的cgroup(包含新的namespace中的进程)
- hierarchy: 将一组cgroup变成树状结构,便于Cgroups继承。
资源限制
可以通过mount | grep cgroup
查看已挂载的subsystem。cgroup相关的文件在/sys/fs/cgroup
下,如果使用了docker的话在这个目录下还会有一个docker
目录,其中是docker的cgroup的相关文件
定义一个新的函数cg()
, 限制容器的最大进程数
func cg() {
cgPath := "/sys/fs/cgroup/"
pidsPath := filepath.Join(cgPath, "pids")
// 在/sys/fs/cgroup/pids下创建container目录
os.Mkdir(filepath.Join(pidsPath, "container"), 0755)
// 设置最大进程数目为20
check(ioutil.WriteFile(filepath.Join(pidsPath, "container/pids.max"), []byte("20"), 0700))
// 将notify_on_release值设为1,当cgroup不再包含任何任务的时候将执行release_agent的内容
check(ioutil.WriteFile(filepath.Join(pidsPath, "container/notify_on_release"), []byte("1"), 0700))
// 加入当前正在执行的进程
check(ioutil.WriteFile(filepath.Join(pidsPath, "container/pids.procs"), []byte(strconv.Itoa(os.Getpid())), 0700))
}
在child()
函数中调用cg()
进行资源限制
func child() {
...
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cg()
cmd.Stdin = os.Stdin
...
}
运行go run main.go run sh
后在主机中的/sys/fs/cgroup/pids/container
下可以看到刚刚进行的限制的内容。
编写一个脚本进行测试。这里将创建100个执行sleep
的进程
d() { sleep 1000; }
for i in $(seq 1 100)
do
echo "sleep $i\n"
d&
done
下面在容器中执行这个脚本test.sh
/ # sh test.sh
sleep 1\n
sleep 2\n
sleep 3\n
sleep 4\n
sleep 5\n
sleep 6\n
sleep 7\n
sleep 8\n
sleep 9\n
sleep 10\n
sleep 11\n
sleep 12\n
sleep 13\n
sleep 14\n
sleep 15\n
test.sh: line 7: can't fork
/ # test.shtest.shtest.shtest.shtest.shtest.shtest.shtest.shtest.sh: : : : : : line line line line : line : line 7777line 7line 7: : : : 7: : 7: can't forkline : can't forkcan't fork: can't forkcan't forkcan't forkcan't fork
7can't fork
:
can't fork
test.sh: line 7: can't fork
test.sh: line 7: can't fork
test.sh: line 7: can't fork
test.sh: line 7: can't fork
test.sh: line 7: can't fork
可以看到在执行过程中只调用了15次sleep
就被不能继续执行了