Инженерная практика Серверная сторона: машина, процесс и жизненный цикл запроса
0%

Серверная сторона: машина, процесс и жизненный цикл запроса

Серверная сторона: машина, процесс и жизненный цикл запроса

В прошлой главе мы условились: клиент — это UX, сервер — это истина. Теперь посмотрим, как эта истина устроена изнутри. Запрос ушёл с устройства пользователя, пересёк сеть и уткнулся в сокет. Что происходит дальше — от системного вызова accept() до строчки в логе — и есть содержание серверной инженерии.

Мы не будем разбирать TCP, TLS и HTTP — это трек networking. Не будем учить Kubernetes — это devops. Не будем сравнивать монолит с микросервисами — это глава 05 нашего трека. Здесь — анатомия одного серверного процесса: что он делает с запросом, как он переживает десять тысяч запросов одновременно, и почему в проде он почти всегда не один.

Три разных значения слова «сервер»

Слово происходит от английского to serve — «обслуживать». Это единственная общая часть у трёх совершенно разных вещей, которые им называют, и именно поэтому разговоры о серверах так часто идут по кругу.

Сервер как железо. Компьютер, оптимизированный не под комфорт одного человека, а под непрерывную работу и обслуживание многих: стоечный корпус на 1–4 юнита, память с коррекцией ошибок (ECC), два блока питания, RAID-контроллер, отдельный канал удалённого управления (IPMI/BMC), чтобы перезагрузить машину, когда ОС не отвечает. Ключевое отличие от десктопа — не мощность, а предсказуемость под нагрузкой и ремонтопригодность без выключения. Сегодня большинство разработчиков этого железа никогда не увидят: между ними и стойкой лежат виртуализация, контейнеры и облачные API. Что выбрать — bare metal, VPS или облако — разбирает глава VPS, VDS и bare metal.

Сервер как процесс. Программа, которая слушает порт, принимает запросы, обрабатывает их и отвечает. nginx, postgres, ваш сервис на Go — это серверы в этом смысле. На одной машине их спокойно живёт десяток, и они друг о друге ничего не знают.

Сервер как роль в протоколе. Тот, кто ждёт и отвечает, в противоположность клиенту, который инициирует. Роль не приклеена к процессу навсегда: ваш API — сервер для браузера и клиент для базы данных в одном и том же запросе. В gRPC-стриминге или WebSocket роли и вовсе становятся симметричными после установки соединения.

Отсюда — важное наблюдение, которое обычно формулируют невнятно: клиент и сервер могут жить на одной машине и всё равно общаться по сетевому протоколу. Ваше приложение ходит в PostgreSQL на 127.0.0.1:5432 через тот же HTTP/TCP-стек, что и до удалённой машины, — просто пакеты не выходят за пределы петлевого интерфейса. Это не «лишний слой»: именно он позволяет однажды вынести базу на отдельный хост, не переписав ни строки. Взаимодействие определяется протоколом, а не физическим расположением.

Практическое следствие: когда кто-то говорит «сервер упал», первым делом уточните, что именно. Умерло железо? Упал процесс приложения? Процесс жив, но перестал отвечать на своей роли, потому что весь пул соединений к базе занят? Это три разных инцидента с тремя разными решениями.

Жизненный цикл HTTP-запроса: от сокета до ответа

Это ядро главы. Инженер, который держит эту последовательность в голове, отлаживает проблемы на порядок быстрее, потому что умеет спросить «на каком именно этапе?».

Разберём этапы, задерживаясь на тех, где обычно ломается.

1. Приём соединения. Процесс вызвал listen() и получил очередь ожидающих соединений — backlog. Ядро завершает трёхстороннее рукопожатие само; приложение забирает готовое соединение вызовом accept(). Если приложение не успевает разгребать очередь, она переполняется, и новые SYN просто отбрасываются — клиент видит таймаут подключения, а в логах приложения нет ничего, потому что запрос до него не дошёл. Это классическая диагностическая ловушка: смотреть надо на счётчики ядра, а не на логи сервиса. Плюс TLS-рукопожатие с его собственной стоимостью — см. TLS.

