Kubernetes是Google开源的容器集群管理系统,k8s是一种容器编排技术,可以在集群主机间进行自动化部署、扩展和容器操作的提供以容器为中心基础设施的开源平台。一般大家接触的k8s基于docker容器,其实Kubernetes不仅仅支持Docker,还支持Rocket,这是另一种容器技术。

K8s项目

Kubernetes一键部署利器:kubeadm

  首先要真正发挥容器技术的实力,你就不能仅仅局限于对 Linux 容器本身的钻研和使用。这些知识更适合作为你的技术储备,以便在需要的时候可以帮你更快的定位问题,并解决问题。而更深入的学习容器技术的关键在于,如何使用这些技术来“容器化”你的应用。

  比如,我们的应用既可能是 Java Web 和 MySQL 这样的组合,也可能是 Cassandra 这样的分布式系统。而要使用容器把后者运行起来,你单单通过 Docker 把一个 Cassandra 镜像跑起来是没用的。要把 Cassandra 应用容器化的关键,在于如何处理好这些 Cassandra 容器之间的编排关系。比如,哪些 Cassandra 容器是主,哪些是从?主从容器如何区分?它们之间又如何进行自动发现和通信?Cassandra 容器的持久化数据又如何保持,等等。这也是为什么我们要反复强调 Kubernetes 项目的主要原因:这个项目体现出来的容器化“表达能力”,具有独有的先进性和完备性。这就使得它不仅能运行 Java Web 与 MySQL 这样的常规组合,还能够处理 Cassandra 容器集群等复杂编排问题。所以,对这种编排能力的剖析、解读和最佳实践,将是本专栏最重要的一部分内容。

  万事开头难,作为一个典型的分布式项目,Kubernetes 的部署一直以来都是挡在初学者前面的一只“拦路虎”。尤其是在 Kubernetes 项目发布初期,它的部署完全要依靠一堆由社区维护的脚本。后面虽然有了 SaltStack、Ansible 等运维工具自动化地执行部署。但这些工具的学习成本可能比 Kubernetes 项目还要高。直到 2017 年,在志愿者的推动下,社区才终于发起了一个独立的部署工具,名叫:kubeadm。这个项目的目的,就是要让用户能够通过这样两条指令完成一个 Kubernetes 集群的部署

# 创建一个 Master 节点
$ kubeadm init
# 将一个 Node 节点加入到当前集群中
$ kubeadm join <Master 节点的 IP 和端口 >

那么kubeadm 的工作原理是什么呢?

从0到1:搭建一个完整的Kubernetes集群

使用Kubernetes 集群发布第一个容器化应用

K8s中的Pod

为什么我们需要Pod?

  Pod,是 Kubernetes 项目的原子调度单位。我们在前面已经花了很多精力去解读 Linux 容器的原理、分析了 Docker 容器的本质,终于,“Namespace 做隔离,Cgroups 做限制,rootfs 做文件系统”这样的“三句箴言”可以朗朗上口了,为什么 Kubernetes 项目又突然搞出一个 Pod 来呢?

  要回答这个问题,我们还是要一起回忆一下我曾经反复强调的一个问题:容器的本质到底是什么?你现在应该可以不假思索地回答出来:容器的本质是进程。

  没错。容器,就是未来云计算系统中的进程;容器镜像就是这个系统里的“.exe”安装包。那么Kubernetes 呢?你应该也能立刻回答上来:Kubernetes 就是操作系统!

  我们在linux里使用$ pstree -g命令,会发现在一个真正的操作系统里,进程并不是“孤苦伶仃”地独自运行的,而是以进程组的方式,“有原则的”组织在一起。在这个进程的树状图中,每一个进程后面括号里的数字,就是它的进程组 ID(Process Group ID, PGID)。对于操作系统来说,这样的进程组更方便管理。举个例子,Linux 操作系统只需要将信号,比如,SIGKILL 信号,发送给一个进程组,那么该进程组中的所有进程就都会收到这个信号而终止运行。而 Kubernetes 项目所做的,其实就是将“进程组”的概念映射到了容器技术中,并使其成为了这个云计算“操作系统”里的“一等公民”。

