Skip to main content

2. 单机到集群的痛点和K8S架构

单机到集群,多了哪些问题​

上一章你跑通了 docker run,机器上可能有几十个容器。老板跟你说"再起一百个",你看了眼机柜:只剩三台空机器了。

问题来了:

  1. 一百个容器怎么分到三台机器上? 手动 ssh 进去 docker run?那怎么保证 CPU 和内存不超?
  2. 容器 A 怎么找到容器 B? 单机上你可以硬写 IP,现在 B 可能在三台机器中的任意一台上。
  3. 节点挂了怎么办? 放到那台机器上的容器就没了,没人知道它原来跑在哪。
  4. 版本怎么升级? 两百个容器,你不可能逐个 docker stop && docker pull && docker run。

这四个问题的本质是:容器从单机扩展到集群后,多出两个核心难题——编排(在哪跑)和发现(怎么找到)。

K8S 的答案:声明式 + 控制器​

K8S 给出的方案很简单——也可能让你第一次看到觉得很蠢:

你只需要告诉 K8S 你要什么,不用告诉它怎么做。

你说:我需要 3 个 nginx 副本。K8S 自己找机器、自己拉镜像、自己起容器。死了一个自动补。你从来不用 ssh 到任何节点。

这叫声明式 API。在 K8S 里,你写的 YAML 不是操作指令,而是"期望状态"。

apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
replicas: 3

你从来没写过"先停旧的,再起新的,curl 一下 /health"这种命令。你就写了 replicas: 3。剩下的 K8S 自己搞定。

架构全景:那些组件在干嘛​

K8S 架构分两层:控制面(Control Plane) 和 数据面(Data Plane / Node)。

┌──────────────────────────────────────────────────┐
│ Control Plane │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ API │ │ CM │ │ Scheduler │ │
│ │ Server │ │(Controller│ │ │ │
│ │ │ │ Manager) │ │ │ │
│ └────┬─────┘ └──────────┘ └──────────────┘ │
│ │ │
│ ┌────▼─────┐ │
│ │ etcd │ ← 所有状态的唯一源头 │
│ └──────────┘ │
└──────────────────────────────────────────────────┘
│ kubelet 向 API Server 汇报
│ API Server 向 kubelet 下指令
┌────────▼──────────────────────────────────────────┐
│ Node │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ kubelet │ │ kube- │ │ Container │ │
│ │ │ │ proxy │ │ Runtime │ │
│ └──────────┘ └──────────┘ └──────────────┘ │
└──────────────────────────────────────────────────┘

每个组件的职责:

组件它的接口干什么
API ServerREST + Watch所有操作的入口,把 YAML 变成 etcd 里的数据,同时广播变更事件
etcdgRPC (etcd v3 API)存储集群所有状态,提供 Watch 推送
Controller ManagerWatch API Server监听到期望状态变化,驱动实际状态向期望靠拢
SchedulerWatch + Bind API发现没有节点的 Pod,决定它跑在哪台机器
kubelet向 API Server 汇报/接收每台机器的代理人,负责让本机跑指定 Pod
kube-proxyWatch Service/Endpoints维护每台机器上的 iptables/ipvs 规则,让流量找到 Pod

思考题:上面列了 6 个组件,如果一定要选一个"最重要的",你选哪个?提示:想想哪个组件挂了之后,整个集群最先不可用——不是"哪个功能最重要",而是"哪个是单点"。另外,K3S 把这个组件塞进单进程这件事本身,说明它是不可被外部替代的。

你写了一个 YAML 执行 kubectl apply,看看这一路发生了什么:

kubectl → API Server (校验 + 写 etcd)
etcd Write → API Server Watch 推送
├→ Controller Manager: "有人要 3 个 nginx,我看到 0 个,创建 3 个 Pod"
│ → API Server: "我写了 3 个 Pod 到 etcd,nodeName 为空"
│ → Scheduler: "有 3 个 Pod 没分配节点,节点 2 的 CPU 最多,分配过去"
│ → API Server: "Pod-1 nodeName=node2, Pod-2 ..."
└→ kubelet: "嘿,我被分配了 Pod-1,拉镜像、起容器"
→ API Server: "Pod-1 Running"

整个流程像一台巨大的事件驱动状态机。每个组件都在 Watch,发现不一致就动手修正。没有谁在发号施令,没有"总控制程序"——这架构正式名叫 Control Loop(控制循环)。

