跳到内容

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

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

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

这通常是你在大多数情况下想要做的,例如

  • 使用 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

信息

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

创建 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 终止代理之后

如果你的容器运行在 Nginx 或 Traefik 等 TLS 终止代理(负载均衡器)之后,请添加 --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 ComposeKubernetes云服务等。

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

不使用容器时,让应用程序在启动时运行并自动重启可能很麻烦和困难。但当使用容器时,在大多数情况下,这项功能是默认包含的。✨

复制 - 进程数量

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

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

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

负载均衡器

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

由于此组件将承担请求的负载并以(希望)均衡的方式在工作进程之间分发,因此它通常也被称为负载均衡器

提示

用于 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 集群中),那么你可能希望有一个单独的容器来完成之前的步骤,在一个容器中运行单个进程,在运行复制的工作容器之前

信息

如果你正在使用 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 缓存,你可以最大限度地缩短构建时间,从而最大限度地提高你的生产力(并避免无聊)。😎