📌 本文部分内容由 AI 辅助整理,已经人工核对。文中所有数字都来自我这台机器上真跑出来的输出,脚本零依赖、不需要 root,你可以在自己的容器里跑一遍——你跑出来的数会和我的完全不同,那才是重点。文末给了脚本全文。

nproc 报 72 核,cgroup 只给 8 核:容器真实限额与被限次数怎么读

前几天我在一个容器里跑东西,跑着跑着,echo alive 都开始返回退出码 1。

不是命令写错了。就是 echo,一个字都不带的 echo,失败了。

第一反应当然是查内存。free -g 显示还有 60 多 G 可用。查 OOM,dmesg 里干干净净。查磁盘,够用。日志全绿,服务看着都活着,就是任何新命令都起不来。

后来定位到根因,我第一次认真读了三个平时从没打开过的文件。这篇就是那次的记录。


一、第一层:nprocfree 读的是宿主机

在容器里跑服务的人几乎都干过同一件事:nproc 看一眼定 worker 数,free -g 看一眼定 batch 大小。

这两个命令读的是 /proc/cpuinfo/proc/meminfo。而 /proc 这两项不被 cgroup 隔离——容器里看到的就是宿主机的。除非镜像里装了 lxcfs 之类的东西专门去伪装,否则你看到的是整台物理机。

限额不在那儿,在 cgroup 的文件里。

这是我这台机器的实际情况:

命令行告诉你cgroup 实际给的
CPUnproc = 72cpu.max = 8.00 核
内存MemTotal = 125.16 GiBmemory.max = 16.00 GiB

cpu.max 这个文件里躺着两个数:

800000 100000

意思是「每 100000 微秒的周期里,最多给你跑 800000 微秒的 CPU 时间」。800000 ÷ 100000 = 8,所以是 8 核。

如果你按 nproc 的 72 去开 worker、去设 OMP_NUM_THREADS,你开的是配额的九倍。这些线程不会报错,它们会互相抢那 8 核的时间片,然后你会得到一个「CPU 使用率不高但就是慢」的服务。


二、第二层:sched_getaffinity 也救不了你

网上关于这个问题的建议,最常见的一条是:别用 os.cpu_count(),用 sched_getaffinity,那个是容器感知的。

在我这台机器上,这条建议完全无效。看实测:

nproc / os.cpu_count()    72
sched_getaffinity         72
cpuset 允许的核           0-71

三个都是 72。cpuset.cpus.effective0-71,也就是所有核都允许我用

这不矛盾。CPU 的限制方式有两种:

  • 绑核型(cpuset):只让你在 0-7 号核上跑。这种 affinity 能测出来。
  • 配额型cpu.max / v1 的 cfs_quota_us):所有核都让你上,但每个周期你总共只能跑这么多微秒。affinity 完全测不出来。

绝大多数容器编排——Docker 的 --cpus、Kubernetes 的 limits.cpu——默认走的是配额型。所以那条流传很广的建议,在最常见的场景下恰好不管用。

顺带一提,Python 3.13 新加的 os.process_cpu_count() 也解决不了这个问题。官方文档对它的描述是:

Get the number of logical CPUs usable by the calling thread of the current process. Returns None if undetermined. It can be less than cpu_count() depending on the CPU affinity.

—— docs.python.org/3.13/library/os.html

关键在最后半句:它比 cpu_count() 少多少,取决于 CPU affinity。全文没提 cgroup 配额,因为它本来就不看那个。所以在配额型限制下它一样会返回 72。(我这台是 3.12,脚本里对这个函数做了存在性判断,有就读、没有就跳过。)

结论:想知道 CPU 配额,除了读 cpu.max 没有别的办法。


三、第三层:内核已经替你数好了,只是那几个文件没人看

前两层还只是「读数不准」。这一层是这篇文章我最想说的:

你根本不用猜「我是不是被限了」。内核为每一类限额都准备了触顶计数器,数就在文件里躺着。

三个文件,各管一类:

文件关键字段记的是什么
cpu.statnr_throttled / throttled_usec被 CPU 配额掐停过多少次、累计停了多久
memory.eventsmax / oom_kill内存触顶多少次、OOM 杀了几个进程
pids.eventsmaxfork 被拒了多少次

这是我那台机器的读数:

  CPU 被掐停次数            915                 占 740307 个调度周期的 0.124%
    累计停了多久            21.4 分钟           纯等待,不是在算
  内存触顶次数              0                   memory.events: max
  被 OOM 杀掉的进程         0                   memory.events: oom_kill
  fork 被拒次数             2143                pids.events: max

