«Шакал»: сжимаем фронтенд +43



Привет! Я — Ваня, лид платформенной команды в Тинькофф Бизнес.

Мое любимое занятие — открывать вкладку DevTools и проверять, сколько весят артефакты сайта. В этой статье расскажу, как мы сократили вес приложения на 30% силами платформенной фронтенд-команды за один день без изменения кода сайта. Никаких хитростей и регистраций — только nginx, docker и node.js (опционально).




Зачем


Сейчас фронтенд-приложения весят немало. Собранные артефакты могут весить 2—3 Мб, а то и больше. Однако пользователям на помощь приходят алгоритмы сжатия.

До недавнего времени мы использовали только Gzip, который был представлен миру еще в 1992 году. Наверное, это самый популярный алгоритм сжатия в вебе, его поддерживают все браузеры выше IE 6.

Напомню, что уровень сжатия у Gzip изменяется в диапазоне от 1 до 9 (больше — эффективнее), а сжимать можно либо «на лету», либо статически.

  • «На лету» (динамически) — артефакты хранятся в полученном после сборки виде, их сжатие происходит во время выдачи на клиент. В нашем случае на уровне nginx.
  • Статически — артефакты после сборки сжимаются, а HTTP-сервер выдает их на клиент «как есть».

Очевидно, что первый вариант требует больше ресурсов сервера на каждый запрос. Второй же — на этапе сборки и подготовки приложения.

Наш фронтенд сжимался динамически четвертым уровнем. Продемонстрирую разницу между сжатым артефактом и исходным:
Уровень сжатия
Вес артефакта, Кб
Время сжатия, мс
0
2522

1
732
42
2
702
44
3
683
48
4
636
55
5
612
65
6
604
77
7
604
80
8
603
104
9
601
102

Можно заметить, что даже четвертый уровень сокращает размер артефакта в 4 раза! А разница между четвертым уровнем и девятым составляет 35 Кб, то есть 1,3% от исходного, но в 2 раза увеличивается время сжатия.

И вот недавно мы задумались: почему бы не перейти на Brotli? Да еще и на самый мощный уровень сжатия!

К слову, этот алгоритм был представлен Google в 2015 году и имеет 11 уровней сжатия. При этом четвертый уровень Brotli эффективнее девятого у Gzip. Я замотивировался и быстро накидал код для сжатия артефактов алгоритмом Brotli. Результаты представлены ниже:
Уровень сжатия
Вес артефакта, Кб
Время сжатия, мс
0
2522
1
662
128
2
612
155
3
601
156
4
574
202
5
526
227
6
512
249
7
501
303
8
496
359
9
492
420
10
452
3708
11
446
8257

Однако из таблицы видно, что даже первый уровень сжатия Brotli выполняется дольше, чем девятый у Gzip. А последний уровень — аж 8,3 секунды! Это насторожило меня.

С другой стороны, результат однозначно впечатляет. Далее я попробовал перенести сжатие на nginx — загуглил документацию. Все оказалось предельно просто:

brotli on;
brotli_comp_level 11;
brotli_types text/plain text/css application/javascript;

Собрал докер-образ, запустил контейнер и был ужасно удивлен:



Время загрузки моего файла выросло в десятки раз — со 100 мс до 5 секунд! Приложением стало невозможно пользоваться.

Изучив документацию глубже, понял, что можно раздавать статически. Воспользовался ранее написанным скриптом, сжал те же артефакты, положил в контейнер, запустил. Время загрузки вернулось в норму — победа! Однако радоваться рано, потому что доля браузеров, поддерживающих этот тип сжатия, — около 80%.

Это означает, что необходимо сохранить обратную совместимость, при этом дополнительно хочется использовать самый эффективный уровень Gzip. Так появилась идея сделать утилиту по сжатию файлов, которая позже получила название «Шакал».



Что нам понадобится?


Nginx, Docker и Node.js, хотя при желании можно и на bash.
С Nginx почти все понятно:

brotli off;
brotli_static on;
gzip_static on;

Но что делать с приложениями, которые еще не успели обновить докер-образ? Правильно, добавить обратную совместимость:

gzip on;
gzip_level 4;
gzip_types text/plain text/css application/javascript;

Объясню принцип работы:


Клиент при каждом запросе передает заголовок Accept-Encoding, в котором перечисляет через запятую поддерживаемые алгоритмы сжатия. Обычно это deflate, gzip, br.

Если у клиента в строке есть br, то nginx ищет файлы с расширением .br, если таких файлов нет и клиент поддерживает Gzip, то ищет .gz. Если таких файлов нет, то пожмет «на лету» и отдаст с четвертым уровнем компрессии.

Если клиент не поддерживает ни один тип сжатия, то сервер выдаст артефакты в исходном виде.

Однако возникла проблема: наш докер-образ nginx не поддерживает модуль Brotli. За основу я взял готовый докер-образ.

Dockerfile для «запаковки» nginx в проекте
FROM fholzer/nginx-brotli

# предварительно очищаем директорию с контентом
RUN rm -rf /usr/share/nginx/html/

# копируем нашу конфигурацию в образ
COPY app/nginx /etc/nginx/conf.d/

# копируем наши артефакты в образ
COPY dist/ /usr/share/nginx/html/

# запускаем
CMD nginx -c /etc/nginx/conf.d/nginx.conf


С балансировкой трафика разобрались, но откуда взять артефакты? Вот здесь-то и придет на помощь «Шакал».

«Шакал»


Это утилита для сжатия статики вашего приложения.

Сейчас это три node.js-скрипта, обернутые в докер-образ с node:alpine. Пробежимся по скриптам.

base-compressor — скрипт, который реализует базовую логику по сжатию.

Аргументы на вход:

  1. Функция сжатия — любая javascript-функция, можно реализовать свой алгоритм сжатия.
  2. Параметры сжатия — объект с параметрами, необходимыми для переданной функции.
  3. Расширение — расширение артефактов сжатия. Необходимо указывать начиная с символа точки.

gzip.js — файл с вызовом base-compressor с переданной функцией Gzip из пакета zlib и указанием девятого уровня компрессии.

brotli.js — файл с вызовом base-compressor с переданной функцией Brotli из одноименного npm-пакета и указанием 11-го уровня компрессии.

Dockerfile создания образа «Шакала»
FROM node:8.12.0-alpine

# копируем скрипты в образ
COPY scripts scripts

# копируем package.json и package-lock.json в образ
COPY package*.json scripts/

# задаем рабочую директорию в образе
WORKDIR scripts

# выполняем установку модулей
# эта установка оставит node_modules/ в образе
# можно оптимизировать, если собрать скрипт предварительно
RUN npm ci

# выполняем параллельно два скрипта
CMD node gzip.js | node brotli.js


Разобрались, как он работает, теперь можно смело запускать:

docker run    -v $(pwd)/dist:/scripts/dist    -e 'dirs=["dist/"]'    -i mngame/shakal

  • -v $(pwd)/dist:/scripts/dist — указываем, какую локальную директорию считать директорией в контейнере (ссылка на маунтинг). Указание директории scripts обязательно, так как она является рабочей внутри контейнера.
  • -e 'dirs=[«dist/»]' — указываем параметр окружения dirs — массив строк, которые описывают директории внутри scripts/, которые будут сжаты.
  • -i mngame/shakal — указание образа с docker.io.

В указанных директориях скрипт рекурсивно сожмет все файлы с указанными расширениями .js, .json, .html, .css и сохранит рядом файлы с расширениями .br и .gz. На нашем проекте этот процесс занимает около двух минут при весе всех артефактов около 6 Мб.

На этом моменте, а может быть, и раньше вы могли подумать: «Какой докер? Какая нода? Почему бы просто не добавить два пакета к себе в package.json проекта и вызывать прямо на postbuild?»

Лично мне очень больно видеть, когда ради прогона линтеров в CI проект устанавливает себе 100+ пакетов, из которых ему на этапе линтинга нужны максимум 10. Это время агента, ваше время, как никак time to market.

В случае с докером мы получаем заранее собранный образ, в котором установлено все необходимое именно для сжатия. Если вам сейчас не нужно ничего сжимать — не сжимайте. Нужен линт — прогоняйте только его, нужны тесты — прогоняйте только их. Плюс мы получаем хорошее версионирование «Шакала»: нам не нужно обновлять его зависимости в каждом проекте — достаточно выпустить новую версию, а проекту — использовать latest-тег.

