0.1. PaaS 0.2. Docker 0.2.1. Docker Image 0.2.2. Docker Swarm 0.2.3. Docker Compose 0.3. 基于Kubernetes API 和扩展接口 从过去物理机和虚拟机为主体的开发运维环境,向以容器为核心的基础设施的转变过程,并不是一次温和的改革,而是涵盖了对网络、存储、调度、操作系统、分布式原理等各个方面的容器化理解和改造。 关于Linux内核、分布式系统、网络、存储等是真正掌握容器技术体系的精髓所在。 容器技术基础:梳理容器技术生态的发展脉络,描述容器技术底层的实现方式 Kubernetes集群搭建与实践:明白kubernetes集群的架构和原理,选择正确的工具和方法,可以“一键搭建” 容器编排与Kubernetes核心特性剖析:“编排”容器都是容器云项目的灵魂所在,也是kubernetes社区持久生命力的源泉 从分布式系统设计的视角出发,抽象和归纳出这些特性中体现出来的普遍方法,带着这些指导思想逐一阐述kubernetes项目关于编排、调度和作业管理的各项核心特性 kubernetes开源社区与生态:“开源生态”永远都是容器技术和kubernetes项目成功的关键 0.1. PaaS 提供应用托管能力:租用一批AWS或OpenStack的虚拟机,然后就像管理物理服务器那样,用脚本或手工方式在这些机器上部署应用 当时的云计算项目,比的就是谁能更好地模拟本地服务器环境,能带来更好的上云体验 PaaS开源项目的出现,就是解决这个问题的最佳方案,核心是一套应用的打包和分发机制。 用户把应用的可执行文件和启动脚本打进一个压缩包,上传到云上存储 开源PaaS项目会通过调度器选择一个可以运行这个应用的虚拟机,然后通知机器上的Agent把应用的压缩包下载下来启动 需要在同一个虚拟机上启动很多个来自不同用户的应用,PaaS开源项目会调用操作系统的Cgroup和Namespace机制为每个应用单独创建一个沙盒的隔离环境,然后在沙盒中启动这些应用,实现了把多个用户的应用互不干涉地在虚拟机里批量地、自动地运行起来的目的。 这正是PaaS项目最核心的能力,运行应用的隔离环境,或者说沙盒,就是所谓的容器。 0.2. Docker Docker项目与大部分PaaS项目的功能和实现原理是一样的,剩下不同的一小部分使得Docker成为无敌的存在: 0.2.1. Docker Image PaaS能够帮助用户大规模部署应用到集群里,是因为它提供了一套应用打包的功能,也是这个功能让他凉了。用户需要为不同的语言、框架,甚至每个版本的应用都维护一个打好的包,并且打好的包在本地能运行上了云可能要修改很多参数和配置。虽然PaaS项目能够一键部署,但是为每个应用打包的工作太累。 Docker镜像解决的就是打包问题。 所谓镜像就是一个压缩包,里面包含的内容比PaaS的应用可执行文件 + 启停脚本的组合要丰富 大多数Docker镜像是直接由一个完整的操作系统的所有文件和目录构成,所以这个压缩包里的内容和本地的开发测试环境用的操作系统是完全一样的 这是Docker镜像最厉害的地方,只要有了这个压缩包,在使用某种技术创建一个沙盒,在沙盒中解压这个包,就可以运行本地开发的程序了。这个过程中完全不需要修改任何的配置,这个压缩包使得本地环境和云环境高度一致。 0.2.2. Docker Swarm 镜像解决了应用打包的难题,还需要有组件能够完成大规模应用部署的职责。 Docker Swarm,原生的容器集群管理项目。 0.2.3. Docker Compose 由Fig项目改名而来。 编排指用户如何通过某些工具或者配置来完成一组虚拟机以及关联资源的定义、配置、创建、删除等工作,然后由云计算平台按照这些指定的逻辑来完成的过程。 容器时代、编排就是对Docker容器的一系列定义、配置和创建动作的管理。 0.3. 基于Kubernetes API 和扩展接口 目前热度极高的微服务治理项目 Istio 被广泛采用的有状态应用部署框架 Operator 还有像 Rook 这样的开源创业项目,它通过kubernetes的可扩展接口,把ceph这样重量级产品封装成了简单易用的容器存储插件 Kubernetes社区以开发者为核心,构建一个相对民主和开放的容器生态。
0.1. 进程 0.1.1. Namespace 0.2. 隔离与限制 0.2.1. 优势 0.2.2. 缺点 0.3. Cgroups(容器的限制) 0.3.1. 例子 0.3.2. 不足 容器技术的兴起源于PaaS技术的普及 Docker项目具有里程碑式的意义 Docker项目通过“容器镜像”,解决了应用打包这个根本性难题 容器本身没有价值,有价值的是“容器编排"。 0.1. 进程 Docker 容器单进程是只有一个进程是可控的(回收与生命周期管理),不是只能运行一个进程,其他运行的进程是不受docker控制的。 数据 + 代码本身的二进制文件,放在磁盘上,就是我们平常说的一个“程序”,也叫可执行镜像。 计算机运行程序的过程: 操作系统从“程序”中发现输入数据保存在一个文件中,这些数据被加载到内存中待命 操作系统又读取到代码表示的计算指令,指示CPU完成相应操作 CPU与内存协作进行运算,需要使用寄存器存放数值,内存堆栈保存执行的命令和变量 计算机中被打开的文件,各种各样的I/O设备在不断调用中修改自己的状态 “程序”被执行起来,从磁盘上的二进制文件,变成了计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备状态信息的一个集合。这样一个程序运行起来后的计算机执行环节的总和,就是进程。 进程: 静态表现:安安静静待在磁盘上的程序 动态表现:计算机里数据和状态的总和 容器技术的核心功能,就是通过约束和修改进程的动态变化,从而为其创造出一个“边界”。 对于Docker等大多数Linux容器来说: Cgroups技术是用来制作约束的主要手段 Namespace技术则是用来修改进程视图的主要方法 0.1.1. Namespace Namespace其实只是Linux创建新进程的一个可选参数,在Linux系统中创建进程的系统调用是clone(),如下: int pid = clone(main_function, stack_size, SIGCHLD, NULL); 这个系统调用创建一个新的进程,并返回它的进程号pid。 当调用clone()系统调用创建一个新进程时,可以在参数中指定CLONE_NEWPID参数,比如: int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL); 这时,新创建的这个进程会“看到”一个全新的进程空间,这个进程空间里,它的PID是1。但是在宿主机真实的进程空间里,这个进程的PID还是真实的数值。 通过多次执行clone()调用,可以创建多个PID Namespace,每个Namespace里的进程,都认为自己是当前容器的第1号进程,它即看不到宿主机里面真真的进程空间,也看不到其他PID Namespace里的具体情况。 除了PID Namespace,Linux操作系统还通过了Mount、UTS、IPC、Network和User这些Namespace,用来对各种进程上下文进行隔离操作。 比如: Mount Namespace:用来让被隔离进程只看到当前Namespace里的挂载点信息 Network Namespace:用来让被隔离的进程只看到当前Namespace里的网络设备和配置 这是Linux容器最基本的实现原理。容器其实是一种特殊的进程而已。 Docker容器实际上是在创建进程时,指定这个进程所需启用的一组Namespace参数,这样容器就只能看到当前Namespace所限定的资源、文件、设备、状态、配置。对于宿主机以及其他不相关的程序,它们完全看不到。 与真实存在的虚拟机不同,在使用Docker的时候,并没有一个真正的“Docker容器”运行在宿主机里面,Docker项目帮助用户启动的,还是原来的进程,只不过在创建这个进程时,Docker为它们加上了各种各样的Namespace参数。 此时,这些进程就会觉得自己是各自PID Namespace里的第1号进程,只能看到各自Mount Namespace里挂在的目录和文件,只能访问各自Network Namespace里的网络设备,就仿佛运行在一个个“容器”里面,与世隔绝。 0.2. 隔离与限制 Namespace技术实际上修改了应用进程看待整个计算机的“视图”,即它的“视线”被操作系统做了限制,只能看到某些指定的内容,但是对于宿主机来说,这些被隔离的进程和其他进程没有太大区别。 Docker Engine或者任何容器管理工具,并不对应用进程的隔离环境负责,也不会创建任何实体的“容器”,真正对隔离环境负责的是宿主机操作系统本身。 使用虚拟化技术作为应用沙盒,就必须要由Hypervisor来负责创建虚拟机,并且它里面必须运行一个完整的Guest OS才能执行用户的应用进程。这不可避免地带来了额外的资源消耗和占用。 根据实验,一个运行着Centos的KVM虚拟机启动后,在不作优化的情况下,虚拟机自己需要占用200M左右内存。此外,用户应用运行在虚拟机里面,它对宿主机操作系统的调用就不可避免地要经过虚拟化软件的拦截和处理,这本身又是一层性能损耗,尤其对计算资源、网络和磁盘I/O的损耗非常大。 相比之下,容器化后的用户应用,却依然还是一个宿主机上的普通进程,这就意味着这些因为虚拟化而带来的性能损耗都是不存在的;另一方面,使用Namespace作为隔离手段的容器并不需要单独的Guest OS,这就使得容器额外的资源占用可以忽略不计。 0.2.1. 优势 敏捷和高性能是容器相较于虚拟机最大的优势,也是它能够在PaaS这种更细粒度的资源管理平台上大行其道的重要原因。 0.2.2. 缺点 基于Linux Namespace的隔离机制,隔离的不彻底。 容器只是运行在宿主机上的特殊进程,多个容器之间使用的还是同一个宿主机的操作系统和内核。 虽然在容器内通过Mount Namespace单独挂载不同版本的操作系统文件,但是不能改变共享宿主机内核的事实。如果要在Windows宿主机上运行Linux容器,如果要在低版本的Linux宿主机上运行高版本的Linux容器,都是行不通的。 Linux内核中,有很多资源和对象是不能被Namespace化的,最典型的例子就是时间。 相比于在虚拟机内可以随便折腾的自由度,在容器里部署应用的时候,“什么能做,什么不能做”,就是用户必须考虑的问题。如果容器中的程序使用settimeofday(2)系统调用修改了时间,整个宿主机的时间都会被随之修改。这显然不符合预期。 共享内核的事实,容器给应用暴露出来的攻击面是相当大的,应用“越狱”的难度自然也比虚拟机低得多。 尽管在实践中可以使用Seccomp等技术,对容器内部发起的所有系统调用进行过滤和甄别来进行安全加固,但是这种方法因为多了一层对系统调用的过滤,一定会拖累容器的性能。默认情况下,不知道该开启哪些系统调用,禁止哪些系统调用。 基于虚拟化或者独立内核技术的容器实现,可以比较好地在隔离与性能之间作出平衡。 0.3. Cgroups(容器的限制) 虽然容器内的进程被隔离在容器内部,但是在宿主机上依然和其他的进程之间是平等的竞争关系。 虽然表面上被隔离,但是所能够使用的资源(cpu、内存),却是可以随时被宿主机上的其他进程(其他容器)占用的。也可能一个容器把所有资源吃光,这些情况显然都不是一个沙盒应该表现出的合理情况。 Linux Cgroups 主要作用: 限制一个进程能够使用的资源上限,包括CPU、内存、磁盘、网络带宽等。 对进程设置优先级、审计、将进程挂起和恢复等。 在Linux中,Cgroups给用户暴露出来的操作接口是文件系统,即它以文件系统和目录的方式组织在操作系统的/sys/fs/cgroup路径下。 使用mount命令展示出来如下: [root@tdc-01 cgroup]# mount -t cgroup cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd) cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb) cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset) cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct,cpu) cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio) cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event) cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices) cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer) cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory) cgroup on /sys/fs/cgroup/net_cls type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls) 它输出的是一系列文件系统目录。 在/sys/fs/cgroup下面有很多诸如cpuset、cpu、memory这样的子目录,也叫子系统。 这些都是这台机器当前可以被Cgroups进行限制的资源种类。在子系统对应的资源种类下,就可以看到该类资具体可以被限制的方法。 如对CPU子系统来说,可以看到如下几个配置文件: [root@tdc-01 cgroup]# ls /sys/fs/cgroup/cpu cgroup.clone_children cgroup.procs cpuacct.stat cpuacct.usage_percpu cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat machine.slice release_agent tasks cgroup.event_control cgroup.sane_behavior cpuacct.usage cpu.cfs_period_us cpu.rt_period_us cpu.shares docker notify_on_release system.slice user.slice 在这些输出中,如cpu.cfs_period_us,cpu.cfs_quota_us这样的关键词,这两个参数需要组合使用,可以用来限制进程在长度为cpu.cfs_period_us的一段时间内,只能被分配到总量为cpu.cfs_quota_us的CPU时间。 具体要如何使用这样的配置文件呢? 在对应的子系统下创建一个目录,这个目录成为一个“控制组”,系统会在新创建的目录下,自动生成该子系统对应的资源限制文件。 root@ubuntu:/sys/fs/cgroup/cpu$ mkdir container root@ubuntu:/sys/fs/cgroup/cpu$ ls container/ 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 0.3.1. 例子 执行如下进程,是一个死循环,将CPU占用到100%。 while : ; do : ; done & 用top指令查看CPU使用。 top - 14:33:24 up 28 days, 4:10, 1 user, load average: 4.50, 3.57, 3.00 Tasks: 1248 total, 2 running, 1246 sleeping, 0 stopped, 0 zombie %Cpu(s): 14.4 us, 2.5 sy, 0.0 ni, 82.3 id, 0.5 wa, 0.0 hi, 0.3 si, 0.0 st KiB Mem : 13174787+total, 43071160 free, 30426240 used, 58250476 buff/cache KiB Swap: 33554428 total, 33554428 free, 0 used. 99608064 avail Mem PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 4517 root 20 0 116372 1556 332 R 100.0 0.0 7:54.13 bash 在输出里可以看到目标进程4517,CPU 的使用率已经 100% 了(%Cpu:100.0us)。 查看新创建的控制组中的配置文件,cpu quota没有任何限制(即-1),cpu period默认为100ms(100000us)。 [root@tdc-01 test]# cat /sys/fs/cgroup/cpu/test/cpu.cfs_period_us 100000 [root@tdc-01 test]# cat /sys/fs/cgroup/cpu/test/cpu.cfs_quota_us -1 通过修改这些文件的内容来设置限制。 向cfs_quota文件中写入20ms(20000us)。 echo 20000 > /sys/fs/cgroup/cpu/test/cpu.cfs_quota_us 表示在每个100ms的时间里,被改控制组限制的进程只能使用20ms的cpu时间,也就是说这个进程只能使用20%的cpu带宽。 把需要被限制的进程PID写入tasks文件中。 echo 226 > /sys/fs/cgroup/cpu/container/tasks 通过top命令再次查看。 Tasks: 1245 total, 2 running, 1243 sleeping, 0 stopped, 0 zombie %Cpu(s): 7.0 us, 1.4 sy, 0.0 ni, 91.2 id, 0.0 wa, 0.0 hi, 0.3 si, 0.0 st KiB Mem : 13174787+total, 43206112 free, 30265896 used, 58275868 buff/cache KiB Swap: 33554428 total, 33554428 free, 0 used. 99768544 avail Mem PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 4517 root 20 0 116372 1556 332 R 19.7 0.0 9:05.19 bash 从输出可以看到目标进程4517的cpu使用降到了20%。 除了CPU子系统外,Cgroups的每一项子系统都有多个资源限制能力,比如: blkio:块设备I/O限制,一般用于磁盘等设备 cpuset:为进程分配单独的CPU核和对应的内存节点 memory:为进程设定内存使用的限制 Linux Cgroups是一个子系统目录加上一组资源限制文件的组合,对于Docker等Linux容器项目来说,需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的PID填写到对应控制组的tasks文件中。 在控制组中的资源文件里面填写的值,就是执行docker run时设定的参数,比如: docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash 在这个容器启动后,查看Cgroups文件系统下,CPU子系统中,docker这个控制组的资源限定文件的内容来确认: cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_period_us 100000 $ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_quota_us 20000 每个容器在docker这个控制组下有对应的容器ID的子目录。 一个正在运行的Docker容器,其实就是一个启用了多个Linux Namespace的应用进程,这个进程能够使用的资源量,受Cgroups配置的限制。 这也是容器技术中一个非常重要的概念:容器是一个“单进程”模型。 由于一个容器的本质就是一个进程,用户的应用进程实际上就是容器里PID=1的进程,也是其他后续创建的所有进程的父进程。这意味着,在一个容器中,没办法同时运行两个不同的应用,除非能事先找到一个公共的PID=1的程序充当两个不同应用的父进程,这也是为什么很多人会用systemd或者supervisord这样的软件来代替应用本身作为容器的启动进程。 0.3.2. 不足 Cgroups对资源的限制能力也有很多不完善的地方,被提及最多的自然是/proc文件系统的问题。 Linux下的/proc目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如CPU的使用情况和内存占用率等,这些文件也是top指令查看系统信息的主要数据来源。 如果在容器中执行top命令,查看到的是宿主机的cpu和内存数据,而不是当前容器的数据。 造成这个问题的原因就是/proc文件系统并不知道用户通过Cgroups给这个容器做了什么样的资源限制,即/proc文件系统不了解Cgroups限制的存在。 在生产环境中,这个问题必须进行修正,否则应用程序在容器里读取的CPU核数、可用内存等信息都是宿主机上的数据,这会给应用的运行带来非常大的困惑和风险,可以利用lxcfs,提升容器资源可见性。
0.1. 概念 0.2. 典型示例 0.3. 容器镜像 0.4. 联合文件系统 0.4.1. 示例 0.4.2. Docker中使用Union File System 0.4.2.1. AuFS 0.4.2.2. 样例 0.4.2.2.1. 第一部分 只读层 0.4.2.2.2. 第二部分 可读可写层 0.4.2.2.3. 第三部分 Init层 0.5. 总结 0.5.1. copy-on-write 0.5.2. Docker 支持的UnionFS 0.1. 概念 Linux容器最基础的两种技术: Namespace:作用“隔离”,让应用进程只能看到该Namespace内的世界 Cgroups:作用“限制”,给上面的世界围上一圈看不见的墙 容器的本质是一种特殊的进程。 被以上两种技术装进了一个被隔离的空间中。这个空间就是PaaS项目赖以生存的应用“沙盒”。 在这个空间中,虽然四周有墙,但是如果容器进程低头一看地面,会是什么样的景象?换句话说,容器里的进程看到的文件系统又是什么样子的? 这是一个关于Mount Namespace的问题:容器里的应用进程,理应看到一份完全独立的文件系统。这样,它就可以在自己的容器目录(比如/tmp)下进行操作,而完全不会受到宿主机以及其他容器的影响。 以下程序作用,在创建子进程时开启指定的Namespace: #define _GNU_SOURCE #include <sys/mount.h>#include <sys/types.h>#include <sys/wait.h>#include <stdio.h>#include <sched.h>#include <signal.h>#include <unistd.h>#define STACK_SIZE (1024 * 1024) static char container_stack[STACK_SIZE]; char* const container_args[] = { "/bin/bash", NULL }; int container_main(void* arg) { printf("Container - inside the container!\n"); execv(container_args[0], container_args); printf("Something's wrong!\n"); return 1; } int main() { printf("Parent - start a container!\n"); int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL); waitpid(container_pid, NULL, 0); printf("Parent - container stopped!\n"); return 0; } 在main函数里,通过clone()系统调用创建了一个新的子进程container_main,并且声明要为它启用Mount Namespace(即:CLONE_NEWNS标志) 在子进程中执行的是/bin/bash程序,也就是一个shell,这个shell就运行在了Mount Namespace的隔离环境中 在子进程中执行ls命令,查看到的还是宿主机的文件,即:即使开启了Mount Namespace,容器进程看到的文件系统还是跟宿主机完全一样。 因为,Mount Namespace修改的是容器进程对文件系统“挂载点”的认知。但是,这就意味着,只有在“挂载”这个操作发生之后,进程的视图才会被改变,而在此之前,新创建的容器会直接继承宿主机的各个挂载点。 因此,创建新进程时,除了声明要启用Mount Namespace之外,还要告诉容器进程,有哪些目录需要重新挂载,比如/tmp目录。在容器进程执行前可以添加一步重新挂载/tmp目录的操作: int container_main(void* arg) { printf("Container - inside the container!\n"); // 如果你的机器的根目录的挂载类型是 shared,那必须先重新挂载根目录 // mount("", "/", NULL, MS_PRIVATE, ""); mount("none", "/tmp", "tmpfs", 0, ""); execv(container_args[0], container_args); printf("Something's wrong!\n"); return 1; } 在修改的代码里,在容器进程启动之前,加上mount("none", "/tmp", "tmpfs", 0, "");就是告诉容器以tmpfs(内存盘)格式,重新挂载/tmp目录 此时重新运行程序后查看/tmp目录会发现变成了一个空目录,这就意味着挂载生效了。 因为创建的新进程启用了Mount Namespace,所以这次挂载操作,只在容器进程的Mount Namespace中有效,在宿主机的挂载中查看不到上述挂载点。 Mount Namespace和其他Namespace的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载(mount)操作才能生效。 在创建新的容器的时候,重新挂载根目录“/”,即可实现,容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。 在Linux系统中,chroot命令可以方便的完成上述工作,“change root file system”,即改变进程的根目录到指定的位置。 0.2. 典型示例 现在有一个$home/test目录,想要把它作为一个/bin/bash进程的根目录。 创建test目录和相关的lib文件夹: mkdir -p $HOME/test mkdir -p $HOME/test/{bin,lib64,lib} cd $T 把bash命令拷贝到test目录对应的bin路径下: cp -v /bin/{bash,ls} $HOME/test/bin 把 bash 命令需要的所有 so 文件,也拷贝到 test目录对应的lib路径下。(找不到so文件可用ldd命令): T=$HOME/test list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')" for i in $list; do cp -v "$i" "${T}${i}"; done 执行chroot命令,告诉操作系统,将$HOME/test目录作为/bin/bash进程的根目录: chroot $HOME/test /bin/bash 此时执行 ls / 返回的都是$HOME/test目录下的内容,而不是宿主机的内容。 对于被chroot的进程来说,它不会感受到自己的根目录被修改了。 0.3. 容器镜像 为了让容器中的根目录看起来更加的真实,一般会在容器的根目录下挂载一个完整操作系统的文件系统,比如Ubuntu 16.04的ISO。这样在容器启动后,我们在容器里通过执行ls / 查看根目录下的内容,就是ubuntu 16.04的所有目录和文件。 这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”,它有个更专业的名字,叫rootfs(根文件系统) 一个最常见的rootfs会包含以下目录: ls / bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var 进入容器之后,执行的/bin/bash 就是rootfs的/bin目录下的可执行文件,与宿主机的/bin/bash完全不同。 对于Docker项目来说,最核心的原理实际上就是为待创建的用户进程: 启用Linux Namespace配置 设置指定的Cgroups参数 切换进程的根目录 这样,一个完整的容器就诞生了,不过Docker项目在最后一步会优先使用pivot_root系统调用,如果系统不支持,才使用chroot 注意:rootfs只是一个操作系统包含的文件、配置和目录,并不包括操作系统的内核。 在Linux系统中,操作系统内核和操作系统包含的文件、配置和目录是分开存放的,只有在开机启动的时候,操作系统才会加载指定版本的内核镜像。 rootfs只包含操作系统的躯壳,不包括操作系统的灵魂。同一台机器上的所有容器都共享宿主机操作系统的内核。 这就意味着,如果应用程序需要配置内核参数,加载额外的内核模块,以及跟内核进行直接的交互,这些操作和依赖的对象都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。 这是容器相比虚拟机的主要缺陷之一。 由于云端和本地服务器环境不同,应用的打包过程,一直是使用PaaS时最痛苦的一个步骤。但有了容器镜像(即rootfs)之后,这个问题就被优雅的解决了。rootfs的存在,保证了容器的一致性。 rootfs里打包的不只是应用,而是整个操作系统的文件和目录,即:应用以及它运行所需的依赖,都被封装在了一起。 容器镜像“打包操作系统”的能力,使得最基础的依赖环境也变成了应用沙盒的一部分,这就赋予了容器所谓的一致性。 对应用依赖的理解,不能局限于编程语言层面,对于一个应用来说,操作系统本身才是它所需要的最完整的“依赖库”。 无论是在本地还是云端,只要解压打包好的容器镜像,那么这个应用运行所需的完整的执行环境就被重现出来了。 这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和云端执行之间的鸿沟。 0.4. 联合文件系统 思考另一个问题,是否在每次开发或者升级应用的时候,都要重复制作一次rootfs? 既然这些修改都是基于一个旧的rootfs,以增量的方式去做修改 所有人都只维护相对于base rootfs修改的增量内容,而不是每次修改都制造一个“fork” Docker在镜像的设计中,引入了层(layer)的概念,用户在制作镜像的每一步操作,都会生成一个层,也就是增量的rootfs。 实现这个想法,使用了联合文件系统(Union File System)的能力。 0.4.1. 示例 UnionFS最主要的功能是将多个不同位置的目录联合挂载到同一个目录下。 两个目录A和B: tree . ├── A │ ├── a │ └── x └── B ├── b └── x 使用联合挂在的方式,将这两个目录挂载到一个公共的目录C: mkdir C mount -t aufs -o dirs=./A:./B none ./C 查看目录C的内容: tree ./C ./C ├── a ├── b └── x 在这个合并后的目录里,有a,b,x三个文件,并且x文件只有一份。这就是合并的含义,并且如果在目录C里对a,b,x文件做修改,这些修改也会在对应的目录A,B中生效。 0.4.2. Docker中使用Union File System 系统版本: Ubuntu 16.04 Docker CE 18.05 这对组合默认使用AuFS,可以使用docker info命令查看到这些信息。 0.4.2.1. AuFS AuFS 名字的进化过程: AnotherUnionFS-->AlternativeUnionFS AlternativeUnionFS-->AdvanceUnionFS 从名字可以发现: AuFS是对Linux原生UnionFS的重写和改进 AuFS没有进入Linux内核的主干,只在Ubuntu和Debian这些发行版中使用 对于AuFS来说,最关键的目录结构在 /var/lib/docker 路径下的 diff 目录: /var/lib/docker/aufs/diff/<layer_id> 通过下面的例子来学习该目录的作用。 0.4.2.2. 样例 启动容器: docker run -d ubuntu:latest sleep 3600 Docker会从Docker Hub上拉取一个Ubuntu镜像到本地。 这里所谓的“镜像”,实际上就是Ubuntu操作系统的rootfs,它的内容是Ubuntu操作系统的所有文件和目录。不过与上文提到的rootfs的差别在于,Docker镜像使用的rootfs,往往由多个“层”组成: docker image inspect ubuntu:latest ... "RootFS": { "Type": "layers", "Layers": [ "sha256:f49017d4d5ce9c0f544c...", "sha256:8f2b771487e9d6354080...", "sha256:ccd4d61916aaa2159429...", "sha256:c01d74f99de40e097c73...", "sha256:268a067217b5fe78e000..." ] } 该Ubuntu镜像实际上由5层组成,这5层就是5个增量rootfs,每一层都是Ubuntu操作系统文件和目录的一部分。 在使用镜像时,Docker会把这些增量联合挂载在一个统一的挂载点上(等价于上面例子中的目录c) 这个挂载点就是 /var/lib/docker/aufs/mnt,比如: /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e 不出意外,这个目录里面正是一个完整的Ubuntu操作系统: ls /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var 这5个镜像层,是如何被联合挂载成这样一个完整的Ubuntu文件系统呢? 这些信息记录在AuFS的系统目录 /sys/fs/aufs 下面。 通过查看AuFS的挂载信息,可以找到这个目录对应的AuFS的内部ID(也叫:si): cat /proc/mounts| grep aufs none /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fc... aufs rw,relatime,si=972c6d361e6b32ba,dio,dirperm1 0 0 即si=972c6d361e6b32ba 使用这个ID,就可以在 /sys/fs/aufs 目录下查看到被联合挂载在一起的各层的信息: 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 从这些信息可以看出: 镜像的层都是放置在 /var/lib/docker/aufs/diff 目录 然后被联合挂载在 /var/lib/docker/aufs/mnt 里面 从这个结构可以看出,这个容器的rootfs由下图所示的三部分组成: 0.4.2.2.1. 第一部分 只读层 它是这个容器的rootfs最下面的五层,对应的正是ubuntu:latest镜像的五层,它们的挂载方式都是只读的(ro+wh,即readonly+whiteout)。 查看每一层中的内容: ls /var/lib/docker/aufs/diff/72b0744e06247c7d0... etc sbin usr var ls /var/lib/docker/aufs/diff/32e8e20064858c0f2... run ls /var/lib/docker/aufs/diff/a524a729adadedb900... bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var 这些层都是以增量的方式包含Ubuntu操作系统的一部分。 0.4.2.2.2. 第二部分 可读可写层 它是这个容器的rootfs最上面的一层(6e3be5d2ecccae7cc),它的挂载方式为rw,即read write。在没有写入文件之前,这个目录是空的,而一旦在容器里做了写操作,修改产生的内容就会以增量的方式出现在这个层中。 为了实现删除文件的操作,AuFS在可读写层创建一个whiteout文件,把只读层里的文件“遮挡”起来。 比如要删除只读层中的foo文件,那么这个删除操作实际上是在可读写层创建了一个名为.wh.foo的文件。这样,当这两个层被联合挂载之后,foo文件就会被.wh.foo文件“遮挡”起来。这个功能就是“ro+wh”的挂载方式,即只读+witheout。 所以: 最上面的可读写层的作用就是专门用来存放修改rootfs后产生的增量,无论是增删改都发生在这里。 当使用完了这个修改过的容器之后,可以使用docker commit 和docker push指令,保存这个被修改过的可读写层,并上传到Docker Hub上。 与此同时,原先的只读层里的内容不会有任何变化。 这就是增量rootfs的好处。 0.4.2.2.3. 第三部分 Init层 它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init层是Docker项目单独生成的一个内部层,专门用来存放 /etc/hosts, /etc/resolv.conf 等信息。 需要这样一层的原因是,这些文件本来属于只读的Ubuntu镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值,比如hostname,所以需要在可读写层对它们进行修改。但是这些修改往往只能对当前容器生效,并不希望执行docker commit时,把这些信息连同可读写层一起提交。 所以Docker做法是,在修改了这些文件之后,以一个单独的层挂载出来,在用户执行docker commit时只会提交可读写层,所以是不包含这些信息的。 最终,这7层被联合挂载到/var/lib/docker/aufs/mnt目录下,表现为一个完整的Ubuntu操作系统供容器使用。 0.5. 总结 通过使用Mount Namespace和rootfs,容器就能够为进程构建出一个完善的文件系统隔离环境。这个功能的实现必须感谢 chroot 和 pivot_root 这两个系统调用切换进程根目录的能力。 在rootfs的基础上,Docker公司创新性地提出了使用多个增量rootfs联合挂载一个完整rootfs的方案,这就是容器镜像中 层 的概念。 容器镜像的发明,不仅打通了“开发--测试--部署”流程的每一个环节,更重要的是:容器镜像将会成为未来软件的主流发布方式。 0.5.1. copy-on-write 上面的读写层通常也称为容器层 下面的只读层称为镜像层 所有的增删查改操作都只会作用在容器层,相同的文件上层会覆盖掉下层。镜像文件的修改,比如修改一个文件的时候,首先会从上到下查找有没有这个文件,找到,就复制到容器层中,修改,修改的结果就会作用到下层的文件,这种方式也被称为copy-on-write。 0.5.2. Docker 支持的UnionFS 包括但不限于以下这几种:aufs, device mapper, btrfs, overlayfs, vfs, zfs。 aufs是ubuntu 常用的 device mapper 是 centos btrfs 是 SUSE overlayfs ubuntu 和 centos 都会使用,现在最新的 docker 版本中默认两个系统都是使用的 overlayfs vfs 和 zfs 常用在 solaris 系统
0.1. 典型示例 0.2. docker exec 0.3. docker commit 0.4. Volume(数据卷) 0.5. 总结 0.1. 典型示例 用docker部署一个Python编写的Web应用,代码如下: from flask import Flask import socket import os app = Flask(__name__) @app.route('/') def hello(): html = "<h3>Hello {name}!</h3>" \ "<b>Hostname:</b> {hostname}<br/>" return html.format(name=os.getenv("NAME", "world"), hostname=socket.gethostname()) if __name__ == "__main__": app.run(host='0.0.0.0', port=80) 这段代码中使用Flask框架启动了一个web服务器,唯一共功能就是:如果当前环境中有“NAME”这个环境变量,就把它打印在“Hello”后,否则就打印“Hello world”,最后再打印出当前环境的hostname。 这个应用的依赖被定义在同目录下的requirements.txt文件中: cat requirements.txt Flask 将这样一个应用容器化的第一步,就是制作容器镜像。有两种方式: 制作rootfs(比较麻烦) Dockerfile(很便捷) # 使用官方提供的 Python 开发镜像作为基础镜像FROMpython:2.7-slim# 将工作目录切换为 /appWORKDIR/app# 将当前目录下的所有内容复制到 /app 下ADD . /app# 使用 pip 命令安装这个应用所需要的依赖RUN pip install --trusted-host pypi.python.org -r requirements.txt# 允许外界访问容器的 80 端口EXPOSE80# 设置环境变量ENV NAME World# 设置容器进程为:python app.py,即:这个 Python 应用的启动命令CMD ["python", "app.py"] 查看当前目录下的文件: ls Dockerfile app.py requirements.txt 在当前目录下,让Docker制作镜像: docker build -t helloworld . # -t 参数为这个镜像加上一个Tag # docker build会自动加载当前目录下的Dockerfile文件 # 然后按照顺序执行文件中的原语 这个过程可以等同于Docker使用基础镜像启动了一个容器,然后在容器中依次执行Dockerfile中的原语。 Dockerfile中的每个原语执行后,都会生成一个对应的镜像层。即使原语本身并没有明显地修改文件的操作(比如,ENV原语),它对应的层也会存在。只不过在外界看来这个层是空的。 # docker bulid 操作完成后,通过docker images 查看结果 docker images REPOSITORY TAG IMAGE ID CREATED SIZE helloworld latest 314b99082eb0 11 seconds ago 130MB python 2.7-slim 804b0a01ea83 3 weeks ago 120MB # 通过 docker run 启动容器 docker run -p 4000:80 helloword # 因为在Dockerfile的CMD中指定了启动容器后运行的进程 # 因此在上面的命令后面可以不写需要启动的进程,否则需要使用如下的命令 docker run -p 4000:80 helloworld python app.py # 容器启动之后,使用docker ps查看 docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES e7501877191e helloworld "python app.py" 43 seconds ago Up 42 seconds 0.0.0.0:4000->80/tcp suspicious_lichterman # 在启动容器的时候,使用-p参数将容器内的80端口映射到宿主机的4000端口 # 然后可以通过宿主机的4000端口访问容器中的进程 curl localhost:4000 <h3>Hello World!</h3><b>Hostname:</b> e7501877191e<br/> 如果在运行容器的时候没有暴露端口,那么需要通过docker inspect命令查看到当前运行着的容器的IP地址才能访问,而不能通过宿主机的IP地址+暴露的端口号来访问,如下: docker inspect 47982cd180a1 "NetworkSettings": { "Bridge": "", "SandboxID": "2d7fe94c1301614cc5b4bd076f6d9afae28183af92d881ddbb34d5fb704d8470", "HairpinMode": false, "LinkLocalIPv6Address": "", "LinkLocalIPv6PrefixLen": 0, "Ports": { "80/tcp": null }, "SandboxKey": "/var/run/docker/netns/2d7fe94c1301", "SecondaryIPAddresses": null, "SecondaryIPv6Addresses": null, "EndpointID": "1ba15c1758f95b8e0527f5a6555c52189d5b443b41439c398e2623b52d958109", "Gateway": "172.17.0.1", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "IPAddress": "172.17.0.2", "IPPrefixLen": 16, "IPv6Gateway": "", "MacAddress": "02:42:ac:11:00:02", "Networks": { "bridge": { "IPAMConfig": null, "Links": null, "Aliases": null, "NetworkID": "a52595db8607583e18e115387c9758f21900dac948bd7e5c7c2ba8edf8077b56", "EndpointID": "1ba15c1758f95b8e0527f5a6555c52189d5b443b41439c398e2623b52d958109", "Gateway": "172.17.0.1", "IPAddress": "172.17.0.2", "IPPrefixLen": 16, "IPv6Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "MacAddress": "02:42:ac:11:00:02", "DriverOpts": null } } } curl 172.17.0.2:80 <h3>Hello World!</h3><b>Hostname:</b> 47982cd180a1<br/> 0.2. docker exec 使用docker exec命令可以进入容器,那么它是如何做到的呢? Linux Namespace创建的隔离空间虽然看不见摸不着,但是一个进程的Namespace信息在宿主机上以文件形式存在。 通过以下命令查看docker 容器的进程号(PID): docker inspect --format '{{ .State.Pid }}' 47982cd180a1 17932 通过查看宿主机的proc文件,可以看到容器进程的所有Namespace对应的文件: ls -l /proc/17932/ns total 0 lrwxrwxrwx 1 root root 0 Nov 8 16:48 ipc -> ipc:[4026532537] lrwxrwxrwx 1 root root 0 Nov 8 16:48 mnt -> mnt:[4026532535] lrwxrwxrwx 1 root root 0 Nov 8 16:29 net -> net:[4026532540] lrwxrwxrwx 1 root root 0 Nov 8 16:48 pid -> pid:[4026532538] lrwxrwxrwx 1 root root 0 Nov 8 17:05 user -> user:[4026531837] lrwxrwxrwx 1 root root 0 Nov 8 16:48 uts -> uts:[4026532536] 可以看到,每个进程的每种Linux Namespace都在对应的/proc/[进程号]/ns下有一个对应的虚拟文件,并且链接到真实的Namespace文件上。 有了这些Linux Namespace的文件后,就可以加入到一个已经存在的Namespace中,即一个进程,可以选择加入到某个进程已有的Namespace中,从而达到进入这个进程所在容器的目的。 这个操作依赖的是Linux的setns()系统调用,通过如下代码说明整个过程: #define _GNU_SOURCE #include <fcntl.h>#include <sched.h>#include <unistd.h>#include <stdlib.h>#include <stdio.h> #define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0) int main(int argc, char *argv[]) { int fd; fd = open(argv[1], O_RDONLY); if (setns(fd, 0) == -1) { errExit("setns"); } execvp(argv[2], &argv[2]); errExit("execvp"); } 以上代码的功能, 接收两个参数,第一个是当前argv[1],表示当前进程要加入的Namespace文件的路径,第二个参数,是要在这个Namespace里运行的进程。 以上代码的核心操作,通过open()系统调用打开了指定的Namespace文件,并把这个文件的描述符fd交给setns()使用,在setns()执行后,当前进程就加入了这个文件对应的Linux Namespace中。 0.3. docker commit docker commit 实际上就是在容器运行起来后,把最上层的“可读写层”,加上原先容器镜像的只读层,打包组合成一个新的镜像,原先的只读层在宿主机上是共享的,不占用额外空间。 因为使用的是联合文件系统,所以在容器中对镜像rootfs所做的任何修改,都会被操作系统先复制到这个可读写层,然后再修改。即 Copy-on-Write 有了Init层的存在,就是为了避免在commit的时候,把容器自己对/etc/hosts等文件的修改也一起提交掉。 0.4. Volume(数据卷) 容器技术使用rootfs机制和Mount Namespace,构建出了与宿主机完全隔离开的文件系统环境。那么以下两个问题如何解决: 容器里进程新建的文件,怎么才能让宿主机获取到? 宿主机上的文件和目录,怎么才能让容器里的进程访问到? Docker Volume解决以上问题,Volume机制,允许将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。 在Docker项目里,支持两种Volume声明方式: docker run -v /test ... docker run -v /home:/test ... 以上两种方式的本质实际上是一样的,都是把一个宿主机的目录挂载进容器的/test目录。 第一种情况,没有显示声明宿主机目录,所以Docker会默认在宿主机上创建一个临时目录/var/lib/docker/volumes/[VOLUME_ID]/_data,然后挂载到容器的/test目录上。 第二种情况,Docker会直接把宿主机的/home目录挂载到容器的/test目录上。 如何做到将宿主机的目录或文件挂载到容器中? 在容器进程被创建之后,尽管开启了Mount Namespace,但是在它执行chroot或pivot_root之前,容器进程一直可以看到宿主机的整个文件系统。 宿主机的文件系统也包括要运行的容器镜像。 容器镜像的各层被保存在/var/lib/docker/aufs/diff目录下 容器启动后,镜像各层被联合挂载在/var/lib/docker/aufs/mnt/目录下 挂载完成后,容器的rootfs就准备好了。 因此,实现宿主机的目录挂载到容器中,只要在rootfs准备好之后,在执行chroot之前,把volume指定的宿主机目录(比如/home),挂载到指定的容器目录(比如/test)在宿主机上对应的目录(即/var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个volume的挂载工作就完成。 /home --> /var/lib/docker/aufs/mnt/可读写层ID/test 因为,在执行挂载操作时,“容器进程”已经创建了,此时的Mount Namespace已经开启,所以这个挂载事件只在这个容器里可见。宿主机上是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被Volume打破。 这里提到的“容器进程”是Docker创建的一个容器初始化进程(dockerinit),而不是应用进程(ENTRYPOINT+CMD)。 dockerinit负责完成: 根目录的准备 挂载设备和目录 配置hostname 等一系列需要在容器内进行的初始化操作 完成以上操作后,它通过execv()系统调用,让应用进程取代自己,成为容器里的PID=1的进程。 这里使用的挂载技术是Linux的绑定挂载(bind mount)机制。它的主要作用就是允许你将一个目录或文件,而不是整个设备,挂载到一个指定的目录上。并且,这时在该挂载点上进行的任何操作,只是发生在被挂载点的目录或文件上,原挂载点的内容则会被隐藏起来且不受影响。 绑定挂载实际上是一个inode替换的过程。在Linux操作系统中,inode可以理解为存放文件内容的“对象”,而dentry(目录项),就是访问这个inode所使用的“指针” 如上图所示,mount --bind /home /test 命令会将/home目录挂载到/test上。其实相当于将/test的dentry重定向到了/home 的inode。这样当修改/test目录时,实际修改的是/home目录的inode。这也就是为何,一旦执行umount命令,/test目录原先的内容就会恢复:因为修改真正发生在的是/home目录里。 在一个正确的时机,进行一次绑定挂载,Docker就成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。这样,进程在容器中对这个/test目录的所有操作,都实际发生在宿主机的对应目录(如/home或/var/lib/docker/volumes/[VOLUME_ID]/_data),而不会影响镜像的内容。 那么这个/test目录的内容,既然被挂载在容器rootfs的可读写层,它会不会被docker commit提交呢? 并不会。 因为,容器的镜像操作,比如docker commit 都是发生在宿主机空间的。而由于Mount Namespace的隔离作用,宿主机不知道这个绑定挂载的存在,所以在宿主机看来,容器中可读写层的/test目录(/var/lib/docker/aufs/mnt/[可读写层ID]/test)始终是空的。 Docker在一开始会创建/test这个目录作为挂载点,所以执行了docker commit之后,在新的镜像中,会多出来一个空的/test目录。 0.5. 总结 Docker容器全景图 这个容器进程“python app.py”,运行在由Linux Namespace和Cgroups构成的隔离环境里,而它运行所需的各种文件,比如python,app.py,以及整个操作系统文件,则由多个联合挂载在一起的rootfs层提供。 这些rootfs层的最下层,是来自Docker镜像的只读层 在只读层上,Docker自己添加的Init层,用来存放被临时修改过的/etc/hosts等文件 rootfs的最上层是一个可读写层,以Copy-on-Write的方式存放任何对只读层的修改,容器声明的Volume的挂载点,也在这一层
0.1. Kubernetes架构 0.1.1. Kubernetes要解决的问题是什么 0.1.1.1. 控制节点(Master) 0.1.1.2. 计算节点(Node) 0.1.2. Kubernetes对容器常见的“访问”进行了分类 0.1.3. Kubernetes对容器的运行形态进行分类 0.2. 总结 "容器",实际上是一个由Linux Namespace、Linux Cgroups和rootfs三种技术构建出来的进程的隔离环境。 一个正在运行的容器可以被“一分为二”看待: 一组联合挂载在/var/lib/docker/aufs/mnt上的rootfs,这部分称为“容器镜像”,是容器的静态视图。 一个由Namespace+Cgroups构成的隔离环境,这部分称为“容器运行时”,是容器的动态视图。 作为一个开发者,我们并不关心容器运行时的差异,因为,在整个“开发->测试->发布”的流程中,真正承载着容器信息进行传递的,是容器镜像,而不是运行时。 这也正是在Docker项目成功后,迅速走向“容器编排”这个“上层建筑”的主要原因。 作为一家云服务商或者基础设施提供商,只要能够将用户提交的Docker镜像以容器的方式运行起来,就能够成为容器生态圈上的一个承载点,从而将整个容器技术栈上的价值,沉淀在这个节点上。 从这个承载点向Docker镜像制作者和使用者方向回溯,整条路径上的各个服务节点,比如CI/CD、监控、安全、网络、存储等,都有可以发挥和盈利的余地。 这个逻辑正是所有云计算提供商如此热衷容器技术的重要原因:通过容器镜像,它们可以和潜在用户(开发者)直接关联起来。 从单一容器到容器集群,容器技术实现了从“容器”到“容器云”的飞跃,标志着它真正得到了市场和生态的认可。 容器从开发者手中一个小工具,成为了云计算领域的主角,能够定义容器组织和管理规范的“容器编排”技术,则坐上了容器技术的“头把交椅”。 容器编排工具: Compose+Swarm (Docker) Kubernetes (Google + RedHat) 谷歌公开发表的基础设施体系(The Google Stack): 0.1. Kubernetes架构 0.1.1. Kubernetes要解决的问题是什么 编排?调度?容器云?集群管理? 这个问题目前没有固定的答案,不同阶段,Kubernetes重点解决的问题不同。 但是对于用户来说,希望Kubernetes帮助我们把容器镜像在一个给定的集群上运行起来。(希望Kubernetes提供路由网关、水平扩展、监控、备份、灾难恢复等。) 以上功能,Docker的(Compose+Swarm)或者传统的PaaS就能做到,因此Kubernetes的核心定位不止于此,全局架构如下: Kubernetes由Master和Node两种节点组成,分别对应这控制节点和计算节点。 0.1.1.1. 控制节点(Master) 出发点:如何编排、管理、调度用户提交的作业。 由三个密切协作的独立组件组合而成: 负责API服务的Kube-apiserver 负责调度的kube-scheduler 负责容器编排的kube-controller-manager 整个集群的持久化数据,由kube-apiserver处理后保存到Etcd中。 0.1.1.2. 计算节点(Node) kubelet,与容器运行时(比如Docker项目)交互,这个交互所依赖的是CRI(Container Runtime Interface)的远程调用接口。这个接口定义了容器运行时各项核心操作,比如:启动一个容器需要的所有参数。具体的容器运行时,比如Docker一般通过OCI规范与底层的Linux操作系统进行交互。也就是将CRI请求翻译成对Linux操作系统的调用(操作Linux Namespace和Cgroups等)。 因此,kubernetes项目并不关心部署的是什么容器运行时,使用的什么技术实现,只要容器运行时能够运行标准的容器,就可以通过实现CRI接入到Kubernetes中。 kubelet通过gRPC协议与Device Plugin插件交互。这个插件是Kubernetes用来管理GPU等宿主机物理设备的主要组件,这个插件是基于Kubernetes项目进行机器学习训练,高性能作业支持等工作必须关注的功能。 kubelet调用网络插件和存储插件为容器配置网络和持久化存储。这两个插件与kubelet交互的接口:CNI(Container Networking Interface)和CSI(Container Storage Interface) Kubernetes项目着重要解决的问题是:运行在大规模集群中的各种任务之间,实际上存在着各种各样的关系。这些关系的处理,才是作业编排和管理系统最困难的地方。 如何处理这些关系?利用Docker Swarm 和Compose来处理一些简单依赖关系。 比如,在Compose项目中,可以为两个容器定义一个“link”,Docker项目负责维护这个“link”关系,具体的做法,将两个容器相互访问所需要的IP地址,端口号等信息以环境变量的形式注入,供应用进程使用: DB_NAME=/web/db DB_PORT=tcp://172.17.0.5:5432 DB_PORT_5432_TCP=tcp://172.17.0.5:5432 DB_PORT_5432_TCP_PROTO=tcp DB_PORT_5432_TCP_PORT=5432 DB_PORT_5432_TCP_ADDR=172.17.0.5 当容器发生变化时(如镜像更新或者被迁移到其他宿主机),这些环境变量的值会由Docker项目自动更新。 简单的依赖关系,使用以上方法没有问题,但是如果要将所有的依赖关系都处理好,link这种简单的方式就不行了。 所以,Kubernetes项目最主要的设计思想是:从更宏观的角度,以统一的方式来定义任务之间的各种关系,并且为将来支持更多种类的关系保留余地。 0.1.2. Kubernetes对容器常见的“访问”进行了分类 常见的“紧密交互”关系:应用之间需要非常频繁的交互和访问或者通过本地文件进行信息交换。常规环境下,这些应用会被部署在同一台服务器,通过localhost通信,通过本地磁盘交换文件。在Kubernetes中,这些容器会被划分为一个Pod,Pod中的容器共享同一个Network Namespace、同一组数据卷,从而达到高效交换信息的目的。 常规需求,如web服务和数据库之间的访问关系;kubernetes提供了一种叫“Service”的服务。像这样的两个应用,往往故意部署在不同的机器上,从而提高容灾能力。但是对于一个容器来说IP地址是不固定的,那么Web怎么找到数据库容器对应的Pod呢?kubernetes通过给Pod绑定Service,而Service声明的IP地址始终不变。 这个Service主要作用是作为Pod的代理入口,从而代替Pod对外暴露一个固定的网络地址。 这样,Web应用只需要关心数据库Pod的Service的信息,Servie后端真正代理的Pod的IP地址、端口等信息的自动更新、维护,则是kubernetes项目的职责。 围绕Pod为核心,构建出Kubernetes项目的核心功能“全景图”: 不同Pod之间不仅有访问关系,还要求发起时加上授权信息。那么如何实现?使用Secret对象,它其实是一个保存在Etcd里的键值对数据。把授权信息以Secret的方式存在Etcd里,Kubernetes会在指定的Pod启动时,自动把Secret里的数据以Volume的方式挂载到容器里。这样就可以使用授权信息进行容器之间的访问。 0.1.3. Kubernetes对容器的运行形态进行分类 Pod 基于Pod改进的对象: Job,用来描述一次性运行的Pod(比如,大数据任务)。 CronJob,用于描述定时任务。DaemonSet,用来描述每个宿主机上必须且只能运行一个副本的守护进程服务 Kubernetes推崇的做法: 通过一个“编排对象”,如pod,job等,来描述你试图管理的应用 再定义一些“服务对象”,如Service,Secret,Horizontal Pod Autoscaler等,这些对象会负责具体的平台级功能 这就是所谓的声明式API,这些API对应的“编排对象”和“服务对象”,都是kubernetes项目中的API对象。 0.2. 总结 过去很多集群管理项目(Yarn、Mesos、Swarm)所擅长的是把一个容器,按照某种规则,放置在某个最佳节点上运行起来,这种功能称为“调度”。 Kubernetes擅长的是按照用户意愿和整个系统的规则,完全自动化地处理好容器之间的各种关系,这种功能称为“编排”。 Kubernetes不仅提供了一个编排工具,更重要的是提供了一套基于容器构建分布式系统的基础依赖。