k8s 学习笔记(一)容器

K8s 学习笔记,容器相关知识。

容器基础

容器的实现

容器是一种沙盒技术,可以通过建立“边界”使不同应用间进行隔离。每一个应用运行在进程之中。容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为应用创建了一个“边界”

Linux 容器(如 Docker)主要是基于 Cgroups 技术创造约束, 使用 Namespace 修改进程视图。

1. Namespace

Namespace 技术实际上修改了应用进程看待整个计算机 “视图”,即它的“视线” 被操作系统做了限制,只能 “看到” 某些指定的内容。

以docker 为例,其主要使用的资源隔离是:

  1. mount - 保证容器文件系统的视图
  2. uts - 用于隔离宿主机的 hostname domainName
  3. pid - 用于隔离宿主机的进程号,保证容器中 init 进程以1号进程启动
  4. network - 用于隔离与宿主机的网络,--network=host参数使用主机网络
  5. user - 控制用户uid 在宿主机上的映射
  6. ipc - 控制进程间通信(信号量等)
  7. cgroup - runc 实现,但是未使用

以 PID Namespace 为例,当我们用指令启动一个 docker

1
docker run -it busybox /bin/sh

查看其内部的进程ps -ef

1
2
3
PID  USER   TIME COMMAND
1 root 0:00 /bin/sh
10 root 0:00 ps

在容器中,/bin/sh 的进程号是 1, 也就是说在当前的进程空间 PID=1, 但是在系统层面其真实的 PID 可能是 100 或其他。

这时,这些进程就会觉得自己是各自 PID Namespace 里的第 1 号进程,只能看到各自 Mount Namespace 里挂载的目录和文件,只能访问到各自 Network Namespace 里的网络设备,就仿佛运行在一个个 “容器” 里面,与世隔绝。

Namespace 其实只是 Linux 创建新进程的一个可选参数。当我们在 Linux 系统中创建线程的系统调用是 clone(),比如:

1
int pid = clone(main_function, stack_size, SIGCHLD, NULL);

返回的 pid 是系统中的 pid,但如果指定CLONE_NEWPID

1
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);

新建的进程会进入到一个新的进程空间,其 PID=1

在linux 中,可以使用 unshare 命令--fork 创建子进程,实现pid的隔离。

Docker vs VM:

  • VM 通过硬件虚拟化功能,模拟出了运行一个操作系统需要的各种硬件,比如 CPU、内存、I/O 设备等等。然后,它在这些虚拟的硬件上安装了一个新的操作系统,即 Guest OS。
  • Docker 启动的还是原来的应用进程,只不过在创建这些进程时,Docker 为它们加上了各种各样的 Namespace 参数,形成了隔离。容器,其实是一种特殊的进程而已。

基于 Linux Namespace 的隔离机制相比于虚拟化技术也有很多不足之处,其中最主要的问题就是:隔离得不彻底。:

  • 基于系统内核
  • 很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时间。

2. Cgroup

Control groups ,Linux内核提供的一种可以限制、记录、隔离进程组(process groups)所使用的物理资源(如:cpu,memory,IO等等)的机制,Cgroups 还能够对进程进行优先级设置、审计,以及将进程挂起和恢复等操作。

Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下

1
2
3
4
5
6
7
8
9
10
11
12
$ mount -t cgroup 

# 用于为进程分配单独的 CPU 核和对应的内存节点;
cpuset on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
# 为进程限制 cpu 使用率相关
cpu on /sys/fs/cgroup/cpu type cgroup (rw,nosuid,nodev,noexec,relatime,cpu)
# 统计进程组使用 cpu 资源
cpuacct on /sys/fs/cgroup/cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct)
# 为​​​块​​​设​​​备​​​设​​​定​​​I/O 限​​​制,一般用于磁盘等设备;
blkio on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
# 为进程设定内存使用的限制。
memory on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)

其中 cpuset cpu memory 子目录作为 Cgroups 可以限制的子系统, 具体的限制策略在每个子目录下。例如 cpu 子系统下有文件

1
2
3
4
5
6
7
8
9
10
11
12
$ ls /sys/fs/cgroup/cpu

