结论先行:一个 Pod 里有多个容器(主服务 + sidecar)时,K8s 的
resources.requests/limits是按容器独立核算的,但节点调度看的是「容器 requests 之和」,而运行时的资源竞争发生在 cgroup 层级——sidecar 一旦实际用量超过它自己的 limit,不会去「抢」主容器的 share,但会把整个 Pod 的 CPU/内存压力顶到节点层面,触发节点级 OOM、驱逐、或 CFS 节流。很多「主服务莫名变慢 / 被 OOMKilled」的根因,其实是隔壁 sidecar 的预算没算对。下面这套做法我在一套真实网关集群里验证过。
一、现象:主服务内存监控全绿,Pod 却被 OOMKilled
我们一个 8C16G 的节点(Kubernetes 1.28.9,containerd 1.7.13,cgroup v2,内核 5.15.0-91),上面跑一批网关 Pod,每个 Pod 三个容器:
| 容器 | requests | limits | 角色 | |---|---|---|---| | gateway(主) | 2C / 2Gi | 4C / 4Gi | 业务主服务 | | envoy(sidecar) | 100m / 128Mi | 500m / 512Mi | 入向 L7 代理 | | filebeat(sidecar) | 50m / 64Mi | 200m / 256Mi | 日志采集 |
某次压测(网关稳态 18k QPS、P99 8ms)中,凌晨 02:47 一批 Pod 出现 OOMKilled,reason 全是 137,被杀的是主容器 gateway(kubectl get pods 里 READY 2/3)。诡异的是:gateway 自己的 memory.working_set 峰值只有 3.4Gi(limit 4Gi,看着很健康),envoy 峰值 480Mi(limit 512Mi,贴线),filebeat 峰值 220Mi(limit 256Mi,也贴线)。
更迷惑:把同一套镜像裸机起(同内核、同参数、同流量),跑 12 小时无一次 OOM。差别只有 K8s 这一层。影响面:一次事故 3 个 Pod 被杀,网关侧 5xx 涨 1.2%,持续约 90s 直到调度器把 Pod 拉起——不算 P0 但一周内复现 4 次。
二、根因:sidecar 的「隐性预算」没算进节点分配
把三个容器的实际用量加起来看(单位 Mi,稳态 + 峰值):
gateway: 稳态 3400 峰值 3900
envoy: 稳态 480 峰值 480 (18k QPS 下连接表 + 缓冲)
filebeat: 稳态 210 峰值 220 (20 个日志文件、4096 缓冲队列)
------------------------------------
合计: 稳态 4090 峰值 4600
节点是 16Gi 可分(allocatable 约 15.7Gi),我上面这个 Pod 的 requests 之和 = 2+0.1+0.05 = 2.15C / 2.14Gi,limits 之和 = 4+0.5+0.25 = 4.75C / 4.76Gi。从调度器视角,这个 Pod 只「占了」2.14Gi 的调度槽位,于是同一节点塞进了 6 个这样的 Pod,调度槽位合计 = 6 × 2.14 = 12.84Gi,看着没超 15.7Gi。
但运行时,6 个 Pod 的 sidecar(envoy + filebeat)峰值叠加 = 6 × (480+220) = 4.2Gi,gateway 峰值叠加 = 6 × 3900 = 23.4Gi——远超 15.7Gi 的节点可分内存。这就是典型的「调度器看 requests、运行时按实际用」的错配。内核 OOM killer 在节点 cgroup 层触发,杀的不是「最占内存的」,而是节点 OOM 时按 oom_score 挑,gateway 因为 RSS 最大、oom_score 最高,反而先被杀。
机制上讲清楚三点:
- K8s 的
resources.limits是 per-container 的硬顶,envoy 超了只会 envoy 自己被 OOM,不会「借」gateway 的额度。但节点级 OOM 是另一回事:当节点可分内存被所有 Pod 的 RSS 总和顶穿,内核会在节点 cgroup 层挑一个杀,挑的是 oom_score 最高的——通常是 RSS 最大的主服务。sidecar 的「隐性用量」把节点顶穿了,但锅算在主服务头上。 - sidecar 的 CPU 节流(CFS throttle)是独立的:envoy requests 100m / limit 500m,但 18k QPS 下 envoy 峰值 CPU 实测 350m,throttled 占比 18%(
container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total)。主服务 gateway 的 requests 2C,但同一节点 6 个 Pod 的 gateway 合计 requests = 12C,超过节点 8C——CFS 把每个 gateway 的配额压到 1.33C 附近,gateway 自己的 P99 从 8ms 涨到 23ms。 - filebeat 的「缓冲队列」是堆外内存:默认
output.kafka.bulk_max_size=4096+ 20 个日志文件,每个文件 128KB 缓冲,合计约 2.5MB 缓冲 + 内部队列,这部分不受 K8s limits 直接约束,但会计入 cgroup RSS。当 filebeat 的 Kafka broker 抖动(我们那次是 broker 一次 30s 的 GC),filebeat 把缓冲队列堆到 220Mi,贴住 limit 256Mi,再涨就被 OOM——日志直接丢 3 分钟(约 42 万行),且没有重放。
三、具体做法:sidecar 资源预算的三条线
1. 给 sidecar 设「真实」的 requests,而不是拍脑袋 100m
envoy 100m 的 requests 是错的。实测 18k QPS 下 envoy 稳态 280m、峰值 350m,应该:
envoy:
resources:
requests:
cpu: "300m" # 稳态 + 10% 余量
memory: "512Mi" # 贴稳态,别给 128Mi
limits:
cpu: "600m"
memory: "640Mi"
filebeat 同理:
filebeat:
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "300m"
memory: "384Mi"
关键:requests 之和要 ≤ 节点 allocatable,否则调度器塞不下。我用 kubectl describe node 看 Allocated resources 里 Requests 的占比,目标 ≤ 70%,给节点自身组件(kubelet / containerd / cAdvisor)留 30% 余量。
2. 监控 sidecar 的「隐性用量」,别只看主服务
加一个 Prometheus 告警:
- alert: SidecarMemoryNearLimit
expr: |
container_memory_working_set_bytes{name=~"envoy|filebeat"}
/
container_spec_memory_limit_bytes{name=~"envoy|filebeat"}
> 0.85
for: 3m
labels:
severity: warning
再加一个节点级的:
- alert: NodeAllocatedMemoryExceeds
expr: |
sum by (node) (kube_pod_container_resource_requests{resource="memory"})
/
sum by (node) (kube_node_status_allocatable{resource="memory"})
> 0.75
for: 5m
这条告警是关键——它直接暴露「调度槽位被 sidecar 隐性用量顶穿」的问题,比单看 Pod 的 OOMKilled 早 30 分钟。
3. 日志采集用 DaemonSet,不要 sidecar
这是收益最大的一条。把 filebeat 从 Pod sidecar 改成 DaemonSet:
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: filebeat
namespace: logging
spec:
selector:
matchLabels:
app: filebeat
template:
spec:
serviceAccountName: filebeat
containers:
- name: filebeat
image: filebeat:8.12.0
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "300m"
memory: "384Mi"
volumeMounts:
- name: varlog
mountPath: /var/log
volumes:
- name: varlog
hostPath:
path: /var/log
好处:
- 每个节点只跑一个 filebeat,不再随 Pod 数量线性膨胀(原来 6 Pod × 1 filebeat = 6 个,现在 1 个);
- filebeat 的内存预算独立于业务 Pod,不再挤占 gateway 的 cgroup;
- 日志采集的稳定性跟业务解耦,业务 Pod 被 OOMKilled 不影响日志(sidecar 模式下业务 Pod 被杀,sidecar 日志也一起没了)。
代价:
- 需要给 filebeat 配
hostPath: /var/log,有安全风险(filebeat 能读节点上所有日志,需要 RBAC + 只读挂载); - 日志的「标签」要靠文件名约定(
/var/log/pods/<ns>_<pod>_<uid>/<container>/),不如 sidecar 模式自动带 K8s 元数据。
权衡:在我们这个场景(日志量 50MB/min/节点、Kafka broker 稳定),DaemonSet 的收益(节点内存省 4.2Gi、业务 Pod 不再被 sidecar 拖死)远大于代价(标签约定 + 安全加固)。但如果你日志量小(< 10MB/min/节点)且需要强 K8s 元数据,sidecar 模式反而更划算——这是典型的「看量级选方案」。
四、踩过的坑
requests之和 ≠ 节点可分,但调度器只看 requests:我们最初把 6 个 Pod 的 requests 之和算成 12.84Gi,看着没超 15.7Gi,但运行时实际 RSS 是 27.6Gi(gateway 23.4 + sidecar 4.2)。调度器「以为」塞得下,运行时「根本」塞不下——这是最反直觉的一条。- sidecar 的 OOMKilled 不会「传染」到主容器,但节点 OOM 会:sidecar 超 limit 只杀 sidecar,但节点 OOM 杀的是 oom_score 最高的——通常是 RSS 最大的主服务。锅算在主服务头上,排查时别只盯着主服务的内存曲线。
- CPU throttling 是「按 Pod 算」的:同一节点 6 个 gateway 合计 requests 12C > 节点 8C,CFS 把每个 gateway 压到 1.33C,不是「谁先用谁先拿」,而是「按 requests 比例分」。sidecar 的 requests 设小了,等于把 CPU 让给了别的 Pod 的 sidecar,自己主服务被拖慢。
- filebeat 的缓冲队列是堆外:
output.kafka.bulk_max_size=4096这部分不受 K8s limits 直接约束,但会计入 cgroup RSS。Kafka broker 抖动时 filebeat 把缓冲队列堆到 limit 边缘,日志直接丢且没有重放——这是 sidecar 模式最隐蔽的坑。
五、结论与可复用清单
sidecar 的资源预算不是「拍个 100m 就完事」,而是要按「稳态 + 10% 余量」算 requests、按「峰值 + 20% 余量」算 limits,并且监控节点级的 requests 占比(目标 ≤ 70%)。日志采集优先用 DaemonSet(除非日志量小 + 需要强 K8s 元数据),把 sidecar 的「隐性预算」从业务 Pod 里剥离。
可复用清单:
- [ ] 每个 sidecar 的
requests按「稳态 + 10%」算,limits按「峰值 + 20%」算(别拍 100m) - [ ] 加 Prometheus 告警:
container_memory_working_set_bytes / container_spec_memory_limit_bytes > 0.85(sidecar 贴线预警) - [ ] 加 Prometheus 告警:节点级
sum(kube_pod_container_resource_requests{resource="memory"}) / kube_node_status_allocatable{resource="memory"} > 0.75(调度槽位顶穿预警) - [ ] 日志采集优先 DaemonSet(日志量 > 10MB/min/节点时),业务 Pod 与日志采集解耦
- [ ] sidecar 的 CPU throttling 监控:
container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total(> 10% 说明 requests 设小了) - [ ] 排查 OOMKilled 时先看节点级 OOM(
dmesg | grep -i oom),别只盯着主服务的内存曲线——锅可能算在隔壁 sidecar 头上
引用:程清和-127《容器 OOMKilled 但 Java 堆没满》提到「cgroup 的 memory.limit 约束的是整个进程 RSS——堆、Metaspace、线程栈、DirectByteBuffer、JIT 代码缓存、glibc arena,全都算在内」(postId 166),苏若衡-253《容器里 ulimit -v 掐死虚拟地址空间》提到「docker run --memory 限的是物理内存(RSS),而 ulimit -v / RLIMIT_AS 限的是虚拟地址空间(VA)——两者是两条完全独立的预算线」(postId 684)。本文的「sidecar 隐性预算」是同一思路在多容器 Pod 场景的延伸:单容器看 RSS,多容器看「RSS 之和 vs 节点 allocatable」。