思考题:Controller Manager、Scheduler、kubelet 全都在跟 API Server 通信——这么多组件互相调用,怎么保证 Controller Manager 不是伪造的、kubelet 确实是那个 Node 上的?组件间的认证具体怎么做,用的是 JWT 还是 TLS 证书?还有,证书会过期——到期前怎么自动换,不停机?

K8S vs K3S:组件打包的区别​

K8S 的通用部署是把这几个组件拆成独立进程,每个都是独立的二进制。听起来整洁,代价是维护它们的 systemd 服务、证书、以及版本一致性。

K3S 的思路不一样——只打包到一个二进制。k3s server 一个命令就在单个进程里串了 API Server、Scheduler、Controller Manager。代码里更直接,K3S 把 kube-apiserver 当库 import 进来:

// k3s/pkg/cluster/https.go - 直接调用 kube-apiserver 的库代码
import "k8s.io/kubernetes/cmd/kube-apiserver/app"

func server(ctx context.Context, cfg *config.Control) error {
return app.NewAPIServerCommand().ExecuteContext(ctx)
}

K8S 需要你把 kube-apiserver 当外部命令启动,K3S 在代码里调起它。对外的 API 一模一样,kubectl 分不出谁是谁。这也是整本书贯穿的思路——接口是一样的,实现的选择各有考量。

没了 Docker 也能转​

第一章讲了 Docker 做容器的机制,但要澄清一个常见的误解:K8S 并不是"Docker 的集群版"。

K8S 1.24 开始正式把 dockershim 移除了。不是 K8S 不跑容器了,而是 K8S 不再依赖 Docker 这个具体实现。K8S 通过 CRI(Container Runtime Interface) 定义了一套容器运行时的标准接口,任何实现了 CRI 的运行时都能用。

// k8s.io/cri-api/pkg/apis/runtime/v1/api.proto
service RuntimeService {
rpc RunPodSandbox(RunPodSandboxRequest) returns (RunPodSandboxResponse);
rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse);
rpc StartContainer(StartContainerRequest) returns (StartContainerResponse);
// ...
}

containerd、CRI-O、Docker(通过 cri-dockerd 适配器)都是 CRI 的实现。K3S 内置了 containerd 作为默认 CRI 实现,K8S 一般你装哪个用哪个。CRI 这玩意儿在第 13 章会展开聊,现在你只需要记住:K8S 通过接口解耦了容器运行时。

思考题:Docker Swarm 是 Docker 公司早期对抗 K8S 的方案,但基本输了。现在 2024 年了,Docker 公司靠什么活着?它拿什么跟 K8S 生态竞争——或者说,它已经不竞争了,改走别的路线了?

小结​

这一章核心就一句话:K8S 是声明式的事件驱动状态机,控制循环驱动实际状态向期望状态靠拢。

记住三个关键概念:

  1. 声明式 API——你声明"我要什么",控制器做"怎么做"
  2. 控制循环——Watch → 比较 → 修正,不用人为干预
  3. 接口解耦——CRI、CNI、CSI 把具体实现抽象掉,K8S 只定义"需要什么能力"

解答​

1. Docker 公司现在靠什么活着?​

Docker Swarm 2015 年推出,功能跟 K8S 重叠——编排、服务发现、负载均衡一应俱全。问题是它比 K8S 晚进入生产战场,社区生态小得多。到 2019 年 Docker 公司营收不行了,把 Docker Enterprise 卖给了 Mirantis,Swarm 基本宣告退场。

现在 Docker 公司的策略不是"再造一个 K8S",而是做开发者体验。核心产品线:

产品做什么
Docker Desktop本地开发环境——macOS/Windows 上一键起 K8S 集群
Docker Hub容器镜像仓库——大部分公共镜像的发布渠道
Docker Compose多容器本地编排——docker-compose up 定义开发环境
Docker Scout镜像安全扫描

Docker 不再跟 K8S 竞争,而是趴在 K8S 生态上:Docker Desktop 内置了 K8S(单节点),Docker Compose 可以把应用描述翻译成 K8S YAML。用 Docker 本地开发 → 推到 K8S 生产环境,变成了一条顺滑的流水线。这比再造一个编排器聪明得多。

2. 哪个组件最重要?​

