你把一个 Python 项目打成镜像,本地 python -m mypkg.main 跑得好好的,一进容器就报找不到模块。Dockerfile 里明明把代码 COPY 进去了,ls 也能看到文件,可 Python 就是"看不见"这个包。
现象
容器里执行入口,报类似:
/usr/local/bin/python: No module named mypkg
或者:
ModuleNotFoundError: No module named 'mypkg'
你进容器看,文件在的:
docker compose exec app ls -la
# 能看到 main.py、requirements.txt 等一堆文件平铺在根目录
文件明明在,但 python -m mypkg.main 就是找不到 mypkg。而在本地开发机上,同样的命令完全正常。
根因
要理解 python -m 包名.入口 里的那个"包名"到底指什么。
python -m X.Y 的含义是:把 X 当成一个包(package)导入,然后运行里面 Y 模块。而要成为一个"包",在 Python 3 的常规规则下,X 必须是一个目录,并且(传统上)该目录里有个 __init__.py(命名空间包可省,但概念上它仍是个目录)。
这里还要区分两种跑法。python mypkg/main.py(直接给文件路径)只需要"文件在"就行;而 python -m mypkg.main(模块方式)走的是导入系统,它会按 sys.path 去逐个目录找"名叫 mypkg 的包"。两种方式对文件布局的要求完全不同,这也是为什么有些项目用直接跑的方式没事、一换 -m 就崩。
关键在于:一个名字能不能被当成包导入,取决于它在文件系统里是不是一个目录,而不是取决于你的文件在不在。
回到镜像。很多 Dockerfile 图省事,写成:
COPY . .
这条指令把项目根目录下的所有文件平铺到容器的工作目录里。于是 main.py、utils.py、requirements.txt 全都躺在同一个目录里,没有 mypkg 这个子目录。这时:
- 本地能跑,是因为你的项目根目录上面还有一层,或者你本地目录就叫
mypkg,导入路径恰好对; - 容器里平铺之后,
mypkg这层目录名没了,Python 自然找不到名为mypkg的包。
说白了,包导入路径取决于文件被放进了哪个目录,而 COPY . . 把目录结构抹平了。
解决
让代码在容器里真的落进一个叫 mypkg 的目录。改构建指令:
WORKDIR /app
# 把当前上下文的内容,放进 /app/mypkg/ 目录里
COPY . ./mypkg/
# 依赖按这个目录来装
RUN pip install --no-cache-dir -r ./mypkg/requirements.txt
CMD ["python", "-m", "mypkg.main"]
这样 /app/mypkg/ 是一个真实目录,/app 又是 WORKDIR(在 sys.path 上),python -m mypkg.main 就能把 mypkg 当包导入、再运行 main 了。
重建并验证:
docker compose build
docker compose up -d
docker compose exec app python -c "import mypkg; print(mypkg.__file__)"
mypkg.__file__ 打印出的路径应该在 /app/mypkg/... 下,就说明结构对了。
延伸与预防
这条坑的教训可以浓缩成一句:"文件在不在"和"能不能被导入"是两回事。
排查 Python 导入问题时,有一个万能动作:把"Python 眼中看到的路径"打出来。
python -c "import sys; print('\n'.join(sys.path))"
python -c "import mypkg; print(mypkg.__file__)"
前者告诉你 Python 会去哪些目录找包,后者(如果能导入)告诉你它从哪个文件加载的。两边一对,问题立刻现形。
预防上,写 Dockerfile 时养成习惯:心里先画一遍容器里的目录树,把 COPY 源 目标 的目标当成分层结构来写,而不是"一股脑铺到根目录"。同一个道理也适用于 WORKDIR 的设置——它决定了所有相对路径(包括 run 里的命令、CMD 里的入口)以哪里为基准。想清楚"哪个目录是包的边界",镜像里就不会再出这种"文件在、模块找不到"的怪事。