第 15 章 隔离与配额:邻居是魔鬼

上一章讲”出错”(崩溃、掉电)。这一章讲另一种更隐蔽的”失败”:资源耗尽。

你的进程可能没写错一行代码,但隔壁进程吃光了内存、抢走了 CPU、占满了磁盘,于是你也跟着遭殃。

操作系统(以及容器技术)用来对付这个问题的武器,是两个词:隔离(isolation)和配额(quota)。

隔离让你”看不到”邻居,配额让你”抢不过”邻居。理解了它们,你就理解了容器、K8s 资源管理,以及你自己系统里的限流。


引子:同一个宿主机,两个进程,一个”饿死”另一个

看一个真实的场景:

宿主机 16GB 内存,跑着两个服务:
  服务 A:一个 Go 服务,内存正常用 4GB
  服务 B:一个"失控"的服务,内存泄漏,一路吃到 14GB

结果:
  服务 A 突然变慢,然后被 OOM Killer 杀掉了。
  而服务 B(真正的"坏蛋")反而活得好好的。

为什么”无辜的” A 被杀,“捣乱的” B 却活着?

因为 OOM Killer 的规则是”杀内存占用最大的”(第 1 章),而 B 占 14GB,A 占 4GB,但 B 可能有个”特权”或者”更小的 oom_score”。

这个问题的本质:在共享的宿主机上,一个”坏邻居”能拖垮其他”好邻居”。

而容器的解决方案,就是这一章的主题:

给每个进程(或容器)划一个”边界”,限制它能看到什么(隔离)、能用多少(配额)。

隔离(namespace):让进程以为”整个系统只有我一个”。 配额(cgroup):让进程”最多只能用这么多资源”。

这样,“坏邻居”最多害死它自己,不会拖垮别人。


本章路线图

第一幕:隔离 —— 让进程以为世界只有它
  Q1  容器和虚拟机有什么区别?容器是怎么"隔离"的?
  Q2  namespace 的六种隔离,分别隔离了什么?

第二幕:配额 —— 让进程用不过界
  Q3  cgroup 是什么?它怎么限制 CPU、内存、I/O?
  Q4  cgroup v1 有什么历史包袱?v2 改进了什么?

第三幕:内存配额的坑
  Q5  容器里的 free 为什么显示宿主机内存?
  Q6  为什么 Java 在容器里总是 OOM?Go 的 GOMAXPROCS 又有什么坑?
  Q7  cgroup OOM 和宿主机 OOM 有什么区别?

第四幕:限流的位置
  Q8  限流应该在哪个层次做?(内核、网关、应用、数据库)
  Q9  隔离和配额面临哪些结构性的问题?

第一幕:隔离 —— 让进程以为世界只有它

Q1 容器和虚拟机有什么区别?容器是怎么”隔离”的?

这是理解”容器”的第一问。

虚拟机(VM):隔离硬件。

虚拟机 = 在硬件上"模拟"出完整的机器:
  - 每个 VM 有自己的"虚拟 CPU、内存、磁盘、网卡"
  - 每个 VM 跑自己的完整操作系统(自己的内核)

隔离层次:硬件级(Hypervisor 模拟硬件)
隔离强度:强(内核都隔离了)
代价:重(每个 VM 要跑一个完整 OS,启动慢、占内存多)

容器(Container):隔离进程。

容器 = 在操作系统上"限制"进程的视野和资源:
  - 每个容器有自己的"视图"(namespace)
  - 每个容器有资源配额(cgroup)
  - 但共享同一个宿主机内核

隔离层次:进程级(namespace + cgroup)
隔离强度:中(共享内核,隔离不如 VM)
代价:轻(启动快、占内存少)

核心区别:

虚拟机容器
隔离单位硬件(整个机器)进程(视野 + 资源)
内核每个 VM 独立内核共享宿主机内核
启动慢(分钟级)快(秒级甚至毫秒)
资源开销重(完整 OS)轻(进程级)
隔离强度强中
密度低(一台机器几个 VM)高(一台机器几十个容器)

容器是怎么”隔离”的?两个机制:

1. namespace(隔离"视野"):
   让容器里的进程,看到"独立的" PID、网络、文件系统、用户等
   → 进程以为"世界只有我一个"

2. cgroup(限制"资源"):
   限制容器的 CPU、内存、I/O 用量
   → 进程用不过界

一句话:容器不是”轻量级虚拟机”,而是”被限制了视野和资源的进程”。

这个区别很重要,因为它决定了容器的”边界”:

  • 容器共享内核 → 内核 bug 影响所有容器
  • 容器是进程 → 隔离不如 VM 彻底
  • 但容器轻 → 能跑高密度

理解了”容器 = 受限进程”,你就理解了容器的能力和局限。

Q2 namespace 的六种隔离,分别隔离了什么?

namespace 是 Linux 的”视野隔离”机制,有六种(实际更多,这是主要的六种):

1. PID namespace(进程隔离):

隔离:进程号(PID)

效果:容器里的进程,以为自己是 PID 1,
  看不到宿主机的其他进程。

