容器保活:如何让用户怎么折腾都无法搞挂 Pod
一、问题背景
我们提供的容器云平台允许用户自定义 CPU、内存、显卡等资源,用户以 root 权限在容器内运行各类任务。实际运营中,两个问题反复出现:
| 问题 | 现象 | 用户影响 |
|---|---|---|
| 内存溢出 | 用户任务超过 Pod 内存上限,触发 OOM Kill | Pod 退出,工作环境丢失 |
| 高负载卡死 | CPU 打满 / Fork 炸弹 / I/O 风暴 | Pod 无法 SSH,只能销毁重建 |
我们的目标:无论用户执行什么任务,都要保证容器不退出、SSH 可达,用户始终能登录容器自行排查和恢复。
二、核心挑战:用户拥有 root 权限
方案设计中最大的约束是:用户在容器内拥有 root 权限。这意味着很多传统方案行不通。
| 用户 root 能做的 | 用户 root 不能做的 |
|---|---|
| kill 容器内可见的进程 | 设置实时调度策略 SCHED_RR/SCHED_FIFO(需要 CAP_SYS_NICE) |
| 创建大量进程 / 线程 | 修改 cgroup 参数(容器内 /sys/fs/cgroup 只读挂载,且无 CAP_SYS_ADMIN) |
| 消耗容器分配的所有 CPU 和内存 | 修改 /proc/<pid>/oom_score_adj(需要 CAP_SYS_RESOURCE) |
| 修改 nice 值(部署 Tetragon 后也不行) | 突破 cgroup 设置的 pids.max 上限 |
关键洞察:root ≠ 全部权限。Linux capability 机制将 root 权限拆分为细粒度的能力项,容器默认只授予有限的 capability。我们的方案正是利用这个差异——从 Sidecar 侧利用用户容器不具备的 capability 进行外部管控。
为什么不用 cgroup 子分组? cgroup 子分组需要将进程分类到 system/ 和 user/ 子组中。但用户以 root 运行,所有进程都从 sshd fork 出来继承同一 cgroup。Sidecar 移动进程存在竞争窗口,且 cgroup v2 的「no internal process」规则使操作复杂且易出错。root 用户的高度自由使进程分类器的判定不可靠。
三、整体架构
┌─────────────────────────────────────────────────────────────┐
│ Node (宿主机) │
│ │
│ ┌───────────────────────────┐ ┌─────────────────────────┐ │
│ │ User Pod (root 用户) │ │ Sidecar Container │ │
│ │ │ │ (SYS_NICE/KILL/ │ │
│ │ sshd ─→ SCHED_RR (RT) │ │ SYS_RESOURCE/PTRACE) │ │
│ │ oom_score_adj=-1000 │ │ │ │
│ │ │ │ ┌───────────────────┐ │ │
│ │ init ─→ oom_adj=-1000 │ │ │ guardian 守护进程 │ │ │
│ │ │ │ │ │ │ │
│ │ 用户进程 (SCHED_OTHER) │ │ │ · MemoryWatcher │ │ │
│ │ ├── 训练任务 │ │ │ · CPUWatcher │ │ │
│ │ ├── 编译任务 │ │ │ · PIDWatcher │ │ │
│ │ └── ... │ │ │ · SSHProber │ │ │
│ │ │ │ │ · ProcessProtect │ │ │
│ └───────────────────────────┘ │ └───────────────────┘ │ │
│ │ │ │
│ │ 挂载: │ │
│ │ · /sys/fs/cgroup (rw) │ │
│ │ · /proc (ro) │ │
│ └─────────────────────────┘ │
│ │
│ /sys/fs/cgroup/kubepods/pod-xxx/container-id/ │
│ ├── memory.max = M × 0.98 (Sidecar 写入) │
│ ├── memory.high = M × 0.90 (Sidecar 写入) │
│ └── pids.max = 4096 (Sidecar 写入) │
│ │
│ /proc/<sshd_pid>/ │
│ ├── oom_score_adj = -1000 (Sidecar 写入,用户无法改) │
│ └── sched → SCHED_RR (Sidecar 设置,用户无法改) │
└─────────────────────────────────────────────────────────────┘
权限隔离是整个方案的安全基石:
┌──────────────────────────────────────────────────────┐
│ 用户容器 (root) │ Sidecar 容器 │
│ │ │
│ 有 root UID (uid=0) │ 有 root UID (uid=0) │
│ 无 CAP_SYS_ADMIN │ 无 CAP_SYS_ADMIN │
│ 无 CAP_SYS_NICE │ ✅ CAP_SYS_NICE │
│ 无 CAP_SYS_RESOURCE │ ✅ CAP_SYS_RESOURCE │
│ 无 CAP_KILL (跨容器) │ ✅ CAP_KILL │
│ 无 CAP_SYS_PTRACE │ ✅ CAP_SYS_PTRACE │
│ │ │
│ cgroup 目录:只读 │ cgroup 目录:读写 │
│ (容器内默认 ro 挂载) │ (hostPath 挂载宿主机的) │
└──────────────────────────────────────────────────────┘
四、内存保活方案(已上线)
4.1 核心思路
利用 cgroup v2 的 memory.max 和 memory.high 配合,实现「软限制减速 + 硬限制兜底」。Sidecar 抢在 Kubernetes OOM 之前介入,kill 用户进程而非让 Pod 退出。
假设用户申请内存为 M:
| 参数 | 值 | 作用 |
|---|---|---|
memory.max | M × 0.98 | 硬上限,留出 2% 缓冲让 Sidecar 抢先介入 |
memory.high | M × 0.90 | 软上限,超过后内核自动减缓内存分配速度 |
为什么设 0.98 而不是 1.0? Pod 的
resources.limits.memory对应 K8s 设置的 cgroupmemory.max。Sidecar 将自己管理的memory.max设为M × 0.98,留出 2% 缓冲区间。当内存使用达到 98% 时 Sidecar 的 kill 介入,而不是等到 100% 触发 K8s OOM 导致 Pod 退出。
4.2 工作流程
用户进程内存增长
│
▼
达到 memory.high (90%)
│
▼
内核自动减缓内存分配 ──→ 大部分场景在这里被自然抑制
│
│ (如果进程继续申请)
▼
Sidecar 检测到内存 > 95% → 告警通知用户
│
│ (如果内存继续上涨)
▼
接近 memory.max (98%)
│
▼
Sidecar kill 内存占用最大的用户进程 (SIGTERM → SIGKILL)
│
▼
内存释放,Pod 存活,SSH 正常
│
▼
(对比:不做干预 → 触发 K8s OOM → Pod 退出)
4.3 进程 Kill 策略
- 白名单保护: PID 1(init)、sshd 及其子进程、guardian 自身、用户自定义保护进程永远不被 kill
- Kill 目标选择: 非白名单进程中,按 RSS 降序排列,kill 占用内存最大的进程
- 优雅退出: 先
SIGTERM,等待 5 秒;仍存活则SIGKILL - 连续触发保护: 30 秒内连续 3 次 kill 后暂停 60 秒,避免 kill-restart 循环
五、CPU/负载保活方案
5.1 五层防御体系
┌───────────────────────────────────────────────────────────────┐
│ 防御层 │ 手段 │ 用户能否绕过 │
├───────────────────────────────────────────────────────────────┤
│ 第一层:调度保障 │ sshd 设为 SCHED_RR │ ❌ 不能 │
│ ├ RT 回退加固 │ nice -20 + Tetragon │ ❌ 不能 │
│ 第二层:OOM 保护 │ sshd oom_score_adj │ ❌ 不能 │
│ 第三层:进程数限制 │ cgroup pids.max │ ❌ 不能 │
│ 第四层:CPU 监控 │ Sidecar 检测+kill │ ❌ 不能 │
│ 第五层:兜底通道 │ 平台 Web Terminal │ ❌ 不能 │
└───────────────────────────────────────────────────────────────┘
5.2 第一层:实时调度策略保障 sshd(核心)
Linux 实时调度策略(SCHED_RR)的进程优先级永远高于普通调度策略(SCHED_OTHER)。即使所有 CPU 核心被普通进程 100% 占满,内核也会抢占普通进程来调度实时进程。
设置实时调度需要 CAP_SYS_NICE 权限。Sidecar 被显式授予此权限,用户容器不具备,因此用户无法修改或取消。
Sidecar(拥有 CAP_SYS_NICE)
│
├── 找到 sshd 主进程 PID
│
├── sched_setscheduler(sshd_pid, SCHED_RR | SCHED_RESET_ON_FORK, priority=1)
│ → sshd 获得实时调度优先级
│ → 即使 CPU 100% 被普通进程占满,sshd 也能被调度
│ → SCHED_RESET_ON_FORK 确保 sshd fork 出的子进程自动重置为 SCHED_OTHER
│
└── 每 30 秒巡检:
├── sshd 主进程是否仍为 SCHED_RR → 如不是则重新设置
└── sshd 是否存活 → 如不存活则告警
为什么必须加 SCHED_RESET_ON_FORK? Linux 中 fork() 默认继承父进程的调度策略,exec() 也不会重置。如果不加此标志,sshd fork 出的所有子进程(用户 shell、训练任务等)都会获得 SCHED_RR 实时调度优先级,导致用户进程与 sshd 平等竞争 RT 时间片,彻底失去保护意义。加了此标志后,子进程会被内核自动重置为 SCHED_OTHER。
前置条件: 宿主机内核需要启用
CONFIG_RT_GROUP_SCHED,且 Pod 所在的 cgroup 层级需要分配 RT 带宽(cpu.rt_runtime_us > 0)。如果宿主机不支持,回退到 nice 值方案。
回退方案 — nice 值 + Tetragon 加固:
如果集群不支持 RT 调度,使用 nice 值作为降级方案。sshd 设为 nice -20(权重 88761),普通进程默认 nice 0(权重 1024),sshd 获得约 87 倍的调度优先级。
nice 值方案的风险在于:root 用户可以 renice -n -20 把自己的进程也设为最高优先级,与 sshd 平等竞争。我们通过 Tetragon eBPF 策略在内核层面拦截 setpriority() / nice() 系统调用来堵住这个漏洞:
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
name: block-nice-change
spec:
kprobes:
- call: "sys_setpriority"
syscall: true
args:
- index: 0
type: int
selectors:
- matchNamespaces:
- namespace: Pid
operator: NotIn
values:
- "host_ns"
matchActions:
- action: Override
argError: -1 # 返回 EPERM
- call: "sys_nice"
syscall: true
selectors:
- matchNamespaces:
- namespace: Pid
operator: NotIn
values:
- "host_ns"
matchActions:
- action: Override
argError: -1
加入 Tetragon 后,nice 回退方案的防护等级从 ~90% 提升至接近 100%,与 SCHED_RR 方案等效。
5.3 第二层:OOM Score 保护
当内存接近上限触发内核 OOM Killer 时,内核根据 oom_score_adj 选择被 kill 的进程。Sidecar 将 sshd 和 init 设为 -1000(永远不被 OOM Kill),guardian 自身设为 -999。
修改 oom_score_adj 需要 CAP_SYS_RESOURCE,容器默认不具备,用户无法绕过。
5.4 第三层:cgroup 级进程数限制(防 Fork 炸弹)
直接在容器的 cgroup 上设置 pids.max = 4096,限制容器内的总进程数。超过后 fork() 返回 EAGAIN,新进程无法创建,sshd 已有的连接不受影响。
Sidecar 配合检测 fork 炸弹(10 秒内进程数增长 > 500),使用 cgroup v2 的 cgroup.freeze 先冻结所有进程再批量清理:
检测到 fork 炸弹
│
├── 1. 写入 cgroup.freeze = 1
│ → 冻结容器内所有进程(包括正在 fork 的)
│
├── 2. 读取 cgroup.procs → 获取所有 PID
│
├── 3. 过滤白名单(sshd, init, guardian)
│
├── 4. kill -9 所有非白名单进程
│
└── 5. 写入 cgroup.freeze = 0
→ 解冻,sshd 等白名单进程恢复运行
为什么不用
kill(-pgid, 9)?fork 炸弹会创建多个进程组,单杀一个 pgid 无法清除干净。cgroup.freeze能冻结所有进程包括正在 fork 的,冻结状态下新 fork 也会被冻结,不会出现"杀不完"的情况。
5.5 第四层:Sidecar CPU 监控 + 渐进式 Kill
Sidecar 是独立容器,拥有独立的 cgroup 和 CPU 资源,即使用户容器 CPU 打满,Sidecar 仍正常运行。
核心决策逻辑:CPU 高 + SSH 不可达 = 卡死,需要干预。 单纯的 CPU 高使用率不触发 kill——用户可能在跑正常的训练任务。
渐进式响应:
│
├── CPU > 95% + SSH 不可达 持续 30s → 告警通知用户
├── 持续 90s → kill CPU 最高的 1 个非白名单进程
├── 持续 180s → kill CPU 前 3 的非白名单进程
└── 持续 300s → kill 所有非白名单进程(最终兜底)
5.6 第五层:平台 Web Terminal 兜底
即使以上所有机制都未能恢复 SSH,平台提供基于 kubectl exec 的 Web Terminal 作为备用入口,通过 kubelet CRI 接口直接进入容器 namespace,不经过 sshd。配合"一键清理"按钮(调用 Sidecar API kill 所有非系统进程),用户可在卡死时自助恢复。
六、场景验证
场景 A:CPU 打满
事件发生 → CPU 100%
│
├── 第一层生效:sshd 是 SCHED_RR,内核抢占普通进程为 sshd 调度
│ → SSH 仍可连接,用户可自行处理
│ → ✅ 大部分情况在此解决
│
├── Sidecar 检测到持续高 CPU + SSH 不可达
│ → 渐进式 kill
│
└── SSH 恢复
场景 B:Fork 炸弹
事件发生 → 进程数暴增
│
├── 第三层生效:pids.max = 4096 硬限制,fork() 返回 EAGAIN
│
├── Sidecar 检测到进程数暴增
│ → cgroup.freeze 冻结 → 批量 kill → 解冻
│ → 进程数回落,sshd 恢复
│
└── 如果 sshd 新连接失败 → Web Terminal 兜底
场景 C:I/O 风暴
事件发生 → 磁盘 I/O 100%,大量进程进入 D 状态
│
├── SCHED_RR 保障 sshd CPU 调度
│ → 但 sshd 自身若需要磁盘 I/O 仍可能受影响
│
└── 兜底:kubectl exec 不依赖 sshd → Web Terminal 最可靠
I/O 风暴的局限: D 状态是内核级阻塞,无法通过调度优先级完全解决。这是方案的已知边界,Web Terminal 是兜底方案。
七、Sidecar 守护进程设计
7.1 进程架构
Sidecar Container(独立 cgroup,独立 CPU/内存资源)
│
└── guardian (主守护进程)
│
├── MemoryWatcher 周期 2s 内存监控 → 告警 → kill
├── CPUWatcher 周期 5s CPU 监控 → SSH 探测 → kill
├── PIDWatcher 周期 3s 进程数监控 → fork 炸弹检测
├── SSHProber 周期 30s TCP 连接 22 端口 + SSH banner
├── ProcessProtector 周期 30s 确保 SCHED_RR / oom_score_adj / nice
└── AlertManager 聚合告警,上报平台
7.2 启动流程
guardian 启动
│
├── 1. 获取环境变量(POD_UID, CONTAINER_NAME, 资源配额等)
│
├── 2. 定位目标容器的 cgroup 路径
│ /host-cgroup/kubepods/.../pod<POD_UID>/<container-id>/
│
├── 3. 设置 cgroup 参数
│ ├── memory.high = M × 0.90
│ ├── memory.max = M × 0.98
│ └── pids.max = 4096
│
├── 4. 保护关键进程
│ ├── sched_setscheduler(sshd_pid, SCHED_RR | SCHED_RESET_ON_FORK, priority=1)
│ │ └── 如果失败(内核不支持 RT),回退: renice -20 sshd
│ ├── sshd oom_score_adj = -1000
│ ├── init oom_score_adj = -1000
│ └── guardian oom_score_adj = -999
│
└── 5. 启动监控循环
7.3 关键数据流
┌────────────────────────────────────────────────────────────┐
│ 数据采集层 │
│ cgroup 文件: proc 文件: │
│ memory.current, cpu.stat, /proc/<pid>/status, │
│ pids.current, cgroup.procs /proc/<pid>/stat │
│ 网络探测: TCP connect Pod:22 │
└────────────────────┬───────────────────────────────────────┘
▼
┌────────────────────────────────────────────────────────────┐
│ 分析决策层 │
│ 内存分析 │ CPU 分析 │ 进程数分析 → 决策引擎 │
│ (使用率、趋势、进程排名、SSH 可达性联合判定) │
└────────────────────┬───────────────────────────────────────┘
▼
┌────────────────────────────────────────────────────────────┐
│ 执行层 │
│ Kill 进程 │ 告警上报 │ 进程属性保护/恢复 │ 日志记录 │
└────────────────────────────────────────────────────────────┘
八、Kubernetes 部署
8.1 Pod Spec 核心配置
apiVersion: v1
kind: Pod
metadata:
name: user-workspace-xxx
spec:
shareProcessNamespace: true # Sidecar 可看到主容器进程
containers:
- name: workspace # 用户主容器
image: registry.example.com/workspace:latest
resources:
requests: { cpu: "4", memory: "10Gi" }
limits: { cpu: "4", memory: "10Gi" }
ports:
- containerPort: 22
- name: guardian # Sidecar 保活容器
image: registry.example.com/guardian:latest
resources:
requests: { cpu: "100m", memory: "64Mi" }
limits: { cpu: "200m", memory: "128Mi" }
securityContext:
privileged: false
capabilities:
add:
- SYS_PTRACE # 读取 /proc/<pid>/ 信息
- SYS_NICE # 设置 sshd SCHED_RR
- KILL # kill 其他容器内的进程
- SYS_RESOURCE # 写入 oom_score_adj
volumeMounts:
- name: cgroup
mountPath: /host-cgroup
- name: host-proc
mountPath: /host-proc
readOnly: true
volumes:
- name: cgroup
hostPath: { path: /sys/fs/cgroup, type: Directory }
- name: host-proc
hostPath: { path: /proc, type: Directory }
8.2 关键配置说明
| 配置项 | 说明 |
|---|---|
shareProcessNamespace: true | 共享 PID 命名空间,Sidecar 可看到主容器进程并发信号 |
hostPath: /sys/fs/cgroup | Sidecar 可读写容器的 cgroup 参数(memory.max, pids.max 等) |
hostPath: /proc | Sidecar 可读写进程属性(oom_score_adj 等) |
SYS_NICE | 设置 sshd SCHED_RR,用户容器不具备此权限无法绕过 |
SYS_RESOURCE | 写入 oom_score_adj,保护 sshd 不被 OOM Kill |
KILL | 向其他容器的进程发送信号 |
九、风险与功能边界
9.1 安全风险
| 风险 | 等级 | 缓解措施 |
|---|---|---|
| Sidecar 挂载 hostPath cgroup | 高 | 限制 Sidecar 只访问该 Pod 的 cgroup 子树,通过环境变量限定路径 |
| Sidecar 拥有额外 capabilities | 中 | 仅赋给 Sidecar,不赋给用户容器;镜像由平台统一管理 |
shareProcessNamespace 下用户可 kill guardian | 高 | 使用 K8s 1.28+ sidecar container(restartPolicy: Always)自动重启;guardian 启动时立即完成关键保护设置,已设置的内核参数(SCHED_RR、oom_score_adj、pids.max)不因 guardian 被杀而失效 |
9.2 功能边界
| 场景 | 覆盖 | 说明 |
|---|---|---|
| 用户进程 OOM | ✅ | 内存保活方案 |
| CPU 打满导致卡死 | ✅ | sshd SCHED_RR + Sidecar 监控 kill |
| Fork 炸弹 | ✅ | cgroup pids.max + cgroup.freeze 批量 kill |
| I/O 风暴 | ⚠️ 部分 | SCHED_RR 保障 CPU 调度,但 sshd 自身 I/O 阻塞无法避免;兜底靠 Web Terminal |
| 用户 root 改 nice 值 | ✅ | SCHED_RR 不受 nice 影响;回退方案下 Tetragon 拦截 |
| 用户 root kill sshd | ⚠️ 部分 | Sidecar 30 秒内检测并告警;兜底靠 Web Terminal |
| GPU 资源争抢 | ❌ | 需额外方案(NVIDIA MPS / MIG) |
| 网络风暴 | ❌ | 需 NetworkPolicy 或 TC 限速 |
十、总结
本方案的核心设计原则是:不信任容器内的任何机制,从外部利用用户无法触及的内核特性进行保护。
用户有 root → 但没有 CAP_SYS_NICE → 无法修改调度策略
用户有 root → 但没有 CAP_SYS_RESOURCE → 无法修改 OOM 保护
用户有 root → 但没有 CAP_SYS_ADMIN → 无法修改 cgroup 参数
用户有 root → 但有 Tetragon → 无法修改 nice 值
内存保活方案已上线运行,CPU/负载保活方案进入实施阶段。通过五层纵深防御,绝大多数异常场景下用户容器都能保持 SSH 可达,即使极端情况下 SSH 不可达,Web Terminal 也能提供兜底的恢复通道。
最终效果:用户怎么折腾都行,容器就是不退出,SSH 就是能连上。

1万+

被折叠的 条评论
为什么被折叠?