看清楚这三行的关系:

  • 内存触顶 0 次,OOM 杀了 0 个。 而这恰恰是所有人第一时间会去查的那一项。查完发现是 0,就得出「不是内存问题」,然后卡住。
  • CPU 被掐停 915 次,累计 21.4 分钟。 占全部调度周期的 0.124%,比例不高,但这 21 分钟里进程是可运行状态却被内核按住不给跑——它不是在算,是在等。表现出来就是零星的、复现不了的卡顿。
  • fork 被拒 2143 次。 这个才是真凶。而 pids.events 这个文件,我敢说大部分人从来没打开过。

一次故障里,最贵的不是修,是找。这三个计数器把「找」这一步从猜变成了读。


四、pids 这一项为什么最难认

因为它的报错形态完全不像「进程数超了」。

Linux 上线程也占一个 pid(内核里线程和进程都是 task),所以 pids.max 管的其实是「线程 + 进程」的总数。这就带来两个后果。

后果一:额度掉得比你想的快。

我这台机器:

  进程/线程数上限           500
    已用                    465                 (93.0%)

500 的额度,用掉 465。这里面绝大部分不是「我开了 465 个程序」,而是几个自带线程池的进程——一个 ffmpeg 起十几个编码线程,一个模型推理进程起几十个,几个一叠加就到顶了。

后果二:撞上限之后,报错五花八门。

内核在这里返回的错误码是 EAGAIN(11)。它的标准描述是:

Resource temporarily unavailable

「资源」是哪个资源,这句话一个字都没说。

这才是它难认的根本原因。同样一句 Resource temporarily unavailable,可能是非阻塞 socket 还没就绪,可能是文件锁没拿到,也可能是——你已经开不出新进程了。错误信息本身不区分。

传到不同的上层,它会长成不同的样子:

  • Python 起子进程 → BlockingIOError: [Errno 11] Resource temporarily unavailable
  • Python 起线程 → RuntimeError: can't start new thread
  • shell 里 → 某个命令莫名其妙退出码 1

我那次就是最后一种。echo 失败,是因为 shell 要先 fork 一个子进程再去执行它,而 fork 不出来了。命令本身一点问题没有。

所以:当报错含糊、而 memory.events 里的 oom_kill 是 0 的时候,去看一眼 pids.events


五、还有个坑:限额不一定设在你这一层

cgroup 是树状的。你的进程在某个节点上,但限额可能设在它的父节点、祖父节点。

最典型的是 Kubernetes:内存上限常常设在 pod 这一层,而你的容器那一层写着 max

如果你只 cat /sys/fs/cgroup/memory.max 读到 max,就会得出「没有内存限制」的结论——错的。

正确做法是从 /proc/self/cgroup 里读出自己所在的路径,然后一路往上爬到根,逐层看,取最严的那个。脚本里的 climb()effective() 干的就是这件事。

(在容器里,/proc/self/cgroup 通常被 cgroup namespace 改写成 0::/,也就是「我就是根」,这时候只有一层。但在裸机上跑 systemd 服务、或者有嵌套 cgroup 的场景,爬这一步是必需的。)


六、脚本

零依赖,纯标准库,不需要 root,cgroup v1 / v2 都认。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
cgroup_limits.py —— 读出容器的真实 CPU / 内存 / 进程数限额,
以及内核已经因为这些限额挡了你多少次。

读三组触顶计数器(这才是重点,它们记的是已经发生过的事):
  · cpu.stat        nr_throttled / throttled_usec   被 CPU 配额掐停多少次、累计多久
  · memory.events   max / oom_kill                  内存触顶多少次、OOM 杀了几个
  · pids.events     max                             fork 被拒了多少次

限额可能设在父层(只读当前层会读到 max 而误判为不限),所以沿 cgroup
层级往上爬,取最严的那个。

零依赖,不需要 root,v1 / v2 都认。

  python3 cgroup_limits.py              # 完整报告
  python3 cgroup_limits.py --json       # 机器可读,接监控用
  python3 cgroup_limits.py --advise 3.5 # 按「每 worker 约 3.5 GB」给出建议并发数

这些文件的语义以内核文档为准:
  https://docs.kernel.org/admin-guide/cgroup-v2.html
  https://docs.kernel.org/scheduler/sched-bwc.html
