Skip to content

FastAPI in Containers - Docker

在部署FastAPI应用程序时,常见的做法是构建一个**Linux容器镜像**。通常使用Docker来完成此操作。然后,您可以通过几种可能的方式部署该容器镜像。

使用Linux容器具有多个优点,包括**安全性**、可复制性、**简单性**等。

Tip

赶时间并且已经了解这些内容?跳转到下面的Dockerfile 👇

Dockerfile预览 👀
FROM python:3.9

WORKDIR /code

COPY ./requirements.txt /code/requirements.txt

RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt

COPY ./app /code/app

CMD ["fastapi", "run", "app/main.py", "--port", "80"]

# 如果运行在Nginx或Traefik等代理后面,添加--proxy-headers
# CMD ["fastapi", "run", "app/main.py", "--port", "80", "--proxy-headers"]

什么是容器

容器(主要是Linux容器)是一种非常**轻量级**的方式,用于打包应用程序及其所有依赖项和必要的文件,同时使它们与其他容器(其他应用程序或组件)在同一系统中保持隔离。

Linux容器使用与主机(机器、虚拟机、云服务器等)相同的Linux内核运行。这意味着它们非常轻量级(与模拟整个操作系统的完整虚拟机相比)。

这样,容器消耗的**资源很少**,与直接运行进程的资源消耗相当(虚拟机消耗的资源要多得多)。

容器还具有自己的**隔离**运行进程(通常只有一个进程)、文件系统和网络,简化了部署、安全性、开发等。

什么是容器镜像

**容器**从**容器镜像**运行。

容器镜像是容器中所有文件、环境变量和默认命令/程序的**静态**版本。这里的**静态**意味着容器**镜像**没有运行,没有被执行,它只是打包的文件和元数据。

与存储静态内容的“容器镜像”相比,“容器”通常指的是运行实例,即正在**执行**的东西。

当**容器**启动并运行时(从**容器镜像**启动),它可以创建或更改文件、环境变量等。这些更改将仅存在于该容器中,但不会持久化到基础容器镜像中(不会保存到磁盘)。

容器镜像类似于**程序**文件和内容,例如python和某个文件main.py

而**容器**本身(与**容器镜像**相对)是镜像的实际运行实例,类似于**进程**。实际上,容器仅在有**进程运行**时才运行(通常只有一个进程)。当容器中没有进程运行时,容器停止。

容器镜像

Docker一直是创建和管理**容器镜像**和**容器**的主要工具之一。

并且有一个公共的Docker Hub,其中包含许多工具、环境、数据库和应用程序的预制**官方容器镜像**。

例如,有一个官方的Python镜像

还有许多其他用于不同事物的镜像,例如数据库,例如:

通过使用预制的容器镜像,可以非常容易地**组合**和使用不同的工具。例如,尝试新的数据库。在大多数情况下,您可以使用**官方镜像**,并通过环境变量进行配置。

这样,在许多情况下,您可以学习容器和Docker,并将这些知识应用于许多不同的工具和组件。

因此,您可以运行**多个容器**,其中包含不同的内容,例如数据库、Python应用程序、带有React前端应用程序的Web服务器,并通过其内部网络将它们连接在一起。

所有容器管理系统(如Docker或Kubernetes)都集成了这些网络功能。

容器和进程

**容器镜像**通常在其元数据中包含默认程序或命令,该程序或命令应在**容器**启动时运行,以及应传递给该程序的参数。这与在命令行中的情况非常相似。 当一个**容器**启动时,它会运行该命令/程序(尽管你可以覆盖它并让它运行不同的命令/程序)。

只要**主进程**(命令或程序)在运行,容器就会保持运行状态。

一个容器通常只有一个**主进程**,但也可以从主进程启动子进程,这样你就可以在同一个容器中有**多个进程**。

但是,如果没有**至少一个运行中的进程**,就不可能有一个运行中的容器。如果主进程停止,容器也会停止。

为 FastAPI 构建 Docker 镜像

好了,现在让我们来构建一些东西吧!🚀

我将向你展示如何基于**官方 Python** 镜像**从头开始**为 FastAPI 构建一个**Docker 镜像**。

这是你在**大多数情况下**想要做的事情,例如:

  • 使用 Kubernetes 或类似工具
  • Raspberry Pi 上运行
  • 使用一个云服务为你运行容器镜像,等等。

包依赖

你通常会在某个文件中列出应用程序的**包依赖**。

这主要取决于你用来**安装**这些依赖的工具。