Результат:


  • Размер артефактов изменился с 636 Кб до 446 Кб.
  • Процентно размер уменьшился на 30%.
  • Время загрузки уменьшилось на 10—12%.
  • Время на декомпрессию, исходя из статьи, осталось прежним.

Итого


Помочь своим пользователям можно прямо сейчас, прямо следующим ПРом: добавляете шаг после сборки — сжатие «Шакалом», после чего доставляете артефакты к себе в контейнер. Через полчаса ваши пользователи чувствуют себя чуть лучше.

У нас получилось уменьшить вес фронтенда на 30% — получится и у вас! Всем легких сайтов.

Ссылочки:


Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (30):

  1. kellas
    /#20858908

    Попробовал, классно, спасибо!
    Но docker-image все же не удобен для использования в CI.
    То есть вот у меня приложение билдится как раз в CI(то есть уже docker-in-docker) и вашу тулзу туда никак не вписать

    CI docker image
    FROM node:10-alpine as builder
    COPY ./prod ./site_sources
    WORKDIR /site_sources
    RUN npm i
    RUN npm run build

    FROM nginx:1.15.8-alpine
    ADD ./nginx.conf /etc/nginx/conf.d/default.conf
    WORKDIR /var/www
    COPY --from=builder /site_sources/dist .
    ...

    • Ish_Ivan
      /#20858982 / +1

      Классная идея! В нашем CI в основном нет multi-stage сборок, поэтому не столкнулись с такой проблемой.
      Предлагаю сделать PR в репозиторий, и я опубликую как пакет, после этого обновлю в статье.

    • Ish_Ivan
      /#20860202

      Принял ваш PR, опубликовал пакет в NPM и обновил статью в разделе «Ссылки». Спасибо вам большое!

  2. Carduelis
    /#20858916 / +1

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

    • Ish_Ivan
      /#20858998

      К сожалению, нет, своих экспериментов мы не проводили, но изучали статьи на эту тему, например, ссылка из моей статьи. У вас есть наработки по таким экспериментам, можете поделиться?

      • Carduelis
        /#20859050

        Мы пока только задумались о внедрении такой фичи (conditional compressing и conditional caching), так что нет. Ваша статья довольно вовремя)

    • CAJAX
      /#20860716

      Степень сжатия не влияет на скорость распаковки.

  3. vintage
    /#20859046 / +1

    Поднимать V8 в докер контейнере только для того, чтобы вызвать консольную утилиту. Фронтенд такой фронтенд.

    • namikiri
      /#20859214

      Спасибо, мил человек, что заметили это безумие. Ну серьёзно. Цензурных выражений не хватает.

    • CAJAX
      /#20860718 / +3

      Напрашивается внедрение блокчейна!

  4. namikiri
    /#20859228

    Сокращение времени доставки артефактов это, конечно, здорово, но хотелось бы видеть облегчение того, что доставляете. Да-да, я именно про код, который будет исполняться у меня на машине. Минификация кода не делает его легче для виртуальной машины JS, в результате обычный сайт подлагивает при прокрутке. Вот в первую очередь хотелось бы читать статьи об оптимизации JS, например, о сокращении использования «современных инструментов» там, где им не место, а уж интернет у меня безлимитный, не обломлюсь загрузить.

  5. Symsym
    /#20860360

    графики нагляднее табличек

  6. ShashkovS
    /#20860906 / +1

    Если сборка проекта через webpack, то можно просто добавить в конфиг это:

    const CompressionPlugin = require('compression-webpack-plugin');
    ...
      new CompressionPlugin({
        filename: '[path].br[query]',
        algorithm: 'brotliCompress',
        test: /\.(html|css|ttf|eot|svg|js)$/,
        compressionOptions: { level: 11 },
        threshold: 1024,
        minRatio: 0.8,
      }),
      new CompressionPlugin({
        filename: '[path].gz[query]',
        test: /\.(html|css|ttf|eot|svg|js)$/,
        threshold: 1024,
        minRatio: 0.8,
      }),
    ...
    

    (Ну и да, кастомно собранный nginx с поддержкой brotli тоже нужен)

  7. monochromer
    /#20861050 / +3

    По поводу npm-пакета. Почему стандартный модуль zlib указан в зависимостях? Также в zlib есть поддержка brotli, начиная, если не путаю, с node 11. Так что утилиту можно было бы разработать полностью без зависимостей.

  8. evil_random
    /#20861374 / +1

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

    А уж как оно всё шикарно тормозит после разжатия.

    PS Ещё очень резануло про gzip и 1992 год. Как будто это что-то плохое. В те времена решения в IT делали качественно хоть и не всегда дальновидно.

    • justboris
      /#20861422 / -1

      Вместо того, чтобы писать лёгкий и производительный код

      Это так написано, как будто написание производительного кода занимает столько же усилий, сколько и медленного, и все пишут медленно только из вредности.


      Конечно, лучше быть богатым и здоровым, а ещё сразу писать оптимизированный код, с учетом всех будущих фич и изменений.

      • evil_random
        /#20861426 / +1

        Медленный код появляется из-за низкой квалификации и незнания ключевых особенностей javascript и работы с DOM. Проблема во фронте в том, что сюда очень низкий порог входа, к сожалению.
        Реакт весьма производителен. Но на нём как понаписывают, аж за голову берёшься.

        • D_E_S
          /#20861780

          Доля правды в этом есть. Встречал у нас на работе специалиста который всю жизнь писал на беке и тут решил на js написать часть со словами, а там всё просто. Так для того чтобы сделать «динамическую форму» он отправлял на сервере в сесии всю таблицу всё делал на сервере и на клиенте назад принимал сессию (не json). Кто угадает что он делал на клиенте, правильно ajax на jquery. А что он делал на сервере, правильно сортировку и математические вычисления. Странно почему код тормозил.

        • Druu
          /#20862064 / +1

          Медленный код появляется из-за низкой квалификации

          Нет, он появляется из-за того, что затраты на медленный код меньше, чем на быстрый. Если вы готовы выложить за проект в Х раз больше — да не вопрос, будет код и легкий и производительный, и какой захотите, хоть с украшениями в виде ascii-арта. Кто платит, тот и музыку заказывает.

          • slonopotamus
            /#20862144 / +1

            что затраты на медленный код меньше, чем на быстрый

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

            • Aquahawk
              /#20862522

              Да, специалист высокой квалификации не будет делать детских ошибок вида отсортировать массив и потом взять первый элемент для максимума, но, в любом случае, как только требуется реальная быстрота разработка замедляется в разы и десятки раз. Требуется всё это профилировать, потом просовывать через апи и слои абстракции быстрые но неудобные интерфейсы, требуется тщательный, часто с тестами, выбор алгоритма, игры с параметрами, применение всяких object pool и прочих техник оптимизации. И код от этого становится хуже, отладка затрудняется, требуется больше тестов и инструментов для стабилизации этого кода (статический анализ, фаззеры, санитайзеры, всякие Electric Fence и прочие Valgrind) И один и тот же высококлассный специалист, в зависимости и тз и условий выдаст код разной производительности за разное время, а цена от времени пляшет. Когда речь идёт плохо или средне, да, зависимость от квалификации. Кода нужна экстра производительность нужен спец экстра класса. Но спец экстра класса лего и непринуждённо с хорошей скоростью выдаёт средний код не напрягаясь.

              • slonopotamus
                /#20862664

                Когда речь идёт плохо или средне, да, зависимость от квалификации. Когда нужна экстра производительность нужен спец экстра класса

                Ну то есть зависимость есть на всём диапазоне, я не понял зачем вы выделили "экстра/реальную" производительность в отдельную группу.

        • justboris
          /#20862420

          С этой частью все понятно, но почему вы пишете "вместо того"? Почему нельзя и то и другое?

  9. Viceroyalty
    /#20861536 / +1

    А я весь функционал держу в беке — я псих? (у меня веб-приложения, а не классические сайты)

    • D_E_S
      /#20861774 / +1

      И даже к примеру списки для select2 в 1000 позиций вы отдаёте беком в html?

    • justboris
      /#20862590

      Если вы попробовали разные варианты, и держать все на беке сработало лучше всего, то дело ваше, как вам удобнее.


      А если вы не еще пробовали, то почему бы не попробовать, вдруг что-то улучшится?

  10. Aquahawk
    /#20861958

    О, кто-то это таки написал, а то я на докладе пообещал, да так и не написал. Более подробно про сжатие разных видов за 20 минут в моём докладе