引入Pod 原因1

  Kubernetes 项目之所以要这么做的原因,我在前面介绍 Kubernetes 和 Borg 的关系时曾经提到过:在 Borg 项目的开发和实践过程中,Google 公司的工程师们发现,他们部署的应用,往往都存在着类似于“进程和进程组”的关系。更具体地说,就是这些应用之间有着密切的协作关系,使得它们必须部署在同一台机器上。而如果事先没有“组”的概念,像这样的运维关系就会非常难以处理。

  假设我们的 Kubernetes 集群上有两个节点:node-1 上有 3 GB 可用内存,node-2 有 2.5 GB 可用内存。这时,假设我要用 Docker Swarm 来运行这个 rsyslogd 程序。为了能够让这三个容器都运行在同一台机器上,我就必须在另外两个容器上设置一个 affinity=main(与 main 容器有亲密性)的约束,即:它们俩必须和 main 容器运行在同一台机器上。然后,我顺序执行:“docker run main”“docker run imklog”和“docker run imuxsock”,创建这三个容器。这样,这三个容器都会进入 Swarm 的待调度队列。然后,main 容器和 imklog 容器都先后出队并被调度到了 node-2 上(这个情况是完全有可能的)。可是,当 imuxsock 容器出队开始被调度时,Swarm 就有点懵了:node-2 上的可用资源只有 0.5GB 了,并不足以运行 imuxsock 容器;可是,根据 affinity=main 的约束,imuxsock 容器又只能运行在 node-2 上。这就是一个典型的成组调度(gang scheduling)没有被妥善处理的例子。在工业界和学术界,关于这个问题的讨论可谓旷日持久,也产生了很多可供选择的解决方案。

  但是,到了 Kubernetes 项目里,这样的问题就迎刃而解了:Pod 是 Kubernetes 里的原子调度单位。这就意味着,Kubernetes 项目的调度器,是统一按照 Pod 而非容器的资源需求进行计算的。所以,像 imklog、imuxsock 和 main 函数主进程这样的三个容器,正是一个典型的由三个容器组成的 Pod。Kubernetes 项目在调度时,自然就会去选择可用内存等于 3 GB 的 node-1 节点进行绑定,而根本不会考虑 node-2。

引入Pod 原因2

  而如果 Pod 的设计只是出于调度上的考虑,那么 Kubernetes 项目似乎完全没有必要非得把 Pod作为“一等公民”吧?这不是故意增加用户的学习门槛吗?没错,如果只是处理“超亲密关系”这样的调度问题,有 Borg 和 Omega 论文珠玉在前,Kubernetes 项目肯定可以在调度器层面给它解决掉。不过,Pod 在 Kubernetes 项目里还有更重要的意义,那就是:容器设计模式。
  为了理解这一层含义,我就必须先给你介绍一下Pod 的实现原理。首先,关于 Pod 最重要的一个事实是:它只是一个逻辑概念。也就是说,Kubernetes 真正处理的,还是宿主机操作系统上 Linux 容器的 Namespace 和Cgroups,而并不存在一个所谓的 Pod 的边界或者隔离环境。那么,Pod 又是怎么被“创建”出来的呢?答案是:Pod,其实是一组共享了某些资源的容器。具体的说:Pod 里的所有容器,共享的是同一个 Network Namespace,并且可以声明共享同一个Volume。

  那这么来看的话,一个有 A、B 两个容器的 Pod,不就是等同于一个容器(容器 A)共享另外一个容器(容器 B)的网络和 Volume 的玩儿法么?这好像通过 docker run --net --volumes-from 这样的命令就能实现嘛。不过这样的话容器 B 就必须比容器 A 先启动,这样一个 Pod 里的多个容器就不是对等关系,而是拓扑关系了。但是这已经离真正的解决方法很接近了。在 Kubernetes 项目里,Pod 的实现需要使用一个中间容器,这个容器叫作 Infra 容器。在这个 Pod 中,Infra 容器永远都是第一个被创建的容器,而其他用户定义的容器,则通过 JoinNetwork Namespace 的方式,与 Infra 容器关联在一起。这样的组织关系,可以用下面这样一个示意图来表达:

