Kubernetes-GCP-入门指南-全-

Kubernetes GCP 入门指南(全)

原文:Beginning Kubernetes on the Google Cloud Platform

协议:CC BY-NC-SA 4.0

一、简介

2016 年,我的最终客户是一家全球物流公司,该公司正在升级一个使用了 15 年的整体清关系统,该系统基于应用服务器(WebLogic)、关系 SQL 数据库(Oracle)和消息队列(TIBCO)。每一层中的每个组件都是一个瓶颈。四节点 WebLogic 集群无法处理负载,最慢的 SQL 查询需要几秒钟才能处理完毕,将消息发布到 TIBCO EMS 队列类似于将羽毛扔进 100 米深的井中。这是一个任务关键型系统,使货物能够通过欧盟各地的清关中心进行转运。这个系统的崩溃(或显著减速)将意味着几个国家和办公室的空架子,等待隔夜文件,逐渐停止。

我被指派领导一个团队负责系统的重新架构。该团队由每个技术层的高度合格的主题专家组成。我们在有助于创建现代的、高度可伸缩的、高度可用的应用的大多数工具方面都有专业知识:用于 NoSQL 数据库的 Cassandra、用于消息传递的 Kafka、用于缓存的 Hazelcast 等等。但是 Java monolith 呢?我们已经将应用逻辑分解到合适的有界上下文中,并且有了一个可靠的微服务架构蓝图,但是无法获得一个容器——更不用说容器编排—专家来帮助我们进行设计。

我不愿意在我已经很忙的日程上增加更多的活动,但我非常渴望看到这个项目完成,我对自己说:“管它呢,我会自己处理的。”第一周,在我绞尽脑汁地思考了这个主题的文献之后,我认为我犯了一个大错误,我应该说服自己离开这个专业领域。市场上有大量自称为“容器编排者”的公司:Docker Swarm、Apache Mesos、Pivotal Cloud Foundry——客户的最爱——当然还有 Kubernetes。每一个都有完全不同的管理容器的概念、工具和工作流程。比如在 Cloud Foundry,纯 Docker 容器是二等公民;开发人员应该将代码直接“推入”平台,并通过一种称为“构建包”的机制,让代码在后台与其运行时相匹配。我对 Pivotal Cloud Foundry 的方法持怀疑态度,这种方法感觉就像 WebLogic 的翻版,在服务器端缺少一个依赖就会导致部署失败。

我试图用建筑师的眼光来处理这个问题,通过理解概念,查看图表,并找出组件如何一起工作,但我就是“不明白”。当遇到诸如“零停机迁移确切地说是是如何工作的?”这样的难题时我只能用模糊的词语来表达。因此,我决定,如果我想回答这些复杂的问题,并深入了解容器编排器,我必须卷起袖子。

在我动手之前,我必须选一个管弦乐队。我根本没有时间去修补每一个。Docker Swarm 在 2016 年感到过于简单,因为企业系统中还需要协调负载均衡器和存储系统之类的东西。Mesos 感觉像是一个通用的解决方案,其中 Docker 是在其开发的后期阶段被硬塞进去的。这给我留下了 Kubernetes,它的文献记录很少,只有它的谷歌遗产作为其看似独特的销售主张。尽管当时还处于起步阶段,但 Kubernetes 似乎关心的是实现高可扩展性和高可用性的一般战略,以及支持上述架构属性的网络和存储资源的复杂编排。Kubernetes 似乎试图解决的权利问题

然而,在当时,库伯内特文学的复杂性是压倒性的。大多数文本开始解释支撑 Kubernetes 集群的所有内部组件(Kubelet、Kube Proxy、etcd 等。)以及他们是如何合作的。解释 Kubernetes 是如何工作的很好,但演示它如何帮助解决世俗问题并不理想。相反,一些其他文本以 30,000 英尺的概述开始,迅速抛出复杂的术语,一次全部:吊舱、复制控制器、扩音器等。

正如我一生中多次做过的那样,我决定把我的发现组织成一个简单的结构,让我原始的猿猴大脑能够理解;所以我开始了我的研究冒险,打算在博客上记录我的见解。但是在这个过程当中发生了意想不到的事情。

当我的实验室实验进行了几周后,我意识到我不再是在研究所谓的“容器编排器”意识到容器——或者更准确地说是 Docker 映像的使用——从架构的角度来看是无关紧要的。Kubernetes 远不止这些。这是一个更基本问题的答案;如何在操作系统级别,而不是在应用级别实现通用的分布式计算。

当硬币落下时,我既兴奋又害怕。《这就是未来》像一张破唱片一样在我脑海里不停播放。Kubernetes 不再是一个“选择”;我处理的问题不是什么样的容器编制器风格最适合实现基于 clearance 微服务的应用。我站在一种技术的前面,这种技术可能会在几年后改变每个人进行通用分布式计算的方式。这就像我第一次在一台只能说 MS-DOS 和 Windows 的电脑上启动 Slackware Linux 的那一刻。

看,在 20 世纪 80 年代,运行 MS-DOS 的 Intel PC 允许一个用户一次运行一个程序。当 GNU/Linux 出现时——承认当时存在其他类似 Unix 的操作系统,如 Xenix、SCO 和 MINIX——像我这样的普通用户第一次有能力在一台计算机上同时运行多个程序。Kubernetes 在进化的道路上更进一步,增加了在不同的计算机上同时运行多个程序的能力。

换句话说,除了硬件抽象之外,现代操作系统的“魔力”在于它有助于并行运行程序,并以水平方式管理它们,从每个程序占用多少 CPU 周期、哪个 CPU 和/或内核支持它们等等中抽象出来。当你启动一个将音频文件压缩成 MP3 的任务时,比方说,通过使用蹩脚的编码器并键入lame equinoxe.wav -o equinoxe.mp3,你不需要关心进程将被分配到哪个 CPU 或内核。想象一下,如果你有 1000 台电脑,而你不必担心哪台电脑会接手这项工作?您可以同时运行多少个 MP3(或视频编码)作业?这正是 Kubernetes 允许您以无缝的方式做到的,这种方式从根本上来说并不比普通 Linux 将用户与任务切换和 CPU/内核分配的不确定性隔离开来更难。

为了解决最初的困境,“Kubernetes 是一个容器编排者吗?”。是的,就 Linux 是可执行和可链接格式(ELF)文件的编排者而言。Kubernetes 不仅仅是运行在多个机器上的 Docker 主机——事实上,它还可以编排 Windows 容器和其他容器类型,如 rkt (Rocket)。此外,Kubernetes 如今已经是通用分布式计算(包括网络和存储资源)的事实上的平台,每个大型云和本地供应商对其无处不在的支持就证明了这一点。

为什么 Kubernetes 在谷歌云平台上

Kubernetes 最初由谷歌设计,并于 2014 年公布。谷歌于 2015 年 7 月 21 日首次发布了 Kubernetes 1.0,当时它将其捐赠给了云原生计算基金会(CNCF)。CNCF 最初成立的主要目的是使 Kubernetes 的供应商中立,由 Linux 基金会管理。Kubernetes 的设计受到了 Borg 的影响,Borg 是谷歌用来运行其数据中心的专有系统。

在其白金会员中,CNCF 包括每一个主要的云供应商(谷歌本身、亚马逊、微软、IBM、甲骨文和阿里巴巴)以及在内部部署领域更熟悉的公司,如 RedHat、英特尔、VMWare、三星和华为。Kubernetes 穿不同的皮肤——它是不同的品牌——取决于供应商。以 AWS 为例,它被称为“亚马逊弹性 Kubernetes 服务”(EKS);在 Azure 中,它被称为“Azure Kubernetes 服务”(AKS)。几乎每个主要的公共和私有云提供商都提供了 Kubernetes 的化身,但是我们选择了谷歌容器引擎(GKE)。为什么呢?

读者不应该妄下结论说“如果它是谷歌制造的,它在谷歌上运行得更好。”这种推理有两点是错误的。首先,Kubernetes 项目自 2015 年开源并移交给 CNCF 以来,除了来自个人爱好者的捐助外,还收到了来自各个赞助商的重大捐助。其次,Kubernetes 是否被认为“运行良好”取决于无数的背景因素:用户与最近的谷歌数据中心的距离、特定的存储和网络需求、特定云供应商的产品目录中可能提供或不提供的特殊云原生服务的选择,等等。

相反,作者选择谷歌云平台(GCP)是因为它的教学便利性,换句话说,它给那些在 Kubernetes 世界迈出第一步的读者带来的好处:

  • 为读者提供 300 美元(美元)或等值的当地货币,让他们体验真实世界的生产级云平台。

  • 一个集成的 Shell(Google Cloud Shell ),它将读者从设置复杂工作站配置的负担中解放出来。端到端的学习之旅只需要一个网络浏览器:Chromebook 或平板电脑就足够了。

  • 高度集成的网络(负载均衡器)和存储资源的可用性,这些资源在本地/本地环境(如 Minikube 或 RedHat OpenShift)中可能不存在或难以设置。

最后,GKE 是一款可供生产的世界级产品,被飞利浦等蓝筹股公司所采用。如果 GCP 的服务产品适合眼前的需求,读者可能会选择将他们公司的工作负载直接迁移到 GCP,而不是——必须——重新投资 AWS 或 Azure 的具体技能。

这本书是给谁的

这本书是为绝对初学者而写的,他们希望将 Kubernetes 带来的基础功能内在化,如动态扩展、零停机部署和外部化配置。初级 Linux 管理员、操作工程师、用任何语言开发基于单片 Linux 的应用的开发人员以及解决方案架构师——他们乐于卷起袖子——都是理想的候选人。

这本书是独立的,不需要云计算、虚拟化或编程等领域的高级知识。甚至不需要精通 Docker。由于 Kubernetes 实际上是一个 Docker 主机,读者可以在谷歌云平台上首次试验 Docker Hub 中的 Docker 和公共 Docker 映像,而无需在本地机器上安装 Docker 运行时。

读者只需要对 Linux 的命令行界面和使用简单 shell 脚本的能力有最低限度的了解,但不一定要写。第九章是一个例外,其中 Python 被广泛用于示例中——鉴于演示 StatefulSets 的动态性的挑战,这是必要的——然而,读者只需要理解如何运行提供的 Python 脚本,而不是它们确切的内部工作方式。

这本书有什么不同

这本书不同于其他看似相似的出版物,因为它优先考虑理解而不是覆盖广泛的大纲,并且它是以严格的自下而上的方式编写的,通常使用小代码示例。

由于这是一本注重教学的书,所以涵盖的主题较少。所选的主题是在循序渐进的基础上慢慢探索的,并使用例子来帮助读者观察、证明和内化【Kubernetes 如何帮助解决问题,而不是它的内部是什么。

自下而上的方法使读者从第一章开始就能提高工作效率,而不必“提前阅读”或等到书的末尾才设置基于 Kubernetes 的工作负载。这种方法也减少了读者在任何给定时间必须在脑海中闪现的概念的数量。

如何使用这本书

这本书有两种阅读方式:被动阅读和主动阅读。

被动方法包括远离电脑阅读书籍。20 世纪 70 年代和 80 年代的许多书都是以被动风格写成的,因为读者家里通常没有电脑。

鉴于作者是在这个时代阅读书籍长大的,本文将被动读者视为一等公民。这意味着所有相关的结果都呈现在书上;读者很少需要自己运行命令或脚本来理解它们的效果。同样,所有代码都在书中重现——除了用文字描述变化的小变化。使用这种方法的唯一问题是,读者可能会倾向于读得太快而忽略重要的细节。避免陷入这个陷阱的一个有效方法是使用荧光笔并在空白处写笔记,这将导致一种自然而有益的减速。

主动阅读模式是指读者在阅读时运行建议的命令和脚本。这种方法有助于以更快的方式内化示例,并且还允许读者尝试他们自己对所提供的代码的修改。如果遵循这种方法,作者建议使用 Google Cloud Shell,而不是与一个潜在的有问题的本地环境进行斗争。

最后,不管读者是选择被动阅读还是主动阅读,作者建议每次阅读“一次完成”一整章。一章中的每一节都介绍了一个重要的新概念,作者希望读者在阅读下一章时能记住这个新概念。表 1-1 以小时为单位提供了每章的预计阅读时间。

表 1-1

每章的预计阅读时间(小时)

|

|

消极的

|

活跃的

|
| --- | --- | --- |
| 第章 1 | 01 时 | 02 时 30 分 |
| 第章 2 | 02 时 | 04 时 30 分 |
| 第三章 | 01 时 30 分 | 03 时 30 分 |
| 第四章 | 01 时 30 分 | 03 时 30 分 |
| 第五章 | 01 时 | 02 时 30 分 |
| 第六章 | 01 时 | 02 时 30 分 |
| 第章第八章 | 01 时 | 02 时 30 分 |
| 第九章 | 02 时 | 04 时 30 分 |

约定

本书使用了以下印刷惯例:

  • 斜体引入一个新的术语或概念,值得读者关注。

  • 大写的名词如“服务”或“部署”指的是 Kubernetes 对象类型,而不是它们在英语中的常规含义。

  • 文本用于指代语法元素,如 YAML 或 JSON、命令或参数。后者还包括用户自定义的名称,如my-cluster

  • <IN-BRACKETS-FIXED-WIDTH>文本是指命令参数。

  • dot.separated.fixed-width-text用于指各种 Kubernetes 对象类型中的属性。可以通过运行kubectl explain <PROPERTY>命令获得这些属性的详细描述,例如kubectl explain pod.spec.containers。运行kubectl要求我们首先设置一个 Kubernetes 集群,我们将在下面的小节中介绍这个集群。在某些情况下,如果上下文暗示父属性,则可以跳过父属性。例如,有时可以使用spec.containers或简单的containers,而不是pod.spec.containers

此外,为了简洁起见,并使该文本更容易理解,命令的输出(kubectl在大多数情况下)可以修改如下:

  • 如果一个命令生成一个包含多列的表格显示,那么与当前讨论无关的列可能会被忽略。

  • 如果命令产生与上下文无关的警告(例如,它们警告用户新 API 中的特性),则这样的警告可能不会被显示。

  • 只要能改进格式,就可以添加或删除空白。

  • 长标识符可以通过用星号替换所述标识符内的样板词片段来缩短。例如,标识符gke-my-cluster-default-pool-4ff6f64a-6f4v代表一个 Kubernetes 节点。我们可以将这样的标识符显示为gke-*-4ff6f64a-6f4v,其中*代表my-cluster-default-pool

  • 每当日期部分使示例超出书的列长度和/或当包括整个日期时间字符串无助于手头的讨论时,可以省略日期部分。例如,像Wed Aug 28 09:17:27 DST 2019 – Started这样的日志行可以简单地显示为17:27 - Started

最后,源代码清单和命令可能会使用反斜杠\来表示多个参数,否则这些参数可能会使用一个长行来编写:

$ gcloud container clusters create my-cluster \
    --issue-client-certificate \
    --enable-basic-auth

读者可以随意忽略反斜线(从而忽略回车),如有必要,可以将由多行组成的示例写成一个长行。

设置 GCP 环境

截至本书付印时,谷歌为其平台上的新账户提供 300 美元的信用额度。300 美元足够多次尝试本书中的例子,甚至运行一些额外的宠物项目一段时间。创建帐户的步骤如下:

  1. 前往 https://cloud.google.com/

  2. 单击“免费试用”按钮。

  3. 按照提示和问题进行操作。

  4. 询问时输入地址和信用卡。

信用卡在信用用完之前不会被扣费,但是需要验证用户的身份。当信用额度用完时,它还可以用来支付谷歌服务。

一旦建立了帐户,下一步就是启用谷歌 Kubernetes 引擎(GKE) API,这样我们就可以从命令行与它进行交互。步骤如下:

  1. 前往 https://console.google.com

  2. 点击左上角的汉堡按钮菜单,显示主菜单。

  3. 选择位于“计算”标题下的“Kubernetes 引擎”,它会将浏览器重定向到 https://console.cloud.google.com/kubernetes

  4. 选择“集群”

  5. 寻找一条消息说“Kubernetes 引擎 API 正在被启用。”

请不要单击“创建集群”、“部署容器”或其他类似选项,因为我们将严格按照下一节中的说明从命令行工作。

使用谷歌云外壳

谷歌遵循代码优先的哲学。这是因为无论我们在 GCP 上执行什么操作——比如启动 Kubernetes 集群——我们都更喜欢编写脚本,以避免将来重复。因此,学习基于 GUI 的工作流,然后使用命令行重新学习相同的等效工作流,是不必要的重复工作。为了拥抱代码优先的方法,我们将使用 Google Cloud Shell 作为本书中所有示例的实际环境。

Google Shell 环境,作为一个一流的公民功能,总是可以在顶部菜单的左侧使用一个命令提示符图标,如图 1-1 所示。读者可能想知道,就学习一项只与谷歌捆绑在一起的技术而言,使用谷歌云外壳是否是一种虚假的经济。一点也不。

img/486631_1_En_1_Fig1_HTML.jpg

图 1-1

左上角菜单栏上的 Google Cloud Shell 图标

与微软的 PowerShell 不同,谷歌的云 Shell 并不是 Bash 的替代品。这是一个基于网络的终端——想想运行在网络浏览器上的 Putty 或 iTerm 它自动连接到一个小型的全功能 Linux 虚拟环境。所述虚拟机为我们提供了一个主目录,用于存储我们的文件,以及我们需要用于本书的预安装和预认证的所有实用程序和命令:

  • gcloud命令,它是 Google SDK 的一部分,已经通过验证,并且在我们登录时指向我们的默认项目

  • kubectl命令,它是 Kubernetes 客户端套件的一部分,一旦创建,就会被认证并指向我们基于 Google 的集群

  • python3命令,在第九章中用于演示状态集

  • curl命令,用于与 web 服务器交互

  • git命令,从 GitHub 下载代码示例

在本地机器上安装所述软件包的步骤取决于读者的机器运行的是 Windows、macOS 还是 Linux——以及在后一种情况下的具体发行版。谷歌在 https://cloud.google.com/sdk/install 为每个操作系统提供指令。我们建议读者在读完这本书后只设置一个本地环境。同样,如果读者运行的是微软 Windows 10,我们强烈建议使用 Linux (WSL)的 Windows 子系统,而不是 Cygwin 或其他伪 Unix 环境。有关 WSL 的好处以及如何安装的更多信息,请参考 https://docs.microsoft.com/en-us/windows/wsl/

注意

本书中的许多例子提示读者打开多个窗口或标签来观察各种 Kubernetes 对象的实时行为。Google Cloud Shell 允许打开多个标签页,但是它们对于“并排”比较没有用。使用 TMUX 通常更方便,它是预先安装的,默认情况下正在运行。TMUX 是一个终端复用器,它允许将一个屏幕分成多个面板,还有许多其他功能。对于本书的范围,以下 TMUX 命令应该足够了:

水平分割屏幕

Ctrl+B(一次)然后"(双引号字符)

垂直分割屏幕

Ctrl + B(一次),然后是%(百分比字符)

在打开的面板间移动(因此它们被选中)

Ctrl + B(一次)和箭头键

增大/减小所选面板的尺寸

Ctrl + B(一次),然后 Ctrl +箭头键

关闭面板

在选定的面板上键入exit

有关 TMUX 的更多信息,请键入man tmux

下载源代码并运行示例

本书中包含的大型程序清单包括一个注释,其文件名通常位于第一行或第二行,这取决于第一行是否用于调用命令——使用 shebang 符号——如下所示:

#!/bin/sh
# create.sh
gcloud container clusters create my-cluster \
  --issue-client-certificate \
  --enable-basic-auth \
  --zone=europe-west2-a

本书包含的所有文件的源代码位于 GitHub 上的 https://github.com/egarbarino/kubernetes-gcp 。Google Cloud Shell 默认安装了 Git。以下命令序列将存储库克隆到本地主目录,并提供文件夹列表,一章一个文件夹:

$ cd ~ # Be sure we are in the home directory
$ git clone \
    https://github.com/egarbarino/kubernetes-gcp.git
$ cd kubernetes-gcp
$ ls
chp1  chp2  chp3  chp4  chp5  chp6  chp7  chp8 ...

为简洁起见,本文中的代码清单不包括章节号作为文件名的一部分。比如之前看到的代码显示的是create.sh而不是chp1/create.sh;读者应该在执行给定章节的代码之前切换到相关目录。例如:

$ cd chp1
$ ls
create.sh  destroy.sh

创建和销毁 Kubernetes 集群

在创建 Kubernetes 集群之前,我们必须决定要运行它的地理位置。谷歌使用术语区域来指代地理位置(例如,都柏林相对于伦敦),而区域则代表隔离区域(在电力供应、网络、计算等方面)。)的环境。然而,区域标识符包括地区和区域。例如,europe-west2-a选择了europe-west2内的区域a,这反过来标识了英国伦敦的一个谷歌数据中心。这里,我们将使用gcloud config set compute/zone <ZONE>命令。

$ gcloud config set compute/zone europe-west2-a
Updated property [compute/zone]:

同样的设置也可以作为一个标志提供,我们稍后会了解到。现在我们准备使用gcloud container clusters create <NAME>命令启动我们的 Kubernetes 集群:

$ gcloud container clusters create my-cluster \
    --issue-client-certificate \
    --enable-basic-auth
Creating cluster my-cluster in europe-west2-a...
Cluster is being health-checked (master is healthy)
done
kubeconfig entry generated for my-cluster.
NAME       LOCATION       MASTER_VERSION NUM_NODES
my-cluster europe-west2-a 1.12.8-gke.10  3

默认集群由三个虚拟机组成(称为节点)。在 Kubernetes 1.12 之前,gcloud container clusters create命令本身就足以创建一个简单的 Kubernetes 集群,但是现在,有必要使用显式标志,以便让用户知道默认设置不一定是最安全的。特别

  • 为了避免设置自定义证书的复杂性,有必要使用--issue-client-certificate标志。

  • --enable-basic-auth标志对于避免建立更健壮的认证机制是必要的。

其他标志可能是必要的,也可能不是必要的,这取决于我们是否有全局默认值:

  • --project=<NAME>标志表示我们是否想要在其他项目中而不是默认项目中创建集群。Google Cloud Shell 通常会自动指向默认项目。如果有疑问,我们可以通过发出gcloud project lists命令来列出项目的数量。同样,如果我们想永久改变默认项目,我们可以使用gcloud config set project <NAME>命令。

  • --zone=<ZONE>标志指示将在其中创建群集的计算区域。可用区域的完整列表可以通过发出gcloud compute zones list命令获得。然而,对于更人性化的列表,包括设施所在的实际城市和国家,URL https://cloud.google.com/compute/docs/regions-zones/ 更有用。可以预先使用gcloud config set compute/zone <ZONE>命令指定默认区域,这正是我们在前面的例子中所做的。本章文件夹下提供的脚本,create.shdestroy.sh,使用此标志设置区域。读者可以根据自己的喜好修改这些脚本。

  • --num-nodes=<NUMBER>标志设置 Kubernetes 集群将包含的虚拟机(节点)数量。这有助于试验更小和更大的集群,尤其是在高可用性场景中。

  • --cluster-version=<VERSION>标志指定了主节点和从节点的 Kubernetes 版本。命令gcloud container get-server-config列出了当前可用的版本。这本书已经过 ?? 版本的检验。如果忽略此标志,服务器将选择最新的稳定版本。如果一些例子因为新版本中引入的向后破坏的变化而失败,那么当运行gcloud container get-server-config命令时,读者可以指定哪个是1.13版本下的最新版本。或者,读者可以使用本章文件夹下提供的misc/create_on_v13.sh脚本。

一旦我们完成了 Kubernetes 集群,我们就可以通过发出gcloud container clusters delete <NAME>命令来处理它。添加--async--quiet标志也是有用的,这样删除过程就可以在后台进行,而不会让文本污染屏幕:

$ gcloud container clusters delete my-cluster \
    --async --quiet

作者建议,只要有必要运行示例,就运行 Kubernetes 集群,以充分利用分配的信用。让一个三节点的 Kubernetes 集群运行几天,由于失误,可以很容易地增加一个价值超过 100 美元的账单,在这个过程中蒸发掉三分之一的免费信用。

本文中的所有例子都假设一个名为my-cluster的 Kubernetes 集群。为了方便起见,脚本create.shdestroy.sh包含在本章的文件夹下。还有一个叫做misc/create_on_v13.sh的脚本,如果 GKE 选择了一个与 1.13 太不一致的默认版本,就不可能重现例子了。

在库伯内特斯思考

在 Kubernetes 中,几乎每个组件类型都被实现为通用的资源,在本文中,我们也经常称之为 Kubernetes 对象,或简称为对象——因为资源具有我们可以检查和更改的属性。以相对水平的方式管理资源;使用kubectl命令的大多数日常操作包括

  • 列出现有对象

  • 检查对象的属性

  • 创建新对象和改变现有对象的属性

  • 删除对象

让我们从对象列表开始。我们可以使用kubectl api-resources命令列出支持的资源类型。这些还不是实例本身,而是 Kubernetes 世界中存在的对象类型:

$ kubectl api-resources
NAME          SHORTNAMES  NAMESPACED  KIND
...
configmaps    cm          true        ConfigMap
endpoints     ep          true        Endpoints
events        ev          true        Event
limitranges   limits      true        LimitRange
namespaces    ns          false       Namespace
nodes         no          false       Node
...

注意,其中一种资源类型叫做nodes。节点是支持我们的 Kubernetes 集群的计算资源,所以这个类的对象保证预先存在——除非我们已经决定建立一个没有工作节点的集群。为了列出对象实例,我们使用kubectl get <RESOURCE-TYPE>命令。在这种情况下,<RESOURCE-TYPE>就是nodes:

$ kubectl get nodes
NAME                 STATUS  AGE   VERSION
gke-*-4ff6f64a-6f4v  Ready   92m   v1.12.8-gke.10
gke-*-4ff6f64a-d8nx  Ready   92m   v1.12.8-gke.10
gke-*-4ff6f64a-nw0m  Ready   92m   v1.12.8-gke.10

<RESOURCE-TYPE>还有一个的简称,是nodesno。Kubectl 通常也接受同一个资源类型的单数(以及复数)版本。例如,在节点的情况下,kubectl get nodeskubectl get node,kubectl get no都是等价的。

一旦我们获得了对象实例的列表,我们就可以检查给定的选定对象的特定属性。这里我们使用命令kubectl describe <RESOURCE-TYPE>/<OBJECT-IDENTIFIER>来获得一个快速的屏幕摘要。我们选择第一个节点,因此<RESOURCE-TYPE>将是nodes,,而<OBJECT-IDENTIFIER>将是gke-*-4ff6f64a-6f4v:

$ kubectl describe nodes/gke-*4ff6f64a-6f4v
...
Addresses:
  InternalIP:   10.154.0.23
  ExternalIP:   35.197.220.185
  ...
System Info:
  Machine ID:       af2b11a0b29eb76e2592b4dd64f16308
  System UUID:      AF2B11A0-B29E-B76E-*-B4DD64F16308
  Boot ID:          22ba7558-7748-45f4-*-789d16d343d2
  Kernel Version:   4.14.127+
  Operating System: linux
  Architecture:     amd64
...

实际对象的底层逻辑属性结构可以通过添加-o json-o yaml标志,使用kubectl get <RESOURCE-TYPE>/<OBJECT-IDENTIFIER>命令以 JSON 或 YAML 格式获得。例如:

$ kubectl get nodes/gke-*-4ff6f64a-6f4v -o yaml
kapiVersion: v1
kind: Node
metadata:
  name: gke-my-cluster-default-pool-4ff6f64a-6f4v
  resourceVersion: "19152"
  uid: 89b70bf4-c330-11e9-9bab-42010a9a0178
  ...
spec:
  podCIDR: 10.0.0.0/24
  ...
status:
  addresses:
  - address: 10.154.0.23
    type: InternalIP
  - address: 35.197.220.185
    type: ExternalIP
...

每个属性的语法和描述可以使用kubectl explain <PROPERTY>命令学习,其中<PROPERTY>由点分隔的字符串组成,其中第一个元素是资源类型,随后的元素是各种嵌套属性。例如:

$ kubectl explain nodes.status.addresses.address
KIND:     Node
VERSION:  v1

FIELD:    address <string>

DESCRIPTION:
     The node address.

就改变一个对象的属性而言,大多数命令如kubectl scale(在第三章中讨论)最终会改变一个或多个对象的属性。然而,我们可以使用kubectl patch <RESOURCE-TYPE>/<OBJECT-IDENTIFIER> -p '<NEW-JSON>'命令直接修改属性。在下面的例子中,我们将一个名为greeting的新标签的值hello添加到gke-*-4ff6f64a-6f4v:

$ kubectl patch nodes/gke-*-4ff6f64a-6f4v \
    -p '{"metadata":{"labels":{"greeting":"hello"}}}'
node/gke-*-4ff6f64a-6f4v patched

使用刚才显示的kubectl get command可以检查结果。然而,在大多数情况下,很少需要使用kubectl patch,因为要么有一个专门的命令来间接产生预期的变化——比如kubectl scale——要么我们将使用kubectl apply命令刷新对象的整个属性集。这个命令和kubectl create一样,将在本书中广泛讨论。

最后,处理对象包括运行 kubectl delete <RESOURCE-TYPE>/<OBJECT-IDENTIFIER>命令。假设名为gke-*-4ff6f64a-6f4v的节点被视为任何其他常规对象,我们可以继续删除它:

$ kubectl delete nodes/gke-*-6f4v
node "gke-*-4ff6f64a-6f4v" deleted

添加--force标志将加快进程,当<OBJECT-IDENTIFIER>被省略时可以使用--all标志,以便删除声明的资源类型下的所有对象实例。或者,kubectl delete all --all可以用来清除默认名称空间中所有用户创建的资源,但是它不会销毁与名称空间无关的节点。大多数对象类型是在特定的命名空间中声明的;由于 pod 是驻留在名称空间中的对象的典型类型,我们将在第二章的末尾讨论这个主题。

注意

这本书经常会提示读者“清理环境”,或者从“新鲜的环境”开始这种请求也可以以意见的形式包括在内。例如:

# Clean up the environment first

$ kubectl apply -f server.yaml

还有一个隐含的假设是,读者也将以一个干净的环境开始每一章。清理环境意味着删除先前在 Kubernetes 集群中创建的所有对象,这样它们就不会占用当前示例所需的资源,同时也避免了名称冲突。

读取器可以通过发出kubectl get all –all命令列出默认名称空间中所有用户创建的对象,并逐个删除每个对象,或者使用kubectl delete all –all命令。如果这不起作用,或者读者觉得环境可能仍然被以前的工件污染,解决方案是关闭 Kubernetes 集群并启动一个新的集群。

这本书是如何组织的

这本书由九章组成。图 1-2 提供了一个高层次的架构图,可作为章节指南:

  • 第一章,“简介”,包括使用 Google Cloud Shell、下载源代码示例和设置 Kubernetes 集群的说明。

  • 第二章“Pod”介绍了 Pod 资源类型,这是 Kubernetes 中最基本的构建块。读者将学习如何引导 Pods 运行,从简单的一次性命令到 web 服务器。操作方面,如设置 CPU 和 RAM 约束,以及使用标签和注释组织 pod,也将包括在内。在本章结束时,读者将能够运行 Kubernetes 集群中运行的应用并与之交互,就像它们运行在本地机器上一样。

  • 第三章,“部署和扩展”,通过引入部署控制器帮助读者将 Pod 提升到一个新的水平,部署控制器允许按需扩展 Pod 和无缝迁移,包括升级 Pod 版本时的蓝/绿部署和回滚。此外,使用水平 Pod 自动缩放(HPA)控制器演示了自动缩放的动态特性。

  • 第四章“服务发现”,教读者如何在公共互联网上以及在 Kubernetes 集群中使用 Pods。此外,本章还解释了服务控制器如何与部署控制器交互,以促进零停机部署以及应用的正常启动和关闭。

  • 第五章,“配置映射和秘密”,展示了如何通过使用配置映射或秘密控制器来存储配置,从而将配置从应用中外部化出来。同样,还将检查 Secrets controller 存储 Docker 注册表凭证和 TLS 证书的能力。

  • 第六章“作业”着眼于使用作业控制器运行批处理进程的情况——不同于稳定的 web 服务器。Kubernetes 的并行化能力有助于减少大型、计算成本高的多项目批处理作业的总处理时间,这一点也受到了特别关注。

  • 第七章“cron Jobs”描述了 CronJob 控制器,它可以以循环方式运行作业,并且依赖于大多数类 Unix 系统中 cron 守护程序的 crontab 文件所使用的相同语法。

  • 第八章“DaemonSets”解释了如何在 Kubernetes 集群的每个节点上部署本地可用的 pod,以便消费 pod 可以通过本地 TCP 连接或本地文件系统从更快的访问时间中受益。

  • 第九章,即最后一章“StatefulSets”,通过带领读者完成使用 StatefulSet 控制器实现原始键/值存储的过程,展示了高度可伸缩的有状态支持服务的本质。在本章结束时,读者将理解为什么云原生(托管)数据存储提供了几乎无与伦比的优势,以及如果读者选择推出自己的数据存储,StatefulSet 控制器提供的工具机制。

请注意,图 1-2 中的箭头表示逻辑流,而不是网络连接。同样,所描绘的节点是工作者节点。主节点,以及在其中运行的对象,由 GCP 管理,不在本初学者手册的范围之内。

img/486631_1_En_1_Fig2_HTML.jpg

图 1-2

Kubernetes 高级架构和章节指南

二、Pods

Pods 是 Kubernetes 集群中最基本的工作单元。一个 Pod 包含一个或多个容器,这些容器将一起部署在同一台机器上,因此可以使用本地数据交换机制(Unix 套接字、TCP 回送设备,甚至内存支持的共享文件夹)来实现更快的通信。

分组在 Pods 中的容器不仅通过避免网络往返来实现更快的通信,它们还可以使用共享资源,比如文件系统。

Pod 的一个关键特征是,一旦部署,它们就共享相同的 IP 地址和端口空间。与 vanilla Docker 不同,Kubernetes Pod 中的容器不在孤立的虚拟网络中运行。

从概念的角度来看,值得理解的是,pod 是位于特定节点中的运行时对象。部署到 Kubernetes 中的 Pod 不是“映像”或“磁盘”,而是实际的、有形的、消耗 CPU 周期的、网络可访问的资源。

在这一章中,我们将首先看看如何启动 Pods 并与之交互,就像它们是本地 Linux 进程一样;我们将学习如何指定参数、通过流水线输入和输出数据,以及连接到公开的网络端口。然后,我们将研究 Pod 管理的更高级的方面,例如与多容器 Pod 交互、设置 CPU 和 RAM 约束、挂载外部存储以及检测健康状况。最后,我们将展示标签注释的用处,它们不仅有助于标记、组织和选择窗格,还有助于标记、组织和选择大多数其他 Kubernetes 对象类型。

发射逃生舱的最快方法

使用最少的键击启动 Pod 的最快方法是发出kubectl run <NAME> --image=<URI>命令,其中<NAME>Pod 的前缀(稍后将详细介绍),而<URI>要么是一个 Docker Hub 映像,如nginx:1.7.9,要么是在其他 Docker 注册表中完全合格的 Docker URI,如谷歌自己的;例如,谷歌的 Hello World Docker 图片位于gcr.io/google-samples/hello-app:1.0

为简单起见,让我们从 Docker Hub 启动一个运行最新版本 Nginx web 服务器的 Pod:

$ kubectl run nginx --image=nginx
deployment.apps/nginx created

现在,尽管这是运行 Pod 最简单的方法,但它实际上会产生比我们可能需要的更复杂的设置。具体来说,它创建了一个部署控制器和一个复制集控制器(将在第三章中介绍)。反过来,ReplicaSet 控制器恰好控制一个被分配了随机名称的 Pod:nginx-8586cf59-8t9z9。我们可以使用kubectl get <RESOURCE-TYPE>命令检查生成的部署、复制集和 Pod 对象,其中<RESOURCE-TYPE>分别是deploymentreplicaset(对于复制集)和pod:

$ kubectl get deployment
NAME   DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
nginx  1       1       1          1         0s

$ kubectl get replicaset
NAME             DESIRED  CURRENT  READY  AGE
nginx-8586cf59   1        1        1      5m

$ kubectl get pod
NAME                 READY STATUS  RESTARTS AGE
nginx-8586cf59-8t9z9 1/1   Running 0        12s

虽然这是运行 Pod 的最简单的方法,但是从简洁的角度来说,Pod 被分配了随机的名称,我们需要使用kubectl get pod或其他机制(如标签选择器)来解决这个问题——这将在本章末尾介绍。此外,我们不能在完成后直接删除 Pod,因为部署和复制集控制器会再次创建它。事实上,要处置我们刚刚创建的 Pod,我们需要删除 Pod 的部署对象,而不是 Pod 本身:

$ kubectl delete deployment/nginx
deployment.extensions "nginx" deleted

默认情况下,kubectl run命令创建部署的原因是因为它的重启策略,重启策略由--restart=<VALUE>标志控制,如果不指定,它将被设置为Always。相反,如果我们将<VALUE>设置为OnFailure,那么 Pod 只有在失败时才会重启。然而,OnFailure策略也没有创造一个干净的容器。创建了一个作业对象(一个控制器,就像一个部署),我们将在到达第六章时讨论它。第三个也是最后一个可能的值是Never,它只创建一个单独的 Pod,其他什么都不创建;这正是我们在本章范围内所需要的。

注意

Kubernetes 将来会反对这样的行为,即无论何时省略了--restart标志,kubectl run命令都会“意外地”创建一个部署。当我们到达第三章时,我们将再次讨论这个话题。

发射单个吊舱

kubectl run <NAME> --image<IMAGE> --restart=Never命令(请注意--restart=Never标志)创建一个单独的 Pod,不创建其他对象。产生的 Pod 将完全按照提供的<NAME>参数命名:

$ kubectl run nginx --image=nginx --restart=Never
pod/nginx created

$ kubectl get pod
NAME      READY     STATUS    RESTARTS   AGE
nginx     1/1       Running   0          0s

如何访问 Nginx web 服务器本身是我们将在前面几节讨论的内容。现在,知道 Nginx 按照 Dockerfile 初始化设置运行就足够了。我们可以通过指定一个命令作为kubectl run的最后一个参数来覆盖输入命令。例如:

# Clean up the environment first
$ kubectl run nginx --image=nginx --restart=Never \
    /usr/sbin/nginx
pod/nginx created

然而,这里的问题是,默认情况下,nginx启动容器内部的进程,并以一个成功状态代码退出,从而结束 Pod 的执行——因此 Nginx web 服务器本身:

$ kubectl get pod
NAME      READY     STATUS      RESTARTS   AGE
nginx     0/1       Completed   0          0s

当强制运行 Pods 时,我们必须始终记住,无论是 web 服务器还是其他应用,进入过程都必须在某种循环中保持暂停,而不是立即完成并退出。在 Nginx 的情况下,我们需要传递-g 'daemon off;'标志。然而,将一个命令作为最后一个参数传递在这里不起作用,因为这些标志将被解释为kubectl run的额外参数。这里的解决方案是使用--command标志和双连字符语法:kubectl run ... --command -- <CMD> [<ARG1> ... <ARG2>]。在双连字符--之后,我们可以写一个带有参数的长命令:

# Clean up the environment first
$ kubectl run nginx --image=nginx --restart=Never \
    --command -- /usr/sbin/nginx -g 'daemon off;'
pod/nginx created

$ kubectl get pod
NAME      READY     STATUS    RESTARTS   AGE
nginx     1/1       Running   0          0s

如果--command标志不存在,那么--之后的参数将被认为是适用于映像‘docker file’命令的参数。然而,请注意,大多数映像没有入口命令,所以--command标志很少是必要的。换句话说,--之后的第一个参数通常被解释为一个命令,即使没有--command标志,因为公共 Docker 映像,比如 Docker Hub 上的映像,很少声明一个ENTRYPOINT属性。在前面的几节中会有更多的介绍。

注意

所展示的例子显示了 HTTP 日志,它需要一个 HTTP 客户端,比如curl首先与 Pod 进行交互。否则,当使用 Nginx 映像连接到 Pod 时,阅读器将不会体验到任何输出。Pod 网络端口的暴露及其与外部 TCP 客户端的交互将在本章前面的几个章节中解释,并在第四章中深入介绍。

使用前面显示的简单语法运行 Pods 的另一个结果是,Pods 被发送到后台,我们看不到它们的输出。我们可以通过发出kubectl attach <POD-NAME>命令连接到正在运行的容器的第一个进程。例如:

$ kubectl attach nginx
Defaulting container name to nginx.
127.0.0.1 - [12:16:00] "GET / HTTP/1.1" 200 612
127.0.0.1 - [12:16:01] "GET / HTTP/1.1" 200 612
127.0.0.1 - [12:16:01] "GET / HTTP/1.1" 200 612
...

Ctrl+C 会把控制权还给我们。我们也可以使用kubectl logs <POD-NAME>,我们将在后面单独讨论。要从后台运行的 Pod 进行处置,使用kubectl delete pod/<NAME>命令。

启动单个 Pod 来运行命令

默认情况下,窗格在后台运行。调试它们需要我们连接到它们,查询它们的日志,或者在它们内部运行一个 shell 在它们终止之前。然而,我们可以发射一个吊舱,并与它保持连接,这样我们就可以立即看到它的输出。这是通过添加--attach标志来实现的:

$ kubectl run nginx --image=nginx --restart=Never \
    --attach
127.0.0.1 - [13:11:03] "GET / HTTP/1.1" 200 612
127.0.0.1 - [13:11:04] "GET / HTTP/1.1" 200 612
127.0.0.1 - [13:11:05] "GET / HTTP/1.1" 200 612

请注意,如前所述,除非我们访问 nginx web 服务器,否则我们不会看到流量日志,稍后我们将对此进行解释。

诸如由 HTTP 服务器生成的那些日志通常是连续生成的;如果我们只想简单地运行一个命令,然后忘记 Pod 会怎么样?嗯,原则上我们只需要使用--attach标志并将所需的命令传递给 Pod 的容器。例如,让我们使用 Docker Hub 中的alpine Docker 映像来运行date命令:

$ kubectl run alpine --image=alpine \
    --restart=Never --attach date
Fri Sep 21 13:25:02 UTC 2018

票据

Docker 映像基于 Alpine Linux。它的大小只有 5 MB,比普通的ubuntu映像小一个数量级——普通映像是 Docker 初学者的首选。alpine映像的好处是,无论何时需要额外的实用程序,它都提供对相当完整的包存储库的访问。

考虑到我们没有向date传递参数,我们可以将它作为kubectl的最后一个参数,而不是使用双连字符--语法。然而,如果我们想再次知道日期和时间,会发生什么呢?

$ kubectl run alpine --image=alpine \
    --restart=Never --attach date
Error from server (AlreadyExists): pods "alpine"
already exists

哦,这当然很烦人;我们不能使用相同的名称第二次运行 Pod。这个问题可以通过使用kubectl delete pod/alpine命令删除 Pod 来解决,但是过一会儿就会变得乏味。幸运的是,Kubernetes 团队考虑到了这个用例,并添加了一个可选标志,--rm (remove),这导致 Pod 在命令结束后被删除:

$ kubectl run alpine --image=alpine \
    --restart=Never --attach --rm date
Fri Sep 21 13:30:35 UTC 2018
pod "alpine" deleted

$ kubectl run alpine --image=alpine --restart=Never --attach --rm date
Fri Sep 21 13:30:40 UTC 2018
pod "alpine" deleted

请注意,--rm仅在以附加模式启动 Pod 时起作用,如果 Pod 在循环中运行,Ctrl+C 不会终止 Pod,就像 Nginx 进程默认情况下所做的那样。

目前为止一切顺利。现在我们知道了如何在 Kubernetes 中运行一次性命令,就好像它是一个本地 Linux 机器一样。不过,我们的本地 Linux 机器不仅运行“一劳永逸”的命令,还允许通过流水线向它们输入数据。例如,假设我们想要运行wc(字数统计)命令,并提供一个作为输入的本地 /etc/resolv.conf文件:

$ wc /etc/resolv.conf
  4  24 182 /etc/resolv.conf

$ cat /etc/resolv.conf | \
    kubectl run alpine --image=alpine \
     --restart=Never --attach --rm wc
        0         0         0
pod "alpine" deleted

前面的例子不成立。为什么呢?这是因为--attach标志仅将 Pod 的容器 STDOUT (标准输出)连接到控制台,而不是 STDIN (标准输入)。为了将 STDIN 传输到我们基于 Alpine 的 Pod,需要一个不同的标志,简称为--stdin-i-i标志还会自动将--attach设置为真,因此不需要同时使用这两个标志:

$ cat /etc/resolv.conf | \
    kubectl run alpine --image=alpine \
    --restart=Never -i --rm wc
        4        24       182
pod "alpine" deleted

交互式运行 Pod

到目前为止,我们已经看到了如何运行后台应用,如 web 服务器和一次性命令。如果我们希望通过 shell 或者通过启动一个命令(如mysql客户机)来交互执行命令,该怎么办?然后,我们所要做的就是指定我们想要一个使用--tty标志或-t的终端。我们可以将-t-i组合在一个标志中,得到-ti:

$ kubectl run alpine --image=alpine \
    --restart=Never -ti --rm sh
If you don't see a command prompt, try pressing enter.
/ # ls
bin    etc    lib    mnt    root   sbin   sys    usr
dev    home   media  proc   run    srv    tmp    var
/ # date
Fri Sep 21 16:16:03 UTC 2018
/ # exit
pod "alpine" deleted

与现有 Pod 交互

正如我们之前提到的,我们可以使用kubectl attach命令来访问 Pod 的容器主进程,但是这不允许运行其他命令:我们只能被动地考虑已经在运行的进程的输出。运行一个 shell 并附加到它是行不通的,因为 shell 会立即退出,除非我们创建一个人工循环:

$ kubectl run alpine --image=alpine \
    --restart=Never sh
pod/alpine created

$ kubectl attach alpine
error: cannot attach a container in a completed pod; current phase is Succeeded

现在让我们继续,创建一个人工循环,并尝试再次连接:

# Clean up the environment first

$ kubectl run alpine --image=alpine \
    --restart=Never -- \
    sh -c "while true; do echo 'doing nothing' ; \
    sleep 1; done"
pod/alpine created

$ kubectl attach alpine
Defaulting container name to alpine.
Use 'kubectl describe pod/alpine -n default' to see
all of the containers in this pod.
If you don't see a command prompt, try pressing enter.
doing nothing
doing nothing
doing nothing
...

现在,容器停留在运行状态(我们可以使用kubectl get pod来确保万无一失),因此可以使用kubectl exec <POD-NAME> <COMMAND>命令对其运行命令:

$ kubectl get pod
NAME      READY     STATUS    RESTARTS   AGE
alpine    1/1       Running   0          30s

$ kubectl exec alpine date
Fri Sep 21 16:50:58 UTC 2018

kubectl run类似,kubectl exec命令采用-i标志,这样它可以将 STDIN 通过流水线传输到 Pod 的容器中:

$ cat /etc/resolv.conf | kubectl exec alpine -i wc
        4        24       182

-t标志可用于打开一个控制台并运行一个新的 shell,以便我们可以执行故障排除练习和/或直接在 Pod 的容器内运行新命令:

$ kubectl exec alpine -ti sh
/ # ps
PID USER     TIME  COMMAND
  1 root     0:00 sh -c while true; do ...
408 root     0:00 sh
417 root     0:00 sleep 1
418 root     0:00 ps
/ # exit

检索和跟踪 Pod 的日志

在 Kubernetes 中,Pod 的日志是容器的第一个进程(使用 PID 1 运行)的输出,而不是物理日志文件(例如,/var/log中某处带有.log扩展名的文件)。原则上,我们可以只使用kubectl attach,但是这个命令不记得在发出它之前产生的输出。我们只能看到从连接开始的输出。

相反,kubectl logs <POD-NAME>显示了默认容器的第一个进程自启动以来在 STDOUT 上转储的所有内容——不包括本书范围之外的缓冲限制:

$ kubectl logs alpine
doing nothing
doing nothing
doing nothing
...

如果我们数一数kubectl logs alpine发出的谱线,我们会看到它们会不断增加:

$ kubectl logs alpine | wc
   1431    2862   20034

# Wait one second
$ kubectl logs alpine | wc
   1432    2864   20048

然而,在大多数情况下,我们想要类似于kubectl attach的行为。是的,我们想知道在之前发生了什么,但是一旦我们赶上了,我们想继续关注新的变化,类似于tail -f Unix 命令。嗯,就像在tail的情况下一样,-f标志允许我们随着更多的输出产生而“跟随”日志:

$ kubectl logs -f alpine
doing nothing
doing nothing
...

在本例中,直到我们按 Ctrl+C 中止会话,命令提示符才会出现。

与 Pod 的 TCP 端口交互

在前面的章节中,我们已经看到了启动包含 Nginx web 服务器的 Pod 的例子:

# Clean up the environment first

$ kubectl run nginx --image=nginx \
    --restart=Never --rm --attach
127.0.0.1 - [06:22:08] "GET / HTTP/1.1" 200 612
127.0.0.1 - [06:22:44] "GET / HTTP/1.1" 200 612
127.0.0.1 - [06:22:45] "GET / HTTP/1.1" 200 612

现在,我们首先如何访问 web 服务器,比方说,通过使用curl命令,以便我们可以生成我们在所示输出中看到的请求?嗯,这取决于我们是想从本地计算机还是从 Kubernetes 集群中的另一个 Pod 访问 Pod。

让我们从第一种情况开始。当从我们的本地计算机访问一个 Pod 时,我们需要创建一个从某个本地可用端口(比如说1080)到nginx Pod(默认为80)的桥(称为端口转发)。用于此目的的命令是kubectl port-forward <POD-NAME> <LOCAL-PORT>:<POD-PORT>

# Assume the nginx Pod is still running

$ kubectl port-forward nginx 1080:80
Forwarding from 127.0.0.1:1080 -> 80
Forwarding from [::1]:1080 -> 80

现在,在一个不同的窗口中,我们可以通过访问当前的本地端口 1080 与nginx Pod 进行交互:

# run on a different window, tab or shell
$ curl http://localhost:1080
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

第二种情况是从另一个 Pod 内部访问 Pod 的 TCP 端口,而不是我们的本地计算机。这里的挑战是,除非我们设置一个服务(在第四章中讨论),否则 Pod 名称不会自动成为可访问的主机名:

$ kubectl run alpine --image=alpine \
    --restart=Never --rm -ti sh
/ # ping nginx
ping: bad address 'nginx'

相反,我们需要的是找出 Pod 的 IP 地址。每个 Pod 都分配有一个唯一的 IP 地址,这样不同的 Pod 之间就不会发生端口冲突。找出一个 Pod 的 IP 地址的最快方法是发出带有包含IP列的-o wide标志的kubectl get pod命令:

$ kubectl get pod -o wide
NAME   READY STATUS  RESTARTS AGE IP
alpine 1/1   Running 0        2m  10.36.2.8
nginx  1/1   Running 0        7m  10.36.1.5

现在我们可以返回到我们的alpine窗口,使用10.36.1.5而不是nginx:

/ # ping 10.36.1.5
PING 10.36.1.5 (10.36.1.5): 56 data bytes
64 bytes from 10.36.1.5: seq=0 ttl=62 time=1.370 ms
64 bytes from 10.36.1.5: seq=1 ttl=62 time=0.354 ms
64 bytes from 10.36.1.5: seq=2 ttl=62 time=0.364 ms
...

在 Alpine 上,wget是预装的,而不是curl,但它的作用是一样的:

# wget -q http://10.36.1.5 -O -
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

-o wide标志应用到kubectl get pod对于简单的手工检查来说很好,但是在脚本化的自动化场景中,我们可能希望以编程的方式获得 Pod 的 IP 地址。在这种情况下,我们可以使用以下命令从其 JSON 表示中查询 Pod 的pod.status.podIP字段:

$ kubectl get pod/nginx -o jsonpath \
    --template="{.status.podIP}"
10.36.1.5

我们将在本章的后面讨论 Pod 的 JSON 表示。关于 JSONPath 查询的更多信息可以从 http://goessner.net/articles/JsonPath/ 获取。

从 Pod 传输文件或将文件传输到 Pod

除了通过 TCP 连接到 Pods,通过流水线将数据传入和传出它们,并在它们内部打开 shells 之外,我们还可以下载和上传文件。文件传输(用 Kubernetes 的行话来说,复制或cp)是通过使用kubectl cp <FROM-FILE> <TO-FILE>命令来实现的,只要使用了<POD-NAME>:path格式,<*-FILE>就会变成一个 Pod 源或接收器。

例如,nginx 的index.html文件被下载到我们当前的目录,如下所示:

$ kubectl cp \
    nginx:/usr/share/nginx/html/index.html \
    index.html
$ head index.html
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

现在让我们覆盖这个文件,并将其上传回nginx Pod

$ echo "<html><body>Hello World</body></html>" > \
    index.html
$ kubectl cp \
    index.html \
    nginx:/usr/share/nginx/html/index.html

最后,搭建一座桥梁来证明我们文件传输的结果:

$ kubectl port-forward nginx 1080:80
Forwarding from 127.0.0.1:1080 -> 80
# In a different window or tab

$ curl http://localhost:1080
<html><body>Hello World</body></html>

选择 Pod 的容器

如引言中所述,一个 Pod 可以容纳多个容器。在我们到目前为止看到的所有例子中,特别是在运行命令或从现有 Pod 获取日志时,似乎 Pod 和 Docker 映像之间存在1:1关系。例如,当我们发出命令kubectl logs nginx时,似乎nginx舱和容器是同一个东西:

$ kubectl logs nginx
127.0.0.1 - [06:22:08] "GET / HTTP/1.1" 200 612
127.0.0.1 - [06:22:44] "GET / HTTP/1.1" 200 612
127.0.0.1 - [06:22:45] "GET / HTTP/1.1" 200 612
...

嗯,这只是 Kubernetes 很好,为我们自动选择了第一个也是唯一的容器。其实kubectl logs nginx可以认为是kubectl logs nginx -c nginx的简化版。标志-c--container的快捷方式:

$ kubectl logs nginx -c nginx
127.0.0.1 - [06:22:08] "GET / HTTP/1.1" 200 612
127.0.0.1 - [06:22:44] "GET / HTTP/1.1" 200 612
127.0.0.1 - [06:22:45] "GET / HTTP/1.1" 200 612
...

现在,我们如何判断一个 Pod 是否有多个容器?最简单的方法就是发出一个kubectl get pod命令,并在READY列下查看运行/声明的值。例如,每个 Kubernetes 的 DNS pod 由四个容器组成,在下面的示例中,每个容器都已启动并运行(如4/4值所示):

$ kubectl get pod --all-namespaces
NAMESPACE    NAME             READY  STATUS
default      nginx            1/1    Running
kube-system  fluentd-*-tr69s  2/2    Running
kube-system  heapster-*-5rks2 3/3    Running
kube-system  kube-dns-*-48wxf 4/4    Running
...

现在不需要关心名称空间,因为我们将在本章末尾讨论这个问题,但是只要我们引用非用户创建的 Pod,我们就需要指定kube-system名称空间(通过-n kube-system标志)。除非另有说明,用户创建的 pod(如nginx)位于default名称空间中。

注意

如第一章所述,长标识符可以通过用星号替换所述标识符中的样板词片段来缩短。在这个特定的部分中,每当空间受限时,名为kube-dns-5dcfcbf5fb-48wxf的 Pod 也被称为kube-dns-*-48wxf。请注意,这不是通配符语法;pod 必须始终以其全名引用。

回到最初的讨论,如果我们试图获取四容器 Pod(如kube-dns-5dcfcbf5fb-48wxf)的日志(或执行命令),那么 Pod 和容器之间 1:1 映射的假象就会消失:

$ kubectl logs -n kube-system pod/kube-dns-*-48wxf
Error from server (BadRequest):
  a container name must be specified
  for pod kube-dns-*-48wxf, choose one of:
[kubedns dnsmasq sidecar prometheus-to-sd]

从显示的结果中可以看出,我们被要求指定一个特定的 Pod,这是使用-c标志完成的。接下来,我们再次运行kubectl logs,但是使用-c sidecar标志指定sidecar容器:

$ kubectl logs -n kube-system -c sidecar \
    pod/kube-dns-*-48wxf
I0922 06:14:50 1 main.go:51] Version v1.14.8.3
I0922 06:14:50 1 server.go:45] Starting server ...
...

在这种情况下,该命令已经足够友好地通知我们哪些容器是可用的,但是并不是所有的命令都必须这样做。我们可以通过运行kubectl describe pod/<NAME>并查看Containers下面第一个缩进的名称来找出容器的名称:

$ kubectl describe -n kube-system \
    pod/kube-dns-*-48wxf
...
Containers:
  kubedns:
    ...
  dnsmasq:
    ...
  sidecar:
    ...
  prometheus-to-sd:
    ...
...

更程序化的方法是使用 JSONPath 查询 Pod 的 JSON pod.spec.containers.name字段:

$ kubectl get -n kube-system pod/kube-dns-*-48wxf \
  -o jsonpath --template="{.spec.containers[*].name}"
kubedns dnsmasq sidecar prometheus-to-sd

故障排除窗格

到目前为止,在我们检查的所有 Pod 交互用例中,假设一直是考虑中的 Pod 至少运行过一次。如果 Pod 根本不启动,没有日志,也没有像 web 服务器这样的 TCP 服务可供我们使用,那该怎么办?一个 Pod 可能由于各种原因而无法运行:它可能具有不稳定的启动配置,它可能需要过多的 CPU 和 RAM,而这些在 Kubernetes 集群中目前是不可用的,等等。然而,Pods 经常无法初始化的一个常见原因是引用的 Docker 映像不正确。例如,在下面的例子中,我们故意将我们最喜欢的 web 服务器拼错为nginex(在x前加上一个e)而不是nginx:

# Clean up the environment first

$ kubectl run nginx --image=nginex \
    --restart=Never
pod/nginx created

$ kubectl get pod
NAME      READY     STATUS             RESTARTS   AGE
nginx     0/1       ErrImagePull       0          2s
nginx     0/1       ImagePullBackOff   0          15s

尽管ImagePullBackOff告诉了我们一些关于映像的信息,但是我们可以使用kubectl describe pod/<NAME>命令找到更多的细节。该命令提供了一个全面的报告,并在最后说明了相关的 Pod 生命周期事件:

$ kubectl describe pod/nginx
...
Type    Reason  Age     Message
----    ------  ----    -------
...
Normal  Pulling 1m (x3) pulling image "nginex"
Warning Failed  1m (x3) Failed to pull image "nginex"

rpc error: code = Unknown desc =
   Error response from daemon:
     repository nginex not found:
       does not exist or no pull access
  ...

在本例中,我们看到没有找到nginex,Pod 控制器尝试了三次(x3)来获取映像,但没有成功。

kubectl describe命令的主要优点是它在一个人可读的报告中总结了 Pod 最重要的细节。当然,每当我们想要捕获一个特定的细节时,这是没有用的,比如 Pod 被分配到的节点或者它的 IP 地址。

包括 Pods 在内的所有 Kubernetes 对象都被表示为一个对象,其属性可以用 JSON 和 YAML 两种格式呈现。为了获得所述对象结构,我们必须使用常规的kubectl get pod/<NAME>命令并分别添加-o json-o yaml标志:

$ kubectl get pod/nginx -o json | head
{
    "apiVersion": "v1",
    "kind": "Pod",
    "metadata": {
        "annotations": {
            "kubernetes.io/limit-ranger":
               "LimitRanger plugin set:
                cpu request for container nginx"
        },
        "creationTimestamp": "2018-09-22T10:19:10Z",
        "labels": {
            "run": "nginx"

$ kubectl get pod/nginx -o yaml | head
apiVersion: v1
kind: Pod
metadata:
  annotations:
    kubernetes.io/limit-ranger:
        'LimitRanger plugin set:
           cpu request for container nginx'
  creationTimestamp: 2018-09-22T10:19:10Z
  labels:
    run: nginx
  name: nginx

所有属性都遵循 JSON 格式的层次结构,可以使用kubectl explain <RESOURCE-TYPE>[.x][.y][.z]命令进行查询,其中x.y.z是嵌套属性。例如:

$ kubectl explain pod
$ kubectl explain pod.spec
$ kubectl explain pod.spec.containers
$ kubectl explain pod.spec.containers.ports

一般来说,大多数 Kubernetes 对象遵循相当一致的结构:

apiVersion: v1 # The object's API version
kind: Pod      # The object/resource type.
metadata:      # Name, label, annotations, etc.
   ...
spec:          # Static properties (e.g. containers)
   ...
status:        # Runtime properties (e.g. podIP)
   ...

可以使用使用--template={}标志指定的 JSONPath 查询检索特定字段,并使用-o jsonpath标志将输出类型更改为jsonpath。例如:

$ kubectl get pod/nginx -o jsonpath \
    --template="{.spec.containers[*].image}"
nginex

Pod 清单

Pod 清单是以声明方式描述 Pod 属性的文件。所有的 POD 都被公式化为一个对象结构。每当我们使用诸如kubectl run这样的命令时,我们实际上是在动态地创建一个 Pod 清单。事实上,我们可以通过向大多数命令添加--dry-run-o yaml标志来查看结果清单。例如:

# Clean up the environment first

$ kubectl run nginx --image=nginx --restart=Never \
    --dry-run=true -o yaml
apiVersion: v1
kind: Pod
metadata:
  creationTimestamp: null
  labels:
    run: nginx
  name: nginx
spec:
  containers:
  - image: nginx
    imagePullPolicy: IfNotPresent
    name: nginx
    resources: {}
  dnsPolicy: ClusterFirst
  restartPolicy: Never
status: {}

我们可以将这个输出保存到一个文件中,比如nginx.yaml,并通过发出kubectl apply -f <MANIFEST>命令从这个文件中创建 Pod:

$ kubectl run nginx --image=nginx --restart=Never \
    --dry-run=true -o yaml > nginx.yaml

$ kubectl apply -f nginx.yaml
pod/nginx created

$ kubectl get pods
NAME      READY     STATUS    RESTARTS   AGE
nginx     1/1       Running   0          0s

我们还可以稍微清理一下nginx.yaml,删除空属性,那些有合理默认值的属性,以及那些只在运行时填充的属性——所有属性和值都在.status下。下面的版本叫做nginx-clean.yaml,是一个由最少的强制属性组成的 Pod 清单:

# nginx-clean.yaml
apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  containers:
  - image: nginx
    name: nginx

使用kubectl apply -f <MANIFEST>创建的 Pod 可以通过引用对象名(例如kubectl delete pod/nginx来删除,但是 Kubernetes 可以在使用kubectl delete -f <MANIFEST>语法时直接从清单文件中提取对象名:

$ kubectl delete -f nginx-clean.yaml
pod "nginx" deleted

注意

原则上,应该通过发出kubectl create -f <MANIFEST>命令来创建一个全新的 Pod,而不是本教材中使用的基于apply的表单。我们更喜欢基于apply的表单的原因是,如果它已经在运行,它还会更新现有的 Pod(或其他资源类型)。

声明容器的网络端口

Pod 内的所有容器共享相同的端口空间。同样,尽管指定端口号(并命名它们)不是强制性的,但只要声明了两个或更多端口,就必须命名端口。此外,当端口在 pod 清单上正式声明时,服务公开(第四章 ??)需要的步骤更少。底线是声明网络端口是良好的 Pod 清单卫生,所以让我们在下面名为nginx-port.yaml:的清单示例中看看如何做

# nginx-port.yaml
apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  containers:
  - image: nginx
    name: nginx
    ports:
    - containerPort: 80
      name: http
      protocol: TCP

.containerPort属性是强制的。默认情况下,.protocol属性的值是TCP。只有在有一个端口被声明的情况下,.name属性才是可选的。如果有多个端口,则每个端口都必须有一个不同的名称。使用kubectl explain pod.spec.containers.ports可以列出其他可选属性。

设置容器的环境变量

许多 Docker 应用映像期望以环境变量的形式定义设置。Mysql 就是一个很好的例子,它至少需要有MYSQL_ROOT_PASSWORD env 变量。环境变量在pod.spec.containers.env被定义为一个数组,其中每个元素由namevalue属性组成:

# mysql.yaml
apiVersion: v1
kind: Pod
metadata:
  name: mysql
spec:
  containers:
  - image: mysql
    name: mysql
    ports:
    - containerPort: 3306
      name: mysql
      protocol: TCP
    env:
    - name: MYSQL_ROOT_PASSWORD
      value: mypassword

即使在使用内部mysql客户端时,连接 MySQL 服务器也确实需要密码:

$ kubectl apply -f mysql.yaml
pod/mysql created

# Wait until mysql transitions to Running

$ kubectl get pod/mysql
NAME    READY   STATUS    RESTARTS   AGE
mysql   1/1     Running   0          105s
$ echo "show databases" | kubectl exec -i mysql \
    -- mysql --password=mypassword
Database
information_schema
mysql
performance_schema
sys

覆盖容器的命令

在本章的前面,我们已经看到了kubectl run命令允许我们覆盖 Docker 映像的默认命令。这也是我们用来运行由可任意处理的 Docker 映像支持的任意命令的机制。例如,如果我们只想检查 UTC 的日期,我们可以如下进行:

$ kubectl run alpine --image=alpine \
    --restart=Never --rm --attach -- date --utc
Sun Sep 23 11:32:03 UTC 2018
pod "alpine" deleted

同样的机制也可以用于执行简单的 shell 脚本,并且不限于运行一次性命令。例如,以下 shell 脚本在无限循环中每秒打印一次当前日期:

$ kubectl run alpine --image=alpine \
    --restart=Never --attach -- \
    sh -c "while true; do date; sleep 1; done"
Sun Sep 23 11:40:59 UTC 2018
Sun Sep 23 11:41:00 UTC 2018
Sun Sep 23 11:41:01 UTC 2018
...

在这两种情况下,kubectl run所做的是用一个数组填充spec.containers.args属性,该数组的第一个元素是命令,第二个和随后的参数是命令的参数。我们可以通过运行kubectl get pod/<POD-NAME> -o yaml并查看下面的内容pod.spec来检查这一点:

$ kubectl get pod/alpine -o yaml | \
    grep "spec:" -A 5
spec:
  containers:
  - args:
    - sh
    - -c
    - while true; do echo date; sleep 1; done

当从头开始创建 Pod 清单时,我们可以使用尖括号数组符号,例如,args: ["sh","-c","while true; do date; sleep 1; done"]。然而,对于长脚本,除了前面显示的数组元素的 YAML 连字符语法之外,我们还可以使用 YAML 流水线语法。我们经常在本文中使用这种方法来提高可读性。这是可用的多线 YAML 选项方法之一。欲了解更多信息,请参考 https://yaml-multiline.info/ ,这有助于找出每个给定多线用例的最佳策略。

这个功能很有用,因为它允许我们以更容易阅读的方式嵌入脚本,如清单alpine-script.yaml所示:

# alpine-script.yaml
apiVersion: v1
kind: Pod
metadata:
 name: alpine
spec:
  containers:
  - name: alpine
    image: alpine
    args:
    - sh
    - -c
    - |
      while true;
        do date;
        sleep 1;
      done

我们仍然必须记住,脚本将作为单个参数传递,因此分号之类的语句结束标记仍然是必要的。我们可以检查alpine-script.yaml脚本的 JSON 表示,看看它是如何翻译的,如下所示:

$ kubectl apply -f alpine-script.yaml
pod/alpine created

$ kubectl get pod/alpine -o json | \
    grep "\"args\"" -A 4
    "args": [
        "sh",
        "-c",
        "while true;\n  do date;\n  sleep 1;\ndone\n"
    ],

在我们结束本节之前,值得一提的是,Docker 映像有一个使用ENTRYPOINT声明在 Docker 文件中定义的入口点命令的概念。由于历史原因以及 Kubernetes 和 Docker 之间的术语差异,pod.spec.containers.args属性以及在kubectl run ... --kubectl exec ... --之后提供的参数会覆盖 Dockerfile 的CMD声明。就其本身而言,CMD既声明了命令,也可能声明了它的参数,例如CMD ["sh", "-c", "echo Hello"]。然而,如果还存在一个ENTRYPOINT声明,这对开发人员来说是一个诅咒,那么规则会以一种扭曲的方式改变:CMD将成为任何入口点命令的默认参数,从而成为 Kubernetes 的pod.spec.container.args属性的默认参数。

大多数现成的 Docker Hub 映像,如nginxbusyboxalpine等,不包含声明。但是如果存在,那么我们需要使用pod.spec.containers.command来覆盖它,然后将pod.spec.containers.args作为所述命令的参数。希望表 2-1 中的例子有助于澄清区别。

表 2-1

Docker 和 Kubernetes 指定命令的结果

|

煤矿管理局

|

ENTRYPOINT(入口点)

|

K8S(消歧义)。args

|

K8S。命令

|

结果

|
| --- | --- | --- | --- | --- |
| 尝试 | n/a | n/a | n/a | 尝试 |
| 尝试 | n/a | ["嘘"] | n/a | 嘘 |
| n/a | ["bash"] | n/a | n/a | 尝试 |
| [" c "," ls"] | ["bash"] | n/a | n/a | 巴沙尔·c·ls |
| n/a | ["bash"] | ["-c ","日期"] | n/a | bash -c 日期 |
| [" c "," ls"] | ["bash"] | ["-c ","日期"] | n/a | bash -c 日期 |
| [" c "," ls"] | ["bash"] | ["-c ","日期"] | ["嘘"] | sh -c 导弹 |

每当需要覆盖 Dockerfile 的ENTRYPOINT以及命令形式kubectl runkubectl exec时,必须添加--command标志,以便双连字符--之后的第一个参数被视为命令,而不是入口点的第一个参数。例如,下面的祈使句

# Clean up the environment first

$ kubectl run alpine --image=alpine \
    --restart=Never --command -- sh -c date
pod/alpine created

$ kubectl logs alpine
Tue Sep 25 20:28:22 UTC 2018

等效于下面的声明性代码:

# alpine-mixed.yaml
apiVersion: v1
kind: Pod
metadata:
 name: alpine
spec:
  containers:
  - name: alpine
    image: alpine
    command:
    - sh
    args:
    - -c
    - date
$ kubectl apply -f alpine-mixed.yaml
pod/alpine created

$ kubectl logs alpine
Tue Sep 25 20:30:10 UTC 2018

在实践中,如前所述,很少需要处理表 2-1 中呈现的排列,因为大多数 Docker 映像不声明麻烦的ENTRYPOINT参数,因此,习惯上简单地使用pod.spec.containers.args作为数组,其中第一个元素是命令,第二个和后面的元素是它的参数。

管理容器的 CPU 和 RAM 需求

每当我们启动一个 Pod 时,Kubernetes 都会找到一个具有足够 CPU 和 RAM 资源的节点来运行在该 Pod 中声明的容器。同样,每当 Pod 容器运行时,Kubernetes 通常不允许它接管整个节点的 CPU 和内存资源,以免损害同一节点上运行的其他容器。

如果我们不指定任何 CPU 或内存界限,Pod 的容器通常会被赋予默认值,这些值通常是使用名称空间范围的 LimitRanger 对象定义的——这超出了本书的范围。

为什么有必要对计算资源进行细粒度控制,而不是让 Kubernetes 使用默认值?因为当涉及到我们的 Kubernetes 的计算资源时,我们想要节俭。每个节点通常由整个虚拟机(或者在极端情况下甚至是物理机)支持,即使没有容器在其上运行,也必须为其提供资金。

这意味着对于一个生产系统来说,让 Kubernetes 为我们的 Pods 容器分配任意的 CPU 和内存边界并不是一个好的成本和利用率策略。例如,一个小的 C 或 Golang 应用可能需要几兆字节,而一个单一的、容器化的 Java 应用本身可能需要 1GB 以上。在第一种情况下,我们希望告诉 Kubernetes 只分配最少的所需资源:换句话说,在所有条件相同的情况下,为 C 或 Golang 应用分配比 Java 应用小得多的计算资源更好。

现在让我们切入正题,展示 Pod 清单是什么样子的,它包括 CPU 和内存的明确界限:

# nginx-limited.yaml
apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  containers:
    - image: nginx
      name: nginx
      # Compute Resource Bounds
      resources:
        requests:
          memory: "64Mi"
          cpu: "500m"
        limits:
          memory: "128Mi"
          cpu: "500m"

我们可以看到,在nginx-limited.yaml上,我们指定了两次.memory.cpu属性,一次在pod.spec.resources.requests下,然后又一次在pod.spec.resources.limits下。有什么区别?区别在于,第一个是先决条件绑定,而第二个是运行时绑定。请求定义了在 Kubernetes 部署 Pod 和相关容器之前,节点中必须可用的最低计算资源级别。限制,相反,在Kubernetes 将容器部署到一个节点之后,建立允许容器使用的最大计算资源级别。

现在让我们更详细地讨论一下 CPU 和内存界限的表达方式。

CPU 资源以 cpu 单位来衡量,这与 AWS 和 Azure 使用的数量级相同(分别是 vCPU 和 vCore)。然而,它也相当于英特尔 CPU 上的一个超线程——在这种情况下,它可能不是一个物理内核。

Kubernetes 使用的默认度量是毫微微核,值的后缀是 m 。一个毫核心(1000m)正好分配一个 CPU 单元。一些例子如表 2-2 所示。

表 2-2

示例 millipore 值的 cpu 分配

|

例子

|

结果

|

意义

|
| --- | --- | --- |
| 64m | 64/1000 = 0.064 | CPU 核心的 6.4% |
| 128 米 | 128/1000 = 0.128 | CPU 内核的 12.8% |
| 500 米 | 500/1000 = 0.5 | CPU 核心的 50% |
| 1000 米 | 1000/1000 = 1 | 恰好一个 CPU 内核 |
| 2000 米 | 2000/1000 = 2 | 正好两个 CPU 内核 |
| 2500 米 | 2500/1000 = 2.5 | 两个 CPU 内核+另一个 CPU 内核的 50% |

也允许分数,例如,0.5 的值将被解释为 500m。然而,从大多数在线例子来看,millicores 似乎是 Kubernetes 社区的首选。

现在让我们把注意力转向记忆。与 CPU 不同,内存总是定义一个绝对值,而不是相对值。内存最终以字节为单位,但通常使用更大的度量单位。可以用十进制和二进制形式指定值:见表 2-3 。

表 2-3

样本内存值的结果,以字节为单位

|

后缀

|

价值

|

例子

|

以字节为单位的示例

|
| --- | --- | --- | --- |
| 不适用的 | Five hundred and twelve | Five hundred and twelve | Five hundred and twelve |
| 千公斤 | One thousand | 128K | One hundred and twenty-eight thousand |
| 谁(kibi) | One thousand and twenty-four | 128Ki | One hundred and thirty-one thousand and seventy-two |
| 百万英镑 | 1000² | 128 米 | One hundred and twenty-eight million |
| 米(mebi) | 1024² | 128 米 | One hundred and thirty-four million two hundred and seventeen thousand seven hundred and twenty-eight |
| 千兆克 | 1000³ | 第一代 | One billion |
| Gi(如) | 1024³ | 1Gi | One billion seventy-three million seven hundred and forty-one thousand eight hundred and twenty-four |

关于请求限制的最后一点评论是,它们并不相互排斥。请求指定了容器在正常情况下运行的界限,而限制代表了最大上限。就 CPU 而言,包括 GKE 在内的大多数 Kubernetes 实现通常会对容器进行节流,但是超过限制值的内存消耗可能会导致突然终止。

注意

指定一个过于悲观的pod.spec.resources.limits值可能会导致灾难性的后果;整个舰队的吊舱可能会不断被杀死和重建,因为他们一再超过指定的上限。在决定使用哪些值之前,最好先在现实条件下对适用应用的运行时行为进行采样。

Pod 卷和卷装载

Kubernetes 中的卷是一种抽象,用于使类似 Unix 的文件系统可以从 Pod 的容器中访问。容器自己的文件系统和卷之间的主要区别在于,大多数卷类型超越了容器的生命周期。换句话说,每当容器崩溃、存在或重新启动时,写入容器文件系统的文件就会丢失。

类似于 Unix 中的mount命令,卷提供了封装实际存储机制及其位置的抽象。就容器而言,卷只是一个本地目录。但是,卷的实施和属性可能会有很大差异:

  • 它可能只是一个临时文件系统,这样单个 Pod 中的容器就可以交换数据。这样的卷类型称为emptyDir

  • 它可能是节点的文件系统中的一个目录,例如hostPath——如果 Pod 被调度到不同的节点,将无法访问该目录。

  • 可能是网络存储设备比如谷歌云存储卷(简称gcePersistentVolume)或者 NFS 服务器。

让我们从最常见、最简单的卷开始:Pod 中的临时文件系统,称为emptyDiremptyDir卷类型与 Pod 生命周期密切相关,可以使用 tmpfs(一种 RAM 支持的文件系统)来获得更快的读/写速度。这是同一 Pod 中两个或更多容器交换数据的默认卷类型。声明 Pod 卷涉及两个方面:

  1. spec.volumes下声明并命名(我们将使用data)Pod 级别的卷,并指定卷类型:在我们的例子中为emptyDir

  2. spec.containers.volumeMounts下的每个适当的容器中安装相关的卷,并在容器中指定将用于访问引用卷的路径(我们选择了/data/)

我们现在将把dataspec.volumesspec.containers.volumeMounts声明组装成一个完整的 Pod 清单文件,称为alpine-emptyDir.yaml:

# alpine-emptyDir.yaml
apiVersion: v1
kind: Pod
metadata:
 name: alpine
spec:
  volumes:
    - name: data
      emptyDir:
  containers:
  - name: alpine
    image: alpine
    args:
    - sh
    - -c
    - |
      date >> /tmp/log.txt;
      date >> /data/log.txt;
      sleep 20;
      exit 1; # exit with error
    volumeMounts:
      - mountPath: "/data"
        name: "data"

alpine-emptyDir.yaml清单将运行一个 shell 脚本,将date命令的输出记录到/tmp/log.txt/data/log.txt中。然后,它将等待 20 秒并出错退出,这将强制容器重启,除非pod.spec.restartPolicy属性被设置为Never

目标是运行 Pod 并让它“崩溃”至少两次:

$ kubectl apply -f alpine-emptyDir.yaml
pod/alpine created

$ kubectl get pod -w
NAME    READY STATUS             RESTARTS  AGE
alpine  1/1   Running            0          0s
alpine  0/1   Error              0         18s
alpine  1/1   Running            1         19s
alpine  0/1   Error              1         39s
alpine  0/1   CrashLoopBackOff   1         54s
alpine  1/1   Running            2         54s
...

在第三次重启时,我们查询/tmp/log.txt/data/log.txt:的内容

$ kubectl exec alpine -- \
    sh -c "cat /tmp/log.txt ; \
    echo "---" ; cat /data/log.txt"
Wed Sep 26 07:20:38 UTC 2018
---
Wed Sep 26 07:19:43 UTC 2018
Wed Sep 26 07:20:04 UTC 2018
Wed Sep 26 07:20:38 UTC 2018

正如所料,/tmp/log.txt只显示了日期时间戳的一个实例,而/data/log.txt显示了三个实例,尽管容器已经崩溃了三次。这是因为,如前所述,emptyDir与 POD 的生命周期息息相关。事实上,删除并重启 Pod 将会删除emptyDir,因此在重启 Pod 后立即查询/data/log.txt将只显示一个条目:

$ kubectl delete -f alpine-emptyDir.yaml
pod "alpine" deleted

$ kubectl apply -f alpine-emptyDir.yaml
pod/alpine created

$ kubectl exec alpine -- cat /data/log.txt
Wed Sep 26 11:25:09 UTC 2018

一种似乎是emptyDir卷型的更稳定的替代品是hostPath卷型。这种类型的卷会在节点的文件系统中装入一个实际目录:

# alpine-hostPath.yaml
...
spec:
  volumes:
    - name: data
      hostPath:
        path: /var/data
...

hostPath卷类型对于以只读方式访问 Kubernetes 文件(如/var/log/kube-proxy.log)很有用,但对于存储我们自己的文件来说,它不是一个好的卷类型。主要有两个原因。首先,除非我们指定一个节点选择器(在前面的几节中有更多关于标签和选择器的内容),否则 Pods 可能会被安排在任何随机的节点上运行。这意味着一个 Pod 可能最初在节点6m9k上运行,但是在删除和重建事件之后在7tck上运行:

$ kubectl get pods -o wide
NAME   READY STATUS  RESTARTS AGE IP         NODE
alpine 1/1   Running 8        23m 10.36.0.10 *-6m9k

$ kubectl delete pod/alpine
pod/alpine deleted
$ kubectl apply -f alpine-hostPath.yaml
pod/alpine created

$ kubectl get pods -o wide
NAME   READY STATUS  RESTARTS AGE IP         NODE
alpine 1/1   Running 1        23m 10.36.0.11 *-7tck

不鼓励使用hostPath存储用户文件的第二个原因是 Kubernetes 节点本身可能会因为升级、打补丁等原因而被破坏和重新创建。,对用户创建的数据的保存和/或稳定名称的使用没有任何保证。同样,一般故障也可能阻止节点恢复。在这种情况下,无论何时恢复和/或重新创建新节点,都不会保留或回收其系统卷。

外部卷和 Google 云存储

我们在上一节中看到的emptyDirhostPath卷类型仅适用于 Kubernetes 集群。前者绑定到 Pod 的生命周期,而后者绑定到节点的分配。

对于严重的长期数据持久性,我们经常需要访问完全独立于 Pod 和整个 Kubernetes 集群本身的企业级存储。一种这样的存储是 GCP 的谷歌云存储,我们在其中定义的卷被简单地称为 ?? 磁盘。

让我们继续使用gcloud command:创建一个 1GB 的磁盘

$ gcloud compute disks create my-disk --size=1GB \
    --zone=europe-west2-a
Created
NAME     ZONE            SIZE_GB  TYPE         STATUS
my-disk  europe-west2-a  1        pd-standard  READY

现在,我们在 Pod 清单中要做的就是使用pdName属性声明一个gcePersistentDisk卷类型和引用my-disk。由于磁盘是通用的块设备,我们还需要使用fsType属性指定特定的文件系统类型:

# alpine-disk.yaml
...
spec:
  volumes:
    - name: data
      gcePersistentDisk:
        pdName: my-disk
        fsType: ext4
...

除了更改卷类型之外,我们还将修改 shell 脚本,以便它能够跟踪/data/log.txt,而不是以错误结束:

# alpine-disk.yaml
...
spec:
  containers:
  - name: alpine
    image: alpine
    args:
    - sh
    - -c
    - date >> /data/log.txt; cat /data/log.txt
...

Pod 将运行一次并完成,生成一个日期条目,现在可以使用kubectl logs进行检查:

$ kubectl apply -f alpine-disk.yaml
pod/alpine created

# Wait until the pod's status is Running first

$ kubectl logs alpine
Fri Sep 28 15:26:08 UTC 2018

我们现在将删除Kubernetes 群集本身,并开始一个新的、全新的群集,以证明存储具有分离的生命周期:

$ ~/kubernetes-gcp/chp1/destroy.sh
# Wait a couple of minutes
$ ~/kubernetes-gcp/chp1/create.sh
Creating cluster...

如果我们应用alpine-disk.yaml Pod 清单,我们将看到除了刚才生成的日期条目之外,上次运行的日期条目仍然存在:

$ kubectl apply -f alpine-disk.yaml
pod/alpine created

$ kubectl logs alpine
Fri Sep 28 15:26:07 UTC 2018
Fri Sep 28 15:46:11 UTC 2018

其他云供应商也有类似的卷类型。比如 AWS 里有awsElasticBlockStorage而 Azure 里有azureDisk。Kubernetes 团队几乎在每个新版本中都不断增加对其他存储机制的支持。如需最新名单,请查看 https://kubernetes.io/docs/concepts/storage/volumes/

有关每种卷类型的设置和字段的更多信息,也可以使用kubectl explain命令,例如kubectl explain pod.spec.volumes.azureDisk

Pod 健康和生命周期

Kubernetes 能够通过一种叫做探针的机制持续监控 Pod 的健康状态。可以在两个不同的类别下声明探测:就绪存活:

  • 准备就绪 : 容器准备就绪以服务用户请求,以便 Kubernetes 可以决定是否添加从服务负载均衡器中移除Pod。

  • 活跃度 : 容器正在按照设计者的意图运行,以便 Kubernetes 可以决定容器是否“卡住”并且必须重启。**

    # Pod manifest file snippet
    spec:
      containers:
         - image: ...
           readinessProbe:
             # configuration here
           livenessProbe:
             # configuration here
    
    

readinessProbelivenessProbe下声明了相同类型的探针,其中典型的有基于命令的HTTPTCP :

  • 基于命令的 : Kubernetes 在容器内运行命令,检查结果是否成功(返回代码= 0)。

  • HTTP : Kubernetes 查询一个 HTTP URL,检查返回码是否大于等于 200 但小于 400。

  • TCP : Kubernetes 只是检查它是否设法打开了一个指定的 TCP 端口。

让我们从基于命令的探测开始。这是最容易实现的,因为它涉及到运行任意命令;只要退出状态代码为 0,容器就被认为是健康的。例如:

# Pod manifest file snippet
spec:
  containers:
    - image: ...
      livenessProbe:
        exec:
          command:
          - cat
          - /tmp/healthy
        initialDelaySeconds: 5
        periodSeconds: 5

在这个代码片段中,只有当/tmp/healthy存在时,cat /tmp/healthy才会返回退出代码 0。这种方法允许向不暴露网络接口的应用添加健康探测器,或者即使暴露了网络接口,这种接口也不能被检测以提供健康状态信息。

现在让我们来看看 HTTP 探针。最基本的 HTTP 探测只是查看 web 服务器的 HTTP 响应状态,检查它是否在 200 和 399 之间;它不要求任何特定的返回主体。任何其他代码都会导致错误。例如,下面的代码片段可以看作是运行curl -I http://localhost:8080/healthy命令并检查HTTP头的状态:

# Pod manifest file snippet
spec:
  containers:
     - image: ...
       livenessProbe:
         httpGet:
           path: /healthy
           port: 8080
         initialDelaySeconds: 5
         timeoutSeconds: 1

其他属性包括

  • host:托管 URL 的主机名;默认情况下,这是 Pod IP。

  • scheme : HTTP(默认)或 HTTPS(将跳过证书验证)。

  • httpHeaders:自定义 HTTP 头。

最后一种探针是 TCP。在最基本的配置中,TCP 探测器只是测试 TCP 端口是否可以打开。从这个意义上说,它甚至比 HTTP 探测更原始,因为它不需要特定的响应。要实现 TCP 探测,我们只需要指定tcpSocket探测类型及其port属性:

# Pod manifest file snippet
spec:
  containers:
     - image: ...
       livenessProbe:
         tcpSocket:
           port: 9090
         initialDelaySeconds: 15
         periodSeconds: 20

既然我们已经介绍了三种不同的探测类型,那么让我们来看看在就绪性和活性上下文中使用它们之间的区别,以及其他附加属性的含义,比如我们还没有讨论过的initialDelaySecondsperiodSeconds

就绪探测器和活跃度探测器的区别在于,就绪探测器告诉 Kubernetes 容器是否应该从服务对象中移除,而服务对象通常通过负载均衡器向外部消费者公开(参见第四章),而活跃度探测器告诉 Kubernetes 容器是否必须重启。从配置的角度来看,两种情况下的低级检查是相同的。只需将特定的探测命令(如httpGet)放在livenessProbereadinessProbe下即可:

# Pod manifest file snippet
spec:
  containers:
     - image: ...
       # A health check failure will result in a
       # container restart
       livenessProbe:
         httpGet:
         ...
# Pod manifest file snippet
spec:
  containers:
     - image: ...
       # A health check failure will result in the
       # container being taken off the load balancer
       readinessProbe:
         httpGet:
         ...

单个容器定义通常同时具有就绪性和活性探测。它们并不相互排斥。

在我们结束这一部分之前,剩下要讨论的是如何频繁以及在何种条件下一个探测将导致声明一个 Pod 为无响应,或者不能服务请求,分别是在活跃度和就绪上下文的情况下。使用initialDelaySecondstimeoutSecondsperiodSecondsfailureThreshold属性来实现对探测器的细粒度行为控制。例如:

# Pod manifest file snippet
spec:
  containers:
     - image: ...
       livenessProbe:
         httpGet:
           path: /healthy
           port: 8080
         initialDelaySeconds: 5
         timeoutSeconds: 1
         periodSeconds: 10
         failureThreshold: 3
         successThreshold: 1

让我们一次看一个属性:

initialDelaySeconds: 5

这里我们说,我们希望在探测开始之前至少等待五秒钟。例如,对于启动可能需要一段时间的 web 服务器,这很有用:

timeoutSeconds: 1

如果我们的服务响应有点慢,我们可能希望给它额外的时间。在这种情况下,我们在端口 8080 上的 http 服务器几乎必须立即响应(在一秒钟内):

periodSeconds: 10

我们不能每一秒钟都发垃圾邮件。我们应该做一个检查,对结果满意,然后回来做另一个测试。该属性控制探测器运行的频率:

failureThreshold: 3

我们应该仅仅因为一个错误就认为探测检查失败吗?肯定不会。此属性控制需要多少次失败才能认为容器对外部世界失败:

successThreshold: 1

这很奇怪。成功门槛有什么用?嗯,有了failureThreshold,我们可以控制需要多少个连续的故障来解释一个容器故障(无论是在就绪性还是活性上下文中)。这就像一个计数器:一次失败,两次失败,三次失败,然后…砰!。但是我们如何重置这个计数器呢?通过计数成功的探测检查。默认情况下,只需要一个成功的结果就可以将计数器重置为零,但我们可能会更悲观,等待两个或更多。

表 2-4 总结了所讨论的属性,并显示了它们的默认值和最小值。

表 2-4

探测器属性的默认值和最小值

|

探测属性名称

|

默认

|

最低限度

|
| --- | --- | --- |
| initialDelaySeconds | 不适用的 | 不适用的 |
| periodSeconds | Ten | one |
| timeoutSeconds | one | one |
| successThreshold | 1* | one |
| failureThreshold | three | one |

我们还可以看看这些属性是如何在一些关键阶段适应 Pod 生命周期的。这一分类可能有助于从另一个角度阐明它们的适用性:

  1. 容器已创建:此时,探测器还没有运行。在转换到由initialDelaySeconds属性设置的(2)之前有一个等待状态。

  2. 探测开始:这是当失败和成功计数器被设置为零并且转换到(3)时发生的。

  3. 运行探测检查:当执行特定检查(如 HTTP)时,由timeoutSeconds属性设置的超时计数器启动。如果检测到故障或超时,则转换到(4)。如果没有超时并且检测到成功状态,则转移到(5)。

  4. 失败:这种情况下,失败计数器递增,成功计数器置零。然后,有一个到(6)的过渡。

  5. 成功:在这种情况下,成功计数器递增,并转换到(6)。如果成功计数器大于或等于successThreshold属性,则失败计数器被设置为零。

  6. 确定故障:如果故障计数器大于或等于由failureThreshold属性指定的值,探测器报告故障——该动作将取决于它是就绪还是活动探测器。否则,将有一个由periodSeconds属性确定的等待状态,然后将发生到(3)的转换。

这个关于 Pod 生命周期的视图是以探针的行为为中心的。第九章对 Pod 生命周期进行了更全面的描述,有助于理解其在服务控制器和有状态服务方面的含义。

名称空间

名称空间是 Kubernetes 中的一个通用概念,而不是 Pods 的专有概念,但是在 Pods 的上下文中了解它们很方便,因为最终,Kubernetes 中的所有工作负载都位于 Pods 中,并且所有 Pods 都位于一个名称空间中。

名称空间是 Kubernetes 用来按照用户定义的标准隔离资源的机制。例如,名称空间可以隔离开发生命周期环境,如开发、测试、登台和生产。他们还可以帮助组织相关资源,而不一定打算建立一个中国墙;例如,一个名称空间可能用于将“产品目录”组件组合在一起,而另一个名称空间用于“订单履行”组件。

让我们以一种相当经验性的方式来看待名称空间。运行kubectl get pod时,似乎什么都没有,除非我们启动自己的 Pod,如alpine:

$ kubectl get pod
NAME      READY     STATUS    RESTARTS   AGE
alpine    1/1       Running   0          31m

这是一种错觉,因为大多数 Kubernetes 命令的目标是用户第一个对象运行的名称空间default。事实上,大多数 Kubernetes 命令都可以被认为隐含了标志-n default,这是--namespace=default的快捷方式:

$ kubectl get pod -n default
NAME      READY     STATUS    RESTARTS   AGE
alpine    1/1       Running   0          33m

正如我们之前提到的,Kubernetes 中几乎所有的工作负载都位于 pod 中。这不仅适用于用户创建的工件,也适用于 Kubernetes 基础设施组件。Kubernetes 自己的大部分实用程序和进程都是作为常规的 Pods 实现的,但是它们在默认情况下看起来是不可见的,因为它们恰好位于一个名为kube-system的独立名称空间中:

$ kubectl get pod -n kube-system
NAME                    READY STATUS   RESTARTS   AGE
event-exporter-*-vsmlb  2/2   Running  0          2d
fluentd-gcp-*-gz4nc     2/2   Running  0          2d
fluentd-gcp-*-lq2lx     2/2   Running  0          2d
fluentd-gcp-*-srg92     2/2   Running  0          2d
heapster-*-xwmvv        3/3   Running  0          2d
kube-dns-*-p95tp        4/4   Running  0          2d
kube-dns-*-wjzqz        4/4   Running  0          2d
...

这些都是普通的 POD。我们可以对它们运行我们目前所学的所有命令。我们只需要记住给我们使用的每个命令添加-n kube-system标志。否则 Kubernetes 会假设-n default。例如,让我们看看在名为kube-dns-*-p95tp的 Pod 中找到的第一个容器内部正在运行什么进程:

$ kubectl exec -n kube-system kube-dns-*-p95tp ps
Defaulting container name to kubedns.
PID USER TIME COMMAND
  1 root 4:35 /kube-dns --domain=cluster.local. ...
 12 root 0:00 ps

如果我们想要识别给定 Pod 被分配到的名称空间,我们可以将标志--all-namespaceskubectl get命令一起使用。例如:

$ kubectl get pod --all-namespaces
NAMESPACE    NAME                   READY STATUS
default      alpine                 1/1   Running
kube-system  event-exporter-*-vsmlb 2/2   Running
kube-system  fluentd-gcp-*-gz4nc    2/2   Running
...

请注意在这个输出中,第一列是如何标识定义每个 Pod 的名称空间的。我们还可以使用kubectl get namespaces列出现有的名称空间本身:

$ kubectl get namespace
NAME          STATUS    AGE
default       Active    2d
kube-public   Active    2d
kube-system   Active    2d

名称空间是 Kubernetes 对象之间最难的逻辑分离形式。假设我们定义了三个不同的名称空间,分别叫做ns1ns2ns3:

$ kubectl create namespace ns1
namespace/ns1 created
$ kubectl create namespace ns2
namespace/ns2 created
$ kubectl create namespace ns3
namespace/ns3 created

我们现在可以在每个名称空间中运行一个名为nginx的 Pod,而不会有任何 Pod 名称冲突:

$ kubectl run nginx --image=nginx --restart=Never \
    --namespace=ns1
pod/nginx created
$ kubectl run nginx --image=nginx --restart=Never \
    --namespace=ns2
pod/nginx created
$ kubectl run nginx --image=nginx --restart=Never \
    --namespace=ns3
pod/nginx created

$ kubectl get pod –-all-namespaces | grep nginx
ns1       nginx   1/1   Running     0    1m
ns2       nginx   1/1   Running     0    1m
ns3       nginx   1/1   Running     0    1m

标签

标签只是用户定义的(或 Kubernetes 生成的)键/值对,它们与一个 Pod(以及任何其他 Kubernetes 对象)相关联。它们对于描述元信息的小元素(键和值都限制在 63 个字符以内)非常有用,例如

  • 一系列相关对象(例如,同一 Pod 的副本)

  • 版本号

  • 环境(例如,开发、试运行、生产)

  • 部署类型(例如,canary 发布或 A/B 测试)

标签是 Kubernetes 中的一个基本概念,因为它是促进编排的机制。当多个 pod(或其他对象类型)被“编排”时,控制器对象(如部署)管理一群 pod 的方式是选择它们的标签,我们将在第三章中看到。

例如,innocent kubectl run命令为每个 Pod 添加一个名为“run”的标签,其值为 Pod 的给定的名称:

$ kubectl run nginx --image=nginx --restart=Never
pod/nginx created

$ kubectl get pods --show-labels
NAME   READY  STATUS    RESTARTS   AGE    LABELS
nginx  1/1    Running   0          44s    run=nginx

正如在这个输出中看到的,--show-labels标志显示了已经为列出的对象声明的标签。标签既可以强制设置,也可以声明设置。例如,下面的 Pod 清单将标签envauthor分别设置为prodErnie:

# nginx-labels.yaml
apiVersion: v1
kind: Pod
metadata:
  name: nginx
  labels:
    env: prod
    author: Ernie
spec:
  containers:
  - image: nginx
    name: nginx
  restartPolicy: Never

这相当于以下命令式语法:

$ kubectl run nginx --image=nginx --restart=Never \
    -l "env=prod,author=Ernie"

-l标志是--labels="<LABELS>"的快捷方式,其中<LABELS>是逗号分隔的键/值对列表。通过运行kubectl apply -f nginx-labels.yaml和使用之前看到的命令性命令,我们可以观察到两个用户定义的标签authorenv已经被设置,而不是默认的run=nginx标签:

$ kubectl get pods --show-labels
NAME  READY STATUS  AGE LABELS
nginx 1/1   Running 3m  author=Ernie,env=prod

在我们继续之前,让我们通过创建两个标签略有不同的 nginx Pods 来增加一点复杂性:

$ kubectl run nginx1 --image=nginx --restart=Never \
    -l "env=dev,author=Ernie"
pod/nginx1 created

$ kubectl run nginx2 --image=nginx --restart=Never \
    -l "env=dev,author=Mohit"
pod/nginx2 created

标签的用处不仅仅在于向离散对象添加任意元数据(目前只是 Pods,因为我们还没有涉及其他资源类型),还在于处理它们的集合。尽管标签是与模式无关的,但是认为我们是在数据库中定义列类型还是有帮助的。例如,现在我们知道了 podnginxnginx1nginx2有一个共同点,即它们分别通过envauthor标签声明了它们的环境和作者,我们可以使用-L <LABEL1,LABEL2,...>标志指定我们希望这些值被列为特定的列:

$ kubectl get pods -L env,author
NAME    READY   STATUS  RESTARTS  AGE   ENV   AUTHOR
nginx   1/1     Running 0         11m   prod  Ernie
nginx1  1/1     Running 0         6m    dev   Ernie
nginx2  1/1     Running 0         5m    dev   Mohit

如果我们不能制定诸如“给我作者是 Ernie 的对象”或“那些有caution标签的对象”这样的查询,标签就没有用了这种表达式被称为选择器表达式。第一个问题是基于等式的表达式,而第二个问题是基于集合的表达式。选择器表达式或简称为选择器在许多控制器对象的清单中声明,以将它们与它们的依赖项连接起来,但它们也可以通过-l <SELECTOR-EXPRESSION>标志强制表达,这是--selector=<SELECTOR-EXPRESSION>的快捷方式。

例如,第一个问题表述如下:

$ kubectl get pods -l author=Ernie
NAME      READY     STATUS    RESTARTS   AGE
nginx     1/1       Running   0          30m
nginx1    1/1       Running   0          24m

我们也可以否定等式表达式,并要求那些 Pods 的作者不是 Ernie:

$ kubectl get pods -l author!=Ernie
NAME      READY     STATUS    RESTARTS   AGE
nginx2    1/1       Running   0          24m

基于集合的问题是关于成员资格的。几分钟前,我们曾询问过标签名为caution的吊舱。在这种情况下,我们只需指定标签名称:

$ kubectl get pods -l caution
No resources found.

的确,我们还没有定义一个caution标签。要询问那些没有标签的对象,我们只需在标签前加上感叹号,如下所示:

$ kubectl get pods -l \!caution
NAME      READY     STATUS    RESTARTS   AGE
nginx     1/1       Running   0          37m
nginx1    1/1       Running   0          32m
nginx2    1/1       Running   0          31m

一种更高级的基于集合的选择器是我们使用<LABEL> in (<VALUE1>,<VALUE2>,...)语法测试多个值的选择器(notin用于求反)。例如,让我们列出那些作者是 Ernie 或 Mohit 的 pod:

$ kubectl get pods -l "author in (Ernie,Mohit)"
NAME      READY     STATUS    RESTARTS   AGE
nginx     1/1       Running   0          50m
nginx1    1/1       Running   0          44m
nginx2    1/1       Running   0          43m

也可以在运行时使用kubectl label <RESOURCE-TYPE>/<OBJECT-IDENTIFIER> <KEY>=<VALUE>命令更改标签。如果我们正在改变一个现有的标签,还必须添加--overwrite标志。例如:

$ kubectl label pod/nginx author=Bert --overwrite
pod/nginx labeled

$ kubectl get pods -L author
NAME   READY  STATUS    RESTARTS   AGE    AUTHOR
nginx  1/1    Running   0          51m    Bert
nginx1 1/1    Running   0          46m    Ernie
nginx2 1/1    Running   0          45m    Mohit

现在,我们可以通过请求那些作者既不是 Ernie 也不是 Mohit 的 pod 来再次运行基于集合的查询:

$ kubectl get pods -l "author notin (Ernie,Mohit)"
NAME      READY     STATUS    RESTARTS   AGE
nginx     1/1       Running   0          54m

最后,可以使用kubectl label <RESOURCE-TYPE>/<OBJECT-IDENTIFIER> <KEY>-移除标签(注意末尾的减号)。以下两条语句添加和删除nginxcaution标签:

$ kubectl label pod/nginx caution=true
pod/nginx labeled

$ kubectl label pod/nginx caution-
pod/nginx labeled

释文

注释类似于标签,因为它们是一种基于键/值的元数据。但是,它们的目的是存储不可识别、不可选择的数据—选择表达式对注释不起作用。

在大多数情况下,注释是静态的,而不是易变的元数据。它们在pod.metadata.annotations内声明:

# Pod manifest file snippet
apiVersion: v1
kind: Pod
metadata:
  name: nginx
  annotations:
    author: Michael Faraday
    e-mail: michael@faraday.com

此外,注释不像标签值那样有 63 个字符的限制。它们可能包含无结构的长字符串。

注释的检索通常是通过 JSONPath 来完成的,因为这并不是针对选择器表达式的。例如,如果author被定义为nginx上的注释字段,我们可以使用以下命令:

$ kubectl get pod/nginx -o jsonpath \
    --template="{.metadata.annotations.author}"
Michael Faraday

摘要

在本章中,我们学习了如何使用 Kubernetes Pods 启动、交互和管理容器化的应用。这包括参数的传递、数据的进出以及网络端口的暴露。然后,我们探讨了更高级的特性,比如 CPU 和 RAM 约束的设置、外部存储的安装以及使用探针进行健康检查的工具。最后,我们展示了标签和注释如何帮助标记、组织和选择窗格——以及几乎任何其他 Kubernetes 对象类型。

本章中所获得的理解足以将 Kubernetes 集群视为一台拥有大量 CPU 和 RAM 的巨型计算机,可以在其中部署整体工作负载。从这个意义上说,这一章是独立的。例如,以一种整体的方式安装一个传统的三层应用,如 WordPress——禁止在公共互联网上公开,在第四章中讨论——不需要我们在这里讨论的特性。

接下来的章节都是关于通过使用 Kubernetes 控制器来超越传统单片的特性,这些控制器为我们提供了高级功能,如高可用性、容错、服务发现、作业调度和分布式数据存储的工具。

三、部署和扩展

一个部署是一组统一管理的 Pod 实例,它们都基于相同的 Docker 映像。一个 Pod 实例被称为副本。部署控制器使用多个副本来实现高可伸缩性,通过提供比单个单体单元更多的计算能力,以及集群内高可用性,通过将流量从不健康的单元分流(在服务控制器的帮助下,我们将在第四章中看到)并在它们出现故障或阻塞时重新启动或重新创建它们。

根据这里给出的定义,部署可能只是“Pod 集群”的一个花哨名称,但“部署”实际上并不是用词不当;部署控制器的真正力量在于其实际的发布能力—向其消费者部署几乎零停机时间的新 Pod 版本(例如,使用蓝/绿或滚动更新)以及不同扩展配置之间的无缝过渡,同时保留计算资源。

本章一开始,我们将概述部署控制器、复制集控制器和 pod 之间的关系。然后,我们将学习如何启动、监视和控制部署。我们还将看到定位和引用 Kubernetes 作为指示部署的结果而创建的对象的各种方法。

一旦涵盖了部署的要点,我们将重点关注可用的部署策略,包括滚动和蓝/绿部署,以及允许在资源利用率和服务消费者影响之间实现最佳折衷的参数。

最后,我们将讨论在 Pod 级别使用 Kubernetes 的开箱即用水平 Pod 自动缩放器(HPA)和在节点级别使用 GKE 在集群创建时的自动缩放标志进行自动缩放的主题。

复制集

由于历史原因,运行副本的过程是通过一个名为 ReplicaSet 的独立组件来处理的。这个组件又替换了一个更老的组件,叫做复制控制器

为了避免任何混淆,让我们花一些时间来理解部署控制器和复制集控制器之间的关系。部署是更高级别的控制器,它管理部署转换(例如滚动更新)以及通过复制集的复制条件。并不是部署替换或者嵌入replica set 对象(Camel 大小写拼写用于指代对象名),他们只是简单地控制它;尽管鼓励用户通过部署与复制集进行交互,但复制集仍然作为离散的 Kubernetes 对象可见。

总之,在 Kubernetes 中,ReplicaSet 是一个独立的、完全合格的对象,但是不鼓励在部署之外运行 ReplicaSet,而且,只要 ReplicaSet 在部署的控制之下,所有的交互都应该由部署对象进行。

我们的第一次部署

部署是 Kubernetes 中的一个基本特性,创建一个部署比创建一个单一的单体 Pod 更容易。如果我们看一下上一章的例子,我们会注意到每一个kubectl run实例都必须带有一个--restart=Never标志。嗯,创建部署的一种“廉价”方式是简单地丢弃这个标志:

$ kubectl run nginx --image=nginx
deployment.apps/nginx created

省略--restart=Never标志具有戏剧性的效果。我们现在已经创建了两个额外的对象,而不是创建一个单元:一个部署控制器,它控制一个复制集,然后复制集反过来控制一个单元!

$ kubectl get deployment,replicaset,pod
NAME               DESIRED CURRENT UP-TO-DATE AVAIL.
deployment.*/nginx 1       1       1          1

NAME                        DESIRED   CURRENT   READY
replicaset.*/nginx-8586cf59 1         1         1

NAME                       READY  STATUS    RESTARTS
pod/nginx-8586cf59-b72sn   1/1    Running   0

虽然以这种方式创建部署很方便,但是 Kubernetes 将来会反对使用kubectl run命令创建部署。新的首选方法是通过kubectl create deployment <NAME>命令,如下所示:

$ kubectl create deployment nginx --image=nginx
deployment.apps/nginx created

目前这两个版本是等价的,但是“老方法”,通过kubectl run,仍然是大多数教科书和官方 http://kubernetes.io 网站上的例子所使用的方法;因此,建议读者暂时记住这两种方法。

注意

从 Kubernetes v1.15 开始,kubectl create <RESOURCE-TYPE>命令仍然不能很好地替代传统的kubectl run方法。例如,当 Kubernetes 团队反对通过传统的基于运行的形式创建 CronJobs(在第七章中有所涉及)命令时,他们没有包括--schedule标志。根据 GitHub 上的功能请求,这个问题在随后的版本中得到了解决。

kubectl create deployment的情况下,--replicas标志缺失。这并不意味着命令被“破坏”,但是它迫使用户采取更多的步骤来实现一个曾经只需要一个命令的目标。部署的副本数量仍然可以通过kubectl scale命令(将在下一节中介绍)或通过声明一个 JSON 片段来强制设置。

将我们的注意力转回到作为创建部署的结果而创建的对象上,给定的名称nginx现在应用于部署控制器实例,而不是 Pod。Pod 有一个随机的名字:nginx-8586cf59-b72sn。为什么 POD 现在有随机的名字是因为它们是短暂的。它们的数量可能不同;一些可能被杀死,一些新的可能被创造,等等。事实上,控制单个 Pod 的部署不是很有用。让我们通过使用--replicas=<N>标志来指定除 1(默认值)之外的副本数量:

# Kill the running Deployment first
$ kubectl delete deployment/nginx

# Specify three replicas
$ kubectl run nginx --image=nginx --replicas=3
deployment.apps/nginx created

我们现在将看到三个而不是一个 pod 在运行:

$ kubectl get pods
NAME                    READY STATUS   RESTARTS  AGE
nginx-64f497f8fd-8grlr  1/1   Running  0         39s
nginx-64f497f8fd-8svqz  1/1   Running  0         39s
nginx-64f497f8fd-b5hxn  1/1   Running  0         39s

副本的数量是动态的,可以在运行时使用kubectl scale deploy/<NAME> --replicas=<NUMBER>命令指定。例如:

$ kubectl scale deploy/nginx --replicas=5
deployment.extensions/nginx scaled

$ kubectl get pods
NAME                    READY STATUS   RESTARTS  AGE
nginx-64f497f8fd-8grlr  1/1   Running  0         5m
nginx-64f497f8fd-8svqz  1/1   Running  0         5m
nginx-64f497f8fd-b5hx   1/1   Running  0         5m
nginx-64f497f8fd-w8p6k  1/1   Running  0         1m
nginx-64f497f8fd-x7vdv  1/1   Running  0         1m7

同样,指定的 Pod 映像也是动态的,可以使用kubectl set image deploy/<NAME> <CONTAINER-NAME>=<URI>命令更改。例如:

$ kubectl set image deploy/nginx nginx=nginx:1.9.1
deployment.extensions/nginx image updated

关于列出部署的更多信息

kubectl get deployments命令显示许多列:

$ kubectl get deployments
NAME    DESIRED  CURRENT  UP-TO-DATE  AVAILABLE  AGE
nginx   5        5        5           5          10m

显示的列是指部署中的 Pod 副本的数量:

  • DESIRED:在deployment.spec.replicas中指定的目标状态

  • CURRENT:运行但不一定可用的副本数量:在deployment.status.replicas中指定

  • UP-TO-DATE:已经被更新以达到当前状态的 Pod 副本的数量:在deployment.status.updatedReplicas中指定

  • AVAILABLE:用户实际可用的副本数量:在deployment.status.availableReplicas中指定

  • AGE:部署控制器自首次创建以来已经运行了多长时间

部署清单

一个最小但完整的部署清单的例子可能会令人生畏。因此,更容易将部署清单视为一个两步过程。

第一步是定义一个 Pod 模板。Pod 模板几乎与独立 Pod 的定义相同,只是我们只填充了metadataspec部分:

# Pod Template
...
spec:
  template:
    metadata:
      labels:
        app: nginx-app # Pod label
    spec:
      containers:
      - name: nginx-container
        image: nginx:1.7.1

还需要声明一个显式的 Pod 标签键/对,因为我们需要引用 Pod。前面我们已经使用了app: nginx-app,,然后在部署规范中使用它来将控制器对象绑定到 Pod 模板:

# Deployment Spec
...
spec:
  replicas: 3        # Specify number of replicas
  selector:
    matchLabels:     # Select Pod using label
      app: nginx-app

spec:下,我们还指定了副本的数量,这相当于命令形式中使用的--replicas=<N>标志。

最后,我们将这两个定义组合成一个完整的部署清单:

# simpleDeployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-declarative
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx-app
  template:
    metadata:
      labels:
        app: nginx-app
    spec:
      containers:
      - name: nginx-container
        image: nginx:1.7.1

首先使用kubectl apply -f <FILE>命令创建这个部署:

$ kubectl apply -f simpleDeployment.yaml
deployment.apps/nginx-declarative created

效果将类似于它的命令性对应物的效果;将创建三个单元:

$ kubectl get pods
NAME                      READY STATUS  RESTARTS AGE
nginx-declarative-*-bj4wn 1/1   Running 0        3m
nginx-declarative-*-brhvw 1/1   Running 0        3m
nginx-declarative-*-tc6hv 1/1   Running 0        3m

监视和控制部署

kubectl rollout status deployment/<NAME>命令用于监控正在进行的部署。例如,假设我们为 Nginx 创建了一个新的强制性部署,并且我们想要跟踪它的进度:

$ kubectl run nginx --image=nginx --replicas=3 \
    ; kubectl rollout status deployment/nginx
deployment.apps/nginx created
Waiting for deployment "nginx" rollout to finish:
0 of 3 updated replicas are available...
Waiting for deployment "nginx" rollout to finish:
1 of 3 updated replicas are available...
Waiting for deployment "nginx" rollout to finish:
2 of 3 updated replicas are available...
deployment "nginx" successfully rolled out

简单的部署——尤其是那些不涉及对现有部署进行更新的部署——通常在几秒钟内即可执行;然而,更复杂的部署可能需要几分钟。在这种情况下,我们可能希望暂停正在进行的部署,以清理资源或执行额外的监视。

分别使用kubectl rollout pause deploy/<NAME>kubectl rollout resume deploy/<NAME>命令暂停和恢复部署。例如:

$ kubectl rollout pause deploy/nginx
deployment "nginx" paused

$ kubectl rollout resume deploy/nginx
deployment "nginx" resumed

找出部署的副本集

副本(Pod 实例)不是由部署控制器直接控制的,而是由中间媒介 ReplicaSet 控制器控制的。因此,确定哪些复制集控制器从属于给定的部署控制器通常是有用的。

这可以通过标签选择器,使用kubectl describe命令或者简单地依靠视觉匹配来实现。

让我们从标签选择器方法开始。在这种情况下,我们简单地使用kubectl get rs命令列出复制集,但是添加了--selector=<SELECTOR-EXPRESSION>标志以匹配部署清单中 Pod 的标签和选择器表达式。例如:

$ kubectl get rs --selector="run=nginx"
NAME               DESIRED   CURRENT   READY     AGE
nginx-64f497f8fd   3         3         3         2m

请注意标签run=nginx是由kubectl run命令自动添加的;在simpleDeployement.yaml,我们使用了一个自定义标签:app=nginx-app

现在,让我们考虑一下kubectl describe方法。在这里,我们只需键入kubectl describe deploy/<NAME>命令并定位OldReplicaSetsNewReplicaSet字段。例如:

$ kubectl describe deploy/nginx
...
OldReplicaSets: <none>
NewReplicaSet:  nginx-declarative-381369836
                (3/3 replicas created)
...

最后一种更简单的方法是简单地输入kubectl get rs并识别前缀是部署名称的复制集。

有时,我们可能想要找出给定复制集的父部署控制器。为此,我们可以使用kubectl describe rs/<NAME>命令并搜索Controlled By字段的值。例如:

$ kubectl describe rs/nginx-381369836
...
Controlled By:  Deployment/nginx
...

或者,如果需要更程序化的方法,我们可以使用 JSONPath:

$ kubectl get pod/nginx-381369836-g4z5r \
    -o jsonpath \
    --template="{.metadata.ownerReferences[*].name}"
nginx-381369836

找出复制体的 POD

在上一节中,我们已经看到了如何识别部署的副本集。反过来,复制集控制 POD;因此,下一个自然的问题是如何找出哪些是在给定复制集控制下的荚果。幸运的是,我们使用了之前见过的三种技术:标签选择器、kubectl describe命令和视觉匹配。

让我们从标签选择器开始。这与之前完全相同,使用了--selector=<SELECTOR-EXPRESSION>标志,除了我们向kubectl get请求类型为pod (Pod)的对象,而不是rs(复制集)。例如:

$ kubectl get pod --selector="run=nginx"
NAME                   READY  STATUS   RESTARTS  AGE
nginx-64f497f8fd-72vfm 1/1    Running  0         18m
nginx-64f497f8fd-8zdhf 1/1    Running  0         18m
nginx-64f497f8fd-skrdw 1/1    Running  0         18m

类似地,kubectl describe命令的使用包括简单地指定复制集的对象类型(rs)和名称:

$ kubectl describe rs/nginx-381369836
...
Events:
  FirstSeen LastSeen Count Message
  --------- -------- ----- -------
  55m       55m      1     Created pod: nginx-*-cv2xj
  55m       55m      1     Created pod: nginx-*-8b5z9
  55m       55m      1     Created pod: nginx-*-npkn8
...

视觉匹配技术是最简单的。我们可以通过考虑 Pod 的两个前缀字符串来计算出副本集的名称。例如,对于 Pod nginx-64f497f8fd-72vfm,它的控制复制集将是nginx-64f497f8fd:

nginx-64f497f8fd-8zdhf  1/1  Running   0     18m

最后但同样重要的是,如果我们从一个 Pod 的对象开始,并想找出它的控制对象,我们可以使用kubectl describe pod/<NAME>命令并定位控制器对象,后跟Controlled By:属性:

$ kubectl describe pod/nginx-381369836-g4z5r
...
Controlled By:  ReplicaSet/nginx-381369836
...

如果需要更程序化的方法,可以使用 JSONPath 获得相同的结果,如下所示:

$ kubectl get pod/nginx-381369836-g4z5r \
    -o jsonpath \
    --template="{.metadata.ownerReferences[*].name}"
nginx-declarative-381369836

删除部署

使用触发级联删除的kubectl delete deploy/<NAME>命令删除部署;所有子副本集和相关的 Pod 对象都将被删除:

$ kubectl delete deploy/nginx
deployment.extensions "nginx" deleted

kubectl delete命令也可以直接从清单文件中选取部署的名称,如下所示:

$ kubectl delete -f simpleDeployment.yaml
deployment.apps "nginx-declarative" deleted

可以通过添加--cascade=false标志来防止级联默认删除行为(导致所有副本集和单元被删除)。例如:

$ kubectl delete -f simpleDeployment.yaml \
    --cascade=false
deployment.apps "nginx-declarative" deleted

版本跟踪与仅扩展部署

部署可以分为两种类型:版本跟踪仅伸缩

修订跟踪部署是更改 Pod 规范的某个方面,很可能是其中声明的容器映像的数量和/或版本。仅改变副本数量(强制和声明)的部署不会触发修订。

例如,发出kubectl scale命令不会创建一个可用于撤销比例变化的修订点。返回到先前数量的副本需要再次设置先前的数量。

通过使用kubectl scale命令或者通过设置deployment.spec.replicas属性并使用kubectl apply -f <DEPLOYMENT-MANIFEST>命令应用相应的文件来强制实现扩展部署。

由于仅扩展部署完全由复制集控制器管理,因此主部署控制器不提供修订跟踪(例如,回滚功能)。客观地说,尽管这种行为相当不一致,但人们可以认为改变副本的数量不如改变映像那样重要。

部署策略

到目前为止,我们只是将部署视为一种部署多个 Pod 副本的机制,但我们没有描述如何控制这一过程:Kubernetes 应该删除所有现有的 Pod(导致停机)还是应该以更优雅的方式进行?这就是部署策略的全部内容。Kubernetes 提供了两大类部署策略:

  • Recreate: Oppenheimer 升级部署的方法:首先销毁所有东西,然后创建由新部署清单声明的副本。

  • RollingUpdate: 微调升级流程,从一次更新一个 Pod 这样的“细致”工作,一直到完全成熟的蓝绿色部署,在部署过程中,在丢弃旧的 Pod 副本之前,会建立一整套新的 Pod 副本。

使用可设置为RecreateRollingUpdatedeployment.spec.strategy.type属性配置策略。后者是默认的。在接下来的两节中,我们将详细讨论每个选项。

重新创建部署

重新创建部署实际上是终止所有现有的 pod,并根据指定的目标状态创建新的集。从这个意义上说,重新创建部署会导致停机,因为一旦 pod 的数量达到零,在创建新的 pod 之前会有一些延迟。

重新创建部署对于非生产场景非常有用,在这些场景中,我们希望尽快看到预期的更改,而不必等待滚动更新程序完成。

通常,部署类型会在部署清单中以声明方式指定:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-declarative
spec:
  replicas: 5
  strategy:
    type: Recreate # Recreate setting
...

当使用kubectl apply -f <MANIFEST>命令应用时,实际的重新创建部署将会发生。

滚动更新部署

滚动更新通常是一次更新一个 Pod(负载均衡器在后台进行相应的管理),这样用户就不会经历停机时间,并且资源(例如,节点数量)得到合理利用。

在实践中,传统的“一次一个”滚动更新部署蓝/绿部署(前面几节将详细介绍蓝/绿部署)都可以通过设置deployment.spec.rolling.Update.maxSurgedeployment.spec.rolling.Update.maxUnavailable变量使用相同的滚动更新机制来实现。我们将进一步了解如何实现这一目标。

现在,让我们考虑下面的说明:

...
spec:
  replicas: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
...

这里显示的maxSurgemaxUnavailable值允许我们调整滚动更新的性质:

  • maxSurge:该属性指定在基线(旧副本集)上的终止过程开始之前,必须为目标(新副本集)创建的单元数量。如果该值为 1,这意味着副本的数量将在部署期间保持不变,代价是 Kubernetes 在任何给定时间为一个额外的单元分配资源。

  • maxUnavailable:该属性指定在任何给定时间可能不可用的节点的最大数量。如果该数量为零,如在前面的例子中,则至少需要值为 1 的maxSurge,因为需要备用资源来保持期望副本的数量恒定。

请注意,maxUnavailablemaxSurge变量接受百分比值。例如,在这种情况下,Kubernetes 25%的额外资源将用于保证运行的副本数量不会减少:

...
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 0%
...

这个例子相当于最初讨论的例子,因为四个副本中的 25%恰好是一个副本。

更高的 MaxSurge 值的利弊

在从基线(旧)发生转换之前,较高的maxSurge设置会创建目标的更多 Pod 实例,将成为(新)复制集。因此,maxSurge号越高,用户达到基线版本的过渡期就越短——只要部署与第四章中控制的服务结合使用。

缺点是集群需要额外的资源来创建新的 pod,同时保持旧的 pod 运行。例如,假设基准复制集计算利用率为 100%,50%的maxSurge设置在迁移高峰时将正好需要 150%的计算资源。

高 MaxUnavailable 值的利弊

在理想情况下,maxUnavailable属性应该简单地设置为 0,以确保副本的数量保持不变。然而,这并不总是可能的,或者必需的

如果 Kubernetes 集群中的计算资源不足,那么maxSurge必须为 0。在这种情况下,从基线状态的转换将涉及取下一个或多个吊舱。

例如,以下设置可确保不分配额外的资源,但在部署期间的任何给定时间,至少有 75%的节点可用:

...
maxSurge: 0%
maxUnavailable: 25%
...

更高的maxUnavailable值的缺点是会减少集群的容量——包括与部署相关的副本,而不是整个 Kubernetes 集群。然而,这未必是一件坏事。部署可以在需求较低的时候执行,减少副本的数量可能不会产生明显的效果。此外,即使该值越高,可用的副本数量越少,整个过程也会更快,因为可以同时更新多个 pod。

蓝色/绿色部署

蓝/绿部署是指我们提前将整个新的部署为 Pod 集群,当准备就绪时,我们一次性将流量从基线 Pod 集群中切换出来。在服务控制器的帮助下,整个过程被透明地编排(参见第四章)。

在 Kubernetes,这并不是一个全新的策略类型;我们仍然使用RollingUpdate部署类型,但是我们将maxSurge属性设置为 100%,将maxUnavailability属性设置为 0,如下所示:

...
maxSurge: 100%
maxUnavailable: 0%
...

假设部署构成了修订变更—底层映像类型或映像版本发生了变化—Kubernetes 将分三大步骤执行蓝/绿部署:

  1. 为要成为新副本集的创建新单元,直到达到maxSurge限制;在这种情况下是 100%。

  2. 将流量重定向到新的副本集——这需要服务控制器的帮助,我们将在第四章中介绍。

  3. 终止旧副本集中的节点。

最大浪涌和最大不可用设置摘要

正如我们在前面几节中看到的,大多数部署策略包括将maxSurgemaxUnavailability属性设置为不同的值。表 3-1 总结了最典型的用例以及相关的权衡和样本值。

表 3-1

不同部署策略的适当值

|

方案

|

权衡取舍

|

maxSurge

|

maxUnavailbility

|
| --- | --- | --- | --- |
| 销毁和部署* | 容量和效用。 | Zero | 100% |
| 一次一个滚动更新 | 资源 | one | Zero |
| 一次一个滚动更新 | 容量 | Zero | one |
| 更快的滚动更新 | 资源 | 25% | Zero |
| 更快的滚动更新 | 容量 | Zero | 25% |
| 蓝色/绿色 | 资源 | 100% | Zero |

*与重新创建的部署相同

受控部署

受控展开是一种允许操作员(或等效的自动化系统)对潜在的失败展开做出反应的展开。部署可能会以多种方式失败,但在两种情况下,Kubernetes 为运营商提供了更多的控制。

第一种情况是最常见的:Kubernetes 无法更新请求的副本数量,因为没有足够的集群资源,或者因为 pod 本身无法启动—例如,当指定了不存在的容器映像时。新的计算资源或容器映像不太可能突然出现并纠正这种情况。相反,我们想要的是 Kubernetes 在设定的时间后认为部署失败,而不是让部署永远进行下去。这是通过将deployment.spec.progressDeadlineSeconds属性设置为适当的值来实现的。

例如,让我们考虑指定大量副本(30 个)的示例,假设一个小型的三节点 Kubernetes 集群计算资源不足:

# nginxDeployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  progressDeadlineSeconds: 60
  replicas: 30 # excessive number for a tiny cluster
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.1
        ports:
        - containerPort: 80

如果 60 秒后所有副本都不可用,属性将强制 Kubernetes 使部署失败:

$ kubectl apply -f nginxDeployment.yaml ; \
    kubectl rollout status deploy/nginx
deployment.apps/nginx created
Waiting for deployment "nginx" rollout to finish:
0 of 30 updated replicas are available...
Waiting for deployment "nginx" rollout to finish:
7 of 30 updated replicas are available...
Waiting for deployment "nginx" rollout to finish:
11 of 30 updated replicas are available...
error: deployment "nginx" exceeded its
progress deadline

第二个场景是部署本身成功,但是容器由于一些内部问题(比如内存泄漏、引导代码中的空指针异常等等)而失败。在受控部署中,我们可能希望部署单个副本,等待几秒钟以确保它是稳定的,然后才继续下一个副本。Kubernetes 的默认行为是并行更新所有副本,这可能不是我们想要的。

属性允许我们定义 Kubernetes 在一个 Pod 准备好之后,在处理下一个副本之前必须等待多长时间。该属性的值是出现最明显问题所需的时间和部署所需时间之间的权衡。

结合progressDeadlineSeconds属性,minReadySeconds属性有助于防止灾难性的部署,尤其是在更新现有的健康部署时。

例如,假设我们有一个名为myapp(在名为myApp1.yaml的清单中声明)的健康部署,它由三个副本组成:

# myApp1.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  labels:
    app: myapp
spec:
  progressDeadlineSeconds: 60
  minReadySeconds: 10
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: myapp
        image: busybox:1.27 # 1.27
        command: ["bin/sh"]
        args: ["-c", "sleep 999999;"]

现在让我们部署myApp1.yaml并通过确保三个 pod 的状态都是Running来检查它是否启动并运行:

$ kubectl apply -f myApp1.yaml
deployment.apps/myapp created

$ kubectl get pods -w
NAME                   READY  STATUS  RESTARTS   AGE
myapp-54785c6ddc-5zmvp 1/1    Running 0          17s
myapp-54785c6ddc-rbcv8 1/1    Running 0          17s
myapp-54785c6ddc-wlf8r 1/1    Running 0          17s

假设我们想用一个新的“有问题的”版本来更新部署,这个版本存储在一个名为myApp2.yaml的清单中:更多关于为什么有问题的信息在源代码片段之后:

# myApp2.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  labels:
    app: myapp
spec:
  progressDeadlineSeconds: 60
  minReadySeconds: 10
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: myapp
        image: busybox:1.27 # 1.27
        command: ["bin/sh"]
        args: ["-c", "sleep 5; exit 1"] # bug!

我们在前面的清单中故意引入了一个错误;应用在五秒钟后退出,并出现错误。我们现在应用新的,有问题的部署清单:

$ kubectl apply -f myApp2.yaml
deployment.apps/myapp configured

如果我们观察 pod 的状态,我们将看到一个新的副本控制器5d79979bc9将被生成,并且它将只创建一个名为nm7pv的 pod。请注意,健康的副本控制器54785c6ddc保持不变:

$ kubectl get pods -w
NAME                   STATUS            RESTARTS
myapp-54785c6ddc-5zmvp Running           0
myapp-54785c6ddc-rbcv8 Running           0
myapp-54785c6ddc-wlf8r Running           0
myapp-5d79979bc9-sgqsn Pending           0
myapp-5d79979bc9-sgqsn Pending           0
myapp-5d79979bc9-sgqsn ContainerCreating 0s
myapp-5d79979bc9-sgqsn Running           0
myapp-5d79979bc9-sgqsn Error             0
myapp-5d79979bc9-sgqsn Running           1
myapp-5d79979bc9-sgqsn Error             1
myapp-5d79979bc9-sgqsn CrashLoopBackOff  1
...

一秒钟后,pod sgqsn显示为Running,,但是因为我们已经将minReadySeconds设置为十秒钟,Kubernetes 还没有开始旋转新的 pod。不出所料,五秒钟后,Pod 出现错误,Kubernetes 继续重新启动 Pod。

由于我们也将deadlineProgressSeconds设置为 60,新的错误部署将在一段时间后过期:

$ kubectl rollout status deploy/myapp
Waiting for deployment "myapp" rollout to finish:
1 out of 3 new replicas have been updated...
Waiting for deployment "myapp" rollout to finish:
1 out of 3 new replicas have been updated...
error: deployment "myapp" exceeded its
progress deadline

最终结果将是现有的健康复制集myapp-54785c6ddc保持不变;这种行为允许我们修复失败的部署,并在不中断健康 pod 用户的情况下重试:

$ kubectl get pods
NAME                   STATUS           RESTARTS
myapp-54785c6ddc-5zmvp Running          0
myapp-54785c6ddc-rbcv8 Running          0
myapp-54785c6ddc-wlf8r Running          0
myapp-5d79979bc9-sgqsn CrashLoopBackOff 5

请注意,使用minReadySeconds属性时,deadlineProgressSeconds中指定的时间可能会稍微延长。

首次展示历史

给定部署的首次展示历史由针对其执行的修订跟踪更新组成。正如我们之前所解释的,仅缩放更新不会创建修订版,也不是部署历史的一部分。修订是允许执行回滚的机制。卷展栏的历史是一个按升序排列的修订列表,可以使用kubectl rollout history deploy/<NAME>命令进行检索。例如:

$ kubectl rollout history deploy/nginx
deployments "nginx"
REVISION        CHANGE-CAUSE
1               <none>
2               <none>

注意,CHANGE-CAUSE字段的值是<none>,因为默认情况下不记录变更命令,. Kubernetes 为每个修订跟踪部署更新分配一个递增的修订号。此外,它还可以记录命令(如kubectl applykubectl set image等)。)用于创建每个修订。要实现这种行为,只需在每个命令后添加--record标志。这将填充CHANGE-CAUSE列—依次从deployment.metadata.annotations.kubernetes.io/change-cause获得。例如:

$ kubectl run nginx --image=nginx:1.7.0 --record
deployment.apps/nginx created

$ kubectl set image deploy/nginx nginx=nginx:1.9.0 \
    --record
deployment.extensions/nginx image updated

$ kubectl rollout history deploy/nginx
deployments "nginx"
REVISION CHANGE-CAUSE
1        kubectl run nginx --image=nginx:1.7.0 ...
2        kubectl set image deploy/nginx ...

了解用于创建修订版的命令可能不足以区分修订版和其他修订版,尤其是在部署的详细信息是通过文件(声明性方法)捕获的情况下,这一点也很有用。为了获得修订版的映像和其他元数据的细节,我们应该使用kubectl rollout history --revision=<N>命令。例如:

$ kubectl rollout history deploy/nginx --revision=1
deployments "nginx" with revision #1
Pod Template:
  Labels:       pod-template-hash=4217019353
        run=nginx
  Annotations:  kubernetes.io/change-cause=kubectl
                run nginx --image=nginx --record=true
  Containers:
   nginx:
    Image:      nginx
    Port:       <none>
    Environment:        <none>
    Mounts:     <none>
  Volumes:      <none>
...

为了使修订历史中的条目数量易于管理,deployment.spec.revisionHistoryLimit有助于建立最大限制。

正在回滚部署

在 Kubernetes 中,回滚部署被称为撤销过程,它是使用kubectl rollout undo deploy/<NAME>命令执行的。撤消过程实际上创建了一个新的修订,其参数与前一个相同。通过这种方式,进一步的撤销将导致回到之前的版本。在以下示例中,我们创建了两个部署,检查了展开历史记录,执行了撤消操作,然后再次检查了展开历史记录:

$ kubectl run nginx --image=nginx:1.7.0 --record
deployment.apps/nginx created

$ kubectl set image deploy/nginx nginx=nginx:1.9.0 \
    --record
deployment.extensions/nginx image updated

$ kubectl rollout history deploy/nginx
deployments "nginx"
REVISION CHANGE-CAUSE
1        kubectl run nginx --image=nginx:1.7.0 ...
2        kubectl set image deploy/nginx ...

$ kubectl rollout undo deploy/nginx
deployment.extensions/nginx

$ kubectl rollout history deploy/nginx
deployments "nginx"
REVISION CHANGE-CAUSE
2       kubectl set image deploy/nginx ...
3       kubectl run nginx --image=nginx:1.7.0 ...

也可以回滚到特定的修订,而不是以前的修订。这是通过使用常规的撤销命令并添加--to-revision=<N>标志来实现的。例如:

$ kubectl rollout undo deploy/nginx --to-revision=2

使用0作为修订号会导致 Kubernetes 恢复到之前的版本——这相当于省略了标志。

水平吊舱自动缩放器

自动扩展是指运行时系统根据 CPU 负载等可观察指标,以无人值守的方式分配额外计算和存储资源的能力。特别是在 Kubernetes 中,当前事实上的自动缩放功能是由一种称为水平 Pod 自动缩放器(HPA)的服务提供的。水平 Pod 自动缩放器(HPA)是一个常规的 Kubernetes API 资源和控制器,它根据观察到的资源利用率以无人值守的方式管理 Pod 的副本数量。它可以被认为是一个机器人,它根据 Pod 的扩展标准(通常是平均 CPU 负载)代表人类管理员发出kubectl scale命令。

为了避免混淆,有必要理解“水平缩放”是指跨多个节点创建或删除 Pod 副本,这是在编写水平 Pod 自动缩放(HPA)服务时 Kubernetes 中正式实现的唯一一种自动缩放类型。不应将水平缩放误认为垂直缩放。垂直扩展涉及增加特定节点的计算资源(例如,RAM 和 CPU)。除了水平扩展和垂直扩展之外,实际的“其他”扩展类型是集群扩展,它调整节点(虚拟机或物理机)的数量,而不是固定节点数量内的单元数量。在本章的最后,我们提供了集群扩展的概述。

设置自动缩放

使用kubectl autoscale命令可以强制设置自动缩放。我们首先应该有一个正在运行的部署(或复制集),它将由水平 Pod 自动缩放器(HPA)控制。我们还必须指定实例的最小和最大数量,最后,还要指定作为扩展基础的 CPU 百分比。完整的命令语法如下:kubectl autoscale deploy/<NAME> --min=<N> --max=<N> --cpu-percent=<N>

例如,要在自动缩放模式下运行 Nginx,我们需要遵循以下两个步骤:

$ kubectl run nginx --image=nginx
deployment.apps/nginx created

$ kubectl autoscale deployment nginx \
    --min=1 --max=3 --cpu-percent=5
horizontalpodautoscaler.autoscaling/nginx autoscaled

CPU 百分比为 5 是故意的,以便在部署负载最小时,很容易观察到 HPA 的行为。

以声明方式设置自动扩展需要创建新的清单文件,该文件指定目标部署(或副本集)副本的最小和最大数量,以及 CPU 阈值:

# hpa.yaml
apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
  name: nginx
spec:
  maxReplicas: 3
  minReplicas: 1
  scaleTargetRef:
    kind: Deployment
    name: nginx
  targetCPUUtilizationPercentage: 5

为了运行清单,使用了kubectl apply -f <FILE>命令,但是我们必须确保在应用自动缩放清单之前已经启动并运行了部署。例如:

$ kubectl run nginx --image=nginx --replicas=1
deployment.apps/nginx created

$ kubectl apply -f hpa.yaml
horizontalpodautoscaler.autoscaling/nginx created

观察自动缩放的作用

观察自动伸缩的运行只需设置一个较低的 CPU 阈值(比如 5%),然后在运行的副本上创建一些 CPU 负载。让我们来看看这个过程的实际操作。

我们首先创建一个复制副本,并将 HPA 连接到它,如前面所示:

$ kubectl run nginx --image=nginx
deployment.apps/nginx created

$ kubectl autoscale deployment nginx \
    --min=1 --max=3 --cpu-percent=5
horizontalpodautoscaler.autoscaling/nginx autoscaled

然后,我们观察自动缩放器的行为:

$ kubectl get hpa -w
NAME  REFERENCE    TARGETS MINPODS MAXPODS REPLICAS
nginx Deployment/* 0% / 5% 1       3       1

然后,我们打开一个单独的 shell,访问部署的 Pod,并生成一个无限循环来引起一些 CPU 负载:

$ kubectl get pods
NAME                   READY STATUS    RESTARTS   AGE
nginx-4217019353-2fb1j 1/1   Running   0          27m

$ kubectl exec -it nginx-4217019353-sn1px \
    -- sh -c 'while true; do true; done'

如果我们回到观察 HPA 控制器的 shell,我们会看到它是如何增加副本数量的:

$ kubectl get hpa -w
NAME  REFERENCE    TARGETS   MINPODS MAXPODS REPLICAS
nginx Deployment/*  0% / 5%  1       3       1
nginx Deployment/* 20% / 5%  1       3       1
nginx Deployment/* 65% / 5%  1       3       2
nginx Deployment/* 80% / 5%  1       3       3
nginx Deployment/* 90% / 5%  1       3       3

如果我们中断无限循环,我们会看到副本的数量会在一段时间后减少到一个。

另一种方法是在 Nginx HTTP 服务器上生成负载。这将是一个更现实的场景,但需要一些额外的步骤来设置。首先,我们需要一个生成负载的工具,比如 ApacheBench:

$ sudo apt-get update
$ sudo apt-get install apache2-utils

然后,我们需要公开外部负载均衡器上的部署。这使用服务控制器命令—将在第四章中进一步解释:

$ kubectl expose deployment nginx \
    --type="LoadBalancer" --port=80 --target-port=80
service/nginx exposed

我们一直等到获得公共 IP 地址:

$ kubectl get service -w
NAME       TYPE         CLUSTER-IP    EXTERNAL-IP
kubernetes ClusterIP    10.59.240.1   <none>
nginx      LoadBalancer 10.59.245.138 <pending>
nginx      LoadBalancer 10.59.245.138 35.197.222.105

然后,我们向它“抛出”过多的流量,在本例中,使用 100 个单独的线程向外部 IP/端口发出 1,000,000 个请求:

$ ab -n 1000000 -c 100 http://35.197.222.105:80/
This is ApacheBench, Version 2.3
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 35.197.222.105 (be patient)

我们可以通过运行kubectl get hpa -w来观察结果。请注意,nginx 是高效的,显著的负载和快速连接同样需要将 pod 的 CPU 提升到 5%以上。

另一个需要考虑的方面是,当观察 HPA 的动作时,它不会立即做出反应。原因是 HPA 通常每 30 秒查询一次资源利用率,除非默认值已经更改,然后根据所有可用单元的最后一分钟平均值做出反应。这意味着,例如,在 30 秒的窗口中,单个 Pod 上短暂的 99% CPU 峰值可能不足以使聚合平均值超过定义的阈值。

此外,HPA 算法的实现方式避免了不稳定的缩放行为。HPA 在纵向扩展时比在横向扩展时相对更快,因为使服务可用优先于节省计算资源。

更多信息请参考 https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/

最后但同样重要的是,当不再需要 HPA 对象时,可以使用kubectl delete hpa/<NAME>命令将其处理掉。例如:

$ kubectl delete hpa/nginx
horizontalpodautoscaler.autoscaling "nginx" deleted

扩展 Kubernetes 集群本身

HPA 控制器受限于固定 Kubernetes 集群中的可用资源。它产生了新的 pod,而不是全新的虚拟机来托管 Kubernetes 节点。

GKE 支持集群式扩展;然而,它的原理与颐康保障户口不同。缩放触发不是 CPU 负载或类似的度量;当 Pods 请求当前集群节点中不可用的计算资源时,会分配新节点。同样,“向下”扩展包括将 pod 整合到更少的节点中;这可能会对那些无法正常管理意外关机的 pod 上运行的工作负载产生影响。

在 GCP 中启用集群式自动缩放的最实用的方法是在创建集群时使用gcloud container clusters create <CLUSTER-NAME> --num-nodes <NUM>(参见第一章了解出错时可能需要的其他附加标志)命令,但添加--enable-autoscaling --min-nodes <MIN> --max-nodes <MAX>作为附加参数。在下面的示例中,我们将<MIN>设置为 3,但将<MAX>设置为 6,以便在需要时可以提供两倍的标准集群容量:

$ gcloud container clusters create my-cluster \
    --num-nodes=3 --enable-autoscaling \
    --min-nodes 3 --max-nodes 6

Creating cluster my-cluster...
Cluster is being health-checked (master is healthy)
done.
Creating node pool my-pool...done.
NAME        LOCATION        MASTER_VERSION  NUM_NODES
my-cluster  europe-west2-a  1.12.8-gke.10   3

查看集群式自动扩展运行情况的最简单方法是启动具有大量副本的部署,并查看部署前后可用节点的数量:

$ kubectl get nodes
NAME                                        STATUS
gke-my-cluster-default-pool-3d996410-7307   Ready     gke-my-cluster-default-pool-3d996410-d2wz   Ready
gke-my-cluster-default-pool-3d996410-gw59   Ready

$ kubectl run nginx --image=nginx:1.9.1 --replicas=25
deployment.apps/nginx created

# After 2 minutes
$ kubectl get nodes
NAME                                        STATUS
gke-my-cluster-default-pool-3d996410-7307   Ready
gke-my-cluster-default-pool-3d996410-d2wz   Ready
gke-my-cluster-default-pool-3d996410-gw59   Ready
gke-my-cluster-default-pool-3d996410-rhnp   Ready
gke-my-cluster-default-pool-3d996410-rjnc   Ready

相反,删除部署会提示自动缩放器减少节点数量:

$ kubectl delete deploy/nginx
deployment.extensions "nginx" deleted

# After some minutes
$ kubectl get nodes
NAME                                        STATUS
gke-my-cluster-default-pool-3d996410-7307   Ready
gke-my-cluster-default-pool-3d996410-d2wz   Ready
gke-my-cluster-default-pool-3d996410-gw59   Ready

摘要

在本章中,我们学习了如何使用部署控制器使用不同的策略(例如滚动和蓝/绿部署)来扩展 pod 和发布新版本。我们还看到了如何监视和控制正在进行的部署,例如,通过暂停、恢复部署,甚至回滚到以前的版本。最后,我们学习了如何在 Pod 和节点级别设置自动伸缩机制,以实现更好的集群资源利用率。

四、服务发现

服务发现是从其他 pod 以及外部世界(互联网)中定位一个或多个 pod 的地址的能力。Kubernetes 提供了一个服务控制器来满足服务发现和连接用例,如 Pod 到 Pod、LAN 到 Pod 和 Internet 到 Pod。

服务发现是必要的,因为 pod 是易变的;在它们的生命周期中,它们可能会被创建和销毁多次,每次都会获得不同的 IP 地址。Kubernetes 的自修复和扩展特性也意味着我们通常需要虚拟 IP 地址和循环负载均衡机制,而不是特定的、类似宠物的 pod 的离散地址。

在本章中,我们将首先探讨前面提到的三种连接用例(Pod 到 Pod、LAN 到 Pod 和 Internet 到 Pod)。然后,我们将研究跨不同空间的发布服务的特性以及多个端口的公开。最后,我们将思考服务控制器如何帮助实现平稳的启动和关闭,以及零停机部署。

连接使用案例概述

服务控制器执行各种功能,但它的主要目的是跟踪 Pods 地址和端口,并将这些信息发布给感兴趣的服务消费者。服务控制器还在群集场景中提供了单一入口点—多个 Pod 副本。为了实现其目的,它使用其他 Kubernetes 服务,如kube-dnskube-proxy,这些服务反过来利用底层内核和来自操作系统的网络资源,如 iptables。

服务控制器适合各种用例,但这些是最典型的用例:

  • Pod-to-Pod : 这个场景涉及一个 Pod 连接到同一个 Kubernetes 集群中的其他 Pod。集群 IP 服务类型用于此目的;它由虚拟 IP 地址和 DNS 条目组成,可由同一集群内的所有 pod 寻址,并将在副本准备就绪和“未准备就绪”时分别从集群中添加和删除副本。

  • LAN-to-Pod : 在这种情况下,服务消费者通常位于 Kubernetes 集群之外,但位于同一个局域网(LAN)内。节点端口服务类型通常适用于满足此用例;服务控制器在每个工作节点中发布一个离散端口,该端口映射到公开的部署。

  • Internet-to-Pod : 在大多数情况下,我们会希望将至少一个部署暴露在互联网上。Kubernetes 将与 GCP 的负载均衡器进行交互,以便创建一个外部公共 IP 地址并将其路由到节点端口。这是一个负载均衡器服务类型。

还有一种特殊情况是无头服务主要与 StatefulSets 结合使用,以提供对每个单独 Pod 的 DNS 访问。这种特殊情况将在第九章中单独介绍。

值得理解的是,服务控制器提供了一个间接层——以 DNS 条目、额外的 IP 地址或端口号的形式——到拥有自己的离散 IP 地址并可以直接访问的 pod。如果我们需要的只是找出一个 Pods 的 IP 地址,我们可以使用kubectl get pods -o wide -l <LABEL>命令,其中<LABEL>将我们正在寻找的 Pods 与其他 Pods 区分开来。只有在大量 pod 运行时才需要-l标志。否则,我们可以通过它们的命名约定来区分这些 POD。

在下一个示例中,我们首先创建三个 Nginx 副本,然后找出它们的 IP 地址:

$ kubectl run nginx --image=nginx --replicas=3
deployment.apps/nginx created

$ kubectl get pods -o wide -l run=nginx
NAME            READY STATUS    RESTARTS   IP
nginx-*-5s7fb   1/1   Running   0          10.0.1.13
nginx-*-fkjx4   1/1   Running   0          10.0.0.7
nginx-*-sg9bv   1/1   Running   0          10.0.1.14

相反,如果我们想要 IP 地址本身——比方说,通过流水线将它们传送到某个程序中——我们可以通过指定 JSON 路径查询来使用编程方法:

$ kubectl get pods -o jsonpath \
   --template="{.items[*].status.podIP}" -l run=nginx
10.36.1.7 10.36.0.6 10.36.2.8

单元到单元连接用例

从外部 Pod 寻址 Pod 涉及创建一个服务对象,该服务对象将观察 Pod,以便在它们分别准备就绪和未准备就绪时,从虚拟 IP 地址(称为集群 IP )添加或删除它们。如第二章所述,容器“准备就绪”的检测可通过自定义探头实现。服务控制器可以针对各种对象,如裸 pod 和复制集,但我们将只关注部署。

第一步是创建服务控制器。创建观察现有部署的服务的命令是kubectl expose deploy/<NAME>。可选标志--name=<NAME>将赋予服务一个不同于其目标部署的名称,只有在我们没有在目标对象上指定端口的情况下,--port=<NUMBER>才是必需的。例如:

$ kubectl run nginx --image=nginx --replicas=3
deployment.apps/nginx created

$ kubectl expose deploy/nginx --port=80
service/nginx exposed

声明性方法类似,但是它需要使用标签选择器(第二章)来标识目标部署:

# service.yaml
kind: Service
apiVersion: v1
metadata:
  name: nginx
spec:
  selector:
    run: nginx
  ports:
  - protocol: TCP
    port: 80

可以在创建部署后立即应用此清单,如下所示:

$ kubectl run nginx --image=nginx --replicas=3
deployment "nginx" created

$ kubectl apply -f service.yaml
service "nginx" created

到目前为止,我们已经解决了“暴露”一个部署的问题,但是结果如何呢?我们现在能做什么以前不可能的事情?首先,让我们键入kubectl get services来查看我们刚刚创建的对象的详细信息:

$ kubectl get services
NAME       TYPE      CLUSTER-IP    EXT-IP PORT(S)
kubernetes ClusterIP 10.39.240.1   <none> 443/TCP
nginx      ClusterIP 10.39.243.143 <none> 80/TCP

IP 地址10.39.243.143现在准备好从端口80上的任何 Pod 接收进入流量。我们可以通过运行连接到此端点的虚拟一次性 Pod 来检查这一点:

$ kubectl run test --rm -i --image=alpine \
    --restart=Never \
    -- wget -O - http://10.39.243.143 | grep title
<title>Welcome to nginx!</title>

我们有一个虚拟 IP 来访问我们部署的 Pod 副本,但是 Pod 如何首先找到 IP 地址呢?好消息是 Kubernetes 为每个服务控制器创建了一个 DNS 条目。在一个简单的、单一集群、单一名称空间的场景中——就像本文中的所有例子一样——我们可以简单地使用服务名本身。例如,假设我们在另一个 Pod 中(例如,Alpine 实例),我们可以如下访问 Nginx web 服务器:

$ kubectl run test --rm -i --image=alpine \
    --restart=Never \
    -- wget -O - http://nginx | grep title
<title>Welcome to nginx!</title>

注意,我们现在用http://nginx代替http://10.39.243.143。对 Nginx 的每个 HTTP 请求将以循环方式命中三个 Pod 副本中的一个。如果我们想让自己相信事实确实如此,我们可以改变每个 Nginx Pod 的index.html内容,使其显示 Pod 的名称,而不是相同的默认欢迎页面。假设我们的三副本 Nginx 部署仍然在运行,我们可以应用建议的更改,首先提取部署的 Pod 名称,然后用$HOSTNAME的值覆盖每个 Pod 中的index.html的内容:

# Extract Pod names
$ pods=$(kubectl get pods -l run=nginx -o jsonpath \
       --template="{.items[*].metadata.name})"

# Change the contents of index.html for every Pod
$ for pod in $pods; \
    do kubectl exec -ti $pod \
           -- bash -c "echo \$HOSTNAME > \
           /usr/share/nginx/html/index.html"; \
    done

现在,我们可以再次启动临时 Pod 但是这一次,我们将循环运行对http://nginx的请求,直到我们按下 Ctrl+C:

$ kubectl run test --rm -i --image=alpine \
    --restart=Never -- \
    sh -c "while true; do wget -q -O \
    - http://nginx ; sleep 1 ; done"
nginx-dbddb74b8-t728t
nginx-dbddb74b8-h87s4
nginx-dbddb74b8-mwcg4
nginx-dbddb74b8-h87s4
nginx-dbddb74b8-h87s4
nginx-dbddb74b8-t728t
nginx-dbddb74b8-h87s4
nginx-dbddb74b8-h87s4
...

正如我们在结果输出中看到的,循环机制正在起作用,因为每个请求都落在一个随机的 Pod 上。

LAN-to-Pod 连接用例

从外部主机访问 Kubernetes 集群的 Pods 涉及到使用NodePort服务类型公开服务(默认服务类型是ClusterIP)。这只是将--type=NodePort标志添加到kubectl expose命令中的问题。例如:

$ kubectl run nginx --image=nginx --replicas=3
deployment.apps/nginx created

$ kubectl expose deploy/nginx --type="NodePort" \
    --port=80
service/nginx exposed

结果是,现在可以通过任何节点 IP 地址上的离散端口访问 Nginx HTTP 服务器。首先,让我们看看分配的端口是什么:

$ kubectl describe service/nginx | grep NodePort
NodePort:                 <unset>  30091/TCP

我们可以看到自动分配的端口是30091。我们现在可以使用外部 IP 地址,从位于同一本地区域的 Kubernetes 集群之外的一台机器,通过该端口上的任何 Kubernetes 工作节点向 Nginx 的 web 服务器发出请求:

$ kubectl get nodes -o wide
NAME                  STATUS  AGE EXTERNAL-IP
gke-*-9777d23b-9103   Ready   7h  35.189.64.73
gke-*-9777d23b-m6hk   Ready   7h  35.197.208.108
gke-*-9777d23b-r4s9   Ready   7h  35.197.192.9

$ curl -s http://35.189.64.73:30091 | grep title
<title>Welcome to nginx!</title>
$ curl -s http://35.197.208.108:30091 | grep title
<title>Welcome to nginx!</title>
$ curl -s http://35.197.192.9:30091 | grep title
<title>Welcome to nginx!</title>

注意

除非应用进一步的安全/网络设置,否则 Lan-to-Pod 示例可能无法直接在 Google Cloud Shell 中工作。这种设置超出了本书的范围。

作为本节的总结,这里提供了kubectl expose deploy/nginx --type="NodePort" --port=80命令的声明版本:

# serviceNodePort.yaml
kind: Service
apiVersion: v1
metadata:
  name: nginx
spec:
  selector:
    run: nginx
  ports:
  - protocol: TCP
    port: 80
  type: NodePort

用于 Pod 到 Pod 访问的 ClusterIP 清单之间的唯一区别是添加了属性type并将其设置为NodePort。如果未声明,该属性的值默认为ClusterIP

互联网到 Pod 连接用例

从互联网访问 pod 包括创建LoadBalancer服务类型。LoadBalancer服务类型类似于NodePort服务类型,因为它将在 Kubernetes 集群的每个节点的离散端口上发布公开的对象。不同的是,除此之外,它还会与谷歌云平台的负载均衡器进行交互,并分配一个可以将流量导向这些端口的公共 IP 地址。

以下示例创建了一个包含三个 Nginx 副本的集群,并将部署公开到 Internet。请注意,最后一个命令使用了-w标志,以便等待分配外部公共 IP 地址:

$ kubectl run nginx --image=nginx --replicas=3
deployment.apps/nginx created

$ kubectl expose deploy/nginx --type=LoadBalancer \
    --port=80
service/nginx exposed

$ kubectl get services -w
NAME  TYPE         CLUSTER-IP    EXT-IP          nginx LoadBalancer 10.39.249.178 <pending>
nginx LoadBalancer 10.39.249.178 35.189.65.215

每当负载均衡器被分配一个公共 IP 地址时,就会填充service.status.loadBalancer.ingress.ip属性——或者其他云供应商(如 AWS)的.hostname。如果我们想以编程方式捕获公共 IP 地址,我们必须做的就是等待,直到设置了这个属性。我们可以通过 Bash 中的一个 while 循环来检测这个解决方案,例如:

while [ -z $PUBLIC_IP ]; \
 do PUBLIC_IP=$(kubectl get service/nginx \
 -o jsonpath \
 --template="{.status.loadBalancer.ingress[*].ip}");\
 sleep 1; \
 done; \
 echo $PUBLIC_IP
35.189.65.215

kubectl expose deploy/nginx --type=LoadBalancer --port=80的声明性版本出现在下一个代码清单中。用于 LAN-to-Pod 用例的清单之间的唯一区别是type属性被设置为LoadBalancer。使用kubectl apply -f serviceLoadBalancer.yaml命令应用清单。在应用这个命令之前,我们可能想通过首先发出kubectl delete service/nginx命令来处理任何正在运行的冲突服务。

# serviceLoadBalancer.yaml
kind: Service
apiVersion: v1
metadata:
  name: nginx
spec:
  selector:
    run: nginx
  ports:
  - protocol: TCP
    port: 80
  type: LoadBalancer

访问不同名称空间中的服务

到目前为止,我们看到的所有例子都存在于默认的名称空间中。这就好像默认情况下每个kubectl命令都添加了-n default标志。因此,我们从来不需要关心完整的 DNS 名称。每当我们通过键入kubectl expose deploy/nginx来公开 Nginx 部署时,我们都可以从 Pods 访问结果服务,而不需要任何额外的域组件,例如,通过键入wget http://nginx

但是,如果使用了更多的名称空间,事情可能会变得棘手,了解与每个服务相关的完整 DNS 记录的形状可能会很有用。让我们假设在default名称空间中有一个nginx服务——在这种情况下,不需要指明特定的名称空间,因为这是默认名称空间——在production名称空间中有另一个名称相同的服务,如下所示:

# nginx in the default namespace

$ kubectl run nginx --image=nginx --port=80
deployment.apps/nginx created

$ kubectl expose deploy/nginx
service/nginx exposed

# nginx in the production namespace

$ kubectl create namespace production
namespace/production created

$ kubectl run nginx --image=nginx --port=80 \
    -n production
deployment.apps/nginx created

$ kubectl expose deploy/nginx -n production
service/nginx exposed

结果是两个名为nginx的服务存在于不同的名称空间中:

$ kubectl get services --all-namespaces | grep nginx
NAMESPACE  NAME  TYPE      CLUSTER-IP      PORT(S)
default    nginx ClusterIP 10.39.243.143   80/TCP
production nginx ClusterIP 10.39.244.112   80/TCP

我们是在10.39.243.143还是10.39.244.112发布 Nginx 服务将取决于请求 Pod 运行的名称空间:

$ kubectl run test --rm -ti --image=alpine \
    --restart=Never \
    -- getent hosts nginx | awk '{ print $1 }'
10.39.243.143

$ kubectl run test --rm -ti --image=alpine \
    --restart=Never \
    -n production \
    -- getent hosts nginx | awk '{ print $1 }'
10.39.244.112

当使用nginx作为主机时,default空间中的单元将连接到10.39.243.143,而production名称空间中的单元将连接到10.39.244.112。从production到达default ClusterIP 的方法是使用完整的域名,反之亦然。

默认配置使用service-name.namespace.svc.cluster.local约定,其中service-namenginx,在我们的示例中namespacedefaultproduction:

$ kubectl run test --rm -ti --image=alpine \
    --restart=Never \
    -- sh -c \
    "getent hosts nginx.default.svc.cluster.local; \
    getent hosts nginx.production.svc.cluster.local"
10.39.243.143     nginx.default.svc.cluster.local
10.39.244.112     nginx.production.svc.cluster.local

在不同的端口上公开服务

命令及其等价的声明形式将自省目标对象,并在其声明的端口上公开它。如果没有可用的端口信息,那么我们使用--port标志或service.spec.ports.port属性来指定端口。在我们前面的例子中,暴露的端口总是与实际的 Pod 的端口一致;每当公开的端口不同于发布的端口时,必须使用服务清单中的--target-port标志或service.spec.ports.targetPort属性来指定。

在下一个例子中,我们像往常一样在端口 80 上创建一个 Nginx 部署,但是在公共负载均衡器的端口8000上公开它。请注意,鉴于暴露端口和发布端口不同,我们必须使用--target-port标志指定暴露端口:

$ kubectl run nginx --image=nginx
deployment.apps/nginx created

$ kubectl expose deploy/nginx --port=8000 \
    --target-port=80 \
    --type=LoadBalancer
service/nginx exposed

$ kubectl get services -w
NAME  TYPE         EXTERNAL-IP   PORT(S)
nginx LoadBalancer <pending>     8000:31937/TCP
nginx LoadBalancer 35.189.65.99  8000:31937/TCP

结果是 Nginx 现在可以通过端口 8000 在公共互联网上访问,即使它是在 Pod 级别的端口 80 上公开的:

$ curl -s -i http://35.189.65.99:8000 | grep title
<title>Welcome to nginx!</title>

为了完整起见,这里我们给出了所呈现的kubectl expose命令的声明性等价物;使用kubectl apply -f serviceLoadBalancerMapped.yaml命令应用。我们可能需要首先通过运行kubectl delete service/nginx来删除使用命令式方法创建的服务:

# serviceLoadBalancerMapped.yaml
kind: Service
apiVersion: v1
metadata:
  name: nginx
spec:
  selector:
    run: nginx
  ports:
  - protocol: TCP
    port: 8000
    targetPort: 80
  type: LoadBalancer

暴露多个端口

一个 Pod 可以公开多个端口,因为它包含多个容器,或者因为一个容器监听多个端口。例如,web 服务器通常在端口 80 上监听常规的未加密流量,在端口 443 上监听 TLS 流量。服务清单中的spec.ports属性需要一个端口声明数组,所以我们所要做的就是将更多的元素添加到这个数组中,记住无论何时定义了两个或更多的端口,都必须给每个端口一个惟一的名称,这样它们才能被区分开来:

# serviceMultiplePorts.yaml
kind: Service
apiVersion: v1
metadata:
  name: nginx
spec:
  selector:
    run: nginx
  ports:
  - name: http  # user-defined name
    protocol: TCP
    port: 80
    targetPort: 80

  - name: https # user-defined name
    protocol: TCP
    port: 443
    targetPort: 443
  type: LoadBalancer

金丝雀释放

canary 发布背后的想法是,我们只向一部分用户公开服务的新版本,然后再向整个用户群推广,这样我们可以观察新服务的行为一段时间,直到我们确信它不存在运行时缺陷。

实现该策略的一个简单方法是创建一个服务对象,在 canary 发布期间,该服务对象在其负载均衡集群中包含一个新的 Pod“金丝雀”。例如,假设生产群集在其当前版本中包括 Pod 版本 1.0 的三个副本,我们可以包括 Pod 版本 2.0 的一个实例,以便 1/4 的流量(平均)到达新的 Pod。

这个策略的关键成分是标签和选择器,我们已经在第二章中介绍过了。我们所要做的就是为将要投入生产的 pod 添加一个标签,并在服务对象中添加一个匹配的选择器。这样,我们可以预先创建服务对象,并让 pod 声明一个标签,使它们被服务对象自动选择。这在行动中比在言语中更容易看到;让我们一步一步地遵循这个过程。

我们首先创建一个服务清单,其选择器将寻找标签prod等于true的 pod:

# myservice.yaml
kind: Service
apiVersion: v1
metadata:
  name: myservice
spec:
  selector:
    prod: "true"
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80
  type: LoadBalancer

应用清单后,我们可以保持第二个窗口打开,在该窗口中,我们将交互地查看哪些端点加入和离开集群:

$ kubectl apply -f myservice.yaml
service/myservice created

$ kubectl get endpoints/myservice -w
NAME        ENDPOINTS   AGE
myservice   <none>      29m

假设我们还没有创建任何 Pods,集群中没有端点,这可以从ENDPOINTS列下的值<none>得到证明。让我们创建由三个副本组成的“现任”v1 生产部署:

$ kubectl run v1 --image=nginx --port=80 \
    --replicas=3 --labels="prod=true"
deployment.apps/v1 created

如果我们检查让kubectl get endpoints/myservice -w运行的终端窗口,我们会注意到将添加三个新的端点。例如:

$ kubectl get endpoints/myservice -w
NAME      ENDPOINTS
myservice 10.36.2.10:80
myservice <none>
myservice 10.36.2.11:80
myservice 10.36.0.6:80,10.36.2.11:80
myservice 10.36.0.6:80,10.36.1.8:80,10.36.2.11:80

由于我们已经请求了一个外部 IP,我们可以使用curl来检查我们的 v1 服务是否可操作:

$ kubectl get service/myservice
NAME      TYPE         EXTERNAL-IP   PORT(S)
myservice LoadBalancer 35.197.192.45 80:30385/TCP

$ curl -I -s http://35.197.192.45 | grep Server
Server: nginx/1.13.8

现在是时候介绍金丝雀 POD 了。让我们创建一个 v2 部署。不同之处在于标签prod被设置为false,并且我们将使用 Apache 服务器而不是 Nginx 作为新版本的容器映像:

$ kubectl run v2 --image=httpd --port=80 \
    --replicas=3 --labels="prod=false"
deployment.apps/v2 created

截至目前,我们可以看到总共有六个 Pod 副本。-L <LABEL>显示标签的值:

$ kubectl get pods -L prod
NAME                  READY  STATUS   RESTARTS  PROD
v1-3781799777-219m3   1/1    Running  0         true
v1-3781799777-qc29z   1/1    Running  0         true
v1-3781799777-tbj4f   1/1    Running  0         true
v2-3597628489-2kl05   1/1    Running  0         false
v2-3597628489-p8jcv   1/1    Running  0         false
v2-3597628489-zc95w   1/1    Running  0         false

为了让 v2 Pods 之一进入myservice集群,我们所要做的就是相应地设置标签。这里我们选择名为v2-3597628489-2kl05的 Pod,并将其prod标签设置为true:

$ kubectl label pod/v2-3597628489-2kl05 \
    prod=true --overwrite
pod "v2-3597628489-2kl05" labeled

在标签操作之后,如果我们检查运行命令kubectl get endpoints/myservice -w的窗口,我们将看到一个额外的端点被添加。此时,如果我们反复点击公共 IP 地址,我们会注意到一些请求到达了 Apache web 服务器:

$ while true ; do curl -I -s http://35.197.192.45 \
    | grep Server ; done
Server: nginx/1.13.8
Server: nginx/1.13.8
Server: nginx/1.13.8
Server: nginx/1.13.8
Server: Apache/2.4.29 (Unix)
Server: nginx/1.13.8
Server: nginx/1.13.8
...

一旦我们对 v2 的行为感到满意,我们就可以将 v2 的其余部分投入生产。如前所示,这可以通过贴标签逐步实现;然而,此时,最好创建一个正式的部署清单,这样一旦应用,Kubernetes 就会以无缝的方式引入 v2 Pods 并淘汰 v2 Pods 请参考第三章了解有关滚动和蓝/绿部署的更多详细信息。

总结这一节,标签和选择器的组合为我们提供了向服务消费者公开哪些 pod 的灵活性。一个实际应用是金丝雀释放的仪器。

Canary 版本和不一致的版本

canary 版本可能包括内部代码增强或错误修复,但也可能向用户引入新功能。这样的新特征可能涉及视觉用户界面本身(例如,HTML 和 CSS)以及支持这样的界面的数据 API。每当是后者的情况时,每个请求可能落在任何随机 Pod 上的事实可能是有问题的。例如,第一个请求可以检索依赖于 v2 REST API 的新的 v2 AngularJS 代码,但是当第二个请求命中负载均衡器时,所选择的 Pod 可以是 v1,并且提供这种所述 API 的不正确版本。本质上,当 canary 版本引入外部变化时——无论是 UI 还是数据方面——我们通常希望用户保持相同的版本,无论是当前版本还是 canary 版本。

用户登陆同一个服务实例的技术术语是粘性会话会话相似性——后者是 Kubernetes 使用的。根据有多少数据可用于识别单个用户,有无数种实现会话相似性的方法。例如,附加到 URL 上的 cookies 或会话标识符可以用在 web 应用的场景中,但是如果接口是协议缓冲区或 Thrift 而不是 HTTP 呢?唯一能够从另一个用户中识别出一个给定用户的细节是他们的客户端 IP 地址,这正是服务对象可以用来实现这个行为的。

默认情况下,会话关联性是禁用的。在命令式上下文中实现会话亲缘关系只是简单地将--session-affinity=ClientIP标志添加到kubectl expose命令中。例如:

# Assume there is a Nginx Deployment running
$ kubectl expose deploy/nginx --type=LoadBalancer \
    --session-affinity=ClientIP
service "nginx" exposed

声明性版本包括设置service.spec.sessionAffinity属性和应用运行kubectl apply -f serviceSessionAffinity.yaml命令的清单:

# serviceSessionAffinity.yaml
kind: Service
apiVersion: v1
metadata:
  name: nginx
spec:
  sessionAffinity: ClientIP
  selector:
    run: nginx
  ports:
  - protocol: TCP
    port: 80
  type: LoadBalancer

基于 IP 的会话相似性的限制是多个用户可能共享同一个 IP 地址,这在公司和学校中是典型的情况。同样,同一个逻辑用户可能看起来来自多个 IP 地址,就像用户在家中使用他们的 Wi-Fi 宽带路由器并通过他们的智能手机使用 LTE 或类似技术观看网飞的情况一样。对于这样的场景,服务的能力是不够的;因此,最好使用具有第 7 层自检能力的服务,如入口控制器。更多信息请参考 https://kubernetes.io/docs/concepts/services-networking/ingress/

正常启动和关闭

受益于宽限启动属性的应用仅在其自举过程完成后才准备好接受用户请求,而宽限关闭意味着应用不应突然停止,从而破坏其用户或其依赖关系的完整性。换句话说,应用应该能够告诉“我已经喝了咖啡,洗了澡,我准备好工作了”以及“我今天到此为止了”。我现在要去刷牙睡觉了。”

服务控制器使用两种主要机制来决定给定的 Pod 是否应该成为其集群的一部分:Pod 的标签和 Pod 的就绪状态,这是使用就绪探测器实现的(参见第二章)。例如,在基于 Spring 的 Java 应用中,从应用启动到 Spring Boot 框架完全初始化并准备好接受 http 请求之间有几秒钟的延迟。

零停机部署

Kubernetes 的部署控制器通过向服务控制器注册新的 Pod 并以协调的方式删除旧的 Pod 来实现零停机部署,以便最终用户始终获得最少数量的 Pod 副本。如第三章所述,零停机部署可以实施为滚动部署或蓝/绿部署。

只需几个步骤就能看到零停机部署的实际效果。首先,我们创建一个常规的 Nginx 部署对象,并通过公共负载均衡器公开它。我们设置了--session-affinity=ClientIP,以便消费客户端在准备就绪后可以无缝过渡到新的升级 Pod:

$ kubectl run site --image=nginx --replicas=3
deployment.apps/site created

$ kubectl expose deploy/site \
    --port=80 --session-affinity=ClientIP \
    --type=LoadBalancer
service/site exposed

# Confirm public IP address
$ kubectl get services -w
NAME    TYPE          EXTERNAL-IP      PORT(S)
site    LoadBalancer  35.197.210.194   80:30534/TCP

然后,我们打开一个单独的终端窗口,让一个简单的 http 客户端在无限循环中运行:

$ while true ; do curl -s -I http://35.197.210.194/ \
    | grep Server; sleep 1 ; done
Server: nginx/1.17.1
Server: nginx/1.17.1
Server: nginx/1.17.1
...

现在我们所要做的就是将deploy/site转换到一个新的部署,这可以通过简单地改变它的底层容器映像来实现。让我们使用来自 Docker Hub 的 Apache HTTPD 映像:

$ kubectl set image deploy/site site=httpd
deployment.extensions/site image updated

如果我们返回到运行示例客户端的窗口,我们将看到很快迎接我们的将是 Apache HTTPD 服务器,而不是 Nginx:

Server: nginx/1.17.1
Server: nginx/1.17.1
Server: nginx/1.17.1
Server: Apache/2.4.39 (Unix)
Server: Apache/2.4.39 (Unix)
Server: Apache/2.4.39 (Unix)
...

使用kubectl get pod -w命令打开另一个平行窗口来监视 Pod 活动也很有趣,这样我们就可以观察新的 Pod 是如何启动的,旧的 Pod 是如何被终止的。

pod 的端点

在大多数情况下,服务控制器的角色是为两个或更多 pod 提供单个端点。但是,在某些情况下,我们可能希望确定服务控制器选择的那些 pod 的特定端点。

kubectl get endpoints/<SERVICE-NAME>命令直接在屏幕上显示多达三个端点,用于即时调试。例如:

$ kubectl get endpoints/nginx -o wide
NAME      ENDPOINTS
nginx     10.4.0.6:80,10.4.1.6:80,10.4.2.6:80

和我们之前看到的一样,可以使用kubectl describe service/<SERVICE-NAME>命令检索相同的信息。如果我们想要一种更程序化的方法,允许我们对端点的数量和值的变化做出反应,我们可以使用 JSONPath 查询。例如:

$ kubectl get endpoints/nginx -o jsonpath \
    --template= "{.subsets[*].addresses[*].ip}"
10.4.0.6 10.4.1.6 10.4.2.6

管理摘要

正如我们已经看到的,服务可以使用kubectl expose命令强制创建,也可以使用清单文件声明创建。使用kubectl get services命令列出服务,使用kubectl delete service/<SERVICE-NAME>命令删除服务。例如:

$ kubectl get services
NAME        TYPE         CLUSTER-IP   EXTERNAL-IP
kubernetes  ClusterIP    10.7.240.1   <none>
nginx       LoadBalancer 10.7.241.102 35.186.156.253

$ kubectl delete services/nginx
service "nginx" deleted

请注意,当服务被删除时,底层部署仍将继续运行,因为它们都有独立的生命周期。

摘要

在本章中,我们了解到服务控制器有助于在服务消费者和 pod 之间创建一个间接层,以便于工具化属性,如自修复、金丝雀释放、负载均衡、正常启动和关闭以及零停机部署。

我们讨论了具体的连接用例,如 Pod 到 Pod、LAN 到 Pod 和 Internet 到 Pod,并看到后者特别有用,因为它允许使用公共 IP 地址访问我们的应用。

我们还解释了如何使用完整的 DNS 记录来消除跨不同名称空间的冲突服务,以及在声明多个端口时命名端口的需要。

五、配置图和机密

云原生应用的一个关键原则是外部化配置。在十二因素应用方法中,这种架构属性最好由因素 III 来描述。这一因素中的一段相关文字,在 https://12factor.net/config 中写道:

十二因素应用将配置存储在环境变量中(通常简称为 env vars 或 env)。Env 变量很容易在部署之间改变,而不需要改变任何代码;与配置文件不同,它们被意外签入代码仓库的可能性很小;与定制配置文件或其他配置机制(如 Java 系统属性)不同,它们是与语言和操作系统无关的标准。

Pods 中的容器通常运行常规的 Linux 发行版——比如 Alpine——这意味着 Kubernetes 可以假设 shell 和环境变量的存在,这与对操作系统不可知的低级虚拟化平台不同。

正如 factor III 的 12-Factor App 段落所建议的,几乎所有的编程语言都可以访问环境变量,因此这无疑是一种将配置细节传递给应用的通用和可移植的方法。Kubernetes 并不局限于简单地填充环境变量;它还可以通过虚拟文件系统提供配置。它还有其他一些技巧,比如从文件中解析键/值对和混淆敏感数据的能力。

这一章分为两大部分。第一部分包括以手动方式设置环境变量,然后通过各种方法使用 ConfigMap 对象自动填充它们:文字值、清单中的硬编码值以及从文件加载的数据。还特别关注以纯文本和二进制形式存储复杂的配置数据,以及使用虚拟文件系统公开配置变量,这有助于实时配置更新。

第二部分重点介绍 Secrets,这是 ConfigMap 的姐妹功能:它支持几乎所有与 ConfigMap 相同的功能,只是它更适合于密码和其他类型的敏感数据。在 Secrets 中,还处理了 Docker 注册中心凭证的特殊情况(从需要身份验证的 Docker 注册中心提取映像时需要),以及 TLS 证书和密钥的存储。

手动设置环境变量

在命令式场景中,可以通过将--env=<NAME>=<VALUE>标志添加到kubectl run命令来定义环境变量。比如说我们有一个变量叫做mysql_host,另一个叫做ldap_host,它们的值分别是mysql1.company.comldap1.company.com;我们将以这种方式应用这些属性:

$ kubectl run my-pod --rm -i --image=alpine \
    --restart=Never \
    --env=mysql_host=mysql1.company.com \
    --env=ldap_host=ldap1.company.com \
    -- printenv | grep host
mysql_host=mysql1.company.com
ldap_host=ldap1.company.com

注意,在最后一个参数中,我们运行了printenv命令,并对包含关键字host的变量进行了 grep 处理;结果包括我们使用--env=<NAME>=<VALUE>标志传递的两个变量。

声明性版本要求我们在 Pod 清单中设置pod.spec.containers.env属性:

# podHardCodedEnv.yaml
apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  restartPolicy: Never
  containers:
    - name: alpine
      image: alpine
      args: ["sh","-c","printenv | grep host"]
      env:
        - name: mysql_host
          value: mysql1.company.com
        - name: ldap_host
          value: ldap1.company.com

运行命令kubectl apply -f podHardCodedEnv.yaml时,不会有任何终端输出。这是因为在清单的args属性中声明的printenvgrep命令将成功退出,而apply命令不会打印出 Pod 的标准输出;因此,我们需要查阅它的日志:

$ kubectl logs pod/my-pod
mysql_host=mysql1.company.com
ldap_host=ldap1.company.com

即使我们已经成功地在给出的例子中具体化了mysql_hostldap_host,我们仍然有一个相对不灵活的系统,其中需要为每个新的环境配置产生新版本的 Pod 清单。只有当环境变量是适用于所有环境的常量时,才适合在 Pod 清单中声明环境变量。在下一节中,我们将学习如何使用 ConfigMap 对象将配置从 Pod 清单中分离出来。

在 Kubernetes 中存储配置属性

Kubernetes 提供了 ConfigMap 对象,用于存储全局配置属性,这些属性与单个工作负载(如 monolithic Pods、部署、作业等)的详细信息(即配置清单)无关。基本配置映射对象由顶级名称(配置映射“名称”本身)和一组键/值对组成。通常,新的 ConfigMap 名称用于每组密切相关的属性。同样,给定的配置图可以适用于多个应用;例如,Java 和. NET 容器化应用可以使用相同的 MySQL 凭证。这种方法有助于集中管理通用配置设置。

通过使用kubectl create configmap <NAME>命令并添加与我们拥有的属性数量一样多的--from-literal=<KEY>=<VALUE>标志,以命令的方式创建一个配置映射。例如,以下命令创建了一个名为data-sources的配置映射,具有mysql_host=mysql1.company.comldap_host=ldap1.company.com属性:

$ kubectl create configmap data-sources \
    --from-literal=mysql_host=mysql1.company.com \
    --from-literal=ldap_host=ldap1.company.com
configmap/data-sources created

这个命令相当于下面显示的声明性版本。使用kubectl apply -f simpleconfigmap.yaml命令应用:

# simpleconfigmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: data-sources
data:
  mysql_host: mysql1.company.com
  ldap_host: ldap1.company.com

现在我们已经创建了一个 ConfigMap 对象,我们可以使用kubectl describe命令快速检查它的状态:

$ kubectl describe configmap/data-sources
...
Data
====
ldap_host:
----
ldap1.company.com
mysql_host:
----
mysql1.company.com

至此,我们已经成功地使用 ConfigMap 对象存储了表示为键/值对的配置数据,但是我们如何将值提取回来呢?编程方法包括使用 JSONPath 查询并将结果存储在环境变量中:

$ mysql_host=$(kubectl get configmap/data-sources \
    -o jsonpath --template="{.data.mysql_host}")
$ ldap_host=$(kubectl get configmap/data-sources \
    -o jsonpath --template="{.data.ldap_host}")

然后,我们可以使用分配的变量将配置值传递给 Pod,如下所示:

$ kubectl run my-pod --rm -i --image=alpine \
    --restart=Never \
    --env=mysql_host=$mysql_host \
    --env=ldap_host=$ldap_host \
    -- printenv | grep host
mysql_host=mysql1.company.com
ldap_host=ldap1.company.com

尽管这种方法是可行的——因为它将配置从 Pod 的清单中分离出来——但它要求我们每次运行接受配置设置的 Kubernetes 对象时都要查询 ConfigMap 相关对象。在下一节中,我们将看到如何使这个过程更有效。

自动应用配置

可以让 Pods 的容器知道它们的环境变量(和值)可以在现有的配置图中找到,这样就不需要逐个手动指定环境变量。通过使用podWithConfigMapReference.yaml清单中所示的pod.spec.containers.envFrom.configMapRef.name属性,可以在 Pod 清单中指定所需的配置映射:

# podWithConfigMapReference.yaml
apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  restartPolicy: Never
  containers:
    - name: alpine
      image: alpine
      args: ["sh","-c","printenv | grep host"]
      envFrom:
      - configMapRef:
          name: data-sources

要运行这个 Pod 清单,从零开始——假设我们事先运行了kubectl delete all --all—我们将首先通过键入kubectl apply -f simpleconfigmap.yaml来设置一个配置映射,然后使用kubectl apply -f podWithConfigMapReference.yaml简单地运行 Pod,而不需要直接与配置映射交互:

$ kubectl apply -f simpleconfigmap.yaml
configmap/data-sources created

$ kubectl apply -f podWithConfigMapReference.yaml
pod/my-pod created

可以通过检查my-pod的日志来观察结果:

$ kubectl logs pod/my-pod
mysql_host=mysql1.company.com
ldap_host=ldap1.company.com

这可能是让 Pod 的容器从 ConfigMap 中自动检索配置数据的最简单的方法,但是它有副作用,即它是不加选择的;它将设置 ConfigMap 中声明的所有环境键和值,不管它们是否相关。

让我们假设有一个名为secret_host的特殊键,其值为hushhush.company.com:

# selectiveProperties.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: data-sources
data:
  mysql_host: mysql1.company.com
  ldap_host: ldap1.company.com
  secret_host: hushhush.company.com

现在我们想以这样一种方式再次定义my-pod,它只从data-sources配置图中检索mysql_hostldap_host,而不检索 secret_host。在这种情况下,我们使用类似于适用于硬编码值的语法;我们在pod.spec.containers.env下创建一个项目数组,并使用name命名键,而不是使用value硬编码值,我们通过创建一个valueFrom对象来引用 ConfigMap 的适用键,如下所示:

...
spec:
  containers:
    - name: ...
      env:
        - name: mysql_host
          valueFrom:
            configMapKeyRef:
              name: data-sources
              key: mysql_host

再次注意,在这个代码片段中,我们使用了valueFrom而不是value,并且我们在下面设置了一个由两个属性组成的configMapKeyRef对象:name引用配置图,key引用所需的键。

名为podManifest.yaml的完整的最终 Pod 清单如下所示:

# podManifest.yaml
apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  restartPolicy: Never
  containers:
    - name: alpine
      image: alpine
      args: ["sh","-c","printenv | grep host"]
      env:
        - name: mysql_host
          valueFrom:
            configMapKeyRef:
              name: data-sources
              key: mysql_host
        - name: ldap_host
          valueFrom:
            configMapKeyRef:
              name: data-sources
              key: ldap_host

没有对secret_host,的显式引用,因此将只设置mysql_hostldap_host的值。假设我们已经定义了新版本的data-sources配置图,我们将首先通过键入kubectl delete all --all来清理环境,应用新的配置图,然后再次运行my-pod:

$ kubectl apply -f selectiveProperties.yaml
configmap/data-sources configured

$ kubectl apply -f podManifest.yaml
pod/my-pod created

不出所料,在检查my-pod的日志时,没有从配置图中提取出secret_host键:

$ kubectl logs pod/my-pod
mysql_host=mysql1.company.com
ldap_host=ldap1.company.com

将配置图的值传递给 Pod 的启动参数

有时,我们希望直接使用 ConfigMap 数据作为命令参数,而不是让容器化的应用显式地检查环境变量,就像我们在前面几节中使用printenv所做的那样。要实现这一点,首先我们必须使用pod.spec.containers.envpod.spec.containers.envFrom属性将所需的配置映射数据分配给环境变量,如前几节所述。一旦设置完成,我们可以使用$(ENV_VARIABLE_KEY)语法在 Pod 清单中的任何地方引用这些变量。

例如,假设我们想要创建一个 Pod,它的唯一目的是使用echo命令问候mysql_host。为了实现这个需求,我们通过引用$(mysql_host)变量查询语句来引用命令参数中的mysql_host变量:

# podManifestWithArgVariables.yaml
apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  restartPolicy: Never
  containers:
    - name: alpine
      image: alpine
      args:
      - /bin/sh
      - -c
      - echo Hello $(mysql_host)
      envFrom:
      - configMapRef:
          name: data-sources

在应用此 Pod 清单之前,我们必须确保删除任何其他正在运行的 Pod——通过运行kubectl delete pod --all,但保留名为data-sources的配置图:

$ kubectl apply -f podManifestWithArgVariables.yaml
pod/my-pod created

$ kubectl logs my-pod
Hello mysql1.company.com

正如所料,变量$(mysql_host)被解析为它的值mysql1.company.com

从文件加载配置图的属性

到目前为止,我们已经看到了如何将键/值对直接定义为kubectl create configmap命令的标志或者在 ConfigMap 的清单中定义。通常情况下,配置文件存储在外部系统中,如 Git 如果是这种情况,我们不希望依赖 shell 脚本来解析和转换这样的文件。幸运的是,我们可以将文件直接导入到 ConfigMap 中,额外的好处是能够使用简单的<KEY>=<VALUE>语法来表达键/值对,类似于 Java 的.properties和微软/Python 的.ini文件类型。让我们考虑名为data-sources.properties的示例文件:

# data-sources.properties
mysql_host=mysql1.company.com
ldap_host=ldap1.company.com

一旦这个文件被保存为data-sources.properties,当运行kubectl create configmap命令时,我们可以通过添加--from-env=<FILE-NAME>标志来引用它。例如:

$ kubectl create configmap data-sources \
    --from-env-file=data-sources.properties
configmap/data-sources created

请注意,由于这是一个create命令,而不是一个apply命令,我们可能首先需要通过发出kubectl delete configmap/data-sources命令删除任何先前声明的同名配置图对象。

在配置图中存储大文件

是否有些应用只需要一组简单的键/值对进行配置,有些应用可能会使用 XML、YAML 或 JSON 格式的大型文档。一个很好的例子是基于 XML 的配置文件,Spring 框架用它来定义 Java 应用中的“bean”——在 Spring Boot 出现之前,它主要依赖于注释而不是庞大的外部配置文件。

ConfigMap 服务不仅限于存储简单的键/值对;键值可以是包含换行符的长文本文档。长文本文档可以在常规 ConfigMap 本身中定义,也可以作为外部文件引用。

例如,假设我们需要在数据源配置旁边包含一个 XML 格式的地址记录。这样的记录将包含在名为configMapLongText.yaml的配置映射清单中,如下所示:

# configMapLongText.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: data-sources
data:
  mysql_host: mysql1.company.com
  ldap_host: ldap1.company.com
  address: |
    <address>
       <number>88</number>
       <street>Wood Street</street>
       <city>London</city>
       <postcode>EC2V 7RS</postcode>
    </address>

让我们应用清单:

$ kubectl apply -f configMapLongText.yaml
configmap/data-sources created

现在我们可以使用kubectl describe configmap/data-sources来确认基于 XML 的address值已经被有效地存储——除了mysql_hostldap_host:

$ kubectl describe configmap/data-sources
...
Data
====
address:
----
<address>
   <number>88</number>
   <street>Wood Street</street>
   <city>London</city>
   <postcode>EC2V 7RS</postcode>
</address>
...

另一种更灵活的方法是引用外部文件。让我们假设地址存储在名为address.xml的文件中:

<address>
  <number>88</number>
  <street>Wood Street</street>
  <city>London</city>
  <postcode>EC2V 7RS</postcode>
</address>

要引用这个文件,我们只需在使用kubectl create configmap命令时添加--from-file=<FILE>标志。例如:

$ kubectl create configmap data-sources \
    --from-file=address.xml
configmap "data-sources" created

这相当于之前看到的方法,除了这样一个事实,即address值的键现在已经变成了文件名本身(address.xml),正如我们在用kubectl describe命令检查结果时看到的:

$ kubectl describe configmap/data-sources
...
Data
====
address.xml:
----
<address>
  <number>88</number>
  <street>Wood Street</street>
  <city>London</city>
  <postcode>EC2V 7RS</postcode>
</address>

值得考虑的是,文件路径将被转换成不包括父文件夹的键。例如,/tmp/address.xml/home/ernie/address.xml都将被转换成一个名为address.xml的键。如果两者都通过单独的--from-file指令被引用,将会报告一个键冲突。

还要注意,为了简洁起见,我们没有为mysql_hostldap_host应用文字值。如果我们想要一个与声明形式完全等价的命令,我们应该添加几个--from-literal标志来包含那些属性。

到目前为止,我们已经学习了如何存储长文本文件,但是如何检索内容以便 Pods 可以使用呢?环境变量不方便,而且最初也不打算存储多行换行的长文本块。然而,我们仍然可以通过使用-e标志让echo解析换行符来检索多行文本。例如,假设我们已经应用了上一个示例中的地址 XML 文件,我们可以按如下方式检索它:

# Assuming we are inside a Pod's container
$ echo -e $address > /tmp/address.xml
$ cat /tmp/address.xml
<address>
   <number>88</number>
   <street>Wood Street</street>
   <city>London</city>
   <postcode>EC2V 7RS</postcode>
</address>

尽管这个技巧允许我们检索相对简单的 XML 文档,但是用多行文本污染 Pod 的环境变量集是不可取的,而且它还有一个主要的限制:数据是不可变的,一旦创建了 Pod,就不能再更改。在下一节中,我们将探索一种更方便的方法来将长文本文档放入 Pod 的容器中。

实时配置图更新

配置映射通常以声明的形式定义。kubectl createkubectl apply的区别在于,后者刷新(覆盖)现有匹配实例的状态——如果有的话。每当我们使用kubectl apply -f <FILE>,应用新的配置图时,我们有效地更新任何匹配的配置图实例及其配置键/值对,但是这并不意味着绑定到刷新的配置图的 pod 得到更新。这是因为到目前为止,我们看到的传播配置数据的方法是通过只设置一次的环境变量——在创建 Pod 的容器时。

通过环境变量(和/或命令参数)使配置图数据对 pod 可用有两个限制。首先,对于多行的长文本来说,这很不方便。第二个,也是最基本的一个,就是环境变量一旦被设置,就不能再被更新,除非为了重启底层的 Linux 进程而重启 Pod。Kubernetes 有一个解决方案,一举解决了这两个问题;它可以使 ConfigMap 属性作为文件在 Pod 的容器内可用,以便键作为文件名出现,值作为它们的内容出现,此外,每当更新底层 ConfigMap 时,它将刷新所述文件。ConfigMap 对象确实允许我们鱼和熊掌兼得。

让我们看看这个“配置为文件”的解决方案是如何工作的。首先,让我们再次考虑我们的data-sources ConfigMap 的清单,它包括一个名为 address 的多行属性:

# configMapLongText.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: data-sources
data:
  mysql_host: mysql1.company.com
  ldap_host: ldap1.company.com
  address: |
    <address>
       <number>88</number>
       <street>Wood Street</street>
       <city>London</city>
       <postcode>EC2V 7RS</postcode>
    </address>

data-sources配置图作为文件系统提供给 Pod 包括两个步骤。首先,我们必须在 Pod 清单中的pod.spec.volumes下定义一个卷名。在此属性下,我们指定卷的名称,在我们的示例中为my-volume,以及所述卷将链接到的配置图的名称data-sources:

...
volumes:
  - name: my-volume
    configMap:
      name: data-sources

第二步是使用我们在pod.spec.containers.volumeMounts下选择的名称(my-volume)来引用卷,从而挂载它。我们还必须指定希望卷挂载到的路径:

...
volumeMounts:
  - name: my-volume
    mountPath: /var/config
...

在我们将卷定义和卷挂载合并到最终的 Pod 清单中之前,我们还想包含一个脚本,通过发出一个ls -l /var/config命令来检查结果目录和文件结构。我们还想通过发出cat /var/config/address命令来查看特定键address的内容。

我们还说过,每当底层配置图更新时,文件都会自动刷新;我们可以通过使用inotifywait命令监视/var/config/address的变化来观察这种行为。结果脚本如下所示:

apk update;
apk add inotify-tools;
ls -l /var/config;
while true;
do cat /var/config/address;
   inotifywait -q -e modify /var/config/address;
done

该脚本以如下方式工作:前两个apk命令安装inotifywait,它是inotify-tools包的一部分,然后它显示在/var/config中找到的文件,最后,它进入一个无限循环,当文件被修改时,它显示/var/config/address的内容。

生成的 Pod 清单称为podManifestVolume.yaml,包括提供的卷和卷装载声明以及前面看到的脚本,如下所示:

# podManifestVolume.yaml
apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  restartPolicy: Never
  containers:
    - name: alpine
      image: alpine
      args:
        - sh
        - -c
        - >
          apk update;
          apk add inotify-tools;
          ls -l /var/config;
          while true;
          do cat /var/config/address;
          inotifywait -q
          -e modify /var/config/address;
          done
      volumeMounts:
        - name: my-volume
          mountPath: /var/config
  volumes:
    - name: my-volume
      configMap:
        name: data-sources

我们现在将应用configMapLongText.yaml(配置映射)和podManifestVolume.yaml(之前定义的清单):

$ kubectl apply -f configMapLongText.yaml -f podManifestVolume.yaml
configmap/data-sources created
pod/my-pod created

通过查看my-pod的日志显示ls -la /var/configcat /var/config/address的结果;

$ kubectl logs -f pod/my-pod
...
lrwxrwxrwx 1 14 Jul 8 address -> ..data/address
lrwxrwxrwx 1 16 Jul 8 ldap_host -> ..data/ldap_host
lrwxrwxrwx 1 17 Jul 8 mysql_host -> ..data/mysql_host
<address>
   <number>88</number>
   <street>Wood Street</street>
   <city>London</city>
   <postcode>EC2V 7RS</postcode>
</address>

让我们检查结果输出。ls -l /var/config命令显示每个 ConfigMap 键(addressldap_hostmysql_host)被表示为一个文件。第二个命令,cat /var/config/address,显示每个键的值现在已经成为文件的内容;在本例中,address包含一个 XML 文件。

我们现在可以观察到“配置为文件”特性对于传播配置更改是如何有用的。首先,我们将定义一个名为configMapLongText_changed.yamldata-sources的新版本,它包含一个address键的更改值:

# configMapLongText_changed.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: data-sources
data:
  mysql_host: mysql1.company.com
  ldap_host: ldap1.company.com
  address: |
    <address>
       <number>33</number>
       <street>Canada Square</street>
       <city>London</city>
       <postcode>E14 5LB</postcode>
    </address>

在应用该清单之前,我们必须确保我们离开了启动并行运行的kubectl logs -f pod/my-pod的窗口,并在一个新窗口中写入以下命令:

$ kubectl apply -f configMapLongText_changed.yaml
configmap/data-sources configured

几秒钟后,我们会注意到运行kubectl logs -f pod/my-pod的窗口显示了在configMapLongText_changed.yaml中声明的新地址:

...
<address>
   <number>33</number>
   <street>Canada Square</street>
   <city>London</city>
   <postcode>E14 5LB</postcode>
</address>

正如我们在本节中看到的,将配置作为文件提供的好处是允许包含长文本文件,并使正在运行的应用能够检测到新的配置更改。这并不意味着使用环境变量是一个低劣的解决方案。即使在配置细节不稳定的情况下,使用环境变量结合金丝雀测试方法仍然是一个好主意,在金丝雀测试方法中,只有一部分 pod(新的 pod)随着旧的 pod 逐渐退役而获得新的更改。

存储二进制数据

ConfigMap 对象被设计用来存储文本数据,因为我们通常使用一个文本友好的接口来检索它的内容,比如在 Linux shell 中的环境变量,以及分别使用kubectl get configmap/<NAME>命令和-o json-o yaml标志时的 JSON 和 YAML 输出。

因为在使用 volume 时,键值可以作为文件的内容出现,所以看起来也是一种存储 BLOB(二进制大对象)数据的实用方法,但是由于前面给出的原因,这里的直觉是误导性的。这个问题的解决方案是使用类似 base64 的 ASCII 编码机制来编码和解码我们感兴趣的任何二进制文件。例如,假设我们想要在名为binary的配置映射上存储名为logo.png的映像的内容,我们将发出以下两个命令:

$ base64 logo.png > /tmp/logo.base64
$ kubectl create configmap binary \
    --from-file=/tmp/logo.base64

然后,在 Pod 中,假设binary配置图安装在/var/config下,我们将获得原始映像,如下所示:

$ base64 -d /var/config/logo.base64 > /tmp/logo.png

自然,在 Python 或 Java 这样的编程语言中,我们更愿意使用本地库,而不是如本例所示的 shell 命令。还要注意,尽管 base64 提供了某种程度的模糊处理,但它不是一种加密形式。我们将在下一节进一步讨论这个话题。

秘密

ConfigMap 对象用于通常来自集中式 SCM 的明文、非敏感数据。对于密码和其他敏感数据,应该使用 Secret 对象。在大多数情况下,对于所有命令性和声明性用例,Secret 对象是 ConfigMap 的“插入式”替换(当以通用模式运行时),除了明文数据应该以 base64 编码并在通过环境变量和卷变得可用时自动解码这一事实。

与秘密对象相关联的安全能力正在不断改进。在撰写本文时,支持对静止秘密进行加密( https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/ ),并采取设计措施防止 pod 访问不打算与其共享的秘密。尽管如此,Secret 对象提供的安全级别不应该被认为适合于保护 Kubernetes 集群管理员的凭证,因为他们具有根访问权限。

我们现在将看看如何使用 Secret 对象存储敏感信息与使用 ConfigMap 对象存储敏感信息之间的主要区别。

配置映射和机密对象之间的差异

命令kubectl create secret generic <NAME>类似于kubectl create configmap <NAME>命令。就像它的 ConfigMap 对应物一样,它有三个标志:--from-literal用于就地值,--from-env-file用于包含多个键/值对的文件,--from-file用于大型数据文件。

我们现在将考虑上述每个用例,目的是存储mysql_user=erniemysql_pass=HushHush凭证。请注意,所有这三个版本都是等价的,并且使用相同的名称,所以如果我们在一个接一个地运行所有示例时出现类似于Error from server (AlreadyExists)的错误,我们必须运行kubectl delete secrets/my-secrets:

用例 1: --from-literal取值:

$ kubectl create secret generic my-secrets \
    --from-literal=mysql_user=ernie \
    --from-literal=mysql_pass=HushHush
secret/my-secrets created

用例 2: --from-env和一个名为mysql.properties的文件:

# secrets/mysql.properties
mysql_user=ernie
mysql_pass=HushHush
$ kubectl create secret generic my-secrets \
    --from-env-file=secrets/mysql.properties
secret/my-secrets created

用例三: --from-file:

$ echo -n ernie > mysql_user
$ echo -n HushHush > mysql_pass
$ kubectl create secret generic my-secrets \
    --from-file=mysql_user --from-file=mysql_pass
secret/my-secrets created

使用声明性清单,我们首先需要将值手动编码为 base64,如下所示:

$ echo -n ernie | base64
ZXJuaWU=
$ echo -n HushHush | base64
SHVzaEh1c2g=

然后,我们可以在清单中使用这些 base64 编码的值:

# secrets/secretManifest.yaml
apiVersion: v1
kind: Secret
metadata:
  name: my-secrets
data:
  mysql_user: ZXJuaWU=
  mysql_pass: SHVzaEh1c2g=

通常,当使用声明性清单而不是命令性形式时,我们只需使用kubectl apply -f <FILE>命令来应用清单文件:

$ kubectl apply -f secrets/secretManifest.yaml
secret/my-secrets unchanged

总的来说,我们已经看到了定义同一组凭证的四种不同方式;前三个使用适用于kubectl create secret generic命令的--from-literal--from-env-file--from-file标志,最后一个使用清单文件。在所有情况下,名为my-secrets的结果对象都是相同的——除了元数据信息和一些其他小细节:

$ kubectl get secret/my-secrets -o yaml
apiVersion: v1
data:
  mysql_pass: SHVzaEh1c2g=
  mysql_user: ZXJuaWU=
kind: Secret
...

kubectl describe命令也很有帮助,但是它不会显示 base64 值;只有它们的长度:

$ kubectl describe secret/my-secrets
...
Data
====
mysql_user:  5 bytes
mysql_pass:  8 bytes

从机密中读取属性

秘密属性在 Pod 清单中以环境变量或卷装载的形式提供,方式与 ConfigMap 类似。在大多数情况下,整体语法保持不变,除了我们使用关键字secretconfigMap本应适用的事实。

在下面的例子中,我们假设已经创建了my-secrets秘密,并且它包含了mysql_usermysql_pass密钥和值。

让我们从envForm方法开始,这是提取秘密的最简单的方法,因为它简单地使用pod.spec.containers.envFrom声明将秘密对象中声明的所有键/值对作为环境变量进行投影。这与我们在配置图的情况下所做的完全一样,除了我们必须用secretRef替换configMapRef:

# secrets/podManifestFromEnv.yaml
apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  restartPolicy: Never
  containers:
    - name: alpine
      image: alpine
      args: ["sh","-c","printenv | grep mysql"]
      envFrom:
      - secretRef:
          name: my-secrets

应用清单,然后检查 Pod 的日志揭示了秘密的价值。这表明 Kubernetes 在容器运行时内设置环境变量之前将值从 base64 解码回纯文本:

$ kubectl apply -f secrets/podManifestFromEnv.yaml
pod/my-pod created

$ kubectl logs pod/my-pod
mysql_user=ernie
mysql_pass=HushHush

另一种稍微更热情但更安全的方法是逐个指定环境变量并选择特定的属性。除了我们用secretKeyRef替换configMapKeyRef之外,这种方法与 ConfigMap 完全相同:

# secrets/podManifesSelectedEnvs.yaml
apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  restartPolicy: Never
  containers:
    - name: alpine
      image: alpine
      args: ["sh","-c","printenv | grep mysql"]
      env:
        - name: mysql_user
          valueFrom:
            secretKeyRef:
              name: my-secrets
              key: mysql_user
        - name: mysql_pass
          valueFrom:
            secretKeyRef:
              name: my-secrets
              key: mysql_pass

应用此清单的结果与前面的示例完全相同:

$ kubectl apply -f \
    secrets/podManifestSelectedEnvs.yaml
pod/my-pod created

$ kubectl logs pod/my-pod
mysql_user=ernie
mysql_pass=HushHush

现在让我们把注意力转向卷。同样,工作流程与配置映射对象的情况相同。我们首先必须在pod.spec.volumes下声明一个卷,然后在pod.spec.containers.volumeMounts将它挂载到一个给定的容器下。只有第一部分(卷的定义)与 ConfigMap 对象不同。这里有两处改动:configMap必须换成secretname必须换成secretName:

# secrets/podManifestVolume.yaml
apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  restartPolicy: Never
  containers:
    - name: alpine
      image: alpine
      args: ["sh","-c","ls -l /var/config"]
      volumeMounts:
        - name: my-volume
          mountPath: /var/config
  volumes:
    - name: my-volume
      secret: # rather than configMap
        secretName: my-secrets # rather than name

一旦应用了清单,就可以在/var/config下找到秘密属性文件。我们保留了与上一个 ConfigMap 示例中相同的挂载点,因为我们想突出相似之处。

这次我们选择了一个简单的脚本,它在运行 Pod 时简单地列出了/var/config目录的内容:

$ kubectl apply -f secrets/podManifestVolume.yaml
pod/my-pod created

$ kubectl logs pod/my-pod
total 0
lrwxrwxrwx 1 17 Jul 8 mysql_pass -> ..data/mysql_pass
lrwxrwxrwx 1 17 Jul 8 mysql_user -> ..data/mysql_user

变更传播的属性仍然存在于 Secrets 中,并且以与 ConfigMaps 中相同的方式工作。为了简洁起见,我们在这里不重复这个例子。

Docker 注册表凭据

到目前为止,我们已经看到了 Secret 对象使用所谓的通用模式。Docker 注册表凭证的处理是其特意设计的扩展之一,有助于提取 Docker 映像,而不需要在 Pod 清单中显式指定凭证,并且缺乏安全性。

在本文的例子中,比如那些涉及 Alpine 或 Nginx 映像的例子,我们一直在处理公共 Docker 注册中心(比如 Docker Hub),所以不需要关心凭证。然而,每当需要私有 Docker 存储库时,我们需要提供正确的用户名、密码和电子邮件地址,然后才能提取映像。

这个过程相当简单,因为它只涉及创建一个保存 Docker 注册中心凭证的秘密对象,我们只需要在适用的 Pod 清单中引用该对象。

使用kubectl create docker-registry <NAME>命令以及每个凭证组件的特定标志创建 Docker 注册表机密:

  • --docker-server=<HOST>:服务器的主机

  • --docker-username=<USER_ID>:用户名

  • --docker-password=<USER_PASS>:密码,未加密

  • --docker-email=<USER_EMAIL>:用户的电子邮件地址

以下是为 Docker Hub 的名为docker-hub-secret的私有存储库创建秘密的示例:

$ kubectl create secret docker-registry docker-hub-secret \
    --docker-server=docker.io \
    --docker-username=egarbarino \
    --docker-password=HushHush \
    --docker-email=antispam@garba.org
secret/docker-hub-secret created

现在,我们可以在pod.spec.imagePullSecrets.name下的 Pod 清单中引用docker-hub-secret。在下一个例子中,我们引用存储在位于docker.io/egarbarino/hello-image的 Docker Hub 中的映像:

# secrets-docker/podFromPrivate.yaml
apiVersion: v1
kind: Pod
metadata:
  name: hello-app
spec:
  containers:
    - name: hello-app
      image: docker.io/egarbarino/hello-image
  imagePullSecrets:
    - name: docker-hub-secret
$ kubectl apply -f secrets-docker/podFromPrivate.yaml
pod/hello-app created

Docker 镜像是一个监听端口 80 的 Flask (Python)应用。如果 Docker 凭证成功,我们应该能够通过在本地主机和hello-app Pod 之间建立隧道来连接到它:

$ kubectl port-forward pod/hello-app 8888:80 \
    > /dev/null &
[1] 5052

$ curl http://localhost:8888
Hello World | Host: hello-app | Hits: 1

读者应该提供他们自己的 Docker Hub 凭证——作者的密码显然不是HushHush。同样值得注意的是,Docker 注册中心证书是使用简单的 base64 编码存储的,这不是一种加密形式,仅仅是混淆。可以通过查询秘密对象并解码结果来检索凭证:

$ kubectl get secret/docker-hub-secret \
    -o jsonpath \
    --template="{.data.\.dockerconfigjson}" \
    | base64 -d
{
   "auths":{
      "docker.io":{
         "username":"egarbarino",
         "password":"HushHush",
         "email":"antispam@garba.org",
         "auth":"ZWdhcmJhcmlubzpUZXN0aW5nJDEyMw=="
      }
   }
}

TLS 公钥对

除了一般的(用户定义的)秘密和 Docker 注册表凭证,秘密对象还具有存储 TLS 公共/密钥对的特殊规定,以便它们可以被诸如第 7 层(http/https)代理入口 ( https://kubernetes.io/docs/concepts/services-networking/ingress/ )之类的对象引用。请注意,在撰写本文时,入口控制器仍处于测试阶段,本文并未涉及。

使用kubectl create secret tls <NAME>命令和以下两个标志存储公钥/私钥对:

  • --cert=<FILE> : PEM 编码的公钥证书。它通常有一个.crt扩展名。

  • --key=<FILE>:私钥。它通常有一个.key扩展名。

假设我们在secrets-tls目录中有文件tls.crttls.key,下面的命令将把它们存储在秘密对象中:

$ kubectl create secret tls my-tls-secret \
    --cert=secrets-tls/tls.crt \
    --key=secrets-tls/tls.key
secret/my-tls-secret created

得到的对象与一般的或 docker-registry 机密没有什么不同。这些文件使用 base64 编码,可以通过查询产生的 Secret 对象轻松地检索和解码。在下一个例子中,我们检索内容,解码它们,并与原始内容进行比较;diff命令没有输出,这意味着两个文件是相同的:

$ kubectl get secret/my-tls-secret \
    --output="jsonpath={.data.tls\.crt}" \
    | base64 -d > /tmp/recovered.crt
$ kubectl get secret/my-tls-secret \
    --output="jsonpath={.data.tls\.key}" \
    | base64 -d > /tmp/recovered.key

$ diff secrets-tls/tls.crt /tmp/recovered.crt
$ diff secrets-tls/tls.key /tmp/recovered.key

管理摘要

常规的 ConfigMap 和 Secret 对象都响应典型的kubectl get <RESOURCE-TYPE>/<OBJECT-IDENTIFIER>kubectl delete <RESOURCE-TYPE>/<OBJECT-IDENTIFIER>命令,分别用于列出和删除。

这是一个列出现有 ConfigMap 对象并删除名为data-sources的对象的示例:

$ kubectl get configmap
NAME                    DATA      AGE
data-sources            1         58s
language-translations   1         34s

$ kubectl delete configmap/data-sources
configmap "data-sources" deleted

同样,这是一个列出现有秘密对象并删除名为my-secrets的对象的示例:

$ kubectl get secret
NAME              TYPE                            DATA
*-token-c4bdr     kubernetes.io/service-*-token   3
docker-hub-secret kubernetes.io/dockerconfigjson  1
my-secrets        Opaque                          2
my-tls-secret     kubernetes.io/tls               2

$ kubectl delete secret/my-secrets
secret "my-secrets" deleted

摘要

本章展示了如何通过使用环境变量标志手动设置配置,以及通过 ConfigMap 和 Secret 对象自动设置配置,从而将配置外部化,使其不被硬编码到应用中。

我们了解到配置图和秘密对象是相似的。它们都有助于填充配置属性(使用标志、清单文件和包含键/值对的外部文件)以及将所述属性注入到 Pods 的容器中(将所有数据作为环境变量投影,选择特定的变量,使变量作为虚拟文件系统可用)。我们还探讨了如何处理文本和二进制形式的长文件,以及如何生成实时配置更新。

最后,我们看到 Secrets 对象还具有存储 Docker 注册表凭证的特殊能力,这些凭证对于从私有存储库中提取 Docker 映像和存储 TLS 键非常有用,TLS 键可以被支持 TLS 的对象(如入口控制器)获取。

六、作业

批处理与持久性应用(如 web 服务器)的不同之处在于,一旦程序达到目标,它们就会完成并终止。批处理的典型示例包括数据库迁移脚本、视频编码作业和提取-加载-转换(ETL)操作手册。与 ReplicaSet 控制器类似,Kubernetes 有一个专用的 Job 控制器,用于管理 pod 以运行面向批处理的工作负载。

使用作业来检测批处理过程相对简单,我们将在这一简短的章节中很快了解到这一点。在第一部分,我们将学习完成并行的概念,这是决定作业动态的两个基本变量。然后,我们将探索可以使用作业控制器实现的三种基本批处理类型:单个批处理基于完成计数的批处理外部协调批处理。最后,在接近尾声时,我们将看看与作业处理相关的典型管理任务:确定作业何时完成,用适当的超时阈值配置作业,以及在不处理作业结果的情况下删除作业。

完成和并行

Kubernetes 依靠标准的 Unix 退出代码来判断一个 Pod 是否成功地完成了它的任务。如果一个 Pod 的容器进入过程通过返回退出代码0而结束,则认为该 Pod 已经成功地完成了它的目标。否则,如果代码不为零,则认为 Pod 出现故障。

作业控制器使用两个关键概念来编排作业:完成并行。使用job.spec.completions属性指定的完成决定了作业必须运行的次数,并以成功退出代码退出—换句话说,0。相反,使用job.spec.parallelism属性指定的并行性设置了可以并行运行的作业数量——换句话说,就是并发运行。

这两个属性值的组合将决定为实现给定完成数而创建的个唯一 pod的数量,以及可能加速旋转的个并行 pod(并行运行的 pod)的数量。表 6-1 提供了一组样本组合的结果。

表 6-1

各种完成和平行排列的效果

|

completions

|

parallelism

|

独特的 POD

|

平行吊舱

|
| --- | --- | --- | --- |
| 未设置 | 未设置 | one | one |
| one | 未设置 | one | one |
| one | one | one | one |
| three | one | three | one |
| three | three | three | three |
| one | three | one | one |
| 未设置 | three | three | three |

现在没有必要花费太多的精力来计算出表 6-1 中给出的组合。在接下来的章节中,我们将学习completionsparallelism在实际用例中的正确用法。

批处理类型概述

批处理可以分为三种类型:单个批处理、基于完成计数的批处理和外部协调的批处理:

  • 单一批处理:成功运行一次 Pod 就足以完成工作。

  • 基于完成计数的批处理:一个 n 数量的 pod 必须成功完成才能完成任务——并行、顺序或两者结合。

  • 外部协调批处理:一群工人在一个集中协调的任务上工作。所需完成的数量事先并不知道。

如前一节所述,成功的定义是 Pod 的容器流程终止,产生值为0的退出状态代码。

单批次过程

使用计划成功运行一次的 Pod 来实现单个批处理过程。这种作业可以以命令和声明的方式运行。使用kubectl create job <NAME> --image=<IMAGE>命令创建一个任务。让我们考虑一个计算两个时间表的 Bash 脚本:

for i in $(seq 10)
  do echo $(($i*2))
done

我们可以将这个脚本作为一个作业以命令的方式运行,或者通过将它作为参数传递给alpinesh命令:

$ kubectl create job two-times --image=alpine \
    -- sh -c \
    "for i in \$(seq 10);do echo \$((\$i*2));done"
job.batch/two-times created

注意

也可以使用traditional kubectl run <NAME> --image=<IMAGE> --restart=OnFailure命令创建作业。这种传统形式现在已被否决;使用kubectl run创建作业时,添加标志--restart=OnFailure必不可少;如果省略,将改为创建部署。

可以通过运行kubectl logs -l job-name=<NAME>命令来获得结果,该命令会将窗格(本例中只有一个)与作业名称的标签进行匹配。作业控制器将添加值job-name并将其设置为自己的名称,以便于识别作业和 pod 之间的父子关系:

$ kubectl logs -l job-name=two-times
2
4
6
8
10
12
14
16
18
20

我们刚刚运行了我们的第一个任务。尽管这一偶数序列可能看起来很简单,但应该理解,作为作业装备的工作负载可以像该示例一样简单,也可以像针对 SQL 数据库运行查询或训练机器学习算法的代码一样复杂。

需要记住的一个方面是,单批次过程不是“一劳永逸”的;它们将资源留在周围,必须手动清理,我们将在后面看到。事实上,同一个作业名不能使用两次,除非我们先删除第一个实例。

注意

我们可能需要添加--tail=-1标志来显示所有结果,只要我们看起来缺少行。当使用标签选择器时,如two-times.yaml的情况,行数限制为 10。

使用kubectl get jobs命令列出可用的作业,当考虑COMPLETIONS列下的值1/1时,将会发现一个预期完成,而一个实际上已存档:

$ kubectl get jobs
NAME        COMPLETIONS   DURATION   AGE
two-times   1/1           3s         9m6sm

当我们键入kubectl get pods时,作业控制的单元可以通过作业的前缀来识别,在本例中为two-times:

$ kubectl get pods
NAME             READY   STATUS      RESTARTS   AGE
two-times-4lrp8  0/1     Completed   0          8m45s

接下来显示了该作业的声明性等效项,它是通过使用kubectl apply -f two-times.yaml命令运行的:

# two-times.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: two-times
spec:
  template:
    spec:
      containers:
      - command:
        - /bin/sh
        - -c
        - for i in $(seq 10);do echo $(($i*2));done
        image: alpine
        name: two-times
      restartPolicy: Never

请注意,job.spec.completionsjob.spec.paralellism属性不存在;它们都将被赋予默认值1

还要记住,除非我们使用kubectl delete job/<NAME>命令明确删除,否则作业控制器及其完成的 Pod 都不会被删除。如果我们想在尝试了具有相同作业名称的命令式表单后立即运行该清单,记住这一点很重要。

two-times.yaml作业是一个保证成功的简单脚本的例子。如果工作失败了怎么办?例如,考虑下面的脚本,它明确地打印主机名和日期,并带有一个不成功的退出代码1:

echo $HOSTNAME failed on $(date) ; exit 1

作业控制器对运行该脚本的反应方式主要取决于两个方面:

  • job.spec.backoffLimit属性(默认设置为 6)将决定作业控制器在放弃之前尝试运行 Pod 的次数。

  • job.spec.template.spec.restartPolicy属性可以是NeverOnFailure,它将决定每次重试是否会启动新的 pod。如果设置为前者,作业控制器将在每次尝试时旋转一个新的 Pod,而不处理失败的 Pod。相反,如果设置为后者,作业控制器将重新启动同一个 Pod,直到成功;然而,因为失效的吊舱被重新利用——通过重启它们——它们因失效而产生的输出丢失了。

NeverOnFailurerestartPolicy值之间做出决定,取决于我们更能接受什么样的妥协。Never通常是最明智的选择,因为它不处理故障吊舱的输出,并允许我们排除故障;然而;将失效的 Pods 留在周围会占用更多的资源。理想情况下,工业作业解决方案应该将有价值的数据保存到永久存储介质中,例如第二章中演示的附件。

现在让我们看看每一个用例,以便更直观地了解每一个用例的副作用。接下来呈现的unlucky-never.yaml清单将backoffLimit属性设置为3,将restartPolicy属性设置为Never:

# unlucky-never.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: unlucky
spec:
  backoffLimit: 3
  template:
    spec:
      restartPolicy: Never
      containers:
        - command:
        - /bin/sh
        - -c
        - echo $HOSTNAME failed on $(date) ; exit 1
        name: unlucky
        image: alpine

我们将通过发出kubectl apply -f unlucky-never.yaml命令来运行unlucky-never.yaml,但是首先,我们将打开一个单独的窗口,在该窗口中我们将运行kubectl get pods -w来查看作业控制器创建了什么 Pods,因为它对exit 1产生的故障做出反应:

$ kubectl get pods -w
unlucky-6qm98 0/1   Pending             0        0s
unlucky-6qm98 0/1   ContainerCreating   0        0s
unlucky-6qm98 0/1   Error               0        1s
unlucky-sxj97 0/1   Pending             0        0s
unlucky-sxj97 0/1   ContainerCreating   0        0s
unlucky-sxj97 1/1   Running             0        0s
unlucky-sxj97 0/1   Error               0        1s
unlucky-f5h9c 0/1   Pending             0        0s
unlucky-f5h9c 0/1   ContainerCreating   0        0s
unlucky-f5h9c 0/1   Error               0        0s

可以理解,创建了三个新的吊舱:unlucky-6qm98unlucky-sxj97unlucky-f5h9c。一切都以错误告终,但为什么呢?让我们检查他们的日志:

$ kubectl logs -l job-name=unlucky
unlucky-6qm98 failed on Sun Jul 14 15:45:18 UTC 2019
unlucky-f5h9c failed on Sun Jul 14 15:45:30 UTC 2019
unlucky-sxj97 failed on Sun Jul 14 15:45:20 UTC 2019

这就是将restartPolicy属性设置为Never的好处。如前所述,失败 pod 的日志被保留,这允许我们诊断错误的性质。最后一个有用的检查是通过kubectl describe job/<NAME>命令。接下来,我们看到当前没有 pod 在运行,零个成功,三个失败:

$ kubectl describe job/unlucky | grep Statuses
Pods Statuses:  0 Running / 0 Succeeded / 3 Failed

restartPolicy属性设置为OnFailure会导致完全不同的行为。让我们再次进行同样的练习,但是使用一个名为unlucky-onFailure.yaml的新清单,其中唯一的变化是前面提到的属性:

# unlucky-onFailure.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: unlucky
spec:
  backoffLimit: 3
  template:
    spec:
      restartPolicy: OnFailure
      containers:
      - command:
        - /bin/sh
        - -c
        - echo $HOSTNAME failed on $(date) ; exit 1
        name: unlucky
        image: alpine

在通过发出kubectl apply -f unlucky-onFailure.yaml命令来应用清单之前,我们将在一个单独的终端窗口中跟踪kubectl get pods -w的结果,就像我们之前所做的那样。

$ kubectl get pods -w
NAME          READY STATUS             RESTARTS AGE
unlucky-fgtq4 0/1   Pending            0        0s
unlucky-fgtq4 0/1   ContainerCreating  0        0s
unlucky-fgtq4 0/1   Error              0        0s
unlucky-fgtq4 0/1   Error              1        1s
unlucky-fgtq4 0/1   CrashLoopBackOff   1        2s
unlucky-fgtq4 0/1   Error              2        17s
unlucky-fgtq4 0/1   CrashLoopBackOff   2        30s
unlucky-fgtq4 1/1   Running            3        41s
unlucky-fgtq4 1/1   Terminating        3        41s
unlucky-fgtq4 0/1   Terminating        3        42s

unlucky-never.yamlrestartPolicyNever相比,我们在这里看到两个关键的不同:只有一个 Pod,unlucky-fgtq4,它被重启三次,而不是三个不同的 Pod,并且 Pod 在结束时终止,而不是以Error状态结束。最根本的副作用是日志被删除,因此我们无法诊断问题:

$ kubectl logs -l job-name=unlucky
# nothing

另一个值得注意的区别是kubectl describe job/unlucky命令会声称只有一个吊舱发生了故障。这是真的;只有一个吊舱确实发生了故障——尽管它根据backoffLimit设置重启了三次:

$ kubectl describe job/unlucky | grep Statuses
Pods Statuses:  0 Running / 0 Succeeded / 1 Failed

基于完成计数的批处理

运行一个 Pod 一次、两次或更多次,并以成功退出代码结束的概念被称为完成。在单个批处理过程的情况下,如前一节所示,job.spec.completions的值默认为1;相反,基于完成计数的批处理过程通常将completions属性设置为大于或等于 2 的值。

在这个用例中,pod 独立运行,彼此没有意识。换句话说,每个 Pod 相对于其他 Pod 的结果和成果是孤立运行的。Pods 中运行的代码如何决定处理哪些数据,如何避免重复工作,以及将结果保存在哪里,这些都是作业控制能力无法解决的实现问题。实现共享状态的典型解决方案是外部队列、数据库或共享文件系统。由于进程是独立的,它们可以并行运行;可以并行运行的进程数量是使用job.spec.parallelism属性指定的。

通过组合使用job.spec.completionsjob.spec.parallelism,我们可以控制一个流程必须运行多少次,以及有多少次将并行运行,以加快总体批处理时间。

为了内部化多个独立过程的工具,我们需要一个独立于其他实例的过程的例子,但是同时,原则上收集不同的结果。为此,我们设计了一个 Bash 脚本,它检查当前时间,如果当前秒是偶数则报告成功,如果是奇数则报告失败:

n=$(date +%S)
if [ $((n%2)) -eq 0 ]
then
  echo SUCCESS: $n
  exit 0
else
  echo FAILURE: $n
  exit 1
fi

作为一个首要目标,我们开始收集六个偶数秒的样本;这意味着我们将job.spec.completions设置为6。我们还想通过并行运行两个 pod 来加速这个过程,所以我们将job.spec.parallelism设置为2。最终的结果是下面的清单,命名为even-seconds.yaml:

# even-seconds.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: even-seconds
spec:
  completions: 6
  parallelism: 2
  template:
    spec:
      restartPolicy: Never
      containers:
     - command:
       - /bin/sh
       - -c
       - >
          n=$(date +%S);
          if [ $((n%2)) -eq 0 ];
          then
            echo SUCCESS: $n;
            exit 0;
          else
            echo FAILURE: $n;
            exit 1;
          fi
        name: even-seconds
        image: alpine

为了理解这个例子的动态性,设置如下三个终端窗口(或标签/面板)是很方便的:

  • 终端窗口 1 :通过运行kubectl get job -w观察作业活动

  • 终端窗口 2 :通过运行kubectl get pods -w观察 Pod 活动

  • 终端窗口 3 :发出apply -f even-seconds.yaml命令运行even-seconds.yaml

通过在前缀为Pod Statuses的行中运行kubectl describe job/even-seconds命令,可以获得一个有趣的摘要,该行是从运行时填充的job.status下的值获得的。

让我们应用even-seconds.yaml,并根据其编排的 pod 来观察作业的行为:

# Keep this running before running `kubectl apply`
$ while true; do kubectl describe job/even-seconds \
    | grep Statuses ; sleep 1 ; done
Pods Statuses:  2 Running / 0 Succeeded / 0 Failed
Pods Statuses:  2 Running / 0 Succeeded / 1 Failed
Pods Statuses:  2 Running / 0 Succeeded / 2 Failed
Pods Statuses:  2 Running / 1 Succeeded / 3 Failed
Pods Statuses:  2 Running / 3 Succeeded / 3 Failed
Pods Statuses:  1 Running / 5 Succeeded / 3 Failed
Pods Statuses:  0 Running / 6 Succeeded / 3 Failed

结果输出是不确定的;失败和重启的次数,以及实际收集的结果,将取决于无数的因素:容器的启动时间、当前时间、CPU 速度等。

已经说明了读者可能会获得不同的结果,让我们分析输出。一开始,作业控制器启动了两个 pod,其中没有一个既没有成功也没有失败。然后,在第二行中,一个 Pod 发生故障,但是由于parallelism被设置为2,另一个 Pod 被启动,因此总是有两个并行运行。过程进行到一半时,作业控制器开始注册更多成功的 pod。在倒数第二行中,只有一个 Pod 正在运行,因为五个已经成功,只剩下一个。最后,在最后一行中,最后一个 Pod 成功完成了我们预期的完成数量:6。

我们跳过kubectl get pods -w的输出,留给读者作为练习(它将与显示不同状态的多个 pod 的早期输出一致:ContainerCreatingErrorCompleted等)。).我们的首要目标是获得 6 个偶数秒的样本,看看这个目标是否已经实现,这很有意思。让我们检查日志,看看:

$ kubectl logs -l job-name=even-seconds \
    | grep SUCCESS
SUCCESS: 34
SUCCESS: 32
SUCCESS: 34
SUCCESS: 30
SUCCESS: 32
SUCCESS: 36

这正是我们要实现的目标。失败的 POD 怎么办?如果我们将restartPolicy属性设置为OnFailure,的话,可能很难发现,但是因为我们特意选择了Never,我们可以从失败的作业中检索输出,并确认失败是由于奇数秒的采样造成的:

$ kubectl logs -l job-name=even-seconds \
    | grep FAILURE
FAILURE: 27
FAILURE: 27
FAILURE: 29

在进入第三个用例之前,当设置job.spec.completionsjob.spec.paralellism属性时,值得注意的一个方面是,作业控制器永远不会实例化多于预期(和/或剩余)完成数的并行 pod。例如,如果我们定义completions: 2parallelism: 5,就好像我们已经将parallelism设置为2。同样,并行运行的 pod 的数量永远不会大于待完成的数量。

外部协调批处理

当有一个控制机制告诉每个 Pod 是否还有工作单元要完成时,就说批处理是外部协调的。在最基本的形式中,这被实现为一个队列:控制进程(生产者)将任务插入到一个队列中,然后由一个或多个工作进程(消费者)进行检索。

在这种情况下,作业控制器假设多个 Pod 针对相同的目标工作,并且无论何时 Pod 报告成功完成,总体批次目标完成,并且不需要进一步的 Pod 运行。想象一个由三个人组成的团队——Mary、Jane 和 Joe——为搬家公司工作,正在将家具装入货车。当简把最后一件家具搬进货车时,比如一把椅子,不仅简的工作完成了,玛丽和乔的工作也完成了;他们都会报告工作完成了。

配置一个作业来满足这个用例需要将job.spec.parallelism属性设置为期望的并行工作线程数,但是不设置job.spec.completions属性。本质上,我们只定义了池中工作进程的数量。这是将外部协调批处理过程与基于单个和完成计数的批处理过程区分开来的关键方面。

为了演示这个用例,我们首先需要建立某种形式的控制队列机制。为了确保我们专注于手头的学习目标——如何配置外部协调的作业——我们将避免引入大型队列或发布/订阅解决方案,如 RabbitMQ 相反,我们将定义一个简单的进程,它监听端口 1080,并为每个网络请求提供一个新的整数(从 1 开始):

i=1
while true; do
  echo -n $i | nc -l -p 1080
  i=$(($i+1))
done

这个脚本的工作方式与医院和邮局中的红色售票机完全一样,第一个客户得到票 1,第二个客户得到票 2,依此类推。不同之处在于,在我们基于 shell 的版本中,客户通过使用nc命令打开端口 1080 获得一个新的号码,而不是从分发器中取出一张票。还请注意,我们使用 Alpine 发布的 Netcat ( nc)命令来设置一个虚拟 TCP 服务器;值得注意的是,就可用标志的数量和类型而言,Netcat 的实现在不同的操作系统之间往往是相当分散的。

在我们创建一个作业来使用队列中的票证之前,让我们先看看运行中的脚本,以便熟悉它的操作。使用kubectl run命令将提供的 shell 脚本作为 Pod 启动,并使用--expose标志将其公开为服务,这样就可以从其他 Pod 使用queue主机名对其进行访问:

$ kubectl run queue --image=alpine \
    --restart=Never \
    --port=1080 \
    --expose \
    -- sh -c \
      "i=1;while true;do echo -n \$i \
      | nc -l -p 1080; i=\$((\$i+1));done"
service/queue created
pod/queue created

我们可以手动测试queue Pod,如下例所示,但是我们必须记住在运行其余示例之前重启部署的 Pod,以便计数器再次从1开始计数。或者,在章节的源文件夹中提供了一个名为startQueue.sh的脚本,它删除任何现有的运行队列并启动一个新队列:

$ kubectl run test --rm -ti --image=alpine \
    --restart=Never -- sh
If you don't see a command prompt, try pressing enter.
/ # nc -w 1 queue 1080
1
/ # nc -w 1 queue 1080
2
/ # nc -w 1 queue 1080
3
/ # nc -w 1 queue 1080
...

请注意,nc命令引用的名为queue的主机是服务控制器创建的 DNS 条目,这是在前面的示例中启动队列 Pod 时添加标志--expose的结果。现在我们有一个中央程序来协调多个 pod 的工作。尽管可能很简单,但队列服务为每个消费者提供了一个独特的任务——由一个新的整数表示。现在让我们定义一个消费者流程,它的工作只是从队列服务中取出一个整数,然后乘以 2。当数字为 101 或更大时,总体目标被认为已经实现,脚本可以通过返回0——成功退出状态代码来宣告胜利:

while true
do n=$(nc -w 1 queue 1080)
   if [ $(($n+0)) -ge 101 ]
   then
     exit 0
   else r=$(($n*2))
     echo -en "$r\n"
     sleep 1
   fi
done

如果这个脚本独立运行,它将产生一个数字序列2, 4, 6, ...,直到到达200。比如说,在 1080 端口访问queue的任何失败都会导致一个非零的退出代码。现在让我们将 shell 脚本嵌入到容器的隔间中,这样我们就可以使用alpine映像来运行它:

# multiplier.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: multiplier
spec:
  parallelism: 3
  template:
    spec:
      restartPolicy: Never
      containers:
      - command:
        - /bin/sh
        - -c
        - >
          while true;
          do n=$(nc -w 1 queue 1080);
            if [ $(($n+0)) -ge 101 ];
            then
              exit 0;
            else r=$(($n*2))
              echo -en "$r\n";
              sleep 1;
            fi;
          done
        name: multiplier
        image: alpine

我们现在通过执行kubectl apply -f <FILE>命令来运行作业:

$ kubectl apply -f multiplier.yaml
job "multiplier" created

我们可以通过在单独的窗口或选项卡上运行kubectl get pods -wkubectl get jobs -w命令来观察作业的行为,如本章前面所建议的。我们将看到三个 pod 将被实例化,几秒钟后,它们的状态将从Running转变为Complete。这几乎是同时发生的,因为它们几乎同时开始获得一个等于或大于 101 的数:

$ kubectl get pods
NAME               READY   STATUS    RESTARTS   AGE
multiplier-7zdm7   1/1     Running   0          7s
multiplier-gdjn2   1/1     Running   0          7s
multiplier-k9fz8   1/1     Running   0          7s
queue              1/1     Running   0          50s

# A few seconds later...
$ kubectl get pods
NAME               READY   STATUS      RESTARTS   AGE
multiplier-7zdm7   0/1     Completed   0          36s
multiplier-gdjn2   0/1     Completed   0          36s
multiplier-k9fz8   0/1     Completed   0          36s
queue              1/1     Running     0          79s

所有三个 pod 的组合结果应该是从 2 到 200 的总共 100 个数字。我们可以通过计算已经生成的行数和检查整个列表本身来检查作业是否完全成功:

$ kubectl logs -l job-name=multiplier --tail=-1 | wc
    100     100     347

$ kubectl logs -l job-name=multiplier --tail=-1 \
   | sort -g
2
4
6
...
196
198
200

注意

--tail=-1标志是必要的,因为标签选择器-l的使用将尾部限制设置为 10。

如果我们想要一个更正式的成功证明,我们还可以对 2 到 200 之间的偶数列表求和,并证明合并后的对数之和是相同的:

$ n=0;for i in $(seq 100);do \
    n=$(($n+($i*2)));done;echo $n
10100

$ n=0; \
  list=$(kubectl logs -l job-name=multiplier \
    --tail=-1); \
    for i in $list;do n=$(($n+$i));done;echo $n
10100

在端口 1080 上运行的queue服务产生一系列整数,再加上multiplier作业读取这些数字并将它们乘以 2,这两者的结合展示了使用作业控制器如何实现高度可伸缩、可并行的批处理过程。每个 Pod 实例工作于乘以一个整数,这是相当随机的,取决于哪个 Pod 首先命中queue服务;但是所有独立计算的聚合结果会产生一个介于 2 和 200 之间的偶数的完整列表。

注意

如果multiplier作业在尝试访问queue TCP 服务器时发现一些错误,那么wc提供的计数可能会大于 100,因为它也会包含错误。

等待作业完成

有多种方法可以检查作业是否以特定方式完成。我们可以使用kubectl get jobs来查看成功完成的数量,或者使用kubectl get pod来查看作业的状态。但是,如果我们想将这种检查集成到编程场景中,我们需要直接询问作业对象。判断作业是否已经完成的一个简单方法是查询job.status.completionTime属性,该属性只填充相关作业完成的时间。

例如,以下 shell 表达式通过重复检查直到job.status.completionTime属性变为非空,一直等到multiplier作业完成:

$ until [ ! -z $(kubectl get job/multiplier \
    -o jsonpath \
    --template="{.status.completionTime}") ]; \
    do sleep 1 ; echo waiting ... ; done ; echo done

暂停卡住的作业

一般来说,只要失败是因为依赖项不可用,让失败的作业继续运行是一个好主意。例如,在我们用来演示外部协调批处理的用例的multiplier作业的情况下,Pod 控制器将保持重启 Pod,直到queue服务变得可用——假设重试次数不大于backoffLimit。这种行为提高了去耦性和可靠性。

但是,在某些情况下,我们对作业在中止之前可能保持失败、未完成状态的最长时间有精确的预期。实现这一点的方法是将job.spec.activeDeadlineSeconds属性设置为所需的秒数。

例如,让我们采用之前使用过的相同的multiplier作业,并将job.spec.activeDeadlineSeconds值设置为 10:

# multiplier-timeout.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: multiplier
spec:
  activeDeadlineSeconds: 10
...

如果我们运行作业,假设我们没有启动队列服务——我们可以通过运行kubectl delete all --all来清理环境——并且我们观察job.status的值,我们将看到作业最终会被取消:

$ kubectl apply -f multiplier-timeout.yaml
job "multiplier" created

$ kubectl get job/multiplier -o yaml -w \
    | grep -e   "^status:" -A 10
status:
  active: 3
  failed: 1
  startTime: "2019-07-17T22:49:37Z"
--
status:
  conditions:
 - lastProbeTime: "2019-07-17T22:49:47Z"
    lastTransitionTime: "2019-07-17T22:49:47Z"
    message: Job was active longer
             than specified deadline
    reason: DeadlineExceeded
    status: "True"
    type: Failed
  failed: 4
  startTime: "2019-07-17T22:49:37Z"

正如在显示的输出中可以看到的,在由.status.startTime属性指示的时间之后十秒钟,由于DeadlineExceeded条件,作业结束。

另一种超时作业的方法是设置job.spec.backoffLimit属性,默认情况下是6。该属性定义了作业控制器在因终端错误而结束时应该创建新 Pod 的次数。但是,我们必须记住,默认情况下,每次重试的等待时间都比前一次(10 秒、20 秒、40 秒等)要长。)

最后,activeDeadlineSeconds属性为整个作业的持续时间设置一个超时,不管它是否处于失败状态。如果到达设定的截止日期,正在经历各种成功完成而没有一个失败的 Pod 的作业也将被中止。

管理摘要

与任何其他常规 Kubernetes 对象一样,可以使用kubectl get jobs命令列出作业对象,并使用kubectl delete job/<NAME>命令删除作业对象。类似于其他控制器,如 ReplicaSet one,delete命令有一个级联效果,在某种意义上,它也将删除作业标签选择器引用的窗格。如果我们只想删除作业本身,我们需要使用--cascade=false标志。例如,在接下来给出的命令序列中,我们运行了用于演示基于完成计数的批处理过程的two-times.yaml作业,我们删除了该作业,然后,最后,我们得到了由 pod 生成的结果:

# Create a Job
$ kubectl apply -f two-times.yaml
job.batch/two-times created

# Delete the Job with but not its Pods
$ kubectl delete job/two-times --cascade=false
job.batch "two-times" deleted

# Confirm that the Job is effectively deleted
$ kubectl get jobs
No resources found.

# Extract logs from the two-times Pods
$ kubectl logs -l job-name=two-times | wc
     10      10      26

摘要

在本章中,我们了解到作业是一个 Pod 控制器,类似于 ReplicaSet 控制器,不同之处在于作业预计会在某个时间点终止。我们看到,作业中的基本工作单元是完成,它发生在一个状态代码为0的 Pod 存在时,并行性允许通过增加并发工作 Pod 的数量来扩展批处理吞吐量。

我们探讨了批处理用例的三种基本类型:单个批处理、基于完成计数的批处理和外部协调的批处理。我们强调了外部协调的批处理过程与基于单个和完成计数的批处理过程之间的关键区别在于,前者的成功标准取决于作业控制器外部的机制,通常是队列。

最后,我们研究了常规的管理任务,比如监视一个作业直到它完成,暂停停滞的作业,删除它们同时保留它们的结果。

七、Cron 作业

有些任务可能需要定期运行;例如,我们可能希望每周压缩和归档日志,或者每月创建备份。Bare Pods 可以用来装备所述任务,但是这种方法需要管理员在 Kubernetes 集群本身之外建立一个调度系统。

对周期性任务进行调度的需求使得 Kubernetes 团队设计了一种独特的控制器,这种控制器模仿了类 Unix 操作系统中的传统 cron 实用程序。不出所料,控制器名为 CronJob ,它使用与 crontab 文件相同的调度规范格式;例如,*/5 * * * *指定作业每五分钟运行一次。在实现方面,CronJob 对象类似于部署控制器,因为它不直接控制 Pods 它创建一个常规的作业对象,该对象反过来负责管理 pod——部署控制器使用一个 ReplicaSet 控制器来实现这个目的。

Kubernetes 的开箱即用 CronJob 控制器的一个关键优势是,与它的 Unix 表亲不同,它不需要一个“宠物”服务器来管理和恢复健康。

本章首先介绍一个简单的 CronJob,它既可以强制启动,也可以交互启动。然后,我们看一下循环任务的调度,其中我们描述了 crontab 字符串的语法。接下来,我们将介绍一次性任务的设置;如何增加作业的历史记录;CronJob 控制器、作业和 pod 之间的交互;以及挂起和恢复活动 CronJobs 的任务。

最后,我们解释了作业并发性,它决定了如何根据指定的设置处理重叠的作业,以及如何在跳过迭代时改变 CronJob 的“追赶”行为。最后,我们回顾一下 CronJob 的生命周期管理任务。

简单的 CronJob

基本的 CronJob 类型既可以使用kubectl run <NAME> --restart=Never --schedule=<STRING> --image=<URI>命令强制创建,也可以使用清单文件声明创建。前两个标志向kubectl run命令发出信号,表明需要 CronJob,而不是 Pod 或 Deployment。我们将首先看命令式版本。

默认的调度时间间隔——也是最低的计时器分辨率——是一分钟,它使用 crontab 字符串由五个连续的星号表示:* * * * * *。使用--schedule=<STRING>标志将字符串传递给kubectl run命令。我们将在下一节学习 crontab 字符串格式。在下面名为simple的 CronJob 中,我们还指定了alpine映像,并传递一个 shell 命令来打印日期和 Pod 的主机名:

$ kubectl run simple \
    --restart=Never \
    --schedule="* * * * *" \
    --image=alpine \
    -- /bin/sh -c \
    "echo Executed at \$(date) on Pod \$HOSTNAME"
cronjob.batch/simple created

注意

Kubernetes 正在使用kubectl run命令反对创建 CronJobs。Kubernetes 版本 1.14 引入了kubectl create cronjob <NAME>命令,从这个版本开始,它将成为事实上必不可少的 CronJob 创建方法。该命令采用与kubectl run相同的标志,但不包括--restart标志。例如:

$ kubectl create cronjob simple \
    --schedule="* * * * *" \
    --image=alpine \
    -- /bin/sh -c \
    "echo Executed at \$(date) on Pod \$HOSTNAME"

在我们创建 CronJob 对象之后——甚至在此之前——我们可以在不同的终端窗口或选项卡上使用kubectl get cronjobs -wkubectl get jobs -wkubectl get pod -w命令开始观察作业和 Pod 活动;这是因为将会创建三种不同的对象——最终分别是:一个 CronJob、一组作业和一组 pod。我们将在接下来的例子中看到所有这些对象类型。

让我们先来看看在创建名为simple的 CronJob 对象后的最初几秒钟的结果,如前所示:

$ kubectl get cronjobs -w
NAME   SCHEDULE  SUSPEND  ACTIVE  LAST SCHEDULE   AGE
simple * * * * * False    0       <none>          0s

$ kubectl get jobs -w
$ # nothing

$ kubectl get pods -w
$ # nothing

正如我们在结果输出中看到的,CronJob 的ACTIVE值是0,并且既没有 Job 也没有 Pod 对象在运行。一旦 CronJob 控制器使用的时钟转到下一分钟,我们将看到ACTIVE——短暂地——转到1,一个新的作业和一个子 Pod 被创建:

$ kubectl get cronjobs -w
NAME   SCHEDULE  SUSPEND  ACTIVE  LAST SCHEDULE  AGE
simple * * * * * False    1       6s             29s

$ kubectl get jobs -w
NAME                DESIRED   SUCCESSFUL   AGE
simple-1520847060   1         0            0s
simple-1520847060   1         1            1s

$ kubectl get pods -w
NAME                    READY STATUS
simple-1520847060-xrnlh 0/1   Pending
simple-1520847060-xrnlh 0/1   ContainerCreating
simple-1520847060-xrnlh 0/1   Completed

这个过程每分钟都会重复,无限期。我们可以使用kubectl logs <POD-NAME>来检查预定任务的结果。接下来,我们检查在kubectl get pods -w输出中显示的运行的第一个 Pod,以及第二个 Pod:

$ kubectl logs simple-1520847060-xrnlh
Executed at Fri Mar 9 10:02:04 UTC 2019 on Pod simple-1520847060-xrnlh

$ kubectl logs simple-1520847120-5fh5k
Executed at Fri Mar 9 10:03:04 UTC 2019 on Pod simple-1520847120-5fh5k

通过比较 pod 的回应时间,10:02:0410:03:04,我们可以看到它们的执行间隔为一分钟,这不一定总是第二分钟,因为启动时间可能不同。如果我们保持kubectl get pod -w运行,我们将看到一个新的 Pod,用不同的名字,将被创建并每分钟运行。

接下来是声明性版本。请注意,在撰写本文时,该 API 仍处于测试阶段——因此有了apiVersion: batch/v1beta1属性——但它很可能会在发布后成为最终版本,因此读者可能最终想要尝试apiVersion: v1,而不是:

# simple.yaml
apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: simple
spec:
  schedule: "* * * * *"
  jobTemplate:
    spec:
      template:
       spec:
          containers:
          - name: simple
            image: alpine
            args:
            - /bin/sh
            - -c
            - >-
              echo Executed at $(date)
              on Pod $HOSTNAME
          restartPolicy: OnFailure

与任何其他清单文件一样,使用了kubectl apply -f <FILE>命令。例如:

# clean up the environment first

$ kubectl apply -f simple.yaml
cronjob.batch/simple created

设置周期性任务

使用 CronJob 对象实现的任务的频率和计时细节是使用cronjob.spec.schedule属性(或使用kubectl run时的--schedule属性)定义的,该属性的值是一个 crontab 字符串。

这种格式的使用对于以前的类 Unix 操作系统管理员来说是一种福气,但是它可能会遭到那些在当今的 JSON 和 YAML 世界中期待更直观和用户友好的方法的人的怀疑。不过,使用这种看似古老的格式不会产生问题,因为我们将足够详细地说明这一点。

crontab 字符串格式有五个组成部分—分钟、小时、一月中的某一天、月和一周中的某一天,由空格分隔。这意味着最低分辨率是整整一分钟;任务不能被安排成每 15 或 30 秒运行一次。

表 7-1

Crontab 的组件和数字值的有效范围

|

分钟

|

小时

|

日/月

|

|

日/周

|
| --- | --- | --- | --- | --- |
| 0-59 | 0-23 | 1-31 | 1-12 | 0-6 |

每个组件要么接受一个特定值,要么接受一个表达式:

  • Digits:每个日期成分的有效范围的特定值。例如,30 15 1 1 *会将任务设置为在每年 1 月 1 日的 15:30 运行。各部件的有效位数范围如表 7-1 所示。

  • *:任意值。例如,* * * * *表示任务将在一个月中的每一分钟、每一小时、每一天以及一周中的每一天运行。

  • ,:值列表分隔符。我们用它来包含多个值。例如,0,30 * * * *表示“每半小时”,因为它指定了 0 分钟和 30 分钟。

  • -:取值范围。有时候写一个值的列表太繁琐了,所以我们不如指定一个范围。例如,* 0-12 * * *表示任务将每分钟运行一次,但仅在 00:00 到 12:00 (AM)之间运行。

  • /:步长值。如果任务以固定的时间间隔运行,例如每五分钟或每两小时运行一次,而不是使用值列表分隔符指定确切的时间,我们可以简单地单步执行。例如,对于所述设置,我们将分别使用*/5 * * * *0 */2 * * *

推理 crontab 字符串格式最简单的方法是将默认的字符串值* * * * *作为基线,并根据手头的需求修改它,使其粒度更小。按照这种思路,表 7-2 显示了一系列样本 crontab 值;宏是一个关键字,可以用来代替基于组件的字符串。

表 7-2

简单 crontab 值示例

|

线

|

|

意义

|
| --- | --- | --- |
| * * * * * | 不适用的 | 每一分钟 |
| 0 * * * * | @hourly | 每小时(每小时开始时) |
| 0 0 * * * | @daily | 每天(00:00) |
| 0 0 * * 0 | @weekly | 每周的第一天(星期日,00:00) |
| 0 0 1 * * | @monthly | 每月的第一天(00:00) |
| 0 0 1 1 * | @yearly | 每年的第一天(00:00) |

基于表 7-2 中给出的样本值,我们可以通过减少数字部分的规则性来进一步细化;示例如表 7-3 所示。

表 7-3

数位循环的细化

|

线

|

意义

|
| --- | --- |
| */15 * * * * | 每一刻钟运行一次 |
| 0 0-5 * * * | 在上午 00:00 到 05:00 之间每小时运行一次 |
| 0 0 1,15 * * | 仅在每月的 1 号和 15 号运行 |
| 0 0 * * 0-5/2 | 从周日到周五,每两天运行一次 |
| 0 0 1 1,7 * | 仅在 1 月 1 日和 7 月 1 日运行 |
| 15 2 5 1 * | 每年 1 月 5 日凌晨 02:15 |

请注意,像@hourly这样的宏集是出现在 Unix 类操作系统中较新的 cron 实现中的一个特性,它在 Kubernetes 中的支持似乎是部分的。例如,@reboot宏没有实现,所以作者建议尽可能使用传统的字符串格式。

还有一个方便的网站, https://crontab.guru/ ,它允许理解和验证 crontab 字符串格式组合,以便它们产生有效的间隔。这个站点有助于回答一些迫切的疑问,比如我的 crontab 字符串格式是否正确?我的 CronJob 会在预期的时间运行吗?

设置一次性任务

作业控制器采用的 crontab 格式是传统格式,而不是扩展格式,这意味着它不接受年份成分。这意味着最细粒度的事件,如15 2 5 1 *(1 月 5 日凌晨 02:15),将使相关联的作业无限期地每年运行一次。在类 Unix 操作系统中,一次性任务通常使用at实用程序而不是cron来运行。在 Kubernetes 中,我们不需要单独的控制器,但是我们需要一些外部进程在 CronJob 运行后处理它,这样它就不会在下一年重复。或者,Pod 本身可以检查它是否在预定的年份运行。

作业历史

如果我们运行上一节中描述的简单 CronJob 示例,并不断重复运行kubectl get pods(例如,使用 Linux watch命令),我们将永远不会看到超过三个 pod。例如:

$ kubectl get pods
NAME                    READY STATUS    RESTARTS AGE
simple-1520677440-swmj6 0/1   Completed 0        2m
simple-1520677500-qx4xc 0/1   Completed 0        1m
simple-1520677560-dcpp6 0/1   Completed 0        9s

simple CronJob 将要再次运行时,最旧的 Pod—”—将被 CronJob 控制器处理掉。这种行为通常是可取的,因为它确保 Kubernetes 集群不会耗尽资源,但是,作为一个结果,这意味着我们将丢失关于被处置的 pod 的所有信息和日志。

幸运的是,可以使用cronjob.spec.successfulJobsHistoryLimitcronjob.spec.failedJobsHistoryLimit属性来调整这种行为。这两个属性的默认值都是3。值0意味着 pod 将在完成后立即被处理,而正值指定将保留用于检查的 pod 的确切数量——包括日志提取。

但是,在大多数情况下,如果 CronJob 的结果非常重要,那么最好将所述结果保存到更永久的存储机制或外部日志记录系统,而不是保存到 STDOUT。

与 CronJob 的作业和窗格交互

使用 CronJob 对象时,一个典型的烦恼是定位其结果作业和 pod(出于故障排除的目的)可能会很繁琐。任务被赋予一个随机名称,然后用于 pod 中的pod.metadata.label.job-name标签。当问“请给我所有与给定 CronJob 匹配的作业或 pod”时,这没有帮助。解决方案是向 Pod 模板手动添加标签;这样的标签也会出现在作业对象中,所以不需要定义两个单独的标签。为了在标记 pod 时与作业对象使用的相同标签约定保持一致,作者建议将标签名称命名为cronjob-name

以前面几节中使用的simple.yaml清单为例,应用建议的cronjob-name标签并将其保存为simple-label.yaml会产生以下清单:

# simple-label.yaml
apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: simple
spec:
  schedule: "* * * * *"
  jobTemplate:
    spec:
      template:
       metadata:
         labels:
           cronjob-name: simple
       spec:
          containers:
          - name: simple
            image: alpine
            args:
            - /bin/sh
            - -c
            - >-
              echo Executed at $(date)
              on Pod $HOSTNAME
          restartPolicy: OnFailure

我们现在可以使用标签选择器标志-l以一种方便的方式与 CronJob 的作业及其 pod 进行交互。接下来,我们应用simple-label.yaml清单,让它运行三分钟以上,列出它的作业,然后获取它的 pod 日志:

# clean up environment first

$ kubectl apply -f simple-label.yaml

# wait > 3 minutes

$ kubectl get jobs -l cronjob-name=simple
NAME                DESIRED   SUCCESSFUL   AGE
simple-1520977740   1         1            2m
simple-1520977800   1         1            1m
simple-1520977860   1         1            17s

$ kubectl logs -l cronjob-name=simple
Executed at Tue Mar 13 21:49:04 UTC 2019 on Pod simple-1520977740-qcmr8
Executed at Tue Mar 13 21:50:04 UTC 2019 on Pod simple-1520977800-jqwl8
Executed at Tue Mar 13 21:51:04 UTC 2019 on Pod simple-1520977860-bcszj

挂起 CronJob

CronJobs 随时可能被暂停。它们也可以在暂停模式下启动,并且仅在特定时间激活;例如,我们可能有许多网络诊断工具在调试会话期间每分钟都在运行,但是我们希望禁用 CronJob,而不是删除它。CronJob 是否处于挂起状态由默认设置为falsecronjob.spec.suspend属性控制。

暂停和恢复一个活动的 CronJob 需要使用kubectl edit cronjob/<NAME>命令或者使用kubectl patch命令来编辑清单。假设simple CronJob 仍在运行,下面的命令将暂停它:

$ kubectl patch cronjob/simple \
    --patch '{"spec" : { "suspend" : true }}'
cronjob "simple" patched

我们可以通过确保运行kubectl get jobs命令时SUSPEND列的值为True来验证kubectl patch命令是否成功:

$ kubectl get cronjob
NAME     SCHEDULE    SUSPEND   ACTIVE   LAST SCHEDULE
simple   * * * * *   True      0        3m17s

恢复暂停的 CronJob 只是简单地将suspend属性设置回false:

$ kubectl patch cronjob/simple \
    --patch '{"spec" : { "suspend" : false }}'
cronjob "simple" patched

请注意,考虑到打补丁过程的异步性质,以及键入kubectl get cronjob时报告的状态,有效的 CronJob 状态和观察到的状态之间可能会有一些暂时的差异。

作业并发

如果在到达下一个计划运行事件时作业尚未完成,会发生什么情况?这是一个好问题;CronJob 控制器对此场景的反应方式取决于cronjob.spec.concurrencyPolicy属性的值:

  • Allow(默认值):CronJob 将简单地启动一个新的作业,并让前一个作业保持并行运行。

  • CronJob 控制器将等待当前运行的作业完成,然后再启动新的作业。

  • CronJob 控制器将终止当前正在运行的作业,并启动一个新的作业。

现在让我们详细看看每个concurrencyPolicy用例,从默认的Allow值开始。为此,我们将修改simple.yaml CronJob 清单,将 shell 脚本替换为 150 秒等待状态(使用sleep 150命令),并在上述sleep语句前后打印时间戳和主机名:

echo $(date) on Pod $HOSTNAME - Start
sleep 150
echo $(date) on Pod $HOSTNAME - Finish

然后,我们将把新的清单保存为long-allow.yaml,它嵌入了所呈现的脚本,生成以下文件:

# long-allow.yaml
apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: long
spec:
  schedule: "* * * * *"
  jobTemplate:
    spec:
      template:
        metadata:
          labels:
            cronjob-name: long
        spec:
          containers:
           - name: long
             image: alpine
             args:
             - /bin/sh
             - -c
             - echo $(date) on Pod $HOSTNAME - Start;
               sleep 150;
               echo $(date) on Pod $HOSTNAME - Finish
          restartPolicy: OnFailure

使用kubectl apply -f long-allow.yaml应用清单并等待大约三分钟以上会产生以下日志输出:

$ kubectl logs -l cronjob-name=long | sort -g
22:17:07 on Pod long-1520979420-t62wq - Start
22:18:07 on Pod long-1520979480-kwqm8 - Start
22:19:07 on Pod long-1520979540-mh5c4 - Start
22:19:37 on Pod long-1520979420-t62wq - Finish
...

正如我们在这里看到的,吊舱t62wqkwqm8mh5c4在每一分钟结束后依次启动。第一个吊舱t62wq在启动后已经完成了 2 分 30 秒。此时,kwqm8mh5c4仍然并行运行,因为它们还没有产生Finish消息。

这种重叠开始和结束工作时间的行为可能不是我们想要的;例如,它可能导致节点资源消耗失控。有可能任务是按顺序运行的,只有在前一个任务完成后才允许新的迭代。在这种情况下,我们将cronjob.spec.concurrencyPolicy属性设置为Forbid

为了观察将concurrencyPolicy值设置为Forbid的行为,我们将如下修改 CronJob 清单:

# long-forbid.yaml
...
spec:
  concurrencyPolicy: Forbid # New attribute
  schedule: "* * * * *"
...

然后,我们将把新清单保存为long-forbid.yaml,并通过发出kubectl apply -f long-forbid.yaml命令来应用它——首先清理环境,不要忘记在填充日志之前我们必须等待几分钟:

$ kubectl logs -l cronjob-name=long | sort -g
22:39:10 on Pod long-1520980740-647m6 - Start
22:41:40 on Pod long-1520980740-647m6 - Finish
22:41:50 on Pod long-1520980860-d6dfb - Start
22:44:20 on Pod long-1520980860-d6dfb - Finish

如此处所示,作业的执行现在处于完美的顺序中。重叠消息的问题——如果真的有问题的话——现在似乎得到了解决,但是如果我们更仔细地观察,我们会发现作业不再精确地在每分钟之交运行。原因是每当cronjob.spec.concurrencyPolicy属性被设置为Forbid时,CronJob 对象将等待当前作业完成,然后再启动新的作业。

使用Forbid值的副作用是,如果作业花费的时间比 crontab 字符串间隔长得多,它们可能会被完全跳过。例如,让我们假设使用0 * * * * crontab 字符串计划每小时运行一次备份。如果备份作业需要六个小时,那么一天中可能只会产生 4 个备份,而不是 24 个。

如果我们不希望作业并行运行,但又希望避免错过预定的“运行槽”,那么唯一的解决方案就是终止当前正在运行的作业,并在预定事件时启动一个新的作业。这正是将cronjob.spec.concurrencyPolicy属性设置为Replace所实现的。让我们再次修改清单来设置这个值,并将其保存为long-replace.yaml:

# long-replace.yaml
...
spec:
  concurrencyPolicy: Replace # New attribute
  schedule: "* * * * *"
...

像往常一样,我们首先清理环境,通过发出kubectl apply -f long-replace.yaml命令应用清单,然后等待几分钟让日志填充:

$ kubectl logs -l cronjob-name=long | sort -g
23:37:07 on Pod long-1520984220-phrqc - Start
23:38:07 on Pod long-1520984280-vm67d - Start
...

通过观察产生的输出可以理解,Replace并发设置确实按照 crontab 字符串强制作业及时启动,但是有两个相当激进的副作用。首先是当前正在运行的作业被残酷地终止。这就是为什么我们看不到日志上打印的Finish句子。第二个问题是,假设正在运行的作业被终止而不是被允许完成,那么在作业在短时间内被删除之前,我们只有有限的时间来查询它们的日志。因此,Replace设置只对那些在下一个预定事件到来时还没有完成的任务有用。

换句话说,将concurrencyPolicy设置为Replace所产生的行为仅在底层 pod 所执行的工作负载具有幂等性质时才适用;它们可以安全地在中途被中断,而不会导致数据丢失或损坏,而不管它们当前的计算状态或它们的挂起输出。接下来,如果所述 Pods 碰巧有重要的事情要告诉世界,那么推荐使用比 STDOUT 更持久的支持服务。

作为本节的总结,表 7-4 总结了与cronjob.spec.concurrencyPolicy属性的每个值(AllowForbidReplace)相关的主要行为。

表 7-4

每个concurrencyPolicy值的 CronJob 行为

|

行为

|

Allow

|

Forbid

|

Replace

|
| --- | --- | --- | --- |
| 多个作业可以并行运行 | 是 | 不 | 不 |
| 工作结果的重叠 | 是 | 不 | 不 |
| 计划事件的及时执行 | 是 | 不 | 是 |
| 正在运行的作业突然终止 | 不 | 不 | 是 |

赶上错过的预定事件

正如我们在上一节中看到的,每当错过多个事件时,CronJob 控制器通常会试图赶上其中一个(但不是全部)错过的事件。运行与错过的预定事件相关联的作业的能力并不神奇;实际上是由cronjob.spec.startingDeadlineSeconds属性决定的。如果不指定该属性,则没有截止日期。

假设我们已经配置了一个持续 25 分钟的 CronJob 在第 0 分钟和第 1 分钟(0,1 * * * *)运行,并且我们还将cronjob.spec.concurrencyPolicy属性设置为Forbid。在这种情况下,第一个实例将正好在第 0 分钟运行,但是第二个实例仍将在第 25 分钟运行,即使它远离预定的第二分钟“槽”

如果我们碰巧给cronjob.spec.startingDeadlineSeconds属性分配了一个离散的正值,那么一旦达到预期的运行迭代,就可能不会发生“追赶”运行。例如,如果我们将该属性设置为300秒(五分钟),那么第二次运行肯定不会发生,因为 CronJob 控制器将在分钟1之后等待五分钟,然后,如果到那时前一个作业还没有完成,它将会放弃。这种行为虽然看起来有问题,但它防止了作业无限期排队的情况,从长远来看,这可能会导致资源消耗不断增加。

管理摘要

正在运行的 CronJobs 的当前列表是通过发出kubectl get cronjobs命令获得的,而一个特定的 CronJob 是通过添加-o json-o yaml标志的kubectl describe cronjob/<NAME>kubectl get cronjob/<NAME>来查询的,以便以结构化的格式获得进一步的细节。

如前几节所述,在 CronJob 清单中为 Pod 规范提供一个标签是很方便的,这样就很容易使用-l(匹配标签)标志来匹配 CronJob 的依赖作业和 Pod。

当使用kubectl delete cronjob/<NAME>命令删除一个 CronJob 时,其所有相关的正在运行和已完成的作业和 pod 也将被删除:

$ kubectl delete cronjob/long
job "long-1521468900" deleted
pod "long-1521468900-k6mkz" deleted
cronjob "long" deleted

如果我们想要删除 CronJob,但是不干扰当前正在运行的作业和 Pod,以便它们可以完成它们的活动,我们可以使用--cascade=false标志。例如:

$ kubectl delete cronjob/long --cascade=false
cronjob.batch "long" deleted

# After a few seconds...
$ kubectl get cronjobs
No resources found.

$ kubectl get jobs
NAME              DESIRED   SUCCESSFUL   AGE
long-1521468540   1         0            45s

$ kubectl get pods
NAME                   READY  STATUS  RESTARTS   AGE
long-1521468540-68dqd  1/1   Running  0          53s

摘要

在这一章中,我们了解到 CronJob 控制器类似于部署对象,因为它控制一个辅助控制器,一个作业,该作业反过来控制 Pods 这种关系在第一章的图 1-2 中说明。我们观察到,CronJob 控制器使用与我们熟悉的 Unix cron 实用程序相同的 crontab 字符串格式,最短的时间间隔是一分钟,而最长的时间间隔是一年。我们还指出,CronJob 的控制器相对于它的 Unix 表亲的优势在于,它通过避免维护额外的“宠物”服务器来托管 cron 实用程序,从而降低了复杂性(和潜在的故障)。

我们特别关注作业并发性,它决定了如何处理重叠的作业——当前一个作业尚未完成时,新的作业应该开始。我们看到,cronjob.spec.concurrencyPolicy属性的默认值Allow,只是让新的作业被创建并与现有的作业并行“堆积”,而Forbid让控制器等待直到前一个作业完成。Replace,第三个,也是最后一个可能的值,采取激进的方法;它只是在开始一个新的作业之前突然终止前一个正在运行的作业。

最后,我们学习了如何调整 CronJob 追赶错过的迭代的方式——当使用cronjob.spec.startingDeadlineSeconds属性将并发策略设置为Forbid时。

八、DaemonSet

DaemonSet 控制器确保每个节点运行一个 Pod 实例。这对于需要在节点级别部署的统一、水平的服务非常有用,例如日志收集器、缓存代理、代理或任何其他类型的系统级功能。但是,为什么像 Kubernetes 这样的分布式系统会把 pod 和“盒子”之间的“紧密耦合”作为一个特性来推广呢?因为性能优势——有时是完全必要的。部署在同一节点内的 pod 可以共享本地网络接口以及本地文件系统;其好处是,与“非机载”网络交互的情况相比,延迟损失要低得多。

在某种程度上,DaemonSet 以 pod 处理容器的方式处理节点:虽然 pod 确保两个或更多容器并置在一起,但 daemon set 保证守护程序(也作为 pod 实现)在每个节点上始终本地可用,以便消费者 Pods a 可以访问它们。

这一简短的章节组织如下:首先,我们将探索 DaemonSets 的两个广泛的连接性用例(TCP 和文件系统)。然后,我们将学习如何使用标签来定位特定的节点。最后,我们将描述部署和 DaemonSets 之间更新策略的差异,以及为什么在后者中不能使用maxSurge属性。

基于 TCP 的守护程序

基于 TCP 的守护程序是由 DaemonSet 控制器管理的常规 Pod,通过它可以通过 TCP 访问服务。不同之处在于,因为每个守护程序控制的 Pod 都保证部署在每个节点中,所以不需要服务发现机制,因为客户端 Pod 可以简单地建立到运行它们的本地节点的连接。我们稍后会看到所有这些是如何工作的。

首先,让我们使用 Netcat nc命令定义一个简单的节点级服务:一个监听端口6666并将所有日志请求附加到一个名为/var/node_log的文件的日志收集器:

nc -lk -p 6666 -e sh -c "cat >> /var/node_log"

下一步是将我们基于 shell 的日志收集器包装在一个 Pod 模板中,该模板又嵌入到一个 DaemonSet 清单中。与部署类似,标签和标签选择器必须匹配:

# logDaemon.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: logd
spec:
  selector:
    matchLabels:
      name: logd
  template:
    metadata:
      labels:
        name: logd
    spec:
     containers:
      - name: logd
        image: alpine
        args:
        - /bin/sh
        - -c
        - >-
          nc -lk -p 6666 -e
          sh -c "cat >> /var/node_log"
        ports:
        - containerPort: 6666
          hostPort: 6666

在应用了logDaemon.yaml清单之后,我们将在每个 Kubernetes 节点上部署一个廉价的自制日志收集服务:

$ kubectl apply -f logDaemon.yaml
daemonset.apps/logd created

由于 DaemonSet 应该为每个 worker 节点创建一个 Pod,因此 Kubernetes 集群中的 Pod 数量和 DaemonSet 控制的 Pod 数量应该匹配:

$ kubectl get nodes
NAME                  STATUS  AGE
gke-*-ab1848a0-ngbp   Ready   20m
gke-*-ab1848a0-pv2g   Ready   20m
gke-*-ab1848a0-s9z7   Ready   20m

$ kubectl get pods -l name=logd -o wide
NAME       STATUS   AGE  NODE
logd-95vnl Running  11m  gke-*-s9z7
logd-ck495 Running  11m  gke-*-pv2g
logd-zttf4 Running  11m  gke-*-ngbp

既然日志收集器 DaemonSet 控制的 pod 在每个节点上都可用,我们将创建一个示例客户机来测试它。以下 shell 脚本每隔 15 秒生成一个问候,并将其发送到在端口6666上运行的 TCP 服务:

while true
do echo $(date) - Greetings from $HOSTNAME |
   nc $HOST_IP 6666
   sleep 15
done

这个脚本中有三个不同的参数:端口号,6666,,这是硬编码的;由 Pod 控制器自动填充的$HOSTNAME变量——可从容器内访问——和用户定义的$HOST_IP变量。Pod 不会明确知道运行 Pod 的节点的主机名或 IP 地址。这就产生了一个需要使用向下 API 来解决的新问题。

Downward API 允许查询 Pod 对象中的字段,并使它们作为环境变量可用于同一 Pod 的容器。在这个特例中,我们感兴趣的是pod.status.hostIP属性。为了将该属性的值“注入”到HOST_IP环境变量中,我们首先使用name属性声明变量的名称,然后使用valueFrom.fieldRef.fieldPath属性从 Pod 的对象中引用所需的属性——所有这些都在pod.spec.containers.env区间下:

...
env:
  - name: HOST_IP
    valueFrom:
      fieldRef:
        fieldPath: status.hostIP
...

定义了示例客户机和注入节点 IP 所需的额外配置后,我们现在将示例客户机的 shell 脚本和向下 API 查询结合起来,在名为logDaemonClient.yaml的单个部署清单中填充HOST_IP:

# logDaemonClient.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: client
spec:
  replicas: 7
  selector:
    matchLabels:
      name: client
  template:
    metadata:
      labels:
        name: client
    spec:
      containers:
      - name: client
        image: alpine
        env:
          - name: HOST_IP
            valueFrom:
              fieldRef:
                fieldPath: status.hostIP
        args:
        - /bin/sh
        - -c
        - >-
          while true;
          do echo $(date) -
          Greetings from $HOSTNAME |
          nc $HOST_IP 6666;
          sleep 15;
          done

应用logDaemonClient.yaml将导致创建多个副本(总共七个),这些副本将位于不同的分类节点上,这可以通过使用-o wide标志运行kubectl get pods命令来观察到:

$ kubectl apply -f logDaemonClient.yaml
deployment.apps/client created

$ kubectl get pods -l name=client -o wide
NAME            IP          NODE
client-*-5hn9p  10.28.2.15  gke-*-ngbp
client-*-74ssw  10.28.0.14  gke-*-pv2g
client-*-h5fmm  10.28.2.16  gke-*-ngbp
client-*-rjgz8  10.28.1.14  gke-*-s9z7
client-*-tgk7r  10.28.0.13  gke-*-pv2g
client-*-twk5p  10.28.2.14  gke-*-ngbp
client-*-wg6th  10.28.1.15  gke-*-s9z7

如果我们随机选择一个由 DaemonSet 控制的 Pod 并查询其日志,我们将看到它们与由部署控制的客户端 Pod 来自同一个节点:

$ kubectl exec logd-8cgrb -- cat /var/node_log
08:58:56 - Greetings from client-*-tgk7r
08:58:57 - Greetings from client-*-74ssw
08:59:11 - Greetings from client-*-tgk7r
08:59:12 - Greetings from client-*-74ssw
08:59:26 - Greetings from client-*-tgk7r
08:59:27 - Greetings from client-*-74ssw
...

作为参考,这些是 DaemonSet 控制的吊舱已经着陆的节点:

$ kubectl get pods -l name=logd -o wide
NAME       IP         NODE
logd-8cgrb 10.28.0.12 gke-*-pv2g
logd-m5z4m 10.28.1.13 gke-*-s9z7
logd-zd9z9 10.28.2.13 gke-*-ngbp

注意,podlogd-8cgrbclient-*-tgk7rclient-*-74ssw都部署在同一个名为gke-*-pv2g的节点上:

$ kubectl get pod/logd-8cgrb -o jsonpath \
    --template="{.spec.nodeName}"
gke-*-pv2g

$ kubectl get pod/client-5cbbb8f78-tgk7r \
    -o jsonpath --template="{.spec.nodeName}"
gke-*-pv2g

$ kubectl get pod/client-5cbbb8f78-74ssw \
    -o jsonpath --template="{.spec.nodeName}"
gke-*-pv2g

概括地说,要设置一个通用的基于 TCP 的 DaemonSet 解决方案,我们需要定义一个 DaemonSet 清单来部署守护进程本身,然后,为了使用 Pod,我们需要使用 Downward API 来注入 Pod 运行的节点的地址。向下 API 的使用包括查询特定 Pod 的对象属性,并通过环境变量使它们对 Pod 可用。

基于文件系统的守护程序

在上一节中,我们考虑了基于 TCP 的 DaemonSet 的情况,其特征在于客户端使用节点的 IP 地址(通过向下 API 注入)直接访问它,而不是使用服务对象。当部署在同一个节点上时,使用 DaemonSet 控制器部署的 pod 还有另一种相互通信的方式:文件系统。

让我们考虑一个守护进程的情况,它使用下面的 shell 脚本每 60 秒从在/var/log中找到的所有日志中创建一个 tarball :

while true
do tar czf \
   /var/log/all-logs-`date +%F`.tar.gz /var/log/*.log
   sleep 60
done

基于文件系统的 DaemonSet 的清单要求我们指定一个卷(对 Pod 中可用的驱动程序和目录的描述)和一个卷挂载(将卷绑定到适用容器内的文件路径)。

对于卷,我们指定了一个名为logdir的卷,它在pod.spec.volumes属性下指向节点的/var/log:

# at pod.spec
volumes:
- name: logdir
  hostPath:
    path: /var/log

然后,我们参考pod.spec.containers.volumeMounts隔间下的logdir卷,并确定它将被安装在我们的容器内的/var/log路径下:

# at pod.spec.containers

volumeMounts:
- name: logdir
  mountPath: /var/log

最后,我们将给出的两个定义组合成一个名为logCompressor.yaml的 DaemonSet 清单:

# logCompressor.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: logcd
spec:
  selector:
    matchLabels:
      name: logcd
  template:
    metadata:
      labels:
        name: logcd
    spec:
      volumes:
      - name: logdir
        hostPath:
          path: /var/log
      containers:
      - name: logcd
        image: alpine
        volumeMounts:
        - name: logdir
          mountPath: /var/log
        args:
        - /bin/sh
        - -c
        - >-
          while true;
          do tar czf
          /var/log/all-logs-`date +%F`.tar.gz
          /var/log/*.log;
          sleep 60;
          done

在应用了logCompressor.yaml之后,我们可以查询一个随机的 Pod 来判断一个 tarball 文件是否已经在它所分配的节点中被创建:

# clean up the environment first
$ kubectl apply -f logCompressor.yaml
daemonset.apps/logcd created

$ kubectl get pods
NAME          READY     STATUS    RESTARTS   AGE
logcd-gdxc7   1/1       Running   0          0s
logcd-krf2r   1/1       Running   0          0s
logcd-rd9mb   1/1       Running   0          0s

$ kubectl exec logcd-gdxc7 \
    -- find /var/log -name "*.gz"
/var/log/all-logs-2019-04-26.tar.gz

既然我们基于文件系统的 DaemonSet 已经启动并运行,让我们继续修改客户端,以便它将输出发送到/var/log/$HOSTNAME.log而不是 TCP 端口 6666:

# logCompressorClient.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: client2
spec:
  replicas: 7
  selector:
    matchLabels:
      app: client2
  template:
    metadata:
      labels:
        app: client2
    spec:
      volumes:
      - name: logdir
        hostPath:
          path: /var/log
      containers:
      - name: client2
        image: alpine
        volumeMounts:
         - name: logdir
           mountPath: /var/log
        args:
        - /bin/sh
        - -c
        - >
          while true;
          do echo $(date) -
          Greetings from $HOSTNAME >> \
          /var/log/$HOSTNAME.log;
          sleep 15;
          done

如果我们仔细观察,我们会发现logCompressorClient.yaml包含与logCompressor.yaml相同的volumesvolumeMounts区间。这是因为 DaemonSet 及其客户端都需要它们共享的文件系统的详细信息。

一旦应用了logCompressorClient.yaml,我们可以等待几分钟,并证明每个主机中生成的 tarball(由 DaemonSet 创建)是否包含部署生成的日志文件:

$ kubectl apply -f logCompressorClient.yaml
deployment.apps/client2 created

# after a few minutes...

$ kubectl exec logcd-gdxc7 -- \
    tar -tf /var/log/all-logs-2019-04-26.tar.gz

var/log/cloud-init.log
var/log/kube-proxy.log
var/log/client2-5549f6854-c9mz2.log
var/log/client2-5549f6854-dvs4f.log
var/log/client2-5549f6854-lhgxx.log
var/log/client2-5549f6854-m29gb.log
var/log/client2-5549f6854-nl6nx.log
var/log/client2-5549f6854-trcgz.log

遵循$HOSTNAME.log命名约定的文件,如client2-5549f6854-c9mz2.log,确实是由logCompressorClient.yaml生成的。

注意

真实世界的日志解决方案很少依赖于节点的文件系统,而是依赖于云支持的外部驱动器(如 Google Cloud Platform Persistent Disk)。相反,CronJob(参见第七章)与 shell sleep语句相反,通常比 DaemonSet 更合适,因为它提供了全局调度功能。

仅在特定节点上运行的守护程序

在一些高级场景中,并非 Kubernetes 集群中的所有节点都是由商用硬件支持的同构、可任意使用的虚拟机。一些服务器可能具有图形处理单元(GPU)和快速固态硬盘(SSD)等特殊功能,甚至使用不同的操作系统。或者,我们可能想要在环境之间建立一个硬的而不是逻辑的隔离;换句话说,我们可能希望在节点级别而不是对象级别隔离环境——就像我们在使用名称空间时通常做的那样。这是我们将在本节中考虑的用例。

分离 DaemonSets,使它们落在特定的节点上,这要求我们对每个节点应用一个标签,以便能够区分。让我们首先列出我们目前拥有的节点:

$ kubectl get nodes
NAME         STATUS   AGE
gke-*-809q   Ready    1h
gke-*-dvzf   Ready    1h
gke-*-v0v7   Ready    1h

现在,我们可以将标签应用于每个列出的节点。标记方法依赖于我们在第二章中探讨过的kubectl label <RESOURCE-TYPE>/<OBJECT-IDENTIFIER>命令。我们将第一个节点gke-*-809q指定为prod(生产),将gke-*-dvzfgke-*-v0v7指定为dev(开发):

$ kubectl label node/gke-*-809q env=prod
node "gke-*-809q" labeled

$ kubectl label node/gke-*-dvzf env=dev
node "gke-*-dvzf" labeled

$ kubectl label node/gke-*-v0v7 env=dev
node "gke-*-v0v7" labeled

然后,我们可以使用kubectl get nodes命令和-L env标志检查每个节点标签的值,该标志显示一个名为ENV的额外列:

$ kubectl get nodes -L env
NAME         STATUS  AGE ENV
gke-*-809q   Ready   1h  prod
gke-*-dvzf   Ready   1h  dev
gke-*-v0v7   Ready   1h  dev

现在,我们要做的就是获取上一节中显示的logCompressor.yaml清单,并将pod.spec.nodeSelector属性添加到 Pod 模板中:

# at spec.template.spec
spec:
  nodeselector:
    env: prod

如果我们将新清单另存为logCompressorProd.yaml并应用它,结果将是 DaemonSet 的 Pod 将只部署到标签为prod的节点:

# clean up the environment first
$ kubectl apply -f logCompressorProd.yaml
daemonset.apps/logcd configured

$ kubectl get pods -o wide
NAME        READY  STATUS    NODE
logcd-4rps8 1/1    Running   gke-*-809q

注意

请注意,选择标记为prod(生产)和dev(开发)的节点仅仅是为了说明。分离 SDLC 阶段的实际环境通常是使用名称空间来实现的,有时甚至是完全不同的 Kubernetes 集群实例。

更新策略

当目标是产生零停机更新时,更新现有的 DaemonSet 与更新部署的工作方式并不完全相同。在典型的零停机部署更新中,会增加一个或多个额外的 Pod(使用deployment.spec.strategy.rollingUpdate.maxSurge属性进行控制),目的是在终止旧的 Pod 之前,始终至少有一个额外的 Pod 可用。该过程由服务控制器辅助,服务控制器可以随着迁移的进行将 pod 移入和移出负载均衡器。在 DaemonSet 的情况下,maxSurge属性不可用;我们会看到原因。

虽然由常规部署控制器控制的吊舱的位置(确切的着陆节点)是相当不合理的,但 DaemonSet 控制器的合同目标是确保每个节点正好有一个吊舱可用。因此,最小和最大“副本”的数量就是集群中的节点总数,不包括使用特殊节点选择器的情况,如上一节所示。此外,DaemonSet 控制器部署的 pod 通常使用文件系统或节点级 TCP 端口进行本地访问,而不是通过服务控制器管理的代理和 DNS 条目进行访问。简而言之,DaemonSet 的 Pod 是节点级的单例,而不是可伸缩对象群中的匿名成员。DaemonSets 实现系统级工作负载,与其他更短暂的应用(例如 web 服务器)相比,它承担着更基础的角色和更高的优先级。

让我们想象一下,假设将 DaemonSet 的maxSurge属性设置为 1 会有什么后果。如果可能的话,在 DaemonSet 更新过程中,可能会有数量超过集群中节点总数的 pod 存在一段时间。例如,在三个节点的 Kubernetes 集群中,1 的maxSurge将允许在 DaemonSet 更新期间存在四个节点。逻辑结果是额外的吊舱将落在已经有一个现有吊舱在运行的节点上;这违反了 DaemonSet 旨在保证的原则:每个节点只存在一个 Pod。结论是更新 DaemonSet(例如,选择新的映像)将涉及一些自然的停机时间,至少在本地节点级别。

DaemonSet 清单允许两种类型的更新策略:OnDeleteRollingUpdate。第一个命令指示 DaemonSet 控制器等待,直到每个 Pod 被手动删除之后,控制器才可以基于新清单中包含的模板用新 Pod 替换它。第二个操作类似于部署控制器的滚动更新声明,除了没有maxSurge属性,只有maxUnavailable。默认的更新策略实际上是RollingUpdate,其maxUnavailablity值为 1:

# at daemonset.spec (default)
updateStrategy:
 type: RollingUpdate
 rollingUpdate:
   maxUnavailable: 1

这种默认配置导致每当产生更新时,一次更新一个节点。例如,如果我们再次运行本章前面介绍的logCompressor.yaml清单,并将其默认映像更改为busybox——我们默认使用alpine——我们将看到 DaemonSet 控制器将一次处理一个节点,终止其正在运行的 Pod,部署新的 Pod,然后再移动到下一个节点:

$ kubectl get pods -o wide
NAME        READY STATUS  IP         NODE
logcd-k6kb4 1/1   Running 10.28.0.10 gke-*-h7b4
logcd-mtpnp 1/1   Running 10.28.2.12 gke-*-10gx
logcd-pgztn 1/1   Running 10.28.1.10 gke-*-lnxh

$ kubectl set image ds/logcd logcd=busybox
daemonset.extensions/logcd image updated

$ kubectl get pods -o wide -w
NAME        READY STATUS      IP         NODE
logcd-k6kb4 1/1   Running     10.28.0.10 gke-*-h7b4
logcd-mtpnp 1/1   Running     10.28.2.12 gke-*-10gx
logcd-pgztn 1/1   Running     10.28.1.10 gke-*-lnxh
logcd-pgztn 1/1   Terminating 10.28.1.10 gke-*-lnxh
logcd-57tzz 0/1   Pending     <none>     gke-*-lnxh
logcd-57tzz 1/1   Running     10.28.1.11 gke-*-lnxh
logcd-k6kb4 1/1   Terminating 10.28.0.10 gke-*-h7b4
...

在来自kubectl get pods的输出中,我们可以看到,最初有三个节点,在发出kubectl set image命令后,节点gke-*-lnxh中的 Pod 被终止,并且在 DaemonSet 控制器选择不同的节点gke-*-h7b4以再次应用更新过程之前,创建一个新的 Pod。

管理摘要

使用kubectl get daemonsets命令列出活动 DaemonSet 控制器的数量。例如,在应用本章中用作示例的logCompressor.yaml清单后,结果将如下所示:

$ kubectl get daemonsets
NAME  DESIRED CURRENT READY UP-TO-DATE AVAILABLE
logcd 3       3       3     3          3

如果没有指定特定的节点选择器——在所示的输出中就是这种情况——DESIRED列下的数字应该与 Kubernetes 集群中的节点总数相匹配。还请注意,显示了一个名为NODE SELECTOR的列(由于空间限制,在显示的输出中省略了该列),该列指示 DaemonSet 是否绑定到特定节点。

对于特定 DaemonSet 的进一步询问,可使用kubectl get daemonset/<NAME>kubectl describe daemonset/<NAME>命令。

删除操作与任何其他 Kubernetes 工作负载控制器一样。默认的删除命令kubectl delete daemonset/<NAME>将删除 DaemonSet 的所有相关窗格,除非应用了标志--cascade=false:

$ kubectl delete ds/logcd

daemonset.extensions "logcd" deleted

$ kubectl get pods
NAME         READY  STATUS
logcd-xgvm9  1/1    Terminating
logcd-z79xb  1/1    Terminating
logcd-5r5mn  1/1    Terminating

摘要

在本章中,我们了解到 DaemonSet 控制器用于在 Kubernetes 集群的每个节点中部署单个 Pod,以便可以使用 TCP 或文件系统对其进行本地访问,这两种方式通常都比其他类型的场外网络访问更快。本章举例说明的日志聚合器被用作教学范例;更多的工业用例包括健康监控代理和服务网格代理——或者所谓的边车

虽然 DaemonSets 与部署类似,但我们看到了一个关键的区别,即它们不适合零停机更新场景,因为 pod 在被新版本替换之前就被终止了;此行为可能会暂时中断使用 TCP 回送设备或文件系统在节点级别访问 DaemonSet 的 pod 的本地 pod。因此,如果希望 daemon set pod 的客户端能够经受住 daemon set pod 的实时更新,则必须以容错方式设计这些客户端。

九、状态集

可以说,十二因素应用方法是云原生应用最广泛的原则之一。称为“后勤服务”的因素 IV 表示后勤服务应被视为附属资源。其中一段,在 https://12factor.net/backing-services 处,写着:

十二要素应用的代码没有区分本地服务和第三方服务。对于应用来说,两者都是附加的资源,可以通过 URL 或存储在配置中的其他定位器/凭证来访问。十二因素应用的部署应该能够在不改变应用代码的情况下,将本地 MySQL 数据库与第三方(如 Amazon RDS)管理的数据库进行交换。同样,本地 SMTP 服务器可以与第三方 SMTP 服务(如邮戳)交换,而无需更改代码。在这两种情况下,只有配置中的资源句柄需要更改。

十二因素应用方法没有说明交付我们自己的支持服务的最佳实践,因为它假设应用将在一个无状态的 PaaS(如 Heroku、Cloud Foundry 或 Google App Engine)上运行,这些都是完全托管的服务。如果我们需要实现自己的后台服务,而不是依赖于,比如说,Google Bigtable,会怎么样?Kubernetes 实现后台服务的答案是 StatefulSet 控制器。

后台服务与无状态的十二因素应用有着不同的动态。缩放不是一件小事;“向下”扩展可能会导致数据丢失,而向上扩展可能会导致现有群集的不适当复制或重新共享。一些后台服务根本不打算扩展,至少不能自动扩展。

后台服务在如何实现高可伸缩性方面也有很大的不同。有些使用管理-工作(主-从)策略(例如 MySQL 和 MongoDB),而有些使用多主架构,例如 Memcached 和 Cassandra。Kubernetes 中的 StatefulSet 控制器不能对每个数据存储的性质做出宽泛的假设;因此,它侧重于底层的、原始的属性,例如稳定的网络身份,这些属性可以根据离散的问题或手头需要的属性,有选择地帮助实现它们。

在这一章中,我们将从头开始构建一个原始的键/值数据存储,它将用于内部化实现 StatefulSets 的原则,而没有遗漏可能不适用于单个特定产品(如 MySQL 或 MongoDB)的细节的风险。随着本章的深入,我们将丰富所述原始键/值数据存储。在第一部分中,我们将介绍顺序 Pod 创建、稳定的网络身份和使用无头服务的原则——后者是发布后台服务的关键。然后,我们将查看 Pod 生命周期事件,我们可以利用这些事件来实现正常的启动和关闭功能。最后,我们将展示如何实现基于存储的持久性,这也是有状态性的最终目的。

原始键/值存储

我们将要看到的可能被认为是穷人的 Memcached 或 BerkleyDB。这个键/值存储只执行三项任务:保存键/值对,通过唯一键查找和检索值,以及列出所有现有键。密钥作为常规文件保存在文件系统中,其中文件名是密钥,其内容是值。没有输入验证、删除功能和任何种类的安全措施。前面提到的三个函数( saveload、allKeys )分别使用 Python 3:

#!/usr/bin/python3
# server.py
from flask import Flask
import os
import sys

if len(sys.argv) < 3:
  print("server.py PORT DATA_DIR")
  sys.exit(1)

app     = Flask(__name__)
port    = sys.argv[1]
dataDir = sys.argv[2] + '/'

@app.route('/save/<key>/<word>')
def save(key, word):
  with open(dataDir + key, 'w') as f:
       f.write(word)
  return word

@app.route('/load/<key>')
def load(key):
  try:
    with open(dataDir + key) as f:
      return f.read()
  except FileNotFoundError:
      return "_key_not_found_"

@app.route('/allKeys')
def allKeys():
  keys = ".join(map(lambda x: x + ",",
         filter(lambda f:
                os.path.isfile(dataDir+'/'+f),
                os.listdir(dataDir)))).rstrip(',')
  return keys

if __name__ == '__main__':
  app.run(host='0.0.0.0', port=port)

请注意,在本章的文件夹下找到的实际文件server.py具有额外的特性(换句话说就是代码),这些特性在给出的清单中被省略了。所述省略的特征有助于处理平稳的启动和关闭,并且将在前面的几个部分中讨论。

要在本地试验服务器,我们可以首先安装 Flask,然后通过传递端口号和数据目录作为参数来启动服务器:

$ sudo pip3 install Flask
$ mkdir -p /tmp/data
$ ./server.py 1080 /tmp/data

一旦服务器启动并运行,我们就可以通过插入和检索一些键/值对来“玩”它:

$ curl http://localhost:1080/save/title/Sapiens
Sapiens
$ curl http://localhost:1080/save/author/Yuval
Yuval
$ curl http://localhost:1080/allKeys
author,title
$ curl http://localhost:1080/load/title
Sapiens
$ curl http://localhost:1080/load/author
Yuval

最小状态集清单

在上一节中,我们已经介绍了用 Python 编写的基于键/值存储 HTTP 的服务器,现在我们将使用 StatefulSet 控制器运行它。

最小 StatefulSet 清单在很大程度上类似于部署清单:它允许定义副本的数量、具有一个或多个容器的 Pod 模板等等:

# wip/server.yaml
# Minimal manifest for running server.py
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: server
spec:
  selector:
    matchLabels:
      app: server
  serviceName: server
  replicas: 3
  template:
    metadata:
      labels:
        app: server
    spec:
      containers:
      - name: server
        image: python:alpine
        args:
        - bin/sh
        - -c
        - >-
          pip install flask;
          python -u /var/scripts/server.py 80
          /var/data
        ports:
        - containerPort: 80
        volumeMounts:
          - name: scripts
            mountPath: /var/scripts
          - name: data
            mountPath: /var/data
      volumes:
        - name: scripts
          configMap:
            name: scripts
        - name: data
          emptyDir:
            medium: Memory

我们使用的不是容器化的server.py,而是包含 Python 3 解释器的现成 Docker 映像python:alpine。文件server.py必须“上传”为名为scripts的配置图,其设置如下:

#!/bin/sh
# wip/configmap.sh
kubectl delete configmap scripts \
  --ignore-not-found=true
kubectl create configmap scripts \
  --from-file=../server.py

另外,请注意使用名为data的卷,该卷使用 RAM 内存设置为类型为emptyData的卷。这意味着我们的键/值存储目前作为内存中的缓存工作,而不是在服务器崩溃后仍然存在的持久性存储。我们将很快详细阐述这方面的内容。

现在,我们已经拥有了将键/值存储作为有状态集合运行所需的一切:

$ cd wip
$ ./configmap.sh
$ kubectl apply -f server.yaml
statefulset.apps/server created

当我们用kubectl get pods列出结果 Pod 时,我们可以注意到,与部署不同,Pod 名称遵循一个连续的顺序,从0,开始,而不是有一个随机的后缀。我们将在下一节讨论顺序 Pod 创建属性:

$ kubectl get pods
NAME       READY     STATUS    RESTARTS   AGE
server-0   1/1       Running   0          5s
server-1   1/1       Running   0          0s
server-2   0/1       Pending   0          0s

为了证明键/值存储服务器工作正常,我们可以用其中一个 pod 建立一个代理,设置一个键/值对,然后检索它的值:

$ kubectl port-forward server-0 1080:80
Forwarding from 127.0.0.1:1080 -> 80
...

# Set a key/value pair
$ curl http://localhost:1080/save/title/Sapiens
Sapiens

# Retrieve the value for the title key
$ curl http://localhost:1080/load/title
Sapiens

如果kubectl port-forward报告了一个错误,比如bind: address already in use,这意味着我们让server.py在端口 1080 上运行,或者其他进程正在使用这个端口。如果源代码中的端口碰巧被永久地分配给了某个其他应用,读者可能会更改该端口。

顺序 Pod 创建

默认情况下,部署控制器并行创建所有的 pod(除非升级时可能会应用.maxSurge约束),以加速该过程。相反,StatefulSet 控制器按顺序创建 pod,从0开始,直到定义的副本数减一。该行为由statefulset.spec.podManagementPolicy属性控制,其默认值为OrderedReady。另一个可能的值是Parallel,,它产生与部署和复制集控制器相同的行为。

在前面的部分中,我们可以通过在应用kubectl apply -f server.yaml之前运行kubectl get pods -w来看到连续的 Pod 创建过程:

$ kubectl get pods -w
NAME       READY  STATUS
server-0   0/1    Pending
server-0   0/1    ContainerCreating
server-0   1/1    Running
server-1   0/1    Pending
server-1   0/1    ContainerCreating
server-1   1/1    Running
server-2   0/1    Pending
server-2   0/1    ContainerCreating
server-2   1/1    Running

为什么顺序 Pod 创建很重要?因为后台服务通常具有依赖于可靠假设的语义,这些假设是关于先前已经创建了哪些确切的 pod 以及接下来将创建哪些 pod:

  • 在基于管理人员-工作人员范例的后备存储中,比如 MongoDB 或 MySQL,工作人员 pod pod-1 和 pod-2 可能希望首先定义 pod-0(管理人员),以便可以向它注册。

  • Pod 创建序列可以包括按比例增加现有的集群,其中数据可以从 pod-0 复制到 pod-1、pod-2 和 pod-3。

  • pod 删除序列可能需要逐个注销工作线程。

正如在最后一点中提到的,顺序 Pod 创建的属性也反向工作:具有最高索引的 Pod 总是首先终止。例如,通过发出kubectl scale statefulset/server --replicas=0将键/存储集群减少到 0 个副本会导致以下行为:

$ kubectl get pods -w
NAME       READY     STATUS
server-0   1/1       Running
server-1   1/1       Running
server-2   1/1       Running
server-2   1/1       Terminating
server-2   0/1       Terminating
server-1   1/1       Terminating
server-1   0/1       Terminating
server-0   1/1       Terminating
server-0   0/1       Terminating

稳定的网络身份

在无状态应用中,每个副本的特定身份和位置是短暂的。只要我们能够到达负载均衡器,哪个特定的副本服务于我们的请求并不重要。对于后台服务来说,情况不一定如此,比如我们的原始键/值存储或 Memcached 之类的服务。许多多主存储在不产生中心争用点的情况下解决规模问题的方法是,让每个客户机(或等效的委托代理)知道每个服务器主机,以便客户机自己决定在哪里存储和检索数据。

因此,在 StatefulSets 的情况下,根据数据存储的水平扩展策略,对于客户端来说,确切地知道它们已经将数据保存到的 Pod 可能是至关重要的,以便在扩展、重启和失败事件之后,加载请求总是与用于原始保存请求的原始 Pod 相匹配。

例如,如果我们将键title设置在 Pod server-0上,我们知道我们可以稍后返回并从完全相同的 Pod 中检索它。相反,如果 Pod 由常规部署控制器管理,那么 Pod 将被赋予一个随机的名称,例如server-1539155708-55dqsserver-1539155708-pkj2w。即使客户端可以记住这样的随机名称,也不能保证存储的键/值对在删除或放大/缩小事件后仍然存在。

稳定网络身份的属性对于应用允许跨多个计算和数据资源扩展数据的分片机制至关重要。分片意味着给定的数据集被分解成块,每个块最终位于不同的服务器上。根据手头数据的类型和更均匀分布的字段或属性,将数据集分成块的标准可能会有所不同;例如,对于联系人实体,名字和姓氏是很好的属性,而性别则不是。

让我们假设我们的数据集由关键字abcd组成。我们如何在三台服务器上平均分配每封信?最简单的解决方案是应用模运算。通过获取每个字母的 ASCII 十进制代码并获取服务器数量的模,我们获得了一个廉价的分片解决方案,如表 9-1 所示。

表 9-1

将模运算符应用于 ASCII 字母

|

钥匙

|

小数

|

以…为模

|

计算机网络服务器

|
| --- | --- | --- | --- |
| a | Ninety-seven | 97 % 3 = 1 | 服务器-1 |
| b | Ninety-eight | 98 % 3 = 2 | 服务器-2 |
| c | Ninety-nine | 99 % 3 = 0 | 服务器-0 |
| d | One hundred | 100 % 3 = 1 | 服务器-1 |

Cassandra 或 Memached 等生产级后备存储中的实际哈希算法将更加复杂,并使用一致的哈希算法——这样,当添加或删除新服务器时,总的来说,密钥不会位于不同的服务器上——但基本原理是相同的。

这里的关键见解是,客户端要求服务器有一个稳定的网络身份,因为它们将为每个服务器分配一个密钥子集。这正是区分 StatefulSets 和 Deployments 的关键特性之一。

无头服务

如果客户机首先无法到达服务器单元,那么稳定的网络身份就没有多大用处。客户端对 StatefulSet 控制的 Pod 的访问不同于适用于部署控制的 Pod 的访问,因为跨随机 Pod 实例的负载均衡是不合适的;客户需要直接访问离散单元。然而,Pod 将到达的具体节点和 IP 地址只能在运行时确定,因此发现机制仍然是必要的。

在 StatefulSets 的情况下使用的解决方案仍然在于服务控制器(第四章),除了它被配置为提供所谓的无头服务。无头服务只提供一个 DNS 条目和代理,而不是一个负载均衡器,它是使用常规服务清单设置的,除了service.spec.clusterIP属性被设置为None:

# wip/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: server
  labels:
    app: server
spec:
  ports:
  - port: 80
  clusterIP: None
  selector:
    app: server

让我们应用如下的service.yaml清单:

# Assume wip/server.yaml has been applied first

$ cd wip
$ kubectl apply -f service.yaml
service/server created

server服务创建的 DNS 条目将为每个正在运行并准备就绪的 Pod 提供一个 DNS SRV 记录。我们可以使用nslookup命令来获得这样的 DNS SRV 记录:

$ kubectl run --image=alpine --restart=Never \
    --rm -i test \
    -- nslookup server

10.36.1.7 server-0.server.default.svc.cluster.local
10.36.1.8 server-2.server.default.svc.cluster.local
10.36.2.7 server-1.server.default.svc.cluster.local

我们的原始键/值存储的智能客户端

到目前为止,我们已经使用 StatefulSet 控制器成功运行了键/值存储的多个副本,并使用 headless 服务使感兴趣的消费者应用可以访问每个 Pod 端点。我们以前也解释过,可伸缩性是由客户机管理的,而不是由服务器本身管理的——在多主范例中,我们选择在本章中探讨。现在,让我们创建一个智能客户端,它允许我们进一步了解 StatefulSet 的行为。

在最后展示整个源代码之前,我们将描述我们的客户端的关键方面。客户端需要了解的第一件事是它将与之交互的服务器的确切集合,以及它是否应该以只读模式运行:

if len(sys.argv) < 2:
  print('client.py SRV_1[:PORT],SRV_2[:PORT],' +
        '... [readonly]')
  sys.exit(1)

# Process input arguments
servers  = sys.argv[1].split(',')
readonly = (True if len(sys.argv) >= 3
            and sys.argv[2] == 'readonly' else False)

三副本服务器的典型调用如下——不要运行此示例;这仅用于说明:

./server.py \
  server-0.server,server-1.server,server-2.server

我们的客户端保存了一组由定义数量的服务器上的英文字母组成的密钥,现在存储在servers变量中。当客户端启动时,它将首先打印由虚线下划线的完整字母表,以及通过获取每个密钥的 ASCII 码的模而被选择来存储每个密钥的服务器号:

# Print alphabet and selected server for each letter
sn = len(servers)
print(' ' * 20 + string.ascii_lowercase)
print(' ' * 20 + '-' * 26)
print(' ' * 20 + ".join(
                   map(lambda c: str(ord(c) % sn),
                   string.ascii_lowercase)))
print(' ' * 20 + '-' * 26)

如果三个服务器被定义为参数,这个代码片段将产生以下输出,这些参数又变成变量servers中的一个列表:

                    abcdefghijklmnopqrstuvwxyz
                    --------------------------
                    12012012012012012012012012
                    --------------------------

让字母表中的每个字母与数字 0、1 或 2 垂直对齐的含义是,按键将按照表 9-2 所示进行分配。

表 9-2

字母表字母和指定的服务器

|

|

计算机网络服务器

|
| --- | --- |
| [c,f,i,l,o,r,u,x] | 服务器-0 .服务器 |
| [a,d,g,j,m,p,s,v,y] | 服务器-1 .服务器 |
| [b,e,h,k,n,q,t,w,z] | 服务器-2 .服务器 |

现在,在初始化之后,客户端将在一个循环中运行,检查是否在匹配的服务器中找到了每个密钥,如果没有,它将尝试插入它,除非它以只读模式运行:

# Iterate through the alphabet repeatedly
while True:
  print(str(datetime.datetime.now())[:19] + ' ',
        end=")
  hits = 0
  for c in string.ascii_lowercase:
    server = servers[ord(c) % sn]
    try:
      r = curl('http://' + server + '/load/' + c)
      # Key found and value match
      if r == c:
        hits = hits + 1
        print('h',end=")
      # Key not found
      elif r == '_key_not_found_':
        if readonly:
          print('m',end=")
        else:
          # Save Key/Value (not read only)
          r = curl('http://' + server +
                   '/save/' + c + '/' + c)
          print('w',end=")
      # Value mismatch
      else:
        print('x',end=")
    except urllib.error.HTTPError as e:
          print(str(e.getcode())[0],end=")
    except urllib.error.URLError as e:
          print('.',end=")
  print(' | hits = {} ({:.0f}%)'
        .format(hits,hits/0.26))
  time.sleep(2)

每个键的结果将使用一个状态字母显示。以下是第一次运行客户端的示例,假设 StatefulSet 及其 headless 服务已启动并正在运行:

      abcdefghijklmnopqrstuvwxyz
      --------------------------
      12012012012012012012012012
      --------------------------
03:51 wwwwwwwwwwwwwwwwwwwwwwwwww | hits = 0 (0%)
03:53 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
03:55 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
...

表 9-3 给出了字母表中每个字母的含义,包括输出中显示的wh

表 9-3

字母表的字母栏下每个状态字母的含义

|

|

描述

|
| --- | --- |
| w | 写:在服务器中没有找到密钥,所以保存了它。 |
| h | 命中:在服务器中找到了密钥。 |
| m | Miss:未找到密钥,不会保存(只读)。 |
| x | 异常:找到了键,但它与其值不匹配。 |
| . | 服务器不可访问或网络故障。 |
| 0-9 | 返回了 HTTP 服务器错误。例如,503 就是 5。 |

在仔细检查了每个代码片段之后,我们现在呈现完整的客户端 Python 脚本:

#!/usr/bin/python3
# client.py
import string
import time
import sys
import urllib.request
import urllib.error
import datetime

if len(sys.argv) < 2:
  print('client.py SRV_1[:PORT],SRV_2[:PORT],' +
        '... [readonly]')
  sys.exit(1)

# Process input arguments
servers  = sys.argv[1].split(',')
readonly = (True if len(sys.argv) >= 3
            and sys.argv[2] == 'readonly' else False)

# Remove boilerplate from HTTP calls
def curl(url):
  return urllib.request.urlopen(url).read().decode()

# Print alphabet and selected server for each letter
sn = len(servers)
print(' ' * 20 + string.ascii_lowercase)
print(' ' * 20 + '-' * 26)
print(' ' * 20 + ".join(
                   map(lambda c: str(ord(c) % sn),
                       string.ascii_lowercase)))
print(' ' * 20 + '-' * 26)

# Iterate through the alphabet repeatedly
while True:
  print(str(datetime.datetime.now())[:19] + ' ',
        end=")
  hits = 0
  for c in string.ascii_lowercase:
    server = servers[ord(c) % sn]
    try:
      r = curl('http://' + server + '/load/' + c)
      # Key found and value match
      if r == c:
        hits = hits + 1
        print('h',end=")
      # Key not found
      elif r == '_key_not_found_':
        if readonly:
          print('m',end=")
        else:
          # Save Key/Value (not read only)
          r = curl('http://' + server +
                   '/save/' + c + '/' + c)
          print('w',end=")
      # Value mismatch
      else:
        print('x',end=")
    except urllib.error.HTTPError as e:
          print(str(e.getcode())[0],end=")
    except urllib.error.URLError as e:
          print('.',end=")
  print(' | hits = {} ({:.0f}%)'
        .format(hits,hits/0.26))
  time.sleep(2)

除了我们之前定义的server.py,我们还必须将client.py添加到名为scripts的配置图中。因此,我们定义了一个名为wip/configmap2.sh :的新文件

#!/bin/sh
# wip/configmap2.sh
kubectl delete configmap scripts \
  --ignore-not-found=true
kubectl create configmap scripts \
  --from-file=server.py --from-file=../client.py

最后,我们需要一个 Pod 清单来运行客户端,并为它提供预期的状态集 Pod 名称:

# client.yaml
apiVersion: v1
kind: Pod
metadata:
  name: client
spec:
  restartPolicy: Never
  containers:
    - name: client
      image: python:alpine
      args:
      - bin/sh
      - -c
      - "python -u /var/scripts/client.py
        server-0.server,server-1.server,\
        server-2.server"
      volumeMounts:
        - name: scripts
          mountPath: /var/scripts
  volumes:
    - name: scripts
      configMap:
        name: scripts

作为最后一个实验,从零开始重置我们的环境是很有趣的,首先启动客户端,并在没有serversstatefullset 的的情况下观察它的初始行为:

# Clean up the environment first

$ cd wip
$ ./configmap2.sh
configmap "scripts" deleted
configmap/scripts created

# Run the client
$ kubectl apply -f ../client.yaml
pod/client created

# Query the client's logs
$ kubectl logs -f client

      abcdefghijklmnopqrstuvwxyz
      --------------------------
      12012012012012012012012012
      --------------------------
00:21 .......................... | hits = 0 (0%)
00:24 .......................... | hits = 0 (0%)
00:26 .......................... | hits = 0 (0%)
00:28 .......................... | hits = 0 (0%)

.(点)字符表示没有可用于任何键的服务器。让我们继续启动服务器及其关联的 headless 服务,同时在一个单独的窗口中查看客户端的日志:

# Note: we are still under the wip directory

$ kubectl apply -f server.yaml
statefulset.apps/server created

$ kubectl apply -f service.yaml
service/server created

客户端将自动开始将密钥(w)保存到出现的每个服务器:

      abcdefghijklmnopqrstuvwxyz
      --------------------------
      12012012012012012012012012
      --------------------------
...   ...                          ...
04:14 .......................... | hits = 0 (0%)
04:18 .......................... | hits = 0 (0%)
04:21 .......................... | hits = 0 (0%)
04:23 w..w..w..w..w..w..w..w..w. | hits = 0 (0%)
04:25 h..h..h..h..h..h..h..h..h. | hits = 9 (35%)
04:27 h.wh.wh.wh.wh.wh.wh.wh.wh. | hits = 9 (35%)
04:29 hwhhwhhwhhwhhwhhwhhwhhwhhw | hits = 17 (65%)
04:31 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
04:33 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)

这是如何逐行解释发生的事情:

04:23 w..w..w..w..w..w..w..w..w. | hits = 0 (0%)

服务器server-1.server第一个出现,这导致密钥[a,d,g,j,m,p,s,v,y]被保存(w)到其中。服务器server-0.serverserver-2.server还无法访问:

04:25 h..h..h..h..h..h..h..h..h. | hits = 9 (35%)

服务器server-1.server已经包含导致命中的密钥[a,d,g,j,m,p,s,v,y](h)。服务器server-0.serverserver-2.server还不能访问。

04:27 h.wh.wh.wh.wh.wh.wh.wh.wh. | hits = 9 (35%)

现在服务器server-2.server已经启动,密钥[b,e,h,k,n,q,t,w,z]已经保存到其中。现在只有server-0.server还无法进入;

04:29 hwhhwhhwhhwhhwhhwhhwhhwhhw | hits = 17 (65%)

服务器server-0终于启动了,密钥[c,f,i,l,o,r,u,x]已经保存到其中:

04:31 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)

密钥集现在分布在所有三台服务器上。

请注意,这个例子似乎违反了顺序创建 Pod 的原则。如果我们使用kubectl get pods -w观察 Pod 创建行为,我们将看到该原理仍然适用。但是,如果 Pods 启动得足够快,就绪探测变为活动状态的时间加上 DNS 缓存,可能会导致从客户端角度看起来无序的行为。如果要求客户端自己体验有保证的有序 Pod 创建,那么我们需要增加一些延迟,以允许就绪性探测器开始工作,并允许刷新和/或刷新 DNS 缓存。

控制后备存储器的创建和终止

正如我们在本章的介绍中所解释的,StatefulSet 控制器不能对后备存储的具体性质做出宽泛的假设,因为每个高可伸缩性和可用性范例以及每个产品的技术设计和约束(例如,MySQL 与 MongoDB)所采取的选择会导致大量的可能性。例如:

  • 向主管登记员工

  • 向控制器(如 ZooKeeper)注册副本

  • 与工人一起复制主数据集

  • 在将先行副本标记为就绪之前,等待集群的最后一个成员启动并运行

  • 领导人改选(并向客户公布选举结果)

  • 重新计算哈希值

话虽如此,我们可以对 StatefulSet 的生命周期进行推理,并理解 Kubernetes 管理员拥有哪些机会来实施控制。StatefulSet 控制器给我们施加所述控制的主要烹饪成分是

  1. 保证 Pod 的创建和终止是有序的,并且它们的身份是可预测的。例如,如果$HOSTNAME的值是server-2,我们可以预计server-0server-1将首先被创建。

  2. 使用 headless 服务到达同一组中的其他 pod 的机会:这与第一点有关;如果我们在server-2内部,无头服务器将发布 DNS 条目以连接到server-0sever-1

  3. 在主容器运行之前,有机会运行一个或多个定制的初始化容器。这些在 StatefulSet 清单中的statefulset.spec.template.spec.initContainers处定义。例如,我们可能希望在正式后备存储的容器运行之前,使用初始化容器从外部源导入 SQL 脚本。官方后台存储的 Docker 映像可能是供应商提供的,用自定义代码“污染它”可能不是一个好主意。

  4. 当每个主容器初始化时,以及当它们使用在statefulset.spec.template.spec.containers.lifecycle声明的生命周期钩子postStartpreStop终止时,运行命令的机会。在前面的小节中,我们将把这个特性用于我们的基本键/值存储。

  5. 捕捉 SIGTERM Linux 信号的机会,该信号在终止时被发送到每个主容器的每个第一个进程。当容器接收到 SIGTERM 信号时,它们可以在由statefulset.spec.template.spec.terminationGracePeriodSeconds属性定义的秒数内运行正常关闭代码。

  6. Pod 的默认活性和准备就绪探测器(见第二章)允许决定给定的 Pod 何时对世界可用。

Pod 生命周期事件的顺序

在上一节中,我们已经看到,在创建和销毁后备存储单元时,有多种运行代码的机会,但是我们没有讨论何时适合应用各种选项。例如,我们应该使用 Init Containers 还是 PostStart hook 为 MySQL 数据库设置初始表吗?要回答这些问题,有必要了解整个 Pod 生命周期中发生的事件的顺序。为此,我们将首先介绍的创建生命周期,然后是的终止生命周期。

注意

本节假设了各种对读者来说可能是新的一般概念:

  • 宽限期:在进程被强制终止之前,允许它运行正常关闭任务的时间。

  • 平稳启动和关闭:分别执行“拆除”和“拆除”任务,帮助最大限度地减少中断,防止系统、进程或数据处于不一致的状态。

  • 钩子:一个占位符,允许插入触发器、脚本或其他代码执行机制。

  • Sigkill:发送给进程以使其立即终止的信号。这个信号通常不能被捕捉或忽略。

  • SIGTERM:发送给进程请求终止的信号。这个信号可以被进程捕获、解释或忽略。作为正常关闭策略的一部分,实现进程级清理代码是有帮助的。

  • 稳态:其特征变量不随时间变化的状态。

创建生命周期包括 Pod 的启动(因为第一次创建 StatefulSet,或者由于缩放事件)。这意味着将一个 Pod 从不存在状态变为运行状态。表 9-4 显示了最相关的生命周期事件的顺序( C0…C4,S ),其中 C 代表创建, S 代表稳定状态。稳定状态是指 Pod 不会发生与启动或关闭过程无关的生命周期变化。在第二行中,P 代表挂起,而 R 代表运行。

表 9-4

状态集控制的 Pod 创建生命周期事件

|

描述

|

无着丝粒的

|

C1

|

C2

|

C3

|

补体第四成份缺乏

|

S

|
| --- | --- | --- | --- | --- | --- | --- |
| Pod 状态 | P | P | 稀有 | 稀有 | 稀有 | 稀有 |
| 初始化容器运行 |   | ●执行下列操作 |   |   |   |   |
| 主容器运行 |   |   | ●执行下列操作 |   |   |   |
| 启动后挂钩运行 |   |   | ●执行下列操作 |   |   |   |
| 活性探测运行 |   |   |   | ●执行下列操作 |   |   |
| 就绪探测运行 |   |   |   | ●执行下列操作 |   |   |
| 此端点已发布 |   |   |   |   | ●执行下列操作 |   |
| N-1 个端点已发布 | ●执行下列操作 | ●执行下列操作 | ●执行下列操作 | ●执行下列操作 | ●执行下列操作 | ●执行下列操作 |
| 发布了 N+1 个端点 |   |   |   |   |   | ●执行下列操作 |

请注意,表 9-4 代表了一个粗略的指南,一些关键的考虑因素也适用:

  • Pod 状态是由pod.status.phase属性提供的正式 Pod 阶段。虽然挂起(P)和运行(R)意味着是正式阶段,但是kubectl get pod命令可能显示中间状态,例如在 C0C1 之间的初始化以及在 C1C2 之间的pod 初始化

  • 如果 Init 容器因退出而失败,并返回一个非零退出代码,主容器将不会被执行。

  • 主容器和与 PostStart 挂钩关联的命令并行运行,Kubernetes 不保证先运行哪一个。

  • 在主容器启动之前,活性和准备就绪探测器开始运行。

  • 适用的 Pod 的端点是在内部准备就绪探测为肯定之后的某个时间由服务控制器发布的,但是 DNS 生存时间(TTL)设置(在服务器和客户端)和网络传播问题可能会延迟其他副本和客户端对 Pod 的可见性。

** N-1 个端点(例如server-0.serverserver-1.server如果参考箱 N 是server-2)被认为是可访问的,因为server-2仅在server-1变为就绪时被初始化;但是,它们在被查询时可能已经失败,或者可能暂时无法访问。由于这个原因,防弹代码应该总是 ping 并探测一个依赖的 Pod,而不是盲目地假设它必须启动并运行,因为有顺序的 Pod 创建保证。

*   *N+2 个端点*(例如,如果参考 Pod N 是`server-2`,则为`server-3.server`和`server-4.server`)将仅在当前 Pod 准备就绪后被初始化。因此,如果某些代码需要等待将来的 Pods 变得可用,它们必须作为主容器运行,并且有一个 DNS 探测/ping 循环。* 

*现在让我们来看一下终端 Pod 生命周期(表 9-5 ),每当一个状态集被缩减或一个单独的 Pod 被删除时,该生命周期从 ?? 开始。第一列 S ,表示触发终止事件之前的 Pod 的稳定状态。

表 9-5

状态集控制的 Pod 终止生命周期事件

|

描述

|

S

|

一种网络的名称(传输率可达 1.54mbps)

|

??

|

??

|
| --- | --- | --- | --- | --- |
| Pod 状态 | 稀有 | T | T | T |
| 主容器运行 | ●执行下列操作 | ●执行下列操作 | ●执行下列操作 |   |
| 预停止挂钩运行 |   | ●执行下列操作 |   |   |
| 宽限期开始 |   | ●执行下列操作 |   |   |
| 宽限期已结束 |   |   |   | ●执行下列操作 |
| 主容器信号术语 |   |   | ●执行下列操作 | ●执行下列操作 |
| 主容器信号 |   |   |   |   |
| 此端点已发布 | ●执行下列操作 |   |   |   |
| N-1 个端点已发布 | ●执行下列操作 | ●执行下列操作 | ●执行下列操作 | ●执行下列操作 |
| 发布了 N+1 个端点 |   |   |   |   |

同样,当考虑表 9-5 时,相当值得注意的事项适用:

  • ?? 的终止事件是“残酷的”,没有留下人们可能需要的那么多优雅的关闭范围。特别是,被终止的 Pod 被立即从无头服务中删除;因此为什么在这个端点发布的上的●(点)字符在 ?? 本身就消失了。虽然底层应用对于那些已经预先通过 TCP 连接建立的客户端可能仍然是可访问的,但是那些恰好在 ?? 之后查询 DNS 服务或端点控制器的客户端将“看不到”终止的 Pod。

  • 预停挂钩宽限期将在 ?? 一起开始。 SIGTERM 信号将由主容器在??处接收,在预停止挂钩完成之后,但在宽限期结束之前。根据terminationGracePeriodSeconds属性的值,无论需要什么样的正常关机代码,都必须在分配的时间内完成。

  • ?? 处,当宽限期结束时,主容器将被杀死,不再有机会恢复或运行缓解代码。

使用 Pod 生命周期挂钩实现正常关机

在上两节中,我们已经讨论了可用于控制状态集生命周期的各种选项,而状态集又是其每个组成 pod 的单个生命周期的集合。现在,在这一节中,我们将回到面向实验室的工作流,并向我们的原始键/值存储添加一种简单形式的正常关闭。

我们的正常关机包括每当 Pod 终止时返回 503 HTTP 错误,而不是让客户端简单地超时。即使一旦确认了终止事件,就将 Pod 从无头服务 DNS 中删除,如果 Pod 突然超时而没有通知,则在上次检查 DNS 条目之前记得 IP 地址(和/或已经建立了 TCP 连接)的客户端可能会表现出不稳定的行为。尽管这个解决方案看起来很简单,但它可以帮助智能客户端“后退”一段时间,实现断路器模式,和/或从不同的源访问数据。

为此,我们将在处理任何 HTTP 请求之前检查是否存在名为_shutting_down_的文件:

if os.path.isfile(dataDir + '_shutting_down_'):
  return "_shutting_down_", 503

前面的if语句用于指示服务是否即将关闭,现在位于更新后的server.py脚本中每个 Flask HTTP 函数的顶部。请注意,为了简洁和避免干扰,本章前面给出的server.py代码清单没有显示save()load()allKeys()函数之后的两行代码。

既然我们在客户端有了一个廉价的优雅关闭机制,我们需要在服务器端实现它。我们在这里需要做的是一旦收到终止事件就创建一个_shutting_down_文件,并在 Pod 启动时删除它。目的是使用该文件的存在与否来分别表示服务器是否将要关闭。为了实现该文件的创建和删除,我们将分别使用preStoppostStart pod 生命周期挂钩(参见表 9-4 和 9-5 ):

# server.yaml
...
spec:
  template:
    spec:
      containers:
      - name: server
        lifecycle:
          postStart:
            exec:
              command:
              - /bin/sh
              - -c
              - rm -f /var/data/_shutting_down_
          preStop:
            exec:
              command:
              - /bin/sh
              - -c
              - touch /var/data/_shutting_down_
...

这个片段包含在新的和最终的 server.yaml清单中,直接位于章节的根目录下,而不是wip/,其中我们还将terminationGracePeriodSeconds设置为10,这样观察终止行为花费的时间就少了(默认为 30 秒)。为了简单起见,我们还在单个 YAML 文件中添加了服务清单,使用了---(三连字符)YAML 符号,这样我们只需一个命令就可以创建最终的服务器:

# Memory-based key/value store
# server.yaml
---
apiVersion: v1
kind: Service
metadata:
  name: server
  labels:
    app: server
spec:
  ports:
  - port: 80
  clusterIP: None
  selector:
    app: server
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: server
spec:
  selector:
    matchLabels:
      app: server
  serviceName: server
  replicas: 3
  template:
    metadata:
      labels:
        app: server
    spec:
      terminationGracePeriodSeconds: 10
      containers:
      - name: server
        image: python:alpine
        args:
        - bin/sh
        - -c
        - >-
          pip install flask;
          python -u /var/scripts/server.py 80
          /var/data
        ports:
        - containerPort: 80
        volumeMounts:
          - name: scripts
            mountPath: /var/scripts
          - name: data
            mountPath: /var/data
        lifecycle:
          postStart:
            exec:
              command:
              - /bin/sh
              - -c
              - rm -f /var/data/_shutting_down_
          preStop:
            exec:
              command:
              - /bin/sh
              - -c
              - touch /var/data/_shutting_down_
      volumes:
        - name: scripts
          configMap:
            name: scripts
        - name: data
          emptyDir:
            medium: Memory

观察状态集故障

在上一节中,我们通过利用postStartpreStop生命周期挂钩,在server.yamlserver.py中实现了优雅关闭功能。在本节中,我们将看到这种功能的实际应用。让我们从将当前工作目录更改为章节的根目录并运行新定义的文件开始:

# clean up the environment first

$ ./configmap.sh
configmap/scripts created

$ kubectl apply -f server.yaml
service/server created
statefulset.apps/server created

$ kubectl apply -f client.yaml
pod/client created

既然已经创建了服务器和客户机对象,我们可以再次跟踪客户机的日志:

$ kubectl logs -f client
      abcdefghijklmnopqrstuvwxyz
      --------------------------
      12012012012012012012012012
      --------------------------
24:41 wwwwwwwwwwwwwwwwwwwwwwwwww | hits = 0 (0%)
24:43 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
24:45 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
24:47 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
...

在保持kubectl logs -f client在单独的终端窗口上运行的同时,我们现在可以看到通过发出kubectl delete pod/server-1命令从 StatefulSet 中删除一个 Pod 的效果:

34:45 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
34:47 5hh5hh5hh5hh5hh5hh5hh5hh5h | hits = 17 (65%)
34:49 5hh5hh5hh5hh5hh5hh5hh5hh5h | hits = 17 (65%)
34:51 5hh5hh5hh5hh5hh5hh5hh5hh5h | hits = 17 (65%)
34:53 .hh.hh.hh.hh.hh.hh.hh.hh.h | hits = 17 (65%)
34:55 .hh.hh.hh.hh.hh.hh.hh.hh.h | hits = 17 (65%)
34:57 .hh.hh.hh.hh.hh.hh.hh.hh.h | hits = 17 (65%)
35:00 .hh.hh.hh.hh.hh.hh.hh.hh.h | hits = 17 (65%)
35:02 .hh.hh.hh.hh.hh.hh.hh.hh.h | hits = 17 (65%)
35:04 whhwhhwhhwhhwhhwhhwhhwhhwh | hits = 17 (65%)
35:06 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)

在这个日志中,我们看到客户端在第34:47秒和第34:51秒之间为我们刚刚删除的服务器显示了一个数字5(代替503)。在此期间,服务器和客户端都可以实现代码以无影响的方式脱离。在34:5335:02之间,客户端无法到达服务器 1,如.(点)所暗示的,直到它最终设法在35:04再次保存密钥,如w(写)所指示的。客户端最终在第二次35:06报告所有服务器上的h(命中)。

如果读者想知道为什么被删除的服务器会在一段时间后自动复活,这是因为 StatefulSet 控制器的职责是确保运行时规范与清单中声明的状态相匹配。假设我们已经删除了一个 Pod,StatefulSet 控件已经采取了纠正措施,以便声明的副本数量和有效运行的副本数量相匹配。

放大和缩小

在由部署控制器管理的无状态 pod 的情况下,零停机扩展的魔力在有状态集的情况下更难实现。我们将首先讨论向上扩展,然后讨论向下扩展,因为每个都有自己的挑战。

向上扩展事件会导致现有客户端可能不知道的新 pod 的出现,除非它们经常检查 DNS SRV 记录并依次更新它们自己。然而,这将打乱哈希算法,并导致后备存储中大量的未命中,就像我们的原始键/值 1 一样。

缩小规模的事件比扩大规模的事件更具破坏性,不仅仅是因为我们正在减少数据保存副本的数量,还因为在 Kubernetes 基于生命周期技术合同以仁慈的方式终止我们的 pod 之前,我们只有一段时间来做任何必要的数据重新分区。

结论是,如果我们的目标是将中断减少到最低限度,我们需要在针对状态集发出kubectl scale命令之前考虑和采取额外的步骤。

实际上,我们面临的挑战是,无论服务器数量是增加还是减少,我们都必须重新计算密钥哈希。表 9-6 以关键字abcd为例,给出了 2 和 3 副本的最终选定服务器。

表 9-6

对 a、b、c 和 d 应用模 2 和模 3 的效果

|

钥匙

|

十二月

|

以…为模

|

N = 2

|

N = 3

|
| --- | --- | --- | --- | --- |
| a | Ninety-seven | 97 %氮 | 服务器-1 | 服务器-1 |
| b | Ninety-eight | 98 %氮 | 服务器-0 | 服务器-2 |
| c | Ninety-nine | 99 %氮 | 服务器-1 | 服务器-0 |
| d | One hundred | 99 %氮 | 服务器-0 | 服务器-1 |

因此,如果服务器的数量从三个副本缩减到两个,我们首先应该将存储在服务器 0-2 中的键值对仅分发到服务器 0-1,反之亦然,如果从两个副本扩展到三个副本。我们已经创建了一个最简单的程序,在一个名为rebalance.py的脚本中捕获这个过程。该 Python 脚本采用现有服务器列表和未来服务器列表来执行必要的键/值对重新分区:

#!/usr/bin/python3
# rebalance.py
import sys
import urllib.request

if len(sys.argv) < 3:
  print('rebalance.py AS_IS_SRV_1[:PORT],' +
        'AS_IS_SRV_2[:PORT]... ' +
        'TO_BE_SRV_1[:PORT],TO_BE_SRV_2[:PORT],...')
  sys.exit(1)

# Process arguments
as_is_servers = sys.argv[1].split(',')
to_be_servers = sys.argv[2].split(',')

# Remove boilerplate from HTTP calls
def curl(url):
  return urllib.request.urlopen(url).read().decode()

# Copy key/vale pairs from AS IS to TO BE servers
urls = []
for server in as_is_servers:
  keys = curl('http://' + server +
              '/allKeys').split(',')
  print(server + ': ' + str(keys))
  for key in keys:
    print(key + '=',end=")
    value = curl('http://' + server +
                 '/load/' + key)
    sn = ord(key) % len(to_be_servers)
    target_server = to_be_servers[sn]
    print(value + ' ' + server +
          '->' + target_server)
    urls.append('http://' + target_server +
                '/save/' + key + '/' + value)
for url in urls:
  print(url,end=")
  print(' ' + curl(url))

像在server.pyclient.py的情况下一样,使用配置图上传脚本:

#!/bin/sh
# configmap.sh
kubectl delete configmap scripts \
  --ignore-not-found=true
kubectl create configmap scripts \
  --from-file=server.py \
  --from-file=client.py --from-file=rebalance.py

如前所述,缩放并不简单,需要小心控制;因此,我们将考虑一个 Pod 清单来执行从三个到两个名为rebalance-down.yaml的副本的缩减迁移:

# rebalance-down.yaml
# Reduce key/store cluster to 2 replicas from 3
apiVersion: v1
kind: Pod
metadata:
  name: rebalance
spec:
  restartPolicy: Never
  containers:
  - name: rebalance
    image: python:alpine
    args:
    - bin/sh
    - -c
    - "python -u /var/scripts/rebalance.py
      server-0.server,server-1.server,\
      server-2.server
      server-0.server,server-1.server"
    volumeMounts:
      - name: scripts
        mountPath: /var/scripts
  volumes:
  - name: scripts
    configMap:
      name: scripts

同样,我们还将rebalance-up.yaml定义为从两个副本扩展到三个副本:

# rebalance-up.yaml
# Scale key/store cluster to 3 replicas from 2
apiVersion: v1
kind: Pod
metadata:
  name: rebalance
spec:
  restartPolicy: Never
  containers:
  - name: rebalance
    image: python:alpine
    args:
    - bin/sh
    - -c
    - "python -u /var/scripts/rebalance.py
      server-0.server,server-1.server
      server-0.server,server-1.server,\
      server-2.server"
    volumeMounts:
      - name: scripts
        mountPath: /var/scripts
  volumes:
  - name: scripts
    configMap:
      name: scripts

现在,我们已经定义了重新平衡脚本和清单来扩展和缩减我们的集群,我们可以清理环境并再次部署服务器和客户端,这样我们就不会受到 Kubernetes 集群中以前示例的干扰:

# clean up the environment first

$ ./configmap.sh
configmap/scripts created

$ kubectl apply -f server.yaml
service/server created
statefulset.apps/server created

$ kubectl apply -f client.yaml
pod/client created

分频

在上一节中,我们已经讨论了这样一个事实,即伸缩并不是微不足道的,直截了当地发出一个kubectl scale命令可能会导致不必要的中断。在这一节中,我们将看到如何以有序的方式缩减我们的原始键/值存储。

我们将经历以下步骤:

  1. 以只读模式运行新的目标客户端(以两个副本为目标),以便我们可以观察迁移的结果。

  2. 停止读/写三副本客户端窗格。

  3. 将键/值对从三个 Pod 集群迁移到两个 Pod 集群。

  4. 将三个副本的集群缩减为两个副本。

让我们从定义一个名为client-ro-2.yaml的 Pod 清单开始,以只读模式运行仅针对server-0server-1client.py:

# client-ro-2.yaml
apiVersion: v1
kind: Pod
metadata:
  name: client-ro-2
spec:
  restartPolicy: Never
  containers:
    - name: client-ro-2
      image: python:alpine
      args:
      - bin/sh
      - -c
      - >
        python -u /var/scripts/client.py
        server-0.server,server-1.server readonly
      volumeMounts:
        - name: scripts
          mountPath: /var/scripts
  volumes:
    - name: scripts
      configMap:
        name: scripts

现在让我们应用它并遵循它的日志:

$ kubectl apply -f client-ro-2.yaml
pod/client-ro-2 created

$ kubectl logs -f client-ro-2

      abcdefghijklmnopqrstuvwxyz
      --------------------------
      10101010101010101010101010
      --------------------------
33:33 hmmmmhhmmmmhhmmmmhhmmmmhhm | hits = 9 (35%)
33:35 hmmmmhhmmmmhhmmmmhhmmmmhhm | hits = 9 (35%)
33:37 hmmmmhhmmmmhhmmmmhhmmmmhhm | hits = 9 (35%)

请注意,在实际场景中,在重新分区完成后,我们将只运行针对新的较小 StatefulSet 的客户端。然而,在这里我们可以更快地实时观察迁移的效果。还要注意,现在只包括了服务器10,大多数键查找都会导致未命中。

现在是微妙的部分,读取密钥并重新计算较小的双副本集群的新散列,这是reblance-down.yaml清单的工作,它又执行rebalance.py。在我们这样做之前,我们必须首先阻止客户端 Pod 对即将弃用的三副本集群执行写入操作:

$ kubectl delete --grace-period=1 pod/client
pod "client" deleted

$ kubectl apply -f rebalance-down.yaml                            pod/rebalance created

$ kubectl logs -f rebalance
server-0.server:
['x', 'u', 'r', 'o', 'l', 'i', 'f', 'c']
x=x server-0.server->server-0.server
u=u server-0.server->server-1.server
...
server-1.server:
['y', 'v', 's', 'p', 'm', 'j', 'g', 'd', 'a']
y=y server-1.server->server-1.server
v=v server-1.server->server-0.server
...
server-2.server:
['z', 'w', 't', 'q', 'n', 'k', 'h', 'e', 'b']
z=z server-2.server->server-0.server
w=w server-2.server->server-1.server
...

pod/rebalance完成时,运行kubectl log -f client-ro-2的窗口将显示整个键集的点击次数:

28:45 hmmmmhhmmmmhhmmmmhhmmmmhhm | hits = 9 (35%)
28:47 hmmmmhhmmmmhhmmmmhhmmmmhhm | hits = 9 (35%)
28:49 hmmmmhhmmmmhhmmmmhhmmmmhhm | hits = 9 (35%)
28:51 hmhhhhhhhhhhhhhhhhhhhhhhhh | hits = 25 (96%)
28:53 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
28:55 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
28:57 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)

此时,我们可以将群集缩减为两个副本,之后,可以针对现在更小的双副本群集以读/写模式启动客户端:

$ kubectl scale statefulset/server --replicas=2
statefulset.apps/server scaled

按比例放大

在上一部分中,我们刚刚将我们的群集从三个副本缩减到两个副本。为了观察正在进行的扩展操作,我们将以与之前类似的方式工作,首先启动一个只读客户端,目标是在client-ro-3.yaml中定义的三个副本:

# client-ro-3.yaml
apiVersion: v1
kind: Pod
metadata:
  name: client-ro-3
spec:
  restartPolicy: Never
  containers:
    - name: client-ro-3
      image: python:alpine
      args:
      - bin/sh
      - -c
      - "python -u /var/scripts/client.py
        server-0.server,server-1.server,\
        server-2.server readonly"
      volumeMounts:
        - name: scripts
          mountPath: /var/scripts
  volumes:
    - name: scripts
      configMap:
        name: scripts

当我们运行这个客户端时,我们期望在server-2上看到一个失败,用.(点)表示。这正是我们所期待的,因为server-2还没有运行:

$ kubectl apply -f client-ro-3.yaml
pod/client-ro-3 created

$ kubectl logs -f client-ro-3

      abcdefghijklmnopqrstuvwxyz
      --------------------------
      12012012012012012012012012
      --------------------------
43:57 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
43:59 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
44:01 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
44:03 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)

由于启用了写功能的客户端不需要知道新的副本(client.yaml不被认为正在运行),因此可以安全地将群集扩展到三个副本,而无需更多操作:

$ kubectl scale statefulset/server --replicas=3
statefulset.apps/server scaled

紧接着,我们应该在运行kubectl logs -f client-ro-3的窗口上观察到由.(点表示的服务器故障变成由字母m表示的未命中:

...
49:54 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
49:56 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
49:58 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
50:00 hmhhmhhmhhmhhmhhmhhmhhmhhm | hits = 17 (65%)
50:02 hmhhmhhmhhmhhmhhmhhmhhmhhm | hits = 17 (65%)
50:06 hmhhmhhmhhmhhmhhmhhmhhmhhm | hits = 17 (65%)
...

到目前为止,一切顺利;我们现在可以通过应用rebalance-up.yaml将键/值对重新划分到一个三副本集群中:

$ kubectl delete pod/rebalance
pod "rebalance" deleted

$ kubectl apply -f rebalance-up.yaml
pod/rebalance created

每当我们跟踪client-ro-3日志的终端窗口显示未命中变成命中时,我们就成功地扩大了集群:

53:24 hmhhmhhmhhmhhmhhmhhmhhmhhm | hits = 17 (65%)
53:26 hmhhmhhmhhmhhmhhmhhhhhmhhh | hits = 19 (73%)
53:28 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
53:30 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
53:32 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)

此时,针对三个副本的群集启用读/写客户端是安全的。

注意

Kubernetes 将 StatefulSet 的 pod 称为副本,因为我们在使用kubectl scale命令时使用了replicas属性和副本语义。但是,StatefulSet 的副本不一定是一字不差的无状态副本,因为它们通常是部署的副本。从逻辑的角度来看,假设我们使用每个 StatefulSet 的 Pod 实例来存储数据集的一个子集,那么将它们视为“分区”会有所帮助

关于扩大和缩小业务规模的结论

向上和向下扩展操作以一种原始的、相当手工的方式演示了每当集群大小改变时重新散列键的问题,并且数据块必须被重新安排到不同数量的服务器中。

大多数高级的现成后备存储(如 MongoDB)通过实现异步复制算法,使用户免受我们刚刚看到的那种手动操作。那何必呢?因为尽管我们原始的键/值存储可能过于简单,但它有助于直观地了解现成解决方案所采取的权衡,以提供几乎零停机扩展的假象。

在这方面,我们的原始键/值存储是可伸缩的,但不是高度可用的,这不是因为数据存储在内存中(我们将在本章结束之前解决这一问题),而是因为单个副本故障会导致数据不可访问。例如,Cassandra 不仅可以使用与本文中使用的哈希方案类似的方法进行扩展,而且还具有很高的可用性:可以对其进行配置,使得相同的数据在被认为“持久化”之前被写入两个或更多的节点。

潜在高度可用(除了高度可伸缩之外)的后备存储带来的复杂性是,它们带来了处理最终一致性的挑战。例如,我们可以很容易地修改我们的客户机,以便使用一个复制方案(如( totalReplicaCount + 1) % totalReplicaCount)将一个密钥保存到两个或多个副本中。然而,每当读取两个键并且它们的值不同时,客户端需要调用哪个键。在我们的原始键/值存储的情况下,我们可以很容易地修改它,以提供一个时间戳,这样客户端就可以将最近的一个作为有效的。

正确的有状态性:磁盘持久性

到目前为止,我们一直将键/值存储视为内存中的缓存,一旦副本打喷嚏,它就会丢失数据。这是故意的,因为我们到目前为止讨论的所有状态属性都与磁盘持久性相对正交:

  • 稳定的网络身份

  • 有序 Pod 创建

  • 服务发现和无头服务

  • 扩展策略

此外,我们决定通过简单地替换卷data的底层实现来区分 RAM 和磁盘存储。到目前为止,所有示例都依赖于基于 RAM 的文件系统,如下所示:

# server.yaml
    ...
    volumes:
      - name: data
        emptyDir:
          medium: Memory

降低磁盘持久性的途径是使用hostPath卷类型。hostPath卷类型允许直接在节点的文件系统上存储数据;然而,这种方法是有问题的,因为它只有在 Pods 总是被安排在相同的节点上运行时才有效。另一个问题是,节点级存储并不意味着是持久的:即使 Pods 被调度到相同的节点,节点的文件系统也不能保证在节点崩溃或重启后仍然存在。

读者可能已经猜到,我们需要的是附加网络存储,它的生命周期独立于 Kubernetes 工作节点的生命周期。在 GCP,这只是创建一个“持久磁盘”的问题,只需发出一条命令:

$ gcloud compute disks create my-disk --size=1GB
Created
NAME     ZONE            SIZE_GB  TYPE         STATUS
my-disk  europe-west2-a  1        pd-standard  READY

然后,我们可以将data卷与 StatefulSet 清单中名为my-disk的 GCP 磁盘相关联:

# server.yaml
    ...
    volumes:
      - name: data
        gcePersistentDisk:
          pdName: my-disk
          fsType: ext4

上述方法的局限性在于只有一个 Pod 可以对my-disk进行读/写访问。所有其他窗格可能只有只读访问权限。如果我们通过修改server.yaml中的volumes声明来尝试上述方法,我们将看到只有server-0会成功启动,而server-1会失败(因此server-2不会被调度)。这是因为只有一个 Pod ( server-0)可以对永久磁盘my-disk进行读/写访问。

我们的键/值存储所采用的多主机方案要求所有副本(以及 pod)具有完全的读/写访问权限。此外,可伸缩系统的要点是数据分布在多个磁盘上,而不是存储在由多个服务器访问的单个中央磁盘上。理想情况下,我们需要为每个副本创建一个单独的磁盘。大致如下的东西:

# Example only, don't run these commands
$ gcloud compute disks create my-disk-server-0
$ gcloud compute disks create my-disk-server-1
$ gcloud compute disks create my-disk-server-2

这种方法需要预先规划给定数量的副本所需的磁盘数量。如果 Kubernetes 能够代表我们为每个需要持久存储的 Pod 发出前面的gcloud compute disks create命令(或者使用底层 API)会怎么样?好消息,可以!欢迎来到持续批量索赔。

持续量声明

持久卷声明可以理解为一种机制,允许 Kubernetes 根据每个 Pod 的身份按需创建磁盘,这样,如果一个 Pod 崩溃或被重新调度,每个 Pod 及其关联的卷之间就会保持 1:1 的链接(Kubernetes 行话中的绑定)。无论一个 Pod 在哪个节点上“醒来”,Kubernetes 总是会附加其对应的绑定卷,用于server-0disk-0;对于server-1disk-1;等等。

虽然 Kubernetes 可能运行在有多个能够授予卷(如disk-0disk-1)的存储阵列的环境中,但这些存储阵列也因云供应商而异。例如,在 AWS 中,这种块存储功能被称为亚马逊弹性存储(EBS ),实现方式与 GCP 持久磁盘不同。问题是,Kubernetes 怎么知道向谁要一卷呢?嗯,存储阵列(或等效物)功能在 Kubernetes 中体现为一个名为存储类的对象。

针对给定的存储类执行持久卷声明。Google Kubernetes 引擎(GKE)提供了一个名为standard的现成存储类:

$ kubectl get storageclass
NAME                 PROVISIONER            AGE
standard (default)   kubernetes.io/gce-pd   40m

$ kubectl describe storageclass/standard
Name:                 standard
IsDefaultClass:       Yes
Annotations:          storageclass.*.kubernetes.io/*
Provisioner:          kubernetes.io/gce-pd
Parameters:           type=pd-standard
AllowVolumeExpansion: <unset>
MountOptions:         <none>
ReclaimPolicy:        Delete
VolumeBindingMode:    Immediate
Events:               <none>

除非另有说明,否则每当按需创建 GKE 磁盘(Kubernetes 中的)时,就会使用standard存储类。StorageClass 有一种驱动程序,它允许 Kubernetes 编排磁盘(或一般的块设备)的创建,而不需要管理员向外部存储接口(如gcloud compute disk create)发出手动命令。

让我们的 StatefulSet 清单请求磁盘到标准的 StorageClass 只需要很少的额外代码。这是在statefulset.spec下创建以下条目的问题:

# server-disk.yaml
    ...
    volumeClaimTemplates:
      - metadata:
          name: data
        spec:
          accessModes: ["ReadWriteOnce"]
          resources:
            requests:
              storage: 1Gi

在这个清单片段中,我们将卷命名为data,请求一个 1GB 的永久磁盘,并将访问模式设置为ReadWriteOnce,这意味着没有其他副本会对其提供的卷进行独占读/写访问。

如前所述,持久卷声明可以理解为一种代表我们使用云提供商的存储接口(在我们的例子中是 GCP)创建磁盘的机制。现在让我们看看这确实是真的。我们将修改原始的server.yaml文件以包含上面的持久卷声明,并删除旧的emptyDir卷定义。新生成的文件被称为server-disk.yaml。让我们再次清理我们的环境,启动我们新定义的基于磁盘的服务器:

# clean up the environment first

$ ./configmap.sh
configmap "scripts" deleted
configmap/scripts created

$ kubectl apply -f server-disk.yaml
service/server created
statefulset.apps/server created

几秒钟后,我们可以通过运行kubectl get pv检查是否已经创建了三个卷(每个副本一个)。请注意,为了简洁起见,已经删除和/或简化了一些细节:

$ kubectl get pv
NAME           CAP STATUS CLAIM
pvc-42339fcc-* 1Gi Bound  default/data-server-0
pvc-4cb81b93-* 1Gi Bound  default/data-server-1
pvc-59792e64-* 1Gi Bound  default/data-server-2

我们现在还可以看到,GCP 将相同的卷视为正确的 Google Cloud 持久磁盘,就好像我们手动创建了它们一样:

$ gcloud compute disks list
...
gke-my-cluster-f8fca-pvc-42339fcc-*
gke-my-cluster-f8fca-pvc-4cb81b93-*
gke-my-cluster-f8fca-pvc-59792e64-*

我们现在不仅有三个独立的卷,每个卷对应一个服务器(server-0server-1server-2),而且我们还有一个自称为一流 Kubernetes 公民的卷:

$ kubectl get pvc
NAME          STATUS VOLUME         CAP MODE
data-server-0 Bound  pvc-42339fcc-* 1Gi RWO
data-server-1 Bound  pvc-4cb81b93-* 1Gi RWO
data-server-2 Bound  pvc-59792e64-* 1Gi RWO

持久卷声明的关键特征是 pod 的生命周期独立于卷的生命周期。换句话说,我们可以删除 pod,让它们崩溃,扩展 stateful set——甚至残酷地删除它。无论发生什么情况,与每个 Pod 关联的卷都将被重新连接。让我们启动我们的测试客户端,这样我们就可以证明事实确实如此:

$ kubectl apply -f client.yaml
pod/client created

$ kubectl logs -f client

      abcdefghijklmnopqrstuvwxyz
      --------------------------
      12012012012012012012012012
      --------------------------
53:25 wwwwwwwwwwwwwwwwwwwwwwwwww | hits = 0 (0%)
53:27 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
53:29 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
53:31 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)

在第二次25之前,密钥已经被写入三个复制品一次。既然密钥已经由适当的持久性存储备份,我们应该会看到服务器故障,但不会再出现写操作(字母w)。我们将从发出kubectl delete pod/server-2命令删除server-2开始,看看这是否确实是真的:

57:04 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
57:06 h5hh5hh5hh5hh5hh5hh5hh5hh5 | hits = 17 (65%)
57:08 h5hh5hh5hh5hh5hh5hh5hh5hh5 | hits = 17 (65%)
57:10 h5hh5hh5hh5hh5hh5hh5hh5hh5 | hits = 17 (65%)
57:12 h5hh5hh5hh5hh5hh5hh5hh5hh5 | hits = 17 (65%)
57:15 h5hh5hh5hh5hh5hh5hh5hh5hh5 | hits = 17 (65%)
57:17 h.hhhhhhhhhhhhhhhhhhhhhhhh | hits = 25 (96%)
58:25 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)

这里我们看到在57:0657:15之间,server-2正在关闭(数字five代表 HTTP 错误503)。然后在57:17服务器变得不可访问一段时间,然后再次恢复在线。请注意,既没有m(未命中)也没有w(写入)字母,因为server-2从未丢失任何数据。

现在让我们做一些更激进的事情,通过发出kubectl delete statefulset/server命令删除 StatefulSet 本身:

26:03 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
26:06 55555555555555555555555555 | hits = 0 (0%)
26:08 55555555555555555555555555 | hits = 0 (0%)
26:10 55555555555555555555555555 | hits = 0 (0%)
26:12 55555555555555555555555555 | hits = 0 (0%)
26:14 55555555555555555555555555 | hits = 0 (0%)
26:16 .......................... | hits = 0 (0%)
27:25 .......................... | hits = 0 (0%)
27:27 .......................... | hits = 0 (0%)

请注意,所有服务器都进入关闭模式,然后变得不可访问,如.(点)所示。在我们确认所有的 pod 都已终止后,我们通过发出kubectl apply -f server-disk.yaml命令再次启动 StatefulSet:

28:05 .......................... | hits = 0 (0%)
28:07 .......................... | hits = 0 (0%)
28:10 ..h..h..h..h..h..h..h..h.. | hits = 8 (31%)
28:12 ..h..h..h..h..h..h..h..h.. | hits = 8 (31%)
28:14 ..h..h..h..h..h..h..h..h.. | hits = 8 (31%)
28:16 ..h..h..h..h..h..h..h..h.. | hits = 8 (31%)
28:18 ..h..h..h..h..h..h..h..h.. | hits = 8 (31%)
28:20 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
28:22 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
28:25 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
28:27 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
28:29 h.hh.hh.hh.hh.hh.hh.hh.hh. | hits = 17 (65%)
28:31 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
28:33 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)
28:35 hhhhhhhhhhhhhhhhhhhhhhhhhh | hits = 26 (100%)

在这里,我们看到服务器逐渐联机,当它们联机时,客户端注册h (hit ),这意味着密钥已被成功检索,无需再次写入。请注意,在本例中,我们排除了由于部分写入、文件句柄未关闭或其他应用级故障导致磁盘上文件损坏的可能性。

摘要

在本章中,我们使用 StatefulSets 从头实现了一个键/值数据存储支持服务,帮助我们观察这种控制器类型保证的关键属性,如顺序 Pod 创建和稳定的网络身份。后者,稳定的网络身份是通过使用无头服务和一致的持久性来公开 pod 的基础,这两个特性也在本章中讨论。

我们还研究了 Pod 生命周期事件及其在集群设置和管理伸缩事件(以及首次启动有状态集群)中的相关性。对于读者来说,当考虑数据分区和复制方面时,即席缩放(使用kubectl scale)命令是困难的;秤事件前后通常都需要额外的步骤,如运行脚本和/或管理程序。*

posted @ 2024-08-12 11:18  绝不原创的飞龙  阅读(3)  评论(0编辑  收藏  举报