FastAPI离线部署攻略:彻底解决/docs白屏与Swagger UI加载失败
我前段时间把一个 FastAPI 服务部署到客户内网服务器上业务接口全部调通轮到最后给客户演示接口文档时打开/docs却是一整页空白。浏览器控制台刷满了红色报错F12 切到 Network 面板一看所有请求都卡在cdn.jsdelivr.net上超时。客户的内网是个物理隔离环境别说访问公网 CDN连 DNS 外网解析都走不通。这个场景其实非常典型FastAPI 的/docs页面并不是一个纯本地页面它默认从公网 CDN 加载 Swagger UI 的 JS 和 CSS一旦断网文档页就只剩一个空壳。这篇文章就把这个问题彻底讲透先带你定位根因再对比几种离线化方案的优劣然后给出完整可复现的实操代码最后补充 Docker 镜像、离线依赖包、反向代理子路径等生产环境下的配合细节以及我实测踩过的一些坑。适合所有在内网、离线环境部署 FastAPI 的开发者参考。1. 白屏的根源FastAPI 的 /docs 天生是“半在线”页面1.1 /docs 背后发生了什么FastAPI 的/docs页面并不是 FastAPI 自己渲染出来的前端文件而是通过fastapi.openapi.docs.get_swagger_ui_html这个函数返回一段 HTML 字符串。这段 HTML 本身只有页面骨架真正的 UI 逻辑、样式、交互全部依赖两个外部资源swagger-ui-bundle.js和swagger-ui.css。关键就在这里这个函数的默认参数值指向的是 jsdelivr 的 CDN 地址。也就是说FastAPI 官方默认假设你的浏览器能访问公网。在本地开发环境下这当然没问题但一旦部署到内网服务器服务器能跑起来浏览器却无法加载远端的 JS/CSS最终呈现给你的就是一块白屏。同样的逻辑也适用于/redoc页面它依赖redoc.standalone.js同样默认走 CDN。很多人在处理/docs时忘了还有一个/redoc等客户打开红红的那一页又傻眼了。1.2 三步定位确认是 CDN 资源不可达如果你第一次遇到这个问题不要急着改代码先花两分钟做下面三个检查能帮你确认问题到底出在哪里。第一步在能访问服务的内网机器上命令行执行curl http://你的服务地址/docs看返回的 HTML 里有没有类似这样的内容link relstylesheet hrefhttps://cdn.jsdelivr.net/npm/swagger-ui-dist5/swagger-ui.css script srchttps://cdn.jsdelivr.net/npm/swagger-ui-dist5/swagger-ui-bundle.js/script只要看到https://开头的 CDN 地址基本就可以断定/docs白屏是和外部资源加载失败有关。第二步按 F12 打开浏览器开发者工具切到 Network 面板刷新页面过滤js和css类型的请求你会看到有几个请求的状态是failed或者一直处于pending域名就是cdn.jsdelivr.net之类的。第三步在内网机器上直接 ping 或 curl 一下那个 CDN 域名curl -I https://cdn.jsdelivr.net内网环境大概率是超时或者直接Could not resolve host。到这里问题就完全定位了不是 FastAPI 服务的问题而是浏览器在尝试加载一个内网到不了的外部资源。1.3 常见的“假解决”换 CDN 和代理都治标不治本有人会说既然 jsdelivr 被墙或不可达那换一个国内 CDN 不就行了比如换成unpkg.com、cdn.bootcdn.net或者staticfile.org。这种思路在“能访问部分公网域名”的内网环境确实能临时解决但遇到真正的物理隔离网络任何公网域名都是不可达的换谁都没用。还有人在内网搭一个反向代理把cdn.jsdelivr.net反向代理到内网某台机器上。这个方案在浏览器端确实能用但它把问题转移到了基础设施层你需要额外维护一个代理服务而且如果客户的内网策略更严格禁止访问非白名单域名这种方案同样会失效。所以我给出的结论是解决方案只有一个终极方向——把 Swagger UI 的静态资源变成你服务本身的一部分完全不依赖任何外部地址。接下来的所有方案都是围绕这个核心思路展开的。2. 方案对比哪种离线化做法最适合你的项目2.1 方案 A用官方 get_swagger_ui_html 手动指向本地资源这是我最推荐的做法也是 FastAPI 官方留好的“后门”。get_swagger_ui_html支持swagger_js_url和swagger_css_url两个参数默认指向 CDN但你可以显式传入本地 URL。操作思路很简单先把 swagger-ui 的静态文件下载到项目目录下再通过 FastAPI 的StaticFiles挂载成可访问的静态资源最后把默认的/docs路由替换掉让它返回你指定的本地资源地址。这个方案的优点是非常贴合 FastAPI 自身的机制不会引入额外依赖代码量少而且升级 FastAPI 时兼容风险最小。缺点是你要自己动手下载和保存静态文件。2.2 方案 B自定义 HTML 模板完全掌控文档页如果你不只是想让文档能显示还想改掉 Swagger UI 的顶部标题、去掉那个 Swagger 默认 logo、加一段公司版权信息甚至把整个文档页和你们自己的前端组件融合到一起那方案 A 就不够用了。这种场景下你可以完全抛弃get_swagger_ui_html自己写一个 HTML 模板文件里面script和link标签的地址都指向本地静态资源。然后用FileResponse或HTMLResponse返回这个模板。优点是自由度极高页面想怎么改就怎么改。缺点是模板需要自己维护而且 Swagger UI 内部有一些配置项比如oauth2RedirectUrl需要在模板里写对不然会踩到奇怪的坑。如果你只是想要一个能用的文档页方案 A 足够。2.3 方案 C直接依赖 fastapi-offline 等封装库GitHub 上有人把 Swagger UI 和 Redoc 的静态文件打包成了一个 Python 库叫fastapi-offline。安装这个库之后引入它提供的FastAPI类就自动替换了默认的 CDN 引用直接离线可用。from fastapi_offline import FastAPI app FastAPI()就这么简单。适合想快速交付、不想关心静态文件细节的团队。但这个方案有一个隐性成本它内部打的是某个固定版本的 Swagger UI可能和你项目使用的 FastAPI 版本存在细微兼容问题。而且这相当于给项目增加了一个维护依赖一旦 FastAPI 官方调整了文档页的加载方式这个库不一定能及时跟进。2.4 三种方案的选型对照方案改动量可控性额外依赖适用场景方案 Aget_swagger_ui_html 覆盖小中无绝大多数内网/离线部署方案 B自定义 HTML 模板中高无需要深度定制页面样式或功能方案 Cfastapi-offline极小低有快速交付、团队可接受第三方依赖如果让我给一个选择建议默认走方案 A只有出现定制需求时再升级到方案 B方案 C 适合那种“今天就要交付、以后再说”的场景。3. 实战扒下 Swagger UI 静态文件替换默认 CDN 引用3.1 在外网机器下载 swagger-ui-dist第一步是在一台能访问公网的机器上把 Swagger UI 的静态文件下载下来。最简单的方式是直接从 jsdelivr 下载对应版本文件。我这里以5.17.14为例这个版本是我在多个 FastAPI 项目里实际测过没问题的版本。需要下载的关键文件只有两个curl -o swagger-ui-bundle.js https://cdn.jsdelivr.net/npm/swagger-ui-dist5.17.14/swagger-ui-bundle.js curl -o swagger-ui.css https://cdn.jsdelivr.net/npm/swagger-ui-dist5.17.14/swagger-ui.css顺手把 favicon 也下载了curl -o favicon-32x32.png https://cdn.jsdelivr.net/npm/swagger-ui-dist5.17.14/favicon-32x32.png curl -o favicon-16x16.png https://cdn.jsdelivr.net/npm/swagger-ui-dist5.17.14/favicon-16x16.png如果团队里有 Node.js 环境也可以直接通过 npm 安装再拷贝npm install swagger-ui-dist5.17.14 cp -r node_modules/swagger-ui-dist/dist/* ./static/docs/这种方式会把整个 dist 目录都拉下来包括 standalone preset、oauth2 相关文件等比较适合后续想深度定制的情况。注意下载时尽量显式指定版本号不要用5这种大版本写法。因为不同大版本之间的目录结构和资源文件名可能有差异固定版本号可以保证你本地文件和 HTML 引用完全匹配。3.2 工程目录结构与静态挂载下载完成后在你的 FastAPI 项目里建一个static/docs目录把文件放进去。典型的项目结构长这样myproject/ ├── app/ │ ├── main.py │ └── ... ├── static/ │ └── docs/ │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── swagger-ui-bundle.js │ └── swagger-ui.css └── requirements.txt然后在main.py里挂载静态目录from fastapi import FastAPI from fastapi.staticfiles import StaticFiles app FastAPI() app.mount(/static, StaticFiles(directorystatic), namestatic)这里的逻辑是把本地static目录暴露成/static这个 URL 前缀。这样浏览器通过http://你的服务地址/static/docs/swagger-ui.css就能直接访问到你放在服务器上的文件。3.3 重写 /docs 路由的核心代码接下来是核心操作。把默认的/docs关掉自己实现一个from fastapi import FastAPI from fastapi.openapi.docs import get_swagger_ui_html from fastapi.staticfiles import StaticFiles app FastAPI(docs_urlNone) app.mount(/static, StaticFiles(directorystatic), namestatic) app.get(/docs, include_in_schemaFalse) async def custom_swagger_ui_html(): return get_swagger_ui_html( openapi_urlapp.openapi_url, titleapp.title - Swagger UI, swagger_js_url/static/docs/swagger-ui-bundle.js, swagger_css_url/static/docs/swagger-ui.css, )代码解释一下docs_urlNone关闭 FastAPI 自动生成的默认/docs路由。这一步非常关键如果你不关你自己定义的/docs会和默认路由冲突要么被覆盖要么访问到的还是那个走 CDN 的旧页面。app.get(/docs, include_in_schemaFalse)自定义/docs路由。include_in_schemaFalse是为了让这个路由本身不出现在 OpenAPI schema 里否则文档页里会莫名其妙多出一个/docs接口。get_swagger_ui_html(...)官方提供的 HTML 生成函数。我们通过swagger_js_url和swagger_css_url参数把资源地址指向本地。这里有一点需要特别注意app.openapi_url默认值是/openapi.json。如果你的项目里显式修改过openapi_url这里也要保持同步否则文档页请求不到 schema。启动服务浏览器访问/docs这时候页面已经能正常显示了而且整个页面不再有任何外部网络请求。3.4 redoc 的离线化需要单独处理很多人处理完/docs就觉得完事了直到客户打开/redoc又是一片白屏。Redoc 是另一个独立的文档渲染器它的离线化要单独处理。先下载 redoc 的静态文件curl -o redoc.standalone.js https://cdn.jsdelivr.net/npm/redocnext/bundles/redoc.standalone.js放到static/docs/目录下。然后在main.py里同样覆盖/redoc路由from fastapi.openapi.docs import get_redoc_html app FastAPI(docs_urlNone, redoc_urlNone) app.get(/redoc, include_in_schemaFalse) async def custom_redoc_html(): return get_redoc_html( openapi_urlapp.openapi_url, titleapp.title - ReDoc, redoc_js_url/static/docs/redoc.standalone.js, )注意redoc.standalone.js这个文件本身比较大接近 1MB传输时间会比 Swagger UI 的 bundle 长一些但在内网环境下这根本不是问题内网带宽再慢也慢不到哪去。3.5 内网浏览器实测Network 面板不再有外部请求完成上述操作后我建议你做一个完整的验证而不仅仅是“看一眼页面出来了”。打开浏览器开发者工具切到 Network 面板刷新/docs然后逐个检查请求列表。确认满足这三个条件所有 JS、CSS、PNG 等静态资源的请求域名都是你自己的服务域名或者内网 IP没有一条是公网域名。文档页能正常渲染接口列表、参数说明、Try it out 按钮。点击 Try it out 执行一次真实请求能正常收到后端响应。我实际验证过内网环境下整页加载时间通常在 1 秒以内相比走公网 CDN 时等待超时的体验完全是天壤之别。4. 和离线部署打配合Docker、依赖包与反向代理4.1 把静态资源随 Docker 镜像打包如果你的 FastAPI 服务最终是容器化部署那么在 Dockerfile 里做两件事就够了把static目录复制进镜像确保工作目录正确。FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]这里COPY . .会把整个项目目录包括static/docs复制进镜像。需要注意运行时的工作目录是/app而StaticFiles(directorystatic)使用的是相对路径。为了避免相对路径在启动目录不同的情况下出现StaticFiles找不到目录的问题更稳妥的做法是使用基于__file__的绝对路径from pathlib import Path STATIC_DIR Path(__file__).resolve().parent.parent / static app.mount(/static, StaticFiles(directorySTATIC_DIR), namestatic)如果main.py在app/子目录下那么Path(__file__).resolve().parent.parent就回到了项目根目录再拼上static就是完整路径。这一点在容器里特别重要因为容器的启动命令可能让你处于意想不到的工作目录。4.2 pip 离线依赖与 docs 资源走同一套分发体系FastAPI 文档页白屏这个问题的本质是“应用依赖了外部网络资源”。顺着这个思路想你会发现内网部署 FastAPI 时还会遇到另一类同样性质的问题Python 第三方包本身也没法用pip install在线安装。常规做法是在外网机器上把依赖打包pip download -r requirements.txt -d ./offline_packages然后把整个offline_packages目录连同代码一起拷进内网在内网机器上执行pip install --no-index --find-links./offline_packages -r requirements.txt这个操作和 docs 资源离线化本质上是同一件事让应用在和外网完全隔离的前提下依然能完整运行。所以我在做项目交付物清单时通常会把静态资源和 Python 依赖打包放在一起统一通过内网文件服务器分发。客户拿到一个压缩包解压后按文档执行两三条命令就全部搞定不用解释什么是 CDN什么是 PyPI。4.3 子路径部署和 root_path 的坑还有一个非常容易忽略的场景你的 FastAPI 服务可能不是部署在域名根路径下而是挂在 Nginx 的某个子路径下比如https://example.com/api/docs。这种场景下如果你在get_swagger_ui_html里写死绝对路径/static/docs/swagger-ui-bundle.js浏览器解析时会把它当成根路径下的资源结果是https://example.com/static/docs/swagger-ui-bundle.js和真实地址https://example.com/api/static/docs/swagger-ui-bundle.js对不上依然 404。有几种处理办法一种是在 HTML 里使用相对路径。因为 Swagger UI 页面本身就位于/api/docs那么相对路径../static/docs/swagger-ui-bundle.js就能正确解析到/api/static/docs/swagger-ui-bundle.js。比如swagger_js_url../static/docs/swagger-ui-bundle.js, swagger_css_url../static/docs/swagger-ui.css,另一种是使用 FastAPI 的root_path机制。FastAPI 支持通过--root-path参数或者root_path配置告诉应用它部署在哪个子路径下request.scope[root_path]可以拿到这个前缀。更规范的做法是使用 FastAPI 提供的request.url_for来生成静态资源 URLfrom fastapi import Request app.get(/docs, include_in_schemaFalse) async def custom_swagger_ui_html(request: Request): return get_swagger_ui_html( openapi_urlrequest.scope.get(root_path, ) app.openapi_url, titleapp.title - Swagger UI, swagger_js_urlrequest.url_for(static, pathdocs/swagger-ui-bundle.js), swagger_css_urlrequest.url_for(static, pathdocs/swagger-ui.css), )request.url_for(static, path...)会根据当前请求的根路径自动生成正确的绝对 URL前提是你挂载StaticFiles时设置了namestatic。这个方案是最保险的不管服务部署在根路径还是子路径都能正确加载。5. 避坑清单落地时最容易忽略的五个细节5.1 docs_urlNone 之后确认默认路由真的被移除这个坑我踩过一次而且排查了很久。当时我在一个已有项目里加离线文档支持只加了自定义/docs路由忘了改FastAPI(docs_urlNone)。结果访问/docs时命中的是 FastAPI 默认路由返回的还是 CDN 引用页面依然白屏。我一度以为是我的本地静态文件路径写错了。排查时很迷惑因为代码里确实写了自定义逻辑但浏览器拿到的 HTML 还是老的。后来直接用 curl 看返回的 HTML 才发现自定义路由根本没有生效。原因是 FastAPI 默认路由的优先级和自定义路由有冲突最终访问到的是默认实现。所以切记只要你想替换/docs一定在创建FastAPI实例时显式设置docs_urlNone同理替换/redoc要设置redoc_urlNone。5.2 换资源后必须清浏览器缓存否则看着还是白屏改完代码后如果浏览器之前已经访问过旧的/docs页面浏览器缓存里可能还存着旧的 HTML 和 CDN 链接。你刷新页面时浏览器可能直接使用缓存的旧页面看起来就像没改一样。我建议在验证时直接开一个无痕窗口或者用CtrlShiftR强制刷新。如果是在代码里频繁调试也可以在浏览器 Network 面板勾选 Disable cache避免这种干扰。5.3 版本一致性js 和 css 不要混用Swagger UI 的swagger-ui-bundle.js和swagger-ui.css必须来自同一个版本不能一个用 5.17.14 的 JS一个用 4.15.5 的 CSS。不同版本的 DOM 结构和样式类名可能不同混用会导致页面渲染错乱布局乱、左侧接口列表点不动、搜索框消失等。我习惯在下载时就固定一个版本号并且在静态文件目录里做一个版本标记比如static/docs/README.txt写明版本号方便以后排障。5.4 生产环境是否需要关闭文档页在一些安全要求高的政企内网项目里你可能根本不想对外暴露接口文档页哪怕它已经离线化。这种情况下最简单的做法是直接关闭app FastAPI(docs_urlNone, redoc_urlNone, openapi_urlNone)设置openapi_urlNone后Swagger UI 就算加载了也拿不到 schema文档页无从渲染。这样做的副作用是/openapi.json也不能访问了如果团队内部还需要用它做接口测试或代码生成建议保留openapi_url只关掉/docs和/redoc两个页面。还有一个常见折中只在配置文件里控制文档开关。from fastapi import FastAPI import os DEBUG os.getenv(DEBUG, false).lower() true app FastAPI( docs_url/docs if DEBUG else None, redoc_url/redoc if DEBUG else None, openapi_url/openapi.json if DEBUG else None, )这样开发和内网演示时能看到文档正式交付给客户的生产环境就完全关闭属于比较稳妥的做法。5.5 静态资源文件名建议带版本号如果你会多次升级 Swagger UI 或 Redoc建议在文件名里带上版本号而不是简单叫swagger-ui-bundle.js。因为浏览器对静态资源有长期缓存机制如果你升级了资源但文件名没变客户内网机器上的浏览器可能继续用旧缓存造成“为什么我更新了服务但文档页还是老的”这种问题。推荐命名方式static/docs/ ├── swagger-ui-bundle-5.17.14.js ├── swagger-ui-5.17.14.css └── redoc.standalone-2.0.0.js然后在代码里引用对应版本的文件名。升级时改文件名和代码引用就能天然规避缓存问题。我在实际项目中还遇到过一种情况团队里有人改了docs_url但没关默认路由结果访问/docs时命中默认路由还是走的 CDN白屏依旧。这个坑排查了很久核心就是记住一旦自定义了/docs必须确保原路由真的被docs_urlNone关掉。保持简单离线环境其实一点都不复杂只要把资源变成服务的一部分剩下的都是水到渠成的事。