最常见的方式是有一个 requirements.txt 文件,其中包含包名及其版本,每行一个。

你当然可以使用你在 关于 FastAPI 版本 中读到的相同思路来设置版本范围。

例如,你的 requirements.txt 可能看起来像这样:

fastapi[standard]>=0.113.0,<0.114.0
pydantic>=2.7.0,<3.0.0

你通常会使用 pip 来安装这些包依赖,例如:

$ pip install -r requirements.txt
---> 100%
Successfully installed fastapi pydantic

Info

还有其他格式和工具来定义和安装包依赖。

创建 FastAPI 代码

  • 创建一个 app 目录并进入它。
  • 创建一个空文件 __init__.py
  • 创建一个 main.py 文件,内容如下:
from typing import Union

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def read_root():
    return {"Hello": "World"}


@app.get("/items/{item_id}")
def read_item(item_id: int, q: Union[str, None] = None):
    return {"item_id": item_id, "q": q}

Dockerfile

现在在同一个项目目录中创建一个 Dockerfile 文件,内容如下:

# (1)!
FROM python:3.9

# (2)!
WORKDIR /code

# (3)!
COPY ./requirements.txt /code/requirements.txt

# (4)!
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt

# (5)!
COPY ./app /code/app

# (6)!
CMD ["fastapi", "run", "app/main.py", "--port", "80"]
  1. 从官方 Python 基础镜像开始。

  2. 将当前工作目录设置为 /code

    这是我们将放置 requirements.txt 文件和 app 目录的地方。

  3. 将包含依赖的文件复制到 /code 目录。

    只复制包含依赖的文件,而不是其余代码。

    因为这个文件**不常更改**,Docker 会检测到它并使用**缓存**进行这一步,也会为下一步启用缓存。

  4. 在 requirements 文件中安装包依赖。

    --no-cache-dir 选项告诉 pip 不要将下载的包保存在本地,因为这仅在 pip 再次运行以安装相同包时才有用,但在使用容器时并非如此。

    Note

    --no-cache-dir 仅与 pip 相关,与 Docker 或容器无关。

    --upgrade 选项告诉 pip 如果包已安装则升级它们。

    因为上一步复制文件可能会被 Docker 缓存 检测到,这一步也将**使用 Docker 缓存**(如果可用)。

    在这一步使用缓存将**节省**你在开发过程中反复构建镜像时的**大量时间**,而不是**每次都下载并安装**所有依赖。

  5. ./app 目录复制到 /code 目录中。

    由于这包含了所有代码,这是**最常更改**的部分,Docker 缓存**不会轻易用于此步或任何**后续步骤

    因此,将此步骤**放在 Dockerfile 的末尾**很重要,以优化容器镜像构建时间。

  6. 设置使用 fastapi run 的**命令**,它在底层使用 Uvicorn。

    CMD 接受一个字符串列表,这些字符串是你会在命令行中输入的内容,用空格分隔。

    这个命令将从**当前工作目录**运行,即你之前用 WORKDIR /code 设置的 /code 目录。

Tip

通过点击代码中的每个数字气泡来查看每行代码的作用。👆

Warning

确保始终使用 CMD 指令的**exec 形式**,如下所述。

使用 CMD - Exec 形式

CMD Docker指令可以使用两种形式编写:

Exec 形式:

# ✅ 这样做
CMD ["fastapi", "run", "app/main.py", "--port", "80"]

⛔️ Shell 形式:

# ⛔️ 不要这样做
CMD fastapi run app/main.py --port 80

确保始终使用 exec 形式,以确保 FastAPI 能够优雅地关闭并触发生命周期事件

你可以在 Docker 文档中的 shell 和 exec 形式中了解更多信息。

在使用 docker compose 时,这一点尤为明显。有关更多技术细节,请参阅此 Docker Compose FAQ 部分:为什么我的服务需要 10 秒才能重新创建或停止?

目录结构

你现在应该有一个如下的目录结构:

.
├── app
│   ├── __init__.py
│   └── main.py
├── Dockerfile
└── requirements.txt

在 TLS 终止代理之后

如果你在 TLS 终止代理(如 Nginx 或 Traefik)之后运行你的容器,请添加 --proxy-headers 选项,这将告诉 Uvicorn(通过 FastAPI CLI)信任该代理发送的标头,告知应用程序正在 HTTPS 等环境下运行。

CMD ["fastapi", "run", "app/main.py", "--proxy-headers", "--port", "80"]

Docker 缓存

