阶段一 · Kubernetes 核心

Pause 容器 / 共享机制 / 生命周期 / 探针 / 资源管理

前置回顾

第 01 篇的核心认知:

  • Pod 是 K8s 最小调度单元,是一组共享 Namespace 的容器集合
  • 所有组件通过 watch API Server 协作:Scheduler 选节点 → kubelet 拉镜像/创建容器

本篇深入 Pod 内部,逐一拆解 Pause 容器的结构,容器间的共享与隔离机制,以及生产环境必须掌握的探针和资源管理.

Pause 容器:Pod 的"骨架"

第 01 篇 Pod 创建全流程的第 5 步提到:runc 会先启动一个 Pause 容器,再启动用户容器.现在来详细看这个特殊容器到底是什么.

每个 Pod 里都有一个你看不到的特殊容器--Pause 容器(Pause Container).它是 Pod 里第一个启动,最后一个退出的容器.

Pod: nginx
┌────────────────────────────────────────────┐
│ │
│ Pause 容器 (sleep forever) │
│ ┌──────────────────────────────────────┐ │
│ │ 持有 Network Namespace │ │ ← 所有用户容器共享这个 NS
│ │ 持有 IPC Namespace │ │
│ │ 作为 PID 1 收割僵尸进程 │ │
│ └──────────────────────────────────────┘ │
│ ↑ ↑ ↑ │
│ ┌────┴────┐ ┌───┴───┐ ┌───┴────┐ │
│ │ nginx │ │ redis │ │ fluentd│ │ ← 用户容器(加入 Pause 的 NS)
│ └─────────┘ └───────┘ └────────┘ │
└────────────────────────────────────────────┘

Pause 容器只有约 700KB 大小(一个极简的 C 程序),做的事情就只有三件:

职责 说明
持有 Network Namespace 用户容器启动时不创建新网络 NS,而是加入 Pause 已有的 NS.Pod 内所有容器共享同一个 IP,同一个 localhost.
持有 IPC Namespace 容器间可通过 System V 信号量或 POSIX 消息队列通信(极少使用).
收割僵尸进程 作为 PID 1,用 wait() 回收孤儿进程.没有它的话,用户容器里产生的僵尸进程永远无法被清理.
# 在宿主机上可以看到 Pause 容器
$ crictl pods | grep nginx
POD ID CREATED STATE NAME NAMESPACE
abc123def... 2 mins ago Ready nginx default

# Pause 容器的 OCI spec 极简:只包含 /pause 二进制,启动后 sleep 无限循环
# 镜像大小 ~700KB,对比 nginx 镜像约 140MB

共享机制:Pod 内容器能看到什么

回顾阶段一第 02 篇--Linux Namespace 是隔离的基本单元.Pod 的共享机制就是有选择地共享或隔离这些 Namespace.

共享的 Namespace

共享的 Namespace 效果 典型场景
Network NS 同一个 IP,同一组端口,localhost 互通 nginx 通过 localhost:24224 把日志发给 fluentd
IPC NS 共享信号量和 POSIX 消息队列 极少数遗留场景,现代微服务基本不用
PID NS(可选,shareProcessNamespace: true) 容器间互相看到对方的进程 调试用(看 Sidecar 进程是否存活)
Volume 共享文件系统路径(通过 emptyDir 等方式挂载) nginx 写 access.log → fluentd 读取并转发
# 共享 Network NS 的实际效果
# Pod 内同时跑 nginx(端口 80)和 redis(端口 6379)

# 在 nginx 容器里:
$ curl localhost:6379 # → 能连到 redis!
$ ip addr # → eth0: 10.244.1.5

# 在 redis 容器里:
$ curl localhost:80 # → 能连到 nginx!
$ ip addr # → eth0: 10.244.1.5

# 不同 Pod 之间绝不能用 localhost,必须用 Pod IP + 端口

独立的 Namespace

独立的 Namespace 原因
Mount NS 每个容器有独立的 rootfs(OverlayFS),不共享才能各自跑不同镜像
PID NS(默认) 每个容器只能看到自己的进程,安全隔离
Cgroup 每个容器的 CPU/内存限制独立计算
User NS 安全考虑,各自 UID/GID 映射