例:
  容器里 ps 只能看到自己的进程,
  看不到宿主机的 1000 个进程。

2. Mount namespace(挂载点隔离):

隔离:文件系统挂载点

效果:容器有自己的"根文件系统"(rootfs),
  看不到宿主机的文件系统。

例:
  容器的 / 是它自己的 rootfs,
  看不到宿主机的 /etc、/home。

3. Network namespace(网络隔离):

隔离:网络设备、IP、端口、路由表

效果:容器有自己的网络栈(独立 IP、端口、路由),
  和宿主机、其他容器隔离。

例:
  容器 A 和容器 B 可以各自监听 8080 端口,互不冲突。

4. IPC namespace(进程间通信隔离):

隔离:System V IPC、POSIX 消息队列

效果:容器的 IPC 对象(信号量、消息队列、共享内存)
  和其他容器隔离。

5. UTS namespace(主机名隔离):

隔离:主机名(hostname)、域名

效果:容器有自己的 hostname,
  可以设置独立的主机名。

6. User namespace(用户隔离):

隔离:用户 ID、组 ID

效果:容器里的 root(UID 0),映射到宿主机的普通用户(UID 1000),
  容器里的 root 在宿主机上"不是 root"(权限受限)。

一张表总结:

namespace隔离什么典型用途
PID进程号容器里 ps 只看到自己
Mount文件系统独立的 rootfs
Network网络独立 IP/端口
IPC进程间通信隔离消息队列
UTS主机名独立 hostname
User用户root 降权

namespace 的本质:给进程一个”过滤后的视野”。

它不创建”新的资源”,而是让进程”看不到”某些资源。

这个思想在工程里很常见:

  • 数据库的”视图”(view):让用户看到”过滤后”的数据
  • 微服务的”网关”:让调用方看到”过滤后”的接口
  • 权限系统:让用户看到”过滤后”的功能

核心:隔离不是”物理分割”,是”视野限制”。 你限制进程”能看到什么”,就实现了”隔离”。


第二幕:配额 —— 让进程用不过界

Q3 cgroup 是什么?它怎么限制 CPU、内存、I/O?

cgroup(control group,控制组)是 Linux 的”资源配额”机制。

它解决的问题:

namespace 只解决了"隔离视野"(看不到别人),
但没解决"资源争抢"(抢 CPU、抢内存)。

一个容器可以看到"自己的"内存,但可能"吃光"宿主机的内存。
所以需要 cgroup 来"限制用量"。

cgroup 能限制什么:

1. CPU:限制 CPU 使用率(配额)
2. 内存:限制内存用量(超了 OOM)
3. I/O:限制磁盘读写速率
4. 网络:限制带宽(配合 tc)
5. 进程数:限制进程数量(pids)

cgroup 的限制机制(以 CPU 和内存为例):

CPU 限制(CFS 配额):

cgroup 的 CPU 限制,基于 CFS 调度器(第 11 章):

  cpu.cfs_quota_us:一个周期内能用多少 CPU 时间(微秒)
  cpu.cfs_period_us:周期长度(默认 100ms = 100000 微秒)

例:
  quota = 50000, period = 100000
  → 每 100ms 周期内,最多用 50ms CPU
  → 相当于"半个核"(50% 的一个核)

内存限制:

  memory.limit_in_bytes:内存上限
  超过上限 → cgroup OOM(杀掉容器里的进程)

机制:
  容器用内存接近上限时,内核回收(第 1 章页面回收)
  回收不动了,就 OOM 杀进程

cgroup 的结构(层级):

cgroup 是"层级"的:
  / (根)
  ├── system.slice (系统服务)
  │   └── sshd
  ├── user.slice (用户进程)
  └── docker
      ├── container-1 (容器 1)
      └── container-2 (容器 2)

子 cgroup 的资源,受父 cgroup 限制。

cgroup 的本质:给资源加上”配额”,防止”无限使用”。

这是”背压”(第 16 章)和”限流”的资源级实现:在内核层面,硬性限制”你能用多少”。

工程启示:你的系统里的”限流”、“配额”,本质上和 cgroup 是同一个思想——给共享资源设上限,防止”坏邻居”(或”突发流量”)耗尽资源。

Q4 cgroup v1 有什么历史包袱?v2 改进了什么?

cgroup 有 v1 和 v2 两个版本,v2 是重写,解决了 v1 的很多问题。

cgroup v1 的历史包袱:

包袱一:每个子系统(controller)独立挂载,层级不统一。

v1 里,CPU、内存、I/O 各自是一个"子系统",独立管理:
  /sys/fs/cgroup/cpu/...    (CPU 限制)
  /sys/fs/cgroup/memory/... (内存限制)
  /sys/fs/cgroup/blkio/...  (I/O 限制)

问题:一个进程要分别加到三个子系统的树里,
  三个树的层级可能不一致 → 管理混乱

包袱二:内存和 swap 的配额分离(最坑的)。