在这个 Dockerfile 中有一个重要的技巧,我们首先只复制 包含依赖项的文件,而不是其余的代码。让我告诉你为什么这样做。

COPY ./requirements.txt /code/requirements.txt

Docker 和其他工具 增量地构建 这些容器镜像,从 Dockerfile 的顶部开始,逐层添加,并添加由 Dockerfile 中每条指令创建的任何文件。

Docker 和类似工具在构建镜像时也使用 内部缓存,如果自上次构建容器镜像以来文件未发生变化,那么它将 重用上次创建的同一层,而不是再次复制文件并从头开始创建新层。

仅仅避免复制文件并不一定能显著改善情况,但由于它在该步骤中使用了缓存,因此可以 为下一步使用缓存。例如,它可以为使用以下指令安装依赖项的步骤使用缓存:

RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt

包含包要求的文件 不会频繁更改。因此,通过仅复制该文件,Docker 将能够 为该步骤使用缓存

然后,Docker 将能够 为下一步使用缓存,即下载并安装这些依赖项。这就是我们 节省大量时间 的地方。✨ ...并避免等待的无聊。😪😆

下载和安装包依赖项 可能需要几分钟,但使用 缓存 最多只需要几秒钟。

由于你会在开发过程中一遍又一遍地构建容器镜像以检查代码更改是否有效,因此这将节省大量累积的时间。

然后,在 Dockerfile 的末尾,我们复制所有代码。由于这是 最频繁更改的部分,我们将其放在末尾,因为几乎总是,此步骤之后的任何内容都无法使用缓存。

COPY ./app /code/app

构建 Docker 镜像

现在所有文件都已就位,让我们构建容器镜像。

  • 进入项目目录(包含 Dockerfileapp 目录)。
  • 构建你的 FastAPI 镜像:
$ docker build -t myimage .

---> 100%

Tip

注意结尾的 .,它等同于 ./,它告诉 Docker 用于构建容器镜像的目录。

在这种情况下,它是当前目录(.)。

启动 Docker 容器

  • 基于你的镜像运行一个容器:
$ docker run -d --name mycontainer -p 80:80 myimage

检查它

你应该能够在 Docker 容器的 URL 中检查它,例如:http://192.168.99.100/items/5?q=somequeryhttp://127.0.0.1/items/5?q=somequery(或使用你的 Docker 主机等效的 URL)。

你将看到类似以下内容:

{"item_id": 5, "q": "somequery"}

交互式 API 文档

现在你可以访问 http://192.168.99.100/docshttp://127.0.0.1/docs(或使用你的 Docker 主机等效地址)。

你将看到自动交互式 API 文档(由 Swagger UI 提供):

Swagger UI

替代 API 文档

你也可以访问 http://192.168.99.100/redochttp://127.0.0.1/redoc(或使用你的 Docker 主机等效地址)。

你将看到替代的自动文档(由 ReDoc 提供):

ReDoc

构建单文件 FastAPI 的 Docker 镜像

如果你的 FastAPI 是一个单文件,例如 main.py 且没有 ./app 目录,你的文件结构可能如下:

.
├── Dockerfile
├── main.py
└── requirements.txt

然后你只需更改 Dockerfile 中相应的路径以复制文件:

FROM python:3.9

WORKDIR /code

COPY ./requirements.txt /code/requirements.txt

RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt

# (1)!
COPY ./main.py /code/

# (2)!
CMD ["fastapi", "run", "main.py", "--port", "80"]
  1. 直接将 main.py 文件复制到 /code 目录(不经过任何 ./app 目录)。

  2. 使用 fastapi run 在单文件 main.py 中运行你的应用程序。

当你将文件传递给 fastapi run 时,它会自动检测到这是一个单文件而不是包的一部分,并知道如何导入和运行你的 FastAPI 应用。😎

部署概念

让我们再次讨论一些相同的 部署概念,这次是从容器的角度来看。

容器主要是一个简化**构建和部署**应用程序过程的工具,但它们并不强制要求特定的处理这些**部署概念**的方法,并且有多种可能的策略。

**好消息**是,每种不同的策略都有办法涵盖所有的部署概念。🎉

让我们从容器的角度来回顾这些**部署概念**:

  • HTTPS
  • 启动时运行
  • 重启
  • 复制(运行的进程数量)
  • 内存
  • 启动前的步骤

HTTPS

如果我们只关注 FastAPI 应用程序的**容器镜像**(以及稍后的运行**容器**),HTTPS 通常会由另一个外部工具处理。

