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"]
# Если запускаете за прокси, например Nginx или Traefik, добавьте --proxy-headers
# CMD ["fastapi", "run", "app/main.py", "--port", "80", "--proxy-headers"]
Что такое контейнер¶
Контейнеры (в основном Linux-контейнеры) — это очень легковесный способ упаковать приложения вместе со всеми их зависимостями и необходимыми файлами, изолировав их от других контейнеров (других приложений или компонентов) в той же системе.
Linux-контейнеры запускаются, используя то же ядро Linux хоста (машины, виртуальной машины, облачного сервера и т.п.). Это означает, что они очень легковесные (по сравнению с полноценными виртуальными машинами, эмулирующими целую операционную систему).
Таким образом, контейнеры потребляют малое количество ресурсов, сопоставимое с запуском процессов напрямую (виртуальная машина потребовала бы намного больше ресурсов).
У контейнеров также есть собственные изолированные выполняемые процессы (обычно всего один процесс), файловая система и сеть, что упрощает развёртывание, безопасность, разработку и т.д.
Что такое образ контейнера¶
Контейнер запускается из образа контейнера.
Образ контейнера — это статическая версия всех файлов, переменных окружения и команды/программы по умолчанию, которые должны присутствовать в контейнере. Здесь статическая означает, что образ не запущен, он не выполняется — это только упакованные файлы и метаданные.
В противоположность «образу контейнера» (хранящему статическое содержимое), «контейнер» обычно означает запущенный экземпляр, то, что выполняется.
Когда контейнер запущен (на основе образа контейнера), он может создавать или изменять файлы, переменные окружения и т.д.. Эти изменения существуют только внутри контейнера и не сохраняются в исходном образе контейнера (не записываются на диск).
Образ контейнера можно сравнить с файлами программы, например python
и каким-то файлом main.py
.
А сам контейнер (в отличие от образа контейнера) — это фактически запущенный экземпляр образа, сопоставимый с процессом. По сути, контейнер работает только тогда, когда в нём есть запущенный процесс (и обычно это один процесс). Контейнер останавливается, когда в нём не остаётся запущенных процессов.
Образы контейнеров¶
Docker — один из основных инструментов для создания и управления образами контейнеров и контейнерами.
Существует публичный Docker Hub с готовыми официальными образами для многих инструментов, окружений, баз данных и приложений.
Например, есть официальный образ Python.
А также множество образов для разных вещей, например баз данных:
- PostgreSQL
- MySQL
- MongoDB
- Redis, и т.д.
Используя готовые образы, очень легко комбинировать разные инструменты и использовать их. Например, чтобы попробовать новую базу данных. В большинстве случаев можно воспользоваться официальными образами и просто настроить их через переменные окружения.
Таким образом, во многих случаях вы можете изучить контейнеры и Docker и переиспользовать эти знания с множеством различных инструментов и компонентов.
Например, вы можете запустить несколько контейнеров: с базой данных, Python-приложением, веб-сервером с фронтендом на React и связать их через внутреннюю сеть.
Все системы управления контейнерами (такие как Docker или Kubernetes) имеют интегрированные возможности для такого сетевого взаимодействия.
Контейнеры и процессы¶
Образ контейнера обычно включает в свои метаданные программу или команду по умолчанию, которую следует запускать при старте контейнера, а также параметры, передаваемые этой программе. Это очень похоже на запуск команды в терминале.
Когда контейнер стартует, он выполняет указанную команду/программу (хотя вы можете переопределить это и запустить другую команду/программу).
Контейнер работает до тех пор, пока работает его главный процесс (команда или программа).
Обычно в контейнере есть один процесс, но главный процесс может запускать подпроцессы, и тогда в том же контейнере будет несколько процессов.
Нельзя иметь работающий контейнер без хотя бы одного запущенного процесса. Если главный процесс останавливается, контейнер останавливается.
Создать Docker-образ для FastAPI¶
Итак, давайте что-нибудь соберём! 🚀
Я покажу, как собрать Docker-образ для FastAPI с нуля на основе официального образа 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"]
-
Начинаем с официального базового образа Python.
-
Устанавливаем текущую рабочую директорию в
/code
.Здесь мы разместим файл
requirements.txt
и директориюapp
. -
Копируем файл с зависимостями в директорию
/code
.Сначала копируйте только файл с зависимостями, не остальной код.
Так как этот файл меняется нечасто, Docker определит это и использует кэш на этом шаге, что позволит использовать кэш и на следующем шаге.
-
Устанавливаем зависимости из файла с требованиями.
Опция
--no-cache-dir
указываетpip
не сохранять загруженные пакеты локально, т.к. это нужно только еслиpip
будет запускаться снова для установки тех же пакетов, а при работе с контейнерами это обычно не требуется.Заметка
--no-cache-dir
относится только кpip
и не имеет отношения к Docker или контейнерам.Опция
--upgrade
указываетpip
обновлять пакеты, если они уже установлены.Поскольку предыдущий шаг с копированием файла может быть обработан кэшем Docker, этот шаг также использует кэш Docker, когда это возможно.
Использование кэша на этом шаге сэкономит вам много времени при повторных сборках образа во время разработки, вместо того чтобы загружать и устанавливать все зависимости каждый раз.
-
Копируем директорию
./app
внутрь директории/code
.Так как здесь весь код, который меняется чаще всего, кэш Docker вряд ли будет использоваться для этого шагa или последующих шагов.
Поэтому важно разместить этот шаг ближе к концу
Dockerfile
, чтобы оптимизировать время сборки образа контейнера. -
Указываем команду для запуска
fastapi run
, под капотом используется Uvicorn.CMD
принимает список строк, каждая из которых — это то, что вы бы ввели в командной строке, разделяя пробелами.Эта команда будет выполнена из текущей рабочей директории, той самой
/code
, которую вы задали вышеWORKDIR /code
.
Подсказка
Посмотрите, что делает каждая строка, кликнув по номеру рядом со строкой. 👆
Предупреждение
Всегда используйте exec-форму инструкции CMD
, как описано ниже.
Используйте CMD
— exec-форма¶
Инструкцию Docker CMD
можно писать в двух формах:
✅ Exec-форма:
# ✅ Делайте так
CMD ["fastapi", "run", "app/main.py", "--port", "80"]
⛔️ Shell-форма:
# ⛔️ Не делайте так
CMD fastapi run app/main.py --port 80
Обязательно используйте exec-форму, чтобы FastAPI мог корректно завершаться и чтобы срабатывали события lifespan.
Подробнее об этом читайте в документации Docker о shell- и exec-формах.
Это особенно заметно при использовании docker compose
. См. раздел FAQ 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 и подобные инструменты также используют внутренний кэш при сборке образа: если файл не изменился с момента предыдущей сборки, будет переиспользован слой, созданный в прошлый раз, вместо повторного копирования файла и создания нового слоя с нуля.
Само по себе избегание копирования всех файлов не всегда даёт много, но благодаря использованию кэша на этом шаге 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-хоста, например: http://192.168.99.100/items/5?q=somequery или http://127.0.0.1/items/5?q=somequery (или аналогичный URL вашего Docker-хоста).
Вы увидите что-то вроде:
{"item_id": 5, "q": "somequery"}
Интерактивная документация API¶
Теперь зайдите на http://192.168.99.100/docs или http://127.0.0.1/docs (или аналогичный URL вашего Docker-хоста).
Вы увидите автоматическую интерактивную документацию API (на базе Swagger UI):
Альтернативная документация API¶
Также можно открыть http://192.168.99.100/redoc или http://127.0.0.1/redoc (или аналогичный URL вашего Docker-хоста).
Вы увидите альтернативную автоматическую документацию (на базе ReDoc):
Собрать Docker-образ для однофайлового FastAPI¶
Если ваше приложение 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"]
-
Копируем файл
main.py
напрямую в/code
(без директории./app
). -
Используем
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 или похожий инструмент.
Поскольку этот компонент принимает нагрузку запросов и распределяет её между воркерами сбалансированно, его часто называют балансировщиком нагрузки.
Подсказка
Тот же компонент прокси завершения TLS, который обрабатывает HTTPS, скорее всего также будет балансировщиком нагрузки.
При работе с контейнерами система, которую вы используете для запуска и управления ими, уже имеет внутренние средства для передачи сетевого взаимодействия (например, HTTP-запросов) от балансировщика нагрузки (который также может быть прокси завершения TLS) к контейнеру(-ам) с вашим приложением.
Один балансировщик — несколько контейнеров-воркеров¶
При работе с Kubernetes или похожими системами управления распределёнными контейнерами их внутренние механизмы сети позволяют одному балансировщику нагрузки, слушающему главный порт, передавать запросы в несколько контейнеров, где запущено ваше приложение.
Каждый такой контейнер с вашим приложением обычно имеет только один процесс (например, процесс Uvicorn с вашим приложением FastAPI). Все они — одинаковые контейнеры, запускающие одно и то же, но у каждого свой процесс, память и т.п. Так вы используете параллелизм по разным ядрам 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"]
- Здесь мы используем опцию
--workers
, чтобы установить число воркеров равным 4.
Примеры, когда это может быть уместно:
Простое приложение¶
Вам может понадобиться менеджер процессов в контейнере, если приложение достаточно простое, чтобы запускаться на одном сервере, а не в кластере.
Docker Compose¶
Вы можете развёртывать на одном сервере (не кластере) с Docker Compose, и у вас не будет простого способа управлять репликацией контейнеров (в Docker Compose), сохраняя общую сеть и балансировку нагрузки.
Тогда вы можете захотеть один контейнер с менеджером процессов, который запускает несколько воркеров внутри.
Главное — ни одно из этих правил не является строго обязательным. Используйте эти идеи, чтобы оценить свой конкретный случай и решить, какой подход лучше для вашей системы, учитывая:
- Безопасность — HTTPS
- Запуск при старте
- Перезапуски
- Репликацию (количество запущенных процессов)
- Память
- Предварительные шаги перед запуском
Память¶
Если вы запускаете один процесс на контейнер, у каждого контейнера будет более-менее чётко определённый, стабильный и ограниченный объём потребляемой памяти (контейнеров может быть несколько при репликации).
Затем вы можете задать такие же лимиты и требования по памяти в конфигурации вашей системы управления контейнерами (например, в Kubernetes). Так система сможет реплицировать контейнеры на доступных машинах, учитывая объём необходимой памяти и доступной памяти в машинах кластера.
Если приложение простое, это, вероятно, не будет проблемой, и жёсткие лимиты памяти можно не указывать. Но если вы используете много памяти (например, с моделями Машинного обучения), проверьте, сколько памяти потребляется, и отрегулируйте число контейнеров на каждой машине (и, возможно, добавьте машины в кластер).
Если вы запускаете несколько процессов в контейнере, нужно убедиться, что их суммарное потребление не превысит доступную память.
Предварительные шаги перед запуском и контейнеры¶
Если вы используете контейнеры (например, Docker, Kubernetes), есть два основных подхода.
Несколько контейнеров¶
Если у вас несколько контейнеров, и, вероятно, каждый запускает один процесс (например, в кластере Kubernetes), то вы, скорее всего, захотите иметь отдельный контейнер, выполняющий предварительные шаги в одном контейнере и одном процессе до запуска реплицированных контейнеров-воркеров.
Информация
Если вы используете Kubernetes, это, вероятно, будет Init Container.
Если в вашем случае нет проблемы с тем, чтобы выполнять эти предварительные шаги многократно и параллельно (например, вы не запускаете миграции БД, а только проверяете готовность БД), вы можете просто выполнить их в каждом контейнере прямо перед стартом основного процесса.
Один контейнер¶
Если у вас простая схема с одним контейнером, который затем запускает несколько воркеров (или один процесс), можно выполнить подготовительные шаги в этом же контейнере непосредственно перед запуском процесса с приложением.
Базовый Docker-образ¶
Ранее существовал официальный Docker-образ FastAPI: tiangolo/uvicorn-gunicorn-fastapi. Сейчас он помечен как устаревший. ⛔️
Скорее всего, вам не стоит использовать этот базовый образ (или какой-либо аналогичный).
Если вы используете Kubernetes (или другое) и уже настраиваете репликацию на уровне кластера через несколько контейнеров, в этих случаях лучше собрать образ с нуля, как описано выше: Создать Docker-образ для FastAPI.
А если вам нужны несколько воркеров, просто используйте опцию командной строки --workers
.
Технические подробности
Этот Docker-образ был создан в то время, когда Uvicorn не умел управлять и перезапускать «упавших» воркеров, и приходилось использовать Gunicorn вместе с Uvicorn, что добавляло заметную сложность, лишь бы Gunicorn управлял и перезапускал воркеров Uvicorn.
Но теперь, когда Uvicorn (и команда fastapi
) поддерживают --workers
, нет причин использовать базовый Docker-образ вместо сборки своего (кода получается примерно столько же 😅).
Развёртывание образа контейнера¶
После того как у вас есть образ контейнера (Docker), его можно развёртывать несколькими способами.
Например:
- С Docker Compose на одном сервере
- В кластере Kubernetes
- В кластере Docker Swarm Mode
- С другим инструментом, например Nomad
- С облачным сервисом, который принимает ваш образ контейнера и разворачивает его
Docker-образ с uv
¶
Если вы используете uv для установки и управления проектом, следуйте их руководству по Docker для uv.
Резюме¶
Используя системы контейнеризации (например, Docker и Kubernetes), довольно просто закрыть все концепции развертывания:
- HTTPS
- Запуск при старте
- Перезапуски
- Репликация (количество запущенных процессов)
- Память
- Предварительные шаги перед запуском
В большинстве случаев вы, вероятно, не захотите использовать какой-либо базовый образ, а вместо этого соберёте образ контейнера с нуля на основе официального Docker-образа Python.
Заботясь о порядке инструкций в Dockerfile
и используя кэш Docker, вы можете минимизировать время сборки, чтобы повысить продуктивность (и не скучать). 😎