Kubernetes-核心指南-全-

Kubernetes 核心指南(全)

原文:Core Kubernetes

译者:飞龙

协议:CC BY-NC-SA 4.0

前置材料

前言

我们编写这本书是为了赋予那些想要将他们的 K8s(Kubernetes)知识提升到下一个层次的人力量,通过立即深入研究与存储、网络和工具相关的各种主题的模糊细节。

尽管我们不试图提供 K8s API 中每个功能的全面指南(因为这是不可能的),但我们真的相信,在阅读这本书之后,用户将对如何在生产集群中推理复杂的基础设施相关问题以及如何在更广泛的环境中思考 Kubernetes 生态系统的整体发展有新的直觉。

存在一些书籍可以让用户学习 Kubernetes 的基础知识,但我们想编写一本教授构成 Kubernetes 核心技术的书籍。网络、控制平面和其他主题以低级细节进行覆盖,这将帮助您理解 Kubernetes 内部的工作方式。了解系统的工作原理将使您成为一名更好的 DevOps 或软件工程师。

我们还希望在这个过程中激励新的 Kubernetes 贡献者。请通过 Twitter(@jayunit100,@chrislovecnm)联系我们,以更多地参与更广泛的 Kubernetes 社区或帮助我们向与本书相关的 GitHub 存储库添加更多示例。

致谢

我们想感谢维护 Kubernetes 的社区和企业。没有他们及其持续的工作,这款软件将不会存在。我们可以提及许多人的名字,但我们知道我们可能会遗漏一些。

我们想感谢 SIG Network(Mikael Cluseau、Khaled Hendiak、Tim Hockins、Antonio Ojea、Ricardo Katz、Matt Fenwick、Dan Winship、Dan Williams、Casey Calendero、Casey Davenport、Andrew Sy 以及许多其他人)中的朋友和导师;SIG Network 和 SIG Windows 社区不知疲倦的开源贡献者(Mark Rosetti、James Sturevant、Claudio Belu、Amim Knabben);Kubernetes 的原始创始人(Joe Beda、Brendan Burns、Ville Aikas 和 Craig McLuckie);以及随后加入他们的 Google 工程师,包括 Brian Grant 和 Tim Hockin。

这份致谢包括社区牧羊人 Tim St. Clair、Jordan Liggit、Bridget Kromhaut 以及许多其他人。我们还想感谢 Rajas Kakodar、Anusha Hedge 和 Neha Lohia,他们组建了一个新兴的 SIG Network India 团队,这个团队激励了我们希望添加到本书下一版(或可能的续集)中的大量内容,当我们深入网络或服务器代理 kube-proxy 时。

Jay 还想感谢 Clint Kitson 和 Aarthi Ganesan,他们赋予他在 VMware 员工的身份下从事这本书的工作,以及他在 VMware 的团队(Amim 和 Zac),他们始终在创新,并一直支持我们。当然,还有 Frances Buran、Karen Miller 以及许多帮助我们将这本书审查并投入生产的 Manning Publications 的工作人员。

最后,感谢所有审稿人:Al Krinker、Alessandro Campeis、Alexandru Herciu、Amanda Debler、Andrea Cosentino、Andres Sacco、Anupam Sengupta、Ben Fenwick、Borko Djurkovic、Daria Vasilenko、Elias Rangel、Eric Hole、Eriks Zelenka、Eugen Cocalea、Gandhi Rajan、Iryna Romanenko、Jared Duncan、Jeff Lim、Jim Amrhein、Juan José Durillo Barrionuevo、Matt Fenwick、Matt Welke、Michał Rutka、Riccardo Marotti、Rob Pacheco、Rob Ruetsch、Roman Levchenko、Ryan Bartlett、Ubaldo Pescatore 和 Wesley Rolnick。你们的建议帮助使这本书变得更好。

关于这本书

适合阅读这本书的人

想要了解更多关于 Kubernetes 内部结构、如何推理其故障模式以及如何扩展以实现自定义行为的人将能从这本书中获得最大收益。如果你不知道 Pod 是什么,你可能想买这本书,但首先应该购买另一本能够给你提供这种理解的书。

此外,对于那些希望更好地理解与 IT 部门、CTO 和其他组织领导者讨论如何采用 Kubernetes 所需方言的日常操作员,同时保留在容器诞生之前就存在的核心基础设施原则,会发现这本书真正有助于弥合新旧基础设施设计决策之间的差距。或者至少,这是我们希望的结果!

本书是如何组织的:路线图

本书包含 15 章:

  • 第一章:在这里,我们为新用户提供 Kubernetes 的高级概述。

  • 第二章:我们探讨 Pod 作为应用程序的原子构建块的概念,并介绍后续章节将深入探讨的底层 Linux 细节的合理性。

  • 第三章:这是我们深入探讨如何使用底层 Linux 原语在 Kubernetes 中构建高级概念,包括 Pod 实现的细节。

  • 第四章:我们现在全力以赴深入 Linux 进程和隔离的内部细节,这些是 Kubernetes 景观中一些不太为人所知的细节。

  • 第五章:在覆盖 Pod 细节(主要是)之后,我们深入探讨 Pod 的网络,并查看它们如何在不同的节点之间连接起来。

  • 第六章:这是我们的第二篇关于网络的文章,我们探讨了 Pod 和网络代理(kube-proxy)网络的更广泛方面,以及如何对其进行故障排除。

  • 第七章:这是我们关于存储的第一章,它对 Kubernetes 存储的理论基础、CSI(容器存储接口)以及它与 kubelet 的交互进行了广泛的介绍。

  • 第八章:在我们的第二章中,我们探讨存储的一些更实用的细节,包括 emptyDir、Secrets 和 PersistentVolumes/dynamic 存储等是如何工作的。

  • 第九章:我们现在深入探讨 kubelet,并查看它如何启动 Pod 以及管理它们的细节,包括对 CRI、节点生命周期和 ImagePullSecrets 等概念的分析。

  • 第十章:Kubernetes 中的 DNS 是一个复杂的话题,几乎在所有基于容器的应用程序中都被用来本地访问内部服务。我们探讨了 CoreDNS,这是 Kubernetes 的 DNS 服务实现,以及不同的 Pod 如何满足 DNS 请求。

  • 第十一章:我们在早期章节中提到的控制平面,现在将详细讨论,包括调度器、控制器管理器和 API 服务器的工作概述。这些构成了 Kubernetes 的“大脑”,在涉及前几章讨论的较低级别概念时,将它们全部整合在一起。

  • 第十二章:因为我们已经涵盖了控制平面逻辑,我们现在深入 etcd,这是 Kubernetes 的坚如磐石的共识机制,以及它是如何发展到满足 Kubernetes 控制平面需求的。

  • 第十三章:我们概述了 NetworkPolicies、RBAC 以及 Pod 和节点级别的安全性,这对于生产场景中的管理员来说应该了解。本章还讨论了 Pod 安全策略 API 的整体进展。

  • 第十四章:在这里,我们探讨节点级别的安全性、云安全性以及 Kubernetes 安全性的其他基础设施相关方面。

  • 第十五章:我们以一个通用的应用工具概述作为结束,以 Carvel 工具包为例,该工具包用于管理 YAML 文件,构建类似 Operator 的应用程序,以及管理应用程序的长期生命周期。

关于代码

我们在 GitHub 仓库 (github.com/jayunit100/k8sprototypes/) 中为本书提供了几个示例,特别是在以下方面

  • 使用 kind 在本地集群上安装 Calico、Antrea 或 Cillium 的真实网络

  • 在现实世界中查看 Prometheus 指标

  • 使用 Carvel 工具包构建应用程序

  • 各种 RBAC 相关实验

本书还提供了许多代码示例。这些示例遍布文本中,并以单独的代码片段形式出现。代码以 fixed-width font like this 的形式出现,因此您在看到它时就会知道。

在许多情况下,原始源代码已被重新格式化;我们添加了换行并重新调整了缩进,以适应书籍中的可用页面空间。在极少数情况下,即使这样也不够,代码片段中包括行续行标记(➥)。代码注释伴随许多列表,突出显示重要概念。您可以从本书的 liveBook(在线)版本中获取可执行的代码片段,网址为 livebook.manning.com/book/core-kubernetes,以及 GitHub 上的 github.com/jayunit100/k8sprototypes/

liveBook 讨论论坛

购买 Core Kubernetes 包含对 liveBook 的免费访问,Manning 的在线阅读平台。使用 liveBook 的独家讨论功能,您可以在全球范围内或特定章节或段落中附加评论。为自己做笔记、提问和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问 livebook.manning.com/book/core-kubernetes/discussion。您还可以在 livebook.manning.com/discussion 上了解更多关于 Manning 论坛和行为准则的信息。

Manning 对读者的承诺是提供一个平台,让读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他们的兴趣分散!只要本书有售,论坛和先前讨论的存档将可通过出版商的网站访问。

关于作者

FM_F02_Vyas

Jay Vyas,博士,目前是 VMware 的员工工程师,曾参与过多个商业和开源 Kubernetes 发行版和平台,包括 OpenShift、VMware Tanzu、Black Duck 的内部多角度 Kubernetes 安装平台,以及他咨询公司 Rocket Rudolf, LLC 的客户定制的 Kubernetes 安装。在过去的几年里,他作为 Apache 软件基金会 PMC(项目管理委员会)的成员,在 BigData 领域的多个项目中工作。自 Kubernetes 诞生以来,他一直在不同角色中参与 Kubernetes,目前大部分时间都在 SIG-Windows 和 SIG-network 社区中度过。他在完成生物信息学数据集市(将联邦数据库集成到人类和病毒蛋白质组挖掘平台)的博士学业期间开始了分布式系统的研究。这使他进入了大数据领域,并扩展数据处理系统,最终转向 Kubernetes。

如果您有兴趣在 . . . 任何事情上合作,可以在 Twitter 上联系 Jay,用户名 @jayunit100。他的日常锻炼是一英里冲刺和直到力竭的引体向上。他还拥有几台合成器,包括 Prophet-6,其声音听起来像一艘宇宙飞船。

FM_F02_Love

克里斯·洛夫是一位谷歌云认证专家,也是 Lionkube 的联合创始人。他在包括谷歌、甲骨文、VMware、思科、强生和其他公司在内的公司拥有超过 25 年的软件和 IT 工程经验。作为 Kubernetes 和 DevOps 社区中的思想领袖,克里斯·洛夫为许多开源项目做出了贡献,包括 Kubernetes、kops(前 AWS SIG 负责人)、Bazel(为 Kubernetes 规则做出贡献)和 Terraform(VMware 插件的早期贡献者)。他的专业兴趣包括 IT 文化转型、容器化技术、自动化测试框架和实践、Kubernetes、Golang(又称 Go)和其他编程语言。洛夫还喜欢在世界各地演讲关于 DevOps、Kubernetes 和技术,以及指导 IT 和软件行业的人士。

在工作之外,洛夫喜欢滑雪、排球、瑜伽以及其他与科罗拉多州生活相关的户外活动。他也是一名超过 20 年的武术实践者。

如果你想与克里斯进行虚拟咖啡或对他有疑问,你可以在 Twitter 或 LinkedIn 上通过@chrislovecnm 联系他。

关于封面插图

《核心 Kubernetes》封面上的图像是“斯特恩,掌舵的船员”,取自阿尔弗雷多·卢索罗的一幅画作雕刻,发表于《意大利插画》第 19 期,1880 年 5 月 9 日。

在那些日子里,仅凭人们的服饰就能轻易识别出他们居住的地方以及他们的职业或社会地位。曼宁通过基于几个世纪前丰富多样的地域文化的书封面,庆祝当今计算机行业的创新精神和主动性,这些文化通过如这幅雕刻画一样的图片被重新呈现出来。

1 为什么存在 Kubernetes

本章涵盖

  • 为什么存在 Kubernetes

  • 常用 Kubernetes 术语

  • Kubernetes 的具体用例

  • 高级 Kubernetes 功能

  • 何时不运行 Kubernetes

Kubernetes 是一个开源平台,用于托管容器并定义以应用程序为中心的 API,用于管理围绕这些容器如何配置存储、网络、安全和其他资源的云语义。Kubernetes 使您应用程序部署的整个状态空间持续进行协调,包括它们如何从外部世界访问。

为什么要在您的环境中实现 Kubernetes,而不是手动使用 DevOps 相关的基础设施工具来配置这些资源?答案在于我们定义 DevOps 的方式,随着时间的推移,它越来越多地集成到整体应用程序生命周期中。DevOps 不断演变,包括支持数据中心中应用程序更自动化管理的流程、工程师和工具。成功完成此任务的关键之一是基础设施的可重复性:对某个组件进行更改以修复事件,但该更改没有完美地复制到所有其他相同组件,这意味着一个或多个组件不同。

在这本书中,我们将深入探讨使用 Kubernetes 与 DevOps 的最佳实践,以便按需复制组件,并使您的系统故障更少。我们还将探索底层的流程,以更好地理解 Kubernetes 并获得最有效的系统。

1.1 在我们开始之前回顾几个关键术语

在 2021 年,Kubernetes 是最常部署的云技术之一。因此,在引用之前,我们并不总是完全定义新术语。如果您是 Kubernetes 的新手或者对一些术语不确定,我们提供了一些关键定义,您可以在本书的前几章中参考这些定义,随着您在这个新领域的学习,我们将更细致、更广泛地重新定义这些概念:

  • CNI 和 CSI—容器网络和存储接口,分别允许为在 Kubernetes 中运行的 Pod(容器)提供可插拔的网络和存储。

  • Container—通常运行应用程序的 Docker 或 OCI 镜像。

  • Control plane—Kubernetes 集群的“大脑”,在这里进行容器的调度和管理所有 Kubernetes 对象(有时称为 Masters)。

  • DaemonSet—类似于部署,但它运行在集群的每个节点上。

  • Deployment—由 Kubernetes 管理的 Pod 集合。

  • kubectl—与 Kubernetes 控制平面通信的命令行工具。

  • kubelet—在您的集群节点上运行的 Kubernetes 代理。它执行控制平面需要它执行的操作。

  • Node—运行 kubelet 进程的机器。

  • OCI—构建可执行、自包含应用程序的通用镜像格式。也称为 Docker 镜像

  • Pod——Kubernetes 对象,封装了正在运行的容器。

1.2 基础设施漂移问题与 Kubernetes

管理基础设施是以可重复的方式管理该基础设施配置的“漂移”,因为硬件、合规性和数据中心的其他要求随着时间的推移而变化。这既适用于应用程序的定义,也适用于这些应用程序运行的宿主机的管理。IT 工程师对常见的繁琐工作如

  • 在服务器群上更新 Java 版本

  • 确保某些应用程序不在特定位置运行

  • 替换或扩展旧或损坏的硬件,并将应用程序从其迁移

  • 手动管理负载均衡路由

  • 在缺乏共同强制配置语言的情况下,忘记记录新的基础设施更改

当我们在数据中心或云中管理和更新服务器时,它们原始定义“漂移”出预期 IT 架构的可能性会增加。应用程序可能会在错误的位置运行,拥有错误的资源分配,或者访问错误的存储模块。

Kubernetes 通过一个便捷的工具kubectl (kubernetes.io/docs/tasks/tools/),为我们提供了一种集中管理所有应用程序整个状态空间的方式:kubectl是一个命令行客户端,它向 Kubernetes API 服务器发出 REST API 调用。我们还可以使用 Kubernetes API 客户端以编程方式执行这些任务。安装kubectl和在一个kind集群上测试它相当容易,我们将在本书的早期阶段这样做。

管理这个复杂的应用状态空间之前的方法包括 Puppet、Chef、Mesos、Ansible 和 SaltStack 等技术。Kubernetes 借鉴了这些不同的方法,通过采用 Puppet 等工具的状态管理能力,同时借鉴了 Mesos 等软件提供的一些应用和调度原语的概念。

Ansible、SaltStack 和 Terraform 通常在基础设施配置中扮演着重要角色(铺平了特定操作系统的要求,如防火墙或二进制安装)。Kubernetes 也管理这个概念,但在 Linux 环境中使用特权容器(在 Windows v1.22 上称为HostProcess Pods)。例如,Linux 系统中的特权容器可以管理 iptables 规则,将流量路由到应用程序,实际上这正是 Kubernetes 服务代理(称为kube-proxy)所做的事情。

Google、Microsoft、Amazon、VMware 以及许多公司已将容器化作为核心和启用策略,使客户能够在不同的云和裸机环境中运行数百或数千个应用程序。因此,容器是运行应用程序和管理工作基础设施(例如为容器提供 IP 地址)的基本原语,这些基础设施运行着这些应用程序所依赖的服务(例如提供定制存储和防火墙需求),最重要的是运行应用程序本身。

Kubernetes 在撰写本文时,基本上是任何云、服务器或数据中心环境中编排和运行容器的现代标准的无争议选择。

1.3 容器和镜像

应用程序有依赖项,必须由它们运行的主机来满足。在容器时代之前,开发者以临时方式完成这项任务(例如,Java 应用程序需要运行 JVM 以及与数据库通信的防火墙规则)。

在其核心,Docker 可以被视为运行容器的途径,其中 容器 是一个正在运行的 OCI 镜像 (github.com/opencontainers/image-spec)。OCI 规范 是一种标准方式来定义一个可以被 Docker 等程序执行的形象,它最终是一个包含多个层的 tarball。镜像内部的每个 tarball 包含诸如 Linux 可执行文件和应用文件等东西。因此,当你运行一个容器时,容器运行时(如 Docker、containerd 或 CRI-O)会取镜像,解包它,并在主机系统上启动一个运行镜像内容的进程。

容器增加了一层隔离,消除了在服务器上管理库或在预加载基础设施时与其他意外应用程序依赖项的需求(图 1.1)。例如,如果你有两个需要同一库不同版本的 Ruby 应用程序,你可以使用两个容器。每个 Ruby 应用程序都在一个运行的容器内隔离,并具有它所需的特定版本的库。

图 1.1 在容器中运行的应用程序

有一个众所周知的过程:“嗯,它在我的机器上运行。”在安装软件时,它通常可以在一个环境或机器上运行,但不能在另一个环境中运行。使用镜像简化了在不同服务器上运行相同软件的过程。我们将在第三章中更多地讨论镜像和容器。

将使用镜像与 Kubernetes 结合起来,允许运行不可变的服务器,这将带来世界级的简单性。随着容器迅速成为软件应用程序部署的行业标准,以下是一些值得注意的数据点:

  • 调查了 88,000 名开发者,Docker 和 Kubernetes 在 2020 年最受欢迎的开发技术中排名第三。 这仅略低于 Linux 和 Docker (mng.bz/nY12)。

  • Datadog 最近发现,Docker 占据了平均开发人员工作流程的 50% 或更多。 同样,公司范围内的采用率也超过所有企业的 25% (www.datadoghq.com/docker-adoption/)。

底线是我们需要容器自动化,这正是 Kubernetes 发挥作用的地方。Kubernetes 在这个领域的主导地位,就像 Oracle 数据库和 vSphere 虚拟化平台在其鼎盛时期所做的那样。多年以后,Oracle 数据库和 vSphere 安装仍然存在;我们预测 Kubernetes 也将拥有同样的长寿。

我们将从对 Kubernetes 功能的基本理解开始这本书。其目的是让您超越基本原理,深入到更底层的核心。让我们深入探讨,看看一个非常简化的 Kubernetes(也称为“K8s”)工作流程,该工作流程展示了构建和运行微服务的一些高级原则。

1.4 Kubernetes 的核心基础

在其核心,我们将 Kubernetes 中的所有内容定义为纯文本文件,通过 YAML 或 JSON 定义,并以声明式的方式为您运行 OCI 镜像。我们可以使用这种相同的方法(YAML 或 JSON 文本文件)来配置网络规则、基于角色的身份验证和授权(RBAC)等。通过学习一种语法及其结构,任何 Kubernetes 系统都可以进行配置、管理和优化。

让我们快速看看如何为简单的应用程序运行 Kubernetes 的一个示例。不用担心;我们将在本书的后面部分提供大量的真实世界示例,引导您了解应用程序的整个生命周期。请将此视为我们迄今为止所做的挥手的视觉指南。为了从具体的微服务示例开始,以下代码片段生成一个 Dockerfile,该 Dockerfile 构建了一个能够运行 MySQL 的镜像:

FROM alpine:3.15.4
RUN apk add --no-cache mysql
ENTRYPOINT ["/usr/bin/mysqld"]

通常,人们会构建这个镜像(使用 docker build),然后将其推送到一个 OCI 仓库(一个可以在运行时由容器存储和检索此类镜像的地方)。您可以在 github.com/goharbor/harbor 找到一个常见的开源仓库,您可以在自己的服务器上托管它。另一个这样的仓库,也是全球数百万应用程序常用的,位于 hub.docker.com/。对于这个例子,让我们假设我们已经推送了这个镜像,现在它在某个地方运行。我们可能还想要构建一个容器来与这个服务通信(也许我们有一个自定义的 Python 应用程序作为 MySQL 客户端)。我们可能定义其 Docker 镜像如下:

FROM python:3.7
WORKDIR /myapp
COPY src/requirements.txt ./
RUN pip install -r requirements.txt
COPY src /myapp
CMD [ "python", "mysql-custom-client.py" ]

现在,如果我们想在 Kubernetes 环境中以容器形式运行我们的客户端和 MySQL 服务器,我们可以通过创建两个 Pod 来轻松实现。这些 Pod 中的每一个都可能运行相应的容器,如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: core-k8s
  spec:
  containers:
    - name: my-mysql-server
      image: myregistry.com/mysql-server:v1.0
---
apiVersion: v1
kind: Pod
metadata:
  name: core-k8s-mysql
  spec:
  containers:
    - name: my-sqlclient
      image: myregistry.com/mysql-custom-client:v1.0
      command: ['tail','-f','/dev/null']

我们通常会存储之前的 YAML 片段到一个文本文件中(例如,my-app.yaml),然后使用 Kubernetes 客户端工具(例如,kubectl create -f my-app.yaml)执行它。此工具连接到 Kubernetes API 服务器并将要存储的 YAML 定义传输过去。然后 Kubernetes 自动获取 API 服务器上我们拥有的两个 Pod 的定义,并确保它们在某处运行。

这不是瞬间发生的:它需要集群中的节点响应不断发生的事件,并通过与 API 服务器通信的 kubelet 更新它们的 Node 对象中的状态。这也要求 OCI 镜像存在于我们的 Kubernetes 集群中的节点上。任何时间都可能出错,因此我们将 Kubernetes 称为“最终一致系统”,其中随着时间的推移对期望状态的协调是一个关键设计理念。这种一致性模型(与保证一致性模型相比)确保我们可以持续请求更改集群中所有应用程序的整体状态空间,并让底层的 Kubernetes 平台确定这些应用程序随时间启动的“如何”。

这在现实世界的场景中自然地扩展。例如,如果您告诉 Kubernetes,“我想在云中的三个区域中分布五个应用程序”,这可以通过定义几行 YAML 并利用 Kubernetes 的调度原语来完成。当然,您需要确保这三个区域确实存在,并且您的调度器知道它们,但即使您没有这样做,Kubernetes 至少会在可用的区域上调度一些工作负载。

简而言之,Kubernetes 允许您定义集群中所有应用程序的期望状态,包括它们的网络连接方式、运行位置、使用的存储方式等等,同时将这些细节的实现委托给 Kubernetes 本身。因此,在生产的 Kubernetes 环境中,您很少需要单独执行一次 Ansible 或 Puppet 更新(除非您正在重新安装 Kubernetes 本身,即使在这种情况下,也有如 Cluster API 这样的工具允许您使用 Kubernetes 来管理 Kubernetes(现在我们真的有点超出我们的理解范围了)。

1.4.1 Kubernetes 中的所有基础设施规则都作为纯 YAML 进行管理

Kubernetes 使用 Kubernetes API 自动化技术栈的所有方面,这些方面可以完全作为 YAML 和 JSON 资源进行管理。这包括传统的 IT 基础设施规则(这些规则以某种方式或形式适用于微服务),例如:

  • 服务器配置端口或 IP 路由

  • 应用程序的持久存储可用性

  • 在特定或任意服务器上托管软件

  • 安全配置,例如 RBAC 或网络规则,以便应用程序相互访问

  • 在每个应用程序和全局基础上进行 DNS 配置

所有这些组件都在配置文件中定义,这些配置文件是 Kubernetes API 中对象的表示。Kubernetes 通过应用更改、监控这些更改以及解决暂时性故障或中断,直到达到期望的最终状态来使用这些构建块和容器。当“夜晚有东西发出声响”时,Kubernetes 将自动处理许多场景,我们不必亲自解决问题。通过自动化配置更复杂的系统,允许 DevOps 团队能够专注于解决复杂问题,规划未来,并为业务找到最佳解决方案。接下来,让我们回顾 Kubernetes 提供的功能以及它们如何支持 Pod 的使用。

1.5 Kubernetes 特性

容器编排平台允许开发者自动化运行实例、配置主机、将容器链接起来以优化编排流程以及扩展应用程序的生命周期。现在是时候深入挖掘容器编排平台的核心特性了,因为本质上,容器需要 Pods,而 Pods 需要 Kubernetes 来

  • 提供一个云中立的 API,用于 API 服务器中的所有功能

  • 在 Kubernetes 控制器管理器(也称为 KCM)中与所有主要云和虚拟平台集成

  • 提供一个容错框架,用于存储和定义所有服务、应用程序、数据中心配置或 Kubernetes 支持的其他基础设施的状态

  • 在最小化面向用户的中断的情况下管理部署,无论是针对单个主机、服务还是应用程序

  • 通过滚动更新意识自动化主机和托管应用程序的扩展

  • 创建具有负载均衡的内联和外部集成(称为 ClusterIP、NodePort 或 LoadBalancer 服务类型)

  • 提供能力,根据其元数据在特定虚拟化硬件上调度应用程序运行,通过节点标签和 Kubernetes 调度器实现

  • 通过 DaemonSets 和其他技术基础设施提供高度可用的平台,优先考虑在集群中所有节点上运行的容器

  • 允许通过域名服务(DNS)进行服务发现,之前由 KubeDNS 实现,最近则由 CoreDNS 实现,它与 API 服务器集成

  • 运行批处理过程(称为作业),它们使用存储和容器的方式与持久化应用程序运行方式相同

  • 包含 API 扩展,并使用自定义资源定义构建原生 API 驱动程序,无需构建任何端口映射或管道

  • 允许检查失败的集群级过程,包括通过kubectl execkubectl describe在任何时间远程执行到任何容器

  • 允许将本地和/或远程存储挂载到容器中,并使用 StorageClass API 和 PersistentVolumes 管理容器的声明性存储卷

图 1.2 是一个 Kubernetes 集群的简单示意图。Kubernetes 所做的工作绝非易事。它标准化了在同一集群中运行或运行的多个应用程序的生命周期管理。Kubernetes 的基础是一个由节点组成的集群。诚然,Kubernetes 的复杂性是工程师对 Kubernetes 的一个抱怨。社区正在努力使其更容易使用,但 Kubernetes 解决的是一个复杂的问题,一开始就很难解决。

图片

图 1.2 一个示例 Kubernetes 集群

如果您不需要高可用性、可扩展性和编排,那么可能您不需要 Kubernetes。现在让我们考虑一个集群中的典型故障场景:

  1. 一个节点停止响应控制平面。

  2. 控制平面将运行在无响应节点上的 Pods 重新调度到另一个或多个节点。

  3. 当用户通过 kubectl 向 API 服务器发出 API 调用时,API 服务器会响应关于无响应节点的正确信息以及 Pods 的新位置。

  4. 所有与 Pod 的服务通信的客户端都被重定向到其新位置。

  5. 连接到故障节点上 Pods 的存储卷被移动到新的 Pod 位置,以便其旧数据仍然可读。

本书的目的在于让您深入了解这一切在底层是如何真正工作的,以及底层 Linux 基本原理如何补充高级 Kubernetes 组件以完成这些任务。Kubernetes 严重依赖于 Linux 堆栈中的数百项技术,这些技术往往难以学习且缺乏深入文档。我们希望您通过阅读本书,能够理解 Kubernetes 的许多细微之处,这些细微之处往往在工程师首次使用教程启动容器时被忽视。

在不可变操作系统上运行 Kubernetes 是很自然的。您有一个基础操作系统,只有在您更新整个操作系统时才会更新(因此是不可变的),您使用该操作系统安装您的节点/Kubernetes。运行不可变操作系统有许多优势,我们在此不一一介绍。您可以在云中、裸金属服务器上,甚至是在树莓派上运行 Kubernetes。事实上,美国国防部目前正在研究如何在一些战斗机上运行 Kubernetes。IBM 甚至支持在其下一代大型机、PowerPC 上运行集群。

随着围绕 Kubernetes 的云原生生态系统不断成熟,它将继续允许组织识别最佳实践,积极采取措施预防问题,并保持环境一致性以避免漂移,即某些机器的行为与其他机器略有不同,因为错过了补丁、未应用或错误地应用了补丁。

1.6 Kubernetes 组件和架构

现在,让我们花点时间从高层次上看看 Kubernetes 架构(图 1.3)。简而言之,它包括你的硬件以及运行 Kubernetes 控制平面的硬件部分以及 Kubernetes 工作节点:

  • 硬件基础设施——包括计算机、网络基础设施、存储基础设施和容器注册库。

  • Kubernetes 工作节点——Kubernetes 集群中计算的基本单元。

  • Kubernetes 控制平面——Kubernetes 的母船。这包括 API 服务器、调度器、控制器管理器和其它控制器。

图 1.3 控制平面和工作节点

1.6.1 Kubernetes API

如果从本章中提取一个重要的事情,这将使你能够深入阅读本书,那就是在 Kubernetes 平台上管理微服务和其它容器化软件应用,仅仅是声明 Kubernetes API 对象的问题。大部分的工作都会为你完成。

本书将深入探讨 API 服务器及其数据存储,etcd。几乎你可以要求kubectl执行的所有操作都会导致在 API 服务器中读取或写入一个定义和版本化的对象。(这个例外是使用kubectl获取正在运行的 Pod 的日志,其中此连接是通过代理转发到节点的。)kube-apiserver(Kubernetes API 服务器)允许对所有对象进行 CRUD(创建、读取、更新和删除)操作,并提供 RESTful(表示状态传输)接口。一些kubectl命令,如describe,是多个对象的组合视图。一般来说,所有 Kubernetes API 对象都有

  • 命名 API 版本(例如,v1rbac.authorization.k8s.io/v1

  • 一种类型(例如,kind: Deployment

  • 元数据部分

我们可以感谢 Kubernetes 的原始创始人之一 Brian Grant,他提出的 API 版本化方案在经过时间的考验后已被证明是稳健的。它可能看起来很复杂,坦白说,有时有点痛苦,但它允许我们进行诸如升级和定义 API 变化的合同。API 变化和迁移通常是相当复杂的,Kubernetes 为 API 变化提供了一个明确的合同。查看 Kubernetes 网站上的 API 版本化文档(mng.bz/voP4),你可以阅读 Alpha、Beta 和 GA API 版本的合同。

在本书的各章节中,我们将专注于 Kubernetes,但会不断回到基本主题:在 Kubernetes 中,几乎所有东西都是为了支持 Pod 而存在的。在这本书中,我们将详细探讨几个 API 元素,包括

  • 运行时 Pod 和部署

  • API 实现细节

  • 入口服务与负载均衡

  • 持久卷和持久卷声明存储

  • 网络策略和网络安全

在标准的 Kubernetes 集群中,你可以操作大约 70 种不同的 API 类型,包括创建、编辑和删除。你可以通过运行kubectl api-resources来查看这些类型。输出应该看起来像这样:

$ kubectl api-resources | head
NAME                    SHORTNAMES  NAMESPACED  KIND
bindings                            true        Binding
componentstatuses       cs          false       ComponentStatus
configmaps              cm          true        ConfigMap
endpoints               ep          true        Endpoints
events                  ev          true        Event
limitranges             limits      true        LimitRange
namespaces              ns          false       Namespace
nodes                   no          false       Node
persistentvolumeclaims  pvc         true        PersistentVolumeClaim

我们可以看到,Kubernetes 本身的每个 API 资源都有

  • 一个简短的名字

  • 一个全名

  • 一个指示它是否绑定到命名空间的标志

在 Kubernetes 中,命名空间允许某些对象存在于特定的……嗯……命名空间内。这为开发者提供了一种简单的分层分组形式。例如,如果你有一个运行 10 个不同微服务的应用程序,你可能会在同一个命名空间内创建所有这些 Pod、服务以及持久卷声明(也称为 PVC)。这样,当你需要删除应用程序时,你只需删除命名空间即可。在第十五章中,我们将探讨分析应用程序生命周期的更高级方法,这些方法比这种简单的方法更先进。但就许多情况而言,命名空间是分离与应用程序相关的所有 Kubernetes API 对象的最明显和直观的解决方案。

1.6.2 示例一:在线零售商

想象一个需要能够根据季节性需求快速扩展的大型在线零售商,比如在假日前后。扩展和预测如何扩展一直是他们最大的挑战——也许是最大的。Kubernetes 解决了运行高度可用、可扩展的分布式系统所带来的众多问题。想象一下,如果你能够轻松地扩展、分配并使系统高度可用,那将会有多大的可能性。这不仅是一种更好的经营方式,而且也是管理系统的最有效和最有效的平台。当结合 Kubernetes 和云提供商时,当你需要额外资源时,你可以运行在别人的服务器上,而不是购买和维护额外的硬件以防万一。

1.6.3 示例二:在线捐赠解决方案

对于这个过渡的一个值得提及的现实世界例子,让我们考虑一个允许用户根据个人选择向广泛慈善机构捐款的在线捐赠网站。假设这个特定的例子最初是一个 WordPress 网站,但最终,商业交易导致完全依赖于 JVM 框架(如 Grails)以及定制的 UX、中间层和数据库层。这个商业巨浪的要求包括机器学习、广告服务、消息传递、Python、Lua、NGINX、PHP、MySQL、Cassandra、Redis、Elastic、ActiveMQ、Spark、狮子、老虎和熊……等等,别再说了。

初始基础设施是一个手工构建的云虚拟机(VM),使用 Puppet 设置一切。随着公司的发展,他们设计了可扩展性,但这包括越来越多的仅托管一个或两个应用程序的 VM。然后他们决定转向 Kubernetes。虚拟机数量从大约 30 个减少到 5 个,并且更容易扩展。他们完全消除了 Puppet 和服务器设置,因此由于转向大量使用 Kubernetes,他们不再需要手动管理机器基础设施。

该公司转向 Kubernetes 解决了整个类别的虚拟机管理问题,减轻了复杂服务发布的 DNS 负担,以及更多问题。此外,在灾难性故障的情况下,恢复时间从基础设施的角度来看更加可预测和管理。当你体验到迁移到标准化、API 驱动的方法的好处时,这种方法运行良好且能够快速进行大规模变更,你开始欣赏 Kubernetes 的声明性特性和其云原生容器编排方法。

1.7 不应使用 Kubernetes 的情况

承认,总有使用 Kubernetes 可能不是最佳选择的情况。其中一些包括

  • 高性能计算(HPC)——使用容器增加了一层复杂性,并且随着新层,性能受到影响。使用容器创建的延迟正在变得很小,但如果你的应用程序受纳诺秒或微秒的影响,使用 Kubernetes 可能不是最佳选择。

  • 遗留——某些应用程序具有硬件、软件和延迟要求,这使得简单地容器化变得困难。例如,你可能购买了来自一个不正式支持在容器中运行或在其 Kubernetes 集群中运行其应用程序的软件公司的应用程序。

  • 迁移——遗留系统的实现可能非常僵化,将其迁移到 Kubernetes 除了“我们建立在 Kubernetes 之上”之外几乎没有其他优势。但迁移之后,当单体应用程序被解析成逻辑组件时,这些组件可以独立扩展。

这里重要的是这一点:学习和掌握基础知识。Kubernetes 以稳定、成本敏感的方式解决了本章中提出的许多问题。

摘要

  • Kubernetes 使你的生活更轻松!

  • Kubernetes 平台可以在任何类型的基础设施上运行。

  • Kubernetes 构建了一个协同工作的组件生态系统。结合这些组件,使公司能够在需要紧急变更时实时预防、恢复和扩展。

  • 在 Kubernetes 中做的所有事情都可以用一款简单的工具来完成:kubectl

  • Kubernetes 从一台或多台计算机创建一个集群,该集群提供了一个部署和托管容器的平台。它提供容器编排、存储管理和分布式网络。

  • Kubernetes 是从之前的配置驱动、容器驱动方法中诞生的。

  • Pod 是 Kubernetes 的基本构建块。它支持 Kubernetes 允许的众多功能:扩展、故障转移、DNS 查询和 RBAC 安全规则。

  • Kubernetes 应用程序完全通过向 Kubernetes API 服务器发出 API 调用来管理。

2 为什么需要 Pod?

本章涵盖

  • 什么是 Pod?

  • 一个示例 Web 应用程序以及为什么我们需要 Pod

  • Kubernetes 是如何为 Pods 构建的

  • Kubernetes 控制平面

在上一章中,我们提供了一个关于 Kubernetes 的高级概述,以及对其特性、核心组件和架构的介绍。我们还展示了几个商业用例,并概述了一些容器定义。Kubernetes Pod 抽象在灵活运行数千个容器方面已经成为企业向容器化过渡的基本部分。在本章中,我们将介绍 Pod 以及 Kubernetes 如何作为基本应用程序构建块来支持它。

如第一章简要提到的,Pod 是在 Kubernetes API 中定义的对象,在 Kubernetes 中大多数事物也是如此。Pod 是可以部署到 Kubernetes 集群中的最小原子单元,Kubernetes 是围绕 Pod 定义构建的。Pod(图 2.1)允许我们定义一个可以包含多个容器的对象,这使得 Kubernetes 能够在节点上创建一个或多个容器。

图 2.1 Pod

图 2.1 Pod

许多其他 Kubernetes API 对象要么直接使用 Pod,要么是支持 Pod 的 API 对象。例如,Deployment 对象就使用了 Pod,以及 StatefulSets 和 DaemonSets。几个不同的高级 Kubernetes 控制器创建和管理 Pod 生命周期。控制器是运行在控制平面上的软件组件。内置控制器的例子包括控制器管理器、云管理器和调度器。但首先,让我们先偏离一下主题,先描述一个 Web 应用程序,然后再将其与 Kubernetes、Pod 和控制平面联系起来。

note 你可能会注意到,我们使用控制平面来定义运行控制器、控制器管理器和调度器的节点组。它们也被称为 masters,但在本书中,当我们谈论这些组件时,我们将使用 control plane

2.1 一个示例 Web 应用程序

让我们通过一个示例 Web 应用程序来了解为什么我们需要 Pod 以及 Kubernetes 是如何构建来支持 Pod 和容器化应用程序的。为了更好地理解为什么需要 Pod,我们将在本章的大部分内容中使用以下示例。

Zeus Zap 能量饮料公司有一个在线网站,允许消费者购买他们不同系列的碳酸饮料。该网站由三个不同的层组成:用户界面(UI)、中间层(各种微服务)和后端数据库。他们还有消息和排队协议。像 Zeus Zap 这样的公司通常有各种面向消费者的前端以及管理端,中间层的不同微服务,以及一个或多个后端数据库。以下是 Zeus Zap 的 Web 应用程序的一个切片分解(图 2.2):

  • 由 NGINX 提供的 JavaScript 前端

  • 两个以 Django 托管的 Python 微服务为主控层的网络控制器

  • 在端口 6379 上运行的后端 CockroachDB,由存储支持

现在,让我们想象他们在生产环境中以四个不同的容器运行这些应用程序。然后他们可以使用以下 docker run 命令启动应用程序:

$ docker run -t -i ui -p 80:80
$ docker run -t -i miroservice-a -p 8080:8080
$ docker run -t -i miroservice-b -b 8081:8081
$ docker run -t -i cockroach:cockroach -p 6379:6379

一旦这些服务启动并运行,公司很快就会意识到以下几点:

  • 除非他们在端口 80 前进行负载均衡,否则他们不能运行 UI 容器的多个副本,因为他们的镜像运行的主机机器上只有一个端口 80。

  • 除非修改 IP 地址并将其注入到网络应用程序中(或者他们添加一个在 CockroachDB 容器移动时动态更新的 DNS 服务器),否则他们不能将 CockroachDB 容器迁移到不同的服务器。

  • 他们需要在单独的服务器上运行每个 CockroachDB 实例,以实现高可用性。

  • 如果一个 CockroachDB 实例在某个服务器上死亡,他们需要一种方法将数据移动到新的节点,并回收未使用的存储空间。

Zeus Zap 也意识到一个容器编排平台存在一些需求。这些包括

  • 数百个进程共享网络,所有进程都绑定到相同的端口

  • 在避免污染本地磁盘的同时,将存储卷从二进制文件迁移和解耦

  • 优化可用 CPU 和内存资源的使用,以实现成本节约

注意:在服务器上运行更多进程通常会导致 嘈杂邻居 现象:拥挤的应用程序会导致对稀缺资源(CPU、内存)的过度竞争。系统必须减轻嘈杂邻居的影响。

在调度服务和管理工作负载均衡器时,大规模(或甚至小规模)运行的容器化应用程序需要更高层次的认识。因此,还需要以下项目:

  • 存储感知调度——在使数据可用时与调度进程协同

  • 服务感知的网络负载均衡——当容器从一个机器移动到另一个机器时,将流量发送到不同的 IP 地址

图 2.2 Zeus Zap 网络架构

我们在应用程序中刚刚分享的启示与 2000 年代分布式调度和编排工具的创始人产生了共鸣,包括 Mesos 和 Borg。Borg 是谷歌的内部容器编排系统,Mesos 是一个开源应用程序,两者都提供集群管理,并且早于 Kubernetes。

2.1.1 我们网络应用程序的基础设施

没有像 Kubernetes 这样的容器编排软件,组织在其基础设施中需要许多组件。为了运行一个应用程序,您需要在云上或物理计算机上使用各种虚拟机(VM),这些计算机充当您的服务器,正如之前提到的,您需要稳定的标识符来定位服务。

服务器负载可能有所不同。例如,你可能需要更多内存的服务器来运行数据库,或者你可能需要一个内存较低但 CPU 更多的系统来运行微服务。此外,你可能需要一个低延迟的存储系统,如 MySQL 或 Postgres,但对于备份和其他通常将数据加载到内存中然后不再接触磁盘的应用程序,你可能需要一个更慢的存储系统。此外,你的持续集成服务器,如 Jenkins 或 CircleCI,需要对你的服务器有完全访问权限,但你的监控系统需要对你的一些应用程序有只读访问权限。现在,再加上人类授权和身份验证。总之,你需要

  • 作为部署平台的虚拟机或物理服务器

  • 负载均衡

  • 应用程序发现

  • 存储

  • 一个安全系统

为了维持系统,你的 DevOps 团队需要维护以下内容(除了许多其他子系统):

  • 集中日志

  • 监控、警报和指标

  • 持续集成/持续交付 (CI/CD) 系统

  • 备份

  • 密钥管理

与大多数自建的应用程序交付平台不同,Kubernetes 出厂就自带日志轮转、检查和管理工具。接下来是业务挑战:运营需求。

2.1.2 运营需求

Zeus Zap 能量饮料公司没有像大多数在线零售商那样的典型季节性增长期,但他们确实赞助了各种电子竞技活动,吸引了大量流量。这是因为市场营销部门和各种在线游戏直播员在这些活动中举办了比赛。这些在线用户流量模式给 DevOps 团队带来了最具挑战性的流量模式之一——突发流量。维护和解决在线应用程序的扩展问题是一个困难的问题,现在团队必须为突发模式进行调度!此外,由于围绕电子竞技活动创建的在线社交媒体活动,公司担心停机。停机成本既难看又庞大。

根据 Gartner 在 2018 年的一项研究 (mng.bz/PWNn),考虑到不同企业的差异,平均每分钟的 IT 停机成本为 5,600 美元。一个应用程序出现两小时的停机时间并不罕见,导致平均成本为 672,000 美元。钱是一回事,但人的成本呢?DevOps 工程师面临停机,这是生活的一部分,但它也会消耗员工,并可能导致燃尽。美国员工的燃尽每年给行业造成约 1250 亿美元至 1900 亿美元的成本 (mng.bz/4j6j)。

许多公司在他们的生产系统中需要一定级别的高可用性和回滚功能。这些需求与对应用程序和硬件冗余的需求相辅相成。然而,为了节省成本,这些公司可能希望在需求较低的时间段内上下调整应用程序的可用性。因此,成本管理通常与关于正常运行时间的更广泛业务需求相矛盾。总结一下,一个简单的 Web 应用程序需要

  • 扩缩

  • 高可用性

  • 对应用程序进行版本控制以允许回滚

  • 成本管理

2.2 什么是 Pod?

大概来说,一个Pod是在 Kubernetes 集群节点上作为容器运行的一个或多个 OCI 镜像。Kubernetes 的节点是单个计算能力(一个服务器),它运行 kubelet。像 Kubernetes 中的其他一切一样,节点也是一个 API 对象。部署 Pod 就像发出以下命令一样简单:

$ cat << EOF > pod.yaml
apiVersion: v1                              ❶
kind: Pod                                   ❷
metadata:
spec:
  container:
    - name: busybox
      image: mycontainerregistry.io/foo     ❸
EOF

$ kubectl create -f pod.yaml                ❹

❶ 与 API 服务器上的版本匹配的 API 版本 ID

❷ kind 声明 API 对象的类型(在这种情况下,一个 Pod)以供 API 服务器使用。

❸ 在注册表中命名镜像

❹ kubectl 命令

之前的语法使用 Linux Bash shell 和kubectl命令运行。kubectl命令是提供命令行界面以与 Kubernetes API 服务器一起工作的二进制文件。

在大多数情况下,Pod 不是直接部署的。相反,它们由我们定义的其他 API 对象(如 Deployments、Jobs、StatefulSets 和 DaemonSets)自动为我们创建:

  • 部署——在 Kubernetes 集群中最常用的 API 对象。它们是典型的 API 对象,比如部署一个微服务。

  • 作业——以批处理方式运行 Pod。

  • StatefulSets——托管需要特定需求且通常是具有状态的应用程序(如数据库)。

  • DaemonSets——当我们希望在集群的每个节点上运行单个 Pod 作为“代理”时使用(通常用于涉及网络、存储或日志的系统服务)。

以下是一个 StatefulSet 功能的列表:

  • 使用序号 Pod 命名以获取唯一的网络标识符

  • 总是挂载到相同 Pod 的持久存储

  • 有序启动、扩缩和更新

提示:Docker 镜像名称支持使用一个名为latest的标签。在生产环境中不要使用 mycontainerregistry.io/foo 这样的镜像名称,因为这会从注册表中拉取latest标签,这是镜像的最新版本。始终使用版本化的标签名称,而不是 latest,甚至更好的是使用 SHA 来安装镜像。镜像标签名称不是不可变的,但镜像 SHA 是。许多生产系统失败是因为意外安装了容器的新版本。朋友们不要让朋友们运行latest

当 Pod 启动时,可以使用简单的kubectl get po命令查看在默认命名空间中运行的 Pod。现在我们已经创建了一个运行的容器,部署宙斯 Zap Web 应用程序的组件(图 2.3)就变得简单了。只需使用 Docker 或 CRI-O 等喜欢的镜像工具将各种二进制文件及其依赖项捆绑到不同的镜像中,这些镜像只是带有一些文件定义的 tar 包。在下一章中,我们将介绍如何手动制作自己的镜像和 Pod。

服务器启动时,系统不会调度各种docker run命令,我们定义了四个高级 API 对象,用于创建 Pod 并调用 Kubernetes API 服务器。正如我们提到的,Pod 很少用于在 Kubernetes 上安装应用程序。用户通常使用更高层次的抽象,如 Deployments 和 StatefulSets。但我们仍然回到 Pod,因为 Deployments 和 StatefulSets 创建副本对象,然后创建 Pod。

图片

图 2.3 在 Kubernetes 上运行的宙斯 Zap 应用

2.2.1 一系列 Linux 命名空间

Kubernetes 命名空间(使用kubectl create ns创建的)与 Linux 命名空间不同。Linux 命名空间是 Linux 内核的一个特性,允许在内核内部进行进程分离。在基本层面上,Pod 是一系列特定配置的命名空间。Pod 具有以下 Linux 命名空间:

  • 一个或多个 PID 命名空间

  • 单个网络命名空间

  • IPC 命名空间

  • cgroup(控制组)命名空间

  • mnt(挂载)命名空间

  • user(用户 ID)命名空间

Linux 命名空间是 Linux 内核文件系统组件,提供从镜像创建运行容器的基本功能。为什么这很重要?让我们回到运行示例 Web 应用的一些要求。

重要的是具备扩展的能力。Pod 不仅为我们和 Kubernetes 提供了部署容器的能力,而且还允许我们扩展处理更多流量的能力。为了降低成本和垂直扩展,需要调整容器资源设置的能力。为了使宙斯 Zap 微服务能够与 CockroachDB 服务器通信,需要部署明确的网络和服务查找。

Pod 及其基础,Linux 命名空间,为所有这些功能提供支持。在网络命名空间内,存在一个虚拟网络栈,该栈连接到一个跨越 Kubernetes 集群的软件定义网络(SDN)系统。通常通过为应用程序的多个 Pod 进行负载均衡来促进扩展需求。Kubernetes 集群内的 SDN 是支持负载均衡的网络框架。

2.2.2 Kubernetes、基础设施和 Pod

服务器依赖于运行 Kubernetes 和 Pods。作为一个计算单元,一个 CPU 功率单元在 Kubernetes 中由一个 API 对象(节点)表示。节点可以在多种平台上运行,但它只是一个具有定义组件的服务器。节点要求包括以下内容:

  • 服务器

  • 一个安装了操作系统的服务器,具有各种 Linux 和 Windows 支持的要求

  • systemd(一个 Linux 系统和服务管理器)

  • kubelet(一个节点代理)

  • 容器运行时(如 Docker 引擎)

  • 一个网络代理(kube-proxy),用于处理 Kubernetes 服务

  • 一个 CNI(容器网络接口)提供者

节点可以在树莓派、云中的虚拟机或多种其他平台上运行。图 2.4 显示了在 Linux 上运行的节点由哪些组件组成。

图片

图 2.4 一个节点

kubelet 是一个作为代理运行的二进制程序,通过多个控制循环与 Kubernetes API 服务器通信。它在每个节点上运行;没有它,Kubernetes 节点就无法调度,也不被视为集群的一部分。理解 kubelet 有助于我们诊断节点无法加入集群和 Pod 无法部署等低级问题。kubelet 确保以下内容:

  • 任何调度到 kubelet 主机的 Pod 都是通过一个控制循环运行的,该循环监视哪些 Pods 被调度到哪些节点。

  • API 服务器通过 Kubernetes 1.17+ 中的心跳不断了解节点上的 kubelet 是否健康。(心跳是通过查看运行集群的 kube-node-lease 命名空间来维护的。)

  • Pod 需要时进行垃圾回收,包括临时存储或网络设备。

然而,kubelet 没有 CNI 提供者和通过容器运行时接口(CRI)可访问的容器运行时,就无法进行任何实际工作。CNI 最终服务于 CRI 的需求,然后启动和停止容器。kubelet 利用 CRI 和 CNI 来协调节点状态和控制平面状态。例如,当控制平面决定 NGINX 将在五节点集群的第二个、第三个和第四个节点上运行时,kubelet 的任务是确保 CRI 提供者从镜像仓库拉取此容器,并在 podCIDR 范围内的 IP 地址上运行。本书将在第九章中介绍这些决策是如何做出的。当提到 CRI 时,必须有一个容器引擎来启动容器。常见的容器引擎包括 Docker 引擎、CRI-O 和 LXC。

服务 是 Kubernetes 定义的一个 API 对象。Kubernetes 网络代理二进制文件(kube-proxy)负责在每个节点上创建 ClusterIP 和 NodePort 服务。不同类型的 Service 包括:

  • ClusterIP—一个内部服务,用于负载均衡 Kubernetes Pods

  • NodePort—Kubernetes 节点上用于负载均衡多个 Pods 的开放端口

  • LoadBalancer—一个外部服务,在集群外部创建负载均衡器

Kubernetes 网络代理可能已安装或未安装,因为一些网络提供商用他们自己的网络组件替换了它,该组件处理服务管理。Kubernetes 允许多个相同类型的 Pod 通过一个服务进行代理。为了使这成为可能,集群中的每个节点都必须知道每个服务和每个 Pod。Kubernetes 网络代理简化并管理每个节点上每个服务,正如为特定集群定义的那样。它支持 TCP、UDP 和 STCP 网络协议,以及转发和负载均衡。

注意,Kubernetes 的网络是通过一个名为 CNI 提供者的软件解决方案提供的。一些 CNI 提供者正在构建组件,用他们自己的软件基础设施替换 Kubernetes 网络代理。这样,他们可以在不使用 iptables 的情况下进行不同的网络操作。

2.2.3 节点 API 对象

正如提到的,节点支持 Pods,控制平面定义了运行控制器、控制器管理器和调度器的节点组。我们可以使用这个简单的kubectl命令查看集群的节点(s):

$ kubectl get no                                     ❶
NAME                STATUS    ROLES   AGE  VERSION
kind-control-plane  NotReady  master  25s  v1.17.0   ❷

❶ 完整的命令是 kubectl get nodes,它检索 Kubernetes 集群的节点对象(s)。

❷ 来自 kind 集群的输出。注意,这是 v1.17.0,可能比您本地运行的版本要旧一些。

现在,让我们看看描述托管 Kubernetes 控制平面的节点的 Node API 对象:

$ kubectl get no kind-control-plane -o yaml

以下示例提供了整个 API Node 对象的值。(在示例中,我们将 YAML 拆分为多个部分,因为它很长。)

apiVersion: v1
kind: Node
metadata:
  annotations:
    kubeadm.alpha.kubernetes.io/cri-socket:
      /run/containerd/containerd.sock         ❶
    node.alpha.kubernetes.io/ttl: "0"
    volumes.kubernetes.io/controller-managed-attach-detach: "true"
  creationTimestamp: "2020-09-20T14:51:57Z"
  labels:                                     ❷
    beta.kubernetes.io/arch: amd64
    beta.kubernetes.io/os: linux
    kubernetes.io/arch: amd64
    kubernetes.io/hostname: kind-control-plane
    kubernetes.io/os: linux
    node-role.kubernetes.io/master: ""
  name: kind-control-plane
  resourceVersion: "1297"
  selfLink: /api/v1/nodes/kind-control-plane
  uid: 1636e5e1-584c-4823-9e6b-66ab5f390592
spec:
  podCIDR: 10.244.0.0/24                      ❸
  podCIDRs:
  - 10.244.0.0/24
# continued in the next section

❶ 使用的 CRI 套接字。在 kind(以及大多数集群)中,这是 containerd 套接字。

❷ 标准标签,包括节点名称

❸ CNI IP 地址,这是 Pod 网络的 CIDR

现在让我们转到状态部分。它提供了有关节点及其组成的详细信息。

status:             ❶
  addresses:
  - address: 172.17.0.2
    type: InternalIP
  - address: kind-control-plane
    type: Hostname
  allocatable:
    cpu: "2"
    ephemeral-storage: 61255492Ki
    hugepages-1Gi: "0"
    hugepages-2Mi: "0"
    memory: 2039264Ki
    pods: "110"
  capacity:
    cpu: "2"
    ephemeral-storage: 61255492Ki
    hugepages-1Gi: "0"
    hugepages-2Mi: "0"
    memory: 2039264Ki
    pods: "110"
  conditions:
  - lastHeartbeatTime: "2020-09-20T14:57:28Z"
    lastTransitionTime: "2020-09-20T14:51:51Z"
    message: kubelet has sufficient memory available
    reason: KubeletHasSufficientMemory
    status: "False"
    type: MemoryPressure
  - lastHeartbeatTime: "2020-09-20T14:57:28Z"
    lastTransitionTime: "2020-09-20T14:51:51Z"
    message: kubelet has no disk pressure
    reason: KubeletHasNoDiskPressure
    status: "False"
    type: DiskPressure
  - lastHeartbeatTime: "2020-09-20T14:57:28Z"
    lastTransitionTime: "2020-09-20T14:51:51Z"
    message: kubelet has sufficient PID available
    reason: KubeletHasSufficientPID
    status: "False"
    type: PIDPressure
  - lastHeartbeatTime: "2020-09-20T14:57:28Z"
    lastTransitionTime: "2020-09-20T14:52:27Z"
    message: kubelet is posting ready status
    reason: KubeletReady
    status: "True"
    type: Ready
  daemonEndpoints:
    kubeletEndpoint:
      Port: 10250

❶ 对节点上运行的 kubelet 的各种状态字段的 API 服务器的更新

接下来,让我们看看节点上运行的所有镜像:

images:                                      ❶
  - names:
    - k8s.gcr.io/etcd:3.4.3-0                ❷
    sizeBytes: 289997247
  - names:
    - k8s.gcr.io/kube-apiserver:v1.17.0      ❸
    sizeBytes: 144347953
  - names:
    - k8s.gcr.io/kube-proxy:v1.17.0
    sizeBytes: 132100734
  - names:
    - k8s.gcr.io/kube-controller-manager:v1.17.0
    sizeBytes: 131180355
  - names:
    - docker.io/kindest/kindnetd:0.5.4       ❹
    sizeBytes: 113207016
  - names:
    - k8s.gcr.io/kube-scheduler:v1.17.0
    sizeBytes: 111937841
  - names:
    - k8s.gcr.io/debian-base:v2.0.0
    sizeBytes: 53884301
  - names:
    - k8s.gcr.io/coredns:1.6.5
    sizeBytes: 41705951
  - names:
    - docker.io/rancher/local-path-provisioner:v0.0.11
    sizeBytes: 36513375
  - names:
    - k8s.gcr.io/pause:3.1
    sizeBytes: 746479

❶ 节点上运行的不同镜像

❷ 作为 Kubernetes 数据库的 etcd 服务器

❸ API 服务器和其他控制器(如 kube-controller-manager)

❹ CNI 提供者。我们需要一个软件定义的网络,这个容器提供了这个功能。

最后,我们将添加nodeInfo块。这包括 Kubernetes 系统的版本控制:

nodeInfo:                 ❶
    architecture: amd64
    bootID: 0c700452-c292-4190-942c-55509dc43a55
    containerRuntimeVersion: containerd://1.3.2
    kernelVersion: 4.19.76-linuxkit
    kubeProxyVersion: v1.17.0
    kubeletVersion: v1.17.0
    machineID: 27e279849eb94684ae8c173287862c26
    operatingSystem: linux
    osImage: Ubuntu 19.10
    systemUUID: 9f5682fb-6de0-4f24-b513-2cd7e6204b0a

❶ 指定有关节点的信息,包括操作系统、kube-proxy 和 kubelet 版本

现在需要的是一个容器引擎、网络代理(kube-proxy)和 kubelet 来运行节点。我们稍后会讨论这个问题。

控制器和控制循环

在 Kubernetes 的上下文中,“控制”这个词是一个多义词;其含义相关,但有点令人困惑。有控制循环、控制器和控制平面。一个 Kubernetes 安装由多个称为“控制器”的可执行二进制文件组成。您可能知道它们是 kubelet、Kubernetes 网络代理、调度器等等。控制器是用称为“控制循环”的计算机编程模式编写的。控制平面容纳特定的控制器。这些节点和控制器是 Kubernetes 的母舰或大脑。关于这个主题的更多内容将在本章的后续部分进行讨论。

构成控制平面的节点有时被称为“主节点”,但我们在整本书中都会使用控制平面。从本质上讲,Kubernetes 是一个状态协调机器,具有各种控制循环,就像空调一样。然而,与调节温度不同,Kubernetes 控制以下内容(以及调节分布式应用程序管理的许多其他方面):

  • 将存储绑定到进程

  • 创建运行中的容器并扩展容器数量

  • 当容器不健康时终止和迁移容器

  • 创建 IP 路由到端口

  • 动态更新负载均衡端点

让我们回顾一下到目前为止我们已经讨论过的需求:Pod 提供了一个部署镜像的工具。这些镜像被部署到节点上,其生命周期由 kubelet 管理。服务对象由 Kubernetes 网络代理管理。像 CoreDNS 这样的 DNS 系统提供了应用程序查找功能,允许一个 Pod 中的微服务查找并与其他运行 CockroachDB 等服务的 Pod 进行通信。

Kubernetes 网络代理还提供了在集群内部进行内部负载均衡的能力,从而帮助实现故障转移、升级、可用性和扩展。为了解决持久存储的需求,mnt Linux 命名空间、kubelet 和节点的组合允许将驱动器挂载到 Pod 上。当 kubelet 创建 Pod 时,该存储就会被挂载到 Pod 上。

看起来我们仍然缺少一些部分。如果某个节点失效了——那会怎样?我们如何将 Pod 部署到节点上?这就引入了控制平面。

2.2.4 我们的 Web 应用程序和控制平面

在建立 Pod 和节点之后,下一步是要弄清楚如何满足复杂的需求,例如高可用性。高可用性(通常称为 HA)不仅仅是简单的故障转移,它还满足服务级别协议(SLA)的要求。系统可用性通常用连续运行时间的 9 个数量级来衡量。这是衡量应用程序或应用程序集可以有多长时间停机的一种度量。四个 9 个数量级给我们每年 52 分钟和 36 秒的停机时间;五个 9 个数量级(99.999% 的正常运行时间)给我们 5 分钟和 15 秒的可能停机时间。99.999% 的正常运行时间给我们每月 26.25 秒的停机时间。拥有五个 9 个数量级意味着我们每月只有不到半分钟的时间,Kubernetes 上托管的应用程序不可用。这非常困难!我们所有的其他要求也同样不简单。这些包括

  • 规模化

  • 成本节约

  • 容器版本控制

  • 用户和应用程序安全

注意:是的,Kubernetes 提供了所有这些功能,但应用程序也必须支持 Kubernetes 的工作方式。我们将在本书的最后一章讨论应用程序设计的注意事项。

第一步是部署 Pod。然而,除此之外,我们有一个系统不仅为我们提供容错性和可伸缩性(并且在同一命令行中),而且还具有节省金钱和控制成本的能力。图 2.5 展示了典型控制平面的组成。

图片

图 2.5 控制平面

2.3 使用 kubectl 创建 Web 应用程序

为了理解控制平面如何促进复杂性,例如规模化和容错性,让我们通过简单的命令:kubectl apply -f deployment.yaml。以下是为部署提供的 YAML:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9
        ports:
        - containerPort: 80

当你执行 kubectl apply 时,kubectl 会与控制平面的第一个组件通信。那就是 API 服务器。

2.3.1 Kubernetes API 服务器:kube-apiserver

Kubernetes API 服务器 kube-apiserver 是一个基于 HTTP 的 REST 服务器,它公开了 Kubernetes 集群的各种 API 对象;这些对象从 Pod 到节点再到水平 Pod 自动扩展器。API 服务器验证并提供网络前端,以在集群的共享状态上执行 CRUD 服务。Kubernetes 的生产控制平面通常提供强大的 Kubernetes API 服务器,这意味着在每个构成控制平面的节点上运行它,或者运行在背后使用某种其他机制进行故障转移和高可用的云服务中。在实践中,这意味着通过 HAProxy 或云负载均衡器端点访问 API 服务器。

控制平面上的组件也会与 API 服务器进行通信。当一个节点启动时,kubelet 确保节点通过与 API 服务器通信来注册到集群。控制平面中的所有组件都有一个控制循环,该循环具有监视功能,用于监视 API 服务器中对象的变化。

API 服务器是控制平面上唯一与 Kubernetes 数据库 etcd 通信的组件。在过去,一些其他组件(如 CNI 网络提供者)曾与 etcd 通信,但当前大多数组件都没有这样做。本质上,API 服务器为所有修改 Kubernetes 集群的操作提供了一个有状态的接口。所有控制平面组件的安全性都是必要的,但保护 API 服务器及其 HTTPS 端点是至关重要的。

当在高可用性控制平面上运行时,每个 Kubernetes API 服务器都是活动的,并接收流量。API 服务器主要是无状态的,可以同时运行在多个节点上。HTTPS 负载均衡器在由多个节点组成的管理平面中作为 API 服务器的代理。但我们不希望任何人都有权限与 API 服务器通信。作为 API 服务器一部分运行的 Admission controllers 在客户端与 API 服务器通信时提供身份验证和授权。

通常,外部身份验证和授权系统以 webhook 的形式集成到 API 服务器中。webhook 是一个允许回调的 HTTP PUSH API。kubectl 调用经过身份验证后,API 服务器将新的部署对象持久化到 etcd。我们的下一步是执行调度器,以便将 Pod 部署到节点上。新的 Pod 需要一个新的家,因此调度器将 Pod 分配到特定的节点。

2.3.2 Kubernetes 调度器:kube-scheduler

分布式调度并非易事。Kubernetes 调度器(kube-scheduler)提供了一个干净、简单的调度实现——非常适合像 Kubernetes 这样的复杂系统。调度器在 Pod 调度时会考虑多个因素。这些因素包括节点上的硬件组件、可用的 CPU 和内存资源、策略调度约束以及其他加权因素。

调度器还遵循 Pod 亲和性和反亲和性规则,这些规则指定了 Pod 的调度和放置行为。本质上,Pod 亲和性规则将 Pod 吸引到符合规则的节点,而 Pod 反亲和性规则则将 Pod 从节点排斥出去。污点(Taints)也允许节点排斥一组 Pod,这意味着调度器可以确定哪些 Pod 不应该运行在哪些节点上。在宙斯 Zap 示例(第 2.1.2 节)中,定义了三个 Pod 副本。这些副本提供了容错能力和扩展能力,调度器确定副本可以运行在哪些节点上,然后 Pod 被调度到每个节点上进行部署。

kubelet 控制 Pod 的生命周期,类似于节点的迷你调度器。一旦 Kubernetes 调度器通过NodeName更新 Pod,kubelet 就会将该 Pod 部署到其节点上。控制平面与不运行控制平面组件的节点完全分离。即使在控制平面故障的情况下,如果节点发生故障,Zeus Zap 也不会丢失任何应用程序信息。在控制平面故障期间,无法部署任何新内容,但网站仍然运行。

如果需要连接到应用程序的持久磁盘怎么办?在这种情况下,存储可能继续为这些应用程序工作,直到并且除非运行这些应用程序的节点出现问题。即使在发生这种情况时,如果控制平面恢复在线,我们通常可以期待数据和应用安全迁移到新位置,这得益于 Kubernetes 控制平面的相应功能。

2.3.3 基础设施控制器

Zeus Zap 基础设施的一个要求是 CockroachDB 集群。CockroachDB 是一个符合 Postgres 规范的分布式数据库,在云原生环境中运行。像数据库这样的有状态应用程序通常有特定的操作要求。这导致需要控制器或 Operator 来管理应用程序。由于 Operator 模式正迅速成为在 Kubernetes 上部署复杂应用程序的标准机制,我们建议避免使用纯 YAML,而是安装并使用 Operator。以下示例安装了 CockroachDB 的 Operator:

$ kubectl apply -f https://raw.githubusercontent.com/
          cockroachdb/cockroach-operator/master/
          install/crds.yaml                            ❶
$ kubectl apply -f https://raw.githubusercontent.com/
          cockroachdb/cockroach-operator/master/
          install/operator.yaml                        ❷

❶ 安装 Operator 使用的自定义资源定义

❷ 在默认命名空间中安装 Operator

自定义资源定义

自定义资源定义(CRDs)是定义新 API 对象的 API 对象。用户通过定义 CRD 来创建它,通常使用 YAML 格式。然后,CRD 应用于现有的 Kubernetes 集群,实际上允许 API 创建另一个 API 对象。我们实际上可以使用 CRD 来定义并允许创建新的自定义资源 API 对象。

安装 CockroachDB Operator 后,我们可以下载 example.yaml。以下显示了此操作的curl命令:

$ curl -LO https://raw.githubusercontent.com/cockroachdb/
           cockroach-operator/master/examples/example.yaml

YAML 片段看起来像这样:

apiVersion: crdb.cockroachlabs.com/v1alpha1
kind: CrdbCluster
metadata:
  name: cockroachdb
spec:
  dataStore:
    pvc:
      spec:
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: "60Gi"
        volumeMode: Filesystem
  resources:
    requests:
      cpu: "2"
      memory: "8Gi"
    limits:
      cpu: "2"
      memory: "8Gi"
  tlsEnabled: true
  image:
    name: cockroachdb/cockroach:v21.1.5     ❶
  nodes: 3
  additionalLabels:
    crdb: is-cool

❶ 用于启动数据库的容器

此自定义资源使用 Operator 模式创建和管理以下资源(请注意,这些项目可能是数百行 YAML):

  • 存储在数据库秘密中的传输层安全性(TLS)密钥

  • 包含持久卷和持久卷声明的 CockroachDB StatefulSet

  • 服务

  • Pod 中断预算(PodDisruptionBudget 或 PDB)

考虑到刚才给出的示例,让我们深入了解名为 Kubernetes 控制器管理器(KCM)或kube-controller-manager组件的基础设施控制器,以及云控制器管理器(CCM)。在基于 Pod 构建的已部署 StatefulSet 中,我们现在需要为 StatefulSet 提供存储。

API 对象 PersistentVolume (PV) 和 PersistentVolumeClaim (PVC) 创建了存储定义,并由 KCM 和 CCM 使其生效。对于 Kubernetes 来说,一个关键特性是能够在众多平台上运行:云平台、裸金属或笔记本电脑。然而,存储和其他组件在不同平台之间是不同的:这就是 KCM 和 CCM 的作用。KCM 是一组在控制平面上的节点上运行的组件,称为 controllers 的控制循环。它是一个单一的二进制文件,但运行多个控制循环,因此有多个控制器。

云控制器管理器(CCM)的诞生

Kubernetes 开发团队由来自世界各地的工程师组成,他们统一在 CNCF(云原生计算基金会)的旗下,该基金会包括数百个企业成员。没有将特定供应商的功能分解成定义良好、可插拔的组件,就无法支持如此众多的企业需求。

KCM 一直是一个紧密耦合且难以维护的代码库,这主要是由于不同供应商技术的意外复杂性。例如,分配新的 IP 地址或存储卷需要完全不同的代码路径,这取决于你是在 Google Kubernetes Engine (GKE) 还是 Amazon Web Services (AWS)。鉴于还有几个定制的本地 Kubernetes 提供方案(vSphere、Openstack 等),自 Kubernetes 诞生以来,云提供商特定的代码一直呈增长态势。

KCM 代码库位于 github.com 仓库中的 kubernetes/kubernetes,通常被称为 kk。拥有一个庞大的单代码库并没有什么问题。谷歌公司只有一个代码库,但 Kubernetes 代码库的单代码库已经超越了 GitHub 和一个公司的使用场景。在某个时刻,从事 Kubernetes 开发的工程师们集体意识到,他们需要像之前提到的那样分解特定供应商的功能。在这场斗争中,一个新兴的组件是创建一个通用的 CCM,它可以从实现云提供商接口的任何供应商那里利用功能(mng.bz/QWRv)。此外,现在这个模式也被用于 Kubernetes 调度器和调度器插件。

CCM 的设计是为了允许更快的云提供商开发和创建云提供商。CCM 创建了一个接口,允许像 DigitalOcean 这样的云提供商在主要的 Kubernetes GitHub 仓库之外开发和维护。这种仓库的重构允许云提供商的所有者管理代码,并使提供商能够以更高的速度移动。每个云提供商现在都生活在 Kubernetes 主仓库之外的自己的仓库中。

自从 Kubernetes v1.6 版本发布以来,开始着手将功能从 KCM 移至 CCM。CCM 承诺使 Kubernetes 完全云无关。这种设计代表了 Kubernetes 架构演变的一般趋势,完全解耦于任何供应商可插拔技术的实现。

当在云平台上运行 Kubernetes 时,Kubernetes 直接与公共或私有云 API 交互,CCM 执行大多数这些 API 调用。该组件的目的是运行云特定的控制器循环和执行基于云的 API 调用。以下是该功能列表:

  • 节点控制器—运行与 KCM 相同的代码

  • 路由控制器—在底层云基础设施中设置路由

  • 服务控制器—创建、更新和删除云提供商负载均衡器

  • 卷控制器—创建、附加和挂载卷,并与云提供商交互以编排卷

这些控制器正在过渡到针对云提供商接口进行操作,这一趋势在 Kubernetes 中普遍存在。其他正在演变以支持 Kubernetes 更模块化、供应商中立的未来的接口包括

  • 容器网络接口 (CNI)—为 Pod 提供 IP 地址

  • 容器运行时接口 (CRI)—定义和插入不同的容器执行引擎

  • 容器存储接口 (CSI)—供应商以模块化方式支持新存储类型,而无需修改 Kubernetes 代码库

现在,回到我们的例子,我们需要存储来附加到 CockroachDB Pods。当 Pod 被调度到节点上的 kubelet 时,KCM(或 CCM)会检测到需要新的存储,并根据其运行的平台创建存储。然后它在节点上挂载存储。当 kubelet 创建 Pod 时,它会确定要附加哪种存储,并通过mnt Linux 命名空间将存储附加到容器。现在我们的应用程序有了存储。

回到我们的用户案例:宙斯 Zap 也需要一个负载均衡器来为他们的公共网站提供服务。创建负载均衡器服务而不是集群 IP 服务涉及 Kubernetes 云提供商“监视”用户负载均衡器请求,然后满足它(例如,通过调用云 API 来分配外部 IP 地址并将其绑定到内部 Kubernetes 服务端点)。然而,从最终用户的角度来看,这个请求相当简单:

apiVersion: v1
kind: Service
metadata:
  name: example-service
spec:
  selector:
    app: example
  ports:
    - port: 8765
      targetPort: 9376
  type: LoadBalancer

KCM 监视循环检测到需要新的负载均衡器,并执行在云中创建负载均衡器的 API 调用所需的 API 调用,或者调用 Kubernetes 集群外部的硬件负载均衡器。在这些 API 调用中,底层基础设施了解哪些节点是集群的一部分,然后路由流量到节点。一旦调用到达节点,由 CNI 提供商提供的软件定义网络就会将流量路由到正确的 Pod。

2.4 扩展、高可用应用程序和控制平面

应用程序的扩缩容是使云中的高可用(HA)应用程序,尤其是在 Kubernetes 中成为可能的基本机制。您可能需要更多的 Pods 来进行缩放,或者因为 Pod 或节点发生故障而没有足够的 Pods,您需要重新部署 Pods。执行 kubectl scale 可以增加和减少集群中运行的 Pods 数量。它根据您提供的命令输入直接操作 ReplicaSets、StatefulSets 或其他使用 Pods 的 API 对象。例如:

$ kubectl scale --replicas 300 deployment zeus-front-end-ui

此命令不适用于 DaemonSets。尽管 DaemonSet 对象创建了 Pods,但它们不可扩展,因为根据定义,它们在每个集群的每个节点上运行单个 Pod:它们的规模由集群中的节点数量决定。在宙斯场景中,此命令根据调度器、KCM 和 kubelet 对前一个示例遵循的相同模式增加或减少支持宙斯前端 UI 部署的 Pods 数量。图 2.6 显示了 kubectl scale 命令的典型顺序。

图 2.6

图 2.6 kubectl scale 命令的操作顺序

现在,当事情发生噗噗声时,这总是会发生?我们可以将基本故障或操作分解为三个级别:Pod、节点和软件更新。

首先,Pod 故障。kubelet 负责 Pod 的生命周期,包括启动、停止和重启 Pods。当一个 Pod 失败时,kubelet 尝试重启它,并且它知道 Pod 失败是通过定义的存活探针,或者 Pod 的进程停止。我们在第九章中对 kubelet 进行了更详细的介绍。

第二,节点故障。kubelet 的一个控制循环不断更新 API 服务器,报告节点健康(通过心跳)。在 Kubernetes 1.17+ 中,您可以通过查看运行集群的 kube-node-lease 命名空间来了解此心跳是如何维护的。如果一个节点没有足够频繁地更新其心跳,KCM 的控制器会将该节点的状态更改为离线,并且不再为该节点调度 Pods。已经存在于节点上的 Pods 将被调度删除,然后重新调度到其他节点。

您可以通过手动运行 kubectl cordon node-name 然后运行 kubectl drain node-name 来观察这个过程。节点有多种条件被监控:网络不可用、没有频繁的 Docker 重启、kubelet 已就绪,等等。任何这些心跳失败都会停止在节点上调度新的 Pods。

最后,由于软件更新中断,许多网站和其他服务已经安排了停机时间,但像 Facebook 和 Google 这样的网络大玩家从未安排过停机时间。这两家公司都使用早于 Kubernetes 定制的软件。Kubernetes 旨在在不中断服务的情况下推出 Kubernetes 更新和新 Pod 更新。然而,有一个巨大的前提条件:在 Kubernetes 平台上运行的软件必须以支持 Kubernetes 重启应用程序的方式具有耐用性。如果它们不足以应对中断,可能会发生数据丢失。

在 Kubernetes 平台上托管的应用程序必须支持优雅关闭和启动。例如,如果您在应用程序内部运行一个事务,它需要支持在应用程序的另一个副本中重做该事务或一旦应用程序重新启动就重新启动事务。升级部署就像更改 YAML 定义中的镜像版本一样简单,这通常只是以三种方式之一完成:

  • kubectl edit——接受一个 Kubernetes API 对象作为输入,并打开一个本地终端以就地编辑 API 对象

  • kubectl apply——接受一个文件作为输入,并找到与该文件对应的 API 对象,自动替换它

  • kubectl patch——应用一个小的“补丁”文件,该文件定义了对象的差异

在第十五章中,我们将探讨完整的 YAML 补丁和应用程序生命周期工具。在那里,我们将以更全面的方式处理这个广泛的主题。

升级 Kubernetes 集群不是一件简单的事情,但 Kubernetes 支持各种升级模式。我们将在本书的最后一章中更详细地讨论这个问题,因为本章全部关于控制平面而不是操作任务。

2.4.1 自动扩展

手动扩展部署很棒,但如果你突然在一个集群上每分钟收到 10,000 个新的 Web 请求怎么办?自动扩展就来拯救了。您可以允许三种不同的自动扩展形式:

  • 创建更多 Pod(使用水平 Pod 自动扩展器进行水平 Pod 自动扩展)

  • 给 Pod 分配更多资源(使用垂直 Pod 自动扩展器进行垂直 Pod 自动扩展)

  • 创建更多节点(使用集群自动扩展器)

注意:自动扩展器在某些裸金属平台上可能可用也可能不可用。

2.4.2 成本管理

当集群自动扩展时,它会自动向集群中添加更多节点,这意味着更多节点意味着更高的云使用成本。更多节点允许您的应用程序拥有更多副本并处理更多负载,但随后您的老板会收到账单,并希望找到节省更多资金的方法。这时就出现了 Pod 密度——密集排列的节点。

Pods 是任何 Kubernetes 应用程序中最小、最基本的部分。它们是一组共享相同网络的容器。托管 Pods 的节点可以是虚拟机或物理服务器。分配给一个节点的 Pod 越多,在额外服务器上的花费就越少。Kubernetes 允许 更高的 Pod 密度,这是在过度配置和密集打包的节点上运行的能力。Pod 密度通过以下步骤进行控制:

  1. 对应用程序进行大小和配置分析—应用程序需要针对内存和 CPU 使用进行测试和分析。一旦分析完成,Kubernetes 中必须为该应用程序设置适当的资源限制。

  2. 选择节点大小—这允许你在同一节点上打包多个应用程序。运行不同大小的虚拟机或具有不同容量的裸金属服务器可以节省金钱,并在其上部署更多的 Pod。你仍然需要确保有足够的节点数量,以允许高可用性,满足你的服务级别协议(SLA)要求。

  3. 在特定节点上组合某些应用程序—这提供了最佳密度。如果你在一个罐子里放了一堆弹珠,罐子里还有很多空间。加入一些沙子或更小的应用程序,可以填补一些空隙。污点(Taints)和容忍度(Tolerations)允许操作员模式对 Pod 部署进行分组和控制。

在所有这些因素中,你还需要考虑的是 嘈杂的邻居。根据你的工作负载,一些调整可能并不合适。再次强调,你可以通过 Pod 亲和力和反亲和力定义在 Kubernetes 集群中更均匀地分散嘈杂的应用程序。我们可以进一步探讨使用自动扩展和云短暂虚拟机来节省成本。此外,简单地关闭开关也有帮助。许多公司都有用于开发和 QA 环境的独立集群。如果你周末不需要运行开发环境,那么为什么它还在运行?简单地将控制平面的工作节点数量减少到零,当你需要集群恢复时,增加工作节点数量。

摘要

  • Pod 是 Kubernetes 基本的 API 对象,它使用 Linux 命名空间创建一个运行一个或多个容器的环境。

  • Kubernetes 是为了以不同的模式运行 Pod 而构建的,这些模式是 API 对象:部署(Deployments)、有状态集(StatefulSets)等等。

  • 控制器是创建和管理 Pod 生命周期的软件组件。这些包括 kubelet、云控制器管理器(CCM)和调度器。

  • 控制平面是 Kubernetes 的核心。通过它,Kubernetes 可以将存储绑定到进程,创建运行中的容器,调整容器的数量,在容器不健康时终止和迁移容器,创建 IP 路由到端口,更新负载均衡端点,并管理分布式应用程序的许多其他方面。

  • API 服务器(即kube-apiserver组件)负责验证并提供网络前端,以在集群的共享状态上执行 CRUD 操作。大多数控制平面都将 API 服务器部署在构成控制平面的每个节点上,为 API 服务器提供高度可用的(HA)集群。

3 让我们构建一个 Pod

本章涵盖

  • 探索 Linux 原语的基本知识

  • 利用 Linux 原语在 Kubernetes 中

  • 不使用 Docker 构建自己的 Pod

  • 为什么某些 Kubernetes 插件随着时间的推移而演变

本章通过介绍使用您操作系统中已经存在的几个 Linux 原语来构建 Pod。这些是 Linux 操作系统中的进程管理的基本构建块,我们很快就会了解到它们可以用来构建更复杂的行政程序或以临时方式完成需要访问操作系统级功能的基本日常任务。这些原语的重要性在于,它们既启发了又实现了 Kubernetes 的许多重要方面。

我们还将探讨为什么我们需要 CNI 提供者,这些是可执行程序,为 Pod 提供 IP 地址。最后,我们将回顾 kubelet 在启动容器中扮演的角色。让我们先从一个简短的介绍开始,以便了解接下来几节将要学习的内容。

Guestbook 应用程序沙盒

在第十五章中,我们将通过一个具有前端、网络和后端的实际 Kubernetes 应用程序进行讲解。如果您想从应用的角度获得 Kubernetes Pods 工作的高级概述,不妨跳转到那一章。

图 3.1 展示了在 Kubernetes 中创建和运行 Pod 的本质。它非常简化,我们将在后续章节中进一步阐述这个图中的某些细节。现在,值得注意的是,Pod 首次创建和被宣布为运行状态之间存在一段很长的时间。假设您自己运行过一些 Pod,您可能非常清楚这种延迟。在这段时间里发生了什么?一系列您在日常生活中可能不会使用的 Linux 原语被召唤起来,以创建所谓的 容器。简而言之

  • kubelet 必须找出它应该运行一个容器。

  • kubelet(通过与容器运行时通信)然后启动一个 pause 容器,这给 Linux 操作系统留出时间来为容器创建网络。这个 pause 容器是我们将要运行的实际应用程序的前身。它存在是为了创建一个初始的家,以便引导我们的新容器网络进程及其进程 ID(PID)。

  • 在启动过程中,各种组件的状态如图 3.1 中的每个泳道所示,会进行振荡。例如,CNI 提供者在将 pause 容器绑定到网络命名空间的时间之外,大部分时间都是空闲的。

图 3.1 使用 Linux 原语引导

在图 3.1 中,x 轴表示 Pod 启动的相对时间尺度。在一段时间内会发生几个操作,这些操作涉及子路径(外部存储目录的布线,我们的容器读取或写入)。这些操作发生在 Pod 进入运行状态前的 30 秒标记之前的同一时间范围内。正如提到的,各种 Linux 命令使用由 kubelet 触发的 Linux 基本原语,以便将 Pod 带入其最终的运行状态。

存储、绑定挂载和子路径

Kubernetes Pods 中的存储通常涉及绑定挂载(将一个文件夹从一个位置附加到另一个位置)。这允许容器在其文件树中的特定子路径“看到”一个目录。这是一个基本的 Linux 功能,在挂载 NFS 共享等操作中经常使用。

绑定挂载在各种系统中被用于实现许多 Kubernetes 关键功能。这包括允许 Pod 访问存储。我们可以使用像nsenter这样的工具来调查哪些目录可供隔离进程使用,而实际上并不依赖于容器运行时(如 Docker 或 crictl)的使用。

由于nsenter是一个简单的 Linux 可执行文件,它针对的是基础 OS API,因此无论你是否在特定的 Kubernetes 发行版中,它总是可用的。因此,即使 Docker 或 crictl 不可用,你也可以使用它。对于 Windows 集群,当然,你不能依赖nsenter,在调查底层问题时,你可能更依赖于容器运行时工具。

作为这些原语基础性质的例子,我们可以查看以下代码的注释,这些代码位于 Kubernetes 本身的pkg/volume/util/subpath/subpath_linux.go文件中,位于此处:mng.bz/8Ml2。这展示了这些原语在 Kubernetes 实现中的普遍性:

func prepareSubpathTarget(mounter mount.Interface,s Subpath)
(bool, string, error) { ... }        ❶

❶ 创建子路径绑定挂载的目标

prepareSubpathTarget函数创建子路径的绑定挂载目标之后,子路径就可以在容器内部访问了,即使它是创建在 kubelet 上的。之前,NsEnterMounter函数提供了这种功能,以便在容器内部完成各种目录操作。你可能不需要再次阅读这段代码。然而,了解 Kubernetes 本身中引用了nsenter的遗迹是有帮助的。

从历史上看,nsenter在 Kubernetes 中多次被用来解决容器运行时在管理存储方面的错误或怪癖。同样,如果你遇到与存储或目录挂载相关的问题,了解有 Linux 工具可以做到与 kubelet 和你的容器运行时相同的事情,检查问题所在的位置是很有用的;nsenter只是简单 Linux 命令的一个例子。

3.1 使用 kind 查看 Kubernetes 原语

让我们从创建一个简单的集群开始,我们可以用它来证明这些概念中的几个。没有比使用kindkind.sigs.k8s.io)更容易创建 Kubernetes 环境引用的方法,kind是一个 Kubernetes 的开发者工具。这将是我们在本书中进行的许多实验的基础。

首先,我们需要设置一个简单的 Linux 环境,这样我们就可以探索这些概念。因为我们将使用kind,这允许您在容器运行时(如 Docker Enginer)内部运行 Kubernetes 集群(单节点或多节点)。kind通过为每个节点创建一个新的容器来在任何操作系统上运行。我们可以将kind集群与表 3.1 所示的现实世界集群进行比较。

表 3.1 比较kind与“真实”集群

集群类型 管理员 Kubelet 类型 允许交换
kind 您(Docker 用户) Docker 容器 是(不适用于生产环境)
GKE(Google Kubernetes Engine) Google 一个 GCE(Google 计算引擎)节点
集群 API 运行中的主集群 您选择的任何云提供商中的虚拟机

表 3.1 比较了kind集群与我们在生产中运行的常规集群的架构。kind的许多方面都不适合生产。例如,因为它允许资源交换,这最终意味着容器可能会利用磁盘空间作为它们的内存。如果许多容器突然需要更多内存来运行,这可能会产生明显的性能和计算成本。然而,kind的好处在于,当学习是重点时,这一点是显而易见的。

  • 它不花费任何费用。

  • 它可以在几秒钟内安装。

  • 如果需要,它可以在几秒钟内重建。

  • 它可以无问题地运行基本的 Kubernetes 功能。

  • 它能够运行几乎任何网络或存储提供商,因此它足够现实,可以开始使用 Kubernetes。

在我们的案例中,我们将使用kindDocker 容器不仅运行一些 Kubernetes 示例,而且将其用作一个轻量级的 Linux 虚拟机,我们可以在其中进行修改。我们将通过深入研究 Kubernetes 网络内部,查看我们如何使用iptables命令在 Kubernetes 集群内部路由流量来结束本章。

设置您的计算机

在我们开始之前,这里有一个关于设置您的计算机的快速说明。我们假设您熟悉使用kubernetes.io网站和各种搜索引擎来查找有关如何安装的最新信息,并且您至少有一些基本的 Linux 经验,可以安装某些发行版的软件包。一般来说,我们不会为您提供所有细微任务的特定说明,但请记住,您将需要一个 Linux 环境来跟随本章的内容:

  • 如果您正在运行 Windows,您将需要使用 VMware Fusion、VirtualBox 或 Hyper-V 安装带有 Linux 的虚拟机。

  • 如果你正在运行 Linux,你可以尝试使用这些示例,无论是有还是没有 kind 集群。

  • 如果你正在运行 Mac,你可以简单地下载 Docker 桌面,然后你就可以设置好了。

如果你还没有设置好所有这些,我们鼓励你花时间去做。如果你是 Linux 用户,你可能已经习惯了不同编程工具的 DIY 设置。对于其他人来说,只需搜索“在 Windows 上运行 Linux 容器”或“如何在 OS X 上运行 Docker”等,你就可以在几分钟内开始运行。几乎每个现代操作系统都以某种方式支持运行 Docker。

3.2 什么是 Linux 原语?

如前所述,Linux 原语是 Linux 操作系统的基本构建块。例如,iptableslsmount 以及大多数 Linux 发行版中可用的许多其他基本程序都是此类原语的例子。如果你在软件开发相关的任何技术领域工作过,你几乎肯定使用过其中的一些命令。例如,ls 命令是任何使用 Linux 终端的人首先学习的工具之一。它列出了你当前目录中的所有文件。如果你向它发送一个参数(例如 /tmp),那么它将列出该目录中的所有文件。

了解这些工具的基本知识让你在理解 Kubernetes 生态系统中众多新插件和附加组件时具有强大的优势。这是因为它们大多都是建立在同一套基本构建块之上的:

  • 网络代理 kube-proxy *创建 iptables 规则,这些规则通常被检查以调试大型集群中的容器网络问题。在 Kubernetes 节点上运行 iptables -L 可以说明这一点。容器网络接口 (CNI) 提供商也使用这个网络代理(例如,用于与 NetworkPolicies 实现相关的各种任务)。

  • 容器存储接口 (CSI) 定义了 kubelet 和存储技术之间的通信套接字。这包括 Pure、GlusterFS、vSAN、弹性块存储 (EBS)、网络文件系统 (NFS) 等资源。例如,在集群中运行 mount 可以显示 Kubernetes 管理的容器和卷挂载,而不依赖于 kubectl 或任何其他非原生 OS 工具,因此,这是在 Kubernetes 中调试底层存储错误时的常见调试技术。

  • 在创建隔离进程时,会使用像 unshare mount 这样的容器运行时命令。这些命令通常需要由创建容器的技术运行。运行这些命令(通常需要 root 权限)的能力是当在 Kubernetes 集群中建模威胁时的重要安全边界。

我们通常通过 shell 访问ls工具,或者通过将许多 shell 命令组合成一个 shell 脚本。许多 Linux 命令可以从 shell 中运行,并且通常它们会返回文本输出,这些输出可以用作下一个命令的输入。我们通常将ls作为更广泛脚本的一部分来访问的原因是,我们可以将其与其他程序结合使用(例如,在目录中的所有文件上运行命令)。这又把我们带回了 Kubernetes 的话题。

3.2.1 Linux 基本操作是资源管理工具

系统管理的一个重要部分涉及管理机器上的资源。尽管ls看起来像是一个简单的程序,但在资源管理方面,它也是一个强大的工具。管理员每天都会使用这个程序来查找大文件或检查用户是否有能力在用户或其他程序报告权限错误的情况下执行基本操作。它使我们能够找到

  • 我们是否可以访问某个文件

  • 在任意目录中可用的文件有哪些

  • 该文件具有哪些功能(例如,它是否可执行?)

说到文件,这让我们想到了 Linux 基本操作的下一个方面:一切皆文件。这是 Linux 与其他操作系统(如 Windows)之间的一个关键区别。事实上,当在 Kubernetes 集群中运行 Windows 节点时,检查和监控事件状态的能力可能会更复杂,因为没有统一的对象表示。例如,许多 Windows 对象存储在内存中,只能通过 Windows API 访问,而不能通过文件系统访问。

“一切皆文件”是 Linux 的独特之处

在 Windows 中,机器的管理通常需要编辑 Windows 注册表。这需要运行自定义程序,并且通常需要使用 Windows GUI。使用 PowerShell 和其他工具执行许多系统管理方面的工作是有方法的;然而,通常不可能仅通过读取和写入文件来管理整个 Windows 操作系统。

相比之下,Linux 管理员通常通过管理纯文本文件来执行系统管理的几乎所有方面。例如,管理员非常了解/proc 目录,它包含有关正在运行进程的实时信息。在许多方面,它可以像管理文件目录一样进行管理,尽管它从任何意义上说都不是一个“正常”的目录。

3.2.2 一切都是文件(或文件描述符)

Linux 的基本操作,从某种意义上讲,几乎总是对某种类型的文件进行操作、移动或提供抽象。这是因为你将需要用 Kubernetes 构建的所有东西最初都是为 Linux 设计的,而 Linux 完全是为了使用文件抽象作为控制原语而设计的。

例如,ls命令作用于文件。它查看一个文件(即目录)并读取该文件内的文件名。然后它将这些字符串打印到另一个文件,称为标准输出。标准输出不是一个我们通常会考虑的典型文件;相反,它是一个文件,当我们写入时,神奇地在我们的终端中显示内容。当我们说在 Linux 中一切都是文件时,我们真的是这个意思!

  • 目录也是一个文件,但它包含其他文件的名称.

  • 设备也被表示为文件给 Linux 内核. 因为设备可以作为文件访问,这意味着你可以使用像ls这样的命令来确认例如以太网设备是否在容器内连接。

  • 套接字和管道也是文件,进程可以在本地使用它们进行通信. 之后,我们将看到 CSI 如何大量利用这种抽象来定义 kubelet 与卷提供者通信的方式,为我们的 Pod 提供存储。

3.2.3 文件是可组合的

结合之前关于文件和资源管理的概念,我们现在来到 Linux 原语最重要的一个点:它们可以组合成更高级的操作。使用管道(|),我们可以将一个命令的输出传递给另一个命令进行处理。这个命令最流行的用法之一是将lsgrep命令结合,以过滤特定文件并列出它们。

例如,一个常见的 Kubernetes 管理任务可能是确认 etcd 在集群内运行且健康。如果作为容器运行,可以在运行 Kubernetes 控制平面组件(几乎总是运行关键的 etcd 进程)的节点上运行以下命令:

$ ls /var/log/containers/ | grep etcd
etcd-kind-control-plane_kube-system_etcd-44daab302813923f188d864543c....log

同样,如果你在一个来源不明的 Kubernetes 集群中,你可以找出与 etcd 相关的配置资源所在的位置。你可能运行如下命令:

$ find /etc | grep etcd; find /var | grep etcd

容器中的 etcd?

你可能想知道为什么 etcd 在容器中运行。简短地说明一下,以免给你错误的印象:在生产集群中,通常将 etcd 运行在与其他容器不同的地方。这可以防止对宝贵的磁盘和 CPU 资源的竞争。然而,许多较小的或开发者集群为了简单起见,将所有控制平面组件运行在同一个地方。话虽如此,许多 Kubernetes 解决方案已经证明,只要这个容器的卷存储在本地磁盘上,那么 etcd 就可以在容器中良好运行,这样在容器重启时就不会丢失。

这就是我们可以用理论走到的尽头。从现在开始,我们将动手操作,运行大量的 Linux 命令,从零开始构建自己的 Pod-like 行为。但在我们走这条路之前,我们将使用kind来设置一个我们可以玩耍的集群。

3.2.4 设置 kind

与 Kubernetes 和 Docker 一起,kind 是由 Kubernetes 社区维护的一个聪明工具。它使用无其他依赖项在 Docker 容器内构建 Kubernetes 集群。这允许开发者本地模拟具有许多节点的真实集群,无需创建虚拟机或使用其他重型结构。它不是一个生产级 Kubernetes 提供商,仅用于开发或研究目的。为了跟随本书,我们将构建一些 kind 集群,对于本章,我们的第一个集群可以通过遵循 mng.bz/voVm 中的说明来构建。

kind 在任何操作系统上只需几秒钟即可安装,并允许我们在 Docker 内运行 Kubernetes。我们将把每个 Docker 容器视为一个虚拟机,并执行到这些容器中以调查 Linux 的一般特性。将 kind 设置为基本 Kubernetes 开发环境的流程很简单:

  1. 安装 Docker。

  2. kubectl 安装到 /usr/local/bin/kubectl。

  3. kind 安装到 /usr/local/bin/kind。

  4. 通过运行 kubectl get pods 测试安装。

我们为什么使用 kind?本书有很多示例,如果您想亲自运行它们(我们鼓励这样做,但阅读本书并不需要这样做),您将需要一个某种 Linux 环境。由于我们谈论的是 Kubernetes,我们选择了 kind,原因如前所述。然而,如果您是高级用户,您不必觉得有义务使用 kind。如果您熟悉很多这方面的内容,只想深入挖掘以到达难点,您也可以在任何 Kubernetes 集群上运行许多这些命令。但当然,我们假设您将在某种 Linux 变体上运行这些命令,因为 cgroups、Linux 命名空间和其他 Kubernetes 基本原语在 Windows 和 Mac OS X 等商业操作系统发行版中默认不可用。

Windows 用户

kind 可以作为 Windows 可执行文件安装。我们鼓励您查看 kind.sigs.k8s.io/docs/user/quick-start/。它甚至有 Choco install 命令可以运行。如果您是 Windows 用户,您可以在 Windows 子系统 for Linux (WSL 2) 中运行本书中的所有命令,这是一个轻量级的 Linux 虚拟机,可以在任何 Windows 机器上轻松运行。

注意,您还可以将 kubectl 作为 Windows 可执行文件运行,以连接到任何云上的远程集群。尽管我们在本书中似乎更倾向于 Linux 和 OS X,但我们完全支持您在 Windows 机器上运行这些命令!

一旦安装了 kind,您就可以创建一个集群。为此,请使用以下命令:

$ kind delete cluster --name=kind    ❶

Deleting cluster "kind" ...

$ kind create cluster                ❷
Creating cluster "kind" ...
 ✓ Ensuring node image (kindest/node:v1.17.0) 🖼
 ✓ Preparing nodes 📦
 ✓ Writing configuration 📜
⢅⡱ Starting control-plane 🕹

❶ 如果之前有集群正在运行,则删除 kind 集群

❷ 启动您的友好集群

现在,您可以看到集群中正在运行的 Pods。要获取 Pods 列表,请执行以下代码片段中的命令,它还显示了该命令的示例输出:

$ kubectl get pods  --all-namespaces
NAMESPACE            NAME                    READY   STATUS    AGE
kube-system          coredns-6955-6bb2z      1/1     Running   3m24s
kube-system          coredns-6955-82zzn      1/1     Running   3m24s
kube-system          etcd-kind-control-plane 1/1     Running   3m40s
kube-system          kindnet-njvrs           1/1     Running   3m24s
kube-system          kube-proxy-m9gf8        1/1     Running   3m24s

如果你想知道你的 Kubernetes 节点在哪里,你很幸运!你可以通过简单地询问 Docker 来轻松列出它:

$ docker ps

CONTAINER ID        IMAGE                  COMMAND
776b91720d39        kindest/node:v1.17.0   "/usr/local/bin/entr."

CREATED             PORTS                       NAMES
4 minutes ago       127.0.0.1:32769->6443/tcp   kind-control-plane

最后,如果你是系统管理员并且想要能够通过 ssh 连接到你的节点,你也很幸运。我们可以通过运行以下命令进入你的 Kubernetes 节点:

$ docker exec -t -i 776b91720d39 /bin/sh

以及本节前面显示的命令。你将需要在节点内部(实际上是一个容器)运行这些命令。

顺便说一句,你可能想知道 Kubernetes 是如何在 Docker 中运行的。这难道意味着 Docker 容器可以启动其他 Docker 容器吗?绝对可以。如果你查看 kind 的 Docker 镜像(mng.bz/nYg5),你可以看到它是如何工作的。特别是,你可以看到它安装的所有 Linux 原语,其中包括我们之前讨论的一些。阅读这段代码是在完成本章后尝试的一个很好的家庭作业。

3.3 在 Kubernetes 中使用 Linux 原语

Kubernetes 中的核心功能通常与基本 Linux 原语的工作方式相关联,无论是间接还是直接。这些原语构成了运行容器的支架,在你理解它们之后,你将不断回到这里。随着时间的推移,你会发现许多使用像“服务网格”或“容器原生存储”这样的热词的技术,最终归结为巧妙组装的相同基本操作系统功能的大杂烩。

3.3.1 运行 Pod 的前提条件

作为提醒,Pod是 Kubernetes 集群中的基本执行单元:它是我们在数据中心中定义将运行的容器的途径。尽管存在一些理论场景,其中一个人可能会使用 Kubernetes 来完成除了运行容器之外的任务,但我们在这本书中不关注这样的异常情况。毕竟,我们假设你对在像地球上其他地方的人一样传统的环境中运行和理解 Kubernetes 感兴趣。

为了创建一个 Pod,我们依赖于实现隔离、网络和进程管理的功能。这些结构可以通过使用 Linux OS 中已经可用的许多实用程序来实现。实际上,其中一些实用程序可能被认为是必需的功能,没有这些功能,kubelet 将无法执行启动 Pod 所需的必要任务。让我们快速看一下我们在 Kubernetes 集群中每天依赖的一些程序(或原语):

  • swapoff—一个禁用内存交换的命令,这是以尊重 CPU 和内存设置的方式运行 Kubernetes 的已知前提条件。

  • iptables—网络代理的核心需求(通常是),它创建 iptables 规则将服务流量发送到我们的 Pods。

  • mount—这个命令(之前提到过)将资源投影到你的路径中的特定位置(例如,它允许你将设备作为文件夹暴露在你的家目录中)。

  • systemd——这个命令通常启动 kubelet,它是集群中运行的核心进程,用于管理你所有的容器。

  • socat——这个命令允许你在进程之间建立双向信息流;socatkubectl port-forward 命令工作的关键部分。

  • nsenter——这是一个工具,用于进入进程的各种命名空间,以便你可以看到正在发生的事情(从网络、存储或进程的角度)。与 Python 中的命名空间具有某些具有本地名称的模块一样,Linux 命名空间具有某些资源,这些资源不能从外部世界本地访问。例如,Kubernetes 集群中 Pod 的唯一 IP 地址不会与其他 Pod 共享,即使在同一节点上,因为每个 Pod(通常)都在一个单独的命名空间中运行。

  • unshare——这是一个允许进程创建与网络、挂载或 PID 视角隔离的子进程的命令。在本章中,我们将彻底使用它来探索容器中的著名 Pid 1 现象,在 Kubernetes 集群中,每个容器都认为自己是整个世界上唯一的程序。

    unshare 也可以隔离挂载点(/ 位置)和网络命名空间(IP 地址),因此它是原生 Linux 操作系统中与 docker run 最直接对应的命令。

  • ps——一个列出正在运行进程的程序。kubelet 需要持续监控进程,以确定它们何时退出等。通过使用 ps 列出进程,你可以确定你的集群中是否有“僵尸”进程,或者是否有特权容器变得失控(创建了许多新的子进程)等。

3.3.2 运行一个简单的 Pod

在我们了解如何使用这些命令之前,让我们看看如何使用我们的 kind 集群创建一个 Pod。通常,你不会手动创建 Pod,而是创建一个 Deployment、DaemonSet 或 Job。现在,最好先不考虑这些高级结构,创建一个简单、孤立的 Pod。我们将通过在以下代码片段中编写 YAML 文件后运行 kubectl create -f pod.yaml 来创建一个 Pod。但在创建之前,让我们快速过一下这个 YAML 文件的两个注意事项,以免你感到困惑:

  • 如果你想知道 BusyBox 镜像是什么,BusyBox Pod 只是一个你可以运行的极简 Linux 镜像,用于调查默认容器行为。尽管通常使用 NGINX Pod 作为标准,但我们选择 BusyBox,因为它包含了 ip a 命令以及其他一些基本实用工具。很多时候,生产级别的微服务会从容器中移除二进制文件以减少潜在的安全漏洞足迹。

  • 如果你想知道为什么我们要定义一个 webapp-port,你正在正确的道路上! 它没有任何作用,除了帮助你熟悉 Pod 定义中的语法。我们不会在端口 80 上运行任何服务,但如果你要用类似 NGINX 这样的东西替换这个镜像,那么这个端口将是一个负载均衡的端点,你可以用它来指向 Kubernetes 服务。

$ cat << EOF > pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: core-k8s
  labels:                                  ❶
   role: just-an-example
    app: my-example-app
    organization: friends-of-manning
    creator: jay
spec:
  containers:
    - name: any-old-name-will-do
      image: docker.io/busybox:latest      ❷
     command: ['sleep','10000']
      ports:
        - name: webapp-port
          containerPort: 80
          protocol: TCP
EOF

$ kubectl create -f pod.yaml               ❸

❶ 标签的元数据让我们可以选择这个 Pod 作为负载均衡的目标或作为对 Kubernetes API 服务器的查询中的过滤器。

❷ 这个 docker.io 镜像名称必须是真实的,可以从互联网上拉取,或者标记为本地容器镜像。

❸ 在 Kubernetes 默认命名空间中创建 Pod

不要与用于创建此集群的 kind 工具混淆,我们在 Pod 定义中的 kind API 字段告诉 API 服务器当我们创建此 Pod 时它是什么类型的 API 对象。如果我们在这里输入错误的内容(例如,Service),那么 API 服务器会尝试创建不同的对象。因此,定义我们的 Pod 的第一步是将其定义为一种类型(或 kind)。稍后,在本节中,我们将运行一个命令来查看此 Pod 的路由和 IP 配置,所以请保留这个片段以便使用!

在创建 Pod 之后,让我们看看其进程在启动时对我们操作系统的可见性。确实,它在操作系统中注册了。下面的 ps -ax 命令是一种快速简单的方法,可以列出系统上的所有进程,包括可能没有终端的进程。x 特别重要,因为我们处理的是系统而不是用户级软件,我们想要所有正在运行的程序的全局计数,以说明教学原因:

$ ps -ax | wc -l               ❶
706

$ kubectl create -f pod.yml    ❷
pod "core-k8s" deleted

$ ps -ax | wc -l               ❸
707

❶ 计算最初运行了多少个进程

❷ 创建一个 Pod

❸ 计算在 Pod 创建后运行了多少个进程

3.3.3 探索 Pod 的 Linux 依赖关系

我们现在已经在集群中运行了一个 Pod。这个 Pod 运行一个程序,该程序需要访问像 CPU、内存、磁盘等基本计算单元。这个 Pod 与常规程序有什么不同?嗯,从最终用户的角度来看,它根本没有任何不同。例如,像任何正常程序一样,我们的 Pod

  • 使用共享库或特定于操作系统的低级实用程序,允许它消耗键盘输入、列出文件等。

  • 可以访问一个客户端,它可以与 TCP/IP 堆栈的实现一起工作,以便进行网络调用和接收。(这些通常被称为 系统调用。)

  • 需要某种内存地址空间来保证其他程序不会覆盖其内存。

当创建此 Pod 时,kubelet 执行了许多任何人在尝试在服务器上运行计算机程序时可能会执行的活动。它

  • 为程序创建一个隔离的运行环境(带有 CPU、内存和命名空间限制)

  • 确保其家有一个工作的以太网连接

  • 给程序访问一些基本文件,用于解析 DNS 或访问存储

  • 告诉程序它可以安全地进入并启动

  • 等待程序退出

  • 清理程序的家和使用的资源

我们会发现,在 Kubernetes 中我们所做的几乎所有事情都是复制了我们几十年来一直在做的常规管理任务。换句话说,kubelet 只是在为我们运行 Linux 系统管理员的操作手册。

我们可以将这个过程可视化为 Pod 的 生命周期,这是一个循环过程,反映了基本控制循环,它定义了 kubelet 在运行时持续执行的操作(图 3.2)。由于 Pod 中的容器可能在任何时候死亡,因此存在一个控制循环来在这些情况下使它们复活。在整个 Kubernetes 中,这样的控制循环以“分形”的方式发生。事实上,可以说 Kubernetes 本身就是一个由巧妙控制循环组成的复杂组织,这些控制循环使我们能够以自动化的方式大规模运行和管理容器。

图片

图 3.2 kubelet/Pod 生命周期控制循环

其中一个最低级别的控制循环就是 kubelet/Pod 生命周期本身。在图 3.2 中,我们将 kubelet 的终止表示为一个点。只要 kubelet 运行,就存在一个持续的协调循环,我们可以检查 Pod 并启动它们。虽然我们再次引用 nsenter 作为 kubelet 的下游操作之一,但请注意,nsenter(可以在 Linux 上运行和管理容器)的使用不会翻译到其他容器运行时或操作系统。

如我们很快就会了解到的那样,Pod 最终可以通过在正确的时间和顺序运行各种命令来获得,从而实现我们在上一章中讨论的可爱功能。我们新创建的 Pod 显示了几个状态字段,这些字段值得检查。

Docker 在其中扮演什么角色呢?

如果你对于 Kubernetes 还有些陌生,你可能想知道我们什么时候开始讨论 Docker。实际上,我们不会过多地讨论 Docker,因为它越来越成为一个与服务器端容器解决方案无关的开发者工具。

尽管 Kubernetes 多年来一直支持原生 Docker,但截至 Kubernetes 1.20,支持 Docker 的弃用过程已经全面展开,最终 Kubernetes 本身将完全不知道任何容器运行时。因此,尽管 kubelet 在资源使用方面维护 Pod 的生命周期,但它将启动和停止 Pod 以及拉取容器镜像的任务委托给名为 CRI(容器运行时接口)的接口。

CRI 最常见实现是containerd,实际上,Docker 本身在底层使用 containerd。CRI 以最小接口的形式表示 containerd 的一些(但不是全部)功能,这使得人们很容易为 Kubernetes 实现自己的容器运行时。标准的 containerd 可执行文件向 kubelet 提供 CRI,当调用这个 CRI 时,containerd(服务)会调用 runc(Linux 容器)或 hcsshim(Windows 容器)等程序,再次在底层。

一旦 Pod 在 Kubernetes 中运行(或者至少 Kubernetes 知道它应该运行一个 Pod),你就可以看到 API 服务器中的Pod对象有新的状态信息。你可以尝试运行以下命令来查看这些信息:

$ kubectl get pods -o yaml

你会看到一个大的 YAML 文件。因为我们承诺我们的 Pod 有自己的 IP 地址和运行其进程的健康环境,现在让我们确认这些资源是可用的。我们通过在查询中使用jsonpath来找到特定的细节。

使用 JSONPath 检查 Pod

status中过滤掉属性,让我们看看这些字段中的几个。同时,我们也会使用 Kubernetes 的 JSONPath 功能来过滤我们想要的具体信息。例如:

$ kubectl get pods -o=jsonpath='{.items[0].status.phase}'  ❶
Running 

$ kubectl get pods -o=jsonpath='{.items[0].status.podIP}'  ❷
10.244.0.11 

$ kubectl get pods -o=jsonpath='{.items[0].status.hostIP}' ❸
172.17.0.2 

❶ 查询我们 Pod 的状态

❷ 查询我们 Pod 的 IP 地址

❸ 查询我们运行的主机的 IP 地址

注意:你可能注意到,除了 pod.yaml 中的新状态信息之外,你还在其spec部分有一些新的信息。这是因为 API 服务器可能需要在提交 Pod 之前修复一些元素。例如,像terminationMessagePathdnsPolicy这样的东西,通常不需要用户进行更改,可以在定义 Pod 后为你添加。在一些组织中,你可能还实施了一个自定义的准入控制器,它在将对象传递到 API 服务器之前“查看”传入的对象并对其进行修改(例如,在大型数据中心中为容器添加硬 CPU 或内存限制)。

在任何情况下,使用之前的命令

  • 我们可以看到我们的 Pod 正在被操作系统监控,并且它的状态是运行中。

  • 我们可以看到 Pod 生活在与我们的主机不同的 IP 空间(称为网络命名空间)中,10.244.0.11/16。

检查我们挂载到 Pod 中的数据

Kubernetes 慷慨地提供给所有 Pod 的一个字段是default-token.卷。这给我们的 Pod 提供了一个证书,允许它们与 API 服务器通信并“回家”。除了 Kubernetes 卷之外,我们还为 Pod 提供了 DNS 信息。要查看这些信息,你可以在 Pod 内部运行mount命令,通过执行kubectl exec。例如:

$ kubectl exec -t -i core-k8s mount | grep resolv.conf
/dev/sda1 on /etc/resolv.conf type ext4 (rw,relatime)

在这个例子中,我们可以看到,当我们在容器内运行*mount*命令时,实际上显示的是文件/etc/resolv.conf(它告诉 Linux DNS 服务器所在的位置)是从另一个位置挂载的。这个位置(/dev/sda1)是卷在我们主机上的位置,那里有相应的resolv.conf文件。实际上,在我们的例子中,mount命令还有其他文件的条目,其中许多实际上是通过符号链接回主机上的/dev/sda1目录。这个目录通常对应于/var/lib/containerd中的一个文件夹。你可以通过运行以下命令来定位这个文件:

$ find /var/lib/containerd/ -name resolv.conf

不要过于纠结这个细节。它是 kubelet 底层实现的一部分,但知道这些文件确实存在于你的系统中,并且可以在需要时使用标准 Linux 工具(例如,如果你的集群表现不佳)找到,这很好。

在虚拟机中,虚拟机管理程序(创建虚拟机的东西)并不知道虚拟机正在运行什么进程。然而,在容器化环境中,所有由 containerd(或 Docker 或任何其他容器运行时)创建的进程都由操作系统本身积极管理。这允许 kubelet 执行许多细粒度的清理和管理任务,同时也允许 kubelet 向 API 服务器暴露有关容器状态的重要信息。

更重要的是,这表明你作为管理员,以及 kubelet 本身,能够管理和查询进程,检查这些进程的卷,甚至在必要时终止这些进程。尽管其中一些可能对你来说很显然,我们之所以展示这一点,是因为我们想要强调,在大多数 Linux 环境中,我们称之为容器的那些东西,只是通过一些隔离的铃声和哨声创建的进程,使它们能够与微服务集群中的数百个其他进程良好地协作。Pod 和进程与您可能运行的常规程序并没有太大的不同。总之,我们的 Pod

  • *具有用于访问我们的 API 服务器的证书的存储卷。这为 Pods 提供了一种简单的方法来访问内部 Kubernetes API,如果它们想要的话。这也是 Operator 或 Controller 模式的基础,这些模式允许 Kubernetes Pods 创建其他 Pods、服务等等。

  • *具有一个特定的 10 子网 IP 地址。这与 172 子网中的主机 IP 地址不同。

  • *在其内部命名空间上的 IP 地址 10.244.0.11 的端口 80 上提供服务。我们集群中的其他 Pod 也可以访问这个端口。

  • *在我们的集群中运行得很好。现在它有一个具有唯一 PID 的容器,这对于我们的主机来说是完全可管理和可见的。

注意,我们还没有对 iptables 进行操作以将流量路由到我们的 Pod。当你创建一个没有服务的 Pod 时,你实际上并没有按照 Kubernetes 通常设计的方式设置网络。尽管我们的 Pod 的 IP 地址在集群中是可查找的,但没有任何方法可以将流量负载均衡到 Pod 的其他兄弟和姐妹。为此,我们需要与服务关联的标签和标签选择器。我们将在第四章中稍后讨论 Kubernetes 网络。

现在你已经探索了真实集群中简单 Pod 的基本功能,我们将看看我们如何可以通过使用基本的 Linux 原语来构建这个功能。通过构建这个功能的过程(没有集群)会让你接触到许多核心能力,这将提高你对 Kubernetes 安装工作原理的理解,如何管理大型集群,以及如何在野外调试容器运行时。系好安全带,准备享受一次刺激的旅程!

3.4 从零开始构建 Pod

我们将回到过去,因为我们将尝试在 Kubernetes 存在之前构建一个容器管理系统。我们应该从哪里开始?我们之前已经讨论过我们 Pod 的四个基本方面,特别是

  • 存储

  • IP 地址分配

  • 网络隔离

  • 流程识别

我们将不得不使用我们的基础 Linux 操作系统来实现这个功能。幸运的是,我们的 kind 集群已经有一个基本的 Linux 操作系统,我们可以用它来本地使用,因此我们不需要为这个练习创建一个专门的 Linux 虚拟机。

这个 Pod 真的能工作吗?

这不是你会在现实世界中运行的 Pod 类型;它将充满手动操作,我们构建它的过程不会扩展到生产工作负载。但是,在我们所做事情的核心,我们将反映 kubelet 在任何云或数据中心创建容器时所经过的相同过程。

让我们进入我们的 kind 集群并开始动手!这可以通过运行 docker ps | grep kind | cut -d' ' -f 1 来列出你的 kind 容器 ID,然后通过运行 docker exec -t -i container_id /bin/sh 来跳入这些节点之一。因为我们将编辑 kind 集群中的文本文件,所以让我们使用以下命令安装 Vim 编辑器或你可能习惯使用的任何其他编辑器:

root@kind-control-plane:/# sudo apt-get update -y
root@kind-control-plane:/#  apt-get install vim

如果你刚接触 Kubernetes 和 kind(并且你现在感到有些头晕),是的,你说得对;我们现在正在做一些元操作。本质上,我们正在模拟一个真实的集群以及我们所有人都熟悉和喜爱的 SSH 调试。在 kind 集群容器上运行 docker exec 的方法(大致上)相当于在真实集群中的真实 Kubernetes 节点上执行 ssh

3.4.1 使用 chroot 创建隔离的进程

首先,我们将以最纯净的方式创建一个容器——一个文件夹,它恰好包含运行 Bash shell 所需的一切,绝对没有其他东西(如图 3.3 所示)。这是使用著名的 chroot 命令完成的。(在其早期,Docker 被称为“加强版的 chroot”)

图片

图 3.3 chroot 命名空间与主机根文件系统的比较

chroot 的目的是为进程创建一个隔离的根目录。这个过程有三个步骤:

  1. 决定你想要运行什么程序以及它在你的文件系统中的运行位置。

  2. 为进程创建一个运行环境。有许多 Linux 程序位于 lib64 目录中,即使运行像 Bash 这样的程序也需要它们。这些需要被加载到新的根目录中。

  3. 将你想要运行的程序复制到 chroot 位置。

最后,你可以运行你的程序,它将在完美的文件系统隔离中运行。这意味着它将无法看到或触摸你的文件系统上的其他信息(例如,它无法编辑 /etc/ 或 /bin/ 中的文件)。

听起来熟悉吗?应该如此!当我们运行 Docker 容器时,无论是在 Kubernetes 中还是不在 Kubernetes 中,我们总是有这种干净、隔离的环境来运行。实际上,如果你查看 Kubernetes 自身的 issue,你肯定能找到许多关于基于 chroot 功能的过去和现在的问题和疑问。现在,让我们通过在 chroot 命名空间中运行一个 Bash 终端来了解 chroot 的工作原理。

以下脚本创建了一个盒子,我们可以在其中运行 Bash 脚本或其他 Linux 程序。它对更广泛系统没有其他可见性,也没有冲突,这意味着如果我们想在盒子内运行 rm -rf /,我们可以这样做而不会破坏我们实际操作系统中的所有文件。当然,我们不推荐你在家里尝试这样做,除非你在一个可丢弃的机器上,因为一个小错误可能会导致大量数据丢失。为了我们的目的,我们将此脚本作为 chroot.sh 本地存储,以防我们想要重用它:

#/bin/bash
mkdir /home/namespace/box

mkdir /home/namespace/box/bin                  ❶
mkdir /home/namespace/box/lib
mkdir /home/namespace/box/lib64

cp -v /usr/bin/kill /home/namespace/box/bin/   ❷
cp -v /usr/bin/ps /home/namespace/box/bin
cp -v /bin/bash /home/namespace/box/bin
cp -v /bin/ls /home/namespace/box/bin

cp -r /lib/* /home/namespace/box/lib/          ❸
cp -r /lib64/* /home/namespace/box/lib64/

mount -t proc proc /home/namespace/box/proc    ❹

chroot /home/namespace/box /bin/bash           ❺

❶ 使我们的盒子具有 bin 和 lib 目录,作为我们的 Bash 程序的依赖项

❷ 将我们基础操作系统中的所有程序复制到这个盒子中,这样我们就可以在我们的根目录中运行 Bash

❸ 将这些程序的库依赖复制到 lib/ 目录中

❹ 将 /proc 目录挂载到这个位置

❺ 这部分很重要:我们在一个沙盒目录中启动我们的隔离 Bash 进程。

记住,我们的根目录的正斜杠(/)将不会有我们不明确加载的程序。这意味着 / 对我们的常规 Linux 路径没有全局访问权限,那里有我们每天运行的正常程序。因此,在前面的代码示例中,我们将 killps(两个基本程序)直接复制到我们的 /home/namespace/box/bin 目录中。而且因为我们已经将 /proc 目录挂载到我们的 chroot 进程中,我们可以看到并访问主机中的进程。这允许我们使用 ps 来探索 chroot 进程的安全边界。此时,你应该看到:

  • 一些命令如 catps 在我们的 chroot 进程中不可用,而 pskill 将可以在任何 Linux 操作系统中运行。

  • 其他运行中的命令(如 ls /)返回的结果与你在完整的操作系统中所看到的结果大不相同。

  • 与你在主机上运行虚拟机时可能看到的情况不同,在这个 chroot 环境中运行或执行事物没有增加性能成本或延迟,因为这只是一个常规的 Linux 命令。如果你没有亲自运行它,结果看起来可能像这样:

root@kind-control-plane:/# ./chroot0.sh
'/bin/bash' -> '/home/namespace/box/bin/bash'
'/bin/ls' -> '/home/namespace/box/bin/ls'
'/lib/x86_64-linux-gnu/libtinfo.so.6' ->
    '/home/namespace/box/lib/libtinfo.so.6'
'/lib/x86_64-linux-gnu/libdl.so.2' ->
    '/home/namespace/box/lib/libdl.so.2'
'/lib64/ld-linux-x86-64.so.2' ->
    '/home/namespace/box/lib/ld-linux-x86-64.so.2'

bash-5.0# ls
bin  lib  lib64

如果你有一台 Linux 操作系统作为比较,从其根目录运行 ls 是很有教育意义的。当你探索完你那荒芜的 chroot 沙漠后,继续输入 exit 返回到你的常规操作系统。哇,那真是个惊吓!

独立的 chroot 环境是我们今天所生活的容器革命的最基础的构建块之一,尽管它长期以来一直被称为“穷人的虚拟机”。chroot 命令通常被 Linux 管理员和 Python 开发者用来执行隔离测试和运行特定程序。如果你阅读了前面提到的“加强版的 chroot”部分,现在可能开始理解这一点了。

3.4.2 使用 mount 为我们的进程提供数据

容器通常需要访问存储在别处的存储:在云端或在主机机器上。mount 命令允许你将一个设备暴露在你操作系统 /(根)目录下的任何目录中。你通常使用 mount 来将磁盘作为文件夹暴露。例如,在我们的运行中的 kind 集群中,执行 mount 会显示由 Kubernetes 管理的几个文件夹,这些文件夹被暴露在特定位置的具体容器中。

从管理角度来看,mount 的最简单用途是为一个可以生活在其他任意位置的磁盘点创建一个已知、恒定的文件夹位置。例如,假设我们想要运行前面的程序,但我们希望它将数据写入一个我们可以稍后丢弃的临时位置。我们可以执行一个简单的操作,如 mount --bind /tmp/ /home/namespace/box/data,在先前的 chroot 程序中创建一个 /data 目录。然后,该命名空间中的任何用户都可以方便地拥有一个 /data 目录,他们可以使用它来访问我们的 /tmp 目录中的文件。

注意,这会打开一个安全漏洞!在我们将/tmp 的内容挂载到容器之后,任何人现在都可以操纵或读取其内容。这实际上就是为什么 Kubernetes 卷的hostPath功能在生产集群中通常被禁用的原因。无论如何,让我们通过使用一些基本的 Linux 原语来确认我们能否将一些数据放入我们在上一节中创建的容器中:

root@kind-control-plane:/# touch /tmp/a
root@kind-control-plane:/# touch /tmp/b
root@kind-control-plane:/# touch /tmp/c
root@kind-control-plane:/# ./chroot0.sh
'/bin/bash' -> '/home/namespace/box/bin/bash'
'/bin/ls' -> '/home/namespace/box/bin/ls'
'/lib/x86_64-linux-gnu/libtinfo.so.6' ->
   '/home/namespace/box/lib/libtinfo.so.6'
'/lib/x86_64-linux-gnu/libdl.so.2' ->
   '/home/namespace/box/lib/libdl.so.2'
'/lib64/ld-linux-x86-64.so.2' ->
   '/home/namespace/box/lib/ld-linux-x86-64.so.2'
bash-5.0# ls data
a  b  c

就这样!你现在创建了一个类似于容器的东西,并且现在可以访问存储。我们将在稍后探讨容器化的更高级方面,包括用于保护 CPU、内存和网络相关资源的命名空间。现在,为了好玩,让我们运行ps并看看在我们的容器中漂浮着哪些其他进程。注意,我们会看到我们的进程和一些其他进程:

bash-5.0# ps
  PID TTY          TIME CMD
 5027 ?        00:00:00 sh
79455 ?        00:00:00 bash
79557 ?        00:00:00 ps

3.4.3 使用 unshare 保护我们的进程

太棒了!到目前为止,我们已经为我们的流程创建了一个沙盒,以及一个可以穿透沙盒的文件夹。这看起来已经很接近 Pod 了,对吧?还不是。尽管我们的 chroot 程序与其他文件隔离(例如,当我们在这个程序内部运行ls时,我们只能看到在 chroot0.sh 脚本中明确挂载的文件),但这是否安全呢?结果证明,给一个进程戴上眼罩并不完全等同于保护它。作为一个简单的例子

  1. 通过让 Docker 像我们之前做的那样执行到kind集群中,在kind集群内部运行ps -ax

  2. 获取 kubelet 的 ID(例如,744)。为了使这个过程更容易,你可以运行ps -ax | grep kubelet

  3. 再次运行 chroot0.sh 命名空间脚本。

  4. bash-5.0#提示符下运行kill 744

你会立即看到你刚刚杀死了 kubelet!尽管我们的 chroot 程序无法访问其他文件夹(因为我们把/的位置移动到了一个新的根目录),但它可以一举找到并杀死关键系统进程。因此,如果这是我们容器的话,我们肯定刚刚发现了一个 CVE(常见漏洞和暴露)的漏洞,这可能会使整个集群崩溃。

如果你真的很淘气,甚至可以通过运行kill 74994来结束这个进程。这会导致bash-5.0#终止行被你的未察觉的 chroot0.sh 进程在最后的喘息中打印出来。因此,不仅其他进程可以看到 chroot0 进程,他们还有权结束和控制它。这就是unshare命令发挥作用的地方。

回想一下,当我们“四处张望”时,看到了一些 PID 较大的 Pod?这告诉我们我们的进程能够访问 proc 目录以查看发生了什么。如果你在构建生产容器化环境时需要解决的首要问题之一是隔离。如果你从这个进程内部运行ps -ax,你会立即明白隔离为什么很重要;如果一个容器可以完全访问主机,它可能会永久性地损坏它,例如,通过杀死 kubelet 进程或删除系统关键文件。

然而,使用unshare命令,我们可以使用chroot在独立的终端中运行 Bash,并具有真正分离的进程空间。也就是说,这次我们将无法杀死 kubelet。以下示例使用unshare命令来完成这项隔离:

# Note that this won't work if you've already unmounted this directory.
root@kcp:/# unshare -p -f
            --mount-proc=/home/namespace/box/proc
            chroot /home/namespace/box /bin/bash    ❶

bash-5.0# ps -ax
PID TTY    STAT   TIME COMMAND                      ❷
1   ?      S      0:00 /bin/bash
2   ?      R+     0:00 ps -ax

❶ 在命名空间中创建一个新的 shell

❷ 观察命名空间中可见的所有进程;这似乎有点低,对吧?

哇!我们之前的过程认为它在以 79455 的身份运行,但现在它正在精确相同的容器中运行。这个进程实际上是从unshare命令开始的,它“认为”它的 PID 是 1。通常,PID 1 是操作系统(systemd)中第一个活跃进程的进程 ID。因此,这次,通过使用unshare来启动chroot,我们做到了

  • 一个独立的进程

  • 一个独立的文件系统

  • 仍然能够从我们的文件系统中编辑/tmp 中的特定文件

现在它开始看起来非常像 Kubernetes Pod。实际上,如果你exec进入任何正在运行的 Kubernetes Pod,你会看到一个类似的 PS(进程状态)表。

3.4.4 创建一个网络命名空间

尽管之前的命令将进程与其他进程隔离,但它仍然使用相同的网络。如果我们想用新的网络运行相同的程序,我们还可以再次使用unshare命令:

root@kind-control-plane:/# unshare -p -n -f
  --mount-proc=/home/namespace/box/proc chroot /home/namespace/box /bin/bash
bash-5.0# ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default...
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN group default...
    link/ipip 0.0.0.0 brd 0.0.0.0
3: ip6tnl0@NONE: <NOARP> mtu 1452 qdisc noop state DOWN group default...
    link/tunnel6 :: brd ::

如果我们将这个 Pod 与在正常 Kubernetes 集群中运行的 Pod 进行比较,我们会看到一个单一、更重要区别:缺少一个功能正常的eth0设备。如果我们运行我们之前带有ip a(我们将在下一节中这样做)的 BusyBox Pod,我们会看到一个具有可用的eth0设备的更加活跃的网络。这是具有网络(在 Kubernetes 世界中通常称为 CNI)的容器和 chroot 进程之间的区别。如前所述,chroot 进程是容器化、Docker 以及最终 Kubernetes 本身的核心,但它们本身并不仅仅对运行容器化应用有用,因为这些必需的附件。

3.4.5 检查进程是否健康

作为一项练习,运行exit并回到我们kind集群中的常规终端。你注意到ip a的输出有区别吗?你当然应该注意到!实际上,如果你在一个独立的网络中运行,cURL 程序(其命令可以像killls一样复制)将无法从外部地址获取信息,而它在原始 chroot 命名空间内运行时却可以正常工作。原因是当我们创建一个新的网络命名空间时,我们失去了从hostNetwork命名空间继承的容器路由和 IP 信息。为了演示这一点,运行curl 172.217.12.164(这是 google.com 的静态 IP 地址)在这两种场景下:

  • 运行 chroot0.sh

  • 运行之前的unshare命令

在这两种情况下,你都在运行一个 chroot 进程;然而,在后一种情况下,进程有一个新的网络和进程命名空间。尽管进程命名空间看起来似乎没问题,但与典型的现实世界进程相比,网络命名空间看起来有点空。让我们看看一个“真实”的容器网络堆栈是什么样的。

让我们重新创建之前运行 BusyBox 容器的原始 pod.yaml 文件。这次,我们将查看其网络信息,然后我们可以无情地再次删除它。请注意,在快速重启容器时,CNI 提供者可能会出现错误。在这种情况下,容器将没有 IP 地址启动。在生产场景中调试容器网络错误时,这种比较是一个需要记住的重要点。具体来说,StatefulSet 容器的重启,这些容器旨在保留 IP 地址,是一个容器网络堆栈可能不幸地类似于之前场景的常见场景。以下代码说明了容器拥有的网络(与上一节中的 chroot 进程相比):

$ kubectl delete -f pod.yaml ;             ❶
   kubectl create -f pod.yaml   
$ kubectl exec -t -i core-k8s ip a         ❷

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1
    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: tunl0@NONE: <NOARP> mtu 1480 qdisc noop qlen 1
    link/ipip 0.0.0.0 brd 0.0.0.0
3: ip6tnl0@NONE: <NOARP> mtu 1452 qdisc noop qlen 1
    link/tunnel6 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00
    brd 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00
5: eth0@if10: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN>
      mtu 1500 qdisc noqueue
    link/ether 4a:9b:b2:b7:58:7c brd ff:ff:ff:ff:ff:ff
    inet 10.244.0.7/24 brd 10.244.0.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::489b:b2ff:feb7:587c/64 scope link
       valid_lft forever preferred_lft forever

❶ 创建本章早些时候的原始 pod.yaml 示例

❷ 运行一个命令来列出其网络接口

在这里,我们看到了鲜明的对比。在前一个进程命名空间中,我们甚至无法运行一个简单的curl命令,我们没有eth0设备,但在这个容器中,我们显然有。如果你想删除这个 pod.yaml 文件,可以这样做。而且,像往常一样,没有必要有任何负面情绪——BusyBox 容器并不太认真对待自己。

我们将在本章后面关于 iptables 和 IP 路由的背景下再次回顾一些网络概念。首先,让我们完成对 Linux 容器创建原语的初步探索。然后,我们将查看典型 Kubernetes 应用程序在生产中最常调整的参数:cgroup 的limits

3.4.6 使用 cgroups 调整 CPU

控制组(简称cgroups)是我们所有人都熟悉并喜爱的旋钮。这些旋钮允许我们给在集群中运行的应用程序提供更多或更少的 CPU 和内存,这些应用程序需要额外的活力。如果你在工作中运行 Kubernetes,你很可能已经开关过这些旋钮。我们可以通过以下命令轻松修改我们之前的 BusyBox 容器,以使用更多或更少的 CPU:

$ cat << EOF > greedy-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: core-k8s-greedy
spec:
  containers:
    - name: any-old-name-will-do
      image: docker.io/busybox:latest
      command: ['sleep','10000']
      resources:                    ❶
       limits:
          memory: "200Mi"
        requests:
          memory: "100Mi"
EOF

❶ 告诉 Kubernetes 创建一个 cgroup 来限制(或不限制)可用的 CPU

3.4.7 创建资源段落

我们将手动走过 kubelet 在定义 cgroup 限制时经过的步骤。请注意,实际上这种方式可以在任何给定的 Kubernetes 发行版中通过--cgroup-driver标志进行配置。(Cgroup 驱动是 Linux 中的架构组件,用于分配 cgroup 资源,通常我们使用 systemd 作为 Linux 驱动。)尽管如此,在 Kubernetes 中运行容器的核心逻辑步骤,涉及为进程创建一个合适的沙盒来执行,基本上是相同的,即使你偏离了传统的 containerd/Linux 架构。实际上,对于 Windows kubelet,相同的resources部分使用完全不同的实现细节来尊重。要定义 cgroup 的限制,请使用以下步骤:

  1. 创建一个 PID(我们已这样做)。这在 Kubernetes 中被称为 Pod 沙盒。

  2. 将该 PID 的限制写入操作系统。

首先,从你的运行中的 chroot0 脚本内部,通过运行echo $$来获取其 PID。把这个数字记下来。对我们来说,这个值是 79455。接下来,我们将通过一系列步骤将这个特定的进程置于只能使用少量字节的情境中。这样做,我们就能估算出ls命令执行所需的内存量:

root@kind-control-plane:/# mkdir
    /sys/fs/cgroup/memory/chroot0                     ❶

root@kind-control-plane:/# echo "10" >
    /sys/fs/cgroup/memory/chroot0/
       memory.limit_in_bytes                          ❷

root@kind-control-plane:/# echo "0" >
    /sys/fs/cgroup/memory/chroot0/memory.swappiness   ❸

root@kind-control-plane:/# echo 79455 >
    /sys/fs/cgroup/memory/chroot0/tasks               ❹

❶ 创建一个 cgroup

❷ 只为我们的容器分配 10 字节内存,使其无法进行基本工作

❸ 确保容器不分配交换空间(Kubernetes 几乎总是这样运行)

❹ 告诉我们的操作系统,这个 cgroup 的进程是 79455(chroot0 Bash 进程)

注意,在示例中,创建/chroot0 目录会触发操作系统动作来创建一个包含内存、CPU 等在内的完整 cgroup。现在,回到你在 chroot0.sh 脚本中启动的 Bash 终端,一个简单的命令如ls将会失败。根据你的操作系统,你可能得到另一个同样令人沮丧的响应,然而,无论如何,这个命令都应该失败:

bash-5.0# ls
bash: fork: Cannot allocate memory

就这样!你现在已经创建了自己的进程,它与其他文件隔离,内存占用有限,并且在一个隔离的进程空间中运行,它认为自己是世界上唯一的进程。这是 Kubernetes 集群中任何 Pod 的自然状态。从现在开始,我们将探讨 Kubernetes 如何扩展这个功能基线,以实现一个复杂、动态和健壮的分布式系统。

3.5 在现实世界中使用我们的 Pod

图 3.4 展示了真实容器的示例。尽管我们在这个章节中学到了很多,但最好记住,一个典型的微服务可能需要与许多其他服务进行通信,这通常意味着需要将这些服务挂载到新的证书上。此外,使用内部 DNS 发现其他服务总是很棘手,这是 Kubernetes 模型在规模上管理微服务的巨大优势之一。因为我们没有机会添加查询内部服务 API 的能力,也没有探索以安全方式与这些服务进行自动注入凭据,所以我们不能说我们的微型原型 cgroup 和命名空间示例可以在现实世界中使用。

在图 3.4 中,你会注意到我们的容器能够与集群中的其他容器通信。然而,由于我们的 Pod 是在没有正确配置网络命名空间和独立 IP 地址的情况下创建的,因此它将无法与其下游依赖服务建立任何类型的直接 TCP 连接。

图 3.4 容器的一个示例

现在,我们将稍微深入探讨拥有网络化容器意味着什么。回想一下,在我们之前尝试查看 Bash 进程的这个方面时,我们看到了它只有一个 IP 地址,并且这个 IP 地址并不是针对我们的容器的。这意味着将无法将传入流量路由到我们在该进程中运行的任何服务的端口上。(请注意,我们并不是建议任何人在 Bash 中运行 Web 服务器,但我们使用 Bash 作为任何程序,包括最常见的容器类型,TCP 服务的隐喻。)

为了开始阐明这个谜团的一部分,我们现在将简要了解一些基本的 Linux 网络原语。这为我们将要覆盖的 Kubernetes 网络的各种方面奠定了基础。

3.5.1 网络问题

任何 Kubernetes 容器可能都需要

  • 直接将流量路由到它以实现集群内或 Pod 到 Pod 的连接

  • 将流量从它路由出去以访问另一个 Pod 或互联网

  • 将流量负载均衡到它,作为具有静态 IP 地址的服务后端端点

为了允许这些操作,我们需要将 Pod 的元数据发布到 Kubernetes 的其他部分(这是 API 服务器的任务),并且我们需要持续监控它们的状态(kubelet 的任务),以确保这个状态随着时间的推移得到更新和填充。因此,Pod 不仅仅有容器命令和 Docker 镜像。它们有标签和如何发布其状态的明确规范,这样它们就可以在 kubelet 提供的功能旁边即时重建。这确保了 IP 地址和 DNS 规则始终是最新的。标签在 Pod 的模式中是显而易见的。当我们提到规范时,我们的意思是 Pod 有明确的状态、重启逻辑以及关于集群内 IP 地址可达性的保证。

注意,我们还将再次使用 Linux 环境来探索这些方面。如果您愿意,可以重新构建您的 kind 集群,以防在前面的章节中疯狂破解时破坏了某些内容。您可以通过运行 kind delete cluster --name=kind 然后跟 kind create cluster 来完成此操作。

3.5.2 利用 iptables 理解 kube-proxy 如何实现 Kubernetes 服务

Kubernetes 服务定义了一个 API 合同,表示“如果您访问此 IP 地址,您将自动转发到许多可能的端点之一。”因此,它们是 Kubernetes 用户在部署微服务时的用户体验的骨干。在大多数集群中,这些网络规则完全由 kube-proxy 实现,kube-proxy 通常配置为使用 iptables 程序进行低级网络路由。

iptables 程序向内核添加规则,然后按顺序处理以处理网络流量,这是 Kubernetes 服务实现路由流量的最常见方式。请注意,iptables 对于基本的 Pod 网络不是必需的(这由 CNI 处理;然而,几乎任何现实世界的 Kubernetes 集群都是通过服务被最终用户消费的)。因此,iptables 及其各种变体是推理 Kubernetes 网络的最基本原语之一。在传统设置(Kubernetes 之外)中,每个 iptables 规则都通过使用 -A ... 语法附加到内核的网络堆栈,如下所示:

iptables -A INPUT -s 10.1.2.3 -j DROP

此命令指示丢弃来自 10.1.2.3 的任何流量。然而,Pod 需要的不仅仅是几条防火墙规则。它至少需要

  • 能够作为服务端点接受流量

  • 能够从其自己的端点向外部世界发送流量

  • 能够跟踪正在进行的 TCP 连接(在 Linux 中,这是通过 conntrack 模块完成的,它是 Linux 内核的一部分)

让我们看看真实的网络服务规则(在 kind 中运行)是如何实现的。我们不会再次使用我们的 Pod,因为要将它连接到 IP 地址,实际上需要一个正在运行且可路由的软件定义网络。相反,我们将保持简单。让我们看看 iptables-save | grep hostnames 命令,它显示了所有用于将我们的网络连接在一起的粘合剂。

3.5.3 使用 kube-dns Pod

kube-dns Pod 是一个很好的学习例子,因为它代表了您通常在 Kubernetes 应用程序中运行的 Pod 类型。kube-dns Pod

  • 可在任何 Kubernetes 集群中运行

  • 没有特殊权限,并使用常规的 Pod 网络,而不是主机网络

  • 将流量发送到端口 53,这是众所周知的标准 DNS 端口

  • 默认情况下已在您的 kind 集群中运行

就像我们之前创建的 Pod 无法访问互联网一样,它也无法接收任何流量。当我们运行 ip a 时,Pod 没有自己的 IP 地址。在 Kubernetes 中,CNI 提供商提供唯一的 IP 地址和路由规则来访问此地址。我们可以使用 ip route 命令来调查这些路由,如下所示:

root@kind-control-plane:/# ip route
default via 172.18.0.1 dev eth0
10.244.0.2 dev vethfc5287fa scope host
10.244.0.3 dev veth31aba882 scope host
10.244.0.4 dev veth1e578a9a scope host
172.18.0.0/16 dev eth0 proto kernel scope link src 172.18.0.2

在代码片段中,IP 路由被定义为将流量发送到特定的 veth 设备。这些设备由我们的网络插件为我们创建。Kubernetes 服务如何将流量路由到它们?为此,我们可以查看 iptables 程序的输出。如果我们运行 iptables-save,我们可以使用 grep 查找 10.244.0.* 地址(具体地址将根据您的集群以及可能拥有的 Pod 数量而变化),并看到存在出口规则,这些规则允许它们建立出站 TCP 连接。

使用服务规则将流量路由到我们的 DNS Pod

内部流量通过以下规则路由到我们的 DNS Pod,使用 -j 选项告诉内核“如果有人试图访问 KUBE-SVC-ERIFX 规则,将其发送到 KUBE-SEP-IT2Z 规则。”iptables 规则中的 -j 选项代表 跳转(就像“跳转到另一个规则”)。跳转规则将网络流量转发到服务端点(一个 Pod),如下例所示:

-A KUBE-SVC-ERIFXISQEP7F7OF4 -m comment --comment
                             "kube-system/kube-dns:dns-tcp"
                             -m statistic
                             --mode random
                             --probability 0.50000000000
                             -j KUBE-SEP-IT2ZTR26TO4XFPTO

使用端点规则定义我们单个 Pod 的规则

当接收到来自服务的流量时,它将使用以下 KUBE-SEP 规则进行路由。这些 Pod 访问外部互联网或接收流量。例如:

-A KUBE-SEP-IT2Z.. -s 10.244.0.2/32
   -m comment --comment "kube-system/kube-dns:dns-tcp"
   -j KUBE-MARK-MASQ

-A KUBE-SEP-IT2Z.. -p tcp
   -m comment --comment "kube-system/kube-dns:dns-tcp"
   -m tcp -j DNAT --to-destination 10.244.0.2:53

如果从示例中不明显,任何前往这些 IP 地址的流量的最终目标端口是 53。这是 kube-dns Pod 提供其流量的端点(运行 CoreDNS Pod 的 IP 地址)。如果这些 Pod 中的任何一个变得不健康,那么 KUBE-SEP-IT2Z 的特定规则将由网络代理 kube-proxy 进行协调,以确保流量仅转发到我们 DNS Pod 的健康副本。请注意,kube-dns 是我们服务的名称,CoreDNS 是实现我们 kube-dns 服务端点的 Pod。

网络代理在其一生中的全部目的就是不断地更新和管理这些简单的规则集,以便 Kubernetes 集群中的任何节点都可以将流量转发到 Kubernetes 服务,这就是我们通常将其通称为 Kubernetes 网络代理或 Kubernetes 服务代理的原因。

3.5.4 考虑其他问题

存储、调度和重启是我们尚未讨论的问题。这些问题中的每一个都会影响任何企业应用程序。例如,传统的数据中心可能需要将数据库从一个服务器迁移到另一个服务器,然后需要以与新数据中心拓扑相补充的方式迁移连接到该数据库的应用程序服务器。在 Kubernetes 中,我们还需要考虑这些古老的原始方法。

存储

除了网络问题之外,我们的 Pod 可能还需要访问许多不同类型的存储。例如,如果我们有一个所有容器都需要使用的大的网络附加存储(NAS),并且我们需要定期更改这个 NAS 的挂载方式,会怎样呢?在我们的前一个例子中,这意味着修改我们的 shell 命令,并逐个更改挂载卷的方式。显然,如果没有额外的基础设施工具来自动化这个过程,对于数百或数千个进程来说,这样做是不可行的。然而,即使有了这样的工具,我们仍然需要一种方法来定义这些存储类型,并报告这些挂载存储卷的附加是否失败。这是由 Kubernetes 的 StorageClasses、PersistentVolumes 和 PersistentVolumeClaims 来管理的。

调度

在上一章中,我们讨论了 Pod 的调度本身就是一个复杂的过程。还记得我们之前设置 cgroups 的情况吗?想象一下,如果我们把内存设置得太高,或者在我们的容器运行在一个内存不足以分配其内存请求的环境中,会发生什么。在这两种情况下,我们可能会使我们的 Kubernetes 集群中的整个节点崩溃。拥有一个足够智能的调度器,能够将 Pod 放置在 cgroup 层次结构能够匹配 Pod 资源需求的位置,这是 Kubernetes 的另一个关键特性,这也是为什么需要 Kubernetes 的原因。

调度是计算机科学中的一个通用问题,因此我们应该在这里指出,存在一些替代的调度工具,例如 Nomad (www.nomadproject.io/),它们以 Kubernetes 无关的方式解决数据中心中的调度问题。话虽如此,Kubernetes 调度器专门针对我们想要运行的 Pod,基于亲和性、CPU、内存、存储可用性、数据中心拓扑等参数,提供简单、以容器为中心和可预测的节点选择。

升级和重启

我们创建 Pod 时运行的 Bash 命令,如果我们的 Pod 的 PID 不断变化,或者我们一开始就忘记记录它,将不会很好地工作。如您所记得的,我们需要为某些操作写下 PID。如果我们想要运行比 bin 或 Bash 更复杂的应用程序,我们可能会发现我们需要从文件夹中删除数据,向文件夹中添加新数据,然后重启我们的脚本。同样,由于需要管理多个应用程序的目录、进程和挂载,并且需要处理高并发和锁定,这个过程在规模上几乎是不可能用 shell 脚本完成的。

管理与 Pod 相关的陈旧进程和/或 cgroups,这些 Pod 可能已经不再运行,是大规模运行容器化工作负载的重要部分,尤其是在微服务(旨在可移植和短暂)的上下文中。Kubernetes 应用程序的数据模型,通常以部署、有状态集、作业和守护进程集的形式考虑,能够以优雅的方式处理升级。

摘要

  • Kubernetes 本身是各种 Linux 基本功能的联合体。

  • 您可以使用 chroot 在任何 Linux 发行版中构建类似 Pod 的结构。

  • 存储管理、调度和网络需要以复杂的方式进行管理,如果我们希望我们的 Pod 在生产环境中运行。

  • iptables 是一种 Linux 基本功能,可以灵活地用于转发流量或创建防火墙。

  • Kubernetes 服务是通过 kube-proxy 实现的,它通常在 iptables 模式下运行。

4 在我们的 Pod 中使用 cgroups 处理进程

本章涵盖

  • 探索 cgroups 的基础知识

  • 识别 Kubernetes 进程

  • 学习如何创建和管理 cgroups

  • 使用 Linux 命令调查 cgroup 层次结构

  • 理解 cgroup v2 与 cgroup v1 的区别

  • 安装 Prometheus 并查看 Pod 资源使用情况

上一章相当详细,你可能觉得它有点理论化。毕竟,现在没有人真的需要从头开始构建自己的 Pods(除非你是 Facebook)。不用担心,从现在开始,我们将开始向上移动到更高的层次。

在本章中,我们将更深入地探讨 cgroups:内核中隔离彼此资源的控制结构。在前一章中,我们实际上实现了一个简单的 cgroup 边界,这是我们完全自己制作的 Pod。这一次,我们将创建一个“真实”的 Kubernetes Pod,并调查内核如何管理该 Pod 的 cgroup 脚印。在这个过程中,我们将通过一些虽然愚蠢但仍有教育意义的例子来了解 cgroups 存在的原因。我们将以查看 Prometheus 为结尾,Prometheus 是时间序列指标聚合器,已成为云原生空间中所有指标和观测平台的实际标准。

在跟随本章内容时,最重要的是记住 cgroups 和 Linux Namespaces 并不是任何黑暗魔法。它们实际上是内核维护的账本,将进程与 IP 地址、内存分配等关联起来。因为内核的工作是为程序提供这些资源,所以很明显,这些数据结构也由内核本身管理。

4.1 Pod 在准备工作完成之前是空闲的

在上一章中,我们简要地提到了 Pod 启动时会发生什么。让我们稍微深入一点,看看 kubelet 实际上需要做什么来创建一个真正的 Pod(图 4.1)。请注意,我们的应用程序在暂停容器添加到我们的命名空间之前是空闲的。之后,我们最终拥有的实际应用程序才开始运行。

图片

图 4.1 容器启动涉及到的进程

图 4.1 显示了 kubelet 在创建容器期间各个部分的状态。每个 kubelet 都将安装一个 CRI,负责运行容器,一个 CNI,负责为容器分配 IP 地址,并且将运行一个或多个 暂停容器(kubelet 在其中创建命名空间和 cgroup 以使容器在其中运行的地方)。为了使应用程序最终准备好 Kubernetes 开始向其负载均衡流量,需要以高度协调的方式运行几个短暂的进程:

  • 如果 CNI 在 CNI 的暂停容器之前运行,它将没有可用的网络。

  • 如果没有可用资源,kubelet 将无法为 Pod 运行设置位置,因此不会发生任何事情。

  • 在每个 Pod 运行之前,都会运行一个暂停容器,它是 Pod 进程的占位符。

我们选择在本章中展示这种复杂的舞蹈的原因是为了强调程序需要资源,而资源是有限的:调配资源是一个复杂、有序的过程。我们运行的程序越多,这些资源请求的交集就越复杂。让我们看看几个示例程序。以下每个程序都有不同的 CPU、内存和存储需求:

  • 计算π——计算π需要访问一个专用的核心以实现持续的 CPU 使用。

  • 缓存维基百科内容以实现快速查找——将维基百科缓存到哈希表中供我们的 Pi 程序使用需要很少的 CPU,但它可能需要大约 100 GB 左右的内存。

  • 备份 1 TB 的数据库——将数据库备份到冷存储供我们的 Pi 程序使用基本上不需要内存,很少的 CPU,以及一个大型、持久的存储设备,这可以是一个慢速旋转的磁盘。

如果我们有一台拥有 2 个核心、101 GB 内存和 1.1 TB 存储的单台计算机,理论上我们可以为每个程序分配等价的 CPU、内存和存储访问。结果将是

  • 如果 Pi 程序编写不当(例如,如果它将中间结果写入持久磁盘),最终可能会超出我们的数据库存储空间。

  • 如果维基百科缓存编写不当(例如,如果其哈希函数过于 CPU 密集型),可能会阻止我们的 Pi 程序快速进行数学计算。

  • 如果数据库程序编写不当(例如,如果它做了太多的日志记录),可能会通过占用所有 CPU 来阻止 Pi 程序执行其任务。

我们可以不使用完全访问我们系统(有限的)所有资源的所有进程来运行,如果我们有能力分配我们的 CPU、内存和磁盘资源的话,我们可以这样做——也就是说,如果我们有能力分配我们的 CPU、内存和磁盘资源:

  • 使用 1 个核心和 1 KB 的内存运行 Pi 进程

  • 使用半个核心和 99 GB 的内存运行维基百科缓存

  • 使用 1 GB 的内存运行数据库备份程序,并使用剩余的 CPU 和一个其他应用无法访问的专用存储卷

为了使所有由我们的操作系统控制的程序都能以可预测的方式进行,cgroups 允许我们为内存、CPU 和其他操作系统资源定义层次分级的分离容器。程序创建的所有线程最初都使用分配给父进程的相同资源池。换句话说,没有人可以在别人的池子里玩。

这本身就是为 Pod 设置 cgroups 的论据。在 Kubernetes 集群中,你可能在单台计算机上运行 100 个程序,其中许多程序在特定时间点是低优先级或完全空闲。如果这些程序预留了大量的内存,它们会使运行此类集群的成本不必要地增加。为饥饿进程提供内存而创建新节点会导致管理开销和随时间累积的基础设施成本。由于容器(提高数据中心利用率)的承诺在很大程度上取决于能够为每个服务运行更小的足迹,因此谨慎使用 cgroups 是以微服务形式运行应用程序的核心。

4.2 Linux 中的进程和线程

Linux 中的每个进程都可以创建一个或多个线程。一个执行 线程 是程序可以用来创建与其他进程共享相同内存的新进程的抽象。例如,我们可以通过使用 ps -T 命令来检查 Kubernetes 中各种独立调度线程的使用情况:

root@kind-control-plane:/# ps -ax | grep scheduler    ❶
631 ?  Ssl 60:14 kube-scheduler
  --authentication-kubeconfig=/etc/kubernetes/...

root@kind-control-plane:/# ps -T 631                  ❷

root@kind-control-plane:/# ps -T 631
  PID  SPID TTY      STAT   TIME COMMAND
  631   631 ?        Ssl    4:40 kube-scheduler --authentication-kube..
  631   672 ?        Ssl   12:08 kube-scheduler --authentication-kube..
  631   673 ?        Ssl    4:57 kube-scheduler --authentication-kube..
  631   674 ?        Ssl    4:31 kube-scheduler --authentication-kube..
  631   675 ?        Ssl    0:00 kube-scheduler --authentication-kube..

❶ 获取 Kubernetes 调度器 Pod 的 PID

❷ 查找 Pod 中的线程

这个查询向我们展示了共享彼此内存的并行调度线程。这些进程有自己的子进程 ID,对于 Linux 内核来说,它们都是普通的旧进程。尽管如此,它们有一个共同点:一个父进程。我们可以通过在我们的 kind 集群中使用 pstree 命令来调查这种父子关系:

/# pstree -t -c | grep sched                 ❶
|-containerd-sh-+-kube-scheduler-+-{kube-}   ❷
|               |                |-{kube-}
|               |                |-{kube-}
|               |                |-{kube-}
|               |                |-{kube-}
|               |                |-{kube-}
|               |                |-{kube-}
|               |                |-{kube-}
|               |                |-{kube-}
|               |                |-{kube-}
|               |                |-{kube-}
|               |                |-{kube-}
|               |                |-{kube-}
|               |                |-{kube-}

❶ 调度器具有父容器仿真层,因此它作为容器运行。

❷ 每个调度线程共享相同的父线程,即调度器本身。

containerd 和 Docker

我们还没有花费时间对比 containerd 和 Docker,但值得注意的是,我们的 kind 集群并不是使用 Docker 作为它们的容器运行时。相反,它们使用 Docker 来创建节点,然后每个节点都使用 containerd 作为运行时。由于各种原因,现代 Kubernetes 集群通常不会使用 Docker 作为 Linux 的容器运行时。Docker 对于开发者来说是一个很好的入门工具,用于运行 Kubernetes,但数据中心需要一种更轻量级的容器运行时解决方案,该解决方案与操作系统更深入地集成。大多数集群在最低级别执行 runC 作为容器运行时,其中 runC 被 containerd、CRI-O 或其他一些安装在节点上的高级命令行可执行程序调用。这导致 systemd 成为容器的父进程而不是 Docker 守护进程。

容器之所以如此受欢迎,其中一个原因是在 Linux 中,它们不会在程序及其宿主之间创建人工边界。相反,它们只是允许以轻量级和比基于虚拟机隔离更容易管理的方式调度程序。

4.2.1 systemd 和初始化进程

现在你已经看到了进程层次结构的实际应用,让我们退一步思考,究竟什么是进程。在我们的可靠的 kind 集群中,我们运行了以下命令来查看谁启动了整个闹剧(查看 systemd 状态日志的前几行)。记住,我们的 kind 节点(我们通过 exec 进入以完成所有这些操作)实际上只是一个 Docker 容器;否则,以下命令的输出可能会让你有些害怕:

root@kind-control-plane:/# systemctl status | head
kind-control-plane
  State: running
   Jobs: 0 queued
 Failed: 0 units
  Since: Sat 2020-06-06 17:20:42 UTC; 1 weeks 1 days
 CGroup: /docker/b7a49b4281234b317eab...9               ❶
         ├── init.scope
         │     ├── 1 /sbin/init
         └── system.slice
             ├── containerd.service                     ❷
             │     ├── 126 /usr/local/bin/containerd

❶ 这个单独的 cgroup 是我们的 kind 节点的父进程。

❷ containerd 服务是 Docker cgroup 的子进程。

如果你恰好有一台普通的 Linux 机器,你可以看到以下输出。这会给你一个更清晰的答案:

State: running
     Jobs: 0 queued
   Failed: 0 units
    Since: Thu 2020-04-02 03:26:27 UTC; 2 months 12 days
   cgroup: / 
           ├── docker
           │     ├── ae17db938d5f5745cf343e79b8301be2ef7
           │     │     ├── init.scope
           │     │     │     └── 20431 /sbin/init
           │     │     └── system.slice

system.slice 下,我们会看到

├── containerd.service
├──  3067 /usr/local/bin/containerd-shim-runc-v2
          -namespace k8s.io -id db70803e6522052e
├──  3135 /pause

在标准的 Linux 机器或 kind 集群节点中,所有 cgroups 的根目录是 /。如果我们真的想了解系统中所有进程的最终父 cgroup 是什么,那就是启动时创建的 / cgroup。Docker 本身是这个 cgroup 的子进程,如果我们运行一个 kind 集群,我们的 kind 节点是这个 Docker 进程的子进程。如果我们运行一个常规的 Kubernetes 集群,我们可能根本看不到 Docker cgroup,相反,我们会看到 containerd 本身是 systemd 根进程的子进程。如果你有一个可以 ssh 进入的 Kubernetes 节点,这可能是一个很好的后续练习。

如果我们沿着这些树向下遍历足够远,我们会在整个操作系统中找到所有可用的进程,包括任何容器启动的进程。请注意,进程 ID(PID),如前一个片段中的 3135,如果我们检查主机机器上的这些信息,实际上是高数值。这是因为容器外部的进程的 PID 与容器内部的进程的 PID 是不同的。如果你想知道为什么,回想一下我们在第一章中如何使用 unshare 命令来分离我们的进程命名空间。这意味着由容器启动的进程没有能力看到、识别或杀死其他容器中运行的进程。这是任何软件部署的重要安全特性。

你可能还在想为什么会有暂停进程。我们每个 containerd-shim 程序都有一个对应的暂停程序,它最初被用作创建我们的网络命名空间的一个占位符。暂停容器还帮助清理进程,并作为我们的 CRI 进行一些基本进程记录的占位符,帮助我们避免僵尸进程。

4.2.2 为我们的进程设置 cgroups

我们现在对这个调度器 Pod 的作用有了相当好的了解:它已经生成了几个子进程,而且很可能是 Kubernetes 创建的,因为它是由 containerd 生成的子进程,而 containerd 是 Kubernetes 在 kind 中使用的容器运行时。作为对进程工作方式的第一印象,你可以杀死 containerd 进程,然后你会自然地看到调度器和其子线程重新活跃起来。这是由 kubelet 本身完成的,它有一个 /manifests 目录。这个目录告诉 kubelet 关于一些即使在 API 服务器能够调度容器之前也应该运行的进程。实际上,Kubernetes 就是通过 kubelet 以这种方式安装的。使用 kubeadm(现在最常用的安装工具)安装 Kubernetes 的生命周期看起来大致如下:

  • kubelet 有一个包含 API 服务器、调度器和控制器管理器的 manifests 目录。

  • kubelet 由 systemd 启动。

  • kubelet 告诉 containerd(或任何容器运行时)开始运行 manifests 目录中的所有进程。

  • 一旦 API 服务器启动,kubelet 就会连接到它,然后运行 API 服务器请求它执行的所有容器。

镜像 Pod 悄悄接近 API 服务器

kubelet 有一个秘密武器:/etc/kubernetes/manifests 目录。这个目录会持续扫描,当 Pod 被放入其中时,它们就会被 kubelet 创建和运行。因为这些不是通过 Kubernetes API 服务器调度的,所以它们需要镜像自己,以便 API 服务器能够知道它们的存在。因此,在 Kubernetes 控制平面不知情的情况下创建的 Pod 被称为 镜像 Pod。

可以像查看其他 Pod 一样通过列出它们来查看镜像 Pod,使用 kubectl get pods -A,但它们是由独立于其他 Pod 的 kubelet 创建和管理的。这允许 kubelet 单独引导一个运行在 Pod 内的整个 Kubernetes 集群。相当狡猾!

你可能会问,“这一切与 cgroups 有什么关系?”实际上,我们一直在探索的调度器被识别为镜像 Pod,分配给它的 cgroups 是使用这个身份命名的。它有这个特殊身份的原因是,最初,API 服务器实际上并不知道镜像 Pod,因为它是由 kubelet 创建的。为了更具体一点,让我们用以下代码探索并找到它的身份:

apiVersion: v1
kind: Pod
metadata:
  annotations:
    kubernetes.io/config.hash: 155707e0c1919c0ad1
    kubernetes.io/config.mirror: 155707e0c19147c8      ❶
    kubernetes.io/config.seen: 2020-06-06T17:21:0
    kubernetes.io/config.source: file
  creationTimestamp: 2020-06-06T17:21:06Z
  labels:

❶ 调度器的镜像 Pod ID

我们使用调度器的镜像 Pod ID 来查找其 cgroups。你可以通过运行 editget action 对控制平面 Pod(例如,kubectl edit Pod -n kube-system kube-apiserver-calico-control-plane)来获取这些 Pod 的内容。现在,让我们运行以下命令,看看我们能否找到与我们的进程器相关联的任何 cgroups:

$ cat /proc/631/cgroup

使用此命令,我们使用之前找到的 PID 来询问 Linux 内核关于调度器的 cgroups 存在情况。输出相当令人畏惧(如下所示)。不必担心 burstable 文件夹;我们将在查看一些 kubelet 内部结构时解释 burstable 概念,它是一种服务质量或 QoS 类别。同时,一个 burstable Pod 通常是指没有硬使用限制的 Pod。调度器是一个典型的 Pod 示例,它通常具有在必要时使用大量 CPU 的能力(例如,在需要快速将 10 或 20 个 Pod 部署到节点上的情况下)。每个条目都有一个非常长的 cgroup 和 Pod 标识符 ID,如下所示:

13:name=systemd:/docker/b7a49b4281234b31
➥ b9/kubepods/burstable/pod155707e0c19147c../391fbfc..
➥ a08fc16ee8c7e45336ef2e861ebef3f7261d

因此,内核正在跟踪 /proc 位置中的所有这些进程,我们可以进一步挖掘以查看每个进程在资源方面的具体获取情况。为了简化进程 631 的整个 cgroups 列表,我们可以 cat cgroup 文件,如下所示。注意,为了便于阅读,我们已缩短了额外的长 ID:

root@kind-control-plane:/# cat /proc/631/cgroup

13:name=systemd:/docker/b7a49b42.../kubepods/burstable/pod1557.../391f...
12:pids:/docker/b7a49b42.../kubepods/burstable/pod1557.../391f...
11:hugetlb:/docker/b7a49b42.../kubepods/burstable/pod1557.../391f...
10:net_prio:/docker/b7a49b42.../kubepods/burstable/pod1557.../391f...
9:perf_event:/docker/b7a49b42.../kubepods/burstable//pod1557.../391f...
8:net_cls:/docker/b7a49b42.../kubepods/burstable//pod1557.../391f...
7:freezer:/docker/b7a49b42.../kubepods/burstable//pod1557.../391f...
6:devices:/docker/b7a49b42.../kubepods/burstable//pod1557.../391f...
5:memory:/docker/b7a49b42.../kubepods/burstable//pod1557.../391f...
4:blkio:/docker/b7a49b42.../kubepods/burstable//pod1557.../391f...
3:cpuacct:/docker/b7a49b42.../kubepods/burstable//pod1557.../391f...
2:cpu:/docker/b7a49b42.../kubepods/burstable//pod1557.../391f...
1:cpuset:/docker/b7a49b42.../kubepods/burstable//pod1557.../391f...
0::/docker/b7a49b42.../system.slice/containerd.service

我们将逐个查看这些文件夹,如下所示。不过,关于 docker 文件夹,你不必过于担心。因为我们处于一个 kind 集群中,docker 文件夹是所有内容的父文件夹。但请注意,实际上,我们的容器都在 containerd 中运行:

  • docker—运行在我们计算机上的 Docker 守护进程的 cgroup,本质上就像一个运行 kubelet 的虚拟机。

  • b7a49b42 . . .—我们的 Docker kind 容器名称。Docker 为我们创建了此 cgroup。

  • kubepods—Kubernetes 为其 Pods 保留的 cgroup 分区。

  • burstable—Kubernetes 的一个特殊 cgroup,定义了调度器获得的服务质量。

  • pod1557 . . .—我们的 Pod ID,它在我们的 Linux 内核中作为其自己的标识符。

在本书撰写时,Docker 已在 Kubernetes 中被弃用。你可以将示例中的 docker 文件夹视为“运行我们的 kubelet 的虚拟机”,因为 kind 本身实际上只是运行一个 Docker 守护进程作为 Kubernetes 节点,然后在节点内部安装 kubelet、containerd 等。因此,在探索 Kubernetes 时,请继续对自己重复说,“kind 本身并不使用 Docker 来运行容器。” 相反,它使用 Docker 来创建节点,并在这些节点内部安装 containerd 作为容器运行时。

我们现在已经看到,每个进程(对于 Linux 机器的 Kubernetes)最终都会落在 proc 目录的账本表中。现在,让我们探索这些字段对于更传统的 Pod:NGINX 容器意味着什么。

4.2.3 为普通 Pod 实现控制组

调度器 Pod 是一个有点特殊的情况,因为它在所有集群上运行,并不是你可能会直接想要调整或调查的东西。一个更现实的场景可能是你想要确认你正在运行的应用程序(如 NGINX)的 cgroups 是否被正确创建。为了尝试这一点,你可以创建一个类似于我们原始的 pod.yaml 的 Pod,该 Pod 运行带有资源请求的 NGINX 网络服务器。Pod 这一部分的规范看起来如下(可能对你来说很熟悉):

spec:
    containers:
    - image: nginx
      imagePullPolicy: Always
      name: nginx
      resources:
        requests:
          cpu: "1"
          memory: 1G

在这种情况下,Pod 定义了一个核心数(1)和内存请求(1 GB)。这两个都进入/sys/fs 目录下定义的 cgroups 中,内核强制执行 cgroup 规则。记住,你需要ssh进入你的节点来做这件事,或者如果你使用kind,可以使用docker exec -t -i 75 /bin/sh来访问kind节点的 shell。

结果是,现在你的 NGINX 容器以专用的方式运行,拥有 1 个核心和 1 GB 的内存访问权。在创建这个 Pod 之后,我们实际上可以直接通过遍历其内存字段的 cgroup 信息来查看其 cgroup 层次结构(再次运行ps -ax命令来追踪它)。这样做,我们可以看到 Kubernetes真正是如何响应我们给出的内存请求的。我们将留给你,读者,去实验其他这样的限制,看看操作系统是如何表达它们的。

如果我们现在查看我们内核的内存表,我们可以看到有一个标记表示为我们的 Pod 划分了多少内存。大约是 1 GB。当我们创建之前的 Pod 时,我们的底层容器运行时在一个内存有限的 cgroup 中。这解决了我们在本章最初讨论的精确问题——为内存和 CPU 隔离资源:

$ sudo cat /sys/fs/memory/docker/753../kubepods/pod8a58e9/d176../
    memory.limit_in_bytes
999997440

因此,Kubernetes 隔离的魔力实际上可以被视为 Linux 机器上常规的、由简单目录结构组织的资源分层分配。内核中有大量的逻辑来“正确处理”这些,但对于任何有勇气揭开盖子的人来说,这些都是容易访问的。

4.3 测试 cgroups

我们现在知道如何确认我们的 cgroups 是否被正确创建。但我们是怎样测试cgroups 是否被我们的进程所尊重的呢?这是一个众所周知的事实,容器运行时和 Linux 内核本身在精确隔离事物方面可能存在缺陷。例如,在某些情况下,如果其他进程没有资源饥饿,操作系统可能会允许容器在其分配的 CPU 配额之上运行。让我们运行一个简单的进程,使用以下代码来测试我们的 cgroups 是否正常工作:

$ cat /tmp/pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: core-k8s
  labels:
    role: just-an-example
    app: my-example-app
    organization: friends-of-manning
    creator: jay
spec:
  containers:
    - name: an-old-name-will-do
      image: busybox:latest
      command: ['sleep', '1000']
      resources:
        limits:             ❶
          cpu:  2
        requests:           ❷
          cpu: 1
      ports:
        - name: webapp-port
          containerPort: 80
          protocol: TCP

❶ 确保我们的 Pod 有足够的机会使用大量的 CPU

❷ 确保我们的 Pod 在获得完整的 CPU 核心访问权之前不会启动

现在,我们可以进入我们的 Pod 并运行一个(讨厌的)CPU 使用率命令。我们将在输出中看到top命令崩溃:

$ kubectl create -f pod.yaml
$ kubectl exec -t -i core-k8s /bin/sh    ❶

#> dd if=/dev/zero of=/dev/null          ❷
$ docker exec -t -i 75 /bin/sh

root@kube-control-plane# top             ❸
PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM   TIME+ COMMAND
91467 root    20   0    1292      4      0 R  99.7   0.0   0:35.89 dd

❶ 创建一个进入你的容器的 shell

❷ 通过运行 dd 无节制地消耗 CPU

❸ 在我们的 Docker kind节点上运行 top 命令来测量 CPU 使用量

如果我们将这个相同的进程隔离并重新运行这个实验会发生什么?为了测试这个,你可以将resources段落更改为以下内容:

resources:
        limits:
          cpu:  .1    ❶
        requests:
          cpu: .1     ❷

❶ 将 CPU 使用量限制在最大 0.1 核心

❷ 预留了整个 0.1 核心,保证了这一 CPU 份额

让我们重新运行以下命令。在这个第二个例子中,我们可以看到我们的kind节点发生了一个压力较小的场景:

root@kube-control-plane# top           ❶
PID USER      PR  NI   VIRT   RES   SHR S  %CPU  %MEM TIME+COMMAND
93311 root    20  0    1292   4     0   R  10.3  0.0  0:03.61 dd

❶ 这次只使用了 CPU 的 10%用于节点。

4.4 kubelet 如何管理 cgroups

在本章的早期,我们简要地提到了其他 cgroups,如blkio。诚然,有各种各样的 cgroups,了解它们是什么是有价值的,尽管 90%的时间,你只会关心大多数容器对 CPU 和内存隔离的关注。

在更低的层面,通过巧妙地使用/sys/fs/cgroup中列出的 cgroup 原语,可以暴露出控制这些资源如何分配给进程的旋钮。有些这样的组对 Kubernetes 管理员来说并不容易使用。例如,freezer cgroup 将相关任务组分配给单个可停止或可冻结的控制点。这种隔离原语允许高效地调度和取消调度群组进程(而且,讽刺的是,有些人批评 Kubernetes 在处理这类调度方面做得不好)。

另一个例子是blkio cgroup,这也是一个不太为人所知的资源,用于管理 I/O。查看/sys/fs/cgroup,我们可以看到 Linux 中可以分层分配的所有各种可量化的资源:

$ ls -d /sys/fs/cgroup/*
/sys/fs/cgroup/blkio freezer perf_event
/sys/fs/cgroup/cpu hugetlb pids
/sys/fs/cgroup/cpuacct memory rdma
/sys/fs/cgroup/cpu,cpuacct net_cls systemd
/sys/fs/cgroup/cpuset net_cls,net_prio unified
/sys/fs/cgroup/devices net_prio

你可以在mng.bz/vo8p上了解 cgroups 的原始意图。一些相关的文章可能已经过时,但它们提供了大量关于 cgroups 如何演变以及它们旨在做什么的信息。对于高级 Kubernetes 管理员来说,理解如何解释这些数据结构在查看不同的容器化技术及其如何影响你的底层基础设施时非常有价值。

4.5 深入了解 kubelet 如何管理资源

现在你已经了解了 cgroups 的来源,那么看看 kubelet 中如何使用 cgroups 就很有意义了;具体来说,是通过allocatable数据结构。查看一个示例 Kubernetes 节点(再次强调,你可以使用你的kind集群来做这件事),我们可以从kubectl get nodes -o yaml的输出中看到以下段落:

...
    allocatable:
      cpu: "12"
      ephemeral-storage: 982940092Ki
      hugepages-1Gi: "0"
      hugepages-2Mi: "0"
      memory: 32575684Ki
      pods: "110"

这些设置看起来熟悉吗?到现在,它们应该很熟悉了。这些资源是可用于为 Pods 分配资源的 cgroup 预算量。kubelet 通过确定节点上的总容量来计算这个值。然后它减去自身以及底层节点所需的 CPU 带宽,并从可分配资源量中减去这部分。这些数字的等式在 mng.bz/4jJR 中有记录,并且可以通过包括 --system-reserved--kubelet-reserved 在内的参数进行切换。这个值随后被 Kubernetes 调度器用来决定是否在这个特定节点上请求运行容器。

通常,你可能会用每个核心的一半来启动 --kubelet-reserved--system-reserved,留下 2 个核心的 CPU,大约有 1.5 个核心空闲来运行工作负载,因为 kubelet 不是一个对 CPU 非常渴求的资源(除了在突发调度或启动时)。在大规模下,所有这些数字都会分解,并依赖于与工作负载类型、硬件类型、网络延迟等因素相关的各种性能因素。作为一个等式,在调度方面,我们有以下实现(system-reserved 指的是健康操作系统运行所需资源量):

可分配 = 节点容量 - kube-reserved - system-reserved

例如,如果你有

  • 为节点预留的 16 个 CPU 核心

  • 集群中为 kubelet 和系统进程预留的 1 个 CPU 核心

可分配的 CPU 数量为 15 个核心。为了说明所有这些是如何与一个已调度、正在运行的容器相关联的

  • kubelet 在你运行 Pods 时创建 cgroups,以限制它们的资源使用。

  • 你的容器运行时在 cgroups 内启动一个进程,这保证了你在 Pod 规范中给出的资源请求。

  • systemd 通常启动 kubelet,它定期广播总可用资源到 Kubernetes API。

  • systemd 通常也会启动你的容器运行时(containerd、CRI-O 或 Docker)。

当你启动 kubelet 时,其中嵌入有父进程逻辑。此设置通过命令行标志(你应该保持启用)进行配置,这使得 kubelet 本身成为其子容器的高级 cgroup 父进程。前面的等式计算了一个 kubelet 可分配 cgroup 的总量。这被称为 可分配资源预算

4.5.1 为什么 Kubernetes 中操作系统不能使用交换空间?

要理解这一点,我们必须稍微深入到我们之前看到的特定 cgroups。还记得我们的 Pods 居住在特殊文件夹中,比如保证和突发吗?如果我们允许操作系统将不活跃的内存交换到磁盘,那么一个空闲进程可能会突然遇到缓慢的内存分配。这种分配会违反 Kubernetes 在定义 Pod 规范时提供给用户的 保证 访问内存,并使性能高度可变。

由于以可预测的方式调度大量进程比任何单个进程的健康状况更重要,我们在 Kubernetes 上完全禁用了交换。为了避免对此有任何混淆,如果你在启用了交换的机器上引导 kubelets,Kubernetes 安装程序,如kubeadm,会立即失败。

为什么不启用交换?

在某些情况下,薄分配内存可能对最终用户有益(例如,它可能允许你在系统上更密集地打包容器)。然而,为了适应这种类型的内存外观而带来的语义复杂性,对大多数用户来说并不成比例地有益。kubelet 的维护者尚未决定(目前)支持这种更复杂的内存概念,在像 Kubernetes 这样的系统中,这种 API 更改很难实现,Kubernetes 被数百万用户使用。

当然,就像技术中的其他一切一样,这正在迅速发展,在 Kubernetes 1.22 中,你会发现实际上有方法可以在启用交换内存的情况下运行(mng.bz/4jY5)。然而,这不建议用于大多数生产部署,因为它会导致工作负载的性能特征非常不稳定。

话虽如此,在容器运行时级别,资源使用(如内存)方面有很多微妙之处。例如,cgroups 如下区分软限制和硬限制:

  • 具有软内存限制的过程,随着时间的推移,其 RAM 的量会有所不同,这取决于系统负载。

  • 一个具有内存限制的过程,如果它在较长时间内超过了其内存限制,就会被终止。

注意,在需要因为这些原因终止进程的情况下,Kubernetes 会将退出代码和 OOMKilled 状态回传给你。你可以增加分配给高优先级容器的内存量,以降低嘈杂的邻居在机器上引起问题的可能性。让我们接下来看看这一点。

4.5.2 技巧:穷人的优先级旋钮

HugePages是一个最初在 Kubernetes 中不受支持的概念,因为它最初是一个以网络为中心的技术。随着它转向核心数据中心技术,更微妙的调度和资源分配策略变得相关。HugePages 配置允许 Pod 访问比 Linux 内核默认内存页面大小更大的内存页面,这通常是 4 KB。

内存,就像 CPU 一样,可以为 Pods 显式分配,并使用千字节、兆字节和吉字节(分别表示为 Kis、Mis 和 Gis)的单位表示。许多内存密集型应用程序,如 Elasticsearch 和 Cassandra,支持使用 HugePages。如果一个节点支持 HugePages 并且也支持 2048 KiB 页面大小,它将暴露一个可调度的资源:HugePages - 2 Mi。一般来说,在 Kubernetes 中使用标准resources指令可以调度对 HugePages 的访问,如下所示:

resources:
  limits:
    hugepages-2Mi: 100Mi

透明 HugePages 是对 HugePages 的优化,它可能对需要高性能的 Pods 有高度可变的影响。在某些情况下,您可能希望禁用它们,特别是对于需要在引导加载程序或操作系统级别拥有大块连续内存的高性能容器。

4.5.3 Hack: 使用 init 容器编辑 HugePages

我们现在已经回到了起点。记得在本章开始时我们查看 /sys/fs 目录以及它是如何为容器管理各种资源的吗?如果可以以 root 权限运行并使用容器挂载 /sys 来编辑这些文件,HugePages 的配置可以在 init 容器中完成。

通过仅向 sys 目录写入文件即可切换 HugePages 的配置。例如,要关闭透明 HugePages,这可能在某些操作系统上对您有性能影响,您通常会运行一个如 echo 'never' > /sys/kernel/mm/redhat_transparent_hugepage/enabled 的命令。如果您需要以特定方式设置 HugePages,您可以从 Pod 规范中完全完成,如下所示:

  1. 声明一个 Pod,假设它基于 HugePages 有特定的性能需求。

  2. 在此 Pod 中声明一个 init 容器,该容器以特权模式运行并使用 hostPath 卷类型挂载 /sys 目录。

  3. init 容器执行任何 Linux 特定命令(如前面的 echo 语句)作为其唯一的执行步骤。

通常,init 容器可用于启动某些可能对 Pod 正确运行所需的 Linux 功能。但请记住,每次您挂载 hostPath 时,您都需要在您的集群上拥有特殊权限,管理员可能不会轻易给您。一些发行版,如 OpenShift,默认拒绝 hostPath 卷挂载。

4.5.4 QoS 类别:为什么它们很重要以及它们是如何工作的

我们在本章中看到了诸如 guaranteedburstable 等术语,但我们还没有定义这些术语。为了定义这些概念,我们首先需要介绍 QoS。

当您去一家高档餐厅时,您期望食物很棒,但您也期望服务员反应迅速。这种反应速度被称为服务质量或 QoS。当我们探讨为什么 Kubernetes 中禁用交换以保证内存访问性能时,我们提到了 QoS。QoS 指的是资源的即时可用性。任何数据中心、虚拟机管理程序或云都必须在应用程序的资源可用性上进行权衡

  • 保证关键服务正常运行,但您花费了大量金钱,因为您拥有的硬件比所需的要多

  • 花很少的钱,冒着关键服务中断的风险

QoS 允许您在高峰时段让许多服务表现不佳,同时不牺牲关键服务的质量。在实践中,这些关键服务可能是支付处理系统、成本高昂的重启机器学习或 AI 任务,或者不能中断的实时通信过程。请记住,Pod 的驱逐很大程度上取决于其资源限制以上的程度。一般来说

  • 表现良好且内存和 CPU 使用量可预测的应用程序在压力情况下被驱逐的可能性比其他应用程序低。

  • 贪婪的应用程序在压力期间更有可能在尝试使用比 Kubernetes 分配的更多 CPU 或内存时被终止,除非这些应用程序属于保证类别。

  • 在压力情况下,尽力而为 QoS 类的应用程序很可能被终止并重新调度。

您可能想知道我们如何决定使用哪种 QoS 类。一般来说,您不会直接决定这一点,而是通过确定您的应用程序是否需要通过 Pod 规范中的 resource 段落保证对资源的访问来影响这一决定。我们将在下一节中介绍这一过程。

4.5.5 通过设置资源创建 QoS 类

根据您如何定义 Pod,系统为您创建了三种 QoS 类:可突发、保证和尽力而为。这些设置可以增加您在集群上可以运行的容器数量,其中一些可能在高负载时停止运行,但可以在稍后重新调度。制定全局策略来决定为最终用户分配多少 CPU 或内存可能很有吸引力,但请注意,很少有一种方案适合所有人:

  • 如果您系统上的所有容器都具有保证 QoS,那么您处理具有调节资源需求动态工作负载的能力将受到阻碍。

  • 如果您的服务器上没有容器具有保证 QoS,那么 kubelet 将无法确保某些关键进程保持运行。

QoS 确定的规则如下(这些是在您的 Pod 中计算并显示为 status 字段的):

  • 尽力而为 Pod 是那些没有 CPU 或内存请求的 Pod。当资源紧张时,它们很容易被终止和替换(并且很可能出现在新的节点上)。

  • 可突发 Pod 是那些具有内存或 CPU 请求但没有为所有类别定义限制的 Pod。与尽力而为 Pod 相比,它们不太可能被替换。

  • 保证 Pod 是那些具有 CPU 和内存请求的 Pod。与可突发 Pod 相比,它们不太可能被替换。

让我们看看实际效果。通过运行 kubectl create ns qos; kubectl -n qos run --image=nginx myapp 创建一个新的部署。然后,编辑部署以包含一个容器规范,该规范声明了请求但没有定义限制。例如:

spec:
      containers:
      - image: nginx
        imagePullPolicy: Always
        name: nginx
        resources:
          requests:
            cpu: "1"
            memory: 1G

当你运行 kubectl get Pods -n qos -o yaml 时,你会看到你的 Pod 的 status 字段被分配了一个 Burstable 类,如下面的代码片段所示。在关键时刻,你可能使用这种技术来确保你业务中最关键的过程都具有保证或 Burstable 状态。

hostIP: 172.17.0.3
    phase: Running
    podIP: 192.168.242.197
    qosClass: Burstable
    startTime: "2020-03-08T08:54:08Z"

4.6 使用 Prometheus、cAdvisor 和 API 服务器监控 Linux 内核

我们在本章中探讨了大量的低级 Kubernetes 概念,并将它们映射到操作系统上,但在现实世界中,你不会手动管理这些数据。相反,为了系统指标和整体趋势,人们通常会在单个时间序列仪表板上汇总容器和系统级别的操作系统信息,这样在紧急情况下,他们可以确定问题的规模并从不同的角度(应用、操作系统等)深入调查。

为了结束本章,我们将稍微提升一下层次,使用 Prometheus,这是云原生应用的行业标准监控工具,以及 Kubernetes 自身的监控工具。我们将探讨如何通过直接检查 cgroups 来量化 Pod 资源使用情况。这有几个在端到端系统可见性方面的优势:

  • 它可以看到可能超出你的集群范围且对 Kubernetes 不可见的隐蔽进程。

  • 你可以直接将 Kubernetes 所知的资源映射到内核级别的隔离工具,这可能会揭示你的集群与操作系统交互方式中的错误。

  • 这是一个了解 kubelet 和你的容器运行时如何大规模实现容器的绝佳工具。

在我们讨论 Prometheus 之前,我们需要谈谈指标。在理论上,指标是某种可量化值;例如,你上个月吃了多少个汉堡。在 Kubernetes 的世界里,数据中心中在线和离线的容器众多,这使得应用指标对于管理员来说非常重要,因为它提供了一个客观且与应用无关的模型来衡量数据中心服务的整体健康状况。

持续使用汉堡的隐喻,你可能有一组看起来像以下代码片段的指标,你可以将其记在日记本上。我们将关注三种基本的指标类型——直方图、仪表盘和计数器:

  • 仪表盘:指示在任何给定时间每秒接收到的请求数量。

  • 直方图:显示不同类型事件的时序区间(例如,在 500 毫秒内完成的请求数量)。

  • 计数器:指定连续增加的事件计数(例如,你总共看到了多少请求)。

作为可能更贴近我们日常生活的具体例子,我们可以输出 Prometheus 关于我们每日卡路里消耗的指标。下面的代码片段显示了这一输出:

meals_today 2                              ❶
cheeseburger 1                             ❷
salad 1
dinner 1
lunch 1
calories_total_bucket_bucket[le=1024] 1    ❸

❶ 你今天总共吃了多少顿饭

❷ 你今天吃了多少个汉堡

❸ 你摄入的卡路里,被分入 2、4、8、16 等桶中,最高可达 2,048

你可能会每天发布一次总餐数。这被称为度量,因为它会上下波动,并定期更新。你今天吃的汉堡包数量将是一个计数器,随着时间的推移会不断递增。对于你摄入的卡路里,这个度量数据表示你吃了一顿少于 1,024 卡路里的餐。这为你提供了一种离散的方式来分类你吃了多少,而不会陷入细节(任何超过 2,048 的都可能是太多了,任何低于 1,024 的都可能是太少了)。

注意,这样的桶通常用于长期监控 etcd。超过 1 秒的写入量对于预测 etcd 故障很重要。随着时间的推移,如果我们汇总了你每天记录的日记条目,只要记录了这些度量数据的时间,你可能会发现一些有趣的关联。例如:

meals_today 2
cheeseburger 50
salad 99
dinner 101
lunch 99

calories_total_bucket_bucket[le=512] 10
calories_total_bucket_bucket[le=1024] 40
calories_total_bucket_bucket[le=2048] 60

如果你将这些度量数据分别绘制在各自的 y 轴上,x 轴为时间,你可能能够看到

  • 吃汉堡包的日子与吃早餐的日子成反比。

  • 你吃汉堡包的数量正在稳步增加。

4.6.1 度量数据的发布成本低,价值极高

度量数据对于容器化和基于云的应用程序非常重要,但它们需要以轻量化和解耦的方式进行管理。Prometheus为我们提供了工具,使我们能够在不创建任何不必要的样板代码或框架的情况下,实现大规模的度量数据。它旨在满足以下要求:

  • 数百或数千个不同的进程可能会发布类似的度量数据,这意味着给定的度量数据需要支持元数据标签来区分这些进程。

  • 应用程序应以语言无关的方式发布度量数据。

  • 应用程序应该发布度量数据,而无需了解这些度量数据是如何被消费的。

  • 对于任何开发者来说,无论他们使用什么语言,发布服务的度量数据都应该很容易。

在程序上,如果我们要在之前的类比中记录饮食选择,我们会声明cheeseburgermeals_todaycalories_total的实例,这些实例分别属于countergaugehistogram类型。这些类型将是 Prometheus API 类型,支持将本地值自动存储到内存中的操作,这些操作可以从本地端点作为 CSV 文件抓取。通常,这是通过向 REST API 服务器添加 Prometheus 处理器来完成的,该处理器仅服务于一个有意义的端点:metrics/。为了管理这些数据,我们可能会使用像这样的 Prometheus API 客户端:

  • 定期观察我们今天吃了多少顿饭的值,因为这是一个度量 API 调用

  • 定期,在午餐后立即增加cheeseburger的值

  • 每日,汇总calories_total的值,这些值可以从不同的数据源中获取

随着时间的推移,我们可能会关联吃汉堡与每天总热量摄入量增加之间的关系,我们也许还能将这些其他指标(例如,我们的体重)与这些值联系起来。尽管任何时间序列数据库都能实现这一点,但作为轻量级指标引擎的 Prometheus,在容器中表现良好,因为它完全由进程以独立和无状态的方式发布,并且已经成为向任何应用程序添加指标的现代标准。

不要等待发布指标

人们常常错误地认为 Prometheus 是一个重量级系统,需要集中安装才能发挥作用。实际上,它仅仅是一个开源的计数工具和一个可以嵌入任何应用程序的 API。Prometheus 主节点能够抓取和整合这些信息的事实显然是这一故事的核心,但并不是开始为您的应用程序发布和收集指标的要求。

任何微服务都可以通过导入 Prometheus 客户端在端点上发布指标。尽管您的集群可能不会消费这些指标,但没有理由不在容器侧提供这些指标,至少可以手动检查应用程序各种可量化方面的计数,如果您想观察它,还可以启动一个临时的 Prometheus 主节点。

所有主要编程语言都有 Prometheus 客户端。因此,对于任何微服务,将各种事件的日常活动记录为 Prometheus 指标既简单又便宜。

4.6.2 为什么我需要 Prometheus?

在这本书中,我们专注于 Prometheus,因为它在云原生领域中是事实上的标准,但我们将通过一个简单而强大的示例来说服您,这个示例展示了如何快速检查 API 服务器内部的工作情况。例如,您可以在终端中运行以下命令来查看 Pod 请求是否对您的 Kubernetes API 服务器造成了很大压力(假设您的kind集群已经启动并运行)。在另一个终端中运行kubectl proxy命令,然后按照如下方式curl API 服务器的指标端点:

$ kubectl proxy                               ❶

$> curl localhost:8001/metrics |grep etcd     ❷
etcd_request_duration_seconds_bucket{op="get",type="*core.Pod",le="0.005"}
174
etcd_request_duration_seconds_bucket{op="get",type="*core.Pod",le="0.01"}
194
etcd_request_duration_seconds_bucket{op="get",type="*core.Pod",le="0.025"}
201
etcd_request_duration_seconds_bucket{op="get",type="*core.Pod",le="0.05"}
203

❶ 允许您访问 localhost:8001 上的 Kubernetes API 服务器

❷ 卷曲 API 服务器的指标端点

任何拥有kubectl客户端的人都可以立即使用curl命令来获取特定 API 端点的响应时间实时指标。在前面的代码片段中,我们可以看到几乎所有的对 Pod API 端点的get调用都在不到.025 秒内返回,这通常被认为是合理的性能。在本章的剩余部分,我们将从头开始为您kind集群设置 Prometheus 监控系统。

4.6.3 创建本地 Prometheus 监控服务

我们可以使用 Prometheus 监控服务来检查在压力下 cgroup 和系统资源的利用情况。在 kind 上的 Prometheus 监控系统架构(图 4.2)包括以下内容:

  • Prometheus 主节点

  • 主节点监控的 Kubernetes API 服务器

  • 许多 kubelet(在我们的例子中为 1 个),每个都是 API 服务器聚合指标信息的数据源

图片

图 4.2 Prometheus 监控部署架构

注意,通常情况下,Prometheus 主节点可能会从许多不同的来源抓取指标,包括 API 服务器、硬件节点、独立数据库,甚至是独立的应用程序。然而,并非所有服务都能方便地聚合到 Kubernetes API 服务器上以供使用。在这个简单的例子中,我们想看看如何使用 Prometheus 来监控 Kubernetes 上的 cgroup 资源使用情况,而且方便的是,我们可以通过直接从 API 服务器抓取所有节点的数据来实现这一点。此外,请注意,我们这个例子中的 kind 集群只有一个节点。即使我们有更多的节点,我们也可以通过在 scrape YAML 文件(我们将在稍后介绍)中添加更多的 target 字段来直接从 API 服务器抓取所有这些数据。

我们将使用以下配置文件启动 Prometheus。然后我们可以将配置文件存储为 prometheus.yaml:

$ mkdir ./data
$ ./prometheus-2.19.1.darwin-amd64/prometheus \
      --storage.tsdb.path=./data --config.file=./prometheus.yml

kubelet 使用 cAdvisor 库来监控 cgroup 并收集有关它们的可量化数据(例如,特定组中的 Pod 使用了多少 CPU 和内存)。因为您已经知道如何浏览 cgroup 文件系统层次结构,所以阅读由 cAdvisor 指标收集的 kubelet 输出将为您带来“啊哈”的时刻(在理解 Kubernetes 本身如何连接到底层内核资源会计方面)。为了抓取这些指标,我们将告诉 Prometheus 每 3 秒查询 API 服务器一次,如下所示:

global:
  scrape_interval: 3s
  evaluation_interval: 3s

scrape_configs:
  - job_name: prometheus
    metrics_path:
      /api/v1/nodes/kind-control-plane/
      proxy/metrics/cadvisor           ❶
    static_configs:
      - targets: ['localhost:8001']    ❷

❶ kind 控制平面节点是我们集群中唯一的节点。

❷ 在我们的集群中添加更多节点或在此处后续作业中抓取更多内容。

现实世界的 Prometheus 配置必须考虑现实世界的限制。这包括数据大小、安全性和警报协议。请注意,时间序列数据库在磁盘使用方面非常贪婪,而且指标可以揭示很多关于您组织威胁模型的信息。这些可能在您早期的原型设计阶段并不重要,正如我们之前提到的,但最好是先从应用程序级别发布您的指标,然后稍后添加管理重型 Prometheus 安装的复杂性。对于我们的简单示例,这将是我们配置 Prometheus 以探索 cgroup 所需要的一切。

再次提醒,API 服务器定期从 kubelet 接收数据,这就是为什么只需要抓取一个端点就能工作的原因。如果不是这样,我们可以直接从 kubelet 本身收集这些数据,甚至运行我们自己的 cAdvisor 服务。现在,让我们看一下容器 CPU 用户秒数总计指标。我们将通过运行以下命令使其激增。

警告:此命令会立即在您的计算机上创建大量的网络和 CPU 流量。

$ kubectl apply -f \
https://raw.githubusercontent.com/
➥ giantswarm/kube-stresscheck/master/examples/node.yaml

此命令启动了一系列资源密集型的容器,这些容器消耗了集群的网络资源、内存和 CPU 周期。如果您在使用笔记本电脑,运行此命令产生的巨型 swarm 容器可能会引起大量的 CPU 峰值,您可能会听到一些风扇噪音。

在图 4.3 中,您将看到我们的kind集群在压力下的样子。我们将把它留给你作为练习,将各种容器 cgroups 和元数据(通过将鼠标悬停在 Prometheus 指标上找到)映射回您系统中运行的进程和容器。特别是,以下指标值得一看,以了解 Prometheus 中的 CPU 级监控。探索这些指标,以及当运行您喜欢的负载或容器时系统中的数百个其他指标,为您创建重要的监控和取证协议提供了良好的方式:

  • container_memory_usage_bytes

  • container_fs_writes_total

  • container_memory_cache

图片

图 4.3 在繁忙的集群中绘制指标

4.6.4 在 Prometheus 中描述故障

在结束本章之前,让我们更详细地查看三种指标类型,以确保万无一失。在图 4.4 中,我们比较了这三种指标在数据中心相同情况下提供不同视角的一般拓扑结构。具体来说,我们可以看到仪表给我们一个布尔值,指示我们的集群是否运行。同时,直方图显示了请求趋势的细粒度信息,直到我们完全失去应用程序。最后,计数器显示了导致故障的整体事务数:

  • 仪表读数对于可能负责应用程序正常运行时间的值班人员来说最有价值。

  • 直方图读数可能对进行“事后”取证调查为什么微服务长时间停机的一名工程师最有价值。

  • 计数器指标是一个很好的方法来确定在故障之前有多少成功的请求被服务。例如,在内存泄漏的情况下,我们可能会发现,在一定的请求次数(比如,15,000 或 20,000)之后,一个网络服务器会可预测地失败。

图片

图 4.4 比较在相同场景集群中仪表、直方图和计数器指标的外观

最终决定使用哪些度量来做出决策的是你,但一般来说,记住你的度量不应仅仅是一个信息堆放的地方。相反,它们应该帮助你讲述一个关于你的服务如何随时间行为和相互交互的故事。通用的度量很少对调试复杂问题有用,所以请花时间将 Prometheus 客户端嵌入到你的应用程序中,收集一些有趣、可量化的应用程序度量。你的管理员会感谢你的!我们将在 etcd 章节中再次回顾度量,所以不用担心——还有更多的 Prometheus 等着你!

摘要

  • 内核表达了容器对 cgroup 的限制。

  • kubelet 启动调度进程,并将其镜像到 API 服务器。

  • 我们可以使用简单的容器来检查 cgroups 如何实现内存限制。

  • kubelet 有 QoS 类别,这些类别细化了你的 Pods 中进程资源的配额。

  • 我们可以使用 Prometheus 来查看集群在压力下的实时度量。

  • Prometheus 表达了三种核心度量类型:仪表盘、直方图和计数器。

5 个 CNI 和为 Pod 提供网络

本章涵盖了

  • 以 kube-proxy 和 CNI 定义 Kubernetes SDN

  • 在传统的 SDN Linux 工具和 CNI 插件之间建立连接

  • 使用开源技术来管理 CNI 的操作方式

  • 探索 Calico 和 Antrea CNI 提供者

软件定义网络(SDN)传统上管理云中以及许多本地数据中心中的虚拟机的负载均衡、隔离和安全。SDN 是一种便利性,它减轻了系统管理员的负担,允许每周或每天重新配置大型数据中心网络,或者在创建或销毁新的虚拟机时进行重新配置。进入容器时代的未来,SDN 的概念获得了全新的意义,因为我们的网络不断变化(在大型 Kubernetes 集群中,每秒都在变化),因此,根据定义,它必须由软件自动化。Kubernetes 网络完全是软件定义的,并且由于 Kubernetes Pod 和服务端点的短暂和动态特性,它始终处于不断变化之中。

在本章中,我们将探讨 Pod 之间的网络连接,特别是如何在特定机器上的数百或数千个容器拥有独特且可集群路由的 IP 地址。Kubernetes 通过使用容器网络接口(CNI)标准,以模块化和可扩展的方式提供这一功能,该标准可以由广泛的技术实现,为每个 Pod 分配一个唯一的可路由 IP 地址。

CNI 规范没有指定容器网络的具体细节

CNI 规范是对将容器添加到网络的高级操作的通用定义。如果你从如何考虑 Kubernetes CNI 提供者的角度去理解它,一开始可能会有些困难。例如,一些 CNI 插件,如 IPAM 插件(www.cni.dev/plugins/current/ipam/),仅负责为容器找到一个有效的 IP 地址,而其他 CNI 插件,如 Antrea 或 Calico,在更高的层面上操作,根据需要将功能委托给其他插件。实际上,一些 CNI 插件根本不将 Pod 附加到网络,而是在更广泛的“让我们将这个容器添加到网络”的工作流程中扮演微小的角色。(理解这一点后,IPAM 插件是理解这一概念的好方法。)

请记住,你将在野外遇到的任何 CNI 插件都是一个独特的存在,可能在连接容器到网络的总体进展中处于不同的时间点。此外,一些 CNI 插件仅在引用它们的其他插件上下文中才有意义。

让我们回顾一下之前提到的 Pods,并回顾它们的核心网络需求。作为探索这个概念的一部分,我们之前讨论了 kube-proxy 如何管理 iptables 规则、nftables、IPVS(IP 虚拟服务器)和其他网络代理实现。我们还查看了各种 KUBE-SEP 规则,这些规则告诉 Linux 内核“伪装”流量,使得从容器中流出的流量被标记为来自节点,或者通过服务 IP 进行 NAT。然后,这些流量被转发到一个正在运行的 Pod,这个 Pod 可能位于我们集群中的不同节点上。

kube-proxy 在将服务路由到后端 Pod 方面非常出色,通常是用户首次接触到的第一个软件定义网络组件。例如,当你第一次运行并使用节点端口公开一个简单的 Kubernetes 应用程序时,你通过 kube-proxy 在你的 Kubernetes 节点上创建的路由规则访问一个 Pod。然而,如果没有在集群上有一个健壮的 Pod 网络,kube-proxy 并不是特别有用。这是因为,最终,它的唯一任务是映射一个服务 IP 地址到一个 Pod 的 IP 地址。如果那个 Pod 的 IP 地址在两个节点之间不可路由,那么 kube-proxy 的路由决策不会导致一个对最终用户可用的应用程序。换句话说,负载均衡器的可靠性仅与其最慢的端点相当。

kpng 项目和 kube-proxy 的未来

随着 Kubernetes 的增长,CNI 生态系统扩展到在 CNI 层面上实际实现 kube-proxy 服务路由功能。这允许 CNI 提供商如 Antrea、Calico 和 Cilium 为 Kubernetes 服务代理提供高性能和扩展的功能集(例如,监控和与其他负载均衡技术的原生集成)。

为了解决需要一个“可插拔”的网络代理的需求,它可以保留 Kubernetes 的一些核心逻辑,同时允许供应商扩展其他部分,kpng 项目(github.com/kubernetes-sigs/kpng)被创建并正在孵化为一个新的 kube-proxy 替代品。它极其模块化,并且完全位于 Kubernetes 代码库之外。如果你对 Kubernetes 负载均衡服务感兴趣,这是一个很好的项目,可以深入了解和学习,但截至本文写作时,它尚未准备好用于生产工作负载。

作为一种可能有一天能完全作为 kpng 扩展实现的替代 CNI 提供的网络代理的例子,你可以查看 Antrea 代理(目前是 Antrea 中的一个新功能)等项目,可以根据用户偏好开启或关闭。你可以在 mng.bz/AxGQ 找到更多信息。

5.1 为什么 Kubernetes 需要软件定义网络

容器网络难题可以这样定义:给定数百个 Pod,其中一些对应于相同的服务,我们如何始终如一地将流量路由到集群内部和外部,以便所有流量始终到达正确的位置,即使我们的 Pod 在移动?这是任何尝试在生产环境中运行非 Kubernetes 容器解决方案的人面临的明显第二天操作问题(例如,Docker)。为了解决这个问题,Kubernetes 给我们提供了两个基本的网络工具:

  • 服务代理——确保 Pod 可以在具有稳定 IP 的服务后面进行负载均衡,并路由 Kubernetes 服务对象

  • CNI——确保 Pod 可以在平坦且易于从集群内部访问的网络中不断重生

这个解决方案的核心是具有类型 ClusterIP 的 Kubernetes 服务对象。ClusterIP 服务是一种 Kubernetes 服务,它可以在您的 Kubernetes 集群内部进行路由,但不能从集群外部访问。它是在其他服务之上构建的基本原语。它也是集群内部应用程序之间相互访问的一种简单方式,无需直接路由到 Pod IP 地址(记住,如果 Pod 移动或死亡,Pod IP 可能会更改)。

例如,如果我们在一个 kind 集群中创建相同的服务三次,我们将看到它在 10.96 IP 空间中有三个随机的 IP 地址。为了验证这一点,我们可以通过连续三次运行 kubectl create service clusterip my-service-1 --tcp="100:100" 来重新创建相同的三个服务(当然,更改 my-service-1 的名称)。之后,我们可以这样列出服务 IP:

$ kubectl get svc -o wide
svc-1 ClusterIP 10.96.7.53    80/TCP 48s app=MyApp
svc-2 ClusterIP 10.96.152.223 80/TCP 33s app=MyApp
svc-3 ClusterIP 10.96.43.92   80/TCP 5s  app=MyApp

对于 Pod,我们也只有一个网络和子网。我们可以看到,在创建新的 Pod 时,新的 IP 地址可以轻松分配。因为我们的 kind 集群已经运行了两个 CoreDNS Pod,我们可以检查它们的 IP 地址以确认这一点:

$ kubectl get pods -A -o wide | grep coredns
kube-system coredns-74ff55c5b-nlxrs 1/1  Running 0 4d16h 192.168.71.1
➥ calico-control-plane <none> <none>
kube-system coredns-74ff55c5b-t4p6s 1/1  Running 0 4d16h 192.168.71.3
➥ calico-control-plane <none> <none>

我们刚刚看到了 Kubernetes SDN 的第一个重要课程:Pod 和服务 IP 地址由我们管理,并且位于不同的 IP 子网中。这在几乎我们将在现实世界中遇到的任何集群中(通常)都是恒定的。事实上,如果我们遇到一个这种情况并不成立的集群,那么有可能 Kubernetes 的某些其他行为已经被严重损害。这种行为可能包括 kube-proxy 路由流量或节点路由 Pod 流量的能力。

Kubernetes 控制平面规划了 Pod 和服务 IP 范围的路线

在 Kubernetes 中,有一个常见的误解,即 CNI 提供者负责服务以及 Pod IP 地址。实际上,当你创建一个新的 ClusterIP 服务时,Kubernetes 控制平面会根据你在启动时通过命令行选项(例如,--service-cluster-ip-range)提供的 CIDR 创建一个新的 IP,该 IP 与 --allocate-node-cidrs 选项一起使用。如果指定了,CNI 提供者通常会依赖于由 API 服务器分配的节点 CIDR。因此,CNI 和网络代理在高度本地化的层面上运作,发布由 Kubernetes 控制平面协调的整体集群配置指令。

5.2 实现 Kubernetes SDN 的服务端:kube-proxy

我们可以创建三种主要的 Kubernetes 服务 API 对象类型(正如你现在可能已经知道的那样):ClusterIPs、NodePorts 和 LoadBalancers。这些服务通过使用 labels 定义我们将连接到哪个后端 Pod。例如,在前面的集群中,我们在 10 子网中有 ClusterIP 服务,这些服务将流量路由到我们的 192 子网中的 Pod。流量目标为服务 IP 的路由是如何进入另一个子网的?它是由 kube-proxy(或更正式地说,Kubernetes 网络或服务代理)进行路由的。

在前面的例子中,我们运行了 kubectl create service my-service-1 --tcp= "100:100" 三次,并得到了三个类型的 ClusterIP 服务。如果我们将这些服务设置为 NodePort 类型,那么这些服务的 IP 将会是集群中的任何节点。如果我们将这些服务设置为 LoadBalancer 类型,那么如果我们在云中,我们的云将提供一个外部 IP,例如 35.1.2.3。这将可以在更广泛的互联网或在我们 Pod、节点或服务 IP 范围之外的网络中访问,具体取决于云提供商。

kube-proxy 是一个代理吗?

在 Kubernetes 的早期阶段,kube-proxy 本身就为传入请求打开了一个新的 Golang 例程;因此,服务实际上是作为用户空间进程实现的,这些进程继续响应流量。Kubernetes iptables 代理(以及后来的 IPVS 代理)和 Windows 内核代理的创建使得 kube-proxy 具有更高的可扩展性和 CPU 效率。

一些用户空间代理的使用案例仍然存在,但数量很少。例如,VMware 的 Tanzu Kubernetes Grid 使用用户空间代理来支持 Windows 集群,因为它不能依赖于内核空间代理。这是由于它在使用 Open vSwitch(OVS)的方式上存在架构差异。无论如何,kube-proxy 通常会告诉其他代理工具关于 Kubernetes 端点的信息,但它通常不被视为传统意义上的代理。

图 5.1 展示了从 LoadBalancer 到 Kubernetes 集群的流量流程。它描述了

  • kube-proxy 使用低级路由技术,如 iptables 或 IPVS,将流量从服务发送到 Pod 内部以及从 Pod 发出。

  • 当我们有一个类型为 LoadBalancer 的服务时,我们从外部世界获得一个 IP 地址。然后它将路由到我们的内部服务 IP。

图 5.1 从 LoadBalancer 到 Kubernetes 集群的流量流程

NodePort 与 ClusterIP 服务比较

NodePorts 是 Kubernetes 中的服务,它们在内部 Pod 网络之外的所有端口上暴露。它们提供了一个基础,您可以在其上构建负载均衡器。例如,您可能有一个在 ClusterIP 100.1.2.3:443 上提供服务的 Web 应用程序。

如果您想从集群外部访问该应用程序,每个节点都可能通过 NodePort 将流量转发到该服务。NodePort 的值是随机的;例如,它可能是 50491 这样的数字。因此,您可以通过 node_ip_1:50491、node_ip_2:50491、node_ip_3:50491 等来访问您的 Web 应用程序。

如果您对使用 externalTrafficPolicy 注解通过注释服务设置路由的更优方法感兴趣,这可能在所有操作系统和云类型上都不相同。如果您决定在服务路由上变得复杂,请务必深入了解细节。

NodePorts 建立在 ClusterIP 服务之上。ClusterIP 服务有一个内部 IP 地址,通常不与您的 Pod 网络重叠,它与您的 API 服务器同步。

为了乐趣而阅读 kube-proxy 的 iptables 规则

如果您想在一个真实的集群中看到完整的 iptables 配置,您可以查看位于 mng.bz/enV9 的 iptables-save-calico.md 文件。我们整理了这个文件,以便查看通常可能从运行在野外的 Kubernetes 集群中输出的所有 iptables 规则。

尤其是在这个文件中,我们注意到有三个主要的 iptables 表,对于 Kubernetes 来说,最重要的是 NAT 表。这是服务和服务 Pod 在大型集群中高度动态的起伏对集群造成影响的地方。正如本书的其他部分所提到的,不同的 kube-proxy 配置之间存在权衡,但到目前为止,最常用的代理是 iptables kube-proxy

5.2.1 kube-proxy 的数据平面

kube-proxy 需要能够处理流向和来自由服务支持的 Pod 的持续 TCP 流量。一个 IP 数据包具有某些基本属性,包括源和目的 IP 地址。在一个复杂的网络中,这些可能会因为数据包通过一系列路由器而改变,我们将 Kubernetes 节点(由于 kube-proxy)视为这样一个路由器。一般来说,对数据包目的地的操作被称为 NAT(指网络地址转换),这是几乎所有网络架构解决方案的基本方面。SNATDNAT 分别指源和目的 IP 地址的转换。

kube-proxy 的数据平面可以通过多种方式完成这项任务,并在启动时通过其 mode 配置指定给 kube-proxy。如果我们深入细节,会发现 kube-proxy 本身被组织成两个独立的控制路径:server_windows.go 和 server_others.go(两者都位于此处:mng.bz/EWxl)。server_windows.go 二进制文件被编译成 kube-proxy.exe 文件,并直接调用底层 Windows 系统 API(例如,用户空间代理的 netsh 命令以及 Windows 内核代理的 hcsshim 和 HCN [mng.bz/N6x2] 容器化 API)。

更常见的情况是在 Linux 上运行 kube-proxy。在这种情况下,运行的是不同的二进制程序(称为 kube-proxy)。这个程序不会将其 Windows 功能编译到其代码路径中。在 Linux 场景中,我们通常运行 iptables 代理。在您的 kind 集群中,kube-proxy 仅以默认 iptables 模式运行。您可以通过运行 kubectl edit cm kube-proxy -n kube-system 来确认 kube-proxy 的配置,并查看其 mode 字段:

  • ipvs 使用内核负载均衡器为服务编写路由规则(Linux)。

  • iptables 使用内核防火墙为服务编写路由规则(Linux)。

  • userspace 使用 Golang go func 工作进程创建一个进程,手动代理流量到 Pod(Linux)。

  • Windows 内核依赖于 hcsshim 和 HCN API 进行负载均衡,这与 OVS 相关的 CNI 实现不兼容,但与其他 CNIs(如 Calico)兼容(类似于 Linux 用户空间选项)。

  • Windows 用户空间还使用 netsh 处理某些路由方面。这对那些由于某些原因无法使用常规 Windows 内核 API 的人来说很有用。请注意,如果您在 Windows 上安装了 OVS 扩展,您可能需要使用用户空间代理,因为内核的 HCN API 不会以相同的方式工作。

注意:在本书中,我们将提到 informers、controllers 和 Operators 的概念,以及它们的行为并不总是均匀地针对发生的配置更改实现。尽管网络代理是用 Kubernetes controller 实现的,但它不会动态响应配置更改。因此,如果您想通过修改服务负载均衡的方式来玩您的 kind 集群,您需要编辑网络代理的 configMap,然后重启其 DaemonSet。(如果您愿意,可以通过杀死您的 DaemonSet 中的一个 Pod,然后查看 Pod 重生时的日志来做这件事。您应该会看到新的 kube-proxy 模式。)

然而,kube-proxy 只是定义 Kubernetes SDN 路由流量的方式之一。为了全面,我们可以将 Kubernetes 路由视为三个独立的层:

  • 外部负载均衡器或入口/网关路由器—将流量转发到 Kubernetes 集群。

  • kube-proxy——管理服务到 Pod 之间的转发。正如你可能已经知道的,术语 proxy 有点误导,因为通常,kube-proxy 只维护由内核或其他数据平面技术(如 iptables 规则)实现的静态路由规则。

  • CNI 提供器——无论我们是通过服务端点访问还是直接访问(Pod 到 Pod 网络),都会路由流量到和从 Pod。

最终,一个 CNI 提供器(如 kube-proxy)也会配置某种类型的规则引擎(如路由表)或 OVS 交换机,以确保节点之间或从外部世界到 Pod 的流量可以路由。如果你想知道为什么 kube-proxy 的技术不同于 CNIs,你并不孤单!许多 CNI 提供器正在努力自己实现一个完整的 kube-proxy,这样 Kubernetes 的 kube-proxy 就不再需要了。

5.2.2 关于 NodePort 是什么?

我们在本章的第一部分展示了 ClusterIP 服务,但我们还没有查看 NodePort 服务。现在让我们通过实际操作并创建一个新的 Kubernetes 服务来做到这一点。这将最终展示添加和修改负载均衡规则是多么容易。对于这个例子,让我们创建一个指向我们集群中 Pod 内运行的 CoreDNS 容器的 NodePort 服务。我们可以通过查看 kubectl get svc -o yaml kube-dns -n kube-system 的内容来快速组合它。然后我们可以将服务类型从 ClusterIP 更改为 NodePort,如下所示:

# save the following file to my-nodeport.yaml
apiVersion: v1
kind: Service
metadata:
  annotations:
    prometheus.io/port: "9153"
    prometheus.io/scrape: "true"
  labels:
    k8s-app: kube-dns
    kubernetes.io/cluster-service: "true"
    kubernetes.io/name: CoreDNS
  name: kube-dns-2                 ❶
  namespace: kube-system
spec:
  ipFamilies:
  - IPv4
  ipFamilyPolicy: SingleStack
  ports:
  - name: dns
    port: 53
    protocol: UDP
    targetPort: 53
  - name: dns-tcp
    port: 53
    protocol: TCP
    targetPort: 53
  - name: metrics
    port: 9153
    protocol: TCP
    targetPort: 9153
  selector:
    k8s-app: kube-dns
  sessionAffinity: None
  type: NodePort                   ❷
status:
  loadBalancer: {}

❶ 将服务命名为 kube-dns-2 以区分已存在的 kube-dns 服务

❷ 将此服务的类型更改为 NodePort

现在,如果我们运行 kubectl create -f my-nodeport.yaml,我们会看到为我们分配了一个随机端口。现在这个端口正在为我们转发流量到 CoreDNS:

kubectl get pods -o wide -A
kube-system   kube-dns     ClusterIP   10.96.0.10
              53/UDP,53/TCP,9153/TCP k8s-app=kube-dns
kube-system   kube-dns-2   NodePort    10.96.80.7
              53:30357/UDP,53:30357/TCP,9153:31588/TCP
              2m33s   k8s-app=kube-dns                  ❶

❶ 将随机端口 30357 和 31588 映射到端口 53

随机端口 30357 和 31588,从我们的 DNS 服务 Pod 映射到端口 53,在集群的所有节点上打开。这是因为所有节点都在运行 kube-proxy。在我们创建 ClusterIP 服务时,这些随机端口尚未分配。

如果你感到勇敢,我们将把这个作为一项练习留给你,在你的 kind Docker 节点上运行 iptables-save 并找出 kube-proxy 为你的新创建的服务 IP 地址编写的规则。 (如果你对 NodePort 感兴趣,你将喜欢我们后面的章节,关于如何在本地安装和测试 Kubernetes 应用程序。在那里,我们将创建几个服务来测试 Kubernetes 中的著名 Guestbook 应用程序。)

现在你已经对服务如何最终在内部 Pod 端口和外部世界之间配置路由规则有了些许了解,让我们来看看 CNI 提供程序。这些提供程序在整体 Kubernetes SDN 网络堆栈中位于服务代理的下一层。最终,我们的服务实际上只是在将流量从 10.96.80.7 路由到我们集群内运行的 Pod。这些 Pod 如何连接到有效的 IP 地址,以及它们如何接收这些流量?答案是……CNI 接口。

5.3 CNI 提供程序

CNI 提供程序实现了 CNI 规范(mng.bz/RENK),该规范定义了一个合同,允许容器运行时在启动时请求一个工作 IP 地址。它们还添加了此规范之外的其他高级功能(如实现网络策略或第三方网络监控集成)。例如,VMware 用户会发现他们可以免费使用 Antrea 作为 CNI 代理,并将其插入到 VMware 的 NSX 平台中,以实现实时容器监控和日志功能,这些功能是当前一些开源 CNI 提供程序所包含的。尽管从理论上讲,CNI 提供程序只需要路由 Pod 流量,但许多提供了额外的功能。以下是一些主要本地 CNI 的简要概述

  • Calico—一个基于 BGP 的 CNI 提供程序,它创建新的边界网关协议(BGP)路由规则以实现数据平面。Calico 还支持 XDP、NAND 和 VXLAN 路由选项(例如,在 Windows 上,以 VXLAN 模式运行 Calico 并不罕见)。作为一个高级 CNI,它具有使用类似于 Cilium 技术的替换kube-proxy的能力。

  • Antrea—一个使用桥来路由所有 Pod 流量的 OVS 数据平面 CNI 提供程序。它在许多高级路由和网络代理替换选项(AntreaProxy)方面与 Calico 相似。

  • Flannel—一个基于桥的 IP CNI 提供程序,现在不再常用。它是生产 Kubernetes 集群的原始主要 CNI 之一。

  • Google, EC2, and NCP—这些基于云的 CNI 使用专有软件来做出云感知的流量路由决策。例如,它们能够创建规则,直接在容器之间路由流量,而无需经过节点网络路径。

  • Cilium—一个基于 XDP 的 CNI 提供程序,它使用现代 Linux API 来路由流量,无需任何内核流量管理。在某些情况下,这允许容器之间的 IP 通信更快、更安全。Cilium 使用其先进的数据路径工具提供网络代理替代方案。

  • KindNet—一个简单的 CNI 插件,默认情况下用于kind集群,但它仅设计用于只有单个子网的单一简单集群。

还有许多其他 CNIs 可能特定于其他供应商或开源技术,以及为各种云环境(如 VMware、Azure、EKS 等)提供的专有 CNI 供应商。这些专有 CNIs 仅在其供应商的基础设施内运行,因此可移植性较低,但通常性能更好或与云功能更好地集成。一些 CNIs,如 Calico 和 Antrea,提供供应商特定和供应商中立的功能(例如 Tigera 或 NSX 特定的集成)。

5.4 深入了解两个 CNI 网络插件:Calico 和 Antrea

图 5.2 显示了 Calico 和 Antrea 插件中 CNI 网络的工作方式。这两个插件通过一系列路由规则和开源技术实现相同的目标状态。CNI 接口定义了任何容器网络解决方案的几个核心功能方面,所有 CNI 插件(例如 BGP 和 OVS)都以不同的方式实现该功能。如图 5.2 所示,不同的 CNIs 使用不同的底层技术栈。

图片

图 5.2 Calico 和 Antrea 插件中的 CNI 网络

kube-proxy是必需的吗?

我们将kube-proxy视为一个要求,但随着网络供应商越来越多地开始提出诸如 Cilium CNI 提供的扩展伯克利包过滤器(eBPF)或 Antrea CNI 提供的 OVS 代理等技术,这些技术可以简化运行kube-proxy的需求。这些技术通常借鉴了kube-proxy的内部逻辑,并尝试以使用不同底层数据平面的方式重现和实现它。然而,在本书出版时的大多数集群仍然使用传统的 iptables 或 Windows 内核代理。因此,我们将kube-proxy视为现代 Kubernetes 集群中的一个恒定特性。但随着云原生领域的扩展,请注意地平线上的这些花哨的替代方案。

5.4.1 CNI 插件的架构

Calico 和 Antrea 都具有类似的架构:一个 DaemonSet 和一个协调容器。为了设置这些,CNI 安装包括四个步骤(通常由您的 CNI 提供商完全自动化,因此可以在简单的 Linux 集群中通过一行命令完成):

  1. 安装kube-proxy,因为您的 CNI 提供者的协调控制器可能需要查询 Kubernetes API 服务器的功能。这通常在安装 Kubernetes 之前由任何 Kubernetes 安装程序为您完成。

  2. 在节点上安装一个二进制 CNI 程序(通常在如/opt/cni/bin 之类的目录中),该程序可以被容器运行时调用以创建一个具有 CNI 提供的 IP 地址的 Pod。

  3. 在您的集群中部署一个 DaemonSet,其中一个容器为它的驻留节点设置网络原语。这个 DaemonSet 在启动时为其主机执行之前的安装步骤。

  4. 在您的集群中部署一个协调容器,该容器可以聚合或代理来自 Kubernetes 的元数据;例如,在单个位置聚合 NetworkPolicy 信息,以便它可以很容易地被 DaemonSet Pods 消费和去重。

对于 CNI 插件没有强制性的架构,但整体上,DaemonSet 加控制器模式相当稳健。在 Kubernetes 中,对于任何旨在与 Kubernetes API 集成的代理导向过程,遵循这种模式通常是一个好主意。

注意 CNI 提供者会给 Pods 分配 IP 地址,但关于这个过程的工作方式的大多数假设最初都是偏向 Linux 操作系统的。因此,我们将查看 Calico 和 Antrea CNI 提供者,但在这样做的时候,你应该注意这些 CNIs 在其他操作系统中的行为可能会有所不同。例如,在 Windows 上,Calico 和 Antrea 通常不是作为 Pods 运行,而是作为使用 nssm 等工具的 Windows 服务运行。目前,一些支持 Linux 和 Windows 的更经得起考验的开源 CNI,如 Calico 和 Antrea,但还有很多其他的选择。

CNI 规范由我们的代理安装的二进制程序实现。特别是,它实现了三个基本的 CNI 操作:ADD、DELETE 和 CHECK,这些操作在 containerd 启动一个新的 Pod 或删除一个 Pod 时被调用。分别,这些操作

  • 将容器添加到网络中

  • 从网络中删除一个容器

  • 检查容器是否正确设置

5.4.2 让我们玩一些 CNI

最后,我们可以开始进行一些黑客活动了!让我们先在我们的 kind 集群中安装一个 Calico CNI 提供者。Calico 使用第 3 层路由(与桥接不同,桥接是一种第 2 层技术)来广播集群中 Pods 的路由。最终用户通常不会注意到这种差异,但对于管理员来说,这是一个重要的区别,因为一些管理员可能希望在他们的集群中为了更广泛的架构设计目标使用第 3 层概念(如 BGP 对等)或第 2 层概念(如基于 OVS 的流量监控):

  • BGP 代表边界网关协议,这是一种在互联网中广泛使用的第 3 层路由技术。

  • OVS 代表 Open vSwitch,这是一个基于 Linux 内核的 API,用于在您的操作系统中编程交换机以创建虚拟 IP 地址。

创建我们的 kind 集群的第一个步骤是禁用其默认的 CNI。然后我们将根据 YAML 规范重新创建它。例如:

$ cat << EOF > kind-Calico-conf.yaml
kind: Cluster
apiVersion: kind.sigs.k8s.io/v1alpha4
networking:
  disableDefaultCNI: true               ❶
  podSubnet: 192.168.0.0/16             ❷
nodes:                                  ❸
- role: control-plane
- role: worker
EOF
$ kind create cluster --name=calico --config=./kind-Calico-conf.yaml

❶ 禁用 kind-net CNI

❷ 将 192.168 子网划分,使其与我们的服务子网正交

❸ 向我们的集群添加第二个节点

kind-net CNI 是一个仅适用于单节点集群的最小 CNI。我们禁用它,以便可以使用真实的 CNI 提供者。所有我们的 Pod 都将位于 192.168 子网的大片区域上。Calico 为每个节点划分这部分,并且它应该与我们的服务子网正交。此外,在我们的集群中有一个第二个节点有助于我们理解 Calico 如何将本地流量与发往另一个节点的流量分开。

使用真实的 CNI 插件设置kind集群与我们已经做的不太一样。一旦这个集群启动,值得暂停一下,看看当 Pod 的 CNI 尚未可用时会发生什么。这会导致无法调度的 Pod,而这些 Pod 在 kubelet/manifests 目录中没有定义。你可以通过运行以下kubectl命令来看到这一点:

$ kubectl get pods --all-namespaces
NAMESPACE     NAME                       READY   STATUS    RESTARTS   AGE
kube-system   coredns-66bff467f8-86mgh   0/1     Pending   0          7m47s
kube-system   coredns-66bff467f8-nfzhz   0/1     Pending   0          7m47s

$ kubectl get nodes
NAME                   STATUS     ROLES    AGE    VERSION
Calico-control-plane   NotReady   master   2m4s   v1.18.2
Calico-worker          NotReady   <none>   85s    v1.18.2

5.4.3 安装 Calico CNI 提供者

到目前为止,我们的 CoreDNS Pod 将无法启动,因为 Kubernetes 调度器看到所有节点都是NotReady状态,正如之前的命令所示。如果不是这种情况,请检查你的 CNI 提供者是否已启动并运行。这种状态是基于 CNI 提供者尚未设置的事实确定的。CNIs 在 CNI 容器在 kubelet 的本地文件系统上写入/etc/cni/net.d文件时配置。为了使我们的集群运行起来,我们现在将安装 Calico:

$ wget https://docs.projectCalico.org/manifests/Calico.yaml
$ kubelet create -f Calico.yaml

大多数时候,Kubernetes 的安全问题很重要

本书专注于学习 Kubernetes 内部结构,但我们并没有花太多时间让每个命令都“滴水不漏”。例如,之前的命令从互联网上拉取清单文件并在你的集群中安装几个容器。如果你不完全理解这些命令的后果,请确保你在沙盒(如kind)中运行这些命令!

第十三章和第十四章提供了 Pod 和节点安全指南。除此之外,如果你对以应用为中心的安全感兴趣,像sigstore.dev/github.com/bitnami-labs/sealed-secrets这样的项目随着时间的推移已经发展起来,以解决围绕 Kubernetes 二进制文件、工件、清单甚至机密的各种安全担忧。如果你对以更安全的方式实现本书中使用的方便的 Kubernetes 惯用法感兴趣,那么深入研究这些(以及其他)Kubernetes 生态系统中的工具是值得的。有关一般 Kubernetes 安全概念的更多信息,请参阅kubernetes.io/docs/concepts/security/或随时加入 Kubernetes 安全邮件列表(mng.bz/QWz1)。

上一步创建了两种容器类型:每个节点上的 Calico-node Pod 和一个在任意节点上运行的 Calico-kube-controllers Pod。一旦这些容器启动,你的节点应该处于Ready状态,你也会看到 CoreDNS Pod 现在正在运行:

$ kubectl get pods --all-namespaces
NAMESPACE            NAME
kube-system          Calico-kube-cntrlrs-57-m5       ❶
kube-system          Calico-node-4mbc5               ❷
kube-system          Calico-node-gpvxm
kube-system          coredns-66bff467f8-98t8j
kube-system          coredns-66bff467f8-m7lj5
kube-system          etcd-Calico-control-plane
kube-system          kube-apiserver-Calico-control-plane
kube-system          kube-controller-mgr
kube-system          kube-proxy-8q5zq
kube-system          kube-proxy-zgrjf
kube-system          kube-scheduler-Calico-control-plane
local-path-storage   local-path-provisioner-b5-fsr

❶ 协调 Calico 节点容器

❷ 在每个节点上设置各种 BGP 和 IP 路由

在这个代码示例中,kube controller 容器协调 Calico 节点容器。每个 Calico 节点容器为给定节点上运行的每个容器设置各种 BGP 和 IP 路由。有两个是因为我们有两个节点。

Calico 和 Antrea 都挂载了所谓的hostPath卷类型。Calico-node 进程的 CNI 二进制文件随后访问这个 hostPath,它连接到你的 kubelet 上的/etc/cni/net.d/。kubelet 使用这个二进制文件在需要为新 Pod 分配 IP 地址时调用 CNI API,因此它可以被视为主机 CNI 提供程序的安装机制。记住,hostPath 卷类型(大多数情况下)是一个反模式,除非你在配置像 CNI 这样的低级 OS 功能。

在图 5.2 中,我们研究了 DaemonSet 功能作为 Calico 和 Antrea 都实现的接口。让我们通过运行kubectl get ds -n kube-system来看看 Calico 创建了什么。我们会看到有一个 Calico DaemonSet 在所有节点上运行 CNI Pod。当我们稍后运行 Antrea 时,我们会看到一个类似的 DaemonSet 用于 Antrea 代理。

因为 Linux CNI 插件通常会将 CNI 二进制文件推送到主机的系统路径中,所以我们可以将 CNI 插件视为实现了MountCniBinary方法。这可能不是正式 CNI 接口的一部分,但它最终将是你在野外看到的几乎所有 CNI 插件的一部分。

太好了!我们现在有一个 CNI 了。让我们通过运行docker exec进入我们的节点并四处看看 Calico 为我们创建了什么。在运行docker exec -t -i <your kind node> /bin/bash之后,我们可以开始查看 Calico 创建的哪些路由。例如:

root@Calico-control-plane:/# ip route
default via 172.18.0.1 dev eth0
172.18.0.0/16 dev eth0 proto kernel scope
              link src 172.18.0.3
192.168.9.128/26 via 172.18.0.2 dev tunl0
              proto bird onlink               ❶
blackhole 192.168.71.0/26 proto bird          ❷
192.168.71.1 dev cali38312ba5f3c scope link
192.168.71.2 dev califcbd6ecdce5 scope link

❶ 根据其子网识别发往另一个节点的流量。

❷ 该节点未匹配但位于 71 子网中的流量将被丢弃。

我们可以看到这里有两个 IP 地址:192.168.71.1 和 71.2. 这些 IP 地址与以字符串cali为前缀的两个设备相关联,这是我们的 Calico-node 容器创建的。这些设备是如何工作的?我们可以通过运行ip a命令来查看它们是如何定义的:

root@Calico-control-plane:/# ip a | grep califc
5: califcbd6ecdce5@if4: <BROADCAST,MULTICAST,UP,LOWER_UP>
➥ mtu 1440 qdisc noqueue state UP group default

现在我们可以看到节点为 Calico 相关的 Pod 创建了一个具有可识别名称的接口。例如:

root@Calico-control-plane:/# apt-get update -y;
➥ apt-get install tcpdump                         ❶
root@Calico-control-plane:/# tcpdump -s 0
➥ -i cali38312ba5f3c -v | grep 192                ❷
tcpdump: listening on cali38312ba5f3c, link-type EN10MB (Ethernet),
➥ capture size 262144 bytes

    10.96.0.1.443 > 192.168.71.1.59186: Flags [P.],
                    cksum 0x14d2 (incorrect -> 0x7189),
                    seq 520038628:520039301, ack 2015131286, win 502,
                    options [nop,nop,TS val 1110809235 ecr 1170831911],
                    length 673
    192.168.71.1.59186 > 10.96.0.1.443: Flags [.],
                    cksum 0x1231 (incorrect -> 0x9f10),
                    ack 673, win 502,
                    options [nop,nop,TS val 1170833141 ecr 1110809235],
                    length 0
    10.96.0.1.443 > 192.168.71.1.59186:
                    Flags [P.], cksum 0x149c (incorrect -> 0xa745),
                    seq 673:1292, ack 1, win 502,
                    options [nop,nop,TS val 1110809914 ecr 1170833141],
                    length 619
    192.168.71.1.59186 > 10.96.0.1.443:
                    Flags [.], cksum 0x1231 (incorrect -> 0x9757),
                    ack 1292, win 502,
                    options [nop,nop,TS val 1170833820 ecr 1110809914],
                    length 0
    192.168.71.1.59186 > 10.96.0.1.443:
                    Flags [P.], cksum 0x1254 (incorrect -> 0x362c),
                    seq 1:36, ack 1292, win 502,
                    options [nop,nop,TS val 1170833820 ecr 1110809914],
                    length 35
    10.96.0.1.443 > 192.168.71.1.59186:
                    Flags [.], cksum 0x1231 (incorrect -> 0x9734),
                    ack 36, win 502, options [nop,nop,TS val 1110809914
                    ecr 1170833820],
                    length 0

❶ 在容器中安装 tcpdump。

❷ 对 Calico 设备运行 tcpdump。

在我们的代码示例中,我们可以看到来自 10.96 子网对 71.1 IP 地址的入站流量。这个子网实际上是我们的 Kubernetes 服务 CoreDNS 容器的子网,这是我们的 DNS 容器通过 CNI 接触到的点。之前的cali3831...设备是直接通过某种以太网电缆(就像任何其他设备一样)连接到我们的节点上的。这被称为一个veth对,其中我们的容器本身有一个虚拟以太网电缆(命名为 cali3831)的一端直接插入到我们的 kubelet 中。这意味着任何试图从我们的 kubelet 访问这个设备的人都可以轻松做到。

现在,让我们回到我们之前展示的 IP 路由表。dev条目现在很清晰。这些对应于直接连接到我们的容器的路由。但是,关于blackhole192.168.9.128/26路由呢?这些路由对应于

  • 属于另一个节点(192.168.9.128/26 路由)的容器

  • 属于任何节点(黑洞路由)的容器

这就是 BGP 在起作用。我们集群中每个运行 Calico-node 守护进程的节点都有一个 IP 地址范围,这些 IP 地址被路由到它。随着新节点的上线,这些路由会随着时间的推移添加到我们的 IP 路由表中。如果你运行kubectl scale deployment coredns -n kube-system --replicas=6,你会发现所有 IP 地址都出现在两个不同的子网之一:

  • 一些 Pod 出现在 192.168.9 子网中。 这些对应于我们的一个节点。

  • 其他 Pod 出现在 192.168.71 子网中。 这些对应于另一个节点。

你在集群中看到的节点越多,你将拥有的子网就越多。每个节点都有自己的 IP 地址范围,你的 CNI 提供者使用这个 IP 地址范围来分配给定节点上 Pod 的 IP 地址,以避免节点间 Pod IP 地址的冲突。这也是一种性能优化,因为不需要对 Pod IP 地址空间进行全局协调。因此,我们可以看到 Calico 通过为单个节点划分 IP 池并随后与内核中的路由表协调这些池来为我们管理 IP 地址范围。

5.4.4 使用 OVS 和 Antrea 的 Kubernetes 网络

对于普通用户来说,Antrea 和 Calico 看起来做的是同一件事:在多节点集群上的容器之间路由流量。然而,当你揭开盖子看看时,如何实现这一点有很多微妙之处。

OVS 是 Antrea 用来提供其 CNI 功能的东西。与 BGP 不同,它不使用 IP 地址作为从节点到节点直接路由的机制,就像我们在 Calico 中看到的那样。相反,它创建了一个在本地的 Kubernetes 节点上运行的桥接器。这个桥接器是使用 OVS 创建的。OVS 实际上是软件定义交换机(就像你在任何电脑店都能买到的那些)。当运行 Antrea 时,OVS 是我们 Pod 与外界之间的接口。

交换机(也称为层 2)和 IP(也称为层 3)路由之间的优缺点超出了本书的范围,并且在学术界和软件公司中都有激烈的争论。在我们的情况下,我们只能说这些是不同的技术,它们都工作得相当好,并且可以轻松扩展以处理数千个 Pod。

让我们再次尝试使用 kind 创建我们的集群,这次使用 Antrea 作为我们的 CNI 提供者。首先,使用 kind delete cluster --name=calico 删除你的最后一个集群,然后我们将使用下面的代码片段重新创建它:

$ cat << EOF > kind-Antrea-conf.yaml
kind: Cluster
apiVersion: kind.sigs.k8s.io/v1alpha3
networking:
  disableDefaultCNI: true
  podSubnet: 192.168.0.0/16
nodes:
- role: control-plane
- role: worker
EOF
$ kind create cluster --name=Calico --config=./kind-Antrea-conf.yaml

一旦你的集群启动,运行

kubectl apply -f https://github.com/vmware-tanzu/Antrea/
➥ releases/download/v0.8.0/Antrea.yml -n kube-system

然后,再次运行 docker exec 并查看你的 kubelets 中的 IP 情况。这次,我们看到为我们创建了几个不同的接口。请注意,我们省略了你在两个 CNIs 中都会看到的 tun0 接口。这是节点之间封装流量流过的网络接口。

有趣的是,当我们运行 ip route 命令时,我们看不到每个正在运行的 Pod 都有一个新的路由。这是因为 OVS 使用了一个桥接器,因此,以太网电缆仍然存在,但它们都直接插入了我们本地运行的 OVS 实例。运行以下命令,我们可以看到 Antrea 中的子网逻辑,这与我们在 Calico 中看到的类似:

root@Antrea-control-plane:/# ip route
172.18.0.0/16 dev eth0 proto kernel scope link src 172.18.0.3
192.168.0.0/24 dev Antrea-gw0 proto kernel scope link
               src 192.168.0.1                         ❶
192.168.1.0/24 via 192.168.1.1 dev
               Antrea-gw0 onlink                       ❷

❶ 通过 0.0 后缀定义了目标我们本地子网的流量

❷ Antrea 网关管理目标另一个子网且带有 1.0 后缀的流量。

现在,为了确认这一点,让我们运行 ip a 命令。这将显示我们机器理解的所有不同 IP 地址:

$ docker exec -t -i ba133 /bin/bash
root@Antrea-control-plane:/# ip a
# ip a
3: ovs-system: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state
    DOWN group default qlen 1000
    link/ether 2e:24:a8:d8:a3:50 brd ff:ff:ff:ff:ff:ff
4: genev_sys_6081: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 65000 qdisc
    noqueue master ovs-system state
    UNKNOWN group default qlen 1000
    link/ether 76:82:e1:8b:d4:86 brd ff:ff:ff:ff:ff:ff
5: Antrea-gw0:<BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue state
    UNKNOWN group default qlen 1000
    link/ether 02:09:36:d3:cf:a4 brd ff:ff:ff:ff:ff:ff
    inet 192.168.0.1/24 brd 192.168.0.255 scope global Antrea-gw0
       valid_lft forever preferred_lft forever

当我们运行 ip a 命令时,值得注意的是我们可以看到几个不熟悉的设备在周围浮动。这些包括

  • genev_sys_6081—Genev 接口,这是 Antrea 使用的隧道协议

  • ovs-system—一个 OVS 接口

  • Antrea-gw0—一个将流量发送到 Pod 的 Antrea 接口

与 Calico 不同,Antrea 实际上会将流量路由到网关 IP 地址,该地址位于使用我们集群 podCIDR 的 Pod 子网上。因此,Antrea 为给定节点设置 Pod IP 地址的算法类似于以下内容:

  1. 为每个节点分配一个 Pod IP 地址子网

  2. 将子网中的第一个 IP 地址分配给给定节点的 OVS 交换机

  3. 将所有新的 Pod 分配到子网中剩余的空闲 IP 地址

这样的集群的路由表将遵循一种按时间顺序排列节点的模式。请注意,每个节点都接收 x.y.z.1 IP 地址上的流量(其分配子网中的第一个 Pod)。每个 Pod 的子网计算方式依赖于你的 Kubernetes 实现以及你的 CNI 提供者逻辑。在某些 CNIs 中,可能每个节点都没有独立的子网,但通常,这是一种 CNI 随时间管理 IP 地址的直观方式,因此相当常见。

请记住,两者Calico 和 Antrea 都会为节点 Pod 网络创建不同的子网,并且从该子网为 Pod 分配 IP 地址。如果你需要调试 CNI 中的网络路径,了解哪些 Pod 将发送到哪些节点可能会帮助你推理出你应该重启、ssh进入或完全删除哪些机器,这取决于你的 DevOps 实践。

以下片段展示了antrea-gw0设备。这是你集群中所有 Pod 的网关 IP 地址:

192.168.0.0/24 dev Antrea-gw0 proto kernel scope
               link src 192.168.0.1                       ❶
192.168.1.0/24 via 192.168.1.1 dev Antrea-gw0 onlink      ❷
192.168.2.0/24 via 192.168.2.1 dev Antrea-gw0 onlink      ❸

❶ 所有本地 Pod 都直接发送到本地 Antrea-gw0 设备。

❷ 将目标为集群第二个节点的 Pod 转发到该 OVS 实例

❸ 将目标为集群第三个节点的 Pod 转发到该 OVS 实例

因此,我们可以看到,在网络桥接模型中,创建的设备类型之间有一些差异:

  • 没有黑洞路由,因为它由 OVS 处理。

  • 我们内核管理的唯一路由是 Antrea 网关(Antrea-gw0)本身的。

  • 所有这个 Pod 的流量都直接发送到Antrea-gw0设备。没有全局路由到其他设备,这与我们 Calico CNI 使用的 BGP 协议不同。

5.4.5 关于不同操作系统上的 CNI 提供者和 kube-proxy 的说明

这里值得指出的是,使用 DaemonSets 来管理 Pod 的主机网络是一种 Linux 特定的方法。在其他操作系统(例如 Windows Kubernetes 节点)中,当运行 containerd 时,实际上你需要使用服务管理器安装你的 CNI 提供者,并且 CNI 提供者作为主机进程运行。尽管这可能会在未来改变(再次以 Windows 为例,正在进行的工作是为了使 Windows Kubernetes 节点能够启用特权容器),但值得注意的是,Linux 网络堆栈非常适合 Kubernetes 网络模型。这主要归功于 cgroups、namespaces 和 Linux root 用户的概念,即使在容器中运行,它也可以作为一个高度特权的进程运行。

虽然 Kubernetes 网络复杂度可能一开始看起来令人畏惧,因为服务网格、CNI 和网络/服务器代理的快速演变,但只要你能理解 Pod 之间路由的基本过程,原则在许多 CNI 实现中都是一致的。

摘要

  • Kubernetes 网络架构与通用 SDN 概念有很多相似之处。

  • Antrea 和 Calico 都是 CNI 提供者,它们在 Pod 的真实网络上叠加了一个集群网络。

  • 基本的 Linux 命令(如ip a)可以用来推理你的 Pod 是如何进行网络连接的。

  • CNI 提供者通常在 DaemonSets 中管理 Pod 网络,在每个节点上运行一个特权 Linux 容器。

  • 边界网关协议(BGP)和 Open vSwitch(OVS)都是 CNI 提供者的核心技术,它们解决了为 Pod 广播和共享 overlay 路由信息的基本问题。

  • 其他操作系统如 Windows 目前并没有像 Linux 那样拥有所有相同的原生 Pod 网络便利性。

6 大规模网络错误的故障排除

本章涵盖

  • 使用 Sonobuoy 确认集群功能

  • 跟踪 Pod 的数据路径

  • 使用 arpip 命令检查 CNI 路由

  • 深入了解 kube-proxy 和 iptables

  • 层 7 网络简介(入口资源)

在本章中,我们将讨论一些用于故障排除大规模网络错误的触点。我们还介绍了 Sonobuoy,这是一个瑞士军刀,用于认证、诊断和测试实时 Kubernetes 集群的功能,它是 Kubernetes 中常用的诊断工具。

Sonobuoy 与 Kubernetes e2e(端到端)测试的比较

Sonobuoy 在容器中运行 Kubernetes e2e 测试套件,并简化了整体结果的检索、存储和归档。

对于经常从源代码树中工作的高级 Kubernetes 用户,您可以直接使用 test/e2e/ 目录(位于 mng.bz/Dgx9)作为 Sonobuoy 的替代方案。我们建议将其作为学习如何运行特定 Kubernetes 测试的入门点。

Sonobuoy 基于 Kubernetes e2e 测试库。Sonobuoy 用于验证 Kubernetes 发布版本,并验证软件是否正确遵循 Kubernetes API 规范。毕竟,Kubernetes 最终只是一个 API,因此我们定义 Kubernetes 集群的方式是一组可以成功通过 Kubernetes 合规性测试套件的节点。

尝试运行 kind-local-up.sh

探索不同的 CNI 是练习解决现实世界中可能遇到的网络问题的好方法。您可以使用 mng.bz/2jg0 上的 kind 菜单来运行具有不同 CNI 提供商的不同 Kubernetes 集群变体。例如,如果您克隆此项目,您可以通过运行 CLUSTER=calico CONFIG=calico-conf.yaml ./kind-local-up.sh 来创建基于 Calico 的集群。其他 CNI 选项(例如 Antrea 和 Cillium)也可用,并且可以从 kind-local-up.sh 脚本中读取。例如,为了创建一个 Antrea 集群以跟随本章中的示例,您可以运行

CLUSTER=antrea CONFIG=kind-conf.yaml ./kind-local-up.sh

然后,您可以修改 CLUSTER 选项以使用不同的 CNI 类型,例如 calicocillium。一旦您的集群创建完成,如果您检查 kube-system 命名空间中的所有 Pods,您应该会看到 CNI Pods 正在愉快地运行。

注意:如果您希望看到添加新的 CNI 菜单或特定版本在 kind 环境中引起问题,请随时在仓库(github.com/jayunit100/k8sprototypes)中提交问题。

6.1 Sonobuoy:一个确认您的集群是否正常工作的工具

一套符合性测试包括数百个测试,以确认从存储卷、网络、Pod 调度以及运行一些基本应用程序的能力。Sonobuoy 项目([sonobuoy.io/](https://sonobuoy.io/))打包了一套 Kubernetes e2e 测试,我们可以在任何集群上运行。这告诉我们集群的哪些部分可能工作不正常。一般来说,您可以下载 Sonobuoy,然后运行以下命令:

$ wget https://github.com/vmware-tanzu/sonobuoy/releases/
            download/v0.51.0/sonobuoy_0.51.0_darwin_amd64.tar.gz
$ tar -xvf sonobuoy
$ chmod +x sonobuoy ; cp sonobuoy /usr/loca/bin/
$ sonobuoy run e2e --focus=Conformance

此示例适用于 MacOS,因此请使用适合您操作系统的相应二进制文件。测试通常在健康的集群上需要 1 到 2 小时。之后,您可以通过运行 sonobuoy status 来获取集群是否正常工作的读数。要特别测试网络,可以运行以下测试:

$ sonobuoy run e2e --e2e-focus=intra-pod

此测试确认您的集群中每个节点都可以与其他节点上的 Pod 进行通信。它确认了您的 CNI 的核心功能以及您的网络代理(kube-proxy)是否正常工作。例如:

$ sonobuoy status
PLUGIN     STATUS     RESULT   COUNT
e2e        complete   passed   1

6.1.1 在真实集群中追踪 Pod 的数据路径

NetworkPolicy API 允许您以 Kubernetes 原生方式创建以应用程序为中心的防火墙规则,并且是安全集群通信规划的核心部分。它在 Pod 层面上操作,这意味着一个 Pod 到另一个 Pod 的连接是否被阻止或允许,取决于特定命名空间中存在的 NetworkPolicy 规则。NetworkPolicies、服务和 CNI 提供者之间有着微妙的相互作用,我们试图在图 6.1 中说明。生产集群中任意两个 Pod 之间的逻辑数据路径可以概括如图所示,其中:

  • 来自 100.96.1.2 的 Pod 通过 DNS 查询(图中未显示)发送流量到它接收到的服务 IP。

  • 然后,服务将来自 Pod 的流量路由到 iptables 确定的 IP。

  • iptables 规则将流量路由到不同节点上的 Pod。

  • 节点接收到数据包,然后 iptables(或 OVS)规则确定它是否违反了网络策略。

  • 数据包被发送到 100.96.1.3 终端。

图 6.1 生产集群中任意两个 Pod 之间的逻辑数据路径

数据路径没有考虑到可能出错的一些注意事项。例如,在现实世界中

  • 第一个 Pod 也可能受到网络策略规则的约束。

  • 在节点 10.1.2.3 和 10.1.2.4 之间的接口可能存在防火墙。

  • CNI 可能会宕机或出现故障,这意味着节点间数据包的路由可能会错误地到达其他地方。

  • 在现实世界中,Pod 访问其他 Pod 可能需要 mTLS(相互 TLS)证书。

如你所知,iptables 规则由规则组成。每个 iptables 表都有不同的链,这些链由规则组成,这些规则决定了数据包的整体流向。以下链在数据包通过集群的典型流中由kube-proxy服务管理。(在下一节中,我们将探讨我们所说的路由设备究竟是什么。)

KUBE_MARK_MASQ -> KUBE-SVC -----> KUBE_MARK_DROP
|-----> KUBE_SEP -> KUBE_MARQ_MASK -> NODE -> route device

6.1.2 使用 Antrea CNI 提供者设置集群

在上一章中,我们讨论了使用 Calico 的网络流量。在这一章中,我们还将这样做。我们还将查看使用 OpenVSwitch(OVS)作为 Calico 用于 IP 路由的技术替代品的 Antrea CNI 提供者。这意味着

  • 与在所有节点上可路由的 IP 的 BGP 广播不同,OVS 在每个节点上运行一个交换机,按接收到的流量进行路由。

  • 与使用 iptables 创建网络策略规则不同,OVS 路由器制定规则以实现 Kubernetes NetworkPolicy API。

我们在这里会重复一些概念,因为,据我们看来,从不同的角度看待相同的材料极大地有助于你理解现实世界中的生产级网络。然而,这次我们将加快速度,因为我们假设你已经理解了之前网络章节中的一些概念。这些概念包括服务、iptables 规则、CNI 提供者和 Pod IP 地址。

要使用 Antrea 提供者设置集群,我们将使用kind,就像我们使用 Calico 时做的那样;然而,这次,我们将直接使用 Antrea 项目提供的“食谱”。要创建一个启用 Antrea 的kind集群,请运行以下步骤:

$ git clone https://github.com/vmware-tanzu/antrea/
$ cd antrea
$ cd ci/kind
$ ./kind-setup.sh

警告:本章中的教程相对高级。为了减少冗余,我们假设你能够在不同集群之间切换上下文。如果你还没有同时启动并运行 Antrea 和 Calico 集群,沿着阅读并尝试一些这些命令可能比试图逐字跟随本节更容易。像往常一样,当在网络的内部进行修改时,如果你还没有这样做,你可能需要在你的kind集群上运行apt-get update; apt-get install net-tools

6.2 使用 arp 和 ip 命令检查不同提供者的 CNI 路由

这次我们跳过了 Kind

虽然你可以在kind集群中运行 Antrea,但为了本章,我们将展示来自 VMware Tanzu 集群的示例。如果你对在kind集群上使用 Antrea 重现此内容感兴趣,你可以运行mng.bz/2jg0中的食谱,这些食谱在kind集群上启用 Calico、Cillium 或 Antrea。Cillium 和 Antrea 都是 CNI 提供者,由于它们依赖于需要少量额外配置的先进 Linux 网络(分别对应 eBPF 和 OVS),因此在kind集群上正确运行需要一些调整。

整个 IP 网络的概念基于这样一个想法:IP 地址最终会将你引导到某种类型的硬件设备,该设备在 IP(第 3 层)抽象的下一层(第 2 层)操作,因此,只能在理解彼此 MAC 地址信息的机器上寻址。通常,检查你的网络是如何运行的第一步是运行ip a命令。这会给你一个俯瞰图,了解你的主机知道哪些网络接口,以及你的集群中作为网络端点的最终目标设备。

在 Antrea 集群中,我们可以使用与上一章相同的docker exec命令进入任何节点,并发出arp -na命令来查看给定节点知道哪些设备。在本章的示例中,我们将展示真实的虚拟机,这样你可以将其作为查看 Antrea 网络的参考,这些网络将(在虚拟上)与你在本地集群中获得的输出相同。

首先,让我们进入一个节点并运行arp命令来查看它知道的 IP 地址。对于节点可以到达的 Pod 的地址,我们将使用100过滤器 grep IP 地址,就像这个案例一样。我们在这个裸金属集群中运行这个演示,机器位于 100 子网:

antrea_node> arp -na | grep 100
? (100.96.26.15) at 86:55:7a:e3:73:71 [ether] on antrea-gw0
? (100.96.26.16) at 4a:ee:27:03:1d:c6 [ether] on antrea-gw0
? (100.96.26.17) at <incomplete> on antrea-gw0
? (100.96.26.18) at ba:fe:0f:3c:29:d9 [ether] on antrea-gw0
? (100.96.26.19) at e2:99:63:53:a9:68 [ether] on antrea-gw0
? (100.96.26.20) at ba:46:5e:de:d8:bc [ether] on antrea-gw0
? (100.96.26.21) at ce:00:32:c0:ce:ec [ether] on antrea-gw0
? (100.96.26.22) at e2:10:0b:60:ab:bb [ether] on antrea-gw0
? (100.96.26.2) at 1a:37:67:98:d8:75 [ether] on antrea-gw0

节点本地的地址包括

antrea_node> arp -na | grep 192
? (192.168.5.160) at 00:50:56:b0:ee:ff [ether] on eth0
? (192.168.5.1) at 02:50:56:56:44:52 [ether] on eth0
? (192.168.5.207) at 00:50:56:b0:80:64 [ether] on eth0
? (192.168.5.245) at 00:50:56:b0:e2:13 [ether] on eth0
? (192.168.5.43) at 00:50:56:b0:0f:52 [ether] on eth0
? (192.168.5.54) at 00:50:56:b0:e4:6d [ether] on eth0
? (192.168.5.93) at 00:50:56:b0:1b:5b [ether] on eth0

6.2.1 什么是 IP 隧道,为什么 CNI 提供者会使用它们?

你可能会想知道那个antrea-gw0设备是什么。如果你在 Calico 集群上运行了这些命令,你可能也看到了tun0设备。无论如何,这些被称为隧道,它们是允许集群中 Pod 之间扁平网络连接的构造。在前面的例子中,antrea-gw0设备对应于管理 Antrea CNI 流量的 OVS 网关。这个网关流量足够智能,可以“隐藏”来自一个 Pod 到另一个 Pod 的流量,使得流量首先流向节点。在 Calico 集群中,你会看到类似的模式,其中使用协议(如 IPIP)来隐藏此类流量。Calico 和 Antrea CNI 提供者都足够智能,知道何时为了性能原因隐藏流量。

现在,让我们看看 Antrea 和 Calico CNI 的起点是如何有趣地开始有所不同的。在我们的 Calico 集群中,运行ip a命令会显示我们有一个tunl0接口。这个接口是由calico_node容器通过brd服务创建的,该服务负责在集群中通过 IPIP 隧道路由流量。我们将它与第二个代码片段中 Antrea 的ip a命令进行对比。

calico_node> ip a
2: tunl0@NONE: <NOARP,UP,LOWER_UP>
mtu 1440 qdisc noqueue state UNKNOWN
group default qlen 1000
antrea_node> ip a
3: ovs-system: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN
   group default qlen 1000
   link/ether 7e:de:21:4b:88:46 brd ff:ff:ff:ff:ff:ff
5: antrea-gw0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc
   noqueue state UNKNOWN group default qlen 1000
    link/ether 82:aa:a9:6f:02:33 brd ff:ff:ff:ff:ff:ff
    inet 100.96.29.1/24 brd 100.96.29.255 scope global antrea-gw0
       valid_lft forever preferred_lft forever
    inet6 fe80::80aa:a9ff:fe6f:233/64 scope link

现在,在两个集群中,运行kubectl scale deployment coredns --replicas=10 -n kube-system。然后重新运行之前的命令。你会看到容器的新 IP 条目。

6.2.2 我们的网络接口上流过多少数据包?

我们知道所有数据包可能都被推送到特殊隧道中,以便在到达 Pod 之前最终到达正确的物理节点。因为每个节点都知道所有 Pod 本地流量,我们可以使用标准的 Linux 工具来监控 Pod 流量,而实际上并不依赖于对 Kubernetes 本身的任何了解。ip命令有一个-s选项来显示流量是否在流动。在 Calico 或 Antrea 集群的节点上运行此命令会告诉我们确切哪个接口的流量流入我们的 Pod 以及流量速率。以下是输出:

10: cali3317e4b4ab5@if5: <BROADCAST,MULTICAST,UP,LOWER_UP>
    mtu 1440 qdisc noqueue state UP group default
    link/ether ee:ee:ee:ee:ee:ee brd ff:ff:ff:ff:ff:ff
    link-netns cni-abb79f5f-b6b0-f548-3222-34b5eec7c94f
    RX: bytes  packets  errors  dropped overrun mcast
    150575     1865     0       2       0       0
    TX: bytes  packets  errors  dropped carrier collsns
    839360     1919     0       0       0       0

5: antrea-gw0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc
➥ noqueue state UNKNOWN group default qlen 1000
    link/ether 82:aa:a9:6f:02:33 brd ff:ff:ff:ff:ff:ff
    inet 100.96.29.1/24 brd 100.96.29.255 scope global antrea-gw0
       valid_lft forever preferred_lft forever
    inet6 fe80::80aa:a9ff:fe6f:233/64 scope link
       valid_lft forever preferred_lft forever
    RX: bytes  packets  errors  dropped overrun mcast
    89662090   1089577  0       0       0       0
    TX: bytes  packets  errors  dropped carrier collsns
    108901694  1208573  0       0       0       0

到目前为止,我们现在对集群中网络连接的工作方式有一个高级的视图。如果没有流量进入与 Calico 或 Antrea 相关的接口,那么(显然)我们的 CNI 出了问题,因为大多数 Kubernetes 集群在稳定状态操作期间至少会有一些 Pod 之间的流量流动。例如,即使在没有用户在kind集群中创建任何 Pod 的情况下,你也会看到kube-proxy Pod 和 CoreDNS Pod 会通过 CoreDNS 服务端点积极通信关于网络流量。看到这些 Pod 处于运行状态是一个良好的合理性测试(特别是对于 CoreDNS,它需要一个 Pod 网络才能工作),并且也将是验证你的 CNI 提供商是否健康的好方法。

6.2.3 路由

我们对 Pod 网络路径的探索的下一级涉及查看这些设备是如何连接到 IP 地址的。在图 6.2 中,我们再次描绘了 Kubernetes 网络的架构。然而,这一次,我们包括了之前命令中揭示的隧道信息。

图 6.2 将隧道信息添加到 Kubernetes 网络架构中

现在我们已经知道了什么是隧道,让我们看看我们的 CNI 是如何通过编程 Linux 路由表来管理路由到隧道的流量的。在 Calico 集群中运行route -n显示了内核中的以下路由表,其中cali接口是节点本地的 Pod,而tunl0接口是 Calico 自己创建的用于将流量发送到网关节点的特殊接口:

# route -n
Kernel IP routing table
Destination     Gateway     Genmask          Flags Metric Ref Use Iface
0.0.0.0         172.18.0.1  0.0.0.0          UG    0      0   0 eth0
172.18.0.0      0.0.0.0     255.255.0.0      U     0      0   0 eth0
192.168.9.128   172.18.0.3  255.255.255.192  UG    0      0   0 tunl0
192.168.71.0    172.18.0.5  255.255.255.192  UG    0      0   0 tunl0
192.168.88.0    172.18.0.4  255.255.255.192  UG    0      0   0 tunl0
192.168.143.64  172.18.0.2  255.255.255.192  UG    0      0   0 tunl0
192.168.173.64  0.0.0.0     255.255.255.192  U     0      0   0 *
192.168.173.65  0.0.0.0     255.255.255.255  UH    0      0   0 calicd2f3
192.168.173.66  0.0.0.0     255.255.255.255  UH    0      0   0 calibaa57

在这个 Calico 的路由表中,我们可以看到

  • 172 个节点是某些 Pod 的网关。

  • 在特定的范围内(在 Genmask 列中显示)的 192 个 IP 地址被路由到特定的节点。

那我们的 Antrea CNI 提供商呢?在类似的集群中,我们不会看到每个设备都有一个新的目标 IP。相反,我们会看到有一个.1 Antrea 网关:

root [ /home/capv ]# route -n
Kernel IP routing table
Destination  Gateway      Genmask         Flags Ref Use Iface
0.0.0.0      192.168.5.1  0.0.0.0         UG    0   0   eth0
100.96.0.0   100.96.0.1   255.255.255.0   UG    0   0   antrea-gw0
100.96.21.0  100.96.21.1  255.255.255.0   UG    0   0   antrea-gw0
100.96.26.0  100.96.26.1  255.255.255.0   UG    0   0   antrea-gw0
100.96.28.0  100.96.28.1  255.255.255.0   UG    0   0   antrea-gw0

在这个 Antrea 的路由表中,我们可以看到

  • 任何目的地为 100.96.0.0 IP 范围的流量都会直接路由到 IP 地址 100.96.0.1。 这是 CNI 网络上 Antrea 使用的 OVS 路由机制的一个保留IP 地址。因此,而不是直接发送到节点 IP 地址,它将所有流量发送到 Antrea 自己管理交换服务的 Pod 网络上的一个 IP 地址。

  • 与 Calico 不同,所有流量(包括本地流量)都直接发送到 Antrea 网关设备。唯一区分其最终目的地的是网关 IP。

因此,我们可以看到

  • Antrea 在每个节点上有一个路由表条目每个节点

  • Calico 在每个 Pod 上有一个路由表条目每个 Pod

6.2.4 CNI 特定工具:Open vSwitch (OVS)

Antrea 和 Calico CNI 插件都在我们的集群中以 Pod 的形式运行。这并不一定适用于所有 CNI 提供者,但如果是的话,我们将在必要时能够使用许多优秀的 Kubernetes 功能来调试网络数据路径。一旦我们开始深入了解 CNIs,我们实际上需要查看像ovs-vsctlantctlcalicoctl等工具。我们不会在这里介绍所有这些工具,但我们将介绍可以在你的集群中 Antrea 容器内轻松运行的ovs-vsctl工具。然后我们可以通过ovs-vsctl工具让 OVS 告诉我们更多关于这个接口的信息。为了使用这个工具,你可以直接使用kubectl exec -t -i antrea-agent-1234 -n kube-system /bin/bash进入 Antrea 容器,创建一个 shell,然后运行以下命令之类的命令:

# ovs-vsctl list interface|grep -A 5 antrea
name                : antrea-gw0
ofport              : 2
ofport_request      : 2
options             : {}
other_config        : {}
statistics          : {collisions=0, rx_bytes=1773391201,
             rx_crc_err=0, rx_dropped=0, rx_errors=0,
             rx_frame_err=0, rx_missed_errors=0, rx_over_err=0,
             rx_packets=16392260, tx_bytes=6090558410,
             tx_dropped=0, tx_errors=0, tx_packets=17952545}

有几个命令行工具可以让你在集群中诊断低级 CNI 问题。对于 CNI 特定的调试,你可以使用antctlcalicoctl

  • antctl列出启用的 Antrea 功能,获取代理的调试信息,并对 Antrea NetworkPolicy 目标进行细粒度分析。

  • calicoctl分析 NetworkPolicy 对象,打印关于网络诊断的信息,并关闭常见的网络功能(作为手动编辑 YAML 文件的替代方案)。

如果你对集群的通用 Linux-centric 调试感兴趣,可以使用像 Sonobuoy 这样的工具在集群上运行一系列端到端测试。你也可以考虑使用github.com/sarun87/k8snetlook工具,该工具为细粒度的网络功能(例如,API 服务器连接性、Pod 连接性等)运行实际的集群诊断。

根据你的网络配置复杂程度,你需要在现实世界中进行的故障排除量会有所不同。每个节点有 100+个 Pod 是很常见的,对这些概念进行一定程度的检查或推理将变得越来越重要。

6.2.5 使用 tcpdump 追踪活动容器的数据路径

现在我们对数据包如何在各种 CNIs 中从一个地方流向另一个地方有了些直觉,让我们回到栈的顶部,看看我们最喜欢的传统网络诊断工具之一:tcpdump。因为我们已经追踪了主机与底层 Linux 网络工具之间的关系,这些工具负责路由流量,我们可能想从容器的角度来查看这些事情。最常用的工具是tcpdump。让我们抓取我们的 CoreDNS 容器之一,并查看其流量。在 Calico 中,我们可以直接嗅探cali设备上的数据包,如下所示:

192.168.173.66  0.0.0.0    255.255.255.255 UH  0   0   0 calibaa5769d671
calico_node> tcpdump -i calicd2f389598e
listening on calicd2f389598e,
link-type EN10MB (Ethernet),
capture size 262144 bytes
20:13:07.733139 IP 10.96.0.1.443 > 192.168.173.65.60684:
  Flags [P.],
  seq 1615967839:1615968486,
  ack 1173977013, win 264,
  options [nop,nop,TS val 296478

10.96.0.1 IP 地址是内部 Kubernetes 服务地址。这个 IP(API 服务器)确认收到了 CoreDNS 服务器获取 DNS 记录的请求。如果我们查看我们集群中的一个典型节点,在那里我们运行 CoreDNS Pod,我们的 Antrea Pods 将被命名为如下:

30: coredns--e5cc00@if3: <BROADCAST,MULTICAST,UP,LOWER_UP>
    mtu 1450 qdisc noqueue master ovs-system state UP
    group default
    link/ether e6:8a:27:05:d7:30 brd ff:ff:ff:ff:ff:ff
    link-netns cni-2c6b1bc0-cf36-132c-dfcb-88dd158f51ca
    inet6 fe80::e48a:27ff:fe05:d730/64 scope link
       valid_lft forever preferred_lft forever

这意味着我们可以通过连接到这个 veth 设备并使用tcpdump来直接嗅探发送到该节点的数据包。以下代码片段展示了如何进行此操作:

calico_node> tcpdump -i coredns--29244a -n

当你运行此命令时,你应该会看到来自不同 Pod 的流量,这些 Pod 正在尝试解析 Kubernetes DNS 记录。我们经常使用-n选项,这样在使用tcpdump时我们的 IP 地址就不会被隐藏。

如果你只想查看一个 Pod 是否在与其他 Pod 通信,你可以前往接收流量的 Pod 所在的节点,并抓取所有包含 Pod IP 地址之一的 TCP 流量。比如说,一个发送流量的 Pod 的 IP 地址是 100.96.21.21。运行此命令会给你一个包含例如 192 地址和 9153 端口的原始数据包转储:

calico_node> tcpdump host 100.96.21.21 -i coredns--29244a
listening on coredns--29244a, link-type EN10MB (Ethernet),
capture size 262144 bytes

21:59:36.818933 IP 100.96.21.21.45978 > 100.96.26.19.9153:
   Flags [S], seq 375193568, win 64860, options [mss 1410,sackOK,TS
   val 259983321 ecr 0,nop,wscale 7], length 0

21:59:36.819008 IP 100.96.26.19.9153 > 100.96.21.21.45978: Flags [S.],
   seq 3927639393, ack 375193569, win 64308, options [mss 1410,
   sackOK,TS val 2440057191 ecr 259983321,nop,wscale 7], length 0

21:59:36.819928 IP 100.96.21.21.45978 > 100.96.26.19.9153:
   Flags [.], ack 1, win 507, options [nop,nop,TS val
   259983323 ecr 2440057191], length 0

tcpdump工具通常用于对容器之间的流量进行实时调试。特别是,如果你没有从接收 Pod 到发送 Pod 的ack响应,这可能意味着你的 Pod 没有收到流量。这可能是由于网络策略或 iptables 规则干扰了正常的kube-proxy转发信息。

注意:传统的 IT 商店通常使用 Puppet 等工具来配置和管理 iptables 规则。将kube-proxy与其他基于 IT 的网络安全规则管理的 iptables 规则结合起来是困难的,通常,在由网络管理员维护的常规规则之外的环境中运行你的节点是最好的选择。

6.3 kube-proxy 和 iptables

关于网络代理,最重要的记住的事情是,其操作通常与你的 CNI 提供者的操作是独立的。当然,就像 Kubernetes 中的所有其他事物一样,这个说法并不是没有例外:一些 CNI 提供者已经考虑实现自己的服务代理,作为 Kubernetes 自带 iptables(或 IPVS)服务代理的替代方案。但话虽如此,这并不是大多数集群运行的标准方式。在大多数集群中,你应该在概念上将服务代理的概念(由kube-proxy执行)与流量路由的概念(由管理 Linux 原语(如 OVS)的 CNI 提供者执行)分开。

这次深入探讨重申了一些基本的 Kubernetes 网络概念。到目前为止,我们已经看到

  • 主机如何将 Pod 流量映射到 IP 和路由命令

  • 你如何验证传入的 Pod 流量并从主机查找 IP 隧道信息

  • 如何使用tcpdump在特定 IP 地址上嗅探流量

现在我们来看看kube-proxy。尽管它不是你的 CNI 的一部分,但在诊断网络问题时理解kube-proxy是至关重要的。

6.3.1 iptables-save 和 diff 工具

当寻找所有服务端点时,你可以对集群运行iptables-save命令。此命令在某个时间点存储每个 iptables 规则。结合diff等工具,它可以用来测量两个 Kubernetes 网络状态之间的差异。从这里,你可以查找注释规则,这些规则告诉你与规则关联的服务。典型的iptables-save运行会产生如下几行规则:

-A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment
   --comment "kube-system/kube-dns:dns" -m statistic --mode random
   --probability 0.10000000009 -j KUBE-SEP-QIVPDYSUOLOYQCAA

-A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment
  --comment "kube-system/kube-dns:dns" -m statistic --mode random
  --probability 0.11111111101 -j KUBE-SEP-N76EJY3A4RTXTN2I

-A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment
  --comment "kube-system/kube-dns:dns" -m statistic --mode random
  --probability 0.12500000000 -j KUBE-SEP-LSGM2AJGRPG672RM

在查看这些服务之后,你将想要找到它们对应的SEP规则。我们可以使用grep来查找与特定服务相关的所有规则。在这种情况下,SEP-QI...对应于我们集群中的 CoreDNS 容器。

注意:我们在许多示例中使用 CoreDNS,因为它是一个可以扩展和缩放的标准化 Pod,很可能运行在几乎任何集群中。你可以使用任何其他 Pod 来完成这个练习,该 Pod 位于内部 Kubernetes 服务后面,并使用 CNI 插件为其 IP 地址(它不使用主机网络)。

calico_node> iptables-save | grep SEP-QI
:KUBE-SEP-QIVPDYSUOLOYQCAA - [0:0]
### Masquerading happens here for outgoing traffic...
-A KUBE-SEP-QIVPDYSUOLOYQCAA -s 192.168.143.65/32
   -m comment
   --comment "kube-system/kube-dns:dns" -j KUBE-MARK-MASQ

-A KUBE-SEP-QIVPDYSUOLOYQCAA -p udp -m comment
  --comment "kube-system/kube-dns:dns" -m udp -j DNAT
  --to-destination 192.168.143.65:53

-A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment
  --comment "kube-system/kube-dns:dns" -m statistic
  --mode random --probability 0.10000000009 -j KUBE-SEP-QIVPDYSUOLOYQCAA

这个步骤在所有 CNI 提供者中都是相同的。正因为如此,我们不会提供 Antrea/Calico 的比较。

6.3.2 查看网络策略如何修改 CNI 规则

入站规则和网络策略是 Kubernetes 网络中最锐利的特性之一,这主要是因为这两个都是由 API 定义的,但由集群中认为是可选的外部服务实现。具有讽刺意味的是,网络策略和入站路由对于大多数 IT 管理员来说是基本要求。因此,尽管这些功能在理论上可能是可选的,但如果你正在阅读这本书,你很可能会使用它们。

Kubernetes 中的 NetworkPolicies 支持在任何 Pod 上阻止入口/出口调用或两者。一般来说,Pod 在 Kubernetes 集群中根本不受保护,因此 NetworkPolicies 被视为安全 Kubernetes 生产集群的重要组成部分。NetworkPolicy API 对于初学者来说可能相当难以使用,所以我们将保持简单以帮助您入门:

  • NetworkPolicies 在特定的命名空间中创建,并通过标签针对 Pod。

  • NetworkPolicies 必须定义一个类型(默认为 ingress)。

  • NetworkPolicies 是累加的,并且是仅允许的,这意味着它们默认拒绝事物,并且可以分层以允许更多和更多的流量白名单

  • Calico 和 Antrea 对 Kubernetes NetworkPolicy API 的实现方式不同。Calico 创建新的 iptables 规则,而 Antrea 创建 OVS 规则。

  • 一些 CNIs,如 Flannel,根本不实现 NetworkPolicy API。

  • 一些 CNIs,如 Cillium 和 OVN(Open Virtual Network)Kubernetes,并没有实现整个 Kubernetes API 的 NetworkPolicy 规范(例如,Cillium 没有实现最近添加的 PortRange 策略,该策略在本文发表时处于 Beta 版本,而 OVN Kubernetes 没有实现 NamedPort 功能)。

重要的是要意识到,Calico 除了网络策略之外不使用 iptables 进行任何操作。所有其他路由都通过 BGP 路由规则完成,我们之前已经讨论过。在本节中,我们将创建一个网络策略,并查看它如何影响 Calico 和 Antrea 中的路由规则。为了开始了解网络策略可能如何影响流量,我们将运行一个 NetworkPolicy 测试,其中阻止所有流向名为web的 Pod 的流量:

kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
metadata:
  name: web-deny-all
spec:
  podSelector:
    matchLabels:
      app: web     ❶
  ingress: []      ❷

❶ 此 NetworkPolicy 作用于默认命名空间中的 app:web 容器。

❷ 由于我们没有实际定义任何入口规则,因此拒绝所有流量

如果我们想要定义一个入口规则,我们的策略可能看起来像这样:

kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
metadata:
  name: web
spec:
  podSelector:
    matchLabels:
      app: web
  ingress:
  - ports:
    - port: 80            ❶
  - from:
      - podSelector:
          matchLabels:
            app: web2     ❷

❶ 允许流量,但我们将其限制在我们的 web 服务器服务的端口上,即 80

❷ 允许 web Pod 响应来自我们的 web2 Pod 的入站流量

注意,在第二个片段中,web2 Pod 也将能够从 web Pod 接收流量。这是因为 web Pod 没有定义任何出口策略,这意味着默认情况下允许所有出口。因此,为了完全锁定 web Pod,我们希望

  • 定义一个仅允许流向关键服务的出口 NetworkPolicy

  • 定义一个仅允许来自关键服务的入口 NetworkPolicy

  • 在前两个策略中添加端口号,以便仅允许关键端口

定义这类 YAML 策略可能非常繁琐。如果您想深入了解这个领域,请参阅mng.bz/XWEl,其中包含几个教程,可介绍您如何为不同的用例创建特定的网络策略。

测试我们 CNI 创建的这些策略的一个好方法是在所有节点上定义一个运行相同容器的 DaemonSet。请注意,我们的 CNI 提供程序为 NetworkPolicies 创建规则是 CNI 提供程序本身的一个特性。这不是 CNI 接口的一部分。因为大多数 CNI 提供程序是为 Kubernetes 构建的,所以 Kubernetes NetworkPolicy API 的实现是他们提供的一个明显的附加功能。

现在,让我们通过创建一个策略可以针对的 Pod 来测试我们的策略。以下 DaemonSet 在所有节点上运行 Pod。每个 Pod 都由上面的策略保护,这导致 Calico CNI(或,作为替代,我们的 Antrea CNI 编写的 OVS 规则)编写一组特定的 iptables 规则。我们可以使用此代码片段中的代码来测试我们的策略:

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: nginx-ds
spec:
  selector:
    matchLabels:
      app: web        ❶
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
      - name: nginx
        image: nginx

❶ 在每个节点上运行 Pod

6.3.3 这些策略是如何实现的?

我们可以使用diffgit diff来比较创建策略前后 iptables 规则的变化。在 Calico 中,您将看到此类策略。这就是策略的drop规则实现的地方。要完成此操作

  1. 在前面的代码片段中创建 DaemonSet,然后在任何节点上运行iptables-save > a1

  2. 创建一个网络策略来阻止此流量,再次运行iptables-save > a2,并将其保存到不同的文件中。

  3. 运行类似于git diff a1 a2的命令并查看差异。

在此情况下,您将看到以下关于策略的新规则:

> -A cali-tw-calic5cc839365a -m comment
  --comment "cali:Uv2zkaIvaVnFWYI9" -m comment
  --comment "Start of policies" -j MARK --set-xmark 0x0/0x20000

> -A cali-tw-calic5cc839365a -m comment
  --comment "cali:7OLyCb9i6s_CPjbu" -m mark --mark 0x0/0x20000
  -j cali-pi-_IDb4Gbl3P1MtRtVzfEP

> -A cali-tw-calic5cc839365a -m comment --comment "cali:DBkU9PXyu2eCwkJC"
  -m comment --comment "Return if policy accepted" -m mark
  --mark 0x10000/0x10000 -j RETURN

> -A cali-tw-calic5cc839365a -m comment --comment "cali:tioNk8N7f4P5Pzf4"
  -m comment --comment "Drop if no policies passed packet" -m mark
  --mark 0x0/0x20000 -j DROP

> -A cali-tw-calic5cc839365a -m comment --comment "cali:wcGG1iiHvTXsj5lq"
  -j cali-pri-kns.default

> -A cali-tw-calic5cc839365a -m comment --comment "cali:gaGDuGQkGckLPa4H"
  -m comment --comment "Return if profile accepted" -m mark
  --mark 0x10000/0x10000 -j RETURN

> -A cali-tw-calic5cc839365a -m comment --comment "cali:B6l_lueEhRWiWwnn"
  -j cali-pri-ksa.default.default

> -A cali-tw-calic5cc839365a -m comment --comment "cali:McPS2ZHiShhYyFnW"
  -m comment --comment "Return if profile accepted" -m mark
  --mark 0x10000/0x10000 -j RETURN
> -A cali-tw-calic5cc839365a -m comment --comment "cali:lThI2kHuPODjvF4v"
  -m comment --comment "Drop if no profiles matched" -j DROP

Antrea 也实现了网络策略,但使用 OVS 流并将其写入表 90。在 Antrea 中运行类似的工作负载,您将看到创建的这些策略。完成此操作的一个简单方法是调用ovs-ofctl。通常,这是在容器内部完成的,因为 Antrea 代理已经完全配置了所有 OVS 管理二进制文件。如果需要,也可以从主机安装 OVS 实用程序来完成此操作。要在 Antrea 集群中运行以下示例,您可以使用kubectl客户端。此命令行显示了 Antrea 如何实现网络策略:

$ kubectl -n kube-system exec -it antrea-agent-2kksz
➥ ovs-ofctl dump-flows br-int | grep table=90
...
Defaulting container name to antrea-agent.
 cookie=0x2000000000000, duration=344936.777s, table=90, n_packets=0,
 n_bytes=0, priority=210,ct_state=-new+est,ip actions=resubmit(,105)

 cookie=0x2000000000000, duration=344936.776s, table=90, n_packets=83160,
 n_bytes=6153840, priority=210,ip,nw_src=100.96.26.1 actions=resubmit(,105)

 cookie=0x2050000000000, duration=22.296s, table=90, n_packets=0,
 n_bytes=0, priority=200,ip,reg1=0x18 actions=conjunction(1,2/2)     ❶

 cookie=0x2050000000000, duration=22.300s, table=90, n_packets=0, n_bytes=0,
 priority=190,conj_id=1,ip actions=load:0x1->NXM_NX_REG6[],resubmit(,105)

 cookie=0x2000000000000, duration=344936.782s, table=90, n_packets=149662,
 n_bytes=11075281, priority=0 actions=resubmit(,100)

❶ 当 Antrea 看到需要将网络策略应用于特定 Pod 时,它会使用 OVS 编写的联合规则。

OVS,类似于 iptables,定义了指定数据包流的规则。Antrea 使用几个 OVS 流表,每个表都有针对不同 Pod 的具体逻辑编程。如果您想在大规模上运行 Antrea 并确认数据中心中 OVS 使用的任何特定细节,可以使用像 Prometheus 这样的工具实时监控 OVS 中活跃使用的流数量。

记住,OVS 和 iptables 都集成在 Linux 内核中,所以您不需要对数据中心做任何特殊操作来使用这些技术。有关如何使用 Prometheus 监控 OVS 的更多信息,本书的配套博客文章可在mng.bz/1jaj找到。在那里,我们将向您详细介绍如何设置 Prometheus 作为 Antrea 的监控工具。

Cyclonus 和 NetworkPolicy e2e 测试

如果你想了解更多关于 NetworkPolicies 的信息,你可以使用 Sonobuoy 运行 Kubernetes e2e 测试。你将得到一个漂亮的表格列表,它精确地打印出哪些 Pod 可以在(和不能)根据策略规范相互通信。另一个用于调查你的 CNI 提供者 NetworkPolicy 功能的更强大的工具是 Cyclonus,它可以从源轻松运行(见github.com/mattfenwick/cyclonus))。

Cyclonus 生成数百个网络策略场景,并检查你的 CNI 提供者是否正确实现了它们。有时,CNI 提供者可能会在实现复杂的 NetworkPolicy API 时出现回归,因此在生产环境中运行这个工具来验证你的 CNI 提供者是否符合 Kubernetes API 规范是一个很好的主意。

6.4 入口控制器

入口控制器允许你通过单个 IP 地址将所有流量路由到你的集群(并且是节省云 IP 地址成本的好方法)。然而,它们可能很难调试,主要是因为它们是附加组件。为了解决这个问题,Kubernetes 社区讨论了提供默认入口控制器的方案。

NGINX、Contour 和网关 API

Kubernetes 的原始入口 API 是由 NGINX 实现的规范标准。然而,不久之后,发生了两个重大转变:

  • Contour (projectcontour.io/)成为了一个替代的 CNCF(云原生计算基金会)入口控制器。

  • 网关 API 作为一种提供更好的多租户解决方案来暴露 Kubernetes 集群路由的替代方式出现。

在发布时,入口 API 处于“困境”之中,很快将被网关 API 取代,后者描述性更强,能够以更灵活的方式向开发者描述不同类型的第 7 层资源。因此,尽管我们鼓励你学习本节的内容,但我们指出,你应该将这部分内容作为一个跳板,开始研究网关 API 及其如何可能满足你未来的需求。要了解更多关于网关 API 的信息,你可以花些时间在gateway-api.sigs.k8s.io/上。

要实现一个入口控制器(或网关 API),你需要决定如何将流量路由到它,因为它的 IP 地址不是常规的 ClusterIP 服务。如果你的入口控制器宕机,所有进入你的集群的流量也会中断,所以你可能会希望将其作为 DaemonSet(如果它在集群中运行)在所有节点上运行。

Contour 在其服务代理的基础上使用一种称为 Envoy 代理的技术。Envoy 可以用来构建入口控制器、服务网格以及其他类型的网络技术,这些技术可以透明地为你转发或管理流量。当你阅读这段内容时,请注意,Kubernetes 服务 API 是上游 Kubernetes 社区持续创新的领域。随着集群变得越来越大,在接下来的几年中,将出现越来越复杂的流量路由模型的需求。

6.4.1 设置 Contour 和 kind 以探索入口控制器

入口控制器的作用是为你将要运行的众多 Kubernetes 服务提供对外界的命名访问。如果你在一个拥有无限公共 IP 的云上,这可能会比其他情况下稍微少一些价值,但入口控制器还允许你干净地设置 HTTPS 透传,监控所有暴露的服务,并围绕外部可访问的 URL 创建策略。

为了探索如何将入口控制器添加到现有的 Kubernetes 集群中,我们将创建一个可靠的 kind 集群。然而,这次,我们将设置它将入口流量转发到端口 80。这些流量将由 Contour 入口控制器解析,这允许我们将多个服务通过名称绑定到集群的端口 80:

kind: Cluster
apiVersion: kind.sigs.k8s.io/v1alpha3
networking:
  disableDefaultCNI: true # disable kindnet
  podSubnet: 192.168.0.0/16 # set to Calico's default subnet
nodes:
- role: control-plane
- role: worker
  extraPortMappings:       ❶
  - containerPort: 80
    hostPort: 80
    listenAddress: "0.0.0.0"
  - containerPort: 443
    hostPort: 443
    listenAddress: "0.0.0.0"

❶ 定义额外的端口映射,以便从我们的本地终端访问端口 80 并将其转发到我们的 kind 节点上的端口 80

此代码片段中的额外端口映射使我们能够从我们的本地终端访问端口 80,并从我们的 kind 节点将流量转发到该端口。请注意,此配置仅适用于单节点集群,因为当在本地机器上运行基于 Docker 的 Kubernetes 节点时,你只有一个端口可以暴露。在我们创建我们的 kind 集群后,我们将安装 Calico,如下例所示。你将拥有一个工作良好的、基本的 Pod 到 Pod 网络:

$ kubectl create -f
  https://docs.projectcalico.org/archive/v3.16/manifests/
  tigera-operator.yaml

$ kubectl -n kube-system set env daemonset/calico-node
        FELIX_IGNORELOOSERPF=true
$ kubectl -n kube-system set env daemonset/calico-node
        FELIX_XDPENABLED=false

好的,现在我们的基础设施都已经设置好了。让我们开始学习入口!在本节中,我们将从底部到顶部暴露 Kubernetes 服务。像往常一样,我们将使用我们可靠的 kind 集群来完成这项工作。然而,这次,我们将

  • 从集群内部访问服务以进行合理性检查

  • 使用 Contour 入口控制器通过主机名管理此服务,以及其他一系列服务

6.4.2 设置简单的 Web 服务器 Pod

要开始,让我们创建我们的 kind 集群,就像之前章节中做的那样。一旦我们启动并运行,我们就会创建一个简单的 Web 应用程序。由于 NGINX 经常被用作入口控制器,这次,我们将创建一个类似的 Python Web 应用程序:

apiVersion: v1
kind: Pod
metadata:
  name: example-pod
  labels:
    service: example-pod      ❶
spec:
  containers:
    - name: frontend
      image: python
      command:
        - "python"
        - "-m"
        - "SimpleHTTPServer"
        - "8080"
      ports:
        - containerPort: 8080

❶ 我们的服务选择此标签。

接下来,我们将通过标准 ClusterIP 服务公开 containerPort。这是所有 Kubernetes 服务中最简单的一个;它除了告诉 kube-proxy 在我们的 Python Pod 中创建一个虚拟 IP 地址(我们之前看到的 KUBESEP 端点)之外,什么都不做:

apiVersion: v1
kind: Service
metadata:
  name: my-service
spec:
  selector:
    service: example-pod    ❶
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080

❶ 将此 Pod 指定为我们的服务端点

到目前为止,我们已经创建了一个小型的 Web 应用程序,它接收来自服务的流量。我们创建的 Web 应用程序在端口 8080 内部提供服务,我们的服务也使用该端口。让我们尝试本地访问它。我们将创建一个简单的 Docker 镜像,我们可以用它来探索我们的集群服务(此镜像是从 github.com/arunvelsriram/utils 分支出来的):

apiVersion: v1
kind: Pod
metadata:
  name: sleep
spec:
  containers:
    - name: check
      image: jayunit100/ubuntu-utils
      command:
        - "sleep"
        - "10000"

现在,从该镜像内部,让我们看看我们是否可以 curl 下载我们的服务。以下 curl 命令输出容器中 /etc/passwd 文件的所有行。如果您更喜欢更友好的东西,也可以将文件,例如 hello.html,写入容器中的 / 目录:

$ kubectl exec -t -i sleep curl my-service:8080/etc/passwd
root:x:0:0:root:/root:/bin/bash                              ❶

❶ 输出 /etc/passwd 文件中的所有行

它成功了!为了使其工作,我们知道

  • Pod 正在运行并在 OS 的 8080 端口上提供所有文件。

  • 由于我们之前创建的 my-service 服务,集群中的每个 Pod 都能够通过端口 8080 访问此服务。

  • kube-proxy 将流量从 my-service 转发到 example-pod 并写入相关的 iptables 转发规则。

  • 我们的 CNI 提供商 能够 创建必要的路由规则(我们在本章前面已经探讨过)并在 iptables 规则转发此数据包后,将流量从 check Pod 的 IP 地址转发到 example-pod

假设我们想从外部世界访问此服务。为此,我们需要

  1. 将其添加到入口资源中,以便 Kubernetes API 可以告诉入口控制器将其流量转发到它

  2. 运行一个入口控制器,它将来自外部世界的流量转发到内部服务

目前有几种不同的入口控制器。流行的有 NGINX 和 Contour。在这种情况下,我们将使用 Contour 来访问此服务:

$ git clone https://github.com/projectcontour/contour.git
$ kubectl apply -f contour/examples/contour

现在,您已经安装了一个入口控制器,它将为您管理所有外部流量。接下来,我们将在本地机器的 /etc/hosts 文件中添加一个条目,它告诉我们要在 localhost 上访问之前的服务:

$ echo "127.0.0.1   my-service.local" >> /etc/hosts

现在,我们将创建一个入口资源:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example-ingress
spec:
  rules:
  - host: my-service.local          ❶
    http:
      paths:
      - path: /
        backend:
          serviceName: my-service   ❷
          servicePort: 8080

❶ 命名我们放在笔记本电脑 127.0.0.1 上的服务

❷ 命名内部 Kubernetes 服务

我们可以从本地计算机向 kind 集群发出 curl 命令。这样工作的方式如下:

  1. 本地,我们的客户端尝试在端口 80 上发出 curl my-service.local。这解析 IP 地址为 127.0.0.1。

  2. 流向我们的 localhost 的流量被我们的 kind 集群中监听 80 的 Docker 节点拦截。

  3. Docker 节点将流量转发到 Contour 入口控制器,该控制器看到我们正在尝试访问 my-service.local。

  4. Contour 的入口控制器将 my-service.local 流量转发到 my-service 后端。

当此过程完成后,我们将看到与之前示例中我们在 sleep 容器中得到的相同输出。以下代码片段显示了此过程,使用 Envoy 服务器在另一端进行监听。这是因为入口控制器使用 Envoy(Contour 在底层使用的服务代理)作为进入集群的网关:

curl -v http://my-service.local/etc/passwd
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Conn to my-service.local (127.0.0.1) port 80    ❶
> GET / HTTP/1.1
> Host: my-service.local
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< server: envoy                                   ❷
< date: Sat, 26 Sep 2020 18:32:36 GMT
< content-type: text/html; charset=UTF-8
< content-length: 728
< x-envoy-upstream-service-time: 1
<
root:x:0:0:root:/root:/bin/bash

❶ 将 my-service.local 解析为 localhost

❷ Envoy 服务器正在响应 HTTP 请求

现在,我们可以通过运行一个将流量转发到 ClusterIP 的入口控制器服务,使用集群 IP 上的内部 curl 命令以及从我们的本地机器运行的 curl 命令来访问由 Python SimpleHTTPServer 托管的内容。如前所述,入口 API 最终将被新的 Gateway API 所取代。

Kubernetes 中的 Gateway API 允许在集群中对不同的租户进行复杂的解耦,用网关、网关类和路由替换入口资源,这些都可以由企业中的不同角色进行配置。尽管如此,网关和入口 API 的概念在功能上是相似的,并且本章中我们学到的许多内容可以自然地转移到 Gateway API。

摘要

  • 在 CNI 插件中的流量转发涉及通过网络接口在节点之间路由 Pod 流量。

  • CNI 插件可以是桥接的或非桥接的,在每种情况下,它们转发流量的方式都不同。

  • 可以使用许多不同的底层技术来实现网络策略,例如 Antrea OpenVSwitch (OVS) 和 Calico iptables。

  • 层 7 网络策略是通过入口控制器实现的。

  • Contour 是一个入口控制器,它解决了 CNIs 在层 7 为 Pods 解决的相同问题,并且可以与任何 CNI 提供商一起工作。

  • 在未来,Gateway API 将用更灵活的 API 架构取代 Ingress API,但本章中你学到的内容可以自然地转移到 Gateway API。

7 Pod 存储和 CSI

本章涵盖

  • 介绍虚拟文件系统(VFS)

  • 探索树内和树外 Kubernetes 存储提供者

  • 在具有多个容器的 kind 集群中运行动态存储

  • 定义容器存储接口(CSI)

存储很复杂,这本书不会涵盖现代应用开发者可用的所有存储类型。相反,我们将从一个具体问题开始:我们的 Pod 需要存储一个文件。这个文件需要在容器重启之间持久化,并且需要能够调度到我们集群中的新节点。在这种情况下,我们在这本书中已经覆盖的默认内置存储卷将“无法满足需求”:

  • 我们的 Pod 不能依赖于hostPath,因为节点本身可能在宿主磁盘上没有唯一的可写目录。

  • 我们的 Pod 也不能依赖于emptyDir,因为它是一个数据库,而数据库无法承担在临时卷上丢失存储信息的风险。

  • 我们的 Pod 可能能够使用 Secrets 保留其证书或密码凭证以访问数据库等服务,但这个 Pod 在 Kubernetes 上运行的应用程序中通常不被视为卷。

  • 我们 Pod 具有在容器文件系统顶层写入数据的能力。这通常很慢,并且不推荐用于高量写入流量。而且,在任何情况下,这根本无法工作:这些数据一旦 Pod 重启就会消失!

因此,我们发现了为我们的 Pod 提供全新维度 Kubernetes 存储:满足应用开发者的需求。Kubernetes 应用程序,就像常规云应用程序一样,通常需要能够在容器内挂载 EBS 卷、NFS 共享或 S3 存储桶中的数据,并从或写入这些数据源。为了解决这个应用程序存储问题,我们需要一个云友好的数据模型和存储 API。Kubernetes 使用持久卷(PV)、持久卷声明(PVC)和存储类(StorageClass)的概念来表示这个数据模型:

  • PVs 为管理员提供了一种在 Kubernetes 环境中管理磁盘卷的方法。

  • PVCs 定义了对这些卷的请求,这些请求可以由应用(通过 Pod)发起,并由 Kubernetes API 在底层满足。

  • StorageClass 为应用开发者提供了一种获取卷的方法,而无需确切知道其实现方式。它为应用提供了一种请求 PVC 的方法,而无需确切知道底层使用的持久卷类型。

StorageClasses 允许应用以声明式的方式请求满足不同最终用户需求的卷或存储类型。这允许你为数据中心设计 StorageClasses,可能满足各种需求,例如

  • 复杂的数据 SLA(保留什么,保留多久,以及不保留什么)

  • 性能要求(批处理应用与低延迟应用)

  • 安全性和多租户语义(用户访问特定卷)

请记住,许多容器(例如,用于管理应用程序证书的 CFSSL 服务器)可能不需要太多的存储,但它们在重启并需要重新加载基本缓存或证书数据等情况下将需要一些存储。在下一章中,我们将进一步探讨如何管理 StorageClasses 的高级概念。如果你是 Kubernetes 的新手,你可能想知道 Pod 是否可以在没有卷的情况下保持任何状态。

Pod 是否保留状态?

简而言之,答案是:不。别忘了,在几乎所有情况下,Pod 都是一个短暂的构造。在某些情况下(例如,使用 StatefulSet),Pod 的一些方面(如 IP 地址或可能的一个本地挂载的主机卷目录)可能在重启之间保持不变。

如果 Pod 因任何原因死亡,它将由 Kubernetes 控制器管理器(KCM)中的进程重新创建。当创建新的 Pod 时,Kubernetes 调度器的任务是确保给定的 Pod 落在能够运行它的节点上。因此,Pod 存储的短暂性质,允许这种实时决策,对于管理大量应用程序的灵活性至关重要。

7.1 简单的偏离:Linux 中的虚拟文件系统(VFS)

在深入探讨 Kubernetes 为 Pod 存储提供的抽象之前,值得注意的一点是,操作系统本身也向程序提供了这些抽象。事实上,文件系统本身是对一个复杂的连接应用程序到一组简单 API 的抽象,我们之前已经见过。你可能已经知道了这一点,但请记住,访问文件就像访问任何其他 API 一样。Linux 操作系统中的文件支持各种明显和基本的命令(以及一些未在此列出的更不透明的命令):

  • read()—从打开的文件中读取一些字节

  • write()—从打开的文件中写入一些字节

  • open()—创建和/或打开一个文件,以便可以进行读写操作

  • stat()—返回有关文件的一些基本信息

  • chmod()—更改用户或组对文件的操作以及读写执行权限

所有这些操作都是针对所谓的虚拟文件系统(VFS)进行的,在大多数情况下,这最终是围绕你的系统 BIOS 的一个包装器。在云环境中,以及在 FUSE(用户空间文件系统)的情况下,Linux VFS 只是一个包装器,它最终是对网络调用的包装。即使你正在将数据写入 Linux 机器之外的磁盘,你仍然是通过 Linux 内核通过 VFS 来访问这些数据的。唯一的区别是,因为你正在写入远程磁盘,VFS 会根据你的操作系统使用其 NFS 客户端、FUSE 客户端或其他所需的文件系统客户端,通过网络发送这个写入操作。这如图 7.1 所示,其中所有的各种容器写入操作实际上是通过 VFS API 进行的:

  • 在 Docker 或 CIR 存储的情况下,VFS 将文件系统操作发送到设备映射器或 OverlayFS,它最终通过你的系统 BIOS 将流量发送到本地设备。

  • 在 Kubernetes 基础设施存储的情况下,VFS 将文件系统操作发送到节点上本地连接的磁盘。

  • 在应用程序的情况下,VFS 通常会将写入操作通过网络发送,尤其是在“真实”的运行在云中或数据中心中的 Kubernetes 集群中。这是因为你没有使用本地卷类型。

那么 Windows 呢?

在 Windows 节点上,kubelet 以与 Linux 类似的方式挂载并向容器提供存储。Windows kubelets 通常运行 CSI 代理 (github.com/kubernetes-csi/csi-proxy),它对 Windows 操作系统进行低级调用,当 kubelet 指示它这样做时挂载和卸载卷。Windows 生态系统中存在关于文件系统抽象的相同概念 (en.wikipedia.org/wiki/Installable_File_System)。

在任何情况下,你不需要理解 Linux 存储 API 就能在 Kubernetes 中挂载 PersistentVolumes。然而,在创建 Kubernetes 解决方案时了解文件系统的基础知识是有帮助的,因为最终,你的 Pods 将会与这些低级 API 交互。现在,让我们回到以 Pod 存储为中心的 Kubernetes 视角。

7.2 Kubernetes 的三种存储需求

术语 存储 是多义的。在我们深入探讨之前,让我们区分一下在 Kubernetes 环境中通常会导致问题的存储类型:

  • Docker/containerd/CRI 存储—运行你的容器的写时复制文件系统。容器在其运行时需要特殊的文件系统,因为它们需要写入 VFS 层(这就是为什么,例如,你可以在容器上运行 rm -rf /tmp 而实际上不会从你的主机上删除任何东西)。通常,Kubernetes 环境使用 btrfs、overlay 或 overlay2 这样的文件系统。

  • Kubernetes 基础设施存储—在单个 kubelets 上使用的 hostPath 或 Secret 卷,用于本地信息共享(例如,作为将要挂载到 Pod 中的秘密的存储位置或存储或网络插件调用的目录)。

  • 应用存储—Pod 在 Kubernetes 集群中使用的存储卷。当 Pods 需要将数据写入磁盘时,它们需要挂载一个存储卷,这通过 Pod 规范来完成。常见的存储卷文件系统有 OpenEBS、NFS、GCE、EC2 和 vSphere 持久磁盘等。

在图 7.1 中,图 7.2 是对其的扩展,我们直观地展示了所有三种存储类型是如何在启动 Pod 时的基本步骤。之前,我们只看了与 CNI 相关的 Pod 启动序列步骤。作为提醒,调度器在 Pod 启动前会进行几个检查,以确认存储已准备好。然后,在启动 Pod 之前,kubelet 和 CSI 提供者在节点上挂载外部应用程序卷,以便 Pod 使用。正在运行的 Pod 可能会将其自己的 OverlayFS 写入数据,这是完全短暂的。例如,它可能有一个用于临时空间的/tmp 目录。最后,一旦 Pod 运行,它会读取本地卷并可能写入其他远程卷。

图 7.1 Pod 启动中的三种存储类型

现在,第一个图示以 CSIDriver 结束,但它所描述的序列图还有许多其他层。在图 7.2 中,我们可以看到 CSIDriver、containerd、分层文件系统和 CSI 卷本身都是针对 Pod 进程的下游目标。具体来说,当 kubelet 启动一个进程时,它会向 containerd 发送消息,containerd 随后在文件系统中创建一个新的可写层。一旦容器化进程启动,它需要从挂载到其上的文件中读取机密信息。因此,在单个 Pod 中会进行许多不同类型的存储调用。在典型的生产场景中,每个调用在其应用程序的生命周期中都有自己的语义和目的。

CSI 卷挂载步骤是在 Pod 启动前发生的最后一步之一。为了理解这一步骤,我们需要快速绕道看看 Linux 是如何组织其文件系统的。

图 7.2 Pod 启动中的三种存储类型,第二部分

7.3 在我们的 kind 集群中创建一个 PVC

理论就到这里吧;让我们给一个简单的 NGINX Pod 提供一些应用存储。我们之前定义了 PVs、PVCs 和 StorageClasses。现在,让我们看看它们是如何被用来为真实的 Pod 提供一个用于存储文件的临时目录的:

  • PV 是由运行在我们kind集群上的动态存储提供者创建的。这是一个容器,它通过满足 PVC 来为 Pod 提供存储。

  • PVC 将在 PersistentVolume 准备好之前不可用,因为调度器需要确保在启动之前可以将存储挂载到 Pod 的命名空间中。

  • kubelet 不会启动 Pod,直到 VFS 成功地将 PVC 挂载到 Pod 的文件系统命名空间中作为一个可写存储位置。

幸运的是,我们的kind集群自带了一个存储提供者。让我们看看当我们请求一个带有新 PVC 的 Pod 时会发生什么,这个 PVC 尚未创建,并且在我们集群中还没有关联的卷。我们可以通过运行以下kubectl get sc命令来检查 Kubernetes 集群中可用的存储提供者:

$ kubectl get sc
NAME                   PROVISIONER             RECLAIMPOLICY
standard (default)     rancher.io/local-path   Delete

VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION    AGE
WaitForFirstConsumer   false                   9d

为了演示 Pod 如何在容器之间共享数据,以及如何以不同的语义挂载多个存储点,这次我们将运行一个包含两个容器和两个卷的 Pod。总的来说,

  • Pod 中的容器可以相互共享信息。

  • kind中,可以通过其动态的hostPath提供程序即时创建持久存储。

  • 任何容器都可以在 Pod 中拥有多个存储卷挂载。

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: dynamic1
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 100k                             ❶
---
apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  containers:
  - image: busybox                              ❷
    name: busybox
    volumeMounts:
      - mountPath: /shared
        name: shared
  - image: nginx                                ❸
    imagePullPolicy: Always
    name: nginx
    ports:
    - containerPort: 80
      protocol: TCP
    volumeMounts:
      - mountPath: /var/www
        name: dynamic1                          ❹
      - mountPath: /shared
        name: shared
  volumes:
  - name: dynamic1
    persistentVolumeClaim:
      claimName: dynamic1                       ❺
  - name: shared
    emptyDir: {}                                ❻
$ kubectl create -f simple.yaml
pod/nginx created

$ kubectl get pods
NAME    READY   STATUS    RESTARTS   AGE
nginx   0/1     Pending   0          3s         ❼

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

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

❶ 与第二个容器共享一个文件夹

❷ 除了与第一个容器共享文件夹外,还为第二个容器指定了一个动态存储卷

❸ 挂载之前创建的卷

❹ 因为存储卷段落位于我们的容器段落之外,所以多个 Pod 可以读取相同的数据。

❺ 如果需要,两个容器都可以访问共享卷

❻ 请求的存储量;我们的 PVC 决定了它是否可以满足。

❼ 第一个状态,挂起状态,是因为我们的 Pod 的存储卷尚未存在。

❽ 最终状态,运行状态,意味着我们的 Pod 的存储卷存在(通过 PVC),Pod 可以访问它;因此,kubelet 启动 Pod。

现在,我们可以在我们的第一个容器中通过运行一个简单的命令,例如echo a > /shared/ASDF,来创建一个文件。我们可以在第二个容器的名为/shared/的 emptyDir 文件夹中轻松地看到这个结果,对于两个容器都是如此:

$ kubectl exec -i -t nginx -t busybox -- /bin/sh
Defaulting container name to busybox.
Use kubectl describe pod/nginx -n default to see the containers in this pod.
/ # cat /shared/a
ASDF

现在我们有一个包含两个卷的 Pod:一个是短暂的,一个是永久的。这是怎么发生的?如果我们查看随我们的kind集群一起提供的local-path-provisioner日志,就会变得很明显:

$ kubectl logs local-path-provisioner-77..f-5fg2w
    -n local-path-storage
controller.go:1027] provision "default/dynamic2" class "standard":
    volume "pvc-ddf3ff41-5696-4a9c-baae-c12f21406022"
        provisioned
controller.go:1041] provision "default/dynamic2" class "standard":
        trying to save persistentvolume "pvc-ddf3ff41-5696-4a9c-baae-
        c12f21406022"
controller.go:1048] provision "default/dynamic2" class "standard":
        persistentvolume "pvc-ddf3ff41-5696-4a9c-baae-c12f21406022" saved
controller.go:1089] provision "default/dynamic2" class "standard": succeeded
event.go:221] Event(v1.ObjectReference{Kind:"PersistentVolumeClaim",
        Namespace:"default", Name:"dynamic2",
        UID:"ddf3ff41-5696-4a9c-baae-
      c12f21406022", APIVersion:"v1", ResourceVersion:"11962",
        FieldPath:""}
    ): type: 'Normal' reason:
        'ProvisioningSucceeded'
    Successfully provisioned volume
        pvc-ddf3ff41-5696-4a9c-baae-c12f21406022

容器始终在我们的集群中以控制器的方式运行。当它看到我们想要一个名为 dynamic2 的卷时,它会为我们创建它。一旦成功,卷本身就会通过 Kubernetes 本身绑定到 PVC。在 Kubernetes 核心中,如果存在一个卷可以满足 PVC 的需求,那么就会发生绑定事件。

在这一点上,Kubernetes 调度器确认这个特定的 PVC 现在可以在节点上部署,如果这个检查通过,Pod 就会从挂起状态移动到容器创建状态,正如我们之前看到的。正如你现在所知道的,容器创建状态仅仅是 kubelet 在 Pod 进入运行状态之前为 Pod 设置 cgroups 和挂载的状态。这个卷是为我们制作的(我们没有手动制作持久卷)是集群中动态存储的一个例子。我们可以这样查看动态生成的卷:

$ kubectl get pv
NAME                                       CAPACITY   ACCESS
pvc-74879bc4-e2da-4436-9f2b-5568bae4351a   100k       RWO

RECLAIM POLICY   STATUS  CLAIM             STORAGECLASS
Delete           Bound   default/dynamic1  standard

如果我们再仔细观察一下,我们可以看到这个卷使用了standard存储类。实际上,这个存储类是 Kubernetes 能够创建这个卷的方式。当定义了一个标准或默认存储类时,没有存储类的 PVC 会自动配置为接收默认的 PVC(如果存在)。这实际上是通过一个准入控制器来实现的,它会预先修改进入 API 服务器的新 Pod,为它们添加一个默认存储类标签。有了这个标签,运行在您的集群中的卷提供程序(在我们的案例中,这被称为 local-path-provisioner,并且与kind捆绑在一起)会自动检测新 Pod 的存储请求并立即创建一个卷:

$ kubectl get sc -o yaml
apiVersion: v1
items:
- apiVersion: storage.k8s.io/v1
  kind: StorageClass
  metadata:
    annotations:
      kubectl.kubernetes.io/last-applied-configuration: |
        {"apiVersion":"storage.k8s.io/v1",
           "kind":"StorageClass","metadata":{
         "annotations":{
                "storageclass.kubernetes.io/is-default-class": "true"}
               ,"name":"standard"
             },
             "provisioner":"rancher.io/local-path",
             "reclaimPolicy":"Delete",
          "volumeBindingMode":"WaitForFirstConsumer"}
      storageclass.kubernetes.io/is-default-class: "true"    ❶
    name: standard
  provisioner: rancher.io/local-path
kind: List                                                   ❷

is-default-class使得这是 Pod 想要存储而不需要显式请求存储类的首选卷。

❷ 在一个集群中,你可以有多个不同的存储类。

一旦我们意识到 Pod 可以有多种不同类型的存储,就变得明显我们需要为 Kubernetes 提供一个可插拔的存储提供者。这就是 CSI 接口(kubernetes-csi.github.io/docs/)的目的。

7.4 容器存储接口(CSI)

Kubernetes CSI 定义了一个接口(图 7.3),以便提供存储解决方案的供应商可以轻松地将自己插入到任何 Kubernetes 集群中,并为应用程序提供广泛的存储解决方案以满足不同的需求。它是树内存储的替代方案,在树内存储中,kubelet 本身将其启动过程中 Pod 的卷类型的驱动程序烘焙到其启动过程中。

图 7.3 Kubernetes CSI 模型的架构

定义 CSI 的目的是为了从供应商的角度轻松管理存储解决方案。为了阐述这个问题,让我们考虑几个 Kubernetes PVC 的底层存储实现:

  • vSphere 的 CSI 驱动程序可以创建基于 VMFS 或 vSAN 的 PersistentVolume 对象。

  • 如果你想在容器中以分布式方式运行卷,像 GlusterFS 这样的文件系统有 CSI 驱动程序允许你这样做。

  • Pure Storage 有一个 CSI 驱动程序,可以直接在 Pure Storage 磁盘阵列上创建卷。

许多其他供应商也为 Kubernetes 提供了基于 CSI 的存储解决方案。在我们描述 CSI 如何使这变得容易之前,我们将快速查看 Kubernetes 中的树内提供者问题。这个 CSI 在很大程度上是对与树内存储模型相关的管理存储卷的挑战的回应。

7.4.1 树内提供者问题

自从 Kubernetes 诞生以来,供应商们花了很多时间将其核心代码库中的互操作性。结果是,不同存储类型的供应商必须将可操作性代码贡献给 Kubernetes 核心本身!在 Kubernetes 代码库中仍然存在这种遗留问题,正如我们可以在mng.bz/J1NV中看到的那样:

package glusterfs

import (
    "context"
         ...
    gcli "github.com/heketi/heketi/client/api/go-client"
    gapi "github.com/heketi/heketi/pkg/glusterfs/api"

GlusterFS 的 API 包(Heketi 是 Gluster 的 REST API)的导入实际上意味着 Kubernetes 了解并依赖于 GlusterFS。进一步观察,我们可以看到这种依赖是如何体现的:

func (p *glusterfsVolumeProvisioner) CreateVolume(gid int)
    (r *v1.GlusterfsPersistentVolumeSource, size int,
     volID string, err error) {
  ...
    // GlusterFS/heketi creates volumes in units of GiB.
    sz, err := volumehelpers.RoundUpToGiBInt(capacity)
  ...
    cli := gcli.NewClient(p.url, p.user, p.secretValue)
  ...

Kubernetes 卷包最终会调用 GlusterFS API 来创建新的卷。这也可以在其他供应商那里看到,例如 VMware 的 vSphere。事实上,包括 VMware、Portworx、ScaleIO 等在内的许多供应商,在 Kubernetes 的 pkg/volume 文件下都有自己的目录。这对于任何开源项目来说都是一个明显的反模式,因为它将供应商特定的代码与更广泛的开源框架混淆。这带来了明显的负担:

  • 用户必须将他们的 Kubernetes 版本与特定的存储驱动程序对齐。

  • 供应商必须不断向 Kubernetes 本身提交代码,以保持他们的存储产品更新。

这两种场景显然是不可持续的。因此,需要一种标准来定义外部化的卷创建、挂载和生命周期功能。类似于我们之前对 CNI 的探讨,CSI 标准通常会导致在每个节点上运行 DaemonSet 来处理挂载(类似于处理命名空间 IP 注入的 CNI 代理)。此外,CSI 允许我们轻松地交换一种存储类型为另一种类型,甚至可以同时运行多种存储类型(这在网络中不容易做到),因为它指定了特定的卷命名约定。

注意,树内问题并不仅限于存储。CRI、CNI 和 CSI 都是源于在 Kubernetes 中长期存在的污染代码。在 Kubernetes 的第一版本中,代码库与 Docker、Flannel 和许多其他文件系统等工具耦合。这些耦合随着时间的推移正在被移除,CSI 只是代码可以从树内移动到树外的一个突出例子,一旦建立了适当的接口。然而,在实践中,仍然有相当多的供应商特定的生命周期代码存在于 Kubernetes 中,真正解耦这些附加技术可能需要数年时间。

7.4.2 CSI 作为在 Kubernetes 内部工作的规范

图 7.4 展示了使用 CSI 驱动程序配置 PVC 的工作流程。与我们在 GlusterFS 中看到的情况相比,它更加透明且解耦,在 GlusterFS 中,不同的组件以离散的方式完成不同的任务。

图 7.4

图 7.4 使用 CSI 驱动程序配置 PVC

CSI 规范抽象地定义了一组通用的功能,允许在不指定任何实现的情况下定义存储服务。在本节中,我们将通过 Kubernetes 本身的环境来探讨这个接口的一些方面。它定义的操作分为三个一般类别:身份服务、控制器服务和节点服务。其核心概念,正如你可能猜到的,是一个控制器,通过与后端提供者(你的昂贵 NAS 解决方案)和 Kubernetes 控制平面协商存储需求,通过满足动态存储请求来实现。让我们快速看一下这三个类别:

  • 身份服务—允许插件服务自我识别(提供关于自身的元数据)。这允许 Kubernetes 控制平面确认特定类型的存储插件正在运行且可用于特定类型的卷。

  • 节点服务—允许 kubelet 本身与一个本地服务通信,该服务可以执行特定于存储供应商的操作。例如,当 CSI 提供商的节点服务被提示挂载特定类型的存储时,它可能会调用供应商特定的二进制文件。这是通过套接字请求的,通过 GRPC 协议进行通信。

  • 控制器服务—实现供应商存储卷的创建、删除和其他生命周期相关事件。请注意,为了使 NodeService 有任何价值,所使用的后端存储系统需要首先 创建 一个可以在适当时刻附加到 kubelet 的卷。因此,控制器服务扮演了一个“粘合剂”的角色,将 Kubernetes 连接到存储供应商。正如你所期望的,这是通过运行针对卷操作的 Kubernetes API 的监视器来实现的。

以下代码片段提供了 CSI 规范的简要概述。我们在这里没有展示所有方法,因为它们可以在 mng.bz/y4V7 找到:

service Identity {
  rpc GetPluginInfo(GetPluginInfoRequest)                  ❶
  rpc GetPluginCapabilities(GetPluginCapabilitiesRequest)
  rpc Probe (ProbeRequest)
}

service Controller {
  rpc CreateVolume (CreateVolumeRequest)
  rpc DeleteVolume (DeleteVolumeRequest)                   ❷
  rpc ControllerPublishVolume (ControllerPublishVolumeRequest)
}

service Node {
  rpc NodeStageVolume (NodeStageVolumeRequest)             ❸
  rpc NodeUnstageVolume (NodeUnstageVolumeRequest)
  rpc NodePublishVolume (NodePublishVolumeRequest)
  rpc NodeUnpublishVolume (NodeUnpublishVolumeRequest)
  rpc NodeGetInfo (NodeGetInfoRequest)
  ...
}

❶ 身份服务告诉 Kubernetes 由集群中运行的控制器可以创建哪些类型的卷。

❷ 在节点可以将卷挂载到 Pod 之前,会调用创建和删除方法,以实现动态存储。

❸ 节点服务是 CSI 的一部分,它在 kubelet 上运行,根据需求将之前创建的卷挂载到特定的 Pod 中。

7.4.3 CSI:存储驱动程序的工作原理

CSI 存储插件 将挂载 Pod 存储所需的操作分解为三个不同的阶段。这包括注册存储驱动程序、请求卷和发布卷。

在 Kubernetes API 中注册存储驱动程序。这涉及到告诉 Kubernetes 如何处理这个特定的驱动程序(是否需要在存储卷可写之前执行某些操作),并让 Kubernetes 知道某种类型的存储对 kubelet 可用。CSI 驱动程序的名字很重要,正如我们很快就会看到的:

type CSIDriverInfoSpec struct {
    Name string `json:"name"`

当请求一个卷(例如通过向您的 $200,000 NAS 解决方案发出 API 调用)时,供应商的存储机制会被调用以创建一个存储卷。这是通过我们之前引入的 CreateVolume 函数完成的。对 CreateVolume 的调用实际上是由(通常是)一个单独的服务完成的,该服务被称为 外部提供者,它可能不在 DaemonSet 中运行。相反,它是一个标准的 Pod,它监视 Kubernetes API 服务器,并通过调用存储供应商的另一个 API 来响应卷请求。此服务查看创建的 PVC 对象,然后针对已注册的 CSI 驱动程序调用 CreateVolume。它知道要调用哪个驱动程序,因为卷名称提供给它这些信息。(因此,正确获取 name 字段非常重要。)在这种情况下,对 CSI 驱动程序中卷的请求与该卷的挂载是分开的。

当发布一个卷时,该卷会被附加(挂载)到一个 Pod 上。这通常由存在于您集群每个节点上的 CSI 存储驱动程序完成。发布一个卷是一种将卷挂载到 kubelet 请求的位置的巧妙说法,以便 Pod 可以向其写入数据。kubelet 负责确保 Pod 的容器以正确的挂载命名空间启动,以便访问此目录。

7.4.4 绑定挂载

您可能还记得,我们之前将挂载定义为简单的 Linux 操作,这些操作将目录暴露在 / 树下的新位置。这是附件程序和 kubelet 之间合同的基本部分,由 CSI 接口定义。在 Linux 中,当我们通过镜像目录将目录提供给 Pod(或任何其他进程)时,我们所指的具体操作称为 绑定挂载。因此,在任何 CSI 提供的存储环境中,Kubernetes 都运行着几个服务,这些服务协调 API 调用之间的微妙交互,以达到将外部存储卷挂载到 Pod 中的最终目标。

因为 CSI 驱动程序是一组通常由供应商维护的容器,kubelet 本身需要能够接受可能会从容器内部创建挂载点。这被称为 挂载传播,并且是 Kubernetes 正确运行某些底层 Linux 要求的重要组成部分。

7.5 快速查看一些正在运行的 CSI 驱动程序

我们将以一些真实的 CSI 提供者的具体示例来结束。由于这可能需要一个正在运行的集群,而不是创建一个逐步演示 CSI 行为的教程(就像我们之前对 CNI 提供者所做的那样),我们将分享 CSI 提供者各个组件的运行日志。这样,您可以看到本章中接口是如何在实时中实现和监控的。

7.5.1 控制器

控制器是任何 CSI 驱动程序的大脑,它将存储请求与后端存储提供者(如 vSAN、EBS 等)连接起来。它实现的接口需要能够动态创建、删除和发布卷,以便我们的 Pod 可以使用。如果我们直接查看运行中的 vSphere CSI 控制器的日志,我们可以看到对 Kubernetes API 服务器的持续监控:

I0711 05:38:07.057037       1 controller.go:819] Started provisioner
   controller csi.vsphere.vmware.com_vsphere-csi-controller-...-
I0711 05:43:25.976079       1 reflector.go:389] sigs.k8s.io/sig-
     storage-lib-external-provisioner/controller/controller.go:807:
        Watch close - *v1.StorageClass total 0 items received
I0711 05:45:13.975291       1 reflector.go:389] sigs.k8s.io/sig-
     storage-lib-external-provisioner/controller/controller.go:804:
        Watch close - *v1.PersistentVolume total 3 items received
I0711 05:46:32.975365       1 reflector.go:389] sigs.k8s.io/sig-
     storage-lib-external-provisioner/controller/controller.go:801:
        Watch close - *v1.PersistentVolumeClaim total 3 items received

一旦感知到这些 PVC,控制器就可以从 vSphere 本身请求存储。然后,vSphere 创建的卷可以跨 PVC 和 PV 同步元数据,以确认 PVC 现在可以挂载。之后,CSI 节点接管(调度器首先会确认 Pod 目标上的 vSphere CSI 节点是健康的)。

7.5.2 节点接口

节点接口负责与 kubelet 通信并将存储挂载到 Pod 上。我们可以通过查看生产中卷的运行日志具体地看到这一点。之前,我们尝试在一个敌对环境中运行 NFS CSI 驱动程序,作为揭示 Linux 底层 VFS 利用情况的一种方式。现在我们已经涵盖了 CSI 接口,让我们再次回顾 NFS CSI 驱动程序在生产中的样子。

我们首先将探讨 NFS 和 vSphere CSI 插件如何使用套接字与 kubelet 通信。这就是接口的节点组件被调用的方式。当我们深入研究 CSI 节点容器的细节时,我们应该看到类似这样的内容:

$ kubectl logs
➥ csi-nodeplugin-nfsplugin-dbj6r  -c nfs
I0711 05:41:02.957011  1 nfs.go:47]
➥ Driver: nfs.csi.k8s.io version: 2.0.0       ❶
I0711 05:41:02.963340  1 server.go:92] Listening for connections on address:
   &net.UnixAddr{
     Name:"/plugin/csi.sock",
     Net:"unix"}                               ❷

$ kubectl logs csi-nodeplugin-nfsplugin-dbj6r
    -c node-driver-registrar
I0711 05:40:53.917188   1 main.go:108] Version: v1.0.2-rc1-0-g2edd7f10
I0711 05:41:04.210022   1 main.go:76] Received GetInfo call: &InfoRequest{}

❶ CSI 驱动程序的名称

❷ kubelet 与其用于存储的 CSI 插件通信的通道

CSI 驱动程序的命名很重要,因为它属于 CSI 协议的一部分。csi-nodeplugin 在启动时打印其确切版本。请注意,csi.sock 插件目录是 kubelet 用于与 CSI 插件通信的通用通道:

$ kubectl logs -f vsphere-csi-node-6hh7l  -n kube-system
➥ -c vsphere-csi-node
{"level":"info","time":"2020-07-08T21:07:52.623267141Z",
  "caller":"logger/logger.go:37",
  "msg":"Setting default log level to :\"PRODUCTION\""}
{"level":"info","time":"2020-07-08T21:07:52.624012228Z",
   "caller":"service/service.go:106",
   "msg":"configured: \"csi.vsphere.vmware.com\"
      with clusterFlavor: \"VANILLA\"
      and mode: \"node\"",
      "TraceId":"72fff590-523d-46de-95ca-fd916f96a1b6"}

level=info msg="identity service registered"    ❶
level=info msg="node service registered"
level=info msg=serving endpoint=
➥ "unix:///csi/csi.sock"                       ❷

❶ 显示驱动程序的标识已注册

❷ 显示使用 CSI 套接字

这就结束了我们对 CSI 接口及其存在原因的讨论。与其他 Kubernetes 组件不同,没有在你面前运行真实工作负载的集群,讨论或推理这一点并不容易。作为后续练习,我们强烈建议在你的选择集群(虚拟机或裸机)上安装 NFS CSI 提供程序(或任何其他 CSI 驱动程序)。一个值得运行的练习是测量卷的创建是否会随着时间的推移而减慢,如果是这样,瓶颈是什么。

我们在本章中没有包含 CSI 驱动程序的实时示例,因为大多数当前在生产集群中使用的 CSI 驱动程序都无法在简单的kind环境中运行。一般来说,只要您理解卷的配置与这些卷的挂载是不同的,您就应该准备好通过将这些两个独立的操作视为不同的故障模式来调试生产系统中的 CSI 故障。

7.5.3 在非 Linux 操作系统上的 CSI

与 CNI 类似,CSI 接口是操作系统无关的;然而,对于能够运行特权容器的 Linux 用户来说,其实现方式非常自然。与 Linux 之外的联网方式一样,CSI 在 Linux 进程中的实现方式传统上略有不同。例如,如果你在 Windows 上运行 Kubernetes,你可能会发现 CSI 代理项目(github.com/kubernetes-csi/csi-proxy)非常有价值,该项目在集群的每个 kubelet 上运行一个服务,抽象掉了实现 CSI 节点功能的大多数 PowerShell 命令。这是因为,在 Windows 上,特权容器的概念相当新颖,并且仅在 containerd 的某些较新版本上工作。

随着时间的推移,我们预计运行 Windows kubelets 的许多人也将能够以与我们在本章中演示的 Linux DaemonSets 类似的行为,将他们的 CSI 实现作为 Windows DaemonSets 运行。最终,抽象存储的需求发生在计算堆栈的许多层面上,Kubernetes 只是在这个不断增长的存储和持久化支持生态系统之上增加的一个抽象层。

摘要

  • 当 Pod 通过 kubelet 执行的挂载操作创建时,它们可以在运行时动态获取存储。

  • kind集群中为 Pod 创建一个 PVC 是尝试 Kubernetes 存储提供者的最简单方式。

  • 对于 NFS 的 CSI 提供者是众多 CSI 提供者之一,它们都遵循相同的 CSI 标准进行容器存储挂载。这使 Kubernetes 源代码与存储供应商源代码解耦。

  • 当实现时,CSI 定义的身份控制器和节点服务,每个都包含几个抽象函数,允许提供者通过 CSI API 动态地为 Pod 提供存储。

  • CSI 接口可以实现在非 Linux 操作系统上工作,其中 Windows kubelets 的 CSI 代理是这个类型实现的主要例子。

  • Linux 虚拟文件系统(VFS)包括任何可以打开、读取和写入的内容。磁盘操作在其 API 之下发生。

8 存储实现和建模

本章涵盖了

  • 探索动态存储的工作原理

  • 在工作负载中利用 emptyDir 卷

  • 使用 CSI 提供商管理存储

  • 使用 hostPath 值与 CNI 和 CSI

  • 为 Cassandra 实现 storageClassTemplates

在 Kubernetes 集群中建模存储是管理员在生产之前需要完成的最重要任务之一。这包括对你生产应用程序的存储需求提出问题,并且有几个维度。你将希望一般性地对任何需要持久存储的应用程序提出以下问题:

  • 存储是否需要持久性或只是尽力而为?持久性存储通常意味着 NAS、NFS 或类似 GlusterFS 的东西。所有这些都有你需要验证的性能权衡。

  • 存储是否需要快速?I/O 是否是瓶颈?如果速度很重要,内存中运行的 emptyDir 或适合这种用途的存储控制器特殊存储类通常是一个不错的选择。

  • 每个容器使用多少存储,你预计要运行多少个容器?可能需要一个存储控制器来处理大量容器。

  • 你是否需要一个专门的磁盘用于安全?如果是这样,本地卷可能可能满足你的需求。

  • 你是否在运行带有模型或训练缓存的 AI 工作负载?这些可能需要快速回收的卷,每次持续几小时。

  • 存储是否在 1-10 GB 的范围内?如果是这样,在大多数情况下,本地存储或 emptyDir 可能适用。

  • 你是否在实施类似于 Hadoop 分布式文件系统(HDFS)或 Cassandra 这样的系统,这些系统为你复制和备份数据?如果是这样,你可以专门使用本地磁盘卷,但这种方式恢复起来比较复杂。

  • 你是否可以接受停机时间和冷存储?如果是这样,可能需要在廉价的分布式卷之上构建一个对象存储模型。像 NFS 或 GlusterFS 这样的技术在这里是一个很好的用例。

8.1 Kubernetes 更广泛生态系统的缩影:动态存储

一旦你对应用程序的存储需求有了感觉,你可以查看 Kubernetes 提供的原语。在存储工作流程中有许多不同动机的角色。这是因为存储,与网络不同,由于物理约束(需要在机器重启之间持久化)以及企业存储的各个方面(法律和程序方面)的极端有限和昂贵,因此是一个极其有限和昂贵的资源。为了在我们脑海中清晰地保持这些角色的顺序,让我们快速看一下图 8.1 中整体存储故事的 1000 英尺表示。

图 8.1 存储的高级表示

在图 8.1 中,你会注意到用户请求存储,管理员通过存储类定义存储,而 CSI 提供者通常负责为用户提供存储以便写入。如果我们回顾一下我们关于网络的那一章,这种多租户的存储提供视图可以被视为类似于正在出现的第 7 层负载均衡的 Gateway API:

  • GatewayClasses 在某种程度上类似于 CSI 的 StorageClasses,因为它们定义了网络的一个入口点类型。

  • 网关在某种程度上类似于 CSI 的 PersistentVolumes(PVs),因为它们代表已配置的第 7 层负载均衡器。

  • 路由在某种程度上类似于 CSI 的 PersistentVolumeClaims(PVCs),因为它们允许单个开发者请求特定应用程序的 GatewayClass 实例。

因此,当我们深入研究存储时,记住 Kubernetes 本身随着时间的推移,越来越多地致力于围绕基础设施资源构建供应商中立的 API,以便开发人员和管理员可以异步和独立地进行管理,这很有帮助。话虽如此,让我们跳入查看动态存储以及它在各种常见用例中对开发者的意义。

8.1.1 动态管理存储:动态提供

在集群中动态管理存储的能力意味着我们需要能够动态提供卷。这被称为动态提供。在最通用意义上,动态提供是许多集群解决方案(例如,Mesos 已经有一段时间提供 PersistentVolume 了,它为进程保留了持久、可回收的本地存储)的一个特性。任何使用过 VSan 等产品的都知道,EBS 云提供商必须提供某种 API 驱动的存储模型。

Kubernetes 中的动态提供具有高度可插拔性(PVCs、CSI)和声明性(PVCs 与动态提供)的特点,这使得你可以为不同类型的存储解决方案构建自己的语义,并在 PVC 和相应的 PersistentVolume 之间提供间接性。

8.1.2 本地存储与 emptyDir 的比较

对于大多数 Kubernetes 新手来说,emptyDir卷是众所周知的。这是将目录挂载到 Pod 中的最简单方法,基本上没有需要密切监控的安全或资源成本。然而,在其使用方面存在许多细微之处,当将应用程序部署到生产环境中时,这些细微之处可以证明是强大的安全和性能提升器。

在表 8.1 中,我们比较了本地和空卷类型。当涉及到本地和 emptyDir 时,尽管所有数据都是本地的,但我们有一个完全不同的存储生命周期。例如,本地卷可以可靠地用于在灾难发生时从运行中的数据库恢复数据,而 emptyDir 则不支持这种用例。对于 PVC,使用第三方卷和卷控制器是第三个用例,通常意味着如果 Pod 需要迁移,存储可以便携并在新节点上挂载。

表 8.1 比较本地、emptyDir 和 PVC 存储

存储类型 1 生命周期 可靠性 实现 典型消费者
Local 本地磁盘的生命周期 节点上的本地磁盘 一个重量级的数据密集型或遗留应用
emptyDir 只要你的 Pod 在同一节点上 节点上的本地文件夹 任何 Pod
PVC 永久¹ 第三方存储供应商 一个轻量级数据库应用

通常,我们为了需要持久性的应用程序而消耗 PVC。在具有复杂持久存储需求的情况下,我们可能会实现一个本地存储卷(例如,需要在同一位置运行并需要连接到大型磁盘以用于遗留目的的应用程序)。emptyDir 卷没有特定的用例,它被用作 Pod 中的瑞士军刀,用于各种目的。emptyDir类型通常用于两个容器需要一个临时存储区来写入数据时。你可能会想知道为什么有人会使用emptyDir类型而不是直接将真实的持久卷挂载到两个容器上。有几个原因:

  • 持久卷通常很昂贵。它们需要一个分布式存储控制器来配置具有特定存储量的卷,而这种存储可能是有限的。如果你不需要保留 Pod 中的数据,那么浪费存储资源就没有价值。

  • 持久卷可能比 emptyDir 卷慢一个数量级或更多。这是因为它们通常需要网络流量和写入某种类型的磁盘。然而,emptyDir 卷可以写入临时文件存储(tmpfs)或甚至纯内存映射卷,这些卷按定义与 RAM 一样快。

  • 持久卷(PersistentVolumes)在本质上比 emptyDir 卷更不安全。持久卷中的数据可能会在集群的不同位置被保留并重新读取。相比之下,emptyDir 卷不能被 Kubernetes 挂载到声明它们的 Pod 之外的内容。

  • emptyDir 可以与临时容器一起使用来创建目录。这包括当应用程序想要在其生命周期内将日志文件或配置文件写入特定位置时,如/var/log 和/etc。

  • 你需要出于性能或功能原因将/tmp 或/var/log 目录添加到容器中。

emptyDir 卷可以用作性能优化或作为修改容器目录结构的一种方式。在功能上,当容器缺少默认文件系统路径,只包含单个二进制可执行文件时,它可能需要一个 emptyDir 卷。有时容器是用 scratch 镜像构建的,以减少其安全足迹,但这会付出没有地方缓存或存储简单文件(如日志或缓存数据)的代价。

即使您在容器镜像中已有可用的/var/log 目录,您仍然可能希望使用 emptyDir 作为写入磁盘的性能优化。这是常见的,因为将文件写入预定义目录(例如,/var/log)的容器可能会因为写时复制的文件系统操作缓慢而受到影响。容器层通常具有这样的文件系统,允许进程将数据写入文件系统的顶层,而不会实际影响底层的容器镜像。这允许您在运行的容器中几乎做任何事情而不会损坏底层的 Docker 镜像,但这会带来性能成本。与其他原生文件系统操作相比,写时复制文件系统通常较慢(并且可能 CPU 密集)。这取决于您为容器运行时运行的存储驱动程序。

如您所见,在空目录卷在生产中的使用方面相当复杂。但总的来说,如果您对 Kubernetes 存储感兴趣,您可能将花费更多的时间来解决与持久卷相关的问题,而不是与临时卷相关的问题。

8.1.3 持久卷

持久卷是 Kubernetes 对可以挂载到 Pod 的存储卷的引用。存储是通过 kubelet 挂载的,它会调用、创建和挂载各种类型的卷和/或潜在的 CSI 驱动程序(我们将在下一节讨论)。因此,持久卷声明(PVC)是对持久卷的命名引用。如果卷的类型是RWO(代表一次读写),则此声明会锁定卷,这样其他 Pod 在卷不再挂载之前可能无法再次使用它。当你创建一个需要持久存储的 Pod 时,以下事件链通常会发生:

  1. 您请求创建一个需要 PVC 的 Pod。

  2. Kubernetes 调度器开始寻找 Pod 的归宿,等待具有适当存储拓扑、CPU 和内存属性节点的到来。

  3. 您创建一个有效的 PVC,以便 Pod 可以访问。

  4. Kubernetes 控制平面会满足卷声明。

    这涉及到通过动态存储控制器创建一个持久卷。大多数生产级 Kubernetes 安装至少包含一个这样的控制器(或者很多,这些控制器通过 StorageClass 名称区分),这通常是一个供应商附加组件。这些控制器只是监视 API 服务器中标准 PVC 对象的创建,然后创建一个卷,这些声明将使用该卷进行存储。

  5. 现在,由于其存储声明已得到满足,调度器将继续决定你的 Pod 现在可以启动了。

  6. 依赖于此声明的 Pod 可以被调度,并且 Pod 将被启动。

  7. 在 Pod 启动过程中,kubelet 会挂载与该声明对应的本地目录。

  8. 本地挂载的卷对 Pod 可写。

  9. 你请求的 Pod 现在正在运行,并正在读取或写入持久卷内部的存储。

Kubernetes 调度器和将卷附加到 Pod

Kubernetes 调度器与将卷附加到 Pod 的逻辑紧密交织在一起。调度器定义了几个扩展点,我们可以根据不同的 Pod 需求(如存储)实现逻辑。这些扩展点是 PreFilter、Filter、PostFilter、Reserve、PreScore、PreBind、Bind、PostBind、Permit 和 QueueSort。PreFilter 扩展点是调度器实现存储逻辑的地方之一。

智能地决定 Pod 是否准备好启动的能力部分取决于调度器所了解的存储参数。例如,调度器会主动避免调度依赖于卷的 Pod,在现有 Pod 已经可以访问此类卷的情况下,这仅允许一个并发读取者。这防止了 Pod 启动错误,其中卷从未绑定,但你无法找出原因。

你可能会想知道为什么调度器需要访问有关存储的信息。(毕竟,正如你可能想象的那样,将存储附加到 Pod 实际上是 kubelet 的职责。)原因是性能和可预测性。由于各种原因,你可能想限制节点上卷的数量。此外,如果特定节点在存储方面有特定的约束,调度器可能会主动避免在这些节点上放置 Pod,以避免创建“僵尸”Pod,尽管已经调度,但由于无法访问存储资源,这些 Pod 从未正确启动。

多亏了 Kubernetes API 在支持存储容量逻辑方面的最新进展,CSI API 包括描述存储约束的能力,并且这种方式可以被查询并由调度器使用,以便将 Pod 放置在最适合其存储需求的节点上。要了解更多信息,您可以查阅mng.bz/M2pE

8.1.4 CSI(容器存储接口)

你可能想知道 kubelet 是如何能够挂载任意存储类型的。例如,像 NFS 这样的文件系统需要在典型的 Linux 发行版上安装 NFS 客户端。确实,存储挂载非常依赖于平台,而 kubelet 并不能神奇地为你解决这个问题。

直到 Kubernetes 1.12 版本,像 NFS、GlusterFS、Ceph 以及许多其他常见的文件系统类型都包含在 kubelet 本身中。然而,CSI 改变了这一点,现在 kubelet 越来越不了解特定平台的文件系统。相反,对挂载特定类型存储感兴趣的用户通常会在他们的集群上运行一个作为 DaemonSet 的 CSI 驱动程序。这些驱动程序通过套接字与 kubelet 通信,并执行必要的低级文件系统挂载操作。向 CSI 的迁移使得供应商能够轻松地演进存储客户端并频繁发布这些客户端的更新,而无需将他们的供应商特定逻辑放入特定的 Kubernetes 版本中。

CNCF 中常见的模式是首先发布一个包含许多依赖项的开源项目,然后随着时间的推移逐渐将这些依赖项分离出来。这有助于为早期采用者创建一个简单的用户体验。然而,一旦某项技术的采用变得普遍,就会在事后进行工作,以解耦这些依赖项,从而清理架构。CNI、CSI 和 CRI 接口都是这种模式的例子。

CSI 是容器存储接口,它已经发展到一个程度,使得持久卷代码不再需要编译到你的 Kubernetes 版本中。CSI 存储模型意味着你只需要实现几个 Kubernetes 概念(一个 DaemonSet 和一个存储控制器),这样 kubelet 就可以分配任何你想要的存储类型。CSI 不是 Kubernetes 特定的。公平地说,Mesos 也支持 CSI,以及 Kubernetes 本身,所以我们并不是在针对任何人。

8.2 动态分配从 CSI 中受益,但两者是正交的

动态分配,即在创建 PVC 时神奇地创建持久卷的能力,与CSI不同,CSI 赋予你将任何类型的存储动态挂载到容器中的能力。然而,这两种技术却是相当协同的。通过结合它们,你可以允许开发人员继续使用相同的声明(通过稍后描述的 StorageClasses)来挂载可能不同类型的存储,这些存储暴露了相同的高级语义。例如,一个fast存储类最初可能使用通过 NAS 暴露的固态硬盘来实现:

kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: fast
parameters:
  type: pd-ssd

之后,你可能会支付一家公司(如 Datera)在另一个存储阵列上提供快速存储。在两种情况下,使用动态分配器,你的开发人员可以继续使用完全相同的 API 请求来为新的存储卷分配,只需在集群上运行 CSI 驱动程序,并且存储控制器在幕后发生变化。

在任何情况下,大多数云提供商都通过简单的云附加磁盘类型作为默认值实现了 Kubernetes 的动态提供。在许多小型应用程序中,云提供商自动选择的缓慢的 PersistentVolume 已经足够。然而,对于异构工作负载,能够在不同的存储模型之间进行选择,并在 PVC 满足方面实施策略(或者,更好的是,操作员)变得越来越重要。

8.2.1 存储类

存储类 允许以声明的方式指定复杂的存储语义。尽管可以向不同类型的存储类发送多个参数,但它们共有的一个参数是 绑定模式。这正是构建自定义、动态提供者可能极为重要的地方。

动态提供不仅仅是提供简单存储的一种方式,而且还是一种强大的工具,可以在具有异构存储需求的数据中心中启用高性能工作负载。在生产中,你关心的每一个不同工作负载都可能从不同的存储类中受益,这些存储类针对绑定模式、保留和性能(我们将在稍后解释)。

一个数据中心假设的存储类提供者

存储类似乎主要是理论上的,直到我们考虑一个 Kubernetes 管理员抵御数十名渴望将应用程序部署到生产环境且对存储工作原理知之甚少的开发者的用例。考虑一下这样一个场景,其中你有三种类型的应用程序:批量数据处理、事务型 Web 风格应用程序和 AI 应用程序。在这个场景中,一个人可能会创建一个具有三个存储类的单个卷提供者。应用程序可以像这样声明性地请求特定的存储类型:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: my-big-data-app-vol
spec:
  storageClassName: bigdata
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 100G

这个 PVC 将像这样存在于 Pod 中:

apiVersion: v1
kind: Pod
metadata:
  name: my-big-data-app
spec:
  volumes:
    - name: myvol
      persistentVolumeClaim:
        claimName: my-big-data-app-vol
  containers:
    - name: my-big-data-app
      image: datacruncher:0.1
      volumeMounts:
        - mountPath: "/mybigdata-app-volume"
          name: myvol

关于 PVC 的工作原理的快速提醒

Kubernetes 会查看 PVC 的元数据(例如,它请求多少存储)然后找到匹配你声明的 PV 对象。因此,你不需要明确地将存储分配给一个声明。相反,你创建一个请求某些属性(例如,100 G 的存储)的声明,并异步创建一个满足这些属性的卷。

8.2.2 回到数据中心相关内容

在我们构想的动态提供者中会发生什么?让我们看看:

  1. 我们编写一个控制循环来监视卷声明。

  2. 当我们看到一个请求时,我们通过 API 调用(例如,调用我们 NAS 上的存储提供者)在 100 G 大小的持久旋转磁盘上召唤一个卷。请注意,另一种实现方式是在 NAS 或 NFS 共享中预先创建许多存储目录。

  3. 然后,我们定义一个 Kubernetes PV 对象来支持 PVC。这种卷类型可以是任何东西,例如 NFS 或 hostPath PV 类型。

从这里开始,Kubernetes 负责工作,一旦 PVC 用后端持久卷填充,我们的 Pod 就可以调度。在这种情况下,我们提到了三个步骤:控制循环、卷请求以及创建该卷。我们关于创建哪种低级存储卷的决定取决于我们的开发人员请求哪种类型的存储。在前一个代码片段中,我们使用了bigdata作为 StorageClass 类型。在数据中心,我们通常支持三种存储类:

  • bigdata(如前所述)

  • postgres

  • ai

为什么有三个类别?没有具体原因需要三个存储类实现。我们可能很容易就有四个或更多的类别。

对于大数据/HDFS/ETL 风格的工作负载,以及存储密集型工作,数据本地性很重要。因此,在这种情况下,你可能希望将数据存储在裸金属磁盘上,并从该磁盘读取,就像它是主机卷挂载一样。这种类型的绑定模式可能从 WaitForFirstConsumer 策略中受益,允许在运行工作负载的节点上直接创建和附加卷,而不是事先创建,可能是在数据本地性较低的地方。

由于 Hadoop 数据节点是集群的持久特性,而 HDFS 本身为你维护复制,因此这种模型的保留策略可能是删除。对于冷存储工作负载(例如,在 GlusterFS 中),你将想要自动化一个策略,为特定命名空间中运行的工作负载实现存储卷的特定转换器。无论如何,所有配置都可能是在当时可用的最便宜的磁盘上按需完成的。

对于 Postgres/RDBMS 风格的工作负载,你需要专用的大容量固态硬盘,可能达到数个 TB。一旦请求存储,你将想要知道你的 Pod 运行在哪里,以便你可以在同一机架或同一节点上预留一个 SSD。由于磁盘本地性和调度可以显著影响这些工作负载的性能,你的 Postgres 存储类可能使用 WaitForFirstConsumer 策略。由于生产中的 Postgres 数据库通常具有重要的交易历史,你可能选择保留策略。

最后,对于 AI 工作负载,你的数据科学家可能不关心存储;他们只想处理数字,可能需要一个临时存储。你希望在开发人员和提供的存储类型之间设置间接层,这样你就可以在集群中不断更改 StorageClass 和卷类型,而不会影响像 YAML API 定义、Helm 图表或应用程序代码这样的东西。类似于冷存储场景,由于 AI 工作负载在将数据输出之前会将其大量吸入内存一段时间,数据本地性并不总是很重要。可以立即绑定以加快 Pod 启动,同样,删除策略可能也是合适的。

由于这些过程的复杂性,您可能需要为满足卷声明提供自定义逻辑。您可以简单地分别将这些卷类型命名为hdfscoldstorepg-perfai-slow

8.3 Kubernetes 存储用例

我们现在已经探讨了为存储建模您的最终用户用例的重要性。现在,让我们看看一些其他主题,这将让您对 Kubernetes 通常如何使用存储卷为 Secret 和网络功能进行基本管理有一个更广泛的感觉。

8.3.1 Secrets:临时共享文件

将文件作为共享方式来分发容器或虚拟机的凭证的设计模式相当常见。例如,cloud-init 语言,它用于在 AWS、Azure 和 vSphere 等云环境中引导虚拟机,有一个write_files指令,在 Kubernetes 环境之外也经常使用,如下所示:

# This is taken from https://cloudinit.readthedocs.io/en/latest/topics
# /examples.html#writing-out-arbitrary-files
write_files:
- encoding: b64
  content: CiMgVGhpcyBmaWxlIGNvbnRyb2xzIHRoZSBzdGF0ZSBvZiBTRUxpbnV4...
  owner: root:root
  path: /etc/sysconfig/selinux
  permissions: '0644'
- content: |
    # My new /etc/sysconfig/samba file
    SMBDOPTIONS="-D"
  path: /etc/sysconfig/samba

与系统管理员使用cloud-init等工具引导虚拟机的方式相同,Kubernetes 使用 API 服务器和 kubelet 以几乎相同的设计模式将 Secret 或文件引导到 Pod 中。如果您管理过需要访问任何类型数据库的云环境,您可能已经以三种方式中的某一种解决了这个问题:

  • 将凭证作为环境变量注入—这要求您对进程运行的上下文有一定的控制权。

  • 将凭证作为文件注入—这意味着可以使用不同的选项或参数上下文重新启动进程,而无需更新其密码环境变量。

  • 使用 Secret API 对象—这是一个 Kubernetes 结构,用于执行与我们在 ConfigMaps 中执行的基本相同类型的操作,但有一些小的注意事项将它们与 ConfigMaps 区分开来:

    • 我们可以使用不同类型的算法来加密和解密 Secret,但不能加密 ConfigMaps。

    • 我们可以使用 API 服务器在 etcd 中加密 Secret,但不能加密 ConfigMaps,这使得 Secret 更容易阅读或调试,但安全性较低。

    • 默认情况下,Secret 中的任何数据都是 Base64 编码的,而不是以纯文本形式存储。这是由于在 Secret 中存储证书或其他复杂数据类型的常见用例(以及显然的好处,即 Base64 编码的字符串难以阅读)。

随着时间的推移,预计供应商将提供针对 Kubernetes 中 Secret API 类型的复杂 Secret 轮换 API。尽管如此,在撰写本文时,Secret 和 ConfigMaps 在 Kubernetes 中如何使用它们方面在很大程度上是相同的。

Secret 看起来像什么?

Kubernetes 中的 Secret 看起来像这样:

apiVersion: v1
kind: Secret
metadata:
  name: mysecret
type: Opaque
data:
  val1: YXNkZgo=
  val2: YXNkZjIK
stringData:
  val1: asdf

在这个 Secret 中,我们有多个值:val1val2StringData 字段实际上以纯文本字符串的形式存储 val,易于阅读。一个常见的误解是 Kubernetes 中的 Secret 数据是通过 Base64 编码来加密的。这并不是事实,因为 Base64 编码根本不提供任何安全性!相反,Kubernetes 中 Secrets 的安全性取决于管理员如何定期审计和轮换 Secrets。无论如何,Kubernetes 中的 Secrets 是安全的,因为它们只被提供给 kubelet,以便将其挂载到有权通过 RBAC 读取它们的 Pods 中。val1 值可能稍后以如下方式挂载到一个 Pod 中:

apiVersion: v1
kind: Pod
metadata:
  name: mypod
spec:
  containers:
  - name: mypod
    image: my-webapp
    volumeMounts:
    - name: myval
      mountPath: "/etc/myval"
      readOnly: true
  volumes:
  - name: myval
    secret:
      secretName: val1

因此,当这个 Pod 运行时,asdf 的值将是 /etc/myval 文件的内容。这可以通过 kubelet 的智能按需创建一个专门为需要访问此 Secret 的容器而设计的特殊临时 tmpfs 卷来实现。当 Kubernetes API 中的 Secret 值发生变化时,kubelet 也可以处理更新此文件,因为它实际上只是一个存在于主机上的文件,通过文件系统命名空间的神奇之处进行共享。

创建一个简单的 Pod,其中包含 emptyDir 卷以实现快速写入访问。

一个典型的 emptyDir Pod 示例可能是一个需要将临时文件写入 /var/tmp 的应用程序。临时存储通常以如下方式挂载到 Pod 中:

  • 一个包含一个或多个文件的卷,这在具有配置数据(例如,用于应用程序的各种旋钮和开关)的 ConfigMap 中很常见。

  • 环境变量,这在 Secrets 中很常见。

如果你有一个使用文件作为不同容器之间锁或信号量的应用程序,或者你需要将一些临时配置注入到应用程序中(例如,通过 ConfigMap),由 kubelet 管理的本地存储卷就足够了。Secret 可以在底层使用 emptyDir 卷来挂载密码(例如,作为文件挂载到容器中)。同样,emptyDir 卷可以被两个 Pod 共享,这样你就可以在两个容器之间构建一个简单的工作或信号队列。

emptyDir 是实现起来最简单的存储类型。它不需要实际的卷实现,并且保证在任何集群上都能工作。为了具体说明,在一个 Redis 数据库中,由于长期持久性并不重要,你可能会将临时存储作为卷挂载,如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: redis
spec:
  containers:
  - name: redis
    image: redis
    volumeMounts:
    - name: redis-storage
      mountPath: /data/redis
  volumes:
  - name: redis-storage
    emptyDir: {}

为什么还要考虑 emptyDir?因为我们之前提到过,emptyDir 的性能可能比容器化目录快得多。记住,你的容器运行时写入文件的方式是通过写时复制的文件系统,这与磁盘上常规文件的写入路径不同。因此,对于在生产容器中需要高性能的文件夹,你可能会故意选择 emptyDir 或 hostPath 卷挂载。在某些容器运行时中,将写入主机文件系统与容器文件系统进行比较时,速度可以快十倍,这种情况并不少见。

8.4 动态存储提供者通常看起来像什么?

与 emptyDir 卷不同,存储提供者通常由供应商在 Kubernetes 之外实现。实现存储解决方案最终涉及实现 CSI 规范的供应步骤。例如,我们可以创建一个 NAS 存储提供者,它遍历一系列预定义的文件夹。在以下内容中,我们只支持六个卷,以便使代码易于阅读和具体。然而,在现实世界中,你可能需要一个更复杂的底层存储目录管理方式来处理卷供应者。例如:

var storageFolder1 = "/opt/NAS/1"                     ❶
var storageFolder2 = "/opt/NAS/2"
var storageFolder3 = "/opt/NAS/3"
var storageFolder4 = "/opt/NAS/4"
var storageFolder5 = "/opt/NAS/5"
var storageFolder6 = "/opt/NAS/6"
var storageFoldersUsed = 0

// Provision creates a storage asset, returning a PV object to represent it.
func (p *hostPathProvisioner) Provision
    (options controller.VolumeOptions) (*v1.PersistentVolume, error) {
    glog.Infof("Provisioning volume %v", options)
    path := path.Join(p.pvDir, options.PVName)

    // Implement our artificial constraint in the simplest way possible...
    if storageFoldersUsed == 0 {
        panic("Cant store anything else !")
    }
    if err := os.MkdirAll(path, 0777); err != nil {
        return nil, err
    }

    // Explicitly chmod created dir so we know that
    // mode is set to 0777 regardless of umask
    if err := os.Chmod(path, 0777); err != nil {
        return nil, err
    }

    // Example of how you might call to your NAS
    folders := []string{
            storageFolder1, storageFolder2, storageFolder3,
            storageFolder4, storageFolder5, storageFolder6
    }                                                 ❷

    // Now let's make the folder ...
    mycompany.ProvisionNewNasResourceToLocalFolder
                (folders[storageFoldersUsed++]);

    // This is taken straight from the minikubes controller, mostly...
    pv := &v1.PersistentVolume{
        ObjectMeta: metav1.ObjectMeta{
            Name: options.PVName,
            Annotations: map[string]string{
                // Change this
                "myCompanyStoragePathIdentity": string(p.identity),
            },
        },
        Spec: v1.PersistentVolumeSpec{                ❸
            PersistentVolumeReclaimPolicy:
              options.PersistentVolumeReclaimPolicy,
            AccessModes:         options.PVC.Spec.AccessModes,
            Capacity: v1.ResourceList{
                v1.ResourceName(v1.ResourceStorage):
                                 options.PVC.Spec.Resources.Requests[
                                     v1.ResourceName(v1.ResourceStorage...
            },
            PersistentVolumeSource: v1.PersistentVolumeSource{
                HostPath: &v1.HostPathVolumeSource{
                                                      ❹
                    Path: storageFolder,
                },
            },
        },
    }
    return pv, nil
}

❶ 支持六种不同的挂载方式

❷ 通过将这些挂载存储在数组中来轮询这些挂载

❸ 创建 PV YAML,类似于我们在其他地方所做的那样

❹ 在底层使用 hostPath,但我们将它挂载到我们的 NAS 目录中

为了澄清,这段代码只是一个假设示例,说明如何通过借鉴 minikube 中的 hostPath 提供者逻辑来编写自定义提供者。minikube 中存储控制器剩余的代码可以在 mng.bz/wn5P 找到。如果你对 PersistentVolumeClaims 或 StorageClasses 以及它们的工作方式感兴趣,你绝对应该阅读它,或者更好的是,自己尝试编译它!

8.5 用于系统控制和/或数据访问的 hostPath

Kubernetes 的 hostPath 卷类似于 Docker 卷,因为它们允许在 Pod 中运行的容器直接写入主机。这是一个强大的功能,但经常被微服务新手滥用,所以使用时要小心。hostPath 卷类型具有广泛的应用场景。这些通常分为两大类:

  • 实用功能—由只能通过访问主机文件资源实现的容器提供(我们将通过一个示例来展示这一点)。

  • 将主机用作持久文件存储—以这种方式,当 Pod 消失时,其数据会持久保存在一个可预测的位置。请注意,这几乎总是反模式,因为这意味着当 Pod 死亡并被重新调度到新节点时,应用程序的行为会有所不同。

8.5.1 hostPaths、CSI 和 CNI:一个典型用例

CNI 和 CSI,它们是 Kubernetes 网络和存储的骨干,都严重依赖于 hostPath 的使用。kubelet 本身在每个节点上运行并挂载和卸载存储卷,通过使用 CSI 驱动程序和主机上共享的 UNIX 域套接字进行这些调用,你猜对了,使用 hostPath 卷。还有一个第二个 UNIX 域套接字,节点驱动程序注册器使用它将 CSI 驱动程序注册到 kubelet。

如前所述,许多涉及应用程序的 hostPath 用例是反模式。然而,hostPath 的一个常见且关键用例是实现 CNI 插件。让我们接下来看看这一点。

一个 CNI hostPath 示例

作为 CNI 提供者对 hostPath 功能高度依赖的一个例子,让我们看看运行中的 Calico 节点中的卷挂载。Calico Pod 负责许多系统级操作,如操纵 XDP 规则、iptables 规则等。此外,这些 Pod 还需要确保 Linux 内核之间的 BGP 表正确同步。因此,正如你所看到的,有许多 hostPath 卷声明来访问各种主机目录。例如:

volumes:
  - hostPath:
      path: /run/xtables.lock
      type: FileOrCreate
    name: xtables-lock
  - hostPath:
      path: /opt/cni/bin
      type: ""
...

在 Linux 上,CNI 提供者通过在容器内部直接将它们自己的二进制文件写入节点本身,通常在/opt/cni/bin 目录下,来将自己安装到 kubelet 上。这是 hostPaths 最流行的用例之一——使用 Linux 容器在 Linux 节点上执行管理操作。许多具有管理性质的应用程序都使用此功能,包括

  • Prometheus,一个指标和监控解决方案,用于挂载/proc 和其他系统资源以检查资源使用情况

  • Logstash,一个日志集成解决方案,用于将各种日志目录挂载到容器中

  • 如前所述,将二进制文件自安装到/opt/cni/bin 的 CNI 提供者

  • 使用 hostPaths 挂载存储供应商特定工具的 CSI 提供者

Calico CNI 提供者是众多此类低级 Kubernetes 系统进程的例子之一,如果没有能够直接从主机挂载设备或目录到容器,这些进程将无法实现。实际上,其他 CNIs(如 Antrea 或 Flannel)以及 CSI 存储驱动程序也要求此功能以引导和管理工作站。

起初,这种自我安装可能有些反直觉,所以你可能想要花点时间思考一下。蒂莫西·圣克莱尔(Timothy St. Claire),Kubernetes 的早期维护者和贡献者,将这种行为称为“摸自己的肚脐眼”。然而,这正是 Kubernetes 设计在 Linux 上工作的核心。我们之所以说在 Linux 上,是因为在其他操作系统(如 Windows)中,这种级别的容器权限尚不可行。随着 Kubernetes 1.22 中 Windows HostProcess 容器的出现,我们可能开始看到这种范式在非 Linux 环境中扎根。因此,hostPath 卷不仅仅是用于启用容器化工作负载的功能,实际上,它是一个允许容器管理 Linux 服务器复杂方面的功能,而不仅仅是针对以开发者为中心的容器化应用程序。

你应该在什么时候使用 hostPath 卷?

在您的存储之旅中,请记住,您可以使用 hostPath 做各种事情,尽管它被认为是一种反模式,但它可以轻松地让您摆脱困境。hostPath 可以让您做诸如快速轻松的备份、满足合规性政策(节点被授权存储,但分布式卷不被授权)以及提供高性能存储而不依赖于深度云原生集成等事情。一般来说,在考虑如何为特定的后端实现存储时,请考虑以下因素:

  • 是否存在本地的 Kubernetes 卷提供者?如果有的话,这可能是最简单的方法,并且需要您端的最少自动化。

  • 如果没有,您的卷供应商是否提供 CSI 实现?如果是,您可以运行它,并且很可能会附带动态提供者。

如果这两种选择都不适用,您可以使用 hostPath 或 Flex 卷等工具将任何类型的存储配置为卷,以便在特定情况下将其绑定到任何 Pod。如果只有您的集群中某些主机可以访问此存储提供者,您可能需要向 Pod 添加调度信息,这也是为什么前述选择通常是理想的原因。

8.5.2 Cassandra:现实世界 Kubernetes 应用存储的示例

在 Kubernetes 上运行的持久化应用程序需要动态扩展,尽管仍然存在一些可预测的方式来访问带有关键数据卷的命名卷。让我们详细看看一个复杂的存储用例——Cassandra。

Cassandra Pods 通常由 StatefulSet 管理。StatefulSet 的概念是 Pod 在同一节点上持续重建。在这种情况下,我们不仅仅有一个卷定义,还有一个 VolumeClaimTemplate。这个模板为每个卷命名不同。

VolumeClaimTemplates 是 Kubernetes API 中的一种结构,它告诉 Kubernetes 如何为 StatefulSet 声明 PersistentVolumes。这样,它们可以根据 StatefulSet 的大小即时创建,由第一次安装此 StatefulSet 或正在扩展它的操作员完成。在这个代码片段中

volumeClaimTemplates:
  - metadata:
      name: cassandra-data

例如,Pod cassandra-1 将会有一个 volumeClaimTemplate cassandra-data-1. 该声明位于同一节点上,并且 StatefulSet 会不断重新调度到同一节点。

确保不要将 StatefulSet 与 DaemonSet 混淆。后者保证相同的 Pod 在集群的 所有 节点上运行。前者保证 Pod 将在相同的节点上重启,但并不暗示运行这些 Pod 的数量或它们将运行的位置。为了使这种区分更加清晰,DaemonSets 通常用于安全工具、网络或存储提供商等容器化应用。现在,让我们快速看一下 Cassandra 的 StatefulSet 及其 volumeClaimTemplate 的样子:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: cassandra
  labels:
    app: cassandra
spec:
  serviceName: cassandra
  replicas: 1
  selector:
    matchLabels:
    ...
        volumeMounts:
        - name: cassandra-data
          mountPath: /cassandra_data
  # These are converted to volume claims by the controller
  # and mounted at the paths mentioned in our discussion, and don't
  # use in production until ssd GCEPersistentDisk or other ssd pd
  volumeClaimTemplates:
  - metadata:
      name: cassandra-data
    spec:
      accessModes: [ "ReadWriteOnce" ]
      storageClassName: fast
      resources:
        requests:
          storage: 1Gi
---
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: fast
parameters:
  type: pd-ssd

从现在开始,每次你的 Cassandra Pod 在这个相同的节点上重启时,它都会访问同一个可预测命名的卷。因此,你可以轻松地向集群添加更多 Cassandra 副本,并保证第八个 Pod 总是在你的 Cassandra 集群中第八个节点上启动。如果没有这样的模板,每次你扩展 Cassandra 集群中 Pod 的数量时,你都必须手动创建唯一的存储 VolumeClaimTemplate 名称。请注意,如果 Pod 需要重新调度到另一个节点,并且存储可以挂载到另一个节点,Pod 的存储将移动,并且 Pod 将在那个节点上启动。

8.5.3 高级存储功能与 Kubernetes 存储模型

不幸的是,特定存储类型的所有原生功能永远无法在 Kubernetes 中完全表达。例如,不同类型的存储卷在底层存储选项方面可能有不同的读写语义。另一个例子是 快照 的概念。许多云供应商允许你备份、恢复或对磁盘进行快照。如果存储供应商支持快照并在他们的 CSI 规范中适当地实现了快照语义,那么你可以使用此功能。

截至 Kubernetes 1.17,快照和克隆(可以在 Kubernetes 中完全实现)已成为 Kubernetes API 中的新操作。例如,以下 PVC 被定义为源自数据源。这个数据源本身又是一个 VolumeSnapshot 对象,这意味着它是一个从特定时间点加载的特定卷:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: restore-pvc
spec:
  storageClassName: csi-hostpath-sc
  dataSource:
    name: new-snapshot-test
    kind: VolumeSnapshot
    apiGroup: snapshot.storage.k8s.io
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi

由于我们已经讨论了 CSI 规范的重要性,你可能已经猜到,将 Kubernetes 客户端连接到特定供应商的快照逻辑是完全不必要的。相反,为了支持此功能,存储供应商只需要实现一些 CSI API 调用,例如

  • CreateSnapshot

  • DeleteSnapshot

  • ListSnapshots

一旦实现这些,Kubernetes CSI 控制器可以通用地管理快照。如果你对你的生产数据卷感兴趣,请咨询你的特定 CSI 驱动程序或 Kubernetes 集群的存储提供商。确保它们实现了 CSI API 的快照组件。

8.6 进一步阅读

J. Eder。“云原生交易平台之路。” mng.bz/p2nE(访问日期:2021 年 12 月 24 日)。

Kubernetes 作者。“PV 控制器更改以支持 PV 删除保护终结者。” mng.bz/g46Z(访问日期:2021 年 12 月 24 日)。

Kubernetes 作者。“移除本地-up 的容器运行时 docker。” mng.bz/enaw(访问日期:2021 年 12 月 24 日)。

Kubernetes 文档。“创建静态 Pod。” mng.bz/g4eZ(访问日期:2021 年 12 月 24 日)。

Kubernetes 文档。“持久卷。” mng.bz/en9w(访问日期:2021 年 12 月 24 日)。

“PostgreSQL 数据库恢复:EOF 之后的意外数据。” mng.bz/aDQx(访问日期:2021 年 12 月 24 日)。

“共享存储。” wiki.postgresql.org/wiki/Shared_Storage(访问日期:2021 年 12 月 24 日)。

Z. Zhuang 和 C. Tran。“消除由后台 IO 流量引起的大 JVM GC 停顿。” mng.bz/5KJ4(访问日期:2021 年 12 月 24 日)。

摘要

  • 存储类(StorageClasses)与其他多租户概念类似,例如 Kubernetes 中的网关类(GatewayClasses)。

  • 管理员使用存储类(StorageClasses)来建模存储需求,以通用的方式适应常见的开发者场景。

  • Kubernetes 本身使用 emptyDir 和 hostPath 卷来完成日常活动。

  • 为了在 Pod 重启之间保持可预测的卷名,你可以使用 VolumeClaimTemplates,它为有状态集(StatefulSet)中的 Pod 创建命名卷。例如,在维护 Cassandra 集群时,这可以启用高性能或有状态的工作负载。

  • 卷快照和克隆是新兴的流行存储选项,可以使用新的 CSI 实现来实现。


¹ 数据持久化依赖于持久卷(PersistentVolume)的回收策略。

9 运行 Pods:kubelet 的工作原理

本章涵盖

  • 学习 kubelet 的功能和配置方式

  • 连接容器运行时和启动容器

  • 控制 Pod 的生命周期

  • 理解 CRI

  • 查看 kubelet 和 CRI 内部的 Go 接口

kubelet 是 Kubernetes 集群的功臣,在生产数据中心中可能有成千上万的 kubelet,因为每个节点都运行 kubelet。在本章中,我们将深入了解 kubelet 的内部工作原理以及它如何精确地使用 CRI(容器运行时接口)来运行容器并管理工作负载的生命周期。

kubelet 的一个任务是启动和停止容器,CRI 是 kubelet 用于与容器运行时交互的接口。例如,containerd 被归类为容器运行时,因为它接受镜像并创建运行中的容器。Docker 引擎 是一个容器运行时,但现在 Kubernetes 社区已经废弃它,转而使用 containerd、runC 或其他运行时。

注意:我们想感谢陈 Dawn 允许我们对她进行关于 kubelet 的采访。陈 Dawn 是 kubelet 二进制文件的原始作者,目前是 Kubernetes 节点特别兴趣小组的负责人之一。该小组维护 kubelet 代码库。

9.1 kubelet 和节点

从高层次来看,kubelet 是一个由 systemd 启动的二进制文件。kubelet 在每个节点上运行,是 Pod 调度器和节点代理,但仅限于本地节点。kubelet 监控并维护其运行的节点上的信息。根据节点的变化,二进制文件通过调用 API 服务器来更新节点对象。

让我们先看看节点对象,这是通过在运行中的集群上执行 kubectl get nodes <insert_node_name> -o yaml 命令获得的。接下来的几个代码块是 kubectl get nodes 命令生成的片段。你可以通过执行 kind create cluster 并运行 kubectl 命令来跟进。例如,kubectl get nodes -o yaml 生成以下输出,为了简洁起见已缩短:

kind: Node
metadata:
  annotations:
    kubeadm.alpha.kubernetes.io/cri-socket:
      /run/containerd/containerd.sock         ❶
    node.alpha.kubernetes.io/ttl: "0"
    volumes.kubernetes.io/controller-managed-attach-detach: "true"
  labels:
    beta.kubernetes.io/arch: amd64
    kubernetes.io/hostname: kind-control-plane
    node-role.kubernetes.io/master: ""
  name: kind-control-plane

❶ kubelet 使用此套接字与容器运行时通信。

在此代码中,节点对象中的元数据告诉我们它的容器运行时是什么以及它运行的 Linux 架构。kubelet 与 CNI 提供者交互。正如我们在其他章节中提到的,CNI 提供者的任务是为 Pods 分配 IP 地址并创建 Pod 的网络,这允许 Kubernetes 集群内部的网络通信。节点 API 对象包括所有 Pods 的 CIDR(IP 地址范围)。重要的是,我们还指定了节点本身的内部 IP 地址,这必然与 Pod 的 CIDR 不同。下一个源代码块显示了 kubectl get node 命令生成的部分 YAML:

spec:
  podCIDR: 10.244.0.0/24

现在我们来看定义中的 status 部分。所有 Kubernetes API 对象都有 specstatus 字段:

  • spec—定义对象的规范(你希望它成为什么)

  • status—表示对象当前状态

status部分是 kubelet 为集群维护的数据,它还包括一个条件列表,这些条件是发送到 API 服务器的心跳消息。节点启动时自动获取所有附加的系统信息。此状态信息发送到 Kubernetes API 服务器,并持续更新。以下代码块显示了kubectl get node产生的部分 YAML,显示了status字段:

status:
  addresses:
  - address: 172.17.0.2
    type: InternalIP
  - address: kind-control-plane
    type: Hostname

在 YAML 文档的下方,你会找到这个节点的allocatable字段。如果你可以探索这些字段,你会看到有关 CPU 和内存的信息:

allocatable:
  ...
  capacity:
    cpu: "12"
    ephemeral-storage: 982940092Ki
    hugepages-1Gi: "0"
    hugepages-2Mi: "0"
    memory: 32575684Ki
    pods: "110"

节点对象中还有其他可用的字段,所以我们鼓励你在检查节点时查看 YAML 文件。你可以有 0 到 15,000 个节点(15,000 个节点被认为是集群中节点的当前极限,因为端点和其他大量元数据密集型操作在规模扩大时变得成本高昂)。节点对象中的信息对于像调度 Pods 这样的任务至关重要。

9.2 核心 kubelet

我们知道 kubelet 是一个安装在每个节点上的二进制文件,我们也知道它是关键的。让我们深入了解 kubelet 的世界以及它所做的工作。没有容器运行时,节点和 kubelet 就失去了作用,它们依赖容器运行时来执行容器化进程。我们将接下来查看容器运行时。

9.2.1 容器运行时:标准和规范

镜像,它们是 tar 包,kubelet 需要定义良好的 API 来执行运行这些 tar 包的二进制文件。这就是标准 API 发挥作用的地方。两个规范,CRI 和 OCI,定义了 kubelet 运行容器目标中的如何什么

  • CRI 定义了如何实现。 这些是用于启动、停止和管理容器和镜像的远程调用。任何容器运行时都以某种方式作为远程服务实现此接口。

  • OCI 定义了什么。 这是容器镜像格式的标准。当你通过 CRI 实现启动或停止容器时,你依赖于该容器的镜像格式以某种方式标准化。OCI 定义了一个包含更多 tar 包和元数据文件的 tar 包。

如果可能的话,启动一个kind集群,这样你就可以与我们一起遍历这些示例。kubelet 的核心依赖项 CRI 必须作为启动参数提供给 kubelet 或以其他方式配置。例如,作为 containerd 配置的示例,你可以在运行的kind集群中查找/etc/containerd/config.toml,并观察各种配置输入,包括定义了 CNI 提供者的钩子。例如:

# explicitly use v2 config format
version = 2

# set default runtime handler to v2, which has a per-pod shim
[plugins."io.containerd.grpc.v1.cri".containerd]
  default_runtime_name = "runc"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
  runtime_type = "io.containerd.runc.v2"

# setup a runtime with the magic name ("test-handler") for k8s
# runtime class tests ...
[plugins."io.containerd.grpc.v1.cri"
    .containerd.runtimes.test-handler]
  runtime_type = "io.containerd.runc.v2"

在下一个示例中,我们使用 kind 创建一个 Kubernetes v1.20.2 集群。请注意,此输出可能因 Kubernetes 版本而异。要在 kind 集群中查看文件,请运行以下命令:

$ kind create cluster                             ❶

$ export \
KIND_CONTAINER=\
$(docker ps | grep kind | awk '{ print $1 }')     ❷

$ docker exec -it "$KIND_CONTAINER" /bin/bash     ❸

root@kind-control-plane:/# \
  cat /etc/containerd/config.toml                 ❹

❶ 创建一个 Kubernetes 集群

❷ 查找正在运行的 kind 容器的 Docker 容器 ID

❸ 执行到正在运行的容器中并获取交互式命令行

❹ 显示 containerd 配置文件

我们不会深入探讨容器实现细节。然而,您需要知道,kubelet 通常在底层依赖于底层运行时。它接受 CRI 提供程序、镜像注册表和运行时值作为输入,这意味着 kubelet 可以适应许多不同的容器化实现(VM 容器、gVisor 容器等)。如果您在 kind 容器内部运行的相同 shell 中,您可以执行以下命令:

root@kind-control-plane:/# ps axu | grep /usr/bin/kubelet
root         653 10.6  3.6 1881872 74020 ?
   Ssl  14:36   0:22 /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=unix:///run/containerd/containerd.sock
   --fail-swap-on=false --node-ip=172.18.0.2
   --provider-id=kind://docker/kind/kind-control-plane
   --fail-swap-on=false

这将打印出提供给 kind 容器内运行的 kubelet 的配置选项和命令行标志。接下来将介绍这些选项;然而,我们不会介绍所有选项,因为有很多。

9.2.2 kubelet 配置及其 API

kubelet 是 Linux 操作系统中各种原语的综合点。其中一些数据结构揭示了其演变的形式和功能。kubelet 在两个不同类别中拥有超过 100 个不同的命令行选项:

  • 选项—切换与 Kubernetes 一起使用的低级 Linux 功能的行为,例如与最大 iptables 使用或 DNS 配置相关的规则

  • 选择—定义 kubelet 二进制文件的生命周期和健康状态

kubelet 有许多边缘情况;例如,它如何处理 Docker 与 containerd 工作负载,如何管理 Linux 与 Windows 工作负载等。在定义其规范时,每个这些边缘情况可能需要几周甚至几个月的时间进行辩论。因此,了解 kubelet 代码库的结构是很好的,这样您就可以深入挖掘,并在遇到错误或意外行为时给自己一些安慰。

注意:Kubernetes v1.22 版本对 kubelet 引入了许多更改。其中一些更改包括删除树内存储提供程序、通过 --seccomp-default 标志引入新的安全默认值、依赖内存交换(称为 NodeSwap 功能)的能力以及内存 QoS 的改进。如果您想了解更多关于 Kubernetes v1.22 版本中所有改进的信息,我们强烈建议您阅读mng.bz/2jy0。与本章节相关的是,kubelet 中最近的一个错误可能导致静态 Pod 清单更改破坏长时间运行的 Pod。

kubelet.go 文件是 kubelet 二进制程序启动的主要入口点。cmd 文件夹包含 kubelet 标志的定义。(查看 mng.bz/REVK 了解标志、CLI 选项和定义。)以下声明了 kubeletFlags 结构。这个结构是用于 CLI 标志的,但我们也有 API 值:

// kubeletFlags contains configuration flags for the kubelet.
// A configuration field should go in the kubeletFlags instead of the
// kubeletConfiguration if any of these are true:
// - its value will never or cannot safely be changed during
//   the lifetime of a node, or
// - its value cannot be safely shared between nodes at the
//   same time (e.g., a hostname);
//   the kubeletConfiguration is intended to be shared between nodes.
// In general, please try to avoid adding flags or configuration fields,
// we already have a confusingly large amount of them.

type kubeletFlags struct {

之前,我们有一个代码块,其中我们使用 grep 搜索 /usr/bin/kubelet,结果的一部分包括 --config=/var/lib/kubelet/config.yaml--config 标志定义了一个配置文件。以下代码块使用 cat 查看该配置文件:

$ cat /var/lib/kubelet/config.yaml    ❶

❶ 输出 config.yaml 文件

以下代码块显示了 cat 命令的输出:

apiVersion: kubelet.config.k8s.io/v1beta1
authentication:
  anonymous:
    enabled: false
  webhook:
    cacheTTL: 0s
    enabled: true
  x509:
    clientCAFile: /etc/kubernetes/pki/ca.crt
authorization:
  mode: Webhook
  webhook:
    cacheAuthorizedTTL: 0s
    cacheUnauthorizedTTL: 0s
clusterDNS:
- 10.96.0.10
clusterDomain: cluster.local
cpuManagerReconcilePeriod: 0s
evictionHard:
  imagefs.available: 0%
  nodefs.available: 0%
  nodefs.inodesFree: 0%
evictionPressureTransitionPeriod: 0s
fileCheckFrequency: 0s
healthzBindAddress: 127.0.0.1
healthzPort: 10248
httpCheckFrequency: 0s
imageGCHighThresholdPercent: 100
imageMinimumGCAge: 0s
kind: kubeletConfiguration
logging: {}
nodeStatusReportFrequency: 0s
nodeStatusUpdateFrequency: 0s
rotateCertificates: true
runtimeRequestTimeout: 0s
staticPodPath: /etc/kubernetes/manifests
streamingConnectionIdleTimeout: 0s
syncFrequency: 0s
volumeStatsAggPeriod: 0s

所有 kubelet API 值都在 mng.bz/wnJP 的 types.go 文件中定义。此文件是一个 API 数据结构,包含 kubelet 的输入配置数据。它定义了通过 mng.bz/J1YV 引用的 kubelet 的许多可配置方面。

注意:尽管我们在 URL 中引用了 Kubernetes 版本 1.20.2,但在你阅读此信息时,请注意,尽管代码位置可能不同,但 API 对象的变化相当缓慢。

Kubernetes API 机制 是定义 Kubernetes 中 API 对象的机制或标准,以及 Kubernetes 源代码库。

你会在 types.go 文件中注意到,许多低级网络和进程控制功能直接发送到 kubelet 作为输入。以下示例显示了 ClusterDNS 配置,你可能能够与之相关联。这对于一个正常工作的 Kubernetes 集群非常重要:

// ClusterDNS is a list of IP addresses for a cluster DNS server. If set,
// the kubelet will configure all containers to use this for DNS resolution
// instead of the host's DNS servers.

ClusterDNS []string

当创建 Pod 时,还会动态生成多个文件。其中之一是 /etc/resolv.conf。它被 Linux 网络堆栈用于执行 DNS 查询,因为该文件定义了 DNS 服务器。我们将看到如何创建此文件。

9.3 创建 Pod 并观察其运行

运行以下命令以在 Kubernetes 集群上创建一个运行中的 NGINX Pod。然后,从命令行,你可以使用 cat 命令查看下一个代码块中的文件:

$ kubectl run nginx --generator=run-pod/v1 \
  --image nginx                                ❶

$ kubectl exec -it nginx -- /bin/bash          ❷
root@nginx:/# cat /etc/resolv.conf             ❸
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5

❶ 启动 Pod

❷ 执行到运行中的 NGINX 容器的 shell 中

❸ 使用 cat 检查 resolv.conf 文件。

现在,你可以看到 kubelet 在创建 Pod(如前一个部分所示)时创建和挂载 resolv.conf 文件。现在你的 Pod 可以执行 DNS 查询,如果你愿意,你可以 ping google.com。types.go 文件中的其他有趣的结构包括

  • ImageMinimumGCAge(用于图像垃圾收集)——在长时间运行的系统中,图像可能会随着时间的推移填满磁盘空间。

  • kubeletCgroups(用于 Pod cgroup 根和驱动程序)——Pod 资源的最终上游池可以是 systemd,这个结构统一了所有进程的管理以及容器的管理。

  • EvictionHard(用于硬限制)——这个结构指定了何时应该删除 Pod,这基于系统负载。

  • EvictionSoft(用于软限制)——这个结构体指定了 kubelet 在驱逐贪婪 Pod 之前等待多长时间。

这些只是 types.go 文件选项中的一小部分;kubelet 有数百种排列组合。所有这些值都是通过命令行选项、默认值或 YAML 配置文件设置的。

9.3.1 启动 kubelet 二进制文件

当一个节点启动时,会发生几个事件,最终导致它作为一个 Kubernetes 集群中的调度目标可用。请注意,由于 kubelet 代码库的变化和 Kubernetes 的一般异步性,事件的顺序是近似的。图 9.1 显示了 kubelet 的启动状态。观察图中的步骤,我们注意到

  • 进行一些简单的合理性检查以确保 Pod(容器)可以被 kubelet 运行。(NodeAllocatable 输入被检查,这定义了分配了多少 CPU 和内存。)

  • containerManager 例程开始。这是 kubelet 的主要事件循环。

  • 添加了一个 cgroup。如果需要,它将通过 setupNode 函数创建。调度器和 ControllerManager 都“注意到”系统中有一个新的节点。它们通过 API 服务器“监视”它,以便它可以运行需要家园(它甚至可以运行新的 Pod)的过程,并确保它不会跳过来自 API 服务器的周期性心跳。如果 kubelet 跳过心跳,节点最终会被 ControllerManager 从集群中移除。

  • deviceManager 事件循环开始。这会将外部插件设备引入 kubelet。然后,这些设备作为连续更新的一部分(在上一步骤中提到)被发送。

  • 将日志记录、CSI 和设备插件功能附加到 kubelet 并注册。

图片

图 9.1 kubelet 启动周期

9.3.2 启动后:节点生命周期

在 Kubernetes 的早期版本(1.17 之前),节点对象通过 kubelet 调用 API 服务器的方式,每 10 秒更新一次状态循环。按照设计,kubelet 与 API 服务器有点健谈,因为集群中的控制平面需要知道节点是否健康。如果你观察一个集群的启动过程,你会注意到 kubelet 二进制文件正在尝试与控制平面通信,并且它将多次这样做,直到控制平面可用。这个控制循环允许控制平面不可用,而节点知道这一点。当 kubelet 二进制文件启动时,它还配置了网络层,让 CNI 提供商创建适当的网络功能,例如为 CNI 网络功能创建一个网桥。

9.3.3 etcd 中的租赁和锁定以及节点租赁的演变

为了优化大型集群的性能并减少网络嘈杂,Kubernetes 1.17 及以后的版本实现了一个特定的 API 服务器端点,用于通过 etcd 的租赁机制管理 kubelet。etcd 引入了租赁的概念,以便可能需要故障转移的 HA(高可用性)组件可以依赖于中央租赁和锁定机制,而不是实现自己的。

任何上过计算机科学课程关于信号量的学生都能理解为什么 Kubernetes 的创造者不希望依赖于为不同组件定制的众多自研锁定实现。两个独立的控制循环维护 kubelet 的状态:

  • kubelet 每 5 分钟更新一次 NodeStatus 对象,以告诉 API 服务器其状态。例如,如果您在升级内存后重新启动节点,您将在 5 分钟后在 API 服务器查看 kubelet 的 NodeStatus 对象中看到此更新。如果您想知道这个数据结构有多大,请在大型生产集群上运行kubectl get nodes -o yaml。您可能会看到成千上万的文本行,每节点至少 10 KB。

  • 独立地,kubelet 每 10 秒更新一次 Lease 对象(非常小)。这些更新允许 Kubernetes 控制平面中的控制器在几秒钟内驱逐似乎已离线的节点,而无需承担发送大量状态信息的高成本。

9.3.4 kubelet 对 Pod 生命周期的管理

在所有预检检查完成后,kubelet 启动一个大的同步循环:containerManager例程。此例程处理 Pod 的生命周期,它由一系列动作的控制循环组成。图 9.2 显示了 Pod 的生命周期和管理 Pod 的步骤:

  1. 启动 Pod 生命周期

  2. 确保 Pod 可以在节点上运行

  3. 设置存储和网络(CNI)

  4. 通过 CRI 启动容器

  5. 监控 Pod

  6. 执行重启

  7. 停止 Pod

图 9.2 一个 kubelet 的 Pod 生命周期

图 9.3 说明了托管在 Kubernetes 节点上的容器的生命周期。如图所示

  1. 用户或副本集控制器决定通过 Kubernetes API 创建 Pod。

  2. 调度器找到 Pod 的正确归宿(例如,IP 地址为 1.2.3.4 的主机)。

  3. 主机 1.2.3.4 上的 kubelet 从其监视 API 服务器 Pod 的数据中获取新数据,并注意到它尚未运行 Pod。

  4. Pod 的创建过程开始。

  5. 暂停容器有一个沙盒,其中请求的一个或多个容器将驻留,定义了 kubelet 和 CNI(容器网络接口)提供者为它创建的 Linux 命名空间和 IP 地址。

  6. kubelet 与容器运行时通信,拉取容器的层,并运行实际镜像。

  7. NGINX 容器启动。

图 9.3 Pod 创建

如果出现错误,例如容器死亡或健康检查失败,Pod 本身可能会被移动到新的节点。这被称为 重新调度。我们提到了暂停容器,这是一个用于创建 Pod 共享 Linux 命名空间的容器。我们将在本章后面介绍暂停容器。

9.3.5 CRI、容器和镜像:它们是如何相关的

kubelet 的工作之一是镜像管理。如果你曾经在你的笔记本电脑上运行过 docker rm -a -qdocker images --prune,你可能熟悉这个过程。尽管 kubelet 只关心运行容器,但这些容器要启动,最终依赖于 基础镜像。这些镜像是从镜像仓库中拉取的。Docker Hub 是这样的一个仓库。

在现有镜像之上创建一个新的层来创建容器。常用的镜像使用相同的层,这些层由运行在 kubelet 上的容器运行时进行缓存。缓存时间基于 kubelet 自身的垃圾收集功能。这个功能会过期并删除从不断增长的注册缓存中删除旧镜像,这最终是 kubelet 的职责来维护。这个过程优化了容器的启动,同时防止磁盘被不再使用的镜像淹没。

9.3.6 kubelet 不运行容器:这是 CRI 的职责

容器运行时提供了与从 kubelet 运行所需容器相关的功能。记住,kubelet 本身不能独立运行容器:它依赖于底层的 containerd 或 runC。这种依赖通过 CRI 接口进行管理。

很可能,无论你运行的是哪个版本的 Kubernetes,你都安装了 runC。你可以使用 runC 高效地手动运行任何镜像。例如,运行 docker ps 来列出本地正在运行的容器。你也可以将镜像导出为 tarball。在我们的例子中,我们可以做以下操作:

$  docker ps                                     ❶
d32b87038ece kindest/node:v1.15.3
"/usr/local/bin/entr..." kind-control-plane
$ docker export d32b > /tmp/whoneedsdocker.tar   ❷
$ mkdir /tmp/whoneedsdocker
$ cd /tmp/whoneedsdocker
$ tar xf /tmp/whoneedsdocker.tar                 ❸
$ runc spec                                      ❹

❶ 获取镜像 ID

❷ 将镜像导出为 tarball

❸ 解压 tarball

❹ 启动 runC

这些命令创建一个 config.json 文件。例如:

{
        "ociVersion": "1.0.1-dev",
        "process": {
                "terminal": true,
                "user": {
                        "uid": 0,
                        "gid": 0
                },
                "args": [
                  "sh"
                ]
          },
          "namespaces": [
              {
                "type": "pid"
              },
              {
                "type": "network"
              },
              {
                "type": "ipc"
              },
              {
                "type": "uts"
              },
              {
                "type": "mount"
              }
          ]
}

通常,你将想要修改 args 部分 sh,这是 runC 创建的默认命令,以执行一些有意义的事情(例如 python mycontainerizedapp.py)。我们从先前的 config.json 文件中省略了大部分样板代码,但保留了一个关键部分:namespaces 部分。

9.3.7 暂停容器:一个“啊哈”时刻

Pod 中的每个容器都对应于一个 runC 操作。因此,我们需要一个暂停容器,它位于所有容器之前。一个暂停容器

  • 等待直到网络命名空间可用,这样 Pod 中的所有容器都可以共享单个 IP 并通过 127.0.0.1 进行通信

  • 暂停,直到文件系统可用,这样 Pod 中的所有容器都可以通过 emptyDir 共享数据

一旦 Pod 设置完成,每次 runC 调用都使用相同的命名空间参数。尽管 kubelet 不运行容器,但在创建 Pod 的过程中有很多逻辑,这是 kubelet 需要管理的。kubelet 确保 Kubernetes 为容器提供网络和存储保证,这使得在分布式场景下运行变得容易。在运行容器之前,还有其他任务,例如拉取镜像,我们将在本章后面详细介绍。首先,我们需要备份并查看 CRI,以便我们能够更清楚地了解容器运行时和 kubelet 之间的边界。

9.4 容器运行时接口 (CRI)

当涉及到 Kubernetes 在大规模下运行容器时,runC 程序只是谜团中的一小部分。整个谜团主要是由 CRI 接口定义的,该接口抽象了 runC 以及其他功能,以实现高级调度、镜像管理和容器运行时功能。

9.4.1 告诉 Kubernetes 我们的容器运行时所在位置

我们如何告诉 Kubernetes 我们的 CRI 服务在哪里运行?如果您查看一个正在运行的 kind 集群,您将看到 kubelet 以以下两个选项运行:

--container-runtime=remote
--container-runtime-endpoint=/run/containerd/containerd.sock

kubelet 通过 gRPC(一个远程过程调用框架)与容器运行时端点进行通信;containerd 本身内置了一个 CRI 插件。这里的“远程”意味着 Kubernetes 可以使用 containerd 套接字作为创建和管理 Pod 及其生命周期的最小接口实现。CRI 是任何容器运行时都可以实现的最低接口。它主要是为了使社区能够快速创新不同的容器运行时(除了 Docker 之外)并将它们插入到 Kubernetes 中并从 Kubernetes 中拔出。

注意:尽管 Kubernetes 在运行容器方面是模块化的,但它仍然是状态化的。您不能在不排空(并可能删除)活动集群中的一个节点的情况下“热插拔”容器运行时从运行中的 Kubernetes 集群中拔出。这种限制是由于 kubelet 管理和创建的元数据和 cgroups 引起的。

由于 CRI 是一个 gRPC 接口,因此 Kubernetes 中的 container-runtime 选项在理想情况下应该定义为 remote,对于较新的 Kubernetes 发行版。CRI 通过一个接口描述了所有容器创建过程,并且像存储和网络一样,Kubernetes 旨在随着时间的推移将容器运行时逻辑从 Kubernetes 核心中移出。

9.4.2 CRI 程序

CRI 由四个高级 go 接口组成。这统一了 Kubernetes 运行容器所需的所有核心功能。CRI 的接口包括

  • PodSandBoxManager—为 Pods 创建设置环境

  • ContainerRuntime—启动、停止和执行容器

  • ImageService—拉取、列出和删除镜像

  • ContainerMetricsGetter—提供关于运行中容器的定量信息

这些接口提供了暂停、拉取和沙箱功能。Kubernetes 期望任何远程 CRI 实现这些功能,并使用 gRPC 调用这些功能。

9.4.3 节点管理器围绕 CRI 的抽象:GenericRuntimeManager

CRI 的功能并不一定涵盖生产容器编排工具的所有基础,例如回收旧镜像、管理容器日志以及处理镜像拉取和镜像拉取回退的生命周期。节点管理器提供了一个 Runtime 接口,由kuberuntime.NewKubeGenericRuntimeManager实现,作为任何 CRI 提供者(containerd、CRI-O、Docker 等)的包装器。运行时管理器(位于mng.bz/lxaM)管理对所有四个核心 CRI 接口的所有调用。以下是一个例子,看看当我们创建一个新的 Pod 时会发生什么:

imageRef, msg, err := m.imagePuller.EnsureImageExists(
        pod, container, pullSecrets,
        podSandboxConfig)                                    ❶
        containerID, err := m.runtimeService.CreateContainer(
        podSandboxID, containerConfig,
        podSandboxConfig)                                    ❷
        err = m.internalLifecycle.PreStartContainer(
        pod, container, containerID)                         ❸
        err = m.runtimeService.StartContainer(
        containerID)                                         ❹
        events.StartedContainer, fmt.Sprintf(
        "Started container %s", container.Name))

❶ 拉取镜像

❷ 在不启动容器的情况下创建容器的 cgroups

❸ 执行网络或设备配置,这取决于 cgroup 或命名空间

❹ 启动容器

你可能会想知道为什么在这段代码中需要一个预启动钩子。Kubernetes 使用预启动钩子的几个常见例子包括某些网络插件和 GPU 驱动程序,这些在 GPU 进程启动之前需要使用 cgroup 特定的信息进行配置。

9.4.4 CRI 是如何被调用的?

上一段代码片段中有一行或多行代码模糊了远程调用 CRI,我们删除了很多冗余。我们将在稍后详细解释EnsureImageExists函数,但首先让我们看看 Kubernetes 如何将低级 CRI 功能抽象为两个主要的 API,这两个 API 是节点管理器内部用来与容器一起工作的。

9.5 节点管理器的接口

在节点管理器的源代码中,定义了各种 Go 接口。接下来的几节将按顺序介绍这些接口,以提供对节点管理器内部工作原理的概述。

9.5.1 Runtime 内部接口

Kubernetes 中的 CRI 被分为三个部分:Runtime、StreamingRuntime 和 CommandRunner。KubeGenericRuntime 接口(位于mng.bz/ BMxg)在 Kubernetes 内部管理,它封装了 CRI 运行时的核心功能。例如:

type KubeGenericRuntime interface {

    kubecontainer.Runtime               ❶
    kubecontainer.StreamingRuntime      ❷
    kubecontainer.CommandRunner         ❸

}

❶ 定义由 CRI 提供者指定的接口

❷ 定义处理流式调用(如 exec/attach/port-forward)的函数

❸ 定义一个在容器中执行命令并返回输出的函数

对于供应商来说,这意味着您首先实现 Runtime 接口,然后是 StreamingRuntime 接口,因为 Runtime 接口描述了 Kubernetes 的大部分核心功能(参见mng.bz/1jXj和[http://mng.bz/PWdn])。gRPC 服务客户端是让您了解 kubelet 如何与 CRI 交互的函数。这些函数定义在kubeGenericRuntimeManager结构体中。具体来说,runtimeService internalapi.RuntimeService与 CRI 提供者交互。

在 RuntimeService 中,我们有 ContainerManager,这就是魔法发生的地方。这个接口是实际 CRI 定义的一部分。下一段代码中的函数调用允许 kubelet 使用 CRI 提供者来启动、停止和移除容器:

// ContainerManager contains methods to manipulate containers managed
// by a container runtime. The methods are thread-safe.

type ContainerManager interface {
    // CreateContainer creates a new container in specified PodSandbox.
    CreateContainer(podSandboxID string, config
       *runtimeapi.ContainerConfig, sandboxConfig
        *runtimeapi.PodSandboxConfig) (string, error)
    // StartContainer starts the container.
    StartContainer(containerID string) error
    // StopContainer stops a running container.
    StopContainer(containerID string, timeout int64) error
    // RemoveContainer removes the container.
    RemoveContainer(containerID string) error
    // ListContainers lists all containers by filters.
    ListContainers(filter *runtimeapi.ContainerFilter)
       ([]*runtimeapi.Container, error)
    // ContainerStatus returns the status of the container.
    ContainerStatus(containerID string)
       (*runtimeapi.ContainerStatus, error)
    // UpdateContainerResources updates the cgroup resources
    // for the container.
    UpdateContainerResources(
       containerID string, resources *runtimeapi.LinuxContainerResources)
         error
    // ExecSync executes a command in the container.
    // If the command exits with a nonzero exit code, an error is returned.
    ExecSync(containerID string, cmd []string, timeout time.Duration)
            (stdout []byte, stderr []byte, err error)
    // Exec prepares a streaming endpoint to exe..., returning the address.
    Exec(*runtimeapi.ExecRequest) (*runtimeapi.ExecResponse, error)
    // Attach prepares a streaming endpoint to attach to
    // a running container and returns the address.
    Attach(req *runtimeapi.AttachRequest)
          (*runtimeapi.AttachResponse, error)
    // ReopenContainerLog asks runtime to reopen
    // the stdout/stderr log file for the container.
    // If it returns error, the new container log file MUST NOT
    // be created.
    ReopenContainerLog(ContainerID string) error
}

9.5.2 kubelet 如何拉取镜像:ImageService 接口

在容器运行时的常规操作中隐藏着 ImageService 接口,它定义了一些核心方法:PullImageGetImageListImagesRemoveImage。拉取镜像的概念,源自 Docker 语义,是 CRI 规范的一部分。您可以在与其它接口相同的文件(runtime.go)中看到其定义。因此,每个容器运行时都实现了这些功能:

// ImageService interfaces allows to work with image service.
type ImageService interface {
    PullImage(image ImageSpec, pullSecrets []v1.Secret,
                  podSandboxConfig *runtimeapi.PodSandboxConfig)
                  (string, error)
    GetImageRef(image ImageSpec) (string, error)
    // Gets all images currently on the machine.
    ListImages() ([]Image, error)
    // Removes the specified image.
    RemoveImage(image ImageSpec) error
    // Returns the image statistics.
    ImageStats() (*ImageStats, error)
}

容器运行时可以调用docker pull来拉取镜像。同样,此运行时可以调用执行docker run来创建容器。如您所记得的,容器运行时可以在 kubelet 启动时设置,使用container-runtime-endpoint标志,如下所示:

--container-runtime-endpoint=unix:///var/run/crio/crio.sock

9.5.3 向 kubelet 提供 ImagePullSecrets

让我们具体了解kubectl、kubelet 和 CRI 接口之间的联系。为了做到这一点,我们将查看如何向 kubelet 提供信息,以便它可以安全地从私有仓库下载镜像。以下是一段 YAML 代码块,它定义了一个 Pod 和一个 Secret。Pod 引用了一个需要登录凭证的安全仓库,而 Secret 存储了登录凭证:

apiVersion: v1
kind: Pod
metadata:
  name: myapp-pod
  labels:
    app: myapp
spec:
  containers:
  - name: myapp-container
    image: my.secure.registry/container1:1.0
  imagePullSecrets:
  - name: my-secret
---
apiVersion: v1
data:
  .dockerconfigjson: sojosaidjfwoeij2f0ei8f...
kind: Secret
metadata:
  creationTimestamp: null
  name: my-secret
  selfLink: /api/v1/namespaces/default/secrets/my-secret
type: kubernetes.io/.dockerconfigjson

在代码片段中,您需要自己生成.dockerconfigjson值。您也可以使用kubectl本身交互式地生成 Secret,如下所示:

$ kubectl create secret docker-registry my-secret
  --docker-server my.secure.registry
  --docker-username my-name --docker-password 1234
  --docker-email jay@apache.org

或者,如果您已经有一个现有的 Docker 配置 JSON 文件,您也可以使用等效的命令:

$ kubectl create secret generic regcred
   --from-file=.dockerconfigjson=<path/to/.docker/config.json>
   --type=kubernetes.io/dockerconfigjson

此命令创建整个 Docker 配置,将其放入.dockerconfigjson 文件中,然后使用该 JSON 有效载荷通过 ImageService 拉取镜像。更重要的是,此服务最终会调用EnsureImageExists函数。然后您可以通过运行kubectl get secret -o yaml来查看 Secret 并复制整个 Secret 值。然后使用 Base64 对其进行解码以查看 kubelet 使用的 Docker 登录令牌。

现在您已经了解了 Docker 守护进程在拉取镜像时如何使用 Secret,我们将回到 Kubernetes 的管道部分,它允许此功能完全通过 Kubernetes 管理的 Secrets 来工作。这一切的关键是 ImageManager 接口,它通过 EnsureImageExists 方法实现此功能。如果需要,此方法会调用 PullImage 函数,具体取决于您 Pod 上定义的 ImagePullPolicy。下一个代码片段发送所需的拉取 Secrets:

type ImageManager interface {
    EnsureImageExists(pod *v1.Pod, container *v1.Container,
      pullSecrets []v1.Secret,
      podSandboxConfig *runtimeapi.PodSandboxConfig)
     (string, string, error)
}

EnsureImageExists 函数接收您在本章前面 YAML 文档中创建的拉取 Secrets。然后通过反序列化 dockerconfigjson 值安全地执行 docker pull。一旦守护进程拉取了此镜像,Kubernetes 就可以继续前进,启动 Pod。

9.6 进一步阅读

M. Crosby. “什么是 containerd?” Docker 博客。 mng.bz/Nxq2 (accessed 12/27/21).

J. Jackson. “GitOps: ‘Git Push’ All the Things.” mng.bz/6Z5G (accessed 12/27/21).

“copy-on-write 在 fork() 中如何处理多个 fork?” Stack Exchange 文档。 mng.bz/Exql (accessed 12/27/21).

“深入探讨 Docker 存储驱动。” YouTube 视频。 www.youtube.com/watch?v=9oh_M11-foU (accessed 12/27/21).

摘要

  • kubelet 在每个节点上运行并控制该节点上 Pods 的生命周期。

  • kubelet 与容器运行时交互以启动、停止、创建和删除容器。

  • 我们可以在 kubelet 中配置各种功能(例如驱逐 Pods 的时间)。

  • 当 kubelet 启动时,它会在节点上运行各种合理性检查,创建 cgroups,并启动各种插件,如 CSI。

  • kubelet 控制 Pod 的生命周期:启动 Pod、确保其运行、创建存储和网络、监控、执行重启以及停止 Pods。

  • CRI 定义了 kubelet 与已安装的容器运行时交互的方式。

  • kubelet 是由各种 Go 接口构建的。这些接口包括 CRI、镜像拉取以及 kubelet 本身。

Kubernetes 中的 10 个 DNS

本章涵盖

  • 审查 Kubernetes 集群中的 DNS

  • 探索分层 DNS

  • 检查 Pod 中的默认 DNS

  • 配置 CoreDNS

DNS 自从互联网存在以来就存在了。微服务使得在规模上管理 DNS 记录变得困难,因为它们需要在内部数据中心上使用大量的域名。Kubernetes 关于 Pod 的 DNS 标准使得 DNS 非常容易,以至于单个应用程序很少需要遵循复杂的指南来查找下游服务。这通常是通过 CoreDNS (github.com/coredns/coredns) 实现的,它是本章的核心。

10.1 DNS(以及 CoreDNS)简介

任何 DNS 服务器的任务是将 DNS 名称(如 www.google.com)映射到 IP 地址(如 142.250.72.4)。在我们每天浏览网页时,DNS 服务器有一些常见的映射。让我们看看其中的一些。

10.1.1 NXDOMAINs、A 记录和 CNAME 记录

当使用 Kubernetes 时,DNS 主要由系统处理,至少在集群中是这样。然而,我们仍然需要定义一些术语来使本章内容具体化,尤其是在你可能关心自定义 DNS 行为的情况下(例如,在本章中看到的无头服务)。至于我们的定义,至少你想要了解

  • NXDOMAIN 响应—当域名不存在 IP 地址时返回的 DNS 响应

  • A 和 AAAA 映射—接受一个主机名作为输入并返回一个 IPv4 或 IPv6 地址(例如,它们接受 google.com 作为输入并返回 142.250.72.4)

  • CNAME 映射—为特定的 DNS 名称返回一个别名(例如,它们将 www.google.com 映射为 google.com

在自建环境中,CNAME 对于 API 客户端和其他依赖服务的向后兼容性至关重要。以下代码片段展示了 A 名称和 CNAME 记录如何交织在一起。这些记录存在于所谓的 区域文件 中。区域文件类似于这样一个长 CSV 文件中的记录(当然,没有逗号):

my.very.old.website CNAME my.new.site.
my.old.website. CNAME my.new.site.
my.new.site. A 192.168.10.123

如果这让你想起了 /etc/hosts,那么你是正确的。Linux 系统的 /etc/hosts 文件只是一个本地 DNS 配置,在计算机连接到互联网以查找可能匹配你在浏览器中输入的 DNS 名称的其他主机之前进行检查,并且 ANAME 和 CNAME 记录由 DNS 服务器提供。甚至在 Kubernetes 之前,就有许多不同的 DNS 服务器实现:

  • 其中一些是递归的;换句话说,它们可以从 DNS 记录的根(如 .edu 或 .com)开始解析互联网上的几乎所有内容。BIND 是这种在 Linux 数据中心中常用的一种服务器。

  • 其中一些是基于云和云集成的(例如,AWS 中的 Route53)并且不由最终用户托管。

  • 在大多数现代安装中,Kubernetes 通常使用 CoreDNS 为 Pod 提供集群内的 DNS 服务。

  • Kubernetes 一致性测试套件实际上确认了某些 DNS 特性是存在的,包括

    • Pod 中的/etc/hosts集群条目,这样它们可以通过内部主机名 kubernetes.default 自动访问 API 服务器

    • 允许注入自己的 DNS 记录的 Pod

    • 必须解析到 A 记录的任意服务和无头服务

    • 拥有自己的 DNS 记录的 Pod

在 Kubernetes 中使用 CoreDNS 来实现这种行为并非必需,但它确实使事情变得更容易。真正重要的是,你的 Kubernetes 发行版遵循 Kubernetes 的 DNS 规范。无论如何,CoreDNS 很可能是你在集群中使用的,而且有充分的理由。它是唯一广泛可用的具有内置 Kubernetes 支持的开放源代码 DNS 服务。它能够

  • 连接到 Kubernetes API 服务器,并在需要时获取 Pod 和服务的 IP 地址。

  • 将 DNS 服务记录解析为 Pod 和集群内服务的 IP 地址。

  • 缓存 DNS 条目,以便大型 Kubernetes 集群,其中数百个 Pod 需要解析服务,可以以高性能的方式工作。

  • 在编译时(而不是运行时)插入新功能。

  • 在高负载环境中也能以极低延迟进行水平扩展和性能表现。

  • 通过coredns.io/plugins/forward/插件将请求转发到外部集群地址的其他上游解析器。

虽然 CoreDNS 可以处理很多事情,但它不会将外部集群地址的请求转发到其他提供递归 DNS 功能的上游服务器。CoreDNS 允许你解析集群网络中服务的 IP 地址以及 Pod 的 IP 地址,在某些情况下(我们将在稍后看到)。

图 10.1 描述了 CoreDNS 与其他 DNS 服务器(如 BIND)之间的关系。任何 DNS 服务器都必须实现解析互联网主机的基线功能。CoreDNS 是在 Kubernetes 之后构建的,因此它也明确支持 Kubernetes DNS。

图 10.1 CoreDNS 与其他 DNS 服务器(如 BIND)之间的关系

10.1.2 Pods 需要内部 DNS

因为在微服务环境中,每个 Pod 通常通过服务访问,Pod 可以来去(这意味着它们有变化的 IP),DNS 是访问任何服务的主要方式。这在互联网和云中都是如此。那些有人给你一个 IP 地址,指向特定服务器或数据库的日子已经过去了。让我们通过启动一个多容器服务并对其进行探测来看看 Pod 如何在集群中通过 DNS 相互通信:

apiVersion: v1
kind: Service
metadata:
  name: nginx4
  labels:
    app: four-of-us
spec:
  ports:
  - port: 80                 ❶
    name: web
  clusterIP: None
  selector:
    app: four-of-us
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: web-ss
spec:
  serviceName: "nginx"
  replicas: 2
  selector:
    matchLabels:
      app: four-of-us
  template:
    metadata:
      labels:
        app: four-of-us
    spec:
      containers:
      - name: nginx
        image: nginx:1.7     ❷
        ports:
        - containerPort: 80
          name: web
---
apiVersion: apps/v1
kind: Deployment             ❸
metadata:
  name: web-dep
spec:
  replicas: 2
  selector:
    matchLabels:
      app: four-of-us
  template:
    metadata:
      labels:
        app: four-of-us
    spec:
      containers:
      - name: nginx
        image: nginx:1.7
        ports:
        - containerPort: 80
          name: web

❶ 为我们的服务提供一个端口

❷ 一个旧的 NGINX 版本,允许在 NGINX Pod 内部使用 shell

❸ 为了比较不同类型的 Pod 中 DNS 的工作方式

在我们的示例中,为服务设置一个端口很重要,因为我们感兴趣的是探索 DNS 解析的方式。此外,请注意,我们使用的是旧版本的 NGINX,这样我们可以在我们的 NGINX Pod 内部有一个 shell。出于安全原因,较新的 NGINX 容器没有 shell。最后,这次我们使用 StatefulSet 来比较不同类型的 Pod 中 DNS 的工作方式。

注意:我们使用的 NGINX 容器允许我们在 shell 中探索,但使用较新的 NGINX 容器没有这种便利。在这本书中我们提到了几次 scratch 容器(真正精简的容器,没有完整的操作系统基础,因此更安全,但也缺少 shell 来访问和进行黑客攻击)。越来越多地,你会发现出于安全原因,容器发布时没有可以进入的 shell。另一个越来越常见的容器基础镜像是为容器提供的distroless基础镜像。如果您想使用一些合理的默认设置安全地构建容器,我们建议使用 distroless 镜像,它具有大多数适用于微服务应用程序的默认设置,而没有可能增加您的 CVE 漏洞足迹的额外冗余。这一概念也在第十三章中有所涉及。要了解更多关于如何从 distroless 基础镜像构建应用程序的信息,您可以查阅github.com/GoogleContainerTools/distroless

在我们开始黑客攻击之前,我们将快速概述 StatefulSets 的概念以及它们在 Kubernetes 中的使用情况。这些通常具有最有趣的 DNS 特性和要求。

10.2 为什么选择 StatefulSets 而不是 Deployments?

在本章中,我们将创建一个运行在所谓的 StatefulSets 中的 Pod。当涉及到 DNS 时,StatefulSets 具有有趣的特性,因此我们将使用这个 Pod 来探测 Kubernetes 在运行具有可靠 DNS 端点的 HA(高可用性)进程时的能力和限制。对于需要具有明确身份的应用程序来说,StatefulSets 非常重要。例如:

  • Apache ZooKeeper

  • MinIO 或其他与存储相关的应用程序

  • Apache Hadoop

  • Apache Cassandra

  • 比特币挖矿应用程序

StatefulSets 与 Kubernetes 中高级 DNS 用例密切相关,因为它们通常用于标准微服务模型开始崩溃的场景,外部实体(服务、应用程序、遗留系统)开始影响应用程序的部署方式。从理论上讲,对于现代无状态应用程序,你应该很少需要使用 StatefulSet,除非有无法通过其他方式获得的临界性能要求。StatefulSets 本质上是更难管理的,随着时间的推移扩展和扩展,而不是“愚蠢”的部署,这种部署在 Pod 重启之间没有携带行李。

10.2.1 无头服务与 DNS

当我们使用 StatefulSet 来部署应用程序时,我们通常与一个无头服务一起这样做。一个 无头服务 是一个没有 ClusterIP 字段的服务,而是直接从 DNS 服务器返回一个 A 记录。这对 DNS 有一些重要的含义。要查看此类服务,请运行以下代码片段:

$ kubectl create -f https://github.com/jayunit100/k8sprototypes/ 
➥ blob/master/smoke-tests/nginx-pod-svc.yaml

之前的命令返回一个 YAML 文件。该文件定义了一个服务如下:

apiVersion: v1
kind: Service
metadata:
  name: headless-svc
spec:
  clusterIP: None
  selector:
    app: nginx
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  # Change this to true if you NEVER want an NXDOMAIN response!
  publishNotReadyAddresses: false                    ❶

publishNotReadyAddresses 决定了你是否会得到 NXDomain 记录。

此服务从定义在此文件中的运行 web 服务器的 Pods 集合中选择。一旦此服务启动

  • 你可以在我们共同部署的 BusyBox Pod 中发出对 wget headless-svc:80 的查询。

  • 你的 BusyBox Pod 查询 CoreDNS(我们将在本章中讨论)以获取无头服务的 IP 地址。

  • CoreDNS 将在检查无头服务是否启动(基于其 readinessProbe)后,返回相应 Pods 的 IP 地址。

注意:如果设置为 truepublishNotReadyAddresses 总是返回 NGINX 的后端 Pods,即使它们尚未就绪。有趣的是,这意味着如果 NGINX 的 Pod 根据其 readinessProbe 不可用,你的底层 CoreDNS 服务将返回 NXDOMAIN 记录而不是 IP 地址。这通常被 Kubernetes 初学者误认为是 DNS 错误,但实际上,这指向了你的 kubelet 或应用程序中可能存在的潜在问题。

为什么使用无头服务?事实证明,许多应用程序通过直接通过 Pod IP 相互连接来建立法定多数和其他网络特定行为,而不是依赖于网络代理(kube-proxy)进行负载均衡连接。一般来说,你应该尽可能使用 ClusterIP 服务,因为从 DNS 视角来看,它们更容易推理,除非你真的需要与 IP 保留、法定多数决策或特定的 IP 到 IP 性能保证相关的某种网络特定行为。

如果你想要了解更多关于无头服务和 DNS 的工作方式,你可以查看 mng.bz/q2Rz 中的步骤。

10.2.2 StatefulSets 中的持久 DNS 记录

让我们重新创建原始的 StatefulSet 示例。作为一个快捷方式,你可以运行 kubectl create -f https://raw.githubusercontent.com/jayunit100/k8sprototypes/master/smoke-tests/four-of-us.yaml。这个服务的名称可以用来查看其端点,如下代码片段所示:

$ kubectl get endpoints -o yaml | grep ip
    - ip: 172.18.0.2
      ip: 10.244.0.13
    - ip: 10.244.0.14
    - ip: 10.244.0.15
      ip: 10.244.0.16

在这里,我们可以看到我们在 13-16 范围内有四个连续的端点。这来自于我们的两个 StatefulSet 副本和两个 Deployment 副本。

10.2.3 使用多语言部署来探索 Pod DNS 属性

在本节中,我们将探讨两种不同的使用 Kubernetes DNS 的方法。然后,我们将比较 StatefulSet 和 Deployment Pods 的 DNS 属性。

首先,让我们看看这些 Pod 的 DNS 是如何工作的。我们可以进行的明显测试之一是检查它们的服务端点。让我们在集群内部进行这个操作,这样我们就不必担心暴露或转发任何端口。首先,创建一个堡垒 Pod,我们可以用它来对不同的应用程序运行_wget_

$ cat << EOF > bastion.yml
apiVersion: v1
kind: Pod
metadata:
  name: core-k8s
  namespace: default     ❶
spec:
  containers:
    - name: bastion
      image: docker.io/busybox:latest
      command: ['sleep','10000']
EOF
$ kubectl create -f bastion.yml

❶ 默认命名空间

注意,默认命名空间是使用这个例子最容易的,但你也可以在不同的命名空间中创建这个 Pod。如果是这样,你需要确保在探测 four-of-us 服务时完全限定 DNS 名称。现在,让我们exec进入这个 Pod,并使用它来完成本章剩余部分的全部实验:

$ kubectl get pods
NAME                       READY   STATUS   AGE
core-k8s                   1/1     Running  9m56s    ❶
web-dep-58db7f9644-fjtp6   1/1     Running  12h
web-dep-58db7f9644-gxddt   1/1     Running  12h
web-ss-0                   1/1     Running  12h
web-ss-1                   1/1     Running  12h

$ kubectl exec -t -i core-k8s /bin/sh

❶ 这是我们将访问的 Pod,作为探索集群内部 DNS 的一种方式。

我们可以做的第一件事就是wget下载我们的端点。以下代码片段显示了该命令:

#> wget nginx4:80
   Connecting to nginx4:80 (10.96.123.164:80)
   saving to 'index.html'

这真是个安慰。我们现在知道我们的服务是启动的。现在,如果我们仔细看看我们的 IP 地址,我们会发现它不在 10.244 范围内。这是因为我们访问的是一个服务,而不是 Pod。通常,你将使用服务名称作为 DNS 名称来访问集群内的服务,但如果我们想访问一个特定的 Pod 呢?那么我们可以使用类似这样的方法:

#> wget nginx:80
   Connecting to nginx:80 (10.96.123.164:80)
   saving to 'index.html'

#> wget web-ss-0.nginx                              ❶
   Connecting to web-ss-0.nginx (10.244.0.13:80)

#> wget web-dep-58db7f9644-fjtp6                    ❷
   bad address 'web-dep-58db7f9644-fjtp6'

❶ Pod 名称加上服务名称组合

❷ 通过名称获取部署中 Pod 的 IP 地址。

在这个例子中,Pod 名称加上服务名称的组合可以像任何 Web 服务器一样访问,而且没有为从部署创建的 Pod 提供等效的 DNS 名称。在我们的容器内部,我们不仅可以通过服务访问我们的 Pod,而且一些 Pod,即由 StatefulSet 创建的 Pod,也可以通过 DNS 直接访问。

当我们对 web-ss-0.nginx 端点运行wget(或者一般地,对任何-0.端点运行)时,我们将直接将此地址解析为给定 StatefulSet 的第一个副本的 IP 地址。要访问第二个副本,我们可以将 0 替换为 1,依此类推。因此,我们学到了关于集群 DNS 的第一个教训:服务和 StatefulSet Pod 都是 Kubernetes 集群中的第一类、稳定的 DNS 端点。现在,如何解析这个极其方便的 web-ss-0.nginx 名称呢?

10.3 resolv.conf 文件

让我们通过查看 resolv.conf 文件本身来了解这些不同的 DNS 请求是如何解析的(或者在某些情况下,无法解析)。这将最终引导我们到 CoreDNS 服务。

10.3.1 关于路由的简要说明

本章不是关于 Pod IP 网络,但这是一个确保在您的思维模型中 DNS 和 Pod 网络基础设施之间有明确联系的好机会,因为集群的这两个方面密切相关。在主机解析为 IP 之后

  • 如果该主机的 IP 是服务,那么确保这个 IP 路由到 Pod 端点就是网络代理的工作。

  • 如果该主机是一个 Pod,那么确保 IP 直接可路由就是你的 CNI 提供商的工作。

  • 如果该主机在互联网上,那么你的 Pod 的出站流量需要通过 iptables 进行 NAT,以便从请求发起的节点流回 Pod 的 TCP 连接。

图 10.2 展示了 Pod 对进入的主机名的 DNS 解析方式。这里的关键功能是,将发送多个版本的 DNS 查询到 10.96.0.10,直到找到匹配项。

图片

图 10.2 Pod 对进入的主机名的 DNS 解析

resolv.conf 文件是配置容器 DNS 的标准方式。在任何你试图了解 Pod 如何配置 DNS 的场景中,这是首先要查看的地方。如果你运行的是现代 Linux 服务器,你可能使用resolvctl代替,但原理是相同的。现在我们可以通过这个代码片段快速查看 DNS 是如何在我们的 Pod 中设置的:

/ # cat /etc/resolv.conf
search                       ❶
  default.svc.cluster.local
  svc.cluster.local
  cluster.local
nameserver 10.96.0.10        ❷
options ndots:5

❶ 将以下内容追加到查询的末尾

❷ 指定一个 DNS 服务器

在这个代码片段中,该文件中的search字段基本上是在说“将这些属性追加到查询的末尾,直到查询返回。”换句话说,首先尝试一个未经修改的 URL 是否可以解析。如果失败,尝试将default.svc.cluster .local添加到 DNS 请求中。如果仍然失败,尝试添加svc.cluster.local,依此类推。接下来,注意nameserver字段。这告诉解析器,它可以通过询问位于 10.96.0.10 的 DNS 服务器(即你的 kube-dns 服务的地址)来纠正外部 DNS 名称(不在/etc/hosts 中的那些)。

我们可以通过运行wget来查看,例如,我们的 StatefulSet Pods 的 DNS 是如何解析到集群网络内的 Pod IP 的。让我们通过kubectl exec进入我们的 NGINX Pod 并运行以下命令:

/ # wget web-ss-0.nginx.default.svc.cluster.local
Connecting to web-ss-0.nginx.default.svc.cluster.local
  (10.244.0.13:80)

我们将把这个作为读者的练习,尝试从不同的命名空间中尝试这个操作。请放心,如果你包含完整的 DNS 名称,web-ss-0在集群的任何命名空间中都可以解析。同样,wget web-ss-0.nginx.default也可以。现在你可以想象出各种使用这个技巧在不同命名空间间共享服务的方法。这个用例中最明显的可能之一是

  • 用户(Joe)在joe命名空间中创建了一个应用程序,该应用程序通常使用my-db URL 在joe命名空间内部访问数据库。

  • 另一个用户(Sally),她在sally命名空间中有一个应用程序,想要访问 my-db 服务,她可以通过使用 URL my-db.joe.svc.cluster .local 来实现。

10.3.2 CoreDNS:ClusterFirst Pod DNS 的上游解析器

CoreDNS 是隐藏在 10.96.0.10 端点背后的神秘名称服务器。如果我们想确认这一点,可以在本地运行kubectl get services。CoreDNS 究竟做了什么,使其能够解析来自互联网、内部集群等的主机?我们可以通过查看其配置映射来了解其设置情况。

CoreDNS 由插件提供支持,你从文件顶部向下读取 CoreDNS 配置,每个插件都是文件中的一行。要查看 CoreDNS 的配置映射,你可以在任何集群上运行kubectl get cm coredns -n kube-system -o yaml。在我们的示例中,这返回

apiVersion: v1
data:
  Corefile: |
    .:53 {
        errors
        health {
           lameduck 5s
        }
        ready
        kubernetes
           cluster.local in-addr.arpa ip6.arpa {   ❶
           pods insecure
           fallthrough in-addr.arpa ip6.arpa
           ttl 30
        }
        prometheus :9153
        forward . /etc/resolv.conf                 ❷
        cache 30                                   ❸
        loop
        reload
        loadbalance
        log {
          class all                                ❹
        }
    }
kind: ConfigMap

❶ 解析集群的本地 IP 主机

❷ 如果 K8s 插件失败,解析互联网地址

❸ 仔细关注这个插件;我们稍后会用到它。

❹ 启用 CoreDNS 响应和错误的详细日志记录

在这个代码示例中,我们首先尝试使用 CoreDNS 的 Kubernetes 插件解析集群的本地 IP 主机。然后,如果 Kubernetes 插件在解析它们时失败,我们使用 kubelet 的 resolv.conf 解析互联网上的地址。

你可能会想知道,如果 CoreDNS 在一个容器中运行,它的 resolv.conf 不会依赖于 CoreDNS 吗?结果证明,答案是否定的!为了了解原因,让我们看看集群的dnsPolicy字段,它在 Kubernetes 集群中的任何 Pod 上都被设置:

> kubectl get pod coredns-66bff467f8-cr9kh
   -o yaml | grep dnsPolicy
     dnsPolicy: ClusterFirst                    ❶

> kubectl get pods -o yaml | grep dnsPolicy     ❷
   dnsPolicy: Default

❶ 使用 CoreDNS 作为主要解析器

❷ 使用默认 dnsPolicy 启动 Pod

ClusterFirst 策略使用 CoreDNS 作为其主要解析器,这就是为什么我们的 Pod 中的 resolv.conf 文件基本上只有 CoreDNS 而没有其他内容。使用默认 dnsPolicy 启动的 Pod 实际上会注入一个/etc/resolv.conf文件,该文件从 Kubelet 本身解析条目。因此,在大多数 Kubernetes 集群中,你会发现

  • 即使 CoreDNS 在常规 Pod 网络中运行,它也有与其他集群中的“正常”Pod 不同的 DNS 策略。

  • 你的集群中的 Pod 首先尝试联系 Kubernetes 内部服务,然后再通过你在 Corefile 中配置的流访问互联网。

  • CoreDNS Pod 本身将非集群内部 IP 地址转发到其主机转发这些 IP 地址的同一位置。换句话说,它继承了 kubelet 的互联网主机解析属性。

10.3.3 破解 CoreDNS 插件配置

CoreDNS 的缓存插件告诉 CoreDNS 它可以缓存结果 30 秒。这意味着如果我们试图

  • 缩小我们的 StatefulSet 规模(通过运行kubectl scale statefulset web-ss --replicas=0

  • 为 web-ss-0.nginx Pod 启动一个wget

  • 将我们的 StatefulSet 规模恢复(通过运行kubectl scale statefulset web-ss --replicas=3

我们实际上会发现,即使在三个副本几乎立即运行的情况下,我们也可以在wget命令中获得一个长挂。这是因为 CoreDNS 的默认设置,即运行其缓存插件并具有 30 秒的响应时间,意味着即使这个 Pod 已经愉快地启动并运行,DNS 请求到 web-ss-0.nginx Pod 也会失败几秒钟。

为了解决这个问题,你可以运行kubectl edit cm coreDNS -n kube-system命令,并将这个cache值修改为更小的数字,例如 5。这样就可以保证 DNS 查询会快速刷新其结果。这个数字越大,随着时间的推移,你将在底层的 Kubernetes 控制平面上产生的负载就越小,但在我们的小型kind集群中,这种开销并不重要。

注意,无论是否使用 Kubernetes,DNS 调优在任何数据中心都是一个深奥的主题。如果你对在更大的集群中进一步调整 DNS 感兴趣,可以在 Kubernetes 的新版本中启动带有 NodeLocalDNS 策略的 kubelet。这个策略通过在集群的所有节点上运行 DaemonSet,使得 DNS 非常快速,因为它缓存了所有 Pod 的所有 DNS 请求。还有许多其他的 CoreDNS 插件调优你可以查看,以及 Prometheus 指标,你可以随着时间的推移进行监控。

摘要

  • Kubernetes 的一个主要特性为 Pods 提供了内部 DNS 以访问服务。

  • 有状态集对于需要具有明确身份的应用程序来说非常重要。

  • 无头服务直接返回 Pod IP,没有稳定的 ClusterIP,这意味着如果 Pod 宕机,有时会返回 NXDOMAIN。

  • 服务和有状态集 Pods 都是 Kubernetes 集群中的第一类、稳定的 DNS 端点。

  • resolv.conf 文件是配置容器 DNS 的标准方式。在任何你试图了解你的 Pod 如何配置 DNS 的场景中,这是首先要查看的地方。

  • CoreDNS 由插件驱动,你从文件顶部向下读取 CoreDNS 配置,其中每个插件都是文件中的一行。

  • CoreDNS 的缓存插件告诉 CoreDNS 它可以缓存结果 30 秒。

11 控制平面的核心

本章涵盖

  • 探索控制平面的核心组件

  • 审查 API 服务器细节

  • 探索调度器接口及其内部工作原理

  • 遍历控制器管理器和云管理器

之前,我们提供了一个 Pod 的高级概述,一个概述 Web 应用程序为什么需要 Pod,以及 Kubernetes 如何使用 Pod 构建。现在我们已经涵盖了所有用例的要求,让我们深入了解控制平面的细节。通常,所有控制平面组件都安装在kube-system命名空间中,这是一个作为操作员,你应该安装非常少组件的命名空间。

注意:你不应该只是使用kube-system!其中一个主要原因是运行在kube-system中的非控制器应用程序增加了安全爆炸半径,这指的是安全入侵的广度和深度。此外,如果你在一个托管系统上,如 GKE 或 EKS,你无法看到所有控制平面组件。我们将在第十三章中更多地讨论爆炸半径和安全最佳实践。

11.1 探索控制平面

开始和探索控制平面的最简单方法之一是使用kind,这是容器中的 Kubernetes(有关安装说明,请参阅以下链接:mng.bz/lalM)。要使用kind查看控制平面,请运行以下命令:

$ kind create cluster                         ❶
$ kubectl cluster-info --context kind-kind    ❷
$ kubectl -n kube-system get po \
-o custom-columns=":metadata.name"            ❸

coredns-6955765f44-g2jqd                      ❹
coredns-6955765f44-lvdxd
etcd-kind-control-plane                       ❺
kindnet-6gw2z                                 ❻
kube-apiserver-kind-control-plane             ❼
kube-controller-manager-kind-control-plane    ❽
kube-proxy-6vsrg                              ❾
kube-scheduler-kind-control-plane             ❿

❶ 在容器中创建运行的 Kubernetes 集群

❷ 将你的 kubectl 内容设置为指向你的本地 kind 集群

❸ 打印控制平面的组件 Pod(我们这里只打印 Pod 名称)

❹ 以 Deployment 运行两个副本

❺ etcd 数据库

❻ CNI 提供者

❼ Kubernetes API 服务器

❽ Kubernetes 控制器管理器

❾ 节点组件 kube proxy

❿ Kubernetes 调度器

你会注意到 kubelet 不是作为一个 Pod 运行的。一些系统在容器内运行 kubelet,但在kind等系统中,kubelet 只是作为一个二进制文件运行。要查看在kind集群中运行的 kubelet,请执行以下命令:

$ docker exec -it \
$(docker ps | grep kind | awk '{print $1}') \
/bin/bash                                             ❶
root@kind-control-plane:/$ ps aux | grep \
"/usr/bin/kubelet"                                    ❷
root    722 11.7  3.5 1272784 71896 ?    Ssl  23:34
➥ 1:10 /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
➥ --fail-swap-on=false --node-ip=172.17.0.2
➥ --fail-swap-on=false                               ❸

❶ 在 kind 容器内运行交互式终端

❷ kubelet 的 ps(进程状态)

❸ 运行中的 kubelet 进程

输入exit以退出容器内的交互式终端。要真正了解控制平面由什么组成,请查看各种 Pod。例如,你可以使用以下命令打印 API 服务器 Pod:

$ kubectl -n kube-system get po kube-apiserver-kind-control-plane -o yaml

11.2 API 服务器细节

现在,是时候深入了解 API 服务器的细节了。因为它不仅是一个 RESTful Web 服务器,而且是控制平面的关键组件。需要注意的是,不仅包括 API 对象,还包括自定义 API 对象。本书后面将介绍身份验证、授权和准入控制器,但首先,让我们更详细地看看 Kubernetes API 对象和自定义资源。

11.2.1 API 对象和自定义 API 对象

Kubernetes 的本质是一个开放平台,这意味着开放的 API。开放一个平台是提供额外创新和创造的方式。以下列出了一些与 Kubernetes 集群相关的 API 资源。您将认识到其中一些 API 对象(如 Deployments 和 Pods):

$ kubectl api-resources -o name | head -n 20   ❶
bindings
componentstatuses
configmaps
endpoints
events
limitranges
namespaces
nodes
persistentvolumeclaims
persistentvolumes
pods
podtemplates
replicationcontrollers
resourcequotas
secrets
serviceaccounts
services
mutatingwebhookconfigurations.admissionregistration.k8s.io
validatingwebhookconfigurations.admissionregistration.k8s.io
customresourcedefinitions.apiextensions.k8s.io

❶ 显示可用的 API,只需查看前 20 个使用 head

当我们使用 ClusterRoleBinding 定义 YAML 清单时,定义的一部分是 API 版本。例如:

apiVersion: rbac.authorization.k8s.io/v1    ❶
kind: ClusterRoleBinding
metadata:
  name: cockroach-operator-default
  labels:
    app: cockroach-operator
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cockroach-operator-role
subjects:
  - name: cockroach-operator-default
  namespace: default
  kind: ServiceAccount

❶ apiVersion 与之前代码片段中的 API 组相匹配。

之前 YAML 中的apiVersion部分定义了 API 的版本。API 版本化是一个复杂的问题。为了允许 API 通过不同的版本进行迁移,Kubernetes 具有版本和级别的功能。例如,在之前的 YAML 定义中,您会注意到我们在apiVersion定义中使用了v1beta1。这表示 ClusterRoleBinding 是一个beta API 对象。

API 对象具有以下级别:alpha、beta 和 GA(通用可用性)。标记为 alpha 的对象永远不应该在生产中使用,因为它们会导致严重的升级路径问题。alpha API 对象将会改变,并且仅用于开发和实验。beta 实际上并不是 beta!通常认为 beta 软件是不稳定的,不适合生产,但 Kubernetes 中的 beta API 对象确实适合生产,并且对这些对象的支持是保证的,与 alpha 对象不同。例如,DaemonSets 已经 beta 了多年,几乎每个人都把它们用于生产。

v1前缀允许 Kubernetes 开发者对 API 对象的版本进行编号。例如,在 Kubernetes v1.17.0 中,自动缩放 API 是

  • /apis/autoscaling/v1

  • /apis/autoscaling/v2beta1

  • /apis/autoscaling/v2beta2

注意,这个列表是 URI 布局。您可以通过首先在本地启动一个kind Kubernetes 集群来查看 API 对象的 URI 布局:

$ kind cluster start

然后,在同时拥有网络浏览器的系统上运行一个kubectl命令。例如:

$ kubectl proxy --port=8181

现在转到 URL http://127.0.0.1:8181/。为了简洁起见,我们不会显示 API 服务器返回的 120 行响应,但如果您在本地这样做,它将为您提供 API 端点的图形视图。

11.2.2 自定义资源定义(CRDs)

在 ClusterRoleBinding 代码片段中,我们定义了 CRDs 来与cockroach数据库操作员通信。现在是时候讨论 CRDs 存在的原因了。在 Kubernetes v1.17.0 中,我们有 54 个 API 对象。以下命令将提供一些见解:

$ kubectl api-resources | wc     ❶
      54     230    5658

❶ 将 kubectl 命令的结果管道传输到 wc 以计算行数、单词数和字符数

你可以理解维护一个包含 54 个不同对象(坦白说,我们需要更多)的系统需要多少开发时间。为了将非核心 API 对象与 API 服务器解耦,创建了 CRD。这允许开发者创建自己的 API 对象定义,然后使用 kubectl 将该定义应用到 API 服务器。以下命令在 API 服务器中创建一个 CRD 对象:

$ kubectl apply -f https://raw.githubusercontent.com/cockroachdb/
➥ cockroach-operator/v2.4.0/config/crd/bases/
➥ crdb.cockroachlabs.com_crdbclusters.yaml

与 API 服务器中的 Pod 或其他股票 API 对象一样,CRD 对象通过程序化扩展 Kubernetes API 平台,无需程序员交互。操作员、自定义准入控制器、Istio、Envoy 以及其他技术现在通过定义自己的 CRD 来使用 API 服务器。但是,这些自定义对象并没有紧密耦合到 Kubernetes API 对象的实现中。此外,许多新的 Kubernetes 核心组件并没有被添加到 API 服务器的基本定义中,而是作为 CRD 添加。这就是 API 服务器。接下来,我们将讨论我们将要覆盖的第一个控制器:Kubernetes 调度器。

11.2.3 调度器细节

调度器,就像其他控制器一样,由各种控制循环组成,处理不同的事件。截至 Kubernetes v1.15.0,调度器被重构为使用调度框架,并且还增加了自定义插件。Kubernetes 支持使用自定义调度器,这些调度器不在实际的调度器中运行,而是在另一个 Pod 中运行。然而,自定义调度器的问题通常是性能不佳。

调度器框架的第一个组件是 QueueSort。它将需要调度的 Pod 排序到一个队列中。然后框架分为两个周期:调度周期和绑定周期。首先,调度周期选择 Pod 运行的节点。一旦调度周期完成,绑定周期接管。

调度器选择 Pod 可以驻留的节点,实际上确定 Pod 是否可以驻留可能需要一些时间。例如,Pod 需要一个卷,因此需要创建该卷。如果所需卷的创建失败会发生什么?那么 Pod 就不能在该节点上运行,该 Pod 的调度将被重新排队。

我们将通过这个过程来了解调度器在调度过程中处理 Pod NodeAffinity 的时间点。每个周期都有其独立的组件,这些组件在调度器 API 中的结构如下。以下代码来自 Kubernetes v1.22 版本,截至 v1.23,它已经被重构,允许通过多点启用插件。截至本书编写时,调度器本身和插件基础并未改变。此代码片段(位于 mng.bz/d2oX)定义了在运行中的调度实例内部注册的各种插件集合。以下是基本 API 定义:

// Plugins include multiple extension points. When specified, the list of
// plugins for a particular extension point are the only ones enabled. If
// an extension point is omitted from the config, then the default set of
// plugins is used for that extension point. Enabled plugins are called in
// the order specified here, after the default plugins. If they need to be
// invoked before the default plugins, the default plugins must be disabled
// and re-enabled here in the desired order.
type Plugins struct {
    // QueueSort is a list of plugins that should be invoked when
    // sorting pods in the scheduling queue.
    QueueSort *PluginSet                                       ❶

    // PreFilter is a list of plugins that should be invoked at the
    // PreFilter extension point of the scheduling framework.
    PreFilter *PluginSet                                       ❷

    // Filter is a list of plugins that should be invoked when filtering
    // nodes that cannot run the Pod.
    Filter *PluginSet

    // PostFilter is a list of plugins that are invoked after filtering
    // phase, no matter whether filtering succeeds or not.
    PostFilter *PluginSet

    // PreScore is a list of plugins that are invoked before scoring.
    PreScore *PluginSet

    // Score is a list of plugins that should be invoked when ranking nodes
    // that have passed the filtering phase.
    Score *PluginSet

    // Reserve is a list of plugins invoked when reserving/unreserving
    // resources after a node is assigned to run the pod.
    Reserve *PluginSet

    // Permit is a list of plugins that control binding of a Pod. These
    // plugins can prevent or delay binding of a Pod.
    Permit *PluginSet

    // PreBind is a list of plugins that should be invoked before a pod
    // is bound.
    PreBind *PluginSet                                         ❸

    // Bind is a list of plugins that should be invoked at the Bind
    // extension point of the scheduling framework. The scheduler calls
    // these plugins in order and skips the rest of these plugins as soon
    // as one returns success.
    Bind *PluginSet                                            ❸

    // PostBind is a list of plugins that should be invoked after a pod
    // is successfully bound.
    PostBind *PluginSet                                        ❸
}

❶ 在队列中对 Pod 进行排序

❷ 调度周期插件从这里开始,结束于 Permit 插件。

❸ 这最后三个插件是绑定周期。

之前代码片段中的结构体在 mng.bz/rJaZ(在 1.21 之后此代码被重构并移动)中被实例化。在下面的代码中,你会认出处理 Pod NodeAffinity 等配置的调度插件,这会影响 Pod 的调度。此过程的第一阶段是队列排序,但请注意,队列排序是可扩展的,因此可以替换:

func getDefaultConfig() *schedulerapi.Plugins {    ❶
    return &schedulerapi.Plugins{
        QueueSort: &schedulerapi.PluginSet{        ❷
            Enabled: []schedulerapi.Plugin{
                {Name: queuesort.Name},
            },
        },

❶ 再次调用 getDefaultConfig()

❷ 调用 getDefaultConfig()

私有函数 getDefaultConfig() 在同一 Go 文件中的 NewRegistry 被调用。这返回一个算法提供程序注册实例。接下来返回的成员定义了调度周期。首先,是预过滤器,它是一系列按顺序运行的插件:

PreFilter: &schedulerapi.PluginSet {
            Enabled: []schedulerapi.Plugin {

                {Name: noderesources.FitName},     ❶

                {Name: nodeports.Name},            ❷

                {Name: podtopologyspread.Name},    ❸

                {Name: interpodaffinity.Name},     ❹

                {Name: volumebinding.Name},        ❺
            },
        },

❶ 检查节点是否有足够的资源

❷ 确定节点是否有空闲端口来托管 Pod

❸ 检查 PodTopologySpread 是否满足,这允许 Pod 在区域之间均匀分布

❹ 处理节点间亲和性,类似于 Pod 反亲和性,根据用户定义的规则将 Pod 从节点排斥

❺ 这实际上不是一个过滤器,但创建了一个在保留和预绑定阶段稍后使用的缓存。

接下来是过滤阶段。请注意,Filter 是一个插件列表,用于确定 Pod 是否可以在特定节点上运行:

Filter: &schedulerapi.PluginSet {
    Enabled: []schedulerapi.Plugin {
        {Name: nodeunschedulable.Name},      ❶

        {Name: noderesources.FitName},       ❷

        {Name: nodename.Name},               ❸

        {Name: nodeports.Name},              ❹

        {Name: nodeaffinity.Name},           ❺

        {Name: volumerestrictions.Name},     ❻

        {Name: tainttoleration.Name},        ❼

        {Name: nodevolumelimits.EBSName},    ❽
        {Name: nodevolumelimits.GCEPDName},
        {Name: nodevolumelimits.CSIName},
        {Name: nodevolumelimits.AzureDiskName},

        {Name: volumebinding.Name},          ❾

        {Name: volumezone.Name},             ❿

        {Name: podtopologyspread.Name},      ⓫
        {Name: interpodaffinity.Name},       ⓫
    },
},

❶ 确保 Pod 不会被调度到过去标记为不可调度的节点(例如,控制平面中的节点)

❷ 再次执行插件

❸ PodSpec API 允许您设置一个节点名,该节点名标识了您希望 Pod 所在的节点。

❹ 插件执行了第二次

❺ 检查 Pod 节点选择器是否匹配节点标签

❻ 检查是否满足各种卷限制

❼ 检查 Pod 是否容忍节点污点

❽ 检查节点是否有能力添加更多卷(例如,节点可以在 GCP 中挂载 16 个卷)。

❾ 在预过滤器中重复一个过滤器

❿ 检查节点所在的区域中是否存在卷

⓫ 这两个过滤器会重复。

在过滤阶段,调度器检查 GCP、AWS、Azure、ISCI 和 RBD 中挂载不同卷的各种约束。例如,Pod 反亲和性确保有状态集的 Pod 驻留在不同的节点上。你可能已经开始注意到,过滤器是从你可能在过去已经创建在 Pod 上的设置中调度 Pod 的。现在,让我们继续到后过滤器。即使过滤失败,此插件也会运行:

PostFilter: &schedulerapi.PluginSet{
  Enabled: []schedulerapi.Plugin{
    {Name: defaultpreemption.Name},    ❶
  },
},

❶ 处理 Pod 抢占

用户可以为 Pod 设置一个优先级类别。如果是这样,默认的抢占插件允许调度器确定是否可以设置另一个 Pod 进行驱逐,以便在优先级类别中为计划中的 Pod 腾出空间。请注意,这些插件会执行所有过滤操作以确定 Pod 是否可以在特定节点上运行。

接下来是评分。调度器构建一个 Pod 可以运行的节点列表,现在是时候通过评分节点来对可以托管 Pod 的节点列表进行排名。因为评分组件也是过滤节点的插件之一,所以你会注意到很多重复的插件名称。调度器首先进行预评分,以便为评分插件创建一个可共享的列表:

PreScore: &schedulerapi.PluginSet{      ❶
  Enabled: []schedulerapi.Plugin{
    {Name: interpodaffinity.Name},
    {Name: podtopologyspread.Name},
    {Name: tainttoleration.Name},
  },
},

❶ 在过滤过程中已经运行了所有定义的插件。

下面的代码片段定义了各种重复插件的重复使用,但也定义了一些新的插件。调度器定义了一个权重值,它会影响调度。所有评分的节点都已通过不同的过滤阶段:

Score: &schedulerapi.PluginSet{
  Enabled: []schedulerapi.Plugin{
    {Name: noderesources.BalancedAllocationName,
    ➥ Weight: 1},                                          ❶

    {Name: imagelocality.Name, Weight: 1},                  ❷

    {Name: interpodaffinity.Name, Weight: 1},               ❸

    {Name: noderesources.LeastAllocatedName,
    ➥ Weight: 1},                                          ❹

    {Name: nodeaffinity.Name, Weight: 1},                   ❺

    {Name: nodepreferavoidpods.Name,
    ➥ Weight: 10000},                                      ❻

    // Weight is doubled because:
    // - This is a score coming from user preference.
    // - It makes its signal comparable to NodeResourcesLeastAllocated.
    {Name: podtopologyspread.Name, Weight: 2},              ❼
    {Name: tainttoleration.Name, Weight: 1},                ❼
  },
},

❶ 优先考虑资源使用平衡的节点

❷ 已经下载 Pod 的镜像的节点得分更高。

❸ 重复插件以评分构建的缓存

❹ 优先考虑请求较少的节点。

❺ 重复插件,再次评分构建的缓存

❻ 如果设置了 preferAvoidPods,则降低节点分数

❼ 重复这两个插件

当优先考虑资源使用平衡的节点时,调度器计算 CPU、内存和卷分数。算法如下:

(cpu((capacity − sum(requested)) * MaxNodeScore/capacity) +
  memory((capacity − sum(requested)) * MaxNodeScore/capacity)) / weightSum

此算法对请求较少的节点进行评分。节点标签preferAvoidPods表示应避免该节点进行调度。

过滤过程的最后一步是保留阶段。在保留阶段,我们为 Pod 在绑定周期内使用保留一个卷。在下面的代码中,请注意 volumebinding 是一个重复的插件:

Reserve: &schedulerapi.PluginSet{
  Enabled: []schedulerapi.Plugin{
    {Name: volumebinding.Name},      ❶
  },
},

❶ 缓存为 Pod 保留一个卷。

调度周期,主要过滤节点,确定 Pod 应该运行在哪个节点上。但是确保 Pod 实际上运行在那个节点上是一个更长的过程,你可能会发现 Pod 被重新排队进行调度。现在让我们看看调度框架中的绑定周期,从预绑定阶段开始。下面的代码片段显示了 PreBind 插件的代码:

PreBind: &schedulerapi.PluginSet{
  Enabled: []schedulerapi.Plugin{
    {Name: volumebinding.Name},     ❶
  },
},
Bind: &schedulerapi.PluginSet{
  Enabled: []schedulerapi.Plugin{
    {Name: defaultbinder.Name},     ❷
  },

},

❶ 将卷绑定到 Pod 上

❷ 通过 API 服务器保存绑定对象,更新 Pod 将启动的节点

在所有这些过程中,调度器有多个队列:一个活动队列,即要调度的 Pod,和一个回退队列,其中包含不可调度的 Pod。调度器中的注册表不会为两个不同的阶段:许可和 PostBind 实例化插件。这些入口点被其他插件使用,例如批处理调度器,它很快将成为调度器的外部插件。因为我们现在有一个调度框架,我们可以使用和注册其他自定义调度插件。这些自定义插件的示例可以在 GitHub 存储库中找到,网址为mng.bz/oaBN

11.2.4 调度总结

图 11.1 显示了组成调度框架的三个组件。这些包括

  • 队列构建器—维护 Pod 队列

  • 调度周期—过滤节点以找到运行 Pod 的节点

  • 绑定周期—将数据保存到 API 服务器,包括绑定信息

图 11.1 Kubernetes 调度器

11.3 控制器管理器

许多原本包含在 KCM(Kubernetes 控制器管理器)中的功能已被移动到 CCM(云控制器管理器)。这个二进制文件是四个组件的组合,这些组件本身是控制器或者仅仅是控制循环。我们将在接下来的章节中探讨这些内容。

11.3.1 存储

Kubernetes 中的存储有点像移动的目标。随着功能从 KCM 移出并进入 CCM,Kubernetes 控制平面中的存储功能也发生了重大变化。在迁移之前,KCM 存储适配器存在于主仓库 kubernetes/kubernetes 中。用户在云中创建了一个 PVC(持久卷声明),然后 KCM 调用了 Kubernetes 项目内部的代码。然后,还有灵活的卷控制器,这些控制器至今仍然存在。但是,回到 KCM,它驱动了 Kubernetes v1.18.x 版本中存储对象的创建。

当用户创建一个 PV 或 PVC,或者一个需要创建 StatefulSet 的 PVC/PV 组合时,控制平面上的一个组件必须启动并控制存储卷的创建。这个存储卷可以由云提供商托管或在另一个虚拟环境中创建。需要注意的是,KCM 控制存储的创建和删除。让我们来看看构成 KCM 的控制器。

节点控制器会监视节点何时宕机。然后,它会更新节点状态在 Nodes API 对象中。

复制控制器维护系统中每个复制控制器对象的正确 Pod 数量。复制控制器对象大部分已被使用 ReplicaSets 的部署所取代。

端点控制器是最后一个控制器,它管理端点对象。端点对象定义在 Kubernetes API 中。这些对象通常不是手动维护的,但它们被创建来为kube-proxy提供将 Pod 连接到服务的信息。一个服务可以有一个或多个 Pod 处理来自该服务的流量。以下是在kind集群上为kube-dns创建的端点示例:

$ kubectl -n kube-system describe endpoints kube-dns
Name:         kube-dns
Namespace:    kube-system
Labels:       k8s-app=kube-dns
              kubernetes.io/cluster-service=true
              kubernetes.io/name=KubeDNS
Annotations:  endpoints.kubernetes.io/last-change-trigger-time:
➥ 2020-09-30T00:21:28Z
Subsets:
  Addresses:          10.244.0.2,10.244.0.4    ❶
  NotReadyAddresses:  <none>
  Ports:
    Name     Port  Protocol
    ----     ----  --------
    dns      53    UDP
    dns-tcp  53    TCP
    metrics  9153  TCP

❶ 属于 kube-dns 服务的 Pod 的 IP 地址

11.3.2 服务账户和令牌

当生成新的命名空间时,Kubernetes 控制器管理器为新的命名空间创建默认的服务账户和 API 访问令牌。如果您在定义 Pod 时没有指定特定的服务账户,它将加入在命名空间中创建的默认服务账户。不出所料,当 Pod 访问集群的 API 服务器时,会使用服务账户。当 Pod 启动时,API 访问令牌会被挂载到 Pod 上,除非用户禁用了令牌的挂载。

提示:如果一个 Pod 不需要 ServiceAccount 令牌,可以通过将automountServiceAccountToken设置为false来禁用令牌的挂载。

11.4 Kubernetes 云控制器管理器(CCM)

假设我们有一个在云服务上运行的 Kubernetes 集群,或者我们在虚拟化提供商上运行 Kubernetes。无论哪种方式,这些不同的托管平台都维护着不同的云控制器,它们与 Kubernetes 托管层的 API 层进行交互。如果您想编写一个新的云控制器,您需要包括以下组件的功能:

  • 节点—用于虚拟实例

  • 路由—用于节点间的流量

  • 外部负载均衡器—用于创建集群节点外部的负载均衡器

与云服务提供商内部组件交互的代码是通过其 API 针对特定提供商的。云控制器接口现在为不同的云提供商定义了一个通用接口。例如,为了让 Omega 为 Kubernetes 构建一个云提供商,我们需要构建一个利用以下接口的控制器:

// Interface is an abstract, pluggable interface for cloud providers.
type Interface interface {

    // Initialize provides the cloud with a Kubernetes client builder and
    // can spawn goroutines to perform housekeeping or run custom
    // controllers specific to the cloud provider. Any tasks started here
    // should be cleaned up when the stop channel closes.
    Initialize(clientBuilder ControllerClientBuilder, stop <-chan struct{})

    // LoadBalancer returns a balancer interface. It also returns true if
    // the interface is supported; otherwise, it returns false.
    LoadBalancer() (LoadBalancer, bool)

    // Instances returns an instances interface. It also returns true if
    // the interface is supported; otherwise, it returns false.
    Instances() (Instances, bool)

    // InstancesV2 is an implementation for instances and should only be
    // implemented by external cloud providers. Implementing InstancesV2
    // is behaviorally identical to Instances but is optimized to
    // significantly reduce API calls to the cloud provider when registering
    // and syncing nodes. It also returns true if the interface is supported
    // and false otherwise.
    // WARNING: InstancesV2 is an experimental interface and is subject to
    // change in v1.20.
    InstancesV2() (InstancesV2, bool)

    // Zones returns a zones interface. It also returns true if the
    // interface is supported and false otherwise.
    Zones() (Zones, bool)

    // Clusters returns a clusters interface. It also returns true if the
    // interface is supported and false otherwise.
    Clusters() (Clusters, bool)

    // Routes returns a routes interface along with whether the interface
    // is supported.
    Routes() (Routes, bool)

    // ProviderName returns the cloud provider ID.
    ProviderName() string

    // HasClusterID returns true if a ClusterID is required and set.
    HasClusterID() bool
}

对于 CCM,推荐的设计模式是实现三个控制循环(控制器)。这些通常作为一个单一的二进制文件部署。

为了将云卷挂载到节点上,我们必须在云中找到该节点,而 Node 控制器提供了这一功能。控制器必须知道集群中哪些节点,这超出了节点启动时 kubelet 提供的信息。在云环境中运行时,Kubernetes 需要关于节点及其在云环境中的部署的具体信息(例如,区域信息)。此外,还有一个层来确定节点是否已完全从云环境中删除。节点控制器在云 API 层和存储之间提供了一个桥梁,并将这些信息存储在 API 服务器中。

Kubernetes 需要在节点间路由流量,这由 Route 控制器处理。如果云需要配置来在节点间路由数据,CCM 会对节点间的所有网络流量进行 API 调用。

“服务控制器”这个名字有点误导。服务控制器只是一个控制器,它仅用于在集群内创建 LoadBalancer 类型的服务。它不促进 Kubernetes 集群内 ClusterIP 服务的任何操作。

11.5 进一步阅读

Acetozi. “Kubernetes 主组件:Etcd、API 服务器、控制器管理器和调度器。” mng.bz/doKX(访问日期:2021 年 12 月 29 日)。

摘要

  • Kubernetes 控制平面提供了在 Kubernetes 集群中编排和托管 Pod 的功能。

  • 调度器由处理不同事件的多个控制循环组成。

  • 调度周期主要过滤节点,确定 Pod 应该运行在哪个节点上。

  • Kubernetes 中的 Beta API 对象已准备好投入生产。对这些对象的支持是保证的,与 alpha 对象不同。

  • KCM 和 CCM 协同工作,通过 KCM 和 CCM 包含的不同控制器提供存储、服务、负载均衡器和其他组件。

12 个 etcd 和控制平面

本章涵盖

  • 比较 etcd v2 和 v3

  • 查看 Kubernetes 中的 watch

  • 探讨严格一致性的重要性

  • 针对 etcd 节点的负载均衡

  • 查看 Kubernetes 中 etcd 的安全模型

如第十一章所述,etcd 是一个具有强一致性保证的键/值存储。它与 ZooKeeper 类似,后者被用于 HBase 和 Kafka 等流行技术。Kubernetes 集群的核心由以下组成

  • kubelet

  • 调度器

  • 控制器管理器(KCM 和 CCM)

  • API 服务器

这些组件通过更新 API 服务器相互通信。例如,如果调度器想在特定节点上运行 Pod,它会通过修改 API 服务器中的 Pod 定义来实现。如果在启动 Pod 的过程中,kubelet 需要广播一个事件,它会通过向 API 服务器发送消息来实现。由于调度器、kubelet 和控制器管理器都通过 API 服务器进行通信,这使得它们高度解耦。例如,调度器不知道 kubelet 如何运行 Pod,kubelet 也不知道 API 服务器如何调度 Pod。换句话说,Kubernetes 是一个巨大的机器,它始终在 API 服务器中存储你的基础设施的状态。

当节点、控制器或 API 服务器失败时,数据中心的应用程序需要进行协调,以便容器可以被调度到新的节点,卷可以被绑定到这些容器上,等等。通过 Kubernetes API 进行的所有状态修改实际上都备份在 etcd 中。在扩展计算的世界里,这并不是什么新鲜事。你可能听说过像 ZooKeeper 这样的工具,它们以相同的方式使用。实际上,HBase、Kafka 和许多其他分布式平台在底层都使用 ZooKeeper。etcd 数据库只是 ZooKeeper 的现代版本,它在如何存储高度关键数据以及在故障场景中协调记录方面有一些不同的看法。

12.1 对于不耐烦的人的笔记

一旦开始研究分布式共识场景和 etcd 数据库的灾难恢复的理论内部机制,可能会感到相当令人不知所措。在我们深入那个领域之前,让我们先了解一下 Kubernetes 中 etcd 的一些实用细节:

  • 如果你丢失了 etcd 数据,你的集群将受到严重损害。备份 etcd!

  • 在生产中运行 etcd v3 需要通过固态硬盘和高速网络进行快速的磁盘访问。

    etcd 中的单个写入操作,如果需要超过 1 秒的时间来序列化到磁盘,可能会逐渐使大型集群停止运行。考虑到你可能会在任何给定时间有许多写入操作,这意味着网络和磁盘需求大致相当于 10 GB 的网络和固态硬盘。根据 etcd 自己的文档(etcd.io/docs/v3.3/op-guide/hardware/):“通常需要 50 个顺序 IOPS(例如,7200 RPM 的硬盘)。”而且,通常 etcd 需要更多的 IOPS

  • 对于给定的计算节点,大多数数据中心或云环境都会出现周期性的故障,因此您需要冗余的 etcd 节点。这意味着在给定的安装中运行三个或更多的 etcd 节点。

  • 对于集群化的 etcd 环境,了解其 Raft 实现的工作原理、为什么磁盘 I/O 对 Raft 共识很重要,以及 etcd 如何使用 CPU 和内存,将变得非常重要。

  • 所有事件,除了集群状态外,都存储在 etcd 中。然而,您应该决定将集群事件(其中有很多)存储在不同的 etcd 端点,这样您的核心集群数据就不会与不重要的事件元数据竞争。

  • 用于与 etcd 服务器交互的命令行工具etcdctl具有自己的嵌入式性能测试,用于快速验证 etcd 性能:etcdctl check perf

  • 如果您需要恢复 etcd 实例,可以遵循mng.bz/6Ze5中的指南手动恢复 etcd 快照。

12.1.1 使用 Prometheus 可视化 etcd 性能

本节中的大部分信息将是轶事性的,因为调整和管理 Kubernetes 内部的 etcd 涉及很多理论。为了弥补这一点,我们将从如何在生产环境中进行 etcd 调整和观察的实践旅程开始。这些示例是高级的,欢迎您跟随,但您不必独立产生这些数据,才能从本节中受益。

图 12.1 显示了在 Kubernetes 集群中发生任何事件时发生的标准流程。所有写操作最终都与多个 etcd 服务器达成一致,认为写操作已完成。这将为我们提供即将讨论的现实场景的背景。

图片

图 12.1 Kubernetes 集群中事件发生时的流程

每个 API 服务器操作(例如,每次您通过kubectl create -f mypod.yaml创建简单的 Pod 时)都会导致对 etcd 的同步写入。这确保了创建 Pod 的请求在 API 服务器死亡的情况下(根据大数定律,它最终会死亡)存储在磁盘上。API 服务器将信息发送到“领导者”etcd 服务器,然后分布式共识的魔力接管,将此写入固定下来。在图 12.1 中,我们可以看到

  • 此集群有三个 etcd 实例。通常,这可以是三个、五个或七个。etcd 实例的数量总是奇数,这样在选举新领导者(我们将在本章末尾讨论 etcd 领导权)时总是可能的。

  • 单个 API 服务器可以接收一个写操作,此时它将在其指定的 etcd 端点存储数据,该端点在启动时特定于您的 etcd 服务器,作为--etcd-servers

  • 写入操作实际上是由最慢的 etcd 节点减慢的。如果一个节点将数据序列化到磁盘的往返时间很慢,那么这个时间将主导事务的总往返时间。

现在,让我们从 etcd 的角度看看当我们的集群健康时会发生什么。首先,你需要安装 Prometheus。(尽管我们之前已经详细讨论过,但在这个案例中,有一个小的偏差:我们将配置在 Docker 中运行的 Prometheus,以便专门抓取 etcd 实例。)你可能还记得,启动 Prometheus 需要给它一个 YAML 文件,这样它就知道需要从哪些目标抓取信息。为了定制这个文件以分析我们示例图中的三个 etcd 集群,你会创建以下内容:

global:
  scrape_interval:     15s
  external_labels:
    monitor: 'myetcdscraper'
scrape_configs:
  - job_name: 'prometheus'
    scrape_interval: 5s
    static_configs:
      - targets: ['10.0.0.217:2381']
      - targets: ['10.0.0.251:2381']
      - targets: ['10.0.0.141:2381']

对于这个过程,我们将关注的指标是 fsync 指标。这个指标告诉我们写入 etcd(磁盘)所需的时间有多长。这个指标被划分为桶(它是一个直方图)。任何接近 1 秒的写入都是一个指标,表明我们的性能处于风险之中。如果我们看到超过 0.25 秒的写入数量呈上升趋势,我们可能会开始担心我们的 etcd 集群正在变慢,因为生产 Kubernetes 集群也可能如此。

使用此配置启动 Prometheus 后,你可以制作一些相当漂亮的图表。让我们看看一个快乐的 Kubernetes 集群,其中各个 etcd 节点都运行正常。Prometheus 的直方图在开始时可能会让人感到困惑。重要的是要记住,如果特定的桶斜率发生变化,你可能会遇到麻烦!在我们的第一个 Prometheus 图表(图 12.2)中,我们可以看到:

  • 超过 1 秒的写入量可以忽略不计。

  • 超过 0.5 秒的写入量可以忽略不计。

  • 整体写入速度的唯一偏差发生在高性能桶中。

  • 最重要的是,线的斜率没有变化。

图 12.2 健康集群和 etcd 指标图

一些集群并不那么令人满意。如果我们将相同的集群重新安装在例如运行在慢速磁盘组上的硬件上,我们最终会得到一个类似于图 12.3 的直方图。与图 12.2 相比,你会注意到直方图的一些桶随着时间的推移发生了剧烈的斜率变化。斜率波动最剧烈的桶代表在不到 0.5 秒内发生的写入。因为我们通常期望几乎所有写入都发生在这一界限以下,我们知道随着时间的推移,我们的集群可能处于危险之中;然而,这并不一定意味着我们的集群不健康或正趋向于灾难。

图 12.3 不健康集群和 etcd 指标图

我们现在已经看到,在集群中监控 etcd 随时间的变化很容易。但我们如何将其关联到实际发生的问题呢?etcd 服务器写容量性能的下降可能导致频繁的领导选举问题。Kubernetes 集群中的每个领导选举事件都意味着在 API 服务器等待 etcd 重新上线期间,kubectl将基本上无用。最后,我们将查看另一个指标:领导选举(图 12.4)。

嵌套虚拟化图

图 12.4 领导选举和 etcd 指标图

要查看图 12.3 的后果,我们可以直接查看领导选举事件的指标,etcd_server_is_leader。通过随时间绘制此指标(图 12.4),您可以轻松地注意到数据中心中何时发生选举的爆发。接下来,我们将介绍一些简单的烟雾测试,您可以使用etcdctl等工具快速诊断单个 etcd 节点。

12.1.2 了解何时调整 etcd

如前节所述,在生产环境中,您可能需要调整 etcd 实例。有许多场景可能导致您考虑这一路径,但为了具体说明,我们将简要探讨几个例子。有许多 Kubernetes 提供商将为您管理 etcd(基于集群 API 的安装将在一定程度上通过将 etcd 存储在可以重新创建的各个节点上来完成此操作)或者完全隐藏 etcd(例如 GKE)。在其他情况下,您可能需要考虑 etcd 的安装方式和运行环境。在这方面有两个有趣的用例:嵌套虚拟化和基于kubeadm的原始 Kubernetes 安装。让我们接下来看看这两个用例。

嵌套虚拟化

嵌套虚拟化在开发者和测试环境中很常见。例如,您可能使用 VMware Fusion 等技术来模拟 vSphere Hypervisor。在这种情况下,您将拥有运行在其他 VM 内部的 VM。我们可以将我们的kind集群中的节点视为嵌套虚拟化的类比:我们有运行在 Docker 守护进程内部的 Docker 容器,这些容器模拟 VM,然后在这些 Docker 节点内部运行 Kubernetes 容器。无论如何,如您所想象的那样,在另一个 VM 内部嵌套 VM 会创建巨大的性能开销,并且不建议在生产 Kubernetes 中使用。它之所以如此危险的硬件配置,主要原因是,随着我们虚拟化多层,我们在硬盘上的写操作中增加了延迟。这种延迟可以使 etcd 变得极其不可靠。

嵌套虚拟化限制了 IOPS(输入/输出操作),并导致常见的写入失败。尽管 Kubernetes 本身可以从中恢复,但如果你在 Kubernetes 中使用 Lease API,许多 Pod 将不断丢失领导者状态,这是越来越常见的情况。这可能导致长时间运行、基于共识的应用程序出现误报和/或进展停滞。例如,Cluster API 本身(我们将在后面介绍)严重依赖于租约以实现健康的功能。如果你将 Cluster API 作为你的 Kubernetes 提供者运行,并且你的管理集群没有健康的 etcd,你可能永远看不到 Kubernetes 集群请求得到满足。即使没有像 Cluster API 这样的集群解决方案,它依赖于 etcd,你仍然会在你的 API 服务器跟上节点状态和接收来自控制器的更新时遇到问题。

kubeadm

对于许多 Kubernetes 提供者来说,kubeadm是默认的安装程序,通常用作 Kubernetes 发行版的构建块。然而,它并没有自带端到端的 etcd 故事。对于生产用例,你需要将你自己的 etcd 数据存储带到kubeadm,而不是使用其默认设置,尽管这些设置是合理的,但可能需要根据可伸缩性要求进行调整或专门设计。例如,你可能想要创建一个具有专用磁盘驱动器的外部 etcd 集群,并将其作为输入发送到你的kubeadm安装。

12.1.3 示例:对 etcd 进行快速健康检查

我们从查看 Prometheus 中的时间序列 fsync 性能开始本章,但在生产环境中,你通常没有能力制作漂亮的图表和发表评论。确保 etcd 没有宕机的一种简单方法就是使用etcdctl命令行工具,该工具包含一个内置的性能测试。例如,先通过ssh(如果你在kind集群上,可以使用docker exec)进入运行 etcd 的集群节点。然后运行find来追踪etcdctl二进制文件所在的位置:

$> find / -name etcdctl # This is obviously a bit of a hack,
                        # but will likely work on any machine
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/
  snapshots/13/fs/usr/local/bin/etcdctl

从这里开始,使用这个二进制文件来配置 etcd。发送必要的cacert文件,如果使用kind或基于 Cluster API 的集群,这些文件可能位于/etc/kubernetes/pki/

$> /var/lib/containerd/io.containerd.snapshotter.v1
➥ .overlayfs/snapshots/13/fs/usr/local/bin/etcdctl \
 --endpoints="https://localhost:2379" \
 --cacert="/etc/kubernetes/pki/etcd/ca.crt" \
 --cert="/etc/kubernetes/pki/etcd/server.crt" \
 --key="/etc/kubernetes/pki/etcd/server.key" \
   check perf
 0 / 60 B
60 / 60 Booooooooooo...ooooooo!
        100.00% 1m0s
PASS: Throughput is 150 writes/s
PASS: Slowest request took 0.200639s
PASS: Stddev is 0.017681s
PASS

这告诉我们,作为基准,etcd 对于生产使用来说足够快。这究竟意味着什么以及为什么它很重要将在本章的其余部分中举例说明。

12.1.4 etcd v3 与 v2 的比较

如果你使用 1.13.0 之后的任何版本的 Kubernetes,并且使用 etcd v2 或更低版本,你会得到以下错误信息:“etcd2 不再支持作为存储后端。”因此,etcd v2 很可能不是问题所在,你很可能在生产环境中运行的是 etcd v3。这是一个好消息,因为当集群规模变得很大,或者你拥有的云原生工具数量增加时,可能会有数百或数千个客户端依赖于你的 Kubernetes API 服务器。一旦达到这个临界点,你需要 etcd v3:

  • etcd v3 在性能方面比 v2 要好得多。

  • etcd v3 使用 gRPC 以实现更快的交易。

  • 与具有分层结构的键空间相比,etcd v3 具有完全平坦的键空间,以便在可以轻松支持数千个客户端的规模上实现更快的并发访问。

  • etcd v3 观察操作,这是 Kubernetes 控制器的基础,可以在单个 TCP 连接上检查许多不同的键。

我们在其他部分已经讨论了 etcd,所以我们假设你已经知道为什么它很重要。相反,我们将专注于本章目的的 etcd 内部实现的“如何”。

12.2 etcd 作为数据存储

一致性算法从一开始就是分布式系统的一个关键部分。早在 20 世纪 70 年代,Ted Codd 的数据库规则确实在很大程度上是为了简化事务编程的世界,以便任何计算机程序都不必浪费时间解决冗余、重叠或不一致的数据记录。Kubernetes 没有不同。

数据平面的架构决策,通过 etcd 实现,以及控制平面(调度器、控制器管理器和 API 服务器)都是基于“不惜一切代价保持一致性”的原则。因此,etcd 解决了协调全局知识的通用问题。Kubernetes API 服务器背后的核心功能包括

  • 创建键/值对

  • 删除键/值对

  • 观察键(带有可以防止观察不必要获取数据的筛选器)

12.2.1 观察:你能在其他数据库上运行 Kubernetes 吗?

Kubernetes 中的观察允许你“观察”一个 API 资源——不是几个 API 资源,而是一个。这一点很重要,因为现实世界的 Kubernetes 原生应用程序可能需要执行许多观察来响应新的 Kubernetes 事件。注意,在这里,我们所说的 API 资源是指特定的对象类型,例如 Pods 或 Services。每次你观察一个资源时,你都可以接收影响它的事件(例如,每次在集群中添加或删除新的 Pod 时,你都可以从你的客户端接收事件)。在 Kubernetes 中构建基于观察的应用程序的模式被称为“控制器模式”。我们在这本书中已经提到过:控制器是 Kubernetes 在集群中管理稳态的支柱。

现在,让我们专注于最后一个操作,因为它与其他数据库后端应用程序相比,是 Kubernetes 工作方式的一个关键区分因素。大多数数据库都没有观察操作。我们在这本书中多次提到了观察的重要性。因为 Kubernetes 本身只是一系列控制器循环,用于维护分布式计算机组的稳态,所以需要一个机制来监控期望状态的变化。一些支持观察的数据库,你可能已经听说过,包括

  • Apache ZooKeeper

  • Redis

  • etcd

Raft 协议是一种管理分布式一致性的方式,它是作为 Apache ZooKeeper 使用的 Paxos 协议的后续而编写的。与 Paxos 相比,Raft 更容易推理,并且简单地定义了一种健壮且可扩展的方式来确保分布式计算机组能够就键/值数据库的状态达成一致。简而言之,我们可以这样定义 Raft:

  1. 在一个节点总数为奇数的数据库中,有一个主节点和多个跟随节点。

  2. 客户端请求在数据库上执行写操作。

  3. 服务器接收写请求并将其转发给多个跟随节点。

  4. 一旦一半的跟随节点接收并确认了写请求,服务器就会提交这个请求。

  5. 客户端从服务器接收到成功的写响应。

  6. 如果领导者死亡,跟随节点将选举一个新的领导者,并且这个过程会继续,旧的领导者被从集群中移除。

在上述数据库中,etcd 基于 Raft 协议具有严格的强一致性模型,并且更专门地构建用于协调数据中心,因此成为了 Kubernetes 的选择。尽管如此,在另一个数据库上运行 Kubernetes 也是可行的。etcd 内部没有 Kubernetes 特定的功能。无论如何,Kubernetes 的核心需求是能够监视数据源,以便执行诸如调度 Pod、创建负载均衡器、提供存储等任务。然而,数据库上监视语义的价值仅与正在协调的数据质量相当。

正如之前提到的,etcd v3 能够在单个 TCP 连接上监视多个记录。这种优化使得 etcd v3 成为大型 Kubernetes 集群的有力伴侣。因此,Kubernetes 对其数据库的第二个要求是一致性。

12.2.2 严格的强一致性

假设现在是圣诞节,你正在运行一个为购物网站提供服务的应用程序,该网站需要极高的正常运行时间。现在,假设你的 etcd 节点“认为”你需要为关键服务分配 2 个 Pod,但实际上你需要 10 个。在这种情况下,缩容事件可能会发生,干扰你对该关键应用程序的生产可用性要求。在这种情况下,从正确的 etcd 节点切换到错误的节点的成本高于完全不切换的成本!因此,etcd 是严格一致的。这是通过 etcd 安装背后的关键架构常数实现的:

  • 在 etcd 集群中只有一个领导者,并且该领导者的世界观是 100%正确的。

  • 在 etcd 实例中,奇数多数的实例可以始终投票决定在需要创建新实例以弥补 etcd 节点丢失的情况下,哪个实例是领导者。

  • 在 etcd 的法定多数中,没有写操作可以发生,直到它被共同持久化到法定多数的磁盘上。

  • 如果一个 etcd 节点没有所有事务的最新记录,它将永远不会提供任何数据。这是通过称为 Raft 的共识协议强制执行的,我们将在后面讨论。

  • 任何时刻的 etcd 集群恰好有一个,且只有一个,我们可以向其写入的领导者。

  • 所有 etcd 写入都会在写入操作上阻塞,级联到至少半数 etcd 节点。

12.2.3 fsync 操作使 etcd 保持一致性

fsync 操作会阻塞磁盘写入,这保证了 etcd 的一致性。当你向 etcd 写入数据时,它保证在写入返回之前,实际的磁盘已经被修改。这可能会使某些 API 操作变慢,反过来,这也保证了在 Kubernetes 故障期间,你永远不会丢失关于集群状态的数据。你的磁盘越快(是的,你的磁盘,而不是你的内存或 CPU),fsync 操作就会越快:

  • 在生产集群中,如果你发现 fsync 操作持续时间超过 1 秒,你通常会看到性能下降(或故障)。

  • 在典型的云环境中,你应该期望这个操作在 250 毫秒左右完成。

理解 etcd 如何执行的最简单方法就是查看它的 fsync 性能。让我们快速在一个你可能在本书早期冒险中启动的无数 kind 集群中这样做。在你的终端中运行 docker exec -t -i <kind container> /bin/bash,如下所示:

$ docker ps
CONTAINER ID    IMAGE                  COMMAND
ba820b1d7adb    kindest/node:v1.17.0   "/usr/local/bin/entr..."

$ docker exec -t -i ba /bin/bash

现在,让我们看看 fsync 的速度。etcd 发布 Prometheus 指标以衡量其性能,这些指标可以通过 curl 下载或在 Grafana 等工具中查看。这些指标告诉我们,以秒为单位,阻塞的 fsync 调用需要多长时间。在一个运行在固态硬盘上的本地 kind 集群中,你会发现这确实很快。例如,在一个运行在笔记本电脑上的本地 kind 集群中,你可能会看到如下内容:

root@kind-control-plane:/#
   curl localhost:2381/metrics|grep fsync
 # TYPE etcd_disk_wal_fsync_duration_seconds histogram
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.001"} 1239
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.002"} 2365
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.004"} 2575
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.008"} 2587
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.016"} 2588
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.032"} 2588
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.064"} 2588
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.128"} 2588
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.256"} 2588
etcd_disk_wal_fsync_duration_seconds_bucket{le="0.512"} 2588
etcd_disk_wal_fsync_duration_seconds_bucket{le="1.024"} 2588
etcd_disk_wal_fsync_duration_seconds_bucket{le="2.048"} 2588
etcd_disk_wal_fsync_duration_seconds_bucket{le="4.096"} 2588
etcd_disk_wal_fsync_duration_seconds_bucket{le="8.192"} 2588
etcd_disk_wal_fsync_duration_seconds_bucket{le="+Inf"} 2588
etcd_disk_wal_fsync_duration_seconds_sum 3.181597084000007
etcd_disk_wal_fsync_duration_seconds_count 2588

这输出中的桶告诉我们

  • 2,588 次磁盘写入中有 1,239 次在 0.001 秒内发生。

  • 在 0.008 秒或更短的时间内,发生了 2,587 次或 2,588 次磁盘写入。

  • 有一次写入发生在 0.016 秒内。

  • 没有写入操作超过 0.016 秒。

你会注意到这些桶是指数级分级的,因为一旦你的写入操作超过 1 秒,那就没关系了;你的集群可能已经损坏。这是因为 Kubernetes 中在任何给定时间都可能有许多监视和事件正在触发以完成它们的工作,所有这些工作都依赖于 etcd 的 I/O 速度。

12.3 查看 Kubernetes 到 etcd 的接口

Kubernetes 数据存储接口为我们提供了 Kubernetes 本身用来访问底层数据存储的实体抽象。当然,Kubernetes 数据存储的唯一流行且经过良好测试的实现是 etcd。Kubernetes 的 API 服务器将 etcd 抽象为几个核心操作——CreateDeleteWatchListGetGetToListList——如下代码片段所示:

type Interface interface {
       Create(ctx context.Context, key string, obj, out runtime.Object, ...
       Delete(ctx context.Context, key string,
           out runtime.Object, preconditions...
       Get(ctx context.Context, key string,
           resourceVersion string, objPtr runtime.Object,
       GetToList(ctx context.Context, key string,
          resourceVersion string, p SelectionPredicate, ...
       List(ctx context.Context, key string,
          resourceVersion string, p SelectionPredicate ...
...

接下来,让我们看看 WatchListWatch。这些函数是使 etcd 与其他数据库(尽管其他数据库如 ZooKeeper 和 Redis 也实现了此 API)不同的部分:

WatchList(ctx context.Context, key string, resourceVersion string ...
Watch(ctx context.Context, key string, resourceVersion string, ...

12.4 etcd 的任务是保持事实清晰

严格一致性是 Kubernetes 在生产中的关键组成部分,正如我们从本章中可以看出的。但是,多个数据库节点如何在任何给定时间都拥有对系统的相同视图呢?答案是,简单来说,它们不能。信息传输有速度限制,尽管其速度限制很快,但并非无限快。从写入发生到写入被级联(或备份)到另一个位置之间始终存在延迟。

许多人已经就这个主题撰写了博士论文,我们不会试图详细解释共识和严格写入的理论限制。然而,我们将定义一些具有特定 Kubernetes 动机的概念,这些概念最终取决于 etcd 维护集群一致视图的能力。例如:

  • 你一次只能接受一个新事实,并且这些事实必须流向运行控制平面的单个节点。

  • 在任何给定时间,系统的状态是所有当前事实的总和。

  • Kubernetes API 服务器提供始终与现有事实流 100% 正确的读写操作。

  • 由于任何数据库中的实体可能会随时间变化,因此可能会提供旧版本的记录,并且 etcd 支持版本化条目的概念。

这涉及两个阶段:在特定时间建立领导权,以便将事实提议到事实流中时,所有系统成员都接受该事实,然后将其写入这些成员。这是所谓的 Paxos 一致性算法 的(粗略)版本。

由于前面的逻辑相当复杂,想象一个场景,其中集群领导者不断地相互竞争和剥夺对方。通过“剥夺”,我们是指领导者选举场景减少了 etcd 可用性对于写入的在线时间。如果选举持续发生,那么写入吞吐量就会受到影响。在 Raft 中,我们不是为每个新事务不断地获取领导权锁,而是不断地从单个领导者发送事实。这个领导者可以随时间变化,并且会选举新的领导者。

etcd 确保领导者的不可用不会导致数据库状态不一致,因此,如果在写入尚未传播到集群中 50% 的 etcd 节点之前,领导者在一个事务中死亡,则写入将被中止。这在本章的图 12.1 中有所描述,其中我们的序列图在集群中的第二个节点确认写入后返回写入请求。请注意,第三个 etcd 节点可以在这个时候慢慢更新其自身的内部状态,而不会减慢整体数据库的速度。

当我们考虑 etcd 节点的分布情况时,这一点非常重要,尤其是如果这些节点分布在不同的网络中。在这种情况下,选举可能会更加频繁,因为数据中心之间的互连速度慢,你可能会更频繁地失去领导者。在任何时候,etcd 集群中所有数据库都将有一个所有事务的最新日志。

12.4.1 etcd 预写日志

由于所有事务都写入预写日志(WAL),etcd 是持久的。为了理解 WAL 的重要性,让我们考虑在执行写入时会发生什么:

  1. 客户端向 etcd 服务器发送请求。

  2. etcd 服务器依赖于 Raft 共识协议来写入事务。

  3. Raft 最终确认 Raft 集群中所有成员 etcd 节点已同步 WAL 文件。因此,在 etcd 集群中,数据始终是一致的,即使你在不同时间向不同的 etcd 服务器发送写入。这是因为集群中的所有 etcd 节点最终都有一个关于系统随时间精确状态的单一 Raft 共识。

是的,我们实际上可以负载均衡 etcd 客户端,尽管 etcd 是严格一致的。你可能想知道客户端如何向许多不同的服务器发送写入,而不会在这些位置之间产生潜在的不一致性。原因在于,同样地,etcd 中的 Raft 实现最终能够将写入转发给 Raft 领导者,无论来源如何。只有当领导者是最新的,并且集群中一半的其他节点也是最新的,写入才算完成。

12.4.2 对 Kubernetes 的影响

由于 etcd 实现了 Raft 作为其共识算法,其结果是,在任何时候,我们都能确切地知道所有 Kubernetes 状态信息存储的位置。除非主 etcd 节点已接受写入并将其级联到集群中的大多数其他节点,否则 Kubernetes 中不会修改任何状态。这对 Kubernetes 的影响是,当 etcd 崩溃时,Kubernetes 的 API 服务器在执行其大多数重要操作时,本质上也是关闭的。

12.5 CAP 定理

CAP 定理是计算机科学中的一个开创性理论;你可以在en.wikipedia.org/wiki/CAP_theorem上了解更多关于它的信息。CAP 定理的基本结论是,你无法在数据库中同时拥有完美的一致性、可用性和分区容错性。etcd 选择一致性作为其最重要的特性。其结果是,如果一个 etcd 集群中的单个领导者宕机,那么数据库将不可用,直到新的领导者被选举出来。

相比之下,有一些数据库如 Cassandra、Solr 等,具有更高的可用性和良好的分区;然而,它们不能保证在给定时间内数据库中的所有节点对数据都有一致的观点。在 etcd 中,我们总是对数据库在任何给定时间的确切状态有一个清晰的定义。与 ZooKeeper、Consul 和其他类似的强一致性键/值存储相比,etcd 在大规模下的性能极其稳定,可预测的延迟是其“杀手级”特性:

  • Consul 适用于服务发现,并且通常存储数兆字节的数据。在高规模下,延迟和性能是一个问题。

  • etcd 适用于具有可预测延迟的可靠键/值存储,因为它可以处理数吉字节的数据。

  • ZooKeeper 可以像 etcd 一样使用,通常情况下,需要注意的是它的 API 是低级别的,它不支持版本化条目,并且扩展性稍微困难一些。

这些权衡的理论基础被称为CAP 定理,它规定你必须在这三个选项之间做出选择:数据一致性、可用性和分区性。例如,如果我们有一个分布式数据库,我们需要交换事务信息。我们可以立即严格地这样做,在这种情况下,我们总是会有一个一致的数据记录,直到我们不再这样做。

为什么我们总是在分布式系统中不能始终有一个完美的数据记录?因为机器可能会宕机,当它们这样做时,我们需要一些时间来恢复它们。这个时间不为零的事实意味着需要始终与其他节点保持一致性的多节点数据库有时可能会不可用。例如,事务必须被阻塞,直到其他数据存储能够消费它们。

如果我们决定我们接受某些数据库在特定时间不接收交易(例如,如果发生 netsplit),会发生什么?在这种情况下,我们可能会牺牲一致性。简而言之,我们必须在现实世界中运行的分布式系统中选择两种场景之一,这样当网络速度慢或机器行为异常时,我们或者

  • 停止接收交易(牺牲可用性)

  • 继续接收交易(牺牲一致性)

这个选择的现实(再次,CAP 定理)限制了分布式系统永远“完美”的能力。例如,关系型数据库通常被认为是一致性和可分区的。同时,像 Solr 或 Cassandra 这样的数据库被认为是可分区的和可用的。

CoreOS(一家被 RedHat 收购的公司)设计了 etcd 来管理大量机器,创建了一个键/值存储,可以为所有节点提供对集群期望状态的统一视图。服务器随后可以通过查看 etcd 本身的状态来进行升级。因此,Kubernetes 采用了 etcd 作为 API 服务器的后端,它提供了一个严格一致性的键/值存储,Kubernetes 可以存储集群的所有期望状态。在本章的最后部分,我们将探讨生产环境中 etcd 的一些显著方面,特别是负载均衡、心跳和大小限制。

12.6 客户端级别的负载均衡和 etcd

如前所述,Kubernetes 集群由控制平面组成,API 服务器需要访问 etcd 以响应用户控制平面组件的事件。在前面的请求中,我们使用了curl来获取原始 JSON 数据。虽然方便,但真正的 etcd 客户端需要访问 etcd 集群中的所有成员,以便可以在节点之间进行负载均衡查询:

  • etcd 客户端试图从所有端点获取所有连接,并保持第一个响应的连接打开。

  • etcd 保持与所有提供给 etcd 客户端的端点之一之间的 TCP 连接。

  • 在连接失败的情况下,可能会发生故障转移到其他端点。

这是在 gRPC 中常见的惯用语。它基于使用 HTTPS 心跳请求的模式。

12.6.1 大小限制:需要(不)担心的问题

etcd 本身有大小限制,并不打算扩展到千兆和太字节级别的键/值内容。它的基本用例是分布式系统的协调和一致性(提示:/etc/是 Linux 机器上软件的配置目录)。在生产 Kubernetes 集群中,内存和磁盘大小的粗略但可靠的起点是每个命名空间 10 KB。这意味着一个拥有 1,000 个命名空间的集群可能使用 1 GB 的 RAM 就能合理地工作。然而,由于 etcd 使用大量内存来管理监视,这是其内存需求的主要因素,这个最小估计并不适用。在一个拥有数千个节点的生产 Kubernetes 集群中,你应该考虑使用 64 GB 的 RAM 来高效地服务所有 kubelets 和其他 API 服务器客户端的监视。

etcd 的单独键/值对通常小于 1.5 MB(操作的请求大小通常应低于此)。这在键/值存储中很常见,因为进行碎片整理和存储优化的能力取决于单个值只能占用一定量的固定磁盘空间。然而,这个值可以通过max-request-bytes参数进行配置。

Kubernetes 并没有明确阻止你存储任意大小的对象(例如,包含超过 2 MB 数据的 ConfigMap),但根据你的 etcd 配置,这可能可行或不可行。请记住,这一点尤为重要,因为 etcd 集群的每个成员都拥有所有数据的完整副本,因此无法通过分片来跨数据分片分配数据。

值的大小有限

Kubernetes 并未设计用于存储无限大的数据类型,etcd 亦是如此:两者都旨在处理分布式系统配置和状态元数据中典型的较小键/值对。由于这个设计决策,etcd 有一些出于好意的限制:

  • 较大的请求可以工作,但可能会增加其他请求的延迟。

  • 默认情况下,任何请求的最大大小为 1.5 MiB。

  • 这个限制可以通过 etcd 服务器上的 --max-request-bytes 标志进行配置。

  • 数据库的总大小有限:

    • 默认存储大小限制为 2 GB,并且建议大多数 etcd 数据库保持在 8 GB 以下。

    • etcd 的默认有效载荷最大值为 1.5 MB。由于描述 Pod 的文本量小于一个千字节,除非你正在创建一个 CRD 或其他比正常 Kubernetes YAML 文件大一千倍的对象,否则这不应该影响你。

我们可以由此得出结论,Kubernetes 本身并不旨在在持久性足迹方面无限增长。这是有道理的。毕竟,即使在 1,000 节点的集群中,如果你每个节点运行 300 个 Pod,并且每个 Pod 使用 1 KB 的文本来存储其配置,你仍然会有不到兆字节的数据。即使每个 Pod 都有 10 个相同大小的 ConfigMap 与之关联,你仍然会低于 50 MB。

你通常不必担心 etcd 的总大小会成为 Kubernetes 性能的限制因素。然而,你确实需要关注监视和负载均衡查询的速度,尤其是如果你有大量应用程序的周转。原因是服务端点和内部路由需要 Pod IP 地址的全局知识,如果这些信息过时,你的集群流量路由能力可能会在生产环境中受到影响。

12.7 etcd 静态加密

如果你已经读到这儿,你现在可能已经意识到 etcd 内部有大量信息,如果遭到破坏,可能导致企业级灾难场景。确实,像数据库密码这样的机密信息通常存储在 Kubernetes 中,并且最终存储在 etcd 中。

由于 API 服务器与其各种客户端之间的流量已知是安全的,因此从 Pod 中窃取秘密的能力通常仅限于那些已经访问到正在积极审计和记录的kubectl客户端的人(因此,很容易追踪)。etcd 本身,尤其是由于其单节点特性,可以说是 Kubernetes 集群中任何黑客最有价值的目标。让我们看看 Kubernetes API 是如何处理加密主题的:

  • Kubernetes API 服务器本身是加密感知的;它接受一个参数,描述了应该加密哪些类型的 API 对象(至少,这通常包括 Secrets)。这个参数是--encryption-provider-config

  • --encryption-provider-config的值是一个包含 API 对象类型(例如,Secrets)和加密提供者列表的字段 YAML 文件。这里有三种:AES-GCM、AES-CBC 和 Secret Box。

  • 之前列出的提供者按降序尝试解密,列表中的第一个提供者用于加密。

因此,Kubernetes API 服务器本身是管理 Kubernetes 集群中 etcd 安全性的最重要的工具。etcd 是你更大的 Kubernetes 故事中的一个工具,并且重要的是不要像对待高度安全的商业数据库那样去思考它。尽管在未来,加密技术可能会发展到 etcd 本身,但就目前而言,在加密驱动器上存储 etcd 数据和在客户端端直接加密是保护其数据的最有效方式。

12.8 etcd 在全局范围内的性能和容错性

etcd 的全局部署指的是你可能希望以地理复制的方式运行 etcd。理解这可能会带来的后果需要重新审视 etcd 的写操作方式。

回想一下,etcd 本身通过 Raft 协议级联写共识,这意味着在写入正式之前,所有 etcd 节点中超过一半需要接受该写入。如前所述,在 etcd 中,共识是任何规模下最重要的属性。默认情况下,etcd 被设计为支持本地部署而不是全局部署,这意味着你必须调整 etcd 以支持全局规模部署。因此,如果你有分布在不同网络上的 etcd,你将不得不调整其多个参数,以便

  • 领导选举更加宽容

  • 心跳间隔较少

12.9 高度分布式 etcd 的心跳时间

如果你正在运行一个跨多个数据中心分布的单个 etcd 集群,你应该怎么做?在这种情况下,你需要改变你对写入吞吐量的预期,这将大大降低。根据 etcd 自己的文档,“美国大陆的合理往返时间为 130ms,美国和日本之间的大致时间为 350-400ms。”(有关此信息的详细信息,请参阅etcd.io/docs/v3.4/tuning/。)

根据这个时间框架,我们应该使用更长的心跳间隔以及更长的领导者选举超时来启动 etcd。当心跳太快时,你会浪费 CPU 周期在网络上发送冗余的维护数据。当心跳太长时,新领导者可能需要被选中的可能性更高。以下是如何为地理分布式部署设置 etcd 的选举设置的示例:

$ etcd --heartbeat-interval=100 --election-timeout=500

12.10 在 kind 集群上设置 etcd 客户端

在运行中的 Kubernetes 环境中访问 etcd 的一个较为复杂的问题仅仅是安全地进行查询。为了解决这个问题,以下 YAML 文件(最初从 mauilion.dev 获取)可以用来快速创建一个 Pod,我们可以使用它来执行 etcd 客户端命令。例如,将以下内容写入一个文件(命名为 cli.yaml),并确保你有一个 kind 集群正在运行(或任何其他 Kubernetes 集群)。你可能需要根据你的 etcd 安全凭证的位置修改 hostPath 的值:

apiVersion: v1
kind: Pod
metadata:
  labels:
    component: etcdclient
    tier: debug
  name: etcdclient
  namespace: kube-system
spec:
  containers:
  - command:
    - sleep
    - "6000"
    image: ubuntu       ❶
    name: etcdclient
    volumeMounts:
    - mountPath: /etc/kubernetes/pki/etcd
      name: etcd-certs
    env:
    - name: ETCDCTL_API
      value: "3"
    - name: ETCDCTL_CACERT
      value: /etc/kubernetes/pki/etcd/ca.crt
    - name: ETCDCTL_CERT
      value: /etc/kubernetes/pki/etcd/healthcheck-client.crt
    - name: ETCDCTL_KEY
      value: /etc/kubernetes/pki/etcd/healthcheck-client.key
    - name: ETCDCTL_ENDPOINTS
      value: "https://127.0.0.1:2379"
  hostNetwork: true
  volumes:
  - hostPath:
      path: /etc/kubernetes/pki/etcd
      type: DirectoryOrCreate
    name: etcd-certs

❶ 如果你想要使用 etcdctl 查询 API 服务器而不是 curl,请将此镜像名称替换为 etcd 服务。

在一个实时集群中使用这样的文件是快速轻松地设置一个可以用来查询 etcd 的容器的简单方法。例如,你可以运行以下代码片段中的命令(在运行 kubectl exec -t -i etcdclient -n kube-system /bin/sh 以打开 bash 终端之后):

#/ curl --cacert /etc/kubernetes/pki/etcd/ca.crt \
--cert /etc/kubernetes/pki/etcd/peer.crt \
--key /etc/kubernetes/pki/etcd/peer.key https://127.0.0.1:2379/health

要返回 etcd 的健康状态或获取 etcd 导出的各种 Prometheus 指标,请运行以下命令:

#/ curl
--cacert /etc/kubernetes/pki/etcd/ca.crt \
--cert /etc/kubernetes/pki/etcd/peer.crt \
--key /etc/kubernetes/pki/etcd/peer.key \
  https://127.0.0.1:2379/metrics

12.10.1 在非 Linux 环境中运行 etcd

在撰写本文时,etcd 可以在 macOS 和 Linux 上运行,但在 Windows 上并不完全受支持。因此,支持多个操作系统(即具有 Linux 和 Windows 节点的 Kubernetes 集群)的 Kubernetes 集群通常有一个完全基于 Linux 的管理集群控制平面,该控制平面在一个 Pod 中运行 etcd。此外,API 服务器、调度器和 Kubernetes 控制管理器也仅在 Linux 上受支持,尽管它们也具备在必要时在 macOS 上运行的能力。因此,尽管 Kubernetes 能够支持非 Linux 操作系统的工作负载(主要是指你可以运行 Windows kubelet 来运行 Windows 容器),但你可能仍然需要在任何 Kubernetes 部署中使用 Linux 操作系统来运行 API 服务器、调度器和控制管理器(当然,还有 etcd)。

摘要

  • etcd 几乎是今天运行的所有 Kubernetes 集群的配置大管家。

  • etcd 是一个开源数据库,在整体使用模式上与 ZooKeeper 和 Redis 同属一家族。它并不适用于大型数据集或应用程序数据。

  • Kubernetes API 抽象了 etcd 支持的五个主要 API 调用,最重要的是,它包括了监视单个项目或列表的能力。

  • etcdctl 是一个强大的命令行工具,用于检查键值对,以及在对集群中某个节点进行压力测试和诊断问题时使用。

  • etcd 对事务的默认约束为 1.5 MB,在大多数常见场景下通常小于 8 GB。

  • etcd,就像 Kubernetes 集群的其他控制平面元素一样,实际上仅支持 Linux,因此,这是大多数 Kubernetes 集群(即使是那些倾向于运行 Windows 工作负载的集群)至少包含一个 Linux 节点的原因之一。

13 容器和 Pod 安全性

本章涵盖

  • 检查安全基础知识

  • 探索容器安全最佳实践

  • 使用安全上下文和资源限制约束 Pod

如果我们试图在安全的建筑中保护我们的计算机,将其锁在受保护的保险库中,放在法拉第笼内,使用生物识别登录,不连接到互联网……,将这些预防措施加起来,它们仍然不足以确保我们的计算机真正安全。作为 Kubernetes 实践者,我们需要根据我们的业务需求做出合理的安全决策。如果我们把所有的 Kubernetes 集群锁在法拉第笼中,断开与互联网的连接,我们将使我们的集群无法使用。但如果我们不关注安全,就会让像比特币矿工这样的人随意进入并入侵我们的集群。

随着 Kubernetes 的成熟和更广泛的应用,Kubernetes 中的常见漏洞和暴露(CVE)变得频繁发生。以下是关于安全性的思考方式:你的系统被黑客攻击的风险!当你被黑客攻击时,需要问的问题是

  • 他们可以获取什么?

  • 他们能做什么?

  • 他们可以获取哪些数据?

安全性是一系列权衡,通常是艰难的决定。通过使用 Kubernetes,我们引入了一个系统,当人们意识到它可以通过一个 API 调用创建一个面向互联网的负载均衡器时,可能会感到害怕。但通过利用简单和基本的做法,我们可以减少安全风险的影响。诚然,大多数公司并没有为基本的安全措施做规划,例如对容器进行安全更新。

安全性是一种平衡行为;它可能会减缓业务发展,但企业只有在全速运转时才能繁荣。安全性固然重要,但过度追求安全性可能会导致资金浪费,并减缓业务和组织的发展。我们必须在可以自动化的实际安全措施和开始深入探索时做出判断。

你不需要自己构建所有的安全预防措施。有一套不断增长的工具可以跟踪 Kubernetes 内运行的容器,并确定可能存在的安全漏洞;例如,Open Policy Agent(OPA),我们将在第十四章中介绍。然而,最终,互联网上的计算机仅仅是不安全的。

正如我们在本书第一章中讨论的那样,DevOps 和 Kubernetes 都是建立在自动化基础上的。在本章中,我们将讨论您需要做什么来自动化 Kubernetes 的安全性。首先,以下部分将介绍一些安全概念,以便我们进入正确的思维模式。

注意:尽管以下两章可能是一整本书的内容,但我们的目的是使这些章节成为一本实用的手册,而不是一本权威指南。当你理解了这本手册后,你就可以更深入地了解每个主题。

13.1 爆炸半径

当某物爆炸时,爆炸半径是从爆炸中心到爆炸边缘的距离。现在,这种爆炸是如何应用到计算机安全(compusec 或网络安全)中的呢?当计算机系统被入侵时,会发生爆炸,通常不止一种方式。假设你在多个节点上运行多个容器,每个容器都有多个安全组件。爆炸会蔓延多远?

  • 被入侵的 Pod 能否访问另一个 Pod?

  • 被入侵的 Pod 能否用来创建另一个 Pod?

  • 被入侵的 Pod 能否用来控制一个节点?

  • 你能从 node01 跳转到 node02 吗?

  • 你能从 Pod 访问外部数据库吗?

  • 你能进入,比如说,一个 LDAP 系统吗?

  • 黑客能否进入你的源代码控制或机密信息?

将安全侵入视为大爆炸的零点。爆炸或侵入蔓延的距离就是爆炸半径。然而,通过实施简单的安全标准,例如不作为 root 运行进程或使用 RBAC,我们可以在东西爆炸时限制其距离。

13.1.1 漏洞

一个 漏洞 是指某处存在弱点。(大坝上有一个裂缝。)在安全方面,我们总是试图防止漏洞(或大坝上的裂缝)而不是修补它。接下来的两章将安排从内到外覆盖 Kubernetes 的漏洞,以及我们如何加强安全。

13.1.2 侵入

一个 侵入 是我们都不希望的事情!它是一种入侵,攻击者利用漏洞并进入我们的系统。例如,一个恶意行为者(入侵者)控制了一个 Pod,并有权访问curlwget。然后入侵者创建了一个以 root 身份在主机网络上运行的另一个 Pod。现在你有一个完全被入侵的集群。

13.2 容器安全

在 Kubernetes 安全方面,最明显的地方是从容器级别开始,因为毕竟,每个 Kubernetes 集群几乎都保证在几个容器中运行。尽管你可以保护集群,但你的前线是你的容器。记住,运行在容器内的自定义软件容易受到攻击是明智的。

当你运行一个应用程序并将其向世界开放时,人们可能是恶意的。例如,有一次我们注意到某个节点的 CPU 级别变得疯狂。一位开发者部署了一个有已知错误的应用程序!比特币矿工利用这个 Pod 进行挖矿,这一切都在几个小时内发生。永远不要忘记,尽管你可以使你的容器尽可能安全,但如果你运行一个有关键问题的软件应用程序,你将使自己容易受到攻击。黑客找到集群可能只需要几分钟,将比特币矿工放入运行有已知 CVE 的软件容器中可能只需要几秒钟。考虑到这一点,让我们概述一些保护容器的最佳实践。

13.2.1 计划更新容器和自定义软件

更新是我们一直看到公司没有做的事情。坦白说,这很可怕,比你看过的最糟糕的恐怖电影还要可怕。大公司泄露数据,因为他们没有更新软件依赖项,甚至没有彻底更新基础镜像。

最好在软件管道中早期就构建更新能力,以防出现安全漏洞。如果你遇到阻力,你可以温和地提醒推动者与泄露客户信息的公司相关的负面宣传。此外,泄露通常会使公司损失数百万美元。

当发布新的基础容器版本和新 CVE 时更新你的容器。CVE 计划包含了当这些问题被发现时创建的网络安全漏洞通知。你可以在 cve.mitre.org/ 上审查这些问题。此外,计划更新自定义软件依赖项。你需要不仅更新围绕软件的容器,还要更新软件本身。

13.2.2 容器筛选

容器筛选是一个报告你的容器是否存在漏洞的系统(例如,想想 2014 年 OpenSSL 包含 Heartbleed 漏洞的情况)。筛选你的镜像的系统不是锦上添花,而是当今环境中的必须。你真的需要筛选容器中的软件漏洞并更新镜像。

软件包括已安装的内容,包括 OpenSSL 和 Bash。不仅必须更新软件,还必须使用 FROM 元素定义基础镜像。这是一项大量工作。我们个人知道这需要多少时间和金钱。设置 CI/CD 和其他工具以快速构建、测试和部署你的容器。许多商业容器注册库包含筛选你的容器的系统,但诚然,如果没有人查看那些通知,那么它们就会被忽略。建立一个系统来监视那些通知,或者获取可以帮助你完成这项工作的商业软件。

13.2.3 容器用户——不要以 root 身份运行

不要在容器内部以 root 用户身份运行。有一种“弹出外壳”的概念,这意味着你逃离了容器的 Linux 命名空间,并可以访问命令行外壳。从外壳中,你可以访问 API 服务器和可能的主机。如果你可以访问外壳,你可能拥有从互联网下载脚本并运行的权限。以 root 用户身份运行会给你在容器中相同的 root 权限,如果弹出容器,可能还有主机系统的 root 权限。

当你定义一个容器并使用 adduser 创建特定的用户和组时,你可以定义一个新的用户。然后,以该用户身份运行你的应用程序。以下是一个来自 Debian 容器的示例,展示了如何创建用户:

$ adduser --disabled-password --no-create-home --gecos '' \
--disabled-login my-app-user

现在,你可以使用该用户运行你的应用程序:

$ su my-app-user -c my-application

在容器内以 root 用户身份运行具有与在主机系统上作为 root 用户操作相同的许多后果:root 就是 root。此外,在 Pod 清单中,您可以定义 runAsUserfsGroup。我们稍后会介绍这两个概念。

13.2.4 使用最小的容器

使用一个专为作为容器运行而构建的轻量级容器操作系统是一个好主意。这限制了容器中的二进制文件数量,从而限制了您的漏洞。像 Google 的 distroless 这样的项目提供了针对特定语言的轻量级容器选项。

将您的运行时容器中的内容限制为仅适用于您的应用程序所必需的内容是 Google 和其他已经使用容器在生产环境中运行多年的技术巨头采用的最佳实践。这提高了扫描器(例如,CVE)的信号与噪声比,并将建立来源的负担仅限于您所需的内容。

—Open Web Application Foundation 的安全备忘录 (mng.bz/g42v)

Google 的 distroless 项目包括一个基础层,以及用于运行不同编程语言的容器,例如 Java。以下是一个使用 golang 容器构建我们的软件的 Go 应用程序示例,随后是一个 distroless 容器:

# Start by building the application.
FROM golang:1.17 as build

WORKDIR /go/src/app
COPY . .

RUN go get -d -v ./...
RUN go install -v ./...

# Now copy it into our base image.
FROM gcr.io/distroless/base
COPY --from=build /go/bin/app /
CMD ["/app"]

然后还有额外的软件。比如说,在创建容器的过程中安装 cURL 来下载二进制文件。cURL 需要被移除。Alpine 发行版通过自动移除构建过程中使用的组件优雅地处理这个问题,但 Debian 和其他发行版则不行。如果您的应用程序不需要它,就不要安装它。安装的越多,可能存在的漏洞数量就越多。即使是示例也遗漏了创建一个新用户来运行二进制文件。只有在必要时才以 root 身份运行。

13.2.5 容器来源

运行 Kubernetes 集群时的一个关键安全问题是了解每个 Pod 内运行的容器镜像,并能够追溯其来源。建立 容器来源 意味着能够将容器的来源追溯到可信赖的起点,并确保您的组织在创建(容器)工件时遵循期望的过程。

不要从您公司不控制的容器注册库部署容器。开源项目很棒,但请在本地构建容器后将其构建和推送到您的仓库中。容器标签不是不可变的;只有 SHA 值是不可变的。没有任何保证说容器实际上就是您认为的那个容器。容器来源允许用户或 Kubernetes 集群确保部署的容器可以被识别,从而保证来源的可靠性。

Kubernetes 团队为 Kubernetes 集群内部运行的所有容器构建了一个镜像基础层。这样做使得团队能够知道该镜像有一个安全的来源,是一致的且经过验证的,并且具有特定的来源。安全的来源还意味着所有镜像都来自已知来源。一致性和验证确保我们在构建镜像时完成特定的步骤,并提供了一个更密封的环境。最后,来源保证了容器的来源是已知的,并且在运行之前不会发生变化。

13.2.6 容器代码检查工具

自动化是减少工作负载和提高系统性能的关键。诚实地讲,安全性是一项大量工作,但您可以使用 hadolint 这样的代码检查工具来查找可能导致安全漏洞的容器和自定义软件中的常见问题。以下是我们过去使用的一些代码检查工具的简要列表:

现在您已经控制了容器安全性,让我们看看下一个级别——Pod。

13.3 Pod 安全性

Kubernetes 允许我们定义 Pod 中用户和 Pod 外 Linux 命名空间(例如,Pod 是否可以在节点上挂载卷?)的权限。Pod 被破坏可能会破坏整个集群!可以使用 nsenter 命令进入 root 进程 (/proc/1),创建一个 shell,并在实际运行被破坏 Pod 的节点上以 root 身份执行。Kubernetes API 允许定义 Pod 权限,并进一步保护 Pod、节点和整个集群。

注意:一些 Kubernetes 发行版,例如 OpenShift,添加了更多的安全层,您可能需要添加更多配置才能使用如安全上下文这样的 API 配置。

13.3.1 安全上下文

记得我们提到过您不应该使用 root 用户运行容器吗?Kubernetes 也允许用户为 Pod 定义一个用户 ID。在 Pod 定义中,您可以指定三个 ID:

  • runAsUser—启动进程所使用的用户 ID

  • runAsGroup—用于进程用户的组

  • fsGroup—用于挂载任何卷和 Pod 进程创建的所有文件的第二个组 ID

如果您的容器以 root 身份运行,您可以强制它以不同的用户 ID 运行。但,再次强调,您不应该有以 root 身份运行的容器,因为您可能会让用户意外遗漏 securityContext 定义。以下 YAML 片段包含了一个具有安全上下文的 Pod:

apiVersion: v1
kind: Pod
metadata:
  name: sc-Pod
spec:
  securityContext:
    runAsUser: 3042                             ❶
    runAsGroup: 4042                            ❷
    fsGroup: 5042                               ❸
    fsGroupChangePolicy: "OnRootMismatch"       ❹
  volumes:
  - name: sc-vol
     emptyDir: {}
  containers:
  - name: sc-container
    image: my-container                         ❺
    volumeMounts:
    - name: sc-vol
      mountPath: /data/foo

❶ 当 Pod 启动时,NGINX 以用户 ID 3042 运行。

❷ 用户 ID 3042 属于组 4042。

❸ 如果 NGINX 进程写入任何文件,它们将以组 ID 5042 写入。

❹ 在将卷挂载到 Pod 之前更改卷的所有权

❺ 一个挂载点

让我们通过使用kind集群来了解这个过程。首先,启动你的集群:

$ kind create cluster

接下来,使用默认容器创建 NGINX 部署:

$ kubectl run nginx --image=nginx

你现在有一个正在运行的 NGINX Pod。接下来,exec进入运行kind集群的 Docker 容器:

$ docker exec -it a62afaadc010 /bin/bash
root@kind-control-plane:/# ps a | grep nginx
2475  0:00 nginx: master process
➥ nginx -g daemon off;                    ❶
2512  22:36   0:00 nginx: worker process

❶ NGINX 的进程是以 root 身份启动的。

我们可以看到 NGINX 进程是以 root 身份运行的,是的,这不是最安全的。为了防止这种情况,清理环境并启动另一个 Pod。下一个命令删除了 NGINX Pod:

$ kubectl delete po nginx

现在,使用以下命令创建具有安全上下文的 Pod:

$ cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: sc-Pod
spec:
  securityContext:
    runAsUser: 3042
    runAsGroup: 4042
    fsGroup: 5042
  volumes:
  - name: sc-vol
    emptyDir: {}
  containers:
  - name: sc-container
    image: nginx
    volumeMounts:
    - name: sc-vol
      mountPath: /usr/share/nginx/html/
EOF

猜猜会发生什么?Pod 启动失败。查看日志:

$ kubectl logs sc-Pod

此命令输出

/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to
➥ perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/
➥ 10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: error: can not modify /etc/nginx/conf.d/
➥ default.conf (read-only file system?)
/docker-entrypoint.sh: Launching /docker-entrypoint.d/
➥ 20-envsubst-on-templates.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
2020/11/08 22:44:59 [warn] 1#1: the "user" directive makes sense only if
➥ the master process runs with super-user privileges, ignored
➥ in /etc/nginx/nginx.conf:2
nginx: [warn] the "user" directive makes sense only if the master process
➥ runs with super-user privileges, ignored in
➥ /etc/nginx/nginx.conf:2
2020/11/08 22:44:59 [emerg] 1#1: mkdir() "/var/cache/nginx/client_temp"
➥ failed (13: Permission denied)
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed
➥ (13: Permission denied)

这里的问题是什么?为什么它不起作用?问题是 NGINX 需要特定的配置才能以非 root 用户身份运行,而许多应用程序需要配置才能以 root 身份运行。对于这个特定的情况,请查看mng.bz/ra4Zmng.bz/Vl4O以获取更多信息。

吸取的教训是:不要以 root 身份运行你的容器,并使用安全上下文来确保它们不以 root 身份运行。

提示:SSL 证书很麻烦,使用这些证书的代码可能同样困难。你经常会遇到代码检查证书的用户是否与进程 ID 用户匹配的问题。当你将 TLS 证书作为 Secret 挂载时,这不会使用fsGroup,这就会造成混乱。Kubernetes 的一个当前限制是当挂载的 Secret 与fsGroup不匹配时。

13.3.2 提升的权限和能力

在 Linux 安全模型中,传统的 UNIX 权限分为两个类别,描述了一个进程——特权和非特权:

  • 一个特权用户是 root 用户或其有效用户 ID 为零的用户,因为我们可以使用sudo来充当 root。

  • 一个无特权用户是一个 ID 不是零的用户。

当你是一个特权用户时,Linux 内核会绕过所有 Linux 权限检查。这就是为什么你可以作为 root 运行令人恐惧的命令rm -rf /。现在大多数发行版至少会询问你是否要删除整个文件系统。当你拥有无特权访问权限时,所有安全权限检查实际上都是基于进程 ID 的。

当定义一个无特权的用户并给他们一个 ID 时,你能够为用户提供能力。这些能力赋予无特权用户执行某些操作的权限,例如更改文件 UID 和 GID。所有这些能力名称都以CAP为前缀;我们刚才提到的能力是CAP_CHOWN。这在 Linux 中是很好的,但为什么我们关心呢?

记得我们说过不要以 root 用户运行吗?假设我们有一个 Pod,它声称需要做出节点网络 iptables 更改或管理 BPF(伯克利包过滤器),例如一个 CNI 提供者,而我们不想以 root 用户运行这个 Pod。Kubernetes 允许你设置 Pod 的安全上下文,定义用户 ID,然后添加特定能力。以下是一个 YAML 示例:

apiVersion: v1
kind: Pod
metadata:
  name: net-cap
spec:
  containers:
  - name: net-cap
    image: busybox
    securityContext:
      runAsUser: 3042
      runAsGroup: 4042
      fsGroup: 5042
      capabilities:
        add: ["NET_ADMIN", "BPF"]      ❶

❶ 给用户赋予 CAP_NET_ADMIN 和 CAT_BPF 能力

你会注意到我们移除了CAP前缀。CAP_NET_ADMIN变成了NET_ADMIN。我们可以使用 CAP 权限做很多有趣的事情,包括允许 Pod 使用CAP_SYS_BOOT重启节点。此外,在 CAP 权限中存在一个子集,称为CAP_SYS。这些权限非常强大。例如,CAP_SYS_ADMIN基本上设置了 root 权限。

我们有 DaemonSets、Pods 和 Deployments 来管理我们的 Kubernetes 集群,设置 iptables 规则,引导 Kubernetes 组件等等。有如此多的用例。再次强调,当你能的时候,不要以 root 用户运行,而是通过 CAP 权限给予尽可能少的权限。诚然,这并不像我们希望的那样精细。例如,没有挂载文件系统的单个权限。在这种情况下,你应该使用 CAP_SYS_ADMIN。

13.3.3 Pod 安全策略(PSP)

注意:截至 Kubernetes v1.21,PSP 已被弃用,并计划在 v1.25 版本中删除,它们正被 Pod 安全准入(见mng.bz/5QQ4)所取代。我们包括这一部分是因为很多人之前使用过 PSP,在撰写本书时,Pod 安全标准还处于 Beta 测试阶段。

为了强制创建适当的 Pod 安全上下文,你可以定义一个 Pod 安全策略(PSP),该策略强制执行定义的安全上下文设置。像所有其他 Kubernetes 结构一样,PodSecurityPolicy 是一个 API 对象:

apiVersion: policy/v1beta1
kind: PodSecurityPolicy
metadata:
  name: example
spec:
  privileged: false  # Don't allow privileged Pods!
  # The rest fills in some required fields.
  seLinux:
    rule: RunAsAny
  supplementalGroups:
    rule: RunAsAny
  runAsUser:
    rule: RunAsAny
  fsGroup:
    rule: RunAsAny
  volumes:
  - '*'

这个代码片段设置了关于 SELinux、用户等方面的各种任意规则。请注意,你不能简单地使每个容器都安全。如果你查看 hyperkube、网络、存储插件等,你会看到这些是系统级管理基础设施工具,它们执行特权操作(例如,设置 iptables 规则),不能以非特权方式运行。现在,当我们启用 PSP 时,你会发现许多 Pod 可能会失败,并且这可能在不可预测的时间发生。这是因为 PSP 是由准入控制器在准入时实现的。让我们看看 Kubernetes 集群中容器安全审计的生命周期:

  • 第 0 天——Pod 可以做任何事情,包括以 root 用户运行和在主机上创建文件。

  • 第 1 天——开发者基于第 0 天的功能构建容器。

  • 第 2 天——安全审计员拿到了这本书。

  • 第 3 天——RBAC 被添加到集群中。现在 API 调用被限制在管理账户。

  • 第 4 天——PSP 被添加到你的集群中。

  • 第 5 天——一半的节点因维护而关闭。

  • 第 6 天——几个 Pod 没有健康地重启。

第 6 天在 PSP 添加后需要一段时间的原因是,一旦 Pod 死亡,其 PSP 将在生产中进行测试。回顾一下容器的工作方式,一个正在运行的容器已经有一个 PID,已经运行了它需要的任何命令,并且与主机网络设备相关联。因此,在容器运行时更改策略并不能安全地消除威胁向量,反而会阻止它们在新位置被引入。图 9.1 显示了 Kubernetes 的 PSP 过程。

图 13.1 Pod 安全策略(PSP)

这是一个重要的概念,你应该在整个本章的其余部分都记住。你实施的每一项策略可能并不是在纠正过去的错误。暴露的 IP 地址、受损的 SSL 证书和开放的 NFS 挂载在 Kubernetes 数据中心中仍然与在 vSphere 数据中心中一样相关,而且由于你将应用程序容器化,安全规则并没有显著变得更容易实施。

13.3.4 不要自动挂载服务账户令牌

默认情况下,服务账户令牌会自动挂载到 Pod 上。该令牌用于在 Pod 的集群中与 API 服务器进行身份验证。是的,这是不好的!坏到足以让一位作者亲自使用粗话来描述他的厌恶。

这个 API 令牌为什么存在呢?有时 Pod 需要访问 API 服务器;例如,维护数据库的操作员可能需要。这是一个有效的用例。但在现实中,99.999%运行自定义软件的 Pod 不需要访问 API 服务器。因此,你应该禁用默认服务账户令牌的自动挂载。这只需要在 Pod YAML 定义中添加一行即可:

apiVersion: v1
kind: Pod
metadata:
  name: no-sa
spec:
  automountServiceAccountToken: false     ❶

❶ 禁用自动挂载

另一种解决方法是关闭所有 Pod 的默认服务账户自动挂载。服务账户(我们稍后会更多地讨论)还有一个字段automountServiceAccountToken。你可以设置任何服务账户在该字段中默认不挂载。

13.3.5 类似 root 的 Pod

我们已经涵盖了所有的配置,这就像在 Pod 和具有特权的用户、无特权的用户,或者具有某些能力的无特权的用户之间进行平衡。但为什么是这样呢?为什么我们不直接以无特权的用户运行所有的 Pod 呢?因为许多 Kubernetes 组件需要作为系统管理员来操作。

许多 Kubernetes 组件以 root 身份运行,这些 Pod 大多属于网络类别。kubelet 以 root 身份运行,但很少在 Pod 内部运行。在每个节点上,我们都有 CNI Pod 来配置集群的网络,这些 Pod 需要网络能力权限。尽管我们无法避免某些安全漏洞,但你可以通过类似 root 的 Pod 来降低风险,包括操作员:

  • 通过将它们放入kube-system或其他命名空间来限制对这些 Pod 的访问。

  • 给予它们尽可能少的类似 root 的权限。

  • 监控它们。

13.3.6 安全外围

Kubernetes 支持三个其他级别的 Pod 安全,这些安全级别通过模块或其他内置的内核功能利用 Linux 内核功能。这些包括

  • AppArmor—在 Linux 内核模块下运行的配置文件提供进程级别的控制。

  • seccomp—通过 Linux 内核中包含的功能,确保进程只能执行定义的安全调用,否则进程会被 SIGKILL 终止。

  • SELinux—安全增强型 Linux,另一个 Linux 内核模块,提供了包括强制访问控制在内的安全策略。

我们将简要提及这些功能,但不会深入探讨。

如果你是一家 RHEL 商店,那么运行 SELinux 是可以理解的,但诚实地讲,这还是让一位作者头疼。如果你运行的是一个有维护的 AppArmor 配置文件的流行开源数据库或软件组件,那么使用该配置文件可能是有意义的。seccomp 功能非常强大,但维护它需要大量工作。诚实地讲,AppArmor 配置文件和 seccomp 都是复杂的,而复杂性往往会导致脆弱的安全。

总是有一些用例需要另一个级别的进程安全,但就像大多数事情一样,我们试图遵循一些指导原则,主要是:KISS(保持简单,傻瓜),递减回报定律,以及 80/20 规则(在你开始实施这些措施之前,先完成 80%的安全工作)。

摘要

  • 如果我们没有关注安全,就会让人们随意进入并入侵我们的集群。安全是一系列充满艰难决策的权衡,但通过利用简单和基本的做法,我们可以减少安全风险的影响。

  • 你不需要自己实现所有的安全预防措施。有一套不断增长的工具可以跟踪容器并确定可能存在的安全漏洞。

  • Kubernetes 安全的最明显起点是在容器级别。容器来源允许你追踪容器的来源到一个可信的起点。

  • 不要以 root 用户运行你的容器,尤其是如果你的环境使用的是不是由你的组织构建的容器。

  • 要找到可能导致安全漏洞的容器常见问题,运行一个 linter,如 hadolint。

  • 如果你的应用程序不需要它,不要安装额外的软件。安装的越多,你可能存在的漏洞数量就越多。

  • 为了确保单个 Pod 的安全,你应该禁用默认服务账户令牌的自动挂载。或者,你也可以关闭所有 Pod 的默认服务账户自动挂载。

  • 通过CAP权限给进程尽可能少的权限。

  • DevOps 建立在自动化之上,Kubernetes 也是如此,安全自动化是减少工作负载和改进系统的关键。

  • 更新你的容器和软件依赖。

14 节点和 Kubernetes 安全

本章涵盖

  • 节点加固和 Pod 清单

  • API 服务器安全,包括 RBAC

  • 用户身份验证和授权

  • 开放策略代理(OPA)

  • Kubernetes 中的多租户

在上一章中,我们刚刚完成了 Pod 的安全设置;现在我们将讨论 Kubernetes 节点的安全。在本章中,我们将包括更多关于节点安全的信息,这些信息与对节点和 Pod 的可能攻击有关,并提供带有多种配置的完整示例。

14.1 节点安全

在 Kubernetes 中保护节点类似于保护任何其他虚拟机或数据中心服务器。我们将从传输层安全(TLS)证书开始介绍。这些证书允许保护节点,但我们还将探讨与镜像不可变性、工作负载、网络策略等相关的问题。将本章视为一个按需菜单,其中包含你至少应该考虑在生产中运行 Kubernetes 的重要安全主题。

14.1.1 TLS 证书

Kubernetes 中的所有外部通信通常都通过 TLS 进行,尽管这可以配置。然而,TLS 有许多不同的版本。因此,你可以为 Kubernetes API 服务器选择一个加密套件。大多数安装程序或自托管的 Kubernetes 版本将为你处理 TLS 证书的创建。加密套件是一系列算法的集合,总体上允许 TLS 安全地进行。定义 TLS 算法包括

  • 密钥交换——设置一个同意的密钥交换方式,用于加密/解密

  • 身份验证——确认消息发送者的身份

  • 加密——使消息在外部人员无法阅读的情况下隐藏

  • 消息认证——确认消息来自有效源

在 Kubernetes 中,你可能会找到以下加密套件:TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384。让我们来分解一下。在这个字符串中,每个下划线(_)将一个算法与下一个算法分开。例如,如果我们设置 API 服务器中的--tls-cipher-suites为类似

TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256

我们可以在mng.bz/nYZ8上查找这个特定的协议,然后确定通信协议的工作方式。例如:

  • 协议——传输层安全(TLS)

  • 密钥交换——椭圆曲线迪菲-赫尔曼临时(ECDHE)

  • 身份验证——椭圆曲线数字签名算法(ECDSA)

  • 加密——通过加密块链模式(AES 256 CBC)使用 256 位密钥的高级加密标准

这些协议的具体细节超出了本书的范围,但重要的是要注意,你需要监控你的 TLS 安全状态,特别是如果它是由你组织中的更大标准机构设置的,以确认你的 Kubernetes 安全模型与组织中的 TLS 标准相一致。例如,要更新任何 Kubernetes 服务使用的加密套件,在启动时发送tls-cipher-suites参数:

--tls-cipher-suites=TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
➥ TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256

将此添加到你的 API 服务器中可以确保它只通过此密钥套件连接到其他服务。如图所示,你可以通过添加逗号来分隔值以支持多个密钥套件。任何服务的帮助页面都提供了一个全面的套件列表(例如,mng.bz/voZq显示了 Kubernetes 调度器kube-scheduler的帮助)。还应注意,

  • 如果一个 TLS 密钥被发现存在漏洞,你将希望更新 Kubernetes API 服务器、调度器、控制器管理器和 kubelet 中的密钥套件。这些组件以某种方式通过 TLS 提供服务。

  • 如果你的组织不允许某些密钥套件,你应该明确删除这些套件。

注意:如果你过于简化允许进入你的 API 服务器的密钥套件,你可能会面临某些类型的客户端无法连接到它的风险。例如,Amazon ELB 有时会使用 HTTPS 健康检查来确保在转发流量之前端点是可用的,但它们不支持 Kubernetes API 服务器中使用的某些常见 TLS 密钥。AWS 负载均衡器 API 的版本 1 仅支持非椭圆曲线密钥算法,例如TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256。这里的结果可能是灾难性的;你的整个集群将完全无法工作!因为通常会在单个 ELB 后面放置多个 API 服务器,所以请记住,随着时间的推移,TCP 健康检查(而不是 HTTPS)可能更容易管理,特别是如果你需要在 API 服务器上使用特殊的安全密钥的话。

14.1.2 不可变操作系统与修补节点

不可变性是你无法更改的东西。一个不可变的操作系统由只读组件和二进制文件组成,你不能对其进行修补。与其修补和更新软件,不如通过从服务器或云中擦除操作系统来替换整个操作系统,然后删除虚拟机并创建一个新的。Kubernetes 通过允许管理员更容易地将工作负载从节点上移除(因为这是一个内置功能)来简化了不可变操作系统的运行。

与使用你的发行版的包管理器自动应用补丁的系统相比,使用不可变操作系统。拥有一个 Kubernetes 集群消除了定制雪花服务器的概念。这些服务器运行特定的应用程序,并用一个标准化的节点替换它们。作为下一个逻辑步骤,运行不可变操作系统。将漏洞注入可变系统的一种最简单的方法是替换一个常见的 Linux 库。

不可变操作系统是只读的,由于它们是只读的,因此无法进行特定的更改,因为你不能将这些更改写入磁盘,这减少了我们的暴露。使用不可变的发行版消除了这些机会中的许多。一般来说,Kubernetes 控制平面(API 服务器、控制器管理器和调度器)节点将具有

  • kubelet 二进制文件

  • kube-proxy二进制文件

  • containerd 或其他容器运行时可执行文件

  • etcd 的镜像

所有这些功能都是为了快速启动而内置的。同时,Kubernetes 工作节点将拥有相同的组件,除了 etcd。这是一个重要的区别,因为 etcd 在 Windows 环境中不支持运行,但一些用户可能希望运行 Windows 工作节点来运行 Windows Pod。

为 Windows 工作节点构建自定义镜像非常重要,因为 Windows 操作系统不可分发,因此最终用户必须构建 Windows kubelet 镜像,如果他们想使用不可变部署模型。要了解更多关于不可变镜像的信息,您可以评估 Tanzu Community Edition 项目(tanzu.vmware.com/tanzu/community)。该项目旨在为更广泛的社区提供一个“包含电池”的方法来使用不可变镜像,并配合 Cluster API 启用可用的、生产级别的集群。许多其他托管 Kubernetes 服务,包括 Google 的 GKE,也使用不可变操作系统。

14.1.3 隔离容器运行时

容器非常出色,但它们并不能完全隔离进程与操作系统。Docker 引擎(以及其他容器引擎)并没有完全将运行中的容器与 Linux 内核沙箱化。容器与宿主机之间没有强大的安全边界,因此如果宿主机的内核存在漏洞,容器可能能够访问 Linux 内核漏洞并利用它。Docker 引擎利用 Linux 命名空间来隔离进程,例如直接访问 Linux 网络堆栈,但仍然存在漏洞。例如,宿主机的 /sys 和 /proc 文件系统仍然被容器内运行的过程读取。

类似于 gVisor、IBM Nabla、Amazon Firecracker 和 Kata 这样的项目提供了一种虚拟 Linux 内核,它将容器进程与宿主机的内核隔离开来,从而提供了一个更真实的沙箱环境。这些项目在开源领域相对较新,至少目前还没有在 Kubernetes 环境中得到广泛使用。这些只是其中一些相当成熟的项目,因为 gVisor 被用作 Google Cloud Platform 的一部分,而 Firecracker 被用作 Amazon Web Services 平台的一部分。也许在你阅读这篇文章的时候,更多的 Kubernetes 集群容器将运行在虚拟内核之上!我们甚至可以思考启动微虚拟机作为 Pod。我们正生活在有趣的时代!

14.1.4 资源攻击

Kubernetes 节点拥有有限的资源,包括 CPU、内存和磁盘。我们在集群上运行了许多 Pod、kube-proxy、kubelets 以及其他 Linux 进程。节点通常有一个 CNI 提供商、一个日志守护进程以及其他支持集群的进程。您需要确保 Pod 中的容器不会耗尽节点资源。如果您不提供限制,那么一个容器可能会耗尽节点资源,影响所有其他系统。本质上,一个 失控的容器进程 可以对一个节点执行拒绝服务(DoS)攻击。这就是资源限制的用武之地……

资源限制通过实现三个不同的 API 级对象和配置来控制。Pod API 对象可以具有控制每个限制的设置。例如,以下 YAML 段落提供了 CPU、内存和磁盘空间使用限制:

apiVersion: v1
kind: Pod
metadata:
  name: core-kube-limited
spec:
  containers:
  - name: app
    image:
    resources:
      requests:                   ❶
       memory: "42Mi"
        cpu: "42m"
        ephemeral-storage: "21Gi"
      limits:                     ❷
       memory: "128Mi"
        cpu: "84m"
        ephemeral-storage: "42Gi"

❶ 提供初始的 CPU、内存或存储量

❷ 设置允许的最大 CPU、内存和存储量

在安全方面,如果这些值中的任何一个超过了限制,Pod 将会被重启。而且,如果再次超过限制,那么 Pod 将被终止,并且不会再次启动。

另一个有趣的事情是,资源 requestslimits 也会影响 Pod 在节点上的调度。托管 Pod 的节点必须具有满足调度器选择托管 Pod 的节点的初始请求的资源。您可能会注意到我们正在使用单位来表示请求和限制内存、CPU 和临时存储值。

14.1.5 CPU 单位

要测量 CPU,Kubernetes 使用的基准单位是 1,这相当于裸金属上的一个超线程或在云中的单个核心/vCPU。您也可以用小数表示 CPU 单位;例如,您可以有 0.25 个 CPU 单位。此外,API 还允许您将 0.25 个十进制 CPU 单位转换为 250 m。所有这些段落都适用于 CPU:

resources:
  requests:
    cpu: "42"    ❶
resources:
  requests:
  cpu: "0.42"    ❷
resources:
  requests:
    cpu: "420m"  ❸

❶ 设置 42 个 CPU(这是一个大服务器!)

❷ 0.42 个 CPU,以 1 为单位进行测量

❸ 这与上一个代码块中的 0.42 相同。

14.1.6 内存单位

内存以字节、整数和固定点数进行测量,使用以下后缀:EPTGMK。或者您可以使用 EiPiTiGiMiKi,它们代表二进制的等效值。以下段落大致都是相同的值:

resources:
  requests:
    memory: "128974848"    ❶
resources:
  requests:
    memory: "129e6"        ❷

❶ 字节纯数字表示(128,974,848 字节)

❷ 129e6 有时写作 129e+6 的科学记数法:129e+6 == 129000000。这个段落代表 129,000,000 字节。

下一个段落处理的是典型的兆比特与兆字节的转换:

resources:
  requests:
    memory: "129M"      ❶

❶ 129 兆比特 == 1.613e+7 字节,接近 129e+6 的值。

接下来是兆字节:

resources:
  requests:
    memory: "123Mi"      ❶

❶ 123 兆字节 == 1.613e+7 字节,接近 129e+6 的值。

14.1.7 存储单位

最新的 API 配置是临时存储请求和限制。临时存储限制适用于三个存储组件:

  • emptyDir 卷,除了 tmpfs

  • 存放节点级日志的目录

  • 可写容器层

当超过限制时,kubelet 会驱逐 Pod。每个节点都配置了最大临时存储量,这再次影响了 Pod 到节点的调度。还有一个用户可以指定特定节点限制的“扩展资源”限制。你可以在 Kubernetes 文档中找到有关扩展资源的更多详细信息。

14.1.8 主机网络与 Pod 网络比较

在第 14.4 节中,我们将介绍 NetworkPolicies。这些政策允许你使用 CNI 提供者锁定 Pod 通信,通常,这些政策为你实现。然而,你应该考虑一种更基本的网络安全类型:不要将 Pod 运行在与你的主机相同的网络上。这立即

  • 限制外部世界对您的 Pod 的访问

  • 限制您的 Pod 对主机网络端口的访问

如果一个 Pod 加入主机网络,将允许 Pod 更容易地访问节点,从而在攻击时增加爆炸半径。如果一个 Pod 不需要在主机网络上运行,请不要在主机网络上运行 Pod!如果 Pod 需要在主机网络上运行,那么请不要将该 Pod 暴露给互联网。以下代码片段是一个在主机网络上运行的 Pod 的部分 YAML 定义。如果 Pod 正在执行管理任务,如日志记录或网络(一个 CNI 提供者),你通常会看到在主机网络上运行的 Pod:

apiVersion: v1
kind: Pod
metadata:
  name: host-Pod
spec:
  hostNetwork: true

14.1.9 Pod 示例

我们已经介绍了不同的 Pod API 配置:服务账户令牌、CPU 和其他资源设置、安全上下文等。以下是一个包含所有配置的示例:

apiVersion: v1
kind: Pod
metadata:
  name: example-Pod
spec:
  automountServiceAccountToken: false   ❶
 securityContext:                       ❷
   runAsUser: 3042
    runAsGroup: 4042
    fsGroup: 5042
    capabilities:
      add: ["NET_ADMIN"]
  hostNetwork: true                     ❸
 volumes:
  - name: sc-vol
     emptyDir: {}
  containers:
  - name: sc-container
    image: my-container
    resources:                          ❹
     requests:
        memory: "42Mi"
        cpu: "42m"
        ephemeral-storage: "1Gi"
      limits:
        memory: "128Mi"
        cpu: "84m"
        ephemeral-storage: "2Gi"
    volumeMounts:
    - name: sc-vol
      mountPath: /data/foo
  serviceAccountName: network-sa        ❺

❶ 禁用服务账户令牌的自动挂载

❷ 设置安全上下文并赋予 NET_ADMIN 能力

❸ 在主机网络上运行

❹ 设置资源限制

❺ 给 Pod 分配一个特定的服务账户

14.2 API 服务器安全

像二进制认证这样的组件使用准入控制器提供的 webhooks。各种控制器是 Kubernetes API 服务器的一部分,并创建一个 webhook 作为事件的入口点。例如,ImagePolicyWebhook 是允许系统响应 webhook 并对容器做出准入决定的插件之一。如果一个 Pod 没有通过准入标准,它将保持挂起状态——它不会被部署到集群中。准入控制器可以验证集群中正在创建的 API 对象,修改或更改这些对象,或者两者都做。从安全角度来看,这为集群提供了大量的控制和审计能力。

14.2.1 基于角色的访问控制(RBAC)

首先,在您的集群上启用基于角色的访问控制(RBAC)是必要的。目前,大多数安装程序和云托管提供商都启用了 RBAC。Kubernetes API 服务器使用 --authorization-mode=RBAC 标志来启用 RBAC。如果您使用的是 Kubernetes 的自托管版本,如 GKE,则 RBAC 已启用。作者确信存在一种边缘情况,即运行 RBAC 无法满足业务需求。然而,在其余 99% 的情况下,您需要启用 RBAC。

RBAC 是一种基于角色的安全机制,它控制用户和系统对资源的访问。它通过角色和权限仅限制授权用户和服务账户对资源的访问。这如何应用于 Kubernetes?您想用 Kubernetes 保护的最关键组件之一是 API 服务器。当系统用户通过 API 服务器对集群具有管理员访问权限时,该用户可以清理节点、删除对象,并造成极大的破坏。在 Kubernetes 中,管理员是在集群上下文中的 root 用户。

RBAC 是一个强大的安全组件,它提供了在集群内限制 API 访问的巨大灵活性。因为它是一个强大的机制,所以它也有通常的副作用,即有时相当复杂且难以调试。

注意:在 Kubernetes 中运行的平均 Pod 不应具有访问 API 服务器的权限,因此您应该禁用服务账户令牌的挂载。

14.2.2 RBAC API 定义

RBAC API 定义了以下类型:

  • Role—包含一组权限,限制在命名空间内

  • ClusterRole—包含一组集群范围内的权限

  • RoleBinding—将角色授予用户或组

  • ClusterRole—将 ClusterRole 授予用户或组

在 Role 和 ClusterRole 定义中,有几个定义的组件。我们将首先介绍的是 动词,它包括 API 和 HTTP 动词。API 服务器内的对象可以有一个 get 请求;因此,有 get 请求动词定义。我们经常从创建 REST 服务时定义的创建、读取、更新和删除(CRUD)动词的角度来考虑这个问题。您可以使用的动词包括

  • 资源请求的 API 请求动词—get、list、create、update、patch、watch、proxy、redirect、delete 和 deletecollection

  • 非资源请求的 HTTP 请求动词—get、post、put 和 delete

例如,如果您想使操作员能够监视和更新 Pods,您可以

  1. 定义资源(在这种情况下,是一个 Pod)

  2. 定义角色可以访问的动词(最可能是 list 和 patch)

  3. 定义 API 组(使用空字符串表示核心 API 组)

你已经熟悉 API 组了,因为它们是出现在 Kubernetes 清单中的 apiVersionkind。API 组遵循 API 服务器中的 REST 路径(/apis/\(GROUP_NAME/\)VERSION)并使用 apiVersion $GROUP_NAME/$VERSION(例如,batch/v1)。不过,让我们保持简单,暂时不处理 API 组。我们将从核心 API 组开始。以下是一个特定命名空间的角色的示例。由于角色仅限于命名空间,这提供了对 Pod 资源执行列表和补丁动词的访问权限:

# Create a custom role in the default namespace that grants access to
# list, and patch Pods
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: Pod-labeler
  namespace: rbac-example
rules:
- apiGroups: [""] # "" refers to the core API group
  resources: ["Pods"] # the
  verbs: ["list", "patch"] # authorization to use list and patch verbs

对于前面的示例,我们可以定义一个服务账户来使用该片段中的角色,如下所示:

# Create a ServiceAccount that will be bound to the role above
apiVersion: v1
kind: ServiceAccount
  metadata:
    name: Pod-labeler
    namespace: rbac-example

之前的 YAML 创建了一个服务账户,该账户可以被 Pod 使用。接下来,我们将创建一个角色绑定,将之前定义的服务账户与之前定义的角色连接起来:

# Binds the Pod-labeler ServiceAccount to the Pod-labeler Role
# Any Pod using the Pod-labeler ServiceAccount will be granted
# API permissions based on the Pod-labeler role.
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: Pod-labeler
  namespace: rbac-example
subjects:
  # List of service accounts to bind
- kind: ServiceAccount
  name: Pod-labeler
roleRef:
  # The role to bind
  kind: Role
  name: Pod-labeler
  apiGroup: rbac.authorization.k8s.io

现在,你可以在分配给该 Pod 的服务账户的部署中启动 Pod:

# Deploys a single Pod to run the Pod-labeler code
apiVersion: apps/v1
kind: Deployment
metadata:
  name: Pod-labeler
  namespace: rbac-example
spec:
  replicas: 1

  # Control any Pod labeled with app=Pod-labeler
  selector:
    matchLabels:
      app: Pod-labeler

  template:
    # Ensure created Pods are labeled with app=Pod-labeler
    # to match the deployment selector
    metadata:
      labels:
        app: Pod-labeler

    spec:
      # define the service account the Pod uses
      serviceAccount: Pod-labeler

      # Another security improvement, set the UID and GID the Pod runs with
      # Pod-level security context to define the default UID and GIDs
      # under which to run all container processes. We use 9999 for
      # all IDs because it is unprivileged and known to be unallocated
      # on the node instances.
      securityContext:
        runAsUser: 9999
        runAsGroup: 9999
        fsGroup: 9999

      containers:
      - image: gcr.io/pso-examples/Pod-labeler:0.1.5
        name: Pod-labeler

让我们回顾一下。我们创建了一个具有修补和列出 Pod 权限的角色。然后,我们创建了一个服务账户,以便我们可以创建 Pod 并让该 Pod 使用定义的用户。接下来,我们定义了一个角色绑定,将服务账户添加到之前定义的角色中。最后,我们启动了一个包含定义 Pod 的部署,该 Pod 使用之前定义的服务账户。

RBAC(基于角色的访问控制)虽然不复杂,但对 Kubernetes 集群的安全性至关重要。前面的 YAML 是从位于 mng.bz/ZzMa 的 Helmsman RBAC 演示中提取的。

14.2.3 资源和子资源

大多数 RBAC 资源使用单个名称,如 Pod 或 Deployment。一些资源有子资源,如下面的代码片段所示:

GET /api/v1/namespaces/rbac-example/Pods/Pod-labeler/log

此 API 端点表示 rbac-example 命名空间中名为 Pod-labeler 的 Pod 的子资源日志路径。定义如下:

GET /api/v1/namespaces/{namespace}/Pods/{name}/log

为了使用日志的子资源,你需要定义一个角色。以下是一个示例:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: rbac-example
  name: Pod-and-Pod-logs-reader
rules:
- apiGroups: [""]
  resources: ["Pods", "Pods/log"]
  verbs: ["get", "list"]

你还可以通过命名 Pod 来进一步限制对 Pod 日志的访问。例如:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: rbac-example
  name: Pod-labeler-logs
rules:
- apiGroups: [""]
  resourceNames: ["Pod-labeler"]
  resources: ["Pods/log"]
  verbs: ["get"]

注意到之前的 YAML 中的 rules 元素是一个数组。以下代码片段显示了如何向 YAML 添加多个权限。resourcesresourceNamesverbs 可以是任何组合:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: rbac-example
  name: Pod-labeler-logs
rules:
- apiGroups: [""]
  resourceNames: ["Pod-labeler"]
  resources: ["Pods/log"]
  verbs: ["get"]
- apiGroups: [""]
  resourceNames: ["another-Pod"]
  resources: ["Pods/log"]
  verbs: ["get"]

资源类似于 Pods 和节点,但 API 服务器还包括不是资源的元素。这些元素由 API REST 端点的实际 URI 组件定义;例如,给 Pod-labeler-logs RBAC 角色访问 /healthz API 端点,如下面的代码片段所示:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: rbac-example
  name: Pod-labeler-logs
rules:
- apiGroups: [""]
  resourceNames: ["Pod-labeler"]
  resources: ["Pods/log"]
  verbs: ["get"]
- apiGroups: [""]
  resourceNames: ["another-Pod"]
  resources: ["Pods/log"]
  verbs: ["get"]
- nonResourceURLs: ["/healthz", "/healthz/*"]    ❶
  verbs: ["get", "post"]

❶ 非资源 URL 中的星号 (*) 是后缀通配符匹配。

14.2.4 主题和 RBAC

角色绑定可以包括 Kubernetes 中的用户、服务账户和组。在以下示例中,我们将创建另一个名为 log-reader 的服务账户,并将该服务账户添加到上一节中的角色绑定定义中。在示例中,我们还有一个名为 james-bond 的用户和一个名为 MI-6 的组:

kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: Pod-labeler
  namespace: rbac-example
subjects:
  # List of service accounts to bind
- kind: ServiceAccount
  name: Pod-labeler
- kind: SeviceAccount
  name: log-reader
- kind: User
  name: james-bond
- kind: Group
  name: MI-6
roleRef:
  # The role to bind
  kind: Role
  name: Pod-labeler
  apiGroup: rbac.authorization.k8s.io

注意:用户和组是由为集群设置的认证策略创建的。

14.2.5 调试 RBAC

虽然 RBAC 很复杂,使用起来也很痛苦,但我们始终有审计日志。当启用时,Kubernetes 会记录影响集群的安全事件的审计跟踪。这些事件包括用户操作、管理员操作和/或集群内的其他组件。基本上,如果你使用了 RBAC 和其他安全组件,你会得到“谁”、“什么”、“从哪里”和“如何”的信息。审计日志是通过一个审计策略文件配置的,该文件通过kube-apiserver --audit-policy-file传递到 API 服务器。

因此,我们有了所有事件的日志——太棒了!但是等等……现在你有一个拥有数百个角色、许多用户和大量角色绑定的集群。现在你必须将所有这些数据结合起来。为此,有几个工具可以帮助我们。这些工具的共同主题是将用于定义 RBAC 访问的不同对象结合起来。为了在集群角色、RoleBindings 和主体中内省基于 RBAC 的权限,需要将主体(可以是用户、组或服务账户)结合起来。

  • ReactiveOps创建了一个工具,允许用户查找用户、组或服务账户当前是成员的角色。rbac-lookup可在github.com/reactiveops/rbac-lookup找到。

  • 另一个可以找到用户或服务账户在集群内具有哪些权限的工具是 kubectl-rbac*。此工具位于github.com/octarinesec/kubectl-rbac

  • Jordan Liggit 有一个名为 audit2rbac*的开源工具。该工具接受审计日志和用户名,创建与请求访问权限相匹配的角色和角色绑定。对 API 服务器进行调用,并可以捕获日志。从那里,你可以运行audit2rbac来生成所需的 RBAC(换句话说,RBAC 反向工程)。

14.3 认证、授权和秘密

Authn(用户认证)和Authz(授权)是认证用户拥有的组和权限。我们在这里也谈论秘密可能看起来有些奇怪,但一些用于认证和授权的工具也用于秘密,并且你通常需要认证和授权来访问秘密。

首先,不要使用在安装集群时生成的默认集群管理证书。你需要一个 IAM(身份和访问管理)服务提供商来认证和授权用户。此外,不要在 API 服务器上启用用户名和密码认证;使用 TLS 证书内置的用户认证功能。

14.3.1 IAM 服务账户:保护你的云 API

Kubernetes 容器具有原生云身份(这些身份了解云)。这既美丽又可怕。如果没有你的云威胁模型,就无法为你的 Kubernetes 集群制定威胁模型。

云 IAM 服务账户构成了安全的基础,包括对人和系统的授权和认证。在数据中心内,Kubernetes 安全配置仅限于 Linux 系统、Kubernetes、网络以及部署的容器。当在云中运行 Kubernetes 时,出现了一个新的问题——节点和 Pods 的 IAM 角色:

  • IAM 是特定用户或服务账户的角色,该角色随后成为某个组的成员。

  • Kubernetes 集群中的每个节点都有一个 IAM 角色。

  • Pods 通常继承该角色。

你集群中的节点,特别是那些运行在控制平面上的节点,需要在云中运行时具有 IAM 角色。这是因为 Kubernetes 中许多原生功能来自于 Kubernetes 本身有一个如何与其云提供商通信的概念。例如,让我们从 GKE 的官方文档中汲取一些启示:Google Cloud Platform 自动创建一个名为计算引擎默认服务账户的服务账户,GKE 将其与它创建的节点关联起来。根据你的项目配置,默认服务账户可能或可能没有权限使用其他云平台 API。GKE 还向计算实例分配了一些有限的访问范围。更新默认服务账户的权限或将更多访问范围分配给计算实例不是从运行在 GKE 上的 Pods 中认证到其他云平台服务的推荐方法。

因此,你的容器在很多情况下具有与节点本身相当的权限。随着云服务逐渐为容器提供更细粒度的权限模型,这种默认设置很可能会在未来得到改善。然而,仍然需要确保 IAM 角色或角色具有最少的适用权限,并且始终存在更改这些 IAM 角色的方法。例如,当在 Google Cloud Platform 中使用 GKE 时,你必须为集群在项目中创建一个新的 IAM 角色。如果不这样做,集群通常会使用计算引擎默认服务账户,该账户具有编辑权限。

Google Cloud 编辑器 允许给定账户(在这种情况下,你的集群中的一个节点,这相当于可能任何 Pod)编辑该项目中任何资源。例如,你只需破坏集群中的某个 Pod,就可以删除整个数据库集群、TCP 负载均衡器或云 DNS 条目。此外,你应该删除你在 GCP 中创建的任何项目的默认服务账户。AWS、Azure 等也存在相同的问题。总之,每个集群都是使用其独特的服务账户创建的,并且该服务账户具有尽可能少的权限。使用 kops(Kubernetes Operations)等工具,我们可以检查 Kubernetes 集群所需的每个权限,然后 kops 为控制平面创建一个特定的 IAM 角色,以及为节点创建另一个角色。

14.3.2 云资源访问

假设你已经以最低权限配置了你的 Kubernetes 节点,现在你已经阅读了这篇文章,你可能感觉安全。实际上,如果你正在运行 AKS(Azure Kubernetes Service)这样的解决方案,你不需要担心配置控制平面,只需关注节点级别的 IAM 即可,但这还不是全部。例如,一个开发者创建了一个需要与托管云服务通信的服务——比如文件存储服务。现在运行的 Pod 需要一个具有正确角色的服务账户。有各种实现这一点的途径。

注意:AKS 可能是解决方案中最简单的,但它确实带来了一些挑战。你需要限制节点上的 Pod,只允许访问云资源,或者接受所有 Pod 现在都将具有文件存储访问的风险。

提示:使用 kube2iam (github.com/jtblin/kube2iam) 或 kiam (github.com/uswitch/kiam) 工具来实现此方法。

一些新创建的操作员可以将特定的服务账户分配给特定的 Pod。每个节点上的组件会拦截对云 API 的调用。它不是使用节点的 IAM 角色,而是将一个角色分配给集群中的 Pod,这通过注解来表示。一些托管云提供商有类似的解决方案。一些云提供商,如 Google,有可以运行并连接到云 SQL 服务的边车。边车被分配一个角色,然后代理连接到数据库的应用程序。

可能最复杂但更稳健的解决方案是使用集中式的密钥库服务器。使用这种方式,应用程序可以检索短期有效的 IAM 令牌,从而允许访问云系统。通常,会使用边车来自动刷新所使用的令牌。我们还可以使用 HashiCorp Vault 来保护非 IAM 凭证的机密信息。如果你的用例需要稳健的机密和 IAM 管理,Vault 是一个绝佳的解决方案,但正如所有关键任务解决方案一样,你需要维护和支持它。

提示:使用 HashiCorp Vault (www.vaultproject.io/)来存储密钥。

14.3.3 私有 API 服务器

在本节中,我们将要讨论的最后一件事是 API 服务器的网络访问。您可以选择使 API 服务器通过互联网不可访问,或者将 API 服务器放在私有网络上。如果您将 API 服务器负载均衡器放在私有网络上,您将需要利用堡垒主机、VPN 或其他形式的连接到 API 服务器,因此这种解决方案并不那么方便。

API 服务器是一个极其敏感的安全点,必须像这样进行保护。DoS 攻击或一般入侵可能会使集群瘫痪。此外,当 Kubernetes 社区发现安全问题时,它们偶尔会存在于 API 服务器中。如果可能,请将您的 API 服务器放在私有网络上,或者至少将能够连接到您的 API 服务器前端负载均衡器的 IP 地址列入白名单。

14.4 网络安全

再次强调,这是一个很少得到适当解决的问题的安全领域。默认情况下,Pod 网络上的 Pod 可以访问集群中任何地方的任何 Pod,这包括 API 服务器。这种能力存在是为了允许 Pod 访问像 DNS 这样的系统进行服务查找。在主机网络上运行的 Pod 几乎可以访问任何东西:所有 Pod、所有节点和 API 服务器。如果启用了端口,主机网络上的 Pod 甚至可以访问 kubelet API 端口。

网络策略是您可以定义以控制 Pod 之间网络流量的对象。NetworkPolicy 对象允许您配置 Pod 的进出访问。进入是指进入 Pod 的流量,而流出是指离开 Pod 的网络流量。

14.4.1 网络策略

您可以在任何 Kubernetes 集群上创建 NetworkPolicy 对象,但您需要一个正在运行的安全提供程序,如 Calico。Calico 是一个 CNI 提供程序,同时也提供了一个单独的应用程序来实现网络策略。如果您在没有提供程序的情况下创建网络策略,该策略将不起作用。网络策略有以下约束和功能。它们是

  • 应用到 Pods

  • 通过标签选择器与特定 Pod 匹配

  • 控制进出网络流量

  • 控制由 CIDR 范围、特定命名空间或匹配的 Pod 或 Pods 定义的网络流量

  • 专门设计用于处理 TCP、UDP 和 SCTP 流量

  • 能够处理命名端口或特定的 Pod 编号

让我们试试这个。为了设置一个kind集群并在其上安装 Calico,首先运行以下命令来创建kind集群,并且不要启动默认的 CNI。Calico 将在之后安装:

$ cat <<EOF | kind create cluster --config -
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
networking:
  # the default CNI will not be installed
  disableDefaultCNI: true
  PodSubnet: "192.168.0.0/16"
EOF

接下来,我们安装 Calico Operator 及其自定义资源。使用以下命令来完成此操作:

$ kubectl create -f \
https://docs.projectcalico.org/manifests/tigera-operator.yaml
$ kubectl create -f \
https://docs.projectcalico.org/manifests/custom-resources.yaml

现在我们可以观察 Pod 启动。使用以下命令:

$ kubectl get Pods --watch -n calico-system

接下来,设置几个命名空间、一个用于提供测试网页的 NGINX 服务器和一个可以运行wget的 BusyBox 容器。为此,请使用以下命令:

$ kubectl create ns web
$ kubectl create ns test-bed
$ kubectl create deployment -n web nginx --image=nginx
$ kubectl expose -n web deployment nginx --port=80
$ kubectl run --namespace=test-bed testing --rm -ti --image busybox /bin/sh

从 BusyBox 容器的命令提示符访问在 web 命名空间中安装的 NGINX 服务器。以下是此命令:

$ wget -q nginx.web -O

现在,安装一个拒绝所有进入 NGINX Pod 的流量的网络策略。使用以下命令:

$ kubectl create -f - <<EOF
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-ingress
  namespace: web
spec:
  PodSelector:
    matchLabels: {}
  policyTypes:
  - Ingress
EOF

此命令创建了一个策略,使得您无法从测试 Pod 访问 NGINX 网页。在测试 Pod 的命令行上运行以下命令。命令将超时并失败:

$ wget -q --timeout=5 nginx.web.svc.cluster.local -O -

接下来,打开从 test-bed 命名空间到 web 命名空间的 Pod 入口。使用以下代码片段:

$ kubectl create -f - <<EOF
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: access-web
  namespace: web
spec:
  PodSelector:
    matchLabels:
      app: nginx
  policyTypes:
    - Ingress
  ingress:
    - from:
      - namespaceSelector:
          matchLabels:
            name: test-bed
EOF

在测试 Pod 的命令行上输入

$ wget -q --timeout=5 nginx.web.svc.cluster.local -O -

您会注意到命令失败了。原因是网络策略匹配标签,而 test-bed 命名空间没有标签。以下命令添加了标签:

$ kubectl label namespaces test-bed name=test-bed

在测试 Pod 的命令行上,检查网络策略现在是否生效。以下是命令:

$ wget -q --timeout=5 nginx.web.svc.cluster.local -O -

对于所有防火墙配置的第一项建议是创建一个拒绝所有规则的规则。此策略拒绝命名空间内的所有流量。运行以下命令并禁用 test-bed 命名空间的所有入口和出口 Pod:

$ kubectl create -f - <<EOF
kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
metadata:
  name: deny-all
  namespace: test-bed
spec:
  PodSelector:
    matchLabels: {}    ❶
 policyTypes:          ❷
 - Ingress
  - Egress
EOF

❶ 匹配命名空间中的所有 Pod

❷ 定义了两种策略类型:入口和出口

现在,实施此策略会产生一些有趣的副作用。不仅 Pod 无法与其他任何东西(除了它们自己的命名空间)通信,而且现在它们无法与 kube-system 命名空间中的 DNS 提供商通信。如果 Pod 不需要 DNS 功能,请不要启用它!让我们应用以下网络策略以启用 DNS 的出口:

$ kubectl label namespaces kube-system name=kube-system
$ kubectl create -f - <<EOF
kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
metadata:
  name: dns-egress
  namespace: test-bed
spec:
  PodSelector:
    matchLabels: {}          ❶
 policyTypes:                ❷
 - Egress
  egress:
  - to:
    - namespaceSelector:     ❸
       matchLabels:
          name: kube-system
    ports:                   ❹
   - protocol: UDP
      port: 53
EOF

❶ 匹配核心 Kubernetes 命名空间中的所有 Pod

❷ 只允许出口

❸ 匹配带有标签的 kube-system 的出口规则

❹ 只允许通过端口 53 的 UDP,这是 DNS 的协议和端口

如果您运行 wget 命令,您会注意到命令仍然失败。我们在 web 命名空间允许了入口,但没有在 test-bed 命名空间到 web 命名空间的出口上启用。运行以下命令以从 test-bed Pod 启用到 web 命名空间的出口:

$ kubectl label namespaces web name=web
$ kubectl create -f - <<EOF
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: test-bed-egress
  namespace: test-bed
spec:
  PodSelector:
    matchLabels: {}
  policyTypes:
    - Egress
  egress:
    - to:
      - namespaceSelector:
          matchLabels:
            name: web
EOF

您可能已经注意到 NetworkPolicy 规则可能会变得复杂。如果您在一个信任模型为 高信任 的集群上运行,实施网络策略可能 不会 帮助您的安全态势。使用 80/20 规则,如果您的组织没有更新其镜像,请不要从 NetworkPolicies 开始。是的,网络策略很复杂,这也是为什么在您的组织中使用服务网格可能有助于安全的原因之一。

服务网格

服务网格 是在 Kubernetes 集群之上运行的应用程序,它提供了各种能力,通常可以改善可观察性、监控和可靠性。常见的服务网格包括 Istio、Linkerd、Consul 以及其他一些。我们在安全章节中提到服务网格,因为它们可以帮助您的组织完成两个关键任务:相互 TLS 和高级网络流量策略。我们对此主题的介绍非常简短,因为关于这个主题有整本书的篇幅。

服务网格在集群中运行的每个应用程序之上添加了一个复杂的层,但也提供了许多良好的安全组件。再次强调,您需要判断是否需要添加服务网格,但不要从第一天开始就使用服务网格。如果您想知道您的集群是否符合 CNCF 对 NetworkPolicy API 的规范,您可以使用 Sonobuoy(我们在前面的章节中已介绍)运行 NetworkPolicy 测试套件:

$ sonobuoy run --e2e-focus=NetworkPolicy
# wait about 30 minutes
$ sonobuoy status

这会输出一系列表格测试,显示您的集群上网络策略的确切工作方式。要了解更多关于 CNI 提供程序 NetworkPolicy API 兼容性概念的信息,请查看mng.bz/XW7M。我们强烈建议在评估 CNI 提供程序与 Kubernetes 网络安全规范的兼容性时运行 NetworkPolicy 兼容性测试。

14.4.2 负载均衡器

需要注意的是,Kubernetes 可以创建外部负载均衡器,将您的应用程序暴露给世界,并且它是自动完成的。这看起来可能像常识,但将错误的服务放入生产环境可能会将服务(如管理用户界面)暴露给数据库。在 CI(持续集成)期间使用工具或像 Open Policy Agent(OPA)这样的工具来确保不会意外创建外部负载均衡器。此外,当可能时,请使用内部负载均衡器。

14.4.3 开放策略代理(OPA)

我们之前提到操作员可以帮助组织进一步保护集群。OPA,一个 CNCF 项目,致力于允许通过准入控制器运行的声明性策略。

OPA 是一个轻量级的通用策略引擎,可以与您的服务一起部署。您可以将 OPA 集成为一个 sidecar、主机级守护进程或库。

服务通过执行查询将策略决策卸载给 OPA。OPA 评估策略和数据以生成查询结果(这些结果会发送回客户端)。策略是用高级声明性语言编写的,可以通过文件系统或定义良好的 API 加载到 OPA 中。

—Open Policy Agent (mng.bz/RE6O)

OPA 维护了两个不同的组件:OPA 准入控制器和 OPA Gatekeeper。Gatekeeper 不使用 sidecar,使用 CRDs(自定义资源定义),可扩展,并执行审计功能。下一节将介绍如何在kind Kubernetes 集群上安装 Gatekeeper。

安装 OPA

首先,清理运行 Calico 的集群。然后,让我们启动另一个集群:

$ kind delete cluster
$ kind create cluster

接下来,使用以下命令安装 OPA Gatekeeper:

$ kubectl apply -f \
https://raw.githubusercontent.com/open-policy-agent/gatekeeper/v3.7.0/
➥ deploy/gatekeeper.yaml

以下命令打印已安装 Pod 的名称:

$ kubectl -n gatekeeper-system get po
NAME                                           READY  STATUS   RESTARTS  AGE
gatekeeper-audit-7d99d9d87d-rb4qh              1/1    Running  0         40s
gatekeeper-controller-manager-f94cc7dfc-j6zjv  1/1    Running  0         39s
gatekeeper-controller-manager-f94cc7dfc-mxz6d  1/1    Running  0         39s
gatekeeper-controller-manager-f94cc7dfc-rvqvj  1/1    Running  0         39s

注意:您也可以使用 Helm 安装 OPA Gatekeeper。

Gatekeeper CRDs

OPA 的一个复杂性是学习一种新的语言(称为 Rego)来编写策略。有关 Rego 的更多信息,请参阅 mng.bz/2jdm。使用 Gatekeeper,你将把用 Rego 编写的策略放入支持的 CRD 中。你需要创建两个不同的 CRD 来添加策略:

  • 一个约束模板用于定义策略及其目标

  • 一个约束用于启用约束模板并定义如何启用策略

以下是一个约束模板及其相关约束的示例。源块包含在 YAML 中定义的两个 CRD。在这个例子中,match 段支持

  • kinds—定义 Kubernetes API 对象

  • namespaces—指定命名空间列表

  • excludedNamespaces—指定要排除的命名空间列表

  • scope— *, 集群或命名空间

  • labelSelector—设置标准的 Kubernetes 标签选择器

  • namespaceSelector—设置标准的 Kubernetes 命名空间选择器

apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
  name: enforcespecificcontainerregistry
spec:
  crd:
    spec:
      names:
        kind: EnforceSpecificContainerRegistry        ❶
       # Schema for the `parameters` field
        openAPIV3Schema:
          properties:
            repos:
              type: array
              items:
                type: string
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |                                         ❶
       package enforcespecificcontainerregistry

        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          satisfied := [good | repo = input.parameters.repos[_] ;
➥ good = startswith(container.image, repo)]
          not any(satisfied)
          msg := sprintf("container ‘%v' has an invalid image repo
➥ ‘%v', allowed repos are %v",
➥ [container.name, container.image, input.parameters.repos])
        }

        violation[{"msg": msg}] {
          container := input.review.object.spec.initContainers[_]
          satisfied := [good | repo = input.parameters.repos[_] ;
➥ good = startswith(container.image, repo)]
          not any(satisfied)
          msg := sprintf("container ‘%v' has an invalid image repo ‘%v',
➥ allowed repos are %v",
➥ [container.name, container.image, input.parameters.repos])
        }
---
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: EnforceSpecificContainerRegistry
metadata:
  name: enforcespecificcontainerregistrytestns
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
    namespaces:
      - "test-ns"
  parameters:
    repos:
      - "myrepo.com"

❶ 定义了 EnforceSpecific-ContainerRegistry,这是一个用于约束的 CRD

现在,让我们将之前的 YAML 文件保存为两个文件(一个包含模板,另一个包含约束)。在集群上,首先安装模板文件,然后安装约束文件。(为了简洁,我们不提供命令。)现在我们可以通过运行以下命令来测试策略:

$ kubectl create ns test-ns
$ kubectl create deployment -n test-ns nginx --image=nginx

你可以通过运行以下命令来检查部署的状态(我们预计 Pod 不会启动):

$ kubectl get -n test-ns deployments.apps
NAME    READY   UP-TO-DATE   AVAILABLE   AGE
nginx   0/1     0            0           37s

如果你执行 kubectl -n test-ns get Pods,你会注意到没有 Pod 在运行。事件日志中包含显示 Pod 创建失败的消息。你可以使用以下命令查看日志:

$ kubectl -n test-ns get events
7s          Warning   FailedCreate        replicaset/nginx-6799fc88d8
➥ Error creating: admission webhook "validation.gatekeeper.sh"
➥ denied the request: [denied by
➥ enforcespecificcontainerregistrytestns] container <nginx>
➥ has an invalid image repo <nginx>, allowed repos are
➥ ["myrepo.com"]

14.4.4 多租户

要对多租户进行分类,查看租户之间的信任水平,然后开发该模型。有三个基本类别或安全模型用于将多租户分类:

  • 高信任(同一家公司)—同一家公司的不同部门在同一个集群上运行工作负载。

  • 中低信任(不同公司)—外部客户在不同的命名空间中运行应用程序。

  • 零信任(受法律管辖的数据)—不同的应用程序运行受法律管辖的数据,允许不同数据存储之间的访问可能导致公司面临法律诉讼。

Kubernetes 社区已经为解决这些用例工作了多年。Jessie Frazelle 在她的博客文章“Kubernetes 中的硬多租户”中很好地总结了这些内容:

多租户模型在社区的多租户工作组中已经进行了广泛的讨论。……还提出了一些提案来解决每个模型。Kubernetes 中当前的租户模型假设集群是安全边界。你可以在 Kubernetes 之上构建 SaaS,但你将需要带来自己的可信 API,而不仅仅是使用 Kubernetes API。当然,这伴随着在为 SaaS 安全构建集群时你必须考虑的许多考虑因素。

——杰西·弗拉泽尔,mng.bz/1jdn

Kubernetes API 并没有考虑到在同一个集群内存在多个隔离的客户的概念。Docker Engine 和其他容器运行时在运行恶意或不信任的工作负载时也存在问题。像 gVisor 这样的软件组件在正确沙箱化容器方面取得了进展,但在撰写本书时,我们还没有达到可以运行完全不受信任的容器的地步。

那么,我们现在在哪里?安全人员会说这取决于你的信任和安全模型。我们之前列出了三种安全模型:高度信任(同一公司)、低信任到无信任(不同公司)和零信任(数据受法律管辖)。Kubernetes 可以支持高度信任的多租户,并且根据模型,可以在高度信任和低信任模型之间支持信任模型。当你与租户之间或租户之间有零信任或低信任时,你需要为每个客户端使用单独的集群。一些公司运行数百个集群,以便每个小型应用程序组都能获得自己的集群,但诚然,这需要管理很多集群。

即使客户属于同一公司,由于数据敏感性,可能还需要在特定节点上隔离 Pods。通过 RBAC、命名空间、网络策略和节点隔离,可以获得相当程度的隔离。诚然,存在不同公司运行的工作负载托管在同一个 Kubernetes 集群中的风险。多租户的支持将随着时间的推移而增长。

注意:多租户也适用于在生产集群中运行其他环境,如开发或测试。然而,通过混合环境,你可能会将不良行为者的代码引入集群中。

使用单个 Kubernetes 来托管多个客户主要面临两个挑战:API 服务器和节点安全。在建立身份验证、授权和 RBAC 之后,为什么 API 服务器和多个租户之间会出现问题?其中一个问题是 API 服务器 URI 布局的问题。对于多个租户使用相同 API 服务器的常见模式是让用户 ID、项目 ID 或某些唯一 ID 作为 URI 的开头。

具有以唯一 ID 开始的 URI 允许租户调用以获取所有命名空间。因为 Kubernetes 没有这种隔离,你需要运行kubectl get namespaces来获取集群中的所有命名空间。你还需要在 Kubernetes API 之上运行一个 API 层,以提供这种形式的隔离。

允许多租户的另一种模式是资源嵌套的能力,以及 Kubernetes 中的基本资源边界是命名空间。Kubernetes 命名空间不允许嵌套。许多资源是跨命名空间的,包括默认的服务账户令牌。通常,租户希望拥有细粒度的 RBAC 能力,而授予用户在 Kubernetes 中创建 RBAC 对象权限可能会赋予用户超出其共享租户的能力。

关于节点安全,挑战在于内部。如果你在同一 Kubernetes 集群上有多个租户,请记住他们共享以下项目(这只是一个简短的列表):

  • 控制平面和 API 服务器

  • 添加 DNS 服务器、日志记录或 TLS 证书生成等附加组件

  • 自定义资源定义(CRDs)

  • 网络

  • 主机资源

信任多租户概述

许多公司希望多租户可以降低成本和管理开销。不运行三个集群:一个用于开发、一个用于测试、一个用于生产环境是有价值的。只需运行一个 Kubernetes 集群用于所有三个环境。此外,一些公司不希望为不同的产品或软件部门拥有单独的集群。这同样是一个业务和安全决策,我们合作的组织通常有预算和人力资源的限制。

我们不会给你提供如何进行多租户的逐步指导。我们只是提供你需要实施的步骤的指南。这些步骤会随着时间的推移而变化,并且在不同安全模型的组织之间会有所不同:

  1. 记录并设计一个安全模型。这可能看起来很明显,但我们看到一些组织没有使用安全模型。安全模型需要包括不同的用户角色,包括集群管理员和命名空间管理员,以及一个或多个租户角色。为组织创建的所有 API 对象、用户和其他组件的标准命名约定也是至关重要的。

  2. 利用各种 API 对象:

    • 命名空间

    • 网络策略

    • ResourceQuotas

    • 服务账户和 RBAC 规则

  3. 使用具有相互 TLS 和网络策略管理等功能的服务网格可以提供另一层安全性。使用服务网格确实会增加显著的复杂性,因此只有在需要时才使用它。

  4. 考虑使用 OPA 来帮助将基于策略的控制应用于 Kubernetes 集群。

提示:如果你打算在单个集群中结合多个环境,不仅存在安全问题,还有测试 Kubernetes 升级的挑战。最好先在另一个集群上测试升级。

14.5 Kubernetes 技巧

这里是一份简短的各种配置和设置要求的列表:

  • 拥有私有 API 服务器端点,并且如果可能的话,不要将你的 API 服务器暴露给互联网。

  • 使用 RBAC。

  • 使用网络策略。

  • 不要在 API 服务器上启用用户名和密码授权。

  • 创建 Pod 时使用特定用户,不要使用默认管理员账户。

  • 很少允许 Pod 在主机网络上运行。

  • 如果 Pod 需要访问 API 服务器,请使用serviceAccountName;否则,将automountServiceAccountToken设置为 false。

  • 在命名空间上使用资源配额,并在所有 Pod 中定义限制。

摘要

  • 节点安全性依赖于 TLS 证书来保护节点和控制平面之间的通信。

  • 使用不可变操作系统可以进一步增强节点安全性。

  • 资源限制可以防止资源级别的攻击。

  • 使用 Pod 网络,除非你不得不使用主机网络。主机网络允许 Pod 与节点操作系统通信。

  • RBAC 是保护 API 服务器的关键。虽然非同寻常,但这是必要的。

  • IAM 服务账户允许正确隔离 Pod 权限。

  • 网络策略是隔离网络流量的关键。否则,一切都可以与一切通信。

  • Open Policy Agent (OPA) 允许用户编写安全策略,并在 Kubernetes 集群上强制执行这些策略。

  • Kubernetes 最初并非以零信任多租户模式构建。你可以找到多租户的形式,但它们伴随着权衡。

15 安装应用程序

本章涵盖

  • 审查 Kubernetes 应用程序管理

  • 安装典型的 Guestbook 应用程序

  • 构建一个适合生产的 Guestbook 应用程序版本

在 Kubernetes 中管理应用程序通常比在裸服务器上部署应用程序要容易得多,因为所有应用程序的配置都可以通过统一的命令行界面完成。尽管如此,当您将成百上千个容器移动到 Kubernetes 环境中时,需要自动化的配置管理量可能难以从统一的角度来处理。ConfigMaps、Secrets、API 服务器凭证以及卷类型的定制只是日常中可能使 Kubernetes 管理变得繁琐的几个方面。

在本章中,我们(终于)将暂时从 Kubernetes 实现的内部细节中退一步,花一点时间来关注应用程序配置和管理的高级方面。我们将从思考应用程序是什么以及我们如何在 Kubernetes 上安装应用程序开始。

15.1 在 Kubernetes 中思考应用程序

对于我们的目的,我们将把 Kubernetes 应用程序称为一组需要部署以提供服务的 API 资源。这个典范的例子可能是 Guestbook 应用程序,定义在 mng.bz/y4NE。此应用程序包括

  • 一个 Redis 主数据库 Pod

  • 一个 Redis 从属数据库 Pod

  • 一个与我们的 Redis 主数据库通信的前端 Pod

  • 为这三个 Pod 提供的服务

应用交付涉及升级、降级、参数化和定制许多不同的 Kubernetes 资源。由于这是一个备受争议的主题,存在许多不同且相互竞争的技术解决方案,我们在此不会尝试为您解决这个整个谜题。相反,我们在本章中包含了大量应用工具,因为您如何部署您的 Pods 的很大一部分与您如何配置它们以及您最初如何安装应用程序有关。我们可以这样在任何 Kubernetes 集群上运行我们的 Guestbook 应用程序:

$ kubectl create -f https://github.com/kubernetes/examples/blob/master/
    guestbook/all-in-one/guestbook-all-in-one.yaml

从互联网上 curl

我们之前已经提到过这一点,但我们会再次强调:从互联网上 curl YAML 文件可能是一件危险的事情。在我们的案例中,我们直接从 github.com/kubernetes curl 我们的 YAML 文件,这是一个由数千名知名且经过审查的 CNCF(云原生计算基金会)组织成员维护的可信仓库。到本章结束时,我们将拥有一个更符合企业级和现实的方式安装相同的 Guestbook 应用程序,所以请耐心等待。

在发出上一条命令后,我们很快就会看到所有我们的 Pods 都启动并运行,我们的三个前端和 Redis 从属 Pods 有多个副本。在最基本层面上,这就是安装 Kubernetes 应用程序的样子:

$ kubectl get pods -A
NAMESPACE  NAME                  READY   STATUS             RESTARTS  AGE
default    frontend-6c-7wjx8     1/1     Running            0         3m18s
default    frontend-6c-g7z8z     1/1     Running            0         3m18s
default    frontend-6c-xd5q2     0/1     ContainerCreating  0         3m18s
default    redis-master-f46-l2d  1/1     Running            0         3m18s
default    redis-slave-797-nv9   1/1     Running            0         3m18s
default    redis-slave-797-9qc   1/1     Running            0         3m18s

15.1.1 应用程序范围影响您应该使用哪些工具

安装 Guestbook 的那一刻起,我们必须问自己几个明显的问题。这些问题与扩展、升级、定制、安全和模块化有关:

  • 用户是否将手动调整 Guestbook 网络应用 Pods 的数量?或者我们希望根据负载自动扩展我们的部署?

  • 我们是否希望定期升级 Guestbook 应用程序,如果是这样,我们是否希望与其他 Pods 同时升级?如果是这样,我们应该构建一个 Kubernetes Operator 吗?

  • 我们是否有明确的离散配置数量(例如,是否有一些我们关心的替代 Redis 配置)?如果是这样,我们应该使用 yttkustomize 或其他工具,这样我们就不需要在保存应用程序设置的新版本时每次都复制和粘贴大量冗余的 YAML 文件。

  • 我们的 Redis 数据库是否安全?它需要安全吗?如果是这样,我们应该为更新或编辑应用程序所在的命名空间添加 RBAC 凭证,还是应该安装与它并行的 NetworkPolicy 规则?我们可以在 redis.io/topics/security 上查看所有规则,并使用 Secrets、ConfigMaps 等实现这些规则。此外,我们可能需要定期轮换这些 Secrets,这将引入对某种定期自动化的需求。(这会暗示需要 Operator。)

  • 尽管我们可以在特定的命名空间中部署我们的应用程序,但我们如何能够跟踪应用程序的整体健康状况随时间的变化,并将其作为一个原子单元进行升级?将大量 Kubernetes 资源作为应用程序从大文件部署出去,在多个方面都显得笨拙。其中之一是,一旦它在我们集群中丢失,我们就不清楚我们的应用程序究竟是什么。

15.2 微服务应用程序通常需要数千行配置代码

微服务将应用程序的个体功能分解为单独的服务,每个服务都有自己的独特配置。与大型单体应用程序相比,这会带来高昂的成本,因为在大型单体应用程序中,容器的许多通信和安全都是通过内存计算的内禀使用来完成的。回到我们的 Guestbook 应用程序,它有三个微服务和 200 行代码,我们可以看到,为每个我们创建的 API 对象,可能需要 10 到 50 行代码。

一个典型的企业级 Kubernetes 应用程序将涉及更复杂的配置,从 10 到 20 个容器不等,每个容器通常至少有一个服务、一个 ConfigMap 对象以及一些与其相关的 Secrets。对于这样一个应用程序配置中的代码量,一个粗略的估计是数千行 YAML,分布在许多文件中。显然,每次部署新应用程序时复制数千行代码是不切实际的。那么,让我们来看看管理长期运行、现实世界应用程序的 Kubernetes 配置的几种不同解决方案。

不要害怕重新思考您的应用程序

在您深入探索应用程序选项的无穷无尽之前,我们只想提醒您注意一个警告:如果您的安装工具过于复杂,那么您很可能正在掩盖您底层应用程序中的损坏元素。在这些情况下,简化您应用程序的管理方式可能是个明智的选择。作为一个主要例子,很多时候,开发者创建了过多的微服务,或者在一个应用程序中构建了比必要的更多灵活性(通常是因为没有足够的测试来衡量和设置正确的配置默认值)。有时,配置管理的最佳解决方案就是完全消除可配置性。

15.3 重新思考我们的 Guestbook 应用在现实世界中的安装

既然我们已经定义了我们的整体问题空间,即管理 Kubernetes 应用程序配置,那么让我们带着这些解决方案重新思考我们的 Guestbook 应用程序:

  • Yaml 模板化—我们将使用ytt来完成这项工作,但也可以使用kustomizehelm3等工具来实现这一功能。

  • 部署和升级我们的应用—在这里我们将使用 Carvel kapp-controller项目,但我们也可以构建一个 Kubernetes Operator 来完成这项工作。

为什么不使用helm

我们并不想明确地支持某一工具而反对另一工具:helm3kustomizeytt都可以根据需要用来实现相同的目标。我们更喜欢ytt,因为它具有模块化和完全可编程的特性(并且它与 Starlark 集成)。但最终,选择一个工具。helm3kustomizeytt都是优秀的工具,但还有许多其他优秀的工具可以解决 YAML 过载问题。没有特定的原因说明为什么这些示例不能使用其他技术来实现。至于这一点,sed总是可用。

Carvel 工具包(carvel.dev工具包)有几个不同的模块化工具,可以一起或单独使用来管理我们迄今为止所描述的整个问题空间。实际上,它是 VMware Tanzu 发行版许多功能的基础。

15.4 安装 Carvel 工具包

探索如何提高我们的 "guestbook-fu" 的第一步是安装 Carvel 工具包。然后我们可以从命令行执行这些工具中的每一个。以下代码片段显示了安装工具包的命令。从现在开始,我们将使用 yttkappkapp-controller 逐步改进和自动化我们的 Guestbook 应用程序:

# on macOS, do this
$ brew tap vmware-tanzu/carvel ; brew install ytt kbld kapp imgpkg kwt vendir
# or on Linux, do this
$ curl -L https://carvel.dev/install.sh | bash

我们真的需要所有的 Carvel 吗?

虽然我们在这个章节中不需要所有的 Carvel 工具,但我们仍然会安装它们,因为它们配合得很好。我们建议您自己探索其中的一些工具(例如 imgpkgvendir)作为额外的练习。Carvel 的每个二进制文件都易于运行,自包含,并且占用系统空间极小。请随意根据您的学习目标自定义此安装。

15.4.1 第一部分:将资源模块化到单独的文件中

当我们看到 200 行的 YAML 墙时,首先考虑的可能是将其分解成更小、更易于理解的块。这样做的原因非常明显:

  • 当我们没有大量重复字符串时,使用 grepsed 等工具要容易得多。

  • 跟踪谁可能更改了特定于特定功能的特定内容,简化了小文件的版本控制。

  • 将新的 Kubernetes 对象添加到我们的文件中最终会变得难以管理,因此模块化将是最终的需求。我们不妨现在就提前做好准备。

我们已经将 Guestbook 的分解工作分成了两个独立的目录。我们将这些目录放在mng.bz/M2wm上供您克隆和尝试。请随意以您觉得直观的方式分解文件。

按照本节中的确切分解步骤不是强制性的,因为如果你问 10 个不同的程序员如何分解一个对象,你会得到 100 个不同的答案。然而,在分解时,请确保进行一个重要的修改:每个 Kubernetes 资源都应该有一个唯一名称。例如,Redis 主部署不能与 Redis 服务对象同名。例如,在下面的代码中,我们给我们的 Redis 主部署名称添加了 -dep 后缀:

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis-master-dep
---

我们也以同样的方式处理了前端 YAML 文件。以下代码片段显示了添加 -dep 后缀后的目录结构:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend-dep
spec:
->  carvel-guestbook git:(master) ✗ tree
.
├──  v0
│      └──  original.yaml
└──  v1
 ├── fe-dep.yaml
 ├── fe-svc.yaml
 ├── redis-master-dep.yaml
 ├── redis-master-svc.yaml
 ├── redis-slave-dep.yaml
 └── redis-slave-svc.yaml

重命名 Redis 主和前端资源

如果您不打算使用mng.bz/M2wm上的文件,并且您正在自己拆分 guestbook YAML,请确保将 Redis 主和前端部署文件的 metadata.name 字段重命名为 redis-master-dep 和 frontend-dep(如前述代码片段所示)。这将使我们能够稍后使用 ytt 容易地查找和替换 YAML 构造的值。

我们现在可以通过运行 kubectl create -f v1/ 来测试我们的分解是否与原始应用程序等效。我们将相信你会运行这个命令并确认三个前端和两个后端 Redis Pod 都已启动并正常运行。然后,你可以设置端口转发,在本地通过端口 8080 浏览 Guestbook 应用程序。例如:

$ kubectl port-forward service/frontend 8080:80
Forwarding from 127.0.0.1:8080 -> 80
Forwarding from [::1]:8080 -> 80

现在,你可以在应用程序的消息字段中轻松输入几个值,并看到这些值存储在后端的 Redis 数据库中。注意,这些值也会显示在你的 Guestbook 登录页面上。

15.4.2 第二部分:使用 ytt 修补我们的应用程序文件

我们有一个具有前端和后端的工作应用程序。现在如果我们决定开始给它增加更多负载会发生什么?我们可能想要给它分配更多的 CPU。为了做到这一点,我们需要修改 fe-dep.yaml 文件以增加 requests.cpu 的值。这意味着我们需要编辑一些 YAML:

containers:
- name: php-redis
  image: gcr.io/google-samples/gb-frontend:v4
  resources:
    requests:
      cpu: 100m        ❶
      memory: 100Mi

❶ 核心的一十分之一对于生产应用来说并不是很多 CPU。

在代码示例中,我们可以轻松地将 100m 替换为 1,但那样我们只是将一个硬编码的常量替换为另一个。如果我们能对这个值进行参数化会更好。此外,我们可能还想增加 Redis 的 CPU 要求。幸运的是,我们有 ytt

YAML 模板引擎 ytt (carvel.dev/ytt/) 允许使用覆盖、修补等模式对 YAML 文件进行不同的自定义。它还通过使用 Starlark 语言来实现文本操作的逻辑决策,支持高级结构。因为我们已经安装了 Carvel 工具包,所以让我们直接深入了解我们如何在第一个 ytt 示例中自定义应用程序的 CPU。

YAML 输入,YAML 输出

ytt 是一个 YAML 输入,YAML 输出工具,这是一个需要牢记的重要概念。与其他在 Kubernetes 生态系统中来来去去的工具不同,ytt(就像 Carvel 框架中的其他工具一样)专注于做一项具体的工作,并且做得非常好。它操作 YAML!它不会为我们安装文件,并且它以任何方式都不特定于 Kubernetes。

对于我们这个 Guestbook 应用程序的第二次(v2)迭代,我们现在将在一个新目录(称为 v2/)中添加一个新文件(称为 ytt-cpu-overlay.yaml)。我们的目标是匹配 php-redis 前端 Web 应用程序中的 cpu 段落与 Redis 主数据库 Pod。以下是代码:

#@ load("@ytt:overlay", "overlay")
#@overlay/match by=overlay.subset(
   {"metadata":{"name":"frontend-dep"}})   ❶
---
spec:
  template:
    spec:
      containers:
      #@overlay/match by="name"            ❷
      - name: php-redis
        #@overlay/match missing_ok=False
        resources:
          requests:
            cpu: 200m                      ❸

❶ 我们 ytt 遮罩识别我们想要匹配的 YAML 片段的名称。

❷ 一旦在容器内部,它将容器替换为 php-redis 名称。

❸ 原始的 CPU 值 100m 现在翻倍为 200m。

类似地,我们也可以为我们的数据库 Pod 做同样的事情。我们可以创建一个新文件,称为 v2/ytt-cpu-overlay-db.yaml,它执行与之前文件相同的功能:

#@ load("@ytt:overlay", "overlay")
#@overlay/match by=overlay.subset({"metadata":{"name":"redis-master-dep"}})
---
spec:
  template:
    spec:
      containers:
      #@overlay/match by="name"
      - name: master
        resources:
          requests:
              #@overlay/match missing_ok=True
              cpu: 300m                        ❶

❶ 添加一个新的 CPU 值(这次,300m 以区分两者)

我们现在可以调用这个 YAML 的转换。例如:

$ tree v2
v2
 ytt-cpu-overlay-db.yml
 ytt-cpu-overlay.yml

$ ytt -f ./v1/  -f v2/ytt-cpu-overlay.yml  -f v2/ytt-cpu-overlay-db.yml
...
            cpu: 200m
...
            cpu: 300m     ❶
$ kubectl delete -f v0/original.yaml
$ ytt -f ./v1/  -f v2/ytt-cpu-overlay.yml \
   | -f v2/ytt-cpu-overlay-db.yml | kubectl create -f -
deployment.apps/frontend-dep created
service/frontend created
deployment.apps/redis-master-dep created
service/redis-master created
deployment.apps/redis-slave created
service/redis-slave created

❶ 仅修改文件以为主节点请求更高的 CPU

太好了,我们现在又回到了起点!最初我们只有一个文件,它很容易打包但难以修改。使用 ytt,我们取了许多不同的文件,并在它们之上添加了一层定制,这样它们就可以像单个 YAML 资源一样被流式传输到 kubectl 命令中。

我们可能会想象,我们的应用程序现在已经准备好投入生产,因为它能够添加和替换我们的开发配置,以使用现实世界的配置。如果你浏览了 carvel.dev/ytt/ 的文档,你会看到还可以进行许多进一步的定制:添加数据值、添加全新的 YAML 构造,等等。然而,在我们的情况下,我们将保持现状,并向上移动到查看我们的修补过的 YAML 资源现在如何被捆绑成一个单一的、可执行的应用程序,其状态被作为一等公民来管理。

15.4.3 第三部分:将 Guestbook 作为单个应用程序管理和部署

你可能已经对我们在这本书中多次运行 kubectl delete 感到厌烦。如果你问过自己为什么这样做,通常是因为我们没有将我们的应用程序与集群中的其他应用程序隔离开来。一个简单的方法是将整个应用程序部署在一个命名空间中,然后可以删除或创建该命名空间。然而,一旦我们开始将多个资源视为单个应用程序,我们就有一系列新的问题想要回答:

  • 在给定的命名空间中,我运行了多少个不同的应用程序?

  • 给定应用中所有资源的升级是否成功?

  • 我将多少种类型的资源关联到了我的应用程序中?

这些问题可以通过结合使用 kubectlgrep 和一些巧妙的 Bash 聚合来回答。然而,如果你在应用中有成百上千个容器、Secrets、ConfigMaps 等其他资源,这种方法就无法扩展。此外,它也无法扩展到集群中所有应用的全范围,这些应用的数量也可能轻易达到数百或数千。这就是 kapp 工具对我们来说变得重要的地方。

那么 helm 呢?

helm 是 Kubernetes 最早且最成功的应用程序管理解决方案之一。最初,它结合了有状态升级和资源安装的方面,并使用 YAML 模板。Carvel 项目借鉴了 helm 的经验,将这些功能中的许多分离成单独的工具。

helm3 实际上是一个更模块化的尝试,用于以无状态方式管理应用程序,这与我们将要看到的 kapp 工具类似。无论如何,helm3 和 Carvel 生态系统有很多重叠,两者都可以用于类似的情况,但它们由不同的观点、哲学方法和社区所引导。我们鼓励你探索两者,特别是如果你觉得 kapp 不是一个理想的解决方案。无论如何,通过使用 kapp 时跟踪 Guestbook 的演变,你将学会很多关于管理 Kubernetes 应用程序的一般知识,所以继续前进!

使用 kapp 工具(carvel.dev/kapp/) 非常简单,尤其是现在我们有了使用 ytt 定制应用程序的能力。为了尝试一下,让我们最后清理一下我们的 Guestbook 应用程序,以防它还在运行:

$ ytt -f ./v1/  -f v2/ytt-cpu-overlay.yml |
    -f v2/ytt-cpu-overlay-db.yml |
    kubectl delete -f -              ❶

❶ 删除我们在上一节中创建的资源(以防它们还在)

我们假设你已经安装了 kapp 二进制文件。现在,让我们运行相同的 ytt 命令来生成我们的应用程序,但使用 kapp 而不是 kubectl 来安装它。注意,在这个例子中,我们使用 Antrea 作为我们的 CNI 提供者。但你在此时运行的 CNI 并不重要,只要你有一个(注意,由于页面限制,此代码片段中的几个列被省略了):

$ kapp deploy -a guestbook -f <(ytt -f ./v1/
➥ -f v2/ytt-cpu-overlay.yml
➥ -f v2/ytt-cpu-overlay-db.yml)         ❶

Target cluster 'https://127.0.0.1:53756' (nodes: antrea-control-plane, 2+)

Changes

Namespace  Name              Kind        ...  Op      Op st.  Wait to   ...
default    frontend          Service     ...  create  -       reconcile ...
^          frontend-dep      Deployment  ...  create  -       reconcile ...
^          redis-master      Service     ...  create  -       reconcile ...
^          redis-master-dep  Deployment  ...  create  -       reconcile ...
^          redis-slave       Deployment  ...  create  -       reconcile ...
^          redis-slave       Service     ...  create  -       reconcile ...

Op:      6 create, 0 delete, 0 update, 0 noop
Wait to: 6 reconcile, 0 delete, 0 noop

Continue? [yN]:

❶ 将 ytt 生成 YAML 的语句推送到 kapp 作为输入

如果你输入 y,你会看到 kapp 做很多工作,包括注释你的资源,以便它可以后来为你管理它们。它将升级或删除资源,或者通过名称给出你应用程序的整体状态。在我们的例子中,我们称我们的应用程序为 Guestbook,但我们可以称它为任何我们想要的名称。

在输入“是”(通过输入 y)后,你现在将看到比 kubectl 可用的更多信息。这是因为对于 kapp 来说,应用程序实际上是一个一等公民,它想确保你所有的资源都以健康状态出现。你可以想象 kapp 可以如何用于 CI/CD 环境来完全自动化应用程序的升级和管理。例如:

11:17:40AM: ---- applying 6 changes [0/6 done] ----
11:17:40AM: create deployment/frontend-dep (apps/v1) namespace: default
11:17:40AM: create deployment/redis-master-dep (apps/v1) namespace: default
11:17:40AM: create service/redis-master (v1) namespace: default
11:17:40AM: create service/redis-slave (v1) namespace: default
11:17:40AM: create deployment/redis-slave (apps/v1) namespace: default
11:17:40AM: create service/frontend (v1) namespace: default
11:17:40AM: ---- waiting on 6 changes [0/6 done] ----
11:17:40AM: ok: reconcile service/frontend (v1) namespace: default
11:17:40AM: ok: reconcile service/redis-slave (v1) namespace: default
11:17:40AM: ok: reconcile service/redis-master (v1) namespace: default
11:17:41AM: ongoing: reconcile deployment/frontend-dep (apps/v1)
➥ namespace: default
11:17:41AM:  ^ Waiting for generation 2 to be observed
11:17:41AM:  L ok: waiting on replicaset/frontend-dep-7bf896bf7c (apps/v1)
➥ namespace: default
11:17:41AM:  L ongoing: waiting on pod/frontend-dep-7bf896bf7c-vbn22 (v1)
➥ namespace: default
11:17:41AM:     ^ Pending: ContainerCreating
11:17:41AM:  L ongoing: waiting on pod/frontend-dep-7bf896bf7c-qph5b (v1)
➥ namespace: default
...
11:17:44AM: ---- waiting on 1 changes [5/6 done] ----
11:18:01AM: ok: reconcile deployment/redis-master-dep (apps/v1)
➥ namespace: default
11:18:01AM: ---- applying complete [6/6 done] ----
11:18:01AM: ---- waiting complete [6/6 done] ----
Succeeded

我们现在可以回到我们的应用程序,再次确认它仍在运行。使用以下命令进行检查:

$ kapp list
Target cluster 'https://127.0.0.1:53756' (nodes: antrea-control-plane, 2+)

Apps in namespace 'default'

Name       Namespaces  Lcs   Lca
guestbook  default     true  12m

Lcs: Last Change Successful
Lca: Last Change Age

1 apps

Succeeded

我们还可以使用 kapp 来获取关于运行中的应用程序的详细信息。为此,我们使用 inspect 命令:

$ kapp inspect --app=guestbook
Target cluster 'https://127.0.0.1:53756' (nodes: antrea-control-plane, 2+)

Resources in app 'guestbook'

Name                         Kind           Owner     Conds.  Age
frontend                     Endpoints      cluster   -       12m
frontend                     Service        kapp      -       12m
frontend-dep                 Deployment     kapp      2/2 t   12m
frontend-dep-7bf7c           ReplicaSet     cluster   -       12m
frontend-dep-7bf7c-g7jlt     Pod            cluster   4/4 t   12m
frontend-dep-7bf7c-qph5b     Pod            cluster   4/4 t   12m
frontend-dep-7bf7c-vbn22     Pod            cluster   4/4 t   12m
frontend-sccps               EndpointSlice  cluster   -       12m
redis-master                 Endpoints      cluster   -       12m
redis-master                 Service        kapp      -       12m
redis-master-dep             Deployment     kapp      2/2 t   12m
redis-master-dep-64fcb       ReplicaSet     cluster   -       12m
redis-master-dep-64fcb-t4hjl Pod            cluster   4/4 t   12m
redis-master-zqdvc           EndpointSlice  cluster   -       12m
redis-slave                  Deployment     kapp      2/2 t   12m
redis-slave                  Endpoints      cluster   -       12m
redis-slave                  Service        kapp      -       12m
redis-slave-dffcf            ReplicaSet     cluster   -       12m
redis-slave-dffcf-75vfq      Pod            cluster   4/4 t   12m
redis-slave-dffcf-lwch9      Pod            cluster   4/4 t   12m
redis-slave-vlnkh            EndpointSlice  cluster   -       12m

Rs: Reconcile state
Ri: Reconcile information

21 resources

Succeeded

注意到一些由 Kubernetes 在幕后创建的对象,例如 Endpoints 和 EndpointSlices,都包含在这个读取结果中。EndpointSlices 及其作为服务负载均衡目标的可用性对于任何应用程序都能被最终用户使用至关重要。kapp 已经为我们捕获了这些信息,包括我们应用程序中所有资源的成功和失败状态,以单一、易于阅读的表格格式。

最后,我们现在可以使用kapp通过运行kapp delete --app=guestbook轻松且完整地删除我们的应用程序。这将是我们kapp deploy操作的逆操作,因此我们不会显示输出,因为此命令的结果主要不言自明。

15.4.4 第四部分:构建一个 kapp 操作符以打包和管理我们的应用程序

现在我们已经将整个应用程序打包成一组原子管理的资源,这些资源具有明确的名称,我们实际上已经构建了可能被认为是自定义资源定义(CRD)的东西。kapp-controller项目允许我们使用一些自动化优点将任何kapp应用程序包装起来。这次最后的探索完成了我们从“来自互联网的一个大块 YAML”到状态化、自动管理应用程序的过渡,我们可以在企业环境中运行这个应用程序以及数百个其他应用程序。它还将温和地向您介绍如何构建 Kubernetes 操作符的概念。

我们首先要做的是使用kapp安装kapp-controller工具。我们再次从互联网上安装东西,但,就像往常一样,在安装之前请随意检查 YAML。为了您的方便,以下是 YAML:

$ kapp deploy -a kc -f https://github.com/vmware-tanzu/
       carvel-kapp-controller/releases/latest/
         download/release.yml                             ❶
$ kapp deploy -a default-ns-rbac -f
     https://raw.githubusercontent.com/vmware-tanzu/
       carvel-kapp-controller/
        develop/examples/rbac/default-ns.yml              ❷

❶ 使用 kapp 工具安装 kapp-controller

❷ 安装一个简单的 RBAC 定义

您可能想知道为什么我们需要为kapp-controller设置 RBAC 规则。安装 RBAC 定义(default-ns.yml)允许默认命名空间中的kapp-controller像任何操作符一样读取和写入 API 对象。操作符是管理应用程序,kapp-controller Pod 需要创建、编辑和更新各种 Kubernetes 资源,以便作为我们应用程序的通用操作符执行其工作。

现在kapp-controller已经在我们的集群中运行,我们可以使用它来自动化上一节中的ytt复杂性,并且我们可以以声明式的方式完成,这完全在 Kubernetes 内部管理。为此,我们需要创建一个kapp CR(自定义资源)。kapp应用程序的规范描述在mng.bz/PWqv。我们关心的特定字段是

  • git——定义了我们应用程序源代码的可克隆 Git 仓库

  • template——定义了安装应用程序的ytt模板所在的位置

我们首先要做的是为我们的原始 Guestbook 应用程序创建一个应用程序规范,该应用程序将作为一个kapp控制的应用程序运行。之后,我们将重新添加我们的ytt模板:

apiVersion: kappctrl.k14s.io/v1alpha1
kind: App
metadata:
  name: guestbook
  namespace: default
spec:
  serviceAccountName: default-ns-sa    ❶
  fetch:                               ❷
  - git:
      url: https://github.com/jayunit100/k8sprototypes
      ref: origin/master

      # We have a directory, named 'app', in the root of our repo.
      # Files describing the app (i.e. pod, service) are in that directory.
      subPath: carvel-guestbook/v1/
  template:                            ❸
  - ytt: {}
  deploy:
  - kapp: {}

❶ 使用之前为该应用程序安装创建的服务帐户

❷ 指定我们的应用程序定义的位置

❸ 因为我们的应用程序代码实际上位于 carvel-guestbook/v1/,所以我们需要指定这个子路径。

到目前为止,你心中的灯泡可能已经亮了起来,关于持续交付的想法,这是完全正确的。这个单独的 YAML 声明让我们可以将我们应用程序的整个管理权交给 Kubernetes 和我们的在线 kapp-controller 操作符本身。让我们试一试。运行 kubectl create -f 命令,使用之前显示的 YAML 片段创建 Guestbook 应用程序,然后执行以下命令:

$ kapp list
Target cluster 'https://127.0.0.1:53756' (nodes: antrea-control-plane, 2+)

Apps in namespace 'default'

Name             Namespaces  Lcs   Lca
default-ns-rbac  default     true  14m
guestbook-ctrl   default     true  1m
...

我们可以看到,我们的 guestbook-ctrl 应用程序是由 kapp-controller 自动为我们创建的。我们还可以再次使用 kapp 来检查这个应用程序:

$ kapp inspect --app=guestbook-ctrl
Target cluster 'https://127.0.0.1:53756'
  (nodes: antrea-control-plane, 2+)

Resources in app 'guestbook-ctrl'

Namespace  Name Kind      Owner    Conds.  Rs
default    fe   Dep       kapp     2/2 t   ok
^          fe   Endpoints cluster  -       ok
^          fe   Service   kapp     -       ok
...

我们现在已经将我们的应用程序集成到一个 CI/CD 系统中,该系统可以完全在 Kubernetes 内部管理。太棒了!现在可以想象构建任意复杂的系统,让开发者提交和维护他们应用程序的 CRDs,这些 CRDs 最终由运行在我们默认命名空间中的单个 kapp-controller 操作符部署和管理。

如果我们想的话,我们可以在新的命名空间中重新部署这个相同的应用程序(通常称为“App CR”)。为此,我们只需运行 kubectl get apps 命令来添加或删除这些内容,因为 kapp-controller Pod 已经为我们集群中的 kapp 应用程序安装了一个 CRD:

$ kubectl get apps
NAME        DESCRIPTION           SINCE-DEPLOY   AGE
guestbook   Reconcile succeeded   5m16s          6m8s

我们刚刚实现了 Guestbook 应用程序的完整 Operator 部署。现在,让我们尝试将我们的 ytt 模板重新添加进来。在这个例子中,我们将之前示例中的 ytt 输出推送到 k8sprototypes 仓库中的特定目录(你可能想为这个练习使用你自己的 GitHub 仓库,但这不是必需的):

apiVersion: kappctrl.k14s.io/v1alpha1
kind: App
metadata:
  name: guestbook
  namespace: default
spec:
  serviceAccountName: default-ns-sa
  fetch:
  - git:
      url: https://github.com/jayunit100/k8sprototypes
      ref: origin/master
      subPath: carvel-guestbook/v2/output/
  template:
  - ytt: {}
  deploy:
  - kapp: {}

我们现在可以通过简单地编写转换后的 ytt 模板到另一个目录来为我们的 Guestbook 应用程序创建一个新的定义,该定义包括我们的 ytt 模板。使用 Operator 来管理我们的应用程序的另一个优点是,我们可以创建和删除它们而无需任何特殊工具。这是因为 kubectl 客户端将它们视为 API 资源。要删除 Guestbook 应用程序,请运行此命令:

$ kubectl delete app guestbook

我们现在可以使用 kubectl 声明性地删除我们的 Guestbook 应用程序,kapp-controller 将完成剩余的工作。我们还可以使用 kubectl describe 等命令来查看应用程序的状态。

我们只是触及了 Operator 模型在管理和创建应用程序定义方面的灵活性。作为后续练习,值得探索

  • 使用 kapp-controller 在许多命名空间中部署相同应用程序的多个副本

  • kapp-controller 中使用 ytt 指令

  • 使用 kapp-controller 的能力部署和管理 Helm 图表作为应用程序

  • 将机密嵌入到你的 kapp 应用程序中,以便你可以安全地部署 CI/CD 工作流

这总结了我们对 Guestbook 应用程序的迭代改进。我们将通过查看我们熟悉的老朋友 Calico 和 Antrea CNI 提供者来结束这一章,看看它们如何实现具有细粒度 CRDs 的完整 Kubernetes Operator。

15.5 重新审视 Kubernetes Operator

kappkapp-controller 工具为我们提供了一种自动的、原子化的方式来以有状态的方式处理 Guestbook 应用程序中的所有服务。因此,这以有机的方式向我们介绍了 Operator 的概念。对于许多应用程序,使用内置工具如 kapp-controller 或类似 Helm (helm.sh) 可以节省你构建完整的 Kubernetes CRD 和 Operator 实现的时间和复杂性。然而,CRDs 在现代 Kubernetes 生态系统中无处不在,如果我们不至少稍微探索一下它们,那将是对你的一种不公。

Operator 工厂

如果你确实认为你的应用程序足够先进,需要自己的细粒度 Kubernetes API 扩展,那么你将想要构建一个 Operator。构建 Operator 的过程通常涉及为自定义 Kubernetes CRDs 自动生成 API 客户端,并确保这些客户端在资源创建、销毁或编辑时执行“正确”的操作。网上有许多工具,例如 github.com/kubernetes-sigs/kubebuilder 项目,可以轻松构建完整的 Operator。

让我们启动一个 kind 集群,使用两个不同的 CNI 提供者(Calico 和 Antrea),以此作为深入了解我们如何使用 CRD 的方式。在这种情况下,因为我们还可能想要向我们的集群添加 NetworkPolicy 对象,所以让我们创建一个基于 Calico 的集群。我们可以使用 kind-local-up.sh 脚本来完成这项工作:

# make sure you've already installed kind and kubectl before running this...
$ git clone \
   https://github.com/jayunit100/k8sprototypes.git
$ cd kind ; ./kind-local-up.sh

Kubernetes 原生应用程序通常为它们的应用程序创建大量的 CRDs。CRDs 允许任何应用程序使用 Kubernetes API 服务器来存储配置数据,并启用 Operators 的创建(我们将在本章后面详细探讨)。Operators 是监视 API 服务器更改并随后运行 Kubernetes 管理任务的 Kubernetes 控制器。例如,如果我们查看我们新创建的 kind 集群,我们可以看到几个 Calico CRDs,它们为作为 CNI 提供者的 Calico 提供了特定的配置:

$ kubectl get crd                        ❶
NAME
bgpconfigurations.crd.projectcalico.org
bgppeers.crd.projectcalico.org
blockaffinities.crd.projectcalico.org
clusterinformations.crd.projectcalico.org
felixconfigurations.crd.projectcalico.org
globalnetworkpolicies.crd.projectcalico.org
globalnetworksets.crd.projectcalico.org
hostendpoints.crd.projectcalico.org
ipamblocks.crd.projectcalico.org
ipamconfigs.crd.projectcalico.org
ipamhandles.crd.projectcalico.org
ippools.crd.projectcalico.org
kubecontrollersconfigurations.crd.projectcalico.org
networkpolicies.crd.projectcalico.org
networksets.crd.projectcalico.org
$ kubectl get kubecontrollersconfigurations -o yaml
apiVersion: v1
items:
- apiVersion: crd.projectcalico.org/v1
  kind: KubeControllersConfiguration
  ...
  spec:
    controllers:
      namespace:
        reconcilerPeriod: 5m0s
      node:
        reconcilerPeriod: 5m0s
        syncLabels: Enabled
      policy:
        reconcilerPeriod: 5m0s
      serviceAccount:
        reconcilerPeriod: 5m0s
      workloadEndpoint:
        reconcilerPeriod: 5m0s
    etcdV3CompactionPeriod: 10m0s
    healthChecks: Enabled                ❷
    logSeverityScreen: Info
    prometheusMetricsPort: 9094          ❸
  status:
    environmentVars:
      DATASTORE_TYPE: kubernetes
      ENABLED_CONTROLLERS: node
    runningConfig:
      controllers:
        node:
          hostEndpoint:
            autoCreate: Disabled
          syncLabels: Disabled
      etcdV3CompactionPeriod: 10m0s
      healthChecks: Enabled
      logSeverityScreen: Info

❶ 列出我们集群中的所有 CRDs

❷ 如果我们认为不需要,我们可以禁用 healthChecks。

❸ 设置 Calico kube controller 提供 Prometheus 指标所使用的端口。这里是我们可以在需要时更改端口的地方。

有趣的是,Calico 将其配置存储在我们的 Kubernetes 集群中,作为一个具有自己类型的自定义对象。实际上,Antrea 也做了类似的事情。我们可以通过再次运行 kind-local-up.sh 脚本来检查 Antrea 集群的内部内容,如下所示:

$ kind delete cluster --name=kcalico              ❶
$ C=antrea CONFIG=conf.yaml ./kind-local-up.sh )  ❷

❶ 删除我们之前的集群

❷ 使用 Antrea 作为 CNI 提供者创建一个新的集群

几分钟后,我们可以查看 Antrea 使用的一些配置对象,就像我们之前对 Calico 所做的那样。以下代码片段显示了生成此输出的命令:

$ kubectl get crd
NAME
antreaagentinfos.clusterinformation.antrea.tanzu.vmwar
antreacontrollerinfos.clusterinformation.antrea.tanzu.
clusternetworkpolicies.security.antrea.tanzu.vmware.co
externalentities.core.antrea.tanzu.vmware.com
networkpolicies.security.antrea.tanzu.vmware.com
tiers.security.antrea.tanzu.vmware.com
traceflows.ops.antrea.tanzu.vmware.com

自定义 NetworkPolicys 对象类型:这是供应商真正喜欢 CRD 的一个例子

如果我们查看 Calico 和 Antrea CRDs,我们可以看到它们有一些共同点,其中之一就是网络策略。Kubernetes 中的 NetworkPolicy API 在使用某些 CNIs 时不支持所有可能的网络策略。例如,PortRange 策略(仅在 Kubernetes v1.21 中添加)在 Calico 和 Antrea 中都是一段时间的供应商特定策略。然而,由于 Calico 和 Antrea 都有自己的网络策略自定义资源,因此用户可以创建这些特定 CNIs 可以理解的较新的 NetworkPolicy 对象。CRDs 提供了一种优雅的方式来区分产品,而无需为管理这些产品创建供应商特定的工具。例如,您可以使用 kubectl edit 指令编辑 k8s.io NetworkPolicy 对象,就像您可以编辑任何 CRD 一样。

如果您想了解更多关于扩展 Kubernetes 网络安全能力的特定网络策略,您可能会对 mng.bz/aD9Ymng.bz/g4mn 感兴趣。当然,如果您还没有学习关于基本 Kubernetes NetworkPolicy API 的知识,您可能首先需要研究这些内容,请访问 mng.bz/enBZ

注意,为 Calico 或 Antrea 创建、编辑或删除 NetworkPolicy 对象会导致立即创建防火墙规则。然而,编辑这些应用程序的其他 CRDs 可能不会立即更改它们的配置,并且这些更改可能直到您重启相应的 Calico 或 Antrea Pods 才会实现。因此,尽管 CRDs 给您提供了扩展 Kubernetes API 服务器的方式,但它们并不保证您的新 API 构造将如何实现。

我们之前作为 CNI 提供者安装的 Calico 是通过 YAML 文件部署的,它关联着几个配置对象。或者,我们也可以使用 Tigera 的 operator 工具(github.com/tigera/operator)来部署它,这个工具会为我们处理 Calico YAML 清单的升级和创建。作为一个实时配置选项,我们还可以安装 calicoctl 工具,它也可以为我们配置其某些方面。

类似地,我们的 Antrea 安装也是使用 YAML 清单完成的(正如我们在之前的 CNI 章节中详细讨论的那样)。就像 Calico 一样,一个 Antrea 集群涉及到创建几个配置组件,这些组件存在于我们的集群内部(见 mng.bz/J1qa)。

我们现在已经探索了 Kubernetes 应用程序管理的许多方面。这个领域的新工具持续发布,因此这可以被视为您探索如何在生产中扩展和管理大量应用程序的开始。对于许多新来者来说,将 ytt 添加到简单的应用程序部署工作流程中可能已经足够他们启动 Kubernetes 应用程序自动化。

15.6 Tanzu Community Edition:Carvel 工具包的端到端示例

Tanzu Community Edition(TCE)是了解 Cluster API 和 Image Builder 项目的好方法。TCE 大量使用 Carvel 来处理极其复杂的集群配置配置文件,以及管理需要由最终用户升级和修改的微服务集群。其逻辑核心的大部分都是围绕 Carvel 家族工具构建的。

如果你想了解kappimgpkgytt以及 Carvel 堆栈中的其他工具如何在现实世界中使用,请查看github.com/vmware-tanzu/tcegithub.com/vmware-tanzu/tanzu-framework。这两个存储库构成了整个 VMware Tanzu Kubernetes 分布的开放源代码安装工具包。在这个分布中

  • ytt使用 Kubernetes Cluster API 规范安装和定义复杂的集群模板。

    例如,ytt用 Linux 集群规范替换了 Windows 集群规范文件(这些文件会手动将 Antrea 代理作为 Windows 进程安装)。随着时间的推移,这可以修改为使用其他 Cluster API 概念,但截至本文撰写时,你可以在mng.bz/p2Z0上看到这些示例的实际应用。ytt将这些目录中的各种文件逐个应用,创建一个单一的、庞大的 YAML 文件,该文件定义了整个集群的蓝图。

  • kappkapp-controller协调从 CNI 规范到这些分布中使用的各种附加应用的一切。

  • imgpkgvendir(我们并未深入探讨)也被用于各种容器打包和发布管理任务。

如果你想了解更多关于 Carvel 工具的信息,你可以加入 Kubernetes Slack 上的#carvel 频道(slack.k8s.io)。在那里,你将找到一个充满活力的提交者社区,他们可以帮助你解决关于这些工具的特定和一般性问题。

Antrea LIVE 节目

作为一个简短的说明,关于 Carvel 工具包各个方面的全面介绍,包括它如何借鉴了 Antrea 的一些概念,可以在 Antrea LIVE 节目中找到。直播的节目可以在antrea.io/live上查看。本书中包括 Prometheus 指标、CNI 提供者等内容,在其他广播中已有涉及。

摘要

  • 使用kubectl和大型 YAML 文件管理 Kubernetes 上的应用是一种简单的方法,但很快就会力不从心。有许多优秀的工具可以帮助你处理 YAML 超载。ytt是我们在本章中介绍的一个。

  • Carvel 工具包有几个应用,帮助我们以高级别编排 Kubernetes 应用。

  • 你可以使用yttkustomize干净地实现 YAML 文件的定制。

  • ytt 可以通过在覆盖文件中添加一个 overlay/match 子句来匹配 YAML 文件的任意部分,该子句在读取原始文件之后应用。然后,它在一个简单、预先存在的标准 Kubernetes YAML 文件之上构建。

  • 您可以使用像 kappHelm 这样的工具将不同 YAML 资源集合视为单个应用程序。

  • 如果您想以有状态的方式打包应用程序,但又不想构建 Operator,您可以使用像 kapp-controller 这样的工具。kapp-controller 会以有状态的方式管理应用程序资源集合,随着时间的推移进行管理。这比构建一个完整的 Operator 简一步,但可以通过几秒钟的时间完成,并且具有许多相同的优势。

  • Operator 可以用来在 Kubernetes 中定义更高级别的 API。这些 API 了解您应用程序的特定生命周期,通常涉及将 Kubernetes 客户端打包到您在集群中运行的容器中。

  • Calico 和 Antrea 都实现了 Kubernetes Operator 模式,用于高度复杂的 Kubernetes API 扩展。这使得您可以通过创建和编辑 Kubernetes 资源来完全管理它们的配置。

  • Carvel 工具包和本书中的许多其他主题在 Antrea 的 YouTube 直播中都有涉及,可以在 antrea.io/live 上观看。

posted @ 2025-11-15 13:07  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报