Pod

  如上图所示,这个 Pod 里有两个用户容器 A 和 B,还有一个 Infra 容器。很容易理解,在Kubernetes 项目里,Infra 容器一定要占用极少的资源,所以它使用的是一个非常特殊的镜像,叫作:k8s.gcr.io/pause 。这个镜像是一个用汇编语言编写的、永远处于“暂停”状态的容器,解压后的大小也只有 100~200 KB 左右。而在 Infra 容器“Hold 住”Network Namespace 后,用户容器就可以加入到 Infra 容器的Network Namespace 当中了。所以,如果你查看这些容器在宿主机上的 Namespace 文件,它们指向的值一定是完全一样的。
这也就意味着,对于 Pod 里的容器 A 和容器 B 来说:

  1.它们可以直接使用 localhost 进行通信;
  2.它们看到的网络设备跟 Infra 容器看到的完全一样;
  3.一个 Pod 只有一个 IP 地址,也就是这个 Pod 的 Network Namespace 对应的 IP 地址;
  4.当然,其他的所有网络资源,都是一个 Pod 一份,并且被该 Pod 中的所有容器共享;
  5.Pod 的生命周期只跟 Infra 容器一致,而与容器 A 和 B 无关。

  而对于同一个 Pod 里面的所有用户容器来说,它们的进出流量,也可以认为都是通过 Infra 容器完成的。这一点很重要,因为将来如果你要为 Kubernetes 开发一个网络插件时,应该重点考虑的是如何配置这个 Pod 的 Network Namespace,而不是每一个用户容器如何使用你的网络配置,这是没有意义的。有了这个设计之后,共享 Volume 就简单多了:Kubernetes 项目只要把所有 Volume 的定义都设计在 Pod 层级即可。这样,一个 Volume 对应的宿主机目录对于 Pod 来说就只有一个,Pod 里的容器只要声明挂载这个 Volume,就一定可以共享这个 Volume 对应的宿主机目录。

容器设计模式

  明白了 Pod 的实现原理后,我们再来讨论“容器设计模式”,就容易多了。Pod 这种“超亲密关系”容器的设计思想,实际上就是希望,当用户想在一个容器里跑多个功能并不相关的应用时,应该优先考虑它们是不是更应该被描述成一个 Pod 里的多个容器。为了能够掌握这种思考方式,你就应该尽量尝试使用它来描述一些用单个容器难以解决的问题。
  例一:我们现在有一个 Java Web 应用的 WAR 包,它需要被放在 Tomcat 的 webapps 目录下运行起来
  只能用 Docker 来做这件事情,那该如何处理这个组合关系呢?
 一种方法是,把 WAR 包直接放在 Tomcat 镜像的 webapps 目录下,做成一个新的镜像运行起来。可是,这时候,如果你要更新 WAR 包的内容,或者要升级 Tomcat 镜像,就要重新制作一个新的发布镜像,非常麻烦。
  另一种方法是,你压根儿不管 WAR 包,永远只发布一个 Tomcat 容器。不过,这个容器的webapps 目录,就必须声明一个 hostPath 类型的 Volume,从而把宿主机上的 WAR 包挂载进Tomcat 容器当中运行起来。不过,这样你就必须要解决一个问题,即:如何让每一台宿主机,都预先准备好这个存储有 WAR 包的目录呢?这样来看,你只能独立维护一套分布式存储系统了。
  实际上,有了 Pod 之后,这样的问题就很容易解决了。我们可以把 WAR 包和 Tomcat 分别做成镜像,然后把它们作为一个 Pod 里的两个容器“组合”在一起。这个 Pod 的配置文件如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: javaweb-2
