磁盘明明没满,服务却写不进去?做个 inode 与分区占用只读报表页,cpolar 给异地同事看

磁盘明明没满,服务却写不进去?做个 inode 与分区占用只读报表页,cpolar 给异地同事看

上周帮朋友看一台跑着定时任务的小服务器,df -h 显示 / 只用了 41%,可日志服务就是报 No space left on device。查了半天才发现,是 /var 那个分区的 inode 被几百万个碎文件吃干净了。容量还有一大截,inode 先没了。

这种问题排查起来最费劲的地方在于:df -h 看不到它,得单独敲 df -i。而且如果人不在现场、服务器又只有内网地址,你跟同事说"你去看下 df -i",对方还不知道在哪台机器上、看哪一行。

所以这篇就做一个很窄的东西:一个只读的报表页,按分区/挂载点列出容量和 inode 两组数据,超阈值标黄标红,然后用 cpolar 把页面映射出去,异地同事打开浏览器就能看。不装 agent,不接 Prometheus,就一个 Python3 脚本。

图注:整体链路:df -P / df -i 采集 → 分区与 inode 双栏报表 → 阈值标黄标红 → 只绑 127.0.0.1:8114 → cpolar 映射给异地同事。

先说清楚它跟姊妹篇的分工。8107 那篇做的是"目录与大文件浏览"视角,回答"是哪个目录、哪个文件在涨"。本篇做的是"分区与 inode 阈值"视角,回答"哪个挂载点快满了、是容量满还是 inode 满"。两篇的页面长得像,但采集的数据源完全不同:这篇只调 df,不碰目录树。

1 这个页面到底看什么

一句话:只看挂载点级别的汇总数字。

具体就两个命令:

  • df -P:拿到文件系统、总容量、已用、可用、使用率、挂载点
  • df -i:拿到同一个挂载点的 inode 总量、已用、可用、inode 使用率

-P 是 POSIX 输出格式,保证每个文件系统一行,不会因为设备名太长被折成两行------这一点在处理长 UUID 挂载时很关键,不加 -P 的话解析很容易错位。

列出来的大致是这样:

挂载点 文件系统 容量使用率 inode 使用率
/ /dev/vda1 41% 96%
/var /dev/vdb1 78% 88%
/data /dev/vdc1 12% 3%

上面这些数字是模拟值,配合截图演示用。真实跑起来的时候数字来自你自己的机器。

阈值定两档:达到 85% 标黄,达到 95% 标红。两档对容量和 inode 都生效,只看不告警------页面不会发邮件、不推 webhook、不写企业微信。为什么不做告警?因为告警一旦发出去就得有人负责值班,这套东西的定位是"需要看一眼的时候能看",不是监控系统。

图注:两种视角对比:容量报表显示还有余量,inode 报表却已经接近耗尽------这就是「容量没满但写不进去」的典型现场。

另外有个边界要先说死:这个页面不浏览目录内文件、不列大文件、不递归 du 。du 在大目录上跑起来是分钟级的 IO,放在一个 HTTP 请求里等,很容易把页面拖死,也不该由报表页来做。找大文件是 8107 那篇的事。

2 环境准备

要求很低:

  • Python 3.8 及以上(标准库 http.server、subprocess、html 都在)
  • Linux 或 macOS
  • 能执行 df 的普通用户权限

不需要 pip install,不需要 Docker,不需要 root。df 是任何人都能跑的。

验证一下环境:

bash 复制代码
python3 -V
df -P | head -3
df -i | head -3

两条 df 都能出东西,就说明基础没问题。

这里有个地方容易踩:macOS 的 df -i 输出跟 Linux 不一样。macOS 会在容量列后面多插几列(iused、ifree、%iused 的位置和 Linux 不同),有些文件系统类型还会把 inode 报成 -。代码里必须按表头定位,不能写死列号。

先看自己机器上的实际表头:

bash 复制代码
df -i | head -1

Linux 一般长这样:

复制代码
Filesystem      Inodes  IUsed   IFree IUse% Mounted on

macOS 一般长这样(设备名带分区号):

复制代码
Filesystem     512-blocks Used Available Capacity iused ifree %iused Mounted on

不过上面第二种是 df -i 在部分 macOS 版本上的老行为。稳妥做法是:在代码里解析表头找 IUse% / %iused、IUsed / iused 这类关键字,找不到就降级 。降级表现是:容量照常显示,inode 那一列显示 N/A,页面不报错、不空白。这比"整页挂掉"要好得多。

3 写这个只读服务

整个服务就是一个文件,叫 disk_report.py。下面给出骨架,关键点是:路由白名单、方法白名单、参数白名单。

先看整体结构:

python 复制代码
#!/usr/bin/env python3
# 骨架示例:分区与 inode 只读报表页
# 仅用于本机 127.0.0.1:8114,不要直接绑定 0.0.0.0
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
import html

HOST = "127.0.0.1"
PORT = 8114

WARN = 85.0
CRIT = 95.0


def run_df(args):
    """执行 df,返回 stdout 文本;失败返回 None"""
    try:
        out = subprocess.run(
            ["df"] + args,
            capture_output=True,
            text=True,
            timeout=5,
            check=False,
        )
        if out.returncode != 0:
            return None
        return out.stdout
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        return None

subprocess.run 用了列表参数、check=False、timeout=5。列表参数意味着不会经过 shell,不存在命令注入的路径;timeout=5 是防止某个网络挂载点卡住时把请求线程占死。

接着解析。容量部分按 df -P 的固定 6 列来,inode 部分按表头关键字定位:

python 复制代码
def parse_capacity(text):
    rows = []
    if not text:
        return rows
    lines = [l for l in text.splitlines() if l.strip()]
    for line in lines[1:]:  # 跳过表头
        parts = line.split()
        if len(parts) < 6:
            continue
        fs, total, used, avail, pct, mount = parts[0], parts[1], parts[2], parts[3], parts[4], parts[5]
        rows.append({
            "fs": fs,
            "mount": mount,
            "total": total,
            "used": used,
            "avail": avail,
            "pct": to_pct(pct),
        })
    return rows


def to_pct(s):
    try:
        return float(s.replace("%", "").strip())
    except ValueError:
        return None

注意 mount 取的是 parts[5]。如果挂载点路径里有空格,split() 会把它切开------这是个已知的限制,处理方式是 line.split(None, 5),最后一段整体作为挂载点。这个细节在从实际机器上取数据时很值得用上:

python 复制代码
    parts = line.split(None, 5)

inode 解析用表头定位列:

python 复制代码
def parse_inode(text):
    result = {}
    if not text:
        return result
    lines = [l for l in text.splitlines() if l.strip()]
    if not lines:
        return result
    header = lines[0].split()
    upper = [h.upper() for h in header]
    try:
        i_used_idx = upper.index("IUSED")
        i_free_idx = upper.index("IFREE")
        i_pct_idx = upper.index("IUSE%")
    except ValueError:
        return result  # 表头不认识,整块降级为 N/A
    for line in lines[1:]:
        parts = line.split(None, max(i_pct_idx, i_used_idx, i_free_idx) + 1)
        if len(parts) <= i_pct_idx:
            continue
        mount = parts[-1]
        result[mount] = {
            "i_used": parts[i_used_idx],
            "i_free": parts[i_free_idx],
            "i_pct": to_pct(parts[i_pct_idx]),
        }
    return result

upper.index("IUSED") 对 macOS 的 iused 也成立,因为做了大写归一。macOS 表头里的 %iused 对应的是 IUSE% 位置语义,但字符串不一样,所以这里加一层兜底:

python 复制代码
    for cand in ("IUSE%", "%IUSED"):
        if cand in upper:
            i_pct_idx = upper.index(cand)
            break