2. Чтение и парсинг. Сервер читает строку запроса и заголовки. Здесь обязаны стоять лимиты: максимальный размер заголовков, максимальный размер тела, таймаут на чтение заголовков. Без последнего вы уязвимы к Slowloris — атаке, где клиент открывает тысячи соединений и шлёт по байту в секунду, удерживая ресурсы. В HTTP/2 и HTTP/3 добавляется мультиплексирование: одно соединение несёт много параллельных потоков, и «соединение» перестаёт быть единицей учёта нагрузки. Детали — HTTP.

3. Маршрутизация. Пара «метод + путь» отображается в функцию-обработчик. Внутри роутера обычно префиксное дерево, поиск за время, пропорциональное длине пути, а не числу маршрутов. Стоимость этого этапа микроскопическая; значение — организационное: маршрут это и есть публичный контракт сервиса.

4. Middleware — цепочка сквозной логики. Слои, через которые запрос проходит по пути к хендлеру и обратно: присвоение request-id, восстановление после паники, распаковка тела, аутентификация, авторизация, ограничение частоты, установка дедлайна, сбор метрик. Порядок здесь — не косметика. Логирование должно стоять снаружи всех остальных, иначе вы не увидите запросы, отбитые лимитером. Восстановление после паники — тоже снаружи, иначе паника в аутентификации уронит процесс. Ограничитель частоты — до тяжёлой работы, иначе он не защищает. Аутентификация — до авторизации, что очевидно, но регулярно нарушается, когда правила разъезжаются по хендлерам: см. авторизация.

5. Хендлер. Здесь и только здесь живёт понимание конкретной операции: разобрать тело, проверить бизнес-правила, вызвать доменный код. Хороший хендлер тонкий — он переводит HTTP в вызов домена и обратно, а не содержит домен внутри себя.

6. Работа с данными. Соединение берётся из пула, а не открывается заново: установка соединения с PostgreSQL стоит миллисекунды и порождает отдельный процесс на сервере БД. Транзакция должна быть настолько короткой, насколько возможно, и внутри неё категорически нельзя ходить в сеть за внешним API. Подробности — Слой данных.

7. Сериализация и ответ. Доменный объект превращается в JSON (или protobuf, или HTML), выбирается код статуса, выставляются заголовки кэширования и корреляции. Здесь же принимается решение о сжатии. Важная деталь: запись в сокет — операция с обратным давлением. Если клиент читает медленно, буфер отправки заполняется, и горутина/поток вашего сервера повисает на записи. Медленный клиент способен исчерпать пул обработчиков — ровно поэтому существует таймаут записи.

8. Наблюдаемость. Структурная строка лога с request-id и trace-id, гистограмма латентности, счётчик статусов, span в распределённой трассировке. Это не «после работы» — это часть работы, без которой сервис необслуживаем: наблюдаемость и дежурства.

Модели конкурентности: как обслужить многих одновременно

Один запрос обслужить легко. Вся инженерия начинается на слове «одновременно». Исторически найдено четыре ответа, и каждый жив до сих пор — просто в своей нише.

Процесс на запрос (CGI). На каждый запрос порождается отдельный процесс. Полная изоляция — падение или утечка не задевает соседей; предельная простота — можно писать на чём угодно, включая shell. Цена: fork плюс exec плюс инициализация рантайма на каждый запрос, никакого переиспользования пула соединений и прогретого кэша. Модель проиграла в вебе, но никуда не делась: именно так работают serverless-функции с холодным стартом и большинство CI-раннеров.

Поток на запрос. Процесс живёт долго, на каждое соединение выделяется поток ОС — Apache в режиме prefork/worker, классические Java-сервлеты, Ruby и PHP под FPM. Огромное достоинство: код пишется линейно, блокирующие вызовы допустимы, отладка тривиальна. Ограничение — стоимость потока: стек в сотни килобайт (по умолчанию 1 МиБ на 64-битном Linux) плюс переключения контекста через ядро. Тысяча одновременных клиентов ещё нормально, десять тысяч — уже нет.

