Утечка памяти в Nuxt на стороне сервера при использовании SSR (Server Side Rendering)

Привет, Хабр! Данная статья обязательна к прочтению всем, кто работает с Vue SSR, в частности с Nuxt. Речь пойдет об утечке памяти при использовании axios.

Предыстория


Пол года назад я попал на проект со стеком VueJS + Nuxt, его особенность была в том, что в проде постоянно умирали нодовские сервера(Nuxt) и на их места поднимались новые. По графикам и логам было видно, что оператива процесса ноды доходила до 100% и она падала с ошибкой out of memory. В это время на место убитого процесса поднимался новый, на что уходило порядка 30 сек., этого хватало, чтобы пользователи успели получить 502 ошибку. Очевидно, что где-то в коде была утечка памяти, которую нужно было найти.

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

  1. Актуальность темы
  2. Axios Interceptors
  3. runInNewContext

1. Актуальность темы


Первым делом, как сделали бы многие из нас, я начал искать решение в интернете, мои запросы выглядели примерно так: NodeJS memory leaks , nuxt memory leaks, nuxt memory leaks in production и т.п.

Конечно же, из двадцати issue на stackoverflow ни одно мне не помогло, но зато я научился отслеживать memory usage через chrome://inspect. К моему разочарованию я обнаружил, что 90% всей памяти, которая почему-то не чистилась — это какие-то Vue'шные функции типа renderComponent, renderElement, и другие.



1. Axios Interceptors


Быстро промотаем мои мучения в поисках проблемы и сразу перейдем к тому, что во всем виноваты axios.interceptors (Прости, Хабр, за поиск виновных).

Сразу оговорюсь, что axios создавался так:

import baseAxios from 'axios';

const axios = baseAxios.create({
  timeout: 10000,
});


export default axios;

А привязывался к контексту приложения вот так:

import axios from './index';

export default function(context) {

  if(!context.axios) {
    context.axios = axios;
  }
}

  • После долгих поисков утечек я обнаружил, что если отключить все axios.interceptors, то память начинает чиститься.
  • В чем же дело?
  • interceptor — это прокси, который перехватывает все response или request и позволяет выполять любой код с ответом(например, хендлить ошибки) или что-то добавлять перед отправкой запроса глобально для всех запросов и в 1 месте, удобно, не так ли? Вот пример, как это выглядит (файл 'plugins/axios/interceptor.js')

export default function({ axios }) {

  const interceptor = axios.interceptors.response.use( (response) => {
    return response;
  }, function (error) {
    //что-то делаем с ошибкой, например логируем
    return Promise.reject(error);
  });

}

И вот здесь начинается веселье. Саму функцию добавления интерцептора мы добавляем через plugins в nuxt.config.js

  plugins: [
    { src: '~/plugins/axios/bindContext' },
    { src: '~/plugins/axios/interceptor' },
  ]

а nuxt автоматически для каждого нового запроса выполняет все plugins функции, затем делает nuxtServerInit и дальше все как обычно. То есть для первого пользователя мы создаем на стороне сервера interceptor, где-то у себя в компонентах в asyncData или в fetch делаем запросы, и interceptor отрабатывает как надо, затем заходит второй пользователь и мы создаем второй interceptor и код внутри функции отработает 2 раза!

Для лучшего понимания моих слов я сделаю вывод счетчика, который инкрементится при каждом вызове функции и 5 раз постучусь на index



Можем заметить что произошло 15 вызовов, а это 1+2+3+4+5, дополнительно вывел время создания очередного интерцептора, чтобы убедиться, что происходят вызовы тех, которые были созданы раннее.

Со школы все мы хорошо помним формулу арифметической прогрессии, а сумму от 1 до n можно записать как n * (n+1) / 2. Получается, что когда к нам зайдет 1000-й пользователь, то наша функция вызовется 1000 раз, а суммарно это уже полмиллиона вызовов, поэтому, если нагрузка средняя или высокая, то не удивляйтесь, если ваш сервер упадет.

Решение проблемы


UPD. Решение №0 — В комментариях описаны хорошие решения данной проблемы.

Решение №1 — Не использовать axios.interceptors.

Решение №2 — Все очень просто, нужно почистить за собой interceptor, руководствуясь документацией аксиоса

