深入剖析Kubernetes(一)


概述

云原生,启动!

云原生体系

  1. 容器化(Containerization)

目前最流行的容器化技术是Docker,你可以将任意大小的应用程序和依赖项,甚至在模拟器上运行的一些程序,都进行容器化。随着时间的推移,你还可以对应用程序进行分割,并将未来的功能编写为微服务。

  1. 持续集成&发布(CI/CD)

创建CI/CD环境,从而使源代码上的任意修改,都能够自动通过容器进行编译、测试,并被部署到预生产甚至生产环境中。

Argo:云原生的工作流引擎 - 知乎 (zhihu.com)

  1. 编排&应用定义(Orchestration&Application Definition)

Kubernetes是目前市场上应用编排领域被最广泛应用的工具,Helm Charts可以用来帮助应用开发和发布者用于升级Kubernetes上运行的应用。

Kubernetes(k8s)中文文档 Kubernetes概述_Kubernetes中文社区
Helm | Docs

  1. 监控&分析(Observability&Analysis)

在这一步中,用户需要为平台选择监控、日志以及跟踪的相关工具,例如将Prometheus用于监控、Fluentd用于日志、Jaeger用于整个应用调用链的跟踪。

通俗易懂:什么是 Jaeger 软件?优势及作用一览 (redhat.com)
Introduction · Prometheus中文技术文档
Fluentd简介_LiangIter的博客-CSDN博客_fluentd

  1. 服务代理、发现、网格化(Service Proxy、Discovery、Mesh)

CoreDNS、Envoy和LInkerd可以分别用于服务发现和服务治理,提供服务的健康检查、请求路由、和负载均衡等功能。

史上最全的高性能代理服务器 Envoy 中文实战教程 !(强烈建议收藏) - 云+社区 - 腾讯云 (tencent.com)
CoreDNS 简单介绍 - 简书 (jianshu.com)
Linkerd 初探 - 简书 (jianshu.com)

  1. 网络策略&安全(Networking,Policy,Security)

Calico、Flannel以及Weave Net等软件用于提供更灵活的网络功能。

calico网络原理、组网方式和使用 - 云+社区 - 腾讯云 (tencent.com)
一篇文章带你了解Flannel - Flannel - 操作系统 - 深度开源 (open-open.com)
Weave系列之Weave Net安装与探索 | SDNLAB | 专注网络创新技术

  1. 分布式数据库&存储(Distributed Database&Storage)

分布式数据库可以提供更好的弹性和伸缩性能,但同时需要专业的容器存储予以支持。

ETCD 简介 + 使用_菲宇的博客-CSDN博客_etcd

  1. 流&消息传递(Streaming&Messaging)

当应用需要比JSON-REST这个模式更高的性能时,可以考虑使用gRPC或者NATS。gRPC是一个通用的RPC(远程调用)框架(类似各种框架中的RPC调用),NATS是一个发布/订阅和负载均衡的消息队列系统。

高性能消息中间件——NATS - 知乎 (zhihu.com)
gRPC详解 - 简书 (jianshu.com)

  1. 容器注册&运行(Container Registry&Runtime)

Harbor是目前最受欢迎的容器镜像库,同时,你也可以选择使用不同的容器运行环境用于运行容器程序。

harbor搭建及使用 - 浪淘沙& - 博客园 (cnblogs.com)

  1. 软件发布

最后可以借助Notary等软件用于软件的安全发布。

Notary项目 - 云+社区 - 腾讯云 (tencent.com)

Docker的本质

和虚拟机的不同

首先我们思考一个问题:容器与进程有何不同?

  • 进程就是程序运行起来后的计算机执行环境的总和

即:计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合。

  • 容器核心就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”

对于 Docker 等大多数 Linux 容器来说,Cgroups 技术是用来制造约束的主要手段,而 Namespace 技术则是用来修改进程视图的主要方法。

上图是一个常见的将Docker和虚拟机进行比较的图,但其实不应该把 Docker Engine 或者任何容器管理工具放在跟 Hypervisor 相同的位置,因为它们并不像 Hypervisor 那样模拟出完整硬件,Docker Engine只是对Linux底层操作系统技术进行封装,将一个进程进行一些资源隔离。Docker Engine扮演的更多是旁路式辅助和管理工作。那么问题来了,他是怎么做到的?

Linux Namespace

进程,说白了就是运行起来的程序,它是参与当前的计算机里的数据和状态的总和。既然我们要虚拟化出一个隔离世界,那第一点就是要修改进程的视图,让他看不见别的运行的进程,比如我们进入到一个容器内执行ps -ef,我们可以发现这个容器内部的1号进程就是我们在docker中最开始执行的/bin/sh,这意味着Docker完全看不见宿主机中的世界,自己被隔离了。

实现这个设计非常简单,这只是一个障眼法,我们只需做到新创建的容器(本质进程),在这个进程空间中它是1,而在宿主机真实的进程空间里,这个进程的 PID 还是真实的数值,这个就是Linux操作系统提供的PID Namespace,Linux 操作系统还提供了 Mount、UTS、IPC、Network 和 User 这些 Namespace,用来对各种不同的进程上下文进行“障眼法”操作。

