У Python-сервиса в Kubernetes нет фреймворка, который сам подключит реестр сервисов, сервер конфигурации и размыкатели: FastAPI даёт маршруты и зависимости, httpx даёт клиента, остальное выбирают по задаче. Это упрощает вопрос «платформа или библиотека» до инженерного: для каждой задачи смотрят, видит ли её платформа. Инфраструктурные задачи (кто где живёт, как доставить конфиг, кого перезапустить) платформа видит и решает лучше кода. Прикладные (ждать ли ответа платёжного шлюза тридцать секунд, повторять ли списание денег) платформа не видит, и их место в коде.
Задача за задачей
| Задача | Kubernetes | Python-сервис |
|---|---|---|
| Найти соседний сервис | DNS и Service: http://orders | клиент по имени, без реестра |
| Балансировка | kube-proxy на уровне соединения | срок жизни соединений; для gRPC свой резолвер |
| Конфигурация | ConfigMap и Secret в переменные или файлы | pydantic-settings при старте, валидация, при нужде перечитывание |
| Секреты | Secret плюс внешнее хранилище через оператор | файл или переменная, без SDK хранилища |
| Перезапуск упавшего | liveness и readiness probes | честные обработчики /health/live и /health/ready |
| Выкат без простоя | rolling update | корректное завершение по SIGTERM |
| Маршрутизация с края | Ingress или Gateway API | свой шлюз только ради прикладной логики |
| Таймауты, повторы, размыкатели | только через mesh | httpx.Timeout, tenacity, aiobreaker |
| Трассировка и метрики | mesh видит только сетевой слой | OpenTelemetry внутри кода |
| Задачи по расписанию | CronJob | планировщик в процессе плюс выбор лидера, если нужно состояние |
| Лимиты запросов | на входе, по IP и пути | по пользователю и тарифу, в коде |
Дальше три строки, в которых чаще всего ошибаются, и две, которые платформа закрывает только наполовину.
Как сосед находится на самом деле
http://orders в кластере это имя объекта Service, полностью orders.shop.svc.cluster.local. Запрос по нему приходит не «на сервис», а на один из его подов, и выбирает под не DNS, а сетевой слой узла: правила kube-proxy перенаправляют соединение на один из живых адресов. Балансировка происходит один раз на соединение, в ядре, без участия приложения.
Балансировка происходит при установке соединения в ядре узла; клиент с keep-alive держит выбранный под часами, и новые поды трафика не получают.
Для Python-сервиса это означает две вещи. Хорошую: никакого реестра, клиента обнаружения и списка адресов в коде, достаточно httpx.AsyncClient и имени. И неприятную: клиент держит постоянные соединения и переиспользует их, пока они живы. Соединение установилось к первому поду, и все запросы через него идут туда часами. Новый под после масштабирования трафика не получает, нагрузка перекашивается, а у gRPC, где одно соединение несёт все вызовы, весь трафик клиента уходит в один под по определению.
У пула httpx нет настройки «срок жизни соединения», есть только keepalive_expiry для простаивающих — по умолчанию пять секунд. Под постоянной нагрузкой соединение не простаивает никогда, поэтому клиента периодически пересоздают сами:
import asyncio
import httpx
class OrdersClient:
def __init__(self, base_url: str) -> None:
self.base_url = base_url
self.client = self._new_client()
def _new_client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=self.base_url,
timeout=httpx.Timeout(5.0, connect=2.0),
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20, keepalive_expiry=30.0),
)
async def rotate_forever(self, every: float = 60.0) -> None:
while True:
await asyncio.sleep(every)
old, self.client = self.client, self._new_client()
await asyncio.sleep(5.0)
await old.aclose()
Раз в минуту запросы переключаются на новый клиент, старый закрывается после паузы на незавершённые ответы, новые соединения открываются уже через kube-proxy и попадают на другие поды. Этого хватает большинству HTTP-сервисов. Для gRPC решение другое: безголовый Service (clusterIP: None) отдаёт в DNS адреса всех подов, а клиент gRPC балансирует вызовы между ними сам:
import grpc
channel = grpc.aio.insecure_channel(
"dns:///orders.shop.svc.cluster.local.:50051",
options=[
("grpc.lb_policy_name", "round_robin"),
("grpc.keepalive_time_ms", 30_000),
("grpc.keepalive_timeout_ms", 5_000),
],
)
Схема dns:/// включает резолвер gRPC, round_robin раскладывает вызовы по всем адресам из ответа DNS, а резолвер перечитывает имя при обрыве соединения. Третий вариант, service mesh, балансирует запросы, а не соединения, и снимает проблему для любого протокола ценой отдельного слоя инфраструктуры.
Две мелочи, которые всплывают на первой аварии. Короткое имя orders резолвер пода разворачивает через список суффиксов (ndots:5 в resolv.conf), и каждый вызов это несколько запросов к DNS; полное имя с точкой на конце (orders.shop.svc.cluster.local.) резолвится одним. И у Python нет собственного кэша DNS: getaddrinfo ходит к резолверу при каждом новом соединении, что ещё один довод за переиспользование соединений и против клиента на каждый запрос.
Конфигурация и секреты: читать просто, обновлять сложно
ConfigMap и Secret попадают в под переменными окружения или файлами, и сервис читает их самым скучным способом: класс BaseSettings из pydantic-settings собирает значения из окружения и файла .env, а валидация происходит при создании объекта. Она обязательна: сервис с лимитом 0 или пустым адресом базы не должен пройти readiness, и Field(gt=0) на поле это проверяет раньше, чем первый запрос. Сервера конфигурации, который надо держать доступным раньше всех остальных, в этой схеме нет, и это хорошо.
Чем платят: изменение ConfigMap само в процесс не приезжает. Переменные окружения не меняются до перезапуска пода, смонтированный файл обновляется с задержкой до минуты-двух, а сервис об этом узнаёт, только если следит за файлом. Обычный ответ это перезапуск через выкат с хешем конфига в аннотации пода; обновление на лету нужно редко и разобрано в отдельной статье.
Secret хранит значение в base64, а не шифрует, поэтому пароли живут во внешнем хранилище (Vault, менеджер секретов облака), откуда их в кластер приносит оператор (External Secrets, Vault Agent). Сервис при этом остаётся простым: читает файл или переменную. Ходить в Vault по клиентской библиотеке из кода оправдано, только когда нужна ротация без перезапуска или динамические учётные данные базы, и тогда Vault становится зависимостью на старте.
Перезапуск и выкат: платформа делает половину
Kubernetes перезапустит упавший под и заменит поды по одному при выкате, но обе гарантии держатся на том, что сделает сервис. Liveness-проба отвечает на вопрос «процесс жив» и не должна зависеть от базы: иначе падение базы перезапустит все поды по кругу. Readiness-проба отвечает «можно давать трафик» и проверяет то, без чего запрос обслужить нельзя, с коротким таймаутом:
import asyncio
from fastapi import APIRouter, Response
health = APIRouter()
@health.get("/health/ready")
async def ready(response: Response, deps: DependenciesDep) -> dict:
try:
async with asyncio.timeout(1.0):
await asyncio.gather(*(dep.ping() for dep in deps))
except (TimeoutError, OSError, ConnectionError) as e:
response.status_code = 503
return {"status": "not ready", "reason": type(e).__name__}
return {"status": "ready"}
Выкат без простоя требует, чтобы сервис по SIGTERM перестал отвечать на readiness, дождался текущих запросов и только потом вышел; без этого каждый rolling update роняет порцию запросов. Как это устроено, от обработки сигнала в uvicorn до preStop, разобрано в завершении FastAPI-сервиса в Kubernetes.
Отдельная особенность Python: uvicorn --workers 4 это четыре процесса в одном контейнере, и у каждого свой пул соединений к базе, свой клиент httpx и свой планировщик. Лимиты пулов и число соединений на стороне базы считают с множителем на число воркеров, а всё, что должно работать в одном экземпляре, — с учётом того, что экземпляров в поде несколько.
Что остаётся коду: устойчивость
Платформа не знает, что POST /charge нельзя повторять, а GET /catalog можно, и не знает, сколько ждать партнёра. Поэтому таймауты, повторы и размыкатели живут в коде, и стандартных кирпичей здесь три.
Таймаут на каждый исходящий вызов: httpx.Timeout(5.0, connect=2.0) у клиента как страховка и asyncio.timeout вокруг сценария как бюджет запроса целиком. Повторы с нарастающей паузой и только для идемпотентных операций: tenacity оборачивает вызов и повторяет сетевые ошибки и 5xx, а предикат позволяет исключить то, что повторять нельзя.
import httpx
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential_jitter
def retryable(error: BaseException) -> bool:
if isinstance(error, httpx.TransportError):
return True
return isinstance(error, httpx.HTTPStatusError) and error.response.status_code >= 500
@retry(retry=retry_if_exception(retryable), stop=stop_after_attempt(3), wait=wait_exponential_jitter(initial=0.1, max=2.0), reraise=True)
async def get_catalog(client: httpx.AsyncClient) -> dict:
response = await client.get("/catalog")
response.raise_for_status()
return response.json()
Размыкатель, который перестаёт ходить к лежащему соседу и даёт ему подняться: aiobreaker для асинхронного кода и pybreaker для синхронного считают ошибки подряд и открываются по порогу.
from aiobreaker import CircuitBreaker, CircuitBreakerError
from datetime import timedelta
payments_breaker = CircuitBreaker(fail_max=5, timeout_duration=timedelta(seconds=20))
async def charge(client: httpx.AsyncClient, request: ChargeRequest) -> ChargeResult:
try:
response = await payments_breaker.call_async(client.post, "/charge", json=request.model_dump())
except CircuitBreakerError as e:
raise PaymentsUnavailable() from e
response.raise_for_status()
return ChargeResult.model_validate(response.json())
Service mesh умеет повторы и размыкатели на уровне сети, но не отличает списание денег от чтения каталога и не видит ошибок, спрятанных в теле ответа 200. Mesh дополняет код там, где сервисов десятки и нужны единые правила между ними, но не заменяет таймаут в обработчике. Подробнее о каждом кирпиче и о том, как они складываются вместе, в паттернах отказоустойчивости на Python.
Что остаётся коду: наблюдаемость, задачи, лимиты
Трассировка и метрики. Mesh показывает, что запрос из orders в payments занял 800 мс, но не покажет, что 700 из них ушли в запрос к базе внутри payments. Отрезки трассировки внутри процесса, метрики по сценариям и логи с идентификатором запроса создаёт код через OpenTelemetry, и это не отменяется никакой платформой.
Задачи по расписанию. Одноразовую или редкую задачу удобнее отдать CronJob: платформа запустит под, дождётся, перезапустит при падении и покажет историю. Задачу, которой нужно состояние процесса или запуск каждые несколько секунд, оставляют внутри сервиса с планировщиком (APScheduler или простой цикл с asyncio.sleep), и тогда при трёх репликах и четырёх воркерах в каждой нужен выбор лидера: блокировка в PostgreSQL (pg_try_advisory_lock), а чаще всего просто идемпотентная задача с SKIP LOCKED в таблице, которую могут выполнять все копии сразу.
Лимиты. Ingress и Gateway API ограничивают по адресу клиента и пути, и этого хватает против грубого потока. Лимит по пользователю, тарифу или ключу API требует знать, кто пришёл, а это знает только код после аутентификации: счётчик в памяти годится для одного воркера, для общего лимита между воркерами и репликами нужен Redis — slowapi поверх limits умеет и то, и другое.
Шлюз. Для маршрутизации, TLS и простых лимитов хватает Ingress. Собственный шлюз на Python оправдан, когда на краю нужна прикладная логика: проверка токена и обогащение заголовков, агрегация ответов, разные лимиты для разных клиентов. Как устроен такой шлюз и каким заголовкам за ним нельзя верить, разобрано в структурных паттернах микросервисов на Python.
Как выбирать
- Сервис в Kubernetes (типовой случай): обнаружение, балансировка, конфигурация, перезапуск и выкат у платформы; в коде таймауты, повторы, размыкатели, трассировка, лимиты по пользователю и корректное завершение. Реестр сервисов, сервер конфигурации и клиент обнаружения не заводят.
- gRPC между сервисами: безголовый Service и резолвер
dns:///сround_robinс первого дня, иначе первый же рост нагрузки покажет один горячий под. - Десятки сервисов и единые правила между ними: mesh как дополнение к коду, не вместо него; таймауты в обработчиках остаются.
- Без оркестратора (виртуальные машины, свой хостинг): реестр вроде Consul и клиентская балансировка снова нужны, и честнее сначала спросить, почему без оркестратора.
Глубже: что ломается при переезде с клиентского обнаружениярасширенное
Сервис, который раньше сам выбирал экземпляр из реестра на каждый запрос, после переезда начинает выбирать его один раз на соединение, и это меняет три привычки. Повтор при ошибке перестаёт попадать на другой экземпляр: соединение то же, под тот же, и повторять имеет смысл только после закрытия соединения или с паузой, за которую kube-proxy успеет перенаправить новое. Проверка здоровья соседа из кода теряет смысл: за это отвечает readiness, и адрес нездорового пода просто исчезает из Service. И «мягкое» снятие с балансировки при выкате становится обязанностью самого соседа: DNS и kube-proxy убирают адрес не мгновенно, поэтому часть запросов в момент выката уйдёт в под, который уже останавливается, и спасает только корректное завершение на его стороне и повтор на стороне клиента.
Коротко
- Инфраструктурные задачи у платформы, прикладные в коде; граница проходит по вопросу «видит ли это Kubernetes».
- Сосед находится по имени Service, балансировка на уровне соединения в kube-proxy; реестр и клиент обнаружения не нужны.
- У пула
httpxнет срока жизни соединения, толькоkeepalive_expiryдля простаивающих: под нагрузкой клиента пересоздают по таймеру, иначе трафик залипает на одном поде. - gRPC: безголовый Service,
dns:///иround_robinчерезgrpc.aio, keepalive; иначе весь трафик клиента в один под. - Полное имя с точкой экономит DNS-запросы при
ndots:5; кэша DNS у Python нет. - Конфигурация через
pydantic-settingsс валидацией при старте; обновление через выкат с хешем, на лету только по необходимости. - Секреты во внешнем хранилище, в под их приносит оператор; клиент хранилища в коде только ради ротации без перезапуска.
- Liveness без базы, readiness с короткими проверками зависимостей; выкат без простоя держится на SIGTERM и ожидании запросов; воркеры
uvicornмножат пулы и планировщики. - Таймауты, повторы для идемпотентного через
tenacity, размыкатели и трассировка внутри процесса остаются в коде даже с mesh. - Расписание через CronJob или планировщик с лидером; лимиты по пользователю в коде с Redis, по адресу на входе.
Что почитать дальше
- Сеть в Kubernetes — Service, kube-proxy, DNS и почему
ndotsдорого обходится. - Деплой и конфигурация в Kubernetes — ConfigMap, Secret, пробы и rolling update.
- Kubernetes и FastAPI: корректное завершение — SIGTERM, preStop и readiness при выкате.
- Паттерны отказоустойчивости на Python — таймауты, повторы и размыкатели как система.
- Структурные паттерны микросервисов на Python — шлюз, обратный прокси и заголовки, которым нельзя верить.
- Конфигурация на лету в Python-сервисе — когда перезапуск не подходит и как перечитать файл из ConfigMap.