Mount NS 不共享是关键设计

如果 Pod 内所有容器共享 Mount NS,那就必须用同一个镜像了.保持 Mount NS 独立,nginx 用 alpine 镜像,fluentd 用 debian 镜像,互不干扰--这正是 Pod 设计的精妙之处:网络共享,文件系统独立.

Pod 生命周期

五个 Phase

Pod 的 status.phase 是宏观状态,整个生命周期只增不减:

[*] --> Pending
Pending --> Running
Running --> Succeeded : 所有容器正常退出 exit 0
Running --> Failed : 某容器异常退出
Running --> Unknown : 节点失联
Phase 含义 时机
Pending Pod 已创建但尚未运行 已写入 etcd,等待 Scheduler 调度,或正在拉镜像,启动 Init Container
Running 至少一个容器在运行 所有容器已创建,至少一个处于运行中(含启动或重启中)
Succeeded 所有容器正常终止 所有容器 exit 0,且 restartPolicy=Never(典型:Job)
Failed 某容器异常终止 某容器 exit ≠ 0 且 restartPolicy=Never,或容器反复崩溃达到回退上限
Unknown 无法获取 Pod 状态 节点失联(kubelet 停止汇报心跳),常见于节点宕机或网络分区

容器状态 ≠ Pod 状态

Pod 的 phase 是汇总状态,每个容器有自己独立的 containerStatus.这是排查时最容易踩的坑:kubectl get pod 显示 Running,不代表所有容器都健康.

Pod phase: Running
├── containerStatuses:
│ ├── nginx: Running (restartCount: 0) ← 正常
│ ├── redis: Running (restartCount: 0) ← 正常
│ └── sidecar: Terminated (restartCount: 3) ← 这个在崩溃重启!
│ lastState: Terminated (reason: Error, exitCode: 1)

提示

  • 只要有一个容器在Running, Pod 的 phase 就是 Running
  • kubectl describe pod 才能看到每个容器的真实状态

重启策略(restartPolicy)

restartPolicy 作用于 Pod 内所有容器(K8s 1.28 前),由 kubelet 在容器退出时执行:

策略 行为 适用场景
Always(默认) 只要退出就重启,无论 exit code 长期运行的服务(Deployment,StatefulSet,DaemonSet)
OnFailure 仅非零退出码才重启 Job(任务执行完就结束,失败才重试)
Never 退出后绝不重启 一次性任务,Init Container

kubelet 实现了一个指数退避(exponential backoff) 机制:容器重启间隔从 10 秒开始,每次翻倍(20s → 40s → 80s...),上限 5 分钟.防止崩溃容器无限重启吃光节点资源.

探针(Probes)

为什么需要探针

容器进程在跑 ≠ 应用已经就绪.
一个 Java 服务启动后可能要花 60 秒加载 Spring 上下文;一个 Web 服务可能因为死锁卡住,进程不退出但再也无法响应请求.
从操作系统的视角看,这些容器都是"活着的"--进程存在,PID 正常--但从业务视角看它们早就废了.

探针就是 kubelet 定期对容器做的健康检查.
kubelet 按你在 YAML 里配置的规则,每隔几秒对容器执行一次检测(发 HTTP 请求 / 执行命令 / 尝试 TCP 连接),根据结果决定是否重启容器或摘除流量.
没有探针的话,K8s 只能靠"进程是否退出"来判断容器是否正常--这远远不够.

探针 = 医院的定期巡房.
护士(kubelet)每隔几分钟来看一次病人(容器):能说话吗?(liveness)能下床活动吗?(readiness)刚做完手术醒了吗?(startup).不同的检查结果对应不同的处置方案--而不是只看"心跳还有没有".

三种探针

这是 Pod 配置里最容易设错的部分:

探针 作用 失败后果 典型检查什么
startupProbe 应用是否启动完成 重启容器 数据库连接就绪,缓存预热完成
livenessProbe 应用是否还活着 重启容器 死锁,内存泄漏导致僵死
readinessProbe 应用能否接流量 从 Service Endpoints 移除(不杀容器) 线程池满,依赖服务不可达

三种探测方式

每种探针都支持同样的三种检查方法:

