"docker基础技术整理"

  "自己动手写docker"

Posted by Xu on April 27, 2018

Docker容器技术整理

容器和虚拟机的比较:

容器:便携,高效,共享内核,用户态运行,Docker容器不和任何基础设施绑定 虚拟机:资源静态分配,hypervisor统一调度管理

使用Go语言的感受:https://blog.csdn.net/qq_21898173/article/details/52671930 * 部署简单 * 具有良好的并发性 * 哲学艺术性的语言设计 * 良好的执行性能 * 丰富的标准库 * 跨平台编译

Go语言的使用场景: * 服务器编程 * 分布式系统 * 网络编程 * 内存数据库 * 云平台

基础技术

Linux namespace介绍

六个不同的命名空间:PID,User,MNT,NET,IPC,UTS

  • clone()创建新进程,根据系统调用的参数来判断什么类型的Namespace参数被创建,它们的子进程也会被包含到这些namespace中去
    • CLONE_NEWNS
    • CLONE_NEWUTS(域名和节点名)
    • CLONE_NEWIPC
    • CLONE_NEWPID
    • CLONE_NEWNET
    • CLONE_NEWUSER
  • unshare()将进程移除某个Namespace
  • setns()将进程加入到某个Namespace

UTS namespace介绍

UTS Namespace主要用来隔离nodename和domainname两个系统标识。

通过配置cmd命令,设置CLONE_NEWUTS参数作为新fork出来的新进程的初始命令。

pstree -pl查看系统中进程之间的关系
echo $$查看当前进程pid
readlink /proc/pid/ns/uts 读取指定pid的uts命名空间id查看是否父子进程是否在不同的UTS Namespace

IPC namespace介绍

该命名空间主要用来隔离System V IPC和POSIX message queue。每一个IPC namespace都有自己的System V IPC和POSIX message queue。同样通过设置cmd及参数CLONE_NEWIPC进行隔离

ipcs -q  //查看ipc消息队列
ipcmk -Q  //创建一个消息
ipcs -a //再进行查看,但在新创建的IPC namespace则看不到新创建的message queue

PID namespace介绍

用来隔离进程id的,同样一个进程在不同的PID namespace里面可以有不同的进程pid,同上设置cmd和CLONE_NEWPID参数

ps 和 top命令会使用/proc下的内容,不适合用于查看新创建的PID namespace下的进程id

Mount namespace

用来隔离各个进程看到的挂载点视图,同上,cmd,CLONE_NEWNS,Mount namespace内部的mount和unmount操作,不会影响到外部命名空间

User Namespace

主要是用来隔离用户和用户组ID,cmd和CLONE_NEWUSER,用id命令查看用户id和用户组id信息

NetWork Namespace

用来隔离网络设备,IP地址,等网络栈的命名空间,每个Namespace可以让每个容器拥有自己独立的(虚拟的)网络设备。cmd和CLONE_NEWNET

Linux Cgroups介绍

什么是Cgroups

用于限制CPU,内存,存储,网络等资源

Cgroups中的三个组件

  1. 一个cgroup包含一组进程,将一组进程和一组subsystem的系统参数关联起来

  2. subsystem是一组资源控制的模块,包含如下几项:
    • blkio: 对块设备(硬盘)的访问控制
    • cpu: 设置cpu被调度的策略
    • cpuacct: 设置cgroup中进程的cpu占用
    • cpuset: 多核机器上设置cgoroup中进程可以使用的cpu和内存(仅使用于numa架构)
    • devices:控制cgroup中进程对设备的访问
    • freezer: 用于挂起和恢复cgroup中的进程
    • memory: 用于控制cgroup中进程的内存占用
    • net_cls: 用于将cgroup中进程产生的网络包进行分类
    • net_prio:设置进程中产生网络流量的优先级
    • ns:namespace相关
  3. hierarchy的功能是把一组cgroup串成一个树状结构,一个这样的树便是一个hierarchy,通过这种树状结构,cgroups可以做到继承