它可以是另一个容器,例如使用 Traefik,处理 HTTPS自动**获取 **证书

Tip

Traefik 与 Docker、Kubernetes 等集成,因此使用它为你的容器设置和配置 HTTPS 非常容易。

或者,HTTPS 可以由云提供商作为其服务之一处理(同时仍在容器中运行应用程序)。

启动时运行和重启

通常有另一个工具负责**启动和运行**你的容器。

它可以是 Docker 直接运行、Docker ComposeKubernetes、**云服务**等。

在大多数(或所有)情况下,有一个简单的选项可以启用容器在启动时运行并在失败时重启。例如,在 Docker 中,它是命令行选项 --restart

如果不使用容器,使应用程序在启动时运行并带有重启功能可能会很麻烦且困难。但在大多数情况下,**使用容器**时,该功能默认包含在内。✨

复制 - 进程数量

如果你有一个 集群 的机器,使用 Kubernetes、Docker Swarm Mode、Nomad 或其他类似的复杂系统来管理多台机器上的分布式容器,那么你可能希望在**集群级别**处理**复制**,而不是在每个容器中使用**进程管理器**(如带有工作者的 Uvicorn)。

像 Kubernetes 这样的分布式容器管理系统通常有一些集成的方式来处理**容器的复制**,同时仍然支持对传入请求的**负载均衡**。所有这些都在**集群级别**进行。

在这种情况下,你可能希望构建一个**从零开始的 Docker 镜像**,如上述解释的那样,安装你的依赖项,并运行**单个 Uvicorn 进程**,而不是使用多个 Uvicorn 工作者。

负载均衡器

在使用容器时,通常会有某个组件**监听主端口**。它可能是一个同时作为**TLS终止代理**的容器,用于处理**HTTPS**或其他类似工具。

由于该组件会承担请求的**负载**,并以(希望是)均衡**的方式将其分发给各个工作节点,因此它通常也被称为**负载均衡器

Tip

用于HTTPS的相同**TLS终止代理**组件很可能也是一个**负载均衡器**。

而在使用容器时,用于启动和管理容器的同一系统已经内置了工具,可以将**网络通信**(例如HTTP请求)从该**负载均衡器**(也可能是一个**TLS终止代理**)传输到运行你应用的容器。

一个负载均衡器 - 多个工作容器

在使用**Kubernetes**或类似的分布式容器管理系统时,利用其内部网络机制,可以让监听主**端口**的单一**负载均衡器**将通信(请求)传输到可能**多个运行你应用的容器**。

每个运行你应用的容器通常只会有**一个进程**(例如运行FastAPI应用的Uvicorn进程)。它们都是**相同的容器**,运行相同的内容,但每个容器都有自己的进程、内存等。这样你可以利用CPU的**不同核心**甚至**不同机器**上的**并行化**。

而带有**负载均衡器**的分布式容器系统会**轮流分发请求**到每个运行你应用的容器。因此,每个请求都可以由运行你应用的多个**复制容器**中的一个来处理。

通常,这个**负载均衡器**还能处理指向集群中*其他*应用的请求(例如指向不同域名或不同URL路径前缀),并将通信传输到集群中运行*其他*应用的正确容器。

每个容器一个进程

在这种场景下,你可能希望每个容器有**一个单一的(Uvicorn)进程**,因为你已经在集群级别处理了复制。

因此,在这种情况下,你**不希望**在容器中有多个工作进程,例如使用--workers命令行选项。你希望每个容器只有一个**单一的Uvicorn进程**(但可能有多个容器)。

在容器内部再有一个进程管理器(就像使用多个工作进程时那样)只会增加**不必要的复杂性**,而你很可能已经通过集群系统解决了这些问题。

多进程容器和特殊情况

当然,也有**特殊情况**,你可能希望有一个包含多个**Uvicorn工作进程**的**容器**。

在这些情况下,你可以使用--workers命令行选项来设置你想要运行的工作进程数量:

FROM python:3.9

WORKDIR /code

COPY ./requirements.txt /code/requirements.txt

RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt

COPY ./app /code/app

# (1)!
CMD ["fastapi", "run", "app/main.py", "--port", "80", "--workers", "4"]
  1. 这里我们使用--workers命令行选项将工作进程数量设置为4。

以下是一些可能合理的例子:

简单应用

如果你的应用**足够简单**,可以在**单个服务器**上运行,而不是集群,你可能希望在容器中有一个进程管理器。