spec:
  initContainers:
  - image: geektime/sample:v2
    name: war
    command: ["cp", "/sample.war", "/app"]
    volumeMounts:
    - mountPath: /app
      name: app-volume
  containers:
  - image: geektime/tomcat:7.0
    name: tomcat
    command: ["sh","-c","/root/apache-tomcat-7.0.42-v2/bin/start.sh"]
    volumeMounts:
    - mountPath: /root/apache-tomcat-7.0.42-v2/webapps
      name: app-volume
   ports:
   - containerPort: 8080
     hostPort: 8001
  volumes:
  - name: app-volume
     emptyDir: {}

  在这个 Pod 中,我们定义了两个容器,第一个容器使用的镜像是 geektime/sample:v2,这个镜像里只有一个 WAR 包(sample.war)放在根目录下。而第二个容器则使用的是一个标准的 Tomcat镜像。不过,你可能已经注意到,WAR 包容器的类型不再是一个普通容器,而是一个 Init Container 类型的容器。在 Pod 中,所有 Init Container 定义的容器,都会比 spec.containers 定义的用户容器先启动。并且,Init Container 容器会按顺序逐一启动,而直到它们都启动并且退出了,用户容器才会启动。

  所以,这个 Init Container 类型的 WAR 包容器启动后,我执行了一句 "cp /sample.war /app",把应用的 WAR 包拷贝到 /app 目录下,然后退出。而后这个 /app 目录,就挂载了一个名叫 app-volume 的 Volume。接下来就很关键了。Tomcat 容器,同样声明了挂载 app-volume 到自己的 webapps 目录下。所以,等 Tomcat 容器启动时,它的 webapps 目录下就一定会存在 sample.war 文件:这个文件正是 WAR 包容器启动时拷贝到这个 Volume 里面的,而这个 Volume 是被这两个容器共享的。像这样,我们就用一种“组合”方式,解决了 WAR 包与 Tomcat 容器之间耦合关系的问题。实际上,这个所谓的“组合”操作,正是容器设计模式里最常用的一种模式,它的名字叫:sidecar。

  顾名思义,sidecar 指的就是我们可以在一个 Pod 中,启动一个辅助容器,来完成一些独立于主进程(主容器)之外的工作。比如,在我们的这个应用 Pod 中,Tomcat 容器是我们要使用的主容器,而 WAR 包容器的存在,只是为了给它提供一个 WAR 包而已。所以,我们用 Init Container 的方式优先运行 WAR 包容器,扮演了一个 sidecar 的角色

  这个例子中的 sidecar 的主要工作也是使用共享的 Volume 来完成对文件的操作。但不要忘记,Pod 的另一个重要特性是,它的所有容器都共享同一个 Network Namespace。这就使得很多与 Pod 网络相关的配置和管理,也都可以交给 sidecar 完成,而完全无须干涉用户容器。这里最典型的例子莫过于 Istio 这个微服务治理项目了。Istio 项目使用 sidecar 容器完成微服务治理的原理,我在后面很快会讲解到。

总结

对于容器来说,一个容器永远只能管理一个进程。更确切地说,一个容器,就是一个进程。这是容器技术的“天性”,不可能被修改。所以,将一个原本运行在虚拟机里的应用,“无缝迁移”到容器中的想法,实际上跟容器的本质是相悖的。这也是当初 Swarm 项目无法成长起来的重要原因之一:一旦到了真正的生产环境上,Swarm 这种单容器的工作方式,就难以描述真实世界里复杂的应用架构了。所以,你现在可以这么理解 Pod 的本质:Pod,实际上是在扮演传统基础设施里“虚拟机”的角色;而容器,则是这个虚拟机里运行的用户程序。所以下一次,当你需要把一个运行在虚拟机里的应用迁移到 Docker 容器中时,一定要仔细分析到底有哪些进程(组件)运行在这个虚拟机里。然后,你就可以把整个虚拟机想象成为一个 Pod,把这些进程分别做成容器镜像,把有顺序关系的容器,定义为 Init Container。这才是更加合理的、松耦合的容器编排诀窍,也是从传统应用架构,到“微服务架构”最自然的过渡方式。Pod 这个概念,提供的是一种编排思想,而不是具体的技术方案。