把这段写进 try/except ValueError 之前就行。如果两种表头都没有,函数返回空字典,页面上的 inode 列全部显示 N/A。

合并和渲染:

python 复制代码
def collect():
    cap = parse_capacity(run_df(["-P"]))
    ino = parse_inode(run_df(["-i"]))
    rows = []
    for r in cap:
        info = ino.get(r["mount"], {})
        rows.append({
            **r,
            "i_used": info.get("i_used", "N/A"),
            "i_free": info.get("i_free", "N/A"),
            "i_pct": info.get("i_pct"),
        })
    return rows


def level(pct):
    if pct is None:
        return "na"
    if pct >= CRIT:
        return "crit"
    if pct >= WARN:
        return "warn"
    return "ok"

level() 对 None 返回 na,这样 inode 缺失时不会误判成 0%。

HTML 渲染用 html.escape 转义每一个从系统拿来的字段。虽然 df 的输出理论上不会有恶意内容,但挂载点名是可以被创建者控制的,这里转义成本极低,不做就是隐患。

python 复制代码
def render(rows):
    trs = []
    for r in rows:
        ip = r["i_pct"]
        ip_txt = "N/A" if ip is None else f"{ip:.0f}%"
        trs.append(
            "<tr class='%s'><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td>"
            "<td>%s</td><td>%s</td><td>%s</td><td class='%s'>%s</td></tr>"
            % (
                level(r["pct"]),
                html.escape(r["fs"]),
                html.escape(r["mount"]),
                html.escape(r["total"]),
                html.escape(r["used"]),
                html.escape(r["avail"]),
                html.escape(f"{r['pct']:.0f}%") if r["pct"] is not None else "N/A",
                html.escape(str(r["i_used"])),
                html.escape(str(r["i_free"])),
                level(ip),
                ip_txt,
            )
        )
    return "<!doctype html><html><head><meta charset='utf-8'><title>分区报表</title></head>" \
           "<body><h3>分区容量与 inode(只读)</h3><table>" \
           "<tr><th>文件系统</th><th>挂载点</th><th>总容量</th><th>已用</th>" \
           "<th>可用</th><th>容量%</th><th>inode 已用</th><th>inode 可用</th>" \
           "<th>inode%</th></tr>" + "".join(trs) + "</table></body></html>"

配色用内联的 <style> 就够,.warn 给背景 #fff3cd,.crit 给 #f8d7da,.na 给灰色文字。不要引外部 CSS,本地打开还得联网加载资源,没必要。

4 路由白名单:只开三个口子

这一节是本篇的核心。一个暴露到内网的 HTTP 服务,最容易出的事不是被人打,而是自己开的口子比想象的多。所以路由白名单要卡死。

规则:

  • GET / 返回报表页
  • HEAD / 返回同样的响应头,不带 body
  • GET /healthz 返回 200 和纯文本 ok
  • 其余方法(PUT / POST / DELETE / PATCH)一律 405,响应头带 Allow: GET, HEAD
  • 非白名单路径一律 404
  • 含 ../、%2e%2e、/etc/passwd 这类越界或敏感路径,同样 404,且响应体不包含任何文件系统路径

实现方式是在 do_* 里统一走一个检查函数:

python 复制代码
class Handler(BaseHTTPRequestHandler):
    server_version = "disk-report/1.0"

    def _route(self, method, body=True):
        path = urlparse(self.path).path
        if path not in ("/", "/healthz"):
            return self._send(404, "not found", body)
        if path == "/healthz":
            if method != "GET":
                return self._send(405, "method not allowed", body, extra={"Allow": "GET, HEAD"})
            return self._send(200, "ok", body, ctype="text/plain; charset=utf-8")
        return self._send(200, render(collect()), body)

    def _send(self, code, text, body=True, ctype="text/html; charset=utf-8", extra=None):
        data = text.encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(data)))
        self.send_header("X-Content-Type-Options", "nosniff")
        for k, v in (extra or {}).items():
            self.send_header(k, v)
        self.end_headers()
        if body:
            self.wfile.write(data)