v1 里,内存和 swap 是"分开"限制的:
  memory.limit_in_bytes:内存上限
  memory.memsw.limit_in_bytes:内存 + swap 的总上限

问题:memsw 的语义反直觉(是"内存+swap"的总和,不是 swap 单独)
  → 配置错误导致"看似限制了内存,实际能无限用 swap"

包袱三:子进程归属问题。

v1 里,一个进程 fork 出的子进程,默认属于"父进程的 cgroup",
但 cgroup 的层级管理有边界情况,导致"进程逃逸"或"归属混乱"。

cgroup v2 的改进:

1. 统一层级(single hierarchy):
   所有子系统(CPU、内存、I/O)在一个统一的树里
   → 管理简单,一个 cgroup 管所有资源

2. 统一内存 + swap 管理:
   memory.max(内存上限)+ memory.swap.max(swap 上限)
   → 语义清晰,不再有 memsw 的坑

3. 更清晰的"无内部进程"规则:
   - 只有"叶子节点"能放进程
   - 内部节点只做"资源分配"(给子树),不放进程
   → 避免归属混乱

4. 更好的 OOM 处理:
   - memory.events 暴露 OOM 事件
   - 支持"杀哪个进程"的精细控制

K8s 的迁移:

K8s 从 1.25 起,cgroup v2 成为默认。
cgroup v2 是未来,v1 是历史包袱(但很多老系统还在用 v1)。

cgroup v1 → v2 的演进启示:

“打补丁式”的设计(v1),最终会积累”历史包袱”(层级混乱、语义反直觉),不得不推倒重来(v2)。

这和第 11 章”O(1) 调度器 → CFS”的演进是同一个道理:当”复杂”和”不一致”积累到一定程度,退回到”简单统一”的原则,反而更好。

你在设计系统时,也尽量避免”打补丁”——一开始就设计好”统一”的模型,比后期”打补丁”省事得多。


第三幕:内存配额的坑

Q5 容器里的 free 为什么显示宿主机内存?

这是容器新手最常见的困惑。

场景:容器限制内存 2GB,但容器里 free 显示 16GB(宿主机内存)。

为什么?
  因为 free 读的是 /proc/meminfo,
  而 /proc/meminfo 是"宿主机的"信息,不是"容器的"。

  容器只隔离了"视野"(namespace),
  但 /proc/meminfo 这个"文件"没有被"容器化"(在旧内核上)。

更本质的原因:

namespace 隔离的是"进程、网络、文件系统"等,
但 /proc 这个"伪文件系统"里的信息,
很多是"宿主机全局"的,没有被 namespace 隔离。

所以容器里读 /proc/meminfo、/proc/cpuinfo,
读到的都是"宿主机的"信息。

这导致的坑:

1. 应用用 free/cat /proc/meminfo 判断内存 → 读到宿主机内存,
   以为"内存充足",结果被 cgroup OOM 杀掉

2. Java 的 JVM 默认用 /proc/meminfo 决定堆大小 → 读到宿主机内存,
   堆设置过大 → 容器 OOM

3. Go 的 runtime.NumCPU() 读 /proc/cpuinfo → 读到宿主机核数,
   GOMAXPROCS 设错 → CPU 节流

现代的改进:

1. Linux 4.x 之后,/proc/meminfo 里的某些字段(如 MemAvailable)
   会反映 cgroup 的限制(在 cgroup v2 下更准确)

2. 应用应该读 cgroup 的限制,而不是 /proc:
   - 内存:/sys/fs/cgroup/memory.max
   - CPU:/sys/fs/cgroup/cpu.max

3. 现代的应用(Java 10+、Go 1.25+)能自动感知 cgroup

这是一个典型的”抽象泄漏”(第 1 章):

容器让进程以为”世界只有它”,但 /proc 这个”窗户”泄漏了宿主机的真相。

教训:依赖”系统全局信息”(内存、CPU 核数)的应用,在容器环境里会”被骗”。 应该读 cgroup 的限制,而不是 /proc 的全局值。

这也解释了为什么”容器化改造”时,Java、Go 应用要专门处理”cgroup 感知”。

Q6 为什么 Java 在容器里总是 OOM?Go 的 GOMAXPROCS 又有什么坑?

这是”容器内存配额”的经典坑,Java 和 Go 各有各的版本。

Java 的坑:JVM 堆大小设置错误。

旧版 Java(< 10):
  JVM 默认根据"宿主机的物理内存"设置堆大小。
  比如宿主机 16GB,JVM 默认最大堆 = 16GB × 1/4 = 4GB。

问题:
  容器限制 2GB,但 JVM 以为有 4GB 可用,
  堆设到 4GB,超过容器的 2GB 限制 → cgroup OOM

即使你设了 -Xmx2g:
  JVM 的堆是 2GB,但还有"非堆内存"(元空间、线程栈、直接内存),
  加起来可能超过 2GB → 还是 OOM

Java 10+ 的改进:

Java 10 之后,JVM 能感知 cgroup 限制(UseContainerSupport 默认开启),
默认堆大小基于 cgroup 限制计算,而不是宿主机内存。