export default function({ axios }) {

  const interceptor = axios.interceptors.response.use( (response) => {
    
    if(process.server) {
      axios.interceptors.response.eject(interceptor);
    }
    
    return response;
  }, function (error) {
    if(process.server) {
      axios.interceptors.response.eject(interceptor);
    }
    
    return Promise.reject(error);
  });

}

Делать это нужно только на стороне сервера, потому что иначе на стороне клиента, после успешного выполнения любого первого запроса, этот интерцептор перестанет выполняться. Есть еще 1 нюанс с тем, что пока мы еще на сервере и обрабатываем запросы очередного пользователя, а запросов может быть не 1, а несколько, тогда при eject'е этого интерцептора, все запросы кроме первого не пройдут через него, в этом случае нужно самостоятельно обдумать момент, при котором нужно выполнить eject, самый простой способ сделать это через setTimeout, например через 10 секунд, тогда мы можем считать, что со стороны сервера мы успеем выполнить все запросы для текущего пользователя и все они выполняться в течение этого времени, когда интерцептор все еще будет активен.

runInNewContext


Это очень забавная опция, из-за которой данный баг невозможно воспроизвести локально, но очень легко воспроизводится в билде. Прочитать про него можно здесь. Когда я готовился к написанию данной статьи, я создал проект starter-template нукста, чтобы воспроизвести данную проблему, и как же я удивился, что для каждого очередного пользователя — interceptor выполнялся 1 раз, а не n. Дело в том, когда мы пишем npm run dev — эта опция по умолчанию равняется true, и каждый раз, когда мы на стороне сервера выполняем функции из plugins, то контекст каждый раз новый (очевидно из названия флага), а в билде он автоматически делается false для лучшей производительности в проде, поэтому пришлось в nuxt.config.js выключить эту опцию


render: {
    bundleRenderer: {
      runInNewContext: false,
    },
  },

Заключение


