其实我挺早就接触Docker和Kubernetes,时间大概在3、4年前吧,但是由于当时所在技术团队的业务模式所限制,还没有真正对容器云有技术需求,所以我更多还是以一种技术玩具的心态接触容器技术。
直到去年开始才正式接触基于容器云平台的技术架构,我从业务运维和DevOps的角度来看,容器云平台与之前的物理机和虚拟机等IaaS层基础上的运维模式有着非常大的差异。
根据这段时间的运维经验,我尝试总结一下某些容器云的运维方法的共同特性,并将其称为“容器运维模式”,简单百度谷歌了一下,没有这个名词,希望是我的首创:)
这个名词灵感来自软件工程的“设计模式”,设计模式(DesignPattern)是一套被反复使用、多数人知晓的、经过分类的、代码设计经验的总结。使用设计模式的目的:为了代码可重用性、让代码更容易被他人理解、保证代码可靠性。设计模式使代码编写真正工程化;设计模式是软件工程的基石脉络,如同大厦的结构一样。
而“容器运维模式”,指的是由DevOps(题外话:DevOps、SRE、SA、运维等等,其实都差不多是同一个意思,业界喜欢创一个新的名词来代替运维,主要是为了区分自己和一些低端系统维护人员)在日常运维容器化项目的一些经验总结,为了区别于传统的物理机、虚拟机的运维套路,而归纳出来的容器运维方法。
回顾过去
从大概10年前,大家都是以【自建IDC】+【物理服务器】的形式进行生产环境基础架构的建设。
然后持续到大概5年前,私有云技术和公有云的兴起,让大批中小型企业减少对物理设备资源建设的人力和资金投入,可以专注于业务研发和运营。
最后到大概3、4年前,容器技术Docker和以Kubernetes为代表的容器编排技术的崛起,以及微服务技术的同步普及,宣告了容器云平台的来临。
而事实上,以Kubernetes为首的相关周边项目,已经成为了容器云领域的首选标准,所以绝大部分技术团队如果现在需要选型容器编排体系,可以无脑选k8s了。
需求的根本——应用交付
在传统裸机(bare metal)或虚拟化的时代,当开发团队将代码交付给运维进行生产环境中部署,但是它却未能正常工作时,挑战就出现了。
“运行环境不一致”、“没有安装相关依赖软件”、“配置文件不一样”等等已经成了开发和运维沟通的惯用语。
在传统的开发场景中,开发和测试团队使用的是与生产环境不同的基础设施,尽管做到了代码和配置解耦,但是在运行环境的转换中,依然会得到像前面所述的团队协作和环境依赖问题。
而贯穿软件生命周期共享相同的容器镜像是容器化带来的最大好处,它简化了开发与运维团队之间的协作关系。
由于本地开发/测试服务器和生产环境的不一致以及应用程序打包部署的过程,一直是让研发和运维纠结的难题,但有了容器之后,由于容器镜像里打包的不仅是应用,而是整个操作系统的文件和目录,即其运行所需的所有依赖,都能被封装一起。
有了容器镜像的打包能力之后,这些应用程序所需的基础依赖环境,也成为了这个应用沙盒的一部分,这可以给这个应用包赋予这样的能力:无论在开发、测试还是生产环境运行,我们只需要解压这个容器镜像,那么这个应用所需的所有运行依赖都是存在的、一致的。
如果熟悉Docker容器技术原理的话,我们知道它主要由Linux内核的Namespace和CGroups以及rootfs技术隔离出来一种特殊进程。
把Docker形容为一个房子的话,Namespace构成了四面墙,为PID\NET\MNT\UTS\IPC等资源进行隔离;CGroups形成了它的天花板,限制了对系统资源的占用;而rootfs是其地基,是通过copy-on-write机制构成的分层镜像,也是开发者最为关心的应用信息的传递载体。
作为开发者,他们可能不关心由前两者构成的容器运行时的环境差异,因为真正承载容器化应用的传递载体,是这个不变的容器镜像。
在Docker技术的普及后不久,为了整个完整的DevOps链条的打通,包括CI/CD、监控、网络、存储、日志收集等生产环境的刚需,以及整个容器生命周期的管理和调度,以Kubernetes为首的容器编排体系也作为上层建筑也迎来了一波快速的增长。从容器到容器云的蜕变,标志着容器运维时代的来临。
容器运维模式的主要场景分析
1、声明式 vs 命令行
我们知道Kubernetes是通过yaml文件(样例如上所示)来对其API对象,如Deployment、Pod、Service、DaemonSet等进行期望状态的描述,然后k8s的控制器有一套状态调谐的机制让各种API对象按要求所述的状态运行。由于这样一套运行机制的存在,所以使得k8s和过往运维常见的命令行,也包括脚本式的运行方式有着很大的差异。
深度使用过puppet的运维工程师可能会比较清楚两者的区别,puppet也是一套基于声明式机制的配置管理和状态管理的工具。在没有puppet之前,运维工程师喜欢用简单的shell、python脚本对众多服务器进行统一的软件安装、配置管理,但随着服务器数量增多和配置项的递增,命令行式的配置管理往往出现各种缺陷。如状态不一致、历史版本无法回滚、配置没有幂等性、需要很多状态判断才能执行最终的操作等等。
而声明式的配置管理方法,可以规避以上弊端,原因如下:
当我们确认了一个版本yaml配置文件后,表示向k8s的Kube-Controller-Manager提交了我们所期望的对象状态信息,然后k8s使用patch的方式对API对象进行修改。而声明式API是k8s项目编排能力的核心所在,它可以在无需干预的情况下对api对象进行增删改查,完成对“期望状态”和“实际状态”的reconcile过程。
以我们常用的deployment对象为例。
1)方式一
2)方式二
首次创建使用 create ,修改yaml使用edit,然后用replace使之生效。
k8s对这两种机制的处理方法是完全不同的,前者是声明式,后者是命令式。
两者的结果虽然都是触发滚动更新,但是前者是对原有API对象打patch,后者是对象的销毁和替换。前者能一次处理多个yaml配置变更的写操作并具备相同配置项的merge能力,后者只能逐个处理,否则有冲突的可能。
所以,我们只需要确认yaml文件的版本,一律通过 kubectl apply命令进行执行,无需再考虑第一步创建、第二步修改、第三步替换之类的命令行。那么我们统一用apply命令,可以通过history命令进行回溯版本,也可以保证apply的结果的幂等性等等。
使用声明式只需要描述最终所需的状态,无需用户关心过多的实现流程和细节,没有像命令行式的那么多上下文关系或者运行环境依赖,甚至可以由开发人员直接编写,运维进行codereview即可。特别在使用Kubernetes这样的容器编排工具,更加要深刻理解和灵活运用声明式的运维模式。
2、API对象
Kubernetes大量的API对象的存在是导致其运维方法和传统系统层运维有区别较大的重要原因之一。
如果我们要深入了解k8s,则需要理解一些它核心的API对象,才能更好地理解这个容器的运行系统。如果把容器理解成一种特殊带有资源隔离、资源限制的进程,那么Pod对象是一组进程组,最后,k8s是运行众多有关联的进程组(Pod)的操作系统。
这一层操作系统运行在PaaS层,比我们传统运维的Linux系统所在的IaaS层要高一层。
而我们在理解这个在PaaS层的k8s对象的概念时,需要一些面向对象的编程思想,会让整个思路梳理地更加清晰。
所谓的面向对象,即在编码过程中设定一切事物皆对象,通过面向对象的方式,将现实世界的事物抽象成对象,现实世界中的关系抽象成类、继承,帮助人们实现对现实世界的抽象与数字建模。
通过面向对象的方法,更利于用人理解的方式对复杂系统进行分析、设计与编程。同时,面向对象能有效提高编程的效率,通过封装技术,消息机制可以像搭积木的一样快速开发出一个全新的系统。
面向对象是指一种程序设计范型,同时也是一种程序开发的方法。对象指的是类的集合。它将对象作为程序的基本单元,将程序和数据封装其中,以提高软件的重用性、灵活性和扩展性。
在系统层运维时候,我们关注的有CPU、内存、IO等硬件对象,以及软件安装卸载、系统服务启停、环境变量、内核版本等软件对象等等,就足以理解和把控整个操作系统运行环境。
理解这些对象可以当成是一种面向过程的思维,因为最初操作系统的设计就是当时的计算机大牛们通过面向过程的思维所写出来的,所以系统很多组成概念无需要面向对象思维就可以理解。
众所周知,Kubernetes是根据谷歌内部运行多年的Borg项目的架构体系所创造出来,所以它具备天生的项目架构前瞻性。一般的开源项目是理论基础走在工程应用的后面,比如docker+swarm为代表,都是现实应用中遇到什么需求,就新增一个功能,慢慢从一个单独容器docker再到了具备基本编排能力的swarm。反观Kubernetes,是一套自顶向下的架构设计,几乎能适配当前所有的应用架构模式,应对什么web-db、lb-web-redis-db、db-master-slave之类的常见架构根本不在话下。
再回到Kubernetes的API对象,k8s使用这些API对象来描述一个集群所期望的运行状态。
通常一个Kubernetes对象包含以下信息:需要运行的应用以及运行在哪些Node上、应用可以使用哪些资源、应用运行时的一些配置,例如副本数、重启策略、升级以及容错性等等。
通过上图可见API对象种类非常多,其实我们应该先重点掌握最核心的Node、Pod、Deployment、RS、Service、Namespace,以及它们之间的关系,这里就不详述了,请参考相关文档。
3、控制器模式
在说Kubernetes的控制器模式之前,我们先看看软件架构中十分常见的MVC模式,即Model(模型)、View(视图)、Controller(控制器)。
1)模型(Model)
用于封装与应用程序的业务逻辑相关的数据以及对数据的处理方法。“ Model”有对数据直接访问的权力,例如对数据库的访问。“Model”不依赖“View”和“Controller”,也就是说, Model不关心它会被如何显示或是如何被操作。但是 Model 中数据的变化一般会通过一种刷新机制被公布。为了实现这种机制,那些用于监视此 Model 的 View必须事先在此 Model 上注册,从而,View 可以了解在数据 Model 上发生的改变。比如:观察者模式(软件设计模式)。
2)视图(View)
能够实现数据有目的的显示(理论上,这不是必需的)。在 View 中一般没有程序上的逻辑。为了实现 View 上的刷新功能,View需要访问它监视的数据模型(Model),因此应该事先在被它监视的数据那里注册。
3)控制器(Controller)
起到不同层面间的组织作用,用于控制应用程序的流程。它处理事件并作出响应。“事件”包括用户的行为和数据 Model 上的改变。
MVC 模式强调职责分离,即视图和数据模型的分离,并利用控制器来作为这两者的逻辑控制的中介,使之具有逻辑复用、松散耦合等优点。
数据模型(Model),它描述了“应用程序是什么”,用于封装和保存应用程序的数据,同时定义操控和处理该数据的逻辑和运算。而且,Model通常是可以复用的。
一个良好的MVC应用程序应该将所有重要的数据都封装到Model中,而应用程序在将持久化的数据(文件、数据库)加载到内存中时,也应该保存在Model中。
因为Model本身就代表着业务的特定数据对象,而在k8s里面,典型的Model就是Pod。
视图(View),它是展现给用户的界面,这个不用多说。这个在k8s的应用不多,例如kubectl的信息输出或者Dashbord等,都可以算是一种View的应用。
控制器(Controller),它充当View和Model的媒介,将模型和视图绑定在一起,包括处理用户的配置输入,以此修改Model。反过来,View需要知道Model中数据的变化,也是通过Controller来完成。除此之外,Controller还可以为应用程序协调任务,管理其它对象的生命周期。在k8s里面,最典型的Controller就是Deployment。
在上文中我们提到了k8s拥有很多API对象,而其中一部分是属于控制器类型的特殊对象,我们可以进入k8s的代码目录:kubernetes/pkg/controller/*,查看所有控制机类型的API对象,包含:deployment\job\namespace\replicaset\cronjob\serviceaccount\volume等等。
由于k8s的架构体系中,View不算是其核心的功能模块,我们这里重点关注Controller和Model的关系,代入k8s对象的话,我们以最典型的Deployment和Pod的关系,作为主要的研究对象。
我们回头看看文章连载前面的 Deployment的yaml配置文件样例,可以划分为两大部分进行分析,配置文件的上半部分是属于控制器,下半部分是数据模型:
其实要深究起来,Deployment不是直接控制Pod,而是通过一个叫ReplicaSet的对象对Pod进行编排控制,所在在Pod的matadata里面会显示其owerReference是ReplicaSet。
也就是说在控制器对象的范围内,也会进行功能的分层,因为不同的控制机之间,存在着可以复用的功能逻辑,比如对Pod的副本数控制。
那么这时候可以抽象出一层例如像ReplicaSet的对象,进行对Pod的副本控制,除了Deployment以外,也存在其他的控制器对象可以利用ReplicaSet进行对Model的控制。
基于这样的分层思想,我们在生产环境场景的所遇到的需求,可以将其控制逻辑都在控制器这一层进行实现。
比如无状态的Deployment和有状态的StatefuleSet,或者每个Node只有一个DeamonSet,尽管各自实现的功能各不相同,但是它们都是可以共用同一套Pod对象的逻辑,而差异的部分都封装在控制器层。
4、接口和实现
接口这个词广泛存在于各种技术文档中,到底接口是什么?
其实,狭义的接口是指代码编写的一个技巧,比如在Java语言里面,一个接口(interface)的特性是只定义了方法返回值、名称、参数等,但没有定义其具体的实现。
接口(interface)无法被实例化,但是可以被实现。一个实现(implements)接口的类(class),必须实现接口内所描述的所有方法,否则就必须声明为抽象类(AbstractClass)。
Java 接口实现:
以上是Java的接口类型,但除了狭义的接口,我们在开发各种软件中也会用到广义的接口。
接口对于调用方来说就是一种事先约定好的协议,它也许是一些预先定义的函数,目的是提供应用程序与开发人员基于某软件或硬件得以访问一组例程的能力,而又无需访问源码,或理解内部工作机制的细节。
而在Kubernetes里面,其很多组件或者实现都采用了接口的形式,留给使用者非常灵活的扩展空间。
比如CRI \ CSI \ CNI 等等,都是Kubernetes留给其底层实现的接口方式。
Kubernetes作为云原生应用的优秀部署平台,已经开放了容器运行时接口(CRI)、容器网络接口(CNI)和容器存储接口(CSI),这些接口让Kubernetes的开放性变得最大化,而Kubernetes本身则专注于容器调度。
我们逐个了解一下以上3个接口,就可以对Kubernetes的实现思想有一定的感受,从而更深地理解其它类似的接口实现。
1)CRI (Container Runtime Interface,容器运行时接口)
Kubernetes其实不会直接和容器打交道,Kubernetes的使用者能接触到的概念只有pod,而pod里包含了多个容器。
CRI中定义了容器和镜像的服务的接口,因为容器运行时与镜像的生命周期是彼此隔离的。
当我们在Kubernetes里用kubectl执行各种命令时,这一切是通过Kubernetes工作节点里所谓“容器运行时”的软件在起作用。大家最熟悉的容器运行时软件当然是Docker,然而Docker只是Kubernetes支持的容器运行时技术的一种。
为了让Kubernetes不和某种特定的容器运行时(Docker)技术绑死,而是能无需重新编译源代码就能够支持多种容器运行时技术的替换,和我们面向对象设计中引入接口作为抽象层一样,在Kubernetes和容器运行时之间我们引入了一个抽象层,即容器运行时接口。就算Docker不再流行了,甚至有了Eocker、Focker等等,就可以通过CRI接口无缝地融入Kubernetes体系。
2)CSI (Container Storage Interface,容器存储接口)
CSI 代表容器存储接口,CSI 试图建立一个行业标准接口的规范,借助 CSI 容器编排系统(CO)可以将任意存储系统暴露给自己的容器工作负载。
类似于 CRI,CSI 也是基于 gRPC 实现。CSI 卷类型是一种 in-tree(即跟其它存储插件在同一个代码路径下,随 Kubernetes的代码同时编译的) 的 CSI 卷插件,用于 Pod 与在同一节点上运行的外部 CSI 卷驱动程序交互。部署 CSI 兼容卷驱动后,用户可以使用 csi作为卷类型来挂载驱动提供的存储。
3)CNI (Container Network Interface,容器存储接口)
CNI(Container NetworkInterface)是CNCF旗下的一个项目,由一组用于配置Linux容器的网络接口的规范和库组成,同时还包含了一些插件。CNI仅关心容器创建时的网络分配,和当容器被删除时释放网络资源。
Kubernetes 网络的发展方向是希望通过插件的方式来集成不同的网络方案, CNI就是这一努力的结果。CNI只专注解决容器网络连接和容器销毁时的资源释放,提供一套框架,所以CNI可以支持大量不同的网络模式,并且容易实现。
CNI的接口中包括以下几个方法:
有四个方法:添加网络、删除网络、添加网络列表、删除网络列表。
5、Master-Node模式与Api-server
Kubernetes有几个核心组件:kube-apiserver、kube-controller-manager、kube-scheduler、kubelet、kube-proxy、CRI(一般是docker)等等。
它们分别是运行在Master或者Node节点上面,我把Master和Node称为物理组件,因为它们是运行于物理环境的,如物理机或者虚拟机。其中Master提供集群的管理控制中心,而Node是真正接受执行任务的工作节点,可以拟人化地理解为:Master是用人经理,Node是工作人员。
而Etcd是用于存储配置信息或者其他需要持久化的数据,独立于Master和Node节点,一般也是三副本的方式运行。
区别于物理组件,逻辑组件是指在程序内的虚拟概念,例如运行在Master的逻辑组件有kube-apiserver、kube-controller-manager、kube-scheduler。
kube-apiserver用于暴露Kubernetes API。任何的资源请求/调用操作都是通过kube-apiserver提供的接口进行。
kube-controller-manager运行管理控制器,它们是集群中处理常规任务的后台线程。逻辑上,每个控制器是一个单独的进程,但为了降低复杂性,它们都被编译成单个二进制文件,并在单个进程中运行。
kube-scheduler监视新创建没有分配到Node的Pod,为Pod选择一个Node。
这几个组件的用途不作特别展开,我们后面将详细聊聊Apiserver。
Node是Kubernetes中的工作节点,最开始被称为minion。一个Node可以是VM或物理机。每个Node(节点)具有运行pod的一些必要服务,并由Master组件进行管理。
然后介绍运行于Node节点的组件:kubelet、kube-proxy、CRI(一般是docker)。
kubelet是主要的节点代理,它会监视已分配给节点的pod,具体功能如:安装Pod所需的volume;下载Pod的Secrets;Pod中运行的docker(或experimentally,rkt)容器;定期执行容器健康检查等等。
kube-proxy通过在主机上维护网络规则并执行连接转发来实现Kubernetes服务抽象。
Docker等容器运行时,作用当然就是用于运行容器。
对于以上的Kubernetes的Master和Node的节点模式,在很多支持分布式架构的软件中都是类似的,如Hadoop等。他们的Master节点往往需要有3个以上,以实现高可用架构。很多软件架构也采取了这样的设计方式,都是为了生产环境所需的高可用性服务。
3)Api-server
前面介绍过Master和Node,它们之间从Master(apiserver)到集群有两个主要的通信路径。第一个是从Apiserver到在集群中的每个节点上运行的kubelet进程。第二个是通过Apiserver的代理功能从Apiserver到任何Node、pod或service。
所以说Apiserver对于Master-Node模式来说是非常重要的沟通桥梁。
从Apiserver到kubelet的连接用于获取pod的日志,通过kubectl来运行pod,并使用kubelet的端口转发功能。这些连接在kubelet的HTTPS终端处终止。
从Apiserver到Node、Pod或Service的连接默认为HTTP连接,因此不需进行认证加密。也可以通过HTTPS的安全连接,但是它们不会验证HTTPS端口提供的证书,也不提供客户端凭据,因此连接将被加密但不会提供任何诚信的保证。这些连接不可以在不受信任/或公共网络上运行。
总结
从过去的【单体式应用+物理机】,到现在【微服务应用+容器云】的运行环境的变革,需要运维工程师同步改变以往的运维技术思维。新技术的应用,会引发更深层次的思考,深入了解容器之后,我们会自然而然地去学习业务最主流的编排工具——Kubernetes。
Kubernetes前身是谷歌的Borg容器编排管理平台,它充分体现了谷歌公司多年对编排技术的最佳实践。而容器云字面意思就是容器的云,实际指的是以容器为单位,封装环境、提供构建、发布、运行分布式应用平台。
而运维工程师在面对业界更新迭代极快的技术潮流下,需要选定一个方向进行深耕,无疑,Kubernetes是值得我们去深入学习的,毕竟它战胜了几乎所有的编排调度工具,成为业内编排标准。
我们通过搭建容器云环境下的应用运行平台,并实现运维自动化,快速部署应用、弹性伸缩和动态调整应用环境资源,提高研发运营效率,最终实现自身的运维价值。
作者介绍
温峥峰,小鹏汽车互联网中心运维高级经理,专注于运维自动化、DevOps实践、运维服务体系建设与容器运维时代下的价值挖掘。知乎专栏:HiPhone运维之道。