如何创建一个cgroups并进行配置:

mkdir cgroup-test
sudo mount -t cgroup -o none,name = cgroup-test cgroup-test ./cgroup-test   //挂载一个hierarchy
ls ./cgroup-test
cgroup.clone_children   cgroup.procs  cgroup.procs   cgroup,sane_behavior  notify_on_release   release_agent  tasks
//这些文件就是hierarchy 中cgroup根节点的配置项
//cgroup.clone_children:子进程cgroup是否继承父进程的cgroup 的cpuset选项
//cgroup.procs:是树中当前节点cgroup中进程组ID
//tasks标示该cgroup下面的进程ID

sudo mkdir cgroup-1  //新建cgroups
tree  //查看当前目录树
--cgroup-1
    |--cgroup.clone_children
    |--cgroup.procs
    |--notify_on_release
    |--tasks
|--cgroup.clone_children
|--cgroup.procs
|--cgroup.sane_behavior
|--notify_on_release
|--release_agent
|--tasks

在cgroup中添加和移动进程:一个进程在一个Cgroups的hierarchy中,只能在一个cgroup节点上存在,添加进程到cgroup只需要 将进程ID写入该cgroup节点的tasks文件即可

echo $$
7475
sudo sh -c "echo $$ >>tasks"  //在cgroup-1目录节点下,将进程ID写入tasks文件
cat /proc/7475/cgroup //该进程在proc下pid的cgroup信息就会自动有cgroup-1的信息

如何通过subsystem限制cgroup中进程的资源?

  1. 其实系统默认已经为每个subsystem创建了一个默认的hierarchy,比如memory的hierarchy:/sys/fs/cgroup/memory
  2. 在这个hierarchy目录下创建一个cgroup目录,这个cgroup目录下会有一些memory限制相关的文件,如memory.limit_in_bytes,我们只要向这个文件写入内存限制,即可限制该cgroup下所有进程的内存
     //在该cgroup根目录下/sys/fs/cgroup/memory/test_cgroup
     sudo sh -c "echo "100m" >memory.limit_in_bytes"
     //然后将进程添加到该cgroup的tasks中
     sudo sh -c "echo $$ >tasks"
    

Docker是如何使用Cgroups的

docker会为每个容器在系统中的hierarchy中每个subsystem创建一个属于docker的cgroup根目录:/sys/fs/cgroup/memory/docker/container_id/。每创建一个容器,就对应在各subsystem中以容器id为目录名创建一个cgroup,然后写入默认或自定义的配置信息,来限制该容器的各个资源。

Go语言实现通过Cgroup,Docker对容器的资源限制:

配置 cmd命令,通过参数设置好六大Namespace,创建进程(也就是容器),然后在docker根目录下 创建容器cgroup根目录。并获取cmd运行的进程pid,然后 写入到该cgroup的tasks文件下,添加到该cgroup,然后设置该cgroup下各种资源限制,根据 默认或自定义的参数

联合挂载文件系统的实现

就是根据容器镜像层的元数据,然后调用如下命令:

sudo mount -t aufs -o dirs=./container-layer:./image-layer4:./image-layer3:./image-layer2:./image-layer1 none ./mnt

//把所有底层镜像层以aufs方式联合挂载到./mnt目录下,然后生成一个可读写层

3.构造容器

3.1run命令的实现

/proc目录介绍:该文件系统由内核提供,它其实不是一个真正的文件系统,只包含了系统运行时的信息(比如系统内存,mount设备信息,一些硬件配置等),它只存在于内存中,而不占用外存空间,它以文件系统的方式为访问内核数据的操作提供接口

/proc下比较重要的部分:

