Это была темная и бурная ночь в мае, когда наши внутренние показатели использования памяти в сервисе проверки адресов без видимых причин резко возросли. Еще более странным было то, что наш крупнейший API и основной сервис внезапно начал падать и перезапускаться каждые несколько часов — действительно темная ночь. Ниже приводится краткое изложение нашего мучительного расследования, длившегося несколько недель, в котором участвовали несколько членов команды и бесконечное количество эмодзи :confused_dog: и :computer_rage_bang_head:. Но (предупреждение о спойлере!) после бури наступает радуга.
TL;DR: Мы узнали, как работает кэширование памяти Docker, нашли 7-летнюю ошибку в управлении файлами, которая привела к секретной уязвимости hapi; все это помогло стабилизировать наши контейнеры и предложение продукта.
Что вам нужно знать о Docker
Традиционно для тех, кто работал с Docker и Datadog, основным способом измерения использования памяти в контейнере является поиск Docker.mem.in_use. Теперь, по неизвестным мне причинам, Docker.mem.in_use используется только для измерения использования памяти Rss, а не комбинации Docker.mem.rss и Docker.mem.cache:
- Docker.mem.cache: Объем памяти, используемый для кэширования данных с диска (например, содержимое памяти, которое может быть точно связано с блоком на блочном устройстве).
- Docker.mem.rss: Объем некэшируемой памяти, принадлежащей процессам контейнера; используется для стеков, куч и т.д.
Использование памяти RSS — это, по сути, то, о чем большинство думает, когда видит утечку памяти; часть кучи, не собранная в мусор, или объект, который продолжает бесконечно расти. Именно поэтому большая часть нашего расследования была направлена на традиционные источники утечек памяти. Но после нескольких недель без особого прогресса я начал собирать воедино причины резкого роста показаний памяти наших контейнеров и утечки памяти в нашей главной службе, начавшейся примерно в одно и то же время.
В ходе расследования я обнаружил, что 9 мая Datadog изменил способ чтения данных о памяти: mem_in_use теперь представляет собой комбинацию rss и использования кэша.
Тогда-то и загорелась лампочка: Ни к одному из наших Docker-контейнеров не было подключено никаких монтируемых томов, поэтому всякий раз, когда мы писали на диск, вместо этого мы писали в память. Логически это вполне логично: если у вас нет места на диске, единственное место, куда вы можете писать, — это память (ram).
На самом деле это даже идет дальше, могут быть случаи, когда у вас есть монтирование или том, но вы все равно замечаете, что docker.mem.cache увеличивается. Некоторые дистрибутивы linux свободно кэшируют чтение/запись дисков в память, если там достаточно места, и автоматически начинают освобождать ее, как только она начинает заканчиваться. Для более глубокого анализа, вот отличная статья: Linux съел мою оперативную память!
Итак:
import { promises as fs } from 'fs'
await fs.writeFile(...)
…записывалась не на диск, а прямо в память. В частности, для нашей службы проверки адресов мы загружаем в память почти 4 гигабайта файлов во время работы.
Это помогло нам определить и устранить проблему в службе проверки адресов: мы неправильно использовали кэшированные сборки Docker. Как только мы исправили эту проблему, показатели памяти вернулись в норму. Для тех, кто запутался, почему это могло произойти, детали нужно держать в секрете, но вкратце, если бы наши активы были правильно кэшированы как слой Docker, 4 ГБ данных в этом случае считались бы частью размера образа и были бы частью драйвера хранения Docker. Таким образом, он больше не использовал бы память для хранения этих файлов, а вместо этого стал бы частью дискового пространства.
Что нужно знать об управлении файлами hapi
Когда один сервис был исправлен, мне оставалось только решить проблему с памятью в нашем основном API. Чтобы понять, как я исправил эту проблему, думаю, уместно предоставить некоторые сведения о hapi.js. Мы используем hapi в качестве основного фреймворка для нашего api и в настоящее время используем v20. Поскольку мы занимаемся превращением активов в печатаемые почтовые отправления, нам нужен был фреймворк, позволяющий легко работать с разбором полезной нагрузки.
Основные варианты работы с полезной нагрузкой, которые предоставляет Hapi, следующие:
- Данные
- Потоки
- Файлы
Первый вариант, Data, довольно прост: если вы передадите тело JSON в запрос Post, он разберет его на JSON-данные. Streams делает то же самое, но если вы получаете многокомпонентную загрузку (т.е. загружается файл), он будет читать этот файл в поток без каких-либо дополнительных инструментов. Третий аналогичен, но он запишет файл в указанный каталог, а не даст вам поток:
‘File’: входящая полезная нагрузка записывается во временный файл в директории, указанной в настройках uploads. Если полезная нагрузка имеет формат ‘multipart/form-data’ и параметр parse равен true, значения полей представляются в виде текста, а файлы сохраняются на диск. Обратите внимание, что очистка файлов, сгенерированных фреймворком, является исключительной обязанностью приложения. Это можно сделать, отслеживая, какие файлы используются (например, используя объект request.app), и слушая событие ‘response’ сервера для выполнения очистки.
Теперь есть две вещи, о которых разработчики должны знать:
- Если вы запускаете контейнер Docker без метода хранения, hapi запишет ваш файл в память. Это относится к тому, о чем я говорил ранее. Это довольно интуитивно, но вы не можете исходить из предположения, что дисковое хранилище действительно существует, учитывая, насколько популярны сейчас такие сервисы, как s3, и как тома встроены по умолчанию.
- Второе — это то, что за очистку этих файлов отвечает исключительно приложение. К сожалению, мы пропустили эту строчку в документации и на самом деле не очищали эти файлы. Само по себе это не самая большая проблема. Было несколько конечных точек, которые не удаляли эти файлы после того, как мы провели серьезную рефакторизацию, поэтому я быстро их очистил. Сразу же после этих изменений наши контейнеры показали значительное снижение утечки памяти, но она все еще оставалась. Теперь наши контейнеры падали каждые 3 дня, а не каждые 3 часа. Это привело меня к последнему открытию…
Жизненный цикл запроса Hapi.js не имеет большого смысла
Хотя документация hapi и сообщает нам, что мы должны удалять активы, она не поднимает довольно важную проблему, заключающуюся в том, что очень важно слушать событие ответа сервера, а не вручную обрабатывать удаление файлов в ваших обычных путях кода. Огромная проблема заключается в том, что разбор полезной нагрузки происходит первым в жизненном цикле hapi почти перед всем остальным — такие вещи, как аутентификация и проверка запроса, происходят после разбора полезной нагрузки, что создает некоторую уязвимость, если вы не будете осторожны.
Допустим, злоумышленник, назовем его Боб, имеет личную месть против Lob. Он может постоянно пытаться попасть на наши конечные точки с неправильно отформатированными запросами, к которым прилагается массивный актив размером 200 мб. Полезная нагрузка запроса была бы разобрана, и файл был бы записан в память. Теперь мы достаточно умны, чтобы создать некоторые валидаторы, которые будут проверять любые прикрепленные файлы, чтобы убедиться, что они меньше определенного размера, скажем, 20 мб в данном случае. Этот же запрос, конечно, не пройдет проверку полезной нагрузки, и тогда мы отправим ответ 422. Но эти 200 мб данных сейчас находятся в памяти, и еще пара запросов приведет к тому, что у наших контейнеров закончится память. Поэтому вместо того, чтобы убедиться, что эти файлы были удалены нашим обычным путем кода и контроллеров, мы должны были убедиться, что они удаляются независимо от того, прошли ли они так далеко в конвейере.
Все, что для этого потребовалось, это небольшой фрагмент кода для очистки полезной нагрузки ответа непосредственно перед ответом.
Допустим, мы обрабатываем такой запрос:
/v1/upload
{
“Upload_name”: “Testing”,
“file_we_want_to_upload”: “/tmp/file.html”
}
Нам нужен обработчик ответа, который удалит этот файл прямо перед отправкой ответа, независимо от того, был ли он успешным или нет.
module.exports = {
name: 'cleanUpAssets',
register: async function (server) {
server.events.on('response', async function (request) {
rimraf(request.payload.file_we_want_to_upload) }
};
Конец радуги
И с этими изменениями наша память больше не пикает! И что самое интересное? Даже после того, как мы уменьшили лимит памяти контейнера на 1/4, мы все еще наблюдали стабильные показатели памяти Праздник продолжался! Теперь эмодзи были типа :partyporg: и :dancingpickle:.
Это сокращение процессора и памяти, скорее всего, приведет к экономии AWS за счет правильного подбора контейнеров; возможно, экономия будет и в будущем, когда мы завершим переход на Nomad.
Инженеров иногда называют упрямыми, но в случае с таинственной утечкой памяти упорство окупилось. Совершенствуя свои знания о Docker и hapi, мы обнаружили унаследованную ошибку и потенциальную уязвимость, а внесенные нами изменения привели к более стабильной, безопасной и экономически эффективной работе.