"""

import argparse
import json
import os
import sys
import unicodedata

CG_ROOT = "/sys/fs/cgroup"


# ---------------------------------------------------------------- 小工具

def dwidth(s):
    """按终端显示宽度算,中文占 2 列。不这么算表格必然错位。"""
    return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)


def wpad(s, width):
    return s + " " * max(0, width - dwidth(s))


def read(path):
    """读一个 cgroup 文件;不存在或没权限就返回 None,不抛。"""
    try:
        with open(path, "r") as f:
            return f.read().strip()
    except (IOError, OSError):
        return None


def human_bytes(n):
    if n is None:
        return "—"
    if n == "max":
        return "不限"
    step = 1024.0
    for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"):
        if abs(n) < step or unit == "PiB":
            return "%.2f %s" % (n, unit) if unit != "B" else "%d B" % n
        n /= step


def human_secs(us):
    """微秒 -> 人看得懂的时长。"""
    if us is None:
        return "—"
    s = us / 1e6
    if s < 60:
        return "%.1f 秒" % s
    if s < 3600:
        return "%.1f 分钟" % (s / 60)
    return "%.2f 小时" % (s / 3600)


def parse_kv(text):
    """把 'a 1\\nb 2' 这种 flat-keyed 文件解析成 dict。"""
    out = {}
    if not text:
        return out
    for line in text.splitlines():
        parts = line.split()
        if len(parts) == 2:
            try:
                out[parts[0]] = int(parts[1])
            except ValueError:
                out[parts[0]] = parts[1]
    return out


# ---------------------------------------------------------------- 版本与路径

def detect_version():
    """v2 的标志是根目录下有 cgroup.controllers。"""
    if os.path.exists(os.path.join(CG_ROOT, "cgroup.controllers")):
        return 2
    if os.path.isdir(os.path.join(CG_ROOT, "memory")):
        return 1
    return 0


def self_cgroup_path():
    """
    /proc/self/cgroup 里 v2 的行长这样:  0::/some/path
    在容器里通常被 cgroup namespace 改写成 0::/ ,也就是「我就是根」。
    """
    txt = read("/proc/self/cgroup")
    if not txt:
        return "/"
    for line in txt.splitlines():
        f = line.split(":", 2)
        if len(f) == 3 and f[0] == "0":
            return f[2] or "/"
    return "/"


def climb(rel, filename):
    """
    从自己所在的 cgroup 一路往上走到根,逐层找 filename。
    返回 [(层级路径, 该层的原始值), ...],从最近的一层开始。

    为什么要爬:限额经常设在父层(比如 Kubernetes 的 pod 层设了内存上限,
    容器层写着 max)。只读当前层会读到 "max",然后得出「没有限制」的错误结论。
    """
    out = []
    parts = [p for p in rel.strip("/").split("/") if p]
    while True:
        d = os.path.join(CG_ROOT, *parts) if parts else CG_ROOT
        v = read(os.path.join(d, filename))
        if v is not None:
            out.append(("/" + "/".join(parts) if parts else "/", v))
        if not parts:
            break
        parts.pop()
    return out


def effective(rel, filename, parse):
    """
    沿层级取「最严」的那个值。parse 把原始文本转成可比较的数(不限 -> None)。
    返回 (数值, 生效层级, 该层原始文本)。
    """
    best, best_at, best_raw = None, None, None
    for path, raw in climb(rel, filename):
        v = parse(raw)
        if v is None:
            continue
        if best is None or v < best:
            best, best_at, best_raw = v, path, raw
    return best, best_at, best_raw


# ---------------------------------------------------------------- 采集

def p_max_int(raw):
    """'max' -> None(不限);其余转 int。"""
    raw = raw.strip()
    if raw == "max":
        return None
    try:
        n = int(raw)
    except ValueError:
        return None
    # v1 用一个巨大的数表示"不限",不是字符串 max
    return None if n >= (1 << 62) else n


def p_cpu_max(raw):
    """v2 的 cpu.max 是 '<quota> <period>',返回等效核数。"""
    f = raw.split()
    if not f or f[0] == "max":
        return None
    try:
        quota = int(f[0])
        period = int(f[1]) if len(f) > 1 else 100000
    except ValueError:
        return None
    return quota / float(period) if period else None


def collect():
    ver = detect_version()
    rel = self_cgroup_path()
    d = {"cgroup_version": ver, "cgroup_path": rel}

    if ver == 2:
        cpu, cpu_at, cpu_raw = effective(rel, "cpu.max", p_cpu_max)
        mem, mem_at, _ = effective(rel, "memory.max", p_max_int)
        pid, pid_at, _ = effective(rel, "pids.max", p_max_int)
        d["cpu_limit"] = cpu
        d["cpu_limit_at"] = cpu_at
        d["cpu_limit_raw"] = cpu_raw
        d["mem_limit"] = mem
        d["mem_limit_at"] = mem_at
        d["pids_limit"] = pid
        d["pids_limit_at"] = pid_at
        d["mem_current"] = p_max_int(read(os.path.join(CG_ROOT, "memory.current")) or "max")
        d["pids_current"] = p_max_int(read(os.path.join(CG_ROOT, "pids.current")) or "max")
        d["cpu_stat"] = parse_kv(read(os.path.join(CG_ROOT, "cpu.stat")))
        d["mem_events"] = parse_kv(read(os.path.join(CG_ROOT, "memory.events")))
        d["pids_events"] = parse_kv(read(os.path.join(CG_ROOT, "pids.events")))
        cs = read(os.path.join(CG_ROOT, "cpuset.cpus.effective"))
        d["cpuset"] = cs or None
    elif ver == 1:
        q = read(CG_ROOT + "/cpu/cpu.cfs_quota_us")
        p = read(CG_ROOT + "/cpu/cpu.cfs_period_us")
        cpu = None
        if q and p and int(q) > 0:
            cpu = int(q) / float(int(p))
        d["cpu_limit"] = cpu
        d["cpu_limit_at"] = "(v1)"
        d["cpu_limit_raw"] = "%s / %s" % (q, p)
        d["mem_limit"] = p_max_int(read(CG_ROOT + "/memory/memory.limit_in_bytes") or "max")
        d["mem_limit_at"] = "(v1)"
        d["mem_current"] = p_max_int(read(CG_ROOT + "/memory/memory.usage_in_bytes") or "max")
        d["pids_limit"] = p_max_int(read(CG_ROOT + "/pids/pids.max") or "max")
        d["pids_limit_at"] = "(v1)"
        d["pids_current"] = p_max_int(read(CG_ROOT + "/pids/pids.current") or "max")
        st = parse_kv(read(CG_ROOT + "/cpu/cpu.stat"))
        d["cpu_stat"] = {
            "nr_throttled": st.get("nr_throttled"),
            "nr_periods": st.get("nr_periods"),
            "throttled_usec": (st.get("throttled_time") or 0) // 1000 or None,
        }
        d["mem_events"] = {}
        d["pids_events"] = {}
        d["cpuset"] = read(CG_ROOT + "/cpuset/cpuset.effective_cpus")

    # 这三个是大多数人实际拿来做决策的数,也是错的来源
    d["seen_cpu_count"] = os.cpu_count()
    try:
        d["seen_affinity"] = len(os.sched_getaffinity(0))
    except (AttributeError, OSError):
        d["seen_affinity"] = None
    d["seen_process_cpu_count"] = (
        os.process_cpu_count() if hasattr(os, "process_cpu_count") else None
    )
    mt = None
    txt = read("/proc/meminfo")
    if txt:
        for line in txt.splitlines():
            if line.startswith("MemTotal:"):
                mt = int(line.split()[1]) * 1024
                break
    d["seen_memtotal"] = mt
    return d


# ---------------------------------------------------------------- 报告

W = 26


def line(title):
    print()
    print("  ── %s " % title + "─" * max(0, 58 - dwidth(title)))


def row(k, v, note=""):
    print("    %s%s%s" % (wpad(k, W), wpad(str(v), 20), note))


def report(d):
    ver = d["cgroup_version"]
    print()
    print("  cgroup v%s   当前所在层级 %s" % (ver or "?", d.get("cgroup_path")))
    if ver == 0:
        print("  没找到 cgroup 挂载点,这台机器可能不在容器里,或者挂载路径不标准。")
        return

    line("你看到的(读的是宿主机的 /proc,容器不隔离这些)")
    row("nproc / os.cpu_count()", d["seen_cpu_count"], "个逻辑核")
    row("sched_getaffinity", d["seen_affinity"], "个可用核")
    if d["seen_process_cpu_count"] is not None:
        row("os.process_cpu_count()", d["seen_process_cpu_count"], "个核 (Python 3.13+)")
    row("free / MemTotal", human_bytes(d["seen_memtotal"]), "")

    line("实际生效的(cgroup 说了算)")
    cpu = d["cpu_limit"]
    if cpu is None:
        row("CPU 配额", "不限", "")
    else:
        row("CPU 配额", "%.2f 核" % cpu, "← %s  [%s]" % (d.get("cpu_limit_raw"), d.get("cpu_limit_at")))
    mem = d["mem_limit"]
    row("内存上限", human_bytes(mem) if mem else "不限",
        ("← [%s]" % d.get("mem_limit_at")) if mem else "")
    if d.get("mem_current"):
        pctm = (100.0 * d["mem_current"] / mem) if mem else 0
        row("  已用", human_bytes(d["mem_current"]), ("(%.1f%%)" % pctm) if mem else "")
    pid = d["pids_limit"]
    row("进程/线程数上限", pid if pid else "不限",
        ("← [%s]" % d.get("pids_limit_at")) if pid else "")
    if d.get("pids_current") is not None:
        pctp = (100.0 * d["pids_current"] / pid) if pid else 0
        row("  已用", d["pids_current"], ("(%.1f%%)" % pctp) if pid else "")
    if d.get("cpuset"):
        row("cpuset 允许的核", d["cpuset"], "")

    # 差距 —— 只陈述两个数,不做倍数渲染
    if cpu and d["seen_cpu_count"]:
        print()
        print("    CPU:命令行告诉你 %d 个,配额只给 %.2f 个。" % (d["seen_cpu_count"], cpu))
        if d.get("cpuset") and "-" in str(d["cpuset"]):
            print("    注意 cpuset 是全开的,所以 affinity 类的检测也看不出来这个限制,")
            print("    限制是配额型的(每个周期只准跑这么多微秒),不是绑核型的。")
    if mem and d["seen_memtotal"]:
        print("    内存:free 告诉你 %s,上限只有 %s。"
              % (human_bytes(d["seen_memtotal"]), human_bytes(mem)))

    line("内核记的账(这几个计数器不用猜,是已经发生过的事)")
    cs = d.get("cpu_stat") or {}
    nt, npd = cs.get("nr_throttled"), cs.get("nr_periods")
    if nt is None:
        row("CPU 被掐停次数", "—", "读不到 cpu.stat")
    else:
        rate = ("%.3f%%" % (100.0 * nt / npd)) if npd else ""
        row("CPU 被掐停次数", nt, "占 %s 个调度周期的 %s" % (npd, rate))
        row("  累计停了多久", human_secs(cs.get("throttled_usec")), "纯等待,不是在算")
        if nt:
            print("      ↑ 非零就说明配额真的不够用过。这段时间进程是可运行状态,")
            print("        但被内核按住不给跑,表现出来就是「莫名其妙的卡顿」。")
    me = d.get("mem_events") or {}
    if me:
        row("内存触顶次数", me.get("max", 0), "memory.events: max")
        row("被 OOM 杀掉的进程", me.get("oom_kill", 0), "memory.events: oom_kill")
    pe = d.get("pids_events") or {}
    if pe:
        row("fork 被拒次数", pe.get("max", 0), "pids.events: max")
        if pe.get("max"):
            print("      ↑ 非零就说明真的开不出新进程了。这个错误最难认:")
            print("        它不报「进程数超了」,而是变成 OSError / BlockingIOError,")
            print("        甚至变成某个子命令「莫名其妙退出码 1」。")

    print()


def advise(d, gb_per_worker):
    line("并发数建议(按你给的每 worker %.2f GB 估)" % gb_per_worker)
    cpu, mem = d["cpu_limit"], d["mem_limit"]
    by_cpu = int(cpu) if cpu else None
    by_mem = int(mem / (gb_per_worker * 1024 ** 3)) if mem else None
    row("按 CPU 配额", by_cpu if by_cpu is not None else "不受限")
    row("按内存上限", by_mem if by_mem is not None else "不受限")
    cands = [x for x in (by_cpu, by_mem) if x is not None]
    if cands:
        n = max(1, min(cands))
        print()
        print("    取小的那个:%d 个。" % n)
        print("    再留出主进程和 CPU 配额本身的余量,实际建议不超过 %d 个。" % max(1, n - 1))
    pid = d["pids_limit"]
    if pid:
        free_pids = pid - (d.get("pids_current") or 0)
        print("    另外:进程数还剩 %d 个额度。每个 worker 如果自带线程池,"
              % free_pids)
        print("    这个额度掉得比你想的快 —— 线程在 pids 里是算数的。")
    print()


def main():
    ap = argparse.ArgumentParser(
        description="读出容器的真实 CPU / 内存 / 进程数限额,以及内核记录的触顶次数",
        formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--json", action="store_true", help="输出 JSON,接监控用")
    ap.add_argument("--advise", type=float, metavar="GB",
                    help="按每个 worker 占用多少 GB,给出建议并发数")
    a = ap.parse_args()

    d = collect()
    if a.json:
        print(json.dumps(d, indent=2, ensure_ascii=False))
        return 0
    report(d)
    if a.advise:
        advise(d, a.advise)
    return 0


if __name__ == "__main__":
    sys.exit(main())

七、怎么用

第一步,在出问题的那个容器里直接跑。

python3 cgroup_limits.py

先看「实际生效的」那一段,跟你以为的对不对得上。再看「内核记的账」那一段——哪一项非零,哪一项就是你要查的方向。三项全零,说明瓶颈不在限额上,可以去查别的了,这也是有价值的信息。

第二步,定并发数的时候带上 --advise

python3 cgroup_limits.py --advise 3.5

3.5 是你估的每个 worker 大概占多少 GB。它会按 CPU 配额和内存上限各算一遍,取小的那个:

  按 CPU 配额               8
  按内存上限                4

  取小的那个:4 个。
  再留出主进程和 CPU 配额本身的余量,实际建议不超过 3 个。
  另外:进程数还剩 32 个额度。

第三步,接监控。

python3 cgroup_limits.py --json

三个触顶计数器都是单调递增的,天生适合做差分告警。比起对 CPU 使用率设阈值,nr_throttled 的增量是个精确得多的信号——使用率高不一定有问题,被掐停了就是真的不够用。

顺便说个刚发生的事。上面第三节那组读数是我开始写这篇文章时采的。写到这里我又采了一次:

写稿开始时写完这一节时增量
pids.eventsmax21432179+36
cpu.statnr_throttled915922+7

就在我敲字的这段时间里,这台机器又被拒绝了 36 次 fork。

这也解释了为什么单次读数意义有限:2143 这个累计值只告诉你「历史上撞过」,而 +36 才告诉你「现在正在撞」。 要做告警,一定是采两次做差。


八、这套方法不解决什么

说清楚边界,免得当银弹用:

  1. 它只回答「限额是多少、撞过几次」,不回答「该设多少」。 该给多少配额是业务问题,脚本不知道你的服务要干嘛。
  2. nr_throttled 非零不等于必须扩容。 短促的突发流量本来就会撞配额,看的是趋势和比例,不是有没有。我这台 0.124%,属于偶发。
  3. 只覆盖 CPU / 内存 / pids 三类。 IO 限速(io.max)、网络方向的限制不在里面——我这台 io.max 是空的,没设,也就没法真跑验证,所以我没写进去。没跑过的东西我不往脚本里塞。
  4. 计数器是容器生命周期内累计的。 重启就清零。判断「最近有没有撞」要靠两次采样做差,单次读数只能告诉你「历史上撞过」。
  5. cgroup v1 的字段名和 v2 不一样,脚本做了兼容,但 v1 那边我手上没有环境实测,只按内核文档写。如果你在 v1 上跑出了不对的结果,欢迎在评论里告诉我具体读数。

小结

  1. 容器里的 nprocfree 读的是宿主机,可以差一个数量级。
  2. sched_getaffinity 只能测出绑核型限制。Docker --cpus 和 K8s limits.cpu 默认是配额型,测不出来。
  3. cpu.max 里的两个数是「配额 / 周期」,相除就是等效核数。
  4. Linux 上线程也占 pid,pids.max 管的是线程加进程的总数。
  5. fork 撞上限返回 EAGAIN,它的描述是「资源暂时不可用」,没说是哪个资源,所以极容易被引到别的方向去查。
  6. 三个触顶计数器:cpu.statnr_throttledmemory.eventsoom_killpids.eventsmax
  7. 限额可能设在父层,读当前层会读到 max 而误判为不限。
  8. 我这台机器的真实读数:命令行报 72 核、配额只有 8 核;free 报 125 GiB、上限只有 16 GiB;OOM 0 次,fork 被拒 2143 次。

最后留个问题:你在容器里跑服务的时候,是按 nproc 定的 worker 数,还是按编排文件里写的 limit 定的?

如果是前者,建议现在就跑一下脚本看看这两个数差多少。如果是后者——那你比我当时强,我当时是前者。

更多推荐