Kubernetes-之书-全-

Kubernetes 之书(全)

原文:zh.annas-archive.org/md5/fe93a46165cb69c9957f8f3cc7645cd6

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

image

容器和 Kubernetes 一起正在改变应用程序的架构、开发和部署方式。容器确保软件在任何部署地点都能可靠运行,而 Kubernetes 让你可以从单一控制平面管理所有容器。

本书旨在帮助你充分利用这些重要的新技术,采用实践示例,不仅尝试主要功能,还探索每个功能的工作原理。通过这种方式,除了能够准备好将应用程序部署到 Kubernetes 外,你还将获得设计高效且可靠的 Kubernetes 集群中应用程序架构的技能,并能够在问题出现时快速诊断。

方法论

Kubernetes 集群的最大优势在于,它通过抽象层隐藏了在多个主机上运行容器的工作。Kubernetes 集群是一个“黑盒”,我们告诉它运行什么,它就运行什么,具备自动扩展、故障切换和应用程序的新版本升级等功能。

尽管这种抽象使得部署和管理应用程序变得更加容易,但它也使得理解集群正在做什么变得困难。因此,本书从“调试”视角呈现每个容器运行时和 Kubernetes 集群的功能。每一次好的调试会话都从将应用程序当作黑盒并观察其行为开始,但它不会仅止步于此。经验丰富的问题解决者知道如何打开黑盒,深入当前的抽象层以下,查看程序是如何运行的,数据是如何存储的,以及流量是如何在网络中流动的。熟练的架构师利用对系统的深刻理解,避免性能和可靠性问题。本书提供了对容器和 Kubernetes 的详细理解,这种理解来源于不仅探索这些技术做了什么,还要了解它们是如何工作的。

在第一部分中,我们将从运行一个容器开始,然后深入容器运行时,理解什么是容器以及如何使用普通操作系统命令模拟容器。在第二部分中,我们将安装一个 Kubernetes 集群并将容器部署到集群中。我们还将看到集群如何工作,包括它如何与容器运行时交互,以及数据包如何在主机网络中从一个容器流向另一个容器。本书的目的是不是为了重复参考文档,列出每个功能提供的所有选项,而是演示每个功能如何实现,从而使得所有文档内容都能理解且有用。

Kubernetes 集群非常复杂,因此本书包含了大量的实践示例,并提供了足够的自动化工具,使你能够独立探索每一章。这些自动化工具可以在 github.com/book-of-kubernetes/examples 上找到,并以宽松的开源许可证发布,因此你可以在自己的项目中进行探索、实验和使用。

运行示例

在本书的许多示例练习中,你将把多个主机组合在一起以构建一个集群,或者操作 Linux 内核的低级功能。基于这个原因,并且为了帮助你在实验过程中感到更舒适,你将完全在临时虚拟机上运行示例。这样,如果你犯了错误,可以迅速删除虚拟机并重新开始。

本书的示例代码库可以在 github.com/book-of-kubernetes/examples 上找到。所有设置示例运行的说明都在示例代码库的 setup 文件夹中的 README.md 文件里。

你需要的东西

即使你在虚拟机中工作,你仍然需要一台控制机器作为起始点,可以运行 Windows、macOS 或 Linux。它甚至可以是一台支持 Linux 的 Chromebook。如果你使用 Windows,你需要使用 Windows Subsystem for Linux (WSL) 来使 Ansible 正常工作。有关详细说明,请参见 setup 文件夹中的 README.md 文件。

在云端或本地运行

为了尽可能让这些示例易于访问,我提供了自动化工具,可以通过 Vagrant 或 Amazon Web Services (AWS) 运行它们。如果你有一台至少具有八核和 8GB 内存的 Windows、macOS 或 Linux 计算机,可以尝试安装 VirtualBox 和 Vagrant,并使用本地虚拟机。如果没有,你可以设置自己在 AWS 上工作。

我们使用 Ansible 来执行 AWS 设置并自动化一些繁琐的步骤。每个章节都包含一个单独的 Ansible 剧本,利用了常见的角色和集合。这意味着你可以逐章工作,每次从全新安装开始。在某些情况下,我还提供了一个“额外的”配置剧本,你可以选择性使用它跳过一些详细的安装步骤,直接进入学习内容。有关更多信息,请参阅每个章节目录中的 README.md 文件。

终端窗口

在你使用 Ansible 配置好虚拟机后,你需要至少一个终端窗口来运行命令。每个章节中的 README.md 文件会告诉你如何做到这一点。在运行任何示例之前,你首先需要成为 root 用户,如下所示:

sudo su -

这将为你提供一个 root shell,并设置你的环境和主目录以匹配。

以 ROOT 用户身份运行

如果你以前使用过 Linux,可能会对以 root 用户身份频繁工作感到不安,因此你可能会感到惊讶,本书中的所有示例都是以 root 用户身份运行的。这是使用临时虚拟机和容器的一大优势;当我们以 root 用户身份操作时,我们是在一个临时的、受限的空间内进行的,这个空间无法影响到其他任何东西。

当你从学习容器和 Kubernetes 转向在生产环境中运行应用时,你将会为集群应用安全控制措施,这些措施将限制管理员访问权限,并确保容器无法突破其隔离环境。这通常包括配置容器使其以非 root 用户身份运行。

在某些示例中,你可能需要打开多个终端窗口,以便在检查一个进程时保持另一个进程在运行。如何操作取决于你;大多数终端应用都支持多个标签页或多个窗口。如果你需要一种在单个标签页中打开多个终端的方法,可以尝试使用终端复用器应用。所有示例中使用的临时虚拟机都预装了 screentmux,可以随时使用。

第一部分

创建和使用容器

容器是现代应用架构中至关重要的组成部分。它们简化了应用组件的打包、部署和扩展。容器使得构建可靠和具有韧性的应用成为可能,可以优雅地处理故障。然而,容器也可能令人困惑。它们看起来像完全不同的系统,具有独立的主机名、网络和存储,但它们并没有很多独立系统的特性,比如独立的控制台或系统服务。为了理解容器如何看起来像独立系统但实际上并非独立,我们来探讨一下容器、容器引擎以及 Linux 内核特性。

第一章:为什么容器很重要

image

现在是做软件开发的好时机。创建一个全新的应用并使其能被数百万用户使用,从未如此简单。现代编程语言、开源库和应用平台使得编写少量代码并实现大量功能成为可能。然而,尽管开始并快速创建一个新应用很容易,但最优秀的应用开发者是那些不仅仅将应用平台视为一个“黑匣子”,而是能够真正理解它如何工作的开发者。创建一个可靠、具有韧性和可扩展的应用需要的不仅仅是知道如何在浏览器或命令行中创建部署。

在本章中,我们将探讨在一个可扩展、云原生的世界中的应用架构。我们将展示为什么容器是打包和部署应用组件的首选方式,以及容器编排如何满足容器化应用的关键需求。最后,我们将展示一个部署到 Kubernetes 的示例应用,给你一个关于这些技术的初步了解。

现代应用架构

现代软件应用的主要主题是规模。我们生活在一个拥有数百万同时在线用户的应用世界。值得注意的是,这些应用不仅能够实现这种规模,还能够保持一定的稳定性,以至于任何一次停机事件都能成为头条新闻,并成为数周或数月技术分析的素材。

随着如此多现代应用在大规模运行,往往很容易忘记,架构设计、构建、部署和维护这种高水平的应用需要付出大量的努力,无论它们设计的规模是面向数千、数百万还是数十亿用户。本章的任务是识别我们需要从应用平台中得到什么,以便运行一个可扩展、可靠的应用,并了解容器化和 Kubernetes 如何满足这些需求。我们将从看现代应用架构的三个关键特性开始,然后探讨这些特性带来的三个主要好处。

特性:云原生

有很多方法可以定义云原生技术(一个好的起点是云原生计算基金会,cncf.io)。我喜欢从“云”是什么以及它能实现什么的角度出发,这样我们就能理解什么样的架构可以最好地利用它。

在其核心,云是一种抽象。在介绍中我们已经讨论了抽象,因此您知道抽象对于计算是至关重要的,但我们也需要深入了解我们的抽象才能正确地使用它们。在云的情况下,提供商正在抽象真实的物理处理器、内存、存储和网络,使得云用户可以简单地声明对这些资源的需求,并且按需分配它们。因此,要拥有“云原生”应用程序,我们需要一个能够利用该抽象的应用程序。在可能的情况下,应用程序不应绑定到特定的主机或特定的网络布局,因为我们不希望限制应用程序组件在主机之间的分布灵活性。

属性:模块化

模块化 对应用架构并不是什么新鲜事物。其目标始终是高内聚,即模块内的一切都与单一目的相关,并且低耦合,即组织模块以最小化模块间通信。然而,尽管模块化仍然是一个关键的设计目标,但定义模块的方式已经有所不同。现代应用架构更倾向于将模块化带入运行时,而不仅仅是将其视为组织代码的一种方式,为每个模块提供独立的操作系统进程,并且不鼓励使用共享文件系统或共享内存进行通信。由于模块是独立的进程,模块间的通信是标准的网络(套接字)通信。

这种方法似乎对硬件资源的利用有些浪费。与其通过套接字复制数据,不如共享内存更为紧凑和快速。但是有两个很好的理由支持使用独立进程。首先,现代硬件速度很快,而且越来越快,想象套接字对我们的应用来说不够快速将是一种过早优化的形式。其次,无论我们有多大的服务器,能容纳的进程数量总是有限的,因此共享内存模型最终会限制我们的扩展能力。

属性:基于微服务

现代应用架构基于形式各异的独立进程模块化,这些个体模块往往非常小。理论上,云可以为我们提供所需的强大虚拟服务器;然而实际上,使用少量强大的服务器比使用许多小型服务器更昂贵且不够灵活。如果我们的模块足够小,它们可以部署到廉价的通用服务器上,这意味着我们可以最大限度地利用云服务提供商的硬件优势。虽然没有一个单一的答案来说明模块需要多小才能成为微服务,但“足够小以便可以灵活地部署它”是一个很好的首要规则。

微服务架构在组织团队方面也具有实际的优势。自从弗雷德·布鲁克斯(Fred Brooks)写了《人月神话》(The Mythical Man-Month)以来,架构师们就意识到,组织人员是开发大型复杂系统的最大挑战之一。从许多小模块构建系统减少了测试的复杂性,同时也使得组织一个大团队变得可能,而不会让每个人都互相干扰。

那么应用服务器呢?

模块化服务的概念有着悠久的历史,其中一种流行的实现方式是构建模块并在应用服务器中运行,例如 Java 企业环境。那么,为什么不继续沿用这种应用程序的模式呢?

尽管应用服务器在许多场景中取得了成功,但它们并不像微服务架构那样具有相同程度的隔离性。因此,应用服务器存在更多的相互依赖问题,导致测试变得更加复杂,团队的独立性也有所降低。此外,通常每个主机上部署一个应用服务器,多个应用共享同一进程空间的模式,远没有容器化的方式灵活,而容器化方法将在本书中详细介绍。

这并不是说你应该立即抛弃应用服务器架构,而转向使用容器。容器化对于任何架构都有很多好处。但是,随着你逐步采用容器化架构,随着时间的推移,向真正的微服务架构迁移将会使你能够充分利用容器和 Kubernetes 所提供的优势。

我们已经看过了现代架构的三个关键属性。现在,让我们来看三个由此产生的关键好处。

好处:可扩展性

让我们从构建最简单的应用程序开始。我们创建一个只在单台机器上运行、并且一次只与一个用户交互的可执行文件。现在,假设我们希望将该应用程序扩展,以便能够同时与成千上万的用户交互。显然,无论我们使用多么强大的服务器,最终某些计算资源都会成为瓶颈。不管瓶颈是处理能力、内存、存储还是网络带宽;一旦我们遇到瓶颈,我们的应用程序将无法处理更多的用户,并且会影响其他用户的性能。

解决这个问题的唯一方法是停止共享导致瓶颈的资源。这意味着我们需要找到一种方法,将应用程序分布到多个服务器上。但如果我们要真正扩展,不能仅此为止。我们还需要在多个网络间进行分布,否则会遇到单个网络交换机的限制。最终,我们甚至需要进行地理分布,否则整个广域网络将达到饱和。

为了构建无可扩展性限制的应用程序,我们需要一种能够根据需要运行额外应用实例的架构。而且,由于应用程序的速度只受限于最慢的组件,我们需要找到一种方法来扩展所有组件,包括我们的数据存储。显而易见,唯一有效的方式就是将应用程序从许多独立的组件构建而成,这些组件不依赖于特定的硬件。换句话说,云原生微服务。

优势:可靠性

让我们回到最简单的应用程序。除了可扩展性限制外,它还有另一个缺陷。它运行在一台服务器上,如果这台服务器发生故障,整个应用程序就会失败。我们的应用程序缺乏可靠性。如同之前所说,解决这个问题的唯一方法就是停止共享可能发生故障的资源。幸运的是,当我们开始将应用程序分布到多台服务器时,我们有机会避免硬件中的单点故障,这样就不会导致整个应用程序的崩溃。而且,由于应用程序的可靠性取决于其最不可靠的组件,我们需要找到一种方法来分发所有内容,包括存储和网络。同样,我们需要云原生微服务,它们在运行位置和实例数量上都具有灵活性。

优势:弹性

云原生微服务架构还有第三个、更微妙的优势。这一次,假设有一个运行在单个服务器上的应用程序,但它可以轻松地作为一个单独的包安装到任意数量的服务器上。每个实例都可以为一个新用户提供服务。理论上,由于我们可以随时将其安装到另一台服务器上,这个应用程序应该具有良好的可扩展性。总体来说,应用程序可以说是可靠的,因为单一服务器的故障只会影响到一个用户,而其他用户可以照常运行。

这种方法所缺失的是弹性的概念,或者说应用程序对故障的有意义响应能力。一个真正具有弹性的应用程序可以在应用程序中的某个硬件或软件发生故障时,确保最终用户根本不会注意到故障的存在。尽管如此,分离的、互不相关的实例在其中一个实例发生故障时仍然能够继续运行,但我们不能真正说这个应用程序表现出了弹性,至少从那个遭遇故障的用户的角度来看是这样。

另一方面,如果我们将应用程序构建为多个独立的微服务,每个微服务都能够通过网络与其他微服务进行通信,那么单个服务器的丧失可能会导致多个微服务实例的损失,但最终用户可以透明地切换到其他服务器上的实例,这样他们甚至不会注意到故障的发生。

为什么选择容器

我已经让现代应用架构和它那炫酷的云原生微服务听起来非常有吸引力了。然而,工程中充满了权衡,经验丰富的工程师会怀疑一定会有一些非常重大的权衡,当然,确实如此。

从许多小的组件构建一个应用程序是非常困难的。围绕微服务组织团队,使得它们能够独立工作可能是很好的,但当需要将这些组件组合成一个可以运行的应用程序时,组件数量庞大意味着我们必须担心如何打包它们,如何将它们交付到运行环境中,如何配置它们,如何为它们提供(可能存在冲突的)依赖项,如何更新它们,以及如何监控它们以确保它们正常工作。

当我们考虑到需要运行每个微服务的多个实例时,这个问题变得更加严重。现在,我们需要一个微服务能够找到另一个微服务的工作实例,并在所有工作实例之间均衡负载。我们需要负载均衡在发生硬件或软件故障时立即重新配置自己。我们需要无缝切换并重试失败的工作,以便将故障对终端用户隐藏起来。而且我们不仅需要监控每个单独的服务,还需要监控所有服务如何协同工作以完成任务。毕竟,如果我们有 99%的微服务正常工作,而 1%的故障使得用户无法使用我们的应用程序,我们的用户是不会在乎其他微服务是否正常工作的。

如果我们想从许多独立的微服务中构建一个应用程序,我们面临许多问题需要解决,并且我们不希望每个微服务团队都去解决这些问题,否则他们就永远没有时间写代码!我们需要一种共同的方式来管理微服务的打包、部署、配置和维护。我们来看一下所需属性的两类:适用于单个微服务的属性和适用于多个微服务共同工作的属性。

容器的需求

对于单个微服务,我们需要以下内容:

打包 将应用程序打包以便交付,打包中需要包括依赖项,以便包是可移植的,并且我们避免微服务之间的冲突。

版本控制 唯一标识一个版本。我们需要随时间更新微服务,并且我们需要知道哪个版本正在运行。

隔离性 防止微服务相互干扰。这使得我们能够灵活地部署微服务。

快速启动 快速启动新的实例。我们需要这个来扩展和应对故障。

低开销 最小化运行微服务所需的资源,以避免对微服务大小的限制。

容器 正是为了解决这些需求而设计的。容器提供隔离,同时具有低开销和快速启动的特点。正如我们将在第五章中看到的,容器是从容器镜像中运行的,容器镜像为我们提供了一种将应用程序及其依赖项打包并唯一标识该包版本的方式。

编排的需求

对于多个微服务协同工作,我们需要:

集群 提供跨多个服务器的容器处理、内存和存储。

发现 为一个微服务提供查找另一个微服务的方法。我们的微服务可能在集群中的任何地方运行,而且它们可能会移动。

配置 将配置与运行时分离,允许我们在不重新构建和重新部署微服务的情况下重新配置应用程序。

访问控制 管理创建容器的授权。这确保了正确的容器运行,错误的容器不会运行。

负载均衡 在工作实例之间分配请求,以避免最终用户或其他微服务自行跟踪所有微服务实例并平衡负载。

监控 识别失败的微服务实例。如果流量指向失败的实例,负载均衡将无法正常工作。

弹性 自动从故障中恢复。如果没有这个能力,故障链可能会致使我们的应用程序崩溃。

这些需求仅在我们在多个服务器上运行容器时才会发挥作用。这与仅打包并运行单个容器是不同的问题。为了解决这些需求,我们需要一个容器编排环境。像 Kubernetes 这样的容器编排环境使我们能够将多个服务器视为一组资源来运行容器,动态地将容器分配到可用的服务器,并提供分布式通信和存储。

运行容器

到现在为止,希望你已经对使用容器化微服务和 Kubernetes 构建应用程序的可能性感到兴奋。让我们先了解一些基础知识,这样你就可以看到这些想法在实践中的应用,并为本书后续深入研究容器技术奠定基础。

容器的样子

在第二章,我们将研究容器平台与容器运行时之间的区别,并使用多个容器运行时来运行容器。目前,我们从一个简单的例子开始,在最流行的容器平台Docker上运行。我们的目标是学习基本的 Docker 命令,这些命令与通用的容器概念相一致。

运行一个容器

第一个命令是run,它会创建一个容器并在其中运行一个命令。我们将告诉 Docker 使用哪个容器镜像的名称。关于容器镜像的详细内容,我们将在第五章中讨论;目前,知道它提供一个独特的名称和版本号就足够了,这样 Docker 就能准确知道运行什么。让我们开始使用本章的示例。

注意

本书的示例代码库位于 github.com/book-of-kubernetes/examples有关设置的详细信息,请参见第 xx 页中的“运行示例”部分。

本节的一个关键概念是容器看起来像是一个完全独立的系统。为了说明这一点,在我们运行容器之前,让我们先看一下主机系统:

root@host01:~# cat /etc/os-release
NAME="Ubuntu"
...
root@host01:~# ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 12:59 ?        00:00:07 /sbin/init
...
root@host01:~# uname -v
#...-Ubuntu SMP ...
root@host01:~# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 ...
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
...
3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel ...
    link/ether 08:00:27:bf:63:1f brd ff:ff:ff:ff:ff:ff
    inet 192.168.61.11/24 brd 192.168.61.255 scope global enp0s8
       valid_lft forever preferred_lft forever
    inet6 fe80::a00:27ff:febf:631f/64 scope link 
       valid_lft forever preferred_lft forever
...

第一个命令查看一个名为/etc/os-release的文件,里面包含有关已安装 Linux 发行版的信息。在这个例子中,我们的虚拟机正在运行 Ubuntu。这与接下来命令的输出相匹配,在该命令中我们看到一个基于 Ubuntu 的 Linux 内核。最后,我们列出网络接口,并看到一个 IP 地址192.168.61.11

示例的设置步骤已自动安装 Docker,因此我们可以直接使用它。首先,让我们用一个命令下载并启动一个 Rocky Linux 容器:

root@host01:~# docker run -ti rockylinux:8
Unable to find image 'rockylinux:8' locally
8: Pulling from library/rockylinux
...
Status: Downloaded newer image for rockylinux:8

我们在docker run命令中使用-ti参数,告诉 Docker 我们需要一个交互式终端来运行命令。docker run的唯一其他参数是容器镜像rockylinux:8,它指定了名称rockylinux和版本8。由于我们没有提供要运行的命令,因此默认使用该容器镜像的bash命令。

现在我们在容器内有了一个 shell 提示符,可以运行一些命令,然后使用exit退出 shell 并停止容器:

➊ [root@18f20e2d7e49 /]# cat /etc/os-release
➋ NAME="Rocky Linux"
  ...
➌ [root@18f20e2d7e49 /]# yum install -y procps iproute
  ...
  [root@18f20e2d7e49 /]# ps -ef
  UID          PID    PPID  C STIME TTY          TIME CMD
  root        ➍ 1       0  0 13:30 pts/0    00:00:00 /bin/bash
  root          19       1  0 13:46 pts/0    00:00:00 ps -ef
  [root@18f20e2d7e49 /]# ip addr
  1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 ...
  link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
  inet 127.0.0.1/8 scope host lo
     valid_lft forever preferred_lft forever
➎ 18: eth0@if19: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 ... 
  link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
  inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
     valid_lft forever preferred_lft forever
  [root@18f20e2d7e49 /]# uname -v
➏ #...-Ubuntu SMP ...
  [root@18f20e2d7e49 /]# exit

当我们在容器内运行命令时,看起来就像是在一个 Rocky Linux 系统中运行。与主机系统相比,有多个差异:

  • shell 提示符中不同的主机名 ➊(我的主机名是18f20e2d7e49,但你的会不同)

  • 不同的文件系统内容 ➋,包括像/etc/os-release这样的基础文件

  • 使用yum ➌安装包,甚至基础命令也需要安装包

  • 限制的运行进程集,没有基础系统服务,我们的 bash shell ➍作为进程 ID(PID)1

  • 不同的网络设备 ➎,包括不同的 MAC 地址和 IP 地址

然而,奇怪的是,当我们运行uname -v时,我们看到的与主机上完全相同的 Ubuntu Linux 内核 ➏。显然,容器并不是我们想象的那样是一个完全独立的系统。

镜像和卷挂载

乍一看,容器看起来像是常规进程和虚拟机的混合体。我们与 Docker 的交互方式更深刻地加强了这一印象。让我们通过运行一个 Alpine Linux 容器来说明这一点。我们将首先“拉取”容器镜像,这与下载虚拟机镜像非常相似:

root@host01:~# docker pull alpine:3
3: Pulling from library/alpine
...
docker.io/library/alpine:3

接下来,我们将从镜像运行一个容器。我们将使用卷挂载来查看主机上的文件,这在虚拟机中是一个常见任务。我们还会告诉 Docker 指定一个环境变量,这就是我们在运行常规进程时会做的事情:

root@host01:~# docker run -ti -v /:/host -e hello=world alpine:3
/ # hostname
75b51510ab61

我们可以像之前在 Rocky Linux 中一样打印容器内的/etc/os-release文件内容:

/ # cat /etc/os-release 
NAME="Alpine Linux"
ID=alpine
...

然而,这次我们还可以打印主机的/etc/os-release文件,因为主机文件系统已挂载到/host

/ # cat /host/etc/os-release 
NAME="Ubuntu"
...

最后,在容器内我们也可以访问传入的环境变量:

/ # echo $hello
world
/ # exit

这种混合虚拟机和常规进程的概念有时会导致新容器用户提出类似“为什么我不能通过 SSH 连接到我的容器?”的问题。接下来几章的一个主要目标是阐明容器到底是什么。

容器究竟是什么

尽管容器看起来像有自己的主机名、文件系统、进程空间和网络,但容器并不是虚拟机。它没有独立的内核,因此不能有独立的内核模块或设备驱动程序。一个容器可以有多个进程,但它们必须由第一个进程(PID 1)显式启动。所以,容器默认不会有 SSH 服务器,大多数容器也不会运行任何系统服务。

在接下来的几章中,我们将看看容器如何在看似是一个独立系统的同时,实际上只是一组进程。现在,让我们再试一个 Docker 示例,看看从主机系统看容器是什么样的。

首先,我们将下载并运行 NGINX,只需一个命令:

root@host01:~# docker run -d -p 8080:80 nginx
Unable to find image 'nginx:latest' locally
latest: Pulling from library/nginx
...
Status: Downloaded newer image for nginx:latest
e9c5e87020372a23ce31ad10bd87011ed29882f65f97f3af8d32438a8340f936

这个示例演示了几个额外有用的 Docker 命令。再次提醒,我们正在混合虚拟机和常规进程的概念。通过使用-d标志,我们告诉 Docker 以守护进程模式(后台模式)运行容器,这正是我们为常规进程所做的事情。然而,使用-p 8080:80带来了另一个虚拟机的概念,因为它指示 Docker 将主机上的 8080 端口转发到容器内的 80 端口,从而让我们即使容器有自己的网络接口,也能从主机连接到 NGINX。

NGINX 现在在 Docker 容器中后台运行。要查看它,请运行以下命令:

root@host01:~# docker ps
CONTAINER ID IMAGE ... PORTS                  NAMES
e9c5e8702037 nginx ... 0.0.0.0:8080->80/tcp   funny_montalcini

由于端口转发,我们可以通过curl从主机系统连接到它:

root@host01:~# curl http://localhost:8080/
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

通过这个示例,我们开始看到容器化如何满足我们在本章前面提到的一些需求。因为 NGINX 被打包成一个容器镜像,我们可以通过一个命令下载并运行它,而不必担心与主机上可能安装的其他内容产生冲突。

让我们再运行一个命令来探索我们的 NGINX 服务器:

root@host01:~# ps -ef | grep nginx | grep -v grep
root     35729 35703 0 14:17 ? 00:00:00 nginx: master ...
systemd+ 35796 35729 0 14:17 ? 00:00:00 nginx: worker ...

如果 NGINX 运行在虚拟机中,我们在主机系统的 ps 列表中是看不到它的。显然,NGINX 在容器中作为一个常规进程运行。同时,我们并不需要将 NGINX 安装到主机系统中就能让它工作。换句话说,我们可以享受虚拟机方法的好处,而无需承受虚拟机的开销。

将容器部署到 Kubernetes

为了在容器化应用程序中实现负载均衡和弹性,我们需要像 Kubernetes 这样的容器编排框架。我们的示例系统还自动安装了一个 Kubernetes 集群,并将 Web 应用程序和数据库部署到了其中。作为我们深入探讨 Kubernetes 的准备,在第二部分中,让我们来看一下这个应用程序。

有很多不同的安装和配置 Kubernetes 集群的选项,许多公司都提供了不同的发行版。在第六章中,我们讨论了多种 Kubernetes 发行版的选择。本章中,我们将使用来自 Rancher 公司的轻量级发行版“K3s”。

为了使用像 Kubernetes 这样的容器编排环境,我们必须放弃对容器的部分控制。我们不再直接执行命令来运行容器,而是告诉 Kubernetes 我们希望它运行哪些容器,Kubernetes 会决定在哪个节点上运行每个容器。Kubernetes 会为我们监控容器,并处理自动重启、故障转移、版本更新,甚至根据负载进行自动扩缩容。这种配置方式被称为声明式

与 Kubernetes 集群交互

Kubernetes 集群有一个 API 服务器,我们可以用它来获取状态并更改集群配置。我们通过 kubectl 客户端应用程序与 API 服务器交互。K3s 附带了它自己的内嵌 kubectl 命令,我们将使用它。让我们先获取一些关于 Kubernetes 集群的基本信息:

root@host01:~# k3s kubectl version
Client Version: version.Info{Major:"1", ...
Server Version: version.Info{Major:"1", ...
root@host01:~# k3s kubectl get nodes
NAME     STATUS   ROLES             AGE   VERSION
host01   Ready    control-plane...  2d    v1...

如你所见,我们正在使用一个单节点 Kubernetes 集群。当然,这并不能满足我们对高可用性的需求。大多数 Kubernetes 发行版,包括 K3s,都支持多节点、高可用性集群,我们将在第二部分中详细介绍这种方式。

应用程序概述

我们的示例应用程序提供一个带有 Web 界面的“待办事项”列表、持久化存储以及条目状态跟踪。即使自动化脚本完成后,这个应用程序在 Kubernetes 中运行也需要几分钟时间。运行后,我们可以在浏览器中访问它,并应该看到类似图 1-1 的内容。

Image

图 1-1:Kubernetes 中的示例应用程序

这个应用程序被分为两种类型的容器,每种类型负责一个应用组件。Node.js 应用程序向浏览器提供文件并提供 REST API。Node.js 应用程序与 PostgreSQL 数据库通信。Node.js 组件是无状态的,因此可以根据用户数量轻松扩展到所需的多个实例。在这种情况下,我们的应用程序的 Deployment 向 Kubernetes 请求了三个 Node.js 容器:

root@host01:~# k3s kubectl get pods
NAME                       READY   STATUS    RESTARTS   AGE
todo-db-7df8b44d65-744mt   1/1     Running   0          2d
todo-655ff549f8-l4dxt      1/1     Running   0          2d
todo-655ff549f8-gc7b6      1/1     Running   1          2d
todo-655ff549f8-qq8ff      1/1     Running   1          2d

命令 get pods 告诉 Kubernetes 列出 Pods。Pod 是一个包含一个或多个容器的组,Kubernetes 将其视为一个单元进行调度和监控。我们将在第二部分中更详细地查看 Pods。

在这里,我们有一个以 todo-db 开头的 Pod,它是我们的 PostgreSQL 数据库。其他三个以 todo 开头的 Pods 是 Node.js 容器。(稍后我们会解释为什么名称后面会有随机字符;你现在可以忽略这一点。)

根据 Kubernetes 的说法,我们的应用组件容器正在运行,所以我们应该能够在浏览器中访问我们的应用。如何操作取决于你是在 AWS 还是 Vagrant 中运行;示例设置脚本会打印出你在浏览器中应该使用的 URL。如果你访问那个 URL,你应该能看到类似于图 1-1 的内容。

Kubernetes 特性

如果我们的唯一目标是运行四个容器,我们可以仅使用之前描述的 Docker 命令来完成。然而,Kubernetes 提供了更多的功能。让我们快速了解一下最重要的功能。

除了运行我们的容器,Kubernetes 还在监控它们。因为我们要求有三个实例,Kubernetes 会确保始终保持三个实例运行。让我们销毁一个并观察 Kubernetes 如何自动恢复:

root@host01:~# k3s kubectl delete pod todo-655ff549f8-qq8ff
pod "todo-655ff549f8-qq8ff" deleted
root@host01:~# k3s kubectl get pods
NAME                       READY   STATUS    RESTARTS   AGE
todo-db-7df8b44d65-744mt   1/1     Running   0          2d
todo-655ff549f8-l4dxt      1/1     Running   0          2d
todo-655ff549f8-gc7b6      1/1     Running   1          2d
todo-655ff549f8-rm8sh      1/1     Running   0          11s

要运行这个命令,你需要复制并粘贴你三个 Pods 之一的完整名称。这个名称会与你的稍微不同。当你删除一个 Pod 时,你应该会看到 Kubernetes 立即创建一个新的 Pod。(你可以通过 AGE 字段来识别哪个是新创建的。)

接下来,让我们探讨一下 Kubernetes 如何自动扩展我们的应用程序。稍后我们将看到如何让 Kubernetes 自动执行此操作,但现在我们将手动进行。假设我们决定需要五个 Pods,而不是三个。我们可以通过一条命令来做到这一点:

root@host01:~# k3s kubectl scale --replicas=5 deployment todo
deployment.apps/todo scaled
root@host01:~# k3s kubectl get pods
NAME                       READY   STATUS    RESTARTS   AGE
todo-db-7df8b44d65-744mt   1/1     Running   0          2d
todo-655ff549f8-l4dxt      1/1     Running   0          2d
todo-655ff549f8-gc7b6      1/1     Running   1          2d
todo-655ff549f8-rm8sh      1/1     Running   0          5m13s
todo-655ff549f8-g7lxg      1/1     Running   0          6s
todo-655ff549f8-zsqp6      1/1     Running   0          6s

我们告诉 Kubernetes 扩展管理我们 Pods 的 Deployment。现在,你可以把 Deployment 看作是 Pods 的“所有者”;它监控它们并控制 Pods 的数量。这里,两个额外的 Pods 被立即创建。我们刚刚扩展了我们的应用程序。

在结束之前,让我们再来看一个至关重要的 Kubernetes 功能。当你在浏览器中加载应用程序时,Kubernetes 会将你的浏览器请求发送到可用的 Pod 之一。每次重新加载时,请求可能会被路由到不同的 Pod,因为 Kubernetes 会自动平衡应用程序的负载。为了实现这一点,当我们将应用程序部署到 Kubernetes 时,应用程序的配置包括一个Service

root@host01:~# k3s kubectl describe service todo
Name:       todo
...
IPs:        10.43.231.177
Port:       <unset>  80/TCP
TargetPort: 5000/TCP
Endpoints:  10.42.0.10:5000,10.42.0.11:5000,10.42.0.14:5000 + 2 more...
...

每个服务都有自己的 IP 地址,并将流量路由到一个或多个端点。在这种情况下,由于我们将 Pod 数量扩展到五个,服务正在对所有五个端点的流量进行负载均衡。

最后的思考

现代应用程序通过基于微服务的架构实现可扩展性和可靠性,微服务可以独立部署并动态分配到可用的硬件上,包括云资源。通过使用容器和容器编排来运行我们的微服务,我们实现了一种通用的方法来打包、扩展、监控和维护微服务,使我们的开发团队可以专注于实际构建应用程序的艰苦工作。

在本章中,我们看到容器化如何创造出一个独立系统的外观,而实际上它只是一个以隔离方式运行的常规进程。我们还看到如何使用 Kubernetes 将整个应用程序作为一组容器进行部署,具备可扩展性和自愈性。当然,Kubernetes 的功能远不止我们在这里提到的这些,足够让我们用整本书来详细讲解!通过这一简要概述,我希望你能对容器和 Kubernetes 产生兴趣,深入了解如何构建高性能且可靠的应用程序。

我们将在本书的第二部分再次讨论 Kubernetes。现在,让我们仔细看看容器是如何创建出一个独立系统的假象的。我们将从使用 Linux 命名空间来实现进程隔离开始。

第二章:进程隔离

image

容器建立在一系列技术的基础上,这些技术旨在隔离一个计算机程序与另一个程序,同时允许多个程序共享相同的 CPU、内存、存储和网络资源。容器利用 Linux 内核的基本能力,特别是命名空间(namespace),它们创建了进程标识符、用户、文件系统和网络接口的独立视图。容器运行时使用多种类型的命名空间,为每个容器提供系统的隔离视图。

在本章中,我们将考虑进程隔离的一些原因,并回顾 Linux 是如何历史性地实现进程隔离的。然后,我们将研究容器如何使用命名空间来提供隔离。我们将通过几种不同的容器运行时进行测试。最后,我们将使用 Linux 命令直接创建命名空间。

理解隔离

在运行一些容器并检查其隔离性之前,让我们先看看进程隔离的动机。我们还将考虑 Linux 中传统的进程隔离方式,以及这如何促成了容器所使用的隔离能力。

为什么进程需要隔离

计算机的整体概念是它是一台通用机器,可以运行多种不同类型的程序。自计算机诞生以来,就有需要在多个程序之间共享同一台计算机的需求。最初,人们通过打孔卡片轮流提交程序,但随着计算机多任务处理的日益复杂,人们可以启动多个程序,而计算机会让它们看起来好像都在同一个 CPU 上同时运行。

当然,一旦某个资源需要共享,就需要确保共享是公平的,计算机程序也不例外。所以,尽管我们认为一个进程是一个独立的程序,拥有自己的 CPU 时间和内存空间,但有许多方式可能导致一个进程对另一个进程造成困扰,包括:

  • 使用过多的 CPU、内存、存储或网络资源

  • 覆盖另一个进程的内存或文件

  • 从另一个进程中提取机密信息

  • 向另一个进程发送错误数据,导致其行为异常

  • 向另一个进程发送大量请求,使其停止响应

错误可能会导致进程意外地做出这些行为,但更大的问题是安全漏洞,允许恶意行为者利用一个进程对另一个进程造成问题。一个漏洞就足以在系统中造成重大问题,因此我们需要能够隔离进程的方式,以限制意外和故意行为带来的损害。

物理隔离是最好的——气隔系统常常被用于保护政府机密信息和安全关键系统——但这种方法对于许多应用来说也过于昂贵且不便。虚拟机可以在共享物理硬件的同时,提供隔离的外观,但虚拟机需要运行自己的操作系统、服务和虚拟设备,导致启动更慢,扩展性差。解决方案是运行常规进程,但利用进程隔离来降低影响其他进程的风险。

文件权限与变更根目录

大多数进程隔离的工作集中在防止一个进程看到它不应该看到的内容。毕竟,如果一个进程甚至无法看到另一个进程,它就更难制造麻烦,无论是意外还是故意。Linux 传统上控制进程能够看到和做什么的方式为容器的思想提供了基础。

最基本的可见性控制之一是文件系统权限。Linux 为每个文件和目录关联一个所有者和一个组,并管理读、写和执行权限。这种基本的权限方案能够很好地确保用户文件的私密性,防止进程覆盖另一个进程的文件,并确保只有像 root 这样的特权用户才能安装新软件或修改关键的系统配置文件。

当然,这种权限方案依赖于我们确保每个进程以真实用户身份运行,并且用户位于适当的组中。通常,每个新服务安装都会为该服务创建一个专用用户。更好的是,这个服务用户可以配置为没有真实的登录 shell,这意味着该用户无法被利用登录系统。为了更清楚地说明这一点,我们来看一个示例。

注意

本书的示例仓库在 github.com/book-of-kubernetes/examples有关设置的详细信息,请参见第 xx 页中的“运行示例”。

Linux 的rsyslogd服务提供日志服务,因此需要写入/var/log中的文件,但不应该有权限读取或写入该目录中的所有文件。文件权限用于控制这一点,示例如下:

   root@host01:~# ps -ef | grep rsyslogd | grep -v grep
➊ syslog  698  1  0 Mar05 ?   00:00:04 /usr/sbin/rsyslogd -n -iNONE
   root@host01:~# su syslog
➋ This account is currently not available.
   root@host01:~# ls -l /var/log/auth.log
➌ -rw-r----- 1 syslog adm 18396 Mar  6 01:27 /var/log/auth.log
   root@host01:~# ls -ld /var/log/private
➍ drwx------ 2 root root 4096 Mar  5 21:04 /var/log/private

syslog用户 ➊ 专门用于运行rsyslogd,并且出于安全原因,该用户被配置为没有登录 shell ➋。由于rsyslogd需要能够写入auth.log,因此赋予了写权限,如文件模式输出中所示 ➌。管理员组(adm)的成员对该文件具有只读权限。

文件模式中的初始 d ➍ 表示这是一个目录。接下来的 rwx 表示 root 用户具有读、写和执行权限。其余的破折号表示 root 组的成员或其他系统用户没有权限,因此我们可以推断出 rsyslogd 进程无法查看此目录的内容。

权限控制很重要,但它并不能完全满足我们对进程隔离的需求。一个原因是,它不足以防止特权升级,即一个脆弱的进程和系统可能让恶意行为者获得 root 权限。为了解决这个问题,一些 Linux 服务通过在文件系统的隔离部分中运行来进一步加强安全。这种方法被称为 chroot,即“更改根目录”。在 chroot 环境中运行需要一些配置,正如我们在这个示例中看到的那样:

   root@host01:~# mkdir /tmp/newroot
   root@host01:~# ➊ cp --parents /bin/bash /bin/ls /tmp/newroot
   root@host01:~# cp --parents /lib64/ld-linux-x86-64.so.2 \
  ➋ $(ldd /bin/bash /bin/ls | grep '=>' | awk '{print $3}') /tmp/newroot
   ...
   root@host01:~# ➌ chroot /tmp/newroot /bin/bash
   bash-5.0# ls -l /bin
   total 1296
➍ -rwxr-xr-x 1 0 0 1183448 Mar  6 02:15 bash
   -rwxr-xr-x 1 0 0  142144 Mar  6 02:15 ls
   bash-5.0# exit
   exit

首先,我们需要将所有打算运行的可执行文件复制到容器中 ➊。我们还需要将这些可执行文件使用的所有共享库复制进来,我们通过 ldd | grep | awk 命令来指定这些库 ➋。当二进制文件和库都被复制进容器后,我们可以使用 chroot 命令 ➌ 进入隔离环境。只有我们复制进来的文件是可见的 ➍。

容器隔离

对于有经验的 Linux 系统管理员来说,文件权限和更改根目录是基础知识。然而,这些概念也为容器的工作原理提供了基础。尽管正在运行的容器看起来像是一个完全独立的系统,拥有自己的主机名、网络、进程和文件系统(正如我们在第一章中看到的那样),它实际上只是一个普通的 Linux 进程,利用隔离机制而不是虚拟机。

一个容器具有多种隔离方式,包括一些我们之前未曾见过的关键隔离类型:

  • 挂载的文件系统

  • 主机名和域名

  • 进程间通信

  • 进程标识符

  • 网络设备

这些不同类型的隔离机制共同作用,使得一个进程或一组进程看起来像是一个完全独立的系统。尽管这些进程仍然共享内核和物理硬件,但这种隔离机制大大确保了它们不会对其他进程造成困扰,特别是当我们正确配置容器,以控制它们可用的 CPU、内存、存储和网络资源时。

容器平台和容器运行时

指定在隔离文件系统中运行进程所需的所有二进制文件、库和配置文件会很繁琐。幸运的是,正如我们在第一章中看到的那样,容器镜像已经预先打包了所需的可执行文件和库。通过使用 Docker,我们能够轻松下载并在容器中运行 NGINX。Docker 是一个容器平台的例子,提供了不仅是运行容器的能力,还有容器存储、网络和安全性。

在背后,现代版本的 Docker 使用containerd作为容器运行时,也被称为容器引擎。容器运行时提供了在容器中运行进程的底层功能。

为了进一步探索隔离性,让我们实验使用两种不同的容器运行时,从现有镜像启动容器,然后检查容器中进程如何与系统的其他部分隔离。

安装 containerd

我们将在第二部分中使用containerd来支持我们的 Kubernetes 集群,因此让我们首先安装并直接与这个运行时交互。直接与containerd交互也将有助于我们探索进程隔离。

你可以通过使用本章示例提供的额外配置脚本跳过安装命令。请参阅本章的 README 文件以获取说明。

尽管containerd可以在标准的 Ubuntu 软件包库中找到,我们还是会从官方的 Docker 软件包库安装,以确保我们获得最新的稳定版本。为此,我们需要让 Apt 支持 HTTP/S 协议,因此我们首先需要进行此设置:

root@host01:~# apt update
...
root@host01:~# apt -y install apt-transport-https
...

现在让我们添加包注册表并进行安装:

root@host01:~# curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
  gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
root@host01:~# echo "deb [arch=amd64" \
  "signed-by=/usr/share/keyrings/docker-archive-keyring.gpg]" \
  "https://download.docker.com/linux/ubuntu focal stable" > \
  /etc/apt/sources.list.d/docker.list
root@host01:~# apt update && apt install -y containerd.io
...
root@host01:~# ctr images ls
REF TYPE DIGEST SIZE PLATFORMS LABELS

最后的命令只是确保包已正确安装,服务正在运行,并且ctr命令可以正常工作。我们没有看到任何镜像,因为我们还没有安装任何镜像。

容器运行时是底层库。通常不会直接使用它们,而是由更高层的容器平台或编排环境(例如 Docker 或 Kubernetes)使用。这意味着它们会将大量精力放在高质量的应用程序编程接口(API)上,但不会在命令行工具上花费太多精力,尽管这些工具仍然是测试所必需的。幸运的是,containerd提供了我们将用于实验的ctr工具。

使用 containerd

我们最初的containerd命令显示尚未下载任何镜像。让我们下载一个小镜像,用于运行容器。我们将使用BusyBox,这是一个包含 shell 和基本 Linux 工具的小型容器镜像。为了下载镜像,我们使用pull命令:

root@host01:~# ctr image pull docker.io/library/busybox:latest
...
root@host01:~# ctr images ls
REF                              ...
docker.io/library/busybox:latest ...

我们的镜像列表不再为空。让我们从这个镜像运行一个容器:

root@host01:~# ctr run -t --rm docker.io/library/busybox:latest v1
/ #

这看起来与使用 Docker 类似。我们使用-t来为这个容器创建一个 TTY,以便与其交互,并使用--rm告诉containerd在主进程停止时删除容器。然而,有一些重要的区别需要注意。当我们在第一章中使用 Docker 时,我们并没有担心在运行容器之前拉取镜像,我们可以使用像nginxrockylinux:8这样的简化名称。ctr工具要求我们指定docker.io/library/busybox:latest,即镜像的完整路径,包括注册表主机名和标签。另外,我们需要先拉取镜像,因为运行时不会自动为我们做这件事。

现在我们进入这个容器,可以看到它具有隔离的网络栈和进程空间:

/ # ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
        valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
        valid_lft forever preferred_lft forever
/ # ps -ef
PID   USER     TIME  COMMAND
    1 root      0:00 sh
    8 root      0:00 ps -ef
/ #

在容器内部,我们看到一个回环网络接口。我们还看到我们的 shell 进程和我们运行的 ps 命令。就容器中的进程而言,我们正在一个没有其他进程运行或在网络上监听的独立系统上运行。

为什么没有桥接接口?

如果你曾经使用过 Docker,可能会惊讶地发现这个容器只有一个回环接口。容器平台的默认网络配置通常还提供一个附加的接口,该接口连接到一个桥接。这样,容器之间可以互相看到,并且容器可以通过网络地址转换(NAT)使用主机接口访问外部网络。

在这种情况下,我们直接与一个较低级别的容器运行时进行交互。这个容器运行时仅处理镜像管理和容器运行。如果我们需要一个桥接接口和互联网连接,就需要自己提供(我们在第四章中正是这么做的)。

我们已经说明了如何与 containerd 运行时交互以运行容器,并且在容器内部,我们与系统的其他部分是隔离的。这个隔离是如何工作的呢?为了找出答案,我们将继续让容器运行并从主机系统进行调查。

介绍 Linux 命名空间

和其他容器运行时一样,containerd 使用名为 命名空间 的 Linux 内核特性来隔离容器中的进程。如前所述,进程隔离的主要工作是确保进程看不到它不该看到的东西。运行在命名空间中的进程只能看到特定系统资源的有限视图。

尽管容器化看起来像是新技术,但 Linux 命名空间已经存在了多年。随着时间的推移,添加了更多类型的命名空间。我们可以使用 lsns 命令找出与我们的容器相关联的命名空间,但首先我们需要知道容器内 shell 进程在主机上的进程 ID(PID)。在保持容器运行的同时,打开另一个终端标签或窗口。(有关更多信息,请参见第 xx 页中的“运行示例”)。然后,使用 ctr 列出正在运行的容器:

root@host01:~# ctr task ls
TASK    PID      STATUS    
v1      18088    RUNNING

让我们使用 ps 来验证我们是否得到了正确的 PID。当你自己运行这些命令时,请务必使用列出中显示的 PID:

root@host01:~# ps -ef | grep 18088 | grep -v grep
root       18088   18067  0 18:46 pts/0    00:00:00 sh
root@host01:~# ps -ef | grep 18067 | grep -v grep
root       18067       1  0 18:46 ?        00:00:00 
  /usr/bin/containerd-shim-runc-v2 -namespace default -id v1 -address 
  /run/containerd/containerd.sock
root       18088   18067  0 18:46 pts/0    00:00:00 sh

正如预期的那样,这个 PID 的父进程是 containerd。接下来,让我们使用 lsns 列出 containerd 创建的命名空间,以隔离这个进程:

root@host01:~# lsns | grep 18088
4026532180 mnt         1 18088 root            sh
4026532181 uts         1 18088 root            sh
4026532182 ipc         1 18088 root            sh
4026532183 pid         1 18088 root            sh
4026532185 net         1 18088 root            sh

在这里,containerd 使用五种不同类型的命名空间来完全隔离在 busybox 容器中运行的进程:

mnt 挂载点

uts Unix 时间共享(主机名和网络域)

ipc 进程间通信(例如,共享内存)

pid 进程标识符(以及正在运行的进程列表)

net 网络(包括接口、路由表和防火墙)

最后,我们通过在该容器中运行exit来关闭 BusyBox 容器(第一个终端窗口):

/ # exit

该命令将返回常规的 Shell 提示符,使我们准备好进行下一组示例。

CRI-O 中的容器和命名空间

除了containerd,Kubernetes 还支持其他容器运行时。根据你使用的 Kubernetes 发行版,你可能会发现容器运行时不同。例如,Red Hat OpenShift 使用CRI-O,这是另一种容器运行时。CRI-O 还被 Podman、Buildah 和 Skopeo 工具套件使用,它们是 Red Hat 8 及相关系统上管理容器的标准方式。

让我们使用 CRI-O 运行相同的容器镜像,以便更好地了解容器运行时如何彼此不同,但也展示它们如何利用相同的 Linux 内核功能进行进程隔离。

你可以通过使用本章示例中提供的额外预配脚本跳过这些安装命令。有关说明,请参阅本章的 README 文件。

OpenSUSE Kubic 项目为各种 Linux 发行版(包括 Ubuntu)提供 CRI-O 的存储库,因此我们将从那里安装。具体的 URL 取决于我们要安装的 CRI-O 版本,且 URL 较长并且难以输入,因此自动化会安装一个脚本来配置一些有用的环境变量。在继续之前,我们需要加载该脚本:

root@host01:~# source /opt/crio-ver

我们现在可以使用环境变量来设置 CRI-O 存储库并安装 CRI-O:

root@host01:~# echo "deb $REPO/$OS/ /" > /etc/apt/sources.list.d/kubic.list
root@host01:~# echo "deb $REPO:/cri-o:/$VERSION/$OS/ /" \
  > /etc/apt/sources.list.d/kubic.cri-o.list
root@host01:~# curl -L $REPO/$OS/Release.key | apt-key add -
...
OK
root@host01:~# apt update && apt install -y cri-o cri-o-runc
...
root@host01:~# systemctl enable crio && systemctl start crio
...
root@host01:~# curl -L -o /tmp/crictl.tar.gz $CRICTL_URL
...
root@host01:~# tar -C /usr/local/bin -xvzf /tmp/crictl.tar.gz
crictl
root@host01:~# rm -f /tmp/crictl.tar.gz

我们首先通过向/etc/apt/sources.list.d添加文件来将 CRI-O 添加到apt的存储库列表中。然后我们使用apt安装 CRI-O 软件包。安装 CRI-O 后,我们使用systemd启用并启动其服务。

containerd不同,CRI-O 没有附带我们可以用来进行测试的命令行工具,因此最后一条命令安装了crictl,它是 Kubernetes 项目的一部分,旨在测试任何与容器运行时接口(CRI)标准兼容的容器运行时。CRI 是 Kubernetes 本身用于与容器运行时通信的编程 API。

因为crictl与任何支持 CRI 的容器运行时兼容,所以需要配置它以连接到 CRI-O。CRI-O 已安装了一个配置文件/etc/crictl.yaml来配置crictl

crictl.yaml

runtime-endpoint: unix:///var/run/crio/crio.sock
image-endpoint: unix:///var/run/crio/crio.sock
...

这个配置告诉crictl连接到 CRÍ-O 的套接字。

要创建和运行容器,crictl命令要求我们提供 JSON 或 YAML 文件格式的定义文件。本章的自动化脚本已将两个crictl定义文件添加到/opt目录。第一个文件,如清单 2-1 所示,创建一个 Pod:

pod.yaml

---
metadata:
  name: busybox
  namespace: crio
linux:
  security_context:
    namespace_options:
      network: 2

清单 2-1:CRI-O Pod 定义

与我们在 第一章 中看到的 Kubernetes Pod 类似,Pod 是一组在同一隔离空间中运行的一个或多个容器。在我们的案例中,我们只需要一个容器在 Pod 中,第二个文件,见 示例 2-2,定义了 CRI-O 应该启动的容器进程。我们提供一个名称(busybox)和命名空间(crio)来区分这个 Pod 和其他 Pod。否则,我们只需提供网络配置。CRI-O 期望使用容器网络接口(CNI)插件来配置网络命名空间。我们将在 第八章 中讨论 CNI 插件,因此现在我们将使用 network: 2 告诉 CRI-O 不要创建单独的网络命名空间,而是使用主机网络:

container.yaml

---
metadata:
  name: busybox
image:
  image: docker.io/library/busybox:latest
args:
  - "/bin/sleep"
  - "36000"

示例 2-2:CRI-O 容器定义

再次使用 BusyBox 是因为它体积小,运行快速且轻量。然而,由于 crictl 会在后台创建此容器而没有终端,我们需要指定 /bin/sleep 作为容器内要运行的命令;否则,容器会立即终止,因为 shell 会发现它没有 TTY。

在运行容器之前,我们首先需要拉取镜像:

root@host01:~# crictl pull docker.io/library/busybox:latest
Image is up to date for docker.io/library/busybox@sha256:...

然后,我们将 pod.yamlcontainer.yaml 文件提供给 crictl,以创建并启动我们的 BusyBox 容器:

root@host01:~# cd /opt
root@host01:~# POD_ID=$(crictl runp pod.yaml)
root@host01:~# crictl pods
POD ID              CREATED                  STATE ...
3bf297ace44b5       Less than a second ago   Ready ...
root@host01:~# CONTAINER_ID=$(crictl create $POD_ID container.yaml pod.yaml)
root@host01:~# crictl start $CONTAINER_ID
91394a7f37e3da3a557782ed6d6eb2cf8c23e5b3dd4e2febd415bba071d10734
root@host01:~# crictl ps
CONTAINER           ... STATE
91394a7f37e3d       ... Running

我们捕获了 Pod 的唯一标识符和容器的标识符,分别保存在 POD_IDCONTAINER_ID 变量中,以便在这里和接下来的命令中使用。

在查看 CRI-O 创建的 Linux 命名空间之前,让我们通过使用 crictl exec 命令在容器内部启动一个新的 shell 进程来查看 busybox 容器的内部:

root@host01:~# crictl exec -ti $CONTAINER_ID /bin/sh
/ # ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
...
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel qlen 1000
...
3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel qlen 1000
...
/ # ps -ef
PID   USER     TIME  COMMAND
    1 root      0:00 /pause
    7 root      0:00 /bin/sleep 36000
    13 root      0:00 /bin/sh
    20 root      0:00 ps -ef
/ # exit

这个在 CRI-O 中运行的 BusyBox 容器与在 containerd 中运行的 BusyBox 看起来有些不同。首先,因为我们将 Pod 配置为 network: 2,所以容器可以看到与常规进程相同的网络设备。其次,我们看到了一些额外的进程。当我们在 第十二章讨论 Kubernetes 下的容器运行时时,我们会看到 PID 为 1 的 pause 进程。另一个额外的进程是 sleep,我们将其作为此容器的入口点。

CRI-O 也使用 Linux 命名空间来进行进程隔离,正如我们从检查容器进程和列出命名空间中看到的那样:

root@host01:~# PID=$(crictl inspect $CONTAINER_ID | jq '.info.pid')
root@host01:~# ps -ef | grep $PID | grep -v grep
root       23906   23894  0 20:15 ?        00:00:00 /bin/sleep 36000
root@host01:/opt# ps -ef | grep 23894 | grep -v grep
root       23894       1  0 20:15 ?        00:00:00 /usr/bin/conmon ...
root       23906   23894  0 20:15 ?        00:00:00 /bin/sleep 36000

crictl inspect 命令提供了大量关于容器的信息,但目前我们只需要 PID。由于 crictl 返回 JSON 格式的输出,我们可以使用 jqinfo 结构中提取 pid 字段并将其保存到一个名为 PID 的环境变量中。尝试运行 crictl inspect $CONTAINER_ID 来查看完整信息。

使用我们发现的 PID,我们可以看到我们的sleep命令。然后,我们可以使用其父 PID 来验证它是由conmon(一个 CRI-O 工具)管理的。接下来,让我们看看 CRI-O 创建的命名空间。由于 CRI-O 中进程的命名空间分配更为复杂,我们将列出 Linux 系统上的所有命名空间,并挑选出与容器相关的命名空间:

root@host01:~# lsns
        NS TYPE   NPROCS   PID USER            COMMAND
...
4026532183 uts         2 23867 root            /pause
4026532184 ipc         2 23867 root            /pause
4026532185 mnt         1 23867 root            /pause
4026532186 pid         2 23867 root            /pause
4026532187 mnt         1 23906 root            /bin/sleep 36000
...

在这里,我们只看到四种类型的命名空间。因为我们告诉 CRI-O 允许容器访问主机的网络命名空间,所以它不需要创建net命名空间。此外,在 CRI-O 中,大多数命名空间与pause命令关联(尽管有些命名空间被多个进程共享,正如我们通过NPROCS列看到的)。有两个mnt命名空间,因为每个 Pod 中的独立容器会得到一组不同的挂载点,具体原因我们将在第五章中讨论。

在命名空间中直接运行进程

在容器中运行进程时,最棘手的任务之一是处理作为 PID 1 所带来的责任。为了更好地理解这一点,我们不会让容器运行时为我们创建命名空间。而是直接与 Linux 内核通信,手动在命名空间中运行进程。我们将使用命令行,虽然容器运行时使用 Linux 内核 API,但结果是相同的。

因为命名空间是 Linux 内核的特性,所以无需安装或配置其他内容。我们只需在启动进程时使用unshare命令:

root@host01:~# unshare -f -p --mount-proc -- /bin/sh -c /bin/bash

unshare命令在不同命名空间下运行一个程序。通过添加-p,我们指定需要一个新的 PID 命名空间。选项--mount-proc与此配合,添加一个新的挂载命名空间,并确保/proc被正确地重新挂载,以便进程看到正确的进程信息。否则,进程仍然可以看到系统中其他进程的信息。最后,--后面的内容指示要运行的命令。

因为这是一个隔离的进程命名空间,它无法看到该命名空间外的进程列表:

root@host01:~# ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 22:21 pts/0    00:00:00 /bin/sh -c /bin/bash
root           2       1  0 22:21 pts/0    00:00:00 /bin/bash
root           9       2  0 22:22 pts/0    00:00:00 ps -ef

获取这个命名空间的 ID,以便我们在列表中识别它:

root@host01:~# ls -l /proc/self/ns/pid
lrwxrwxrwx 1 root root 0 Mar  6 22:22 /proc/self/ns/pid -> 'pid:[4026532190]'

现在,从另一个终端窗口列出所有命名空间,并查找与我们的隔离 shell 相关的命名空间:

root@host01:~# lsns
        NS TYPE NPROCS PID   USER COMMAND
...
4026532189 mnt  3      12110 root unshare -f -p ...
4026532190 pid  2      12111 root /bin/sh -c /bin/bash
...
root@host01:~# exit

我们看到一个与之前看到的匹配的pid命名空间。此外,我们还看到了一个mnt命名空间。这个命名空间确保我们的 shell 看到/proc中的正确信息。

因为pid命名空间是由sh命令拥有的,当我们在命名空间内运行ps时,sh命令的 PID 为 1。这意味着sh负责正确管理其子进程(如bash)。例如,sh负责向其子进程发送信号,确保它们正确终止。记住这一点很重要,因为这是在运行容器时常见的问题,可能导致僵尸进程或清理已停止容器时的其他问题。

幸运的是,sh 很好地处理了它的管理任务,我们可以看到,当我们向它发送 kill 信号时,它会将该信号传递给它的子进程。从第二个终端窗口运行此命令,位于命名空间之外:

root@host01:~# kill -9 12111

在第一个窗口中,你会看到以下输出:

root@host01:~# Killed

这表明 bash 收到了 kill 信号并正确终止。

最后的思考

虽然容器创建了一个完全独立的系统的表象,但其实现方式与虚拟机完全不同。相反,这个过程类似于传统的进程隔离方式,例如用户权限和独立的文件系统。容器运行时使用命名空间,这是 Linux 内核内置的功能,可实现各种类型的进程隔离。在本章中,我们研究了 containerd 和 CRI-O 容器运行时如何使用多种类型的 Linux 命名空间,为每个容器提供对其他进程、网络设备和文件系统的独立视图。命名空间的使用防止了容器中的进程看到并干扰其他进程。

同时,容器中的进程仍然共享相同的 CPU、内存和网络。一个使用过多资源的进程会阻止其他进程正常运行。然而,命名空间无法解决这个问题。为了防止这个问题,我们需要关注资源限制——这是下一章的主题。

第三章:资源限制

image

我们在第二章中做的进程隔离工作非常重要,因为一个进程通常无法影响它看不见的东西。然而,我们的进程可以看到主机的 CPU、内存和网络,因此,进程有可能通过过度使用这些资源,导致其他进程无法正确运行,无法为其他进程留下足够的空间。在本章中,我们将看到如何保证进程仅使用其分配的 CPU、内存和网络资源,从而确保我们可以准确划分资源。这将在我们进行容器编排时有所帮助,因为它将为 Kubernetes 提供有关每个主机可用资源的确定性,从而在调度容器时进行决策。

CPU、内存和网络是重要的,但还有一个非常重要的共享资源:存储。然而,在像 Kubernetes 这样的容器编排环境中,存储是分布式的,限制需要在整个集群层面应用。因此,我们对存储的讨论必须等到我们在第十五章引入分布式存储时才开始。

CPU 优先级

我们需要分别查看 CPU、内存和网络,因为应用限制的效果在每种情况下不同。让我们首先看看如何控制 CPU 使用。为了理解 CPU 限制,我们首先需要了解 Linux 内核是如何决定运行哪个进程以及运行多长时间的。在 Linux 内核中,调度器会维护一个所有进程的列表。它还会追踪哪些进程准备好运行,以及每个进程最近运行了多长时间。这使得它能够创建一个优先级列表,从而选择下一个要运行的进程。调度器的设计尽量公平(它被称为完全公平调度器);因此,它会尽力给所有进程提供运行的机会。然而,它也接受外部输入,决定哪些进程比其他进程更为重要。这个优先级划分由两个部分组成:调度策略,以及在该策略下每个进程的优先级。

实时和非实时策略

调度器支持几种不同的策略,但就我们的目的而言,我们可以将它们分为实时策略和非实时策略。术语实时意味着某些现实世界的事件对进程至关重要,并且需要在特定的最后期限前完成处理。如果进程在最后期限过后还没有完成处理,就会发生不良后果。例如,进程可能在从嵌入式硬件设备收集数据。在这种情况下,进程必须在硬件缓冲区溢出之前读取数据。实时进程通常不会非常占用 CPU,但当它需要 CPU 时,不能等待,因此所有处于实时策略下的进程都比任何处于非实时策略下的进程优先级更高。让我们通过一个示例 Linux 系统来探讨这个问题。

注意

本书的示例仓库位于 github.com/book-of-kubernetes/examples有关设置的详细信息,请参见第 xx 页的“运行示例”部分。

Linux 的ps命令告诉我们每个进程适用的具体策略。在host01上运行此命令,以查看本章示例:

root@host01:~# ps -e -o pid,class,rtprio,ni,comm
 PID CLS RTPRIO  NI COMMAND
   1 TS       -   0 systemd
...
   6 TS       - -20 kworker/0:0H-kblockd
...
  11 FF      99   - migration/0
  12 FF      50   - idle_inject/0
...
  85 FF      99   - watchdogd
...
 484 RR      99   - multipathd
...
7967 TS       -   0 ps

-o标志为ps提供了一个自定义的输出字段列表,包括调度策略classCLS)和两个数字优先级字段:RTPRIONI

首先查看CLS字段,许多进程列出为TS,表示“时间共享”,这是默认的非实时策略。这包括我们自己运行的命令(例如我们运行的ps命令)以及重要的 Linux 系统进程,如systemd。然而,我们也看到具有FF策略(先进先出,FIFO)和RR策略(轮转)的进程。这些是实时进程,因此它们的优先级高于系统中所有非实时策略的进程。列表中的实时进程包括watchdog(用于检测系统死锁,因此可能需要抢占其他进程)和multipathd(用于监视设备更改,并且必须在其他进程有机会与设备通信之前配置设备)。

除了类之外,两个数字优先级字段还告诉我们进程在策略中的优先级。不出所料,RTPRIO字段表示“实时优先级”,仅适用于实时进程。NI字段是进程的“nice”级别,仅适用于非实时进程。由于历史原因,nice 级别从-20(最不友好,或最高优先级)到 19(最友好,最低优先级)。

设置进程优先级

Linux 允许我们为启动的进程设置优先级。我们来尝试通过优先级控制 CPU 使用。我们将运行一个名为stress的程序,它旨在对我们的系统进行压力测试。我们将使用基于 CRI-O 的容器化版本的stress

如之前所述,我们需要为 Pod 和容器定义 YAML 文件,以告诉crictl该运行什么。清单 3-1 中显示的 Pod YAML 与第二章中的 BusyBox 示例几乎相同,唯一不同的是名称:

po-nolim.yaml

---
metadata:
  name: stress
  namespace: crio
linux:
  security_context:
    namespace_options:
      network: 2

清单 3-1:BusyBox Pod

容器的 YAML 相比于 BusyBox 示例有更多的更改。除了使用不同的容器镜像,即已经安装了stress的镜像外,我们还需要向stress提供参数,告诉它只使用一个 CPU:

co-nolim.yaml

---
metadata:
  name: stress
image:
  image: docker.io/bookofkubernetes/stress:stable
args:
  - "--cpu"
  - "1"
  - "-v"

host01上已安装 CRI-O,因此只需几条命令即可启动这个容器。首先,我们将拉取镜像:

root@host01:/opt# crictl pull docker.io/bookofkubernetes/stress:stable
Image is up to date for docker.io/bookofkubernetes/stress...

然后,我们可以从镜像运行一个容器:

root@host01:~# cd /opt
root@host01:/opt# PUL_ID=$(crictl runp po-nolim.yaml)
root@host01:/opt# CUL_ID=$(crictl create $PUL_ID co-nolim.yaml po-nolim.yaml)
root@host01:/opt# crictl start $CUL_ID
...
root@host01:/opt# crictl ps
CONTAINER      IMAGE                                    ...
971e83927329e  docker.io/bookofkubernetes/stress:stable ...

crictl ps命令只是用来检查我们的容器是否按预期运行。

现在,stress程序已在我们的系统上运行,我们可以查看当前的优先级和 CPU 使用情况。我们想查看当前的 CPU 使用情况,因此我们将使用top

root@host01:/opt# top -b -n 1 -p $(pgrep -d , stress)
top - 18:01:58 up  1:39,  1 user,  load average: 1.01, 0.40, 0.16
Tasks:   2 total,   1 running,   1 sleeping,   0 stopped,   0 zombie
%Cpu(s): 34.8 us, 0.0 sy, 0.0 ni, 65.2 id, 0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :   1987.5 total,   1024.5 free,    195.8 used,    767.3 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.   1643.7 avail Mem 

  PID   USER  PR  NI  ...  %CPU  %MEM    TIME+ COMMAND
  13459 root  20   0  ... 100.0   0.2  0:29.78 stress-ng
  13435 root  20   0  ...   0.0   0.2  0:00.01 stress-ng

pgrep命令查找了stress的进程 ID(PID);有两个 PID,因为stress为我们请求的 CPU 负载操作创建了一个独立的进程。这个 CPU 工作进程占用了一个 CPU 的 100%;幸运的是,我们的虚拟机有两个 CPU,所以它并没有超载。

我们以默认优先级启动了这个进程,因此它的 nice 值为0,如NI列所示。如果我们改变这个优先级会发生什么呢?让我们使用renice来找出答案:

root@host01:/opt# renice -n 19 -p $(pgrep -d ' ' stress)
13435 (process ID) old priority 0, new priority 19
13459 (process ID) old priority 0, new priority 19

之前使用的ps命令期望 PID 通过逗号分隔,而renice命令期望 PID 通过空格分隔;幸运的是,pgrep可以同时处理这两种情况。

我们已经成功地改变了进程的优先级:

root@host01:/opt# top -b -n 1 -p $(pgrep -d , stress)
top - 18:11:04 up  1:48,  1 user,  load average: 1.07, 0.95, 0.57
Tasks:   2 total,   1 running,   1 sleeping,   0 stopped,   0 zombie
%Cpu(s): 0.0 us, 0.0 sy, 28.6 ni, 71.4 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :   1987.5 total,   1035.6 free,    182.2 used,    769.7 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.   1657.2 avail Mem 

  PID   USER  PR  NI  ...  %CPU  %MEM     TIME+ COMMAND
  13459 root  39  19  ... 100.0   0.2   9:35.50 stress-ng
  13435 root  39  19  ...   0.0   0.2   0:00.01 stress-ng

新的 nice 值是19,意味着我们的进程比之前的优先级低。然而,stress程序仍然占用了一个 CPU 的 100%!这是怎么回事?问题在于优先级只是一个相对的度量。如果没有其他程序需要 CPU(在这种情况下是这样),即使是低优先级的进程也可以尽可能多地使用 CPU。

这种安排可能看起来是我们想要的。毕竟,如果 CPU 可用,我们是不是希望我们的应用组件能够使用它?不幸的是,尽管这听起来很合理,但由于两个主要原因,它并不适合我们的容器化应用。首先,像 Kubernetes 这样的容器编排环境在容器能够被分配到任何具有足够资源的主机上时效果最佳。我们不可能了解 Kubernetes 集群中每个容器的相对优先级,特别是当我们考虑到一个 Kubernetes 集群可能是多租户的,即多个独立的应用或团队可能在同一个集群中使用时。第二,Kubernetes 如果没有某个容器将使用多少 CPU 的概念,就无法知道哪些主机已经满载,哪些主机还有空余空间。如果多个容器在同一台主机上同时变得繁忙,它们将争夺可用的 CPU 核心,整个主机将变慢,这是我们不希望发生的情况。

Linux 控制组

正如我们在上一节看到的,进程优先级调整不会帮助像 Kubernetes 这样的容器编排环境了解在调度新容器时应该使用哪个主机,因为即使是低优先级进程在 CPU 空闲时也能获得大量的 CPU 时间。而且由于我们的 Kubernetes 集群可能是多租户的,集群不能仅仅依赖每个容器承诺只使用一定量的 CPU。首先,这样可能会导致一个进程负面影响到另一个进程,无论是恶意的还是意外的。其次,进程并不真正控制自己的调度;它们在 Linux 内核决定分配 CPU 时间时才获得 CPU 时间。我们需要一种不同的解决方案来控制 CPU 的使用。

为了找到答案,我们可以采用实时处理所使用的一种方法。正如我们在前一部分提到的,实时进程通常不需要大量计算,但当它需要 CPU 时,它需要立即获取。为了确保所有实时进程都能获得它们需要的 CPU,通常会为每个进程保留一部分 CPU 时间。即使我们的容器进程不是实时的,我们也可以使用相同的策略。如果我们能配置容器,使其只能使用分配的 CPU 时间片,Kubernetes 将能够计算每个主机上可用的空间,并能够将容器调度到有足够空间的主机上。

为了管理容器对 CPU 核心的使用,我们将使用 控制组。控制组(cgroups)是 Linux 内核的一个特性,用于管理进程资源的使用。每种资源类型,如 CPU、内存或块设备,都可以有一个与之关联的 cgroup 层级结构。进程进入 cgroup 后,内核会自动应用该组的控制。

cgroup 的创建和配置是通过一种特定的文件系统处理的,类似于 Linux 通过 /proc 文件系统报告系统信息的方式。默认情况下,cgroup 的文件系统位于 /sys/fs/cgroup

root@host01:~# ls /sys/fs/cgroup
blkio        cpuacct  freezer  net_cls           perf_event  systemd
cpu          cpuset   hugetlb  net_cls,net_prio  pids        unified
cpu,cpuacct  devices  memory   net_prio          rdma

/sys/fs/cgroup 中的每一项都是可以限制的不同资源。如果我们查看其中一个目录,我们可以开始看到可以应用的控制。例如,对于cpu

root@host01:~# cd /sys/fs/cgroup/cpu
root@host01:/sys/fs/cgroup/cpu# ls -F
cgroup.clone_children  cpuacct.stat               cpuacct.usage_user
cgroup.procs           cpuacct.usage              init.scope/
cgroup.sane_behavior   cpuacct.usage_all          notify_on_release
cpu.cfs_period_us      cpuacct.usage_percpu       release_agent
cpu.cfs_quota_us       cpuacct.usage_percpu_sys   system.slice/
cpu.shares             cpuacct.usage_percpu_user  tasks
cpu.stat               cpuacct.usage_sys          user.slice/

ls 命令上的 -F 标志会为目录添加斜杠字符,这使我们可以开始看到层级结构。每个子目录(init.scopesystem.sliceuser.slice)都是一个独立的 CPU cgroup,每个都有一组适用于该 cgroup 中进程的配置文件。

使用 cgroups 的 CPU 配额

为了理解这个目录的内容,让我们看看如何使用 cgroups 来限制 stress 容器的 CPU 使用情况。我们将重新检查它的 CPU 使用情况:

root@host01:/sys/fs/cgroup/cpu# top -b -n 1 -p $(pgrep -d , stress)
top - 22:40:12 up 12 min,  1 user,  load average: 0.81, 0.35, 0.21
Tasks:   2 total,   1 running,   1 sleeping,   0 stopped,   0 zombie
%Cpu(s): 37.0 us, 0.0 sy, 0.0 ni, 63.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :   1987.5 total,   1075.1 free,    179.4 used,    733.0 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.   1646.3 avail Mem 

  PID USER   PR  NI ...  %CPU  %MEM     TIME+ COMMAND
  5964 root  20  19 ...  100.0  0.2   1:19.72 stress-ng
  5932 root  20  19 ...  0.0    0.2   0:00.02 stress-ng

如果你仍然没有看到 stress 正在运行,使用本章前面提到的命令重新启动它。接下来,让我们探索 stress CPU 进程所在的 CPU cgroup。我们可以通过在 /sys/fs/cgroup/cpu 层级中的文件内查找其 PID 来做到这一点:

root@host01:/sys/fs/cgroup/cpu# grep -R $(pgrep stress-ng-cpu)
system.slice/runc-050c.../cgroup.procs:5964
system.slice/runc-050c.../tasks:5964

stress 进程属于 system.slice 层级,并位于由 runc 创建的子目录中,runc 是 CRI-O 的内部组件之一。这非常方便,因为这意味着我们不需要创建自己的 cgroup 并将此进程移入其中。这也不是偶然的;正如我们稍后将看到的,CRI-O 支持对容器设置 CPU 限制,因此它自然需要为每个运行的容器创建一个 cgroup。实际上,cgroup 的名称是以容器 ID 命名的。

让我们进入容器 cgroup 的目录:

root@host01:/sys/fs/cgroup/cpu# cd system.slice/runc-${CUL_ID}.scope

我们使用之前保存的容器 ID 变量进入适当的目录。一旦进入该目录,我们可以看到它具有与/sys/fs/cgroup/cpu根目录相同的配置文件:

root@host01:/sys/fs/...07.scope# ls
cgroup.clone_children  cpu.uclamp.max        cpuacct.usage_percpu_sys
cgroup.procs           cpu.uclamp.min        cpuacct.usage_percpu_user
cpu.cfs_period_us      cpuacct.stat          cpuacct.usage_sys
cpu.cfs_quota_us       cpuacct.usage         cpuacct.usage_user
cpu.shares             cpuacct.usage_all     notify_on_release
cpu.stat               cpuacct.usage_percpu  tasks

cgroup.procs文件列出了这个控制组中的进程:

root@host01:/sys/fs/...07.scope# cat cgroup.procs
5932
5964

这个目录还有许多其他文件,但我们主要关心三个文件:

cpu.shares 这个 cgroup 相对于同级 cgroup 所占的 CPU 份额

cpu.cfs_period_us 一个周期的长度,以微秒为单位

cpu.cfs_quota_us 一个周期内的 CPU 时间,以微秒为单位

我们将查看 Kubernetes 如何在第十四章中使用cpu.shares。现在,我们需要一种方法来控制我们的实例,避免它对系统造成过载。为此,我们将为这个容器设置一个绝对配额。首先,让我们查看cpu.cfs_period_us的值:

root@host01:/sys/fs/...07.scope# cat cpu.cfs_period_us
100000

该周期设置为 100,000 微秒,或者 0.1 秒。我们可以利用这个数字来计算应该设置什么样的配额,以限制stress容器能使用的 CPU 量。目前,没有设置配额:

root@host01:/sys/fs/...07.scope# cat cpu.cfs_quota_us
-1

我们只需更新cpu.cfs_quota_us文件即可设置配额:

root@host01:/sys/fs/...07.scope# echo "50000" > cpu.cfs_quota_us

这为该 cgroup 中的进程提供了每 100,000 微秒 50,000 微秒的 CPU 时间,平均分配为 50%的 CPU。进程会立即受到影响,正如我们可以确认的那样:

root@host01:/sys/fs/...07.scope# top -b -n 1 -p $(pgrep -d , stress)
top - 23:53:05 up  1:24,  1 user,  load average: 0.71, 0.93, 0.98
Tasks:   2 total,   1 running,   1 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.0 us, 3.6 sy, 7.1 ni, 89.3 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :   1987.5 total,   1064.9 free,    174.6 used,    748.0 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.   1663.9 avail Mem 

  PID USER   PR  NI  ...  %CPU  %MEM     TIME+ COMMAND
  5964 root  39  19  ...  50.0   0.2  73:45.68 stress-ng-cpu
  5932 root  39  19  ...   0.0   0.2   0:00.02 stress-ng

你的清单可能不会显示出精确的 50% CPU 使用率,因为top命令测量 CPU 使用情况的周期可能与内核的调度周期不完全对齐。但平均来看,我们的stress容器现在无法使用超过 50%的单个 CPU。

在继续之前,让我们先停止stress容器:

root@host01:/sys/fs/...07.scope# cd
root@host01:/opt# crictl stop $CUL_ID
...
root@host01:/opt# crictl rm $CUL_ID
...
root@host01:/opt# crictl stopp $PUL_ID
Stopped sandbox ...
root@host01:/opt# crictl rmp $PUL_ID
Removed sandbox ...

使用 CRP-O 和 crictl 设置 CPU 配额

如果每次都需要在文件系统中找到 cgroup 位置并更新每个容器的 CPU 配额来控制 CPU 使用,这将是一件繁琐的事情。幸运的是,我们可以在crictl的 YAML 文件中指定配额,CRI-O 会为我们强制执行。让我们看看一个安装在/opt中的例子,当我们设置这个虚拟机时,配置也已安装。

Pod 配置与清单 3-1 只有略微的不同。我们添加了cgroup_parent设置,这样可以控制 CRI-O 创建 cgroup 的位置,这将使我们更容易找到 cgroup 并查看其配置:

po-clim.yaml

---
metadata:
  name: stress-clim
  namespace: crio
linux:
  cgroup_parent: pod.slice
  security_context:
    namespace_options:
      network: 2

容器配置是我们包含 CPU 限制的地方。我们的stress1容器将只分配 10%的 CPU:

co-clim.yaml

---
---
metadata:
  name: stress-clim
image:
  image: docker.io/bookofkubernetes/stress:stable
args:
  - "--cpu"
  - "1"
  - "-v"
linux:
  resources:
    cpu_period: 100000
    cpu_quota: 10000

cpu_period的值对应于文件cpu.cfs_period_us,并提供配额适用的周期长度。cpu_quota的值对应于文件cpu.cfs_quota_us。通过将配额除以周期,我们可以确定这将设置一个 10%的 CPU 限制。现在,让我们启动这个带有 CPU 限制的stress容器:

root@host01:~# cd /opt
root@host01:/opt# PCL_ID=$(crictl runp po-clim.yaml)
root@host01:/opt# CCL_ID=$(crictl create $PCL_ID co-clim.yaml po-clim.yaml)
root@host01:/opt# crictl start $CCL_ID
...
root@host01:/opt# crictl ps
CONTAINER      IMAGE                                    ...
ea8bccd711b86  docker.io/bookofkubernetes/stress:stable ...

我们的容器立即被限制为 10%的 CPU 使用:

root@host01:/opt# top -b -n 1 -p $(pgrep -d , stress)
top - 17:26:55 up 19 min,  1 user,  load average: 0.27, 0.16, 0.13
Tasks:   4 total,   2 running,   2 sleeping,   0 stopped,   0 zombie
%Cpu(s): 10.3 us, 0.0 sy, 0.0 ni, 89.7 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :   1987.5 total,   1053.4 free,    189.3 used,    744.9 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.   1640.4 avail Mem 

  PID USER   PR  NI ... %CPU  %MEM     TIME+ COMMAND
  8349 root  20   0 ... 10.0   0.2   0:22.67 stress-ng
  8202 root  20   0 ...  0.0   0.2   0:00.02 stress-ng

如同我们之前的例子所示,显示的 CPU 使用率是在 top 运行期间的快照,因此可能不会完全匹配限制,但从长远来看,这个进程不会超过其分配的 CPU 使用量。

我们可以检查 cgroup 来确认 CRI-O 是否将其放置在我们指定的位置,并自动配置了 CPU 配额:

root@host01:/opt# cd /sys/fs/cgroup/cpu/pod.slice
root@host01:...pod.slice# cat crio-$CCL_ID.scope/cpu.cfs_quota_us
10000

CRI-O 为我们的容器创建了一个新的 cgroup 父级 pod.slice,在其中为容器创建了一个特定的 cgroup,并配置了它的 CPU 配额,而我们无需动手。

我们不再需要这个容器了,所以让我们把它移除:

root@host01:/sys/fs/cgroupcpu/pod.slice# cd
root@host01:~# crictl stop $CCL_ID
...
root@host01:~# crictl rm $CCL_ID
...
root@host01:~# crictl stopp $PCL_ID
Stopped sandbox ...
root@host01:~# crictl rmp $PCL_ID
Removed sandbox ...

使用这些命令,我们先停止容器,再删除容器,最后删除 Pod。

内存限制

内存是进程的另一个重要资源。如果系统没有足够的内存来满足请求,内存分配将失败。这通常会导致进程出现异常行为或完全失败。当然,大多数 Linux 系统使用 交换空间 将内存内容暂时写入磁盘,这使得系统内存看起来比实际更大,但也会降低系统性能。这个问题足够重要,以至于 Kubernetes 团队不鼓励在集群中启用交换空间。

此外,即使我们能够使用交换空间,我们也不希望某个进程占用所有常驻内存,从而使其他进程变得非常缓慢。因此,我们需要限制进程的内存使用,以便它们能够相互协作。我们还需要为内存使用设置一个明确的最大值,以便 Kubernetes 可以可靠地确保主机在调度新容器到主机之前有足够的可用内存。

Linux 系统与其他 Unix 变种一样,传统上需要处理多个共享稀缺资源的用户。因此,内核支持对系统资源的限制,包括 CPU、内存、子进程数量和打开文件数。我们可以通过命令行使用 ulimit 命令来设置这些限制。例如,一种限制类型是“虚拟内存”限制。它不仅包括进程在常驻内存中使用的 RAM,还包括它使用的任何交换空间。以下是一个限制虚拟内存的 ulimit 命令示例:

root@host01:~# ulimit -v 262144

-v 开关指定了虚拟内存的限制。参数以字节为单位,因此 262144 将对我们从这个 shell 会话启动的每个附加进程设置一个 256MiB 的虚拟内存限制。设置虚拟内存限制是一个总的限制;它可以确保进程不能通过交换空间绕过限制。我们可以通过将一些数据加载到内存中来验证限制是否已应用:

root@host01:~# cat /dev/zero | head -c 500m | tail
tail: memory exhausted

这个命令从 /dev/zero 中读取数据,并尝试将它找到的前 500MiB 零字节保持在内存中。然而,当 tail 命令尝试分配更多空间来存放从 head 获取的零字节时,它因为达到限制而失败。

因此,Unix 限制使我们能够控制进程的内存使用,但由于一些原因,它们无法提供容器所需的所有功能。首先,Unix 限制只能应用于单个进程或整个用户。这两者都不能满足我们的需求,因为容器实际上是一个进程组。容器的初始进程可能会创建许多子进程,并且容器中的所有进程都需要在相同的限制下运行。同时,将限制应用于整个用户并不能真正帮助我们在像 Kubernetes 这样的容器编排环境中,因为从操作系统的角度来看,所有容器都属于同一个用户。其次,关于 CPU 限制,常规的 Unix 限制唯一能做的就是限制进程在被终止之前获得的最大 CPU 时间。这不是我们在共享 CPU 给长时间运行的进程时所需要的限制类型。

我们将不再使用传统的 Unix 限制,而是再次使用 cgroups,这次是为了限制进程可用的内存。我们将使用相同的 stress 容器镜像,这次包含一个尝试分配大量内存的子进程。

如果我们在启动 stress 容器后尝试应用内存限制,我们会发现内核不允许这么做,因为它已经占用了过多内存。因此,我们将立即在 YAML 配置中应用它。和之前一样,我们需要一个 Pod:

po-mlim.yaml

---
metadata:
  name: stress2
  namespace: crio
linux:
  cgroup_parent: pod.slice
  security_context:
    namespace_options:
      network: 2

这与我们用于 CPU 限制的 Pod 相同,但为了避免冲突,名称不同。就像我们之前做的那样,我们要求 CRI-O 将 cgroup 放入pod.slice,这样我们就可以轻松找到它。

我们还需要一个容器定义:

co-mlim.yaml

 ---
 ---
 metadata:
   name: stress2
 image:
   image: docker.io/bookofkubernetes/stress:stable
 args:
   - "--vm"
   - "1"
   - "--vm-bytes"
➊ - "512M"
   - "-v"
 linux:
   resources:
  ➋ memory_limit_in_bytes: 268435456
     cpu_period: 100000 
  ➌ cpu_quota: 10000

新的资源限制是 memory_limit_in_bytes,我们将其设置为 256MiB ➋。我们保持 CPU 配额 ➌,因为持续尝试分配内存将消耗大量 CPU。最后,在 args 部分,我们告诉 stress 尝试分配 512MB 的内存 ➊。

我们可以使用与之前相同的 crictl 命令运行它:

root@host01:~# cd /opt 
root@host01:/opt# PML_ID=$(crictl runp po-mlim.yaml)
root@host01:/opt# CML_ID=$(crictl create $PML_ID co-mlim.yaml po-mlim.yaml)
root@host01:/opt# crictl start $CML_ID
...

如果我们告诉 crictl 列出容器,所有情况看起来都正常:

root@host01:/opt# crictl ps
CONTAINER     IMAGE                                    ... STATE   ...
31025f098a6c9 docker.io/bookofkubernetes/stress:stable ... Running ...

这表明容器处于 Running 状态。然而,在背后,stress 正在努力分配内存。如果我们打印出来自 stress 容器的日志信息,我们就可以看到这一点:

root@host01:/opt# crictl logs $CML_ID
...
stress-ng: info:  [6] dispatching hogs: 1 vm
...
stress-ng: debug: [11] stress-ng-vm: started [11] (instance 0)
stress-ng: debug: [11] stress-ng-vm using method 'all'
stress-ng: debug: [11] stress-ng-vm: child died: signal 9 'SIGKILL' (instance 0)
stress-ng: debug: [11] stress-ng-vm: assuming killed by OOM killer, restarting again...
stress-ng: debug: [11] stress-ng-vm: child died: signal 9 'SIGKILL' (instance 0)
stress-ng: debug: [11] stress-ng-vm: assuming killed by OOM killer, restarting again...

Stress 报告说其内存分配进程正在被 “内存不足” 持续终止。

我们可以看到内核报告显示 oom_reaper 确实是导致进程被终止的原因:

root@host01:/opt# dmesg | grep -i oom_reaper | tail -n 1
[  696.651056] oom_reaper: reaped process 8756 (stress-ng-vm)...

OOM killer 是 Linux 在整个系统内存不足时使用的功能,它需要终止一个或多个进程来保护系统。在这种情况下,它通过发送 SIGKILL 信号终止进程,以确保 cgroup 在其内存限制下。SIGKILL 是一种信号,通知进程立即终止,且不进行任何清理。

为什么使用 OOM Killer?

当我们使用常规限制来控制内存时,超出限制会导致内存分配失败,但内核不会使用 OOM 杀手来终止我们的进程。为什么会有这种差异?答案在于容器的本质。当我们设计使用容器化微服务的可靠系统时,我们会发现,容器应该是快速启动和快速扩展的。这意味着应用中的每个单独容器本质上并不太重要。这也意味着,一个容器可能会被意外终止,通常不会引发太大关注。再加上不检查内存分配错误是最常见的 bug 之一,因此直接终止进程被认为是更安全的做法。

话虽如此,值得注意的是,确实可以为某个 cgroup 关闭 OOM 杀手。然而,与其让内存分配失败,效果是将进程暂停,直到该组中的其他进程释放内存。实际上,这样更糟,因为现在我们有一个既没有被正式终止,又没有执行任何有用操作的进程。

在继续之前,让我们先把这个不断失败的stress容器解脱出来:

root@host01:/opt# crictl stop $CML_ID
...
root@host01:/opt# crictl rm $CML_ID
...
root@host01:/opt# crictl stopp $PML_ID
Stopped sandbox ...
root@host01:/opt# crictl rmp $PML_ID
Removed sandbox ...
root@host01:/opt# cd

停止并移除容器和 Pod 可以防止stress容器浪费 CPU,不断尝试重启内存分配过程。

网络带宽限制

在本章中,我们从易于限制的资源转向了更难限制的资源。我们从 CPU 开始,内核完全负责哪个进程获得 CPU 时间以及在被抢占之前能获得多少时间。接着我们看了内存,内核没有能力强制进程放弃内存,但至少内核可以控制内存分配是否成功,或者它可以终止请求过多内存的进程。

现在我们开始讨论网络带宽,控制网络带宽比控制 CPU 或内存更为困难,原因有两个。首先,网络设备不像 CPU 或内存那样可以“合并”,因此我们需要在每个独立的网络设备层面上进行限制。其次,我们的系统实际上无法控制通过网络发送给它的数据;我们只能完全控制出口带宽,即通过特定网络设备发送的流量。

正确的网络管理

要实现一个完全可靠的集群,仅仅控制出站流量显然是不够的。一个下载大文件的进程将和一个上传大量数据的进程一样占用可用带宽。然而,我们实际上无法控制通过特定网络接口进入我们主机的流量,至少在主机层面上是无法控制的。如果我们真的想要管理网络带宽,我们需要在交换机或路由器上处理这类问题。例如,将物理网络划分为虚拟局域网(VLAN)是很常见的做法。一个 VLAN 可能是用于审计、日志记录以及供管理员登录使用的管理网络。我们还可能为重要的容器流量预留另一个 VLAN,或者使用流量整形确保重要数据包能够通过。只要我们在交换机上执行这种配置,通常可以允许剩余带宽以“最佳努力”方式传输。

虽然 Linux 确实为网络接口提供了一些 cgroup 功能,但这些仅有助于我们优先处理和分类网络流量。因此,与其使用 cgroups 来控制出站流量,我们将直接配置 Linux 内核的流量控制功能。我们将使用iperf3来测试网络性能,应用出站流量限制,然后再次进行测试。在本章的示例中,具有 IP 地址192.168.61.12host02已自动设置并运行iperf3服务器,以便我们可以从host01向其发送数据。

让我们首先查看在没有限制的接口上可以获得的出站带宽:

root@host01:~# iperf3 -c 192.168.61.12
Connecting to host 192.168.61.12, port 5201
[  5] local 192.168.61.11 port 49044 connected to 192.168.61.12 port 5201
...
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  2.18 GBytes  1.87 Gbits/sec  13184             sender
[  5]   0.00-10.00  sec  2.18 GBytes  1.87 Gbits/sec                  receiver
...

这个例子展示了千兆网速。根据你运行示例的方式,你可能会看到更低或更高的数值。现在我们有了基准,我们可以使用tc设置一个出站流量配额。你需要选择一个符合你带宽的配额;最有可能的是,设置 100Mb 的上限将会有效:

root@host01:~# IFACE=$(ip -o addr | grep 192.168.61.11 | awk '{print $2}')
root@host01:~# tc qdisc add dev $IFACE root tbf rate 100mbit \
  burst 256kbit latency 400ms

网络接口的名称在不同的系统上可能不同,因此我们使用ip addr来确定我们要控制的接口。然后,我们使用tc来实际应用限制。命令中的token tbf代表令牌桶过滤器。使用令牌桶过滤器时,每个数据包都会消耗令牌。桶会随着时间的推移不断地重新填充令牌,但如果桶在任何时候为空,数据包会被排队,直到有令牌可用。通过控制桶的大小和桶填充的速率,内核能够轻松地设置带宽限制。

现在我们已经对这个接口应用了限制,让我们通过再次运行完全相同的iperf3命令来查看其效果:

root@host01:~# iperf3 -c 192.168.61.12
Connecting to host 192.168.61.12, port 5201
[  5] local 192.168.61.11 port 49048 connected to 192.168.61.12 port 5201
...
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec   114 MBytes  95.7 Mbits/sec    0             sender
[  5]   0.00-10.01  sec   113 MBytes  94.5 Mbits/sec                  receiver
...

如预期所示,我们现在在这个接口上限速到 100Mbps。

当然,在这种情况下,我们限制了系统上每个人使用的网络接口的带宽。要正确使用这种功能来控制带宽使用,我们需要更精确地设定限制。然而,为了做到这一点,我们需要将一个进程隔离到其自己的一组网络接口中,这将是下一章的主题。

总结思考

确保一个进程不会给系统上的其他进程带来问题,包括确保它公平共享 CPU、内存和网络带宽等系统资源。在本章中,我们看到 Linux 提供了控制组(cgroups)来管理 CPU 和内存限制,以及管理网络接口的流量控制能力。当我们创建一个 Kubernetes 集群并部署容器到其中时,我们将看到 Kubernetes 如何利用这些底层 Linux 内核功能来确保容器被调度到具有足够资源的主机上,并确保这些主机上的容器行为良好。

我们已经介绍了容器运行时提供的一些最重要的进程隔离元素,但还有两种隔离类型我们尚未探讨:网络隔离和存储隔离。在下一章中,我们将看看 Linux 网络命名空间是如何被用来让每个容器看起来拥有自己的一组网络接口,包括独立的 IP 地址和端口。我们还将探讨这些单独容器接口的流量如何在我们的系统中流动,以便容器之间可以互相通信并与网络的其余部分进行通信。

第四章:网络命名空间

image

理解容器网络是构建基于容器化微服务的现代应用程序时面临的最大挑战。首先,即使没有引入容器,网络也是复杂的。仅仅从一台物理服务器发送一个简单的ping到另一台物理服务器,就涉及了多个抽象层。其次,容器引入了额外的复杂性,因为每个容器都有自己的一组虚拟网络设备,使其看起来像一个独立的机器。更重要的是,像 Kubernetes 这样的容器编排框架通过增加一个“覆盖”网络,使得容器即使运行在不同的主机上也能进行通信,从而增加了更多的复杂性。

在本章中,我们将详细了解容器网络是如何工作的。我们将查看容器的虚拟网络设备,包括每个网络设备如何分配一个可以访问主机的独立 IP 地址。我们还将看到,同一主机上的容器如何通过桥接设备连接到彼此,以及容器设备如何配置以路由流量。最后,我们将探讨如何使用地址转换,使容器能够连接到其他主机,而不会暴露容器网络内部结构到主机的网络上。

网络隔离

在第二章中,我们讨论了隔离对于系统可靠性的重要性,因为进程通常不能影响它们看不见的东西。这是容器网络隔离的重要原因之一。另一个原因是配置的简便性。要运行一个作为服务器的进程,比如一个 Web 服务器,我们需要选择一个或多个网络接口来监听该服务器,并且需要选择一个端口号来监听。我们不能让两个进程在同一个接口的相同端口上监听。

因此,作为服务器的进程通常会提供一种配置方式,让我们指定其监听连接的端口。然而,这仍然要求我们了解其他服务器的情况以及它们使用的端口,从而确保没有冲突。这对于像 Kubernetes 这样的容器编排框架来说几乎是不可能的,因为新的进程可以随时出现,来自不同的用户,并且可能需要监听任何端口。

解决这个问题的方法是为每个容器提供独立的虚拟网络接口。这样,容器中的进程可以选择任何它想要的端口——它将监听一个与另一个容器中进程不同的网络接口。我们来看一个简短的例子。

注意

本书的示例仓库位于 github.com/book-of-kubernetes/examples有关设置的详细信息,请参见第 xx 页中的“运行示例”。

我们将运行两个实例的 NGINX Web 服务器;每个实例将在端口 80 上监听。与以前一样,我们将使用 CRI-O 和 crictl,但我们将使用一个脚本来减少输入:

root@host01:~# cd /opt
root@host01:/opt# source nginx.sh
...

nginx.sh 之前的 source 很重要;它确保脚本以一种方式运行,使得它设置的环境变量在我们的 shell 中对未来的命令可用。在 nginx.sh 中是我们在前几章中使用过的常规命令 crictl runpcrictl createcrictl start。YAML 文件也与我们以前看到的示例非常相似;唯一的区别是我们使用了安装有 NGINX 的容器镜像。

让我们验证我们有两个 NGINX 服务器正在运行:

root@host01:/opt# crictl ps
CONTAINER      IMAGE            ... NAME    ...
ae341010886ae  .../nginx:latest ... nginx2  ...
6a95800b16f15  .../nginx:latest ... nginx1  ...

我们还可以验证两个 NGINX 服务器都在监听端口 80,这是 Web 服务器的标准端口:

root@host01:/opt# crictl exec $N1C_ID cat /proc/net/tcp
  sl  local_address ...
   0: 00000000:0050 ...
root@host01:/opt# crictl exec $N2C_ID cat /proc/net/tcp
  sl  local_address ...
   0: 00000000:0050 ...

通过打印 /proc/net/tcp 我们查看开放的端口,因为我们需要在 NGINX 容器内运行这个命令,而我们没有标准的 Linux 命令,如 netstatss。正如我们在 第二章 中看到的,在容器中,我们有一个单独的 mnt 命名空间为每个容器提供单独的文件系统,因此只有在该单独文件系统中可用的可执行文件才能在该命名空间中运行。

在这两种情况下显示的端口是 0050,这是十六进制中的端口 80 在十进制中的表示。如果这两个进程在没有网络隔离的同一系统上运行,它们都无法同时监听端口 80,但在这种情况下,这两个 NGINX 实例有单独的网络接口。为了进一步探索这一点,让我们启动一个新的 BusyBox 容器:

root@host01:/opt# source busybox.sh
...

现在除了我们的两个 NGINX 容器外,BusyBox 也在运行:

root@host01:/opt# crictl ps
CONTAINER      IMAGE              ... NAME    ...
189dd26766d26  .../busybox:latest ... busybox ...
ae341010886ae  .../nginx:latest   ... nginx2  ...
6a95800b16f15  .../nginx:latest   ... nginx1  ...

让我们在容器内部启动一个 shell:

root@host01:/opt# crictl exec -ti $B1C_ID /bin/sh
/ #

列表 4-1 显示了容器的网络设备和地址。

/ # ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue ...
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
        valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
        valid_lft forever preferred_lft forever
3: eth0@if7: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 9a:7c:73:2f:f7:1a brd ff:ff:ff:ff:ff:ff
    inet 10.85.0.4/16 brd 10.85.255.255 scope global eth0
        valid_lft forever preferred_lft forever
    inet6 fe80::987c:73ff:fe2f:f71a/64 scope link 
        valid_lft forever preferred_lft forever

列表 4-1: BusyBox 网络

忽略标准的环回设备,我们看到一个网络设备,其 IP 地址为 10.85.0.4。这与主机的 IP 地址 192.168.61.11 根本不对应;它在完全不同的网络上。由于我们的容器位于单独的网络上,我们可能不希望能够从容器内部 ping 底层主机系统,但这是有效的,正如 列表 4-2 所示。

/ # ping -c 1 192.168.61.11
PING 192.168.61.11 (192.168.61.11): 56 data bytes
64 bytes from 192.168.61.11: seq=0 ttl=64 time=7.471 ms

--- 192.168.61.11 ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 7.471/7.471/7.471 ms

列表 4-2: BusyBox ping 测试

要使流量从我们的容器到主机网络,路由表中必须有一个条目来实现这一点。正如 列表 4-3 所示,我们可以使用 ip 命令验证这一点。

/ # ip route
default via 10.85.0.1 dev eth0 
10.85.0.0/16 dev eth0 scope link  src 10.85.0.4

列表 4-3: BusyBox 路由

预期地,存在一个默认路由。当我们发送 ping 时,我们的 BusyBox 容器连接到 10.85.0.1,然后有能力将 ping 转发直至到达 192.168.61.11

我们将保持这三个容器继续运行以进一步探索它们,但让我们退出 BusyBox shell 返回主机:

/ # exit

从容器内部查看网络可以解释为什么我们的两个 NGINX 服务器都能监听 80 端口。如前所述,只有一个进程能够监听特定接口的端口,但当然,如果每个 NGINX 服务器都有一个单独的网络接口,就不会发生冲突。

网络命名空间

CRI-O 使用 Linux 网络命名空间来创建这种隔离。在第二章中,我们简要地探讨了网络命名空间;在本章中,我们将更详细地讨论它们。

首先,让我们使用lsns命令列出 CRI-O 为我们的容器创建的网络命名空间:

root@host01:/opt# lsns -t net
        NS TYPE NPROCS   PID USER    NETNSID NSFS                   COMMAND
4026531992 net     114     1 root unassigned                        /sbin/init
4026532196 net       4  5801 root          0 /run/netns/ab8be6e6... /pause
4026532272 net       4  5937 root          1 /run/netns/8ffe0394... /pause
4026532334 net       2  6122 root          2 /run/netns/686d71d9... /pause

除了用于所有不在容器中的进程的根网络命名空间外,我们还看到三个网络命名空间,每个命名空间对应一个我们创建的 Pod。

当我们使用 CRI-O 与crictl时,网络命名空间实际上属于 Pod。这里列出的pause进程存在的目的是为了让命名空间在 Pod 内的容器进出时能够持续存在。

在上一个示例中,有四个网络命名空间。第一个是我们主机启动时创建的根命名空间。其他三个是为我们启动的每个容器创建的:两个 NGINX 容器和一个 BusyBox 容器。

检查网络命名空间

为了了解网络命名空间是如何工作的并进行操作,我们将使用ip netns命令列出网络命名空间:

root@host01:/opt# ip netns list
7c185da0-04e2-4321-b2eb-da18ceb5fcf6 (id: 2)
d26ca6c6-d524-4ae2-b9b7-5489c3db92ce (id: 1)
38bbb724-3420-46f0-bb50-9a150a9f0889 (id: 0)

这个命令会在不同的配置位置查找网络命名空间,因此只列出了三个容器命名空间。

我们希望获取我们 BusyBox 容器的网络命名空间。它是三个列出的命名空间之一,我们可以猜测它是标记为(id: 2)的那个,因为我们最后创建了它,但我们也可以使用crictljq来提取我们需要的信息:

root@host01:/opt# NETNS_PATH=$(crictl inspectp $B1P_ID |
  jq -r '.info.runtimeSpec.linux.namespaces[]|select(.type=="network").path')
root@host01:/opt# echo $NETNS_PATH
/var/run/netns/7c185da0-04e2-4321-b2eb-da18ceb5fcf6
root@host01:/opt# NETNS=$(basename $NETNS_PATH)
root@host01:/opt# echo $NETNS
7c185da0-04e2-4321-b2eb-da18ceb5fcf6

如果单独运行crictl inspectp $B1P_ID,你将看到关于 BusyBox Pod 的大量信息。在所有这些信息中,我们只需要关于网络命名空间的信息,因此我们使用jq分三步提取这些信息。首先,它会深入到 JSON 数据中,提取与此 Pod 相关的所有命名空间。然后,它只选择具有type字段为network的命名空间。最后,它提取该命名空间的path字段,并将其存储在环境变量NETNS_PATH中。

crictl返回的值是网络命名空间在/var/run下的完整路径。对于接下来的命令,我们只需要命名空间的值,所以我们使用basename来去掉路径部分。此外,由于如果将这些信息分配给环境变量会更易于使用,我们这么做了,然后使用echo打印出该值,以便我们确认一切正常。

当然,对于交互式调试,您通常可以仅滚动浏览整个crictl inspectp(用于 Pods)和crictl inspect(用于容器)的内容,并选择您想要的值。但是使用jq提取数据的这种方法在脚本编写或减少手动扫描输出量方面非常有用。

现在我们从crictl中提取了 BusyBox 的网络命名空间,让我们看看分配给该命名空间的进程有哪些:

root@host01:/opt# ps --pid $(ip netns pids $NETNS)
PID TTY      STAT   TIME COMMAND
5800 ?        Ss     0:00 /pause
5839 ?        Ss     0:00 /bin/sleep 36000

如果我们只运行ip netns pids $NETNS,我们将得到一个进程 ID(PID)列表,但没有额外的信息。我们将该输出发送到ps --pid,这样我们就可以看到命令的名称。正如预期的那样,我们看到了我们在运行 BusyBox 容器时指定的pause进程和sleep进程。

在上一节中,我们使用crictl exec在容器内运行了一个 Shell,这使我们能够看到该网络命名空间中可用的网络接口。现在我们知道了网络命名空间的 ID,我们可以使用ip netns exec从网络命名空间内单独运行命令。使用ip netns exec非常强大,因为它不仅限于网络命令,还可以是任何进程,比如 Web 服务器。但请注意,这与完全在容器内部运行不同,因为我们没有进入容器的任何其他命名空间(例如用于进程隔离的pid命名空间)。

接下来,让我们在 BusyBox 网络命名空间内尝试ip addr命令:

root@host01:/opt# ip netns exec $NETNS ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue ...
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
        valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
        valid_lft forever preferred_lft forever
3: eth0@if7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue ...
    link/ether 9a:7c:73:2f:f7:1a brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.85.0.4/16 brd 10.85.255.255 scope global eth0
        valid_lft forever preferred_lft forever
    inet6 fe80::987c:73ff:fe2f:f71a/64 scope link 
        valid_lft forever preferred_lft forever

这里看到的网络设备和 IP 地址列表与我们在清单 4-1 内部运行 BusyBox 容器时看到的内容相匹配。 CRI-O 正在创建这些网络设备并将它们放置在网络命名空间中。(当我们查看第八章节关于 Kubernetes 网络时,我们将看到 CRI-O 是如何配置执行容器网络的。)现在,让我们看看如何创建自己的设备和网络命名空间以进行网络隔离。这也将向我们展示在容器网络出现问题时如何进行调试。

创建网络命名空间

我们可以用一个命令创建一个网络命名空间:

root@host01:/opt# ip netns add myns

这个新的命名空间立即出现在列表中:

root@host01:/opt# ip netns list
myns
7c185da0-04e2-4321-b2eb-da18ceb5fcf6 (id: 2)
d26ca6c6-d524-4ae2-b9b7-5489c3db92ce (id: 1)
38bbb724-3420-46f0-bb50-9a150a9f0889 (id: 0)

这个命名空间目前还不是很有用;它有一个回环接口,但没有其他内容:

root@host01:/opt# ip netns exec myns ip addr
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

此外,即使是回环接口也是关闭的,因此无法使用。让我们快速修复它:

root@host01:/opt# ip netns exec myns ip link set dev lo up
root@host01:/opt# ip netns exec myns ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue ...
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever

回环接口现在已经启动,并且具有127.0.0.1的典型 IP 地址。现在,在这个网络命名空间中基本的回环ping将会起作用:

root@host01:/opt# ip netns exec myns ping -c 1 127.0.0.1
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.035 ms

--- 127.0.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.035/0.035/0.035/0.000 ms

ping回环网络接口的能力是任何网络堆栈的有用初步测试,因为它显示了发送和接收数据包的能力。因此,我们现在在新的网络命名空间中拥有一个基本的工作网络堆栈,但它仍然不是特别有用,因为回环接口本身无法与系统上的其他任何东西通信。我们需要在此网络命名空间中添加另一个网络设备,以便与主机和其他网络建立连接。

为此,我们将创建一个虚拟以太网(veth)设备。你可以将 veth 视为一根虚拟网络电缆。像网络电缆一样,它有两个端口,任何从一个端口进入的东西都会从另一个端口出来。因此,通常使用术语veth 对

我们从一个创建 veth 对的命令开始:

root@host01:/opt# ip link add myveth-host type veth \
                  peer myveth-myns netns myns

该命令做了三件事:

  1. 创建一个名为myveth-host的 veth 设备

  2. 创建一个名为myveth-myns的 veth 设备

  3. 将设备myveth-myns放置到网络命名空间myns

veth 对的主机端出现在主机的常规网络设备列表中:

root@host01:/opt# ip addr
...
8: myveth-host@if2: <BROADCAST,MULTICAST> mtu 1500 ... state DOWN ...
    link/ether fe:7a:5d:86:00:d9 brd ff:ff:ff:ff:ff:ff link-netns myns

该输出显示了myveth-host,并且它连接到了网络命名空间myns中的设备。

如果你自己运行此命令并查看主机网络设备的完整列表,你会注意到每个容器网络命名空间都有额外的veth设备。这些设备是 CRI-O 在我们部署 NGINX 和 BusyBox 时创建的。

同样,我们可以看到我们的myns网络命名空间有了一个新的网络接口:

root@host01:/opt# ip netns exec myns ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue ...
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: myveth-myns@if8: <BROADCAST,MULTICAST> mtu 1500 ... state DOWN ...
    link/ether 26:0f:64:a8:37:1f brd ff:ff:ff:ff:ff:ff link-netnsid 0

如之前所述,这个接口当前是关闭的。我们需要启动 veth 对的两端,才能开始通信。我们还需要为myveth-myns端分配一个 IP 地址,以使其能够通信:

root@host01:/opt# ip netns exec myns ip addr add 10.85.0.254/16 \
                  dev myveth-myns
root@host01:/opt# ip netns exec myns ip link set dev myveth-myns up
root@host01:/opt# ip link set dev myveth-host up

一个快速检查确认我们已经成功配置了 IP 地址并启动了网络:

root@host01:/opt# ip netns exec myns ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue ...
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: myveth-myns@if8: <BROADCAST,MULTICAST,UP,LOWER_UP> ... state UP ...
    link/ether 26:0f:64:a8:37:1f brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.85.0.254/16 scope global myveth-myns
       valid_lft forever preferred_lft forever
    inet6 fe80::240f:64ff:fea8:371f/64 scope link 
       valid_lft forever preferred_lft forever

除了回环接口,我们现在还看到一个具有 IP 地址10.85.0.254的附加接口。如果我们尝试ping这个新的 IP 地址,会发生什么呢?事实证明,我们确实可以ping它,但只能在网络命名空间内部进行:

   root@host01:/opt# ip netns exec myns ping -c 1 10.85.0.254
   PING 10.85.0.254 (10.85.0.254) 56(84) bytes of data.
   64 bytes from 10.85.0.254: icmp_seq=1 ttl=64 time=0.030 ms

   --- 10.85.0.254 ping statistics ---
➊ 1 packets transmitted, 1 received, 0% packet loss, time 0ms
   rtt min/avg/max/mdev = 0.030/0.030/0.030/0.000 ms
   root@host01:/opt# ping -c 1 10.85.0.254
   PING 10.85.0.254 (10.85.0.254) 56(84) bytes of data.
   From 10.85.0.1 icmp_seq=1 Destination Host Unreachable

   --- 10.85.0.254 ping statistics ---
➋ 1 packets transmitted, 0 received, +1 errors, 100% packet loss, time 0ms

第一个ping命令通过ip netns exec运行,以便在网络命名空间内运行,显示了成功的响应➊。然而,第二个ping命令没有通过ip netns exec运行,显示没有接收到数据包➋。问题在于,我们已经成功创建了一个网络命名空间中的网络接口,并且 veth 对的另一端在主机网络上,但我们没有在主机上连接相应的网络设备,因此没有主机网络接口可以与网络命名空间中的接口通信。

与此同时,当我们从 BusyBox 容器中运行ping测试时,在清单 4-2 中,我们能够顺利地ping主机。显然,CRI-O 在创建容器时为我们进行了更多配置。让我们在下一节中探讨这一点。

桥接接口

veth 对的主机端目前没有连接到任何设备,因此我们还不能与外界进行通信也就不足为奇。为了修复这个问题,让我们来看一下 CRI-O 创建的其中一个 veth 对:

root@host01:/opt# ip addr
...
7: veth062abfa6@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> ... master cni0 ...
    link/ether fe:6b:21:9b:d0:d2 brd ff:ff:ff:ff:ff:ff link-netns ...
    inet6 fe80::fc6b:21ff:fe9b:d0d2/64 scope link 
       valid_lft forever preferred_lft forever
...

与我们创建的接口不同,这个接口指定了 master cni0,表明它属于一个 网络桥接器。网络桥接器用于将多个接口连接在一起。你可以将它视为一个以太网交换机,因为它根据接口的媒体访问控制(MAC)地址来路由流量。

我们可以在主机的网络设备列表中看到桥接器 cni0

root@host01:/opt# ip addr
...
4: cni0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue ...
    link/ether 8e:0c:1c:7d:94:75 brd ff:ff:ff:ff:ff:ff
    inet 10.85.0.1/16 brd 10.85.255.255 scope global cni0
       valid_lft forever preferred_lft forever
    inet6 fe80::8c0c:1cff:fe7d:9475/64 scope link 
 valid_lft forever preferred_lft forever
...

这个桥接器比典型的以太网交换机更智能,它提供了一些防火墙和路由功能。它的 IP 地址也是 10.85.0.1。这个 IP 地址与我们在 Listing 4-3 中看到的 BusyBox 容器默认路由相同,因此我们已经开始解开 BusyBox 容器能够与其网络外部主机通信的谜团。

向桥接器添加接口

要检查桥接器并向其添加设备,我们将使用 brctl 命令。首先,让我们检查桥接器:

root@host01:/opt# brctl show
bridge name     bridge id               STP enabled     interfaces
cni0            8000.8e0c1c7d9475       no              veth062abfa6
                                                        veth43ab68cd
                                                        vetha251c619

桥接器 cni0 上有三个接口,分别对应我们运行的三个容器的 veth 对的主机端(两个 NGINX 和一个 BusyBox)。我们可以利用这个现有的桥接器来为我们创建的网络命名空间设置网络连接:

root@host01:/opt# brctl addif cni0 myveth-host
root@host01:/opt# brctl show
bridge name     bridge id               STP enabled     interfaces
cni0            8000.8e0c1c7d9475       no              myveth-host
                                                        veth062abfa6
                                                        veth43ab68cd
                                                        vetha251c619

现在我们 veth 对的主机端已连接到桥接器,这意味着我们现在可以从主机使用 ping 命令测试与命名空间的连接:

   root@host01:/opt# ping -c 1 10.85.0.254
   PING 10.85.0.254 (10.85.0.254) 56(84) bytes of data.
   64 bytes from 10.85.0.254: icmp_seq=1 ttl=64 time=0.194 ms

   --- 10.85.0.254 ping statistics ---
➊ 1 packets transmitted, 1 received, 0% packet loss, time 0ms
   rtt min/avg/max/mdev = 0.194/0.194/0.194/0.000 ms

数据包被接收 ➊ 的事实表明我们已经建立了一个有效的连接。我们应该为它的成功感到高兴,但如果我们真的想理解这一点,我们不能仅仅满足于说“我们可以从主机 ping 这个接口”。我们需要更具体地了解流量是如何流动的。

跟踪流量

让我们实际跟踪一下这个流量,看看在我们运行 ping 命令时发生了什么。我们将使用 tcpdump 打印流量。首先,让我们在后台启动一个 ping 命令,以便产生一些流量来跟踪:

root@host01:/opt# ping 10.85.0.254 >/dev/null 2>&1 &
...

我们将输出发送到 /dev/null,以免它干扰到我们的会话。现在,让我们使用 tcpdump 来查看流量:

root@host01:/opt# timeout 1s tcpdump -i any -n icmp
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on any, link-type LINUX_SLL (Linux cooked v1), ...
17:37:33.204863 IP 10.85.0.1 > 10.85.0.254: ICMP echo request, ...
17:37:33.204894 IP 10.85.0.1 > 10.85.0.254: ICMP echo request, ...
17:37:33.204936 IP 10.85.0.254 > 10.85.0.1: ICMP echo reply, ...
17:37:33.204936 IP 10.85.0.254 > 10.85.0.1: ICMP echo reply, ...

4 packets captured
4 packets received by filter
0 packets dropped by kernel
root@host01:/opt# killall ping

我们使用 timeout 来防止 tcpdump 无限运行,之后我们还会使用 killall 来停止 ping 命令并终止其在后台的运行。

输出显示 ping 来自桥接接口,该接口的 IP 地址是 10.85.0.1。这是因为主机的路由表设置:

root@host01:/opt# ip route
...
10.85.0.0/16 dev cni0 proto kernel scope link src 10.85.0.1 
192.168.61.0/24 dev enp0s8 proto kernel scope link src 192.168.61.11

当 CRI-O 创建了桥接器并配置了其 IP 地址时,它还设置了一条路由,确保所有目标为 10.85.0.0/16 网络的流量(即从 10.85.0.010.85.255.255 的所有流量)都会通过 cni0。这足以让 ping 命令知道如何发送数据包,桥接器处理剩下的工作。

事实上,ping来自10.85.0.1网桥接口而不是192.168.61.11主机接口,实际上有很大的区别,我们可以通过尝试从命名空间向主机网络运行ping来看到这一点。让我们尝试从命名空间内部向主机网络进行ping

root@host01:/opt# ip netns exec myns ping -c 1 192.168.61.11
ping: connect: Network is unreachable

这里的问题是我们的网络命名空间中的接口不知道如何到达主机网络。网桥是可用的,并愿意将流量路由到主机网络,但我们尚未配置必要的路由来使用它。让我们现在来做这个:

root@host01:/opt# ip netns exec myns ip route add default via 10.85.0.1

现在ping命令有效了:

root@host01:/opt# ip netns exec myns ping -c 1 192.168.61.11
PING 192.168.61.11 (192.168.61.11) 56(84) bytes of data.
64 bytes from 192.168.61.11: icmp_seq=1 ttl=64 time=0.097 ms

--- 192.168.61.11 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.097/0.097/0.097/0.000 ms

这说明了在调试网络问题时需要记住的一个重要规则:很容易就会对网络流量的实际发送和接收情况下结论。往往需要使用跟踪工具来查看实际的流量情况,没有什么可以替代这一点。

主机上的 IP 地址

此方法并不是唯一能实现从主机到网络命名空间的连接性的方法。我们还可以直接为 veth 对的主机端分配 IP 地址。然而,即使这样做可以使主机能够与我们的网络命名空间通信,但它不会提供多个网络命名空间之间进行通信的方法。使用桥接接口,正如 CRI-O 所做的那样,能够在主机上互连所有容器,使它们看起来都在同一个网络上。

这也解释了为什么我们没有给 veth 对的主机端分配 IP 地址。在使用网桥时,只有网桥接口会获得 IP 地址。添加到网桥的接口不会获得 IP 地址。

在进行最后一次更改后,看起来我们已经匹配了我们的容器的网络配置,但我们仍然缺少与host01之外的更广泛网络通信的能力。我们可以通过尝试从我们的网络命名空间向host02进行ping来演示这一点,host02位于与host01相同的内部网络上,并具有 IP 地址192.168.61.12。如果我们尝试从我们的 BusyBox 容器进行ping,它会成功:

root@host01:/opt# crictl exec $B1C_ID ping -c 1 192.168.61.12
PING 192.168.61.12 (192.168.61.12): 56 data bytes
64 bytes from 192.168.61.12: seq=0 ttl=63 time=0.816 ms

--- 192.168.61.12 ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 0.816/0.816/0.816 ms

ping输出报告收到一个数据包。但是,如果我们尝试使用我们创建的网络命名空间执行相同的命令,它却不起作用:

root@host01:/opt# ip netns exec myns ping -c 1 192.168.61.12
PING 192.168.61.12 (192.168.61.12) 56(84) bytes of data.

--- 192.168.61.12 ping statistics ---
1 packets transmitted, 0 received, 100% packet loss, time 0ms

此命令报告未接收到任何数据包。

实际上,我们应该感到惊讶的是,我们的 BusyBox 容器的ping命令确实有效了。毕竟,host02并不知道任何关于 BusyBox 容器、cni0网桥接口或容器所在的10.85.0.0/16网络的信息。host02如何可能与我们的 BusyBox 容器进行ping通信?要理解这一点,我们需要看看网络伪装。

伪装

伪装,也称为网络地址转换(NAT),在网络中每天都会使用。例如,大多数家庭连接到互联网时,只有一个可以从互联网访问的 IP 地址,但家庭网络内的许多设备也需要连接互联网。路由器的工作就是让所有来自该网络的流量看起来都是从单一 IP 地址发出的。它通过重写出站流量的IP 地址,并跟踪所有出站连接,以便它可以重写任何回复的目标IP 地址来实现这一点。

注意

我们在这里讨论的 NAT 类型在技术上被称为源 NAT(SNAT)。不过不要过于纠结于这个名称;为了让它正常工作,任何回复数据包必须将其目标地址重新写入。这里的“源”一词意味着,当发起新连接时,源地址是被重写的。

伪装听起来正是我们需要的,用于将运行在10.85.0.0/16网络中的容器连接到主机网络192.168.61.0/24,实际上它确实是这样工作的。当我们从 BusyBox 容器发送 ping 时,源 IP 地址被重写,使得 ping 看起来是来自host01的 IP 192.168.61.11。当host02回应时,它将回复发送到192.168.61.11,但是目标地址被重写,最终实际上是发送到了 BusyBox 容器。

让我们追踪一下ping流量,直到整个过程完成,以便演示:

root@host01:/opt# crictl exec $B1C_ID ping 192.168.61.12 >/dev/null 2>&1 &
[1] 6335
root@host01:/opt# timeout 1s tcpdump -i any -n icmp
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on any, link-type LINUX_SLL (Linux cooked v1)...
18:53:44.310789 IP 10.85.0.4 ➊ > 192.168.61.12: ICMP echo request, id 12, seq 17...
18:53:44.310789 IP 10.85.0.4 > 192.168.61.12: ICMP echo request, id 12, seq 17...
18:53:44.310876 IP 192.168.61.11 ➋ > 192.168.61.12: ICMP echo request, id 12, seq 17...
18:53:44.311619 IP 192.168.61.12 > 192.168.61.11: ICMP echo reply, ➌ id 12, seq 17...
18:53:44.311648 IP 192.168.61.12 > 10.85.0.4: ➍ ICMP echo reply, id 12, seq 17...
18:53:44.311656 IP 192.168.61.12 > 10.85.0.4: ICMP echo reply, id 12, seq 17...

6 packets captured
6 packets received by filter
0 packets dropped by kernel
root@host01:/opt# killall ping

ping从我们的 BusyBox 容器中发起时,它的源 IP 地址是10.85.0.4 ➊。这个地址被重写,使得ping看起来是来自主机 IP 192.168.61.11 ➋。当然,host02知道如何响应来自该地址的ping,所以ping得到了回复 ➌。此时,伪装的另一部分开始生效,目标地址被重写为10.85.0.4 ➍。最终,BusyBox 容器能够向一个独立主机发送数据包并接收到回复。

为了完成我们网络命名空间的设置,我们需要一个类似的规则来伪装来自10.85.0.254的流量。我们可以从使用iptables查看 CRI-O 创建的规则开始,看看它在配置容器时做了什么:

root@host01:/opt# iptables -t nat -n -L
...
Chain POSTROUTING (policy ACCEPT)
target                        prot opt source    destination ...
CNI-f82910b3a7e28baf6aedc0d3  all  --  10.85.0.2 anywhere    ...
CNI-7f8aa3d8a4f621b186149f43  all  --  10.85.0.3 anywhere    ...
CNI-48ad69d30fe932fda9ea71d2  all  --  10.85.0.4 anywhere    ...

Chain CNI-48ad69d30fe932fda9ea71d2 (1 references)
target     prot opt source               destination         
ACCEPT     all  --  anywhere             10.85.0.0/16 ...
MASQUERADE all  --  anywhere             !224.0.0.0/4 ...
...

伪装从连接发起时开始;在这种情况下,当流量的源地址位于10.85.0.0/16网络时就会开始。为此,使用了POSTROUTING链,因为它会处理所有出站流量。POSTROUTING链中有一条针对每个容器的规则;每条规则都会调用该容器的CNI链。

为了简洁起见,只展示了三个CNI链中的一个。其他两个是相同的。CNI链首先会接受所有本地容器网络的流量,因此这些流量不会被伪装。然后,它为所有流量设置伪装(除了224.0.0.0/4,这是无法伪装的多播流量,因为无法正确路由回复)。

这个配置中缺少的是来自10.85.0.254的流量的匹配设置,10.85.0.254是我们在网络命名空间中分配给接口的 IP 地址。让我们添加这个设置。首先,在nat表中创建一个新的链:

root@host01:/opt# iptables -t nat -N chain-myns

接下来,添加一个规则来接受本地网络的所有流量:

root@host01:/opt# iptables -t nat -A chain-myns -d 10.85.0.0/16 -j ACCEPT

现在,所有剩余的流量(除了组播)应该都被伪装:

root@host01:/opt# iptables -t nat -A chain-myns \
                  ! -d 224.0.0.0/4 -j MASQUERADE

最后,告诉iptables对于来自10.85.0.254的任何流量使用这个链:

root@host01:/opt# iptables -t nat -A POSTROUTING -s 10.85.0.254 -j chain-myns

我们可以通过重新列出规则来验证我们是否正确完成了所有操作:

root@host01:/opt# iptables -t nat -n -L
...
Chain POSTROUTING (policy ACCEPT)
target      prot opt source               destination
chain-myns  all  --  10.85.0.254          anywhere            
...
Chain chain-myns (1 references)
target      prot opt source               destination         
ACCEPT      all  --  anywhere             10.85.0.0/16        
MASQUERADE  all  --  anywhere             !224.0.0.0/4

看起来我们已经得到了所需的配置,因为这个配置与我们为 BusyBox 容器配置虚拟网络设备的方式相匹配。为了确认,让我们再次尝试对host02进行ping

root@host01:/opt# ip netns exec myns ping -c 1 192.168.61.12
PING 192.168.61.12 (192.168.61.12) 56(84) bytes of data.
64 bytes from 192.168.61.12: icmp_seq=1 ttl=63 time=0.843 ms

--- 192.168.61.12 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.843/0.843/0.843/0.000 ms

成功!我们已经完全复制了 CRI-O 为我们的容器提供的网络隔离和连接性。

最后的想法

容器网络在运行容器时看起来 deceptively 简单。每个容器都被提供一组自己的网络设备,避免了端口冲突的问题,也减少了一个容器对另一个容器的影响。然而,正如我们在本章中所看到的,这种“简单”的网络隔离需要一些复杂的配置,不仅仅是隔离,还需要实现容器之间以及容器与其他网络之间的连接性。在第二部分中,当我们正确引入 Kubernetes 后,我们将回到容器网络,并展示当我们需要连接在不同主机上运行的容器并在多个容器实例之间负载均衡流量时,复杂性如何增加。

目前,在我们进入 Kubernetes 之前,还有一个关键主题需要处理,就是容器存储的工作原理。我们需要理解容器存储是如何工作的,包括当启动一个新的容器时,作为基础文件系统使用的容器镜像,以及正在运行的容器使用的临时存储。在下一章,我们将探讨容器存储是如何简化应用部署的,以及如何通过使用分层文件系统来节省存储空间并提高效率。

第五章:容器镜像和运行时层

image

要运行一个进程,我们需要存储空间。容器化软件的一个显著优势之一是能够将应用程序与其依赖项捆绑在一起进行交付。因此,我们需要存储程序的可执行文件以及其使用的任何共享库。我们还需要存储配置文件、日志和程序管理的任何数据。所有这些存储都必须隔离,以防容器干扰主机系统或其他容器。总的来说,这对存储需求很大,这意味着容器引擎必须提供一些独特的功能,以有效利用磁盘空间和带宽。在本章中,我们将探讨分层文件系统如何使容器镜像下载高效,并使容器启动高效。

文件系统隔离

在 第二章 中,我们看到如何使用 chroot 环境创建文件系统的一个独立隔离部分,该部分只包含我们运行进程所需的二进制文件和库。即使是运行简单的 ls 命令,我们也需要这个二进制文件和几个库。一个更完整功能的容器,比如运行 NGINX web 服务器的容器,需要更多——一个完整的 Linux 发行版的文件集。

在 chroot 示例中,当我们准备好使用它时,我们从主机系统构建了隔离的文件系统。对于容器而言,这种方法是不实际的。相反,隔离的文件系统被打包在一个 容器镜像 中,这是一个包含所有文件和元数据(如环境变量和默认可执行文件)的即用包。

容器镜像内容

让我们快速查看一个 NGINX 容器镜像的内部。在本章中,我们将使用 Docker 运行命令,因为它仍然是构建容器镜像的最常用工具。

注意

本书示例的示例存储库位于 github.com/book-of-kubernetes/examples请参阅 第 xx 页 上的“运行示例”获取设置详细信息。

在本章的示例中,从 host01 运行以下命令下载镜像:

root@host01:~# docker pull nginx
Using default tag: latest
latest: Pulling from library/nginx
...
Status: Downloaded newer image for nginx:latest
docker.io/library/nginx:latest

docker pull 命令会从 镜像仓库 下载一个镜像。镜像仓库是一个实现下载和发布容器镜像 API 的 Web 服务器。我们可以通过 docker images 命令列出已下载的镜像:

root@host01:~# docker images
REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
nginx        latest    f0b8a9a54136   7 days ago    133MB

这个镜像大小为 133MB,并具有唯一标识符 f0b8a9a54136。(你的标识符将不同,因为每天都会构建新的 NGINX 容器镜像。)该镜像不仅包含 NGINX 可执行文件和所需的库,还包括基于 Debian 的 Linux 发行版。我们在 第一章 中简要看到了这一点,当时我们在 Ubuntu 主机和内核上演示了 Rocky Linux 容器,但让我们稍微详细地看一下。首先,运行一个 NGINX 容器:

root@host01:~# docker run --name nginx -d nginx
516d13e912a55cfc6f73f0dd473661d6b7d3b868d5a07a2bc7253971015b6799

--name 标志为容器指定了一个友好的名称,未来我们可以在命令中使用这个名称,而 -d 标志则将容器发送到后台运行。

现在,让我们探索一下运行中容器的文件系统:

root@host01:~# docker exec -ti nginx /bin/bash
root@516d13e912a5:/#

在这里,我们可以看到 NGINX 工作所需的各种库:

root@516d13e912a5:/# ldd $(which nginx)
        linux-vdso.so.1 (0x00007ffe2a1fa000)
...
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fe0d6531000)
        /lib64/ld-linux-x86-64.so.2 (0x00007fe0d6ed4000)

所有这些库都是我们下载的容器镜像的一部分,因此我们的 NGINX 容器不需要(也无法访问)主机系统中的任何文件。

我们不仅拥有了大量必要的库,而且在 /etc 目录下还有典型的配置文件,这是我们期望在 Debian 系统中找到的:

root@516d13e912a5:/# ls -1 /etc
...
debian_version
deluser.conf
dpkg
...
systemd/
...

这个列表显示了文件系统中甚至包含了一些对容器来说并不真正需要的目录,比如 /etc/systemd 目录。(记住,容器只是以隔离方式运行的一组相关进程,所以容器几乎从不运行像 systemd 这样的系统服务管理器。)这个完整的文件系统被包含进来有几个原因。首先,许多进程被设计成预期通常的文件集合会存在。其次,从一个典型的 Linux 发行版开始构建容器镜像要更容易一些。

我们容器的独立文件系统也是可写的。既然我们已经打开了这个 shell,让我们向容器中的一个文件发送一些随机数据,以便稍后从主机上检查这个存储。然后我们可以退出 shell:

root@516d13e912a5:/# dd if=/dev/urandom of=/tmp/data bs=1M count=10
...
10485760 bytes (10 MB, 10 MiB) copied, 0.0913977 s, 115 MB/s
root@516d13e912a5:/# exit

dd 命令将一个 10MB 的文件写入了 /tmp 目录。尽管我们退出了 shell,容器仍然在运行,因此我们可以使用 docker inspect 查看该容器使用的磁盘空间:

root@host01:~# docker inspect -s nginx | jq '.[0].SizeRw'
10487109

-s 标志告诉 docker inspect 输出容器的大小。由于 docker inspect 生成了庞大的 JSON 输出,我们使用 JSON 查询工具 jq 来选择我们需要的字段。

报告的大小大约是 10MB,表明容器只消耗了写入的文件所需的读写存储空间,加上 NGINX 写入的任何文件。我们将在本章的后续部分更详细地探讨这一点。

镜像版本与层

快速下载一个预先打包好的文件系统以运行一个进程,仅仅是容器镜像的一个优点。另一个优点是能够标记镜像的不同版本,以便于快速升级。让我们通过拉取并运行两个不同版本的 Redis,这个流行的内存键值数据库,来探索一下这一点:

   root@host01:~# docker pull redis:6.0.13-alpine
   6.0.13-alpine: Pulling from library/redis
➊ 540db60ca938: Pull complete 
   29712d301e8c: Pull complete 
   8173c12df40f: Pull complete 
   ...
   docker.io/library/redis:6.0.13-alpine
   root@host01:~# docker pull redis:6.2.3-alpine
   6.2.3-alpine: Pulling from library/redis
➋ 540db60ca938: Already exists 
   29712d301e8c: Already exists 
   8173c12df40f: Already exists 
   ...
   docker.io/library/redis:6.2.3-alpine

冒号后的数据是镜像标签,作为版本标识符。之前,当我们省略这个标签时,Docker 默认为latest,它是一个像其他标签一样的标签,但根据约定,用于指代最新发布的镜像。通过指定版本,我们可以确保即使发布了更新的 Redis 版本,我们仍然会继续运行相同的版本,直到我们准备好升级为止。标签可以包含任何字符,通常在连字符后添加额外的信息。在这个例子中,标签末尾的 -alpine 表示这个镜像基于 Alpine Linux,这是一个轻量级的 Linux 发行版,因为其小巧的体积,它在制作容器镜像时非常流行。

另一个值得注意的有趣事项是,当我们下载 Redis 的第二个版本时,其中一些内容 ➋ 被标记为 已存在。查看第一个 Redis 下载,我们可以看到相同的唯一标识符也出现在那里 ➊。这是因为一个容器镜像是由多个层组成的,这些标识符唯一地描述了一个层。如果我们已经下载的层被另一个镜像使用,我们就不需要再次下载它,从而节省了下载时间。此外,每个层只需要在磁盘上存储一次,从而节省了磁盘空间。

我们现在已经下载了两个不同版本的 Redis:

root@host01:~# docker images | grep redis
redis        6.0.13-alpine   a556c77d3dce   2 weeks ago   31.3MB
redis        6.2.3-alpine    efb4fa30f1cf   2 weeks ago   32.3MB

虽然 Docker 报告每个镜像的大小大约为 30MB,但这是所有层的总大小,并未考虑共享层所带来的存储节省。实际存储在磁盘上的空间更少,正如我们通过检查 Docker 使用磁盘空间的情况所看到的:

root@host01:~# docker system df -v
Images space usage:

REPOSITORY TAG           ... SIZE      SHARED SIZE   UNIQUE SIZE ...
redis      6.0.13-alpine ... 31.33MB   6.905MB       24.42MB     ...
redis      6.2.3-alpine  ... 32.31MB   6.905MB       25.4MB      ...

这两个 Redis 镜像共享了将近 7MB 的基础层。

这两个版本的 Redis 可以分别运行:

root@host01:~# docker run -d --name redis1 redis:6.0.13-alpine
66dbf56ec0e8db24ca78afc07c68b7d0699d68b4749e0c03310857cfce926366
root@host01:~# docker run -d --name redis2 redis:6.2.3-alpine
9dd3f86a1284171e5ca60f7f8a6a13dc517237826a92b3cb256f5ac64a5f5c31

现在两个镜像都在运行,我们可以确认我们的容器中有我们想要的确切版本的 Redis,与最新发布的版本无关,也不受主机服务器上可用版本的影响:

root@host01:~# docker logs redis1 | grep version
1:C 21 May 2021 14:18:24.952 # Redis version=6.0.13, ...
root@host01:~# docker logs redis2 | grep version
1:C 21 May 2021 14:18:36.387 # Redis version=6.2.3, ...

这对于构建可靠系统来说是一个很大的优势。我们可以使用一个版本的软件彻底测试我们的应用程序,并确保在我们选择升级之前,该版本会继续使用。我们还可以轻松地将软件与新版本进行测试,而不需要升级主机系统。

构建容器镜像

在前面的例子中,我们看到通过共享层来减少容器镜像的下载和磁盘需求。这种层共享可以与任何容器镜像一起使用,而不仅仅是同一软件的两个不同版本。

容器镜像中的层来自于它的构建方式。容器镜像的构建从一个基础镜像开始。例如,我们的两个 Redis 版本都是从相同的 Alpine Linux 基础镜像开始的,这也是为什么这些层在该镜像中得以共享的原因。从基础镜像开始,构建过程中的每个步骤都会产生一个新的层。这个新层仅包含来自该构建步骤的文件系统变化。

基础镜像也必须来自某个地方,最终必须有一个初始层,这通常是从某个 Linux 发行版创建的最小 Linux 文件系统,转移到一个空的容器镜像中,然后扩展成为初始层。

使用 Dockerfile

构建容器镜像有许多不同的方法,但最流行的方法是创建一个名为 DockerfileContainerfile 的文件,指定镜像的命令和配置。以下是一个简单的 Dockerfile,它将 web 内容添加到 NGINX 镜像中:

Dockerfile

---
FROM nginx

# Add index.html
RUN echo "<html><body><h1>Hello World!</h1></body></html>" \
    >/usr/share/nginx/html/index.html

每一行 Dockerfile 中都以一个命令开始,后面跟着参数。空行和 # 后的内容会被忽略,行末的反斜杠表示该命令会延续到下一行。命令有很多种,以下是最常见的几种:

FROM 指定此构建的基础镜像。

RUN 在容器内运行命令。

COPY 将文件复制到容器中。

ENV 指定一个环境变量。

ENTRYPOINT 配置容器的初始进程。

CMD 设置初始进程的默认参数。

Docker 提供了 docker build 命令来从 Dockerfile 构建镜像。docker build 命令通过逐一运行 Dockerfile 中的每个命令来创建一个新镜像。列表 5-1 说明了如何运行 docker build

   root@host01:~# cd /opt/hello
   root@host01:/opt/hello# docker build -t hello .
➊ Sending build context to Docker daemon  2.048kB
   Step 1/2 : FROM nginx
 ➋ ---> f0b8a9a54136
   Step 2/2 : RUN echo "<html><body><h1>Hello World!</h1></body></html>" ...
 ➌ ---> Running in 77ba9163d0a5
   Removing intermediate container 77ba9163d0a5
    ---> e9ca31d590f9
   Successfully built e9ca31d590f9
➍ Successfully tagged hello:latest

列表 5-1: Docker 构建

-t 开关告诉 docker build 将构建过程中的镜像存储为名称为 hello 的镜像。

审查构建过程中的各个步骤将有助于澄清容器镜像是如何创建的。首先,Docker 将构建上下文发送到 Docker 守护进程 ➊。构建上下文是一个目录及其所有文件和子目录。在这种情况下,当我们在 docker build 命令末尾添加 . 时,我们指定了当前目录作为构建上下文。实际的容器镜像构建是在守护进程内进行的,因此只有构建上下文中的文件才能用于 COPY 命令。

其次,Docker 确定了我们的基础镜像,在本例中是 nginx。它显示的唯一标识符 ➋ 与我们运行 docker images 时之前显示的 NGINX 镜像相同。第三,Docker 执行了我们在 RUN 步骤中指定的命令。该命令实际上是在基于我们的 NGINX 基础镜像 ➌ 创建的容器内运行的,这意味着只有容器镜像中安装的命令才能运行。如果我们需要其他命令可用,可能需要创建一个 RUN 步骤来安装它们,才能使用。

所有构建步骤完成后,Docker 使用 -t 标志用我们提供的名称为新容器镜像打标签。如前所述,我们没有指定版本,因此默认使用 latest。现在我们可以在可用镜像列表中看到该镜像:

root@host01:/opt/hello# docker images | grep hello
hello        latest          e9ca31d590f9   9 minutes ago   133MB

这个镜像的唯一标识符与 Listing 5-1 结尾处的输出匹配。这个镜像显示为 133MB,因为它包含了所有来自 NGINX 镜像的层,外加我们添加的新小 HTML 文件。和之前一样,共享层只存储一次,因此构建这个镜像所需的额外存储非常小。

注意

当你自己尝试这个例子时,显示的“hello”镜像的唯一标识符会有所不同,即使 Dockerfile 中的 HTML 文件内容相同。每一层的标识符不仅基于该层的文件内容,还基于其上层的标识符。因此,如果两个镜像的标识符相同,我们可以确定它们的内容完全相同,即使它们是分开构建的。

我们可以像运行其他镜像一样运行基于这个新镜像的容器:

root@host01:/opt/hello# docker run -d -p 8080:80 hello
83a23cf2921bb37474bfcefb0da45f9953940febfefd01ebadf35405d88c4396
root@host01:/opt/hello# curl http://localhost:8080/
<html><body><h1>Hello World!</h1></body></html>

如第一章所述,-p标志将主机端口转发到容器,使我们即使容器运行在一个独立的网络命名空间中,仍然可以从主机访问 NGINX 服务器。然后我们可以使用curl来查看我们的容器是否包含我们提供的内容。

镜像标记与发布

镜像已经可以在本地运行,但我们还没有准备好将其发布到注册表。要发布到注册表,我们需要给镜像一个名称,该名称包含注册表位置的完整主机和路径,以确保我们在引用镜像时能获取到我们期望的内容。

为了演示,让我们从不同的注册表拉取多个 BusyBox 镜像。我们将从quay.io开始,quay.io是一个替代的容器镜像注册表:

root@host01:/opt/hello# docker pull quay.io/quay/busybox
...
quay.io/quay/busybox:latest

这个镜像名称指定了主机quay.io以及该主机内镜像的位置quay/busybox。和之前一样,因为我们没有指定版本,latest被用作默认版本。我们能够拉取名为latest的版本,因为有人明确将latest版本的镜像发布到了这个注册表。

我们使用这个命令获取的 BusyBox 镜像与直接拉取busybox时获得的镜像不同:

root@host01:/opt/hello# docker pull busybox
...
docker.io/library/busybox:latest
root@host01:/opt/hello# docker images | grep busybox
busybox                latest          d3cd072556c2   3 days ago       1.24MB
quay.io/quay/busybox   latest          e3121c769e39   8 months ago     1.22MB

当我们使用简单名称busybox时,Docker 默认从docker.io/library拉取镜像。这个注册表被称为Docker Hub,你可以在hub.docker.com浏览它。

类似地,当我们使用简单名称hello构建镜像时,Docker 会将其视为属于docker.io/library。这个路径是官方 Docker 镜像的路径,当然,我们没有权限将镜像发布到这里。

本章的自动化设置包括运行一个本地容器注册表,这意味着如果我们正确命名镜像,我们可以将其发布到该本地注册表:

root@host01:/opt/hello# docker tag hello registry.local/hello
root@host01:/opt/hello# docker images | grep hello
hello                  latest          e9ca31d590f9   52 minutes ago   133MB
registry.local/hello   latest          e9ca31d590f9   52 minutes ago   133MB

现在,同一个镜像在两个不同的名称下存在,利用镜像按层存储的方式提供了额外的优势。为镜像添加额外的名称是很便宜的。当然,我们本来也可以在最初运行docker build时使用完整的名称,但在构建和本地使用镜像时,使用较短的名称更为方便。

现在我们已经正确命名了镜像,我们可以使用docker push将其发布:

root@host01:/opt/hello# docker push registry.local/hello
Using default tag: latest
The push refers to repository [registry.local/hello]
...

我们的本地注册表一开始是空的,因此此命令会上传所有的层,但如果我们推送任何包含相同层的未来镜像,它们不会被再次上传。同样,如果我们从注册表中删除一个镜像标签,层数据并不会被删除。

发布镜像的能力不限于我们自己构建的镜像。我们可以标记并推送刚刚从 Docker Hub 下载的 BusyBox 镜像:

root@host01:/opt/hello# docker tag busybox registry.local/busybox
root@host01:/opt/hello# docker push registry.local/busybox
Using default tag: latest
The push refers to repository [registry.local/busybox]
...
root@host01:/opt/hello# cd

重新标记一个镜像,以便我们可以将其上传到私有注册表,这是一个常见的做法,它有助于应用程序更快启动并避免依赖于互联网注册表。

最后一个命令(cd)将我们带回到我们的主目录,因为我们在/opt/hello中已经完成操作。

镜像和容器存储

如前所述,使用单独的层来构建容器镜像有多个优势,包括减少下载大小、减少磁盘空间,并且可以在不使用额外空间的情况下为镜像重新标记新名称。运行中的容器所需的额外磁盘空间仅限于我们在容器运行时写入的文件。最后,所有的示例都展示了新容器启动的速度有多快。所有这些特性加在一起,说明为什么层必须共享,不仅仅是镜像,还有新的容器。为了更好地利用这种分层方法来构建高效的镜像,了解这种分层文件系统是如何工作的非常重要。

覆盖文件系统

当我们运行容器时,我们看到的是一个看似单一的文件系统,所有层都被合并在一起,并且可以对任何文件进行更改。如果我们从同一个镜像运行多个容器,我们会在每个容器中看到一个独立的文件系统,这样一个容器中的更改不会影响到另一个容器。那么,为什么在每次启动容器时不需要复制整个文件系统呢?答案就是覆盖文件系统

一个 Overlay 文件系统有三个主要部分。lower 目录 是“基础”层所在的位置。(可能有多个 lower 目录。)upper 目录包含“覆盖”层,mount 目录是将统一文件系统提供给用户的地方。挂载目录中的目录列表反映了所有层的文件,按优先顺序排列。对挂载目录所做的任何更改,实际上是通过从 lower 目录将更改的文件复制到 upper 目录并更新它来写入上层目录——这一过程称为 写时复制。删除操作也会作为元数据写入 upper 目录,因此 lower 目录保持不变。这意味着多个用户可以共享 lower 目录而不会发生冲突,因为它仅供读取,从不写入。

Overlay 文件系统不仅对容器镜像和容器有用,它还对嵌入式系统有用,例如网络路由器,对于这种设备,固件中写入只读文件系统,使得设备每次重新启动时都能安全地回到已知状态。它对于虚拟机也有用,可以使多个虚拟机从同一镜像启动。

Overlay 文件系统是由 Linux 内核模块提供的,能提供非常高的性能。我们可以轻松创建一个 Overlay 文件系统。第一步是创建必要的目录:

root@host01:~# mkdir /tmp/{lower,upper,work,mount}

mkdir 命令在 /tmp 目录中创建了四个独立的目录。我们已经讨论过 lower 目录、upper 目录和 mount 目录。work 目录是一个额外的空目录,Overlay 文件系统使用它作为临时空间,确保挂载目录中的更改是原子性的——也就是说,确保它们一次性出现。

让我们向 lower 目录和 upper 目录中添加一些内容:

root@host01:~# echo "hello1" > /tmp/lower/hello1
root@host01:~# echo "hello2" > /tmp/upper/hello2

接下来,我们只需要挂载 Overlay 文件系统:

root@host01:~# mount -t overlay \
  -o rw,lowerdir=/tmp/lower,upperdir=/tmp/upper,workdir=/tmp/work \
  overlay /tmp/mount

/tmp/mount 目录现在包含了上层和下层目录的合并内容:

root@host01:~# ls -l /tmp/mount
total 8
-rw-r--r-- 1 root root 7 May 24 23:05 hello1
-rw-r--r-- 1 root root 7 May 24 23:05 hello2
root@host01:/opt/hello# cat /tmp/mount/hello1
hello1
root@host01:/opt/hello# cat /tmp/mount/hello2
hello2

我们所做的任何更改都会显示在挂载位置,但实际上是在上层目录中进行的:

root@host01:~# echo "hello3" > /tmp/mount/hello3
root@host01:~# ls -l /tmp/mount
total 8
-rw-r--r-- 1 root root 7 May 24 23:05 hello1
-rw-r--r-- 1 root root 7 May 24 23:10 hello2
-rw-r--r-- 1 root root 7 May 24 23:09 hello3
root@host01:~# ls -l /tmp/lower
total 4
-rw-r--r-- 1 root root 7 May 24 23:05 hello1
root@host01:~# ls -l /tmp/upper
total 8
-rw-r--r-- 1 root root 7 May 24 23:10 hello2
-rw-r--r-- 1 root root 7 May 24 23:09 hello3

此外,即使删除文件,也不会影响 lower 目录:

   root@host01:~# rm /tmp/mount/hello1
   root@host01:~# ls -l /tmp/mount
   total 8
   -rw-r--r-- 1 root root 7 May 24 23:10 hello2
   -rw-r--r-- 1 root root 7 May 24 23:09 hello3
   root@host01:~# ls -l /tmp/lower
   total 4
   -rw-r--r-- 1 root root 7 May 24 23:05 hello1
   root@host01:~# ls -l /tmp/upper
   total 8
➊ c--------- 1 root root 0, 0 May 24 23:11 hello1
   -rw-r--r-- 1 root root    7 May 24 23:10 hello2
   -rw-r--r-- 1 root root    7 May 24 23:09 hello3

hello1 在上层目录 ➊ 的列表旁边的 c 表明这是一个 字符特殊文件。它的作用是表示该文件在上层目录中已被删除。因此,尽管它仍然存在于 lower 目录中,但在挂载的文件系统中并未显示出来。

多亏了这种方法,我们可以使用独立的 Overlay 重新使用 lower 目录,类似于我们可以从同一镜像运行多个独立容器的方式:

root@host01:~# mkdir /tmp/{upper2,work2,mount2}
root@host01:~# mount -t overlay \
  -o rw,lowerdir=/tmp/lower,upperdir=/tmp/upper2,workdir=/tmp/work2 \
  overlay /tmp/mount2
root@host01:~# ls -l /tmp/mount2
total 4
-rw-r--r-- 1 root root 7 May 24 23:05 hello1

不仅来自 lower 目录的“已删除”文件会出现,来自第一个上层目录的任何内容也不会出现,因为它不是这个新 Overlay 的一部分。

理解容器层

有了关于 Overlay 文件系统的信息,我们可以探索正在运行的 NGINX 容器的文件系统:

root@host01:~# ROOT=$(docker inspect nginx \
  | jq -r '.[0].GraphDriver.Data.MergedDir')
root@host01:~# echo $ROOT
/var/lib/docker/overlay2/433751e2378f9b11.../merged

如前所述,我们使用 jq 只选择我们想要的字段;在这种情况下,它是容器文件系统的 merged 目录路径。这个合并目录是 overlay 文件系统的挂载点:

root@host01:~# mount | grep $ROOT | tr [:,] '\n'
overlay on /var/lib/docker/overlay2/433751e2378f9b11.../merged ...
lowerdir=/var/lib/docker/overlay2/l/ERVEI5TCULK4PCNO2HSWB4MFDB
/var/lib/docker/overlay2/l/RQDO2PYQ3OKMKDY3DAYPAJTZHF
/var/lib/docker/overlay2/l/LFSBVPYPODQJXDL5WQTI7ISYNC
/var/lib/docker/overlay2/l/TLZUYV2BFQNPFGU3AZFUHOH27V
/var/lib/docker/overlay2/l/4M66FKSHDBNUWE7UAF2REQHSB2
/var/lib/docker/overlay2/l/LCTKPRHP6LG7KC7JQHETKIL6TZ
/var/lib/docker/overlay2/l/JOECSCSAQ5CPNHGEURVRT4JRQQ
upperdir=/var/lib/docker/overlay2/433751e2378f9b11.../diff
workdir=/var/lib/docker/overlay2/433751e2378f9b11.../work,xino=off)

tr 命令将冒号和逗号转换为换行符,以使输出更加易读。

mount 命令显示了 lowerdir 的七个单独条目,每个条目对应 NGINX 容器镜像中的一层。这七个目录,加上 upperdir,在 overlay 文件系统中合并在一起。

我们可以在挂载目录和上层目录中看到我们之前创建的 10MB 数据文件:

root@host01:~# ls -l $ROOT/tmp/data
-rw-r--r-- 1 root root 10485760 May 25 00:27 /var/lib/.../merged/tmp/data
root@host01:~# ls -l $ROOT/../diff/tmp/data
-rw-r--r-- 1 root root 10485760 May 25 00:27 /var/lib/.../diff/tmp/data

实际的文件存储在上层目录 diff 中,而挂载目录 merged 只是 overlay 文件系统生成的视图。

通常,我们不需要从宿主机深入容器文件系统,因为我们可以直接从容器内运行命令来探索其文件。然而,这种技术在容器引擎行为不正常时拉取容器文件会非常有用。

实用的镜像构建建议

使用容器镜像时,overlay 文件系统的方式带来了一些重要的实际影响。首先,由于 overlay 文件系统可以有多个下层目录,且合并操作具有高效性,因此将容器镜像分为多个层几乎不会带来性能损失。这使得我们在构建容器镜像时能够非常模块化,方便复用各层。例如,我们可以从一个基础镜像开始,然后在其上构建一个安装了常用依赖的镜像,再构建另一个镜像,加入一些特定应用组件的依赖,最后构建另一个镜像,添加特定的应用程序。使用分层的方法组装应用容器镜像可以实现非常高效的镜像传输和存储,因为基础层可以在各个组件之间共享。

其次,因为在上层删除一个文件并不会真正删除下层的文件,我们需要小心如何处理大型临时文件,以及在构建镜像时如何存储机密信息。在这两种情况下,如果在文件仍然存在时完成了一层构建,它将永远存在,从而浪费带宽和空间,甚至更糟,泄露机密信息给下载镜像的任何人。一般来说,你应该假设每一行 Dockerfile 都会生成一个新的层,而且你应该假设每个命令相关的所有信息都会存储在镜像的元数据中。因此:

  • 在一个 RUN 行中执行多个步骤,并确保每个 RUN 命令在执行后自行清理。

  • 不要使用 COPY 命令将大文件或机密信息转移到镜像中,即使你在后续的 RUN 步骤中清理它们。

  • 不要使用 ENV 存储机密信息,因为最终生成的值会成为镜像元数据的一部分。

开放容器倡议

一个容器镜像不仅仅是构成覆盖文件系统的层集合。它还包括重要的元数据,如容器的初始命令以及该命令的任何环境变量。开放容器倡议(OCI)提供了存储图像信息的标准格式。它确保由一个工具构建的容器镜像可以被任何其他工具使用,并提供了逐层或完整包裹传输图像的标准方式。

为了演示 OCI 格式,让我们从 Docker 中提取一个 BusyBox 容器镜像,并使用 Skopeo 将其存储为 OCI 格式,Skopeo 是一个设计用于在仓库和格式之间移动容器镜像的程序。第一步是提取镜像:

root@host01:~# skopeo copy docker-daemon:busybox:latest oci:busybox:latest
...

这条命令告诉 Skopeo 从 Docker 引擎的存储中获取镜像,并以 OCI 格式输出。现在我们有一个包含该镜像的 busybox 目录:

root@host01:~# ls -l busybox
total 12
drwxr-xr-x 3 root root 4096 May 24 23:59 blobs
-rw-r--r-- 1 root root  247 May 24 23:59 index.json
-rw-r--r-- 1 root root   31 May 24 23:59 oci-layout

oci-layout 文件指定了用于此镜像的 OCI 版本:

root@host01:~# jq . busybox/oci-layout
{
  "imageLayoutVersion": "1.0.0"
}

index.json 文件告诉我们有关该镜像的信息:

root@host01:~# jq . busybox/index.json
{
  "schemaVersion": 2,
 "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:9c3c5aeeaa7e1629871808339...",
      "size": 347,
      "annotations": {
        "org.opencontainers.image.ref.name": "latest"
      }
    }
  ]
}

manifests 属性是一个允许我们在单个 OCI 目录或包中存储多个镜像的数组。实际的文件系统内容按层存储在 blobs 目录中,每个层作为单独的 .tar 文件,因此任何共享层只存储一次。

这个 BusyBox 镜像只有一个单独的层。要查看其内容,我们需要通过 index.json 和镜像清单找到其 .tar 文件的路径:

root@host01:~# MANIFEST=$(jq -r \
  .manifests[0].digest busybox/index.json | sed -e 's/sha256://')
root@host01:~# LAYER=$(jq -r \
  .layers[0].digest busybox/blobs/sha256/$MANIFEST | sed -e 's/sha256://')
root@host01:~# echo $LAYER
197dfd3345530fd558a64f2a550e8af75a9cb812df5623daf0392aa39e0ce767

blobs 目录中的文件使用从文件内容计算得出的 SHA-256 摘要命名。我们首先使用 jq 获取 BusyBox 镜像清单的摘要,去掉前面的 sha256: 部分以获取清单文件的名称。然后读取清单以找到第一个(也是唯一的)层。现在我们可以看到这一层的内容:

root@host01:~# tar tvf busybox/blobs/sha256/$LAYER
drwxr-xr-x 0/0               0 2021-05-17 19:07 bin/
-rwxr-xr-x 0/0         1149184 2021-05-17 19:07 bin/
hrwxr-xr-x 0/0               0 2021-05-17 19:07 bin/[[ link to bin/[
...
drwxr-xr-x 0/0               0 2021-05-17 19:07 dev/
drwxr-xr-x 0/0               0 2021-05-17 19:07 etc/
...

tar 命令传递 tvf 告诉它列出我们指定的文件的内容表,这里是 BusyBox 镜像层。该层包含一个完整的 Linux 文件系统,其中 BusyBox 作为大多数标准 Linux 命令的单个可执行文件。

使用这个 busybox 目录,我们还可以将容器镜像打包起来,移动到另一个系统,然后在另一个容器引擎中拉取它。

总结思路

运行容器时,我们会得到一个看起来是独立的、隔离的文件系统,可以按需修改。在底层,容器引擎使用覆盖文件系统将多个容器镜像层合并到一起,并使用可写目录存储我们所做的所有更改。使用覆盖文件系统不仅使新容器快速启动,还意味着我们可以从同一镜像运行多个容器而无需等待文件复制完成,并且可以通过共享镜像层来减少所需的磁盘空间。

现在我们已经了解了进程隔离、资源限制、网络隔离和容器存储,这些都是容器在打包、分发、更新和运行应用组件时非常有价值的主要特性。接下来,我们将讨论只有在像 Kubernetes 这样的容器编排环境中才能获得的关键特性。我们将在[第二部分中进行讨论。

第二部分

KUBERNETES 中的容器

计算机具有有限的处理能力、存储和内存,并且由易于出现故障的组件构成,特别是在错误的时机。为了构建一个可扩展、可靠的应用程序,我们不能仅仅依赖单一主机的资源或依赖单一故障点。与此同时,我们也不希望放弃容器所提供的模块化和灵活性。在第二部分中,我们将看到 Kubernetes 如何满足跨一群机器运行容器的基本要求,包括跨主机容器网络、可扩展性、自动故障切换和分布式存储。

第六章:为什么 KUBERNETES 很重要

image

容器使我们能够改变打包和部署应用组件的方式,但在集群中编排容器才能真正发挥容器化微服务架构的优势。正如第一章中所描述,现代应用架构的主要优点是可扩展性、可靠性和弹性,而这三大优点都需要像 Kubernetes 这样的容器编排环境,才能在多个服务器和网络中运行许多容器化的应用组件实例。

在本章中,我们将首先讨论在集群中跨多个服务器运行容器时出现的一些跨领域关注点。然后,我们将描述为解决这些关注点而设计的 Kubernetes 核心概念。在介绍完成后,我们将重点介绍本章的主要内容,即实际安装 Kubernetes 集群,包括重要的附加组件,如网络和存储。

在集群中运行容器

将我们的应用组件分布在多个服务器上的需求,对于现代应用架构来说并不新鲜。为了构建可扩展且可靠的应用,我们始终需要利用多个服务器来处理应用负载,并避免单点故障。我们现在将这些组件运行在容器中,并没有改变我们对多台服务器的需求;我们最终仍然使用 CPU,并且依赖硬件。

与此同时,容器编排环境带来了可能在其他类型的应用基础设施中不存在的挑战。当容器是我们构建系统时最小的单个模块时,我们最终得到的应用组件更加自包含,从我们的基础设施角度来看更具“封闭性”。这意味着,与静态的应用架构不同,在静态架构中我们预先选择将哪些应用组件分配到特定服务器上,而使用 Kubernetes 时,我们尽量使得任何容器都能在任何地方运行。

跨领域关注点

在任何地方运行任何容器的能力最大化了我们的灵活性,但也增加了 Kubernetes 本身的复杂性。Kubernetes 无法提前知道将要求运行哪些容器,且容器工作负载随着新应用的部署或应用负载变化而不断变化。为了应对这一挑战,Kubernetes 需要考虑以下设计参数,这些参数适用于所有容器编排软件,无论运行什么容器:

动态调度 新的容器必须分配到服务器上,并且由于配置变化或故障,分配可能会发生变化。

分布式状态 整个集群必须保持关于容器运行状态和位置的信息,即使在硬件或网络故障时也要保持这一信息。

多租户 应该能够在单一集群中运行多个应用程序,同时实现安全性和可靠性的隔离。

硬件隔离 集群必须在云环境中运行,并且可以运行在各种类型的常规服务器上,实现容器与这些环境之间的隔离。

用来描述这些设计参数的最佳术语是 横切关注点,因为它们适用于我们可能需要部署的任何类型的容器化软件,甚至是 Kubernetes 基础架构本身。这些参数与我们在第一章中看到的容器编排需求一起工作,并最终推动 Kubernetes 架构和关键设计决策。

Kubernetes 概念

为了解决这些横切关注点,Kubernetes 架构允许任何东西在任何时候进出。这不仅包括部署到 Kubernetes 的容器化应用程序,还包括 Kubernetes 本身的基本软件组件,甚至包括底层硬件,如服务器、网络连接和存储。

分离的控制平面

显然,要让 Kubernetes 成为容器编排环境,它需要能够运行容器。这个能力由一组叫做节点的工作机器提供。每个节点运行一个与底层容器运行时接口的 kubelet 服务,用于启动和监控容器。

Kubernetes 还有一组核心软件组件,负责管理工作节点及其容器,但这些软件组件与工作节点是分开部署的。这些核心 Kubernetes 软件组件统称为控制平面。由于控制平面与工作节点分离,工作节点可以运行控制平面,从而让 Kubernetes 核心软件组件受益于容器化。分离的控制平面也意味着 Kubernetes 本身具有微服务架构,允许对每个 Kubernetes 集群进行定制。例如,一个控制平面组件——云控制器管理器,仅在将 Kubernetes 部署到云提供商时使用,并且它会根据所使用的云提供商进行定制。这样的设计为应用容器和 Kubernetes 控制平面的其他部分提供了硬件隔离,同时仍然可以利用每个云提供商的特定功能。

声明式 API

Kubernetes 控制平面的一个关键组件是API 服务器。API 服务器为集群控制和监视提供接口,其他集群用户和控制平面组件使用它。在定义 API 时,Kubernetes 可以选择命令式风格,其中每个 API 端点都是诸如“运行容器”或“分配存储”的命令。相反,API 是声明式的,提供诸如创建修补获取删除的端点。这些命令的效果是从集群配置中创建、读取、更新和删除资源,每个资源的具体配置告诉 Kubernetes 我们希望集群执行什么操作。

这种声明式 API 对满足动态调度和分布式状态的横切关注点至关重要。因为声明式 API 只报告或更新集群配置,因此很容易对可能导致命令丢失的服务器或网络故障做出反应。考虑一个例子,即使在发出apply命令以更改集群配置后,API 服务器连接丢失。当连接恢复时,客户端只需查询集群配置,并确定命令是否成功接收。或者更简单地,客户端可以再次发出相同的apply命令,因为只要集群配置最终符合期望,Kubernetes 将尝试对实际集群进行“正确的操作”。这个核心原则被称为幂等性,意味着可以安全地多次发出相同的命令,因为它最多只会应用一次。

自我修复

基于声明式 API,Kubernetes 被设计为自我修复。这意味着控制平面组件不断监视集群配置和实际集群状态,并尝试使它们保持一致。集群配置中的每个资源都有一个相关的状态和事件日志,反映了配置如何实际导致集群状态的变化。

配置和状态分离使得 Kubernetes 非常具有弹性。例如,表示容器的资源如果已经被调度并且实际在运行,则可能处于Running状态。如果 Kubernetes 控制平面与运行容器的服务器失去连接,它可以立即将状态设置为Unknown,然后努力重新建立连接或将节点视为失败并重新调度容器。

同时,使用声明式 API 和自愈方法具有重要的意义。由于 Kubernetes API 是声明式的,命令的“成功”响应仅意味着集群配置已更新。这并不意味着集群的实际状态已更新,因为可能需要一些时间才能实现请求的状态,或者可能存在一些问题,导致集群无法实现该状态。因此,我们不能仅仅因为创建了适当的资源,就假设集群正在运行我们期望的容器。相反,我们必须监视资源的状态,并查看事件日志,以诊断 Kubernetes 控制平面在使实际集群状态与我们指定的配置匹配时可能遇到的任何问题。

集群部署

在掌握了一些 Kubernetes 核心概念后,我们将使用 kubeadm Kubernetes 管理工具,在多台虚拟机上部署一个高度可用的 Kubernetes 集群。

选择 Kubernetes 发行版

与我们在第一章中使用特定的 Kubernetes 发行版不同,我们将使用通用的上游代码库部署一个“原生”的 Kubernetes 集群。这种方法给我们提供了最好的机会,能够跟随集群的部署过程,并将在接下来的几章中更容易深入探索集群。然而,当你准备好部署自己的 Kubernetes 集群时,特别是在生产环境中,考虑使用一个预构建的 Kubernetes 发行版,以便于管理并具备内建的安全性。云原生计算基金会(CNCF)发布了一套符合性测试,你可以用来确保你选择的 Kubernetes 发行版符合 Kubernetes 规范。

我们的 Kubernetes 集群将分布在四台虚拟机上,分别标记为 host01host04。其中三台虚拟机,host01host03,将运行控制平面组件,而第四台将仅作为工作节点。我们将使用三台控制平面节点,因为这是运行高度可用集群所需的最小数量。Kubernetes 使用投票机制提供故障转移,至少需要三台控制平面节点;这样,在网络故障发生时,集群能够检测到应该继续运行的节点。此外,为了使集群尽可能小,以便于我们在接下来的示例中使用,我们将配置 Kubernetes 在控制平面节点上运行常规容器,尽管我们通常会避免在生产集群中这么做。

注意

本书的示例代码库位于 github.com/book-of-kubernetes/examples有关设置的详细信息,请参阅 第 xx 页的“运行示例”部分。

从本章的说明开始,确保四台虚拟机都启动并运行,无论是在 Vagrant 中还是在 AWS 中。自动化配置将为所有四台机器设置containerdcrictl,因此我们无需手动配置。自动化配置脚本还将设置kube-vip或 AWS 网络负载均衡器,以提供所需的高可用性功能,如下文所述。

注意

你可以使用本章示例提供的额外provisioning脚本自动安装 Kubernetes。有关说明,请参阅本章的 README 文件。

你需要在每个虚拟机上运行命令,因此你可能希望为每个虚拟机打开一个终端标签。但是,第一系列命令需要在所有主机上运行,因此自动化脚本会在host01上设置一个名为k8s-all的命令来完成这项工作。你可以在/usr/local/bin/k8s-all中查看这个脚本的内容,或者查看本示例中setup目录下的k8s Ansible 角色。

前提软件包

第一步是确保启用br_netfilter内核模块并设置为开机加载。Kubernetes 使用 Linux 防火墙的高级功能来处理跨集群的网络通信,因此我们需要这个模块。运行这两个命令:

root@host01:~# k8s-all modprobe br_netfilter
...
root@host01:~# k8s-all "echo 'br_netfilter' > /etc/modules-load.d/k8s.conf"

第一个命令确保模块已安装在当前运行的内核中,第二个命令将其添加到开机加载模块列表中。第二个命令中的稍微奇怪的引号确保在远程主机上发生 shell 重定向。

接下来,在清单 6-1 中,我们将设置一些 Linux 内核参数,以启用所需的高级网络功能,这些功能也用于通过sysctl命令跨集群进行网络通信:

root@host01:~# k8s-all sysctl -w net.ipv4.ip_forward=1 \
  net.bridge.bridge-nf-call-ip6tables=1 \
  net.bridge.bridge-nf-call-iptables=1

清单 6-1:内核设置

该命令启用以下 Linux 内核网络功能:

net.ipv4.ip_forward 将数据包从一个网络接口转发到另一个网络接口(例如,从容器的网络命名空间内的接口到主机网络)。

net.bridge.bridge-nf-call-ip6tables 通过iptables防火墙运行 IPv6 桥接流量。

net.bridge.bridge-nf-call-iptables 通过iptables防火墙运行 IPv4 桥接流量。

最后两项的需求将在第九章中变得更加明确,当我们讨论 Kubernetes 如何为服务提供网络时。

在清单 6-1 中的这些sysctl更改在重启后不会保持。自动化脚本会处理这些更改的持久化,因此如果你重启虚拟机,可以运行extra provisioning 脚本,或者重新运行这些命令。

我们现在已经完成了配置 Linux 内核以支持 Kubernetes 部署,准备好进行实际安装。首先,我们需要安装一些前提软件包:

root@host01:~# k8s-all apt install -y apt-transport-https \
  open-iscsi nfs-common

apt-transport-https 包确保 apt 能通过安全 HTTP 协议连接到仓库。其他两个软件包是我们在集群启动并运行后将安装的集群附加组件所需的。

Kubernetes 软件包

现在,我们可以添加 Kubernetes 仓库来安装将设置我们集群的 kubeadm 工具。首先,添加用于检查软件包签名的 GPG 密钥:

root@host01:~# k8s-all "curl -fsSL \
  https://packages.cloud.google.com/apt/doc/apt-key.gpg | \
  gpg --dearmor -o /usr/share/keyrings/google-cloud-keyring.gpg"

这条命令使用 curl 下载 GPG 密钥。然后它使用 gpg 重新格式化密钥,并将结果写入到 /usr/share/keyrings。命令行标志 fsSLcurl 设置为一种更适合链式命令的模式,包括避免不必要的输出、跟随服务器重定向,并在出现问题时终止执行。

接下来,我们添加仓库配置:

root@host01:~# k8s-all "echo 'deb [arch=amd64' \
  'signed-by=/usr/share/keyrings/google-cloud-keyring.gpg]' \
  'https://apt.kubernetes.io/ kubernetes-xenial main' > \
  /etc/apt/sources.list.d/kubernetes.list"

和之前一样,引号非常重要,确保命令可以通过 SSH 正确传递到集群中的所有其他主机。命令将 kubernetes-xenial 配置为发行版;这个发行版用于任何版本的 Ubuntu,从较旧的 Ubuntu Xenial 开始。

在创建完这个新仓库之后,我们需要在所有主机上运行 apt update 来下载软件包列表:

root@host01:~# k8s-all apt update
...

现在我们可以使用 apt 安装所需的软件包:

root@host01:~# source /opt/k8sver
root@host01:~# k8s-all apt install -y kubelet=$K8SV kubeadm=$K8SV kubectl=$K8SV

source 命令加载一个带有变量的文件,用于安装特定版本的 Kubernetes。这个文件由自动化脚本创建,确保我们在所有章节中使用一致的 Kubernetes 版本。你可以更新自动化脚本来选择要安装的 Kubernetes 版本。

apt 命令安装以下三个软件包以及一些依赖项:

kubelet 服务用于所有工作节点,它与容器引擎接口,按控制平面的调度运行容器。

kubeadm 是我们用来安装 Kubernetes 并维护集群的管理工具。

kubectl 是我们用来检查 Kubernetes 集群并创建、删除资源的命令行客户端。

kubelet 包会立即启动其服务,但由于我们还没有安装控制平面,服务一开始会处于失败状态:

root@host01:~# systemctl status kubelet
  kubelet.service - kubelet: The Kubernetes Node Agent
...
   Main PID: 75368 (code=exited, status=1/FAILURE)

我们需要控制刚刚安装的软件包的版本,因为我们希望将集群的所有组件一起升级。为了防止不小心更新这些软件包,我们将把它们保持在当前版本:

root@host01:~# k8s-all apt-mark hold kubelet kubeadm kubectl

这条命令防止标准的 apt full-upgrade 命令更新这些软件包。相反,如果我们升级集群,我们需要通过使用 apt install 指定我们想要的确切版本。

集群初始化

下一条命令 kubeadm init 初始化控制平面,并为所有节点提供 kubelet 工作节点服务配置。我们将在集群中的一个节点上运行 kubeadm init,然后在其他节点上使用 kubeadm join 将它们加入现有的集群。

要运行kubeadm init,我们首先创建一个 YAML 配置文件。这种方式有几个优点。它大大减少了我们需要记住的命令行标志数量,而且它让我们可以将集群配置保存在一个版本库中,从而对集群配置进行控制。然后,我们可以更新 YAML 文件并重新运行kubeadm来更改集群配置。

本章的自动化脚本已经在/etc/kubernetes中填充了一个 YAML 配置文件,所以它已准备好使用。以下是该文件的内容:

kubeadm-init.yaml

---
apiVersion: kubeadm.k8s.io/v1beta3
kind: InitConfiguration
bootstrapTokens:
- groups:
  - system:bootstrappers:kubeadm:default-node-token
  token: 1d8fb1.2875d52d62a3282d
  ttl: 2h0m0s
  usages:
  - signing
  - authentication
nodeRegistration:
  kubeletExtraArgs:
    node-ip: 192.168.61.11
 taints: []
localAPIEndpoint:
  advertiseAddress: 192.168.61.11
certificateKey: "5a7e07816958efb97635e9a66256adb1"
---
apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
kubernetesVersion: 1.21.4
apiServer:
  extraArgs:
    service-node-port-range: 80-32767
networking:
  podSubnet: "172.31.0.0/16"
controlPlaneEndpoint: "192.168.61.10:6443"
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
serverTLSBootstrap: true

这个 YAML 文件有三个文档,通过破折号(---)分隔。第一个文档是专门用于初始化集群的,第二个文档包含更通用的配置,第三个文档用于提供跨所有节点的kubelet设置。我们来看一下每个配置项的用途:

apiVersion / kind 告诉 Kubernetes 每个 YAML 文档的用途,以便它能够验证内容。

bootstrapTokens 配置一个密钥,供其他节点用来加入集群。token应该在生产集群中保密。它会在两个小时后自动过期,所以如果我们以后想加入更多节点,需要重新生成一个。

nodeRegistration 配置项,用于传递给在host01上运行的kubelet服务。node-ip字段确保kubelet将正确的 IP 地址注册到 API 服务器,以便 API 服务器能够与之通信。taints字段确保常规容器可以被调度到控制平面节点上。

localAPIEndpoint API 服务器应该使用的本地 IP 地址。我们的虚拟机有多个 IP 地址,我们希望 API 服务器监听正确的网络。

certificateKey 配置一个密钥,供其他节点用来获取 API 服务器的证书。这个密钥是必须的,以便在我们高可用集群中的所有 API 服务器实例都可以使用相同的证书。在生产集群中要保密。

networking 集群中的所有容器都会从podSubnet中获取一个 IP 地址,无论它们运行在哪个主机上。稍后,我们将安装一个网络驱动程序,确保集群中所有主机上的容器能够互相通信。

controlPlaneEndpoint API 服务器的外部地址。对于高可用集群,这个 IP 地址需要能够访问到任何 API 服务器实例,而不仅仅是第一个实例。

serverTLSBootstrap 指示kubelet使用控制器管理器的证书授权机构来请求服务器证书。

apiVersionkind 字段将在每个 Kubernetes YAML 文件中出现。apiVersion 字段定义了一组相关的 Kubernetes 资源,包括版本号。然后,kind 字段选择该组中的具体资源类型。这不仅允许 Kubernetes 项目和其他供应商随着时间的推移添加新的资源组,还允许在保持向后兼容的同时更新现有资源的规范。

高可用集群

controlPlaneEndpoint 字段用于配置高可用集群的最重要要求:一个可以访问所有 API 服务器的 IP 地址。我们需要在初始化集群时立即设置此 IP 地址,因为它用于生成客户端验证 API 服务器身份的证书。提供集群范围的 IP 地址的最佳方式取决于集群运行的位置;例如,在云环境中,使用提供商内建的能力(如 Amazon Web Services 中的弹性负载均衡器(ELB)或 Azure 负载均衡器)是最好的选择。

由于两种不同环境的特性,本书中的示例在使用 Vagrant 运行时使用 kube-vip,在使用 Amazon Web Services 运行时使用 ELB。示例文档中的顶层 README.md 文件包含更多细节。安装和配置会自动完成,因此无需进行其他配置。我们可以直接使用 192.168.61.10:6443,并期望流量能够到达运行在 host01host03 上的任何 API 服务器实例。

因为我们已经准备好集群配置文件(YAML 文件),所以初始化集群的 kubeadm init 命令非常简单。我们只需要在 host01 上运行此命令:

root@host01:~# /usr/bin/kubeadm init \
  --config /etc/kubernetes/kubeadm-init.yaml --upload-certs

--config 选项指向我们之前查看过的 YAML 配置文件(kubeadm-init.yaml),而 --upload-certs 选项告诉 kubeadm 应该将 API 服务器的证书上传到集群的分布式存储中。其他控制平面节点随后可以在加入集群时下载这些证书,从而使所有 API 服务器实例使用相同的证书,这样客户端就会信任它们。这些证书是使用我们提供的 certificateKey 进行加密的,这意味着其他节点需要此密钥才能解密它们。

kubeadm init 命令在 host01 上初始化控制平面的组件。这些组件以容器的形式运行,并由 kubelet 服务进行管理,这使得它们容易升级。几个容器镜像将被下载,因此根据虚拟机的速度和网络连接的情况,此命令可能需要一段时间。

将节点加入集群

kubeadm init命令会输出一个kubeadm join命令,我们可以用它将其他节点加入集群。然而,自动化脚本已经将配置文件预先放置到每个其他节点,确保它们以正确类型的节点加入。服务器host02host03将作为额外的控制平面节点加入,而host04将仅作为工作节点加入。

这是host02的 YAML 配置文件,带有其特定设置:

kubeadm-join.yaml(host02)

---
apiVersion: kubeadm.k8s.io/v1beta3
kind: JoinConfiguration
discovery:
  bootstrapToken:
    apiServerEndpoint: 192.168.61.10:6443
    token: 1d8fb1.2875d52d62a3282d
    unsafeSkipCAVerification: true
  timeout: 5m0s
nodeRegistration:
  kubeletExtraArgs:
    cgroup-driver: containerd
    node-ip: 192.168.61.12
  taints: []
  ignorePreflightErrors:
    - DirAvailable--etc-kubernetes-manifests
controlPlane:
  localAPIEndpoint:
    advertiseAddress: 192.168.61.12
  certificateKey: "5a7e07816958efb97635e9a66256adb1"

该资源的类型为JoinConfiguration,但大部分字段与kubeadm-init.yaml文件中的InitConfiguration相同。最重要的是,tokencertificateKey与我们之前设置的秘密匹配,因此此节点将能够验证自己并解密 API 服务器证书。

一个不同之处是新增了ignorePreflightErrors。这个部分只有在我们安装kube-vip时出现,因为在这种情况下,我们需要将kube-vip的配置文件预先放置到/etc/kubernetes/manifests目录,并且需要告诉kubeadm该目录已经存在是可以的。

因为我们有这个 YAML 配置文件,kubeadm join命令很简单。在host02上运行它:

root@host02:~# /usr/bin/kubeadm join --config /etc/kubernetes/kubeadm-join.yaml

和之前一样,这个命令使用本节点上的kubelet服务以容器的方式运行控制平面组件,因此需要一些时间来下载容器镜像并启动容器。

当它完成时,在host03上运行完全相同的命令:

root@host03:~# /usr/bin/kubeadm join --config /etc/kubernetes/kubeadm-join.yaml

自动化脚本已经为每个主机设置了正确的 IP 地址,因此每个主机之间的配置差异已经考虑到。

当这个命令完成时,我们将创建一个高可用的 Kubernetes 集群,控制平面组件在三个独立的主机上运行。然而,我们还没有常规的工作节点。让我们解决这个问题。

我们将从将host04作为常规工作节点加入开始,并在host04上运行完全相同的kubeadm join命令,但 YAML 配置文件会有所不同。以下是该文件:

kubeadm-join.yaml(host04)

---
apiVersion: kubeadm.k8s.io/v1beta3
kind: JoinConfiguration
discovery:
  bootstrapToken:
    apiServerEndpoint: 192.168.61.10:6443
    token: 1d8fb1.2875d52d62a3282d
    unsafeSkipCAVerification: true
  timeout: 5m0s
nodeRegistration:
  kubeletExtraArgs:
    cgroup-driver: containerd
    node-ip: 192.168.61.14
  taints: []

这个 YAML 文件缺少controlPlane字段,因此kubeadm将其配置为常规工作节点,而非控制平面节点。

现在让我们将host04加入集群:

root@host04:~# /usr/bin/kubeadm join --config /etc/kubernetes/kubeadm-join.yaml

这个命令完成得稍微快一些,因为它不需要下载控制平面容器镜像并运行它们。我们现在有四个节点在集群中,可以通过在host01上运行kubectl来验证:

root@host01:~# export KUBECONFIG=/etc/kubernetes/admin.conf
root@host01:~# kubectl get nodes
NAME     STATUS     ROLES        ...
host01   NotReady   control-plane...
host02   NotReady   control-plane...
host03   NotReady   control-plane...
host04   NotReady   <none>       ...

第一个命令设置了一个环境变量,告诉kubectl使用哪个配置文件。/etc/kubernetes/admin.conf文件是在kubeadm初始化host01作为控制平面节点时自动创建的。该文件告诉kubectl使用哪个地址来访问 API 服务器,使用哪个证书来验证安全连接,以及如何进行身份验证。

当前四个节点应该报告状态为NotReady。让我们运行kubectl describe命令以获取节点详细信息:

root@host01:~# kubectl describe node host04
Name:               host04
...
Conditions:
  Type   Status ... Message
  ----   ------ ... -------
  Ready  False  ... container runtime network not ready...
...

我们还没有为我们的 Kubernetes 集群安装网络驱动程序,因此所有节点都报告为NotReady状态,这意味着它们不会接受常规的应用工作负载。Kubernetes 通过在节点配置中放置一个污点来传达这一点。污点限制了可以在节点上调度的内容。我们可以使用kubectl列出节点上的污点:

root@host01:~# kubectl get node -o json | \
  jq '.items[]|.metadata.name,.spec.taints[]'
"host01"
{
  "effect": "NoSchedule",
  "key": "node.kubernetes.io/not-ready"
}
"host02"
{
  "effect": "NoSchedule",
  "key": "node.kubernetes.io/not-ready"
}
"host03"
{
  "effect": "NoSchedule",
  "key": "node.kubernetes.io/not-ready"
}
"host04"
{
  "effect": "NoSchedule",
  "key": "node.kubernetes.io/not-ready"
}

我们选择json格式的输出,以便可以使用jq仅打印我们需要的信息。由于所有节点的状态都是NotReady,它们都有一个not-ready污点,并设置为NoSchedule,这会阻止 Kubernetes 调度器将容器调度到这些节点上。

通过在kubeadm配置中将taints指定为空数组,我们防止了三个控制平面节点有额外的控制平面污点。在生产集群中,这个污点将应用容器与控制平面容器隔离开,以确保安全,因此我们会保留这个污点。不过,在我们的示例集群中,这意味着我们需要多个额外的虚拟机作为工作节点,而我们并不希望这样。

命令kubectl taint允许我们手动移除not-ready污点,但正确的方法是安装一个网络驱动程序作为集群附加组件,这样节点将正确报告为Ready,从而使我们能够在其上运行容器。

安装集群附加组件

我们已经在四个单独的节点上安装了kubelet,并在其中三个节点上安装了控制平面,并将它们加入到我们的集群中。对于剩下的节点,我们将使用控制平面来安装集群附加组件。这些附加组件类似于我们部署的常规应用程序。它们由 Kubernetes 资源组成,并在容器中运行,但它们为集群提供我们应用程序所需的基本服务。

要让基础集群启动并运行,我们需要安装三种类型的附加组件:网络驱动程序存储驱动程序入口控制器。我们还将安装一个第四个可选附加组件,度量服务器

网络驱动程序

Kubernetes 网络基于容器网络接口(CNI)标准。任何人都可以通过实现这一标准为 Kubernetes 构建新的网络驱动程序,因此 Kubernetes 网络驱动程序有多种选择。我们将在第八章中演示不同的网络插件,但本书中的大多数集群都使用 Calico 网络驱动程序,因为它是许多 Kubernetes 平台的默认选择。

首先,下载 Calico 的主要 YAML 配置文件:

root@host01:~# cd /etc/kubernetes/components
root@host01:/etc/kubernetes/components# curl -L -O $calico_url
...

-L选项告诉curl跟随任何 HTTP 重定向,而-O选项则告诉curl将内容保存到文件中,文件名与 URL 中的文件名相同。calico_url环境变量的值在k8s-ver脚本中设置,该脚本还指定了 Kubernetes 的版本。这是非常重要的,因为 Calico 对我们运行的 Kubernetes 版本非常敏感,因此选择兼容的版本非常关键。

主要的 YAML 配置文件写入本地文件tigera-operator.yaml。这指的是初始安装是一个 Kubernetes Operator,之后它会创建所有其他集群资源来安装 Calico。我们将在第十七章中探讨 Operator。

除了这个主要的 YAML 配置文件,本章节的自动化脚本还添加了一个名为custom-resources.yaml的文件,为我们的示例集群提供了必要的配置。现在,我们可以告诉 Kubernetes API 服务器将这些文件中的所有资源应用到集群中:

root@host01:/etc/kubernetes/components# kubectl apply -f tigera-operator.yaml
...
root@host01:/etc/kubernetes/components# kubectl apply -f custom-resources.yaml

Kubernetes 需要几分钟来下载容器镜像并启动容器,之后 Calico 将在我们的集群中运行,节点应该报告为Ready状态:

root@host01:/etc/kubernetes/components# kubectl get nodes
NAME     STATUS   ROLES                ...
host01   Ready    control-plane,master ...
host02   Ready    control-plane,master ...
host03   Ready    control-plane,master ...
host04   Ready    <none>               ...

Calico 通过安装一个DaemonSet工作,这是一个 Kubernetes 资源,指示集群在每个节点上运行特定的容器或一组容器。Calico 容器随后为在该节点上运行的任何容器提供网络服务。然而,这引出了一个重要问题。当我们在集群中安装 Calico 时,所有节点都有一个污点,告诉 Kubernetes 不要在其上调度容器。那么,Calico 是如何在所有节点上运行容器的呢?答案是容忍

容忍是应用于资源的配置设置,指示 Kubernetes 即使可能存在污点,也可以将该资源调度到节点上。Calico 在将其 DaemonSet 添加到集群时会指定一个容忍设置,正如我们通过kubectl所看到的:

root@host01:/etc/kubernetes/components# kubectl -n calico-system \
  get daemonsets -o json | \
  jq '.items[].spec.template.spec.tolerations[]'
{
  "key": "CriticalAddonsOnly",
  "operator": "Exists"
}
{
  "effect": "NoSchedule",
  "operator": "Exists"
}
{
  "effect": "NoExecute",
  "operator": "Exists"
}

-n选项选择calico-system命名空间。命名空间是 Kubernetes 用来将集群资源彼此隔离的一种方式,既出于安全原因,也为了避免命名冲突。此外,与之前一样,我们请求 JSON 输出,并使用jq选择我们感兴趣的字段。如果你想查看资源的完整配置,可以使用-o=json而不带jq,或者使用-o=yaml

这个 DaemonSet 有三个容忍设置,第二个容忍设置提供了我们所需的行为。它告诉 Kubernetes 调度程序即使在节点上存在NoSchedule污点,也可以继续调度。这样,Calico 就可以在节点准备好之前启动,而一旦运行,它会将节点状态更改为Ready,从而可以调度正常的应用程序容器。控制平面组件也需要类似的容忍设置,才能在节点显示Ready之前运行。

安装存储

集群节点已经准备好,所以如果我们部署一个常规应用,其容器将运行。然而,要求持久存储的应用将无法启动,因为集群还没有存储驱动程序。像网络驱动程序一样,Kubernetes 有多个存储驱动程序可供选择。容器存储接口(CSI)提供了存储驱动程序与 Kubernetes 配合使用所需满足的标准。我们将使用 Longhorn,这是一个来自 Rancher 的存储驱动程序;它安装简单,并且不需要额外的硬件支持,如额外的块设备或访问基于云的存储。

Longhorn 利用我们之前安装的 iSCSI 和 NFS 软件。它要求所有节点都启用了并正在运行 iscsid 服务,因此我们需要确保所有节点都满足这一要求:

root@host01:/etc/kubernetes/components# k8s-all systemctl enable --now iscsid

现在我们可以在集群上安装 Longhorn。安装 Longhorn 的过程与 Calico 很相似。首先下载 Longhorn 的 YAML 配置文件:

root@host01:/etc/kubernetes/components# curl -LO $longhorn_url

longhorn_url 环境变量同样由 k8s-ver 脚本设置,这让我们能够确保兼容性。

使用 kubectl 安装 Longhorn:

root@host01:/etc/kubernetes/components# kubectl apply -f longhorn.yaml

和之前一样,kubectl apply 确保 YAML 文件中的资源被应用到集群中,并根据需要创建或更新它们。kubectl apply 命令支持将 URL 作为资源的来源应用到集群,但对于这三次安装,我们运行一个单独的 curl 命令,因为方便拥有一个本地副本来应用到集群中的内容。

Longhorn 已经安装在集群上,我们将在本章接下来的内容中验证这一点。

入口控制器

现在我们已经有了网络和存储,但目前的网络仅允许从我们集群内部访问容器。我们还需要一个将容器化应用暴露到集群外部的服务。最简单的方法是使用入口控制器。正如我们在第九章中所描述的,入口控制器监视 Kubernetes 集群中的 Ingress 资源并路由网络流量。

我们首先下载入口控制器的 YAML 配置文件:

root@host01:/etc/kubernetes/components# curl -Lo ingress-controller.yaml
  $ingress_url

和我们之前的例子一样,ingress_url 环境变量由 k8s-ver 脚本设置,以确保兼容性。在这种情况下,URL 以通用路径 deploy.yaml 结尾,因此我们使用 -ocurl 提供文件名,以明确说明下载的 YAML 文件的用途。

使用 kubectl 安装入口控制器:

root@host01:/etc/kubernetes/components# kubectl apply -f ingress-controller.yaml

这会创建很多资源,但主要有两个部分:一个实际执行 HTTP 流量路由的 NGINX Web 服务器,以及一个监视集群中 Ingress 资源变化并相应配置 NGINX 的组件。

还有一步我们需要完成。当前安装的 ingress 控制器尝试请求一个外部 IP 地址,以便允许外部流量访问它。由于我们运行的是一个没有外部 IP 地址访问权限的示例集群,因此此方法不可行。相反,我们将通过集群主机的端口转发来访问 ingress 控制器。目前,我们的 ingress 控制器已经配置为支持端口转发,但它使用的是一个随机端口。我们希望选择一个端口,以确保知道如何找到 ingress 控制器。同时,我们还将添加一个注释,以使该 ingress 控制器成为此集群的默认控制器。

为了应用端口更改,我们将为我们的 Kubernetes 集群提供一个额外的 YAML 配置文件,其中仅包含我们需要的更改。以下是该 YAML 文件:

ingress-patch.yaml

---
apiVersion: v1
kind: Service
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
spec:
  ports:
    - port: 80
      nodePort: 80
    - port: 443
      nodePort: 443

此文件指定了服务的名称和命名空间,以确保 Kubernetes 知道在哪些位置应用这些更改。它还指定了我们正在更新的 port 配置,以及用于端口转发的集群节点端口 nodePort。我们将在 第九章 中更详细地讨论 NodePort 服务类型和端口转发。

要修补该服务,我们使用 kubectl patch 命令:

root@host01:/etc/kubernetes/components# kubectl patch -n ingress-nginx \
  service/ingress-nginx-controller --patch-file ingress-patch.yaml
service/ingress-nginx-controller patched

要应用注释,请使用 kubectl annotate 命令:

root@host01:/etc/kubernetes/components# kubectl annotate -n ingress-nginx \
  ingressclass/nginx ingressclass.kubernetes.io/is-default-class="true"
ingressclass.networking.k8s.io/nginx annotated

Kubernetes 在我们进行每次更改时都会向每个资源报告更改情况,因此我们可以知道我们的更改已经应用。

指标服务器

我们的最终附加组件是一个 指标服务器,它从我们的节点收集利用率指标,从而启用自动扩缩。为此,它需要连接到集群中的 kubelet 实例。出于安全考虑,它在连接到 kubelet 时需要验证 HTTP/S 证书。这就是为什么我们将 kubelet 配置为请求由控制器管理器签名的证书,而不是允许 kubelet 生成自签名证书的原因。

在设置过程中,kubelet 在每个节点上创建了一个证书请求,但这些请求并未自动批准。让我们查找这些请求:

root@host01:/etc/kubernetes/components# kubectl get csr
NAME      ... SIGNERNAME                                  ... CONDITION
csr-sgrwz ... kubernetes.io/kubelet-serving               ... Pending
csr-agwb6 ... kubernetes.io/kube-apiserver-client-kubelet ... Approved,Issued
csr-2kwwk ... kubernetes.io/kubelet-serving               ... Pending
csr-5496d ... kubernetes.io/kube-apiserver-client-kubelet ... Approved,Issued
csr-hm6lj ... kubernetes.io/kube-apiserver-client-kubelet ... Approved,Issued
csr-jbfmx ... kubernetes.io/kubelet-serving               ... Pending
csr-njjr7 ... kubernetes.io/kube-apiserver-client-kubelet ... Approved,Issued
csr-v7tcs ... kubernetes.io/kubelet-serving               ... Pending
csr-vr27n ... kubernetes.io/kubelet-serving               ... Pending

每个 kubelet 都有一个客户端证书,用于向 API 服务器进行身份验证;这些证书在引导过程中已自动批准。我们需要批准的请求是 kubelet-serving 证书请求,这些证书在客户端(如我们的指标服务器)连接到 kubelet 时使用。一旦请求被批准,控制器管理器就会签署证书。然后,kubelet 会收集该证书并开始使用它。

我们可以通过查询所有 kubelet-serving 请求的名称,并将这些名称传递给 kubectl certificate approve,一次性批准所有这些请求:

root@host01:/etc/kubernetes/components# kubectl certificate approve \$(kubectl
  get csr --field-selector spec.signerName=kubernetes.io/kubelet-serving -o name)
certificatesigningrequest.certificates.k8s.io/csr-sgrwz approved
...

我们现在可以通过下载并应用其 YAML 配置来安装我们的指标服务器:

root@host01:/etc/kubernetes/components# curl -Lo metrics-server.yaml \$metrics_url
root@host01:/etc/kubernetes/components# kubectl apply -f metrics-server.yaml
...
root@host01:/etc/kubernetes/components# cd
root@host01:~#

这个组件是我们需要安装的最后一个,因此我们可以离开这个目录。通过这些集群附加组件,我们现在拥有一个完整且高可用的 Kubernetes 集群。

探索集群

在将第一个应用程序部署到这个全新的 Kubernetes 集群之前,让我们先探索一下它上面正在运行的内容。我们在这里使用的命令将对以后调试我们自己的应用程序和一个运行不正常的集群时很有帮助。

我们将使用 crictl,这是我们在第一部分中用来探索运行容器的相同命令,来查看在 host01 上运行的容器:

root@host01:~# crictl ps
CONTAINER       ... STATE    NAME                       ...
25c63f29c1442   ... Running  longhorn-csi-plugin        ...
2ffdd044a81d8   ... Running  node-driver-registrar      ...
94468050de89c   ... Running  csi-provisioner            ...
119fbf417f1db   ... Running  csi-attacher               ...
e74c1a2a0c422   ... Running  kube-scheduler             ...
d1ad93cdbc686   ... Running  kube-controller-manager    ...
76266a522cc3d   ... Running  engine-image-ei-611d1496   ...
fc3cd1679e33e   ... Running  replica-manager            ...
48e792a973105   ... Running  engine-manager             ...
e658baebbc295   ... Running  longhorn-manager           ...
eb51d9ec0f2fc   ... Running  calico-kube-controllers    ...
53e7e3e4a3148   ... Running  calico-node                ...
772ac45ceb94e   ... Running  calico-typha               ...
4005370021f5f   ... Running  kube-proxy                 ...
26929cde3a264   ... Running  kube-apiserver             ...
9ea4c2f5af794   ... Running  etcd                       ...

控制平面节点非常忙碌,因为这个列表包括 Kubernetes 控制平面组件、Calico 组件和 Longhorn 组件。如果在所有节点上运行此命令,并且整理出各个容器在哪里运行以及其目的,这将会让人感到困惑。幸运的是,kubectl 提供了更清晰的视图,尽管知道我们可以深入到这些底层细节,准确查看在某个节点上运行的容器是什么,还是很有用的。

要使用 kubectl 探索集群,我们需要知道集群资源是如何组织到命名空间中的。如前所述,Kubernetes 命名空间提供安全性并避免名称冲突。为了确保幂等性,Kubernetes 需要每个资源都有一个唯一的名称。通过将资源划分到命名空间中,我们允许多个资源具有相同的名称,同时仍然使 API 服务器能够确切地知道我们指的是什么资源,这也支持多租户,这是我们的一个跨切面问题。

即使我们刚刚设置了集群,它已经填充了几个命名空间:

root@host01:~# kubectl get namespaces
NAME              STATUS   AGE
calico-system     Active   50m
default           Active   150m
kube-node-lease   Active   150m
kube-public       Active   150m
kube-system       Active   150m
longhorn-system   Active   16m
tigera-operator   Active   50m

当我们运行 kubectl 命令时,它们将应用于 default 命名空间,除非我们使用 -n 选项来指定不同的命名空间。

要查看哪些容器正在运行,我们可以使用 kubectl 获取 Pod 列表。我们将在第七章中更详细地查看 Kubernetes Pods。现在,只需知道 Pod 是一个或多个容器的集合,类似于我们在第一部分中使用 crictl 创建的 Pods。

如果我们尝试列出 default 命名空间中的 Pods,我们可以看到目前还没有任何 Pods:

root@host01:~# kubectl get pods
No resources found in default namespace.

到目前为止,当我们安装集群基础设施组件时,它们都被创建在其他命名空间中。这样,当我们配置普通用户帐户时,可以防止这些用户查看或编辑集群基础设施。Kubernetes 基础设施组件都被安装到了 kube-system 命名空间:

root@host01:~# kubectl -n kube-system get pods
NAME                             READY   STATUS    ...
coredns-558bd4d5db-7krwr         1/1     Running   ...
...
kube-apiserver-host01            1/1     Running   ...
...

我们在第十一章中讨论了控制平面组件。现在,让我们先探索其中一个控制平面 Pod——运行在 host01 上的 API 服务器。我们可以使用 kubectl describe 获取此 Pod 的所有详细信息:

root@host01:~# kubectl -n kube-system describe pod kube-apiserver-host01
Name:                 kube-apiserver-host01
Namespace:            kube-system
...
Node:                 host01/192.168.61.11
...
Status:               Running
Containers:
  kube-apiserver:
    Container ID:  containerd://26929cde3a264e...
...

命名空间和名称共同唯一标识这个 Pod。我们还可以看到 Pod 所在的节点、其状态以及关于实际容器的详细信息,包括一个容器 ID,我们可以使用 crictl 来找到底层 containerd 运行时中的容器。

让我们还验证一下 Calico 是否按预期部署到集群中:

root@host01:~# kubectl -n calico-system get pods
NAME                                       READY   STATUS    ...
calico-kube-controllers-7f58dbcbbd-ch7zt   1/1     Running   ...
calico-node-cp88k                          1/1     Running   ...
calico-node-dn4rj                          1/1     Running   ...
calico-node-xnkmg                          1/1     Running   ...
calico-node-zfscp                          1/1     Running   ...
calico-typha-68b99cd4bf-7lwss              1/1     Running   ...
calico-typha-68b99cd4bf-jjdts              1/1     Running   ...
calico-typha-68b99cd4bf-pjr6q              1/1     Running   ...

之前我们看到 Calico 安装了一个 DaemonSet 资源。Kubernetes 使用这个 DaemonSet 中的配置,自动为每个节点创建一个 calico-node Pod。像 Kubernetes 本身一样,Calico 也使用一个独立的控制平面来处理网络的整体配置,而其他 Pods 则提供该控制平面。

最后,我们将查看为 Longhorn 运行的容器:

root@host01:~# kubectl -n longhorn-system get pods
NAME                                       READY   STATUS    RESTARTS   AGE
engine-image-ei-611d1496-8q58f             1/1     Running   0          31m
...
longhorn-csi-plugin-8vkr6                  2/2     Running   0          31m
...
longhorn-manager-dl9sb                     1/1     Running   1          32m
...

与 Calico 类似,Longhorn 使用 DaemonSets,使其能够在每个节点上运行容器。这些容器为节点上的其他容器提供存储服务。Longhorn 还包括许多其他容器,它们作为控制平面运行,包括提供 Kubernetes 用来指示 Longhorn 在需要时创建存储的 CSI 实现。

我们花了很多精力来设置这个集群,因此,如果在本章结束时没有至少运行一个应用程序,那将是非常可惜的。在下一章中,我们将探讨多种不同的运行容器方式,但我们先在 Kubernetes 集群中快速运行一个简单的 NGINX 网络服务器:

root@host01:~# kubectl run nginx --image=nginx
pod/nginx created

这看起来像一个命令式的指令,但实际上,kubectl 正在使用我们指定的名称和容器镜像创建一个 Pod 资源,并将该资源应用于集群。让我们再次查看默认的命名空间:

root@host01:~# kubectl get pods -o wide
NAME    READY   STATUS    ... IP               NODE  ...
nginx   1/1     Running   ... 172.31.89.203   host02 ...

我们使用了 -o wide 来查看关于 Pod 的额外信息,包括其 IP 地址和调度位置,这些每次创建 Pod 时可能会不同。在这个例子中,Pod 被调度到了 host02,这表明我们成功地允许常规应用容器部署到我们的控制平面节点。IP 地址来自我们配置的 Pod CIDR,并由 Calico 自动分配。

Calico 还处理路由流量,以便我们可以从集群中的任何容器以及从主机网络访问 Pod。让我们验证这一点,从一个常规的 ping 开始:

root@host01:~# ping -c 1 172.31.89.203
PING 172.31.89.203 (172.31.89.203) 56(84) bytes of data.
64 bytes from 172.31.89.203: icmp_seq=1 ttl=63 time=0.848 ms

--- 172.31.89.203 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.848/0.848/0.848/0.000 ms

在此处替换为您 Pod 的 IP 地址。

我们还可以使用 curl 来验证 NGINX 网络服务器是否正常工作:

root@host01:~# curl http://172.31.89.203
...
<title>Welcome to nginx!</title>
...

Kubernetes 集群已经正常工作,准备好供我们部署应用。Kubernetes 将利用集群中的所有节点来负载均衡我们的应用,并在发生故障时提供弹性。

最后的思考

在这一章中,我们探讨了 Kubernetes 的架构,具备灵活性,允许集群组件随时加入或退出。这不仅适用于容器化应用程序,也适用于集群组件,包括控制平面微服务以及集群所使用的底层服务器和网络。我们成功地引导启动了一个集群,并动态地向其中添加了节点,配置这些节点接受特定类型的容器,然后使用 Kubernetes 集群本身动态地添加网络和存储驱动程序来运行和监控它们。最后,我们将第一个容器部署到 Kubernetes 集群,允许它自动将容器调度到可用节点上,并通过我们的网络驱动程序从主机网络访问该容器。

现在我们有了一个高可用的集群,接下来可以看看如何将应用程序部署到 Kubernetes。我们将探索一些关键的 Kubernetes 资源,这些资源是创建可扩展、可靠的应用程序所必需的。这个过程将为我们深入探索 Kubernetes 奠定基础,包括了解当我们的应用程序未按预期运行时发生了什么,以及如何调试应用程序或 Kubernetes 集群的问题。

第七章:将容器部署到 Kubernetes

image

现在我们已准备好在工作中的 Kubernetes 集群上运行容器。由于 Kubernetes 提供声明式 API,我们将创建各种资源类型来运行它们,并且会监控集群以查看 Kubernetes 对每种资源类型的处理方式。

不同的容器有不同的使用场景。有些容器可能需要多个相同的实例,并具备自动扩缩容功能,以在负载下表现良好。其他容器可能仅用于执行一次性命令。还有一些容器可能需要固定的顺序,以便选择单个主实例,并提供受控的故障转移到副实例。Kubernetes 为这些使用场景提供了不同的 控制器 资源类型。我们将依次查看每个控制器,但我们将从最基本的资源——Pod 开始,它被所有这些使用场景所利用。

Pods

Pod 是 Kubernetes 中最基本的资源,是我们运行容器的方式。每个 Pod 可以包含一个或多个容器。Pod 用于提供我们在第二章中看到的进程隔离。Linux 内核命名空间在 Pod 和容器级别得到应用:

mnt 挂载点:每个容器都有自己的根文件系统;其他挂载点对 Pod 中的所有容器都可用。

uts Unix 时间共享:在 Pod 级别进行隔离。

ipc 进程间通信:在 Pod 级别进行隔离。

pid 进程标识符:在容器级别进行隔离。

net 网络:在 Pod 级别进行隔离。

这种方式的最大优势是多个容器可以像同一虚拟主机上的进程一样工作,使用 localhost 地址进行通信,同时基于独立的容器镜像。

部署 Pod

为了开始使用,让我们直接创建一个 Pod。与上一章中我们使用 kubectl run 自动生成 Pod 规格不同,这次我们将直接使用 YAML 文件进行指定,以便完全控制 Pod,并为以后使用控制器创建 Pods 做好准备,从而提供可扩展性和故障转移能力。

注意

本书的示例仓库在 github.com/book-of-kubernetes/examples有关设置详细信息,请参见“运行示例”部分,位于第 xx 页。

本章的自动化脚本执行完整的集群安装,包含三个节点,运行控制平面和常规应用,提供最小的高可用集群用于测试。自动化还会创建一些 Kubernetes 资源的 YAML 文件。以下是一个基本的 YAML 资源,用于创建运行 NGINX 的 Pod:

nginx-pod.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  containers:
  - name: nginx
    image: nginx

Pod 是 核心 Kubernetes API 的一部分,因此我们只需为 apiVersion 指定 v1 的版本号。指定 Pod 作为 kind 可以告诉 Kubernetes 我们在 API 组中创建的资源类型。我们将在所有 Kubernetes 资源中看到这些字段。

metadata字段有许多用途。对于 Pod,我们只需要提供一个必需的字段——name。我们没有在 metadata 中指定namespace,因此默认情况下,这个 Pod 将被放入default命名空间。

剩下的字段spec告诉 Kubernetes 运行此 Pod 所需的一切。目前,我们提供的是最基本的信息,即要运行的容器列表,但还有许多其他选项可供选择。在这种情况下,我们只有一个容器,因此我们只提供 Kubernetes 应该使用的容器名称和镜像。

让我们将这个 Pod 添加到集群中。自动化将文件添加到了/opt,因此我们可以在host01上按如下方式操作:

root@host01:~# kubectl apply -f /opt/nginx-pod.yaml
pod/nginx created

在清单 7-1 中,我们可以查看 Pod 的状态。

root@host01:~# kubectl get pods -o wide
NAME    READY   STATUS    RESTARTS   AGE     IP               NODE   ...
nginx   1/1     Running   0          2m26s   172.31.25.202   host03 ...

清单 7-1:NGINX 状态

在 Pod 显示为Running之前可能需要一些时间,特别是如果你刚刚设置了 Kubernetes 集群,它仍在忙于部署核心组件。不断尝试这个kubectl命令以检查状态。

为了避免多次输入kubectl命令,你也可以使用watchwatch命令是观察集群随时间变化的一个好方法。只需在命令前加上watch,它将每两秒钟自动执行一次。

我们在命令中添加了-o wide选项,以查看此 Pod 的 IP 地址和节点分配。Kubernetes 会为我们管理这些信息。在这种情况下,Pod 被调度到了host03,所以我们需要去那里查看正在运行的容器:

root@host03:~# crictl pods --name nginx
POD ID         CREATED         STATE  NAME   NAMESPACE  ...
9f1d6e0207d7e  19 minutes ago  Ready  nginx  default    ...

在 NGINX Pod 所在的主机上运行此命令。

如果我们收集了 Pod ID,我们还可以看到容器:

root@host03:~# POD_ID=$(crictl pods -q --name nginx)
root@host03:~# crictl ps --pod $POD_ID
CONTAINER      IMAGE          CREATED         STATE    NAME   ...
9da09b3671418  4cdc5dd7eaadf  20 minutes ago  Running  nginx  ...

这个输出看起来非常类似于清单 7-1 中的kubectl get命令输出,这并不令人惊讶,因为我们的集群是通过在该节点上运行的kubelet服务获取这些信息的,而kubelet服务又使用与crictl相同的容器运行时接口(CRI)API 与容器引擎进行通信。

Pod 详情和日志

使用crictl与底层容器引擎一起探查集群中运行的容器非常有价值,但它确实要求我们连接到运行该容器的特定主机。大多数时候,我们可以通过使用kubectl命令连接到集群的 API 服务器,从任何地方检查 Pod,从而避免这一点。让我们回到host01,进一步探查 NGINX Pod。

在第六章中,我们看到如何使用kubectl describe来查看集群节点的状态和事件日志。我们可以使用相同的命令查看其他 Kubernetes 资源的状态和配置详情。以下是我们 NGINX Pod 的事件日志:

 root@host01:~# kubectl describe pod nginx
 Name:         nginx
 Namespace: ➊ default 
 ...
 Containers:
   nginx:
     Container ID:   containerd://9da09b3671418...
 ...
➋ Type    Reason     Age   From               Message
   ----    ------     ----  ----               -------
   Normal  Scheduled  22m   default-scheduler  Successfully assigned ...
   Normal  Pulling    22m   kubelet            Pulling image "nginx"
   Normal  Pulled     21m   kubelet            Successfully pulled image ...
   Normal  Created    21m   kubelet            Created container nginx
   Normal  Started    21m   kubelet            Started container nginx

我们可以使用kubectl describe查看许多不同的 Kubernetes 资源,因此我们首先告诉kubectl我们关注的是一个 Pod,并提供 Pod 的名称。因为我们没有指定命名空间,Kubernetes 将默认在default命名空间中查找该 Pod ➊。

注意

我们在本书中的大多数示例使用默认命名空间,以减少输入,但使用多个命名空间来将应用分开是一个好习惯,这样可以避免命名冲突并管理访问控制。我们将在 第十一章 中更详细地讨论命名空间。

kubectl describe 命令的输出提供了事件日志 ➋,这是在启动容器遇到问题时,第一个需要查看的地方。

Kubernetes 在部署容器时需要经过几个步骤。首先,它需要将容器调度到一个节点上,这要求该节点可用且具备足够的资源。然后,控制权转交给该节点上的kubelet,它需要与容器引擎交互,拉取镜像,创建容器并启动它。

容器启动后,kubelet 会收集标准输出和标准错误。我们可以使用 kubectl logs 命令查看这些输出:

root@host01:~# kubectl logs nginx
...
2021/07/13 22:37:03 [notice] 1#1: start worker processes
2021/07/13 22:37:03 [notice] 1#1: start worker process 33
2021/07/13 22:37:03 [notice] 1#1: start worker process 34

kubectl logs 命令始终指向一个 Pod,因为 Pod 是运行容器的基本资源,而我们的 Pod 只有一个容器,所以我们只需要将 Pod 的名称作为一个参数传递给 kubectl logs。和之前一样,Kubernetes 会在 default 命名空间中查找,因为我们没有指定命名空间。

即使容器已经退出,容器输出仍然可用,因此如果容器被拉取并成功启动后崩溃,kubectl logs 命令是查看日志的地方。当然,我们希望容器打印出一条日志消息,解释为何崩溃。在 第十章 中,我们将讨论如果容器无法启动且没有日志消息时该怎么办。

我们已经完成了 NGINX Pod 的操作,现在让我们清理它:

root@host01:~# kubectl delete -f /opt/nginx-pod.yaml
pod "nginx" deleted

我们可以使用相同的 YAML 配置文件删除 Pod,这在我们将多个 Kubernetes 资源定义在同一个文件中时非常方便,因为一个命令就能删除所有资源。kubectl 命令使用文件中定义的每个资源的名称来执行删除操作。

部署

要运行一个容器,我们需要一个 Pod,但这并不意味着我们通常希望直接创建 Pod。当我们直接创建 Pod 时,我们无法获得 Kubernetes 提供的可扩展性和故障转移功能,因为 Kubernetes 只会运行 Pod 的一个实例。这个 Pod 只会在创建时分配给一个节点,即使该节点发生故障,也不会重新分配。

为了获得可扩展性和故障转移,我们需要创建一个控制器来管理 Pod。我们将介绍多种可以运行 Pods 的控制器,但让我们先从最常见的 Deployment 开始。

创建一个 Deployment

Deployment 管理一个或多个 完全相同 的 Kubernetes Pods。当我们创建一个 Deployment 时,我们提供一个 Pod 模板。Deployment 然后借助 ReplicaSet 创建与该模板匹配的 Pods。

DEPLOYMENTS 和 REPLICASETS

Kubernetes 随着时间的发展,逐步演化了其控制器资源。第一种类型的控制器,ReplicationController,仅提供了基本功能。它被 ReplicaSet 所取代,后者在识别要管理的 Pod 方面进行了改进。

替换 ReplicationControllers 为 ReplicaSets 的部分原因是 ReplicationControllers 变得越来越复杂,使得代码难以维护。新的方法将控制器的责任分拆给 ReplicaSets 和 Deployments。ReplicaSets 负责基本的 Pod 管理,包括监控 Pod 状态和执行故障切换。Deployments 则负责跟踪由于配置更改或容器镜像更新而导致的 Pod 模板的变化。Deployments 和 ReplicaSets 共同工作,但 Deployment 会创建自己的 ReplicaSet,因此我们通常只需要与 Deployments 交互。出于这个原因,我通常使用Deployment这个术语泛指 ReplicaSet 提供的功能,例如监控 Pod 并提供所请求的副本数量。

这是我们将用来创建 NGINX Deployment 的 YAML 文件:

nginx-deploy.yaml

---
 kind: Deployment
 apiVersion: apps/v1 
 metadata:
➊ name: nginx 
 spec:
   replicas: 3 
   selector: 
     matchLabels:
       app: nginx
   template:
     metadata:
    ➋ labels:
         app: nginx
  ➌ spec:   
       containers:
       - name: nginx
         image: nginx
      ➍ resources:
           requests:
             cpu: "100m"

Deployments 位于apps API 组中,因此我们为apiVersion指定apps/v1。像每个 Kubernetes 资源一样,我们需要提供一个唯一的名称 ➊,以便将这个 Deployment 与我们可能创建的其他 Deployment 区分开来。

Deployment 规格包含几个重要字段,我们来详细看看它们。replicas字段告诉 Kubernetes 我们想要多少个相同的 Pod 实例。Kubernetes 将努力保持这数量的 Pod 在运行。下一个字段,selector,用于使 Deployment 能够找到它的 Pod。matchLabels的内容必须与template.metadata.labels字段 ➋中的内容完全匹配,否则 Kubernetes 将拒绝该 Deployment。

最后,template.spec ➌的内容将作为此 Deployment 创建的任何 Pod 的spec。这里的字段可以包括我们为 Pod 提供的任何配置。此配置与我们之前查看的nginx-pod.yaml相匹配,不同之处在于我们添加了一个 CPU 资源请求 ➍,以便以后可以配置自动扩缩容。

让我们从这个 YAML 资源文件创建我们的 Deployment:

root@host01:~# kubectl apply -f /opt/nginx-deploy.yaml
deployment.apps/nginx created

我们可以使用kubectl get跟踪 Deployment 的状态:

root@host01:~# kubectl get deployment nginx
NAME    READY   UP-TO-DATE   AVAILABLE   AGE
nginx   3/3     3            3           4s

当 Deployment 完全启动时,它将报告已准备好并可用的三个副本,这意味着我们现在有三个由这个 Deployment 管理的独立 NGINX Pod:

root@host01:~# kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-6799fc88d8-6vn44   1/1     Running   0          18s
nginx-6799fc88d8-dcwx5   1/1     Running   0          18s
nginx-6799fc88d8-sh8qs   1/1     Running   0          18s

每个 Pod 的名称以 Deployment 的名称开头。Kubernetes 会添加一些随机字符来构建 ReplicaSet 的名称,然后再加上更多随机字符,以确保每个 Pod 都有唯一的名称。我们不需要直接创建或管理 ReplicaSet,但可以使用kubectl get来查看它:

root@host01:~# kubectl get replicasets
NAME               DESIRED   CURRENT   READY   AGE
nginx-6799fc88d8   3         3         3       30s

尽管我们通常只与 Deployments 交互,但了解 ReplicaSet 仍然很重要,因为在创建 Pod 时遇到的一些特定错误只会在 ReplicaSet 事件日志中报告。

nginx 前缀在 ReplicaSet 和 Pod 名称中纯粹是为了方便。Deployment 不使用名称来与 Pods 匹配。相反,它使用选择器来匹配 Pod 上的标签。如果我们在其中一个 Pod 上运行kubectl describe,就能看到这些标签:

root@host01:~# kubectl describe pod nginx-6799fc88d8-6vn44
Name:         nginx-6799fc88d8-6vn44
Namespace:    default
...
Labels:       app=nginx
...

这与 Deployment 的选择器匹配:

root@host01:~# kubectl describe deployment nginx
Name:                   nginx
Namespace:              default
...
Selector:               app=nginx
...

Deployment 查询 API 服务器以识别与其选择器匹配的 Pods。而 Deployment 使用程序化 API,下面的kubectl get命令生成了类似的 API 服务器查询,给我们一个了解其工作原理的机会:

root@host01:~# kubectl get all -l app=nginx
NAME                     READY   STATUS    RESTARTS   AGE
nginx-6799fc88d8-6vn44   1/1     Running   0          69s
nginx-6799fc88d8-dcwx5   1/1     Running   0          69s
nginx-6799fc88d8-sh8qs   1/1     Running   0          69s

NAME                               DESIRED   CURRENT   READY   AGE
replicaset.apps/nginx-6799fc88d8   3         3         3       69s

在这种情况下,使用kubectl get all可以列出多种不同类型的资源,只要它们与选择器匹配。因此,我们不仅能看到三个 Pods,还能看到 Deployment 为管理这些 Pods 而创建的 ReplicaSet。

看起来可能有些奇怪,Deployment 使用选择器而不是仅仅跟踪它创建的 Pods。然而,这种设计使得 Kubernetes 更容易自我修复。在任何时候,Kubernetes 节点可能会掉线,或者我们可能会遇到网络分割,期间某些控制节点与集群失去连接。如果一个节点重新上线,或者集群在网络分割后需要重新组合,Kubernetes 必须能够查看所有运行中的 Pods 的当前状态,并找出需要进行哪些更改以实现所需的状态。这可能意味着,当由于节点断开连接导致 Deployment 启动了一个额外的 Pod 时,在该节点重新连接时,Deployment 需要关闭一个 Pod,以便集群能够保持适当数量的副本。使用选择器避免了 Deployment 需要记住它曾创建过的所有 Pods,即使是那些在失败节点上的 Pods。

监控与扩展

因为 Deployment 正在监视它的 Pods,以确保我们有正确数量的副本,所以我们可以删除一个 Pod,它会被自动重新创建:

root@host01:~# kubectl delete pod nginx-6799fc88d8-6vn44
pod "nginx-6799fc88d8-6vn44" deleted
root@host01:~# kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-6799fc88d8-dcwx5   1/1     Running   0          3m52s
nginx-6799fc88d8-dtddk   1/1     Running   0        ➊ 14s
nginx-6799fc88d8-sh8qs   1/1     Running   0          3m52s

一旦旧的 Pod 被删除,Deployment 就会创建一个新的 Pod ➊。类似地,如果我们更改 Deployment 的副本数量,Pods 会自动更新。让我们再添加一个副本:

root@host01:~# kubectl scale --replicas=4 deployment nginx
deployment.apps/nginx scaled
root@host01:~# kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-6799fc88d8-dcwx5   1/1     Running   0          8m22s
nginx-6799fc88d8-dtddk   1/1     Running   0          4m44s
nginx-6799fc88d8-kk7r6   1/1     Running   0        ➊ 5s 
nginx-6799fc88d8-sh8qs   1/1     Running   0          8m22s

第一个命令将副本数量设置为四个。因此,Kubernetes 需要启动一个新的相同 Pod 来满足我们请求的数量 ➊。我们可以通过更新 YAML 文件并重新运行kubectl apply来扩展 Deployment,或者我们可以使用kubectl scale命令直接编辑 Deployment。无论哪种方式,这都是一种声明式方法;我们在更新 Deployment 的资源声明;然后,Kubernetes 会更新集群的实际状态以使其匹配。

同样,缩小 Deployment 会导致 Pods 被自动删除:

root@host01:~# kubectl scale --replicas=2 deployment nginx
deployment.apps/nginx scaled
root@host01:~# kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-6799fc88d8-dcwx5   1/1     Running   0          10m
nginx-6799fc88d8-sh8qs   1/1     Running   0          10m

当我们缩小时,Kubernetes 会选择两个 Pods 进行终止。这些 Pods 需要一些时间来完成关闭,届时我们只会有两个 NGINX Pods 在运行。

自动扩展

对于正在接收用户真实请求的应用程序,我们会选择必要的副本数量以提供高质量的应用程序,同时在可能的情况下缩减副本数量,以减少应用程序使用的资源。当然,我们的应用程序负载是不断变化的,持续监控应用程序的每个组件并独立地进行缩放会很繁琐。相反,我们可以让集群为我们执行监控和缩放工作,使用 HorizontalPodAutoscaler。这里的 horizontal 术语仅指自动缩放器可以更新由控制器管理的同一 Pod 的副本数量。

要配置自动缩放,我们创建一个新的资源,引用我们的部署。然后,集群监控 Pod 使用的资源,并根据需要重新配置部署。我们可以使用 kubectl autoscale 命令将 HorizontalPodAutoscaler 添加到我们的部署中,但使用 YAML 资源文件可以将自动缩放配置保持在版本控制下,这样更好。以下是 YAML 文件:

nginx-scaler.yaml

   ---
➊ apiVersion: autoscaling/v2
   kind: HorizontalPodAutoscaler
   metadata:
     name: nginx
     labels:
       app: nginx
   spec:
  ➋ scaleTargetRef:
       apiVersion: apps/v1
       kind: Deployment
       name: nginx
  ➌ minReplicas: 1
     maxReplicas: 10
     metrics:
       - type: Resource
         resource:
           name: cpu
           target:
             type: Utilization
             averageUtilization: ➍ 50

metadata 字段中,我们添加了标签 app: nginx。这不会改变资源的行为;其唯一目的是确保如果我们在 kubectl get 命令中使用 app=nginx 标签选择器时,这个资源能够显示出来。通过一致的元数据标记应用程序组件的这种方式是一个好习惯,有助于他人理解哪些资源是相关的,并且使调试更容易。

这个 YAML 配置使用了版本 2 的自动缩放器配置 ➊。提供新的 API 资源组版本是 Kubernetes 在不失去任何向后兼容性的情况下支持未来功能的方式。通常,在最终配置发布之前,会发布 alpha 和 beta 版本的资源组,并且 beta 版本与最终版本之间至少有一个版本重叠,以支持无缝升级。

自动缩放器的版本 2 支持多个资源。每个资源用于计算对所需 Pod 数量的投票,最大数值将胜出。支持多个资源需要改变 YAML 布局,这是 Kubernetes 维护者创建新资源版本的常见原因。

我们使用 NGINX 部署 ➋ 的 API 资源组、类型和名称将其指定为自动缩放器的目标,这些足以唯一标识 Kubernetes 集群中的任何资源。然后,我们告诉自动缩放器监控属于该部署的 Pod 的 CPU 使用率 ➍。自动缩放器将努力保持 Pod 的平均 CPU 使用率接近 50%,并根据需要进行扩展或缩减。然而,副本数将永远不会超过我们指定的范围 ➌。

让我们使用此配置创建自动缩放器:

root@host01:~# kubectl apply -f /opt/nginx-scaler.yaml
horizontalpodautoscaler.autoscaling/nginx created

我们可以查询集群,查看它是否已被创建:

root@host01:~# kubectl get hpa
NAME    REFERENCE          TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
nginx   Deployment/nginx   0%/50%    1         10        3          96s

输出显示了自动伸缩器的目标引用、当前和期望的资源利用率,以及副本的最大值、最小值和当前值。

我们使用 hpa 作为 horizontalpodautoscaler 的缩写。Kubernetes 允许我们使用单数或复数名称,并为大多数资源提供缩写,以节省输入。例如,我们可以输入 deploy 来代替 deployment,甚至可以输入 po 来代替 pods。每一个额外的击键都很重要!

自动伸缩器使用 kubelet 已经从容器引擎收集的 CPU 利用率数据。这个数据由我们作为集群附加组件安装的指标服务器集中管理。如果没有这个集群附加组件,就不会有利用率数据,自动伸缩器也不会对部署进行任何更改。在这种情况下,因为我们实际上没有使用我们的 NGINX 服务器实例,它们没有消耗任何 CPU,部署被缩减到一个 Pod,即我们指定的最小值:

root@host01:~# kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-6799fc88d8-dcwx5   1/1     Running   0          15m

自动伸缩器已计算出只需要一个 Pod,并已将部署调整到匹配的规模。然后,部署选择一个 Pod 终止,以达到所需的规模。

为了准确性,自动伸缩器不会使用最近才启动的 Pod 的 CPU 数据,并且它有逻辑来防止过于频繁地进行缩放,因此如果你快速完成了这些示例,你可能需要等待几分钟才能看到其缩放。

我们将在 第十四章 中更详细地探讨 Kubernetes 资源利用率指标。

其他控制器

部署是最通用和最常用的控制器,但 Kubernetes 还有其他一些有用的选项。在本节中,我们将探讨 JobCronJobStatefulSets 以及 DaemonSets

作业和定时作业

部署非常适合应用组件,因为我们通常希望一个或多个实例持续运行。然而,对于需要运行命令的情况,无论是一次性运行还是按计划运行,我们可以使用 Job。主要区别在于,部署确保任何停止运行的容器都会重启,而 Job 可以检查主进程的退出代码,仅当退出代码非零时才会重启,表示失败。

作业定义与部署非常相似:

sleep-job.yaml

---
apiVersion: batch/v1
kind: Job
metadata:
  name: sleep
spec:
  template:
    spec:
      containers:
      - name: sleep
        image: busybox
        command: 
          - "/bin/sleep"
          - "30"
      restartPolicy: OnFailure

restartPolicy 可以设置为 OnFailure,此时容器将在退出代码为非零时重启,或者设置为 Never,此时无论退出代码是什么,容器退出后作业将完成。

我们可以创建并查看作业及其创建的 Pod:

root@host01:~# kubectl apply -f /opt/sleep-job.yaml
job.batch/sleep created
root@host01:~# kubectl get job
NAME    COMPLETIONS   DURATION   AGE
sleep   0/1           3s         3s
root@host01:~# kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
...
sleep-fgcnz              1/1     Running   0          10s

作业根据 YAML 文件中提供的规范创建了一个 Pod。作业反映出 0/1 的完成状态,因为它正在等待其 Pod 成功退出。

当 Pod 运行了 30 秒后,它以零代码退出,表示成功,并且作业和 Pod 状态相应更新:

root@host01:~# kubectl get jobs
NAME    COMPLETIONS   DURATION   AGE
sleep   1/1           31s        40s
root@host01:~# kubectl get pods
NAME                     READY   STATUS      RESTARTS   AGE
nginx-65db7cf9c9-2wcng   1/1     Running     0          31m
sleep-fgcnz              0/1     Completed   0          43s

Pod 仍然可用,这意味着我们可以查看其日志(如果需要的话),但它显示为 Completed 状态,因此 Kubernetes 不会尝试重新启动已退出的容器。

CronJob 是一种按照计划创建 Jobs 的控制器。例如,我们可以设置我们的 sleep Job 每天运行一次:

sleep-cronjob.yaml

 ---
 apiVersion: batch/v1
 kind: CronJob
 metadata:
   name: sleep
 spec:
➊ schedule: "0 3 * * *"
➋ jobTemplate: 
   spec:
     template:
       spec:
         containers:
           - name: sleep
             image: busybox
             command: 
               - "/bin/sleep"
               - "30"
         restartPolicy: OnFailure

Job 规格的全部内容都嵌入在 jobTemplate 字段 ➋ 中。然后,我们添加一个遵循 Unix cron 命令标准格式的 schedule ➊。在这个例子中,0 3 * * * 表示 Job 应该在每天凌晨 3 点创建。

Kubernetes 的设计原则之一是任何东西都可能随时发生故障。对于 CronJob,如果集群在 Job 计划执行的时间遇到问题,Job 可能不会按计划执行,或者可能会被执行两次。这意味着你应该小心编写幂等的 Job,以便它们能够处理缺失或重复的调度。

如果我们创建这个 CronJob

root@host01:~# kubectl apply -f /opt/sleep-cronjob.yaml 
cronjob.batch/sleep created

它现在已存在于集群中,但不会立即创建 Job 或 Pod:

root@host01:~# kubectl get jobs
NAME    COMPLETIONS   DURATION   AGE
sleep   1/1           31s        2m32s
root@host01:~# kubectl get pods
NAME                     READY   STATUS      RESTARTS   AGE
nginx-65db7cf9c9-2wcng   1/1     Running     0          33m
sleep-fgcnz              0/1     Completed   0          2m23s

相反,每当 CronJob 的调度被触发时,它将创建一个新的 Job。

StatefulSets

到目前为止,我们已经看过一些创建相同 Pods 的控制器。对于 Deployments 和 Jobs,我们并不在乎哪个 Pod 是哪个,或者它部署在哪里,只要我们在正确的时间运行足够的实例。然而,这并不总是符合我们所需的行为。例如,尽管 Deployment 可以创建具有持久存储的 Pods,但存储必须是为每个新的 Pod 创建一个全新的存储,或者同一个存储必须在所有 Pods 之间共享。这与“主从”架构(例如数据库)并不完全匹配。对于这些情况,我们希望将特定的存储附加到特定的 Pods 上。

同时,由于 Pod 可能因硬件故障或升级而来去变化,我们需要一种方法来管理 Pod 的替换,以确保每个 Pod 都附加到正确的存储上。这就是 StatefulSet 的目的。StatefulSet 通过编号(从零开始)标识每个 Pod,并为每个 Pod 分配相应的持久存储。当 Pod 必须被替换时,新 Pod 会被分配相同的数字标识符,并附加到相同的存储上。Pods 可以查看它们的主机名来确定其标识符,因此 StatefulSet 对于需要固定主实例的情况以及动态选择主实例的情况都很有用。

在接下来的几章中,我们将深入探讨 Kubernetes StatefulSets 的更多细节,包括持久存储和服务。对于这一章,我们将查看 StatefulSet 的一个基本示例,然后在引入其他重要概念时进一步扩展。

对于这个简单的示例,让我们创建两个 Pods,并展示它们如何获得独特的存储,这些存储即使 Pod 被替换也会保持不变。我们将使用这个 YAML 资源:

sleep-set.yaml

 ---
 apiVersion: apps/v1
 kind: StatefulSet
 metadata:
    name: sleep
 spec:
➊ serviceName: sleep 
   replicas: 2
   selector:
     matchLabels:
       app: sleep
   template:
     metadata:
       labels:
         app: sleep
     spec:
       containers:
         - name: sleep
           image: busybox
           command: 
             - "/bin/sleep"
             - "3600"
        ➋ volumeMounts: 
             - name: sleep-volume
               mountPath: /storagedir
➌ volumeClaimTemplates: 
     - metadata:
         name: sleep-volume
       spec:
         storageClassName: longhorn
         accessModes:
           - ReadWriteOnce
         resources:
           requests:
             storage: 10Mi

与 Deployment 或 Job 相比,这里有一些重要的不同之处。首先,我们必须声明一个 serviceName,将这个 StatefulSet 绑定到 Kubernetes Service ➊。这个连接用于为每个 Pod 创建一个 DNS(域名服务)条目。我们还必须提供一个模板,供 StatefulSet 用来请求持久化存储 ➌,然后告诉 Kubernetes 在我们的容器中挂载该存储 ➋。

实际的 sleep-set.yaml 文件是自动化脚本安装的,其中包含 sleep 服务定义。我们将在第九章中详细讲解服务。

让我们创建 sleep StatefulSet:

root@host01:~# kubectl apply -f /opt/sleep-set.yaml

StatefulSet 创建了两个 Pods:

root@host01:~# kubectl get statefulsets
NAME    READY   AGE
sleep   2/2     1m14s
root@host01:~# kubectl get pods
NAME      READY   STATUS    RESTARTS   AGE
...
sleep-0   1/1     Running   0          57s
sleep-1   1/1     Running   0          32s

每个 Pod 的持久化存储是全新的,因此它开始时是空的。让我们创建一些内容。最简单的方法是通过容器内部,使用 kubectl exec 命令,它允许我们在容器内运行命令,类似于 crictlkubectl exec 命令无论容器在哪个主机上运行都能工作,即使我们从集群外部连接到 Kubernetes API 服务器也是如此。

让我们将每个容器的主机名写入文件并打印出来,以便验证它是否成功:

root@host01:~# kubectl exec sleep-0 -- /bin/sh -c \
  'hostname > /storagedir/myhost'
root@host01:~# kubectl exec sleep-0 -- /bin/cat /storagedir/myhost
sleep-0
root@host01:~# kubectl exec sleep-1 -- /bin/sh -c \
  'hostname > /storagedir/myhost'
root@host01:~# kubectl exec sleep-1 -- /bin/cat /storagedir/myhost
sleep-1

现在我们的每个 Pod 在其持久化存储中都有独特的内容。让我们删除其中一个 Pod,并验证它的替代 Pod 是否继承了前一个 Pod 的存储:

root@host01:~# kubectl delete pod sleep-0
pod "sleep-0" deleted
root@host01:~# kubectl get pods
NAME      READY   STATUS    RESTARTS   AGE
...
sleep-0   1/1     Running   0          28s
sleep-1   1/1     Running   0          8m18s
root@host01:~# kubectl exec sleep-0 -- /bin/cat /storagedir/myhost
sleep-0

删除 sleep-0 后,我们看到一个新 Pod 被创建,且其名称与之前不同,这与 Deployment 不同,因为 Deployment 为每个新 Pod 生成一个随机名称。此外,对于这个新 Pod,我们之前创建的文件仍然存在,因为 StatefulSet 在删除旧 Pod 时,将相同的持久存储附加到了它创建的新 Pod 上。

Daemon Sets

DaemonSet 控制器类似于 StatefulSet,DaemonSet 也运行特定数量的 Pods,每个 Pod 都有独特的身份。然而,DaemonSet 每个节点只运行一个 Pod,这主要对集群的控制平面和附加组件(如网络或存储插件)非常有用。

我们的集群已经安装了多个 DaemonSets,因此让我们来看一下已经在运行的 calico-node DaemonSet,它在每个节点上运行,为该节点上的所有容器提供网络配置。

calico-node DaemonSet 位于 calico-system 命名空间,因此我们将指定该命名空间来请求有关 DaemonSet 的信息:

root@host01:~# kubectl -n calico-system get daemonsets
NAME          DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   ...
calico-node   3         3         3       3            3           ...

我们的集群有三个节点,因此 calico-node DaemonSet 创建了三个实例。以下是该 DaemonSet 的 YAML 格式配置:

root@host01:~# kubectl -n calico-system get daemonset calico-node -o yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
...
  name: calico-node
  namespace: calico-system
...
spec:
...
  selector:
    matchLabels:
      k8s-app: calico-node
...

-o yaml 参数用于 kubectl get 命令,输出一个或多个资源的配置和状态,格式为 YAML,这样我们可以详细检查 Kubernetes 资源。

这个 DaemonSet 的选择器期望标签 k8s-app 设置为 calico-node。我们可以使用它来仅显示此 DaemonSet 创建的 Pods:

root@host01:~# kubectl -n calico-system get pods \
  -l k8s-app=calico-node -o wide
NAME                READY   STATUS   ... NODE   ...
calico-node-h9kjh   1/1     Running  ... host01 ...
calico-node-rcfk7   1/1     Running  ... host03 ...
calico-node-wj876   1/1     Running  ... host02 ...

DaemonSet 已创建了三个 Pods,每个 Pod 都被分配到了我们集群中的一个节点。如果我们向集群中添加更多节点,DaemonSet 也会在新节点上调度一个 Pod。

最后的思考

本章从普通集群用户的角度探讨了 Kubernetes,创建控制器进而创建带有容器的 Pods。掌握这些控制器资源类型的核心知识对于构建我们的应用程序至关重要。与此同时,重要的是要记住,Kubernetes 使用了我们在第一部分中探讨过的容器技术。

容器技术的一个关键方面是能够将容器隔离在不同的网络命名空间中。在 Kubernetes 集群中运行容器增加了网络方面的额外要求,因为我们现在需要连接运行在不同集群节点上的容器。在下一章中,我们将考虑多种方法来实现这一目标,并探讨覆盖网络。

第八章:覆盖网络

image

当所有容器都在单个主机上时,容器网络已经足够复杂,正如我们在第四章中看到的那样。当我们扩展到一个包含多个节点的集群时,所有节点都运行容器时,复杂性会大幅增加。我们不仅需要为每个容器提供自己的虚拟网络设备,并管理 IP 地址,动态创建新的网络命名空间和设备,还需要确保一个节点上的容器能够与所有其他节点上的容器进行通信。

在本章中,我们将描述如何使用 覆盖网络 来提供跨 Kubernetes 集群所有节点的单一容器网络的表象。我们将考虑两种不同的方法来路由容器流量穿越主机网络,检查每种方法的网络配置和流量流向。最后,我们将探讨 Kubernetes 如何使用容器网络接口(CNI)标准将网络配置作为一个独立的插件,使其能够轻松切换到新的技术,并在需要时允许自定义解决方案。

集群网络

Kubernetes 集群的基本目标是将一组主机(物理机或虚拟机)视为一个单一的计算资源,可以根据需要分配以运行容器。从网络的角度来看,这意味着 Kubernetes 应该能够将 Pod 调度到任何节点,而不必担心与其他节点上的 Pods 的连接问题。这也意味着 Kubernetes 应该有一种方式,能够动态地为 Pods 分配 IP 地址,以支持集群范围的网络连接性。

正如我们将在本章中看到的,Kubernetes 使用插件设计来允许任何兼容的网络软件分配 IP 地址并提供跨节点的网络连接性。所有插件必须遵循几个重要的规则。首先,Pod 的 IP 地址应该来自一个单一的 IP 地址池,尽管这个池可以按节点细分。这意味着我们可以将所有 Pods 视为一个单一的平面网络,无论 Pods 运行在哪里。其次,流量应该是可路由的,以便所有 Pods 都能看到所有其他 Pods 和控制平面。

CNI 插件

插件通过 CNI 标准与 Kubernetes 集群进行通信,特别是与 kubelet 通信。CNI 规范了 kubelet 如何查找和调用 CNI 插件。当创建一个新的 Pod 时,kubelet 首先分配网络命名空间。然后它调用 CNI 插件,并为其提供网络命名空间的引用。CNI 插件向命名空间添加网络设备,分配 IP 地址,并将该 IP 地址返回给 kubelet

让我们看看这个过程是如何工作的。为了做到这一点,本章的示例包括两种不同的环境和两种不同的 CNI 插件:Calico 和 WeaveNet。这两个插件都为 Pods 提供网络连接,但在跨节点网络方面有所不同。我们将从 Calico 环境开始。

注意

本书的示例仓库位于 github.com/book-of-kubernetes/examples有关设置的详细信息,请参阅第 xx 页中的“运行示例”。

默认情况下,CNI 插件信息保存在 /etc/cni/net.d 目录中。我们可以在该目录中查看 Calico 配置:

root@host01:~# ls /etc/cni/net.d
10-calico.conflist  calico-kubeconfig

文件10-calico.conflist包含实际的 Calico 配置。文件calico-kubeconfig由 Calico 组件用于与控制平面进行身份验证;它是基于在 Calico 安装过程中创建的服务账户生成的。配置文件名前缀为10-,因为kubelet会对它找到的任何配置文件进行排序,并使用第一个文件。

清单 8-1 显示了配置文件,该文件是 JSON 格式,指定了要使用的网络插件。

root@host01:~# cat /etc/cni/net.d/10-calico.conflist 
{
  "name": "k8s-pod-network",
  "cniVersion": "0.3.1",
  "plugins": [
    {
      "type": "calico",
...
    },
    {
      "type": "bandwidth",
      "capabilities": {"bandwidth": true}
    },
    {"type": "portmap", "snat": true, "capabilities": {"portMappings": true}}
  ]
}

清单 8-1:Calico 配置

最重要的字段是type;它指定了要运行的插件。在本例中,我们运行了三个插件:calico,用于处理 Pod 网络;bandwidth,可以用来配置网络限制;以及portmap,用于将容器端口暴露到主机网络。这两个插件通过capabilities字段告知kubelet它们的用途;因此,当kubelet调用它们时,它会传递相关的带宽和端口映射配置,以便插件可以进行必要的网络配置更改。

为了运行这些插件,kubelet需要知道它们的位置。实际插件可执行文件的默认位置是 /opt/cni/bin,插件名称与type字段相匹配。

root@host01:~# ls /opt/cni/bin
bandwidth  calico-ipam  flannel      install   macvlan  sbr     vlan
bridge     dhcp         host-device  ipvlan    portmap  static
calico     firewall     host-local   loopback  ptp      tuning

在这里,我们看到一组常见的网络插件,它们是由kubeadm与我们的 Kubernetes 集群一起安装的。我们还看到了calico,它是由我们在集群初始化后安装的 Calico DaemonSet 添加到该目录中的。

Pod 网络

让我们查看一个示例 Pod,以便了解 CNI 插件如何配置 Pod 的网络命名空间。这个行为与我们在第四章中做的非常相似,通过将虚拟网络设备添加到网络命名空间中,来启用容器之间以及与主机网络之间的通信。

让我们创建一个基本的 Pod:

pod.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: pod
spec:
  containers:
  - name: pod
    image: busybox
    command: 
      - "sleep"
      - "infinity"
  nodeName: host01

我们添加了额外的字段nodeName,强制此 Pod 在host01上运行,这样更容易找到并检查其网络配置。

我们通过常规命令启动 Pod:

root@host01:~# kubectl apply -f /opt/pod.yaml
pod/pod created

接下来,检查它是否正在运行:

root@host01:~# kubectl get pods
NAME   READY   STATUS    RESTARTS   AGE
pod    1/1     Running   0          2m32s

它运行后,我们可以使用crictl捕获它的唯一 ID:

root@host01:~# POD_ID=$(crictl pods --name pod -q)
root@host01:~# echo $POD_ID
b7d2391320e07f97add7ccad2ad1a664393348f1dcb6f803f701318999ed0295

此时,使用 Pod ID,我们可以找到其网络命名空间。在清单 8-2 中,我们使用jq来提取我们想要的数据,就像在第四章中做的那样。然后我们将其赋值给一个变量。

root@host01:~# NETNS_PATH=$(crictl inspectp $POD_ID |
  jq -r '.info.runtimeSpec.linux.namespaces[]|select(.type=="network").path')
root@host01:~# echo $NETNS_PATH
/var/run/netns/cni-7cffed61-fb56-9be1-0548-4813d4a8f996
root@host01:~# NETNS=$(basename $NETNS_PATH)
root@host01:~# echo $NETNS
cni-7cffed61-fb56-9be1-0548-4813d4a8f996

清单 8-2:网络命名空间

现在,我们可以探索网络命名空间,查看 Calico 是如何为这个 Pod 设置 IP 地址和网络路由的。首先,正如预期的那样,这个网络命名空间是为我们的 Pod 使用的:

root@host01:~# ps $(ip netns pids $NETNS)
    PID TTY      STAT   TIME COMMAND
  35574 ?        Ss     0:00 /pause
  35638 ?        Ss     0:00 sleep infinity

我们可以看到预期中的两个进程。第一个是一个暂停容器,每当我们创建 Pod 时,它总是会被创建。这是一个永久容器,用于保持网络命名空间。第二个是我们运行sleep的 BusyBox 容器,正如我们在 Pod 的 YAML 文件中配置的那样。

现在,让我们看看配置好的网络接口:

root@host03:~# ip netns exec $NETNS ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN ...
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
3: ➊ eth0@if16: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 ... state UP ...
    link/ether 7a:9e:6c:e2:30:47 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet ➋ 172.31.239.205/32 brd 172.31.25.202 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::789e:6cff:fee2:3047/64 scope link 
       valid_lft forever preferred_lft forever

Calico 在网络命名空间➊中创建了网络设备eth0@if16,并为其分配了 IP 地址172.31.239.205➋。请注意,该 IP 地址的网络掩码是/32,这表示所有流量必须通过配置好的路由器。这与第四章中桥接容器网络的工作方式不同。这样配置是必要的,以便 Calico 通过网络策略提供防火墙功能。

该 Pod 所选的 IP 地址最终是由 Calico 决定的。Calico 的 IP 地址空间配置为172.31.0.0/16,用于 Pod 的 IP 地址分配。Calico 决定如何在节点之间划分该地址空间,并从分配给节点的范围内为每个 Pod 分配 IP 地址。然后,Calico 将此 IP 地址返回给kubelet,以便更新 Pod 的状态:

root@host01:~# kubectl get pods -o wide
NAME   READY   STATUS    RESTARTS   AGE   IP                NODE    ...
pod    1/1     Running   0          16m   172.31.239.205   host01   ...

当 Calico 在 Pod 中创建网络接口时,它是作为虚拟以太网(veth)对的一部分来创建的。veth 对充当一个虚拟网络线缆,创建一个到根命名空间中网络接口的连接,从而允许 Pod 外部的连接。清单 8-3 让我们看看 veth 对的两个部分。

root@host01:~# ip netns exec $NETNS ip link
...
3: eth0@if13: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue ... 
    link/ether 6e:4c:3a:41:d0:54 brd ff:ff:ff:ff:ff:ff link-netnsid 0
root@host01:~# ip link | grep -B 1 $NETNS
13: cali9381c30abed@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 ... 
    link/ether ee:ee:ee:ee:ee:ee ... link-netns cni-7cffed61-fb56-9be1-0548-4813d4a8f996

清单 8-3:Calico veth 对

第一个命令打印命名空间内的网络接口,而第二个命令打印主机上的接口。每个命令都包含字段link-netns,指向另一个接口的相应网络命名空间,显示这两个接口创建了 Pod 命名空间与根命名空间之间的链接。

跨节点网络

到目前为止,容器中虚拟网络设备的配置与第四章中的容器网络非常相似,当时并未安装 Kubernetes 集群。区别在于,网络插件配置不仅仅是为了连接单节点上的容器,而是为了连接在集群中任何地方运行的容器。

为什么不使用 NAT?

常规的容器网络确实提供与主机网络的连接。然而,正如我们所讨论的,它是通过网络地址转换(NAT)来实现的。这对于运行单个客户端应用程序的容器来说是可以的,因为连接跟踪使得 Linux 能够将服务器响应路由到原始容器中。但这对于需要充当服务器的容器就不适用了,而这正是 Kubernetes 集群的一个关键使用场景。

对于大多数使用 NAT 连接到更广泛网络的私有网络,端口转发用于从私有网络内部暴露特定服务。对于每个 Pod 中的每个容器来说,这并不是一个好的解决方案,因为我们很快就会用尽可分配的端口。网络插件最终确实使用 NAT,但仅仅是为了将作为客户端的容器连接到集群外部的网络。此外,我们将在第九章中看到端口转发的行为,它将是暴露服务到集群外部的可能方法之一。

跨节点网络的挑战在于,Pod 网络的 IP 地址范围与主机网络不同,因此主机网络不知道如何路由这些流量。网络插件有几种不同的方法来解决这个问题。我们将继续使用运行 Calico 的集群开始,然后展示使用 WeaveNet 的不同跨节点网络技术。

Calico 网络

Calico 使用第 3 层路由进行跨节点网络连接。这意味着它基于 IP 地址进行路由,在每个主机和 Pod 中配置 IP 路由表,以确保流量发送到正确的主机,然后到达正确的 Pod。因此,在主机级别,我们看到 Pod 的 IP 地址作为源地址和目标地址。由于 Calico 依赖于 Linux 的内建路由功能,我们不需要配置主机网络交换机来路由流量,但我们确实需要配置主机网络交换机上的任何安全控制,以允许 Pod 的 IP 地址跨网络传输。

为了探索 Calico 跨节点网络连接,最好有两个 Pods:一个在 host01 上,另一个在 host02 上。我们将使用这个资源文件:

two-pods.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: pod1
spec:
  containers:
  - name: pod1
    image: busybox
    command: 
      - "sleep"
      - "infinity"
  nodeName: host01
---
apiVersion: v1
kind: Pod
metadata:
  name: pod2
spec:
  containers:
  - name: pod2
    image: busybox
    command: 
      - "sleep"
      - "infinity"
  nodeName: host02

和往常一样,这些文件已经通过自动化脚本加载到本章节的/opt目录中。

--- 分隔符允许我们将两个不同的 Kubernetes 资源放在同一个文件中,以便我们可以一起管理它们。这两个 Pod 的唯一配置差异是它们各自有一个 nodeName 字段,以确保它们被分配到正确的节点。

让我们删除现有的 Pod,并用我们需要的两个 Pod 替换它:

root@host01:~# kubectl delete -f /opt/pod.yaml
pod "pod" deleted
root@host01:~# kubectl apply -f /opt/two-pods.yaml 
pod/pod1 created
pod/pod2 created

在这些 Pods 启动后,我们需要收集它们的 IP 地址:

root@host01:~# IP1=$(kubectl get po pod1 -o json | jq -r '.status.podIP')
root@host01:~# IP2=$(kubectl get po pod2 -o json | jq -r '.status.podIP')
root@host01:~# echo $IP1
172.31.239.216
root@host01:~# echo $IP2
172.31.89.197

我们能够使用简单的 jq 过滤器提取 Pod IP,因为我们的 kubectl get 命令保证只返回一个项目。如果我们没有过滤器地运行 kubectl get,或者使用可能匹配多个 Pods 的过滤器,JSON 输出将是一个列表,我们需要相应地修改 jq 过滤器。

让我们快速验证这两个 Pods 之间的连接性:

root@host01:~# kubectl exec -ti pod1 -- ping -c 3 $IP2
PING 172.31.89.197 (172.31.89.197): 56 data bytes
64 bytes from 172.31.89.197: seq=0 ttl=62 time=2.867 ms
64 bytes from 172.31.89.197: seq=1 ttl=62 time=0.916 ms
64 bytes from 172.31.89.197: seq=2 ttl=62 time=1.463 ms

--- 172.31.89.197 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max = 0.916/1.748/2.867 ms

ping 命令显示所有三个数据包成功到达,因此我们知道 Pods 可以跨节点通信。

如我们之前的示例所示,每个 Pod 都有一个网络接口,网络长度为/32,意味着所有流量必须经过路由器。例如,以下是pod1的 IP 配置和路由表:

root@host01:~# kubectl exec -ti pod1 -- ip addr
...
3: eth0@if17: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1450 qdisc noqueue 
    link/ether f2:ed:e8:04:00:cc brd ff:ff:ff:ff:ff:ff
    inet 172.31.239.216/32 brd 172.31.239.216 scope global eth0
...
root@host01:~# kubectl exec -ti pod1 -- ip route
default via 169.254.1.1 dev eth0 
169.254.1.1 dev eth0 scope link

根据此配置,当我们运行ping命令时,网络栈会识别目标 IP 不属于任何接口的本地网络。因此,它会在其地址解析协议(ARP)表中查找169.254.1.1以确定“下一跳”应该发送到哪里。如果我们尝试在容器或主机上找到一个具有169.254.1.1地址的接口,我们是无法成功的。Calico 并不会实际将该地址分配给某个接口,而是配置了“代理 ARP”,使得数据包通过 veth 对的eth0端发送。因此,容器内的 ARP 表中会有169.254.1.1的条目:

root@host01:~# kubectl exec -ti pod1 -- arp -n
? (169.254.1.1) at ee:ee:ee:ee:ee:ee [ether]  on eth0
...

如清单 8-3 所示,硬件地址ee:ee:ee:ee:ee:ee属于 veth 对的主机端,因此这足以将数据包从容器中取出并进入根网络命名空间。从那里,IP 路由接管。

Calico 已经配置了路由表,根据节点的目标 IP 地址范围将数据包发送到其他集群节点,并根据每个容器的 IP 地址将数据包发送到本地容器。我们可以在主机上的 IP 路由表中看到这个结果:

root@host01:~# ip route
...
172.31.25.192/26 via 192.168.61.13 dev enp0s8 proto 80 onlink 
172.31.89.192/26 via 192.168.61.12 dev enp0s8 proto 80 onlink 
172.31.239.216 dev calice0906292e2 scope link 
...

由于 ping 的目标地址位于172.31.89.192/26网络中,数据包现在被路由到192.168.61.12,即host02

让我们查看host02上的路由表,以便跟随接下来的步骤:

root@host02:~# ip route
...
172.31.239.192/26 via 192.168.61.11 dev enp0s8 proto 80 onlink 
172.31.25.192/26 via 192.168.61.13 dev enp0s8 proto 80 onlink 
172.31.89.197 dev calibd2348b4f67 scope link 
...

如果你想自己运行这个命令,确保从host02运行。当我们的数据包到达host02时,它已经有了一个特定目标 IP 地址的路由,这个路由将数据包发送到附加在pod2网络命名空间的 veth 对中。

现在,ping 数据包已经到达,pod2内的网络栈会发送回一个回复。这个回复会通过相同的过程,到达host02的根网络命名空间。根据host02的路由表,它会被发送到host01,并使用172.31.239.216的路由表条目将数据包发送到适当的容器。

由于 Calico 使用的是第 3 层路由,主机网络可以看到实际的容器 IP 地址。我们可以使用tcpdump来确认这一点。为此,我们将切换回host01

首先,让我们在后台启动tcpdump

root@host01:~# tcpdump -n -w pings.pcap -i any icmp &
[1] 70949
tcpdump: listening on any ...

-n标志告诉tcpdump避免查找任何 IP 地址的主机名,这样可以节省时间。-w pings.pcap标志告诉tcpdump将数据写入文件pings.pcap-i any标志告诉它监听所有网络接口;icmp过滤器告诉它仅监听 ICMP 流量;最后,&放在命令末尾表示将其放入后台。

pcap 文件扩展名非常重要,因为我们的 Ubuntu 主机系统只允许 tcpdump 读取具有该扩展名的文件。

现在,让我们再次运行 ping

root@host01:~# kubectl exec -ti pod1 -- ping -c 3 $IP2
...
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max = 0.928/0.991/1.115 ms

ICMP 请求和回复已被收集,但它们在内存中被缓冲。

为了将它们转储到文件中,我们将关闭 tcpdump

root@host01:~# killall tcpdump
12 packets captured
12 packets received by filter
0 packets dropped by kernel

有三个 ping,每个 ping 包括一个请求和一个回复。因此,我们可能期望有六个数据包,但事实上我们捕获了 12 个。为了理解原因,让我们打印出 tcpdump 收集到的数据包的详细信息:

root@host01:~# tcpdump -enr pings.pcap
reading from file pings.pcap, link-type LINUX_SLL (Linux cooked v1)
00:16:23...  In f2:ed:e8:04:00:cc ➊ ... 172.31.239.216 > 172.31.89.197: ICMP echo request ...
00:16:23... Out 08:00:27:b7:ef:ef ➋ ... 172.31.239.216 > 172.31.89.197: ICMP echo request ...
00:16:23...  In 08:00:27:fc:d2:36 ➌ ... 172.31.89.197 > 172.31.239.216: ICMP echo reply ...
00:16:23... Out ee:ee:ee:ee:ee:ee ➍ ... 172.31.89.197 > 172.31.239.216: ICMP echo reply ...
...

tcpdump-e 标志打印硬件地址;否则,我们无法区分某些数据包。第一个硬件地址 ➊ 是 Pod 内部 eth0 的硬件地址。接下来是相同的数据包,但这次硬件地址是主机接口 ➋。然后我们看到回复,首先到达主机接口,并带有 host02 的硬件地址 ➌。最后,数据包被路由到对应我们 Pod 的 Calico 网络接口 ➍,我们的 ping 已经完成了往返。

我们现在完成了这两个 Pod,让我们删除它们:

root@host01:~# kubectl delete -f /opt/two-pods.yaml
pod "pod1" deleted
pod "pod2" deleted

对于 Kubernetes 集群来说,使用第三层路由是一个优雅的跨节点网络解决方案,因为它利用了 Linux 原生的路由和流量转发能力。然而,这意味着主机网络能看到 Pod 的 IP 地址,这可能需要安全规则的更改。例如,为了配合本书,在亚马逊网络服务(AWS)中自动设置虚拟机时,不仅配置了一个安全组以允许 Pod IP 地址空间内的所有流量,还关闭了虚拟机实例的“源/目标检查”。否则,底层 AWS 网络基础设施将拒绝传递具有意外 IP 地址的流量到我们集群的节点。

WeaveNet

第三层路由并不是跨节点网络的唯一解决方案。另一种选择是将容器数据包“封装”到明确从主机到主机发送的数据包中。这是流行的网络插件(如 Flannel 和 WeaveNet)采取的方法。我们将看一个 WeaveNet 的例子,但使用 Flannel 的流量看起来非常相似。

注意

基于 Calico 的较大集群也会使用封装技术来处理某些网络之间的流量。例如,在 AWS 中跨多个区域或可用区的集群可能需要配置 Calico 来使用封装,因为可能无法或不方便为跨区域或可用区的所有路由器配置必要的 Pod IP 路由。

因为在网络中可能会有一些定义的标准,所以有封装的标准也并不奇怪:虚拟可扩展局域网(VXLAN)。在 VXLAN 中,每个数据包都被包装在一个 UDP 数据报中并发送到目的地。

我们将使用相同的two-pods.yaml配置文件,在我们的 Kubernetes 集群中创建两个 Pod,这次使用的是本章示例中weavenet目录构建的集群。如同之前一样,我们最终会有一个 Pod 在host01,另一个 Pod 在host02

root@host01:~# kubectl apply -f /opt/two-pods.yaml
pod/pod1 created
pod/pod2 created

让我们检查一下这些 Pod 是否正在运行,并且正确分配到它们各自的主机:

root@host01:~# kubectl get po -o wide
NAME   READY   STATUS    ... IP           NODE     ...
pod1   1/1     Running   ... 10.46.0.8    host01   ...
pod2   1/1     Running   ... 10.40.0.21   host02   ...

在这些 Pod 运行后,我们可以使用之前显示的相同命令来收集它们的 IP 地址:

root@host01:~# IP1=$(kubectl get po pod1 -o json | jq -r '.status.podIP')
root@host01:~# IP2=$(kubectl get po pod2 -o json | jq -r '.status.podIP')
root@host01:~# echo $IP1
10.46.0.8
root@host01:~# echo $IP2
10.40.0.21

请注意,分配的 IP 地址看起来与 Calico 示例完全不同。进一步探索显示地址和路由配置也有所不同,正如清单 8-4 中所示。

root@host01:~# kubectl exec -ti pod1 -- ip addr
...
25: eth0@if26: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1376 qdisc noqueue 
    link/ether e6:78:69:44:3d:a4 brd ff:ff:ff:ff:ff:ff
    inet 10.46.0.8/12 brd 10.47.255.255 scope global eth0
       valid_lft forever preferred_lft forever
...
root@host01:~# kubectl exec -ti pod1 -- ip route
default via 10.46.0.0 dev eth0 
10.32.0.0/12 dev eth0 scope link  src 10.46.0.8

清单 8-4: WeaveNet 网络

这一次,我们的 Pod 获得了一个大范围的/12网络中的 IP 地址,意味着单个网络中有超过一百万个可能的地址。在这种情况下,我们 Pod 的网络栈预计能够使用 ARP 直接识别网络上任何其他 Pod 的硬件地址,而不是像我们在 Calico 中看到的那样将流量路由到网关。

和之前一样,我们确实在这两个 Pod 之间建立了连接:

root@host01:~# kubectl exec -ti pod1 -- ping -c 3 $IP2
PING 10.40.0.21 (10.40.0.21): 56 data bytes
64 bytes from 10.40.0.21: seq=0 ttl=64 time=0.981 ms
64 bytes from 10.40.0.21: seq=1 ttl=64 time=0.963 ms
64 bytes from 10.40.0.21: seq=2 ttl=64 time=0.871 ms
--- 10.40.0.21 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max = 0.871/0.938/0.981 ms

现在我们已经运行了这个ping命令,我们应该期待pod1网络栈中的 ARP 表已经填充了pod2网络接口的硬件地址:

root@host01:~# kubectl exec -ti pod1 -- arp -n
? (10.40.0.21) at ba:75:e6:db:7c:c6 [ether]  on eth0
? (10.46.0.0) at 1a:72:78:64:36:c6 [ether]  on eth0

正如预期的那样,pod1有一个针对pod2 IP 地址的 ARP 表项,对应于pod2内部的虚拟网络接口:

root@host01:~# kubectl exec -ti pod2 -- ip addr
...
53: eth0@if54: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1376 qdisc noqueue 
    link/ether ➊ ba:75:e6:db:7c:c6 brd ff:ff:ff:ff:ff:ff
    inet 10.40.0.21/12 brd 10.47.255.255 scope global eth0
       valid_lft forever preferred_lft forever
...

pod1的 ARP 表中的硬件地址与pod2虚拟网络设备的硬件地址匹配➊。为了实现这一点,WeaveNet 正在通过网络路由 ARP 请求,以便pod2的网络栈能够做出响应。

让我们看看跨节点的 ARP 和 ICMP 流量是如何传输的。首先,尽管 IP 地址管理可能不同,Calico 和 WeaveNet 之间的一个重要相似之处是,二者都使用 veth 对将容器连接到主机。如果你想深入探索这一点,可以使用清单 8-2 和清单 8-3 中的命令来确定pod1的网络命名空间,然后在host01上使用ip addr验证是否存在一个具有link-netns字段的veth设备,该字段对应于该网络命名空间。

出于我们的目的,因为我们之前已经看到过这个情况,我们假设流量是通过由 veth 对创建的虚拟网络线路传输的,并到达主机。从这里开始,我们追踪这两个 Pod 之间的 ICMP 流量。

如果我们使用与 Calico 相同的tcpdump捕获,我们将能够捕获到 ICMP 流量,但这只能帮助我们到达一定程度。让我们继续查看一下:

root@host01:~# tcpdump -w pings.pcap -i any icmp &
[1] 55999
tcpdump: listening on any, link-type LINUX_SLL (Linux cooked v1) ...
root@host01:~# kubectl exec -ti pod1 -- ping -c 3 $IP2
...
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max = 0.824/1.691/3.053 ms
root@host01:~# killall tcpdump
24 packets captured
24 packets received by filter
0 packets dropped by kernel

如同之前一样,我们在后台运行了tcpdump来捕获所有网络接口上的 ICMP 流量,运行了我们的ping命令,然后停止了tcpdump,让它写出捕获的包。这一次我们有 24 个数据包可以查看,但它们仍然不能讲述整个故事:

root@host01:~# tcpdump -enr pings.pcap
reading from file pings.pcap, link-type LINUX_SLL (Linux cooked v1)
16:22:08.211499   P e6:78:69:44:3d:a4 ... 10.46.0.8 > 10.40.0.21: ICMP echo request ...
16:22:08.211551 Out e6:78:69:44:3d:a4 ... 10.46.0.8 > 10.40.0.21: ICMP echo request ...
16:22:08.211553   P e6:78:69:44:3d:a4 ... 10.46.0.8 > 10.40.0.21: ICMP echo request ...
16:22:08.211745 Out e6:78:69:44:3d:a4 ... 10.46.0.8 > 10.40.0.21: ICMP echo request ...
16:22:08.212917   P ba:75:e6:db:7c:c6 ... 10.40.0.21 > 10.46.0.8: ICMP echo reply ...
16:22:08.213704 Out ba:75:e6:db:7c:c6 ... 10.40.0.21 > 10.46.0.8: ICMP echo reply ...
16:22:08.213708   P ba:75:e6:db:7c:c6 ... 10.40.0.21 > 10.46.0.8: ICMP echo reply ...
16:22:08.213724 Out ba:75:e6:db:7c:c6 ... 10.40.0.21 > 10.46.0.8: ICMP echo reply ...
...

这些行显示了一个单独的ping请求和回复的四个数据包,但硬件地址并没有发生变化。发生的情况是,这些 ICMP 数据包在网络接口之间被传递,且没有修改。然而,我们仍然没有看到实际在host01host02之间传输的流量,因为我们从未看到任何与主机接口对应的硬件地址。

要查看主机级流量,我们需要告诉tcpdump捕获 UDP 流量,然后将其视为 VXLAN,这样可以使tcpdump识别出 ICMP 数据包的存在。

让我们重新开始捕获,这次查找 UDP 流量:

root@host01:~# tcpdump -w vxlan.pcap -i any udp &
...
root@host01:~# kubectl exec -ti pod1 -- ping -c 3 $IP2
...
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max = 1.139/1.364/1.545 ms
root@host01:~# killall tcpdump
22 packets captured
24 packets received by filter
0 packets dropped by kernel

这次我们将数据包数据保存在vxlan.pcap中。在这个例子中,tcpdump捕获了 22 个数据包。由于我们集群中有大量的跨 Pod 流量,而不仅仅是 ICMP 流量,您可能会看到不同的数量。

我们捕获的数据包覆盖了host01上的所有 UDP 流量,而不仅仅是我们的 ICMP 流量,因此在打印出清单 8-5 中显示的数据包时,我们需要进行选择。

root@host01:~# tcpdump -enr vxlan.pcap -T vxlan | grep -B 1 ICMP
reading from file vxlan.pcap, link-type LINUX_SLL (Linux cooked v1)
16:45:47.307949 Out 08:00:27:32:a0:28 ... 
  length 150: 192.168.61.11.50200 > 192.168.61.12.6784: VXLAN ...
e6:78:69:44:3d:a4 > ba:75:e6:db:7c:c6 ... 
  length 98: 10.46.0.8 > 10.40.0.21: ICMP echo request ...
16:45:47.308699  In 08:00:27:67:b9:da ... 
  length 150: 192.168.61.12.43489 > 192.168.61.11.6784: VXLAN ... 
ba:75:e6:db:7c:c6 > e6:78:69:44:3d:a4 ... 
  length 98: 10.40.0.21 > 10.46.0.8: ICMP echo reply ...
16:45:48.308240 Out 08:00:27:32:a0:28 ... 
  length 150: 192.168.61.11.50200 > 192.168.61.12.6784: VXLAN ... 
...

清单 8-5:VXLAN 捕获

-T vxlan标志告诉tcpdump将其看到的数据包数据视为 VXLAN 数据。这使得tcpdump可以深入查看并提取封装数据包中的数据,从而识别出那些被隐藏在内部的 ICMP 数据包。接着,我们使用grep-B 1标志来查找这些 ICMP 数据包,并打印出它们之前的一行,以便查看 VXLAN 包装器。

这个捕获显示了主机的硬件地址,这表明我们已经成功捕获了在主机之间传输的流量。每个 ICMP 数据包都被封装在一个 UDP 数据报中,并通过主机网络发送。这些数据报的 IP 源和目标地址是主机网络的 IP 地址192.168.61.11192.168.61.12,因此主机网络从未看到 Pod 的 IP 地址。然而,这些信息仍然存在于封装的 ICMP 数据包中,因此,当数据报到达目的地时,WeaveNet 能够将 ICMP 数据包发送到正确的目的地。

封装的优点是,我们所有的跨节点流量看起来就像主机之间的普通 UDP 数据报。通常,我们无需做任何额外的网络配置来允许这种流量。然而,我们也付出了代价。正如在清单 8-5 中看到的,每个 ICMP 数据包大小为 98 字节,但封装后的数据包为 150 字节。为了进行封装所需的包装器会产生网络开销,我们需要为每个发送的数据包支付这个开销。

请回顾一下清单 8-4 中的另一个结果。Pod 内部的虚拟网络接口的最大传输单元(MTU)为 1,376。这个值代表可以发送的最大数据包;任何更大的数据包必须被分段并在目的地重新组装。这个 1,376 的 MTU 远小于主机网络上的标准 1,500。Pod 接口上较小的 MTU 确保 Pod 的网络栈会进行必要的分段处理。这样,我们可以确保即使添加了封装层,主机层也不会超过 1,500。因此,如果你使用的是通过封装实现的网络插件,值得探索如何配置巨型帧,以便在主机网络上启用大于 1,500 的 MTU。

选择网络插件

网络插件可以采用不同的方式来实现跨节点的网络连接。然而,正如工程学中的普遍规律,每种方法都有其权衡。第 3 层路由利用了 Linux 的原生功能,在使用网络带宽方面效率较高,但可能需要定制底层主机网络。通过 VXLAN 封装的方法适用于任何可以在主机之间发送 UDP 数据报的网络,但它会增加每个数据包的开销。

无论如何,我们的 Pods 都能满足其需求,即能够与集群中其他位置的 Pods 进行通信。实际上,配置工作和性能差异通常很小。因此,选择网络插件的最佳方式是从你的 Kubernetes 发行版推荐或默认安装的插件开始。如果你发现某些特定用例的性能无法满足要求,你可以基于实际网络流量而不是猜测,测试其他插件。

网络定制

某些场景可能需要比单一 Pod 网络连接跨所有集群节点更为复杂的集群网络。例如,一些受监管的行业要求某些数据(如安全审计日志)通过一个独立的网络传输。其他系统可能有专门的硬件,要求与该硬件交互的应用组件必须放置在特定的网络或虚拟局域网(VLAN)中。

网络插件架构的一个优势是 Kubernetes 集群能够容纳这些特定的网络场景。只要 Pods 有一个接口能够连接到集群的其他部分(并且能够从集群其他部分访问),Pods 就可以有额外的网络接口来提供专门的连接。

我们来看一个例子。我们将配置两个在同一节点上的 Pods,使它们拥有一个本地的仅主机网络,可以用于相互通信。由于是仅主机网络,它不提供与集群其他部分的连接,因此我们还将使用 Calico 为 Pods 提供集群网络。

由于需要配置 Calico 和我们的仅主机网络,我们将调用两个不同的 CNI 插件,它们将在 Pod 的网络命名空间中创建虚拟网络接口。如同我们在示例 8-1 中看到的那样,确实可以在一个配置文件中配置多个 CNI 插件。然而,kubelet 只期望其中一个 CNI 插件实际分配网络接口和 IP 地址。为了解决这个问题,我们将使用 Multus,一个设计用来调用多个插件的 CNI 插件,但会将其中一个插件视为主插件,用于向 kubelet 报告 IP 地址信息。Multus 还允许我们根据需要选择应用哪些 CNI 插件到每个 Pod。

我们将首先在本章的 calico 示例集群中安装 Multus:

root@host01:~# kubectl apply -f /opt/multus-daemonset.yaml
customresourcedefinition.../network-attachment-definitions... created
clusterrole.rbac.authorization.k8s.io/multus created
clusterrolebinding.rbac.authorization.k8s.io/multus created
serviceaccount/multus created
configmap/multus-cni-config created
daemonset.apps/kube-multus-ds created

正如文件名所示,这个 YAML 文件中的主要资源是一个 DaemonSet,它在每个主机上运行一个 Multus 容器。然而,这个文件还安装了其他几个资源,包括一个 CustomResourceDefinition。这个 CustomResourceDefinition 允许我们配置网络附加资源,告诉 Multus 在特定 Pod 中使用哪些 CNI 插件。

我们将在第十七章中详细查看 CustomResourceDefinitions。现在,在示例 8-6 中,我们将看到用于配置 Multus 的 NetworkAttachmentDefinition。

netattach.yaml

---
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
  name: macvlan-conf
spec:
  config: '{
      "cniVersion": "0.3.0",
      "type": "macvlan",
      "mode": "bridge",
      "ipam": {
        "type": "host-local",
        "subnet": "10.244.0.0/24",
        "rangeStart": "10.244.0.1",
        "rangeEnd": "10.244.0.254"
      }
    }'

示例 8-6:网络附加

spec 中的 config 字段看起来像一个 CNI 配置文件,这并不奇怪,因为 Multus 需要使用这些信息在我们要求将其添加到 Pod 时调用 macvlan CNI 插件。

我们需要将这个 NetworkAttachmentDefinition 添加到集群中:

root@host01:~# kubectl apply -f /opt/netattach.yaml 
networkattachmentdefinition.k8s.cni.cncf.io/macvlan-conf created

这个定义并不会立即影响任何 Pod;它只是为将来使用提供了 Multus 配置。

当然,要使用这个配置,必须调用 Multus。那么,当我们已经将 Calico 安装到这个集群时,如何实现这一点呢?答案就在/etc/cni/net.d 目录中,这个目录在 Multus DaemonSet 初始化时会修改我们集群中所有节点上的配置:

root@host01:~# ls /etc/cni/net.d
00-multus.conf  10-calico.conflist  calico-kubeconfig  multus.d

Multus 保留了现有的 Calico 配置文件,但添加了它自己的 00-multus.conf 配置文件和 multus.d 目录。由于 00-multus.conf 文件在字母排序中排在 10-calico.conflist 前面,kubelet 会在下次创建新 Pod 时开始使用它。

这是 00-multus.conf

00-multus.conf

{
  "cniVersion": "0.3.1",
  "name": "multus-cni-network",
  "type": "multus",
  "capabilities": {
    "portMappings": true,
    "bandwidth": true
  },
  "kubeconfig": "/etc/cni/net.d/multus.d/multus.kubeconfig",
  "delegates": [
    {
      "name": "k8s-pod-network",
      "cniVersion": "0.3.1",
      "plugins": [
        {
          "type": "calico",
...
          }
        },
        {
          "type": "bandwidth",
...
        },
        {
          "type": "portmap",
...
        }
      ]
    }
  ]
}

delegates 字段来自 Multus 找到的 Calico 配置。这个字段用于确定 Multus 在每次调用时始终使用的默认 CNI 插件。顶层的 capabilities 字段是必须的,以确保 Multus 从 kubelet 获取所有正确的配置数据,以便能够调用 portmapbandwidth 插件。

现在 Multus 已经完全设置好了,让我们用它向两个 Pod 添加一个仅主机网络。这些 Pod 的定义如下:

local-pods.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: pod1
  annotations:
    k8s.v1.cni.cncf.io/networks: macvlan-conf
spec:
  containers:
  - name: pod1
    image: busybox
    command: 
      - "sleep"
      - "infinity"
  nodeName: host01
---
apiVersion: v1
kind: Pod
metadata:
  name: pod2
  annotations:
    k8s.v1.cni.cncf.io/networks: macvlan-conf
spec:
  containers:
  - name: pod2
    image: busybox
    command: 
      - "sleep"
      - "infinity"
  nodeName: host01

这一次,我们需要这两个 Pod 最终都在 host01 上运行,以便仅限主机的网络功能得以实现。此外,我们为每个 Pod 添加了 k8s.v1.cni.cncf.io/networks 注解。Multus 使用这个注解来识别应运行的额外 CNI 插件。macvlan-conf 这个名字与我们在 Listing 8-6 中的 NetworkAttachmentDefinition 中提供的名称匹配。

让我们创建这两个 Pod:

root@host01:~# kubectl apply -f /opt/local-pods.yaml
pod/pod1 created
pod/pod2 created

在这些 Pod 运行之后,我们可以检查它们是否各自有一个额外的网络接口:

root@host01:~# kubectl exec -ti pod1 -- ip addr
...
3: eth0@if12: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1450 qdisc noqueue 
    link/ether 9a:a1:db:ec:c7:91 brd ff:ff:ff:ff:ff:ff
    inet 172.31.239.198/32 brd 172.31.239.198 scope global eth0
       valid_lft forever preferred_lft forever
...
4: net1@if2: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 9e:4f:c4:47:40:07 brd ff:ff:ff:ff:ff:ff
    inet 10.244.0.2/24 brd 10.244.0.255 scope global net1
       valid_lft forever preferred_lft forever
...
root@host01:~# kubectl exec -ti pod2 -- ip addr
...
3: eth0@if13: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1450 qdisc noqueue 
    link/ether 52:08:99:a7:d2:bc brd ff:ff:ff:ff:ff:ff
    inet 172.31.239.199/32 brd 172.31.239.199 scope global eth0
       valid_lft forever preferred_lft forever
...
4: net1@if2: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether a6:e5:01:82:81:82 brd ff:ff:ff:ff:ff:ff
    inet 10.244.0.3/24 brd 10.244.0.255 scope global net1
       valid_lft forever preferred_lft forever
...

macvlan CNI 插件已添加额外的 net1 网络接口,并使用我们在 NetworkAttachmentDefinition 中提供的 IP 地址管理配置。

这两个 Pod 现在可以通过以下接口相互通信:

root@host01:~# kubectl exec -ti pod1 -- ping -c 3 10.244.0.3
PING 10.244.0.3 (10.244.0.3): 56 data bytes
64 bytes from 10.244.0.3: seq=0 ttl=64 time=3.125 ms
64 bytes from 10.244.0.3: seq=1 ttl=64 time=0.192 ms
64 bytes from 10.244.0.3: seq=2 ttl=64 time=0.085 ms

--- 10.244.0.3 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max = 0.085/1.134/3.125 ms

这种通信通过 macvlan CNI 插件创建的桥接网络进行,而不是通过 Calico 进行。

请记住,我们在这里的目的仅仅是演示自定义网络,而无需集群主机外部的任何特定 VLAN 或复杂设置。对于实际的集群,这种仅限主机的网络价值有限,因为它限制了 Pod 的部署位置。在这种情况下,将两个容器放入同一个 Pod 可能更为可取,这样它们总是会一起调度,并可以使用 localhost 进行通信。

最后的思考

在这一章中,我们已经看了很多网络接口和流量流动。大多数情况下,了解集群中的每个 Pod 都会从 Pod 网络中分配一个 IP 地址,并且集群中的任何 Pod 都可以与任何其他 Pod 通信,且可以被访问,这就足够了。任何 Kubernetes 网络插件都可以提供这种功能,无论它们使用的是第 3 层路由、VXLAN 封装,还是两者兼而有之。

同时,集群中确实会发生网络问题,因此集群管理员和用户必须理解流量如何在主机之间流动,以及这些流量对主机网络的表现,以便调试交换机和主机配置问题,或者仅仅为了构建能够充分利用集群的应用程序。

我们还没有完成使 Kubernetes 集群完全功能所需的网络层。在下一章中,我们将探讨 Kubernetes 如何在 Pod 网络之上提供服务层,以提供负载均衡和自动故障切换,并结合 Ingress 网络层使容器服务在集群外部可访问。

第九章:服务和入口网络

image

创建一个集群级别的网络,使得所有 Pod 可以互相通信,涉及了相当复杂的操作。同时,我们仍然没有获得构建可扩展、弹性应用所需的所有网络功能。我们需要支持将应用组件跨多个实例进行负载均衡的网络,并且提供将流量发送到新的 Pod 实例的能力,以应对现有实例的故障或升级需求。此外,Pod 网络设计为私有网络,意味着它仅能从集群内部直接访问。我们需要额外的流量路由功能,以便外部用户可以访问我们在容器中运行的应用组件。

在本章中,我们将讨论服务和入口网络。Kubernetes 的服务网络提供了一个额外的网络层,位于 Pod 网络之上,包括动态发现和负载均衡。我们将看到这个网络层如何工作,以及如何利用它将我们的应用组件暴露给集群中的其他部分,作为可扩展和有弹性的服务。然后,我们将探讨入口配置如何为这些服务提供流量路由,将它们暴露给外部用户。

服务

将部署和覆盖网络结合起来,我们可以创建多个相同的容器实例,每个实例都有一个唯一的 IP 地址。让我们创建一个 NGINX 部署来说明:

nginx-deploy.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: nginx
spec:
  replicas: 5
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx

这与我们之前看到的部署类似。在这种情况下,我们要求 Kubernetes 为我们维护五个 Pod,每个 Pod 运行一个 NGINX web 服务器。

注意

本书的示例仓库位于 github.com/book-of-kubernetes/examples有关设置的详细信息,请参见“运行示例”部分,第 xx 页。

自动化脚本已经将此文件放置在/opt目录下,因此我们可以将其应用到集群中:

root@host01:~# kubectl apply -f /opt/nginx-deploy.yaml
deployment.apps/nginx created

在这些 Pod 启动后,我们可以检查它们是否已分布到集群中,并且每个 Pod 都有一个 IP 地址:

root@host01:~# kubectl get pods -o wide
NAME                     READY   STATUS    ... IP                NODE    ...
nginx-6799fc88d8-2wqc7   1/1     Running   ... 172.31.239.231   host01   ...
nginx-6799fc88d8-78bwx   1/1     Running   ... 172.31.239.229   host01   ...
nginx-6799fc88d8-dtx7s   1/1     Running   ... 172.31.89.240    host02   ...
nginx-6799fc88d8-wh479   1/1     Running   ... 172.31.239.230   host01   ...
nginx-6799fc88d8-zwx27   1/1     Running   ... 172.31.239.228   host01   ...

如果这些容器只是某个服务器的客户端,那可能就是我们需要做的全部了。例如,如果我们的应用架构是通过发送和接收消息来驱动的,只要这些容器能够连接到消息服务器,它们就能按要求工作。然而,因为这些容器充当服务器的角色,客户端需要能够找到它们并建立连接。

就目前而言,我们的独立 NGINX 实例对客户端来说并不太实用。当然,直接连接到这些 NGINX 服务器 Pod 中的任何一个都是可能的。例如,我们可以通过其 IP 地址与列表中的第一个进行通信:

root@host01:~# curl -v http://172.31.239.231
*   Trying 172.31.239.231:80...
* Connected to 172.31.239.231 (172.31.239.231) port 80 (#0)
> GET / HTTP/1.1
...
< HTTP/1.1 200 OK
< Server: nginx/1.21.3
...

不幸的是,单独选择一个实例并不能提供负载均衡或故障转移功能。此外,我们无法提前知道 Pod 的 IP 地址,而且每次对 Deployment 进行更改时,Pods 会被重新创建并获得新的 IP 地址。

解决这种情况需要具备两个主要特性。首先,我们需要一个客户端可以用来查找服务器的众所周知的名称。其次,我们需要一个一致的 IP 地址,这样当客户端识别到一个服务器时,即使 Pod 实例来来去去,也可以继续使用相同的地址进行连接。这正是 Kubernetes 通过 Service 提供的功能。

创建 Service

让我们为我们的 NGINX Deployment 创建一个 Service,看看这能带来什么。清单 9-1 提供了资源的 YAML 文件。

nginx-service.yaml

---
kind: Service
apiVersion: v1
metadata:
  name: nginx
spec:
  selector:
    app: nginx
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80

清单 9-1:NGINX Service

首先,Service 具有一个 selector,与 Deployment 类似。这个选择器以相同的方式使用:用来识别将与 Service 关联的 Pods。然而,与 Deployment 不同,Service 不以任何方式管理它的 Pods;它只是将流量路由到它们。

流量路由是基于我们在 ports 字段中指定的端口。由于 NGINX 服务器监听的是端口 80,我们需要将其指定为 targetPort。我们可以使用任何我们想要的 port,但最简单的做法是保持一致,特别是因为 80 是 HTTP 的默认端口。

让我们将这个 Service 应用到集群中:

root@host01:~# kubectl apply -f /opt/nginx-service.yaml 
service/nginx created

我们现在可以看到已经创建了 Service:

root@host01:~# kubectl get services
NAME         TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1        <none>        443/TCP   14d
nginx        ClusterIP   10.100.221.220   <none>        80/TCP    25s

这个 nginx Service 默认类型为 ClusterIP。Kubernetes 已经为该 Service 自动分配了一个集群 IP 地址。该 IP 地址与我们的 Pods 的地址空间完全不同。

使用选择器,这个 Service 会识别我们的 NGINX 服务器 Pods,并自动开始将流量负载均衡到它们。当匹配选择器的 Pods 来来去去时,Service 会自动更新其负载均衡。只要 Service 存在,它就会保持相同的 IP 地址,这样客户端就能持续通过一致的方式找到我们的 NGINX 服务器实例。

让我们验证是否能够通过 Service 访问 NGINX 服务器:

root@host01:~# curl -v http://10.100.221.220
*   Trying 10.100.221.220:80...
* Connected to 10.100.221.220 (10.100.221.220) port 80 (#0)
> GET / HTTP/1.1
...
< HTTP/1.1 200 OK
< Server: nginx/1.21.3
...

我们可以看到,Service 已经正确地识别了所有五个 NGINX Pods:

root@host01:~# kubectl describe service nginx
Name:              nginx
Namespace:         default
...
Selector:          app=nginx
...
Endpoints:         172.31.239.228:80,172.31.239.229:80,172.31.239.230:80 
+ 2 more...
...

Endpoints 字段显示 Service 当前正在将流量路由到所有五个 NGINX Pods。作为客户端,我们不需要知道是哪个 Pod 处理了我们的请求。我们只与 Service IP 地址交互,允许 Service 为我们选择一个实例。

当然,在这个示例中,我们必须查找 Service 的 IP 地址。为了方便客户端,我们仍然应该提供一个众所周知的名称。

Service DNS

Kubernetes 通过 DNS(域名系统)服务器为每个服务提供一个众所周知的名称,该服务器会动态更新集群中每个服务的名称和 IP 地址。每个 Pod 都配置了这个 DNS 服务器,这样 Pod 就可以使用服务的名称来连接到一个实例。

让我们创建一个 Pod,以便我们可以尝试这个操作:

pod.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: pod
spec:
  containers:
  - name: pod
    image: alpine
    command: 
      - "sleep"
      - "infinity"

我们使用alpine而不是busybox作为这个 Pod 的镜像,因为我们需要使用一些 DNS 命令,这些命令要求我们安装一个功能更强大的 DNS 客户端。

注意

BusyBox 是一个非常适合 Kubernetes 集群调试的镜像,因为它非常小并且包含许多有用的命令。然而,为了保持 BusyBox 的小巧,通常这些命令只包含最常用的选项。Alpine 是一个非常好的调试替代品。默认的 Alpine 镜像使用 BusyBox 来提供其许多初始命令,但通过安装适当的软件包,也可以将其替换为功能更完整的替代品。

接下来,创建 Pod:

root@host01:~# kubectl apply -f /opt/pod.yaml 
pod/pod created

它启动后,让我们使用它连接到我们的 NGINX 服务,如示例 9-2 中所示。

root@host01:~# kubectl exec -ti pod -- wget -O - http://nginx
Connecting to nginx (10.100.221.220:80)
...
<title>Welcome to nginx!</title>
...

示例 9-2:连接到 NGINX 服务

我们能够使用服务的名称nginx,并且该名称解析为服务的 IP 地址。之所以能这样工作,是因为我们的 Pod 已经配置为与集群内置的 DNS 服务器通信:

root@host01:~# kubectl exec -ti pod -- cat /etc/resolv.conf 
search default.svc.cluster.local svc.cluster.local cluster.local 
nameserver 10.96.0.10
options ndots:5

我们打印容器内的文件/etc/resolv.conf,因为这是用来配置 DNS 的文件。

引用的名称服务器10.96.0.10本身就是一个 Kubernetes 服务,但它位于kube-system命名空间中,因此我们需要在该命名空间中查找它:

root@host01:~# kubectl -n kube-system get services
NAME            TYPE       CLUSTER-IP      ... PORT(S)                  AGE
kube-dns        ClusterIP  10.96.0.10      ... 53/UDP,53/TCP,9153/TCP   14d
metrics-server  ClusterIP  10.105.140.176  ... 443/TCP                  14d

kube-dns服务连接到一个名为 CoreDNS 的 DNS 服务器部署,该服务器监听 Kubernetes 集群中服务的变化。CoreDNS 根据需要更新 DNS 服务器配置,以保持与当前集群配置同步。

名称解析和命名空间

Kubernetes 集群中的 DNS 名称是基于命名空间以及集群域的。由于我们的 Pod 位于default命名空间,因此它的搜索路径已被配置为default.svc.cluster.local,这是列表中的第一个条目,因此在查找服务时,它将首先搜索default命名空间。这就是为什么我们能够使用裸服务名称nginx来找到nginx服务的原因——该服务也位于default命名空间中。

我们也可以使用完全限定的名称找到相同的服务:

root@host01:~# kubectl exec -ti pod -- wget -O - http://nginx.default.svc
Connecting to nginx.default.svc (10.100.221.220:80)
...
<title>Welcome to nginx!</title>
...

理解命名空间和服务查找之间的相互作用非常重要。Kubernetes 集群的一个常见部署模式是将同一个应用程序多次部署到不同的命名空间,并使用简单的主机名使应用程序组件相互通信。这个模式通常用于将应用程序的“开发”版本和“生产”版本部署到同一个集群中。如果我们打算使用这种模式,我们需要确保在应用程序组件尝试相互发现时,坚持使用纯粹的主机名;否则,我们可能会与应用程序的错误版本进行通信。

/etc/resolv.conf中另一个重要的配置项是ndots条目。ndots条目告诉主机名解析器,当它看到一个包含四个或更少点的主机名时,它应该先尝试附加各种搜索域,而不是在没有附加任何域名的情况下直接执行绝对查找。这对于确保我们在访问集群外部之前,尝试查找集群内的服务至关重要。

结果是,当我们在清单 9-2 中使用名称nginx时,我们容器内的 DNS 解析器立即尝试了nginx.default.svc.cluster.local并找到了正确的服务。

为了确保这一点清晰明了,我们再看一个例子:查找另一个命名空间中的服务。kube-system命名空间中有一个metrics-server服务。为了查找它,我们可以在 Pod 中使用标准的主机查找命令dig

我们的 Pod 使用的是 Alpine Linux,因此我们需要安装bind-tools包来获取dig工具:

root@host01:~# kubectl exec -ti pod -- apk add bind-tools
...
OK: 13 MiB in 27 packages

现在,让我们首先使用纯主机名尝试查找metrics-server

root@host01:~# kubectl exec -ti pod -- dig +search metrics-server
...
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 38423
...

我们在命令中添加了+search标志,告诉dig使用来自/etc/resolv.conf的搜索路径信息。然而,即便有了这个标志,我们仍然无法找到服务,因为我们的 Pod 位于default命名空间,因此搜索路径没有让dig去查找kube-system命名空间。

让我们再试一次,这次指定正确的命名空间:

root@host01:~# kubectl exec -ti pod -- dig +search metrics-server.kube-system
...
;; ANSWER SECTION:
metrics-server.kube-system.svc.cluster.local. 30 IN A 10.105.140.176
...

这个查找成功了,我们能够获得metrics-server服务的 IP 地址。之所以成功,是因为搜索路径的第二个条目包括了svc.cluster.local。在最初尝试了metrics-server.kube-system.default.svc.cluster.local(失败)之后,dig接着尝试了metrics-server.kube-system.svc.cluster.local,这次成功了。

流量路由

我们已经看到了如何创建和使用服务,但还没有了解实际的流量路由是如何工作的。事实证明,服务的网络流量工作方式与我们在第八章中看到的覆盖网络完全不同,这可能会导致一些混淆。

例如,虽然我们可以使用wget通过nginx服务名访问 NGINX 服务器实例,但我们可能会期待同样能够使用ping,然而这并不起作用:

root@host01:~# kubectl exec -ti pod -- ping -c 3 nginx
PING nginx (10.100.221.220): 56 data bytes

--- nginx ping statistics ---
3 packets transmitted, 0 packets received, 100% packet loss
command terminated with exit code 1

名称解析按预期工作,因此 ping 知道应该使用哪个目标 IP 地址来发送 ICMP 包。但是,从该 IP 地址没有收到回应。我们可以查看集群中每个主机和容器的网络接口,却永远找不到承载 10.100.221.220 这个 Service IP 地址的接口。那么,为什么我们的 HTTP 流量能够顺利到达 NGINX Service 实例呢?

在我们集群的每个节点上,都有一个名为 kube-proxy 的组件,它配置 Service 的流量路由。kube-proxy 作为一个 DaemonSet 在 kube-system 命名空间中运行。每个 kube-proxy 实例都会监视集群中 Service 的变化,并配置 Linux 防火墙以路由流量。

我们可以使用 iptables 命令查看防火墙配置,看看 kube-proxy 如何为我们的 nginx Service 配置流量路由:

   root@host01:~# iptables-save | grep 'default/nginx cluster IP'
➊ -A KUBE-SERVICES ! -s 172.31.0.0/16 -d 10.100.221.220/32 -p tcp -m comment 
    --comment "default/nginx cluster IP" -m tcp --dport 80 -j KUBE-MARK-MASQ
➋ -A KUBE-SERVICES -d 10.100.221.220/32 -p tcp -m comment --comment 
   "default/nginx cluster IP" -m tcp --dport 80 -j KUBE-SVC-2CMXP7HKUVJN7L6M

iptables-save 命令备份当前所有 Linux 防火墙规则,因此它对于打印所有规则非常有用。grep 命令用于搜索 kube-proxy 应用于它所创建的 Service 规则的注释字符串。在这个示例中,kube-proxy 为整个 Service 创建了两条规则。第一条规则 ➊ 查找目标是我们的 Service 且不是来自 Pod 网络的流量。这个流量必须标记为网络地址转换(NAT)伪装,以便任何响应流量的源地址会被重写为 Service IP 地址,而不是实际处理请求的 Pod。第二条规则 ➋ 将所有目标是 Service 的流量发送到一个独立的规则链,这个规则链会将流量转发到一个 Pod 实例。注意,在这两种情况下,规则只会匹配目标端口为 80 的 TCP 流量。

我们可以检查这个独立的规则链,看看实际是如何将流量路由到各个 Pod 实例的。确保在此命令中替换规则链的名称为之前输出中显示的名称:

root@host01:~# iptables-save | grep KUBE-SVC-2CMXP7HKUVJN7L6M
...
-A KUBE-SVC-2CMXP7HKUVJN7L6M ... -m statistic --mode random 
  --probability 0.20000000019 -j KUBE-SEP-PIVU7ZHMCSOWIZ2Z
-A KUBE-SVC-2CMXP7HKUVJN7L6M ... -m statistic --mode random 
  --probability 0.25000000000 -j KUBE-SEP-CFQXKE74QEHFB7VJ
-A KUBE-SVC-2CMXP7HKUVJN7L6M ... -m statistic --mode random 
  --probability 0.33333333349 -j KUBE-SEP-DHDWEJZ7MGGIR5XF
-A KUBE-SVC-2CMXP7HKUVJN7L6M ... -m statistic --mode random 
  --probability 0.50000000000 -j KUBE-SEP-3S3S2VJCXSAISE2Z
-A KUBE-SVC-2CMXP7HKUVJN7L6M ... -j KUBE-SEP-AQWD2Y25T24EHSNI

输出显示了五条规则,分别对应于 Service 的选择器匹配的五个 NGINX Pod 实例。这五条规则共同提供了跨所有实例的随机负载均衡,确保每个实例都有相同的机会被选中处理新的连接。

可能看起来有些奇怪的是,每条规则的 probability 数值会递增。这是必要的,因为规则是顺序评估的。对于第一条规则,我们希望有 20% 的概率选择第一个实例。然而,如果我们没有选择第一个实例,剩下的只有四个实例,因此我们希望有 25% 的概率选择第二个实例。相同的逻辑适用于所有后续实例,直到最后一个实例,我们希望在跳过了其他所有实例之后总是选择它。

让我们快速验证这些规则是否到达预期的目标地点(再次提醒,请确保在此命令中替换规则链的名称):

root@host01:~# iptables-save | grep KUBE-SEP-PIVU7ZHMCSOWIZ2Z
...
-A KUBE-SEP-PIVU7ZHMC ... -s 172.31.239.235/32 ... --comment "default/nginx" -j KUBE-MARK-MASQ
-A KUBE-SEP-PIVU7ZHMCSOWIZ2Z -p tcp ... -m tcp -j DNAT --to-destination 172.31.239.235:80

该输出展示了两条规则。第一条是 NAT 伪装配置的另一半,我们标记了所有离开 Pod 实例的包,以便它们的源地址可以被重写,看起来是来自 Service。第二条规则实际上是将流量路由到特定 Pod 的规则,它执行目标地址的重写,使得原本应该发送到 Service IP 的包现在发送到 Pod。之后,覆盖网络接管,实际上将数据包发送到正确的容器。

通过理解 Service 流量是如何被路由的,我们可以理解为什么 ICMP 包没有通过。kube-proxy 创建的防火墙规则仅适用于目标端口为 80 的 TCP 流量。因此,没有防火墙规则来重写我们的 ICMP 包,因此它们无法到达能够回复它们的网络栈。类似地,如果我们有一个监听多个端口的容器,我们将能够直接使用 Pod 的 IP 地址连接到这些端口,但 Service IP 地址只会在我们明确声明该端口的 Service 规范时路由流量。这在部署应用程序时可能会引起混淆,Pod 按预期启动并监听流量,但 Service 配置错误导致流量无法路由到所有正确的目标端口。

外部网络

现在,我们已经具备了足够的网络层来满足所有内部集群通信需求。每个 Pod 都有自己的 IP 地址,并且可以连接到其他 Pod 以及控制平面,利用 Service 网络我们可以实现基于运行多个 Pod 实例的负载均衡和故障转移。然而,我们仍然缺少外部用户访问我们集群中服务的能力。

为了提供外部用户的访问,我们不能再仅依赖于集群特定的 IP 地址范围,因为外部网络无法识别这些地址范围。相反,我们需要一种方法将外部可路由的 IP 地址分配给我们的服务,可以通过显式地将 IP 地址与服务关联,或者使用 ingress 控制器 来监听外部流量并将其路由到服务。

外部服务

我们之前创建的 nginx Service 是一个 ClusterIP Service,它是默认的 Service 类型。Kubernetes 支持多种 Service 类型,包括为需要公开的服务而设计的类型:

None 也称为 无头 服务,用于启用对选定 Pod 的跟踪,但没有 IP 地址或任何网络路由行为。

ClusterIP 默认的 Service 类型,提供对选定 Pod 的跟踪,一个集群内路由的集群 IP 地址,以及在集群 DNS 中的一个知名名称。

NodePort 扩展了 ClusterIP,并为集群中的所有节点提供了一个端口,该端口路由到该服务。

LoadBalancer 扩展了 NodePort,并使用底层云提供商来获取一个外部可访问的 IP 地址。

ExternalName 在集群 DNS 中为一个知名服务名称设置别名,指向某个外部 DNS 名称。用于使外部资源看起来像集群内的服务。

在这些服务类型中,NodePortLoadBalancer 类型最适合将服务暴露到集群外部。LoadBalancer 类型似乎最直接,因为它只是为服务添加一个外部 IP 地址。然而,它需要与底层云环境集成,以便在创建服务时创建外部 IP 地址,将流量从该 IP 地址路由到集群的节点,并在集群外创建 DNS 记录,使外部用户能够在我们已经拥有的预注册域名下找到该服务,而不是仅在集群内有效的 cluster.local 域名。

因此,LoadBalancer 服务对于我们知道所使用的云环境,并且我们创建的服务会长期存在的情况最为有用。对于 HTTP 流量,我们可以通过将 NodePort 服务与入口控制器一起使用,获得 LoadBalancer 服务的大部分好处,并且还可以更好地支持动态部署新的应用程序和服务。

在继续讨论入口控制器之前,让我们将现有的 nginx 服务转换为 NodePort 服务,这样我们就可以查看效果。我们可以通过使用补丁文件来实现这一点:

nginx-nodeport.yaml

---
spec:
  type: NodePort

补丁文件允许我们仅更新我们关心的特定字段。在这种情况下,我们只更新服务的类型。为了使其生效,我们只需要在正确的位置指定一个更改的字段,这样 Kubernetes 就能知道该修改哪个字段。我们不需要更改服务的选择器或端口,只需要更改类型,因此补丁非常简单。

让我们使用这个补丁:

root@host01:~# kubectl patch svc nginx --patch-file /opt/nginx-nodeport.yaml 
service/nginx patched

对于这个命令,我们必须指定要补丁的资源和要使用的补丁文件。其结果与我们编辑服务的 YAML 资源文件并再次使用 kubectl apply 相同。

服务现在看起来有点不同:

root@host01:~# kubectl get service nginx
NAME    TYPE       CLUSTER-IP       EXTERNAL-IP   PORT(S)        AGE
nginx   NodePort   10.100.221.220   <none>        80:31326/TCP   2h

NodePort 服务提供了 ClusterIP 服务的所有行为,因此我们仍然拥有与我们的 nginx 服务关联的集群 IP。服务甚至保留了相同的集群 IP。唯一的变化是 PORT 字段现在显示服务端口 80 被附加到节点端口 31326。

每个集群节点上的 kube-proxy 服务正在监听这个端口(请确保使用适合你服务的正确节点端口):

root@host01:~# ss -nlp | grep 31326
tcp   LISTEN 0  4096  .0.0.0:31326 ... users:(("kube-proxy",pid=3339,fd=15))

结果是,我们仍然可以在我们的 Pod 内部使用 nginx 服务名,但也可以使用来自主机的 NodePort:

root@host01:~# kubectl exec -ti pod -- wget -O - http://nginx
Connecting to nginx (10.100.221.220:80)
...
<title>Welcome to nginx!</title>
...
root@host01:~# wget -O - http://host01:31326
...
Connecting to host01 (host01)|127.0.2.1|:31326... connected.
...
<h1>Welcome to nginx!</h1>
...

因为kube-proxy监听所有网络接口,我们已经成功地将这个服务暴露给外部用户。

Ingress 服务

尽管我们已成功将 NGINX 服务暴露到集群外部,但仍然没有为外部用户提供良好的用户体验。要使用NodePort服务,外部用户需要知道至少一个集群节点的 IP 地址,并且需要知道每个服务监听的精确端口。如果该服务被删除并重新创建,则该端口可能会发生变化。我们可以通过告诉 Kubernetes 使用哪个端口来部分解决这个问题,但我们不想对任何任意服务这么做,因为多个服务可能会选择相同的端口。

我们真正需要的是一个单一的外部入口点,用于跟踪可用的多个服务,并使用规则将流量路由到它们。这样,我们就可以在集群内部完成所有的路由配置,以便服务可以动态地来来去去。同时,我们可以为我们的集群提供一个单一的、所有外部用户都能使用的入口点。

对于 HTTP 流量,Kubernetes 提供了正是这种能力,称之为Ingress。为了配置我们的集群将外部 HTTP 流量路由到服务,我们需要定义一组 Ingress 资源,指定路由规则,并部署接收和路由流量的入口控制器。当我们设置集群时,我们已经安装了入口控制器:

root@host01:~# kubectl -n ingress-nginx get deploy
NAME                       READY   UP-TO-DATE   AVAILABLE   AGE
ingress-nginx-controller   1/1     1            1           15d
root@host01:~# kubectl -n ingress-nginx get svc
NAME                      TYPE        ... PORT(S)               ...
ingress-nginx-controller  NodePort    ... 80:80/TCP,443:443/TCP ...
...

我们的入口控制器包括一个部署和一个服务。由于该服务类型为NodePort,我们知道kube-proxy正在集群所有节点的 80 和 443 端口上监听,准备将流量路由到相关的 Pod。

顾名思义,我们的入口控制器实际上是一个 NGINX web 服务器的实例;然而,在这种情况下,NGINX 仅作为 HTTP 反向代理,而不提供任何自己的网页内容。入口控制器监听集群中 Ingress 资源的变化,并根据定义的规则重新配置 NGINX,以连接到后端服务器。这些规则使用 HTTP 请求中的主机或路径信息来选择为该请求提供服务的服务。

让我们创建一个 Ingress 资源,将流量路由到我们在示例 9-1 中定义的nginx服务。以下是我们将创建的资源:

nginx-ingress.yaml

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web01
spec:
  rules:
    - host: web01
      http:
        paths:
          - path: /
 pathType: Prefix
            backend:
              service:
                name: nginx
                port:
                  number: 80

这个资源指示入口控制器查看 HTTP Host头部。如果它看到web01作为Host头部,它就会尝试与我们指定的paths中的路径进行匹配。在这种情况下,所有路径都会匹配/前缀路径,因此所有流量都会路由到nginx服务。

在将其应用到集群之前,让我们确认一下如果我们尝试使用一个入口控制器无法识别的主机名会发生什么。我们将使用与集群相关联的高可用性 IP 地址,因为集群的负载均衡器会将其转发到其中一个实例:

root@host01:~# curl -vH "Host:web01" http://192.168.61.10
...
> Host:web01
...
<head><title>404 Not Found</title></head>
...

curl 命令中的 -H "Host:web01" 标志告诉 curl 在 HTTP 请求中使用 host01 作为 Host 头部的值。考虑到我们示例集群中没有能够将 web01 转换为集群 IP 地址的 DNS 服务器,这是必要的。

如我们所见,作为 Ingress 控制器的 NGINX 服务器被配置为每当收到不匹配任何已配置的 Ingress 资源的请求时,都会回复一个 404 Not Found 错误信息。在这种情况下,因为我们还没有创建任何 Ingress 资源,所以任何请求都会得到这个响应。

让我们将 web01 Ingress 资源应用到集群中:

root@host01:~# kubectl apply -f /opt/nginx-ingress.yaml 
ingress.networking.k8s.io/web01 created

现在既然已存在 Ingress 资源,如清单 9-3 所示,集群的高可用性 IP 和各个主机上的 HTTP 80 端口请求都会被路由到 nginx 服务:

root@host01:~# curl -vH "Host:web01" http://host01
...
> Host:web01
...
<title>Welcome to nginx!</title>
...
root@host01:~# curl -vH "Host:web01" http://192.168.61.10
...
> Host:web01
...
<title>Welcome to nginx!</title>
...

清单 9-3:通过 Ingress 配置 NGINX

两种情况的输出是相同的,显示流量正被路由到 nginx 服务。

web01-ingress 资源中,我们能够使用 nginx 服务的裸名称。服务名称的查找是基于 Ingress 资源所在的位置。由于我们在默认的命名空间中创建了 Ingress 资源,因此它会首先在该命名空间中查找服务。

将这一切结合起来,我们现在有了一个高可用性解决方案,将外部用户的流量路由到集群中的 HTTP 服务器。这将集群的高可用性 IP 地址 192.168.61.10 与暴露为 NodePort 服务的 Ingress 控制器结合在一起,该服务位于集群所有节点的 80 端口。Ingress 控制器可以通过创建新的 Ingress 资源动态配置以暴露其他服务。

生产环境中的 Ingress

清单 9-3 中的 curl 命令看起来仍然有点奇怪,因为我们需要手动覆盖 HTTP Host 头。为了在生产集群中使用 Ingress 资源暴露服务,我们还需要执行一些额外的步骤。

首先,我们需要让集群拥有一个可外部路由的 IP 地址,并且这个地址需要有一个在 DNS 中注册的知名名称。做到这一点的最佳方法是使用通配符 DNS 方案,使得给定域名下的所有主机都路由到集群的外部 IP。例如,如果我们拥有 cluster.example.com 域名,我们可以创建一个 DNS 条目,使得 *.cluster.example.com 路由到集群的外部 IP 地址。

这种方法在跨多个网络的大型集群中仍然有效。我们只需要为 DNS 条目关联多个 IP 地址,可能还需要使用基于位置的 DNS 服务器,将客户端路由到最接近的服务。

接下来,我们需要为我们的 Ingress 控制器创建一个 SSL 证书,该证书包含我们的通配符 DNS 作为主题备用名称(SAN)。这将使得我们的 Ingress 控制器能够为外部用户提供安全的 HTTP 连接,无论他们使用的是哪个特定的服务主机名。

最后,当我们定义我们的 Service 时,需要为 host 字段指定完全限定的域名。对于上述示例,我们应该指定 web01.cluster.example.com,而不是仅仅使用 web01

在完成这些额外步骤之后,任何外部用户都可以通过 HTTPS 连接到我们 Service 的完全限定主机名,例如 https://web01.cluster.example.com。这个主机名会解析到我们集群的外部 IP 地址,负载均衡器会将流量路由到集群的某个节点。此时,我们的 Ingress 控制器会监听标准端口 443,提供其通配符证书,该证书与客户端的期望匹配。安全连接建立后,Ingress 控制器会检查 HTTP Host 头,并将连接代理到正确的 Service,随后将 HTTP 响应返回给客户端。

这种方法的优点是,一旦我们完成设置,就可以随时部署新的 Ingress 资源来将 Service 暴露到外部,只要我们选择一个唯一的主机名,就不会与任何其他暴露的 Service 冲突。在初始设置完成后,所有配置都保存在集群内部,我们仍然为所有 Service 保持高度可用的配置。

最后思考

在 Kubernetes 集群中路由网络流量可能涉及相当复杂的操作,但最终结果是直接的:我们可以将应用程序组件部署到集群中,并实现自动扩展和故障转移,外部用户可以使用一个广为人知的名称访问我们的应用,而不需要知道应用程序是如何部署的,或者我们使用了多少个容器实例来满足需求。如果我们将应用程序构建得具有弹性,那么我们的应用程序容器可以在不影响用户的情况下升级到新版本或因故障重启。

当然,如果我们要构建具有弹性的应用程序组件,那么了解容器部署过程中可能出现的问题非常重要。在下一章中,我们将讨论一些在 Kubernetes 集群中部署容器时常见的问题以及如何调试这些问题。

第十章:当事情出错时

image

到目前为止,我们的 Kubernetes 安装和配置进展顺利,控制器在创建 Pods 和启动容器方面没有问题。当然,在现实世界中,事情很少这么简单。虽然无法展示复杂应用部署中可能出现的所有问题,但我们可以看看一些最常见的问题。最重要的是,我们可以探索一些调试工具,帮助我们诊断任何问题。

在本章中,我们将研究如何诊断在 Kubernetes 上部署的应用容器的问题。我们将循序渐进地了解调度和运行容器的生命周期,检查每个步骤可能出现的问题,以及如何诊断和解决它们。

调度

调度是 Kubernetes 对 Pod 及其容器执行的第一个操作。当一个 Pod 被创建时,Kubernetes 调度器会将其分配给一个节点。通常,这个过程会很快自动完成,但某些问题可能会阻止调度的成功执行。

无可用节点

一种可能性是调度器根本没有可用的节点。这种情况可能是因为我们的集群没有配置任何用于常规应用容器的节点,或者因为所有节点都已失败。

为了说明没有可用节点进行分配的情况,我们将创建一个带有 节点选择器 的 Pod。节点选择器指定一个或多个节点标签,Pod 必须在匹配这些标签的节点上进行调度。节点选择器在集群中的某些节点与其他节点有所不同时很有用(例如,当一些节点拥有更新的 CPU,支持容器所需的更高级指令集时)。

注意

本书的示例仓库位于 github.com/book-of-kubernetes/examples有关设置的详细信息,请参见 第 xx 页 中的“运行示例”部分。

我们将从一个具有节点选择器的 Pod 定义开始,这个选择器与我们的任何节点都不匹配:

nginx-selector.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  containers:
  - name: nginx
    image: nginx
  nodeSelector:
 ➊ purpose: special

节点选择器 ➊ 告诉 Kubernetes 只将这个 Pod 分配给一个标签为 purpose 且值为 special 的节点。尽管我们当前没有节点匹配该标签,我们仍然可以创建这个 Pod:

root@host01:~# kubectl apply -f /opt/nginx-selector.yaml
pod/nginx created

然而,Kubernetes 在尝试调度 Pod 时遇到了问题,因为它找不到匹配的节点:

root@host01:~# kubectl get pods -o wide
NAME    READY   STATUS    RESTARTS   AGE    IP       NODE     ...
nginx   0/1     Pending   0          113s   <none>   <none>   ...

我们看到的状态是 Pending,节点分配为 <none>。这是因为 Kubernetes 还没有将这个 Pod 调度到一个节点上。

kubectl get 命令通常是我们应该运行的第一个命令,用于查看我们部署到集群中的资源是否存在问题。如果出现问题,就像在本例中一样,下一步是使用 kubectl describe 查看详细的状态和事件日志:

root@host01:~# kubectl describe pod nginx
Name:         nginx
Namespace:    default
...
Status:       Pending
...
Node-Selectors:              purpose=special

Events:
  Type     Reason            Age    From               Message
  ----     ------            ----   ----               -------
  Warning  FailedScheduling  4m36s  default-scheduler  0/3 nodes are 
    available: 3 node(s) didn't match Pod's node affinity/selector.
  Warning  FailedScheduling  3m16s  default-scheduler  0/3 nodes are 
    available: 3 node(s) didn't match Pod's node affinity/selector.

事件日志告诉我们具体问题所在:Pod 无法调度,因为没有节点匹配选择器。

让我们向其中一个节点添加必要的标签:

root@host01:~# kubectl get nodes
NAME     STATUS   ROLES        ...
host01   Ready    control-plane...
host02   Ready    control-plane...
host03   Ready    control-plane...
root@host01:~# kubectl label nodes host02 purpose=special
node/host02 labeled

我们首先列出可用的三个节点,然后将必要的标签应用到其中一个节点上。一旦我们应用了这个标签,Kubernetes 现在可以调度该 Pod:

root@host01:~# kubectl get pods -o wide
NAME    READY   STATUS    RESTARTS   AGE   IP               NODE     ...
nginx   1/1     Running   0          10m   172.31.89.196   host02   ...
root@host01:~# kubectl describe pod nginx
Name:         nginx
Namespace:    default
...
Events:
  Type     Reason            Age    From               Message
  ----     ------            ----   ----               -------
  Warning  FailedScheduling  10m    default-scheduler  0/3 nodes are 
    available: 3 node(s) didn't match Pod's node affinity/selector.
 Warning  FailedScheduling  9m17s  default-scheduler  0/3 nodes are 
    available: 3 node(s) didn't match Pod's node affinity/selector.
  Normal   Scheduled         2m22s  default-scheduler  Successfully assigned 
    default/nginx to host02
...

正如预期的那样,Pod 被调度到了我们应用了标签的节点上。

这个示例,与本章中我们将看到的其他示例一样,展示了如何在 Kubernetes 中进行调试。在我们创建了所需的资源后,我们查询集群状态,以确保这些资源的实际部署成功。当我们发现问题时,可以纠正这些问题,我们的资源将按照预期启动,而无需重新安装我们的应用组件。

让我们清理一下这个 NGINX Pod:

root@host01:~# kubectl delete -f /opt/nginx-selector.yaml
pod "nginx" deleted

让我们也从节点中移除标签。我们通过在标签后添加一个减号来移除它,以便标识:

root@host01:~# kubectl label nodes host02 purpose-
node/host02 unlabeled

我们已经解决了一个关于调度器的问题,但还有另一个问题需要我们关注。

资源不足

在选择节点来托管 Pod 时,调度器还会考虑每个节点上可用的资源以及 Pod 所需的资源。我们在第十四章中详细探讨了资源限制;目前只需要知道,每个容器都可以请求它所需的资源,调度器将确保它被调度到一个有这些资源可用的节点。当然,如果没有节点有足够的资源,调度器将无法调度该 Pod。相反,Pod 会处于 Pending 状态等待。

让我们看一个示例 Pod 定义来说明这一点:

sleep-multiple.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: sleep
spec:
  containers:
  - name: sleep
    image: busybox
 command: 
      - "/bin/sleep"
      - "3600"
    resources:
      requests:
        cpu: "2"
  - name: sleep2
    image: busybox
    command: 
      - "/bin/sleep"
      - "3600"
    resources:
      requests:
        cpu: "2"

在这个 YAML 定义中,我们在同一个 Pod 中创建了两个容器。每个容器请求两个 CPU。因为 Pod 中的所有容器必须在同一个主机上,以便共享某些 Linux 命名空间类型(尤其是网络命名空间,这样它们可以使用 localhost 进行通信),所以调度器需要找到一个有四个 CPU 可用的单一节点。在我们的一个小集群中,这是不可能的,正如我们尝试部署该 Pod 时所看到的那样:

root@host01:~# kubectl apply -f /opt/sleep-multiple.yaml
pod/sleep created
root@host01:~# kubectl get pods -o wide
NAME    READY   STATUS    RESTARTS   AGE   IP       NODE   ...
sleep   0/2     Pending   0          7s    <none>   <none> ...

如之前所述,kubectl describe 给出了事件日志,揭示了问题:

root@host01:~# kubectl describe pod sleep
Name:         sleep
Namespace:    default
...
Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  71s   default-scheduler  0/3 nodes are 
    available: 3 Insufficient cpu.

请注意,无论我们的节点实际负载有多重,都无关紧要:

root@host01:~# kubectl top node
NAME     CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%   
host01   429m         21%    1307Mi          69%
host02   396m         19%    1252Mi          66%
host03   458m         22%    1277Mi          67%

容器实际使用多少 CPU 也无关紧要。调度器完全根据请求来分配 Pod;通过这种方式,当负载增加时,我们不会突然让 CPU 超负荷。

我们不能神奇地为我们的节点提供更多 CPU,因此,要让这个 Pod 被调度,我们需要为两个容器指定较低的 CPU 使用量。我们可以使用一个更合理的值:0.1 CPU:

sleep-sensible.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: sleep
spec:
  containers:
  - name: sleep
    image: busybox
    command: 
      - "/bin/sleep"
      - "3600"
    resources:
      requests:
     ➊ cpu: "100m"
  - name: sleep2
    image: busybox
    command: 
      - "/bin/sleep"
      - "3600"
    resources:
      requests:
        cpu: "100m"

100m ➊ 等同于“100 毫 CPU”或 CPU 的十分之一 (0.1)。

即使这是一个单独的文件,它声明了相同的资源,因此 Kubernetes 会将其视为更新。然而,如果我们尝试将其应用为对现有 Pod 的更改,它将失败:

root@host01:~# kubectl apply -f /opt/sleep-sensible.yaml
The Pod "sleep" is invalid: spec: Forbidden: pod updates may not change 
  fields other than ...

我们不允许更改已存在 Pod 的资源请求,这也合乎逻辑,因为 Pod 在创建时只会分配给节点一次,改变资源使用可能会导致节点过载。

如果我们使用的是像 Deployment 这样的控制器,控制器可以为我们处理替换 Pods 的操作。由于我们是直接创建 Pod,因此需要手动删除然后重新创建它:

root@host01:~# kubectl delete pod sleep
pod "sleep" deleted
root@host01:~# kubectl apply -f /opt/sleep-sensible.yaml
pod/sleep created

我们的新 Pod 在节点分配上没有问题:

root@host01:~# kubectl get pods -o wide
NAME    READY   STATUS    RESTARTS   AGE   IP               NODE  ...
sleep   2/2     Running   0          51s   172.31.89.199   host02 ...

如果我们在节点上运行 kubectl describe,可以看到我们的新 Pod 已经分配到节点的一些 CPU:

root@host01:~# kubectl describe node host02
Name:               host02
...
Capacity:
  cpu:                2
...
Non-terminated Pods:          (10 in total)
  Namespace  Name     CPU Requests  CPU Limits  ...
  ---------  ----     ------------  ----------  ...
...
  default    sleep ➊ 200m (10%)    0 (0%)      ... 
...

请确保使用正确的节点名称,指向部署 Pod 的节点。因为我们的 Pod 有两个容器,每个请求 100m,所以它的总请求为 200m ➊。

让我们最后清理这个 Pod:

root@host01:~# kubectl delete pod sleep
pod "sleep" deleted

其他错误可能会阻止 Pod 被调度,但这些是最常见的问题。最重要的是,我们在这里使用的命令适用于所有情况。首先,使用 kubectl get 来确定 Pod 的当前状态,然后使用 kubectl describe 查看事件日志。这两个命令在出现问题时总是一个不错的起点。

拉取镜像

Pod 被调度到节点上后,本地的 kubelet 服务会与底层的容器运行时交互,创建一个隔离的环境并启动容器。然而,仍然有一个应用配置错误可能导致我们的 Pod 停留在 Pending 阶段:无法拉取容器镜像。

三个主要问题可能会导致容器运行时无法拉取镜像:

  • 无法连接到容器镜像注册表

  • 请求的镜像授权问题

  • 镜像在注册表中缺失

正如我们在 第五章 中所描述的,镜像注册表是一个 Web 服务器。通常,镜像注册表位于集群外部,节点需要能够连接到外部网络或互联网才能访问注册表。此外,大多数注册表支持发布需要身份验证和授权才能访问的私有镜像。当然,如果没有发布我们指定名称的镜像,容器运行时将无法从注册表拉取它。

所有这些错误在我们的 Kubernetes 集群中表现相同,仅在事件日志中的信息有所不同,因此我们只需要探索其中一个错误。我们将重点讨论可能最常见的问题:由于镜像名称中的拼写错误导致镜像缺失。

让我们尝试使用这个 YAML 文件创建一个 Pod:

nginx-typo.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  containers:
  - name: nginx
    image: nginz

因为在 Docker Hub 中没有名为 nginz 的镜像,所以无法拉取这个镜像。让我们看看将此资源添加到集群时会发生什么:

root@host01:~# kubectl apply -f /opt/nginx-typo.yaml
pod/nginx created
root@host01:~# kubectl get pods
NAME    READY   STATUS             RESTARTS   AGE
nginx   0/1     ImagePullBackOff   0          20s

我们的 Pod 状态是ImagePullBackOff,这立即传达了两个信息。首先,这个 Pod 尚未到达容器运行的阶段,因为它还没有拉取容器镜像。其次,与所有错误一样,Kubernetes 会继续尝试该操作,但会使用退避算法来避免让我们的集群资源过载。拉取镜像涉及通过网络与镜像仓库通信,如果在短时间内频繁发起请求,这对仓库来说既不礼貌也浪费网络带宽。此外,故障的原因可能是暂时性的,因此集群将继续尝试,希望问题能够解决。

Kubernetes 使用退避算法来重试错误,这对于调试非常重要。在这种情况下,我们显然不会将一个nginx镜像发布到 Docker Hub 来解决问题。但在一些我们通过发布镜像或更改镜像权限来修复问题的情况下,了解 Kubernetes 不会立即获取到这些更改也很重要,因为每次失败后,重试之间的延迟时间会增加。

让我们查看事件日志,以便看到这个退避过程的实际效果:

root@host01:~# kubectl describe pod nginx
Name:         nginx
Namespace:    default
...
Status:     ➊ Pending 
...
Events:
  Type     Reason     Age                 From               Message
  ----     ------     ----                ----               -------
  Normal   Scheduled  114s                default-scheduler  Successfully 
    assigned default/nginx to host03
...
  Warning  Failed     25s (x4 over 112s)  kubelet            Failed to pull 
    image "nginz": ... ➋ pull access denied, repository does not exist or may 
    require authorization  ...
...
  Normal   BackOff    1s ➌ (x7 over 111s)   kubelet            ...

如前所述,我们的 Pod 仍然处于Pending状态 ➊。然而,这时 Pod 已经完成了调度活动,并且开始拉取镜像。出于安全考虑,镜像仓库并不区分我们没有权限访问的私有镜像和缺失的镜像,因此 Kubernetes 只能告诉我们问题是两者之一 ➋。最后,我们可以看到 Kubernetes 在我们创建 Pod 的两分钟内尝试了七次拉取镜像 ➌,并且最后一次尝试是在一秒钟前。

如果我们等待几分钟,然后再次运行相同的kubectl describe命令,重点观察退避行为,我们可以看到每次重试之间的时间间隔变得非常长:

root@host01:~# kubectl describe pod nginx
Name:         nginx
Namespace:    default
...
Events:
  Type     Reason     Age                   From               Message
  ----     ------     ----                  ----               -------
...
  Normal   BackOff    4m38s (x65 over 19m)  kubelet            ...

现在 Kubernetes 已经在 19 分钟内尝试了 65 次拉取镜像。然而,随着时间的推移,延迟已经增加,且每次尝试之间的最大延迟达到了五分钟。这意味着在我们调试这个问题时,每次都需要等待最多五分钟来查看问题是否已解决。

让我们继续解决这个问题,以便能够看到实际效果。我们可以修复 YAML 文件并再次运行kubectl apply,但我们也可以使用kubectl set来修复它:

root@host01:~# kubectl set image pod nginx nginx=nginx
pod/nginx image updated
root@host01:~# kubectl get pods
NAME    READY   STATUS             RESTARTS   AGE
nginx   0/1     ImagePullBackOff   0          28m

kubectl set命令要求我们指定资源类型和名称;在本例中是pod nginx。然后我们指定nginx=nginx来提供要修改的容器名称(因为一个 Pod 可以有多个容器)以及新镜像。

我们修正了镜像名称,但 Pod 仍然显示ImagePullBackOff,因为我们必须等待五分钟的计时器结束,Kubernetes 才会再次尝试。下一次尝试时,镜像拉取成功,Pod 开始运行:

root@host01:~# kubectl get pods
NAME    READY   STATUS    RESTARTS   AGE
nginx   1/1     Running   0          32m

在继续之前,让我们先清理一下 Pod:

root@host01:~# kubectl delete pod nginx
pod "nginx" deleted

再次说明,我们通过使用 kubectl getkubectl describe 解决了问题。然而,当容器运行起来时,这些命令就不足以提供帮助了。

运行中的容器

在指示容器运行时拉取所需镜像后,kubelet 会告诉运行时启动容器。对于本章中的其他示例,我们假设容器运行时按预期工作。此时,我们将面临的主要问题是容器未按预期启动。让我们从一个简单的调试示例开始,看看容器无法运行的情况,然后再看一个更复杂的示例。

使用日志进行调试

对于我们的简单示例,我们首先需要一个 Pod 定义,里面的容器在启动时会失败。下面是一个会导致失败的 PostgreSQL Pod 定义:

postgres-misconfig.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: postgres
spec:
  containers:
  - name: postgres
    image: postgres

这个定义看起来似乎没有问题,但 PostgreSQL 在容器中运行时有一些必要的配置。

我们可以使用 kubectl apply 来创建 Pod:

root@host01:~# kubectl apply -f /opt/postgres-misconfig.yaml
pod/postgres created

等待大约一分钟以便拉取镜像后,我们可以使用 kubectl get 检查状态,这时我们会看到一个之前没有见过的状态:

root@host01:~# kubectl get pods
NAME       READY   STATUS             RESTARTS     AGE
postgres   0/1     CrashLoopBackOff   1 (8s ago)   25s

CrashLoopBackOff 状态表示 Pod 中的一个容器已经退出。由于这不是一个 Kubernetes 作业,它并不期望容器退出,所以它被认为是崩溃。

如果你在合适的时间查看 Pod,可能会看到 Error 状态,而不是 CrashLoopBackOff。这是暂时的:Pod 在崩溃后会立即过渡到该状态。

ImagePullBackOff 状态类似,CrashLoopBackOff 使用一种算法来重试失败,每次失败时增加重试之间的时间,以避免给集群带来过大负担。我们可以等待几分钟,再次打印状态来查看这种退避情况:

root@host01:~# kubectl get pods
NAME       READY   STATUS             RESTARTS       AGE
postgres   0/1     CrashLoopBackOff   5 (117s ago)   5m3s

在经过五次重启后,我们已经进入了每次重试之间需要等待超过一分钟的状态。等待时间将继续增加,直到达到五分钟,然后 Kubernetes 会继续每五分钟重试一次,直到无限期地进行下去。

让我们像往常一样使用 kubectl describe 尝试获取更多关于此失败的信息:

root@host01:~# kubectl describe pod postgres
Name:         postgres
Namespace:    default
...
Containers:
  postgres:
...
    State:          Waiting
      Reason:       CrashLoopBackOff
    Last State:     Terminated
      Reason:       Error
      Exit Code:    1
...
Events:
  Type     Reason     Age                    From               Message
  ----     ------     ----                   ----               -------
...
  Warning  BackOff    3m13s (x24 over 8m1s)  kubelet            Back-off 
    restarting failed container

kubectl describe 命令确实为我们提供了一个有用的信息:容器的退出代码。然而,这只是告诉我们发生了某种错误;它不足以完全调试失败的原因。为了弄清楚容器失败的原因,我们将使用 kubectl logs 命令查看容器日志:

root@host01:~# kubectl logs postgres
Error: Database is uninitialized and superuser password is not specified.
  You must specify POSTGRES_PASSWORD to a non-empty value for the
  superuser. For example, "-e POSTGRES_PASSWORD=password" on "docker run".
...

即使容器已经停止,我们依然可以查看日志,因为容器运行时已经捕获了它们。

这个消息直接来自 PostgreSQL 本身。幸运的是,它告诉我们问题的具体原因:我们缺少一个必需的环境变量。我们可以通过更新 YAML 资源文件快速修复这个问题:

postgres-fixed.yaml

---
apiVersion: v1
kind: Pod
metadata:
 name: postgres
spec:
  containers:
  - name: postgres
    image: postgres
 ➊ env:
    - name: POSTGRES_PASSWORD
      value: "supersecret"

env 字段 ➊ 添加了一个配置,用来传递所需的环境变量。当然,在实际系统中,我们不会将这些信息直接写在 YAML 文件中以明文方式存储。我们将在第十六章中讨论如何保护这种信息。

要应用这个更改,我们首先需要删除 Pod 定义,然后将新的资源配置应用到集群中:

root@host01:~# kubectl delete pod postgres
pod "postgres" deleted
root@host01:~# kubectl apply -f /opt/postgres-fixed.yaml
pod/postgres created

如前所述,如果我们使用的是控制器(如 Deployment),我们可以直接更新 Deployment,它会为我们处理删除旧 Pod 并创建新 Pod 的任务。

现在我们已经修复了配置,我们的 PostgreSQL 容器按预期启动:

root@host01:~# kubectl get pods
NAME       READY   STATUS    RESTARTS   AGE
postgres   1/1     Running   0          77s

在继续下一个例子之前,让我们清理一下这个 Pod:

root@host01:~# kubectl delete pod postgres
pod "postgres" deleted

大多数编写良好的应用程序在终止之前会打印日志消息,但我们需要为更困难的情况做好准备。让我们再看一个包含两种新调试方法的例子。

使用 Exec 调试

对于这个例子,我们需要一个表现不好的应用程序。我们将使用一个进行不当内存访问的 C 程序。这个程序被打包进一个 Alpine Linux 容器中,以便我们可以在 Kubernetes 中将其作为容器运行。以下是 C 源代码:

crasher.c

int main() {
  char *s = "12";
  s[2] = '3';
 return 0;
}

代码的第一行创建了一个指向长度为两个字符的字符串的指针;第二行尝试写入不存在的第三个字符,导致程序立即终止。

这个 C 程序可以通过使用gcc编译在任何系统上生成一个crasher可执行文件。如果你在主机 Linux 系统上构建,可以使用这个gcc命令:

$ gcc -g -static -o crasher crasher.c

-g 参数确保调试符号可用。我们稍后将使用这些符号。-static 参数最为重要;我们希望将其打包为一个独立的应用程序,放入 Alpine 容器镜像中。如果我们在其他 Linux 发行版(如 Ubuntu)上构建,标准库基于不同的工具链,动态链接将失败。因此,我们希望我们的可执行文件将所有依赖项静态链接。最后,我们使用-o来指定输出的可执行文件名称,并提供 C 源文件的名称。

另外,你可以直接使用已经构建并发布到 Docker Hub 的容器镜像,镜像名称为bookofkubernetes/crasher: stable。这个镜像是通过 GitHub Actions 自动构建并发布的,基于仓库中的代码 github.com/book-of-kubernetes/crasher。以下是该仓库中的 Dockerfile

Dockerfile

FROM alpine AS builder
COPY ./crasher.c /
RUN apk --update add gcc musl-dev && \
    gcc -g -o crasher crasher.c

FROM alpine
COPY --from=builder /crasher /crasher
CMD [ "/crasher" ]

这个 Dockerfile 利用了 Docker 的多阶段构建功能,减少了最终镜像的大小。为了在 Alpine 容器中进行编译,我们需要gcc和核心的 C 头文件及库。然而,这些会使容器镜像显著增大。我们只在编译时需要它们,因此希望避免将这些额外内容包含在最终镜像中。

当我们使用在第五章中看到的 docker build 命令来构建时,Docker 会基于 Alpine Linux 创建一个容器,将我们的源代码复制到其中,安装开发工具,并编译应用程序。然后,Docker 会使用一个新的 Alpine Linux 容器重新开始,并将第一个容器中生成的可执行文件复制到新的容器中。最终的容器镜像来自第二个容器,因此我们避免将开发工具添加到最终镜像中。

让我们在 Kubernetes 集群中运行这个镜像。这次我们将使用 Deployment 资源,以便可以演示如何编辑它来解决崩溃的容器问题:

crasher-deploy.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: crasher
spec:
  replicas: 1
  selector:
    matchLabels:
      app: crasher
  template:
    metadata:
      labels:
        app: crasher
    spec:
      containers:
      - name: crasher
        image: bookofkubernetes/crasher:stable

这个基本的 Deployment 与我们在第七章中介绍的 Deployment 非常相似。我们指定 image 字段来匹配镜像发布的位置。

我们可以像往常一样将这个 Deployment 添加到集群中:

root@host01:~# kubectl apply -f /opt/crasher-deploy.yaml
deployment.apps/crasher created

一旦 Kubernetes 有机会调度 Pod 并拉取镜像,它就会开始崩溃,正如预期的那样:

root@host01:~# kubectl get pods
NAME                       READY   STATUS             RESTARTS      AGE
crasher-76cdd9f769-5blbn   0/1     CrashLoopBackOff   3 (24s ago)   73s

如前所述,使用 kubectl describe 只能告诉我们容器的退出代码。还有另一种获取退出代码的方法;我们可以使用 kubectl get 的 JSON 输出格式和 jq 工具来捕获退出代码:

root@host01:~# kubectl get pod crasher-7978d9bcfb-wvx6q -o json | \
  jq '.status.containerStatuses[].lastState.terminated.exitCode'
139

一定要使用 kubectl get pods 输出中的正确 Pod 名称。我们需要的特定字段路径是基于 Kubernetes 内部如何跟踪该资源的;通过一些实践,构建路径并使用 jq 捕获特定字段会变得更加容易,这是在脚本编写中非常有用的技巧。

退出代码 139 告诉我们容器因段错误而终止。然而,日志对于诊断问题没有帮助,因为我们的程序在崩溃之前没有打印任何信息:

root@host01:~# kubectl logs crasher-76cdd9f769-5blbn
[ no output ]

我们遇到了一个大问题。日志没有帮助,所以下一步是使用 kubectl exec 进入容器。然而,容器在应用程序崩溃后立即停止,没能维持足够长的时间让我们进行调试工作。

为了解决这个问题,我们需要一种方法来启动容器而不运行崩溃的程序。我们可以通过覆盖默认命令来让容器保持运行状态。由于我们是基于 Alpine Linux 镜像构建的,sleep 命令可供我们使用。

我们可以编辑 YAML 文件并更新 Deployment,但也可以直接使用 kubectl edit 命令编辑 Deployment,这样当前的定义会在编辑器中打开,我们所做的任何更改都会保存到集群中:

root@host01:~# kubectl edit deployment crasher

这将会在编辑器窗口中打开 vi,里面包含以 YAML 格式表示的 Deployment 资源。该资源会包含比我们创建时更多的字段,因为 Kubernetes 会向我们展示资源的状态以及一些带有默认值的字段。

如果你不喜欢 vi,可以在 kubectl edit 命令前加上 KUBE_EDITOR=nano 来使用 Nano 编辑器。

在文件中,找到这些行:

    spec:
      containers:
      - image: bookofkubernetes/crasher:stable
        imagePullPolicy: IfNotPresent

即使imagePullPolicy行未出现在 YAML 资源中,你仍会看到它,因为 Kubernetes 已自动将默认策略添加到资源中。在imageimagePullPolicy之间添加一行,使结果如下所示:

    spec:
      containers:
      - image: bookofkubernetes/crasher:stable
        args: ["/bin/sleep", "infinity"]
        imagePullPolicy: IfNotPresent

这行新增的代码覆盖了容器的默认命令,使其运行sleep,而不是运行我们的崩溃程序。保存并退出编辑器,kubectl将会加载新的定义:

deployment.apps/crasher edited

kubectl将此更改应用到集群后,Deployment 必须删除旧的 Pod 并创建一个新的 Pod。这个过程会自动完成,因此我们唯一能注意到的区别是 Pod 名称中自动生成的部分。当然,我们还会看到 Pod 正在运行:

root@host01:~# kubectl get pods
NAME                       READY   STATUS    RESTARTS   AGE
crasher-58d56fc5df-vghbt   1/1     Running   0          3m29s

我们的 Pod 现在正在运行,但它只是在运行sleep。我们仍然需要调试我们的实际应用程序。为此,我们现在可以在容器内获取一个 Shell 提示符:

root@host01:~# kubectl exec -ti crasher-58d56fc5df-vghbt -- /bin/sh
/ #

当我们更改定义时,Deployment 替换了 Pod,因此名称发生了变化。如前所述,请使用正确的 Pod 名称。此时我们可以手动尝试我们的崩溃程序:

/ # /crasher
Segmentation fault (core dumped)

在许多情况下,能够以这种方式运行程序,调整不同的环境变量和命令行选项,可能足以找到并修复问题。或者,我们可以尝试使用strace来运行程序,它会告诉我们程序在崩溃之前尝试进行哪些系统调用以及尝试打开哪些文件。在这种情况下,我们知道程序因段错误崩溃,这意味着问题很可能是编程错误,因此我们最好的方法是通过端口转发将调试工具连接到应用程序。

使用端口转发进行调试

我们将使用基于文本的调试器gdb来演示端口转发,但任何可以通过网络端口连接的调试器都可以使用。首先,我们需要使用一个调试器在容器内创建我们的应用程序,该调试器将在网络端口上监听,并在运行代码之前等待。为此,我们需要在容器内安装gdb。由于这是一个 Alpine 容器,我们将使用apk

/ # apk add gdb
...
(13/13) Installing gdb (10.1-r0)
Executing busybox-1.32.1-r3.trigger
OK: 63 MiB in 27 packages

我们安装的gdb版本包含gdbserver,它使我们能够启动一个网络调试会话。

由于gdb是一个基于文本的调试器,我们显然可以直接启动它来调试应用程序,但使用带有 GUI 的调试器通常更为方便,因为它使我们更容易逐步调试源代码、设置断点和观察变量。因此,我将展示如何通过网络连接调试器的过程。

让我们启动gdbserver并设置它监听端口2345

/ # gdbserver localhost:2345 /crasher
Process /crasher created; pid = 25
Listening on port 2345

请注意,我们告诉gdbserver监听localhost接口。我们仍然可以连接到调试器,因为 Kubernetes 将为我们提供通过kubectl port-forward命令进行端口转发的功能。此命令使kubectl连接到 API 服务器,并请求它将流量转发到指定 Pod 上的特定端口。其优势在于,我们可以从任何能够连接到 API 服务器的地方使用此端口转发功能,甚至是集群外部。

专门使用端口转发来运行远程调试器可能不是 Kubernetes 集群管理员或容器化应用程序开发人员的日常工作,但当没有其他方法找到 bug 时,这是一项有价值的技能。它也是展示端口转发功能以访问 Pod 的一个绝佳方式。

由于我们的调试器正在第一个终端中运行,我们需要另一个终端标签或窗口来进行端口转发,这可以从我们集群中的任何主机完成。我们使用host01

root@host01:~# kubectl port-forward pods/crasher-58d56fc5df-vghbt 2345:2345
Forwarding from 127.0.0.1:2345 -> 2345
Forwarding from [::1]:2345 -> 2345

kubectl命令开始在端口2345上监听,并将所有流量通过 API 服务器转发到我们指定的 Pod。由于此命令会持续运行,我们需要另一个终端窗口或标签来执行我们的最后一步,即运行将用于连接到容器中运行的调试服务器的调试器。这必须在与kubectl port-forward命令相同的主机上完成,因为该程序仅在本地接口上监听。

此时,我们可以运行任何知道如何与调试服务器通信的调试器。为了简单起见,我们再次使用gdb。我们将首先切换到/opt目录,因为我们的 C 源文件在那里:

root@host01:~# cd /opt

现在我们可以启动gdb并使用它连接到调试服务器:

root@host01:/opt# gdb -q
(gdb) target remote localhost:2345
Remote debugging using localhost:2345
...
Reading /crasher from remote target...
Reading symbols from target:/crasher...
0x0000000000401bc0 in _start ()

我们的调试会话成功连接并等待我们启动程序,我们将使用continue命令来启动:

(gdb) continue
Continuing.

Program received signal SIGSEGV, Segmentation fault.
main () at crasher.c:3
3         s[2] = '3';

使用调试器,我们能够准确看到哪一行源代码导致了段错误,现在我们可以弄清楚如何修复它。

最终想法

当我们将应用程序组件移入容器镜像并在 Kubernetes 集群中运行时,我们在可扩展性和自动故障转移方面获得了巨大的好处,但也引入了在启动应用程序时可能出错的新问题,并带来了调试这些问题的新挑战。在本章中,我们探讨了如何使用 Kubernetes 命令系统地跟踪我们的应用程序启动和运行,以确定是什么阻止了它正常工作。通过这些命令,我们可以调试在应用程序级别发生的任何问题,即使某个应用程序组件在容器化环境中无法正确启动。

现在我们已经清楚地了解了如何使用 Kubernetes 运行容器,接下来我们可以深入研究集群本身的功能。在这个过程中,我们将确保探讨每个组件的工作原理,以便拥有诊断问题所需的工具。我们将在下一章开始,详细了解 Kubernetes 控制平面。

第十一章:控制平面和访问控制

图片

控制平面管理 Kubernetes 集群,存储应用程序的期望状态,监视当前状态以检测和恢复任何问题,调度新容器,并配置网络路由。在本章中,我们将仔细研究 API 服务器,这是控制平面的主要接口,也是检索任何状态和对整个集群进行更改的入口点。

虽然我们将重点放在 API 服务器上,但控制平面包括多个其他服务,每个服务都有各自的角色。其他控制平面服务作为 API 服务器的客户端,监视集群变化,并采取适当措施来更新集群的状态。以下列表描述了其他控制平面组件:

调度程序 将每个新 Pod 分配给一个节点。

控制器管理器 具有多种责任,包括为部署创建 Pod、监视节点并对故障做出反应。

云控制器管理器 这是一个可选组件,与底层云提供程序接口,检查节点并配置网络流量路由。

当我们演示 API 服务器的工作原理时,我们还将看到 Kubernetes 如何管理安全性,以确保只有授权的用户和服务可以查询集群并进行更改。像 Kubernetes 这样的容器编排环境的目的是为我们可能需要运行的任何类型的容器化应用程序提供平台,因此这种安全性至关重要,以确保集群仅按预期使用。

API 服务器

尽管它在 Kubernetes 架构中的核心地位,API 服务器的目的很简单。它使用 HTTP 和表征状态转移(REST)暴露接口,用于执行集群中资源的基本创建、检索、更新和删除。它执行身份验证以识别客户端,授权以确保客户端对特定请求有权限,并验证以确保任何创建或更新的资源与相应的规范匹配。它还根据从客户端接收到的命令读取和写入数据存储。

然而,API 服务器并不负责实际更新集群的当前状态以匹配期望状态。这是其他控制平面和节点组件的责任。例如,如果客户端创建一个新的 Kubernetes 部署,API 服务器的工作仅仅是更新数据存储中的资源信息。然后,调度程序负责决定 Pod 将在哪里运行,分配给节点上的 kubelet 服务负责创建和监视容器,并配置网络以将流量路由到容器。

在这一章中,我们有一个由自动化脚本配置的三节点 Kubernetes 集群。三台节点都充当控制平面节点,因此有三份 API 服务器在运行。我们可以与其中任意一台进行通信,因为它们共享同一个后端数据库。API 服务器正在端口 6443 上监听安全 HTTP 连接,这是默认端口。

注意

本书的示例仓库位于 github.com/book-of-kubernetes/examples有关设置的详细信息,请参见第 xx 页中的“运行示例”。

我们一直在使用kubectl与 API 服务器通信,创建和删除资源并获取状态,且kubectl一直通过端口 6443 使用安全 HTTP 与集群通信。它之所以这样做,是因为在集群初始化时,kubeadm将一个 Kubernetes 配置文件安装到/etc/kubernetes中。这个配置文件还包含认证信息,使我们能够读取集群状态并进行更改。

因为 API 服务器期待安全 HTTP 连接,我们可以使用curl直接与 Kubernetes API 通信。这将帮助我们更好地理解通信是如何工作的。我们从一个简单的curl命令开始:

root@host01:~# curl https://192.168.61.11:6443/
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html
...

这个错误信息表明curl不信任 API 服务器提供的证书。我们可以使用curl查看这个证书:

root@host01:~# curl -kv https://192.168.61.11:6443/
...
* Server certificate:
*  subject: CN=kube-apiserver
...
*  issuer: CN=kubernetes
...

-k选项告诉curl忽略任何证书问题,而-v选项则告诉curl提供更多的连接日志信息。

为了让curl信任这个证书,它需要信任issuer,因为 issuer 是证书的签署者。我们来从 Kubernetes 安装中提取证书,这样我们就可以将curl指向它:

root@host01:~# cp /etc/kubernetes/pki/ca.crt .

确保在文件名末尾加上.,将该文件复制到当前目录。我们这么做完全是为了方便后面命令的输入。

在使用证书之前,让我们先查看一下它:

root@host01:~# openssl x509 -in ca.crt -text
Certificate:
...
        Issuer: CN = kubernetes
...
        Subject: CN = kubernetes

IssuerSubject是相同的,因此这是一个自签名证书。它是通过kubeadm在初始化集群时创建的。使用生成的证书使得kubeadm能够适应我们特定的集群网络配置,并且允许我们的集群拥有唯一的证书和密钥,而无需外部证书机构(CA)。然而,这意味着我们需要配置kubectl以信任此证书,以便在任何需要与 API 服务器通信的系统上使用。

现在我们可以告诉curl使用这个证书来验证 API 服务器:

root@host01:~# curl --cacert ca.crt https://192.168.61.11:6443/
{
...
  "status": "Failure",
  "message": "forbidden: User \"system:anonymous\" cannot get path \"/\"",
...
  "code": 403
}

现在我们已经为 curl 提供了正确的根证书,curl 可以验证 API 服务器证书,我们也能成功连接到 API 服务器。然而,API 服务器返回 403 错误,表示我们没有授权。这是因为目前我们没有为 curl 提供任何身份验证信息,导致 API 服务器将我们视为匿名用户。

最后一点:为了使此 curl 命令生效,我们需要选择性地使用主机名或 IP 地址。API 服务器监听所有网络接口,因此我们可以使用 localhost127.0.0.1 连接它。然而,这些并未列在 kube-apiserver 证书中,且由于 curl 不会信任连接,因此无法用于安全的 HTTP。

API 服务器身份验证

我们需要提供身份验证信息,API 服务器才会接受我们的请求,因此让我们了解一下 API 服务器的身份验证过程。身份验证是通过一组插件来处理的,每个插件会查看请求,以确定它是否能够识别客户端。第一个成功识别客户端的插件将身份信息提供给 API 服务器。然后,这个身份与授权一起使用,以确定客户端被允许执行的操作。

由于身份验证是基于插件的,因此可以根据需要使用多种不同的客户端身份验证方式。甚至可以在 API 服务器前添加一个代理,执行自定义身份验证逻辑,并通过 HTTP 头将用户的身份传递给 API 服务器。

对于我们的目的,我们将关注集群内部或集群设置过程中使用的三种主要身份验证插件:客户端证书启动令牌服务账户

客户端证书

如前所述,像 curl 这样的 HTTP 客户端通过将服务器的主机名与其证书进行比较,来验证服务器的身份,同时还会检查证书的签名是否与受信任的 CA 列表一致。除了检查服务器身份外,安全的 HTTP 还允许客户端向服务器提交证书。服务器将签名与其受信任的机构列表进行比对,然后使用证书的主题作为客户端的身份。

Kubernetes 广泛使用 HTTP 客户端证书身份验证,以便集群服务能够与 API 服务器进行身份验证。这包括控制平面组件以及每个节点上运行的 kubelet 服务。我们可以使用 kubeadm 列出控制平面使用的证书:

root@host01:~# kubeadm certs check-expiration
...
CERTIFICATE                ...  RESIDUAL TIME   CERTIFICATE AUTHORITY ...
admin.conf                 ...  363d                                  ...
apiserver                  ...  363d            ca                    ...
apiserver-etcd-client      ...  363d            etcd-ca               ...
apiserver-kubelet-client   ...  363d            ca                    ...
controller-manager.conf    ...  363d                                  ...
etcd-healthcheck-client    ...  363d            etcd-ca               ...
etcd-peer                  ...  363d            etcd-ca               ...
etcd-server                ...  363d            etcd-ca               ...
front-proxy-client         ...  363d            front-proxy-ca        ...
scheduler.conf             ...  363d                                  ...
...

RESIDUAL TIME 列显示证书过期前剩余的时间;默认情况下,它们在一年后过期。使用 kubeadm certs renew 来续订证书,传递证书的名称作为参数。

列表中的第一个项目 admin.conf 是我们在过去几章中用于验证自己身份的方式。在初始化过程中,kubeadm 创建了这个证书,并将其信息存储在 /etc/kubernetes/admin.conf 文件中。我们运行的每个 kubectl 命令都在使用这个文件,因为我们的自动化脚本设置了 KUBECONFIG 环境变量:

root@host01:~# echo $KUBECONFIG
/etc/kubernetes/admin.conf

如果我们没有设置 KUBECONFIGkubectl 将使用默认文件,即用户主目录下的 .kube/config 文件。

admin.conf 凭据旨在提供紧急访问集群的权限,绕过授权。在生产集群中,我们会避免直接使用这些凭据进行日常操作。相反,生产集群的最佳做法是为管理员和普通用户集成一个单独的身份管理器。对于我们的示例,由于没有单独的身份管理器,我们将为普通用户创建一个额外的证书。这种证书可能对在集群外部运行的自动化进程有用,但它无法与身份管理器集成。

我们可以使用 kubeadm 创建一个新的客户端证书:

root@host01:~# kubeadm kubeconfig user --client-name=me \
  --config /etc/kubernetes/kubeadm-init.yaml > kubeconfig

kubeadm kubeconfig user 命令请求 API 服务器生成一个新的客户端证书。由于这个证书是由集群的 CA 签名的,因此它可以用于身份验证。证书与连接 API 服务器所需的配置一起保存在 kubeconfig 文件中:

root@host01:~# cat kubeconfig
apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: ...
    server: https://192.168.61.10:6443
  name: kubernetes
contexts:
- context:
    cluster: kubernetes
    user: me
  name: me@kubernetes
current-context: me@kubernetes
kind: Config
preferences: {}
users:
- name: me
  user:
    client-certificate-data: ...
    client-key-data: ...

clusters 部分定义了连接到 API 服务器所需的信息,包括在我们高可用配置中,所有三个 API 服务器共享的负载均衡地址。users 部分定义了我们创建的新用户及其客户端证书。

到目前为止,我们成功创建了一个新用户,但尚未赋予该用户任何权限,因此我们用这些凭据的操作不会非常成功:

root@host01:~# KUBECONFIG=kubeconfig kubectl get pods
Error from server (Forbidden): pods is forbidden: User "me" cannot list 
  resource "pods" in API group "" in the namespace "default"

本章稍后我们将看到如何为这个用户授予权限。

引导令牌

初始化一个分布式系统,如 Kubernetes 集群,是一项具有挑战性的任务。每个节点上运行的 kubelet 服务必须被添加到集群中。为此,kubelet 必须连接到 API 服务器并获取由集群的 CA 签名的客户端证书。然后,kubelet 服务使用该客户端证书进行集群认证。

证书的生成必须安全进行,以消除将恶意节点添加到集群的可能性,并防止恶意进程冒充真实节点。因此,API 服务器不能为任何请求加入集群的节点提供证书。相反,节点必须生成自己的私钥,向 API 服务器提交证书签名请求(CSR),并接收签名证书。

为了确保这个过程的安全性,我们需要确保一个节点被授权提交证书签名请求。但这个提交必须在节点获得用于更长期身份验证的客户端证书之前进行——我们面临一个先有鸡还是先有蛋的问题!Kubernetes 通过时间限制令牌来解决这个问题,这些令牌被称为引导令牌。引导令牌成为一个预共享的秘密,API 服务器和新节点都知道它。使这个令牌具有时间限制可以降低它暴露时对集群的风险。Kubernetes 控制器管理器负责在引导令牌过期时自动清理它们。

当我们初始化集群时,kubeadm 创建了一个引导令牌,但它配置为两小时后过期。如果我们在此之后需要将额外的节点加入集群,我们可以使用 kubeadm 生成一个新的引导令牌:

root@host01:~# TOKEN=$(kubeadm token create)
root@host01:~# echo $TOKEN
pqcnd6.4wawyqgkfaet06zm

这个令牌作为 Kubernetes Secret 被添加到kube-system命名空间。我们将在第十六章中更详细地讨论 Secrets。现在,我们只需要验证它是否存在:

root@host01:~# kubectl -n kube-system get secret
NAME                    TYPE                           DATA   AGE
...
bootstrap-token-pqcnd6  bootstrap.kubernetes.io/token  6      64s
...

我们可以使用这个令牌通过 HTTP Bearer 身份验证向 API 服务器发出请求。这意味着我们在 HTTP 头中提供令牌,头部的名称为Authorization,并以Bearer为前缀。当引导令牌认证插件看到该头并将提供的令牌与相应的密钥进行匹配时,它会验证我们的身份并允许我们访问 API。

出于安全原因,引导令牌仅能访问 API 服务器的证书签名请求功能,因此我们的令牌只能执行这个操作。

让我们使用引导令牌列出所有证书签名请求:

root@host01:~# curl --cacert ca.crt \
  -H "Authorization: Bearer $TOKEN" \
  https://192.168.61.11:6443/apis/certificates.k8s.io/v1/certificatesigningrequests
{
  "kind": "CertificateSigningRequestList",
 "apiVersion": "certificates.k8s.io/v1",
  "metadata": {
    "resourceVersion": "21241"
  },
  "items": [
...
  ]
}

了解引导令牌的工作原理非常重要,因为它们对于将节点添加到集群至关重要。然而,正如其名称所示,引导令牌实际上只有这个目的;通常不会用于正常的 API 服务器访问。对于正常的 API 服务器访问,尤其是在集群内部,我们需要一个服务账户

服务账户

在 Kubernetes 集群中运行的容器通常需要与 API 服务器进行通信。例如,在我们在第六章中部署的所有组件,包括 Calico 网络插件、Longhorn 存储驱动程序和指标服务器,都会与 API 服务器通信,以观察和修改集群状态。为了支持这一点,Kubernetes 会自动将凭证注入每个运行中的容器。

当然,出于安全原因,仅授予每个容器所需的 API 服务器权限非常重要,因此我们应该为每个应用或集群组件创建一个单独的 ServiceAccount。然后,将这些 ServiceAccount 的信息添加到 Deployment 或其他控制器中,以便 Kubernetes 会注入正确的凭据。在某些情况下,我们可能会为一个应用使用多个 ServiceAccount,限制每个应用组件只能访问其所需的权限。

除了为每个应用或组件使用单独的 ServiceAccount 外,最好为每个应用使用单独的命名空间。正如我们稍后将看到的,权限可以限制在单一命名空间内。让我们首先创建命名空间:

root@host01:~# kubectl create namespace sample
namespace/sample created

ServiceAccount 使用承载令牌,该令牌存储在 Kubernetes 创建 ServiceAccount 时自动生成的 Secret 中。让我们为本章中将要创建的 Deployment 创建一个 ServiceAccount:

read-pods-sa.yaml

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: read-pods
  namespace: sample

请注意,我们使用元数据将这个 ServiceAccount 放入我们刚刚创建的sample命名空间中。我们也可以使用 -n 标志与 kubectl 一起指定命名空间。我们将使用常规的 kubectl apply 来创建这个 ServiceAccount:

root@host01:~# kubectl apply -f /opt/read-pods-sa.yaml
serviceaccount/read-pods created

当创建 ServiceAccount 时,控制器管理器会检测到这一点,并自动创建一个包含凭证的 Secret:

root@host01:~# kubectl -n sample get serviceaccounts
NAME        SECRETS   AGE
default     1         27s
read-pods   1         8s
root@host01:~# kubectl -n sample get secrets
NAME                    TYPE                                  DATA   AGE
default-token-mzwpt     kubernetes.io/service-account-token   3      43s
read-pods-token-m4scq   kubernetes.io/service-account-token   3      25s

请注意,除了我们刚刚创建的read-pods ServiceAccount 外,还有一个已经存在的default ServiceAccount。这个账户是在创建命名空间时自动创建的;如果我们没有指定使用哪个 ServiceAccount 来为 Pod 提供服务,Kubernetes 将使用它。

新创建的 ServiceAccount 还没有任何权限。为了开始添加权限,我们需要了解一下 基于角色的访问控制(RBAC)。

基于角色的访问控制

在 API 服务器找到能够识别客户端的认证插件之后,它会使用该身份来判断客户端是否有权限执行所需的操作,这通过组合属于用户的角色列表来完成。角色可以直接与用户关联,也可以与用户所在的组关联。组成员资格是身份的一部分。例如,客户端证书可以通过在证书主题中包含组织字段来指定用户的组。

角色和集群角色

每个角色都有一组权限。权限允许客户端对一种或多种资源类型执行一个或多个操作。

举个例子,让我们定义一个角色,授予客户端读取 Pod 状态的权限。我们有两个选择:可以创建一个RoleClusterRole。Role 仅在单个命名空间内可见和可用,而 ClusterRole 在所有命名空间中都可见和可用。这一差异允许管理员在整个集群中定义通用角色,这些角色在新命名空间创建时立即可用,同时也允许为特定命名空间委派访问控制。

下面是 ClusterRole 的示例定义。该角色仅具备读取 Pods 数据的权限;不能修改 Pods 或访问其他集群信息:

pod-reader.yaml

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: pod-reader
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "watch", "list"]

因为这是一个集群范围的角色,将其分配给某个命名空间是没有意义的,因此我们不指定命名空间。

这个定义的关键部分是规则列表。每个 ClusterRole 或 Role 可以有任意数量的规则。每条规则都有一组verbs,定义了允许的操作。在此案例中,我们将getwatchlist作为动词,这意味着该角色允许读取 Pods,但不允许进行任何修改操作。

每条规则适用于一个或多个资源类型,这取决于apiGroupsresources的组合。每条规则为列出的verbs(动词)操作提供权限。在这种情况下,空字符串""用于引用默认的 API 组,即 Pods 所在的地方。如果我们想要同时包括 Deployments 和 StatefulSets,我们需要将规则定义如下:

- apiGroups: ["", "apps"]
  resources: ["pods", "deployments", "statefulsets"]
  verbs: ["get", "watch", "list"]

我们需要将"apps"添加到apiGroups字段中,因为 Deployment 和 StatefulSet 属于该组(在声明资源时,可以在apiVersion中识别)。当我们声明 Role 或 ClusterRole 时,API 服务器会接受apiGroupsresources字段中的任何字符串,无论该组合是否确实识别出任何资源类型,因此,必须注意资源属于哪个组。

让我们定义我们的pod-reader ClusterRole:

root@host01:~# kubectl apply -f /opt/pod-reader.yaml
clusterrole.rbac.authorization.k8s.io/pod-reader created

现在 ClusterRole 已经存在,我们可以应用它。为此,我们需要创建一个角色绑定。

角色绑定和集群角色绑定

让我们将这个pod-reader ClusterRole 应用到我们之前创建的read-pods ServiceAccount。我们有两个选择:可以创建一个RoleBinding,它会将权限分配到特定的命名空间,或者创建一个ClusterRoleBinding,它会将权限分配到所有命名空间。这一特性非常有用,因为它意味着我们可以创建一个像pod-reader这样的 ClusterRole,并使其在整个集群中可见,但只在特定命名空间内创建绑定,以便用户和 ServiceAccount 仅能访问他们被允许访问的命名空间。这帮助我们应用之前提到的每个应用有一个命名空间的模式,同时确保非管理员用户无法接触到关键的基础设施组件,如在kube-system命名空间中运行的组件。

按照这个做法,我们将创建一个 RoleBinding,以便我们的 ServiceAccount 仅有权在 sample 命名空间中读取 Pods:

read-pods-bind.yaml

---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: read-pods
  namespace: sample
subjects:
- kind: ServiceAccount
  name: read-pods
  namespace: sample
roleRef:
  kind: ClusterRole
  name: pod-reader
  apiGroup: rbac.authorization.k8s.io

不出所料,RoleBinding 将一个 Role 或 ClusterRole 与一个主体关联起来。RoleBinding 可以包含多个主体,因此我们可以通过单个绑定将相同的角色绑定到多个用户或组。

我们在元数据中定义了一个命名空间,并且在标识主体的地方也定义了命名空间。在这种情况下,两个地方都是 sample,因为我们希望授予 ServiceAccount 在其自己的命名空间中读取 Pod 状态的权限。然而,这两个命名空间也可以不同,以允许一个命名空间中的 ServiceAccount 在另一个命名空间中具有特定权限。当然,我们也可以使用 ClusterRoleBinding 来授予跨所有命名空间的权限。

现在我们可以创建 RoleBinding 了:

root@host01:~# kubectl apply -f /opt/read-pods-bind.yaml
rolebinding.rbac.authorization.k8s.io/read-pods created

我们现在已授予 read-pods ServiceAccount 在 sample 命名空间中读取 Pods 的权限。为了演示其工作原理,我们需要创建一个分配给 read-pods ServiceAccount 的 Pod。

将 Service Account 分配给 Pods

要将 ServiceAccount 分配给 Pod,只需将 serviceAccountName 字段添加到 Pod 的 spec 中:

read-pods-deploy.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: read-pods
  namespace: sample
spec:
  replicas: 1
  selector:
    matchLabels:
      app: read-pods
  template:
    metadata:
      labels:
        app: read-pods
    spec:
      containers:
      - name: read-pods
        image: alpine
        command: ["/bin/sleep", "infinity"]
      serviceAccountName: read-pods

所标识的 ServiceAccount 必须存在于创建 Pod 的命名空间中。Kubernetes 会将 Pod 的容器注入 Service-Account 令牌,以便容器可以认证到 API 服务器。

让我们通过一个示例来展示这个过程,并说明授权是如何应用的。首先创建这个 Deployment:

root@host01:~# kubectl apply -f /opt/read-pods-deploy.yaml
deployment.apps/read-pods created

这会创建一个运行 sleep 的 Alpine 容器,我们可以将其用作 shell 命令的基础。

要进入 shell 提示符,我们首先获取 Pod 的生成名称,然后使用 kubectl exec 创建 shell:

root@host01:~# kubectl -n sample get pods
NAME                        READY   STATUS    RESTARTS   AGE
read-pods-9d5565548-fbwjb   1/1     Running   0          6s
root@host01:~# kubectl -n sample exec -ti read-pods-9d5565548-fbwjb -- /bin/sh
/ #

ServiceAccount 令牌挂载在目录 /run/secrets/kubernetes.io/serviceaccount 中,因此切换到该目录并列出其内容:

/ # cd /run/secrets/kubernetes.io/serviceaccount
/run/secrets/kubernetes.io/serviceaccount # ls -l
total 0
lrwxrwxrwx    1 root     root  ...  ca.crt -> ..data/ca.crt
lrwxrwxrwx    1 root     root  ...  namespace -> ..data/namespace
lrwxrwxrwx    1 root     root  ...  token -> ..data/token

这些文件看起来像是奇怪的符号链接,但内容如预期所示。ca.crt 文件是集群的根证书,它用于信任与 API 服务器的连接。

让我们将令牌保存在一个变量中,以便使用:

/run/secrets/kubernetes.io/serviceaccount # TOKEN=$(cat token)

现在我们可以使用这个令牌与 curl 连接到 API 服务器。但首先,我们需要将 curl 安装到 Alpine 容器中:

default/run/secrets/kubernetes.io/serviceaccount # apk add curl
...
OK: 8 MiB in 19 packages

我们的 ServiceAccount 被允许对 Pods 执行 getlistwatch 操作。让我们列出 sample 命名空间中的所有 Pods:

/run/secrets/kubernetes.io/serviceaccount # curl --cacert ca.crt \
  -H "Authorization: Bearer $TOKEN" \
  https://kubernetes.default.svc/api/v1/namespaces/sample/pods
  "kind": "PodList",
  "apiVersion": "v1",
  "metadata": {
    "resourceVersion": "566610"
  },
  "items": [
    {
      "metadata": {
        "name": "read-pods-9d5565548-fbwjb",
...
  ]
}

与引导令牌一样,我们使用 HTTP Bearer 身份验证将 ServiceAccount 令牌传递给 API 服务器。由于我们在容器内部操作,我们可以使用标准地址 kubernetes.default.svc 来查找 API 服务器。这是可行的,因为 Kubernetes 集群始终在 default 命名空间中拥有一个服务,使用我们在第九章中看到的服务网络将流量路由到 API 服务器实例。

curl命令成功了,因为我们的 ServiceAccount 已绑定到我们创建的pod-reader角色。然而,RoleBinding 限定于sample命名空间,因此我们不能列出其他命名空间中的 Pods:

/run/secrets/kubernetes.io/serviceaccount # curl --cacert ca.crt \
  -H "Authorization: Bearer $TOKEN" \
  https://kubernetes.default.svc/api/v1/namespaces/kube-system/pods
{
  "kind": "Status",
  "apiVersion": "v1",
  "metadata": {
  },
  "status": "Failure",
  "message": "pods is forbidden: User 
    \"system:serviceaccount:default:read-pods\" cannot list resource 
    \"pods\" in API group \"\" in the namespace \"kube-system\"",
  "reason": "Forbidden",
  "details": {
    "kind": "pods"
  },
  "code": 403
}

我们可以使用错误信息来确认我们的 ServiceAccount 分配和身份验证按预期工作,因为 API 服务器将我们识别为read-pods ServiceAccount。然而,我们没有带有正确权限的 RoleBinding 来读取kube-system命名空间中的 Pods,因此请求被拒绝。

同样,由于我们仅有 Pods 的权限,我们不能列出我们的 Deployment,尽管它也位于sample命名空间中:

/run/secrets/kubernetes.io/serviceaccount # curl --cacert ca.crt \
  -H "Authorization: Bearer $TOKEN" \
  https://kubernetes.default.svc/apis/apps/v1/namespaces/sample/deploy
ments
{
  "kind": "Status",
  "apiVersion": "v1",
  "metadata": {
  },
  "status": "Failure",
  "message": "deploy.apps is forbidden: User 
    \"system:serviceaccount:default:read-pods\" cannot list resource 
    \"deploy\" in API group \"apps\" in the namespace \"sample\"",
  "reason": "Forbidden",
  "details": {
    "group": "apps",
    "kind": "deploy"
  },
 "code": 403
}

URL 的路径方案略有不同,从/apis/apps/v1而非/api/v1开始,这是因为 Deployments 位于apps API 组中,而不是默认的 API 组中。这个命令失败的原因类似,因为我们没有必要的权限来列出 Deployments。

我们已经完成了这个 shell 会话,接下来让我们退出它:

/run/secrets/kubernetes.io/serviceaccount # exit

不过,在我们结束 RBAC 话题之前,让我们展示一种简单的方法,为命名空间授予普通用户权限,而不允许任何管理员职能。

将角色绑定到用户

为了授予普通用户权限,我们将利用一个现有的 ClusterRole,名为edit,它已经设置好为用户提供大多数资源类型的查看和编辑权限。

让我们快速查看一下edit ClusterRole,看看它有哪些权限:

root@host01:~# kubectl get clusterrole edit -o yaml
...
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
...
rules:
...
- apiGroups:
  - ""
  resources:
  - pods
  - pods/attach
  - pods/exec
  - pods/portforward
  - pods/proxy
  verbs:
  - create
  - delete
  - deletecollection
  - patch
  - update
...

完整的列表包含大量不同的规则,每个规则都有自己的一套权限。此示例中的子集仅展示了一个规则,用于为 Pods 提供编辑权限。

与 Pods 相关的某些命令,如exec,被单独列出,以便进行更细粒度的控制。例如,对于生产系统,允许某些人能够创建和删除 Pods 并查看日志,但不提供使用exec的权限可能会更有用,因为exec可能会被用来访问敏感的生产数据。

之前,我们创建了一个名为me的用户,并将客户端证书保存到名为kubeconfig的文件中。然而,我们还没有将任何角色绑定到该用户,因此该用户只有自动加入system:authenticated组时所拥有的非常有限的权限。

结果正如我们之前看到的那样,我们的普通用户甚至不能列出default命名空间中的 Pods。让我们将这个用户绑定到编辑角色。和之前一样,我们将使用常规的 RoleBinding,作用范围限定在sample命名空间,这样该用户将无法访问kube-system命名空间中的集群基础设施组件。

Listing 11-1 展示了我们需要的 RoleBinding。

edit-bind.yaml

---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: editor
  namespace: sample
subjects:
- kind: User
  name: me
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole 
  name: edit
  apiGroup: rbac.authorization.k8s.io

Listing 11-1: 将编辑角色绑定到用户

现在我们应用这个 RoleBinding 来为用户添加权限:

root@host01:~# kubectl apply -f /opt/edit-bind.yaml
rolebinding.rbac.authorization.k8s.io/editor created

现在我们可以使用这个用户查看和修改 Pods、Deployments 和许多其他资源:

root@host01:~# KUBECONFIG=kubeconfig kubectl -n sample get pods
NAME                        READY   STATUS    RESTARTS   AGE
read-pods-9d5565548-fbwjb   1/1     Running   0          54m
root@host01:~# KUBECONFIG=kubeconfig kubectl delete -f /opt/read-pods-deploy.yaml
deployment.apps "read-pods" deleted

然而,由于我们使用的是 RoleBinding 而不是 ClusterRoleBinding,因此该用户无法查看其他命名空间:

root@host01:~# KUBECONFIG=kubeconfig kubectl get -n kube-system pods
Error from server (Forbidden): pods is forbidden: User "me" cannot list 
  resource "pods" in API group "" in the namespace "kube-system"

kubectl 显示的错误消息与 API 服务器 JSON 响应中的 message 字段形式相同。这并非巧合;kubectl 是 API 服务器 REST API 前的友好命令行界面。

最后的想法

API 服务器是 Kubernetes 控制平面中的一个核心组件。集群中的每个其他服务都会持续连接到 API 服务器,监视集群中的变化,以便采取适当的行动。用户也使用 API 服务器来部署和配置应用程序以及监控状态。在这一章中,我们看到了 API 服务器提供的底层 REST API,用于创建、检索、更新和删除资源。我们还看到了 API 服务器内置的广泛认证和授权功能,确保只有授权的用户和服务可以访问和修改集群状态。

在下一章中,我们将探讨集群基础设施的另一面:节点组件。我们将看到 kubelet 服务如何隐藏容器引擎之间的差异,以及它如何使用我们在第一部分中看到的容器功能来创建、启动和配置集群中的容器。

第十二章:容器运行时

image

在上一章中,我们了解了控制平面如何管理和监控集群的状态。然而,实际上是容器运行时,特别是 kubelet 服务,负责创建、启动、停止和删除容器,以实际将集群带入所需状态。本章中,我们将探索 kubelet 如何在我们的集群中配置及其运作方式。

作为这一探索的一部分,我们将讨论 kubelet 如何在依赖于控制平面的同时,也承载着控制平面的功能。最后,我们将探讨 Kubernetes 集群中的节点维护,包括如何为维护关闭节点、可能导致节点无法正常工作的问题、当节点突然变得不可用时集群如何表现,以及节点在失去集群连接时的行为。

节点服务

将普通主机转化为 Kubernetes 节点的主要服务是 kubelet。由于它对 Kubernetes 集群的关键性,我们将详细探讨它是如何配置的,以及它的行为方式。

CONTAINERD 和 CRI-O

本章的示例提供了自动化脚本,用于通过两种容器运行时之一启动集群:containerd 和 CRI-O。我们将主要使用 containerd 安装,但也会简要介绍配置差异。CRI-O 集群的设置允许你尝试使用一个独立的容器运行时。它还展示了 kubelet 隐藏这一差异的事实,因为集群的其他配置不受容器运行时更改的影响。

我们在第六章中设置集群时,已将 kubelet 作为软件包安装到所有节点上,之后的各章中,自动化过程也同样为每个章节设置了它。

注意

本书的示例代码仓库在 github.com/book-of-kubernetes/examples详细设置方法请见“运行示例”部分,第 xx 页。

kubelet 包还包含一个系统服务。我们的操作系统使用 systemd 来运行服务,因此我们可以使用 systemctl 获取服务信息:

root@host01:~# systemctl status kubelet
  kubelet.service - kubelet: The Kubernetes Node Agent
     Loaded: loaded (/lib/systemd/system/kubelet.service; enabled; ...
    Drop-In: /etc/systemd/system/kubelet.service.d
               10-kubeadm.conf
     Active: active (running) since ...

第一次启动 kubelet 时,它没有加入集群所需的配置。当我们运行 kubeadm 时,它创建了前面输出中显示的文件 10-kubeadm.conf。该文件通过设置命令行参数来为集群配置 kubelet 服务。

清单 12-1 展示了传递给 kubelet 服务的命令行参数。

root@host01:~# strings /proc/$(pgrep kubelet)/cmdline
/usr/bin/kubelet
--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf
--kubeconfig=/etc/kubernetes/kubelet.conf
--config=/var/lib/kubelet/config.yaml
--container-runtime=remote
--container-runtime-endpoint=/run/containerd/containerd.sock
--node-ip=192.168.61.11
--pod-infra-container-image=k8s.gcr.io/pause:3.4.1

清单 12-1:Kubelet 命令行

pgrep kubelet嵌入命令输出kubelet服务的进程 ID。我们接着使用该 ID 通过/proc Linux 虚拟文件系统打印进程的命令行。我们使用strings来打印该文件,而不是cat,因为每个单独的命令行参数都是以空字符结尾,strings会将其转换为良好的多行显示格式。

kubelet服务需要三个主要的配置选项组:集群配置容器运行时配置网络配置

Kubelet 集群配置

集群配置选项告诉kubelet如何与集群通信以及如何进行身份验证。当kubelet第一次启动时,它使用清单 12-1 中显示的bootstrap-kubeconfig来查找集群,验证服务器证书,并使用我们在第十一章中讨论的引导令牌进行身份验证。这个引导令牌用于提交此新节点的证书签名请求(CSR)。然后,kubelet从 API 服务器下载签名的客户端证书,并将其存储在/etc/kubernetes/kubelet.conf中,这是由kubeconfig选项指定的位置。此kubelet.conf文件遵循与配置kubectl与 API 服务器通信相同的格式,正如我们在第十一章中看到的那样。在kubelet.conf写入后,引导文件会被删除。

在清单 12-1 中指定的/var/lib/kubelet/config.yaml文件也包含了重要的配置内容。为了从kubelet拉取度量信息,我们需要为其配置自己的服务器证书,而不仅仅是客户端证书,并且需要配置它如何验证自己的客户端。以下是由kubeadm创建的配置文件中的相关内容:

root@host01:~# cat /var/lib/kubelet/config.yaml
...
authentication:
  anonymous:
    enabled: false
  webhook:
    cacheTTL: 0s
    enabled: true
  x509:
    clientCAFile: /etc/kubernetes/pki/ca.crt
...

authentication部分告诉kubelet不允许匿名请求,但允许 webhook 承载令牌以及由集群证书颁发机构签名的任何客户端证书。我们为度量服务器安装的 YAML 资源文件包括一个 ServiceAccount,该账户在其部署中使用,因此它会自动注入凭证,供其用来向kubelet实例进行身份验证,正如我们在第十一章中看到的那样。

Kubelet 容器运行时配置

容器运行时配置选项告诉kubelet如何连接到容器运行时,以便kubelet能够管理本地机器上的容器。由于kubelet期望运行时支持容器运行时接口(CRI)标准,因此只需要几个设置,如清单 12-1 所示。

第一个关键设置是 container-runtime,可以设置为 remotedocker。Kubernetes 诞生于 Docker 引擎与 containerd 运行时分离之前,因此它对 Docker 有遗留支持,使用 shim 来模拟标准的 CRI 接口。因为我们直接使用 containerd,而不是通过 Docker shim 或 Docker 引擎,所以我们将其设置为 remote

接下来,我们使用 container-runtime-endpoint 设置指定容器运行时的路径。此情况下的值是 /run/containerd/containerd.sockkubelet 连接到这个 Unix 套接字以发送 CRI 请求并接收状态。

container-runtime-endpoint 命令行设置是切换集群在 containerd 和 CRI-O 之间所需的唯一差异。此外,当节点初始化时,kubeadm 会自动检测到它,因此自动化脚本中的唯一差异是在安装 Kubernetes 之前安装 CRI-O,而不是 containerd。如果我们查看 CRI-O 集群中 kubelet 的命令行选项,我们会看到命令行选项中只有一个变化:

root@host01:~# strings /proc/$(pgrep kubelet)/cmdline
...
--container-runtime-endpoint=/var/run/crio/crio.sock
...

剩下的命令行选项与我们的 containerd 集群相同。

最后,我们还有一个与容器运行时相关的设置:pod-infra-container-image。此设置指定 Pod 基础设施镜像。我们在第二章中以 pause 进程的形式看到了这个镜像,它是为我们的容器创建的 Linux 命名空间的所有者。在这种情况下,这个 pause 进程将来自容器镜像 k8s.gcr.io/pause:3.4.1

拥有一个单独的容器来管理 Pod 中容器之间共享的命名空间是非常方便的。因为 pause 进程实际上什么都不做,它非常可靠,不容易崩溃,所以即使 Pod 中的其他容器意外终止,它也能继续管理这些共享的命名空间。

pause 镜像大约有 300KB,如我们在其中一个节点上运行 crictl 所见:

root@host01:~# crictl images
IMAGE             TAG                 IMAGE ID            SIZE
,,,
k8s.gcr.io/pause  3.4.1               0f8457a4c2eca       301kB
...

此外,pause 进程几乎不占用 CPU,因此每个 Pod 为每个节点增加一个额外进程对节点的影响很小。

Kubelet 网络配置

网络配置帮助 kubelet 将其集成到集群中,并将 Pods 集成到整体的集群网络中。正如我们在第八章中看到的,实际的 Pod 网络设置是由网络插件执行的,但 kubelet 也有几个重要的角色。

我们的kubelet命令行包括一个与网络配置相关的选项:node-ip。这是一个可选标志,如果没有提供,kubelet将尝试确定它应该使用的 IP 地址与 API 服务器进行通信。然而,直接指定该标志是有用的,因为它可以确保我们的集群在节点有多个网络接口的情况下正常工作(例如本书示例中的 Vagrant 配置,其中使用一个单独的内部网络进行集群通信)。

除了这一行命令行选项外,kubeadm还将两个重要的网络设置放入/var/lib/kubelet/config.yaml

root@host01:~# cat /var/lib/kubelet/config.yaml
...
clusterDNS:
- 10.96.0.10
clusterDomain: cluster.local
...

这些设置用于将/etc/resolv.conf文件提供给所有容器。clusterDNS条目提供了该 DNS 服务器的 IP 地址,而clusterDomain条目提供了一个默认的搜索域,以便我们区分集群内部的主机名和外部网络上的主机名。

让我们快速查看这些值是如何提供给 Pod 的。我们将从创建一个 Pod 开始:

root@host01:~# kubectl apply -f /opt/pod.yaml 
pod/debug created

几秒钟后,当 Pod 正在运行时,我们可以获取一个 shell:

root@host01:~# kubectl exec -ti debug -- /bin/sh
/ #

请注意,/etc/resolv.conf是我们容器中单独挂载的文件:

/ # mount | grep resolv
/dev/sda1 on /etc/resolv.conf type ext4 ...

其内容反映了kubelet的配置:

/ # cat /etc/resolv.conf 
search default.svc.cluster.local svc.cluster.local cluster.local 
nameserver 10.96.0.10
options ndots:5

这个 DNS 配置指向 Kubernetes 集群核心组件的一部分 DNS 服务器,使得我们在第九章中看到的服务查找成为可能。根据你网络中的 DNS 配置,你可能会在search列表中看到其他项目,而不仅仅是这里显示的内容。

同时请注意,/run/secrets/kubernetes.io/serviceaccount也是我们容器中单独挂载的目录。这个目录包含了我们在第十一章中看到的 ServiceAccount 信息,用于在容器内与 API 服务器进行身份验证:

/ # mount | grep run
tmpfs on /run/secrets/kubernetes.io/serviceaccount type tmpfs (ro,relatime)

在这种情况下,挂载的目录是tmpfs类型,因为kubelet已经创建了一个内存文件系统来存储认证信息。

让我们通过退出 shell 会话并删除 Pod 来结束操作(我们不再需要它):

/ # exit
root@host01:~# kubectl delete pod debug

这次清理将使得接下来的 Pod 列表更加清晰,因为我们将查看当一个节点停止工作时集群的反应。在此之前,我们还有一个关键的谜题需要解决:kubelet如何同时托管控制平面并依赖于它。

静态 Pods

创建我们的集群时,我们遇到了一种“先鸡还是先蛋”的问题。我们希望kubelet能够管理控制平面组件作为 Pods,因为这样可以更容易地监控、维护和更新控制平面组件。然而,kubelet依赖于控制平面来决定运行哪些容器。解决方案是让kubelet支持静态 Pod 定义,它从文件系统中拉取并在建立控制平面连接之前自动运行。

这个静态 Pod 配置在/var/lib/kubelet/config.yaml中处理:

root@host01:~# cat /var/lib/kubelet/config.yaml 
...
staticPodPath: /etc/kubernetes/manifests
...

如果我们查看 /etc/kubernetes/manifests,我们会看到多个 YAML 文件。这些文件是由 kubeadm 放置的,定义了运行此节点控制平面组件所必需的 Pods:

root@host01:~# ls -1 /etc/kubernetes/manifests
etcd.yaml
kube-apiserver.yaml
kube-controller-manager.yaml
kube-scheduler.yaml

正如预期的那样,我们看到每个我们在第十一章讨论过的三个关键控制平面服务都有一个 YAML 文件。我们还看到一个 etcd 的 Pod 定义,etcd 是存储集群状态并帮助选举领导者以确保我们集群高可用的组件。我们将在第十六章中更详细地了解 etcd

这些文件中的每一个都包含一个 Pod 定义,类似于我们已经看到的:

root@host01:~# cat /etc/kubernetes/manifests/kube-apiserver.yaml 
apiVersion: v1
kind: Pod
metadata:
...
  name: kube-apiserver
  namespace: kube-system
spec:
  containers:
  - command:
    - kube-apiserver
...

kubelet 服务持续监控此目录中的任何变化,并相应地更新对应的静态 Pod,这使得 kubeadm 能够在不中断的情况下,按滚动方式升级集群的控制平面。

集群附加组件如 Calico 和 Longhorn 也可以使用这个目录运行,但它们使用 DaemonSet 来确保集群在每个节点上运行一个 Pod。这是有道理的,因为 DaemonSet 可以一次性管理整个集群,确保所有节点之间的配置一致。

这个静态 Pod 目录在我们的三个控制平面节点 host01host03 与我们的“普通”节点 host04 上有所不同。为了将 host04 设为普通节点,kubeadm 会在 /etc/kubernetes/manifests 中省略控制平面的静态 Pod 文件:

root@host04:~# ls -1 /etc/kubernetes/manifests
root@host04:~#

请注意,这个命令是在 host04 上执行的,它是我们集群中唯一的普通节点。

节点维护

控制平面中的控制器管理器组件持续监控节点,以确保它们仍然连接且健康。kubelet 服务负责报告节点信息,包括节点内存使用、磁盘使用和与底层容器运行时的连接。如果一个节点变得不健康,控制平面将把 Pods 移动到其他节点,以维持部署的预期规模,并且在节点恢复健康之前,不会向该节点调度任何新的 Pods。

节点排空与隔离

如果我们知道需要对某个节点进行维护,比如重启,我们可以告诉集群将 Pods 从该节点迁移,并将该节点标记为不可调度。我们通过使用 kubectl drain 命令来实现这一点。

举个例子,我们创建一个有八个 Pods 的 Deployment,这样每个节点很可能会获得一个 Pod:

root@host01:~# kubectl apply -f /opt/deploy.yaml 
deployment.apps/debug created

如果我们给足够的启动时间,我们可以看到 Pods 被分配到各个节点上:

root@host01:~# kubectl get pods -o wide
NAME                     READY   STATUS    ... NODE   ...
debug-8677494fdd-7znxn   1/1     Running   ... host02 ...  
debug-8677494fdd-9dgvd   1/1     Running   ... host03 ...  
debug-8677494fdd-hv6mt   1/1     Running   ... host04 ...  
debug-8677494fdd-ntqjp   1/1     Running   ... host02 ...  
debug-8677494fdd-pfw5n   1/1     Running   ... host03 ...  
debug-8677494fdd-qbhmn   1/1     Running   ... host02 ...  
debug-8677494fdd-qp9zv   1/1     Running   ... host03 ...  
debug-8677494fdd-xt8dm   1/1     Running   ... host03 ...

为了最小化我们的测试集群大小,我们的普通节点 host04 资源较少,因此在这个例子中它只获得一个 Pod。但这足以看到当我们关闭节点时会发生什么。这个过程有一定的随机性,所以如果你没有看到任何 Pods 分配到 host04,你可以删除 Deployment 重新尝试,或者像我们在下一个例子中那样缩小后再放大。

要关闭节点,我们使用 kubectl drain 命令:

root@host01:~# kubectl drain --ignore-daemonsets host04
node/host04 cordoned
WARNING: ignoring DaemonSet-managed Pods: ...
...
pod/debug-8677494fdd-hv6mt evicted
node/host04 evicted

我们需要提供--ignore-daemonsets选项,因为我们所有的节点都运行着 Calico 和 Longhorn DaemonSets,当然,这些 Pod 无法迁移到其他节点。

驱逐过程会花费一些时间。完成后,我们可以看到部署在另一个节点上创建了一个 Pod,这样我们的 Pod 数量保持在八个:

root@host01:~# kubectl get pods -o wide
NAME                     READY   STATUS    ... NODE     ...
debug-8677494fdd-7znxn   1/1     Running   ... host02   ...
debug-8677494fdd-9dgvd   1/1     Running   ... host03   ...
debug-8677494fdd-ntqjp   1/1     Running   ... host02   ...
debug-8677494fdd-pfw5n   1/1     Running   ... host03   ...
debug-8677494fdd-qbhmn   1/1     Running   ... host02   ...
debug-8677494fdd-qfnml   1/1     Running   ... host01   ...
debug-8677494fdd-qp9zv   1/1     Running   ... host03   ...
debug-8677494fdd-xt8dm   1/1     Running   ... host03   ...

此外,节点已被隔离,因此将不会再有 Pod 被调度到该节点上:

root@host01:~# kubectl get nodes
NAME     STATUS                     ROLES        ...
host01   Ready                      control-plane...
host02   Ready                      control-plane...
host03   Ready                      control-plane...
host04   Ready,SchedulingDisabled   <none>       ...

此时,停止kubelet或容器运行时、重启节点,甚至完全从 Kubernetes 中删除该节点都是安全的:

root@host01:~# kubectl delete node host04
node "host04" deleted

该删除操作会从集群的存储中移除节点信息,但由于该节点仍然拥有有效的客户端证书和所有配置,简单地重启host04上的kubelet服务将把它重新加入集群。首先让我们重启kubelet

root@host04:~# systemctl restart kubelet

请确保在host04上执行此操作。接下来,在host01上,如果我们等待host04上的kubelet完成上次运行的清理并重新初始化,我们会看到它重新出现在节点列表中:

root@host01:~# kubectl get nodes
NAME     STATUS   ROLES        ...
host01   Ready    control-plane...
host02   Ready    control-plane...
host03   Ready    control-plane...
host04   Ready    <none>       ...

注意,隔离已经被移除,host04不再显示包含SchedulingDisabled的状态。这是移除隔离的一种方式,另一种方式是直接使用kubectl uncordon命令。

不健康节点

如果节点因内存不足或磁盘空间等资源限制变得不健康,Kubernetes 还会自动将 Pod 迁移到其他节点。让我们模拟host04上内存不足的情况,以便观察这一过程。

首先,我们需要重置debug部署的规模,以确保新的 Pod 被分配到host04上:

root@host01:~# kubectl scale deployment debug --replicas=1
deployment.apps/debug scaled
root@host01:~# kubectl scale deployment debug --replicas=12
deployment.apps/debug scaled

我们首先将部署的规模缩减到最小,然后再将其扩大。这样,我们有更多机会将至少一个 Pod 调度到host04上。一旦 Pod 有机会稳定下来,我们会看到host04上再次有 Pod:

root@host01:~# kubectl get pods -o wide
NAME                     READY   STATUS    ... NODE     ...
...
debug-8677494fdd-j7cth   1/1     Running   ... host04   ...
debug-8677494fdd-jlj4v   1/1     Running   ... host04   ...
...

我们可以使用kubectl top来检查当前节点的统计信息:

root@host01:~# kubectl top nodes
NAME     CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%   
host01   503m         25%    1239Mi          65%       
host02   518m         25%    1346Mi          71%       
host03   534m         26%    1382Mi          73%       
host04   288m         14%    542Mi           29%

host04总共有 2GB 内存,目前已使用超过 500MiB。默认情况下,当剩余内存少于 100MiB 时,kubelet会驱逐 Pod。我们可以尝试使用节点上的内存直到低于这个默认阈值,但这很冒险,因为大量使用内存可能会导致节点行为不正常。相反,我们可以更新驱逐限制。为此,我们将向/var/lib/kubelet/config.yaml添加几行,然后重启kubelet

这是我们将添加到kubelet配置文件中的额外配置:

node-evict.yaml

evictionHard:
  memory.available: "1900Mi"

这会告诉kubelet,如果可用内存少于 1,900MiB,它将开始驱逐 Pod。在我们的示例集群中的节点上,这将立即发生。让我们应用这一更改:

root@host04:~# cat /opt/node-evict.yaml >> /var/lib/kubelet/config.yaml
root@host04:~# systemctl restart kubelet

请确保在host04上执行这些命令。第一条命令会向kubelet配置文件中添加额外的行。第二条命令会重启kubelet,以便它加载这个更改。

如果我们检查host04的节点状态,它似乎仍然是就绪的:

root@host01:~# kubectl get nodes
NAME     STATUS   ROLES        ...
host01   Ready    control-plane...
host02   Ready    control-plane...
host03   Ready    control-plane...
host04   Ready    <none>       ...

然而,节点的事件日志清楚地显示了发生了什么:

root@host01:~# kubectl describe node host04
Name:               host04
...
  Normal   NodeHasInsufficientMemory  6m31s                ...
  Warning  EvictionThresholdMet       7s (x14 over 6m39s)  ...

节点开始驱逐 Pod,并且集群会根据需要在其他节点上自动创建新的 Pod 以保持所需的规模:

root@host01:~# kubectl get pods -o wide
NAME                     READY   STATUS        ... NODE     ...
debug-8677494fdd-4274k   1/1     Running       ... host01   ...
debug-8677494fdd-4pnzb   1/1     Running       ... host01   ...
debug-8677494fdd-5nw6n   1/1     Running       ... host01   ...
debug-8677494fdd-7kbp8   1/1     Running       ... host03   ...
debug-8677494fdd-dsnp5   1/1     Running       ... host03   ...
debug-8677494fdd-hgdbc   1/1     Running       ... host01   ...
debug-8677494fdd-j7cth   1/1     Running       ... host04   ...
debug-8677494fdd-jlj4v   0/1     OutOfmemory   ... host04   ...
debug-8677494fdd-lft7h   1/1     Running       ... host01   ...
debug-8677494fdd-mnk6r   1/1     Running       ... host01   ...
debug-8677494fdd-pc8q8   1/1     Running       ... host01   ...
debug-8677494fdd-sr2kw   0/1     OutOfmemory   ... host04   ...
debug-8677494fdd-tgpb2   1/1     Running       ... host03   ...
debug-8677494fdd-vnjks   0/1     OutOfmemory   ... host04   ...
debug-8677494fdd-xn8t8   1/1     Running       ... host02   ...

分配给host04的 Pod 显示为OutOfMemory,它们已被其他节点上的 Pod 替换。这些 Pod 在节点上被停止,但不像前面我们排空节点的情况,这些 Pod 不会自动终止。即使节点从低内存状态恢复,这些 Pod 仍将显示在 Pod 列表中,处于OutOfMemory状态,直到重新启动kubelet

节点不可达

我们还有一个案例要讨论。在我们之前的两个示例中,kubelet可以与控制平面通信以更新其状态,使控制平面能够相应地采取行动。但是如果出现网络问题或突然断电,并且节点失去与集群的连接而无法报告正在关闭,会发生什么情况呢?在这种情况下,集群将记录节点状态为未知,并在超时后开始将 Pod 转移到其他节点。

让我们模拟一下这种情况。我们将从恢复host04到正常工作状态开始:

root@host04:~# sed -i '/^evictionHard/,+2d' /var/lib/kubelet/config.yaml 
root@host04:~# systemctl restart kubelet

确保在host04上运行这些命令。第一个命令删除我们添加到kubelet配置中的两行,而第二个命令重新启动kubelet以应用更改。现在我们可以再次调整我们的部署,以便重新分配:

root@host01:~# kubectl scale deployment debug --replicas=1
root@host01:~# kubectl scale deployment debug --replicas=12

与之前一样,在运行这些命令后,请等待几分钟以使 Pod 稳定下来。然后,使用kubectl get pods -o wide来验证至少有一个 Pod 分配到了host04

现在我们准备强制断开host04与集群的连接。我们将通过添加防火墙规则来执行此操作:

root@host04:~# iptables -I INPUT -s 192.168.61.10 -j DROP
root@host04:~# iptables -I OUTPUT -d 192.168.61.10 -j DROP

确保在host04上运行此命令。第一个命令告诉防火墙丢弃所有来自 IP 地址192.168.61.10的流量,这是所有三个控制平面节点共享的高可用 IP 地址。第二个命令告诉防火墙丢弃所有发送到同一 IP 地址的流量。

大约一分钟后,host04将显示为NotReady状态:

root@host01:~# kubectl get nodes
NAME     STATUS     ROLES        ...
host01   Ready      control-plane...
host02   Ready      control-plane...
host03   Ready      control-plane...
host04   NotReady   <none>       ...

如果等待几分钟,host04上的 Pod 将显示为Terminating,因为集群放弃了这些 Pod 并将它们转移到其他节点:

root@host01:~# kubectl get pods -o wide
NAME                     READY   STATUS        ... NODE     ...
debug-8677494fdd-2wrn2   1/1     Running       ... host01   ...
debug-8677494fdd-4lz48   1/1     Running       ... host02   ...
debug-8677494fdd-78874   1/1     Running       ... host01   ...
debug-8677494fdd-7f8fw   1/1     Running       ... host01   ...
debug-8677494fdd-9vb5m   1/1     Running       ... host03   ...
debug-8677494fdd-b7vj6   1/1     Running       ... host03   ...
debug-8677494fdd-c2c4v   1/1     Terminating   ... host04   ...
debug-8677494fdd-c8tzv   1/1     Running       ... host03   ...
debug-8677494fdd-d2r6b   1/1     Terminating   ... host04   ...
debug-8677494fdd-d5t6b   1/1     Running       ... host01   ...
debug-8677494fdd-j7cth   1/1     Terminating   ... host04   ...
debug-8677494fdd-jjfsl   1/1     Terminating   ... host04   ...
debug-8677494fdd-nqb8z   1/1     Running       ... host03   ...
debug-8677494fdd-sskd5   1/1     Running       ... host02   ...
debug-8677494fdd-wz6c6   1/1     Terminating   ... host04   ...
debug-8677494fdd-x5b4w   1/1     Running       ... host02   ...
debug-8677494fdd-zfbml   1/1     Running       ... host01   ...

然而,因为host04上的kubelet无法连接到控制平面,它不知道它应该关闭其 Pod。如果我们检查在host04上运行哪些容器,我们仍然会看到多个容器在运行:

root@host04:~# crictl ps
CONTAINER           IMAGE          ...  STATE      NAME  ...
2129a1cb00607       16ea53ea7c652  ...  Running    debug ...
cfd7fd6142321       16ea53ea7c652  ...  Running    debug ...
0289ffa5c816d       16ea53ea7c652  ...  Running    debug ...
fb2d297d11efb       16ea53ea7c652  ...  Running    debug ...
...

不仅 Pods 仍在运行,而且由于我们切断连接的方式,它们仍然能够与集群的其余部分进行通信。这一点非常重要。Kubernetes 会尽力运行请求的实例数量并响应错误,但它只能基于它所拥有的信息来执行此操作。在这种情况下,由于host04上的kubelet无法与控制平面通信,Kubernetes 无法知道 Pods 仍然在运行。在为像 Kubernetes 集群这样的分布式系统构建应用时,你应该认识到某些类型的错误可能会导致意想不到的结果,比如部分网络连接或与指定实例数量不同的情况。在包含滚动更新的更高级的应用架构中,这甚至可能导致旧版本的应用组件意外运行。确保构建能够应对这些意外行为的具有弹性的应用。

最后的思考

最终,要拥有一个 Kubernetes 集群,我们需要能够运行容器的节点,这意味着需要连接到控制平面和容器运行时的kubelet实例。在本章中,我们检查了如何配置kubelet以及当节点离开或加入集群时,集群的行为——无论是故意的还是由于故障。

本章的一个关键主题是 Kubernetes 如何在节点出现问题时仍然保持指定数量的 Pods 运行。在下一章中,我们将看到如何将监控扩展到容器内部及其进程,确保进程按预期运行。我们将看到如何指定探针以允许 Kubernetes 监控容器,以及当容器不健康时集群如何响应。

第十三章:健康探针

image

拥有一个可靠的应用程序不仅仅是让应用组件保持运行。应用组件还需要能够及时响应请求,并从依赖项中获取数据并发出请求。这意味着,“健康”应用组件的定义对于每个组件都是不同的。

同时,Kubernetes 需要知道 Pod 及其容器的健康状态,以便只将流量路由到健康的容器,并替换掉失败的容器。因此,Kubernetes 允许为容器配置自定义健康检查,并将这些健康检查集成到工作负载资源的管理中,例如 Deployment。

在本章中,我们将学习如何为我们的应用程序定义健康探针。我们将研究基于网络的健康探针和容器内部的探针。我们还将了解 Kubernetes 如何运行这些健康探针,并且当容器变得不健康时如何响应。

关于探针

Kubernetes 支持三种不同类型的探针:

Exec 运行一个命令或脚本来检查容器的状态。

TCP 确定一个套接字是否打开。

HTTP 验证 HTTP GET 是否成功。

此外,我们可以将这三种探针中的任何一种用于三种不同的用途:

Liveness 检测并重启失败的容器。

Startup 在启动活性探针之前,给容器额外的时间。

Readiness 在容器尚未准备好时避免向其发送流量。

在这三种用途中,最重要的是活性探针,因为它在容器的主要生命周期内运行,并可能导致容器重启。我们将详细了解活性探针,并利用这些知识理解如何使用启动探针和就绪探针。

活性探针

活性探针会在容器启动后持续运行。活性探针作为容器定义的一部分创建,任何未通过活性探针检查的容器将会被自动重启。

Exec 探针

首先从一个简单的活性探针开始,该探针会在容器内运行一个命令。Kubernetes 期望命令在超时前完成,并返回零表示成功,或者返回非零代码表示存在问题。

NOTE

本书的示例仓库在 github.com/book-of-kubernetes/examples关于如何设置,请参见第 xx 页中的“运行示例”。

让我们通过一个 NGINX web 服务器容器来说明这一点。我们将使用这个 Deployment 定义:

nginx-exec.yaml

------
kind: Deployment
apiVersion: apps/v1
metadata:
  name: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
 app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx
        livenessProbe:
          exec:
            command: ["/usr/bin/curl", "-fq", "http://localhost"]
          initialDelaySeconds: 10
          periodSeconds: 5

livenessProbeexec部分告诉 Kubernetes 在容器内运行一个命令。在这种情况下,使用curl并加上-q标志,这样它不会打印页面内容,而只是返回一个零退出代码表示成功。另外,-f标志使得curl对于任何 HTTP 错误响应(即 300 以上的响应码)返回非零退出代码。

curl命令每 5 秒运行一次,基于periodSeconds;它在容器启动后 10 秒开始,基于initialDelaySeconds

本章的自动化脚本会将nginx-exec.yaml文件添加到/opt目录。按照平常的方式创建此部署:

root@host01:~# kubectl apply -f /opt/nginx-exec.yaml 
deployment.apps/nginx created

结果 Pod 的状态看起来和没有存活探针的 Pod 没有什么不同:

root@host01:~# kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-68dc5f984f-jq5xl   1/1     Running   0          25s

然而,除了常规的 NGINX 服务器进程外,curl每 5 秒在容器内运行一次,验证是否可以连接到服务器。通过kubectl describe命令得到的详细输出展示了这一配置:

root@host01:~# kubectl describe deployment nginx
Name:                   nginx
Namespace:              default
...
Pod Template:
  Labels:  app=nginx
  Containers:
   nginx:
...
 Liveness:     exec [/usr/bin/curl -q http://localhost] delay=10s 
    timeout=1s period=5s #success=1 #failure=3
...

因为定义了存活探针,所以 Pod 持续显示Running状态且没有重启,这表明探针检查成功。#success字段显示一个成功的运行就足以认为容器是存活的,而#failure值显示连续三次失败会导致 Pod 被重启。

我们使用-q来丢弃curl的日志,但即使没有该标志,成功的存活探针的任何日志都会被丢弃。如果我们想保存存活探针的实时日志信息,我们需要将其发送到文件或使用日志库将其发送到网络。

在继续介绍其他类型的探针之前,让我们先看看如果存活探针失败会发生什么。我们将修补curl命令,尝试从服务器检索一个不存在的路径,这会导致curl返回非零退出代码,从而使我们的探针失败。

在第九章中我们使用了补丁文件来编辑 Service 类型。这里我们再做一次补丁以应用更改:

nginx-404.yaml

---
spec:
  template:
    spec:
      containers:
     ➊ - name: nginx
          livenessProbe:
            exec:
              command: ["/usr/bin/curl", "-fq", "http://localhost/missing"]

尽管补丁文件允许我们仅更新我们关心的特定字段,但在这种情况下,补丁文件有几行,因为我们需要指定完整的层次结构,并且还必须指定我们要修改的容器名称➊,以便 Kubernetes 将这些内容合并到该容器的现有定义中。

要补丁部署,请使用kubectl patch命令:

root@host01:~# kubectl patch deploy nginx --patch-file /opt/nginx-404.yaml 
deployment.apps/nginx patched

由于我们在部署中修改了 Pod 的规格,Kubernetes 需要终止旧的 Pod 并创建一个新的 Pod:

root@host01:~# kubectl get pods
NAME                     READY   STATUS        RESTARTS   AGE
nginx-679f866f5b-7lzsb   1/1     Terminating   0          2m28s
nginx-6cb4b995cd-6jpd7   1/1     Running       0          3s

最初,新 Pod 显示Running状态。然而,如果我们大约 30 秒后再次检查,会发现 Pod 出现了问题:

root@host01:~# kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-6cb4b995cd-6jpd7   1/1     Running   1          28s

我们没有改变存活探针的初始延迟和周期,所以第一次探针是在 10 秒后启动,之后每 5 秒运行一次。需要三次失败才能触发重启,因此看到在 25 秒后出现一次重启并不奇怪。

Pod 的事件日志指出了重启的原因:

root@host01:~# kubectl describe pod
Name:         nginx-6cb4b995cd-6jpd7
...
Containers:
  nginx:
...
    Last State:     Terminated
...
Events:
  Type     Reason     Age                From     Message
  ----     ------     ----               ----     -------
...
  Warning  Unhealthy  20s (x9 over 80s)  kubelet  Liveness probe failed: ...
curl: (22) The requested URL returned error: 404 Not Found
...

事件日志提供了有用的 curl 输出,告诉我们存活探针失败的原因。Kubernetes 将继续每 25 秒重启容器,因为每个新容器启动后都会失败三个连续的存活探针。

HTTP 探针

在容器内运行命令以检查健康状况的能力使我们能够执行自定义探针。然而,对于像这样的 web 服务器,我们可以利用 Kubernetes 中的 HTTP 探针功能,避免在容器镜像内部使用 curl,同时验证从 Pod 外部的连接性。

让我们用一个新的配置替换我们的 NGINX Deployment,使用 HTTP 探针:

nginx-http.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: nginx
spec:
  replicas: 1
 selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx
        livenessProbe:
          httpGet:
            path: /
            port: 80

在此配置中,我们告诉 Kubernetes 连接到 Pod 的 80 端口并在根路径 / 上执行 HTTP GET。由于我们的 NGINX 服务器监听 80 端口并将在根路径上提供欢迎文件,我们可以期待它正常工作。

我们指定了整个 Deployment,而不是使用补丁,因此我们将使用 kubectl apply 来更新 Deployment:

root@host01:~# kubectl apply -f /opt/nginx-http.yaml 
deployment.apps/nginx configured

我们也可以使用补丁来进行这个更改,但这次会更复杂,因为补丁文件会被合并到现有配置中。因此,我们需要两个命令:一个删除现有的存活探针,另一个添加新的 HTTP 存活探针。最好是完全替换资源。

注意

kubectl patch 命令是一个有价值的调试命令,但生产环境中的应用程序应该将 YAML 资源文件放在版本控制下,以便进行变更跟踪和同行审查,并且每次都应该应用整个文件,以确保集群反映当前仓库的内容。

现在我们已经应用了新的 Deployment 配置,Kubernetes 会创建一个新的 Pod:

root@host01:~# kubectl get pods
NAME                    READY   STATUS    RESTARTS   AGE
nginx-d75d4d675-wvhxl   1/1     Running   0          2m38s

对于 HTTP 探针,kubelet 负责按适当的时间表运行 HTTP GET 请求并确认结果。默认情况下,任何 HTTP 返回码在 200 或 300 系列内都被视为成功响应。

NGINX 服务器会记录所有请求,因此我们可以使用容器日志来查看正在进行的探测:

root@host01:~# kubectl logs nginx-d75d4d675-wvhxl
...
... 22:23:31 ... "GET / HTTP/1.1" 200 615 "-" "kube-probe/1.21" "-"
... 22:23:41 ... "GET / HTTP/1.1" 200 615 "-" "kube-probe/1.21" "-"
... 22:23:51 ... "GET / HTTP/1.1" 200 615 "-" "kube-probe/1.21" "-"

这次我们没有指定 periodSeconds,所以 kubelet 以默认的每 10 秒一次的频率进行探测。

在继续之前,让我们先清理一下 NGINX Deployment:

root@host01:~# kubectl delete deployment nginx
deployment.apps "nginx" deleted

我们已经看过了三种探针中的两种,接下来我们来看看 TCP 探针。

TCP 探针

类似 PostgreSQL 这样的数据库服务器监听网络连接,但它不使用 HTTP 进行通信。我们仍然可以使用 TCP 探针为这些类型的容器创建探测。它不能提供 HTTP 或 exec 探针的配置灵活性,但它可以验证 Pod 中的容器是否在指定端口上监听连接。

这里是一个带有 TCP 探针的 PostgreSQL Deployment:

postgres-tcp.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: postgres
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
      - name: postgres
        image: postgres
        env:
        - name: POSTGRES_PASSWORD
          value: "supersecret"
        livenessProbe:
 tcpSocket:
            port: 5432

我们在 第十章 中看到需要 POSTGRES_PASSWORD 环境变量。这个例子中唯一更改的配置是 livenessProbe。我们指定了一个 5432 的 TCP 套接字,因为这是 PostgreSQL 的标准端口。

和往常一样,我们可以创建这个部署,并在一段时间后观察它是否正在运行:

root@host01:~# kubectl apply -f /opt/postgres-tcp.yaml 
deployment.apps/postgres created
...
root@host01:~# kubectl get pods
NAME                       READY   STATUS    RESTARTS   AGE
postgres-5566ff748-jqp5d   1/1     Running   0          29s

同样,执行探针的是 kubelet。它仅通过与端口建立 TCP 连接并断开连接来执行此操作。当发生这种情况时,PostgreSQL 不会输出任何日志,因此我们知道探针是否有效的唯一方式是检查容器是否继续运行且没有重启:

root@host01:~# kubectl get pods
NAME                       READY   STATUS    RESTARTS   AGE
postgres-5566ff748-jqp5d   1/1     Running   0          2m7s

在继续之前,让我们清理一下部署:

root@host01:~# kubectl delete deploy postgres
deployment.apps "postgres" deleted

我们现在已经查看了所有三种类型的探针。虽然我们使用这三种类型来创建存活探针,但这三种类型同样适用于启动探针和就绪探针。唯一的区别是当探针失败时,我们集群的行为会有所不同。

启动探针

不健康的容器可能会为应用程序带来各种困难,包括响应迟缓、请求响应错误或数据异常,因此我们希望 Kubernetes 在容器变为不健康时能迅速作出反应。然而,当容器首次启动时,可能需要一些时间才能完全初始化。在此期间,它可能无法响应存活探针。

由于这种延迟,我们需要在容器失败探针之前设置一个较长的超时时间,以便容器有足够的时间进行初始化。然而,同时我们又需要一个较短的超时时间,以便快速检测到失败的容器并重启它。解决方法是配置一个单独的 启动探针。Kubernetes 将使用启动探针配置,直到探针成功;然后它会切换到存活探针。

例如,我们可以如下配置我们的 NGINX 服务器部署:

...
spec:
...
  template:
...
    spec:
      containers:
      - name: nginx
        image: nginx
        livenessProbe:
          httpGet:
            path: /
            port: 80
        startupProbe:
          httpGet:
            path: /
            port: 80
          periodSeconds: 
          initialDelaySeconds: 30
          periodSeconds: 10
          failureThreshold: 60

根据这个配置,Kubernetes 将在启动后 30 秒开始检查容器。它将每 10 秒检查一次,直到探针成功或尝试达到 60 次失败。其效果是容器有 10 分钟的时间完成初始化并成功响应探针。如果容器在此时间内未能通过探针检查,它将被重启。

一旦容器成功通过一次探针,Kubernetes 就会切换到 livenessProbe 配置。因为我们没有重写任何定时参数,所以这将每 10 秒进行一次探针检查,连续三次探针失败将导致重启。我们最初为容器提供 10 分钟的存活时间,但之后将在 30 秒内没有响应时重启容器。

startupProbe被完全独立定义,这意味着可以为启动创建与活跃检查不同的检查。当然,重要的是要明智选择,以确保容器不会在活跃探针通过之前就通过其启动探针,因为那样会导致不适当的重启。

就绪探针

第三个探针的目的是检查 Pod 的就绪性就绪性这个术语可能与启动探针显得有些重复。然而,尽管完成初始化是软件就绪性的一个重要部分,一个应用组件可能由于多种原因无法准备好执行工作,尤其是在一个高可用的微服务架构中,组件可能随时进出。

就绪探针应当用于任何容器无法执行工作因为超出其控制的故障的情况,而不是用于初始化。这个问题可能是暂时的,因为其他地方的重试逻辑可能会修复这个故障。例如,一个依赖外部数据库的 API 如果数据库无法访问,可能会失败其就绪探针,但该数据库可能随时恢复服务。

这也与启动和活跃探针形成了有价值的对比。如前所述,Kubernetes 将在容器未通过配置的启动或活跃探针次数时重启容器。但如果问题是外部依赖失败或缺失,重启容器毫无意义,因为重启容器无法解决外部的问题。

同时,如果一个容器缺少所需的外部依赖项,它就无法执行工作,因此我们不希望向它发送任何工作。在这种情况下,最好的做法是保持容器运行,并给予它重新建立所需连接的机会,但避免向它发送任何请求。与此同时,我们可以希望集群中其他地方有一个相同部署的 Pod 正常工作,使我们的应用整体对局部故障具有弹性。

这正是 Kubernetes 中就绪探针的工作原理。如我们在第九章中所看到的,Kubernetes 服务持续监视与其选择器匹配的 Pod,并为其集群 IP 配置负载均衡,将流量路由到这些 Pod。如果 Pod 报告自己未就绪,服务将停止将流量路由到它,但kubelet不会触发任何其他操作,如容器重启。

让我们来举一个例子。我们希望对 Pod 的就绪性进行单独控制,因此我们将使用一个稍微做作的例子,而不是一个真实的外部依赖来决定就绪性。我们将部署一组 NGINX Pod,并且这次会有一个相应的服务:

nginx-ready.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
 metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx
        livenessProbe:
          httpGet:
            path: /
            port: 80
        readinessProbe:
          httpGet:
            path: /ready
            port: 80
---
kind: Service
apiVersion: v1
metadata:
  name: nginx
spec:
  selector:
    app: nginx
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80

此部署将其livenessProbe保留为 NGINX 正常工作的指标,并添加了一个readinessProbe。服务定义与第九章中看到的完全相同,并将流量路由到我们的 NGINX Pod。

此文件已写入/opt,因此我们可以将其应用到集群中:

root@host01:~# kubectl apply -f /opt/nginx-ready.yaml 
deployment.apps/nginx created
service/nginx created

这些 Pod 运行后会保持运行状态,因为存活探针成功:

root@host01:~# kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-67fb6485f5-2k2nz   0/1     Running   0          38s
nginx-67fb6485f5-vph44   0/1     Running   0          38s
nginx-67fb6485f5-xzmj5   0/1     Running   0          38s

此外,我们创建的服务已分配了一个集群 IP:

root@host01:~# kubectl get services
NAME         TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
...
nginx        ClusterIP   10.101.98.80   <none>        80/TCP    3m1s

然而,我们无法使用该 IP 地址访问任何 Pod:

root@host01:~# curl http://10.101.98.80
curl: (7) Failed to connect to 10.101.98.80 port 80: Connection refused

这是因为当前 NGINX 在/ready路径上没有内容可提供,因此返回404,就绪探针失败。对 Pod 的详细检查显示它尚未准备就绪:

root@host01:~# kubectl describe pod
Name:         nginx-67fb6485f5-2k2nz
...
Containers:
  nginx:
...
    Ready:          False
...

因此,服务没有任何端点可用于路由流量:

root@host01:~# kubectl describe service nginx
Name:              nginx
...
Endpoints:         
...

因为服务没有端点,已配置iptables拒绝所有流量:

root@host01:~# iptables-save | grep default/nginx
-A KUBE-SERVICES -d 10.101.98.80/32 -p tcp -m comment --comment "default/nginx has no endpoints"  
  -m tcp --dport 80 -j REJECT --reject-with icmp-port-unreachable

要解决此问题,我们需要至少一个 Pod 准备就绪,以确保 NGINX 有内容可以提供给/ready路径。我们将使用容器的主机名来跟踪哪个 Pod 正在处理我们的请求。

要使其中一个 Pod 准备就绪,让我们首先再次获取 Pod 列表,只是为了方便获取 Pod 名称:

root@host01:~# kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-67fb6485f5-2k2nz   0/1     Running   0          10m
nginx-67fb6485f5-vph44   0/1     Running   0          10m
nginx-67fb6485f5-xzmj5   0/1     Running   0          10m

现在,我们将选择一个并使其报告为准备就绪:

root@host01:~# kubectl exec -ti nginx-67fb6485f5-2k2nz -- \
  cp -v /etc/hostname /usr/share/nginx/html/ready
'/etc/hostname' -> '/usr/share/nginx/html/ready'

我们的服务将开始显示一个有效的端点:

root@host01:~# kubectl describe svc nginx
Name:              nginx
...
Endpoints:         172.31.239.199:80
...

更好的是,我们现在可以通过集群 IP 访问 NGINX 实例,内容与主机名对应:

root@host01:~# curl http://10.101.98.80/ready
nginx-67fb6485f5-2k2nz

注意 URL 末尾的/ready,因此响应是主机名。如果多次运行此命令,我们会看到每次主机名都相同。这是因为通过存活探针的唯一 Pod 正在处理所有服务流量。

让我们也使其他两个 Pod 变为准备就绪状态:

root@host01:~# kubectl exec -ti nginx-67fb6485f5-vph44 -- \
  cp -v /etc/hostname /usr/share/nginx/html/ready
'/etc/hostname' -> '/usr/share/nginx/html/ready'
root@host01:~# kubectl exec -ti nginx-67fb6485f5-xzmj5 -- \
  cp -v /etc/hostname /usr/share/nginx/html/ready
'/etc/hostname' -> '/usr/share/nginx/html/ready'

我们的服务现在展示所有三个端点:

root@host01:~# kubectl describe service nginx
Name:              nginx
...
Endpoints:         172.31.239.199:80,172.31.239.200:80,172.31.89.210:80
...

多次运行curl命令显示流量现在分布在多个 Pod 之间:

root@host01:~# for i in $(seq 1 5); do curl http://10.101.98.80/ready; done
nginx-67fb6485f5-xzmj5
nginx-67fb6485f5-2k2nz
nginx-67fb6485f5-xzmj5
nginx-67fb6485f5-vph44
nginx-67fb6485f5-vph44

嵌入命令$(seq 1 5)返回数字一至五,导致for循环运行curl五次。如果多次运行相同的for循环,你将看到主机名的不同分布。如第九章所述,负载均衡基于随机均匀分布,每个端点被选中作为新连接的概率相等。

一个良好的实践是为每个应用程序提供一个 HTTP 准备就绪端点,检查应用程序及其依赖项的当前状态,并在组件健康时返回 HTTP 成功代码(如200),否则返回 HTTP 错误代码(如500)。某些应用框架(如 Spring Boot)提供自动公开存活和就绪端点的应用程序状态管理。

总结思路

Kubernetes 提供了检查我们的容器并确保它们按预期工作的能力,而不仅仅是进程正在运行。这些探针可以包括在容器内运行任意命令,验证端口是否开放以进行 TCP 连接,或者容器是否正确响应 HTTP 请求。为了构建弹性应用程序,我们应为每个应用程序组件定义一个存活探针和一个就绪探针。存活探针用于重新启动不健康的容器;就绪探针确定 Pod 是否能处理服务流量。此外,如果组件需要额外的初始化时间,我们还应定义一个启动探针,以确保在初始化完成后能够给予其所需的初始化时间,并在初始化完成后迅速响应失败。

当然,为了使我们的容器按预期运行,集群中的其他容器也必须表现良好,不能使用过多的集群资源。在下一章中,我们将看看如何限制我们的容器在使用 CPU、内存、磁盘空间和网络带宽方面,以及如何控制用户可用的总资源量。指定限制和配额的能力对于确保我们的集群能够支持多个应用程序并保持可靠的性能至关重要。

第十四章:限制与配额

image

为了让我们的集群为应用程序提供一个可预测的环境,我们需要控制每个独立应用程序组件使用的资源。如果一个应用程序组件可以使用给定节点上所有的 CPU 或内存,Kubernetes 调度器将无法自信地将新 Pod 分配到节点,因为它无法知道每个节点的可用空间有多少。

在本章中,我们将探讨如何指定请求的资源和限制,确保容器获得所需的资源而不影响其他容器。我们将在运行时级别检查单个容器,以便我们可以看到 Kubernetes 如何配置我们在第一部分中看到的容器技术,足以满足容器的资源需求,同时避免容器超出其限制。

最后,我们将探讨如何使用基于角色的访问控制来管理配额,限制特定用户或应用程序可以请求的资源量,这将帮助我们了解如何以一种可靠支持多个独立应用程序或开发团队的方式管理集群。

请求与限制

Kubernetes 支持多种不同类型的资源,包括处理、内存、存储、网络带宽和特殊设备的使用,如图形处理单元(GPU)。我们将在本章后面讨论网络限制,但首先让我们从最常见的资源类型开始:处理和内存。

处理和内存限制

处理和内存资源的规范有两个目的:调度和防止冲突。Kubernetes 为每个目的提供不同类型的资源规范。Pod 的容器在 Kubernetes 中消耗处理和内存资源,因此资源规范应用于这些地方。

在调度 Pods 时,Kubernetes 使用容器规范中的 requests 字段,将该字段的值在 Pod 中的所有容器中相加,并找到一个在处理和内存上都有足够余量的节点。通常,requests 字段设置为每个容器在 Pod 中的预期平均资源需求。

资源规范的第二个目的在于防止拒绝服务问题,其中一个容器占用了整个节点的资源,负面影响到其他容器。这要求在运行时执行容器资源的强制限制。Kubernetes 使用容器规范中的 limits 字段来实现这一目的,因此我们需要确保将 limits 字段设置得足够高,以便容器能够在不超出限制的情况下正确运行。

性能调优

请求应与预期的平均资源需求相匹配的想法,基于一个假设,即集群中各个容器的负载峰值是不可预测且不相关的,因此可以假设负载峰值会在不同时间发生。即便如此,仍然存在多个容器在同一节点上出现负载峰值时,导致该节点过载的风险。如果不同 Pod 之间的负载峰值是相关的,这种过载的风险就会增加。同时,如果我们为最坏情况配置requests,可能会导致集群过大,大部分时间都处于闲置状态。在第十九章中,我们探讨了 Kubernetes 为 Pod 提供的不同服务质量(QoS)类,并讨论了如何在性能保证和集群效率之间找到平衡。

清单 14-1 通过使用请求和限制的部署示例开始我们的检查。

nginx-limit.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx
        resources:
          requests:
            memory: "64Mi"
            cpu: "250m"
          limits:
            memory: "128Mi"
            cpu: "500m"
      nodeName: host01

清单 14-1:带有限制的部署

我们将使用这个部署来探索如何在容器运行时级别配置资源限制,因此我们使用nodeName字段确保容器最终运行在host01上。这会限制调度器放置 Pod 的位置,但调度器仍然会使用requests字段来确保有足够的资源。如果host01变得过于繁忙,调度器将拒绝调度该 Pod,这类似于我们在第十章中看到的情况。

resources字段是在单个容器级别定义的,允许我们为 Pod 中的每个容器指定单独的资源需求。对于这个容器,我们指定了64Mi的内存请求和128Mi的内存限制。后缀Mi表示我们使用的是 2 的幂次单位兆二进制字节(mebibytes),即 2 的 20 次方,而不是 10 的幂次单位兆字节(megabytes),后者的值略小,为 10 的 6 次方。

与此同时,使用cpu字段指定的处理请求和限制并不是基于任何绝对的处理单位,而是基于我们集群的合成cpu 单位。每个 cpu 单位大致对应一个虚拟 CPU 或核心。m后缀指定了千分之一 cpu,因此我们的requests值为250m,相当于四分之一核心,而limit500m,相当于半个核心。

注意

本书的示例代码库位于 github.com/book-of-kubernetes/examples有关如何设置的详细信息,请参见第 xx 页的“运行示例”。

让我们创建这个部署:

root@host01:~# kubectl apply -f /opt/nginx-limit.yaml 
deployment.apps/nginx created

Pod 将被分配到host01并启动:

root@host01:~# kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-56dbd744d9-vg5rj   1/1     Running   0          22m

然后host01将显示资源已分配给 Pod。

root@host01:~# kubectl describe node host01
Name:               host01
...
Non-terminated Pods:          (15 in total)
  Namespace Name                   CPU Requests CPU Limits Memory Requests Memory Limits Age
  --------- ----                   ------------ ---------- --------------- ------------- ---
...
  default   nginx-56dbd744d9-vg5rj 250m (12%)   500m (25%) 64M (3%)        128M (6%)     61s
...

即使我们的 NGINX web 服务器处于空闲状态,没有使用大量的处理或内存资源,这一点仍然成立:

root@host01:~# kubectl top pods
...
NAME                     CPU(cores)   MEMORY(bytes)   
nginx-56dbd744d9-vg5rj   0m           5Mi

类似于我们在第十二章中看到的,这个命令查询收集来自每个集群节点上运行的 kubelet 数据的度量插件。

Cgroup 强制执行

我们指定的处理和内存限制是通过使用 Linux 控制组(cgroup)功能来强制执行的,这在第三章中有描述。Kubernetes 在 /sys/fs/cgroup 文件系统中的每个层级内管理自己的空间。例如,内存限制是在内存 cgroup 中配置的:

root@host01:~# ls -1F /sys/fs/cgroup/memory
...
kubepods.slice/
...

给定主机上的每个 Pod 在 kubepods.slice 树中都有一个目录。然而,找到特定 Pod 的目录需要一些工作,因为 Kubernetes 将 Pod 划分为不同的服务类别,并且 cgroup 目录的名称与 Pod 或其容器的 ID 不匹配。

为了避免我们在 /sys/fs/cgroup 中四处查找,我们将使用本章自动化脚本安装的一个脚本:/opt/cgroup-info。这个脚本使用 crictl 查询容器运行时的 cgroup 路径,然后从该路径收集 CPU 和内存限制数据。脚本的最重要部分是这个收集路径的部分:

cgroup-info

#!/bin/bash
...
POD_ID=$(crictl pods --name ${POD} -q)
...
cgp_field='.info.config.linux.cgroup_parent'
CGP=$(crictl inspectp $POD_ID | jq -r "$cgp_field")

CPU=/sys/fs/cgroup/cpu/$CGP
MEM=/sys/fs/cgroup/memory/$CGP
...

crictl pods 命令收集 Pod 的 ID,然后与 crictl inspectpjq 一起使用,以收集一个特定字段,称为 cgroup_parent。这个字段是为该 Pod 在每种资源类型中创建的 cgroup 子目录。

让我们使用我们的 NGINX Web 服务器运行这个脚本,看看 CPU 和内存限制是如何配置的:

root@host01:~# kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-56dbd744d9-vg5rj   1/1     Running   0          59m
root@host01:~# /opt/cgroup-info nginx-56dbd744d9-vg5rj

Container Runtime
-----------------
Pod ID: 54602befbd141a74316323b010fb38dae0c2b433cdbe12b5c4d626e6465c7315
Cgroup path: /kubepods.slice/...9f8f3dcf_6cca_49b8_a3df_d696ece01f59.slice

CPU Settings
------------
CPU Shares: 256
CPU Quota (us): 50000 per 100000

Memory Settings
---------------
Limit (bytes): 134217728

我们首先收集 Pod 的名称,然后用它来收集 cgroup 信息。请注意,这只有在 Pod 运行在 host01 上时才有效;该脚本适用于任何 Pod,但必须从该 Pod 运行所在的主机上执行。

对于 CPU 配置,有两个关键数据。配额是硬限制;它意味着在任何给定的 100,000 微秒期间,这个 Pod 只能使用 50,000 微秒的处理器时间。这个值对应于清单 14-1 中指定的 500m CPU 限制(回想一下,500m 限制相当于半个核心)。

除了这个硬限制之外,我们在清单 14-1 中指定的 CPU 请求字段已经用于配置 CPU 配额。正如我们在第三章中看到的,这个字段按相对方式配置 CPU 使用率。因为它是相对于相邻目录中的值的,所以没有单位,因此 Kubernetes 以每个核心等于 1,024 为基础计算 CPU 配额。我们指定了 250m 的 CPU 请求,因此这相当于 256。

CPU 配额并没有对 CPU 使用设定任何限制,因此如果系统空闲,Pod 可以使用其硬性限制范围内的所有处理能力。然而,随着系统变得繁忙,CPU 配额决定了每个 Pod 相对于同一服务类中的其他 Pod 分配的处理能力。这有助于确保如果系统超载,所有 Pod 将根据其 CPU 请求公平地降级。

最后,对于内存,只有一个相关的值。我们指定了 128Mi 的内存限制,相当于 128MiB。正如我们在第三章中看到的,如果我们的容器尝试超过此限制,它将被终止。因此,至关重要的是要么配置应用程序使其不会超过此值,要么了解应用程序在负载下的表现,以选择最佳限制。

一个进程实际使用的内存量最终取决于该进程本身,这意味着内存请求值除了在初始使用时确保有足够的内存来调度 Pod 外没有其他作用。因此,我们在 cgroup 配置中看不到 64Mi 的内存请求值被使用。

资源分配在 cgroup 中的反映方式让我们了解到关于集群性能的重要信息。因为 requests 用于调度,而 limits 用于运行时强制执行,所以一个节点可能会过度分配处理能力和内存。如果容器的 limit 大于 requests,并且容器始终在其 requests 之上运行,这可能会导致节点上的容器出现性能问题。我们将在第十九章中更详细地讨论这一点。

我们已经完成了 NGINX 部署,现在让我们将其删除:

root@host01:~# kubectl delete -f /opt/nginx-limit.yaml 
deployment.apps "nginx" deleted

到目前为止,容器运行时可以强制执行我们所看到的限制。然而,集群必须强制执行其他类型的限制,如网络。

网络限制

理想情况下,我们的应用程序将设计为中等程度地需要用于互相通信的带宽,并且我们的集群将有足够的带宽来满足所有容器的需求。然而,如果确实有一个容器试图占用超过其份额的网络带宽,我们需要一种方法来限制它。

因为网络设备是通过插件配置的,我们需要一个插件来管理带宽。幸运的是,bandwidth 插件是与我们的 Kubernetes 集群一起安装的标准 CNI 插件的一部分。此外,正如我们在第八章中看到的,默认的 CNI 配置启用了 bandwidth 插件:

root@host01:~# cat /etc/cni/net.d/10-calico.conflist 
{
  "name": "k8s-pod-network",
  "cniVersion": "0.3.1",
  "plugins": [
...
    {
      "type": "bandwidth",
      "capabilities": {"bandwidth": true}
    },
...
  ]

结果是,kubelet 在每次创建新 Pod 时都会调用 bandwidth 插件。如果 Pod 配置了带宽限制,插件将利用我们在第三章中看到的 Linux 内核的流量控制功能,确保 Pod 的虚拟网络设备不会超过指定的限制。

让我们来看一个例子。首先,我们部署一个 iperf3 服务器来监听客户端连接:

iperf-server.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: iperf-server
spec:
  replicas: 1
  selector:
    matchLabels:
      app: iperf-server
  template:
    metadata:
      labels:
        app: iperf-server
    spec:
      containers:
      - name: iperf
        image: bookofkubernetes/iperf3:stable
        env:
        - name: IPERF_SERVER
          value: "1"
        resources: ...
---
kind: Service
apiVersion: v1
metadata:
  name: iperf-server
spec:
  selector:
    app: iperf-server
  ports:
  - protocol: TCP
    port: 5201
    targetPort: 5201

除了 Deployment,我们还创建了一个 Service。这样,我们的 iperf3 客户端就可以通过其知名名称 iperf-server 找到服务器。我们指定了端口 5201,这是 iperf3 的默认端口。

让我们部署这个服务器:

root@host01:~# kubectl apply -f /opt/iperf-server.yaml 
deployment.apps/iperf-server created
service/iperf-server created

让我们运行一个不应用任何带宽限制的 iperf3 客户端。这将让我们了解在没有任何流量控制的情况下,集群网络的速度。以下是客户端定义:

iperf.yaml

---
kind: Pod
apiVersion: v1
metadata:
  name: iperf
spec:
  containers:
  - name: iperf
    image: bookofkubernetes/iperf3:stable
    resources: ...

通常,iperf3 客户端模式下会运行一次然后终止。这个镜像有一个脚本会重复运行 iperf3,每次运行之间休眠一分钟。让我们启动一个客户端 Pod:

root@host01:~# kubectl apply -f /opt/iperf.yaml 
pod/iperf created

Pod 启动需要几秒钟,之后初次运行将需要 10 秒钟。大约 30 秒后,Pod 日志将显示结果:

root@host01:~# kubectl logs iperf
Connecting to host iperf-server, port 5201
[  5] local 172.31.89.200 port 54346 connected to 10.96.0.192 port 5201
[ ID] Interval           Transfer     Bitrate         Retr  Cwnd
[  5]   0.00-1.00   sec   152 MBytes  1.28 Gbits/sec  225    281 KBytes       
[  5]   1.00-2.00   sec   154 MBytes  1.29 Gbits/sec  153    268 KBytes       
[  5]   2.00-3.00   sec   163 MBytes  1.37 Gbits/sec  230    325 KBytes       
[  5]   3.00-4.00   sec   171 MBytes  1.44 Gbits/sec  254    243 KBytes       
[  5]   4.00-5.00   sec   171 MBytes  1.44 Gbits/sec  191    319 KBytes       
[  5]   5.00-6.00   sec   174 MBytes  1.46 Gbits/sec  230    302 KBytes       
[  5]   6.00-7.00   sec   180 MBytes  1.51 Gbits/sec  199    221 KBytes       
[  5]   7.00-8.01   sec   151 MBytes  1.26 Gbits/sec  159    270 KBytes       
[  5]   8.01-9.00   sec   160 MBytes  1.36 Gbits/sec  145    298 KBytes       
[  5]   9.00-10.00  sec   147 MBytes  1.23 Gbits/sec  230    276 KBytes       
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  1.59 GBytes  1.36 Gbits/sec  2016             sender
[  5]   0.00-10.00  sec  1.59 GBytes  1.36 Gbits/sec                  receiver

iperf Done.

在这种情况下,我们看到客户端和服务器之间的传输速率为 1.36 GBits/sec。根据您的集群部署情况以及客户端和服务器是否位于同一主机上,您的结果可能会有所不同。

在继续之前,我们将关闭现有的客户端,以防它干扰我们的下一个测试:

root@host01:~# kubectl delete pod iperf
pod "iperf" deleted

显然,在运行时,iperf3 尝试尽可能多地使用网络带宽。这对于测试应用程序来说没问题,但对于 Kubernetes 集群中的应用组件来说,这种行为并不太礼貌。为了限制其带宽,我们将在 Pod 定义中添加一个注解:

iperf-limit.yaml

 ---
 kind: Pod
 apiVersion: v1
 metadata:
   name: iperf-limit
➊ annotations:
     kubernetes.io/ingress-bandwidth: 1M
     kubernetes.io/egress-bandwidth: 1M
 spec:
   containers:
 - name: iperf
     image: bookofkubernetes/iperf3:stable
     resources: ...
   nodeName: host01

我们希望检查如何将限制应用到网络设备上,如果这个 Pod 最终在 host01 上,检查会更容易,所以我们相应地设置了 nodeName。否则,这个 Pod 定义中唯一的变化是 Pod 元数据中的 annotations 部分 ➊。我们为 ingress 和 egress 设置了 1M 的值,相当于对 Pod 设置了 1Mb 的带宽限制。当这个 Pod 被调度时,kubelet 会获取这些注解,并将指定的带宽限制发送给带宽插件,以便它可以相应地配置 Linux 流量整形。

让我们创建这个 Pod 并查看它的实际操作:

root@host01:~# kubectl apply -f /opt/iperf-limit.yaml 
pod/iperf-limit created

和之前一样,我们等待足够的时间让客户端完成一次与服务器的测试,然后打印日志:

root@host01:~# kubectl logs iperf-limit
Connecting to host iperf-server, port 5201
[  5] local 172.31.239.224 port 45680 connected to 10.96.0.192 port 5201
[ ID] Interval           Transfer     Bitrate         Retr  Cwnd
[  5]   0.00-1.01   sec  22.7 MBytes 190 Mbits/sec    0   1.37 KBytes       
[  5]   1.01-2.01   sec  0.00 Bytes  0.00 bits/sec    0    633 KBytes       
[  5]   2.01-3.00   sec  0.00 Bytes  0.00 bits/sec    0    639 KBytes       
[  5]   3.00-4.00   sec  0.00 Bytes  0.00 bits/sec    0    646 KBytes       
[  5]   4.00-5.00   sec  0.00 Bytes  0.00 bits/sec    0    653 KBytes       
[  5]   5.00-6.00   sec  1.25 MBytes 10.5 Mbits/sec   0    658 KBytes       
[  5]   6.00-7.00   sec  0.00 Bytes  0.00 bits/sec    0    658 KBytes       
[  5]   7.00-8.00   sec  0.00 Bytes  0.00 bits/sec    0    658 KBytes       
[  5]   8.00-9.00   sec  0.00 Bytes  0.00 bits/sec    0    658 KBytes       
[  5]   9.00-10.00  sec  0.00 Bytes  0.00 bits/sec    0    658 KBytes       
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  24.0 MBytes  20.1 Mbits/sec    0             sender
[  5]   0.00-10.10  sec  20.7 MBytes  17.2 Mbits/sec                  receiver

iperf Done.

变化是显著的,因为该 Pod 的速度受限于我们在没有限制的客户端上看到的速度的一小部分。然而,由于流量整形基于令牌桶过滤器,短时间内流量控制并不精确,因此我们看到的比特率大约为 20Mb 而不是 1Mb。要了解原因,让我们看看实际的流量整形配置。

bandwidth 插件将令牌桶过滤器应用于为 Pod 创建的虚拟以太网(veth)对的主机端,因此我们可以通过显示主机接口的流量控制配置来查看它:

root@host01:~# tc qdisc show
...
qdisc tbf 1: dev calid43b03f2e06 ... rate 1Mbit burst 21474835b lat 4123.2s 
...

rateburst的组合展示了为什么我们的 Pod 能够在 10 秒的测试运行中达到 20Mb。由于burst值,Pod 能够立即发送大量数据,但代价是花费了几秒钟的时间,无法发送或接收任何数据。在一个更长的时间间隔内,我们会看到平均为 1Mbps 的带宽,但我们仍然会看到这种爆发式的行为。

在继续之前,让我们清理客户端和服务器:

root@host01:~# kubectl delete -f /opt/iperf-server.yaml 
deployment.apps "iperf-server" deleted
service "iperf-server" deleted
root@host01:~# kubectl delete -f /opt/iperf-limit.yaml
pod "iperf-limit" deleted

管理 Pod 的带宽是有用的,但正如我们所见,带宽限制可能表现为 Pod 视角中的间歇性连接。因此,这种流量整形应该被视为无法配置自身带宽使用的容器的最后手段。

配额

限制(Limits)允许我们的 Kubernetes 集群确保每个节点拥有足够的资源来支持其分配的 Pod。然而,如果我们希望集群能够可靠地托管多个应用程序,我们需要一种方法来控制任何一个应用程序可以请求的资源数量。

为了实现这一点,我们将使用配额(quotas)。配额是基于命名空间(Namespaces)分配的,它们指定了在该命名空间内可以分配的最大资源量。这不仅包括 CPU 和内存等基本资源,还包括如 GPU 等专用集群资源。我们甚至可以使用配额来指定在给定命名空间内可以创建的特定对象类型的最大数量,比如部署(Deployment)、服务(Service)或定时任务(CronJob)。

由于配额是基于命名空间分配的,它们需要与我们在第十一章中描述的访问控制结合使用,以确保特定用户受我们创建的配额约束。这意味着创建命名空间和应用配额通常由集群管理员处理。

让我们为我们的部署创建一个示例命名空间:

root@host01:~# kubectl create namespace sample
namespace/sample created

现在,让我们创建一个ResourceQuota资源类型,以便为命名空间应用配额:

quota.yaml

---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: sample-quota
  namespace: sample
spec:
  hard:
    requests.cpu: "1"
    requests.memory: 256Mi
    limits.cpu: "2"
    limits.memory: 512Mi

这个资源定义了 CPU 和内存的配额,适用于请求(requests)和限制(limits)。单位与 Listing 14-1 中部署(Deployment)规范中的限制相同。

让我们将此配额应用到sample命名空间:

root@host01:~# kubectl apply -f /opt/quota.yaml
resourcequota/sample-quota created

我们可以看到这个配额已经成功应用:

root@host01:~# kubectl describe namespace sample
Name:         sample
Labels:       kubernetes.io/metadata.name=sample
Annotations:  <none>
Status:       Active

Resource Quotas
  Name:            sample-quota
  Resource         Used  Hard
  --------         ---   ---
  limits.cpu       0     2
  limits.memory    0     512Mi
  requests.cpu     0     1
  requests.memory  0     256Mi
...

即使这个配额会应用于所有尝试在命名空间中创建 Pod 的用户,包括集群管理员,考虑到管理员总是可以创建新的命名空间来绕过配额,使用普通用户更为现实。因此,我们还将创建一个用户:

root@host01:~# kubeadm kubeconfig user --client-name=me \
  --config /etc/kubernetes/kubeadm-init.yaml > kubeconfig

如同我们在第十一章中所做的那样,我们将把edit角色绑定到该用户,以提供在sample命名空间中创建和编辑资源的权限。我们将使用在 Listing 11-1 中看到的相同 RoleBinding:

root@host01:~# kubectl apply -f /opt/edit-bind.yaml
rolebinding.rbac.authorization.k8s.io/editor created

现在我们的用户已设置完成,让我们设置KUBECONFIG环境变量,以便未来的kubectl命令将以我们的正常用户身份执行:

root@host01:~# export KUBECONFIG=kubeconfig

首先,我们可以验证普通用户所拥有的 edit 角色并不允许对命名空间中的配额进行更改,这很合理——配额是管理员职能:

root@host01:~# kubectl delete -n sample resourcequota sample-quota
Error from server (Forbidden): resourcequotas "sample-quota" is forbidden: 
User "me" cannot delete resource "resourcequotas" in API group "" in the 
namespace "sample"

现在我们可以在 sample 命名空间中创建一些 Pods 来测试配额。首先,让我们尝试创建一个没有限制的 Pod:

root@host01:~# kubectl run -n sample nginx --image=nginx
Error from server (Forbidden): pods "nginx" is forbidden: failed quota: 
sample-quota: must specify limits.cpu,limits.memory...

因为我们的命名空间有配额,我们不再允许创建没有指定限制的 Pods。

在清单 14-2 中,我们再次尝试,这次使用了一个指定资源限制的部署,该部署为它创建的 Pods 设置了资源限制。

sleep.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: sleep
  namespace: sample
spec:
  replicas: 1
  selector:
    matchLabels:
      app: sleep
  template:
 metadata:
      labels:
        app: sleep
    spec:
      containers:
      - name: sleep
        image: busybox
        command:
          - "/bin/sleep"
          - "3600"
        resources:
          requests:
            memory: "64Mi"
            cpu: "250m"
          limits:
            memory: "128Mi"
            cpu: "512m"

清单 14-2:带有限制的部署

现在我们可以将其应用到集群中:

root@host01:~# kubectl apply -n sample -f /opt/sleep.yaml 
deployment.apps/sleep created

这是成功的,因为我们指定了必要的请求和限制字段,并且没有超过配额。此外,Pod 以我们指定的限制启动:

root@host01:~# kubectl get -n sample pods
NAME                     READY   STATUS    RESTARTS   AGE
sleep-688dc46d95-wtppg   1/1     Running   0          72s

然而,我们可以看到,我们现在正在使用配额中的资源:

root@host01:~# kubectl describe namespace sample
Name:         sample
Labels:       kubernetes.io/metadata.name=sample
Annotations:  <none>
Status:       Active

Resource Quotas
  Name:            sample-quota
  Resource         Used   Hard
  --------         ---    ---
  limits.cpu       512m   2
  limits.memory    128Mi  512Mi
  requests.cpu     250m   1
  requests.memory  64Mi   256Mi
...

这将限制我们扩展该部署的能力。让我们来说明一下:

root@host01:~# kubectl scale -n sample deployment sleep --replicas=12
deployment.apps/sleep scaled
root@host01:~# kubectl get -n sample pods
NAME                     READY   STATUS    RESTARTS   AGE
sleep-688dc46d95-trnbl   1/1     Running   0          6s
sleep-688dc46d95-vzfsx   1/1     Running   0          6s
sleep-688dc46d95-wtppg   1/1     Running   0          3m13s

我们请求了 12 个副本,但我们只看到有三个在运行。如果我们描述这个部署,就会看到一个问题:

root@host01:~# kubectl describe -n sample deployment sleep
Name:      sleep
Namespace: sample
...
Replicas:   12 desired | 3 updated | 3 total | 3 available | 9 unavailable
...
Conditions:
  Type             Status  Reason
  ----             ------  ------
  Progressing      True    NewReplicaSetAvailable
  Available        False   MinimumReplicasUnavailable
  ReplicaFailure   True    FailedCreate
OldReplicaSets:    <none>
NewReplicaSet:     sleep-688dc46d95 (3/12 replicas created)
...

现在命名空间报告说,我们已经消耗了足够的配额,无法为另一个 Pod 分配所需的资源:

root@host01:~# kubectl describe namespace sample
Name:         sample
...
Resource Quotas
  Name:            sample-quota
  Resource         Used   Hard
  --------         ---    ---
  limits.cpu       1536m  2
  limits.memory    384Mi  512Mi
  requests.cpu     750m   1
  requests.memory  192Mi  256Mi
...

我们的 Pods 正在运行 sleep,因此我们知道它们几乎不使用任何 CPU 或内存。然而,Kubernetes 是基于我们指定的配额来计算配额利用率,而不是 Pod 实际使用的资源。这一点至关重要,因为进程在变得繁忙时可能会使用更多的 CPU 或分配更多的内存,而 Kubernetes 需要确保为集群的其他部分留出足够的资源,以保证其正常运行。

最终思考

为了让我们的容器化应用程序更可靠,我们需要确保一个应用组件不会占用过多资源,从而有效地使集群中其他容器“饿死”。Kubernetes 能够利用底层容器运行时和 Linux 内核的资源限制功能,将每个容器限制在其已分配的资源范围内。这一做法确保了容器在集群节点上的调度更加可靠,并确保即使集群负载较重,集群资源的分配也能公平共享。

在本章中,我们已经了解了如何为我们的部署指定资源需求,以及如何为命名空间应用配额,从而有效地将集群中的所有节点视为一个大型可用资源池。在下一章,我们将探讨这一原理如何扩展到存储方面,看看如何动态地为 Pods 分配存储,无论它们被调度到哪里。

第十五章:持久存储

image

可扩展性和快速故障切换是容器化应用的巨大优势,且扩展、更新和替换没有持久存储的无状态容器要容易得多。因此,我们通常使用部署(Deployments)来创建一个或多个仅具有临时存储的 Pod 实例。

然而,即使我们有一个大多数组件都是无状态的应用架构,我们仍然需要一些持久存储来支持我们的应用。同时,我们不想失去将 Pod 部署到集群中任何节点的能力,也不希望在容器或节点故障时丢失持久存储的内容。

在本章中,我们将看到 Kubernetes 如何通过使用插件架构按需为 Pods 提供持久存储,该架构允许任何支持的分布式存储引擎作为后端存储。

存储类

Kubernetes 的存储插件架构高度灵活;它认识到一些集群可能根本不需要存储,而其他集群则需要多个存储插件来处理大量数据或低延迟存储。因此,kubeadm 在集群安装时不会立即设置存储;它是在安装后通过向集群添加StorageClass资源来配置的。

每个 StorageClass 都标识一个特定的存储插件,该插件将提供实际存储以及任何其他所需的参数。我们可以使用多个存储类来定义不同的插件或参数,甚至使用相同插件但不同参数的多个存储类,以便为不同的用途提供独立的服务类别。例如,一个集群可能提供内存存储、固态硬盘存储和传统的旋转磁盘存储,让应用程序选择最适合特定目的的存储类型。该集群可能为更昂贵且低延迟的存储提供较小的配额,同时为更适合不常访问数据的慢速存储提供较大的配额。

Kubernetes 内置了一组内部存储提供者。这包括支持流行云服务提供商(如 Amazon Web Services、Microsoft Azure 和 Google Container Engine)的存储驱动程序。然而,只要存储插件支持容器存储接口(CSI)这一已发布的标准,就可以轻松使用任何存储插件与存储提供商接口。

当然,为了与 CSI 兼容,存储提供者必须包含一些最低限度的功能,这些功能对于 Kubernetes 集群中的存储至关重要。最重要的功能包括动态存储管理(配置和解除配置)和动态存储附加(在集群中的任何节点上挂载存储)。这两个关键特性使得集群能够为任何请求存储的 Pod 分配存储,并在集群中的任何节点上调度该 Pod,如果现有节点失败或 Pod 被替换,还能在任何节点上启动具有相同存储的新 Pod。

存储类定义

我们在第六章中部署的 Kubernetes 集群包含了 Longhorn 存储插件(请参阅“安装存储”章节 102 页)。自动化脚本已将其安装到集群中,并为后续各章做好了准备。部分安装工作创建了一个 DaemonSet,以确保 Longhorn 组件存在于每个节点上。该 DaemonSet 启动了多个 Longhorn 组件,并创建了一个 StorageClass 资源,告诉 Kubernetes 如何使用 Longhorn 为 Pod 配置存储。

注意

本书的示例仓库在 github.com/book-of-kubernetes/examples有关如何设置的详细信息,请参见“运行示例”章节 xx 页。

示例 15-1 显示了 Longhorn 创建的 StorageClass。

root@host01:~# kubectl get storageclass
NAME      PROVISIONER         RECLAIMPOLICY  VOLUMEBINDINGMODE  ALLOWVOLUMEEXPANSION ...
longhorn  driver.longhorn.io  Delete         Immediate          true                 ...

示例 15-1:Longhorn StorageClass

这两个最重要的字段显示了 StorageClass 的名称和供应者。名称用于资源规格中,标识应该使用 Longhorn StorageClass 来配置请求的卷,而供应者则是kubelet内部用来与 Longhorn CSI 插件通信的。

CSI 插件内部实现

在继续配置卷并将其附加到 Pods 之前,我们先快速了解一下kubelet是如何查找并与 Longhorn CSI 插件通信的。请注意,kubelet作为服务直接运行在集群节点上;另一方面,所有 Longhorn 组件都被容器化。这意味着二者需要通过在主机文件系统上创建的 Unix 套接字来帮助它们进行通信,然后将该套接字挂载到 Longhorn 容器的文件系统中。Unix 套接字允许两个进程通过流式数据进行通信,类似于网络连接,但没有网络开销。

为了探讨这种通信如何工作,首先我们将列出在host01上运行的 Longhorn 容器:

root@host01:~# crictl ps --name 'longhorn.*|csi.*'
CONTAINER     ... STATE    NAME ...
c8347a513f71e ... Running  csi-provisioner ...
47f950a3e8dbf ... Running  csi-provisioner ...
3aad0fef7454e ... Running  longhorn-csi-plugin ...
9bfb61f786afa ... Running  csi-snapshotter ...
24a2994a264a1 ... Running  csi-snapshotter ...
7ee4c748b4c02 ... Running  csi-snapshotter ...
8d92886fdacda ... Running  csi-resizer ...
9868014407fe0 ... Running  csi-resizer ...
408d16181af51 ... Running  csi-attacher ...
0c6c341debb0c ... Running  longhorn-driver-deployer ...
ba328a9d0aaf2 ... Running  longhorn-manager ...
c39e5c4fee3bb ... Running  longhorn-ui ...

Longhorn 创建的容器名称以longhorncsi开头,因此我们使用正则表达式和crictl来仅显示这些容器。

让我们获取csi-attacher容器的容器 ID,然后检查它,看看它挂载了哪些卷:

root@host01:~# CID=$(crictl ps -q --name csi-attacher)
root@host01:~# crictl inspect $CID
{
...
    "mounts": 
      {
        "containerPath": "/csi/",
 ➊ "hostPath": "/var/lib/kubelet/plugins/driver.longhorn.io",
        "propagation": "PROPAGATION_PRIVATE",
        "readonly": false,
        "selinuxRelabel": false
      }
...
      "envs": [
        {
          "key": "ADDRESS",
       ➋ "value": "/csi/csi.sock"
        },
...
}

crictl inspect 命令返回了容器的很多数据,但在这个示例中我们只展示了相关数据。我们可以看到这个 Longhorn 组件被指示连接到 /csi/csi.sock ➋,这是容器内的 Unix 套接字挂载点,kubelet 用它与存储驱动进行通信。我们还可以看到容器内的 /csi 实际上是 /var/lib/kubelet/plugins/driver.longhorn.io ➊。/var/lib/kubelet/pluginskubelet 查找存储插件的标准位置,当然,driver.longhorn.ioprovisioner 字段的值,如 [Listing 15-1 中的 Longhorn StorageClass 所定义。

如果我们查看主机,能够确认这个 Unix 套接字存在:

root@host01:~# ls -l /var/lib/kubelet/plugins/driver.longhorn.io
total 0
srwxr-xr-x 1 root root 0 Feb 18 20:17 csi.sock

作为第一个字符的 s 表示这是一个 Unix 套接字。

持久卷

现在我们已经了解了 kubelet 如何与外部存储驱动通信,让我们看看如何请求分配存储并将其附加到 Pod。

Stateful Sets

在 Pod 中获取存储的最简单方式是使用 StatefulSet(第七章 中描述的一种资源)。像 Deployment 一样,StatefulSet 会创建多个 Pod,这些 Pod 可以分配到任何节点。然而,StatefulSet 还会创建持久存储,以及每个 Pod 和其存储之间的映射。如果某个 Pod 需要被替换,它将被替换为一个具有相同标识符和相同持久存储的新 Pod。

Listing 15-2 展示了一个示例 StatefulSet,它创建了两个带有持久存储的 PostgreSQL Pods。

pgsql-set.yaml

---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres
  replicas: 2
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
      - name: postgres
        image: postgres
        env:
        - name: POSTGRES_PASSWORD
       ➊ value: "supersecret"
        - name: PGDATA
       ➋ value: /data/pgdata
        volumeMounts:
        - name: postgres-volume
       ➌ mountPath: /data
  volumeClaimTemplates:
  - metadata:
      name: postgres-volume
    spec:
      storageClassName: longhorn
      accessModes:
        - ReadWriteOnce
      resources:
        requests:
          storage: 1Gi

Listing 15-2: PostgreSQL StatefulSet

除了通过环境变量设置密码 ➊,我们还将 PGDATA 设置为 /data/pgdata ➋,这告诉 PostgreSQL 数据库文件应该存储的位置。这与我们作为 StatefulSet 一部分声明的卷挂载相一致,因为那个持久卷将挂载到 /data ➌。PostgreSQL 容器镜像文档建议将数据库文件配置在挂载点下的子目录中,以避免数据目录的所有权问题。

与 PostgreSQL Pod 的配置分开,我们为 StatefulSet 提供了 volumeClaimTemplates 字段。这个字段告诉 StatefulSet 我们希望如何配置持久存储。它包括 StorageClass 的名称和请求的大小,还包括 ReadWriteOnceaccessMode,我们稍后将探讨。StatefulSet 将使用此规范为每个 Pod 分配独立的存储。

如 第七章 中所提到的,这个 StatefulSet 通过 serviceName 字段引用了一个 Service,该 Service 用来为 Pods 创建域名。Service 的定义在同一个文件中,具体如下:

pgsql-set.yaml

---
apiVersion: v1
kind: Service
metadata:
  name: postgres
spec:
  clusterIP: None
  selector:
    app: postgres

clusterIP 字段设置为 None 会使其成为一个 无头服务,这意味着不会从服务 IP 范围分配 IP 地址,也不会为该 Service 配置 第九章 中描述的负载均衡。这个方法通常用于 StatefulSet。对于 StatefulSet,每个 Pod 都有自己独特的身份和独特的存储。由于服务负载均衡是随机选择目标,因此通常在 StatefulSet 中无效。相反,客户端需要明确选择一个 Pod 实例作为目标。

让我们创建 Service 和 StatefulSet:

root@host01:~# kubectl apply -f /opt/pgsql-set.yaml 
service/postgres created
statefulset.apps/postgres created

启动 Pods 需要一些时间,因为它们是顺序创建的,一个接一个。它们启动后,我们可以看到它们的名称:

root@host01:~# kubectl get pods
NAME         READY   STATUS    RESTARTS   AGE
postgres-0   1/1     Running   0          97s
postgres-1   1/1     Running   0          51s

让我们在容器内检查持久化存储:

root@host01:~# kubectl exec -ti postgres-0 -- /bin/sh
# findmnt /data
TARGET SOURCE                         FSTYPE OPTIONS
/data  /dev/longhorn/pvc-83becdac-... ext4   rw,relatime
# exit

如请求所示,我们看到一个已经挂载在 /data 的 Longhorn 设备。即使节点失败或 Pod 升级,Kubernetes 仍会保留这个持久化存储。

这个 StatefulSet 还有两个重要的资源需要探索。第一个是我们创建的无头 Service:

root@host01:~# kubectl get svc
NAME         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   54m
postgres     ClusterIP   None         <none>        <none>    19m

postgres Service 存在,但没有显示集群 IP 地址,因为我们创建它时是一个无头服务。然而,它为关联的 Pods 创建了 DNS 记录,因此我们可以使用它来连接特定的 PostgreSQL Pods,而无需知道 Pod 的 IP 地址。

我们需要使用集群 DNS 来进行查找。最简单的方法是从容器内进行:

root@host01:~# kubectl run -ti --image=alpine --restart=Never alpine
If you don't see a command prompt, try pressing enter.
/ #

这种形式的 run 命令保持在前台并为我们提供一个交互式终端。它还告诉 Kubernetes 在我们退出 shell 时不要尝试重启容器。

在这个容器内部,我们可以通过一个众所周知的名称来引用我们的任何 PostgreSQL Pod:

/ # ping -c 1 postgres-0.postgres.default.svc
PING postgres-0.postgres.default.svc (172.31.239.198): 56 data bytes
64 bytes from 172.31.239.198: seq=0 ttl=63 time=0.093 ms
...
/# ping -c 1 postgres-1.postgres.default.svc
PING postgres-1.postgres.default.svc (172.31.239.199): 56 data bytes
64 bytes from 172.31.239.199: seq=0 ttl=63 time=0.300 ms
...
# exit

命名约定与我们在 第九章 中看到的 Service 相同,但多了一个主机名前缀来表示 Pod 的名称;在这种情况下,可能是 postgres-0postgres-1

另一个重要的资源是 StatefulSet 自动创建的 PersistentVolumeClaim。PersistentVolumeClaim 实际上是通过 Longhorn StorageClass 分配存储的:

root@host01:~# kubectl get pvc
NAME                         STATUS   VOLUME      ...   CAPACITY   ...
postgres-volume-postgres-0   Bound    pvc-83becdac...   1Gi        ...
postgres-volume-postgres-1   Bound    pvc-0d850889...   1Gi        ...

我们用缩写 pvc 来代替其全称 persistentvolumeclaim

StatefulSet 使用了 清单 15-2 中 volumeClaimTemplates 字段的数据来创建这两个 PersistentVolumeClaims。然而,如果我们删除 StatefulSet,PersistentVolumeClaims 会继续存在:

root@host01:~# kubectl delete -f /opt/pgsql-set.yaml 
service "postgres" deleted
statefulset.apps "postgres" deleted
root@host01:~# kubectl get pvc
NAME                         STATUS   VOLUME      ...   CAPACITY   ...
postgres-volume-postgres-0   Bound    pvc-83becdac...   1Gi        ...
postgres-volume-postgres-1   Bound    pvc-0d850889...   1Gi        ...

这可以保护我们免于意外删除持久化存储。如果我们再次创建 StatefulSet 并在卷声明模板中保持相同的名称,我们的新 Pods 会重新获得相同的存储。

高可用 PostgreSQL

我们已经部署了两个独立的 PostgreSQL 实例,每个实例都有自己的独立持久存储。然而,这只是部署高可用数据库的第一步。我们还需要将其中一个实例配置为主实例,另一个配置为备份实例,配置从主实例到备份实例的复制,以及配置故障切换。我们还需要配置客户端连接到主实例,并在发生故障时切换到新的主实例。幸运的是,我们无需自己进行这些配置。在 第十七章中,我们将看到如何利用自定义资源的强大功能,部署一个 Kubernetes Operator 来自动处理所有这些任务。

StatefulSet 是处理需要多个容器实例并且每个实例都需要独立存储的最佳方式。然而,我们也可以更直接地使用持久卷,这样能让我们对它们如何挂载到 Pod 中有更多控制。

卷和声明

Kubernetes 有两种资源类型:PersistentVolume 和 PersistentVolumeClaim。PersistentVolumeClaim 表示对已分配存储的请求,而 PersistentVolume 则包含关于已分配存储的信息。在大多数情况下,这种区别并不重要,我们可以专注于 PersistentVolumeClaim。然而,在两种情况下,区别是很重要的:

  • 管理员可以手动创建 PersistentVolume,并将这个 PersistentVolume 直接挂载到 Pod 中。

  • 如果在按照 PersistentVolumeClaim 中指定的方式分配存储时出现问题,PersistentVolume 将不会被创建。

为了说明,我们首先从一个自动分配存储的 PersistentVolumeClaim 开始:

pvc.yaml

---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nginx-storage
spec:
  storageClassName: longhorn
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 100Mi

我们将这个 PersistentVolumeClaim 命名为 nginx-storage,因为我们接下来会用到它。这个 PersistentVolumeClaim 请求从 longhorn 存储类中获取 100MiB 的存储。当我们将这个 PersistentVolumeClaim 应用到集群时,Kubernetes 会调用 Longhorn 存储驱动并分配存储,过程中会创建一个 PersistentVolume:

root@host01:~# kubectl apply -f /opt/pvc.yaml 
persistentvolumeclaim/nginx-storage created
root@host01:~# kubectl get pv
NAME         ...  CAPACITY ... STATUS  CLAIM                               STORAGECLASS ...
pvc-0b50e5b4-...  1Gi      ... Bound   default/postgres-volume-postgres-1  longhorn     ...
pvc-ad092ba9-...  1Gi      ... Bound   default/postgres-volume-postgres-0  longhorn     ...
pvc-cb671684-...  100Mi    ... Bound   default/nginx-storage               longhorn     ...

缩写 pvpersistentvolumes 的简称。

即使没有 Pod 在使用这个存储,它仍然显示为 Bound 状态,因为有一个活动的 PersistentVolumeClaim 绑定了这个存储。

如果我们尝试创建一个没有匹配存储类的 PersistentVolumeClaim,集群将无法创建相应的 PersistentVolume:

pvc-man.yaml

---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: manual
spec:
  storageClassName: manual
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 100Mi

因为没有名为 manual 的 StorageClass,Kubernetes 无法自动创建这个存储:

root@host01:~# kubectl apply -f /opt/pvc-man.yaml 
persistentvolumeclaim/manual created
root@host01:~# kubectl get pvc
NAME                         STATUS    ... STORAGECLASS   AGE
manual                       Pending   ... manual         6s
...
root@host01:~# kubectl get pv
NAME                                       ...
pvc-0b50e5b4-9889-4c8d-a651-df78fa2bc764   ...
pvc-ad092ba9-cf30-4b7d-af01-ff02a5924db7   ...
pvc-cb671684-1719-4c33-9dd8-bcbbf24523b4   ...

我们的 PersistentVolumeClaim 处于 Pending 状态,并且没有相应的 PersistentVolume。然而,作为集群管理员,我们可以手动创建这个 PersistentVolume:

pv.yaml

---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: manual
spec:
  claimRef:
    name: manual
    namespace: default
  accessModes:
    - ReadWriteOnce
  capacity:
    storage: 100Mi
  csi:
    driver: driver.longhorn.io
    volumeHandle: manual

以这种方式创建 PersistentVolume 时,我们需要指定所需的卷类型。在这种情况下,通过包含 csi 字段,我们将其标识为由 CSI 插件创建的卷。然后,我们指定要使用的 driver 并为 volumeHandle 提供唯一值。在 PersistentVolume 创建后,Kubernetes 会直接调用 Longhorn 存储驱动程序来分配存储。

我们通过以下方式创建 PersistentVolume:

root@host01:~# kubectl apply -f /opt/pv.yaml 
persistentvolume/manual created

因为我们为这个 PersistentVolume 指定了 claimRef,它将自动进入 Bound 状态:

root@host01:~# kubectl get pv manual
NAME     CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   ...
manual   100Mi      RWO            Retain           Bound    ...

这将花费几秒钟,因此 PersistentVolume 可能会短暂地显示为 Available

PersistentVolumeClaim 也会进入 Bound 状态:

root@host01:~# kubectl get pvc manual
NAME     STATUS   VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   AGE
manual   Bound    manual   100Mi      RWO            manual         2m20s

对于管理员来说,手动创建 PersistentVolume 在某些特殊情况下非常有用,尤其是当应用程序需要特定存储时。然而,对于大多数持久存储,最好通过 StorageClass 和 PersistentVolumeClaim 或 StatefulSet 来自动化存储分配。

Deployments

既然我们已经直接创建了 PersistentVolumeClaim 并且有了相关的卷,我们就可以在 Deployment 中使用它。为了演示这一点,我们将展示如何使用持久存储来保存由 NGINX 网络服务器提供的 HTML 文件:

nginx.yaml

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx
        volumeMounts:
       ➊ - name: html
            mountPath: /usr/share/nginx/html
      volumes:
     ➋ - name: html
 persistentVolumeClaim:
            claimName: nginx-storage

将持久存储挂载到容器中需要两个步骤。首先,我们声明一个名为 html ➋ 的 volume,该卷引用我们创建的 PersistentVolumeClaim。这样,存储就可以在 Pod 中使用。接下来,我们声明一个 volumeMount ➊ 来指定这个特定的卷应该出现在容器的文件系统中的位置。将这两个步骤分开的好处是,我们可以在同一个 Pod 中的多个容器中挂载相同的卷,这使得我们能够在使用文件的情况下,即使这些进程来自不同的容器镜像,也能在进程之间共享数据。

这一功能允许一些有趣的用例。例如,假设我们正在构建一个包含一些静态内容的 web 应用程序。我们可能会部署一个 NGINX 网络服务器来提供这些内容,正如我们在这里所做的那样。同时,我们还需要一种更新内容的方法。我们可以通过在 Pod 中添加一个额外的容器,让它定期检查新内容,并更新与 NGINX 容器共享的持久卷。

让我们创建 NGINX Deployment,以便我们能够展示如何从持久存储中提供 HTML 文件。持久存储将会为空,因此最初不会有任何网络内容可供提供。让我们看看 NGINX 在这种情况下会如何表现:

root@host01:~# kubectl apply -f /opt/nginx.yaml 
deployment.apps/nginx created

一旦 NGINX 服务器启动并运行,我们需要获取它的 IP 地址,以便使用 curl 发出 HTTP 请求:

root@host01:~# IP=$(kubectl get po -l app=nginx -o jsonpath='{..podIP}')
root@host01:~# curl -v http://$IP
...
* Connected to 172.31.25.200 (172.31.25.200) port 80 (#0)
> GET / HTTP/1.1
...
< HTTP/1.1 403 Forbidden

在这种情况下,为了获取 IP 地址,我们使用 kubectljsonpath 输出格式,而不是使用 jq 来过滤 JSON 输出;jsonpath 提供了一个非常有用的语法,可以在 JSON 对象中进行搜索并提取单个唯一命名的字段(在这个例子中是 podIP)。我们也可以使用类似于在第八章中做的 jq 过滤器,但 jq 的递归语法更为复杂。

获取到 IP 地址后,我们使用 curl 来联系 NGINX。正如预期的那样,我们没有看到 HTML 响应,因为我们的持久存储是空的。然而,我们知道我们的卷已经正确挂载,因为在这种情况下,我们甚至没有看到默认的 NGINX 欢迎页面。

让我们复制一个 index.html 文件,以便给我们的 NGINX 服务器提供一些内容:

root@host01:~# POD=$(kubectl get po -l app=nginx -o jsonpath='{..metadata.name}')
root@host01:~# kubectl cp /opt/index.html $POD:/usr/share/nginx/html

首先,我们捕获由部署随机生成的 Pod 名称,然后使用 kubectl cp 将一个 HTML 文件复制进去。如果我们再次运行 curl,我们将看到一个更好的响应:

root@host01:~# curl -v http://$IP
...
* Connected to 172.31.239.210 (172.31.239.210) port 80 (#0)
> GET / HTTP/1.1
...
< HTTP/1.1 200 OK
...
<html>
  <head>
    <title>Hello, World</title>
  </head>
  <body>
    <h1>Hello, World!</h1>
  </body>
</html>
...

因为这是持久存储,所以即使我们删除并重新创建部署,这些 HTML 内容仍然可用。

然而,我们仍然有一个重要的问题需要解决。进行部署的主要原因之一是能够扩展到多个 Pod 实例。扩展这个部署是非常有意义的,因为我们可以有多个 Pod 实例来提供相同的 HTML 内容。不幸的是,目前扩展无法正常工作:

root@host01:~# kubectl scale --replicas=3 deployment/nginx
deployment.apps/nginx scaled

部署似乎已经扩展,但如果我们查看 Pod,我们会发现我们并没有真正拥有多个运行中的实例:

root@host01:~# kubectl get pods
NAME                    READY   STATUS              RESTARTS   AGE
...
nginx-db4f4d5d9-7q7rd   0/1     ContainerCreating   0          46s
nginx-db4f4d5d9-gbqxm   0/1     ContainerCreating   0          46s
nginx-db4f4d5d9-vrzr4   1/1     Running             0          10m

这两个新实例卡在了 ContainerCreating 状态。让我们检查其中一个 Pod,看看原因:

root@host01:~# kubectl describe pod/nginx-db4f4d5d9-7q7rd
Name:           nginx-db4f4d5d9-7q7rd
...
Status:         Pending
Events:
  Type     Reason              Age   From                     Message
  ----     ------              ----  ----                     -------
...
  Warning  FailedAttachVolume  110s  attachdetach-controller  Multi-Attach 
    error for volume "pvc-cb671684-1719-4c33-9dd8-bcbbf24523b4" Volume is 
    already used by pod(s) nginx-db4f4d5d9-vrzr4

我们创建的第一个 Pod 已经占用了该卷,其他 Pod 无法附加到它,因此它们卡在了 Pending 状态。更糟糕的是,这不仅阻止了扩展,还阻止了升级或对部署进行其他配置更改。如果我们更新部署配置,Kubernetes 会尝试在关闭任何旧的 Pod 之前使用新配置启动一个 Pod。新的 Pod 无法附加到卷,因此无法启动,这样旧的 Pod 就永远不会被清理,配置更改也永远不会生效。

我们可以通过几种方式强制更新 Pod。首先,每次我们做出更改时,可以手动删除并重新创建部署。其次,我们可以配置 Kubernetes 使用 Recreate 更新策略,在删除旧的 Pod 之前先删除它。我们将在第二十章中更详细地探讨更新策略选项。目前值得注意的是,这仍然无法让我们扩展部署。

如果我们想修复这个问题,以便能够扩展部署,我们需要允许多个 Pod 同时附加到持久卷。我们可以通过更改持久卷的访问模式来实现这一点。

访问模式

Kubernetes 拒绝将多个 Pod 附加到同一个持久卷,因为我们将 PersistentVolumeClaim 配置为 ReadWriteOnce 的访问模式。另一种访问模式 ReadWriteMany 将允许所有 NGINX 服务器 Pod 同时挂载存储。只有一些存储驱动程序支持 ReadWriteMany 访问模式,因为它要求能够管理文件的同时更改,包括动态地将更改传递给集群中的所有节点。

Longhorn 确实支持 ReadWriteMany,因此创建一个具有 ReadWriteMany 访问模式的 PersistentVolumeClaim 是一个简单的变更:

pvc-rwx.yaml

---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: storage
spec:
  storageClassName: longhorn
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 100Mi

不幸的是,我们无法修改现有的 PersistentVolumeClaim 来更改访问模式。并且在存储仍然被我们的 Deployment 使用时,无法删除 PersistentVolumeClaim。所以我们需要清理所有内容,然后重新部署:

root@host01:~# kubectl delete deploy/nginx pvc/storage
deployment.apps "nginx" deleted
persistentvolumeclaim "storage" deleted
root@host01:~# kubectl apply -f /opt/pvc-rwx.yaml 
persistentvolumeclaim/storage created
root@host01:~# kubectl apply -f /opt/nginx.yaml 
deployment.apps/nginx created

我们指定 deploy/nginxpvc/storage 作为要删除的资源。这种标识资源的方式允许我们在同一个命令中操作两个资源。

大约一分钟后,新的 NGINX Pod 将开始运行:

root@host01:~# kubectl get pods
NAME                    READY   STATUS      RESTARTS   AGE
...
nginx-db4f4d5d9-6thzs   1/1     Running     0          44s

到这个时候,我们需要再次复制 HTML 内容,因为删除 PersistentVolumeClaim 会删除之前的存储:

root@host01:~# POD=$(kubectl get po -l app=nginx -o jsonpath='{..metadata.name}')
root@host01:~# kubectl cp /opt/index.html $POD:/usr/share/nginx/html
... no output ...

这一次,当我们扩展 NGINX 部署时,额外的两个 Pod 能够挂载存储并开始运行:

root@host01:~# kubectl scale --replicas=3 deploy nginx
deployment.apps/nginx scaled
root@host01:~# kubectl get po
NAME                    READY   STATUS      RESTARTS   AGE
...
nginx-db4f4d5d9-2j629   1/1     Running     0          23s
nginx-db4f4d5d9-6thzs   1/1     Running     0          5m19s
nginx-db4f4d5d9-7r5qj   1/1     Running     0          23s

所有三个 NGINX Pod 都在提供相同的内容,如果我们获取其中一个新 Pod 的 IP 地址并连接到它,就能看到这一点:

root@host01:~# IP=$(kubectl get po nginx-db4f4d5d9-2j629 -o jsonpath='{..podIP}')
root@host01:~# curl http://$IP
<html>
  <head>
    <title>Hello, World</title>
  </head>
  <body>
    <h1>Hello, World!</h1>
  </body>
</html>

此时,我们可以使用任何一个 NGINX Pod 来更新 HTML 内容,所有 Pod 都会提供新的内容。我们甚至可以使用一个单独的 CronJob,并配合一个动态更新内容的应用组件,NGINX 会很高兴地提供任何当前的文件。

最后的想法

持久存储是构建一个完全功能的应用程序的基本需求。在集群管理员配置了一个或多个存储类之后,应用程序开发人员可以轻松地将持久存储作为其应用部署的一部分动态请求。在大多数情况下,最好的方法是使用 StatefulSet,因为 Kubernetes 会自动为每个 Pod 分配独立的存储,并在故障转移和升级过程中保持 Pod 与存储之间的一对一关系。

与此同时,还有其他存储使用场景,比如多个 Pod 访问相同的存储。我们可以通过直接创建一个 PersistentVolumeClaim 资源,然后在像 Deployment 或 Job 这样的控制器中声明它作为一个卷,轻松处理这些场景。

虽然持久存储是让文件内容对容器可用的有效方式,但 Kubernetes 还有其他强大的资源类型,可以存储配置数据并将其传递给容器,作为环境变量或文件内容。在下一章中,我们将探索如何管理应用程序配置和机密。

第十六章:配置与机密

image

任何高质量的应用程序都设计为可以在运行时注入关键配置项,而不是将其嵌入源代码中。当我们将应用程序组件迁移到容器时,我们需要一种方法来告诉容器运行时需要注入哪些配置信息,以确保我们的应用程序组件按预期行为运行。

Kubernetes 提供了两种主要的资源类型用于注入这些配置信息:ConfigMap 和 Secret。这两种资源在功能上非常相似,但有些许不同的使用场景。

注入配置

当我们在第一部分中查看容器运行时时,我们看到可以将环境变量传递给我们的容器。当然,由于 Kubernetes 为我们管理容器运行时,我们首先需要将这些信息传递给 Kubernetes,然后 Kubernetes 再将其传递给容器运行时。

注意

本书的示例代码库位于 github.com/book-of-kubernetes/examples有关设置的详细信息,请参阅第 xx 页中的“运行示例”。

对于简单的配置注入,我们可以直接从 Pod 规范中提供环境变量。当我们在第十章创建 PostgreSQL 服务器时,就看到了一个类似的 Pod 示例。下面是一个 PostgreSQL 部署示例,其中的 Pod 规范包含了类似的配置:

pgsql.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: postgres
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
      - name: postgres
        image: postgres
        env:
        - name: POSTGRES_PASSWORD
          value: "supersecret"

当我们直接在 Deployment 中提供环境变量时,这些环境变量会直接存储在 YAML 文件中,以及该 Deployment 的集群配置中。以这种方式嵌入环境变量有两个重要问题。首先,我们降低了灵活性,因为我们无法在不更改 Deployment YAML 文件的情况下指定环境变量的新值。其次,密码以明文形式直接显示在 Deployment YAML 文件中。YAML 文件通常会被检查到源代码管理中,因此我们很难充分保护密码。

GITOPS

定义 Kubernetes 资源的 YAML 文件之所以经常被提交到源代码管理,是因为这是管理应用程序部署的最佳方式。GitOps 是一种最佳实践,通过这种方式,所有配置都保存在 Git 仓库中。这包括集群配置、额外的基础设施组件,如负载均衡器、入口控制器和存储插件,以及构建、组合和部署应用程序所需的所有信息。GitOps 提供了集群配置变更的日志,避免了随着时间推移可能发生的配置漂移,并确保开发、测试和生产环境之间的一致性。不仅如此,像 FluxCD 和 ArgoCD 这样的 GitOps 工具可以用来监控 Git 仓库的变化,并自动拉取最新配置来更新集群。

首先,我们来看一下如何将配置移出 Deployment;然后我们再考虑如何最好地保护密码。

配置外部化

将配置嵌入到 Deployment 中会使资源定义变得不那么可重用。例如,如果我们想为应用程序的测试版本和生产版本部署 PostgreSQL 服务器,重用相同的 Deployment 可以避免重复,并防止两个版本之间的配置漂移。然而,出于安全考虑,我们不希望在这两个环境中使用相同的密码。

更好的做法是通过将配置存储在单独的资源中并从 Deployment 中引用它来实现配置外部化。为此,Kubernetes 提供了 ConfigMap 资源。ConfigMap 指定了一组键值对,可以在指定 Pod 时引用。例如,我们可以这样定义 PostgreSQL 配置:

pgsql-cm.yaml

---
kind: ConfigMap
apiVersion: v1
metadata:
  name: pgsql
data:
  POSTGRES_PASSWORD: "supersecret"

通过将这些配置信息存储在 ConfigMap 中,它不再是 Deployment YAML 文件或 Deployment 集群配置的一部分。

在我们定义好 ConfigMap 后,可以在我们的 Deployment 中引用它,如 示例 16-1 中所示。

pgsql-ext-cfg.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: postgres
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
      - name: postgres
        image: postgres
        envFrom:
        - configMapRef:
            name: pgsql

示例 16-1:带 ConfigMap 的 PostgreSQL

env 字段的位置,我们有一个 envFrom 字段,用于指定一个或多个 ConfigMap,作为容器的环境变量。ConfigMap 中的所有键值对将成为环境变量。

这与直接在 Deployment 中指定一个或多个环境变量具有相同的效果,但我们的 Deployment 规范现在是可重用的。Deployment 将在其自己的 Namespace 中查找已识别的 ConfigMap,因此我们可以在不同的 Namespaces 中从相同的规范创建多个 Deployments,每个都可以有不同的配置。

这种通过使用命名空间隔离来防止命名冲突的方法,结合我们在第十一章中看到的命名空间范围的安全控制和我们在第十四章中看到的命名空间范围的配额,使得单个集群可以被多个不同的团队用于不同的目的,这个概念被称为多租户

让我们创建这个部署并查看 Kubernetes 如何注入配置。首先,让我们创建实际的部署:

root@host01:~# kubectl apply -f /opt/pgsql-ext-cfg.yaml 
deployment.apps/postgres created

这个命令成功完成,因为部署已经在集群中创建,但 Kubernetes 无法启动任何 Pod,因为缺少 ConfigMap:

root@host01:~# kubectl get pods
NAME                       READY  STATUS                      RESTARTS  AGE
postgres-6bf595fcbc-s8dqz  0/1    CreateContainerConfigError  0         53s

如果我们现在创建 ConfigMap,我们会看到 Pod 被创建:

root@host01:~# kubectl apply -f /opt/pgsql-cm.yaml 
configmap/pgsql created
root@host01:~# kubectl get pods
NAME                        READY   STATUS    RESTARTS   AGE
postgres-6bf595fcbc-s8dqz   1/1     Running   0          2m41s

Kubernetes 可能需要一分钟左右的时间来确定 ConfigMap 是否可用并启动 Pod。一旦 Pod 启动,我们可以验证环境变量是否根据 ConfigMap 中的数据被注入:

root@host01:~# kubectl exec -ti postgres-6bf595fcbc-s8dqz -- /bin/sh -c env
...
POSTGRES_PASSWORD=supersecret
...

env 命令会打印出与进程关联的所有环境变量。因为 Kubernetes 向我们的 /bin/sh 进程提供了与主 PostgreSQL 进程相同的环境变量,所以我们知道环境变量已经按预期设置。然而,值得注意的是,即使我们可以随时更改 ConfigMap,这样做也不会导致部署更新其 Pods;应用程序不会自动获取任何环境变量的变化。相反,我们需要对部署进行一些配置更改,促使它创建新的 Pods。

尽管配置已经被外部化,但我们仍然没有保护它。接下来我们来做这个操作。

保护机密

在保护机密数据时,思考保护措施的性质非常重要。例如,我们可能需要保护我们的应用程序用来连接数据库的身份验证信息。然而,鉴于应用程序本身需要这些信息才能建立连接,任何能够检查应用程序内部细节的人都会能够提取这些凭证。

正如我们在第十一章中看到的,Kubernetes 对每种资源类型提供细粒度的访问控制。为了保护机密数据,Kubernetes 提供了一个单独的资源类型,Secret。通过这种方式,只有那些需要访问的用户才能访问机密数据,这一原则被称为最小权限

Secret 资源类型的另一个优点是,它对所有数据使用 base64 编码,并在数据提供给 Pod 时自动解码,这简化了二进制数据的存储。

加密机密数据

默认情况下,存储在 Secret 中的数据是 base64 编码的,但没有加密。可以加密密钥数据,且在生产集群中这样做是一个良好的实践,但请记住,数据必须解密才能提供给 Pod。因此,任何能够控制某个命名空间中 Pod 存在的人都能访问 Secret 数据,任何能够访问底层容器运行时的集群管理员也能访问。这一点即便是 Secret 数据在存储时进行了加密也同样成立。适当的访问控制对于保持集群的安全至关重要。

Secret 的定义几乎与 ConfigMap 的定义完全相同:

pgsql-secret.yaml

---
kind: Secret
apiVersion: v1
metadata:
  name: pgsql
stringData:
  POSTGRES_PASSWORD: "supersecret"

唯一明显的区别是 Secret 的资源类型,而不是 ConfigMap。然而,也有一个微妙的差别。当我们定义这个 Secret 时,我们将键值对放置在一个名为 stringData 的字段中,而不是仅仅使用 data。这告诉 Kubernetes 我们提供的是未编码的字符串。当 Kubernetes 创建 Secret 时,它会为我们编码这些字符串:

root@host01:~# kubectl apply -f /opt/pgsql-secret.yaml 
secret/pgsql created
root@host01:~# kubectl get secret pgsql -o json | jq .data
{
  "POSTGRES_PASSWORD": "c3VwZXJzZWNyZXQ="
}

即使我们使用字段 stringData 并提供了未编码的字符串来指定数据,实际的 Secret 仍然使用字段 data 并使用 base64 编码存储值。我们也可以自己进行 base64 编码。在这种情况下,我们直接将值放入 data 字段:

pgsql-secret-2.yaml

---
kind: Secret
apiVersion: v1
metadata:
  name: pgsql
data:
  POSTGRES_PASSWORD: c3VwZXJzZWNyZXQ=

这种方法对于定义 Secret 的二进制内容是必要的,以便我们能够将该二进制内容作为 YAML 资源定义的一部分提供。

我们在 Deployment 定义中使用 Secret 的方式与使用 ConfigMap 完全相同:

pgsql-ext-sec.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: postgres
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
      - name: postgres
        image: postgres
        envFrom:
        - secretRef:
            name: pgsql

唯一的变化是用 secretRef 代替了 configMapRef

为了测试这个,我们可以应用这个新的 Deployment 配置:

root@host01:~# kubectl apply -f /opt/pgsql-ext-sec.yaml 
deployment.apps/postgres configured

从我们的 Pod 的角度来看,行为完全相同。Kubernetes 处理 base64 解码,使解码后的值对我们的 Pod 可见:

root@host01:~# kubectl get pods
NAME                        READY   STATUS        RESTARTS   AGE
postgres-6bf595fcbc-s8dqz   1/1     Terminating   0          12m
postgres-794ff85bbf-xzz49   1/1     Running       0          26s
root@host01:~# kubectl exec -ti postgres-794ff85bbf-xzz49 -- /bin/sh -c env
...
POSTGRES_PASSWORD=supersecret
...

如之前所示,我们使用 env 命令来验证 POSTGRES_PASSWORD 环境变量是否按预期设置。无论我们是直接指定环境变量,还是使用 ConfigMap 或 Secret,Pod 都会看到相同的行为。

在继续之前,让我们删除这个 Deployment:

root@host01:~# kubectl delete deploy postgres
deployment.apps "postgres" deleted

使用 ConfigMap 和 Secret,我们可以将应用程序的环境变量配置外部化,从而使我们的 Deployment 规范可重用,并便于对密钥数据进行精细化访问控制。

注入文件

当然,环境变量并不是我们常见的唯一配置应用程序的方式。我们还需要一种方式来提供配置文件。我们可以使用我们已经看到的相同的 ConfigMap 和 Secret 资源来实现。

以这种方式注入的任何文件都会覆盖容器镜像中存在的文件,这意味着我们可以为容器镜像提供一个合理的默认配置,然后通过每次运行容器来覆盖该配置。这大大简化了容器镜像的重用。

能够在 ConfigMap 中指定文件内容,然后将其挂载到容器中,立即对配置文件非常有用,但我们也可以利用它更新我们在第十五章中展示的 NGINX web 服务器示例。正如我们将看到的,通过这个版本,我们可以仅使用 Kubernetes 资源的 YAML 文件来声明 HTML 内容,而无需通过控制台命令将内容复制到 PersistentVolume 中。

第一步是定义一个包含我们想要提供的 HTML 内容的 ConfigMap:

nginx-cm.yaml

---
kind: ConfigMap
apiVersion: v1
metadata:
  name: nginx
data:
  index.html: |
    <html>
      <head>
        <title>Hello, World</title>
      </head>
      <body>
        <h1>Hello, World from a ConfigMap!</h1>
      </body>
    </html>

键值对中的关键部分用于指定所需的文件名,在这种情况下是index.html。为了便于阅读,我们使用管道字符(|)来开始 YAML 多行字符串。只要后续行保持缩进,或者直到 YAML 文件结束,这个字符串就会继续。我们可以通过添加更多的键来定义多个文件。

在我们在清单 16-1 中看到的部署中,我们将 ConfigMap 指定为环境变量的来源。在这里,我们将它指定为卷挂载的来源:

nginx-deploy.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx
        volumeMounts:
        - name: nginx-files
          mountPath: /usr/share/nginx/html
      volumes:
        - name: nginx-files
          configMap:
            name: nginx

这个卷定义看起来与我们在第十五章中看到的类似。和之前一样,卷规范分为两部分。volume字段指定了卷的来源,在这种情况下是 ConfigMap。volumeMounts让我们指定容器中文件应该挂载到的路径。除了使我们能够在 Pod 中的多个容器之间使用相同的卷外,这还意味着我们在挂载持久卷和将配置作为文件挂载到容器文件系统时可以共享相同的语法。

我们先创建 ConfigMap,然后启动这个部署:

root@host01:~# kubectl apply -f /opt/nginx-cm.yaml 
configmap/nginx created
root@host01:~# kubectl apply -f /opt/nginx-deploy.yaml
deployment.apps/nginx created

在 Pod 运行后,我们可以看到文件内容符合预期,NGINX 正在服务我们的 HTML 文件:

root@host01:~# IP=$(kubectl get po -l app=nginx -o jsonpath='{..podIP}')
root@host01:~# curl http://$IP
<html>
  <head>
    <title>Hello, World</title>
  </head>
  <body>
    <h1>Hello, World from a ConfigMap!</h1>
  </body>
</html>

输出看起来与我们在第十五章中看到的相似,当时我们将 HTML 内容提供为 PersistentVolume,但我们能够避免附加 PersistentVolume 并将内容复制到其中的工作。实际上,这两种方法都有其价值,因为维护一个包含大量数据的 ConfigMap 会显得笨重。

为了让 ConfigMap 的内容作为文件出现在目录中,Kubernetes 会将 ConfigMap 的内容写到主机文件系统中,然后将该目录从主机挂载到容器中。这意味着特定目录会作为mount命令在容器内输出的一部分显示:

root@host01:~# kubectl exec -ti nginx-58bc54b5cd-4lbkq -- /bin/mount
...
/dev/sda1 on /usr/share/nginx/html type ext4 (ro,relatime)
...

mount命令报告显示,目录/usr/share/nginx/html是一个来自主机主硬盘/dev/sda1的单独挂载路径。

我们已经完成了 NGINX 的部署,接下来删除它:

root@host01:~# kubectl delete deploy nginx
deployment.apps "nginx" deleted

接下来,让我们看看 ConfigMap 和 Secret 信息在典型的 Kubernetes 集群中是如何存储的,这样我们就可以看到kubelet从哪里获取这些内容。

集群配置仓库

虽然可以选择不同的配置仓库来运行 Kubernetes 集群,但大多数 Kubernetes 集群使用etcd作为所有集群配置数据的后端存储。这不仅包括 ConfigMap 和 Secret 存储,还包括所有其他集群资源和当前集群状态。Kubernetes 还使用etcd来在多个 API 服务器的高可用配置下选举领导者。

尽管etcd通常是稳定和可靠的,但节点故障可能导致etcd集群无法重新建立并选举出领导者。我们展示etcd的目的不仅仅是为了查看配置数据如何存储,还旨在提供一些有价值的背景信息,帮助管理员在需要调试时理解这一重要的集群组件。

对于我们所有的示例集群,etcd与 API 服务器安装在同一节点上,这在小型集群中是很常见的。在大型集群中,将etcd运行在独立的节点上,使其可以与 Kubernetes 控制平面分开扩展,这也是常见的做法。

为了探索etcd后端存储的内容,我们将使用etcdctl,这是一个为控制和排查etcd问题而设计的命令行客户端。

使用etcdctl

我们需要告诉etcdctl我们的etcd服务器实例位于何处,以及如何进行认证。为了认证,我们将使用与 API 服务器相同的客户端证书。

为了方便起见,我们可以设置etcdctl将读取的环境变量,这样我们就不必在每个命令中通过命令行传递这些值。

这里是我们需要的环境变量:

etcd-env

export ETCDCTL_API=3
export ETCDCTL_CACERT=/etc/kubernetes/pki/etcd/ca.crt
export ETCDCTL_CERT=/etc/kubernetes/pki/apiserver-etcd-client.crt
export ETCDCTL_KEY=/etc/kubernetes/pki/apiserver-etcd-client.key
export ETCDCTL_ENDPOINTS=https://192.168.61.11:2379

这些变量配置etcdctl如下:

ETCDCTL_API 使用etcd API 的版本 3。对于近期的etcd版本,仅支持版本 3。

ETCDCTL_CACERT 使用提供的证书授权验证etcd主机。

ETCDCTL_CERT 使用此证书认证到etcd

ETCDCTL_KEY 使用这个私钥认证到etcd

ETCDCTL_ENDPOINTS 通过此 URL 连接到etcd。尽管etcd运行在所有三个节点上,我们只需要与其中一个节点进行通信。

在我们的示例中,这些环境变量方便地存储在/opt中的一个脚本中,以便我们加载它们并用于后续命令:

root@host01:~# source /opt/etcd-env

现在我们可以使用etcdctl命令来检查集群及其存储的配置数据。我们先从仅列出集群成员开始:

root@host01:~# etcdctl member list
45a2b6125030fdde, started, host02, https://192.168.61.12:2380, https://192.168.61.12:2379
91007aab9448ce27, started, host03, https://192.168.61.13:2380, https://192.168.61.13:2379
bf7b9991d532ba78, started, host01, https://192.168.61.11:2380, https://192.168.61.11:2379

如预期的那样,每个控制平面节点都有一个etcd实例。对于高可用配置,我们需要至少运行三个实例,并且需要大多数实例运行才能保证集群的健康。这个etcdctl命令是判断集群是否有故障节点的第一步。

只要集群保持健康,我们就可以存储和检索数据。在etcd中,信息是以键值对的形式存储的。键是作为路径在层次结构中指定的。我们可以列出有内容的路径:

root@host01:~# etcdctl get / --prefix --keys-only
...
/registry/configmaps/default/nginx
/registry/configmaps/default/pgsql
...
/registry/secrets/default/pgsql
...

--prefix 标志告诉 etcdctl 获取所有以 / 开头的键,而 --keys-only 确保我们只打印出键,防止数据过载。然而,仍然会返回大量信息,包括我们在本书中描述的所有 Kubernetes 资源类型。还包括我们刚刚创建的 ConfigMaps 和 Secrets。

解密 etcd 中的数据

我们通常可以依赖 Kubernetes 将正确的配置信息存储在 etcd 中,并且可以依赖 kubectl 查看当前的集群配置。然而,了解底层数据存储的工作原理是很有用的,以防我们需要在集群故障或异常状态时检查配置。

为了节省存储空间和带宽,etcd 和 Kubernetes 都使用 protobuf 库,这是一个语言中立的二进制数据格式。由于我们正在使用 etcdctletcd 获取数据,我们可以要求它以 JSON 格式返回数据;然而,JSON 数据将包含一个嵌入的 protobuf 结构,其中包含 Kubernetes 的数据,因此我们还需要解码它。

让我们首先检查 etcd 中 Kubernetes Secret 的 JSON 格式。我们将通过 jq 进行格式化输出:

root@host01:~# etcdctl -w json get /registry/secrets/default/pgsql | jq
{
 "header": {
...
  },
  "kvs": [
    {
      "key": "L3JlZ2lzdHJ5L3NlY3JldHMvZGVmYXVsdC9wZ3NxbA==",
      "create_revision": 14585,
      "mod_revision": 14585,
      "version": 1,
      "value": "azhzAAoMCgJ2MRIGU2..."
    }
  ],
  "count": 1
}

kvs 字段包含 Kubernetes 为此 Secret 存储的键值对。该键的值是一个简单的 base64 编码字符串:

root@host01:~# echo $(etcdctl -w json get /registry/secrets/default/pgsql \
| jq -r '.kvs[0].key' | base64 -d)
/registry/secrets/default/pgsql

我们使用 jq 提取键的值,并以原始格式(无引号)返回,然后使用 base64 解码该字符串。

当然,这个键值对中有趣的部分是值,因为它包含了实际的 Kubernetes Secret。尽管该值也是 base64 编码的,但我们需要做更多的解开处理才能访问其信息。

在解码 base64 值后,我们将得到一个 protobuf 消息。然而,它有一个 Kubernetes 使用的魔术前缀,以允许未来存储格式的更改。如果我们查看解码值的前几个字节,就可以看到该前缀:

root@host01:~# etcdctl -w json get /registry/secrets/default/pgsql \
| jq -r '.kvs[0].value' | base64 -d | head --bytes=10 | xxd
00000000: 6b38 7300 0a0c 0a02 7631                 k8s.....v1

我们使用 head 获取解码值的前 10 个字节,然后使用 xxd 查看十六进制转储。前几个字节是 k8s,后跟一个 ASCII 空字符。从第 5 字节开始的其余数据是实际的 protobuf 消息。

让我们再运行一个命令,使用 protoc 工具实际解码 protobuf 消息:

root@host01:~# etcdctl -w json get /registry/secrets/default/pgsql \
| jq -r '.kvs[0].value' | base64 -d | tail --bytes=+5 | protoc --decode_raw
1 {
  1: "v1"
  2: "Secret"
}
2 {
  1 {
    1: "pgsql"
    2: ""
    3: "default"
    4: ""
...
  }
  2 {
    1: "POSTGRES_PASSWORD"
    2: "supersecret"
  }
  3: "Opaque"
}
...

protoc 工具主要用于生成源代码来读取和写入 protobuf 消息,但它在消息解码方面也非常有用。正如我们所看到的,在 protobuf 消息中包含了 Kubernetes 为此 Secret 存储的所有数据,包括资源版本和类型、资源名称和命名空间,以及数据。这说明,如前所述,访问 Kubernetes 运行的主机就可以访问集群中的所有密钥数据。即使我们将 Kubernetes 配置为在存储到 etcd 之前加密数据,密钥本身也需要以未加密的形式存储在 etcd 中,以便 API 服务器可以使用它们。

最后的思考

通过为 Pods 提供环境变量或文件的能力,ConfigMaps 和 Secrets 使我们能够将容器的配置外部化,这使得我们可以在各种应用程序中重用 Kubernetes 资源定义,例如 Deployments 和容器镜像。

同时,我们需要意识到 Kubernetes 是如何存储这些配置数据的,以及它是如何将这些数据提供给容器的。任何拥有正确角色的人都可以使用kubectl访问配置数据;任何可以访问运行容器的主机的人都可以从容器运行时访问这些数据;任何拥有正确认证信息的人都可以直接从etcd中访问它。对于生产集群,确保这些机制的安全性至关重要。

到目前为止,我们已经看到 Kubernetes 如何在 etcd 中存储内建的集群资源数据,但 Kubernetes 也可以存储我们可能选择声明的任何自定义资源数据。在下一章中,我们将探讨自定义资源定义如何使我们能够通过运维工具在 Kubernetes 集群中添加新的行为。

第十七章:自定义资源和操作员

image

我们已经看到,Kubernetes 集群中使用了许多不同的资源类型来运行容器工作负载、扩展它们、配置它们、路由网络流量并为它们提供存储。然而,Kubernetes 集群的一个最强大的功能是能够定义自定义资源类型,并将这些类型与我们已经看到的所有内置资源类型集成到集群中。

自定义资源定义使我们能够定义任何新的资源类型,并让集群跟踪相应的资源。我们可以利用这一能力为集群添加复杂的新行为,例如自动化部署一个高可用的数据库引擎,同时充分利用集群内置资源类型的所有现有功能以及集群控制平面的资源和状态管理。

在本章中,我们将看到自定义资源定义如何工作,以及我们如何利用它们部署 Kubernetes 操作员,从而扩展我们的集群以实现我们所需的任何额外行为。

自定义资源

在第六章中,我们讨论了 Kubernetes API 服务器如何提供声明式 API,其中主要操作是创建、读取、更新和删除集群中的资源。声明式 API 具有弹性的优势,因为集群可以跟踪资源的期望状态,并努力确保集群保持在该期望状态。然而,声明式 API 在扩展性方面也具有显著优势。API 服务器提供的操作足够通用,以至于将其扩展到任何类型的资源都很容易。

我们已经看到 Kubernetes 如何利用这种扩展性逐步更新其 API。Kubernetes 不仅能够随着时间的推移支持资源的新版本,还能够将具有新功能的全新资源添加到集群中,同时通过旧资源保持向后兼容性。我们在第七章中讨论了版本 2 的 HorizontalPodAutoscaler 的新功能,以及 Deployment 如何取代 ReplicationController。

我们确实能在使用CustomResourceDefinitions时看到这种扩展性的强大。CustomResourceDefinition,或简称 CRD,使我们能够动态地向集群添加任何新的资源类型。我们只需向 API 服务器提供新资源类型的名称和用于验证的规格,API 服务器就会立即允许我们创建、读取、更新和删除该新类型的资源。

CRD 非常有用并且被广泛使用。例如,已经部署到我们集群中的基础设施组件包括 CRD。

注意

本书的示例仓库位于 github.com/book-of-kubernetes/examples有关设置的详细信息,请参见第 xx 页中的“运行示例”部分。

让我们来看看已经在我们的集群中注册的 CRD:

root@host01:~# kubectl get crds
NAME                                                  CREATED AT
...
clusterinformations.crd.projectcalico.org             ...
...
installations.operator.tigera.io                      ...
...
volumes.longhorn.io                                   ...

为了避免命名冲突,CRD 的名称必须包含一个组名,通常基于域名来确保唯一性。这个组名也用于为 API 服务器提供的 REST API 建立到该资源的路径。在这个例子中,我们看到 CRD 属于 crd.projectcalico.org 组和 operator.tigera.io 组,这两个组都由 Calico 使用。我们还看到一个属于 longhorn.io 组的 CRD,这个 CRD 是 Longhorn 使用的。

这些 CRD 允许 Calico 和 Longhorn 使用 Kubernetes API 将配置信息和状态信息记录在 etcd 中。CRD 还简化了自定义配置。例如,作为将 Calico 部署到集群的一部分,自动化创建了一个安装资源,对应于 installations.operator.tigera.io CRD:

custom-resources.yaml

---
apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
  name: default
spec:
  calicoNetwork:
    ipPools:
    - blockSize: 26
      cidr: 172.31.0.0/16
...

这个配置是我们看到 Pods 获得 172.31.0.0/16 网络块中的 IP 地址的原因。这个 YAML 文件被自动放置在 /etc/kubernetes/components 中,并作为 Calico 安装的一部分自动应用到集群。当部署时,Calico 会查询 API 服务器,查找此安装资源的实例,并相应地配置网络。

创建 CRD

让我们通过创建自己的 CRD 来进一步探索 CRD。我们将使用列表 17-1 中提供的定义。

crd.yaml

 ---
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
➊ name: samples.bookofkubernetes.com
 spec:
➋ group: bookofkubernetes.com
   versions:
  ➌ - name: v1
       served: true
       storage: true
       schema:
         openAPIV3Schema:
           type: object
           properties:
             spec:
               type: object
               properties:
                 value:
                   type: integer
➍ scope: Namespaced
 names:
➎ plural: samples
➏ singular: sample
➐ kind: Sample
     shortNames:
    ➑ - sam

列表 17-1:示例 CRD

这个定义包含多个重要部分。首先,定义了几种类型的名称。元数据 name 字段 ➊ 必须将资源的复数名称 ➎ 和组 ➋ 组合在一起。这些命名组件对于通过 API 进行访问也至关重要。

命名还包括 kind ➐,它在 YAML 文件中使用。这意味着当我们基于这个 CRD 创建特定资源时,我们将使用 kind: Sample 来标识它们。最后,我们需要定义如何在命令行中引用这个 CRD 的实例。这包括资源的完整名称,这在 singular ➏ 字段中指定,以及任何我们希望命令行识别的 shortNames ➑。

现在我们已经根据这个 CRD 为实例提供了所有必要的名称,接下来我们可以讨论 CRD 是如何被跟踪以及它包含了哪些数据。scope ➍ 字段告诉 Kubernetes 这个资源应该在 Namespace 级别进行跟踪,还是资源是集群范围的。命名空间资源会收到包含其所在命名空间的 API 路径,可以通过角色(Roles)和角色绑定(RoleBindings)在每个命名空间的基础上控制对命名空间资源的访问和修改权限,正如我们在第十一章中所看到的。

第三,versions 部分允许我们定义在基于此 CRD 创建资源时有效的实际内容。为了支持版本更新,可以有多个版本。每个版本都有一个 schema,声明哪些字段是有效的。在这个例子中,我们定义了一个 spec 字段,其中包含一个名为 value 的字段,并且我们声明这个字段的类型为整数。

这里有很多必需的配置,让我们回顾一下结果。这个 CRD 使我们能够告诉 Kubernetes 集群跟踪一种全新的资源类型——Sample。这个资源的每个实例(每个 Sample)都将属于一个命名空间,并且在 value 字段中包含一个整数。

让我们在集群中创建这个 CRD:

root@host01:~# kubectl apply -f /opt/crd.yaml
customresourcedefinition...k8s.io/samples.bookofkubernetes.com created

现在我们可以创建此类型的对象,并从集群中获取它们。以下是使用我们定义的 CRD 创建新示例的 YAML 定义示例:

sample.yaml

---
apiVersion: bookofkubernetes.com/v1
kind: Sample
metadata:
  namespace: default
  name: somedata
spec:
  value: 123

我们将 apiVersionkind 与我们的 CRD 匹配,并确保 spec 与 schema 对应。这意味着我们必须提供一个名为 value 的字段,并且该字段的值必须是整数。

我们现在可以像创建其他资源一样,在集群中创建这个资源:

root@host01:~# kubectl apply -f /opt/somedata.yaml 
sample.bookofkubernetes.com/somedata created

现在有一个名为 somedata 的示例,它是 default 命名空间的一部分。

当我们在 Listing 17-1 中定义 CRD 时,我们为 Sample 资源指定了复数、单数和简短名称。我们可以使用这些名称中的任何一个来检索新资源:

root@host01:~# kubectl get samples
NAME       AGE
somedata   56s
root@host01:~# kubectl get sample
NAME       AGE
somedata   59s
root@host01:~# kubectl get sam
NAME       AGE
somedata   62s

通过仅声明我们的 CRD,我们就扩展了 Kubernetes 集群的行为,使其能够理解什么是 samples,并且我们可以在 API 中以及命令行工具中使用它。

这意味着 kubectl describe 也适用于 Samples。我们可以看到 Kubernetes 跟踪了与我们的新资源相关的其他数据,不仅仅是我们指定的数据:

root@host01:~# kubectl describe sample somedata
Name:         somedata
Namespace:    default
...
API Version:  bookofkubernetes.com/v1
Kind:         Sample
Metadata:
  Creation Timestamp:  ...
...
  Resource Version:  9386
  UID:               37cc58db-179f-40e6-a9bf-fbf6540aa689
Spec:
  Value:  123
Events:   <none>

这些附加数据,包括时间戳和资源版本控制,对于我们想要使用 CRD 中的数据是必不可少的。为了有效地使用我们的新资源,我们需要一个持续监控资源新实例或更新实例的软件组件,并根据情况采取相应的行动。我们将使用一个常规的 Kubernetes Deployment 来运行此组件,并与 Kubernetes API 服务器进行交互。

观察 CRD

对于核心 Kubernetes 资源,控制平面组件通过与 API 服务器通信来采取正确的操作,当资源被创建、更新或删除时。例如,控制器管理器包括一个组件,监视服务和 Pod 的变化,使其能够更新每个服务的端点列表。然后,每个节点上的 kube-proxy 实例根据这些端点进行必要的网络路由更改,将流量发送到 Pods。

对于 CRD,API 服务器仅跟踪资源的创建、更新和删除。其他软件负责监视资源实例并采取正确的行动。为了方便监视资源,API 服务器提供了 watch 操作,通过 长轮询 保持连接打开,并在事件发生时持续推送事件。由于长轮询连接可能会随时中断,Kubernetes 跟踪的时间戳和资源版本数据将使我们能够在重新连接时检测到我们已经处理的集群变化。

我们可以直接从 curl 命令或 HTTP 客户端中使用 API 服务器的 watch 功能,但使用 Kubernetes 客户端库要容易得多。对于这个示例,我们将使用 Python 客户端库来演示如何监视我们的自定义资源。以下是我们将使用的 Python 脚本:

watch.py

   #!/usr/bin/env python3
   from kubernetes import client, config, watch
   import json, os, sys

   try:
  ➊ config.load_incluster_config()
   except:
     print("In cluster config failed, falling back to file", file=sys.stderr)
  ➋ config.load_kube_config()

➌ group = os.environ.get('WATCH_GROUP', 'bookofkubernetes.com')
   version = os.environ.get('WATCH_VERSION', 'v1')
   namespace = os.environ.get('WATCH_NAMESPACE', 'default')
   resource = os.environ.get('WATCH_RESOURCE', 'samples')
   api = client.CustomObjectsApi()

   w = watch.Watch()
➍ for event in w.stream(api.list_namespaced_custom_object,
          group=group, version=version, namespace=namespace, plural=resource):
➎ json.dump(event, sys.stdout, indent=2)
    sys.stdout.flush()

要连接到 API 服务器,我们需要加载集群配置。这包括 API 服务器的位置以及我们在第十一章中看到的认证信息。如果我们在 Kubernetes Pod 中运行容器,我们将自动获得这些信息,因此我们首先尝试加载集群内配置 ➊。然而,如果我们在 Kubernetes 集群外部,通常会使用 Kubernetes 配置文件作为备选方案 ➋。

在我们建立与 API 服务器的连接方式之后,我们使用自定义对象 API 和一个 watch 对象来流式传输与我们的自定义资源相关的事件 ➍。stream() 方法接受一个函数名和相关参数,这些参数我们已经从环境变量或默认值中加载 ➌。我们使用 list_namespaced_custom_object 函数,因为我们关心的是我们的自定义资源。Python 库中的所有 list_* 方法都设计用于与 watch 一起工作,以返回添加、更新和删除事件的流,而不仅仅是检索当前对象列表。当事件发生时,我们会将它们打印到控制台中,格式易于阅读 ➎。

我们将在 Kubernetes 部署中使用这个 Python 脚本。我已经构建并发布了一个容器镜像来运行它,所以这项任务非常简单。以下是部署定义:

watch.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: watch
spec:
  replicas: 1
  selector:
    matchLabels:
      app: watch
  template:
    metadata:
      labels:
        app: watch
    spec:
      containers:
      - name: watch
        image: bookofkubernetes/crdwatcher:stable
      serviceAccountName: watcher

此部署将运行一个 Python 脚本,监视 Sample CRD 实例上的事件。然而,在我们创建这个部署之前,我们需要确保我们的监视脚本有权限读取我们的自定义资源。默认的 ServiceAccount 权限最小,因此我们需要为此部署创建一个 ServiceAccount,并确保它有权限查看我们的 Sample 自定义资源。

我们本可以将一个自定义 Role 绑定到我们的 ServiceAccount 来实现这一点,但利用角色聚合将我们的 Sample 自定义资源添加到已经存在的 view ClusterRole 中会更加方便。这样,集群中任何拥有 view ClusterRole 的用户都将获得对我们 Sample 自定义资源的访问权限。

我们首先为我们的自定义资源定义一个新的 ClusterRole:

sample-reader.yaml

 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRole
 metadata:
   name: sample-reader
   labels:
  ➊ rbac.authorization.k8s.io/aggregate-to-view: "true"
 rules:
➋ - apiGroups: ["bookofkubernetes.com"]
    resources: ["samples"]
    verbs: ["get", "watch", "list"]

这个 ClusterRole 赋予了 getwatchlist 我们的 Sample 自定义资源 ➋ 的权限。我们还在元数据 ➊ 中添加了一个标签,向集群指示我们希望这些权限被聚合到 view ClusterRole 中。因此,我们不需要将 ServiceAccount 绑定到我们在这里定义的 sample-reader ClusterRole,而是可以将 ServiceAccount 绑定到通用的 view ClusterRole,从而为它提供对所有资源的只读访问权限。

我们还需要声明 ServiceAccount,并将其绑定到 view ClusterRole:

sa.yaml

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: watcher
  namespace: default
---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: viewer
  namespace: default
subjects:
- kind: ServiceAccount
 name: watcher
  namespace: default
roleRef:
  kind: ClusterRole
  name: view
  apiGroup: rbac.authorization.k8s.io

我们使用 RoleBinding 来限制该 ServiceAccount 仅对 default 命名空间内的资源具有只读访问权限。RoleBinding 将 watcher ServiceAccount 绑定到通用的 view ClusterRole。由于我们指定的角色聚合,这个 ClusterRole 将可以访问我们的 Sample 自定义资源。

我们现在准备应用所有这些资源,包括我们的 Deployment:

root@host01:~# kubectl apply -f /opt/sample-reader.yaml 
clusterrole.rbac.authorization.k8s.io/sample-reader created
root@host01:~# kubectl apply -f /opt/sa.yaml
serviceaccount/watcher created
rolebinding.rbac.authorization.k8s.io/viewer created
root@host01:~# kubectl apply -f /opt/watch.yaml 
deployment.apps/watch created

不久之后,我们的监视器 Pod 将开始运行:

root@host01:~# kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
watch-69876b586b-jp25m   1/1     Running   0          47s

我们可以打印监视器的日志,以查看它从 API 服务器接收到的事件:

root@host01:~# kubectl logs watch-69876b586b-jp25m
{
  "type": "ADDED",
  "object": {
    "apiVersion": "bookofkubernetes.com/v1",
    "kind": "Sample",
    "metadata": {
...
      "creationTimestamp": "...",
...
      "name": "somedata",
      "namespace": "default",
      "resourceVersion": "9386",
      "uid": "37cc58db-179f-40e6-a9bf-fbf6540aa689"
 },
    "spec": {
      "value": 123
    }
  },
...

请注意,尽管我们在部署监视器之前就创建了 somedata Sample,但监视器 Pod 收到了这个 Sample 的 ADDED 事件。API 服务器能够确定我们的监视器还没有检索到这个对象,因此在连接时它会立即向我们发送一个事件,就像该对象是新创建的一样,这避免了我们本来需要处理的竞争条件。然而,注意如果客户端被重新启动,它将作为一个新客户端出现在 API 服务器上,并再次看到相同 Sample 的 ADDED 事件。因此,在我们实现处理自定义资源的逻辑时,必须确保逻辑是幂等的,以便我们能够多次处理相同的事件。

操作员

除了将事件记录到控制台之外,我们还会采取什么样的行动来响应自定义资源的创建、更新或删除呢?正如我们在检查自定义资源如何用于配置集群中 Calico 网络时所看到的,自定义资源的一个用途是配置集群基础设施组件,例如网络和存储。但另一个真正充分利用自定义资源的模式是 Kubernetes 的 Operator

Kubernetes Operator 模式扩展了集群的行为,使得更容易部署和管理特定的应用程序组件。与直接使用 Kubernetes 资源集(如部署和服务)的标准集不同,我们只需创建特定于应用程序组件的自定义资源,操作器将为我们管理底层的 Kubernetes 资源。

让我们看一个示例,以说明 Kubernetes Operator 模式的强大。我们将在集群中添加一个 Postgres Operator,这将使我们能够通过添加单个自定义资源来部署高可用的 PostgreSQL 数据库到我们的集群。

我们的自动化已将所需文件暂存到 /etc/kubernetes/components 并执行了一些初始设置,所以剩下的唯一步骤就是添加操作器。该操作器是一个普通的部署,将在我们选择的任何命名空间中运行。然后,它将监视自定义 postgresql 资源,并相应地创建 PostgreSQL 实例。

让我们部署该操作器:

root@host01:~# kubectl apply -f /etc/kubernetes/components/postgres-operator.yaml 
deployment.apps/postgres-operator created

这创建了操作器本身的部署,它创建一个单独的 Pod:

root@host01:~# kubectl get pods
NAME                                 READY   STATUS    RESTARTS   AGE
postgres-operator-5cdbff85d6-cclxf   1/1     Running   0          27s
...

Pod 与 API 服务器通信以创建定义 PostgreSQL 数据库所需的 CRD:

root@host01:~# kubectl get crd postgresqls.acid.zalan.do
NAME                        CREATED AT
postgresqls.acid.zalan.do   ...

尚未在集群中运行任何 PostgreSQL 实例,但我们可以通过基于该 CRD 创建自定义资源来轻松部署 PostgreSQL:

pgsql.yaml

---
apiVersion: "acid.zalan.do/v1"
kind: postgresql
metadata:
  name: pgsql-cluster
  namespace: default
spec:
  teamId: "pgsql"
  volume:
    size: 1Gi
    storageClass: longhorn
  numberOfInstances: 3
  users:
    dbuser:
    - superuser
    - createdb
  databases:
    defaultdb: dbuser
  postgresql:
    version: "14"

此自定义资源告诉 Postgres Operator 使用服务器版本 14 生成一个 PostgreSQL 数据库,具有三个实例(一个主实例和两个备份)。每个实例都将具有持久存储。主实例将配置为指定的用户和数据库。

Kubernetes Operator 模式的真正价值在于我们声明的 YAML 资源文件简短、简单且明确地与我们想要看到的 PostgreSQL 配置相关联。操作器的工作是将此信息转换为 StatefulSet、Services 和其他集群资源,以便操作此数据库。

我们像处理任何其他资源一样将此自定义资源应用于集群:

root@host01:~# kubectl apply -f /opt/pgsql.yaml 
postgresql.acid.zalan.do/pgsql-cluster created

我们应用后,Postgres Operator 将接收添加事件,并为 PostgreSQL 创建必要的集群资源:

root@host01:~# kubectl logs postgres-operator-5cdbff85d6-cclxf
... level=info msg="Spilo operator..."
...
... level=info msg="ADD event has been queued" 
  cluster-name=default/pgsql-cluster pkg=controller worker=0
... level=info msg="creating a new Postgres cluster" 
  cluster-name=default/pgsql-cluster pkg=controller worker=0
...
... level=info msg="statefulset 
  \"default/pgsql-cluster\" has been successfully created" 
  cluster-name=default/pgsql-cluster pkg=cluster worker=0
...

最终,将有一个 StatefulSet 和三个运行的 Pod(除了操作器本身仍在运行的 Pod):

root@host01:~# kubectl get sts
NAME            READY   AGE
pgsql-cluster   3/3     2m39s
root@host01:~# kubectl get po
NAME                                 READY   STATUS    RESTARTS   AGE
pgsql-cluster-0                      1/1     Running   0          2m40s
pgsql-cluster-1                      1/1     Running   0          2m18s
pgsql-cluster-2                      1/1     Running   0          111s
postgres-operator-5cdbff85d6-cclxf   1/1     Running   0          4m6s
...

所有这些资源完全在集群上运行可能需要几分钟时间。

与我们在 第十五章 中创建的 PostgreSQL StatefulSet 不同,此 StatefulSet 中的所有实例均配置为高可用性,这可以通过检查每个 Pod 的日志来演示:

root@host01:~# kubectl logs pgsql-cluster-0
...
... INFO: Lock owner: None; I am pgsql-cluster-0
... INFO: trying to bootstrap a new cluster
...
... INFO: initialized a new cluster
...
... INFO: no action. I am (pgsql-cluster-0) the leader with the lock
root@host01:~# kubectl logs pgsql-cluster-1
...
... INFO: Lock owner: None; I am pgsql-cluster-1
... INFO: waiting for leader to bootstrap
... INFO: Lock owner: pgsql-cluster-0; I am pgsql-cluster-1
...
... INFO: no action. I am a secondary (pgsql-cluster-1) and following 
    a leader (pgsql-cluster-0)

如我们所见,第一个实例 pgsql-cluster-0 已将自己标识为领导者,而 pgsql-cluster-1 则配置为跟随者,将复制到领导者数据库的任何更新。

为了管理 PostgreSQL 的领导者和跟随者,并使数据库客户端能够访问领导者,操作器已创建了多个服务:

root@host01:~# kubectl get svc
NAME                   TYPE        CLUSTER-IP      ... PORT(S)    AGE
...
pgsql-cluster          ClusterIP   10.101.80.163   ... 5432/TCP   6m52s
pgsql-cluster-config   ClusterIP   None            ... <none>     6m21s
pgsql-cluster-repl     ClusterIP   10.96.13.186    ... 5432/TCP   6m52s

pgsql-cluster 服务只将流量路由到主节点;其他服务用于管理复制到备份实例。操作员会处理在主实例由于故障切换而发生变化时更新服务的任务。

要移除 PostgreSQL 数据库,我们只需要删除自定义资源,其余操作由 Postgres Operator 处理:

root@host01:~# kubectl delete -f /opt/pgsql.yaml 
postgresql.acid.zalan.do "pgsql-cluster" deleted

操作员会检测到删除操作并清理相关的 Kubernetes 集群资源:

root@host01:~# kubectl logs postgres-operator-5cdbff85d6-cclxf
...
... level=info msg="deletion of the cluster started" 
  cluster-name=default/pgsql-cluster pkg=controller worker=0
... level=info msg="DELETE event has been queued" 
  cluster-name=default/pgsql-cluster pkg=controller worker=0
...
... level=info msg="cluster has been deleted" 
  cluster-name=default/pgsql-cluster pkg=controller worker=0

Postgres Operator 现在已移除与该数据库集群相关的 StatefulSet、持久存储和其他资源。

我们能够轻松地部署和移除 PostgreSQL 数据库服务器,包括自动配置为高可用性配置的多个实例,这展示了 Kubernetes Operator 模式的强大。通过定义 CRD,常规的部署可以扩展我们的 Kubernetes 集群的行为。结果是无缝地增加了集群的新功能,并且与 Kubernetes 集群的内置功能完全集成。

最后的思考

CustomResourceDefinitions 和 Kubernetes Operators 为集群带来高级功能,但它们是通过构建在我们在本书中看到的基本 Kubernetes 集群功能之上的。Kubernetes API 服务器具有处理任何类型集群资源存储和检索的可扩展性。因此,我们能够动态定义新的资源类型,并让集群为我们管理这些资源。

我们在本书的第二部分中已经看到过这种模式。Kubernetes 本身是建立在我们在第一部分中看到的容器基本功能之上的,且它是通过将更基本的功能整合在一起来实现其更高级的功能的。通过理解这些基本功能的工作原理,我们能够更好地理解这些高级功能,即使它们的行为乍一看有点神奇。

我们现在已经了解了构建高质量、高性能应用程序所需掌握的 Kubernetes 关键能力。接下来,我们将关注在 Kubernetes 集群中运行应用时,如何提高应用的性能和弹性。

第三部分

高效 Kubernetes

尽管容器旨在隐藏集群中各个主机及其底层硬件的一些复杂性,但现实世界中的应用程序需要进行调优,以充分发挥可用计算能力。这种调优必须以与我们 Kubernetes 集群的可扩展性和弹性兼容的方式进行,这样我们就不会失去动态调度和横向扩展的优势。换句话说,我们需要向集群提供提示,帮助它以最有效的方式调度容器。

第十八章:亲和性和设备

image

理想化的应用程序展示了完全的简单性。它的设计简单,开发简单,部署简单。它的各个组件都是无状态的,因此很容易扩展以服务尽可能多的用户。每个服务端点都充当纯粹的函数,其输出仅由输入决定。应用程序处理的数据量合理,CPU 和内存需求适中,请求和响应容易适配到一个最多只有几千字节的 JSON 结构中。

当然,除了教程之外,理想化的应用程序是不存在的。现实世界中的应用程序会存储状态,包括长期的持久存储和可以快速访问的缓存。现实世界的应用程序有数据安全和授权方面的考虑,因此它们需要进行用户身份验证,记住用户是谁,并相应地限制访问权限。许多现实世界的应用程序还需要访问专用硬件,而不仅仅是使用理想化的 CPU、内存、存储和网络资源。

我们希望在 Kubernetes 集群上部署现实世界中的应用程序,而不仅仅是理想化的应用程序。这意味着我们需要做出明智的决策,关于如何部署那些让我们远离理想化世界的应用程序组件——在那个世界中,集群决定运行多少个容器实例以及如何调度它们。然而,我们不想创建一个过于僵化的应用架构,以至于失去集群的可扩展性和弹性。相反,我们希望在集群内工作,给集群一些提示,指导如何部署我们的应用组件,同时尽可能保持灵活性。在本章中,我们将探讨我们的应用组件如何在不失去 Kubernetes 优势的情况下,强制与其他组件或专用硬件之间形成一定的耦合。

亲和性与反亲和性

我们将首先看一下管理 Pods 调度的情况,这样我们可以优先或避免将多个容器部署在同一个节点上。例如,如果我们有两个消耗大量网络带宽并相互通信的容器,我们可能希望这两个容器一起运行在一个节点上,以减少延迟并避免拖慢集群中的其他部分。或者,如果我们希望确保一个高可用组件能够在集群中的一个节点丢失时依然存活,我们可能希望将 Pod 实例拆分,使它们尽可能在不同的集群节点上运行。

合并多个独立的容器到一个 Pod 规范中,是共置容器的一种方法。这对于两个进程完全相互依赖的情况是一个很好的解决方案。然而,这也失去了单独扩展实例的能力。例如,在一个由分布式存储支持的 Web 应用中,我们可能需要比存储进程更多的 Web 服务器进程实例。我们需要将这些应用组件放置在不同的 Pod 中,以便能够单独扩展它们。

在 第八章中,当我们想确保一个 Pod 在指定的节点上运行时,我们在 Pod 规范中添加了 nodeName 字段以覆盖调度器。这个方法对于示例来说是可以的,但对于实际应用,它会消除性能和可靠性所必需的扩展和故障转移功能。相反,我们将使用 Kubernetes 的 亲和性 概念,为调度器提供关于如何分配 Pod 的提示,而不强制任何 Pod 必须在特定节点上运行。

亲和性允许我们根据其他 Pods 的存在来限制 Pod 应该调度到哪里。让我们来看一个使用 iperf3 网络测试应用的例子。

集群区域

Pod 亲和性对于跨多个网络的大型集群最为有用。例如,我们可能会将 Kubernetes 集群部署到多个不同的数据中心,以消除单点故障。在这些情况下,我们会根据一个包含多个节点的区域来配置亲和性。在这里,我们只有一个小型示例集群,所以我们将把集群中的每个节点视为一个独立的区域。

反亲和性

让我们从亲和性的反面开始:反亲和性。反亲和性会导致 Kubernetes 调度器避免将 Pods 共置在一起。在这种情况下,我们将创建一个有三个独立 iperf3 服务器 Pod 的 Deployment,但我们将使用反亲和性规则将这三个 Pod 分布到不同的节点上,使每个节点都有一个 Pod。

注意

本书的示例代码库位于 github.com/book-of-kubernetes/examples有关如何设置的详细信息,请参见 第 xx 页中的“运行示例”。

这是我们需要的 YAML 定义:

ipf-server.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: iperf-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: iperf-server
  template:
    metadata:
      labels:
        app: iperf-server
    spec:
   ➊ affinity:
        podAntiAffinity:
       ➋ requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - iperf-server
         ➌ topologyKey: "kubernetes.io/hostname"
 containers:
      - name: iperf
        image: bookofkubernetes/iperf3:stable
        env:
        - name: IPERF_SERVER
          value: "1"

这个 Deployment 资源是典型的,除了新的 affinity 部分 ➊。我们指定了一个基于 Deployment 用来管理其 Pods 的相同标签的反亲和性规则。通过这个规则,我们指定不希望将 Pod 调度到已经有 app=iperf-server 标签的区域。

topologyKey ➌ 指定了区域的大小。在这种情况下,集群中的每个节点都有不同的 hostname 标签,因此每个节点都被视为一个不同的区域。因此,反亲和性规则会阻止 kube-scheduler 在第一个 Pod 已经调度到某个节点后,再将第二个 Pod 调度到该节点。

最后,因为我们使用 requiredDuringScheduling ➋ 指定了规则,所以这是一个 反亲和性规则,这意味着调度器不会调度 Pod,除非它能满足这个规则。如果规则不能满足,也可以使用 preferredDuringScheduling 并分配一个权重,给调度器提供提示,但不会阻止 Pod 调度。

注意

topologyKey 可以基于应用于节点的任何标签。基于云的 Kubernetes 分发通常会根据节点的可用区自动为每个节点应用标签,这使得使用反亲和性在可用区之间分布 Pods 以实现冗余变得容易。

让我们应用这个 Deployment 并查看结果:

root@host01:~# kubectl apply -f /opt/ipf-server.yaml 
deployment.apps/iperf-server created

一旦我们的 Pod 启动运行,我们会看到每个节点都被分配了一个 Pod:

root@host01:~# kubectl get po -o wide
NAME                            READY   STATUS    ... NODE     ...
iperf-server-7666fb76d8-7rz8j   1/1     Running   ... host01   ...
iperf-server-7666fb76d8-cljkh   1/1     Running   ... host02   ...
iperf-server-7666fb76d8-ktk92   1/1     Running   ... host03   ...

因为我们有三个节点和三个实例,这与使用 DaemonSet 本质上是相同的,但这种方法更加灵活,因为它不需要每个节点上都有实例。在大型集群中,我们可能只需要少量的 Pod 实例来满足服务需求。使用基于主机名的反亲和性与区域相结合,可以让我们在仍然将每个 Pod 分配到不同节点以提高可用性的同时,指定部署的正确规模。而且反亲和性也可以用于将 Pods 分布到其他类型的区域。

在继续之前,让我们创建一个 Service,供我们的 iperf3 客户端找到一个服务器实例。以下是 YAML 文件:

ipf-svc.yaml

---
kind: Service
apiVersion: v1
metadata:
  name: iperf-server
spec:
  selector:
    app: iperf-server
  ports:
  - protocol: TCP
    port: 5201
    targetPort: 5201

让我们将此应用于集群:

root@host01:~# kubectl apply -f /opt/ipf-svc.yaml 
service/iperf-server created

服务会启动所有三个 Pod:

root@host01:~# kubectl get ep iperf-server
NAME           ENDPOINTS                                                 ...
iperf-server   172.31.239.207:5201,172.31.25.214:5201,172.31.89.206:5201 ...

ependpoints 的缩写。每个 Service 都有一个相关联的 Endpoint 对象,用来记录当前接收流量的 Pods。

亲和性

我们现在准备将 iperf3 客户端部署到这些服务器实例上。我们希望以相同的方式将客户端分配到每个节点,但我们需要确保每个客户端都部署到一个有服务器实例的节点上。为此,我们将使用亲和性和反亲和性规则:

ipf-client.yaml

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: iperf
spec:
  replicas: 3
  selector:
    matchLabels:
      app: iperf
  template:
    metadata:
      labels:
 app: iperf
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - iperf
            topologyKey: "kubernetes.io/hostname"
        ➊ podAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - iperf-server
            topologyKey: "kubernetes.io/hostname"
      containers:
      - name: iperf
        image: bookofkubernetes/iperf3:stable

额外的 podAffinity 规则 ➊ 确保每个客户端实例只有在服务器实例已经存在的情况下才会部署到节点。亲和性规则中的字段与反亲和性规则相同。

让我们部署客户端实例:

root@host01:~# kubectl apply -f /opt/ipf-client.yaml 
deployment.apps/iperf created

在这些 Pods 运行后,我们可以看到它们已经分布到集群中的所有三个节点:

root@host01:~# kubectl get po -o wide
NAME                            READY   STATUS    ... NODE     ... 
iperf-c8d4566f-btppf            1/1     Running   ... host02   ... 
iperf-c8d4566f-s6rpn            1/1     Running   ... host03   ... 
iperf-c8d4566f-v9v8m            1/1     Running   ... host01   ... 
...

看起来我们已将iperf3客户端和服务器部署得能够使每个客户端连接到其本地的服务器实例,从而最大化客户端和服务器之间的带宽。然而,实际上并非如此。因为iperf-server服务配置了所有三个 Pods,每个客户端 Pod 都连接到一个随机的服务器。因此,我们的客户端可能无法正常工作。你可能会看到日志显示某个客户端能够连接到服务器,但也可能会看到客户端 Pods 处于ErrorCrashLoopBackOff状态,并且有类似如下的日志输出:

root@host01:~# kubectl logs iperf-c8d4566f-v9v8m
iperf3: error - the server is busy running a test. try again later
iperf3 error - exiting

这表示某个客户端正在连接到已经有客户端连接的服务器,这意味着至少有两个客户端在使用同一个服务器。

服务流量路由

我们希望配置我们的客户端 Pods,使其能够访问我们部署的本地服务器 Pod,而不是不同节点上的服务器 Pod。让我们首先确认流量是否在所有三个服务器 Pods 之间随机路由。我们可以查看kube-proxy为该服务创建的iptables规则:

root@host01:~# iptables-save | grep iperf-server
...
-A KUBE-SVC-KN2SIRYEH2IFQNHK -m comment --comment "default/iperf-server" 
  -m statistic --mode random --probability 0.33333333349 -j KUBE-SEP-IGBNNG5F5VCPRRWI
-A KUBE-SVC-KN2SIRYEH2IFQNHK -m comment --comment "default/iperf-server" 
  -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-FDPADR4LUNHDJSPL
-A KUBE-SVC-KN2SIRYEH2IFQNHK -m comment --comment "default/iperf-server" 
  -j KUBE-SEP-TZDPKVKUEZYBFM3V

我们在host01上运行这个命令,看到有三条独立的iptables规则,并且目标是随机选择的。这意味着,host01上的iperf3客户端可能会被路由到任何一个服务器 Pod。

为了解决这个问题,我们需要更改我们服务的内部流量策略配置。默认情况下,策略是Cluster,表示集群中的所有 Pods 都是有效的目标。我们可以将策略更改为Local,这样就会限制服务仅路由到同一节点上的 Pods。

让我们修补服务来更改这个策略:

root@host01:~# kubectl patch svc iperf-server -p '{"spec":{"internalTrafficPolicy":"Local"}}'
service/iperf-server patched

更改立即生效,我们可以通过再次查看iptables规则来验证:

root@host01:~# iptables-save | grep iperf-server
...
-A KUBE-SVC-KN2SIRYEH2IFQNHK -m comment --comment "default/iperf-server" \
  -j KUBE-SEP-IGBNNG5F5VCPRRWI

这一次,只有一个可能的目标被配置在host01上,因为该服务只有一个本地 Pod 实例。

几分钟后,iperf3客户端现在显示出我们预期看到的输出:

root@host01:~# kubectl logs iperf-c8d4566f-btppf
Connecting to host iperf-server, port 5201
...
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  8.67 GBytes  7.45 Gbits/sec  1250             sender
[  5]   0.00-10.00  sec  8.67 GBytes  7.45 Gbits/sec                  receiver
...

不仅所有客户端都能够连接到独特的服务器,而且由于网络连接是本地到每个节点的,性能始终很高。

在继续之前,让我们清理这些资源:

root@host01:~# kubectl delete svc/iperf-server deploy/iperf deploy/iperf-server
service "iperf-server" deleted
deployment.apps "iperf" deleted
deployment.apps "iperf-server" deleted

虽然Local内部流量策略有助于最大化客户端和服务器之间的带宽,但它也有一个主要的限制。如果某个节点没有健康的 Pod 实例,那么该节点上的客户端将根本无法访问服务,即使其他节点上有健康的实例。在使用这种设计模式时,至关重要的是还要配置一个就绪探针,如第十三章中所述,它不仅检查 Pod 本身,还检查其服务依赖性。这样,如果某个节点上的服务无法访问,该节点上的客户端也会报告自己为不健康,从而不会有流量路由到它。

我们所看到的亲和性和反亲和性功能使我们能够在不牺牲应用组件的可扩展性和弹性的前提下,向调度器提供提示。然而,尽管在应用架构中有紧密连接的组件时,使用这些功能可能很有诱惑力,但最好是让调度器无阻碍地工作,仅在实际的性能测试表明它能够带来显著差异时,才添加亲和性。

为了提高性能,服务路由是 Kubernetes 中的一个活跃开发领域。对于跨多个区域运行的集群,一种名为拓扑感知提示(Topology Aware Hints)的新功能,可以使 Kubernetes 将连接路由到离服务实例最近的地方,从而提高网络性能,同时在必要时允许跨区域流量。

硬件资源

亲和性和反亲和性允许我们控制 Pods 的调度位置,但应该仅在必要时使用。那么,对于某些 Pod 需要访问仅在某些节点上可用的专用硬件的情况该怎么办呢?例如,我们可能有需要图形处理单元(GPU)加速的处理任务,但为了降低成本,我们可能会限制集群中的 GPU 节点数量。在这种情况下,确保 Pod 被调度到正确的地方是绝对必要的。

和之前一样,我们可以通过 nodeName 将 Pod 直接绑定到某个节点。但集群中可能有多个节点具备所需的硬件,因此我们真正需要的是能够向 Kubernetes 说明需求,然后让调度器决定如何满足这个需求。

Kubernetes 提供了两种相关的方法来解决这一需求:设备插件和扩展资源。设备插件提供了最完整的功能,但插件本身必须存在于硬件设备上。同时,扩展资源可以用于任何硬件设备,但 Kubernetes 集群只会跟踪该资源的分配,而不实际管理其在容器中的可用性。

实现设备插件需要与 kubelet 紧密协作。类似于我们在第十五章中看到的存储插件架构,设备插件会向运行在节点上的 kubelet 实例注册自己,标识它管理的任何设备。Pod 标识它们所需的设备,设备管理器告诉 kubelet 如何在容器内使设备可用(通常是通过将设备从主机挂载到容器的文件系统中)。

由于我们是在一个虚拟化的示例集群中操作,因此没有专用硬件来演示设备插件,但扩展资源从分配的角度来看是相同的,因此我们仍然可以对整体方法有所了解。

首先,通过更新集群,指示某个节点具有示例扩展资源。我们通过修补节点的 status 来实现这一点。理想情况下,我们可以使用 kubectl patch 来执行此操作,但不幸的是,无法通过该命令更新资源的 status,因此我们只能使用 curl 直接调用 Kubernetes API。 /opt 目录下有一个脚本可以简化此过程。清单 18-1 展示了相关部分。

add-hw.sh

#!/bin/bash
...
patch='
[
  {
    "op": "add", 
    "path": "/status/capacity/bookofkubernetes.com~1special-hw", 
    "value": "3"
  }
]
'
curl --cacert $ca --cert $cert --key $key \
  -H "Content-Type: application/json-patch+json" \
  -X PATCH -d "$patch" \
  https://192.168.61.10:6443/api/v1/nodes/host02/status
...

清单 18-1:特殊硬件脚本

curl 命令发送一个 JSON 补丁对象来更新节点的 status 字段,在 capacity 下添加一个名为 bookofkubernetes.com/special-hw 的条目。~1 起到斜杠字符的作用。

运行脚本以更新节点:

root@host01:~# /opt/add-hw.sh 
...

从 API 服务器返回的响应包括整个节点的资源。让我们再次确认我们关心的字段,以确保它已经应用:

root@host01:~# kubectl get node host02 -o json | jq .status.capacity
{
  "bookofkubernetes.com/special-hw": "3",
  "cpu": "2",
  "ephemeral-storage": "40593612Ki",
  "hugepages-2Mi": "0",
  "memory": "2035228Ki",
  "pods": "110"
}

扩展资源与节点的标准资源一起显示。现在,我们可以像请求标准资源一样请求该资源,正如我们在第十四章中看到的那样。

这是一个请求特殊硬件的 Pod:

hw.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: sleep
spec:
  containers:
  - name: sleep
    image: busybox
    command: ["/bin/sleep", "infinity"]
    resources:
      limits:
        bookofkubernetes.com/special-hw: 1

我们使用 resources 字段来指定对特殊硬件的需求。资源要么被分配,要么不分配;因此,requestslimits 之间没有区别,所以 Kubernetes 希望我们使用 limits 来指定。当我们将此应用到集群时,Kubernetes 调度器会确保该 Pod 运行在能够满足此要求的节点上:

root@host01:~# kubectl apply -f /opt/hw.yaml 
pod/sleep created

因此,Pod 最终被调度到 host02

root@host01:~# kubectl get po -o wide
NAME    READY   STATUS    ... NODE     ...
sleep   1/1     Running   ... host02   ...

此外,节点状态现在反映了该扩展资源的分配:

root@host01:~# kubectl describe node host02
Name:               host02
...
Allocated resources:
...
  Resource                         Requests     Limits
  --------                         --------     ------
...
  bookofkubernetes.com/special-hw  1            1
...

当我们在清单 18-1 中添加扩展资源时,所指定的三台 special-hw 的可用数量,以及该资源分配给 Pod 的方式,都是任意的。扩展资源就像一个信号量,防止过多的用户同时使用同一资源,但如果我们真的有三个单独的特殊硬件设备在同一节点上运行,我们需要增加额外的处理来避免多个用户冲突。

如果我们根据指定的可用资源尝试过度分配,Pod 将无法调度。如果我们尝试添加另一个需要所有三个特殊硬件设备的 Pod,我们可以确认这一点:

hw3.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: sleep3
spec:
  containers:
  - name: sleep
    image: busybox
    command: ["/bin/sleep", "infinity"]
    resources:
 limits:
        bookofkubernetes.com/special-hw: 3

让我们尝试将这个 Pod 添加到集群中:

root@host01:~# kubectl apply -f /opt/hw3.yaml 
pod/sleep created

由于没有足够的特殊硬件设备可用,因此这个 Pod 保持在 Pending 状态:

root@host01:~# kubectl get po -o wide
NAME    READY   STATUS    ... NODE     ...
sleep   1/1     Running   ... host02   ...
sleep3  0/1     Pending   ... <none>   ...

Pod 将等待硬件可用。让我们删除原始的 Pod 以释放空间:

root@host01:~# kubectl delete pod sleep 
pod/sleep deleted

我们的新 Pod 现在将开始运行:

root@host01:~# kubectl get po -o wide
NAME    READY   STATUS    ... NODE     ...
sleep3  1/1     Running   ... host02   ...

和之前一样,Pod 被调度到 host02,这是由于特殊硬件的需求。

设备驱动程序从资源分配的角度来看是相同的。在这两种情况下,我们都使用limits字段来确定硬件要求。唯一的不同之处在于,我们不需要手动修补节点来记录资源,因为当设备驱动程序注册时,kubelet会自动更新节点的状态。此外,当容器创建时,kubelet会调用设备驱动程序来执行任何必要的硬件分配和配置。

最终思考

与理想应用程序不同,在现实世界中,我们通常需要处理紧密耦合的应用组件和对专用硬件的需求。至关重要的是,我们必须在不失去从将应用程序部署到 Kubernetes 集群中获得的灵活性和弹性的前提下,考虑这些应用程序的需求。在本章中,我们看到亲和性和设备驱动程序如何使我们能够向调度程序提供提示和资源要求,同时仍然允许它具有动态管理应用程序规模的灵活性。

调度并不是我们在考虑如何从现实世界应用程序中获得所需行为和性能时唯一需要关注的问题。在下一章中,我们将看到如何通过使用服务质量类来塑造我们 Pod 的处理和内存分配。

第十九章:调优服务质量

image

理想情况下,我们的应用程序应使用最小或高度可预测的处理、内存、存储和网络资源。然而,在现实世界中,应用程序是“突发性的”,其负载变化由用户需求、大量数据或复杂处理驱动。在 Kubernetes 集群中,应用组件动态部署到集群中不同的节点上,如果负载在这些节点间分布不均,可能会造成性能瓶颈。

从应用架构的角度来看,越是将应用组件做得小巧且可扩展,我们就能越均匀地分配负载到集群中。不幸的是,性能问题并不总是能够通过水平扩展来解决。在本章中,我们将探讨如何使用资源规格来向集群提供有关如何调度我们的 Pod 的提示,目的是使应用性能更加可预测。

实现可预测性

在日常语言中,“实时”一词通常指某些迅速且持续发生的事情。但在计算机科学中,我们区分“实时”和“实时快速”,它们甚至被认为是对立的。这是因为可预测性的重要性。

实时处理指的是需要跟上现实世界某些活动的处理。它可以是任何需要跟上传感器数据输入并保持最新电子飞行显示的飞机驾驶舱软件,也可以是需要及时接收并解码每一帧视频以便显示的视频流应用程序。在实时系统中,至关重要的是我们能够保证处理“足够快”,以跟上现实世界的需求。

只要“足够快”就好。处理速度不需要快过现实世界,因为应用程序没有其他事情可做。但即便是一个处理速度慢于现实世界的时间间隔,也意味着我们落后于输入或输出,导致观影者的不满——甚至可能导致飞机坠毁。

因此,实时系统中的主要目标是可预测性。资源是根据系统可能遇到的最坏情况进行分配的,我们愿意提供比实际需要更多的处理能力,以确保在最坏情况下有足够的余地。实际上,要求这类系统在最大预期负载下,即使在可用处理和内存资源上,也要保持低于 50%的利用率是很常见的。

但尽管响应性始终很重要,大多数应用程序并不在实时环境中运行,而这种额外的资源余量是昂贵的。出于这个原因,大多数系统试图在可预测性和效率之间找到平衡,这意味着我们通常愿意容忍应用组件略微的性能下降,只要它是暂时的。

服务质量类别

为了帮助我们平衡集群中容器的可预测性和效率,Kubernetes 将 Pods 分配到三种不同的服务质量类别:BestEffortBurstableGuaranteed。从某种意义上讲,我们可以将这些类别看作是描述性的。BestEffort 用于我们没有提供任何资源要求时,它只能尽最大努力为 Pod 提供足够的资源。Burstable 用于 Pod 可能超过其资源请求的情况。Guaranteed 用于我们提供一致的资源要求,并且期望 Pod 始终保持在这些要求内。因为这些类别是描述性的,并且仅基于容器在 Pod 中指定的资源要求,因此没有办法手动指定 Pod 的 QoS。

QoS 类别有两种使用方式。首先,属于同一 QoS 类别的 Pods 会被分组,以便进行 Linux 控制组(cgroups)配置。正如我们在第三章中看到的,cgroups 用于控制一组进程的资源使用,特别是处理能力和内存,因此,Pod 的 cgroup 会影响其在系统负载较高时的处理时间优先级。其次,如果节点因内存资源不足需要开始逐出 Pods,QoS 类别会影响哪些 Pods 会首先被逐出。

BestEffort

最简单的情况是我们声明一个没有 limits 的 Pod。在这种情况下,Pod 被分配到 BestEffort 类别。让我们创建一个示例 Pod 来探索这意味着什么。

注意

本书的示例代码库位于 github.com/book-of-kubernetes/examples有关如何设置的详细信息,请参见第 xx 页中的“运行示例”。

这是 Pod 的定义:

best-effort.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: best-effort
spec:
  containers:
  - name: best-effort
    image: busybox
    command: ["/bin/sleep", "infinity"]
  nodeName: host01

这个定义完全没有resources字段,但如果我们包含一个带有requests但没有limitsresources字段,QoS 类别会是一样的。

我们使用 nodeName 强制将该 Pod 部署到 host01,以便观察其资源使用配置。让我们将其应用到集群中:

root@host01:~# kubectl apply -f /opt/best-effort.yaml 
pod/best-effort created

在 Pod 启动后,我们可以查看它的详细信息,看到它已分配到 BestEffort QoS 类别:

root@host01:~# kubectl get po best-effort -o json | jq .status.qosClass
"BestEffort"

我们可以使用在第十四章中看到的 cgroup-info 脚本,查看 QoS 类别如何影响 Pod 中容器的 cgroup 配置:

root@host01:~# /opt/cgroup-info best-effort

Container Runtime
-----------------
Pod ID: 205...

Cgroup path: /kubepods.slice/kubepods-besteffort.slice/kubepods-...

CPU Settings
------------
CPU Shares: 2
CPU Quota (us): -1 per 100000

Memory Settings
---------------
Limit (bytes): 9223372036854771712

该 Pod 在 CPU 和内存使用上实际上没有限制。然而,Pod 的 cgroup 位于kubepods-besteffort.slice路径下,反映了它被分配到BestEffort QoS 类别中。这种分配直接影响了它的 CPU 优先级,正如我们在比较BestEffort类别和Burstable类别的cpu.shares时所看到的那样:

root@host01:~# cat /sys/fs/cgroup/cpu/kubepods.slice/kubepods-besteffort.slice/cpu.shares 
2
root@host01:~# cat /sys/fs/cgroup/cpu/kubepods.slice/kubepods-burstable.slice/cpu.shares 
1157

正如我们在第十四章中看到的,这些值是相对的,因此这一配置意味着,当系统的处理负载很高时,Burstable Pods 中的容器将被分配比BestEffort Pods 中容器超过 500 倍的处理器份额。这个值是基于已经在BestEffortBurstable QoS 类别中的 Pod 数量,包括在host01上运行的各种集群基础设施组件,因此你可能会看到略有不同的值。

kubepods.slice cgroup 与用户和系统进程的 cgroup 处于同一级别,因此当系统负载较高时,它会获得与其他 cgroup 几乎相等的处理时间份额。基于在kubepods.slice cgroup 中识别到的cpu.sharesBestEffort Pods 相对于Burstable Pods,获得的处理器时间份额不到总份额的 1%,即使不考虑分配给Guaranteed Pods 的处理器时间。这意味着当系统负载高时,BestEffort Pods 几乎没有处理器时间,因此它们应该仅用于在集群空闲时运行的后台处理。此外,由于只有在未指定limits时才将 Pods 放置在BestEffort类别中,因此它们无法在具有限制配额的命名空间中创建。因此,我们的大多数应用程序 Pods 将位于其他两个 QoS 类别之一。

Burstable

如果 Pod 同时指定了requestslimits,并且这两个规格不同,则 Pod 会被放置在Burstable类别中。正如我们在第十四章中看到的,requests规格用于调度目的,而limits规格用于运行时强制执行。换句话说,这种情况下的 Pods 可以在其requests级别之上有“突发”的资源使用,但不能超过其limits

让我们来看一个例子:

burstable.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: burstable
spec:
  containers:
  - name: burstable
    image: busybox
    command: ["/bin/sleep", "infinity"]
    resources:
      requests:
        memory: "64Mi"
        cpu: "50m"
      limits:
        memory: "128Mi"
        cpu: "100m"
  nodeName: host01

这个 Pod 定义提供了requestslimits资源要求,并且它们是不同的,因此我们可以预期这个 Pod 将被放置在Burstable类别中。

让我们将这个 Pod 应用到集群中:

root@host01:~# kubectl apply -f /opt/burstable.yaml 
pod/burstable created

接下来,让我们验证它是否已分配到Burstable QoS 类别:

root@host01:~# kubectl get po burstable -o json | jq .status.qosClass
"Burstable"

实际上,cgroup 配置遵循了我们指定的 QoS 类别和limits

root@host01:~# /opt/cgroup-info burstable

Container Runtime
-----------------
Pod ID: 8d0...
Cgroup path: /kubepods.slice/kubepods-burstable.slice/kubepods-...

CPU Settings
------------
CPU Shares: 51
CPU Quota (us): 10000 per 100000

Memory Settings
---------------
Limit (bytes): 134217728

limits为此 Pod 指定的值用于设置 CPU 限制和内存限制。此外,正如我们预期的,这个 Pod 的 cgroup 被放置在kubepods-burstable.slice中。

Burstable QoS 类别添加另一个 Pod,导致 Kubernetes 重新平衡了处理器时间的分配:

root@host01:~# cat /sys/fs/cgroup/cpu/kubepods.slice/kubepods-besteffort.slice/cpu.shares 
2
root@host01:~# cat /sys/fs/cgroup/cpu/kubepods.slice/kubepods-burstable.slice/cpu.shares 
1413

结果是,Burstable QoS 类别下的 Pod 显示 cpu.shares 的值为 1413,而 BestEffort 类别下的 Pod 仍然显示 2。这意味着在负载下,Burstable 类别 Pod 的相对处理器份额是 700 比 1。再一次,你可能会看到略有不同的值,取决于 Kubernetes 为 host01 分配了多少基础设施 Pod。

因为 Burstable 类 Pod 是根据 requests 调度的,但 cgroup 运行时强制执行是基于 limits 的,所以节点的处理器和内存资源可能会超额分配。只要节点上的 Pod 彼此之间平衡,平均利用率与 requests 匹配,就没有问题。如果平均利用率超过了 requests,就会出现问题。在这种情况下,Pod 会看到其 CPU 被限速,如果内存变得紧张,可能会被驱逐,就像我们在第十章中看到的那样。

保证类

如果我们希望提高 Pod 可用处理能力和内存的可预测性,可以通过设置相同的 requestslimits 来将 Pod 放入 Guaranteed QoS 类别。以下是一个示例:

guaranteed.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: guaranteed
spec:
  containers:
  - name: guaranteed
    image: busybox
    command: ["/bin/sleep", "infinity"]
    resources:
      limits:
        memory: "64Mi"
 cpu: "50m"
  nodeName: host01

在这个例子中,只有 limits 被指定,因为如果 requests 缺失,Kubernetes 会自动将 requests 设置为与 limits 匹配。

让我们将此应用于集群:

root@host01:~# kubectl apply -f /opt/guaranteed.yaml 
pod/guaranteed created

在 Pod 运行后,验证其 QoS 类别:

root@host01:~# kubectl get po guaranteed -o json | jq .status.qosClass
"Guaranteed"

cgroup 配置看起来有点不同:

root@host01:~# /opt/cgroup-info guaranteed

Container Runtime
-----------------
Pod ID: 146...
Cgroup path: /kubepods.slice/kubepods-...

CPU Settings
------------
CPU Shares: 51
CPU Quota (us): 5000 per 100000

Memory Settings
---------------
Limit (bytes): 67108864

与其将这些容器放入单独的目录中,Guaranteed QoS 类别下的容器直接放入 kubepods.slice 中。将它们放置在这个位置的效果是,当系统负载时,会优先考虑 Guaranteed 类 Pod 中的容器,因为这些容器按个别处理器份额接收 CPU 分配,而不是按类接收。

QoS 类别驱逐

Guaranteed QoS 类别 Pod 的优先处理也扩展到了 Pod 驱逐。如第三章中所述,cgroup 对内存限制的强制执行是由 OOM killer 处理的。当节点完全耗尽内存时,OOM killer 也会运行。为了帮助 OOM killer 选择要终止的容器,Kubernetes 会根据 Pod 的 QoS 类别设置 oom_score_adj 参数。此参数的值范围从 -1000 到 1000。数值越高,OOM killer 选择终止进程的可能性就越大。

oom_score_adj 值会为每个进程记录在 /proc 中。自动化系统已添加一个名为 oom-info 的脚本,用于获取特定 Pod 的该值。让我们检查每个 QoS 类别下 Pod 的值:

root@host01:~# /opt/oom-info best-effort
OOM Score Adjustment: 1000
root@host01:~# /opt/oom-info burstable
OOM Score Adjustment: 968
root@host01:~# /opt/oom-info guaranteed
OOM Score Adjustment: -997

BestEffort QoS 类中的 Pods 具有最大调整值为 1000,因此它们会首先成为 OOM 杀手的目标。Burstable QoS 类中的 Pods 其得分是基于 requests 字段中指定的内存量计算的,作为节点总内存容量的百分比。因此,这个值对于每个 Pod 都会有所不同,但始终介于 2 和 999 之间。因此,Burstable QoS 类中的 Pods 在 OOM 杀手的优先级中始终排在第二位。与此同时,Guaranteed QoS 类中的 Pods 被设置为接近最小值,在本例中为 -997,因此它们会尽可能避免被 OOM 杀手终止。

当然,正如 第三章 中提到的,OOM 杀手会立即终止一个进程,因此它是一种极端的措施。当节点上的内存不足但尚未耗尽时,Kubernetes 会尝试驱逐 Pods 以回收内存。这个驱逐过程也根据 QoS 类进行优先级排序。BestEffort 类中的 Pods 和使用超过其 requests 值的 Burstable 类 Pods(高使用 Burstable)是最先被驱逐的,其次是使用低于其 requests 值的 Burstable 类 Pods(低使用 Burstable)和 Guaranteed 类中的 Pods。

在继续之前,让我们做一些清理:

root@host01:~# kubectl delete po/best-effort po/burstable po/guaranteed
pod "best-effort" deleted
pod "burstable" deleted
pod "guaranteed" deleted

现在我们可以在本章稍后再看一下 Pod 优先级时从头开始。

选择 QoS 类

鉴于处理时间和驱逐优先级的这一优先顺序,可能会想将所有 Pods 都放在 Guaranteed QoS 类中。对于某些应用组件来说,这是一个可行的策略。如 第七章 所述,我们可以配置一个 HorizontalPodAutoscaler,当现有实例消耗了它们分配资源的显著比例时,自动创建新的 Pod 实例。这意味着我们可以为 Deployment 中的 Pods 请求一个合理的 limits 值,并允许集群在这些 Pods 接近限制时自动扩展 Deployment。如果集群运行在云环境中,我们甚至可以将自动扩展扩展到节点级别,在负载高时动态创建新的集群节点,在集群空闲时减少节点数量。

仅使用Guaranteed Pod 配合自动扩展听起来不错,但这假设我们的应用组件是容易扩展的。它也只有在我们的应用负载由许多小请求组成时才有效,这样负载增加主要意味着我们正在处理来自更多用户的类似大小的请求。如果我们的应用组件周期性地处理大或复杂的请求,我们必须为这些组件设置limits,以应对最坏情况。考虑到Guaranteed QoS 类中的 Pod 具有requests等于limits,我们的集群需要足够的资源来处理这个最坏情况,否则我们甚至无法调度我们的 Pod。这将导致集群在没有达到最大负载时大部分处于空闲状态。同样,如果我们有扩展性限制,如依赖于专业硬件,我们可能会对可以为某个组件创建的 Pod 数量有自然限制,从而迫使每个 Pod 拥有更多资源来处理其在整体负载中的份额。

因此,平衡使用GuaranteedBurstable QoS 类对我们的 Pod 来说是有意义的。任何负载稳定,或者可以通过水平扩展来满足额外需求的 Pod,应该使用Guaranteed类。那些更难以扩展,或者需要处理大负载和小负载混合的 Pod,应该使用Burstable类。这些 Pod 应该根据其平均利用率来指定requests,并根据其最坏情况来指定limits。以这种方式指定资源需求,将确保集群的预期性能边际可以通过简单地将分配的资源与集群容量进行比较来进行监控。最后,如果一个大请求导致多个应用组件同时以最坏情况的利用率运行,那么可能值得进行性能测试,并探索反亲和性,如第十八章所述,以避免过载单个节点。

Pod 优先级

除了使用提示帮助 Kubernetes 集群理解在系统高度负载时如何管理 Pods,还可以直接告诉集群为某些 Pods 分配比其他 Pods 更高的优先级。在 Pod 驱逐时,这种更高的优先级适用,因为 Pods 会根据其 QoS 类内的优先级顺序被驱逐。它在调度时也适用,因为 Kubernetes 调度器会在必要时驱逐 Pod,以便调度一个优先级更高的 Pod。

Pod 优先级是一个简单的数字字段;数字越大,优先级越高。大于十亿的数字保留给关键系统 Pod。为了为 Pod 分配优先级,我们必须首先创建一个PriorityClass资源。以下是一个示例:

essential.yaml

---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: essential
value: 999999

让我们将其应用到集群中:

root@host01:~# kubectl apply -f /opt/essential.yaml 
priorityclass.scheduling.k8s.io/essential created

现在这个 PriorityClass 已经定义完毕,我们可以将其应用到 Pods。不过,首先让我们创建大量低优先级的 Pods,通过这些 Pods,我们可以看到 Pods 被抢占。我们将使用这个 Deployment:

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: lots
spec:
  replicas: 1000
  selector:
    matchLabels:
      app: lots
  template:
    metadata:
      labels:
        app: lots
    spec:
      containers:
      - name: sleep
        image: busybox
        command: ["/bin/sleep", "infinity"]
        resources:
          limits:
            memory: "64Mi"
            cpu: "250m"

这是一个基本的 Deployment,运行 sleep,并且没有请求太多内存或 CPU,但它将 replicas 设置为 1000,所以我们要求 Kubernetes 集群创建 1,000 个 Pods。示例集群的规模不足以部署 1,000 个 Pods,因为我们没有足够的资源来满足这个规格,而且每个节点默认最多只能调度 110 个 Pods。不过,还是让我们将它应用到集群中,如清单 19-1 所示,调度器会创建尽可能多的 Pods:

root@host01:~# kubectl apply -f /opt/lots.yaml 
deployment.apps/lots created

清单 19-1:部署大量 Pods

让我们描述一下 Deployment,看看情况如何:

root@host01:~# kubectl describe deploy lots
Name:                   lots
Namespace:              default
...
Replicas:               1000 desired ... | 7 available | 993 unavailable
...

由于集群基础设施组件已经运行了一些 Pods,我们的示例集群仅能容纳七个 Pods。不幸的是,这就是我们能得到的所有 Pods:

root@host01:~# kubectl describe node host01
Name:               host01
  (Total limits may be over 100 percent, i.e., overcommitted.)
Allocated resources:
...
  Resource           Requests     Limits
  --------           --------     ------
  cpu             ➊ 1898m (94%)  768m (38%)
  memory             292Mi (15%)  192Mi (10%)
  ephemeral-storage  0 (0%)       0 (0%)
  hugepages-2Mi      0 (0%)       0 (0%)
...

host01 的数据表明,我们已经分配了 94% 的可用 CPU ➊。但是我们的每个 Pod 请求 250 毫核心,所以没有足够的容量来调度另一个 Pod 到这个节点。其他两个节点也处于类似情况,没有足够的 CPU 容量来调度更多 Pods。不过,集群的运行状况非常良好。理论上,我们已经分配了所有的处理能力,但那些容器仅仅在运行 sleep,因此它们实际上并没有使用很多 CPU。

同时,重要的是要记住,requests 字段用于调度,因此尽管我们有一些基础设施 BestEffort Pods,它们指定了 requests 但没有 limits,而且我们这个节点上有足够的 Limits 容量,但我们依然没有空间调度新的 Pods。只有 Limits 可以超配,Requests 不能。

由于我们没有更多的 CPU 来分配给 Pods,Deployment 中剩余的 Pods 都卡在了 Pending 状态:

root@host01:~# kubectl get po | grep -c Pending
993

这 993 个 Pods 都有默认的 pod 优先级 0。因此,当我们使用 essential PriorityClass 创建一个新 Pod 时,它将排到调度队列的前面。不仅如此,集群还会根据需要驱逐 Pods,以便让它能够被调度。

这是 Pod 定义:

needed.yaml

---
apiVersion: v1
kind: Pod
metadata:
  name: needed
spec:
  containers:
  - name: needed
    image: busybox
    command: ["/bin/sleep", "infinity"]
    resources:
      limits:
        memory: "64Mi"
        cpu: "250m"
  priorityClassName: essential

这里的关键区别是 priorityClassName 的指定,它与我们创建的 PriorityClass 匹配。让我们将其应用到集群中:

root@host01:~# kubectl apply -f /opt/needed.yaml 
pod/needed created

集群需要一些时间来驱逐另一个 Pod,以便为这个 Pod 调度,但大约一分钟后它将开始运行:

root@host01:~# kubectl get po needed
NAME     READY   STATUS    RESTARTS   AGE
needed   1/1     Running   0          36s

为了让这一切发生,我们在清单 19-1 中创建的 lots Deployment 中的一个 Pod 必须被驱逐:

root@host01:~# kubectl describe deploy lots
Name:                   lots
Namespace:              default
CreationTimestamp:      Fri, 01 Apr 2022 19:20:52 +0000
Labels:                 <none>
Annotations:            deployment.kubernetes.io/revision: 1
Selector:               app=lots
Replicas:               1000 desired ... | ➊ 6 available | 994 unavailable

现在在部署中只剩下六个 Pod ➊,因为有一个 Pod 被驱逐。值得注意的是,处于Guaranteed QoS 类别并没有防止该 Pod 被驱逐。Guaranteed QoS 类别在节点资源使用导致的驱逐中有优先权,但在调度器为更高优先级的 Pod 找到空间时,不能阻止驱逐。

当然,指定 Pod 的更高优先级,从而驱逐其他 Pod 的能力是非常强大的,应该谨慎使用。普通用户没有能力创建新的 PriorityClass,管理员可以为给定的命名空间应用配额,以限制 PriorityClass 的使用,实质上限制普通用户创建高优先级的 Pod。

最后的思考

将应用部署到 Kubernetes 上,使其既高效又可靠,需要理解应用架构以及每个组件的正常负载和最坏情况下的负载。Kubernetes QoS 类别允许我们塑造 Pod 部署到节点的方式,以在资源使用的可预测性和效率之间实现平衡。此外,QoS 类别和 Pod 优先级都可以为 Kubernetes 集群提供提示,以便在集群负载过高时,部署的应用能够优雅降级。

在下一章,我们将整合如何最好地利用 Kubernetes 集群的特性来部署高性能、具韧性的应用的想法。我们还将探讨如何监控这些应用,并自动响应行为变化。

第二十章:应用程序弹性

image

在本书的过程中,我们已经看到容器和 Kubernetes 如何实现可扩展的、具有弹性的应用程序。通过使用容器,我们可以将应用程序组件封装起来,使得进程相互隔离,拥有独立的虚拟化网络堆栈和独立的文件系统。然后,每个容器可以快速部署,而不会干扰其他容器。当我们在容器运行时之上添加 Kubernetes 作为容器编排层时,我们能够将多个独立的主机合并为一个集群,动态调度容器到可用的集群节点,支持自动扩展和故障转移、分布式网络、流量路由、存储和配置。

本书中我们看到的所有容器和 Kubernetes 特性协同工作,为部署可扩展且具备弹性的应用程序提供了必要的基础设施,但要利用这些基础设施,我们需要正确配置我们的应用程序。在本章中,我们将再次回顾我们在第一章中部署的 todo 应用程序。不过这次,我们将其部署到 Kubernetes 集群中的多个节点上,从而消除单点故障,并利用 Kubernetes 提供的关键功能。我们还将探讨如何监控我们的 Kubernetes 集群和已部署的应用程序的性能,以便在问题导致用户停机之前识别性能瓶颈。

示例应用栈

在第一章中,我们将 todo 部署到运行 k3s(由 Rancher 提供)的 Kubernetes 集群上。我们已经具备了一定的可扩展性和故障转移功能。Web 层基于 Deployment,因此我们可以通过单个命令来扩展服务器实例的数量。我们的 Kubernetes 集群会监控这些实例,以便在实例失败时进行替换。然而,我们仍然存在一些单点故障问题。我们尚未引入高可用 Kubernetes 控制平面的概念,因此选择仅在单节点配置下运行 k3s。此外,尽管我们为 PostgreSQL 数据库使用了 Deployment,但它缺乏任何必要的高可用性配置。在本章中,我们将看到如何纠正这些限制,并利用我们所学到的其他 Kubernetes 特性。

数据库

让我们从部署一个高可用的 PostgreSQL 数据库开始。第十七章展示了 Kubernetes Operator 设计模式如何使用自定义资源定义(CustomResourceDefinitions)来扩展集群的行为,使得打包和部署高级功能变得更加容易。我们将使用在那一章中介绍的 Postgres Operator 来部署我们的数据库。

注意

本书的示例代码库在 github.com/book-of-kubernetes/examples有关设置的详细信息,请参见第 xx 页中的“运行示例”。本章使用了一个更大的六节点集群,以为应用程序和我们将要部署的所有监控组件提供空间。有关更多信息,请参阅本章的 README.md 文件。

本章的自动化已部署了 Postgres Operator 及其配置。你可以通过查看 /etc/kubernetes/components 目录中的文件来检查 Postgres Operator 及其配置。该 Operator 正在 todo 命名空间中运行,todo 应用程序也已在该命名空间中部署。许多 Operator 更倾向于在自己的命名空间中运行并跨集群操作,但 Postgres Operator 设计为直接部署到数据库所在的命名空间中。

因为我们使用的是 Postgres Operator,所以可以通过向集群应用自定义资源来创建一个高可用的 PostgreSQL 数据库:

database.yaml

 ---
 apiVersion: "acid.zalan.do/v1"
 kind: postgresql
 metadata:
➊ name: todo-db
 spec:
   teamId: todo
   volume:
     size: 1Gi
     storageClass: longhorn
➋ numberOfInstances: 3
   users:
  ➌ todo:
     - superuser
     - createdb
   databases:
  ➍ todo: todo
   postgresql:
     version: "14"

本文演示的所有文件都已被放置在 /etc/kubernetes/todo 目录中,方便你进行探索并尝试修改。todo 应用程序已自动部署,但所有组件可能需要几分钟才能达到健康状态。

Postgres Operator 的职责是创建 Secrets、StatefulSets、Services 以及部署 PostgreSQL 所需的其他核心 Kubernetes 资源。我们只需要提供它应该使用的配置。我们首先确定数据库的名称为 todo-db ➊,这个名称将作为连接到主数据库实例的主要 Service 的名称,因此我们将在应用程序配置中再次看到这个名称。

我们需要一个高可用的数据库,因此我们指定了三个实例 ➋。我们还要求 Postgres Operator 创建一个 todo 用户 ➌,并使用 todo 用户作为所有者创建一个 todo 数据库 ➍。这样,我们的数据库就已经设置好了,我们只需要填充表格以存储应用程序数据。

我们可以验证数据库是否在集群中运行:

root@host01:~# kubectl -n todo get sts
NAME      READY   AGE
todo-db   3/3     6m1s

todo-db StatefulSet 有三个 Pod,所有 Pod 均已准备就绪。

由于 Postgres Operator 使用 StatefulSet,如我们在第十五章中所见,数据库实例创建时会为其分配 PersistentVolumeClaim:

root@host01:~# kubectl -n todo get pvc
NAME               STATUS   ... CAPACITY   ACCESS MODES   STORAGECLASS   AGE
pgdata-todo-db-0   Bound    ... 1Gi        RWO            longhorn       10m
pgdata-todo-db-1   Bound    ... 1Gi        RWO            longhorn       8m44s
pgdata-todo-db-2   Bound    ... 1Gi        RWO            longhorn       7m23s

这些 PersistentVolumeClaims 会在数据库实例 Pod 出现故障并需要重新创建时被重新使用,且 Longhorn 存储引擎正在将存储分布在整个集群中,因此即使我们遇到节点故障,数据库仍能保持应用程序数据。

请注意,当我们要求 Postgres 操作员创建todo用户时,并没有指定密码。出于安全考虑,Postgres 操作员会自动生成密码。此密码会放入一个 Secret 中,名称基于用户和数据库的名称。我们可以看到为todo用户创建的 Secret:

root@host01:~# kubectl -n todo get secret
NAME                                                    TYPE    DATA   AGE
...
todo.todo-db.credentials.postgresql.acid.zalan.do       Opaque  2      8m30s

我们需要使用这些信息来配置应用,使其能够进行数据库身份验证。

在查看应用配置之前,让我们检查一下 Postgres 操作员创建的服务:

root@host01:~# kubectl -n todo get svc todo-db
NAME      TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
todo-db   ClusterIP   10.110.227.34   <none>        5432/TCP   59m

这是一个ClusterIP服务,这意味着它可以在集群内部的任何地方访问,但不会对外暴露。这完全符合我们应用的需求,因为我们的 Web 服务组件是唯一对外暴露的组件,因此是唯一会暴露到集群外的。

应用部署

我们应用的所有数据都存储在 PostgreSQL 数据库中,因此 Web 服务器层是无状态的。对于这个无状态组件,我们将使用部署并设置自动扩展。

部署包含大量信息,让我们一步一步来看。要查看整个部署配置并了解其如何组合在一起,您可以查看集群节点上的文件/etc/kubernetes/todo/application.yaml

第一部分告诉 Kubernetes 我们正在创建一个部署:

---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: todo
  labels:
    app: todo

这一部分很简单,因为我们只是指定了部署的元数据。请注意,我们没有在元数据中包含namespace。相反,当我们将此部署应用到集群时,会直接将其提供给 Kubernetes。这样,我们可以在开发、测试和生产版本中重复使用相同的部署 YAML,并将每个版本放在不同的命名空间中,以避免冲突。

label字段纯粹是信息性字段,不过它也为我们提供了一种方法,通过匹配标签查询集群中与此应用相关的所有资源。

部署 YAML 的下一部分指定了集群应如何处理更新:

spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 30%
      maxSurge: 50%

replicas字段告诉 Kubernetes 要初始创建多少个实例。自动扩展配置会自动调整这个数量。

strategy字段允许我们配置此部署以便在没有应用停机的情况下进行更新。我们可以选择RollingUpdateRecreate作为策略。使用Recreate时,当部署发生变化时,所有现有的 Pods 都会被终止,然后创建新的 Pods。而使用RollingUpdate时,新的 Pods 会立即创建,旧的 Pods 会继续运行,以确保在更新过程中应用组件可以继续运行。

我们可以使用maxUnavailablemaxSurge字段控制滚动更新的操作方式,这些字段可以指定为整数值或当前副本数量的百分比。在这种情况下,我们为maxUnavailable指定了 30%,因此 Deployment 将限制滚动更新过程,以防止我们低于当前副本数的 70%。此外,由于我们将maxSurge设置为 50%,Deployment 将在新的 Pod 启动时,直到正在运行或创建过程中的 Pod 数量达到当前副本数的 150%。

RollingUpdate策略是默认策略,默认情况下,maxSurgemaxUnavailable均为 25%。除非必须使用Recreate,否则大多数 Deployment 应该使用RollingUpdate策略。

Deployment YAML 的下一部分将 Deployment 与其 Pod 关联起来:

  selector:
    matchLabels:
      app: todo
  template:
 metadata:
      labels:
        app: todo

Pod metadata中的selectorlabels必须匹配。正如我们在第七章中看到的,Deployment 使用selector来跟踪其 Pod。

在这一部分,我们现在开始定义此 Deployment 所创建 Pod 的template。Deployment YAML 的其余部分完成了 Pod 模板,完全由此 Pod 运行的单个容器的配置组成:

    spec:
      containers:
      - name: todo
        image: bookofkubernetes/todo:stable

容器名称主要是信息性的,尽管它对于有多个容器的 Pod 来说是必需的,这样我们可以在需要检索日志和使用exec运行命令时选择一个容器。image告诉 Kubernetes 需要获取哪个容器镜像来运行该容器。

Pod 模板的下一部分指定了此容器的环境变量:

        env:
        - name: NODE_ENV
          value: production
        - name: PREFIX
          value: /
        - name: PGHOST
          value: todo-db
        - name: PGDATABASE
          value: todo
        - name: PGUSER
          valueFrom:
            secretKeyRef:
              name: todo.todo-db.credentials.postgresql.acid.zalan.do
              key: username
              optional: false
        - name: PGPASSWORD
          valueFrom:
            secretKeyRef:
              name: todo.todo-db.credentials.postgresql.acid.zalan.do
              key: password
              optional: false

一些环境变量具有静态值;它们预计在所有使用此 Deployment 的实例中保持不变。PGHOST环境变量与 PostgreSQL 数据库的名称匹配。Postgres Operator 在todo命名空间中创建了一个名为todo-db的 Service,在这些 Pod 运行的地方,因此 Pod 能够将此主机名解析为 Service 的 IP 地址。目标为 Service IP 地址的流量随后将通过我们在第九章中看到的iptables配置路由到主 PostgreSQL 实例。

最后的两个变量提供了应用程序用于认证数据库的凭证。我们使用从 Secret 中获取配置并将其作为环境变量提供给容器的功能,类似于我们在第十六章中看到的。然而,在这种情况下,我们需要环境变量的名称与 Secret 中的键名称不同,因此我们使用了一种稍微不同的语法,允许我们分别指定每个变量的名称。

最后,我们声明了此容器的资源需求和它暴露的端口:

        resources:
          requests:
            memory: "128Mi"
            cpu: "50m"
          limits:
            memory: "128Mi"
            cpu: "50m"
        ports:
        - name: web
          containerPort: 5000

ports字段在 Pod 中纯粹是信息性的;实际的流量路由将在 Service 中配置。

resources字段中,我们将requestslimits设置为该容器相同的值。正如我们在第十九章中看到的,这意味着 Pod 将被放置在Guaranteed服务质量类中。由于 Web 服务组件是无状态的且易于扩展,因此使用相对较低的 CPU 限制是合理的,在此情况下为 50 毫核心,即一个核心的 5%,并依赖自动扩展来创建新的实例,以应对负载增大时的需求。

Pod 自动扩展

为了自动扩展部署以匹配当前负载,我们使用了水平 Pod 自动扩展器(HorizontalPodAutoscaler),正如我们在第七章中看到的那样。这是自动扩展器的配置:

scaler.yaml

---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: todo
  labels:
    app: todo
spec:
 scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: todo
  minReplicas: 3
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 50

如我们之前的示例所示,我们为该资源应用标签,纯粹是为了信息目的。这个自动扩展器需要三个关键配置项。首先,scaleTargetRef指定了我们希望扩展todo部署。由于这个自动扩展器部署在todo命名空间中,它会找到正确的部署进行扩展。

其次,我们指定minReplicasmaxReplicas的范围。我们选择3作为最小副本数,因为我们希望即使发生 Pod 故障,应用程序也能保持弹性。为了简化,我们没有应用我们在第十八章中看到的反亲和性配置,但这也是一种避免所有实例都部署在单个节点上的良好实践。我们根据集群的大小选择最大副本数;对于生产环境的应用程序,我们会根据预计处理的最大负载来选择副本数。

第三,我们需要指定自动扩展器将用来决定需要多少副本的指标。我们基于 CPU 利用率来配置这个自动扩展器。如果 Pod 的平均利用率超过 Pod requests的 50%,部署将会扩展。我们将requests设置为 50 毫核心,这意味着平均利用率超过 25 毫核心将导致自动扩展器增加副本数。

为了获取平均 CPU 利用率,自动扩展器依赖于一个集群基础设施组件,该组件从每个节点上运行的kubelet服务中获取度量数据,并通过 API 暴露这些度量数据。对于本章,我们有一些额外的集群监控功能要展示,所以自动化跳过了我们在第六章中描述的常规度量服务器组件。我们将在本章稍后部署一个替代方案。

应用程序服务

我们应用程序的最终集群资源是服务。列表 20-1 展示了我们在本章中使用的定义。

service.yaml

---
kind: Service
apiVersion: v1
metadata:
  name: todo
  labels:
    app: todo
spec:
  type: NodePort
  selector:
    app: todo
  ports:
  - name: web
    protocol: TCP
    port: 5000
    nodePort: 5000

列表 20-1:Todo 服务

我们使用与在部署中看到的相同的selector来查找将接收发送到该服务的流量的 Pods。正如我们在第九章中看到的,服务的ports字段至关重要,因为iptables流量路由规则仅为我们指定的端口配置。在这种情况下,我们声明port为 5000,并未声明targetPort,因此此服务将流量发送到 Pods 的 5000 端口,这与我们的 Web 服务器监听的端口相匹配。我们还为这个端口配置了一个name,这在稍后配置监控时会很重要。

本章中,我们通过NodePort暴露了我们的应用程序服务,这意味着我们集群中的所有节点都将被配置为将流量路由到发送到任何主机接口的nodePort的服务。因此,我们可以访问集群中任何节点的 5000 端口,流量会被路由到我们的应用程序:

root@host01:~# curl -v http://host01:5000/
...
< HTTP/1.1 200 OK
< X-Powered-By: Express
...
<html lang="en" data-framework="backbonejs">
    <head>
        <meta charset="utf-8">
        <title>Todo-Backend client</title>
        <link rel="stylesheet" href="css/vendor/todomvc-common.css">
        <link rel="stylesheet" href="css/chooser.css">
    </head>
...
</html>

这个服务流量路由在任何主机接口上都能工作,因此todo应用程序也可以从集群外部访问。URL 的不同取决于你使用的是 Vagrant 配置还是 Amazon Web Services 配置,因此本章的自动化包括一条消息,告知使用的 URL。

NODEPORT,而非 INGRESS

当我们在第一章中部署todo时,我们使用 Ingress 暴露了服务。正如我们在第九章中看到的,Ingress 将多个服务整合在一起,使它们都可以在不要求每个服务有单独外部可路由 IP 地址的情况下暴露到集群外部。我们将在本章稍后暴露一个监控服务,因此我们需要暴露多个服务到集群外部。然而,由于我们正在使用私有网络上的示例集群,我们没有可用的底层网络基础设施来充分利用 Ingress。通过改为使用NodePort,我们能够以一种既适用于 Vagrant 配置又适用于 Amazon Web Services 配置的方式,将多个服务暴露到集群外部。

现在我们已经查看了todo应用程序中的所有组件,利用本书中学到的知识消除了单点故障并最大化了可扩展性。

你还可以在* github.com/book-of-kubernetes/todo 上查看todo应用程序的源代码,其中包括用来构建应用程序容器镜像的Dockerfile*,以及每当代码发生更改时,自动构建并发布到 Docker Hub 的 GitHub Actions。

然而,尽管我们的 Kubernetes 集群现在会尽最大努力保持此应用程序的运行和性能,我们仍可以做更多工作来监控todo应用程序和 Kubernetes 集群。

应用程序和集群监控

适当的应用和集群监控对应用程序至关重要,原因有很多。首先,我们的 Kubernetes 集群将尽力保持应用程序运行,但任何硬件或集群故障都可能导致应用程序处于无法正常工作或降级的状态。如果没有监控,我们将依赖用户告诉我们应用程序何时出现故障或表现异常,这样的用户体验很差。其次,如果我们确实看到应用程序出现故障或性能问题,我们需要数据来诊断问题,或者试图识别某种模式以找到根本原因。提前构建监控要比在我们已经看到问题后再去应用它要容易得多。最后,我们可能会遇到一些集群或应用程序的问题,这些问题发生在用户未察觉的层面,但它们可能预示着潜在的性能或稳定性问题。集成适当的监控使我们能够在这些问题变得更严重之前发现它们。它还使我们能够随着时间的推移衡量应用程序,确保新增的功能不会降低其性能。

幸运的是,尽管我们确实需要在每个应用组件的层面上考虑监控,但我们不需要自己构建监控框架。许多成熟的监控工具已经设计好,可以在 Kubernetes 集群中工作,因此我们可以快速启动并运行。在本章中,我们将介绍kube-prometheus,这是一个完整的工具栈,我们可以将其部署到集群中,用于监控集群和todo应用程序。

Prometheus 监控

kube-prometheus的核心组件是,顾名思义,开源的 Prometheus 监控软件。Prometheus 作为服务器部署,定期查询各种指标源并累积它收到的数据。它支持一种优化为“时间序列”数据的查询语言,使得收集显示系统在某一时刻性能的单个数据点变得容易。然后,它将这些数据点汇总,绘制出系统负载、资源利用率和响应能力的图景。

对于每个暴露指标的组件,Prometheus 期望访问一个 URL 并返回标准格式的数据。通常使用路径/metrics来暴露指标给 Prometheus。遵循这一约定,Kubernetes 控制平面组件已经以 Prometheus 期望的格式暴露了指标。

举个例子,我们可以使用curl访问 API 服务器上的/metrics路径,以查看它提供的指标。为了实现这一点,我们需要进行 API 服务器的身份验证,因此让我们使用一个脚本来收集客户端证书以进行身份验证:

api-metrics.sh

#!/bin/bash
conf=/etc/kubernetes/admin.conf
...
curl --cacert $ca --cert $cert --key $key https://192.168.61.10:6443/metrics
...

运行此脚本会返回大量的 API 服务器指标:

root@host01:~# /opt/api-server-metrics.sh
...
# TYPE rest_client_requests_total counter
rest_client_requests_total{code="200",host="[::1]:6443",method="GET"} 9051
rest_client_requests_total{code="200",host="[::1]:6443",method="PATCH"} 25
rest_client_requests_total{code="200",host="[::1]:6443",method="PUT"} 21
rest_client_requests_total{code="201",host="[::1]:6443",method="POST"} 179
rest_client_requests_total{code="404",host="[::1]:6443",method="GET"} 155
rest_client_requests_total{code="404",host="[::1]:6443",method="PUT"} 1
rest_client_requests_total{code="409",host="[::1]:6443",method="POST"} 5
rest_client_requests_total{code="409",host="[::1]:6443",method="PUT"} 62
rest_client_requests_total{code="500",host="[::1]:6443",method="GET"} 18
rest_client_requests_total{code="500",host="[::1]:6443",method="PUT"} 1
...

这个示例仅说明了收集和暴露的数百个指标中的一小部分。此响应的每一行都提供一个数据点给 Prometheus。我们可以在大括号中包含附加的指标参数,以便进行更复杂的查询。例如,前面示例中的 API 服务器数据可以用来确定 API 服务器处理的客户端请求总数,以及导致错误的请求的原始数量和百分比。大多数系统能应对少量的 HTTP 错误响应,但错误响应的突然增加通常是更严重问题的良好指示,因此在配置报告阈值时,这非常有价值。

除了 Kubernetes 集群已经提供给 Prometheus 的所有数据外,我们还可以配置我们的应用程序来暴露指标。我们的应用程序基于 Node.js,因此我们使用prom-client库来完成此操作。如清单 20-2 所示,我们的todo应用程序在/metrics处暴露指标,类似于 API 服务器。

root@host01:~# curl http://host01:5000/metrics/
# HELP api_success Successful responses
# TYPE api_success counter
api_success{app="todo"} 0

# HELP api_failure Failed responses
# TYPE api_failure counter
api_failure{app="todo"} 0
...
# HELP process_cpu_seconds_total Total user and system CPU time ...
# TYPE process_cpu_seconds_total counter
process_cpu_seconds_total{app="todo"} 0.106392
...

清单 20-2:待办事项指标

响应包括一些与所有应用程序相关的默认指标。它还包括一些特定于todo应用程序的计数器,用于跟踪 API 的使用情况和响应时间。

部署 kube-prometheus

到此为止,我们的 Kubernetes 集群和应用程序已经准备好根据需求提供这些指标,但我们还没有在集群中运行 Prometheus 服务器来收集它们。为了解决这个问题,我们将部署完整的kube-prometheus堆栈。它不仅包括一个 Prometheus 操作员,简化了 Prometheus 的部署和配置,还包括其他有用的工具,如 Alertmanager,它可以响应集群和应用程序的警报触发通知,以及 Grafana,这是一个我们将用来查看收集的指标的仪表盘工具。

要部署kube-prometheus,我们将使用一个已安装在/opt中的脚本。这个脚本从 GitHub 下载当前的kube-prometheus版本并应用清单。

按照以下方式运行脚本:

root@host01:~# /opt/install-kube-prometheus.sh
...

这些清单还包括一个 Prometheus 适配器。Prometheus 适配器实现了与我们在第二部分中部署到集群的metrics-server相同的 Kubernetes 指标 API,因此它暴露了从kubelet获取的 CPU 和内存数据,使我们的 HorizontalPodAutoscaler 能够跟踪todo应用程序的 CPU 利用率。然而,它还将这些利用率数据暴露给 Prometheus,以便我们在 Grafana 仪表盘中查看它。正因为如此,在本章中我们使用 Prometheus 适配器来替代常规的metrics-server

我们可以通过列出monitoring命名空间中的 Pods 来查看 Prometheus 适配器和其他组件:

root@host01:~# kubectl -n monitoring get pods
NAME                                   READY   STATUS    RESTARTS   AGE
alertmanager-main-0                    2/2     Running   0          14m
alertmanager-main-1                    2/2     Running   0          14m
alertmanager-main-2                    2/2     Running   0          14m
blackbox-exporter-6b79c4588b-pgp5r     3/3     Running   0          15m
grafana-7fd69887fb-swjpl               1/1     Running   0          15m
kube-state-metrics-55f67795cd-mkxqv    3/3     Running   0          15m
node-exporter-4bhhp                    2/2     Running   0          15m
node-exporter-8mc5l                    2/2     Running   0          15m
node-exporter-ncfd2                    2/2     Running   0          15m
node-exporter-qp7mg                    2/2     Running   0          15m
node-exporter-rtn2t                    2/2     Running   0          15m
node-exporter-tpg97                    2/2     Running   0          15m
prometheus-adapter-85664b6b74-mglp4    1/1     Running   0          15m
prometheus-adapter-85664b6b74-nj7hp    1/1     Running   0          15m
prometheus-k8s-0                       2/2     Running   0          14m
prometheus-k8s-1                       2/2     Running   0          14m
prometheus-operator-6dc9f66cb7-jtrqd   2/2     Running   0          15m

除了 Prometheus 适配器外,我们还看到 Alertmanager、Grafana 和各种 exporter Pod,这些 Pod 从集群基础设施中收集指标并将其暴露给 Prometheus。我们还看到了 Prometheus 本身和 Prometheus Operator 的 Pod。每当我们更改 Prometheus Operator 所监控的自定义资源时,Prometheus Operator 会自动更新 Prometheus。最重要的自定义资源是清单 20-3 中所示的 Prometheus 资源。

root@host01:~# kubectl -n monitoring describe prometheus
Name:         k8s
Namespace:    monitoring
...
API Version:  monitoring.coreos.com/v1
Kind:         Prometheus
...
Spec:
...
  Image:  quay.io/prometheus/prometheus:v2.32.1
...
  Service Account Name:  prometheus-k8s
  Service Monitor Namespace Selector:
  Service Monitor Selector:
...

清单 20-3:Prometheus 配置

Prometheus 自定义资源允许我们配置哪些命名空间中的服务需要被监控。在清单 20-3 中展示的默认配置并没有为 Service Monitor Namespace Selector 或 Service Monitor Selector 指定值。因此,默认情况下,Prometheus Operator 会在所有命名空间中查找监控配置,且没有任何元数据标签。

为了识别要监控的特定服务,Prometheus Operator 会监视另一个自定义资源 ServiceMonitor,正如在清单 20-4 中所示。

root@host01:~# kubectl -n monitoring get servicemonitor
NAME                      AGE
alertmanager-main         20m
blackbox-exporter         20m
coredns                   20m
grafana                   20m
kube-apiserver            20m
kube-controller-manager   20m
kube-scheduler            20m
kube-state-metrics        20m
kubelet                   20m
node-exporter             20m
prometheus-adapter        20m
prometheus-k8s            20m
prometheus-operator       20m

清单 20-4:默认 ServiceMonitors

当我们安装 kube-prometheus 时,它配置了多个 ServiceMonitor 资源。因此,我们的 Prometheus 实例已经在监控 Kubernetes 控制平面组件和在集群节点上运行的 kubelet 服务。让我们看看 Prometheus 从哪些目标中抓取指标,并查看这些指标是如何用于填充 Grafana 中的仪表板的。

集群指标

安装脚本修改了 monitoring 命名空间中 Grafana 和 Prometheus 服务,将其暴露为 NodePort 服务。自动化脚本会打印出可以用来访问 Prometheus 的 URL。初始页面如下所示图 20-1。

图片

图 20-1:Prometheus 初始页面

点击顶部菜单栏中 状态 菜单下的 目标 项,查看 Prometheus 当前正在抓取集群中的哪些组件。点击 折叠所有,以获取汇总列表,如图 20-2 所示。

图片

图 20-2:Prometheus 目标

这个列表与我们在清单 20-4 中看到的 ServiceMonitors 列表匹配,向我们展示了 Prometheus 正在按照 Prometheus Operator 配置的方式抓取服务。

我们可以使用 Prometheus Web 界面直接查询数据,但 Grafana 已经配置了一些有用的仪表板,因此我们可以更轻松地在其中查看数据。自动化脚本会打印出可以用来访问 Grafana 的 URL。使用默认的 admin 作为用户名,admin 作为密码登录。系统会提示你更改密码;你可以直接点击 跳过。此时,你应该看到 Grafana 的初始页面,如图 20-3 所示。

图片

图 20-3:Grafana 初始页面

从这个页面中,选择菜单中的浏览选项。在默认文件夹中有许多仪表盘。例如,通过选择默认,然后选择Kubernetes计算资源Pod,你可以看到一个仪表盘,如图 20-4 所示,展示了集群中任何 Pod 随时间变化的 CPU 和内存使用情况。

Image

图 20-4:Pod 计算资源

在这个仪表盘中,所有的todo数据库和应用 Pod 都可以选择,首先选择todo命名空间,这样我们就可以通过使用默认监控配置来获取关于我们应用的宝贵信息。这是可能的,因为 Prometheus 适配器正在从kubelet服务拉取数据,这些数据包括每个运行中的 Pod 的资源利用情况。然后,Prometheus 适配器暴露了一个/metrics端点供 Prometheus 抓取和存储,而 Grafana 则查询 Prometheus 来构建显示随时间变化的使用情况图表。

kube-prometheus的默认安装中,还有许多其他 Grafana 仪表盘可以探索。再次选择浏览菜单项,选择其他仪表盘,查看可用的数据。

添加服务监控

尽管我们已经获得了关于todo应用程序的有用指标,但 Prometheus 尚未抓取我们的应用 Pod 以提取我们在清单 20-2 中看到的 Node.js 指标。为了配置 Prometheus 抓取todo的指标,我们需要向 Prometheus Operator 提供一个新的 ServiceMonitor 资源,告诉它有关我们todo服务的信息。

在生产集群中,像我们todo应用程序这样的应用部署团队通常没有权限在monitoring命名空间中创建或更新资源。然而,Prometheus Operator 默认会在所有命名空间中查找 ServiceMonitor 资源,因此我们可以在todo命名空间中创建一个 ServiceMonitor。

不过,首先我们需要授权 Prometheus 查看我们在todo命名空间中创建的 Pods 和 Services。由于此访问控制配置只需应用于单一命名空间,我们将通过创建一个 Role 和 RoleBinding 来实现。以下是我们将使用的 Role 配置:

rbac.yaml

---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
...
  name: prometheus-k8s
rules:
  - apiGroups:
    - ""
    resources:
    - services
    - endpoints
    - pods
    verbs:
    - get
    - list
    - watch
...

我们需要确保允许访问 Services、Pods 和 Endpoints,因此我们确认这些资源列在resources字段中。Endpoint 资源记录了当前接收流量的 Pod,这对 Prometheus 识别它抓取的所有 Pod 至关重要。由于 Prometheus 只需要只读权限,我们只指定getlistwatch操作符。

拥有这个角色后,我们需要将其绑定到 Prometheus 使用的 ServiceAccount 上。我们可以通过这个 RoleBinding 来完成:

rbac.yaml

---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
...
  name: prometheus-k8s
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: prometheus-k8s
subjects:
  - kind: ServiceAccount
    name: prometheus-k8s
    namespace: monitoring

roleRef与我们在前面的示例中声明的 Role 相匹配,而subjects字段列出了 Prometheus 正在使用的 ServiceAccount,基于我们在清单 20-3 中看到的信息。

这两个 YAML 资源位于同一个文件中,因此我们可以将它们同时应用到集群中。我们需要确保将它们应用到todo命名空间,因为这是我们希望 Prometheus 访问的命名空间:

root@host01:~# kubectl -n todo apply -f /opt/rbac.yaml
role.rbac.authorization.k8s.io/prometheus-k8s created
rolebinding.rbac.authorization.k8s.io/prometheus-k8s created

现在我们已授权 Prometheus 访问我们的 Pods 和服务,我们可以创建 ServiceMonitor 了。以下是其定义:

svc-mon.yaml

---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: todo
spec:
  selector:
    matchLabels:
      app: todo
  endpoints:
    - port: web

ServiceMonitor 使用选择器,类似于 Service 或 Deployment。我们之前将app: todo标签应用于服务,因此matchLabels字段会使 Prometheus 选择该服务。endpoints字段与我们在清单 20-1 中声明的端口名称相匹配。Prometheus 要求我们命名端口,以便进行匹配。

让我们将这个 ServiceMonitor 应用到集群中:

root@host01:~# kubectl -n todo apply -f /opt/svc-mon.yaml
servicemonitor.monitoring.coreos.com/todo created

和之前一样,我们需要确保将其部署到todo命名空间,因为 Prometheus 将配置为查找与 ServiceMonitor 位于同一命名空间的具有适当标签的服务。

因为 Prometheus Operator 正在监视新的 ServiceMonitor 资源,使用我们在第十七章中看到的 API,它会立即获取这个新的资源,并重新配置 Prometheus 以开始抓取该服务。然后,Prometheus 需要几分钟时间来注册新的目标并开始抓取它们。如果我们在此完成后回到 Prometheus 的目标页面,新的服务就会出现,如图 20-5 所示。

Image

图 20-5:Prometheus 监控 todo

如果我们点击todo服务旁边的显示更多按钮,我们会看到它的三个端点,如图 20-6 所示。

Image

图 20-6:Todo 端点

可能会让人惊讶的是,我们创建了一个 ServiceMonitor,指定todo服务作为目标,但 Prometheus 却在抓取 Pods。不过,这正是 Prometheus 必须这样工作的原因。因为 Prometheus 使用常规的 HTTP 请求来抓取指标,并且由于服务流量路由每次会随机选择一个 Pod 进行新连接,Prometheus 每次抓取时都会从一个随机的 Pod 获取指标。通过绕过服务直接识别端点,Prometheus 能够抓取所有服务 Pod 的指标,从而实现整个应用的指标聚合。

我们已经成功将 Node.js 和自定义指标集成到 Prometheus 中,除了已经收集的默认资源使用率指标。在我们结束对应用监控的介绍之前,先运行一个 Prometheus 查询,来演示数据是否已经被拉取。首先,你应该使用自动化脚本打印出的 URL 与todo应用程序进行交互。这将确保有足够的指标可以显示,并且已经有足够的时间让 Prometheus 抓取这些数据。接下来,再次打开 Prometheus Web 界面,或者点击任何 Prometheus 网页顶部的Prometheus,返回主页。然后,在查询框中输入api_success并按下 ENTER。自定义的todo指标应该会显示出来,如图 20-7 所示。

Image

图 20-7:Todo 指标查询

现在我们可以监控 Kubernetes 集群和todo应用程序了。

最后的思考

在本章中,我们探讨了容器和 Kubernetes 的各种功能是如何结合在一起,使我们能够部署一个可扩展、具有弹性的应用程序。我们使用了关于容器的一切知识——部署(Deployments)、服务(Services)、网络、持久存储、Kubernetes 运维管理器(Operators)和基于角色的访问控制(RBAC)——不仅部署了todo应用,还配置了我们集群和应用的 Prometheus 监控。

Kubernetes 是一个功能复杂的平台,具有许多不同的能力,而且新的功能正在不断增加。本书的目的不仅仅是向你展示运行 Kubernetes 应用所需的最重要功能,还为你提供工具来探索 Kubernetes 集群,进行故障排除和性能监控。因此,你应该能够在 Kubernetes 添加新功能时,能够探索这些功能,并克服部署复杂应用程序并使其表现良好的挑战。

posted @ 2025-11-26 09:18  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报