但还是要小心"非堆内存",建议:
  -Xmx 设为容器 limit 的 70-80%
  预留非堆内存的空间

Go 的坑:GOMAXPROCS 和 GOMEMLIMIT。

Go 的 GOMAXPROCS(第 11 章):
  runtime.NumCPU() 读 /proc/cpuinfo → 读到宿主机核数
  容器限制 2 核,但 GOMAXPROCS = 宿主机的 64 核
  → Go 以为有 64 个核,创建 64 个 P,64 个线程抢 2 个核的配额
  → 上下文切换暴增 + CPU 节流(throttling)

Go 的 GC 和内存(第 1、2 章):
  Go 的 GC 默认根据"可用内存"决定 GC 频率
  如果读到宿主机内存(16GB),GC 会"懒惰"
  但容器限制 2GB,堆涨到 2GB 就被 cgroup OOM
  → Go 来不及 GC,就被杀了

解决方案:

Go:
  # 1. automaxprocs(自动读 cgroup 配额,设置 GOMAXPROCS)
  import _ "go.uber.org/automaxprocs"

  # 2. GOMEMLIMIT(告诉 GC 内存上限)
  GOMEMLIMIT=1500MiB  # 容器 limit 2GB 的 75%

  # 3. Go 1.25+ 自动感知 cgroup

Java:
  # 1. 用新版 Java(10+),自动感知 cgroup
  # 2. 手动设 -Xmx(容器 limit 的 70-80%)
  # 3. -XX:MaxRAMPercentage=70.0(按容器 limit 的百分比)

Java 和 Go 的坑,本质是同一个:

应用用”宿主机的资源信息”(内存、核数)做决策,但容器限制了资源。

结果:应用”过度使用”资源(堆太大、线程太多),触发 cgroup OOM 或 CPU 节流。

教训:容器化部署时,应用必须”感知 cgroup 的限制”,用容器的配额(而不是宿主机的总量)来设置堆大小、线程池大小、GC 参数。

Q7 cgroup OOM 和宿主机 OOM 有什么区别?

第 1 章讲过宿主机 OOM,这里对比 cgroup OOM。

宿主机 OOM:

触发:整个宿主机的物理内存耗尽

处理:OOM Killer 在"所有进程"里挑一个杀(第 1 章的评分)

特点:
  - 影响整个宿主机(所有容器都受影响)
  - 挑选范围是"所有进程"

cgroup OOM:

触发:某个 cgroup(容器)的内存用量,超过它的 memory limit

处理:杀掉"这个 cgroup 里的"进程

特点:
  - 只影响这个容器(其他容器不受影响)
  - 挑选范围是"这个容器的进程"

关键:宿主机可能还有大量空闲内存,
  但容器超了自己的 limit,还是被 OOM。

cgroup OOM 的几个坑:

坑一:容器 OOM 了,但宿主机 free 显示"内存充足"
  → 因为 OOM 是"容器级"的(超 limit),不是"宿主机级"的
  → 排查时,看容器的内存指标,不是宿主机的

坑二:cgroup v1 的 OOM 处理会"锁住"整个 cgroup
  → OOM 处理期间,cgroup 里的其他进程被卡住("假死")
  → v2 改进了

坑三:oom_score_adj 影响 cgroup OOM 的选择
  → 容器内哪个进程被杀,受 oom_score_adj 影响(第 1 章)

监控容器的内存,该看什么指标?

1. 容器的工作集(working set):
   = 匿名内存 + 活跃的 page cache
   = 最接近"OOM 判断"的指标

2. Prometheus 里的 container_memory_working_set_bytes

3. 不要看宿主机 free(那是全局的)

cgroup OOM 和宿主机 OOM 的区别,本质是”限制的层次”:

  • 宿主机 OOM:全局的物理内存耗尽
  • cgroup OOM:某个容器的”配额”耗尽(即使全局内存充足)

理解了这个区别,你就理解了”容器 OOM 但宿主机内存充足”这种”诡异”现象。

工程启示:限流/OOM 是”分层”的——全局有限流,每个租户/容器也有各自的配额。排查问题时,要先搞清楚”是哪一层的限制触发了”。


第四幕:限流的位置

Q8 限流应该在哪个层次做?(内核、网关、应用、数据库)

这一问把”配额”思想落地到”限流”的层次选择。

限流的四个层次:

1. 内核层(cgroup / tc):
   限制进程的资源(CPU、内存、I/O、带宽)
   → 最底层,硬限制

2. 网关层(Nginx / Envoy / API 网关):
   限制请求速率(QPS)、并发连接
   → 入口限流,保护后端

3. 应用层(业务代码 / 中间件):
   限制业务操作(下单次数、调用频率)
   → 业务级限流

4. 数据库层(连接数、慢查询):
   限制数据库资源(连接池大小、慢查询超时)
   → 保护数据库

各层次的适用场景:

内核层(cgroup):
  - 限制"资源"(CPU、内存),不是"请求"
  - 适用:防止容器/进程耗尽资源
  - 例:容器 limit、CPU quota

