
如果你已经把 FastAPI 的接口写到自己都想点赞的程度那你很快就会遇到一个绕不开的门槛怎么让这东西在别人的服务器上跑起来。Docker 部署这件事对后端开发者来说就像驾照最后的路考——前面写得再顺这一步过不了项目就永远停在本地。这篇是系列第 28 篇正好卡在一个承上启下的位置前面的依赖注入、异步 SQLAlchemy、中间件、任务队列你都熟了现在要解决的是如何让这套代码脱离你的开发机在任何一台普通 Linux 服务器上稳定运行。我的观点很直接FastAPI 上生产环境Docker 不是可选项是默认项。这篇文章不会带你背命令而是把镜像怎么写、compose 怎么排、反代怎么配、坑怎么踩一次讲明白。1. 先想清楚FastAPI 部署用 Docker 到底解决了什么我见过太多本地跑得好好的一上服务器就崩的项目。现象五花八门本地 Python 3.11 跑得飞起服务器装的是 3.8语法不兼容pip 装依赖时某个包编译报错因为服务器缺了系统库甚至同一个 requirements.txt两台机器装出来的包版本都不完全一致。说白了你之前一直在用虚拟环境隔离 Python 包的依赖但它隔离不了操作系统的差异。Python 版本、glibc 还是 musl、有没有 libpq、libssl、gcc这些全都在你的环境控制范围之外。Docker 解决的就是这个根本问题把运行环境本身打成镜像和环境一起分发。镜像里不只有你的代码还有经过验证的 Python 版本、系统依赖、pip 安装结果。服务器上只需要一个干净的操作系统和 Docker 引擎不需要预先配置任何开发环境。你拉下来的镜像在本地怎么跑在生产就怎么跑行为是一致的。这一点带来的部署心态变化是决定性的——从调环境变成跑镜像服务器的角色退化成了一个纯执行外壳。有人会问那我在服务器上建个 venv装好依赖用 systemd 拉起 uvicorn 不也一样吗对单服务、单机、不扩容的场景确实够用。但如果你的项目要连 PostgreSQL、连 Redis、后面还要加 Nginx 反代再考虑到你需要在多台机器上保持一致手工维护这套状态会非常痛苦。Docker 部署的本质是让环境状态变得可控、可复制、可回滚镜像不可变容器可替换配置全部外置成环境变量。你不会想去 SSH 进服务器手动改某个配置文件那等于把线上环境变成了一个不可复现的黑盒。还有一个新手常见的心理障碍总觉得 Docker 是运维的事写代码的不该操心。实际上在今天这个开发节奏下部署已经是开发工作流的一部分。你写 API 的时候就会考虑镜像构建、启动命令、健康检查上线才会顺利。所以我建议你的学习路径是先把一个 FastAPI 容器跑稳再玩 compose 编排最后才考虑 Kubernetes 那套东西。很多人一上来就研究 K8s自己的镜像还是坏的纯属拿大炮打蚊子。2. 基础镜像选择与 Dockerfile 关键设计2.1 镜像选型的取舍为什么我推荐 slim基础镜像是 Dockerfile 的第一行也是很多人踩坑的开始。打开 Docker Hubpython 官方镜像下面一排 tagpython:3.11、python:3.11-slim、python:3.11-alpine、python:3.11-bookworm等等。三个主要选项的体积差异很直观完整版大约 1GBslim 大约 150MBalpine 大约 50MB。单看数字alpine 完胜于是不少教程都推荐用 alpine 来精简镜像。我对 alpine 的态度是能用但别无脑用。alpine 的底层不是常见的 glibc而是 musl libc很多 Python 包在 PyPI 上只提供针对 glibc 编译的 wheel 文件。你 pip install 的时候 pip 找不到现成的 wheel就会自动切到源码编译编译又需要 gcc、musl-dev、python3-dev 这些编译工具链。换句话说一个在 slim 镜像里 5 秒装完的 psycopg2在 alpine 里可能要装五分钟还可能报编译错误。省下来的那 100MB 磁盘空间换不来这个折腾成本。slim 是 Debian 的官方精简变体保留 glibc绝大多数二进制 wheel 可以直接用。对一个 FastAPI SQLAlchemy PostgreSQL 的项目来说slim 是省空间和省时间之间的均衡解。我个人的经验基准开发调试用完整版或者直接用 slim产线长期跑用 slim如果某天你确实遇到体积敏感的场景比如镜像要多地分发再研究 distroless 或者多阶段构建那是后话。另外务必钉死 tag不要写FROM python:latest。我有一位同事的教训至今被我反复引用某天他重新构建镜像latest 悄悄换了小版本依赖装的版本跟 lock 文件不一致线上出现了一个只在生产环境出现的诡异行为。可复现的部署第一步就是镜像版本身份明确。2.2 一个能直接用的 Dockerfile先给一份经过生产验证的 Dockerfile再逐行说为什么这么写FROM python:3.11-slim ENV PYTHONDONTWRITEBYTECODE1 \ PYTHONUNBUFFERED1 \ PIP_NO_CACHE_DIR1 \ PIP_DISABLE_PIP_VERSION_CHECK1 WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . RUN useradd --create-home --shell /usr/sbin/nologin appuser \ chown -R appuser:appuser /app USER appuser EXPOSE 8000 HEALTHCHECK --interval30s --timeout5s --start-period20s --retries3 \ CMD python -c import urllib.request; urllib.request.urlopen(http://127.0.0.1:8000/health) CMD [gunicorn, app.main:app, --worker-class, uvicorn.workers.UvicornWorker, --bind, 0.0.0.0:8000]逐行拆解背后的逻辑你才知道怎么根据自己项目调整。PYTHONDONTWRITEBYTECODE1让 Python 不要在容器里生成__pycache__目录。容器本来就是一次性的pyc 文件不但占空间还会让镜像每层内容变得更乱。PYTHONUNBUFFERED1让 Python 的标准输出不做缓冲日志实时打到 stdout。如果你用了 print 做调试日志没有这一行docker logs 里经常会看不到输出因为输出还在缓冲区里没 flush。这也是新手排查半天容器明明起来了但没日志的最常见原因之一。PIP_NO_CACHE_DIR1pip 默认会把下载的 wheel 缓存到容器里这些缓存毫无用处还会显著增大镜像体积。关掉。然后注意这个顺序先COPY requirements.txt .执行RUN pip install最后才COPY . .。顺序不是随手写的是为了吃 Docker 的层缓存。你的项目代码变化频率极高每次提交都要改而 requirements.txt 和依赖列表变化频率低。构建镜像时只要这一层的输入没变Docker 会直接复用上一次构建的缓存层。如果你把整个项目 COPY 放前面那么任何一行代码的改动都会让后续所有层全部重跑包括耗时最长的 pip install。把依赖安装放前面日常开发重新构建往往只需要几秒钟而不是几分钟。USER appuser不要用 root 跑应用。容器隔离不彻底一旦你的应用被攻破root 权限的容器意味着攻击者对整个主机有更大的操作空间。创建专用的非特权用户是标准做法。注意如果你需要写挂载盘比如上传目录挂进来目录的属主要有 appuser 的写权限否则容器里会报 Permission denied这个坑等下再说。HEALTHCHECK固定目录里最容易被忽略的一段。默认镜像里没有 curl你又不想为了健康检查去装一堆工具直接用 Python 的 urllib 请求你的 /health 接口最省事。--start-period20s是给应用启动留的宽限期不然每次重启都会报健康检查失败。2.3 .dockerignore被大多数人忽略的构建加速器Docker 构建的第一步是收集构建上下文——你执行docker build时Docker 会把当前目录下所有文件发送给构建守护进程这就是所谓的 build context。如果你没有配置排除规则本地的.venv、.git、__pycache__、测试目录、甚至.env文件都会被无条件打包上传。我见过一个前端资源较多的项目构建上下文有 800 多MB每次 build 光上传就花两分钟实际上代码只要 2MB。在项目根目录创建一个.dockerignore文件内容和.gitignore逻辑类似.venv/ venv/ .git/ .gitignore __pycache__/ *.pyc .env .tests/ docs/ .idea/ .vscode/这里面有一条安全红线.env必须排除。如果你在服务器上执行docker build注意构建上下文一旦包含.env它就会被作为层的内容永久留在镜像历史里。哪怕镜像最终只 COPY 了代码历史层里也有你的数据库密码。环境变量的注入应该放在容器运行期完成而不是打进镜像。2.4 健康检查接口别只写一个 200 就完事很多 FastAPI 项目的 /health 长这样app.get(/health) async def health(): return {status: ok}这个接口只能证明进程活着证明不了服务可用。更好的做法是让健康检查带上依赖服务的最小探测比如快速查一次数据库是否可连接app.get(/health) async def health(session: AsyncSession Depends(get_db_session)): await session.execute(text(SELECT 1)) return {status: ok, db: up}不过注意频率如果你 5 秒检查一次每次查库对数据库就是无意义的连续打扰。健康检查间隔宁可松一点默认 30 秒即可。这个接口本身要写得足够轻不要带复杂业务逻辑否则它自己反而会成为新的不稳定点。3. 用 docker-compose 把 FastAPI 和数据库编排起来3.1 为什么不是一行 docker run 到底到现在为止你其实可以用一个 docker run 命令把容器拉起来。但你的项目大概率不止一个服务FastAPI 应用、PostgreSQL、Redis将来还有 Nginx。如果每个服务都靠手敲 docker run你需要记住每个命令的端口映射、网络、卷、环境变量参数。每次重启都要在 shell 历史里翻半天换台机器部署等于重新脑力劳动。docker-compose 做的事情是把一组服务用声明式 YAML 描述出来。compose 文件就是整个部署架构的文档跟着 git 走换机器、换环境、恢复部署都靠同一份文件。我个人有个习惯本地开发和生产共用同一份 compose 文件差异只通过.env切换。环境之间越接近你遇到本地没问题线上崩的概率越小。3.2 一份典型的 FastAPI 编排文件直接看一份可以落地的 compose 配置services: app: build: . ports: - ${APP_PORT:-8000}:8000 env_file: - .env depends_on: db: condition: service_healthy redis: condition: service_started db: image: postgres:16-alpine environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}] interval: 5s timeout: 3s retries: 5 redis: image: redis:7-alpine command: redis-server --appendonly yes volumes: - redis_data:/data volumes: postgres_data: redis_data:讲几个值得注意的细节。ports这一行生产环境建议写成127.0.0.1:8000:8000只暴露给宿主机本机。如果 Nginx 也以容器方式跑在同一个 compose 网络里应用端口根本不需要映射到宿主机服务之间通过内部网络访问。端口暴露面越小越安全。depends_on的老版本只会等容器启动不会等服务可用。PostgreSQL 容器启动了并不代表它已经接受连接了。没有健康检查条件时你的 app 一启动就去连数据库大概率连不上然后直接退出。新版 compose 支持condition: service_healthy让 app 等待数据库完全就绪。注意如果你用的 Docker 版本较老老的 compose 不认识这个字段建议装 compose v2 插件。volumes这一段是数据持久化的关键。容器是随时可以删除重建的如果你不挂数据卷数据库文件就存在容器可写层里容器一删数据灰飞烟灭。命名卷named volume由 Docker 管理数据存放在宿主机的 docker 目录下重新创建容器会重新挂载同一份数据。我在动手敲任何数据库容器之前都先问自己一句数据放哪了这是一个值得养成肌肉记忆的反射。3.3 数据库连接串里那个致命的 localhost新手第一次把 FastAPI 放进容器最常见的报错是Connection refused。看代码数据库地址明明写的localhost为什么连不上因为 compose 里每个服务默认在独立的网络命名空间里服务之间通过服务名互相访问。你的 app 容器里的 localhost 指向的是自己而不是 db 容器。所以 SQLAlchemy 的连接串必须写服务名DATABASE_URLpostgresqlasyncpg://user:passworddb:5432/mydb注意这里db是 compose 里数据库服务的名字不是域名也不是 IP。这个容器内 localhost 是隔离的概念如果不建立后面还有更多网络问题折磨你。另一个相关的问题是数据库迁移。项目上线前你得执行 alembic upgrade head但应用容器里已经起了服务你怎么在不让服务启动的情况下执行命令两种常用办法一是临时跑一个一次性容器docker compose run --rm app alembic upgrade head二是写一个启动脚本每次容器启动先跑迁移再起服务。第一种手工方式适合发布频率低的场景第二种适合需要自动化发布的场景。#!/bin/sh set -e alembic upgrade head exec $在 Dockerfile 里把它设为 ENTRYPOINTCOPY --chownappuser:appuser entrypoint.sh /app/entrypoint.sh RUN chmod x /app/entrypoint.sh ENTRYPOINT [/app/entrypoint.sh]这个脚本最后一行的exec $是精髓。exec会用启动脚本的进程替换掉当前 shell 进程让 gunicorn 成为容器内的 PID 1。如果不写 exec容器里的进程树就是 shell → gunicornDocker 停止容器时发 SIGTERM 信号给 PID 1那个 shell而 shell 不一定把信号转发给子进程导致你的 gunicorn 收不到温和停机的信号请求可能被粗暴掐断。这个细节关系到生产环境的优雅退出值得在脚本里专门注意。3.4 日志别裸奔容器会吃满磁盘容器默认日志驱动会把 stdout 全部写到宿主机的/var/lib/docker/containers/...下不设上限的话一个忘了处理异常日志的服务能慢慢把磁盘写满。在 compose 里给每个服务统一定上滚动策略logging: driver: json-file options: max-size: 20m max-file: 5这样单个服务日志最多占用 100MB老日志自动清理。你的应用日志应当一律输出到 stdout而不是在容器里写文件。Docker 生态里日志收集器都是从 stdout 抓日志的你如果让应用自己写日志文件反而会跟宿主机和日志平台完全脱节。4. 生产部署反向代理、worker 数计算与热更新陷阱4.1 生产环境别再裸跑 uvicorn开发环境用uvicorn app.main:app --reload很舒服但生产环境单起一个 uvicorn 进程有几个问题一是多核 CPU 用不满单进程无论怎么调事件循环也只能吃一个核二是进程崩溃没有自动拉起机制除非依赖 Docker 的重启策略。生产标准做法是用 gunicorn 做进程管理器让 uvicorn 以 worker 的形式跑command: gunicorn app.main:app --worker-class uvicorn.workers.UvicornWorker --workers 4 --bind 0.0.0.0:8000 --timeout 60 --graceful-timeout 30 --access-logfile -这里--timeout不是请求超时而是 worker 静默超时——如果 worker 在这个时间内没有跟 gunicorn 通信比如代码阻塞了gunicorn 就把它杀掉重启。如果你的接口里有长时间运行的任务这个值要调大否则运行到一半的请求会被误杀。--graceful-timeout是平滑重启时给 worker 处理完手头请求的时间宽限过了这个时间才强制杀掉。把 access log 打到-stdout方便统一收集。4.2 worker 数量到底怎么算网上最常看到的公式是2 * CPU 核数 1这个公式来自 gunicorn 官方文档但它是针对同步 worker 的经验值。FastAPI 配 UvicornWorker 本身就是异步 worker每个 worker 能同时挂着几千个并发请求瓶颈通常不在 worker 数反而在后面的数据库连接池和内存预算。我的经验做法先按 CPU 核数的 1 到 2 倍起步。2 核 4G 的机器先从 3 到 4 个 worker 开始跑一段时间看监控。如果 CPU 平均负载不高但是请求延迟高说明阻塞在等待外部资源数据库、第三方 API加了 worker 未必有用先查慢查询和连接池配置。如果 CPU 打满且内存余量充足再加 worker。不要一上来就配 16 个 worker 在 2 核机器上——每个 worker 都是独立进程FastAPI SQLAlchemy 的组合实测空闲内存经常在 150MB 到 250MB16 个 worker 直接把 4G 内存吃穿进程被 OOM kill 是必然的。另外一个容易忽略的点如果你用了 SQLAlchemy每个 worker 默认会创建自己的连接池。你要确保pool_size乘上 worker 数不要超过 PostgreSQL 的max_connections默认 100。很多人 API 一压测就报数据库连接不够不是数据库配置有问题而是开了 8 个 worker每个池 20 个连接一共 160 个连接直接打爆了数据库。4.3 Nginx 反代让 FastAPI 拿到真实客户端 IP生产环境一般不会让应用容器直接暴露 80 端口对外前面架一个 Nginx 做反向代理顺便处理静态文件、HTTPS 证书、请求体大小限制。一份最基础的反代配置长这样upstream fastapi_app { server app:8000; } server { listen 80; server_name yourdomain.example; location / { proxy_pass http://fastapi_app; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } client_max_body_size 20m; }proxy_set_header里那两行是关键。FastAPI 在容器里拿到的对端 IP永远是 Nginx 容器的 IP而不是真实用户的 IP。你如果做登录风控、访问日志、限流依赖的都是X-Forwarded-For。配置了转发头还不行你还得让 FastAPI 的服务端信任这个代理。gunicorn 默认不信任任意代理 IP需要显式声明--forwarded-allow-ips*这个参数只在内部网络里安全——你的 Nginx 和应用在同一个 compose 网络里外部流量进不来。如果你还想保持更强的隔离也可以用127.0.0.1或者你 Nginx 容器的内网 IP不过 include 在 docker compose 网络下*是常见做法。如果你服务里有 WebSocket 功能Nginx 这一段要单独处理。WebSocket 需要升级连接默认反代配置会把 Upgrade 头吃掉导致握手失败location /ws/ { proxy_pass http://fastapi_app; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; }FastAPI 写异步 WebSocket 很方便部署时最容易漏掉的就是升级头。我亲眼见过一个在线客服项目本地联调一切正常部署上线后客户端连不上 WebSocket排查了半天发现是 Nginx 没转发 Upgrade 头。4.4 上线前必须关掉的三个开发习惯第一--reload绝对不要出现在生产环境。它的本意是开发时监听代码变化自动重启到了生产环境只会白耗 CPU 做文件监视而且会让程序因为无关的代码变更反复重启造成不必要的流量中断。第二不要用docker compose up -d直接跑完就结束还要注意构建配置的来源。很多人开发时图方便compose 里直接写build: .加--reload代码挂载卷进去改完自动生效。这个模式带到生产环境是反模式生产应该用构建好的镜像运行而不是把源代码卷进容器。代码变更后的正确流程是重新构建镜像 → 重新创建容器让运行的版本和 git 记录一一对应。第三配置项不要硬编码。数据库地址、密钥、第三方 API key这些一律通过环境变量注入。建议你在 FastAPI 项目里用 pydantic-settings 做配置管理这样配置变更不用改代码重新构建只需修改 compose 的 env 配置重建容器。4.5 一份最小可用的上线检查清单每次发布前我会按这个顺序过流程可以当作你自己的操作模板# 1. 本地构建并验证 docker compose build docker compose up -d docker compose ps curl http://127.0.0.1:8000/health # 2. 看日志确认启动过程干净 docker compose logs app --tail 50 # 3. 如果连了数据库检查迁移状态 docker compose run --rm app alembic current # 4. 验证反代和公网访问 curl -I https://yourdomain.example/api/v1/health curl -H X-Forwarded-For: 203.0.113.10 http://127.0.0.1:8000/health # 5. 确认容器重启策略和资源限制已配置 docker inspect container-id | grep -A5 RestartPolicy # 6. 压测一遍基础接口看好 worker 数和内存曲线 docker stats没有压测工具的至少用 curl 并发循环打几十个请求观察响应时间有没有明显劣化、有没有 5xx 报错。5. 上线后的常见问题与排查实录这部分是实战里最值钱的环节。我把踩过的坑按常见程度排个序每一项都给了直接可查的排查路径。5.1 容器启动秒退现象docker compose up之后容器立刻退出docker compose ps显示 Exited。排查第一步永远是看日志docker compose logs app --tail 50。最常见的几个原因和对应日志特征日志特征原因解决办法python: cant open file /app/main.py: No such file or directoryWORKDIR 或 CMD 路径写错代码没 COPY 到预期位置检查 Dockerfile 的 WORKDIR 和 COPY 路径ModuleNotFoundError: No module named app项目结构和启动模块路径不对确认 gunicorn 里app.main:app和实际包名一致Connection refused ... db服务启动顺序问题或连接串主机名写错检查 DATABASE_URL 是否使用 compose 服务名没有日志直接退出启动脚本可能exit 0了或者资源限制docker inspect看 OOMKilled 字段第二个问题是资源限制。小机器上构建的镜像很大运行内存不足容器会被系统 OOM killer 杀掉。docker inspect container | grep OOMKilled如果显示 true基本可以确认是内存不够。5.2 端口冲突Address already in use服务器上可能已经有一个服务占了 8000 端口。先ss -lntp看哪个进程在用再决定改哪个端口。compose 里的映射是宿主机端口和容器端口分开的左边是宿主机端口右边是容器端口ports: - 8001:8000这样容器内部还是 8000宿主机可以用 8001 访问。做端口映射时养成习惯容器端口别乱改它由应用监听决定宿主机端口才用来做对外调配。5.3 数据库数据消失现象容器删掉重建数据全没了。几乎所有情况都是没用命名卷或者不小心执行了docker compose down -v。down -v里的 v 就是 volumes它会连挂载的数据卷一起删掉。有一次我执行down -v清理环境把一个本地测试库的数据清空了还好只是测试数据。生产环境绝对不要随手带-v。想安全地停服务只删容器、保留数据用docker compose down就够了。另外时刻通过docker volume ls确认数据卷是你预期的那几个。5.4 pip 安装缓慢或编译报错服务器在国外或国内都一样会遇到这个问题默认 PyPI 源可能慢到超时。解决方案是换国内镜像源。在 Dockerfile 里给 pip 指定镜像地址RUN pip config set global.index-url https://pypi.example-mirror/simple \ pip install -r requirements.txt镜像源的地址网上搜一下就有用你网络环境下最快的那个即可。如果依赖里有编译型包比如某些地理位置库slim 镜像下编译报错通常缺两个系统包gcc和对应的-dev包。可以在构建阶段临时安装编译工具链RUN apt-get update \ apt-get install -y --no-install-recommends build-essential libpq-dev \ pip install -r requirements.txt \ apt-get purge -y build-essential apt-get autoremove -y这属于多阶段构建的雏形思路需要编译工具的步骤留在安装依赖的层里最终的镜像不保留编译器避免镜像不必要地膨胀。5.5 容器内时间不对容器默认时区是 UTC如果你不处理你的日志时间戳和业务时间都会跟本地差 8 小时。方法是在 compose 里给服务设置环境变量environment: - TZAsia/Shanghai大多数 slim 镜像不一定带时区数据文件设置 TZ 后可能还是 UTC。稳妥做法是构建时安装 tzdataRUN apt-get update apt-get install -y --no-install-recommends tzdata \ rm -rf /var/lib/apt/lists/*不过我个人的架构建议是应用内部统一存 UTC展示层再转本地时区。数据库存 UTC 时间不会因为部署环境变化而产生歧义日志工具普遍也认 UTC 或 ISO 8601 带时区格式。这个越早统一后面踩跨时区坑的概率越低。5.6 重启后代码还是旧的这种现象一般是忘了重新构建镜像。docker compose up -d只会创建/重新创建容器如果你没有先执行docker compose build它用的还是旧的镜像。更隐蔽的情况是 Docker 层缓存让你以为重新构建了实际上 pip 层直接用了旧缓存。怀疑缓存问题时的最快验证方式docker compose build --no-cache但这不是日常做法缓存是你的朋友别动不动全量禁用。更推荐的做法是 CI/CD 里每次构建都打上不可重复的镜像标签比如your-app:${COMMIT_SHA}部署时指定 tag从机制上保证跑到的一定是新构建出来的镜像。5.7 一个完整的排查思路遇到线上问题我建议按这个顺序逐层推进先docker compose ps看容器状态是运行、退出、还是重启循环再看docker logs应用日志永远是第一证据来源然后docker inspect看资源限制、健康状态、重启策略必要时docker exec -it container sh进容器手动验证 curl 接口、ping 数据库服务名、查环境变量最后回头查 compose 配置和 Dockerfile而不是先怀疑代码这个顺序最核心的原则是把应用现场信息抓完整再开始推理。很多新手一上线出问题就去翻代码逻辑折腾半天发现是环境变量写错了本末倒置。我个人在实际操作中的一个体会是Docker 部署 FastAPI 这件事真正难的往往不是写 Dockerfile而是建立起容器是一个独立小世界的心智模型。代码、依赖、网络、时区、数据、日志每一块都要想清楚它在容器内外怎么衔接。把这份 compose 文件当成你项目的第二个 README每次部署都按清单走你很快就会发现部署从最恐慌的环节变成了最放心的环节。接下来你可以顺理成章地把构建和发布流程接到持续集成上让 git 提交自动触发构建和部署那是第 29 篇适合展开的话题了。