Pull to refresh

Сервировка сжатых файлов и использование CDN

Reading time11 min
Views2.2K

При загрузке сайта на сервер ложится множество задач, которые необходимо выполнять быстро и стабильно. Но ответственность за часть из них (например, обработку запросов на получение файлов, их пересылку клиенту и компрессию передаваемых данных) можно переложить на специализировые файловые хранилища. Они, как правило, имеют несколько территориальных зон и отдают клиенту файлы от наиболее близкого сервера (так, время загрузки ресурсов из физически удаленных от расположения основного сервера мест значительно сократится). Эти преимущества использования CDN — разгрузка сервера, сокращение времени доставки контента, а также сокращение трафика, передаваемого основным сервером (который обычно дороже), привели к довольно широкому использованию подобного подхода. Сегодня разберемся, как работать с Amazon S3-совместимыми CDN и настраивать передачу сжатых файлов.


Генерация сжатых файлов


На данный момент существует 2 распространенных в веб-среде стандарта — Gzip и Brotli. Второй отличается более эффективной компрессией (на 15-20%), но работает только по HTTPS (за исключением localhost, где работает и по HTTP) и в достаточно свежих браузерах (> IE 11). Некоторые CDN (Amazon CloudFront) поддерживают сжатие "на лету", но только в формате Gzip, в то время как другие (я использую Yandex Object Storage) лишены подобной логики, представляя собой просто облачные папки с файлами без какой-либо логики, кроме распределенной по регионам доставки.


Соответственно, необходимо генерировать сжатые файлы самостоятельно, например, используя плагин compression-webpack-plugin:


webpack-custom/plugins/pluginCompressionGzip.ts
import webpack from 'webpack';
import CompressionPlugin from 'compression-webpack-plugin';

export const pluginCompressionGzip: webpack.Plugin = new CompressionPlugin({
  test: /\.(js|css)$/i,
  cache: true,
  filename: '[path].gz',
  algorithm: 'gzip',
  deleteOriginalAssets: false,
  compressionOptions: {
    level: 9,
  },
});

webpack-custom/plugins/pluginCompressionBrotli.ts
import webpack from 'webpack';
import CompressionPlugin from 'compression-webpack-plugin';

export const pluginCompressionBrotli: webpack.Plugin = new CompressionPlugin({
  test: /\.(js|css)$/i,
  cache: true,
  filename: '[path].br',
  algorithm: 'brotliCompress',
  deleteOriginalAssets: false,
  compressionOptions: {
    level: 11,
  },
});

Эти плагины, разумеется, нужны только для фронтендовой сборки (при использовании изоморфных сборок) и их следует отключать для ускорения процесса локальной сборки, например, с помощью env-переменной:


webpack-custom/configs/configPlugins.ts
import webpack from 'webpack';

import { env } from '../../env';
import { pluginHtml } from '../plugins/pluginHtml';
import { pluginDefine } from '../plugins/pluginDefine';
import { pluginExtract } from '../plugins/pluginExtract';
import { pluginLodashModule } from '../plugins/pluginLodashModule';
import { pluginCompressionGzip } from '../plugins/pluginCompressionGzip';
import { pluginCompressionBrotli } from '../plugins/pluginCompressionBrotli';
import { pluginCircularDependency } from '../plugins/pluginCircularDependency';

export const configPlugins: webpack.Configuration['plugins'] = [
  pluginHtml,
  pluginDefine,
  pluginExtract,
  pluginLodashModule,
  env.CIRCULAR_CHECK && pluginCircularDependency,
  env.GENERATE_COMPRESSED && pluginCompressionGzip,
  env.GENERATE_COMPRESSED && pluginCompressionBrotli,
].filter(Boolean);

Загрузка файлов на CDN