/*
CLONE_NEWPID : 新创建的这个进程将会“看到”一个全新的进程空间,在这个进程空间里,它的 PID 是 1
CLONE_NEWIPC : 在新的IPC命名空间中创建进程
CLONE_NEWNET : 在新的网络命名空间中创建进程
CLONE_NEWNS  : 在新的mount命名空间中创建进程
CLONE_NEWUTS : 新的UTS命名空间中创建进程
CLONE_NEWUSER: 新的用户命名空间中创建进程
*/

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

如果熟悉 Linux可能会联想到 linux 中的 chroot 命令,该命令允许将当前目录修改成根目录(即根目录 / 的挂载点切换了),相当于文件系统被隔离了,Namespace 也具有相似的功能,但更加强大。

以一个具体的例子来解释 Namespace 的作用,假设你有一台性能非常好的计算机,你向用户出售自己的计算机的资源,每个用户买到一个 ssh 实例,为了避免不同客户之间相互干扰,你可能会对不同用户进行权限限制,让用户只能访问自己 ssh 实例下的资源。

但有些操作需要 root 权限,而我们不能将 root 权限提供给用户,此时就可以使用 Namespae 了,通过 User Namespace 对 UID 进行隔离,具体而言,UID 为 x 的用户在该 Namespace 中具有 root 权限,但在真实物理机中,他依旧是 UID 为 x 的用户,这就解决了用户间隔离的问题。

此外还可以通过 PID Namespace 对 PID 进行隔离,从该 Namespace 中的用户角度看,Namespace 中就像一台新的 Linux,有自己的 init 进程(初始进程,PID 为 1),其他进程的 PID 在 init 进程 PID 上递增,也就是上文提到的。Docker 利用 Linux Namespace 功能实现多个 Docker 容器相互隔离,具有独立环境的功能,Go 语言对 Namespce API 进行了相应的封装,当然,不要觉得就这?这个技术听起来容易,但是要真正考虑到安全易用还有许多细节,比如权限、路由表、iptables规则配置等等问题。

Linux Cgroups

以上namespace只是让容器只看到自己内部的情况,但其实它作为宿主机上普通的进程和其他进程一样,也需要平等的竞争计算机资源如CPU,内存,带宽等,因此容器资源会随时被其他进程所抢占,甚至吃光,这些情况显然不是一个“沙盒”应该表现出来的合理行为,因此,Linux Cgroups 就是 Linux 内核中用来为进程设置资源限制的一个重要功能。

Cgroups使用非常简单,它是一组文件系统目录,如下图所示,只需要在里面写入配额即可,例如cfs_period 和 cfs_quota,它是用来限制进程在长度为 cfs_period 的一段时间内,只能被分配到总量为 cfs_quota 的 CPU 时间。

// 每 100 ms 的时间里,被该控制组限制的进程只能使用 50 ms 的 CPU 时间
// 即最多使用50%的CPU带宽
// 从下面的top可以明显看出

echo 50000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us

echo 100000 > /sys/fs/cgroup/cpu/container/cpu.cfs_period_us 

Cgroups 由 3 个组件构成,分别是 cgroup(控制组)、subsystem(子系统)以及 hierarchy(层级树),3 者相互协同作用,Cgroups 会将系统进程分组(cgroup)然后通过 hierachy 构建成独立的树,树的节点就是 cgroup(进程组),每颗树都可以与一个或多个 subsystem 关联,subsystem 会对树中对应的组进行操作。

  • cgroup 是对进程分组管理的一种机制,一个 cgroup 通常包含一组(多个)进程,Cgroups 中的资源控制都以 cgroup 为单位实现。
  • subsystem 是一组(多个)资源控制的模块,每个 subsystem 会管理到某个 cgroup 上,对该 cgroup 中的进程做出相应的限制和控制。
  • hierarchy 会将一组(多个)cgroup 构建成一个树状结构,Cgropus 可以利用该结构实现继承等功能

有个几个规则需要注意:

  1. 一个 subsystem 只能附加到一个 hierarchy,而一个 hierarchy 可以附加多个 subsystem
  2. 一个进程可以作为多个 cgroup 的成员,但这些 cgroup 只能在不同的 hierarchy 中
  3. 一个进程 fork 出子进程,此时子进程与父进程默认是在同一个 cgroup 中,可以根据需要移动到其他 cgroup

rootfs

namespace的隔离中有一个Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效,否则我们执行ls,仍然看到的是宿主机的目录。不难想到,我们可以在容器进程启动之前重新挂载它的整个根目录“/”,而由于 Mount Namespace 的存在,这个挂载对宿主机不可见,这是专属于容器自己的文件系统,这样容器进程就可以在里面随便折腾了。这是如何做到的呢,其实非常简单,核心就是chroot:

mkdir -p $HOME/test
mkdir -p $HOME/test/{bin,lib64,lib}
cp -v /bin/{bash,ls} $HOME/test/bin
T=$HOME/test
list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
for i in $list; do cp -v "$i" "${T}${i}"; done