* /proc/N:PID为N的进程相关信息
* /proc/N/cmdline:进程启动的命令
* /proc/N/cwd:链接到进程当前工作目录
* /proc/N/environ:进程环境变量列表
* /proc/N/exe:链接到进程的执行命令文件
* /proc/N/fd:包含进程相关的所有文件描述符
* /proc/N/maps:与进程相关的内存映射信息
* /proc/N/mem:指代进程持有的内存
* /proc/N/root:链接到进程的根目录
* /proc/N/stat:进程的状态
* /proc/N/statm:进程使用内存的状态
* /proc/N/status:进程的状态信息,比stat更具有可读性
* /proc/self:链接到当前正在运行的进程

runc的实现步骤:

主要通过两个命令:initCommand,runCommand来实现,runCommand会调用NewParentProcess,执行如下命令cmd:

/proc/self/exe init ...//将init作为参数传递给创建的进程

配置cmd的参数CLONE_NEWXXX设置Namespace,然后执行init初始化进程,通过调用execve(const char * filename,char * argv[],char * const envp[]);来替换第一个创建的进程,将init初始化进程作为该容器的PID为1的进程:

docker_1

3.2增加资源限制

3.1 小节已经成功创建了容器进程,并且设置好了六大命名空间,现在对该进程进行资源限制

package subsystem
//用于传递资源限制配置的结构体
type ResourceConfig struct{
    MemoryLimit string
    CpuShare string
    Cpuset string
}
//subsystem接口
type Subsystem interface{
    Name() string;
    Set(path string,res *ResourceConfig) error
    Apply(path string,pid int) error
    Remove(path string) error
}
//通过不同的subsystem初始化实例创建资源限制处理链数组
var{
    SubsystemsIns = []Subsystem{
        &CpusetSubsystem{},
        &MemorySubsystem{},
        &CpuSubsystem{},
    }
}
//memory subsystem的实现
针对每个类型Subsystem对接口Name,Set,Apply,Remove实现
分别实现:
1. 获取子系统名称
2. 设置对应cgroup下文件的数据
3. 将进程添加到一个cgroup中
4. 移除指定路径下的cgroup

/proc/pid/mountinfo:保存该进程下所有的挂载信息。我们可以根据该进程的挂载信息,找到该进程对应的subsystem子系统挂载的根目录,然后找到对应的文件写入限制数据。

  • 然后依据设置好的ResourceConfig资源配置信息
  • 并以 mountinfo中获取的子系统挂载的根目录逐个调用 SubsystemsIns中的资源设置接口Set
  • 并将该进程的PID逐个添加 Apply到各子系统的cgroup(对应子系统下的容器id)下。

docker_2

3.3docker daemon父进程给容器初始化进程通过管道通信的实现

4. 构造镜像

4.1设置容器进程的工作目录

  • 使用pivot_root系统调用,将进程的工作目录移动到一个新的root文件系统下,不依赖于旧的文件系统。
  • 我们使用busybox镜像文件系统,将进程的工作目录移动到该busybox镜像之下,我们可以在这个镜像的目录之下,挂载我们想要挂载的文件系统,如tmpfs:一种基于内存的文件系统

4.2使用AUFS包装busybox

四个步骤:

  1. 创建可读层
  2. 创建可读写层
  3. 创建挂载点/mnt,将可读层和可读写层以AUFS的方式联合挂载到挂载点./mnt
  4. 将挂载点作为容器进程的根目录

容器退出的步骤:

  1. 卸载挂载点的文件系统
  2. 删除挂载点
  3. 删除可读写层

4.3实现数据卷的挂载

三个步骤:

  1. 首先,读取宿主机文件目录URL,创建宿主机文件目录
  2. 然后,读取容器挂载点URL,在容器文件系统里创建挂载点
  3. 最后,把宿主机文件目录挂载到容器挂载点即可完成数据卷的挂载

这三个步骤会在启动容器的过程当中分析-v参数,来进行挂载。挂载参数为 “/root/volume:/containerVolume”

5.构建容器进阶

5.1 实现容器的后台运行