cgroup.clone_children
cpu.cfs_period_us
cpu.rt_period_us
cpu.shares
notify_on_release
cgroup.procs
cpu.cfs_quota_us
cpu.rt_runtime_us
cpu.stat
tasks

当我们想限制 cpu 资源时,所需的步骤如下:

  1. /sys/fs/cgroup/cpu 下创建目录 container(控制组)
    container目录下,会自动生成该子系统对应的资源限制文件
  2. 配置限制文件
  3. 被限制的进程的 PID 写入 container 组里的 tasks 文件(实现限制进程的资源)
docker 中的 cgroup

对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的 PID 填写到对应控制组的 tasks 文件。

在 docker 中,有两种驱动实现cgroup:

  • cgroupfs
    这种方式的原理如同上文所述,将pid 写入 cgroup 文件,资源限制写入对应的 mem cpu cgroup 文件
  • systemd
    这种方式提供了一种 cgroup 管理方式,它不允许手动更改 cgroup 文件,相反 systemd 提供了一系列更改 cgroup 文件的接口,且与底层系统耦合。

docker 中常常用到的 cgroup 资源限制主要是:

  1. cpu cpuset cpuacct – cpu利用率、cpu使用核数等
  2. memory – 内存使用限制
  3. device – 用于安全,可以控制容器中可以看到的设备
  4. freezer – 用于安全,在停止容器时,冻结进程,防止进程 fork 出子进程并逃逸到宿主机。
  5. blkio – 限制容器用到的磁盘的io速率限制 cgroup v1只能限制同步io(block io),无法限制 buffer io
  6. pid – 容器中最大的进程数量

不常用:

  1. net_cls
  2. net_prio
  3. hugetlb
  4. perf_event
  5. rdma

上述资源限制中,runc不支持 rdma

总结

容器是一个 “单进程” 模型, 用户应用进程实际上是 Docker 中 PID=1 的进程。 也就是说在一个容器中,没办法同时运行两个不同的应用,除非你能事先找到一个公共的 PID=1 的程序来充当两个不同应用的父进程。

容器与应用同生命周期

此外还存在一些问题,在容器中的 /proc 文件系统不了解 Cgroups 限制的存在, 即在容器中使用 top 时会显示宿主机的 CPU 和内存数据。

解决这个问题需要借助 lxcfs, 做法是把宿主机的 /var/lib/lxcfs/proc/memoinfo 文件挂载到Docker容器的/proc/meminfo位置,容器中进程读取相应文件内容时,LXCFS的FUSE实现会从容器对应的Cgroup中读取正确的内存限制,从而使得应用获得正确的资源约束设定。详细内容

容器镜像

文件系统

容器使用 Mount Namespace 隔离容器看到的文件系统与宿主机的文件系统,但是在创建进程时,即使开启了 Mount Namespace,容器进程看到的文件系统也跟宿主机完全一样。

Mount Namespace 修改的,是容器进程对文件系统 “挂载点” 的认知。

只有在 “挂载” 这个操作发生之后,进程的视图才会被改变,而在此之前,新创建的容器会直接继承宿主机的各个挂载点。重新挂载的目录只在 Mount Namespace 中才会生效。

在 Linux 系统中,可以使用 chroot 来更改 /bin/bash 进程的根目录,Mount Namespace 则是基于对chroot 不断改良发明而来。

chroot 更换根目录过程:

  1. 新建一个目录 $HOME/test
  2. 将 bash 命令拷贝到 $HOME/test/bin 下
  3. 将 bash 所需的 .so 文件拷贝到 $HOME/test/lib 下
  4. chroot $HOME/test /bin/bash (告诉操作系统将 $HOME/test 目录作为 /bin/bash 进程的根目录)

在容器的根目录,则是挂载一个完整操作系统的文件系统,用来为容器进程提供隔离后执行环境的文件系统,这个文件系统称为 rootfs

对 Docker 项目来说,它最核心的原理实际上就是为待创建的用户进程:

  1. 启用 Linux Namespace 配置;
  2. 设置指定的 Cgroups 参数;
  3. 切换进程的根目录(Change Root)

容器镜像结构

由于 rootfs 的存在,应用的云端环境和本地环境完全一致:因为对一个应用来说,操作系统本身才是它运行所需要的最完整的 “依赖库”,rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。

