DevOps-2-3-工具集-全-

DevOps 2.3 工具集(全)

原文:annas-archive.org/md5/cac3372193ed241a4a20416527ba7309

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

如果你读过《DevOps 工具集系列》中的其他书籍(www.devopstoolkitseries.com/),你应该知道我非常喜欢容器、调度器和编排工具。《DevOps 2.0 工具集:通过容器化微服务自动化持续部署流水线》(www.amazon.com/dp/B01BJ4V66M) 最初是关于多种不同 DevOps 工具和实践的概述,其中容器占据重要但并非决定性的位置。与此同时,我完全迷上了 Docker 和 Swarm,于是我决定写《DevOps 2.1 工具集:Docker Swarm:在 Docker Swarm 集群中构建、测试、部署和监控服务》(www.amazon.com/dp/1542468914)。当我完成它时,我觉得一些高级话题没有得到探讨,值得专门写一本书来讲解。于是,《DevOps 2.2 工具集:自给自足的 Docker 集群:构建自适应和自愈的 Docker 集群》(www.amazon.com/dp/1979347190) 应运而生。

所有这些书籍(无论是直接还是间接)都集中在 Docker Swarm 上,我觉得 Kubernetes 值得拥有自己的篇幅。说实话,几年前我对它持消极态度。它对于大多数用例来说太复杂了。仅仅尝试安装它,在经历了几天的挣扎后放弃就足够了。然而,自那时以来,Kubernetes 走过了漫长的道路。尽管它的创始原则没有改变,但随着时间的推移,它变得更加成熟、更加简洁,规模也变得更大了。今天,它是最大的也是最被广泛采用的容器编排平台。一些最知名的软件公司纷纷加入其中。许多初创公司也涌现出新的解决方案。Kubernetes 背后的开源社区是软件开发史上最大的社区之一。这个社区充满活力、快速发展,并且对 Kubernetes 的成功有着极大的利益驱动。甚至 Docker 也选择支持它并加入这个社区。Kubernetes 的前景非常光明,任何人都不应忽视它。

如果你已经选择了其他容器调度工具(例如 Docker Swarm、Mesos、Nomad 或其他工具),你可能会想知道是否值得花时间学习 Kubernetes。我认为是值得的。我们应该始终关注不同的解决方案,否则我们就是盲目地被迫选择一个。不管你决定采用 Kubernetes 还是坚持使用其他工具,我认为你应该了解它是如何工作的,以及它提供了什么。这关乎做出明智的决定。

概述

本书的目标不是说服你采用 Kubernetes,而是提供其功能的详细概述。我希望你能对 Kubernetes 知识充满信心,然后再决定是否采纳它。除非你已经决定,并且偶然发现这本书来寻找 Kubernetes 的相关指导。

计划是覆盖 Kubernetes 的所有方面,从基础到高级功能。我们不仅会讲解官方项目背后的工具,还会涉及第三方插件。我希望,当你读完这本书时,你能够自称为“Kubernetes 忍者”。我不能说你会了解 Kubernetes 生态系统中的一切,因为这几乎不可能做到,因为它的发展速度远远超过任何一个人能够跟上的速度。我可以说的是,你将非常自信地在生产环境中运行任何规模的 Kubernetes 集群。

像我所有的其他书籍一样,本书也非常实践。书中会有足够的理论,帮助你理解每个主题背后的原理。书中充满了大量示例,因此我需要提醒你一下。如果你打算在公交车上或睡觉前在床上阅读这本书,最好不要购买。你需要坐在电脑前。终端将是你最好的朋友,kubectl将是你的爱人。

本书假设你已经对容器,特别是 Docker 感到熟悉。我们不会详细讲解如何构建镜像、什么是容器注册表以及如何编写 Dockerfile。我希望你已经了解这些内容。如果你不太清楚这些,建议先学习一些基础的容器操作,再来阅读这本书。本书的内容是关于在你构建好镜像并将其存储在注册表中之后的事情。

本书的内容是关于大规模运行容器,以及在问题出现时不慌乱。这是关于当前和未来的软件部署与监控。这是关于迎接挑战,并始终走在技术前沿。

最终,你可能会遇到困难,需要帮助。或者你可能想写一个评论或对本书的内容提出意见。请加入 DevOps20 (slack.devops20toolkit.com/) Slack 频道,发表你的想法、提问或参与讨论。如果你更倾向于一对一的交流,可以通过 Slack 给我发私信,或者发送电子邮件到 viktor@farcic.com。所有我写的书对我来说都非常重要,我希望你能有一个愉快的阅读体验。这个体验的一部分就是可以随时联系我。不要害羞。

请注意,这本书与之前的书籍一样,属于自出版。我认为没有中介介入作者和读者之间是最好的方式。它让我写得更快,更频繁地更新书籍,并与您进行更直接的交流。你的反馈是这个过程的一部分。无论你在书籍只有部分或所有章节完成时购买,都意味着这本书永远不会真正完成。随着时间的推移,它需要更新,以便与技术或流程的变化保持一致。尽可能地,我会保持它的更新,并在合理的时候发布更新。最终,事情可能会发生如此大的变化,以至于更新不再是一个好的选择,那时候就意味着需要一本全新的书。我会继续写,只要你继续支持我

下载示例代码文件

你可以从www.packtpub.com的帐户下载本书的示例代码文件。如果你是在其他地方购买了这本书,你可以访问www.packtpub.com/support,并注册以便直接通过邮件获得文件。

你可以通过以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择“SUPPORT”标签。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书名,并按照屏幕上的指示操作。

文件下载后,请确保使用最新版本解压或提取文件夹:

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/The-DevOps-2.3-Toolkit。我们还在github.com/PacktPublishing/上提供了其他代码包,来自我们丰富的书籍和视频目录。快来看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的截图/图表的彩色图片。你可以在此处下载:www.packtpub.com/sites/default/files/downloads/TheDevOps2.3Toolkit_ColorImages.pdf

使用的约定

本书中使用了多种文本约定。

CodeInText:表示文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。示例:"即使我们在容器内执行了docker命令,输出仍然清晰地显示了主机上的镜像。"

一段代码如下所示:

global: 
  scrape_interval:     15s 

scrape_configs: 
  - job_name: Prometheus 
    metrics_path: /prometheus/metrics 
    static_configs: 
      - targets: 
        - localhost:9090 

当我们希望引起你对代码块中特定部分的注意时,相关的行或项目会以粗体显示:

global: 
  scrape_interval:     15s 

scrape_configs: 
  - job_name: Prometheus 
    metrics_path: /prometheus/metrics 
    static_configs: 
      - targets: 
        - localhost:9090 

任何命令行输入或输出如下所示:

docker container exec -it $ID \
 curl node-exporter:9100/metrics

粗体:表示新术语、重要词汇或屏幕上显示的内容。例如,菜单或对话框中的词汇会以这种形式出现在文本中。以下是一个例子:“请输入test在项目名称字段中,选择Pipeline作为类型,然后点击确认按钮。”

警告或重要提示如下所示。

小贴士和技巧如下所示。

联系我们

我们总是欢迎读者的反馈。

一般反馈:请通过电子邮件发送至feedback@packtpub.com,并在邮件主题中提及书名。如果您对本书的任何内容有疑问,请发送邮件至questions@packtpub.com

勘误:尽管我们已尽力确保内容的准确性,但错误难免会发生。如果您发现本书中的错误,我们非常感激您能够向我们报告。请访问www.packtpub.com/submit-errata,选择您的书籍,点击“勘误提交表格”链接并填写相关信息。

盗版:如果您在互联网上发现我们作品的任何非法复制品,请提供相关的地址或网站名称。请通过copyright@packtpub.com与我们联系,并附上材料链接。

如果您有兴趣成为作者:如果您在某个领域具有专业知识,并且有意写书或为书籍贡献内容,请访问authors.packtpub.com

第一章:我们是如何走到今天的?

很少有公司活在当下。我们大多数人仍然停留在过去,使用着过时的技术和过时的流程。如果我们停留在过去太久,可能会失去重新回到当下的机会。我们可能会进入一个平行时空,甚至不再存在。

每家公司都是一家软件公司。这一点适用于那些还未意识到这一点的公司。我们都在奔跑,并且不断加速。这是一场没有终点的比赛。没有赢家,只有那些跌倒后没有站起来的人。我们生活在一个变化速度不断加快的时代。公司一夜之间可能会被创造或摧毁。没有人是安全的。没有人可以安于现状。

技术变化如此之快,以至于很难(如果不是不可能)跟上。我们刚学习到一种新技术,它就已经过时,被其他东西取代了。以容器为例,Docker 只是几年前才出现,大家已经在各种场景中使用它。尽管它是一个非常年轻的产品,但它已经经历了多次变革。就在我们学会如何使用 docker run 时,我们被告知它已经过时,应该用 docker-compose up 来代替。于是我们开始将所有的 docker run 命令转换成 Compose 的 YAML 格式。等我们完成了转换,我们又得知容器不应该直接运行,而是应该使用容器调度器来替代。更复杂的是,我们还需要在 Mesos 和 Marathon、Docker Swarm 或 Kubernetes 之间做选择。

我们可以选择忽视这些趋势,但那样意味着我们会落后于竞争对手。没有什么选择,只有不断地努力保持竞争力。一旦我们放松警惕,停止学习和改进,竞争对手就会接管我们的业务。每个人都面临改进的压力,即使是高度监管的行业也是如此。只有当我们设法回到现在,创新才有可能。只有当我们掌握了别人今天所做的事情,才能向前推进并提出新的想法。今天,容器调度器已经是常态。它们不再是未来的东西,它们是现在的事物。尽管它们很可能在接下来的几个月和几年中发生巨大变化,但它们已经存在,并且将一直存在。理解容器调度器至关重要。在这些调度器中,Kubernetes 是最广泛使用的,并且拥有庞大的社区支持。

在我们深入了解 Kubernetes 之前,可能值得回顾一些历史,尝试理解我们当时试图解决的一些问题,以及我们面临的一些挑战。

从过去的一瞥

想象一个年轻男孩。他刚完成几个月的工作。他为自己取得的成就感到骄傲,但同时也担心它是否能正常运作。他还没有在“真实”的服务器上尝试过。这将是他第一次交付自己工作的成果。

他从抽屉里拿出一张软盘,插入电脑,复制之前编译的文件。他庆幸打孔卡片已经成为过去的事情。

他从桌子前站起来,走出办公室,朝着他的车走去。到达服务器所在的楼栋需要超过两个小时。他对于必须开车两个小时的前景感到不满,但没有更好的替代方案。他本可以让信使送去软盘,但那样没什么用,因为他想亲自安装软件。他必须亲自去那里,无法远程操作。

过了一会儿,他进入了存放服务器的房间,插入软盘,复制并安装软件。十五分钟后,他的脸上显现出压力的迹象。事情没有按预期的方式运行。出现了一个意外问题。他正在收集输出并写下笔记。他尽力保持冷静,收集尽可能多的信息。他担心又要长时间开车回到电脑前,可能还需要几天,甚至几周,才能弄清楚问题所在并解决它。他会回去并安装修复程序,也许第二次会成功,但更可能不会。

基础设施管理的简史

很久以前,在一个遥远的星系里……

我们会订购服务器,然后等上几个月直到它们到货。更糟糕的是,即便它们到了,我们还要等上几周,有时甚至几个月,才能把它们放到机架上并完成配置。大多数时候我们只是一直在等待。等待服务器,等待配置完成,等待获得部署批准,然后继续等待。只有耐心的人才能成为软件工程师。尽管如此,那也是打孔卡片和软盘之后的时代。我们有互联网或其他方式远程连接到机器。尽管如此,一切仍然需要大量等待。

鉴于要让服务器完全投入使用需要的时间,只有少数人能接触到这些服务器也就不足为奇了。如果有人做了不该做的事情,我们可能会面临长时间的宕机。此外,没有人知道那些服务器上运行的是什么。由于一切都靠人工操作,过了一段时间后,这些服务器就成了垃圾场。东西随着时间积累。无论多少精力投入到文档中,只要给足时间,服务器的状态总会与文档偏离。这就是手动配置和安装的本质。系统管理员成了神一样的人物。他是唯一一个知道一切的人,或者更可能,他假装自己知道。他是地下城的守护者,拥有通往王国的钥匙。每个人都可以被替代,只有他不行。

然后出现了配置管理工具。我们得到了 CFEngine。它基于承诺理论,能够将服务器调整到期望的状态,无论其实际状态如何。至少,这是理论。即使有缺点,CFEngine 也完成了其主要目标。它允许我们指定静态基础设施的状态,并有合理的保证能实现该状态。除了其主要目标,它还是服务器设置文档化的进步。与手动的花招式操作(通常导致文档与实际状态之间的显著差异)不同,CFEngine 允许我们拥有一个几乎完全与实际状态匹配的规范。它提供的另一个大优势是,可以为不同的环境提供或多或少相同的设置。专用于测试的服务器可以(几乎)与分配给生产环境的服务器相同。不幸的是,CFEngine 和类似工具的使用尚未得到广泛推广。我们不得不等待虚拟机的出现,直到自动化配置管理成为常态。然而,CFEngine 并非为虚拟机设计。它们是为静态的裸金属服务器而设计的。尽管如此,CFEngine 依然是对行业的巨大贡献,尽管它未能广泛采用。

在 CFEngine 之后,出现了 Chef、Puppet、Ansible、Salt 和其他类似工具。生活一度美好,直到虚拟机问世,或者更准确地说,直到虚拟机得到了广泛应用。我们很快会回到这些工具。现在,让我们转向下一个进化改进。

除了迫使我们保持耐心,物理服务器在资源利用方面也是一种巨大的浪费。它们有预定义的规格,由于等待时间相当长,我们通常会选择大规格的服务器。越大,越好。这意味着一个应用或服务通常所需的 CPU 和内存比服务器提供的要少。除非不在乎成本,否则这意味着我们会将多个应用部署到同一台服务器上。结果就是依赖关系的噩梦。我们必须在自由和标准化之间做出选择。

自由意味着不同的应用可以使用不同的运行时依赖。一项服务可能需要 JDK3,而另一项可能需要 JDK4。第三个可能是用 C 语言编译的。你大概明白接下来会发生什么了。我们在一台服务器上托管的应用越多,依赖关系就越复杂。通常,这些依赖会发生冲突,产生一些没人预料到的副作用。由于我们天生有将任何专长转化为独立部门的需求,负责基础设施的人迅速放弃了自由,转而选择可靠性。这就意味着“对我越简单,对你越可靠”。自由败北,标准化获胜。

标准化从系统架构师决定唯一正确的开发和部署方式开始。他们是一群很有趣的人。冒着把所有人都放在同一个框里并讽刺这一职业的风险,我会描述一个普通的系统架构师为一个(可能经验丰富的)程序员,他决定爬升自己公司的阶梯。说到阶梯,通常有两种。一种是管理阶梯,需要广泛掌握 Microsoft Word 和 Excel。精通所有 MS Office 工具是加分项。那些精通 MS Project 的人被认为是终极专家。哦,我忘了提到邮件技能。他们必须能够每天至少发送十五封邮件,询问状态报告。

大多数专家级程序员(老手)不会选择这条道路。许多人更愿意保持技术路线。这意味着接管系统架构师的角色。问题是,“技术路线”往往是一种欺骗。架构师仍然必须掌握所有管理技能(例如,Word、Excel 和邮件),并具备绘制图表的额外能力。这并不容易。一个系统架构师必须知道如何画矩形、圆形和三角形。他必须精通着色,并且能把它们连接起来。图形有虚线和实线。有些线条必须像箭头一样结束。选择箭头的方向本身就是一个挑战,因此线条通常会在两端都有箭头。

成为架构师的重要部分是,绘制图表和编写无数页 Word 文档非常耗时,以至于编程不再是他们的工作内容。他们停止了学习和探索,除了 Google 搜索和比较表格之外没有其他来源。最终的结果是,架构设计反映了架构师在跳到新职位之前所掌握的知识。

为什么我提到架构师?原因很简单。他们负责由系统管理员要求的标准化。他们会画出图表并选择开发人员使用的技术栈。不管那个栈是什么,都必须被视为“圣经”并严格遵守。系统管理员很高兴,因为有了标准和预定义的服务器设置方式。架构师们也很高兴,因为他们的图表有了实际用途。由于这些栈本应持续存在,开发人员也很兴奋,因为他们不需要学习任何新的东西。标准化扼杀了创新,但每个人都很开心。快乐是必须的,不是吗?如果 JDK2 运行得很好,为什么我们还需要 Java 6 呢?这一点已经通过无数图表得到了验证。

然后出现了虚拟机,打破了每个人的快乐。

虚拟机VM)相比裸金属基础设施是一项巨大的进步。它们使我们能够更加精确地定义硬件需求。虚拟机可以快速创建和销毁。它们可以有不同的配置,一个可以运行 Java 应用程序,另一个可以专门用于 Ruby on Rails。我们可以在几分钟内获得它们,而不需要等待数月。然而,即使虚拟机带来的好处是显而易见的,直到多年后它们才被广泛采用。即使如此,虚拟机的采用仍然存在很多问题。公司常常将裸金属服务器上使用的相同做法转移到虚拟机上。这并不是说虚拟机的采用没有带来即时的价值。服务器的等待时间从几个月减少到了几周。如果没有行政任务、手动操作和操作瓶颈,它们本可以将等待时间缩短到几分钟。尽管如此,等待几周总比等待几个月好。另一个好处是,我们可以在不同的环境中拥有相同的服务器。公司开始复制虚拟机。虽然这比以前好多了,但它并没有解决缺乏文档和从零创建虚拟机的能力问题。尽管如此,多个相同的环境总比一个环境好,即使我们不知道里面到底有什么。

随着虚拟机(VM)的采用逐渐增多,配置管理工具的数量也在增加。我们有了 Chef、Puppet、Ansible、Salt 等工具。虽然其中一些工具可能在虚拟机出现之前就存在,但虚拟机使得它们变得流行。它们帮助推广了“基础设施即代码”(Infrastructure as Code)原则。然而,这些工具的设计原理与 CFEngine 相同。也就是说,它们是以静态基础设施为设计基础的。另一方面,虚拟机为动态基础设施开辟了新的天地,其中虚拟机被不断创建和销毁。可变性和不断的创建与销毁发生了冲突。可变基础设施非常适合静态基础设施,但它无法有效应对现代数据中心的动态特性所带来的挑战。可变性最终不得不让位于不可变性。

当不可变基础设施的理念开始获得关注时,人们开始将其与配置管理的概念结合起来。然而,当时可用的工具并不适合这个工作。它们(如 Chef、Puppet、Ansible 等)是以“在运行时将服务器配置到期望状态”的理念设计的。另一方面,不可变的过程假设(几乎)没有东西可以在运行时改变。工件应该作为不可变的镜像创建。在基础设施的情况下,这意味着虚拟机是从镜像创建的,而不是在运行时进行更改。如果需要升级,应该创建新的镜像,并用基于新镜像的虚拟机替换旧的虚拟机。这样的过程带来了速度和可靠性。在适当的测试到位的情况下,不可变的方式总是比可变的方式更可靠。

因此,我们获得了能够构建虚拟机镜像的工具。今天,它们由 Packer 主导。配置管理工具迅速加入了这一行列,供应商告诉我们,它们在配置镜像和运行时配置服务器时同样有效。然而,事实并非如此,因为这些工具背后的逻辑不同。它们的设计目的是将处于未知状态的服务器配置到期望的状态。它们假设我们无法确认当前状态是什么。另一方面,虚拟机镜像总是基于具有已知状态的镜像。例如,如果我们选择 Ubuntu 作为基础镜像,我们知道它内部包含什么。添加额外的包和配置非常简单。不需要像“如果这样,则那样,否则做其他事情”这样的复杂逻辑。当当前状态已知时,简单的 shell 脚本就能与任何配置管理工具相媲美。仅使用 Packer 创建虚拟机镜像是相当直接的。尽管如此,配置管理工具并没有完全失去价值。我们仍然可以使用它们来协调基于镜像创建虚拟机的过程,并且可能执行一些不能预先配置的运行时配置,对吧?

我们协调基础设施的方式也必须发生变化。需要更高水平的动态性和弹性。随着像亚马逊云服务AWS)这样的云托管提供商的出现,这一点变得尤为明显,后来还有 Azure 和 GCE。他们向我们展示了什么是可能实现的。虽然一些公司接受了云服务,其他公司则采取了防守的态度。“我们可以建立一个内部云”,“AWS 太贵”,“我想做,但由于法律原因做不到”,“我们的市场不同”,这些都是人们在极力维护现状时常用的几种错误理由。并不是说这些陈述没有一点道理,而是说它们更多时候被用作借口,而非真正的原因。

尽管如此,云计算仍然成功地成为了工作方式,企业将其基础设施迁移到某个云服务提供商那里,或者至少开始考虑这一点。越来越多的公司正在放弃本地基础设施,我们可以放心地预测这一趋势将继续下去。然而,问题依然存在。我们如何管理云中的基础设施,充分利用其带来的所有好处?我们如何应对其高度动态的特性?答案以供应商特定工具(如 CloudFormation)或中立解决方案(如 Terraform)的形式出现。结合能够帮助我们创建镜像的工具,它们代表了新一代的配置管理。我们所谈论的是由不变性支撑的完全自动化。

我们生活在一个不再需要通过 SSH 连接到服务器的时代。

今天,现代基础设施是通过不变的镜像来创建的。任何升级都是通过构建新的镜像并进行滚动更新来逐一替换虚拟机。基础设施的依赖关系从不在运行时发生变化。像 Packer、Terraform、CloudFormation 等工具正是当今问题的答案。

不变性背后固有的一个好处是基础设施和部署之间的清晰划分。直到不久前,这两者融为一体,形成了一个不可分割的过程。随着基础设施成为一种服务,部署过程可以明确分开,从而让不同的团队、个人和专业人士能够各自掌控。

我们需要回顾一下过去,讨论一下部署历史。它们的变化是否和基础设施一样大?

部署过程的简短历史

在最初,没有包管理器。没有 JAR、WAR、RPM、DEB 和其他包格式。最多,我们可以将文件压缩成一个发布包。更常见的是,我们手动将文件从一个地方复制到另一个地方。当这种做法与旨在长期使用的裸金属服务器结合时,结果简直是生不如死。过了一段时间,没人知道服务器上安装了什么。不断的覆盖、重新配置、包安装以及可变类型的操作,导致了不稳定、不可靠且没有文档支持的软件运行在无数操作系统补丁之上。

配置管理工具(例如 CFEngine、Chef、Puppet 等)的出现有助于减少混乱。尽管如此,它们更多的是改进了操作系统的设置和维护,而不是新版本的部署。即使这些工具背后的公司很快意识到扩大其范围将带来经济利益,它们也从未被设计用于此目的。

即使有了配置管理工具,多个服务在同一台服务器上运行的问题依然存在。不同的服务可能有不同的需求,而这些需求有时会发生冲突。一方可能需要 JDK6,另一方需要 JDK7。第一个服务的新版本可能要求将 JDK 升级到新版本,但这可能会影响同一服务器上的其他服务。冲突和操作复杂性非常常见,以至于许多公司选择进行标准化。正如我们所讨论的,标准化是创新的杀手。我们标准化得越多,能够提出更好解决方案的空间就越小。即使这不是问题,标准化和明确的隔离意味着升级某个服务变得非常复杂。影响可能是不可预见的,而且一次性升级所有内容所需的工作量如此巨大,以至于许多人长时间(甚至永远)选择不升级。最终,许多公司被迫长时间使用旧的技术栈。

我们需要一种进程隔离的方式,而不需要为每个服务单独配置虚拟机。同时,我们必须想出一种不可变的方式来部署软件。可变性使我们无法实现可靠的环境。随着虚拟机的出现,不可变性变得可行。我们不再需要通过运行时更新来部署版本,而是可以创建新的虚拟机,不仅包括操作系统和补丁,还包括我们自己的软件。每次我们想发布新版本时,我们可以创建一个新的镜像,并实例化任意数量的虚拟机。我们可以进行不可变的滚动更新。尽管如此,实际上并没有多少公司在这样做。它太昂贵了,不仅在资源上,时间上也非常浪费。这个过程太长了。即使这不成问题,为每个服务配置一个单独的虚拟机会导致 CPU 和内存的浪费。

幸运的是,Linux 引入了命名空间(namespaces)、控制组(cgroups)以及其他被统称为容器的技术。它们轻量、快速且廉价。它们提供了进程隔离以及其他许多好处。不幸的是,它们并不容易使用。尽管容器技术已经存在了一段时间,只有少数几家公司具备利用它们的专业知识。我们不得不等到 Docker 的出现,才使容器变得易于使用,并使其对所有人都可及。

现在,容器是打包和部署服务的首选方式。它们是我们曾经迫切想实现的不可变性问题的答案。容器提供了必要的进程隔离、优化的资源利用率和其他一些好处。然而,我们也意识到我们需要更多。仅仅运行容器是不够的。我们需要能够扩展它们,让它们具备容错能力,提供集群之间透明的通信,还有很多其他需求。容器只是这幅拼图中的一个低级部分。真正的好处是通过位于容器之上的工具来获得的。这些工具今天被称为容器调度器。它们是我们的接口。我们不管理容器,是它们管理我们。

如果你还没有使用任何容器调度器,你可能会想知道它们是什么。

什么是容器调度器?

想象一下我还是个年轻的青少年。放学后,我们会去院子里踢足球。那是一个激动人心的场景。我们一群人随意地在院子里奔跑,完全没有组织。没有进攻,也没有防守。我们只会追着球跑。每个人都朝着球的方向跑,有人把球踢向左边,我们就朝那个方向跑,结果又因为有人把球踢了回来而开始向后跑。策略很简单。朝着球跑,能踢就踢,随便在哪踢,重复。这么多年过去了,我依然不明白有人是怎么进球的。那完全是随机的,适用于一群孩子。没有策略,没有计划,也没有意识到获胜需要协调。甚至守门员也总是出现在场地的随机位置。如果他在他守的门附近接到球,他会继续带着球跑。大部分进球都是踢向空门的。那是一种“每个人为自己”类型的野心。我们每个人都希望能进球,为自己争光。幸运的是,最主要的目标是玩得开心,所以团队的胜负并不那么重要。如果我们是一个“真正的”团队,我们就需要一个教练。我们需要有人告诉我们策略是什么,谁应该做什么,什么时候进攻,什么时候退守。我们需要有人来指挥我们。这个场地(集群)有着一个随机数量的人(服务),而他们有着共同的目标(获胜)。因为任何人都可以随时加入游戏,所以人数(服务)是不断变化的。

有人受伤并需要被替换,或者当没有替代者时,我们其余的人必须接管他的任务(自愈)。这些足球比赛可以很容易地转化为集群。就像我们需要有人告诉我们该做什么(教练),集群也需要某种东西来协调所有服务和资源。两者不仅需要做出事先决策,还需要不断观察比赛/集群,并根据内外部影响调整策略/调度。我们需要一个教练,而集群需要一个调度器。它们需要一个框架,来决定服务应该部署在哪里,并确保它保持期望的运行时规范。

集群调度器有许多目标。它确保资源得到高效利用并符合约束条件。它确保服务(几乎)始终运行。它提供容错和高可用性。它确保指定数量的副本被部署。这个列表可以持续一段时间,并且在不同的解决方案中有所不同。然而,不管集群调度器的具体责任列表如何,它们都可以通过主要目标进行概括。调度器确保服务或节点的期望状态(几乎)始终得到满足。与其使用命令式方法来实现目标,我们可以通过调度器采用声明式方法。我们可以告诉调度器期望的状态是什么,它将尽最大努力确保我们的期望(几乎)始终得到满足。例如,我们可以告诉调度器,我们的期望状态是让服务运行并具有五个副本,而不是执行五次部署过程,指望我们会有五个副本。

命令式和声明式方法之间的差异看起来可能很微妙,但实际上差异巨大。通过声明期望状态的方式,调度器可以监控集群,并在实际状态与期望状态不匹配时执行操作。与执行部署脚本相比,两者都会部署服务并产生相同的初始结果。然而,脚本并不会确保结果在一段时间后得到保持。如果一个小时后,某个副本失败了,我们的系统就会受到影响。传统上,我们通过警报和人工干预来解决这个问题。操作员会收到副本失败的通知,然后登录服务器并重启进程。如果整个服务器宕机,操作员可能会选择创建一个新的服务器,或者将失败的副本部署到其他服务器之一。但在此之前,他需要检查哪个服务器有足够的可用内存和 CPU。所有这些,甚至更多,都是由调度器在没有人工干预的情况下完成的。可以把调度器看作是持续监控系统并修复期望状态与实际状态之间差异的操作员。不同之处在于,调度器无比快速且精准。它们不会疲劳,不需要上厕所,也不需要工资。它们是机器,或者更准确地说,是在其上运行的软件。

这引出了容器调度器。它们与一般调度器有何不同呢?

容器调度器与一般调度器基于相同的原则。其显著区别在于它们使用容器作为部署单元。它们部署的是以容器镜像打包的服务。它们根据所需的内存和 CPU 规格尝试将服务放置在一起。它们确保所需数量的副本(几乎)始终运行。总的来说,它们的工作方式与其他调度器相同,但容器是最小且唯一的打包单元。这给它们带来了明显的优势。它们不关心容器内部的内容。从调度器的角度来看,所有容器都是一样的。

容器提供了其他部署机制无法提供的好处。作为容器部署的服务是隔离且不可变的。隔离提供了可靠性。隔离有助于网络和存储卷管理。它避免了冲突。它允许我们在任何地方部署任何东西,而不必担心这些东西是否会与同一服务器上运行的其他进程发生冲突。调度器结合容器和虚拟机提供了终极的集群管理理想状态。虽然未来这一点可能会发生变化,但目前为止,容器调度器是工程成就的巅峰。它们让我们能够结合开发者对快速和频繁部署的需求与系统管理员对稳定性和可复现性的目标。这就引出了 Kubernetes。

什么是 Kubernetes?

要理解 Kubernetes,重要的是要意识到直接运行容器对于大多数使用场景来说是一个糟糕的选择。容器是低级实体,需要一个框架来支撑它们。它们需要一些东西来提供我们期望从集群中部署的服务所需的所有附加功能。换句话说,容器很方便,但不应该直接运行。原因很简单,容器本身不提供容错能力。它们无法轻松地部署到集群中的最佳位置,并且,简而言之,不适合运维人员使用。这并不意味着容器本身没有用处。它们是有用的,但如果我们要充分发挥它们的真正潜力,它们还需要更多。如果我们需要在大规模操作容器,并且需要它们具备容错和自愈功能,以及我们期望现代集群所具备的其他功能,我们需要更多。我们至少需要一个调度器,可能还需要更多。

Kubernetes 最初由 Google 的一个团队开发,基于他们多年在大规模运行容器的经验。后来,它被捐赠给了 云原生计算基金会 (CNCF) (www.cncf.io/)。它是一个真正的开源项目,可能是历史上发展最快的项目之一。

Kubernetes 是一个容器调度器,功能远不止如此。我们可以使用它来部署我们的服务,进行无停机时间的发布更新,以及对这些服务进行扩展(或缩减)。它是可移植的,可以在公有云或私有云上运行,也可以在本地或混合环境中运行。从某种程度上说,Kubernetes 使得你的基础设施与供应商无关。我们可以将一个 Kubernetes 集群从一个托管供应商迁移到另一个供应商,而几乎不需要改变任何部署和管理流程。Kubernetes 可以轻松扩展,以满足几乎任何需求。我们可以选择使用哪些模块,还可以开发额外的功能并将其插入系统。

如果我们选择使用 Kubernetes,我们就决定放弃控制权。Kubernetes 将决定在哪里运行某些东西以及如何实现我们指定的状态。这种控制允许 Kubernetes 将服务的副本放置在最合适的服务器上,在需要时重启它们,进行复制,并对它们进行扩展。我们可以说,自愈能力是其设计初衷的一部分。另一方面,自适应功能也在逐步实现中。到目前为止,它仍处于初期阶段,但很快将成为系统的一个核心部分。

零停机部署、容错、高可用性、扩展、调度和自我修复,应该足以让你看到 Kubernetes 的价值。然而,这仅仅是它所提供的一部分功能。我们可以使用它为有状态应用程序挂载存储卷。它允许我们将机密信息存储为秘密。我们可以使用它验证服务的健康状况。它可以负载均衡请求并监控资源。它提供服务发现和便捷的日志访问,等等。Kubernetes 所能做的事情清单很长,而且在迅速增加。与 Docker 一起,它正逐渐成为一个涵盖整个软件开发和部署生命周期的平台。

Kubernetes 项目刚刚起步。它还处于初期阶段,我们可以期待很快会有大量改进和新功能推出。尽管如此,不要被“初期阶段”所迷惑。即使这个项目还年轻,它背后拥有世界上最大的社区之一,并且已被用于一些全球最大规模的集群。不要再等了,现在就采用它吧!

第二章:在本地运行 Kubernetes 集群

本书的一个目标是将学习成本控制到最低。秉承这一精神,我们将尽可能长时间地运行本地 Kubernetes 集群。最终,我们将不得不切换到托管的多节点 Kubernetes 集群。我会尽力将这个过程推迟到尽可能晚,而不限制你的学习体验。目前,我们将在你的笔记本上创建一个本地 Kubernetes 集群。

有很多种方法可以设置本地 Kubernetes 集群。例如,我们可以使用 Vagrant (www.vagrantup.com/) 创建一些节点,并执行一系列 shell 命令,将它们转换为 Kubernetes 集群。我们甚至可以进一步创建一个已经预装所有必需软件的 VirtualBox 镜像,并用它来创建 Vagrant 虚拟机。我们还可以使用 Ansible 来进行镜像的配置,并执行所有将虚拟机加入集群所需的命令。我们可以做很多其他事情,但我们不打算这样做。

目前,重点不是教授你如何设置 Kubernetes 集群的所有复杂细节。相反,我希望尽可能快地让你跟上进度,让你能够体验 Kubernetes,而不被安装细节所干扰。

如果本书的主题是 Docker Swarm(就像在The DevOps 2.1 Toolkit: Docker Swarm 中那样),我们只需要在 Mac 或 Windows 上使用 Docker(或在 Linux 上原生运行),并执行一个 docker swarm init 命令。这就是创建本地 Docker Swarm 集群所需的一切。我们能否用 Kubernetes 实现同样的简单性呢?

在 2017 年 10 月,Docker 宣布在 Docker for Mac 和 Windows 中初步支持 Kubernetes。在撰写本文时,这项功能仅在 Mac 的边缘通道中可用。

Minikube 在你的笔记本电脑上的虚拟机内创建了一个单节点集群。虽然这并不理想,因为我们无法展示 Kubernetes 在多节点环境下提供的一些功能,但它足够用来解释 Kubernetes 背后的大部分概念。稍后我们将转向更接近生产环境的设置,探索那些在 Minikube 中无法展示的功能。

给 Windows 用户的提示

请通过 Git 安装的 GitBash 运行所有示例。这样,你在书中看到的命令将与在 MacOS 或任何 Linux 发行版上执行的命令相同。如果你使用的是 Hyper-V 而不是 VirtualBox,你可能需要以管理员身份运行 GitBash 窗口。

在我们深入讨论 Minikube 安装之前,有一些先决条件需要我们进行设置。首先要设置的是kubectl

安装 kubectl

Kubernetes 的命令行工具 kubectl 用于管理集群和运行在其中的应用程序。在本书中我们将频繁使用 kubectl,因此目前不会深入讲解其细节。相反,我们会通过接下来的示例来讨论它的命令。目前,把它看作是你与 Kubernetes 集群的交流工具。

让我们安装 kubectl

本章中的所有命令都可以在 02-minikube.sh (gist.github.com/vfarcic/77ca05f4d16125b5a5a5dc30a1ade7fc) Gist 中找到。

如果您已经安装了 kubectl,可以跳过安装步骤。只需确保版本是 1.8 或以上。

如果您是 MacOS 用户,请执行以下命令:

curl -LO https://storage.googleapis.com/kubernetes-release/release/`curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt`/bin/darwin/amd64/kubectl

chmod +x ./kubectl

sudo mv ./kubectl /usr/local/bin/kubectl  

如果您已经安装了 Homebrew(brew.sh/)包管理器,可以通过以下命令使用 "brew" 安装:

brew install kubectl  

如果您是 Linux 用户,安装 kubectl 的命令如下:

curl -LO https://storage.googleapis.com/kubernetes-release/release/$(curl -s 
 https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl

chmod +x ./kubectl

sudo mv ./kubectl /usr/local/bin/kubectl  

最后,Windows 用户应通过以下命令下载二进制文件。

curl -LO https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/windows/amd64/kubectl.exe

随时将二进制文件复制到任何目录。重要的是将其添加到 PATH 中。

让我们检查 kubectl 版本,并同时验证它是否正常工作。不管您使用的是哪个操作系统,命令如下:

kubectl version

输出如下:

Client Version: version.Info{Major:"1", Minor:"9", GitVersion:"v1.9.0", 
GitCommit:"925c127ec6b946659ad0fd596fa959be43f0cc05", 
GitTreeState:"clean", BuildDate:"2017-12-15T21:07:38Z", GoVersion:"go1.9.2", 
Compiler:"gc", Platform:"darwin/amd64"}  

无法连接到服务器 localhost:8080 - 您是否指定了正确的主机或端口?

这是一个非常丑陋且难以阅读的输出。幸运的是,kubectl 可以使用几种不同的格式来输出结果。例如,我们可以告诉它输出为 yaml 格式

kubectl version --output=yaml 

输出如下:

clientVersion:
  buildDate: 2017-12-15T21:07:38Z
  compiler: gc
  gitCommit: 925c127ec6b946659ad0fd596fa959be43f0cc05
  gitTreeState: clean
  gitVersion: v1.9.0
  goVersion: go1.9.2
  major: "1"
  minor: "9"
  platform: darwin/amd64

The connection to the server localhost:8080 was refused - did you specify the right host or port? 

这是一个更好的(更易读的)输出。

我们可以看到客户端版本为 1.9。在底部显示错误信息,表明 kubectl 无法连接到服务器。这是预期中的情况,因为我们还没有创建集群。接下来我们就要进行这一步。

在撰写本书时,kubectl 的版本为 1.9.0。您安装时的版本可能不同。

安装 Minikube

Minikube 支持多种虚拟化技术。我们在本书中将使用 VirtualBox,因为它是唯一支持所有操作系统的虚拟化工具。如果你还没有安装,请前往下载 VirtualBox 页面 (www.virtualbox.org/wiki/Downloads),下载与您的操作系统匹配的版本。请记住,要使 VirtualBox 或 HyperV 正常工作,必须在 BIOS 中启用虚拟化。大多数笔记本电脑默认应已启用此功能。

最后,我们可以安装 Minikube。

如果您使用的是 MacOS,请执行以下命令:

brew cask install minikube  

如果您更喜欢使用 Linux,可以执行以下命令:

curl -Lo minikube 
https://storage.googleapis.com/minikube/releases/latest/minikube-"linux-amd64 && chmod +x minikube && sudo mv minikube "/usr/local/bin/

最后,如果你是 Windows 用户,你将不会收到命令。相反,你需要从 minikube-windows-amd64.exestorage.googleapis.com/minikube/releases/latest/minikube-windows-amd64.exe)下载最新版本,重命名为 minikube.exe,并将其添加到你的路径中。

我们通过检查 Minikube 的版本来测试它是否正常工作。

minikube version  

输出如下:

minikube version: v0.23.0 

现在我们准备好开始使用这个集群了。

使用 Minikube 创建本地 Kubernetes 集群

Minikube 背后的开发者将创建集群的过程做得尽可能简单。我们只需执行一个命令,Minikube 就会在本地启动一个虚拟机,并将必要的 Kubernetes 组件部署到其中。虚拟机会通过一个名为 localkube 的单一二进制文件配置 Docker 和 Kubernetes。

minikube start --vm-driver=virtualbox  

Windows 用户须知

你可能会遇到 virtualbox 的问题。如果是这样,你可以考虑改用 hyperv。打开 Powershell 管理员窗口,执行 Get-NetAdapter 命令,并记录下你的网络连接名称。创建一个 hyperv 虚拟交换机:New-VMSwitch -name NonDockerSwitch -NetAdapterName Ethernet -AllowManagementOS $true,将 Ethernet 替换为你的网络连接名称。然后创建 Minikube 虚拟机:minikube start --vm-driver=hyperv --hyperv-virtual-switch "NonDockerSwitch" --memory=4096。其他 Minikube 命令,如 minikube startminikube stopminikube delete,无论你是使用 VirtualBox 还是 Hyper-V,都可以正常工作。

稍等片刻,一个新的 Minikube 虚拟机将被创建并设置好,集群将准备就绪可以使用。

当我们执行 minikube start 命令时,它基于 Minikube 镜像创建了一个新的虚拟机。该镜像包含了一些二进制文件,既有 Dockerwww.docker.com/)也有 rktcoreos.com/rkt/)容器引擎,以及 localkube 库。这个库包含了运行 Kubernetes 所需的所有组件。稍后我们将详细介绍这些组件。目前,最重要的是,localkube 提供了运行本地 Kubernetes 集群所需的一切。

图 2-1:Minikube 简化架构

记住,这是一个单节点集群。虽然这有点遗憾,但它仍然是目前最简单的方法(据我所知)来“玩转”本地的 Kubernetes。暂时应该够用了。稍后,我们将探索如何创建一个多节点集群,它将更加接近生产环境的设置。

让我们来查看集群的状态:

minikube status  

输出如下:

minikube: Running
cluster: Running
kubectl: Correctly Configured: pointing to minikube-vm at 192.168.99.100 

Minikube 正在运行,它初始化了一个 Kubernetes 集群。它还配置了 kubectl,使其指向新创建的虚拟机。

本书中不会看到太多的 UI。我认为终端是操作集群的最佳方式。更重要的是,我坚信应该先通过命令掌握一款工具。等我们感觉熟练并理解了工具的工作原理后,可以选择在其基础上使用 UI。在后面的章节中,我们会探索 Kubernetes UI。现在,我让你快速瞥一眼它。

minikube dashboard  

可以自由探索 UI,但不要花太多时间。你会被一些我们尚未学习的概念弄混。等我们学习了 pods、replica-sets、services 和其他 Kubernetes 组件后,UI 会变得更加有意义。

图 2-2:Kubernetes 仪表盘

另一个有用的 Minikube 命令是 docker-env

minikube docker-env  

输出如下:

export DOCKER_TLS_VERIFY="1"
export DOCKER_HOST="tcp://192.168.99.100:2376"
export DOCKER_CERT_PATH="/Users/vfarcic/.minikube/certs"
export DOCKER_API_VERSION="1.23"
# Run this command to configure your shell:
# eval $(minikube docker-env)  

如果你曾使用 Docker Machine,你会注意到输出是相同的。docker-machine envminikube docker-env 都有相同的作用。它们输出本地 Docker 客户端与远程 Docker 服务器通信所需的环境变量。在这种情况下,那个 Docker 服务器就是 Minikube 创建的虚拟机中的 Docker 服务器。我假设你已经在笔记本上安装了 Docker。如果没有,请访问 Docker 安装页面(docs.docker.com/install/),并按照你操作系统的说明进行安装。Docker 安装完成后,我们可以将你笔记本上的客户端与 Minikube 虚拟机中的服务器连接起来。

eval $(minikube docker-env)  

我们通过 minikube docker-env 命令评估(创建)了所提供的环境变量。因此,我们发送到本地 Docker 客户端的每个命令都会在 Minikube 虚拟机上执行。我们可以通过例如列出该虚拟机上所有正在运行的容器来轻松验证这一点。

docker container ls  

输出中列出的容器是 Kubernetes 所需的容器。我们可以将它们视为系统容器。我们不会逐一讨论它们。事实上,我们不会讨论它们中的任何一个。至少,暂时不会。你现在需要知道的是,它们使得 Kubernetes 能够正常工作。

由于几乎虚拟机中的所有内容都是容器,将本地 Docker 客户端指向其中的服务应该就足够了(除了 kubectl)。不过,在某些情况下,你可能需要通过 SSH 进入虚拟机。

minikube ssh

docker container ls

exit  

我们进入了 Minikube 虚拟机,列出了容器,然后退出了。没有必要做其他操作,除了展示 SSH 是可用的,尽管你可能不会使用它。

还有什么需要验证的吗?例如,我们可以确认 kubectl 也指向了 Minikube 虚拟机。

kubectl config current-context  

输出应该是一个单词,minikube,表示 kubectl 已配置为与新创建的集群中的 Kubernetes 通信。

作为额外的验证,我们可以列出集群的所有节点。

kubectl get nodes  

输出如下:

NAME     STATUS ROLES  AGE VERSION
minikube Ready  <none> 31m v1.8.0  

不足为奇的是,只有一个节点,方便地被命名为 minikube

如果你熟悉 Docker Machine 或 Vagrant,你可能会注意到它们之间有相似的模式。Minikube 的命令几乎与 Docker Machine 的命令完全相同,而 Docker Machine 的命令又类似于 Vagrant 的命令。

让我们快速浏览一下当前在我们小型集群中运行的组件。

kubectl get all --all-namespaces

看哪,集群的全部荣耀。它由许多我们尚未探索的构建模块组成。而且,这些只是开始。随着我们的需求和知识增加,我们将添加更多的内容。目前,记住有许多活动的组件。我们暂时不深入探讨,这样会有些过于复杂。

回到 Minikube,我们可以做所有我们期望从虚拟机中看到的常见操作。例如,我们可以停止它。

minikube stop  

我们可以重新启动它。

minikube start  

我们可以删除它。

minikube delete  

一个有趣的功能是能够指定我们希望使用的 Kubernetes 版本。

由于 Kubernetes 仍然是一个年轻的项目,我们可以预期它会快速发生很多变化。这意味着我们的生产集群可能无法运行最新版本。另一方面,我们应该尽力使我们的本地环境尽可能接近生产环境(在合理范围内)。

我们可以通过以下命令列出所有可用版本:

minikube get-k8s-versions  

输出内容,限于前几行,如下所示:

The following Kubernetes versions are available:
        - v1.9.0
        - v1.8.0
        - v1.7.5
        - v1.7.4
        - v1.7.3
        - v1.7.2
        - v1.7.0
        ...

现在我们知道了哪些版本是可用的,我们可以基于 Kubernetes v1.7.0 创建一个新的集群。

minikube start \
 --vm-driver=virtualbox \
 --kubernetes-version="v1.7.0"

kubectl version --output=yaml  

我们创建了一个新的集群,并输出了客户端和服务器的版本信息。

后一个命令的输出如下:

clientVersion:
  buildDate: 2017-10-24T19:48:57Z
  compiler: gc
  gitCommit: bdaeafa71f6c7c04636251031f93464384d54963
  gitTreeState: clean
  gitVersion: v1.8.2
  goVersion: go1.8.3
  major: "1"
  minor: "8"
  platform: darwin/amd64
serverVersion:
  buildDate: 2017-10-04T09:25:40Z
  compiler: gc
  gitCommit: d3ada0119e776222f11ec7945e6d860061339aad
  gitTreeState: dirty
  gitVersion: v1.7.0
  goVersion: go1.8.3
  major: "1"
  minor: "7"
  platform: linux/amd64

如果你关注serverVersion部分,你会注意到major版本是1,而minor版本是7

现在怎么办?

我们已经简要介绍了 Minikube。实际上,这也可以算作一个较长的介绍。我们用它来创建一个单节点的 Kubernetes 集群,启动 UI,执行常见的虚拟机操作,如stoprestartdelete等。除此之外没什么了。如果你熟悉 Vagrant 或 Docker Machine,原理是一样的,命令也非常相似。

离开之前,我们会销毁这个集群。下一章将从头开始。这样,你可以随时在任何章节执行命令。

minikube delete  

就这样,集群不复存在。

第三章:创建 Pods

Pods 就像我们用来建造房屋的砖块。它们本身并不显眼,单独看也不显得特别重要。然而,它们是基本的构建块,没有它们,我们无法构建我们设定的解决方案。

如果你使用过 Docker 或 Docker Swarm,你可能已经习惯了认为容器是最小的单位,复杂的模式是在它之上构建的。而在 Kubernetes 中,最小的单位是 Pod。Pod 是表示集群中正在运行的进程的方式。从 Kubernetes 的角度来看,没有比 Pod 更小的单位。

一个 Pod 封装了一个或多个容器。它提供了一个独特的网络 IP,附加了存储资源,并决定容器的运行方式。Pod 内的一切都是紧密耦合的。

我们需要澄清,Pod 中的容器不一定是由 Docker 创建的。其他容器运行时也受到支持。然而,在撰写本书时,Docker 是最常用的容器运行时,我们的所有示例都会使用 Docker。

从本章开始,我们将打破传统,避免在深入实践示例之前进行长篇的概念解释。相反,我们将通过实践来学习理论,一步一步来。

我们将直接进入动手操作。由于没有 Kubernetes 集群,我们无法创建 Pods,因此我们的首要任务是先创建一个集群。

创建集群

我们将使用 Minikube 创建一个本地的 Kubernetes 集群。

本章中的所有命令都可以在 03-pods.sh (gist.github.com/vfarcic/d860631d0dd3158c32740e9260c7add0) Gist 中找到。

minikube start --vm-driver=virtualbox

kubectl get nodes  

后者命令的输出如下:

NAME     STATUS ROLES  AGE VERSION
minikube Ready  <none> 47s v1.8.0  

为了简化过程并免去你编写所有配置文件的麻烦,我们将克隆 GitHub 仓库 vfarcic/k8s-specs (github.com/vfarcic/k8s-specs)。它包含了我们本章以及本书大多数其他章节所需的一切。

git clone https://github.com/vfarcic/k8s-specs.git

cd k8s-specs  

我们克隆了仓库并进入了创建的目录。

现在我们可以运行我们的第一个 Pod。

快速而简便地运行 Pods

就像我们可以执行 docker run 来创建容器一样,kubectl 也允许我们通过一个命令来创建 Pods。例如,如果我们想要创建一个包含 Mongo 数据库的 Pod,命令如下:

kubectl run db --image mongo  

你会注意到输出中显示 deployment "db" was created。Kubernetes 运行的不仅仅是一个 Pod,它创建了一个 Deployment 和一些其他的东西。我们暂时不会深入讨论所有细节。现在重要的是,我们已经创建了一个 Pod。我们可以通过列出集群中的所有 Pods 来确认这一点:

kubectl get pods  

输出结果如下:

NAME                READY STATUS            RESTARTS AGE
db-59d5f5b96b-kch6p 0/1   ContainerCreating 0        1m  

我们可以看到 Pod 的名称、就绪状态、状态、重启次数以及存在时长(年龄)。如果你反应够快,或者你的网络较慢,可能没有 Pod 准备就绪。我们期望有一个 Pod,但此时没有正在运行的 Pod。由于mongo镜像相对较大,因此可能需要一些时间才能从 Docker Hub 拉取镜像。过了一段时间后,我们可以再次检索 Pods,确认 Mongo 数据库的 Pod 是否正在运行。

kubectl get pods  

输出如下:

NAME                READY STATUS  RESTARTS AGE
db-59d5f5b96b-kch6p 1/1   Running 0        6m  

我们可以看到,这次 Pod 已就绪,我们可以开始使用 Mongo 数据库。

我们可以确认基于mongo镜像的容器确实正在集群中运行。

eval $(minikube docker-env)

docker container ls -f ancestor=mongo  

我们评估了minikube变量,以便我们的本地 Docker 客户端使用在虚拟机内运行的 Docker 服务器。接着,我们列出了所有基于mongo镜像的容器。输出如下(为了简洁起见,已删除 ID):

IMAGE COMMAND                CREATED       STATUS       PORTS NAMES
mongo "docker-entrypoint.s..." 5 minutes ago Up 5 minutes       k8s
 _db_db-...  

如你所见,Pod 中定义的容器正在运行。

图 3-1:一个包含单个容器的 Pod

那不是运行 Pods 的最佳方式,因此我们将删除部署,这样会删除它所包含的所有内容,包括 Pod。

kubectl delete deployment db  

输出如下:

deployment "db" deleted  

为什么我说那不是运行 Pods 的最佳方式?我们使用了命令式方式告诉 Kubernetes 该做什么。尽管在某些情况下这样做可能有用,但大多数时候我们希望利用声明式方法。我们希望能够在文件中定义我们需要的内容,并将这些信息传递给 Kubernetes。这样,我们可以拥有一个有文档记录的、可重复的过程,并且(应该)可以进行版本控制。此外,kubectl run的命令相对简单。在实际应用中,我们需要声明的内容远比部署名称和镜像多。像kubectl这样的命令可能会迅速变得冗长,且在许多情况下非常复杂。相反,我们将以 YAML 格式编写规范。稍后,我们将看到如何使用声明式语法实现类似的结果。

通过声明式语法定义 Pods

尽管一个 Pod 可以包含任意数量的容器,但最常见的使用场景是使用单容器 Pod 模型。在这种情况下,Pod 是围绕一个容器的封装。从 Kubernetes 的角度来看,Pod 是最小的单元。我们不能告诉 Kubernetes 运行一个容器。相反,我们要求它创建一个封装容器的 Pod。

让我们来看一个简单的 Pod 定义:

cat pod/db.yml  

输出如下:

apiVersion: v1
kind: Pod
metadata:
 name: db
 labels:
 type: db
 vendor: Mongo Labs
spec:
 containers:
 - name: db
 image: mongo:3.3
 command: ["mongod"]
 args: ["--rest", "--httpinterface"]  

我们正在使用v1版本的 Kubernetes Pods API。apiVersionkind都是必需的。通过这种方式,Kubernetes 知道我们想做什么(创建一个 Pod)以及使用哪个 API 版本。

下一部分是metadata。它提供了不影响 Pod 行为的信息。我们使用metadata来定义 Pod 的名称(db)和一些标签。稍后,当我们学习控制器时,标签将有实际用途。目前,它们纯粹是信息性的。

最后一部分是spec,我们在其中定义了一个容器。正如你可能猜到的,我们可以将多个容器定义为一个 Pod。否则,部分将以单数形式(container而非containers)书写。稍后我们将探讨多容器 Pod。

在我们的案例中,容器定义了名称(db)、镜像(mongo)、容器启动时应执行的命令(mongod),以及最后的参数集。参数以数组形式定义,在这个例子中包含两个元素(--rest--httpinterface)。

我们不会深入探讨你可以用来定义 Pod 的所有内容。在本书中,你将看到很多其他常用(或不那么常用)的 Pod 定义项。稍后,当你决定学习所有可以应用的参数时,请查看官方的、不断变化的Pod v1 core文档。

让我们创建在db.yml文件中定义的 Pod。

kubectl create -f pod/db.yml  

你会注意到我们在命令中并没有需要指定pod。该命令将创建在pod/db.yml文件中定义的资源类型。稍后,你会看到一个 YAML 文件可以包含多个资源的定义。

让我们看一下集群中的 Pods:

kubectl get pods  

输出如下:

NAME READY STATUS  RESTARTS AGE
db   1/1   Running 0        11s  

我们名为db的 Pod 已经启动并运行。

在某些情况下,你可能想通过指定wide输出获取更多信息。

kubectl get pods -o wide  

输出如下:

NAME READY STATUS  RESTARTS AGE IP         NODE
db   1/1   Running 0        1m  172.17.0.4 minikube  

如你所见,我们得到了两列额外的信息:IP 和节点。

如果你想解析输出,使用json格式可能是最佳选择。

kubectl get pods -o json  

输出数据太大,无法在书中呈现,尤其是因为我们不会详细介绍通过json输出格式提供的所有信息。

当我们需要比默认输出提供的更多信息,但仍希望以对人类友好的格式呈现时,yaml输出可能是最佳选择。

kubectl get pods -o yaml  

就像使用json输出一样,我们不会深入讨论从 Kubernetes 获取的所有信息。随着时间的推移,你将熟悉与 Pod 相关的所有信息。目前,我们想集中精力关注最重要的方面。

让我们介绍一个新的kubectl子命令。

kubectl describe pod db  

describe子命令返回了指定资源的详细信息。在这个例子中,资源是名为db的 Pod。

输出太大,无法逐一讲解每个细节。此外,如果你熟悉容器,大部分内容应该是显而易见的。我们将简要评论最后一部分,名为 events

...
Events:
 Type    Reason                 Age   From               Message
 ----    ------                 ----  ----               -------
 Normal  Scheduled              2m    default-scheduler  Successfully assigned db to minikube
 Normal  SuccessfulMountVolume  2m    kubelet, minikube  MountVolume.SetUp succeeded for volume "default-token-x27md"
 Normal  Pulling                2m    kubelet, minikube  pulling image "mongo:3.3"
 Normal  Pulled                 2m    kubelet, minikube  Successfully pulled image "mongo:3.3"
 Normal  Created                2m    kubelet, minikube  Created container
 Normal  Started                2m    kubelet, minikube  Started container

我们可以看到 Pod 已创建并经历了多个阶段,如下图所示。尽管从用户的角度来看过程很简单,但在后台发生了许多事情。

现在可能是暂停练习、讨论 Kubernetes 组件的一些细节,并试图理解 Pod 调度是如何工作的好时机。

该过程涉及三个主要组件。

API 服务器 是 Kubernetes 集群的核心组件,运行在主节点上。由于我们使用的是 Minikube,因此主节点和工作节点被打包在同一个虚拟机中。然而,更严肃的 Kubernetes 集群应该将这两个节点分布在不同的主机上。

所有其他组件与 API 服务器交互并保持监视变化。Kubernetes 中的大多数协调工作都涉及一个组件向 API 服务器资源写入,另一个组件在监视该资源。第二个组件将几乎立即对变化作出反应。

调度器 也在主节点上运行。它的任务是监视未分配的 Pod,并将它们分配到具有符合 Pod 要求的可用资源(CPU 和内存)的节点上。由于我们正在运行一个单节点集群,指定资源不会提供太多关于资源使用的见解,因此我们会留到稍后讨论。

Kubelet 在每个节点上运行。它的主要功能是确保分配给该节点的 Pod 正在运行。它会监视该节点的任何新 Pod 分配。如果一个 Pod 被分配到 Kubelet 正在运行的节点上,它将拉取 Pod 定义并使用它通过 Docker 或任何其他支持的容器引擎来创建容器。

执行 kubectl create -f pod/db.yml 命令后发生的事件顺序如下:

  1. Kubernetes 客户端(kubectl)向 API 服务器发送请求,要求创建在 pod/db.yml 文件中定义的 Pod。

  2. 由于调度器在监视 API 服务器的新事件,它检测到有一个未分配的 Pod。

  3. 调度器决定将 Pod 分配给哪个节点,并将该信息发送到 API 服务器。

  4. Kubelet 也在监视 API 服务器。它检测到 Pod 被分配到它正在运行的节点上。

  5. Kubelet 向 Docker 发送请求,请求创建构成该 Pod 的容器。在我们的案例中,Pod 定义了一个基于 mongo 镜像的单一容器。

  6. 最终,Kubelet 向 API 服务器发送请求,通知它 Pod 已成功创建。

这个过程现在可能不太容易理解,因为我们正在运行一个单节点集群。如果我们有更多的虚拟机,调度可能会在其他地方发生,过程的复杂性也会更容易理解。我们会在适当的时候到达那里。

图 3-2:Pod 调度顺序

在许多情况下,通过引用定义资源的文件来描述资源更加实用。这样就不会产生混淆,也不需要记住资源的名称。我们本可以执行以下命令:

kubectl describe -f pod/db.yml  

输出应该是相同的,因为在两种情况下,kubectl都向 Kubernetes API 发送了请求,获取名为db的 Pod 的信息。

就像在 Docker 中一样,我们可以在 Pod 内的运行容器中执行一个新的进程。

kubectl exec db ps aux  

输出如下:

USER PID %CPU %MEM    VSZ   RSS TTY STAT START TIME COMMAND
root   1  0.5  2.9 967452 59692 ?   Ssl  21:47 0:03 mongod --rest --httpinterface
root  31  0.0  0.0  17504  1980 ?   Rs   21:58 0:00 ps aux

我们告诉 Kubernetes 我们想在db Pod 的第一个容器内执行一个进程。由于我们的 Pod 只定义了一个容器,这个容器就是第一个容器。可以通过设置--container(或-c)参数来指定使用哪个容器。当 Pod 中运行多个容器时,这一点特别有用。

除了将 Pod 作为参考外,kubectl exec几乎与docker container exec命令相同。显著的区别是,kubectl允许我们在集群内的任何节点上的容器中执行进程,而docker container exec则仅限于在特定节点上的容器中执行。

我们可以进入一个正在运行的容器,而不是在其中执行一个新的短时进程。例如,我们可以通过-i (stdin)-t(终端)参数使执行变得交互式,并在容器内运行shell

kubectl exec -it db sh  

我们现在在容器内的sh进程中。由于容器托管着一个 Mongo 数据库,我们可以例如执行db.stats()来确认数据库确实在运行。

echo 'db.stats()' | mongo localhost:27017/test  

我们使用mongo客户端执行db.stats(),用于检查在localhost:27017上运行的test数据库。由于我们不是在这本书中学习 Mongo(至少不是现在),这个练习的唯一目的是证明数据库已经启动并在运行。接下来让我们退出容器。

exit  

日志应该从容器发送到一个中央位置。然而,由于我们尚未深入探讨这个话题,因此能够查看 Pod 中容器的日志会非常有用。

输出日志的命令如下,适用于db Pod 中唯一的容器:

kubectl logs db  

输出太大且整个输出并不重要。最后一行之一如下所示:

...
2017-11-10T22:06:20.039+0000 I NETWORK  [thread1] waiting for connections on port 27017
...  

使用-f(或--follow)选项,我们可以实时跟踪日志。就像使用exec子命令一样,如果一个 Pod 定义了多个容器,我们可以通过-c参数指定使用哪个容器。

当 Pod 内的容器死掉时会发生什么?让我们模拟一个故障并观察发生的情况。

kubectl exec -it db pkill mongod

kubectl get pods  

我们终止了容器的主进程,并列出了所有的 Pods。输出如下:

NAME READY STATUS  RESTARTS AGE
db   1/1   Running 1        13m  

容器正在运行(1/1)。Kubernetes 保证 Pod 内的容器(几乎)始终在运行。请注意,RESTARTS字段现在的值为1。每当一个容器失败时,Kubernetes 会重新启动它:

图 3-3:容器失败的 Pod

最后,如果我们不再需要 Pod,可以将其删除。

kubectl delete -f pod/db.yml

kubectl get pods  

我们移除了在db.yml中定义的 Pods,并检索了集群中所有 Pod 的列表。后者命令的输出如下:

NAME READY STATUS      RESTARTS AGE
db   0/1   Terminating 1        3h  

就绪容器的数量下降至0db Pod 的状态为terminating

当我们发送删除 Pod 的指令时,Kubernetes 尝试优雅地终止它。首先,它会向所有 Pod 内容器中的主进程发送TERM信号。从那时起,Kubernetes 为每个容器提供了 30 秒的时间,以便容器中的进程能够优雅地关闭。一旦宽限期到期,KILL信号会被发送以强制终止所有主进程,并与之一起终止所有容器。默认的宽限期可以通过 YAML 定义中的gracePeriodSeconds值或kubectl delete命令的--grace-period参数进行更改。

如果我们在发出delete指令后的 30 秒重复执行get pods命令,Pod 应该会从系统中移除:

kubectl get pods  

这次,输出结果不同。

No resources found.  

系统中唯一的 Pod 已不再存在。

在单个 Pod 中运行多个容器

Pods 旨在运行多个合作进程,这些进程应作为一个统一体进行操作。这些进程被封装在容器中。构成 Pod 的所有容器都运行在同一台机器上。Pod 不能分布在多个节点上。

Pod 中的所有进程(容器)共享相同的资源集,它们可以通过localhost相互通信。共享资源之一是存储。在 Pod 中定义的卷可以被所有容器访问,从而使它们可以共享相同的数据。我们稍后将更深入地探讨存储内容。现在,让我们来看看pod/go-demo-2.yml的规范:

cat pod/go-demo-2.yml  

输出如下:

apiVersion: v1
kind: Pod
metadata:
 name: go-demo-2
 labels:
 type: stack
spec:
 containers:
 - name: db
 image: mongo:3.3
 - name: api
 image: vfarcic/go-demo-2
    env:
 - name: DB
 value: localhost  

YAML 文件定义了一个包含名为dbapi的两个容器的 Pod。vfarcic/go-demo-2镜像中的服务使用环境变量DB来确定数据库的位置。该值为localhost,因为同一 Pod 中的所有容器都可以通过它访问。

让我们创建 Pod:

kubectl create -f pod/go-demo-2.yml

kubectl get -f pod/go-demo-2.yml  

我们创建了一个在go-demo-2.yml文件中定义的新 Pod,并从 Kubernetes 获取了其信息。后者命令的输出如下:

NAME      READY STATUS  RESTARTS AGE
go-demo-2 2/2   Running 0        2m  

READY列可以看出,这次 Pod 有两个容器(2/2)。

这可能是一个很好的机会来引入格式化,获取特定信息。

假设我们想获取 Pod 中容器的名称。首先,我们需要熟悉 Kubernetes API。我们可以通过访问Pod v1 corev1-9.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.9/#pod-v1-core)文档来实现。虽然阅读文档迟早是必须的,但我们将采用更简单的方式,直接检查 Kubernetes 的输出。

kubectl get -f pod/go-demo-2.yml -o json  

输出过大,不适合在书中展示,所以我们将专注于当前任务。我们需要获取 Pod 中容器的名称。因此,我们需要关注输出中的以下部分:

{
 ...
 "spec": {
 "containers": [
 {
 ...
 "name": "db",
 ...
 },
 {
 ...
 "name": "api",
 ...
 }
 ],
 ...
 },
 ...
}  

用于筛选输出并仅获取容器名称的get命令如下:

kubectl get -f pod/go-demo-2.yml \
 -o jsonpath="{.spec.containers[*].name}"

输出如下:

db api  

我们使用了jsonpath作为输出格式,并指定要从spec中获取所有containers的名称。现在这种筛选和格式化信息的能力可能看起来不那么重要,但当我们进入更复杂的场景时,它将变得非常宝贵。尤其是在我们尝试自动化过程并向 Kubernetes API 发送请求时,这一点尤为明显。

我们如何在 Pod 内执行命令呢?与之前做类似任务的例子不同,这次 Pod 中有两个容器,因此我们需要更具体地指定。

kubectl exec -it -c db go-demo-2 ps aux  

输出应显示db容器中的进程,即mongod进程。

容器的日志怎么办?正如你可能猜到的,我们不能执行类似kubectl logs go-demo-2的命令,因为该 Pod 托管了多个容器。相反,我们需要更具体地指定想查看日志的容器名称:

kubectl logs go-demo-2 -c db  

那如何进行扩展呢?例如,我们如何扩展服务,使得 API 有两个容器,数据库有一个容器?

一种选择是定义 Pod 中的两个容器。我们来看看一个可能完成所需任务的 Pod 定义。

cat pod/go-demo-2-scaled.yml  

输出如下:

apiVersion: v1
kind: Pod
metadata:
 name: go-demo-2
 labels:
 type: stack
spec:
 containers:
 - name: db
 image: mongo:3.3
 - name: api-1
 image: vfarcic/go-demo-2
 env:
 - name: DB
 value: localhost
 - name: api-2
 image: vfarcic/go-demo-2
 env:
 - name: DB
 value: localhost  

我们为 API 定义了两个容器,分别命名为api-1api-2。剩下的就是创建 Pod。但我们暂时不打算这么做。

我们不应将 Pod 视为资源,应该仅仅看作是我们集群中最小单元的定义。Pod 是一个容器集合,共享相同的资源,仅此而已。其他的任务应通过更高层次的构造来完成。在接下来的章节中,我们将探索如何在不改变 Pod 定义的情况下进行扩展。

让我们回到最初定义了 apidb 容器的多容器 Pod。这个设计选择非常糟糕,因为它将两者紧密耦合在一起。因此,当我们探讨如何扩展 Pods(而不是容器)时,两者都需要匹配。例如,如果我们将 Pod 扩展到三个实例,我们将得到三个 API 和三个数据库。相反,我们应该定义两个 Pod,每个容器一个(dbapi)。这样,我们就能灵活地独立处理每个容器。

还有一些其他原因不建议将多个容器放在同一个 Pod 中。现在,先耐心等待。大多数情况下,可能认为多容器 Pod 是解决方案的场景,最终将通过其他资源来解决。

Pod 是容器的集合。然而,这并不意味着多容器 Pod 是常见的。它们是非常罕见的。你创建的大多数 Pod 将是单容器的。

这是否意味着多容器 Pod 是没有用的?并不是。实际上,在某些场景下,将多个容器放入同一个 Pod 是一个好主意。然而,这些场景非常特定,并且在大多数情况下是基于一个作为主服务的容器,其他容器作为 side-car 服务。一个常见的用例是用于 持续集成CI)、持续交付CD)或 持续部署CDP)的多容器 Pod。我们稍后会探讨这些场景。现在,我们将重点关注单容器 Pod。

在我们开始讲解容器健康检查之前,让我们先移除 Pod。

kubectl delete -f pod/go-demo-2.yml  

监控健康状态

vfarcic/go-demo-2 Docker 镜像被设计为在出现问题的第一时间就失败。在这种情况下,不需要任何健康检查。当问题发生时,主进程停止,承载它的容器也停止,Kubernetes 会重启该失败的容器。然而,并非所有服务都设计为快速失败。即使是设计为快速失败的服务,也可能仍然受益于额外的健康检查。例如,一个后端 API 可能在运行,但由于内存泄漏,响应请求的速度比预期慢。这样的情况可能需要一个健康检查,来验证服务是否在例如两秒钟内作出响应。我们可以利用 Kubernetes 的 liveness 和 readiness 探针来实现这一点。

livenessProbe 可用于确认容器是否应继续运行。如果探针失败,Kubernetes 会终止该容器并应用重启策略,默认情况下为Always。另一方面,readinessProbe 应该用作指示服务是否准备好接收请求的标志。与 Services 构造结合时,只有将 readinessProbe 状态设置为 Success 的容器才会接收请求。我们将稍后讨论 readinessProbe,因为它与 Services 直接相关。相反,我们将先探讨 livenessProbe。两者的定义方式相同,因此对其中一个的经验可以轻松应用到另一个上。

让我们来看看我们迄今为止使用的 Pod 的更新定义:

cat pod/go-demo-2-health.yml  

输出如下:

apiVersion: v1
kind: Pod
metadata:
 name: go-demo-2
 labels:
 type: stack
spec:
 containers:
 - name: db
 image: mongo:3.3
 - name: api
 image: vfarcic/go-demo-2
 env:
 - name: DB
 value: localhost
 livenessProbe:
 httpGet:
 path: /this/path/does/not/exist
 port: 8080
 initialDelaySeconds: 5
 timeoutSeconds: 2 # Defaults to 1
 periodSeconds: 5 # Defaults to 10
 failureThreshold: 1 # Defaults to 3  

不要因为在这个 Pod 中看到两个容器而感到困惑。我坚持我的观点。这两个容器应该在不同的 Pod 中定义。然而,由于这需要我们尚未掌握的知识,而vfarcic/go-demo-2在没有数据库的情况下无法工作,我们必须坚持使用这个指定了两个容器的示例。很快我们就会将其拆解开来。

额外的定义在livenessProbe内。

我们定义了动作应该是httpGet,后面跟着服务的pathport。由于/this/path/does/not/exist对自身来说是正确的,探针将会失败,从而向我们展示容器不健康时会发生什么。host没有指定,因为它默认使用 Pod IP。

在下方,我们声明了第一次执行探针时应该延迟五秒(initialDelaySeconds),请求在两秒后超时(timeoutSeconds),该过程应该每五秒重复一次(periodSeconds),并且(failureThreshold)定义了在放弃之前必须尝试多少次。

让我们来看一下探针的实际运行情况。

kubectl create \
 -f pod/go-demo-2-health.yml  

我们创建了带有探针的 Pod。现在我们必须等待探针失败几次。一分钟就足够了。一旦等待完成,我们可以描述 Pod:

kubectl describe \
 -f pod/go-demo-2-health.yml  

输出的底部包含事件。它们如下所示:

...
Events:
 Type     Reason                 Age              From           Message
 ----     ------                 ----             ----           -------
 Normal   Scheduled              6m               default-scheduler  Successfully assigned go-demo-2 to minikube
 Normal   SuccessfulMountVolume  6m               kubelet, minikube  MountVolume.SetUp succeeded for volume "default-token-7jc7q"
 Normal   Pulling                6m               kubelet, minikube  pulling image "mongo"
 Normal   Pulled                 6m               kubelet, minikube  Successfully pulled image "mongo"
 Normal   Created                6m               kubelet, minikube  Created container
 Normal   Started                6m               kubelet, minikube  Started container
 Normal   Created                5m (x3 over 6m)  kubelet, minikube  Created container
 Normal   Started                5m (x3 over 6m)  kubelet, minikube  Started container
 Warning  Unhealthy              5m (x3 over 6m)  kubelet, minikube  Liveness probe failed: HTTP probe failed with statuscode: 404
 Normal   Pulling                5m (x4 over 6m)  kubelet, minikube  pulling image "vfarcic/go-demo-2"
  Normal   Killing                5m (x3 over 6m)  kubelet, minikube  Killing container with id docker://api:Container failed live ness probe.. Container will be killed and recreated.
 Normal   Pulled                 5m (x4 over 6m)  kubelet, minikube  Successfully pulled image "vfarcic/go-demo-2"  

我们可以看到,一旦容器启动,探针就会执行,并且失败了。因此,容器被终止后又重新创建。在前面的输出中,我们可以看到这个过程重复了三次(3x over ...)。

如果你想了解所有可用的选项,请访问Probe v1 corev1-9.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.9/#probe-v1-core)。

Pod 本身(几乎)没有用处。

Pod 是 Kubernetes 中的基本构建块。在大多数情况下,你不会直接创建 Pod,而是使用更高层次的构建块,如控制器。

Pod 是可消耗的,它们不是持久的服务。尽管 Kubernetes 尽力确保 Pod 中的容器(几乎)始终正常运行,但 Pod 本身却不能这样。如果 Pod 失败、被销毁或从节点中驱逐,它将不会重新调度,至少没有控制器的情况下是如此。类似地,如果整个节点被销毁,节点上的所有 Pod 将会消失。Pod 不会自我修复。除了某些特殊情况,Pod 并不是用来直接创建的。

不要自行创建 Pod。让控制器为你创建 Pod。

现在怎么办?

我们将删除集群并重新开始下一章。

minikube delete  

请花些时间更好地熟悉 Pods。它们是 Kubernetes 中最基础、也可以说是最关键的构建模块。既然你现在已经对 Pods 有了扎实的理解,接下来的一个好步骤可能是阅读 PodSpec v1 核心文档(v1-9.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.9/#pod-v1-core)。

图 3-4:至今为止探索的组件

第四章:使用 ReplicaSets 扩展 Pods

大多数应用程序应该是可扩展的,所有应用程序都必须具备容错性。Pods 本身并不提供这些功能,而 ReplicaSets 提供了这些功能。

我们了解到,Pods 是 Kubernetes 中最小的单位。我们还了解到,Pods 并不具备容错性。如果一个 Pod 被销毁,Kubernetes 不会采取任何措施来修复这个问题。也就是说,如果没有控制器,Pods 是不会得到自动修复的。

我们将探索的第一个控制器叫做 ReplicaSet。它的主要功能,几乎是唯一功能,就是确保指定数量的 Pod 副本(几乎)始终与实际状态匹配。这意味着 ReplicaSets 使 Pods 具备可扩展性。

我们可以将 ReplicaSets 看作一种自我修复机制。只要满足基本条件(例如足够的内存和 CPU),与 ReplicaSet 关联的 Pods 会被保证运行。它们提供了容错性和高可用性。

值得一提的是,ReplicaSet 是下一代 ReplicationController。唯一显著的区别是 ReplicaSet 增强了对选择器的支持。其他一切都相同。由于 ReplicationController 被视为弃用,因此我们只关注 ReplicaSet。

ReplicaSet 的主要功能是确保指定数量的服务副本(几乎)始终在运行。

让我们通过示例来探索 ReplicaSet,看看它是如何工作的,以及它到底做了什么。

第一步是创建一个 Kubernetes 集群。

创建集群

我们将继续使用 Minikube 在本地模拟集群。

本章中的所有命令都可以在 04-rs.shgist.github.com/vfarcic/f6588da3d1c8a82100a81709295d4a93)Gist 中找到。

minikube start --vm-driver=virtualbox

kubectl config current-context  

我们创建了一个单节点集群并配置了 kubectl 以便使用它。

在我们探索第一个 ReplicaSet 示例之前,我们将进入本地的 vfarcic/k8s-spec 仓库,并拉取最新版本。谁知道呢,也许自上次你查看它以来,我添加了一些新内容。

cd k8s-specs

git pull  

现在集群正在运行,并且包含规格的仓库是最新的,我们可以创建我们的第一个 ReplicaSet。

创建 ReplicaSets

让我们看一下基于上一章节创建的 Pod 的 ReplicaSet:

cat rs/go-demo-2.yml  

输出如下:

apiVersion: apps/v1beta2
kind: ReplicaSet
metadata:
 name: go-demo-2
spec:
 replicas: 2
 selector:
 matchLabels:
 type: backend
 service: go-demo-2
 template:
 metadata:
 labels:
 type: backend
 service: go-demo-2
 db: mongo
 language: go
 spec:
 containers:
 - name: db
 image: mongo:3.3
 - name: api
 image: vfarcic/go-demo-2
 env:
 - name: DB
 value: localhost
 livenessProbe:
 httpGet:
 path: /demo/hello
 port: 8080  

apiVersionkindmetadata 字段是所有 Kubernetes 对象的必填项,ReplicaSet 也不例外。

我们指定了 apiVersionapps/v1beta2。在写这篇文章时,ReplicaSet 仍处于 beta 阶段。很快它将被视为稳定版本,到时候你可以将该值替换为 apps/v1kindReplicaSetmetadata 中有一个 name 键设置为 go-demo-2。我们本可以扩展 ReplicaSet 的 metadata 来添加标签,但我们跳过了这一部分,因为它们仅用于信息传递,不会影响 ReplicaSet 的行为。

你应该对这三个字段比较熟悉,因为我们在处理 Pod 时已经探索过它们。除了这些字段,spec部分也是必需的。

我们在spec部分定义的第一个字段是replicas。它设置 Pod 的期望副本数。在这种情况下,ReplicaSet 应该确保同时运行两个 Pod。如果我们没有指定replicas的值,默认值将为1

下一个spec部分是selector。我们用它来选择哪些 Pod 应包含在 ReplicaSet 中。它不会区分由 ReplicaSet 或其他过程创建的 Pod。换句话说,ReplicaSet 和 Pod 是解耦的。如果已经存在匹配selector的 Pod,ReplicaSet 将不会做任何操作。如果不存在,它将创建足够数量的 Pod 以匹配replicas字段的值。不仅如此,ReplicaSet 会创建缺失的 Pod,还会监控集群并确保所需数量的replicas几乎始终在运行。如果已经有更多匹配selector的 Pod 在运行,ReplicaSet 会终止一些 Pod 以匹配replicas中设置的数量。

我们使用了spec.selector.matchLabels来指定几个标签。它们必须与spec.template中定义的标签匹配。在我们的例子中,ReplicaSet 将查找type设置为backendservice设置为go-demo-2的 Pod。如果具有这些标签的 Pod 尚不存在,ReplicaSet 将根据spec.template部分创建它们。

spec字段的最后一部分是template。它是spec中唯一必需的字段,且其结构与 Pod 的规格相同。至少,spec.template.metadata.labels部分的标签必须与spec.selector.matchLabels中指定的标签匹配。我们还可以设置其他标签,这些标签仅用于信息性目的。ReplicaSet 会确保具有相同标签的 Pod 的数量与副本数匹配。在我们的例子中,我们将typeservice设置为相同的值,并添加了两个额外的标签(dblanguage)。

可能会让人感到困惑的是,spec.template.spec.containers字段是必需的。ReplicaSet 将查找其他方式创建的具有匹配标签的 Pod。如果我们已经创建了一个标签为type: backendservice: go-demo-2的 Pod,那么这个 ReplicaSet 会找到它们,而不会创建spec.template中定义的 Pod。这个字段的主要目的是确保所需数量的replicas在运行。如果这些 Pod 是通过其他方式创建的,ReplicaSet 将不会做任何事情。否则,它将根据spec.template中的信息创建 Pod。

最后,spec.template.spec部分包含我们在上一章中使用的相同containers定义。它定义了一个具有两个容器(dbapi)的 Pod。

在前一章中,我提到这两个容器不应属于同一个 Pod。对于由 ReplicaSet 管理的 Pods 中的容器也是如此。不过,我们还没有机会探索如何允许运行在不同 Pods 中的容器相互通信。因此,暂时我们将继续使用同样有缺陷的 Pods 定义。

让我们先创建 ReplicaSet 并亲身体验它的优势。

kubectl create -f rs/go-demo-2.yml  

我们收到了 replicaset "go-demo-2"created 的响应。我们可以通过列出集群中所有的 ReplicaSets 来确认这一点。

kubectl get rs  

输出如下:

NAME      DESIRED CURRENT READY AGE
go-demo-2 2       2       0     14s  

我们可以看到期望的副本数是 2,并且与当前值匹配。ready 字段的值仍为 0,但在镜像被拉取并且容器运行后,它将变为 2

我们可以检索 rs/go-demo-2.yml 文件中指定的副本,而不是检索集群中所有的副本。

kubectl get -f rs/go-demo-2.yml  

输出应该是相同的,因为在两种情况下,集群中只有一个 ReplicaSet 在运行。

在前一章中我们探索的所有其他 kubectl get 参数同样适用于 ReplicaSets,或者更准确地说,适用于所有 Kubernetes 对象。kubectl describe 命令也是如此:

kubectl describe -f rs/go-demo-2.yml  

输出的最后几行如下:

...
Events:
 Type   Reason           Age  From                  Message
 ----   ------           ---- ----                  -------
 Normal SuccessfulCreate 3m   replicaset-controller Created pod: 
 go-demo-2-v59t5
 Normal SuccessfulCreate 3m   replicaset-controller Created pod: 
 go-demo-2-5fd54  

从事件来看,我们可以看到 ReplicaSet 在尝试将期望状态与实际状态匹配时创建了两个 Pods。

最后,如果你还不相信 ReplicaSet 创建了缺失的 Pods,我们可以列出集群中所有正在运行的 Pods 并确认这一点:

kubectl get pods --show-labels  

为了确保安全,我们使用了 --show-labels 参数,这样我们可以验证集群中的 Pods 是否与 ReplicaSet 创建的 Pods 匹配。

输出如下:

NAME            READY STATUS  RESTARTS AGE LABELS
go-demo-2-5fd54 2/2   Running 0        6m  db=mongo,language=go,service=go-demo-2,type=backend
go-demo-2-v59t5 2/2   Running 0        6m  db=mongo,language=go,service=go-demo-2,type=backend    

图 4-1:一个包含两个 Pod 副本的 ReplicaSet

kubectl create -f rs/go-demo-2.yml 命令执行的事件顺序如下:

  1. Kubernetes 客户端(kubectl)向 API 服务器发送请求,请求创建定义在 rs/go-demo-2.yml 文件中的 ReplicaSet。

  2. 控制器正在监视 API 服务器的新事件,它检测到有一个新的 ReplicaSet 对象。

  3. 控制器创建了两个新的 Pod 定义,因为我们在 rs/go-demo-2.yml 文件中将副本数配置为 2

  4. 由于调度器正在监视 API 服务器的新事件,它检测到有两个未分配的 Pods。

  5. 调度器决定将 Pod 分配到哪个节点,并将该信息发送给 API 服务器。

  6. Kubelet 也在监视 API 服务器。它检测到这两个 Pod 已经分配到它所在的节点上。

  7. Kubelet 向 Docker 发送请求,请求创建构成 Pod 的容器。在我们的例子中,Pod 定义了基于 mongoapi 镜像的两个容器。因此,最终会创建四个容器。

  8. 最后,Kubelet 向 API 服务器发送请求,通知其 Pods 已成功创建。

图 4-2:请求创建 ReplicaSet 后的事件顺序

我们描述的顺序有助于理解从我们请求创建新 ReplicaSet 之时开始,到集群中发生的所有事情。然而,这可能有些混乱,所以我们将尝试通过一个更直观的图表来解释同一过程,以便更接近地展示集群的情况。

图 4-3:请求创建 ReplicaSet 后的事件顺序

通常,我们会有一个多节点集群,Pods 会分布在不同节点上。目前,在使用 Minikube 时,只有一台服务器同时充当主节点和工作节点。稍后,当我们开始使用多节点集群时,Pods 的分布将变得更加明显。架构也是如此。我们稍后会更详细地解释 Kubernetes 的不同组件。

让我们看看我们可以对 ReplicaSets 执行哪些类型的操作。

操作 ReplicaSets

如果我们删除 ReplicaSet 会发生什么?正如你可能猜到的那样,ReplicaSet 及其创建的所有对象(即 Pods)将通过执行 kubectl delete -f rs/go-demo-2.yml 命令一同消失。然而,由于 ReplicaSets 和 Pods 是松耦合的对象,且具有匹配的标签,我们可以删除一个而不删除另一个。例如,我们可以删除我们创建的 ReplicaSet,同时保留这两个 Pods。

kubectl delete -f rs/go-demo-2.yml \
 --cascade=false  

我们使用了 --cascade=false 参数,防止 Kubernetes 删除所有下游对象。因此,我们获得了 replicaset "go-demo-2"删除 的确认。让我们确认它是否真的从系统中移除。

kubectl get rs  

正如预期的那样,输出显示 没有找到资源

如果 --cascade=false 确实防止 Kubernetes 删除下游对象,那么 Pods 应该继续在集群中运行。让我们确认这个假设。

kubectl get pods  

输出如下:

NAME            READY STATUS  RESTARTS AGE
go-demo-2-md5xp 2/2   Running 0        9m
go-demo-2-vnmf7 2/2   Running 0        9m  

虽然我们删除了 ReplicaSet,但 ReplicaSet 创建的两个 Pods 仍然在集群中运行。

当前在集群中运行的 Pods 与我们之前创建的 ReplicaSet 没有任何关联。我们删除了 ReplicaSet,但 Pods 仍然存在。由于 ReplicaSet 使用标签来决定集群中是否已经运行了所需数量的 Pods,我们应该得出结论:如果我们再次创建相同的 ReplicaSet,它应该会重新使用集群中运行的两个 Pods。让我们验证一下。

除了之前执行的 kubectl create 命令,我们还将添加 --save-config 参数。它将保存 ReplicaSet 的配置,允许我们稍后执行一些额外的操作。稍后我们会讨论这些操作。现在,重要的是我们即将创建与之前相同的 ReplicaSet。

kubectl create -f rs/go-demo-2.yml \
 --save-config  

输出显示 replicaset "go-demo-2" 已创建。让我们看看 Pods 怎么样了。

kubectl get pods  

输出如下:

NAME            READY STATUS  RESTARTS AGE
go-demo-2-md5xp 2/2   Running 0        10m
go-demo-2-vnmf7 2/2   Running 0        10m  

如果你比较 Pods 的名称,你会发现它们与我们创建 ReplicaSet 之前的名称相同。它根据匹配的标签进行查找,推断出有两个 Pod 与之匹配,并决定不需要创建新的 Pod。匹配的 Pods 已经满足所需的副本数。

由于我们保存了配置,我们可以apply一个更新后的 ReplicaSet 定义。例如,我们可以使用rs/go-demo-2-scaled.yml文件,文件中唯一的不同是副本数设置为4。我们本可以一开始就使用apply创建 ReplicaSet,但我们没有这样做。apply命令会自动保存配置,以便我们以后可以编辑它。create命令默认不执行此操作,因此我们必须通过--save-config手动保存它。

kubectl apply -f rs/go-demo-2-scaled.yml  

这次输出稍有不同。我们看到的不是 ReplicaSet 被创建,而是它被configured(配置)了:

让我们看一下这些 Pods。

kubectl get pods  

输出如下:

NAME            READY STATUS  RESTARTS AGE
go-demo-2-ckmtv 2/2   Running 0        50s
go-demo-2-lt4qm 2/2   Running 0        50s
go-demo-2-md5xp 2/2   Running 0        11m
go-demo-2-vnmf7 2/2   Running 0        11m  

正如预期的那样,现在集群中有四个 Pods。如果你仔细观察 Pods 的名称,你会注意到其中有两个与之前相同。

当我们应用了新的配置,replicas设置为4而不是2时,Kubernetes 更新了 ReplicaSet,后者反过来评估了具有匹配标签的 Pods 的当前状态。它找到了两个具有相同标签的 Pods,并决定创建两个新的 Pods,以便新的期望状态能够与实际状态匹配。

让我们看看当一个 Pod 被销毁时会发生什么。

POD_NAME=$(kubectl get pods -o name \
 | tail -1)

kubectl delete $POD_NAME  

我们获取了所有 Pods,并使用-o name只获取它们的名称。结果通过tail -1命令传输,以便只输出其中一个名称。结果存储在环境变量POD_NAME中。后面的命令使用该变量来移除该 Pod,模拟故障发生。

让我们再看看集群中的 Pods:

kubectl get pods  

输出如下:

NAME              READY     STATUS        RESTARTS   AGE
go-demo-2-ckmtv   2/2       Running       0          10m
go-demo-2-lt4qm   2/2       Running       0          10m
go-demo-2-md5xp   2/2       Running       0          13m
go-demo-2-t8sfs   2/2       Running       0          30s
go-demo-2-vnmf7   0/2       Terminating   0          13m  

我们可以看到,我们删除的 Pod 正在terminating(终止)。然而,由于我们有一个 ReplicaSet,其replicas设置为4,一旦它发现 Pod 数量降到3,它就创建了一个新的 Pod。我们刚刚见证了自愈的过程。只要集群中有足够的可用资源,ReplicaSets 会确保指定数量的 Pod 副本(几乎)始终运行。

让我们看看如果我们移除 ReplicaSet 在选择器中使用的一个 Pod 标签会发生什么。

POD_NAME=$(kubectl get pods -o name \
 | tail -1)

kubectl label $POD_NAME service-

kubectl describe $POD_NAME  

我们使用相同的命令来获取一个 Pod 的名称,并执行移除service标签的命令。请注意标签名称末尾的-。这是表示标签应该被移除的语法:

最后,我们描述了 Pod:

最后一个命令的输出,限于标签部分,如下所示:

...
Labels: db=mongo
 language=go
 type=backend
...  

如你所见,service标签已经消失。

现在,让我们列出集群中的 Pods,并检查是否有任何变化:

kubectl get pods --show-labels  

输出如下:

NAME            READY STATUS  RESTARTS AGE LABELS
go-demo-2-ckmtv 2/2   Running 0        24m db=mongo,language=go,service=go-demo-2,type=backend
go-demo-2-lt4qm 2/2   Running 0        24m db=mongo,language=go,service=go-demo-2,type=backend
go-demo-2-md5xp 2/2   Running 0        28m db=mongo,language=go,type=backend
go-demo-2-nrnbh 2/2   Running 0        4m  db=mongo,language=go,service=go-demo-2,type=backend
go-demo-2-t8sfs 2/2   Running 0        15m db=mongo,language=go,service=go-demo-2,type=backend 

Pods 的总数增加到了五个。当我们从一个 Pod 中移除了service标签时,ReplicaSet 发现与selector标签匹配的 Pods 数量为三,并创建了一个新的 Pod。现在,我们有四个由 ReplicaSet 控制的 Pods 和一个由于不匹配标签而自由运行的 Pod。

如果我们添加了我们移除的标签会发生什么?

kubectl label $POD_NAME service=go-demo-2

kubectl get pods --show-labels  

我们添加了service=go-demo-2标签并列出了所有的 Pods。

后一命令的输出如下:

NAME            READY STATUS      RESTARTS AGE LABELS
go-demo-2-ckmtv 2/2   Running     0        28m db=mongo,language=go,service=go-demo-2,type=backend
go-demo-2-lt4qm 2/2   Running     0        28m db=mongo,language=go,service=go-demo-2,type=backend
go-demo-2-md5xp 2/2   Running     0        31m db=mongo,language=go,service=go-demo-2,type=backend
go-demo-2-nrnbh 0/2   Terminating 0        7m  db=mongo,language=go,service=go-demo-2,type=backend
go-demo-2-t8sfs 2/2   Running     0        18m db=mongo,language=go,service=go-demo-2,type=backend

在我们添加标签的那一刻,ReplicaSet 发现有五个具有匹配选择器标签的 Pods。由于规范规定应该有四个 Pod 的副本,它移除了一个 Pod,以使期望状态与实际状态匹配。

前几个示例再次表明,ReplicaSets 和 Pods 通过匹配标签松散耦合,并且 ReplicaSets 使用这些标签来维护实际状态和期望状态之间的一致性。到目前为止,自愈工作如预期般正常。

现在怎么办?

好消息是 ReplicaSets 相对直接。它们保证 Pod 的指定数量副本将在系统中运行,只要有可用资源。这是其主要且可以说是唯一的目的。

不好的消息是 ReplicaSets 很少单独使用。你几乎不会直接创建 ReplicaSet 就像你不会创建 Pods 一样。相反,我们倾向于通过 Deployments 创建 ReplicaSets。换句话说,我们使用 ReplicaSets 来创建和控制 Pods,使用 Deployments 来创建 ReplicaSets(以及其他一些内容)。我们很快就会介绍 Deployment。现在,请删除你的本地 Minikube 集群。下一章将从头开始。

minikube delete  

如果您想了解更多关于 ReplicaSets 的信息,请查看 ReplicaSet v1 apps (v1-9.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.9/#replicaset-v1-apps) API 文档。

图 4-4:迄今为止探索的组件

第五章:使用服务启用 Pods 之间的通信

不能相互通信或无法对最终用户访问的应用程序毫无价值。只有在建立了通信路径后,应用程序才能履行其角色。

Pods 是 Kubernetes 中最小的单位,生命周期相对较短。它们被创建,然后被销毁。它们从不被修复。系统通过创建新的 Pods(单元)和销毁那些不健康或多余的 Pods 来进行自我修复。系统是长寿的,而 Pods 不是。

控制器与调度器等其他组件一起,确保 Pods 执行正确的操作。它们控制调度器。到目前为止,我们只使用了其中一个。ReplicaSet 负责确保所需数量的 Pods 始终在运行。如果数量不足,它会创建新的 Pods;如果数量过多,它会销毁一些 Pods。那些变得不健康的 Pods 也会被终止。所有这些,甚至更多,都由 ReplicaSet 控制。

我们当前设置的问题在于没有通信路径。我们的 Pods 不能相互通信。到目前为止,只有同一 Pod 内的容器能够通过localhost相互通信。这导致我们设计时将 API 和数据库都放入同一个 Pod 内。这是一个糟糕的解决方案,原因有很多。主要问题在于我们不能在没有彼此的情况下扩展其中一个服务。我们无法设计出这样的设置,例如有三个 API 副本和一个数据库副本。主要的障碍就是通信。

说实话,每个 Pod 确实会有自己的地址。我们本可以将 API 和数据库拆分到不同的 Pods 中,并配置 API Pods 通过所在 Pod 的地址与数据库通信。然而,由于 Pods 不可靠、生命周期短暂且具有不稳定性,我们不能假设数据库总是可以通过 Pod 的 IP 访问。当该 Pod 被销毁(或失败)时,ReplicaSet 会创建一个新的 Pod,并为其分配一个新的地址。我们需要一个稳定的、永不改变的地址,用于将请求转发到当前正在运行的 Pod。

Kubernetes 服务提供了通过地址访问关联 Pods 的方式。

让我们看看服务的实际应用。

创建集群

你知道的,按照惯例,每一章开始时,我们都会从 vfarcic/k8s-specs (github.com/vfarcic/k8s-specs) 仓库拉取最新代码,并创建一个新的 Minikube 集群。

本章中的所有命令都可以在 05-svc.sh (github.com/vfarcic/k8s-specs) Gist 中找到。

cd k8s-specs

git pull

minikube start --vm-driver=virtualbox

kubectl config current-context  

现在我们已经拉取了最新代码,并且 Minikube 集群正在运行(再次启动)。

我们可以开始第一个 Service 示例了。

通过暴露端口创建服务

在深入了解服务之前,我们应该创建一个与上一章中使用的类似的 ReplicaSet。它将提供我们可以用来演示服务工作原理的 Pods。

让我们快速浏览一下 ReplicaSet 的定义:

cat svc/go-demo-2-rs.yml  

唯一显著的区别是db容器的定义。具体如下。

...
- name: db
 image: mongo:3.3
 command: ["mongod"]
 args: ["--rest", "--httpinterface"]
 ports:
 - containerPort: 28017
 protocol: TCP
...  

我们自定义了命令和参数,以便 MongoDB 可以暴露 REST 接口。我们还定义了containerPort。这些附加配置是必要的,以便我们能够测试数据库是否可以通过服务访问。

让我们创建 ReplicaSet:

kubectl create -f svc/go-demo-2-rs.yml

kubectl get -f svc/go-demo-2-rs.yml  

我们创建了 ReplicaSet 并从 Kubernetes 中获取了其状态。输出如下:

NAME      DESIRED CURRENT READY AGE
go-demo-2 2       2       2     1m  

你可能需要等待直到两个副本都启动并运行。如果在你的情况下,READY列还没有显示2,请稍等片刻并再次执行get命令查看状态。等到两个副本都运行时,我们就可以继续操作了。

我们可以使用kubectl expose命令将资源暴露为新的 Kubernetes 服务。该资源可以是 Deployment、另一个 Service、ReplicaSet、ReplicationController 或 Pod。我们将暴露 ReplicaSet,因为它已经在集群中运行。

kubectl expose rs go-demo-2 \
 --name=go-demo-2-svc \
 --target-port=28017 \
 --type=NodePort  

我们指定了要暴露一个 ReplicaSet(rs),并且新服务的名称应为go-demo-2-svc。应暴露的端口是28017(MongoDB 接口正在监听的端口)。最后,我们指定服务的类型应为NodePort。因此,目标端口将在集群的每个节点上暴露到外部,并会路由到 ReplicaSet 控制的某个 Pod。

我们还可以使用其他类型的服务。

ClusterIP(默认类型)仅在集群内部暴露端口。此端口无法从外部任何地方访问。当我们希望启用 Pod 之间的通信,同时又不希望任何外部访问时,ClusterIP非常有用。如果使用NodePort,则会自动创建ClusterIPLoadBalancer类型只有与云提供商的负载均衡器结合使用时才有意义。ExternalName将服务映射到外部地址(例如,kubernetes.io)。

本章我们将重点介绍NodePortClusterIP类型。LoadBalancer将等到我们将集群迁移到云提供商后才会使用,而ExternalName的使用非常有限。

创建服务时启动的进程如下:

  1. Kubernetes 客户端(kubectl)向 API 服务器发送请求,请求基于通过go-demo-2 ReplicaSet 创建的 Pods 来创建服务。

  2. Endpoint 控制器正在监视 API 服务器上的新服务事件。它检测到有一个新的服务对象。

  3. Endpoint 控制器创建了与服务同名的端点对象,并使用服务选择器来识别端点(在此例中是go-demo-2 Pod 的 IP 和端口)。

  4. kube-proxy 正在监视 Service 和 endpoint 对象。它检测到有一个新的 Service 和一个新的 endpoint 对象。

  5. kube-proxy 添加了 iptables 规则,捕获流量到 Service 端口并将其重定向到端点。对于每个端点对象,它会添加 iptables 规则来选择一个 Pod。

  6. kube-dns 插件正在监视 Service。它检测到有一个新的 Service。

  7. kube-dns 将 db 容器的记录添加到 DNS 服务器(skydns)。

图 5-1:请求创建 Service 后的事件顺序

我们描述的这个过程对于我们想要理解从请求创建新 Service 开始,到集群中发生的每一件事是非常有用的。然而,这个过程可能会显得过于混乱,因此我们将尝试通过一个更能代表集群的图表来解释相同的过程。

图 5-2:请求创建 Service 时 Kubernetes 组件的视图

让我们来看一下我们的新 Service。

kubectl describe svc go-demo-2-svc  

输出如下:

Name:                    go-demo-2-svc
Namespace:               default
Labels:                  db=mongo
 language=go
 service=go-demo-2
 type=backend
Annotations:             <none>
Selector:                service=go-demo-2,type=backend
Type:                    NodePort
IP:                      10.0.0.194
Port:                    <unset>  28017/TCP
TargetPort:              28017/TCP
NodePort:                 <unset>  31879/TCP
Endpoints:               172.17.0.4:28017,172.17.0.5:28017
Session Affinity:        None
External Traffic Policy: Cluster
Events:                  <none>  

我们可以看到名称和命名空间。我们还没有探索命名空间(稍后会介绍),由于我们没有指定任何命名空间,它被设置为 default。由于 Service 与通过 ReplicaSet 创建的 Pods 关联,因此它继承了所有这些 Pod 的标签。选择器与 ReplicaSet 中的选择器匹配。Service 并未直接与 ReplicaSet(或任何其他控制器)关联,而是通过匹配标签与 Pods 关联。

接下来是 NodePort 类型,它将端口暴露给所有节点。由于 NodePort 自动创建了 ClusterIP 类型,因此集群中的所有 Pods 都可以访问 TargetPortPort 设置为 28017。这是 Pods 用来访问 Service 的端口。由于我们在执行命令时没有显式指定该端口,其值与 TargetPort 的值相同,即与 Pod 关联的端口,该端口将接收所有请求。NodePort 是自动生成的,因为我们没有显式设置它。它是我们可以用来从集群外部访问 Service 以及 Pods 的端口。在大多数情况下,它应该是随机生成的,这样可以避免端口冲突。

让我们看看 Service 是否真的有效:

PORT=$(kubectl get svc go-demo-2-svc \
 -o jsonpath="{.spec.ports[0].nodePort}")

IP=$(minikube ip)

open "http://$IP:$PORT"  

给 Windows 用户的提示

Git Bash 可能无法使用 open 命令。如果是这种情况,将 open 命令替换为 echo。这样,你将获得一个完整的地址,应该在你选择的浏览器中直接打开。

我们使用了 kubectl get 命令的过滤输出来检索 nodePort 并将其存储为环境变量 PORT。接下来,我们获取了 minikube VM 的 IP 地址。最后,我们通过 Service 端口在浏览器中打开了 MongoDB UI。

图 5-3:通过暴露 ReplicaSet 创建的 Service

正如我在前面章节中提到的,除非我们正在尝试一些快速的黑客操作,否则使用命令式命令创建 Kubernetes 对象并不是一个好主意。服务也是如此。尽管 kubectl expose 完成了工作,但我们应该尝试通过 YAML 文件使用一种文档化的方法。从这个角度出发,我们将销毁已创建的服务并重新开始。

kubectl delete svc go-demo-2-svc  

通过声明式语法创建服务

我们可以通过 svc/go-demo-2-svc.yml 规范实现与使用 kubectl expose 相似的结果。

cat svc/go-demo-2-svc.yml  

输出如下:

apiVersion: v1
kind: Service
metadata:
 name: go-demo-2
spec:
 type: NodePort
 ports:
 - port: 28017
 nodePort: 30001
 protocol: TCP
 selector:
 type: backend
 service: go-demo-2  

你应该熟悉 apiVersionkindmetadata 的含义,所以我们将直接跳到 spec 部分。既然我们已经通过 kubectl expose 命令探索过一些选项,spec 部分应该比较容易理解。

服务的类型设置为 NodePort,这意味着这些端口将同时在集群内和外部通过向任何节点发送请求的方式可用。

ports 部分指定请求应该转发到端口为 28017 的 Pods。nodePort 是新的。我们没有让服务暴露一个随机端口,而是将其设置为明确的 30001 值。尽管在大多数情况下,这不是一个好的实践,但我觉得演示这个选项也是个不错的主意。协议设置为 TCP。唯一的其他选择是使用 UDP。我们本可以完全跳过协议部分,因为 TCP 是默认值,但有时候,留下一些选项作为提醒是个好主意。

selector 被服务用于确定哪些 Pods 应该接收请求。它的工作方式与 ReplicaSet 的选择器相同。在这种情况下,我们定义了服务应将请求转发到 type 设置为 backendservice 设置为 go-demo 的 Pods。这两个标签在 ReplicaSet 的 Pods spec 中设置。

既然定义中没有什么神秘的内容,我们可以继续并创建服务。

kubectl create -f svc/go-demo-2-svc.yml

kubectl get -f svc/go-demo-2-svc.yml  

我们创建了服务并从 API 服务器获取了其信息。后一条命令的输出如下:

NAME      TYPE     CLUSTER-IP EXTERNAL-IP PORT(S)         AGE
go-demo-2 NodePort 10.0.0.129 <none>      28017:30001/TCP 10m  

现在服务已经运行(再次),我们可以通过尝试访问 MongoDB UI 来再次确认它是否按预期工作。

open "http://$IP:30001"  

由于我们将 nodePort 固定为 30001,因此无需从 API 服务器获取端口。相反,我们使用了 Minikube 节点的 IP 和硬编码的端口 30001 来打开 UI。

图 5-4:具有匹配 Pods 和硬编码端口的服务

让我们看看端点。它包含了应该接收请求的 Pod 列表。

kubectl get ep go-demo-2 -o yaml  

输出如下:

apiVersion: v1
kind: Endpoints
metadata:
 creationTimestamp: 2017-12-12T16:00:51Z
 name: go-demo-2
 namespace: default
 resourceVersion: "5196"
 selfLink: /api/v1/namespaces/default/endpoints/go-demo-2
 uid: a028b9a7-df55-11e7-a8ef-080027d94e34
subsets:
- addresses:
 - ip: 172.17.0.4
 nodeName: minikube
 targetRef:
 kind: Pod
 name: go-demo-2-j8kdw
 namespace: default
 resourceVersion: "5194"
 uid: ac70f868-df4d-11e7-a8ef-080027d94e34
 - ip: 172.17.0.5
 nodeName: minikube
 targetRef:
 kind: Pod
 name: go-demo-2-5vlcc
 namespace: default
 resourceVersion: "5184"
 uid: ac7214d9-df4d-11e7-a8ef-080027d94e34
 ports:
 - port: 28017
 protocol: TCP  

我们可以看到有两个子集,分别对应于包含与 Service selector 相同标签的两个 Pods。每个 Pod 都有一个唯一的 IP 地址,并会在转发请求时包含在算法中。实际上,这并不算复杂的算法。请求将随机发送到这些 Pods。这种随机性产生了类似于轮询负载均衡的效果。如果 Pods 数量不变,每个 Pod 会接收大致相等的请求数量。

对于大多数使用场景,随机请求转发应该足够。如果不够,我们将不得不借助第三方解决方案(暂时如此)。然而,当 Kubernetes 1.9 发布时,我们将有一个替代 iptables 解决方案。我们将能够应用不同类型的负载均衡算法,如最后连接、目标哈希、新队列等。不过,目前的解决方案仍基于 iptables,我们暂时保持这个方案。

在这本书中,到目前为止,我已经提到过几次我们当前的 Pod 设计有缺陷。我们有两个容器(一个 API 和一个数据库)打包在一起。除此之外,它还阻止了我们对其中一个进行扩展而不影响另一个。现在,我们已经学会了如何使用 Services,我们可以重新设计我们的 Pod 解决方案。

在继续之前,我们将删除我们创建的 Service 和 ReplicaSet:

kubectl delete -f svc/go-demo-2-svc.yml

kubectl delete -f svc/go-demo-2-rs.yml  

ReplicaSet 和 Service 都已删除,我们可以重新开始。

分割 Pod 并通过 Services 建立通信

让我们看一下一个仅包含数据库的 Pod 的 ReplicaSet 定义:

cat svc/go-demo-2-db-rs.yml  

输出如下:

apiVersion: apps/v1beta2
kind: ReplicaSet
metadata:
 name: go-demo-2-db
spec:
 selector:
 matchLabels:
 type: db
 service: go-demo-2
 template:
 metadata:
 labels:
 type: db
 service: go-demo-2
 vendor: MongoLabs
 spec:
 containers:
 - name: db
 image: mongo:3.3
 ports:
 - containerPort: 28017  

我们只对发生变化的部分进行评论。

由于这个 ReplicaSet 仅定义了数据库,我们将副本数减少到 1。说实话,MongoDB 也应该进行扩展,但这超出了本章(甚至可能是本书)的范围。现在,我们假设一个数据库副本就足够了。

由于 selector 标签需要唯一,我们稍微更改了一下它们。service 仍然是 go-demo-2,但 type 已更改为 db

其余的定义相同,只是 containers 现在只包含 mongo。我们将在一个单独的 ReplicaSet 中定义 API。

在我们转到引用其 Pod 的 Service 之前,先创建 ReplicaSet。

kubectl create \
 -f svc/go-demo-2-db-rs.yml  

已创建一个对象,剩下三个。

下一个是我们刚通过 ReplicaSet 创建的 Pod 的 Service。

cat svc/go-demo-2-db-svc.yml  

输出如下:

apiVersion: v1
kind: Service
metadata:
 name: go-demo-2-db
spec:
 ports:
 - port: 27017
 selector:
 type: db
 service: go-demo-2  

这个 Service 定义没有包含任何新内容。没有 type,所以它将默认使用 ClusterIP。由于没有理由让集群外部的任何人访问数据库,因此无需使用 NodePort 类型暴露它。我们还跳过了指定 nodePort,因为只允许集群内部的通信。protocol 也是如此。TCP 就足够了,它恰好是默认协议。最后,selector 标签与定义 Pod 的标签相同。

让我们来创建 Service:

kubectl create \
 -f svc/go-demo-2-db-svc.yml  

数据库部分已经完成。副本集将确保 Pod(几乎)始终运行,而服务将允许其他 Pods 通过固定的 DNS 与其通信。

继续处理后端 API...

cat svc/go-demo-2-api-rs.yml  

输出如下:

apiVersion: apps/v1beta2
kind: ReplicaSet
metadata:
 name: go-demo-2-api
spec:
 replicas: 3
 selector:
 matchLabels:
 type: api
 service: go-demo-2
 template:
 metadata:
 labels:
 type: api
 service: go-demo-2
 language: go
 spec:
 containers:
 - name: api
 image: vfarcic/go-demo-2
 env:
 - name: DB
 value: go-demo-2-db
 readinessProbe:
 httpGet:
 path: /demo/hello
 port: 8080
 periodSeconds: 1
 livenessProbe:
 httpGet:
 path: /demo/hello
 port: 8080

和数据库一样,这个副本集应该很熟悉,因为它与我们之前使用的副本集非常相似。我们只会评论其中的差异。

replicas的数量设置为3。这解决了我们在之前的副本集中遇到的一个主要问题,即定义了包含两个容器的 Pod。现在副本数量可以不同,我们有一个数据库 Pod,三个后端 API Pod。

type标签设置为api,以便副本集和(即将到来的)服务可以区分这些 Pod 与为数据库创建的 Pod。

我们设置了环境变量DB,其值为go-demo-2-dbvfarcic/go-demo-2镜像中的代码通过读取该变量来建立与数据库的连接。在这种情况下,我们可以说它会尝试连接到运行在 DNS go-demo-2-db上的数据库。如果你返回查看数据库服务定义,你会注意到它的名称也是go-demo-2-db。如果一切正常,我们应该期待 DNS 已经与服务一起创建,并且会将请求转发到数据库。

在早期的 Kubernetes 版本中,使用的是userspace代理模式。它的优点是代理会将失败的请求重试到另一个 Pod。随着转向iptables模式,这一特性丧失了。然而,iptables更快、更可靠,因此丧失重试机制的缺失得到了很好的弥补。这并不意味着请求会“盲目地”发送到 Pods。缺少重试机制通过我们为副本集添加的readinessProbe得到了缓解。

readinessProbelivenessProbe有相同的字段。我们对两者使用了相同的值,除了periodSeconds,我们将其从默认的10设置为1livenessProbe用于确定一个 Pod 是否存活,或者是否应该被新 Pod 替代,而readinessProbe则由iptables使用。未通过readinessProbe的 Pod 将被排除,并且不会接收请求。从理论上讲,请求可能仍然会在两次探测之间发送到故障 Pod,但由于iptables会在下一次探测响应 HTTP 代码小于200或大于等于400时立即更改,因此这种情况发生的请求数量会很少。

理想情况下,应用程序会为readinessProbelivenessProbe设置不同的端点。但这个没有,因此相同的端点就足够了。你可以怪我太懒,没去添加它们。

让我们创建副本集。

kubectl create \
 -f svc/go-demo-2-api-rs.yml  

只缺少一个对象,那就是服务:

cat svc/go-demo-2-api-svc.yml  

输出如下:

apiVersion: v1
kind: Service
metadata:
 name: go-demo-2-api
spec:
 type: NodePort
 ports:
 - port: 8080
 selector:
 type: api
 service: go-demo-2 

这个定义中没有什么新的内容。type设置为NodePort,因为 API 应该可以从集群外部访问。selector标签type设置为api,以便它与 Pod 中定义的标签匹配。

这是我们将要创建的最后一个对象(在本节中),所以让我们继续并进行创建:

kubectl create \
 -f svc/go-demo-2-api-svc.yml  

我们来看看集群中有什么内容:

kubectl get all  

输出如下:

NAME             DESIRED CURRENT READY AGE
rs/go-demo-2-api 3       3       3     18m
rs/go-demo-2-db  1       1       1     48m
rs/go-demo-2-api 3       3       3     18m
rs/go-demo-2-db  1       1       1     48m    
NAME                   READY STATUS  RESTARTS AGE
po/go-demo-2-api-6brtz 1/1   Running 0        18m
po/go-demo-2-api-fj9mg 1/1   Running 0        18m
po/go-demo-2-api-vrcxh 1/1   Running 0        18m
po/go-demo-2-db-qcftz  1/1   Running 0        48m  
NAME              TYPE      CLUSTER-IP EXTERNAL-IP PORT(S)        
AGE
svc/go-demo-2-api NodePort  10.0.0.162 <none>      8080:31256/TCP 
2m
svc/go-demo-2-db  ClusterIP 10.0.0.19  <none>      27017/TCP      
48m
svc/kubernetes    ClusterIP 10.0.0.1   <none>      443/TCP        
1h  

db和 api 的两个 ReplicaSets 都在其中,接着是go-demo-2-api Pod 的三个副本和go-demo-2-db Pod 的一个副本。最后,两个服务也在运行,包括 Kubernetes 自身创建的那个。

我不确定为什么在这个视图中会出现重复的 ReplicaSets。我的最佳猜测是这可能是一个 bug,应该很快就会被修复。说实话,我没有花时间去调查这个问题,因为它不会影响集群和 ReplicaSets 的工作。如果你执行kubectl get rs,你会看到只有两个 ReplicaSets,而不是四个。

在继续之前,可能值得一提的是,vfarcic/go-demo-2镜像背后的代码设计是,如果它无法连接到数据库,它将失败。go-demo-2-api Pod 的三个副本能够运行,意味着通信已经建立。剩下的唯一验证就是检查我们是否能从集群外部访问 API。让我们试试看。

PORT=$(kubectl get svc go-demo-2-api \
 -o jsonpath="{.spec.ports[0].nodePort}")

curl -i "http://$IP:$PORT/demo/hello"  

我们获取了服务的端口(我们仍然保留之前的 Minikube 节点IP)并使用它发送了请求。最后一条命令的输出如下:

HTTP/1.1 200 OK
Date: Tue, 12 Dec 2017 21:27:51 GMT
Content-Length: 14
Content-Type: text/plain; charset=utf-8

hello, world!  

我们得到了响应200,以及一个友好的hello, world!消息,表明 API 确实可以从集群外部访问。

此时,你可能会想,是否需要为一个应用程序准备四个 YAML 文件。我们能不能简化这些定义?实际上不能。我们能否将所有内容定义在一个文件中?继续往下看。

在继续之前,我们将删除我们创建的对象。到现在为止,你可能已经注意到,我喜欢摧毁现有的东西然后重新开始。请耐心一点,摧毁是有充分理由的:

kubectl delete -f svc/go-demo-2-db-rs.yml

kubectl delete -f svc/go-demo-2-db-svc.yml

kubectl delete -f svc/go-demo-2-api-rs.yml

kubectl delete -f svc/go-demo-2-api-svc.yml  

我们创建的所有内容都已删除,我们可以重新开始。

在同一个 YAML 文件中定义多个对象

vfarcic/go-demo-2mongo镜像构成了同一个堆栈。它们一起工作,拥有四个 YAML 定义很让人困惑。以后会更复杂,因为我们将会向堆栈中添加更多对象。如果我们把迄今为止创建的所有对象移到一个单一的 YAML 定义中,事情会变得更简单,容易得多。幸运的是,这很容易实现。

让我们再看看另一个 YAML 文件:

cat svc/go-demo-2.yml  

我们不展示输出,因为它与前四个 YAML 文件的内容相同。唯一的不同是每个对象的定义之间用三个破折号(---)分隔。

如果你像我一样偏执,你会想再次确认一切是否按预期工作,所以让我们创建那个文件中定义的对象:

kubectl create -f svc/go-demo-2.yml

kubectl get -f svc/go-demo-2.yml  

后者命令的输出如下:

NAME            DESIRED CURRENT READY AGE
rs/go-demo-2-db 1       1       1     1m

NAME             TYPE      CLUSTER-IP EXTERNAL-IP PORT(S)   AGE
svc/go-demo-2-db ClusterIP 10.0.0.250 <none>      27017/TCP 1m

NAME             DESIRED CURRENT READY AGE
rs/go-demo-2-api 3       3       3     1m

NAME              TYPE     CLUSTER-IP EXTERNAL-IP PORT(S)        
AGE
svc/go-demo-2-api NodePort 10.0.0.99  <none>      8080:31726/TCP 
1m  

创建了两个 ReplicaSet 和两个服务,我们可以为将四个文件替换为一个而感到高兴。

最后,为了安全起见,我们还将再次检查堆栈 API 是否正在运行并且可访问。

PORT=$(kubectl get svc go-demo-2-api \
 -o jsonpath="{.spec.ports[0].nodePort}")

curl -i "http://$IP:$PORT/demo/hello"  

响应为200,表明一切按预期工作。

在我们结束关于服务的讨论之前,我们可能希望了解一下发现过程。

服务发现

服务可以通过两种主要模式进行发现:环境变量和 DNS。

每个 Pod 都获取与每个活动服务相关的环境变量。它们的格式与 Docker 链接期望的格式相同,也具有更简单的 Kubernetes 特定语法。

让我们看一下我们正在运行的其中一个 Pod 中的环境变量。

POD_NAME=$(kubectl get pod \
 --no-headers \
 -o=custom-columns=NAME:.metadata.name \
 -l type=api,service=go-demo-2 \
 | tail -1)

kubectl exec $POD_NAME env  

输出仅限于与go-demo-2-db服务相关的环境变量,如下所示:

GO_DEMO_2_DB_PORT=tcp://10.0.0.250:27017
GO_DEMO_2_DB_PORT_27017_TCP_ADDR=10.0.0.250
GO_DEMO_2_DB_PORT_27017_TCP_PROTO=tcp
GO_DEMO_2_DB_PORT_27017_TCP_PORT=27017
GO_DEMO_2_DB_PORT_27017_TCP=tcp://10.0.0.250:27017
GO_DEMO_2_DB_SERVICE_HOST=10.0.0.250
GO_DEMO_2_DB_SERVICE_PORT=27017  

前五个变量采用 Docker 格式。如果你曾经使用过 Docker 网络,你应该对它们很熟悉。至少,如果你熟悉 Swarm(独立)和 Docker Compose 的工作方式的话。Swarm(模式)的后续版本仍然会生成这些环境变量,但用户大多已放弃使用它们,转而使用 DNS。

最后两个环境变量是 Kubernetes 特定的,遵循[SERVICE_NAME]_SERVICE_HOST[SERVICE_NAME]_SERVICE_PORT格式(服务名称为大写)。

无论你选择使用哪一组环境变量(如果有的话),它们的目的都是一样的。它们提供了一个可以用来连接到服务,从而连接到相关 Pod 的参考。

当我们描述go-demo-2-db服务时,事情会变得更加清晰。

kubectl describe svc go-demo-2-db  

输出如下:

Name:              go-demo-2-db
Namespace:         default
Labels:            <none>
Annotations:       <none>
Selector:          service=go-demo-2,type=db
Type:              ClusterIP
IP:                10.0.0.250
Port:              <unset>  27017/TCP
TargetPort:        27017/TCP
Endpoints:         172.17.0.4:27017
Session Affinity:  None
Events:            <none>

关键在于IP字段。那是可以访问此服务的 IP,它与环境变量GO_DEMO_2_DB_*GO_DEMO_2_DB_SERVICE_HOST的值匹配。

形成go-demo-2-api Pods 的容器中的代码可以使用任何这些环境变量来构造与go-demo-2-db Pods 的连接字符串。例如,我们本可以使用GO_DEMO_2_DB_SERVICE_HOST来连接到数据库。然而,我们并没有这样做。原因很简单,使用 DNS 更为便捷。

让我们再看一眼来自go-demo-2-api-rs.ymlReplicaSet定义片段:

cat svc/go-demo-2-api-rs.yml
...
env:
- name: DB
 value: go-demo-2-db
...  

我们声明了一个名为服务名称(go-demo-2-db)的环境变量。代码使用该变量作为连接数据库的连接字符串。Kubernetes 将服务名称转换为 DNS,并将其添加到 DNS 服务器中。它是一个由 Minikube 已设置的集群插件。

让我们回顾一下与服务发现相关的事件顺序和涉及的组件:

  1. api 容器 go-demo-2 尝试连接到 go-demo-2-db 服务时,它会查看 /etc/resolv.conf 中配置的名称服务器。kubelet 在 Pod 调度过程中将名称服务器配置为 kube-dns 服务的 IP(10.96.0.10)。

  2. 容器查询监听在端口 53 的 DNS 服务器。go-demo-2-db 的 DNS 被解析为服务 IP 10.0.0.19。这个 DNS 记录是在服务创建过程中由 kube-dns 添加的。

  3. 容器使用服务 IP,它通过 iptables 规则转发请求。iptables 规则是在服务和端点创建过程中由 kube-proxy 添加的。

  4. 由于我们只有一个 go-demo-2-db Pod 的副本,iptables 只会将请求转发到一个端点。如果我们有多个副本,iptables 会充当负载均衡器,将请求随机转发到服务的多个端点。

图 5-5:服务发现过程及涉及的组件

接下来做什么?

就这些了。我们已经讲解了服务的最重要方面。还有一些我们尚未探索的情况,但目前的知识应该足够让你继续前进。

服务是不可或缺的对象,没有它们,Pod 之间的通信将变得困难且不稳定。它们提供静态地址,通过这些地址我们不仅可以从其他 Pod 访问它们,还可以从集群外部访问它们。拥有固定的入口点至关重要,因为它为本来动态的集群元素提供了稳定性。Pod 会来来去去,而服务则一直存在。

我们只差一个关键的主题,就能拥有一个功能完整、但仍然简单的应用部署和管理策略。我们还没有探索如何在没有停机的情况下部署和更新服务。

我们已经讨论完这个话题,接下来是时候销毁我们迄今为止所做的一切了。

minikube delete  

如果你想了解更多关于服务的信息,请查看 Service v1 核心 (v1-9.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.9/#service-v1-core) API 文档。

图 5-6:迄今为止探索的组件

Kubernetes Pods、ReplicaSets 和 Services 与 Docker Swarm 堆栈的对比

从本章开始,我们将把每个 Kubernetes 功能与 Docker Swarm 对应功能进行对比。这样,Swarm 用户可以更平滑地过渡到 Kubernetes,或者根据他们的目标,选择继续使用 Swarm。

请记住,比较仅会基于特定的功能集进行。你(目前)无法得出 Kubernetes 是否优于 Docker Swarm 的结论。你需要全面掌握这两款产品,才能做出明智的决定。像接下来的比较,只有在对两款产品进行更详细的审视时,才会有用。

目前,我们将比较范围限制为 Pod、ReplicaSet 和 Service 一方面,以及 Docker Service 堆栈另一方面。

让我们从 Kubernetes 文件go-demo-2.ymlgithub.com/vfarcic/k8s-specs/blob/master/svc/go-demo-2.yml)开始(这是我们之前使用过的同一个文件)。

定义如下:

apiVersion: apps/v1beta2
kind: ReplicaSet
metadata:
 name: go-demo-2-db
spec:
 selector:
 matchLabels:
 type: db
 service: go-demo-2
 template:
 metadata:
 labels:
 type: db
 service: go-demo-2
 spec:
 containers:
 - name: db
 image: mongo:3.3
 ports:
 - containerPort: 28017

---

apiVersion: v1
kind: Service
metadata:
 name: go-demo-2-db
spec:
 ports:
 - port: 27017
 selector:
 type: db
 service: go-demo-2

---

apiVersion: apps/v1beta2
kind: ReplicaSet
metadata:
 name: go-demo-2-api
spec:
 replicas: 3
 selector:
 matchLabels:
 type: api
 service: go-demo-2
 template:
 metadata:
 labels:
 type: api
 service: go-demo-2
 spec:
 containers:
 - name: api
 image: vfarcic/go-demo-2
 env:
 - name: DB
 value: go-demo-2-db
 readinessProbe:
 httpGet:
 path: /demo/hello
 port: 8080
 periodSeconds: 1
 livenessProbe:
 httpGet:
 path: /demo/hello
 port: 8080

---

apiVersion: v1
kind: Service
metadata:
 name: go-demo-2-api
spec:
 type: NodePort
 ports:
 - port: 8080
 selector:
 type: api
 service: go-demo-2  

现在,让我们来看一下在go-demo-2-swarm.yml中定义的 Docker 堆栈(github.com/vfarcic/k8s-specs/blob/master/svc/go-demo-2-swarm.yml)。

规范如下:

version: "3"
services:
 api:
 image: vfarcic/go-demo-2
 environment:
 - DB=db
 ports:
 - 8080
 deploy:
 replicas: 3
 db:
 image: mongo  

两种定义实现了相同的结果。从功能角度来看,没有重要区别,唯一的区别是 Pods。Docker 没有创建类似的选项。当 Swarm 服务被创建时,它们会分布在集群中,并且没有简单的方法来指定多个容器应运行在同一节点上。是否需要多容器 Pod,这是我们稍后探讨的内容。现在,我们暂时忽略这个功能。

如果我们执行类似于docker stack deploy -c svc/go-demo-2-swarm.yml go-demo-2的命令,结果将与我们运行kubectl create -f svc/go-demo-2.yml时的结果相同。在这两种情况下,我们都会得到三个vfarcic/go-demo-2副本,以及一个mongo副本。各自的调度器确保期望状态(几乎)始终与实际状态匹配。通过内部 DNS 进行的网络通信在这两种解决方案中都已建立。集群中的每个节点都会暴露一个随机定义的端口,将请求转发到api。总的来说,这两种解决方案在功能上没有区别。

在服务定义的方式上,确实存在相当大的差异。Docker 的堆栈定义更加紧凑、直接。我们在 12 行中定义了 Kubernetes 格式中约 80 行所需要的内容。

有人可能会争辩说 Kubernetes 的 YAML 文件可以更小,也许可以。但无论如何简化,它仍然会更大、更复杂。也有人可能会说,Docker 的堆栈缺少readinessProbelivenessProbe。是的,确实如此,这也是因为我决定不把它们放在那里,因为vfarcic/go-demo-2镜像已经有了 Docker 用于类似目的的HEALTHCHECK定义。在大多数情况下,Dockerfile 是定义健康检查的更好地方,而不是堆栈定义。这并不意味着它不能在 YAML 文件中设置或覆盖。如果需要,它是可以的。但在这个例子中,并不需要。

总的来说,如果我们仅限于 Kubernetes 的 Pods、ReplicaSets 和 Services,以及它们在 Docker Swarm 中的等效项,由于 Docker Swarm 定义规范的方式更加简单直接,后者更具优势。从功能角度来看,两者非常相似。

你应该得出 Swarm 比 Kubernetes 更好的结论吗?完全不应如此。至少,在我们比较其他特性之前,不应该这么认为。Swarm 赢得了这场战斗,但战争才刚刚开始。随着我们的深入,你会发现 Kubernetes 还有更多值得探索的内容。我们只是触及了皮毛。

第六章:零停机发布部署

如果我们要在竞争中生存下去,我们必须在功能开发和测试完成后尽快将其发布到生产环境。频繁发布的需求进一步强调了零停机部署的重要性。

我们已经学会了如何部署打包为 Pod 的应用程序,如何通过 ReplicaSets 扩展它们,以及如何通过 Services 启用通信。然而,如果我们无法用新版本更新这些应用程序,那一切都是无用的。Kubernetes 部署在这一点上非常有用。

我们的应用程序的期望状态始终在变化。导致新状态的最常见原因是新的发布版本。这个过程相对简单。我们进行更改并将其提交到代码仓库,然后进行构建和测试。一旦我们确认它按预期工作,我们就将其部署到集群中。无论该部署是针对开发、测试、预生产还是生产环境,我们都需要将新版本部署到集群中,即使这个集群只是运行在笔记本电脑上的单节点 Kubernetes。不论我们有多少个环境,过程应该始终保持相同,或者至少尽可能相似。

部署必须确保无停机。无论是在测试集群还是生产集群上执行,都不应中断消费者的服务,这样会导致金钱和对产品的信任损失。过去的时代,用户可能不在乎应用偶尔无法工作,但现在竞争如此激烈,单次糟糕的体验可能会导致用户选择其他解决方案。在今天的规模下,0.1%的请求失败率都被视为灾难性的。虽然我们可能永远无法达到 100%的可用性,但我们至少不应该自己导致停机,并且必须尽量减少其他可能导致停机的因素。

由我们无法控制的外部环境引起的失败,按定义来说是我们无法采取任何措施的事情。然而,由于过时的做法或疏忽导致的失败则是应该避免的失败。Kubernetes 部署为我们提供了必要的工具,使我们能够在不产生停机的情况下更新应用程序,从而避免这些失败。

让我们探讨一下 Kubernetes 部署的工作原理,以及我们通过采用它们获得的好处。

创建一个集群

在每一章开始时创建集群可以让我们直接进入书中的任何部分,而不需要担心是否需要完成前面的章节要求。它还使我们可以在章节之间暂停,而无需让笔记本电脑因运行一个未使用的虚拟机而承受压力。缺点是,这部分是每章最枯燥的部分。因此,今天就讲到这里,让我们尽快完成这项工作。

本章的所有命令都可以在 06-deploy.sh 文件中找到,地址为 gist.github.com/vfarcic/677a0d688f65ceb01e31e33db59a4400

cd k8s-specs

git pull

minikube start --vm-driver=virtualbox

kubectl config current-context  

代码已更新,集群正常运行,我们可以开始探索 Deployments。

部署新版本

就像我们不应该直接创建 Pods,而是使用 ReplicaSet 等其他控制器一样,我们也不应该直接创建 ReplicaSets。Kubernetes Deployments 会为我们创建它们。如果你在想为什么,稍等片刻,我们将揭示答案。首先,我们将创建几个 Deployments,一旦熟悉了这个过程和结果,就会很明显为什么它们在管理 ReplicaSets 方面比我们更有效。

让我们来看一下我们迄今为止使用的数据库 ReplicaSet 的 Deployment 规格。

cat deploy/go-demo-2-db.yml  

输出结果如下:

apiVersion: apps/v1beta2
kind: Deployment
metadata:
 name: go-demo-2-db
spec:
 selector:
 matchLabels:
 type: db
 service: go-demo-2
 template:
 metadata:
 labels:
 type: db
 service: go-demo-2
 vendor: MongoLabs
 spec:
 containers:
 - name: db
 image: mongo:3.3
 ports:
 - containerPort: 28017  

如果你将这个 Deployment 与我们在前一章中创建的 ReplicaSet 进行比较,你可能会很难找到差异。除了kind字段之外,它们是相同的。

由于在这种情况下,Deployment 和 ReplicaSet 是相同的,你可能会想知道使用其中一个而不是另一个的优势是什么。

我们将定期在kubectl create命令中添加--record选项。这样,我们就能跟踪每次对资源(如 Deployments)的更改。

让我们创建 Deployment 并探索它所提供的功能。

kubectl create \
 -f deploy/go-demo-2-db.yml \
 --record

kubectl get -f deploy/go-demo-2-db.yml  

后一个命令的输出如下:

NAME         DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
go-demo-2-db 1       1       1          0         7s  

Deployment 已创建。然而,get命令没有提供太多信息,所以让我们用describe命令查看。

kubectl describe \
 -f deploy/go-demo-2-db.yml  

输出结果(仅限最后几行)如下:

...
Events:
 Type   Reason            Age  From                  Message
 ----   ------            ---- ----                  -------
 Normal ScalingReplicaSet 2m   deployment-controller Scaled up r
eplica set go-demo-2-db-75fbcbb5cd to 1  

Events部分,我们可以看到 Deployment 创建了一个 ReplicaSet。更准确地说,它对其进行了扩展。这很有趣,它显示了 Deployments 控制 ReplicaSets。Deployment 创建了 ReplicaSet,而 ReplicaSet 又创建了 Pods。让我们通过检索所有对象的列表来确认这一点:

kubectl get all  

输出结果如下:

NAME                DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
deploy/go-demo-2-db 1       1       1          1         8m

NAME                       DESIRED CURRENT READY AGE
rs/go-demo-2-db-75fbcbb5cd 1       1       1     8m

NAME                             READY STATUS  RESTARTS AGE
po/go-demo-2-db-75fbcbb5cd-k6tz9 1/1   Running 0        8m

NAME           TYPE      CLUSTER-IP EXTERNAL-IP PORT(S) AGE
svc/kubernetes ClusterIP 10.0.0.1   <none>      443/TCP 14m  

三个对象都已创建,你可能会想,为什么要创建 Deployment 呢?你可能认为如果直接创建 ReplicaSet,结果应该是一样的。你是对的。到目前为止,从功能角度来看,直接创建 ReplicaSet 或通过 Deployment 创建没有什么区别。Deployments 的真正优势在于我们尝试改变它的一些方面时才会显现出来。例如,我们可能会选择将 MongoDB 升级到 3.4 版本。

图 6-1:Deployment 及其级联效应,创建 ReplicaSet,并通过 ReplicaSet 创建 Pods

在继续讨论 Deployment 更新之前,我们将通过一个序列图来回顾我们惯常的流程。我们不会重复解释 ReplicaSet 对象创建后发生的事件,因为这些步骤已经在前面的章节中解释过。

  1. Kubernetes 客户端(kubectl)向 API 服务器发送请求,请求创建deploy/go-demo-2-db.yml文件中定义的 Deployment。

  2. 部署控制器正在监视 API 服务器的新事件,并检测到有新的 Deployment 对象。

  3. 部署控制器创建了一个新的 ReplicaSet 对象

图 6-2:请求创建部署时的事件顺序

更新部署

让我们看看当我们给db Pod 设置新镜像时会发生什么。

kubectl set image \
 -f deploy/go-demo-2-db.yml \
 db=mongo:3.4 \
 --record  

新镜像被拉取需要一些时间,所以你可以去喝杯咖啡。一旦你回来,我们可以通过检查它创建的事件来describe该部署。

kubectl describe \
 -f deploy/go-demo-2-db.yml

输出的最后几行如下:

...
Events:
 Type    Reason             Age   From                   Message
 ----    ------             ----  ----                   -------
 Normal  ScalingReplicaSet  19m   deployment-controller  Scaled 
up replica set go-demo-2-db-75fbcbb5cd to 1
 Normal  ScalingReplicaSet  5m    deployment-controller  Scaled 
up replica set go-demo-2-db-f8d4b86ff to 1
 Normal  ScalingReplicaSet  0s    deployment-controller  Scaled 
down replica set go-demo-2-db-75fbcbb5cd to 0  

我们可以看到它创建了一个新的 ReplicaSet,并将旧的 ReplicaSet 缩放到了0。如果在你的情况中,最后一行没有出现,你需要等到新的 mongo 镜像被拉取完毕。

部署并不是直接在 Pods 层面操作,而是创建了一个新的 ReplicaSet,后者基于新镜像生成 Pods。等它们完全可用后,旧的 ReplicaSet 会被缩放到0。由于我们只运行了一个副本的 ReplicaSet,可能不太清楚为什么采用这种策略。当我们为 API 创建一个部署时,情况会变得更加明确。

为了安全起见,我们可能想从集群中获取所有对象:

kubectl get all  

输出如下:

NAME                  DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   
AGE
deploy/go-demo-2-db   1         1         1            1           
3m

NAME                         DESIRED   CURRENT   READY     AGE
rs/go-demo-2-db-75fbcbb5cd   0         0         0         3m
rs/go-demo-2-db-f8d4b86ff    1         1         1         2m
NAME                              READY     STATUS    RESTARTS   
AGE
po/go-demo-2-db-f8d4b86ff-qvhgg   1/1       Running   0          
2m

NAME             TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   
AGE
svc/kubernetes   ClusterIP   10.0.0.1     <none>        443/TCP   
35m  

如你所见,两个 ReplicaSets 都存在。然而,一个处于非活动状态(已缩放为0)。

你会注意到,Pod 名称中包含了一个与新 ReplicaSet 名称中的哈希值相匹配的哈希值,即 f8d4b86ff。尽管看起来像是一个随机值,但实际上并不是。如果你销毁部署并重新创建它,你会发现 Pod 名称和 ReplicaSet 名称中的哈希值保持一致。这个值是通过对 ReplicaSet 的 PodTemplate 进行哈希生成的。只要 PodTemplate 不变,哈希值也会保持一致。这样,部署就可以知道是否有与 Pods 相关的更改,如果有更改,就会创建一个新的 ReplicaSet。

kubectl set image命令并不是更新部署的唯一方法。我们也可以使用kubectl edit。命令如下。请不要执行它。如果你执行了(违背我的建议),你需要输入:q并按Enter键才能退出。

kubectl edit -f deploy/go-demo-2-db.yml  

我认为上述的edit命令并不是更新定义的好方法。它既不实用,也没有文档说明。如果我们希望将部署更新与 CI/CD 工具之一集成,kubectl set image会更有用。由于接下来会有专门讲解持续部署的章节,我们从现在开始将继续使用kubectl set image

另一种替代方法是更新 YAML 文件并执行kubectl apply命令。虽然这对不常更新的应用程序来说是个好主意,但对于每周、每天甚至每小时都会发生变化的应用程序来说并不适用。

MongoDB 是那些每年可能只更新几次新版本的应用之一,因此在源代码仓库中始终保持最新的 YAML 文件是一种很好的实践。我们使用了kubectl set image,只是为了让你了解接下来我们将要探讨的内容:如何在没有停机的情况下进行频繁的部署。

单纯更新 Pod 镜像远远无法展示 Deployment 的真正优势。为了看到其真正的力量,我们应该部署 API。由于它可以扩展到多个 Pod,它将为我们提供一个更好的操作环境。

在我们继续之前,让我们通过添加一个 Service 来完成数据库的配置,从而启用集群内部的通信:

kubectl create \
 -f deploy/go-demo-2-db-svc.yml \
 --record  

零停机部署

更新单个副本的 MongoDB 无法展示 Deployment 背后的真正能力。我们需要一个可扩展的服务。并不是说 MongoDB 不能扩展(它是可以的),但它不像一个从一开始就设计为可扩展的应用程序那样简单。我们将跳到堆栈中的第二个应用程序,并创建一个 ReplicaSet 的 Deployment,基于vfarcic/go-demo-2镜像创建 Pod。但在此之前,我们将花一些时间讨论零停机部署的需求。

一方面,我们的应用程序应该具备非常高的可用性。根据上下文和目标的不同,我们通常会讨论 99%之后有多少个“9”。至少,一个应用程序的可用性必须达到 99.9%。更常见的是,它应该接近 99.99%甚至 99.999%的可用性。百分之百的可用性通常是不可能的,或者实现起来成本过高。我们无法避免所有故障,但可以将其减少到可接受的范围内。

无论 SLA 的可用性是多少,应用程序(至少是我们开发的应用程序)必须是可扩展的。只有当有多个副本时,我们才能期望有合理的可用性。可扩展的应用程序不仅可以将负载分摊到多个实例上,还能确保一个副本的故障不会导致停机。健康的实例会继续处理负载,直到调度器重新创建失败的副本。

高可用性是通过容错和可扩展性来实现的。如果其中任何一项缺失,任何故障可能都会产生灾难性的后果。

我们讨论故障和可扩展性的原因在于不可变部署的特性。如果一个 Pod 是不可变的,那么更新它的新版本的唯一方法是销毁旧的 Pod,并用基于新镜像的 Pod 替换它们。Pod 的销毁与故障没有太大区别。在两种情况下,Pod 都停止工作。另一方面,容错(重新调度)是对失败 Pod 的替代。

唯一的实质性区别在于,新版本发布会导致 Pod 被新的基于新镜像的 Pod 替换。只要过程得到控制,当应用程序有多个副本运行并且设计得当时,新版本发布应该不会导致停机。

我们不应该担心新版本发布的频率。无论是每月发布一次、每周发布一次、每天发布一次还是每几分钟发布一次,过程应该是相同的。如果发布过程中出现任何停机时间,我们可能会被迫减少新版本发布的频率。事实上,在软件开发历史上,我们曾经被告知发布应该是有限的。每年发布几次是常态。之所以发布不频繁,部分原因是它们会造成停机时间。如果我们能够实现零停机部署,那么发布频率就可以发生变化,我们可以目标为持续部署。我们现在不打算深入探讨持续部署的好处,这在此时并不相关。相反,我们将专注于零停机部署。如果有选择的话,没人会选择那种“稍微有点停机”的策略,所以我假设每个人都希望能够在没有中断的情况下发布。

零停机部署是频繁发布的前提条件。

让我们来看看 API 的 Deployment 定义:

cat deploy/go-demo-2-api.yml  

输出如下:

apiVersion: apps/v1beta2
kind: Deployment
metadata:
 name: go-demo-2-api
spec:
 replicas: 3
 selector:
 matchLabels:
 type: api
 service: go-demo-2
 minReadySeconds: 1
 progressDeadlineSeconds: 60
 revisionHistoryLimit: 5
 strategy:
 type: RollingUpdate
 rollingUpdate:
 maxSurge: 1
 maxUnavailable: 1
 template:
 metadata:
 labels:
 type: api
 service: go-demo-2
 language: go
 spec:
 containers:
 - name: api
 image: vfarcic/go-demo-2
 env:
 - name: DB
 value: go-demo-2-db
 readinessProbe:
 httpGet:
 path: /demo/hello
 port: 8080
 periodSeconds: 1
 livenessProbe:
 httpGet:
 path: /demo/hello
 port: 8080  

我们将跳过对apiVersionkindmetadata的解释,因为它们总是遵循相同的模式。

spec部分包含了一些我们之前没有见过的字段,也有一些我们熟悉的字段。replicasselector与我们在上一章的 ReplicaSet 中使用的相同。

minReadySeconds定义了 Kubernetes 在将 Pod 视为健康之前的最小秒数。我们将该字段的值设置为1秒。默认值为0,这意味着一旦 Pod 准备好,并且在指定时livenessProbe返回 OK,它们就会被视为可用。如果有疑问,可以省略此字段,保留默认值0。我们主要是为了演示目的才定义了这个值。

下一个字段是revisionHistoryLimit。它定义了我们可以回滚的旧 ReplicaSet 数量。像大多数字段一样,它的默认值是合理的10。我们将其更改为5,因此,我们将能够回滚到之前的五个 ReplicaSet 中的任何一个。

strategy可以是RollingUpdateRecreate类型。后者将在更新之前杀死所有现有的 Pod。Recreate类似于我们过去使用的过程,当时部署新版本的典型策略是先停止现有版本,然后用新版本替换它。这种方法不可避免地会导致停机。只有在应用程序没有设计为支持两个版本并行存在时,这个策略才有用。不幸的是,这种情况比应有的还要普遍。如果你怀疑你的应用程序是否属于这种情况,可以问自己以下问题:如果我的应用程序的两个不同版本并行运行,是否会有不良影响?如果是这样,Recreate策略可能是一个不错的选择,并且你必须意识到,无法实现零停机部署

recreate策略更适合我们的单副本数据库。我们应该已经设置了本地数据库复制(与 Kubernetes 的 ReplicaSet 对象不同),但正如前面所述,这超出了本章的范围(也许甚至超出了本书的范围)。

如果我们将数据库作为单副本运行,必须挂载一个网络驱动器卷。这样可以避免在更新过程中或发生故障时数据丢失。由于大多数数据库(包括 MongoDB)不能让多个实例写入相同的数据文件,因此在没有复制的情况下,先停止旧版本再创建新版本是一种很好的策略。我们稍后会应用它。

RollingUpdate策略是默认类型,这也是有原因的。它允许我们在不间断的情况下部署新版本。它会创建一个副本数为零的新 ReplicaSet,并根据其他参数,逐步增加新 ReplicaSet 的副本数,同时减少旧 ReplicaSet 的副本数。该过程在新 ReplicaSet 的副本完全替代旧版本副本时结束。

RollingUpdate作为首选策略时,可以通过maxSurgemaxUnavailable字段进行微调。前者定义了可以超过所需数量(通过replicas设置)的 Pod 的最大数量。它可以设置为一个绝对值(例如,2)或一个百分比(例如,35%)。Pod 的总数永远不会超过所需数量(通过replicas设置)和maxSurge的总和。默认值为25%

maxUnavailable定义了不可操作的 Pod 的最大数量。例如,如果副本数设置为 15,而此字段设置为 4,则在任何时刻运行的最少 Pod 数量将为 11。就像maxSurge字段一样,这个字段的默认值也是25%。如果没有指定此字段,将始终至少有 75%的 Pod 是期望中的 Pod。

在大多数情况下,Deployment 特定字段的默认值是一个不错的选择。我们仅通过更改默认设置来展示我们可以使用的更多选项。在随后的大多数 Deployment 定义中,我们将移除这些设置。

template是我们之前使用的相同的PodTemplate。最佳实践是像我们为mongo:3.3设置镜像标签时那样,明确指定镜像标签。然而,这对于我们构建的镜像可能并不是最佳策略。只要我们遵循正确的实践,就可以依赖latest标签保持稳定。即使发现它们不稳定,我们也可以通过创建一个新的latest标签来迅速修复。然而,我们不能指望第三方镜像也如此,它们必须始终使用特定版本的标签。

永远不要基于latest标签部署第三方镜像。通过明确发布版本,我们可以更好地控制生产环境中正在运行的内容,并明确下一次升级应该是什么。

我们不会一直使用latest标签来标识我们的服务,而只是用于初始部署。假设我们尽最大努力保持latest标签稳定且适合生产环境,它在首次设置集群时非常方便。之后,每个新的发布版本将使用一个特定的标签。我们的自动化持续部署流水线将在后续章节中为我们完成此任务。

如果您有信心保持latest标签的稳定性,那么在应用程序的首次部署时使用它是非常方便的。

在我们探索滚动更新之前,我们应该创建部署,并通过它发布我们应用程序的第一个版本。

kubectl create \
 -f deploy/go-demo-2-api.yml \
 --record

kubectl get -f deploy/go-demo-2-api.yml  

我们创建了 Deployment,并从 Kubernetes API 服务器获取了该对象。

后一个命令的输出如下:

NAME          DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
go-demo-2-api 3       3       3          3         1m  

请确保可用的 Pods 数量为3。如果不是,稍等片刻。一旦所有 Pods 都启动并运行,我们将拥有一个创建了新 ReplicaSet 的 Deployment,而 ReplicaSet 又根据vfarcic/go-demo-2镜像的最新版本创建了三个 Pods。

让我们看看设置新镜像时会发生什么。

kubectl set image \
 -f deploy/go-demo-2-api.yml \
 api=vfarcic/go-demo-2:2.0 \
 --record  

我们可以通过几种方式观察更新过程中发生了什么。其中一种方法是通过kubectl rollout status命令。

kubectl rollout status -w \
 -f deploy/go-demo-2-api.yml  

输出如下:

...
deployment "go-demo-2-api" successfully rolled out  

从最后一条记录中,我们可以看到新部署的发布成功了。根据设置新镜像和显示发布状态之间经过的时间,您可能会看到其他记录标记了进展情况。不过,我认为kubectl describe命令的事件提供了一个更清晰的执行过程图景。

kubectl describe \
 -f deploy/go-demo-2-api.yml  

输出的最后几行如下:

...
Replicas: 3 desired | 3 updated | 3 total | 3 available | 0 unava
liable
...
OldReplicaSets:  <none>
NewReplicaSet:   go-demo-2-api-68c75f4f5 (3/3 replicas created)
Events:
 Type   Reason            Age  From                  Message
 ----   ------            ---- ----                  -------
 Normal ScalingReplicaSet 2m   deployment-controller Scaled up r
eplica set go-demo-2-api-68df567fb5 to 3
 Normal ScalingReplicaSet 2m   deployment-controller Scaled up r
eplica set go-demo-2-api-68c75f4f5 to 1
 Normal ScalingReplicaSet 2m   deployment-controller Scaled down 
replica set go-demo-2-api-68df567fb5 to 2
 Normal ScalingReplicaSet 2m   deployment-controller Scaled up r
eplica set go-demo-2-api-68c75f4f5 to 2
 Normal ScalingReplicaSet 2m   deployment-controller Scaled down 
replica set go-demo-2-api-68df567fb5 to 1
 Normal ScalingReplicaSet 2m   deployment-controller Scaled up r
eplica set go-demo-2-api-68c75f4f5 to 3
 Normal ScalingReplicaSet 2m   deployment-controller Scaled down 
replica set go-demo-2-api-68df567fb5 to 0  

我们可以看到,期望的副本数是3。该数量已更新并且都处于可用状态。

输出底部是与部署相关的事件。该过程始于增加新 ReplicaSet (go-demo-2-api-68c75f4f5) 的副本数至 1。接下来,它减少了旧 ReplicaSet (go-demo-2-api-68df567fb5) 的副本数至 2。同样的过程,增加新的副本并减少旧的 ReplicaSet 的副本数一直持续,直到新的 ReplicaSet 达到所需的数量 (3),而旧的则减少到零。

整个过程中没有停机时间。用户无论在更新前、期间还是之后发送请求,都会收到应用程序的响应。唯一重要的是,在更新期间,响应可能来自旧版或新版。更新过程中,两个版本都在并行运行。

让我们看一下回滚历史记录:

kubectl rollout history \
 -f deploy/go-demo-2-api.yml  

输出如下所示:

deployments "go-demo-2-api"
REVISION CHANGE-CAUSE
1        kubectl create --filename=deploy/go-demo-2-api.yml --rec
ord=true
2        kubectl set image api=vfarcic/go-demo-2:2.0 --filename=d
eploy/go-demo-2-api.yml  

到目前为止,我们可以看到软件有两个修订版。更改原因显示了每个修订版由哪个命令创建。

ReplicaSets 怎么样?

kubectl get rs  

输出结果仅限于 go-demo-2-api,如下所示。

NAME                     DESIRED CURRENT READY AGE
go-demo-2-api-68c75f4f5  3       3       3     4m
go-demo-2-api-68df567fb5 0       0       0     4m
...  

我们可以看到部署没有修改 ReplicaSet,而是创建了一个新的 ReplicaSet,在过程结束时,旧的 ReplicaSet 被缩减为零副本。

图 6-2 中的图示了自执行 kubectl set image 命令以来发生的事件流。它紧密地描述了我们从 kubectl describe 命令中已经看到的事件。

图 6-3:部署控制器滚动更新工作流程

我们取得了很大进展。然而,意外随时可能发生,我们必须做好应对准备。

回滚还是向前滚动?

在这一点上,我们或多或少能够在准备好的时候立即向生产环境部署新的发布版。然而,总会出现问题。意外情况会发生。错误会悄悄溜进来,使我们的生产集群面临风险。在这种情况下,我们该怎么办?这个问题的答案很大程度上取决于变更的规模和部署的频率。

如果我们正在使用持续部署流程,我们会经常向生产环境部署新的发布版。与其等待功能累积,我们更倾向于部署小块。在这种情况下,修复问题可能与回滚一样快。毕竟,解决仅仅几小时工作(也许一天)导致的问题需要多少时间?可能不多。问题是由最近的一个变更引入的,该变更仍在工程师的头脑中。修复它不应该需要很长时间,我们应该能够很快部署新版本。

你可能没有频繁发布,或者所包含的更改量超过了几百行代码。在这种情况下,前进的速度可能不如预期。即便如此,回滚可能也无法实现。如果数据库模式发生变化,且与使用它的后端旧版本不兼容,我们可能无法回滚部署。一旦第一个事务开始处理,我们可能就无法再回滚,至少没有丢失自新发布以来生成的数据。

回滚引入了数据库更改的发布通常是不可行的。即便可行,在实践持续部署时,尤其是高频发布且更改范围较小的情况下,前进通常是更好的选择。

我已经尽力劝阻你不要回滚,但在某些情况下回滚是更好的选择。在其他情况下,它可能是唯一的选择。幸运的是,使用 Kubernetes 回滚相对来说是非常直接的。

我们假设刚刚发现最新发布的vfarcic/go-demo-2镜像存在问题,需要回滚到先前的版本。执行这一操作的命令如下:

kubectl rollout undo \
 -f deploy/go-demo-2-api.yml

kubectl describe \
 -f deploy/go-demo-2-api.yml  

后者命令的输出,仅限最后几行,结果如下:

OldReplicaSets:  <none>
NewReplicaSet:   go-demo-2-api-68df567fb5 (3/3 replicas created)
Events:
 Type   Reason             Age             From                  
Message
 ----   ------             ----            ----                  
-------
 Normal ScalingReplicaSet  6m              deployment-controller 
Scaled up replica set go-demo-2-api-68c75f4f5 to 1
 Normal ScalingReplicaSet  6m              deployment-controller 
Scaled down replica set go-demo-2-api-68df567fb5 to 2
 Normal ScalingReplicaSet  6m              deployment-controller 
Scaled up replica set go-demo-2-api-68c75f4f5 to 2
 Normal ScalingReplicaSet  6m              deployment-controller 
Scaled down replica set go-demo-2-api-68df567fb5 to 1
 Normal ScalingReplicaSet  6m              deployment-controller 
Scaled up replica set go-demo-2-api-68c75f4f5 to 3
 Normal ScalingReplicaSet  6m              deployment-controller 
Scaled down replica set go-demo-2-api-68df567fb5 to 0
 Normal DeploymentRollback 1m              deployment-controller 
Rolled back deployment "go-demo-2-api" to revision 1
 Normal ScalingReplicaSet  1m              deployment-controller 
Scaled up replica set go-demo-2-api-68df567fb5 to 1
 Normal ScalingReplicaSet  1m              deployment-controller 
Scaled down replica set go-demo-2-api-68c75f4f5 to 2
 Normal ScalingReplicaSet  1m (x2 over 6m) deployment-controller 
Scaled up replica set go-demo-2-api-68df567fb5 to 3
 Normal ScalingReplicaSet  1m (x3 over 1m) deployment-controller 
(combined from similar events): Scaled down replica set go-demo-2
-api-68c75f4f5 to0  

从事件部分可以看到,Deployment 启动了回滚操作,从那里开始,我们之前经历的过程被逆转了。它开始增加旧 ReplicaSet 的副本数,并减少最新 ReplicaSet 的副本数。一旦该过程完成,旧的 ReplicaSet 变为活动状态并拥有所有副本,而新的 ReplicaSet 则被缩放为零。

最终结果可能更容易通过位于Events上方的NewReplicaSet条目看到。在我们撤销部署之前,值是go-demo-2-api-68c75f4f5,现在是go-demo-2-api-68df567fb5

仅了解最新 Deployment 的当前状态通常是不够的,我们可能需要获取过去的发布列表。可以通过kubectl rollout history命令来获取。

kubectl rollout history \
 -f deploy/go-demo-2-api.yml  

输出结果如下:

REVISION  CHANGE-CAUSE
2         kubectl set image api=vfarcic/go-demo-2:2.0 --filename=
deploy/go-demo-2-api.yml
3         kubectl create --filename=deploy/go-demo-2-api.yml --re
cord=true  

如果你查看第三次修订,你会注意到变更原因是我们第一次创建 Deployment 时使用的相同命令。在执行kubectl rollout undo之前,我们有两个修订版本;12undo命令检查了倒数第二个修订版本(1)。由于新的部署不会删除 ReplicaSets,而是将其缩放到0,因此撤销最后一次更改所需做的只是将其缩放回所需的副本数,并同时将当前的副本数缩放为零。

让我们加快进度,部署几个新版本。这样可以为我们提供更广阔的探索空间,以尝试一些我们可以在 Deployments 中做的额外操作。

kubectl set image \
 -f deploy/go-demo-2-api.yml \
 api=vfarcic/go-demo-2:3.0 \
 --record

kubectl rollout status \
 -f deploy/go-demo-2-api.yml  

我们将镜像更新为vfarcic/go-demo-2:3.0并检索了发布状态。后者命令的最后一行如下:

deployment "go-demo-2-api" successfully rolled out  

部署成功更新,因此创建了一个新的 ReplicaSet,并将其扩展到所需的副本数。先前活跃的 ReplicaSet 被缩放到0。因此,我们正在运行标签3.0vfarcic/go-demo-2镜像。

我们将重复使用4.0标签进行操作:

kubectl set image \
 -f deploy/go-demo-2-api.yml \
 api=vfarcic/go-demo-2:4.0 \
 --record

kubectl rollout status \
 -f deploy/go-demo-2-api.yml  

rollout status的最后一行输出确认了发布成功。

现在,我们已经部署了一些版本,可以检查当前的rollout history

kubectl rollout history \
 -f deploy/go-demo-2-api.yml  

输出如下:

deployments "go-demo-2-api"
REVISION CHANGE-CAUSE
2        kubectl set image api=vfarcic/go-demo-2:2.0 --filename=d
eploy/go-demo-2-api.yml --record=true
3        kubectl create --filename=deploy/go-demo-2-api.yml --rec
ord=true
4        kubectl set image api=vfarcic/go-demo-2:3.0 --filename=d
eploy/go-demo-2-api.yml --record=true
5        kubectl set image api=vfarcic/go-demo-2:4.0 --filename=deploy/go-demo-2-api.yml --record=true  

我们可以清楚地看到生成这些更改的命令,以及通过它们我们如何将应用程序推进到当前基于镜像vfarcic/go-demo-2:4.0的版本。

如您所见,我们可以通过kubectl rollout undo命令回滚到上一个版本。在大多数情况下,当遇到问题且无法通过创建包含修复的新版本进行向前推进时,回滚是正确的操作。然而,有时即使这样也不够,我们必须回退到比上一个版本更早的时间点。

假设我们发现当前版本不仅有问题,而且之前的几个版本也存在 bug。按照相同的思路,我们假设最后一个正确的版本是基于镜像vfarcic/go-demo-2:2.0。我们可以通过执行以下命令来解决该问题(请勿执行此命令):

kubectl set image \
 -f deploy/go-demo-2-api.yml \
 api=vfarcic/go-demo-2:2.0 \
 --record  

尽管该命令肯定会解决问题,但有一种更简单的方法来实现相同的结果。我们可以通过回退到最后一个工作正常的版本来undorollout。假设我们要恢复到镜像vfarcic/go-demo-2:2.0,通过查看历史记录中的变更原因,我们得知应该回退到版本2。这可以通过--to-revision参数来完成。命令如下:

kubectl rollout undo \
 -f deploy/go-demo-2-api.yml \
 --to-revision=2

kubectl rollout history \
 -f deploy/go-demo-2-api.yml  

我们通过回退到版本2撤销了发布。我们还恢复了history

后续命令的输出如下:

deployments "go-demo-2-api"
REVISION  CHANGE-CAUSE
3         kubectl create --filename=deploy/go-demo-2-api.yml --re
cord=true
4         kubectl set image api=vfarcic/go-demo-2:3.0 --filename=
deploy/go-demo-2-api.yml --record=true
5         kubectl set image api=vfarcic/go-demo-2:4.0 --filename=
deploy/go-demo-2-api.yml --record=true
6         kubectl set image api=vfarcic/go-demo-2:2.0 --filename=
deploy/go-demo-2-api.yml --record=true  

通过新版本6,我们可以看到当前活跃的部署是基于镜像vfarcic/go-demo-2:2.0。我们成功地回到了特定的时间点。问题已经解决,如果这是一个在生产集群中运行的“真实”应用程序,我们的用户将继续与我们实际有效的版本进行交互。

回滚失败的部署

发现关键性 bug 可能是回滚的最常见原因,但也有其他情况。例如,我们可能会遇到无法创建 Pods 的情况。一个容易重现的案例是尝试部署一个不存在标签的镜像。

kubectl set image \
 -f deploy/go-demo-2-api.yml \
 api=vfarcic/go-demo-2:does-not-exist \
 --record 

输出如下:

deployment "go-demo-2-api" image updated  

看见这样的消息,你可能会以为一切都正常了。然而,输出的只是表示部署中使用的镜像定义已成功更新。这并不意味着副本集背后的 Pods 确实在运行。举个例子,我可以向你保证,vfarcic/go-demo-2:does-not-exist镜像并不存在。

请确保自执行kubectl set image命令后,至少已经过去60秒。如果你想知道为什么要等待,答案就在go-demo-2-api部署定义中的progressDeadlineSeconds字段。这就是部署在得出无法进行的结论之前必须等待的时间,因为无法运行某个 Pod。

让我们来看看副本集。

kubectl get rs -l type=api  

输出如下:

NAME                     DESIRED CURRENT READY AGE
go-demo-2-api-5b49d94f9b 0       0       0     8m
go-demo-2-api-68c75f4f5  2       2       2     9m
go-demo-2-api-7cb9bb5675 0       0       0     8m
go-demo-2-api-68df567fb5 0       0       0     9m
go-demo-2-api-dc7877dcd  2       2       0     4m  

到目前为止,在不同情况下,新的副本集(go-demo-2-api-dc7877dcd)中的所有 Pods 应该已被设置为3,而之前的副本集(go-demo-2-api-68c75f4f5)中的 Pods 应该已经缩减到0。然而,部署过程中发现了问题,并停止了更新操作。

我们应该能够通过kubectl rollout status命令获取更详细的信息:

kubectl rollout status \
 -f deploy/go-demo-2-api.yml  

输出如下:

error: deployment "go-demo-2-api" exceeded its progress deadline  

部署意识到不应该继续进行。新的 Pods 没有运行,且达到了限制。继续尝试也没有意义。

如果你期待部署在失败后会自动回滚,那你就错了。它不会自动回滚。至少,不没有额外的插件。并不是说我期待你在终端前坐着,等待超时,然后检查rollout status,再决定是保持新的更新还是回滚。我希望你能够在自动化 CDP 管道中部署新的版本。幸运的是,status命令返回1表示部署失败,我们可以利用这些信息决定接下来的操作。对于那些不常接触 Linux 的人来说,任何非0的退出代码都被视为错误。让我们通过检查上一个命令的退出代码来确认这一点:

echo $?  

输出的确是1,从而确认了部署失败。

我们很快会探讨自动化 CDP 管道。目前,先记住我们可以通过查看部署更新是否成功来进行判断。

现在我们发现上一次的部署失败了,我们应该撤销它。你已经知道如何操作,但我会再提醒一下,以防你有点健忘。

kubectl rollout undo \
 -f deploy/go-demo-2-api.yml

kubectl rollout status \
 -f deploy/go-demo-2-api.yml  

上一个命令的输出确认了deployment "go-demo-2-api"已经成功部署

既然我们已经学会了如何回滚,无论问题是严重的 bug 还是无法运行新版本,我们可以稍作休息,先把到目前为止所学的所有定义合并到一个 YAML 文件中。但在此之前,我们先删除我们创建的对象。

kubectl delete \
 -f deploy/go-demo-2-db.yml

kubectl delete \
 -f deploy/go-demo-2-db-svc.yml

kubectl delete \
 -f deploy/go-demo-2-api.yml  

将所有内容合并到同一个 YAML 定义中

将这一部分视为一个简短的插曲。我们将本章中使用的定义合并成一个单独的 YAML 文件。你之前已经见过类似的示例,因此无需详细解释。

cat deploy/go-demo-2.yml  

如果你开始与之前的定义进行对比,你会发现一些不同之处。minReadySecondsprogressDeadlineSecondsrevisionHistoryLimitstrategy字段已从go-demo-2-api Deployment 中移除。我们主要是用它们来演示如何使用它们。但由于 Kubernetes 有合理的默认值,我们在这个定义中省略了它们。你还会注意到有两个 Services,尽管我们在本章中只创建了一个。由于我们没有需要访问 API 的需求,因此在我们的示例中没有使用go-demo-2-api Service。但为了完整性,它被包含在这个定义中。最后,数据库的部署策略被设置为recreate。正如前面所解释的,它更适合单副本数据库,尽管我们没有挂载可以保留数据的卷。

让我们创建deploy/go-demo-2.yml中定义的对象。记住,通过使用--save-config,我们确保可以在以后编辑配置。另一种选择是使用kubectl apply

kubectl create \
 -f deploy/go-demo-2.yml \
 --record --save-config

kubectl get -f deploy/go-demo-2.yml  

后一个命令的输出如下:

NAME                DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
deploy/go-demo-2-db 1       1       1          1         15s

NAME             TYPE      CLUSTER-IP EXTERNAL-IP PORT(S)   AGE
svc/go-demo-2-db ClusterIP 10.0.0.125 <none>      27017/TCP 15s

NAME                 DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
deploy/go-demo-2-api 3       3       3          3         15s

NAME              TYPE     CLUSTER-IP EXTERNAL-IP PORT(S)        
AGE
svc/go-demo-2-api NodePort 10.0.0.57  <none>      8080:31586/TCP 
15s

所有四个对象(两个 Deployments 和两个 Services)都已创建,我们可以继续探索如何通过单个命令更新多个对象。

更新多个对象

即使大多数时候我们将请求发送到特定的对象,几乎所有操作都是通过选择器标签进行的。当我们更新 Deployments 时,它们会根据匹配的选择器来选择要创建和扩展的 ReplicaSets。它们反过来使用匹配的选择器来创建或终止 Pods。Kubernetes 中的几乎所有操作都是通过标签选择器来完成的。只是有时候这一点对我们来说是隐藏的。

我们不必仅通过指定对象的名称或其定义所在的 YAML 文件来更新对象。我们还可以使用标签来决定应该更新哪个对象。这为我们打开了一些有趣的可能性,因为选择器可能匹配多个对象。

假设我们正在运行多个带有 Mongo 数据库的 Deployments,现在到了更新它们到新版本的时候。在我们探索如何做到这一点之前,我们将创建另一个 Deployment,以便我们至少有两个带有数据库 Pods 的 Deployment。

让我们首先看一下定义:

cat deploy/different-app-db.yml  

输出如下:

piVersion: apps/v1beta2
kind: Deployment
metadata:
 name: different-app-db
  labels:
     type: db
     service: different-app
     vendor: MongoLabs
spec:
 selector:
 matchLabels:
 type: db
 service: different-app
 template:
 metadata:
 labels:
 type: db
 service: different-app
 vendor: MongoLabs
 spec:
 containers:
 - name: db
 image: mongo:3.3
 ports:
 - containerPort: 28017  

go-demo-2-db Deployment 相比,唯一的区别在于service标签。两者的type都设置为db

让我们创建这个 Deployment:

kubectl create \
 -f deploy/different-app-db.yml  

现在我们有了两个带有mongo:3.3 Pods 的 Deployments,我们可以尝试同时更新这两个。

诀窍是找到一个标签(或一组标签),它能够唯一标识我们想要更新的所有 Deployments。

让我们看看带有标签的 Deployment 列表:

kubectl get deployments --show-labels  

输出如下:

NAME             DESIRED CURRENT UP-TO-DATE AVAILABLE AGE LABELS
different-app-db 1       1       1          1         1h  service
=different-app,type=db,vendor=MongoLabs
go-demo-2-api    3       3       3          3         1h  languag
e=go,service=go-demo-2,type=api
go-demo-2-db     1       1       1          1         1h  service
=go-demo-2,type=db,vendor=MongoLabs  

我们想要更新使用different-app-dbgo-demo-2-db部署创建的mongo Pods。它们都通过type=dbvendor=MongoLabs标签唯一标识。让我们来测试一下:

kubectl get deployments \
 -l type=db,vendor=MongoLabs  

输出如下所示:

NAME             DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
different-app-db 1       1       1          1         1h
go-demo-2-db     1       1       1          1         1h  

我们可以看到,使用这两个标签进行过滤是有效的。我们只检索了我们想要更新的部署,所以让我们继续并推出新版本:

kubectl set image deployments \
 -l type=db,vendor=MongoLabs \ 
 db=mongo:3.4 --record  

输出如下所示:

deployment "different-app-db" image updated
deployment "go-demo-2-db" image updated  

最后,在进入下一个主题之前,我们应该验证镜像是否确实更改为mongo:3.4

kubectl describe \
 -f deploy/go-demo-2.yml  

输出,仅限于相关部分,如下所示:

...
 Containers:
 db:
 Image:        mongo:3.4
...  

如我们所见,更新确实成功,至少在该部署上是如此。可以随意描述deploy/different-app-db.yml中定义的部署。你应该会看到它的镜像也已更新为新版。

扩展部署

扩展部署有许多不同的方式。我们在这一部分所做的所有操作不仅限于部署,任何控制器,如 ReplicaSet,甚至那些我们还没有探索过的控制器,都可以应用这些方法。

如果我们认为副本数量的变化相对较少,或者部署是手动进行的,那么扩展的最佳方式是编写一个新的 YAML 文件,或者更好的是,修改现有的文件。假设我们将 YAML 文件存储在代码库中,通过更新现有文件,我们就能有一个文档化并可复现的集群内部对象定义。

我们已经在应用go-demo-2-scaled.yml定义时进行了扩展。我们将做类似的事情,但这次是针对部署进行操作。

让我们看一下deploy/go-demo-2-scaled.yml

cat deploy/go-demo-2-scaled.yml  

我们不会显示整个文件的内容,因为它与deploy/go-demo-2.yml几乎相同。唯一的区别是go-demo-2-api部署的副本数量。

...
apiVersion: apps/v1beta2
kind: Deployment
metadata:
  name: go-demo-2-api
spec:
  replicas: 5
...  

目前,我们运行的是三个副本。应用新的定义后,它应该增加到五个副本。

kubectl apply \
 -f deploy/go-demo-2-scaled.yml  

请注意,尽管文件不同,但资源的名称是相同的,因此kubectl apply并没有创建新对象,而是更新了那些发生变化的对象。特别是,它更改了go-demo-2-api部署的副本数量。

让我们确认,确实有五个副本的 Pod 是通过该部署控制的。

kubectl get \
 -f deploy/go-demo-2-scaled.yml  

输出,仅限于deploy/go-demo-2-api,如下所示:

...
NAME                 DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
deploy/go-demo-2-api 5       5       5          5         11m
...  

结果应该不足为奇。毕竟,我们之前在探索 ReplicaSets 时已经执行过相同的过程。

虽然使用 YAML 文件(或其他控制器)扩展部署是保持文档准确的绝佳方式,但它很少适应集群的动态特性。我们应该力求实现一个可以自动扩展(和缩减)服务的系统。当扩展频繁且希望自动化时,我们不能指望每次更新 YAML 定义并将其推送到 Git。这将非常低效,而且如果通过仓库 Webhook 触发,可能会导致交付流水线的不必要执行。毕竟,我们真的希望每天多次推送更新的 YAML 文件吗?

replicas 的数量不应是设计的一部分。相反,它是一个波动的数字,随着流量、内存和 CPU 使用率等因素的变化而持续变化(或者至少经常变化)。

根据发布频率,image 也可以说是一样的。如果我们在进行持续交付或部署,我们可能会每周发布一次,每天发布一次,甚至更频繁。在这种情况下,新的镜像会经常部署,并且没有强烈的理由要求我们在每次发布新版本时都更改 YAML 文件。尤其是当我们通过自动化过程进行部署时(如我们应该做的那样),这一点尤为重要。

我们稍后会探讨自动化。现在,我们将限制在类似于 kubectl set image 的命令上。我们用它来在每次发布时更改 Pods 使用的 image。类似地,我们将使用 kubectl scale 来更改副本数量。可以把这看作是对即将到来的自动化的介绍。

kubectl scale deployment \
 go-demo-2-api --replicas 8 --record  

我们扩展了与 Deployment go-demo-2-api 相关的副本数量。请注意,这次我们没有使用 -f 来引用文件。由于在同一个 YAML 文件中指定了两个 Deployment,这将导致两个都被扩展。由于我们只想对特定的 Deployment 进行扩展,所以我们改为使用了它的名称。

让我们确认扩展是否如预期那样工作。

kubectl get -f deploy/go-demo-2.yml  

输出(仅限于 Deployment)如下:

NAME                DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
deploy/go-demo-2-db 1       1       1          1         33m

NAME                 DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
deploy/go-demo-2-api 8       8       8          8         33m  

正如我之前提到的,我们将花费相当多的时间来实现自动化,届时你将不必手动扩展应用程序。不过,我认为了解 kubectl scale 命令的存在是有用的。现在,你知道如何扩展 Deployment(和其他控制器)。

现在怎么办?

我们所学的一切都指向了 Deployments。Pods 不能直接创建,而必须通过 ReplicaSets 来创建,而 ReplicaSets 同样不能直接创建,必须通过 Deployments 来创建。它们是允许我们不仅可以创建 ReplicaSets 和 Pods,而且可以在不造成任何停机的情况下更新它们(前提是应用程序经过相应设计)。我们将 Deployments 与 Services 结合使用,使 Pods 之间能够相互通信,或者能够从集群外部访问。总的来说,我们已经拥有了将服务发布到生产环境所需的一切。这并不意味着我们已经理解了 Kubernetes 的所有关键方面。我们离这个目标还远。但我们已经具备了运行某些类型应用程序到生产环境所需的几乎所有内容。我们所缺少的只是网络部分。

在进入我们知识探索任务的下一阶段之前,我们将销毁当前运行的集群,让我们的笔记本休息一下。

minikube delete

如果你想了解更多关于部署的内容,请查阅 Deployment v1 apps 的API 文档

图 6-4:到目前为止探索的组件

在进入下一章之前,我们将探讨 Kubernetes 部署与 Docker Swarm 堆栈之间的差异。

Kubernetes 部署与 Docker Swarm 堆栈的对比

如果你已经使用过 Docker Swarm,那么 Kubernetes Deployments 背后的逻辑应该是熟悉的。两者的目的相同,都可以用来部署新应用或更新已经在集群中运行的应用。在这两种情况下,我们都可以轻松地部署新版本而不会造成停机(前提是应用架构允许)。

与之前 Kubernetes Pods、ReplicaSets 和 Services 与 Docker Swarm Stacks 的对比不同,Kubernetes Deployments 确实提供了一些潜在的重要功能差异。但在我们深入探讨功能对比之前,我们将先花些时间来了解我们定义对象的方式有何不同。

一个 Kubernetes Deployment 和 Service 的定义示例如下:

apiVersion: apps/v1beta2
kind: Deployment
metadata:
 name: go-demo-2-db
spec:
 selector:
 matchLabels:
 type: db
 service: go-demo-2
 strategy:
 type: Recreate
 template:
 metadata:
 labels:
 type: db
 service: go-demo-2
 vendor: MongoLabs
 spec:
 containers:
 - name: db
 image: mongo:3.3
 ports:
 - containerPort: 28017

---

apiVersion: v1
kind: Service
metadata:
 name: go-demo-2-db
spec:
 ports:
 - port: 27017
 selector:
 type: db
 service: go-demo-2

---

apiVersion: apps/v1beta2
kind: Deployment
metadata:
 name: go-demo-2-api
spec:
 replicas: 3
 selector:
 matchLabels:
 type: api
 service: go-demo-2
 template:
 metadata:
 labels:
 type: api
 service: go-demo-2
 language: go
 spec:
 containers:
 - name: api
 image: vfarcic/go-demo-2
 env:
 - name: DB
 value: go-demo-2-db
 readinessProbe:
 httpGet:
 path: /demo/hello
 port: 8080
 periodSeconds: 1
 livenessProbe:
 httpGet:
 path: /demo/hello
 port: 8080

---

apiVersion: v1
kind: Service
metadata:
 name: go-demo-2-api
spec:
 type: NodePort
 ports:
 - port: 8080
 selector:
 type: api
 service: go-demo-2  

一个等效的 Docker Swarm 堆栈定义如下:

version: "3"
services:
 api:
 image: vfarcic/go-demo-2
 environment:
 - DB=db
 ports:
 - 8080
 deploy:
 replicas: 3
 db:
 image: mongo:3.3  

两种定义提供的功能大致相同。

显然,Kubernetes 部署需要一个更长的定义,并且具有更复杂的语法。值得注意的是,Swarm 中与 readinessProbelivenessProbe 对应的功能在该栈中并不存在,因为它们在 Dockerfile 中作为 HEALTHCHECK 被定义。即便我们去掉它们,Kubernetes 部署依然较长且更为复杂。

如果仅仅比较定义对象的方式,Docker Swarm 显然是胜者。接下来我们来看从功能角度我们能得出什么结论。

创建对象相对简单。kubectl createdocker stack deploy 都能在没有停机的情况下部署新版本。新的容器,或者在 Kubernetes 中是新的 Pods,将会被创建,同时旧的容器会被终止。到目前为止,这两个方案差别不大。

其中一个主要的区别是发生故障时的处理方式。Kubernetes Deployment 在发生故障时不会执行任何修复操作。它会停止更新,导致新的和旧的 Pods 并行运行。另一方面,Docker Swarm 可以配置为自动回滚。这看起来像是 Docker Swarm 的又一个优势。然而,Kubernetes 有一些 Swarm 没有的功能。我们可以使用 kubectl rollout status 命令来查看更新是否成功,如果失败,我们可以 undo 更新的 rollout。尽管我们需要执行一些命令才能实现相同的结果,但当更新是自动化时,这可能更有利。知道更新是成功还是失败,不仅可以执行后续的回滚操作,还可以通知相关人员问题的存在。

这两种方法各有优缺点。在某些情况下,Docker Swarm 的自动回滚更合适,而 Kubernetes 的更新状态则在其他情况下更为有效。这些方法不同,并没有明显的优胜者,因此我认为这也是平局。

Kubernetes Deployment 可以记录历史。我们可以使用 kubectl rollout history 命令查看过去的更新历史。当更新按预期工作时,history 并不是非常有用。但当出现问题时,它可能提供额外的见解。这可以与回滚到特定版本的能力结合使用,而不一定是回滚到上一个版本。然而,大多数时候我们会回滚到上一个版本。回滚到更早的版本通常不太有用。即使这种需求出现,两个产品都可以做到。不同之处在于,Kubernetes Deployment 允许我们回滚到特定的修订版本(例如,我们现在是第五个修订版本,可以回滚到第二个修订版本)。而在 Docker Swarm 中,我们需要发布一个新的更新(例如,将镜像更新为 2.0 标签)。由于容器是不可变的,结果是一样的,所以差异仅在于回滚背后的语法。

两种产品都具备回滚到特定版本或标签的功能。我们可以讨论哪种语法更直接或更有用。差异很小,我认为在这项功能上没有赢家。可以说这是平局。

由于 Kubernetes 中几乎所有内容都基于标签选择器,它拥有 Docker Swarm 所没有的功能。我们可以同时更新多个部署。例如,我们可以发布一个更新(kubetl set image),使用过滤器查找所有 Mongo 数据库并将其升级到更新版本。这是一个在 Docker Swarm 中需要几行 bash 脚本才能实现的功能。然而,尽管能够更新所有符合特定标签的部署听起来像是一个有用的功能,但实际上它并不总是如此。更多时候,这种操作可能会产生不希望出现的效果。例如,如果我们有五个后端应用程序都使用 Mongo 数据库(每个应用一个数据库),我们可能希望以更加可控的方式进行升级。负责这些服务的团队可能希望测试每次升级并确认其可行性。我们可能不会等到所有升级完成,而是当负责的团队觉得准备好了时,就升级单个数据库。

在某些情况下,更新多个对象是有用的,所以这点我必须给 Kubernetes 认可。虽然是一个小胜,但依然算数。

还有一些事情是 Kubernetes 更容易实现的。例如,由于 Kubernetes 服务的工作方式,创建一个蓝绿部署流程比使用滚动更新要简单得多。然而,这样的过程属于高级用法,所以我会将其排除在这次比较之外。它(可能)稍后会提到。

很难说哪个解决方案提供更好的结果。从用户友好性的角度来看,Docker Swarm 依然很出色。另一方面,Kubernetes 部署提供了几个额外的功能。

写一个 Docker Swarm 堆栈文件比编写 Kubernetes 部署定义要简单得多。Kubernetes 部署提供了 Swarm 所没有的一些附加功能。然而,这些功能对于大多数使用场景来说重要性较小。那些真正重要的功能,或多或少是相同的。

不要仅仅基于 Kubernetes 部署和 Docker Swarm 堆栈之间的差异做出决策。在定义语法方面,Swarm 明显占优,而在功能方面,Kubernetes 比 Swarm 稍微有些优势。如果你仅仅根据部署做决策,Swarm 可能是一个稍微更好的选择。或者不是。这完全取决于在你案例中最重要的是什么。你关心 YAML 语法吗?那些额外的 Kubernetes 部署功能是你会用到的吗?

无论如何,Kubernetes 提供了更多的功能,基于如此有限的比较范围做出的任何结论都注定是不完整的。我们只是触及了表面。敬请关注更多内容。

第七章:使用 Ingress 转发流量

无法访问的应用程序是无用的。Kubernetes 服务提供了可访问性,但有一定的可用性成本。每个应用程序都可以通过不同的端口访问。我们不能指望用户知道我们集群中每个服务的端口。

Ingress 对象管理着外部访问运行在 Kubernetes 集群内部的应用程序。乍一看,似乎我们已经通过 Kubernetes 服务完成了这一任务,但它们并没有真正让应用程序可访问。我们仍然需要基于路径和域的转发规则、SSL 终止以及其他许多功能。在更传统的设置中,我们可能会使用外部代理和负载均衡器。Ingress 提供了一个 API,让我们能够实现这些功能,并附带了一些我们期望从动态集群中获得的其他特性。

我们将通过示例探索问题和解决方案。现在,首先我们需要创建一个集群。

创建集群

和之前的每一章一样,我们将首先创建一个 Minikube 单节点集群。

本章中的所有命令都可以在 07-ingress.sh (gist.github.com/vfarcic/54ef6592bce747ff2d1b089834fc755b) Gist 中找到。

cd k8s-specs

git pull

minikube start --vm-driver=virtualbox

kubectl config current-context  

集群应已启动并运行,我们可以继续。

探索通过 Kubernetes 服务启用外部访问时的不足

在我们了解问题之前,我们无法探索解决方案。因此,我们将重新创建一些对象,利用我们已经掌握的知识。这将帮助我们查看 Kubernetes 服务是否满足应用程序用户的所有需求。或者,更明确地说,我们将探索在使我们的应用程序对用户可访问时,缺少哪些功能。

我们已经讨论过,通过服务发布固定端口是一个不好的做法。这种方法很可能导致冲突,或者至少会增加额外的负担,需要仔细跟踪每个端口属于哪个服务。我们之前已经放弃了这个选项,现在也不会改变主意。既然我们已经澄清了这一点,让我们回过头来创建上一章中的 Deployments 和 Services。

kubectl create \
 -f ingress/go-demo-2-deploy.yml

kubectl get \
 -f ingress/go-demo-2-deploy.yml  

get 命令的输出如下:

NAME                DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
deploy/go-demo-2-db 1       1       1          1         48s

NAME             TYPE      CLUSTER-IP EXTERNAL-IP PORT(S)   AGE
svc/go-demo-2-db ClusterIP 10.0.0.14  <none>      27017/TCP 48s

NAME                 DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
deploy/go-demo-2-api 3       3       3          3         48s

NAME              TYPE     CLUSTER-IP EXTERNAL-IP PORT(S)        
AGE
svc/go-demo-2-api NodePort 10.0.0.179 <none>      8080:30417/TCP 
48s  

如你所见,这些就是我们之前创建的相同的 Services 和 Deployments。

在继续之前,我们应该等到所有 Pods 都启动并运行。

kubectl get pods  

输出如下:

NAME                           READY STATUS  RESTARTS AGE
go-demo-2-api-68df567fb5-8qcmv 1/1   Running 0        3m
go-demo-2-api-68df567fb5-k55d4 1/1   Running 0        3m
go-demo-2-api-68df567fb5-ws9cj 1/1   Running 0        3m
go-demo-2-db-dd48b7dfc-hdxbz   1/1   Running 0        3m  

如果在你的情况下,某些 Pods 尚未运行,请等待片刻并重新执行 kubectl get pods 命令。我们将在它们准备好后继续。

访问应用程序的一种明显方法是通过服务:

IP=$(minikube ip)

PORT=$(kubectl get svc go-demo-2-api \
 -o jsonpath="{.spec.ports[0].nodePort}")

curl -i "http://$IP:$PORT/demo/hello"  

我们获取了 Minikube 的 IP 地址以及 go-demo-2-api 服务的端口。我们使用这些信息发送了请求。

curl 命令的输出如下:

HTTP/1.1 200 OK
Date: Sun, 24 Dec 2017 13:35:26 GMT
Content-Length: 14
Content-Type: text/plain; charset=utf-8

hello, world!  

应用程序返回了状态码 200,从而确认服务确实转发了请求。

尽管发布一个随机端口,甚至是硬编码的单一应用程序端口可能不会太糟,但如果我们将同样的原则应用到更多的应用程序上,用户体验将会非常糟糕。为了让这个问题更清楚,我们将部署另一个应用程序:

kubectl create \
 -f ingress/devops-toolkit-dep.yml \
 --record --save-config

kubectl get \
 -f ingress/devops-toolkit-dep.yml  

这个应用程序遵循与第一个应用程序类似的逻辑。从后面的命令中我们可以看到,它包含一个 Deployment 和一个 Service。由于 YAML 定义与之前使用的非常相似,因此细节不太重要。关键是现在我们有两个应用程序在集群内运行。

让我们检查一下新应用程序是否真的可以访问:

PORT=$(kubectl get svc devops-toolkit \
 -o jsonpath="{.spec.ports[0].nodePort}")

open "http://$IP:$PORT"  

我们获取了新服务的端口,并在浏览器中打开应用程序。你应该能看到一个包含《DevOps 工具包》书籍的简单前端。如果没有看到,你可能需要稍等一会儿,直到容器拉取完成,再试一次。

请求的简化流程如图 7-1所示。用户向集群的某个节点发送请求。请求由一个服务接收,并通过负载均衡转发到其中一个关联的 Pod。其实这个过程要比这复杂,涉及到 iptables、kube DNS、kube proxy 和其他一些组件。我们在第五章《使用服务启用 Pod 之间的通信》中对这些进行了更详细的探讨,可能不需要再一一介绍。为了简洁起见,简化的图示应该足够:

图 7-1:通过服务访问应用程序

我们不能指望用户知道每个应用程序背后的具体端口。即使只有两个应用程序,这样也不太符合用户友好性。如果应用程序数量增加到几十个甚至几百个,我们的业务也将很快消亡。

我们需要的是一种通过标准的 HTTP (80) 或 HTTPS (443) 端口使所有服务都能访问的方法。单独使用 Kubernetes 服务无法实现这一点,我们还需要更多的东西。

我们需要做的是在预定义的路径和域名上授予访问我们的服务的权限。我们的go-demo-2服务可以通过基础路径/demo与其他服务区分开来。同样,书籍应用程序可以通过devopstoolkitseries.com域名访问。如果我们能做到这一点,我们就可以通过以下命令访问它们:

curl "http://$IP/demo/hello"  

请求收到了 Connection refused 响应。端口 80 上没有进程在监听,所以这个结果并不令人意外。我们本可以将其中一个服务修改为发布固定端口 80,而不是分配一个随机端口。然而,这样做仍然只会提供对两个应用程序中的一个的访问。

我们通常希望将每个应用程序与不同的域名或子域名关联。在我们运行的示例之外,书籍应用程序可以通过 devopstoolkitseries.com (www.devopstoolkitseries.com/) 域名访问。由于我不会给你修改我的域名 DNS 记录的权限,我们将通过将域名添加到 Host 头来模拟这一点。

应该验证我们集群内运行的应用程序是否可以通过 devopstoolkitseries.com 域名访问的命令如下:

curl -i \
 -H "Host: devopstoolkitseries.com" \
 "http://$IP"  

正如预期的那样,请求仍然被拒绝。

最后但同样重要的是,我们应该能够通过启用 HTTPS 访问,使一些应用程序(如果不是全部)变得部分安全。这意味着我们应该有地方存储我们的 SSL 证书。我们可以将它们放在应用程序内部,但那样只会增加操作复杂性。相反,我们应该朝着在客户端和应用程序之间进行 SSL 卸载的方向努力。

我们面临的问题是常见的,因此 Kubernetes 有解决方案也就不足为奇了。

启用 Ingress 控制器

我们需要一个机制来接受预定义端口(例如 80443)上的请求,并将其转发到 Kubernetes 服务。它应该能够根据路径和域名区分请求,并能够执行 SSL 卸载。

Kubernetes 本身没有现成的解决方案来实现这一点。与通常作为 kube-controller-manager 二进制文件一部分的其他类型的控制器不同,Ingress 控制器需要单独安装。与控制器不同,kube-controller-manager 提供了 Ingress 资源,其他第三方解决方案可以利用这些资源来提供请求转发和 SSL 功能。换句话说,Kubernetes 只提供了一个 API,我们需要设置一个将使用它的控制器。

幸运的是,社区已经构建了众多 Ingress 控制器。我们不会评估所有可用的选项,因为这需要大量空间,而且大多取决于你的需求和你的托管供应商。相反,我们将通过 Minikube 中已经可用的 Ingress 控制器来探索它是如何工作的。

让我们来看一下 Minikube 插件的列表:

minikube addons list  

输出如下:

- kube-dns: enabled
- registry: disabled
- registry-creds: disabled
- dashboard: enabled
- coredns: disabled
- heapster: disabled
- ingress: disabled
- addon-manager: enabled
- default-storageclass: enabled  

我们可以看到 ingress 作为 Minikube 插件之一可用。然而,它默认是禁用的,因此我们的下一步操作将是启用它。

如果你以前使用过 Minikube,ingress 插件可能已经启用。如果是这种情况,请跳过接下来的命令。

minikube addons enable ingress  

现在 ingress 插件已启用,我们将检查它是否在我们的集群内运行:

kubectl get pods -n kube-system \
 | grep ingress

忽略 -n 参数。我们尚未探索命名空间。目前,请注意命令的输出应显示 nginx-ingress-controller-... Pod 正在运行。

如果输出为空,可能需要稍等片刻,直到容器被拉取完毕,然后重新执行 kubectl get all --namespace ingress-nginx 命令。

Minikube 附带的 Ingress 控制器基于 Google Cloud Platform (GCP) 容器注册中心中托管的 gcr.io/google_containers/nginx-ingress-controller (console.cloud.google.com/gcr/images/google-containers/GLOBAL/nginx-ingress-controller?gcrImageListsize=50) 镜像。该镜像基于 NGINX Ingress 控制器 (github.com/kubernetes/ingress-nginx/blob/master/README.md)。它是目前 Kubernetes 社区支持和维护的仅有的两个控制器之一。另一个是 GLBC (github.com/kubernetes/ingress-gce/blob/master/README.md),它与 Google Compute Engine (GCE) (cloud.google.com/compute/) Kubernetes 托管解决方案一起使用。

默认情况下,Ingress 控制器只配置了两个端点。

如果我们想检查控制器的健康状态,可以向 /healthz 发送请求。

curl -i "http://$IP/healthz"  

输出如下:

HTTP/1.1 200 OK
Server: nginx/1.13.5
Date: Sun, 24 Dec 2017 15:22:20 GMT
Content-Type: text/html
Content-Length: 0
Connection: keep-alive
Strict-Transport-Security: max-age=15724800; includeSubDomains;  

它以状态码 200 OK 响应,表示它是健康的并准备好处理请求。没什么复杂的,接下来我们会进入第二个端点。

Ingress 控制器有一个默认的 catch-all 端点,当请求不匹配其他任何条件时会使用该端点。由于我们还没有创建任何 Ingress 资源,这个端点应该对除 /healthz 外的所有请求返回相同的响应:

curl -i "http://$IP/something" 

输出如下:

HTTP/1.1 404 Not Found
Server: nginx/1.13.5
Date: Sun, 24 Dec 2017 15:36:23 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 21
Connection: keep-alive
Strict-Transport-Security: max-age=15724800; includeSubDomains;

default backend - 404  

我们收到了响应,表示请求的资源未找到。

现在我们准备创建我们的第一个 Ingress 资源。

基于路径创建 Ingress 资源

我们将尝试通过端口 80 使我们的 go-demo-2-api 服务可用。我们将通过定义一个 Ingress 资源并设置规则,将所有路径以 /demo 开头的请求转发到 go-demo-2-api 服务来实现。

让我们来看一下 Ingress 的 YAML 定义:

cat ingress/go-demo-2-ingress.yml  

输出如下:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
 name: go-demo-2
 annotations:
 ingress.kubernetes.io/ssl-redirect: "false"    nginx.ingress.kubernetes.io/ssl-redirect: "false"
spec:
 rules:
 - http:
 paths:
 - path: /demo
 backend:
 serviceName: go-demo-2-api
 servicePort: 8080  

这次,metadata 包含了一个我们之前没有使用过的字段。annotations 部分允许我们向 Ingress 控制器提供额外的信息。如你将很快看到的,Ingress API 规范简洁且有限。这样做是有目的的。该规范 API 仅定义了所有 Ingress 控制器必须的字段。Ingress 控制器所需的所有额外信息都通过 annotations 来指定。这样,控制器背后的社区可以以极快的速度发展,同时仍然提供基本的通用兼容性和标准。

一般注解的列表和支持它们的控制器可以在 Ingress 注解页面找到(github.com/kubernetes/ingress-nginx/blob/master/docs/user-guide/nginx-configuration/annotations.md)。有关 NGINX Ingress 控制器的注解,请访问 NGINX 注解页面(github.com/kubernetes/ingress-nginx/blob/master/README.md),而针对 GCE Ingress 的注解,请访问ingress-gce页面(github.com/kubernetes/ingress-gce)。

你会注意到文档使用了nginx.ingress.kubernetes.io/注解前缀。这是一个相对较新的变化,在撰写本文时,它适用于控制器的测试版本。我们将其与ingress.kubernetes.io/前缀结合使用,以便定义在所有 Kubernetes 版本中都能生效。

我们只指定了一个注解。nginx.ingress.kubernetes.io/ssl-redirect: "false"告诉控制器,我们不希望将所有 HTTP 请求重定向到 HTTPS。我们必须这样做,因为接下来的练习没有 SSL 证书。

在我们对metadata annotations有了一些了解后,我们可以继续研究ingress的规格。

我们在spec部分指定了一组rules。它们用于配置 Ingress 资源。现在,我们的规则是基于http的,具有一个单一的pathbackend。所有以/demo开头的请求都会被转发到go-demo-2-api服务的8080端口。

现在我们已经简要了解了一些 Ingress 配置选项,我们可以继续创建资源了。

kubectl create \
 -f ingress/go-demo-2-ingress.yml

kubectl get \
 -f ingress/go-demo-2-ingress.yml  

后者命令的输出如下:

NAME      HOSTS ADDRESS        PORTS AGE
go-demo-2 *     192.168.99.100 80    29s  

我们可以看到 Ingress 资源已创建。如果在你的情况下,地址为空,不用慌张,它可能需要一些时间才能获取到。

让我们看看是否可以成功地将请求发送到基础路径/demo

IP=$(kubectl get ingress go-demo-2 \
    -o jsonpath="{.status.loadBalancer.ingress[0].ip}")

curl -i "http://$IP/demo/hello"  

输出如下:

HTTP/1.1 200 OK
Server: nginx/1.13.5
Date: Sun, 24 Dec 2017 14:19:04 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 14
Connection: keep-alive
Strict-Transport-Security: max-age=15724800; includeSubDomains;

hello, world!  

状态码200 OK清楚地表明这一次,应用程序通过端口80可以访问。如果这还不足以让你放心,你还可以观察到hello, world!的响应。

我们当前使用的 go-demo-2 服务不再适合我们的 Ingress 配置。使用 type: NodePort,它配置为在所有节点上导出端口 8080。由于我们期望用户通过端口 80 通过 Ingress 控制器访问应用程序,因此可能不需要通过端口 8080 允许外部访问。我们应该切换到 ClusterIP 类型。这样只允许集群内部直接访问该服务,从而通过 Ingress 限制所有外部通信。

我们不能仅仅通过新的定义来更新 Service。一旦 Service 端口被暴露,就不能再取消暴露。我们将删除创建的 go-demo-2 对象并重新开始。除了需要更改 Service 类型之外,这还将使我们有机会将所有内容统一到一个 YAML 文件中。

kubectl delete \
 -f ingress/go-demo-2-ingress.yml

kubectl delete \
 -f ingress/go-demo-2-deploy.yml  

我们删除了与 go-demo-2 相关的对象,现在可以看看统一的定义。

cat ingress/go-demo-2.yml  

我们不会详细讨论新的定义,因为它没有任何显著变化。它将 ingress/go-demo-2-ingress.ymlingress/go-demo-2-deploy.yml 合并为一个文件,并从 go-demo-2 服务中移除了 type: NodePort

kubectl create \
 -f ingress/go-demo-2.yml \
 --record --save-config

curl -i "http://$IP/demo/hello"  

我们从统一的定义中创建了对象,并发送了请求来验证一切是否按预期工作。响应应该是 200 OK,表示一切(仍然)按预期工作。

请注意,Kubernetes 需要几秒钟才能让所有对象按预期运行。如果你操作太快,可能会收到 404 Not Found 响应,而不是 200 OK。如果发生这种情况,你只需要再次发送 curl 请求。

让我们通过一个顺序图来看看,当我们创建 Ingress 资源时发生了什么。

  1. Kubernetes 客户端(kubectl)向 API 服务器发送请求,要求创建在 ingress/go-demo-2.yml 文件中定义的 Ingress 资源。

  2. Ingress 控制器正在监视 API 服务器的新事件。它检测到有一个新的 Ingress 资源。

  3. Ingress 控制器配置了负载均衡器。在这个例子中,它是 nginx,通过 minikube addons enable ingress 命令启用。它修改了 nginx.conf,并加入了所有 go-demo-2-api 端点的值。

图 7-2:创建 Ingress 资源请求后事件的顺序

现在,其中一个应用程序可以通过 Ingress 访问,我们应该将相同的原则应用于另一个应用程序。

让我们来看看 devops-toolkit 应用程序背后所有对象的完整定义。

cat ingress/devops-toolkit.yml  

限制为 Ingress 对象的输出如下:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
 name: devops-toolkit
 annotations:
    ingress.kubernetes.io/ssl-redirect: "false"
    nginx.ingress.kubernetes.io/ssl-redirect: "false"
spec:
 rules:
 - http:
 paths:
 - path: /
 backend:
 serviceName: devops-toolkit
 servicePort: 80
...  

devops-toolkit Ingress 资源与go-demo-2非常相似。唯一显著的区别是path设置为/。它将处理所有请求。如果我们将其改为一个唯一的基础路径(例如/devops-toolkit),那将是一个更好的解决方案,因为这将提供一个唯一的标识符。然而,这个应用程序没有定义基础路径的选项,因此如果尝试在 Ingress 中定义基础路径,它将导致无法检索资源。我们需要编写rewrite规则来代替。例如,我们可以创建一个规则,将路径基础/devops-toolkit重写为/。这样,如果有人发送请求到/devops-toolkit/something,Ingress 会在发送到目标服务之前将其重写为/something。虽然这种做法通常很有用,但我们暂时忽略它。我有更好的计划,直到我决定揭示它们之前,/作为基础path应该足够了。

除了添加 Ingress 之外,定义中还移除了服务中的type: NodePort。这是我们之前在go-demo-2服务上做过的相同操作。我们不需要外部访问该服务。

让我们移除旧的对象,并创建ingress/devops-toolkit.yml文件中定义的对象:

kubectl delete \
 -f ingress/devops-toolkit-dep.yml

kubectl create \
 -f ingress/devops-toolkit.yml \
 --record --save-config  

我们移除了旧的devops-toolkit并创建了新的。

让我们来看一下集群内部运行的 Ingress:

kubectl get ing  

输出如下:

NAME           HOSTS ADDRESS        PORTS AGE
devops-toolkit *     192.168.99.100 80    20s
go-demo-2      *     192.168.99.100 80    58s  

我们可以看到,现在我们有了多个 Ingress 资源。Ingress 控制器(在本例中为 NGINX)会根据这两个资源进行配置。

我们可以定义多个 Ingress 资源来配置单个 Ingress 控制器。

让我们确认这两个应用程序是否可以通过 HTTP(端口80)访问。

open http://$IP

curl "http://$IP/demo/hello"  

第一个命令在浏览器中打开了其中一个应用程序,而另一个则返回了我们熟悉的hello, world!消息。

Ingress 是一种(类似)服务,运行在集群的所有节点上。用户可以向任何节点发送请求,只要请求匹配其中一个规则,它将被转发到相应的服务。

图 7-3:通过 Ingress 控制器访问的应用程序

即使我们可以通过相同的端口(80)向两个应用程序发送请求,这通常并不是最优的解决方案。如果用户能够通过不同的域名访问这些应用程序,他们可能会更高兴。

基于域名创建 Ingress 资源

我们将尝试重构我们的devops-toolkit Ingress 定义,以便控制器能够转发来自devopstoolkitseries.com域名的请求。此更改应该是最小化的,因此我们马上开始处理。

cat ingress/devops-toolkit-dom.yml  

与之前的定义相比,唯一的区别在于新增的条目host: devopstoolkitseries.com。由于这个域名将是唯一可以通过该域访问的应用程序,我们还移除了path: /条目。

让我们apply新的定义:

kubectl apply \
 -f ingress/devops-toolkit-dom.yml \
 --record  

如果我们向应用程序发送一个类似的无域名请求,会发生什么呢?我相信你已经知道答案了,但我们还是来验证一下:

curl -I "http://$IP"  

输出结果如下:

HTTP/1.1 404 Not Found
Server: nginx/1.13.5
Date: Sun, 24 Dec 2017 14:50:29 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 21
Connection: keep-alive
Strict-Transport-Security: max-age=15724800; includeSubDomains;  

没有定义 Ingress 资源来监听/路径。更新后的 Ingress 将仅在请求来自devopstoolkitseries.com时转发请求。

我拥有devopstoolkitseries.com域名,并且不愿意将我的 DNS 注册信息提供给你来配置它指向你 Minikube 集群的 IP。因此,我们无法通过向devopstoolkitseries.com发送请求来进行测试。我们可以做的是通过在请求头中添加该域名来“伪造”它:

curl -I \ 
 -H "Host: devopstoolkitseries.com" \
 "http://$IP"

输出结果如下:

HTTP/1.1 200 OK
Server: nginx/1.13.5
Date: Sun, 24 Dec 2017 14:51:09 GMT
Content-Type: text/html
Content-Length: 12872
Connection: keep-alive
Last-Modified: Thu, 14 Dec 2017 13:59:34 GMT
ETag: "5a3283c6-3248"
Accept-Ranges: bytes  

现在,Ingress 接收到一个看起来像是来自devopstoolkitseries.com域名的请求,它将请求转发给了devops-toolkit服务,后者又将其负载均衡到其中一个devops-toolkit Pod。结果,我们得到了200 OK响应。

为了确保万无一失,我们将验证go-demo-2 Ingress 是否仍然有效。

curl -H "Host: acme.com" \
 "http://$IP/demo/hello"  

我们得到了著名的hello, world!响应,从而确认两个 Ingress 资源都在正常工作。即使我们“伪造”了最后一个请求,仿佛它来自acme.com,它仍然正常工作。由于go-demo-2 Ingress 没有定义任何host,它接受所有以/demo开头的请求。

我们还缺少一些东西,其中之一就是设置默认后端。

创建带有默认后端的 Ingress 资源

在某些情况下,我们可能希望定义一个默认后端。我们可能希望将不符合任何 Ingress 规则的请求转发到默认后端。

让我们来看一个示例:

curl -I -H "Host: acme.com" \
    "http://$IP"

到目前为止,我们的集群中有两组 Ingress 规则。一组接受所有以/demo为基础路径的请求。另一组转发所有来自devopstoolkitseries.com域名的请求。我们刚刚发送的请求并不符合这两组规则,因此响应再次是 404 Not Found。

假设将所有带有错误域名的请求转发到devops-toolkit应用程序是个好主意。当然,这里的“错误域名”是指我们拥有的域名,而不是那些已经包含在 Ingress 规则中的域名:

cat ingress/default-backend.yml  

输出结果如下:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
 name: default
 annotations:
 ingress.kubernetes.io/ssl-redirect: "false"
    nginx.ingress.kubernetes.io/ssl-redirect: "false"
spec:
 backend:
 serviceName: devops-toolkit
 servicePort: 80  

这里没有 Deployment,也没有 Service。这次,我们只创建了一个 Ingress 资源。

spec没有规则,只有一个单一的backend

当 Ingress 的spec没有规则时,它被视为默认后端。因此,它将转发所有不匹配其他 Ingress 资源中的路径和/或域名规则的请求。

我们可以使用默认后端作为默认的404页面,或在其他规则未涵盖的情况下使用。

你会注意到 serviceNamedevops-toolkit。如果我为此创建一个单独的应用程序,示例会更好。冒着被你叫懒的风险,我想说,这个例子并不重要。我们现在只想看到一些不同于 404 Not Found 的响应。

kubectl create \
 -f ingress/default-backend.yml  

我们创建了带有默认后端的 Ingress 资源,现在可以测试它是否真正有效:

curl -I -H "Host: acme.com" \
 "http://$IP"

这次,输出不同了。我们得到了 200 OK,而不是 404 Not Found 响应。

HTTP/1.1 200 OK
...  

接下来做什么?

我们探索了 Ingress 资源和控制器的一些基本功能。具体来说,我们几乎审视了 Ingress API 中定义的所有功能。

我们没有探索的一个显著功能是 TLS 配置。没有它,我们的服务无法提供 HTTPS 请求。为了启用它,我们需要配置 Ingress 来卸载 SSL 证书。

我们没有探索 TLS 的原因有两个。首先,我们没有有效的 SSL 证书。除此之外,我们还没有学习 Kubernetes Secrets。我建议你在决定使用哪个 Ingress 控制器后,自己探索 SSL 配置。而 Secrets 会很快进行解释。

一旦我们将集群迁移到我们将与某个托管供应商一起创建的“真实”服务器上,我们将探索其他 Ingress 控制器。在此之前,你可以通过更详细地阅读 NGINX Ingress 控制器的 文档 来受益。具体来说,我建议你特别关注它的注解部分,文档链接

现在,另一章已经完成,我们将销毁集群,让你的笔记本休息一下,它也该休息了。

minikube delete  

如果你想了解更多关于 Ingress 的信息,请查看 Ingress v1beta1 扩展的 API 文档

在进入下一章之前,我们将探索 Kubernetes Ingress 与 Docker Swarm 中的等效项之间的区别。

图 7-4:到目前为止已探索的组件

Kubernetes Ingress 与 Docker Swarm 的等效项比较

Kubernetes 和 Docker Swarm 都有 Ingress,比较它们并探索差异可能会很有吸引力。虽然从表面上看,似乎这是正确的做法,但问题在于,Ingress 在两者中的工作方式差异很大。

Swarm 的 Ingress 网络更像是 Kubernetes 服务。两者都可以并且应该用于向集群内外的客户端暴露端口。如果我们对这两个产品进行比较,我们会发现 Kubernetes 服务类似于 Docker Swarm 的 Overlay 和 Ingress 网络的组合。Overlay 用于提供集群内应用程序之间的通信,而 Swarm 的 Ingress 是一种 Overlay 网络,用于将端口发布到外部世界。事实是,Swarm 并没有 Kubernetes Ingress 控制器的等效物。也就是说,如果我们不将 Docker 企业版纳入考虑的话

Kubernetes Ingress 等效的功能没有随 Docker Swarm 一起发布,并不意味着无法通过其他方式实现类似的功能。它是可以的。例如,Traefik 可以同时作为 Kubernetes Ingress 控制器,也可以作为动态的 Docker Swarm 代理。无论选择哪个调度程序,它提供的功能或多或少是相同的。如果你在寻找 Swarm 特定的替代方案,你可能会选择 Docker Flow Proxy (proxy.dockerflow.com/)(由我亲自编写)。

总的来说,一旦我们停止比较两个平台上的 Ingress,并开始寻找相似的功能集,我们可以迅速得出结论,Kubernetes 和 Docker Swarm 都提供了相似的功能集。我们可以使用路径和域名将流量从一组端口(例如,80443)路由到匹配规则的特定应用程序。两者都允许我们卸载 SSL 证书,并且都提供了使所有必要配置动态化的解决方案。

如果从功能层面上讲,两个平台提供了非常相似的功能集,那么在仅考虑动态路由和负载均衡时,我们能得出结论说两个调度程序没有本质区别吗?我认为不能。一些重要的区别可能并非功能性方面的。

Kubernetes 提供了一个明确定义的 Ingress API,第三方解决方案可以利用它来提供无缝的体验。让我们看一个例子:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
 name: devops-toolkit
spec:
 rules:
 - host: devopstoolkitseries.com
 http:
 paths:
 - backend:
 serviceName: devops-toolkit
 servicePort: 80  

这个定义可以与许多不同的解决方案一起使用。在这个 Ingress 资源后面可以是 nginx、voyager、haproxy 或 trafficserver Ingress 控制器。它们都使用相同的 Ingress API 来推断应该使用哪些服务进行转发算法。即使是以与常用的 Ingress 注解不兼容著称的 Traefik,也会接受这个 YAML 定义。

拥有一个明确定义的 API 仍然为创新留下了很大的空间。我们可以使用 annotations 提供我们的 Ingress 控制器可能需要的额外信息。某些注解在不同的解决方案中使用,而其他注解则是特定于某个控制器的。

总的来说,Kubernetes Ingress 控制器结合了一个明确定义(且简单)的规范,所有 Ingress 控制器都必须接受,并且同时通过元数据中指定的自定义 annotations 提供了创新的空间。

Docker Swarm 没有类似 Ingress API 的东西。类似 Kubernetes Ingress 控制器的功能可以通过使用 Swarm Kit 或使用 Docker API 来实现。问题在于没有定义的 API 应该遵循第三方解决方案,因此每个解决方案都是独立的世界。例如,了解 Traefik 的工作原理并不能帮助你在尝试切换到 Docker Flow Proxy 时多少有所帮助。每个解决方案都是孤立运行的,操作方式各不相同。由于 Docker 没有专注于制定标准,因此没有标准可循。

Docker 对调度的方法完全基于内置到 Docker Server 中的功能。只有一种方法可以做到这一点。通常,这提供了非常用户友好和可靠的体验。如果 Swarm 能够满足你的需求,那么它是一个很好的选择。但问题在于当你需要更多功能时,可能会在寻找 Docker Swarm 解决方案时遇到困难。

当我们将 Kubernetes 的 ReplicaSets、Services 和 Deployments 与它们在 Docker Swarm 中的等效物进行比较时,功能级别上没有实质性差异。从用户体验的角度来看,Swarm 提供了更好的结果。它的 YAML 文件更为简单和简洁。仅仅考虑这些功能,Swarm 比 Kubernetes 更具优势。这一次情况不同了。

Kubernetes 的策略主要基于 API。一旦定义了某种资源类型,任何解决方案都可以利用它来提供所需的功能。这在 Ingress 方面尤为明显。我们可以在众多解决方案中进行选择。其中一些由 Kubernetes 社区开发和维护(例如 GLBC 和 NGINX Ingress 控制器),而其他则由第三方提供。无论解决方案来自何方,都遵循相同的 API 和 YAML 定义。因此,我们有更多的解决方案可供选择,而不会牺牲资源定义的一致性。

如果我们将比较局限于 Kubernetes Ingress 控制器及其在 Docker Swarm 中的等效物,前者显然是胜者。假设当前策略持续下去,Docker 需要将第 7 层转发添加到 Docker Server 中,才能在这一前沿重新进入竞争。如果我们仅限于这一组功能,Kubernetes 通过其 Ingress API 赢得胜利,它不仅打开了内部解决方案的大门,还包括第三方控制器。

我们还处于起步阶段。还有许多值得比较的特性。我们只是触及了表面。请继续关注更多信息。

第八章:使用卷访问主机的文件系统

没有状态的系统是无法存在的。尽管目前有将应用程序开发为无状态的趋势,但我们仍然需要处理状态问题。有数据库和其他有状态的第三方应用程序。无论我们做什么,都需要确保无论容器、Pod,甚至整个节点发生什么情况,状态都能够保持。

大多数时候,有状态的应用程序会将其状态存储在磁盘上。这给我们带来了一个问题。如果容器崩溃,kubelet 会重启它。问题在于,它会基于相同的镜像创建一个新的容器。崩溃容器内积累的所有数据将会丢失。

Kubernetes 卷解决了容器崩溃时保持状态的需求。从本质上讲,卷是指向文件和目录的引用,这些文件和目录对构成 Pod 的容器可访问。不同类型的 Kubernetes 卷之间的主要区别在于这些文件和目录是如何创建的。

虽然卷的主要用途是保持状态,但其实还有很多其他用途。例如,我们可以使用卷来访问主机上运行的 Docker 套接字,或者我们可以使用它们来访问存储在主机文件系统中的配置文件。

我们可以将卷描述为一种访问文件系统的方式,该文件系统可能运行在同一主机或其他地方。无论该文件系统在哪里,它都是容器挂载卷时外部的。有人挂载卷的原因有很多,其中状态保持只是其中之一。

Kubernetes 支持超过二十五种卷类型。要逐一讲解它们需要花费大量时间。此外,即使我们愿意这么做,很多卷类型都是特定于某个托管商的。例如,awsElasticBlockStore 仅与 AWS 配合使用,azureDiskazureFile 仅与 Azure 配合使用,依此类推。我们将把探索范围限制在 Minikube 中可以使用的卷类型。你应该能够将这些知识推断到适用于你所选择的托管商的卷类型。

让我们开始吧。

创建集群

这次,在准备创建 Minikube 集群时,我们将执行一个额外的操作。

本章中的所有命令都可以在 08-volume.sh (gist.github.com/vfarcic/5acafb64c0124a1965f6d371dd0dedd1) Gist 中找到。

cd k8s-specs 

git pull 

cp volume/prometheus-conf.yml \  
    ~/.minikube/files 

我们需要 Minikube 虚拟机内的文件。它启动时,会将主机上 ~/.minikube/files 目录中的所有文件复制到虚拟机中的 /files 目录。

根据你的操作系统,~/.minikube/files 目录可能在其他地方。如果是这种情况,请调整前面的命令。

现在文件已复制到共享目录,我们可以重复之前做过的相同过程。请注意,我们已经添加了上一章节的步骤,以启用 ingress 插件。

minikube start --vm-driver=virtualbox

minikube addons enable ingress

kubectl config current-context

现在 Minikube 集群已启动,我们可以探索第一种卷类型。

通过 hostPath 卷访问主机的资源

无论如何,最终我们都需要构建镜像。一种简单的解决方案是直接在服务器上执行docker image build命令。然而,这可能会导致问题。在单个主机上构建镜像意味着资源利用不均衡,并且存在单点故障。如果我们能够在 Kubernetes 集群内的任何地方构建镜像,岂不是更好吗?

我们可以基于docker镜像创建一个 Pod,而不是执行docker image build命令。Kubernetes 会确保 Pod 被调度到集群中的某个位置,从而更好地分配资源使用。

让我们从一个基础的例子开始。如果我们能够列出镜像,我们就能证明在容器内运行 docker 命令是可行的。因为从 Kubernetes 的角度来看,Pod 是最小的实体,所以我们将运行 Pod。

kubectl run docker \
 --image=docker:17.11 \
    --restart=Never \
    docker image ls

kubectl get pods --show-all  

我们创建了一个名为docker的 Pod,并基于官方的docker镜像。由于我们想执行一个一次性的命令,因此指定它应该Never重启。最后,容器命令是docker image ls。第二个命令列出了集群中所有的 Pod(包括失败的 Pod)。

后者命令的输出如下:

NAME   READY STATUS RESTARTS AGE
docker 0/1   Error  0        1m  

输出应该显示状态为Error,这表示我们运行的容器存在问题。如果你的状态还不是Error,可能是 Kubernetes 仍在拉取镜像。在这种情况下,请稍等片刻,然后重新执行kubectl get pods命令。

让我们看一下容器的日志:

kubectl logs docker  

输出如下:

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?  

Docker 由两个主要部分组成。一个是客户端,一个是服务器。当我们执行docker image ls时,我们调用了客户端,它通过 API 与服务器进行通信。问题在于,Docker 服务器并没有在容器中运行。我们应该做的是告诉客户端(在容器内)使用已经在主机(Minikube VM)上运行的 Docker 服务器。

默认情况下,客户端通过位于/var/run/docker.sock的套接字向服务器发送指令。如果我们将该文件从主机挂载到容器内,就可以实现我们的目标。

在尝试启用容器内的 Docker 客户端与主机上的 Docker 服务器之间的通信之前,我们将删除几分钟前创建的 Pod:

kubectl delete pod docker  

让我们看一下存储在volume/docker.yml中的 Pod 定义:

cat volume/docker.yml

输出如下:

apiVersion: v1 
kind: Pod 
metadata: 
  name: docker 
spec: 
  containers: 
  - name: docker 
    image: docker:17.11 
    command: ["sleep"] 
    args: ["100000"] 
    volumeMounts: 
    - mountPath: /var/run/docker.sock 
      name: docker-socket
  volumes: 
  - name: docker-socket 
    hostPath: 
      path: /var/run/docker.sock 
      type: Socket 

部分定义与我们之前执行的kubectl run命令非常相似。唯一显著的区别是在volumeMountsvolumes部分。

volumeMounts 字段相对简单,不论我们使用哪种类型的卷,都是相同的。在这一部分,我们指定了 mountPath 和卷的名称。前者是我们期望在容器内挂载的路径。你会注意到,我们并没有在 VolumeMounts 部分指定卷的类型或其他任何细节。相反,我们仅仅引用了一个名为 docker-socket 的卷。

每种类型的卷配置在 volumes 部分中定义。在本例中,我们使用的是 hostPath 卷类型。

hostPath 允许我们将主机上的文件或目录挂载到 Pod,并通过这些 Pod 挂载到容器。在讨论此类型的实用性之前,我们先简单讨论一下在什么情况下它并不是一个好的选择。

不要使用 hostPath 来存储应用的状态。由于它将文件或目录从主机挂载到 Pod,因此它不是容错的。如果服务器失败,Kubernetes 会将 Pod 调度到健康的节点,而状态将会丢失。

对于我们的使用场景,hostPath 非常合适。我们不是用它来保存应用的状态,而是为了访问与 Pod 运行在同一主机上的 Docker 服务。

hostPath 类型只有两个字段。path 表示我们希望从主机挂载的文件或目录。由于我们想挂载一个套接字,因此相应地设置了 type。当然,也可以使用其他类型。

Directory 类型将挂载主机上的目录。该目录必须存在于给定的路径上。如果不存在,我们可以改用 DirectoryOrCreate 类型,后者具有相同的目的。区别在于,DirectoryOrCreate 会在主机上不存在目录时自动创建该目录。

FileFileOrCreate 与其 Directory 对应类型类似。唯一的区别是这次我们会挂载文件,而不是目录。

其他支持的类型包括 SocketCharDeviceBlockDevice。它们应该是自解释的。如果你不知道字符设备或块设备是什么,那么你可能不需要这些类型。

最后但同样重要的是,我们修改了命令和参数为 sleep 100000。这将给我们更多自由,因为我们可以创建 Pod,进入它唯一的容器,并尝试不同的命令。

让我们创建 Pod,并检查这次是否可以从容器内部执行 Docker 命令:

kubectl create \
 -f volume/docker.yml

由于镜像已经拉取,启动 Pod 应该几乎是瞬时的。

让我们看看是否能检索到 Docker 镜像列表:

kubectl exec -it docker \
 -- docker image ls \
 --format "{{.Repository}}" 

我们执行了 docker image ls 命令,并通过将输出格式限制为 Repository 来缩短结果。输出如下:

Docker
gcr.io/google_containers/nginx-ingress-controller
gcr.io/google_containers/k8s-dns-sidecar-amd64
gcr.io/google_containers/k8s-dns-kube-dns-amd64
gcr.io/google_containers/k8s-dns-dnsmasq-nanny-amd64
gcr.io/google_containers/kubernetes-dashboard-amd64
gcr.io/google_containers/kubernetes-dashboard-amd64
gcr.io/google-containers/kube-addon-manager
gcr.io/google_containers/defaultbackend   
gcr.io/google_containers/pause-amd64

即使我们在容器内执行了 docker 命令,输出仍清楚地显示了来自主机的镜像。我们证明了将 Docker 套接字(/var/run/docker.sock)作为卷挂载可以实现容器内的 Docker 客户端与主机上运行的 Docker 服务器之间的通信。

图 8-1:HostPath 挂载在容器内

让我们进入容器,看看能否构建 Docker 镜像。

kubectl exec -it docker sh

要构建镜像,我们需要一个 Dockerfile 和应用程序的源代码。我们将继续使用 go-demo-2 作为示例,因此我们的第一步是克隆仓库:

apk add -U git

git clone \ 
    https://github.com/vfarcic/go-demo-2.git 
cd go-demo-2  

我们使用 apk add 安装了 gitdocker 和许多其他镜像使用 alpine 作为基础镜像。如果你不熟悉 alpine,它是一个非常精简且高效的基础镜像,我强烈建议在构建自己的镜像时使用它。像 debiancentosubunturedhat 和类似的基础镜像,常常因为误解容器工作原理而成为糟糕的选择。

alpine 使用 apk 包管理器,因此我们调用它安装了 git。接下来,我们克隆了 vfarcic/go-demo-2 仓库,最后进入了 go-demo-2 目录:

让我们快速看一下 Dockerfile

cat Dockerfile  

输出结果如下:

FROM golang:1.9 AS build 
ADD . /src 
WORKDIR /src 
RUN go get -d -v -t 
RUN go test --cover -v ./... --run UnitTest 
RUN go build -v -o go-demo 

FROM alpine:3.4 
MAINTAINER      Viktor Farcic viktor@farcic.com 

RUN mkdir /lib64 &amp;&amp; ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2 

EXPOSE 8080 
ENV DB db 
CMD ["go-demo"] 
HEALTHCHECK --interval=10s CMD wget -qO- localhost:8080/demo/hello 

COPY --from=build /src/go-demo /usr/local/bin/go-demo 
RUN chmod +x /usr/local/bin/go-demo 

由于本书专注于 Kubernetes,我们不会详细探讨这个 Dockerfile 的内容,只评论它使用了 Docker 的多阶段构建。第一阶段下载依赖,运行单元测试,并构建二进制文件。第二阶段重新开始,构建一个新镜像,并将之前阶段构建的 go-demo 二进制文件复制过来。

我真心希望你对 Docker 已经非常熟练,因此无需进一步解释镜像构建。如果情况并非如此,你可能需要查阅官方文档或我之前的书籍之一。本书专注于 Kubernetes。

让我们测试构建镜像是否确实可行。

docker image build \ 
    -t vfarcic/go-demo-2:beta . 

docker image ls \ 
    --format "{{.Repository}}" 

我们执行了 docker image build 命令,然后运行了 docker image ls。后者的输出如下:

vfarcic/go-demo-2
<none>
golang
docker
alpine
gcr.io/google_containers/nginx-ingress-controller
gcr.io/google_containers/k8s-dns-sidecar-amd64
gcr.io/google_containers/k8s-dns-kube-dns-amd64
gcr.io/google_containers/k8s-dns-dnsmasq-nanny-amd64
gcr.io/google_containers/kubernetes-dashboard-amd64
gcr.io/google_containers/kubernetes-dashboard-amd64
gcr.io/google-containers/kube-addon-manager
gcr.io/google_containers/defaultbackend
gcr.io/google_containers/pause-amd64  

如果我们与之前的 docker image ls 输出进行对比,会发现这一次列出了几个新镜像。golangalpine 镜像作为每个构建阶段的基础。而 vfarcic/go-demo-2 则是我们构建的结果。最后,<none> 只是构建过程中的残余,可以安全移除。

docker system prune -f 

docker image ls \  
    --format "{{.Repository}}" 

docker system prune 命令会移除所有未使用的资源。至少是那些由 Docker 创建但未使用的资源。我们通过再次执行 docker image ls 来验证这一点。这一次,我们可以看到 <none> 镜像已经消失。

我们将销毁 docker Pod,并探索 hostPath 卷类型的其他用法:

Exit

kubectl delete \
    -f volume/docker.yml

hostPath 是访问主机资源(如 /var/run/docker.sock/dev/cgroups 等)的好方法。前提是我们要访问的资源和 Pod 在同一节点上。

让我们看看是否能找到 hostPath 的其他用例。

使用 hostPath 卷类型注入配置文件

我们即将首次部署 Prometheus(prometheus.io/)(在本书中)。我们不会深入讨论该应用,除了说它非常棒,并且你应该考虑将其用于监控和告警需求。为了避免让你失望,我必须告诉你,Prometheus 并不在本章的范围内,可能也不在本书的范围内。我们仅仅是用它来演示一些 Kubernetes 概念。我们并不打算学习如何操作它。

让我们看看应用的定义:

cat volume/prometheus.yml  

输出如下:

apiVersion: extensions/v1beta1 
kind: Ingress 
metadata: 
  name: Prometheus 
  annotations: 
    ingress.kubernetes.io/ssl-redirect: "false"
    nginx.ingress.kubernetes.io/ssl-redirect: "false" 
spec: 
  rules: 
  - http: 
      paths: 
      - path: /Prometheus 
        backend: 
          serviceName: Prometheus 
          servicePort: 9090 

--- 

apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: Prometheus 
spec: 
  selector: 
    matchLabels: 
      type: monitor 
      service: Prometheus 
  strategy: 
    type: Recreate 
  template: 
    metadata: 
      labels: 
        type: monitor 
        service: Prometheus 
    spec: 
      containers: 
      - name: Prometheus 
        image: prom/prometheus:v2.0.0 
        command: 
        - /bin/Prometheus 
        args: 
        - "--config.file=/etc/prometheus/prometheus.yml" 
        - "--storage.tsdb.path=/prometheus" 
        - "--web.console.libraries=/usr/share" 
        - "--web.external-url=http://192.168.99.100/prometheus" 

--- 

apiVersion: v1 
kind: Service 
metadata: 
  name: Prometheus 
spec: 
  ports: 
  - port: 9090 
  selector: 
    type: monitor 
    service: Prometheus 

那个 YAML 文件并没有什么真正的新内容。它定义了一个 Ingress、一个部署和一个服务。不过,我们可能需要修改一件事。如果我们想更改基础路径,Prometheus 需要一个完整的external-url。目前,它被设置为我 Minikube 虚拟机的 IP。在你的情况下,那个 IP 可能不同。我们将通过添加一些sed“魔法”来修复它,确保 IP 与您的 Minikube 虚拟机的 IP 匹配。

cat volume/prometheus.yml | sed -e \  
    "s/192.168.99.100/$(minikube ip)/g" \  
    | kubectl create -f - \ 
    --record --save-config 

kubectl rollout status deploy prometheus 

我们输出了volume/prometheus.yml文件的内容,使用sed命令将硬编码的 IP 替换为实际的 Minikube 实例的值,然后将结果传递给kubectl create。请注意,这次的create命令使用了破折号(-)而不是文件路径。这表明应该使用stdin

在创建应用后,我们使用kubectl rollout status命令确认部署已完成。

现在我们可以在浏览器中打开 Prometheus 了。

open "http://$(minikube ip)/prometheus"  

一开始,应用似乎运行正常。然而,由于目标是应用的关键部分,我们应该检查它们。对于不熟悉 Prometheus 的人来说,它从目标(外部数据源)拉取数据,默认情况下,只配置了一个目标:Prometheus 本身。Prometheus 会始终从这个目标拉取数据,除非我们另行配置。

让我们看看它的目标。

open "http://$(minikube ip)/prometheus/targets"  

出现了一些问题。默认的目标无法访问。在我们开始惊慌之前,我们应该仔细查看其配置。

open "http://$(minikube ip)/prometheus/config"  

问题出在metrics_path字段上。默认情况下,它被设置为/metrics。然而,由于我们已经将基础路径更改为/prometheus,该字段的值应该为/prometheus/metrics

长话短说,我们必须更改 Prometheus 的配置。

例如,我们可以进入容器,更新配置文件,并发送重新加载请求给 Prometheus。这个解决方案非常糟糕,因为它只会持续到下次更新应用,或者直到容器崩溃,Kubernetes 决定重新调度它为止。

让我们探索一下替代方案。例如,我们也可以使用hostPath卷来实现这一点。如果我们能确保正确的配置文件在虚拟机中,Pod 就可以将其挂载到prometheus容器。让我们试试看。

cat volume/prometheus-host-path.yml

输出(仅显示相关部分)如下:

apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: Prometheus 
spec: 
  selector: 
    ... 
    spec: 
      containers: 
        ... 
        volumeMounts: 
        - mountPath: /etc/prometheus/prometheus.yml 
          name: prom-conf 
      volumes: 
      - name: prom-conf 
        hostPath: 
          path: /files/prometheus-conf.yml 
          type: File 
... 

与之前的定义相比,唯一显著的区别在于增加了 volumeMountsvolumes 字段。我们使用的是与之前相同的模式,只不过这次 type 设置为 File。一旦我们应用了这个 Deployment,主机上的 /files/prometheus-conf.yml 文件将在容器内作为 /etc/prometheus/prometheus.yml 可用。

如果你还记得,我们将一个文件复制到 ~/.minikube/files 目录,并且 Minikube 将其复制到虚拟机中的 /files 目录。

在某些情况下,文件可能最终被复制到虚拟机的根目录(/),而不是 /files。如果发生了这种情况,请进入虚拟机(minikube ssh),并通过执行以下命令将文件移到 /files(前提是 /files 目录不存在或为空)。

minikube ssh

sudo mkdir /files

sudo mv /prometheus-conf.yml  /files/

exit  

现在是时候查看文件的内容了。

minikube ssh sudo chmod +rw \ 
    /files/prometheus-conf.yml 

minikube ssh cat \  
    /files/prometheus-conf.yml 

我们更改了文件的权限并显示了其内容。

输出如下:

global: 
  scrape_interval:     15s 

scrape_configs: 
  - job_name: Prometheus 
    metrics_path: /prometheus/metrics 
    static_configs: 
      - targets: 
        - localhost:9090 

这个配置几乎与 Prometheus 默认使用的配置相同。唯一的区别是在 metrics_path,它现在指向 /prometheus/metrics

让我们看看使用新配置的 Prometheus 是否按预期工作:

cat volume/prometheus-host-path.yml \ 
 | sed -e \
 "s/192.168.99.100/$(minikube ip)/g" \
 | kubectl apply -f -

kubectl rollout status deploy Prometheus

open http://$(minikube ip)/prometheus/targets  

我们应用了新的定义(在执行了 sed 的“魔法”之后),等待 rollout 完成,然后在浏览器中打开 Prometheus 目标。这次,使用更新的配置,Prometheus 成功地从当前配置的唯一目标拉取数据:

图 8-2:Prometheus 目标界面

接下来的逻辑步骤是为 Prometheus 配置额外的目标。具体来说,你可能想配置它以获取已经通过 Kubernetes API 提供的指标。不过,我们不会这么做。首先,本章并不是关于监控和告警的。第二,也是更重要的原因是,使用 hostPath 卷类型来提供配置不是一个好主意。

hostPath 卷将主机上的一个目录映射到 Pod 运行的地方。使用它来“注入”配置文件到容器中,意味着我们必须确保该文件在集群的每个节点上都存在。

使用 Minikube 可能会产生误导。由于我们运行的是单节点集群,意味着我们运行的每个 Pod 都会调度到一个节点上。将配置文件复制到这个单节点,如我们在示例中所做的,确保它可以在任何 Pod 中挂载。然而,一旦我们向集群中添加更多节点,就会出现副作用。我们需要确保集群中的每个节点都有我们想要挂载的相同文件,因为我们无法预测单个 Pod 会被调度到哪里。这会带来过多不必要的工作和额外的复杂性。

另一种解决方案是将 NFS 驱动器挂载到所有节点,并在那里存储文件。这可以确保文件在所有节点上可用,只要我们忘记在每个节点上挂载 NFS。

另一种解决方案可能是创建一个自定义的 Prometheus 镜像。它可以基于官方镜像,并且只需一个COPY指令来添加配置。这个解决方案的优势在于镜像将是完全不可变的。它的状态不会被不必要的卷挂载污染。任何人都可以运行该镜像并期待相同的结果。这是我更倾向的解决方案。然而,在某些情况下,你可能希望使用稍微不同的配置部署相同的应用程序。在这些情况下,我们是否应该回退到在每个节点上挂载一个 NFS 驱动器并继续使用hostPath

即使挂载 NFS 驱动器可以解决一些问题,它仍然不是一个很好的解决方案。为了从 NFS 挂载文件,我们需要使用nfskubernetes.io/docs/concepts/storage/volumes/#nfs)卷类型,而不是hostPath。即使如此,它仍然是一个次优解决方案。一个更好的方法是使用configMap。我们将在下一章中探讨这个问题。

确实要使用hostPath来挂载主机资源,如/var/run/docker.sock/dev/cgroups。不要使用它来注入配置文件或存储应用程序的状态。

我们将继续讨论一种更为特殊的卷类型。但在此之前,我们将删除当前运行的 Pod:

kubectl delete \  
    -f volume/prometheus-host-path.yml 

使用 gitRepo 挂载 Git 仓库

gitRepo卷类型可能不会出现在你列出的前三大卷类型中。或者,也可能会。它完全取决于你的使用场景。我喜欢它,因为它展示了如何将卷的概念扩展到新的创新解决方案。

让我们通过volume/github.yml定义来查看其实际效果:

cat volume/github.yml

输出如下:

apiVersion: v1 
kind: Pod 
metadata: 
  name: github 
spec: 
  containers: 
  - name: github 
    image: docker:17.11 
    command: ["sleep"] 
    args: ["100000"] 
    volumeMounts: 
    - mountPath: /var/run/docker.sock 
      name: docker-socket 
    - mountPath: /src 
      name: github 
  volumes: 
  - name: docker-socket 
    hostPath: 
      path: /var/run/docker.sock 
      type: Socket 
  - name: github 
    gitRepo: 
      repository: https://github.com/vfarcic/go-demo-2.git 
      directory: . 

这个 Pod 定义与volume/docker.yml非常相似。唯一显著的区别是我们添加了第二个volumeMount。它将在容器内部挂载/src目录,并使用名为github的卷。卷的定义非常简单。gitRepo类型定义了 Gitrepositorydirectory。如果我们省略后者,仓库将作为/src/go-demo-2挂载。

gitRepo卷类型允许使用第三个字段,但我们没有使用它。我们本可以设置仓库的特定revision。不过,出于演示目的,HEAD应该足够了。

让我们创建 Pod。

kubectl create \  
    -f volume/github.yml 

现在我们已经创建了 Pod,我们将进入其唯一的容器,并检查gitRepo是否如预期工作:

kubectl exec -it github sh 

cd /src 

ls -l 

我们进入 Pod 的容器,切换到/src目录,并列出了其中的所有文件和目录。这证明了gitRepovfarcic/go-demo-2GitHub 仓库的内容挂载为一个卷。

图 8-3:GitHub 仓库挂载在容器内

由于 Pod 容器基于docker镜像,并且插座也已挂载,我们应该能够使用由gitRepo卷提供的源代码来构建镜像:

docker image build \  
    -t vfarcic/go-demo-2:beta . 

这一次,构建应该非常快速,因为我们已经在主机上有了相同的镜像,而且在此期间源代码没有发生变化。你应该能看到每个构建层都有一个Using cache的通知。

既然我们已经证明了这一点,接下来让我们退出容器并移除 Pod:

Exit 

kubectl delete \  
    -f volume/github.yml 

gitRepo是一个小巧的新增功能,它并没有为我们节省大量工作,也没有提供什么真正特别的功能。我们可以通过使用带有git的镜像并执行简单的git clone命令来达到相同的效果。不过,这种卷类型在某些情况下可能会派上用场。我们在 YAML 文件中定义的内容越多,就越不依赖临时命令。这样,我们就能朝着完全文档化的流程迈进。

gitRepo卷类型帮助我们将git命令(例如git clone)移到 YAML 定义中。它还消除了容器中需要git二进制文件的需求。虽然gitRepo可能并不总是最佳选择,但它确实是值得考虑的一个选项。

通过 emptyDir 卷类型持久化状态

这一次,我们将部署 Jenkins,看看会遇到什么挑战。

我们来看看volume/jenkins.yml的定义:

cat volume/jenkins.yml  

输出结果如下:

apiVersion: extensions/v1beta1 
kind: Ingress 
metadata: 
  name: Jenkins 
  annotations: 
    ingress.kubernetes.io/ssl-redirect: "false"
    nginx.ingress.kubernetes.io/ssl-redirect: "false" 
spec: 
  rules: 
  - http: 
      paths: 
      - path: /Jenkins 
        backend: 
          serviceName: Jenkins 
          servicePort: 8080 

--- 

apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: Jenkins 
spec: 
  selector: 
    matchLabels: 
      type: master 
      service: Jenkins 
  strategy: 
    type: Recreate 
  template: 
    metadata: 
      labels: 
        type: master 
        service: Jenkins 
    spec: 
      containers: 
      - name: Jenkins 
        image: vfarcic/Jenkins 
        env: 
        - name: JENKINS_OPTS 
          value: --prefix=/jenkins 

--- 

apiVersion: v1 
kind: Service 
metadata: 
  name: Jenkins 
spec: 
  ports: 
  - port: 8080 
  selector: 
    type: master 
    service: jenkins 

这个 YAML 文件没有什么特别的。它定义了一个路径为/jenkins的 Ingress,一个 Deployment 和一个 Service。我们不会浪费时间在这些上,而是继续前进并创建这些对象。

kubectl create \  
    -f volume/jenkins.yml \  
    --record --save-config 

kubectl rollout status deploy jenkins 

我们创建了对象并等待进程完成。现在,我们可以在我们选择的浏览器中打开 Jenkins:

open "http://$(minikube ip)/jenkins" 

Jenkins UI 已经打开,确认应用程序已正确部署。Jenkins 的主要功能是执行作业,因此理应创建一个作业:

open "http://$(minikube ip)/jenkins/newJob" 

请在项目名称字段中输入test,选择Pipeline作为类型,然后点击“OK”按钮。

不需要让 Pipeline 执行任何特定的任务。现在,只要你保存作业,就应该没有问题。

让我们探索一下如果 Jenkins 容器中的主进程终止会发生什么:

POD_NAME=$(kubectl get pods \  
    -l service=jenkins,type=master \  
    -o jsonpath="{.items[*].metadata.name}") 

kubectl exec -it $POD_NAME kill 1 

我们检索到了 Pod 的名称,并使用它在唯一的容器中执行了kill 1命令。结果是模拟了一个失败。很快,Kubernetes 检测到这个失败并重新创建了容器。让我们再检查一遍。

kubectl get pods 

输出结果如下:

NAME                     READY STATUS  RESTARTS AGE
jenkins-76d59945d8-zcz8m 1/1   Running 1        12m

我们看到有一个容器正在运行。由于我们杀掉了主进程,并且因此杀死了第一个容器,重启次数增加到了 1。

让我们回到 Jenkins UI,检查一下作业发生了什么。我相信你已经知道答案了,但我们还是再确认一遍。

open "http://$(minikube ip)/jenkins" 

正如预期的那样,我们创建的任务消失了。当 Kubernetes 重新创建失败的容器时,它是从相同的镜像创建了一个新的容器。在运行中的容器内生成的所有内容都不复存在。我们恢复到了初始状态:

让我们看看稍作更新的 YAML 定义:

cat volume/jenkins-empty-dir.yml  

输出内容(只包括相关部分)如下:

... 
kind: Deployment 
... 
spec: 
  ... 
  template: 
    ... 
    spec: 
      containers: 
        ... 
        volumeMounts: 
        - mountPath: /var/jenkins_home 
          name: jenkins-home 
      volumes: 
      - emptyDir: {} 
        name: jenkins-home 
... 

我们添加了一个引用 jenkins-home 卷的挂载。此时,卷类型是 emptyDir。我们很快会讨论这种新的卷类型。但是,在我们深入解释之前,我们先体验一下它的效果:

kubectl apply \  
    -f volume/jenkins-empty-dir.yml 

kubectl rollout status deploy jenkins 

我们应用了新的定义,并等待部署完成。

现在我们可以打开新的 Jenkins 任务屏幕,重复之前的操作:

open "http://$(minikube ip)/jenkins/newJob" 

请在项目名称字段中输入 test,选择 Pipeline 作为类型,点击确定按钮,然后点击保存按钮完成操作。

现在我们将停止容器并观察会发生什么:

POD_NAME=$(kubectl get pods \  
    -l service=jenkins,type=master \  
    -o jsonpath="{.items[*].metadata.name}") 

kubectl exec -it $POD_NAME kill 1 

kubectl get pods 

输出应该显示有一个正在运行的容器,换句话说,Kubernetes 已检测到故障并创建了一个新的容器。

最后,让我们再一次打开 Jenkins 的主页:

open "http://$(minikube ip)/jenkins" 

这次,test 任务仍然存在。即使容器失败,应用程序的状态也得以保留,并且 Kubernetes 创建了一个新的容器:

图 8-4:带有持久化状态的 Jenkins

现在我们来讨论一下 emptyDir 卷。它与我们到目前为止探索的那些卷有显著不同。

当 Pod 被分配到一个节点时,会创建一个 emptyDir 卷。只要 Pod 继续在该服务器上运行,该卷就会一直存在。

这意味着 emptyDir 可以在容器失败时继续存活。当容器崩溃时,Pod 不会从节点中移除。相反,Kubernetes 会在同一个 Pod 内重新创建失败的容器,从而保留 emptyDir 卷。总的来说,这种卷类型只具有部分容错能力。

如果 emptyDir 并不完全具有容错性,你可能会想知道为什么我们一开始要讨论它。

emptyDir 卷类型是我们在不使用网络驱动器的情况下能接近容错卷的最接近类型。由于我们没有网络驱动器,所以我们不得不使用 emptyDir 作为最接近容错持久化的卷类型。

当你开始部署第三方应用程序时,你会发现许多应用程序都附带了推荐的 YAML 定义。如果你仔细观察,会注意到很多使用了 emptyDir 卷类型。并不是说 emptyDir 是最好的选择,而是它完全取决于你的需求、托管服务商、基础设施以及其他许多因素。没有一种“通用适用”的持久性和容错性卷类型。另一方面,emptyDir 总是能工作。由于它没有外部依赖,因此可以将其作为示例,假设人们会根据需要更改为更适合自己的卷类型。

有一个不成文的假设,即emptyDir是用于测试目的的,并将在正式进入生产环境之前更换为其他方案。

只要我们使用 Minikube 来创建 Kubernetes 集群,就会使用emptyDir作为持久卷的解决方案。不要灰心。稍后,一旦我们转向更“正式”的集群配置,我们将探索更好的持久化状态的方案。目前,你已经初步体验过。真正的(且持久的)内容将在之后呈现。

现在怎么办?

除了emptyDir,我们在本章中展示的卷类型选择并不仅仅是基于它们在 Minikube 集群中使用的能力。这三种卷类型中的每一种都将在接下来的章节中成为关键部分。我们将使用hostPath从容器内部访问 Docker 服务器。gitRepo卷类型在我们开始设计持续部署管道时将变得非常重要。只要我们使用 Minikube,emptyDir类型就会是必需的。在我们找到更好的 Kubernetes 集群创建方案之前,emptyDir将继续在我们的 Minikube 示例中使用。

我们才刚刚触及卷的表面。至少还有两种卷类型我们应该在 Minikube 中进一步探索,另外一种(或更多)则是当我们转向其他集群创建方案时需要探索的内容。整个书籍中将要探索的卷是一个足够庞大的主题,值得单独成章,或者,正如我们已经提到的,需要我们摆脱 Minikube。现在,我们将销毁集群并休息一下。

minikube delete  

如果你想了解更多关于卷的信息,请参考卷 v1 核心的 API 文档(v1-9.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.9/#volume-v1-core)。

下一章将专注于configMap卷类型。希望它能够解决一些问题,并提供比本章中使用的方案更好的解决方案。ConfigMap 值得单独成章,所以它将有一章内容。

图 8-5:迄今为止探索的组件

第九章:使用 ConfigMap 注入配置文件

ConfigMap 允许我们将配置与应用镜像分开存储。这种分离在其他替代方案不适合时非常有用。

几乎每个应用程序都可以通过配置进行微调。传统的软件部署方法促进了配置文件的使用。然而,我们现在讨论的不是传统的部署,而是通过 Kubernetes 调度器进行的先进、分布式和不可变部署。使用根本性的新技术通常需要新的流程和不同的架构,才能最大化其潜力。另一方面,我们不能仅仅抛弃所有现有的东西,从头开始。

我们必须尝试平衡新原则和遗留需求。

如果我们今天开始开发一个新的应用,它将是分布式的、可扩展的、无状态的,并且具有容错能力。这些是当今的需求。尽管我们可能会质疑是否有足够的人知道如何设计一个具有这些质量属性的应用,但几乎没有人会反对拥有这些属性。经常被忽视的是配置。你的新应用应该使用什么机制来配置自己?环境变量怎么样?

环境变量非常适合分布式系统。它们易于定义,并且是可移植的。它们是新应用配置机制的理想选择。

然而,在某些情况下,配置可能对环境变量来说过于复杂。在这种情况下,我们可能需要退回到文件(希望是 YAML)。当这些情况与几乎完全使用基于文件配置的遗留应用结合时,很明显我们不能仅仅依赖环境变量。

当配置基于文件时,我们可以采取的最佳方法是将配置嵌入到 Docker 镜像中。这样,我们就走上了完全不可变的道路。然而,当我们的应用需要为不同集群(例如,测试和生产)提供不同的配置选项时,这可能不可行。我将忽略我内心想要将其转化为“你不需要为不同环境使用不同配置”这一讨论的需求。只是暂时假设你有非常充分的理由来使用这样的配置。在这种情况下,将配置文件嵌入到镜像中是行不通的。这就是 ConfigMap 发挥作用的地方。

ConfigMap 允许我们将配置“注入”到容器中。配置的来源可以是文件、目录或字面值。目标可以是文件或环境变量。

ConfigMap 从一个源获取配置,并将其挂载到正在运行的容器中,作为一个卷。

这就是你将获得的全部理论内容。我们不会进行冗长的解释,而是通过一些示例进行实践,并评论我们所体验到的特性。我们将通过实践学习,而不是通过记忆理论来学习。

让我们准备集群并查看 ConfigMap 的实际应用。

创建集群

这与之前的过程相同,所以让我们默默地完成它。

本章中的所有命令都可以在09-config-map.sh (gist.github.com/vfarcic/717f8418982cc5ec1c755fcf7d4255dd) Gist 中找到。

cd k8s-specs

git pull

minikube start --vm-driver=virtualbox

minikube addons enable ingress

kubectl config current-context  

现在我们可以尝试 ConfigMap 的第一个变体。

从文件注入配置

在其最纯粹、也可能是最常见的形式下,ConfigMap 包含一个文件。例如,我们可以从cm/prometheus-conf.yml文件创建一个 ConfigMap:

kubectl create cm my-config \
 --from-file=cm/prometheus-conf.yml

我们创建了一个名为ConfigMapcm)的my-config。该映射的数据来自cm/prometheus-conf.yml文件的内容。

让我们描述它,看看会得到什么。

kubectl describe cm my-config  

输出结果如下:

Name:         my-config 
Namespace:    default 
Labels:       <none> 
Annotations:  <none> 

Data 
==== 
prometheus-conf.yml: 
---- 
global: 
  scrape_interval:     15s 

scrape_configs: 
  - job_name: Prometheus 
    metrics_path: /prometheus/metrics 
    static_configs: 
      - targets: 
        - localhost:9090 

Events:  <none> 

重要的部分位于Data下方。我们可以看到键,这里是文件的名称(prometheus-conf.yml)。再往下,你可以看到文件的内容。如果你执行cat cm/prometheus-conf.yml,你会看到它与我们从 ConfigMap 描述中看到的内容相同。

ConfigMap 本身是没有用的。它只是另一个卷,就像所有其他卷一样,需要挂载。

让我们看看在cm/alpine.yml中定义的 Pod 规格。

cat cm/alpine.yml  

输出结果如下:

apiVersion: v1 
kind: Pod 
metadata: 
  name: alpine 
spec: 
  containers: 
  - name: alpine 
    image: alpine 
    command: ["sleep"] 
    args: ["100000"] 
    volumeMounts: 
    - name: config-vol 
      mountPath: /etc/config 
  volumes: 
  - name: config-vol 
    configMap: 
      name: my-config 

重要的部分是volumeMountsvolumes。由于volumeMounts在所有 Volume 类型中都是相同的,因此它没有什么特别的。我们定义了它应该基于名为config-vol的卷,并且应该挂载路径/etc/configvolumes部分使用configMap作为类型,在这种情况下,只有一个name项,与我们之前创建的 ConfigMap 的名称一致。

让我们创建 Pod 并看看会发生什么。

kubectl create -f cm/alpine.yml

kubectl get pods  

请在继续之前确认 Pod 确实正在运行。

让我们看看 Pod 内唯一容器中/etc/config目录的内容。

kubectl exec -it alpine -- \
 ls /etc/config 

输出结果如下:

prometheus-conf.yml

现在/etc/config目录下有一个文件,它与我们在 ConfigMap 中存储的文件一致。

如果你在刚才执行的ls命令中添加-l,你会看到prometheus-conf.yml是指向..data/prometheus-conf.yml的链接。如果你深入查看,你会发现..data也是指向一个由时间戳命名的目录的链接。如此类推。目前,所有链接背后的确切逻辑和实际文件并不十分重要。从功能的角度来看,prometheus-conf.yml存在,我们的应用可以对其执行所需的操作。

让我们确认容器内文件的内容确实与我们用来创建 ConfigMap 的源文件相同:

kubectl exec -it alpine -- \
 cat /etc/config/prometheus-conf.yml  

输出应该与cm/prometheus-conf.yml文件的内容相同。

我们看到了 ConfigMap 的一个组合。让我们看看还能用它做些什么。我们将删除迄今为止创建的对象,并重新开始:

kubectl delete -f cm/alpine.yml

kubectl delete cm my-config  

我们不限于使用一个--from-file参数。我们可以根据需要指定多个。

让我们看看执行接下来的命令时会发生什么:

kubectl create cm my-config \
 --from-file=cm/prometheus-conf.yml \ 
    --from-file=cm/prometheus.yml

kubectl create -f cm/alpine.yml

kubectl exec -it alpine -- \
 ls /etc/config

我们创建了一个包含两个文件的 ConfigMap,并根据 alpine.yml 定义创建了相同的 Pod。最后,我们输出了 Pod 唯一容器中 /etc/config 目录下的文件列表。最后命令的输出如下:

prometheus-conf.yml  prometheus.yml  

我们可以看到两个文件都存在于容器中。这让我们得出结论,ConfigMap 可以包含多个文件,所有文件都会被创建在挂载它的容器中。

让我们再次删除对象,并探索 --from-file 参数背后的另一个选项。

kubectl delete -f cm/alpine.yml

kubectl delete cm my-config  

--from-file 参数可能会让你得出结论,认为它的值只能指定一个文件路径。其实它也可以与目录一起使用。例如,我们可以将 cm 目录中的所有文件添加到 ConfigMap 中。

kubectl create cm my-config \
 --from-file=cm  

我们创建了 my-config ConfigMap 并使用了 cm 目录。让我们描述它,并查看里面有什么。

kubectl describe cm my-config  

输出如下(为了简洁,文件内容被省略)。

Name:         my-config
Namespace:    default
Labels:       <none>
Annotations:  <none>

Data
====
alpine-env-all.yml:
----
...
alpine-env.yml:
----
...
alpine.yml:
----
...
my-env-file.yml:
----
...
prometheus-conf.yml:
----
...
prometheus.yml:
----
...

Events:  <none>  

我们可以看到 cm 目录中的所有六个文件现在都在 my-config ConfigMap 内。

我相信你已经知道,如果我们创建一个挂载该 ConfigMap 的 Pod,会发生什么。我们还是来看一下:

kubectl create -f cm/alpine.yml

kubectl exec -it alpine -- \
 ls /etc/config  

最后命令的输出如下:

alpine-env-all.yml alpine.yml      prometheus-conf.yml
alpine-env.yml     my-env-file.yml prometheus.yml  

所有文件都在那里,现在是时候从文件和目录转移开了。所以,首先让我们删除这些对象,并讨论其他配置源。

kubectl delete -f cm/alpine.yml

kubectl delete cm my-config  

从键/值字面量注入配置

希望即使我们的应用需要不同的配置来在不同的集群中工作,这些差异也会受到限制。通常,它们应该仅限于少数几个键/值条目。在这种情况下,使用 --from-literal 创建 ConfigMap 可能更容易。

让我们看一个例子:

kubectl create cm my-config \
 --from-literal=something=else \
 --from-literal=weather=sunny

kubectl get cm my-config -o yaml  

最后命令的输出如下(metadata 为了简洁被省略):

apiVersion: v1 
data: 
  something: else 
  weather: sunny 
kind: ConfigMap 
... 

我们可以看到添加了两个条目,每个字面量对应一个条目。

让我们创建一个挂载了 ConfigMap 的 Pod:

kubectl create -f cm/alpine.yml 

kubectl exec -it alpine -- \
    ls /etc/config 

最后命令的输出如下:

something  weather 
Both files are there.  

最后,让我们确认其中一个文件的内容是正确的。

kubectl exec -it alpine -- \
 cat /etc/config/something  

输出如下:

else  

--from-literal 参数在我们需要在不同的集群中设置一小组配置条目时非常有用。它更合理的做法是只指定发生变化的部分,而不是所有的配置选项。

问题在于,大多数现有应用程序并没有设计成从不同的文件中读取独立的配置条目。另一方面,如果你正在开发一个新应用程序,你也许不会选择这种方式,因为你可以将它开发成读取环境变量的方式。在 ConfigMap 和环境变量之间做出选择时,大多数情况下环境变量会胜出。

总的来说,我不确定你会多频繁使用 --from-literal 参数。可能会用很多次,更可能完全不会用。

还有一个配置源需要探索,所以让我们删除当前正在运行的对象,然后继续。

kubectl delete -f cm/alpine.yml

kubectl delete cm my-config

从环境文件注入配置

让我们看一下 cm/my-env-file.yml 文件:

cat cm/my-env-file.yml  

输出如下:

something=else 
weather=sunny 

该文件包含与我们在使用 --from-literal 时示例中使用的相同的键值对:

让我们看看如果我们使用该文件作为源来创建 ConfigMap,会发生什么。

kubectl create cm my-config \
 --from-env-file=cm/my-env-file.yml

kubectl get cm my-config -o yaml  

我们使用 --from-env-file 参数创建了 ConfigMap,并以 yaml 格式获取了 ConfigMap。

后者命令的输出如下(为了简便,metadata 被删除):

apiVersion: v1 
data: 
  something: else 
  weather: sunny 
kind: ConfigMap 
... 

我们可以看到有两个条目,每个条目都对应文件中的键值对。结果与我们使用 --from-literal 参数创建 ConfigMap 时相同。两个不同的源产生了相同的结果:

如果我们使用 --from-file 参数,结果将如下:

apiVersion: v1 
data: 
  my-env-file.yml: | 
    something=else 
    weather=sunny 
kind: ConfigMap 
... 

总的来说,--from-file 读取一个或多个文件的内容,并使用文件名作为键存储它。--from-env-file 假定文件的内容是键值对格式,并将每个键值对作为单独的条目存储。

将 ConfigMap 输出转换为环境变量

到目前为止,我们看到的所有示例只是源不同。目标始终是相同的。无论是通过文件、目录、字面值还是环境文件创建 ConfigMap,最终都会将一个或多个文件注入到容器中。

这次我们会尝试一些不同的方式。我们将看到如何将 ConfigMap 转换为环境变量。

让我们来看一个示例定义:

cat cm/alpine-env.yml  

输出如下。

apiVersion: v1 
kind: Pod 
metadata: 
  name: alpine-env 
spec: 
  containers: 
  - name: alpine 
    image: alpine 
    command: ["sleep"] 
    args: ["100000"] 
    env: 
    - name: something 
      valueFrom: 
        configMapKeyRef: 
          name: my-config 
          key: something 
    - name: weather 
      valueFrom: 
        configMapKeyRef: 
          name: my-config 
          key: weather 

cm/alpine.yml 相比,主要的区别是 volumeMountsvolumes 部分消失了。这次我们有了 env 部分。

我们没有 value 字段,而是有 valueFrom。进一步来说,我们声明它应该从名为 my-config 的 ConfigMap 中获取值。由于该 ConfigMap 有多个值,我们也指定了 key

让我们创建 Pod:

kubectl create \
 -f cm/alpine-env.yml

kubectl exec -it alpine-env -- env  

我们创建了 Pod,并在其唯一的容器内执行了 env 命令。后者命令的输出,限于相关部分,如下所示:

... 
weather=sunny 
something=else 
... 

还有一种方法,通常更有用,可以从 ConfigMap 中指定环境变量。在尝试之前,我们将删除当前正在运行的 Pod:

kubectl delete \
 -f cm/alpine-env.yml  

让我们再看另一个定义:

cat cm/alpine-env-all.yml  

输出如下:

apiVersion: v1 
kind: Pod 
metadata: 
  name: alpine-env 
spec: 
  containers: 
  - name: alpine 
    image: alpine 
    command: ["sleep"] 
    args: ["100000"] 
    envFrom: 
    - configMapRef: 
        name: my-config 

区别仅在于环境变量的定义方式。这次,语法更简短了。我们有了 envFrom,而不是 env 部分。它可以是 configMapRefsecretRef。由于我们还没有探索 Secrets,我们将继续使用前者。在 configMapRef 中是指向 my-config ConfigMap 的名称引用。

让我们来看一下实际效果。

kubectl create \
 -f cm/alpine-env-all.yml

kubectl exec -it alpine-env -- env  

我们创建了 Pod,并从其唯一的容器内获取了所有环境变量。后者命令的输出,限于相关部分,如下所示:

... 
something=else 
weather=sunny 
... 

结果与之前相同。唯一的区别在于我们定义环境变量的方式。使用 env.valueFrom.configMapKeyRef 语法时,我们需要分别指定每个 ConfigMap 键。这让我们可以控制作用域,并与容器变量的名称建立关系。

envFrom.configMapRef 会将所有 ConfigMap 的数据转换为环境变量。如果你不需要在 ConfigMap 和环境变量键之间使用不同的名称,这通常是一个更好、更简单的选择。语法简短,而且我们不需要担心是否遗漏了 ConfigMap 的某个键。

将 ConfigMap 定义为 YAML

到目前为止,我们创建的所有 ConfigMap 都是通过 kubectl create cm 命令完成的。如果我们不能像定义其他 Kubernetes 资源和对象一样通过 YAML 定义它们,那就太可惜了。幸运的是,我们可以。Kubernetes 中的一切都可以通过 YAML 定义,包括 ConfigMap。

即使我们还没有将 ConfigMap 以 YAML 格式进行定义,但在本章中我们已经多次见过这种格式。由于我无法确定你是否能凭记忆创建一个 ConfigMap 的 YAML 文件,让我们简化操作,使用 kubectl 来输出我们现有的 my-config ConfigMap 以 YAML 格式。

kubectl get cm my-config -o yaml  

输出结果如下:

apiVersion: v1 
data: 
  something: else 
  weather: sunny 
kind: ConfigMap 
metadata: 
  name: my-config 
  ... 

就像任何其他 Kubernetes 对象一样,ConfigMap 也有 apiVersionkindmetadata。数据部分是定义映射的地方。每个映射必须有一个键和值。在这个例子中,有一个键 weather,值为 sunny

让我们尝试将这些知识转化为部署 Prometheus 所需的对象。

cat cm/prometheus.yml  

输出,限制在相关部分,结果如下:

apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: Prometheus 
spec: 
  ... 
  template: 
    ... 
    spec: 
      containers: 
        ... 
        volumeMounts: 
        - mountPath: /etc/Prometheus 
          name: prom-conf 
      volumes: 
      - name: prom-conf 
        configMap: 
          name: prom-conf 
... 
apiVersion: v1 
kind: ConfigMap 
metadata: 
  name: prom-conf 
data: 
  prometheus.yml: | 
    global: 
      scrape_interval:     15s 

    scrape_configs: 
      - job_name: Prometheus 
        metrics_path: /prometheus/metrics 
        static_configs: 
          - targets: 
            - localhost:9090 

Deployment 对象定义了引用 prom-conf 卷的 volumeMount,该卷是一个 configMap。我们之前见过不少类似的例子。

ConfigMap 对象的 data 部分只有一个键(prometheus.yml)。一旦这个 ConfigMap 被挂载为卷,文件的名称将与键相同(prometheus.yml)。该值具有一些“特殊”的语法。与之前例子中值直接跟在冒号后面的单一单词不同,现在值的结构要复杂一些。更准确地说,它包含多行内容。在处理较大值时,我们可以从管道符号(|)开始。Kubernetes 将把值解释为“后续所有内容,只要它有缩进”。你会注意到,值的所有行都至少比键(prometheus.yml)的起始位置向右缩进了两个空格。如果你想插入额外的键,只需将其添加在与其他 prometheus.yml 相同的缩进级别即可。

让我们创建应用并确认一切按预期工作。

cat cm/prometheus.yml | sed -e \
 "s/192.168.99.100/$(minikube ip)/g" \
 | kubectl create -f -

kubectl rollout status deploy prometheus

open "http://$(minikube ip)/prometheus/targets"  

我们创建了对象(通过 sed 转换),等待直到 Deployment 部署完成,最后,我们在浏览器中打开了 Prometheus 目标页面。结果应该是指向 Prometheus 内部指标的绿色目标。

请不要使用 ConfigMaps!

根据我的经验,ConfigMaps 被过度使用了。

如果你有一个在多个集群中相同的配置,或者你只有一个集群,那么你所需要做的就是将它包含在你的 Dockerfile 中,然后忘记它的存在。当配置没有变化时,就没有必要使用配置文件。至少,在不可变镜像之外是不需要的。不幸的是,这并不总是这样,实际上,几乎从来都不是这样。我们往往把事情弄得比应该更复杂。除此之外,这通常意味着一个几乎没人用的冗长配置选项列表。不过,一些东西通常会发生变化,从一个集群到另一个集群,我们可能需要考虑替代配置的方式,而不是将其硬编码在镜像中。

设计你的新应用程序时,使用配置文件和环境变量的组合。确保配置文件中的默认值合理,并适用于大多数使用场景。将其打包到镜像中。在运行容器时,只声明代表特定集群差异的环境变量。这样,你的配置将既便于移植,又简洁。

如果你的应用程序不是新的,并且不支持通过环境变量进行配置怎么办?那么就重构它,使它支持这种方式。添加读取一些环境变量的能力不应该很难。记住,你不需要所有的设置,只需要那些在不同集群之间有所不同的设置。很难想象这样的请求会复杂或耗时。如果是的话,你可能在考虑将应用程序容器化之前,应该先修复更重要的问题。

不过,配置文件不会消失。无论我们选择哪种策略,每个镜像都应该有一份配置文件,并包含合理的默认值。也许,我们可以额外努力一下,修改应用程序,使得配置条目可以从两个位置加载。这样,我们就可以从一个位置加载默认值,只从另一个位置加载差异值。至少,这样可以减少为每个集群指定更多的配置的需求。在这种情况下,ConfigMap 的 --from-literal--from-env-file 源是一个很好的选择。

当其他一切都失败时,--from-file 源就是你的朋友。只需确保 ConfigMap 不与挂载它的对象定义在同一个文件中。如果它们在同一个文件中,这就意味着它们只能在一个集群内使用。否则,我们就会部署相同的配置,并且我们应该回到最初的想法,将其与应用程序一起打包进镜像。

不要让这种悲观情绪阻止你使用 ConfigMap。它们非常方便,你应该采用它们。我之所以试图让你感到沮丧,是想让你思考替代方案,而不是告诉你永远不要使用 ConfigMap。

那么现在该怎么办?

下一章将探讨与 ConfigMap 非常相似的内容。显著的区别在于,这次我们会更加保密。

现在,我们将销毁本章使用的集群。

minikube delete 

如果你想了解更多关于 ConfigMap 的信息,请查看 ConfigMap v1 核心的 API 文档(v1-9.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.9/#configmap-v1-core)。

图 9-1:到目前为止探索的组件

Kubernetes ConfigMap 与 Docker Swarm 配置的比较

Kubernetes ConfigMap 和 Docker Swarm 配置的机制几乎相同。至少,从功能角度来看是这样的。两者都允许我们将一些字面文本存储在调度器的内部数据存储中,并且都允许将它们添加到容器中。两者的语法都非常简单直观。不过,还是有一些区别。

Docker 擅长防止人们做傻事(“傻事”这个词的政治正确版本)。一个例子就是尝试删除配置。如果有 Docker 服务引用了该配置,它是不能被删除的。只有在删除了所有引用它的服务后,我们才允许删除配置源。另一方面,Kubernetes 允许我们删除 ConfigMap 对象,而不会提示任何后果。

另一方面,Kubernetes ConfigMap 提供了更多的选项。虽然 Docker Swarm 配置只能通过文件或 stdin 创建,但 Kubernetes 的等效配置可以通过文件、目录、字面值以及带有环境变量的文件生成。每个来源都可以多次使用。我们甚至可以将它们结合起来。此外,Kubernetes ConfigMap 不仅可以转换为文件,还可以转换为环境变量。灵活性和额外的功能在源端和目标端都可以使用。

Docker Swarm 赢得了用户体验分。Kubernetes 则因为提供更多选择而获得了一颗星。两者之间没有显著差异足以决定胜负,所以我宣布它们是 平局

还有许多其他值得比较的功能。我们还没有完成。敬请期待更多内容。

第十章:使用 Secrets 隐藏机密信息

我们不能对所有信息一视同仁。敏感数据需要额外的保护。Kubernetes 通过 Secrets 提供了额外的保护层。

Secret 是相对少量的敏感数据。典型的 Secrets 候选项包括密码、令牌和 SSH 密钥。

Kubernetes Secrets 与 ConfigMaps 非常相似。如果比较语法上的区别,你会注意到它们几乎没有区别(如果有的话)。从概念上讲,ConfigMaps 和 Secrets 在本质上是相同的。如果你熟悉 ConfigMaps,那么你应该能够轻松地将这些知识应用到 Secrets 上。

我们已经在不知不觉中使用了 Secrets。到目前为止,我们创建的每个 Pod 都由系统自动挂载了一个 Secret。我们将从探索自动生成的 Secrets 开始,然后逐步创建我们自己的 Secrets。

创建集群

我们将继续使用 Minikube,因此创建集群的指令仍然相同。这些指令应该已经刻在你脑海中,因此我们将直接执行它们而不再解释。

本章中的所有命令都可以在10-secret.sh (gist.github.com/vfarcic/37b3ef7afeaf9237aeb2b9a8065b10c3) Gist 中找到。

cd k8s-specs

git pull

minikube start --vm-driver=virtualbox

minikube addons enable ingress

kubectl config current-context  

我们将从部署一个没有创建任何用户定义的 Secret 的应用开始。

探索内置 Secrets

我们将创建与之前定义的 Jenkins 对象相同的对象:

kubectl create \
 -f secret/jenkins-unprotected.yml \
 --record --save-config

kubectl rollout status deploy jenkins  

我们创建了一个 Ingress、一个 Deployment 和一个 Service 对象。我们还执行了kubectl rollout status命令,该命令会告诉我们部署何时完成。

secret/jenkins-unprotected.yml定义没有使用任何新特性,因此我们不会浪费时间逐一解释这个 YAML 文件。相反,我们将直接在浏览器中打开 Jenkins UI。

open "http://$(minikube ip)/jenkins"  

仔细观察,你会发现没有登录按钮。Jenkins 当前没有保护。镜像确实允许定义初始管理员用户名和密码。如果/etc/secrets/jenkins-user/etc/secrets/jenkins-pass文件存在,初始化脚本将读取这些文件,并使用其中的内容来定义用户名和密码。由于我们已经熟悉 ConfigMaps,我们可以用它们来生成这些文件。然而,由于用户名和密码应该比其他配置项更加保护,我们将切换到 Secrets。

如果你对详细信息感兴趣,请探索jenkins/Dockerfilegithub.com/vfarcic/docker-flow-stacks/blob/master/jenkins/Dockerfile)来自vfarcic/docker-flow-stackgithub.com/vfarcic/docker-flow-stacks)仓库。重要部分是它需要/etc/secrets/jenkins-user/etc/secrets/jenkins-pass文件。如果我们能以相对安全的方式提供它们,我们的 Jenkins 将默认(更加)安全。

我们将从检查集群中是否已有一些 Secrets 开始:

kubectl get secrets  

输出如下:

NAME                TYPE                                DATA AGE
default-token-l9fhk kubernetes.io/service-account-token 3    32m  

我们没有创建任何 Secret,但系统中却有一个可用的 Secret。

default-token-l9fhk Secret 是由 Kubernetes 自动创建的。它包含可用于访问 API 的凭据。此外,Kubernetes 会自动修改 Pods 以使用此 Secret。除非我们调整服务账户,否则我们创建的每个 Pod 都会包含此 Secret。让我们确认这确实是事实。

kubectl describe pods  

输出,仅限相关部分,如下:

...
 Mounts:
 /var/jenkins_home from jenkins-home (rw)
 /var/run/secrets/kubernetes.io/serviceaccount from default-
 token-l9fhk (ro)
...
Volumes:
 jenkins-home:
 Type:    EmptyDir (a temporary directory that shares a pod's 
 lifetime)
 Medium:
 default-token-l9fhk:
 Type:        Secret (a volume populated by a Secret)
 SecretName:  default-token-l9fhk
 Optional:    false
...  

我们可以看到挂载了两个卷。第一个卷(/var/jenkins_home)是我们定义的。这与我们在上一章中使用的挂载卷相同,旨在通过挂载其主目录来保留 Jenkins 的状态。

第二个挂载更为有趣。我们可以看到它引用了自动生成的 Secret default-token-l9fhk,并将其挂载为/var/run/secrets/kubernetes.io/serviceaccount。让我们查看一下这个目录。

POD_NAME=$(kubectl get pods \
 -l service=jenkins,type=master \
 -o jsonpath="{.items[*].metadata.name}")

kubectl exec -it $POD_NAME -- ls \
 /var/run/secrets/kubernetes.io/serviceaccount  

输出如下:

ca.crt  namespace  token  

通过自动挂载该 Secret,我们得到了三个文件。如果我们想从容器内访问 API 服务器,这些文件是必须的。ca.crt是证书,namespace包含 Pod 运行的命名空间,而最后一个是我们需要的 token,用于与 API 建立通信。

我们不会深入讨论证明这些文件可以安全地访问 API 服务器的示例。只需记住,如果你需要这样做,Kubernetes 通过那个自动生成的 Secret 已经为你提供了保障。

让我们回到当前任务。我们希望通过为 Jenkins 提供初始的用户名和密码来增强其安全性。

创建和挂载通用 Secrets

创建 Secrets 的命令几乎与创建 ConfigMaps 时相同。例如,我们可以基于字面值生成 Secrets。

kubectl create secret \
 generic my-creds \
 --from-literal=username=jdoe \
 --from-literal=password=incognito  

主要的区别在于我们指定了 Secret 的类型为generic。它也可以是docker-registrytls。我们不会探讨这两种类型,只会提到前者可用于向kubelet提供从私有仓库拉取镜像所需的凭据,后者用于存储证书。在本章中,我们将重点讨论generic类型的秘密,它与 ConfigMaps 使用相同的语法。

就像 ConfigMaps 一样,generic 类型的 Secrets 也可以使用--from-env-file--from-file--from-literal作为源。它们可以作为文件挂载,或者转换为环境变量。由于创建 Secrets 与创建 ConfigMaps 非常相似,我们不会详细探讨所有可能的组合。如果你已经忘记了可以使用的参数,我建议你查阅 ConfigMaps 章节。

目前,我们创建了一个名为my-creds的 Secret,包含两个字面值。

让我们看看现在集群中有哪些 Secrets:

kubectl get secrets

输出如下:

NAME                TYPE                                DATA AGE
default-token-n6fs4 kubernetes.io/service-account-token 3    33m
my-creds            Opaque                              2    6s  

我们可以看到新创建的 Secret 已经可用,并且它包含两条数据。

让我们查看 Secret 的json表示,并尝试找出如何检索它。

kubectl get secret my-creds -o json  

输出如下(metadata部分为了简洁被省略):

{
 "apiVersion": "v1",
 "data": {
 "password": "aW5jb2duaXRv",
 "username": "amRvZQ=="
 },
 "kind": "Secret",
 "metadata": {
 ...
 },
 "type": "Opaque"
}  

我们可以看到data字段包含passwordusername,它们与我们在创建 Secret 时指定的字面值一致。

你会注意到这些值“很奇怪”。它们是经过编码的。如果我们想看到我们存储为秘密的原始值,我们需要解码它们:

kubectl get secret my-creds \
 -o jsonpath="{.data.username}" \
 | base64 --decode  

我们使用jsonpath过滤输出,使其仅检索username数据。由于值是编码的,我们将输出通过base64命令解码。结果是jdoe

类似地,检索并解码第二个 Secret 数据的命令如下:

kubectl get secret my-creds \
 -o jsonpath="{.data.password}" \
 | base64 --decode  

输出是incognito

让我们看看如何挂载我们创建的 Secret:

cat secret/jenkins.yml  

输出,限制为相关部分,如下所示:

apiVersion: apps/v1beta2
kind: Deployment
metadata:
 name: Jenkins
spec:
 ...
 template:
 ...
 spec:
 containers:
 - name: Jenkins
 image: vfarcic/Jenkins
 env:
         - name: JENKINS_OPTS
 value: --prefix=/Jenkins
 volumeMounts:
 - name: jenkins-home
 mountPath: /var/jenkins_home
 - name: jenkins-creds
 mountPath: /run/secrets
 volumes:
 - name: jenkins-home
 emptyDir: {}
 - name: jenkins-creds
 secret:
 secretName: my-creds
 defaultMode: 0444
 items:
 - key: username
 path: jenkins-user
 - key: password
 path: jenkins-pass
...  

我们添加了jenkins-creds,它挂载了/etc/secrets目录。jenkins-creds卷引用了名为my-creds的 Secret。由于我们希望容器内的进程只能读取 Secret,因此将defaultMode设置为0444。这将给予所有人读取权限。通常,我们会将其设置为0400,只给root用户读取权限。然而,由于 Jenkins 镜像使用的是jenkins用户,因此我们将读取权限授予了所有人,而不仅仅是root用户。

最后,由于镜像期望文件名为jenkins-userjenkins-pass,我们明确指定了路径。否则,Kubernetes 会创建名为usernamepassword的文件。

让我们应用新的定义:

kubectl apply -f secret/jenkins.yml

kubectl rollout status deploy jenkins  

我们应用了定义并等待新对象被部署。

现在,我们可以检查是否正确地将文件存储在/etc/secrets目录中:

POD_NAME=$(kubectl get pods \
 -l service=jenkins,type=master \
 -o jsonpath="{.items[*].metadata.name}")

kubectl exec -it $POD_NAME \
 -- ls /etc/secrets  

后者命令的输出如下:

jenkins-pass  jenkins-user  

我们需要的文件确实是注入的。为了安全起见,我们还将检查其中一个文件的内容:

kubectl exec -it $POD_NAME \
 -- cat /etc/secrets/jenkins-user  

输出为jdoe,这是我们新部署的 Jenkins 的用户名。

最后,让我们确认应用程序确实是安全的。

open "http://$(minikube ip)/jenkins"  

你会看到,这次创建新任务的链接已经不见了。

如果你想登录到新部署的(并且更安全的)Jenkins,请使用jdoeincognito

Secrets 与 ConfigMaps 的比较

到目前为止,Kubernetes Secrets 似乎与 ConfigMaps 没有什么区别。从功能角度来看,它们确实是一样的。两者都允许我们注入一些内容。两者都可以使用文件、字面值和包含环境变量的文件作为数据源。两者都可以将数据作为文件或环境变量输出到容器中。甚至使用 Secrets 的语法几乎与 ConfigMaps 使用的相同。

ConfigMaps 和 Secrets 之间唯一显著的区别是后者会在 tmpfs 中创建文件。它们作为内存中的文件构建,因此不会在主机的文件系统上留下痕迹。单凭这一点还不足以称 Secrets 为安全,但它是朝着这个目标迈出的一步。我们需要将它们与授权策略结合起来,确保密码、密钥、令牌以及其他绝不公开显示的数据的安全。即便如此,我们仍然可能希望将注意力转向像 HashiCorp Vault 这样的第三方 Secret 管理器(www.vaultproject.io/)。

Secrets 几乎与 ConfigMaps 相同。主要的区别在于,Secret 文件是在 tmpfs 中创建的。Kubernetes 的 Secrets 并不能使你的系统更安全,它们只是朝着这个目标迈出的第一步。

不那么机密的 Secrets

Kubernetes 几乎所需的一切都存储在 etcd 中(github.com/coreos/etcd)。其中包括 Secrets。问题在于它们是以纯文本形式存储的。任何访问 etcd 的人都可以访问 Kubernetes 的 Secrets。我们可以限制对 etcd 的访问,但这并不是我们问题的终结。etcd将数据以纯文本形式存储到磁盘上。限制对 etcd 的访问仍然会让 Secrets 暴露于那些可以访问文件系统的人。以某种方式来说,这减少了将 Secrets 存储在 tmpfs 中的优势。如果这些 Secrets 仍然被 etcd 存储在磁盘上,容器使用 tmpfs 存储它们就没有太大意义。

即使我们已经确保了 etcd 的访问安全,并确保未授权用户无法访问 etcd 所使用的文件系统分区,我们仍然面临风险。当多个 etcd 副本运行时,数据会在它们之间同步。默认情况下,etcd 副本之间的通信没有加密。任何监听该通信的人都可能获取我们的 Secrets。

Kubernetes Secrets 是朝着正确方向迈出的第一步。毫无疑问,使用 Secrets 比将机密信息暴露为环境变量或其他不太安全的方法要好得多。然而,Secrets 仍然可能给我们带来虚假的安全感。

我们需要采取额外的预防措施来保护自己。这可能包括但不限于以下措施:

  • 使用 SSL/TLS 保护 etcd 实例之间的通信。

  • 限制对 etcd 的访问并擦除已使用的磁盘或分区。

  • 不要在存储库中的 YAML 文件中定义机密信息。通过临时的kubectl create secret命令创建机密信息。如果可能,之后删除命令历史记录。

  • 确保使用机密信息的应用程序不会不小心将其输出到日志中或传递给其他应用程序。

  • 创建策略,仅允许受信任的用户检索机密信息。但是,你需要注意,即使有适当的策略,任何拥有运行 Pod 权限的用户也可以挂载机密并读取它。

我们尚未探索 etcd 配置,也没有学习如何设置授权策略。目前,只需记住,机密信息并不像人们想象的那样安全。至少,Kubernetes 社区提供的机密信息并不安全。我鼓励你使用它们,只要你意识到它们的不足之处。

那么,接下来做什么?

没有太多需要说的,我们将进入破坏模式并删除我们创建的集群:

minikube delete  

如果你想了解更多关于机密信息的内容,请查阅 Secret v1 core 的 API 文档(v1-9.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.9/#secret-v1-core)。

图 10-1:到目前为止探索的组件

Kubernetes 机密信息与 Docker Swarm 机密信息的对比

机密信息与 Kubernetes 的 ConfigMaps 和 Docker Swarm 的配置非常相似。我们为配置所说的一切同样适用于机密信息,只是多了一些附加功能。

Kubernetes 和 Docker Swarm 都将机密信息存储在容器内的 tmpfs 中。从这一点上看,它们是同样安全的。显著的区别在于机密信息的存储方式。

Kubernetes 将机密信息存储在 etcd 中。默认情况下,它们是暴露的,因此我们需要采取额外的措施来保护它们。另一方面,Docker Swarm 的机密信息默认更为安全。它们通过 SSL/TLS 在管理节点之间进行同步,并且在存储时是加密的。我更喜欢 Docker Swarm 机密信息的“默认安全”策略。在 Kubernetes 中,我们需要采取额外的步骤,才能达到与 Docker Swarm 相似的安全级别。

另一方面,Kubernetes 与第三方解决方案集成以管理机密信息要好得多。例如,将 HashiCorp Vault(www.vaultproject.io/)集成到 Kubernetes 工作流中,要比将其与 Docker Swarm 集成要顺利得多。使用 Vault 比 Kubernetes 和 Swarm 本身提供的解决方案更为优秀。

尽管可以通过 Vault 等产品提高 Kubernetes 的安全性,但目前我们正在评估 Kubernetes 和 Docker Swarm 自带的秘密管理功能。如果排除第三方解决方案,Docker Swarm 在这方面明显优于 Kubernetes。其默认的秘密管理更加安全。即便对 Kubernetes 进行一些调整(尤其是 etcd),Docker Swarm 依然更为安全。这并不意味着这两款产品的秘密管理就没有可改进之处,它们都有各自的不足之处。然而,我必须宣布 Docker Swarm 在这一回合中获胜。它的秘密更加保密

第十一章:将集群划分为命名空间

应用程序及其相应对象通常需要彼此分开,以避免冲突和其他不良影响。

我们可能需要将不同团队创建的对象分开。例如,我们可以给每个团队一个单独的集群,让他们可以“实验”而不会影响其他人。在其他情况下,我们可能希望创建不同的集群,用于各种不同的目的。例如,我们可以有一个生产集群和一个测试集群。我们通常通过创建不同的集群来解决许多其他问题。大多数问题源自对某些对象可能对其他对象产生不良影响的担忧。我们可能担心某个团队会不小心将应用程序的生产版本替换为未经测试的测试版。或者,我们可能担心性能测试会拖慢整个集群的速度。恐惧是我们倾向于采取防守性和保守性措施的主要原因之一。在某些情况下,这种恐惧是基于过去的经验。在其他情况下,它可能源于对我们采用的工具了解不足。更常见的是,这两者的结合。

拥有多个 Kubernetes 集群的问题在于,每个集群都需要操作和资源的开销。管理一个集群通常远非简单。拥有几个集群会很复杂。拥有多个集群则可能变成一场噩梦,需要投入大量的时间来进行操作和维护。如果这种开销还不够,我们还必须注意,每个集群都需要专门的资源来运行 Kubernetes。集群越多,消耗的资源(CPU、内存、IO)就越多。虽然大集群也有类似的问题,但事实仍然是,拥有多个小集群的资源开销高于拥有一个大集群。

我并不是想阻止你拥有多个 Kubernetes 集群。在许多情况下,这是一个受欢迎的(如果不是必需的)策略。然而,也可以选择使用 Kubernetes 命名空间。在本章中,我们将探讨将集群拆分为不同区域的方式,作为拥有多个集群的替代方案。

创建集群

你知道该怎么做,所以让我们快速完成集群设置。

本章中的所有命令都可以在 11-ns.shgist.github.com/vfarcic/6e0a03df4c64a9248fbb68673c1ab719)Gist 中找到。

cd k8s-specs

git pull

minikube start --vm-driver=virtualbox

minikube addons enable ingress

kubectl config current-context  

现在集群已经创建(再次),我们可以开始探索命名空间了。

部署第一次发布

我们将从部署 go-demo-2 应用程序开始,并使用它来探索命名空间。

cat ns/go-demo-2.yml  

定义与我们之前使用的相同,因此我们将跳过对 YAML 文件的解释。相反,我们将直接进入部署过程。

与之前的情况不同,我们将部署应用程序的特定标签。如果这是一个 Docker Swarm 堆栈,我们会将 vfarcic/go-demo-2 镜像的标签定义为环境变量,默认值为 latest。不幸的是,Kubernetes 并不提供这个选项。由于我认为为每个版本创建不同的 YAML 文件并不是一个好主意,因此我们将使用 sed 在将定义传递给 kubectl 之前进行修改。

使用 sed 来修改 Kubernetes 定义并不是一个好方法。事实上,这简直是一个糟糕的解决方案。我们应该使用像 Helm(helm.sh/)这样的模板解决方案。然而,我们现在专注于 Kubernetes,Helm 和其他第三方产品超出了本书的范畴。因此,我们将不得不使用 sed 命令作为一种变通方法:

IMG=vfarcic/go-demo-2

TAG=1.0

cat ns/go-demo-2.yml \
 | sed -e \
    "s@image: $IMG@image: $IMG:$TAG@g" \
    | kubectl create -f - 

我们声明了环境变量 IMGTAG。接下来,我们用 cat 读取 YAML 文件并将输出通过管道传递给 sed。然后,它将 image: vfarcic/go-demo-2 替换为 image: vfarcic/go-demo-2:1.0。最后,修改后的定义被传递给 kubectl。当 -f 参数后跟一个破折号(-)时,kubectl 会使用标准输入(stdin)而不是文件。在我们的例子中,这个输入是通过添加特定 tag (1.0)vfarcic/go-demo-2 镜像而修改过的 YAML 定义。

让我们确认部署是否成功完成:

kubectl rollout status \
    deploy go-demo-2-api

我们将通过发送 HTTP 请求来检查应用程序是否已正确部署。由于我们刚刚创建的 Ingress 资源的 host 被设置为 go-demo-2.com,因此我们需要通过在请求中添加 Host: go-demo-2.com 头来“伪装”这一点:

curl -H "Host: go-demo-2.com" \
 "http://$(minikube ip)/demo/hello"

输出结果如下:

hello, release 1.0!  

我们之所以在部署特定版本时经历了这么多曲折的过程,很快就会揭晓原因。目前,我们假设我们在生产环境中运行的是第一个版本。

探索虚拟集群

几乎所有的系统服务都作为 Kubernetes 对象运行。Kube DNS 是一个部署,Minikube 插件管理器、仪表盘、存储控制器和 nginx Ingress 是我们当前在 Minikube 集群中运行的部分系统 Pods。尽管如此,我们还没有看到它们。尽管我们已经执行了好几次 kubectl get all,但并未看到任何这些对象。那么这是怎么回事呢?如果我们列出所有对象,是否能看到它们?让我们检查一下。

kubectl get all  

输出结果仅显示我们创建的对象。有 go-demo-2 部署、ReplicaSets、服务和 Pods。唯一可以观察到的系统对象是 kubernetes 服务。

从当前信息来看,如果我们将观察范围仅限于 Pods,我们可以通过图 11-1 来描述我们的集群。

图 11-1:包含 go-demo-2 Pods 的集群

总的来说,我们的集群运行的是系统级对象和我们创建的对象的混合体,但只有后者是可见的。你可能会想执行kubectl get --help,希望能找到一个参数来获取系统级对象的信息。你可能认为它们默认对你是隐藏的。其实并非如此。它们并没有被隐藏。相反,它们不在我们当前查看的命名空间中。

Kubernetes 使用命名空间来创建虚拟集群。当我们创建 Minikube 集群时,我们得到了三个命名空间。在某种程度上,每个命名空间都是集群中的一个子集群。它们为名称提供作用域。

到目前为止,我们的经验告诉我们,不能有两个相同类型的对象,且名称相同。例如,不能有两个名为go-demo-2-api的部署。然而,这个规则仅适用于命名空间。在集群内部,只要它们属于不同的命名空间,我们可以有多个相同名称的相同类型的对象。

到目前为止,我们的印象是我们在操作一个 Minikube Kubernetes 集群的层级。那是一个错误的假设。实际上,我们一直处于集群中所有可能的命名空间之一。更具体地说,我们迄今为止执行的所有命令都在default命名空间中创建了对象。

命名空间不仅仅是对象名称的作用域。它们使我们能够将一个集群划分为不同的用户组。每个命名空间可以有不同的权限和资源配额。如果我们将命名空间与其他 Kubernetes 服务和概念结合使用,我们还能做很多其他事情。然而,我们将忽略权限、配额、策略等我们尚未探索的内容。我们将仅关注命名空间本身。

我们将从探索预定义的命名空间开始。

探索现有的命名空间

现在我们知道集群中有多个命名空间,让我们稍微探索一下它们。

我们可以通过kubectl get namespaces命令列出所有命名空间。与大多数其他 Kubernetes 对象和资源一样,我们也可以使用简写ns来代替完整名称。

kubectl get ns  

输出如下:

NAME          STATUS    AGE
default       Active    3m
kube-public   Active    3m
kube-system   Active    3m  

我们可以看到,在创建 Minikube 集群时,自动设置了三个命名空间。

default命名空间是我们一直使用的命名空间。如果我们没有特别指定,所有的kubectl命令将作用于default命名空间中的对象。我们的go-demo-2应用程序就在这个命名空间中运行。虽然我们之前没有意识到它的存在,但现在我们知道,所有我们创建的对象都在这里。

图 11-2:命名空间与 go-demo-2 Pods

有很多种方法可以指定一个命名空间。现在,我们将使用--namespace参数。它是所有kubectl命令都可以使用的全局选项之一。

获取kube-public命名空间中所有对象的命令如下:

kubectl --namespace kube-public get all  

输出显示未找到资源。这令人失望,不是吗?Kubernetes 不会使用kube-public命名空间来存放其系统级对象。我们创建的所有对象都在default命名空间中。

kube-public命名空间是所有命名空间的用户都可以读取的。它存在的主要原因是提供一个空间,我们可以在其中创建应该在整个集群中可见的对象。一个很好的例子是 ConfigMap。当我们在例如default命名空间中创建一个 ConfigMap 时,它只对同一命名空间中的其他对象可访问。其他地方的对象根本不知道它的存在。如果我们希望这样的 ConfigMap 在任何地方的对象都能看到,我们会把它放到kube-public命名空间中。我们不太会使用这个命名空间(如果有的话)。

kube-system命名空间至关重要。Kubernetes 所需的几乎所有对象和资源都在其中运行。我们可以通过执行以下命令来检查这一点:

kubectl --namespace kube-system get all  

我们检索了所有在kube-system命名空间中运行的对象和资源。输出如下:

NAME            DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
deploy/kube-dns 1       1       1          1         3m

NAME                   DESIRED CURRENT READY AGE
rs/kube-dns-86f6f55dd5 1       1       1     3m

NAME            DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
deploy/kube-dns 1       1       1          1         3m

NAME                   DESIRED CURRENT READY AGE
rs/kube-dns-86f6f55dd5 1       1       1     3m

NAME                              READY STATUS  RESTARTS AGE
po/default-http-backend-j7mlp     1/1   Running 0        3m
po/kube-addon-manager-minikube    1/1   Running 0        4m
po/kube-dns-86f6f55dd5-62dsn      3/3   Running 0        3m
po/kubernetes-dashboard-mtkrl     1/1   Running 1        3m
po/nginx-ingress-controller-fxrhn 1/1   Running 0        3m
po/storage-provisioner            1/1   Running 0        3m

NAME                        DESIRED CURRENT READY AGE
rc/default-http-backend     1       1       1     3m
rc/kubernetes-dashboard     1       1       1     3m
rc/nginx-ingress-controller 1       1       1     3m

NAME                     TYPE      CLUSTER-IP    EXTERNAL-IP PORT
(S)       AGE
svc/default-http-backend NodePort  10.107.189.73 <none>      80:3
0001/TCP  3m
svc/kube-dns             ClusterIP 10.96.0.10    <none>      53/U
DP,53/TCP 3m
svc/kubernetes-dashboard NodePort  10.96.41.245  <none>      80:3
0000/TCP  3m  

如我们所见,kube-system命名空间中运行着相当多的内容。例如,我们知道有一个 nginx Ingress 控制器,但这是我们第一次看到它的对象。它由一个复制控制器nginx-ingress-controller和它创建的 Podnginx-ingress-controller-fxrhn组成。

图 11-3:命名空间和 Pod

只要系统按预期工作,就不需要在kube-system命名空间中做太多事情。真正有趣的部分开始于我们创建新的命名空间。

部署到新命名空间

目前,我们正在运行go-demo-2应用的 1.0 版本。我们可以把它看作是生产版本。现在,假设负责该应用的团队刚刚发布了一个新版本。他们运行了单元测试并构建了二进制文件,生产了一个新的 Docker 镜像并将其标记为vfarcic/go-demo-2:2.0。他们没有做的是运行功能性测试、性能测试以及其他需要运行应用的测试。这个新版本仍然无法部署到生产环境,所以我们还不能执行滚动更新,并用新版本替换生产版本。我们需要完成测试运行,而为此我们需要让新版本与旧版本并行运行。

我们可以,例如,创建一个仅用于测试目的的新集群。虽然在某些情况下这是一个不错的选择,但在其他情况下可能是资源的浪费。此外,我们还会在测试集群中面临同样的挑战。可能会有多个新版本需要并行部署和测试。

另一种选择是为每个要测试的发布版本创建一个新的集群。这将创建必要的隔离并保持我们追求的自由。然而,这种方式很慢。创建一个集群需要时间。即使看起来不多,光是这十分钟(如果不是更多)就浪费了太多时间。即使你不同意我,认为十分钟不算什么,但这种做法依然成本太高。每个集群都有资源开销,这是必须支付的。虽然集群的整体大小会影响资源开销,但集群的数量对它的影响更大。拥有许多小集群比一个大集群更昂贵。更重要的是,操作成本。尽管操作成本与集群数量通常不成比例,但它依然会增加。

为我们所有的测试需求拥有一个独立的集群并非坏主意。我们不应轻易放弃它,就像我们应该考虑为每个新发布创建(并销毁)一个新的集群一样。然而,在你开始创建新的 Kubernetes 集群之前,我们将探讨如何利用单一集群并借助命名空间实现相同的目标。

首先,我们需要创建一个新的命名空间,然后才能使用它。

kubectl create ns testing

kubectl get ns  

后一个命令的输出如下:

NAME          STATUS    AGE
default       Active    5m
kube-public   Active    5m
kube-system   Active    5m
testing       Active    3s

我们可以看到新命名空间testing已被创建。

我们可以继续使用--namespace参数在新创建的命名空间中操作。然而,每个命令都添加--namespace参数很繁琐。相反,我们将创建一个新的上下文。

kubectl config set-context testing \
 --namespace testing \
    --cluster minikube \
    --user minikube  

我们创建了一个名为testing的新上下文。它与minikube上下文相同,唯一的区别是它使用了testing命名空间。

kubectl config view  

限制为相关部分的输出如下:

...
contexts:
- context:
 cluster: minikube
 user: minikube
 name: minikube
- context:
 cluster: minikube
 Namespace: testing
 user: minikube
 name: testing
...  

我们可以看到有两个上下文。两者都设置为使用相同的minikube集群和相同的minikube用户。唯一的区别是一个没有设置命名空间,这意味着它将使用default。另一个则设置为testing

现在我们有了两个上下文,我们可以切换到testing

kubectl config use-context testing  

我们切换到了使用同名命名空间的testing上下文。从现在起,所有的kubectl命令将在testing命名空间的上下文中执行。也就是说,直到我们再次更改上下文或使用--namespace参数。

为了安全起见,我们将确认新创建的命名空间中没有运行任何资源。

kubectl get all  

输出显示未找到资源

如果我们在相同的命令中添加--namespace=default参数,再次执行该命令,我们会看到我们之前创建的go-demo-2对象仍在运行。

继续并部署新的发布版本。正如我们之前所解释的,部署的主要目标是提供测试发布版本的手段。它应该对用户保持隐藏。他们应该不知道新部署的存在,继续使用 1.0 版本,直到我们确认 2.0 版本如预期般正常:

TAG=2.0

DOM=go-demo-2.com

cat ns/go-demo-2.yml \
    | sed -e \
    "s@image: $IMG@image: $IMG:$TAG@g" \
    | sed -e \
    "s@host: $DOM@host: $TAG\.$DOM@g" \
    | kubectl create -f -  

就像之前一样,我们使用sed修改了镜像定义。这一次,我们部署的是2.0标签。

除了更改镜像标签外,我们还修改了主机。这次,Ingress 资源将配置主机为2.0.go-demo-2.com。这将允许我们使用该域名测试新版本,同时用户将继续通过go-demo-2.com域名访问生产版本 1.0。

让我们确认部署是否完成。

kubectl rollout status \
 deploy go-demo-2-api

输出如下:

deployment "go-demo-2-api" successfully rolled out  

如你所见,我们部署了go-demo-2-api,以及一些其他资源。这意味着我们有两组同名的对象,一组运行在default命名空间中,另一组(版本 2.0)运行在testing命名空间中。

图 11-4:带有新命名空间测试的集群

在我们打开一瓶香槟庆祝新版本的成功部署且未影响生产环境之前,我们应该验证两者是否确实按预期工作。

如果我们向go-demo-2.com发送请求,我们应该收到来自default命名空间中运行的 1.0 版本的响应。

curl -H "Host: go-demo-2.com" \
    "http://$(minikube ip)/demo/hello"

输出如下:

hello, release 1.0!  

另一方面,如果我们向2.0.go-demo-2.com发送请求,我们应该从testing命名空间中运行的 2.0 版本获得响应。

curl -H "Host: 2.0.go-demo-2.com" \
 "http://$(minikube ip)/demo/hello"

输出如下:

hello, release 2.0!  

我们通过不同的命名空间实现的结果与使用独立集群时的预期非常相似。主要的区别在于,我们无需通过创建一个新的集群来复杂化事情,而是通过使用一个新的命名空间来节省了时间和资源。

如果这是一个“现实世界”的情况,我们会运行功能测试以及其他类型的测试,使用新部署的版本。希望这些测试是自动化的,且只会持续几分钟。由于测试部分不在本章的范围内(可能也不在本书的范围内),我们将跳过测试部分。我们假设这些测试已经执行,并且是成功的。

通信是处理命名空间时非常重要的主题,因此我们将花一点时间来探讨它。

在命名空间之间进行通信

我们将创建一个基于alpine的 Pod,用于演示命名空间之间的通信。

kubectl config use-context minikube

kubectl run test --image=alpine \
 --restart=Never sleep 10000

我们切换到minikube上下文(default命名空间),并创建了一个基于alpine镜像的 Pod。我们让它sleep很长时间,否则容器将没有进程,几乎会立即停止。

在继续之前,我们应该确认 Pod 确实正在运行。

kubectl get pod test  

输出如下:

NAME READY STATUS  RESTARTS AGE
test 1/1   Running 0        10m  

如果在你的情况下 Pod 尚未就绪,请稍等片刻。

在继续之前,我们将在test Pod 内安装curl

kubectl exec -it test \
    -- apk add -U curl  

我们已经探讨了同一 Namespace 中对象之间的通信。由于 test Pod 运行在 default Namespace 中,我们可以通过使用服务名称作为 DNS 名称来访问 go-demo-2-api 服务。

kubectl exec -it test -- curl \
    "http://go-demo-2-api:8080/demo/hello"

输出如下:

hello, release 1.0!  

我们得到了 1.0 版本的响应,因为它运行在同一 Namespace 中。这是否意味着我们无法访问其他 Namespace 中的服务?

当我们创建一个服务时,它会创建几个 DNS 条目,其中一个与服务名称对应。因此,go-demo-2-api 服务创建了一个基于该名称的 DNS。实际上,完整的 DNS 条目是 go-demo-2-api.svc.cluster.local。这两个条目都解析到相同的服务 go-demo-2-api,而在这种情况下,它运行在 default Namespace 中。

我们得到的第三个 DNS 条目采用 <service-name>.<namespace-name>.svc.cluster.local 格式。在我们的案例中,这是 go-demo-2-api.default.svc.cluster.local。或者,如果我们更喜欢短一点的版本,也可以使用 go-demo-2-api.default

在大多数情况下,当与同一 Namespace 中的服务进行通信时,使用 <service-name>.<namespace-name> 格式并没有太大的必要。DNS 以 Namespace 名称存在的主要目的,是为了在我们想要访问位于不同 Namespace 中的服务时使用。

如果我们希望从 default Namespace 中的 test Pod 访问在 testing Namespace 中运行的 go-demo-2-api,我们应该使用 go-demo-2-api.testing.svc.cluster.local DNS,或者更好的是,使用更短的版本 go-demo-2-api.testing

kubectl exec -it test -- curl \
    "http://go-demo-2-api.testing:8080/demo/hello"

这次,输出结果不同:

hello, release 2.0!  

Kube DNS 使用 DNS 后缀 testing 来推断我们想要访问位于该 Namespace 中的服务。因此,我们收到了 go-demo-2 应用程序的 2.0 版本的响应。

删除 Namespace 及其所有对象

Namespaces 的另一个便捷特性是它们的级联效果。例如,如果我们删除 testing Namespace,那么其中运行的所有对象和资源也会被一并删除。

kubectl delete ns testing

kubectl -n testing get all  

我们删除了 testing Namespace,并恢复了所有其中的对象。输出如下:

NAME                              READY STATUS      RESTARTS AGE
po/go-demo-2-api-56dfb69dbd-8w6rf 0/1   Terminating 0        2m
po/go-demo-2-api-56dfb69dbd-hrr4b 0/1   Terminating 0        2m
po/go-demo-2-api-56dfb69dbd-ws855 0/1   Terminating 0        2m
po/go-demo-2-db-5b49cc946b-xdd6v  0/1   Terminating 0        2m  

请注意,在你的情况下,输出可能会显示更多的对象。如果是这样,那是因为你操作太快,Kubernetes 还没有时间将它们删除。

一两秒后,testing Namespace 中唯一剩下的对象是状态为 terminating 的 Pods。一旦宽限期结束,它们也会被删除。Namespace 被删除,我们在其中创建的所有内容也会被移除。

删除命名空间及其所托管的所有对象和资源的能力在我们需要创建临时对象时尤为有用。一个好的例子是持续部署CDP)过程。我们可以创建一个命名空间来构建、打包、测试以及执行管道所需的其他任务。完成后,我们可以简单地删除该命名空间。否则,我们将需要跟踪我们创建的所有对象,并确保在终止 CDP 管道之前将它们删除。

现在托管我们 2.0 版本的命名空间已经消失,我们可能需要再次检查生产版本(1.0)是否仍在运行。

kubectl get all  

输出应显示go-demo-2的 Deployments、ReplicaSets、Pods 和 Services,因为我们仍在使用default上下文。

为了安全起见,我们将检查来自go-demo-2.com域的请求是否仍然返回来自 1.0 版本的响应。

curl -H "Host: go-demo-2.com" \
 "http://$(minikube ip)/demo/hello"

如预期的那样,响应是hello, release 1.0!

如果这是一个持续部署管道,剩下的唯一任务就是执行滚动更新,将生产发布的镜像更改为vfarcic/go-demo-2:2.0。命令可以如下所示:

kubectl set image \
 deployment/go-demo-2-api \
    api=vfarcic/go-demo-2:2.0 \
    --record

现在该做什么?

将测试版本作为持续部署过程的一部分进行部署并不是命名空间的唯一用途。还有许多其他情况下它们非常有用。例如,我们可以为组织中的每个团队分配一个独立的命名空间,或者根据应用类型(例如监控、持续部署、后端等)将集群划分为多个命名空间。总的来说,命名空间是将集群分成不同部分的一个便捷方法。我们将创建的一些命名空间会长期存在,而另一些,如我们例子中的测试命名空间,将是短期的。

命名空间的真正力量体现在它们与授权策略和约束结合使用时。然而,我们尚未探索这些主题,因此目前我们将把命名空间的体验局限于其基本形式。

本章已经结束,这意味着我们即将删除集群。

minikube delete  

如果你想了解更多关于命名空间的信息,请查看 Namespace v1 核心的API 文档

Kubernetes 命名空间与 Docker Swarm 的等效项比较(如果有的话)

Docker Swarm 没有类似于 Kubernetes 命名空间的功能。我们无法将 Swarm 集群分割成多个部分。因此,我们可以通过说 Kubernetes 在此功能上明显优于 Docker Swarm 来结束这个比较,因为 Docker Swarm 没有命名空间。但这并不完全准确。

Docker Swarm 堆栈在某种程度上类似于 Kubernetes 命名空间。堆栈中的所有服务通过堆栈名称和其中服务名称的组合唯一标识。默认情况下,堆栈内的所有服务都可以通过堆栈的默认网络互相通信。服务只有在显式附加到同一网络时,才能与来自其他堆栈的服务通信。总的来说,每个 Swarm 堆栈都是与其他堆栈分开的。从某种程度上说,它们类似于 Kubernetes 命名空间。

尽管 Docker Swarm 堆栈提供了类似于 Kubernetes 命名空间的功能,但它们的使用受到限制。例如,如果我们希望将集群划分为生产和测试环境,我们需要创建两个潜在较大的 Swarm 堆栈文件。这将是不切实际的。此外,Kubernetes 命名空间可以与资源配额、策略以及其他许多功能相关联。它们确实充当真正独立的集群。另一方面,Swarm 堆栈旨在将服务分组为逻辑实体。尽管 Kubernetes 命名空间和 Docker Swarm 堆栈中的某些功能有重叠,但这仍然是 Kubernetes 的明显胜利。

有人可能会争辩说,它们仅适用于更大的集群或有多个团队的大型组织。我认为这是一种轻描淡写的说法。命名空间可以应用于许多其他场景。例如,为每个持续集成、交付或部署管道创建一个新的命名空间是一种有益的实践。我们可以为名称提供独特的作用域,可以通过资源配额来减少潜在问题,还能提高安全性。在整个过程中,最终我们可以删除命名空间以及其中创建的所有对象。

Kubernetes 命名空间是使 Kubernetes 成为大集群需求团队以及依赖自动化的团队更可能选择的平台之一。在我们到目前为止比较的特性中,这是两个平台之间的第一个真正的区分点。Kubernetes 在这一回合中获胜。

第十二章:保护 Kubernetes 集群

安全实现是一场由一个拥有完全封锁策略的团队与一个通过为每个人提供完全自由来取得胜利的团队之间的博弈。你可以把它看作是无政府主义者和极权主义者之间的斗争。游戏能够获胜的唯一方式是两者融合成一种新的方式。唯一可行的策略是,在不牺牲安全性的情况下实现自由(尽可能少地牺牲安全性)。

目前,我们的集群已经尽可能地安全了。这里只有一个用户(你)。没有其他人能够操作它。其他人甚至不能列出集群中的 Pods。你是裁判、陪审团和执行者。你是无可争议的王者,拥有像神一样的权力,这些权力不与任何人共享。

“只有我能做事”策略在模拟笔记本电脑上的集群时非常有效。当唯一的目标是独自学习时,它就能达到目的。当我们创建一个“真实”的集群,整个公司将以某种形式进行协作时,我们就需要定义(并应用)认证和授权策略。如果你的公司很小,只有少数几个人会操作集群,那么为每个人提供相同的集群范围内的管理员权限是一种简单而合法的解决方案。但大多数情况下,情况并非如此。

你的公司可能有不同信任级别的人。即使不是这样,不同的人也会需要不同级别的访问权限。有些人可以做任何他们想做的事情,而其他人则没有任何类型的访问权限。大多数人能够在两者之间做一些事情。我们可能选择为每个人提供一个单独的 Namespace,并禁止他们访问其他 Namespace。有些人可能能够操作生产 Namespace,而其他人可能只对分配给开发和测试的 Namespace 感兴趣。我们可以应用的排列组合是无限的。然而,有一点是肯定的。我们将需要创建认证和授权机制。很可能,我们需要创建一些权限,这些权限有时会在整个集群范围内应用,而在其他情况下则仅限于某些 Namespaces。

这些以及其他许多策略可以通过使用 Kubernetes 授权和认证来创建。

访问 Kubernetes API

与 Kubernetes 的每一次交互都必须经过其 API,并且需要进行授权。这种通信可以通过用户或服务账户发起。目前在我们集群中运行的所有 Kubernetes 对象都是通过服务账户与 API 进行交互的。我们不会深入讨论这些内容,而是专注于人类用户的授权。

通常,Kubernetes API 是在一个安全的端口上提供服务的。我们的 Minikube 集群也不例外。我们可以通过 kubectl 配置查看端口。

本章中的所有命令都可以在 12-auth.sh (gist.github.com/vfarcic/f2c4a72a1e010f1237eea7283a9a0c11) Gist 中找到。

kubectl config view \
    -o jsonpath='{.clusters[?(@.name=="minikube")].cluster.server}'  

我们使用 jsonpath 输出了位于名为 minikube 的集群中的 cluster.server 条目。

输出如下:

https://192.168.99.105:8443

我们可以看到 kubectl 正在通过 8443 端口访问 Minikube Kubernetes API。由于访问已被加密,它需要证书,这些证书存储在 certificate-authority 条目中。让我们看一下。

kubectl config view \
 -o jsonpath='{.clusters[?(@.name=="minikube")].cluster.certif
icate-authority}'  

输出如下:

/Users/vfarcic/.minikube/ca.crt  

ca.crt 证书是与 Minikube 集群一起创建的,目前,它是我们访问 API 的唯一方式。

如果这是一个“真实”的集群,我们还需要为其他用户启用访问权限。我们可以将已经拥有的证书发送给他们,但那样会非常不安全,并且可能导致许多潜在问题。稍后我们将探讨如何安全地为其他用户启用集群访问权限。目前,我们将专注于探索 Kubernetes 用于授权 API 请求的过程。

每个对 API 的请求都经过三个阶段。首先需要进行身份验证,其次需要授权,最后需要通过准入控制。

身份验证过程是从 HTTP 请求中获取用户名。如果请求无法通过身份验证,则操作将以 401 状态码中止。

一旦用户通过身份验证,授权过程将验证是否允许执行指定的操作。授权可以通过 ABAC、RBAC 或 Webhook 模式进行。

最后,一旦请求被授权,它将经过准入控制器。准入控制器在对象被持久化之前拦截请求,并可以对其进行修改。它们是高级主题,本文不会涵盖这些内容。

身份验证过程相当标准,没有太多要说的。另一方面,准入控制器是非常复杂的内容,不适合在此详细讨论。因此,我们将重点研究授权这一主题。

授权请求

就像 Kubernetes 中的几乎所有其他功能一样,授权是模块化的。我们可以选择使用 NodeABACWebhookRBAC 授权。Node 授权用于特定目的。它根据 kubelet 被调度运行的 Pods 授予权限。基于属性的访问控制ABAC)基于属性和策略的组合,并且由于 RBAC 的引入,它被认为已经过时。Webhook 用于通过 HTTP POST 请求进行事件通知。最后,基于角色的访问控制RBAC)根据单个用户或组的角色授予(或拒绝)对资源的访问权限。

在四种授权方法中,RBAC 是基于用户授权的正确选择。由于本章将重点探讨授权人类的方式,RBAC 将是我们的主要关注点。

我们可以通过 RBAC 做什么?首先,我们可以通过仅允许授权用户访问来确保集群的安全。我们可以定义不同的角色,为用户和组授予不同级别的访问权限。有些角色可能拥有类似上帝的权限,几乎可以做任何事,而其他角色则可能仅限于基本的非破坏性操作。介于两者之间可能还有许多其他角色。我们可以将 RBAC 与命名空间结合使用,只允许用户在集群的特定部分进行操作。根据具体的使用案例,我们还可以应用许多其他组合。

由于我对过多的理论感到不舒服,我们将把其余的内容留到以后,通过一些示例来探索细节。正如你可能已经猜到的,我们将从一个新的 Minikube 集群开始。

创建集群

创建 Minikube 集群的命令如下:

cd k8s-specs

git pull

minikube start --vm-driver virtualbox 
kubectl config current-context  

从 minikube v0.26 开始,RBAC 默认已安装。如果你的版本较旧,则需要添加 --extra-config apiserver.Authorization.Mode=RBAC 参数。或者,更好的是,升级你的 minikube 二进制文件。

在集群中拥有一些对象可能会派上用场,因此我们将部署 go-demo-2 应用程序。我们将使用它来测试我们很快将使用的不同授权策略的各种组合。

go-demo-2 应用程序的定义与我们在前几章中创建的相同,因此我们将跳过解释,直接执行 kubectl create

kubectl create \
 -f auth/go-demo-2.yml \
 --record --save-config  

创建用户

Kubernetes 的强大功能在你的公司中传播开来了。人们变得好奇,并希望尝试一下。作为 Kubernetes 专家,你接到 John Doe 的电话也就不足为奇了。他想“玩” Kubernetes,但他没有时间自己搭建集群。由于他知道你已经搭建好了集群,他希望你能让他使用你的集群。

由于你不打算将证书给 John,因此决定让他使用自己的用户进行身份验证。

你需要为他创建证书,因此第一步是验证你的笔记本是否已安装 OpenSSL。

openssl version  

安装的 OpenSSL 版本不应该有问题。我们输出version只是为了验证软件是否正常工作。如果输出类似于command not found: openssl,则需要安装二进制文件(wiki.openssl.org/index.php/Binaries)。

我们要做的第一件事是为 John 创建一个私钥。我们假设 John Doe 的用户名是 jdoe

mkdir keys

openssl genrsa \
 -out keys/jdoe.key 2048

我们创建了 keys 目录,并生成了一个私钥 jdoe.key

接下来,我们将使用私钥生成证书:

openssl req -new \
    -key keys/jdoe.key \
    -out keys/jdoe.csr \
    -subj "/CN=jdoe/O=devs"

给 Windows 用户的提示

如果你收到类似于Subject does not start with '/'。制作证书请求时出现问题的错误,请将前一个命令中的-subj "/CN=jdoe/O=devs"替换为-subj "//CN=jdoe\O=devs",然后重新执行该命令。

我们创建了证书 jdoe.csr,其中包含一个特定的主题,这有助于我们识别约翰。CN 是用户名,O 表示他所属的组织。约翰是开发者,因此 devs 应该是合适的。

对于最终的证书,我们需要集群的证书颁发机构CA)。它将负责批准请求并生成约翰用于访问集群的必要证书。由于我们使用了 Minikube,证书颁发机构在集群创建时已经为我们生成。它应该位于操作系统用户的主文件夹中的 .minikube 目录下。让我们确认一下它是否在那里。

ls -1 ~/.minikube/ca.*  

Minikube 的目录可能在其他地方。如果是这种情况,请将 ~/.minikube 替换为正确的路径。

输出结果如下:

/Users/vfarcic/.minikube/ca.crt
/Users/vfarcic/.minikube/ca.key
/Users/vfarcic/.minikube/ca.pem  

现在我们可以通过批准证书签名请求 jdoe.csr 来生成最终的证书。

openssl x509 -req \
    -in keys/jdoe.csr \
    -CA ~/.minikube/ca.crt \
    -CAkey ~/.minikube/ca.key \
    -CAcreateserial \
    -out keys/jdoe.crt \
    -days 365

由于我们心胸宽广,我们让证书 jdoe.crt 的有效期为一年(365 天)。

为了简化过程,我们将集群的证书颁发机构复制到 keys 目录中。

cp ~/.minikube/ca.crt keys/ca.crt  

让我们检查一下我们生成的内容:

ls -1 keys  

输出结果如下:

ca.crt
jdoe.crt
jdoe.csr
jdoe.key  

约翰不需要 jdoe.csr 文件。我们只用了它来生成最终证书 jdoe.crt。不过,他确实需要其他所有的文件。

除了密钥之外,约翰还需要知道集群的地址。在本章的开始,我们已经创建了 jsonpath,它可以检索服务器地址,所以这部分应该很简单。

SERVER=$(kubectl config view \
 -o jsonpath='{.clusters[?(@.name=="minikube")].cluster.server
}')

echo $SERVER  

输出结果如下:

https://192.168.99.106:8443  

配备了新的证书、密钥、集群证书颁发机构和服务器地址,约翰可以配置他的 kubectl 安装。

由于约翰不在场,我们将进行角色扮演,代替他执行操作。

约翰首先需要使用我们发送给他的地址和证书颁发机构来设置集群。

kubectl config set-cluster jdoe \
    --certificate-authority \
    keys/ca.crt \
    --server $SERVER

我们创建了一个名为 jdoe 的新集群。

接下来,他将需要使用我们为他创建的证书和密钥来设置凭证。

kubectl config set-credentials jdoe \
 --client-certificate keys/jdoe.crt \
 --client-key keys/jdoe.key  

我们创建了一组名为 jdoe 的新凭证。

最后,约翰需要创建一个新的上下文:

kubectl config set-context jdoe \
 --cluster jdoe \
 --user jdoe

kubectl config use-context jdoe  

我们创建了使用新创建的集群和用户的 jdoe 上下文,并确保我们正在使用这个新创建的上下文。

让我们看一下配置:

kubectl config view  

限制在约翰设置中的输出结果如下:

...
clusters:
- cluster:
 certificate-authority: /Users/vfarcic/IdeaProjects/k8s-specs/
keys/ca.crt
 server: https://192.168.99.106:8443
 name: jdoe
...
contexts:
- context:
 cluster: jdoe
 user: jdoe
 name: jdoe
...
current-context: jdoe
...
users:
- name: jdoe
 user:
 client-certificate: /Users/vfarcic/IdeaProjects/k8s-specs/key
s/jdoe.crt
 client-key: /Users/vfarcic/IdeaProjects/k8s-specs/keys/jdoe.k
ey
...  

约翰应该会很高兴地认为他可以访问我们的集群。因为他是一个好奇的人,他肯定会想看看我们正在运行的 Pods。

kubectl get pods  

输出结果如下:

Error from server (Forbidden): pods is forbidden: User "jdoe" can
not list pods in the namespace "default"

这真让人沮丧。约翰可以访问我们的集群,但他无法获取 Pods 的列表。由于希望永不熄灭,约翰可能会检查是否被禁止查看其他类型的对象。

kubectl get all  

输出是一个长长的列表,列出了他被禁止查看的所有对象。

约翰拿起手机,不仅请求你给予他访问集群的权限,还请求他有权限“玩”集群。

在修改约翰的权限之前,我们应该先了解一下参与 RBAC 授权过程的组件。

探索 RBAC 授权

管理 Kubernetes RBAC 需要了解一些元素。具体来说,我们需要了解规则(Rules)、角色(Roles)、主体(Subjects)和 RoleBindings。

Rule 是一组操作(动词)、资源和 API 组。动词描述了可以在属于不同 API 组的资源上执行的活动。

通过规则定义的权限是累加的。我们无法拒绝对某些资源的访问。

当前支持的动词如下:

动词 描述
get 检索特定对象的信息
list 检索一组对象的信息
create 创建特定对象
update 更新特定对象
patch 补丁特定对象
watch 监听对象的变化
proxy 代理请求
redirect 重定向请求
delete 删除特定对象
deletecollection 删除一组对象

例如,如果我们只希望允许用户创建对象并获取其信息,我们将使用动词 getlistcreate。动词可以是星号(*),从而允许所有动词(操作)。

动词与 Kubernetes 资源结合使用。例如,如果我们只希望允许用户创建 Pods 并获取其信息,我们将 getlistcreate 动词与 pods 资源结合使用。

规则的最后一个元素是 API 组。RBAC 使用 rbac.authorization.k8s.io 组。如果我们切换到其他授权方法,我们还需要更改该组。

Role 是一组规则(Rules)。它定义一个或多个可以绑定到用户或用户组的规则。角色的关键在于它们是应用于某个 Namespace 的。如果我们希望创建一个指向整个集群的角色,我们将使用 ClusterRole。两者的定义方式相同,唯一的区别在于作用范围(Namespace 或整个集群)。

授权机制的下一个元素是 Subjects。它们定义了执行操作的实体。Subject 可以是 用户(User)、(Group)或 服务帐户(Service Account)。用户是一个或一个位于集群外的进程。服务帐户用于在 Pods 内部运行的进程,且这些进程想要使用 API。由于本章关注人类身份验证,因此我们现在不讨论它们。最后,组是用户或服务帐户的集合。某些组是默认创建的(例如,cluster-admin)。

最后,我们需要 RoleBindings。顾名思义,它们将 Subjects 绑定到 Roles。由于 Subjects 定义了用户,RoleBindings 实际上是将用户(或组或服务帐户)绑定到 Roles,从而授予他们在特定 Namespace 内执行某些操作的权限。与角色一样,RoleBindings 有一个集群范围的替代方案,叫做 ClusterRoleBindings。唯一的区别是它们的作用范围不限于某个 Namespace,而是应用于整个集群。

所有这些可能看起来令人困惑和压倒性。你甚至可能会说你什么都没理解。别担心。我们将通过实际示例更详细地探讨每个 RBAC 组件。我们先进行了说明,因为人们说应该先解释理论,后演示。我认为这不是正确的方法,但我不希望你说我没有提供理论。不管怎样,接下来的示例会澄清一切。

让我们回到 John 的问题,并尝试解决它。

查看预定义的集群角色

John 感到沮丧。他可以访问集群,但不被允许执行任何操作。他甚至不能列出 Pods。自然,他请求我们更加宽容,允许他在我们的集群中“玩”。

由于我们不做任何假设,我们决定第一步应该是验证 John 的说法。是不是他连获取集群中的 Pods 都做不到?

在继续之前,我们将停止模拟 John 的身份,回到使用minikube用户授予的类似神的管理员权限来使用集群。

kubectl config use-context minikube

kubectl get all  

现在我们切换到minikube上下文(以及minikube用户),我们重新获得了完全的权限,kubectl get all返回了default命名空间中的所有对象。

让我们验证一下 John 确实不能在default命名空间中列出 Pods。

我们可以配置与他使用相同的证书,但那会使过程更加复杂。相反,我们将使用一个kubectl命令,允许我们检查如果我们是特定用户,是否能执行某个操作。

kubectl auth can-i get pods --as jdoe  

响应是no,表明jdoe不能get pods--as参数是一个全局选项,可以应用于任何命令。kubectl auth can-i是一个“特殊”命令。它不会执行任何操作,而只是验证是否可以执行某个操作。如果没有--as参数,它将验证当前用户(在此情况下为minikube)是否可以执行某个操作。

我们已经简要讨论了角色(Roles)和集群角色(ClusterRoles)。让我们看看集群中或default命名空间中是否已配置这些角色。

kubectl get roles  

输出结果显示no resourcesfound。在default命名空间中没有任何角色。这个结果是预期的,因为 Kubernetes 集群没有预定义的角色。我们需要自己创建需要的角色。

那么集群角色怎么样呢?让我们检查一下。

kubectl get clusterroles  

这次我们获得了相当多的资源。我们的集群已经默认定义了一些集群角色。那些以system:开头的角色是为 Kubernetes 系统保留的集群角色。修改这些角色可能导致集群无法正常工作,因此我们不应更新它们。相反,我们将跳过系统角色,专注于应该分配给用户的角色。

输出结果仅限于那些应分配给用户的集群角色,具体如下:

NAME          AGE
admin         1h
cluster-admin 1h
edit          1h
view          1h  

权限最少的集群角色是view。我们来仔细看看它:

kubectl describe clusterrole view  

限制输出为前几行,如下所示:

Name:        view
Labels:      kubernetes.io/bootstrapping=rbac-defaults
Annotations: rbac.authorization.kubernetes.io/autoupdate=true
PolicyRule:
 Resources              Non-Resource URLs Resource Names Verbs
 ---------              ----------------- -------------- -----
 bindings               []                []             [get li
st watch]
 configmaps             []                []             [get li
st watch]
 cronjobs.batch         []                []             [get li
st watch]
 daemonsets.extensions  []                []             [get li
st watch]
 deployments.apps       []                []             [get li
st watch]
 ...  

它包含一个长长的资源列表,所有资源都具有getlistwatch操作权限。看起来,它会允许绑定该角色的用户检索所有资源。我们尚未验证这些资源列表是否真的完整。现在来看,它似乎是一个非常适合分配给那些应拥有非常有限权限的用户的角色。与绑定到特定命名空间的角色不同,集群角色可在整个集群中使用。这是一个显著的区别,我们稍后会利用这一点。

让我们探索另一个预定义的集群角色。

kubectl describe clusterrole edit  

限制输出为 Pods,如下所示:

...
pods             [] [] [create delete deletecollection get list p
atch update watch]
pods/attach      [] [] [create delete deletecollection get list p
atch update watch]
pods/exec        [] [] [create delete deletecollection get list p
atch update watch]
pods/log         [] [] [get list watch]
pods/portforward [] [] [create delete deletecollection get list p
atch update watch]
pods/proxy       [] [] [create delete deletecollection get list p
atch update watch]
pods/status      [] [] [get list watch]
...  

如我们所见,edit集群角色允许我们对 Pods 执行任何操作。如果我们浏览整个列表,会看到edit角色允许我们对任何 Kubernetes 对象执行几乎所有操作。它看起来赋予了我们无限的权限。然而,仍有一些资源未列出。我们可以通过集群角色admin来观察这些差异。

kubectl describe clusterrole admin  

如果你仔细观察,你会注意到集群角色admin有一些额外的条目。

限制输出为不包含在集群角色edit中的记录,如下所示:

...
localsubjectaccessreviews.authorization.k8s.io [] [] [create]
rolebindings.rbac.authorization.k8s.io         [] [] [create dele
te deletecollection get list patch update watch]
roles.rbac.authorization.k8s.io                [] [] [create dele
te deletecollection get list patch update watch]
...  

editadmin的主要区别在于,后者允许我们操作角色和角色绑定。而edit允许我们执行与 Kubernetes 对象如 Pods 和 Deployments 相关的几乎所有操作,admin则更进一步,提供了一个额外的功能,允许我们通过修改现有角色或创建新的角色和角色绑定来定义其他用户的权限。admin角色的主要限制是,它无法更改命名空间本身,也无法更新资源配额(我们尚未探索这些配额)。

只剩下一个预定义的非系统集群角色。

kubectl describe clusterrole \
 cluster-admin  

输出如下所示:

Name:        cluster-admin
Labels:      kubernetes.io/bootstrapping=rbac-defaults
Annotations: rbac.authorization.kubernetes.io/autoupdate=true
PolicyRule:
 Resources Non-Resource URLs Resource Names Verbs
 --------- ----------------- -------------- -----
 [*]               []             [*]
 *.*       []                []             [*] 

集群角色cluster-admin毫无保留地提供权限。星号(*)意味着所有内容。它赋予了类似上帝般的权限。绑定该角色的用户可以执行任何操作,没有任何限制。cluster-admin角色是绑定到minikube用户的。我们可以通过执行以下命令轻松确认这一点:

kubectl auth can-i "*" "*"  

输出为yes。尽管我们并未真正确认cluster-admin角色已绑定到minikube,但我们确实验证了它可以执行任何操作。

创建角色绑定和集群角色绑定

角色绑定将用户(或组,或服务账户)绑定到角色(或集群角色)。由于 John 希望能更多地了解我们的集群,我们将创建一个角色绑定,允许他查看default命名空间中(几乎)所有的对象。这应该是我们赋予 John 适当权限之旅的良好开始:

kubectl create rolebinding jdoe \
 --clusterrole view \
 --user jdoe \
 --namespace default \
 --save-config

kubectl get rolebindings  

我们创建了一个名为jdoe的角色绑定。由于集群角色view已经提供了我们大致需要的权限,我们直接使用它,而不是创建一个全新的角色。

后者命令的输出证明新创建的角色绑定jdoe确实已创建。

这是一个很好的时机来澄清一下,Role Binding 并不一定只能与 Role 配合使用,它也可以与 Cluster Role 结合使用(如我们的示例所示)。作为一个经验法则,当我们认为某个角色可能会在集群范围内使用(配合 Cluster Role Bindings)或在多个命名空间中使用(配合 Role Bindings)时,我们会定义 Cluster Roles。权限的作用范围是通过绑定类型来定义的,而不是通过角色类型来定义的。由于我们使用的是 Role Binding,作用范围仅限于单个命名空间,在我们的例子中是 default

让我们查看新创建的 Role Binding 的详细信息:

kubectl describe rolebinding jdoe
Name:        jdoe
Labels:      <none>
Annotations: <none>
Role:
 Kind: ClusterRole
 Name: view
Subjects:
 Kind Name Namespace
 ---- ---- ---------
 User jdoe  

我们可以看到,Role Binding jdoe 只有一个主体,即用户 jdoe。命名空间为空可能有点令人困惑,您可能会认为这个 Role Binding 适用于所有命名空间。但这种假设是错误的。请记住,Role Binding 始终与特定命名空间相关联,我们刚刚描述的是在 default 命名空间中创建的 Role Binding。相同的 Role Binding 不应该在其他地方可用。让我们确认这一点:

kubectl --namespace kube-system \
 describe rolebinding jdoe  

我们描述了命名空间 kube-system 中的 Role Binding jdoe

输出如下:

Error from server (NotFound): rolebindings.rbac.authorization.k8s
.io "jdoe" not found  

命名空间 kube-system 没有该 Role Binding。我们从未创建过它。

通过 kubectl auth can-i 命令验证我们的权限是否设置正确可能会更容易:

kubectl auth can-i get pods \
 --as jdoe

kubectl auth can-i get pods \
 --as jdoe --all-namespaces  

第一个命令验证了用户 jdoe 是否可以从 default 命名空间中 获取 Pods。答案是 yes。第二个命令检查了 Pods 是否可以从所有命名空间中检索,答案是 no。目前,John 只能看到 default 命名空间中的 Pods,且无法查看其他命名空间中的 Pods。

从现在开始,John 应该能够查看 default 命名空间中的 Pods。然而,既然他和我们在同一家公司工作,我们应该对他有更多的信任。为什么不赋予他查看任何命名空间 Pods 的权限呢?为什么不将相同的权限应用于整个集群?在我们这么做之前,我们将删除之前创建的 Role Binding 并重新开始:

kubectl delete rolebinding jdoe  

我们将修改 John 的 view 权限,使其能够跨整个集群应用。我们不再执行其他临时的 kubectl 命令,而是以 YAML 格式定义 ClusterRoleBinding 资源,以便更好地记录这个变更。

让我们查看 auth/crb-view.yml 文件中的定义。

cat auth/crb-view.yml  

输出如下:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
 name: view
subjects:
- kind: User
 name: jdoe
 apiGroup: rbac.authorization.k8s.io
roleRef:
 kind: ClusterRole
 name: view
 apiGroup: rbac.authorization.k8s.io  

功能上,区别在于这次我们创建的是 ClusterRoleBinding,而不是 RoleBinding。此外,我们显式地指定了 apiGroup,这样可以明确指出 ClusterRole 是 RBAC 类型。

kubectl create -f auth/crb-view.yml \
 --record --save-config

我们创建了 YAML 文件中定义的角色,输出确认了 clusterrolebinding "view" 已被 created

我们可以通过描述新创建的角色来进一步验证一切是否正常。

kubectl describe clusterrolebinding \
    view

输出如下:

Name:         view
Labels:       <none>
Annotations:  <none>
Role:
 Kind:  ClusterRole
 Name:  view
Subjects:
 Kind  Name  Namespace
 ----  ----  ---------
 User  jdoe  

最后,我们将模拟 John 并验证他是否能够从任何命名空间中获取 Pods:

kubectl auth can-i get pods \
 --as jdoe --all-namespaces

输出是yes,因此确认jdoe可以查看 Pods。

我们非常激动,迫不及待地想告诉 John,他已经被授予了权限。然而,通话开始一分钟后,他提出了一个问题。虽然能够查看跨集群的 Pods 是一个好的开始,但他需要一个地方,让他和其他开发人员能拥有更多的自由。他们需要能够部署、更新、删除并访问他们的应用程序。他们可能需要做更多的事情,但他们无法提供更多的信息。他们对 Kubernetes 还不太熟悉,因此不知道该期待什么。他请求你找到一个解决方案,让他们能够执行帮助他们开发和测试软件的操作,而不会影响集群中的其他用户。

这个新请求为结合命名空间与角色绑定提供了一个很好的机会。我们可以创建一个dev命名空间,并允许一组选定的用户在其中几乎做任何事情。这应该能够在dev命名空间内为开发人员提供足够的自由,同时避免影响其他命名空间中资源的风险。

让我们来看看auth/rb-dev.yml的定义:

cat auth/rb-dev.yml  

输出如下:

apiVersion: v1
kind: Namespace
metadata:
 name: dev

---

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
 name: dev
 namespace: dev
subjects:
- kind: User
 name: jdoe
 apiGroup: rbac.authorization.k8s.io
roleRef:
 kind: ClusterRole
 name: admin
 apiGroup: rbac.authorization.k8s.io  

第一部分定义了dev命名空间,而第二部分指定了同名的绑定。由于我们使用的是RoleBinding(而非ClusterRoleBinding),因此影响将仅限于dev命名空间。目前,只有一个主体(用户jdoe)。我们可以预计,随着时间推移,列表会增长。

最后,roleRef使用的是ClusterRole(而不是Role)类型。尽管集群角色在整个集群中可用,但由于我们将其与RoleBinding结合使用,这将使其仅限于指定的命名空间。

admin集群角色有一整套广泛的资源和操作,用户(目前只有jdoe)将能够在dev命名空间内几乎执行任何操作。

让我们创建新资源:

ubectl create -f auth/rb-dev.yml \
    --record --save-config  

输出如下:

namespace "dev" created
rolebinding "dev" created  

我们可以看到命名空间和角色绑定已经创建。

让我们验证一下,例如,jdoe是否可以创建和删除 Deployments:

kubectl --namespace dev auth can-i \
 create deployments --as jdoe

kubectl --namespace dev auth can-i \
 delete deployments --as jdoe

在这两种情况下,输出都是yes,确认jdoe至少可以执行createdelete操作。由于我们已经查看了admin集群角色中定义的资源列表,我们可以假设,如果我们检查其他操作,也会得到相同的响应。

但是,John 仍然没有获得一些权限。只有cluster-admin角色涵盖了所有权限。admin集群角色非常广泛,但它并不包含所有资源和操作。我们可以通过以下命令确认这一点:

kubectl --namespace dev auth can-i \
 "*" "*" --as jdoe

输出是no,表示在dev命名空间内,John 仍然有一些操作是被禁止的。这些操作主要与集群管理相关,仍然在我们的控制之中。

John 很高兴。他和他的开发同事们有一个集群区域,他们可以在其中几乎做任何事情,而不会影响其他命名空间。

John 是一个团队合作者,但他也希望有自己的空间。现在他知道为开发者创建命名空间有多简单,他在想是否可以为他自己生成一个命名空间。你开始觉得他是一个不知足的人,总是要求更多,但你不能否认他的新请求是有道理的。创建他的个人命名空间应该很简单,那为什么不满足他的愿望呢?

让我们来看一下另一个 YAML 定义:

cat auth/rb-jdoe.yml

输出如下:

apiVersion: v1
kind: Namespace
metadata:
    name: jdoe

---

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
 name: jdoe
 namespace: jdoe
subjects:
- kind: User
 name: jdoe
 apiGroup: rbac.authorization.k8s.io
roleRef:
 kind: ClusterRole
 name: cluster-admin
 apiGroup: rbac.authorization.k8s.io  

这个定义与之前的差别不大。重要的变化是命名空间是jdoe,而且 John 可能是唯一的用户,至少在他决定添加其他人之前。通过引用 cluster-admin 角色,他被赋予了在该命名空间内做任何事情的完全权限。他可能会部署一些酷东西,并授权其他人查看。每个人偶尔都会想炫耀一下。无论如何,那将是他的决定。毕竟,这是他的命名空间,他应该可以在其中做任何他喜欢的事。

让我们创建新的资源:

kubectl create -f auth/rb-jdoe.yml \
 --record --save-config

在继续之前,我们将确认 John 是否真的可以在 jdoe 命名空间中做任何他喜欢的事情。

kubectl --namespace jdoe auth can-i \
    "*" "*" --as jdoe

正如预期的那样,回应是yes,这意味着 John 在他自己小小的星系中是一个神一般的人物。

John 喜欢拥有自己命名空间的想法。他将其作为自己的游乐场。然而,他还有一件事没有满足。他恰好是一个发布经理。与其他开发者不同,他负责将新版本部署到生产环境。他计划通过 Jenkins 来自动化这个过程。然而,这需要一些时间,直到那时,他应该被允许手动执行部署。我们已经决定生产版本应部署到 default 命名空间,因此他将需要额外的权限。

经过短暂的讨论,我们决定发布经理所需的最小权限是对 Pods、Deployments 和 ReplicaSets 执行操作。拥有该角色的人应该能够做几乎所有与 Pods 相关的事情,而对 Deployments 和 ReplicaSets 的允许操作应仅限于 creategetlistupdatewatch。我们认为他们不应该能删除它们。

我们并不完全确信这些就是发布经理所需要的全部权限,但这是一个很好的开始。如果有需要,我们以后可以随时更新角色。

目前,John 将是唯一的发布经理。一旦我们确信该角色按预期工作,我们会添加更多用户。

既然我们已经有了计划,我们可以继续创建一个角色和绑定,来定义发布经理的权限。我们首先需要做的是弄清楚将要使用的资源、操作动词和 API 组。我们可能需要参考一下admin集群角色,以获取灵感:

kubectl describe clusterrole admin  

仅限 Pods 的输出如下:

...
pods             [] [] [create delete deletecollection get list p
atch update watch]
pods/attach      [] [] [create delete deletecollection get list p
atch update watch]
pods/exec        [] [] [create delete deletecollection get list p
atch update watch]
pods/log         [] [] [get list watch]
pods/portforward [] [] [create delete deletecollection get list p
atch update watch]
pods/proxy       [] [] [create delete deletecollection get list p
atch update watch]
pods/status      [] [] [get list watch]
...  

如果我们仅将pods作为规则资源进行指定,可能无法创建我们需要的所有与 Pods 相关的权限。尽管大多数对 Pods 的操作可以通过pods资源来实现,我们可能还需要添加一些子资源。例如,如果我们希望能够检索日志,就需要pods/log资源。在这种情况下,pods将是一个命名空间资源,而log将是pods的子资源。

Deployment 和 ReplicaSet 对象带来了不同的挑战。如果我们回顾一下kubectl describe clusterrole admin命令的输出,我们会注意到deployments有 API 组。与通过斜杠(/)分隔的子资源不同,API 组是通过点(.)来分隔的。因此,当我们看到像deployments.apps这样的资源时,意味着它是通过 API 组apps的 Deployment。核心 API 组则被省略。

通过探索auth/crb-release-manager.yml中的定义,理解子资源和 API 组可能会更容易:

cat auth/crb-release-manager.yml  

该定义的大部分内容遵循我们已经使用过几次的相同模式。我们将重点关注ClusterRolerules部分。其内容如下:

...
rules:
- resources: ["pods", "pods/attach", "pods/exec", "pods/log", "po
ds/status"]
 verbs: ["*"]
 apiGroups: [""]
- resources: ["deployments", "replicasets"]
 verbs: ["create", "get", "list", "watch"]
 apiGroups: ["", "apps", "extensions"]
...  

发布经理需要的访问级别在 Pods 和 Deployments、ReplicaSets 之间有所不同。因此,我们将它们分为两组。

第一组规则指定了pods资源,并附带了几个子资源(attachexeclogstatus)。这应该涵盖了我们迄今为止探索的所有用例。由于我们没有创建 Pod 代理或端口转发,它们没有被包括在内。

我们已经说过发布经理应该能够对 Pods 执行任何操作,所以verbs只包含一个带星号(*)的条目。另一方面,所有 Pod 资源都属于同一个 Core 组,因此我们无需在apiGroups字段中指定任何内容。

第二组规则针对的是deploymentsreplicasets资源。考虑到我们决定对它们采取更严格的控制,我们指定了更具体的verbs,只允许发布经理执行creategetlistwatch操作。由于我们没有指定deletedeletecollectionpatchupdate动词,发布经理将无法执行相关操作。

正如你所看到的,RBAC 规则可以从非常简单到非常精细化,取决于具体需求。我们可以自行决定希望达到的粒度级别。

让我们创建与发布经理相关的角色和绑定。

kubectl create \
 -f auth/crb-release-manager.yml \
    --record --save-config

为了安全起见,我们将描述新创建的 Cluster Role,并确认它具备我们需要的权限。

kubectl describe \
 clusterrole release-manager

输出如下:

Name:         release-manager
Labels:       <none>
Annotations:  kubectl.kubernetes.io/last-applied-configuration={"
apiVersion":"rbac.authorization.k8s.io/v1","kind":"ClusterRole","
metadata":{"annotations":{},"name":"release-manager","namespace":
""},"rules":[{"apiG...
 kubernetes.io/change-cause=kubectl create --filenam
e=auth/crb-release-manager.yml --record=true --save-config=true
PolicyRule:
 Resources              Non-Resource URLs Resource Names Verbs
 ---------              ----------------- -------------- -----
 deployments            []                []             [create 
get list update watch]
 deployments.apps       []                []             [create 
get list update watch]
 deployments.extensions []                []             [create 
get list update watch]
 pods                   []                []             [*]
 pods/attach            []                []             [*]
 pods/exec              []                []             [*]
 pods/log               []                []             [*]
 pods/status            []                []             [*]
 replicasets            []                []             [create 
get list update watch]
 replicasets.apps       []                []             [create 
get list update watch]
 replicasets.extensions []                []             [create 
get list update watch]  

正如你所看到的,被分配到该角色的用户几乎可以对 Pods 执行任何操作,而他们在 Deployments 和 ReplicaSets 上的权限仅限于创建和查看。他们不能更新或删除这些资源。访问任何其他资源是被禁止的。

目前,John 是唯一被绑定到release-manager角色的用户。我们将模拟他,并验证他是否能够做一些与 Pods 相关的操作:

kubectl --namespace default auth \
 can-i "*" pods --as jdoe

我们将进行类似的验证,但仅限于创建 Deployments。

kubectl --namespace default auth \
    can-i create deployments --as jdoe

在这两种情况下,我们都得到了yes的答案,从而确认了 John 可以执行这些操作。

在告诉 John 他的新权限之前,我们最后一次验证将是确认他不能删除 Deployments。

kubectl --namespace default auth can-i \
 delete deployments --as jdoe

输出是no,明确表示此操作是被禁止的。

我们打电话给 John,告诉他他作为发布经理现在在集群中被允许做的所有事情。

让我们看看 John 在获得新权限后会做些什么。我们将通过切换到jdoe上下文来模拟他。

kubectl config use-context jdoe  

通过 Mongo DB 可以快速验证 John 是否能够创建 Deployments。

kubectl --namespace default \
 run db --image mongo:3.3

John 成功地在default命名空间中创建了 Deployment。

kubectl --namespace default \
    delete deployment db

输出如下:

Error from server (Forbidden): replicasets.extensions "db-649df9d
899" is forbidden: User "jdoe" cannot delete replicasets.extensio
ns in the namespace "default"  

我们可以看到,John 无法删除由 Deployment 创建的 ReplicaSet。

让我们检查 John 是否可以在自己的命名空间中执行任何操作:

kubectl config set-context jdoe \
    --cluster jdoe \
    --user jdoe \
    --namespace jdoe

kubectl config use-context jdoe

kubectl run db --image mongo:3.3  

我们更新了jdoe的上下文,使其使用与默认命名空间相同名称的命名空间。接下来,我们确保使用该上下文,并基于mongo镜像创建了新的 Deployment。

由于 John 应该能够在他的命名空间内执行任何操作,他应该也能够删除该 Deployment。

kubectl delete deployment db  

最后,让我们尝试一些需要较高权限的操作:

kubectl create rolebinding mgandhi \
    --clusterrole=view \
    --user=mgandhi \
    --namespace=jdoe

输出如下:

rolebinding "mgandhi" created  

John 甚至能够向他的命名空间添加新用户,并将他们绑定到任何角色(只要不超出他的权限)。

用组替代用户

定义一个可以访问jdoe命名空间的单一用户可能是最好的方法。我们预计只有 John 会想要访问它。他是该命名空间的所有者,这是他的私人游乐场。即使他选择添加更多用户,他也可能会独立于我们的 YAML 定义进行操作。毕竟,如果不给他类似神的权限,那么赋予他这种权限又有什么意义呢?从我们的角度看,该命名空间只有一个用户,且将继续只有一个用户。

我们无法对defaultdev命名空间中的权限应用相同的逻辑。我们可能会选择为我们组织中的每个人在default命名空间中授予view角色。类似地,我们公司中的开发人员应该能够从dev命名空间中部署更新删除资源。总的来说,我们可以预期,随着时间的推移,viewdev绑定中的用户数量会增加。不断地添加新用户是一个重复、无聊且容易出错的过程,你可能不希望去做。与其成为一个讨厌自己繁琐工作的人的人,不如创建一个基于角色将用户分组的系统。在我们创建约翰的证书时,我们已经朝这个方向迈出了第一步。

让我们再看看我们之前创建的证书的主题。

openssl req -in keys/jdoe.csr \
    -noout -subject

输出如下:

subject=/CN=jdoe/O=devs  

我们可以看到用户名是jdoe,他属于devs组织。我们将忽略他可能应该至少属于另一个组织(release-manager)这一事实。

如果你仔细观察,你可能记得我提到过几次,RBAC 可以与用户、组和服务帐户一起使用。组与用户相同,只不过它们验证附加在请求 API 的证书是否属于指定的组(O),而不是名称(CN)。

让我们快速看一下另一个 YAML 定义。

cat auth/groups.yml  

输出如下:

apiVersion: v1
kind: Namespace
metadata:
 name: dev

---

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
 name: dev
 namespace: dev
subjects:
- kind: Group
 name: devs
 apiGroup: rbac.authorization.k8s.io
roleRef:
 kind: ClusterRole
 name: admin
 apiGroup: rbac.authorization.k8s.io

---

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
 name: view
subjects:
- kind: Group
 name: devs
 apiGroup: rbac.authorization.k8s.io
roleRef:
 kind: ClusterRole
 name: view
 apiGroup: rbac.authorization.k8s.io  

你会注意到,角色绑定dev和集群角色绑定view几乎与我们之前使用的相同。唯一的区别在于subjects.kind字段。这次,我们使用Group作为值。因此,我们将授予属于devs组织的所有用户权限。

在我们应用更改之前,我们需要将上下文切换回minikube

kubectl config use-context minikube

kubectl apply -f auth/groups.yml \
    --record

输出如下:

namespace "dev" configured
rolebinding "dev" configured
clusterrolebinding "view" configured  

我们可以看到,新定义重新配置了一些资源。

现在新定义已应用,我们可以验证约翰是否仍然能够在dev命名空间中创建对象。

kubectl --namespace dev auth \
 can-i create deployments --as jdoe

输出是no,表示jdoe不能创建部署。在你开始想知道哪里出错之前,我应该告诉你,这是预期且正确的响应。--as参数模拟了约翰的身份,但证书仍然来自minikube。Kubernetes 无法知道jdoe属于devs组。至少,在约翰发出自己的证书请求之前,是无法知道的。

我们将不再使用--as参数,而是切换回jdoe上下文,并尝试创建一个部署。

kubectl config use-context jdoe

kubectl --namespace dev \
    run new-db --image mongo:3.3

这次输出是deployment "new-db" created,这明确表明,作为devs组成员的约翰可以创建部署

从现在开始,任何证书的主题中包含/O=devs的用户,都将拥有与约翰在dev命名空间内相同的权限,并且在其他地方拥有view权限。我们刚刚避免了不断修改 YAML 文件和应用更改的麻烦。

现在怎么办?

授权和认证是至关重要的安全组件。如果没有适当的权限设置,我们可能会面临暴露风险,带来潜在的严重后果。此外,通过合适的规则(Rules)、角色(Roles)和角色绑定(RoleBindings),我们不仅可以让集群更安全,还可以促进组织内部不同成员之间的协作。唯一的挑战是找到安全性和自由之间的平衡。这需要时间,直到达成平衡。

结合命名空间和 RBAC 提供了很好的隔离。如果没有命名空间,我们就需要创建多个集群。如果没有 RBAC,这些集群要么会暴露,要么仅限于少数几个用户。两者结合提供了一种在不牺牲安全性的前提下,增加协作的优秀方式。

我们没有深入探讨服务账户(Service Accounts)。它们是除用户(Users)和组(Groups)之外的第三种主题(Subjects)。我们将留到以后再讨论,因为它们主要用于需要访问 Kubernetes API 的 Pod。本章重点讨论的是人类以及我们如何以安全、受控的方式让他们访问集群。

我们仍然缺少一个重要的限制条件。通过结合命名空间(Namespaces)和基于角色的访问控制(RBAC),我们可以限制用户的操作。然而,这并不能防止他们部署可能会导致整个集群崩溃的应用程序。我们需要将资源配额(Resource Quotas)加入其中。这将是下一章的内容。

现在,我们将销毁集群,休息一下。本章我们涵盖了很多内容,值得休息一下。

minikube delete  

如果你想了解更多关于角色的信息,请查阅 Role v1 rbac 的 API 文档(v1-9.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.9/#role-v1-rbac)和 ClusterRole v1 rbac 的 API 文档(v1-9.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.9/#clusterrole-v1-rbac)。同样,你可能也想访问 RoleBinding v1 rbac 的 API 文档(v1-9.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.9/#rolebinding-v1-rbac)和 ClusterRoleBinding v1 rbac 的 API 文档(v1-9.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.9/#clusterrolebinding-v1-rbac)。

Kubernetes RBAC 与 Docker Swarm RBAC 对比

Docker 也有 RBAC。与 Kubernetes 一样,它围绕主题(subjects)、角色(roles)和资源集合(resource collections)进行组织。在许多方面,二者提供了非常相似的功能。我们是否应该迅速宣布平局?

Kubernetes RBAC 和 Docker 提供的 RBAC 之间有一个关键区别。后者不是免费的。你需要购买 Docker 企业版 (EE) 来保障集群的安全,而不仅仅是“只有持证人才能访问”。如果你已经购买了 Docker EE,那么你已经做出了决定,关于是否使用 Docker 或 Kubernetes 的讨论也就结束了。Docker EE 很棒,而且很快它不仅会与 Swarm 一起工作,还会与 Kubernetes 一起工作。你已经购买了它,没有太多理由去切换到其他东西。然而,这个对比专注于开源核心版本能提供的功能,忽略了第三方和企业版本的扩展。

如果我们坚持做一个“仅限开箱即用”的对比,Kubernetes 显然是赢家。它有 RBAC,而 Docker Swarm 没有。问题不在于 Swarm 没有 RBAC,而在于它没有任何内建的基于用户的认证机制。因此,这是一个非常简短的对比。如果你不想购买企业产品,并且确实需要一个授权和认证机制,那么 Kubernetes 是唯一的选择。就像命名空间一样,Kubernetes 通过其众多 Swarm 所没有的功能展现了它的优势。

第十三章:管理资源

如果没有指明容器需要多少 CPU 和内存,Kubernetes 只能将所有容器视为平等。这往往导致资源使用分配极不均衡。如果没有资源规格就请求 Kubernetes 调度容器,就像进入一辆由盲人驾驶的出租车一样。

我们已经走了很长一段路,从最初的起点开始,逐步了解了许多 Kubernetes 的基本对象类型和原则。现在我们最缺少的就是资源管理。到目前为止,Kubernetes 一直在盲目地调度我们部署的应用程序。我们从未告诉它这些应用程序需要多少资源,也没有设定任何限制。没有这些,Kubernetes 执行任务的方式就显得非常短视。Kubernetes 看到的很多,但远远不够。我们很快就会改变这一点,给 Kubernetes 配上一副眼镜,让它有更清晰的视野。

一旦我们学会了如何定义资源,我们将进一步确保设定一些限制,确定默认值,并设置配额,以防止应用程序超载集群。

本章是拼图的最后一块。解决了这一部分,我们就可以开始考虑在生产环境中使用 Kubernetes 了。你可能还不能掌握操作 Kubernetes 所需的所有知识,实际上没有人能做到这一点。但你会知道足够多的内容,能够把你引导到正确的方向。

创建集群

我们将进行几乎与前几章相同的操作。我们会进入我们克隆的 vfarcic/k8s-specs 仓库所在的目录,拉取最新代码,启动 Minikube 集群,等等。这次唯一不同的地方是我们将启用一个额外的插件——Heapster。现在解释它的功能和为什么需要它还为时过早,稍后会详细说明。暂时只需记住,你的集群里很快会有一个叫 Heapster 的东西。如果你还不知道它是什么,认为这是一个悬念引导,稍后会有更多解释。

本章中的所有命令都可以在 13-resource.sh (gist.github.com/vfarcic/cc8c44e1e84446dccde3d377c131a5cd) Gist 中找到。

cd k8s-specs

git pull

minikube start --vm-driver=virtualbox

kubectl config current-context

minikube addons enable ingress

minikube addons enable heapster  

现在已经拉取了最新的代码,集群也在运行,插件已启用,我们可以继续并探索如何定义容器的内存和 CPU 资源。

定义容器的内存和 CPU 资源

到目前为止,我们还没有指定容器应该使用多少内存和 CPU,也没有设定它们的限制。如果我们这样做,Kubernetes 的调度器将能更好地了解这些容器的需求,从而做出更好的决策,决定将 Pods 放置在哪些节点上,以及当容器“行为异常”时应该如何处理。

让我们来看一下修改后的 go-demo-2 定义:

cat res/go-demo-2-random.yml  

规范几乎与我们之前使用的相同。唯一新增的条目是在resources部分。

输出,仅限于相关部分,如下所示:

... 
apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: go-demo-2-db 
spec: 
  ... 
  template: 
    ... 
    spec: 
      containers: 
      - name: db 
        image: mongo:3.3 
        resources: 
          limits: 
            memory: 200Mi 
            cpu: 0.5 
          requests: 
            memory: 100Mi 
            cpu: 0.3 
... 
apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: go-demo-2-api 
spec: 
  ... 
  template: 
    ... 
    spec: 
      containers: 
      - name: api 
        image: vfarcic/go-demo-2 
        ... 
        resources: 
          limits: 
            memory: 100Mi 
            cpu: 200m 
          requests: 
            memory: 50Mi 
            cpu: 100m 
... 

我们在resources部分指定了limitsrequests条目。

CPU 资源以cpu单位进行度量。cpu单位的确切含义取决于我们托管集群的位置。如果服务器是虚拟化的,那么一个cpu单位相当于一个虚拟化处理器vCPU)。当在裸机上运行且启用了超线程时,一个cpu等于一个超线程。为了简化,我们假设一个cpu资源等于一个 CPU 处理器(尽管这并不完全准确)。

如果一个容器设置为使用两个CPU,而另一个设置为使用一个CPU,则后者的处理能力将仅为前者的一半。

CPU 值可以被分割。在我们的示例中,db容器的 CPU 请求设置为0.5,这相当于半个 CPU。这个值也可以表示为500m,即五百毫 CPU。如果你再看看api容器的 CPU 规格,你会看到它的 CPU 限制设置为400m,请求为200m,它们分别等于0.40.2个 CPU。

内存资源的模式与 CPU 类似。主要区别在于单位。内存可以表示为K千字节)、M兆字节)、G千兆字节)、T太字节)、P拍字节)和E艾字节)。我们也可以使用二的幂次方单位KiMiGiTiPiEi

如果我们回到go-demo-2-random.yml定义文件,我们会看到db容器的限制设置为200Mi两百兆字节),请求设置为100Mi一百兆字节)。

我们已经提到过limitsrequests好几次,但仍然没有解释它们各自的含义。

限制表示容器不应超过的资源量。假设我们将限制定义为上限,当达到该上限时,表示出现了问题,并且是一种防止单个恶性容器因内存泄漏或类似问题而占用过多资源的方式。

如果容器是可重启的,Kubernetes 会重启超出内存限制的容器。否则,它可能会终止该容器。请记住,如果容器属于一个 Pod(所有 Kubernetes 管理的容器都是如此),即使它被终止,也会被重新创建。

与内存不同,CPU 限制永远不会导致容器终止或重启。相反,容器将不会允许在较长时间内消耗超过 CPU 限制的资源。

请求代表预期的资源利用率。Kubernetes 使用它们来决定如何根据节点的实际资源使用情况,将 Pods 放置在集群中。

如果容器超出了其内存请求,且节点的内存不足,Pod 可能会被驱逐。这种驱逐通常会导致 Pod 被调度到另一节点,只要该节点有足够的可用内存。如果由于资源不足,Pod 无法调度到任何节点,它将进入待处理状态,直到某个节点的资源被释放,或者集群中新增一个节点。

如果仅仅讨论 资源 的理论,而没有实际的例子,可能会让人感到困惑。因此,我们将继续并创建在 go-demo-2-random.yml 文件中定义的资源:

kubectl create \
 -f res/go-demo-2-random.yml \
 --record --save-config

kubectl rollout status \
 deployment go-demo-2-api  

我们创建了资源并等待 go-demo-2-api Deployment 部署完成。后续命令的输出应该如下所示:

deployment "go-demo-2-api" successfully rolled out  

让我们描述 go-demo-2-api Deployment,并查看其 limitsrequests

kubectl describe deploy go-demo-2-api  

限制为 limitsrequests 的输出如下:

... 
Pod Template: 
  ... 
  Containers: 
    ... 
   Limits: 
      cpu:    200m 
      memory: 100Mi 
    Requests: 
      cpu:    100m 
      memory: 50Mi 
... 

我们可以看到,limitsrequests 对应于我们在 go-demo-2-random.yml 文件中定义的内容。这个结果并不令人惊讶。

让我们描述形成集群的节点(尽管这里只有一个)。

kubectl describe nodes  

限制为资源相关条目的输出如下:

... 
Capacity: 
 cpu:     2 
 memory:  2048052Ki 
 pods:    110 
... 
Non-terminated Pods: (12 in total) 
  Namespace          Name                         CPU Requests CPU Limits Memory Requests Memory Limits 
  ---------          ----                         ------------ ---------- --------------- ------------- 
  default            go-demo-2-api-...            100m (5%)    200m (10%) 50Mi (2%)       100Mi (5%) 
  default            go-demo-2-api-...            100m (5%)    200m (10%) 50Mi (2%)       100Mi (5%) 
  default            go-demo-2-api-...            100m (5%)    200m (10%) 50Mi (2%)       100Mi (5%) 
  default            go-demo-2-db-...             300m (15%)   500m (25%) 100Mi (5%)      200Mi (10%) 
  kube-system        default-http-...             10m (0%)     10m (0%)   20Mi (1%)       20Mi (1%) 
  kube-system        heapster-...                 0 (0%)       0 (0%)     0 (0%)          0 (0%) 
  kube-system        influxdb-grafana-...         0 (0%)       0 (0%)     0 (0%)          0 (0%) 
  kube-system        kube-addon-manager-minikube  5m (0%)      0 (0%)     50Mi (2%)       0 (0%) 
  kube-system        kube-dns-54cccfbdf8-...      260m (13%)   0 (0%)     110Mi (5%)      170Mi (8%) 
  kube-system        kubernetes-dashboard-...     0 (0%)       0 (0%)     0 (0%)          0 (0%) 
  kube-system        nginx-ingress-controller-... 0 (0%)       0 (0%)     0 (0%)          0 (0%) 
  kube-system        storage-provisioner          0 (0%)       0 (0%)     0 (0%)          0 (0%) 
Allocated resources: 
  (Total limits may be over 100 percent, i.e., overcommitted.) 
  CPU Requests CPU Limits  Memory Requests Memory Limits 
  ------------ ----------  --------------- ------------- 
  875m (43%)   1110m (55%) 430Mi (22%)     690Mi (36%) 
... 

容量 表示节点的总体容量。在我们的例子中,minikube 节点有 2 个 CPU、2GB 的内存,最多可运行 110 个 Pods。这些是硬件或在我们案例中,由 Minikube 创建的虚拟机大小所强加的上限。

接下来是 未终止的 Pods 部分。它列出了所有具有 CPU 和内存限制及请求的 Pods。例如,我们可以看到 go-demo-2-db Pod 的内存限制设置为 100Mi,即占总容量的 5%。类似地,我们可以看到并非所有 Pods 都指定了资源。例如,heapster-snq2f Pod 的所有值都设置为 0。Kubernetes 无法适当处理这些 Pods。然而,由于这是一个演示集群,我们可以宽容对待,忽略资源未指定的问题。

最后,已分配资源 部分提供了所有 Pods 的汇总值。例如,我们可以看到 CPU 限制为 55%。限制值甚至可以超过 100%,这并不一定是需要担心的事情。并非所有容器都会超过请求的内存和 CPU 值。即使发生这种情况,Kubernetes 也会知道如何处理。

真正重要的是,请求的内存和 CPU 总量在容量的限制范围内。然而,这也引出了一个有趣的问题。我们到目前为止定义资源的依据是什么?

测量实际的内存和 CPU 消耗

我们是如何得出当前内存和 CPU 值的?为什么将 MongoDB 的内存设置为100Mi?为什么不是50Mi1Gi?现在承认我们有的值是随机的是令人尴尬的。我猜测基于vfarcic/go-demo-2镜像的容器需要比 Mongo 数据库更少的资源,因此它们的值相对较小。这是我定义资源的唯一标准。

在您对我为资源放置随机值感到不满之前,您应该知道我们没有任何支持我们的指标。任何人的猜测与我的一样好。

了解一个应用程序使用多少内存和 CPU 的唯一方法是检索指标。我们将使用 Heapster (github.com/kubernetes/heapster) 来实现这一目的。

Heapster 收集和解释各种信号,如计算资源使用情况,生命周期事件等。在我们的情况下,我们只关注我们集群中正在运行的容器的 CPU 和内存消耗。

在创建集群时,我们启用了heapster插件,并且 Minikube 将其部署为系统应用程序。不仅如此,它还部署了 InfluxDB (github.com/influxdata/influxdb) 和 Grafana (grafana.com/)。前者是 Heapster 存储数据的数据库,后者可以通过仪表板可视化数据。

您可能倾向于认为 Heapster、InfluxDB 和 Grafana 可能是您监控需求的解决方案。我建议不要做出这样的决定。我们仅使用 Heapster,因为它作为 Minikube 插件 readily 可用。将 Heapster 开发为监控工具的想法在很大程度上已被放弃。它的主要重点是作为一些 Kubernetes 特性所需的内部工具。相反,我建议结合使用 Prometheus (prometheus.io/) 和 Kubernetes API 作为指标的来源,以及 Alertmanager (prometheus.io/docs/alerting/alertmanager/) 作为您的警报需求。然而,这些工具不在本章的范围内,所以您可能需要从它们的文档中学习,或者等待本书的续集出版(暂定名为高级 Kubernetes)。

仅使用 Heapster 作为快速检索指标的一种方法。探索 Prometheus 和 Alertmanager 的组合,以满足您的监控和警报需求。

现在我们澄清了 Heapster 的作用及其非作用,我们可以继续确认它确实在我们的集群内运行。

kubectl --namespace kube-system \
    get pods 

输出如下:

NAME                         READY STATUS  RESTARTS AGE
default-http-backend-...     1/1   Running 0        59m
heapster-...                 1/1   Running 0        59m
influxdb-grafana-...         2/2   Running 0        59m
kube-addon-manager-minikube  1/1   Running 0        59m
kube-dns-54cccfbdf8-...      3/3   Running 0        59m
kubernetes-dashboard-...     1/1   Running 0        59m
nginx-ingress-controller-... 1/1   Running 0        59m
storage-provisioner          1/1   Running 0        59m  

正如你所见,heapsterinfluxdb-grafana Pods 正在运行。

我们将仅探索足够多的 Heapster 来检索我们需要的数据。为此,我们需要访问其 API。然而,Minikube 没有暴露其端口,这将是我们要做的第一件事:

kubectl --namespace kube-system \ 
    expose rc heapster \ 
    --name heapster-api \ 
    --port 8082 \ 
    --type NodePort 

我们需要找出为我们创建了哪个NodePort。为此,我们需要熟悉服务的 JSON 定义:

kubectl --namespace kube-system \ 
    get svc heapster-api \  
    -o json 

我们正在寻找spec.ports数组中的nodePort条目。检索并将其输出分配给PORT变量的命令如下:

PORT=$(kubectl --namespace kube-system \ 
    get svc heapster-api \ 
    -o jsonpath="{.spec.ports[0].nodePort}") 

我们使用jsonpath输出仅检索spec.ports数组中的第一个(也是唯一的)条目的nodePort

让我们尝试一次非常简单的 Heapster API 查询。

BASE_URL="http://$(minikube ip):$PORT/api/v1/model/namespaces/default/pods" 

curl "$BASE_URL" 

curl请求的输出如下:

[ 
  "go-demo-2-api-796db5987d-dm69g", 
  "go-demo-2-db-bf6f5b486-p9vhj", 
  "go-demo-2-api-796db5987d-5t84b", 
  "go-demo-2-api-796db5987d-99nh6" 
 ] 

我们实际上不需要 Heapster 来检索 Pod 列表。我们真正需要的是其中一个 Pod 的指标。为此,我们需要它的名称。

我们将使用一个类似的命令来检索 Heapster 的服务端口。

DB_POD_NAME=$(kubectl get pods \ 
    -l service=go-demo-2 \ 
    -l type=db \ 
    -o jsonpath="{.items[0].metadata.name}") 

我们检索了所有标签为service=go-demo-2type=db的 Pod,并将输出格式化,以便从第一个项中检索metadata.name。该值被存储为DB_POD_NAME变量。

现在我们可以查看 Pod 中db容器的可用指标。

curl "$BASE_URL/$DB_POD_NAME/containers/db/metrics"  

输出如下:

[
 "memory/rss",
 "cpu/usage_rate",
 "cpu/request",
 "memory/usage",
 "memory/major_page_faults_rate",
 "cpu/limit",
 "memory/page_faults",
 "memory/major_page_faults",
 "uptime",
 "memory/limit",
 "cpu/usage",
 "memory/page_faults_rate",
 "memory/working_set",
 "restart_count",
 "memory/request"
]

如你所见,大多数可用的指标与内存和 CPU 相关。

让我们看看内存使用是否确实与我们为go-demo-2-db部署定义的内存资源相匹配。提醒一下,我们将内存请求设置为100Mi,内存限制设置为200Mi

检索db容器内存使用情况的请求如下:

curl "$BASE_URL/$DB_POD_NAME/containers/db/metrics/memory/usage"  

输出仅限于几个条目,如下所示:

{ 
  "metrics": [ 
   ... 
   { 
    "timestamp": "2018-02-01T20:24:00Z", 
    "value": 38334464 
   }, 
   { 
    "timestamp": "2018-02-01T20:25:00Z", 
    "value": 38342656 
   } 
  ], 
  "latestTimestamp": "2018-02-01T20:25:00Z" 
 } 

我们可以看到内存使用量大约是 38MB。与我们设置的100Mi相比,这是一个相当大的差距。当然,这项服务并没有承受真实的生产负载,但由于我们正在模拟一个“真实”的集群,我们假装38Mi确实是在“真实”条件下的内存使用情况。这意味着我们通过分配一个几乎是实际使用量三倍的值,过高地估计了请求。

那 CPU 怎么样呢?我们在这方面也犯了如此巨大的错误吗?提醒一下,我们将 CPU 请求设置为0.3,限制为0.5

curl "$BASE_URL/$DB_POD_NAME/containers/db/metrics/cpu/usage_rate"  

输出仅限于几个条目,如下所示。

{ 
  "metrics": [ 
   ... 
   { 
    "timestamp": "2018-02-01T20:25:00Z", 
    "value": 5 
   }, 
   { 
    "timestamp": "2018-02-01T20:26:00Z", 
    "value": 4 
   } 
  ], 
  "latestTimestamp": "2018-02-01T20:26:00Z" 
 } 

如我们所见,CPU 使用量大约为5m0.005 CPU。我们再次在资源规范上犯了一个巨大的错误。我们的值大约高出六十倍。

我们期望(资源请求和限制)与实际使用之间的这种偏差可能会导致调度极度不平衡,产生不良后果。我们将很快修正资源配置。现在,我们将探讨一下如果资源量低于实际使用情况会发生什么。

探索资源规范与资源使用之间差异的影响

让我们看看稍微修改过的go-demo-2定义:

cat res/go-demo-2-insuf-mem.yml  

与之前的定义相比,差异仅在于go-demo-2-db部署中db容器的resources

输出仅限于相关部分,如下所示:

apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: go-demo-2-db 
spec: 
  ... 
  template: 
    ... 
    spec: 
      containers: 
      - name: db 
        image: mongo:3.3 
        resources: 
          limits: 
            memory: 10Mi 
            cpu: 0.5 
          requests: 
            memory: 5Mi 
            cpu: 0.3 

内存限制设置为10Mi,请求为5Mi。由于我们已经从 Heapster 的数据中知道 MongoDB 大约需要38Mi,这次内存资源远低于实际使用量。

让我们看看当应用新配置时会发生什么:

kubectl apply \  
    -f res/go-demo-2-insuf-mem.yml \
    --record 

kubectl get pods 

我们应用了新配置并检索了 Pods。输出如下:

    NAME              READY STATUS    RESTARTS AGE
    go-demo-2-api-... 1/1   Running   0        1m
    go-demo-2-api-... 1/1   Running   0        1m
    go-demo-2-api-... 1/1   Running   0        1m
    go-demo-2-db-...  0/1   OOMKilled 2        17s

在你的情况下,状态可能不是OOMKilled。如果是这样,请再等待一段时间并重新检索 Pods。状态最终应更改为CrashLoopBackOff

如您所见,go-demo-2-db Pod 的状态是OOMKilled内存溢出终止)。Kubernetes 检测到实际使用量远超限制,且将 Pod 标记为终止候选者。容器随后被终止。Kubernetes 稍后会重新创建已终止的容器,但很快会发现内存使用仍然超出限制。如此反复循环,直到继续下去。

如果节点有足够的可用内存,容器可以超过其内存请求。另一方面,容器不能使用超过限制的内存。当发生这种情况时,它将成为终止的候选者。

让我们描述一下部署并查看db容器的状态:

kubectl describe pod go-demo-2-db  

限制输出的相关部分如下:

...
Containers:
 db:
 ...
 Last State:     Terminated
 Reason:       OOMKilled
 Exit Code:    137
 ...
Events:
 Type    Reason  Age             From              Message
 ----    ------  ----            ----              -------
 ...
 Warning BackOff 3s (x8 over 1m) kubelet, minikube Back-off restarting failed container

我们可以看到db容器的最后状态是OOMKilled。当我们查看事件时,可以看到到目前为止,该容器因BackOff原因已重启了八次。

让我们通过另一个更新的定义来探索另一种可能的情况:

cat res/go-demo-2-insuf-node.yml  

如之前所述,更改仅影响go-demo-2-db部署中的resources部分。相关部分的输出如下:

apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: go-demo-2-db 
spec: 
  ... 
  template: 
    ... 
    spec: 
      containers: 
      - name: db 
        image: mongo:3.3 
        resources: 
          limits: 
            memory: 8Gi 
            cpu: 0.5 
          requests: 
            memory: 4Gi 
            cpu: 0.3 

这次,我们指定请求的内存是节点总内存(2GB)的两倍。内存限制甚至更高。

让我们应用这个更改并观察会发生什么:

kubectl apply \  
    -f res/go-demo-2-insuf-node.yml \ 
    --record 

kubectl get pods 

后续命令的输出如下:

NAME                           READY STATUS  RESTARTS AGE
go-demo-2-api-796db5987d-8wbk4 1/1   Running 0        8m
go-demo-2-api-796db5987d-w6mnx 1/1   Running 0        8m
go-demo-2-api-796db5987d-wtz4q 1/1   Running 0        9m
go-demo-2-db-5d5c46bc7c-d676j  0/1   Pending 0        13s  

这次,Pod 的状态是Pending。Kubernetes 无法将其放置在集群中的任何地方,并正在等待直到情况有所变化。

尽管内存请求与容器相关,但通常更有意义的是将它们转化为 Pod 的需求。我们可以说,Pod 的请求内存是构成该 Pod 的所有容器请求的总和。在我们的案例中,Pod 只有一个容器,因此它们的请求是相等的。限制也是如此。

在调度过程中,Kubernetes 会将一个 Pod 的请求进行求和,并寻找一个有足够可用内存和 CPU 的节点。如果 Pod 的请求无法满足,Pod 将进入待处理状态,期待某个节点释放资源,或者集群中加入新服务器。由于在我们这种情况下不会发生这样的事情,通过go-demo-2-db部署创建的 Pod 将永远处于待处理状态,除非我们再次更改内存请求。

当 Kubernetes 无法找到足够的空闲资源来满足构成 Pod 的所有容器的资源请求时,它会将状态更改为Pending。这些 Pod 将保持此状态,直到请求的资源变得可用。

让我们描述一下 go-demo-2-db 部署,看看是否能从中获取一些额外的有用信息。

kubectl describe pod go-demo-2-db  

限制在事件部分的输出如下:

...
Events:
 Type    Reason           Age               From              Message
 ----    ------           ----              ----              -------
  Warning FailedScheduling 11s (x7 over 42s) default-scheduler 0/
 1 nodes are available: 1 Insufficient memory.

我们可以看到它已经 FailedScheduling 了七次,且消息清楚地表明存在 Insufficient memory

我们将恢复到初始定义。尽管我们知道它的资源配置不正确,但我们知道它满足所有要求,且所有 Pod 将成功调度:

kubectl apply \  
    -f res/go-demo-2-random.yml \  
    --record 

kubectl rollout status \  
    deployment go-demo-2-db 

kubectl rollout status \ 
    deployment go-demo-2-api 

现在所有 Pod 都在运行,我们应该尝试编写更好的定义。为此,我们需要观察内存和 CPU 使用情况,并利用这些信息来决定请求和限制。

基于实际使用情况调整资源

我们已经看到了资源使用和资源规格之间差异可能带来的一些影响。调整我们的规格以更好地反映实际的内存和 CPU 使用情况是理所当然的。

我们从数据库开始:

DB_POD_NAME=$(kubectl get pods \  
    -l service=go-demo-2 \  
    -l type=db \ 
    -o jsonpath="{.items[0].metadata.name}") 

curl "$BASE_URL/$DB_POD_NAME/containers/db/metrics/memory/usage" 

curl "$BASE_URL/$DB_POD_NAME/containers/db/metrics/cpu/usage_rate" 

我们检索到了数据库 Pod 的名称,并使用它获取了 db 容器的内存和 CPU 使用情况。因此,我们现在知道内存使用量在 30Mi40Mi 之间。同样,我们知道 CPU 消耗大约在 5m 附近。

让我们拿 api 容器的相同指标来看看。

API_POD_NAME=$(kubectl get pods \ 
    -l service=go-demo-2 \ 
    -l type=api \ 
    -o jsonpath="{.items[0].metadata.name}") 

curl "$BASE_URL/$API_POD_NAME/containers/api/metrics/memory/usage" 

curl 
 "$BASE_URL/$API_POD_NAME/containers/api/metrics/cpu/usage_rate" 

正如预期的那样,api 容器使用的资源甚至比 MongoDB 还少。它的内存在 3Mi7Mi 之间。它的 CPU 使用率低到 Heapster 将其四舍五入为 0m

具备了这些知识,我们可以继续更新我们的 YAML 定义。然而,在此之前,我需要澄清一些事情。

我们收集的指标基于一些什么也不做的应用程序。一旦它们开始承载实际负载并开始处理生产规模的数据,指标将会发生剧烈变化。你需要的是一种方法来预测应用程序在生产环境中将使用多少资源,而不是在简单的测试环境中。你可能倾向于进行压力测试,以模拟生产环境。这样做是有意义的,但不一定能得到与实际生产环境相似的行为。

复制生产环境和模拟真实用户行为是很困难的。压力测试能够帮助你走一半的路。剩下的一半,你需要在生产环境中监控你的应用,并且,除了其他事情外,还需要根据实际情况调整资源。你应该考虑许多额外的因素,但目前,我想强调的是,什么都不做的应用并不是衡量资源使用的好标准。不过,我们将假设当前运行的应用程序承受的是类似生产环境的负载,且我们收集的指标代表了这些应用在生产中的表现。

简单的测试环境无法反映生产环境中资源的使用情况。压力测试是一个好的开始,但并不是一个完整的解决方案。只有生产环境提供真实的度量数据。

让我们来看看一个更能代表应用程序资源使用的新定义。

cat res/go-demo-2.yml  

输出,仅限于相关部分如下:

apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: go-demo-2-db 
spec: 
  ... 
  template: 
    ... 
    spec: 
      containers: 
      - name: db 
        image: mongo:3.3 
        resources: 
          limits: 
            memory: "100Mi" 
            cpu: 0.1 
          requests: 
            memory: "50Mi" 
            cpu: 0.01 
... 
apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: go-demo-2-api 
spec: 
  ... 
  template: 
    ... 
    spec: 
      containers: 
      - name: api 
        image: vfarcic/go-demo-2 
        ... 
        resources: 
          limits: 
            memory: "10Mi" 
            cpu: 0.1 
          requests: 
            memory: "5Mi" 
            cpu: 0.01 

这样就好多了。资源请求仅略高于当前使用量。我们将内存限制设置为请求值的两倍,以便应用程序在偶尔(且短暂)需要额外内存时,有充足的资源。CPU 限制远高于请求值,主要是因为我太不好意思将 CPU 限制设置为不到十分之一的 CPU。无论如何,关键在于请求值接近实际使用情况,而限制值较高,以便在资源使用暂时激增时,应用程序有些喘息空间。

现在剩下的就是应用新的定义:

kubectl apply \  
    -f res/go-demo-2.yml \  
    --record 

kubectl rollout status \ 
    deployment go-demo-2-api 

deployment "go-demo-2-api" 已成功发布,我们可以进入下一个主题。

探索服务质量(QoS)合同

当我们向 Kubernetes API 发送请求创建一个 Pod(直接或通过其中一个控制器)时,它会启动调度过程。接下来会发生什么,或者更准确地说,它决定在哪个地方运行 Pod,主要取决于我们为组成该 Pod 的容器定义的资源。简而言之,Kubernetes 会决定在某个节点上部署 Pod,只要该节点有足够的可用内存。

当定义了内存请求时,Pod 将获得它们请求的内存。如果某个容器的内存使用量超过请求的数量,或者其他 Pod 需要这些内存,那么托管该容器的 Pod 可能会被终止。请注意,我说的是 Pod 可能 被终止。是否发生这种情况取决于其他 Pod 的请求和集群中可用的内存。另一方面,超出内存限制的容器总是会被终止(除非是暂时的情况)。

CPU 请求和限制有些不同。超过指定 CPU 资源的容器不会被终止。相反,它们会被限制。

现在我们稍微了解了 Kubernetes 的终止机制,我们应该注意到(几乎)没有什么是随机发生的。当没有足够的资源来满足所有 Pod 的需求时,Kubernetes 会销毁一个或多个容器。决定销毁哪一个是绝非随机的。谁将成为不幸的那个,取决于分配的 服务质量QoS)。优先级最低的会最先被销毁。

由于这可能是你第一次听说 QoS,我们将花一些时间来解释它们是什么以及如何工作。

Pods 是 Kubernetes 中最小的单元。由于几乎所有东西都最终作为 Pod 存在(无论以何种方式),因此 Kubernetes 承诺为集群中运行的所有 Pods 提供特定的保证也就不足为奇了。每当我们向 API 发送请求以创建或更新 Pod 时,Pod 会被分配一个 QoS 类别。这些 QoS 用于做出决策,例如在哪里调度 Pod 或是否驱逐它。

我们不会直接指定 QoS。相反,它们是根据我们在资源请求和限制上的决策来分配的。

目前,有三种 QoS 类别可用。每个 Pod 都可以拥有 GuaranteedBurstableBestEffort QoS。

Guaranteed QoS 只分配给那些为所有容器设置了 CPU 请求和限制以及内存请求和限制的 Pods。我们使用上一个定义创建的 Pods 符合这一标准。然而,还有一个必须满足的必要条件。每个容器的请求和限制值必须相同。不过,这里有个陷阱。当容器仅指定限制时,请求会自动设置为与限制相同的值。换句话说,当容器没有指定请求时,只要它们的限制已定义,它们将拥有 Guaranteed QoS。

我们可以总结出保证 QoS 的标准如下:

  • 必须设置内存和 CPU 限制

  • 内存和 CPU 请求必须设置为与限制相同的值,或者可以留空,在这种情况下,它们默认为限制值(我们稍后会详细探讨)。

分配了 Guaranteed QoS 的 Pods 是最高优先级,除非它们超出限制或不健康,否则永远不会被杀掉。当出现问题时,它们是最后一个被终止的。只要它们的资源使用量在限制范围内,Kubernetes 总是会选择在资源使用超出容量时终止具有其他 QoS 分配的 Pods。

让我们继续下一个 QoS。

Burstable QoS 分配给不符合 Guaranteed QoS 标准但至少有一个容器定义了内存或 CPU 请求的 Pods。

具有 Burstable QoS 的 Pods 保证最小(请求的)内存使用量。如果有更多资源可用,它们可能会使用更多资源。如果系统处于压力状态并需要更多可用内存,则具有 Burstable QoS 的容器比那些具有 Guaranteed QoS 的容器更容易被杀掉,前提是没有具有 BestEffort QoS 的 Pods。你可以将具有该 QoS 的 Pods 看作中等优先级。

最后,我们到达了最后一个 QoS。

BestEffort QoS 分配给不符合 Guaranteed 或 Burstable 标准的 Pods。它们是由没有定义任何资源的容器组成的 Pods。符合 BestEffort 条件的 Pods 中的容器可以使用任何它们需要的可用内存。

当需要更多资源时,Kubernetes 将开始终止位于 BestEffort QoS 的 Pods 中的容器。它们的优先级最低,当需要更多内存时,它们是最先被终止的。

让我们来看一下我们的 go-demo-2-db Pod 被分配了哪种 QoS。

kubectl describe pod go-demo-2-db  

输出,限制为相关部分,如下所示:

... 
Containers: 
  db: 
    ... 
    Limits: 
      cpu:    100m 
      memory: 100Mi 
    Requests: 
      cpu:    10m 
      memory: 50Mi 
... 
QoS Class:       Burstable 
... 

该 Pod 被分配了可突发的 QoS。它的限制与请求不同,因此不符合保证的 QoS。由于它的资源已设置,并且不符合保证的 QoS,Kubernetes 将其分配为第二优先级的 QoS。

现在,让我们看一下稍作修改的定义:

cat res/go-demo-2-qos.yml  

输出,限制为相关部分,如下所示:

apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: go-demo-2-db 
spec: 
  ... 
  template: 
    ... 
    spec: 
      containers: 
      - name: db 
        image: mongo:3.3 
        resources: 
          limits: 
            memory: "50Mi" 
            cpu: 0.1 
          requests: 
            memory: "50Mi" 
            cpu: 0.1 
... 
apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: go-demo-2-api 
spec: 
  ... 
  template: 
    ... 
    spec: 
      containers: 
      - name: api 
        image: vfarcic/go-demo-2 
... 

这一次,我们指定了 cpumemory 对容器的 requestslimits 应具有相同的值,以便通过 go-demo-2-db Deployment 创建的容器。因此,它应该被分配为 Guaranteed QoS。

go-demo-2-api Deployment 的容器没有任何 resources 定义,因此将被分配为 BestEffort QoS。

让我们确认这两个假设(不说是猜测)是否确实正确。

kubectl apply \  
    -f res/go-demo-2-qos.yml \ 
    --record 

kubectl rollout status \ 
    deployment go-demo-2-db 

我们应用了新的定义并输出了 go-demo-2-db Deployment 的滚动更新状态。

现在我们可以描述通过 go-demo-2-db Deployment 创建的 Pod,并检查其 QoS。

kubectl describe pod go-demo-2-db  

输出,限制为相关部分,如下所示:

Containers: 
  db: 
    ... 
    Limits: 
      cpu:    100m 
      memory: 50Mi 
    Requests: 
      cpu:    100m 
      memory: 50Mi 
... 
QoS Class: Guaranteed 
... 

内存和 CPU 的限制与请求相同,因此 QoS 为 Guaranteed

让我们检查通过 go-demo-2-api Deployment 创建的 Pods 的 QoS。

kubectl describe pod go-demo-2-api  

输出,限制为相关部分,如下所示:

... 
QoS Class:       BestEffort 
... 
QoS Class:       BestEffort 
... 
QoS Class:       BestEffort 
... 

通过 go-demo-2-api Deployment 创建的三个 Pods 没有任何资源定义,因此它们的 QoS 被设置为 BestEffort

我们将不再需要之前创建的对象,所以我们在进入下一个主题之前会先将它们删除。

kubectl delete \
 -f res/go-demo-2-qos.yml

在命名空间内定义资源默认值和限制

我们已经学习了如何利用 Kubernetes 命名空间在一个集群内创建多个子集群。当与 RBAC 结合使用时,我们可以创建命名空间并授予用户使用这些命名空间的权限,而不暴露整个集群。然而,仍然有一件事缺失。

我们可以假设,创建一个 test 命名空间,并允许用户在不允许其访问其他命名空间的情况下创建对象。尽管这样比允许所有人完全访问集群要好,但这种策略并不能防止人们将整个集群宕机或影响在其他命名空间中运行的应用程序的性能。我们缺少的那一块拼图就是在命名空间级别上的资源控制。

我们已经讨论过每个容器都应该定义资源的 limitsrequests。这些信息帮助 Kubernetes 更高效地调度 Pods。它还为 Kubernetes 提供了可以用来决定是否驱逐或重启 Pod 的信息。然而,虽然我们可以指定 resources,并不意味着我们必须强制定义它们。我们应该有能力设置默认的 resources,以便在我们忘记显式指定时自动应用。

即使我们定义了默认的 resources,我们也需要一种设置限制的方式。否则,所有具有部署 Pod 权限的人都有可能运行请求资源超过我们愿意提供的应用程序。

总而言之,我们下一步的任务是定义默认请求和限制,并指定可以为 Pod 定义的最小和最大值。

我们将从创建一个 test 命名空间开始。

kubectl create namespace test  

创建了一个 playground 命名空间后,我们可以查看一个新的定义。

cat res/limit-range.yml  

输出如下:

apiVersion: v1 
kind: LimitRange 
metadata: 
  name: limit-range 
spec: 
  limits: 
  - default: 
      memory: 50Mi 
      cpu: 0.2 
    defaultRequest: 
      memory: 30Mi 
      cpu: 0.05 
    max: 
      memory: 80Mi 
      cpu: 0.5 
    min: 
      memory: 10Mi 
      cpu: 0.01 
    type: Container 

我们指定资源应该是 LimitRange 类型。它的 spec 有四个 limits

对于没有指定资源的容器,将应用 default 限制和 defaultRequest 条目。如果容器没有内存或 CPU 限制,它将被分配到 LimitRange 中设置的值。default 条目用作限制,defaultRequest 条目用作请求。

当容器确实定义了资源时,它们将根据 LimitRange 中指定的 maxmin 阈值进行评估。如果容器不符合标准,将不会创建承载容器的 Pod。

我们很快将看到四个 limits 的实际实现。目前,下一步是创建 limit-range 资源:

kubectl --namespace test create \  
    -f res/limit-range.yml \  
    --save-config --record 

我们创建了 LimitRange 资源。

让我们描述创建资源的 test 命名空间。

kubectl describe namespace test  

输出仅限于相关部分如下。

...
Resource Limits
 Type      Resource Min  Max  Default Request Default Limit  Max  Limit/Request Ratio
 ----      -------- ---  ---  --------------- ------------- ----- ------------------
 Container cpu      10m  500m 50m             200m          -
 Container memory   10Mi 80Mi 30Mi            50Mi          - 

我们可以看到 test 命名空间拥有我们指定的资源限制。我们设置了五个可能的值中的四个。maxLimitRequestRatio 没有被设置,我们将简要描述它。当设置了 MaxLimitRequestRatio 时,容器的请求和限制资源必须同时非零,并且限制除以请求必须小于或等于枚举值。

让我们再看一个 go-demo 定义的变体:

cat res/go-demo-2-no-res.yml  

值得注意的是,没有一个容器定义了任何资源。

接下来,我们将创建在 go-demo-2-no-res.yml 文件中定义的对象。

kubectl --namespace test create \  
    -f res/go-demo-2-no-res.yml \ 
    --save-config --record 

kubectl --namespace test \ 
    rollout status \ 
    deployment go-demo-2-api 

我们在 test 命名空间内创建了对象,并等待成功部署 deployment "go-demo-2-api"

让我们描述我们创建的一个 Pod:

kubectl --namespace test describe \  
    pod go-demo-2-db 

输出仅限于相关部分如下:

... 
Containers: 
  db: 
    ... 
    Limits: 
      cpu:     200m 
      memory:  50Mi 
    Requests: 
      cpu:        50m 
      memory:     30Mi 
... 

即使我们没有明确指定 go-demo-2-db Pod 内 db 容器的资源,资源已经被设置。db 容器被分配了 test 命名空间的 default 限制作为容器的限制。类似地,defaultRequest 限制被用作容器的请求。

当我们尝试创建未指定资源的容器宿主 Pod 时,将会受到命名空间限制的影响。

我们仍然应该定义容器资源,而不是依赖命名空间的默认限制。毕竟,它们只是在有人忘记定义资源时的备选项。

让我们看看当资源被定义但不符合命名空间 minmax 限制时会发生什么。

我们将使用之前使用的相同go-demo-2.yml

cat res/go-demo-2.yml  

输出,限定为相关部分,如下所示:

... 
apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: go-demo-2-db 
spec: 
  ... 
  template: 
    ... 
    spec: 
      containers: 
      - name: db 
        image: mongo:3.3 
        resources: 
          limits: 
            memory: "100Mi" 
            cpu: 0.1 
          requests: 
            memory: "50Mi" 
            cpu: 0.01 
... 
apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: go-demo-2-api 
spec: 
  ... 
  template: 
    ... 
    spec: 
      containers: 
      - name: api 
        ... 
        resources: 
          limits: 
            memory: "10Mi" 
            cpu: 0.1 
          requests: 
            memory: "5Mi" 
            cpu: 0.01 
... 

关键点在于,两个部署(Deployments)的resources都已定义。

让我们创建这些对象并获取事件。这些将帮助我们更好地理解发生了什么。

kubectl --namespace test apply \
 -f res/go-demo-2.yml \
 --record

kubectl --namespace test get events -w  

后一个命令的输出,限定为相关部分,如下所示:

    ... Error creating: pods "go-demo-2-db-868dbbc488-s92nm" is forbi
 dden: maximum memory usage per Container is 80Mi, but limit is 100Mi.
    ...
    ... Error creating: pods "go-demo-2-api-6bd767ffb6-96mbl" is 
 forbidden: minimum memory usage per Container is 10Mi, but request is 
 5Mi.
    ...

我们可以看到我们被禁止创建这两个 Pod。事件之间的区别在于是什么原因导致 Kubernetes 拒绝了我们的请求。

go-demo-2-db-* Pod 无法创建,因为其每个容器的最大内存使用量为 80Mi,但限制为 100Mi。另一方面,我们被禁止创建go-demo-2-api-* Pods,因为每个容器的最小内存使用量为 10Mi,但请求为 5Mi

test命名空间中的所有容器必须遵守minmax限制。否则,我们将被禁止创建它们。容器的限制不能高于命名空间的max限制。另一方面,容器资源请求不能小于命名空间的min限制。

如果我们将命名空间的限制视为上下阈值,我们可以说容器请求不能低于它们,而容器限制不能高于它们。

按下Ctrl + C键以停止观察事件。

如果我们直接创建 Pod 而不是通过部署(Deployments),可能更容易观察到maxmin限制的效果。

kubectl --namespace test run test \  
    --image alpine \ 
    --requests memory=100Mi \ 
    --restart Never \  
    sleep 10000 

我们尝试创建一个内存请求设置为100Mi的 Pod。由于命名空间的限制为80Mi,API 返回了错误信息,表示Pod "test" 无效。尽管max限制是针对容器的limit,但在没有限制的情况下使用了内存请求。

我们将进行类似的练习,但这次只设置1Mi作为内存请求。

kubectl --namespace test run test \ 
    --image alpine \ 
    --requests memory=1Mi \  
    --restart Never \ 
    sleep 10000 

这次,错误稍有不同。我们可以看到pods "test" 被禁止:每个容器的最小内存使用量为 10Mi,但请求为 1Mi。我们请求的值低于test命名空间的min限制,因此我们被禁止创建 Pod。

在进入下一个主题之前,我们将删除test命名空间。

kubectl delete namespace test  

为命名空间定义资源配额

资源默认值和限制是防止恶意或意外部署 Pod 的良好第一步,这些 Pod 可能对集群产生不良影响。然而,任何具有在命名空间中创建 Pod 权限的用户都可以超载系统。即使将max值设置为合理较小的内存和 CPU 量,用户也可能部署成千上万,甚至数百万个 Pod,从而“消耗”掉所有可用的集群资源。这种效果可能并非出于恶意,而是意外发生的。例如,一个 Pod 可能附加到一个自动扩展的系统上,并且没有定义上限,结果它可能会扩展到太多副本。还有很多其他方式可能导致情况失控。

我们需要做的是通过配额来定义命名空间的边界。

通过配额,我们可以保证每个命名空间获得其应有的资源份额。与适用于每个容器的LimitRange规则不同,ResourceQuota基于资源总消耗定义命名空间的限制。

我们可以使用ResourceQuota对象来定义命名空间中可以消耗的总计算资源(内存和 CPU)。我们还可以用它来限制存储使用或命名空间中可以创建的某一类型对象的数量。

让我们来看看在 Minikube 集群中我们拥有的资源。它很小,甚至不是真正的集群。然而,它是我们唯一拥有的(暂时),所以请发挥你的想象力,假装它是“真实的”。

我们的集群有 2 个 CPU 和 2GB 内存。现在,假设这个集群仅用于开发和生产目的。我们可以使用default命名空间用于生产,并创建一个dev命名空间用于开发。我们可以假设生产环境应该消耗集群的所有资源,减去分配给dev命名空间的资源,而dev命名空间则不应超过特定的资源限制。

事实是,2 个 CPU 和 2GB 内存的情况下,我们能够分配给开发者的资源并不多。尽管如此,我们还是会尽量慷慨一些。我们将为请求分配 500MB 和 0.8 个 CPU。我们还将通过设置 1 个 CPU 和 1GB 内存的限制来允许资源使用时偶尔的突发需求。此外,我们可能希望将 Pod 的数量限制为 10 个。最后,为了降低风险,我们将拒绝开发者使用 NodePort 的权限。

这不是一个不错的计划吗?我想象在这一刻,你正在点头表示同意,所以我们继续前进,创建我们讨论过的配额。

让我们来看看dev.yaml的定义:

cat res/dev.yml  

输出如下:

apiVersion: v1
kind: Namespace
metadata:
 name: dev

---

apiVersion: v1
kind: ResourceQuota
metadata:
  name: dev
  namespace: dev
spec:
  hard:
    requests.cpu: 0.8
    requests.memory: 500Mi
    limits.cpu: 1
    limits.memory: 1Gi
    pods: 10
    services.nodeports: "0"  

除了创建dev命名空间,我们还创建了一个ResourceQuota。它指定了一组hard限制。请记住,它们是基于聚合数据的,而不是像 LimitRanges 那样按容器计算的。

我们设置了请求配额为0.8个 CPU 和500Mi内存。类似地,限制配额设置为1个 CPU 和1Gi内存。最后,我们指定dev命名空间最多可以有10个 Pod,且不能有 NodePort。这就是我们制定和定义的计划。现在让我们创建这些对象并探索其效果。

kubectl create \ 
 -f res/dev.yml \ 
 --record --save-config  

我们可以从输出中看到namespace "dev"resourcequota "dev"都已创建。为了安全起见,我们将描述新创建的devquota

kubectl --namespace dev describe \ 
    quota dev 

输出如下:

Name:               dev
Namespace:          dev
Resource            Used  Hard
--------            ----  ----
limits.cpu          0     1
limits.memory       0     1Gi
pods                0     10
requests.cpu        0     800m
requests.memory     0     500Mi
services.nodeports  0     0

我们可以看到硬性限制已设置,并且当前没有使用。这是预料之中的,因为我们没有在dev命名空间中运行任何对象。让我们通过创建已经过于熟悉的go-demo-2对象来让它变得有趣一些。

kubectl --namespace dev create \
 -f res/go-demo-2.yml \ 
 --save-config --record

kubectl --namespace dev \ 
 rollout status \ 
 deployment go-demo-2-api  

我们从go-demo-2.yml文件中创建了对象,并等待go-demo-2-api部署完成。现在我们可以重新查看dev配额的值:

kubectl --namespace dev describe \ 
    quota dev 

输出如下:

Name:              dev
Namespace:         dev
Resource           Used  Hard
--------           ----  ----
limits.cpu         400m  1
limits.memory      130Mi 1Gi
pods               4     10
requests.cpu       40m   800m
requests.memory    65Mi  500Mi
services.nodeports 0     0  

Used 列中,我们可以看到例如我们目前正在运行 4 个 Pod,并且仍然低于 10 的限制。其中一个 Pod 是通过 go-demo-2-db 部署创建的,另外三个是通过 go-demo-2-api 创建的。如果你总结这些 Pod 中容器的资源配置,你会看到这些值与已使用的 limitsrequests 匹配。

到目前为止,我们还没有达到任何配额。让我们尝试打破至少一个配额。

cat res/go-demo-2-scaled.yml  

仅限相关部分的输出如下:

... 
apiVersion: apps/v1beta2 
kind: Deployment 
metadata: 
  name: go-demo-2-api 
spec: 
  replicas: 15 
  ... 

go-demo-2-scaled.yml 的定义几乎与 go-demo-2.yml 相同。唯一的不同是 go-demo-2-api 部署的副本数增加到十五个。正如你所知道的,这将导致通过该部署创建十五个 Pod。

我敢肯定你能猜到如果我们应用新的定义会发生什么,但我们还是会继续这样做。

kubectl --namespace dev apply \
 -f res/go-demo-2-scaled.yml \
 --record

我们应用了新的定义。我们将给 Kubernetes 一些时间来完成工作,然后再查看它将生成的事件。因此,深呼吸,从一数到你笔记本电脑上处理器的数量。以我为例,应该是“一 Mississippi,二 Mississippi,三 Mississippi”,一直数到十六 Mississippi。

kubectl --namespace dev get events  

dev 命名空间内生成的一些事件的输出如下:

...
... Error creating: pods "go-demo-2-api-..." is forbidden: exceeded quota: dev, requested: limits.cpu=100m,pods=1, used: limits.cpu=1,pods=10, limited: limits.cpu=1,pods=10
    13s         13s          1         go-demo-2-api-6bd767ffb6.150f5   1f4b3a7ed3f         ReplicaSet                          Warning .
 .. Error creating: pods "go-demo-2-api-..." is forbidden: exceeded quota: dev, requested: limits.cpu=100m,pods=1, used: limits.cpu=1,pods=10, limited: limits.cpu=1,pods=10
... 

我们可以看到我们达到了命名空间配额施加的两个限制。我们达到了 CPU 的最大值(1)和 Pods 的最大值(10)。因此,ReplicaSet 控制器被禁止创建新的 Pod。

我们应该能够通过描述 dev 命名空间来确认达到的硬限制。

kubectl describe namespace dev  

仅限于 Resource Quotas 部分的输出如下:

... 
Resource Quotas 
 Name:               dev 
 Resource            Used   Hard 
 --------            ---    --- 
 limits.cpu          1      1 
 limits.memory       190Mi  1Gi 
 pods                10     10 
 requests.cpu        100m   800m 
 requests.memory     95Mi   500Mi 
 services.nodeports  0      0 
... 

如事件所示,limits.cpupods 资源在 UserHard 列中的值是相同的。因此,我们将无法再创建更多的 Pod,也无法为已运行的 Pod 增加 CPU 限制。

最后,让我们来看一下 dev 命名空间中的 Pod。

kubectl get pods --namespace dev  

以下是前面命令的输出:

NAME              READY STATUS  RESTARTS AGE
go-demo-2-api-... 1/1   Running 0        3m
go-demo-2-api-... 1/1   Running 0        3m
go-demo-2-api-... 1/1   Running 0        5m
go-demo-2-api-... 1/1   Running 0        3m
go-demo-2-api-... 1/1   Running 0        5m
go-demo-2-api-... 1/1   Running 0        3m
go-demo-2-api-... 1/1   Running 0        3m
go-demo-2-api-... 1/1   Running 0        3m
go-demo-2-api-... 1/1   Running 0        5m
go-demo-2-db-...  1/1   Running 0        5m  

go-demo-2-api 部署成功创建了九个 Pod。再加上通过 go-demo-2-db 创建的 Pod,我们达到了十个的上限。

我们确认了限制和 Pod 配额有效。我们将在进行下一次验证之前恢复到先前的定义(即不会达到任何配额的定义)。

kubectl --namespace dev apply \
 -f res/go-demo-2.yml \
 --record

kubectl --namespace dev \
 rollout status \
 deployment go-demo-2-api  

后者命令的输出应表明 deployment "go-demo-2-api" 已成功发布

让我们来看一下另一个稍微修改过的 go-demo-2 对象定义:

cat res/go-demo-2-mem.yml  

仅限相关部分的输出如下:

...
apiVersion: apps/v1beta2
kind: Deployment
metadata:
 name: go-demo-2-db
spec:
 ...
 template:
 ...
 spec:
 containers:
 - name: db
 image: mongo:3.3
 resources:
 limits:
 memory: "100Mi"
 cpu: 0.1
 requests:
 memory: "50Mi"
 cpu: 0.01
...
apiVersion: apps/v1beta2
kind: Deployment
metadata:
 name: go-demo-2-api
spec:
 replicas: 3
 ...
 template:
 ...
 spec:
 containers:
 - name: api
 ...
 resources:
 limits:
 memory: "200Mi"
 cpu: 0.1
 requests:
 memory: "200Mi"
 cpu: 0.01
...  

go-demo-2-api 部署的 api 容器的内存请求和限制都设置为 200Mi,而数据库的内存请求为 50Mi。知道 dev 命名空间的 requests.memory 配额为 500Mi,简单的数学运算足以得出结论,我们无法运行 go-demo-2-api 部署的所有三个副本。

kubectl --namespace dev apply \
 -f res/go-demo-2-mem.yml \
 --record  

就像之前一样,我们应该等一会儿,再查看 dev 命名空间的事件。

kubectl --namespace dev get events \
 | grep mem  

输出仅限于其中一个条目的部分,如下所示:

... Error creating: pods "go-demo-2-api-..." is forbidden: exceeded quota: dev, requested: requests.memory=200Mi, used: requests.memory=455Mi, limited: requests.memory=500Mi

我们达到了 requests.memory 配额。因此,至少一个 Pod 的创建被禁止。我们可以看到,我们请求创建了一个内存请求为 200Mi 的 Pod。由于当前内存请求的汇总为 455Mi,创建该 Pod 会超过分配的 500Mi

让我们仔细看看命名空间。

kubectl describe namespace dev  

输出仅限于 Resource Quotas 部分,如下所示:

...
Resource Quotas
 Name:               dev
 Resource            Used   Hard
 --------            ---    ---
 limits.cpu          400m   1
 limits.memory       510Mi  1Gi
 pods                4      10
 requests.cpu        40m    800m
 requests.memory     455Mi  500Mi
 services.nodeports  0      0
...  

确实,已使用的内存请求量为 455Mi,这意味着我们可以创建额外的 Pod,最多 45Mi,而不是 200Mi

在我们探索最后一个定义的配额之前,我们将再次恢复到 go-demo-2.yml

kubectl --namespace dev apply \ 
 -f res/go-demo-2.yml \ 
 --record

kubectl --namespace dev \
 rollout status \
 deployment go-demo-2-api  

唯一未验证的配额是 services.nodeports。我们将其设置为 0,因此我们不应该允许暴露任何节点端口。让我们确认这一点是否确实正确。

kubectl expose deployment go-demo-2-api \
 --namespace dev \
 --name go-demo-2-api \ 
 --port 8080 \
 --type NodePort  

输出如下所示:

Error from server (Forbidden): services "go-demo-2-api" is forbidden: exceeded quota: dev, requested: services.nodeports=1, used: services.nodeports=0, limited: services.nodeports=0

所有配额按预期工作。但还有其他配额。我们没有时间探索我们可以使用的所有配额的示例。相反,我们将它们全部列出,供将来参考。

我们可以将配额分为几个组:

计算资源配额 限制计算资源的总和。它们如下所示:

资源名称 描述
cpu 在所有非终止状态的 Pod 中,CPU 请求的总和不能超过此值。
limits.cpu 在所有非终止状态的 Pod 中,CPU 限制的总和不能超过此值。
limits.memory 在所有非终止状态的 Pod 中,内存限制的总和不能超过此值。
memory 在所有非终止状态的 Pod 中,内存请求的总和不能超过此值。
requests.cpu 在所有非终止状态的 Pod 中,CPU 请求的总和不能超过此值。
requests.memory 在所有非终止状态的 Pod 中,内存请求的总和不能超过此值。

存储资源配额 限制存储资源的总和。我们尚未深入探索存储(除了几个本地示例),所以你可能需要保留下面的列表以备将来参考:

资源名称 描述
requests.storage 在所有持久卷声明中,存储请求的总和不能超过此值。
persistentvolumeclaims 命名空间中可以存在的持久卷声明的总数。
[PREFIX]/requests.storage 与存储类名称相关的所有持久卷索赔的总存储请求不能超过此值。
[PREFIX]/persistentvolumeclaims 与存储类名称相关的所有持久卷索赔的总数,可存在于命名空间中。
requests.ephemeral-storage 命名空间中所有 Pod 的本地临时存储请求总和不能超过此值。
limits.ephemeral-storage 命名空间中所有 Pod 的本地临时存储限制的总和不能超过此值。

请注意,[PREFIX]应替换为<storage-class-name>.storageclass.storage.k8s.io

对象计数配额 限制了给定类型对象的数量。它们如下:

资源名称 描述
configmaps 可存在于命名空间中的配置映射总数。
persistentvolumeclaims 可存在于命名空间中的持久卷索赔总数。
pods 可存在于命名空间中的非终端状态下的 Pod 总数。如果 status.phase 为(Failed,Succeeded),则 Pod 处于终止状态。
replicationcontrollers 可存在于命名空间中的复制控制器总数。
resourcequotas 可存在于命名空间中的资源配额总数。
services 可存在于命名空间中的服务总数。
services.loadbalancers 可存在于命名空间中的负载均衡器类型的服务总数。
services.nodeports 可存在于命名空间中的节点端口类型的服务总数。
secrets 可存在于命名空间中的秘密总数。

现在怎么办?

那不是一次很好的体验吗?

Kubernetes 在整个集群中广泛依赖可用资源。但它不能变出魔法。我们需要通过定义我们预期容器将消耗的资源来帮助它。

尽管 Heapster 不是收集指标的最佳解决方案,但它已经在我们的 Minikube 集群中可用,我们用它来了解我们的应用程序使用多少资源,通过这些信息,我们优化了我们的资源定义。没有指标,我们的定义就是纯粹的猜测。当我们猜测时,Kubernetes 也需要猜测。一个稳定的系统是基于事实而不是想象的可预测系统。Heapster 帮助我们将假设转化为可衡量的事实,然后我们将这些事实提供给 Kubernetes,在其调度算法中使用。

资源定义的探索引导我们进入服务质量QoS)。尽管 Kubernetes 决定使用哪种 QoS,但如果我们要优先考虑应用程序及其可用性,了解决策过程中使用的规则是至关重要的。

所有这些将引导我们走向确保集群安全、稳定和强大的战略的顶点。将集群划分为命名空间并采用 RBAC 还远远不够。RBAC 可以防止未经授权的用户访问集群,并为我们信任的用户提供权限。然而,RBAC 并不能阻止用户通过过多的部署、过大的应用程序或不准确的资源配置,意外(或故意)将集群置于危险之中。只有将 RBAC 与资源默认值、限制和配额结合使用,我们才能期望构建一个容错且稳健的集群,能够可靠地托管我们的应用程序。

我们几乎学完了所有 Kubernetes 的核心对象和原理。现在是时候迁移到一个“真实”的集群了。我们即将最后一次删除 Minikube 集群(至少在本书中是最后一次)。

minikube delete

Kubernetes 资源管理与 Docker Swarm 的对比

资源管理可以分为几个类别。我们需要定义一个容器预期使用多少内存和 CPU,以及它的资源限制。这些信息对调度器做出“智能”决策至关重要,尤其是在计算容器部署位置时。在这一点上,Kubernetes 和 Docker Swarm 没有本质上的区别。两者都使用请求的资源来决定容器的部署位置,并通过资源限制来决定何时驱逐容器。在这一点上,它们基本相同。

我们如何知道应该为每个容器分配多少内存和 CPU?这是我听到过无数次的问题。答案很简单。收集指标,评估它们,调整资源,休息一下,然后重复。我们从哪里收集指标?随你选择。Prometheus 是一个不错的选择。它从哪里获取指标?嗯,这取决于你使用的是哪个调度器。如果是 Docker Swarm,你需要运行一堆导出器。或者,你也许足够大胆,尝试暴露 Docker 内部指标的实验性功能,采用 Prometheus 格式。你甚至可能充满热情,认为这些指标足以满足你所有的监控和警报需求。也许,当你读到这篇文章时,这个功能已经不再是实验性的了。另一方面,Kubernetes 拥有一切,甚至更多。你可以使用 Heapster,或者你可能会发现它限制太多,于是配置 Prometheus 直接从 Kubernetes API 获取指标。Kubernetes 暴露了大量的数据,远比你可能需要的要多。你可以获取内存、CPU、IO、网络和无数其他指标,从而做出智能决策,不仅是关于容器所需资源的决策,还可以做出关于更多方面的决策。

为了澄清一点,无论你是运行 Kubernetes 还是 Docker Swarm,你都可以获得相同的度量指标。主要的区别在于 Kubernetes 通过其 API 暴露这些指标,而 Swarm 则需要在使用其有限的度量指标与设置像 cAdvisor 和 Node Exporter 这样的导出器之间做出抉择。很可能,你会发现你需要同时使用 Swarm API 的度量指标和导出器的数据。Kubernetes 提供了一个更为强大的解决方案,尽管你可能仍然需要一个或两个导出器。但总的来说,Kubernetes 能通过其 API 提供你几乎所有需要的度量指标,这无疑是一个非常方便的功能。

坦率地说,我们从两个调度器中获取度量指标的方式差异并不是特别重要。如果这就是资源管理的故事结束之处,我会得出结论:两种解决方案大致是一样好的。但故事还在继续。这也是相似之处结束的地方。更确切地说,这是 Docker Swarm 结束的地方,而 Kubernetes 才刚刚开始。

Kubernetes 允许我们定义资源的默认值和限制,并将其应用于没有指定资源的容器。它还允许我们指定资源配额,以防止资源被意外或恶意地过度使用。配额和名称空间相结合提供了非常强大的保障。它们为我们提供了设计真正容错系统的一些手段,通过防止恶意容器、失控的扩展和人为错误将集群拖入停滞。不要一秒钟都认为配额是构建强大系统所需的唯一因素。它不是拼图中的唯一一块,但无论如何,它是一个重要的部分。

名称空间与配额相结合非常重要。我甚至可以说它们是至关重要的。如果没有它们,我们将不得不为每个小组、团队或部门创建一个集群。或者,我们可能不得不进一步收紧流程,防止我们的团队无法充分利用容器编排工具的优势。如果目标是为我们的团队提供自由,而不牺牲集群稳定性,那么 Kubernetes 显然比 Docker Swarm 更具优势。

这场战斗 Kubernetes 赢了,但战争依然在继续。

好吧,我有点夸张了,使用了战斗战争这些词。它并不是一个冲突,两个社区正在增加合作,分享想法和解决方案。两个平台正在融合。不过,目前来看,Kubernetes 在资源管理方面相较 Docker Swarm 确实占有明显优势。

第十四章:创建一个生产就绪的 Kubernetes 集群

创建 Kubernetes 集群并非简单任务。我们需要做出许多选择,并且很容易在各种选项中迷失。可选择的排列组合几乎是无限的,但我们的集群需要一致地进行配置。从第一次尝试设置集群的经验中,很容易就会变成一个困扰你一生的噩梦。

与将几乎所有内容打包成单一二进制文件的 Docker Swarm 不同,Kubernetes 集群需要在各个节点上运行多个独立的组件。设置这些组件可以非常简单,也可能变得非常复杂,这一切取决于我们最初做出的选择。我们需要做的第一件事之一就是选择一个工具,用来创建 Kubernetes 集群。

如果我们决定安装一个 Docker Swarm 集群,我们只需要在所有服务器上安装 Docker 引擎,并在每个节点上执行 docker swarm initdocker swarm join 命令,仅此而已。Docker 将一切打包到一个二进制文件中。Docker Swarm 的设置过程简单至极。而 Kubernetes 就不一样了。与高度意见化的 Swarm 不同,Kubernetes 提供了更高的自由选择。它的设计注重扩展性。我们需要在众多组件中做出选择。其中一些是由 Kubernetes 核心项目维护的,而其他则由第三方提供。扩展性可能是 Kubernetes 快速发展的主要原因之一。今天,几乎每个软件供应商都在为 Kubernetes 构建组件,或者提供其之上的服务。

除了智能设计以及解决与分布式、可扩展、容错和高可用系统相关的问题外,Kubernetes 的强大之处还在于它得到了无数个人和公司广泛的采用和支持。只要你理解它背后的责任,你就可以利用这股力量。亲爱的读者,如何构建你的 Kubernetes 集群,选择哪些组件来托管,完全取决于你。你可以选择从零开始构建,也可以使用像Google Cloud PlatformGCE)Kubernetes 引擎这样的托管解决方案。不过,实际上还有第三种选择。我们可以选择使用其中一个安装工具。大多数工具都有明确的使用意见,且可调整的参数非常有限。

你可能会认为,使用 kubeadm 从零开始创建一个集群并不难。如果运行 Kubernetes 就是我们唯一需要做的事,你是对的。但是事实并非如此。我们还需要确保它是容错的并且具有高可用性。它需要经得起时间的考验。构建一个稳健的解决方案将需要结合 Kubernetes 核心和第三方组件、AWS 的专业知识,以及大量的自定义脚本来将这两者结合起来。但我们不会走这条路,至少现在不走。

我们将使用 Kubernetes Operationskops)来创建集群。它介于从零开始自己动手和托管解决方案(例如 GCE)之间。对于新手和老手来说,它都是一个很好的选择。你将学习运行 Kubernetes 集群所需的组件。你将能够做出一些选择。然而,我们不会深入讨论从零开始设置集群的问题。相信我,这个坑非常深,要想爬出来可能需要很长时间。

通常,这是一个很好的机会来解释 Kubernetes 集群中最重要的组件。天哪,你可能已经在想,为什么我们不在开始时就做这个了。尽管如此,我们会再推迟一会儿再讨论。我相信,先创建一个集群,再通过实时示例来讨论组件会更好。我觉得,通过实际的操作和触摸去理解某些东西,要比仅仅停留在理论层面要容易得多。

总而言之,我们将先创建一个集群,稍后再讨论它的组件。

既然我已经提到我们将使用 kops 来创建集群,那我们就先简单介绍一下它背后的项目。

什么是 Kubernetes 操作(kops)项目?

如果你访问 Kubernetes Operationskops)(github.com/kubernetes/kops) 项目,你看到的第一句话是它是“让生产级 Kubernetes 集群快速上线的最简单方法。”在我看来,只有在排除 Google Kubernetes EngineGKE)的情况下,这句话才是准确的。今天(2018 年 2 月),其他托管服务商还没有发布他们的 Kubernetes 即服务解决方案。亚马逊的 Elastic Container Service for KubernetesEKS)(aws.amazon.com/eks/) 仍然没有对公众开放。Azure Container ServiceAKS)(azure.microsoft.com/en-us/services/kubernetes-service/) 也是一个新的增添,仍然有一些痛点。等到你阅读本文时,所有主要托管商可能都会有他们的解决方案。不过,我更倾向于使用 kops,因为它提供了几乎相同的简易性,同时没有剥夺我们对过程的控制。它让我们能够对集群进行比托管解决方案更多的定制。它完全是开源的,可以存储在版本控制中,也不是设计来让你被某个供应商锁定。

如果你的托管服务商是 AWS,我认为 kops 是创建 Kubernetes 集群的最佳方式。至于 GCE 是否适用,尚有争议,因为 GKE 表现非常出色。我们可以预期未来 kops 会扩展到其他托管商。例如,在本文撰写时,VMWare 正处于 alpha 阶段,应该很快就会稳定。Azure 和 Digital Ocean 的支持正在增加,正如我写这篇文章时所看到的那样。

我们将使用 kops 在 AWS 中创建 Kubernetes 集群。这是故事的部分内容,可能会让你感到失望。你可能选择在其他地方运行 Kubernetes,但不要沮丧。几乎所有 Kubernetes 集群遵循相同的原则,尽管它们的设置方法可能不同。原则才是真正重要的,一旦你成功地在 AWS 上设置了 Kubernetes,你将能够将这些知识迁移到其他地方。

选择 AWS 的原因在于其广泛的使用。它是目前拥有最多用户群的托管服务提供商。如果我要盲目下注你的选择,那一定是 AWS,因为从统计上看,这是最有可能的选择。我无法在单一章节中覆盖所有的选项。如果我需要遍历所有托管提供商和可能帮助安装的不同项目,我们得为此写一本完整的书。相反,我邀请你在完成在 AWS 上使用 kops 安装 Kubernetes 后,进一步探索该主题。作为替代方案,可以通过 slack.devops20toolkit.com 与我联系,或者发邮件到 viktor@farcic.com,我会帮助你。如果我收到足够多的消息,我甚至可能会为 Kubernetes 安装专门写一本书。

我偏离了 kops……

Kops 让我们可以创建一个生产级的 Kubernetes 集群。这意味着我们不仅可以用它来创建集群,还可以用它来升级集群(没有停机时间)、更新集群,或者如果不再需要它时销毁集群。一个集群如果没有高度可用和容错能力,不能称为“生产级”。如果我们希望它能够自动化运行,应该能够完全通过命令行执行。这些以及其他很多功能,正是 kops 所提供的,这也使它如此优秀。

Kops 遵循与 Kubernetes 相同的哲学。我们创建一组 JSON 或 YAML 对象,并将其发送到控制器,控制器负责创建集群。

我们将很快更详细地讨论 kops 能做和不能做的事情。现在,我们将进入本章的实际操作部分,确保所有安装的前置条件已设置好。

为集群设置做准备

我们将继续使用 vfarcic/k8s-specs 仓库中的规格,因此我们首先要做的是进入克隆该仓库的目录,并拉取最新版本。

本章中的所有命令都可以在 14-aws.sh (gist.github.com/vfarcic/04af9efcd1c972e8199fc014b030b134) Gist 中找到。

cd k8s-specs

git pull 

我假设你已经有了一个 AWS 账户。如果没有,请访问 Amazon Web Services (aws.amazon.com/) 并注册。

如果你已经熟悉 AWS,你可能只需要浏览接下来的内容并执行命令。

我们应该做的第一件事是获取 AWS 凭证。

请打开 Amazon EC2 控制台 (console.aws.amazon.com/ec2/v2/home),点击右上角菜单中的您的名字并选择“我的安全凭证”。您将看到不同类型凭证的屏幕。展开“访问密钥(Access Key ID 和 Secret Access Key)”部分,点击“创建新访问密钥”按钮。展开“显示访问密钥”部分以查看密钥。

您将无法稍后查看密钥,因此这是唯一一次能够下载密钥文件的机会。

我们将把密钥作为环境变量,这些变量将由 AWS 命令行界面 (AWS CLI) (aws.amazon.com/cli/) 使用。

请在执行以下命令之前,将 [...] 替换为您的密钥:

export AWS_ACCESS_KEY_ID=[...] 

export AWS_SECRET_ACCESS_KEY=[...] 

我们需要安装 AWS 命令行界面 (CLI) (aws.amazon.com/cli/) 并收集您的帐户信息。

如果您还没有,请打开 安装 AWS 命令行界面 页面,并按照适合您操作系统的安装方法进行安装。

给 Windows 用户的提示:我发现最方便的在 Windows 上安装 AWS CLI 的方法是使用 Chocolatey (chocolatey.org/)。下载并安装 Chocolatey,然后在管理员命令提示符下运行 choco install awscli。本章稍后将使用 Chocolatey 安装 jq。

完成后,我们将通过输出版本来确认安装是否成功。

给 Windows 用户的提示:您可能需要重新打开您的 GitBash 终端,以使环境变量 PATH 的更改生效。

aws --version

输出(来自我的笔记本电脑)如下:

aws-cli/1.11.15 Python/2.7.10 Darwin/16.0.0 botocore/1.4.72

Amazon EC2 托管在全球多个位置。这些位置由区域和可用区组成。每个区域是一个由多个隔离位置组成的独立地理区域,这些位置称为可用区。Amazon EC2 使您能够将资源(如实例)和数据放置在多个位置。

接下来,我们将定义环境变量 AWS_DEFAULT_REGION,该变量将告诉 AWS CLI 默认使用哪个区域。

export AWS_DEFAULT_REGION=us-east-2 

目前,请注意,您可以将变量的值更改为任何其他区域,只要该区域至少有三个可用区。我们很快会讨论为什么选择 us-east-2 区域以及需要多个可用区的原因。

接下来,我们将创建一些 身份和访问管理 (IAM) 资源。尽管我们可以使用您注册 AWS 时使用的用户创建一个集群,但创建一个仅包含我们后续练习所需权限的独立帐户是一个良好的实践:

首先,我们将创建一个名为 kops 的 IAM 组:

aws iam create-group \
 --group-name kops

输出如下:

{
 "Group": {
 "Path": "/",
 "CreateDate": "2018-02-21T12:58:47.853Z",
 "GroupId": "AGPAIF2Y6HJF7YFYQBQK2",
 "Arn": "arn:aws:iam::036548781187:group/kops",
 "GroupName": "kops"
 }
}

除了确认没有错误信息、证明组已成功创建外,我们不太关心输出中的任何信息。

接下来,我们将为该组分配几个策略,从而为该组的未来用户提供足够的权限,创建我们所需的对象。

由于我们的集群将由 EC2(aws.amazon.com/ec2/)实例组成,因此该组需要拥有创建和管理这些实例的权限。我们还需要一个地方存储集群的状态,因此我们需要访问 S3(aws.amazon.com/s3/)。此外,我们需要添加 VPC(aws.amazon.com/vpc/)以使我们的集群与外界隔离。最后,我们还需要能够创建额外的 IAM。

在 AWS 中,用户权限通过创建策略来授予。我们需要的策略包括AmazonEC2FullAccessAmazonS3FullAccessAmazonVPCFullAccessIAMFullAccess

将所需策略附加到kops组的命令如下:

aws iam attach-group-policy \
 --policy-arn arn:aws:iam::aws:policy/AmazonEC2FullAccess \
 --group-name kops

aws iam attach-group-policy \
 --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess \
 --group-name kops

aws iam attach-group-policy \
 --policy-arn arn:aws:iam::aws:policy/AmazonVPCFullAccess \
 --group-name kops

aws iam attach-group-policy \
 --policy-arn arn:aws:iam::aws:policy/IAMFullAccess \
 --group-name kops  

现在我们已经创建了一个拥有足够权限的组,接下来我们应该创建一个用户。

aws iam create-user \
 --user-name kops

输出如下:

{
 "User": {
 "UserName": "kops",
 "Path": "/",
 "CreateDate": "2018-02-21T12:59:28.836Z",
 "UserId": "AIDAJ22UOS7JVYQIAVMWA",
 "Arn": "arn:aws:iam::036548781187:user/kops"
 }
}  

就像我们创建组时一样,输出的内容并不重要,唯一重要的是确认命令已成功执行。

我们创建的用户还没有加入kops组。接下来我们来解决这个问题:

aws iam add-user-to-group \
 --user-name kops \
 --group-name kops  

最后,我们还需要为新创建的用户生成访问密钥。没有这些密钥,我们将无法代表其执行操作。

aws iam create-access-key \
 --user-name kops >kops-creds  

我们创建了访问密钥,并将输出存储在kops-creds文件中。让我们快速查看它的内容。

cat kops-creds  

输出如下:

{
 "AccessKey": {
 "UserName": "kops",
 "Status": "Active",
 "CreateDate": "2018-02-21T13:00:24.733Z",
 "SecretAccessKey": "...",
 "AccessKeyId": "..."
 }
}  

请注意,我已移除了密钥的值。我还不够信任你,将我的 AWS 账户密钥交给你。

我们需要SecretAccessKeyAccessKeyId字段。因此,接下来的步骤是解析kops-creds文件的内容,并将这两个值存储为环境变量AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY

为了实现完全自动化,我们将使用jqstedolan.github.io/jq/)解析kops-creds文件的内容。请下载并安装适合你操作系统的发行版。

提示 Windows 用户:通过 Chocolatey 在管理员命令提示符下使用choco install jq来安装jq

export AWS_ACCESS_KEY_ID=$(\
 cat kops-creds | jq -r \
 '.AccessKey.AccessKeyId')

export AWS_SECRET_ACCESS_KEY=$(
 cat kops-creds | jq -r \
 '.AccessKey.SecretAccessKey')  

我们使用cat命令输出文件内容,并结合jq命令过滤输入,以便只提取我们需要的字段。

从现在开始,所有的 AWS CLI 命令将不再由你用于注册 AWS 的管理员用户执行,而是由kops执行。

kops-creds文件必须得到妥善保护,只有你信任的人才能访问。如何保护它的方法因组织而异。不管你采取什么措施,千万不要把它写在便签上并贴在显示器上,存放在 GitHub 仓库中更是一个坏主意。

接下来,我们应该决定使用哪些可用区。因此,让我们查看 us-east-2 区域中有哪些可用区。

aws ec2 describe-availability-zones \
    --region $AWS_DEFAULT_REGION

输出结果如下:

{
 "AvailabilityZones": [
 {
 "State": "available", 
 "RegionName": "us-east-2", 
 "Messages": [], 
 "ZoneName": "us-east-2a"
 }, 
 {
 "State": "available", 
 "RegionName": "us-east-2", 
 "Messages": [], 
 "ZoneName": "us-east-2b"
 }, 
 {
 "State": "available", 
 "RegionName": "us-east-2", 
 "Messages": [], 
 "ZoneName": "us-east-2c"
 }
 ]
}

正如我们所看到的,该区域有三个可用区。我们将把它们存储在一个环境变量中。

提醒 Windows 用户:请在接下来的命令中使用 tr '\r\n' ', ',而不是 tr '\n' ','

export ZONES=$(aws ec2 \
 describe-availability-zones \
 --region $AWS_DEFAULT_REGION \
 | jq -r \
 '.AvailabilityZones[].ZoneName' \
 | tr '\n' ',' | tr -d ' ')

ZONES=${ZONES%?}

echo $ZONES  

就像访问密钥一样,我们使用 jq 将结果限制为仅显示区域名称,并将其与 tr 结合,替换掉换行符为逗号。第二条命令会去掉尾部的逗号。

最后一条命令的输出,回显了环境变量的值,结果如下:

us-east-2a,us-east-2b,us-east-2c  

我们稍后会讨论使用三个可用区的原因。现在,只需要记住它们已存储在环境变量 ZONES 中。

最后的准备步骤是创建设置所需的 SSH 密钥。由于在此过程中我们可能会创建其他一些工件,我们将创建一个专门用于集群创建的目录。

mkdir -p cluster

cd cluster  

可以通过 aws ec2 命令 create-key-pair 创建 SSH 密钥:

aws ec2 create-key-pair \
 --key-name devops23 \
 | jq -r '.KeyMaterial' \
 >devops23.pem

我们创建了一个新的密钥对,过滤输出,使得只有 KeyMaterial 被返回,并将其存储在 devops23.pem 文件中。

出于安全考虑,我们应该更改 devops23.pem 文件的权限,使得只有当前用户可以读取它。

chmod 400 devops23.pem \ 

最后,我们只需要新生成的 SSH 密钥的公钥部分,因此我们将使用 ssh-keygen 提取它。

ssh-keygen -y -f devops23.pem 
 >devops23.pub  

如果这是您第一次接触 AWS,那么这些步骤可能看起来有些令人生畏。然而,它们其实是非常标准的。无论在 AWS 中做什么,您都需要或多或少地执行相同的操作。并非所有步骤都是强制性的,但它们都是良好的实践。拥有一个专门的(非管理员)用户和仅包含所需策略的用户组总是一个好主意。访问密钥对于任何 aws 命令都是必需的。如果没有 SSH 密钥,任何人都无法进入服务器。

好消息是我们已经完成了前提条件的设置,现在可以将注意力转向创建 Kubernetes 集群。

在 AWS 中创建 Kubernetes 集群

我们将首先决定即将创建的集群的名称。我们选择将其命名为 devops23.k8s.local。如果没有 DNS,这个名称的后缀(.k8s.local)是必须的。这是 kops 用来决定是否创建一个基于 gossip 的集群,或者依赖于公开可用域名的命名约定。如果这是一个“真实”的生产集群,您可能会有一个 DNS。然而,由于我无法确定您是否在本书中的练习中有 DNS,我们将采取更为保守的做法,使用 gossip 模式继续。

我们将把名称存储在一个环境变量中,以便它易于访问。

export NAME=devops23.k8s.local  

当我们创建集群时,kops 会将其状态存储在我们即将配置的位置。如果你使用过 Terraform,你会注意到 kops 使用了非常相似的方法。它使用在创建集群时生成的状态进行所有后续操作。如果我们想改变集群的任何方面,首先需要更改所需的状态,然后再将这些更改应用到集群中。

目前,在 AWS 中创建集群时,唯一存储状态的选项是 Amazon S3aws.amazon.com/s3/)存储桶。我们可以预期很快会有更多的存储选项。现在,S3 是我们唯一的选择。

创建我们所在区域的 S3 存储桶的命令如下:

export BUCKET_NAME=devops23-$(date +%s)

aws s3api create-bucket \
 --bucket $BUCKET_NAME \
 --create-bucket-configuration \ 
 LocationConstraint=$AWS_DEFAULT_REGION  

我们创建了一个唯一名称的存储桶,输出结果如下:

{
 "Location": http://devops23-1519993212.s3.amazonaws.com/
}  

为了简化,我们将定义环境变量 KOPS_STATE_STORE。kops 将使用它来知道我们存储状态的位置。否则,我们需要在每个 kops 命令中都使用 --store 参数。

export KOPS_STATE_STORE=s3://$BUCKET_NAME  

在创建集群之前,只差一步了。我们需要安装 kops。

如果你是 MacOS 用户,安装 kops 最简单的方法是通过 Homebrewbrew.sh/)。

brew update && brew install kops  

作为替代方案,我们可以从 GitHub 下载一个发布版本。

curl -Lo kops https://github.com/kubernetes/kops/releases/download/$(curl -s 
https://api.github.com/repos/kubernetes/kops/releases/latest | grep tag_name | cut -d '"' -f 4)/kops-darwin-amd64

chmod +x ./kops

sudo mv ./kops /usr/local/bin/  

如果你是 Linux 用户,安装 kops 的命令如下:

wget -O kops https://github.com/kubernetes/kops/releases/download/$(curl -s 
https://api.github.com/repos/kubernetes/kops/releases/latest | grep tag_name | 
cut -d '"' -f 4)/kops-linux-amd64

chmod +x ./kops

sudo mv ./kops /usr/local/bin/  

最后,如果你是 Windows 用户,你将无法安装 kops。在撰写本文时,它的发布版本不包括 Windows 二进制文件。别担心,我不会放弃你,亲爱的 Windows 用户。我们很快就能通过利用 Docker 运行任何 Linux 应用的能力来解决这个问题。唯一的要求是你安装了 Docker for Windows(www.docker.com/docker-windows)。

我已经创建了一个包含 kops 及其依赖项的 Docker 镜像。因此,我们将创建一个别名 kops,该别名将创建一个容器,而不是运行二进制文件。结果是一样的。

创建 kops 别名的命令如下。如果你是 Windows 用户,请执行该命令:

mkdir config

alias kops="docker run -it --rm \
 -v $PWD/devops23.pub:/devops23.pub \ 
 -v $PWD/config:/config \
 -e KUBECONFIG=/config/kubecfg.yaml \ 
 -e NAME=$NAME -e ZONES=$ZONES \
 -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \ 
 -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \ 
 -e KOPS_STATE_STORE=$KOPS_STATE_STORE \
 vfarcic/kops"  

我们不会深入讨论 docker run 命令使用的所有参数。它们的用法将在我们开始使用 kops 时变得清晰。只需要记住,我们传递了所有可能使用的环境变量,并挂载了 SSH 密钥和 kops 存储 kubectl 配置的目录。

我们终于准备好创建集群了。但是,在我们开始之前,我们将花一点时间讨论我们可能需要的要求。毕竟,并不是所有的集群都是一样的,我们即将做出的选择可能会严重影响我们实现目标的能力。

我们可能首先问自己的是,我们是否想要高可用性。如果有人回答“不”,那将是很奇怪的。谁不希望拥有一个(几乎)始终可用的集群呢?相反,我们会问自己是什么因素可能导致集群的宕机。

当一个节点被销毁时,Kubernetes 会将所有运行在该节点上的应用程序重新调度到健康的节点上。我们需要做的就是确保,稍后一个新的服务器被创建并加入集群,从而使其容量恢复到期望的值。我们稍后会讨论新节点是如何应对服务器故障时创建的。现在,我们假设这一切会以某种方式发生。

但仍然有一个陷阱。因为新节点需要加入集群,如果失败的服务器是唯一的主节点,就没有集群可以加入了。一切都完了。关键在于主服务器所在的位置。它们承载着 Kubernetes 无法运作的关键组件。

所以,我们需要不止一个主节点。两个怎么样?如果一个失败了,另一个还能继续工作。但这依然行不通。

每一条进入主节点的信息都会传播到其他主节点,只有在多数节点同意后,该信息才会被提交。如果我们失去多数(50%+1),主节点就无法建立法定人数(quorum)并停止工作。如果两个主节点中有一个宕机,我们只能获得一半的投票,因此我们将失去建立法定人数的能力。因此,我们需要三个或更多的主节点。大于 1 的奇数是“魔术”数字。鉴于我们不会创建一个大型集群,三个就足够了。

有了三个主节点,我们就能防范任何一个主节点的故障。鉴于失败的服务器会被新的服务器替代,只要在任何时候只有一个主节点失败,我们应该具备容错性并具有高可用性。

始终为主节点设置一个大于 1 的奇数数量。

拥有多个主节点的整体想法,如果一个完整的数据中心宕机的话,将不再有太大意义。

尽力防止数据中心故障是值得称赞的。然而,无论数据中心设计得多么完美,总有可能出现导致其中断的情况。所以,我们需要不止一个数据中心。按照主节点的逻辑,我们至少需要三个。但和几乎所有事情一样,我们不能随便选择这三个(或更多)数据中心。如果它们相距太远,之间的延迟可能会太高。由于每一条信息都需要传播到集群中的所有主节点,数据中心之间的缓慢通信将严重影响整个集群。

总的来说,我们需要三个数据中心,它们要足够接近以提供低延迟,同时又要物理上分隔,以便一个数据中心的故障不会影响到其他数据中心。由于我们打算在 AWS 中创建集群,我们将使用可用区AZs),它是物理上分隔且具有低延迟的数据中心。

始终将你的集群分布在至少三个数据中心,这些数据中心距离足够近,以确保低延迟。

高可用性不仅仅是运行多个主节点和将集群分布在多个可用区之间的问题,我们稍后会回到这个话题。现在,我们将继续探索我们必须做出的其他决策。

我们应该选择哪种网络?我们可以选择 kubenetCNI经典外部 网络。

经典的 Kubernetes 原生网络已被弃用,转而支持 kubenet,因此我们可以立即排除它。

外部网络通常用于一些自定义实现和特定用例,因此我们也将排除这一选项。

这就剩下 kubenet 和 CNI。

容器网络接口CNI)允许我们插入第三方网络驱动程序。Kops 支持 Calico (docs.projectcalico.org/v2.0/getting-started/kubernetes/installation/hosted/)、flannel (github.com/coreos/flannel)、Canal(Flannel + Calico)(github.com/projectcalico/canal)、kopeio-vxlan (github.com/kopeio/networking)、kube-router (github.com/kubernetes/kops/blob/master/docs/networking.md#kube-router-example-for-cni-ipvs-based-service-proxy-and-network-policy-enforcer)、romana (github.com/romana/romana)、weave (github.com/weaveworks-experiments/weave-kube) 和 amazon-vpc-routed-eni (github.com/kubernetes/kops/blob/master/docs/networking.md#amazon-vpc-backend) 网络。每种网络都有优缺点,且在实现方式和主要目标上各不相同。选择其中之一需要对每个方案进行详细分析。我们将留待以后再做这方面的比较,今天我们专注于 kubenet

Kubenet 是 kops 的默认网络解决方案。它是 Kubernetes 原生的网络方案,被认为经过实战检验,非常可靠。然而,它也有一个限制。在 AWS 上,每个节点的路由都配置在 AWS VPC 路由表中。由于这些路由表不能有超过五十条条目,因此 kubenet 只能在最多五十个节点的集群中使用。如果你打算建立一个更大的集群,必须切换到之前提到的某个 CNI。

如果你的集群小于五十个节点,使用 kubenet 网络。

好消息是,使用任何网络解决方案都很容易。我们只需要指定 --networking 参数,并跟上网络的名称。

鉴于我们没有足够的时间和空间来评估所有的 CNI,我们将使用 kubenet 作为我们即将创建的集群的网络解决方案。我鼓励你自己探索其他选项(或者等到我写一篇文章或新书时再了解)。

最后,我们只剩下一个选择需要做出决定。我们的节点大小应该是多少?由于我们不会运行很多应用,t2.small 应该足够了,而且能将 AWS 成本控制到最低。t2.micro 太小,因此我们选择了 AWS 提供的第二小的实例类型。

你可能已经注意到我们没有提到持久化存储卷。我们将在下一章中进行探讨。

使用我们讨论过的规格创建集群的命令如下:

kops create cluster \
 --name $NAME \
 --master-count 3 \
 --node-count 1 \
 --node-size t2.small \
 --master-size t2.small \
 --zones $ZONES \
 --master-zones $ZONES \
 --ssh-public-key devops23.pub \
 --networking kubenet \
 --kubernetes-version v1.8.4 \
 --yes  

我们指定集群应有三个主节点和一个工作节点。记住,我们始终可以增加工作节点的数量,因此目前不需要比实际需求更多的节点。

工作节点和主节点的大小都设置为 t2.small。这两种类型的节点将分布在我们通过环境变量 ZONES 指定的三个可用区中。接下来,我们定义了公钥和网络类型。

我们使用 --kubernetes-version 参数指定我们偏好运行版本 v1.8.4。否则,我们将得到一个由 kops 认为稳定的最新版本的集群。尽管运行最新的稳定版本可能是个不错的主意,但我们需要稍微滞后几个版本,以展示 kops 提供的一些特性。

默认情况下,kops 将 authorization 设置为 AlwaysAllow。由于这是一个生产就绪集群的模拟,我们将其更改为 RBAC,这是我们在之前的章节中已经探索过的。

--yes 参数指定集群应立即创建。如果没有它,kops 只会更新 S3 存储桶中的状态,我们需要执行 kops apply 来创建集群。虽然这种两步法更为推荐,但我有点急切,想尽快看到集群的最终效果。

命令的输出如下:

...
kops has set your kubectl context to devops23.k8s.local

Cluster is starting.  It should be ready in a few minutes.

Suggestions:
 * validate cluster: kops validate cluster
 * list nodes: kubectl get nodes --show-labels
 * ssh to the master: ssh -i ~/.ssh/id_rsa admin@api.devops23.k8s.local
The admin user is specific to Debian. If not using Debian please use the appropriate user based on your OS.
 * read about installing addons: https://github.com/kubernetes/kops/blob/master/docs/addons.md  

我们可以看到 kubectl 上下文已更改,指向正在启动的新集群,并且即将就绪。接下来列出了一些建议的后续操作。我们暂时跳过它们。

Windows 用户注意

Kops 是在容器内执行的。它更改了容器内的上下文,但这个上下文现在已经消失。因此,你的本地kubectl上下文保持不变。我们可以通过执行kops export kubecfg --name ${NAME}export KUBECONFIG=$PWD/config/kubecfg.yaml来修复这个问题。第一个命令将配置导出了/config/kubecfg.yaml。这个路径是通过环境变量KUBECONFIG指定的,并且在本地硬盘上挂载为config/kubecfg.yaml。第二个命令将KUBECONFIG导出到本地。通过这个变量,kubectl 现在被指示使用config/kubecfg.yaml中的配置,而不是默认配置。在运行这些命令之前,请等待 AWS 几分钟,让所有的 EC2 实例创建完成并加入集群。等待后,执行这些命令,你就一切准备好了。

我们将使用 kops 来获取有关新创建集群的信息。

kops get cluster  

输出如下:

NAME               CLOUD ZONES
devops23.k8s.local aws   us-east-2a,us-east-2b,us-east-2c  

这些信息没有告诉我们任何新内容。我们已经知道集群的名称以及它运行的区域。

kubectl cluster-info怎么样?

kubectl cluster-info  

输出如下:

Kubernetes master is running at https://api-devops23-k8s-local-ivnbim-6094461
90.us-east-2.elb.amazonaws.com
KubeDNS is running at https://api-devops23-k8s-local-ivnbim-609446190.us-east
-2.elb.amazonaws.com/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.  

我们可以看到主节点正在运行,KubeDNS 也在运行。集群可能已经准备好了。如果在你的情况下 KubeDNS 没有出现在输出中,你可能需要再等几分钟。

我们可以通过kops validate命令获得更可靠的集群准备状态信息。

kops validate cluster  

输出如下:

Using cluster from kubectl context: devops23.k8s.local

Validating cluster devops23.k8s.local

INSTANCE GROUPS
NAME              ROLE   MACHINETYPE MIN MAX SUBNETS
master-us-east-2a Master t2.small    1   1   us-east-2a
master-us-east-2b Master t2.small    1   1   us-east-2b
master-us-east-2c Master t2.small    1   1   us-east-2c
nodes             Node   t2.small    1   1   us-east-2a,us-east-2b,us-east-2c

NODE STATUS
NAME                 ROLE   READY
ip-172-20-120-133... master True
ip-172-20-34-249...  master True
ip-172-20-65-28...   master True
ip-172-20-95-101...  node   True

Your cluster devops23.k8s.local is ready  

这很有用。我们可以看到集群使用了四个实例组,或者用 AWS 的术语来说,四个自动扩展组ASG)。每个主节点都有一个,所有(工作)节点共用一个。

每个主节点都有一个单独的 ASG,其原因在于需要确保每个主节点都在自己的可用区AZ)中运行。这样,我们可以保证整个 AZ 的故障只会影响一个主节点。而节点(工作节点)则不受任何特定 AZ 的限制。AWS 可以在任何可用的 AZ 中调度节点。

我们稍后会更详细地讨论 ASG。

输出中进一步显示,我们可以看到四台服务器,其中三台为主节点,一台为工作节点。所有节点都已准备好。

最终,我们得到了确认信息:我们的cluster devops23.k8s.local 已准备好

使用到目前为止获得的信息,我们可以通过图 14-1来描述集群。

图 14-1:构成 Kubernetes 集群的服务器

显然,集群中包含的内容比图 14-1所展示的要多得多。那么,让我们来发现 kops 为我们创建的那些好东西吧。

探索构成集群的组件

当 kops 创建 VM(EC2 实例)时,第一件事就是执行nodeup。它进而安装了一些软件包。它确保 Docker、Kubelet 和 Protokube 都已经启动并运行。

Docker用于运行容器。我很难想象你不知道 Docker 的作用,因此我们跳到下一个内容。

Kubelet 是 Kubernetes 的节点代理。它在集群的每个节点上运行,其主要目的就是运行 Pods。或者,更准确地说,它确保 PodSpecs 中描述的容器在健康的情况下始终运行。它主要通过 Kubernetes 的 API 服务器获取应该运行的 Pods 信息。作为替代方案,它还可以通过文件、HTTP 端点和 HTTP 服务器获取这些信息。

与 Docker 和 Kubelet 不同,Protokube 是 kops 特有的。它的主要职责是发现主节点磁盘、挂载磁盘并创建清单。其中一些清单被 Kubelet 用于创建系统级 Pods,并确保它们始终运行。

除了通过清单中定义的 Pods 启动容器(由 Protokube 创建)之外,Kubelet 还会尝试联系 API 服务器,而该服务器最终也是由 Kubelet 启动的。一旦连接建立,Kubelet 会注册其所在的节点。

这三个软件包在所有节点上运行,无论它们是主节点还是工作节点:

图 14-2:组成 Kubernetes 集群的服务器

让我们来看看当前在集群中运行的系统级 Pods:

kubectl --namespace kube-system get pods 

输出结果如下:

NAME                                         READY STATUS  RESTARTS AGE
dns-controller-...                           1/1   Running 0        5m
etcd-server-events-ip-172-20-120-133...      1/1   Running 0        5m
etcd-server-events-ip-172-20-34-249...       1/1   Running 1        4m
etcd-server-events-ip-172-20-65-28...        1/1   Running 0        4m
etcd-server-ip-172-20-120-133...             1/1   Running 0        4m
etcd-server-ip-172-20-34-249...              1/1   Running 1        3m
etcd-server-ip-172-20-65-28...               1/1   Running 0        4m
kube-apiserver-ip-172-20-120-133...          1/1   Running 0        4m
kube-apiserver-ip-172-20-34-249...           1/1   Running 3        3m
kube-apiserver-ip-172-20-65-28...            1/1   Running 1        4m
kube-controller-manager-ip-172-20-120-133... 1/1   Running 0        4m
kube-controller-manager-ip-172-20-34-249...  1/1   Running 0        4m
kube-controller-manager-ip-172-20-65-28...   1/1   Running 0        4m
kube-dns-7f56f9f8c7-...                      3/3   Running 0        5m
kube-dns-7f56f9f8c7-...                      3/3   Running 0        2m
kube-dns-autoscaler-f4c47db64-...            1/1   Running 0        5m
kube-proxy-ip-172-20-120-133...              1/1   Running 0        4m
kube-proxy-ip-172-20-34-249...               1/1   Running 0        4m
kube-proxy-ip-172-20-65-28...                1/1   Running 0        4m
kube-proxy-ip-172-20-95-101...               1/1   Running 0        3m
kube-scheduler-ip-172-20-120-133...          1/1   Running 0        4m
kube-scheduler-ip-172-20-34-249...           1/1   Running 0        4m
kube-scheduler-ip-172-20-65-28...            1/1   Running 0        4m  

如你所见,许多核心组件正在运行。

我们可以将核心(或系统级)组件分为两组。主组件仅在主节点上运行。在我们的例子中,它们是 kube-apiserverkube-controller-managerkube-scheduleretcddns-controller。节点组件在所有节点上运行,包括主节点和工作节点。我们已经讨论了其中的一些。除了 Protokube、Docker 和 Kubelet 之外,我们还需要了解 kube-proxy,这是另一个节点组件。由于这可能是你第一次听说这些核心组件,我们将简要解释每个组件的功能。

Kubernetes API 服务器kube-apiserver)接受请求以创建、更新或删除 Kubernetes 资源。它监听 8080443 端口。前者是不安全的,仅能从同一服务器访问。通过它,其他组件可以在不需要令牌的情况下注册自己。后者(443 端口)用于与 API 服务器的所有外部通信。这些通信可以是面向用户的,例如,当我们发送 kubectl 命令时。Kubelet 也使用 443 端口与 API 服务器通信并注册自己为节点。

无论谁发起与 API 服务器的通信,其目的都是验证和配置 API 对象。其他的对象包括 Pods、Services、ReplicaSets 等等。它的使用不仅限于面向用户的交互。集群中的所有组件都与 API 服务器进行交互,以执行需要集群范围共享状态的操作。

集群的共享状态存储在etcdgithub.com/coreos/etcd)中。它是一个键/值存储,其中保存了所有集群数据,并通过一致性数据复制保持高可用性。它分为两个 Pod,其中etcd-server保存集群的状态,etcd-server-events存储事件。

Kops 为每个etcd实例创建一个EBS 卷。它充当其存储。

Kubernetes Controller Manager (kube-controller-manager) 负责运行控制器。您已经看到一些控制器在运行,比如ReplicaSetsDeployments。除了这些对象控制器外,kube-controller-manager还负责节点控制器,负责监视服务器并在其中一个不可用时做出响应。

Kubernetes Scheduler (kube-scheduler) 监视 API 服务器以获取新的 Pod,并将它们分配给一个节点。从那时起,这些 Pod 由分配的节点上的 Kubelet 运行。

DNS Controller (dns-controller) 允许节点和用户发现 API 服务器。

Kubernetes Proxy (kube-proxy) 反映了通过 API 服务器定义的服务。它负责 TCP 和 UDP 转发。它在集群的所有节点上运行(包括主节点和工作节点)。

图 14-3:集群的核心组件

在我们的新集群中,还有更多的工作。目前,我们只探索了主要的组件。

接下来,我们将尝试更新我们的集群。

更新集群

无论我们计划多少,我们都永远无法管理一个集群,它的能力应该在今天和明天同样好。事情在变化,我们需要能够适应这些变化。理想情况下,我们的集群应该通过评估指标和触发警报来自动增加和减少其容量,这些警报将与 kops 或直接与 AWS 交互。但这是一个我们目前无法涵盖的高级主题。目前,我们将限制范围在手动更新集群上。

使用 kops,我们不能直接更新集群。相反,我们编辑存储在 S3 存储桶中的集群期望状态。一旦状态改变,kops 将进行必要的更改以符合新的期望。

我们将尝试更新集群,从一个工作节点增加到两个。换句话说,我们希望在集群中添加一个服务器。

让我们看看通过kops edit提供的子命令。

kops edit --help  

输出,仅限于可用命令,如下所示:

...
Available Commands:
 cluster       Edit cluster.
 federation    Edit federation.
 instancegroup Edit instancegroup.
...  

我们可以进行三种类型的编辑。由于我们没有设置联合,因此这一项排除在外。你可能认为cluster会提供创建新工作节点的可能性。然而,事实并非如此。如果你执行kops edit cluster --name $NAME,你会看到配置中并没有指示我们应该有多少个节点。这是正常的,因为我们不应直接在 AWS 中创建服务器。正如 Kubernetes 一样,AWS 也更倾向于使用声明式方法而非命令式方法。至少在处理 EC2 实例时是如此。

我们不会直接发送命令式指令来创建新节点,而是会修改与工作节点相关的自动扩展组ASG)的值。一旦我们更改了 ASG 值,AWS 将确保其符合新的期望值。它不仅会创建一台新服务器以符合新的 ASG 大小,而且还会监控 EC2 实例,确保在其中一台实例出现故障时,保持期望的实例数量。

所以,我们将选择第三个kops edit选项。

kops edit ig --name $NAME nodes  

我们执行了kops edit ig命令,其中iginstancegroup的一个别名。我们使用--name参数指定了集群的名称。最后,我们将服务器的类型设置为nodes。结果,我们看到了与工作节点相关的自动扩展组(Auto-Scaling Group)的InstanceGroup配置。

输出如下:

apiVersion: kops/v1alpha2
kind: InstanceGroup
metadata:
 creationTimestamp: 2018-02-23T00:04:50Z
 labels:
 kops.k8s.io/cluster: devops23.k8s.local
 name: nodes
spec:
 image: kope.io/k8s-1.8-debian-jessie-amd64-hvm-ebs-2018-01-14
 machineType: t2.small
 maxSize: 1
 minSize: 1
 nodeLabels:
 kops.k8s.io/instancegroup: nodes
 role: Node
 subnets:
 - us-east-2a
 - us-east-2b
 - us-east-2c  

请记住,你在屏幕上看到的并不是标准输出(stdout)。相反,配置会在你默认的编辑器中打开。在我的情况下,它是vi

我们可以从这个配置中看到一些有用的信息。例如,用于创建 EC2 实例的image是基于 Debian 的,专为 kops 定制。machineType表示 EC2 的大小,设置为t2.small。再往下看,我们可以看到我们在三个子网中运行虚拟机,或者说,因为我们在 AWS 中,实际上是三个可用区。

我们关心的配置部分是spec条目中的maxSizeminSize。这两个值都设置为1,因为那是我们在创建集群时指定的工作节点数。请将这两个条目的值更改为2,然后保存并退出。

如果你使用vi作为默认编辑器,你需要按I键进入插入模式。从那时起,你可以更改值。编辑完成后,请按ESC键,然后输入:wq。冒号(:)允许我们输入命令,w代表保存,q代表退出。别忘了按回车键。如果你使用的不是vi,那就看你自己了。我相信你知道如何操作你的默认编辑器。如果不确定,Google 是你的好朋友。

图 14-4:kops edit命令背后的过程

既然我们已经更改了配置,我们需要告诉 kops,我们希望它更新集群,以符合新的期望状态。

kops update cluster --name $NAME --yes  

输出的最后几行如下:

...
kops has set your kubectl context to devops23.k8s.local

Cluster changes have been applied to the cloud.

Changes may require instances to restart: kops rolling-update cluster  

我们可以看到,kops 将我们的 kubectl 上下文设置为我们更新的集群。其实没有必要这样做,因为那已经是我们的上下文了,但它还是执行了此操作。接下来,我们收到了变更“已应用到云端”的确认。

最后一句话很有意思。它告诉我们可以使用 kops rolling-updatekops update 命令会一次性将所有更改应用到集群。这可能会导致停机。例如,如果我们想将镜像更换为更新版本,运行 kops update 会一次性重新创建所有工作节点。因此,从实例被关闭的那一刻到新实例创建完成并且 Kubernetes 调度 Pods 的过程中,我们会经历停机。Kops 知道此类操作不应被允许,因此,如果更新需要替换服务器,它什么也不做,而是期望你在之后执行 kops rolling-update。但这不是我们的情况。添加新节点不需要重启或替换现有服务器。

kops rolling-update 命令旨在无停机地应用更改。它会一次应用到一台服务器,这样大多数服务器始终处于运行状态。与此同时,Kubernetes 会重新调度在被停机的服务器上运行的 Pods。

只要我们的应用程序被扩展,kops rolling-update 应该不会导致停机。

让我们看看当我们执行 kops update 命令时发生了什么。

  1. Kops 从 S3 存储桶中获取了期望的状态。

  2. Kops 向 AWS API 发送请求,以更改工作节点 ASG 的值。

  3. AWS 修改了工作节点 ASG 的值,增加了 1。

  4. ASG 创建了一个新的 EC2 实例,以符合新的大小要求。

  5. Protokube 安装了 Kubelet 和 Docker,并创建了包含 Pods 列表的清单文件。

  6. Kubelet 读取了清单文件,并运行了形成 kube-proxy Pod(唯一在工作节点上的 Pod)的容器。

  7. Kubelet 向 kube-apiserver(通过 dns-controller)发送请求,以注册新节点并将其加入集群。关于新节点的信息被存储在 etcd 中。

这个过程几乎与用于创建集群节点的过程相同。

图 14-5:kops update 命令背后的过程

除非你是一个非常慢的读者,否则 ASG 创建了一个新的 EC2 实例,Kubelet 将其加入了集群。我们可以通过 kops validate 命令来确认这一点。

kops validate cluster  

输出如下:

Validating cluster devops23.k8s.local

INSTANCE GROUPS
NAME              ROLE   MACHINETYPE MIN MAX SUBNETS
master-us-east-2a Master t2.small    1   1   us-east-2a
master-us-east-2b Master t2.small    1   1   us-east-2b
master-us-east-2c Master t2.small    1   1   us-east-2c
nodes             Node   t2.small    2   2   us-east-2a,us-east-2b,us-east-2c

NODE STATUS
NAME                 ROLE   READY
ip-172-20-120-133... master True
ip-172-20-33-237...  node   True
ip-172-20-34-249...  master True
ip-172-20-65-28...   master True
ip-172-20-95-101...  node   True

Your cluster devops23.k8s.local is ready  

我们可以看到,现在我们有两个节点(之前只有一个),它们位于 us-east-2 区域的三个可用区中的某个位置。

类似地,我们可以使用 kubectl 来确认 Kubernetes 确实将新的工作节点添加到了集群中。

kubectl get nodes  

输出如下:

NAME                 STATUS ROLES  AGE VERSION
ip-172-20-120-133... Ready  master 13m v1.8.4
ip-172-20-33-237...  Ready  node   1m  v1.8.4
ip-172-20-34-249...  Ready  master 13m v1.8.4
ip-172-20-65-28...   Ready  master 13m v1.8.4
ip-172-20-95-101...  Ready  node   12m v1.8.4  

这很简单,不是吗?从现在开始,我们可以轻松地添加或移除节点。

那么,升级怎么样呢?

手动升级集群

升级集群的过程取决于我们想做什么。

如果我们想将其升级到特定的 Kubernetes 版本,可以执行类似我们添加新工作节点时的过程。

kops edit cluster $NAME  

就像之前一样,我们将编辑集群定义。唯一的不同是这次我们不是编辑特定的实例组,而是整个集群。

如果你查看面前的 YAML 文件,你会看到它包含了我们创建集群时指定的信息,以及我们未设置的 kops 默认值。

目前,我们关注的是kubernetesVersion。请找到它并将版本从v1.8.4更改为v1.8.5。保存并退出。

既然我们修改了集群的期望状态,就可以继续执行kops update

kops update cluster $NAME  

输出的最后一行指出我们必须指定--yes以应用更改。与上次执行kops update时不同,这次我们没有指定--yes参数。因此,我们获得了一个预览,或者说是一个干运行,显示了如果我们应用更改会发生什么。之前,我们添加了一个新工作节点,这个操作不会影响现有的服务器。我们当时足够大胆,直接更新了集群,而没有预览会创建、更新或销毁哪些资源。然而,这次我们要升级集群中的服务器。现有节点将被新节点替代,这是一项潜在的危险操作。以后,我们可能会信任 kops 自动执行正确的操作,完全跳过预览。但现在,我们应该评估一下如果继续操作会发生什么。

请查看输出。你将看到一个类似 Git 的差异,列出了将应用于集群中某些资源的更改。请慢慢查看。

既然你对更改充满信心,我们就可以应用这些更改。

kops update cluster $NAME --yes  

输出的最后一行指出更改可能需要重新启动实例:kops rolling-update cluster。我们之前已经看到过这个消息,但这次没有执行更新。原因很简单,虽然不一定直观。我们可以更新自动扩展组,因为那会导致节点的创建或销毁。但当我们需要替换它们时,像这种情况,执行一个简单的更新可能会造成灾难性的后果。一次性更新所有内容,充其量会造成停机。在我们的案例中,情况更糟。一次性销毁所有主节点可能导致法定人数丧失,新的集群可能无法恢复。

总的来说,kops 在进行“big bang”更新时需要额外的步骤,因为这可能会导致不期望的结果。因此,我们需要执行kops rolling-update命令。由于我们仍然处于不安全状态,所以我们首先运行预览。

kops rolling-update cluster $NAME  

输出如下:

NAME              STATUS      NEEDUPDATE READY MIN MAX NODES
master-us-east-2a NeedsUpdate 1          0     1   1   1
master-us-east-2b NeedsUpdate 1          0     1   1   1
master-us-east-2c NeedsUpdate 1          0     1   1   1
nodes             NeedsUpdate 2          0     2   2   2

Must specify --yes to rolling-update.  

我们可以看到所有节点都需要更新。由于我们已经通过kops update命令的输出评估了这些更改,我们将继续进行并应用滚动更新。

kops rolling-update cluster $NAME --yes  

滚动更新过程已启动,预计需要大约 30 分钟才能完成。

我们将逐步查看输出:

NAME              STATUS      NEEDUPDATE READY MIN MAX NODES
master-us-east-2a NeedsUpdate 1          0     1   1   1
master-us-east-2b NeedsUpdate 1          0     1   1   1
master-us-east-2c NeedsUpdate 1          0     1   1   1
nodes             NeedsUpdate 2          0     2   2   2  

输出内容与我们在请求预览时获得的信息相同,因此没有太多需要评论的地方:

I0225 23:03:03.993068       1 instancegroups.go:130] Draining the node: "ip-1
 72-20-40-167...".
node "ip-172-20-40-167..." cordoned
node "ip-172-20-40-167..." cordoned
WARNING: Deleting pods not managed by ReplicationController, 
 ReplicaSet, Job, DaemonSet or StatefulSet: etcd-server-events-
 ip-172-20-40-167..., etcd-server-ip-172-20-40-167..., kube-apiserver-ip-172-20-40-167..., kube-controller-manager-ip-172-20-40-167..., 
 kube-proxy-ip-172-20-40-167..., kube-scheduler-ip-172-20-40-167...
node "ip-172-20-40-167..." drained

kops 选择了排空其中一个主节点,而不是销毁第一个节点。这样,运行在该节点上的应用可以优雅地关闭。我们可以看到,它排空了在服务器 ip-172-20-40-167 上运行的 etcd-server-eventsetcd-server-ipkube-apiserverkube-controller-managerkube-proxykube-scheduler Pods。因此,Kubernetes 将它们重新调度到其他健康的节点上。可能并非所有的 Pods 都如此,但对于那些可以重新调度的 Pods 来说是成立的。

I0225 23:04:37.479407 1 instancegroups.go:237] Stopping instance "i-
 06d40d6ff583fe10b", node "ip-172-20-40-167...", in group "master-us-east-
 2a.masters.devops23.k8s.local".  

我们可以看到,在排空完成后,主节点被停止。由于每个主节点都与一个自动扩展组相关联,AWS 会检测到该节点已不存在,并启动一个新节点。新服务器初始化后,nodeup 将执行并安装 Docker、Kubelet 和 Protokube。后者将创建一个清单,用于 Kubelet 运行主节点所需的 Pods。Kubelet 还会将新节点注册到某个健康的主节点上。

这一过程与创建新集群或添加新服务器时执行的过程相同。这是整个过程中最耗时的一部分(大约需要五分钟)。

I0225 23:09:38.218945 1 instancegroups.go:161] Validating the cluster.
I0225 23:09:39.437456 1 instancegroups.go:212] Cluster validated.  

我们可以看到,在等待一切稳定后,kops 验证了集群,从而确认了第一个主节点的升级已成功完成。

图 14-6:滚动升级其中一个主节点

一旦验证完第一个主节点的升级,kops 会继续进行下一个节点的升级。在接下来的十到十五分钟内,其他两个主节点会重复相同的过程。所有三个主节点升级完成后,kops 会对工作节点执行相同的过程,我们还需要再等十到十五分钟。

I0225 23:34:01.148318 1 rollingupdate.go:191] Rolling update 
 completed for cluster "devops23.k8s.local"!

最后,一旦所有服务器都升级完成,我们可以看到滚动更新已经完成。

整个体验是积极的,但过程较长。自动扩展组需要一些时间来检测到服务器已宕机。创建并初始化新虚拟机需要一两分钟。Docker、Kubelet 和 Protokube 需要被安装。构成核心 Pod 的容器需要被拉取。总的来说,需要完成的工作还不少。

如果 kops 使用不可变的方法并将所有内容打包成镜像(AMI),升级过程会更快。然而,选择将操作系统与软件包和核心 Pod 解耦,因此安装需要在运行时进行。另外,默认的发行版是 Debian,它不像 CoreOS 那样轻量。由于这些以及其他一些设计选择,整个过程较为冗长。再加上 AWS 执行其部分过程所需的时间,每个集群节点的升级时长超过五分钟。即便只有五个节点,整个过程也需要大约三十分钟。如果集群规模更大,升级可能需要几个小时,甚至几天。

尽管升级过程需要相当长的时间,但它是免手动的。如果我们足够勇敢,可以让 kops 完成任务,然后将时间投入到更有趣的事情上。假设我们的应用程序设计得可扩展且具容错能力,我们不会经历停机。这比是否能够观看升级过程更重要。如果我们信任系统,就可以在后台运行它并忽略它。然而,赢得信任是很难的。在将我们的命运交给它之前,我们需要成功执行几次升级。即便如此,我们仍应建立一个强大的监控和报警系统,以便在出现问题时通知我们。不幸的是,这本书不涉及这些内容。你需要等到下一本书或者自己探索。

让我们回到集群,验证 Kubernetes 是否确实已经升级。

kubectl get nodes  

输出如下:

NAME                 STATUS ROLES  AGE VERSION
ip-172-20-107-172... Ready  node   4m  v1.8.5
ip-172-20-124-177... Ready  master 16m v1.8.5
ip-172-20-44-126...  Ready  master 28m v1.8.5
ip-172-20-56-244...  Ready  node   10m v1.8.5
ip-172-20-67-40...   Ready  master 22m v1.8.5  

从每个节点的版本来看,所有节点都已升级到 v1.8.5。升级过程成功。

尝试经常升级。作为一个经验法则,你应该一次升级一个次要版本。

即使你落后于稳定的 kops 推荐版本几个次要版本,执行多次滚动升级(每次升级一个次要版本)也比一次性跳到最新版本更好。通过升级到下一个次要版本,你将最小化潜在问题,并简化回滚操作(如果需要的话)。

即使 kops 相当可靠,你也不应盲目相信它。创建一个与生产环境相同版本的小型测试集群,执行升级过程,并验证一切是否按预期工作是相对简单的。完成后,你可以销毁测试集群,避免不必要的开销。

不要轻易相信任何人。在单独的集群中测试升级。

自动升级集群

在开始滚动更新过程之前,我们编辑了集群的期望状态。尽管这有效,但我们通常会始终升级到最新的稳定版本。在这种情况下,我们可以执行 kops upgrade 命令。

kops upgrade cluster $NAME --yes  

请注意,这次我们通过设置 --yes 参数跳过了预览步骤。输出如下:

ITEM    PROPERTY          OLD    NEW
Cluster KubernetesVersion v1.8.5 1.8.6

Updates applied to configuration.
You can now apply these changes, using 'kops update cluster 
devops23.k8s.local'

我们可以看到当前的 Kubernetes 版本是 v1.8.5,如果选择继续操作,它将升级到最新版本,在撰写本文时,最新版本是 v1.8.6

kops update cluster $NAME --yes  

正如之前所看到的,从最后一条记录可以看出,更改可能需要实例重启:kops rolling-update cluster

让我们继续:

kops rolling-update cluster $NAME --yes  

我将跳过对输出的评论,因为它与上次升级集群时相同。从过程的角度来看,唯一的显著不同是我们没有通过指定所需版本来编辑集群的期望状态,而是通过 kops upgrade 命令启动了升级过程。其他方面在两种情况下完全相同。

如果我们要创建一个测试集群,并编写一组测试来验证升级过程,我们可以定期执行升级过程。例如,我们可以在 Jenkins 中创建一个作业,每月升级一次。如果没有新的 Kubernetes 版本,它将什么也不做。如果有新版本,它将创建一个与生产环境相同版本的集群,对其进行升级,验证一切是否按预期工作,销毁测试集群,升级生产集群,并进行下一轮测试。然而,达到这一点需要时间和经验。在那之前,手动执行的升级是可行的方式。

在我们能够将应用程序部署到模拟的生产集群之前,我们还缺少一件事情。

访问集群

我们需要一种方式来访问集群。到目前为止,我们看到我们至少可以与 Kubernetes API 进行交互。每次执行kubectl时,它都会通过 API 服务器与集群进行通信。这种通信是通过 AWS 弹性负载均衡器(ELB)建立的。让我们快速看一下它。

aws elb describe-load-balancers  

输出的相关部分如下:

{
 "LoadBalancerDescriptions": [
 {
 ...
 "ListenerDescriptions": [
 {
 "Listener": {
 "InstancePort": 443, 
 "LoadBalancerPort": 443, 
 "Protocol": "TCP", 
 "InstanceProtocol": "TCP"
 }, 
 ...
 "Instances": [
 {
 "InstanceId": "i-01f5c2ca47168b248"
 }, 
 {
 "InstanceId": "i-0305e3b2d3da6e1ce"
 }, 
 {
 "InstanceId": "i-04291ef2432b462f2"
 }
 ], 

 "DNSName": "api-devops23-k8s-local-ivnbim-1190013982.us-east-2.elb.amazonaws.com", 
 ...
 "LoadBalancerName": "api-devops23-k8s-local-ivnbim", 
 ...  

Listener部分可以看出,只有443端口是开放的,因此只允许 SSL 请求。这三个实例属于管理节点。我们可以放心地假设,这个负载均衡器仅用于访问 Kubernetes API。换句话说,我们仍然缺少通过工作节点访问的方式,通过它我们可以与应用程序进行通信。稍后我们会回到这个问题。

对用户来说,重要的条目是DNSName。这是我们需要使用的地址,如果我们想要与 Kubernetes 的 API 服务器通信的话。负载均衡器的作用是确保我们拥有一个固定地址,并且请求会被转发到一个健康的主节点。

最后,负载均衡器的名称是api-devops23-k8s-local-ivnbim。记住它以api-devops23开头非常重要。你很快就会明白为什么这个名称很重要。

我们可以通过检查kubectl配置来确认DNSName确实是通向 API 的入口:

kubectl config view  

输出的相关部分如下:

apiVersion: v1
clusters:
- cluster:
 certificate-authority-data: REDACTED
 server: https://api-devops23-k8s-local-ivnbim-1190013982.us-east-2.elb.am
 azonaws.com
 name: devops23.k8s.local
...
current-context: devops23.k8s.local
...  

我们可以看到devops23.k8s.local被设置为使用amazonaws.com子域作为服务器地址,并且它是当前的上下文。这是 ELB 的 DNS。

图 14-7:Kubernetes API 服务器后的负载均衡器

我们能够访问 API,但这并没有让我们更接近能够访问我们即将部署的应用程序。我们已经了解到,我们可以使用 Ingress 将请求引导到一组端口(通常是80443)。然而,即使我们部署了 Ingress,我们仍然需要一个进入工作节点的入口点。我们需要另一个位于节点之上的负载均衡器。

幸运的是,kops 有一个解决方案。

我们可以使用 kops 的插件来部署额外的核心服务。你可以通过浏览 github.com/kubernetes/kops/tree/master/addons 中的目录,获取当前可用插件的列表。尽管大多数插件都很有用,但我们只关注当前任务。

插件通常是 Kubernetes 资源,定义在 YAML 文件中。我们所需要做的就是选择想要的插件,选择我们偏好的版本,并执行kubectl create。我们将创建在ingress-nginx版本v1.6.0中定义的资源。

我们不会深入探讨我们即将使用的定义 YAML 文件背后的细节,这个文件用于创建 kops 为我们组装的资源。我将把这部分留给你自己。相反,我们将继续使用kubectl create

kubectl create \
 -f https://raw.githubusercontent.com/kubernetes/kops/master/addons/ingress-nginx/v1.6.0.yaml

输出如下:

namespace "kube-ingress" created
serviceaccount "nginx-ingress-controller" created
clusterrole "nginx-ingress-controller" created
role "nginx-ingress-controller" created
clusterrolebinding "nginx-ingress-controller" created
rolebinding "nginx-ingress-controller" created
service "nginx-default-backend" created
deployment "nginx-default-backend" created
configmap "ingress-nginx" created
service "ingress-nginx" created
deployment "ingress-nginx" created  

我们可以看到,在命名空间kube-ingress中创建了相当多的资源。让我们来看看里面有什么。

kubectl --namespace kube-ingress \
 get all

输出如下:

NAME                         DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
deploy/ingress-nginx         3       3       3          3         1m
deploy/nginx-default-backend 1       1       1          1         1m
NAME                                DESIRED CURRENT READY AGE
rs/ingress-nginx-768fc7997b         3       3       3     1m
rs/nginx-default-backend-74f9cd546d 1       1       1     1m
NAME                                      READY STATUS  RESTARTS AGE
po/ingress-nginx-768fc7997b-4xfq8         1/1   Running 0        1m
po/ingress-nginx-768fc7997b-c7zvx         1/1   Running 0        1m
po/ingress-nginx-768fc7997b-clr5m         1/1   Running 0        1m
po/nginx-default-backend-74f9cd546d-mtct8 1/1   Running 0        1m
NAME                      TYPE         CLUSTER-IP     EXTERNAL-IP      PORT(S)                    AGE
svc/ingress-nginx         LoadBalancer 100.66.190.165 abb5117871831... 80:301
    07/TCP,443:30430/TCP 1m
svc/nginx-default-backend ClusterIP    100.70.227.240 <none>           80/TCP
                         1m

我们可以看到,它创建了两个部署,这些部署创建了两个ReplicaSets,进而创建了 Pods。此外,我们还得到了两个服务。因此,Ingress 正在我们的集群中运行,距离能够测试它又近了一步。尽管如此,我们仍然需要弄清楚如何访问集群。

两个服务中的一个(ingress-nginx)是LoadBalancer类型。我们在讨论服务时并未探索这种类型。

LoadBalancer服务类型通过云提供商的负载均衡器将服务暴露到外部。NodePortClusterIP服务会自动创建,外部负载均衡器将路由到这些服务。Ingress 是“智能的”,知道如何创建和配置 AWS ELB。它所需要的只是一个注解service.beta.kubernetes.io/aws-load-balancer-proxy-protocol(在 YAML 文件中定义)。

你会注意到,ingress-nginx服务发布了端口30107并将其映射到8030430被映射到443。这意味着,在集群内部,我们应该能够向30107发送 HTTP 请求,向30430发送 HTTPS 请求。然而,这只是部分情况。由于该服务是LoadBalancer类型,我们也应该期望 AWS 弹性负载均衡器ELBs)发生一些变化。

让我们检查一下集群中负载均衡器的状态。

aws elb describe-load-balancers  

输出(只显示相关部分)如下:

{
 "LoadBalancerDescriptions": [
 {
 ...
 "LoadBalancerName": "api-devops23-k8s-local-ivnbim",
 ...
 }, 
 {
 ...
 "ListenerDescriptions": [
 {
 "Listener": {
 "InstancePort": 30107, 
 "LoadBalancerPort": 80, 
 "Protocol": "TCP", 
 "InstanceProtocol": "TCP"
 }, 
 "PolicyNames": []
 }, 
 {
 "Listener": {
 "InstancePort": 30430, 
 "LoadBalancerPort": 443, 
 "Protocol": "TCP", 
 "InstanceProtocol": "TCP"
 }, 
          "PolicyNames": []
        }
    ], 
      ...
      "Instances": [
        {
          "InstanceId": "i-063fabc7ad5935db5"
        },
        {
          "InstanceId": "i-04d32c91cfc084369"
        }
    ], 
    "DNSName": "a1c431cef1bfa11e88b600650be36f73-2136831960.us-east-2.elb.amazonaws.com", 
      ...
   "LoadBalancerName": "a1c431cef1bfa11e88b600650be36f73", 
      ...

从输出中我们可以看到,新增了一个负载均衡器。

新的负载均衡器发布了端口80(HTTP),并将其映射到30107。这个端口与ingress-nginx服务发布的端口相同。类似地,LB 发布了端口443(HTTPS),并将其映射到30430。从Instances部分我们可以看到,它当前映射到两个工作节点。

接下来,我们可以看到 DNSName。我们应该获取它,但不幸的是,LoadBalancerName 并不遵循任何特定格式。然而,我们知道现在有两个负载均衡器,其中专用于主节点的负载均衡器的名称以 api-devops23 开头。因此,我们可以通过指定名称中不包含该前缀来获取另一个负载均衡器。我们将使用 jq 指令中的 not 来实现这一点。

以下是从新负载均衡器获取 DNS 的命令:

CLUSTER_DNS=$(aws elb \
 describe-load-balancers | jq -r \
 ".LoadBalancerDescriptions[] \
 | select(.DNSName \
 | contains (\"api-devops23\") \
 | not).DNSName")  

我们很快会回到新创建的 Ingress 和负载均衡器。现在,我们继续部署 go-demo-2 应用程序。

部署应用程序

将资源部署到 AWS 上运行的 Kubernetes 集群与在其他任何地方的部署没有区别,包括 Minikube。这就是 Kubernetes 的一个重要优势,或者说是任何其他容器调度器的优势。我们在托管提供商和我们的应用程序之间有一层抽象。因此,我们可以将几乎任何 YAML 定义部署到任何 Kubernetes 集群,无论它位于何处。这非常重要。它为我们提供了很高的自由度,避免了供应商锁定。当然,我们不能轻松地从一个调度器切换到另一个调度器,这意味着我们被“锁定”在了我们选择的调度器中。尽管如此,依赖开源项目总比依赖 AWS、GCE 或 Azure 等商业托管供应商要好。

我们需要花时间设置 Kubernetes 集群,而不同的托管提供商步骤会有所不同。然而,一旦集群搭建完成,我们可以创建几乎任何 Kubernetes 资源,完全忽略其底层的具体实现。无论我们的集群是在 AWS、GCE、Azure、私有云还是其他地方,结果都是一样的。

让我们回到当前任务,创建 go-demo-2 资源:

cd ..

kubectl create \
 -f aws/go-demo-2.yml \
 --record --save-config

我们回到仓库的根目录,创建了在 aws/go-demo-2.yml 中定义的资源。输出结果如下:

ingress "go-demo-2" created
deployment "go-demo-2-db" created
service "go-demo-2-db" created
deployment "go-demo-2-api" created
service "go-demo-2-api" created  

接下来,我们应该等待 go-demo-2-api 部署完成。

kubectl rollout status \
 deployment go-demo-2-api  

输出结果如下:

deployment "go-demo-2-api" successfully rolled out  

最后,我们可以验证应用程序是否正在运行,并且可以通过 AWS 弹性负载均衡器ELB)提供的 DNS 进行访问:

curl -i "http://$CLUSTER_DNS/demo/hello"  

我们得到了响应码 200 和消息 hello, world!。我们在 AWS 上设置的 Kubernetes 集群运行正常!

当我们向专用于工作节点的 ELB 发送请求时,它执行了轮询并将请求转发到一个健康的节点。一旦进入工作节点,请求被 nginx 服务接收,转发到 Ingress,然后再转发到构成 go-demo-2-api ReplicaSet 副本的某个容器。

图 14-8:Kubernetes 工作节点背后的负载均衡器

值得指出的是,构成我们应用程序的容器总是运行在 worker 节点上。另一方面,master 服务器完全专用于运行 Kubernetes 系统。并不意味着我们不能像在 Minikube 中那样将 master 和 worker 组合在同一台服务器上创建集群。然而,这是有风险的,最好将两种类型的节点分开。Master 节点在专用服务器上运行时更可靠。Kops 知道这一点,甚至不允许我们将两者混合。

探索高可用性和容错性

如果集群没有容错能力,它将不可靠。Kops 的目的是使其具备容错能力,但我们还是要验证一下。

让我们获取 worker 节点实例的列表:

aws ec2 \
 describe-instances | jq -r \
 ".Reservations[].Instances[] \
 | select(.SecurityGroups[]\
 .GroupName==\"nodes.$NAME\")\
 .InstanceId"  

我们使用aws ec2 describe-instances来获取所有实例(共五个)。输出被发送到jq,由它按专门用于 worker 节点的安全组进行筛选。

输出如下:

i-063fabc7ad5935db5
i-04d32c91cfc084369  

我们将终止其中一个 worker 节点。为了做到这一点,我们将随机选择一个,并获取它的 ID。

INSTANCE_ID=$(aws ec2 \
 describe-instances | jq -r \
 ".Reservations[].Instances[] \ 
 | select(.SecurityGroups[]\
 .GroupName==\"nodes.$NAME\")\
 .InstanceId" | tail -n 1)  

我们使用了和之前相同的命令,并添加了tail -n 1,这样输出就限制为一行(条目)。我们将结果存储在INSTANCE_ID变量中。现在我们知道要终止哪个实例。

aws ec2 terminate-instances \
 --instance-ids $INSTANCE_ID  

输出如下:

{
 "TerminatingInstances": [
 {
 "InstanceId": "i-063fabc7ad5935db5",
 "CurrentState": {
 "Code": 32,
 "Name": "shutting-down"
 },
 "PreviousState": {
 "Code": 16,
 "Name": "running"
 }
 }
 ]
}  

从输出中我们可以看到该实例正在关闭。我们可以通过列出来自安全组nodes.devops23.k8s.local的所有实例来确认这一点。

aws ec2 describe-instances | jq -r \
    ".Reservations[].Instances[] \
    | select(\
    .SecurityGroups[].GroupName \
    ==\"nodes.$NAME\").InstanceId"

输出如下:

i-04d32c91cfc084369  

正如预期的那样,现在我们只运行了一个实例。剩下的就是等待一分钟,然后重复相同的命令。

aws ec2 \ 
 describe-instances | jq -r \
 ".Reservations[].Instances[] \ 
 | select(.SecurityGroups[]\
 .GroupName==\"nodes.$NAME\")\
 .InstanceId"  

输出如下:

i-003b4b1934d85641a
i-04d32c91cfc084369  

这一次,我们可以看到又有了两个实例。唯一的不同是,这一次其中一个实例 ID 不同。

AWS 自动扩展组发现实例与期望的数量不符,于是创建了一个新实例。

AWS 创建了一个新节点来替换我们终止的节点,这并不意味着新服务器已加入 Kubernetes 集群。让我们验证一下:

kubectl get nodes  

输出如下:

NAME                                        STATUS ROLES  AGE VERSION
ip-172-20-55-183.us-east-2.compute.internal Ready  master 30m v1.
 8.6
ip-172-20-61-82.us-east-2.compute.internal  Ready  node   13m v1.
 8.6
ip-172-20-71-53.us-east-2.compute.internal  Ready  master 30m v1.
 8.6
ip-172-20-97-39.us-east-2.compute.internal  Ready  master 30m v1.
 8.6 

如果你足够快,你的输出应该也会显示只有一个(worker) node。一旦 AWS 创建了一个新服务器,直到 Docker、Kubelet 和 Protokube 安装完成,容器拉取并运行,并通过其中一个 master 注册节点,还需要一点时间。

我们再试一次。

kubectl get nodes  

输出如下:

NAME                                        STATUS ROLES  AGE VERSION
ip-172-20-55-183.us-east-2.compute.internal Ready  master 32m v1.
 8.6
ip-172-20-61-82.us-east-2.compute.internal  Ready  node   15m v1.
 8.6
ip-172-20-71-53.us-east-2.compute.internal  Ready  master 32m v1.
 8.6
ip-172-20-79-161.us-east-2.compute.internal Ready  node   2m  v1.
 8.6
ip-172-20-97-39.us-east-2.compute.internal  Ready  master 32m v1.
 8.6  

这一次,(worker)节点的数量回到了两个。我们的集群恢复到了期望的状态。

我们刚才经历的,基本上和执行滚动升级时一样。唯一的区别是我们终止了一个实例,以模拟故障。在升级过程中,kops 也会做同样的事情。它一次关闭一个实例,并等待直到集群恢复到期望的状态。

随时可以对主节点进行类似的测试。唯一的区别是,你需要使用masters而不是nodes作为安全组名称的前缀。由于其他一切都相同,我相信你不需要更多的指令和解释。

给予他人访问集群的权限

除非你打算成为组织中唯一拥有集群访问权限的人,否则你需要创建一个kubectl配置,分发给你的同事。让我们来看看步骤:

cd cluster

mkdir -p config

export KUBECONFIG=$PWD/config/kubecfg.yaml  

我们回到cluster目录,创建了config子目录,并导出了KUBECONFIG变量,指向我们希望存储配置文件的路径。现在,我们可以执行kops export

kops export kubecfg --name ${NAME}

cat $KUBECONFIG  

后一个命令的输出如下:

apiVersion: v1
clusters:
- cluster:
 certificate-authority-data: ...
 server: https://api-devops23-k8s-local-ivnbim-609446190.us-east-2.elb.amazonaws.com
  name: devops23.k8s.local
contexts:
- context:
 cluster: devops23.k8s.local
 user: devops23.k8s.local
 name: devops23.k8s.local
current-context: devops23.k8s.local
kind: Config
preferences: {}
users:
- name: devops23.k8s.local
 user:
 as-user-extra: {}
 client-certificate-data: ...
 client-key-data: ...
 password: oeezRbhG4yz3oBUO5kf7DSWcOwvjKZ6l
 username: admin
- name: devops23.k8s.local-basic-auth
 user:
 as-user-extra: {}
    password: oeezRbhG4yz3oBUO5kf7DSWcOwvjKZ6l
    username: admin

现在,你可以将这个配置文件交给你的同事,他将拥有和你一样的访问权限。

说实话,你应该创建一个新用户和密码,或者更好的是,创建一个 SSH 密钥,并让你组织中的每个用户使用自己的认证来访问集群。你还应该为每个用户或一组用户创建 RBAC 权限。我们不会详细讲解这些步骤,因为它们已经在第十二章《保护 Kubernetes 集群》中解释过了,Securing Kubernetes Clusters

销毁集群

本章即将结束,我们不再需要集群。我们希望尽快销毁它。没有什么理由在不使用时让它继续运行。但在进行破坏性操作之前,我们将创建一个文件,存储本章使用的所有环境变量。这样下次我们想重建集群时会更加方便。

echo "export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID 
export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY 
export AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION 
export ZONES=$ZONES 
export NAME=$NAME 
export KOPS_STATE_STORE=$KOPS_STATE_STORE" \ 
    >kops 

我们将变量及其值写入了kops文件,现在我们可以删除集群:

kops delete cluster \
 --name $NAME \
 --yes  

输出如下:

...
Deleted kubectl config for devops23.k8s.local

Deleted cluster: "devops23.k8s.local"  

Kops 从我们的kubectl配置中删除了集群的引用,并开始删除它所创建的所有 AWS 资源。我们的集群不复存在。我们可以继续并删除 S3 存储桶。

aws s3api delete-bucket \
 --bucket $BUCKET_NAME  

我们不会删除 IAM 资源(组、用户、访问密钥和策略)。在 AWS 中保留它们不会产生费用,我们也能避免重新执行创建它们的命令。然而,我会列出这些命令作为参考。

请勿执行以下命令。它们仅作为参考。我们将在下一章使用这些资源。

# Replace `[...]` with the administrative access key ID.
export AWS_ACCESS_KEY_ID=[...]

# Replace `[...]` with the administrative secret access key.
export AWS_SECRET_ACCESS_KEY=[...]

aws iam remove-user-from-group \
 --user-name kops \
 --group-name kops

aws iam delete-access-key \ 
 --user-name kops \
 --access-key-id $(\
 cat kops-creds | jq -r\ 
 '.AccessKey.AccessKeyId')

aws iam delete-user \
 --user-name kops

aws iam detach-group-policy \
 --policy-arn arn:aws:iam::aws:policy/AmazonEC2FullAccess \
 --group-name kops

aws iam detach-group-policy \
 --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess \
 --group-name kops

aws iam detach-group-policy \
 --policy-arn arn:aws:iam::aws:policy/AmazonVPCFullAccess \
 --group-name kops

aws iam detach-group-policy \
 --policy-arn arn:aws:iam::aws:policy/IAMFullAccess \
 --group-name kops

aws iam delete-group \
 --group-name kops  

接下来做什么?

我们在 AWS 中运行着一个生产就绪的 Kubernetes 集群。这难道不是值得庆祝的事情吗?

Kops 证明了它相对易于使用。我们执行的aws命令比kops命令更多。如果不考虑这些命令,整个集群可以通过单个kops命令创建。我们可以轻松地添加或删除工作节点。升级过程简单且可靠,虽然有些漫长。关键是,通过滚动升级,我们可以避免停机。

有一些kops命令我们没有探讨。我觉得现在你已经掌握了重要部分,并且能够通过文档弄清楚剩下的内容。

你可能会觉得自己已经准备好应用到目前为止所学的一切。别急着打开那瓶你为特殊场合准备的香槟。还有一个重要的话题我们需要探索。我们推迟了关于有状态服务的讨论,因为当时我们没有使用外部存储的能力。我们确实使用了卷,但它们都是本地卷,不具备持久性。单个服务器的故障就能证明这一点。现在我们在 AWS 上运行集群,我们可以探讨如何部署有状态应用。

Kubernetes 操作(kops)与 Docker for AWS 的比较

Docker for AWS (D4AWS)很快成为了在 AWS(和 Azure)上创建 Docker Swarm 集群的首选方式。同样,kops 是创建 Kubernetes 集群的最常用工具,至少在本文写作时是如此。

使用这两个工具得到的结果大致相同。它们都会创建安全组、VPC、自动伸缩组、弹性负载均衡器以及集群所需的其他所有组件。在这两种情况下,自动伸缩组负责创建 EC2 实例。两者都依赖外部存储来保持集群的状态(kops 使用 S3,D4AWS 使用 DynamoDB)。在这两种情况下,由自动伸缩组创建的 EC2 实例知道如何运行系统级服务并加入集群。如果我们排除一个方案使用 Docker Swarm 而另一个使用 Kubernetes 的事实,仅观察结果(即集群),就没有什么显著的功能差异。因此,我们将重点关注用户体验。

两个工具都可以通过命令行执行,这就是我们能察觉到的第一个区别。

Docker for AWS 依赖于 CloudFormation 模板,因此我们需要执行aws cloudformation命令。Docker 提供了一个模板,我们应使用参数来定制它。在我看来,CloudFormation 要求我们传递参数的方式实在是有些愚蠢。

让我们来看一个例子:

aws cloudformation create-stack \
 --template-url https://editions-us-east-1.s3.amazonaws.com/aws/stable/Docker.tmpl \
 --capabilities CAPABILITY_IAM \
 --stack-name devops22 \
    --parameters \
 ParameterKey=ManagerSize,ParameterValue=3 \
 ParameterKey=ClusterSize,ParameterValue=2 \
 ParameterKey=KeyName,ParameterValue=workshop \ 
 ParameterKey=EnableSystemPrune,ParameterValue=yes \
 ParameterKey=EnableCloudWatchLogs,ParameterValue=no \ 
 ParameterKey=EnableCloudStorEfs,ParameterValue=yes \
 ParameterKey=ManagerInstanceType,ParameterValue=t2.small \
    ParameterKey=InstanceType,ParameterValue=t2.small  

写出像ParameterKey=ManagerSize,ParameterValue=3这样的内容,而不是ManagerSize=3,无疑是让人烦恼的。

使用kops创建 Kubernetes 集群的示例命令如下:

kops create cluster \
 --name $NAME \
 --master-count 3 \
 --node-count 1 \
 --node-size t2.small \
 --master-size t2.small \ 
 --zones $ZONES \
 --master-zones $ZONES \
 --ssh-public-key devops23.pub \
 --networking kubenet \
 --kubernetes-version v1.8.4 \
 --yes 

这是不是更简单更直观?

此外,kops 是一个包含我们所需要的一切的二进制文件。例如,我们可以执行 kops --help 来查看可用选项和一些示例。如果我们想知道 Docker For AWS 可以使用哪些参数,我们需要浏览模板。这显然没有运行 kops create cluster --help 那样直观和简单。即使我们不介意查看 Docker For AWS 模板,我们仍然没有命令行上的示例(而不是浏览器中的示例)。从用户体验的角度来看,如果我们仅限于命令行接口,kops 胜过 Docker For AWS。简单来说,执行一个专门用于管理集群的明确定义的二进制文件比执行带有远程模板的 aws cloudformation 命令要好。

Docker 选择 CloudFormation 是不是一个错误?我不这么认为。即使命令行体验不尽如人意,显然他们希望提供一种与托管供应商原生兼容的体验。在我们的案例中,这是 AWS,但对于 Azure 也可以说是同样的道理。如果你总是通过命令行操作集群(就像我认为你应该做的那样),那么这个故事就到此为止,kops 以微弱优势获胜。

我们能够使用 CloudFormation 创建 Docker For AWS 集群,意味着我们可以通过 AWS 控制台来利用它。这就转化为 UI 体验。我们可以使用 AWS 控制台的 UI 来创建、更新或删除集群。我们可以查看事件的进展,探索创建的资源,回滚到先前的版本,等等。通过选择 CloudFormation 模板,Docker 决定不仅提供命令行,还提供了可视化体验。

就我个人而言,我认为用户界面是邪恶的,我们应该通过命令行来完成所有操作。话虽如此,我完全理解并不是每个人都有相同的看法。即使你决定永远不使用 UI 进行“实际”工作,至少在开始时,UI 仍然非常有帮助,它可以作为一种学习体验,让你了解可以做什么,不能做什么,以及所有步骤如何关联在一起。

图 14-9:Docker For AWS 用户界面

这确实是一个艰难的抉择。重要的是,这两个工具都能创建可靠的集群。kops 在命令行方面更具用户友好性,但没有 UI。另一方面,Docker For AWS 通过 CloudFormation 作为原生 AWS 解决方案工作。这样它就拥有了 UI,但也牺牲了命令行体验的最佳效果。

你不必在两者之间做出选择,因为这个选择不取决于你更喜欢哪个,而是取决于你是否想使用 Docker Swarm 或 Kubernetes。

第十五章:持久化状态

如果在重新调度时丢失应用状态,那么无论我们有多高的容错能力和高可用性,都没有意义。拥有状态是不可避免的,我们需要无论发生什么,始终保存它,不论是应用程序、服务器,甚至整个数据中心。

保存应用状态的方式取决于它们的架构。一些应用将数据存储在内存中,并依赖于定期备份。其他应用能够在多个副本之间同步数据,因此一个副本的丢失不会导致数据丢失。然而,大多数应用依赖于磁盘来存储它们的状态。我们将重点关注这一类有状态的应用。

如果我们要构建容错系统,就需要确保系统任何部分的故障都能恢复。由于速度至关重要,我们不能依赖手动操作来从故障中恢复。即使我们能做到,也没有人愿意坐在屏幕前,等待某些东西发生故障,然后再把它恢复到先前的状态。

我们已经看到,Kubernetes 在大多数情况下会从应用程序、服务器,甚至整个数据中心的故障中恢复。它会将 Pods 重新调度到健康节点。我们也体验过 AWS 和 kops 在基础设施层面上实现大致相同效果的方式。自动扩展组会重新创建故障节点,且由于它们使用 kops 启动过程进行配置,新的实例会拥有所需的所有内容,并加入集群。

唯一阻止我们称系统(大多数情况下)具备高可用性和容错性的是我们没有解决在故障发生时如何保持状态的问题。这也是我们接下来将要探讨的主题。

无论我们的有状态应用程序或其运行的服务器发生什么,我们都会尽力保留我们的数据。

创建 Kubernetes 集群

我们将通过重新创建与上一章相似的集群开始:

本章中的所有命令可以在 15-pv.sh (gist.github.com/vfarcic/41c86eb385dfc5c881d910c5e98596f2) Gist 中找到。

cd k8s-specs

git pull

cd cluster  

我们进入了 k8s-specs 仓库的本地副本,拉取了最新的代码,并进入了 cluster 目录。

在上一章中,我们将使用的环境变量存储在 kops 文件中。让我们快速看一下它们。

cat kops  

输出结果(不包括密钥)如下:

export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export AWS_DEFAULT_REGION=us-east-2
export ZONES=us-east-2a,us-east-2b,us-east-2c
export NAME=devops23.k8s.local
export KOPS_STATE_STORE=s3://devops23-1520933480  

通过将环境变量存储在文件中,我们可以通过 source 命令快速加载它们,从而加速该过程。

在书籍的早期版本中,我们用于存储环境变量到 kops 文件的命令存在错误。export 命令缺失。请确保你文件中的所有行都以 export 开头。如果不是这种情况,请相应地更新它。

source kops  

现在环境变量已设置好,我们可以继续创建S3存储桶:

export BUCKET_NAME=devops23-$(date +%s)

aws s3api create-bucket \ 
 --bucket $BUCKET_NAME \
 --create-bucket-configuration \
 LocationConstraint=$AWS_DEFAULT_REGION

export KOPS_STATE_STORE=s3://$BUCKET_NAME  

创建kops别名的命令如下。只有Windows 用户执行此命令:

alias kops="docker run -it --rm \
 -v $PWD/devops23.pub:/devops23.pub \
 -v $PWD/config:/config \
 -e KUBECONFIG=/config/kubecfg.yaml \
 -e NAME=$NAME -e ZONES=$ZONES \
 -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
 -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
 -e KOPS_STATE_STORE=$KOPS_STATE_STORE \
 vfarcic/kops"  

现在,我们终于可以在 AWS 中创建一个新的 Kubernetes 集群了。

kops create cluster \
 --name $NAME \
 --master-count 3 \
 --master-size t2.small \
 --node-count 2 \
 --node-size t2.medium \
 --zones $ZONES \
 --master-zones $ZONES \
 --ssh-public-key devops23.pub \
 --networking kubenet \
  --yes  

如果我们将此命令与上一章执行的命令进行比较,会发现只有几个小变化。我们将node-count增加到2node-size增大为t2.medium。这将为我们提供足够的容量,以应对本章中的所有练习。

让我们验证集群:

kops validate cluster  

假设在我们执行kops create cluster后已经过了一段时间,输出应表明cluster devops23.k8s.local is ready

给 Windows 用户的提示

Kops 在容器内执行。它改变了容器内的上下文,但现在上下文已消失。结果,你本地的kubectl上下文保持不变。我们可以通过执行kops export kubecfg --name ${NAME}export KUBECONFIG=$PWD/config/kubecfg.yaml来解决这个问题。第一个命令将配置导出到/config/kubecfg.yaml。该路径通过环境变量KUBECONFIG指定,并被挂载为本地硬盘上的config/kubecfg.yaml。后一个命令将在本地导出KUBECONFIG。通过这个变量,kubectl现在会使用config/kubecfg.yaml中的配置,而不是默认配置。在你执行这些命令之前,请给 AWS 一些时间来创建所有 EC2 实例,并让它们加入集群。等待并执行这些命令后,一切就绪。

如果我们希望访问将要部署的应用程序,我们将需要 Ingress。

kubectl create \
 -f https://raw.githubusercontent.com/kubernetes/kops/master/addons/ingress-nginx/v1.6.0.yaml

如果没有 ELB DNS,Ingress 对我们帮助不大,所以我们也需要获取 ELB DNS:

CLUSTER_DNS=$(aws elb \
 describe-load-balancers | jq -r \
 ".LoadBalancerDescriptions[] \
 | select(.DNSName \
 | contains (\"api-devops23\") \
 | not).DNSName")

echo $CLUSTER_DNS  

后一个命令的输出应该以us-east-2.elb.amazonaws.com结尾。

最后,既然集群设置已经完成,我们可以回到仓库根目录。

cd ..  

部署无状态持久化的状态应用

我们将通过部署一个没有任何持久化机制的状态应用程序来开始探索。这将帮助我们更好地理解在本章中使用的一些 Kubernetes 概念和资源的好处。

我们已经部署过几次 Jenkins。由于它是一个有状态的应用程序,非常适合用作实验平台。

让我们看一下存储在pv/jenkins-no-pv.yml文件中的定义。

cat pv/jenkins-no-pv.yml  

YAML 定义了jenkins命名空间、Ingress 控制器和服务。我们已经熟悉这些资源类型,因此跳过解释,直接进入 Deployment 定义。

cat命令的输出,限定为jenkins部署,如下所示:

...
apiVersion: apps/v1beta2
kind: Deployment
metadata:
 name: Jenkins
      namespace: Jenkins
    spec:
      selector:
        matchLabels:
          app: Jenkins
      strategy:
        type: Recreate
      template:
        metadata:
          labels:
            app: Jenkins
        spec:
          containers:
          - name: Jenkins
            image: vfarcic/Jenkins
            env:
            - name: JENKINS_OPTS
              value: --prefix=/Jenkins
            volumeMounts:
            - name: jenkins-creds
              mountPath: /etc/secrets
            resources:
              limits:
                memory: 2Gi
                cpu: 1
              requests:
                memory: 1Gi
                cpu: 0.5
          volumes:
          - name: jenkins-creds
            secret:
              secretName: jenkins-creds

这个部署没有什么特别的地方。我们已经使用过非常类似的部署。此外,到现在为止,你已经是部署控制器的专家了。

唯一值得一提的是,只有一个卷挂载,它引用了我们用来为 Jenkins 提供初始管理员用户的秘密。Jenkins 将其状态保存在 /var/jenkins_home 中,而我们没有挂载该目录。

让我们创建在 pv/jenkins-no-pv.yml 中定义的资源:

kubectl create \
 -f pv/jenkins-no-pv.yml \
 --record --save-config  

输出如下:

namespace "jenkins" created
ingress "jenkins" created
service "jenkins" created
deployment "jenkins" created  

我们将快速查看事件,作为检查一切是否成功部署的方式:

kubectl --namespace jenkins \
 get events

输出(仅限相关部分)如下:

...
2018-03-14 22:36:26 +0100 CET   2018-03-14 22:35:54 +0100 CET   7         jenkins-8768d486-lmv6b.151be70fd682e40d   Pod                 Warning   FailedMount  kubelet, ip-172-20-99-208.us-east-2.compute.internal   MountVolume.SetUp 
 failed for volume "jenkins-creds" : secrets "jenkins-creds" not found
    ...

我们可以看到唯一的卷设置失败,因为它找不到作为 jenkins-creds 引用的秘密。让我们创建它:

kubectl --namespace jenkins \
 create secret \
 generic jenkins-creds \
 --from-literal=jenkins-user=jdoe \
 --from-literal=jenkins-pass=incognito  

现在,随着 jenkins 命名空间中创建的秘密 jenkins-creds,我们可以确认部署的发布成功。

kubectl --namespace jenkins \
 rollout status \
 deployment jenkins  

我们可以从输出中看到,deployment "jenkins" 已成功发布。

现在一切正常运行,我们可以在浏览器中打开 Jenkins 用户界面:

open "http://$CLUSTER_DNS/jenkins"

对 Windows 用户的说明

Git Bash 可能无法使用 open 命令。如果是这种情况,请将 open 命令替换为 echo。这样,你将得到应在你选择的浏览器中直接打开的完整地址。

请点击登录链接,输入 jdoe 作为用户,incognito 作为密码。完成后,点击登录按钮。

现在我们以 jdoe 管理员身份认证成功,我们可以继续创建一个任务。这将生成一个状态,我们可以用来探索有状态应用程序失败时会发生什么。

请点击创建新任务链接,输入 my-job 作为项目名称,选择管道作为任务类型,并点击确定按钮。

你将看到任务配置屏幕。这里无需做任何操作,因为我们目前并不关心任何特定的管道定义。只需点击保存按钮即可。

接下来,我们将通过终止 jenkins 部署中创建的 Pod 内运行的 java 进程来模拟一个故障。为了做到这一点,我们需要找出 Pod 的名称。

kubectl --namespace jenkins \
 get pods \
 --selector=app=jenkins \
 -o json  

我们从 jenkins 命名空间中获取了 Pods,使用选择器 api=jenkins 进行过滤,并将输出格式化为 json

输出(仅限相关部分)如下:

{
 "apiVersion": "v1",
 "items": [
 {
 ...
 "metadata": {
 ...
 "name": "jenkins-8768d486-lmv6b",
 ...

我们可以看到名称位于某个 itemsmetadata 条目中。我们可以利用这一点来构建 jsonpath,仅提取 Pod 的名称:

POD_NAME=$(kubectl \
 --namespace jenkins \
 get pods \
 --selector=app=jenkins \
 -o jsonpath="{.items[*].metadata.name}")

echo $POD_NAME  

Pod 的名称现在存储在环境变量 POD_NAME 中。

后续命令的输出如下:

jenkins-8768d486-lmv6b  

现在我们知道了托管 Jenkins 的 Pod 名称,我们可以继续并终止 java 进程:

kubectl --namespace jenkins \
 exec -it $POD_NAME pkill java  

一旦我们终止了 Jenkins 进程,容器就失败了。从经验来看,我们知道 Pod 内部的失败容器会被重新创建。结果是,我们有一个短暂的停机时间,但 Jenkins 已经再次运行。

让我们看看之前创建的任务发生了什么。我相信你知道答案,但我们还是检查一下:

open "http://$CLUSTER_DNS/jenkins"  

如预期的那样,my-job 无法找到。托管 /var/jenkins_home 目录的容器失败了,并且被替换成了一个新的容器。我们创建的状态丢失了。

说实话,我们已经在 第八章,使用卷访问主机文件系统 中看到了我们可以挂载卷来尝试保持状态跨故障的情况。然而,过去我们使用的是 emptyDir,它挂载了一个本地卷。尽管比什么都不使用要好,但这种卷仅在存储它的服务器运行时存在。如果服务器发生故障,存储在 emptyDir 中的状态就会丢失。这样的解决方案仅比不使用任何卷稍好一些。通过使用本地磁盘,我们只是在推迟不可避免的故障,迟早我们会遇到同样的情况。最后我们会困惑于为什么在 Jenkins 中创建的所有内容都丢失了。我们可以做得更好。

创建 AWS 卷

如果我们希望保存即使在服务器故障后也能持久化的状态,我们有两个可选择的方案。例如,我们可以将数据存储在本地,并将其复制到多个服务器上。这样,容器可以使用本地存储,并确保文件在所有服务器上都能访问到。如果我们自己实现这一过程,配置起来会非常复杂。说实话,我们可以使用某些卷驱动程序来实现这一点。但我们会选择一种更常用的方法来确保状态在故障中持久化。我们将使用外部存储。

由于我们在 AWS 中运行我们的集群,我们可以在 S3 (aws.amazon.com/s3/)、弹性文件系统EFS) (aws.amazon.com/efs/) 和 弹性块存储 (EBS)(aws.amazon.com/ebs/) 之间进行选择。

S3 是通过其 API 访问的,并不适合作为本地磁盘的替代品。因此,我们只剩下了 EFS 和 EBS 作为选择。

EFS 有一个显著的优势,它可以挂载到多个 EC2 实例,并跨多个可用区进行分布。它是我们能够实现的最接近容错存储的解决方案。即使整个可用区(数据中心)发生故障,我们仍然可以在集群所使用的其他可用区中使用 EFS。然而,这也带来了一定的成本。EFS 会引入性能惩罚。毕竟,它是一个 网络文件系统NFS),这意味着它会带来更高的延迟。

弹性块存储EBS)是我们在 AWS 中可以使用的最快存储。它的数据访问延迟非常低,因此在性能是主要关注点时,它是最佳选择。缺点是可用性。它不能跨多个可用区工作。如果某个区发生故障,意味着会有停机时间,至少直到该区恢复正常运行状态。

我们将选择 EBS 来满足我们的存储需求。Jenkins 强烈依赖 I/O,因此我们需要尽可能快速地访问数据。然而,选择 EBS 还有一个原因。EBS 完全支持 Kubernetes。EFS 将会推出,但在写作时,它仍处于实验阶段。作为一个额外的优势,EBS 比 EFS 便宜得多。

考虑到需求和 Kubernetes 提供的功能,选择显而易见。我们将使用 EBS,尽管如果 Jenkins 所在的可用区发生故障,我们可能会遇到问题。在这种情况下,我们需要将 EBS 卷迁移到一个健康的区域。没有完美的解决方案。

我们有些急于求成了。我们暂时搁置 Kubernetes,集中精力创建 EBS 卷。

每个 EBS 卷都与一个可用区绑定。与 EFS 不同,EBS 不能跨多个可用区。所以,我们首先需要做的是找出工作节点所在的区域。我们可以通过描述属于安全组 nodes.devops23.k8s.local 的 EC2 实例来获取这些信息。

aws ec2 describe-instances  

输出,限定为相关部分,如下:

{
 "Reservations": [
 {
 "Instances": [
 {
 ...
          "SecurityGroups": [
            {
              "GroupName": "nodes.devops23.k8s.local",
              "GroupId": "sg-33fd8c58"
            }
          ],
          ...
          "Placement": {
            "Tenancy": "default",
            "GroupName": "",
            "AvailabilityZone": "us-east-2a"
          },
          ...

我们可以看到信息位于 Reservations.Instances 数组中。要获取区域,我们需要通过 SecurityGroups.GroupName 字段筛选输出。区域名称位于 Placement.AvailabilityZone 字段中。

进行筛选并获取工作节点可用区的命令如下:

aws ec2 describe-instances \
 | jq -r \
 ".Reservations[].Instances[] \
 | select(.SecurityGroups[]\
 .GroupName==\"nodes.$NAME\")\
 .Placement.AvailabilityZone"  

输出如下:

us-east-2a
us-east-2c  

我们可以看到,两个工作节点位于区域 us-east-2aus-east-2c

检索两个工作节点区域并将其存储在环境变量中的命令如下:

aws ec2 describe-instances \
 | jq -r \
 ".Reservations[].Instances[] \
 | select(.SecurityGroups[]\
 .GroupName=="\nodes.$NAME\")\
 .Placement.AvailabilityZone" \
 | tee zones

AZ_1=$(cat zones | head -n 1)

AZ_2=$(cat zones | tail -n 1)  

我们检索了区域并将输出存储到 zones 文件中。接下来,我们用 head 命令检索了第一行并将其存储在环境变量 AZ_1 中。同样地,我们将最后一行(第二行)存储在变量 AZ_2 中。

现在我们拥有了创建几个卷所需的所有信息。

接下来的命令需要较新的 aws 版本。如果执行失败,请更新您的 AWS CLI 二进制文件到最新版本。

VOLUME_ID_1=$(aws ec2 create-volume \
 --availability-zone $AZ_1 \
 --size 10 \
 --volume-type gp2 \
 --tag-specifications "ResourceType=volume,Tags=[{Key=KubernetesCluster,Value=$NAME}]" \
 | jq -r '.VolumeId')

VOLUME_ID_2=$(aws ec2 create-volume \
 --availability-zone $AZ_1 \
 --size 10 \
 --volume-type gp2 \
 --tag-specifications "ResourceType=volume,Tags=[{Key=KubernetesCluster,Value=$NAME}]" \
    | jq -r '.VolumeId')

VOLUME_ID_3=$(aws ec2 create-volume \
    --availability-zone $AZ_2 \
    --size 10 \
    --volume-type gp2 \
    --tag-specifications "ResourceType=volume,Tags=[{Key=KubernetesCluster,Value=$NAME}]" \
 | jq -r '.VolumeId')

我们执行了三次 aws ec2 create-volume 命令,结果创建了三个 EBS 卷。两个在同一区域,第三个在另一区域。它们的空间都是 10 GB。我们选择了 gp2 作为卷类型。其他类型要么需要更大的大小,要么更贵。若有疑问,gp2 通常是 EBS 卷的最佳选择。

我们还定义了一个标签,用于帮助我们区分专门为该集群分配的卷与我们在 AWS 账户中为其他用途可能拥有的卷。

最后,jq 筛选了输出,只提取了卷 ID。结果存储在环境变量 VOLUME_ID_1VOLUME_ID_2VOLUME_ID_3 中。

让我们快速查看一下我们作为环境变量存储的其中一个 ID:

echo $VOLUME_ID_1  

输出如下:

vol-092b8980b1964574a  

最后,为了安全起见,我们将列出与 ID 匹配的卷,从而毫无疑问地确认 EBS 确实已创建。

aws ec2 describe-volumes \
 --volume-ids $VOLUME_ID_1  

输出结果如下:

{
 "Volumes": [
 {
 "AvailabilityZone": "us-east-2c",
 "Attachments": [],
 "Tags": [
 {
 "Value": "devops23.k8s.local",
 "Key": "KubernetesCluster"
 }
 ],
 "Encrypted": false,
 "VolumeType": "gp2",
 "VolumeId": "vol-092b8980b1964574a",
 "State": "available",
 "Iops": 100,
 "SnapshotId": "",
 "CreateTime": "2018-03-14T21:47:13.242Z",
 "Size": 10
 }
 ]
}  

现在 EBS 卷确实是 available 并且与工作节点位于同一可用区,我们可以继续创建 Kubernetes 持久化卷。

图 15-1:在与工作节点相同的可用区中创建的 EBS 卷

创建 Kubernetes 持久化卷

我们有几个 EBS 卷可用,但这并不意味着 Kubernetes 知道它们的存在。我们需要添加 PersistentVolumes,它们将作为 Kubernetes 集群与 AWS EBS 卷之间的桥梁。

PersistentVolumes 使我们能够抽象存储提供的细节(例如 EBS)与如何使用存储的细节。就像 Volumes 一样,PersistentVolumes 是 Kubernetes 集群中的资源。主要的区别是它们的生命周期独立于使用它们的单个 Pod。

让我们来看一个定义,它将创建一些 PersistentVolumes:

cat pv/pv.yml  

输出结果,限于三卷中的第一卷,具体如下:

kind: PersistentVolume
apiVersion: v1
metadata:
 name: manual-ebs-01
 labels:
 type: ebs
spec:
 storageClassName: manual-ebs
 capacity:
 storage: 5Gi
 accessModes:
 - ReadWriteOnce
 awsElasticBlockStore:
 volumeID: REPLACE_ME_1
 fsType: ext4
...  

spec 部分包含了一些有趣的细节。我们将 manual-ebs 设置为存储类名称。稍后我们将了解它的功能。目前,只需要记住这个名称。

我们定义了存储容量为 5Gi。它不需要与我们之前创建的 EBS 容量相同,只要不大于即可。Kubernetes 会尽量匹配 PersistentVolume 和容量相似的 EBS,在这种情况下,容量相同或类似的 EBS。如果我们只有一个 10 GB 的 EBS 卷,它就是 5Gi 请求的最接近(也是唯一)匹配。理想情况下,持久卷的容量应与 EBS 大小匹配,但我想展示任何小于或等于实际大小的值都能满足要求。

我们指定了访问模式为 ReadWriteOnce。这意味着我们将能够以只读写一次的方式挂载卷。任何时候只有一个 Pod 能够使用它。由于 EBS 不能挂载到多个实例,这种策略对我们非常适合。我们选择的访问模式不算真正的选择,而是对 EBS 工作方式的确认。其他访问模式是 ReadOnlyManyReadWriteMany。这两种模式会导致卷可以挂载到多个 Pod,分别以只读或读写模式。这些模式更适合像 EFS 这样的 NFS 系统,后者可以被多个实例挂载。

到目前为止,我们探讨过的spec字段是所有持久卷类型通用的。除此之外,还有一些条目是特定于我们与 Kubernetes PersistentVolume关联的实际卷的。由于我们将使用 EBS,因此我们指定了awsElasticBlockStore,并提供了卷 ID 和文件系统类型。由于我无法提前知道您 EBS 卷的 ID,所以在定义中将其值设置为REPLACE_ME。稍后我们将用之前创建的 EBS 的 ID 来替换它。

我们本来可以指定许多其他类型。如果这个集群运行在 Azure 上,我们可以使用azureDiskazureFile。在Google Compute EngineGCE)中,它将是GCEPersistentDisk。我们也可以设置Glusterfs。或者,如果我们把这个集群运行在本地数据中心,可能会使用nfs。我们可以使用的类型还有很多,但由于我们在 AWS 上运行集群,许多类型将无法使用,而其他一些可能会设置起来太复杂。由于 EBS 已经可用,我们就选择它吧。总的来说,这个集群运行在 AWS 上,而awsElasticBlockStore是最简单的,若不是最好的选择。

现在我们已经理解了 YAML 定义,我们可以继续创建PersistentVolume

cat pv/pv.yml \
 | sed -e \
 "s@REPLACE_ME_1@$VOLUME_ID_1@g" \
 | sed -e \
 "s@REPLACE_ME_2@$VOLUME_ID_2@g" \
 | sed -e \
 "s@REPLACE_ME_3@$VOLUME_ID_3@g" \
 | kubectl create -f - \
 --save-config --record  

我们使用cat命令输出pv/pv.yml文件的内容,并将其管道传输到sed命令,这些命令又将REPLACE_ME_*字符串替换为我们之前创建的 EBS 卷的 ID。结果被传递给kubectl create命令,后者创建了持久卷。从输出结果可以看到,所有三个 PersistentVolume 都已创建。

让我们来看一下当前在集群中可用的持久卷。

kubectl get pv  

输出如下:

NAME          CAPACITY ACCESS MODES RECLAIM POLICY STATUS    CLAIM STORAGECLASS REASON AGE
manual-ebs-01 5Gi      RWO          Retain         Available       manual-ebs          11s
manual-ebs-02 5Gi      RWO          Retain         Available       manual-ebs          11s
manual-ebs-03 5Gi      RWO          Retain         Available       manual-ebs          11s

不足为奇的是,我们有三个卷:

我们看到的有趣部分是状态信息。持久卷是可用的。我们已经创建了它们,但没有人使用它们。它们只是静静地等待某人来声明它们。

图 15-2:与 EBS 卷关联的 Kubernetes 持久卷

声明持久卷

如果没有人使用它们,Kubernetes 持久卷是没有用的。它们仅作为与特定 EBS 卷相关的对象存在。它们在等待通过PersistentVolumeClaim资源来声明它们。

就像可以请求特定资源(如内存和 CPU)的 Pod 一样,PersistentVolumeClaims也可以请求特定的大小和访问模式。尽管它们的类型不同,但两者从某种程度上来说都在消耗资源。就像 Pod 不应指定在哪个节点上运行一样,PersistentVolumeClaims也不能定义应挂载哪个卷。相反,Kubernetes 调度器将根据请求的资源为它们分配一个卷。

我们将使用pv/pvc.yml来探讨如何声明一个持久卷:

cat pv/pvc.yml 

输出如下:

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
 name: jenkins
 namespace: jenkins
spec:
 storageClassName: manual-ebs
 accessModes:
 - ReadWriteOnce
 resources:
 requests:
 storage: 1Gi  

YAML 文件定义了一个存储类名称为 manual-ebsPersistentVolumeClaim。这与我们之前创建的持久卷 manual-ebs-* 使用的存储类相同。访问模式和存储请求也与我们为持久卷定义的内容匹配。

请注意,我们没有指定要使用哪个卷。相反,这个声明指定了一组属性(storageClassNameaccessModesstorage)。系统中任何符合这些规格的卷都可能被名为jenkinsPersistentVolumeClaim声明。请记住,resources不必完全匹配。任何具有相同或更大存储量的卷都被视为匹配。对于1Gi的请求可以转化为至少 1Gi。在我们的案例中,1Gi的请求匹配所有三个持久卷,因为它们的存储量被设置为5Gi

现在我们已经探讨了声明的定义,我们可以继续并创建它:

kubectl create -f pv/pvc.yml \
 --save-config --record  

输出显示 persistentvolumeclaim "jenkins" 已创建

让我们列出这些声明,看看我们得到了什么:

kubectl --namespace jenkins \
 get pvc  

输出如下:

NAME    STATUS VOLUME        CAPACITY ACCESS MODES STORAGECLASS AGE
jenkins Bound  manual-ebs-02 5Gi      RWO          manual-ebs   17s  

我们从输出中看到,声明的状态是Bound。这意味着声明找到了匹配的持久卷并将其绑定。我们可以通过列出卷来确认这一点:

kubectl get pv  

输出如下:

NAME          CAPACITY ACCESS MODES RECLAIM POLICY STATUS    CLAIM           STORAGECLASS REASON AGE
manual-ebs-01 5Gi      RWO          Retain         Available                 manual-ebs          7m
manual-ebs-02 5Gi      RWO          Retain         Bound     jenkins/jenkins manual-ebs          7m
manual-ebs-03 5Gi      RWO          Retain         Available                 manual-ebs          7m

我们可以看到,其中一个卷(manual-ebs-02)将状态从Available更改为Bound。这就是我们刚刚创建的索赔所绑定的卷。我们可以看到,该索赔来自jenkins命名空间和jenkins``PersistentVolumeClaim

图 15-3:持久卷声明的创建

请注意,如果 PersistentVolumeClaim 无法找到匹配的卷,它将会永远处于未绑定状态,除非我们添加一个符合规格的新持久卷。

我们仍然没有达到我们的目标。声明一个卷并不意味着任何人都会使用它。另一方面,我们的 Jenkins 需要持久化它的状态。我们将把PersistentVolumeClaim与 Jenkins 容器关联起来。

将已声明的卷附加到 Pods

cat pv/jenkins-pv.yml  

输出的相关部分如下:

...
apiVersion: apps/v1beta2
kind: Deployment
metadata:
 name: jenkins
 namespace: jenkins
spec:
 ...
 template:
 ...
 spec:
 containers:
 - name: jenkins
 ...
 volumeMounts:
 - name: jenkins-home
 mountPath: /var/jenkins_home
 ...
 volumes:
 - name: jenkins-home
 persistentVolumeClaim:
 claimName: jenkins
 ...  

你会注意到,这次我们添加了一个新的卷 jenkins-home,它引用了名为 jenkinsPersistentVolumeClaim。从容器的角度来看,声明就是一个卷。

让我们部署 Jenkins 资源,并确认一切按预期工作。

kubectl apply \
 -f pv/jenkins-pv.yml \
 --record  

输出如下:

namespace "jenkins" configured
ingress "jenkins" configured
service "jenkins" configured
deployment "jenkins" configured  

我们将等到部署完成后,再进行一个测试,确认 Jenkins 的状态现在已经被持久化。

kubectl --namespace jenkins \
 rollout status \
 deployment jenkins  

一旦部署完成,我们将看到一条消息,表示 deployment "jenkins" 已成功滚动部署。

我们发送了一个请求到 Kubernetes API 来创建一个 Deployment。结果,我们得到了一个ReplicaSet,它进一步创建了jenkins Pod。它挂载了PersistentVolumeClaim,该声明绑定到PersistenceVolume,后者与 EBS 卷相关联。结果,EBS 卷被挂载到运行在 Pod 中的jenkins容器。

图 15-4 的简化版本显示了事件序列。

图 15-4:事件序列始于请求创建一个具有 PersistentVolumeClaim 的 Jenkins Pod

  1. 我们执行了kubectl命令

  2. kubectlkube-apiserver发送了一个请求,以创建pv/jenkins-pv.yml中定义的资源

  3. jenkins Pod 是在一个工作节点上创建的

  4. 由于 Pod 中的jenkins容器具有PersistentVolumeClaim,它将其挂载为逻辑卷

  5. PersistentVolumeClaim 已经绑定到一个 PersistentVolume

  6. PersistentVolume 与一个 EBS 卷相关联

  7. EBS 卷作为物理卷被挂载到jenkins Pod

现在 Jenkins 已经启动运行,我们将执行与之前类似的一套步骤,并验证状态是否在故障时得到了保留。

open "http://$CLUSTER_DNS/jenkins"  

我们打开了 Jenkins 主屏幕。如果您没有通过身份验证,请点击“登录”链接,并输入jdoe作为用户名和**incognito*作为密码。点击登录按钮。

你会看到一个创建新作业的链接。点击它。将my-job输入为项目名称,选择Pipeline作为作业类型,然后点击“确定”按钮。一旦进入作业配置屏幕,我们只需点击“保存”按钮。一个空作业足以测试持久性。

现在我们需要找出通过jenkins Deployment 创建的 Pod 的名称。

POD_NAME=$(kubectl \
 --namespace jenkins \
 get pod \
 --selector=app=jenkins \
 -o jsonpath="{.items[*].metadata.name}")

通过环境变量POD_NAME存储的 Pod 名称,我们可以继续并终止运行 Jenkins 的java进程。

kubectl --namespace jenkins \
 exec -it $POD_NAME pkill java  

我们杀死了 Jenkins 进程,从而模拟容器的故障。结果,Kubernetes 检测到故障并重新创建了容器。

一分钟后,我们可以再次打开 Jenkins 主屏幕,并检查状态(我们创建的作业)是否被保留。

open "http://$CLUSTER_DNS/jenkins"  

如您所见,作业仍然可用,从而证明我们成功地将 EBS 卷挂载为 Jenkins 保存其状态的目录。

如果我们不销毁容器,而是终止运行 Pod 的服务器,从功能角度来看,结果是一样的。Pod 将重新调度到一个健康的节点。Jenkins 会重新启动,并从 EBS 卷恢复其状态。或者,至少我们希望是这样。然而,在我们的集群中,不能保证会发生这样的行为。

我们只有两个工作节点,分布在两个(从三个中选择)可用区。如果托管 Jenkins 的节点发生故障,我们将只剩下一个节点。更准确地说,直到自动扩展组检测到缺少 EC2 实例并重新创建它,我们只会有一个工作节点在集群中运行。在这几分钟内,我们剩下的唯一节点并不在同一区域。如前所述,每个 EBS 实例都绑定到一个区域,而我们挂载到 Jenkins Pod 的 EBS 实例不会与另一个 EC2 实例所在的区域关联。因此,PersistentVolume 无法重新绑定 EBS 卷,导致故障的容器无法重建,直到故障的 EC2 实例被重建。

新的 EC2 实例可能不会和故障服务器所在的区域处于同一区域。因为我们使用了三个可用区,其中一个已包含 EC2 实例,AWS 会在其他两个可用区中的一个重建故障服务器。我们有 50% 的机会,新的 EC2 实例会和故障服务器所在的区域在同一可用区。这并不是一个理想的概率。

在实际的场景中,我们可能会有超过两个工作节点。即使只是稍微增加到三个节点,也会大大增加故障服务器在同一区域重建的机会。自动扩展组会尽可能地在所有可用区之间均衡分配 EC2 实例,但这并不能保证一定会发生。一个合理的最小工作节点数是六个。

我们的服务器越多,集群具有容错能力的机会就越大。特别是当我们托管有状态应用时,这一点尤为重要。事实上,我们几乎肯定有这些应用。几乎没有系统是没有某种形式的状态的。

如果更多的服务器更好,那么如果我们的系统较小并且需要,比如说,不到六台服务器时,我们可能会处于一个复杂的情况。在这种情况下,我建议使用更小的虚拟机。例如,如果你打算使用三台 t2.xlarge 的 EC2 实例作为工作节点,你可以重新考虑并改用六台 t2.large 的服务器。当然,更多的节点意味着在操作系统、Kubernetes 系统 Pod 和一些其他方面会有更多的资源开销。然而,我相信这种开销会通过集群的更高稳定性得到补偿。

还有一种情况可能会遇到。整个可用区(数据中心)可能会出现故障。Kubernetes 会继续正常运行。它将只有两个而不是三个主节点,故障的工作节点将在健康的区域重新创建。然而,我们的有状态服务将遇到问题。Kubernetes 无法重新调度那些挂载到故障区 EBS 卷的服务。我们需要等待可用区恢复在线,或者需要手动将 EBS 卷移到健康区。很有可能在这种情况下,EBS 将不可用,因此无法迁移。

我们可以创建一个过程,在多个可用区之间实时(接近)复制 EBS 卷中的数据,但这也有一个缺点。这样的操作会非常昂贵,而且在一切正常运作时,可能会减慢状态的恢复。我们是否应该选择较低的性能而换取更高的可用性?增加的运营开销是否值得?这些问题的答案因具体场景而异。

还有另一种选择。我们可以使用EFS (aws.amazon.com/efs/)替代 EBS。但是,这也会影响性能,因为 EFS 通常比 EBS 慢。除此之外,Kubernetes 目前并不支持生产就绪的 EFS。在写这篇文章时,EFS provisioner (github.com/kubernetes-incubator/external-storage/tree/master/aws/efs)仍处于测试阶段。到你读这篇文章时,情况可能已经有所变化,或者可能没有变化。即使efs provisioner稳定下来,它仍然是比 EBS 更慢且更贵的解决方案。

也许你会决定放弃 EBS(和 EFS),转而选择其他类型的持久存储。你可以选择许多不同的选项。我们不会深入探讨这些选项,因为对所有流行方案的深入比较需要更多的篇幅,而我们剩余的篇幅有限。可以将它们视为一个高级话题,下一本书会讲解。或者也许不会。我还不清楚The DevOps 2.4 Toolkit一书的具体范围。

总的来说,每种解决方案都有优缺点,且没有一种能适用于所有场景。无论好坏,我们将继续使用 EBS,直到本书结束。

回到与 EBS 绑定的 PersistentVolumes…

现在,我们已经探索了如何管理静态持久卷,我们将尝试使用动态方法实现相同的结果。但在此之前,我们先来看看当我们创建的一些资源被删除时会发生什么。

让我们删除jenkins部署。

kubectl --namespace jenkins delete \
 deploy jenkins  

输出显示我们deployment "jenkins"已被删除

PersistentVolumeClaim 和 PersistentVolume 发生了什么事情吗?

kubectl --namespace jenkins get pvc

kubectl get pv  

两个命令的合并输出如下:

NAME    STATUS VOLUME        CAPACITY ACCESS MODES STORAGECLASS   AGE
jenkins Bound  manual-ebs-02 5Gi      RWO          manual-ebs     57s

NAME          CAPACITY ACCESS MODES RECLAIM POLICY STATUS    CLAIM           STORAGECLASS REASON AGE
manual-ebs-01 5Gi      RWO          Retain         Available jenkins/jenkins manual-ebs          10m
manual-ebs-02 5Gi      RWO          Retain         Bound     jenkins/jenkins manual-ebs          10m
manual-ebs-03 5Gi      RWO          Retain         Available jenkins/jenkins manual-ebs          10m

即使我们删除了 Jenkins 部署,并且删除了使用该声明的 Pod,PersistentVolumeClaim 和 PersistentVolumes 仍然保持不变。manual-ebs-01卷仍然绑定到jenkins声明。

如果我们删除 PersistentVolumeClaim jenkins,会发生什么?

kubectl --namespace jenkins \
 delete pvc jenkins  

输出显示persistentvolumeclaim "jenkins"已被删除

现在,让我们来看一下 PersistentVolumes 的情况:

kubectl get pv  

输出如下:

NAME          CAPACITY ACCESS MODES RECLAIM POLICY STATUS   CLAIM          STORAGECLASS REASON AGE
manual-ebs-01 5Gi      RWO          Retain         Available jenkins/jenkins manual-ebs          10m
manual-ebs-02 5Gi      RWO          Retain         Released  jenkins/jenkins manual-ebs          10m
manual-ebs-03 5Gi      RWO          Retain         Available jenkins/jenkins manual-ebs          10m

这一次,manual-ebs-2卷已被Released

这是一个很好的时机,来解释我们创建的 PersistentVolumes 所应用的Retain策略。

ReclaimPolicy定义了一个卷在从其声明中释放后应该如何处理。该策略在我们删除绑定到manual-ebs-02的 PersistentVolumeClaim 时就已经应用。当我们创建 PersistentVolumes 时,我们没有指定ReclaimPolicy,所以这些卷被分配了默认的Retain策略。

Retain回收策略强制手动回收资源。当 PersistentVolumeClaim 被删除时,PersistentVolume 仍然存在,并且该卷被认为是released(已释放)。但它尚未对其他声明可用,因为先前声明者的数据仍然保留在该卷上。在我们的例子中,这些数据就是 Jenkins 的状态。如果我们希望这个 PersistentVolume 变得可用,我们需要删除 EBS 卷上的所有数据。

由于我们在 AWS 中运行集群,删除资源比回收资源更容易,因此我们将删除已释放的 PersistentVolume,而不是尝试清理我们在 EBS 中生成的所有内容。实际上,我们将删除所有的卷,因为我们即将探索如何动态地实现相同的效果。

另外两种回收策略是RecycleDeleteRecycle被认为已经弃用,所以我们不会浪费时间来解释它。Delete策略需要动态配置,但我们会推迟解释,直到我们探讨该主题。

让我们删除一些内容:

kubectl delete -f pv/pv.yml 

输出如下:

persistentvolume "manual-ebs-01" deleted
persistentvolume "manual-ebs-02" deleted
persistentvolume "manual-ebs-03" deleted 

我们可以看到所有三个 PersistentVolumes 都被删除了。然而,只有 Kubernetes 资源被移除。我们仍然需要手动删除 EBS 卷。

如果你进入 AWS 控制台,你会看到所有三个 EBS 卷现在处于available状态,等待挂载。我们将删除它们:

aws ec2 delete-volume \
 --volume-id $VOLUME_ID_1

aws ec2 delete-volume \
 --volume-id $VOLUME_ID_2

aws ec2 delete-volume \
 --volume-id $VOLUME_ID_3

我们已经完成了对手动创建持久卷的巡回讲解。如果我们采用这种卷管理方式,集群管理员需要确保始终有额外的可用卷供新的声明使用。这是一个繁琐的工作,往往会导致拥有比实际需要更多的卷。另一方面,如果我们没有足够的可用(未使用)卷,我们就有可能面临某个声明无法找到合适的卷进行挂载的风险。

手动卷管理有时是不可避免的,特别是当你选择使用本地基础设施结合 NFS 时。然而,这不是我们的情况。AWS 的核心就是动态资源配置,我们将充分利用这一点。

使用存储类动态配置持久卷

到目前为止,我们使用了静态的 PersistentVolumes。我们必须手动创建 EBS 卷和 Kubernetes PersistentVolumes。只有当两者都可用时,我们才能部署挂载这些卷的 Pod,通过 PersistentVolumeClaims 进行挂载。我们将这个过程称为静态卷配置。

在某些情况下,静态卷配置是必要的。我们的基础设施可能无法创建动态卷。这通常出现在基于 NFS 的本地基础设施中。即便如此,通过使用一些工具、改变流程以及选择合适的支持卷类型,我们通常可以达到动态配置卷的效果。不过,这在遗留流程和基础设施中可能仍然是一个挑战。

由于我们的集群在 AWS 中,我们不能将手动配置卷归咎于遗留的基础设施。事实上,我们本可以直接进入这一部分。毕竟,AWS 就是为了动态基础设施管理而存在的。然而,我认为通过首先探索手动配置,会更容易理解这些过程。到目前为止我们获得的知识将帮助我们更好地理解接下来要做的事情。开始手动配置的第二个原因是我无法预测你的计划。也许你会在必须是静态的基础设施上运行 Kubernetes 集群。即使我们在示例中使用的是 AWS,但到目前为止你所学到的所有内容也可以在静态基础设施上实现。你只需要将 EBS 替换为 NFS,并参考 NFSVolumeSource(v1-9.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.9/#nfsvolumesource-v1-core)文档。NFS 特定的字段只有三个,所以你应该能很快上手。

在我们讨论如何启用动态持久卷配置之前,我们应该理解,只有当没有静态的 PersistentVolumes 匹配我们的声明时,才会使用动态配置。换句话说,Kubernetes 总是优先选择静态创建的 PersistentVolumes,而不是动态的。

动态卷配置允许我们按需创建存储。我们可以在资源请求时自动配置存储,而不是手动预配置存储。

我们可以通过使用来自 storage.k8s.io API 组的 StorageClasses 来启用动态供给。它们允许我们描述可以声明的存储类型。一方面,集群管理员可以根据存储类型创建任意数量的 StorageClasses。另一方面,集群的用户无需担心每个外部存储的细节。这是一个双赢的局面,管理员不需要预先创建 PersistentVolumes,而用户只需声明他们需要的存储类型。

为了启用动态供给,我们需要创建至少一个 StorageClass 对象。幸运的是,kops 已经设置了一些,我们不妨看看目前集群中可用的 StorageClasses:

kubectl get sc  

输出如下:

NAME          PROVISIONER           AGE
default       kubernetes.io/aws-ebs 44m
gp2 (default) kubernetes.io/aws-ebs 44m  

我们可以看到,集群中有两个 StorageClasses。两者都使用相同的 aws-ebs 供应者。除了名称外,唯一的区别,至少在这个输出中,是其中一个被标记为 default。稍后我们将探讨这意味着什么。现在,我们相信 kops 正确配置了这些类,并尝试声明一个 PersistentVolume。

让我们快速看看另一个 jenkins 定义:

cat pv/jenkins-dynamic.yml  

输出,仅限于相关部分,如下所示:

...
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
 name: jenkins
 namespace: jenkins
spec:
 storageClassName: gp2
 accessModes:
 - ReadWriteOnce
 resources:
 requests:
 storage: 1Gi
...  

这个 Jenkins 定义与我们之前使用的几乎相同。唯一的区别在于 PersistentVolumeClaim,这次指定了 gp2 作为 StorageClassName。不过还有一个区别。这一次我们没有预先 provision 的 PersistentVolume。如果一切按预期工作,将会动态创建一个新的 PersistentVolume。

kubectl apply \
 -f pv/jenkins-dynamic.yml \
 --record

我们可以看到一些资源被重新配置了,而另一些则被创建了。

接下来,我们将等待直到 jenkins 部署成功滚动完成:

kubectl --namespace jenkins \
 rollout status \
 deployment jenkins

现在,我们应该能够通过 jenkins 命名空间的事件来查看发生了什么。

kubectl --namespace jenkins \
 get events

输出,仅限于最后几行,如下所示:

...
20s 20s 1 jenkins.... Deployment            Normal ScalingReplicaSet     deployment-controller       Scaled up replica set jenkins-... to 1
20s 20s 1 jenkins.... PersistentVolumeClaim Normal ProvisioningSucceeded persistentvolume-controller Successfully provisioned volume pvc-... using kubernetes.io/aws-ebs

我们可以看到,一个新的 PersistentVolume 已经 成功 provision

让我们查看 PersistentVolumeClaim 的状态。

kubectl --namespace jenkins get pvc  

输出如下:

NAME    STATUS VOLUME  CAPACITY ACCESS MODES STORAGECLASS AGE
jenkins Bound  pvc-... 1Gi      RWO          gp2          1m

输出中重要的部分是状态。我们可以看到它已与 PersistentVolume 绑定,从而再次确认该卷确实是动态创建的。

为了安全起见,我们也会列出 PersistentVolumes:

kubectl get pv  

输出如下:

NAME    CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM                STORAGECLASS REASON AGE
pvc-... 1Gi      RWO          Delete         Bound  jenkins/jenkins gp2                      4m

正如预期的那样,PersistentVolume 已经创建,它与 PersistentVolumeClaim 绑定,并且其回收策略为 Delete。我们很快就能看到该策略的实际效果。

最后,我们进行的最后一次验证是确认 EBS 卷是否也已创建:

aws ec2 describe-volumes \
 --filters 'Name=tag-key,Values="kubernetes.io/created-for/pvc
 /name"'

输出,仅限于相关部分,如下所示:

{
 "Volumes": [
 {
 "AvailabilityZone": "us-east-2c",
 ...
 "VolumeType": "gp2",
 "VolumeId": "vol-0a4d5cfa4699e5c6f",
 "State": "in-use",
 ...
 }
 ]
}  

我们可以看到,一个新的 EBS 卷已在可用区 us-east-2c 中创建,类型为 gp2,状态为 in-use

动态供给工作正常!考虑到我们使用的是 AWS,这比使用静态资源要好得多。

在我们进入下一个主题之前,我们将探讨回收策略Delete的效果。为此,我们将删除 Deployment 和 PersistentVolumeClaim。

kubectl --namespace jenkins \
 delete deploy,pvc jenkins  

输出如下:

deployment "jenkins" deleted
persistentvolumeclaim "jenkins" deleted

现在请求的卷已被移除,我们可以检查动态供应的 PersistentVolumes 发生了什么。

kubectl get pv  

输出显示未找到资源,清楚地表明通过请求创建的 PersistentVolume 已经不存在了。

那么 AWS EBS 卷怎么样?它也被移除了吗?

aws ec2 describe-volumes \
 --filters 'Name=tag-key,Values="kubernetes.io/created-for/pvc/name"'

输出如下:

{
 "Volumes": []
}  

我们得到了一个空数组,证明 EBS 卷也已被移除。

通过动态卷供应,不仅在资源请求时创建卷,而且当请求释放时,这些卷也会被删除。动态删除通过回收策略Delete完成。

使用默认存储类

使用动态供应简化了一些事情。然而,用户仍然需要知道使用哪种卷类型。虽然在很多情况下这是一个重要的选择,但也常常会有用户不希望为此担忧的情况。可能更方便的是使用集群管理员选择的卷类型,并让所有没有指定storageClassName的请求得到默认卷。我们将尝试通过其中一个 Admission 控制器来实现这一点。

Admission 控制器正在拦截请求到 Kubernetes API 服务器的请求。我们不会深入讨论 Admission 控制器,因为 Kubernetes 支持的控制器列表相对较大。我们只对DefaultStorageClass感兴趣,而这个控制器恰好在我们使用 kops 创建的集群中已经启用。

DefaultStorageClass Admission 控制器观察 PersistentVolumeClaims 的创建。通过它,所有没有请求特定存储类的 PersistentVolumeClaims 会自动被添加上默认的存储类。因此,那些没有请求任何特殊存储类的 PersistentVolumeClaims 将绑定到由默认StorageClass创建的 PersistentVolumes。从用户的角度来看,不需要关心卷的类型,因为它们将根据默认类型进行配置,除非用户选择了特定的类。

让我们来看看当前在集群中可用的存储类:

kubectl get sc

输出如下:

NAME          PROVISIONER           AGE
default       kubernetes.io/aws-ebs 56m
gp2 (default) kubernetes.io/aws-ebs 56m  

这不是我们第一次列出集群中的存储类。然而,我们并没有讨论到两个存储类(gp2)中的一个被标记为默认StorageClass

让我们描述一下gp2类。

kubectl describe sc gp2 

限制在相关部分的输出如下:

Name:            gp2
IsDefaultClass:  Yes
Annotations:     kubectl.kubernetes.io/last-applied-configuration={"apiVersion":"storage.k8s.io/v1","kind":"StorageClass","metadata":{"annotations":{"storageclass.beta.kubernetes.io/is-default-class":"true"},"labels":{"k8s-addon":"storage-aws.addons.k8s.io"},"name":"gp2","namespace":""},"parameters":{"type":"gp2"},"provisioner":"kubernetes.io/aws-ebs"}
,storageclass.beta.kubernetes.io/is-default-class=true
Provisioner:    kubernetes.io/aws-ebs
Parameters:     type=gp2
ReclaimPolicy:  Delete
Events:         <none>  

重要部分在于注解。其之一是".../is-default-class":"true"。它将该StorageClass设置为默认。结果,任何未指定 StorageClass 名称的 PersistentVolumeClaim 都会使用该 StorageClass 来创建 PersistentVolumes。

让我们尝试调整 Jenkins 堆栈,利用动态供应与DefaultStorageClass相关联的卷的能力。

新的 Jenkins 定义如下:

cat pv/jenkins-default.yml

输出,仅限于 PersistentVolumeClaim,如下所示。

...
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
 name: jenkins
 namespace: jenkins
spec:
 accessModes:
 - ReadWriteOnce
 resources:
 requests:
 storage: 1Gi
...  

很难区分这个 YAML 文件和我们之前使用的文件之间的差异。它非常小,难以察觉,因此我们将执行 diff 来对比这两个文件:

diff pv/jenkins-dynamic.yml \
 pv/jenkins-default.yml

输出如下:

48d47
<   storageClassName: gp2  

如你所见,唯一的不同是 pv/jenkins-dynamic.yml 没有 storageClassName: gp2。这个字段在新的定义中被省略了。我们新的 PersistentVolumeClaim 没有关联的 StorageClass。

让我们 apply 新的定义:

kubectl apply \
 -f pv/jenkins-default.yml \
 --record  

输出如下:

namespace "jenkins" configured
ingress "jenkins" configured
service "jenkins" configured
persistentvolumeclaim "jenkins" created
deployment "jenkins" created  

我们关注的是 PersistentVolumes,所以让我们来获取它们。

kubectl get pv
NAME    CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM                STORAGECLASS REASON AGE
pvc-... 1Gi      RWO          Delete         Bound  jenkins/jenkins gp2                      16s

如你所见,尽管我们没有指定任何 StorageClass,但还是基于 gp2 类创建了一个卷,而 gp2 恰好是默认的存储类。

在我们探索如何创建自己的 StorageClasses 之前,我们将删除 jenkins Deployment 和 PersistentVolumeClaim。

kubectl --namespace jenkins \
 delete deploy,pvc jenkins

输出如下:

deployment "jenkins" deleted
persistentvolumeclaim "jenkins" deleted  

创建存储类

即使 kops 创建了两个 StorageClasses,它们都基于 gp2。虽然这是最常用的 EBS 类型,但我们可能希望基于 AWS 提供的其他三个选项之一来创建卷。

假设我们希望为 Jenkins 使用最快的 EBS 卷类型,那就是 io1。由于 kops 并没有创建这种类型的 StorageClass,我们可能需要自己创建一个。

创建基于 EBS io1 的 StorageClass 的 YAML 文件定义在 pv/sc.yml 中。我们快速看一下。

cat pv/sc.yml  

输出如下:

kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
 name: fast
 labels:
 type: ebs
provisioner: kubernetes.io/aws-ebs
parameters:
 type: io1
reclaimPolicy: Delete  

我们使用了 kubernetes.io/aws-ebs 作为 provisioner。这是一个必填字段,用来确定用于供应 PersistentVolumes 的插件。由于我们在 AWS 上运行集群,aws-ebs 是合适的选择。我们还可以选择其他一些 provisioners,其中一些是特定于某个托管提供商的(例如,GCEPersistentDiskAzureDisk),而其他一些则可以在任何地方使用(例如,GlusterFS)。

支持的 provisioners 列表在不断增长。在撰写本文时,支持的类型如下:

Volume Plugin Internal Provisioner
AWSElasticBlockStore yes
AzureFile yes
AzureDisk yes
CephFS no
Cinder yes
FC no
FlexVolume no
Flocker yes
GCEPersistentDisk yes
Glusterfs yes
iSCSI no
PhotonPersistentDisk yes
Quobyte yes
NFS no
RBD yes
VsphereVolume yes
PortworxVolume yes
ScaleIO yes
StorageOS yes
Local no

内部供应器是那些名称以kubernetes.io为前缀的(例如kubernetes.io/aws-ebs)。它们是与 Kubernetes 一起发布的。另一方面,外部供应器是与 Kubernetes 分开发布的独立程序。一个常用的外部供应器例子是NFS。参数依赖于 StorageClass。我们使用了aws-ebs供应器,它允许我们指定type参数,该参数定义了支持的 Amazon EBS 卷类型之一。它可以是 EBS Provisioned IOPS SSD(io1)、EBS 普通用途 SSDgp2)、吞吐优化 HDD(st1)和冷 HDD(sc1)。我们将其设置为io1,这是性能最高的 SSD 卷。更多信息请参考参数kubernetes.io/docs/concepts/storage/storage-classes/#parameters)部分的Storage Classes文档。最后,我们将reclaimPolicy设置为Delete。与Retain不同,Retain要求我们在释放的卷变得可供新 PersistentVolumeClaims 使用之前删除卷内容,而Delete会删除 PersistentVolume 及其在外部架构中的关联卷。Delete回收策略仅适用于某些外部卷,如 AWS EBS、Azure Disk 或 Cinder 卷。现在我们已经初步了解了 StorageClass 定义,可以继续创建它。

kubectl create -f pv/sc.yml

输出显示storageclass "fast" 已创建,因此我们将再次列出集群中的 StorageClasses。

kubectl get sc

输出如下:

NAME          PROVISIONER           AGE default       kubernetes.io/aws-ebs 58m fast          kubernetes.io/aws-ebs 19s gp2 (default) kubernetes.io/aws-ebs 58m

我们可以看到,这次我们使用了一个新的 StorageClass。

让我们再看一个 Jenkins 定义。

cat pv/jenkins-sc.yml

输出,仅限相关部分,如下:

... kind: PersistentVolumeClaim apiVersion: v1 metadata:
 name: jenkins namespace: jenkins spec:
 storageClassName: fast accessModes: - ReadWriteOnce resources: requests: storage: 4Gi ...

与之前的定义相比,唯一的区别是我们现在使用了新创建的名为fast的 StorageClass。

最后,我们将通过部署新的jenkins定义来确认新的 StorageClass 是否正常工作。

kubectl apply \
 -f pv/jenkins-sc.yml \ --record

输出如下:

namespace "jenkins" configured ingress "jenkins" configured service "jenkins" configured persistentvolumeclaim "jenkins" created deployment "jenkins" created

作为最终验证,我们将列出 EBS 卷并确认是否基于新的类创建了一个新卷。

aws ec2 describe-volumes \ 
    --filters 'Name=tag-key,Values="kubernetes.io/created-for/pvc/name"' 

输出,仅限相关部分,如下:

{
 "Volumes": [ { ... "VolumeType": "io1",
 "VolumeId": "vol-0e0af4f2a7a54354d", "State": "in-use", ...    }
 ]
}

我们可以看到,新创建的 EBS 卷类型是io1,并且它是in-use状态。

图 15-5:通过请求创建一个带有 PersistentVolumeClaim 并使用自定义 StorageClass 的 Jenkins Pod,启动的事件序列

通过简化版本的事件流,可以看到在创建jenkins Deployment 时,事件的流转过程如下:

  1. 我们创建了jenkins Deployment,随后创建了一个 ReplicaSet,ReplicaSet 又创建了一个 Pod。

  2. Pod 通过 PersistentVolumeClaim 请求持久存储。

  3. PersistentVolumeClaim 请求了带有 StorageClass 名称fast的 PersistentStorage。

  4. StorageClass fast 被定义为创建一个新的 EBS 卷,因此它向 AWS API 请求了一个卷。

  5. AWS API 创建了一个新的 EBS 卷。

  6. EBS 卷已挂载到 jenkins Pod。

我们已经完成了持久卷的探索。你应该已经掌握了如何持久化你的有状态应用程序,唯一待做的就是删除这些卷和集群。

那现在该怎么办?

现在没什么可做的了,除了销毁我们迄今为止所做的操作。

这次,我们不能直接删除集群。这样做会导致 EBS 卷继续运行。因此,我们需要先删除这些卷。

我们可以通过 AWS CLI 删除 EBS 卷。然而,还有一种更简便的方法。如果我们删除所有对 EBS 卷的声明,它们也会被删除,因为我们的 PersistentVolumes 是按照回收策略 Delete 创建的。EBS 卷在需要时创建,不需要时销毁。

由于所有的声明都在 jenkins 命名空间中,删除它是删除所有资源的最简便方法。

kubectl delete ns jenkins

输出显示 namespace "jenkins" 被删除,我们可以继续删除集群。

kops delete cluster \ --name $NAME \ --yes

从输出中我们可以看到集群 devops23.k8s.local 已被删除,剩下的只有用于 kops 状态的 S3 存储桶,我们也将删除它。

aws s3api delete-bucket \ --bucket $BUCKET_NAME

在离开之前,请参考以下 API 文档以了解更多关于卷相关资源的信息。

就这样,没剩下什么了。

第十六章:结束

我们已经完成了这本书,但我们只刚刚开始接触 Kubernetes。它的范围如此广泛,单一本书甚至无法涵盖核心组件。如果我们将范围扩展到包括云原生计算基金会CNCF)(www.cncf.io/)项目中的所有成员以及可以添加的第三方解决方案,我们将需要一系列专门针对 Kubernetes 的书籍。更复杂的是,Kubernetes 生态系统发展得如此迅速,几乎不可能跟得上其步伐。即使我们今天能够掌握所有知识,过一周后我们也会掉队。

我们可以使用几乎无限的组合来创建和使用 Kubernetes 集群。我们可以选择仅使用核心组件,或者将它们与第三方工具结合。我们可以选择仅使用“原生 Kubernetes”,或者采用像 OpenShift (www.openshift.com/)、Docker 企业版 (www.docker.com/kubernetes#/EE) 或 Rancher (rancher.com/) 这样的平台。还有很多其他我们可能想要探索的平台,而且随着时间的推移,选择的名单还在不断增长。

一些 Kubernetes 平台具有高度的偏向性,而其他平台则迫使我们自己选择组成集群的组件。我们很容易在评估哪些组件最适合我们的用例时耗费大量时间。以网络为例,我知道至少有二十种解决方案,而且我非常确定这个数字可以增加十倍。仅仅这一单一选择就足以让你陷入困境。如果再加上持久存储、Ingress 和身份验证,我们需要评估的内容已经超出了一个人能承受的范围。

如果我们将托管供应商纳入评估范围,就会面临更多的选项和新的限制。几乎所有人现在都意识到 Kubernetes 将成为标准,并且它对他们的产品至关重要,因此必须采用 Kubernetes。他们都在为自己的基础设施添加组件,以使 Kubernetes 能够无缝运行。它们试图通过宣称 Kubernetes 在他们的环境中运行得比其他地方更好来吸引你。这也意味着,你将面临额外的新组件。

Kubernetes 被设计成可扩展的。几乎每个软件和托管供应商都在其上添加了自己的组件。这种扩展性可能是 Kubernetes 获得如此广泛采用的主要原因,也是它能迅速发展的原因,但也正是因此它如此复杂,有时甚至不太直观。它庞大无比,且会继续发展。随着它的增长,复杂性也随之增加,可能让人感到不知所措。

不必绝望。你学到的原则无论你如何设置 Kubernetes 集群,或者选择使用生态系统的哪些部分,都是有效的。如果你没有只是草草浏览这本书,你应该已经掌握了核心的基础组件。你完成了最难的部分。你学会了一些最常用的资源,理解了它们背后的逻辑,知道了如何将它们连接起来。从现在开始,你只需要继续探索并扩展你的 Kubernetes 知识。当然前提是你选择了 Kubernetes 作为你的平台。

对许多人来说,到目前为止,你们所学的几乎就是他们所需要的全部。如果情况不是这样,The DevOps 2.4 Toolkit的工作已经开始,我希望你们能继续加入我,共同完成这段旅程。

我还不清楚下一本书的具体范围。但我知道,为了保持书籍在四百页的自设限制内,有很多与 Kubernetes 相关的内容我不得不舍弃。好消息是,下一本书也将与 Kubernetes 有关。也许它会被命名为《The DevOps 2.3 Toolkit 中你未曾发现的东西》,或者《The DevOps 2.4 Toolkit – 高级 Kubernetes》。这一切很快就会变得清晰。也许,到你阅读这段文字的时候,它已经出版了。

贡献者

我在 LeanPub 上提前发布了这本书(leanpub.com/the-devops-2-3-toolkit)。当它公开时,书的内容已经写了大约 10%。这让你们中的许多人能够提前访问这本书的内容,并且给了我一个获得反馈的机会。结果非常好。许多人给我发送了笔记,报告了错误,提出了改进建议,推荐了应该探索的工具和流程,等等。

很容易会有人想将整个书的功劳归于自己,但那样是不真实的。这本书是作者(我)和许多读者(你们)团队合作的结果。它证明了精益出版法的有效性,并且我们可以在写书时应用敏捷原则。没有固定的范围,决策也不是事先做出的。我会写一个章节,写完就交付(冲刺)。你们会审阅,并发送你们的笔记和评论,让我可以改进它(冲刺回顾)。我们每天都会通过电子邮件和 Slack 消息交流(每日站会)。我们进行短期迭代,从错误中学习并加以改进。

亲爱的读者们,是你们让这本书变得伟大!

一些人从人群中脱颖而出。

Neeraj Kothari通过质疑我的写作,提供建议,并给我发送评论来提供帮助。他认为我的序列和图表过于基础,甚至可以说是错误的。他非常坚持,最后我将这些工作委派给了他。你在书中看到的大部分图表都是他的,以及我们执行kubectl命令时发生的事件的解释。

我很想写下他的传记,但他似乎忽略了我要求写下他是谁以及他做了什么的请求。也许他以后会写下来,时间会告诉我们。

Prageeth Warnak 不断提交修正和建议的拉取请求。他使这本书比我依赖我常常不正确的读者期望假设更加清晰。

Prageeth 是一位经验丰富的 IT 专业人士,目前担任澳大利亚电信巨头 Telstra 的首席软件架构师。他喜欢与新技术合作,并喜欢在闲暇时阅读书籍(尤其是 Viktor 的著作)、观看 Netflix 和 Fox 新闻。他与家人一起居住在墨尔本。他着迷于正确实施微服务和 DevOps。

David Jacob 尽力纠正了我的“蹩脚”英语。没有他,你可能很难理解我想说的话。

David 是一位后端 Java 开发者,在过去两年里转变成了系统管理员。他正在专注于提升在 Linux、网络和 DevOps 实践中的熟练程度,并希望将来能有更多时间编程。他居住在柏林,没有养猫。

Don Becker 帮助解决使用 Hyper-V 调试 Minikube 时的 Windows 问题。

Don 是科技行业的老将,在几乎所有 IT 和软件开发领域担任过多个职位。他与妻子、三个孩子和三只猫一起居住在亚利桑那州的凤凰城。他目前的重点是从虚拟机向容器化、微服务、OpenFAAS 和当然是 Kubernetes 的转变。

Vadim Gusev 从初学者的角度帮助校对和讨论书籍结构。

Vadim 是一位年轻的 IT 专家,最初从事网络工程师工作,但对云和容器的概念非常着迷,因此决定转向 DevOps 的职业道路。他在一家小型创业公司工作,并引领其走向充满希望的容器化未来,主要依靠 Viktor 的著作指导。他闲暇时喜欢健身、打鼓和故意拖延。

posted @ 2025-06-29 10:38  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报