导语 | 当今容器化技术发展盛行。本文将介绍从单机容器化技术Docker到分布式容器化架构方案Kubernetes,主要面向小白读者,旨在快速带领读者了解Docker、Kubernetes的架构、原理、组件及相关使用场景。
一、Docker
(一)什么是Docker
Docker是一个开源的应用容器引擎,是一种资源虚拟化技术,让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的Linux机器上。虚拟化技术演历路径可分为三个时代:
在没有Docker的时代,我们会使用硬件虚拟化(虚拟机)以提供隔离。这里,虚拟机通过在操作系统上建立了一个中间虚拟软件层Hypervisor,并利用物理机器的资源虚拟出多个虚拟硬件环境来共享宿主机的资源,其中的应用运行在虚拟机内核上。但是,虚拟机对硬件的利用率存在瓶颈,因为虚拟机很难根据当前业务量动态调整其占用的硬件资源,加之容器化技术蓬勃发展使其得以流行。
Docker、虚拟机对比:
另外开发人员在实际的工作中,经常会遇到测试环境或生产环境与本地开发环境不一致的问题,轻则修复保持环境一致,重则可能需要返工。但Docker恰好解决了这一问题,它将软件程序和运行的基础环境分开。开发人员编码完成后将程序整合环境通过DockerFile打包到一个容器镜像中,从根本上解决了环境不一致的问题。
(二)Docker的构成
Docker由镜像、镜像仓库、容器三个部分组成。
(三)Docker的实现原理
到此读者们肯定很好奇Docker是如何进行资源虚拟化的,并且如何实现资源隔离的,其核心技术原理主要有(内容部分参考自Docker核心技术与实现原理):
(https://draveness.me/docker/)
>在日常使用Linux或者macOS时,我们并没有运行多个完全分离的服务器的需要,但是如果我们在服务器上启动了多个服务,这些服务其实会相互影响的,每一个服务都能看到其他服务的进程,也可以访问宿主机器上的任意文件,这是很多时候我们都不愿意看到的,我们更希望运行在同一台机器上的不同服务能做到完全隔离,就像运行在多台不同的机器上一样。
命名空间(Namespaces)是Linux为我们提供的用于分离进程树、网络接口、挂载点以及进程间通信等资源的方法。Linux的命名空间机制提供了以下七种不同的命名空间,通过这七个选项我们能在创建新的进程时设置新进程应该在哪些资源上与宿主机器进行隔离。
1. CLONE_NEWCGROUP; 2. CLONE_NEWIPC; 3. CLONE_NEWNET; 4. CLONE_NEWNS; 5. CLONE_NEWPID; 6. CLONE_NEWUSER; 7. CLONE_NEWUTS。
在Linux系统中,有两个特殊的进程,一个是pid为1的/sbin/init进程,另一个是pid为2的kthreadd进程,这两个进程都是被Linux中的上帝进程 idle创建出来的,其中前者负责执行内核的一部分初始化工作和系统配置,也会创建一些类似getty的注册进程,而后者负责管理和调度其他的内核进程。
当在宿主机运行Docker,通过`docker run`或`docker start`创建新容器进程时,会传入CLONE_NEWPID实现进程上的隔离。
接着,在方法`createSpec`的`setNamespaces`中,完成除进程命名空间之外与用户、网络、IPC以及UTS相关的命名空间的设置。
func (daemon *Daemon) createSpec(c *container.Container) (*specs.Spec, error) { s := oci.DefaultSpec()
// ... if err := setNamespaces(daemon, &s, c); err != nil { return nil, fmt.Errorf("linux spec namespaces: %v", err) }
return &s, nil}
func setNamespaces(daemon *Daemon, s *specs.Spec, c *container.Container) error { // user // network // ipc // uts
// pid if c.HostConfig.PidMode.IsContainer() { ns := specs.LinuxNamespace{Type: "pid"} pc, err := daemon.getPidContainer(c) if err != nil { return err } ns.Path = fmt.Sprintf("/proc/%d/ns/pid", pc.State.GetPID()) setNamespace(s, ns) } else if c.HostConfig.PidMode.IsHost() { oci.RemoveNamespace(s, specs.LinuxNamespaceType("pid")) } else { ns := specs.LinuxNamespace{Type: "pid"} setNamespace(s, ns) }
return nil}
当Docker容器完成命名空间的设置,其网络也变成了独立的命名空间,与宿主机的网络互联便产生了限制,这就导致外部很难访问到容器内的应用程序服务。Docker提供了4种网络模式,通过`--net`指定。
由于后续介绍Kubernetes利用了Docker的bridge网络模式,所以仅介绍该模式。Linux中为了方便各网络命名空间的网络互相访问,设置了Veth Pair和网桥来实现,Docker也是基于此方式实现了网络通信。
下图`eth0`与`veth9953b75`是一个Veth Pair,`eth0`与`veth3e84d4f`为另一个Veth Pair。Veth Pair在容器内一侧会被设置为`eth0`模拟网卡,另一侧连接Docker0网桥,这样就实现了不同容器间网络的互通。加之Docker0为每个容器配置的iptables规则,又实现了与宿主机外部网络的互通。
解决了进程和网络隔离的问题,但是 Docker 容器中的进程仍然能够访问或者修改宿主机器上的其他目录,这是我们不希望看到的。
在新的进程中创建隔离的挂载点命名空间需要在clone函数中传入 CLONE_NEWNS,这样子进程就能得到父进程挂载点的拷贝,如果不传入这个参数子进程对文件系统的读写都会同步回父进程以及整个主机的文件系统。当一个容器需要启动时,它一定需要提供一个根文件系统rootfs,容器需要使用这个文件系统来创建一个新的进程,所有二进制的执行都必须在这个根文件系统中,并建立一些符号链接来保证IO不会出现问题。
另外,通过Linux的`chroot`命令能够改变当前的系统根目录结构,通过改变当前系统的根目录,我们能够限制用户的权利,在新的根目录下并不能够访问旧系统根目录的结构个文件,也就建立了一个与原系统完全隔离的目录结构。
Control Groups(CGroups)提供了宿主机上物理资源的隔离,例如 CPU、内存、磁盘 I/O和网络带宽。主要由这几个组件构成:
每一个CGroup下面都有一个tasks文件,其中存储着属于当前控制组的所有进程的pid,作为负责cpu的子系统,cpu.cfs_quota_us文件中的内容能够对CPU的使用作出限制,如果当前文件的内容为50000,那么当前控制组中的全部进程的CPU占用率不能超过50%。
当我们使用Docker关闭掉正在运行的容器时,Docker的子控制组对应的文件夹也会被Docker进程移除。
Docker中的每一个镜像都是由一系列只读的层组成的,Dockerfile中的每一个命令都会在已有的只读层上创建一个新的层:
联合文件系统(Union File System),它可以把多个目录内容联合挂载到同一个目录下,而目录的物理位置是分开的。UnionFS可以把只读和可读写文件系统合并在一起,具有写时复制功能,允许只读文件系统的修改可以保存到可写文件系统当中。Docker之前使用的为AUFS(Advanced UnionFS),现为Overlay2。
Docker中的每一个镜像都是由一系列只读的层组成的,Dockerfile中的每一个命令都会在已有的只读层上创建一个新的层:
FROM ubuntu:15.04COPY . /appRUN make /appCMD python /app/app.py
容器中的每一层都只对当前容器进行了非常小的修改,上述的Dockerfile 文件会构建一个拥有四层layer的镜像:
当镜像被`docker run`命令创建时就会在镜像的最上层添加一个可写的层,也就是容器层,所有对于运行时容器的修改其实都是对这个容器读写层的修改。容器和镜像的区别就在于,所有的镜像都是只读的,而每一个容器其实等于镜像加上一个可读写的层,也就是同一个镜像可以对应多个容器。
二、Kubernetes
Kubernetes,简称 K8s,其中8代指中间的8个字符。Kubernetes 项目庞大复杂,文章不能面面俱到,因此这个部分将向读者提供一种主线学习思路:+ 为什么要Kubernetes?+ 什么是Kubernetes?+ Kubernetes提供的组件及适用场景+ Kubernetes的架构+ Kubernetes架构模块实现原理有更多未交代或浅尝辄止的地方读者可以查阅文章或书籍深入研究。
(一)为什么要Kubernetes
尽管Docker为容器化的应用程序提供了开放标准,但随着容器越来越多出现了一系列新问题:
Kubernetes应运而生。
Kubernetes是一个全新的基于容器技术的分布式架构方案,这个方案虽然还很新,但却是Google十几年来大规模应用容器技术的经验积累和升华的重要成果,确切的说是Google一个久负盛名的内部使用的大规模集群管理系统——Borg的开源版本,其目的是实现资源管理的自动化以及跨数据中心的资源利用率最大化。
Kubernetes具有完备的集群管理能力,包括多层次的安全防护和准入机制、多租户应用支撑能力、透明的服务注册和服务发现机制、内建的智能负载均衡器、强大的故障发现和自我修复能力、服务滚动升级和在线扩容能力、可扩展的资源自动调度机制,以及多力度的资源配额管理能力。同时,Kubernetes提供了完善的管理工具,这些工具涵盖了包括开发、部署测试、运维监控在内的各个环节,不仅是一个全新的基于容器技术的分布式架构解决方案,还是一个一站式的完备分布式系统开发和支撑平台。
Pod是Kubernetes最重要的基本概念,可由多个容器(一般而言一个容器一个进程,不建议一个容器多个进程)组成,它是系统中资源分配和调度的最小单位。下图是Pod的组成示意图,其中有一个特殊的Pause容器:
Pause容器的状态标识了一个Pod的状态,也就是代表了Pod的生命周期。另外Pod中其余容器共享Pause容器的命名空间,使得Pod内的容器能够共享Pause容器的IP,以及实现文件共享。以下是一个Pod的定义:
apiVersion: v1 # 分组和版本kind: Pod # 资源类型metadata: name: myWeb # Pod名 labels: app: myWeb # Pod的标签spec: containers: - name: myWeb # 容器名 image: kubeguide/tomcat-app:v1 # 容器使用的镜像 ports: - containerPort: 8080 # 容器监听的端口 env: # 容器内环境变量 - name: MYSQL_SERVICE_HOST value: 'mysql' - name: MYSQL_SERVICE_PORT value: '3306' resources: # 容器资源配置 requests: # 资源下限,m表示cpu配额的最小单位,为1/1000核 memory: "64Mi" cpu: "250m" limits: # 资源上限 memory: "128Mi" cpu: "500m"
EndPoint : PodIP + containerPort,代表一个服务进程的对外通信地址。一个Pod也存在具有多个Endpoint的情 况,比如当我们把Tomcat定义为一个Pod时,可以对外暴露管理端口与服务端口这两个Endpoint。
Label是Kubernetes系统中的一个核心概念,一个Label表示一个 key=value的键值对,key、value的值由用户指定。Label可以被附加到各种资源对象上,例如Node、Pod、Service、RC等,一个资源对象可以定义任意数量的Label,同一个Label也可以被添加到任意数量的资源对象上。Label通常在资源对象定义时确定,也可以在对象创建后动态添加或者删除。给一个资源对象定义了Label后,我们随后可以通过Label Selector查询和筛选拥有这个Label的资源对象,来实现多维度的资源分组管理功能,以便灵活、方便地进行资源分配、调度、配置、部署等管理工作。Label Selector当前有两种表达式,基于等式的和基于集合的:
以myWeb Pod为例:
apiVersion: v1 # 分组和版本kind: Pod # 资源类型metadata: name: myWeb # Pod名 labels: app: myWeb # Pod的标签
当一个Service的selector中指明了这个Pod时,该Pod就会与该Service绑定:
apiVersion: v1 kind: Service metadata: name: myWeb spec: selector: app: myWeb ports: - port: 8080
Replication Controller,简称RC,简单来说,它其实定义了一个期望的场景,即声明某种Pod的副本数量在任意时刻都符合某个预期值。
RC的定义包括如下几个部分:
apiVersion: v1kind: ReplicationControllermetadata: name: frontendspec: replicas: 3 # Pod 副本数量 selector: app: frontend template: # Pod 模版 metadata: labels: app: frontend spec: containers: - name: tomcat_demp image: tomcat ports: - containerPort: 8080
当提交这个RC在集群中后,Controller Manager会定期巡检,确保目标Pod实例的数量等于RC的预期值,过多的数量会被停掉,少了则会创建补充。通过`kubectl scale`可以动态指定RC的预期副本数量。
目前,RC已升级为新概念——Replica Set(RS),两者当前唯一区别是,RS支持了基于集合的Label Selector,而RC只支持基于等式的Label Selector。RS很少单独使用,更多是被Deployment这个更高层的资源对象所使用,所以可以视作RS+Deployment将逐渐取代RC的作用。
Deployment和RC相似度超过90%,无论是作用、目的、Yaml定义还是具体命令行操作,所以可以将其看作是RC的升级。而Deployment 相对于RC的一个最大区别是我们可以随时知道当前Pod“部署”的进度。实际上由于一个Pod的创建、调度、绑定节点及在目 标Node上启动对应的容器这一完整过程需要一定的时间,所以我们期待系统启动N个Pod副本的目标状态,实际上是一个连续变化的“部署过程”导致的最终状态。
apiVersion: v1kind: Deploymentmetadata: name: frontendspec: replicas: 3 selector: matchLabels: app: frontend matchExpressions: - {key: app, operator: In, values [frontend]} template: metadata: labels: app: frontend spec: containers: - name: tomcat_demp image: tomcat ports: - containerPort: 8080
除了手动执行`kubectl scale`完成Pod的扩缩容之外,还可以通过Horizontal Pod Autoscaling(HPA)横向自动扩容来进行自动扩缩容。其原理是追踪分析目标Pod的负载变化情况,来确定是否需要针对性地调整目标Pod数量。当前,HPA有一下两种方式作为Pod负载的度量指标:
apiVersion: autoscaling/v1kind: HorizontalPodAutoscalermetadata: name: php-apache namespace: defaultspec: maxReplicas: 3 minReplicas: 1 scaletargetRef: kind: Deployment name: php-apache targetCPUUtilizationPercentage: 90
根据上边定义,当Pod副本的CPUUtilizationPercentage超过90%时就会出发自动扩容行为,数量约束为1~3个。
在Kubernetes系统中,Pod的管理对象RC、Deployment、DaemonSet和Job都面向无状态的服务。但现实中有很多服务是有状态的,特别是 一些复杂的中间件集群,例如MySQL集群、MongoDB集群、Akka集 群、ZooKeeper集群等,这些应用集群有4个共同点。
因此,StatefulSet具有以下特点:
Headless Service : Headless Service与普通Service的关键区别在于, 它没有Cluster IP,如果解析Headless Service的DNS域名,则返回的是该 Service对应的全部Pod的Endpoint列表。
Service在Kubernetes中定义了一个服务的访问入口地址,前端的应用(Pod)通过这个入口地址访问其背后的一组由Pod副本组成的集群实例Service与其后端Pod副本集群之间则是通过Label Selector,来实现无缝对接的。
apiVersion: v1kind: servicemetadata: name: tomcat_servicespec: ports: - port: 8080 name: service_port - port: 8005 name: shutdown_port selector: app: backend
在Kubernetes集群中,每个Node上会运行着kube-proxy组件,这其实就是一个负载均衡器,负责把对Service的请求转发到后端的某个Pod实例上,并在内部实现服务的负载均衡和绘画保持机制。其主要的实现就是每个Service在集群中都被分配了一个全局唯一的Cluster IP,因此我们对 Service的网络通信根据内部的负载均衡算法和会话机制,便能与Pod副本集群通信。
因为ClusterIP在Service的整个声明周期内是固定的,所以在Kubernetes中,只需将Service的Name和其Cluster IP做一个DNS域名映射即可解决。
Volume是Pod中能够被多个容器访问的共享目录,Kubernetes中的 Volume概念、用途、目的与Docker中的Volumn比较类似,但不等价。首先,其可被定义在Pod上,然后被一个Pod里的多个容器挂载到具体的文件目录下;其次,Kubernetes中的Volume与Pod的生命周期相同,但与容器的生命周期不相关,当容器终止或者重启时,Volume中的数据也不会丢失。
template: metadata: labels: app: frontend spec: volumes: # 声明可挂载的volume - name: dataVol emptyDir: {} containers: - name: tomcat_demo image: tomcat ports: - containerPort: 8080 volumeMounts: # 将volume通过name挂载到容器内的/mydata-data目录 - mountPath: /mydata-data name: dataVol
Kubernetes提供了非常丰富的Volume类型:
在使用虚拟机的情况下,我们通常会先定义一个网络存储,然后从中划出一个“网盘”并挂接到虚拟机上。Persistent Volume(PV) 和与之相关联的 Persistent Volume Claim(PVC) 也起到了类似的作用。PV可以被理解成Kubernetes集群中的某个网络存储对应的一块存储,它与Volume类似,但有以下区别:
apiVersion: v1kind: PersistentVolumemetadata: name: pv001 spec: capacity: storage: 5Gi accessMods: - ReadWriteOnce nfs: path: /somePath server: xxx.xx.xx.x
> accessModes,有几种类型,1.ReadWriteOnce:读写权限,并且只能被单个Node挂载。2. ReadOnlyMany:只读权限,允许被多个Node挂载。3.ReadWriteMany:读写权限,允许被多个Node挂载。
如果Pod想申请某种类型的PV,首先需要定义一个PersistentVolumeClaim对象:
apiVersion: v1kind: PersistentVolumeClaim # 声明pvcmetadata: name: pvc001 spec: resources: requests: storage: 5Gi accessMods: - ReadWriteOnce
然后在Pod的Volume中引用PVC即可。
volumes: - name: mypd persistentVolumeClaim: claimName: pvc001
PV有以下几种状态:
Namespace在很多情况下用于实现多租户的资源隔离。分组的不同项目、小组或用户组,便于不同的分组在共享使用整个集群的资源的同时还能被分别管理。Kubernetes集群在启动后会创建一个名为default的Namespace,通过kubectl可以查看:
我们知道,Docker通过将程序、依赖库、数据及 配置文件“打包固化”到一个不变的镜像文件中的做法,解决了应用的部署的难题,但这同时带来了棘手的问题,即配置文件中的参数在运行期如何修改的问题。我们不可能在启动Docker容器后再修改容器里的配置文件,然后用新的配置文件重启容器里的用户主进程。为了解决这个问题,Docker提供了两种方式:
在大多数情况下,后一种方式更合适我们的系统,因为大多数应用通常从一个或多个配置文件中读取参数。但这种方式也有明显的缺陷:我们必须在目标主机上先创建好对应 配置文件,然后才能映射到容器里。上述缺陷在分布式情况下变得更为严重,因为无论采用哪种方式,写入(修改)多台服务器上的某个指定文件,并确保这些文件保持一致,都是一个很难完成的目标。针对上述问题,Kubernetes给出了一个很巧妙的设计实现。
首先,把所有的配置项都当作key-value字符串,这些配置项可以作为Map表中的一个项,整个Map的数据可以被持久化存储在Kubernetes的 Etcd数据库中,然后提供API以方便Kubernetes相关组件或客户应用 CRUD操作这些数据,上述专门用来保存配置参数的Map就是Kubernetes ConfigMap资源对象。Kubernetes提供了一种内建机制,将存储在etcd中的ConfigMap通过Volume映射的方式变成目标Pod内的配置文件,不管目标Pod被调度到哪台服务器上,都会完成自动映射。进一步地,如果ConfigMap中的key-value数据被修改,则映射到Pod中的“配置文件”也会随之自动更新。
Kubernetes由Master节点、Node节点以及外部的ETCD集群组成,集群的状态、资源对象、网络等信息存储在ETCD中,Mater节点管控整个集群,包括通信、调度等,Node节点为工作真正执行的节点,并向主节点报告。Master节点由以下组件构成:
Kubernetes API Server通过一个名为kube-apiserver的进程提供服务,该进程运行在Master上。在默认情况下,kube-apiserver进程在本机的8080端口(对应参数--insecure-port)提供REST服务。我们可以同时启动HTTPS安全端口(--secure-port=6443)来启动安全机制,加强 REST API访问的安全性。
由于API Server是Kubernetes集群数据的唯一访问入口,因此安全性与高性能就成为API Server设计和实现的两大核心目标。通过采用HTTPS安全传输通道与CA签名数字证书强制双向认证的方式,API Server的安全性得以保障。此外,为了更细粒度地控制用户或应用对Kubernetes资源对象的访问权限,Kubernetes启用了RBAC访问控制策略。Kubernetes的设计者综合运用以下方式来最大程度地保证API Server的性能。
一个角色就是一组权限的集合,都是以许可形式,不存在拒绝的规则。Role作用于一个命名空间中,ClusterRole作用于整个集群。
apiVersion:rbac.authorization.k8s.io/v1beta1kind:Rolemetadata: namespace: default #ClusterRole可以省略,毕竟是作用于整个集群 name: pod-readerrules:- apiGroups: [""] resources: ["pod"] verbs: ["get","watch","list"]
RoleBinding和ClusterRoleBinding是把Role和ClusterRole的权限绑定到ServiceAccount上。
kind: ClusterRoleBindingapiVersion: rbac.authorization.k8s.io/v1metadata: namespace: default name: app-adminsubjects:- kind: ServiceAccount name: app apiGroup: "" namespace: defaultroleRef: kind: ClusterRole name: cluster-admin apiGroup: rbac.authorization.k8s.io
Service Account也是一种账号,但它并不是Kubernetes集群的用户 (系统管理员、运维人员、租户用户等)用的,而是给运行在Pod里的进程用的,它为Pod里的进程提供了必要的身份证明。在每个Namespace下都有一个名为default的默认Service Account对象,在这个Service Account里面有一个名为Tokens的可以当作Volume被挂载到Pod里的 Secret,当Pod启动时,这个Secret会自动被挂载到Pod的指定目录下,用来协助完成Pod中的进程访问API Server时的身份鉴权。
下边介绍几种Controller Manager的实现组件:
kubernetes 的配额管理使用过 Admission Control来控制的,提供了两种约束,LimitRanger和 ResourceQuota。LimitRanger 作用于 Pod 和 Container 之上(limit ,request),ResourceQuota 则作用于 Namespace。资源配额,分三个层次:
管理Namesoace创建删除。
Endpoints表示一个service对应的所有Pod副本的访问地址,而Endpoints Controller就是负责生成和维护所有Endpoints对象的控制器。
> 负责监听Service和对应Pod副本的变化,若Service被创建、更新、删除,则相应创建、更新、删除与Service同名的Endpoints对象。 > EndPoints对象被Node上的kube-proxy使用。
Kubernetes Scheduler的作用是将待调度的Pod(API新创 建的Pod、Controller Manager为补足副本而创建的Pod等)按照特定的调 度算法和调度策略绑定(Binding)到集群中某个合适的Node上,并将绑定信息写入etcd中。Kubernetes Scheduler当前提供的默认调度流程分为以下两步:
Kubernetes的网络利用了Docker的网络原理,并在此基础上实现了跨Node容器间的网络通信。
CNI提供了一种应用容器的插件化网络解决方案,定义对容器网络 进行操作和配置的规范,通过插件的形式对CNI接口进行实现,以Flannel举例,完成了Node间容器的通信模型。
可以看到,Flannel首先创建了一个名为flannel0的网桥,而且这个网桥的一端连接docker0网桥,另一端连接一个叫作flanneld的服务进程。flanneld进程并不简单,它上连etcd,利用etcd来管理可分配的IP地址段资源,同时监控etcd中每个Pod的实际地址,并在内存中建立了一个Pod节点路由表;它下连docker0和物理网络,使用内存中的Pod节点路由表,将docker0发给它的数据包包装起来,利用物理网络的连接将数据包投递到目标flanneld上,从而完成Pod到Pod之间的直接地址通信。
从Kubernetes1.11版本开始,Kubernetes集群的DNS服务由CoreDNS提供。CoreDNS是CNCF基金会的一个项目,是用Go语言实现的高性能、插件式、易扩展的DNS服务端。
三、结语
文章包含的内容说多不多,说少不少,但对于Docker、Kubernetes知识原理的小白来说是足够的,笔者按照自己的学习经验,以介绍为出发点,让大家更能了解相关技术原理,所以实操的部分较少。Kubernetes技术组件还是十分丰富的,文章有选择性地进行了介绍,感兴趣的读者可以再自行从官方或者书籍中学习了解。
作者简介
胡洪昊
腾讯后台开发工程师
胡洪昊,腾讯后台开发工程师。毕业于东北大学,对云原生相关技术有浓厚兴趣,有一定二次开发实践经验,目前负责看点信息流投放链路相关工作。
推荐阅读