方式 怎么用 适用场景
exec 在容器内执行命令,返回 0 = 成功 没有 HTTP 端口的应用(如数据库,消息队列);检查本地文件
httpGet 发起 HTTP GET,2xx-3xx = 成功 Web 应用(最常用),可带自定义 Header
tcpSocket TCP 三次握手成功 = 成功 非 HTTP 协议的 TCP 服务(如 gRPC,MySQL,Redis)

startupProbe:慢启动的守护神

这是最容易被忽略但最重要的探针.它的唯一职责是:给慢启动应用争取时间,防止被 liveness 误杀.

容器启动


startupProbe 开始探测 ──→ 失败 → 重启容器(从头再来)

│ 成功!(startupProbe 此后不再运行)

livenessProbe + readinessProbe 同时开始
│ │
│ 失败 │ 失败
▼ ▼
重启容器 从 Service Endpoints 移除
(恢复健康后自动加回来)

没有 startupProbe 的经典翻车场景

Java 应用启动需要 90 秒(加载 Spring 上下文,建立连接池).
如果给 livenessProbe 设了 initialDelaySeconds: 30, failureThreshold: 3(最多 60 秒容忍窗口).启动 60 秒后 liveness 判定失败 → 杀掉容器 → 重新启动 → 又 60 秒被杀 → 无限重启循环.
你以为是代码 bug,其实是探针配错了.startupProbe 就是解决这个问题的.

# 正确用法:startupProbe 给 Java 应用 5 分钟启动窗口
startupProbe:
httpGet:
path: /actuator/health
port: 8080
periodSeconds: 10
failureThreshold: 30 # 30 × 10s = 5 分钟
livenessProbe: # startup 成功后才开始
httpGet:
path: /actuator/health
port: 8080
periodSeconds: 10
failureThreshold: 3 # 3 × 10s = 30 秒容忍

关键参数:

参数 默认值 说明
periodSeconds 10 探测间隔
failureThreshold 3 连续失败多少次算失败;对于 startupProbe,这个值应该设大一些(如 30),给足启动时间,而不是调大 periodSeconds
successThreshold 1 连续成功多少次算成功(startup/liveness 必须是 1)
timeoutSeconds 1 单次探测的超时时间
initialDelaySeconds 0 容器启动后等多久再开始探(有 startupProbe 时通常设为 0)

startupProbe 失败 ≠ 立即重启容器

每次探测失败只是记一次数,然后按 periodSeconds 继续探下一次.只有连续失败次数达到 failureThreshold,才判定启动失败,杀掉容器重来--整个 startupProbe 窗口也随之重置.
这就是为什么 failureThreshold 值得设大(如 30):它不是"重试 30 次",而是"容忍 30 次失败后才判死刑".
设成 30 × 10s = 5 分钟的窗口,远比把 periodSeconds 设成 100s 要合理--因为 periodSeconds 不仅控制重试间隔,更决定了应用就绪后多快能被发现(最多浪费一个 interval).

livenessProbe:死了就重启

liveness 的语义是:"进程在跑,但已经无法提供服务"--比如死锁,无限循环,内存泄漏导致 GC 停顿.进程没退出(所以 restartPolicy 不触发),但无法正常响应请求.
liveness 探测发现后杀掉容器,让 kubelet 重建.

# liveness 应该极简:只检查"我是否还能响应最基本请求"
livenessProbe:
httpGet:
path: /healthz # 端点应该极轻量--不查 DB,不调外部 API
port: 8080
periodSeconds: 10 # 探得别太频繁
timeoutSeconds: 1
failureThreshold: 3 # 给 30 秒容错窗口,避免 GC pause 误杀

liveness 不要做重操作

/healthz 端点里不要查数据库连接状态,不要调下游服务健康检查.这些属于 readiness 的范畴.liveness 挂了会杀容器--因为瞬时 DB 抖动杀掉应用进程是极其糟糕的设计.

readinessProbe:活着但不下线

readiness 的语义是:"暂时无法服务"--应用还活着,但当前不适合接收请求.失败时 kubelet 不杀容器,只是通知 Service Controller 把 Pod 从 Endpoints 列表里摘掉.恢复后自动加回来.

