杭州滨江网站建设公司网站模板库官网
前言
Linux容器的本质,是一个被限制和隔离的进程。它通过Linux内核提供的命名空间(Namespaces)实现资源隔离,通过控制组(Cgroups)实现资源限制,通过联合文件系统(UnionFS)实现轻量化的镜像分层。与传统虚拟机不同,容器共享宿主机内核,仅通过内核特性隔离进程视图(如PID、网络、用户等命名空间),因此启动更快、开销更小。典型实现如 Docker,实质是通过 runC 等运行时工具调用内核 API,将应用及其依赖打包为可移植的标准化单元。
本文基于 Linux 内核 API 实现一个简易版容器,通过 shell 脚本的开发方式。
容器三板斧
命名空间(Namespaces)—— 实现资源隔离
Namespaces 是 Linux 内核提供的一种资源隔离机制,它使得进程拥有独立的视图,让进程“看起来”自己拥有独立的 网络栈、文件系统挂载、主机名域名、用户和 PID 等资源。
这种隔离有什么用呢?
举个例子,你通过容器部署多个后端服务,服务均占用 8080 端口,如果容器没有独立的网络栈,容器服务的端口之间就会冲突,这显然不是容器应该出现的问题。
再举个例子,在宿主机上,你只有普通用户的权限,但你想在容器里以 root 用户折腾一下,这完全是可以的。在容器里,你甚至可以有单独的文件系统,随意折腾都不会影响到宿主机。
控制组(Cgroups)—— 实现资源限制
Namespaces 更像是逻辑上的隔离,容器本质上和宿主机上其它进程没有任何区别。这就会带来一个问题,如果某个容器失控,耗尽了所有的内存、CPU、IO 资源,就会导致宿主机上的其它进程或容器“饿死”,甚至宿主机本身崩溃,这肯定不是我们想看到的。
所以 Linux 内核提供了 Cgroups 来限制进程对资源的占用限制。
举个例子,在一台 16c 64g 的机器上通过容器部署服务,限制每个容器最多占用 2c 4g,这样即使某个容器失控,它最多也只会占用 2c 4g 的资源,影响不会很大。
根文件系统(Rootfs)—— 独立的文件系统
要想让容器隔离的更彻底,容器应该具备一个独立的根文件系统。在这个根文件系统里,具备容器服务运行的所有依赖和三方库,这就避免因为运行环境不同而带来各种各样的问题。
举个例子,我开发的一个服务应该运行在 centos 上,同时要求其已经安装了 Tomcat 和 JDK。那么在启动这个容器时,宿主机就应该为容器准备好这么一个根文件系统,在这个根文件系统里,已经装好了 Tomcat 和 JDK,同时具备 centos 的基础依赖和库。最后把容器进程的根文件系统,切到这个下面即可。
实现简易容器
有了上述前置知识,实现一个简易容器还是很容易的。为了方便,这里采用 shell 脚本的方式。
要实现一个简易版容器,首先你要有一台 Linux 机器,然后它必须支持 Namespaces 和 Cgroups ,同时你还要准备一个镜像文件,也就是容器的根文件系统。
如下所示,在/pocker
目录下有2个文件
- image.tar 镜像文件,解压后是一个基础的Ubuntu文件系统,同时它里面安装了 nginx
- pocker 实现容器的脚本文件(参考 docker 命名方式)
pocker 脚本代码如下所示:
- 定义了容器运行的基础工作目录、和 cgroup 的目录
- image_file 是容器镜像文件,通过命令行参数指定
- container_name 容器的名字,随机8位字符
- container_path 容器的路径,在工作目录下,以容器名创建的目录
- container_rootfs_path 容器根文件系统路径,镜像文件会解压到这里
- cgroup_path 容器关联的 cgroup 路径,容器启动会创建这个目录,然后配置 CPU、内存的限制
- netns_name 容器网络命名空间的名称,"netns_"加上容器名
- 变量赋值后,解压镜像文件到容器根文件系统
- 创建容器关联的 cgroup 目录,配置 CPU、内存的限制
- 创建 veth peer,可以把它看作是一根网线,一端 veth0 插在容器上,一端 veth1 插在宿主机上,宿主机通过 veth1 访问容器内的 nginx 服务
- 调用 unshare 让容器进程拥有独立的命名空间
- chroot 切换容器进程的根目录到容器的根文件系统
- 最后,挂载 proc,修改 hostname,启动nginx,再启动一个 bash 方便和容器交互
- 最后容器进程推出,释放相关资源
#!/bin/bash
base_path='/var/pocker'
cgroup_base_path="/sys/fs/cgroup"
image_file=''
container_name=''run(){image_file="$1"container_name="$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 8)"container_path="$base_path/$container_name"container_rootfs_path="$base_path/$container_name/rootfs"cgroup_path="$cgroup_base_path/$container_name"netns_name="netns_$container_name"print_initmkdir -p "$container_rootfs_path"tar -xf "$image_file" -C "$container_rootfs_path"printf "The image file has been successfully decompressed."echo $cmd > "$container_path/init.cmd"mkdir -p $cgroup_pathecho '20000 100000' > $cgroup_path/cpu.maxecho '256M' > $cgroup_path/memory.maxecho $$ > $cgroup_path/cgroup.procsprintf "cgroup configuration successful.\n"veth0="veth0-$container_name"veth1="veth1-$container_name"sudo ip netns add $netns_namesudo ip link add $veth0 type veth peer name $veth1sudo ip link set $veth0 netns $netns_namesudo ip netns exec $netns_name ip link set $veth0 name eth0sudo ip netns exec $netns_name ip addr add 10.1.1.1/24 dev eth0sudo ip netns exec $netns_name ip link set eth0 upsudo ip addr add 10.1.1.2/24 dev $veth1sudo ip link set $veth1 upprintf "Network configuration successful.\n"sudo ip netns exec $netns_name \unshare -fmuip \chroot $container_rootfs_path bash -c "mount -t proc proc /proc && hostname $container_name && mknod -m 666 /dev/null c 1 3 && /sbin/nginx && bash"sudo ip netns delete $netns_namerm -rf $container_pathprintf 'quit...\n'
}print_init(){printf "prepare to start the container: $container_name \n"printf "container image: $image_file \n"printf "container path: $container_path \n"
}case "$1" inrun)shiftrun "$@";;*)echo "Unknown command: $1";;
esac
启动容器
在启动容器前,先看下宿主机的网络设备,发现是没有 veth 设备的。
接着,在/pocker
目录下,执行下述命令,即可启动一个部署了nginx的容器
$ ./pocker run image.tar
如上所示,没有报错,就启动成功了。
现在当前bash终端是运行在容器内的,同时可以看到,hostname 已经是容器名1h0MuJlK
了。 当前bash是容器内的1号进程,容器进程PID是和宿主机隔离的。
容器的网络栈也是和宿主机隔离的,只有一个eth0,它其实是我配置的veth0,只不过被我改名成 eth0了。
容器内 nginx 也已经部署成功了
先别着急访问服务,先看下 cgroup 是否生效。在脚本里,我们对 CPU 的配置是20000 100000
,也就是在 100000 微秒内,允许容器占用 20000 微秒的 CPU,即允许容器最多使用 1/5 颗CPU核心。
在容器内执行while : ; do : ; done
死循环耗光CPU,另开一个bash,top 命令查看,发现容器进程占用了单颗CPU核心的20%,cgroup 确实生效了,再也不担心单个容器把资源耗光了。
接下来,在宿主机上开一个bash。为了体现“容器的本质就是进程”这句话,可以看到,容器内运行的nginx,在宿主机上就是一个普通的进程,和其它进程并无二致。
事实上,nginx 的实际进程PID=51256,但是在容器看来,它的进程PID=6,这就是 Namespace 视图的“障眼法”在起作用。
最后,我们访问容器服务。因为容器服务绑定的端口是在自己独立的网络栈里面的,也就是 veth0,要访问它必须通过 veth1,因为它俩是相互连接的。
在宿主机上,也能看到 veth 设备了,它的IP是 10.1.1.2,对端 veth0 的 IP 是 10.1.1.1。
因为nginx默认绑定的是80端口,要想访问容器nginx服务,直接通过10.1.1.1:80
访问即可。
容器退出后,相关的资源都会被释放掉。
至此,容器启动演示完毕。
尾巴
容器的本质是进程,只不过它是被 Namespaces 视图隔离,同时被 cgroups 资源限制的进程,它拥有自己的根文件系统 Rootfs,根文件系统包含容器运行的除内核外所有依赖的文件,这些文件打包以后就是容器镜像。
通过 shell 脚本,利用 Linux 内核特性可以很轻松实现一个简易版的容器,本文重点是帮助大家理解容器是怎么一回事儿,这种方式实现的容器远不到可用的程度。
不过话说回来,容器本身没有什么价值,有价值的是容器编排。