跳至内容

容器中的 FastAPI - Docker

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

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

提示

如果您很着急,并且已经了解了这些内容?跳到下面的 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"]

# If running behind a proxy like Nginx or Traefik add --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 或类似工具
  • 树莓派上运行时
  • 使用可以为您运行容器镜像的云服务,等等。

包要求

您通常会在某个文件中拥有应用程序的包要求

这主要取决于您用来安装这些要求的工具。

最常见的方法是使用一个名为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

信息

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

创建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将再次运行以安装相同的包时才会这样做,但在使用容器时情况并非如此。

    注意

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

    --upgrade选项告诉pip如果包已安装,则对其进行升级。

    由于之前的复制文件的步骤可以被Docker 缓存检测到,因此此步骤也将使用 Docker 缓存(如果可用)。

    在此步骤中使用缓存将节省您在开发过程中反复构建镜像时的大量时间,而不是每次下载和安装所有依赖项。

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

    由于所有代码都在这里,而代码是最常更改的部分,因此 Docker 缓存不会轻易用于此步骤或后续步骤

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

  6. 命令设置为使用fastapi run,它在内部使用 Uvicorn。

    CMD接受一个字符串列表,每个字符串都是您在命令行中输入的,用空格隔开。

    此命令将在当前工作目录中运行,即与您上面使用WORKDIR /code设置的/code目录相同。

提示

通过单击代码中的每个数字气泡来回顾每行代码的作用。👆

警告

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

使用CMD - Exec 形式

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

Exec 形式

# ✅ Do this
CMD ["fastapi", "run", "app/main.py", "--port", "80"]

⛔️ Shell 形式

# ⛔️ Don't do this
CMD fastapi run app/main.py --port 80

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

您可以在Docker 文档中有关 shell 和 exec 形式了解更多信息。

当使用docker compose时,这可能非常明显。有关更多技术细节,请参阅 Docker Compose 常见问题解答部分:为什么我的服务需要 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 镜像

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

  • 转到项目目录(包含Dockerfile的目录,其中包含您的app目录)。
  • 构建 FastAPI 镜像
$ docker build -t myimage .

---> 100%

提示

请注意末尾的.,它等效于./,它告诉 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 主机)。

您将看到类似以下内容:

{"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** 和**自动**获取**证书**。

提示

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

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

启动时运行和重启

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

它可能是**Docker** 本身、**Docker Compose**、**Kubernetes**、**云服务**等。

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

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

复制 - 进程数量

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

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

在这些情况下,您可能希望从头开始构建一个**Docker 镜像**,如 上面所述,安装您的依赖项,并运行**单个 Uvicorn 进程**,而不是使用多个 Uvicorn 工作进程。

负载均衡器

使用容器时,通常会有一些组件**监听主要端口**。它可能是另一个容器,它也是一个**TLS 终止代理**来处理**HTTPS** 或一些类似的工具。

由于此组件将接收请求的**负载**并将其分配给工作进程(以一种(希望)**均衡**的方式),因此它也通常称为**负载均衡器**。

提示

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

在使用容器时,您用来启动和管理它们的相同系统将已经具有内部工具来传输来自该**负载均衡器**(它也可能是一个**TLS 终止代理**)到包含您应用程序的容器的**网络通信**(例如 HTTP 请求)。

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

使用**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**集群中),那么您可能希望有一个**单独的容器**在一个容器中运行一个**进程**,完成**启动前的步骤**,然后运行复制的工作进程容器。

信息

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

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

单个容器

如果您有一个简单的设置,使用一个**单个容器**,然后启动多个**工作进程**(或者只启动一个进程),那么您可以在同一个容器中运行启动前的步骤,在使用应用程序启动进程之前运行它们。

基本 Docker 镜像

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

您可能**不**应该使用此基本 Docker 镜像(或任何其他类似镜像)。

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

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

技术细节

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

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

部署容器镜像

拥有容器(Docker)镜像后,有几种方法可以部署它。

例如

  • 在单台服务器上使用 **Docker Compose**
  • 使用 **Kubernetes** 集群
  • 使用 Docker Swarm 模式集群
  • 使用其他工具,例如 Nomad
  • 使用可以接受您的容器镜像并部署它的云服务

带有 uv 的 Docker 镜像

如果您使用 uv 安装和管理项目,可以按照他们的 uv Docker 指南 进行操作。

回顾

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

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

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

通过在 Dockerfile 中仔细安排指令顺序以及 **Docker 缓存**,您可以 **最大限度地减少构建时间**,从而提高工作效率(并避免枯燥)。 😎