Docker背后的内核知识
Docker容器本质上是宿主机上的进程。
Docker通过namespace实现了资源隔离,通过cgroups实现了资源限制,通过写时复制(copy-on-write)实现了高效的文件操作。
隔离与限制
隔离
Namespace技术实际修改了应用进程看待整个计算机的“视图”,即它的“视线”被操作系统做了限制,只能“看到”某些指定的内容。
Docker与虚拟机
上述图用Docker Engine来取代Hypervisor,仿佛Docer实现了轻量的虚拟化技术,但是其实是错误的
在理解了Namespace工作方式后,会发现在使用Docker时,并没有一个真正的“Docker”容器运行在宿主机中。Docker项目帮助用户启动的还是原来的应用进程,只不过在创建这些进程时,Docker为它们加上了各种各样的Namespace参数。
这时,这些进程就会觉得自己是各自PID namespace里的第1号进程,只能看到各自Mount Namespace里挂载的目录和文件,只能访问到各自Network Namespace里的网络设备,就仿佛运行在一个个容器里,与世隔绝。
于是在虚拟机与容器技术对比图中,不应该把Docker Engine或者任何容器管理工具放在和Hypervisor相同的位置,因为他们并不像Hypervisor那样对应用进程的隔离环境负责,也不会创建任何实体的”容器”,真正对隔离环境负责的是宿主机操作系统本身
同时这样的架构也解释了为什么Docker项目比虚拟机更受欢迎的原因
这是因为虚拟化技术作为应用沙盒,就必须要由Hypervisor来负责创建虚拟机,这个虚拟机是真实存在的,并且它里面必须运行一个完整的Guest OS才能执行用户的应用进程,这就不可避免的带来了额外的资源消耗和占用,用户应用运行在虚拟机里,它对宿主机操作系统的调用就不可避免要经过虚拟化软件的拦截和处理,这本身又是一层性能损耗。
相比之下,容器化后的用户应用依旧还是一个宿主机上的普通进程,这就表示虚拟化而带来的性能损耗是不存在的,另一方面,使用Namespace作为隔离手段的容器并不需要单独的Guest OS,这就使得容器额外的资源占用几乎可以忽略不计。
所以,“敏捷”和”高性能”是容器相较于虚拟机最大的优势,也是它能够在PaaS这种更细粒度的资源管理平台上大行其道的原因。
Namespace隔离机制的缺点
有利就有弊,基于Linux Namespace的隔离机制相比于虚拟化技术也有很多不足,其中最主要的问题就是:隔离得不彻底
首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。这意味着,如果你要在Windows宿主机上运行Linux容器,或者在低版本的Linux宿主机上运行高版本的容器,都是行不通的。
其次,在Linux内核中,有很多资源和对象是不能被Namespace化的,最典型的例子就是时间。这就意味着,如果你的容器中程序使用settimeofday(2)系统调用修改了时间,整个宿主机的时间都会被随之修改,这县仍然不符合用户的预期。相比于虚拟机里可以随便折腾的自由度,在容器部署应用时,”什么能做,什么不能做”就是用户必须考虑的一个问题
限制
你也许会好奇,我们不是通过Linux Namespace创建了一个容器了吗,为什么还要对容器做”限制”呢?
我们以PID Namespace为例,来解释问题
虽然容器里第一号进程在”障眼法”的干预下只能看到容器里的亲宽广,但是宿主机上,它作为第100号进程与其他所有进程之间依然是平等的竞争关系。这就意味着,虽然第100号进程表面上被隔离开来,但是它所能用到的资源(比如CPU\内存),却是可以随时被宿主机上的其他进程(或其他容器)占用的。当然,这个100号进程自己也可能把所有资源吃光。这些行为显然不是一个“沙盒”应该表现出来的行为。
而Linux Cgroups就是Linux内核中用来为进程设置资源限制的一个重要功能。Linux Cgroups最主要的作用是限制一个进程组能够使用的资源上限,包括CPU、内存、磁盘、网络带宽等
在Linux中,Cgroups给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的/sys/fs/cgroup/路径下
|
|
在/sys/fs/cgroup下有诸如cpuset、cpu、memory这样的子目录,也叫子系统,这些都是这台机器当前可以被cgroups进行限制的资源品种。在子系统对应的资源种类下,就可以看到该类资源具体可以被限制的方法
|
|
其中可以通过cfs_period和cfs_quota这两个参数组合使用来限制在cfs_period时间内,最大被分配到总量cfs_quota的cpu时间
下面我们就cgroup来做一些测试
首先,在对应的子系统下创建一个目录,比如在/sys/fs/cgroup/cpu下
|
|
目录container就成为一个控制组,你会发现,操作系统会在你新创建的container目录下,自动生成该子系统对应的资源限制文件
首先,在后台执行这样一条脚本命令
$ while : ; do : ; done &
[1] 226
这条命令执行了死循环,可以把计算机cpu吃到100%,根据输出看到这个脚本在后台运行的进程PID是226
此时通过top查看,cpu已经被打满。我们回到container目录,查看container控制组里cpu.cfs_quota_us文件还没有任何限制(-1),cpu period则是默认的100ms(100000us)
然后向container组的cfs_quota文件写入20ms(20000us),意味着,在每100ms时间里,被该控制组限制的进程只能使用20ms的cpu时间
|
|
接着讲被限制的PID写入container组的tasks文件,上面的设置就会对该进程生效了
|
|
此时通过top命令可以查看到cpu使用率立即降到20%
除cpu子系统外,Cgroup的每一项子系统都具有其独特的资源限制能力,比如:
- Blkio,为块设备设定i/o限制,一般用于磁盘等设备
- cpuset,为进程分配单独的cpu核和对应的内存节点
- memory,为进程设定内存使用的限制
Linux Cgroups的设计还是比较易用的,简单粗暴理解它就是一个子系统目录加上一组资源限制文件的组合。对于Docker来说,它们只需要在每个子系统下,为每个容器创建一个控制组,然后在容器启动后,将容器进程pid填写到控制组的tasks文件中即可。
|
|
上述容器只能使用20%的cpu带宽
Cgroups资源限制的缺点
提及最多的就是/proc文件系统的问题,Linux下的/proc目录存储的是记录当前内核运行状态的一系列状态文件,用户可以通过访问这些文件,查看系统及当前运行进程的信息,比如cpu使用情况,内存占用率等,这些也是top命令查看系统信息的主要数据来源。
但是如果你在容器里执行top命令,就会发现,它显示的信息居然是宿主机的cpu和内存数据,而不是当前容器的数据
造成这个问题的原因就是: /proc文件系统并不知道用户通过Cgroups给这个容器做了什么样的资源限制,即:/proc文件系统不了解Cgroups限制的存在。
解决方法: 利用lxcfs
,把宿主机的/var/lib/lxcfs/prox/*
文件挂载到容器的/proc/*
深入理解容器镜像
挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的”容器镜像”,它有一个更专业的名字,叫做: rootfs(根文件系统)
现在你应该可以理解,对Docker项目来说,它最核心的原理实际上是为待创建的用户进程:
1.启用Linux Namespace配置
2.设置指定的Cgroups参数
3.切换进程的根目录(Change Root)
需要明确的是,rootfs只是一个操作系统所包含的文件、配置和目录,并不包含操作系统内核。在Linux操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。这就意味着同一台机器上的所有容器,都共享宿主机操作系统的内核。
这也是容器相较虚拟机的缺陷之一,不过正是rootfs的存在,容器才有了一个被反复宣传至今的重要特性: 一致性。
由于rootfs里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端环境之间难以逾越的鸿沟。
这时你发现一个问题: 难道我每开发一个应用,或者升级一下现有的应用,都要重复制作一次rootfs吗?
比如,我现在在用Ubuntu系统iso做了一个rootfs,然后里面安装了java环境,用来部署我的环境,那么我的另一个同事在发布他的java应用时,显然希望能够直接用我安装过java环境的rootfs,而不是重复这个过程。
为解决上述问题,Docker公司在实现Docker镜像时做了一个小小的创新:
Docker在镜像设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量的rootfs。
当然,这个想法不是乱想出来了,而是用到了一种叫做联合文件系统(unionFS)的能力,它主要功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下,比如我有两个目录A和B,他们分别有两个文件
|
|
然后我使用联合挂载的方法,将这两个目录挂载到一个公共的目录C中:
|
|
这时再看C中的内容,就能看到A和B下的文件被合并在了一起
|
|
在Docker项目中,又是如何使用这种Union File System的呢?
在我试验的环境里,使用了overlay2这个联合文件系统,这个信息可以通过docker info查看
我们再拿Docker中的Ubuntu镜像举个例子。这个所谓的镜像,实际上就是一个ubuntu操作系统的rootfs,内容是ubuntu操作系统的所有文件和目录。不过和之前的rootfs稍微不同的是,Docker镜像的rootfs,通常是由多个层组成
|
|
可以看到,这个ubuntu镜像由5层构成
容器的rootfs由3部分构成,自上向下逐层覆盖合并
第一部分: 只读层
它是容器rootfs最下面的五层,可以看到,他们的挂载方式都是只读的(ro+wh)
第二部分: 可读写层
这是容器rootfs最上面的一层,它的挂载方式是rw。在没写入文件之前,这个目录是空的。而一旦在容器做了写操作,你所产生的内容就会以增量的方式出现在这个层中。
可这样带来另一个问题: 如果我现在要做的,是删除只读层里的一个文件呢?
为了实现这样的删除操作,AuFS会在可读写层创建一个whiteout文件,把只读层的文件”遮挡”起来。比如你要删除只读层中名叫foo的文件,那么这个删除操作实际是在可读写层创建了一个叫.wh.foo的文件,这样,当这两个层被联合挂载后,foo文件就会被.wh.foo文件遮挡起来。这个功能也就是”ro+wh”的挂载范式,即只读+whiteout
第三部分: init层
init层是Docker项目单独生成的一个内部层,专门用来存放/etc/hosts、/etc/resove.conf等信息
需要这样一层的原因是,这些文件原本属于只读的ubuntu镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值,比如hostname,所以就需要在可读写层对他们进行修改。可是这些修改往往只对当前的容器有效,我们并不希望执行docker commit时,把这些信息联通可读写层一起提交,所以Docker的做法是,在修改了这些文件后,以一个单独的层挂载出来。而用户执行docker commit只会提交读写层。
重新认识Docker容器
Linux Nampespace创建的格里空间虽然看不见摸不着,但是一个进程的Namespace信息在宿主机上是确确实实存在的,并且是以一个文件的方式存在。
比如可以通过如下命令看到,当前正在运行的Docker容器IDE进程(PID)是25686:
|
|
这时,你可以通过查看宿主机的/proc文件,看到这个25686进程的所有Namespace对应的文件
|
|
有这样一个可以hold住所有Linux Namespace的文件,我们就可以对Namespace做一些很有意义的事情,比如: 加入到一个已经存在的Namespace当中。
这就意味着: 一个进程,可以选择加入到某个进程已有的Namespace当中,从而达到”进入”这个进程所在容器的目录,这正是docker exec的实现原理
Docker专门提供了一个参数,可以让你启动一个容器并”加入”到另一个容器的Network Namespce中,这个参数就是-net,比如
|
|
Volume机制
前面介绍了通过rootfs机制和Mount Namespace,构建出一个同宿主机完全隔离开的文件系统环境。这时候,我们需要考虑这两个问题
- 容器里进程新建的文件,怎么才能让宿主机获取到
- 宿主机上的文件和目录,怎么才能让容器里的进程访问到
这正是Docker Volume要解决的问题:Volume机制,允许你讲宿主机上指定的目录或文件,挂载到容器里进行读取和修改操作
|
|
这条命令的本质是把宿主机的目录挂载进了容器的/test目录
那么Docker是如何做到把一个宿主机上的目录或文件,挂载到容器里面去的呢?难道又是Mount Namespace的黑科技吗?
实际并不需要那么麻烦,前面提到,当容器进程被创建后,尽管开启在Mount Namespace,但是在它执行chroot(或pivot_root)之前,容器进程一直可以看到宿主机上的整个文件系统。
所以我们只需要在rootfs准备好之后,在执行chroot之前,把Volume指定的宿主机目录(比如/home目录),挂载到指定的容器目录(比如/test目录)在宿主机上对应的目录(即/var/lib/docker/aufs/mnt/[可读写层ID]/test)上,这个Volume的挂载工作就完成了
更重要的是,由于执行这个挂载操作时,容器进程已经创建了,也就意味着,此时Mount Namespace已经开启。所以这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被Volume打破。
而在这里要用的到挂载技术,就是Linux的绑定挂载机制。它的主要作用就是,允许你将一个目录或文件,而不是整个设备,挂载到一个指定的目录上。并且这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或文件上,而原挂载点的内容则会被隐藏起来不受影响。
其实,绑定挂载实际上是一个inode替换的过程。在Linux操作系统中,inode可以理解为存放文件内容的对象,而dentry,也叫目录项,就是访问这个inode所使用的指针
从容器到容器云
谈谈Kubernetes的本质
Kubernetes项目最主要的设计思想是,从更宏观的角度,以统一的方式来定义任务之间的各种关系,并为将来支持更多种类的关系留有余地
Service 的主要作用就是作为Pod的代理入口(Portal),从而代替Pod对外暴露一个固定的网络地址
job 描述一次性运行的Pod
cronJob 描述定时任务
daemonSet 描述每个宿主机上必须且只能运行一个副本的守护进程服务
调度: 把一个容器,按照某种规则,放置在某个最佳结点上运行起来
编排: 按照用户的意愿和整个系统的规则,完全自动化地处理好容器之间的各种关系
|
|
|
|
这样,两个完全相同的nginx容器副本就被启用了
K8s一键部署利器:kubeadm
安装kubeadm和Docker
|
|
部署kubernets和Master节点
编写给kubeadm使用的yaml文件(kubeadm.yaml)
|
|
|
|
如果cpu是单核需要加—ignore-preflight-errors ‘NumCPU’ 选项
完成后,kubeadm会成一条命令,用于给这个master节点增加更多工作节点(worker)
|
|
此外,kubeadm还会提醒我们第一次使用kubeadm集群所需要的配置命令
|
|
需要这些配置的原因是: Kubernets 集群默认需要加密方式访问,这几条命令,就是讲刚刚部署生成的k8s集群的安全配置文件保存到当前用户的.kube目录下,kubectl默认会使用这个目录下的授权信息访问k8s集群
|
|
可以看到master结点状态是NotReady,这是为什么呢?在调试k8s集群时,最重要的手段就是用kubectl describe来查看节点(Node)对象的详细信息、状态和事件(event)
|
|
可以看到NodeNotReady的原因在于,我们尚未部署任何网络插件。
另外我们还可以通过Kubectl检查这个节点上的各个系统Pod的状态。其中kube-system是k8s项目预留的系统Pod工作空间(和Linux namespace是两码事,只是k8s划分不同工作空间的单位)
|
|
可以看到CoreDns,kube-controller-manager等依赖网络的Pod都处于Pending状态,即调度失败。这是符合预期的,因为这个Master节点的网络尚未就绪
部署网络插件
|
|
再查检查pod状态,可以发现所有系统Pod都成功启动了,而刚刚部署的Weave网络插件则在kube-system下面新建了一个名叫weave-net-cmk27的Pod,一般来说,这些Pod就是容器网络插件在每个节点上的控制组件
K8s支持容器网络插件,使用的是一个名叫CNI的通用接口(container network interface)。
至此,K8s的master节点就部署完成了。如果你只需要单个节点的k8s,那么已经可以使用了。不过默认情况下,k8s的master节点是不能运行用户Pod的,所以还需要做一点额外操作。
部署kubernets的worker节点
k8s的worker节点跟master节点几乎是相同的。它们运行着的都是一个kubelet组件。唯一区别在于,在kubeadm init过程中,kubelet启动后,master节点上还会自动运行kube-apiserver、kube-scheduler、kube-controller-manager这三个系统Pod
所以相比之下,部署worker节点反而是最简单的,只需要两步即可完成。
第一步,在所有worker节点上执行”安装kubeadm和Docker”一节的所有步骤
第二步,执行部署master节点时生成的kubeadm join命令
|
|
至此,工作节点部署完成。
通过Taint/Toleration调整Master执行Pod的策略
前面提到过,默认Master节点不允许执行用户Pod,k8s是通过一种叫taint/toleration的机制实现的。
其原理很简单: 一旦某个节点被加上了一个Taint,即被”打上了污点”,那么所有Pod就都不能再这个节点上运行,因为Kubernets的Pod都有”洁癖”。
除非,有个别的Pod声明自己能够”容忍”这个”污点”,即声明了Toleration,它才可以在这个节点上运行
其中,为节点打“污点”的命令是:
|
|
此时,node1节点上会增加一个键值对格式的taint,即:foo=bar:NoSchedule。其中值里面的NoSchedule,意味着这个taint只会在调度新Pod时产生作用,而不会影响已经在node1上运行的Pod,哪怕它们没有Toleration
那么Pod又如何声明Toleration呢?
我们只要在Pod的.yaml文件的spec部分,加入tolerations字段即可:
|
|
这个Toleration的含义是,这个Pod能够容忍所有键值对为foo=bar的taint
现在我们回到已经搭建的集群上来。这时用kubectl describe检查一下master节点的taint字段,就会发现
|
|
可以看到master节点默认被打上了node-role.kubernetes.io/master:NoSchedule这样一个”污点”,其中”键”是node-role.kubernetes.io/master,并没有值
此时,你可以按上述spec的方式来让Pod容忍污点。当然如果你就是想要一个单节点的k8s,那么正确的做法是删除master上的taint:
|
|
如上所示,最后加了一个短横线”-“,这就意味着移除所有以”node-role.kubernets.io/master”为键的taint
接下来,在介绍一些其他的辅助插件比如dashboard和存储插件
部署dashboard可视化插件
|
|
部署完成后,我们就可以查看dashboard对应的Pod的状态了
|
|
需要注意的是,由于dashboard是一个web server,很多人会在公有云商无意地暴露出dashboard端口从而造成安全隐患,从1.7版本后的danshboard项目部署完后,默认只能通过proxy的方式本地访问,具体方法见dashboard 官方文档
部署容器存储插件
接下来我们完成k8s集群的最后一块拼图: 容器持久化存储
我们前面提到过,很多时间我们需要用数据卷(volume)把外面宿主机上的目录或者文件挂载进容器的mount namespace中,从而达到容器和宿主机共享这些目录或者文件的目录,也就是可以在这些数据卷中新建和写入文件。
可是,如果你的某一台机器上启动的容器,显然无法看到其他机器上的容器在它们的数据间里写入的文件。这就是容器最典型的特征之一: 无状态
而容器的持久化存储,就是用来保存容器存储状态的重要手段: 存储插件会在容器里挂载一个基于网络或者其他方式的远程数据卷,使得在容器里创建的文件,实际上是保存在远程存储服务器上,或者以分布式的方式保存在多个节点上,而与当前宿主机没有任何绑定关系。这样,无论你在哪个宿主机上新启动的容器,都可以请求挂载置顶的持久化存储卷,从而访问到存储卷里保存的内容。这就是”持久化”的含义。
在这次部署实战中,我们选择部署一个很重要的k8s存储插件项目: Rook
Rook项目是一个基于Ceph的k8s存储插件。不过,不同于ceph的简单封装,rook在自己的视线中加入了水平扩展、迁移、灾难本分、监控等大量的企业级功能,使得这个项目编程一个完整的、生产级别可用的容器存储插件。
得益于容器化技术,用两条指令,Rook就可以把复杂的Ceph存储后端部署起来
|
|
这样,一个基于Rook持久化存储集群就以容器的方式运行起来了,而接下来k8s项目上创建的所有Pod就能够通过Persistent Volume(PV)和Persistent Volume Claim(PVC)的方式,在容器里挂载由Ceph提供的数据卷了。
小结
开发和使用k8s的重要指导思想,即: 基于k8s开展工作时,一定要优先考虑两个问题:
- 我的工作是否可以容器化
- 我的工作是否可以借助k8s API和可扩展机制来完成?
而一旦这项工作可以基于k8s实现容器化,就很有可能实现像上述部署过程一样,大幅简化原本复杂的运维工作
我的第一个容器化应用
本篇中,我们扮演一个应用开发者的角色,使用上篇的k8s集群来发布第一个容器化应用
作为一个应用开发者,首先要做的是制作容器的镜像,接着你需要按照k8s项目的规范和要求,将你的镜像组织为k8s能够认识的方式,然后提交上去
那么,什么才是k8s项目能够”认识”的方式呢?
这就是使用k8s的必备技能: 编写配置文件
k8s跟docker等最大的不同,就在于它不推荐你使用命令行的方式直接运行容器(虽然也支持,比如kubectl run),而是希望你用yaml文件的方式,即:把容器的定义、参数、配置,统统记录到一个yaml文件中,然后用这样一句指令把它运行起来:
|
|
为什么我们需要Pod
Pod是k8s项目的原子调度单位
一个Pod中可以有多个容器
Pod只是一个逻辑概念
Pod里的所有容器,共享同一个network namespace,并且可以声明共享同一个volume
成组调度没有被妥善处理的例子
第一个例子WAR包和WEB服务器
initContainer:init container定义的容器会比spec.containers定义的容器先启动。并且,init container容器会按顺序逐一启动,而直到他们都启动并退出了,用户容器才会启动
Sidecar: sidecar指的是在一个Pod中,启动一个辅助容器,来完成一些独立于主进程(主进程)之外的工作
深入解析Pod对象
Pod扮演的是传统部署环境里“虚拟机”的角色。这样的设计,是为了使用户从传统环境(虚拟机环境)向k8s(容器环境)的迁移,更加平滑。
而如果你能把Pod看成传统环境里”机器”、把容器看做是运行在这个机器里的“用户程序”,那么很多关于Pod对象的设计就非常容易理解了。
比如,凡是调度、网络、存储,以及安全相关的属性,基本上都是Pod级别的。
Projected volume
- secret
- configMap
- downward api
- serviceAccountToken
secret
将配置存放到etcd中
Service account对象的作用,就是k8s系统内置的一种“服务账号”,它是k8s 进行权限分配的对象。像这样的service account的授权信息和文件,保存在serviceaccounttoken中,它是一种特殊的secet对象,任何运行在k8s集群上的应用,都必须使用这个serviceaccounttoken里保存的授权信息,也就是token才可以合法地访问API Server.
另外为了方便使用,k8s已经为你提供了一个默认的服务账号”default service account”,并且,任何一个运行在k8s里的pod,都可以直接使用这个默认的service account,并无需显示挂载。如果你查看任意一个运行在k8s集群里的Pod,就会发现每一个Pod都已经自动声明了一个类型是Secret、名为default-token-xxx的Volume,然后挂载在每个容器的一个固定目录上。
|
|
所以说k8s其实在每个Pod创建时自动在它的spec.volumes部分添加上了默认serviceaccounttoken的定义,然后自动给每个容器加上了对应的volumeMount字段,这个过程对用户是完全透明的。一旦Pod创建完成,容器里的应用就可以直接从这个默认的ServiceAccountToken的挂载目录里访问到授权信息和文件。这个容器里的路径是固定的,即/var/run/secrets/kuberetes.io/serviceaccount
除默认的service account外,我们很多时候还需要创建一些我们自己定义的serviceaccount,来应对不同的权限配置。这样,我们的Pod里的容器就可以通过挂载这些service account对应的service account token来使用这些自定义的授权信息。
这种把k8s客户端以容器的方式运行在集群里,然后使用default service account 自动授权的方式,被称作“inClusterConfig”,也是我最推荐的进行k8s api编程的授权方式
Pod另一个重要配置: 容器健康检查和恢复机制
在k8s中,你可以为Pod里的容器定义一个健康检查”探针”,这样kubelet就会根据这个Probe的返回值决定容器的状态,而不是直接以容器进行是否运行(来自Docker返回的信息)作为依据。这种机制,是生产环境保证应用健康存活的重要手段
liveness
podPreset
k8s一切皆对象的思想
编排其实很简单: 谈谈“控制器”模型
控制器模式
调谐,调谐的结果,往往是对被控制对象的写操作,这也是k8s“面向API对象编程”的一个直观体现
PodTemplate
经典PAAS的记忆: 作业副本与水平扩展
水平扩展
|
|
滚动更新
|
|
|
|
通过.spec.strategy.rollingUpdate.partition实现灰度
容器化守护进程的意义: DaemonSet
DaemonSet主要作用是,让你在K8s集群里,运行一个DaemonPod。这个Pod有以下特征
- 这个Pod运行在K8s集群的每一个节点上
- 这个节点上只有一个这样的Pod实例
- 当有新的节点加入k8s集群后,该Pod会自动地在新节点上被创建出来;而当旧结点被删除后,它上面的Pod也相应地被回收掉
相应的实现有各网络插件的Agent、各存储插件的Agent组件、各种监控组件和日志组件
controllerRevision
撬动离线业务: Job与CronJob
Deployment、StatefulSet、DaemonSet主要编排对象是长作业(Long Running Task),比如Nginx、Mysql等,这些应用一旦运行起来,除非出错或停止,它的容器进程会一直处于Running状态
对于离线业务或叫batch job(计算业务),这种业务在计算完后就直接退出了,如果用Deployment来管理这种业务的话,就会发现Pod回在计算结束后退出,然后被Deployment Controller不断重启。在k8s中使用Job来管理离线业务
|
|
restartPolicy在Job对象中只允许被直射为Never和OnFailure,在Deployment对象中,restartPolicy则只允许被设置为Always
如果定义了restartPolicy=Never,那么离线作业失败后Job Controller就会不断尝试创建一个新Pod,我们可以通过Job对象的spec.backoffLimit字段设定重试次数
如果定义了restartPolicy=OnFailur,那么作业失败后,job controller不会尝试重新创建新的Pod,但是会不断尝试重启Pod里的容器
|
|
并行作业
Job对象中,负责并行控制的参数有两个:
- spec.parallelism,它定义的是一个job在任意时间最多可以启动多少个Pod同时运行
- spec.completions,它定义的是Job至少要完成的Pod数目,即Job的最小完成数
job controller控制的对象,直接就是Pod
用法1:外部管理器+job
|
|
创建Job时,替换掉$ITEM这样的变量
所有来自于同一个模板的Job,都有一个jobgroup:jobexample标签,也就是说这一组Job使用这样一个相同的标识
|
|
用法2:拥有固定任务数的并行job
用法3:指定并行数但不指定completions值
CronJob
CronJob描述的是定时任务
|
|
其实CronJob是一个对Job对象的控制器
|
|
需要注意的是,由于定时任务的特殊性,很可能某个Job还没执行完,另一个新Job旧产生了。这时我们可以通过spec.concurrencyPolicy字段来定义具体的处理策略。比如:
1.concurrencyPolicy=Allow,这也是默认情况,意味着这些Job可以同时存在
2.concurrencyPolicy=Forbid,这意味着不会创建新的Pod,该创建周期被跳过
3.concurrencyPolicy=Replace,这意味着新产生的Job会替换旧的、没有执行完的Job
失败一次,这次创建就会标记为miss,如果达到100次miss,那么cronjob就会停止再创建这个job。这个时间窗口,可以由spec.startingDeadlineSeconds字段指定,比如spec.startingDeadlineSeconds=200,意思是过去200s里,如果miss的数目达到100次,那么这个job就不会被创建执行了。
声明式API与kubernets编程范式
声明式API操作
即kubectl apply命令
与使用kubectl replace等有什么区别吗?
实际上,可以简单理解,kubectl replace执行过程,是使用新的YAML文件中过的API对象,替换原有的API对象;而kubectl apply,则是执行了一个对原有API对象的PATCH操作。
类似的,kubectl set image 和kubectl edit也是对已有API对象的修改
更进一步地,这意味着kube-apiserver在响应命令式请求(比如kubectl replace)的时候,一次只能处理一个写请求,否则有产生冲突的可能。而对于声明式请求(比如 kubectl apply),一次能处理多个写操作,并具备merge能力
以istio为例,istio是一个基于k8s项目的微服务治理框架,架构如下
以上我们可以看出,istio最根本的组件是运行在一个应用pod里的envoy容器
Envoy项目是Lyft公司推出的一个高性能C++网络代理,在istio项目中,Envoy代理服务以sidecar容器的方式运行在每一个被治理的应用pod中。我们知道,Pod里所有容器都共享一个Network Namespace。所以Envoy容器就能通过配置Pod里的iptables规则,把整个Pod的进出流量接管下来。
这时,istio的控制层(control plane)里的pilot组件,就能够通过调用每个Envoy容器的API,对这个Envoy代理进行配置,从而实现微服务治理。
一起看一个例子
假设这个istio架构图左边Pod是已经在运行的应用,右边Pod则是我们刚刚上线的新版本。这时,Pilot通过调节这两个Pod里的Envoy容器配置,从而将90%的流量分配给旧版本应用,将10%流量分配给新版本应用,这样一个典型的“灰度发布”的场景就完成了。比如Istio可以调节这个流量从90%-10%,改为80%-20%,再到50%-50%,最后到0%-100%,就完成了这个灰度发布的过程。
在整个微服务过程中,无论是对Envoy容器的部署,还是像上面这样对Envoy代理的配置,用户和应用都是完全“无感”的
这时你会疑问,istio项目明明需要在每个Pod里安装一个Envoy容器,又怎么能做到无感呢?
实际上,istio项目使用的,是k8s中一个非常重要的功能,叫做Dynamic Admission Control.
在k8s中,当一个Pod或任何一个API对象被提交给APIServer之后,总有一些“初始化”性质的工作需要在它们被k8s项目正式处理之前进行。比如,自动为所有Pod加上某些标签(Lables)
而这个“初始化”操作的实现,借助的是一个叫做Admission的功能。它其实是k8s项目里一组被称为Admission Controller的代码,可以选择性地被编译进APIServer中,在API对象创建之后会被立刻调用到。这意味着如果现在想添加一些自己的规则到Admission Controller的话,需要重新编译并重启APIServer,显然,这种方法影响太大。所以K8s项目为我们额外提供了一种“热插拔”式Admission机制,它就是Dynamic Admission Control,也叫Initializer
声明式API的独特之处
- 所谓声明式,指的就是我只需要提交一个定义好的API对象来“声明”,我所期望的状态是什么样子。
- 其次,“声明式API”允许有多个API写端,以PATCH的方式对API对象进行修改,而无需关心本地原始YAML文件的内容
- 最后,有了上述两个能力,k8s项目才可以基于API对象的增删改查在完全无需外界干预的情况下,完成对“实际状态”和”期望状态”的调谐过程
所以说声明式API,才是k8s项目编排能力“赖以生存”的核心所在
深入解析声明式API: API对象的奥秘
一个API对象在Etcd里的完整资源路径,是由Group(API组)、Version(API版本)、Resource(API资源类型)三个部分组成的
基于角色的权限控制
Role+RoleBinding+ServiceAccount
Role:角色,它其实是一组规则
subject: 被作用者
RoleBinding:定义了”被作用者”和”角色“的绑定关系
聪明的微创新: Operator工作原理解读
PV、PVC、StorageClass
用户提交请求创建pod,Kubernetes发现这个pod声明使用了PVC,那就靠PersistentVolumeController帮它找一个PV配对。
没有现成的PV,就去找对应的StorageClass,帮它新创建一个PV,然后和PVC完成绑定。
新创建的PV,还只是一个API 对象,需要经过“两阶段处理”变成宿主机上的“持久化 Volume”才真正有用:
第一阶段由运行在master上的AttachDetachController负责,为这个PV完成 Attach 操作,为宿主机挂载远程磁盘;
第二阶段是运行在每个节点上kubelet组件的内部,把第一步attach的远程磁盘 mount 到宿主机目录。这个控制循环叫VolumeManagerReconciler,运行在独立的Goroutine,不会阻塞kubelet主循环。
完成这两步,PV对应的“持久化 Volume”就准备好了,POD可以正常启动,将“持久化 Volume”挂载在容器内指定的路径。
浅谈容器网络
Linux容器所能看见的“网络栈”,实际上是被隔离在它自己的Network Namespace当中的
所谓“网络栈”包括: 网卡,回环设备、路由表、iptables规则。对一个进程来说,这些要素构成了它发起和响应网络请求的基本环境。
容器间通信
容器可以声明直接使用宿主机的网络栈(—net=host),即不开启network namespace,而更多地情况下,我们希望容器能够使用自己的网络栈,即拥有属于自己的IP地址和端口。
这时一个显而易见的问题就被抛出来了: 隔离容器间如何交互呢?
为理解这个问题,可以把每个容器看做一台主机,他们都有一套独立的网络栈。
如果想实现两台主机之间通信,最直接的办法,就是把他们用一根网站连接起来;而如果想实现多台主机通信,那就需要用网线,把它们连接到一台交换机上。
在Linux中,能够给起到虚拟交换机作用的设备,是网桥(bridge)。它是一个工作在数据链路层的设备,主要功能是根据MAC地址学习来讲数据包转发到网桥的不同端口上。
在Docker项目中默认会在宿主机上创建一个名叫docker0的网桥,如何将容器连接到docker0网桥上呢?
这时候我们需要一种名叫Veth Pair的虚拟设备了。Veth Pair设备的特点是: 它总是以两张虚拟网卡(Veth Peer)的形式成对出现。并且,从其中一个网卡发出的虚拟包,可以直接出现在与它对应的另一张网卡上,哪怕这两个网卡在不同的namespace中。
这时你应该明白了,我们可以把Veth Pair作为连接不同network namespace的网线
宿主机上
|
|
接下来一个问题,刚才是同宿主机内容器的交互,那么不同宿主机间容器如何通信呢?
这其实就是容器的”跨主通信”问题。在Docker默认配置下,一台宿主机上的docker0网桥和其它宿主机上的docker0网桥没有任何关联,它们之间也无法连通。所以连接在这些网桥上的容器,自然也就无法通信了。
不过万变不离其宗,如果我们通过软件的方式,创建一个整个集群“公共”的网桥,然后把集群中所有容器都连接到这个网桥上,不就可以通信了?
没错,这样一来我们整个集群的容器网络就会类似下图:
可以看到,构建这种容器网络的核心在于:我们需要在已有的宿主机网络上,再通过软件构建一个覆盖在已有宿主机网络之上的、可以把所有容器连通在一起的虚拟网络。所以,这种技术就被称为Overlay Network(覆盖网络)
深入解析容器跨主机网络
隧道机制
- UDP
- VXLAN
这两种模式是很多其他容器网络插件的基础。比如Weave的两种模式,以及Docker的Overlay模式
UDP
UDP模式存在严重的性能问题,问题出在哪呢?
UDP模式需要多次用户态与内核态之间的数据拷贝,仅在发出IP包的时候就要进行3次拷贝
第一次: 用户态的容器进程发出的IP包经过docker0网桥进入内核态
第二次: IP包根据路由表进入TUN(flannel0)设备,从而回到用户态的flanneld进程
第三次: flanneld进行UDP封包之后重新进入内核态,将UDP包通过宿主机的eth0发出去
我们在进行系统编程时,有个非常非常重要的优化原则,就是要减少用户态到内核态的切换次数,并把核心的处理逻辑都放在内核态进行。这也是为什么,Flannel后来支持的VXLAN模式,主键成为了主流的容器网络方案的原因
VXLAN
VXLAN的设计思想是:在现有的三层网络之上,“覆盖”一层虚拟的、由内核VXLAN模块负责维护的二层网络,使得连接这个VXLAN二层网络的“主机”(虚拟机或容器)之间,可以像在同一个局域网里那样自由通信。当然,实际上,这些主机可能分布在不同的宿主机上,甚至是分布在不同的物理机房里。
为了能够在二层网络上打通”隧道“,VXLAN会在宿主机上设置一个特殊的网络设备作为”隧道“的两端。这个设备就叫做VTEP,即VXLAN Tunnel End Point(虚拟隧道端点)
VXLAN本身就是Linux内核的一个模块,它进行封装和解封装的对象是二层数据帧,而这个工作的执行过程,全部是在内核里完成的
kubernetes网络模型与CNI网络插件
kubernetes网络模型
- 所有容器都可以直接使用IP地址与其他容器通信,而无需使用NAT
- 所有宿主机都可以直接使用IP地址与所有容器通信,而无需使用NAT,反之亦然
- 容器自己”看到”的自己的IP地址,和别人(宿主机或者容器)看到的地址是完全一样的
解读kubernetes三层网络方案
为什么说Kubernetes只有soft multi-tenancy(弱多租户)
kubernetes里的Pod默认都是”允许所有“(Accept ALL)的,即:Pod可以接受来自任何发送方的请求;或者,向任何接收方发送请求。
Kubernetes的网络方案对”隔离”到底是如何考虑的呢?难道kubernetes就不管网络“多租户”的需求了吗?
kubernetes对Pod进行隔离的手段,即NetworkPolicy。
NetworkPolicy实际上是宿主机上的一系列iptables规则。这个传统laas里面的安全组(Security Group)是非常类似的,而基于上述讲述,你会发现这样一个事实:
kubernetes的网络模型以及大多数容器网络实现,其实既不保证容器之间二层网络的互通,也不会实现容器之间的二层网络隔离。这跟laaS项目管理虚拟机的方式,是完全不同的。
所以说,kubernetes从底层的设计和实现上,更倾向于假设你已经有了一套完整的物理基础设备,然后kubernetes负责在此基础上提供一种”弱多租户”的能力。
|
|
该隔离规则只对 default Namespace 下的,携带了 role=db 标签的 Pod 有效。限制的请求类型包括 ingress(流入)和 egress(流出)。
Kubernetes 会拒绝任何访问被隔离 Pod 的请求,除非这个请求来自于以下“白名单”里的对象,并且访问的是被隔离 Pod 的 6379 端口。这些“白名单”对象包括:
default Namespace 里的,携带了 role=fronted 标签的 Pod;
任何 Namespace 里的、携带了 project=myproject 标签的 Pod;
任何源地址属于 172.17.0.0/16 网段,且不属于 172.17.1.0/24 网段的请求。
Kubernetes 会拒绝被隔离 Pod 对外发起任何请求,除非请求的目的地址属于 10.0.0.0/24 网段,并且访问的是该网段地址的 5978 端口。
iptables
资料)
iptables是在linux内核里挡在网卡和用户态进程之间的一道防火墙。他们的关系如下:
Tables
iptables包含5张表
- raw
- filter
- nat
- mangle
- security
大部分情况下仅需要使用filter和nat
链(Chains)
表由链组成,链是一些按顺序排列的规则的列表。
默认filter包含INPUT、OUTPUT、FORWARD三条内置链。nat表包含PREROUTING、POSTROUTING和OUTPUT链
|
|
找到容器不容易: Service、DNS与服务发现
kubernetes之所以需要service,一方面是因为Pod的IP不是固定的,另一方面是因为一组Pod实例之间总会有负载均衡的需求
|
|
这个service的例子中,使用selector字段来声明这个service只代理写到了app=hostnames标签的Pod,并且,这个Service的80端口,代理的是Pod的9376端口,而访问9376端口的话,应用会返回自己的hostname。
被selector选中的Pod,就成为Service的Endpoints
|
|
只有处于Running状态且readinessProbe检查通过的Pod,才会出现在Service的Endpoints列表中。并且当某一个Pod出现问题时,Kubernetes会自动把它从Service里摘除。
此时,通过该Service的VIP地址10.0.1.175,你就可以访问到它所代理的Pod了
|
|
这个VIP地址是kubernetes自动为Service分配的。
Service分为以下几种模式
- ClusterIP模式
你可能比较好奇,kubernetes里的Service究竟是如何工作的呢?
实际上Service是由kube-proxy组件,加上iptables共同实现的。
拿上面创建的service为例,一旦它被提交给kubernetes,那么kube-proxy就可以通过service的Informer感知到这样一个Service对象的添加。而作为对这个事件的相应,它就会在宿主机上创建这样一条iptables规则,(可以通过iptables-save看到它),如下
|
|
这条规则的含义是: 凡是目的地址是10.106.133.173,目的端口是80的IP包,都应该跳转到另外一条名叫KUBE-SVC-ODX2UBAZM7RQWOIU
的iptables链处理。
而我们前面已经看到10.106.133.173正是这个Service的VIP,所以这条规则就为Service设置了一个固定的入口地址,并且由于10.106.133.173只是一条iptables规则上的配置,并没有真正的网络设备,所以你ping这个地址,是不会有任何响应的
对于KUBE-SEP-E345ZOKZUP634EZV
这条规则,你可以看到它是一组规则的集合。iptables的规则匹配是从上到下逐条进行的,所以为保证上述三条规则每条被选中的概率相同,它们的probalility字段值依次被设置为1/3、1/2和1。这三条链最终目的地,其实就是这个Service代理的三个Pod,所以这一组规则,就是Service实现负载均衡的位置。
|
|
另外,这三条链,其实三条DNAT规则。DNAT规则的作用就是在PREROUTING之前,也就是路由之前,将流入IP报的目的地址和端口,改为—to-destination所指定的新的目的地址和端口。可以看到,这个目的地址和端口,正式被代理Pod的IP地址和端口。
这样,访问Service VIP的IP包经过iptables处理后,就编程了访问具体某个一个后端Pod的IP包了。这些Endpoints对应的iptables规则,正是kube-proxy通过监听Pod的变化时间,在宿主机上生成并维护的。
此时宿主机(master和工作节点)都可以访问VIP(机器上都有对应的iptables记录)
不难看出,当宿主机有大量Pod时,成百上千条iptables规则不断地被刷新,会大量占用宿主机的CPU资源,甚至会让宿主机”卡“在这个过程。所以说,一直以来,基于iptables的Service实现,都是制约kubernetes项目承载更多量级的Pod的主要障碍。
而IPVS模式的Service,就是解决这个问题的一个行之有效的方法。
IPVS模式的工作原理,其实跟iptables类似。当我们创建Service后,kube-proxy会首先在宿主机上创建一个虚拟网卡(kube-ipvs0),并为它分配Service VIP作为IP地址,如下
|
|
接着,kube-proxy就会通过Linux的IPVS模块,为这个IP地址设置三个IPVS虚拟主机,并设置这三个虚拟主机指甲您使用轮训(rr)模式来作为负载均衡策略。我们可以通过ipvsadm查看这个设置,如下:
|
|
相比iptables,IPVS在内核中实现也是基于Netfilter的NAT模式,所以在转发这一层上,理论上IPVS并没有显著的性能提升。但是IPVS并不需要在宿主机上为每个Pod设置iptables规则,而是把对这些”规则“的处理放到了内核态,从而极大的降低了维护这些规则的代价。这也印证了”将重要操作放入内核态”是提高性能的重要手段。
不过需要注意的是,IPVS模块只负责上述的负载均衡和代理功能,而一个完整的Service流程正常工作所需的包过滤、SNAT等操作,还是要考iptables来实现。只不过这些辅助的iptables规则数量有限,也不会随着Pod数量增加而增加。
所以在大规模集群里,非常建议为Kube-proxy设置—proxy-mode=ipvs来启动这个功能。
Service和DNS的关系
在kubernetes中,Service和Pod都会被分配对应的DNS A记录(从域名解析IP的记录)
Cluster IP模式 | Headless Service模式 | |
---|---|---|
Service | Service.namspace.svc.cluster.local | Service.namspace.svc.cluster.local |
Pod | Pod-ip-address.my-namespace.pod.cluster.local | Pod-ip-address.my-namespace.pod.cluster.local |
其实Service机制,以及kubernetes里的DNS插件,都是在帮你解决同一个同问,即: 如何找到我的某一个容器
这个问题在平台级项目中,往往被称为服务发现,即:当我的一个服务(Pod)的IP地址是不固定的且没有办法提前获知的,我该如何通过一个固定的方式访问到这Pod呢?
ClusterIP模式的Service为你提供的,是一个Pod的稳定的IP地址,即VIP。并且,这里Pod和Service的关系是可以通过Label确定的。
而Headless Service为你提供的,则是一个Pod的稳定的DNS名称,并且,这个名字是可以通过Pod名字和Service名字拼接出来的
Docker模块
Docker Daemon
Docker Client
network
execdriver/volumedriver/graphdriver
镜像管理
容器化思维
网络实战
Linux网络虚拟化
veth - Virtual Ethernet Device(虚拟以太网设备)
容器编排
FAQ
kubeadm token过期导致工作节点无法加入集群
重新生成token并打印加入集群命令
|
|
—ttl 过期时间 default:24h0m0s 设置0表示永不过期
资源有哪些常用的命令
|
|
|
|
—record可以在annotation中记录记录当前命令创建或升级了该资源。
—output=yaml 以yaml的形式输出资源描述信息,可以查看资源当前配置及默认信息
如何在一个yaml文件中声明多个资源
使用---
分割资源
|
|
使用get无法获取到已启动的pod
很大可能是namespace导致的
kubectl get resource 默认使用的namespace是default
|
|
如何查找容器和宿主机上veth设备的关系
容器内,查看容器网络链接到系统级的网卡的唯一编号
|
|
宿主机上,通过ip link查看链路层信息 查到链路号66对应的网卡 vethc1385a4
|
|
这样就可以确定该容器在宿主机上对应的veth pair是vethc1385a4
此时宿主机上brctl show
可以看到vethc1385a4是链接在docker0虚拟网桥上的
ps: 另一个简单的办法是在容器中使用ip a
查看网卡信息,注意其中的@if12
,表示对应宿主机的网卡12
|
|
如何在docker run时运行多条命令
|
|
iptables与路由表的关系
路由表主要用于路由功能,负责的是网络层的routing decision.
iptables是linux内核的数据包过滤、操作子系统,作用与机器网络传输的各个环节,通常用于操作NAT