网关层:
  - 限制"请求"(QPS、并发)
  - 适用:保护后端不被流量打垮
  - 例:限流、熔断、降级

应用层:
  - 限制"业务操作"(有业务语义的限流)
  - 适用:防止滥用(防刷、防超卖)
  - 例:用户每天最多下单 10 次

数据库层:
  - 限制"数据库资源"
  - 适用:保护数据库
  - 例:连接池上限、慢查询 kill

怎么选择限流层次?

判断标准:你要防的是"什么级别的滥用"?

1. 防"资源耗尽"(内存、CPU)→ 内核层(cgroup)
2. 防"流量打垮后端" → 网关层
3. 防"业务滥用"(刷单、超卖)→ 应用层
4. 防"数据库被打垮" → 数据库层

通常要"多层配合":
  网关限流(入口)+ 应用限流(业务)+ 内核配额(资源)

一个关键认知:限流要”尽早”。

越靠近入口(网关),限流的"成本"越低:
  - 网关拒绝 → 请求根本没到后端 → 后端零负担
  - 应用拒绝 → 请求已经过了网关,浪费了网关和后端
  - 数据库拒绝 → 请求已经走到了数据库 → 数据库已经"受伤"

所以:能在网关限流,就别拖到数据库。

限流层次的选择,本质是”在离’源头’最近的地方,拦截’不该有的’请求”。

这和第 16 章”背压”是呼应的:越早背压,系统的代价越小。

工程启示:多层限流 + 越早越好,是保护系统的基本思路。

Q9 隔离和配额面临哪些结构性的问题?

最后泼冷水。隔离和配额这套机制,有几个根子上的矛盾:

问题一:隔离的”视野限制”会泄漏。

namespace 隔离了"进程、网络、文件系统",
但很多"全局信息"没被隔离:
  - /proc/meminfo(内存信息,Q5 讲过)
  - /proc/cpuinfo(CPU 信息)
  - 时钟(所有容器共享宿主机时钟)
  - 内核日志、内核版本

这些"泄漏",让容器里的应用"误判"环境(Q6 的 Java/Go 坑)。

问题二:配额的”静态 vs 动态”矛盾。

cgroup 的配额是"静态"的(你设了 2GB 就是 2GB),
但业务负载是"动态"的(高峰期要 4GB,低谷期 1GB)。

静态配额的后果:
  - 设大了 → 资源浪费(低谷期闲置)
  - 设小了 → 高峰期 OOM

解法:动态配额(弹性伸缩、VPA),但复杂。

问题三:配额和”应用自管理”的冲突。

第 1、3 章讲过"领域知识优先于通用策略":
  应用自己知道"什么时候该释放内存"(GC),
  但 cgroup 不知道,只会在"超限时"硬杀(OOM)。

结果:
  应用本来可以"主动降级"(GC、限流)来避免 OOM,
  但 cgroup 的硬限制让它"没机会降级就被杀了"。

解法:应用感知配额(GOMEMLIMIT、-Xmx),主动在配额内降级。

问题四:隔离的”强度”和”成本”矛盾。

隔离越强(VM 级),成本越高(重、慢、低密度)。
隔离越弱(进程级、namespace),成本越低,但越不安全(共享内核)。

容器选择了"进程级隔离"(轻),但代价是"共享内核"的风险:
  - 内核 bug 影响所有容器
  - 逃逸攻击(容器逃到宿主机)

所以安全敏感的场景(多租户、不可信代码),
  还是要用 VM(或 gVisor、Firecracker 等强隔离方案,第 19 章)。

隔离和配额的最终启示:

“隔离”和”配额”是”多租户共享资源”的必要手段,但它们不是免费的。

  • 隔离会”泄漏”(全局信息泄漏)
  • 配额会”误杀”(静态配额 vs 动态负载)
  • 强隔离有成本(重、慢)

所以设计多租户系统,核心是回答:

  1. 隔离够强吗?(安全 vs 成本的平衡)
  2. 配额合理吗?(静态 vs 动态的平衡)
  3. 应用感知配额吗?(避免”误杀”)

而这些权衡,本质和第 13 章的”特权级”、第 9 章的”缓存一致性”是同一个问题:隔离(安全)和共享(性能/成本)的平衡。


思考题

思考题 1

面试官问:“容器和虚拟机有什么区别?容器是怎么实现隔离的?“请回答,并说明”容器共享内核”这个特性带来的安全风险。

参考答案

核心答案:虚拟机隔离硬件(每个 VM 有独立内核),容器隔离进程(共享宿主机内核,用 namespace + cgroup 限制视野和资源)。容器共享内核,所以隔离不如 VM 彻底,有逃逸风险。

虚拟机(VM):

在硬件上模拟完整机器:
  - Hypervisor 模拟虚拟 CPU、内存、磁盘、网卡
  - 每个 VM 跑完整的操作系统(自己的内核)

隔离层次:硬件级
隔离强度:强(内核都隔离了)
代价:重(启动慢、占内存多、密度低)

容器(Container):

