一个怀疑者与 Kubernetes 的初次接触(2024)
摘要
一位资深系统工程师兼 Kubernetes 怀疑者分享了他亲身体验 Kubernetes 的过程,他认为 Kubernetes 的价值主要源于其控制循环和声明式期望状态,而不仅仅是一张编排架构图。
暂无内容
查看缓存全文
缓存时间: 2026/10/17 19:50
# 一个怀疑论者与 Kubernetes 的初次接触
Source: https://blog.davidv.dev/posts/first-contact-with-k8s/
我从事系统管理 / 工程 / 基础设施开发工作已经很多年了,不知怎的一直避免与 Kubernetes 有任何交集。这种回避并非偶然;我的工作需要对物理环境中的工作负载放置进行非常严格的控制。
随着时间推移,我在没有确凿依据的情况下逐渐对 Kubernetes 形成了怀疑态度,唯一模糊的认知就是它"是一种新的做事方式"以及"不必要的复杂性"。
我觉得是时候基于事实而非印象来形成观点了,于是我写下这篇学习笔记。
虽然有大量教程涵盖 Kubernetes 集群的使用和运维,以及对各组件的基本描述,但对我来说并不太管用。这些资料大多只是浅浅地介绍了各组件*做什么*,却往往缺少背后的原理和权衡取舍。
本文旨在从一个我认为有用的视角来阐述这些概念,但并非追求面面俱到。
## 一个"标准"的描述
Kubernetes 允许你运行任意工作负载,并提供以下能力:
- 指定资源需求(CPU、磁盘、内存、实例数……)
- 动态伸缩实例数量
但更重要的是,它为你*做到*的事情:
- 选择应用程序运行在哪个主机上
- "自愈"(崩溃的实例会自动重启,实例会自动从故障主机中迁出)
- 通过 DNS 暴露服务进行服务发现
而它只*要求*你将工作负载打包为一个 [Docker 镜像](https://kubernetes.io/docs/concepts/containers/images/),这看起来是一个合理的代价。
组成集群的基本构建块可以定义如下:
- [Pod](https://kubernetes.io/docs/concepts/workloads/pods/):工作单元,由一组 Docker 镜像及其配置组成
- [Node](https://kubernetes.io/docs/concepts/architecture/nodes/):运行 Kubernetes 节点代理(kubelet)的计算机,负责执行 Pod
- [Cluster](https://kubernetes.io/docs/concepts/architecture/):`Node`的逻辑集合,连同控制平面
- [Service](https://kubernetes.io/docs/concepts/services-networking/service/):一组 Pod 的逻辑分组
- [Namespace](https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/):`Cluster`的逻辑子划分,为名称提供作用域(类似于 DNS 搜索域)
用图示来看这些概念之间的关系会更直观:
虽然这个解释还算过得去,但对我来说并不太有用——很多基础设施平台看起来都*长得差不多*像这张图,这里并没有什么特别之处。
## 我认为重要的部分
我的观点是,Kubernetes 的大部分价值源于两个核心概念:
### 控制循环
[工作负载](https://kubernetes.io/docs/concepts/workloads/)由一组[控制循环](https://en.wikipedia.org/wiki/Control_loop)(命名为 [Controller](https://kubernetes.io/docs/concepts/architecture/controller/))来管理。
控制循环会通过传感器观察特定变量,如果有必要,会持续地通过控制元件执行*动作*,以达到*期望状态*。
一个有趣的细节是,控制元件并不一定*直接*影响传感器观察到的内容。
这个描述非常笼统,下面举几个例子:
- 一个需要处理队列中事件的工作负载
- 期望状态:队列为空
- 传感器:队列深度
- 控制元件:事件处理服务器的数量
- 一个需要水平扩展以应对用户负载的工作负载
- 期望状态:保持 P90 延迟低于 X 毫秒
- 传感器:后端的延迟指标
- 控制元件:启动新的 Pod 来应对需求
- 通用健康监控
- 期望状态:所有节点健康
- 传感器:节点的健康指标
- 控制元件:将故障节点从集群中移除
### Service
[Service](https://kubernetes.io/docs/concepts/services-networking/service/)是一个网络层面的概念,为一组后端 Pod 提供稳定的访问,它提供:
- **稳定标识**:不变的(虚拟)IP 地址和一条 DNS 记录
- 负载均衡:将传入流量分发到各个 Pod
- 服务发现:允许其他组件找到并与此 Service 通信
与"经典"集群相比,一个 Service 提供了类似于以下组合的抽象:
- N 个运行 [Keepalived](https://www.keepalived.org/) 的节点 + NGINX(带有动态后端集合)
- 指向虚拟(Keepalived)IP 的 CNAME 记录
每当创建一个新的 Service 时:
1. 会为它分配一个虚拟 IP
2. 每个节点都会更新其网络配置(`netfilter`),将发往每个 Service 虚拟 IP 的数据包转发到后端 Pod。
网络配置由运行在每个节点上的 Controller 更新:[kube-proxy](https://kubernetes.io/docs/concepts/overview/components/#kube-proxy),其主要职责是监视所有`Service`对象的状态,并在每次变化时更新`netfilter`规则。
这种设计意味着所有节点都充当 Service 流量的负载均衡器,因此 Service 流量转发不存在单点故障,且流量转发能力随集群规模而扩展。
对我来说,这种设计的一个有趣特性是每个节点都会收到**每一个** Service 的规则,这带来了一些影响:
- `kube-proxy`的实现非常简单
- 规则数量可能很大,这对于某些流量转发实现可能是个问题(`iptables`会线性评估每条规则)
- 在某些场景下流量会被多次转发,以更高的可用性换取集群的额外负载
- 当 Pod 迁移到另一个节点时,在旧的 DNS 条目过期之前,流量会被转发两次
- 不需要在`kube-proxy`层面处理 DNS 条目的缓存失效问题
- 行为异常的客户端(例如不在重连前重新解析 DNS 的客户端)仍能正常工作
我们可以追踪一个从"Pod 1"到"Service 1"的数据包的旅程:
## 工作负载管理
或者叫"东西如何、何时、何地运行"
你可以手动创建一个 Pod,使用如下 YAML 定义:
```yaml
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80
```
但这样做的话,你只是*手动*调度了这个 Pod 的*一次*执行;这很简单,但有一些讨厌的限制:
- 如果承载 Pod 的节点宕了,Pod 不会被重新调度
- 如果 Pod 因为节点资源压力而被终止(通过[驱逐](https://kubernetes.io/docs/concepts/scheduling-eviction/node-pressure-eviction/)机制),它同样不会被重新调度
- 你不能说"现在我想要两个这样的 Pod"
你可以改为创建一个 [ReplicaSet](https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/),它是一个 Controller,作为其控制循环的一部分,它会确保始终有正确数量的 Pod 副本在运行,这就解决了节点故障/驱逐导致你的 Pod 被删除的问题。
```yaml
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: nginx-replicaset
spec:
replicas: 3
template:
spec:
containers:
- name: nginx
image: nginx:1.14.2
```
ReplicaSet 允许你更新期望的副本数量,*仅此而已*(比如更新`nginx`版本,或者暴露另一个端口)——你需要删除资源再重新创建它。
为了解决这些限制,你可以使用 [Deployment](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/),它是一个 Controller,会确保正确数量的 Pod **最终**运行着**正确的镜像**。
**最终**这个词非常重要,因为 Deployment 允许你更新 Pod 运行的镜像(或版本),并选择变更速率,涵盖了"版本升级"的标准工作流。
换句话说,当你更新一个 Deployment 时,它会创建一个新的 ReplicaSet 并逐渐将其扩容,同时缩减旧的 ReplicaSet,从而实现滚动更新。
## 存储管理
或者叫"数据如何持久化并随你的 Pod 一起移动"
默认情况下,Pod 内的容器不共享存储/文件系统。
如果你想在 Pod 内的容器之间共享存储,可以使用[临时卷(Ephemeral Volume)](https://kubernetes.io/docs/concepts/storage/ephemeral-volumes/),其生命周期与 Pod 绑定——Pod 关闭时,数据即被删除。
临时卷看起来没什么用[^1],但它们确实有一些使用场景:
1. 边车容器(sidecar)进行日志采集
2. 边车容器为主应用程序预填缓存
如果你想拥有*真正持久*的存储,可以使用[持久卷(Persistent Volume)](https://kubernetes.io/docs/concepts/storage/persistent-volumes/),其生命周期与 Pod 的生命周期是解耦的[^2]。
你可以用一个[声明(Claim)](https://kubernetes.io/docs/concepts/storage/persistent-volumes/)来定义这样的卷,如下所示:
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nginx-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
```
然后你可以在 Pod 中这样使用它:
```yaml
apiVersion: v1
kind: Pod
metadata:
name: nginx-with-pv
spec:
containers:
- name: nginx
image: nginx
volumeMounts:
- name: html-volume
mountPath: /usr/share/nginx/html
volumes:
- name: html-volume
persistentVolumeClaim:
claimName: nginx-pvc
```
一些有趣的地方:
- 同一个 Claim 的多个使用者(Pod)会共享存储,和以往一样,如果有多个写入者,这是相当有风险的
- PersistentVolume 由不同的驱动支撑(NFS、SMB、iSCSI……),但 Pod 并不清楚使用的是哪一个,所以 Pod 无法依赖某些特性(比如原子重命名)
如果你想要*每个实例*拥有独立的持久存储,可以使用 [StatefulSet](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/),它在一个资源中定义了:工作负载(Pod)、副本数量、挂载配置和卷声明*模板*。
`StatefulSet`的一些有趣特性:
1. Pod 拥有稳定的主机名(`nginx-0`、`nginx-1`……)
2. 部署按顺序执行:
1. 按升序创建 Pod(0, 1, …)
2. 按降序删除 Pod(3, 2, …)
3. 按降序更新 Pod(3, 2, …)
这看起来很*奇怪*,我在网上找到的解释说编号较低的实例是"核心的"或"更稳定的"——我无法理解,因为一个频繁崩溃的 Pod 也不会被重新编号。也许按"运行时长"降序更新会更好?也许这种"资历"/"稳定性"的设定是后来追加到 Pod 上的,而 Kubernetes 只是需要*某种*顺序?
在运维层面,到目前为止我仍然不清楚如何手动检查持久卷的内容,除了进入运行中的容器并在里面执行命令;如果我的容器里没有 shell 怎么办?
## 总评
我们已经覆盖了 Kubernetes 的主要部分——控制循环、Service、工作负载和存储。学习这些概念让我形成了期望中的那种"不那么毫无根据的"观点(不过,我仍然没有实际运维或使用过一个集群)。
总的来说,我认为这些**概念**非常有道理,特别是 Service(包括网络模型和 kube-proxy)非常出色,而保持 Controller 模式作为资源管理的引擎是正确的运作方式。
我留下了一些开放式的问题/吐槽,就放在这里供你们品味
[Controller 文档](https://kubernetes.io/docs/concepts/architecture/controller/)中说:
> 随着工作在进行中以及控制循环自动修复故障,你的集群可能在任何时刻发生变化。这意味着,你的集群可能永远无法达到稳定状态。只要你的集群的控制器在运行并能做出有用的更改,整体状态是否稳定并不重要。
为什么状态不稳定不重要?如果我在运维一个无法收敛的集群,我想立刻知道!
- 既然有 Controller 模式,为什么不支持"云原生"架构?
- 我希望能有一个 ReplicaSet 根据队列深度的某种简单计算来自动调整`replicas`(比如:队列深度 / 16 = 副本数)
- 定义这类事件(队列深度、打开的连接数、响应延迟)的接口会很棒
- 基本上就是 [Horizontal Pod Autoscaler](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/),但传感器不仅仅是"CPU"
- 为什么存储和网络实现是"树外"的(CNI / CSI)?
- 我猜是为了"模块化"和"关注点分离"
- 既然如此,为什么又对云厂商有显式支持?
- 比如:LoadBalancer 支持 AWS/GCP/Azure/……
而对于最大的开放话题,虽然这不是 Kubernetes 的锅,但我有一番牢骚不吐不快
### 字符串类型
Kubernetes"社区"似乎铁了心要让自己的生活更艰难,他们居然基于**文本插值**来构建工具。就好像他们看到了 [Ansible](https://www.ansible.com/) 然后想:"嘿,这个看起来好痛苦,我们来搞这个吧!"
以这个 [RabbitMQ scaler](https://keda.sh/docs/2.14/scalers/rabbitmq-queue/)(KEDA,由 Microsoft 维护)为例:
> value: 触发时使用的消息积压量或每秒发布速率。(当 mode: MessageRate 时,此值可以是浮点数)
`value`的*类型*取决于`mode`的*值*?这是什么鬼?还有`mode`是一个字符串而它本该是枚举??
[Helm](https://github.com/helm/helm)是怎么火起来的??像这样的工具怎么可能*做文本模板*——明明有那么多事情可以做,偏偏选了这个?
随便拿一个 [NGINX chart](https://artifacthub.io/packages/helm/bitnami/nginx?modal=template&template=deployment.yaml) 看看大多数值是怎么用*手动字符串缩进*处理的:
```
{{- include "common.tplvalues.render" ( dict "value" .Values.commonAnnotations "context" $ ) | nindent 4 }} 14 {{- end }}
```
或者
```
{{- toYaml { "rollingUpdate": {}, "type": "RollingUpdate" } .Values.updateStrategy | nindent 4 }}
```
为什么我们要用计算机来生成一种结构化语言(YAML),却要靠手动加空格来让语法合法?不应该存在这种中间文本模板表示形式。
相似文章
求职面试教会我的 Kubernetes 知识
一位求职者发现,小型初创公司采用 Kubernetes 并非出于技术可扩展性,而是为了组织层面的好处,如一致性、共享知识和可追溯性。这篇文章反思了 Kubernetes 对小型团队的非技术优势。
@jaga_prasanna: https://x.com/jaga_prasanna/status/2079614504451838426
一个教育性的推文串,使用像亚马逊仓库和巡航控制这样易于理解的类比来解释Kubernetes的核心概念,分解了组件和期望状态机制。
@BenjDicken: 对Kubernetes的精彩介绍。没有它,需要处理100种复杂性和边缘情况。有了它,你就有…
一条推文强调了对Kubernetes的精彩介绍,并引用了Fatih Arslan关于控制理论和反馈循环的文章,这些理论用于构建能够自我修复、弹性伸缩、可扩展数千个数据库的系统。
@Pragmatic_Eng: “你如何说服其他工程师?你并不是他们的经理”。@kelseyhightower,前谷歌杰出工程师…
Kelsey Hightower 分享了他如何运用同理心工程会议——让资深工程师在手动安装 Kubernetes 过程中体验困难——来建立信任并推动云工具改进。
@devops_nk:有人构建了一个完全在你的浏览器中运行的Kubernetes。这可能是学习Kubernetes的最简单方式。- 无需Do…
一位开发者通过将核心组件重写为TypeScript,并在AI辅助下进行代码转换,将Kubernetes移植到浏览器中完全运行。该工具无需安装,专为学习、教学和面试准备而设计。