跳转至

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. 安装要求文件中的包依赖项。

    --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 镜像

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

  • 转到项目目录(你的 Dockerfile 所在的位置,包含你的 app 目录)。
  • 构建你的 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 主机)。

你将看到类似这样的内容:

{"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 是单个文件,例如,没有 ./app 目录的 main.py,你的文件结构可能如下所示:

.
├── 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 终止代理组件可能也是负载均衡器

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

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

当使用 Kubernetes 或类似的分布式容器管理系统时,使用它们的内部网络机制将允许监听主端口的单个负载均衡器将通信(请求)传输到可能运行你的应用程序的多个容器

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

并且分布式容器系统与负载均衡器轮流将请求分配给运行你的应用程序的每个容器。因此,每个请求可以由运行你的应用程序的多个复制容器中的一个处理。

通常,这个负载均衡器将能够处理发送到你的集群中其他应用程序的请求(例如,到不同的域,或 under 不同的 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

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

单个容器

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

基础 Docker 镜像

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

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

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

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

技术细节

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

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

部署容器镜像

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

例如:

  • 在单个服务器中使用 Docker Compose
  • 使用 Kubernetes 集群
  • 使用 Docker Swarm Mode 集群
  • 使用另一个工具如 Nomad
  • 使用云服务获取你的容器镜像并部署它

带有 uv 的 Docker 镜像

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

回顾

使用容器系统(例如使用 DockerKubernetes)变得相当直接地处理所有部署概念

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

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

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