K8S作为新型基础服务组件,吸收云计算运维特点、充分解耦、提供最大的扩展性和灵活性,自身创造了诸多全新概念和名词。
K8S官网有详细的文档[1],但官方文档的编写是以功能为维度去关联概念,本文尝试从概念维度关联功能和意义,提供不同的学习视角,加深理解。本文会较全面的串讲k8s核心概念及实际意义。
Docker基本概念
Docker要干的事就俩字:隔离。用os支持的、高性能方式,在app和宿主机os之间加一扇中间层:容器(就像JVM夹在CPU和字节码之间),想把app放到其他位置运行的话,直接端走容器和里面的app就可以了,或者一个更形象的例子:我要在煤气灶(宿主机)上烤鱼(app),煤气灶有不同的类型、环境和火力,我要用鱼去适配这个煤气灶就得把鱼切成不同大小,非常麻烦,最简单的方式就是加一个锅容器(docker container),我端着锅和里面的鱼可以随便换灶。
容器技术是app移植的关键
回到Docker这边,比如我们要部署一套nginx,之前你需要下载tar包、安装依赖库等、进行系统配置等,而docker的出现让app部署一个命令就能完成,工作简化、便于控制、部署高效[2]。
同时docker生态具有共享的思维,和git类似,docker也有仓库(repository)的概念[3]。你可以找到官方或社区app的docker镜像,pull下来就可直接使用或进行定制化修改。
Docker基本概念
镜像(image)和容器(container)
既然我们可以利用docker端着我们的app到处部署安装,那我们就要区分安装包和已经安装的程序。
Image镜像可以理解为软件安装包,利用Dockerfile描述你app的配置信息。日常windows安装软件需要一个.exe文件吧,安装时设定路径、配置可选组件什么的,安装后桌面一个图标双击打开运行即可。Image是安装包则container就等于安装在你电脑上的程序实体(安装了就算,不管你打没打开)。
总结一下,镜像可类比app安装包、java中的class对象或一张光盘,是对app的静态描述。而经过docker run运行起来的镜像就是容器,可类比java中new出来的对象、是一个被安装的程序、是可直接使用的动态app实例[4]。
镜像分层
镜像内部层次复杂,比如我们自己写了一个web app,镜像里就要带着tomcat、ubuntu系统和许许多多web app要调用的组件,这样安装之后的这个web app才能直接运行。上述每一个组件都会进行分层,每一层都是一个相应具有独立功能的rootfs文件系统,最底层由Kernel的bootfs驱动(boot file system)。
镜像分层
分层带来的最直观好处就是资源节约和共享。image是网络请求拉下来的,比如这个web app用到了Debian OS,如果有另一个机器学习项目也用到了Debian,直接用本地的 Debian OS image就行,不需要重复拉取。
理论上docker image分为三层,由下向上分为:
- 只读层:就是这些最原始的image,os、web容器等
- init层:存放不能提交到镜像的个性化数据。比如你的app要根据网络配置不同的host文件,那这个个性化数据就要写到init层,但不能提交到image里,因为如果换个网络环境配置就无效了。
- 读写层:存放需要提交到镜像的个性化数据(docker commit)。比如一个业务jar文件,因为是完成容器业务功能的一部分,所以必须提交到镜像的最顶层读写层,当容器运行时所有层的image都要merge,就可以把这个jar文件写到容器的os中了,到这里你可以思考下删除文件如何配合merge工作。
Docker隔离原理
Docker的隔离分为环境隔离和资源隔离。环境隔离指进程、文件系统、网络等内容的隔离,多维度的隔离让容器内部更像一个独立的空间,其基于操作系统 namespace 实现,namespace类型很多[5],包括:
- PID Namespace 独立进程
- Mount
- UTS
- IPC
- Network
- User
资源隔离指容器对硬件资源的使用限制,包括cpu、内存、磁盘及网络带宽,该限制基于os的cgroup实现。
另外为了避免容器内的操作影响宿主机安全,docker采用chroot/pviot_root彻底隔离了宿主机和容器的文件系统,但同时共享宿主机系统内核。
如果需要进一步Dockerfile的编写入门资料,可以参照这份教程[6]。
新西兰种植园地主:Dockerfile编写理论到实战
K8S基本概念
介绍完docker,我们来了解下k8s和docker间的关系。docker的容器隔离让部署app变得可移植和傻瓜化,但在大规模集群场景下,需要一个集中控制中心,对各个节点(node,物理机,但也可以是虚拟机)发号施令。想象一个拥有一千台节点的集群,你无法挨个节点执行命令,也不知道上面到底存在什么容器、机器挂掉如何进行容器调度、如何自动扩容等,而k8s的出现就是解决这个问题的。
Pod
Pod(豆荚)是k8s调度的最小单位,有自身的生命周期[7],同时也是k8s最重要、最核心的概念。pod可以包含一个或多个容器,但通常情况只包含一个。
我们直接操纵容器就行了,为什么还要包一层pod呢?
这里体现了k8s概念的核心创造信条:系统设计中没有加一层解决不了的问题,如果有,就再加一层。
POD核心内容
首先,容器是docker的概念,k8s为了解耦,有必要对其进行包装,以后如果不需要docker或者容器概念发生升级,k8s仅处理pod对容器的控制逻辑即可,缩小影响范围。
其次,pod的本质是容器的增强器[8],能完成的功能包括:
- 多容器管理:多容器场景一般出现在几个容器间具有超耦合关系。比如一个web app可以有war包容器和tomcat容器,两者组合才能运行(init container+container=这个pod)。还有一类场景就是需要利用容器的设计模式:比如容器内应用需要统一外部可访问的接口,那就加装一个适配器(adapter)容器、要对app隐藏对外请求,则可以加装一个大使(ambassador)处理来自容器的本地请求、还可以部署一个边车(sidecar)容器收集日志后发给ELK等[9]。
- 暴露对外访问:pod默认运行在k8s私有网段,外部无法访问,但也可以通过 kubectl port-forward 开放临时调试。
- 滚动更新:配合deployment组件实现部署和升级策略。
- 容器健康检查:pod使用探针(probe)通过shell、tcp或http的方式对容器进行健康探测工作,当发现容器出现异常,pod会根据配置的不同重启策略(restartPolicy)对container作出后续操作。probe的种类可分为:
- Startup:检查app是否已经启动成功,否则重启且不开始后续探测。
- Liveness:检查app是否正常运行否则重启。
- Readiness:检查app是否可以对外提供服务否则不切入流量。
- 资源限制配置:通过limits字段限制容器的cpu时间片或内存占用等,也可以引入Metrics Server +Prometheus对资源使用情况可视化。
- 自动扩/缩容:配合HPA(HorizontalPodAutoscaler)组件完成。阈值完全基于 Metrics Server 上报的node 资源指标,比如CPU占用率。实际来看,建议用Custom Metrics 的方式自定义扩展,比如采用系统负载+其他指标的方案。
POD 核心能力扩展
ReplicaSet和Deployment
Pod管理了容器,那谁来管理pod呢?考虑到高可用和高性能,app pod需要集群部署,所以维护集群内pod数量等信息就需要在pod上再加一层,这就是副本集(ReplicaSet,RS),RS中可以声明期望的副本数,从而保证整个k8s中某组pod永远存在指定数量的集群副本。
Deploymeny/RS 核心内容
Deployment的目的则是支持app“部署”活动,具体功能包括:
- 监控ReplicaSet状态(扩/缩容进行中、完成、失败)
- 新旧pod替换策略(金丝雀发布[10]、recreate 重造等)
- 回滚策略(可指定版本回滚)
Deployment的核心是RS配置,同时提供了诸多部署过程的策略(由deployment controller 控制RS,而RS又去控制创建pod),还支持在线的服务数量scale伸缩。
Service
Deployment定义了pod工作组的部署活动及策略,但是有两个业务问题没有解决:
- 固定对外IP问题:k8s只为pod分配了默认的、非固定ip的请求地址,那如何对外暴露稳定地址?
- 负载均衡问题:对一组pod的访问如何负载均衡?
Service通过k8s设置的VIP和虚拟网卡,请求会最终访问到具体pod的ip(此为推荐的内核态的IPVS模式,并非iptables、userspace模式)。service其实就是一个工作在四层的负载均衡组件,反向代理了label selector能获取到的pod,如果pod发生变化,Service会通过 controller manager 实时监控到[11](集群状态信息注册发现实际存在etcd中)。
Service默认是“ClusterIP”模式只能在集群内部访问,对外开放则需要用“NodePort”模式开一个对外端口,但此模式限制多多,不推荐。综合来看,k8s的service更接近dubbo功能,定位于内部服务注册发现和负载均衡。另外,如果不需要负载均衡(如自建注册中心、有状态服务场景),也可以采用headless service。
Service概念思维导图
Ingress、Ingress Controller和Ingress Class
Service完成了注册发现和内部负载均衡,但四层负载均衡性能虽好,却拿不到HTTP信息、不够灵活,存在局限性。所以我们现在需要一个真正对外的网关(七层负载均衡设备),利用网关细分外部请求再转给各个Service(就像nginx通常做的那样,“代理Service的Service”[12])。Ingress的出现解决了这个问题,可进行更精细的请求规则转发(毕竟工作在7层嘛)。
但实际上k8s的设计没这么简单,为提高网关的灵活性和可替换性,网关又作出了三层逻辑拆分。我们可以思考一下,网关层转发功能实际可拆分为两部分:
- 转发的配置定义
- 实际做请求转发的程序
为了做逻辑划分,k8s将配置定义放在Ingress对象的rule中,真正做转发的程序定义为Ingress Controller,一般由社区实现,最有名的IC当然还是ng家的nginx-ingress-controller。
随着集群规模的扩大,出现内网外网需不同ingress-controller、多租户隔离等需求,于是k8s又引入可配置在Ingress内的Ingress Class[13],为选择不同的Ingress Controller使用。
DaemonSet
好,以上一套服务体系就基本形成了闭环,但现在pod的部署、宿主机的情况对我们来说依然是个黑盒子。DaemonSet就是一种可在每个宿主机仅调度部署一套的特殊pod,作用一般是配合集群监控、收集宿主机负载、收集日志等工作,这种app在一台node上部署多个也毫无意义。
另外DaemonSet还存在污点/容忍度概念,与pod和node间调度相关,可自行了解。
K8S架构[14]
K8S架构图
明确k8s基本概念后其架构会容易理解很多,k8s是中心化服务,分为控制面(Control Plane,master)和数据面(即Node)。控制面是K8S的大脑,组件包含:
- etcd数据库,负责注册服务、保存k8s配置和所有的API对象等数据
- scheduler 负责监视pod的调度情况
- controller-manager 负责节点、任务、端点和账户的总体调度
- apiserver 对外提供操作接口,可以通过kubectl调用。
数据面Node是具体部署app的虚拟机或宿主机,最核心的组件就是kubelet,负责启动node并管理container,此外还有kube-proxy做k8s service的网络代理。
K8S存储体系
PV和PVC
K8S提供了一套管理docker volume的存储体系,用同Ingress类似的方式进行了分层解耦,包括:
- PersistentVolume(PV):一个预制的资源实体或数据卷,比如指定大小的磁盘空间(hostPath)、临时资源(emptyDir)或NFS等,支持的Volume种类非常丰富[15]。
- PersistentVolumeClaim(PVC):PVC和PV不同,PV是具体的存储实体,而PVC代表一份使用空间的“申请 claim”,描述了pod需要申请多大空间、什么类型等,然后通过 PersistentVolumeController不断尝试进行PV与PVC的自动绑定,如果能找到合法的PV则申请成功,否则pod启动失败。比如PVC要申请10MB的磁盘空间,但是找了半天才发现PV最大的才8MB,因此PV管理会非常麻烦,平时不能申请太多浪费空间,但有时又面临可用PV不足的问题,于是为了方便运维又引入一个子概念StorageClass。
- StorageClass:就是用来自动创建PV并执行PV与PVC绑定的。StorageClass实际就是创建PV的模板,在设定PVC时,将你定义的StorageClass对象设置上去即可,就不需要手动分配PV空间了。
PersistentVolume相关概念
需要强调的是,hostPath这种本地磁盘型的pv是不支持pod在不同节点间迁移的,可以尝试引入pod的原地重建插件或直接使用其他网络类型的PV如NFS。
有状态服务
有状态服务的有状态指的是:
- 拓扑结构:服务间依赖关系、主从依赖关系
- 数据持久化,PV和pod之间的强依赖关系
StatefulSet
为了解决拓扑依赖问题,StatefulSet采用headless service避免负载均衡并为每个pod设置了一个DNS,重启时会按照最初按顺序命名的app0、app1、app2... one by one 的顺序启动,保证了拓扑状态的稳定性。
StatefulSet会直接控制pod,在数据持久化时就固定了要声明的PVC编号,且PVC不会因为pod消失而消失,新pod建立时会寻找旧pod的PVC,重新完成绑定关系。
StatefulSet 相关概念
不过有的问题依然存在:比如一个redis集群,redis0 pod启动之后作为master,其余redis1、redis2相继启动且都作为redis0的slave去同步rdb文件,问题体现在:
- 拓扑关系死板固定。我们要进行写操作只能固定的使用redis0网络地址, 不支持自动选主切换机制。
- 主从等设置非常麻烦。比如读写分离,要在yml配置中获取pod自身的启动序号,当自身是redis0则用master配置,其他则用slave配置;或者设置主、从两组StatefulSet分别配置,总之非常死板麻烦。
Operator[16]
Operator是用编程的方式解决 StatefulSet 僵化问题的方案,其利用自定义API,相当于自定义了类型并自动化了一系列工作,其本质是对k8s的扩展,比如可以定义好你对集群的期望后,启动种子集群,然后逐步启动新pod加入。
ConfigMap/Secret
类似分布式配置中心工作,ConfigMap(cm)存储在k8s控制面,而Pod引入的cm支持实时更新,同时还支持文件类型。Secret就是加密的cm。
总结
现在我们知道,K8S以容器技术为依托,逐步解决了大规模服务运维、部署和调度和问题。
Pod是k8s中最核心的概念,绝大部分能力都依托pod这一核心,并进行能力扩展。
这些扩展的能力融合了现代运维和分布式服务治理工作,包括耳熟能详的:滚动更新、灰度发布、健康检查、负载监控、自动扩容、资源隔离、服务隔离、路由网关、负载均衡、主从关系运维、服务注册发现、分布式配置等等诸多方面,更高级别的服务治理由istio以pod边车的方式提供,包括分布式追踪、限流等功能。最后,k8s并没有放弃复杂的有状态服务部署,依然在这个领域的自动化运维上作出努力。
最后,我们可以再次思考k8s体现的分层设计理念,分层虽然创造了许多复杂的概念、提高了学习门槛,却强化了整体架构的灵活性和扩展性。