如果你最近在写Python后端,大概率绕不开FastAPI;如果你负责把服务扔到服务器上跑起来,那Docker基本也是躲不掉的。这两个东西放在一起,已经成了当下Python Web服务上线的默认姿势。这篇文章围绕“docker部署fastapi”这件事,把完整流程、配置文件和踩过的坑一次性说清楚。不管你是刚写完第一个FastAPI接口的新手,还是已经上线过几个项目、但没认真抠过容器细节的同学,都能在里头找到直接能抄的内容。1. 用Docker跑FastAPI,到底图什么1.1 环境一致性是最直接的收益Python开发里最头疼的问题之一就是“在我机器上能跑”。开发机用的macOS,服务器用的某个Linux发行版,两边Python小版本不一样、依赖的底层库版本对不上、某些带C扩展的包在服务器上编译缺头文件,光是把环境对齐就能消耗大半天。Docker把应用和整个运行环境打包在一起,镜像跑到哪都是同一个系统、同一套依赖、同一个Python解释器。部署时只要机器能跑Docker,镜像拉下来启动,环境和本地完全一致,这种确定性比任何配置管理工具都来得省心。FastAPI对Python版本和依赖库有明确要求,比如需要Python 3.8以上,pydantic的版本也要配套。如果服务器上已经跑了别的Python服务,直接改全局Python版本或者翻pip全局安装包,很容易互相打架。容器化之后,每个项目有自己独立的解释器和依赖目录,互不干扰。这也是我后来把所有后端服务全部容器化的直接原因——不再为了每个项目的依赖隔离去折腾虚拟环境。1.2 FastAPI的异步模型和容器机制天然合拍FastAPI本身是异步框架,典型的进程模型是一个或多个Uvicorn worker进程。这种模型放在容器里管理非常顺:镜像启动命令直接拉起Uvicorn,Docker负责进程生命周期和资源限制,后续如果要做水平扩展,直接在编排层增加容器副本数就行,改造链路很平滑。另外一个合拍点是FastAPI的自动交互文档。容器化之后,开发、测试、生产环境的 /docs 路径可以保持统一,不需要因为部署位置不同去额外配置。只要把容器端口映射好,浏览器打开就能看到接口列表和在线调试面板,前后端联调的时候非常方便。1.3 Docker层缓存对Python依赖的加速效应Docker镜像的分层机制对Python项目特别友好。依赖安装和业务代码复制可以拆成两个层:依赖层很少变化,业务代码层每次发布都会更新。构建时只要依赖文件没变,Docker会直接复用已有的依赖层缓存,原来要几分钟的构建能缩短到几十秒。这一点看起来不起眼,但在一次构建要花大量时间、还可能触及CI流水线超时上限的时候,价值非常明显。我拿生活里的场景做个类比:镜像构建就像整理一个长期使用的工具箱,依赖层是那些不太动的常用工具,代码层是每次都要换的图纸。只要工具箱没变,每次只需要换图纸,不用把整个箱子推倒重来。理解了这一点,就能明白为什么后面Dockerfile里要把COPY命令的顺序设计得那么讲究。2. Dockerfile设计,重点是别写成玩具写法2.1 基础镜像:slim还是alpineFastAPI官方推荐用 python:版本-slim 作为基础镜像。slim基于Debian,体积比完整镜像小很多,同时保留apt包管理器,需要装系统级工具时比较灵活。也有一部分人会用alpine,体积更极端,但alpine的musl libc和Python的很多二进制依赖存在兼容性问题,比如pydantic-core这类带Rust扩展的包,经常要在构建时临时装一堆编译工具,反而拖慢构建速度。我的建议是:没有极端的体积要求,优先选slim,省事稳定。基础镜像版本要和本地开发环境尽量一致。比如本地用Python 3.11,镜像也写 python:3.11-slim,尽量避免解释器小版本差异带来的行为不一致。这一点在小团队里经常被忽略,但真出了问题,比如某个语法特性在不同小版本下表现不同,排查成本反而更高。2.2 依赖层和代码层的顺序决定构建效率COPY命令有一个容易被忽略的规则:Docker构建时,只要某一层内容变化,这一层以及之后所有层缓存都会失效。所以“改得越频繁的内容就越放后面”是基本优化原则。依赖文件requirements.txt通常几天才改一次,业务代码每次提交都会变。标准顺序是先把requirements.txt复制进镜像,执行pip install,然后再复制业务代码。FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app ./app EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]这段Dockerfile看起来简单,但有几点值得展开。第一,pip install加 --no-cache-dir 可以避免把缓存文件写进镜像层,镜像更干净,体积也更小,这个参数我实测下来对构建成功率没有负面影响,建议直接当成固定习惯。第二,WORKDIR设置成/app之后,所有相对路径都基于这个目录,后面挂载卷或者写日志时能少踩很多路径坑。第三,CMD里必须用 --host 0.0.0.0,这一步很多人会漏掉,后面单独说。2.3 非root用户运行是必须踩过的坑很多初学者会在容器里用root用户跑应用,开发时没感觉,一旦生产环境出问题就很被动。如果应用被攻击者利用漏洞拿到容器内执行权限,而容器是root,宿主机挂载目录、其他容器都会面临更大风险,安全扫描工具也会直接告警。标准做法是创建专用用户,用USER指令切换。FastAPI应用通常只需要访问自己的代码目录和临时文件,不需要root权限。下面这段是在之前的Dockerfile基础上补充用户创建和切换:FROM python:3.11-slim RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app ./app RUN chown -R appuser:appuser /app USER appuser EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]这里有个细节:COPY之后要给/app目录修改属主,否则切换到非root用户后,应用根本没有权限读取代码文件。我第一次写的时候拉掉了chown这一行,容器启动立刻报PermissionError,之后我每次都提醒自己先确认目录权限。另外,应用要写日志文件或者上传目录时,也需要保证对应路径已创建且属主正确,否则运行期会出现奇奇怪怪的写入失败。2.4 多阶段构建适合什么场景如果FastAPI项目是纯Python代码,没有依赖C编译的扩展包,单阶段构建就够了。但项目一旦涉及机器翻译库、图像处理库,或者需要编译复杂的C扩展,构建工具链和运行依赖会混在一起,镜像体积直接膨胀。多阶段构建可以把编译好的产物复制到干净的运行镜像里,丢掉所有编译工具和中间文件。以某个图像处理服务为例,第一阶段安装build-essential和编译依赖,完成pip install;第二阶段只复制site-packages和业务代码。最终运行镜像不包含gcc、make这些工具,攻击面小,体积也小。FastAPI项目如果包含二进制编译组件,建议按这个思路处理:FROM python:3.11-slim AS builder RUN apt-get update && apt-get install -y build-essential COPY requirements.txt . RUN pip install --prefix=/install -r requirements.txt FROM python:3.11-slim COPY --from=builder /install /usr/local COPY app ./app第二阶段继承干净的运行镜像,不装编译工具,最终体积往往只有第一阶段的一半甚至更低。依赖包在安装过程产生的编译中间文件,也会被天然隔离在builder阶段,不会带进最终镜像。3. docker-compose编排和服务配置实战3.1 单容器到多服务,文件化管理的必要性只部署一个FastAPI容器,docker run一条命令确实够了。但实际项目很少只有一个容器,通常还要配数据库、缓存、反向代理,甚至还有定时任务容器和迁移任务容器。用一条命令管理多个容器不是不行,但状态查询、网络隔离、环境变量传递都变得很零碎。docker-compose存在的意义就是把这些容器用一份YAML文件描述清楚,一条命令拉起整个环境,一条命令全部停掉,团队成员之间的操作方式也完全一致。FastAPI应用和数据库的联调在本地尤其方便。以前本地要装PostgreSQL、跑Redis组件,现在compose文件里定义几个服务,docker compose up之后就能得到一个和线上结构一致的环境。新成员加入时,只要拉下仓库、启动compose,不用碰宿主机环境,几分钟就能开始开发。3.2 一份可以直接抄的compose配置下面是我常用的FastAPI生产compose配置骨架,覆盖数据库、迁移、应用和基础健康检查。实际使用中根据项目需要增删服务就行。version: "3.8" services: db: image: postgres:15-alpine environment: POSTGRES_USER: appuser POSTGRES_PASSWORD: apppassword POSTGRES_DB: appdb volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"] interval: 5s timeout: 5s retries: 5 api: build: context: . dockerfile: Dockerfile environment: DATABASE_URL: postgresql+asyncpg://appuser:apppassword@db:5432/appdb depends_on: db: condition: service_healthy ports: - "8000:8000" volumes: - ./logs:/app/logs restart: unless-stopped migrate: build: context: . dockerfile: Dockerfile command: alembic upgrade head environment: DATABASE_URL: postgresql+asyncpg://appuser:apppassword@db:5432/appdb depends_on: db: condition: service_healthy volumes: pgdata:这份配置里有几个关键点要说明。第一个是迁移容器:项目用了Alembic管理数据库迁移时,我会把迁移任务做成临时容器,它启动后只执行alembic upgrade head,执行完就退出。这在CI/CD流水线里很好用,发布前先跑迁移容器,迁移成功再发新版本应用,避免应用起来之后数据库表还没建好。第二个是depends_on加condition: service_healthy,让应用容器等数据库健康检查通过后再启动,避免应用先起来连数据库失败、随后退出的竞态问题。3.3 开发环境和生产环境的配置差异开发环境要的是热重载和本地调试,生产环境要的是稳定和多副本,这两种需求不能押到同一个配置里。我会用多个compose文件配合环境变量做区分:docker-compose.yml放公共配置,docker-compose.dev.yml放开发配置,docker-compose.prod.yml放生产配置。开发配置里,应用容器不直接用镜像里的启动命令,而是挂载本地代码目录并覆盖启动命令,让Uvicorn以--reload模式运行:# docker-compose.dev.yml services: api: command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload volumes: - ./app:/app/app生产配置里则使用gunicorn作为进程管理器,用Uvicorn worker启动多个进程:# docker-compose.prod.yml services: api: command: gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000为什么生产环境要换gunicorn?单进程Uvicorn在生产环境下无法利用多核CPU,而且缺少成熟的worker管理机制,某个worker崩溃后需要外部手段重启。gunicorn负责fork多个worker、回收异常worker,配合UvicornWorker跑异步接口,是FastAPI官方文档也推荐的生产方式。worker数量一般按CPU核数乘以2再加1这个经验值来定,比如4核机器设置9个worker,当然具体还得结合业务IO特性调整。3.4 端口暴露和反向代理compose默认会给每个项目创建独立的网络,服务之间通过服务名互相访问,这个默认行为本身是安全的。实际中要特别注意,别把不必要的端口暴露到宿主机。FastAPI应用通常只需要对外暴露8000端口给API网关或者反向代理,数据库、Redis这类服务只在内网被访问,不需要映射端口。如果一定要在宿主机上连数据库调试,建议在开发配置里临时暴露,生产配置坚决不加。反向代理我一般用Nginx。容器里跑Nginx,监听80和443,做TLS终止和静态资源处理,再通过服务名转发到api容器。这个组合在真实部署环境里非常常见,配合证书自动续期,基本能覆盖Web服务的入口需求。FastAPI本身不擅长处理静态文件和TLS,把这些交给反向代理,应用容器就能更纯粹地处理业务逻辑。4. 上线前检查清单和常见坑位4.1 健康检查配置到位了吗如果没有健康检查,编排系统无法判断容器是活着还是在慢慢死掉。很多部署失败的场景是:容器进程没有退出,但应用内部已经无法处理请求,比如数据库连接池耗尽、某个线程被阻塞。这个时候Docker还认为容器是健康的,流量继续打进来,用户看到的就是无响应。FastAPI加健康检查只需要写一个简单接口,返回服务状态和依赖健康情况:from fastapi import FastAPI app = FastAPI() @app.get("/healthz") async def healthz(): return {"status": "ok"}更完整的实现会同时检查数据库连接和缓存连接,一旦某个依赖不可用就返回503。编排工具遇到非零退出码会重启容器,应用就能自动恢复。写在compose里,就是前面示例中api服务下面的healthcheck字段,测试命令指向这个接口。4.2 环境变量怎么管理才不泄露常见错误是把环境变量直接写死在compose文件里,本地开发虽然方便,但文件一旦进了Git仓库,密码和密钥就全部泄露了。我的做法是使用.env文件管理敏感配置,同时把.env加进.gitignore。compose文件里用 ${VAR} 占位符引用变量,实际值只存在本机的.env文件里。生产环境我倾向于在CI/CD流水线里通过密钥管理系统动态注入环境变量,不让敏感信息以明文形式落任何文本文件。这样即使配置仓库被人拿走,也拿不到真实密钥。代码层面用os.getenv或者pydantic-settings读取环境变量,运行时拿到什么就用什么,部署细节和代码实现之间彻底解耦。4.3 日志要往哪儿去容器模式下,应用日志应该输出到标准输出和标准错误,而不是写文件。Docker会把这两个流收集起来,通过docker logs命令查看。这样设计的好处是日志和容器生命周期绑定,容器删了,日志也不会在宿主机上堆积占空间。FastAPI配套的Uvicorn和Gunicorn日志默认就走标准输出,所以不需要专门配置日志文件。如果项目要长期留存日志做分析,更合适的做法是把容器stdout交给日志收集工具处理,在compose里挂载日志目录写文件不是首选,因为文件权限和轮转问题都要额外处理。除非有非常明确的留存需求,否则尽量走stdout这条路径。4.4 常见部署失败与排查速查表我在实际部署中遇到过不少典型问题,整理成一张表方便直接对照:现象可能原因排查方法解决方案容器启动后秒退依赖缺失或启动命令错误docker logs看启动日志核对CMD路径和依赖安装完整性端口映射后宿主机访问不了应用只绑定了localhost查看Uvicorn启动参数使用0.0.0.0绑定所有网卡数据库连接失败数据库未就绪或地址写错进入容器内测试连接使用compose服务名替代localhost,增加healthcheck镜像体积突然变大安装包缓存没清理docker history查看每层大小pip加--no-cache-dir,apt清理缓存构建缓存经常失效COPY顺序不合理检查Dockerfile文件变更频率依赖复制放前面,业务代码放后面容器运行期不能写文件目录属主和权限不对ls -l检查挂载目录构建阶段chown,挂载目录指定uid/gid第一次部署最常犯的错就是数据库连接地址。在宿主机上连接数据库用localhost没问题,但在compose网络里,数据库的地址是服务名,比如db,而不是localhost。DATABASE_URL必须写成类似 postgresql+asyncpg://appuser:apppassword@db:5432/appdb 的形式。我见过好几个同事把这个点忽略掉,跑到容器里排查半天,最后才发现是地址写错。4.5 升级和回滚的实操顺序发布新版本时,如果一直用同一个镜像标签覆盖,线上一旦出问题,旧版本镜像被冲掉,回滚非常麻烦。我给自己定了一条简单规则:每个新构建的镜像打上唯一标签,比如git提交的短哈希或者时间戳,compose里的镜像名跟着变。要回滚时,只需要把compose配置里的标签改回上一个值,重新执行docker compose up -d。这里补充一个容易踩的细节:即使版本号变了,某些环境里容器运行时可能因为镜像拉取策略默认值不同,继续使用旧镜像。在生产环境需要显式设置拉取策略,确保部署脚本执行时拉取的是指定标签的新镜像。发布过程最后会收敛成三个动作:构建并推镜像、更新compose镜像标签、拉起服务。整个过程可控而且可复现。5. 现在我的标准发布流程和最后几条建议每次部署前我会按下面这个清单走一遍,基本能规避大部分问题:本地docker compose up拉起整套环境,确认应用能正常访问 /healthz确认Dockerfile配置了非root用户,运行期写入目录权限正常构建镜像,检查体积和依赖层缓存是否生效生产compose只暴露必要端口,数据库等内网服务不映射端口配置好健康检查和depends_on条件,规避启动顺序竞态环境变量全部通过.env或者密钥管理工具注入,不写死在配置文件启动后看docker logs,确认worker数量和启动命令正确模拟一次回滚:把镜像标签换成旧版本,确认能快速恢复服务这套步骤花不了多少时间,但能把绝大多数低级事故挡在发布之前。尤其是非root用户和健康检查两项,解决了运行期很多隐患,也让我在被安全扫描报告提醒的时候不用手忙脚乱补配置。最后再分享个小技巧:在Dockerfile里给镜像加上明确的LABEL,比如项目名称、git提交号、构建时间。这些信息在排查问题、定位版本时非常有用,有时候光靠镜像tag根本无法知道这个镜像是哪个提交构建出来的,有了LABEL就能一步查清楚。这套流程还可以继续往下扩展,比如把构建和测试接入自动发布流水线,或者在编排层加入自动扩缩容。但基础打得稳,往上叠东西才踏实。如果你正准备部署自己的第一个FastAPI容器,先把本文提到的Dockerfile、compose配置和检查清单过一遍,再去折腾更复杂的编排,后面会遇到的问题会少一大半。
如果你最近在写Python后端,大概率绕不开FastAPI;如果你负责把服务扔到服务器上跑起来,那Docker基本也是躲不掉的。这两个东西放在一起,已经成了当下Python Web服务上线的默认姿势。这篇文章围绕“docker部署fastapi”这件事,把完整流程、配置文件和踩过的坑一次性说清楚。不管你是刚写完第一个FastAPI接口的新手,还是已经上线过几个项目、但没认真抠过容器细节的同学,都能在里头找到直接能抄的内容。1. 用Docker跑FastAPI,到底图什么1.1 环境一致性是最直接的收益Python开发里最头疼的问题之一就是“在我机器上能跑”。开发机用的macOS,服务器用的某个Linux发行版,两边Python小版本不一样、依赖的底层库版本对不上、某些带C扩展的包在服务器上编译缺头文件,光是把环境对齐就能消耗大半天。Docker把应用和整个运行环境打包在一起,镜像跑到哪都是同一个系统、同一套依赖、同一个Python解释器。部署时只要机器能跑Docker,镜像拉下来启动,环境和本地完全一致,这种确定性比任何配置管理工具都来得省心。FastAPI对Python版本和依赖库有明确要求,比如需要Python 3.8以上,pydantic的版本也要配套。如果服务器上已经跑了别的Python服务,直接改全局Python版本或者翻pip全局安装包,很容易互相打架。容器化之后,每个项目有自己独立的解释器和依赖目录,互不干扰。这也是我后来把所有后端服务全部容器化的直接原因——不再为了每个项目的依赖隔离去折腾虚拟环境。1.2 FastAPI的异步模型和容器机制天然合拍FastAPI本身是异步框架,典型的进程模型是一个或多个Uvicorn worker进程。这种模型放在容器里管理非常顺:镜像启动命令直接拉起Uvicorn,Docker负责进程生命周期和资源限制,后续如果要做水平扩展,直接在编排层增加容器副本数就行,改造链路很平滑。另外一个合拍点是FastAPI的自动交互文档。容器化之后,开发、测试、生产环境的 /docs 路径可以保持统一,不需要因为部署位置不同去额外配置。只要把容器端口映射好,浏览器打开就能看到接口列表和在线调试面板,前后端联调的时候非常方便。1.3 Docker层缓存对Python依赖的加速效应Docker镜像的分层机制对Python项目特别友好。依赖安装和业务代码复制可以拆成两个层:依赖层很少变化,业务代码层每次发布都会更新。构建时只要依赖文件没变,Docker会直接复用已有的依赖层缓存,原来要几分钟的构建能缩短到几十秒。这一点看起来不起眼,但在一次构建要花大量时间、还可能触及CI流水线超时上限的时候,价值非常明显。我拿生活里的场景做个类比:镜像构建就像整理一个长期使用的工具箱,依赖层是那些不太动的常用工具,代码层是每次都要换的图纸。只要工具箱没变,每次只需要换图纸,不用把整个箱子推倒重来。理解了这一点,就能明白为什么后面Dockerfile里要把COPY命令的顺序设计得那么讲究。2. Dockerfile设计,重点是别写成玩具写法2.1 基础镜像:slim还是alpineFastAPI官方推荐用 python:版本-slim 作为基础镜像。slim基于Debian,体积比完整镜像小很多,同时保留apt包管理器,需要装系统级工具时比较灵活。也有一部分人会用alpine,体积更极端,但alpine的musl libc和Python的很多二进制依赖存在兼容性问题,比如pydantic-core这类带Rust扩展的包,经常要在构建时临时装一堆编译工具,反而拖慢构建速度。我的建议是:没有极端的体积要求,优先选slim,省事稳定。基础镜像版本要和本地开发环境尽量一致。比如本地用Python 3.11,镜像也写 python:3.11-slim,尽量避免解释器小版本差异带来的行为不一致。这一点在小团队里经常被忽略,但真出了问题,比如某个语法特性在不同小版本下表现不同,排查成本反而更高。2.2 依赖层和代码层的顺序决定构建效率COPY命令有一个容易被忽略的规则:Docker构建时,只要某一层内容变化,这一层以及之后所有层缓存都会失效。所以“改得越频繁的内容就越放后面”是基本优化原则。依赖文件requirements.txt通常几天才改一次,业务代码每次提交都会变。标准顺序是先把requirements.txt复制进镜像,执行pip install,然后再复制业务代码。FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app ./app EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]这段Dockerfile看起来简单,但有几点值得展开。第一,pip install加 --no-cache-dir 可以避免把缓存文件写进镜像层,镜像更干净,体积也更小,这个参数我实测下来对构建成功率没有负面影响,建议直接当成固定习惯。第二,WORKDIR设置成/app之后,所有相对路径都基于这个目录,后面挂载卷或者写日志时能少踩很多路径坑。第三,CMD里必须用 --host 0.0.0.0,这一步很多人会漏掉,后面单独说。2.3 非root用户运行是必须踩过的坑很多初学者会在容器里用root用户跑应用,开发时没感觉,一旦生产环境出问题就很被动。如果应用被攻击者利用漏洞拿到容器内执行权限,而容器是root,宿主机挂载目录、其他容器都会面临更大风险,安全扫描工具也会直接告警。标准做法是创建专用用户,用USER指令切换。FastAPI应用通常只需要访问自己的代码目录和临时文件,不需要root权限。下面这段是在之前的Dockerfile基础上补充用户创建和切换:FROM python:3.11-slim RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app ./app RUN chown -R appuser:appuser /app USER appuser EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]这里有个细节:COPY之后要给/app目录修改属主,否则切换到非root用户后,应用根本没有权限读取代码文件。我第一次写的时候拉掉了chown这一行,容器启动立刻报PermissionError,之后我每次都提醒自己先确认目录权限。另外,应用要写日志文件或者上传目录时,也需要保证对应路径已创建且属主正确,否则运行期会出现奇奇怪怪的写入失败。2.4 多阶段构建适合什么场景如果FastAPI项目是纯Python代码,没有依赖C编译的扩展包,单阶段构建就够了。但项目一旦涉及机器翻译库、图像处理库,或者需要编译复杂的C扩展,构建工具链和运行依赖会混在一起,镜像体积直接膨胀。多阶段构建可以把编译好的产物复制到干净的运行镜像里,丢掉所有编译工具和中间文件。以某个图像处理服务为例,第一阶段安装build-essential和编译依赖,完成pip install;第二阶段只复制site-packages和业务代码。最终运行镜像不包含gcc、make这些工具,攻击面小,体积也小。FastAPI项目如果包含二进制编译组件,建议按这个思路处理:FROM python:3.11-slim AS builder RUN apt-get update && apt-get install -y build-essential COPY requirements.txt . RUN pip install --prefix=/install -r requirements.txt FROM python:3.11-slim COPY --from=builder /install /usr/local COPY app ./app第二阶段继承干净的运行镜像,不装编译工具,最终体积往往只有第一阶段的一半甚至更低。依赖包在安装过程产生的编译中间文件,也会被天然隔离在builder阶段,不会带进最终镜像。3. docker-compose编排和服务配置实战3.1 单容器到多服务,文件化管理的必要性只部署一个FastAPI容器,docker run一条命令确实够了。但实际项目很少只有一个容器,通常还要配数据库、缓存、反向代理,甚至还有定时任务容器和迁移任务容器。用一条命令管理多个容器不是不行,但状态查询、网络隔离、环境变量传递都变得很零碎。docker-compose存在的意义就是把这些容器用一份YAML文件描述清楚,一条命令拉起整个环境,一条命令全部停掉,团队成员之间的操作方式也完全一致。FastAPI应用和数据库的联调在本地尤其方便。以前本地要装PostgreSQL、跑Redis组件,现在compose文件里定义几个服务,docker compose up之后就能得到一个和线上结构一致的环境。新成员加入时,只要拉下仓库、启动compose,不用碰宿主机环境,几分钟就能开始开发。3.2 一份可以直接抄的compose配置下面是我常用的FastAPI生产compose配置骨架,覆盖数据库、迁移、应用和基础健康检查。实际使用中根据项目需要增删服务就行。version: "3.8" services: db: image: postgres:15-alpine environment: POSTGRES_USER: appuser POSTGRES_PASSWORD: apppassword POSTGRES_DB: appdb volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"] interval: 5s timeout: 5s retries: 5 api: build: context: . dockerfile: Dockerfile environment: DATABASE_URL: postgresql+asyncpg://appuser:apppassword@db:5432/appdb depends_on: db: condition: service_healthy ports: - "8000:8000" volumes: - ./logs:/app/logs restart: unless-stopped migrate: build: context: . dockerfile: Dockerfile command: alembic upgrade head environment: DATABASE_URL: postgresql+asyncpg://appuser:apppassword@db:5432/appdb depends_on: db: condition: service_healthy volumes: pgdata:这份配置里有几个关键点要说明。第一个是迁移容器:项目用了Alembic管理数据库迁移时,我会把迁移任务做成临时容器,它启动后只执行alembic upgrade head,执行完就退出。这在CI/CD流水线里很好用,发布前先跑迁移容器,迁移成功再发新版本应用,避免应用起来之后数据库表还没建好。第二个是depends_on加condition: service_healthy,让应用容器等数据库健康检查通过后再启动,避免应用先起来连数据库失败、随后退出的竞态问题。3.3 开发环境和生产环境的配置差异开发环境要的是热重载和本地调试,生产环境要的是稳定和多副本,这两种需求不能押到同一个配置里。我会用多个compose文件配合环境变量做区分:docker-compose.yml放公共配置,docker-compose.dev.yml放开发配置,docker-compose.prod.yml放生产配置。开发配置里,应用容器不直接用镜像里的启动命令,而是挂载本地代码目录并覆盖启动命令,让Uvicorn以--reload模式运行:# docker-compose.dev.yml services: api: command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload volumes: - ./app:/app/app生产配置里则使用gunicorn作为进程管理器,用Uvicorn worker启动多个进程:# docker-compose.prod.yml services: api: command: gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000为什么生产环境要换gunicorn?单进程Uvicorn在生产环境下无法利用多核CPU,而且缺少成熟的worker管理机制,某个worker崩溃后需要外部手段重启。gunicorn负责fork多个worker、回收异常worker,配合UvicornWorker跑异步接口,是FastAPI官方文档也推荐的生产方式。worker数量一般按CPU核数乘以2再加1这个经验值来定,比如4核机器设置9个worker,当然具体还得结合业务IO特性调整。3.4 端口暴露和反向代理compose默认会给每个项目创建独立的网络,服务之间通过服务名互相访问,这个默认行为本身是安全的。实际中要特别注意,别把不必要的端口暴露到宿主机。FastAPI应用通常只需要对外暴露8000端口给API网关或者反向代理,数据库、Redis这类服务只在内网被访问,不需要映射端口。如果一定要在宿主机上连数据库调试,建议在开发配置里临时暴露,生产配置坚决不加。反向代理我一般用Nginx。容器里跑Nginx,监听80和443,做TLS终止和静态资源处理,再通过服务名转发到api容器。这个组合在真实部署环境里非常常见,配合证书自动续期,基本能覆盖Web服务的入口需求。FastAPI本身不擅长处理静态文件和TLS,把这些交给反向代理,应用容器就能更纯粹地处理业务逻辑。4. 上线前检查清单和常见坑位4.1 健康检查配置到位了吗如果没有健康检查,编排系统无法判断容器是活着还是在慢慢死掉。很多部署失败的场景是:容器进程没有退出,但应用内部已经无法处理请求,比如数据库连接池耗尽、某个线程被阻塞。这个时候Docker还认为容器是健康的,流量继续打进来,用户看到的就是无响应。FastAPI加健康检查只需要写一个简单接口,返回服务状态和依赖健康情况:from fastapi import FastAPI app = FastAPI() @app.get("/healthz") async def healthz(): return {"status": "ok"}更完整的实现会同时检查数据库连接和缓存连接,一旦某个依赖不可用就返回503。编排工具遇到非零退出码会重启容器,应用就能自动恢复。写在compose里,就是前面示例中api服务下面的healthcheck字段,测试命令指向这个接口。4.2 环境变量怎么管理才不泄露常见错误是把环境变量直接写死在compose文件里,本地开发虽然方便,但文件一旦进了Git仓库,密码和密钥就全部泄露了。我的做法是使用.env文件管理敏感配置,同时把.env加进.gitignore。compose文件里用 ${VAR} 占位符引用变量,实际值只存在本机的.env文件里。生产环境我倾向于在CI/CD流水线里通过密钥管理系统动态注入环境变量,不让敏感信息以明文形式落任何文本文件。这样即使配置仓库被人拿走,也拿不到真实密钥。代码层面用os.getenv或者pydantic-settings读取环境变量,运行时拿到什么就用什么,部署细节和代码实现之间彻底解耦。4.3 日志要往哪儿去容器模式下,应用日志应该输出到标准输出和标准错误,而不是写文件。Docker会把这两个流收集起来,通过docker logs命令查看。这样设计的好处是日志和容器生命周期绑定,容器删了,日志也不会在宿主机上堆积占空间。FastAPI配套的Uvicorn和Gunicorn日志默认就走标准输出,所以不需要专门配置日志文件。如果项目要长期留存日志做分析,更合适的做法是把容器stdout交给日志收集工具处理,在compose里挂载日志目录写文件不是首选,因为文件权限和轮转问题都要额外处理。除非有非常明确的留存需求,否则尽量走stdout这条路径。4.4 常见部署失败与排查速查表我在实际部署中遇到过不少典型问题,整理成一张表方便直接对照:现象可能原因排查方法解决方案容器启动后秒退依赖缺失或启动命令错误docker logs看启动日志核对CMD路径和依赖安装完整性端口映射后宿主机访问不了应用只绑定了localhost查看Uvicorn启动参数使用0.0.0.0绑定所有网卡数据库连接失败数据库未就绪或地址写错进入容器内测试连接使用compose服务名替代localhost,增加healthcheck镜像体积突然变大安装包缓存没清理docker history查看每层大小pip加--no-cache-dir,apt清理缓存构建缓存经常失效COPY顺序不合理检查Dockerfile文件变更频率依赖复制放前面,业务代码放后面容器运行期不能写文件目录属主和权限不对ls -l检查挂载目录构建阶段chown,挂载目录指定uid/gid第一次部署最常犯的错就是数据库连接地址。在宿主机上连接数据库用localhost没问题,但在compose网络里,数据库的地址是服务名,比如db,而不是localhost。DATABASE_URL必须写成类似 postgresql+asyncpg://appuser:apppassword@db:5432/appdb 的形式。我见过好几个同事把这个点忽略掉,跑到容器里排查半天,最后才发现是地址写错。4.5 升级和回滚的实操顺序发布新版本时,如果一直用同一个镜像标签覆盖,线上一旦出问题,旧版本镜像被冲掉,回滚非常麻烦。我给自己定了一条简单规则:每个新构建的镜像打上唯一标签,比如git提交的短哈希或者时间戳,compose里的镜像名跟着变。要回滚时,只需要把compose配置里的标签改回上一个值,重新执行docker compose up -d。这里补充一个容易踩的细节:即使版本号变了,某些环境里容器运行时可能因为镜像拉取策略默认值不同,继续使用旧镜像。在生产环境需要显式设置拉取策略,确保部署脚本执行时拉取的是指定标签的新镜像。发布过程最后会收敛成三个动作:构建并推镜像、更新compose镜像标签、拉起服务。整个过程可控而且可复现。5. 现在我的标准发布流程和最后几条建议每次部署前我会按下面这个清单走一遍,基本能规避大部分问题:本地docker compose up拉起整套环境,确认应用能正常访问 /healthz确认Dockerfile配置了非root用户,运行期写入目录权限正常构建镜像,检查体积和依赖层缓存是否生效生产compose只暴露必要端口,数据库等内网服务不映射端口配置好健康检查和depends_on条件,规避启动顺序竞态环境变量全部通过.env或者密钥管理工具注入,不写死在配置文件启动后看docker logs,确认worker数量和启动命令正确模拟一次回滚:把镜像标签换成旧版本,确认能快速恢复服务这套步骤花不了多少时间,但能把绝大多数低级事故挡在发布之前。尤其是非root用户和健康检查两项,解决了运行期很多隐患,也让我在被安全扫描报告提醒的时候不用手忙脚乱补配置。最后再分享个小技巧:在Dockerfile里给镜像加上明确的LABEL,比如项目名称、git提交号、构建时间。这些信息在排查问题、定位版本时非常有用,有时候光靠镜像tag根本无法知道这个镜像是哪个提交构建出来的,有了LABEL就能一步查清楚。这套流程还可以继续往下扩展,比如把构建和测试接入自动发布流水线,或者在编排层加入自动扩缩容。但基础打得稳,往上叠东西才踏实。如果你正准备部署自己的第一个FastAPI容器,先把本文提到的Dockerfile、compose配置和检查清单过一遍,再去折腾更复杂的编排,后面会遇到的问题会少一大半。