Перейти к основному содержимому

Как запустить Fastapi в Yandex Cloud Serverless Containers

· 5 мин. чтения

В принципе весь процесс описан в документации, но там упомянут всего один фреймворк - Sanic, а тут мы разберем как запустить Fastapi.

Минимальный проект

Создаем проект

Для этого воспользуемся еще одной инструкцией, на этот раз из документации Fastapi

mkdir app
touch app/__init__.py
touch app/main.py
git init

cat << EOF > requirements.txt
fastapi
pydantic
uvicorn
EOF

git add .
git commit -m "Initial commit"

Наполняем проект

В app/app.py добавим простой хэндлер.

app/app.py
import os
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}

Дополнительно к примеру из документации Fastapi, мы добавим main.py, чтобы программно запустить сервер через вызов uvicorn.run(). В параметрах запуска сервера мы указываем host и port, который берем из переменных окружения. Это требование описано в облачной документации.

main.py
import os

import uvicorn

from app.app import app

if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "8080")), log_config=None)

Создаем Dockerfile

Dockerfile
# Для основы возьмем образ с Python 3.9
FROM python:3.9

# Установим рабочую директорию
WORKDIR /code

# Скопируем файлы с зависимостями
COPY ./requirements.txt /code/requirements.txt

# Установим зависимости
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt

# Скопируем файлы проекта
COPY ./main.py /code
COPY ./app /code/app

# Запустим сервер
CMD python main.py

Собираем контейнер

Для этого подготовим Makefile

Makefile
REVISION := $(shell git rev-parse --short HEAD)
DOCKER_IMAGE_NAME = cr.yandex/your_cr_id/fatsapi:$(REVISION)

build:
docker build --platform linux/amd64 -t $(DOCKER_IMAGE_NAME) .

push:
docker push $(DOCKER_IMAGE_NAME)

run:
docker run --rm -e PORT=8080 -p 8080:8080 $(DOCKER_IMAGE_NAME)

Теперь мы можем собрать и запушить контейнер

make build
make push

Отлично, теперь мы можем запустить контейнер локально

make run

Запускаем в Yandex Cloud Serverless Containers

Для создания ревизии необходимо указать имя и тег контейнера, а также указать сервисный аккаунт, который будет использоваться для запуска контейнера. У него должны быть права на запуск пулл образов из Container Registry. Например, роль container-registry.images.puller на каталог или реестр, в которых находится Docker-образ.

Если все прошло успешно, то при переходе по публичной ссылке из консоли, мы должны увидеть следующий результат:

{"Hello": "World"}

Отлично. Базовый сценарий работает. Давайте теперь добавим улучшений, которые помогут удобнее анализировать работу контейнера.

Добавляем структурированные логи

Если мы запускаем наш текущий пример, то мы увидим в логах следующее:

INFO:     Started server process [7]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
INFO: 172.17.0.1:48202 - "GET / HTTP/1.1" 200 OK

В сервисе просмотра логов в облаке это будет выглядеть так:

Для начала не плохо, но мы можем сделать лучше. Для этого воспользуемся структурированными логами.

Добавляем логгер

app/logger.py
import json
import logging
from logging import Formatter

class JsonFormatter(Formatter):
def __init__(self):
super(JsonFormatter, self).__init__()

def format(self, record):
json_record = {}
json_record["message"] = record.getMessage()
json_record["level"] = str.replace(str.replace(record.levelname, "WARNING", "WARN"), "CRITICAL", "FATAL")
return json.dumps(json_record)

logger = logging.root
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger.handlers = [handler]
logger.setLevel(logging.DEBUG)

Дополним наш main.py следующим образом:

app/main.py
  import uvicorn
from fastapi import FastAPI
+ from src.logger import logger

app = FastAPI()


@app.get("/")
async def hello():
+ logger.info("Hello")
return "Hello World"


if __name__ == "__main__":
- uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "8080")), log_level="info")
+ uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "8080")), log_config=None)

Теперь мы можем запустить контейнер локально и увидеть в логах следующее:

{"message": "Hello", "level": "INFO"}

Неплохое начало. Но можно сделать лучше.

Добавляем контекст

Если в рамках одного запроса мы будем писать в логи, то нам будет полезно знать, какой запрос сейчас обрабатывается. Для того чтобы собрать все записи, мы можем писать в логи идентификатор запроса. Для этого нам необходимо добавить middleware, который будет добавлять в контекст запроса идентификатор.

pip install asgi_correlation_id

Теперь если пользователь в запросе передаст заголовок X-Request-ID, то мы будем использовать его в качестве идентификатора запроса. Если заголовок не передан, то мы сгенерируем случайный идентификатор. Получить идентификатор можно вызвав correlation_id.get().

app/logger.py
  import json
import logging
from logging import Formatter

+ from asgi_correlation_id import correlation_id


class JsonFormatter(Formatter):
def __init__(self):
super(JsonFormatter, self).__init__()

def format(self, record):
json_record = {}
json_record["message"] = record.getMessage()
+ json_record["request_id"] = correlation_id.get()
json_record["level"] = str.replace(str.replace(record.levelname, "WARNING", "WARN"), "CRITICAL", "FATAL")
if "req" in record.__dict__:
json_record["req"] = record.__dict__["req"]

if "res" in record.__dict__:
json_record["res"] = record.__dict__["res"]

if record.levelno == logging.ERROR and record.exc_info:
json_record["err"] = self.formatException(record.exc_info)
return json.dumps(json_record)


logger = logging.root
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger.handlers = [handler]
logger.setLevel(logging.DEBUG)

logging.getLogger("uvicorn.access").disabled = True
+ logging.getLogger("uvicorn.asgi").disabled = True
+ logging.getLogger("uvicorn.error").disabled = True

Отключив uvicorn.asgi и uvicorn.error мы избавимся от дублирования логов, потому что переопределим функциональность их в нашем middleware.

app/log_middleware.py
import sys
import traceback
from time import time

from asgi_correlation_id import correlation_id
from starlette.middleware.base import BaseHTTPMiddleware

from app.logger import logger


class LogMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
try:
start = time()
response = await call_next(request)
process_time = time() - start
logger.info(
"Incoming request",
extra={
"process_time": process_time,
"req": {
"method": request.method,
"url": str(request.url),

},
"res": {"status_code": response.status_code, },
},
)
return response
except Exception:
exc_type, exc_value, exc_traceback = sys.exc_info()
logger.error(
"Internal server error",
extra={
"request_id": correlation_id.get(),
"req": {
"method": request.method,
"url": str(request.url),
},
"res": {"status_code": 500, "err": "".join(traceback.format_exception(
exc_type,
exc_value,
exc_traceback))},
},
)

Теперь в лога мы увидим записи с правильным уровнем логирования, по которому можно легко пофильтровать записи.

А если кликнуть на поле в логах, то мы сможем быстро добавить условие фильтрации по этому полю.

Код примера можно найти тут