Docker部署FastAPI实战:从Dockerfile到Compose的完整指南
docker部署fastapi说穿了就是把你写好的FastAPI应用、Python解释器、还有所有依赖库打包成一个可运行的镜像然后丢到任意一台装好Docker的服务器上跑起来。我自己刚开始用FastAPI写接口时最头疼的就是本地跑得好好的一上服务器各种环境问题Python版本对不上、依赖装不齐、端口被占、进程莫名挂掉。后来整套服务改成容器化部署这个困扰才算彻底解决。所以今天想认真聊聊docker部署fastapi这件事把从零到一的操作流程和踩坑经历都摊开讲。这篇文章的重点不是给你一个只展示“Hello World”的最小Demo而是围绕真实部署场景展开Dockerfile怎么设计才合理、docker-compose.yml里每个配置项到底在干什么、容器启动后怎么验证、出了问题从哪里入手查。适合三类人看刚学会FastAPI想尝试容器化的新手、小团队里没有专职运维却要把服务跑稳的后端同学以及被“本地正常、服务器就崩”反复折磨的开发者。先说个前提我下面给的配置不是标准答案而是我实际用了很久、确认稳定的一套模板。你可以直接抄走改一改也可以理解了原理之后按自己的场景再调整。重点在为什么这么做而不只是怎么做。1. 整体思路与部署形态拆解1.1 为什么FastAPI项目值得做容器化FastAPI是当前写API服务很顺手的一个框架基于类型标注自动生成OpenAPI文档自带请求参数校验原生支持异步接口。我自己对它的评价是“开发体验和性能表现都很均衡”。但框架再好部署方式跟不上后面照样会出幺蛾子。裸机部署最大的坑是环境不一致。我见过不止一个项目开发者本地用的是Python 3.12服务器上装的是3.9跑起来某些依赖直接报错甚至有的语法在低版本解释器里根本不认识。还有依赖冲突问题一台服务器上跑好几个Python服务A项目要requests 2.28B项目要requests 2.31全局环境里装来装去时间一长谁也说不清楚当前到底处于什么状态。Docker解决的就是这种环境碎片化问题。镜像相当于把代码、依赖、运行环境乃至系统配置全部打包成一份不可变产物。打个比方这不是给你一张装修图纸让你到新家重新施工而是把已经装修好的房间原样打包带走到了新地方拆箱就能入住。镜像到什么机器上跑出来的行为都一样这是容器化部署最核心的价值。落到FastAPI场景里镜像里装什么非常明确Python解释器、项目代码、requirements.txt里的依赖、必要的系统库。对外暴露的就是一个HTTP端口逻辑干净。这样一个镜像既能给开发环境调试也能推到服务器跑生产行为和表现完全一致。1.2 单机Compose还是多机编排部署形态的选择我见过很多新手一上来就奔着Kubernetes去结果被各种概念绕得晕头转向。我的建议很直接先想清楚你的规模再决定方案。如果你只有一台服务器或者服务数量不超过十个Docker Compose是性价比最高的选择。Compose就靠着一个个docker-compose.yml文件把多个容器的启动方式、网络、卷、环境变量统一描述和编排。一条docker compose up -d就能把所有服务拉起重启策略、健康检查也都能在这个文件里配好。Kubernetes解决的是更大规模的问题多节点调度、服务发现、自动伸缩、滚动发布。这些能力确实强但代价是学习和运维成本都很高。小团队为了跑一个FastAPI服务就去搭一套K8s集群大概率是杀鸡用牛刀最后光维护集群本身就能耗掉大量精力。部署工具的复杂度应该跟业务复杂度匹配而不是跟技术时髦度匹配。所以这篇文章的主线落在单机Compose上。等将来流量和节点规模确实上去了再把无状态服务平滑迁移到更复杂的编排平台也不迟。容器化的核心好处比如镜像交付、环境隔离在Compose阶段已经完全能够享受到。1.3 开发环境与生产环境要刻意区分容器化部署里很容易犯的一个错误是把开发环境的习惯直接照搬进生产。开发时我们希望改代码立刻生效所以要挂载源码目录并启用热重载生产环境如果也这么干等于让容器里的代码跟着宿主机文件变动镜像的可复现性就被破坏了。我自己的习惯是分两套环境配置规则很简单开发和生产的代码逻辑可以一致运行方式必须分开。对比维度开发环境生产环境代码挂载挂载宿主机目录改代码即时生效代码打进镜像不挂载热重载uvicorn --reload禁用reload进程模型单进程即可gunicorn多worker日志级别调试级输出访问日志与错误日志分离环境变量.env.dev.env.prod这个区分很重要。生产环境追求的是稳定、可复现、可回滚而开发环境追求的是灵活、迭代快。两者混在一起很容易出现“我本地改了立刻生效怎么服务器上不生效”的困惑——其实不是不生效而是生产方式压根不该依赖文件变动。2. Dockerfile怎么写才优雅多阶段构建的3个关键细节2.1 项目目录结构与依赖管理在动笔写Dockerfile之前先把项目目录结构理清楚这一步很值得花点时间。很多人喜欢把代码直接堆在根目录Dockerfile、源码、测试文件全混在一起构建上下文乱七八糟写COPY路径时也特别容易出错。一个清晰的结构长这样fastapi-docker-demo/ ├── app/ │ ├── __init__.py │ ├── main.py │ ├── api/ │ │ └── health.py │ ├── core/ │ │ ├── config.py │ │ └── database.py │ └── models/ ├── requirements.txt ├── Dockerfile ├── .dockerignore ├── docker-compose.yml └── .env.exampleapp/放业务代码Dockerfile、docker-compose.yml放在根目录.env.example是环境变量模板真实密钥写在本地的.env里并且通过.gitignore排除掉坚决不提交进代码仓库。依赖管理这一块我用最传统的requirements.txt因为它简单、直观Docker构建时直接pip install -r requirements.txt就能干活。如果你的项目已经采用pyproject.toml也没问题只是Dockerfile里的安装命令要做对应调整。这里有一个必须养成的习惯依赖版本尽可能锁定不要写fastapi这种不带版本号的裸包。否则今天构建的镜像和三个月后构建的镜像依赖完全不是同一套线上出问题都没法稳定复现。锁版本虽然繁琐几秒钟但在容器场景里能帮你省掉后面大量定位问题的时间。2.2 多阶段构建镜像体积与运行安全兼顾多阶段构建是我强烈推荐的做法。它解决的问题很本质构建阶段需要用的工具运行阶段根本不需要。比如你要编译某些带C扩展的依赖需要gcc、make这类工具链但运行阶段只需要编译好的二进制文件。如果直接把带着编译器的镜像当作交付物体积会多出不少攻击面也更大。下面这份Dockerfile是我实际用下来比较顺手的模板FROM python:3.11-slim AS builder WORKDIR /app RUN apt-get update apt-get install --no-install-recommends -y build-essential COPY requirements.txt . RUN pip install --no-cache-dir --prefix/install -r requirements.txt FROM python:3.11-slim AS runner WORKDIR /app COPY --frombuilder /install /usr/local COPY app ./app ENV PYTHONUNBUFFERED1 \ PYTHONDONTWRITEBYTECODE1 EXPOSE 8000 CMD [gunicorn, app.main:app, -w, 4, -k, uvicorn.workers.UvicornWorker, --bind, 0.0.0.0:8000]逐步拆开讲。第一阶段叫builder基础镜像选的python:3.11-slim。为什么不用完整版python:3.11因为完整版镜像里塞了一堆文档和工具链体积明显更大。为什么不用alpine因为alpine的musl库和不少Python二进制的兼容性偶尔会出问题装带C扩展的包容易踩坑。slim基于Debian和完整版同源只是精简过对Python项目来说是体积与兼容性的折中。build-essential是给编译环境准备的。如果你的全部依赖都有预编译的wheel这一行可以去掉但保留更稳妥——某些依赖在slim镜像里没有对应轮子时现场编译就是兜底方案。最关键的一行是pip install --prefix/install它把依赖安装到一个自定义目录而不是系统解释器的site-packages。第二阶段只需要把整个目录拷过去再配上/usr/local路径就能被解释器识别。这样最终镜像里不需要pip不需要编译工具干干净净还能显著降低镜像体积。第二阶段runner是最终交付的镜像只做两件事从构建阶段复制依赖、复制业务代码。最后用gunicorn启动而不是直接uvicorn原因很简单uvicorn单进程只能吃一个CPU核心没有进程管理能力gunicorn作为进程管理器能派生出多个worker进程配合uvicorn.workers.UvicornWorker既保留uvicorn的异步性能又能把多核CPU用起来。这里有个容易踩的细节CMD我写成了exec形式也就是方括号数组语法。如果写成CMD gunicorn ...这种shell形式容器启动时会多包一层/bin/sh -c在某些信号转发场景下比如docker stop发送SIGTERM时gunicorn可能收不到信号最后只能被强制杀掉优雅停机就失效了。所以生产环境推荐exec形式。2.3 .dockerignore与镜像瘦身很多刚接触Docker的人会忽略.dockerignore然后发现每次构建都慢镜像还特别大。原因很简单docker build时默认会把Dockerfile所在目录的全部内容当作构建上下文发给Docker引擎。如果你把.git、node_modules、venv这种大文件夹一起传进去构建过程自然又慢又占资源。一份够用的.dockerignore长这样.git .venv venv __pycache__ *.pyc .env .env.* !.env.example Dockerfile .dockerignore .gitignore注意最后那两行排除.env相关文件但保留.env.example模板。真实密钥绝对不能进入构建上下文这个底线要守住。一旦密钥被写进镜像层再想清除就得重新构建并处理历史版本非常被动。镜像瘦身除了多阶段构建还有几个实用技巧安装依赖时加--no-cache-dir避免pip缓存进镜像apt-get装包后顺手清理/var/lib/apt/lists/*尽量合并RUN里连续的命令减少镜像层数基础镜像优先选slim而不是完整版。构建完可以用docker images看一下实际大小。一个只跑FastAPI的镜像多阶段处理后通常能控制在200MB以内这已经算很健康的状态了。3. Compose编排与参数选择从启动到验证一步不落3.1 一份能直接改改就用的docker-compose.yml镜像负责解决怎么打包Compose负责解决怎么跑。如果你的服务只有一个FastAPI容器用docker run也能凑合但一旦加了数据库、队列、缓存命令就会变得又长又难维护。Compose把这些全部写进一个YAML文件团队里任何人拿到项目都能一键启动。下面是一份包含FastAPI服务和PostgreSQL数据库的完整示例如果你的项目暂时不需要数据库把db服务整个删掉就好services: api: build: . image: fastapi-docker-demo:latest container_name: demo-api restart: unless-stopped ports: - 8000:8000 env_file: - .env depends_on: db: condition: service_healthy healthcheck: test: [CMD, python, -c, import urllib.request; urllib.request.urlopen(http://localhost:8000/health, timeout3)] interval: 30s timeout: 5s retries: 3 start_period: 10s networks: - demo-network db: image: postgres:16-alpine container_name: demo-db restart: unless-stopped environment: - POSTGRES_USERuser - POSTGRES_PASSWORDpass - POSTGRES_DBfapi_demo volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U user -d fapi_demo] interval: 10s timeout: 5s retries: 5 networks: - demo-network volumes: pgdata: networks: demo-network: driver: bridge有几点可以注意。Compose文件里我没写version字段因为新版Docker Compose v2已经基本忽略这个字段了写不写都不影响运行。container_name是自定义容器名同一台机器跑多个项目时注意别重名。image和build同时出现时Compose会先构建镜像然后打上image指定的名字后续部署和回滚都能直接用镜像名操作。3.2 端口、环境变量、健康检查与重启策略逐个拆解这些配置背后的逻辑。端口映射8000:8000表示把宿主机8000端口映射到容器内的8000端口。对外只暴露API端口就够了PostgreSQL不需要用ports暴露到宿主机。容器间通信走Compose内部网络直接通过服务名db访问就行根本不需要绕一圈经过宿主机端口。这样做既减少了攻击面也避免了一堆端口暴露引起的冲突问题。env_file: .env会把宿主机.env文件里的键值对注入容器。数据库连接信息、密钥这类配置不需要写死在镜像里环境变了只改.env镜像完全不用重新构建。这和“镜像不可变”的理念并不冲突镜像保证代码和依赖的一致性环境变量负责不同环境之间的配置差异。depends_on这里有个很容易踩的坑。它本身只控制容器启动顺序不能保证数据库已经“可用”。PostgreSQL容器启动和“能接受连接”是两回事前者可能几秒完成后者也许还要初始化数据目录、加载配置。所以我在db上加了healthcheck用pg_isready判断数据库是否真正就绪然后在api的depends_on里写上condition: service_healthy。这样Compose会等到数据库健康了才启动API容器避免应用起来后拿着连接串一通狂连全部失败。API自己的健康检查也不能省。容器进程活着不代表服务可用进程可能在但端口没监听或应用卡死。slim镜像里默认没有curl所以我用Python自带的urllib请求/health接口避免为了健康检查额外装工具。start_period: 10s的意思是给应用留10秒启动时间这期间的检查失败不计入重试次数防止服务启动稍慢就被误杀重启。重启策略restart: unless-stopped表示容器因异常退出时自动重启除非是你手动docker stop停掉的。生产环境建议都用这个省得半夜服务挂了没人拉第二天早上用户先发现。不用纠结这个策略是不是最好它已经是单机场景下性价比很高的选择。3.3 Gunicorn Worker数量怎么定worker数量怎么定网上流传最广的公式是2 * CPU核心数 1这个说法来自gunicorn官方文档对CPU密集型任务的建议。FastAPI接口多数属于I/O密集型比如查数据库、调外部HTTP服务所以可以适当多开一些worker但也不能盲目加。我习惯的路径是先按公式给一个保守值再压测验证。比如一台2核4G的服务器公式给的是2 * 2 1 5实际可以先开4个worker观察CPU、内存、响应时间的变化再决定往上调还是往下调。加worker时重点盯内存每个worker都会载入一份应用代码占几十到一两百MB很正常5个worker就是几百MB起步。内存只有4G的机器如果业务数据量还大worker开多了反而容易触发OOM。除了-w还有几个参数在生产里值得关注--timeout 30默认30秒如果某些接口执行时间偏长比如导出报表、批量计算一定要调大否则worker会被gunicorn误杀客户端收到504--keep-alive 5HTTP长连接保持时间频繁请求时影响明显--max-requests 1000worker处理一定数量请求后自动重启能有效规避内存慢慢堆高的问题所以我实际推荐的启动命令会比默认写法更稳妥gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --timeout 60 --max-requests 1000 --max-requests-jitter 100--max-requests-jitter 100是让重启时机带上随机抖动避免多worker同时到达上限一起重启造成请求抖动。3.4 从构建到启动一条龙操作流程配置写完后实际操作流程并不长。我习惯的完整操作是# 1. 构建镜像并启动 docker compose up -d --build # 2. 查看所有服务当前状态 docker compose ps # 3. 跟随查看API服务日志 docker compose logs -f api # 4. 验证健康检查接口 curl http://localhost:8000/health第一次构建会比较慢因为要下载基础镜像并安装依赖。第二次构建如果依赖没变Docker会利用层缓存过程通常很快。curl返回类似{status:ok}的JSON时说明服务已经正常起来。再用docker compose ps看状态如果显示Up (healthy)说明健康检查也通过了。之后的日常更新流程更简单改代码跑docker compose up -d --build它会自动重新构建镜像并重启有变动的服务。不需要先down再upCompose会自己处理好。4. 踩坑实录常见问题与排查思路4.1 容器启动后立刻退出这是新手遇到最多的情况docker compose up -d执行完过两秒再看容器已经Exited了。千万别慌第一步永远是看日志docker compose logs api日志里通常会给出明确线索。我遇到的典型错误有三类。一是ModuleNotFoundError: No module named app说明CMD里的模块路径不对或者WORKDIR下的目录结构里没有app这个包。检查Dockerfile里的WORKDIR /app和COPY app ./app是否匹配。二是Address already in use宿主机8000端口被别的进程占了用ss -lntp | grep 8000或lsof -i:8000查一下。三是日志完全空白就退出多半是CMD写法有问题容器找不到可执行命令。检查CMD的exec形式是否给出了完整的可执行文件路径。4.2 健康检查一直失败健康检查配了但docker ps显示状态一直是starting或unhealthy常见原因主要有三个。第一镜像里没有curl健康检查命令却用了curl -f ...。slim镜像默认不带curl命令直接失败。解决办法是用Python的urllib或者把curl装进镜像但前者明显更省体积。第二接口路径和实际路由对不上。健康检查访问的路径必须是应用里真实存在的FastAPI项目里我一般专门写/health路由并在这个接口里顺带检查数据库、缓存的连接状态。如果依赖还没就绪/health返回500健康检查自然失败。第三start_period设置太短。应用迁移或首次启动时可能需要十几秒如果启动宽限期只有3秒健康检查失败就会直接计入重试服务还没起来就被标记失败。建议根据自己的项目实际启动速度留够余量。4.3 容器内连不上数据库FastAPI容器连接PostgreSQL时最容易犯的错是连接串里写了localhost或127.0.0.1。在Compose网络里这两个地址指向容器自身根本不是数据库容器。正确的做法是把Compose文件里数据库服务的名字当作主机名DATABASE_URLpostgresqlasyncpg://user:passdb:5432/fapi_demo这里的db就是Compose文件里PostgreSQL服务的名称Compose内置的DNS会自动把这个服务名解析成对应的容器IP。记住这条规则在Compose网络内主机名用服务名不要用localhost。如果是连接云上的托管数据库那直接填对应的内网或公网地址同时检查容器出站网络、白名单和安全组设置。还有一种和Docker无关但部署阶段很容易暴露的问题异步框架配了同步驱动。FastAPI用SQLAlchemy异步模式时要用asyncpg或aiomysql这类异步驱动如果误用同步驱动事件循环会被阻塞性能会非常难看。4.4 时区、日志与接口超时问题容器默认时区是UTC日志时间戳会比北京时间慢8个小时业务时间显示也会跟着错乱。解决办法很直接在Compose的environment里加TZAsia/Shanghai或者把宿主机的时区文件挂载进去environment: - TZAsia/Shanghai日志方面我的建议是让应用直接往标准输出写日志。Docker会把容器的标准输出收集起来docker compose logs能直接查看将来接日志采集系统也方便。不要在容器里额外装rsyslog也不必把日志写成容器的本地文件。容器是临时实体一切可观测信息应该走stdout这样容器重建、迁移时日志才不会丢。接口超时也是生产环境一个高频坑。gunicorn默认30秒超时如果你的FastAPI业务里有跑批、报表、大文件上传后处理这类长耗时接口worker会被误杀并返回504。应对办法是调大--timeout同时尽量把重逻辑拆出来交给异步任务队列处理。容器化能做到进程稳定但如果应用本身设计上存在长阻塞那终究是治标不治本。5. 再往前走一步镜像仓库、CI/CD与基础资源管理5.1 镜像仓库与自动化构建如果你只是在本地或一两台服务器上折腾前面几章已经够用了。一旦团队有多台服务器或者想把交付流程固定下来下一步就该把镜像推到仓库管理。思路很直接本地或CI里构建镜像docker tag打上版本号再docker push推到私有镜像仓库服务器上执行docker compose pull然后docker compose up -d完成更新。镜像仓库可以自己搭也可以直接用现成的镜像仓库服务关键是让镜像成为团队内正式的交付物而不是只存在于某台机器的本地目录里。引入CI/CD之后流程会变成代码提交到仓库自动化流水线执行测试、构建镜像、推送镜像服务器自动拉取并重启服务。这个阶段的核心价值是交付流程完全可重复。人工部署难免漏步骤自动化部署是固定的、可预期的不会因为某个同事今天状态不好就少执行一条命令。5.2 资源限制与基础探活生产环境我建议在Compose里给容器加上资源限制防止某个服务内存泄漏把整台机器拖垮deploy: resources: limits: cpus: 1.0 memory: 512M这个配置在单机Compose模式下同样生效。具体数值可以根据压测结果调整但原则很明确一台机器上所有容器的资源上限之和必须小于机器总资源留出操作系统本身的运行余量。探活方面healthcheck加restart: unless-stopped已经是最基础的组合。再往前走一步可以在宿主机上写一个简单的定时探测脚本每分钟curl一次对外服务地址连续失败就触发告警。这些做法看起来朴素但对小团队来说往往比上一堆复杂的监控系统更实际、更可靠。5.3 一点个人体会整套流程从零到一走完我最大的体会是容器化解决的是环境层面的问题不是代码质量的问题。镜像再标准、Compose再稳定应用本身存在连接泄漏、内存泄漏时该出的问题一样出。但反过来说正因为有了容器化你排查问题时能先把环境因素排除掉一门心思去查代码和依赖这种“缩小排查范围”本身就是效率的巨大提升。我现在维护的几个FastAPI服务都跑在同一套Compose模式里新服务要上线时复制一份目录改改服务名和环境变量半小时就能把整套东西拉起来。用一套经过验证的流程去套新项目比每次都从零开始配环境舒坦太多。如果你正准备部署一个FastAPI项目或者正在被环境问题反复折磨不妨按这篇文章的思路试一次。先把Dockerfile写好再把Compose配好最后把健康检查和重启策略加上。跑通之后你会发现部署这件事其实可以很安静。