2. 单机到集群的痛点和K8S架构
单机到集群,多了哪些问题
上一章你跑通了 docker run,机器上可能有几十个容器。老板跟你说"再起一百个",你看了眼机柜:只剩三台空机器了。
问题来了:
- 一百个容器怎么分到三台机器上? 手动
ssh进去docker run?那怎么保证 CPU 和内存不超? - 容器 A 怎么找到容器 B? 单机上你可以硬写 IP,现在 B 可能在三台机器中的任意一台上。
- 节点挂了怎么办? 放到那台机器上的容器就没了,没人知道它原来跑在哪。
- 版本怎么升级? 两百个容器,你不可能逐个
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 Server | REST + Watch | 所有操作的入口,把 YAML 变成 etcd 里的数据,同时广播变更事件 |
| etcd | gRPC (etcd v3 API) | 存储集群所有状态,提供 Watch 推送 |
| Controller Manager | Watch API Server | 监听到期望状态变化,驱动实际状态向期望靠拢 |
| Scheduler | Watch + Bind API | 发现没有节点的 Pod,决定它跑在哪台机器 |
| kubelet | 向 API Server 汇报/接收 | 每台机器的代理人,负责让本机跑指定 Pod |
| kube-proxy | Watch 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 是声明式的事件驱动状态机,控制循环驱动实际状态向期望状态靠拢。
记住三个关键概念:
- 声明式 API——你声明"我要什么",控制器做"怎么做"
- 控制循环——Watch → 比较 → 修正,不用人为干预
- 接口解耦——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(身份) |
|---|---|
| kubelet | system:node:<node-name> |
| Controller Manager | system:kube-controller-manager |
| Scheduler | system: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 CA | etcd 集群内部的 peer + client 证书 |
| Kubernetes CA | API 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 是怎么可靠存储这些状态的。