欢迎光临
我们一直在努力

Python后端部署排障04:磁盘还有空间,为何文件就是打不开

Python后端部署排障04:磁盘还有空间,为何文件就是打不开

“打不开文件”描述的是结果,并没有指出资源瓶颈。路径不存在、权限不足、进程文件描述符耗尽、磁盘块耗尽、inode 耗尽,都可能出现在业务访问文件的附近,但修复方法完全不同。把这些错误一律归为磁盘满,再随手删除日志,既可能无效,也可能丢掉最有价值的现场。

本课从一个有趣的差别出发:关闭文件会释放文件描述符,但不一定删除文件;删除文件名会减少目录项,但不一定立即回收文件数据。内核管理的是多种不同对象。只要分清“文件名、打开的引用、存储空间”,你就能解释很多看起来互相矛盾的输出。

文件名、打开引用与实际存储各有生命周期

E03 完整答案:发了信号,还要收回退出状态

python3 lessons/04/answer_previous.py 必须在 Linux 上执行。它启动的 worker 来自本专题 labs/,不依赖前两套课程。父程序设置启动与退出的期限,失败时仅回收自己创建的子进程。临时目录离开作用域后清除,但终端保留一份 JSON 结果供验收记录。

lessons/04/answer_previous.py

"""E03完整答案:仅终止本程序创建的worker;等待退出后检查finally。"""
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import time

