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

Мое любимое занятие — открывать вкладку 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
    07.11.2019 14:29

    Попробовал, классно, спасибо!
    Но 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 .
    ...


    1. Ish_Ivan Автор
      07.11.2019 14:40
      +1

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


    1. Ish_Ivan Автор
      07.11.2019 18:49

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


  1. Carduelis
    07.11.2019 14:30
    +1

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


    1. Ish_Ivan Автор
      07.11.2019 14:41

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


      1. Carduelis
        07.11.2019 14:49

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


    1. CAJAX
      07.11.2019 21:10

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


  1. vintage
    07.11.2019 14:48
    +1

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


    1. namikiri
      07.11.2019 15:21

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


    1. CAJAX
      07.11.2019 21:11
      +3

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


  1. namikiri
    07.11.2019 15:23

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


  1. Symsym
    07.11.2019 19:32

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


  1. ShashkovS
    07.11.2019 22:12
    +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 тоже нужен)


  1. monochromer
    07.11.2019 23:01
    +3

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


  1. evil_random
    08.11.2019 01:17
    +1

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

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

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


    1. justboris
      08.11.2019 02:14
      -1

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

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


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


      1. evil_random
        08.11.2019 02:17
        +1

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


        1. D_E_S
          08.11.2019 08:14

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


        1. Druu
          08.11.2019 10:05
          +1

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

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


          1. slonopotamus
            08.11.2019 10:21
            +1

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

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


            1. Aquahawk
              08.11.2019 11:28

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


              1. slonopotamus
                08.11.2019 12:01

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

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


        1. justboris
          08.11.2019 11:10

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


      1. slonopotamus
        08.11.2019 11:50

        1. justboris
          09.11.2019 21:58

          Там в комментарии это был сарказм


  1. Viceroyalty
    08.11.2019 03:54
    +1

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


    1. D_E_S
      08.11.2019 08:08
      +1

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


    1. justboris
      08.11.2019 11:45

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


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


      1. Viceroyalty
        09.11.2019 15:47

        Я JS пока не знаю)


  1. Aquahawk
    08.11.2019 09:30

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