但现在有一个问题:每开发一个应用,或者升级一下现有的应用,都要重复制作一次 rootfs 吗?

Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。

layer 的设计使用了联合文件系统(Union File System),其最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。

例如:/A 下有 a,x 两个文件, /B 下有 b,x两个文件,当把 /A /B 都挂载到 /C 的目录下,/C 下则有 a,b,x 三个文件。

在 Docker 中使用 AuFS(Alternative UnionFS),是对 Linux 原生的 UnionFS 的重写和改进。对于 AuFS 来说,它最关键的目录结构在 /var/lib/docker 路径下的 diff 目录:/var/lib/docker/aufs/diff/<layer_id>

例如 Ubuntu 的镜像:

1
2
3
4
5
6
7
8
9
10
11
$ docker image inspect ubuntu:latest
"RootFS": {
"Type": "layers",
"Layers": [ // 有5层, 每层都是 rootfs 的增量,Ubuntu 操作系统文件与目录的一部分
"sha256:f49017d4d5ce9c0f544c...",
"sha256:8f2b771487e9d6354080...",
"sha256:ccd4d61916aaa2159429...",
"sha256:c01d74f99de40e097c73...",
"sha256:268a067217b5fe78e000..."
]
}

在使用镜像时,Docker 会把这些增量联合挂载在一个统一的挂载点上: /var/lib/docker/aufs/mnt/

下面解析一下这5个镜像层如何被联合挂载成一个完整的 Ubuntu 文件系统的。

  1. 镜像层挂载的信息保存在 /sys/fs/aufs 中,通过查看 AuFS 的挂载信息,可以找到这个目录对应的 AuFS 的内部ID:

    1
    $ cat /proc/mounts| grep aufs
  2. 使用这个 ID,你就可以在 /sys/fs/aufs 下查看被联合挂载在一起的各个层的信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    $ cat /sys/fs/aufs/si_972c6d361e6b32ba/br[0-9]*

    /var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...=rw
    /var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...-init=ro+wh
    /var/lib/docker/aufs/diff/32e8e20064858c0f2...=ro+wh
    /var/lib/docker/aufs/diff/2b8858809bce62e62...=ro+wh
    /var/lib/docker/aufs/diff/20707dce8efc0d267...=ro+wh
    /var/lib/docker/aufs/diff/72b0744e06247c7d0...=ro+wh
    /var/lib/docker/aufs/diff/a524a729adadedb90...=ro+wh

ps: 若系统中没有配置 Aufs, 则默认使用 graphDriver 机制,挂载目录。

镜像中的 GraphDriver 字段下显示了挂载的根目录:

因此这个容器的 rootfs 组成结构如下图

只读层

可读写层

它是这个容器的 rootfs 最上面的一层,一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。

若是删除一个只读层文件,AuFS 会在可读写层创建一个 whiteout 文件,把只读层里的文件 “遮挡” 起来。

最上面这个可读写层的作用,就是专门用来存放你修改 rootfs 后产生的增量,无论是增、删、改,都发生在这里。而当我们使用完了这个被修改过的容器之后,还可以使用 docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub 上,供其他人使用;而与此同时,原先的只读层里的内容则不会有任何变化。

Init 层

以 “-init” 结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。

需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。

可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息连同可读写层一起提交掉。

所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。

容器实践

用 Docker 部署一个用 Python 编写的 Web 应用,解析整个步骤过程。

1. 制作容器镜像

Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 使用官方提供的 Python 开发镜像作为基础镜像
FROM python:2.7-slim

# 将工作目录切换为 /app
WORKDIR /app

# 将当前目录下的所有内容复制到 /app 下
ADD . /app

# 使用 pip 命令安装这个应用所需要的依赖
RUN pip install --trusted-host pypi.python.org -r requirements.txt

# 允许外界访问容器的 80 端口
EXPOSE 80

# 设置环境变量
ENV NAME World

# 设置容器进程为:python app.py,即:这个 Python 应用的启动命令
CMD ["python", "app.py"]

Dockerfile 的设计思想,是使用一些标准的原语(即大写高亮的词语),描述我们所要构建的 Docker 镜像。并且这些原语,都是按顺序处理的。
在 dockerfile 中的CMD 其实是作为 ENTRYPOINT 的参数存在,ENTRYPOINT 不指定时默认为 /bin/sh -c