Основной вопрос, на который нужно ответить в этом разделе — это на каком этапе загружать сгенерированные файлы в распределенное хранилище. Схемы может быть две:


  • сразу после завершения процесса сборки. В этом случае собранные артефакты дублируются: одна версия хранится в CI/CD (например, в готовом для запуска докер-образе либо просто в zip-файле, который можно при необходимости распаковать и запустить на сервере или локальной машине), а вторая кладется в уникальный bucket (так называется корневая папка с файлами в CDN). Удобно именовать эту папку в формате projectName_gitCommitHash. При прямом запросе на новый бакет не будет работать кеширование (т.к. меняется адрес ссылки с https://storage/projectName_333/client.js на https://storage/projectName_111/client.js), поэтому в данном случае необходимо использовать редирект-сервер. Он перенаправляет запросы на соответствующую текущей запущенной версии папку, таким образом являясь дополнительным этапом в доставке файлов, который необходимо обслуживать;
  • артефакты загружаются в соответствующий стенду bucket при запуске сервера (это может быть projectName_production или projectName_some-test-server).

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


Для начала необходимы следующие настраиваемые переменные (архитектура сборки взята отсюда):


CDN_ENABLED=true
CDN_BUCKET=local-bucket
CDN_BUCKET_PREFIX=myproject-
CDN_ENDPOINT=https://storage.yandexcloud.net
CDN_ACCESS_KEY_ID=
CDN_SECRET_ACCESS_KEY=

LOGS_CDN=false

При выключенной настройке CDN_ENABLED для удобной локальной разработки нужно будет учесть схему раздачи статики непосредственно основным сервером. CDN_BUCKET_PREFIX сделан для возможности работы нескольких проектов / отдельных сервисов в рамках одного и того же хранилища.


Далее необходимо установить aws-sdk, подключиться и создать методы для создания bucket и загрузки файлов:


server/serverUtils/s3.ts
import S3 from 'aws-sdk/clients/s3';

import { env } from '../../env';

export const s3Options = {
  credentials: {
    accessKeyId: env.CDN_ACCESS_KEY_ID,
    secretAccessKey: env.CDN_SECRET_ACCESS_KEY,
  },
  region: 'ru-central1',
  endpoint: env.CDN_ENDPOINT,
  httpOptions: {
    connectTimeout: 1000 * 60,
  },
};

export const s3 = new S3(s3Options);

export function logMessage(...consoleArgs: any[]) {
  return function promiseHandler(arg: any) {
    if (env.LOGS_CDN) console.log(...consoleArgs);

    return arg;
  };
}

export function createCDNBucket({ Bucket }: { Bucket: string }) {
  logMessage(`Try to create bucket:${Bucket}`);

  return Promise.resolve()
    .then(() => s3.headBucket({ Bucket }).promise())
    .then(logMessage(`Bucket:${Bucket} already exists`))
    .catch(err => {
      // bucket does not exist, create one
      if (err.statusCode >= 400 && err.statusCode < 500) {
        return Promise.resolve()
          .then(() => s3.createBucket({ Bucket, ACL: 'public-read' }).promise())
          .then(logMessage(`Bucket:${Bucket} created`));
      }

      throw err;
    });
}

export function uploadToCDNBucket({
  Bucket,
  Expires,
  fileName,
  fileContent,
  ContentType,
  ContentEncoding,
}: {
  Bucket: string;
  Expires?: any;
  fileName: string;
  fileContent: any;
  ContentType: string;
  ContentEncoding?: string;
}) {
  return Promise.resolve()
    .then(logMessage(`Try to upload file ${fileName} to bucket:${Bucket}`))
    .then(() =>
      s3
        .putObject({
          ACL: 'public-read',
          Key: fileName,
          Body: fileContent,
          Bucket,
          Expires,
          ContentType,
          ContentEncoding,
        })
        .promise()
    )
    .then(logMessage(`File ${fileName} uploaded to bucket:${Bucket}`));
}

Далее в рецепт запуска сервера необходимо включить логику загрузки файлов:


server/serverUtils/copyAssetsToCDN.ts
import fs from 'fs';
import path from 'path';

import mime from 'mime-types';

import { readDirRecursive } from 'serverUtils';

import { env } from '../../env';
import { paths } from '../../paths';
import { configEntryServer } from '../../webpack-custom/configs/configEntryServer';

import { createCDNBucket, uploadToCDNBucket } from './s3';

const Bucket = `${env.CDN_BUCKET_PREFIX}${env.CDN_BUCKET}`;

function uploadBuildFile(filePath) {
  const fileRelativeName = filePath
    .replace(`${paths.buildPath}${path.sep}`, '')
    .replace(/\\/g, '/');

  let ContentEncoding;
  if (/\.br/.test(filePath)) ContentEncoding = 'br';
  if (/\.gz/.test(filePath)) ContentEncoding = 'gzip';

  let ContentType = mime.lookup(fileRelativeName) || 'application/octet-stream';
  if (/\.css/.test(filePath)) ContentType = 'text/css';
  if (/\.js/.test(filePath)) ContentType = 'application/javascript';

  const Expires = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); // 1 month

  return Promise.resolve()
    .then(() => fs.promises.readFile(filePath))
    .then(fileContent =>
      uploadToCDNBucket({
        Bucket,
        Expires,
        fileName: fileRelativeName,
        fileContent,
        ContentType,
        ContentEncoding,
      })
    );
}