这里有个细节:path not in ("/", "/healthz") 是精确匹配,/etc/passwd 不在集合里,直接 404。/../etc/passwd 经浏览器或 curl 规范化后成为 /etc/passwd,同样落在 404 里。%2e%2e 编码的情况 urlparse 不会自动解码,路径字符串本身就不匹配,还是 404。

响应体也要控制。404 的 body 固定是 not found 四个词,不回显请求路径。为什么?因为回显路径等于告诉扫描器"我在解析你的输入",而且路径里如果带系统路径信息,反而成了泄露。固定文本最干净。

这一条容易被忽略:很多自建小服务的 404 页会把请求 URL 打回去,图个方便。放到公网上,这就是一个免费的路径探测反馈。

方法处理:

python 复制代码
    def do_GET(self):
        self._route("GET")

    def do_HEAD(self):
        self._route("HEAD", body=False)

    def do_POST(self):
        self._send(405, "method not allowed", extra={"Allow": "GET, HEAD"})

    def do_PUT(self):
        self._send(405, "method not allowed", extra={"Allow": "GET, HEAD"})

    def do_DELETE(self):
        self._send(405, "method not allowed", extra={"Allow": "GET, HEAD"})

    def do_OPTIONS(self):
        self._send(405, "method not allowed", extra={"Allow": "GET, HEAD"})

注意 do_HEAD 传了 body=False,但 Content-Length 仍然按完整内容长度发。这是 HEAD 的语义:头跟 GET 一样,只是不带 body。如果 HEAD 的 Content-Length 是 0,一些客户端会认为页面是空的。

启动:

python 复制代码
if __name__ == "__main__":
    srv = ThreadingHTTPServer((HOST, PORT), Handler)
    print(f"listening on http://{HOST}:{PORT}")
    srv.serve_forever()

ThreadingHTTPServer 而不是 HTTPServer,是因为有多个挂载点的时候 df 调两次,单线程遇到慢挂载点会串行阻塞。

5 明确不做的三件事

这一节单独拎出来写,因为它决定了这个服务的性质。

第一,不做一键清理。 页面上不会出现任何 rm -rf、truncate、logrotate --force 按钮。原因很简单:一个本来只是"看看"的页面,一旦有了删除入口,任何人拿到地址就具备了破坏能力。真正要清理的时候,SSH 上去手动处理,多一步确认,多一层责任。

第二,不暴露 POST /exec。 不提供"输入命令、返回输出"的接口。这类接口看起来方便,实际上等于把 shell 挂在了 HTTP 上,鉴权一旦漏掉就是灾难。要查什么,加一个只读的固定路由,比开放任意命令安全一百倍。

第三,不对非清单目标做扫描。 不遍历 /proc、不扫 /home、不递归列目录,只对 df 报出来的挂载点取数。这既是性能考虑,也是范围考虑------这个工具的职责边界就是分区级。

图注:macOS 与 Linux 的 df -i 表头命名差异(iused/ifree 对 IUsed/IFree),匹配不到时降级显示为 N/A。

补一句,服务本身不读环境变量。所有配置都写在文件顶部的常量里,HOST、PORT、WARN、CRIT 四个值。为什么?环境变量在进程启动时可以被注入,一个只读工具没必要给自己留这个口子。

6 启动并验证

先在本机起起来:

bash 复制代码
python3 disk_report.py

终端会打印 listening on http://127.0.0.1:8114。另开一个窗口跑自检:

bash 复制代码
# 首页应该 200
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8114/

# HEAD 也应该 200,且能看到 Content-Length
curl -I http://127.0.0.1:8114/

# 健康检查返回 ok
curl -s http://127.0.0.1:8114/healthz

