Борьба с утечками ресурсов в реальном времени без перекомпиляции серверных приложений
Крис Касперски
Памяти свойственно утекать, образовывая мощные осадочные пласты в адресном пространстве, которые уже никогда-никогда не вернутся обратно в общий пул, а потому, сколько бы виртуальной памяти у нас не было — рано или поздно она все-таки заканчивается, что особенно актуально для серверов, пилотируемых в круглосуточном режиме без ежедневных перезагрузок. И хотя разработчики периодически исправляют ошибки, реальной помощи от них ждать не приходится, и мы остаемся со своими проблемам один на один…
Содержание:
- Введение
- Классификация утечек и причины их возникновения
- Утечка ресурсов как направленная атака
- Схватка с утечками врукопашную
- Перезагрузка приложений
- Принудительное освобождение памяти
- Заключение
Большинство статей, посвященных проблемам утечек ресурсов, ориентированы, главным образом, на программистов, имеющих в своем распоряжении исходные коды и обширный набор различных диагностических утилит: от штатных отладчиков, входящих в комплект поставки компилятора, до специализированных анализаторов, типа IBM Rational Purify, BoundsChecker или Valgrind.
Работа с исполняемыми модулями уже откомпилированных программ в лучшем случае поддерживается в очень ограниченном режиме (а зачастую не поддерживается вообще), но, как бы там ни было, даже обнаружив место утечки, исправить ее непосредственно в машинном коде может только продвинутый хакер (сколько времени он проведет за отладчиком, история умалчивает, и кто оплатит его работу, остается только гадать).
Мы же люди простые. Администраторы мелко-корпоративных, офисных или даже домашних серверов, работающих, как правило, на основе NT-based систем. Исходных текстов у нас нет, да и времени/средств на исправление чужих ошибок тоже. Тем не менее, бороться с утечками все же приходится. Кому не случалось перегружать зависший сервер, не реагирующий даже на CTRL-ALT-DEL, и давить на RESET с угрозой разрушения дискового тома и потери кучи оперативных данных?
На самом деле, чтобы справиться с утечками (или, по крайней мере, минимизировать возможные последствия) совершенно необязательно быть хакером и владеть исходными текстами. Более того, борьба (включая превентивные мероприятия) практически не отнимает времени и потому может быть взята на вооружение любым администратором, даже самым начинающим.
Классификация утечек и причины их возникновения
Прежде, чем бороться с утечками, необходимо разобраться: что это вообще такое, и почему они происходят. Куда утекает память? Риторический вопрос! Никуда она не утекает, просто термин неудачный такой. Правильнее говорить об «отложении» или «пластовании» ресурсов, по аналогии с осадочными слоями. Рассмотрим следующий (вполне классический) пример:
foo(char *x)
{
// выделяем буфер из динамической памяти (также называемой кучей)
char *p = malloc(MAX_SIZE);
// если строка не влезает в буфер, возвращаемся из функции
if (strlen(x) >= MAX_SIZE)
return ERR_STR_TOO_LONG;
// копируем строку в буфер
strcpy(p, x);
// делаем с ней что-нибудь полезное
// освобождаем выделенную память
free(p);
return OK;
}
Фрагмент исходного кода, демонстрирующего утечку памяти
Программист выделяет буфер под копируемую строку и, прежде чем начать копирование, заботливо проверяет ее длину. Если строка не помещается в буфер, происходит немедленный возврат из функции с сообщением об ошибке, но! Выделенная память не освобождается! И не освободится никогда! Лишь при завершении процесса система автоматически освободит все, что к тому времени он успел понавыделять. Учитывая, что серверные приложения не перезапускаются месяцами (и даже годами), становится ясно: утечки представляют собой едва ли не основную проблему, и даже потеря одного байта в долговременной перспективе выливается в сотни мегабайт «осадочной» памяти.
При этом от разработчиков серверных приложений автору постоянно приходится слышать, что мол, проблема утечек — фундаментальна, и что если сервер теряет не более 1 Кб памяти в секунду — это вполне нормально. Количество установленной физической памяти не играет никакой роли, и падение производительности за счет утечек практически полностью нивелируется тем фактом, что операционная система вытесняет неиспользованные страницы на диск в файл подкачки. Однако адресное пространство процесса не безгранично и на 32-битных платформах по умолчанию составляет чуть менее 2 Гб (остальные 2 Гб занимает ядро ОС, ядерные структуры данных, драйвера и т.д.).
Легко рассчитать, что если память утекает со скоростью 1 Кб в секунду, то адресное пространство будет полностью исчерпано за 25 дней, а на самом деле намного раньше, т.к. помимо динамической памяти в обозначенные 2 Гб входит стек, образы исполняемых файлов и библиотек, структуры данных операционной системы прикладного режима и т.д. Для рабочей станции работать в течение месяца без перезагрузок — слегка противоестественно, а вот для серверов — это вполне нормальное состояние, но чтобы они не грохнулись раньше времени, необходимо преодолеть утечки.
Утечки делятся на две категории: жесткие (hard) и мягкие (soft). Мягкие утечки (также называемые локальными) действуют только в течение некоторого периода времени, а затем возвращают «награбленные» ресурсы в общий пул. Вот, например, некоторый сервер обрабатывает запросы пользователей в отдельном потоке и под каждый запрос выделяет определенное количество памяти, но не освобождает ее после завершения обработки запроса, однако, при отключении клиента вся память освобождается одним махом — вот это и называется локальной утечкой.
Жесткие (или глобальные) утечки не освобождаются вплоть до того момента, когда администратор не отправит сервер в shutdown или не перезагрузит ОС. Последний момент очень важен! Если приложение выделяет блоки совместно используемой памяти (shared memory), то они не освобождаются вместе с завершением выделившего их процесса и продолжают болтаться в адресном пространстве вплоть до полной перезагрузки.
Кстати, помимо утечек памяти, существует проблема утечки и прочих системных ресурсов, например, файловых дескрипторов, количество которых хоть и велико, но все же конечно. Если сервер открывает файлы, забывая их закрыть, то в какой-то момент система просто рухнет, будучи не в силах открыть файл даже для своих сугубо системных нужд.
Утечка ресурсов как направленная атака
Приложение может работать годами, не вызывая никаких проблем и вдруг… с некоторого момента администратора начинают доставать непрекращающиеся утечки. Но ведь машинный код в отличие от фрегата не может «прохудиться» от старости!
Все дело в том, что существует целый подкласс DoS-атак, вызывающих отказ в обслуживании путем генерации запросов, приводящих к утечкам памяти. Вернемся к листингу 1. Допустим, что процедура foo() обрабатывает поля некоторого заголовка, причем длина строки MAX_SIZE выбрана программистом с большим запасом так, что нормальные запросы обрабатываются без каких-либо проблем. Но вот коварные хакеры находят ошибку в коде и начинают бомбардировать сервер строками невероятной длины. И хотя это не приводит к немедленному отказу, количество свободной памяти постепенно уменьшается и уменьшается, вплоть до полного исчерпания кучи.
К сожалению, разработчики и специалисты по безопасности склонны недооценивать данный подкласс атак, поскольку ни к захвату управления, ни к утрате конфиденциальности он не приводит, а потому заплатки под известные дыры зачастую вообще не выпускаются!
Можно ли справиться с такими атаками самостоятельно? Имея хороший брандмауэр с гибкой системой фильтрации, просто добавляем новое правило, отсекающее определенные запросы со строками чрезмерной длины (естественно, чтобы разобраться в ситуации, потребуется тщательно проанализировать системные логи и дампы перехватчика сетевых пакетов).
Схватка с утечками врукопашную
Залогом успешной борьбы с утечками становится заблаговременная подготовка. Прежде всего, постарайся до максимума увеличить объем виртуальной памяти, не забывая о том, что если стартовый объем файла подкачки меньше конечного, то при достижении пороговой величины система попытается увеличить размер файла подкачки (если дискового места хватит), причем все запросы на выделение памяти в это время будут отклоняться, и приложение вместо валидного указателя получит ноль, а вот как оно отреагирует на это – сказать сложно. Часть приложений завершат свою работу в аварийном режиме (с потерей не сохраненных данных), часть поведут себя неадекватно, выдавая порой странные результаты. Так что лучше не мелочиться, не жертвовать дисковым пространством, а выделять, так выделять! Вопрос только — сколько?
Допустим, у нас есть k серверных приложений, и они порождают n процессов (их легко посчитать в диспетчере задач). Поскольку на 32х битных платформах каждый процесс владеет четырьмя гигабайтами оперативной памяти, нам потребуется 4*(MAX(k, n) Гб памяти, плюс еще пару гигабайт под системные нужды. Однако, при изменении размера файла подкачки через графический интерфейс (Мой Компьютер/Свойства Системы/Дополнительно/Параметры быстродействия/Виртуальная память/Изменить) мы ограничены 4х разрядным полем в мегабайтах, т.е. не можем получить более 10 Гб виртуальной памяти, чего для большинства нужд более чем достаточно, однако, для серверов с многодневным аптаймом, на которых установлена куча серверных приложений, может потребоваться и больший объем, установить который поможет бесплатная утилита pagefileconfig.vbs ().
Однако, независимо от количества имеющейся виртуальной памяти, каждый процесс в свое расположение получает чуть меньше двух гигабайт кучи, которых при интенсивных утечках хватает совсем ненадолго. А потом — бац! И сервер в дауне. Иди, поднимай его потом…
Следующие операционные системы: Windows XP Professional, NT Server 4.0 Enterprise Edition, W2K Advanced Server, W2K Datacenter Server, Server 2003/Enterprise Edition/Datacenter Edition при загрузке поддерживают специальный ключ «/3G», с помощью которого можно «ужать» систему до 1 Гб и выделить высвободившееся место в личное пользование каждого процесса, т.е. размер кучи возрастает до 3 Гб (ну, или чуть меньше за счет стека, образа исполняемого файла и динамических библиотек). Подробнее об этом можно прочитать на , а ниже приводится пример готового boot.ini файла, приготовленного по данной технологии:
[Boot Loader] Timeout=30 Default=multi(0)disk(0)rdisk(0)partition(2)WINNT [Operating Systems] multi(0)disk(0)rdisk(0)partition(2)WINNT="Windows Server 2003" /3GB
Если планируется использовать сервер в полностью автономном режиме длительное время (например, ты уезжаешь в отпуск, оставляя домашний компьютер с ftp-архивом, предоставленным самому себе), то здесь потребуются намного более радикальные меры борьбы с утечками. А именно — периодический перезапуск серверных приложений командой kill.exe (входит в бесплатно распространяемый набор Microsoft Debugging Tools, Support Tools, а также в Microsoft Platform SDK), закинутой в системный планировщик (см. описание штатной команды «at»).
Кстати говоря, многие сервера имеют свои собственные встроенные планировщики, позволяющие делать мягкий shutdown, при котором блокируется подключение новых клиентов, и в момент, когда «отвалится» последний из имеющихся, сервер отправит себя на перезагрузку.
С серверами, реализованными как системные службы, в некотором смысле дела обстоят намного лучше, поскольку всякая служба обязана (по условиям спецификации) поддерживать мягкую перезагрузку без потерь оперативных данных, однако, далеко не всякая мягкая перезагрузка возвращает «осадочную» память, к тому же, источником утечек вполне может оказаться и головной процесс SERVICES.EXE, под которым «крышуются» все службы (см. листинг ниже). Попытка «убийства» SERVICES.EXE либо закончится сообщением о невозможности совершения такой операции, либо (если же все-таки увенчается успехом) система тут же обрушится. Вот так ситуация!
System Process (0)
System (8)
SMSS.EXE (232)
CSRSS.EXE (260)
WINLOGON.EXE (280) NetDDE Agent
SERVICES.EXE (308)
svchost.exe (480)
DLLHOST.EXE (1048)
Smc.exe (504) Sygate Personal Firewall
ups.exe (536)
svchost.exe (568) MCI command handling window
vmware-authd.ex (1240)
Процесс SERVICES.EXE выступает крышей для многих служб
Вопрос из зала: а с какой частотой следует перегружать серверные процессы или даже саму операционную систему целиком, если перезагрузка данного процесса невозможна? Ответ: чтобы не привязываться к конкретному расписанию, будем периодически вызывать API-функцию VirtualQueryEx, возвращающую размер виртуальной памяти, потребляемый каждым процессом, и, как только он достигнет определенного порогового значения, выбранного нами заранее, уходить в reboot (естественно, для этого необходимо уметь хоть немного программировать).
Функция VirtualQueryEx принимает на грудь дескриптор процесса и возвращает следующие данные:
typedef struct _MEMORY_BASIC_INFORMATION {
// базовый адрес региона
PVOID BaseAddress;
// базовый адрес выделенного блока памяти
PVOID AllocationBase;
// "первородные" атрибуты защиты
DWORD AllocationProtect;
// размер региона в байтах
DWORD RegionSize;
// тип региона (выделен, закреплен, свободен)
DWORD State;
// текущие атрибуты защиты
DWORD Protect;
// тип страниц памяти
DWORD Type;
} MEMORY_BASIC_INFORMATION;
Вызывая ее многократно с различными базовыми адресами, мы, в конечном счете, получим полную картину адресного пространства, позволяющую нам принять решение о перезагрузке, когда свободных блоков практически не останется (тут, кстати говоря, необходимо учесть, что даже если мы имеем 100 несмежных свободных блоков по 4 Кб, а программа просит каких-то жалких 10 Кб, запрос на выделение памяти не может быть выполнен в силу фрагментации кучи, а потому суммарный размер свободных блоков еще ни о чем не говорит!).
Детали реализации мы оставим в стороне. Это совсем несложная утилита, которую легко написать менее чем за вечер, однако, она необыкновенно эффективна в «разруливании» автопилотируемых серверов.
Принудительное освобождение памяти
А вот не хотим мы перезапускать ни серверное приложение, ни саму операционную систему. Ну, вот не хотим, и все! Что тогда? Вот тогда-то нам и пригодится весьма продвинутая методика, дающая неплохой результат, хотя и без всяких гарантий. Анализ большого количества программ, страдающих хроническими утечками памяти, показал, что указатели на блоки динамической памяти, как правило, помещаются в локальные стековые переменные, автоматически уничтожаемые компилятором при выходе из функции. Следовательно, если на данный блок динамической памяти не ссылаются ни другие блоки, ни локальные переменные, то его с высокой степенью можно считать «потерянным» и с некоторым риском освободить, возвращая память обратно в кучу.
Подобный «сборщик мусора» представляет собой довольно сложную программу, вынужденную учитывать многие нюансы, перечисление которых тянет на отдельный монументальный труд. У мыщъх’а пока что имеется pre-alpha версия, предназначенная сугубо для внутреннего использования.
Как она работает? Вместо того чтобы определять границы стека каждого из потоков, мыщъх просто сканирует адресное пространство процесса (естественно, исключая невыделенные блоки), «выцеживая» 32-битные значения, похожие на указатели. «Похожие», значит, находящиеся в пределах динамических блоков памяти, полный перечень которых можно получить посредством следующих API-функций: CreateToolhelp32SnapshotHeap32FirstHeap32ListFirstHeap32ListNextHeap32Next.
Занятые блоки динамической памяти, в границах которых нет ни одного указателя, считаются «осадочными» и освобождаются. А вот как они освобождаются — это уже вопрос. Можно, конечно, вызывать API-Функцию VirtualFreeEx, и дело с хвостом, но! Компиляторы работают с динамической памятью не напрямую, а посредством своих собственных библиотек времени исполнения (Runtime Library или, сокращенно, RTL). Любая работа с динамической памятью в обход RTL-менеджера неминуемо приводит к краху приложения. Поэтому мы должны выпрыснуть свой код в подопытный процесс и вызывать RTL-функцию освобождения памяти. Например, в языке С — это функция free().

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