//告诉操作系统,我们将使用 $HOME/test 目录作为 /bin/bash 进程的根目录
//这样docker启动时的根目录就是我们之前宿主机上新建的$HOME/test,它不知道这个宿主机的目录,他对于自己的根目录深信不疑
chroot $HOME/test /bin/bash

当然,为了能够让容器的这个根目录看起来更“真实”,我们一般会在这个容器的根目录下挂载一个完整操作系统的文件系统,比如 Cent7的 ISO。这样,在容器启动之后,我们在容器里通过执行 “ls /“ 查看根目录下的内容,就是Cent7的所有目录和文件。但话又说回来,rootfs 只包括了操作系统的“躯壳”,并没有包括操作系统的“灵魂”,因为所有的容器都共享同一个宿主机系统的内核。

rootfs(根文件系统),这个为docker提供隔离后执行环境的文件系统,又名容器镜像。Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs,这里核心技术实现是OverlayFS和copy-on-wirte。

OverlayFS联合挂载顾名思义,就是将多个不同目录影射到一个基础目录中,比如:

那么docker容器中,可在下图看到三个层结构,即:lowerdir、uperdir、merged,其中 lowerdir 是只读的 image layer,其实就是 rootfs,对比我们上述演示的目录 A 和 B,我们知道 image layer 可以分很多层,所以对应的 lowerdir 是可以有多个目录,后续这些目录会联合挂载在merged中。

而 upperdir 则是在 lowerdir 之上的一层,这层是读写层,在启动一个容器时候会进行创建,所有的对容器数据更改都发生在这里层,对比示例中的 C。最后 merged 目录是容器的挂载点,也就是给用户暴露的统一视角,对比示例中的/tmp/test。下

图还有一个层没有画出来,就是init层,用于保存专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息连同可读写层一起提交掉。所以,就单独搞出一个init层,用户commit只不提交这层的。

为什么要有这个upperdir呢,这就是“分层镜像”设计的巧妙之处,比如容器的读过程,如果文件在容器层(upperdir),直接读取文件;如果文件不在容器层(upperdir),则从镜像层(lowerdir)读取;比如容器的写过程,首次写入通过copy_up行为将文件从 lowdir 拷贝到 upperdir,后续写操作只对副本进行操作;比如容器的删除过程,即在upperdir中创建 同名的whiteout 文件,它是空白的,这样镜像层虽然存在,用户已经无法继续访问了。

所以最上面这个可读写层的作用,就是专门用来存放你修改基础rootfs 后产生的增量,无论是增、删、改,都发生在这里。而当我们使用完了这个被修改过的容器之后,还可以使用 docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub 上,供其他人使用;而与此同时,原先的只读层里的内容则不会有任何变化。这就是增量 rootfs 的好处。否则没有这层读写层,大家直接修改了基础rootfs话,新旧两个 rootfs 之间就没有任何关系了,这样做的结果就是极度的碎片化。

小结

综上,docker容器本质就是蒙了双眼的进程,这就意味着这些因为虚拟化而带来的性能损耗都是不存在的;而另一方面,使用 Namespace 作为隔离手段的容器并不需要单独的 Guest OS,这就使得容器额外的资源占用几乎可以忽略不计,这也就是docker的最大优势:敏捷和高性能。

不过弊端也十分明显,就是隔离的不彻底,既然是特殊的进程,就意味着多个容器还是共享一个宿主机的操作系统内核,你不可能在linux宿主机跑windows容器,或高于宿主机版本的linux容器。其次有一些linux内核资源是没办法隔离的,比如时间,如果有些容器内部操作改变了时间那么整个宿主机上其他容器也会改变,这显然是不符合用户的预期的,这带来的后果就是容器给应用暴露出来的攻击面非常大,安全性比虚拟机低很多。

一个正在运行的 Linux 容器,其实可以被“一分为二”地看待:一组联合挂载在 /var/lib/docker/aufs/mnt 上的 rootfs,这一部分我们称为“容器镜像”(Container Image),是容器的静态视图;一个由 Namespace+Cgroups 构成的隔离环境,这一部分我们称为“容器运行时”(Container Runtime),是容器的动态视图。

exec的实现原理

实际上,Linux Namespace 创建的隔离空间虽然看不见摸不着,但一个进程的 Namespace 信息在宿主机上是确确实实存在的,并且是以一个文件的方式存在。

比如,通过如下指令,你可以看到当前正在运行的 Docker 容器的所有namespace了:

而exec其实就是调用了linux的系统调用setns()进入到某个进程已有的某个namespace中,比如:

int main(int argc, char *argv[]) {

    int fd = open("/proc/14532/ns/net", O_RDONLY);
    if (setns(fd, 0) == -1) {
        printf("setns");
    }
    char *sh = "/bin/bash";
    execvp(sh , &sh); 
    printf("execvp");
}
//加入到了容器进程的Network Namespace中了,/bin/bash 进程的网络设备视图,也被修改了。

文章作者: JoyTsing
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 JoyTsing !
评论
  目录