# readiness 可以做重检查:依赖服务是否可达,线程池是否满
readinessProbe:
httpGet:
path: /ready
port: 8080
periodSeconds: 5 # 可以探得比 liveness 频繁
failureThreshold: 2 # 快速摘除
successThreshold: 1 # 恢复后立刻加回来
场景 liveness 结果 readiness 结果 后果
正常运行 ✅ 成功 ✅ 成功 接收流量
DB 连接池耗尽 ✅ 成功(/healthz 还能回 200) ❌ 失败 从 Service 摘除,不接新请求;等连接池恢复后自动加回
死锁 / 僵死 ❌ 失败 ❌ 失败 杀掉容器,重建
启动中(预热) ❓ 由 startupProbe 覆盖 ❌ 失败 不接流量直到就绪

常见配置错误

错误 为什么有问题 正确做法
liveness 和 readiness 用同一个 endpoint 数据库抖动 → readiness 摘除(正确)且 liveness 杀进程(错误) liveness 用 /healthz(极简),readiness 用 /ready(可查依赖)
liveness 配得太敏感 periodSeconds: 1, failureThreshold: 2 → 任何 GC pause 都可能触发误杀 periodSeconds: 10, failureThreshold: 3(给 30 秒窗口)
慢启动无 startupProbe 应用还在初始化就被 liveness 杀掉,陷入重启循环 加 startupProbe,failureThreshold 设足够大
liveness 查外部依赖 依赖抖动 → 误杀应用 → 雪崩(大量 Pod 同时被重启) 外部依赖检查只放在 readiness 里

Init Container:启动前的准备工作

Init Container 是在用户容器启动之前按顺序执行的一组容器.它们的核心特性:顺序执行,必须成功退出,可以有不同镜像.

Pod: my-app
┌──────────────────────────────────────────────┐
│ │
│ Init Container 1: wait-for-db │
│ "检查数据库是否可连接" │
│ → 完成 → 退出(exit 0) │
│ │
│ Init Container 2: db-migrate │
│ "执行数据库迁移脚本" │
│ → 完成 → 退出(exit 0) │
│ │
│ Init Container 3: fetch-config │
│ "从配置中心拉取运行时配置,写入共享 Volume" │
│ → 完成 → 退出(exit 0) │
│ │
│ ──────── 全部 Init 成功退出 ──────── │
│ │
│ Main Container: my-app │
│ "启动应用(此时 DB 已迁移,配置已就绪)" │
│ → 持续运行,不退出 │
└──────────────────────────────────────────────┘

关键特性一览:

特性 说明
顺序执行 1 → 2 → 3,前面失败后面不启动,整个 Pod 卡在 Pending 状态
必须退出 Init Container 是任务型容器,完成就退出(exit 0);不持续运行
独立镜像 Init Container 可以用 busybox,curl,python 等工具镜像,即使主容器用的是 distroless(无 shell)镜像
共享 Volume 通过 Volume 把 Init Container 的产出(下载的配置,生成的证书等)传递给主容器
独立资源限制 每个 Init Container 有独立的 resources.requests/limits
apiVersion: v1
kind: Pod
metadata:
name: my-app
spec:
initContainers:
- name: wait-for-db
image: busybox:1.36
command: ['sh', '-c',
'until nc -z db-service 5432; do echo "waiting for db..."; sleep 2; done']
# 循环用 netcat 检测 db:5432 直到连通
- name: init-schema
image: my-app:migrate # 专门做迁移的镜像版本
command: ['./migrate.sh']
volumeMounts:
- name: config
mountPath: /etc/config
containers:
- name: main-app
image: my-app:latest
volumeMounts:
- name: config
mountPath: /etc/config # 读取 Init Container 写好的配置
volumes:
- name: config
emptyDir: {}

Init Container = 舞台搭建工,Main Container = 演员.
搭建工先把灯光,道具(DB 迁移,配置下载)准备好,全搞定了才退场.然后演员上台--他不需要知道灯光怎么搭的,只需要舞台是就绪的.搭建工和演员用不同的工具(不同镜像),互不干扰.

Sidecar 模式(K8s 1.28+)

传统上 Pod 内所有容器的 restartPolicy 是一样的.K8s 1.28 引入了容器级别的 restartPolicy,让 Sidecar 类型容器拥有独立于主容器的生命周期管理.

