Все знают, что gzip‑файлы можно объединять и получать при этом корректные gzip‑файлы. Но знают ли об этом браузеры? Разбираемся, как обстоят дела с конкатенацией в gzip, Brotli и zstd.

Чем меньше данных мы передаём браузеру, тем лучше. Сжатие — один из способов сократить объём передаваемых данных. Но оно требует вычислительных ресурсов, поэтому приходится искать баланс между степенью сжатия и скоростью самого сжатия. Скорость распаковки тоже может иметь значение, но в случае с браузерами обычно считается, что эффективность сжатия важнее скорости распаковки.

Если ответ формируется непосредственно при обработке запроса (например, когда нужно получить из базы данных информацию о конкретном пользователе), можно сжимать данные динамически, прямо на сервере. В таком случае сжатие должно быть относительно быстрым, чтобы не вносить существенную задержку в обработку запроса.

А вот со статическими данными (будь то изображения, CSS, JavaScript или даже HTML) всё иначе. Здесь можно позволить себе более ресурсоёмкое сжатие, если оно даст меньший размер файла. Достаточно сжать данные один раз, а дальше постоянно пользоваться результатом.

В реестре IANA зарегистрировано около десятка способов кодирования содержимого, включая gzip, Brotli и zstd. Но, согласно Web Almanac 2025 от HTTP Archive, широко используются только эти три формата. При этом Brotli уже занимает лидирующую позицию по доле трафика, проходящего через CDN, а в трафике без CDN по‑прежнему преобладает gzip.

И это неудивительно: gzip существует уже целую вечность. Brotli получил широкую поддержку в 2017 году, а zstd лишь в этом году стал доступен на всех основных пользовательских платформах (в их последних версиях). Посмотрим, как изменилась ситуация в предстоящем Web Almanac 2026. Возможно, Brotli уже успел выйти в лидеры и в трафике без CDN.

Давайте разберёмся с основами сжатия и посмотрим, какие проблемы могут возникнуть при объединении сжатых файлов разных форматов.

Сжатие на этапе сборки

Допустим, у нас есть веб‑сервер.

import http.server


class Handler(http.server.SimpleHTTPRequestHandler):
  def do_GET(self):
    body = open(self.translate_path(self.path), "rb").read()
    self.send_response(200)
    self.end_headers()
    self.wfile.write(body)


http.server.test(HandlerClass=Handler)

И немного HTML.

<h1>Hello world!</h1>

Запустим сервер.

$ python3 server_nocompress.py
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

А в другом окне отправим запрос через curl.