export function copyAssetsToCDN() {
  if (!env.CDN_ENABLED) return Promise.resolve();

  const startTime = Date.now();

  const excludedFilenames = Object.keys(configEntryServer).map(fileName => `${fileName}.js`);

  return Promise.resolve()
    .then(() => createCDNBucket({ Bucket }))
    .then(() => readDirRecursive(paths.buildPath))
    .then(buildFilesPaths =>
      buildFilesPaths.filter(
        filePath => !excludedFilenames.some(fileName => filePath.includes(fileName))
      )
    )
    .then(buildFilesPaths => Promise.all(buildFilesPaths.map(uploadBuildFile)))
    .then(() => {
      const endTime = Date.now();

      console.log(
        `Files from build folder have been uploaded to CDN within ${
          (endTime - startTime) / 1000
        } seconds`
      );
    });
}

server/server.ts
import { createServer, copyAssetsToCDN } from 'serverUtils';
import { handleFileRoutes } from 'routeMiddlewares/handleFileRoutes';
import { handlePageRoutes } from 'routeMiddlewares/handlePageRoutes';
import { handleMissingRoutes } from 'routeMiddlewares/handleMissingRoutes';

Promise.resolve()
  .then(copyAssetsToCDN)
  .then(() => createServer())
  .then(server => server.useMiddlewares([handleFileRoutes, handlePageRoutes, handleMissingRoutes]))
  .then(server => server.start())
  .catch(error => {
    console.error(error);

    // eslint-disable-next-line no-process-exit
    process.exit(1);
  });

Логика кода следующая:


  • рекурсивно считывается папка с артефактами (build), исключая серверные файлы
  • для сжатых файлов устанавливается соответствующий ContentEncoding
  • для всех файлов устанавливается ContentType (библиотека mime-types в общем случае устанавливает его исходя из расширения файла, но для сжатых файлов, имеющих двойное расширение, приходится устанавливать вручную)
  • устанавливается Expire-заголовок для интенсивного кеширования (как альтернатива CacheControl, который в моей версии CDN не устанавливается)
  • файл загружается на CDN с относительным путем от папки build и прямыми слешами в пути, т.к. с обратными не будут созданы подпапки в bucket.

Теперь при запуске / перезапуске сервера с включенной настройкой CDN_ENABLED файлы загружаются в стороннее хранилище.


Отдача сжатых файлов


При отдаче html-шаблона на первоначальный запрос от клиента при включенной генерации сжатых файлов необходимо заменить расширения файлов в тегах script и link на соответствующие версии. Важно при этом учитывать возможность клиента с ними работать, о чем он сообщает в заголовке Accept-Encoding:


server/serverUtils/templateModifiers.ts
const compressions = [
  {
    encoding: 'br',
    extension: 'br',
  },
  {
    encoding: 'gzip',
    extension: 'gz',
  },
];

export function injectCompressed(req, str) {
  const acceptedEncodings = req.acceptsEncodings();
  const acceptedCompression = env.GENERATE_COMPRESSED
    ? compressions.find(({ encoding }) => acceptedEncodings.includes(encoding))
    : null;

  return !acceptedCompression
    ? str
    : str
        .replace(/\.js/g, `.js.${acceptedCompression.extension}`)
        .replace(/\.css/g, `.css.${acceptedCompression.extension}`);
}

server/routeMiddlewares/handlePageRoutes.ts
import fs from 'fs';
import path from 'path';

import { injectAppMarkup, injectBrowserReload, injectCompressed } from 'serverUtils';

import { paths } from '../../paths';

const template = fs.readFileSync(path.resolve(paths.buildPath, 'template.html'), 'utf-8');

export function handlePageRoutes(app) {
  app.get('*', (req, res) => {
    Promise.resolve()
      .then(() => injectAppMarkup(template))
      .then(modTemplate => injectCompressed(req, modTemplate))
      .then(modTemplate => injectBrowserReload(modTemplate))
      .then(modTemplate => res.send(modTemplate))
      .catch(error => {
        console.error(error);

        res.status(500);
        res.send('Unpredictable error');
      });
  });
}

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


Отдача файлов при выключенном CDN


Предполагается, что данный режим будет использоваться только при локальной разработке, а сжатие файлов при этом будет включаться только для дебага. Тем не менее, при сбое работы CDN можно легко переключить на сервере env-параметр и запустить этот режим.


server/routeMiddlewares/handleFileRoutes.ts
import serveStatic from 'serve-static';

import { env } from '../../env';

export const compressions = [
  {
    encoding: 'br',
    extension: 'br',
  },
  {
    encoding: 'gzip',
    extension: 'gz',
  },
];