C10K — водораздел. В 1999 году Дэн Кегель сформулировал задачу C10K: как обслужить десять тысяч одновременных соединений на одной машине. Ответ был не «купить железо помощнее», а «сменить модель»: отказаться от потока на соединение и перейти к мультиплексированию ввода-вывода через epoll (Linux) и kqueue (BSD). Эта статья изменила индустрию — из неё вышли nginx, libevent, Node.js и вся событийная школа. Читать её стоит целиком: она про мышление, а не про API.

Событийный цикл. Один поток (или по потоку на ядро) держит тысячи соединений: ядро сообщает, какие дескрипторы готовы, приложение обрабатывает их короткими порциями и никогда не блокируется. Так устроены nginx, Node.js, Redis, Nginx-подобные прокси. Плюсы — минимальная память на соединение, отличная пропускная способность на I/O-нагрузке. Минус жёсткий: любая длинная синхронная операция останавливает всё. Один цикл на миллион итераций, одна распаковка большого JSON, один синхронный bcrypt — и все клиенты ждут. Отсюда правило: тяжёлые вычисления выносятся в пул воркеров или в отдельный сервис. См. производительность конкурентности.

Корутины и зелёные потоки. Компромисс, победивший в 2010-е: рантайм даёт дешёвые единицы исполнения (килобайты вместо мегабайт) и сам мультиплексирует их на небольшое число потоков ОС, автоматически переключаясь на блокирующих операциях. Горутины в Go, процессы BEAM в Erlang и Elixir, виртуальные потоки в Java 21 (JEP 444), корутины Kotlin, async/await в Rust и Python. Вы пишете линейный код, а получаете эффективность событийной модели. Не бесплатно: планировщик в пользовательском пространстве усложняет профилирование, а «дешёвые» единицы исполнения провоцируют запускать их без счёта, пока не кончится память или пул соединений к БД.

Модель Стоимость единицы Стиль кода Ломается на
Процесс на запрос очень высокая любой частых коротких запросах
Поток на запрос ~1 МиБ + контекст линейный, блокирующий 10⁴ соединений
Событийный цикл байты колбэки/async, неблокирующий CPU-bound работе в обработчике
Корутины килобайты линейный неограниченном порождении задач

Дополнительное чтение о внутреннем устройстве событийной модели — глава про nginx в The Architecture of Open Source Applications и документация event loop в Node.js.

Stateless: не догма, а требование масштабирования

«Сервер не должен хранить состояние» звучит как заповедь, и её часто повторяют, не объясняя. Объяснение простое и целиком практическое.

Пусть сессия пользователя лежит в памяти процесса. Тогда: (1) второй экземпляр сервиса не сможет обслужить этого пользователя — придётся привязывать его к конкретному инстансу; (2) перезапуск при деплое разлогинит всех; (3) падение узла потеряет данные; (4) автомасштабирование станет опасным, потому что уменьшение числа реплик означает потерю состояния. Каждое из четырёх следствий блокирует горизонтальное масштабирование — а именно оно является главным способом выдержать рост нагрузки.

Поэтому двенадцатифакторное приложение требует: процессы не разделяют ничего и рассматриваются как одноразовые. Состояние выносится наружу:

  • Сессии — в Redis или в подписанный токен у клиента (см. JWT и токены). Токен избавляет от хранилища, но платит за это трудностью отзыва.
  • Файлы пользователей — в объектное хранилище, а не на диск инстанса: объектные хранилища.
  • Кэш — во внешний Redis или в локальную память с осознанием, что у каждого инстанса она своя и данные могут расходиться.
  • Sticky sessions (привязка клиента к инстансу по cookie на балансировщике) — костыль, а не решение: он лечит симптом, оставляя болезнь. Иногда оправдан для WebSocket-соединений, но он ломает равномерность нагрузки и превращает выкатку в потерю сессий.

Важная оговорка: stateless не означает «состояния нет». Оно есть всегда — просто оно вынесено туда, где им умеют управлять: в базу, кэш, брокер. Более того, существует целый класс принципиально stateful-серверов, и попытка сделать их stateless — ошибка: игровой сервер с тиковым циклом и позициями объектов в памяти, сервер совместного редактирования, движок реального времени. Для них горизонтальное масштабирование решается шардированием по «комнатам», а не отказом от состояния. См. партиционирование.