Docker Compose

你可能正在使用**Docker Compose**部署到**单个服务器**(而不是集群),因此在没有共享网络和**负载均衡**的情况下,管理容器的复制(使用Docker Compose)并不容易。

然后你可能希望有一个**单一容器**,其中有一个**进程管理器**启动**多个工作进程**。


主要的一点是,这些都不是**你必须盲目遵循的**铁律。你可以利用这些想法来**评估你自己的用例**,并决定哪种方法最适合你的系统,同时考虑如何管理以下概念:

  • 安全性 - HTTPS
  • 启动时运行
  • 重启
  • 复制(运行的进程数量)
  • 内存
  • 启动前的步骤

内存

如果你每个容器运行**一个单一进程**,你将有一个或多或少定义明确、稳定且有限的内存量,由每个容器(如果它们被复制,则有多个)消耗。 然后,你可以在容器管理系统(例如在**Kubernetes**中)的配置中设置相同的内存限制和需求。这样,它将能够根据容器所需的内存量和集群中机器的可用内存量,在**可用机器**上**复制容器**。

如果你的应用程序**简单**,这可能**不会成为问题**,你可能不需要指定严格的内存限制。但如果你**使用了大量内存**(例如使用**机器学习**模型),你应该检查你消耗了多少内存,并调整**每台机器**上运行的**容器数量**(并可能向集群中添加更多机器)。

如果你在**每个容器中运行多个进程**,你必须确保启动的进程数量不会**消耗超过可用内存**。

启动和容器之前的步骤

如果你使用容器(例如 Docker、Kubernetes),那么你可以使用两种主要方法。

多个容器

如果你有**多个容器**,可能每个容器运行一个**单个进程**(例如在**Kubernetes**集群中),那么你可能希望在一个单独的容器中执行**之前步骤**的工作,该容器运行一个单个进程,在**运行复制的 worker 容器**之前

Info

如果你使用 Kubernetes,这可能是一个 Init Container

如果你的用例中没有问题在**并行多次**运行这些之前步骤(例如,如果你没有运行数据库迁移,而只是检查数据库是否已经准备就绪),那么你也可以在每个容器中直接在启动主进程之前运行它们。

单个容器

如果你有一个简单的设置,使用一个**单个容器**,然后启动多个**worker 进程**(或者也只是一个进程),那么你可以在同一个容器中运行这些之前步骤,就在启动带有应用程序的进程之前。

基础 Docker 镜像

曾经有一个官方的 FastAPI Docker 镜像:tiangolo/uvicorn-gunicorn-fastapi。但它现在已被弃用。⛔️

你**可能不应该**使用这个基础 Docker 镜像(或其他类似的镜像)。

如果你使用**Kubernetes**(或其他)并且已经在集群级别设置了**复制**,使用多个**容器**。在这些情况下,你最好**从头开始构建镜像**,如上所述:为 FastAPI 构建 Docker 镜像

如果你需要多个 worker,你可以简单地使用 --workers 命令行选项。

技术细节

当 Uvicorn 不支持管理和重启死掉的 worker 时,创建了这个 Docker 镜像,因此需要使用 Gunicorn 和 Uvicorn,这增加了相当多的复杂性,只是为了让 Gunicorn 管理和重启 Uvicorn worker 进程。

但现在 Uvicorn(和 fastapi 命令)支持使用 --workers,没有理由使用基础 Docker 镜像而不是自己构建(代码量几乎相同 😅)。

部署容器镜像

在拥有容器(Docker)镜像后,有几种部署方式。

例如:

  • 在单个服务器上使用**Docker Compose**
  • 使用**Kubernetes**集群
  • 使用 Docker Swarm Mode 集群
  • 使用其他工具,如 Nomad
  • 使用云服务,该服务获取你的容器镜像并进行部署

使用 uv 的 Docker 镜像

如果你使用 uv 来安装和管理你的项目,你可以按照他们的 uv Docker 指南

总结

使用容器系统(例如使用**Docker**和**Kubernetes**),处理所有**部署概念**变得相当简单:

  • HTTPS
  • 启动时运行
  • 重启
  • 复制(运行的进程数量)
  • 内存
  • 启动前的步骤

在大多数情况下,你可能不希望使用任何基础镜像,而是**从头开始构建容器镜像**,基于官方的 Python Docker 镜像。

通过注意 Dockerfile 中的指令**顺序**和**Docker 缓存**,你可以**最小化构建时间**,以最大化你的生产力(并避免无聊)。😎