Два location, каждый писали десятки раз:
location /api/ { proxy_pass http://backend; proxy_buffering off; proxy_next_upstream error timeout; proxy_connect_timeout 2s; } location /php/ { fastcgi_pass unix:/run/php-fpm.sock; fastcgi_buffering off; fastcgi_next_upstream error timeout; fastcgi_connect_timeout 2s; }
Разные протоколы, разные модули, разные страницы документации — и одинаковый набор опций с точностью до префикса. Не совпадение: 46 директив fastcgi_* из 53 имеют точный аналог у proxy_*, и 39 общих полей конфигурации сливаются с одинаковыми значениями по умолчанию. Расходится ровно одно — путь для временных файлов.
Два независимо написанных модуля так не сходятся. Они и не независимы: всё, чем proxy_pass отличается от fastcgi_pass, — девять указателей на функции. Остальное — общий код в ngx_http_upstream.c, который не принадлежит ни одному из модулей.
Поэтому, когда вы час крутите fastcgi_next_upstream и читаете документацию по fastcgi, вы читаете не тот файл.
Доказательство — свой рабочий модуль для выдуманного протокола, 335 строк и пять колбэков:
$ curl -i http://127.0.0.1:8081/echo/hello HTTP/1.1 200 OK Server: nginx/1.31.3 Content-Length: 63 привет от бэкенда, ты просил /echo/hello
Таймауты, повторы, балансировка — не написаны. Достались даром, потому что лежат не здесь.
Дальше — где проходит граница между «протоколом» и общей машинерией. И заодно выяснится, что контракт врёт в обе стороны: один из девяти указателей не вызывается вообще ни разу с января 2005-го, а десятый, которого в контракте нет, обязателен при включённой буферизации и уронит вас на первом же ответе.
Весь разбор — по тегу release-1.31.3 репозитория nginx/nginx:
git clone https://github.com/nginx/nginx && cd nginx git checkout release-1.31.3
Все ссылки дальше — в формате файл:строка для этого тега.
Это вторая часть. В финале первой я написал, что proxy_pass и fastcgi_pass — «один модуль с разными обработчиками». Неточно: модули разные. Здесь как раз про то, где именно проходит граница между общим и частным.
Улика: 46 совпадающих директив
Числа из первого абзаца проверяются в три команды:
p() { grep -oE "ngx_string\(\"$1_[a-z_]+\"\)" "src/http/modules/ngx_http_$1_module.c" \ | sed "s/.*(\"$1_//; s/\").*//" | sort -u; } p proxy | wc -l # 79 p fastcgi | wc -l # 53 comm -12 <(p proxy) <(p fastcgi) | wc -l # 46
А последняя команда показывает границу лучше любого объяснения:
comm -13 <(p proxy) <(p fastcgi)
catch_stderr index keep_conn param path_info script_name split_path_info
Семь директив, которых нет у proxy, — ровно те семь, что описывают формат FastCGI. Всё остальное у двух модулей общее.
Общее — почти всё
Объяснение в первой же строке обеих конфигураций:
typedef struct { ngx_http_upstream_conf_t upstream; ...
Модули эту структуру не копируют, а включают. proxy_buffering и fastcgi_buffering пишут в одно поле, просто через разные offsetof.
Контракт: девять указателей
src/http/ngx_http_upstream.h:372:
ngx_int_t (*input_filter_init)(void *data); ngx_int_t (*input_filter)(void *data, ssize_t bytes); void *input_filter_ctx; #if (NGX_HTTP_CACHE) ngx_int_t (*create_key)(ngx_http_request_t *r); #endif ngx_int_t (*create_request)(ngx_http_request_t *r); ngx_int_t (*reinit_request)(ngx_http_request_t *r); ngx_int_t (*process_header)(ngx_http_request_t *r); void (*abort_request)(ngx_http_request_t *r); void (*finalize_request)(ngx_http_request_t *r, ngx_int_t rc);
Плюс rewrite_redirect и rewrite_cookie следом — девять. create_key в этот счёт не входит: он существует только в сборке с кешем, и его в блоке видно под #if.
Считаем места вызова (именно вызовы, не функции) в ngx_http_upstream.c:
колбэк |
вызовов |
где |
|---|---|---|
|
1 |
|
|
3 |
|
|
4 |
|
|
1 |
|
|
0 |
— |
К нулю в последней строке вернёмся отдельно, он заслуживает разговора. Сперва две средние строки: у обеих вызовов больше одного, и не все они про то, про что кажется.
У process_header три вызова — и это три разных мира.
:2580 — обычный путь: ngx_http_upstream_process_header(), внутри for ( ;; ). Вернули NGX_AGAIN — цикл делает continue, читает из сокета ещё раз и зовёт снова. Есть и четвёртый способ попасть сюда: goto again на :2600 после Early Hints, когда бэкенд прислал 1xx и за ним в том же буфере лежит следующий блок заголовков. То есть колбэк обязан быть перезапускаемым с произвольной позиции буфера.
:1129 лежит в ngx_http_upstream_cache_send() (:1093) и разбирает заголовок из файла кеша. Тот же колбэк читает и живой ответ бэкенда, и то, что nginx положил на диск час назад. Различить он их не может — и не должен.
Отсюда главное требование к
process_header: чистый разбор буфера. Без побочных эффектов и без единого предположения о том, что за буфером есть сокет.
:2525 — ветка if (u->conf->ignore_input), где нет ни recv(), ни цикла: колбэк зовут один раз по тому, что уже в буфере. Кто ставит этот флаг:
grep -rn 'ignore_input = ' src/ # ngx_http_tunnel_module.c:360
Ровно один модуль. Про него — в конце.
У reinit_request четыре вызова, но три из них про кеш. Обычный повтор на следующий бэкенд — только :2080. Остальные три (:2831, :2876, :4705) стоят под #if (NGX_HTTP_CACHE): отдача протухшего кеша по cache_use_stale и ревалидация 304. В сборке --without-http-cache их нет.
Контракт врёт в обе стороны
Одного указателя не хватает
У input_filter формально есть реализация по умолчанию (:3344):
if (u->input_filter == NULL) { u->input_filter_init = ngx_http_upstream_non_buffered_filter_init; u->input_filter = ngx_http_upstream_non_buffered_filter; u->input_filter_ctx = r; }
Только блок стоит внутри if (!u->buffering) (:3334). Оба вызова u->input_filter — :3374 и :4027 — тоже небуферизованный путь.
Буферизованный путь идёт через ngx_event_pipe_t и другой колбэк с другой сигнатурой (src/event/ngx_event_pipe.h:19 и :44):
typedef ngx_int_t (*ngx_event_pipe_input_filter_pt)(ngx_event_pipe_t *p, ngx_buf_t *buf); ... ngx_event_pipe_input_filter_pt input_filter;
И вот главное:
grep -c 'pipe->input_filter' src/http/ngx_http_upstream.c # 0 grep -n 'p->input_filter(' src/event/ngx_event_pipe.c # 360, 457, 474
Машинерия его никогда не подставляет, а ngx_event_pipe.c зовёт в трёх местах без проверки на NULL. proxy ставит оба — u->input_filter (:963) и u->pipe->input_filter (:959).
Второй этаж той же ловушки: input_filter_init буферизованный путь зовёт (:3604), но с другим контекстом:
if (u->input_filter_init && u->input_filter_init(p->input_ctx) != NGX_OK)
p->input_ctx против u->input_filter_ctx в небуферизованном (:3357). p->input_ctx машинерия тоже не заполняет. Проверка на NULL здесь есть, так что падения не будет — будет NULL в вашем контексте.
Падений, кстати, два, и у них разные стек-трейсы:
buffering = 1,u->pipeне аллоцирован → падение сразу вngx_http_upstream_send_response()наp = u->pipe(:3490) и первом обращении к полю;buffering = 1,u->pipeесть,input_filterне задан → падение вngx_event_pipe.c:360.
Второй коварнее: код собирается, конфиг валиден, первый запрос уходит — и падает на чтении ответа.
Один лишний
Теперь ноль из таблицы.
grep -rn 'abort_request' src/
Двадцать пять вхождений: восемь объявлений, восемь присваиваний, восемь определений функций и одна строка в ngx_http_upstream.h:382. Ни одного вызова.
Не «редко», не «под #if», не «в другом файле». Ноль во всём дереве.
Причём не «перестали вызывать» — не вызывали никогда:
git log --oneline -S'abort_request(r)' --all # пусто: строки вызова нет ни в одном коммите публичной истории git log --oneline -S'abort_request' -- src/http/ngx_http_upstream.h # 02025fd6b nginx-0.1.14-RELEASE import
Указатель приехал с импортом версии 0.1.14 — Tue Jan 18 13:03:58 2005. И вот что делает эту дату интересной: тем же коммитом в nginx приехал ngx_http_fastcgi_module. То есть abort_request положили в контракт ровно тогда, когда у nginx появился второй upstream-протокол — когда контракт вообще понадобился. Заложили в фундамент в день, когда фундамент заливали, и с тех пор ни разу не вызвали.
Двадцать один год восемь модулей — включая tunnel, написанный в апреле 2026-го, — добросовестно реализуют колбэк, которого никто не зовёт. Автор tunnel скопировал его вместе с остальными, потому что так выглядит контракт.
Это не претензия к nginx: abort_request — часть публичного API, выкинуть его нельзя, не сломав сторонние модули. Вывод другой: контракт нельзя читать как спецификацию.
Спецификации, кстати, и нет. Официальный Development Guide про ngx_http_upstream_t не пишет ничего: ни create_request, ни process_header, ни abort_request в нём не встречаются ни разу, слово upstream — пять раз и вскользь. Единственный источник истины про контракт — call sites, и мы их только что пересчитали.
Настоящая форма контракта
Соберём, кто что реально ставит:
Читается сразу:
Пять указателей ставят все восемь — create_request, reinit_request, process_header, abort_request, finalize_request. Это и есть ядро. «Пять из девяти» — не свойство учебного примера, а свойство контракта. И один из этих пяти, как мы только что выяснили, не вызывается никогда.
rewrite_redirect и rewrite_cookie — только proxy и proxy_v2. Это proxy_redirect и proxy_cookie_domain, вещи чисто HTTP-шные. Машинерия проверяет их на NULL, опциональность объявлена честно.
create_key и pipe->input_filter совпадают по колонкам. Не совпадение: кеш идёт через тот же ngx_event_pipe, что и буферизация. Три модуля, которые не ставят pipe->input_filter, — ровно те, что не умеют буферизацию.
Про эти три отдельно. memcached (:618–620) и grpc (:4555–4557) делают то же, что мой учебный модуль:
/* the hardcoded values */ conf->upstream.cyclic_temp_file = 0; conf->upstream.buffering = 0;
Дословно тот же приём, под тем же комментарием, в двух production-модулях. Отсюда же отсутствие директив memcached_buffering и grpc_buffering. Третий, tunnel, уходит иначе: ignore_input = 1 плюс u->upgrade = 1 (:303), а ngx_http_upstream_send_response() проверяет u->upgrade (:3293) до развилки по buffering.
Размеры при этом разлетаются от 5434 строк у proxy до 537 у tunnel. Соблазнительно прочитать как «tunnel умеет меньше» — неверно: соединение, таймауты и повторы у него те же. Сравните крайности осмысленно: memcached при 736 строках разбирает заголовок вида VALUE <key> <flags> <bytes>, а proxy_v2 при 4299 — фреймы HTTP/2 и HPACK. Разница в размере — сложность чужого формата, а не проксирования.
Кстати про proxy_v2. Мультиплексирование — главная фича HTTP/2 — здесь не работает, но не так, как можно подумать: stream_id не константа. На новом соединении он равен единице (:4245), на переиспользованном по keepalive растёт на два (:4231), и фильтр тела переписывает id в уже собранных фреймах (:1092, :1110). Потоки нумеруются честно — 1, 3, 5, 7. Просто в каждый момент времени поток на соединении ровно один: в keepalive-пул оно возвращается только после того, как ответ дочитан. Nginx платит 4299 строк за HTTP/2 к бэкенду и получает из него HTTP/1.1 с бинарным фреймингом.
Свой протокол на том же контракте
Проверим прямо. Модуль под выдуманный протокол:
запрос: GET <uri>\r\n ответ: OK <длина тела>\r\n<тело>
Директива пишет clcf->handler; ядро копирует его в r->content_handler в ngx_http_update_location_config() (ngx_http_core_module.c:1425), а ngx_http_core_content_phase() читает раньше всех обработчиков фазы (:1302). Развилка «веб-сервер или прокси» разрешается одним присваиванием — и не в модуле, а в ядре. Сам механизм на Хабре разбирали simpleadmin и OTUS, повторяться не буду.
Обработчик — создать upstream, подставить колбэки, уйти:
u->create_request = ngx_http_echo_pass_create_request; u->reinit_request = ngx_http_echo_pass_reinit_request; u->process_header = ngx_http_echo_pass_process_header; u->abort_request = ngx_http_echo_pass_abort_request; u->finalize_request = ngx_http_echo_pass_finalize_request; r->main->count++; ngx_http_upstream_init(r); return NGX_DONE;
Те же пять, что у всех восьми. Включая тот, который никто не позовёт, — я оставил его сознательно, чтобы модуль выглядел как все остальные.
Разбор ответа, сокращённо:
for (p = u->buffer.pos; p < last; p++) { if (*p == LF) { goto found; } } return NGX_AGAIN; found: /* ... валидация: префикс "OK " и минимальная длина ... */ len = ngx_atoof(u->buffer.pos + 3, p - u->buffer.pos - 4); u->buffer.pos = p + 1; /* дальше — тело */ u->headers_in.status_n = NGX_HTTP_OK; u->headers_in.content_length_n = len;
Дальше машинерия сама отдаст тело клиенту и закроет соединение с бэкендом.
auto/configure --prefix=/tmp/ngx --with-debug --add-module=../ngx_echo_pass make -j4 && make install
335 строк, из них 105 комментарии и пустые. Кода около 230, и больше половины — обязательная обвязка. Протокол — три функции.
Две грабли на первом модуле
Если соберётесь писать свой, две вещи испортят вам вечер раньше, чем контракт станет важен: обе ловят на первой же сборке и обе не гуглятся по тексту ошибки.
nginx собирается с -Werror безусловно — для gcc (auto/cc/gcc:161), clang и icc, вне всяких условий, с авторским комментарием # stop on warning прямо над строкой. Один неиспользованный массив — и сборка падает целиком.
Вторая: ngx_log_debug1 и формат %*s несовместимы. Звёздочка съедает отдельный аргумент, нужен ngx_log_debug2. Компилятор говорит «macro passed 6 arguments, but takes just 5», что с первого взгляда не наводит.
Кто что делает: трасса
Собираем с --with-debug, ставим error_log ... debug;, делаем один запрос (префиксы вида 21#0: *1 убраны, остальное дословно):
echo_pass create_request: "GET /echo/hello" http upstream connect: -2 http upstream send request handler http upstream send request http upstream send request body http upstream process header echo_pass process_header: длина тела 63 echo_pass finalize_request: rc=0 close http upstream connection: 9
Три строки из девяти — мой код. Причём колбэков я поставил пять: reinit_request не позвали, потому что повтора не было, а abort_request не позовут никогда. Трасса — не иллюстрация, а ещё одно подтверждение.
connect: -2 — это NGX_AGAIN: соединение не установилось мгновенно, машинерия ушла в event loop и вернулась позже. Модуль об этом не знает и знать не должен.
Самый маленький — и самый новый
ngx_http_tunnel_module.c — 537 строк, пять колбэков подряд (:208). Тот же набор, что у учебного модуля. Только это не учебный пример:
git log --diff-filter=A --format='%an, %ad%n%s' \ -- src/http/modules/ngx_http_tunnel_module.c
Roman Arutyunyan, Thu Apr 16 20:48:02 2026 +0400 HTTP tunnel module
Апрель 2026, первый релиз — 1.31.0. В nginx из репозитория вашего дистрибутива его ещё нет.
Из сообщения коммита:
The module handles CONNECT requests and establishes a tunnel to a backend.
CONNECT — это forward-прокси. Не «сходить на бэкенд за ответом», а «протянуть трубу и уйти с дороги». Отсюда и ignore_input: бэкенд в ответ на CONNECT заголовка не присылает, и process_header зовут ровно один раз по тому, что уже в буфере. Тот самый :2525 из таблицы выше.
Контракт, спроектированный под обратное проксирование к FastCGI и HTTP, выдержал задачу другого класса теми же пятью указателями. Ценой отказа от части общего: tunnel идёт через u->resolved (:252), адрес берёт из самого CONNECT и в блок upstream{} не заходит — балансировки у него нет.
Что из этого следует
Директивы *_pass не выбирают модуль проксирования — они выбирают набор колбэков. Отсюда 46 совпадающих директив из 53. Всё, чем отличается разговор с бэкендом, лежит за девятью указателями; всё остальное общее.
Баги и особенности тоже общие. Всё из первой части — просадка effective_weight, поведение max_fails, отрицательные счётчики после сбоя — работает одинаково для любого *_pass с блоком upstream{}. tunnel сюда не входит: у него нет upstream{}.
Контракт — не спецификация. Пять указателей ставят все, но один из них мёртв с января 2005-го, а обязательный pipe->input_filter в контракте не объявлен вовсе. Читать надо call sites, а не заголовочный файл — тем более что в официальном Development Guide этого контракта нет вообще.
Написать поддержку своего протокола реалистично. Не «форкнуть nginx», а добавить пару сотен строк. Только не забудьте pipe->input_filter, если включаете буферизацию, — или сделайте как memcached и grpc и выключите её гвоздями.
Проверьте у себя
Модуль со стенда целиком — в репозитории статьи. Соберите, поставьте error_log ... debug;, сделайте запрос: чередование своего кода и машинерии видно сразу.
Если у вас есть свой upstream-модуль — интересуют две вещи. Сколько колбэков из девяти вы поставили и почему именно столько. И на чём споткнулись при первой сборке. Одной строкой:
модуль <что делает> · <N> колбэков из 9 · первая ошибка сборки: <какая>
И отдельный вопрос, на который у меня нет ответа: зачем abort_request вообще нужен?
Версию «раньше вызывался, потом убрали» я отсёк: строки вызова нет ни в одном коммите публичной истории. Значит, дело в замысле, а не в рефакторинге. Моя догадка слабая — место под «клиент отвалился, бросай запрос к бэкенду» зарезервировали в январе 2005-го и не пригодилось, потому что этот случай закрыл finalize_request: ngx_http_upstream_check_broken_connection() финализирует с NGX_HTTP_CLIENT_CLOSED_REQUEST (:1556, :1565), и модуль отличает такое завершение по rc. Отдельный колбэк оказался не нужен, а из заголовка не ушёл.
Если у вас есть версия лучше или ссылка на обсуждение в nginx-devel — напишите, забираю.