做后端服务这几年我发现同事之间聊得最多的往往不是业务逻辑反而是那些看起来最不起眼的“部署细节”。就拿Docker 构建 Python FastAPI 镜像来说网上模板一搜一大把但真正把镜像体积、构建速度、运行稳定性都照顾到的其实不多。这篇内容是我自己的实战经验整理核心围绕 FastAPI 项目镜像构建这条线从基础镜像选型到依赖缓存策略再到多阶段构建、非 root 运行、健康检查和优雅停机适合正在把 FastAPI 服务容器化、或者被镜像体积和构建速度困扰的同学参考希望能帮你少踩几个我踩过的坑。1. 先搞清楚基础镜像的差别再决定 FROM 谁1.1 slim、alpine、标准版到底差在哪很多人写 Dockerfile 的第一步就是FROM python:3.12但这往往是后续一系列麻烦的起点。先看一张我常用的基础镜像对比表心里有个底再说基础镜像体积libc兼容性适合场景python:3.12约 400MBglibc最省心本地快速验证python:3.12-slim约 120~150MBglibc好线上推荐python:3.12-alpine约 50MBmusl看运气体积敏感且依赖为纯 Python很多教程吹 alpine 体积小实际上 FastAPI 生态里真正决定镜像体积的不是基础镜像本身而是依赖包的数量和形态。我维护的一个内部 API 服务依赖里有pydantic-core和orjson这类带二进制扩展的包。在 alpine 上构建的时候如果找不到对应的 musl wheelpip 就会现场编译这时你不仅需要gcc、musl-dev甚至可能要在容器里再装一套 Rust 工具链。曾经有一次构建直接卡在编译阶段二十多分钟最后还报错我当场就放弃了。python:3.12-slim基于 Debian 体系自带 glibc绝大多数manylinux的 wheel 都能直接装上。即使需要编译也只需要build-essential不需要折腾 musl 相关工具链。FastAPI 服务只要不是对镜像体积有极致要求我基本都建议无脑选 slim 版本。1.2 tag 漂移问题别让构建结果“看天吃饭”还有一个非常隐蔽但迟早会踩的坑基础镜像的 tag 不是固定的。python:3.12-slim这个 tag 会随着官方镜像更新而漂移今天构建没问题过一阵子可能突然失败因为底层的基础镜像替你悄悄换了版本。有次项目组做例行重构代码没动构建却突然报错排查到最后发现是新版基础镜像里某个系统库更新连带影响了一个 C 扩展包的编译环境。从那以后我所有 Dockerfile 都会把 tag 锁到具体小版本比如FROM python:3.12.7-slim如果团队对可复现性要求更高可以直接用镜像的 digest 地址保证每次拉下来的镜像完全一致。不过这样做的代价是升级基础镜像时必须手动去查新 digest日常项目其实锁到小版本就够了。2. 把依赖安装设计成“缓存友好”构建速度才谈得上优化2.1 写不对 .dockerignore后面全白搭第一次给 FastAPI 项目写 Dockerfile 时我直接打包构建上下文结果每次把.venv、node_modules、.git这些无关文件都塞进 Docker 守护进程。构建上下文瞬间从几 KB 膨胀到上百 MB传到服务器再解压光这一步就要好几分钟而且每次代码改动都要重新走一遍。后来老老实实写.dockerignore这些问题才真正解决掉。一个比较通用的模板长这样__pycache__/ *.py[cod] *$py.class *.so .env .venv/ venv/ env/ .git/ .gitignore .idea/ .vscode/ .vscode/ *.md tests/ Dockerfile docker-compose*.yml .mypy_cache/ .pytest_cache/ .ruff_cache/写完.dockerignore之后我再顺手加了个tar命令验证一下构建上下文大小看到从几百 MB 降到几 KB才明白很多“构建慢”的问题根本不是机器性能差而是把垃圾文件一起打包了。2.2 COPY 顺序决定 layer 缓存能不能复用Docker 的镜像是一层一层叠加的每一层如果内容变了那从这一层开始往后的所有层缓存都会失效。所以最经典的策略是先把依赖清单复制进去装好依赖再把业务代码复制进去。COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . .这样做的核心逻辑是依赖清单很少改动业务代码天天变。只要requirements.txt没变后面每次代码改动都能复用依赖安装那一层缓存构建速度能提升一个数量级。如果连依赖清单都不经常变还可以更进一步把requirements.txt按稳定程度拆成几个文件先安装最稳定的大包再安装容易更新的小包。我见过有些项目把requirements.txt拆成requirements-base.txt和requirements-dev.txt利用的也是同一个缓存命中思路。安装依赖时那几个参数也顺带解释一下RUN pip install \ --no-cache-dir \ --disable-pip-version-check \ -r requirements.txt--no-cache-dir防止 pip 在镜像里留一份本地缓存浪费空间--disable-pip-version-check省去每次安装时的版本升级提示也让构建日志更干净。如果团队已经用uv这类新一代包管理器思路是一样的先把锁文件复制进镜像再安装依赖最后复制业务代码。工具可以换缓存友好的层设计逻辑不会变。3. 多阶段构建不是炫技是给运行时镜像做减法3.1 为什么一定要把 builder 和 runtime 拆开很多人的第一个 Dockerfile 长这样一个 FROM装依赖Copy 代码跑起来。看着简单但生产环境问题很多。FastAPI 依赖里有不少带 C 扩展的包安装时需要编译工具。如果直接在最终镜像里装完这些工具又不清理体积会非常难看。我见过一个简单的 FastAPI 镜像因为保留了 build-essential最终大小直接到了 800MB部署的时候光拉镜像就占了半天时间。多阶段构建的思路是把“构建环境”和“运行环境”彻底分开builder 阶段装编译工具、装依赖、编扩展该干嘛干嘛runtime 阶段只保留 Python 运行环境和装好的依赖编译器、头文件一概不要我之前做的一个项目builder 阶段大概 500MB但 runtime 打完只有 160MB 左右。镜像变小是一方面更重要的收益是攻击面变小了运行容器里没有 gcc即使应用被攻破想在容器里现编一个提权工具也不是那么容易。3.2 多阶段构建后镜像体积到底差多少用我自己做过的一个 FastAPI 服务举例主要依赖是 FastAPI、SQLAlchemy、asyncpg、pydantic v2。同样一套代码三种打法的结果如下构建方式镜像体积说明单阶段不清理780MB系统编译工具全带进去了单阶段清理 apt 缓存320MB省了一部分但还是带着编译器多阶段分离 builder/runtime170MB干净符合生产预期多阶段构建的核心写法是这样FROM python:3.12.7-slim AS builder WORKDIR /app RUN apt-get update \ apt-get install -y --no-install-recommends build-essential \ rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --prefix/install -r requirements.txt FROM python:3.12.7-slim AS runtime COPY --frombuilder /install /usr/local--prefix/install让 pip 把依赖统一装到一个目录里最后整个目录从 builder 阶段复制到 runtime 阶段。复制过去的位置是/usr/local正好落在 Python 的默认搜索路径里所以不需要额外改PYTHONPATH直接就能 import。网上有些方案是在 runtime 阶段用--no-cache-dir重装一遍依赖虽然也能跑但完全没有利用多阶段的编译成果白白多花两遍安装时间不是最优解。4. 非 root 运行与健康检查两个看似小事关键时刻救命4.1 生产镜像默认 root 运行风险比你想的大Docker 镜像里默认是 root 用户很多人觉得反正容器有隔离root 就 root 吧。但实际中有一个很实际的风险一旦你的 FastAPI 应用因为某个漏洞被写入了文件比如 Arbitrary File Upload 之类攻击者拿到的就是 root 权限。如果容器还挂载了宿主机目录他可以通过挂载点读写宿主机上的文件危害瞬间放大。所以标准做法是创建一个普通用户来运行应用RUN groupadd -r appuser useradd -r -g appuser -d /home/appuser -s /sbin/nologin appuser \ mkdir -p /app chown -R appuser:appuser /app USER appuser WORKDIR /app这里有个容易被忽略的细节切换用户之后应用目录默认不再是可写的。如果服务里需要写临时文件建议把可写路径指向明确设置过的/tmp或者单独挂载一个数据卷并确保目录属主正确。还有一类依赖比如机器学习相关的库运行时会往用户主目录写缓存。如果创建用户时指定了-d /home/appuser还要保证这个目录真实存在。之前我在一个图像处理服务里遇到过 matplotlib 在容器里报 “no writable dir” 的错最后发现是 HOME 指向了一个不存在的目录用环境变量把MPLCONFIGDIR指到/tmp才解决。FastAPI 服务平时不一定会碰到但一旦依赖某个库写缓存这就是必修课。4.2 HEALTHCHECK 别让健康检查成为误判源健康检查是生产环境里判断容器是否活着的关键机制尤其是配合编排系统的时候。很多 Dockerfile 里没有写HEALTHCHECK这在生产环境可不行编排系统判断你的服务挂了只能靠端口探测但端口通不代表服务真的健康。比较稳的写法是让 FastAPI 提供一个/health路由然后镜像里的健康检查命令定期探测它HEALTHCHECK --interval30s --timeout3s --start-period10s --retries3 \ CMD python -c import urllib.request; urllib.request.urlopen(http://127.0.0.1:8000/health) || exit 1这里我用 Python 的urllib而不是curl目的就是省掉一次apt-get install curl镜像能更干净。如果你的镜像里已经装了 curl也可以直接curl -f http://localhost:8000/health || exit 1。健康检查的参数也要注意--start-period10s应用启动可能需要加载模型、初始化连接池给足启动时间不然刚启动就被杀很冤--interval30s不要设置得太频繁否则大量请求打到自己的 /health 接口反而干扰正常业务--timeout3s健康检查命令本身不能太慢3 秒够用我在某个项目里就遇到过健康检查“反噬”的情况检查间隔设了 5 秒服务在高峰期本来压力就大健康检查自己还要抢资源结果本来没事的服务被编排系统反复重启。把 interval 调回 30 秒之后一切都正常了。5. 优雅停机总超时信号透传与 tini 是根源5.1 为什么 docker stop 要等几十秒FastAPI 用uvicorn跑起来之后最典型的问题是容器停止时卡很久。表面现象是执行docker stop后容器一直不退出直到超时才被强制 kill。这个问题的根子在容器的 PID 1 进程。Docker 镜像里的进程如果有 shell 包着比如CMD [sh, -c, uvicorn app.main:app --host 0.0.0.0 --port 8000]shell 作为 PID 1收到的 SIGTERM 信号并不会自动转发给实际的 uvicorn 进程。uvicorn 收不到退出通知自然不会走优雅关闭逻辑连接还在慢慢存活着整个容器就只能一直卡着。解决方式是引入一个专门处理信号和僵尸进程的轻量进程管理器最常见是tiniRUN apt-get update \ apt-get install -y --no-install-recommends tini \ rm -rf /var/lib/apt/lists/* ENTRYPOINT [/usr/bin/tini, --] CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000, --workers, 1]tini会作为 PID 1负责接收信号并把 SIGTERM 透传给 uvicorn也会回收因工作进程提前退出而产生的僵尸进程。没有 tini 的容器在滚动发布时往往表现为“旧实例迟迟不退出”有了它之后整个发布流程顺滑很多。5.2 容器里到底要不要用 --workers这是另一个高频争议点。uvicorn --workers 4看起来能利用多核但这里是存在陷阱的多 worker 模式在 uvicorn 里是父进程拉起 N 个子进程每个子进程独立处理连接。如果部署在 Kubernetes 里同时跑 4 个副本再加每个副本 4 个 worker进程数会爆炸很多连接管理、内存配额都会出问题。我现在的默认策略是容器内单进程跑--workers 1横向扩容靠编排系统拉更多副本。这样每个容器逻辑最简单信号处理最清晰也方便按实例做滚动更新。如果单个容器就是需要多 worker比如跑在裸机 Docker 上不想用编排那就一定要配合 tini 使用不然父进程和子进程之间的信号转发会埋下各种奇怪问题。还有一点不管用不用 tiniFastAPI 应用里如果自己注册了signal.signal(signal.SIGTERM, ...)来做清理注意别和 uvicorn 默认行为冲突。大多数情况你不需要自己写 SIGTERM 处理除非有特殊的连接池关闭逻辑。6. 一个可以直接抄的完整 Dockerfile 与踩坑记录6.1 逐段拆解一个可上生产的 Dockerfile下面是我目前给 FastAPI 服务默认使用的 Dockerfile 模板注释里写清楚了每一段的作用# syntaxdocker/dockerfile:1 FROM python:3.12.7-slim AS builder ENV PYTHONDONTWRITEBYTECODE1 \ PYTHONUNBUFFERED1 \ PIP_DISABLE_PIP_VERSION_CHECK1 \ PIP_NO_CACHE_DIR1 WORKDIR /app RUN apt-get update \ apt-get install -y --no-install-recommends build-essential \ rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --prefix/install -r requirements.txt FROM python:3.12.7-slim AS runtime RUN groupadd -r appuser useradd -r -g appuser -d /home/appuser -s /sbin/nologin appuser \ mkdir -p /app \ chown -R appuser:appuser /app \ apt-get update \ apt-get install -y --no-install-recommends tini \ rm -rf /var/lib/apt/lists/* COPY --frombuilder /install /usr/local COPY --chownappuser:appuser . /app USER appuser WORKDIR /app EXPOSE 8000 HEALTHCHECK --interval30s --timeout3s --start-period10s --retries3 \ CMD python -c import urllib.request; urllib.request.urlopen(http://127.0.0.1:8000/health) || exit 1 ENTRYPOINT [/usr/bin/tini, --] CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000, --workers, 1]几个值得说明的设计决策# syntaxdocker/dockerfile:1开启新版 Dockerfile 语法很多 BuildKit 新特性会依赖这一行builder 里临时装build-essentialruntime 里完全不装这是体积控制的关键COPY --chownappuser:appuser . /app保证业务代码复制进来就是普通用户可读的--workers 1明确写出来防止有人误加环境变量覆盖如果应用里还需要外部 HTTPS 访问runtime 阶段可能需要考虑ca-certificates包。大多数 slim 镜像已内置如果你的依赖会校验证书建议手动加上。6.2 配合 docker compose 使用时的一点建议实际项目一般是一套服务跑在一起所以我通常会补一个 docker-compose 片段不在 compose 里重复写镜像内部参数services: api: build: context: . dockerfile: Dockerfile ports: - 8000:8000 restart: unless-stopped read_only: true tmpfs: - /tmp这里两个细节值得嚼一下read_only: true让整个根文件系统只读应用想写文件只能写挂载的卷或 tmpfs这是除了非 root 用户之外的第二道保险tmpfs: /tmp给临时文件提供一个内存盘既保证可写又保证容器停止后数据自动清空如果业务需要把某个数据目录持久化比如上传文件就单独挂一个命名卷并且注意目录属主必须和镜像里的appuser对得上否则会出现“容器起来很顺利一写文件就报 permission denied”的经典问题。6.3 我实际踩过的三个坑坑一alpine 的兼容性陷阱。我和不少同事一样早期为了追求镜像小选过 alpine。FastAPI 本身没问题但一旦依赖里有针对 musl 没有预编译 wheel 的包构建现场就会开始编译不仅慢还经常因为缺系统库失败。后来统一改回 slim构建稳定性和速度反而都提升了镜像体积多出的几十 MB 完全可接受。坑二切了普通用户却忘授权限。有个服务启动后一直报编码文件写入失败因为日志目录是 root 创建的切了USER appuser之后没有权限写。这个问题浪费了整整一个下午。如果你的服务启动时要写文件记得在RUN阶段就把对应目录chown给普通用户或者用 entrypoint 里显式chown之后再启动。坑三没处理 SIGTERM滚动发布卡住。这应该是我见过最普遍的问题。最开始我写的 CMD 是[sh, -c, uvicorn ...]发布系统每次停止旧容器都要等到超时强杀服务不优雅退出连接一直悬挂发布窗口被无限拉长。换成 tini 作为 entrypoint 之后这个问题彻底消失。6.4 几个零散的效率技巧最后再分享几个我平时常用的操作细节。调试构建缓存是否生效可以用docker build --progressplain .日志里每一步都会标注CACHED或实际执行一眼就能看出缓存命中情况。构建时经常搭配--pull参数强制拉取最新的基础镜像避免本地缓存了太久没更新的镜像导致 debug 半天。如果是 CI 环境还可以用--cache-from指定上一次构建的镜像让流水线里的增量构建变得更快不过本地开发场景不需要这么较真。如果要把镜像推到某个远端仓库构建的时候尽量用一个稳定的命名规范比如myapp-api:1.4.2。镜像标签要和代码版本关联而不是每次都用 latest否则等你想回滚某一个具体版本时会发现自己根本不知道哪个镜像对应哪个代码。