Pod基本概念

  现在,你已经非常清楚:Pod,而不是容器,才是 Kubernetes 项目中的最小编排单位。将这个设计落实到 API 对象上,容器(Container)就成了 Pod 属性里的一个普通的字段。那么,一个很自然的问题就是:到底哪些属性属于 Pod 对象,而又有哪些属性属于 Container 呢?

  要彻底理解这个问题,你就一定要牢记我在上一篇文章中提到的一个结论:Pod 扮演的是传统部署环境里“虚拟机”的角色。这样的设计,是为了使用户从传统环境(虚拟机环境)向Kubernetes(容器环境)的迁移,更加平滑。而如果你能把 Pod 看成传统环境里的“机器”、把容器看作是运行在这个“机器”里的“用户程序”,那么很多关于 Pod 对象的设计就非常容易理解了。

  比如,凡是调度、网络、存储,以及安全相关的属性,基本上是 Pod 级别的。类似地,凡是 Pod 中的容器要共享宿主机的 Namespace,也一定是 Pod 级别的定义

Pod重要字段

  这些属性的共同特征是,它们描述的是“机器”这个整体,而不是里面运行的“程序”。比如,配置这个“机器”的网卡(即:Pod 的网络定义),配置这个“机器”的磁盘(即:Pod 的存储定义),配置这个“机器”的防火墙(即:Pod 的安全定义)。更不用说,这台“机器”运行在哪个服务器之上(即:Pod 的调度)。接下来将介绍一些Pod重要字段及含义。

字段 含义 字段 含义
NodeSelector 是一个供用户将 Pod 与 Node 进行绑定的字段 NodeName 一旦该字段被赋值,Kubernetes 项目就会被认为这个 Pod 已经经过了调度,调度的结果就是赋值的节点名字。
HostAliases 定义了 Pod 的 hosts 文件(比如 /etc/hosts)里的内容 ImagePullPolicy 它定义了镜像拉取的策略,默认是 Always,即每次创建 Pod 都重新拉取一次镜像。
Lifecycle 。它定义的是 Container Lifecycle Hooks,即在容器状态发生变化时触发一系列“钩子” ··· ···

Pod生命周期

  Pod 生命周期的变化,主要体现在 Pod API 对象的Status 部分,这是它除了 Metadata 和 Spec之外的第三个重要字段。其中,pod.status.phase,就是 Pod 的当前状态,它有如下几种可能的情况:

  1. Pending。这个状态意味着,Pod 的 YAML 文件已经提交给了 Kubernetes,API 对象已经被创建并保存在 Etcd 当中。但是,这个 Pod 里有些容器因为某种原因而不能被顺利创建。比如,调度不成功。
  2. Running。这个状态下,Pod 已经调度成功,跟一个具体的节点绑定。它包含的容器都已经创建成功,并且至少有一个正在运行中。
  3. Succeeded。这个状态意味着,Pod 里的所有容器都正常运行完毕,并且已经退出了。这种情况在运行一次性任务时最为常见。
  4. Failed。这个状态下,Pod 里至少有一个容器以不正常的状态(非 0 的返回码)退出。这个状态的出现,意味着你得想办法 Debug 这个容器的应用,比如查看 Pod 的 Events 和日志。
  5. Unknown。这是一个异常状态,意味着 Pod 的状态不能持续地被 kubelet 汇报给 kube-apiserver,这很有可能是主从节点(Master 和 Kubelet)间的通信出现了问题

Pod进阶

