简介:《Kubernetes in Action中文版》面向希望系统掌握容器编排的开发者、运维人员与云原生入门读者,由马尔科·卢克沙著、七牛容器云团队翻译,围绕在 Kubernetes 中部署分布式容器应用展开。全书从 Docker 与 Kubernetes 的由来和发展讲起,通过逐步扩展一个应用实例,讲解 Pod、服务、部署、存储、网络等核心概念,并延伸到监控、调试与伸缩等进阶主题,帮助读者理解 Kubernetes 架构并形成可操作的实践路径。书中还结合容器编排系统的定位,说明 Kubernetes 如何管理分布式应用的部署结构与线上流量、高效组织容器和服务,并降低底层网络和硬件设施带来的负担;开篇的推荐序与译者序也补充了国内团队实践 Kubernetes 的背景。压缩包为 1 个 PDF 文件,大小约 15.62MB,便于在电脑或阅读器上查阅。目前已有 1212 人学习,适合作为从入门到进阶阶段的系统读物和案头参考。
1. 声明式 API:为什么删掉一个 Pod 它自己会回来
很多人在 K8S 上踩的第一个坑,是把kubectl apply当成远程的nohup:敲下回车,就以为事情办完了。事实恰好相反——你提交的那份 YAML 从来不是一条“命令”,而是一份“期望状态”的声明,API Server 把它写进 etcd 就算交差了,真正让容器跑起来的是另一批进程。这个认知差是后面几乎所有玄学现象的源头:Pod 被删了自己长回来、改了镜像名字却没立刻生效、副本数从 3 调到 5 之后多出来的两个“凭空出现”。
理解“谁在观察、谁在收敛、收敛失败时看什么”,比背一百条 kubectl 命令值钱得多。这篇不教你怎么搭集群,而是把 K8S 从 YAML 到容器的这条链路拆开,看看声明式 API、控制器循环、Service 与 kube-proxy、调度器与 cgroup 这几层各自负责什么,以及它们之间的边界一旦被误判会翻什么车。
2. Pod 与控制器:为什么最小的调度单元不是容器
如果你从 Docker 世界过来,最不习惯的一点就是:K8S 里能调度的最小单位不是容器,是 Pod。这个设计不是为了让名词显得高级,而是因为“两个进程必须共享网络和存储”这件事,在真实的分布式应用里太常见了——边车代理、日志采集、本地缓存,都需要和主容器看见同一块网卡、同一个目录。Pod 就是为这种“必须同生共死”的约束准备的边界。
2.1 pause 容器与共享命名空间:Pod 到底共享了什么
一个 Pod 落地到节点上,kubelet 做的第一件事不是拉业务镜像,而是先起一个极其轻量的沙箱容器(社区里常叫 pause 容器)。这个容器几乎不干活,它唯一的价值是持有 network namespace、IPC namespace 和 UTS namespace,然后业务容器以“加入已有命名空间”的方式启动。这就是为什么同一个 Pod 里的两个容器可以用localhost互相访问——它们根本没有各自独立的网卡。
这带来几个直接后果:同一 Pod 内容器的端口会冲突,因为它们共享同一个网络栈;Pod 有一个统一的 IP,这个 IP 属于沙箱而不是某个业务容器;容器重启不会改变 Pod IP,但 Pod 重建一定会换 IP(除非用了特殊网络方案)。存储层面则是另一套逻辑,volume 挂在 Pod 上,容器通过 volumeMount 各自挂载,所以共享文件靠的是挂同一个卷,而不是共享文件系统。
apiVersion: v1 kind: Pod metadata: name: demo-shared spec: containers: - name: writer # 主容器:周期性写文件 image: busybox command: ["sh", "-c", "while true; do date >> /data/out.log; sleep 5; done"] volumeMounts: - name: shared-data mountPath: /data # 与 sidecar 挂同一个卷 - name: reader # 边车:读取同一份文件 image: busybox command: ["sh", "-c", "tail -f /data/out.log"] volumeMounts: - name: shared-data mountPath: /data volumes: - name: shared-data emptyDir: {} # Pod 级别生命周期,Pod 删除即清空这段清单里最关键的两行不是镜像,而是两个mountPath指向同一个emptyDir。emptyDir的生命周期绑在 Pod 上,而不是容器上,所以 writer 容器崩溃重启之后,之前写进去的内容还在;但整个 Pod 被删除重建,卷就跟着清空。这就是“容器级”和“Pod 级”生命周期的分界线,很多持久化相关的误判都是从这条线上滑出去的。
提示:如果两个容器需要互相通过
localhost:端口通信,它们必须同 Pod;如果它们需要独立伸缩、独立发布,就绝对不该同 Pod。这条判断标准比任何最佳实践清单都实用。
2.2 Deployment → ReplicaSet → Pod:三层对象的职责切分
初学者常以为 Deployment 直接管 Pod,其实中间还有一层 ReplicaSet。Deployment 管的是“版本”,ReplicaSet 管的是“某个版本应该有几个副本”,Pod 是被 ReplicaSet 用标签选择器圈住的那批实例。滚动更新时你会同时看到新旧两个 ReplicaSet 并存,这正是这套三层结构的价值所在:回滚不需要重新构建,只要把流量切回旧 RS 就行,旧 RS 的模板还完整地躺在那儿。
控制器的工作方式非常朴素,本质上是一道减法:读一遍期望副本数,数一遍实际存活的 Pod,差值为正则创建,为负则删除。它不关心“上一个动作有没有成功”,只关心“现在和期望差多少”。这种 level-triggered 的思路,比“记住上一步做了什么”的 edge-triggered 思路抗故障能力强得多——控制器重启、消息丢失、节点失联,都不会让系统永久跑偏,下一轮循环自然会把它拉回来。
apiVersion: apps/v1 kind: Deployment metadata: name: web spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 # 更新期间最多允许多出 1 个 Pod maxUnavailable: 0 # 更新期间不允许可用副本低于期望值 selector: matchLabels: app: web # 必须与下面 template 的标签一致,否则被 API Server 拒绝 template: metadata: labels: app: web spec: containers: - name: web image: nginx:1.25 ports: - containerPort: 80 readinessProbe: # 没有它就绪判定,maxUnavailable: 0 会直接卡死 httpGet: path: / port: 80 periodSeconds: 5maxSurge: 1搭配maxUnavailable: 0是一种“先加后减”的策略:先起一个新 Pod,等它就绪,再删一个旧的,全程可用副本不低于 3。这个组合很安全,但它有一个隐形前提——新 Pod 必须真的能变成就绪状态。如果有人为了图省事把readinessProbe删了,K8S 会把“容器跑起来了”当成“服务可用了”,滚动更新看起来飞快,实际上流量已经被打进还没预热完的进程里。
2.3 标签与选择器:对象之间靠“约定”而不是“引用”连接
K8S 的各个对象之间几乎没有硬引用:Service 不知道 Pod 的 ID,Deployment 也不持有 Pod 的句柄,它们全靠标签选择器去“认领”。这种设计让对象解耦得非常彻底——你可以手工起一个带相同标签的 Pod,Service 会毫不犹豫地把它加进后端名单;你也可以给 Pod 打上新的标签,让它瞬间从某个 Service 的后端里消失。
代价是,标签成了整个系统里最容易出问题的地方。一个很典型的场景:改了template.metadata.labels却忘了同步改spec.selector.matchLabels,API Server 会直接拒绝这次 apply,因为 Deployment 的 selector 在创建后是不可变的。另一个更隐蔽的场景是给已有的 Pod 手动加了额外标签,导致两个 Service 的选择器同时命中了它,流量被分摊到了不该去的地方,排查时看日志完全对不上。
3. Service 与网络:为什么 ClusterIP 上根本没有进程在监听
“我在 Pod 里 curl 一个 Service 的 ClusterIP,能通,但我netstat却看不到任何进程监听这个地址。”这是最容易被当成 bug 的正常现象。ClusterIP 从来不是一个真实的网卡地址,它是一组由 kube-proxy 维护的转发规则的“名字”。理解这一点,才能理解为什么 Service 不通时,去查 Pod 的日志是白费力气。
3.1 Pod IP 的可变性:为什么不能把它写进配置
Pod 是被控制器反复创建删除的对象,每次重建都会拿到新 IP。即便不考虑重建,扩缩容也会让后端集合不断变化。如果把 Pod IP 硬编码进上游服务的配置里,那么每一次发布都是一次手工改配置的事故现场。Service 存在的意义就是把“一组随时变化的 IP”抽象成“一个稳定的虚拟地址 + 一个稳定的 DNS 名”。
这里有个常被忽略的细节:ClusterIP 在 Service 的生命周期内是固定的,删除重建 Service 才会变。所以集群内部服务之间互访,正确姿势是用 Service 名(依赖集群 DNS 解析到 ClusterIP),而不是记 IP。DNS 名在跨命名空间时是<service>.<namespace>.svc.cluster.local,同命名空间下可以直接用短名。
3.2 kube-proxy:iptables 规则怎么把 ClusterIP 变成 Pod IP
每个节点上都跑着一个 kube-proxy,它 watch 所有 Service 和 Endpoints 的变化,然后把变化翻译成本机的转发规则。在 iptables 模式下,一条 Service 会被翻译成一条 KUBE-SVC 链,每个后端 Pod 对应一条 KUBE-SEP 链;请求进入 KUBE-SERVICES 链后被按概率分流到某个 KUBE-SEP,最后做 DNAT 改成 Pod 的真实 IP 和端口。
# 查看集群里所有 Service 的入口规则(需要节点上的 root 权限) iptables -t nat -L KUBE-SERVICES -n --line-numbers # 输出大致形如: # Chain KUBE-SERVICES (2 references) # target prot opt source destination # KUBE-SVC-XXXX tcp -- 0.0.0.0/0 10.96.0.10 /* kube-system/kube-dns */ # KUBE-SVC-YYYY tcp -- 0.0.0.0/0 10.96.12.34 /* default/web */ # 再看某条 SVC 链内部,后端是如何按概率分配的 iptables -t nat -L KUBE-SVC-YYYY -n # 多个后端时会出现 --probability 0.33333 之类的分流条件--probability是关键:iptables 模式下的负载均衡本质是“按概率随机选”,不是轮询,也不感知后端当前的连接数。所以你会看到两个副本的流量比大致是 1:1,但短时间内完全可能连续几次都打到同一个 Pod 上。想改成真正的连接级负载均衡,通常要切到 IPVS 模式,它用哈希表做转发,规则数量随 Service 数量线性增长而不是指数增长,大集群下性能差别很明显。
提示:如果你在节点上
tcpdump一个 ClusterIP,会发现在离开 Pod 之后目标地址已经被 DNAT 成 Pod IP 了。这解释了为什么后端服务看到的来源 IP 常常是节点 IP 或 Pod IP,而不是客户端的真实地址——想做真实来源透传,得在 Service 上配externalTrafficPolicy一类的选项。
3.3 Endpoints 与就绪探针:Service 的后端名单从哪来
Service 自己不维护后端列表,它有一个伴生对象叫 Endpoints(新版本里更细粒度的是 EndpointSlice),由控制平面根据 selector 实时计算出来。也就是说,一个 Pod 要被 Service 选中,需要同时满足两件事:标签匹配选择器,并且通过就绪探针。第二点经常被忽略——容器 Running 不等于 Ready。
readinessProbe和livenessProbe的职责完全不同,混用会出大问题。readiness 决定“要不要把这个 Pod 放进流量后端”,失败只会把它摘出去,容器还在跑;liveness 决定“这个容器是不是已经僵死了”,失败会触发重启。如果你把 liveness 探针指向一个会随下游抖动而失败的接口,那下游一慢,你的容器就被自己人一个个杀掉,形成级联雪崩。血泪经验是:liveness 只该探测“进程自身是否还能响应”,任何依赖外部服务的检查都放进 readiness。
4. 调度与资源:request/limit 背后是一本什么账
资源相关的线上事故,八成可以归到两个误解上:以为limits是“最多用这么多”,以为不写requests就不会占资源。真实的语义要绕得多——requests是调度器做加减法用的账面数字,limits是内核 cgroup 真正执行的硬墙,两者走的根本不是同一条路径。
4.1 调度器两阶段:Filter 与 Score,request 决定“能不能放”
调度一个 Pod 分两步。第一步过滤(Filter),把所有不满足硬性条件的节点剔除:剩余可分配资源是否够requests之和、节点污点是否被容忍、节点亲和性是否匹配、端口是否冲突。第二步打分(Score),在剩下的节点里按均衡度、镜像本地缓存情况等维度排序,选分最高的那个。
注意第一步用的是requests而不是limits。一个requests.cpu: 100m、limits.cpu: 4的 Pod,在调度器眼里只占 0.1 核,节点上可以塞进几十个这样的 Pod,然后它们在高峰期一起去抢 CPU——这就是典型的超卖。所以requests的写法应该接近“这个服务平时的真实占用”,而不是“随便填个小数字好调度”。填小了调度很顺,运行时全是限流。
4.2 limit 落到 cgroup:CPU 被限流,内存被 OOMKill
两种资源的超额行为完全不同,这是最容易踩坑的地方。CPU 是可压缩资源,超过limits.cpu之后内核的 CFS 调度器会让你等——表现为响应变慢、延迟毛刺,但进程不会被杀。内存是不可压缩资源,超过limits.memory之后内核只能杀掉容器里的某个进程,这就是OOMKilled,退出码 137。
resources: requests: cpu: 500m # 调度器按这个数算账,0.5 核 memory: 512Mi # 节点上预留 512Mi limits: cpu: "1" # 内核按 100000/100000 的 quota 限流,超了只是变慢 memory: 1Gi # 超了直接 OOMKill,退出码 137判断到底是限流还是被 OOM,看两个地方就够了:kubectl describe pod的Last State里如果写着Reason: OOMKilled,那就是内存撞墙;如果是延迟问题,去节点上看 cgroup 的cpu.stat里nr_throttled和throttled_time有没有涨。CPU 限流最阴险的地方在于它不产生任何错误日志,只在监控曲线上表现为周期性的延迟尖峰,很多人会先怀疑网络和数据库,绕一大圈才回到 cgroup。
4.3 QoS 三级与驱逐顺序:为什么有的 Pod 先死
当节点资源真的不够时,kubelet 会主动驱逐 Pod 来保命,驱逐顺序由 QoS 等级决定。这个等级不是你显式声明的,而是从 requests 和 limits 的写法推导出来的。
| QoS 等级 | 判定条件 | 驱逐优先级 | 典型场景 |
|---|---|---|---|
| Guaranteed | 每个容器都设置了 cpu 和 memory 的 request 且等于 limit | 最低(最后被驱逐) | 数据库、核心网关 |
| Burstable | 至少一个容器设置了 request,但不满足 Guaranteed | 中等 | 大多数业务服务 |
| BestEffort | 所有容器都没设置任何 request/limit | 最高(最先被驱逐) | 临时任务、调试 Pod |
这张表解释了为什么“不写 resources 最省事”是一种危险的习惯:BestEffort 的 Pod 在节点压力下会被优先清掉,而它的主人往往还以为是自己代码崩了。更麻烦的是,驱逐是按 Pod 整体计算的,一个 Pod 里只要有一个容器没写 limit,整个 Pod 就掉到 Burstable。
5. 避坑与排查:那些看起来像 K8s bug 的现象
集群出问题时,最浪费时间的动作是“猜”。下面这五条是我见过复现率最高的误判,每一条都按“现象 → 原因 → 解决”拆开,顺序大致对应 Pod 的生命周期:调度、拉镜像、启动、运行、被访问。
5.1 Pod 一直 Pending:调度器根本没找到能放的节点
现象:Pod 创建成功,状态永远是Pending,kubectl logs什么都看不到。
原因:Pending意味着 Pod 还没被绑定到任何节点,日志自然无从谈起。常见触发点有三个:节点剩余可分配资源小于requests之和;节点带污点而 Pod 没有对应容忍;Pod 声明了 PVC 但存储类没有可用卷,导致卷绑定卡住。前两个看kubectl describe pod末尾的Events,会明确写出Insufficient cpu或node(s) had untolerated taint。第三个的线索在 PVC 上,kubectl get pvc会显示Pending。
解决:先看 Events 再动手,不要盲扩节点。资源不够就核对是不是requests填得离谱;污点问题要么加容忍要么换节点池;存储问题去看 StorageClass 和后端存储的健康状态。我一般的习惯是先kubectl get events --sort-by=.lastTimestamp扫一遍最近事件,比逐个 describe 快得多。
5.2 ImagePullBackOff:问题往往不在镜像地址
现象:Pod 卡在ImagePullBackOff或ErrImagePull,重启次数为 0。
原因:这两个状态是同一件事的两个阶段,前者是“拉取失败后进入退避重试”。真正的原因藏在 Events 里:镜像名拼错、tag 不存在、私有仓库缺少imagePullSecrets、节点到仓库的网络不通。有一个特别隐蔽的情形是本地开发用了imagePullPolicy: IfNotPresent,镜像在本地机器上有,节点上没有,看起来“明明构建成功了却拉不到”。
解决:kubectl describe pod看 Events 里的具体报错,区分是 401(凭证问题)、404(名字或 tag 问题)还是超时(网络问题)。生产环境建议用不可变 tag 或 digest,避免latest带来的不确定性;凭据统一用 Secret 挂载,别在节点上手工docker login,那台节点一旦重建就重现故障。
5.3 CrashLoopBackOff:容器启动就死,日志却看不到
现象:Pod 反复重启,间隔越来越长,kubectl logs提示容器不在运行或者输出为空。
原因:容器已经死过一次,当前实例还没起来。这时候kubectl logs默认看的是“当前容器”,自然是空的。另一个可能是进程在打印任何日志之前就退出,比如启动命令写错、配置文件挂载成了目录却当成文件读、依赖的环境变量缺失。
解决:加--previous看上一个实例的日志;如果还是空,就把command换成sleep之类的长驻命令进容器里手动跑一遍原始启动命令,能立刻看到真实报错。还有一种情况是livenessProbe配得太激进,容器还在预热就被判死,表现和代码崩溃一模一样——把探针的initialDelaySeconds调大就能验证这个猜想。
5.4 内存持续增长后被 OOMKilled:不是内存泄漏也能被杀
现象:Pod 运行一段时间后突然重启,退出码 137,业务侧看起来毫无征兆。
原因:退出码 137 = 128 + 9,即被 SIGKILL。在容器里,最常见的施暴者就是内核的内存 cgroup。注意这不一定是泄漏——很多运行时(比如某些语言的 GC 策略)倾向于把内存吃满再回收,只要瞬时超过limits.memory,内核不会跟你商量。此外,同一个 Pod 里如果有边车容器,它们共享的是 Pod 级别的内存上限压力,一个边车涨上去也可能连累主容器。
解决:先看describe pod的Last State确认OOMKilled,再对比监控里的实际峰值和 limit 的差距。调整方式有两种:把 limit 抬到峰值之上留出余量,或者把 limit 去掉只保留 request(前提是节点资源可控)。不推荐的做法是“把 limit 设成特别大”,那等于把风险从容器转移给了整台节点。
5.5 Service 访问不通:先查后端名单,再查端口映射
现象:Pod 内部curl一个 Service 名超时或连接被拒,但后端 Pod 明明 Running。
原因:按概率从高到低排——Service 的 selector 没匹配到任何 Pod(Endpoints 为空);targetPort写的是容器内不存在的端口;readinessProbe一直失败导致 Pod 被摘出后端;或者有 NetworkPolicy 拦了流量。还有一个纯配置错误:客户端和服务不在同一命名空间,却用了短域名,DNS 解析直接失败。
解决:kubectl get endpoints <svc>是最快的分诊手段,为空就是选择器或就绪状态的问题,非空就往端口和策略方向查。端口层面注意port是 Service 暴露的端口,targetPort是容器监听的端口,两者写反了照样能创建成功,只是永远连不上。再往下就是 DNS,起一个临时 Pod 用nslookup验证解析链。
6. 设计取舍:把复杂性交给控制循环,值不值
把前面几章串起来看,K8S 的几乎所有行为都能归到同一条设计主线上:不让任何组件去“记住历史”,只让它反复比较“现状”和“期望”。控制器不比对上一步动作、kube-proxy 不维护连接状态、调度器不记录上次调度结果,全靠 watch 到的最新快照重新算一遍。这套做法换来的是极强的自愈能力——任何组件重启、任何消息丢失,系统都能靠下一轮循环回到正轨;代价是它不承诺“立刻”,只承诺“最终”。
这个取舍在工程上有两个直接后果,值得每个用它的人心里有数。第一,所有控制器逻辑必须幂等。创建 Pod 之前先确认存不存在、写 Endpoints 之前先算最新集合,这类“先查后写”的代码是常态。第二,并发冲突靠乐观锁解决。每个对象都带resourceVersion,更新时带上旧版本号,版本对不上就返回冲突让你重试,而不是加一把分布式锁。这就是为什么你在 Events 里偶尔会看到the object has been modified; please apply your changes to the latest version——不是错误,是设计。
想验证某个现象到底归哪一层管,我常用的办法是逐层截断:先kubectl get -w盯着对象状态变化的时间线,确认控制器有没有反应;再看 Events 确认是哪一层报的错;最后才进节点看 cgroup、iptables 这些内核层面的东西。顺序颠倒过来,很容易在日志里泡两小时,最后发现是requests填错了一个数量级。
DDx 这类问题最忌讳的是“改一个参数试试”。声明式系统的收敛是有延迟的,你改完立刻观察到的现象可能还是上一轮循环的结果。我吃过这个亏:改完maxUnavailable不到十秒就断定没生效,又改回去,结果两次变更叠在一起,滚动更新彻底卡住,最后只能回滚重来。从那以后,我每次动控制器参数都强制走一遍“改 → 等状态稳定 → 看 Events → 再看指标”的完整闭环,绝不在收敛过程中做第二次判断。希望这套拆解能帮你在下次遇到“看起来像 bug”的现象时,先想到是哪一层在说话。
本文还有配套的精品资源,点击获取