你的平台团队接手了 500 台裸机、2000+ 微服务实例:机器 CPU 利用率常年 <15%(每个服务独占机器怕互相干扰)、发布靠 Ansible 脚本逐台推、机器挂了靠 on-call 半夜爬起来手动迁移。目标:把 2000 个实例塞进这批机器、利用率提到 40%+、发布自动化、节点故障 30 秒内自愈。这就是 Google 内部 Borg、以及开源 Kubernetes 要解决的问题。
graph TD
U["用户 / CI
kubectl apply YAML"]
API["API Server
唯一写入口 · 校验/鉴权"]
ETCD[("etcd
集群唯一真相
Raft 强一致")]
SCHED["Scheduler
pod → node 绑定"]
CM["Controller Manager
调和循环 reconcile"]
subgraph node["Worker Node ×N"]
KUBELET["kubelet
node agent"]
CRI["容器运行时
containerd + runc"]
PROXY["kube-proxy / CNI
Service 转发"]
POD["Pods
namespace+cgroup 隔离"]
end
U --> API
API <--> ETCD
SCHED -.watch/bind.-> API
CM -.watch/reconcile.-> API
KUBELET -.watch 本节点 pod.-> API
KUBELET --> CRI --> POD
PROXY --> POD
classDef ctrl fill:#1a2530,stroke:#64c8ff,color:#e8eef5
classDef store fill:#2a1530,stroke:#ff7ab6,color:#e8eef5
classDef work fill:#0e2030,stroke:#5eead4,color:#e8eef5
class API,SCHED,CM ctrl
class ETCD store
class KUBELET,CRI,PROXY,POD work
核心是 watch + reconcile:所有组件不直接互相调用,而是 watch API Server 的期望状态,各自把现实拉向期望
组件职责:API Server 是唯一写 etcd 的入口,负责鉴权、校验、乐观并发(resourceVersion)。etcd 用 Raft 存整个集群的期望+实际状态,是唯一真相源。Scheduler 只做一件事:把未绑定的 pod 挑一个 node 写回去。Controller Manager 跑几十个控制器(Deployment/ReplicaSet/Node…),每个都是「观察实际 vs 期望,有差就动作」的死循环。kubelet 是节点代理,watch 分配到本节点的 pod,调 containerd 拉起容器。
核心 trade-off:用内核隔离换启动速度和密度,代价是共享内核 = 隔离弱于虚拟机。
原理:容器不是「轻量虚拟机」,它就是一个普通 Linux 进程,只是被两组内核特性框住。namespace 管「你能看见什么」——pid(独立进程树,容器里 PID 1)、net(独立网卡/IP/端口)、mnt(独立文件系统视图)、uts(主机名)、ipc、user(UID 映射)。cgroup(control group)管「你能用多少」——CPU 配额、内存上限、IO 带宽、PID 数量。镜像则靠 overlayfs 分层:只读层共享,写时复制。三者合起来:进程以为自己独占一台机器,实际和几十个邻居共享同一个内核。
| 隔离强度 | 启动 | 密度/开销 | 适用 | |
|---|---|---|---|---|
| 容器 (runc) | 弱(共享内核,一个内核漏洞全穿) | ~ 毫秒 | 高(无 guest OS) | 同信任域微服务 |
| VM (KVM) | 强(独立内核+hypervisor) | ~ 秒级 | 低(每个一份 OS) | 多租户强隔离 |
| gVisor/Kata | 中强(用户态内核 / 微 VM) | 亚秒 | 中 | 跑不可信代码 |
# cgroup v2 给一个容器限 1.5 核 + 512MB(伪 shell)
mkdir /sys/fs/cgroup/mysvc
echo "150000 100000" > /sys/fs/cgroup/mysvc/cpu.max # 每 100ms 给 150ms CPU 时间 = 1.5 核
echo 536870912 > /sys/fs/cgroup/mysvc/memory.max # 超了触发 OOM kill
echo $CONTAINER_PID > /sys/fs/cgroup/mysvc/cgroup.procs
# 内存超限 → 内核 OOM killer 杀进程 → kubelet 记 OOMKilled → 按 restartPolicy 重启
核心 trade-off:声明式(说「我要 3 个副本」)比命令式(说「启动一个容器」)更能自愈,代价是所有逻辑变成最终一致的异步循环,难调试。
原理:K8s 里你不「启动容器」,你声明期望:一个 Deployment 说「nginx:1.25,3 副本」。之后 Deployment 控制器创建 ReplicaSet,ReplicaSet 控制器保证「实际 pod 数 == 3」。每个控制器都是同一个模式的 reconcile loop:读期望、读实际、算 diff、执行、写回状态,永不停。删掉一个 pod → 实际=2≠期望=3 → 控制器立刻补一个。这就是自愈的本质:没有「故障处理特例代码」,故障只是一次让实际偏离期望的事件,循环自然把它拉回来。Service 提供稳定 VIP + DNS,把一组随时生灭的 pod IP 抽象成一个不变的入口。
# 每个控制器的骨架(伪代码)——K8s 全靠这个循环
def reconcile(desired, actual):
if actual.replicas < desired.replicas:
create_pods(desired.replicas - actual.replicas) # 缺了补
elif actual.replicas > desired.replicas:
delete_pods(actual.replicas - desired.replicas) # 多了删
# 关键:幂等 + level-triggered(看当前状态,不看事件流)
# 漏掉一个事件没关系,下一轮 loop 看到的还是当前真实差异
核心 trade-off:bin-packing 塞得越满利用率越高,但留给突发和故障转移的余量越少。
原理:调度器对每个待调度 pod 分两阶段。Filter(预选):排除不满足硬约束的 node——资源不够、node selector 不匹配、污点(taint)未被容忍、反亲和冲突。Score(优选):给活下来的 node 打分——资源最空闲的(LeastAllocated 铺开) 还是最满的(MostAllocated 打包省机器)、亲和性偏好、镜像本地已有。选最高分绑定。自愈靠三种探针:liveness(挂了就重启)、readiness(没准备好就摘出 Service 端点,但不重启)、startup(慢启动服务的宽限期)。节点级:node 心跳超时 → 标记 NotReady → 驱逐上面的 pod → 别处重建。
# liveness vs readiness:最常被混淆,配错=雪崩
livenessProbe: { httpGet: {path: /healthz}, periodSeconds: 10, failureThreshold: 3 }
# 连续 3 次失败 → 重启容器
readinessProbe: { httpGet: {path: /ready}, periodSeconds: 5 }
# 失败 → 从 Service 摘掉,不接新流量,但不重启(等依赖恢复)
# ⚠️ 陷阱:liveness 探针探了下游依赖(如 DB),DB 抖一下 → 全部 pod 被判死重启 → 自造雪崩
核心 trade-off:用每 pod 一个 Envoy sidecar 换来「零改代码」的重试/熔断/mTLS/可观测,代价是每跳多一次代理、资源翻倍。
原理:微服务多了,重试、超时、熔断、mTLS、流量切分、链路追踪这些「网络关切」如果写进每个服务的业务代码,就得为每种语言维护一套库、且升级要改所有服务重新发版。Service Mesh 把这些能力抽到一个 sidecar 代理(Envoy)里,和业务容器同 pod,劫持所有进出流量。业务代码只管发普通 HTTP,重试/加密/指标全由 sidecar 透明完成。数据平面(一堆 Envoy)+ 控制平面(Istio 的 istiod)分离:控制平面把路由规则、证书下发给所有 Envoy,Envoy 负责真正转发。
kubectl apply 到 pod 跑起来,中间经过哪些组件?② node 宕机后 pod 多久、如何被重建?③ 为什么声明式比命令式更能自愈?④ Service Mesh 解决什么、代价是什么、什么时候不该上?⑤ liveness 和 readiness 区别,配错各会怎样?edge-triggered 意味着「删除 pod」是一个事件,控制器收到才动作。问题:网络分区、控制器重启、watch 断连都会丢事件——一旦漏掉「pod 被删」,实际副本数永久少 1,没人纠正。
level-triggered 每轮循环都重新读当前真实状态算 diff:漏了事件没关系,下一轮 list 到「实际 2 ≠ 期望 3」照样补齐。控制器重启后做一次全量 list 就自动对齐,无需重放事件历史。
代价是:必须幂等(同一 diff 执行多次结果一致),且有轮询/resync 开销。K8s 用 informer 缓存 + 周期性 resync 兼顾:平时靠 watch 增量省流量,定期全量兜底防漏。这正是「自愈」能力的根:故障不是特例,只是又一次让 level 偏离的状态。
etcd 单 Raft group,写要多数派持久化 fsync,写吞吐有硬上限(每秒万级量级),且所有 API Server watch 的扇出也从它出。规模逼近数千 node 时,症状:apply 延迟升高、watch 事件堆积、list 全量拖垮内存。
缓解:① 把高频变动的 events 对象拆到独立 etcd 实例,别和核心对象抢 IO;② API Server 侧 watch cache,让众多 client 复用一份 watch,不直接压 etcd;③ 分页 list + resourceVersion 避免全量;④ 控制 CRD/对象数量与更新频率(滥用 status 高频写是常见杀手);⑤ 终极手段——拆集群。单集群总有规模天花板,Google Borg 早就用「cell」分片,K8s 生产也普遍多集群 + 联邦,用爆炸半径换可扩展性。
塞满 90% 时,集群几乎没 headroom。一台 node 挂 → 上面十几个 pod 全变 Pending 要重新调度 → 但其他 node 也都 ~90% 满,放不下 → pod 卡 Pending,或触发 autoscaler 加机器(有分钟级冷启动延迟)。同时存活 node 要吸收挂掉 node 的流量,本就接近饱和 → 连锁过载 → 更多 node 被压垮。这就是相关性故障(correlated failure)。
对立本质:故障转移需要「别处有空位」接住难民 pod,而空位就是被浪费的利用率。高利用率=没空位=转移失败。工程解法不是二选一,而是显式预留:保留 N+1 或按可用区留 headroom(如每 AZ 留够扛住整个 AZ 挂的余量)、用 PodDisruptionBudget 限制同时不可用数、反亲和把副本摊开。本质和容量规划的 P99/排队论 headroom 是同一件事(Day 27)。
三笔账:① 资源——每个 sidecar 常驻几十~上百 MB 内存 + 一份 CPU,1000 pod = 1000 份,可能占集群 10-20% 的资源纯做代理;② 延迟——每次服务调用现在要过「本地 Envoy 出 → 对端 Envoy 入」两跳代理,每跳亚毫秒但累积、且 P99 尾部被放大;③ 运维——sidecar 版本升级要滚动重启所有业务 pod,注入失败/启动顺序(sidecar 没起来业务先发流量)都是坑。
去 sidecar 动机:这些开销对「只想要 mTLS + 基础指标」的团队性价比太低。Ambient mode 把 L4(mTLS、TCP 路由)下沉到 node 级共享代理(ztunnel),只在需要 L7(HTTP 路由、重试)时才走一层共享 waypoint 代理;Cilium 用 eBPF 直接在内核态做 L4 策略和负载均衡,连代理进程都省了。代价是 L7 能力和每-pod 隔离粒度要重新权衡——这是「能力全 vs 开销低」谱系上的重新选点,不是免费午餐。
容器隔离全靠内核的 namespace/cgroup。这意味着攻击面就是整个 Linux 内核——一个内核提权漏洞(container escape)就能从容器逃到宿主,进而看到同宿主其他租户的容器。同信任域的自家微服务无所谓,但公有云上邻居可能是攻击者跑的恶意代码,一次逃逸=跨租户数据泄露,不可接受。
AWS 的解法:Lambda 和 Fargate 底层不用裸容器,而用 Firecracker microVM——每个函数/任务跑在一个极简 KVM 虚拟机里,有独立 guest 内核,用 hypervisor 这条比内核 syscall 窄得多、更易审计的边界隔离租户。它砍掉传统 VM 的设备模拟,启动压到 ~百毫秒、内存开销极小,拿回接近容器的密度同时保住 VM 级隔离。同类思路还有 Google gVisor(用户态重实现内核 syscall 拦截)、Kata Containers(轻量 VM)。结论:隔离强度和密度可以部分兼得,但从来不是免费的,多租户跑不可信代码必须付这笔隔离税。