Как по мне, данная проблема очень серьезная, и стоит уделить ей особое внимание. Возможно эта проблема касается не только Vue ssr, но и других, и не только axios, но и любых других HTTP клиентов, в которых есть прокси, похожие на interceptor. Если у вас есть вопросы, можно писать мне в Telegram @alexander_proydenko. Весь код, который использовал в статье, можно посмотреть на github здесь.
Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 20

    0
    а если сам axios подключить плагином?
      0
      каким образом?
      в данном случае через плагин происходит явное записывание axios в контекст приложения, который создан 1 раз. Везде использовать import axios from 'axios' может не совсем подходить из-за того, что может быть конфиг какой нибудь типа кеширования запросов или общие заголовки, и делать это каждый раз не совсем хорошо
        0
        ниже ответили за меня. @nuxtjs/axios модуль
      +1
      А всего лишь надо было взять @nuxtjs/axios.

      Ошибка в том, что инстанс axios'а надо создавать при каждом ssr-запросе (т.е. внутри вашей функции-плагина). Проблема хорошо описана в ssr-документации самого Vue.
        0
        Можно попробовать, одно из решений данной проблемы
          0
          Ваше решение в статье стоит заменить на указанное выше и вы сами описали одну из причин:
          при eject'е этого интерцептора, все запросы кроме первого не пройдут через него

          Сама проблема — классический способ выстрелить себе в ногу с node.js, nuxt тут не причем
            0
            Не всегда является возможным быстро заменить в проекте axios на nuxt'овый, особенно, когда до конца не уверен что дело в этом, идея статьи помочь тем, кто столкнулся с этой проблемой, и есть надежда, что он прочитает эту статью и выберет любое решение приведенное в ней или в комментарии.
              0
              Прочитайте комментарий внимательно, не обязательно заменять модуль:

              Сразу оговорюсь, что axios создавался так:
              import baseAxios from 'axios';
              export default function createAxiosInstance () {
                return baseAxios.create({
                  timeout: 10000,
                });
              };

              А привязывался к контексту приложения вот так:
              import createAxiosInstance from './index';
              export default function(context) {
                  context.axios = createAxiosInstance();
              }

                0
                только что попробовал сделать таким образом и действительно оно помогло, но есть 1 нюанс, если использовать плагин кеширования, то ничего кешироваться не будет
                import baseAxios from 'axios';
                import LRUCache from 'lru-cache';
                
                import { cacheAdapterEnhancer } from 'axios-extensions';
                
                
                export default function () {
                  return baseAxios.create({
                    timeout: 10000,
                    adapter: cacheAdapterEnhancer(baseAxios.defaults.adapter, {
                      defaultCache: new LRUCache({
                        maxAge: 1000 * 60 * 5,
                        max: 50,
                      }),
                      enabledByDefault: true,
                      cacheFlag: 'useCache',
                    }),
                  });
                }
                

                это нужно если я например не хочу каждый раз ходить на апи для получения какой нибудь статической информации, которая меняется не чаще чем раз в час
                  +1
                  Ну ё.
                  Инстанцирование объекта LRUCache вынесите за скобки функции-фабрики
                    0
                    Огонь, все звезды сошлись
                    +1
                    Конечно не будет. Ведь инстанс создаётся на каждый ssr-запрос.
                    А плагин расчитан на работу с синглтоном, например в браузерном spa.
                    Т.е. ваш кэш будет работать в рамках одного ssr-контекста, но этого не достаточно, судя по вашей задаче.

                    В текущей ситуации можно написать какой-то внешний кэшер (под внешним я подразумеваю тот, который будет инстанцироваться как синглтон, на всё приложение). Но здесь в ключе кеша, помимо урла, надо учесть заголовки (ведь для разных зарегистрированных пользователей могут прилететь разные ответы) — Cookie, Pragma, Cache-Control и т.п., параметры запроса — body, query. Также кэшировать можно только GET-запросы, но не POST/PUT/PATCH/DELETE.
                    А в итоге всё-равно прийти к выводу, что лучше над всем этим вообще не заморачиваться)
                    Гораздо проще где-то в request-интерцепторе точечно по урлу отлавливать нужные запросы (результат которых нужно закешировать): если в кеше есть результат — сразу его возвращать; если нет — дальше в response-интерцепторе также точечно поймать ответ и положить его в кэш.
                      0
                      Ну или как сказали выше, да) вынести инстанцирование кэша за функцию-фабрику
          +2

          Написать такую большую статью и так и не разобраться в чём суть и свалить всё на интерцепторы — so frontend way in 2020…
          Когдаж вы начнёте основы своих инструментов знать…

            0
            Очевидно что суть в замыкании, garbage collector не мог за собой память почистить, потому что компоненты вью ссылались на data, которую вернул axios, и когда он прошел через интерцептор, осталась ссылка интерцептора на data, и поэтому gc ничего не почистил за собой. Когда я столкнулся с этой проблемой, я и понятия не имел что дело в этом, а статья написана в помощь тем, кто столкнется с данной проблемой и чтобы проверили у себя в проекте этот кейс, описанный в статье. В любом случае спасибо за комментарий
              0
              Дело не в garbage collector'е (с т.з. gc здесь вообще никакой проблемы нет и очищать ему тут нечего, потому что интерцепторы — это просто массив функций).
              А в том, что сам nuxt вызывает все функции-плагины для каждого ssr-запроса (которые объявлены в массиве plugins конфига нюкста), в каждую из которых он передаёт уникальный контекст этого запроса.
              А т.к. у вас один инстанс axios'а на всё приложение, то при каждом ssr-запросе к этому синглтону бесконечно добавлялись интерцепторы, до тех пор, пока процессу памяти хватало.
                0
                Я дебажил память и видел, что там находятся Vue'ые элементы, да и держать в памяти 10к функций это не сильно ресурсозатратно, я проводил тесты, на которых рендерелись тяжелые страницы, и в этом случае нода падала через 20-40 запросов, а когда рендерилась легкая страница, то могла упасть на 1000ом, в обоих случаях память не вычищалась.
                  0
                  Тут все пытаются объяснить, что у вас логически неверный код и вы описываете его следствия аж на целую статью как открытие. Вы создаете объект для всего приложения, а потом в каждом запросе добавляете ему интерсепторы. Тут нужно или создавать объект для каждого запроса, или интерсепторы прописывать один раз.
                    0
                    Действительно, вся проблема в логически неверном коде, но когда на большом проекте появляются утечки, то не всегда очевидно где их искать. Очень здорово, что появились более красивые решения, чем мои костыли. Цель статьи помочь тем, кто пытается найти утечку памяти у себя.
                      0
                      Обновил статью, добавил решение №0. Всем спасибо.

            Only users with full accounts can post comments. Log in, please.