Жизненный цикл серверного процесса

Сервис — это не только «работает» и «не работает». Между ними есть состояния, которые обязательно нужно спроектировать, иначе каждый деплой будет стоить пользователям ошибок.

Два момента здесь стоят целых инцидентов.

Readiness и liveness — разные пробы. Liveness отвечает на вопрос «процесс жив или его надо перезапустить»; readiness — «можно ли слать мне трафик прямо сейчас». Если поставить проверку доступности базы в liveness, то кратковременная недоступность БД приведёт к перезапуску всех инстансов сразу — вместо деградации вы получите полный отказ.

Graceful shutdown — это не «поймать сигнал и выйти». Правильная последовательность: получили SIGTERM → выставили readiness=falseподождали, пока балансировщик заметит и перестанет слать новые запросы (обычно 5–15 секунд, это самый забываемый шаг) → перестали принимать соединения → дождались завершения активных запросов с лимитом по времени → закрыли пул БД, отправили несброшенные метрики и трейсы → вышли. Без паузы после снятия readiness часть запросов гарантированно попадёт в уже закрывающийся процесс, и пользователь увидит 502. Про безопасные выкатки — release safety.

Классификация серверных ролей

Старые справочники перечисляют «web-сервер, игровой, почтовый, DNS, FTP, VPN, прокси» одним плоским списком, будто это однородные сущности. Полезнее классифицировать по роли в системе — по тому, где процесс стоит на пути запроса и чем владеет.

Как читать эту карту. Фронтальный слой ничего не знает о вашем домене: он принимает соединения, снимает TLS, выбирает живой апстрим, режет слишком частых клиентов, отдаёт статику. Именно сюда переехали «веб-сервер» и «прокси-сервер» из старых списков, и именно здесь ваше приложение получает бесплатную защиту от медленных клиентов. См. прокси и балансировка.

Прикладной слой — то, что пишете вы. Сюда же попадают «игровой сервер» и «видеосервер» из легаси-классификации, и полезно понять, чем они особенные: игровой держит авторитетное состояние мира в памяти и работает тиковым циклом, а не запрос-ответом; медиасервер отдаёт длинные потоки и упирается не в CPU, а в сеть и диск, поэтому его выносят на CDN. Реалтайм-протоколы — в главе WebSocket и реальное время.

Слой хранения владеет данными. «Файловый сервер» и «FTP-сервер» из старых списков в современных системах почти полностью вытеснены объектным хранилищем с S3-совместимым API, а «сервер локальной сети» — это не тип сервера, а описание сетевого периметра: та же файловая шара, СУБД или каталог, просто недоступные извне.

Инфраструктурный слой обслуживает не пользователей, а саму систему: DNS переводит имена в адреса (DNS), VPN даёт защищённый доступ в периметр (NAT, файрволы, VPN), почтовый сервер обслуживает SMTP/IMAP, каталог хранит учётные записи. Эти роли обычно не пишут, а эксплуатируют или покупают как сервис.

Фоновая обработка не отвечает клиенту вовсе: она разбирает очередь, шлёт письма, пересчитывает отчёты, чистит старые данные.

Почему в проде это почти всегда разные процессы, а не один «сервер». Четыре причины, и все практические. Во-первых, разные профили нагрузки: API упирается в задержку, воркер — в пропускную способность, отчёт — в CPU и память; смешав их, вы получите отчёт, который тормозит оформление заказа. Во-вторых, разное масштабирование: реплик API нужно столько, сколько пользователей, а воркеров — сколько задач в очереди. В-третьих, изоляция сбоев: тяжёлая задача, съевшая память, не должна отправлять в OOM ваш API. В-четвёртых, разный режим остановки: HTTP-инстанс дренится за секунды, воркер обязан доработать текущую задачу. Как это раскладывается по инфраструктуре — Масштабирование.

Фоновая работа: очередь, воркер, идемпотентность

Как только в хендлере появляется что-то, чего пользователь не обязан ждать (письмо, отчёт, вебхук наружу, пересчёт индекса), оно должно уехать в очередь. Схема простая: хендлер кладёт задачу в брокер и немедленно отвечает 202 Accepted, отдельный процесс-воркер разбирает очередь.

