《深入剖析Kubernetes》学习笔记(一):容器技术

Docker & Kubernetes 背景

  • 传统 Paas 项目如 Cloud Foundry 最核心的组件就是一套应用的打包和分发机制。Cloud Foundry 为每种主流编程语言都定义了一种打包格式,用户把应用的可执行文件和启动脚本打进一个压缩包内上传后交给服务商通过调度器选择一个可以运行这个应用的虚拟机,然后通知这个机器上的 Agent 把应用压缩包下载下来启动。
  • Docker 镜像是直接由一个完整操作系统的所有文件和目录构成的,所以这个压缩包里的内容跟你本地开发和测试环境用的操作系统是完全一样的。这种机制直接打包了应用运行所需要的整个操作系统,从而保证了本地环境和云端环境的高度一致,避免了用户通过「试错」来匹配两种不同运行环境之间差异的痛苦过程。
  • 「编排」(Orchestration)在云计算行业里不算是新词汇,它主要是指用户如何通过某些工具或者配置来完成一组虚拟机以及关联资源的定义、配置、创建、删除等工作,然后由云计算平台按照这些指定的逻辑来完成的过程。容器时代,「编排」显然就是对 Docker 容器的一系列定义、配置和创建动作的管理。
  • 大数据所关注的计算密集型离线业务,其实并不像常规的 Web 服务那样适合用容器进行托管和扩容,也没有对应用打包的强烈需求,所以 Hadoop、Spark 等项目到现在也没在容器技术上投下更大的赌注。
  • Mesosphere 公司发布了一个名为 Marathon 的项目,而这个项目很快就成为了 Docker Swarm 的一个有力竞争对手。Mesos 社区却拥有一个独特的竞争力:超大规模集群的管理经验。Mesos+Marathon 的组合实际上进化成了一个高度成熟的 PaaS 项目,同时还能很好地支持大数据业务。
  • Mesosphere 公司不失时机地提出了一个名叫「DC/OS」(数据中心操作系统)的口号和产品,旨在使用户能够像管理一台机器那样管理一个万级别的物理机集群,并且使用 Docker 容器在这个集群里自由地部署应用。这对很多大型企业来说具有着非同寻常的吸引力。
  • 2015 年 6 月 22 日,由 Docker 公司牵头,CoreOS、Google、RedHat 等公司共同宣布,Docker 公司将 Libcontainer 捐出,并改名为 RunC 项目,交由一个完全中立的基金会管理,然后以 RunC 为依据,大家共同制定一套容器和镜像的标准和规范。这套标准和规范,就是 OCI( Open Container Initiative )。OCI 的提出,意在将容器运行时和镜像的实现从 Docker 项目中完全剥离出来。这样做,一方面可以改善 Docker 公司在容器技术上一家独大的现状,另一方面也为其他玩家不依赖于 Docker 项目构建各自的平台层能力提供了可能。尽管 Docker 是 OCI 的发起者和创始成员,它却很少在 OCI 的技术推进和标准制定等事务上扮演关键角色,也没有动力去积极地推进这些所谓的标准。这也正是迄今为止 OCI 组织效率持续低下的根本原因。
  • Kubernetes 项目的基础特性,并不是几个工程师突然「拍脑袋」想出来的东西,而是 Google 公司在容器化基础设施领域多年来实践经验的沉淀与升华。这,正是 Kubernetes 项目能够从一开始就避免同 Swarm 和 Mesos 社区同质化的重要手段。
  • Mesos 社区与容器技术的关系,更像是「借势」,而不是这个领域真正的参与者和领导者,加上它所属的 Apache 社区固有的封闭性,导致了 Mesos 社区虽然技术最为成熟,却在容器编排领域鲜有创新。
  • Kubernetes 项目让人耳目一新的设计理念和号召力,很快就构建出了一个与众不同的容器编排与管理的生态。
  • 从 API 到容器运行时的每一层,Kubernetes 项目都为开发者暴露出了可以扩展的插件机制,鼓励用户通过代码的方式介入 Kubernetes 项目的每一个阶段。Kubernetes 项目的这个变革的效果立竿见影,很快在整个容器社区中催生出了大量的、基于 Kubernetes API 和扩展接口的二次创新工作。

容器基础

  • 容器其实是一种沙盒技术,应用与应用之间因为有了边界而不至于相互干扰。
  • 容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个「边界」。
  • 对于 Docker 等大多数 Linux 容器来说,Cgroups 技术是用来制造约束的主要手段,而 Namespace 技术则是用来修改进程视图的主要方法。