传统模式(K8s < 1.28):
Pod 内所有容器共享 restartPolicy
→ 主容器退出 → 所有 Sidecar 一起被杀 ❌(Sidecar 来不及刷缓冲区)

K8s 1.28+ 原生 Sidecar:
initContainers 中设 restartPolicy: Always
→ Sidecar 先于主容器启动
→ 主容器退出后,给 Sidecar 发送 SIGTERM,等它优雅退出
→ Sidecar 崩溃时独立重启(不影响主容器)
apiVersion: v1
kind: Pod
metadata:
name: app-with-sidecar
spec:
initContainers:
- name: log-collector # Sidecar 声明在 initContainers 里
image: fluentd:latest
restartPolicy: Always # ← 关键:Always = 这是一个 Sidecar
volumeMounts:
- name: logs
mountPath: /var/log
containers:
- name: main-app
image: my-app:latest
volumeMounts:
- name: logs
mountPath: /var/log
volumes:
- name: logs
emptyDir: {}

Sidecar 生命周期:

Sidecar 容器启动(在所有普通 Init 之后,和主容器同级)

├── Sidecar 崩溃 → 独立重启(不影响主容器)

├── 主容器退出 → 给 Sidecar 发 SIGTERM → 等 terminationGracePeriodSeconds → SIGKILL

└── 所有容器退出 → Pod 完成

常见 Sidecar 场景:

场景 工具 做什么
服务网格 Istio/Envoy proxy 每 Pod 注入一个流量代理,接管进出流量,实现 mTLS,限流,路由
日志收集 fluentd / filebeat 读取应用日志文件并转发到日志中心
指标暴露 Prometheus exporter 暴露 /metrics 端点供 Prometheus 采集
配置热更新 confd / consul-template 监听配置中心变更,动态渲染配置文件并 reload 主容器

资源管理:requests 与 limits

每个容器都可以声明自己需要多少资源和最多能用多少资源.这两个值直接影响 Scheduler 的调度决策和 kubelet 的驱逐行为.

字段 含义 调度影响 运行时影响
requests "调度时保证给我的量" Scheduler 只把 Pod 放到剩余资源 ≥ requests 的节点上 决定 QoS 等级
limits "你最多用这么多,不准超" 不影响调度 CPU 超限 → 被 throttle(限速);内存超限 → OOMKilled(杀容器)
spec:
containers:
- name: app
image: my-app:latest
resources:
requests:
cpu: "500m" # 调度时保证至少 0.5 核
memory: "256Mi" # 调度时保证至少 256MB
limits:
cpu: "2" # 最多用 2 核(超过就 throttle)
memory: "512Mi" # 最多用 512MB(超过就 OOMKill)

CPU 和内存超限行为不同

  • CPU 是可压缩资源: 超了 limits 只会被 throttle(降速),容器不会被杀
  • 内存是不可压缩资源: 超了 limits 就是 OOMKilled,容器直接被内核杀掉重启
  • limits.memory 一定得配,不然一个内存泄漏的容器可能吃光整台机器的内存

QoS 等级与驱逐优先级

K8s 根据 requests 和 limits 的配置自动给 Pod 分配 QoS(Quality of Service)等级.当节点资源紧张时,kubelet 按这个优先级驱逐 Pod:

QoS 等级 条件 被驱逐优先级 典型场景
Guaranteed 所有容器的 requests == limits(且都设了) 最后被驱逐 核心数据库,支付服务
Burstable 至少一个容器设了 requests 或 limits,但不满足 Guaranteed 条件 中等 普通 Web 应用
BestEffort 没有任何容器设 requests 或 limits 最先被驱逐 测试/开发环境,批处理任务
# 节点内存紧张时的驱逐顺序:
# BestEffort → Burstable(超量使用最多的先杀)→ Guaranteed(最后考虑)

Guaranteed Pod(requests=limits=512Mi) ← 不超量,最后才被杀
Burstable Pod(requests=256Mi, limits=1Gi)← 用了 900Mi,超量 644Mi
Burstable Pod(requests=128Mi, limits=512Mi)← 用了 400Mi,超量 272Mi
BestEffort Pod(啥都没设) ← 已经在第一批被杀了