Три правила, без которых схема превращается в источник инцидентов. Обработчик обязан быть идемпотентным: практически все брокеры дают гарантию «хотя бы один раз», значит одна и та же задача рано или поздно приедет дважды, и повторное выполнение не должно создавать второе письмо или второй платёж. Ретраи — с экспоненциальной задержкой и джиттером: одновременный повтор тысячи задач добьёт уже страдающую зависимость. Нужна очередь мёртвых писем (DLQ) и предел числа попыток, иначе одна «ядовитая» задача будет вечно крутиться и забивать воркеры. Подробно — идемпотентность и доставка и обмен сообщениями.

Ресурсы и пределы: где сервер упирается

Сервер редко «не выдерживает нагрузку» абстрактно. Он упирается в конкретный конечный ресурс, и их немного.

Файловые дескрипторы. Каждое соединение, каждый открытый файл — дескриптор. Лимит по умолчанию (ulimit -n) исторически 1024, и его надо поднимать осознанно. Симптом исчерпания — too many open files и внезапная неспособность принимать соединения при нормальном CPU.

Эфемерные порты и TIME_WAIT. Исходящие соединения расходуют порты из ограниченного диапазона; после закрытия сокет некоторое время висит в TIME_WAIT. Сервис, который на каждый запрос открывает новое соединение к соседу вместо переиспользования, упирается в этот потолок задолго до полки по CPU. Лечится пулом keep-alive-соединений.

Пул соединений к базе. Самый частый и самый недооценённый предел. Правило: суммарный размер пулов всех инстансов должен быть меньше max_connections базы, с запасом на служебные подключения и миграции. Двадцать инстансов по пулу в 50 соединений — это 1000 подключений, чего PostgreSQL по умолчанию не даст. Обратная ошибка симметрична: пул меньше числа обработчиков превращает базу в узкое место, и корутины начинают стоять в очереди за соединением. Между приложением и базой ставят пулер (PgBouncer и подобные): PostgreSQL.

Обратное давление. Когда входящий поток стабильно превышает пропускную способность, очередь внутри процесса растёт, латентность уходит в небо, а клиенты уже отвалились по таймауту — сервер работает вхолостую. Фред Эбер сформулировал это точнее всех: очереди не лечат перегрузку. Лечит сброс нагрузки: ограничение числа одновременно обрабатываемых запросов и честный 429/503 сверх него. Это лучше, чем медленно умирать для всех: см. деградация и паттерны устойчивости.

Чек-лист таймаутов

Правило, которое связывает всю таблицу: таймаут внешнего уровня должен быть больше суммы внутренних, но конечен везде. Отсутствие таймаута — это таймаут, равный бесконечности, и он всегда неправильный.

Уровень Что ограничиваем Ориентир
Балансировщик / ingress полное время запроса 30–60 с
Сервер: чтение заголовков защита от Slowloris 5 с
Сервер: чтение тела загрузка файлов 15–60 с
Сервер: запись ответа медленный клиент 30 с
Сервер: idle keep-alive простаивающие соединения 60–75 с, больше чем у прокси перед ним
Хендлер (контекст запроса) бюджет обработки 1–5 с
Получение соединения из пула ожидание в очереди 0.5–2 с
Запрос в БД (statement_timeout) зависший запрос меньше бюджета хендлера
Исходящий HTTP: connect / total внешняя зависимость 1 с / 3 с
Ретраи, суммарно общий бюджет попыток не больше бюджета хендлера
Graceful shutdown drain активных запросов больше самого долгого запроса

Ориентиры и логику подбора хорошо разбирает AWS Builders’ Library: Timeouts, retries and backoff with jitter.

Код: минимальный сервер, который не стыдно вывести в прод

Go, стандартная библиотека, ничего лишнего — но со всеми таймаутами и корректной остановкой.

package main

import (
	"context"
	"errors"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"sync/atomic"
	"syscall"
	"time"
)