def main():
if sys.platform != "linux":
raise SystemExit("E03 requires Linux SIGTERM semantics")
worker = Path(__file__).resolve().parents[2] / "labs" / "signal_worker.py"
if not worker.is_file():
raise SystemExit(f"Missing course worker: {worker}")
with tempfile.TemporaryDirectory(prefix="quoteops-signal-") as name:
directory = Path(name)
process = subprocess.Popen(
[sys.executable, str(worker), "–directory", str(directory)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
try:
deadline = time.monotonic() + 5
while not (directory / "heartbeat.txt").exists():
if process.poll() is not None or time.monotonic() >= deadline:
raise RuntimeError("Worker failed to become ready")
time.sleep(0.02)
pid = int((directory / "worker.pid").read_text())
assert pid == process.pid
os.kill(process.pid, signal.SIGTERM)
output, _ = process.communicate(timeout=5)
assert process.returncode == 0, output
shutdown = (directory / "shutdown.txt").read_text()
assert "signal=15 finally=yes" in shutdown, shutdown
assert not (directory / "worker.pid").exists()
print(json.dumps({"pid": pid, "exit_code": process.returncode,
"shutdown": shutdown.strip(), "output": output}))
finally:
if process.poll() is None:
process.kill()
process.communicate(timeout=5)

if __name__ == "__main__":
main()

这里没有通过进程名查找所有 Python 再批量终止,也没有把 PID 文件直接当成授权对象。程序从 Popen 持有本次子进程,核对 PID 文件后才发送信号。communicate() 同时读取输出并等待回收,避免子进程已经结束而父进程未读取退出状态。清理中的强制终止只用于正常 TERM 流程失败后回收本次实验。

文件描述符是每个进程持有的小整数

进程打开文件、socket、管道时,会得到一个文件描述符,简称 FD。常见的零、一、二分别是标准输入、输出、错误,但不能假定每次新打开的文件都从三开始,运行库和服务管理器可能已经持有其他 FD。整数背后指向内核的打开对象;它不是文件路径本身,也不意味着磁盘上多了一份数据。

Linux 可以用 ls -l /proc/<PID>/fd 观察某进程持有的引用,cat /proc/<PID>/limits 观察进程资源上限。用 shell 的 ulimit -n 查询的是该 shell 的相关限制,后来启动的子进程会继承限制,但它不能替你证明另一个早就运行的服务的限制值。排查服务时应对准服务 PID,而不是只看自己终端。

如果请求处理路径每次打开文件却没关闭,单次请求可能正常,运行一段时间后才出现 EMFILE。常见中文是“打开文件过多”。它表示当前进程的 FD 达到上限,不等于系统磁盘容量不足。系统级表项耗尽可能是 ENFILE,层次又不同。看到 errno 后再决定查看进程 FD 数量还是整机资源,能把范围迅速缩小。

让一个短命子进程体验 EMFILE

不能在共享服务器上无限打开文件来“模拟事故”。下面父程序专门创建一个子进程,子进程把自己的软上限降到不超过六十四,再反复打开 /dev/null。这个设备不会消耗磁盘数据块,每次打开仍会占用 FD,正好把两种资源分开。循环也有明确次数,不会试探宿主机极限。

labs/fd_limit.py

"""有限FD耗尽:父进程不改限制,只有新建子进程降低soft limit。"""
import errno
import json
import os
from pathlib import Path
import subprocess
import sys

def child():
import resource
original = resource.getrlimit(resource.RLIMIT_NOFILE)
limit = min([64] + [item for item in original if item != resource.RLIM_INFINITY])
if limit < 16:
raise RuntimeError("Need at least 16 available descriptor limit")
handles = []
observed = None
try:
resource.setrlimit(resource.RLIMIT_NOFILE, (limit, original[1]))
for _ in range(limit + 1):
try:
handles.append(os.open(os.devnull, os.O_RDONLY))
except OSError as exc:
if exc.errno != errno.EMFILE:
raise
observed = exc.errno
break
assert observed == errno.EMFILE, "Expected process-local EMFILE"
opened = len(handles)
finally:
for descriptor in handles:
os.close(descriptor)
resource.setrlimit(resource.RLIMIT_NOFILE, original)
descriptor = os.open(os.devnull, os.O_RDONLY)
os.close(descriptor)
print(json.dumps({"pid": os.getpid(), "soft_limit": limit,
"opened": opened, "errno": observed, "reopened": True}))

if __name__ == "__main__":
if sys.platform != "linux":
raise SystemExit("RLIMIT_NOFILE experiment requires Linux")
if sys.argv[1:] == ["–child"]:
child()
elif not sys.argv[1:]:
subprocess.run([sys.executable, str(Path(__file__).resolve()), "–child"],
check=True, timeout=10)
else:
raise SystemExit("Usage: python3 fd_limit.py")

执行 python3 labs/fd_limit.py 后,结果中的 opened 不一定是六十一,因为启动时已有多少描述符与运行环境相关。可靠断言是出现 errno=24,即 Linux 的 EMFILE,关闭本次打开的所有 FD 后能再次打开设备。软限制随后恢复,子进程最终退出;父进程始终没有修改自己的上限,更没有修改宿主配置。

恢复限制不能代替关闭泄漏。把上限从一千调到十万,只可能推迟一个持续增长的问题,并增加它影响系统的机会。真正修复通常在生命周期:使用 with open(…)、让连接池在正确时机关闭、异常分支也执行释放。只有需求规模与资源预算有证据时,才讨论合理提高限额,而不是让每个报错都变成配置加零。

Python 的垃圾回收可能最终关闭某些对象,但不要拿“通常很快回收”当资源管理合同。循环引用、不同解释器、异常对象持有栈帧,都可能改变释放时机。上下文管理器和 finally 则把责任写进控制流,读代码时就能判断成功与失败两条路径是否释放。服务器运行时间越长,这种确定性越重要。

df 和 du 为什么可能意见不一致

df -h /具体路径 查询该路径所在文件系统的空间统计;du -sh /具体目录 遍历目录树并累计能够访问的文件占用。二者问题不同,因此结果不必相等。目录可能不是整个文件系统,权限可能阻止遍历,还可能存在稀疏文件、硬链接、挂载点或尚未释放的已删除文件。

df -h .
df -i .
du -sh labs

df -i 检查 inode,适合诊断小文件极多的场景。磁盘尚有若干 GB,但不能再创建新文件,可能因为 inode 耗尽;单纯删除一个大文件只释放一个 inode,未必是最有效恢复方法。实际文件系统的分配策略不同,不能把某台机器的数字关系当成跨系统定律。

最经典的情况是日志文件被进程一直打开,你用 rm 删除了名字,du 看不到它,df 却没有明显下降。因为目录项移除后,打开引用仍然存在,进程还能继续写那个对象。Linux 的 /proc/<PID>/fd 常把它标为 (deleted)。等最后一个引用关闭,内核才有机会回收相应存储。此时要通过服务支持的日志重开或有计划重启释放引用,不能盲目杀所有写日志的进程。

这也解释了为什么日志轮转需要应用配合。只改文件名并不能保证应用切到新文件;只截断文件也可能破坏正在读取它的工具。后面容器采用 stdout 和有大小限制的轮转设置,目的是把日志生命周期交给明确的管理者。本课先学会判断空间到底被哪种对象持有。

ENOSPC 只在小 tmpfs 中复现

真实磁盘满可能影响 SSH、日志、数据库与其他用户,因此禁止通过填满宿主盘练习。下面脚本只接受精确挂载点 /pit/tmpfs,检查 /proc/self/mountinfo 确认为 tmpfs,并限制总容量不超过十六 MiB。课程实际验收使用八 MiB 的独立 tmpfs,同时给容器设置内存与 PID 限额。

labs/tmpfs_full.py

"""仅写受控/pit/tmpfs小挂载;不允许普通宿主磁盘路径。"""
import errno
import json
import os
from pathlib import Path
import sys
import tempfile

def main():
if sys.platform != "linux":
raise SystemExit("tmpfs experiment requires Linux")
root = Path("/pit/tmpfs")
if root.is_symlink() or root.resolve() != root or not root.is_dir():
raise SystemExit("Need exact /pit/tmpfs mount")
entries = Path("/proc/self/mountinfo").read_text().splitlines()
if not any(line.split()[4] == str(root) and
line.split(" – ", 1)[1].split()[0] == "tmpfs" for line in entries):
raise SystemExit("/pit/tmpfs must be an isolated tmpfs mount")
info = os.statvfs(root)
capacity = info.f_blocks * info.f_frsize
if not 0 < capacity <= 16 * 1024 * 1024:
raise SystemExit("Refuse tmpfs larger than 16 MiB; use 8 MiB")
failure = None
with tempfile.TemporaryDirectory(prefix="quoteops-enospc-", dir=root) as name:
target = Path(name) / "fill.bin"
try:
with target.open("wb", buffering=0) as handle:
for _ in range(20): # 最多尝试20MiB,不存在无限写盘循环。
handle.write(b"x" * (1024 * 1024))
except OSError as exc:
if exc.errno != errno.ENOSPC:
raise
failure = exc.errno
finally:
target.unlink(missing_ok=True)
assert failure == errno.ENOSPC, "Did not observe expected ENOSPC"
target.write_bytes(b"recovered")
assert target.read_bytes() == b"recovered"
print(json.dumps({"capacity_bytes": capacity, "errno": failure,
"recovered": True, "cleaned": True}))

if __name__ == "__main__":
main()

脚本每次最多写一 MiB,最多尝试二十次。文件空间耗尽后必须捕获到 ENOSPC,随后删除自己创建的填充文件,再写入一小段内容并读回。无论填充时发生什么,finally 都尝试释放那个文件;整个目录由临时目录管理器回收。容量检查、有限循环、独立挂载和清理分别防住不同失误,不能只依赖其中一层。

这一实验要到第七课了解镜像后再由课程工具或验收者执行。Linux 宿主机直接运行脚本会因没有受控挂载而拒绝,这就是正确行为,不应为了让它通过去创建一个普通 /pit/tmpfs 目录。tmpfs 使用内存相关资源,并不是“完全没有成本”;部署时还应考虑内存与交换空间的边界。我们只借它获得小且可控的存储容量。

本课不会声称 Windows 跑一遍语法检查就证明上述 Linux 实验通过。resource、/proc、信号与挂载信息都涉及系统行为,必须在相应环境取证。交付报告会区分本地检查与远端运行结果;你自己复现时也应保存实际命令、退出码和 JSON,而不是把文章中的预期现象写成自己的测量值。

空间恢复之后,业务还需要一次小写入

释放空间只是让下一次操作重新拥有机会,不能证明之前失败的事务已经成功。数据库写到一半可能回滚,日志记录可能缺失,上传文件可能只留下不完整内容。恢复后应该先做一笔受控写入并读回,再检查业务状态是否需要人工补偿。不能看到磁盘百分比下降,就对外宣布所有失败请求都自动恢复。

文件关闭也存在时机上的细节。带缓冲的写入可能先把数据留在用户态内存,真正的异常直到刷新或关闭时才出现;这正是异常处理要覆盖整个文件上下文的原因。本课填充实验关闭缓冲,让容量耗尽更直接地暴露在写调用中,但这不意味着所有生产写入都应关闭缓冲。性能与持久性需求不同,选择也会不同。

另一个常被忽略的限制是配额。某个用户或项目的配额达到上限时,整个文件系统仍可能有空间,错误也可能与普通磁盘满不同。遇到磁盘统计和错误不一致时,应查清目标文件系统、运行身份与配额政策,保留具体错误号,再请有权限的管理员核对。不要把每个限制都当成可以随意提高的障碍。

可观测性最好在耗尽之前就建立。持续增长的文件数、描述符数量或日志体积,通常比最后一次异常更早暴露趋势。课程后半段会把资源预算写入部署配置;在这里先形成习惯:每一种资源都有容量、当前使用、增长速度和释放条件。找到增长来源,才有机会从反复救火走向修复根因。

E04:证明释放以后能恢复

E04:创建一个独立子进程,只降低它自己的 RLIMIT_NOFILE,在有限循环中打开 /dev/null,捕获 EMFILE。 在 finally 关闭全部本次 FD 并恢复原限制,然后再次成功打开并关闭一个 FD。输出软上限、打开数量、错误号与恢复结果。不得改父 shell、宿主系统上限,不得向磁盘写入填充数据。下一课给出完整答案,并把“资源存在”与“服务可访问”继续拆成 DNS 和 TCP 两层。


课程目录:《Linux、网络与后端部署排障:从“接口能跑”走到“服务能维护”》 · 上一篇:第03篇 · 下一篇:第05篇

赞(0)
未经允许不得转载:171主机测评 » Python后端部署排障04:磁盘还有空间,为何文件就是打不开
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址