API Server。 但不是因为它"功能最重要"——而是它是唯一的写入点。

K8S 的规则很简单:所有状态变更必须经过 API Server。Controller Manager 不能直接写 etcd,Scheduler 不能直接调 kubelet——任何组件想"做点什么事",都得通过 API Server 的 REST + Watch 接口。API Server 挂了之后集群不是"部分不可用",是完全不可用——新 Pod 没法调、事件没人推、状态改不了。

K3S 把这个体现得很直接——k3s server 里 API Server 是唯一不能去掉的组件。K3S 可以不用自己的 Scheduler(用外部调度器),可以不内置 Controller Manager(跑自己的 Controller),但 API Server 永远在——因为其他组件都依赖它提供的接口。

3. 组件间的认证如何解决?​

所有组件跟 API Server 通信用的都是 TLS 双向认证(mTLS)——不是用户名密码,不是 JWT,是证书。

每个组件有自己的证书:

组件证书里的 Common Name(身份)
kubeletsystem:node:<node-name>
Controller Managersystem:kube-controller-manager
Schedulersystem:kube-scheduler
kubectl 用户jimmy(你配的 CN)

通信过程是双向的——双方都验证对方的证书:

kubelet → API Server:
1. API Server 出示自己的证书 → kubelet 用 --kubelet-certificate-authority 的 CA 验证
2. kubelet 出示自己的证书 → API Server 用 --client-ca-file 的 CA 验证
3. 提取证书里的 CN = system:node:node-1
4. RBAC 检查:这个 CN 有没有权限做这个操作?

所以 K8S 集群的信任根是两个 CA:

CA签什么证书
etcd CAetcd 集群内部的 peer + client 证书
Kubernetes CAAPI Server + kubelet + Controller Manager + Scheduler 的客户端证书

这些证书在 kubeadm init 时自动生成,存在 /etc/kubernetes/pki/ 下:

/etc/kubernetes/pki/
ca.crt ← Kubernetes CA 的证书(信任锚点)
ca.key ← Kubernetes CA 的私钥
apiserver.crt ← API Server 的服务端证书
apiserver-kubelet-client.crt ← API Server 连接 kubelet 用的客户端证书
front-proxy-ca.crt ← 聚合 API 的 CA

K3S 的处理更简单——因为所有组件都跑在同一个进程里,K3S 启动时如果发现没有证书,自己生成一个 CA 然后把所有证书签好:

// k3s/pkg/server/cert.go 简化
func GenerateServerCerts(config *config.Control) error {
// 1. 如果没有 CA,自签一个
ca := loadOrGenerateCA("/var/lib/rancher/k3s/server/tls/server-ca.crt")
// 2. 用这个 CA 签出 API Server、kubelet、etcd 等所有证书
certs := []string{"client-admin", "server", "kubelet", "kube-proxy", ...}
for _, name := range certs {
signCert(ca, name)
}
}

4. 证书更新怎么做?​

证书有过期时间——kubeadm 生成的默认 1 年。到期了怎么办?

kubeadm 的方案:kubeadm certs renew 命令,把 /etc/kubernetes/pki/ 下面快过期的证书重新签发一遍:

# 查看哪些证书快过期了
kubeadm certs check-expiration

# 一键续签所有证书
kubeadm certs renew all

# 重启控制面组件让它们加载新证书
systemctl restart kubelet

续签原理很简单——CA 的私钥(ca.key)还在,用它给 API Server 和 kubelet 重新签发一份新证书(新 NotAfter 延长 1 年),替换掉旧文件。因为 CA 没变,信任链不断,组件重启后加载新证书就能正常通信。

但有个硬伤:kubeadm certs renew 不会自动跑——你得记得做,每年一次。如果证书真的过期了,kubelet 连不上 API Server,集群就瘫了。K8S 1.31 之后 kubeadm 开始引入自动续签的实验性功能(kubeadm certs renew 配 CronJob),但生产上还是建议配监控告警提前提醒。

K3S 的方案更省心:K3S 启动时自动检查证书是否在 90 天内过期,到期前自动续签——不需要你手动跑任何命令。因为 K3S 知道所有组件都在同一个进程里,续签后直接 reload 就行,没有"通知外部组件"的问题。

下一章先不说 Pod,我们看看整个状态机的"内存"——etcd 是怎么可靠存储这些状态的。