$ { curl -sv http://localhost:8000/index.html; } 2>&1 | grep -v '^[*{}]'
> GET /index.html HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.12.3
< Date: Sun, 04 Oct 2026 21:15:40 GMT
<
<h1>Hello world!</h1>

Отлично, всё работает. Теперь поговорим о сжатии данных в вебе.

Сжатие данных в вебе

Главная задача сжатия в вебе — сократить объём данных, передаваемых от сервера клиенту. Но для этого клиент должен явно сообщить, что поддерживает сжатие, передав заголовок Accept-Encoding со списком поддерживаемых форматов (например, gzip, Brotli, zstd).

Мы можем сжимать статические файлы динамически, если в запросе есть заголовок Accept-Encoding.

import gzip
import http.server


class Handler(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        body = open(self.translate_path(self.path), "rb").read()
        self.send_response(200)
        if "gzip" in self.headers.get("Accept-Encoding", ""):
            body = gzip.compress(body)
            self.send_header("Content-Encoding", "gzip")
        self.end_headers()
        self.wfile.write(body)


http.server.test(HandlerClass=Handler)

Запустим сервер.

$ python3 server_dynamic_compress.py
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

Можно вручную передать заголовок Accept-Encoding и получить содержимое в сжатом виде. Правда, чтобы увидеть исходные данные, придётся пропустить ответ через gunzip.

$ { curl -sv -H "Accept-Encoding: gzip" http://localhost:8000/index.html | gunzip; } 2>&1 | grep -v '^[*{}]'
> GET /index.html HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/8.5.0
> Accept: */*
> Accept-Encoding: gzip
>
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.12.3
< Date: Sun, 04 Oct 2026 21:16:27 GMT
< Content-Encoding: gzip
<
<h1>Hello world!</h1>

Но поскольку сжатие — стандартный механизм веба, в curl для этого есть специальный флаг --compressed.

$ { curl -sv --compressed http://localhost:8000/index.html; } 2>&1 | grep -v '^[*{}]'
> GET /index.html HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/8.5.0
> Accept: */*
> Accept-Encoding: deflate, gzip, br, zstd
>
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.12.3
< Date: Sun, 04 Oct 2026 21:17:26 GMT
< Content-Encoding: gzip
<
<h1>Hello world!</h1>

Но вернёмся к сжатию. Мы реализовали динамическое сжатие во время обработки запроса, однако это неэффективно, ведь файл у нас статический.

Вместо этого можно заранее сжать index.html в index.html.gz, а при получении запроса не сжимать index.html на лету, а просто отдавать содержимое index.html.gz.

import http.server


class Handler(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        path = self.translate_path(self.path)
        self.send_response(200)
        if "gzip" in self.headers.get("Accept-Encoding", ""):
            path += ".gz"
            self.send_header("Content-Encoding", "gzip")
        self.end_headers()
        self.wfile.write(open(path, "rb").read())


http.server.test(HandlerClass=Handler)

Сожмём файл с помощью gzip и запустим сервер.

$ cat index.html | gzip > index.html.gz
$ python3 server_static_compress.py
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

Теперь запросим файл.

$ { curl -sv --compressed http://localhost:8000/index.html; } 2>&1 | grep -v '^[*{}]'
> GET /index.html HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/8.5.0
> Accept: */*
> Accept-Encoding: deflate, gzip, br, zstd
>
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.12.3
< Date: Sun, 04 Oct 2026 21:23:10 GMT
< Content-Encoding: gzip
<
<h1>Hello world!</h1>

Теперь мы можем отдавать как сжатые, так и несжатые данные без дополнительных накладных расходов на сжатие во время обработки запроса.

Конкатенация сжатых данных

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

gzip известен тем, что допускает конкатенацию.

echo '<title>My website</title>' | gzip > header.html.gz
echo '<h1>Hello world!</h1>' | gzip > body.html.gz
cat header.html.gz body.html.gz > index.html.gz

И при распаковке мы получаем содержимое обоих исходных файлов.

$ gunzip -c index.html.gz
<title>My website</title>
<h1>Hello world!</h1>

Но приложения, получающие эти данные (HTTP‑клиенты, браузеры и так далее), не всегда умеют корректно обрабатывать такие файлы. А иногда и вовсе не умеют.

$ curl --compressed http://localhost:8000/index.html
<title>My website</title>
curl: (23) Failed writing received data to disk/application

Так себе результат! Но хотя бы curl сообщил, что что‑то пошло не так.

Теперь установим Chrome.

curl -O https://dl.google.com/linux/direct/google-chrome-stable\_current\_amd64.deb
sudo apt install -y ./google-chrome-stable_current_amd64.deb

И проверим, как он справится с нашим файлом.

$ google-chrome --headless --no-sandbox --dump-dom http://localhost:8000/index.html
[138026:138026:1004/214112.064341:ERROR:dbus/object_proxy.cc:572] Failed to call method: org.freedesktop.DBus.Properties.GetAll: object_path= /org/freedesktop/UPower/devices/DisplayDevice: org.freedesktop.DBus.Error.ServiceUnknown: The name org.freedesktop.UPower was not provided by any .service files
<html><head><title>My website</title>
</head><body></body></html>

Chrome даже не сообщает, что вторая часть данных потерялась.

Получается, что хотя формат gzip и консольная утилита «поддерживают» конкатенацию, другие программы не обязательно умеют с ней работать.

В репозитории zlib есть пример программы gzjoin.c, которая объединяет несколько gzip‑файлов. Сама zlib — это библиотека с поддержкой формата gzip, которую использует большинство языков программирования, если не реализуют этот формат самостоятельно. А gzip — отдельная утилита.

Программа действительно требует распаковки каждого файла, но только для поиска по его содержимому. При этом для объединения файлов не требуется повторное сжатие.

Установим zlib и компилятор C, а затем соберём gzjoin.c, исходный код которой входит в пакет zlib.

sudo apt update -y
sudo apt-get install -y build-essential zlib1g-dev
cc -o gzjoin /usr/share/doc/zlib1g-dev/examples/gzjoin.c -lz

Теперь корректно объединим два gzip‑файла и запросим результат через curl у нашего Python‑сервера, который всё ещё работает.

$ ./gzjoin header.html.gz body.html.gz > safeindex.html.gz
$ curl --compressed http://localhost:8000/safeindex.html
<title>My website</title>
<h1>Hello world!</h1>

Проверим и в Chrome.

$ google-chrome --headless --no-sandbox --dump-dom http://localhost:8000/safeindex.html
[138218:138218:1004/214243.315401:ERROR:dbus/object_proxy.cc:572] Failed to call method: org.freedesktop.DBus.Properties.GetAll: object_path= /org/freedesktop/UPower/devices/DisplayDevice: org.freedesktop.DBus.Error.ServiceUnknown: The name org.freedesktop.UPower was not provided by any .service files
<html><head><title>My website</title>
</head><body><h1>Hello world!</h1>
</body></html>

Таким образом, gzjoin.c отлично демонстрирует, как можно решить эту задачу. Но, насколько мне известно, ни одна реализация gzip в основных языках программирования не предоставляет API для такого объединения файлов.

Конечно, любой разработчик может самостоятельно реализовать аналог gzjoin.c для своей среды, но было бы гораздо удобнее иметь такую возможность прямо в стандартном API.

Brotli

Эталонная реализация Brotli от Google не поддерживает конкатенацию по аналогии с gzip.

Установим Brotli от Google.

sudo apt install -y brotli

Попробуем объединить два файла, сжатых с помощью Brotli.

echo '<title>My website</title>' | brotli > header.html.br
echo '<h1>Hello world!</h1>' | brotli > body.html.br
cat header.html.br body.html.br > index.html.br

При распаковке объединённого файла возникает ошибка, и вторая часть данных теряется.

$ brotli --decompress --stdout header.html.br
<title>My website</title>
$ brotli --decompress --stdout body.html.br
<h1>Hello world!</h1>
$ brotli --decompress --stdout index.html.br
<title>My website</title>
corrupt input [index.html.br]
  • В 2018 году разработчики Dropbox немного доработали механизм сжатия в своей реализации rust‑brotli, чтобы поддержать конкатенацию с помощью инструмента, похожего на gzjoin.

  • В 2020 году они рассказали об этом в своём блоге.

  • А в 2025 году разработчики rust‑brotli добавили флаги, позволяющие создавать Brotli‑файлы, которые можно объединять обычным cat, без сторонних инструментов. Нужно лишь не забыть добавить определённый байт в конец потока.

Установим их консольную утилиту.

sudo apt-get install -y cargo
cargo install brotli

Теперь создадим две сжатые части.

echo '<title>My website</title>' | ~/.cargo/bin/brotli -c -appendable -bytealign -bare > header.html.br
echo '<h1>Hello world!</h1>' | ~/.cargo/bin/brotli -c -catable -appendable -bytealign -bare > body.html.br
{ cat header.html.br body.html.br; printf '\x03'; } > index.html.br

И теперь даже Brotli от Google может распаковать получившийся файл.

$ ~/.cargo/bin/brotli index.html.br
<title>My website</title>
<h1>Hello world!</h1>
$ brotli --decompress --stdout index.html.br
<title>My website</title>
<h1>Hello world!</h1>

Такой способ гораздо гибче, чем объединение потоков Brotli через указание смещений в потоке (stream offsets) для каждой части, которую планируется объединить.

Это самое близкое к полноценной конкатенации решение, которое предлагает Brotli от Google. К тому же поддержка смещений в потоке в клиентских библиотеках Brotli от Google реализована далеко не везде, даже если эти библиотеки находятся в том же репозитории. А через консольную утилиту Brotli смещения в потоке вообще недоступны.

И всё же оба варианта поддерживаются чуть лучше, чем gzjoin.c, который существует лишь в виде примера, даже без отдельного API. При этом оба способа объединения Brotli позволяют получать сжатые файлы, которые корректно читают и curl, и браузеры.

Проверим это с помощью ещё одного Python‑сервера, который поддерживает только Brotli.

import http.server


class Handler(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        path = self.translate_path(self.path)
        self.send_response(200)
        if "br" in self.headers.get("Accept-Encoding", ""):
            path += ".br"
            self.send_header("Content-Encoding", "br")
        self.end_headers()
        self.wfile.write(open(path, "rb").read())


http.server.test(HandlerClass=Handler)

Запустим сервер.

$ python3 server_static_compress_br.py
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

Всё работает и в curl, и в Chrome.

$ curl --compressed http://localhost:8000/index.html
<title>My website</title>
<h1>Hello world!</h1>
$ google-chrome --headless --no-sandbox --dump-dom http://localhost:8000/index.html
[139661:139661:1004/231920.832523:ERROR:dbus/object_proxy.cc:572] Failed to call method: org.freedesktop.DBus.Properties.GetAll: object_path= /org/freedesktop/UPower/devices/DisplayDevice: org.freedesktop.DBus.Error.ServiceUnknown: The name org.freedesktop.UPower was not provided by any .service files
<html><head><title>My website</title>
</head><body><h1>Hello world!</h1>
</body></html>

Отличный результат.

zstd

zstd — единственный из рассматриваемых форматов, который изначально проектировался с поддержкой конкатенации, пригодной для использования в вебе. Установим его.

sudo apt install -y zstd

Создадим файл из нескольких сжатых частей.

echo '<title>My website</title>' | zstd > header.html.zst
echo '<h1>Hello world!</h1>' | zstd > body.html.zst
cat header.html.zst body.html.zst > index.html.zst

И он без проблем распаковывается.

$ zstd --decompress -c index.html.zst
<title>My website</title>
<h1>Hello world!</h1>

Теперь проверим, как это работает при передаче данных с сервера.

import http.server


class Handler(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        path = self.translate_path(self.path)
        self.send_response(200)
        if "zstd" in self.headers.get("Accept-Encoding", ""):
            path += ".zst"
            self.send_header("Content-Encoding", "zstd")
        self.end_headers()
        self.wfile.write(open(path, "rb").read())


http.server.test(HandlerClass=Handler)

Запустим сервер.

$ python3 server_static_compress_zstd.py
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

И curl, и браузер корректно обрабатывают полученный файл.

$ curl --compressed http://localhost:8000/index.html
<title>My website</title>
<h1>Hello world!</h1>
$ google-chrome --headless --no-sandbox --dump-dom http://localhost:8000/index.html
[139953:139953:1004/232250.964510:ERROR:dbus/object_proxy.cc:572] Failed to call method: org.freedesktop.DBus.Properties.GetAll: object_path= /org/freedesktop/UPower/devices/DisplayDevice: org.freedesktop.DBus.Error.ServiceUnknown: The name org.freedesktop.UPower was not provided by any .service files
<html><head><title>My website</title>
</head><body><h1>Hello world!</h1>
</body></html>

Разработчикам zstd отдельное спасибо за то, что сделали всё настолько просто!

Как развивается спецификация

Самое интересное здесь не то, что в спецификации есть ограничения, а то, что разработчикам таких реализаций, как rust‑brotli, удалось их обойти, сохранив совместимость с другими реализациями. Полученные файлы по‑прежнему можно распаковать с их помощью.

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

  • 17 ноября в 20:00. «Почему тормозит приложение: диагностика в Wireshark». Записаться.

  • 19 ноября в 20:00. «Пишем веб-сервер на Java». Записаться.

Только 11 ноября в OTUS — скидка 20% на курсы и подписку. Предложение действует один день. Все подробности — в каталоге.