// ready — готовность принимать трафик. Читается пробой /readyz.
// Отдельно от liveness: liveness отвечает «жив ли процесс», ready — «слать ли мне запросы».
var ready atomic.Bool

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	mux := http.NewServeMux()
	mux.HandleFunc("GET /livez", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK) // процесс жив — этого достаточно, в БД не ходим
	})
	mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
		if !ready.Load() {
			http.Error(w, "draining", http.StatusServiceUnavailable)
			return
		}
		w.WriteHeader(http.StatusOK)
	})
	mux.HandleFunc("GET /api/orders/{id}", handleGetOrder)

	srv := &http.Server{
		Addr: ":8080",
		// Порядок обёрток важен: восстановление после паники — снаружи всего,
		// иначе паника в аутентификации уронит процесс целиком.
		Handler: withRecover(withRequestID(withAccessLog(logger, mux))),

		ReadHeaderTimeout: 5 * time.Second,  // защита от Slowloris
		ReadTimeout:       15 * time.Second, // всё чтение запроса вместе с телом
		WriteTimeout:      30 * time.Second, // защита от медленного клиента
		IdleTimeout:       60 * time.Second, // сколько держим простаивающий keep-alive
		MaxHeaderBytes:    1 << 20,          // 1 МиБ — иначе память съест один клиент
	}

	// Прогрев ДО объявления готовности: пул БД, кэши, проверка зависимостей.
	pool := mustOpenDB(logger)
	ready.Store(true)

	// Контекст отменяется по SIGINT или SIGTERM.
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	go func() {
		logger.Info("сервер слушает", "addr", srv.Addr)
		// При штатной остановке ListenAndServe вернёт ErrServerClosed — это не ошибка.
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			logger.Error("сервер не смог слушать порт", "err", err)
			os.Exit(1)
		}
	}()

	<-ctx.Done()
	logger.Info("получен сигнал остановки, начинаем drain")

	// Шаг 1. Снимаем себя с балансировщика и ЖДЁМ, пока он это заметит.
	// Самый забываемый шаг: без него часть запросов прилетит в закрывающийся процесс.
	ready.Store(false)
	time.Sleep(10 * time.Second)

	// Шаг 2. Перестаём принимать новые соединения, ждём завершения активных.
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		logger.Error("drain не уложился в срок, рвём соединения", "err", err)
		_ = srv.Close()
	}

	// Шаг 3. Освобождаем ресурсы: пул БД, продюсер очереди, экспортёр трейсов.
	pool.Close()
	logger.Info("остановлены штатно")
}

func handleGetOrder(w http.ResponseWriter, r *http.Request) {
	// Бюджет на обработку. r.Context() уже отменяется при разрыве соединения клиентом,
	// а этот дедлайн дополнительно ограничивает нас самих и всё, что ниже по стеку.
	ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
	defer cancel()

	order, err := findOrder(ctx, r.PathValue("id"))
	switch {
	case errors.Is(err, errNotFound):
		writeJSON(w, http.StatusNotFound, apiError{Code: "NOT_FOUND"})
	case errors.Is(err, context.DeadlineExceeded):
		// Честно говорим, что не успели, вместо того чтобы висеть.
		writeJSON(w, http.StatusGatewayTimeout, apiError{Code: "TIMEOUT"})
	case err != nil:
		writeJSON(w, http.StatusInternalServerError, apiError{Code: "INTERNAL"})
	default:
		writeJSON(w, http.StatusOK, order)
	}
}

Тот же смысл на Python — событийный цикл вместо горутин, но требования не меняются: прогрев до готовности, дедлайн на обработку, пауза перед закрытием.

# main.py — asyncio + FastAPI. Логика жизненного цикла та же, что в Go.
import asyncio
from contextlib import asynccontextmanager

import asyncpg
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse

state: dict = {"ready": False, "pool": None}


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Прогрев: поднимаем пул ДО того, как объявим себя готовыми принимать трафик.
    state["pool"] = await asyncpg.create_pool(
        dsn="postgresql://app@db/app",
        min_size=5, max_size=20,      # суммарно по всем инстансам < max_connections
        command_timeout=3,            # серверный предел на запрос
    )
    state["ready"] = True
    try:
        yield
    finally:
        # Drain: сначала снимаемся с балансировщика, ждём, потом закрываем ресурсы.
        state["ready"] = False
        await asyncio.sleep(10)
        await state["pool"].close()