# 非 GET 方法应该 405,并且响应头有 Allow
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8114/
curl -s -i -X PUT http://127.0.0.1:8114/ | grep -i "^allow:"

# 越界路径应该 404
curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:8114/../etc/passwd"
curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:8114/etc/hosts"
curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:8114/%2e%2e/etc/passwd"

期望结果:前两行 200,第三行输出 ok,第四、五行 405 且 grep 能打出 Allow: GET, HEAD,最后三行都是 404。

如果 POST 返回的不是 405,优先检查是不是在 do_POST 之前有别的中间件拦了请求------本服务没挂中间件,所以这种问题一般出在代码没保存或进程没重启。

如果首页 200 但表格是空的,先手动跑一遍 df -P,确认当前用户能正常执行;再确认 df 在 PATH 里。

如果 inode 列全是 N/A,把自己机器上 df -i | head -1 的表头贴出来对照 parse_inode 的查找逻辑。这是跨平台最常出问题的地方。

图注:只读服务骨架:请求经方法白名单进入 GET / 、HEAD / 、GET /healthz 三条路由,下方接 df 采集器。

7 用 cpolar 给异地同事看

本机验证通了,问题就变成"怎么让不在同一个网络里的人打开"。cpolar 做这件事只需要一条命令,前提是服务还跑在 127.0.0.1:8114。

先装 cpolar。macOS 用 Homebrew:

bash 复制代码
brew tap probezy/cpolar
brew install cpolar

Linux 用官方安装脚本:

bash 复制代码
curl -L https://www.cpolar.com/static/downloads/install-release-cpolar.sh | sudo bash

装完注册账号并登录:

bash 复制代码
cpolar authtoken <你的 authtoken>

authtoken 在 cpolar 官网后台的"验证"页面,登录后才能看到。这里别用别人的 token,它跟账号绑定,泄漏了别人就能在你自己账号下建隧道。

然后起隧道:

bash 复制代码
cpolar http 8114

终端会返回一个公网地址,形如 https://xxxx.r6.cpolar.top,把它发给同事就行。

图注:只读边界:写方法返回 405 并带 Allow: GET, HEAD,越界路径与 /etc/passwd 一律 404。

这里要说清楚免费版的限制:免费账号拿到的随机地址是临时的,24 小时内会变。也就是说,今天发出去的链接,第二天失效,同事点进去是 404 或连接失败。如果只是临时看一眼,够用;如果是要长期挂在群里让大家随时点,就得往下走。

先是固定二级子域名,需要在官网后台"预留"一个名字,比如 disk- 开头加一截自定义后缀,绑定到 HTTP 隧道上。这需要基础套餐及以上 。配置好之后,地址就固定成 https://disk-xxx.cpolar.cn 这种形式,不会再变。

再往上是固定 TCP 端口和自定义域名。固定 TCP 端口需要专业套餐及以上 ;用自己的域名(比如 report.yourdomain.com)做 CNAME 接入也需要专业套餐及以上。

对我们这个场景,其实基础套餐就够了。一个只读报表页,HTTP 隧道加固定二级子域名,同事收藏一下就能一直用。

图注:cpolar 暴露方式与套餐口径:免费随机地址 24 小时内会变;固定二级子域名需基础套餐及以上;固定 TCP 与自定义域名需专业套餐及以上。

8 隔离环境中的可执行实验提纲

这篇没有在真实生产机上跑过采集,也没有起隧道。下面的提纲是给要在隔离环境里复现的人用的,每一步都能独立验证。

准备:一台 Linux 虚拟机或容器,或者一台不重要的 macOS 测试机。不要在生产环境上做阈值测试。

  1. 建一个 64MB 的 loop 设备文件系统,格式化后挂到 /mnt/ino-test,专门用来打满 inode:
bash 复制代码
dd if=/dev/zero of=/tmp/ino.img bs=1M count=64
mkfs.ext4 /tmp/ino.img
mkdir -p /mnt/ino-test
mount -o loop /tmp/ino.img /mnt/ino-test

需要 root 才能挂载。这一步在隔离环境里做,别在跑业务的机器上做。

  1. 用大量小文件把 inode 吃满。for 循环里建 20 万个空文件,大致能吃掉这个小文件系统的 inode:
bash 复制代码
cd /mnt/ino-test
python3 -c "
for i in range(200000):
    open(f'f{i}', 'w').close()
"
  1. 观察两组数字:
bash 复制代码
df -P /mnt/ino-test
df -i /mnt/ino-test

预期是容量使用率很低(几十 MB 文件系统里的空文件几乎不占块),inode 使用率接近 100%。这就是本文开头那个故障的复现。

  1. 启动 disk_report.py,确认页面里 /mnt/ino-test 这一行的 inode 列标红,容量列是绿色。

  2. 跑第 6 节那组 curl 自检,确认 405、404、Allow 头都符合预期。

  3. 可选:起 cpolar,用手机 4G 网络打开公网地址,确认异地可访问、页面渲染正常。

  4. 清理:

bash 复制代码
umount /mnt/ino-test
rm -f /tmp/ino.img

第 2 步里创建的 20 万个文件在 umount 之后随镜像一起删掉,不会留在宿主机上。

9 总结

到这一步,手上有一个按挂载点汇总的只读报表页:容量和 inode 两套数字并排显示,85% 和 95% 两档高亮,三个路由白名单,四种非 GET 方法统一 405。用 cpolar 一条命令映射出去,异地同事用浏览器就能看到"是哪个分区满了、是容量满还是 inode 满"。

  • 数据源只有 df -P 和 df -i,不碰目录树,不跑 du
  • 路由只开 GET /、HEAD /、GET /healthz,其余 404 或 405,不做清理、不做 exec、不做扫描
  • 免费隧道地址 24 小时会变,要长期用就上基础套餐的固定二级子域名

如果后面要扩展,最自然的下一步是加一行"这个挂载点属于哪个备份任务"的静态映射,让数字带上业务含义。但别再往上加写操作入口------这个工具的边界,就是只读。


附:本轮说明

  • 本文未在真实环境采集磁盘数据,未启动服务,未起 cpolar 隧道。
  • 文中容量、使用率、inode 数字均为示例/模拟值。
  • 代码为骨架示例,已标注,落地前需按自己机器上的 df 输出复核一遍解析逻辑。
  • 姊妹篇 8107 做目录与大文件浏览,本篇做分区与 inode 阈值视角。
相关推荐
Lsetea1 小时前
curl访问HTTPS返回304却没有正文:条件请求、缓存与证书验收排查
linux·http·https·ssl证书·curl
NEAI_N1 小时前
嵌入式Linux调试:microcom 串口工具
linux
叼烟扛炮2 小时前
Linux 学习笔记(九):自动化构建神器 Make 与 Makefile
linux·运维·服务器
lisanmengmeng2 小时前
监控Kubernetes集群的GPU资源(三)
linux·容器·kubernetes
蜡笔羊驼2 小时前
ModuleNotFoundError: No module named ‘torchvision.transforms.functional_tensor‘
linux·人工智能·python
箓维2 小时前
线程池&&线程安全与可重入
linux·网络·安全
程序员-Benothing2 小时前
Linux端口与连接排查:ss、lsof、nc、telnet、curl
linux·运维·服务器
邪修king2 小时前
Re:Linux系统篇(三十七):进程间通信Chapter4:System V 共享内存深度解析:为什么它是最快的 IPC?
android·linux·运维
张张123y3 小时前
2026 Agent 框架怎么选?拆 3 层底层逻辑(运行循环 / 记忆 / 工具协议)+ 一张决策表
linux·ubuntu·microsoft·langgraph·mcp·crewai·agent 框架