容器隔离

  • 在 Linux 系统中创建进程的系统调用是 clone(),比如:

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

    该系统调用就会创建一个新的进程,并且返回它的进程号 PID。在执行 clone() 系统调用创建一个新进程时,可以指定 CLONE_NEWPID 参数。这样创建的进程将会「看到」一个全新的进程空间,在这个进程空间里,它的 PID 是 1。在宿主机真实的进程空间里,这个进程的 PID 还是真实的数值,比如 100:

    1
    
    int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL); 
    
  • 多次执行上面的 clone() 调用,这样就会创建多个 PID Namespace,而每个 Namespace 里的应用进程,都会认为自己是当前容器里的第 1 号进程,它们既看不到宿主机里真正的进程空间,也看不到其他 PID Namespace 里的具体情况。除了 PID Namespace,Linux 操作系统还提供了 Mount、UTS、IPC、Network 和 User 这些 Namespace,用来对各种不同的进程上下文进行「障眼法」操作。

    • eg.Mount Namespace:用于让被隔离进程只看到当前 Namespace 里的挂载点信息。
    • eg.Network Namespace:用于让被隔离进程看到当前 Namespace 里的网络设备和配置。
  • Docker 容器实际上是在创建容器进程时,指定了这个进程所需要启用的一组 Namespace 参数。这样,容器就只能「看」到当前 Namespace 所限定的资源、文件、设备、状态,或者配置。而对于宿主机以及其他不相关的程序,它就完全看不到了。跟真实存在的虚拟机不同,在使用 Docker 的时候,并没有一个真正的「Docker 容器」运行在宿主机里面。Docker 项目帮助用户启动的,还是原来的应用进程,只不过在创建这些进程时,Docker 为它们加上了各种各样的 Namespace 参数。这些进程会觉得自己是各自 PID Namespace 里的第 1 号进程,只能看到各自 Mount Namespace 里挂载的目录和文件,只能访问到各自 Network Namespace 里的网络设备,就仿佛运行在一个个「容器」里面,与世隔绝。

  • 用户运行在容器里的应用进程,跟宿主机上的其他进程一样,都由宿主机操作系统统一管理,只不过这些被隔离的进程拥有额外设置过的 Namespace 参数。容器并不直接运行在 Docker 上,Docker 只是辅助建立隔离环境,让容器基于 Linux 操作系统运行。Docker 项目在这里扮演的角色,更多的是旁路式的辅助和管理工作。

  • 容器优点:

    • 使用虚拟化技术作为应用沙盒,就必须要由 Hypervisor 来负责创建虚拟机,这个虚拟机是真实存在的,并且它里面必须运行一个完整的 Guest OS 才能执行用户的应用进程。这就不可避免地带来了额外的资源消耗和占用。相比之下,容器化后的用户应用,却依然还是一个宿主机上的普通进程,这就意味着这些因为虚拟化而带来的性能损耗都是不存在的;
    • 使用 Namespace 作为隔离手段的容器并不需要单独的 Guest OS,这就使得容器额外的资源占用几乎可以忽略不计。
  • 容器缺点:

    • 容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。eg. 无法在宿主机中运行其他版本内核的系统容器
    • 在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的。eg. 如果你的容器中的程序使用 settimeofday(2) 系统调用修改了时间,整个宿主机的时间都会被随之修改
    • 容器给应用暴露出来的攻击面是相当大的,应用「越狱」的难度自然也比虚拟机低得多。
    • /proc 文件系统并不知道用户通过 Cgroups 给这个容器做了什么样的资源限制,即:/proc 文件系统不了解 Cgroups 限制的存在。在生产环境中,这个问题必须进行修正,否则应用程序在容器里读取到的 CPU 核数、可用内存等信息都是宿主机上的数据,这会给应用的运行带来非常大的困惑和风险。这也是在企业中,容器化应用碰到的一个常见问题,也是容器相较于虚拟机另一个不尽如人意的地方。

容器限制

  • Linux 操作系统内核之中为资源隔离提供了三种技术:
    • namespace:2002 年从 Linux 2.4.19 开始出现,用来创建独立的文件系统、主机名、进程号、网络等资源空间,实现系统全局资源和进程局部资源的隔离。
    • cgroup:2008 年从 Linux 2.6.24 开始出现,它的全称是 Linux Control Group,用来实现对进程的 CPU、内存等资源的优先级和配额限制。
    • chroot:在 1979 年的 UNIX V7 就已经出现了,它可以更改进程的根目录,也就是限制访问文件系统。
  • 对 Docker 项目来说,它最核心的原理实际上就是创建一个用户进程:
    • 启用 Linux Namespace 配置。
    • 设置指定的 Cgroups,Linux Control Group) 参数。
    • 切换进程的根目录(chroot,Change Root)。
  • 容器是一个创建时指定了 namespace 的进程,并且启动参数里设置了 cpu、内存的限制,使用的文件系统目录也被提前设置,挂载到了其他的目录(这个就是镜像)。所以容器没有像虚拟机一样虚拟出内核等,容器使用的是宿主机的内核。

