Day 49 Hard Containers Kubernetes Scheduling Service Mesh

容器与编排 — 声明式控制平面如何让机器自愈Containers & Orchestration: namespaces/cgroups, Kubernetes objects, Scheduling, Service Mesh

问题场景 + 需求约束

你的平台团队接手了 500 台裸机、2000+ 微服务实例:机器 CPU 利用率常年 <15%(每个服务独占机器怕互相干扰)、发布靠 Ansible 脚本逐台推、机器挂了靠 on-call 半夜爬起来手动迁移。目标:把 2000 个实例塞进这批机器、利用率提到 40%+、发布自动化、节点故障 30 秒内自愈。这就是 Google 内部 Borg、以及开源 Kubernetes 要解决的问题。

高层架构(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 拉起容器。

关键技术点

1. 容器原理:namespace 切视图 + cgroup 切资源

核心 trade-off:用内核隔离换启动速度和密度,代价是共享内核 = 隔离弱于虚拟机。

原理:容器不是「轻量虚拟机」,它就是一个普通 Linux 进程,只是被两组内核特性框住。namespace 管「你能看见什么」——pid(独立进程树,容器里 PID 1)、net(独立网卡/IP/端口)、mnt(独立文件系统视图)、uts(主机名)、ipcuser(UID 映射)。cgroup(control group)管「你能用多少」——CPU 配额、内存上限、IO 带宽、PID 数量。镜像则靠 overlayfs 分层:只读层共享,写时复制。三者合起来:进程以为自己独占一台机器,实际和几十个邻居共享同一个内核。

Trade-off(容器 vs 虚拟机 vs 安全沙箱):
隔离强度启动密度/开销适用
容器 (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 重启
现实案例:

2. 声明式对象模型:期望状态 + 调和循环

核心 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(声明式 vs 命令式):
现实案例:

3. 调度与自愈:过滤 → 打分 → 探针

核心 trade-off:bin-packing 塞得越满利用率越高,但留给突发和故障转移的余量越少。

原理:调度器对每个待调度 pod 分两阶段。Filter(预选):排除不满足硬约束的 node——资源不够、node selector 不匹配、污点(taint)未被容忍、反亲和冲突。Score(优选):给活下来的 node 打分——资源最空闲的(LeastAllocated 铺开) 还是最满的(MostAllocated 打包省机器)、亲和性偏好、镜像本地已有。选最高分绑定。自愈靠三种探针liveness(挂了就重启)、readiness(没准备好就摘出 Service 端点,但不重启)、startup(慢启动服务的宽限期)。节点级:node 心跳超时 → 标记 NotReady → 驱逐上面的 pod → 别处重建。

Trade-off(打包策略):
# liveness vs readiness:最常被混淆,配错=雪崩
livenessProbe:   { httpGet: {path: /healthz}, periodSeconds: 10, failureThreshold: 3 }
                 # 连续 3 次失败 → 重启容器
readinessProbe:  { httpGet: {path: /ready},   periodSeconds: 5 }
                 # 失败 → 从 Service 摘掉,不接新流量,但不重启(等依赖恢复)
# ⚠️ 陷阱:liveness 探针探了下游依赖(如 DB),DB 抖一下 → 全部 pod 被判死重启 → 自造雪崩
现实案例:

4. Service Mesh:把网络能力下沉到 sidecar

核心 trade-off:用每 pod 一个 Envoy sidecar 换来「零改代码」的重试/熔断/mTLS/可观测,代价是每跳多一次代理、资源翻倍。

原理:微服务多了,重试、超时、熔断、mTLS、流量切分、链路追踪这些「网络关切」如果写进每个服务的业务代码,就得为每种语言维护一套库、且升级要改所有服务重新发版。Service Mesh 把这些能力抽到一个 sidecar 代理(Envoy)里,和业务容器同 pod,劫持所有进出流量。业务代码只管发普通 HTTP,重试/加密/指标全由 sidecar 透明完成。数据平面(一堆 Envoy)+ 控制平面(Istio 的 istiod)分离:控制平面把路由规则、证书下发给所有 Envoy,Envoy 负责真正转发。

Trade-off(网络能力放哪):
现实案例:

扩展与优化(增长后怎么办)

常见陷阱 + 面试问题

1. liveness 探针探依赖 = 自造雪崩。 liveness 只应探进程自身健康。若探了下游 DB,DB 抖动会让全部 pod 被判死重启,把局部故障放大成全局。依赖健康交给 readiness。
2. 不设 request/limit 或设错。 不设 request → 调度器当它 0,超卖到 OOM;memory limit 设太低 → OOMKilled 反复重启;CPU limit 太低 → CFS throttle 导致 P99 毛刺。
3. 把 Pod 当宠物。 Pod 是牛不是宠物(cattle not pets),随时会被驱逐/重建,IP 会变。任何依赖固定 pod IP 或 pod 本地磁盘的设计都是错的——用 Service + PV。
4. 以为容器=安全隔离。 容器共享内核,一个内核提权漏洞可穿透所有容器。跑不可信代码要上 gVisor/Kata/microVM,别指望 namespace。
5. 面试高频: ① 一个 kubectl apply 到 pod 跑起来,中间经过哪些组件?② node 宕机后 pod 多久、如何被重建?③ 为什么声明式比命令式更能自愈?④ Service Mesh 解决什么、代价是什么、什么时候该上?⑤ liveness 和 readiness 区别,配错各会怎样?

深入资源

深入思考(点击展开答案)

1. 为什么 K8s 坚持 level-triggered(看当前状态)而不是 edge-triggered(响应事件)?如果控制器漏掉一个 watch 事件会怎样?

edge-triggered 意味着「删除 pod」是一个事件,控制器收到才动作。问题:网络分区、控制器重启、watch 断连都会丢事件——一旦漏掉「pod 被删」,实际副本数永久少 1,没人纠正。

level-triggered 每轮循环都重新读当前真实状态算 diff:漏了事件没关系,下一轮 list 到「实际 2 ≠ 期望 3」照样补齐。控制器重启后做一次全量 list 就自动对齐,无需重放事件历史。

代价是:必须幂等(同一 diff 执行多次结果一致),且有轮询/resync 开销。K8s 用 informer 缓存 + 周期性 resync 兼顾:平时靠 watch 增量省流量,定期全量兜底防漏。这正是「自愈」能力的根:故障不是特例,只是又一次让 level 偏离的状态。

2. etcd 用 Raft 保证强一致,但整个集群所有 watch 和写都压在它上面。这会成为什么规模的瓶颈?有哪些缓解手段?

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 生产也普遍多集群 + 联邦,用爆炸半径换可扩展性。

3. bin-packing 把机器塞到 90%,一台 node 挂了会发生什么连锁反应?为什么「高利用率」和「高可用」在这里天然对立?

塞满 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)。

4. Service Mesh 给每个 pod 加一个 Envoy sidecar。在一个 1000 pod 的集群,这笔开销具体贵在哪?业界为什么开始搞 ambient/eBPF「去 sidecar」?

三笔账:① 资源——每个 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 开销低」谱系上的重新选点,不是免费午餐。

5. 容器共享宿主内核。这个「特性」在多租户公有云(跑客户任意代码)场景下为什么是致命的?AWS 是怎么绕过的?

容器隔离全靠内核的 namespace/cgroup。这意味着攻击面就是整个 Linux 内核——一个内核提权漏洞(container escape)就能从容器逃到宿主,进而看到同宿主其他租户的容器。同信任域的自家微服务无所谓,但公有云上邻居可能是攻击者跑的恶意代码,一次逃逸=跨租户数据泄露,不可接受。

AWS 的解法:Lambda 和 Fargate 底层不用裸容器,而用 Firecracker microVM——每个函数/任务跑在一个极简 KVM 虚拟机里,有独立 guest 内核,用 hypervisor 这条比内核 syscall 窄得多、更易审计的边界隔离租户。它砍掉传统 VM 的设备模拟,启动压到 ~百毫秒、内存开销极小,拿回接近容器的密度同时保住 VM 级隔离。同类思路还有 Google gVisor(用户态重实现内核 syscall 拦截)、Kata Containers(轻量 VM)。结论:隔离强度和密度可以部分兼得,但从来不是免费的,多租户跑不可信代码必须付这笔隔离税