Projected Volume

  作为 Kubernetes 项目里最核心的编排对象,Pod 携带的信息非常丰富。其中,资源定义(比如CPU、内存等),以及调度相关的字段,我会在后面专门讲解调度器时再进行深入的分析。在本篇,我们就先从一种特殊的 Volume 开始,来帮助你更加深入地理解 Pod 对象各个重要字段的含义。这种特殊的 Volume,叫作 Projected Volume,你可以把它翻译为“投射数据卷”(备注:Projected Volume 是 Kubernetes v1.11 之后的新特性)。

  这是什么意思呢?在 Kubernetes 中,有几种特殊的 Volume,它们存在的意义不是为了存放容器里的数据,也不是用来进行容器和宿主机之间的数据交换。这些特殊 Volume 的作用,是为容器提供预先定义好的数据。所以,从容器的角度来看,这些 Volume 里的信息就是仿佛是被 Kubernetes“投射”(Project)进入容器当中的。这正是 Projected Volume 的含义。到目前为止,Kubernetes 支持的 Projected Volume 一共有四种:
  1. Secret;
  2. ConfigMap;
  3. Downward API;
  4. ServiceAccountToken。

  ···

Service Account

容器健康检查和恢复机制

PodPreset(Pod 预设置)

容器编排

编排之控制器模型

  上文中,我和你详细介绍了 Pod 的用法,讲解了 Pod 这个 API 对象的各个字段。而接下来,我们就一起来看看“编排”这个 Kubernetes 项目最核心的功能吧。实际上,你可能已经有所感悟:Pod 这个看似复杂的 API 对象,实际上就是对容器的进一步抽象和封装而已。

  说得更形象些,“容器”镜像虽然好用,但是容器这样一个“沙盒”的概念,对于描述应用来说,还是太过简单了。这就好比,集装箱固然好用,但是如果它四面都光秃秃的,吊车还怎么把这个集装箱吊起来并摆放好呢?所以,Pod 对象,其实就是容器的升级版。它对容器进行了组合,添加了更多的属性和字段。这就好比给集装箱四面安装了吊环,使得 Kubernetes 这架“吊车”,可以更轻松地操作它。而 Kubernetes 操作这些“集装箱”的逻辑,都由控制器(Controller)完成。

  这时,你也许就会好奇:究竟是 Kubernetes 项目中的哪个组件,在执行这些操作呢?我在前面介绍 Kubernetes 架构的时候,曾经提到过一个叫作 kube-controller-manager 的组件。实际上,这个组件,就是一系列控制器的集合。

调谐(Reconcile)

Deployment控制器

StatefulSet

DaemonSet

离线业务对象 Job

定时业务对象 CronJob

声明式 API

声明式 API,才是 Kubernetes 项目编排能力“赖以生存”的核心所在,希望你能够认真理解。

深入解析声明式API(一):API对象的奥秘

深入解析声明式API(二):编写自定义控制器

基于角色的权限控制:RBAC

Operator工作原理解读

容器存储

Kubernetes 项目处理容器持久化存储的核心原理

PVC 和 PV 的设计与实现原理

本地持久化卷

编写自己的存储插件:FlexVolume与CSI

容器存储实践:CSI插件编写指南

容器网络

容器跨主机网络

Kubernetes网络模型与CNI网络插件

解读Kubernetes三层网络方案

为什么说Kubernetes只有soft multi-tenancy?

Service、DNS与服务发现

容器服务

从外界连通Service与Service调试“三板斧”

Service 与 Ingress

Kubernetes 的资源管理与调度

Kubernetes默认调度器

Kubernetes默认调度器的优先级与抢占机制

Kubernetes GPU管理与Device Plugin机制

SIG-Node与CRI

解读 CRI 与 容器运行时

Kubernetes 的安全策略

Kata Containers 与 gVisor

Prometheus、Metrics Server与Kubernetes监控体系

Custom Metrics

Kubernetes 容器日志