Привет, Хабр! Данная статья обязательна к прочтению всем, кто работает с Vue SSR, в частности с Nuxt. Речь пойдет об утечке памяти при использовании axios.
Пол года назад я попал на проект со стеком VueJS + Nuxt, его особенность была в том, что в проде постоянно умирали нодовские сервера(Nuxt) и на их места поднимались новые. По графикам и логам было видно, что оператива процесса ноды доходила до 100% и она падала с ошибкой out of memory. В это время на место убитого процесса поднимался новый, на что уходило порядка 30 сек., этого хватало, чтобы пользователи успели получить 502 ошибку. Очевидно, что где-то в коде была утечка памяти, которую нужно было найти.
Сразу хочу выделить ключевые моменты, так как прочтение только части данной статьи может не ответить на все ваши вопросы:
Первым делом, как сделали бы многие из нас, я начал искать решение в интернете, мои запросы выглядели примерно так: NodeJS memory leaks , nuxt memory leaks, nuxt memory leaks in production и т.п.
Конечно же, из двадцати issue на stackoverflow ни одно мне не помогло, но зато я научился отслеживать memory usage через chrome://inspect. К моему разочарованию я обнаружил, что 90% всей памяти, которая почему-то не чистилась — это какие-то Vue'шные функции типа renderComponent, renderElement, и другие.
Быстро промотаем мои мучения в поисках проблемы и сразу перейдем к тому, что во всем виноваты axios.interceptors (Прости, Хабр, за поиск виновных).
Сразу оговорюсь, что axios создавался так:
А привязывался к контексту приложения вот так:
И вот здесь начинается веселье. Саму функцию добавления интерцептора мы добавляем через plugins в nuxt.config.js
а 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, руководствуясь документацией аксиоса
Делать это нужно только на стороне сервера, потому что иначе на стороне клиента, после успешного выполнения любого первого запроса, этот интерцептор перестанет выполняться. Есть еще 1 нюанс с тем, что пока мы еще на сервере и обрабатываем запросы очередного пользователя, а запросов может быть не 1, а несколько, тогда при eject'е этого интерцептора, все запросы кроме первого не пройдут через него, в этом случае нужно самостоятельно обдумать момент, при котором нужно выполнить eject, самый простой способ сделать это через setTimeout, например через 10 секунд, тогда мы можем считать, что со стороны сервера мы успеем выполнить все запросы для текущего пользователя и все они выполняться в течение этого времени, когда интерцептор все еще будет активен.
Это очень забавная опция, из-за которой данный баг невозможно воспроизвести локально, но очень легко воспроизводится в билде. Прочитать про него можно здесь. Когда я готовился к написанию данной статьи, я создал проект starter-template нукста, чтобы воспроизвести данную проблему, и как же я удивился, что для каждого очередного пользователя — interceptor выполнялся 1 раз, а не n. Дело в том, когда мы пишем npm run dev — эта опция по умолчанию равняется true, и каждый раз, когда мы на стороне сервера выполняем функции из plugins, то контекст каждый раз новый (очевидно из названия флага), а в билде он автоматически делается false для лучшей производительности в проде, поэтому пришлось в nuxt.config.js выключить эту опцию
Как по мне, данная проблема очень серьезная, и стоит уделить ей особое внимание. Возможно эта проблема касается не только Vue ssr, но и других, и не только axios, но и любых других HTTP клиентов, в которых есть прокси, похожие на interceptor. Если у вас есть вопросы, можно писать мне в Telegram @alexander_proydenko. Весь код, который использовал в статье, можно посмотреть на github здесь.
Предыстория
Пол года назад я попал на проект со стеком VueJS + Nuxt, его особенность была в том, что в проде постоянно умирали нодовские сервера(Nuxt) и на их места поднимались новые. По графикам и логам было видно, что оператива процесса ноды доходила до 100% и она падала с ошибкой out of memory. В это время на место убитого процесса поднимался новый, на что уходило порядка 30 сек., этого хватало, чтобы пользователи успели получить 502 ошибку. Очевидно, что где-то в коде была утечка памяти, которую нужно было найти.
Сразу хочу выделить ключевые моменты, так как прочтение только части данной статьи может не ответить на все ваши вопросы:
- Актуальность темы
- Axios Interceptors
- 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 здесь.