namespace是linux提供的一种操作系统级别的资源隔离。

命名空间是被不断完善加入到linux内核当中的,这里介绍几个常用的namespace(runc里会多一个Cgroup namespace)

  • UTS(CLONE_NEWUTS):主要用于hostname的隔离
  • User(CLONE_NEWUSER):User ID和group IDs的隔离
  • PID(CLONE_NEWPID):进程id的隔离
  • Mount(CLONE_NEWNS):挂载点的隔离
  • Network(CLONE_NEWNET):网络设备、栈、端口等信息的隔离
  • IPC(CLONE_NEWIPC):系统进程间通信的一些资源(System V IPC、 POSIX message queues )

go中通过设置cmd的SysProcAttr参数,即可设置进程的namespaces。

通过这种方式我们也就可以创建一个命名空间隔离的进程,但是这里和docker的容器还是有一些差距,比如pid为1的进程不是本身,以及进程文件系统等信息。

以下以mydocker同时也是runc中做法为例。

/proc/self/exe
/proc/dev
syscall.Exec

挂载procfs让进程只能看到当前pid namespace的进程信息

/dev
syscall.Exec

Cgroup

Control groups,控制组,也是linux内核级别的资源控制,这里的资源指的是内存、cpu核心数、cpu时间分片等资源。

cgroup是一个层级的树状结构,linux以虚拟文件系统的方式对起进行展示。

cat /proc/self/mountinfo | grep memory/sys/fs/cgroup/

这里的memory就是一个cgroup的subsystem,默认有如下几个subsystem

  • devices
  • memory
  • cpu
  • cpuacct
  • cpuset
  • blkio
  • perf_event
  • freezer
  • hugetlb
  • pids
/sys/fs/cgroup/memory/docker/{container-id}/memory.limit_in_bytes/sys/fs/cgroup/memory/docker/{container-id}/tasks

tasks中的所有进程都会被限制成100m内存大小的限制。

因此在之前的init进程创建后,在对应的cgroup中设置对应的资源限制,以及当前进程的pid,即完成了容器的cgroup资源限制。

Image

其实通过docker pull下来的image都是一个个完整的文件系统,通过如下方式我们就可以获取一个busybox的镜像

这个busybox.tar中就是一个简易且完整的文件系统,只要将当前的rootfs切换到这个文件系统中,容器则有了自己的rootfs。

pivot_root

通过如下方式就可以将我们的rootfs挂载到指定的文件系统中(例如busybox解压后的目录),容器也就有了自己独立的文件系统。

pivot_root

pivot_root

UnionFS

即联合文件系统,通俗来讲就是将几个只读目录和一个可写目录联合挂载到同一个目录,在联合挂载的目录上呈现的文件是那几个只读和一个可写目录的“叠加”状态。

linux有多种联合文件系统驱动,docker最初默认采用的是aufs,后续更改为overlay2,当然还支持一些例如 devicemapper 、 zfs 、vfs之类的文件驱动。

以aufs为例,通过如下方式即可将busybox目录作为只读目录,writeLayer做为可写目录挂载到mnt目录中。

此时,在mnt上对文件进行修改操作时,会将修改后的内容拷贝一份到writeLayer目录中。

.wh.{filename}

从而保证了容器上对文件的操作不会影响到镜像目录,而是只将增量保存在了writerLayer目录中,当容器被删除时,只要删除对应的writeLayer目录即可。

docker中这三个目录也分别有如下对应关系,只是通常一个容器镜像可能由多个layers只读层联合而成。

/var/lib/docker/aufs/mnt
/var/lib/docker/aufs/layers
/var/lib/docker/aufs/diff

顺带说一下volume数据卷,其实所谓数据卷其实也就是新生成了一个目录然后被联合挂载到了mnt中的指定目录,只是这个目录到时候会被保留下来。

Network

到目前为止,其实只差给容器“插上网线”,一个容器的基础功能其实就大差不离了。

这里介绍一下docker默认的桥接模式网络,主要会涉及veth、bridge、iptables这些概念。

首先需要创建一个bridge网桥

然后设置这个bridge的ip(192.168.0.1),以及其路由表(192.168.0.0/24)

192.168.0.0/24
ifconfig testbridge

iptables -t nat -vnL POSTROUTING

目前为止网桥设备已经准备好了,接下来就是为容器安上网卡

这里需要创建一对veth,一端连至bridge

另一端配置成container的veth(虽然现在还没给容器安上),即容器的网卡

然后就是把另一端的veth设备放入容器的network namespace中,由于容器的network namespace是隔离的,因此启用veth的话需要先进入对应的命名空间。

runtime.LockOSThread()

进入容器的网络命名空间之后,就可以启用这张网卡,以及loopback本地环回网卡。最后设置路由表将本地所有流量都指向这个veth的网络设备。

目前为止,容器就拥有了和宿主机、bridge网段内容器、以及外网通信的能力。

最后就是端口映射,我们只需要在本地的iptables中设置,将本地的80端口的tcp流量都转发到容器的80端口,即完成了本地端口和容器端口的映射。

此时监听容器中的80端口,即可就接收到发往宿主机80端口上的数据。

其他一些功能

daemon

StartWait

但自己跑的时候 有个小坑,就是在goland中运行时,父进程退出了子进程也会跟着一起退出,换成在terminal中运行即可了。

exec

当容器进程以daemon方式运行时,如何再进入到容器内部呢。

通过setns这个系统调用就可以将当前线程放入到对应的namespace。

__attribute__((constructor))

然后就是在进入init进程前在环境变量中设置container的pid以及要exec的命令等信息。

__attribute__((constructor))setns(fd(/proc/container_pid/ns/*))
go build-gcflags all=-N

stop

SIGTERM

pipe

runc中的要执行的系统命令参数不是直接通过传参的方式传入init进程然后运行的,而是创建了一个pipe。

把这个writepipe保留在主进程中,启动init进程之后在往writepipe中写入要传入的命令。

而readpipe则通过如下方式设置为init进程文件描述符为3的文件(0、1、2分别已被标准输入、标准输出、错误输出预定)

然后init进程在执行完挂载的操作之后,再读取当前文件描述符为3的文件中的命令参数信息并执行。