自己动手写Docker

前面把Docker-Practice看完,基本了解了Docker的用法。还写了篇博客Docker的一些小实践,后来想深入了解Docker,买了浙大出的Docker容器与容器云,看起来很吃力,一直没能坚持看完。后来发现了自己动手写Docker这本书,从头开始教你写一个简易的Docker,非常适合进阶。看完后不仅对Docker有了更深的了解,还掌握了更多Linux相关的知识,收益很多。

基础技术

Namespace

命名空间 (namespaces) 是Linux内核的功能,它可以隔离一系列的系统资源。举个例子,我们可以开一个shell,在里面可以访问机器的任意文件,可以ps -ef查看所有的进程列表。而命名空间就相当于在机器里开辟了一台新机器给你用,你看不到宿主机器上别的信息。

  • MountNamespace:隔离挂载点视图,通俗点讲就是文件系统隔离,你在自己的容器里mount/unmount不会影响宿主机器。
  • UTS Namespace:用来隔离nodename,domainname两个系统标识。这样每个空间会有自己的hostname。
  • IPC Namespace:用来隔离系统的message queues。
  • PID Namespace:顾名思义,隔离进程ID的。ps -ef后就看不到宿主机器上别的进程ID了。
  • Network Namespace:顾名思义,隔离网络设备,IP地址端口等信息。
  • User Namespace:隔离用户的用户组ID,也就是uid在宿主机器和容器内是不一样的。
func main() {    
    cmd := exec.Command("sh")    
    cmd.SysProcAttr = &syscall.SysProcAttr{Cloneflags: syscall.CLONE_NEWUTS | 
    syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET |   
    syscall.CLONE_NEWIPC,}
    ...  
}

Cgroups

Cgroups提供了对一组进程及将来的子进程资源限制,包括CPU,内存,存储,网络等资源。这样就可以限制容器的资源占用,防止多个容器相互影响并且抢占资源。

Cgroups的三个组件:

  • cgroup:一个cgroup包含一组进程,一组进程和一组subsystem参数可以关联起来。
  • subsystem:一组资源控制的模块,比如CPU,内存占用等。
  • hierarchy:把一组cgroup串成一个树状结构,一个树便是一个hierarchy。
    三个组件相互协作,相互之间会有一些限制。具体就不讲了,主要目的都是为了让限制进程资源这个操作变的简单易用。
~ mkdir cgroup-test  //创建一个hierarchy挂载点  
~ sudo mount -t cgroup -o none,name=cgroup-test cgroup-test ./cgroup-test  //挂载一个hierarchy  
~ sudo mkdir cgroup-1 //创建子cgroup  
~ sudo mkdir cgroup-1 //创建子cgroup  
~ tree

cgroup
可以看到在一个cgroup的目录下创建文件夹时,内核会把文件夹标记为这个cgroup的子cgroup,它们会继承cgroup的属性。

接下去就是把pid移动到tasks里

~ echo $$  
5863
~ sudo sh -c "echo $$ >> tasks"  
~ cat /proc/5863/cgroup  

cgroup_pid

最后就是通过subsystem限制cgroup中进程的资源。也就是让hierarchy关联subsystem。
系统已经默认为每个subsystem创建一个默认的hierarchy。

~ pwd  
/sys/fs/cgroup  
~ ls  
blkio  cpu  cpuacct  cpuset  devices  freezer  hugetlb  memory  perf_event  systemd  

比如memory文件夹里可以mkdir一个新的cgroup,在memory.limit_in_bytes文件里添加限制,在tasks里添加需要限制的pid。

AUFS

AUFS是Docker选用的第一种存储驱动,全称是advanced multi-layered unification filesystem,主要功能是把多个文件夹的内容合并到一起,提供一个统一的视图。

想要了解AUFS,只要实际操作一下就行了。Linux文件系统之aufs,推荐这个教程,照着敲一遍命令就行。

使用AUFS时,建议参考livecd及docker的使用方式,就是将所有的目录都以只读的方式和一个支持读写的空目录联合起来,这样所有的修改都会存到那个指定的空目录中,不用之后删除掉那个目录就可以了,并且在使用的过程中不要绕过aufs直接操作底层的branch,也不要动态的增加和删除branch,如果把使用场景弄得太复杂,由于aufs里面的细节很多,很有可能会由于对aufs的理解不深而踩坑。

除了AUFS之外,Docker 还支持了不同的存储驱动,在最新的Docker中,overlay2取代了AUFS成为了推荐的存储驱动,但是在没有overlay2驱动的机器上仍然会使用AUFS作为Docker的默认驱动。