1
$ docker build -t helloworld .
  • -t 加标签命名
  • . 表示加载当前目录下的 dockerfile
    docker build 会自动加载当前目录下的 Dockerfile 文件,然后按照顺序,执行文件中的原语。而这个过程,实际上可以等同于 Docker 使用基础镜像启动了一个容器,然后在容器中依次执行 Dockerfile 中的原语。

需要注意的是,Dockerfile 中的每个原语执行后,都会生成一个对应的镜像层

2.启动容器

1
2
# 启动容器
docker run -p 4000:80 helloworld
  1. 命名镜像
    docker tag helloworld geektime/helloworld:v1
  2. 推镜像
    docker push geektime/helloworld:v1
  3. 修改文件内容后,提交镜像
    docker commit 4ddf4638572d geektime/helloworld:v2
    docker commit,实际上就是在容器运行起来后,把最上层的“可读写层”,加上原先容器镜像的只读层,打包组成了一个新的镜像。由于使用了联合文件系统,你在容器里对镜像 rootfs 所做的任何修改,都会被操作系统先复制到这个可读写层,然后再修改。这就是所谓的:Copy-on-Write。

docker exec 如何进入容器

一个进程的每种 Linux Namespace,都在它对应的 /proc/[进程号]/ns 下有一个对应的虚拟文件,并且链接到一个真实的 Namespace 文件上。

容器的具体实现是使用 int setns(int fd, int nstype) 系统调用,fd 参数指明了关联的namespace,其指向了\proc\PID\ns目录系一个符号连接的文件描述符。

3.容器数据卷

在容器中,使用了 rootfs 和 Mount Namespace 技术创建了完全隔离的文件系统环境,但是有这样两个问题:

  • 容器里进程新建的文件,怎么才能让宿主机获取到?
  • 宿主机上的文件和目录,怎么才能让容器里的进程访问到?

首先,Volume 声明有以下两种方式

1
2
3
4
# docker 临时创建 /var/lib/docker/volumes/[VOLUME_ID]/_data,并挂载到容器的 /test中
$ docker run -v /test ...
# 宿主机 /home 挂载到容器的 /test中
$ docker run -v /home:/test ...

针对第二个问题,可以回顾一下 mount namespace机制,在执行 chroot(或者 pivot_root)之前,容器进程始终可以看到宿主机上整个文件系统;同时,而宿主机上的文件系统,也自然包括了我们要使用的容器镜像。

所以,我们只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作就完成了。

更重要的是,由于执行这个挂载操作时,“容器进程”已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。

注意:这里提到的 “ 容器进程 “,是 Docker 创建的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程。

linux 的绑定挂载

正如上图所示,mount –bind /home /test,会将 /home 挂载到 /test 上。其实相当于将 /test 的 dentry,重定向到了 /home 的 inode。这样当我们修改 /test 目录时,实际修改的是 /home 目录的 inode。这也就是为何,一旦执行 umount 命令,/test 目录原先的内容就会恢复:因为修改真正发生在的,是 /home 目录里。

所以,在一个正确的时机,进行一次绑定挂载,Docker 就可以成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。

那么,这个 /test 目录里的内容,既然挂载在容器 rootfs 的可读写层,它会不会被 docker commit 提交掉呢?

容器的镜像操作,比如 docker commit,都是发生在宿主机空间的。而由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的 /test 目录(/var/lib/docker/aufs/mnt/[可读写层 ID]/test),始终是空的。

总结

这个容器进程“python app.py”,运行在由 Linux Namespace 和 Cgroups 构成的隔离环境里;而它运行所需要的各种文件,比如 python,app.py,以及整个操作系统文件,则由多个联合挂载在一起的 rootfs 层提供。

这些 rootfs 层的最下层,是来自 Docker 镜像的只读层。

在只读层之上,是 Docker 自己添加的 Init 层,用来存放被临时修改过的 /etc/hosts 等文件。

而 rootfs 的最上层是一个可读写层,它以 Copy-on-Write 的方式存放任何对只读层的修改,容器声明的 Volume 的挂载点,也出现在这一层。

© 2020 packy's workspace All Rights Reserved.
Theme by hiero