在Docker的早期版本,所有容器的init进程都是从docker daemon 这个进程fork出来的,这会导致一个众所周知的问题,如果docker daemon挂掉,那么所有容器都会宕掉。这给升级docker daemon带来很大的风险。后来docker使用了containerd,也就是现在的runc,便可即使daemon挂掉,容器依然健在。

runc可以提供一种detach功能,保证runc在退出的情况下依然可以运行,容器进程就是mydocker进程fork出来的子进程。子进程的结束和父进程的运行是一个异步的过程,如果父进程退出,子进程就会成为孤儿进程,进程号为1的进程init会接管这些孤儿进程。这就可以实现mydocker退出,容器不宕掉。

参数”-d”就是设置了detach的功能,用detach方式创建了容器,就不能用parent.wait()去等待,创建容器之后,父进程就已经退出了,然后由操作系统进程ID为1的init进程去接管容器进程

5.2 实现对运行中容器的查看

我们会将容器的信息保存到一个结构体,并且写入到一个配置文件config.json永久保存,然后通过containerDriver去加载所有容器配置相关的信息,以供docker运行时使用。

5.3 实现容器日志的查看

创建一个记录日志的文件,我们会在创建容器的时候,将标准输出挂载到我们设置的日志文件中去。

5.4 进入容器Namesace(exec)

setns是一个系统调用,可以根据提供的PID再次进入到指定的Namespace中。它需要打开/proc/pid/ns/文件夹下对应的文件获取该进程的namespace信息,然后使当前进程进入到指定的Namespace中

所以docker exec的过程就是

  1. 先获取要执行的命令
  2. 再根据pid到指定目录/proc/pid/ns/ipc(uts,net,pid,mnt)来获取对应的namespace信息
  3. 通过调用setns进入到各命名空间

5.5 实现停止容器(stop)

stop容器的原理很简单,就是通过查找容器的主进程的PID,然后发送SIGTERM信号,再修改对应容器配置信息的驱动信息

6.docker的镜像构建机制

6.1 镜像介绍

镜像是一种文件存储形式,Docker镜像与虚拟机镜像有很大的相似度,然而也有着本质的区别,相同的是两者都含有文件系统的内容,不同的是,Docker镜像不含操作系统的内容.

6.2 镜像构建过程

镜像构建命令中具有5个参数:tag,suppressOutput,noCache(使用镜像缓存),rm,forceRm

  1. 解析参数
  2. 获取DockerFile相关内容,根据内容创建buildFile对象:
    • 可以从本地获取
    • 从远程url获取
    • 从git源获取
  3. Docker 命令解析流程
    • 解析DockerFile中每一条命令,并通过Golang的反射机制来找到对应的执行方法

6.3 Dockerfile命令执行分析

源码级别的分析详见博客:Docker容器镜像构建分析

FROM命令

FROM命令一般是Dockerfile的首条命令,紧跟其后的参数为具体的镜像名称,作为build流程的基础镜像。FROM命令的基础镜像信息会被记录到buildFile对象中,对应属性为buildFile.image.

流程:

  1. daemon.repository中查找指定的镜像,若镜像不存在,则立即执行镜像下载任务
  2. 若镜像存在,则获取镜像信息并开始buildFile的配置流程

RUN命令

执行流程:

  1. probeCache:缓存机制
    • Docker在build RUN命令之前,buildFile的属性image肯定有一个值(父镜像的ID);
    • 又由于在执行build RUN命令时,Docker Daemon首先要配置buildFile的config属性,实则是在buildFile.image的config属性上进行配置。
    • 因此Docker Daemon只需要遍历本地所有的镜像,只要存在一个镜像,该镜像的父镜像ID与当前buildFile的image值相等,同时,此镜像的config与buildFile.config相同,则可以认为匹配成功,直接在本地使用该镜像即可。
  2. 如果镜像匹配失败,则根据容器RUN命令的容器运行环境配置信息来创建容器对象
  3. 挂载容器运行所需要的镜像层数据
  4. 运行容器
  5. 提交容器的新镜像