ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python应用容器化实战:镜像瘦身与compose编排

Python应用容器化实战:镜像瘦身与compose编排

这两年只要聊到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 Hub,Python 官方镜像里最常用的三个 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_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1

这两行的作用是禁用 pip 的本地缓存目录和版本检查,否则每次构建,镜像里都会残留一份 pip 下载的临时文件。

2.3 多阶段构建解决编译工具造成的镜像臃肿

Python 项目一旦涉及编译型依赖,镜像体积就开始失控。典型场景是安装psycopg2-binary还好,但如果你需要psycopg2源码包,或者numpy、pandas、lxml这类依赖在目标平台没有预编译 wheel,pip 就会调用 gcc 现场编译。

编译需要完整的 gcc、Python 头文件、各类系统库,这些工具动辄几百 MB,但它们只服务于“构建”这一个过程,运行时根本用不到。多阶段构建就是专门解决这个问题的:先用一个完整的构建环境把依赖编译好、打包好,再换一个干净的精简镜像,只把编译产物拷贝过去。

# ---- 第一阶段:编译依赖 ---- FROM python:3.12-slim AS builder WORKDIR /app ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1 COPY requirements.txt . RUN pip wheel --wheel-dir /wheels -r requirements.txt # ---- 第二阶段:运行环境 ---- FROM python:3.12-slim WORKDIR /app # 从构建阶段提取wheel包,只安装不编译 COPY --from=builder /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_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 # 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"]

几个容易被忽略的地方:

PYTHONUNBUFFERED=1保证 Python 的 stdout 不缓冲。不设这个变量时,print 的输出在容器里可能迟迟不出现,直接表现就是docker logs看不到日志,排查问题像抓瞎。PYTHONDONTWRITEBYTECODE=1是禁止 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_URL=postgresql://user:pass@host/db -e DEBUG=true 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_password@db: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_password@db: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: 5s

slim镜像里没有 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 环境变量,并安装 tzdata:

ENV TZ=Asia/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 1:

RUN 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_SECRET=xxx my-python-app

第四,把暴露的端口控制到最少。容器只需要暴露必须对外的端口,其他端口一律不映射。调试完记得关掉调试端口,这比任何防火墙配置都管用。

我自己在容器化过程中踩得最深的一次坑,是为了验证一个定时任务怎么都触发不了,最后发现是时区没设对,任务一直按 UTC 凌晨触发,而本地北京时间已经到了早上八点。那种排查过程极其痛苦,所以后来坚持在 Dockerfile 里把时区、日志旋转、非 root 用户这几件事一次性写进所有项目的基础模板里。如果你正在折腾自己手上的 Python 应用,推荐也按这个顺序走一遍:先确认基础镜像,再调依赖安装顺序,然后加上多阶段构建,最后用 compose 把依赖服务串起来。每一步解决的都是一个具体的实际问题,等这套流程跑顺了,你会发现部署 Python 应用这件事,终于不再依赖某台机器的特殊环境了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进