在操作系统上限制进程:
  - namespace:隔离视野(PID、网络、文件系统、用户等)
  - cgroup:限制资源(CPU、内存、I/O)
  - 共享宿主机内核

隔离层次:进程级
隔离强度:中(共享内核)
代价:轻(启动快、密度高)

容器怎么隔离:

1. namespace(六种):
   PID(进程)、Mount(文件系统)、Network(网络)、
   IPC(进程间通信)、UTS(主机名)、User(用户)

   效果:容器里的进程以为"世界只有它",
   看不到宿主机的其他进程、文件、网络。

2. cgroup:
   CPU 配额、内存上限、I/O 限制
   效果:容器用不过界,防止"坏邻居"耗尽资源

“共享内核”带来的安全风险:

1. 内核漏洞影响所有容器:
   内核有一个 bug(提权、内存损坏),
   攻击者在一个容器里利用它,可能影响宿主机和其他容器。

2. 逃逸攻击(container escape):
   容器里的进程,理论上被限制在容器里,
   但如果有漏洞(比如 Docker 的某些逃逸漏洞),
   可能"逃"到宿主机,获得宿主机权限。

3. 侧信道攻击(side channel):
   共享 CPU、缓存、内存,攻击者可能通过"侧信道"
   推断其他容器的信息。

4. 隔离不如 VM 彻底:
   VM 的内核是隔离的(内核 bug 只影响那个 VM),
   容器的内核是共享的(内核 bug 影响所有容器)。

怎么缓解(安全敏感场景):

1. 强隔离方案:gVisor(用户态内核)、Firecracker(轻量 VM)、Kata Containers
   → 在容器和宿主机之间加一层隔离(第 19 章)

2. 安全加固:seccomp(限制系统调用)、capabilities(限制权限)、
   read-only rootfs(只读文件系统)

3. 多租户隔离:不可信的租户用独立 VM 或强隔离容器,
   可信的租户才共享内核

这道题考察什么:

  1. 知不知道 VM 和容器的核心区别(隔离层次)
  2. 知不知道 namespace + cgroup 的机制
  3. 知不知道”共享内核”的安全风险
  4. 知不知道强隔离方案(gVisor、Firecracker)

思考题 2

你的 Go 服务部署在 K8s,容器 limit 是 2 核 4GB。但压测时发现:CPU 使用率很高但吞吐上不去,且偶发 OOM。请分析可能的原因,并给出解决方案。

参考答案

这是”容器资源限制”和”应用未感知”的典型问题,可能的原因和解决方案如下:

原因一:GOMAXPROCS 没设置,读到宿主机核数。

现象:CPU 使用率高但吞吐上不去(CPU 节流 throttling)

分析:
  Go 的 runtime.NumCPU() 读 /proc/cpuinfo,
  返回宿主机核数(比如 64 核)。
  容器限制 2 核,但 GOMAXPROCS = 64。

后果:
  Go 创建 64 个 P(逻辑处理器),64 个线程,
  但 cgroup 只给 2 核的配额。
  64 个线程抢 2 核 → 上下文切换暴增 + CPU 节流。
  → CPU 使用率"看起来高"(都在切换),但有效吞吐低。

解决:
  import _ "go.uber.org/automaxprocs"  # 自动读 cgroup 配额
  # 或 GOMAXPROCS=2

原因二:GC 没感知内存限制,来不及 GC 就被 OOM。

现象:偶发 OOM

分析:
  Go 的 GC 默认根据"可用内存"(读到宿主机 16GB)决定 GC 频率,
  所以 GC 比较"懒惰"。
  但容器限制 4GB,堆涨到 4GB 就被 cgroup OOM。
  → Go 来不及 GC,就被杀了。

解决:
  GOMEMLIMIT=3GiB  # 容器 limit 的 75%
  # 让 GC 知道内存上限,接近时更激进地 GC

原因三:内存 limit 4GB 设置不合理(太紧)。

分析:
  Go 的堆 + goroutine 栈 + 非堆内存(runtime 元数据、cgo)+ page cache,
  加起来可能接近或超过 4GB。

  即使堆是 2GB,加上栈(100 万 goroutine × 2KB = 2GB),
  就超了 4GB。

解决:
  1. 分析内存构成:堆、栈、非堆各占多少
  2. 合理设置 limit(留足余量)
  3. 控制 goroutine 数量(防止栈内存暴涨)

原因四:容器里的其他进程也占内存。

分析:
  K8s Pod 里可能有多个容器(sidecar),
  或者有 pause 容器、日志采集器等,
  它们也占内存,要和主容器共享 4GB 的 limit。

解决:
  1. 检查 Pod 里的所有容器,合理分配 limit
  2. 用 requests/limits 区分"保证值"和"上限"

完整的诊断流程:

1. 看 CPU 是否节流:
   cat /sys/fs/cgroup/cpu.stat
   # nr_throttled > 0 说明 CPU 被节流了

2. 看 GOMAXPROCS:
   在服务里打印 runtime.GOMAXPROCS(0)
   # 如果远大于 2,说明读到了宿主机核数

