做后端服务这几年,我发现同事之间聊得最多的往往不是业务逻辑,反而是那些看起来最不起眼的“部署细节”。就拿Docker 构建 Python FastAPI 镜像来说,网上模板一搜一大把,但真正把镜像体积、构建速度、运行稳定性都照顾到的,其实不多。这篇内容是我自己的实战经验整理,核心围绕 FastAPI 项目镜像构建这条线,从基础镜像选型到依赖缓存策略,再到多阶段构建、非 root 运行、健康检查和优雅停机,适合正在把 FastAPI 服务容器化、或者被镜像体积和构建速度困扰的同学参考,希望能帮你少踩几个我踩过的坑。
1. 先搞清楚基础镜像的差别,再决定 FROM 谁
1.1 slim、alpine、标准版到底差在哪
很多人写 Dockerfile 的第一步就是FROM python:3.12,但这往往是后续一系列麻烦的起点。先看一张我常用的基础镜像对比表,心里有个底再说:
| 基础镜像 | 体积 | libc | 兼容性 | 适合场景 |
|---|---|---|---|---|
| python:3.12 | 约 400MB+ | glibc | 最省心 | 本地快速验证 |
| python:3.12-slim | 约 120~150MB | glibc | 好 | 线上推荐 |
| python:3.12-alpine | 约 50MB | musl | 看运气 | 体积敏感且依赖为纯 Python |
很多教程吹 alpine 体积小,实际上 FastAPI 生态里真正决定镜像体积的不是基础镜像本身,而是依赖包的数量和形态。
我维护的一个内部 API 服务,依赖里有pydantic-core和orjson这类带二进制扩展的包。在 alpine 上构建的时候,如果找不到对应的 musl wheel,pip 就会现场编译,这时你不仅需要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/runtime | 170MB | 干净,符合生产预期 |
多阶段构建的核心写法是这样:
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 --from=builder /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 --interval=30s --timeout=3s --start-period=10s --retries=3 \ 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-period=10s:应用启动可能需要加载模型、初始化连接池,给足启动时间,不然刚启动就被杀,很冤--interval=30s:不要设置得太频繁,否则大量请求打到自己的 /health 接口,反而干扰正常业务--timeout=3s:健康检查命令本身不能太慢,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 收不到退出通知,自然不会走优雅关闭逻辑,连接还在慢慢存活着,整个容器就只能一直卡着。
解决方式是引入一个专门处理信号和僵尸进程的轻量进程管理器,最常见是tini:
RUN 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 使用,不然父进程和子进程之间的信号转发会埋下各种奇怪问题。
还有一点:不管用不用 tini,FastAPI 应用里如果自己注册了signal.signal(signal.SIGTERM, ...)来做清理,注意别和 uvicorn 默认行为冲突。大多数情况你不需要自己写 SIGTERM 处理,除非有特殊的连接池关闭逻辑。
6. 一个可以直接抄的完整 Dockerfile 与踩坑记录
6.1 逐段拆解一个可上生产的 Dockerfile
下面是我目前给 FastAPI 服务默认使用的 Dockerfile 模板,注释里写清楚了每一段的作用:
# syntax=docker/dockerfile:1 FROM python:3.12.7-slim AS builder ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_NO_CACHE_DIR=1 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 --from=builder /install /usr/local COPY --chown=appuser:appuser . /app USER appuser WORKDIR /app EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ 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"]几个值得说明的设计决策:
# syntax=docker/dockerfile:1开启新版 Dockerfile 语法,很多 BuildKit 新特性会依赖这一行- builder 里临时装
build-essential,runtime 里完全不装,这是体积控制的关键 COPY --chown=appuser: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 --progress=plain .,日志里每一步都会标注CACHED或实际执行,一眼就能看出缓存命中情况。
构建时经常搭配--pull参数强制拉取最新的基础镜像,避免本地缓存了太久没更新的镜像导致 debug 半天。如果是 CI 环境,还可以用--cache-from指定上一次构建的镜像,让流水线里的增量构建变得更快,不过本地开发场景不需要这么较真。
如果要把镜像推到某个远端仓库,构建的时候尽量用一个稳定的命名规范,比如myapp-api:1.4.2。镜像标签要和代码版本关联,而不是每次都用 latest,否则等你想回滚某一个具体版本时会发现自己根本不知道哪个镜像对应哪个代码。