Серверная сторона: машина, процесс и жизненный цикл запроса
В прошлой главе мы условились: клиент — это 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, а пул соединений согласован с лимитами базы.
Источники
- Dan Kegel. The C10K problem — статья, изменившая подход к серверной конкурентности.
- The Architecture of Open Source Applications: nginx — как устроен событийный сервер изнутри.
- Node.js: The event loop — официальное описание фаз цикла.
- JEP 444: Virtual Threads — возвращение линейного кода без цены потоков ОС.
- The Twelve-Factor App — почему процессы одноразовые и ничего не разделяют.
- Fred Hebert. Queues Don’t Fix Overload — про обратное давление и сброс нагрузки.
- AWS. Timeouts, retries, and backoff with jitter — практическое руководство по дедлайнам.
- Google SRE Book. Handling Overload — что делать, когда запросов больше, чем ёмкости.
- pkg.go.dev: net/http.Server — документация полей таймаутов из примера выше.
Что дальше
Мы дошли до момента, где хендлер обращается к хранилищу, и остановились. Дальше — самое дорогое и самое инертное, что есть в системе: Слой данных.