这两年只要聊到Python项目部署几乎绕不开Docker。我也见过不少团队把Dockerfile写“能跑”就算完事结果镜像动不动就一个多G构建时间越来越长部署到服务器上还经常冒出权限、时区、日志丢失这些奇奇怪怪的问题。这篇文章把我自己把一个Python后端服务和一个数据清洗脚本分别容器化的完整过程拆开讲一遍覆盖Dockerfile设计、镜像瘦身、docker compose编排、本地调试和常见坑位处理。适合刚接触容器化、想把自己手上的Python应用打包进Docker的开发者也适合那些Dockerfile已经能跑、但总觉得哪里不对劲的人。1. 容器化之前先认清Python应用在容器里的运行方式变了1.1 容器不是虚拟机进程模型先想明白很多人第一次接触Docker会下意识把它和虚拟机划等号开一个容器感觉就像开了一台小电脑里面有自己的文件系统、有自己的网络栈于是做什么事都按“装系统”的思路来。这个认知会埋下很多坑。容器和虚拟机的本质区别在于虚拟机里跑的是一套完整操作系统容器里跑的只是一个进程外加这个进程所依赖的文件系统视图。容器共享宿主机的内核没有自己的内核也不负责管理硬件资源。说得再直白一点docker run启动一个 Python 镜像时实际发生的事是宿主机上多了一个名为 uvicorn 或者 gunicorn 的进程只不过这个进程被命名空间隔离了文件、网络和 PID 视图。这个区别带来的第一个直接影响是你的应用进程必须以前台方式运行不能像传统部署那样在后台跑。传统服务器上用nohup python xxx.py 启服务容器里不能这么干。如果一个容器的主进程退出了容器就会直接结束哪怕里面还有其他子进程在干活。所以 Dockerfile 里的CMD必须是前台进程Python 里尤其常见的问题是用 gunicorn 启动时忘记关 daemon 模式或者调用了某个库导致进程 fork 到后台结果 docker run 一执行容器就退出日志里什么都看不到。另一个影响是 PID 1 的特殊性。容器里的第一个进程承担着处理 SIGTERM、回收僵尸进程这些职责。Python 应用如果直接当 PID 1对退出信号的处理通常不太规范。实测中比较稳妥的做法是使用 tini 或者让基础镜像里的入口机制接管信号转发否则docker stop发出去的 SIGTERM 可能无法传递给 gunicorn 的工作进程造成优雅退出永远不生效。下面会专门讲这一点。1.2 什么样的Python应用最适合第一批容器化不是所有 Python 项目都值得立刻容器化先给想上容器的项目做个分类能省掉很多折腾。第一类是无状态 Web 服务比如 FastAPI、Flask、Django 写的 API。这类应用对磁盘没有持久化要求实例可以随时启动、销毁、扩容容器化之后收益最大。部署时只需要把代码和依赖打包进镜像运行环境完全一致再不用听“在我机器上能跑啊”这种话了。第二类是定时任务和一次性脚本像是数据清洗、报表生成、爬虫采集。这类任务最大的痛点在于依赖环境不好复现今天在 A 机器上能跑明天在 B 机器上就缺个系统库。把整个执行环境打包进镜像后配合宿主机上的 cron 或者调度平台每次运行都是全新环境跑完即弃非常干净。第三类是带外部依赖的服务应用需要连 MySQL、Redis、消息队列。容器化后整个应用链条可以用 docker compose 一次性拉起本地开发环境和生产环境的拓扑保持一致从根本上减少“配置漂移”。反过来有几类项目容器化性价比不高。比如依赖特定 GPU 驱动版本、需要直接操作宿主机内核模块、或者应用本身就是个桌面 GUI 程序强行容器化只会把复杂度从代码层转移到容器层得不偿失。1.3 从源码到镜像再到容器这条链路上都有什么角色把 Python 项目容器化核心动作其实只有两个字打包。但要理解打包过程中的各个环节才能对付后面各种疑难杂症。先看最基础的构建链路构建上下文build context执行docker build .时那个.就是构建上下文。Docker 会把整个上下文目录打包发送给守护进程然后镜像里能 COPY 到的文件仅限于上下文范围内。如果你的项目目录里塞了.git目录、node_modules、虚拟环境venv这些全都会被发送过去构建速度会肉眼可见地变慢。所以.dockerignore文件的作用和.gitignore一样重要甚至更重要。镜像由图层组成Dockerfile 里的每一条指令比如FROM、COPY、RUN都会生成一个新的 layer。镜像是这些 layer 叠加在一起的结果。构建时如果某一行没变Docker 会直接复用之前构建好的缓存 layer所以指令顺序直接影响构建速度这个稍后展开。容器是镜像的实例镜像打包的是静态文件系统容器则多了运行时的进程状态、网络端口、挂载卷、环境变量。同一个镜像可以同时启动多个互不干扰的容器实例。把这三个角色理清楚你会发现 Dockerfile 的本质就变成了一件事用最少的图层变化把应用代码、依赖、运行配置组合成一个可复现的静态文件系统快照。后面所有优化都围绕这个目标展开。2. 用一份Dockerfile把Python应用装进容器从能跑到跑得好2.1 基础镜像不是越轻越好slim、alpine和debian的取舍打开 Docker HubPython 官方镜像里最常用的三个 tag 是python:3.12、python:3.12-slim和python:3.12-alpine。很多人下意识选择 alpine因为体积最小但实际用起来会踩不少暗坑。它们的差异主要体现在两个地方底层 C 库不同以及自带工具链的完备程度不同。python:3.12基于 Debian 完整版本自带一堆编译工具、系统库体积大但兼容性最好。适合需要编译大量 C 扩展的场景比如psycopg2、numpy、pandas。python:3.12-slim同样是 Debian 系但精简掉了大部分非必要包体积比完整版小很多又保留了 Debian 的包管理机制和 glibc。绝大多数 Python 项目选这个就对了。python:3.12-alpine基于 Alpine Linux使用 musl libc体积确实可以小到 50MB 级别。但 musl 和 glibc 的行为差异会引发一些诡异问题比如某些二进制 wheel 包没有 musl 版本需要现场编译pandas这类重库在 alpine 上安装时间感人还有 DNS 解析、线程栈大小、日期处理的细微差异。基础镜像体积兼容性构建速度适用场景python:3.12大最好快有预编译wheel写 C 扩展、重依赖服务python:3.12-slim中等好快大多数 Web 服务和脚本默认首选python:3.12-alpine很小部分包需源码编译可能很慢纯 Python 依赖、对体积极度敏感的场景我的实践结论是除非对镜像体积有硬性要求否则一律用-slim系列。alpine 省下来的那几十、上百 MB在部署环节往往会被构建时多花的时间抵消掉。如果你的依赖特别干净只有 requests、fastapi 这类纯 Python 包再考虑 alpine。2.2 调整依赖安装顺序让Layer缓存真正生效Dockerfile 的指令顺序有个底层机制在起作用镜像分层和构建缓存。Docker 在构建每一个 layer 之前会先检查是否有可以复用的缓存。判断方式简单粗暴如果当前指令和指令涉及的文件都没变化就直接用缓存 layer。一旦某一步缓存失效后续所有步骤都得重跑。这个机制给我们的启发是把变化频率低的内容放在前面变化频率高的放在后面。Python 项目里变化频率最低的是requirements.txt或者pyproject.toml里锁定的依赖列表代码是每天都会变的。所以标准做法是先把依赖文件复制进去安装依赖再 COPY 项目代码。直接对比两种写法的差异# 错误示范代码先拷进去依赖变化就会导致依赖重装 COPY . . RUN pip install -r requirements.txt# 正确做法依赖单独一层缓存代码变更不影响依赖层 COPY requirements.txt . RUN pip install -r requirements.txt COPY . .第一次构建时两者速度差不多但只要改了一行业务代码第一种写法会把pip install也重新执行一遍耗时按分钟计算第二种写法能直接命中依赖层缓存几秒钟就出镜像。在依赖包很多、安装时间很长的项目上这个差异会非常明显。pip install本身也有缓存机制在镜像里如果不控制会白白增加体积。建议在 Dockerfile 里设置ENV PIP_NO_CACHE_DIR1 \ PIP_DISABLE_PIP_VERSION_CHECK1这两行的作用是禁用 pip 的本地缓存目录和版本检查否则每次构建镜像里都会残留一份 pip 下载的临时文件。2.3 多阶段构建解决编译工具造成的镜像臃肿Python 项目一旦涉及编译型依赖镜像体积就开始失控。典型场景是安装psycopg2-binary还好但如果你需要psycopg2源码包或者numpy、pandas、lxml这类依赖在目标平台没有预编译 wheelpip 就会调用 gcc 现场编译。编译需要完整的 gcc、Python 头文件、各类系统库这些工具动辄几百 MB但它们只服务于“构建”这一个过程运行时根本用不到。多阶段构建就是专门解决这个问题的先用一个完整的构建环境把依赖编译好、打包好再换一个干净的精简镜像只把编译产物拷贝过去。# ---- 第一阶段编译依赖 ---- FROM python:3.12-slim AS builder WORKDIR /app ENV PIP_NO_CACHE_DIR1 PIP_DISABLE_PIP_VERSION_CHECK1 COPY requirements.txt . RUN pip wheel --wheel-dir /wheels -r requirements.txt # ---- 第二阶段运行环境 ---- FROM python:3.12-slim WORKDIR /app # 从构建阶段提取wheel包只安装不编译 COPY --frombuilder /wheels /wheels RUN pip install --no-index --find-links/wheels -r requirements.txt COPY . . CMD [python, main.py]先解释一下pip wheel这步pip 会把 requirements 里所有依赖下载并构建成.whl文件存放到指定目录。这个动作需要完整的编译环境所以放在只用来构建的 builder 阶段执行。第二阶段是干净的 slim 镜像里面没有 gcc但因为有现成的 wheel 包可以直接pip install --no-index离线安装不需要再走一遍编译流程。这样操作之后最终镜像只剩运行所需的 Python 代码和依赖库臃肿的编译工具链被完整地挡在镜像外面。我做过一个内部数据处理服务单阶段构建镜像 1.2GB改用多阶段构建后直接降到 280MB效果非常直观。2.4 一个可以直接参考的生产级Dockerfile把前面这些要点整合到一起一个 Web 服务项目的 Dockerfile 长这样FROM python:3.12-slim # 1. 控制pip缓存与编码 ENV PIP_NO_CACHE_DIR1 \ PIP_DISABLE_PIP_VERSION_CHECK1 \ PYTHONUNBUFFERED1 \ PYTHONDONTWRITEBYTECODE1 # 2. 先装系统级依赖如果应用需要 RUN apt-get update \ apt-get install -y --no-install-recommends libpq5 \ rm -rf /var/lib/apt/lists/* # 3. 人人可忘的创建普通用户 RUN useradd --create-home --uid 1000 appuser WORKDIR /app # 4. 先复制依赖文件安装依赖再复制代码利用缓存 COPY requirements.txt . RUN pip install -r requirements.txt COPY . . # 5. 收尾设置 USER appuser EXPOSE 8000 CMD [gunicorn, main:app, --workers, 2, --bind, 0.0.0.0:8000]几个容易被忽略的地方PYTHONUNBUFFERED1保证 Python 的 stdout 不缓冲。不设这个变量时print 的输出在容器里可能迟迟不出现直接表现就是docker logs看不到日志排查问题像抓瞎。PYTHONDONTWRITEBYTECODE1是禁止 Python 在当前目录写__pycache__缓存文件防止容器运行过程中在只读层写入无意义的文件。useradd --create-home --uid 1000 appuser这行看似多余实则很关键。默认情况下容器以 root 运行一旦应用存在漏洞攻击者就直接拿到了容器里的最高权限。生产环境的标准是让应用以普通用户运行后面单独有一节展开讲文件权限问题。3. 开发调试别和部署一套配置本地热更新、环境变量与调试技巧3.1 把源码目录挂进去改代码不用重新build如果每改一行代码都要重新docker build一次开发效率会低到让人怀疑人生。容器开发模式的核心思路是用 bind mount 把宿主机当前代码目录直接挂进容器容器里运行的就是宿主机的代码改完立刻生效。假设你的镜像已经构建好本地调试时这样启动docker run -p 8000:8000 -v $(pwd):/app -e PYTHONPATH/app my-python-app-v $(pwd):/app把当前目录挂载到容器内的/app覆盖掉镜像里 COPY 进去的代码副本。配合--reload参数比如 uvicorn 的--reload或 gunicorn 的--reload源码一保存容器里的进程就自动重启。这一步省掉的反复构建时间在依赖多的项目上是以天计的。要注意的是 bind mount 在 Windows 和 macOS 上会有文件系统同步性能损耗特别是大量文件读写时明显变慢。这是 Docker Desktop 的文件共享机制限制属于预期行为不用太在意。真正要注意的是-v挂载会把宿主机的目录内容和容器的目录直接打通如果容器代码对挂载目录里的文件有写操作会在宿主机上留下对应文件别把容器里的临时文件污染到宿主机代码目录里。3.2 用环境变量接管所有环境相关配置传统 Python 应用最常见的坑是配置写死在代码里。今天改个数据库地址还要进容器改代码重新 build这等于把环境差异重新引入了容器体系。容器化的铁律是环境相关的一切都通过环境变量注入。数据库地址、Redis 密码、调试开关、日志级别全部不要写死在源码里。Python 里读取环境变量最标准的方式是os.environ推荐用一个 pydantic-settings 或者简单的 dataclass 做集中管理而不是在代码里到处散落os.getenv。举个例子from pydantic_settings import BaseSettings class Settings(BaseSettings): app_name: str demo debug: bool False database_url: str sqlite:///./dev.db class Config: env_file .env settings Settings()启动容器时用-e参数注入docker run -e DATABASE_URLpostgresql://user:passhost/db -e DEBUGtrue my-python-app这一套做下来同一个镜像在本地、测试环境、生产环境跑出来的行为可以完全一致差异只体现在环境变量上。构建一次镜像处处运行这才是容器化的意义。3.3 容器内调试三件套日志、exec和调试端口很多人容器化之后不会调试了其实思路和平时用服务器没区别只是入口变成了 docker 命令。第一件套标准输出日志。让应用所有日志都打到 stdout容器日志统一用docker logs -f 容器名去拉。Python 里不要使用 logging 的 FileHandler也不要把日志写到本地文件因为容器一旦被删除文件就没了。日志采集统一走 stdout后面接上日志平台或者直接docker logs查看简单又可靠。第二件套进入容器执行命令。遇到“容器里这个文件到底长什么样”这类问题不用猜直接进去看docker exec -it my-container bash容器里大概率没有 bash只有 sh也很够用。进去之后可以看当前目录、检查环境变量、手动跑一下 Python 脚本定位是不是依赖安装有问题。经常有人忘记-it结果容器提示“cannot enable tty mode”加上就好。第三件套远程调试端口。Web 服务可以暴露调试端口用 debugpy 或者 IDE 的 attach 功能远程连进来。以 debugpy 为例在启动入口加几行import debugpy debugpy.listen((0.0.0.0, 5678)) debugpy.wait_for_client()然后在 docker run 时把 5678 端口暴露出来本地 IDE 就能连接断点调试。所有连接都需要格外小心端口安全调试完成后务必移除。4. 单容器到多服务用docker compose把应用和数据库串联起来4.1 为什么需要编排而不只是多个docker run单个容器的部署方式应付一个独立脚本绰绰有余但 Web 应用几乎都要配数据库、缓存、消息队列。如果靠手敲两条docker run命令很快会遇到问题。第一个问题是网络。两个独立的容器各自有隔离的网络栈想互相访问必须手写 IP 或通过--link参数关联一旦容器重启IP 可能变化配置就要跟着改。第二个问题是启动顺序。应用容器启动时数据库如果还没就绪连接直接报错你只能靠手动 sleep 或者反复重启。docker compose 干的事情就是把“多个服务、共享网络、共享存储、启动顺序”这一整套编排逻辑写进一个 YAML 文件一个命令拉起整条应用链这是生产中从单容器迈向多容器最顺的一条路径。4.2 编排文件的核心结构服务、网络、卷一个经典的 Python Web 应用 PostgreSQL 的 compose 文件如下services: db: image: postgres:16-alpine environment: POSTGRES_USER: app POSTGRES_PASSWORD: app_password POSTGRES_DB: appdb volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U app -d appdb] interval: 5s timeout: 3s retries: 5 web: build: . ports: - 8000:8000 environment: DATABASE_URL: postgresql://app:app_passworddb:5432/appdb volumes: - .:/app depends_on: db: condition: service_healthy command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload volumes: pgdata:这个文件里的几个设计点值得逐一说明web和db是同一个 compose 项目下的两个服务compose 会自动为他们创建共享网络。web 服务访问数据库时直接用服务名db作为主机名不需要关心容器的实际 IP。这个“服务名即域名”的机制是 compose 的精髓让应用配置里的数据库地址变成可读性极高的postgresql://app:app_passworddb:5432/appdb。volumes: - .:/app是开发模式下的挂载。生产部署时这一行应该删除改用镜像内 COPY 进去的代码开发时必须保留否则每改动一个文件都要重建镜像。depends_on加condition: service_healthy的意思是启动 web 前先确认 db 健康。注意普通depends_on只管启动顺序不管可用状态数据库容器起来了不代表已经能接受连接。配合healthcheck才能真正解决启动竞态问题。4.3 健康检查、依赖启动顺序与数据持久化健康检查不只是给 depends_on 用它还直接影响负载均衡和调度系统对容器状态的判断。compose 里healthcheck如果不写Docker 会用默认的健康检查机制如果有的话更推荐每个服务都显式声明。对于 Python Web 服务一个实用的健康检查是让应用提供一个/healthz接口返回 200。然后 compose 里这样配healthcheck: test: [CMD, python, -c]但这条命令不好用因为 Healthcheck 的test字段是一次执行的 command array不像 shell 那样灵活。更简单的方案是直接请求端口比如healthcheck: test: [CMD-SHELL, python -c \import urllib.request; urllib.request.urlopen(http://127.0.0.1:8000/healthz)\] interval: 10s timeout: 3s retries: 3 start_period: 5sslim镜像里没有 curl用 Python 标准库发请求最省事不用额外装包。这个检查做的动作是每隔 10 秒访问一次健康接口连续失败 3 次就标记为 unhealthy。加了健康检查后可以在宿主机用docker compose ps直观看到每个服务的健康状态部署排错时非常有用。数据持久化走命名的 volume。上面的pgdata就是一个命名的 Docker volume数据库文件存放在 volume 里不会随容器删除而消失。备份数据时直接对 volume 操作即可docker run --rm -v pgdata:/var/lib/postgresql/data -v $(pwd):/backup alpine tar czf /backup/pgdata.tar.gz -C /var/lib/postgresql/data .这套机制比用 bind mount 把数据库文件放宿主机目录要稳妥得多Docker 会管理权限和路径映射不容易踩到文件权限不一致的坑。4.4 日常运维操作启动、停止、重建与日志查看compose 的常用命令并不多但有几个细微差别用错了会很难受。docker compose up -d后台启动整套服务。docker compose down停止并删除容器和网络但不会删除 volume所以数据还在。docker compose down -v在此基础上连 volume 一起删数据会清空。生产环境这条命令要当成高危命令看待手滑一次数据库就没了。docker compose build web docker compose up -d修改了 Dockerfile 或代码后先重建镜像再拉起服务。docker compose logs -f web实时跟踪 web 服务日志。实际使用中的另一个高频坑是docker compose up之前没有检查配置文件语法导致运行时才报错。建议每次改完docker-compose.yml先用docker compose config它会解析并输出最终配置语法有问题会在这一步直接暴露比启动到一半再报错强得多。5. 容器化路上我踩过的坑和优化经验5.1 镜像体积从1.2GB降到180MB的优化路径一个常见的 Python 服务明明只写了 300 行业务代码镜像却能到 1GB 以上。这个体积不是代码撑起来的是依赖和构建工具撑起来的。我优化过的一个项目体积从 1.2GB 一路降到 180MB核心动作就三个第一换基础镜像。从python:3.12换成python:3.12-slim体积直接砍掉 700MB。完整版里带了一大批开发工具运行时根本用不到。第二多阶段构建。项目里用了psycopg2源码包单阶段构建时 gcc、PostgreSQL 头文件、编译工具全被留在镜像里。改成多阶段构建后编译产物和运行依赖分离这一项又省掉 300MB 左右。第三清理系统包缓存。装系统依赖时如果没有rm -rf /var/lib/apt/lists/*apt 的索引文件会残留在镜像里每个包几十 MB。这行看似不起眼其实收获不小。注意一个反向陷阱不要为了让镜像变小盲目用 alpine。如果你的依赖里有pandas、numpy这类重量级库alpine 上需要编译耗时从几分钟变成几十分钟为了省 100MB 体积非常不值。5.2 非root用户和文件权限问题的处理默认情况下 Docker 容器以 root 身份运行。在本地跑没事生产环境这会成为一个安全隐患容器里的 root 和宿主机的 root 共享内核权限边界一旦容器被攻破攻击者就以最高权限在内核层面操作宿主机资源。标准做法是在镜像里创建一个低权限用户RUN useradd --create-home appuser USER appuser但这里有个容易让人抓狂的连锁问题代码目录的权限。如果基础镜像的 WORKDIR 是/app这个目录默认属于 root。切换成 appuser 之后应用可能没有权限在 /app 下写临时文件。两个解决办法一是运行时把目录所有权交出去——RUN chown -R appuser:appuser /app二是用挂载卷时确保宿主机目录的权限允许容器内用户读写。bind mount 场景下容器内用户的 uid 和宿主机用户的 uid 不一致windows/mac 尤其常见会导致文件在宿主机里创建后容器内无法读取或者反过来。优先策略是给应用用户设定固定 uid比如上面 Dockerfile 里--uid 1000然后把宿主机用户的 uid 也统一成 1000一劳永逸。5.3 时区、信号退出、日志丢失这类隐蔽问题这三个坑各自都不大但每个都能让人排查半天。时区问题。大多数基础镜像默认 UTC 时区日志时间戳和业务处理的本地时间会差 8 小时。如果应用的定时任务依赖本地时区判断“今天是几号”而且系统里没有装 tzdata直接表现为任务执行时间错位。解决办法是设置 TZ 环境变量并安装 tzdataENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone但注意底层系统比如 Debian可能没有/usr/share/zoneinfo需要先安装 tzdata 包。信号退出问题。前面说过容器里 PID 1 是应用进程SIGTERM 的处理容易出问题。用 gunicorn 启动时如果容器收到 SIGTERM理想流程是 gunicorn 优雅停止 worker、等待当前请求处理完毕、然后退出。但 Python 进程直接作为 PID 1 时默认的信号处理经常不转发给子进程。推荐做法是引入 tini 作为 PID 1RUN apt-get install -y tini ENTRYPOINT [/usr/bin/tini, --] CMD [gunicorn, main:app, --workers, 2, --bind, 0.0.0.0:8000]在我实际测试中加 tini 之前docker stop经常要等满 10 秒才被强制杀掉加之后平滑退出的延迟在毫秒级。日志丢失问题。容器日志不是无限的。默认情况下 Docker 的 json-file 日志驱动会把日志存到宿主机本地但如果不加保留策略日志文件无限增长会把磁盘写满。在/etc/docker/daemon.json里配置 rotation{ log-driver: json-file, log-opts: { max-size: 20m, max-file: 3 } }配置后重启 Docker 生效每个容器最多保留三个 20MB 的日志文件。这是很多人第一次在生产环境跑容器化 Python 应用时会遇到的“日志突然消失”的真正原因。5.4 安全加固的底线操作安全方面不需要做得多复杂但有几条底线操作不能缺。第一不要用 latest 标签。python:3.12-slim比python:latest好得多显式锁定小版本比如python:3.12.4-slim可以让镜像内容完全可复现。latest 一更新下次构建出来的镜像可能就和上次不同了这在生产环境是不可接受的。第二定期更新基础镜像和依赖。镜像里的依赖包是构建时的快照时间一长老漏洞就会出来。建议定期重新构建一遍镜像用pip list --outdated检查依赖更新把安全补丁跟上来。这不是一次性的任务而是和宿主机打补丁一样的例行操作。第三使用 Dockerfile 时避免把敏感信息写死在镜像层里。比如构建时需要传入 API key很多人会用 ARG 写在 Dockerfile 里然后在 build 命令里传值。但这样做密钥会留在镜像的构建历史中任何拿到镜像的人都能通过docker history看到。正确做法是运行时通过-e或 secret 机制注入。# 错误密钥进镜像历史 ARG API_SECRET RUN echo $API_SECRET /app/secret.txt # 正确运行时注入 docker run -e API_SECRETxxx my-python-app第四把暴露的端口控制到最少。容器只需要暴露必须对外的端口其他端口一律不映射。调试完记得关掉调试端口这比任何防火墙配置都管用。我自己在容器化过程中踩得最深的一次坑是为了验证一个定时任务怎么都触发不了最后发现是时区没设对任务一直按 UTC 凌晨触发而本地北京时间已经到了早上八点。那种排查过程极其痛苦所以后来坚持在 Dockerfile 里把时区、日志旋转、非 root 用户这几件事一次性写进所有项目的基础模板里。如果你正在折腾自己手上的 Python 应用推荐也按这个顺序走一遍先确认基础镜像再调依赖安装顺序然后加上多阶段构建最后用 compose 把依赖服务串起来。每一步解决的都是一个具体的实际问题等这套流程跑顺了你会发现部署 Python 应用这件事终于不再依赖某台机器的特殊环境了。