cgroup

  • 一个正在运行的 Docker 容器,其实就是一个启用了多个 Linux Namespace 的应用进程,而这个进程能够使用的资源量,则受 Cgroups 配置的限制。这也是容器技术中一个非常重要的概念,即:容器是一个「单进程」模型

  • Linux Cgroups 的全称是 Linux Control Group,它是 Linux 内核中用来为进程设置资源限制的一个重要功能。其主要作用是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。

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

  • 用户执行 docker run 时,可以通过指定参数来控制对应的资源文件配额:

    1
    
    $ docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash
    

chroot

  • chroot (change root file system) 可以改变进程的根目录到你指定的位置。对于被 chroot 的进程来说,它并不会感受到自己的根目录已经被「修改」成 $HOME/test 了。

namespace

  • Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是 Linux 操作系统里的第一个 Namespace。
  • Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效。
  • 在容器进程启动之前重新挂载它的整个根目录「/」。而由于 Mount Namespace 的存在,这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了。

容器原理

  • Docker 使用包括但不限于以下文件系统进行目录联合挂载:

    • overlayfs 为目前最新的 docker 版本中 ubuntu 和 centos 系统默认使用的文件系统。
    • AuFS 常用于 Ubuntu。
    • device mapper 常用于 CentOS。
    • btrfs 常用于 SUSE。
    • vfs 常用于 solaris 系统。
    • zfs 常用于 solaris 系统。
  • Union File System 也叫 UnionFS,最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。

  • AuFSAdvance UnionFS)是对 Linux 原生 UnionFS 的重写和改进。

  • 为了删除操作,AuFS 会在可读写层创建一个 whiteout 文件,把只读层里的文件「遮挡」起来。实际上是在可读写层创建一个名为。wh.xxx 的文件。这样,当这两个层被联合挂载之后,该文件就会被。wh.xxx 遮挡起来,消失了。这个功能就是「ro+wh」的挂载方式,即只读+whiteout 的含义。

  • docker exec 的实现原理是一个名叫 setns() 的 Linux 系统调用。下例展示的是通过 open() 系统调用打开了指定的 Namespace 文件,并把这个文件的描述符 fd 交给 setns() 使用。在 setns() 执行后,当前进程就加入了这个文件对应的 Linux Namespace 当中了。

    1
    
    fd = open(argv[1], O_RDONLY); if (setns(fd, 0) == -1) { errExit("setns"); }
    
  • 我们通常会在容器的根目录下挂载一个完整操作系统的文件系统,比如 Ubuntu16.04 的 ISO。容器启动之后在容器里执行 ls / 可以查看根目录下的内容,即 Ubuntu 16.04 的所有目录和文件。而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的「容器镜像」,也叫作:rootfs(根文件系统)。

  • 当容器进程被创建之后,尽管开启了 Mount Namespace,但是在它执行 chroot(或者 pivot_root)之前,容器进程一直可以看到宿主机上的整个文件系统。所以只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作就完成了。由于执行这个挂载操作时,「容器进程」已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。宿主机是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。

  • Volume 使用到的挂载技术,就是 Linux 的绑定挂载(bind mount)机制。它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。

  • 容器的镜像操作,比如 docker commit,都是发生在宿主机空间的。而由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的 /test 目录(/var/lib/docker/aufs/mnt/[可读写层 ID]/test)始终是空的。不过,由于 Docker 一开始还是要创建 /test 这个目录作为挂载点,所以执行了 docker commit 之后,你会发现新产生的镜像里,会多出来一个空的 /test 目录。毕竟,新建目录操作,又不是挂载操作,Mount Namespace 对它可起不到「障眼法」的作用。即宿主机执行 docker commit 不会把挂载过的目录打包,因为这个目录在宿主机上来看,就是一个空文件夹