function redirectToCdn(req, res, next) {
  return req.originalUrl.includes('.')
    ? res.redirect(`${env.CDN_ENDPOINT}/${env.CDN_BUCKET_PREFIX}${env.CDN_BUCKET}${req.url}`)
    : next();
}

function setContentType(contentType) {
  return (req, res, next) => {
    res.set('Content-Type', contentType);

    return next();
  };
}

function setEncoding(encoding) {
  return (req, res, next) => {
    res.set('Content-Encoding', encoding);

    return next();
  };
}

export function handleFileRoutes(app) {
  if (env.CDN_ENABLED) return app.get('*', redirectToCdn);

  compressions.forEach(({ encoding, extension }) => {
    app.get(`*.js.${extension}`, setContentType('application/javascript'));
    app.get(`*.js.${extension}`, setEncoding(encoding));

    app.get(`*.css.${extension}`, setContentType('text/css'));
    app.get(`*.css.${extension}`, setEncoding(encoding));
  });

  app.use(serveStatic('build'));

  // Send 404 for all not found files
  app.get('*', (req, res, next) => (req.originalUrl.includes('.') ? res.sendStatus(404) : next()));
}

Схема работы:


  • если CDN включен, то все запросы на файлы перенаправляются на него, т.е. наш сервер выступает в качестве прокси. Этот функционал пригождается редко, так как цель — как раз избегать подобного поведения, но иногда нужно иметь ссылку на файл относительно главного домена (для подтверждения прав, например);
  • если выключен, то при запросе на файлы .gz и .br проставляются соответствующие заголовки;
  • если файл отсутствует и CDN включен, то статус запроса и контент ошибок определяет он сам, а если выключен — то ошибка 404 отдается нашим сервером.

Прямые ссылки на CDN


На данный момент файлы сжаты и лежат на стороннем сервере, соответственно осталось лишь изменить ссылки с относительных от текущего домена. Заменой шаблона как выше это сделать не получится, так как дополнительные ресурсы (изображения, например) и асинхронные чанки могут там не присутствовать, поэтому необходимо глобально заменить publicPath в конфиге Webpack:


webpack-custom/configs/configOutput.ts
import webpack from 'webpack';

import { env } from '../../env';
import { paths } from '../../paths';

const publicPath = !env.CDN_ENABLED
  ? '/'
  : `${env.CDN_ENDPOINT}/${env.CDN_BUCKET_PREFIX}${env.CDN_BUCKET}/`;

export const configOutput: webpack.Configuration['output'] = {
  path: paths.buildPath,
  filename: env.FILENAME_HASH ? '[name].[contenthash].js' : '[name].js',
  publicPath,
};

webpack-custom/configs/configOutputServer.ts
import webpack from 'webpack';

import { env } from '../../env';
import { paths } from '../../paths';

const publicPath = !env.CDN_ENABLED
  ? '/'
  : `${env.CDN_ENDPOINT}/${env.CDN_BUCKET_PREFIX}${env.CDN_BUCKET}/`;

export const configOutputServer: webpack.Configuration['output'] = {
  path: paths.buildPath,
  filename: '[name].js', // static name for server build
  publicPath,
};

Это последний шаг настройки, в итоге при включенных соответствующих настройках при загрузке страницы получаем ссылки на ресурсы следующего вида:


<script src="https://storage.yandexcloud.net/myproject-bucket-production/client.67132bc5b2cbe6df5b5e.js.br"></script>

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


Остался лишь один очевидный сайд-эффект: асинхронно загружаемые чанки запрашиваются в несжатых версиях, так как не проходят через сервер, способный определить Accept-Encoding и перенаправить на соответствующую версию. Как правило, в архитектурах, работающих по lazy-паттерну, уже закладываются механизмы для смягчения падения производительности при дозагрузке, поэтому небольшое дополнительное время (которое будет заметно только при медленном интернете у клиента) не станет проблемой. Но так как в большинстве проектов такой паттерн не используется, то лишь немногие столкнутся с этим.


Совет напоследок — не поленитесь измерить скорость отдачи статики в текущем режиме работы в вашем проекте при открытии сайта из целевых регионов (есть инструменты, предоставляющие эту возможность бесплатно). Не все CDN-серверы одинаково хороши, и, несмотря на очевидные преимущества "на бумаге", результат на практике не всегда впечатляющ при использовании высокодоступного и быстрого хостинга и основном потоке клиентов из локального региона.


Репозиторий


Высокодоступного кодинга!

Tags:
Hubs:
Total votes 2: ↑2 and ↓0+2
Comments0

Articles