Linux

整个Docker用了很多系统调用,核心的用法都是和Linux有关的。下面就举几个Docker里用到的关于Linux的知识。

进程

进程是Linux以及现在操作系统中非常重要的概念,它表示一个正在执行的程序,也是在现代分时系统中的一个任务单元。
ps -ef
all_pid
当前机器上有很多的进程正在执行,在上述进程中有两个非常特殊,一个是pid为1的/sbin/init进程,另一个是pid为2的kthreadd 进程,这两个进程都是被Linux中的上帝进程idle创建出来的。其中前者负责执行内核的一部分初始化工作和系统配置,而后者负责管理和调度其他的内核进程。

为什么容器能在后台运行呢?

容器在操作系统看来就是一个进程。容器是被当前mydocker进程fork出来的子进程,父进程永远不知道子进程什么时候结束。所以当父进程退出后,子进程就成了孤儿进程,这个时候pid为1的进程init就会接手这些孤儿进程,成为他们的父进程。正因为如此,容器才能在后台运行。

挂载点

命令格式:mount [-t vfstype] [-o options] device dir

-t:挂载设备的系统类型,比如windows上常见的ntfs,linux上常见的ext2,mac上常见的HFS+。通常不必指定,mount会自动选择正确的类型。
-o:描述设备或档案的挂接方式。比如只读方式挂接设备的ro,读写方式挂接设备的rw。

所以mount挂载的作用,通俗点讲就是将一个设备(通常是存储设备)挂接到一个已存在的目录上。访问这个目录就是访问该存储设备。

上面的都很好理解,很符合平时的认知。不过还有一种伪文件系统(也即虚拟文件系统)。比如proc,sysfs,tmpfs。一个正常运行的linux系统,都会在rootfs中挂载以上的几种文件系统。

在Docker用到mount的地方非常多,举2个例子:

  1. 当容器退出时,容器可写层的所有内容都会被删除。那么用户需要持久化容器里的部分数据应该怎么办?

这个时候就用到mount了,只要将宿主机器的目录作为数据卷挂载到容器中,在删除整个容器系统挂载点前,先把宿主机器的目录作为的数据卷卸载掉就行。

  1. 怎么保证当前的容器进程没有办法访问宿主机器上其他目录?
    这个时候就用到pivot_root或者chroot了,它可以改变进程能够访问个文件目录的根节点。
// pivor_root
put_old = mkdir(...);
pivot_root(rootfs, put_old);
chdir("/");
unmount(put_old, MS_DETACH);
rmdir(put_old);

// chroot
mount(rootfs, "/", NULL, MS_MOVE, NULL);
chroot(".");
chdir("/");

区别就是:chroot只是对一个进程生效,但是pivor_root,会unmount原来的root。

网络

每一个使用docker run启动的容器其实都具有单独的网络命名空间,Docker为我们提供了四种不同的网络模式,Host、Container、None 和 Bridge 模式。
其中Docker默认的网络设置模式是网桥模式。

网桥设备作为一个虚拟设备,用于连接多个端口,可以构建一个局域网。而Docker在主机上启动之后,会创建虚拟网桥 docker0。

一个个容器就像是一个个独立设备一样连在 docker0 上,连接的方式是虚拟网卡:

每一个容器在创建时都会创建一对虚拟网卡,两个虚拟网卡组成了数据的通道,其中一个会放在创建的容器中,另一个会加入到 docker0 网桥中。

docker_network_1

docker0 会为每一个容器分配一个新的 IP 地址并将 docker0 的 IP 地址设置为默认的网关。网桥 docker0 通过 iptables 中的配置与宿主机器上的网卡相连,所有符合条件的请求都会通过 iptables 转发到 docker0 并由网桥分发给对应的机器。

docker_network_2

iptables中的配置一般都是宿主机器的端口和容器ip地址的映射。举个例子:
docker run -d -p 6379:6379 redis
这个时候iptables的配置里就会看到一条新的规则:
DNAT tcp -- anywhere anywhere tcp dpt:6379 to:192.168.0.4:6379
上述规则会将从任意源发送到当前机器 6379 端口的 TCP 包转发到 192.168.0.4:6379 所在的地址上。

下面就是整个的流程图:

docker_network_3

感想

Docker代码库是非常非常庞大的,但是看完这本书,已经能实现一个很简单的Docker了。Docker也就没有那么神秘了,其中收获最多的还是Linux操作系统相关的知识......

Reference

作者:levi
comments powered by Disqus