app = FastAPI(lifespan=lifespan)


@app.get("/readyz")
async def readyz():
    if not state["ready"]:
        return JSONResponse({"status": "draining"}, status_code=503)
    return {"status": "ok"}


@app.get("/api/orders/{order_id}")
async def get_order(order_id: str):
    try:
        # Без бюджета медленная БД превращается в бесконечно растущую очередь задач,
        # и event loop начинает обслуживать клиентов, которые давно ушли по таймауту.
        async with asyncio.timeout(3):
            async with state["pool"].acquire() as conn:
                row = await conn.fetchrow(
                    "SELECT id, total FROM orders WHERE id = $1", order_id
                )
    except TimeoutError:
        raise HTTPException(status_code=504, detail="TIMEOUT")

    if row is None:
        raise HTTPException(status_code=404, detail="NOT_FOUND")
    return dict(row)

Отдельно проследите, чтобы drain дожил до продакшена: uvicorn --timeout-graceful-shutdown 30, а в оркестраторе — terminationGracePeriodSeconds больше, чем сумма паузы и лимита drain. Иначе процесс убьют посреди корректной остановки.

Типичные ошибки

Бесконечные таймауты. HTTP-клиент по умолчанию во многих языках ждёт вечно. Один зависший внешний сервис постепенно занимает все обработчики, и падает не он, а вы. Правило: у каждого сетевого вызова есть явный дедлайн.

Отсутствие graceful shutdown. Или он есть, но без паузы после снятия readiness. Симптом — всплеск 502 при каждом деплое, который команда привыкла считать нормой.

Состояние в памяти процесса. Счётчики, кэш авторизации, «мы же помним, что этот пользователь уже платил». Работает, пока инстанс один; на втором начинаются необъяснимые расхождения, а при рестарте всё пропадает.

Синхронный вызов внешнего API прямо в хендлере. Особенно внутри транзакции БД. Транзакция держит блокировки столько, сколько отвечает чужой сервис, — и деградация партнёра превращается в дедлоки у вас. Всё, чего пользователь может не ждать, уезжает в очередь.

Пул соединений меньше числа обработчиков. Тысяча горутин конкурирует за 10 соединений: CPU простаивает, латентность растёт, а метрики базы показывают, что она не нагружена. Обратный вариант — суммарный пул больше max_connections — даёт too many connections при выкатке новой версии, когда старые и новые инстансы существуют одновременно.

Пробы, которые лгут. /healthz, отвечающий 200 всегда, — бесполезен; он же, ходящий в базу и объявленный liveness-пробой, — опасен, потому что превращает деградацию БД в перезапуск всего парка.

Логи без корреляции. Строки без request-id и trace-id не позволяют собрать историю одного запроса из трёх сервисов. Это выясняется в момент инцидента, когда что-то менять уже поздно.

Мини-итог

  • «Сервер» — три разные вещи: железо, процесс и роль в протоколе. Уточняйте, о какой идёт речь, особенно в инциденте.
  • Клиент и сервер общаются по протоколу независимо от того, на одной ли они машине; это и позволяет разносить их позже без переписывания.
  • Путь запроса — accept, парсинг, маршрутизация, middleware, хендлер, данные, сериализация, ответ, наблюдаемость. Ломается почти всегда на конкретном шаге, и полезно уметь спросить, на каком.
  • Четыре модели конкурентности живы одновременно; C10K объяснил, почему поток на соединение перестал масштабироваться, и породил событийную школу, из которой выросли корутины.
  • Stateless — не догма, а условие горизонтального масштабирования. Состояние никуда не исчезает, оно выносится туда, где им умеют управлять.
  • В проде фронтальный слой, приложение, хранилище и воркеры — всегда разные процессы: разная нагрузка, разное масштабирование, разная изоляция сбоев.
  • Таймаут есть у всего, graceful shutdown включает паузу после снятия readiness, а пул соединений согласован с лимитами базы.

Источники

Что дальше

Мы дошли до момента, где хендлер обращается к хранилищу, и остановились. Дальше — самое дорогое и самое инертное, что есть в системе: Слой данных.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов