在容器里跑 Python 项目最让人头大的几个报错里ModuleNotFoundError: No module named src绝对排得上号。明明本地跑得好好的一打包进 Docker 就翻车。而且这个报错还有个迷惑性极强的地方它不像端口冲突或内存不足那样与环境资源相关纯粹是 Python 的模块解析机制和容器文件系统之间的一次错位。换句话说不是代码本身坏了而是 Python 在容器环境里找不到本该找到的路。这篇文章不打算只给你一个能跑的 Dockerfile我会把这个错误的底层逻辑、几种不同的解法、以及为什么有些解法治标不治本讲清楚最后配上一套可以直接抄作业的配置。如果你刚好卡在这个报错上或者想搞明白容器里 Python 路径到底是怎么回事这篇文章应该能帮你省下不少排查时间。1. 先别急着改代码搞懂 Python 为什么找不到 src1.1 报错本质sys.path 里没有项目根目录Python 在解释import src.xxx的时候会去sys.path这个列表里按顺序找有没有一个叫src的包。sys.path由三部分构成当前脚本所在目录、PYTHONPATH环境变量、以及 Python 安装时的默认路径。本地开发时你大概率是在 PyCharm 或者 VS Code 里点了运行按钮。IDE 会把项目根目录自动加到PYTHONPATH里或者直接设置工作目录为项目根路径所以import src没问题。但到了 Docker 容器里情况变了。我举个例子。假设项目结构是这样的my_project/ ├── Dockerfile ├── requirements.txt └── src/ ├── __init__.py ├── main.py └── utils/ ├── __init__.py └── helper.pyDockerfile 里通常会这样写WORKDIR /app COPY . /app CMD [python, src/main.py]看起来没问题目录结构也复制进去了。但当你执行python src/main.py时Python 会把src/目录作为脚本所在目录加入sys.path也就是说sys.path里包含的是/app/src而不是/app。这时候main.py里写from src.utils import helperPython 会尝试在/app/src下找src子目录自然找不到直接抛出ModuleNotFoundError。这个问题的本质是Python 把/app/src当成了工作根而不是/app。你从/app启动容器是一个逻辑但 Python 解释器按脚本位置推导路径又是另一套逻辑两者没有对齐。1.2 本地与容器环境的行为差异本地开发时看不见这个问题的原因不只是 IDE。如果你在终端里跑python src/main.py同样会报模块找不到。只是大多数人习惯了 IDE 按钮忘了底层其实是 IDE 在帮你干活。Docker 还有另一个隐藏行为需要注意.dockerignore文件。很多人根本没建这个文件导致构建镜像时把本地的.venv、node_modules、.git甚至 IDE 配置目录全打进了镜像。这会让镜像体积巨大而且可能把本地 Python 缓存里的路径信息带进去制造出诡异的、难以复现的报错。另外容器内的用户权限、工作目录设置也会影响。如果 Dockerfile 里没有设WORKDIR默认是/那你COPY完代码后根目录下虽然有了项目文件但python命令执行时的工作路径或者默认搜索路径都不受控。这些问题叠加在一起会让同一套代码在不同人手里出现完全不同的表现。2. 最直接有效的几种解法按场景选2.1 设置 PYTHONPATH 环境变量最简单的解法是在 Dockerfile 里显式声明PYTHONPATHFROM python:3.10-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . ENV PYTHONPATH/app CMD [python, src/main.py]关键就是这行ENV PYTHONPATH/app。它告诉 Python 解释器除了脚本所在目录还要把/app也作为包搜索路径。这样src就能被找到了。实测下来这是最省心的方案。不用改代码、不用改启动方式只需在 Dockerfile 里加一行。而且这个方案对项目里任何层级的文件都有效不光是入口脚本连单元测试、子模块里的互相导入都能覆盖到。这背后的原理是 Python 解释器启动时会读取PYTHONPATH环境变量并把它追加到sys.path里。只是大多数入门教程不会特意强调这一点导致很多人对 PYTHONPATH 只有模糊印象真遇到问题根本想不起来用它。2.2 用 python -m 方式启动如果你不太想动 Dockerfile 环境变量还有一个更Python 官方推荐的方式用-m参数以模块方式运行而不是直接指定文件路径。WORKDIR /app COPY . /app CMD [python, -m, src.main]注意这里的区别python src/main.py和python -m src.main在底层逻辑上完全不同。前者是把src/main.py当作一个独立脚本执行Python 只把src目录加入搜索路径。后者是把src.main当作一个模块来加载Python 会把当前工作目录也就是/app自动加入sys.path。所以即便你不在 Dockerfile 里设置PYTHONPATH-m方式也能让你的from src.utils import helper正常工作。这个方式还有一个附带好处相对导入的坑会少很多。脚本方式运行时包内的相对导入容易出错模块方式则遵循正常的包解析规则行为与 Python 模块标准一致。不过要注意一点如果src目录下没有__init__.py-m src.main会报错说命名空间包不可执行。这个得提前确认项目里每个目录都要有对应的__init__.py文件才稳妥。2.3 把项目安装成可编辑包实现路径自治如果项目要长期维护或者你要在多个环境本地、测试机、CI、容器间反复跑前两种方案都偏环境配置依赖而这一种是从项目结构层面根除问题把项目用 setuptools 或者 pyproject.toml 声明成一个包然后用pip install -e .安装进 Python 环境。我把这个方案单独列出来说因为它解决的不只是ModuleNotFoundError而是把整个项目的路径逻辑从靠环境猜变成了靠 Python 包管理机制保证。具体做法是FROM python:3.10-slim WORKDIR /app COPY pyproject.toml . COPY src ./src COPY README.md . RUN pip install --no-cache-dir -e . CMD [python, -m, src.main]pyproject.toml里至少要有这些内容[build-system] requires [setuptools61.0] build-backend setuptools.build_meta [project] name my-awesome-project version 0.1.0 [tool.setuptools.packages.find] include [src*]执行RUN pip install -e .之后Python 环境里会生成一个可编辑安装包直接把项目根路径注册进环境中。无论在哪个目录执行 Python 命令只要环境没换import src就一定能成功。这个方案的优点是彻底且不改任何 Python 代码但代价是需要额外维护一个pyproject.toml对老旧项目或者在包名上有特殊设定的项目需要小心处理包发现规则。2.4 在代码入口手动修改 sys.path如果你不想加环境变量、不想改 pyproject、也不想改 Dockerfile但手头项目又急着上容器那么这个方案是你的兜底import os import sys # 把项目根目录手动挂载到模块搜索路径里 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from src.utils import helper我平时把这种写法称为硬核兜底因为它确实是代码层面的 hack而不是规范做法。但对那种目录结构非常混乱、或者暂时没办法动 Dockerfile 的临时场景它能在 10 秒内让项目跑起来。它的逻辑很好理解获取当前文件绝对路径向上退两级src/main.py向上退到项目根把这个根目录insert到sys.path的最前面。这样 Python 在 import 时第一个就会去这个路径找src。不过要提醒一句这个方案最好只作为过渡手段。原因有两点第一它让代码对外部环境产生路径假设如果换了目录结构或者改了文件层级这段代码就得跟着改第二可维护性差新人接手时几乎不可能靠直觉理解为什么项目根目录要被手动塞进 sys.path。3. 一次完整的 Docker 容器化实操从 Dockerfile 到镜像调试3.1 写出一个规范的 Dockerfile并理解每一行的意义这里给你一套可以用到生产环境的配置方案。假设项目结构是这样的order_api/ ├── .dockerignore ├── Dockerfile ├── requirements.txt ├── pyproject.toml ├── src/ │ ├── __init__.py │ ├── main.py │ ├── app.py │ └── rules/ │ ├── __init__.py │ ├── pricing.py │ └── stock.py └── tests/ └── test_pricing.py推荐的 DockerfileFROM python:3.10-slim ENV PYTHONUNBUFFERED1 \ PYTHONDONTWRITEBYTECODE1 \ PIP_NO_CACHE_DIR1 \ PYTHONPATH/app WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY pyproject.toml . COPY src ./src COPY tests ./tests RUN pip install --no-cache-dir -e . EXPOSE 8000 CMD [python, -m, src.main]我把PYTHONPATH/app和RUN pip install -e .同时用了。这不是重复而是双保险。PYTHONPATH保证即使某些子模块内部用了非常规的路径查找逻辑也能兜住pip install -e .则从包管理层面注册路径两者互补实测能覆盖 99% 的模块导入场景。ENV PYTHONUNBUFFERED1的作用是让容器里的 Python 日志实时输出。不加这个的话日志会被 Python 的缓冲区攒着容器日志看着像卡死其实程序早跑完了。这行很多人嫌啰嗦不写等真正排查问题时会追悔莫及。3.2 可编辑安装方式详解解决包内相互引用的问题刚才 Dockerfile 里有一句RUN pip install --no-cache-dir -e .这一句其实是整容器化项目最值得细心维护的地方。先看pyproject.toml的配置。项目入口在src/main.py内部模块像pricing.py里可能要用到stock.py于是代码里会出现这种导入from src.rules.pricing import calculate如果项目没有安装成包容器里执行时Python 解释器只把脚本所在目录/app/src当成搜索路径from src.rules这个导入就会立即失败。安装成可编辑包之后Python 才会在全局 site-packages 目录里创建一个指向 /app 的链接文件把所有包含src的导入都映射到 /app/src 下。这里有个小细节我要强调如果你的包是src布局源码都放 src 目录下面在pyproject.toml里必须写明包发现的路径。[tool.setuptools.packages.find] where [.] include [src*]where指定从哪个目录开始寻找子包include限定包名模式。这个配置没写对pip install -e .会执行成功但实际一个包都没安装进去问题还会原封不动地回来。我当初踩过这个坑排查了很久才意识到pip install -e .其实等于没干活。3.3 构建镜像与容器内调试的完整流程写完 Dockerfile执行构建docker build -t order-api:0.1.0 .构建完成后先别急着直接跑起来用一个小技巧来验证容器内的模块路径是否正常docker run --rm -it order-api:0.1.0 bash进入容器后依次执行cd /app python -c import sys; print(sys.path) python -c import src; print(src.__file__)第一条命令确认sys.path里有没有/app第二条确认src包是否能被正常导入。如果输出结果里有/app且src路径能打印出来说明模块导入环境没问题。接着再验证入口能否正常启动python -m src.main这一步通过的话整个容器化运行的路径问题就都解决了最后退出容器直接跑镜像docker run -d --name order-api-cont -p 8000:8000 order-api:0.1.03.4 这一套方案治好了什么问题我按给的示例项目来复盘一下这套方案解决了什么入口脚本src/main.py内部可能有from src.app import create_app这在容器里不会报错因为pip install -e .已经在环境里注册好了 src子模块交叉导入from src.rules.pricing import calculate也能正常工作因为包内导入遵循的是包路径规则而非脚本路径规则即使有人误把WORKDIR改了比如改成/app/srcPYTHONPATH/app依然能够兜住一部分问题只是-e安装包会受些影响这套组合的优点在于它不是单一防线而是多层路径保障任何一层失效其他层还能顶上。4. 常见问题速查与避坑心得这一步讲几个我实际踩过的坑整理成表格方便你直接对照。问题现象根本原因解决方案No module named src未设置 PYTHONPATH且用脚本方式启动加ENV PYTHONPATH/app或改用python -m src.mainNo module named src.rules.xxx项目未安装为包子模块无法按包路径导入执行pip install -e .确保pyproject.toml配好包发现pip install -e .成功但实际没装包pyproject.toml里[tool.setuptools.packages.find]配置不正确检查where与include用pip show 包名验证容器里import src成功但运行时某些资源文件找不到代码用的是相对路径读取配置文件容器工作目录不对改用Path(__file__).parent定位资源或在 Dockerfile 中固定WORKDIR镜像巨大构建缓慢没有.dockerignore本地 venv 或 git 目录被打包新建.dockerignore排除.venv、.git、__pycache__改完代码后容器里还是旧逻辑构建用了缓存层构建时加--no-cache或合理安排 COPY 顺序容器能启动但日志里没有任何输出没设PYTHONUNBUFFERED1在 ENV 里加PYTHONUNBUFFERED14.1 .dockerignore 是被大多数人忽略的关键文件我见过太多项目Dockerfile 写得规规矩矩但镜像一构建就是 1GB 起步。跑起来后还会有莫名其妙的模块冲突问题因为本地的.venv整个被复制进去了里面一堆旧版本的包直接把镜像里刚装的依赖覆盖掉。.dockerignore放在项目根目录和 Dockerfile 同级内容大致如下.git .gitignore .venv venv __pycache__ *.pyc *.pyo .pytest_cache .mypy_cache .editorconfig .idea .vscode Dockerfile .dockerignore这里有个细节.dockerignore里其实没必要把Dockerfile本身排除掉因为 Docker 构建时虽然会把 Dockerfile 读进构建上下文但不会把它复制到镜像层里。不过写上也问题不大。排除.venv尤其重要。如果你本地创建了虚拟环境里面装的包版本和requirements.txt里的不一致一旦被 COPY 进镜像sys.path会优先命中/app/.venv/lib/python3.x/site-packages然后出现一个很妖的 bug镜像里用pip list看到的是新版本但程序运行时实际 import 的是旧版本。4.2 为什么主推模块方式而不是脚本方式这一点值得展开讲。python -m src.main和python src/main.py的差别只差一个-m但适用场景完全不同。-m意味着 Python 以模块的方式加载入口文件此时解释器会把当前工作目录加入sys.path设置逻辑更贴近项目内运行。用脚本方式时sys.path[0]是src目录。如果你还在 Dockerfile 里设置了WORKDIR /appsys.path就长这样/app/src /app /usr/local/lib/python3.10/site-packages这个路径列表里/app/src在最前面但src目录里根本没有src子目录于是from src.utils import helper直接失败。用模块方式时sys.path[0]是/app/app /usr/local/lib/python3.10/site-packages/app下面正好有src目录问题迎刃而解。我之前给一个项目排查这个问题的时候发现对方为了解决报错在src目录下面又硬生生创建了一个虚拟的src文件夹里面放了一堆__init__.py和转发逻辑。这种工作区式的绕过方式能跑但维护成本极高每加一个新模块就要去同步那份假的目录树。用了-m方式后那个假目录直接删掉项目立刻恢复清爽。4.3 路径死结PYTHONPATH 设了还是报错我遇到过一种诡异场景Dockerfile 里明明写了ENV PYTHONPATH/app但容器里运行仍然报ModuleNotFoundError。后来排查发现是docker run命令里用-e PYTHONPATH把环境变量覆盖成空字符串了。还有一种情况是 CI 里为了安全性会在启动指令里强制指定某个PYTHONPATH结果把镜像里默认设置顶掉了。排查的时候不要只看 Dockerfile还要检查启动命令中有没有-e PYTHONPATH的覆盖容器编排文件里有没有environment配置有没有外部脚本在启动前unset PYTHONPATH这种问题用一句命令行就能确认docker run --rm -it your-image bash -c echo \$PYTHONPATH纸面配置和运行时配置不一致的情况并不少见尤其是涉及多个部署环境时。4.4 命名空间包引发的伪报错还有一种情况容易误判为 import 错误项目根目录和子目录都叫src并且没有__init__.py。Python 3.3 之后支持命名空间包它允许一个包横跨多个目录。这在本地运行没问题因为解释器把多个src目录都找到了。但进了容器可能只 COPY 了其中一个目录结果 Python 仍然尝试用命名空间包的机制解析找不全时报错不是提示缺哪个路径而是直接说No module named src。解决办法是确保src目录和所有子包目录都包含__init__.py哪怕里面一句代码没有也放一个。这样 Python 会把它当作常规包处理行为更可预测。5. 几个真正能提升效率的习惯收拾完ModuleNotFoundError这个具体问题再分享几个我在容器化过程中逐渐建立的习惯。构建后立刻进容器验证一下再启动服务。很多人省掉这个步骤镜像构建完直接docker run -d然后查日志发现模块报错又回来改 Dockerfile 重新构建。来回一趟就是好几分钟。直接在容器里做一次python -c import src验证10 秒钟就能确认路径配置是否正常省下的却是反复构建的循环。别在 CMD 里写复杂 shell 脚本除非必要。CMD [sh, -c, cd /app python src/main.py]这种写法在调试时会很痛苦因为不好透传信号而且路径逻辑都藏在字符串里看不清。更建议把环境配置写在 Dockerfile 的 ENV 和 WORKDIR 里CMD 保持单纯干净。用exec形式直接启动进程后面排查看日志也舒服。把依赖安装和代码复制分成两步缓存。Dockerfile 里先COPY requirements.txt再RUN pip install最后才COPY . .。这样只要requirements.txt没变每次构建镜像时就能直接命中 pip install 的缓存层大幅缩短构建时间。这个技巧在很多教程里都有但实际项目里很少见到严格遵守的更多的是图省事一口气把整个项目 COPY 进去然后每次 build 都全量重装依赖。镜像标签别老是 latest。给你的容器镜像打上明确版本号比如v0.1.0、build-20240517这样出问题时可以精准定位是对应哪个镜像层也方便回滚到上一个可用版本。latest标签在本地练手没什么但一旦部署环境多了latest会让你完全不知道跑的是哪个版本的代码。调试容器路径问题用好这两个组合拳docker run --rm -it your-image bash进去之后先pwd确认当前目录再python -c import sys; print(sys.path)查看路径列表。这种组合能绕开大多数跟路径相关的妖魔鬼怪。有时候你以为代码写错了其实是工作目录和你预期不一致。我在实际项目里踩过的比较多的路径坑大部分都能用上面这五条习惯绕开。如果已经在泥潭里了按顺序排查通常不用改动一行代码就能找齐问题根源。最后再补一个小知识点如果项目规模不大、结构也很简单其实只要写好WORKDIR和PYTHONPATH再加上-m方式启动三个点做到位ModuleNotFoundError基本不会出现在容器环境里。真正让人头疼的往往是那些隐藏的 COPY 顺序错误、.dockerignore缺失或者环境变量覆盖而不是 import 本身写错了。项目上容器前花几分钟把这些点都过一遍比等报错再排查要高效得多。
阅读完成 · 觉得有帮助?