问题背景
运维组有一台跳板机,/opt/ops/ 下放着一批 Python 脚本:备份、巡检、日报。日报脚本每天定时把 Excel 报表用 smtplib 发到管理层邮箱,跑了两年多,一直稳定。
上周同事写了一个发告警邮件的小工具,图省事直接命名为 email.py,放在同一个目录里,供其他脚本 import email 调用。当天下午的日报任务失败,第二天早上的巡检也没发出来。登录机器手动执行,报错一模一样;把脚本拷到 /tmp 下运行,又正常了。
故障现象
- 日报脚本执行到发信步骤抛异常,退出码非零,cron 没有发出任何邮件。
- 报错核心是
ModuleNotFoundError: No module named 'email.message'; 'email' is not a package。 - 同一个脚本在
/tmp目录下运行正常,在/opt/ops/下必现。 - Python 版本、依赖包、邮箱配置都没有变过;最近唯一的变更就是新增了
email.py。 - 其他目录下的 Python 脚本发信正常,只有
/opt/ops/里的脚本受影响。 - 报错栈指向
smtplib的导入语句,看起来像是标准库坏了,很容易往“Python 环境损坏”方向排查。
排查过程
第一步读完整报错。异常栈里 smtplib.py 执行 import email.message,随后抛出“email 不是包”。标准库的 email 明明是一个包,email.message 是它的子模块,正常情况下不可能导入失败;报错说明 Python 找到的 email 不是标准库那个。
第二步看 Python 实际导入了什么。在 /opt/ops/ 目录下执行 python -c "import email; print(email.__file__)",输出是 /opt/ops/email.py,不是 Python 安装目录下的 email/__init__.py。也就是说,本地这个同名文件把标准库的 email 包整个顶掉了。
第三步确认导入顺序。python -c "import sys; print(sys.path[:3])" 显示 sys.path 的第一项是脚本所在目录(或空字符串代表当前目录),标准库路径排在后面。Python 导入模块时按 sys.path 顺序查找,本地目录优先,所以 import email 先命中了 /opt/ops/email.py。这里还有一个容易混淆的点:python script.py 时 sys.path[0] 是脚本所在目录,python -m module 时是当前目录,交互式解释器则是空字符串;两种方式都可能把本地文件顶到标准库前面。另外,PYTHONPATH 里的路径也会插在标准库之前,排查时要一并看。
第四步解释报错为什么指向标准库。smtplib 内部要导入 email.message、email.utils 这些子模块;本地的 email.py 是一个普通模块文件,不是包,不能有子模块,于是 import email.message 失败,报“email is not a package”。错误信息里出现的是 smtplib 和 email,真正的“凶手”是那个同名的本地文件,所以第一眼很难联想到它。
第五步验证因果。把 email.py 重命名为 mail_helper.py,同时删掉目录下的 __pycache__(避免残留的 email.cpython-*.pyc 干扰),再执行 python -c "import email; print(email.__file__)",输出回到标准库路径;日报脚本立即恢复正常。把文件改回 email.py,故障复现,变量唯一。
第六步排查同类隐患。扫描 /opt/ops/ 下所有文件名,和 Python 标准库模块名比对,确认没有 logging.py、json.py、types.py、queue.py 这类同名文件;这几个名字一旦撞上,会分别把日志、JSON、类型、队列相关模块顶掉,症状各不相同,但根因一样。
解决方案
- 重命名同名文件:把
email.py改成mail_helper.py之类的业务名,确保不与标准库顶层模块重名;改完删除__pycache__,再验证import email指向标准库。 - 改成包结构:把运维脚本收进一个包,例如
ops/目录加__init__.py,工具模块用ops.mail_helper导入;包内文件名不再参与顶层模块名解析,冲突面小很多。 - 加自动检查:在 CI 或 pre-commit 里扫描仓库中的
.py文件名,与sys.stdlib_module_names求交集,命中就失败;也可以用 Ruff 的A005(stdlib-module-shadowing)规则兜底。 - 统一命名规范:工具模块加业务前缀(
ops_mail.py、ops_log.py),禁止用email、logging、json、types、queue、select、socket这类通用名做文件名。 - 排障手册补一条:遇到标准库导入异常,先执行
python -c "import 模块名; print(模块名.__file__)"看实际加载路径,再print(sys.path[:3])看搜索顺序,不要一上来就重装 Python。 - 验证:改完后跑一遍日报、巡检、备份三个脚本,确认发信、日志、JSON 解析都正常;再把检查脚本接入发布流程。
根因分析
直接根因是 /opt/ops/email.py 与标准库 email 包同名,且脚本目录在 sys.path 中最先被搜索,导致 import email 加载了本地文件;smtplib 需要 email.message 子模块,本地模块不是包,导入失败。
更深一层,是 Python 的导入机制把“文件名”直接当成了“模块名”。在大多数语言里,一个业务文件叫 email.py 不会影响系统库;在 Python 里,只要它和脚本在同一目录,就会参与顶层模块解析。命名在这里不是风格问题,而是命名空间问题。
还有一层是错误信息的误导性。报错栈指向 smtplib 和标准库 email,没有任何一行提到“本地有个 email.py”;如果不知道用 __file__ 和 sys.path 去验证实际加载路径,很容易把时间花在重装依赖、升级 Python 上。这个故障的排查成本,主要来自“看起来不像自己写的问题”。
预防措施
- 建立文件名黑名单:标准库顶层模块名禁止用作脚本/工具文件名;新文件提交前用脚本比对
sys.stdlib_module_names。 - CI 增加 lint 规则:Ruff
A005或自定义检查,发现 stdlib 同名文件直接失败。 - 运维脚本统一包结构,工具模块加业务前缀;不要把所有脚本平铺在一个目录里靠通用名互相 import。
- 排障手册固化“导入路径三连”:
模块.__file__、sys.path[:3]、python -v导入日志;先确认加载的是谁,再谈修复。 - 发布流程里加一条冒烟测试:用干净环境执行一次核心脚本,确认标准库和第三方依赖没有被本地文件遮蔽。
总结
在 Python 里,文件名就是模块名,脚本目录就是搜索路径的第一站。给工具起名 email.py,等于在自家门口放了一个和邮局同名的信箱,所有寄往“email”的信都会先落到这里。这个故障不需要改代码逻辑、不需要重装环境,只需要把文件改名;难的是从“标准库坏了”的直觉里跳出来,先问一句:Python 实际加载的,到底是哪个文件?