Pod 终止流程

当 Pod 被删除时(无论是手动 kubectl delete,滚动更新缩容,还是节点驱逐),K8s 执行一个优雅终止流程:

1. API Server 收到 DELETE 请求
→ Pod 状态改为 Terminating
→ Pod 从 Service Endpoints 移除(不再接收新流量)
→ 通知 kubelet

2. kubelet 收到终止通知
→ 向 Pod 内每个容器的主进程(PID 1)发送 SIGTERM
→ 等待 terminationGracePeriodSeconds 秒(默认 30s)

3. 容器收到 SIGTERM:
✓ 优雅关闭:完成正在处理的请求,关闭 DB 连接,刷新日志
✓ 应用退出(exit 0)

4. 如果到了宽限期还没退出:
→ kubelet 发送 SIGKILL(-9)
→ 强制杀掉进程
→ 容器被清理

5. Pod 从 API Server 中删除
→ kubelet 清理容器,网络,Volume
→ 全流程结束

应用必须处理 SIGTERM 才能优雅关闭

很多应用(尤其是简单脚本)不处理 SIGTERM,导致 kubelet 等到宽限期结束直接 SIGKILL: 正在处理的请求丢失,导致数据不一致.
这需要你在代码里用 signal.Notify(Go),process.on('SIGTERM', ...)(Node.js)或 atexit + signal(Python)等机制来捕获并做清理.

可以自定义宽限期:

spec:
terminationGracePeriodSeconds: 60 # 给应用 60 秒优雅关闭
containers:
- name: app
image: my-app:latest
lifecycle:
preStop: # 在发 SIGTERM 之前先执行这个
exec:
command: ["/bin/sh", "-c", "sleep 5 && nginx -s quit"]
# 等 5 秒让 Service 摘除生效,再优雅停 nginx

小结

本篇要点回顾

要点 一句话概括
Pause 容器 Pod 的"骨架":持有共享 Namespace,收割僵尸进程,最先启动最后退出
共享机制 共享 Network/IPC NS 和 Volume;Mount/PID/User/Cgroup NS 各自独立
生命周期五阶段 Pending → Running → Succeeded/Failed(终态),不会自动回到 Running
startupProbe 给慢启动应用争取时间;成功后才启动 liveness + readiness;防止误杀
livenessProbe 检测应用是否"活着但废了"(死锁等);失败 → 杀容器重启
readinessProbe 检测应用是否"能接流量";失败 → 从 Service 摘除(不杀容器)
Init Container 按顺序执行的任务型容器;不同镜像,不同工具;产出通过 Volume 传给主容器
Sidecar 与主容器平级的辅助容器;K8s 1.28+ 原生支持独立生命周期和重启策略
requests vs limits requests 影响调度("保证给我的量"),limits 限制运行时("最多用这么多");内存超限 = OOMKill
QoS 等级 Guaranteed(requests=limits)> Burstable > BestEffort;资源紧张时按此优先级驱逐
SIGTERM vs SIGKILL 优雅关闭 = SIGTERM + 等待宽限期 + 应用清理;超时 = SIGKILL 强杀

动手练习

  1. 在一个 Pod 里跑两个容器(nginx + busybox),用 kubectl exec 分别进入两个容器,执行 ip addrcurl localhost,验证它们共享同一个 IP 和 localhost.
  2. 写一个 Deployment,给容器配置 startupProbe + livenessProbe + readinessProbe 三探针,故意让 readiness 失败(比如检查一个不存在的文件),观察 kubectl get endpoints 的变化.
  3. 配一个带 Init Container 的 Pod:Init Container 往 emptyDir 写入 index.html,主容器用 nginx 挂载并 serve 该文件.验证 Init Container 的产出能传递给主容器.
  4. 给一个容器设 resources.limits.memory: "50Mi",用 stress-ng --vm 1 --vm-bytes 100M 模拟 OOM,观察 Pod 的 OOMKilled 状态和 restartCount 增长.
  5. 思考题:如果 readinessProbe 和 livenessProbe 用同一个 HTTP endpoint 会有什么风险?画一个流量摘除 vs 容器重启的决策矩阵.