Kubernetes 的本质

  • 一个「容器」,实际上是一个由 Linux Namespace、Linux Cgroups 和 rootfs 三种技术构建出来的进程的隔离环境。
  • 容器可以分为两个部分:
    • 一组联合挂载在 /var/lib/docker/aufs/mnt 上的 rootfs,称为「容器镜像」(Container Image),是容器的静态视图
    • 一个由 Namespace+Cgroups 构成的隔离环境,称为「容器运行时」(Container Runtime),是容器的动态视图
  • Kubernetes 项目最主要的设计思想是从更宏观的角度,以统一的方式来定义任务之间的各种关系,并且为将来支持更多种类的关系留有余地。
  • 在常规环境下,这些应用往往会被直接部署在同一台机器上,通过 Localhost 通信,通过本地磁盘目录交换文件。而在 Kubernetes 项目中,这些容器则会被划分为一个「Pod」,Pod 里的容器共享同一个 Network Namespace、同一组数据卷,从而达到高效率交换信息的目的。
  • Kubernetes 给 Pod 绑定一个 Service 服务,而 Service 服务声明的 IP 地址等信息是「终生不变」的。这个 Service 服务的主要作用,就是作为 Pod 的代理入口(Portal),从而代替 Pod 对外暴露一个固定的网络地址。
  • Kubernetes 项目提供了一种叫作 Secret 的对象,它其实是一个保存在 Etcd 里的键值对数据。把 Credential 信息以 Secret 的方式存在 Etcd 里,Kubernetes 就会在指定的 Pod(比如,Web 应用的 Pod)启动时,自动把 Secret 里的数据以 Volume 的方式挂载到容器里。Web 应用可以通过这样的方式实现数据库的访问。
  • Kubernetes 定义了新的、基于 Pod 改进后的对象,如:
    • Job,用来描述一次性运行的 Pod(比如,大数据任务)
    • DaemonSet,用来描述每个宿主机上必须且只能运行一个副本的守护进程服务;
    • CronJob,则用于描述定时任务等等。
  • Kubernetes 项目推崇的使用方法是所谓的「声明式 API」。这种 API 对应的「编排对象」和「服务对象」,都是 Kubernetes 项目中的 API 对象(API Object)。这是 Kubernetes 最核心的设计理念:
    • 通过一个「编排对象」,比如 Pod、Job、CronJob 等,来描述你试图管理的应用;
    • 为这些「编排对象」定义一些「服务对象」,比如 Service、Secret、Horizontal Pod Autoscaler(自动水平扩展器)等,从而实现具体的平台级功能。

Kubernetes 配置

示例:定义一个符合下属条件的 Pod:

  • 容器副本个数 (spec.replicas) 为 2
  • Pod 里只有一个容器镜像(spec.containers.image)为 nginx:1.7.9
  • 容器监听端口(containerPort)是 80
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.8
        ports:
        - containerPort: 80
        volumeMounts:
        - mountPath: "/usr/share/nginx/html"
          name: nginx-vol
      volumes:
      - name: nginx-vol
        emptyDir: {}
  • Pod 就是 Kubernetes 世界里的「应用」;而一个应用,可以由多个容器组成。

  • 使用一种 API 对象(eg. Deployment)管理另一种 API 对象(eg. Pod)的方法,在 Kubernetes 中,叫作「控制器」模式(controller pattern)。

  • 每一个 API 对象都有一个叫作 Metadata 的字段,这个字段就是 API 对象的「标识」,即元数据,它也是我们从 Kubernetes 里找到这个对象的主要依据。这其中最主要使用到的字段是 Labels。

  • Labels 就是一组 key-value 格式的标签。而像 Deployment 这样的控制器对象,就可以通过这个 Labels 字段从 Kubernetes 中过滤出它所关心的被控制对象。比如,在上面这个 YAML 文件中,Deployment 会把所有正在运行的、携带 app: nginx 标签的 Pod 识别为被管理的对象,并确保这些 Pod 的总数严格等于两个。过滤规则定义在 Deployment 的 spec.selector.matchLabels 字段。一般称之为:Label Selector。

  • 与 Labels 格式、层级完全相同的字段叫 Annotations,它专门用来携带 key-value 格式的内部信息。所谓内部信息,指的是对这些信息感兴趣的,是 Kubernetes 组件本身,而不是用户。

  • 一个 Kubernetes 的 API 对象的定义,大多可以分为 Metadata 和 Spec 两个部分。前者存放的是这个对象的元数据,对所有 API 对象来说,这一部分的字段和格式基本上是一样的;而后者存放的,则是属于这个对象独有的定义,用来描述它所要表达的功能。

  • 使用 kubectl apply 命令,来统一进行 Kubernetes 对象的创建和更新操作。这样的操作方法,是 Kubernetes「声明式 API」所推荐的使用方法。Kubernetes 会根据 YAML 文件的内容变化自动进行具体的处理。用户只需围绕着可以版本化管理的 YAML 文件,而不是「行踪不定」的命令行进行协作,从而大大降低开发人员和运维人员之间的沟通成本:

    1
    
    $ kubectl apply -f nginx-deployment.yaml
    
  • Kubernetes 项目通过这些 YAML 文件,就保证了应用的「部署参数」在开发与部署环境中的一致性。而当应用本身发生变化时,开发人员和运维人员可以依靠容器镜像来进行同步;当应用部署参数发生变化时,这些 YAML 文件就是他们相互沟通和信任的媒介。

updatedupdated2022-10-302022-10-30