У директив proxy_* и fastcgi_* совпадает 46 опций из 53 — буферизация, таймауты, кеш, next_upstream, с точностью до префикса и вплоть до значений по умолчанию. Два независимо написанных модуля так не сходятся.Они и не независимы. Всё, чем proxy_pass отличается от fastcgi_pass, — девять указателей на функции в ngx_http_upstream_t. Соединение, таймауты, повторы, буферизация и отдача клиенту лежат в общих 7352 строках ngx_http_upstream.c, и восемь модулей — от proxy до свежего tunnel — дёргают один и тот же код.Только контракт из этих девяти указателей врёт в обе стороны. Один из них, abort_request, ставят все восемь модулей — а машинерия не вызывает его ни разу: ноль вызовов во всём дереве и ни одного коммита с вызовом за всю публичную историю, с импорта 0.1.14 в январе 2005-го. Другой, pipe->input_filter, в контракте не объявлен вовсе — но обязателен, как только включена буферизация, и вызывается без проверки на NULL.Разбираем по тегу release-1.31.3 со ссылками файл:строка: все места вызова каждого колбэка, включая тот, который разбирает заголовок не из сокета, а из файла кеша; матрица «кто какие указатели ставит» по всем восьми модулям; два сценария падения с разными стек-трейсами. Плюс свой рабочий upstream-модуль на 335 строк, собранный и проверенный curl'ом, и tunnel — самый маленький из восьми, приехавший в open source в апреле. Читать далее
Два 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 — «один модуль с разными обработчиками». Неточно: модули разные. Здесь как раз про то, где именно проходит граница между общим и частным.
Числа из первого абзаца проверяются в три команды:
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 |
|
| — |
К нулю в последней строке вернёмся отдельно, он заслуживает разговора. Сперва две средние строки: у обеих вызовов больше одного, и не все они про то, про что кажется.
У 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: два колбэка, две сигнатуры, две точки падения
Падений, кстати, два, и у них разные стек-трейсы:
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 install335 строк, из них 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.cRoman 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 — напишите, забираю.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | В nginx один алгоритм балансировки | 0 | 8.33 | 04-08-2026 |
| 2 | ставим 6 прoкси в 2 клика за 5 минут на 1 VPS | 5 | 7 | 03-07-2026 |
| 3 | И еще немного извращений из мира прокси и VPN | 0 | 8.75 | 30-07-2026 |
| 4 | Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов | 5 | 8 | 07-07-2026 |
| 5 | Свой VPN на Rust: как я спорил с сетью, TLS и самим собой | 7 | 8 | 27-06-2026 |
| 6 | Свой VPN на Rust: как я спорил с сетью, TLS и самим собой | 0 | 8.78 | 27-06-2026 |
| 7 | Собрать прошлое: как архивировать весь трафик сборки SONiC | 0 | 8.94 | 20-07-2026 |
| 8 | Теперь вы можете защититься от утечки данных при работе с любыми языковыми моделями | 0 | 9.5 | 22-07-2026 |
| 9 | Формула «идеального enterprise» для open-source | 0 | 18.47 | 12-08-2026 |
| 10 | Вторая копия Vue: как лишняя строка в lockfile повесила Chromium | 0 | 6.77 | 09-08-2026 |