3. 看内存构成:
   pprof 看堆、goroutine 数量
   # 判断是堆、栈还是非堆内存

4. 看 OOM 日志:
   kubectl describe pod 看 OOM 事件
   # 判断是 cgroup OOM 还是宿主机 OOM

解决方案总结:

1. automaxprocs(自动设置 GOMAXPROCS = cgroup 核数)
2. GOMEMLIMIT=3GiB(告诉 GC 内存上限)
3. 合理设置 requests/limits(requests 是保证,limits 是上限)
4. 监控容器级指标(container_memory_working_set_bytes),
   而不是宿主机指标

这道题考察什么:

  1. 知不知道 GOMAXPROCS 的容器坑
  2. 知不知道 GOMEMLIMIT 的作用
  3. 知不知道”CPU 节流”和”cgroup OOM”
  4. 会不会系统化地诊断(而不是瞎猜)

思考题 3

“限流应该在哪个层次做?“你的服务是一个电商 API,有:网关(Nginx)、应用(Go)、数据库(MySQL)。请设计一个”多层限流”方案,并说明每层防什么。

参考答案

核心思路:多层限流,每层防不同级别的”滥用”,越靠近入口越早拦截。

第一层:网关(Nginx/API 网关)。

防什么:流量攻击、突发流量打垮后端

限流方式:
  - QPS 限流(limit_req)
  - 并发连接数限流(limit_conn)
  - IP 级别的限流(防单个 IP 刷)

例:
  # 每个 IP 每秒最多 10 个请求
  limit_req_zone $binary_remote_addr zone=per_ip:10m rate=10r/s;
  
  # 全局每秒最多 1000 个请求
  limit_req_zone $server_name zone=global:10m rate=1000r/s;

第二层:应用(Go)。

防什么:业务滥用(刷单、超卖、接口被恶意调用)

限流方式:
  - 用户级限流(每个用户的操作频率)
  - 接口级限流(某个接口的调用频率)
  - 降级(高峰期关闭非核心功能)

例:
  # 用户级限流(用 Redis + 令牌桶/滑动窗口)
  # 每个用户每天最多下单 10 次
  
  # 接口级限流(用 golang.org/x/time/rate)
  limiter := rate.NewLimiter(rate.Limit(100), 100)  # 每秒 100 个
  
  # 降级:活动高峰,关闭"推荐"等非核心接口

第三层:数据库(MySQL)。

防什么:数据库被打垮(连接耗尽、慢查询拖垮)

限流方式:
  - 连接池上限(max_connections)
  - 慢查询超时(max_execution_time)
  - 读写分离(读走从库,减轻主库压力)

例:
  max_connections = 500
  max_execution_time = 5000  # 5 秒超时

第四层(补充):资源层(cgroup/K8s)。

防什么:资源耗尽(内存、CPU)

限流方式:
  - 容器 limit(内存、CPU 上限)
  - Pod 的 requests/limits

例:
  resources:
    requests:
      cpu: "1"
      memory: "2Gi"
    limits:
      cpu: "2"
      memory: "4Gi"

各层防什么(总结表):

层次防什么限流对象手段
网关流量攻击QPS、并发、IPlimit_req/conn
应用业务滥用用户、接口令牌桶、滑动窗口
数据库连接/慢查询连接数、超时max_connections
资源资源耗尽CPU、内存cgroup limit

关键设计原则:

1. 越早拦截越好:
   网关拦截 → 后端零负担
   应用拦截 → 后端部分负担
   数据库拦截 → 数据库已"受伤"

2. 每层限流"独立",但"配合":
   网关防"流量",应用防"业务",数据库防"资源"
   三层配合,层层设防

3. 限流要"优雅":
   - 不是"粗暴拒绝",而是"友好提示"(429 + 提示信息)
   - 降级 > 拒绝 > 崩溃

这道题考察什么:

  1. 知不知道”多层限流”的思想
  2. 知不知道每层防什么(不是堆技术,而是”防什么”)
  3. 知不知道”越早越好”的原则
  4. 会不会设计”优雅”的限流(不是粗暴拒绝)

思考题 4(开放题)

“隔离”和”共享”是一对永恒的矛盾。请从”单机(进程隔离)、容器(namespace)、微服务(进程+网络)、组织(团队边界)“四个尺度,说说这个矛盾的体现,以及各自的”隔离强度”和”共享成本”。

参考答案

开放题,考察你能不能把”隔离 vs 共享”抽象成跨尺度的通用权衡。

核心矛盾:隔离越强,越安全、越独立,但共享越难、通信成本越高。

尺度一:单机(进程隔离)。

隔离:进程的虚拟地址空间(第 1 章)、特权级(第 13 章)
共享:文件系统、page cache、CPU、内存带宽

隔离强度:强(进程崩了不影响别人)
共享成本:低(共享内存、文件都很快)

矛盾:进程间要"通信"(共享数据),
  要么用 IPC(管道、共享内存,快但复杂),
  要么用网络(慢但简单)。

尺度二:容器(namespace)。

隔离:namespace(视野)+ cgroup(资源)
共享:宿主机内核、宿主机硬件

隔离强度:中(共享内核,隔离不如进程彻底)
共享成本:低(共享内核,资源利用率高)

矛盾:容器"轻"(共享内核,密度高),
  但"共享内核"有安全风险(逃逸、侧信道)。

尺度三:微服务(进程 + 网络)。

隔离:进程边界 + 网络边界
共享:网络、数据库、消息队列

隔离强度:中(进程隔离,但共享数据库/网络)
共享成本:高(网络通信,毫秒级延迟)

矛盾:微服务"独立部署、独立扩展"(隔离好),
  但"跨服务调用"慢(网络),
  且"共享数据库"导致耦合(数据一致性难)。

尺度四:组织(团队边界)。

隔离:团队边界、职责划分、代码仓库
共享:公共组件、基础设施、文档

隔离强度:弱(靠约定,不是硬隔离)
共享成本:中(沟通成本、协调成本)

矛盾:团队"独立"(隔离)能快速迭代,
  但"共享组件"(公共库、基础设施)需要协调,
  协调成本随团队规模增长(康威定律)。

统一的观察:

“隔离 vs 共享”的权衡,在每一层都是”安全/独立 vs 成本/效率”的权衡。

  • 隔离强 → 安全、独立,但共享难、成本高
  • 共享多 → 效率高、成本低,但耦合、风险大

而”正确的隔离边界”,取决于”组件之间的耦合度”:

  • 耦合强的组件,应该共享(放一起,否则通信成本高)
  • 耦合弱的组件,应该隔离(分开,避免互相拖累)

这就是”高内聚、低耦合”原则的底层逻辑。

一个判断框架(设计任何系统都适用):

问自己三个问题:

1. 这些组件"耦合"吗?
   - 耦合强(频繁交互、共享数据)→ 共享(放一起)
   - 耦合弱(独立、偶尔交互)→ 隔离(分开)

2. 隔离的"代价"能接受吗?
   - 隔离强 → 通信成本高(跨进程、跨网络)
   - 隔离弱 → 风险大(一个 bug 拖垮全部)

3. 有没有"中间地带"?
   - 单机:线程(轻隔离)vs 进程(强隔离)
   - 容器:容器(中隔离)vs VM(强隔离)
   - 微服务:一个服务(共享)vs 拆分(隔离)

这道题的价值:

它让你看到,“隔离 vs 共享”是贯穿计算机系统(和人类社会)的根本权衡。

理解了它,你就理解了:

  • 为什么进程比线程”重”(隔离更强,但通信更贵)
  • 为什么容器比 VM”轻”(隔离更弱,但密度更高)
  • 为什么微服务要”合理拆分”(拆太细,通信成本高;拆太粗,耦合)

而”正确的边界”,永远是”在耦合强的地方共享,在耦合弱的地方隔离”。


一页纸总结

┌──────────────────────────────────────────────────────────────┐
│  第 15 章 · 隔离与配额:邻居是魔鬼                                │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  【隔离 vs 配额】                                               │
│    隔离(namespace):让进程以为世界只有它(限制视野)             │
│    配额(cgroup):让进程用不过界(限制资源)                      │
│    容器 = 被限制了视野和资源的进程                                │
│                                                              │
│  【namespace 六种】                                             │
│    PID/Mount/Network/IPC/UTS/User                             │
│    本质:隔离不是物理分割,是视野限制                             │
│                                                              │
│  【cgroup 的坑】                                                │
│    v1:层级混乱 + memsw 反直觉(历史包袱)                       │
│    v2:统一层级 + 清晰语义(推倒重来)                            │
│                                                              │
│  【内存配额的经典坑】                                            │
│    容器 free 显示宿主机内存(/proc 泄漏)                        │
│    Java 读宿主机内存设堆 → OOM                                  │
│    Go 读宿主机核数设 GOMAXPROCS → CPU 节流                      │
│    解法:感知 cgroup(automaxprocs、GOMEMLIMIT、-Xmx 百分比)    │
│                                                              │
│  【cgroup OOM vs 宿主机 OOM】                                   │
│    宿主机 OOM:全局物理内存耗尽                                  │
│    cgroup OOM:容器超自己的 limit(即使全局内存充足)             │
│    排查要看"哪一层的限制触发了"                                  │
│                                                              │
│  【限流的层次】                                                 │
│    内核(资源)→ 网关(流量)→ 应用(业务)→ 数据库(资源)        │
│    原则:越早拦截越好,多层配合                                  │
│                                                              │
│  【一句话】                                                     │
│    隔离让你看不到邻居,配额让你抢不过邻居;                        │
│    隔离 vs 共享,是安全/独立与成本/效率的永恒权衡。                │
│                                                              │
│  【下一章预告】                                                  │
│    配额是"硬限制",但系统过载时,还有更优雅的应对——               │
│    下一章讲背压、超时与降级:宁可慢,不要死。                     │
└──────────────────────────────────────────────────────────────┘