Стриминг ZIP‑архивов на лету с nginx + mod_zip — просто, как 2 байта переслать

от автора

Я работаю в Росэлторг, крупнейшем операторе электронных торгов: множество торговых секций, более миллиона пользователей, а общий объём проведённых торгов перевалил за 52 трлн рублей. Документов соответственно много: документация по закупкам, заявкам, протоколам, аккредитационные сведения, договоры — каждый привязан к сущностям со своими правилами доступа в зависимости от набора ролей пользователя и состояния закупочной процедуры.

Пользователям регулярно нужно скачивать наборы документов архивом. Долгое время у нас для этого использовались только файлы из собственной системы, которые мы сами же и генерировали, либо которые к нам загружали пользователи. Для этого у нас используется mod_zip, модуль для nginx. Когда передо мной встала задача интегрировать два новых внешних источника документов, то я столкнулся со сложностями, о которых расскажу ниже. Новые файлы хранились не у нас, а в сторонних сервисах. У другой команды уже был прототип решения: скачивание удалённых файлов на сервер, расчет контрольных сумм, сборка манифеста ZIP и выдача клиенту. Работает, но появляется лишняя нагрузка на диск и сеть, пользователь вынужден дольше ждать, когда начнется скачивание.

Я решил это иначе — через mod_zip с proxy_pass для удалённых источников. В конце статьи есть ссылка на демо, где всё это можно потрогать руками. В демо‑репозитории и листингах кода в статье я привожу простейшие конструкции для демонстрации сути подхода, без бизнес‑логики, проверки прав, доступности файла и так далее

Как работает mod_zip

Модуль перехватывает HTTP‑ответы с заголовком X-Archive-Files: zip, читает тело ответа как манифест файлов, получает каждый файл через механизм внутренних подзапросов nginx и стримит валидный ZIP‑архив напрямую клиенту, ничего не записывая на диск.

Формат манифеста: по одному файлу на строку в следующем формате:

<crc32> <размер_в_байтах> <внутренний_url> <имя_в_архиве> <crc32> <размер_в_байтах> <внутренний_url> <имя_в_архиве>

Пример:

037b3c3a  664   /local-files/lorem.txt              lorem.txtb91a4c12  8832  /local-files/data.csv               data.csv-         1478  /proxy-remote/evanmiller/mod_zip/master/LICENSE  remote/LICENSE

Поле внутренний_url соответствует location в nginx.conf. На практике надёжно работают два варианта: файловый alias для локальных файлов и proxy_pass для удалённых. Клиент эти пути никогда не видит.

Поле CRC32 опционально: если нет возможности посчитать контрольную сумму, то можно проставить - и mod_zip посчитает его на лету. Минус: отключается поддержка заголовка Range, то есть возобновляемые загрузки работать не будут. Так как у нас ссылки на скачивание архивов по своей сути одноразовые, то для нас это приемлемо. Поля размер, расположение и имя в архиве — обязательны. Без точного размера файла архив будет битым. Об этом подробнее ниже.

Архитектура

Поток запросов при скачивании архива:

Клиент └─► nginx (mod_zip)      ├─ /api/download/*  ──► PHP-FPM (FastCGI)      │                           └─ возвращает манифест X-Archive-Files      │      ├─ [internal] /local-files/    ──► alias /var/www/files/      └─ [internal] /proxy-remote/   ──► proxy_pass upstream_service

Роль бэка (у нас PHP, поэтому дальше буду писать PHP) здесь минимальна: собрать манифест и вернуть его с нужными заголовками. PHP не видит содержимого файлов вообще. nginx получает каждый файл через подзапрос и пишет его в ZIP‑поток.

Конфиг nginx:

# PHP API — публичныйlocation /api/ {    fastcgi_pass php:9000;    fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;    include fastcgi_params;}# Локальные файлы: internal - только для подзапросов nginxlocation /local-files/ {    internal;    alias /var/www/files/;}# Удалённые файлы: также internal, запрос проксируется на внешний сервисlocation /proxy-remote/ {    internal;    proxy_pass https://your-external-service.com/;    rewrite ^/proxy-remote/(.*) /$1 break;}

Пример функции скачивания:

public function download(Response $res, array $files): Response{    $lines = [];    foreach ($files as $entry) {        $lines[] = sprintf(            '%s %d %s %s',            $entry['crc32'] ?? '-',            $entry['size'],            $entry['internal_url'],            $entry['filename']        );    }    $manifest = implode("\n", $lines) . "\n";    return $res        ->withHeader('X-Archive-Files', 'zip')        ->withHeader('Content-Disposition', 'attachment; filename="documents.zip"')        ->withStatus(200)        ->withBody($this->toStream($manifest));}

Никакого кода стриминга, ZIP‑библиотек или временных файлов.

Почему размер обязателен

ZIP‑архив это не один сжатый блоб, а последовательность независимых записей. Каждая запись начинается с local file header, структуры фиксированного размера с именем файла, методом сжатия и размерами. После заголовка идут данные. В конце архива central directory, оглавление со ссылками на все записи. Формат проектировался для последовательной записи: написали заголовок, стримим данные, переходим к следующему файлу. При генерации ZIP непосредственно в HTTP‑ответ вернуться назад и переписать уже отправленный заголовок невозможно — байты уже отправились клиенту по сети.

В самом ZIP‑формате предусмотрен механизм Data Descriptor (generalPurposeBitFlag=0×0008), когда в local file header можно записать нули или заглушки для размеров и CRC, а после данных файла дописать структуру с реальными значениями. Но mod_zip пишет local file header до того, как отправляет подзапрос за данными файла. К моменту, когда придёт первый байт содержимого, заголовок с размером уже отправлен. Любое подставное значение даст битый архив. Для локальных файлов всё просто: filesize() в PHP. С удалёнными файлами сложнее.

Решением может стать HEAD‑запрос, при условии, что удалённый сервис возвращает Content-Length. Это обязательное требование: без этого у вас нет размера для манифеста:

private function getRemoteFileSize(string $url): int{    $ctx = stream_context_create(['http' => [        'method'          => 'HEAD',        'timeout'         => 5,        'follow_location' => 1,        'ignore_errors'   => true,        'header'          => "User-Agent: your-app/1.0\r\n",    ]]);    $headers = @get_headers($url, true, $ctx);    if ($headers === false) {        return 0;    }    $cl = $headers['Content-Length'] ?? $headers['content-length'] ?? 0;    if (is_array($cl)) {        $cl = end($cl); // на случай редиректов    }    return (int) $cl;}

Важный нюанс: PHP не имеет доступа к internal location напрямую — он доступен только через nginx subrequest, инициируемый mod_zip. Директива internal означает, что location доступен только изнутри самого nginx. Поэтому для HEAD‑запроса PHP идёт напрямую к внешнему сервису (скорее всего, необходимый хост и так доступен вашему проекту и используется в коде, но для демонстрации я запроксировал и этот location). Сами данные файла пройдут через nginx proxy_pass, когда mod_zip запустит nginx‑подзапрос.

На практике, для каждого удалённого источника нужен как минимум один nginx location‑префикс (internal, если файлы недоступны через публичное API, или без этого атрибута, если формирование архива просто одна из опций получить файлы). В демо я сделал один internal для GET‑подзапросов mod_zip, один обычный для HEAD‑запросов из PHP. В демо я назвал их /proxy-remote/ и /proxy-head/.

Если внешний источник требует аутентификации, то необязательно проксировать напрямую. Можно направить internal location на контроллер в своём приложении за пределами обычного auth middleware, там проставить нужные токены и отдать поток от внешнего сервиса обратно подзапросу. То есть получится такой контроллер, который недоступен извне, не требует аутентификации, но при этом имеет все плюсы самого приложения (как минимум доступ к env, БД и возможность писать логи в поток вывода приложения). В этом же контроллере при желании можно разделить и разные источники, то есть подзапрос nginx будет по префиксу направляться на контроллер, а уже из него получать поток на нужный источник данных.

До и после

mod_zip + скачивание файлов

mod_zip + proxy_pass

Время до первого байта

После завершения скачивания

Сразу

Память на загрузку

Пропорционально размеру архива

Несколько КБ

Сеть на загрузку

Дважды: upstream → сервер, сервер → клиент

Один раз: upstream → nginx → клиент

Параллельные загрузки

Ограничены диском и CPU

Ограничены сетью

Удалённые файлы, Disk I/O

Скачать локально, потом в архив

Стримятся напрямую через nginx

То есть бонусом к простой реализации получаем еще уменьшение нагрузки и на диск, и на сеть вдвое. Минус — достаточно сложное для понимания взаимодействие и необходимость обновлять nginx.

Дебаггинг подзапросов

Подзапросы mod_zip внутренние для nginx и не попадают в логи приложения, не появляются в вашей системе сбора логов, потому что идут мимо потоков вывода приложения. Логов подзапросов не будет в Kibana или иной системе. Когда что‑то пойдёт не так, архив просто не сформируется, поэтому необходимо тщательно проверять и логировать все на уровне формирования манифеста и до передачи управления nginx.

Для удалённых источников надёжно работает только proxy_pass. Я пробовал кастомные FastCGI‑обработчики и другие варианты, и ничего не работало. Модуль завязан на том, как nginx обрабатывает проксированные ответы внутри. proxy_pass с internal — единственный вариант (если знаете другие, то делитесь в комментариях).

Когда нужно понять что происходит, помогает debug‑лог nginx (при разработке он доступен, в продакшене зачастую уже нет):

error_log /var/log/nginx/error.log debug;

Ищите строки вида:

mod_zip: subrequest for "/proxy-remote/some/file.pdf" initiated, result 0mod_zip: subrequest for "/proxy-remote/some/file.pdf" finalized, status 200

Отсутствующая строка «finalized» или статус не 200 — указатель на проблемный файл.

Что пошло не так

В реальном коде структура сложнее, чем в демо: пакеты документов охватывают разных контрагентов с разными ролями и правами. Логика манифеста учитывает типы документов и структуру папок внутри архива, есть retry‑логика и fallback, когда внешний сервис недоступен.

Два внешних источника документов. Первый поддерживал HEAD и с ним всё просто, размеры резолвились при сборке манифеста. Второй не поддерживал HEAD, но у нас в базе были метаданные файлов с размерами, которые мы получали через очередь и записывали в БД — использовали их.

Тесты успешно прошли на всех стендах. Зарелизились, но архивы по второму источнику не формировались. Поскольку ошибки подзапросов никуда не всплывают, единственный инструмент это лог nginx. Вернувшись к коду, нашёл единственное место где могла быть проблема: размеры из базы. Запрос в прод по вложенным JSONB‑структурам показал нулевые значения в части записей. Оказалось, что мы получали пакеты с нулевыми размерами файлов в части записей. Тестовые данные были чистыми (да и создать вручную такие записи во внешней системе невозможно), поэтому такие ошибки не поймали. Для новых пакетов договорились: соседний сервис для новых документов в случае отсутствии размера выполняет HEAD‑запросы на своей стороне и формирует корректные пакеты для отправки к нам. Для существующих данных, а это миллионы JSONB‑записей, большинство из которых никогда не попадут в архивный запрос, массовая миграция не имела смысла. Поэтому обращаемся к новому эндпоинту лениво, в момент сборки манифеста, когда встречаем нулевой размер в базе.

0 это валидное целое число, исключения из‑за строгой типизации не будет. Просто получите битый архив или зависшую загрузку, которая отваливается по таймауту. Стоит валидировать размеры и вообще всё (доступность документов, наличие названий, хэшсумм и так далее) до сборки манифеста.

Демо

Собрал Docker‑демо с тремя кейсами формирования архива: только локальные файлы, только удалённые файлы через nginx proxy_pass и смешанный архив с обоими источниками. Docker позволяет быстро и изолированно собрать все необходимое окружение для демонстрации того, что описано в статье.

Ссылка: github.com/dmitrii‑starikov/on‑the‑fly‑archive‑demo

make start # Зайти в браузере на http://localhost:8099

ссылка на оригинал статьи https://habr.com/ru/articles/1054302/